Add last viewed badge for previously viewed media (#1910)

for both desktop and webapp
This commit is contained in:
BFlat
2026-08-18 11:06:28 +07:00
committed by GitHub
parent ce8b7e4637
commit b55aa0d2cb
13 changed files with 109 additions and 21 deletions
@@ -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 (
@@ -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 }),
}),
);