Rewrite the calendar frontend in Next.js
Replaces the Angular 18 app with Next.js 16 / React 19 / Tailwind 4 / shadcn, the same stack and design system as nachklang-feedback, planned in docs/rewrite-plan.md. - Admin: all calendars in one mobile-friendly list (month groups, calendar chips, Kommend/Vergangen/Alle, search, sort, filters in the URL), an edit sheet for create/edit/duplicate/delete, publish confirmation, and an "Abonnieren" dialog with subscription links for every calendar. - Public landing page: subscribe links from env instead of hardcoded, plus the next public events, server-rendered and revalidated every 5 minutes. - All times handled explicitly as Europe/Berlin; whole-day events stored as 00:00-23:59. Fixes carried over from the Angular app: the move dialog leaking a cancelled calendar change, saves that looked saved when they failed, in-place Date mutation, and duplicated calendar-id mappings. - Same shared-session auth as before (proxy.ts gate, /admin/me, 401 vs 403). - 112 Vitest tests over lib/; docs in docs/ (architecture, features, gotchas, API wish list, deployment). Needs the API branch feature/calendar-ical-escaping-subscriptions deployed first (GET /calendar/events/subscriptions). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
138 files changed
+15942
-24010
No files matched your search
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import type { AdminUser } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* The signed-in account, for components below the admin layout. The layout
|
||||
* renders its children only once /me has answered, so inside the admin area
|
||||
* this is never null.
|
||||
*/
|
||||
const AdminSessionContext = createContext<AdminUser | null>(null);
|
||||
|
||||
export const AdminSessionProvider = AdminSessionContext.Provider;
|
||||
|
||||
export const useAdminUser = (): AdminUser => {
|
||||
const user = useContext(AdminSessionContext);
|
||||
if (!user) {
|
||||
throw new Error("useAdminUser() used outside the admin layout");
|
||||
}
|
||||
return user;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { calendarById } from "@/lib/calendars";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** The calendar an event belongs to, in that calendar's colour - never the bare id. */
|
||||
export function CalendarBadge({ calendarId, className }: { calendarId: number; className?: string }) {
|
||||
const calendar = calendarById(calendarId);
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-5 items-center gap-1.5 rounded-full px-2 text-xs font-medium ring-1 ring-inset",
|
||||
calendar?.badgeClass ?? "bg-muted text-muted-foreground ring-border",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className={cn("size-1.5 rounded-full", calendar?.dotClass ?? "bg-muted-foreground")} />
|
||||
{calendar?.label ?? `Kalender ${calendarId}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { CircleAlertIcon, LoaderCircleIcon } from "lucide-react";
|
||||
import type { CalendarLoad } from "@/components/admin/useEvents";
|
||||
import { CALENDARS } from "@/lib/calendars";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* One toggle per calendar. A row that scrolls sideways on phones rather than
|
||||
* wrapping into three lines of chrome above the list; wraps on wider screens.
|
||||
*/
|
||||
export function CalendarChips({
|
||||
selected,
|
||||
counts,
|
||||
loads,
|
||||
onToggle,
|
||||
}: {
|
||||
selected: readonly number[];
|
||||
counts: Record<number, number>;
|
||||
loads: Record<number, CalendarLoad>;
|
||||
onToggle: (calendarId: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="-mx-4 flex gap-2 overflow-x-auto px-4 pb-1 [scrollbar-width:none] sm:mx-0 sm:flex-wrap sm:overflow-visible sm:px-0 [&::-webkit-scrollbar]:hidden"
|
||||
role="group"
|
||||
aria-label="Kalender"
|
||||
>
|
||||
{CALENDARS.map((calendar) => {
|
||||
const on = selected.includes(calendar.id);
|
||||
const load = loads[calendar.id];
|
||||
return (
|
||||
<button
|
||||
key={calendar.id}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => onToggle(calendar.id)}
|
||||
title={load?.status === "error" ? `${calendar.label} konnte nicht geladen werden: ${load.message}` : undefined}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center gap-2 rounded-full border px-3 text-sm transition-colors outline-none focus-visible:ring-3 focus-visible:ring-ring/30",
|
||||
on
|
||||
? "border-foreground/15 bg-foreground/[0.04] font-medium text-foreground"
|
||||
: "border-border text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("size-2.5 rounded-full", on ? calendar.dotClass : "ring-1 ring-muted-foreground/50 ring-inset")}
|
||||
/>
|
||||
{calendar.label}
|
||||
{load?.status === "loading" ? (
|
||||
<LoaderCircleIcon aria-label="lädt" className="size-3.5 animate-spin text-muted-foreground" />
|
||||
) : load?.status === "error" ? (
|
||||
<CircleAlertIcon aria-label="Fehler" className="size-3.5 text-destructive" />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{counts[calendar.id] ?? 0}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import { RepeatIcon } from "lucide-react";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { CALENDARS, calendarById } from "@/lib/calendars";
|
||||
import { repeatLabel } from "@/lib/event-display";
|
||||
import { setCalendar, setStart, type EventForm as EventFormState, type EventFormErrors } from "@/lib/event-form";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Element ids, so the sheet can focus the first field with an error. */
|
||||
export const fieldId = (field: keyof EventFormState): string => `event-${field}`;
|
||||
|
||||
const CALENDAR_ITEMS = CALENDARS.map((c) => ({ value: String(c.id), label: c.label }));
|
||||
|
||||
/**
|
||||
* The event's fields. Presentational: every rule (end follows start, the
|
||||
* birthday recurrence, validation) lives in lib/event-form.ts, and the sheet
|
||||
* owns the state.
|
||||
*
|
||||
* Inputs are the browser's native date/time pickers, which are the good ones
|
||||
* on phones. The URL field is plain text with a URL keyboard rather than
|
||||
* type="url", whose built-in validation would reject "nachklang.art" before
|
||||
* normaliseUrl() could accept it.
|
||||
*/
|
||||
export function EventForm({
|
||||
form,
|
||||
errors,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
form: EventFormState;
|
||||
/** Only the errors to show - empty until the first save attempt. */
|
||||
errors: EventFormErrors;
|
||||
disabled: boolean;
|
||||
onChange: (form: EventFormState) => void;
|
||||
}) {
|
||||
const set = <K extends keyof EventFormState>(key: K, value: EventFormState[K]) => onChange({ ...form, [key]: value });
|
||||
const forcedRepeat = calendarById(form.calendarId)?.forcedRepeatFrequency;
|
||||
const invalid = (field: keyof EventFormState) => (errors[field] ? true : undefined);
|
||||
|
||||
return (
|
||||
<FieldGroup className="gap-5">
|
||||
<Field data-invalid={invalid("calendarId")}>
|
||||
<FieldLabel htmlFor={fieldId("calendarId")}>Kalender</FieldLabel>
|
||||
<Select
|
||||
items={CALENDAR_ITEMS}
|
||||
value={form.calendarId ? String(form.calendarId) : null}
|
||||
onValueChange={(value) => value && onChange(setCalendar(form, Number(value)))}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger id={fieldId("calendarId")} className="w-full" aria-invalid={invalid("calendarId")}>
|
||||
<SelectValue placeholder="Kalender wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CALENDARS.map((c) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>
|
||||
<span aria-hidden className={cn("size-2 rounded-full", c.dotClass)} />
|
||||
{c.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError>{errors.calendarId}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={invalid("name")}>
|
||||
<FieldLabel htmlFor={fieldId("name")}>Titel</FieldLabel>
|
||||
<Input
|
||||
id={fieldId("name")}
|
||||
value={form.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
disabled={disabled}
|
||||
aria-invalid={invalid("name")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<FieldError>{errors.name}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field orientation="horizontal">
|
||||
<FieldLabel htmlFor={fieldId("wholeDay")} className="font-normal">
|
||||
Ganztägig
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
id={fieldId("wholeDay")}
|
||||
checked={form.wholeDay}
|
||||
onCheckedChange={(checked) => set("wholeDay", checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<DateTimeRow
|
||||
label="Beginn"
|
||||
dateField="startDate"
|
||||
timeField="startTime"
|
||||
date={form.startDate}
|
||||
time={form.startTime}
|
||||
showTime={!form.wholeDay}
|
||||
errors={errors}
|
||||
disabled={disabled}
|
||||
onDate={(date) => onChange(setStart(form, date, form.startTime))}
|
||||
onTime={(time) => onChange(setStart(form, form.startDate, time))}
|
||||
/>
|
||||
<DateTimeRow
|
||||
label="Ende"
|
||||
dateField="endDate"
|
||||
timeField="endTime"
|
||||
date={form.endDate}
|
||||
time={form.endTime}
|
||||
showTime={!form.wholeDay}
|
||||
errors={errors}
|
||||
disabled={disabled}
|
||||
onDate={(date) => set("endDate", date)}
|
||||
onTime={(time) => set("endTime", time)}
|
||||
/>
|
||||
|
||||
{form.repeatFrequency && (
|
||||
<p className="-mt-1 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<RepeatIcon aria-hidden className="size-4 shrink-0" />
|
||||
{forcedRepeat
|
||||
? `Wiederholt sich ${repeatLabel(form.repeatFrequency)} – das gilt für alle Termine im Kalender „${calendarById(form.calendarId)?.label}“.`
|
||||
: `Wiederholt sich ${repeatLabel(form.repeatFrequency)}.`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor={fieldId("location")}>Ort</FieldLabel>
|
||||
<Input
|
||||
id={fieldId("location")}
|
||||
value={form.location}
|
||||
onChange={(e) => set("location", e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={invalid("url")}>
|
||||
<FieldLabel htmlFor={fieldId("url")}>Link</FieldLabel>
|
||||
<Input
|
||||
id={fieldId("url")}
|
||||
value={form.url}
|
||||
onChange={(e) => set("url", e.target.value)}
|
||||
disabled={disabled}
|
||||
aria-invalid={invalid("url")}
|
||||
inputMode="url"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
placeholder="z. B. nachklang.art/konzert"
|
||||
/>
|
||||
<FieldError>{errors.url}</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor={fieldId("description")}>Beschreibung</FieldLabel>
|
||||
<Textarea
|
||||
id={fieldId("description")}
|
||||
value={form.description}
|
||||
onChange={(e) => set("description", e.target.value)}
|
||||
disabled={disabled}
|
||||
rows={4}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Status</FieldLabel>
|
||||
{form.status === "PRIVATE" ? (
|
||||
<FieldDescription>Privat – dieser Status wird beim Speichern beibehalten.</FieldDescription>
|
||||
) : (
|
||||
<>
|
||||
<ToggleGroup
|
||||
value={[form.status]}
|
||||
onValueChange={(value) => {
|
||||
const next = value[0];
|
||||
if (next === "DRAFT" || next === "PUBLIC") set("status", next);
|
||||
}}
|
||||
variant="outline"
|
||||
spacing={0}
|
||||
disabled={disabled}
|
||||
className="grid w-full grid-cols-2"
|
||||
aria-label="Status"
|
||||
>
|
||||
<ToggleGroupItem value="DRAFT" className="aria-pressed:bg-foreground/[0.06] aria-pressed:font-semibold">
|
||||
Entwurf
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="PUBLIC" className="aria-pressed:bg-foreground/[0.06] aria-pressed:font-semibold">
|
||||
Veröffentlicht
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<FieldDescription>
|
||||
{form.status === "DRAFT"
|
||||
? "Entwürfe sieht nur, wer hier angemeldet ist – nicht in Abos und nicht auf der Website."
|
||||
: "Veröffentlichte Termine erscheinen in den Kalender-Abos."}
|
||||
</FieldDescription>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function DateTimeRow({
|
||||
label,
|
||||
dateField,
|
||||
timeField,
|
||||
date,
|
||||
time,
|
||||
showTime,
|
||||
errors,
|
||||
disabled,
|
||||
onDate,
|
||||
onTime,
|
||||
}: {
|
||||
label: string;
|
||||
dateField: "startDate" | "endDate";
|
||||
timeField: "startTime" | "endTime";
|
||||
date: string;
|
||||
time: string;
|
||||
showTime: boolean;
|
||||
errors: EventFormErrors;
|
||||
disabled: boolean;
|
||||
onDate: (date: string) => void;
|
||||
onTime: (time: string) => void;
|
||||
}) {
|
||||
const error = errors[dateField] ?? (showTime ? errors[timeField] : undefined);
|
||||
return (
|
||||
<Field data-invalid={error ? true : undefined}>
|
||||
<FieldLabel htmlFor={fieldId(dateField)}>{label}</FieldLabel>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={fieldId(dateField)}
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => onDate(e.target.value)}
|
||||
disabled={disabled}
|
||||
aria-invalid={errors[dateField] ? true : undefined}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
{showTime && (
|
||||
<Input
|
||||
id={fieldId(timeField)}
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => onTime(e.target.value)}
|
||||
disabled={disabled}
|
||||
aria-invalid={errors[timeField] ? true : undefined}
|
||||
aria-label={`${label} Uhrzeit`}
|
||||
className="w-28 shrink-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FieldError>{error}</FieldError>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { CalendarX2Icon, SearchXIcon } from "lucide-react";
|
||||
import { Fragment } from "react";
|
||||
import { EventListItem } from "@/components/admin/EventListItem";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { formatDay } from "@/lib/dates";
|
||||
import { groupByMonth, type Range, type Sort } from "@/lib/filter";
|
||||
import type { CalendarEvent } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* The list itself: grouped by month when sorted by start (what people scan
|
||||
* for), flat when sorted by creation date (where month headings would repeat
|
||||
* and mislead).
|
||||
*/
|
||||
export function EventList({
|
||||
events,
|
||||
now,
|
||||
sort,
|
||||
range,
|
||||
query,
|
||||
loading,
|
||||
noCalendars,
|
||||
publishingId,
|
||||
todayMarker,
|
||||
onOpen,
|
||||
onPublish,
|
||||
onClearQuery,
|
||||
}: {
|
||||
events: CalendarEvent[];
|
||||
now: Date;
|
||||
sort: Sort;
|
||||
range: Range;
|
||||
query: string;
|
||||
loading: boolean;
|
||||
noCalendars: boolean;
|
||||
publishingId: number | null;
|
||||
/** Index into `events` to show the "Heute" marker in front of (`events.length` = after the last); null for none. */
|
||||
todayMarker: number | null;
|
||||
onOpen?: (event: CalendarEvent) => void;
|
||||
onPublish: (event: CalendarEvent) => void;
|
||||
onClearQuery: () => void;
|
||||
}) {
|
||||
if (noCalendars) {
|
||||
return <Empty icon={<CalendarX2Icon />} title="Kein Kalender ausgewählt" text="Wähle oben mindestens einen Kalender." />;
|
||||
}
|
||||
if (events.length === 0 && loading) {
|
||||
return <ListSkeleton />;
|
||||
}
|
||||
if (events.length === 0) {
|
||||
if (query.trim() !== "") {
|
||||
return (
|
||||
<Empty
|
||||
icon={<SearchXIcon />}
|
||||
title={`Keine Termine für „${query.trim()}“`}
|
||||
text="Die Suche berücksichtigt Titel und Ort."
|
||||
action={
|
||||
<Button variant="outline" size="sm" onClick={onClearQuery}>
|
||||
Suche leeren
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const text = { future: "Keine kommenden Termine.", past: "Keine vergangenen Termine.", all: "Noch keine Termine." }[range];
|
||||
return <Empty icon={<CalendarX2Icon />} title={text} text="In den ausgewählten Kalendern ist nichts eingetragen." />;
|
||||
}
|
||||
|
||||
const markerBefore = todayMarker !== null && todayMarker < events.length ? events[todayMarker].eventId : null;
|
||||
const marker = <TodayMarker now={now} />;
|
||||
|
||||
const item = (event: CalendarEvent) => (
|
||||
<Fragment key={event.eventId}>
|
||||
{event.eventId === markerBefore && marker}
|
||||
<EventListItem
|
||||
event={event}
|
||||
now={now}
|
||||
publishing={publishingId === event.eventId}
|
||||
onOpen={onOpen}
|
||||
onPublish={onPublish}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
if (sort.startsWith("created")) {
|
||||
return <ul className="divide-y overflow-hidden rounded-xl border bg-card">{events.map(item)}</ul>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{groupByMonth(events, now).map((group, i) => (
|
||||
<section key={`${group.key}-${i}`} aria-label={group.label}>
|
||||
<h2 className="sticky top-[calc(3.5rem+env(safe-area-inset-top))] z-10 -mx-1 bg-background/95 px-1 py-2 text-sm font-semibold text-muted-foreground backdrop-blur">
|
||||
{group.label}
|
||||
</h2>
|
||||
<ul className="divide-y overflow-hidden rounded-xl border bg-card">{group.events.map(item)}</ul>
|
||||
</section>
|
||||
))}
|
||||
{todayMarker === events.length && <ul className="overflow-hidden rounded-xl border">{marker}</ul>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Where today falls in the list. The page scrolls it into view in the "Alle" range. */
|
||||
function TodayMarker({ now }: { now: Date }) {
|
||||
return (
|
||||
<li
|
||||
id="heute"
|
||||
className="flex items-center gap-2 bg-primary/[0.05] px-4 py-1.5 text-xs font-semibold text-primary"
|
||||
>
|
||||
<span aria-hidden className="size-1.5 rounded-full bg-primary" />
|
||||
Heute · {formatDay(now)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty({ icon, title, text, action }: { icon: React.ReactNode; title: string; text: string; action?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed px-6 py-12 text-center">
|
||||
<div className="text-muted-foreground [&_svg]:size-8">{icon}</div>
|
||||
<p className="font-medium">{title}</p>
|
||||
<p className="text-sm text-muted-foreground">{text}</p>
|
||||
{action && <div className="mt-2">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3" aria-busy="true" aria-label="Termine werden geladen">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<div className="divide-y rounded-xl border">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<div key={i} className="flex gap-4 p-4">
|
||||
<Skeleton className="h-12 w-11" />
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-3 w-1/3" />
|
||||
<Skeleton className="h-5 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { LoaderCircleIcon, MapPinIcon, RepeatIcon, SendIcon } from "lucide-react";
|
||||
import { CalendarBadge } from "@/components/admin/CalendarBadge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { dateBlock, describeWhen, isPast, occurrence, repeatLabel } from "@/lib/event-display";
|
||||
import type { CalendarEvent } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* One event in the list. The whole row opens the event (a stretched button on
|
||||
* the title, so the row's other button - "Veröffentlichen" - stays a separate,
|
||||
* valid control rather than a button inside a button).
|
||||
*/
|
||||
export function EventListItem({
|
||||
event,
|
||||
now,
|
||||
publishing,
|
||||
onOpen,
|
||||
onPublish,
|
||||
}: {
|
||||
event: CalendarEvent;
|
||||
now: Date;
|
||||
publishing: boolean;
|
||||
onOpen?: (event: CalendarEvent) => void;
|
||||
onPublish: (event: CalendarEvent) => void;
|
||||
}) {
|
||||
const { start } = occurrence(event, now);
|
||||
const block = dateBlock(start);
|
||||
const repeat = repeatLabel(event.repeatFrequency);
|
||||
const title = event.name || "(ohne Titel)";
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"relative flex gap-3 px-3 py-3 transition-colors sm:gap-4 sm:px-4",
|
||||
onOpen && "hover:bg-muted/50 has-[button[data-open]:focus-visible]:bg-muted/50",
|
||||
isPast(event, now) && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-11 shrink-0 flex-col items-center pt-0.5 text-center leading-tight" aria-hidden>
|
||||
<span className="text-[11px] font-medium text-muted-foreground uppercase">{block.weekday}</span>
|
||||
<span className="text-xl font-semibold tabular-nums">{block.day}</span>
|
||||
<span className="text-[11px] text-muted-foreground">{block.month}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
{onOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
data-open
|
||||
onClick={() => onOpen(event)}
|
||||
className="text-left font-medium outline-none after:absolute after:inset-0 after:content-['']"
|
||||
>
|
||||
<span className="line-clamp-2">{title}</span>
|
||||
</button>
|
||||
) : (
|
||||
<span className="line-clamp-2 font-medium">{title}</span>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-sm text-muted-foreground">
|
||||
<span>{describeWhen(event, now)}</span>
|
||||
{event.location && (
|
||||
<span className="inline-flex min-w-0 items-center gap-1">
|
||||
<MapPinIcon aria-hidden className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{event.location}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<CalendarBadge calendarId={event.calendarId} />
|
||||
{event.status === "DRAFT" && (
|
||||
<span className="inline-flex h-5 items-center rounded-full border border-dashed border-amber-400 px-2 text-xs font-medium text-amber-800">
|
||||
Entwurf
|
||||
</span>
|
||||
)}
|
||||
{event.status === "PRIVATE" && (
|
||||
<span className="inline-flex h-5 items-center rounded-full bg-muted px-2 text-xs font-medium text-muted-foreground">
|
||||
Privat
|
||||
</span>
|
||||
)}
|
||||
{repeat && (
|
||||
<span className="inline-flex h-5 items-center gap-1 rounded-full bg-muted px-2 text-xs text-muted-foreground">
|
||||
<RepeatIcon aria-hidden className="size-3" />
|
||||
{repeat}
|
||||
</span>
|
||||
)}
|
||||
{event.status === "DRAFT" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="relative z-10 ml-auto"
|
||||
disabled={publishing}
|
||||
onClick={() => onPublish(event)}
|
||||
>
|
||||
{publishing ? <LoaderCircleIcon className="animate-spin" /> : <SendIcon />}
|
||||
Veröffentlichen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
"use client";
|
||||
|
||||
import { CircleAlertIcon, CopyIcon, LoaderCircleIcon, Trash2Icon, XIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useAdminUser } from "@/components/admin/AdminSession";
|
||||
import { EventForm, fieldId } from "@/components/admin/EventForm";
|
||||
import { useMediaQuery } from "@/components/admin/useMediaQuery";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { goToAdminLogin } from "@/lib/admin-auth";
|
||||
import { api } from "@/lib/api";
|
||||
import { describeApiError, isSessionExpired } from "@/lib/api-errors";
|
||||
import { calendarById } from "@/lib/calendars";
|
||||
import { formatDateTime } from "@/lib/dates";
|
||||
import {
|
||||
contentChanged,
|
||||
duplicateForm,
|
||||
eventFromPayload,
|
||||
formFromEvent,
|
||||
isFormDirty,
|
||||
newEventForm,
|
||||
toPayload,
|
||||
validateEventForm,
|
||||
type EventForm as EventFormState,
|
||||
type EventFormField,
|
||||
} from "@/lib/event-form";
|
||||
import type { CalendarEvent } from "@/lib/types";
|
||||
|
||||
export type SheetMode =
|
||||
| { kind: "new"; calendarId: number }
|
||||
| { kind: "edit"; event: CalendarEvent }
|
||||
| { kind: "duplicate"; event: CalendarEvent };
|
||||
|
||||
const TITLES: Record<SheetMode["kind"], string> = {
|
||||
new: "Neuer Termin",
|
||||
edit: "Termin bearbeiten",
|
||||
duplicate: "Termin duplizieren",
|
||||
};
|
||||
|
||||
// Top to bottom, so focus lands on the first problem the user would see.
|
||||
const FIELD_ORDER: EventFormField[] = ["calendarId", "name", "startDate", "startTime", "endDate", "endTime", "url"];
|
||||
|
||||
/**
|
||||
* Create, edit or duplicate one event. A bottom sheet on phones, a side panel
|
||||
* from `sm` up.
|
||||
*
|
||||
* Nothing closes before the API has answered - the Angular row snapped shut
|
||||
* the moment Save was clicked, so a failed save looked saved. On a failure the
|
||||
* sheet stays open with the edits and says what went wrong; a 401 is the one
|
||||
* failure that leaves, to the login page.
|
||||
*
|
||||
* The parent gives it a fresh `key` per opening, so state never leaks from one
|
||||
* event into the next.
|
||||
*/
|
||||
export function EventSheet({
|
||||
open,
|
||||
mode,
|
||||
now,
|
||||
onClose,
|
||||
onClosed,
|
||||
onSaved,
|
||||
onDeleted,
|
||||
onDuplicate,
|
||||
}: {
|
||||
open: boolean;
|
||||
mode: SheetMode | null;
|
||||
now: Date;
|
||||
/** Asked to close; the sheet has already dealt with unsaved changes. */
|
||||
onClose: () => void;
|
||||
/** The closing animation finished - safe to drop `mode`. */
|
||||
onClosed: () => void;
|
||||
onSaved: (event: CalendarEvent, previous: CalendarEvent | null) => void;
|
||||
onDeleted: (event: CalendarEvent) => void;
|
||||
onDuplicate: (event: CalendarEvent) => void;
|
||||
}) {
|
||||
const user = useAdminUser();
|
||||
const desktop = useMediaQuery("(min-width: 640px)");
|
||||
|
||||
// What "unchanged" means. Moves forward after a partial save (moved, but the
|
||||
// update failed), so a retry doesn't try to move again.
|
||||
const [original, setOriginal] = useState<CalendarEvent | null>(mode?.kind === "edit" ? mode.event : null);
|
||||
const [initial, setInitial] = useState<EventFormState | null>(() => (mode ? initialForm(mode, now) : null));
|
||||
const [form, setForm] = useState<EventFormState | null>(initial);
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [confirmDiscard, setConfirmDiscard] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const errors = useMemo(() => (form ? validateEventForm(form) : {}), [form]);
|
||||
const dirty = form !== null && initial !== null && isFormDirty(initial, form);
|
||||
const busy = saving || deleting;
|
||||
|
||||
// Leaving the page with unsaved edits - reload, closing the tab - asks too.
|
||||
useEffect(() => {
|
||||
if (!open || !dirty) return;
|
||||
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
|
||||
window.addEventListener("beforeunload", warn);
|
||||
return () => window.removeEventListener("beforeunload", warn);
|
||||
}, [open, dirty]);
|
||||
|
||||
const requestClose = () => {
|
||||
if (busy) return;
|
||||
if (dirty) {
|
||||
setConfirmDiscard(true);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const leaveForLogin = () => {
|
||||
toast.error("Deine Sitzung ist abgelaufen. Du wirst zur Anmeldung weitergeleitet …");
|
||||
goToAdminLogin();
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!form || !initial || !mode) return;
|
||||
setShowErrors(true);
|
||||
setSaveError(null);
|
||||
const firstError = FIELD_ORDER.find((field) => errors[field]);
|
||||
if (firstError) {
|
||||
document.getElementById(fieldId(firstError))?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = toPayload(form);
|
||||
setSaving(true);
|
||||
let moved = false;
|
||||
try {
|
||||
if (!original) {
|
||||
const eventId = await api.createEvent(payload);
|
||||
onSaved(eventFromPayload(eventId, payload, user.fullName, new Date()), null);
|
||||
toast.success(`„${payload.name}“ ist angelegt.`);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Calendar first, then content: a move only rewrites the calendar, so if
|
||||
// the second call fails the event is at least where it was sent, and the
|
||||
// message can say exactly that.
|
||||
if (payload.calendarId !== original.calendarId) {
|
||||
await api.moveEvent(original.eventId, payload);
|
||||
moved = true;
|
||||
}
|
||||
if (contentChanged(initial, form)) {
|
||||
await api.updateEvent(original.eventId, payload);
|
||||
}
|
||||
onSaved(eventFromPayload(original.eventId, payload, user.fullName, new Date(), original), original);
|
||||
toast.success(`„${payload.name}“ ist gespeichert.`);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
if (isSessionExpired(e)) {
|
||||
leaveForLogin();
|
||||
return;
|
||||
}
|
||||
if (moved && original) {
|
||||
// Half done: record the move, keep the other edits on screen.
|
||||
const movedEvent = { ...original, calendarId: payload.calendarId };
|
||||
onSaved(movedEvent, original);
|
||||
setOriginal(movedEvent);
|
||||
setInitial({ ...initial, calendarId: payload.calendarId });
|
||||
setSaveError(
|
||||
`Der Termin wurde nach „${calendarById(payload.calendarId)?.label}“ verschoben, die übrigen Änderungen aber nicht gespeichert: ${describeApiError(e)}`,
|
||||
);
|
||||
} else {
|
||||
setSaveError(describeApiError(e));
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!original) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.deleteEvent(original.eventId);
|
||||
setConfirmDelete(false);
|
||||
onDeleted(original);
|
||||
toast.success(`„${original.name}“ ist gelöscht.`);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
if (isSessionExpired(e)) {
|
||||
leaveForLogin();
|
||||
return;
|
||||
}
|
||||
setConfirmDelete(false);
|
||||
setSaveError(`Löschen fehlgeschlagen: ${describeApiError(e)}`);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) requestClose();
|
||||
}}
|
||||
onOpenChangeComplete={(isOpen) => {
|
||||
if (!isOpen) onClosed();
|
||||
}}
|
||||
>
|
||||
<SheetContent
|
||||
side={desktop ? "right" : "bottom"}
|
||||
showCloseButton={false}
|
||||
className="gap-0 data-[side=bottom]:max-h-[92dvh] data-[side=bottom]:rounded-t-2xl data-[side=right]:w-full data-[side=right]:sm:max-w-lg"
|
||||
>
|
||||
{mode && form && (
|
||||
<form
|
||||
noValidate
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void save();
|
||||
}}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<SheetHeader className="flex-row items-start justify-between gap-3 border-b p-4">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SheetTitle>{TITLES[mode.kind]}</SheetTitle>
|
||||
<SheetDescription className={mode.kind === "edit" ? "line-clamp-1" : undefined}>
|
||||
{mode.kind === "edit"
|
||||
? mode.event.name
|
||||
: mode.kind === "duplicate"
|
||||
? `Kopie von „${mode.event.name}“ – wird als neuer Entwurf angelegt.`
|
||||
: "Wird als Entwurf angelegt, wenn du den Status nicht änderst."}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon-sm" onClick={requestClose} aria-label="Schließen" disabled={busy}>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-5">
|
||||
<EventForm form={form} errors={showErrors ? errors : {}} disabled={busy} onChange={setForm} />
|
||||
{original && <Metadata event={original} />}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 border-t p-4 pb-[calc(1rem+env(safe-area-inset-bottom))]">
|
||||
{saveError && (
|
||||
<div role="alert" className="flex gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<CircleAlertIcon aria-hidden className="mt-0.5 size-4 shrink-0" />
|
||||
<p>{saveError}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Labels follow the footer's own width (container query), not the window's: the
|
||||
side panel is capped, so a wide window says nothing about the room in here. */}
|
||||
<div className="@container flex items-center gap-2">
|
||||
{original && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
disabled={busy}
|
||||
aria-label="Löschen"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2Icon />
|
||||
<span className="hidden @md:inline">Löschen</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDuplicate(original)}
|
||||
// Duplicating would silently drop the edits made so far.
|
||||
disabled={busy || dirty}
|
||||
title={dirty ? "Erst speichern oder verwerfen, dann duplizieren" : "Duplizieren"}
|
||||
aria-label="Duplizieren"
|
||||
>
|
||||
<CopyIcon />
|
||||
<span className="hidden @md:inline">Duplizieren</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={requestClose} disabled={busy}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={busy}>
|
||||
{saving && <LoaderCircleIcon className="animate-spin" />}
|
||||
{original ? "Speichern" : "Anlegen"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<AlertDialog open={confirmDiscard} onOpenChange={setConfirmDiscard}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Änderungen verwerfen?</AlertDialogTitle>
|
||||
<AlertDialogDescription>Was du hier geändert hast, ist noch nicht gespeichert.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Weiter bearbeiten</AlertDialogCancel>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setConfirmDiscard(false);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Verwerfen
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={confirmDelete} onOpenChange={(next) => !deleting && setConfirmDelete(next)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>„{original?.name}“ löschen?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Der Termin verschwindet aus dem Kalender und aus allen Abos. Das lässt sich hier nicht rückgängig machen.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>Abbrechen</AlertDialogCancel>
|
||||
<Button variant="destructive" onClick={() => void remove()} disabled={deleting}>
|
||||
{deleting ? <LoaderCircleIcon className="animate-spin" /> : <Trash2Icon />}
|
||||
Löschen
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function initialForm(mode: SheetMode, now: Date): EventFormState {
|
||||
switch (mode.kind) {
|
||||
case "new":
|
||||
return newEventForm(now, mode.calendarId);
|
||||
case "edit":
|
||||
return formFromEvent(mode.event);
|
||||
case "duplicate":
|
||||
return duplicateForm(mode.event);
|
||||
}
|
||||
}
|
||||
|
||||
/** Who made and last changed the event. Legacy rows can lack either name. */
|
||||
function Metadata({ event }: { event: CalendarEvent }) {
|
||||
const line = (label: string, who: string, when: Date | null) =>
|
||||
when || who ? (
|
||||
<p>
|
||||
{label}
|
||||
{who && ` von ${who}`}
|
||||
{when && ` · ${formatDateTime(when)}`}
|
||||
</p>
|
||||
) : null;
|
||||
return (
|
||||
<div className="mt-6 flex flex-col gap-0.5 border-t pt-4 text-xs text-muted-foreground">
|
||||
{line("Erstellt", event.createdBy, event.created)}
|
||||
{line("Zuletzt geändert", event.lastModifiedBy, event.lastModified)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDownUpIcon, RefreshCwIcon, SearchIcon, XIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import type { Range, Sort } from "@/lib/filter";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const RANGE_LABELS: Record<Range, string> = { future: "Kommend", past: "Vergangen", all: "Alle" };
|
||||
const SORT_LABELS: Record<Sort, string> = {
|
||||
start_asc: "Beginn – früheste zuerst",
|
||||
start_desc: "Beginn – späteste zuerst",
|
||||
created_asc: "Erstellt – älteste zuerst",
|
||||
created_desc: "Erstellt – neueste zuerst",
|
||||
};
|
||||
|
||||
/**
|
||||
* Search, sort, reload and the time range. The search box keeps its own text
|
||||
* and writes it to the URL after a short pause, so typing doesn't replace the
|
||||
* URL on every keystroke.
|
||||
*/
|
||||
export function EventsToolbar({
|
||||
query,
|
||||
range,
|
||||
sort,
|
||||
loading,
|
||||
onQueryChange,
|
||||
onRangeChange,
|
||||
onSortChange,
|
||||
onReload,
|
||||
}: {
|
||||
query: string;
|
||||
range: Range;
|
||||
sort: Sort;
|
||||
loading: boolean;
|
||||
onQueryChange: (query: string) => void;
|
||||
onRangeChange: (range: Range) => void;
|
||||
onSortChange: (sort: Sort) => void;
|
||||
onReload: () => void;
|
||||
}) {
|
||||
const [text, setText] = useState(query);
|
||||
// Take over a query that changed from outside (the empty state's "Suche
|
||||
// leeren", the back button) - but not our own write coming back through the
|
||||
// URL, which would drop whatever was typed while it travelled.
|
||||
const [seenQuery, setSeenQuery] = useState(query);
|
||||
const [emitted, setEmitted] = useState(query);
|
||||
if (query !== seenQuery) {
|
||||
setSeenQuery(query);
|
||||
if (query !== emitted) {
|
||||
setText(query);
|
||||
}
|
||||
}
|
||||
|
||||
const onQueryChangeRef = useRef(onQueryChange);
|
||||
useEffect(() => {
|
||||
onQueryChangeRef.current = onQueryChange;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (text === query) return;
|
||||
const timer = setTimeout(() => {
|
||||
setEmitted(text);
|
||||
onQueryChangeRef.current(text);
|
||||
}, 250);
|
||||
return () => clearTimeout(timer);
|
||||
}, [text, query]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<SearchIcon aria-hidden className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Titel oder Ort suchen"
|
||||
aria-label="Termine durchsuchen"
|
||||
className="pr-9 pl-9 [&::-webkit-search-cancel-button]:hidden"
|
||||
/>
|
||||
{text !== "" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setText("");
|
||||
onQueryChange("");
|
||||
}}
|
||||
aria-label="Suche leeren"
|
||||
className="absolute top-1/2 right-2 flex size-6 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="outline" size="icon" aria-label="Sortierung" />}>
|
||||
<ArrowDownUpIcon />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-60">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Sortieren nach</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup value={sort} onValueChange={(value) => onSortChange(value as Sort)}>
|
||||
{(Object.keys(SORT_LABELS) as Sort[]).map((key) => (
|
||||
// Base UI keeps a menu open after a radio pick by default; one choice is all there is here.
|
||||
<DropdownMenuRadioItem key={key} value={key} closeOnClick>
|
||||
{SORT_LABELS[key]}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button variant="outline" size="icon" onClick={onReload} aria-label="Neu laden" disabled={loading}>
|
||||
<RefreshCwIcon className={cn(loading && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ToggleGroup
|
||||
value={[range]}
|
||||
onValueChange={(value) => {
|
||||
// A segmented control: clicking the pressed item must not leave nothing selected.
|
||||
const next = value[0] as Range | undefined;
|
||||
if (next) onRangeChange(next);
|
||||
}}
|
||||
variant="outline"
|
||||
spacing={0}
|
||||
className="grid w-full grid-cols-3 sm:inline-grid sm:w-auto"
|
||||
aria-label="Zeitraum"
|
||||
>
|
||||
{(Object.keys(RANGE_LABELS) as Range[]).map((key) => (
|
||||
<ToggleGroupItem key={key} value={key} className="aria-pressed:bg-foreground/[0.06] aria-pressed:font-semibold">
|
||||
{RANGE_LABELS[key]}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { LoaderCircleIcon, SendIcon } from "lucide-react";
|
||||
import { CalendarBadge } from "@/components/admin/CalendarBadge";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { calendarById } from "@/lib/calendars";
|
||||
import { formatDay } from "@/lib/dates";
|
||||
import { describeWhen, occurrence } from "@/lib/event-display";
|
||||
import type { CalendarEvent } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* "Really publish?" - publishing puts the event into every subscriber's
|
||||
* calendar app, and for the public calendar onto nachklang.art, which is not
|
||||
* something to do by a stray tap on a phone.
|
||||
*
|
||||
* Stays open with a spinner until the API has answered, and stays open on a
|
||||
* failure (the page shows the error as a toast), so a retry is one tap.
|
||||
*/
|
||||
export function PublishDialog({
|
||||
event,
|
||||
now,
|
||||
publishing,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
event: CalendarEvent | null;
|
||||
now: Date;
|
||||
publishing: boolean;
|
||||
onConfirm: (event: CalendarEvent) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const calendar = event ? calendarById(event.calendarId) : undefined;
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={event !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !publishing) onCancel();
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
{event && (
|
||||
<>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>„{event.name}“ veröffentlichen?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{calendar?.slug === "public"
|
||||
? "Der Termin wird danach öffentlich angezeigt – auf nachklang.art und bei allen, die den Kalender abonniert haben."
|
||||
: `Der Termin erscheint danach bei allen, die den Kalender „${calendar?.label ?? event.calendarId}“ abonniert haben.`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex flex-col gap-1.5 rounded-lg bg-muted/60 px-3 py-2.5 text-sm">
|
||||
<span>
|
||||
{formatDay(occurrence(event, now).start)} · {describeWhen(event, now)}
|
||||
</span>
|
||||
<CalendarBadge calendarId={event.calendarId} className="self-start" />
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={publishing}>Abbrechen</AlertDialogCancel>
|
||||
<Button onClick={() => onConfirm(event)} disabled={publishing}>
|
||||
{publishing ? <LoaderCircleIcon className="animate-spin" /> : <SendIcon />}
|
||||
Veröffentlichen
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</>
|
||||
)}
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { CalendarPlusIcon, CheckIcon, CircleAlertIcon, CopyIcon, RefreshCwIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { goToAdminLogin } from "@/lib/admin-auth";
|
||||
import { api } from "@/lib/api";
|
||||
import { describeApiError, isSessionExpired } from "@/lib/api-errors";
|
||||
import { CALENDARS, type CalendarSlug } from "@/lib/calendars";
|
||||
import { toGoogleCalendar, toWebcal } from "@/lib/public-calendar";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Subscription = { calendar: CalendarSlug; icalUrl: string };
|
||||
type Load = { status: "idle" } | { status: "loading" } | { status: "ready"; subscriptions: Subscription[] } | { status: "error"; message: string };
|
||||
|
||||
/**
|
||||
* Subscription links for every calendar, for the editor's own phone or to
|
||||
* pass on to members. The URLs of the internal calendars carry their shared
|
||||
* password, which is why they come from the API (GET
|
||||
* /calendar/events/subscriptions, editors only) rather than being built here -
|
||||
* the browser has no other way to know the passwords.
|
||||
*
|
||||
* Fetched each time the dialog opens, so a rotated password shows up at once.
|
||||
*/
|
||||
export function SubscriptionsDialog() {
|
||||
const [load, setLoad] = useState<Load>({ status: "idle" });
|
||||
const [copied, setCopied] = useState<CalendarSlug | null>(null);
|
||||
|
||||
const fetchLinks = async () => {
|
||||
setLoad({ status: "loading" });
|
||||
try {
|
||||
setLoad({ status: "ready", subscriptions: await api.getSubscriptions() });
|
||||
} catch (e) {
|
||||
if (isSessionExpired(e)) {
|
||||
goToAdminLogin();
|
||||
return;
|
||||
}
|
||||
setLoad({ status: "error", message: describeApiError(e) });
|
||||
}
|
||||
};
|
||||
|
||||
const copy = async (subscription: Subscription) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(subscription.icalUrl);
|
||||
setCopied(subscription.calendar);
|
||||
setTimeout(() => setCopied((current) => (current === subscription.calendar ? null : current)), 2000);
|
||||
} catch {
|
||||
// No clipboard access: the URL is selectable in the dialog instead.
|
||||
setCopied(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(open) => {
|
||||
if (open) void fetchLinks();
|
||||
}}
|
||||
>
|
||||
<DialogTrigger render={<Button variant="ghost" size="sm" aria-label="Abonnieren" title="Kalender abonnieren" />}>
|
||||
<CalendarPlusIcon />
|
||||
<span className="hidden sm:inline">Abonnieren</span>
|
||||
</DialogTrigger>
|
||||
{/* minmax(0,1fr): an implicit grid column grows to its longest unbreakable content - here a
|
||||
full URL - so `truncate` on the URL never kicked in and the dialog's content overflowed. */}
|
||||
<DialogContent className="max-h-[90dvh] grid-cols-[minmax(0,1fr)] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Kalender abonnieren</DialogTitle>
|
||||
<DialogDescription>
|
||||
Mit diesen Links erscheinen die Termine automatisch in einer Kalender-App. Die Links der internen Kalender
|
||||
enthalten das jeweilige Kalender-Passwort – bitte nur an Mitglieder weitergeben.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{load.status === "loading" || load.status === "idle" ? (
|
||||
<div className="flex flex-col gap-2" aria-busy="true" aria-label="Links werden geladen">
|
||||
{CALENDARS.map((c) => (
|
||||
<Skeleton key={c.id} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : load.status === "error" ? (
|
||||
<div role="alert" className="flex flex-col items-start gap-3 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<p className="flex gap-2">
|
||||
<CircleAlertIcon aria-hidden className="mt-0.5 size-4 shrink-0" />
|
||||
Die Links konnten nicht geladen werden: {load.message}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => void fetchLinks()}>
|
||||
<RefreshCwIcon />
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y rounded-xl border">
|
||||
{CALENDARS.map((calendar) => {
|
||||
const subscription = load.subscriptions.find((s) => s.calendar === calendar.slug);
|
||||
return (
|
||||
<li key={calendar.id} className="flex flex-col gap-2 p-3">
|
||||
<span className="flex items-center gap-2 text-sm font-medium">
|
||||
<span aria-hidden className={cn("size-2.5 rounded-full", calendar.dotClass)} />
|
||||
{calendar.label}
|
||||
</span>
|
||||
{subscription ? (
|
||||
<>
|
||||
<code className="block truncate rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground select-all">
|
||||
{subscription.icalUrl}
|
||||
</code>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="xs" render={<a href={toWebcal(subscription.icalUrl)} />}>
|
||||
Apple Kalender
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
render={<a href={toGoogleCalendar(subscription.icalUrl)} target="_blank" rel="noopener noreferrer" />}
|
||||
>
|
||||
Google Kalender
|
||||
</Button>
|
||||
<Button variant="outline" size="xs" onClick={() => void copy(subscription)}>
|
||||
{copied === calendar.slug ? <CheckIcon /> : <CopyIcon />}
|
||||
{copied === calendar.slug ? "Kopiert" : "Link kopieren"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Für diesen Kalender ist auf dem Server kein Passwort eingerichtet, daher gibt es keinen Abo-Link.
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Chor und Geburtstage teilen sich ein Passwort: Wird es geändert, müssen beide neu abonniert werden.
|
||||
</p>
|
||||
<DialogFooter showCloseButton />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { goToAdminLogin } from "@/lib/admin-auth";
|
||||
import { api } from "@/lib/api";
|
||||
import { describeApiError, isSessionExpired } from "@/lib/api-errors";
|
||||
import { CALENDARS, calendarById } from "@/lib/calendars";
|
||||
import type { CalendarEvent } from "@/lib/types";
|
||||
|
||||
export type CalendarLoad = { status: "loading" } | { status: "ready" } | { status: "error"; message: string };
|
||||
|
||||
const initialLoads = (): Record<number, CalendarLoad> =>
|
||||
Object.fromEntries(CALENDARS.map((c) => [c.id, { status: "loading" } as CalendarLoad]));
|
||||
|
||||
/**
|
||||
* Every calendar's events, loaded in parallel - there is no endpoint that
|
||||
* returns them all at once (docs/api-wishlist.md).
|
||||
*
|
||||
* Each calendar loads and fails on its own: one calendar the API cannot serve
|
||||
* shows an error on its chip instead of blanking the whole list. The admin feed
|
||||
* returns exactly the calendar's own events (it ignores `includes_calendars`),
|
||||
* so a calendar's slice of the list can be replaced by calendarId.
|
||||
*
|
||||
* A 401 on any of them means the session is gone, and goes to the login page.
|
||||
*/
|
||||
export function useEvents() {
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [loads, setLoads] = useState<Record<number, CalendarLoad>>(initialLoads);
|
||||
// Per-calendar request counter, so a slow response cannot overwrite a newer one.
|
||||
const generation = useRef<Record<number, number>>({});
|
||||
|
||||
const fetchCalendar = useCallback(async (calendarId: number) => {
|
||||
const calendar = calendarById(calendarId);
|
||||
if (!calendar) return;
|
||||
const mine = (generation.current[calendarId] = (generation.current[calendarId] ?? 0) + 1);
|
||||
try {
|
||||
const loaded = await api.getEvents(calendar.slug);
|
||||
if (generation.current[calendarId] !== mine) return;
|
||||
setEvents((current) => [...current.filter((e) => e.calendarId !== calendarId), ...loaded]);
|
||||
setLoads((current) => ({ ...current, [calendarId]: { status: "ready" } }));
|
||||
} catch (e) {
|
||||
if (generation.current[calendarId] !== mine) return;
|
||||
if (isSessionExpired(e)) {
|
||||
goToAdminLogin();
|
||||
return;
|
||||
}
|
||||
setLoads((current) => ({ ...current, [calendarId]: { status: "error", message: describeApiError(e) } }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
for (const calendar of CALENDARS) {
|
||||
void fetchCalendar(calendar.id);
|
||||
}
|
||||
}, [fetchCalendar]);
|
||||
|
||||
/** Reload one calendar, or all. Shows the loading state; existing events stay visible meanwhile. */
|
||||
const reload = useCallback(
|
||||
(calendarId?: number) => {
|
||||
const ids = calendarId === undefined ? CALENDARS.map((c) => c.id) : [calendarId];
|
||||
setLoads((current) => ({ ...current, ...Object.fromEntries(ids.map((id) => [id, { status: "loading" }])) }));
|
||||
for (const id of ids) {
|
||||
void fetchCalendar(id);
|
||||
}
|
||||
},
|
||||
[fetchCalendar],
|
||||
);
|
||||
|
||||
/** Re-fetch without flagging the calendar as loading - after a write, to pick up server-side fields. */
|
||||
const refresh = useCallback((calendarId: number) => void fetchCalendar(calendarId), [fetchCalendar]);
|
||||
|
||||
/** Put a saved event into the list (new or changed), replacing any copy with the same id. */
|
||||
const upsertEvent = useCallback((event: CalendarEvent) => {
|
||||
setEvents((current) => [...current.filter((e) => e.eventId !== event.eventId), event]);
|
||||
}, []);
|
||||
|
||||
const removeEvent = useCallback((eventId: number) => {
|
||||
setEvents((current) => current.filter((e) => e.eventId !== eventId));
|
||||
}, []);
|
||||
|
||||
return { events, loads, reload, refresh, upsertEvent, removeEvent };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { parseListState, toQueryString, type ListState } from "@/lib/url-state";
|
||||
|
||||
/**
|
||||
* The list's filters, read from and written to the query string. `replace`
|
||||
* rather than `push`: flipping a filter should not fill the back button's
|
||||
* history with one entry per click.
|
||||
*
|
||||
* Needs a <Suspense> boundary above it - useSearchParams() in a prerendered
|
||||
* page opts the subtree into client-side rendering.
|
||||
*/
|
||||
export function useListState(): [ListState, (patch: Partial<ListState>) => void] {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const state = useMemo(() => parseListState(new URLSearchParams(searchParams.toString())), [searchParams]);
|
||||
|
||||
const update = useCallback(
|
||||
(patch: Partial<ListState>) => {
|
||||
const query = toQueryString({ ...state, ...patch });
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
|
||||
},
|
||||
[state, router, pathname],
|
||||
);
|
||||
|
||||
return [state, update];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
/** Whether a CSS media query matches, kept current. False during prerendering. */
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
return useSyncExternalStore(
|
||||
(onChange) => {
|
||||
const list = window.matchMedia(query);
|
||||
list.addEventListener("change", onChange);
|
||||
return () => list.removeEventListener("change", onChange);
|
||||
},
|
||||
() => window.matchMedia(query).matches,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, LinkIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The one interactive subscribe option: copies the iCal URL for apps that
|
||||
* want it pasted (Outlook and friends). Takes the shared card classes from the
|
||||
* page so the three options stay identical.
|
||||
*/
|
||||
export function CopyLinkCard({ url, className }: { url: string; className: string }) {
|
||||
const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setState("copied");
|
||||
} catch {
|
||||
// No clipboard access (older browser, insecure context): show the URL instead of failing silently.
|
||||
setState("failed");
|
||||
}
|
||||
setTimeout(() => setState("idle"), 2500);
|
||||
};
|
||||
|
||||
return (
|
||||
<button type="button" onClick={copy} className={className} aria-live="polite">
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-10 shrink-0 items-center justify-center rounded-lg transition-colors",
|
||||
state === "copied" ? "bg-emerald-50 text-emerald-600" : "bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
{state === "copied" ? <CheckIcon aria-hidden className="size-5" /> : <LinkIcon aria-hidden className="size-5" />}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="font-medium">{state === "copied" ? "Link kopiert" : "iCal-Link kopieren"}</span>
|
||||
{/* break-all only for the URL (it has no spaces to wrap at); on words it splits mid-word. */}
|
||||
<span className={cn("text-sm text-muted-foreground", state === "failed" && "break-all")}>
|
||||
{state === "failed" ? url : "Outlook und andere Apps"}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: AlertDialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Backdrop
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-sm data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AlertDialogPrimitive.Popup.Props & {
|
||||
size?: "default" | "sm"
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Popup
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-6 rounded-4xl bg-popover p-6 text-popover-foreground shadow-xl ring-1 ring-foreground/5 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-md dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn(
|
||||
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"mb-2 inline-flex size-16 items-center justify-center rounded-full bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-lg font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
return (
|
||||
<Button
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
}: AlertDialogPrimitive.Close.Props &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Close
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
render={<Button variant={variant} size={size} />}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-3xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-4xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-transparent dark:hover:bg-input/30",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
|
||||
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-sm data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-4xl bg-popover p-6 text-sm text-popover-foreground shadow-xl ring-1 ring-foreground/5 duration-100 outline-none sm:max-w-md dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-4 right-4 bg-secondary"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Schließen</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Schließen
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-48 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-3xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-3 py-2.5 text-xs text-muted-foreground data-inset:pl-9.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-2.5 rounded-2xl px-3 py-2 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-9.5 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 rounded-2xl px-3 py-2 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-9.5 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("w-auto min-w-36 rounded-3xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2.5 rounded-2xl py-2 pr-8 pl-3 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-9.5 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2.5 rounded-2xl py-2 pr-8 pl-3 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-9.5 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1.5 my-1.5 h-px bg-border/50", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-3 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
||||
horizontal:
|
||||
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
responsive:
|
||||
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-1 leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:bg-input/30 has-[>[data-slot=field]]:rounded-2xl has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-input/40 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 *:data-[slot=field]:p-4",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
||||
"last:mt-0 nth-last-2:-mt-1",
|
||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-sm font-normal text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-9 w-full min-w-0 rounded-3xl border border-transparent bg-input/50 px-3 py-1 text-base transition-[color,box-shadow,background-color] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1.5 p-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-3xl border border-transparent bg-input/50 px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow,background-color] outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-3xl bg-popover text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-3 py-2.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2.5 rounded-2xl py-2 pr-8 pl-3 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn(
|
||||
"pointer-events-none -mx-1.5 my-1.5 h-px bg-border",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Backdrop
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/30 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: SheetPrimitive.Popup.Props & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Popup
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col bg-popover bg-clip-padding text-sm text-popover-foreground shadow-xl transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-4 right-4 bg-secondary"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Schließen</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Popup>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: SheetPrimitive.Description.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-2xl bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||
|
||||
// Light only, like the rest of the app (globals.css) - the generated version
|
||||
// followed the OS theme via next-themes, which gave dark toasts on a light page.
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
return (
|
||||
<Sonner
|
||||
theme="light"
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
<CircleCheckIcon className="size-4" />
|
||||
),
|
||||
info: (
|
||||
<InfoIcon className="size-4" />
|
||||
),
|
||||
warning: (
|
||||
<TriangleAlertIcon className="size-4" />
|
||||
),
|
||||
error: (
|
||||
<OctagonXIcon className="size-4" />
|
||||
),
|
||||
loading: (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
),
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: SwitchPrimitive.Root.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border-2 transition-all outline-none group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-5 data-[size=default]:w-11 data-[size=sm]:h-4 data-[size=sm]:w-7 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary group-has-[:focus-visible]/field-label:data-checked:border-primary data-unchecked:border-transparent data-unchecked:bg-input/90 group-has-[:focus-visible]/field-label:data-unchecked:border-transparent data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="pointer-events-none block rounded-full bg-background shadow-sm ring-0 transition-transform not-dark:bg-clip-padding group-data-[size=default]/switch:h-4 group-data-[size=default]/switch:w-6 group-data-[size=sm]/switch:h-3 group-data-[size=sm]/switch:w-4 data-checked:translate-x-[calc(100%-8px)] dark:data-checked:bg-primary-foreground data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full resize-none rounded-2xl border border-transparent bg-input/50 px-3 py-3 text-base transition-[color,box-shadow,background-color] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
spacing: 2,
|
||||
orientation: "horizontal",
|
||||
})
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 2,
|
||||
orientation = "horizontal",
|
||||
children,
|
||||
...props
|
||||
}: ToggleGroupPrimitive.Props &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
data-orientation={orientation}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] data-[spacing=0]:data-[variant=outline]:rounded-3xl data-vertical:flex-col data-vertical:items-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider
|
||||
value={{ variant, size, spacing, orientation }}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<TogglePrimitive
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
className={cn(
|
||||
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-3 group-data-[spacing=0]/toggle-group:shadow-none focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-2.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-2.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-3xl group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-3xl group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-3xl group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-3xl data-[state=on]:bg-muted group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TogglePrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client"
|
||||
|
||||
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"group/toggle inline-flex items-center justify-center gap-1 rounded-3xl text-sm font-medium whitespace-nowrap transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border border-input bg-transparent hover:bg-muted",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-9 min-w-9 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
|
||||
sm: "h-8 min-w-8 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
lg: "h-10 min-w-10 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
Reference in new issue
Block a user