Files
Epicnabbo-Catalogus-Updated…/src/features/housekeeping/foundation/shell/favorites.tsx
T

242 lines
5.8 KiB
TypeScript

"use client";
import {
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useState } from "react";
import Link from "@/components/link";
import type {
HousekeepingRouteDefinition,
HousekeepingSurface,
} from "../contracts";
import type { HousekeepingPreferences } from "../preferences/schema";
import { toHousekeepingHref } from "../routing/href";
import {
type HousekeepingPreferenceSaver,
preferencesAfterSave,
} from "./widget-settings";
export function movePreferenceId(
ids: readonly string[],
activeId: string,
overId: string,
): string[] {
const from = ids.indexOf(activeId);
const to = ids.indexOf(overId);
if (from < 0 || to < 0 || from === to) return [...ids];
return arrayMove([...ids], from, to);
}
export function togglePinnedRoute(
preferences: HousekeepingPreferences,
routeId: string,
): HousekeepingPreferences {
const pinned = preferences.pinnedRouteIds.includes(routeId);
if (pinned) {
return {
...preferences,
pinnedRouteIds: preferences.pinnedRouteIds.filter((id) => id !== routeId),
shortcutOrder: preferences.shortcutOrder.filter((id) => id !== routeId),
};
}
return {
...preferences,
pinnedRouteIds: [...preferences.pinnedRouteIds, routeId],
shortcutOrder: preferences.shortcutOrder.includes(routeId)
? [...preferences.shortcutOrder]
: [...preferences.shortcutOrder, routeId],
};
}
function orderedPinnedRouteIds(preferences: HousekeepingPreferences): string[] {
const pinned = new Set(preferences.pinnedRouteIds);
const ordered = preferences.shortcutOrder.filter((id) => pinned.has(id));
for (const id of preferences.pinnedRouteIds) {
if (!ordered.includes(id)) ordered.push(id);
}
return ordered;
}
function SortableFavorite({
route,
surface,
unpin,
unpinLabel,
}: {
readonly route: HousekeepingRouteDefinition;
readonly surface: HousekeepingSurface;
readonly unpin: () => void;
readonly unpinLabel: string;
}) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({
id: route.id,
});
return (
<li
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className="flex items-center gap-2 rounded-md border border-[var(--admin-border)] p-3"
>
<button
type="button"
aria-label={route.labelKey}
className="cursor-grab text-[var(--admin-text-muted)]"
{...attributes}
{...listeners}
>
↕
</button>
<Link
className="min-w-0 flex-1"
href={toHousekeepingHref(route.href, surface)}
>
{route.labelKey}
</Link>
<button
type="button"
onClick={unpin}
className="rounded-md px-2 py-1 text-sm"
>
{unpinLabel}
</button>
</li>
);
}
export interface FavoritesLabels {
readonly title: string;
readonly pin: string;
readonly unpin: string;
readonly error: string;
}
export function Favorites({
preferences: initialPreferences,
routes,
surface,
savePreferences,
labels,
}: {
readonly preferences: HousekeepingPreferences;
readonly routes: readonly HousekeepingRouteDefinition[];
readonly surface: HousekeepingSurface;
readonly savePreferences: HousekeepingPreferenceSaver;
readonly labels: FavoritesLabels;
}) {
const [preferences, setPreferences] = useState(initialPreferences);
const [error, setError] = useState(false);
const [saving, setSaving] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const routeById = new Map(routes.map((route) => [route.id, route]));
const pinnedIds = orderedPinnedRouteIds(preferences).filter((id) =>
routeById.has(id),
);
const unpinned = routes.filter(
(route) => !preferences.pinnedRouteIds.includes(route.id),
);
const commit = async (proposed: HousekeepingPreferences) => {
setSaving(true);
setError(false);
try {
const outcome = preferencesAfterSave(
preferences,
proposed,
await savePreferences(proposed),
);
setPreferences(outcome.preferences);
setError(outcome.error !== undefined);
} catch {
setError(true);
} finally {
setSaving(false);
}
};
const onDragEnd = (event: DragEndEvent) => {
if (!event.over) return;
const shortcutOrder = movePreferenceId(
pinnedIds,
String(event.active.id),
String(event.over.id),
);
void commit({ ...preferences, shortcutOrder });
};
return (
<section
aria-labelledby="housekeeping-favorites-title"
className="space-y-3"
>
<h2 id="housekeeping-favorites-title" className="text-lg font-semibold">
{labels.title}
</h2>
{error ? (
<p role="alert" className="text-[var(--admin-danger)]">
{labels.error}
</p>
) : null}
<DndContext sensors={sensors} onDragEnd={onDragEnd}>
<SortableContext
items={pinnedIds}
strategy={verticalListSortingStrategy}
>
<ul className="space-y-2">
{pinnedIds.map((id) => {
const route = routeById.get(id);
return route ? (
<SortableFavorite
key={id}
route={route}
surface={surface}
unpinLabel={labels.unpin}
unpin={() => void commit(togglePinnedRoute(preferences, id))}
/>
) : null;
})}
</ul>
</SortableContext>
</DndContext>
<ul className="space-y-2">
{unpinned.map((route) => (
<li
key={route.id}
className="flex items-center justify-between gap-3 p-2"
>
<span>{route.labelKey}</span>
<button
type="button"
disabled={saving}
onClick={() =>
void commit(togglePinnedRoute(preferences, route.id))
}
className="rounded-md px-2 py-1 text-sm"
>
{labels.pin}
</button>
</li>
))}
</ul>
</section>
);
}