feat: release toolbox portal 0.1.0
This commit is contained in:
356
src/App.tsx
Normal file
356
src/App.tsx
Normal 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 pages—never 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user