diff --git a/src/app/_components/media/focal-point-picker.tsx b/src/app/_components/media/focal-point-picker.tsx new file mode 100644 index 00000000..aeadcd8b --- /dev/null +++ b/src/app/_components/media/focal-point-picker.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useCallback, useRef } from "react"; +import ImageWithFallback from "@/app/_components/ui/image-with-fallback"; +import { Button } from "@/app/_components/ui"; +import { CrosshairIcon } from "lucide-react"; + +/** + * Fokuspunkt eines Bildes setzen: der Punkt, der sichtbar bleibt, wenn das Bild + * irgendwo als `object-cover` beschnitten wird (Kartenkopf, Karussell, Kachel). + * + * Die Spalten gab es schon, nur keinen Weg sie zu setzen — bis hierher blieb + * als einziges Mittel, das Bild neu zuzuschneiden und damit das Original zu + * verlieren. Der Fokuspunkt lässt die Datei in Ruhe. + */ +export function FocalPointPicker({ + url, + alt, + x, + y, + onChange, +}: { + url: string; + alt: string; + x: number | null; + y: number | null; + onChange: (next: { x: number | null; y: number | null }) => void; +}) { + const frameRef = useRef(null); + + const setFromEvent = useCallback( + (clientX: number, clientY: number) => { + const frame = frameRef.current?.getBoundingClientRect(); + if (!frame || frame.width === 0 || frame.height === 0) return; + const nextX = ((clientX - frame.left) / frame.width) * 100; + const nextY = ((clientY - frame.top) / frame.height) * 100; + onChange({ + x: Math.round(Math.min(100, Math.max(0, nextX))), + y: Math.round(Math.min(100, Math.max(0, nextY))), + }); + }, + [onChange], + ); + + const hasFocalPoint = x != null && y != null; + + return ( +
+
: der Klick trägt eine Position, keine Ja/Nein-Auswahl. + // Für die Tastatur stehen darunter die beiden Zahlenfelder. + onClick={(event) => setFromEvent(event.clientX, event.clientY)} + className="dark:border-dark-border relative aspect-video w-full cursor-crosshair overflow-hidden rounded-lg border border-gray-200 bg-gray-100 dark:bg-gray-800" + title="Klicken, um den Bildmittelpunkt zu setzen" + > + + {hasFocalPoint && ( + + )} +
+ +
+ + + +
+

+ Bestimmt, welcher Bildausschnitt sichtbar bleibt, wenn das Bild + beschnitten dargestellt wird. Ohne Fokuspunkt wird die Bildmitte + verwendet. +

+
+ ); +} diff --git a/src/app/_components/media/media-delete-dialog.tsx b/src/app/_components/media/media-delete-dialog.tsx new file mode 100644 index 00000000..8322b8d1 --- /dev/null +++ b/src/app/_components/media/media-delete-dialog.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { api } from "@/trpc/react"; +import ImageWithFallback from "@/app/_components/ui/image-with-fallback"; +import { Button } from "@/app/_components/ui"; +import { + ScrollableModal, + ScrollableModalCard, + ScrollableModalBody, + ScrollableModalFooter, +} from "@/app/_components/ui/scrollable-modal"; +import { AlertTriangleIcon } from "lucide-react"; +import { + formatFileSize, + getMimeTypeIcon, + type MediaItem, +} from "./media-shared"; + +/** + * Löschdialog mit Verwendungsnachweis. + * + * Der vorherige Dialog fragte nur „bist du sicher?“ und nannte nicht einmal die + * Datei. Das war vor allem deshalb heikel, weil zwei Beziehungen auf + * `onDelete: Cascade` stehen: mit dem Bild verschwand stillschweigend das ganze + * Bläserheft bzw. die Karussell-Folie. + */ +export function MediaDeleteDialog({ + media, + onClose, + onConfirm, + isDeleting, +}: { + media: MediaItem; + onClose: () => void; + onConfirm: () => void; + isDeleting: boolean; +}) { + const { data: usage, isLoading } = api.media.getUsage.useQuery({ + id: media.id, + }); + + const cascades = usage?.usages.filter((entry) => entry.cascade) ?? []; + const references = usage?.usages.filter((entry) => !entry.cascade) ?? []; + + return ( + + + +

+ Medium löschen +

+ +
+
+ {media.mimeType.startsWith("image/") ? ( + + ) : ( + + {getMimeTypeIcon(media.mimeType)} + + )} +
+
+

+ {media.name} +

+

+ {formatFileSize(media.size)} +

+
+
+ + {isLoading ? ( +

+ Verwendung wird geprüft … +

+ ) : cascades.length > 0 ? ( +
+

+ + Achtung: Es werden weitere Einträge mitgelöscht +

+
    + {cascades.map((entry, index) => ( +
  • + {entry.kind}:{" "} + {entry.label} +
  • + ))} +
+

+ Diese Einträge hängen unmittelbar an der Datei und verschwinden + mit ihr. Tausche das Bild dort zuerst aus, wenn der Eintrag + bleiben soll. +

+
+ ) : null} + + {references.length > 0 && ( +
+

+ Wird an {references.length}{" "} + {references.length === 1 ? "Stelle" : "Stellen"} verwendet +

+
    + {references.slice(0, 8).map((entry, index) => ( +
  • + {entry.kind}:{" "} + {entry.label} +
  • + ))} + {references.length > 8 && ( +
  • … und {references.length - 8} weitere
  • + )} +
+

+ Dort bleibt der Eintrag bestehen, verliert aber sein Bild. +

+
+ )} + + {!isLoading && usage?.total === 0 && ( +

+ Das Medium wird derzeit nirgendwo verwendet. +

+ )} + +

+ Die Datei wird auch von der Festplatte entfernt. Diese Aktion kann + nicht rückgängig gemacht werden. +

+
+ + +
+ + +
+
+
+
+ ); +} diff --git a/src/app/_components/media/media-edit-modal.tsx b/src/app/_components/media/media-edit-modal.tsx new file mode 100644 index 00000000..4bab007c --- /dev/null +++ b/src/app/_components/media/media-edit-modal.tsx @@ -0,0 +1,374 @@ +"use client"; + +import { useState } from "react"; +import { api } from "@/trpc/react"; +import { useToast } from "@/app/_components/ui/toast"; +import { + Button, + Input, + Label, + Textarea, + Checkbox, + Badge, +} from "@/app/_components/ui"; +import { + ScrollableModal, + ScrollableModalCard, + ScrollableModalHeader, + ScrollableModalBody, + ScrollableModalFooter, +} from "@/app/_components/ui/scrollable-modal"; +import { CropIcon, DownloadIcon } from "lucide-react"; +import ImageCropEditor from "@/app/_components/posts/image-crop-editor"; +import { splitMediaTags } from "@/lib/media-tags"; +import { FocalPointPicker } from "./focal-point-picker"; +import { useMediaDownload } from "./use-media-download"; +import { useReplaceMediaFile } from "./use-replace-media-file"; +import { + formatDate, + formatFileSize, + getMimeTypeIcon, + getMimeTypeLabel, + statusColors, + statusLabels, + type MediaItem, +} from "./media-shared"; + +/** + * Der Formularzustand, wie ihn der Dialog beim Öffnen aus dem Medium zieht. + * Strings bleiben Strings — die Umwandlung leerer Felder zu `null` passiert + * erst beim Speichern, damit die Eingabe sich normal bedienen lässt. + */ +type EditForm = { + name: string; + alt: string; + title: string; + caption: string; + copyright: string; + creator: string; + tags: string; + isPublic: boolean; + focalPointX: number | null; + focalPointY: number | null; +}; + +function toForm(media: MediaItem): EditForm { + return { + name: media.name, + alt: media.alt ?? "", + title: media.title ?? "", + caption: media.caption ?? "", + copyright: media.copyright ?? "", + creator: media.creator ?? "", + tags: media.tags.join(", "), + isPublic: media.isPublic, + focalPointX: media.focalPointX, + focalPointY: media.focalPointY, + }; +} + +export function MediaEditModal({ + media, + onClose, +}: { + media: MediaItem; + onClose: () => void; +}) { + const toast = useToast(); + const utils = api.useUtils(); + const { downloadOne } = useMediaDownload(); + + const [form, setForm] = useState(() => toForm(media)); + const [error, setError] = useState(""); + const [isCropping, setIsCropping] = useState(false); + + /** + * Der Zuschnitt läuft *über* diesem Dialog, nicht an seiner Stelle: „Abbrechen“ + * im Zuschneide-Fenster führt damit zurück ins Formular, und die bereits + * getippten Angaben stehen noch da. + */ + const { replace, isBusy: isReplacing } = useReplaceMediaFile(() => { + setIsCropping(false); + // Der Server verwirft den Fokuspunkt beim Ersetzen — er zeigte auf einen + // Ausschnitt, den es nicht mehr gibt. Das Formular muss mitziehen, sonst + // schriebe „Speichern“ den alten Punkt wieder zurück. + setForm((current) => ({ + ...current, + focalPointX: null, + focalPointY: null, + })); + }); + + const updateMutation = api.media.update.useMutation({ + onSuccess: () => { + void utils.media.getAll.invalidate(); + void utils.media.getStatistics.invalidate(); + toast.success("Änderungen gespeichert"); + onClose(); + }, + onError: (mutationError) => { + setError(mutationError.message); + toast.error(mutationError.message); + }, + }); + + const isImage = media.mimeType.startsWith("image/"); + + const set = (key: K, value: EditForm[K]) => + setForm((current) => ({ ...current, [key]: value })); + + const handleSave = () => { + const name = form.name.trim(); + if (!name) { + setError("Der Name darf nicht leer sein."); + return; + } + + updateMutation.mutate({ + id: media.id, + name, + // `|| null` statt `|| undefined`: ein geleertes Feld soll die Spalte + // leeren. Mit `undefined` ließe Prisma den alten Wert stehen, und der + // Dialog meldete eine Änderung, die nie stattgefunden hat. + alt: form.alt.trim() || null, + title: form.title.trim() || null, + caption: form.caption.trim() || null, + copyright: form.copyright.trim() || null, + creator: form.creator.trim() || null, + tags: splitMediaTags(form.tags), + isPublic: form.isPublic, + focalPointX: form.focalPointX, + focalPointY: form.focalPointY, + }); + }; + + return ( + + + +
+

+ Medium bearbeiten +

+ + {statusLabels[media.status]} + +
+
+ + + {/* Links das Bild samt Werkzeugen, rechts die Metadaten: das + Formular ist der eigentliche Zweck des Dialogs und bekommt die + Spalte, die nicht scrollen muss. */} +
+
+ {isImage ? ( + + setForm((current) => ({ + ...current, + focalPointX: x, + focalPointY: y, + })) + } + /> + ) : ( +
+ {getMimeTypeIcon(media.mimeType)} +
+ )} + +
+ {isImage && ( + + )} + +
+ +
+
+
Typ
+
{getMimeTypeLabel(media.mimeType)}
+
+
+
Größe
+
+ {formatFileSize(media.size)} + {media.width && media.height + ? ` · ${media.width}×${media.height}` + : ""} +
+
+
+
Hochgeladen
+
{formatDate(media.createdAt)}
+
+ {media.uploadedBy && ( +
+
Von
+
{media.uploadedBy.displayName}
+
+ )} +
+
Dateiname
+
+ {media.filename} +
+
+
+
+ +
+
+ + set("name", event.target.value)} + error={!form.name.trim()} + /> +

+ Interne Bezeichnung in der Medienübersicht und im Download. +

+
+ +
+ + set("alt", event.target.value)} + placeholder="Beschreibung für Screenreader" + /> +
+ +
+ + set("title", event.target.value)} + placeholder="Anzeigetitel" + /> +
+ +
+ +