mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-24 18:22:29 +00:00
Add last viewed badge for previously viewed media (#1910)
for both desktop and webapp
This commit is contained in:
+9
-5
@@ -4,9 +4,9 @@ import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import {
|
||||
GenerationGridView,
|
||||
type GalleryItem,
|
||||
is3DMediaClass,
|
||||
type RecreateSlotContext,
|
||||
useGallerySelectionStore,
|
||||
useLastViewedGenerationStore,
|
||||
} from "@storyteller/ui-generation-list";
|
||||
import { useRecreateFromPromptToken } from "../../lib/recreate";
|
||||
import { useGalleryItemActions } from "./useGalleryItemActions";
|
||||
@@ -34,6 +34,7 @@ export function GenerationGalleryGrid({
|
||||
}: GenerationGalleryProps) {
|
||||
const selectionActive = useGallerySelectionStore((s) => s.active);
|
||||
const selectedIds = useGallerySelectionStore((s) => s.ids);
|
||||
const lastViewedId = useLastViewedGenerationStore((s) => s.id);
|
||||
return (
|
||||
<GenerationGridView
|
||||
inProgressJobs={inProgressJobs}
|
||||
@@ -54,6 +55,7 @@ export function GenerationGalleryGrid({
|
||||
selectionMode={!!selectable && selectionActive}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
lastViewedId={lastViewedId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -70,14 +72,16 @@ function CardRecreateButton({
|
||||
mediaClass,
|
||||
kind,
|
||||
}: RecreateSlotContext) {
|
||||
// Recreate only targets the image/video create pages; 3D generations have no
|
||||
// recreate flow, so map to a valid class for the hook and render nothing.
|
||||
// Recreate only targets the image/video create pages; 3D and audio
|
||||
// generations have no recreate flow, so map to a valid class for the hook
|
||||
// (it must run unconditionally) and render nothing.
|
||||
const hasRecreateFlow = mediaClass === "image" || mediaClass === "video";
|
||||
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
|
||||
promptToken,
|
||||
is3DMediaClass(mediaClass) ? "image" : mediaClass,
|
||||
hasRecreateFlow ? mediaClass : "image",
|
||||
);
|
||||
|
||||
if (is3DMediaClass(mediaClass)) return null;
|
||||
if (!hasRecreateFlow) return null;
|
||||
|
||||
if (kind === "failed") {
|
||||
return (
|
||||
|
||||
+9
-5
@@ -4,9 +4,9 @@ import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import {
|
||||
GenerationListView,
|
||||
type GalleryItem,
|
||||
is3DMediaClass,
|
||||
type RecreateSlotContext,
|
||||
useGallerySelectionStore,
|
||||
useLastViewedGenerationStore,
|
||||
} from "@storyteller/ui-generation-list";
|
||||
import { toast } from "../toast/toast";
|
||||
import { useRecreateFromPromptToken } from "../../lib/recreate";
|
||||
@@ -38,6 +38,7 @@ export function GenerationGalleryList({
|
||||
}: GenerationGalleryProps) {
|
||||
const selectionActive = useGallerySelectionStore((s) => s.active);
|
||||
const selectedIds = useGallerySelectionStore((s) => s.ids);
|
||||
const lastViewedId = useLastViewedGenerationStore((s) => s.id);
|
||||
return (
|
||||
<GenerationListView
|
||||
inProgressJobs={inProgressJobs}
|
||||
@@ -63,6 +64,7 @@ export function GenerationGalleryList({
|
||||
selectionMode={!!selectable && selectionActive}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
lastViewedId={lastViewedId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -86,14 +88,16 @@ function RowRecreateButton({
|
||||
mediaClass,
|
||||
kind,
|
||||
}: RecreateSlotContext) {
|
||||
// Recreate only targets the image/video create pages; 3D generations have no
|
||||
// recreate flow, so map to a valid class for the hook and render nothing.
|
||||
// Recreate only targets the image/video create pages; 3D and audio
|
||||
// generations have no recreate flow, so map to a valid class for the hook
|
||||
// (it must run unconditionally) and render nothing.
|
||||
const hasRecreateFlow = mediaClass === "image" || mediaClass === "video";
|
||||
const { isRecreating, handleRecreate } = useRecreateFromPromptToken(
|
||||
promptToken,
|
||||
is3DMediaClass(mediaClass) ? "image" : mediaClass,
|
||||
hasRecreateFlow ? mediaClass : "image",
|
||||
);
|
||||
|
||||
if (is3DMediaClass(mediaClass)) return null;
|
||||
if (!hasRecreateFlow) return null;
|
||||
return (
|
||||
<Tooltip content="Recreate" position="top">
|
||||
<button
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useLastViewedGenerationStore } from "@storyteller/ui-generation-list";
|
||||
import type { GalleryItem } from "./useGalleryData";
|
||||
|
||||
export function useLightboxNav(flatItems: GalleryItem[]) {
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [lightboxItem, setLightboxItem] = useState<GalleryItem | null>(null);
|
||||
|
||||
const handleGalleryItemClick = useCallback((item: GalleryItem) => {
|
||||
// Every lightbox show (open and prev/next) moves the "Last viewed" marker
|
||||
// so the feed can badge that tile after the lightbox closes.
|
||||
const showItem = useCallback((item: GalleryItem) => {
|
||||
useLastViewedGenerationStore.getState().setId(item.id);
|
||||
setLightboxItem(item);
|
||||
setLightboxOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleGalleryItemClick = useCallback(
|
||||
(item: GalleryItem) => {
|
||||
showItem(item);
|
||||
setLightboxOpen(true);
|
||||
},
|
||||
[showItem],
|
||||
);
|
||||
|
||||
const closeLightbox = useCallback(() => {
|
||||
setLightboxOpen(false);
|
||||
setLightboxItem(null);
|
||||
@@ -20,13 +31,11 @@ export function useLightboxNav(flatItems: GalleryItem[]) {
|
||||
: -1;
|
||||
|
||||
const navigatePrev =
|
||||
currentIndex > 0
|
||||
? () => setLightboxItem(flatItems[currentIndex - 1])
|
||||
: undefined;
|
||||
currentIndex > 0 ? () => showItem(flatItems[currentIndex - 1]) : undefined;
|
||||
|
||||
const navigateNext =
|
||||
currentIndex >= 0 && currentIndex < flatItems.length - 1
|
||||
? () => setLightboxItem(flatItems[currentIndex + 1])
|
||||
? () => showItem(flatItems[currentIndex + 1])
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
|
||||
@@ -38,6 +38,9 @@ export interface DesktopGenerationGalleryProps {
|
||||
isInitialLoading: boolean;
|
||||
onLoadMore: () => void;
|
||||
onGalleryItemClick: (item: GalleryItem) => void;
|
||||
/** Id of the item most recently viewed in the lightbox; that tile gets a
|
||||
* persistent "Last viewed" badge. */
|
||||
lastViewedId?: string | null;
|
||||
/** Image page only: show a "Make Video" quick action on image items. */
|
||||
enableMakeVideo?: boolean;
|
||||
/** Enables the multi-select + batch download flow (select toggle in the
|
||||
|
||||
@@ -18,6 +18,7 @@ import { HelpMenuButton } from "@storyteller/ui-help-menu";
|
||||
import {
|
||||
useGalleryData,
|
||||
useGenerationJobs,
|
||||
useLastViewedGenerationStore,
|
||||
type GalleryItem,
|
||||
} from "@storyteller/ui-generation-list";
|
||||
import { useDesktopUsername } from "~/components/generation-feed/useDesktopUsername";
|
||||
@@ -115,7 +116,10 @@ const CreateAudio = () => {
|
||||
|
||||
// Open a completed row in the global lightbox (rendered by TopBar's
|
||||
// gallery modal); prev/next walk the merged feed order.
|
||||
const lastViewedId = useLastViewedGenerationStore((s) => s.id);
|
||||
|
||||
const openInLightbox = useCallback((item: GalleryItem) => {
|
||||
useLastViewedGenerationStore.getState().setId(item.id);
|
||||
const list = flatCompletedRef.current;
|
||||
const index = list.findIndex((i) => i.id === item.id);
|
||||
galleryModalLightboxNavPrev.value =
|
||||
@@ -156,6 +160,7 @@ const CreateAudio = () => {
|
||||
isInitialLoading={gallery.isInitialLoading}
|
||||
onLoadMore={gallery.loadMore}
|
||||
onGalleryItemClick={openInLightbox}
|
||||
lastViewedId={lastViewedId}
|
||||
/>
|
||||
}
|
||||
promptBox={
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
import {
|
||||
useGalleryData,
|
||||
useLastViewedGenerationStore,
|
||||
type GalleryItem,
|
||||
} from "@storyteller/ui-generation-list";
|
||||
import { useDesktopGenerationFeed } from "~/components/generation-feed/useDesktopGenerationFeed";
|
||||
@@ -128,7 +129,10 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
|
||||
|
||||
// Open a completed row in the global lightbox (rendered by TopBar's
|
||||
// gallery modal); prev/next walk the merged feed order.
|
||||
const lastViewedId = useLastViewedGenerationStore((s) => s.id);
|
||||
|
||||
const openInLightbox = useCallback((item: GalleryItem) => {
|
||||
useLastViewedGenerationStore.getState().setId(item.id);
|
||||
const list = flatCompletedRef.current;
|
||||
const index = list.findIndex((i) => i.id === item.id);
|
||||
galleryModalLightboxNavPrev.value =
|
||||
@@ -169,6 +173,7 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
|
||||
isInitialLoading={gallery.isInitialLoading}
|
||||
onLoadMore={gallery.loadMore}
|
||||
onGalleryItemClick={openInLightbox}
|
||||
lastViewedId={lastViewedId}
|
||||
enableMakeVideo
|
||||
selectable
|
||||
selectionBarBottomOffset={promptHeight + 32}
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
import {
|
||||
useGalleryData,
|
||||
useLastViewedGenerationStore,
|
||||
type GalleryItem,
|
||||
} from "@storyteller/ui-generation-list";
|
||||
import { useDesktopGenerationFeed } from "~/components/generation-feed/useDesktopGenerationFeed";
|
||||
@@ -156,7 +157,10 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
|
||||
|
||||
// Open a completed row in the global lightbox (rendered by TopBar's
|
||||
// gallery modal); prev/next walk the merged feed order.
|
||||
const lastViewedId = useLastViewedGenerationStore((s) => s.id);
|
||||
|
||||
const openInLightbox = useCallback((item: GalleryItem) => {
|
||||
useLastViewedGenerationStore.getState().setId(item.id);
|
||||
const list = flatCompletedRef.current;
|
||||
const index = list.findIndex((i) => i.id === item.id);
|
||||
galleryModalLightboxNavPrev.value =
|
||||
@@ -197,6 +201,7 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
|
||||
isInitialLoading={gallery.isInitialLoading}
|
||||
onLoadMore={gallery.loadMore}
|
||||
onGalleryItemClick={openInLightbox}
|
||||
lastViewedId={lastViewedId}
|
||||
selectable
|
||||
selectionBarBottomOffset={promptHeight + 32}
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ export * from "./lib/prompts-cache";
|
||||
export * from "./lib/media-prompt-token-cache";
|
||||
export * from "./lib/gallery-view-store";
|
||||
export * from "./lib/gallery-selection-store";
|
||||
export * from "./lib/last-viewed-store";
|
||||
export * from "./lib/GalleryViewToggle";
|
||||
export * from "./lib/GalleryAutoplayToggle";
|
||||
export * from "./lib/GallerySelectToggle";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useState, type ReactNode } from "react";
|
||||
import { BoxIcon, CheckIcon, ImageIcon, MusicIcon, VideoIcon } from "lucide-react";
|
||||
import { BoxIcon, CheckIcon, EyeIcon, ImageIcon, MusicIcon, VideoIcon } from "lucide-react";
|
||||
import { DynamicIcon } from "@storyteller/icons";
|
||||
import {
|
||||
getCreatorIconPathForModelId,
|
||||
@@ -73,6 +73,8 @@ export interface GalleryCardProps {
|
||||
selectMode?: boolean;
|
||||
selected?: boolean;
|
||||
onToggleSelect?: (item: GalleryItem) => void;
|
||||
/** Persistent badge marking the item most recently viewed in the lightbox. */
|
||||
lastViewed?: boolean;
|
||||
}
|
||||
|
||||
export const GalleryCard = memo(function GalleryCard({
|
||||
@@ -84,6 +86,7 @@ export const GalleryCard = memo(function GalleryCard({
|
||||
selectMode = false,
|
||||
selected = false,
|
||||
onToggleSelect,
|
||||
lastViewed = false,
|
||||
}: GalleryCardProps) {
|
||||
const isSquare = shape === "square";
|
||||
const cached = aspectRatioCache.get(item.id);
|
||||
@@ -162,7 +165,7 @@ export const GalleryCard = memo(function GalleryCard({
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`group relative block w-full rounded-lg bg-ui-controls/40 leading-none transition-shadow hover:ring-2 hover:ring-primary-400/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 cursor-pointer ${isSquare ? "aspect-square" : ""} ${selected ? "ring-2 ring-primary-400" : ""}`}
|
||||
className={`group relative block w-full rounded-lg bg-ui-controls/40 leading-none transition-shadow hover:ring-2 hover:ring-primary-400/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 cursor-pointer ${isSquare ? "aspect-square" : ""} ${selected ? "ring-2 ring-primary-400" : lastViewed ? "ring-2 ring-primary-400/50" : ""}`}
|
||||
style={outerStyle}
|
||||
onClick={handleCardClick}
|
||||
onKeyDown={handleCardKeyDown}
|
||||
@@ -223,6 +226,16 @@ export const GalleryCard = memo(function GalleryCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Persistent "Last viewed" badge (stays until another item is opened
|
||||
in the lightbox). Top-right so it never collides with the selection
|
||||
checkbox chip at top-left. */}
|
||||
{lastViewed && (
|
||||
<div className="pointer-events-none absolute right-2 top-2 z-10 flex items-center gap-1 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-white/80">
|
||||
<EyeIcon />
|
||||
Last viewed
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover overlay with media type + model badges and quick actions */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 bg-gradient-to-t rounded-b-lg from-black/70 to-transparent px-2 pb-2 pt-6 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="pointer-events-auto flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, type ReactNode } from "react";
|
||||
import { BoxIcon, CheckIcon, ImageIcon, MusicIcon, PlayIcon, VideoIcon } from "lucide-react";
|
||||
import { BoxIcon, CheckIcon, EyeIcon, ImageIcon, MusicIcon, PlayIcon, VideoIcon } from "lucide-react";
|
||||
import {
|
||||
getCreatorIconPathForModelId,
|
||||
getModelDisplayName,
|
||||
@@ -27,6 +27,8 @@ export interface GalleryRowProps {
|
||||
selectMode?: boolean;
|
||||
selected?: boolean;
|
||||
onToggleSelect?: (item: GalleryItem) => void;
|
||||
/** Persistent badge marking the item most recently viewed in the lightbox. */
|
||||
lastViewed?: boolean;
|
||||
}
|
||||
|
||||
export const GalleryRow = memo(function GalleryRow({
|
||||
@@ -40,6 +42,7 @@ export const GalleryRow = memo(function GalleryRow({
|
||||
selectMode = false,
|
||||
selected = false,
|
||||
onToggleSelect,
|
||||
lastViewed = false,
|
||||
}: GalleryRowProps) {
|
||||
const isVideo = item.mediaClass === "video";
|
||||
const is3D = is3DMediaClass(item.mediaClass);
|
||||
@@ -103,7 +106,7 @@ export const GalleryRow = memo(function GalleryRow({
|
||||
)}
|
||||
|
||||
{/* Thumbnail */}
|
||||
<div className="relative size-[100px] shrink-0 overflow-hidden rounded-md bg-ui-controls/40 leading-none">
|
||||
<div className={`relative size-[100px] shrink-0 overflow-hidden rounded-md bg-ui-controls/40 leading-none ${lastViewed ? "ring-2 ring-primary-400/50" : ""}`}>
|
||||
<GalleryThumbnail
|
||||
thumbnail={item.thumbnail}
|
||||
stillThumbnail={item.stillThumbnail}
|
||||
@@ -122,6 +125,14 @@ export const GalleryRow = memo(function GalleryRow({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Persistent "Last viewed" badge (stays until another item is
|
||||
opened in the lightbox). */}
|
||||
{lastViewed && (
|
||||
<div className="pointer-events-none absolute left-1 top-1 z-10 flex items-center gap-1 rounded-full bg-black/70 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wide text-white/80">
|
||||
<EyeIcon />
|
||||
Last viewed
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title + model / media meta */}
|
||||
|
||||
@@ -53,6 +53,8 @@ export interface GenerationGridViewProps {
|
||||
selectionMode?: boolean;
|
||||
selectedIds?: ReadonlySet<string>;
|
||||
onToggleSelect?: (item: GalleryItem) => void;
|
||||
/** Marks the tile the user most recently viewed in the lightbox. */
|
||||
lastViewedId?: string | null;
|
||||
}
|
||||
|
||||
export function GenerationGridView({
|
||||
@@ -71,6 +73,7 @@ export function GenerationGridView({
|
||||
selectionMode = false,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
lastViewedId,
|
||||
}: GenerationGridViewProps) {
|
||||
const sentinelRef = useInfiniteScrollSentinel(hasMore, onLoadMore);
|
||||
|
||||
@@ -198,6 +201,7 @@ export function GenerationGridView({
|
||||
selectMode={selectionMode}
|
||||
selected={selectionMode && selectedIds?.has(entry.item.id)}
|
||||
onToggleSelect={onToggleSelect}
|
||||
lastViewed={!!lastViewedId && entry.item.id === lastViewedId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface GenerationListViewProps {
|
||||
selectionMode?: boolean;
|
||||
selectedIds?: ReadonlySet<string>;
|
||||
onToggleSelect?: (item: GalleryItem) => void;
|
||||
/** Marks the row the user most recently viewed in the lightbox. */
|
||||
lastViewedId?: string | null;
|
||||
}
|
||||
|
||||
export function GenerationListView({
|
||||
@@ -75,6 +77,7 @@ export function GenerationListView({
|
||||
selectionMode = false,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
lastViewedId,
|
||||
}: GenerationListViewProps) {
|
||||
const sentinelRef = useInfiniteScrollSentinel(hasMore, onLoadMore);
|
||||
|
||||
@@ -211,6 +214,7 @@ export function GenerationListView({
|
||||
selectMode={selectionMode}
|
||||
selected={selectionMode && selectedIds?.has(entry.item.id)}
|
||||
onToggleSelect={onToggleSelect}
|
||||
lastViewed={!!lastViewedId && entry.item.id === lastViewedId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
// Remembers the generation most recently viewed in a lightbox opened from a
|
||||
// create-page feed, so the feed can badge that tile after the lightbox
|
||||
// closes. Set on every lightbox show (prev/next included). Module-level so it
|
||||
// survives page remounts (tab switch, route change); in-memory only.
|
||||
// Lightboxes opened elsewhere (library, task queue) intentionally do not move
|
||||
// the marker.
|
||||
|
||||
interface LastViewedGenerationState {
|
||||
id: string | null;
|
||||
setId: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useLastViewedGenerationStore = create<LastViewedGenerationState>(
|
||||
(set) => ({
|
||||
id: null,
|
||||
setId: (id) => set({ id }),
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user