evOo-local/apps/web/src/pages/media/MediaStudio.tsx

193 lines
6.4 KiB
TypeScript

import { Trans, useLingui } from "@lingui/react/macro";
import type { MediaProject } from "@rakazo/contracts";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Tabs,
TabsList,
TabsTrigger,
} from "@rakazo/ui-web";
import { ChevronDown, ChevronLeft, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { MediaStudioContext, useMediaCapabilities } from "../../lib/media-studio";
import { rpc } from "../../lib/rpc";
import { BrandKitView } from "./BrandKitView";
import { CollectionView } from "./CollectionView";
import { EditorView } from "./EditorView";
import { LibraryView } from "./LibraryView";
import type { MediaSection, MediaView } from "./nav";
import { pushView, sectionOf } from "./nav";
import { PeopleView } from "./PeopleView";
import { ProjectView } from "./ProjectView";
import { ResultsView } from "./ResultsView";
import { ReviewView } from "./ReviewView";
function ViewSwitch({
view,
navigate,
onClose,
}: {
view: MediaView;
navigate: (view: MediaView) => void;
onClose: () => void;
}) {
const props = { navigate, onClose };
switch (view.name) {
case "library":
return <LibraryView {...props} mode={view.mode} />;
case "collection":
return <CollectionView {...props} collectionId={view.collectionId} />;
case "people":
return <PeopleView {...props} />;
case "results":
return <ResultsView {...props} />;
case "project":
return <ProjectView {...props} projectId={view.projectId} />;
case "editor":
return <EditorView {...props} projectId={view.projectId} planId={view.planId} />;
case "review":
return <ReviewView {...props} projectId={view.projectId} renderId={view.renderId} />;
case "brandKit":
return <BrandKitView {...props} brandKitId={view.brandKitId} />;
}
}
function ProjectsMenu({
navigate,
refreshKey,
}: {
navigate: (view: MediaView) => void;
refreshKey: unknown;
}) {
const [projects, setProjects] = useState<MediaProject[]>([]);
useEffect(() => {
void refreshKey;
let active = true;
rpc.media.projects
.list({ cursor: null, limit: 20 })
.then((page) => {
if (active) setProjects(page.items.filter((project) => project.status !== "archived"));
})
.catch(() => undefined);
return () => {
active = false;
};
}, [refreshKey]);
if (projects.length === 0) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="ghost" size="sm" data-testid="media-projects-menu" />}
>
<Trans>Projects</Trans>
<ChevronDown data-icon="inline-end" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-80 w-64 overflow-y-auto">
{projects.map((project) => (
<DropdownMenuItem
key={project.id}
onClick={() => navigate({ name: "project", projectId: project.id })}
>
<span className="truncate" dir="auto">
{project.title}
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
/** Media Studio surface: a full-window layer with its own back stack. */
export function MediaStudio({
onClose,
canLinkFolder = false,
}: {
onClose: () => void;
/** Deployment owner only: folder sources live on the host. */
canLinkFolder?: boolean;
}) {
const { t } = useLingui();
const capabilities = useMediaCapabilities();
const [stack, setStack] = useState<MediaView[]>([{ name: "library" }]);
const view = stack.at(-1) ?? { name: "library" };
const section = sectionOf(view);
const navigate = useCallback(
(next: MediaView) => setStack((current) => pushView(current, next)),
[],
);
const back = useCallback(
() => setStack((current) => (current.length > 1 ? current.slice(0, -1) : current)),
[],
);
const env = useMemo(() => ({ capabilities, canLinkFolder }), [capabilities, canLinkFolder]);
const notConfigured = capabilities !== null && !capabilities.configured;
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent
data-testid="media-studio"
data-view={view.name}
showCloseButton={false}
className="inset-0 top-0 left-0 flex h-full w-full max-w-none translate-x-0 translate-y-0 flex-col gap-0 rounded-none bg-background p-0 text-foreground ring-0 sm:max-w-none"
>
<header className="flex items-center gap-2 border-b border-sidebar-border px-3 py-2.5 md:px-[18px]">
{stack.length > 1 ? (
<Button variant="ghost" size="icon-sm" aria-label={t`Back`} onClick={back}>
<ChevronLeft />
</Button>
) : null}
<DialogTitle className="text-ui-body font-medium text-foreground">
<Trans>Media</Trans>
</DialogTitle>
<div className="flex min-w-0 flex-1 justify-center">
<Tabs
value={section ?? ""}
onValueChange={(value) => navigate({ name: value as MediaSection })}
>
<TabsList variant="line" aria-label={t`Media Studio`}>
<TabsTrigger value="library">
<Trans>Library</Trans>
</TabsTrigger>
<TabsTrigger value="results">
<Trans>Results</Trans>
</TabsTrigger>
<TabsTrigger value="brandKit">
<Trans>Brand kit</Trans>
</TabsTrigger>
</TabsList>
</Tabs>
</div>
<ProjectsMenu navigate={navigate} refreshKey={view} />
<DialogClose aria-label={t`Close`} render={<Button variant="ghost" size="icon-sm" />}>
<X />
</DialogClose>
</header>
<MediaStudioContext.Provider value={env}>
<main className="flex min-h-0 flex-1 flex-col">
{notConfigured ? (
<div className="grid flex-1 place-items-center px-8 text-center text-ui-body text-muted-foreground">
<Trans>Media Studio is not set up on this server.</Trans>
</div>
) : (
<ViewSwitch view={view} navigate={navigate} onClose={onClose} />
)}
</main>
</MediaStudioContext.Provider>
</DialogContent>
</Dialog>
);
}