Skip to content
This repository was archived by the owner on May 24, 2026. It is now read-only.
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/features/characters/components/CharacterAvatarImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { AvatarCrop } from "../../../shared/lib/utils";
import { cn, getAvatarCropStyle, parseAvatarCropJson } from "../../../shared/lib/utils";

function isAvatarCrop(value: unknown): value is AvatarCrop {
return (
!!value &&
typeof value === "object" &&
Number.isFinite((value as AvatarCrop).srcX) &&
Number.isFinite((value as AvatarCrop).srcY) &&
Number.isFinite((value as AvatarCrop).srcWidth) &&
Number.isFinite((value as AvatarCrop).srcHeight) &&
(value as AvatarCrop).srcWidth > 0 &&
(value as AvatarCrop).srcHeight > 0 &&
(value as AvatarCrop).srcX >= 0 &&
(value as AvatarCrop).srcY >= 0 &&
(value as AvatarCrop).srcX + (value as AvatarCrop).srcWidth <= 1.001 &&
(value as AvatarCrop).srcY + (value as AvatarCrop).srcHeight <= 1.001
);
}

function resolveAvatarCrop(crop: unknown): AvatarCrop | null {
if (!crop) return null;
if (typeof crop === "string") return parseAvatarCropJson(crop);
return isAvatarCrop(crop) ? crop : null;
}

export function CharacterAvatarImage({
src,
alt,
crop,
className,
}: {
src: string;
alt: string;
crop?: unknown;
className?: string;
}) {
return (
<img
src={src}
alt={alt}
loading="lazy"
draggable={false}
className={cn("h-full w-full object-cover", className)}
style={getAvatarCropStyle(resolveAvatarCrop(crop))}
/>
);
}
21 changes: 7 additions & 14 deletions src/features/characters/components/CharacterLibraryView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { ArrowLeft, ArrowUpDown, Download, MessageCircle, Pencil, Plus, Search,
import { useCharacters } from "../hooks/use-characters";
import { useStartChatFromCharacter } from "../hooks/use-start-chat-from-character";
import { getCharacterTitle } from "../../../shared/lib/character-display";
import { cn, getAvatarCropStyle, type AvatarCrop } from "../../../shared/lib/utils";
import { cn } from "../../../shared/lib/utils";
import { useUIStore } from "../../../shared/stores/ui.store";
import { CharacterAvatarImage } from "./CharacterAvatarImage";

type CharacterData = Record<string, unknown> & {
name?: string;
Expand Down Expand Up @@ -106,14 +107,10 @@ function CharacterLibraryDetailCard({
<div className="overflow-hidden rounded-[1.5rem] border border-[var(--border)]/50 bg-[var(--background)]/70 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.95)] sm:rounded-[2rem]">
<div className="relative aspect-[4/3] overflow-hidden bg-gradient-to-br from-pink-400/25 via-rose-500/15 to-sky-400/15">
{character.avatarPath ? (
<img
<CharacterAvatarImage
src={character.avatarPath}
alt={characterName || "Selected character"}
className="h-full w-full object-cover"
style={getAvatarCropStyle(
character.parsed.extensions?.avatarCrop as AvatarCrop
| undefined,
)}
crop={character.parsed.extensions?.avatarCrop}
/>
) : (
<div className="flex h-full w-full items-center justify-center text-white/85">
Expand Down Expand Up @@ -428,15 +425,11 @@ export function CharacterLibraryView() {
>
<div className="relative h-24 w-24 shrink-0 overflow-hidden bg-gradient-to-br from-pink-400/25 via-rose-500/15 to-sky-400/15 sm:h-auto sm:w-full sm:aspect-[4/3]">
{char.avatarPath ? (
<img
<CharacterAvatarImage
src={char.avatarPath}
alt={charName}
loading="lazy"
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
style={getAvatarCropStyle(
char.parsed.extensions?.avatarCrop as AvatarCrop
| undefined,
)}
crop={char.parsed.extensions?.avatarCrop}
className="transition-transform duration-300 group-hover:scale-[1.03]"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-white/85">
Expand Down
30 changes: 17 additions & 13 deletions src/features/characters/components/CharactersPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ import {
} from "lucide-react";
import { getCharacterTitle } from "../../../shared/lib/character-display";
import { useUIStore } from "../../../shared/stores/ui.store";
import { cn, getAvatarCropStyle, type AvatarCrop } from "../../../shared/lib/utils";
import { cn } from "../../../shared/lib/utils";
import { ExportFormatDialog, type ExportFormatChoice } from "../../../shared/components/ui/ExportFormatDialog";
import { CharacterAvatarImage } from "./CharacterAvatarImage";

type CharacterRow = {
id: string;
Expand Down Expand Up @@ -181,9 +182,17 @@ export function CharactersPanel() {
}, [characters]) as ParsedCharacterRow[];

const charMap = useMemo(() => {
const map = new Map<string, { name: string; comment?: string | null; avatarPath: string | null }>();
const map = new Map<
string,
{ name: string; comment?: string | null; avatarPath: string | null; avatarCrop?: unknown }
>();
for (const c of parsedCharacters) {
map.set(c.id, { name: c.parsed.name ?? "Unknown", comment: c.comment, avatarPath: c.avatarPath });
map.set(c.id, {
name: c.parsed.name ?? "Unknown",
comment: c.comment,
avatarPath: c.avatarPath,
avatarCrop: c.parsed.extensions?.avatarCrop,
});
}
return map;
}, [parsedCharacters]);
Expand Down Expand Up @@ -944,13 +953,12 @@ export function CharactersPanel() {
}}
className="group/member flex cursor-pointer items-center gap-2 rounded-lg p-1.5 transition-all hover:bg-[var(--sidebar-accent)]"
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg overflow-hidden bg-gradient-to-br from-pink-400 to-rose-500 text-white">
<div className="relative flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-gradient-to-br from-pink-400 to-rose-500 text-white">
{member.avatarPath ? (
<img
<CharacterAvatarImage
src={member.avatarPath}
alt={member.name}
loading="lazy"
className="h-full w-full object-cover"
crop={member.avatarCrop}
/>
) : (
<User size="0.75rem" />
Expand Down Expand Up @@ -1120,14 +1128,10 @@ export function CharactersPanel() {
<div className="relative flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-pink-400 to-rose-500 text-white shadow-sm">
{avatarUrl ? (
<div className="absolute inset-0 overflow-hidden rounded-xl">
<img
<CharacterAvatarImage
src={avatarUrl}
alt={charName}
className="h-full w-full object-cover"
style={getAvatarCropStyle(
char.parsed.extensions?.avatarCrop as AvatarCrop
| undefined,
)}
crop={char.parsed.extensions?.avatarCrop}
/>
</div>
) : (
Expand Down