242 lines
5.8 KiB
TypeScript
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>
|
|
);
|
|
}
|