import { useState, useMemo, useCallback } from "react"; import { Lock, Unlock } from "lucide-react"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { MetadataTranslatePanel } from "@/components/MetadataTranslatePanel"; import { TagInput } from "@/components/TagInput"; import ImageSelectorTab from "@/components/ImageSelectorTab"; import type { ItemDetail } from "@/api/types"; import { useUpdateItemMetadata, useRefreshItemMetadata, type UpdateItemMetadataRequest, } from "@/hooks/queries/items"; import { useIsActingAdmin } from "@/hooks/useIsActingAdmin"; import { cn } from "@/lib/utils"; // MetadataField enum values matching internal/metadata/types.go const FIELD_NAME = 0; const FIELD_OVERVIEW = 1; const FIELD_GENRES = 2; const FIELD_STUDIOS = 3; const FIELD_RATING = 6; const FIELD_TAGS = 8; const FIELD_RUNTIME = 7; const FIELD_CONTENT_RATING = 9; const FIELD_AIR_SCHEDULE = 11; const FIELD_RELEASE_DATES = 13; const AIR_TIMEZONES = [ "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", "Europe/London", "Europe/Paris", "Asia/Tokyo", "Asia/Seoul", "Australia/Sydney", ]; type Section = "general" | "dates" | "tags" | "ids" | "images"; const SECTIONS: { key: Section; label: string; types: ItemDetail["type"][] }[] = [ { key: "general", label: "General", types: ["movie", "series", "season", "episode"] }, { key: "dates", label: "Dates & Ratings", types: ["movie", "series", "season", "episode"] }, { key: "tags", label: "Tags & Genres", types: ["movie", "series"] }, { key: "ids", label: "External IDs", types: ["movie", "series", "season", "episode"] }, // Episodes are excluded: the image search endpoint only returns series-level // posters/backdrops/logos, and episodes store stills — there is nothing an // episode apply could legitimately show or persist from this dialog. { key: "images", label: "Images", types: ["movie", "series", "season"] }, ]; // Maps form field names to their MetadataField lock value. const FIELD_LOCK_MAP: Record = { title: FIELD_NAME, sort_title: FIELD_NAME, original_title: FIELD_NAME, tagline: FIELD_NAME, overview: FIELD_OVERVIEW, genres: FIELD_GENRES, studios: FIELD_STUDIOS, networks: FIELD_STUDIOS, countries: FIELD_TAGS, rating_imdb: FIELD_RATING, rating_tmdb: FIELD_RATING, rating_rt_critic: FIELD_RATING, rating_rt_audience: FIELD_RATING, runtime: FIELD_RUNTIME, content_rating: FIELD_CONTENT_RATING, air_time: FIELD_AIR_SCHEDULE, air_timezone: FIELD_AIR_SCHEDULE, year: FIELD_RELEASE_DATES, release_date: FIELD_RELEASE_DATES, first_air_date: FIELD_RELEASE_DATES, last_air_date: FIELD_RELEASE_DATES, }; interface EditMetadataDialogProps { item: ItemDetail; open: boolean; onOpenChange: (open: boolean) => void; } function initFormState(item: ItemDetail) { return { title: item.title ?? "", sort_title: item.sort_title ?? "", original_title: item.original_title ?? "", overview: item.overview ?? "", tagline: item.tagline ?? "", content_rating: item.content_rating ?? "", year: item.year ?? 0, runtime: item.runtime ?? 0, genres: item.genres ?? [], studios: item.studios ?? [], networks: item.networks ?? [], countries: item.countries ?? [], release_date: item.release_date ?? "", first_air_date: item.first_air_date ?? "", last_air_date: item.last_air_date ?? "", air_time: item.air_time ?? "", air_timezone: item.air_timezone ?? "", air_date: item.air_date ?? "", status: item.status ?? "", rating_imdb: item.rating_imdb, rating_tmdb: item.rating_tmdb, rating_rt_critic: item.rating_rt_critic, rating_rt_audience: item.rating_rt_audience, imdb_id: item.imdb_id ?? "", tmdb_id: item.tmdb_id ?? "", tvdb_id: item.tvdb_id ?? "", season_number: item.season_number ?? undefined, episode_number: item.episode_number ?? undefined, }; } export default function EditMetadataDialog({ item, open, onOpenChange }: EditMetadataDialogProps) { const [activeSection, setActiveSection] = useState
("general"); const [form, setForm] = useState(() => initFormState(item)); const [lockedFields, setLockedFields] = useState>( () => new Set(item.locked_fields ?? []), ); const [showResetConfirm, setShowResetConfirm] = useState(false); const updateMutation = useUpdateItemMetadata(item.content_id); const refreshMutation = useRefreshItemMetadata(); const isLockable = item.type === "movie" || item.type === "series"; const canEditImages = useIsActingAdmin(); const visibleSections = SECTIONS.filter( (s) => s.types.includes(item.type) && (s.key !== "images" || canEditImages), ); const effectiveActiveSection = visibleSections.some((section) => section.key === activeSection) ? activeSection : "general"; const originalForm = useMemo(() => initFormState(item), [item]); const setField = useCallback( (field: string, value: unknown) => { setForm((prev) => ({ ...prev, [field]: value })); if (isLockable && field in FIELD_LOCK_MAP) { const lockField = FIELD_LOCK_MAP[field] as number; setLockedFields((prev) => new Set(prev).add(lockField)); } }, [isLockable], ); const toggleLock = useCallback((metadataField: number) => { setLockedFields((prev) => { const next = new Set(prev); if (next.has(metadataField)) { next.delete(metadataField); } else { next.add(metadataField); } return next; }); }, []); const lockedCount = lockedFields.size; function handleSave() { const data: UpdateItemMetadataRequest = {}; if (form.title !== originalForm.title) data.title = form.title; if (form.sort_title !== originalForm.sort_title) data.sort_title = form.sort_title; if (form.original_title !== originalForm.original_title) data.original_title = form.original_title; if (form.overview !== originalForm.overview) data.overview = form.overview; if (form.tagline !== originalForm.tagline) data.tagline = form.tagline; if (form.content_rating !== originalForm.content_rating) data.content_rating = form.content_rating; if (form.year !== originalForm.year) data.year = form.year; if (form.runtime !== originalForm.runtime) data.runtime = form.runtime; if (JSON.stringify(form.genres) !== JSON.stringify(originalForm.genres)) data.genres = form.genres; if (JSON.stringify(form.studios) !== JSON.stringify(originalForm.studios)) data.studios = form.studios; if (JSON.stringify(form.networks) !== JSON.stringify(originalForm.networks)) data.networks = form.networks; if (JSON.stringify(form.countries) !== JSON.stringify(originalForm.countries)) data.countries = form.countries; if (form.release_date !== originalForm.release_date) data.release_date = form.release_date || null; if (form.first_air_date !== originalForm.first_air_date) data.first_air_date = form.first_air_date || null; if (form.last_air_date !== originalForm.last_air_date) data.last_air_date = form.last_air_date || null; if (form.air_time !== originalForm.air_time) data.air_time = form.air_time || null; if (form.air_timezone !== originalForm.air_timezone) // Send "" (not null) when cleared: the server treats null as "skip", so // clearing a previously-set timezone would not persist. "" is accepted by // validation and normalized to NULL server-side. data.air_timezone = form.air_timezone; if (form.air_date !== originalForm.air_date) data.air_date = form.air_date || null; if (form.status !== originalForm.status) data.status = form.status; if (form.rating_imdb !== originalForm.rating_imdb) data.rating_imdb = form.rating_imdb; if (form.rating_tmdb !== originalForm.rating_tmdb) data.rating_tmdb = form.rating_tmdb; if (form.rating_rt_critic !== originalForm.rating_rt_critic) data.rating_rt_critic = form.rating_rt_critic; if (form.rating_rt_audience !== originalForm.rating_rt_audience) data.rating_rt_audience = form.rating_rt_audience; if (form.imdb_id !== originalForm.imdb_id) data.imdb_id = form.imdb_id; if (form.tmdb_id !== originalForm.tmdb_id) data.tmdb_id = form.tmdb_id; if (form.tvdb_id !== originalForm.tvdb_id) data.tvdb_id = form.tvdb_id; if (form.season_number !== originalForm.season_number) data.season_number = form.season_number; if (form.episode_number !== originalForm.episode_number) data.episode_number = form.episode_number; if (isLockable) { const originalLocked = new Set(item.locked_fields ?? []); const currentLocked = Array.from(lockedFields).sort(); const originalSorted = Array.from(originalLocked).sort(); if (JSON.stringify(currentLocked) !== JSON.stringify(originalSorted)) { data.locked_fields = currentLocked; } } if (Object.keys(data).length === 0) { onOpenChange(false); return; } updateMutation.mutate(data, { onSuccess: () => onOpenChange(false), }); } function handleReset() { refreshMutation.mutate({ item, mode: "quick" }); setShowResetConfirm(false); onOpenChange(false); } function renderLockIcon(fieldName: string) { if (!isLockable || !(fieldName in FIELD_LOCK_MAP)) return null; const metadataField = FIELD_LOCK_MAP[fieldName] as number; const isLocked = lockedFields.has(metadataField); return ( ); } const typeLabel = item.type === "movie" ? "Movie" : item.type === "series" ? "Series" : item.type === "season" ? "Season" : "Episode"; return ( <> {/* Header */}
Edit Metadata {typeLabel}
{isLockable && lockedCount > 0 && ( {lockedCount} locked )}
{/* Sidebar — horizontal tabs on mobile, vertical on sm+ */} {/* Content */}
{effectiveActiveSection === "general" && (
setField("title", e.target.value)} />
setField("sort_title", e.target.value)} /> setField("original_title", e.target.value)} />