feat: release toolbox portal 0.1.0

This commit is contained in:
2026-07-20 18:40:51 +02:00
commit 5d2e466ad6
55 changed files with 11127 additions and 0 deletions

356
src/App.tsx Normal file
View File

@@ -0,0 +1,356 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AppCard } from './components/AppCard';
import { Logo } from './components/Logo';
import { PreferencesPanel } from './components/PreferencesPanel';
import { contextualLaunchUrl, loadCatalogue } from './catalogue';
import { cleanPreferences, sortAppIds } from './preferences';
import type { LoadedCatalogue, ResolvedApp } from './types';
import { usePreferences } from './usePreferences';
const CATALOGUE_HREF = './toolbox.catalog.json';
type LoadState =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; value: LoadedCatalogue };
function appMatches(
app: ResolvedApp,
query: string,
category: string
): boolean {
if (category && !app.categories.includes(category)) return false;
if (!query) return true;
const haystack = [app.name, app.description, ...app.categories, ...app.tags]
.join(' ')
.toLocaleLowerCase();
return haystack.includes(query.toLocaleLowerCase());
}
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 [showHidden, setShowHidden] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [preferences, setPreferences, storageAvailable] = usePreferences();
const closeSettings = useCallback(() => setSettingsOpen(false), []);
useEffect(() => {
const controller = new AbortController();
loadCatalogue(CATALOGUE_HREF, controller.signal).then(
(value) => setLoadState({ status: 'ready', value }),
(error) => {
if (controller.signal.aborted) return;
setLoadState({
status: 'error',
message:
error instanceof Error
? error.message
: 'The catalogue could not be loaded.',
});
}
);
return () => controller.abort();
}, [reloadKey]);
const loaded = loadState.status === 'ready' ? loadState.value : undefined;
const apps = useMemo(() => loaded?.apps ?? [], [loaded]);
useEffect(() => {
if (!loaded) return;
const clean = cleanPreferences(
preferences,
loaded.apps.map((app) => app.id)
);
if (JSON.stringify(clean) !== JSON.stringify(preferences))
setPreferences(clean);
}, [loaded, preferences, setPreferences]);
useEffect(() => {
document.documentElement.dataset.theme = preferences.theme;
}, [preferences.theme]);
const categories = useMemo(
() =>
[...new Set(apps.flatMap((app) => app.categories))].sort((a, b) =>
a.localeCompare(b)
),
[apps]
);
const orderedApps = useMemo(() => {
const byId = new Map(apps.map((app) => [app.id, app]));
return sortAppIds(
apps.map((app) => app.id),
preferences
).flatMap((id) => {
const app = byId.get(id);
return app ? [app] : [];
});
}, [apps, preferences]);
const visibleApps = orderedApps.filter((app) => {
const hidden = preferences.hidden.includes(app.id);
return (showHidden || !hidden) && appMatches(app, query.trim(), category);
});
function toggleListItem(field: 'pinned' | 'hidden', id: string) {
setPreferences((current) => ({
...current,
[field]: current[field].includes(id)
? current[field].filter((item) => item !== id)
: [...current[field], id],
}));
}
function moveApp(id: string, direction: -1 | 1) {
const pinned = preferences.pinned.includes(id);
const peers = visibleApps.filter(
(app) => preferences.pinned.includes(app.id) === pinned
);
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 };
});
}
const title = loaded?.catalogue.name ?? 'add·ideas Toolbox';
const brand = loaded?.catalogue.theme.brand ?? 'add·ideas';
return (
<>
<div
className="site-shell"
inert={settingsOpen}
aria-hidden={settingsOpen || 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>
<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>
</section>
{loadState.status === 'loading' && (
<section className="state-card" aria-live="polite">
<div className="loader" aria-hidden="true" />
<h2>Loading your toolbox</h2>
<p>Reading the local app catalogue</p>
</section>
)}
{loadState.status === 'error' && (
<section className="state-card state-card--error" role="alert">
<span className="state-icon" aria-hidden="true">
!
</span>
<h2>Toolbox unavailable</h2>
<p>{loadState.message}</p>
<button
type="button"
onClick={() => {
setLoadState({ status: 'loading' });
setReloadKey((key) => key + 1);
}}
>
Try again
</button>
</section>
)}
{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">
</span>
<h2>No tools yet</h2>
<p>
The catalogue is valid but does not contain any enabled
apps.
</p>
</section>
)}
{apps.length > 0 && visibleApps.length === 0 && (
<section className="state-card">
<span className="state-icon" aria-hidden="true">
</span>
<h2>No matching tools</h2>
<p>
Try another search or category, or show your hidden tools.
</p>
<button
type="button"
onClick={() => {
setQuery('');
setCategory('');
setShowHidden(true);
}}
>
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>
)}
{loaded.unavailable.length > 0 && (
<details className="manifest-errors">
<summary>
{loaded.unavailable.length} configured{' '}
{loaded.unavailable.length === 1 ? 'tool is' : 'tools are'}{' '}
unavailable
</summary>
<ul>
{loaded.unavailable.map((app) => (
<li key={app.id}>
<strong>{app.name}</strong>: {app.reason}
</li>
))}
</ul>
</details>
)}
</>
)}
</main>
<footer className="site-footer">
<p>
<Logo size={22} />{' '}
<span>
Privacy differs by tool. Review each disclosure before opening.
Apps open as full pagesnever embedded.
</span>
</p>
<span>
Portal v0.1.0 ·{' '}
<a
href="https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.1.0"
target="_blank"
rel="noopener noreferrer"
>
Source and license
</a>
</span>
</footer>
</div>
<PreferencesPanel
open={settingsOpen}
preferences={preferences}
storageAvailable={storageAvailable}
onChange={setPreferences}
onClose={closeSettings}
/>
</>
);
}