Files
silo-server/web/src/components/ImageSelectorTab.tsx
T
1664c60425 fix(metadata): publish artwork revisions atomically (#399)
* fix(metadata): publish artwork revisions atomically

* fix(metadata): harden artwork revision cleanup

* fix(metadata): address artwork revision review findings

- restore image applies for all media_items types and reject unsupported
  target/image combinations with 400 before uploading; episodes coerce to
  stills and the web dialog no longer offers image tabs episodes can't use
- add WHEN clauses to displacement triggers and hoist to_jsonb so bulk
  catalog upserts that assign unchanged artwork columns skip the trigger
- make artworkkey the single variant-ladder owner: imagecache derives its
  widths from it and triggers store image_type instead of hardcoded
  variant arrays, expanded by the collector at deletion time
- sweep dormant registry rows periodically so references lost through
  untriggered surfaces degrade to slow cleanup instead of leaking
- park just-published revisions dormant, keep dormant rows dormant on
  re-cache, and batch the GC reference pre-check per run
- heal rows re-referencing a just-deleted revision via reconciler-style
  resets after the deletion commits
- share a per-URL image-loaded hook across DetailHero, ItemCard,
  SectionItemCard, GlobalSearch, and CollectionPosterCard
- deduplicate Cache/CacheBytes finalization and drop unused VariantPaths
  plumbing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(catalog): cast reused timestamp parameter in revision upsert

Postgres cannot deduce one type for $3 used both as a plain value and
inside a CASE arm; the dev deploy surfaced it as SQLSTATE 42P08 on every
publication. Cast both uses and cover the arm/park/track upserts with
database-backed tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(metadata): address artwork revision review comments

- keep a durable heal path: deletion marks deleted_at instead of removing
  the registry row, so a failed post-delete heal retries with backoff and
  broken references never park; trackers clear the marker on re-upload
- never treat bare existence as an immutable-content match; backends
  without content verification rewrite the object
- exercise revisioned cover keys in scanner/enrichment fakes, compare the
  tracked manifest exactly, and honor cancellation in the blocking test
  deleter

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:33:21 -04:00

257 lines
9.2 KiB
TypeScript

import { useState, useMemo, useCallback } from "react";
import { Check, Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { ItemDetail, RemoteImage } from "@/api/types";
import { useItemImages, useApplyItemImage } from "@/hooks/queries/items";
import { cn } from "@/lib/utils";
type ImageTab = "poster" | "backdrop" | "logo";
const IMAGE_TABS: { key: ImageTab; label: string; aspect: string }[] = [
{ key: "poster", label: "Posters", aspect: "aspect-[2/3]" },
{ key: "backdrop", label: "Backdrops", aspect: "aspect-video" },
{ key: "logo", label: "Logos", aspect: "aspect-video" },
];
// Seasons only store a poster; the server rejects other image types for them.
const SEASON_TABS = IMAGE_TABS.filter((tab) => tab.key === "poster");
interface ImageSelectorTabProps {
item: ItemDetail;
enabled: boolean;
}
export default function ImageSelectorTab({ item, enabled }: ImageSelectorTabProps) {
const availableTabs = item.type === "season" ? SEASON_TABS : IMAGE_TABS;
const [activeTab, setActiveTab] = useState<ImageTab>("poster");
const [textlessOnly, setTextlessOnly] = useState(false);
const [selectedImage, setSelectedImage] = useState<RemoteImage | null>(null);
// Track which images were applied in this session (original_url per type).
const [appliedImages, setAppliedImages] = useState<Record<string, string>>({});
const { data, isLoading, isError } = useItemImages(item.content_id, enabled);
const applyMutation = useApplyItemImage();
const images = data?.images ?? [];
const current = data?.current;
const providerErrors = data?.provider_errors;
const tabConfig = IMAGE_TABS.find((t) => t.key === activeTab)!;
const filteredImages = useMemo(() => {
let result = images.filter((img) => img.type === activeTab);
if (textlessOnly && activeTab !== "logo") {
result = result.filter((img) => img.language === "");
}
return result;
}, [images, activeTab, textlessOnly]);
// The "current" image for this tab. We first check session-local applied
// images (which use original_url), then fall back to the server's stored
// path (which may be an S3 key or a plugin path depending on cache state).
const currentOriginalUrl = appliedImages[activeTab] ?? "";
const currentStoredPath = useMemo(() => {
if (!current) return "";
switch (activeTab) {
case "poster":
return current.poster_url ?? "";
case "backdrop":
return current.backdrop_url ?? "";
case "logo":
return current.logo_url ?? "";
}
}, [current, activeTab]);
const handleApply = useCallback(() => {
if (!selectedImage) return;
applyMutation.mutate(
{
item,
request: {
original_url: selectedImage.original_url,
type: selectedImage.type,
provider_id: selectedImage.provider_id,
},
},
{
onSuccess: () => {
setAppliedImages((prev) => ({
...prev,
[selectedImage.type]: selectedImage.original_url,
}));
setSelectedImage(null);
},
},
);
}, [selectedImage, applyMutation, item]);
return (
<div className="flex h-full flex-col gap-3">
{/* Info banner */}
<div className="bg-muted/50 text-muted-foreground shrink-0 rounded-lg px-3 py-2 text-[11px]">
Image changes apply immediately and are not affected by Cancel.
</div>
{/* Image type tabs */}
<div className="flex shrink-0 items-center gap-1">
{availableTabs.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => {
setActiveTab(tab.key);
setSelectedImage(null);
setTextlessOnly(false);
}}
className={cn(
"rounded-md px-3 py-1.5 text-[12px] font-medium transition-colors",
activeTab === tab.key
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{tab.label}
</button>
))}
{/* Textless filter — hidden for logos */}
{activeTab !== "logo" && (
<button
type="button"
onClick={() => setTextlessOnly(!textlessOnly)}
className={cn(
"ml-auto rounded-md px-2.5 py-1.5 text-[11px] font-medium transition-colors",
textlessOnly
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground",
)}
>
Textless
</button>
)}
</div>
{/* Provider errors */}
{providerErrors && Object.keys(providerErrors).length > 0 && (
<div className="flex shrink-0 items-center gap-2 rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-1.5 text-[11px] text-amber-600 dark:text-amber-400">
<AlertCircle className="size-3.5 shrink-0" />
<span>
Could not load from{" "}
{Object.keys(providerErrors)
.map((k) => k.toUpperCase())
.join(", ")}
</span>
</div>
)}
{/* Image grid */}
{isLoading ? (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="text-muted-foreground size-6 animate-spin" />
</div>
) : isError ? (
<div className="flex flex-1 items-center justify-center">
<p className="text-muted-foreground text-sm">Failed to load images.</p>
</div>
) : filteredImages.length === 0 ? (
<div className="flex flex-1 items-center justify-center">
<p className="text-muted-foreground text-sm">
{textlessOnly ? "No textless images available." : "No images available."}
</p>
</div>
) : (
<ScrollArea className="min-h-0 flex-1">
<div
className={cn(
"grid gap-2 pr-3 pb-1",
activeTab === "poster" ? "grid-cols-3 sm:grid-cols-4" : "grid-cols-2 sm:grid-cols-3",
)}
>
{filteredImages.map((img, index) => {
const isSelected = selectedImage === img;
const isCurrent =
img.original_url === currentOriginalUrl || img.original_url === currentStoredPath;
return (
<button
key={`${img.provider_id}-${img.original_url}-${index}`}
type="button"
onClick={() => setSelectedImage(isSelected ? null : img)}
className={cn(
"group relative overflow-hidden rounded-lg border transition-all",
isSelected
? "border-primary ring-primary/30 ring-2"
: isCurrent
? "border-primary/50"
: "border-border hover:border-foreground/20",
)}
>
<div className={cn(tabConfig.aspect, "bg-muted")}>
<img
src={img.url}
alt=""
loading="lazy"
className="absolute inset-0 h-full w-full object-cover"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
</div>
{/* Current indicator */}
{isCurrent && (
<div className="bg-primary/90 text-primary-foreground absolute top-1 left-1 flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[9px] font-semibold">
<Check className="size-2.5" />
Current
</div>
)}
{/* Selected indicator */}
{isSelected && !isCurrent && (
<div className="bg-primary text-primary-foreground absolute top-1 right-1 flex size-5 items-center justify-center rounded-full">
<Check className="size-3" />
</div>
)}
{/* Provider badge */}
<div className="absolute right-1 bottom-1">
<Badge
variant="secondary"
className="bg-black/60 px-1 py-0 text-[9px] text-white backdrop-blur-sm"
>
{img.provider_id.toUpperCase()}
</Badge>
</div>
</button>
);
})}
</div>
</ScrollArea>
)}
{/* Apply button */}
{selectedImage && (
<div className="border-border/50 shrink-0 border-t pt-3">
<Button
onClick={handleApply}
disabled={applyMutation.isPending}
className="w-full"
size="sm"
>
{applyMutation.isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Applying...
</>
) : (
`Apply ${activeTab.charAt(0).toUpperCase() + activeTab.slice(1)}`
)}
</Button>
</div>
)}
</div>
);
}