feat(housekeeping): personalize command deck
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user