Port collection templates with poster support and async syncs
- Add poster URLs through collection import and template apply flows - Queue large template bundle syncs and preserve existing collection posters - Update template bundle assets, docs, and limit handling
This commit is contained in:
@@ -21,7 +21,10 @@ const baseTemplate: CollectionTemplate = {
|
||||
describe("CollectionTemplateCard", () => {
|
||||
it("shows the template title, description, source badge, and media kind", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<CollectionTemplateCard template={baseTemplate} onPick={() => {}} />,
|
||||
<CollectionTemplateCard
|
||||
template={{ ...baseTemplate, poster_path: "/images/collection-templates/trending.jpg" }}
|
||||
onPick={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("Trending Movies This Week");
|
||||
@@ -29,6 +32,7 @@ describe("CollectionTemplateCard", () => {
|
||||
expect(markup).toContain("TMDB");
|
||||
expect(markup).toContain("Movies");
|
||||
expect(markup).toContain("syncs daily");
|
||||
expect(markup).not.toContain("<img");
|
||||
});
|
||||
|
||||
it("badges templates that require a profile", () => {
|
||||
|
||||
@@ -19,34 +19,20 @@ export function CollectionTemplateCard({ template, onPick }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPick(template)}
|
||||
className="border-border hover:border-primary hover:bg-accent group bg-card flex h-full flex-col overflow-hidden rounded-md border text-left transition-colors"
|
||||
className="border-border hover:border-primary hover:bg-accent bg-card flex h-full flex-col rounded-md border p-4 text-left transition-colors"
|
||||
>
|
||||
{template.poster_path ? (
|
||||
<div className="bg-muted relative aspect-[2/3] w-full overflow-hidden">
|
||||
<img
|
||||
src={template.poster_path}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.02]"
|
||||
/>
|
||||
<div className="absolute top-2 right-2 flex flex-wrap justify-end gap-1">
|
||||
<TemplateBadges template={template} />
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div
|
||||
className="bg-primary/10 text-primary flex h-10 w-10 shrink-0 items-center justify-center rounded-md text-xl"
|
||||
aria-hidden
|
||||
>
|
||||
{template.icon}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start justify-between gap-2 p-4 pb-0">
|
||||
<div
|
||||
className="bg-primary/10 text-primary flex h-10 w-10 items-center justify-center rounded-md text-xl"
|
||||
aria-hidden
|
||||
>
|
||||
{template.icon}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||
<TemplateBadges template={template} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||
<TemplateBadges template={template} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-1 flex-col gap-3 p-4">
|
||||
</div>
|
||||
<div className="mt-3 flex flex-1 flex-col gap-3">
|
||||
<div>
|
||||
<p className="text-sm leading-tight font-medium">{template.title}</p>
|
||||
<p className="text-muted-foreground mt-1 line-clamp-3 text-xs">{template.description}</p>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { SyncScheduleField } from "@/components/collections/SyncScheduleField";
|
||||
|
||||
import { MDBListBrowser } from "./MDBListBrowser";
|
||||
import { TemplatePosterField, type TemplatePosterMode } from "./TemplatePosterField";
|
||||
|
||||
interface Props {
|
||||
template: CollectionTemplate;
|
||||
@@ -94,6 +95,10 @@ export function CollectionTemplateConfigForm({
|
||||
const defaultProfileId = template.requires_profile ? (profiles[0]?.id ?? "") : "";
|
||||
const [profileId, setProfileId] = useState(defaultProfileId);
|
||||
const [mdblistUrl, setMdblistUrl] = useState(template.mdblist?.url ?? "");
|
||||
const [posterMode, setPosterMode] = useState<TemplatePosterMode>(() =>
|
||||
template.poster_path ? "default" : "custom",
|
||||
);
|
||||
const [customPosterUrl, setCustomPosterUrl] = useState("");
|
||||
|
||||
// Discover- and Collection-source templates are bundle-only: the spec is
|
||||
// backend-driven and can't be edited inline. Render a read-only summary so
|
||||
@@ -135,6 +140,9 @@ export function CollectionTemplateConfigForm({
|
||||
featured,
|
||||
sync_schedule: syncSchedule.trim() || undefined,
|
||||
limit: parsedLimit,
|
||||
...(posterMode === "custom"
|
||||
? { poster_source_url: customPosterUrl.trim() || undefined }
|
||||
: { poster_url: template.poster_path || undefined }),
|
||||
};
|
||||
|
||||
if (template.source === "tmdb" && template.tmdb) {
|
||||
@@ -284,6 +292,15 @@ export function CollectionTemplateConfigForm({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<TemplatePosterField
|
||||
template={template}
|
||||
mode={posterMode}
|
||||
onModeChange={setPosterMode}
|
||||
customUrl={customPosterUrl}
|
||||
onCustomUrlChange={setCustomPosterUrl}
|
||||
inputId="template-poster-url"
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="template-limit">Max Items</Label>
|
||||
@@ -291,7 +308,7 @@ export function CollectionTemplateConfigForm({
|
||||
id="template-limit"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
max={200}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
value={limit}
|
||||
|
||||
@@ -341,6 +341,7 @@ function TemplateBundleApplyView({
|
||||
const [result, setResult] = useState<ApplyCollectionTemplateBundleResponse | null>(null);
|
||||
const applyBundle = useApplyCollectionTemplateBundle();
|
||||
const disabled = libraryIds.length === 0 || applyBundle.isPending;
|
||||
const queuesInitialSyncs = bundle.id === "all_defaults";
|
||||
const selectedLibraries = useMemo(
|
||||
() => libraries.filter((library) => libraryIds.includes(library.id)),
|
||||
[libraries, libraryIds],
|
||||
@@ -403,6 +404,12 @@ function TemplateBundleApplyView({
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="text-sm font-semibold">{bundle.title}</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs leading-snug">{bundle.description}</p>
|
||||
{queuesInitialSyncs ? (
|
||||
<p className="text-muted-foreground mt-2 text-xs leading-snug">
|
||||
Collections are created first; initial syncs are queued so this large bundle can finish
|
||||
without waiting on every source.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -488,7 +495,7 @@ function TemplateBundleApplyView({
|
||||
Preview
|
||||
</Button>
|
||||
<Button type="button" disabled={disabled} onClick={() => run(false)}>
|
||||
Apply Defaults
|
||||
{applyBundle.isPending ? "Applying..." : "Apply Defaults"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -594,8 +601,13 @@ function buildFeaturedBundleRequest(
|
||||
function BundleApplyResult({ result }: { result: ApplyCollectionTemplateBundleResponse }) {
|
||||
const actionLabel = result.dry_run ? "Would create" : "Created";
|
||||
const deleteActionLabel = result.dry_run ? "Would delete" : "Deleted";
|
||||
const created = result.created ?? [];
|
||||
const skipped = result.skipped ?? [];
|
||||
const failed = result.failed ?? [];
|
||||
const deleted = result.deleted ?? [];
|
||||
const deleteFailed = result.delete_failed ?? [];
|
||||
const deleteSkipped = result.delete_skipped ?? [];
|
||||
const syncQueued = result.sync_queued ?? [];
|
||||
const featured = result.featured ?? [];
|
||||
const featuredFailed = result.featured_failed ?? [];
|
||||
return (
|
||||
@@ -603,13 +615,20 @@ function BundleApplyResult({ result }: { result: ApplyCollectionTemplateBundleRe
|
||||
<div className="font-medium">
|
||||
{result.delete_existing ? (
|
||||
<>
|
||||
{deleteActionLabel} {result.deleted.length}; delete skipped {deleteSkipped.length};
|
||||
delete failed {deleteFailed.length};{" "}
|
||||
{deleteActionLabel} {deleted.length}; delete skipped {deleteSkipped.length}; delete
|
||||
failed {deleteFailed.length};{" "}
|
||||
</>
|
||||
) : null}
|
||||
{actionLabel} {result.created.length}; skipped {result.skipped.length}; failed{" "}
|
||||
{result.failed.length}; featured {featured.length}; featured failed {featuredFailed.length}
|
||||
{actionLabel} {created.length}; skipped {skipped.length}; failed {failed.length}
|
||||
{syncQueued.length > 0 ? `; sync queued ${syncQueued.length}` : ""}; featured{" "}
|
||||
{featured.length}; featured failed {featuredFailed.length}
|
||||
</div>
|
||||
{syncQueued.length > 0 ? (
|
||||
<div className="text-muted-foreground text-xs leading-snug">
|
||||
Initial syncs are running in the background. Collections are available now; items appear
|
||||
as each sync finishes.
|
||||
</div>
|
||||
) : null}
|
||||
{deleteFailed.length > 0 ? (
|
||||
<div className="text-destructive space-y-1 text-xs">
|
||||
{deleteFailed.slice(0, 5).map((entry) => (
|
||||
@@ -620,9 +639,9 @@ function BundleApplyResult({ result }: { result: ApplyCollectionTemplateBundleRe
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{result.failed.length > 0 ? (
|
||||
{failed.length > 0 ? (
|
||||
<div className="text-destructive space-y-1 text-xs">
|
||||
{result.failed.slice(0, 5).map((entry) => (
|
||||
{failed.slice(0, 5).map((entry) => (
|
||||
<div key={`${entry.library_id}:${entry.template_id}`}>
|
||||
{entry.library_name} / {entry.template_title}: {entry.reason ?? "failed"}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CollectionTemplate } from "@/lib/collectionTemplates";
|
||||
|
||||
export type TemplatePosterMode = "default" | "custom";
|
||||
|
||||
interface TemplatePosterFieldProps {
|
||||
template: CollectionTemplate;
|
||||
mode: TemplatePosterMode;
|
||||
onModeChange: (mode: TemplatePosterMode) => void;
|
||||
customUrl: string;
|
||||
onCustomUrlChange: (value: string) => void;
|
||||
inputId: string;
|
||||
}
|
||||
|
||||
export function TemplatePosterField({
|
||||
template,
|
||||
mode,
|
||||
onModeChange,
|
||||
customUrl,
|
||||
onCustomUrlChange,
|
||||
inputId,
|
||||
}: TemplatePosterFieldProps) {
|
||||
const hasDefaultPoster = Boolean(template.poster_path);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>Poster</Label>
|
||||
<div className="border-border space-y-3 rounded-md border p-3">
|
||||
{hasDefaultPoster ? (
|
||||
<div role="radiogroup" aria-label="Poster source" className="grid gap-2 sm:grid-cols-2">
|
||||
<PosterChoiceButton
|
||||
label="Server default"
|
||||
active={mode === "default"}
|
||||
onClick={() => onModeChange("default")}
|
||||
/>
|
||||
<PosterChoiceButton
|
||||
label="Custom URL"
|
||||
active={mode === "custom"}
|
||||
onClick={() => onModeChange("custom")}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{mode === "default" && template.poster_path ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={template.poster_path}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="border-border bg-muted h-20 w-14 shrink-0 rounded-md border object-cover"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Server default</p>
|
||||
<p className="text-muted-foreground truncate text-xs">{template.poster_path}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
id={inputId}
|
||||
type="url"
|
||||
value={customUrl}
|
||||
onChange={(event) => onCustomUrlChange(event.target.value)}
|
||||
placeholder="https://example.com/poster.jpg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PosterChoiceButton({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"border-border hover:bg-accent flex h-9 items-center justify-between rounded-md border px-3 text-sm transition-colors",
|
||||
active && "border-primary bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{active ? <Check className="h-4 w-4" aria-hidden /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
import { MDBListBrowser } from "./MDBListBrowser";
|
||||
import { TemplatePosterField, type TemplatePosterMode } from "./TemplatePosterField";
|
||||
|
||||
interface Props {
|
||||
template: CollectionTemplate;
|
||||
@@ -89,6 +90,10 @@ export function UserCollectionTemplateConfigForm({ template, onCancel, onCreated
|
||||
const [isShared, setIsShared] = useState(false);
|
||||
const [mdblistUrl, setMdblistUrl] = useState(template.mdblist?.url ?? "");
|
||||
const [libraryIds, setLibraryIds] = useState<number[]>([]);
|
||||
const [posterMode, setPosterMode] = useState<TemplatePosterMode>(() =>
|
||||
template.poster_path ? "default" : "custom",
|
||||
);
|
||||
const [customPosterUrl, setCustomPosterUrl] = useState("");
|
||||
|
||||
const eligibility = libraryEligibilityForMediaKind(template.media_kind);
|
||||
const pickerLibraries = libraries.map((lib) => ({
|
||||
@@ -113,6 +118,10 @@ export function UserCollectionTemplateConfigForm({ template, onCancel, onCreated
|
||||
sync_schedule: scheduleChoiceToRequest(schedule),
|
||||
limit: parsedLimit,
|
||||
is_shared: isShared,
|
||||
poster_url:
|
||||
posterMode === "custom"
|
||||
? customPosterUrl.trim() || undefined
|
||||
: template.poster_path || undefined,
|
||||
library_ids: libraryIds.length > 0 ? libraryIds : undefined,
|
||||
};
|
||||
|
||||
@@ -236,6 +245,15 @@ export function UserCollectionTemplateConfigForm({ template, onCancel, onCreated
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<TemplatePosterField
|
||||
template={template}
|
||||
mode={posterMode}
|
||||
onModeChange={setPosterMode}
|
||||
customUrl={customPosterUrl}
|
||||
onCustomUrlChange={setCustomPosterUrl}
|
||||
inputId="user-template-poster-url"
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-template-limit">Max Items</Label>
|
||||
|
||||
Reference in New Issue
Block a user