Character preview & mobile drawer improvements (#1843)

- Characters modal: new eye button on character cards to view the image
full size
- Mention chip menu: tapping the avatar thumbnail also opens the
full-size view
- Card buttons (view/edit/delete) are always visible on touch devices
instead of hover-only
- Mobile: Characters open in a bottom drawer with a 2-column grid,
matching the other settings drawers
- All mobile drawers now have a close (X) button
- All mobile drawers can be pulled down to dismiss - the sheet follows
your thumb and closes from where you let go
This commit is contained in:
BFlat authored and GitHub committed 2026-08-04 22:38:32 +07:00
1 parent e4fc9446d8
commit 9c18f746d4
5 files changed
+274 -82

No files matched your search

@@ -10,6 +10,7 @@ import {
faXmark,
faPen,
faTrashAlt,
faEye,
} from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import {
@@ -20,7 +21,9 @@ import {
import { toast } from "../toast/toast";
import { v4 as uuidv4 } from "uuid";
import { Button } from "@storyteller/ui-button";
import { useCharactersStore } from "@storyteller/ui-promptbox";
import { useCharactersStore, DeckPreviewModal } from "@storyteller/ui-promptbox";
import { useIsMobile } from "../ui/use-mobile";
import { SettingsDrawer } from "./mobile/SettingsDrawer";
interface CharactersModalProps {
isOpen: boolean;
@@ -50,6 +53,7 @@ export const CharactersModal = ({
onClose,
onSelectCharacter,
}: CharactersModalProps) => {
const isMobile = useIsMobile();
const [view, setView] = useState<ModalView>("list");
const [editingCharacter, setEditingCharacter] = useState<Character | null>(
null,
@@ -154,6 +158,58 @@ export const CharactersModal = ({
return () => timers.forEach(clearTimeout);
}, [pendingCharacters]);
const content =
view === "list" ? (
<CharacterListView
key={refreshKey}
onCreateClick={() => setView("create")}
onSelectCharacter={onSelectCharacter}
onEditCharacter={handleEdit}
pendingCharacters={pendingCharacters}
onPendingResolved={removePending}
/>
) : view === "create" ? (
<NewCharacterView
onBack={() => setView("list")}
onCreated={handleCreated}
/>
) : editingCharacter ? (
<EditCharacterView
character={editingCharacter}
onBack={() => {
setEditingCharacter(null);
setView("list");
}}
onSaved={handleEditDone}
/>
) : null;
if (isMobile) {
return (
<SettingsDrawer
open={isOpen}
onOpenChange={(open) => {
if (!open) handleClose();
}}
title={
view === "list"
? "Characters"
: view === "create"
? "New Character"
: "Edit Character"
}
// Create/edit render their own back-arrow heading inside the body.
hideHeader={view !== "list"}
// The full-size preview and library picker open modals on top of
// this drawer — a modal sheet's body lock would strand
// pointer-events on <body> (see SettingsDrawer).
modal={false}
>
<div className="pb-2">{content}</div>
</SettingsDrawer>
);
}
return (
<Modal
isOpen={isOpen}
@@ -170,30 +226,7 @@ export const CharactersModal = ({
: "min(calc(600px - 2.5rem), calc(80vh - 2.5rem))",
}}
>
{view === "list" ? (
<CharacterListView
key={refreshKey}
onCreateClick={() => setView("create")}
onSelectCharacter={onSelectCharacter}
onEditCharacter={handleEdit}
pendingCharacters={pendingCharacters}
onPendingResolved={removePending}
/>
) : view === "create" ? (
<NewCharacterView
onBack={() => setView("list")}
onCreated={handleCreated}
/>
) : editingCharacter ? (
<EditCharacterView
character={editingCharacter}
onBack={() => {
setEditingCharacter(null);
setView("list");
}}
onSaved={handleEditDone}
/>
) : null}
{content}
</div>
</Modal>
);
@@ -220,6 +253,9 @@ const CharacterListView = ({
const [loading, setLoading] = useState(true);
const [confirmDelete, setConfirmDelete] = useState<Character | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [previewCharacter, setPreviewCharacter] = useState<Character | null>(
null,
);
const storeSetCharacters = useCharactersStore((s) => s.setCharacters);
const storeSetLoaded = useCharactersStore((s) => s.setLoaded);
@@ -295,7 +331,7 @@ const CharacterListView = ({
return (
<div className="flex flex-col">
{loading && characters.length === 0 ? (
<div className="grid grid-cols-4 gap-3">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
@@ -327,7 +363,7 @@ const CharacterListView = ({
`}</style>
</div>
) : (
<div className="grid grid-cols-4 gap-3">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{/* Create New card */}
<button
onClick={onCreateClick}
@@ -411,30 +447,50 @@ const CharacterListView = ({
</div>
</button>
{/* Edit / Delete overlay buttons (user-created only) */}
{isUserCreated && (
<div className="absolute right-1.5 top-1.5 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation();
onEditCharacter(character);
}}
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 transition-colors hover:bg-black/80"
>
<FontAwesomeIcon icon={faPen} className="text-[10px]" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setConfirmDelete(character);
}}
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 transition-colors hover:bg-red-500"
>
<FontAwesomeIcon
icon={faTrashAlt}
className="text-[10px]"
/>
</button>
{/* View / Edit / Delete overlay buttons. Hover-revealed on
desktop; always visible on touch devices (no hover). */}
{(character.maybe_avatar?.cdn_url || isUserCreated) && (
<div className="absolute right-1.5 top-1.5 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
{character.maybe_avatar?.cdn_url && (
<button
onClick={(e) => {
e.stopPropagation();
setPreviewCharacter(character);
}}
title="View full size"
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 transition-colors hover:bg-black/80"
>
<FontAwesomeIcon icon={faEye} className="text-[10px]" />
</button>
)}
{isUserCreated && (
<>
<button
onClick={(e) => {
e.stopPropagation();
onEditCharacter(character);
}}
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 transition-colors hover:bg-black/80"
>
<FontAwesomeIcon
icon={faPen}
className="text-[10px]"
/>
</button>
<button
onClick={(e) => {
e.stopPropagation();
setConfirmDelete(character);
}}
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 transition-colors hover:bg-red-500"
>
<FontAwesomeIcon
icon={faTrashAlt}
className="text-[10px]"
/>
</button>
</>
)}
</div>
)}
</div>
@@ -477,6 +533,20 @@ const CharacterListView = ({
</div>
</div>
)}
<DeckPreviewModal
item={
previewCharacter?.maybe_avatar?.cdn_url
? {
id: previewCharacter.token,
kind: "image",
url: previewCharacter.maybe_avatar.cdn_url,
name: previewCharacter.name,
}
: null
}
onClose={() => setPreviewCharacter(null)}
/>
</div>
);
};
@@ -1,4 +1,6 @@
import { type ReactNode } from "react";
import { useRef, type ReactNode } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark } from "@fortawesome/pro-solid-svg-icons";
import {
Sheet,
SheetContent,
@@ -6,6 +8,9 @@ import {
SheetTitle,
} from "../../ui/sheet";
// Drag distance past which releasing the handle dismisses the drawer.
const DRAG_DISMISS_PX = 80;
interface SettingsDrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -17,6 +22,9 @@ interface SettingsDrawerProps {
// their overlapping mount/unmount strands one of those locks on <body>,
// freezing the whole page. A non-modal sheet skips the lock entirely.
modal?: boolean;
// Visually hide the header (the title stays for screen readers) when the
// drawer body renders its own heading, e.g. a back-arrow sub-view.
hideHeader?: boolean;
}
// Bottom sheet used for every mobile settings group (model, output, etc.).
@@ -26,20 +34,82 @@ export function SettingsDrawer({
title,
children,
modal = true,
hideHeader = false,
}: SettingsDrawerProps) {
const sheetRef = useRef<HTMLDivElement>(null);
const drag = useRef({ active: false, startY: 0, delta: 0 });
const endDrag = () => {
if (!drag.current.active) return;
const { delta } = drag.current;
drag.current.active = false;
const el = sheetRef.current;
if (el) {
// Restore the base transition so the snap back to rest animates.
el.style.transition = "";
el.style.transform = "";
if (delta > DRAG_DISMISS_PX) {
// Radix unmounts the sheet after the close animation, so the var
// never leaks into later opens or button/backdrop closes.
el.style.setProperty("--ac-drawer-drag-y", `${delta}px`);
}
}
if (delta > DRAG_DISMISS_PX) onOpenChange(false);
};
return (
<Sheet open={open} onOpenChange={onOpenChange} modal={modal}>
<SheetContent
ref={sheetRef}
side="bottom"
aria-describedby={undefined}
modal={modal}
open={open}
className="ac-drawer-content max-h-[80vh] rounded-t-2xl bg-ui-panel pb-[max(1rem,env(safe-area-inset-bottom))]"
>
<div className="mx-auto mt-2 h-1 w-10 shrink-0 rounded-full bg-white/20" />
<SheetHeader className="pb-2">
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<button
type="button"
aria-label="Close"
onClick={() => onOpenChange(false)}
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white"
>
<FontAwesomeIcon icon={faXmark} className="text-sm" />
</button>
{/* Drag zone: the handle pill + header pull the sheet down; past the
threshold, releasing dismisses. `touch-none` keeps the browser
from claiming the gesture for scrolling. The body below scrolls
natively and does not drag. */}
<div
className={hideHeader ? "shrink-0 touch-none pb-3" : "shrink-0 touch-none"}
onPointerDown={(e) => {
drag.current = { active: true, startY: e.clientY, delta: 0 };
e.currentTarget.setPointerCapture(e.pointerId);
// The sheet's base `transition` class would ease each transform
// update ~150ms behind the finger — disable it while dragging
// so the sheet sticks to the thumb.
const el = sheetRef.current;
if (el) el.style.transition = "none";
}}
onPointerMove={(e) => {
if (!drag.current.active) return;
const raw = e.clientY - drag.current.startY;
drag.current.delta = raw;
const el = sheetRef.current;
if (el) {
// Downward tracks 1:1; upward rubber-bands (the sheet can't
// grow, but a dead stop feels broken).
const y = raw >= 0 ? raw : Math.max(raw / 4, -32);
el.style.transform = `translateY(${y}px)`;
}
}}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<div className="mx-auto mt-2 h-1 w-10 shrink-0 rounded-full bg-white/20" />
<SheetHeader className={hideHeader ? "sr-only" : "select-none pb-2 pr-12"}>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4">{children}</div>
</SheetContent>
</Sheet>
+4 -1
View File
@@ -475,9 +475,12 @@
}
}
/* --ac-drawer-drag-y: set inline by SettingsDrawer when a pull-down gesture
dismisses the sheet, so the slide-down starts from where the finger let go
instead of snapping back to the top first. Fresh mounts default to 0. */
@keyframes ac-drawer-down {
from {
transform: translateY(0);
transform: translateY(var(--ac-drawer-drag-y, 0px));
}
to {
transform: translateY(100%);
@@ -11,6 +11,7 @@ import {
faXmark,
faPen,
faTrashAlt,
faEye,
} from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import {
@@ -23,6 +24,7 @@ import { toast } from "@storyteller/ui-toaster";
import { v4 as uuidv4 } from "uuid";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { useCharactersStore } from "./promptStore";
import { DeckPreviewModal } from "./deck/DeckCard";
import { Input } from "@storyteller/ui-input";
import { Button } from "@storyteller/ui-button";
import { Label } from "@storyteller/ui-label";
@@ -228,6 +230,9 @@ const CharacterListView = ({
}) => {
const [characters, setCharacters] = useState<Character[]>([]);
const [loading, setLoading] = useState(true);
const [previewCharacter, setPreviewCharacter] = useState<Character | null>(
null,
);
const storeSetCharacters = useCharactersStore((s) => s.setCharacters);
const storeSetLoaded = useCharactersStore((s) => s.setLoaded);
@@ -439,30 +444,50 @@ const CharacterListView = ({
</div>
</button>
{/* Edit / Delete overlay buttons (user-created only) */}
{isUserCreated && (
<div className="absolute right-1.5 top-1.5 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation();
onEditCharacter(character);
}}
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 transition-colors hover:bg-black/80"
>
<FontAwesomeIcon icon={faPen} className="text-[10px]" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete(character);
}}
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 transition-colors hover:bg-red-500"
>
<FontAwesomeIcon
icon={faTrashAlt}
className="text-[10px]"
/>
</button>
{/* View / Edit / Delete overlay buttons. Hover-revealed on
desktop; always visible on touch devices (no hover). */}
{(character.maybe_avatar?.cdn_url || isUserCreated) && (
<div className="absolute right-1.5 top-1.5 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
{character.maybe_avatar?.cdn_url && (
<button
onClick={(e) => {
e.stopPropagation();
setPreviewCharacter(character);
}}
title="View full size"
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 transition-colors hover:bg-black/80"
>
<FontAwesomeIcon icon={faEye} className="text-[10px]" />
</button>
)}
{isUserCreated && (
<>
<button
onClick={(e) => {
e.stopPropagation();
onEditCharacter(character);
}}
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 transition-colors hover:bg-black/80"
>
<FontAwesomeIcon
icon={faPen}
className="text-[10px]"
/>
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete(character);
}}
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 transition-colors hover:bg-red-500"
>
<FontAwesomeIcon
icon={faTrashAlt}
className="text-[10px]"
/>
</button>
</>
)}
</div>
)}
</div>
@@ -471,6 +496,19 @@ const CharacterListView = ({
</div>
)}
<DeckPreviewModal
item={
previewCharacter?.maybe_avatar?.cdn_url
? {
id: previewCharacter.token,
kind: "image",
url: previewCharacter.maybe_avatar.cdn_url,
name: previewCharacter.name,
}
: null
}
onClose={() => setPreviewCharacter(null)}
/>
</div>
);
};
@@ -168,7 +168,18 @@ export function MentionChipMenu({
{view === "menu" ? (
<>
<div className="flex items-center gap-2 px-2 py-1.5">
<ChipAvatar preview={currentPreview} name={currentName} />
<button
type="button"
onClick={onPreview}
title="View full size"
aria-label={`View ${currentName}`}
className="group/avatar relative shrink-0 cursor-pointer overflow-hidden rounded-md"
>
<ChipAvatar preview={currentPreview} name={currentName} />
<span className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover/avatar:opacity-100">
<FontAwesomeIcon icon={faEye} className="h-3 w-3 text-white" />
</span>
</button>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-base-fg">
{currentName}