feat(media): add Convex media archive with backfill and mixed-media library

This commit is contained in:
2026-04-10 15:15:44 +02:00
parent ddb2412349
commit a1df097f9c
26 changed files with 2664 additions and 122 deletions

View File

@@ -7,6 +7,7 @@ import { useTheme } from "next-themes";
import { useMutation } from "convex/react";
import { useTranslations } from "next-intl";
import {
Box,
ChevronDown,
Coins,
ImageIcon,
@@ -16,6 +17,7 @@ import {
Moon,
Search,
Sun,
Video,
} from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@@ -66,6 +68,50 @@ function formatDimensions(width: number | undefined, height: number | undefined)
return "Größe unbekannt";
}
function getMediaItemKey(item: NonNullable<ReturnType<typeof useDashboardSnapshot>["snapshot"]>["mediaPreview"][number]): string {
if (item.storageId) {
return item.storageId;
}
if (item.originalUrl) {
return `url:${item.originalUrl}`;
}
if (item.previewUrl) {
return `preview:${item.previewUrl}`;
}
if (item.sourceUrl) {
return `source:${item.sourceUrl}`;
}
return `${item.kind}:${item.createdAt}:${item.filename ?? "unnamed"}`;
}
function getMediaItemMeta(item: NonNullable<ReturnType<typeof useDashboardSnapshot>["snapshot"]>["mediaPreview"][number]): string {
if (item.kind === "video") {
return "Videodatei";
}
return formatDimensions(item.width, item.height);
}
function getMediaItemLabel(item: NonNullable<ReturnType<typeof useDashboardSnapshot>["snapshot"]>["mediaPreview"][number]): string {
if (item.filename) {
return item.filename;
}
if (item.kind === "video") {
return "Unbenanntes Video";
}
if (item.kind === "asset") {
return "Unbenanntes Asset";
}
return "Unbenanntes Bild";
}
export function DashboardPageClient() {
const t = useTranslations("toasts");
const router = useRouter();
@@ -357,16 +403,27 @@ export function DashboardPageClient() {
) : (
<div className="grid gap-3 sm:grid-cols-4">
{(mediaPreview ?? []).map((item) => {
const itemKey = getMediaItemKey(item);
const previewUrl = resolveMediaPreviewUrl(item, mediaPreviewUrlMap);
const itemLabel = getMediaItemLabel(item);
const itemMeta = getMediaItemMeta(item);
return (
<article key={item.storageId} className="overflow-hidden rounded-xl border bg-card">
<article key={itemKey} className="overflow-hidden rounded-xl border bg-card">
<div className="relative aspect-square bg-muted/50">
{previewUrl ? (
{previewUrl && item.kind === "video" ? (
<video
src={previewUrl}
className="h-full w-full object-cover"
muted
playsInline
preload="metadata"
/>
) : previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={previewUrl}
alt={item.filename ?? "Mediathek-Bild"}
alt={itemLabel}
className="h-full w-full object-cover"
loading="lazy"
/>
@@ -376,17 +433,21 @@ export function DashboardPageClient() {
</div>
) : (
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
<ImageIcon className="size-5" />
{item.kind === "video" ? (
<Video className="size-5" />
) : item.kind === "asset" ? (
<Box className="size-5" />
) : (
<ImageIcon className="size-5" />
)}
</div>
)}
</div>
<div className="space-y-1 p-2">
<p className="truncate text-xs font-medium" title={item.filename}>
{item.filename ?? "Unbenanntes Bild"}
</p>
<p className="text-[11px] text-muted-foreground">
{formatDimensions(item.width, item.height)}
<p className="truncate text-xs font-medium" title={itemLabel}>
{itemLabel}
</p>
<p className="text-[11px] text-muted-foreground">{itemMeta}</p>
</div>
</article>
);
@@ -400,7 +461,7 @@ export function DashboardPageClient() {
open={isMediaLibraryDialogOpen}
onOpenChange={setIsMediaLibraryDialogOpen}
title="Mediathek"
description="Alle deine Bilder aus LemonSpace in einer zentralen Vorschau."
description="Alle deine Medien aus LemonSpace in einer zentralen Vorschau."
/>
</div>
);