* feat(sections): fix broken home section templates and add six new ones Fixes templates that silently produced nothing: - award_winners: hide from gallery (resolver is a stub until award data exists); saved sections keep resolving - seasonal_themed: christmas/st_patricks/thanksgiving get an interim title-keyword resolver, and multi-theme selection skips themes without an executable query so a data-less theme can no longer black out the section during its own window (previously killed the section all of December) - taste_match: empty genre now auto-picks the profile's strongest taste cluster (fallback: server top genre); the default preset was permanently empty - because_you_watched: honor the recipe's anchor_item_id key (fetcher only read legacy source_item_id, so pinning an anchor did nothing) - editorial_spotlight: reject subject_type=franchise (validated but could never resolve); fix drawer misrepresenting pinned presets as auto-rotate - admin_curated_list: add a catalog-search item picker so Editor's Picks is actually addable; block saving an empty list; hide admin_only recipes from profile-facing galleries - discovery fetchers (hidden_gems, forgotten_favorites, critically_acclaimed): honor single/multi library scope, intersected with viewer access; implement hidden_gems max_play_count New templates: returning_shows (new season of shows you've watched), genre_roulette (rotating top-genre spotlight with title override), anniversaries (milestone release anniversaries this month), short_watches (well-rated movies under a runtime cap), family_movie_night seasonal theme (Fri/Sat evenings), and a "New in 4K" format_showcase preset via a new sort=recent param. Adds a blanket test asserting every visible gallery preset's defaults pass its own recipe validation — the gap that let taste_match and Editor's Picks ship broken. New SQL shapes validated with EXPLAIN against the dev database. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sections): address PR #332 review findings Codex review: - returning_shows: the new-season file check now applies the effective library scope (section scope ∩ viewer-allowed, minus disabled) to media_files.media_folder_id, so an episode file that only exists in an out-of-scope folder can no longer surface the series - buildLibraryScope: replaced the media_item_libraries row join with EXISTS / NOT EXISTS semi-joins. An item in several in-scope libraries now yields exactly one row in the non-GROUP BY rails (short_watches, anniversaries, seasonal keyword, format_showcase, new_to_library, ...), and the disabled-library check is item-level, closing the join-row leak where membership in an allowed library masked membership in a disabled one. Deny-only mode keeps the positive-membership guard, mirroring catalog's appendDiscoveryLibraryScope. CodeRabbit review: - recommendations reader: a taste cluster whose cached items are entirely filtered out now falls through to the next cluster / global fallback instead of returning an empty row - genre_roulette: multi-library scopes get distinct rotation seeds - returning_shows: reject negative lookback_days at validation - shared oneOf() enum validator replaces per-recipe switch duplication - SeasonalTitleOverride usable-filter contract covered by a direct test - web NumberParamField: integer-only guard + step=1 (backend fields are Go ints; fractional values failed unmarshalling at save) - curated list picker: search failures show an error instead of a misleading "No matches."; pre-existing item_ids hydrate display titles via the watch-detail endpoint instead of rendering raw ids Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
204 lines
6.4 KiB
TypeScript
204 lines
6.4 KiB
TypeScript
import { useState } from "react";
|
|
import BulkApplyDialog from "./BulkApplyDialog";
|
|
import RecipeParamFields from "./RecipeParamFields";
|
|
import type { RecipeDefinition, GalleryPreset } from "@/lib/recipes";
|
|
|
|
export interface AddPayload {
|
|
section_type: string;
|
|
title: string;
|
|
item_limit: number;
|
|
featured: boolean;
|
|
enabled: boolean;
|
|
config: Record<string, unknown>;
|
|
apply_to_all_libraries?: boolean;
|
|
library_ids?: number[];
|
|
}
|
|
|
|
interface Props {
|
|
def: RecipeDefinition;
|
|
preset: GalleryPreset;
|
|
/** Close the drawer without saving. Used by the bottom Cancel button. */
|
|
onCancel: () => void;
|
|
/**
|
|
* Return to the gallery from the header link. When omitted, the link
|
|
* falls back to onCancel (closes the drawer entirely).
|
|
*/
|
|
onBackToGallery?: () => void;
|
|
onAdd: (payload: AddPayload) => void | Promise<void>;
|
|
showBulkApply?: boolean;
|
|
showEnabled?: boolean;
|
|
}
|
|
|
|
export default function RecipeConfigDrawer({
|
|
def,
|
|
preset,
|
|
onCancel,
|
|
onBackToGallery,
|
|
onAdd,
|
|
showBulkApply = true,
|
|
showEnabled = true,
|
|
}: Props) {
|
|
const [title, setTitle] = useState(preset.display_name);
|
|
const [params, setParams] = useState<Record<string, unknown>>({ ...preset.default_params });
|
|
const [limit, setLimit] = useState<number>(20);
|
|
const [featured, setFeatured] = useState(false);
|
|
const [enabled, setEnabled] = useState(true);
|
|
const [applyAll, setApplyAll] = useState(false);
|
|
const [bulkOpen, setBulkOpen] = useState(false);
|
|
const libraryCollectionID =
|
|
typeof params.library_collection_id === "string" ? params.library_collection_id : "";
|
|
const userCollectionID =
|
|
typeof params.user_collection_id === "string" ? params.user_collection_id : "";
|
|
const collectionID = libraryCollectionID || userCollectionID;
|
|
const isAutoBackedTraktPreset =
|
|
def.type === "collection" &&
|
|
params.source_provider === "trakt" &&
|
|
(params.source_preset === "trending" || params.source_preset === "popular");
|
|
const collectionMissing =
|
|
def.type === "collection" && collectionID.trim() === "" && !isAutoBackedTraktPreset;
|
|
const curatedListEmpty =
|
|
def.type === "admin_curated_list" &&
|
|
(!Array.isArray(params.item_ids) || params.item_ids.length === 0);
|
|
|
|
const handleAdd = () => {
|
|
if (collectionMissing || curatedListEmpty) {
|
|
return;
|
|
}
|
|
const payload = {
|
|
section_type: def.type,
|
|
title,
|
|
item_limit: limit,
|
|
featured,
|
|
enabled,
|
|
config: params,
|
|
apply_to_all_libraries: false,
|
|
};
|
|
if (showBulkApply && applyAll) {
|
|
setBulkOpen(true);
|
|
return;
|
|
}
|
|
void Promise.resolve(onAdd(payload)).catch(() => {
|
|
// The owner reports the failure and keeps the drawer mounted for retry.
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-[540px] rounded-xl border border-white/10 bg-zinc-900 p-6">
|
|
<div className="flex items-center justify-between border-b border-white/10 pb-3">
|
|
<h3 className="text-base font-semibold">
|
|
{preset.icon} {preset.display_name}
|
|
</h3>
|
|
<button
|
|
type="button"
|
|
onClick={onBackToGallery ?? onCancel}
|
|
className="text-xs text-white/60"
|
|
>
|
|
← Back to gallery
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mt-4 rounded border-l-2 border-indigo-500 bg-indigo-500/10 px-3 py-2 text-sm">
|
|
{preset.description_long ?? preset.description_short}
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<label htmlFor="rcd-title" className="mb-1 block text-xs text-white/70">
|
|
Title
|
|
</label>
|
|
<input
|
|
id="rcd-title"
|
|
className="w-full rounded border border-white/15 bg-white/5 px-3 py-2 text-sm"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<RecipeParamFields def={def} params={params} onChange={setParams} />
|
|
</div>
|
|
|
|
{collectionMissing ? (
|
|
<p className="mt-2 text-xs text-amber-300">
|
|
Choose a synced collection before adding this section.
|
|
</p>
|
|
) : null}
|
|
|
|
{curatedListEmpty ? (
|
|
<p className="mt-2 text-xs text-amber-300">Add at least one title to the curated list.</p>
|
|
) : null}
|
|
|
|
<div className="mt-4">
|
|
<label className="mb-1 block text-xs text-white/70">Item limit</label>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={100}
|
|
className="w-full rounded border border-white/15 bg-white/5 px-3 py-2 text-sm"
|
|
value={limit}
|
|
onChange={(e) => setLimit(Number(e.target.value) || 20)}
|
|
/>
|
|
</div>
|
|
|
|
<label className="mt-3 flex items-center justify-between text-sm">
|
|
<span>Show as featured hero</span>
|
|
<input type="checkbox" checked={featured} onChange={(e) => setFeatured(e.target.checked)} />
|
|
</label>
|
|
|
|
{showBulkApply ? (
|
|
<label className="mt-2 flex items-center justify-between text-sm">
|
|
<span>Apply to all libraries</span>
|
|
<input
|
|
type="checkbox"
|
|
checked={applyAll}
|
|
onChange={(e) => setApplyAll(e.target.checked)}
|
|
/>
|
|
</label>
|
|
) : null}
|
|
|
|
{showEnabled ? (
|
|
<label className="mt-2 flex items-center justify-between text-sm">
|
|
<span>Enabled</span>
|
|
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
|
</label>
|
|
) : null}
|
|
|
|
<div className="mt-5 flex justify-end gap-2 border-t border-white/10 pt-4">
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
className="rounded border border-white/15 px-3 py-1 text-sm"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleAdd}
|
|
disabled={collectionMissing || curatedListEmpty}
|
|
className="rounded bg-indigo-600 px-3 py-1 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
Add section
|
|
</button>
|
|
</div>
|
|
|
|
{showBulkApply ? (
|
|
<BulkApplyDialog
|
|
open={bulkOpen}
|
|
onClose={() => setBulkOpen(false)}
|
|
onConfirm={(libraryIDs) =>
|
|
onAdd({
|
|
section_type: def.type,
|
|
title,
|
|
item_limit: limit,
|
|
featured,
|
|
enabled,
|
|
config: params,
|
|
apply_to_all_libraries: true,
|
|
library_ids: libraryIDs,
|
|
})
|
|
}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|