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

View File

@@ -1,13 +1,33 @@
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
rectSortingStrategy,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import {
ToolboxHeader,
type ToolboxHeaderApp,
} from '@add-ideas/toolbox-shell-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AppCard } from './components/AppCard';
import { HelpPanel } from './components/HelpPanel';
import { Logo } from './components/Logo';
import { PreferencesPanel } from './components/PreferencesPanel';
import { contextualLaunchUrl, loadCatalogue } from './catalogue';
import { cleanPreferences, sortAppIds } from './preferences';
import { cleanPreferences, reorderWithinIds, sortAppIds } from './preferences';
import type { LoadedCatalogue, ResolvedApp } from './types';
import { usePreferences } from './usePreferences';
const CATALOGUE_HREF = './toolbox.catalog.json';
const PORTAL_SOURCE = 'https://git.add-ideas.de/zemion/toolbox-portal';
type LoadState =
| { status: 'loading' }
@@ -17,9 +37,11 @@ type LoadState =
function appMatches(
app: ResolvedApp,
query: string,
category: string
category: string,
tag: string
): boolean {
if (category && !app.categories.includes(category)) return false;
if (tag && !app.tags.includes(tag)) return false;
if (!query) return true;
const haystack = [app.name, app.description, ...app.categories, ...app.tags]
.join(' ')
@@ -27,15 +49,84 @@ function appMatches(
return haystack.includes(query.toLocaleLowerCase());
}
interface AppSectionProps {
activeTag: string;
apps: ResolvedApp[];
catalogueUrl: string;
hiddenIds: readonly string[];
id: string;
onCategory: (category: string) => void;
onHide: (id: string) => void;
onPin: (id: string) => void;
onTag: (tag: string) => void;
pinned: boolean;
title: string;
}
function AppSection({
activeTag,
apps,
catalogueUrl,
hiddenIds,
id,
onCategory,
onHide,
onPin,
onTag,
pinned,
title,
}: AppSectionProps) {
if (apps.length === 0) return null;
return (
<section className="app-section" aria-labelledby={`${id}-title`}>
<header className="app-section__header">
<h2 id={`${id}-title`}>{title}</h2>
<span>{apps.length}</span>
</header>
<SortableContext
id={id}
items={apps.map((app) => app.id)}
strategy={rectSortingStrategy}
>
<div className="app-grid">
{apps.map((app) => (
<AppCard
key={app.id}
app={app}
catalogueUrl={catalogueUrl}
contextualUrl={contextualLaunchUrl}
pinned={pinned}
hidden={hiddenIds.includes(app.id)}
activeTag={activeTag}
onCategory={onCategory}
onPin={() => onPin(app.id)}
onHide={() => onHide(app.id)}
onTag={onTag}
/>
))}
</div>
</SortableContext>
</section>
);
}
export default function App() {
const [loadState, setLoadState] = useState<LoadState>({ status: 'loading' });
const [reloadKey, setReloadKey] = useState(0);
const [query, setQuery] = useState('');
const [category, setCategory] = useState('');
const [activeTag, setActiveTag] = useState('');
const [showHidden, setShowHidden] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [preferences, setPreferences, storageAvailable] = usePreferences();
const closeSettings = useCallback(() => setSettingsOpen(false), []);
const closeHelp = useCallback(() => setHelpOpen(false), []);
const dialogOpen = settingsOpen || helpOpen;
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
useEffect(() => {
const controller = new AbortController();
@@ -91,8 +182,26 @@ export default function App() {
}, [apps, preferences]);
const visibleApps = orderedApps.filter((app) => {
const hidden = preferences.hidden.includes(app.id);
return (showHidden || !hidden) && appMatches(app, query.trim(), category);
return (
(showHidden || !hidden) &&
appMatches(app, query.trim(), category, activeTag)
);
});
const pinnedApps = visibleApps.filter((app) =>
preferences.pinned.includes(app.id)
);
const regularApps = visibleApps.filter(
(app) => !preferences.pinned.includes(app.id)
);
const headerApps: ToolboxHeaderApp[] = apps.map((app) => ({
id: app.id,
name: app.name,
href: app.isExternal
? app.entryUrl
: contextualLaunchUrl(app.entryUrl, loaded?.catalogueUrl.href ?? ''),
newTab: !app.launchModes.includes('navigate'),
}));
function toggleListItem(field: 'pinned' | 'hidden', id: string) {
setPreferences((current) => ({
@@ -103,25 +212,26 @@ export default function App() {
}));
}
function moveApp(id: string, direction: -1 | 1) {
const pinned = preferences.pinned.includes(id);
const peers = visibleApps.filter(
(app) => preferences.pinned.includes(app.id) === pinned
function handleDragEnd(event: DragEndEvent) {
if (!event.over) return;
const activeId = String(event.active.id);
const overId = String(event.over.id);
const activePinned = preferences.pinned.includes(activeId);
if (activePinned !== preferences.pinned.includes(overId)) return;
const peers = (activePinned ? pinnedApps : regularApps).map(
(app) => app.id
);
const currentIndex = peers.findIndex((app) => app.id === id);
const target = peers[currentIndex + direction];
if (!target) return;
setPreferences((current) => {
const currentPosition = current.order.indexOf(id);
const targetPosition = current.order.indexOf(target.id);
if (currentPosition < 0 || targetPosition < 0) return current;
const order = [...current.order];
[order[currentPosition], order[targetPosition]] = [
order[targetPosition]!,
order[currentPosition]!,
];
return { ...current, order };
});
setPreferences((current) => ({
...current,
order: reorderWithinIds(current.order, activeId, overId, peers),
}));
}
function clearFilters() {
setQuery('');
setCategory('');
setActiveTag('');
if (preferences.hidden.length > 0) setShowHidden(true);
}
const title = loaded?.catalogue.name ?? 'add·ideas Toolbox';
@@ -131,49 +241,92 @@ export default function App() {
<>
<div
className="site-shell"
inert={settingsOpen}
aria-hidden={settingsOpen || undefined}
inert={dialogOpen}
aria-hidden={dialogOpen || undefined}
>
<a className="skip-link" href="#main-content">
Skip to tools
</a>
<header className="site-header">
<div className="header-inner">
<a
className="brand"
href={loaded ? loaded.homeUrl : './'}
aria-label={`${title} home`}
>
<Logo />
<span>
<strong>{brand}</strong>
<small>Toolbox</small>
</span>
</a>
<button
className="settings-button"
type="button"
onClick={() => setSettingsOpen(true)}
>
<span aria-hidden="true"></span> Personalize
</button>
</div>
</header>
<ToolboxHeader
className="portal-header"
title={title}
iconUrl="./favicon.svg"
theme={preferences.theme}
homeHref={loaded?.homeUrl ?? './'}
homeLabel={brand}
helpAction={{ onClick: () => setHelpOpen(true) }}
sourceHref={PORTAL_SOURCE}
sourceLabel="Toolbox source on Gitea"
onPersonalize={() => setSettingsOpen(true)}
apps={headerApps}
allAppsHref="#main-content"
/>
<main id="main-content">
<section className="hero" aria-labelledby="page-title">
<p className="eyebrow">
Privacy details up front · useful by default
</p>
<h1 id="page-title">
Your document tools,
<br />
<span>in one calm place.</span>
</h1>
<p className="hero-copy">
Focused browser utilities with a privacy disclosure for each tool.
Choose one and keep your work moving.
</p>
<div className="hero-intro">
<p className="eyebrow">
Privacy details up front · useful by default
</p>
<h2 id="page-title">
Your document tools,
<br />
<span>in one calm place.</span>
</h2>
<p className="hero-copy">
Focused browser utilities with a privacy disclosure for each
tool. Choose one and keep your work moving.
</p>
</div>
{loaded && (
<section className="tool-controls" aria-label="Find a tool">
<label className="search-field">
<span className="visually-hidden">Search tools</span>
<span aria-hidden="true"></span>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search tools"
type="search"
/>
</label>
<label className="category-field">
<span className="visually-hidden">Filter by category</span>
<select
value={category}
onChange={(event) => setCategory(event.target.value)}
>
<option value="">All categories</option>
{categories.map((item) => (
<option value={item} key={item}>
{item}
</option>
))}
</select>
</label>
{activeTag && (
<button
className="active-filter"
type="button"
onClick={() => setActiveTag('')}
>
#{activeTag} <span aria-hidden="true">×</span>
<span className="visually-hidden">Clear tag filter</span>
</button>
)}
{preferences.hidden.length > 0 && (
<label className="hidden-toggle">
<input
type="checkbox"
checked={showHidden}
onChange={(event) => setShowHidden(event.target.checked)}
/>
Show hidden ({preferences.hidden.length})
</label>
)}
</section>
)}
</section>
{loadState.status === 'loading' && (
@@ -205,43 +358,6 @@ export default function App() {
{loaded && (
<>
<section className="tool-controls" aria-label="Find a tool">
<label className="search-field">
<span className="visually-hidden">Search tools</span>
<span aria-hidden="true"></span>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search tools"
type="search"
/>
</label>
<label className="category-field">
<span className="visually-hidden">Filter by category</span>
<select
value={category}
onChange={(event) => setCategory(event.target.value)}
>
<option value="">All categories</option>
{categories.map((item) => (
<option value={item} key={item}>
{item}
</option>
))}
</select>
</label>
{preferences.hidden.length > 0 && (
<label className="hidden-toggle">
<input
type="checkbox"
checked={showHidden}
onChange={(event) => setShowHidden(event.target.checked)}
/>
Show hidden ({preferences.hidden.length})
</label>
)}
</section>
{apps.length === 0 && loaded.unavailable.length === 0 && (
<section className="state-card">
<span className="state-icon" aria-hidden="true">
@@ -264,43 +380,51 @@ export default function App() {
<p>
Try another search or category, or show your hidden tools.
</p>
<button
type="button"
onClick={() => {
setQuery('');
setCategory('');
setShowHidden(true);
}}
>
<button type="button" onClick={clearFilters}>
Clear filters
</button>
</section>
)}
{visibleApps.length > 0 && (
<section className="app-grid" aria-label="Available tools">
{visibleApps.map((app) => {
const pinned = preferences.pinned.includes(app.id);
const peers = visibleApps.filter(
(peer) => preferences.pinned.includes(peer.id) === pinned
);
return (
<AppCard
key={app.id}
app={app}
catalogueUrl={loaded.catalogueUrl.href}
contextualUrl={contextualLaunchUrl}
pinned={pinned}
hidden={preferences.hidden.includes(app.id)}
first={peers[0]?.id === app.id}
last={peers.at(-1)?.id === app.id}
onPin={() => toggleListItem('pinned', app.id)}
onHide={() => toggleListItem('hidden', app.id)}
onMove={(direction) => moveApp(app.id, direction)}
/>
);
})}
</section>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<div className="app-sections" aria-label="Available tools">
<AppSection
id="pinned-apps"
title="Pinned"
apps={pinnedApps}
pinned
activeTag={activeTag}
catalogueUrl={loaded.catalogueUrl.href}
hiddenIds={preferences.hidden}
onCategory={setCategory}
onPin={(id) => toggleListItem('pinned', id)}
onHide={(id) => toggleListItem('hidden', id)}
onTag={(tag) =>
setActiveTag((current) => (current === tag ? '' : tag))
}
/>
<AppSection
id="regular-apps"
title="Tools"
apps={regularApps}
pinned={false}
activeTag={activeTag}
catalogueUrl={loaded.catalogueUrl.href}
hiddenIds={preferences.hidden}
onCategory={setCategory}
onPin={(id) => toggleListItem('pinned', id)}
onHide={(id) => toggleListItem('hidden', id)}
onTag={(tag) =>
setActiveTag((current) => (current === tag ? '' : tag))
}
/>
</div>
</DndContext>
)}
{loaded.unavailable.length > 0 && (
@@ -332,9 +456,9 @@ export default function App() {
</span>
</p>
<span>
Portal v0.1.0 ·{' '}
Portal v0.2.0 ·{' '}
<a
href="https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.1.0"
href="https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.4.0"
target="_blank"
rel="noopener noreferrer"
>
@@ -351,6 +475,7 @@ export default function App() {
onChange={setPreferences}
onClose={closeSettings}
/>
<HelpPanel open={helpOpen} onClose={closeHelp} />
</>
);
}