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,155 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import {
|
||||
addCollection,
|
||||
addTitle,
|
||||
createOrder,
|
||||
createUniverse,
|
||||
deleteOrder,
|
||||
removeTitle,
|
||||
renameOrder,
|
||||
renameUniverse,
|
||||
setOrderPositions,
|
||||
shareUniverse,
|
||||
trashUniverse,
|
||||
unshareUniverse,
|
||||
UniverseError,
|
||||
} from "@/lib/universes";
|
||||
|
||||
/** Server-Aktionen für Universen. Zugriffsrechte prüft die Domänenschicht. */
|
||||
|
||||
export type UniverseActionResult = { ok: boolean; message?: string; id?: string };
|
||||
|
||||
async function run(
|
||||
fn: (userId: string) => Promise<{ message?: string; id?: string } | void>,
|
||||
): Promise<UniverseActionResult> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||||
|
||||
try {
|
||||
const result = (await fn(user.id)) ?? {};
|
||||
revalidatePath("/universen", "layout");
|
||||
revalidatePath("/", "layout");
|
||||
return { ok: true, message: result.message, id: result.id };
|
||||
} catch (error) {
|
||||
if (error instanceof UniverseError) return { ok: false, message: error.message };
|
||||
console.error("Universums-Aktion fehlgeschlagen:", error);
|
||||
return { ok: false, message: "Das hat nicht geklappt. Bitte noch einmal versuchen." };
|
||||
}
|
||||
}
|
||||
|
||||
const id = z.string().min(1).max(64);
|
||||
const name = z.string().trim().min(1).max(120);
|
||||
|
||||
export async function createUniverseAction(rawName: string, description?: string) {
|
||||
return run(async (userId) => {
|
||||
const universe = await createUniverse(userId, name.parse(rawName), description);
|
||||
return { message: `„${universe.name}" angelegt`, id: universe.id };
|
||||
});
|
||||
}
|
||||
|
||||
export async function renameUniverseAction(
|
||||
universeId: string,
|
||||
data: { name?: string; description?: string | null },
|
||||
) {
|
||||
return run(async (userId) => {
|
||||
await renameUniverse(userId, id.parse(universeId), {
|
||||
...(data.name !== undefined ? { name: name.parse(data.name) } : {}),
|
||||
...(data.description !== undefined
|
||||
? { description: z.string().max(2000).nullable().parse(data.description) }
|
||||
: {}),
|
||||
});
|
||||
return { message: "Gespeichert" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function trashUniverseAction(universeId: string) {
|
||||
return run(async (userId) => {
|
||||
await trashUniverse(userId, id.parse(universeId));
|
||||
return { message: "Universum gelöscht" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function addTitleToUniverseAction(universeId: string, titleId: string) {
|
||||
return run(async (userId) => {
|
||||
await addTitle(userId, id.parse(universeId), id.parse(titleId));
|
||||
return { message: "Aufgenommen" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeFromUniverseAction(universeId: string, itemId: string) {
|
||||
return run(async (userId) => {
|
||||
await removeTitle(userId, id.parse(universeId), id.parse(itemId));
|
||||
return { message: "Aus dem Universum entfernt" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function addCollectionAction(universeId: string, tmdbCollectionId: number) {
|
||||
return run(async (userId) => {
|
||||
const added = await addCollection(
|
||||
userId,
|
||||
id.parse(universeId),
|
||||
z.number().int().parse(tmdbCollectionId),
|
||||
);
|
||||
return {
|
||||
message:
|
||||
added === 0
|
||||
? "Alles daraus war schon drin"
|
||||
: `${added} ${added === 1 ? "Titel" : "Titel"} übernommen`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function createOrderAction(universeId: string, rawName: string) {
|
||||
return run(async (userId) => {
|
||||
const order = await createOrder(userId, id.parse(universeId), name.parse(rawName));
|
||||
return { message: "Reihenfolge angelegt", id: order.id };
|
||||
});
|
||||
}
|
||||
|
||||
export async function renameOrderAction(orderId: string, rawName: string) {
|
||||
return run(async (userId) => {
|
||||
await renameOrder(userId, id.parse(orderId), name.parse(rawName));
|
||||
return { message: "Umbenannt" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteOrderAction(orderId: string) {
|
||||
return run(async (userId) => {
|
||||
await deleteOrder(userId, id.parse(orderId));
|
||||
return { message: "Reihenfolge gelöscht" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function reorderAction(orderId: string, itemIds: string[]) {
|
||||
return run(async (userId) => {
|
||||
await setOrderPositions(userId, id.parse(orderId), z.array(id).max(1000).parse(itemIds));
|
||||
return { message: "Reihenfolge gespeichert" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function shareUniverseAction(
|
||||
universeId: string,
|
||||
email: string,
|
||||
permission: "READ" | "WRITE",
|
||||
) {
|
||||
return run(async (userId) => {
|
||||
const target = await shareUniverse(
|
||||
userId,
|
||||
id.parse(universeId),
|
||||
z.string().trim().email().parse(email),
|
||||
z.enum(["READ", "WRITE"]).parse(permission),
|
||||
);
|
||||
return { message: `Mit ${target.name} geteilt` };
|
||||
});
|
||||
}
|
||||
|
||||
export async function unshareUniverseAction(universeId: string, memberId: string) {
|
||||
return run(async (userId) => {
|
||||
await unshareUniverse(userId, id.parse(universeId), id.parse(memberId));
|
||||
return { message: "Freigabe aufgehoben" };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getUniverse, suggestCollections } from "@/lib/universes";
|
||||
import { formatMinutes } from "@/lib/labels";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { ProgressBar } from "@/components/ProgressBar";
|
||||
import { OrderedList } from "@/components/universes/OrderedList";
|
||||
import {
|
||||
AddTitles,
|
||||
DeleteUniverse,
|
||||
OrderTabs,
|
||||
SharePanel,
|
||||
} from "@/components/universes/UniverseTools";
|
||||
|
||||
type Params = Promise<{ id: string }>;
|
||||
type Search = Promise<{ reihenfolge?: string }>;
|
||||
|
||||
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const universe = await prisma.universe.findUnique({ where: { id }, select: { name: true } });
|
||||
return { title: universe?.name ?? "Universum" };
|
||||
}
|
||||
|
||||
export default async function UniversePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Params;
|
||||
searchParams: Search;
|
||||
}) {
|
||||
const user = await requireUser();
|
||||
const settings = await getSettings();
|
||||
const { id } = await params;
|
||||
const { reihenfolge } = await searchParams;
|
||||
|
||||
const universe = await getUniverse(user.id, id, reihenfolge);
|
||||
if (!universe) notFound();
|
||||
|
||||
const activeOrder = universe.orders.find((order) => order.id === universe.activeOrderId);
|
||||
const inUniverse = new Set(universe.entries.map((entry) => entry.titleId));
|
||||
|
||||
// Vorschläge zum Aufnehmen: alles aus der eigenen Bibliothek, was noch fehlt.
|
||||
const [libraryTitles, collections] = universe.canWrite
|
||||
? await Promise.all([
|
||||
prisma.libraryEntry.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { title: { title: "asc" } },
|
||||
select: {
|
||||
title: { select: { id: true, title: true, type: true, releaseDate: true } },
|
||||
},
|
||||
}),
|
||||
suggestCollections(user.id),
|
||||
])
|
||||
: [[], []];
|
||||
|
||||
const candidates = libraryTitles
|
||||
.map((entry) => entry.title)
|
||||
.filter((title) => !inUniverse.has(title.id))
|
||||
.map((title) => ({
|
||||
id: title.id,
|
||||
title: title.title,
|
||||
type: title.type,
|
||||
year: title.releaseDate?.getFullYear() ?? null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<header className="mb-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
||||
Universum
|
||||
{!universe.isOwner && ` · geteilt von ${universe.ownerName}`}
|
||||
{!universe.canWrite && " · nur ansehen"}
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">{universe.name}</h1>
|
||||
{universe.description && (
|
||||
<p className="mt-1 max-w-2xl text-sm text-[var(--fl-muted)]">{universe.description}</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<section className="fl-card mb-5 p-5" aria-label="Gesamtfortschritt">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">
|
||||
{universe.progress.completed}{" "}
|
||||
<span className="text-base font-normal text-[var(--fl-muted)]">
|
||||
von {universe.progress.total} abgeschlossen
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||
{formatMinutes(universe.progress.watchedMinutes)} gesehen
|
||||
{universe.progress.remainingMinutes > 0 && (
|
||||
<>
|
||||
{" · noch "}
|
||||
<span className="text-[var(--fl-text)]">
|
||||
{formatMinutes(universe.progress.remainingMinutes)}
|
||||
</span>
|
||||
{" bis zum Ende des Universums"}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{universe.nextUp && (
|
||||
<div className="text-right">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
||||
Als Nächstes dran
|
||||
</p>
|
||||
<Link
|
||||
href={`/titel/${universe.nextUp.titleId}`}
|
||||
className="fl-gradient-text text-lg font-semibold"
|
||||
>
|
||||
{universe.nextUp.title}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<ProgressBar
|
||||
percent={universe.progress.percent}
|
||||
label={`${universe.progress.percent} Prozent des Universums abgeschlossen`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mb-4">
|
||||
<OrderTabs universe={universe} canWrite={universe.canWrite} />
|
||||
{activeOrder?.kind === "RELEASE" ? (
|
||||
<p className="mt-2 text-xs text-[var(--fl-faint)]">
|
||||
Diese Reihenfolge ergibt sich aus den Erscheinungsdaten und bleibt damit immer aktuell.
|
||||
</p>
|
||||
) : universe.canWrite ? (
|
||||
<p className="mt-2 text-xs text-[var(--fl-faint)]">
|
||||
Ziehen oder die Pfeile benutzen, um umzusortieren — gespeichert wird sofort.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
|
||||
<div className="order-2 lg:order-1">
|
||||
<OrderedList
|
||||
universeId={universe.id}
|
||||
orderId={universe.activeOrderId}
|
||||
entries={universe.entries}
|
||||
sortable={activeOrder?.kind === "CUSTOM"}
|
||||
canWrite={universe.canWrite}
|
||||
nextUpItemId={universe.nextUp?.itemId ?? null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aside className="order-1 space-y-5 lg:order-2">
|
||||
{universe.canWrite && (
|
||||
<div className="fl-card p-4">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
||||
Titel aufnehmen
|
||||
</h2>
|
||||
<AddTitles
|
||||
universeId={universe.id}
|
||||
candidates={candidates}
|
||||
collections={collections}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{universe.isOwner && (
|
||||
<>
|
||||
<div className="fl-card p-4">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
||||
Teilen
|
||||
</h2>
|
||||
<SharePanel universe={universe} />
|
||||
</div>
|
||||
|
||||
<div className="fl-card p-4">
|
||||
<DeleteUniverse universeId={universe.id} name={universe.name} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-sm">
|
||||
<Link href="/universen" className="text-[var(--fl-faint)] hover:text-[var(--fl-muted)]">
|
||||
← Alle Universen
|
||||
</Link>
|
||||
</p>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { NewUniverseForm } from "@/components/universes/UniverseTools";
|
||||
|
||||
export const metadata: Metadata = { title: "Neues Universum" };
|
||||
|
||||
export default async function NewUniversePage() {
|
||||
const user = await requireUser("/universen/neu");
|
||||
const settings = await getSettings();
|
||||
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<div className="mx-auto max-w-xl">
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Neues Universum</h1>
|
||||
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Ein Universum bündelt Filme und Serien, die zusammengehören. Die
|
||||
Erscheinungsreihenfolge ist von Anfang an dabei; weitere Reihenfolgen legst du
|
||||
danach an.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="fl-card p-5 sm:p-6">
|
||||
<NewUniverseForm />
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-sm">
|
||||
<Link href="/universen" className="text-[var(--fl-faint)] hover:text-[var(--fl-muted)]">
|
||||
← Zurück zur Übersicht
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { listUniverses } from "@/lib/universes";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Poster } from "@/components/Poster";
|
||||
import { ProgressBar } from "@/components/ProgressBar";
|
||||
|
||||
export const metadata: Metadata = { title: "Universen" };
|
||||
|
||||
export default async function UniversesPage() {
|
||||
const user = await requireUser("/universen");
|
||||
const settings = await getSettings();
|
||||
const universes = await listUniverses(user.id);
|
||||
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<header className="mb-6 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Universen</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-[var(--fl-muted)]">
|
||||
Filme und Serien, die zusammengehören — mit mehreren Reihenfolgen und einem
|
||||
Gesamtfortschritt.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/universen/neu" className="fl-btn fl-btn-primary">
|
||||
Neues Universum
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{universes.length === 0 ? (
|
||||
<div className="fl-card p-8 text-center">
|
||||
<p className="mx-auto max-w-md text-[var(--fl-muted)]">
|
||||
Noch kein Universum angelegt. Ein Universum bündelt zusammengehörige Titel — etwa alle
|
||||
MCU-Filme samt Serien — und zeigt dir, was als Nächstes dran ist.
|
||||
</p>
|
||||
<Link href="/universen/neu" className="fl-btn fl-btn-primary mt-4">
|
||||
Erstes Universum anlegen
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{universes.map((universe) => (
|
||||
<li key={universe.id}>
|
||||
<Link
|
||||
href={`/universen/${universe.id}`}
|
||||
className="fl-card fl-poster-card block overflow-hidden p-4"
|
||||
>
|
||||
<div className="mb-3 flex gap-1.5">
|
||||
{universe.posters.length > 0 ? (
|
||||
universe.posters.map((path, index) => (
|
||||
<div key={`${path}-${index}`} className="w-1/5">
|
||||
<Poster path={path} alt="" size="w154" />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="w-1/5">
|
||||
<Poster path={null} alt={universe.name} size="w154" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 className="font-semibold">{universe.name}</h2>
|
||||
{universe.description && (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-[var(--fl-muted)]">
|
||||
{universe.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-2 text-xs text-[var(--fl-faint)]">
|
||||
{universe.itemCount} {universe.itemCount === 1 ? "Titel" : "Titel"}
|
||||
{!universe.isOwner ? ` · geteilt von ${universe.ownerName}` : ""}
|
||||
</p>
|
||||
|
||||
<div className="mt-2">
|
||||
<ProgressBar
|
||||
percent={universe.percent}
|
||||
label={`${universe.completed} von ${universe.itemCount} abgeschlossen`}
|
||||
showValue
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user