feat(web): show metadata tools to curators

This commit is contained in:
Quick
2026-05-26 18:24:39 -04:00
parent afaac04edf
commit c6bccd358e
5 changed files with 80 additions and 42 deletions
+16 -8
View File
@@ -35,6 +35,7 @@ import {
type EpisodeNavigationState,
} from "./itemDetailLayout";
import { getWatchedActionLabel } from "./watchedState";
import { canCurateMetadata as canCurateMetadataForUser } from "@/lib/permissions";
function formatDuration(minutes: number): string {
if (minutes <= 0) return "";
@@ -49,6 +50,7 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e
useAmbientColor(item.backdrop_thumbhash);
const { user } = useAuth();
const isAdmin = user?.role === "admin";
const canCurateMetadata = canCurateMetadataForUser(user);
const { profile: currentProfile } = useCurrentProfile();
const [editOpen, setEditOpen] = useState(false);
const [downloadOpen, setDownloadOpen] = useState(false);
@@ -294,12 +296,15 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e
watchedLabel={getWatchedActionLabel(item)}
onToggleWatched={() => watchedMutation.mutate(!(item.user_data?.played ?? false))}
isUpdatingWatched={watchedMutation.isPending}
onRefresh={(mode) =>
refreshMetadataMutation.mutate({
item,
mode,
onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }),
})
onRefresh={
canCurateMetadata
? (mode) =>
refreshMetadataMutation.mutate({
item,
mode,
onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }),
})
: undefined
}
isRefreshing={refreshMetadataMutation.isPending}
onRedetectIntro={
@@ -307,7 +312,8 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e
}
isRedetectingIntro={redetectIntroMutation.isPending}
isAdmin={isAdmin}
onEditMetadata={isAdmin ? () => setEditOpen(true) : undefined}
canCurateMetadata={canCurateMetadata}
onEditMetadata={canCurateMetadata ? () => setEditOpen(true) : undefined}
versions={item.versions ?? []}
playbackVariants={item.playback_variants}
selectedVersion={selectedVersion}
@@ -367,7 +373,9 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e
{item.crew && item.crew.length > 0 && <CrewList crew={item.crew} />}
</div>
{isAdmin && <EditMetadataDialog item={item} open={editOpen} onOpenChange={setEditOpen} />}
{canCurateMetadata && (
<EditMetadataDialog item={item} open={editOpen} onOpenChange={setEditOpen} />
)}
<DownloadVersionPicker
open={downloadOpen}
onOpenChange={setDownloadOpen}
+18 -10
View File
@@ -29,6 +29,7 @@ import { selectDefaultPlaybackVariantVersion } from "./components/versionRanking
import { RecommendationGridSkeleton } from "./components/SectionSkeletons";
import { resolveLeafPrimaryAction } from "./itemDetailLayout";
import { getWatchedActionLabel } from "./watchedState";
import { canCurateMetadata as canCurateMetadataForUser } from "@/lib/permissions";
function formatDuration(minutes: number): string {
if (minutes <= 0) return "";
@@ -42,6 +43,7 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov
useAmbientColor(item.backdrop_thumbhash);
const { user } = useAuth();
const isAdmin = user?.role === "admin";
const canCurateMetadata = canCurateMetadataForUser(user);
const { profile: currentProfile } = useCurrentProfile();
const isFavorite = item.user_state?.is_favorite ?? false;
@@ -238,17 +240,21 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov
isFavorite={isFavorite}
onToggleWatchlist={() => toggleWatchlistMutation.mutate(inWatchlist)}
inWatchlist={inWatchlist}
onRefresh={(mode) =>
refreshMetadataMutation.mutate({
item,
mode,
onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }),
})
onRefresh={
canCurateMetadata
? (mode) =>
refreshMetadataMutation.mutate({
item,
mode,
onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }),
})
: undefined
}
isRefreshing={refreshMetadataMutation.isPending}
isAdmin={isAdmin}
onEditMetadata={isAdmin ? () => setEditOpen(true) : undefined}
onMatchItem={isAdmin ? () => setMatchOpen(true) : undefined}
canCurateMetadata={canCurateMetadata}
onEditMetadata={canCurateMetadata ? () => setEditOpen(true) : undefined}
onMatchItem={canCurateMetadata ? () => setMatchOpen(true) : undefined}
versions={item.versions}
playbackVariants={item.playback_variants}
selectedVersion={selectedVersion}
@@ -307,8 +313,10 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov
)
)}
</div>
{isAdmin && <EditMetadataDialog item={item} open={editOpen} onOpenChange={setEditOpen} />}
{isAdmin && (
{canCurateMetadata && (
<EditMetadataDialog item={item} open={editOpen} onOpenChange={setEditOpen} />
)}
{canCurateMetadata && (
<MatchItemDialog
key={item.content_id}
item={item}
+16 -8
View File
@@ -15,6 +15,7 @@ import DetailBreadcrumb from "./components/DetailBreadcrumb";
import SeasonEpisodeGrid from "./components/SeasonEpisodeGrid";
import type { EpisodeNavigationState } from "./itemDetailLayout";
import { getWatchedActionLabel } from "./watchedState";
import { canCurateMetadata as canCurateMetadataForUser } from "@/lib/permissions";
function seasonLabel(seasonNumber: number, title?: string) {
if (title) return title;
@@ -27,6 +28,7 @@ export default function SeasonContent({ item }: { item: ItemDetail & { type: "se
useAmbientColor(item.backdrop_thumbhash);
const { user } = useAuth();
const isAdmin = user?.role === "admin";
const canCurateMetadata = canCurateMetadataForUser(user);
const [editOpen, setEditOpen] = useState(false);
const watchedMutation = useWatchedStateMutation(item);
const refreshMetadataMutation = useRefreshItemMetadata();
@@ -103,16 +105,20 @@ export default function SeasonContent({ item }: { item: ItemDetail & { type: "se
watchedLabel={getWatchedActionLabel(item)}
onToggleWatched={() => watchedMutation.mutate(!(item.user_data?.played ?? false))}
isUpdatingWatched={watchedMutation.isPending}
onRefresh={(mode) =>
refreshMetadataMutation.mutate({
item,
mode,
onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }),
})
onRefresh={
canCurateMetadata
? (mode) =>
refreshMetadataMutation.mutate({
item,
mode,
onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }),
})
: undefined
}
isRefreshing={refreshMetadataMutation.isPending}
isAdmin={isAdmin}
onEditMetadata={isAdmin ? () => setEditOpen(true) : undefined}
canCurateMetadata={canCurateMetadata}
onEditMetadata={canCurateMetadata ? () => setEditOpen(true) : undefined}
/>
}
/>
@@ -144,7 +150,9 @@ export default function SeasonContent({ item }: { item: ItemDetail & { type: "se
</div>
)}
</div>
{isAdmin && <EditMetadataDialog item={item} open={editOpen} onOpenChange={setEditOpen} />}
{canCurateMetadata && (
<EditMetadataDialog item={item} open={editOpen} onOpenChange={setEditOpen} />
)}
</div>
);
}
+18 -10
View File
@@ -25,12 +25,14 @@ import ActionBar from "./components/ActionBar";
import { SeasonCarouselSkeleton, RecommendationGridSkeleton } from "./components/SectionSkeletons";
import { getSeasonDisplayTitle, resolveSeriesPrimaryAction } from "./itemDetailLayout";
import { getWatchedActionLabel } from "./watchedState";
import { canCurateMetadata as canCurateMetadataForUser } from "@/lib/permissions";
export default function SeriesContent({ item }: { item: ItemDetail & { type: "series" } }) {
const navigate = useNavigate();
useAmbientColor(item.backdrop_thumbhash);
const { user } = useAuth();
const isAdmin = user?.role === "admin";
const canCurateMetadata = canCurateMetadataForUser(user);
const isFavorite = item.user_state?.is_favorite ?? false;
const inWatchlist = item.user_state?.in_watchlist ?? false;
@@ -156,17 +158,21 @@ export default function SeriesContent({ item }: { item: ItemDetail & { type: "se
isFavorite={isFavorite}
onToggleWatchlist={() => toggleWatchlistMutation.mutate(inWatchlist)}
inWatchlist={inWatchlist}
onRefresh={(mode) =>
refreshMetadataMutation.mutate({
item,
mode,
onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }),
})
onRefresh={
canCurateMetadata
? (mode) =>
refreshMetadataMutation.mutate({
item,
mode,
onReplaced: (contentID) => navigate(`/item/${contentID}`, { replace: true }),
})
: undefined
}
isRefreshing={refreshMetadataMutation.isPending}
isAdmin={isAdmin}
onEditMetadata={isAdmin ? () => setEditOpen(true) : undefined}
onMatchItem={isAdmin ? () => setMatchOpen(true) : undefined}
canCurateMetadata={canCurateMetadata}
onEditMetadata={canCurateMetadata ? () => setEditOpen(true) : undefined}
onMatchItem={canCurateMetadata ? () => setMatchOpen(true) : undefined}
rating={item.user_rating ?? null}
onRatingChange={handleRatingChange}
/>
@@ -213,8 +219,10 @@ export default function SeriesContent({ item }: { item: ItemDetail & { type: "se
)
)}
</div>
{isAdmin && <EditMetadataDialog item={item} open={editOpen} onOpenChange={setEditOpen} />}
{isAdmin && (
{canCurateMetadata && (
<EditMetadataDialog item={item} open={editOpen} onOpenChange={setEditOpen} />
)}
{canCurateMetadata && (
<MatchItemDialog
key={item.content_id}
item={item}
@@ -71,6 +71,7 @@ interface ActionBarProps {
onEditMetadata?: () => void;
onMatchItem?: () => void;
isAdmin?: boolean;
canCurateMetadata?: boolean;
versions?: FileVersion[];
playbackVariants?: PlaybackVariant[];
selectedVersion?: FileVersion | null;
@@ -119,6 +120,7 @@ export default function ActionBar({
onEditMetadata,
onMatchItem,
isAdmin = false,
canCurateMetadata = false,
versions,
playbackVariants,
selectedVersion,
@@ -223,6 +225,10 @@ export default function ActionBar({
const hasOverflowActions = Boolean(
restartHref || onToggleWatchlist || onDownload || onSearchSubtitles,
);
const hasAdminActions = Boolean(isAdmin && (contentId || onRedetectIntro));
const hasMetadataActions = Boolean(
canCurateMetadata && (onRefresh || onEditMetadata || onMatchItem),
);
const formattedResumeTime = formatPlaybackTime(resumePositionSeconds ?? 0);
const percentComplete =
@@ -370,10 +376,10 @@ export default function ActionBar({
Search Subtitles
</DropdownMenuItem>
)}
{isAdmin && (
{(hasAdminActions || hasMetadataActions) && (
<>
{hasOverflowActions && <DropdownMenuSeparator />}
{contentId && (
{isAdmin && contentId && (
<DropdownMenuItem
onSelect={() =>
navigate(`/admin/history?media_item_id=${encodeURIComponent(contentId)}`)
@@ -382,7 +388,7 @@ export default function ActionBar({
View Play History
</DropdownMenuItem>
)}
{onRefresh && (
{canCurateMetadata && onRefresh && (
<DropdownMenuItem
disabled={isRefreshing}
onSelect={() => {
@@ -393,19 +399,19 @@ export default function ActionBar({
Refresh Metadata
</DropdownMenuItem>
)}
{onRedetectIntro && (
{isAdmin && onRedetectIntro && (
<DropdownMenuItem disabled={isRedetectingIntro} onSelect={onRedetectIntro}>
<RefreshCw className={`size-4 ${isRedetectingIntro ? "animate-spin" : ""}`} />
Re-detect Intro Markers
</DropdownMenuItem>
)}
{onEditMetadata && (
{canCurateMetadata && onEditMetadata && (
<DropdownMenuItem onSelect={onEditMetadata}>
<Pencil className="size-4" />
Edit Metadata
</DropdownMenuItem>
)}
{onMatchItem && (
{canCurateMetadata && onMatchItem && (
<DropdownMenuItem onSelect={onMatchItem}>
<Search className="size-4" />
Match Item