feat(canvas): finalize mixer reconnect swap and related updates

This commit is contained in:
2026-04-11 07:42:42 +02:00
parent f3dcaf89f2
commit 028fce35c2
52 changed files with 3859 additions and 272 deletions

View File

@@ -7,8 +7,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
useAuthQuery: vi.fn(),
resolveUrls: vi.fn(async () => ({})),
useTranslations: vi.fn(),
}));
const translations = {
previous: "Zurueck",
next: "Weiter",
pageOf: "Seite {page} von {totalPages}",
} as const;
vi.mock("convex/react", () => ({
useMutation: () => mocks.resolveUrls,
}));
@@ -17,6 +24,10 @@ vi.mock("@/hooks/use-auth-query", () => ({
useAuthQuery: (...args: unknown[]) => mocks.useAuthQuery(...args),
}));
vi.mock("next-intl", () => ({
useTranslations: (...args: unknown[]) => mocks.useTranslations(...args),
}));
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div>{children}</div> : null,
@@ -49,7 +60,23 @@ describe("MediaLibraryDialog", () => {
beforeEach(() => {
mocks.useAuthQuery.mockReset();
mocks.resolveUrls.mockReset();
mocks.useTranslations.mockReset();
mocks.resolveUrls.mockImplementation(async () => ({}));
const translate = (
key: keyof typeof translations,
values?: Record<string, string | number>,
) => {
const template = translations[key] ?? key;
if (!values) {
return template;
}
return template.replace(/\{(\w+)\}/g, (_, token: string) => {
const value = values[token];
return value === undefined ? `{${token}}` : String(value);
});
};
mocks.useTranslations.mockReturnValue(translate);
container = document.createElement("div");
document.body.appendChild(container);
@@ -86,7 +113,7 @@ describe("MediaLibraryDialog", () => {
);
});
it("renders at most 8 cards and shows Freepik-style pagination footer", async () => {
it("renders at most 8 cards and shows localized pagination footer", async () => {
mocks.useAuthQuery.mockReturnValue({
items: makeItems(10),
page: 1,
@@ -102,9 +129,9 @@ describe("MediaLibraryDialog", () => {
const cards = document.querySelectorAll("img[alt^='Item 1-']");
expect(cards).toHaveLength(8);
expect(document.body.textContent).toContain("Previous");
expect(document.body.textContent).toContain("Page 1 of 3");
expect(document.body.textContent).toContain("Next");
expect(document.body.textContent).toContain("Zurueck");
expect(document.body.textContent).toContain("Seite 1 von 3");
expect(document.body.textContent).toContain("Weiter");
});
it("updates query args when clicking next and previous", async () => {
@@ -135,7 +162,7 @@ describe("MediaLibraryDialog", () => {
});
const nextButton = Array.from(document.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Next",
(button) => button.textContent?.trim() === "Weiter",
);
if (!(nextButton instanceof HTMLButtonElement)) {
throw new Error("Next button not found");
@@ -149,7 +176,7 @@ describe("MediaLibraryDialog", () => {
expect(nextCallArgs).toEqual(expect.objectContaining({ page: 2, pageSize: 8 }));
const previousButton = Array.from(document.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Previous",
(button) => button.textContent?.trim() === "Zurueck",
);
if (!(previousButton instanceof HTMLButtonElement)) {
throw new Error("Previous button not found");

View File

@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation } from "convex/react";
import { AlertCircle, Box, ImageIcon, Loader2, Video } from "lucide-react";
import { useTranslations } from "next-intl";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
@@ -73,15 +74,18 @@ function formatDimensions(width: number | undefined, height: number | undefined)
return `${width} x ${height}px`;
}
function formatMediaMeta(item: MediaLibraryItem): string {
function formatMediaMeta(
item: MediaLibraryItem,
tCommon: ReturnType<typeof useTranslations>,
): string {
if (item.kind === "video") {
if (typeof item.durationSeconds === "number" && Number.isFinite(item.durationSeconds)) {
return `${Math.max(1, Math.round(item.durationSeconds))}s`;
}
return "Videodatei";
return tCommon("videoFile");
}
return formatDimensions(item.width, item.height) ?? "Groesse unbekannt";
return formatDimensions(item.width, item.height) ?? tCommon("unknownSize");
}
function getItemKey(item: MediaLibraryItem): string {
@@ -104,32 +108,37 @@ function getItemKey(item: MediaLibraryItem): string {
return `${item.kind}:${item.createdAt}:${item.filename ?? "unnamed"}`;
}
function getItemLabel(item: MediaLibraryItem): string {
function getItemLabel(
item: MediaLibraryItem,
tCommon: ReturnType<typeof useTranslations>,
): string {
if (item.filename) {
return item.filename;
}
if (item.kind === "video") {
return "Unbenanntes Video";
return tCommon("untitledVideo");
}
if (item.kind === "asset") {
return "Unbenanntes Asset";
return tCommon("untitledAsset");
}
return "Unbenanntes Bild";
return tCommon("untitledImage");
}
export function MediaLibraryDialog({
open,
onOpenChange,
onPick,
title = "Mediathek",
title,
description,
pageSize = DEFAULT_PAGE_SIZE,
kindFilter,
pickCtaLabel = "Auswaehlen",
pickCtaLabel,
}: MediaLibraryDialogProps) {
const tDialog = useTranslations("mediaLibrary.dialog");
const tCommon = useTranslations("mediaLibrary.common");
const [page, setPage] = useState(1);
const normalizedPageSize = useMemo(() => {
if (typeof pageSize !== "number" || !Number.isFinite(pageSize)) {
@@ -203,7 +212,7 @@ export function MediaLibraryDialog({
return;
}
setUrlMap({});
setUrlError(error instanceof Error ? error.message : "URLs konnten nicht geladen werden.");
setUrlError(error instanceof Error ? error.message : tDialog("urlResolveError"));
} finally {
if (!isCancelled) {
setIsResolvingUrls(false);
@@ -216,7 +225,7 @@ export function MediaLibraryDialog({
return () => {
isCancelled = true;
};
}, [metadata, open, resolveUrls]);
}, [metadata, open, resolveUrls, tDialog]);
const items: MediaLibraryItem[] = useMemo(() => {
if (!metadata) {
@@ -229,16 +238,25 @@ export function MediaLibraryDialog({
}));
}, [metadata, urlMap]);
const visibleItems = useMemo(() => items.slice(0, DEFAULT_PAGE_SIZE), [items]);
const visibleItems = useMemo(
() => items.slice(0, normalizedPageSize),
[items, normalizedPageSize],
);
const isMetadataLoading = open && metadata === undefined;
const isInitialLoading = isMetadataLoading || (metadata !== undefined && isResolvingUrls);
const isPreviewMode = typeof onPick !== "function";
const effectiveTitle = title ?? tDialog("title");
const effectivePickCtaLabel = pickCtaLabel ?? tDialog("pick");
const effectiveDescription =
description ??
(kindFilter === "image"
? "Waehle ein Bild aus deiner LemonSpace-Mediathek."
: "Durchsuche deine Medien aus Uploads, KI-Generierung und Archivquellen.");
? tDialog("descriptionImage")
: kindFilter === "video"
? tDialog("descriptionVideo")
: kindFilter === "asset"
? tDialog("descriptionAsset")
: tDialog("descriptionDefault"));
async function handlePick(item: MediaLibraryItem): Promise<void> {
if (!onPick || pendingPickItemKey) {
@@ -257,7 +275,7 @@ export function MediaLibraryDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] sm:max-w-5xl" showCloseButton>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogTitle>{effectiveTitle}</DialogTitle>
<DialogDescription>{effectiveDescription}</DialogDescription>
</DialogHeader>
@@ -277,15 +295,15 @@ export function MediaLibraryDialog({
) : urlError ? (
<div className="flex h-full min-h-[260px] flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 text-center">
<AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm font-medium">Mediathek konnte nicht geladen werden</p>
<p className="text-sm font-medium">{tDialog("errorTitle")}</p>
<p className="max-w-md text-xs text-muted-foreground">{urlError}</p>
</div>
) : items.length === 0 ? (
<div className="flex h-full min-h-[260px] flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 text-center">
<ImageIcon className="h-8 w-8 text-muted-foreground" />
<p className="text-sm font-medium">Keine Medien vorhanden</p>
<p className="text-sm font-medium">{tDialog("emptyTitle")}</p>
<p className="text-xs text-muted-foreground">
Sobald du Medien hochlaedst oder generierst, erscheinen sie hier.
{tDialog("emptyDescription")}
</p>
</div>
) : (
@@ -293,8 +311,8 @@ export function MediaLibraryDialog({
{visibleItems.map((item) => {
const itemKey = getItemKey(item);
const isPickingThis = pendingPickItemKey === itemKey;
const itemLabel = getItemLabel(item);
const metaLabel = formatMediaMeta(item);
const itemLabel = getItemLabel(item, tCommon);
const metaLabel = formatMediaMeta(item, tCommon);
return (
<div
@@ -340,7 +358,7 @@ export function MediaLibraryDialog({
</p>
{isPreviewMode ? (
<p className="mt-auto text-[11px] text-muted-foreground">Nur Vorschau</p>
<p className="mt-auto text-[11px] text-muted-foreground">{tDialog("previewOnly")}</p>
) : (
<Button
type="button"
@@ -352,10 +370,10 @@ export function MediaLibraryDialog({
{isPickingThis ? (
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
Wird uebernommen...
{tDialog("pickLoading")}
</>
) : (
pickCtaLabel
effectivePickCtaLabel
)}
</Button>
)}
@@ -375,10 +393,10 @@ export function MediaLibraryDialog({
onClick={() => setPage((current) => Math.max(1, current - 1))}
disabled={page <= 1}
>
Previous
{tDialog("previous")}
</Button>
<span className="text-xs text-muted-foreground">
Page {metadata.page} of {metadata.totalPages}
{tDialog("pageOf", { page: metadata.page, totalPages: metadata.totalPages })}
</span>
<Button
variant="outline"
@@ -386,7 +404,7 @@ export function MediaLibraryDialog({
onClick={() => setPage((current) => Math.min(metadata.totalPages, current + 1))}
disabled={page >= metadata.totalPages}
>
Next
{tDialog("next")}
</Button>
</div>
) : null}