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.
273 lines
8.2 KiB
TypeScript
273 lines
8.2 KiB
TypeScript
"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>
|
|
);
|
|
}
|