Compare commits
No commits in common. "26872b5dc4cb87d30d8901da11d35fd55ea5073d" and "884d8903ca25772bec177afb4b559fae2e650b39" have entirely different histories.
26872b5dc4
...
884d8903ca
@ -1,452 +0,0 @@
|
|||||||
import { expect, type Page, type Route, test } from "@playwright/test";
|
|
||||||
import { captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Media Studio library, collection, people and results views on the scripted
|
|
||||||
* runtime (media emulators enabled). The library is seeded through the device
|
|
||||||
* source RPCs with synthetic metadata; people and results need analysis and
|
|
||||||
* publications the scripted stack does not produce, so those RPCs are stubbed
|
|
||||||
* with synthetic records. Every name and value below is fake.
|
|
||||||
*/
|
|
||||||
|
|
||||||
type Json = Record<string, unknown>;
|
|
||||||
|
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
function syntheticItems(count: number) {
|
|
||||||
const start = Date.UTC(2026, 4, 2, 9);
|
|
||||||
const cities = [
|
|
||||||
{ lat: 52.52, lon: 13.4 },
|
|
||||||
{ lat: 48.14, lon: 11.58 },
|
|
||||||
{ lat: 53.55, lon: 9.99 },
|
|
||||||
];
|
|
||||||
return Array.from({ length: count }, (_, index) => {
|
|
||||||
const session = index % 3;
|
|
||||||
const city = cities[session] ?? cities[0];
|
|
||||||
const photo = index % 5 === 4;
|
|
||||||
return {
|
|
||||||
externalId: `e2e-clip-${index}`,
|
|
||||||
kind: photo ? "photo" : "video",
|
|
||||||
capturedAt: new Date(start + session * 3 * DAY_MS + index * 60_000).toISOString(),
|
|
||||||
durationMs: photo ? null : 4000 + (index % 4) * 1000,
|
|
||||||
width: 1920,
|
|
||||||
height: 1080,
|
|
||||||
fps: photo ? null : 30,
|
|
||||||
gpsLat: city?.lat ?? null,
|
|
||||||
gpsLon: city?.lon ?? null,
|
|
||||||
deviceModel: null,
|
|
||||||
albumNames: [],
|
|
||||||
isFavorite: index % 7 === 0,
|
|
||||||
fileName: `clip-${index}.${photo ? "jpg" : "mov"}`,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fulfill(route: Route, json: unknown) {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({ json }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stub(page: Page, procedure: string, respond: (input: Json) => unknown) {
|
|
||||||
await page.route(`**/rpc/${procedure}`, async (route) => {
|
|
||||||
const body = (route.request().postDataJSON() ?? {}) as { json?: Json };
|
|
||||||
await fulfill(route, respond(body.json ?? {}));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = "2026-10-01T12:00:00.000Z";
|
|
||||||
|
|
||||||
function person(id: string, name: string, overrides: Json = {}) {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
displayName: name,
|
|
||||||
isSelf: false,
|
|
||||||
confirmed: false,
|
|
||||||
anonymizeByDefault: "none",
|
|
||||||
consentRecordedAt: null,
|
|
||||||
consentArtifactId: null,
|
|
||||||
ageVerifiedAt: null,
|
|
||||||
ageVerificationMethod: "none",
|
|
||||||
faceTrackCount: 3,
|
|
||||||
coverThumbAssetId: null,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function syntheticAsset(index: number) {
|
|
||||||
return {
|
|
||||||
id: `scale-${index}`,
|
|
||||||
sourceId: "scale-source",
|
|
||||||
externalId: `scale-${index}`,
|
|
||||||
kind: "video",
|
|
||||||
storage: "remote",
|
|
||||||
capturedAt: now,
|
|
||||||
durationMs: 4000,
|
|
||||||
width: 1920,
|
|
||||||
height: 1080,
|
|
||||||
fps: 30,
|
|
||||||
gpsLat: null,
|
|
||||||
gpsLon: null,
|
|
||||||
deviceModel: null,
|
|
||||||
albumNames: [],
|
|
||||||
isFavorite: false,
|
|
||||||
ingestState: "indexed",
|
|
||||||
hasProxy: false,
|
|
||||||
hasThumbnail: false,
|
|
||||||
qualityScore: null,
|
|
||||||
createdAt: now,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openMediaStudio(page: Page) {
|
|
||||||
await expect(page.getByTestId("media-studio-open")).toBeVisible({ timeout: 30_000 });
|
|
||||||
await page.getByTestId("media-studio-open").click();
|
|
||||||
const studio = page.getByTestId("media-studio");
|
|
||||||
await expect(studio).toBeVisible();
|
|
||||||
return studio;
|
|
||||||
}
|
|
||||||
|
|
||||||
test("media library groups sessions, selects clips and creates a project", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
test.setTimeout(240_000);
|
|
||||||
const stamp = Date.now();
|
|
||||||
await signup(page, `media-library-${stamp}@rakazo.test`, "password12", "Media Library");
|
|
||||||
await completeOnboarding(page);
|
|
||||||
|
|
||||||
const capabilities = await rpc<{ configured: boolean }>(page, "media/capabilities", {});
|
|
||||||
expect(capabilities.configured).toBe(true);
|
|
||||||
|
|
||||||
const source = await rpc<{ id: string }>(page, "media/sources/registerClient", {
|
|
||||||
kind: "ios_library",
|
|
||||||
label: "Test phone",
|
|
||||||
deviceId: `e2e-device-${stamp}`,
|
|
||||||
});
|
|
||||||
const pushed = await rpc<{ created: number }>(page, "media/sources/pushItems", {
|
|
||||||
sourceId: source.id,
|
|
||||||
items: syntheticItems(30),
|
|
||||||
deletedExternalIds: [],
|
|
||||||
syncToken: null,
|
|
||||||
complete: true,
|
|
||||||
});
|
|
||||||
expect(pushed.created).toBe(30);
|
|
||||||
await rpc(page, "media/collections/recluster", {});
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () =>
|
|
||||||
(
|
|
||||||
await rpc<{ items: unknown[] }>(page, "media/collections/list", {
|
|
||||||
kind: "session",
|
|
||||||
cursor: null,
|
|
||||||
limit: 10,
|
|
||||||
})
|
|
||||||
).items.length,
|
|
||||||
{ timeout: 90_000, intervals: [1000] },
|
|
||||||
)
|
|
||||||
.toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const studio = await openMediaStudio(page);
|
|
||||||
const sessions = studio.getByTestId("media-collection-card");
|
|
||||||
await expect(sessions.first()).toBeVisible();
|
|
||||||
await expect(studio.getByTestId("media-indexing")).toHaveCount(0);
|
|
||||||
await captureScreenshot(page, testInfo, "media-library-sessions");
|
|
||||||
|
|
||||||
await studio.getByRole("tab", { name: "Themes" }).click();
|
|
||||||
await expect(studio.getByRole("tab", { name: "Themes" })).toHaveAttribute(
|
|
||||||
"aria-selected",
|
|
||||||
"true",
|
|
||||||
);
|
|
||||||
await captureScreenshot(page, testInfo, "media-library-themes");
|
|
||||||
await studio.getByRole("tab", { name: "Sessions" }).click();
|
|
||||||
|
|
||||||
await studio.getByRole("button", { name: "Videos", exact: true }).click();
|
|
||||||
const grid = studio.getByTestId("media-asset-grid");
|
|
||||||
await expect(grid).toBeVisible();
|
|
||||||
await expect(grid.getByTestId("media-asset-tile").first()).toBeVisible();
|
|
||||||
await expect(grid).toHaveAttribute("data-count", "24");
|
|
||||||
await captureScreenshot(page, testInfo, "media-library-filter-videos");
|
|
||||||
await studio.getByRole("button", { name: "Videos", exact: true }).click();
|
|
||||||
|
|
||||||
await studio.getByTestId("media-collection-card").first().click();
|
|
||||||
await expect(studio).toHaveAttribute("data-view", "collection");
|
|
||||||
const tiles = studio.getByTestId("media-asset-tile");
|
|
||||||
await expect(tiles.first()).toBeVisible();
|
|
||||||
await tiles.nth(0).click();
|
|
||||||
await tiles.nth(1).click();
|
|
||||||
await expect(tiles.nth(0)).toHaveAttribute("aria-pressed", "true");
|
|
||||||
await expect(studio.getByRole("button", { name: "2 selected" })).toBeVisible();
|
|
||||||
await captureScreenshot(page, testInfo, "media-collection-selection");
|
|
||||||
|
|
||||||
await studio.getByTestId("media-create-project-open").click();
|
|
||||||
const create = page.getByTestId("media-create-project");
|
|
||||||
await expect(create).toBeVisible();
|
|
||||||
await expect(create.getByRole("checkbox").first()).toBeVisible();
|
|
||||||
await captureScreenshot(page, testInfo, "media-create-project");
|
|
||||||
await create.getByRole("button", { name: "Create project" }).click();
|
|
||||||
await expect(studio).toHaveAttribute("data-view", "project");
|
|
||||||
|
|
||||||
await studio.getByRole("button", { name: "Back" }).click();
|
|
||||||
await expect(studio).toHaveAttribute("data-view", "collection");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("people view confirms, merges and records consent", async ({ page }, testInfo) => {
|
|
||||||
const stamp = Date.now();
|
|
||||||
await signup(page, `media-people-${stamp}@rakazo.test`, "password12", "Media People");
|
|
||||||
await completeOnboarding(page);
|
|
||||||
|
|
||||||
let people = [
|
|
||||||
person("person-a", "Person 1"),
|
|
||||||
person("person-b", "Person 2", { confirmed: true }),
|
|
||||||
person("person-c", "Person 3"),
|
|
||||||
];
|
|
||||||
const consentCalls: Json[] = [];
|
|
||||||
await stub(page, "media/people/list", () => people);
|
|
||||||
await stub(page, "media/people/update", (input) => {
|
|
||||||
const { personId, ...patch } = input;
|
|
||||||
people = people.map((row) => (row.id === personId ? { ...row, ...patch } : row));
|
|
||||||
return people.find((row) => row.id === personId);
|
|
||||||
});
|
|
||||||
await stub(page, "media/people/merge", (input) => {
|
|
||||||
const [target, ...others] = input.personIds as string[];
|
|
||||||
people = people
|
|
||||||
.filter((row) => !others.includes(row.id))
|
|
||||||
.map((row) => (row.id === target ? { ...row, confirmed: true, faceTrackCount: 6 } : row));
|
|
||||||
return people.find((row) => row.id === target);
|
|
||||||
});
|
|
||||||
await stub(page, "media/people/recordConsent", (input) => {
|
|
||||||
consentCalls.push(input);
|
|
||||||
people = people.map((row) =>
|
|
||||||
row.id === input.personId
|
|
||||||
? {
|
|
||||||
...row,
|
|
||||||
consentRecordedAt: input.consentRecordedAt,
|
|
||||||
ageVerificationMethod: input.ageVerificationMethod,
|
|
||||||
ageVerifiedAt: input.ageVerifiedAt,
|
|
||||||
}
|
|
||||||
: row,
|
|
||||||
);
|
|
||||||
return people.find((row) => row.id === input.personId);
|
|
||||||
});
|
|
||||||
|
|
||||||
const studio = await openMediaStudio(page);
|
|
||||||
await studio.getByTestId("media-connect-source-open").click();
|
|
||||||
const connect = page.getByTestId("media-connect-source");
|
|
||||||
await expect(connect).toBeVisible();
|
|
||||||
await captureScreenshot(page, testInfo, "media-library-connect-source");
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
await expect(connect).toBeHidden();
|
|
||||||
await expect(studio).toBeVisible();
|
|
||||||
|
|
||||||
await studio.getByRole("tab", { name: "People" }).click();
|
|
||||||
await expect(studio).toHaveAttribute("data-view", "people");
|
|
||||||
const cards = studio.getByTestId("media-person");
|
|
||||||
await expect(cards).toHaveCount(3);
|
|
||||||
await captureScreenshot(page, testInfo, "media-people");
|
|
||||||
|
|
||||||
const first = cards.filter({ hasText: "Person 1" });
|
|
||||||
await first.getByRole("button", { name: "Confirm" }).click();
|
|
||||||
await expect(first.getByText("Confirmed")).toBeVisible();
|
|
||||||
|
|
||||||
await first.getByRole("combobox", { name: "Anonymization" }).selectOption("blur");
|
|
||||||
await expect(first.getByRole("combobox", { name: "Anonymization" })).toHaveValue("blur");
|
|
||||||
await expect(
|
|
||||||
first
|
|
||||||
.getByRole("combobox", { name: "Anonymization" })
|
|
||||||
.locator("option[value=synthetic_identity]"),
|
|
||||||
).toBeDisabled();
|
|
||||||
|
|
||||||
await first.getByRole("button", { name: "Add consent" }).click();
|
|
||||||
const consent = page.getByTestId("media-consent");
|
|
||||||
await expect(consent).toBeVisible();
|
|
||||||
await consent.getByLabel("Consent given on").fill("2026-09-01");
|
|
||||||
await consent.getByLabel("Age proof").selectOption("platform_verified");
|
|
||||||
await consent.getByLabel("Verified on").fill("2026-09-02");
|
|
||||||
await captureScreenshot(page, testInfo, "media-people-consent");
|
|
||||||
await consent.getByRole("button", { name: "Save" }).click();
|
|
||||||
await expect(consent).toBeHidden();
|
|
||||||
expect(consentCalls).toEqual([
|
|
||||||
{
|
|
||||||
personId: "person-a",
|
|
||||||
consentArtifactId: null,
|
|
||||||
consentRecordedAt: "2026-09-01T12:00:00.000Z",
|
|
||||||
ageVerificationMethod: "platform_verified",
|
|
||||||
ageVerifiedAt: "2026-09-02T12:00:00.000Z",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
await expect(first.locator("[data-consent=complete]")).toBeVisible();
|
|
||||||
|
|
||||||
await cards
|
|
||||||
.filter({ hasText: "Person 2" })
|
|
||||||
.getByRole("checkbox", { name: "Select Person 2" })
|
|
||||||
.click();
|
|
||||||
await cards
|
|
||||||
.filter({ hasText: "Person 3" })
|
|
||||||
.getByRole("checkbox", { name: "Select Person 3" })
|
|
||||||
.click();
|
|
||||||
await studio.getByRole("button", { name: "Merge" }).click();
|
|
||||||
await page.getByRole("alertdialog").getByRole("button", { name: "Merge" }).click();
|
|
||||||
await expect(cards).toHaveCount(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("results import a CSV with a mapping and show the funnel", async ({ page }, testInfo) => {
|
|
||||||
const stamp = Date.now();
|
|
||||||
await signup(page, `media-results-${stamp}@rakazo.test`, "password12", "Media Results");
|
|
||||||
await completeOnboarding(page);
|
|
||||||
|
|
||||||
let imported = false;
|
|
||||||
const saved: Json[] = [];
|
|
||||||
await stub(page, "media/metrics/funnel", () =>
|
|
||||||
imported
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
publicationId: "pub-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
profileId: "example-short-vertical",
|
|
||||||
variant: "15s",
|
|
||||||
thumbnailVariant: "a",
|
|
||||||
views: 12000,
|
|
||||||
clicks: 840,
|
|
||||||
conversions: 42,
|
|
||||||
revenueCents: 41958,
|
|
||||||
currency: "EUR",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
publicationId: "pub-2",
|
|
||||||
projectId: "project-1",
|
|
||||||
profileId: "example-paid-video",
|
|
||||||
variant: null,
|
|
||||||
thumbnailVariant: null,
|
|
||||||
views: 3000,
|
|
||||||
clicks: 300,
|
|
||||||
conversions: 30,
|
|
||||||
revenueCents: 29970,
|
|
||||||
currency: "EUR",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
await stub(page, "media/metrics/segments", () =>
|
|
||||||
imported
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
segmentId: "segment-1",
|
|
||||||
assetId: "asset-1",
|
|
||||||
startMs: 1000,
|
|
||||||
endMs: 4000,
|
|
||||||
publications: 2,
|
|
||||||
views: 15000,
|
|
||||||
clicks: 1140,
|
|
||||||
conversions: 72,
|
|
||||||
revenueCents: 71928,
|
|
||||||
currency: "EUR",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
await stub(page, "media/publications/list", () => ({ items: [], nextCursor: null }));
|
|
||||||
await stub(page, "media/metrics/mappings", () => []);
|
|
||||||
await stub(page, "media/metrics/saveMapping", (input) => {
|
|
||||||
saved.push(input);
|
|
||||||
return { ...input, id: "mapping-1" };
|
|
||||||
});
|
|
||||||
await stub(page, "media/metrics/importCsv", () => {
|
|
||||||
imported = true;
|
|
||||||
return { imported: 2, skipped: [] };
|
|
||||||
});
|
|
||||||
|
|
||||||
const studio = await openMediaStudio(page);
|
|
||||||
await studio.getByRole("tab", { name: "Results" }).click();
|
|
||||||
await expect(studio.getByText("No results yet.")).toBeVisible();
|
|
||||||
|
|
||||||
await studio.getByRole("button", { name: "Import CSV" }).click();
|
|
||||||
const dialog = page.getByTestId("media-csv-import");
|
|
||||||
await dialog.getByLabel("CSV file").setInputFiles({
|
|
||||||
name: "example-platform.csv",
|
|
||||||
mimeType: "text/csv",
|
|
||||||
buffer: Buffer.from(
|
|
||||||
"date;utm_content;views;clicks;purchases;revenue\n2026-09-01;variant-a;12000;840;42;419.58\n2026-09-01;variant-b;3000;300;30;299.70\n",
|
|
||||||
),
|
|
||||||
});
|
|
||||||
await expect(dialog.getByLabel("Views")).toHaveValue("views");
|
|
||||||
await expect(dialog.getByLabel("Purchases")).toHaveValue("purchases");
|
|
||||||
await captureScreenshot(page, testInfo, "media-results-mapping");
|
|
||||||
await dialog.getByRole("button", { name: /^Import/ }).click();
|
|
||||||
await expect(dialog.getByTestId("media-csv-imported")).toHaveText("2 rows imported");
|
|
||||||
expect(saved[0]).toMatchObject({
|
|
||||||
name: "example-platform",
|
|
||||||
delimiter: ";",
|
|
||||||
publicationKey: "utm_content",
|
|
||||||
columns: { publication: "utm_content", observedAt: "date", views: "views" },
|
|
||||||
});
|
|
||||||
await dialog.getByRole("button", { name: "Done" }).click();
|
|
||||||
|
|
||||||
const funnel = studio.getByTestId("media-funnel");
|
|
||||||
await expect(funnel).toBeVisible();
|
|
||||||
await expect(funnel).toContainText("15,000");
|
|
||||||
await expect(funnel).toContainText("1,140");
|
|
||||||
await expect(funnel).toContainText("72");
|
|
||||||
await expect(studio.getByTestId("media-results-segments")).toBeVisible();
|
|
||||||
await captureScreenshot(page, testInfo, "media-results-funnel");
|
|
||||||
|
|
||||||
await studio.getByRole("button", { name: "More" }).click();
|
|
||||||
await page.getByRole("menuitem", { name: "Reset teaser model" }).click();
|
|
||||||
await page.getByRole("alertdialog").getByRole("button", { name: "Reset" }).click();
|
|
||||||
await expect(studio.getByText("Reset", { exact: true })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the asset grid stays virtualized across 50k assets", async ({ page }) => {
|
|
||||||
test.setTimeout(240_000);
|
|
||||||
const stamp = Date.now();
|
|
||||||
await signup(page, `media-scale-${stamp}@rakazo.test`, "password12", "Media Scale");
|
|
||||||
await completeOnboarding(page);
|
|
||||||
|
|
||||||
const total = 50_000;
|
|
||||||
await stub(page, "media/sources/list", () => [
|
|
||||||
{
|
|
||||||
id: "scale-source",
|
|
||||||
kind: "folder",
|
|
||||||
label: "Archive",
|
|
||||||
status: "idle",
|
|
||||||
assetCount: total,
|
|
||||||
lastSyncedAt: now,
|
|
||||||
error: null,
|
|
||||||
createdAt: now,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
await stub(page, "media/assets/search", (input) => {
|
|
||||||
if (Array.isArray(input.ingestStates)) return { items: [], nextCursor: null };
|
|
||||||
const offset = Number(input.cursor ?? 0);
|
|
||||||
const limit = Number(input.limit ?? 100);
|
|
||||||
const end = Math.min(total, offset + limit);
|
|
||||||
return {
|
|
||||||
items: Array.from({ length: end - offset }, (_, index) => syntheticAsset(offset + index)),
|
|
||||||
nextCursor: end < total ? String(end) : null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const studio = await openMediaStudio(page);
|
|
||||||
await studio.getByRole("button", { name: "Videos", exact: true }).click();
|
|
||||||
const grid = studio.getByTestId("media-asset-grid");
|
|
||||||
await expect(grid.getByTestId("media-asset-tile").first()).toBeVisible();
|
|
||||||
|
|
||||||
let loaded = 0;
|
|
||||||
while (loaded < total) {
|
|
||||||
await grid.evaluate((element) => {
|
|
||||||
element.scrollTop = element.scrollHeight;
|
|
||||||
});
|
|
||||||
await expect
|
|
||||||
.poll(async () => Number(await grid.getAttribute("data-count")), { timeout: 10_000 })
|
|
||||||
.toBeGreaterThan(loaded);
|
|
||||||
loaded = Number(await grid.getAttribute("data-count"));
|
|
||||||
expect(await grid.getByTestId("media-asset-tile").count()).toBeLessThan(400);
|
|
||||||
}
|
|
||||||
expect(loaded).toBe(total);
|
|
||||||
await grid.evaluate((element) => {
|
|
||||||
element.scrollTop = element.scrollHeight;
|
|
||||||
});
|
|
||||||
await expect(grid.locator('[data-asset-id="scale-49999"]')).toBeVisible();
|
|
||||||
expect(await grid.getByTestId("media-asset-tile").count()).toBeLessThan(400);
|
|
||||||
});
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
import { useLingui } from "@lingui/react/macro";
|
|
||||||
import type { MediaAsset } from "@rakazo/contracts";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useCallback, useRef, useState } from "react";
|
|
||||||
import { AssetTile } from "./AssetTile";
|
|
||||||
import { VirtualGrid } from "./VirtualGrid";
|
|
||||||
|
|
||||||
/** Selected asset ids; shift-click selects the range since the last click. */
|
|
||||||
export function useAssetSelection() {
|
|
||||||
const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set());
|
|
||||||
const anchor = useRef<number | null>(null);
|
|
||||||
|
|
||||||
const toggle = useCallback((assets: readonly MediaAsset[], index: number, range: boolean) => {
|
|
||||||
const target = assets[index];
|
|
||||||
if (!target) return;
|
|
||||||
const start = anchor.current;
|
|
||||||
setSelected((current) => {
|
|
||||||
const next = new Set(current);
|
|
||||||
if (range && start !== null) {
|
|
||||||
const [from, to] = [Math.min(start, index), Math.max(start, index)];
|
|
||||||
for (const asset of assets.slice(from, to + 1)) next.add(asset.id);
|
|
||||||
} else if (next.has(target.id)) {
|
|
||||||
next.delete(target.id);
|
|
||||||
} else {
|
|
||||||
next.add(target.id);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
anchor.current = index;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clear = useCallback(() => {
|
|
||||||
anchor.current = null;
|
|
||||||
setSelected(new Set());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { selected, toggle, clear };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AssetGrid({
|
|
||||||
assets,
|
|
||||||
selection,
|
|
||||||
scrub = false,
|
|
||||||
onNearEnd,
|
|
||||||
footer,
|
|
||||||
}: {
|
|
||||||
assets: readonly MediaAsset[];
|
|
||||||
selection: ReturnType<typeof useAssetSelection>;
|
|
||||||
scrub?: boolean;
|
|
||||||
onNearEnd?: () => void;
|
|
||||||
footer?: ReactNode;
|
|
||||||
}) {
|
|
||||||
const { t } = useLingui();
|
|
||||||
const selecting = selection.selected.size > 0;
|
|
||||||
return (
|
|
||||||
<VirtualGrid
|
|
||||||
testId="media-asset-grid"
|
|
||||||
label={t`Clips`}
|
|
||||||
count={assets.length}
|
|
||||||
getKey={(index) => assets[index]?.id ?? index}
|
|
||||||
minTileWidth={150}
|
|
||||||
tileAspect={9 / 16}
|
|
||||||
onNearEnd={onNearEnd}
|
|
||||||
footer={footer}
|
|
||||||
renderItem={(index) => {
|
|
||||||
const asset = assets[index];
|
|
||||||
if (!asset) return null;
|
|
||||||
return (
|
|
||||||
<AssetTile
|
|
||||||
asset={asset}
|
|
||||||
scrub={scrub}
|
|
||||||
selecting={selecting}
|
|
||||||
selected={selection.selected.has(asset.id)}
|
|
||||||
onToggle={(event) => selection.toggle(assets, index, event.shiftKey)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,155 +0,0 @@
|
|||||||
import { useLingui } from "@lingui/react/macro";
|
|
||||||
import type { MediaAsset } from "@rakazo/contracts";
|
|
||||||
import { Checkbox, cn } from "@rakazo/ui-web";
|
|
||||||
import { Image as ImageIcon, Star, Video } from "lucide-react";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { mediaAssetPath, useMediaObjectUrl } from "../../../lib/media-bytes";
|
|
||||||
import { formatDuration, scrubFraction } from "../../../lib/media-library";
|
|
||||||
|
|
||||||
/** Poster of an asset, or a quiet placeholder until the proxy job made one. */
|
|
||||||
export function AssetThumb({
|
|
||||||
assetId,
|
|
||||||
hasThumbnail,
|
|
||||||
kind,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
assetId: string | null;
|
|
||||||
hasThumbnail: boolean;
|
|
||||||
kind?: MediaAsset["kind"];
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const thumb = useMediaObjectUrl(
|
|
||||||
assetId && hasThumbnail ? mediaAssetPath(assetId, "thumb") : null,
|
|
||||||
{ keep: true },
|
|
||||||
);
|
|
||||||
if (thumb.url) {
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={thumb.url}
|
|
||||||
alt=""
|
|
||||||
draggable={false}
|
|
||||||
className={cn("size-full object-cover", className)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const Icon = kind === "photo" || kind === "live_photo" ? ImageIcon : Video;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"grid size-full place-items-center bg-muted text-muted-foreground/60",
|
|
||||||
thumb.status === "loading" && "animate-pulse",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon strokeWidth={1.6} className="size-icon-lg" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Plays the proxy under the pointer: left edge is the first frame, right edge the last. */
|
|
||||||
function Scrubber({ asset, fraction }: { asset: MediaAsset; fraction: number }) {
|
|
||||||
const proxy = useMediaObjectUrl(mediaAssetPath(asset.id, "proxy"));
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
|
||||||
const duration = (asset.durationMs ?? 0) / 1000;
|
|
||||||
useEffect(() => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!proxy.url || !video || duration <= 0) return;
|
|
||||||
const target = fraction * duration;
|
|
||||||
if (Math.abs(video.currentTime - target) > 0.04) video.currentTime = target;
|
|
||||||
}, [proxy.url, fraction, duration]);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{proxy.url ? (
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
src={proxy.url}
|
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
preload="auto"
|
|
||||||
className="absolute inset-0 size-full object-cover"
|
|
||||||
onLoadedMetadata={(event) => {
|
|
||||||
event.currentTarget.currentTime = fraction * (event.currentTarget.duration || 0);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className="absolute bottom-0 left-0 h-0.5 bg-primary-foreground"
|
|
||||||
style={{ width: `${fraction * 100}%` }}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AssetTile({
|
|
||||||
asset,
|
|
||||||
selected,
|
|
||||||
selecting,
|
|
||||||
onToggle,
|
|
||||||
scrub = false,
|
|
||||||
}: {
|
|
||||||
asset: MediaAsset;
|
|
||||||
selected: boolean;
|
|
||||||
/** Show checkboxes on every tile, not only on hover. */
|
|
||||||
selecting: boolean;
|
|
||||||
onToggle: (event: React.MouseEvent | React.KeyboardEvent) => void;
|
|
||||||
scrub?: boolean;
|
|
||||||
}) {
|
|
||||||
const { t } = useLingui();
|
|
||||||
const [fraction, setFraction] = useState<number | null>(null);
|
|
||||||
const canScrub = scrub && asset.kind !== "photo" && asset.hasProxy && (asset.durationMs ?? 0) > 0;
|
|
||||||
const duration = asset.kind === "photo" ? "" : formatDuration(asset.durationMs);
|
|
||||||
const captured = asset.capturedAt ? new Date(asset.capturedAt).toLocaleString() : "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="media-asset-tile"
|
|
||||||
data-asset-id={asset.id}
|
|
||||||
aria-pressed={selected}
|
|
||||||
aria-label={captured || t`Clip`}
|
|
||||||
onClick={onToggle}
|
|
||||||
onPointerMove={
|
|
||||||
canScrub
|
|
||||||
? (event) => {
|
|
||||||
const box = event.currentTarget.getBoundingClientRect();
|
|
||||||
setFraction(scrubFraction(event.clientX, box.left, box.width));
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onPointerLeave={canScrub ? () => setFraction(null) : undefined}
|
|
||||||
className={cn(
|
|
||||||
"group/tile relative size-full overflow-hidden rounded-lg bg-muted outline-none ring-offset-2 ring-offset-background focus-visible:ring-2 focus-visible:ring-ring",
|
|
||||||
selected && "ring-2 ring-primary",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<AssetThumb assetId={asset.id} hasThumbnail={asset.hasThumbnail} kind={asset.kind} />
|
|
||||||
{canScrub && fraction !== null ? <Scrubber asset={asset} fraction={fraction} /> : null}
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"absolute left-1.5 top-1.5 transition-opacity",
|
|
||||||
selecting || selected ? "opacity-100" : "opacity-0 group-hover/tile:opacity-100",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={selected}
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-hidden="true"
|
|
||||||
className="pointer-events-none bg-background/80"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
{asset.isFavorite ? (
|
|
||||||
<Star
|
|
||||||
aria-hidden="true"
|
|
||||||
fill="currentColor"
|
|
||||||
className="absolute right-1.5 top-1.5 size-icon-sm text-primary-foreground drop-shadow"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{duration ? (
|
|
||||||
<span className="absolute bottom-1.5 right-1.5 rounded bg-foreground/70 px-1 font-mono text-ui-caption tabular-nums text-background">
|
|
||||||
{duration}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
import { Trans } from "@lingui/react/macro";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@rakazo/ui-web";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
export function ConfirmDialog({
|
|
||||||
open,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
action,
|
|
||||||
destructive = false,
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
title: ReactNode;
|
|
||||||
description?: ReactNode;
|
|
||||||
action: ReactNode;
|
|
||||||
destructive?: boolean;
|
|
||||||
onConfirm: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<AlertDialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(next) => {
|
|
||||||
if (!next) onCancel();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
|
||||||
{description ? <AlertDialogDescription>{description}</AlertDialogDescription> : null}
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
variant={destructive ? "destructive" : "default"}
|
|
||||||
onClick={() => onConfirm()}
|
|
||||||
>
|
|
||||||
{action}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,248 +0,0 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
|
||||||
import type { MediaSource } from "@rakazo/contracts";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
Field,
|
|
||||||
FieldLabel,
|
|
||||||
Input,
|
|
||||||
} from "@rakazo/ui-web";
|
|
||||||
import { FolderOpen, Images, RefreshCw, Trash2 } from "lucide-react";
|
|
||||||
import { useId, useState } from "react";
|
|
||||||
import { desktopBridge } from "../../../lib/desktop";
|
|
||||||
import { rpcErrorMessage } from "../../../lib/media-studio";
|
|
||||||
import { rpc } from "../../../lib/rpc";
|
|
||||||
import { ConfirmDialog } from "./ConfirmDialog";
|
|
||||||
import type { usePhotosSync } from "./usePhotosSync";
|
|
||||||
|
|
||||||
const SERVER_SYNCED = new Set<MediaSource["kind"]>(["folder", "emulator"]);
|
|
||||||
|
|
||||||
export function ConnectSourceDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
sources,
|
|
||||||
canLinkFolder,
|
|
||||||
photos,
|
|
||||||
onChanged,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
sources: readonly MediaSource[];
|
|
||||||
canLinkFolder: boolean;
|
|
||||||
photos: ReturnType<typeof usePhotosSync>;
|
|
||||||
onChanged: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useLingui();
|
|
||||||
const labelId = useId();
|
|
||||||
const pathId = useId();
|
|
||||||
const [label, setLabel] = useState("");
|
|
||||||
const [path, setPath] = useState("");
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [removing, setRemoving] = useState<MediaSource | null>(null);
|
|
||||||
const picker = desktopBridge()?.files?.pickDirectory;
|
|
||||||
const nothingToConnect = !canLinkFolder && !photos.available;
|
|
||||||
|
|
||||||
const run = async (action: () => Promise<unknown>) => {
|
|
||||||
setBusy(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await action();
|
|
||||||
onChanged();
|
|
||||||
return true;
|
|
||||||
} catch (cause) {
|
|
||||||
setError(rpcErrorMessage(cause, t`That did not work. Try again.`));
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const connectFolder = async () => {
|
|
||||||
const ok = await run(() =>
|
|
||||||
rpc.media.sources.createFolder({
|
|
||||||
label: label.trim() || folderName(path) || t`Folder`,
|
|
||||||
path: path.trim(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (ok) {
|
|
||||||
setLabel("");
|
|
||||||
setPath("");
|
|
||||||
onOpenChange(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent data-testid="media-connect-source" className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Sources</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{sources.length > 0 ? (
|
|
||||||
<ul className="flex flex-col divide-y divide-border rounded-lg border border-border">
|
|
||||||
{sources.map((source) => (
|
|
||||||
<li key={source.id} className="flex items-center gap-2 px-3 py-2">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate text-ui-body-sm font-medium" dir="auto">
|
|
||||||
{source.label}
|
|
||||||
</div>
|
|
||||||
<div className="truncate text-ui-caption text-muted-foreground">
|
|
||||||
{source.status === "error" && source.error ? (
|
|
||||||
<span className="text-destructive">{source.error}</span>
|
|
||||||
) : (
|
|
||||||
<Trans>{source.assetCount} items</Trans>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{SERVER_SYNCED.has(source.kind) ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={t`Sync`}
|
|
||||||
disabled={busy || source.status === "syncing"}
|
|
||||||
onClick={() => void run(() => rpc.media.sources.sync({ sourceId: source.id }))}
|
|
||||||
>
|
|
||||||
<RefreshCw className={source.status === "syncing" ? "animate-spin" : ""} />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={t`Remove ${source.label}`}
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => setRemoving(source)}
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{photos.available ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="justify-start"
|
|
||||||
disabled={photos.syncing || photos.denied}
|
|
||||||
onClick={() => {
|
|
||||||
void photos.sync();
|
|
||||||
onOpenChange(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Images aria-hidden="true" />
|
|
||||||
<Trans>Apple Photos</Trans>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{photos.denied ? (
|
|
||||||
<p className="text-ui-body-sm text-muted-foreground">
|
|
||||||
<Trans>Allow evOo to access Photos in System Settings.</Trans>
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{canLinkFolder ? (
|
|
||||||
<form
|
|
||||||
className="flex flex-col gap-3"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
void connectFolder();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor={pathId}>
|
|
||||||
<Trans>Folder</Trans>
|
|
||||||
</FieldLabel>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
id={pathId}
|
|
||||||
value={path}
|
|
||||||
onChange={(event) => setPath(event.target.value)}
|
|
||||||
placeholder={t`/Users/you/Movies`}
|
|
||||||
spellCheck={false}
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
{picker ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
aria-label={t`Choose folder`}
|
|
||||||
onClick={() => {
|
|
||||||
void picker()
|
|
||||||
.then((picked) => {
|
|
||||||
if (picked) setPath(picked);
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FolderOpen aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
{path.trim() ? (
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor={labelId}>
|
|
||||||
<Trans>Name</Trans>
|
|
||||||
</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id={labelId}
|
|
||||||
value={label}
|
|
||||||
maxLength={60}
|
|
||||||
onChange={(event) => setLabel(event.target.value)}
|
|
||||||
placeholder={folderName(path)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
) : null}
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="submit" disabled={busy || !path.trim()}>
|
|
||||||
<Trans>Connect</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{nothingToConnect && sources.length === 0 ? (
|
|
||||||
<p className="text-ui-body-sm text-muted-foreground">
|
|
||||||
<Trans>Only the owner of this evOo can connect a folder.</Trans>
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{error || photos.error ? (
|
|
||||||
<p role="alert" className="text-ui-body-sm text-destructive">
|
|
||||||
{error ?? photos.error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<ConfirmDialog
|
|
||||||
open={removing !== null}
|
|
||||||
destructive
|
|
||||||
title={<Trans>Remove {removing?.label ?? ""}?</Trans>}
|
|
||||||
description={
|
|
||||||
<Trans>Its clips leave the library. Your original files stay untouched.</Trans>
|
|
||||||
}
|
|
||||||
action={<Trans>Remove</Trans>}
|
|
||||||
onCancel={() => setRemoving(null)}
|
|
||||||
onConfirm={() => {
|
|
||||||
const target = removing;
|
|
||||||
setRemoving(null);
|
|
||||||
if (target) void run(() => rpc.media.sources.remove({ sourceId: target.id }));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function folderName(path: string): string {
|
|
||||||
return (
|
|
||||||
path
|
|
||||||
.trim()
|
|
||||||
.replace(/[\\/]+$/, "")
|
|
||||||
.split(/[\\/]/)
|
|
||||||
.at(-1) ?? ""
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,151 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,208 +0,0 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
|
||||||
import type { MediaProjectGoal, PlatformProfileSummary } from "@rakazo/contracts";
|
|
||||||
import { MediaProjectGoalSchema } from "@rakazo/contracts";
|
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
Field,
|
|
||||||
FieldLabel,
|
|
||||||
FieldLegend,
|
|
||||||
FieldSet,
|
|
||||||
Input,
|
|
||||||
NativeSelect,
|
|
||||||
NativeSelectOption,
|
|
||||||
} from "@rakazo/ui-web";
|
|
||||||
import { useEffect, useId, useState } from "react";
|
|
||||||
import { rpcErrorMessage } from "../../../lib/media-studio";
|
|
||||||
import { rpc } from "../../../lib/rpc";
|
|
||||||
|
|
||||||
export function useGoalLabels(): Record<MediaProjectGoal, string> {
|
|
||||||
const { t } = useLingui();
|
|
||||||
return {
|
|
||||||
teaser: t`Teaser`,
|
|
||||||
full_release: t`Full release`,
|
|
||||||
trailer: t`Trailer`,
|
|
||||||
highlight: t`Highlight`,
|
|
||||||
story: t`Story`,
|
|
||||||
custom: t`Custom`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateProjectDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
defaultTitle,
|
|
||||||
collectionIds,
|
|
||||||
assetIds,
|
|
||||||
onCreated,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
defaultTitle: string;
|
|
||||||
collectionIds: string[];
|
|
||||||
assetIds: string[];
|
|
||||||
onCreated: (projectId: string) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useLingui();
|
|
||||||
const goals = useGoalLabels();
|
|
||||||
const titleId = useId();
|
|
||||||
const goalId = useId();
|
|
||||||
const [title, setTitle] = useState(defaultTitle);
|
|
||||||
const [goal, setGoal] = useState<MediaProjectGoal>("teaser");
|
|
||||||
const [profiles, setProfiles] = useState<PlatformProfileSummary[] | null>(null);
|
|
||||||
const [chosen, setChosen] = useState<ReadonlySet<string>>(() => new Set());
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
setTitle(defaultTitle);
|
|
||||||
setError(null);
|
|
||||||
let active = true;
|
|
||||||
rpc.media.profiles
|
|
||||||
.list({})
|
|
||||||
.then((list) => {
|
|
||||||
if (!active) return;
|
|
||||||
setProfiles(list);
|
|
||||||
setChosen((current) =>
|
|
||||||
current.size > 0 ? current : new Set(list.slice(0, 1).map((p) => p.id)),
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((cause) => {
|
|
||||||
if (active) setError(rpcErrorMessage(cause, t`Could not load platforms.`));
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, [open, defaultTitle, t]);
|
|
||||||
|
|
||||||
const create = async () => {
|
|
||||||
setBusy(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const project = await rpc.media.projects.create({
|
|
||||||
title: title.trim(),
|
|
||||||
goal,
|
|
||||||
collectionIds,
|
|
||||||
assetIds,
|
|
||||||
profileIds: [...chosen],
|
|
||||||
brandKitId: null,
|
|
||||||
});
|
|
||||||
onOpenChange(false);
|
|
||||||
onCreated(project.id);
|
|
||||||
} catch (cause) {
|
|
||||||
setError(rpcErrorMessage(cause, t`Could not create the project.`));
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent data-testid="media-create-project" className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>New project</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<form
|
|
||||||
className="flex flex-col gap-4"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
void create();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor={titleId}>
|
|
||||||
<Trans>Title</Trans>
|
|
||||||
</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id={titleId}
|
|
||||||
value={title}
|
|
||||||
maxLength={120}
|
|
||||||
onChange={(event) => setTitle(event.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor={goalId}>
|
|
||||||
<Trans>Goal</Trans>
|
|
||||||
</FieldLabel>
|
|
||||||
<NativeSelect
|
|
||||||
id={goalId}
|
|
||||||
className="w-full"
|
|
||||||
value={goal}
|
|
||||||
onChange={(event) => setGoal(event.target.value as MediaProjectGoal)}
|
|
||||||
>
|
|
||||||
{MediaProjectGoalSchema.options.map((option) => (
|
|
||||||
<NativeSelectOption key={option} value={option}>
|
|
||||||
{goals[option]}
|
|
||||||
</NativeSelectOption>
|
|
||||||
))}
|
|
||||||
</NativeSelect>
|
|
||||||
</Field>
|
|
||||||
<FieldSet>
|
|
||||||
<FieldLegend variant="label">
|
|
||||||
<Trans>Platforms</Trans>
|
|
||||||
</FieldLegend>
|
|
||||||
{profiles?.length === 0 ? (
|
|
||||||
<p className="text-ui-body-sm text-muted-foreground">
|
|
||||||
<Trans>No platform profiles found.</Trans>
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<div className="flex max-h-56 flex-col gap-1 overflow-y-auto">
|
|
||||||
{(profiles ?? []).map((profile) => (
|
|
||||||
<div
|
|
||||||
key={profile.id}
|
|
||||||
className="flex items-center gap-2.5 rounded-md px-1 py-1.5 text-ui-body-sm hover:bg-muted"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
id={`${goalId}-${profile.id}`}
|
|
||||||
checked={chosen.has(profile.id)}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setChosen((current) => {
|
|
||||||
const next = new Set(current);
|
|
||||||
if (checked) next.add(profile.id);
|
|
||||||
else next.delete(profile.id);
|
|
||||||
return next;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor={`${goalId}-${profile.id}`}
|
|
||||||
className="min-w-0 flex-1 cursor-pointer truncate"
|
|
||||||
>
|
|
||||||
{profile.name}
|
|
||||||
</label>
|
|
||||||
<span className="text-ui-caption text-muted-foreground">
|
|
||||||
{profile.aspectRatios.join(" · ")}
|
|
||||||
</span>
|
|
||||||
{profile.unverified || profile.stale ? (
|
|
||||||
<Badge variant="outline">
|
|
||||||
<Trans>Unverified</Trans>
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</FieldSet>
|
|
||||||
{error ? (
|
|
||||||
<p role="alert" className="text-ui-body-sm text-destructive">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="submit" disabled={busy || !title.trim() || chosen.size === 0}>
|
|
||||||
<Trans>Create project</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,354 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,172 +0,0 @@
|
|||||||
import { Plural, Trans, useLingui } from "@lingui/react/macro";
|
|
||||||
import type { MediaCollection } from "@rakazo/contracts";
|
|
||||||
import {
|
|
||||||
InputGroup,
|
|
||||||
InputGroupAddon,
|
|
||||||
InputGroupInput,
|
|
||||||
Tabs,
|
|
||||||
TabsList,
|
|
||||||
TabsTrigger,
|
|
||||||
Toggle,
|
|
||||||
} from "@rakazo/ui-web";
|
|
||||||
import { Search } from "lucide-react";
|
|
||||||
import type { LibraryFilter } from "../../../lib/media-library";
|
|
||||||
import { formatDuration, LIBRARY_FILTERS } from "../../../lib/media-library";
|
|
||||||
import { LoadingState } from "../../ai/primitives";
|
|
||||||
import { AssetThumb } from "./AssetTile";
|
|
||||||
|
|
||||||
export type LibraryMode = "sessions" | "themes" | "people";
|
|
||||||
|
|
||||||
/** Sessions | Themes | People, shared by the library and people views. */
|
|
||||||
export function LibraryModeToggle({
|
|
||||||
mode,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
mode: LibraryMode;
|
|
||||||
onChange: (mode: LibraryMode) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useLingui();
|
|
||||||
return (
|
|
||||||
<Tabs value={mode} onValueChange={(value) => onChange(value as LibraryMode)}>
|
|
||||||
<TabsList aria-label={t`Group by`}>
|
|
||||||
<TabsTrigger value="sessions">
|
|
||||||
<Trans>Sessions</Trans>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="themes">
|
|
||||||
<Trans>Themes</Trans>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="people">
|
|
||||||
<Trans>People</Trans>
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LibrarySearch({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useLingui();
|
|
||||||
return (
|
|
||||||
<InputGroup className="w-full max-w-64">
|
|
||||||
<InputGroupAddon>
|
|
||||||
<Search strokeWidth={1.8} aria-hidden="true" className="size-icon-md" />
|
|
||||||
</InputGroupAddon>
|
|
||||||
<InputGroupInput
|
|
||||||
type="search"
|
|
||||||
value={value}
|
|
||||||
onChange={(event) => onChange(event.target.value)}
|
|
||||||
placeholder={t`Search`}
|
|
||||||
aria-label={t`Search media`}
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
</InputGroup>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FilterChips({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
value: ReadonlySet<LibraryFilter>;
|
|
||||||
onChange: (value: ReadonlySet<LibraryFilter>) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useLingui();
|
|
||||||
const labels: Record<LibraryFilter, string> = {
|
|
||||||
videos: t`Videos`,
|
|
||||||
photos: t`Photos`,
|
|
||||||
favorites: t`Favorites`,
|
|
||||||
best: t`Best`,
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<fieldset aria-label={t`Filters`} className="flex flex-wrap items-center gap-1.5">
|
|
||||||
{LIBRARY_FILTERS.map((filter) => (
|
|
||||||
<Toggle
|
|
||||||
key={filter}
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="rounded-full"
|
|
||||||
pressed={value.has(filter)}
|
|
||||||
onPressedChange={(pressed) => {
|
|
||||||
const next = new Set(value);
|
|
||||||
if (pressed) next.add(filter);
|
|
||||||
else next.delete(filter);
|
|
||||||
onChange(next);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{labels[filter]}
|
|
||||||
</Toggle>
|
|
||||||
))}
|
|
||||||
</fieldset>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Only rendered while indexing runs; disappears when the work is done. */
|
|
||||||
export function IndexingProgress({ done, total }: { done?: number; total?: number }) {
|
|
||||||
const { t } = useLingui();
|
|
||||||
return (
|
|
||||||
<div data-testid="media-indexing" className="flex items-center gap-3 px-4 py-2">
|
|
||||||
<LoadingState label={t`Indexing`} />
|
|
||||||
{total ? (
|
|
||||||
<span className="font-mono text-ui-body-sm tabular-nums text-muted-foreground">
|
|
||||||
{done ?? 0}/{total}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CollectionCard({
|
|
||||||
collection,
|
|
||||||
onOpen,
|
|
||||||
}: {
|
|
||||||
collection: MediaCollection;
|
|
||||||
onOpen: () => void;
|
|
||||||
}) {
|
|
||||||
const range = collectionDateRange(collection);
|
|
||||||
const count = collection.assetCount;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="media-collection-card"
|
|
||||||
onClick={onOpen}
|
|
||||||
className="group/card flex size-full min-w-0 flex-col gap-1.5 rounded-lg text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
<div className="relative min-h-0 flex-1 overflow-hidden rounded-lg bg-muted">
|
|
||||||
<AssetThumb
|
|
||||||
assetId={collection.coverAssetId}
|
|
||||||
hasThumbnail={collection.coverAssetId !== null}
|
|
||||||
className="transition-transform group-hover/card:scale-[1.02]"
|
|
||||||
/>
|
|
||||||
{collection.totalDurationMs > 0 ? (
|
|
||||||
<span className="absolute bottom-1.5 right-1.5 rounded bg-foreground/70 px-1 font-mono text-ui-caption tabular-nums text-background">
|
|
||||||
{formatDuration(collection.totalDurationMs)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 px-0.5">
|
|
||||||
<div className="truncate text-ui-body-sm font-medium text-foreground" dir="auto">
|
|
||||||
{collection.title}
|
|
||||||
</div>
|
|
||||||
<div className="truncate text-ui-caption text-muted-foreground">
|
|
||||||
{range ? `${range} · ` : ""}
|
|
||||||
<Plural value={count} one="# item" other="# items" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function collectionDateRange(
|
|
||||||
collection: Pick<MediaCollection, "startAt" | "endAt">,
|
|
||||||
): string {
|
|
||||||
if (!collection.startAt) return "";
|
|
||||||
const start = new Date(collection.startAt);
|
|
||||||
const end = collection.endAt ? new Date(collection.endAt) : start;
|
|
||||||
const day = (date: Date) => date.toLocaleDateString(undefined, { dateStyle: "medium" });
|
|
||||||
return day(start) === day(end) ? day(start) : `${day(start)} – ${day(end)}`;
|
|
||||||
}
|
|
||||||
@ -1,184 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,199 +0,0 @@
|
|||||||
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, UserRound } 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">
|
|
||||||
{person.coverThumbAssetId ? (
|
|
||||||
<AssetThumb assetId={person.coverThumbAssetId} hasThumbnail />
|
|
||||||
) : (
|
|
||||||
<UserRound
|
|
||||||
aria-hidden="true"
|
|
||||||
strokeWidth={1.6}
|
|
||||||
className="m-3 size-6 text-muted-foreground/60"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</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,128 +0,0 @@
|
|||||||
import { cn } from "@rakazo/ui-web";
|
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
||||||
import { gridColumns, gridRowCount } from "../../../lib/media-library";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Row-virtualized grid: only the rows in view (plus overscan) are mounted, so
|
|
||||||
* 50k items cost the same DOM as fifty. Tiles share one size derived from the
|
|
||||||
* container width; `onNearEnd` asks for the next page before the end shows.
|
|
||||||
*/
|
|
||||||
export function VirtualGrid({
|
|
||||||
count,
|
|
||||||
renderItem,
|
|
||||||
getKey,
|
|
||||||
minTileWidth = 168,
|
|
||||||
gap = 8,
|
|
||||||
tileAspect = 9 / 16,
|
|
||||||
extraHeight = 0,
|
|
||||||
overscan = 4,
|
|
||||||
onNearEnd,
|
|
||||||
footer,
|
|
||||||
className,
|
|
||||||
label,
|
|
||||||
testId,
|
|
||||||
}: {
|
|
||||||
count: number;
|
|
||||||
renderItem: (index: number) => ReactNode;
|
|
||||||
getKey?: (index: number) => string | number;
|
|
||||||
minTileWidth?: number;
|
|
||||||
gap?: number;
|
|
||||||
/** Media height / width. */
|
|
||||||
tileAspect?: number;
|
|
||||||
/** Fixed height under the media (title lines). */
|
|
||||||
extraHeight?: number;
|
|
||||||
overscan?: number;
|
|
||||||
onNearEnd?: () => void;
|
|
||||||
footer?: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
label?: string;
|
|
||||||
testId?: string;
|
|
||||||
}) {
|
|
||||||
const scrollRef = useRef<HTMLElement>(null);
|
|
||||||
const [width, setWidth] = useState(0);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const element = scrollRef.current;
|
|
||||||
if (!element) return;
|
|
||||||
const measure = () => setWidth(element.clientWidth);
|
|
||||||
measure();
|
|
||||||
const observer = new ResizeObserver(measure);
|
|
||||||
observer.observe(element);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const padding = 16;
|
|
||||||
const inner = Math.max(0, width - padding * 2);
|
|
||||||
const columns = gridColumns(inner, minTileWidth, gap);
|
|
||||||
const tileWidth = columns > 0 ? (inner - gap * (columns - 1)) / columns : minTileWidth;
|
|
||||||
const rowHeight = Math.round(tileWidth * tileAspect) + extraHeight + gap;
|
|
||||||
const rows = gridRowCount(count, columns);
|
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
|
||||||
count: rows,
|
|
||||||
getScrollElement: () => scrollRef.current,
|
|
||||||
estimateSize: () => rowHeight,
|
|
||||||
overscan,
|
|
||||||
paddingStart: padding,
|
|
||||||
paddingEnd: padding,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
virtualizer.measure();
|
|
||||||
}, [virtualizer, rowHeight]);
|
|
||||||
|
|
||||||
const items = virtualizer.getVirtualItems();
|
|
||||||
const lastRow = items.at(-1)?.index ?? -1;
|
|
||||||
const nearEnd = rows > 0 && lastRow >= rows - overscan - 1;
|
|
||||||
const latestNearEnd = useRef(onNearEnd);
|
|
||||||
latestNearEnd.current = onNearEnd;
|
|
||||||
useEffect(() => {
|
|
||||||
if (nearEnd) latestNearEnd.current?.();
|
|
||||||
}, [nearEnd, count]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
ref={scrollRef}
|
|
||||||
aria-label={label}
|
|
||||||
data-testid={testId}
|
|
||||||
data-count={count}
|
|
||||||
className={cn("rk-scroll relative min-h-0 flex-1 overflow-y-auto", className)}
|
|
||||||
>
|
|
||||||
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
|
||||||
{width > 0
|
|
||||||
? items.map((row) => {
|
|
||||||
const start = row.index * columns;
|
|
||||||
const end = Math.min(count, start + columns);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={row.key}
|
|
||||||
className="absolute inset-x-4 grid"
|
|
||||||
style={{
|
|
||||||
top: row.start,
|
|
||||||
gap,
|
|
||||||
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Array.from({ length: end - start }, (_, offset) => {
|
|
||||||
const index = start + offset;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={getKey ? getKey(index) : index}
|
|
||||||
style={{ height: rowHeight - gap }}
|
|
||||||
className="min-w-0"
|
|
||||||
>
|
|
||||||
{renderItem(index)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
: null}
|
|
||||||
</div>
|
|
||||||
{footer}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
import type { PhotosSourceStatus, PhotosSyncOutcome, PhotosSyncProgress } from "@rakazo/contracts";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { desktopBridge } from "../../../lib/desktop";
|
|
||||||
import { selectedSpaceId } from "../../../lib/rpc";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apple Photos runs in the desktop main process; the renderer only asks for a
|
|
||||||
* sync and shows progress. Absent outside the macOS desktop app.
|
|
||||||
*/
|
|
||||||
export function usePhotosSync(onFinished?: (outcome: PhotosSyncOutcome) => void) {
|
|
||||||
const photos = desktopBridge()?.media?.photos;
|
|
||||||
const [status, setStatus] = useState<PhotosSourceStatus>({ available: false });
|
|
||||||
const [progress, setProgress] = useState<PhotosSyncProgress | null>(null);
|
|
||||||
const [syncing, setSyncing] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!photos) return;
|
|
||||||
let active = true;
|
|
||||||
photos
|
|
||||||
.status()
|
|
||||||
.then((value) => {
|
|
||||||
if (!active) return;
|
|
||||||
setStatus(value);
|
|
||||||
if (value.available && value.syncing) setSyncing(true);
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
const off = photos.onProgress((value) => {
|
|
||||||
if (active) setProgress(value);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
off();
|
|
||||||
};
|
|
||||||
}, [photos]);
|
|
||||||
|
|
||||||
const sync = useCallback(async () => {
|
|
||||||
if (!photos) return;
|
|
||||||
setSyncing(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const outcome = await photos.sync({ spaceId: selectedSpaceId() });
|
|
||||||
if (!outcome.ok) setError(outcome.message);
|
|
||||||
onFinished?.(outcome);
|
|
||||||
} catch (cause) {
|
|
||||||
setError(cause instanceof Error ? cause.message : String(cause));
|
|
||||||
} finally {
|
|
||||||
setSyncing(false);
|
|
||||||
setProgress(null);
|
|
||||||
photos
|
|
||||||
.status()
|
|
||||||
.then(setStatus)
|
|
||||||
.catch(() => undefined);
|
|
||||||
}
|
|
||||||
}, [photos, onFinished]);
|
|
||||||
|
|
||||||
const cancel = useCallback(() => {
|
|
||||||
void photos?.cancel().catch(() => undefined);
|
|
||||||
}, [photos]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
available: Boolean(photos) && status.available,
|
|
||||||
denied:
|
|
||||||
status.available &&
|
|
||||||
(status.authorization === "denied" || status.authorization === "restricted"),
|
|
||||||
syncing,
|
|
||||||
progress,
|
|
||||||
error,
|
|
||||||
sync,
|
|
||||||
cancel,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import {
|
|
||||||
createMediaObjectUrlCache,
|
|
||||||
mediaAssetPath,
|
|
||||||
mediaBundlePath,
|
|
||||||
mediaRenderFilePath,
|
|
||||||
mediaUploadChunkPath,
|
|
||||||
} from "./media-bytes";
|
|
||||||
|
|
||||||
function setup(idleLimit = 2) {
|
|
||||||
let next = 0;
|
|
||||||
const revoked: string[] = [];
|
|
||||||
const loads: string[] = [];
|
|
||||||
const pending = new Map<string, (blob: Blob) => void>();
|
|
||||||
const cache = createMediaObjectUrlCache({
|
|
||||||
load: (path, signal) => {
|
|
||||||
loads.push(path);
|
|
||||||
return new Promise<Blob>((resolve, reject) => {
|
|
||||||
pending.set(path, resolve);
|
|
||||||
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")));
|
|
||||||
});
|
|
||||||
},
|
|
||||||
createUrl: () => {
|
|
||||||
next += 1;
|
|
||||||
return `blob:${next}`;
|
|
||||||
},
|
|
||||||
revokeUrl: (url) => revoked.push(url),
|
|
||||||
spaceId: () => "space-1",
|
|
||||||
idleLimit,
|
|
||||||
});
|
|
||||||
const finish = (path: string) => pending.get(path)?.(new Blob(["x"]));
|
|
||||||
return { cache, revoked, loads, finish };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("media byte paths", () => {
|
|
||||||
it("encodes ids into the byte routes", () => {
|
|
||||||
expect(mediaAssetPath("a/b", "thumb")).toBe("/api/media/assets/a%2Fb/thumb");
|
|
||||||
expect(mediaRenderFilePath("r1", "video.mp4", { download: true })).toBe(
|
|
||||||
"/api/media/renders/r1/files/video.mp4?download=1",
|
|
||||||
);
|
|
||||||
expect(mediaBundlePath("p1")).toBe("/api/media/bundles/p1");
|
|
||||||
expect(mediaUploadChunkPath("u1", 3)).toBe("/api/media/uploads/u1/chunks/3");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("media object URL cache", () => {
|
|
||||||
it("shares one load between holders", async () => {
|
|
||||||
const { cache, loads, finish } = setup();
|
|
||||||
const a = cache.acquire("/t/1", { keep: true });
|
|
||||||
const b = cache.acquire("/t/1", { keep: true });
|
|
||||||
finish("/t/1");
|
|
||||||
await expect(a.promise).resolves.toBe("blob:1");
|
|
||||||
await expect(b.promise).resolves.toBe("blob:1");
|
|
||||||
expect(loads).toEqual(["/t/1"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps thumbnails idle up to the limit, evicting the oldest", async () => {
|
|
||||||
const { cache, revoked, finish } = setup(2);
|
|
||||||
for (const id of ["1", "2", "3"]) {
|
|
||||||
const handle = cache.acquire(`/t/${id}`, { keep: true });
|
|
||||||
finish(`/t/${id}`);
|
|
||||||
await handle.promise;
|
|
||||||
handle.release();
|
|
||||||
}
|
|
||||||
expect(cache.size()).toBe(2);
|
|
||||||
expect(revoked).toEqual(["blob:1"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops large files as soon as the last holder releases", async () => {
|
|
||||||
const { cache, revoked, finish } = setup();
|
|
||||||
const handle = cache.acquire("/p/1");
|
|
||||||
finish("/p/1");
|
|
||||||
await handle.promise;
|
|
||||||
handle.release();
|
|
||||||
handle.release();
|
|
||||||
expect(cache.size()).toBe(0);
|
|
||||||
expect(revoked).toEqual(["blob:1"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("aborts a load nobody waits for anymore", async () => {
|
|
||||||
const { cache } = setup();
|
|
||||||
const handle = cache.acquire("/p/2");
|
|
||||||
const settled = vi.fn();
|
|
||||||
handle.promise.catch(settled);
|
|
||||||
handle.release();
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
expect(settled).toHaveBeenCalled();
|
|
||||||
expect(cache.size()).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,218 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { selectedSpaceId, withSpaceHeaders } from "./rpc";
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Media byte routes (thumbnails, proxies, render files, bundles, upload chunks).
|
|
||||||
* They need the session cookie and the space header, which an `<img src>` or
|
|
||||||
* `<video src>` cannot send, so bytes are fetched here and handed to elements
|
|
||||||
* as object URLs. Thumbnails stay cached after release; large files do not.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type MediaAssetFile = "thumb" | "proxy";
|
|
||||||
|
|
||||||
export function mediaAssetPath(assetId: string, file: MediaAssetFile): string {
|
|
||||||
return `/api/media/assets/${encodeURIComponent(assetId)}/${file}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mediaRenderFilePath(
|
|
||||||
renderId: string,
|
|
||||||
fileName: string,
|
|
||||||
options: { download?: boolean } = {},
|
|
||||||
): string {
|
|
||||||
const base = `/api/media/renders/${encodeURIComponent(renderId)}/files/${encodeURIComponent(fileName)}`;
|
|
||||||
return options.download ? `${base}?download=1` : base;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mediaBundlePath(publicationId: string): string {
|
|
||||||
return `/api/media/bundles/${encodeURIComponent(publicationId)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mediaUploadChunkPath(uploadId: string, index: number): string {
|
|
||||||
return `/api/media/uploads/${encodeURIComponent(uploadId)}/chunks/${index}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MediaFetchError extends Error {
|
|
||||||
constructor(readonly status: number) {
|
|
||||||
super(`Media request failed (${status})`);
|
|
||||||
this.name = "MediaFetchError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetch a media byte route with the session cookie and the selected space. */
|
|
||||||
export async function fetchMedia(path: string, init: RequestInit = {}): Promise<Response> {
|
|
||||||
const response = await fetch(path, {
|
|
||||||
...init,
|
|
||||||
credentials: "include",
|
|
||||||
headers: withSpaceHeaders(init.headers),
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new MediaFetchError(response.status);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Save a media file through the browser's download flow. */
|
|
||||||
export async function downloadMedia(path: string, fileName: string): Promise<void> {
|
|
||||||
const blob = await (await fetchMedia(path)).blob();
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
try {
|
|
||||||
const anchor = document.createElement("a");
|
|
||||||
anchor.href = url;
|
|
||||||
anchor.download = fileName;
|
|
||||||
anchor.click();
|
|
||||||
} finally {
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Entry = {
|
|
||||||
promise: Promise<string>;
|
|
||||||
url: string | null;
|
|
||||||
refs: number;
|
|
||||||
/** Keep the object URL after the last release (small files such as thumbnails). */
|
|
||||||
keep: boolean;
|
|
||||||
controller: AbortController;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Idle thumbnails kept around for scrolling back; each one is a few KB. */
|
|
||||||
export const MEDIA_IDLE_CACHE_LIMIT = 400;
|
|
||||||
|
|
||||||
export interface MediaObjectUrlCache {
|
|
||||||
acquire(
|
|
||||||
path: string,
|
|
||||||
options?: { keep?: boolean },
|
|
||||||
): { promise: Promise<string>; release: () => void };
|
|
||||||
/** Number of entries, including idle ones (for tests). */
|
|
||||||
size(): number;
|
|
||||||
clear(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMediaObjectUrlCache(deps: {
|
|
||||||
load: (path: string, signal: AbortSignal) => Promise<Blob>;
|
|
||||||
createUrl: (blob: Blob) => string;
|
|
||||||
revokeUrl: (url: string) => void;
|
|
||||||
spaceId: () => string | null;
|
|
||||||
idleLimit?: number;
|
|
||||||
}): MediaObjectUrlCache {
|
|
||||||
const entries = new Map<string, Entry>();
|
|
||||||
const idleLimit = deps.idleLimit ?? MEDIA_IDLE_CACHE_LIMIT;
|
|
||||||
|
|
||||||
const drop = (key: string, entry: Entry) => {
|
|
||||||
entries.delete(key);
|
|
||||||
entry.controller.abort();
|
|
||||||
if (entry.url) deps.revokeUrl(entry.url);
|
|
||||||
};
|
|
||||||
|
|
||||||
const trim = () => {
|
|
||||||
let idle = 0;
|
|
||||||
for (const entry of entries.values()) if (entry.refs === 0) idle += 1;
|
|
||||||
for (const [key, entry] of entries) {
|
|
||||||
if (idle <= idleLimit) break;
|
|
||||||
if (entry.refs > 0) continue;
|
|
||||||
drop(key, entry);
|
|
||||||
idle -= 1;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
acquire(path, options = {}) {
|
|
||||||
const key = `${deps.spaceId() ?? ""}|${path}`;
|
|
||||||
let entry = entries.get(key);
|
|
||||||
if (entry) {
|
|
||||||
// Re-insert so the map order stays least recently used first.
|
|
||||||
entries.delete(key);
|
|
||||||
entries.set(key, entry);
|
|
||||||
} else {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const created: Entry = {
|
|
||||||
url: null,
|
|
||||||
refs: 0,
|
|
||||||
keep: options.keep ?? false,
|
|
||||||
controller,
|
|
||||||
promise: Promise.resolve(""),
|
|
||||||
};
|
|
||||||
created.promise = deps.load(path, controller.signal).then(
|
|
||||||
(blob) => {
|
|
||||||
const url = deps.createUrl(blob);
|
|
||||||
if (entries.get(key) !== created) {
|
|
||||||
deps.revokeUrl(url);
|
|
||||||
throw new DOMException("Released", "AbortError");
|
|
||||||
}
|
|
||||||
created.url = url;
|
|
||||||
return url;
|
|
||||||
},
|
|
||||||
(error: unknown) => {
|
|
||||||
if (entries.get(key) === created) entries.delete(key);
|
|
||||||
throw error;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
// Callers that went away never observe the rejection.
|
|
||||||
created.promise.catch(() => undefined);
|
|
||||||
entries.set(key, created);
|
|
||||||
entry = created;
|
|
||||||
}
|
|
||||||
const held = entry;
|
|
||||||
held.refs += 1;
|
|
||||||
let released = false;
|
|
||||||
return {
|
|
||||||
promise: held.promise,
|
|
||||||
release: () => {
|
|
||||||
if (released) return;
|
|
||||||
released = true;
|
|
||||||
held.refs -= 1;
|
|
||||||
if (held.refs > 0 || entries.get(key) !== held) return;
|
|
||||||
if (held.keep && held.url) trim();
|
|
||||||
else drop(key, held);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
size: () => entries.size,
|
|
||||||
clear: () => {
|
|
||||||
for (const [key, entry] of entries) drop(key, entry);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const sharedCache = createMediaObjectUrlCache({
|
|
||||||
load: async (path, signal) => (await fetchMedia(path, { signal })).blob(),
|
|
||||||
createUrl: (blob) => URL.createObjectURL(blob),
|
|
||||||
revokeUrl: (url) => URL.revokeObjectURL(url),
|
|
||||||
spaceId: selectedSpaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type MediaObjectUrlState =
|
|
||||||
| { status: "idle" | "loading"; url: null }
|
|
||||||
| { status: "ready"; url: string }
|
|
||||||
| { status: "missing"; url: null };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Object URL for a media byte route, or null while loading, when `path` is null,
|
|
||||||
* or when the file does not exist (yet). `keep` caches the bytes after unmount.
|
|
||||||
*/
|
|
||||||
export function useMediaObjectUrl(
|
|
||||||
path: string | null,
|
|
||||||
options: { keep?: boolean } = {},
|
|
||||||
): MediaObjectUrlState {
|
|
||||||
const keep = options.keep ?? false;
|
|
||||||
const [state, setState] = useState<MediaObjectUrlState>({ status: "idle", url: null });
|
|
||||||
useEffect(() => {
|
|
||||||
if (!path) {
|
|
||||||
setState({ status: "idle", url: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let active = true;
|
|
||||||
setState({ status: "loading", url: null });
|
|
||||||
const handle = sharedCache.acquire(path, { keep });
|
|
||||||
handle.promise.then(
|
|
||||||
(url) => {
|
|
||||||
if (active) setState({ status: "ready", url });
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
if (active) setState({ status: "missing", url: null });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
handle.release();
|
|
||||||
};
|
|
||||||
}, [path, keep]);
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
@ -1,182 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
assetQueryFor,
|
|
||||||
BEST_QUALITY,
|
|
||||||
consentInput,
|
|
||||||
consentState,
|
|
||||||
csvHeaders,
|
|
||||||
dayToIso,
|
|
||||||
formatDuration,
|
|
||||||
funnelTotals,
|
|
||||||
funnelWidths,
|
|
||||||
gridColumns,
|
|
||||||
gridRowCount,
|
|
||||||
guessColumns,
|
|
||||||
guessDelimiter,
|
|
||||||
guessPublicationKey,
|
|
||||||
hasAssetQuery,
|
|
||||||
type LibraryFilter,
|
|
||||||
scrubFraction,
|
|
||||||
splitCsvLine,
|
|
||||||
} from "./media-library";
|
|
||||||
|
|
||||||
describe("library filters", () => {
|
|
||||||
it("maps chips to an asset query", () => {
|
|
||||||
const filters = new Set<LibraryFilter>(["videos", "favorites", "best"]);
|
|
||||||
expect(assetQueryFor(filters, " beach ")).toEqual({
|
|
||||||
kinds: ["video"],
|
|
||||||
favoritesOnly: true,
|
|
||||||
minQuality: BEST_QUALITY,
|
|
||||||
text: "beach",
|
|
||||||
});
|
|
||||||
expect(assetQueryFor(new Set(["photos"]), "")).toEqual({ kinds: ["photo", "live_photo"] });
|
|
||||||
expect(assetQueryFor(new Set(), "")).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("knows when a query is active", () => {
|
|
||||||
expect(hasAssetQuery(new Set(), " ")).toBe(false);
|
|
||||||
expect(hasAssetQuery(new Set(), "x")).toBe(true);
|
|
||||||
expect(hasAssetQuery(new Set(["videos"]), "")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("grid geometry", () => {
|
|
||||||
it("fits columns to the width", () => {
|
|
||||||
expect(gridColumns(0, 160, 8)).toBe(1);
|
|
||||||
expect(gridColumns(159, 160, 8)).toBe(1);
|
|
||||||
expect(gridColumns(328, 160, 8)).toBe(2);
|
|
||||||
expect(gridColumns(1000, 160, 8)).toBe(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("counts rows for 50k assets without materializing them", () => {
|
|
||||||
expect(gridRowCount(50_000, 6)).toBe(8334);
|
|
||||||
expect(gridRowCount(0, 6)).toBe(0);
|
|
||||||
expect(gridRowCount(5, 0)).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clamps the scrub position", () => {
|
|
||||||
expect(scrubFraction(50, 0, 100)).toBe(0.5);
|
|
||||||
expect(scrubFraction(-10, 0, 100)).toBe(0);
|
|
||||||
expect(scrubFraction(200, 0, 100)).toBe(1);
|
|
||||||
expect(scrubFraction(5, 0, 0)).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("formatDuration", () => {
|
|
||||||
it("formats clip lengths", () => {
|
|
||||||
expect(formatDuration(4000)).toBe("0:04");
|
|
||||||
expect(formatDuration(62_400)).toBe("1:02");
|
|
||||||
expect(formatDuration(3_723_000)).toBe("1:02:03");
|
|
||||||
expect(formatDuration(null)).toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("consent", () => {
|
|
||||||
const now = new Date("2026-10-03T10:00:00Z");
|
|
||||||
|
|
||||||
it("records platform verification with its date", () => {
|
|
||||||
expect(
|
|
||||||
consentInput(
|
|
||||||
"p1",
|
|
||||||
{ consentDate: "2026-10-01", method: "platform_verified", verifiedDate: "2026-09-30" },
|
|
||||||
now,
|
|
||||||
),
|
|
||||||
).toEqual({
|
|
||||||
personId: "p1",
|
|
||||||
consentArtifactId: null,
|
|
||||||
consentRecordedAt: "2026-10-01T12:00:00.000Z",
|
|
||||||
ageVerificationMethod: "platform_verified",
|
|
||||||
ageVerifiedAt: "2026-09-30T12:00:00.000Z",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops the verification date when there is no age proof", () => {
|
|
||||||
expect(
|
|
||||||
consentInput("p1", { consentDate: "2026-10-01", method: "none", verifiedDate: "x" }, now)
|
|
||||||
?.ageVerifiedAt,
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects incomplete or future dates", () => {
|
|
||||||
expect(
|
|
||||||
consentInput("p1", { consentDate: "", method: "none", verifiedDate: "" }, now),
|
|
||||||
).toBeNull();
|
|
||||||
expect(
|
|
||||||
consentInput(
|
|
||||||
"p1",
|
|
||||||
{ consentDate: "2026-10-01", method: "document_on_file", verifiedDate: "" },
|
|
||||||
now,
|
|
||||||
),
|
|
||||||
).toBeNull();
|
|
||||||
expect(
|
|
||||||
consentInput("p1", { consentDate: "2026-12-01", method: "none", verifiedDate: "" }, now),
|
|
||||||
).toBeNull();
|
|
||||||
expect(dayToIso("2026-13-45")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("summarizes a person's state", () => {
|
|
||||||
const base = { consentRecordedAt: null, ageVerificationMethod: "none", ageVerifiedAt: null };
|
|
||||||
expect(consentState({ ...base, ageVerificationMethod: "none" })).toBe("missing");
|
|
||||||
expect(consentState({ ...base, consentRecordedAt: "2026-10-01T12:00:00.000Z" } as never)).toBe(
|
|
||||||
"consent_only",
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
consentState({
|
|
||||||
consentRecordedAt: "2026-10-01T12:00:00.000Z",
|
|
||||||
ageVerificationMethod: "platform_verified",
|
|
||||||
ageVerifiedAt: "2026-10-01T12:00:00.000Z",
|
|
||||||
}),
|
|
||||||
).toBe("complete");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("csv mapping", () => {
|
|
||||||
it("splits quoted cells", () => {
|
|
||||||
expect(splitCsvLine('a,"b, c","d ""e"""', ",")).toEqual(["a", "b, c", 'd "e"']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects the delimiter and headers", () => {
|
|
||||||
const csv = "Datum;utm_content;Aufrufe;Klicks;Käufe;Umsatz\n2026-10-01;a;1;2;3;4";
|
|
||||||
const delimiter = guessDelimiter(csv.split("\n")[0] ?? "");
|
|
||||||
expect(delimiter).toBe(";");
|
|
||||||
const headers = csvHeaders(csv, delimiter);
|
|
||||||
expect(headers).toEqual(["Datum", "utm_content", "Aufrufe", "Klicks", "Käufe", "Umsatz"]);
|
|
||||||
expect(guessColumns(headers)).toEqual({
|
|
||||||
publication: "utm_content",
|
|
||||||
observedAt: "Datum",
|
|
||||||
views: "Aufrufe",
|
|
||||||
clicks: "Klicks",
|
|
||||||
conversions: "Käufe",
|
|
||||||
revenue: "Umsatz",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("guesses how publications are identified", () => {
|
|
||||||
expect(guessPublicationKey("utm_content")).toBe("utm_content");
|
|
||||||
expect(guessPublicationKey("tracking_url")).toBe("tracking_url");
|
|
||||||
expect(guessPublicationKey("Post URL")).toBe("external_url");
|
|
||||||
expect(guessPublicationKey("publication_id")).toBe("id");
|
|
||||||
expect(guessPublicationKey(undefined)).toBe("utm_content");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("funnel", () => {
|
|
||||||
it("sums steps and keeps currencies apart", () => {
|
|
||||||
const totals = funnelTotals([
|
|
||||||
{ views: 1000, clicks: 100, conversions: 10, revenueCents: 9900, currency: "EUR" },
|
|
||||||
{ views: 500, clicks: 20, conversions: 1, revenueCents: 1500, currency: "USD" },
|
|
||||||
{ views: 10, clicks: 0, conversions: 0, revenueCents: 0, currency: "EUR" },
|
|
||||||
]);
|
|
||||||
expect(totals).toEqual({
|
|
||||||
views: 1510,
|
|
||||||
clicks: 120,
|
|
||||||
conversions: 11,
|
|
||||||
revenue: [
|
|
||||||
{ currency: "EUR", cents: 9900 },
|
|
||||||
{ currency: "USD", cents: 1500 },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(funnelWidths(totals).views).toBe(1);
|
|
||||||
expect(funnelWidths({ views: 0, clicks: 0, conversions: 0 }).views).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,315 +0,0 @@
|
|||||||
import type {
|
|
||||||
AgeVerificationMethod,
|
|
||||||
ConsentInput,
|
|
||||||
FunnelRow,
|
|
||||||
MediaAssetKind,
|
|
||||||
MediaAssetQuery,
|
|
||||||
MediaPerson,
|
|
||||||
MetricCsvMapping,
|
|
||||||
} from "@rakazo/contracts";
|
|
||||||
|
|
||||||
/* Pure helpers behind the Media Studio library, people and results views. */
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Library filters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const LIBRARY_FILTERS = ["videos", "photos", "favorites", "best"] as const;
|
|
||||||
export type LibraryFilter = (typeof LIBRARY_FILTERS)[number];
|
|
||||||
|
|
||||||
/** Quality floor for the "best" chip. */
|
|
||||||
export const BEST_QUALITY = 0.6;
|
|
||||||
|
|
||||||
export function assetQueryFor(
|
|
||||||
filters: ReadonlySet<LibraryFilter>,
|
|
||||||
text: string,
|
|
||||||
): Partial<MediaAssetQuery> {
|
|
||||||
const query: Partial<MediaAssetQuery> = {};
|
|
||||||
const kinds: MediaAssetKind[] = [];
|
|
||||||
if (filters.has("videos")) kinds.push("video");
|
|
||||||
if (filters.has("photos")) kinds.push("photo", "live_photo");
|
|
||||||
if (kinds.length > 0) query.kinds = kinds;
|
|
||||||
if (filters.has("favorites")) query.favoritesOnly = true;
|
|
||||||
if (filters.has("best")) query.minQuality = BEST_QUALITY;
|
|
||||||
const trimmed = text.trim();
|
|
||||||
if (trimmed) query.text = trimmed.slice(0, 200);
|
|
||||||
return query;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasAssetQuery(filters: ReadonlySet<LibraryFilter>, text: string): boolean {
|
|
||||||
return filters.size > 0 || text.trim().length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ingest states that mean the server is working on an asset. "indexed" is left
|
|
||||||
* out: device-held assets wait there until the device uploads them.
|
|
||||||
*/
|
|
||||||
export const INGEST_PENDING_STATES = ["proxying", "proxied", "analyzing"] as const;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Grid geometry
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Columns that fit `width` with tiles at least `minTile` wide. */
|
|
||||||
export function gridColumns(width: number, minTile: number, gap: number): number {
|
|
||||||
if (!Number.isFinite(width) || width <= 0) return 1;
|
|
||||||
return Math.max(1, Math.floor((width + gap) / (minTile + gap)));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function gridRowCount(items: number, columns: number): number {
|
|
||||||
return Math.ceil(items / Math.max(1, columns));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Formatting
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function formatDuration(ms: number | null | undefined): string {
|
|
||||||
if (ms == null || !Number.isFinite(ms) || ms < 0) return "";
|
|
||||||
const total = Math.round(ms / 1000);
|
|
||||||
const hours = Math.floor(total / 3600);
|
|
||||||
const minutes = Math.floor((total % 3600) / 60);
|
|
||||||
const seconds = String(total % 60).padStart(2, "0");
|
|
||||||
if (hours > 0) return `${hours}:${String(minutes).padStart(2, "0")}:${seconds}`;
|
|
||||||
return `${minutes}:${seconds}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatMoney(cents: number, currency: string, locale?: string): string {
|
|
||||||
try {
|
|
||||||
return new Intl.NumberFormat(locale, { style: "currency", currency }).format(cents / 100);
|
|
||||||
} catch {
|
|
||||||
return `${(cents / 100).toFixed(2)} ${currency}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatRate(numerator: number, denominator: number, locale?: string): string {
|
|
||||||
if (denominator <= 0) return "–";
|
|
||||||
return new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1 }).format(
|
|
||||||
numerator / denominator,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Position of the pointer across an element as 0..1, for hover scrubbing. */
|
|
||||||
export function scrubFraction(clientX: number, left: number, width: number): number {
|
|
||||||
if (width <= 0) return 0;
|
|
||||||
return Math.min(1, Math.max(0, (clientX - left) / width));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Consent
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type ConsentState = "complete" | "consent_only" | "missing";
|
|
||||||
|
|
||||||
export function consentState(
|
|
||||||
person: Pick<MediaPerson, "consentRecordedAt" | "ageVerificationMethod" | "ageVerifiedAt">,
|
|
||||||
): ConsentState {
|
|
||||||
if (!person.consentRecordedAt) return "missing";
|
|
||||||
if (person.ageVerificationMethod !== "none" && person.ageVerifiedAt) return "complete";
|
|
||||||
return "consent_only";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConsentForm {
|
|
||||||
/** yyyy-mm-dd from a date input. */
|
|
||||||
consentDate: string;
|
|
||||||
method: AgeVerificationMethod;
|
|
||||||
/** yyyy-mm-dd; ignored when method is "none". */
|
|
||||||
verifiedDate: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
||||||
|
|
||||||
/** yyyy-mm-dd → ISO timestamp at UTC noon, so the calendar day survives any time zone. */
|
|
||||||
export function dayToIso(day: string): string | null {
|
|
||||||
if (!DAY.test(day)) return null;
|
|
||||||
const date = new Date(`${day}T12:00:00.000Z`);
|
|
||||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isoToDay(iso: string | null | undefined): string {
|
|
||||||
return iso ? iso.slice(0, 10) : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function todayDay(now: Date = new Date()): string {
|
|
||||||
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
|
|
||||||
return local.toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build the recordConsent input, or null when the form is incomplete. */
|
|
||||||
export function consentInput(
|
|
||||||
personId: string,
|
|
||||||
form: ConsentForm,
|
|
||||||
now: Date = new Date(),
|
|
||||||
): ConsentInput | null {
|
|
||||||
const consentRecordedAt = dayToIso(form.consentDate);
|
|
||||||
if (!consentRecordedAt) return null;
|
|
||||||
const today = todayDay(now);
|
|
||||||
if (form.consentDate > today) return null;
|
|
||||||
if (form.method === "none") {
|
|
||||||
return {
|
|
||||||
personId,
|
|
||||||
consentArtifactId: null,
|
|
||||||
consentRecordedAt,
|
|
||||||
ageVerificationMethod: "none",
|
|
||||||
ageVerifiedAt: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const ageVerifiedAt = dayToIso(form.verifiedDate);
|
|
||||||
if (!ageVerifiedAt || form.verifiedDate > today) return null;
|
|
||||||
return {
|
|
||||||
personId,
|
|
||||||
consentArtifactId: null,
|
|
||||||
consentRecordedAt,
|
|
||||||
ageVerificationMethod: form.method,
|
|
||||||
ageVerifiedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// CSV metric import
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type CsvDelimiter = MetricCsvMapping["delimiter"];
|
|
||||||
|
|
||||||
/** Split one CSV line, honoring double-quoted fields. */
|
|
||||||
export function splitCsvLine(line: string, delimiter: CsvDelimiter): string[] {
|
|
||||||
const cells: string[] = [];
|
|
||||||
let cell = "";
|
|
||||||
let quoted = false;
|
|
||||||
for (let index = 0; index < line.length; index += 1) {
|
|
||||||
const char = line[index];
|
|
||||||
if (quoted) {
|
|
||||||
if (char === '"' && line[index + 1] === '"') {
|
|
||||||
cell += '"';
|
|
||||||
index += 1;
|
|
||||||
} else if (char === '"') {
|
|
||||||
quoted = false;
|
|
||||||
} else {
|
|
||||||
cell += char;
|
|
||||||
}
|
|
||||||
} else if (char === '"') {
|
|
||||||
quoted = true;
|
|
||||||
} else if (char === delimiter) {
|
|
||||||
cells.push(cell.trim());
|
|
||||||
cell = "";
|
|
||||||
} else {
|
|
||||||
cell += char;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cells.push(cell.trim());
|
|
||||||
return cells;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function firstCsvLine(csv: string): string {
|
|
||||||
const withoutBom = csv.startsWith("") ? csv.slice(1) : csv;
|
|
||||||
return withoutBom.split(/\r?\n/, 1)[0] ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function guessDelimiter(headerLine: string): CsvDelimiter {
|
|
||||||
const counts: Array<[CsvDelimiter, number]> = [
|
|
||||||
[",", headerLine.split(",").length],
|
|
||||||
[";", headerLine.split(";").length],
|
|
||||||
["\t", headerLine.split("\t").length],
|
|
||||||
];
|
|
||||||
counts.sort((a, b) => b[1] - a[1]);
|
|
||||||
return counts[0]?.[0] ?? ",";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function csvHeaders(csv: string, delimiter: CsvDelimiter): string[] {
|
|
||||||
return splitCsvLine(firstCsvLine(csv), delimiter).filter((header) => header.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MappingColumn = keyof MetricCsvMapping["columns"];
|
|
||||||
export const MAPPING_COLUMNS: MappingColumn[] = [
|
|
||||||
"publication",
|
|
||||||
"observedAt",
|
|
||||||
"views",
|
|
||||||
"clicks",
|
|
||||||
"conversions",
|
|
||||||
"revenue",
|
|
||||||
"currency",
|
|
||||||
];
|
|
||||||
|
|
||||||
const COLUMN_HINTS: Record<MappingColumn, RegExp> = {
|
|
||||||
publication: /(utm.?content|tracking|publication|post|video|url|link|^id$)/i,
|
|
||||||
observedAt: /(date|day|time|observed|datum)/i,
|
|
||||||
views: /(views?|impressions?|plays?|aufrufe)/i,
|
|
||||||
clicks: /(clicks?|taps?|klicks?)/i,
|
|
||||||
conversions: /(conversions?|purchases?|sales|orders?|käufe|kaeufe)/i,
|
|
||||||
revenue: /(revenue|earnings|amount|umsatz|income)/i,
|
|
||||||
currency: /(currency|währung|waehrung)/i,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Best guess of which header feeds which metric; every header is used at most once. */
|
|
||||||
export function guessColumns(headers: string[]): Partial<Record<MappingColumn, string>> {
|
|
||||||
const used = new Set<string>();
|
|
||||||
const guess: Partial<Record<MappingColumn, string>> = {};
|
|
||||||
for (const column of MAPPING_COLUMNS) {
|
|
||||||
const match = headers.find((header) => !used.has(header) && COLUMN_HINTS[column].test(header));
|
|
||||||
if (match) {
|
|
||||||
guess[column] = match;
|
|
||||||
used.add(match);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return guess;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function guessPublicationKey(
|
|
||||||
header: string | undefined,
|
|
||||||
): MetricCsvMapping["publicationKey"] {
|
|
||||||
if (!header) return "utm_content";
|
|
||||||
if (/utm/i.test(header)) return "utm_content";
|
|
||||||
if (/track/i.test(header)) return "tracking_url";
|
|
||||||
if (/(url|link)/i.test(header)) return "external_url";
|
|
||||||
if (/id$/i.test(header)) return "id";
|
|
||||||
return "utm_content";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Funnel
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface FunnelTotals {
|
|
||||||
views: number;
|
|
||||||
clicks: number;
|
|
||||||
conversions: number;
|
|
||||||
/** One amount per currency; revenue never mixes currencies. */
|
|
||||||
revenue: Array<{ currency: string; cents: number }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function funnelTotals(
|
|
||||||
rows: ReadonlyArray<
|
|
||||||
Pick<FunnelRow, "views" | "clicks" | "conversions" | "revenueCents" | "currency">
|
|
||||||
>,
|
|
||||||
): FunnelTotals {
|
|
||||||
const revenue = new Map<string, number>();
|
|
||||||
let views = 0;
|
|
||||||
let clicks = 0;
|
|
||||||
let conversions = 0;
|
|
||||||
for (const row of rows) {
|
|
||||||
views += row.views;
|
|
||||||
clicks += row.clicks;
|
|
||||||
conversions += row.conversions;
|
|
||||||
if (row.revenueCents > 0 || !revenue.has(row.currency)) {
|
|
||||||
revenue.set(row.currency, (revenue.get(row.currency) ?? 0) + row.revenueCents);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
views,
|
|
||||||
clicks,
|
|
||||||
conversions,
|
|
||||||
revenue: [...revenue.entries()]
|
|
||||||
.map(([currency, cents]) => ({ currency, cents }))
|
|
||||||
.sort((a, b) => b.cents - a.cents || a.currency.localeCompare(b.currency)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bar widths for the funnel steps, each relative to views (0..1). */
|
|
||||||
export function funnelWidths(totals: Pick<FunnelTotals, "views" | "clicks" | "conversions">) {
|
|
||||||
const base = Math.max(totals.views, totals.clicks, totals.conversions, 1);
|
|
||||||
return {
|
|
||||||
views: totals.views / base,
|
|
||||||
clicks: totals.clicks / base,
|
|
||||||
conversions: totals.conversions / base,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
/** Media has no realtime subscription: clients poll while work runs, then stop. */
|
|
||||||
export const MEDIA_POLL_MS = 2000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Call `tick` every `intervalMs` while `active`. Ticks never overlap: the next
|
|
||||||
* one is scheduled after the previous settles. Hidden tabs skip ticks.
|
|
||||||
*/
|
|
||||||
export function usePolling(
|
|
||||||
tick: () => unknown,
|
|
||||||
active: boolean,
|
|
||||||
intervalMs: number = MEDIA_POLL_MS,
|
|
||||||
): void {
|
|
||||||
const latest = useRef(tick);
|
|
||||||
latest.current = tick;
|
|
||||||
useEffect(() => {
|
|
||||||
if (!active) return;
|
|
||||||
let stopped = false;
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
const schedule = () => {
|
|
||||||
if (stopped) return;
|
|
||||||
timer = setTimeout(run, intervalMs);
|
|
||||||
};
|
|
||||||
const run = async () => {
|
|
||||||
if (stopped) return;
|
|
||||||
if (typeof document !== "undefined" && document.hidden) {
|
|
||||||
schedule();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await latest.current();
|
|
||||||
} catch {
|
|
||||||
// The caller renders its own error state; polling continues.
|
|
||||||
}
|
|
||||||
schedule();
|
|
||||||
};
|
|
||||||
schedule();
|
|
||||||
return () => {
|
|
||||||
stopped = true;
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [active, intervalMs]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Render states that still change on the server. */
|
|
||||||
export const MEDIA_RENDER_ACTIVE_STATES = ["queued", "rendering", "gating"] as const;
|
|
||||||
|
|
||||||
export function isRenderActive(status: string): boolean {
|
|
||||||
return (MEDIA_RENDER_ACTIVE_STATES as readonly string[]).includes(status);
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
import type { MediaCapabilities } from "@rakazo/contracts";
|
|
||||||
import { createContext, useContext, useEffect, useState } from "react";
|
|
||||||
import { rpc } from "./rpc";
|
|
||||||
|
|
||||||
/** What every Media Studio view may read about the deployment. */
|
|
||||||
export interface MediaStudioEnv {
|
|
||||||
capabilities: MediaCapabilities | null;
|
|
||||||
/** Folder sources live on the host, so only the deployment owner links them. */
|
|
||||||
canLinkFolder: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MediaStudioContext = createContext<MediaStudioEnv>({
|
|
||||||
capabilities: null,
|
|
||||||
canLinkFolder: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function useMediaStudio(): MediaStudioEnv {
|
|
||||||
return useContext(MediaStudioContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Load media capabilities once; null until known, and on failure. */
|
|
||||||
export function useMediaCapabilities(enabled = true): MediaCapabilities | null {
|
|
||||||
const [capabilities, setCapabilities] = useState<MediaCapabilities | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return;
|
|
||||||
let active = true;
|
|
||||||
rpc.media
|
|
||||||
.capabilities({})
|
|
||||||
.then((value) => {
|
|
||||||
if (active) setCapabilities(value);
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, [enabled]);
|
|
||||||
return capabilities;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Media Studio entry points stay hidden unless the deployment configured it. */
|
|
||||||
export function useMediaStudioAvailable(enabled = true): boolean {
|
|
||||||
return useMediaCapabilities(enabled)?.configured === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function rpcErrorMessage(error: unknown, fallback: string): string {
|
|
||||||
if (error && typeof error === "object" && "message" in error) {
|
|
||||||
const message = String((error as { message: unknown }).message);
|
|
||||||
if (message && !/^(Internal|Bad Request|Precondition)/i.test(message)) return message;
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
@ -1,147 +1,6 @@
|
|||||||
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";
|
||||||
|
|
||||||
export function CollectionView({
|
/** Placeholder; the W3-web-library slice replaces this view. */
|
||||||
navigate,
|
export function CollectionView(_props: MediaViewProps & Record<string, unknown>) {
|
||||||
collectionId,
|
return null;
|
||||||
}: 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,294 +1,6 @@
|
|||||||
import { Plural, Trans, useLingui } from "@lingui/react/macro";
|
|
||||||
import type { MediaAsset, MediaCollection, MediaSource } from "@rakazo/contracts";
|
|
||||||
import { Button } from "@rakazo/ui-web";
|
|
||||||
import { Plus } from "lucide-react";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { LoadingState } from "../../components/ai/primitives";
|
|
||||||
import { AssetGrid, useAssetSelection } from "../../components/media/library/AssetGrid";
|
|
||||||
import { ConnectSourceDialog } from "../../components/media/library/ConnectSourceDialog";
|
|
||||||
import { CreateProjectDialog } from "../../components/media/library/CreateProjectDialog";
|
|
||||||
import {
|
|
||||||
CollectionCard,
|
|
||||||
FilterChips,
|
|
||||||
IndexingProgress,
|
|
||||||
LibraryModeToggle,
|
|
||||||
LibrarySearch,
|
|
||||||
} from "../../components/media/library/LibraryChrome";
|
|
||||||
import { usePhotosSync } from "../../components/media/library/usePhotosSync";
|
|
||||||
import { VirtualGrid } from "../../components/media/library/VirtualGrid";
|
|
||||||
import type { LibraryFilter } from "../../lib/media-library";
|
|
||||||
import { assetQueryFor, hasAssetQuery, INGEST_PENDING_STATES } from "../../lib/media-library";
|
|
||||||
import { usePolling } from "../../lib/media-poll";
|
|
||||||
import { rpcErrorMessage, useMediaStudio } from "../../lib/media-studio";
|
|
||||||
import { rpc } from "../../lib/rpc";
|
|
||||||
import type { MediaViewProps } from "./nav";
|
import type { MediaViewProps } from "./nav";
|
||||||
|
|
||||||
const PAGE = 500;
|
/** Placeholder; the W3-web-library slice replaces this view. */
|
||||||
|
export function LibraryView(_props: MediaViewProps & Record<string, unknown>) {
|
||||||
type Paged<T> = { items: T[]; cursor: string | null; done: boolean; loading: boolean };
|
return null;
|
||||||
const emptyPage = <T,>(): Paged<T> => ({ items: [], cursor: null, done: false, loading: false });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cursor-paged list that appends on demand. A new `key` starts over and drops
|
|
||||||
* pages still in flight for the previous key.
|
|
||||||
*/
|
|
||||||
function usePagedList<T>(
|
|
||||||
key: string,
|
|
||||||
load: (cursor: string | null) => Promise<{ items: T[]; nextCursor: string | null }>,
|
|
||||||
enabled: boolean,
|
|
||||||
) {
|
|
||||||
const [state, setState] = useState<Paged<T>>(emptyPage);
|
|
||||||
const [error, setError] = useState<unknown>(null);
|
|
||||||
const generation = useRef(0);
|
|
||||||
const busy = useRef(false);
|
|
||||||
const loadRef = useRef(load);
|
|
||||||
loadRef.current = load;
|
|
||||||
const stateRef = useRef(state);
|
|
||||||
stateRef.current = state;
|
|
||||||
|
|
||||||
const more = useCallback(async () => {
|
|
||||||
const current = stateRef.current;
|
|
||||||
if (busy.current || current.done) return;
|
|
||||||
busy.current = true;
|
|
||||||
const mine = generation.current;
|
|
||||||
setState((s) => ({ ...s, loading: true }));
|
|
||||||
try {
|
|
||||||
const page = await loadRef.current(current.cursor);
|
|
||||||
if (mine !== generation.current) return;
|
|
||||||
setError(null);
|
|
||||||
setState((s) => ({
|
|
||||||
items: [...s.items, ...page.items],
|
|
||||||
cursor: page.nextCursor,
|
|
||||||
done: page.nextCursor === null,
|
|
||||||
loading: false,
|
|
||||||
}));
|
|
||||||
} catch (cause) {
|
|
||||||
if (mine !== generation.current) return;
|
|
||||||
setError(cause);
|
|
||||||
setState((s) => ({ ...s, loading: false, done: true }));
|
|
||||||
} finally {
|
|
||||||
if (mine === generation.current) busy.current = false;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
|
||||||
generation.current += 1;
|
|
||||||
busy.current = false;
|
|
||||||
stateRef.current = emptyPage();
|
|
||||||
setState(emptyPage());
|
|
||||||
setError(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void key;
|
|
||||||
reset();
|
|
||||||
if (enabled) void more();
|
|
||||||
}, [key, enabled, reset, more]);
|
|
||||||
|
|
||||||
const restart = useCallback(() => {
|
|
||||||
reset();
|
|
||||||
void more();
|
|
||||||
}, [reset, more]);
|
|
||||||
|
|
||||||
return { ...state, error, more, reset: restart };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LibraryView({
|
|
||||||
navigate,
|
|
||||||
mode = "sessions",
|
|
||||||
}: MediaViewProps & { mode?: "sessions" | "themes" }) {
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { canLinkFolder } = useMediaStudio();
|
|
||||||
const [filters, setFilters] = useState<ReadonlySet<LibraryFilter>>(() => new Set());
|
|
||||||
const [text, setText] = useState("");
|
|
||||||
const [debouncedText, setDebouncedText] = useState("");
|
|
||||||
const [sources, setSources] = useState<MediaSource[] | null>(null);
|
|
||||||
const [pending, setPending] = useState(false);
|
|
||||||
const [connectOpen, setConnectOpen] = useState(false);
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
|
||||||
const selection = useAssetSelection();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => setDebouncedText(text), 250);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [text]);
|
|
||||||
|
|
||||||
const searching = hasAssetQuery(filters, debouncedText);
|
|
||||||
const query = assetQueryFor(filters, debouncedText);
|
|
||||||
const queryKey = JSON.stringify(query);
|
|
||||||
const kind = mode === "themes" ? "theme" : "session";
|
|
||||||
|
|
||||||
const collections = usePagedList<MediaCollection>(
|
|
||||||
kind,
|
|
||||||
(cursor) => rpc.media.collections.list({ kind, cursor, limit: PAGE }),
|
|
||||||
!searching,
|
|
||||||
);
|
|
||||||
const assets = usePagedList<MediaAsset>(
|
|
||||||
queryKey,
|
|
||||||
(cursor) => rpc.media.assets.search({ ...query, cursor, limit: PAGE }),
|
|
||||||
searching,
|
|
||||||
);
|
|
||||||
|
|
||||||
const refreshStatus = useCallback(async () => {
|
|
||||||
const [list, work] = await Promise.all([
|
|
||||||
rpc.media.sources.list({}),
|
|
||||||
rpc.media.assets.search({ ingestStates: [...INGEST_PENDING_STATES], cursor: null, limit: 1 }),
|
|
||||||
]);
|
|
||||||
setSources(list);
|
|
||||||
setPending(work.items.length > 0);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const photos = usePhotosSync(
|
|
||||||
useCallback(() => {
|
|
||||||
void refreshStatus();
|
|
||||||
collections.reset();
|
|
||||||
}, [refreshStatus, collections.reset]),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void refreshStatus().catch(() => setSources([]));
|
|
||||||
}, [refreshStatus]);
|
|
||||||
|
|
||||||
const indexing =
|
|
||||||
photos.syncing || pending || (sources ?? []).some((source) => source.status === "syncing");
|
|
||||||
usePolling(refreshStatus, indexing);
|
|
||||||
|
|
||||||
// Indexing just finished: the grouping changed underneath us.
|
|
||||||
const wasIndexing = useRef(indexing);
|
|
||||||
useEffect(() => {
|
|
||||||
if (wasIndexing.current && !indexing) {
|
|
||||||
if (searching) assets.reset();
|
|
||||||
else collections.reset();
|
|
||||||
}
|
|
||||||
wasIndexing.current = indexing;
|
|
||||||
}, [indexing, searching, assets.reset, collections.reset]);
|
|
||||||
|
|
||||||
const noSources = sources !== null && sources.length === 0 && !photos.syncing;
|
|
||||||
const selectedIds = [...selection.selected];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div data-testid="media-library" 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={mode}
|
|
||||||
onChange={(next) =>
|
|
||||||
navigate(next === "people" ? { name: "people" } : { name: "library", mode: next })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<LibrarySearch value={text} onChange={setText} />
|
|
||||||
<FilterChips value={filters} onChange={setFilters} />
|
|
||||||
<div className="ml-auto 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>
|
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
|
||||||
<Trans>Create project</Trans>
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={t`Sources`}
|
|
||||||
onClick={() => setConnectOpen(true)}
|
|
||||||
>
|
|
||||||
<Plus />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{indexing ? (
|
|
||||||
<IndexingProgress
|
|
||||||
done={photos.progress?.uploadsDone}
|
|
||||||
total={photos.progress?.uploadsTotal}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{sources === null ? (
|
|
||||||
<div className="grid flex-1 place-items-center">
|
|
||||||
<LoadingState label={t`Loading`} />
|
|
||||||
</div>
|
|
||||||
) : noSources ? (
|
|
||||||
<div className="grid flex-1 place-items-center px-8">
|
|
||||||
<div className="flex flex-col items-center gap-3 text-center">
|
|
||||||
<p className="text-ui-body text-muted-foreground">
|
|
||||||
<Trans>Connect your photos and videos.</Trans>
|
|
||||||
</p>
|
|
||||||
<Button data-testid="media-connect-source-open" onClick={() => setConnectOpen(true)}>
|
|
||||||
<Trans>Connect a source</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : searching ? (
|
|
||||||
assets.items.length === 0 && !assets.loading ? (
|
|
||||||
<EmptyMessage error={assets.error}>
|
|
||||||
<Trans>Nothing matches.</Trans>
|
|
||||||
</EmptyMessage>
|
|
||||||
) : (
|
|
||||||
<AssetGrid
|
|
||||||
assets={assets.items}
|
|
||||||
selection={selection}
|
|
||||||
scrub
|
|
||||||
onNearEnd={() => void assets.more()}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : collections.items.length === 0 && !collections.loading ? (
|
|
||||||
indexing ? null : (
|
|
||||||
<EmptyMessage error={collections.error}>
|
|
||||||
{mode === "themes" ? <Trans>No themes yet.</Trans> : <Trans>No sessions yet.</Trans>}
|
|
||||||
</EmptyMessage>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<VirtualGrid
|
|
||||||
testId="media-collection-grid"
|
|
||||||
label={mode === "themes" ? t`Themes` : t`Sessions`}
|
|
||||||
count={collections.items.length}
|
|
||||||
getKey={(index) => collections.items[index]?.id ?? index}
|
|
||||||
minTileWidth={220}
|
|
||||||
gap={16}
|
|
||||||
tileAspect={10 / 16}
|
|
||||||
extraHeight={44}
|
|
||||||
onNearEnd={() => void collections.more()}
|
|
||||||
renderItem={(index) => {
|
|
||||||
const collection = collections.items[index];
|
|
||||||
if (!collection) return null;
|
|
||||||
return (
|
|
||||||
<CollectionCard
|
|
||||||
collection={collection}
|
|
||||||
onOpen={() => navigate({ name: "collection", collectionId: collection.id })}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ConnectSourceDialog
|
|
||||||
open={connectOpen}
|
|
||||||
onOpenChange={setConnectOpen}
|
|
||||||
sources={sources ?? []}
|
|
||||||
canLinkFolder={canLinkFolder}
|
|
||||||
photos={photos}
|
|
||||||
onChanged={() => void refreshStatus()}
|
|
||||||
/>
|
|
||||||
<CreateProjectDialog
|
|
||||||
open={createOpen}
|
|
||||||
onOpenChange={setCreateOpen}
|
|
||||||
defaultTitle={debouncedText.trim() || t`New project`}
|
|
||||||
collectionIds={[]}
|
|
||||||
assetIds={selectedIds}
|
|
||||||
onCreated={(projectId) => navigate({ name: "project", projectId })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyMessage({ error, children }: { error: unknown; children: React.ReactNode }) {
|
|
||||||
const { t } = useLingui();
|
|
||||||
return (
|
|
||||||
<div className="grid flex-1 place-items-center px-8 text-center text-ui-body text-muted-foreground">
|
|
||||||
{error ? rpcErrorMessage(error, t`Could not load the library.`) : children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,47 +1,21 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { useState } from "react";
|
||||||
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 { BrandKitView } from "./BrandKitView";
|
||||||
import { CollectionView } from "./CollectionView";
|
import { CollectionView } from "./CollectionView";
|
||||||
import { EditorView } from "./EditorView";
|
import { EditorView } from "./EditorView";
|
||||||
import { LibraryView } from "./LibraryView";
|
import { LibraryView } from "./LibraryView";
|
||||||
import type { MediaSection, MediaView } from "./nav";
|
import type { MediaView } from "./nav";
|
||||||
import { pushView, sectionOf } from "./nav";
|
|
||||||
import { PeopleView } from "./PeopleView";
|
import { PeopleView } from "./PeopleView";
|
||||||
import { ProjectView } from "./ProjectView";
|
import { ProjectView } from "./ProjectView";
|
||||||
import { ResultsView } from "./ResultsView";
|
import { ResultsView } from "./ResultsView";
|
||||||
import { ReviewView } from "./ReviewView";
|
import { ReviewView } from "./ReviewView";
|
||||||
|
|
||||||
function ViewSwitch({
|
/** Media Studio surface. Chrome (header, close, tabs) belongs to the W3-web-library slice. */
|
||||||
view,
|
export function MediaStudio({ onClose }: { onClose: () => void }) {
|
||||||
navigate,
|
const [view, setView] = useState<MediaView>({ name: "library" });
|
||||||
onClose,
|
const props = { navigate: setView, onClose };
|
||||||
}: {
|
|
||||||
view: MediaView;
|
|
||||||
navigate: (view: MediaView) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const props = { navigate, onClose };
|
|
||||||
switch (view.name) {
|
switch (view.name) {
|
||||||
case "library":
|
case "library":
|
||||||
return <LibraryView {...props} mode={view.mode} />;
|
return <LibraryView {...props} />;
|
||||||
case "collection":
|
case "collection":
|
||||||
return <CollectionView {...props} collectionId={view.collectionId} />;
|
return <CollectionView {...props} collectionId={view.collectionId} />;
|
||||||
case "people":
|
case "people":
|
||||||
@ -58,135 +32,3 @@ function ViewSwitch({
|
|||||||
return <BrandKitView {...props} brandKitId={view.brandKitId} />;
|
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,167 +1,6 @@
|
|||||||
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";
|
||||||
|
|
||||||
export function PeopleView({ navigate }: MediaViewProps) {
|
/** Placeholder; the W3-web-library slice replaces this view. */
|
||||||
const { t } = useLingui();
|
export function PeopleView(_props: MediaViewProps & Record<string, unknown>) {
|
||||||
const { capabilities } = useMediaStudio();
|
return null;
|
||||||
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,307 +1,6 @@
|
|||||||
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";
|
||||||
|
|
||||||
const ALL = "";
|
/** Placeholder; the W3-web-library slice replaces this view. */
|
||||||
|
export function ResultsView(_props: MediaViewProps & Record<string, unknown>) {
|
||||||
type Data = {
|
return null;
|
||||||
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>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
/** Screens inside Media Studio. Every view receives `navigate` to move between them. */
|
/** Screens inside Media Studio. Every view receives `navigate` to move between them. */
|
||||||
export type MediaView =
|
export type MediaView =
|
||||||
| { name: "library"; mode?: "sessions" | "themes" }
|
| { name: "library" }
|
||||||
| { name: "collection"; collectionId: string }
|
| { name: "collection"; collectionId: string }
|
||||||
| { name: "people" }
|
| { name: "people" }
|
||||||
| { name: "results" }
|
| { name: "results" }
|
||||||
@ -12,36 +12,3 @@ export type MediaView =
|
|||||||
export interface MediaViewProps {
|
export interface MediaViewProps {
|
||||||
navigate: (view: MediaView) => void;
|
navigate: (view: MediaView) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Top-level sections: opening one starts a fresh back stack. */
|
|
||||||
export type MediaSection = "library" | "results" | "brandKit";
|
|
||||||
|
|
||||||
export function sectionOf(view: MediaView): MediaSection | null {
|
|
||||||
switch (view.name) {
|
|
||||||
case "library":
|
|
||||||
case "people":
|
|
||||||
case "collection":
|
|
||||||
return "library";
|
|
||||||
case "results":
|
|
||||||
return "results";
|
|
||||||
case "brandKit":
|
|
||||||
return "brandKit";
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isRootView(view: MediaView): boolean {
|
|
||||||
return (
|
|
||||||
view.name === "library" ||
|
|
||||||
view.name === "people" ||
|
|
||||||
view.name === "results" ||
|
|
||||||
(view.name === "brandKit" && !view.brandKitId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Push `next` onto the back stack; root views replace it. */
|
|
||||||
export function pushView(stack: readonly MediaView[], next: MediaView): MediaView[] {
|
|
||||||
if (isRootView(next)) return [next];
|
|
||||||
return [...stack, next];
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user