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:
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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user