Files
flimmer/src/components/universes/UniverseTools.tsx
T
Florian Wilde 853d56f764
CI / pruefen (pull_request) Canceled after 0s
Standardreihenfolge je Universum festlegen
Ein Universum kann mehrere Reihenfolgen haben, aber welche beim Oeffnen
erschien, liess sich nicht bestimmen — es war immer die
Erscheinungsreihenfolge. Das Feld isDefault gab es schon und wurde beim
Oeffnen auch ausgewertet; es fehlte nur der Weg, es zu setzen.

Der Standard gehoert dem Universum, nicht der einzelnen Person: wer
Schreibrecht hat, legt ihn fuer alle fest — so wie die Reihenfolgen selbst
auch geteilt sind. Die Rangfolge bleibt dadurch die schlichte: die Adresse
schlaegt den Standard schlaegt die erste.

setDefaultOrder() setzt zurueck und neu in einer Transaktion, damit nie zwei
Standards nebeneinander stehen. deleteOrder() faengt jetzt den Fall ab, dass
die Standardreihenfolge geloescht wird — dann uebernimmt die
Erscheinungsreihenfolge, statt dass die Auswahl auf "die erste" faellt.

Ueber die Schnittstelle geht es per PATCH /api/v1/universen/{id}. Der
Endpunkt prueft, dass die Reihenfolge zu genau diesem Universum gehoert:
ohne das setzte ein PATCH auf A den Standard in B, weil die Domaenenschicht
das Universum aus der Reihenfolge ableitet.

Vier Tests dazu: Umstellen samt Eindeutigkeit, Vorrang der Adresse,
Rueckfall beim Loeschen, Rechtepruefung. Klickweg und Endpunkt gegen einen
Produktionsbau geprueft.

Refs #4
2026-09-01 23:49:52 +02:00

436 lines
15 KiB
TypeScript

"use client";
import { useState, useTransition } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { clsx } from "clsx";
import {
addCollectionAction,
addTitleToUniverseAction,
createOrderAction,
createUniverseAction,
deleteOrderAction,
setDefaultOrderAction,
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}
{order.isDefault && (
<>
{/* Häkchen an der Reihenfolge, die beim Öffnen erscheint. Das
Wort dahinter steht nur für Screenreader — sichtbar würde
es die Reiter auseinanderziehen. */}
<svg
viewBox="0 0 24 24"
className="ml-1.5 inline h-3.5 w-3.5 align-[-2px]"
fill="none"
stroke="currentColor"
strokeWidth="3"
aria-hidden
>
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="sr-only"> (Standard)</span>
</>
)}
</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.isDefault && (
<button
type="button"
onClick={() =>
startTransition(async () => {
report(await setDefaultOrderAction(active.id));
router.refresh();
})
}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-text)] hover:underline"
>
Als Standard festlegen
</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>
);
}