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,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