feat(media): collection, people with consent capture, and results views
This commit is contained in:
parent
9a3423e458
commit
80ca23c546
151
apps/web/src/components/media/library/ConsentDialog.tsx
Normal file
151
apps/web/src/components/media/library/ConsentDialog.tsx
Normal file
@ -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<ConsentForm | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setForm(person ? initialForm(person) : null);
|
||||||
|
setError(null);
|
||||||
|
}, [person]);
|
||||||
|
|
||||||
|
const methods: Record<AgeVerificationMethod, string> = {
|
||||||
|
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 (
|
||||||
|
<Dialog open={person !== null} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent data-testid="media-consent" className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Consent · {person?.displayName ?? ""}</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{form ? (
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void save();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={consentId}>
|
||||||
|
<Trans>Consent given on</Trans>
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={consentId}
|
||||||
|
type="date"
|
||||||
|
max={today}
|
||||||
|
value={form.consentDate}
|
||||||
|
onChange={(event) => setForm({ ...form, consentDate: event.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={methodId}>
|
||||||
|
<Trans>Age proof</Trans>
|
||||||
|
</FieldLabel>
|
||||||
|
<NativeSelect
|
||||||
|
id={methodId}
|
||||||
|
className="w-full"
|
||||||
|
value={form.method}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm({ ...form, method: event.target.value as AgeVerificationMethod })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(Object.keys(methods) as AgeVerificationMethod[]).map((method) => (
|
||||||
|
<NativeSelectOption key={method} value={method}>
|
||||||
|
{methods[method]}
|
||||||
|
</NativeSelectOption>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
</Field>
|
||||||
|
{form.method !== "none" ? (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={verifiedId}>
|
||||||
|
<Trans>Verified on</Trans>
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={verifiedId}
|
||||||
|
type="date"
|
||||||
|
max={today}
|
||||||
|
value={form.verifiedDate}
|
||||||
|
onChange={(event) => setForm({ ...form, verifiedDate: event.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="text-ui-body-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={busy || !input}>
|
||||||
|
<Trans>Save</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
354
apps/web/src/components/media/library/CsvImportDialog.tsx
Normal file
354
apps/web/src/components/media/library/CsvImportDialog.tsx
Normal file
@ -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<MetricCsvMapping, "columns"> & {
|
||||||
|
columns: Partial<Record<MappingColumn, string>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<MetricCsvMapping[]>([]);
|
||||||
|
const [csv, setCsv] = useState<string | null>(null);
|
||||||
|
const [fileName, setFileName] = useState("");
|
||||||
|
const [draft, setDraft] = useState<Draft | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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<MappingColumn, string> = {
|
||||||
|
publication: t`Publication`,
|
||||||
|
observedAt: t`Date`,
|
||||||
|
views: t`Views`,
|
||||||
|
clicks: t`Clicks`,
|
||||||
|
conversions: t`Purchases`,
|
||||||
|
revenue: t`Revenue`,
|
||||||
|
currency: t`Currency`,
|
||||||
|
};
|
||||||
|
const keyLabels: Record<MetricCsvMapping["publicationKey"], string> = {
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent data-testid="media-csv-import" className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Import results</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{result ? (
|
||||||
|
<div className="flex flex-col gap-2 text-ui-body-sm">
|
||||||
|
<p data-testid="media-csv-imported">
|
||||||
|
<Plural value={result.imported} one="# row imported" other="# rows imported" />
|
||||||
|
</p>
|
||||||
|
{result.skipped.length > 0 ? (
|
||||||
|
<ul className="max-h-40 overflow-y-auto text-muted-foreground">
|
||||||
|
{result.skipped.slice(0, 20).map((skip) => (
|
||||||
|
<li key={skip.row}>
|
||||||
|
<Trans>
|
||||||
|
Row {skip.row}: {skip.reason}
|
||||||
|
</Trans>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => onOpenChange(false)}>
|
||||||
|
<Trans>Done</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void runImport();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={fileId}>
|
||||||
|
<Trans>CSV file</Trans>
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={fileId}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv,text/plain"
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) void readFile(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{draft && csv ? (
|
||||||
|
<>
|
||||||
|
{mappings.length > 0 ? (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={mappingId}>
|
||||||
|
<Trans>Mapping</Trans>
|
||||||
|
</FieldLabel>
|
||||||
|
<NativeSelect
|
||||||
|
id={mappingId}
|
||||||
|
className="w-full"
|
||||||
|
value={draft.id ?? NEW}
|
||||||
|
onChange={(event) => {
|
||||||
|
const chosen = mappings.find((row) => row.id === event.target.value);
|
||||||
|
setDraft(chosen ? draftFrom(chosen) : { ...draft, id: undefined });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NativeSelectOption value={NEW}>{t`New mapping`}</NativeSelectOption>
|
||||||
|
{mappings.map((row) => (
|
||||||
|
<NativeSelectOption key={row.id} value={row.id ?? NEW}>
|
||||||
|
{row.name}
|
||||||
|
</NativeSelectOption>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={nameId}>
|
||||||
|
<Trans>Platform</Trans>
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={nameId}
|
||||||
|
value={draft.name}
|
||||||
|
maxLength={60}
|
||||||
|
onChange={(event) => setDraft({ ...draft, name: event.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{MAPPING_COLUMNS.map((column) => (
|
||||||
|
<Field key={column}>
|
||||||
|
<FieldLabel htmlFor={`${mappingId}-${column}`}>{labels[column]}</FieldLabel>
|
||||||
|
<NativeSelect
|
||||||
|
id={`${mappingId}-${column}`}
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
value={draft.columns[column] ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
columns: {
|
||||||
|
...draft.columns,
|
||||||
|
[column]: event.target.value || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<NativeSelectOption value="">–</NativeSelectOption>
|
||||||
|
{headers.map((header) => (
|
||||||
|
<NativeSelectOption key={header} value={header}>
|
||||||
|
{header}
|
||||||
|
</NativeSelectOption>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
</Field>
|
||||||
|
))}
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={`${mappingId}-key`}>
|
||||||
|
<Trans>Publication is</Trans>
|
||||||
|
</FieldLabel>
|
||||||
|
<NativeSelect
|
||||||
|
id={`${mappingId}-key`}
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
value={draft.publicationKey}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
publicationKey: event.target.value as MetricCsvMapping["publicationKey"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(Object.keys(keyLabels) as MetricCsvMapping["publicationKey"][]).map(
|
||||||
|
(key) => (
|
||||||
|
<NativeSelectOption key={key} value={key}>
|
||||||
|
{keyLabels[key]}
|
||||||
|
</NativeSelectOption>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</NativeSelect>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={`${mappingId}-unit`}>
|
||||||
|
<Trans>Revenue in</Trans>
|
||||||
|
</FieldLabel>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<NativeSelect
|
||||||
|
id={`${mappingId}-unit`}
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
value={draft.revenueUnit}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
revenueUnit: event.target.value as MetricCsvMapping["revenueUnit"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<NativeSelectOption value="major">1.00</NativeSelectOption>
|
||||||
|
<NativeSelectOption value="cents">100</NativeSelectOption>
|
||||||
|
</NativeSelect>
|
||||||
|
<Input
|
||||||
|
aria-label={t`Currency`}
|
||||||
|
className="h-7 w-16 uppercase"
|
||||||
|
maxLength={3}
|
||||||
|
value={draft.defaultCurrency}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft({ ...draft, defaultCurrency: event.target.value.toUpperCase() })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="text-ui-body-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={busy || !mapping || !csv}>
|
||||||
|
{fileName ? <Trans>Import {fileName}</Trans> : <Trans>Import</Trans>}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
apps/web/src/components/media/library/Funnel.tsx
Normal file
61
apps/web/src/components/media/library/Funnel.tsx
Normal file
@ -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 (
|
||||||
|
<section
|
||||||
|
data-testid="media-funnel"
|
||||||
|
aria-label={t`Funnel`}
|
||||||
|
className="flex flex-col gap-2 rounded-xl border border-border bg-card p-4"
|
||||||
|
>
|
||||||
|
{steps.map((step) => (
|
||||||
|
<div key={step.key} className="grid grid-cols-[6.5rem_1fr_auto] items-center gap-3">
|
||||||
|
<span className="text-ui-body-sm text-muted-foreground">{step.label}</span>
|
||||||
|
<div className="h-6 overflow-hidden rounded-md bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-md bg-primary"
|
||||||
|
style={{ width: `${Math.max(step.value > 0 ? 1 : 0, step.width * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="min-w-24 text-right font-mono text-ui-body-sm tabular-nums">
|
||||||
|
{number(step.value)}
|
||||||
|
{step.rate ? <span className="ml-2 text-muted-foreground">{step.rate}</span> : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="grid grid-cols-[6.5rem_1fr] items-center gap-3">
|
||||||
|
<span className="text-ui-body-sm text-muted-foreground">
|
||||||
|
<Trans>Revenue</Trans>
|
||||||
|
</span>
|
||||||
|
<span className="text-right font-mono text-ui-body tabular-nums font-semibold">
|
||||||
|
{totals.revenue.length === 0
|
||||||
|
? "–"
|
||||||
|
: totals.revenue.map((row) => formatMoney(row.cents, row.currency, locale)).join(" · ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
apps/web/src/components/media/library/MetricRecordDialog.tsx
Normal file
184
apps/web/src/components/media/library/MetricRecordDialog.tsx
Normal file
@ -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<Counts>(ZERO);
|
||||||
|
const [currency, setCurrency] = useState("EUR");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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) => (
|
||||||
|
<Field key={key}>
|
||||||
|
<FieldLabel htmlFor={`${ids.publication}-${key}`}>{label}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={`${ids.publication}-${key}`}
|
||||||
|
inputMode={key === "revenue" ? "decimal" : "numeric"}
|
||||||
|
value={counts[key]}
|
||||||
|
onChange={(event) => setCounts({ ...counts, [key]: event.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent data-testid="media-metric-record" className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Enter results</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-3"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void save();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={ids.publication}>
|
||||||
|
<Trans>Publication</Trans>
|
||||||
|
</FieldLabel>
|
||||||
|
<NativeSelect
|
||||||
|
id={ids.publication}
|
||||||
|
className="w-full"
|
||||||
|
value={publicationId}
|
||||||
|
onChange={(event) => setPublicationId(event.target.value)}
|
||||||
|
>
|
||||||
|
{publications.map((publication) => (
|
||||||
|
<NativeSelectOption key={publication.id} value={publication.id}>
|
||||||
|
{publication.title ?? publication.profileId}
|
||||||
|
</NativeSelectOption>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={ids.day}>
|
||||||
|
<Trans>Date</Trans>
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={ids.day}
|
||||||
|
type="date"
|
||||||
|
max={todayDay()}
|
||||||
|
value={day}
|
||||||
|
onChange={(event) => setDay(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{numberField("views", t`Views`)}
|
||||||
|
{numberField("clicks", t`Clicks`)}
|
||||||
|
{numberField("conversions", t`Purchases`)}
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
{numberField("revenue", t`Revenue`)}
|
||||||
|
<Input
|
||||||
|
id={ids.currency}
|
||||||
|
aria-label={t`Currency`}
|
||||||
|
className="w-16 uppercase"
|
||||||
|
maxLength={3}
|
||||||
|
value={currency}
|
||||||
|
onChange={(event) => setCurrency(event.target.value.toUpperCase())}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="text-ui-body-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={busy || !valid}>
|
||||||
|
<Trans>Save</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
194
apps/web/src/components/media/library/PersonCard.tsx
Normal file
194
apps/web/src/components/media/library/PersonCard.tsx
Normal file
@ -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<AnonymizeMode, string> {
|
||||||
|
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<string | null>(null);
|
||||||
|
const state = consentState(person);
|
||||||
|
|
||||||
|
const commitName = () => {
|
||||||
|
const trimmed = name?.trim();
|
||||||
|
setName(null);
|
||||||
|
if (trimmed && trimmed !== person.displayName) onUpdate({ displayName: trimmed });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="media-person"
|
||||||
|
data-person-id={person.id}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-2.5 rounded-xl border border-border bg-card p-3",
|
||||||
|
selected && "ring-2 ring-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative size-12 shrink-0 overflow-hidden rounded-full bg-muted">
|
||||||
|
<AssetThumb
|
||||||
|
assetId={person.coverThumbAssetId}
|
||||||
|
hasThumbnail={person.coverThumbAssetId !== null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{name !== null ? (
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
aria-label={t`Name`}
|
||||||
|
value={name}
|
||||||
|
maxLength={60}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
onBlur={commitName}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") commitName();
|
||||||
|
if (event.key === "Escape") setName(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="max-w-full truncate text-left text-ui-body font-medium hover:underline"
|
||||||
|
onClick={() => setName(person.displayName)}
|
||||||
|
dir="auto"
|
||||||
|
>
|
||||||
|
{person.displayName}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1.5 text-ui-caption text-muted-foreground">
|
||||||
|
{person.isSelf ? (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
<Trans>You</Trans>
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
<Trans>{person.faceTrackCount} appearances</Trans>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Checkbox
|
||||||
|
aria-label={t`Select ${person.displayName}`}
|
||||||
|
checked={selected}
|
||||||
|
onCheckedChange={(checked) => onSelect(checked === true)}
|
||||||
|
/>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={<Button variant="ghost" size="icon-sm" aria-label={t`More`} />}
|
||||||
|
>
|
||||||
|
<MoreHorizontal />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
checked={person.isSelf}
|
||||||
|
onCheckedChange={(checked) => onUpdate({ isSelf: checked === true })}
|
||||||
|
>
|
||||||
|
<Trans>This is me</Trans>
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem variant="destructive" onClick={onRemove}>
|
||||||
|
<Trans>Delete face data</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NativeSelect
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
aria-label={t`Anonymization`}
|
||||||
|
value={person.anonymizeByDefault}
|
||||||
|
onChange={(event) => onUpdate({ anonymizeByDefault: event.target.value as AnonymizeMode })}
|
||||||
|
>
|
||||||
|
{AnonymizeModeSchema.options.map((mode) => (
|
||||||
|
<NativeSelectOption
|
||||||
|
key={mode}
|
||||||
|
value={mode}
|
||||||
|
disabled={mode === "synthetic_identity" && !syntheticAvailable}
|
||||||
|
>
|
||||||
|
{modes[mode]}
|
||||||
|
</NativeSelectOption>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{person.confirmed ? (
|
||||||
|
<span className="flex items-center gap-1 text-ui-body-sm text-muted-foreground">
|
||||||
|
<Check aria-hidden="true" className="size-icon-sm" />
|
||||||
|
<Trans>Confirmed</Trans>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => onUpdate({ confirmed: true })}>
|
||||||
|
<Trans>Confirm</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={state === "complete" ? "ghost" : "outline"}
|
||||||
|
className="ml-auto"
|
||||||
|
data-consent={state}
|
||||||
|
onClick={onConsent}
|
||||||
|
>
|
||||||
|
{state === "complete" ? (
|
||||||
|
<ShieldCheck aria-hidden="true" className="text-success" />
|
||||||
|
) : (
|
||||||
|
<ShieldAlert aria-hidden="true" className="text-warning" />
|
||||||
|
)}
|
||||||
|
{state === "missing" ? <Trans>Add consent</Trans> : <Trans>Consent</Trans>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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";
|
import type { MediaViewProps } from "./nav";
|
||||||
|
|
||||||
/** Placeholder; the W3-web-library slice replaces this view. */
|
export function CollectionView({
|
||||||
export function CollectionView(_props: MediaViewProps & Record<string, unknown>) {
|
navigate,
|
||||||
return null;
|
collectionId,
|
||||||
|
}: MediaViewProps & { collectionId: string }) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const [data, setData] = useState<{ collection: MediaCollection; assets: MediaAsset[] } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [editing, setEditing] = useState<string | null>(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 (
|
||||||
|
<div className="grid flex-1 place-items-center px-8 text-center text-ui-body text-muted-foreground">
|
||||||
|
{error ?? <LoadingState label={t`Loading`} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { collection, assets } = data;
|
||||||
|
const selectedIds = [...selection.selected];
|
||||||
|
const range = collectionDateRange(collection);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="media-collection" className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div className="flex flex-wrap items-center gap-3 px-4 pt-3 pb-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{editing !== null ? (
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
aria-label={t`Name`}
|
||||||
|
value={editing}
|
||||||
|
maxLength={120}
|
||||||
|
className="max-w-md"
|
||||||
|
onChange={(event) => 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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<h2 className="truncate text-ui-title-sm font-semibold tracking-tight" dir="auto">
|
||||||
|
{collection.title}
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
aria-label={t`Rename`}
|
||||||
|
onClick={() => setEditing(collection.title)}
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-ui-body-sm text-muted-foreground">
|
||||||
|
{range ? `${range} · ` : ""}
|
||||||
|
<Plural value={collection.assetCount} one="# item" other="# items" />
|
||||||
|
{collection.totalDurationMs > 0
|
||||||
|
? ` · ${formatDuration(collection.totalDurationMs)}`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{selectedIds.length > 0 ? (
|
||||||
|
<Button variant="ghost" size="sm" onClick={selection.clear}>
|
||||||
|
<Plural value={selectedIds.length} one="# selected" other="# selected" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
data-testid="media-create-project-open"
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
>
|
||||||
|
<Trans>Create project</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="px-4 text-ui-body-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<AssetGrid assets={assets} selection={selection} scrub />
|
||||||
|
<CreateProjectDialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
defaultTitle={collection.title}
|
||||||
|
collectionIds={selectedIds.length > 0 ? [] : [collection.id]}
|
||||||
|
assetIds={selectedIds}
|
||||||
|
onCreated={(projectId) => navigate({ name: "project", projectId })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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";
|
import type { MediaViewProps } from "./nav";
|
||||||
|
|
||||||
/** Placeholder; the W3-web-library slice replaces this view. */
|
export function PeopleView({ navigate }: MediaViewProps) {
|
||||||
export function PeopleView(_props: MediaViewProps & Record<string, unknown>) {
|
const { t } = useLingui();
|
||||||
return null;
|
const { capabilities } = useMediaStudio();
|
||||||
|
const [people, setPeople] = useState<MediaPerson[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set());
|
||||||
|
const [consentFor, setConsentFor] = useState<MediaPerson | null>(null);
|
||||||
|
const [removing, setRemoving] = useState<MediaPerson | null>(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<unknown>, 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 (
|
||||||
|
<div data-testid="media-people" className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 px-4 pt-3 pb-2">
|
||||||
|
<LibraryModeToggle
|
||||||
|
mode="people"
|
||||||
|
onChange={(next) => {
|
||||||
|
if (next !== "people") navigate({ name: "library", mode: next });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{selected.size >= 2 ? (
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setSelected(new Set())}>
|
||||||
|
<Plural value={selected.size} one="# selected" other="# selected" />
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => setMerging(true)}>
|
||||||
|
<Trans>Merge</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="px-4 pb-2 text-ui-body-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{people === null ? (
|
||||||
|
<div className="grid flex-1 place-items-center">
|
||||||
|
<LoadingState label={t`Loading`} />
|
||||||
|
</div>
|
||||||
|
) : people.length === 0 ? (
|
||||||
|
<div className="grid flex-1 place-items-center px-8 text-center text-ui-body text-muted-foreground">
|
||||||
|
<Trans>No people found yet.</Trans>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rk-scroll min-h-0 flex-1 overflow-y-auto px-4 pb-4">
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-3">
|
||||||
|
{people.map((person) => (
|
||||||
|
<PersonCard
|
||||||
|
key={person.id}
|
||||||
|
person={person}
|
||||||
|
syntheticAvailable={syntheticAvailable}
|
||||||
|
selected={selected.has(person.id)}
|
||||||
|
onSelect={(value) =>
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConsentDialog
|
||||||
|
person={consentFor}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setConsentFor(null);
|
||||||
|
}}
|
||||||
|
onSaved={replace}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={merging}
|
||||||
|
title={<Plural value={selected.size} one="Merge # person?" other="Merge # people?" />}
|
||||||
|
description={<Trans>Their appearances become one person.</Trans>}
|
||||||
|
action={<Trans>Merge</Trans>}
|
||||||
|
onCancel={() => setMerging(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setMerging(false);
|
||||||
|
void merge();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={removing !== null}
|
||||||
|
destructive
|
||||||
|
title={<Trans>Delete face data of {removing?.displayName ?? ""}?</Trans>}
|
||||||
|
description={
|
||||||
|
<Trans>Face tracks, recognition data and marks of this person are deleted.</Trans>
|
||||||
|
}
|
||||||
|
action={<Trans>Delete</Trans>}
|
||||||
|
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.`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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";
|
import type { MediaViewProps } from "./nav";
|
||||||
|
|
||||||
/** Placeholder; the W3-web-library slice replaces this view. */
|
const ALL = "";
|
||||||
export function ResultsView(_props: MediaViewProps & Record<string, unknown>) {
|
|
||||||
return null;
|
type Data = {
|
||||||
|
funnel: FunnelRow[];
|
||||||
|
segments: SegmentFunnelRow[];
|
||||||
|
publications: MediaPublication[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ResultsView({ navigate }: MediaViewProps) {
|
||||||
|
const { t, i18n } = useLingui();
|
||||||
|
const [projects, setProjects] = useState<MediaProject[]>([]);
|
||||||
|
const [projectId, setProjectId] = useState(ALL);
|
||||||
|
const [data, setData] = useState<Data | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div data-testid="media-results" className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 px-4 pt-3 pb-2">
|
||||||
|
{projects.length > 0 ? (
|
||||||
|
<NativeSelect
|
||||||
|
size="sm"
|
||||||
|
aria-label={t`Project`}
|
||||||
|
value={projectId}
|
||||||
|
onChange={(event) => setProjectId(event.target.value)}
|
||||||
|
>
|
||||||
|
<NativeSelectOption value={ALL}>{t`All projects`}</NativeSelectOption>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<NativeSelectOption key={project.id} value={project.id}>
|
||||||
|
{project.title}
|
||||||
|
</NativeSelectOption>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
) : null}
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{resetDone ? <SuccessPop label={t`Reset`} /> : null}
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
|
||||||
|
<Upload data-icon="inline-start" aria-hidden="true" />
|
||||||
|
<Trans>Import CSV</Trans>
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={<Button variant="ghost" size="icon-sm" aria-label={t`More`} />}
|
||||||
|
>
|
||||||
|
<MoreHorizontal />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem disabled={!hasPublications} onClick={() => setRecordOpen(true)}>
|
||||||
|
<Trans>Enter results</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem variant="destructive" onClick={() => setResetOpen(true)}>
|
||||||
|
<Trans>Reset teaser model</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="px-4 pb-2 text-ui-body-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{data === null ? (
|
||||||
|
<div className="grid flex-1 place-items-center">
|
||||||
|
<LoadingState label={t`Loading`} />
|
||||||
|
</div>
|
||||||
|
) : data.funnel.length === 0 ? (
|
||||||
|
<div className="grid flex-1 place-items-center px-8 text-center text-ui-body text-muted-foreground">
|
||||||
|
<Trans>No results yet.</Trans>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rk-scroll flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-4 pb-6">
|
||||||
|
<Funnel totals={totals} />
|
||||||
|
|
||||||
|
<section aria-label={t`Publications`}>
|
||||||
|
<h3 className="mb-2 text-ui-body-sm font-medium text-muted-foreground">
|
||||||
|
<Trans>Publications</Trans>
|
||||||
|
</h3>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table data-testid="media-results-publications" className="w-full text-ui-body-sm">
|
||||||
|
<thead className="text-left text-muted-foreground">
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<th className="py-1.5 pr-3 font-medium">
|
||||||
|
<Trans>Project</Trans>
|
||||||
|
</th>
|
||||||
|
<th className="py-1.5 pr-3 font-medium">
|
||||||
|
<Trans>Platform</Trans>
|
||||||
|
</th>
|
||||||
|
<th className="py-1.5 pr-3 font-medium">
|
||||||
|
<Trans>Variant</Trans>
|
||||||
|
</th>
|
||||||
|
<NumberHead>
|
||||||
|
<Trans>Views</Trans>
|
||||||
|
</NumberHead>
|
||||||
|
<NumberHead>
|
||||||
|
<Trans>Clicks</Trans>
|
||||||
|
</NumberHead>
|
||||||
|
<NumberHead>
|
||||||
|
<Trans>Purchases</Trans>
|
||||||
|
</NumberHead>
|
||||||
|
<NumberHead>
|
||||||
|
<Trans>Revenue</Trans>
|
||||||
|
</NumberHead>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.funnel.map((row) => (
|
||||||
|
<tr
|
||||||
|
key={`${row.publicationId}-${row.currency}`}
|
||||||
|
className="border-b border-border/60"
|
||||||
|
>
|
||||||
|
<td className="py-1.5 pr-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="max-w-48 truncate text-left hover:underline"
|
||||||
|
onClick={() => navigate({ name: "project", projectId: row.projectId })}
|
||||||
|
dir="auto"
|
||||||
|
>
|
||||||
|
{projectTitle.get(row.projectId) ?? "–"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 pr-3">{row.profileId}</td>
|
||||||
|
<td className="py-1.5 pr-3 text-muted-foreground">
|
||||||
|
{[row.variant, row.thumbnailVariant].filter(Boolean).join(" · ") || "–"}
|
||||||
|
</td>
|
||||||
|
<NumberCell>{number(row.views)}</NumberCell>
|
||||||
|
<NumberCell>{number(row.clicks)}</NumberCell>
|
||||||
|
<NumberCell>{number(row.conversions)}</NumberCell>
|
||||||
|
<NumberCell>{formatMoney(row.revenueCents, row.currency, locale)}</NumberCell>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{data.segments.length > 0 ? (
|
||||||
|
<section aria-label={t`Teaser segments`}>
|
||||||
|
<h3 className="mb-2 text-ui-body-sm font-medium text-muted-foreground">
|
||||||
|
<Trans>Teaser segments</Trans>
|
||||||
|
</h3>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table data-testid="media-results-segments" className="w-full text-ui-body-sm">
|
||||||
|
<thead className="text-left text-muted-foreground">
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<th className="py-1.5 pr-3 font-medium">
|
||||||
|
<Trans>Segment</Trans>
|
||||||
|
</th>
|
||||||
|
<NumberHead>
|
||||||
|
<Trans>Uses</Trans>
|
||||||
|
</NumberHead>
|
||||||
|
<NumberHead>
|
||||||
|
<Trans>Views</Trans>
|
||||||
|
</NumberHead>
|
||||||
|
<NumberHead>
|
||||||
|
<Trans>Clicks</Trans>
|
||||||
|
</NumberHead>
|
||||||
|
<NumberHead>
|
||||||
|
<Trans>Purchases</Trans>
|
||||||
|
</NumberHead>
|
||||||
|
<NumberHead>
|
||||||
|
<Trans>Revenue</Trans>
|
||||||
|
</NumberHead>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.segments.map((row) => (
|
||||||
|
<tr
|
||||||
|
key={`${row.segmentId}-${row.currency}`}
|
||||||
|
className="border-b border-border/60"
|
||||||
|
>
|
||||||
|
<td className="py-1.5 pr-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-7 w-12 shrink-0 overflow-hidden rounded bg-muted">
|
||||||
|
<AssetThumb assetId={row.assetId} hasThumbnail />
|
||||||
|
</div>
|
||||||
|
<span className="font-mono tabular-nums text-muted-foreground">
|
||||||
|
{formatDuration(row.startMs)}–{formatDuration(row.endMs)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<NumberCell>{number(row.publications)}</NumberCell>
|
||||||
|
<NumberCell>{number(row.views)}</NumberCell>
|
||||||
|
<NumberCell>{number(row.clicks)}</NumberCell>
|
||||||
|
<NumberCell>{number(row.conversions)}</NumberCell>
|
||||||
|
<NumberCell>
|
||||||
|
{formatMoney(row.revenueCents, row.currency, locale)}
|
||||||
|
</NumberCell>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CsvImportDialog
|
||||||
|
open={importOpen}
|
||||||
|
onOpenChange={setImportOpen}
|
||||||
|
onImported={() => void load()}
|
||||||
|
/>
|
||||||
|
<MetricRecordDialog
|
||||||
|
open={recordOpen}
|
||||||
|
onOpenChange={setRecordOpen}
|
||||||
|
publications={data?.publications ?? []}
|
||||||
|
onRecorded={() => void load()}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={resetOpen}
|
||||||
|
destructive
|
||||||
|
title={<Trans>Reset teaser model?</Trans>}
|
||||||
|
description={<Trans>Teaser picks go back to the default weights.</Trans>}
|
||||||
|
action={<Trans>Reset</Trans>}
|
||||||
|
onCancel={() => setResetOpen(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setResetOpen(false);
|
||||||
|
setResetDone(false);
|
||||||
|
rpc.media.metrics
|
||||||
|
.resetTeaserModel({})
|
||||||
|
.then(() => setResetDone(true))
|
||||||
|
.catch((cause) => setError(rpcErrorMessage(cause, t`Could not reset.`)));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberHead({ children }: { children: React.ReactNode }) {
|
||||||
|
return <th className="py-1.5 pl-3 text-right font-medium">{children}</th>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberCell({ children }: { children: React.ReactNode }) {
|
||||||
|
return <td className="py-1.5 pl-3 text-right font-mono tabular-nums">{children}</td>;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user