feat: redesign Toolbox navigation and personalization

This commit is contained in:
2026-07-23 00:33:03 +02:00
parent 587aadb79b
commit 67ba5e3eca
25 changed files with 1075 additions and 427 deletions
+40 -66
View File
@@ -1,66 +1,31 @@
import type { ThemeMode } from './types';
export const PREFERENCES_KEY = '@add-ideas/toolbox-portal:v1:preferences';
export interface Preferences {
version: 1;
pinned: string[];
order: string[];
hidden: string[];
theme: ThemeMode;
}
import {
TOOLBOX_PREFERENCES_KEY,
defaultToolboxPreferences,
parseToolboxPreferences,
readToolboxPreferences,
writeToolboxPreferences,
type ToolboxPreferences,
} from '@add-ideas/toolbox-contract';
export const PREFERENCES_KEY = TOOLBOX_PREFERENCES_KEY;
export type Preferences = ToolboxPreferences;
export const defaultPreferences: Preferences = {
version: 1,
...defaultToolboxPreferences,
pinned: [],
order: [],
hidden: [],
theme: 'system',
};
function uniqueStrings(value: unknown, field: string): string[] {
if (
!Array.isArray(value) ||
value.some((item) => typeof item !== 'string' || item.length === 0)
)
throw new Error(`${field} must be an array of non-empty strings.`);
return [...new Set(value)];
}
export function parsePreferences(value: unknown): Preferences {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error('Preferences must be a JSON object.');
}
const candidate = value as Record<string, unknown>;
if (candidate.version !== 1)
throw new Error('Unsupported preferences version.');
const theme = candidate.theme;
if (!['light', 'dark', 'system'].includes(String(theme)))
throw new Error('Invalid theme preference.');
return {
version: 1,
pinned: uniqueStrings(candidate.pinned, 'pinned'),
order: uniqueStrings(candidate.order, 'order'),
hidden: uniqueStrings(candidate.hidden, 'hidden'),
theme: theme as ThemeMode,
};
}
export const parsePreferences = parseToolboxPreferences;
export function readPreferences(storage: Storage = localStorage): Preferences {
const value = storage.getItem(PREFERENCES_KEY);
if (!value) return { ...defaultPreferences };
try {
return parsePreferences(JSON.parse(value));
} catch {
return { ...defaultPreferences };
}
return readToolboxPreferences(storage);
}
export function writePreferences(
preferences: Preferences,
storage: Storage = localStorage
): void {
storage.setItem(PREFERENCES_KEY, JSON.stringify(preferences));
writeToolboxPreferences(preferences, storage);
}
export function cleanPreferences(
@@ -84,27 +49,36 @@ export function sortAppIds(
preferences: Preferences
): string[] {
const order = new Map(preferences.order.map((id, index) => [id, index]));
const pinned = new Set(preferences.pinned);
return [...appIds].sort((left, right) => {
const pinDifference = Number(pinned.has(right)) - Number(pinned.has(left));
if (pinDifference) return pinDifference;
return (
return [...appIds].sort(
(left, right) =>
(order.get(left) ?? Number.MAX_SAFE_INTEGER) -
(order.get(right) ?? Number.MAX_SAFE_INTEGER)
);
});
);
}
export function moveId(
export function reorderWithinIds(
order: string[],
id: string,
direction: -1 | 1
activeId: string,
overId: string,
peerIds: readonly string[]
): string[] {
const current = order.indexOf(id);
if (current < 0) return order;
const target = current + direction;
if (target < 0 || target >= order.length) return order;
const next = [...order];
[next[current], next[target]] = [next[target]!, next[current]!];
return next;
if (activeId === overId) return order;
const peers = new Set(peerIds);
if (!peers.has(activeId) || !peers.has(overId)) return order;
const orderedPeers = order.filter((id) => peers.has(id));
for (const id of peerIds) {
if (!orderedPeers.includes(id)) orderedPeers.push(id);
}
const from = orderedPeers.indexOf(activeId);
const to = orderedPeers.indexOf(overId);
if (from < 0 || to < 0) return order;
const [moved] = orderedPeers.splice(from, 1);
if (!moved) return order;
orderedPeers.splice(to, 0, moved);
let peerIndex = 0;
return order.map((id) =>
peers.has(id) ? (orderedPeers[peerIndex++] ?? id) : id
);
}