feat: redesign Toolbox navigation and personalization
This commit is contained in:
379
src/App.tsx
379
src/App.tsx
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user