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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user