From 80ca23c546c38a36f5574c6327c51a4f899a3721 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 19:18:50 +0000 Subject: [PATCH] feat(media): collection, people with consent capture, and results views --- .../media/library/ConsentDialog.tsx | 151 ++++++++ .../media/library/CsvImportDialog.tsx | 354 ++++++++++++++++++ .../src/components/media/library/Funnel.tsx | 61 +++ .../media/library/MetricRecordDialog.tsx | 184 +++++++++ .../components/media/library/PersonCard.tsx | 194 ++++++++++ apps/web/src/pages/media/CollectionView.tsx | 147 +++++++- apps/web/src/pages/media/PeopleView.tsx | 167 ++++++++- apps/web/src/pages/media/ResultsView.tsx | 307 ++++++++++++++- 8 files changed, 1556 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/components/media/library/ConsentDialog.tsx create mode 100644 apps/web/src/components/media/library/CsvImportDialog.tsx create mode 100644 apps/web/src/components/media/library/Funnel.tsx create mode 100644 apps/web/src/components/media/library/MetricRecordDialog.tsx create mode 100644 apps/web/src/components/media/library/PersonCard.tsx diff --git a/apps/web/src/components/media/library/ConsentDialog.tsx b/apps/web/src/components/media/library/ConsentDialog.tsx new file mode 100644 index 0000000..b1eaadf --- /dev/null +++ b/apps/web/src/components/media/library/ConsentDialog.tsx @@ -0,0 +1,151 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import type { AgeVerificationMethod, MediaPerson } from "@rakazo/contracts"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Field, + FieldLabel, + Input, + NativeSelect, + NativeSelectOption, +} from "@rakazo/ui-web"; +import { useEffect, useId, useState } from "react"; +import type { ConsentForm } from "../../../lib/media-library"; +import { consentInput, isoToDay, todayDay } from "../../../lib/media-library"; +import { rpcErrorMessage } from "../../../lib/media-studio"; +import { rpc } from "../../../lib/rpc"; + +function initialForm(person: MediaPerson): ConsentForm { + return { + consentDate: isoToDay(person.consentRecordedAt) || todayDay(), + method: person.ageVerificationMethod, + verifiedDate: isoToDay(person.ageVerifiedAt) || todayDay(), + }; +} + +/** The only place consent and age proof are written; agents never reach it. */ +export function ConsentDialog({ + person, + onOpenChange, + onSaved, +}: { + person: MediaPerson | null; + onOpenChange: (open: boolean) => void; + onSaved: (person: MediaPerson) => void; +}) { + const { t } = useLingui(); + const consentId = useId(); + const methodId = useId(); + const verifiedId = useId(); + const [form, setForm] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + setForm(person ? initialForm(person) : null); + setError(null); + }, [person]); + + const methods: Record = { + platform_verified: t`Verified by platform`, + document_on_file: t`ID document on file`, + none: t`No age proof`, + }; + const input = person && form ? consentInput(person.id, form) : null; + const today = todayDay(); + + const save = async () => { + if (!input) return; + setBusy(true); + setError(null); + try { + onSaved(await rpc.media.people.recordConsent(input)); + onOpenChange(false); + } catch (cause) { + setError(rpcErrorMessage(cause, t`Could not save consent.`)); + } finally { + setBusy(false); + } + }; + + return ( + + + + + Consent · {person?.displayName ?? ""} + + + {form ? ( +
{ + event.preventDefault(); + void save(); + }} + > + + + Consent given on + + setForm({ ...form, consentDate: event.target.value })} + /> + + + + Age proof + + + setForm({ ...form, method: event.target.value as AgeVerificationMethod }) + } + > + {(Object.keys(methods) as AgeVerificationMethod[]).map((method) => ( + + {methods[method]} + + ))} + + + {form.method !== "none" ? ( + + + Verified on + + setForm({ ...form, verifiedDate: event.target.value })} + /> + + ) : null} + {error ? ( +

+ {error} +

+ ) : null} + + + +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/media/library/CsvImportDialog.tsx b/apps/web/src/components/media/library/CsvImportDialog.tsx new file mode 100644 index 0000000..b53b545 --- /dev/null +++ b/apps/web/src/components/media/library/CsvImportDialog.tsx @@ -0,0 +1,354 @@ +import { Plural, Trans, useLingui } from "@lingui/react/macro"; +import type { MetricCsvMapping } from "@rakazo/contracts"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Field, + FieldLabel, + Input, + NativeSelect, + NativeSelectOption, +} from "@rakazo/ui-web"; +import { useEffect, useId, useMemo, useState } from "react"; +import type { CsvDelimiter, MappingColumn } from "../../../lib/media-library"; +import { + csvHeaders, + firstCsvLine, + guessColumns, + guessDelimiter, + guessPublicationKey, + MAPPING_COLUMNS, +} from "../../../lib/media-library"; +import { rpcErrorMessage } from "../../../lib/media-studio"; +import { rpc } from "../../../lib/rpc"; + +const MAX_CSV_CHARS = 5_000_000; +const NEW = ""; + +type Draft = Omit & { + columns: Partial>; +}; + +function draftFrom(mapping: MetricCsvMapping): Draft { + return { ...mapping, columns: { ...mapping.columns } }; +} + +function toMapping(draft: Draft): MetricCsvMapping | null { + const { publication, observedAt } = draft.columns; + if (!publication || !observedAt || !draft.name.trim()) return null; + if (!/^[A-Za-z]{3}$/.test(draft.defaultCurrency)) return null; + const columns: MetricCsvMapping["columns"] = { publication, observedAt }; + for (const key of MAPPING_COLUMNS) { + const value = draft.columns[key]; + if (value) columns[key] = value; + } + return { + ...draft, + name: draft.name.trim(), + defaultCurrency: draft.defaultCurrency.toUpperCase(), + columns, + }; +} + +export function CsvImportDialog({ + open, + onOpenChange, + onImported, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onImported: () => void; +}) { + const { t } = useLingui(); + const fileId = useId(); + const mappingId = useId(); + const nameId = useId(); + const [mappings, setMappings] = useState([]); + const [csv, setCsv] = useState(null); + const [fileName, setFileName] = useState(""); + const [draft, setDraft] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState<{ + imported: number; + skipped: Array<{ row: number; reason: string }>; + } | null>(null); + + useEffect(() => { + if (!open) return; + setCsv(null); + setFileName(""); + setDraft(null); + setError(null); + setResult(null); + rpc.media.metrics + .mappings({}) + .then(setMappings) + .catch(() => setMappings([])); + }, [open]); + + const headers = useMemo( + () => (csv && draft ? csvHeaders(csv, draft.delimiter) : []), + [csv, draft?.delimiter, draft], + ); + + const labels: Record = { + publication: t`Publication`, + observedAt: t`Date`, + views: t`Views`, + clicks: t`Clicks`, + conversions: t`Purchases`, + revenue: t`Revenue`, + currency: t`Currency`, + }; + const keyLabels: Record = { + utm_content: t`UTM content`, + tracking_url: t`Tracking link`, + external_url: t`Post link`, + id: t`Publication ID`, + }; + + const readFile = async (file: File) => { + setError(null); + setResult(null); + if (file.size > MAX_CSV_CHARS) { + setError(t`This file is too large.`); + return; + } + const text = await file.text(); + setCsv(text); + setFileName(file.name); + const delimiter: CsvDelimiter = guessDelimiter(firstCsvLine(text)); + const found = csvHeaders(text, delimiter); + const matching = mappings.find((mapping) => + Object.values(mapping.columns).every((column) => !column || found.includes(column)), + ); + if (matching) { + setDraft(draftFrom(matching)); + return; + } + const columns = guessColumns(found); + setDraft({ + name: file.name.replace(/\.[^.]+$/, "").slice(0, 60) || t`Import`, + delimiter, + columns, + publicationKey: guessPublicationKey(columns.publication), + revenueUnit: "major", + defaultCurrency: "EUR", + }); + }; + + const mapping = draft ? toMapping(draft) : null; + + const runImport = async () => { + if (!csv || !mapping) return; + setBusy(true); + setError(null); + try { + const saved = await rpc.media.metrics.saveMapping(mapping); + if (!saved.id) throw new Error(t`Could not save the mapping.`); + const outcome = await rpc.media.metrics.importCsv({ mappingId: saved.id, csv }); + setResult(outcome); + onImported(); + } catch (cause) { + setError(rpcErrorMessage(cause, t`Import failed.`)); + } finally { + setBusy(false); + } + }; + + return ( + + + + + Import results + + + {result ? ( +
+

+ +

+ {result.skipped.length > 0 ? ( +
    + {result.skipped.slice(0, 20).map((skip) => ( +
  • + + Row {skip.row}: {skip.reason} + +
  • + ))} +
+ ) : null} + + + +
+ ) : ( +
{ + event.preventDefault(); + void runImport(); + }} + > + + + CSV file + + { + const file = event.target.files?.[0]; + if (file) void readFile(file); + }} + /> + + {draft && csv ? ( + <> + {mappings.length > 0 ? ( + + + Mapping + + { + const chosen = mappings.find((row) => row.id === event.target.value); + setDraft(chosen ? draftFrom(chosen) : { ...draft, id: undefined }); + }} + > + {t`New mapping`} + {mappings.map((row) => ( + + {row.name} + + ))} + + + ) : null} + + + Platform + + setDraft({ ...draft, name: event.target.value })} + /> + +
+ {MAPPING_COLUMNS.map((column) => ( + + {labels[column]} + + setDraft({ + ...draft, + columns: { + ...draft.columns, + [column]: event.target.value || undefined, + }, + }) + } + > + – + {headers.map((header) => ( + + {header} + + ))} + + + ))} + + + Publication is + + + setDraft({ + ...draft, + publicationKey: event.target.value as MetricCsvMapping["publicationKey"], + }) + } + > + {(Object.keys(keyLabels) as MetricCsvMapping["publicationKey"][]).map( + (key) => ( + + {keyLabels[key]} + + ), + )} + + + + + Revenue in + +
+ + setDraft({ + ...draft, + revenueUnit: event.target.value as MetricCsvMapping["revenueUnit"], + }) + } + > + 1.00 + 100 + + + setDraft({ ...draft, defaultCurrency: event.target.value.toUpperCase() }) + } + /> +
+
+
+ + ) : null} + {error ? ( +

+ {error} +

+ ) : null} + + + +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/media/library/Funnel.tsx b/apps/web/src/components/media/library/Funnel.tsx new file mode 100644 index 0000000..916a831 --- /dev/null +++ b/apps/web/src/components/media/library/Funnel.tsx @@ -0,0 +1,61 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import type { FunnelTotals } from "../../../lib/media-library"; +import { formatMoney, formatRate, funnelWidths } from "../../../lib/media-library"; + +/** Views → Clicks → Purchases → Revenue, each bar relative to views. */ +export function Funnel({ totals }: { totals: FunnelTotals }) { + const { t, i18n } = useLingui(); + const locale = i18n.locale; + const widths = funnelWidths(totals); + const number = (value: number) => new Intl.NumberFormat(locale).format(value); + const steps = [ + { key: "views", label: t`Views`, value: totals.views, width: widths.views, rate: null }, + { + key: "clicks", + label: t`Clicks`, + value: totals.clicks, + width: widths.clicks, + rate: formatRate(totals.clicks, totals.views, locale), + }, + { + key: "conversions", + label: t`Purchases`, + value: totals.conversions, + width: widths.conversions, + rate: formatRate(totals.conversions, totals.clicks, locale), + }, + ]; + return ( +
+ {steps.map((step) => ( +
+ {step.label} +
+
0 ? 1 : 0, step.width * 100)}%` }} + /> +
+ + {number(step.value)} + {step.rate ? {step.rate} : null} + +
+ ))} +
+ + Revenue + + + {totals.revenue.length === 0 + ? "–" + : totals.revenue.map((row) => formatMoney(row.cents, row.currency, locale)).join(" · ")} + +
+
+ ); +} diff --git a/apps/web/src/components/media/library/MetricRecordDialog.tsx b/apps/web/src/components/media/library/MetricRecordDialog.tsx new file mode 100644 index 0000000..df9b97d --- /dev/null +++ b/apps/web/src/components/media/library/MetricRecordDialog.tsx @@ -0,0 +1,184 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import type { MediaPublication } from "@rakazo/contracts"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Field, + FieldLabel, + Input, + NativeSelect, + NativeSelectOption, +} from "@rakazo/ui-web"; +import { useEffect, useId, useState } from "react"; +import { dayToIso, todayDay } from "../../../lib/media-library"; +import { rpcErrorMessage } from "../../../lib/media-studio"; +import { rpc } from "../../../lib/rpc"; + +type Counts = { views: string; clicks: string; conversions: string; revenue: string }; +const ZERO: Counts = { views: "0", clicks: "0", conversions: "0", revenue: "0" }; + +function count(value: string): number | null { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : null; +} + +/** Manual entry for platforms without an export. */ +export function MetricRecordDialog({ + open, + onOpenChange, + publications, + onRecorded, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + publications: readonly MediaPublication[]; + onRecorded: () => void; +}) { + const { t } = useLingui(); + const ids = { publication: useId(), day: useId(), currency: useId() }; + const [publicationId, setPublicationId] = useState(""); + const [day, setDay] = useState(todayDay()); + const [counts, setCounts] = useState(ZERO); + const [currency, setCurrency] = useState("EUR"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setPublicationId(publications[0]?.id ?? ""); + setDay(todayDay()); + setCounts(ZERO); + setError(null); + }, [open, publications]); + + const revenue = Number(counts.revenue.replace(",", ".")); + const values = { + views: count(counts.views), + clicks: count(counts.clicks), + conversions: count(counts.conversions), + }; + const observedAt = dayToIso(day); + const valid = + publicationId !== "" && + observedAt !== null && + values.views !== null && + values.clicks !== null && + values.conversions !== null && + Number.isFinite(revenue) && + revenue >= 0 && + /^[A-Z]{3}$/.test(currency); + + const save = async () => { + if (!valid || !observedAt) return; + setBusy(true); + setError(null); + try { + await rpc.media.metrics.record({ + publicationId, + observedAt, + views: values.views ?? 0, + clicks: values.clicks ?? 0, + conversions: values.conversions ?? 0, + revenueCents: Math.round(revenue * 100), + currency, + }); + onOpenChange(false); + onRecorded(); + } catch (cause) { + setError(rpcErrorMessage(cause, t`Could not save.`)); + } finally { + setBusy(false); + } + }; + + const numberField = (key: keyof Counts, label: string) => ( + + {label} + setCounts({ ...counts, [key]: event.target.value })} + /> + + ); + + return ( + + + + + Enter results + + +
{ + event.preventDefault(); + void save(); + }} + > + + + Publication + + setPublicationId(event.target.value)} + > + {publications.map((publication) => ( + + {publication.title ?? publication.profileId} + + ))} + + + + + Date + + setDay(event.target.value)} + /> + +
+ {numberField("views", t`Views`)} + {numberField("clicks", t`Clicks`)} + {numberField("conversions", t`Purchases`)} +
+ {numberField("revenue", t`Revenue`)} + setCurrency(event.target.value.toUpperCase())} + /> +
+
+ {error ? ( +

+ {error} +

+ ) : null} + + + +
+
+
+ ); +} diff --git a/apps/web/src/components/media/library/PersonCard.tsx b/apps/web/src/components/media/library/PersonCard.tsx new file mode 100644 index 0000000..f686e87 --- /dev/null +++ b/apps/web/src/components/media/library/PersonCard.tsx @@ -0,0 +1,194 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import type { AnonymizeMode, MediaPerson } from "@rakazo/contracts"; +import { AnonymizeModeSchema } from "@rakazo/contracts"; +import { + Badge, + Button, + Checkbox, + cn, + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + Input, + NativeSelect, + NativeSelectOption, +} from "@rakazo/ui-web"; +import { Check, MoreHorizontal, ShieldAlert, ShieldCheck } from "lucide-react"; +import { useState } from "react"; +import { consentState } from "../../../lib/media-library"; +import { AssetThumb } from "./AssetTile"; + +export function useAnonymizeLabels(): Record { + const { t } = useLingui(); + return { + none: t`Visible`, + blur: t`Blur`, + pixelate: t`Pixelate`, + solid: t`Solid color`, + sticker: t`Sticker`, + partial_eyes: t`Eye bar`, + partial_upper: t`Upper face`, + partial_lower: t`Lower face`, + reframe_out: t`Crop out`, + synthetic_identity: t`Synthetic face (experimental)`, + }; +} + +export function PersonCard({ + person, + selected, + onSelect, + syntheticAvailable, + onUpdate, + onConsent, + onRemove, +}: { + person: MediaPerson; + selected: boolean; + onSelect: (selected: boolean) => void; + syntheticAvailable: boolean; + onUpdate: (patch: { + displayName?: string; + isSelf?: boolean; + confirmed?: boolean; + anonymizeByDefault?: AnonymizeMode; + }) => void; + onConsent: () => void; + onRemove: () => void; +}) { + const { t } = useLingui(); + const modes = useAnonymizeLabels(); + const [name, setName] = useState(null); + const state = consentState(person); + + const commitName = () => { + const trimmed = name?.trim(); + setName(null); + if (trimmed && trimmed !== person.displayName) onUpdate({ displayName: trimmed }); + }; + + return ( +
+
+
+ +
+
+ {name !== null ? ( + setName(event.target.value)} + onBlur={commitName} + onKeyDown={(event) => { + if (event.key === "Enter") commitName(); + if (event.key === "Escape") setName(null); + }} + /> + ) : ( + + )} +
+ {person.isSelf ? ( + + You + + ) : null} + {person.faceTrackCount} appearances +
+
+ onSelect(checked === true)} + /> + + } + > + + + + onUpdate({ isSelf: checked === true })} + > + This is me + + + + Delete face data + + + +
+ + onUpdate({ anonymizeByDefault: event.target.value as AnonymizeMode })} + > + {AnonymizeModeSchema.options.map((mode) => ( + + {modes[mode]} + + ))} + + +
+ {person.confirmed ? ( + + + ) : ( + + )} + +
+
+ ); +} diff --git a/apps/web/src/pages/media/CollectionView.tsx b/apps/web/src/pages/media/CollectionView.tsx index b155058..e9bdcfa 100644 --- a/apps/web/src/pages/media/CollectionView.tsx +++ b/apps/web/src/pages/media/CollectionView.tsx @@ -1,6 +1,147 @@ +import { Plural, Trans, useLingui } from "@lingui/react/macro"; +import type { MediaAsset, MediaCollection } from "@rakazo/contracts"; +import { Button, Input } from "@rakazo/ui-web"; +import { Pencil } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { LoadingState } from "../../components/ai/primitives"; +import { AssetGrid, useAssetSelection } from "../../components/media/library/AssetGrid"; +import { CreateProjectDialog } from "../../components/media/library/CreateProjectDialog"; +import { collectionDateRange } from "../../components/media/library/LibraryChrome"; +import { formatDuration } from "../../lib/media-library"; +import { rpcErrorMessage } from "../../lib/media-studio"; +import { rpc } from "../../lib/rpc"; import type { MediaViewProps } from "./nav"; -/** Placeholder; the W3-web-library slice replaces this view. */ -export function CollectionView(_props: MediaViewProps & Record) { - return null; +export function CollectionView({ + navigate, + collectionId, +}: MediaViewProps & { collectionId: string }) { + const { t } = useLingui(); + const [data, setData] = useState<{ collection: MediaCollection; assets: MediaAsset[] } | null>( + null, + ); + const [error, setError] = useState(null); + const [editing, setEditing] = useState(null); + const [createOpen, setCreateOpen] = useState(false); + const selection = useAssetSelection(); + + useEffect(() => { + let active = true; + setData(null); + setError(null); + rpc.media.collections + .get({ collectionId }) + .then((value) => { + if (active) setData(value); + }) + .catch((cause) => { + if (active) setError(rpcErrorMessage(cause, t`Could not load this session.`)); + }); + return () => { + active = false; + }; + }, [collectionId, t]); + + const rename = useCallback( + async (title: string) => { + setEditing(null); + const trimmed = title.trim(); + if (!data || !trimmed || trimmed === data.collection.title) return; + try { + const collection = await rpc.media.collections.rename({ collectionId, title: trimmed }); + setData((current) => (current ? { ...current, collection } : current)); + } catch (cause) { + setError(rpcErrorMessage(cause, t`Could not rename.`)); + } + }, + [collectionId, data, t], + ); + + if (!data) { + return ( +
+ {error ?? } +
+ ); + } + + const { collection, assets } = data; + const selectedIds = [...selection.selected]; + const range = collectionDateRange(collection); + + return ( +
+
+
+ {editing !== null ? ( + setEditing(event.target.value)} + onBlur={() => void rename(editing)} + onKeyDown={(event) => { + if (event.key === "Enter") void rename(editing); + if (event.key === "Escape") { + event.stopPropagation(); + setEditing(null); + } + }} + /> + ) : ( +
+

+ {collection.title} +

+ +
+ )} +
+ {range ? `${range} · ` : ""} + + {collection.totalDurationMs > 0 + ? ` · ${formatDuration(collection.totalDurationMs)}` + : ""} +
+
+
+ {selectedIds.length > 0 ? ( + + ) : null} + +
+
+ {error ? ( +

+ {error} +

+ ) : null} + + 0 ? [] : [collection.id]} + assetIds={selectedIds} + onCreated={(projectId) => navigate({ name: "project", projectId })} + /> +
+ ); } diff --git a/apps/web/src/pages/media/PeopleView.tsx b/apps/web/src/pages/media/PeopleView.tsx index 60d71a2..2dbbaca 100644 --- a/apps/web/src/pages/media/PeopleView.tsx +++ b/apps/web/src/pages/media/PeopleView.tsx @@ -1,6 +1,167 @@ +import { Plural, Trans, useLingui } from "@lingui/react/macro"; +import type { MediaPerson } from "@rakazo/contracts"; +import { Button } from "@rakazo/ui-web"; +import { useCallback, useEffect, useState } from "react"; +import { LoadingState } from "../../components/ai/primitives"; +import { ConfirmDialog } from "../../components/media/library/ConfirmDialog"; +import { ConsentDialog } from "../../components/media/library/ConsentDialog"; +import { LibraryModeToggle } from "../../components/media/library/LibraryChrome"; +import { PersonCard } from "../../components/media/library/PersonCard"; +import { rpcErrorMessage, useMediaStudio } from "../../lib/media-studio"; +import { rpc } from "../../lib/rpc"; import type { MediaViewProps } from "./nav"; -/** Placeholder; the W3-web-library slice replaces this view. */ -export function PeopleView(_props: MediaViewProps & Record) { - return null; +export function PeopleView({ navigate }: MediaViewProps) { + const { t } = useLingui(); + const { capabilities } = useMediaStudio(); + const [people, setPeople] = useState(null); + const [error, setError] = useState(null); + const [selected, setSelected] = useState>(() => new Set()); + const [consentFor, setConsentFor] = useState(null); + const [removing, setRemoving] = useState(null); + const [merging, setMerging] = useState(false); + const syntheticAvailable = capabilities?.operations.synthesizeIdentity?.available === true; + + const load = useCallback(async () => { + try { + setPeople(await rpc.media.people.list({})); + } catch (cause) { + setError(rpcErrorMessage(cause, t`Could not load people.`)); + setPeople((current) => current ?? []); + } + }, [t]); + + useEffect(() => { + void load(); + }, [load]); + + const replace = (person: MediaPerson) => + setPeople((current) => current?.map((row) => (row.id === person.id ? person : row)) ?? null); + + const act = async (action: () => Promise, fallback: string) => { + setError(null); + try { + await action(); + } catch (cause) { + setError(rpcErrorMessage(cause, fallback)); + } + }; + + const merge = () => + act(async () => { + const merged = await rpc.media.people.merge({ personIds: [...selected] }); + setSelected(new Set()); + await load(); + replace(merged); + }, t`Could not merge.`); + + return ( +
+
+ { + if (next !== "people") navigate({ name: "library", mode: next }); + }} + /> + {selected.size >= 2 ? ( +
+ + +
+ ) : null} +
+ {error ? ( +

+ {error} +

+ ) : null} + {people === null ? ( +
+ +
+ ) : people.length === 0 ? ( +
+ No people found yet. +
+ ) : ( +
+
+ {people.map((person) => ( + + setSelected((current) => { + const next = new Set(current); + if (value) next.add(person.id); + else next.delete(person.id); + return next; + }) + } + onUpdate={(patch) => + void act( + async () => + replace(await rpc.media.people.update({ personId: person.id, ...patch })), + t`Could not save.`, + ) + } + onConsent={() => setConsentFor(person)} + onRemove={() => setRemoving(person)} + /> + ))} +
+
+ )} + + { + if (!open) setConsentFor(null); + }} + onSaved={replace} + /> + } + description={Their appearances become one person.} + action={Merge} + onCancel={() => setMerging(false)} + onConfirm={() => { + setMerging(false); + void merge(); + }} + /> + Delete face data of {removing?.displayName ?? ""}?} + description={ + Face tracks, recognition data and marks of this person are deleted. + } + action={Delete} + onCancel={() => setRemoving(null)} + onConfirm={() => { + const target = removing; + setRemoving(null); + if (!target) return; + void act(async () => { + await rpc.media.people.remove({ personId: target.id }); + setPeople((current) => current?.filter((row) => row.id !== target.id) ?? null); + setSelected((current) => { + const next = new Set(current); + next.delete(target.id); + return next; + }); + }, t`Could not delete.`); + }} + /> +
+ ); } diff --git a/apps/web/src/pages/media/ResultsView.tsx b/apps/web/src/pages/media/ResultsView.tsx index f36273d..bc6dcc0 100644 --- a/apps/web/src/pages/media/ResultsView.tsx +++ b/apps/web/src/pages/media/ResultsView.tsx @@ -1,6 +1,307 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import type { + FunnelRow, + MediaProject, + MediaPublication, + SegmentFunnelRow, +} from "@rakazo/contracts"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + NativeSelect, + NativeSelectOption, +} from "@rakazo/ui-web"; +import { MoreHorizontal, Upload } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { LoadingState, SuccessPop } from "../../components/ai/primitives"; +import { AssetThumb } from "../../components/media/library/AssetTile"; +import { ConfirmDialog } from "../../components/media/library/ConfirmDialog"; +import { CsvImportDialog } from "../../components/media/library/CsvImportDialog"; +import { Funnel } from "../../components/media/library/Funnel"; +import { MetricRecordDialog } from "../../components/media/library/MetricRecordDialog"; +import { formatDuration, formatMoney, funnelTotals } from "../../lib/media-library"; +import { rpcErrorMessage } from "../../lib/media-studio"; +import { rpc } from "../../lib/rpc"; import type { MediaViewProps } from "./nav"; -/** Placeholder; the W3-web-library slice replaces this view. */ -export function ResultsView(_props: MediaViewProps & Record) { - return null; +const ALL = ""; + +type Data = { + funnel: FunnelRow[]; + segments: SegmentFunnelRow[]; + publications: MediaPublication[]; +}; + +export function ResultsView({ navigate }: MediaViewProps) { + const { t, i18n } = useLingui(); + const [projects, setProjects] = useState([]); + const [projectId, setProjectId] = useState(ALL); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [importOpen, setImportOpen] = useState(false); + const [recordOpen, setRecordOpen] = useState(false); + const [resetOpen, setResetOpen] = useState(false); + const [resetDone, setResetDone] = useState(false); + + useEffect(() => { + rpc.media.projects + .list({ cursor: null, limit: 100 }) + .then((page) => setProjects(page.items)) + .catch(() => undefined); + }, []); + + const load = useCallback(async () => { + const scope = projectId === ALL ? {} : { projectId }; + try { + const [funnel, segments, publications] = await Promise.all([ + rpc.media.metrics.funnel(scope), + rpc.media.metrics.segments(scope), + rpc.media.publications.list({ ...scope, cursor: null, limit: 500 }), + ]); + setData({ funnel, segments, publications: publications.items }); + setError(null); + } catch (cause) { + setError(rpcErrorMessage(cause, t`Could not load results.`)); + setData((current) => current ?? { funnel: [], segments: [], publications: [] }); + } + }, [projectId, t]); + + useEffect(() => { + void load(); + }, [load]); + + const totals = useMemo(() => funnelTotals(data?.funnel ?? []), [data]); + const projectTitle = useMemo( + () => new Map(projects.map((project) => [project.id, project.title])), + [projects], + ); + const locale = i18n.locale; + const number = (value: number) => new Intl.NumberFormat(locale).format(value); + const hasPublications = (data?.publications.length ?? 0) > 0; + + return ( +
+
+ {projects.length > 0 ? ( + setProjectId(event.target.value)} + > + {t`All projects`} + {projects.map((project) => ( + + {project.title} + + ))} + + ) : null} +
+ {resetDone ? : null} + + + } + > + + + + setRecordOpen(true)}> + Enter results + + + setResetOpen(true)}> + Reset teaser model + + + +
+
+ {error ? ( +

+ {error} +

+ ) : null} + + {data === null ? ( +
+ +
+ ) : data.funnel.length === 0 ? ( +
+ No results yet. +
+ ) : ( +
+ + +
+

+ Publications +

+
+ + + + + + + + Views + + + Clicks + + + Purchases + + + Revenue + + + + + {data.funnel.map((row) => ( + + + + + {number(row.views)} + {number(row.clicks)} + {number(row.conversions)} + {formatMoney(row.revenueCents, row.currency, locale)} + + ))} + +
+ Project + + Platform + + Variant +
+ + {row.profileId} + {[row.variant, row.thumbnailVariant].filter(Boolean).join(" · ") || "–"} +
+
+
+ + {data.segments.length > 0 ? ( +
+

+ Teaser segments +

+
+ + + + + + Uses + + + Views + + + Clicks + + + Purchases + + + Revenue + + + + + {data.segments.map((row) => ( + + + {number(row.publications)} + {number(row.views)} + {number(row.clicks)} + {number(row.conversions)} + + {formatMoney(row.revenueCents, row.currency, locale)} + + + ))} + +
+ Segment +
+
+
+ +
+ + {formatDuration(row.startMs)}–{formatDuration(row.endMs)} + +
+
+
+
+ ) : null} +
+ )} + + void load()} + /> + void load()} + /> + Reset teaser model?} + description={Teaser picks go back to the default weights.} + action={Reset} + onCancel={() => setResetOpen(false)} + onConfirm={() => { + setResetOpen(false); + setResetDone(false); + rpc.media.metrics + .resetTeaserModel({}) + .then(() => setResetDone(true)) + .catch((cause) => setError(rpcErrorMessage(cause, t`Could not reset.`))); + }} + /> +
+ ); +} + +function NumberHead({ children }: { children: React.ReactNode }) { + return {children}; +} + +function NumberCell({ children }: { children: React.ReactNode }) { + return {children}; }