feat: Oberfläche für Universen

Umsortieren geht per Ziehen und über Pfeilknöpfe: reines Drag & Drop wäre mit
Tastatur nicht bedienbar und auf dem Handy fummelig — die Knöpfe sind der
verlässliche Weg, das Ziehen der bequeme.

Die Erscheinungsreihenfolge lässt sich gar nicht erst anfassen, weil sie sich
aus den Daten ergibt.
This commit is contained in:
Florian Wilde
2026-09-01 19:39:32 +02:00
parent 90497ddc3b
commit 5ee6946fd5
6 changed files with 1149 additions and 0 deletions
+272
View File
@@ -0,0 +1,272 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { clsx } from "clsx";
import { reorderAction, removeFromUniverseAction } from "@/app/actions/universes";
import { useToasts } from "@/components/Toasts";
import { Poster } from "@/components/Poster";
import { ProgressBar } from "@/components/ProgressBar";
import { formatMinutes, STATUS_COLOR, STATUS_LABEL, type StatusKey } from "@/lib/labels";
import type { UniverseEntry } from "@/lib/universes";
/**
* Nummerierte Liste eines Universums in der gewählten Reihenfolge.
*
* Umsortieren geht per Ziehen **und** über Pfeilknöpfe. Reines Drag & Drop
* wäre mit Tastatur nicht bedienbar und auf dem Handy fummelig; die Knöpfe
* sind der verlässliche Weg, das Ziehen der bequeme.
*
* Die Erscheinungsreihenfolge (`sortable = false`) ergibt sich aus den Daten
* und lässt sich deshalb gar nicht erst anfassen.
*/
export function OrderedList({
universeId,
orderId,
entries,
sortable,
canWrite,
nextUpItemId,
}: {
universeId: string;
orderId: string;
entries: UniverseEntry[];
sortable: boolean;
canWrite: boolean;
nextUpItemId: string | null;
}) {
const router = useRouter();
const { report } = useToasts();
const [items, setItems] = useState(entries);
const [dragging, setDragging] = useState<string | null>(null);
const [, startTransition] = useTransition();
useEffect(() => setItems(entries), [entries]);
const interactive = sortable && canWrite;
function persist(next: UniverseEntry[]) {
setItems(next);
startTransition(async () => {
const result = await reorderAction(
orderId,
next.map((entry) => entry.itemId),
);
if (!result.ok) {
report(result);
setItems(entries);
}
router.refresh();
});
}
function move(index: number, delta: number) {
const target = index + delta;
if (target < 0 || target >= items.length) return;
const next = [...items];
const [moved] = next.splice(index, 1);
if (moved) next.splice(target, 0, moved);
persist(next);
}
function dropOn(targetItemId: string) {
if (!dragging || dragging === targetItemId) return;
const from = items.findIndex((entry) => entry.itemId === dragging);
const to = items.findIndex((entry) => entry.itemId === targetItemId);
if (from < 0 || to < 0) return;
const next = [...items];
const [moved] = next.splice(from, 1);
if (moved) next.splice(to, 0, moved);
setDragging(null);
persist(next);
}
if (items.length === 0) {
return (
<div className="fl-card p-8 text-center">
<p className="text-[var(--fl-muted)]">
Dieses Universum ist noch leer. Nimm Filme und Serien aus deiner Bibliothek auf.
</p>
</div>
);
}
return (
<ol className="space-y-2">
{items.map((entry, index) => (
<li
key={entry.itemId}
draggable={interactive}
onDragStart={() => setDragging(entry.itemId)}
onDragEnd={() => setDragging(null)}
onDragOver={(event) => {
if (interactive) event.preventDefault();
}}
onDrop={() => dropOn(entry.itemId)}
className={clsx(
"fl-card flex items-center gap-3 p-2.5 transition-opacity sm:gap-4 sm:p-3",
dragging === entry.itemId && "opacity-40",
entry.itemId === nextUpItemId && "ring-1",
)}
style={
entry.itemId === nextUpItemId
? {
boxShadow: "var(--fl-glow)",
borderColor: "color-mix(in oklab, var(--fl-magenta) 45%, transparent)",
}
: undefined
}
>
<span
aria-hidden
className={clsx(
"w-7 shrink-0 text-center font-mono text-sm",
entry.completed ? "text-[var(--fl-success)]" : "text-[var(--fl-faint)]",
)}
>
{entry.completed ? "✓" : index + 1}
</span>
<Link href={`/titel/${entry.titleId}`} className="w-12 shrink-0 sm:w-14">
<Poster path={entry.posterPath} alt={entry.title} size="w154" />
</Link>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<Link
href={`/titel/${entry.titleId}`}
className="font-medium hover:text-[var(--fl-accent)]"
>
{entry.title}
</Link>
{entry.itemId === nextUpItemId && (
<span
className="rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]"
style={{ backgroundImage: "var(--fl-gradient)" }}
>
Als Nächstes dran
</span>
)}
{entry.status && (
<span
className="text-[0.65rem] font-medium"
style={{ color: STATUS_COLOR[entry.status as StatusKey] }}
>
{STATUS_LABEL[entry.status as StatusKey]}
</span>
)}
</div>
<p className="mt-0.5 text-xs text-[var(--fl-faint)]">
{entry.type === "SERIES" ? "Serie" : "Film"}
{entry.year ? ` · ${entry.year}` : ""}
{entry.type === "SERIES"
? ` · ${entry.watchedEpisodes}/${entry.totalEpisodes} Episoden`
: ""}
{entry.remainingMinutes > 0 ? ` · noch ${formatMinutes(entry.remainingMinutes)}` : ""}
</p>
{entry.type === "SERIES" && entry.totalEpisodes > 0 && (
<div className="mt-1.5 max-w-xs">
<ProgressBar
percent={(entry.watchedEpisodes / entry.totalEpisodes) * 100}
label={`${entry.watchedEpisodes} von ${entry.totalEpisodes} Episoden gesehen`}
/>
</div>
)}
</div>
{canWrite && (
<div className="flex shrink-0 items-center gap-1">
{interactive && (
<>
<IconButton
label={`${entry.title} nach oben`}
disabled={index === 0}
onClick={() => move(index, -1)}
>
<path d="m6 15 6-6 6 6" />
</IconButton>
<IconButton
label={`${entry.title} nach unten`}
disabled={index === items.length - 1}
onClick={() => move(index, 1)}
>
<path d="m6 9 6 6 6-6" />
</IconButton>
</>
)}
<RemoveButton universeId={universeId} itemId={entry.itemId} title={entry.title} />
</div>
)}
</li>
))}
</ol>
);
}
function IconButton({
label,
disabled,
onClick,
children,
}: {
label: string;
disabled?: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
className="rounded-lg p-1.5 text-[var(--fl-faint)] transition-colors hover:bg-[var(--fl-surface-2)] hover:text-[var(--fl-text)] disabled:opacity-30 disabled:hover:bg-transparent"
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
{children}
</svg>
</button>
);
}
function RemoveButton({
universeId,
itemId,
title,
}: {
universeId: string;
itemId: string;
title: string;
}) {
const router = useRouter();
const { report } = useToasts();
const [, startTransition] = useTransition();
return (
<IconButton
label={`${title} aus dem Universum entfernen`}
onClick={() =>
startTransition(async () => {
report(await removeFromUniverseAction(universeId, itemId));
router.refresh();
})
}
>
<path d="m6 6 12 12M18 6 6 18" />
</IconButton>
);
}
+401
View File
@@ -0,0 +1,401 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { clsx } from "clsx";
import {
addCollectionAction,
addTitleToUniverseAction,
createOrderAction,
createUniverseAction,
deleteOrderAction,
shareUniverseAction,
trashUniverseAction,
unshareUniverseAction,
} from "@/app/actions/universes";
import { useToasts } from "@/components/Toasts";
import type { UniverseView } from "@/lib/universes";
/** Formular für ein neues Universum. */
export function NewUniverseForm() {
const router = useRouter();
const { report } = useToasts();
const [pending, startTransition] = useTransition();
return (
<form
action={(formData) => {
const name = String(formData.get("name") ?? "").trim();
if (!name) return;
startTransition(async () => {
const result = await createUniverseAction(
name,
String(formData.get("description") ?? ""),
);
report(result);
if (result.ok && result.id) router.push(`/universen/${result.id}`);
});
}}
className="space-y-4"
>
<div className="space-y-1.5">
<label htmlFor="name" className="block text-sm font-medium text-[var(--fl-muted)]">
Name
</label>
<input
id="name"
name="name"
required
autoFocus
placeholder="MCU, Star Wars, Wizarding World …"
className="fl-input"
/>
</div>
<div className="space-y-1.5">
<label htmlFor="description" className="block text-sm font-medium text-[var(--fl-muted)]">
Beschreibung <span className="text-xs text-[var(--fl-faint)]">(optional)</span>
</label>
<textarea id="description" name="description" rows={3} className="fl-input resize-y" />
</div>
<button type="submit" disabled={pending} className="fl-btn fl-btn-primary">
{pending ? "Einen Moment …" : "Universum anlegen"}
</button>
</form>
);
}
/** Umschalter zwischen den Reihenfolgen eines Universums. */
export function OrderTabs({
universe,
canWrite,
}: {
universe: UniverseView;
canWrite: boolean;
}) {
const router = useRouter();
const params = useSearchParams();
const { report } = useToasts();
const [adding, setAdding] = useState(false);
const [, startTransition] = useTransition();
function select(orderId: string) {
const next = new URLSearchParams(params.toString());
next.set("reihenfolge", orderId);
router.replace(`/universen/${universe.id}?${next}`, { scroll: false });
}
const active = universe.orders.find((order) => order.id === universe.activeOrderId);
return (
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap gap-1.5" role="tablist" aria-label="Reihenfolge">
{universe.orders.map((order) => {
const isActive = order.id === universe.activeOrderId;
return (
<button
key={order.id}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => select(order.id)}
className={clsx(
"rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors",
isActive
? "border-transparent text-[var(--fl-on-accent)]"
: "border-[var(--fl-border)] text-[var(--fl-muted)] hover:border-[var(--fl-border-strong)] hover:text-[var(--fl-text)]",
)}
style={isActive ? { backgroundImage: "var(--fl-gradient)" } : undefined}
>
{order.name}
</button>
);
})}
</div>
{canWrite && (
<>
{adding ? (
<form
action={(formData) => {
const name = String(formData.get("orderName") ?? "").trim();
if (!name) return;
setAdding(false);
startTransition(async () => {
const result = await createOrderAction(universe.id, name);
report(result);
if (result.ok && result.id) select(result.id);
router.refresh();
});
}}
className="flex items-center gap-1.5"
>
<input
name="orderName"
autoFocus
placeholder="z. B. Chronologisch"
className="fl-input w-48 py-1.5 text-sm"
/>
<button type="submit" className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm">
Anlegen
</button>
<button
type="button"
onClick={() => setAdding(false)}
className="text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]"
>
Abbrechen
</button>
</form>
) : (
<button
type="button"
onClick={() => setAdding(true)}
className="rounded-full border border-dashed border-[var(--fl-border-strong)] px-3.5 py-1.5 text-sm text-[var(--fl-muted)] transition-colors hover:text-[var(--fl-text)]"
>
+ Reihenfolge
</button>
)}
{active && active.kind === "CUSTOM" && (
<button
type="button"
onClick={() =>
startTransition(async () => {
report(await deleteOrderAction(active.id));
router.replace(`/universen/${universe.id}`);
router.refresh();
})
}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
>
„{active.name}" löschen
</button>
)}
</>
)}
</div>
);
}
/** Titel aus der eigenen Bibliothek aufnehmen — mit Tippfilter. */
export function AddTitles({
universeId,
candidates,
collections,
}: {
universeId: string;
candidates: { id: string; title: string; type: string; year: number | null }[];
collections: { id: number; name: string }[];
}) {
const router = useRouter();
const { report } = useToasts();
const [query, setQuery] = useState("");
const [pending, startTransition] = useTransition();
const filtered = query.trim()
? candidates
.filter((item) => item.title.toLocaleLowerCase("de-DE").includes(query.toLocaleLowerCase("de-DE")))
.slice(0, 8)
: candidates.slice(0, 8);
return (
<div className="space-y-4">
{collections.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-semibold text-[var(--fl-muted)]">
Aus einer TMDB-Reihe übernehmen
</h3>
<div className="flex flex-wrap gap-1.5">
{collections.map((collection) => (
<button
key={collection.id}
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await addCollectionAction(universeId, collection.id));
router.refresh();
})
}
className="rounded-full border border-[var(--fl-border)] px-3 py-1.5 text-sm text-[var(--fl-muted)] transition-colors hover:border-[var(--fl-border-strong)] hover:text-[var(--fl-text)]"
>
{collection.name}
</button>
))}
</div>
</div>
)}
<div>
<h3 className="mb-2 text-sm font-semibold text-[var(--fl-muted)]">
Aus der Bibliothek aufnehmen
</h3>
{candidates.length === 0 ? (
<p className="text-sm text-[var(--fl-faint)]">
Alles aus deiner Bibliothek ist schon in diesem Universum.
</p>
) : (
<>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Titel filtern …"
aria-label="Titel filtern"
className="fl-input mb-2"
/>
<ul className="space-y-1">
{filtered.map((item) => (
<li key={item.id}>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await addTitleToUniverseAction(universeId, item.id));
router.refresh();
})
}
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-[var(--fl-surface-2)]"
>
<span className="min-w-0 truncate">{item.title}</span>
<span className="ml-3 shrink-0 text-xs text-[var(--fl-faint)]">
{item.type === "SERIES" ? "Serie" : "Film"}
{item.year ? ` · ${item.year}` : ""}
</span>
</button>
</li>
))}
{filtered.length === 0 && (
<li className="px-3 py-2 text-sm text-[var(--fl-faint)]">Kein Treffer.</li>
)}
</ul>
</>
)}
</div>
</div>
);
}
/** Freigabe an andere Konten der Instanz. */
export function SharePanel({ universe }: { universe: UniverseView }) {
const router = useRouter();
const { report } = useToasts();
const [permission, setPermission] = useState<"READ" | "WRITE">("READ");
const [pending, startTransition] = useTransition();
return (
<div className="space-y-3">
<form
action={(formData) => {
const email = String(formData.get("email") ?? "").trim();
if (!email) return;
startTransition(async () => {
report(await shareUniverseAction(universe.id, email, permission));
router.refresh();
});
}}
className="space-y-2"
>
<label htmlFor="share-email" className="block text-sm font-medium text-[var(--fl-muted)]">
Mit jemandem auf dieser Instanz teilen
</label>
<input
id="share-email"
name="email"
type="email"
placeholder="person@beispiel.de"
className="fl-input"
/>
<div className="flex flex-wrap items-center gap-2">
<select
value={permission}
onChange={(event) => setPermission(event.target.value as "READ" | "WRITE")}
aria-label="Recht"
className="rounded-lg border border-[var(--fl-border)] bg-[var(--fl-surface-2)] px-2.5 py-1.5 text-sm"
>
<option value="READ">Nur ansehen</option>
<option value="WRITE">Gemeinsam pflegen</option>
</select>
<button type="submit" disabled={pending} className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm">
Teilen
</button>
</div>
</form>
{universe.members.length > 0 && (
<ul className="space-y-1 border-t border-[var(--fl-border)] pt-3">
{universe.members.map((member) => (
<li key={member.id} className="flex items-center gap-2 text-sm">
<span className="min-w-0 flex-1 truncate">
{member.name}
<span className="ml-1.5 text-xs text-[var(--fl-faint)]">
{member.permission === "WRITE" ? "pflegt mit" : "liest mit"}
</span>
</span>
<button
type="button"
onClick={() =>
startTransition(async () => {
report(await unshareUniverseAction(universe.id, member.id));
router.refresh();
})
}
className="text-xs text-[var(--fl-faint)] underline-offset-2 hover:text-[var(--fl-danger)] hover:underline"
>
entfernen
</button>
</li>
))}
</ul>
)}
</div>
);
}
/** Universum löschen — mit Rückfrage. */
export function DeleteUniverse({ universeId, name }: { universeId: string; name: string }) {
const router = useRouter();
const { report } = useToasts();
const [confirming, setConfirming] = useState(false);
const [pending, startTransition] = useTransition();
if (!confirming) {
return (
<button
type="button"
onClick={() => setConfirming(true)}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
>
Universum löschen
</button>
);
}
return (
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-[var(--fl-muted)]">„{name}" wirklich löschen?</span>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await trashUniverseAction(universeId));
router.push("/universen");
})
}
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm text-[var(--fl-danger)]"
>
Ja, löschen
</button>
<button
type="button"
onClick={() => setConfirming(false)}
className="text-[var(--fl-faint)] hover:text-[var(--fl-text)]"
>
Abbrechen
</button>
</div>
);
}