Files
toolbox-portal/src/App.tsx

527 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, useRef, useState } from 'react';
import { AppCard } from './components/AppCard';
import { AppDetailsPanel } from './components/AppDetailsPanel';
import { HelpPanel } from './components/HelpPanel';
import { Logo } from './components/Logo';
import { PreferencesPanel } from './components/PreferencesPanel';
import { contextualLaunchUrl, loadCatalogue } from './catalogue';
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' }
| { status: 'error'; message: string }
| { status: 'ready'; value: LoadedCatalogue };
type PortalOverlay =
{ kind: 'help' } | { kind: 'details'; appId: string } | null;
function appMatches(
app: ResolvedApp,
query: 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(' ')
.toLocaleLowerCase();
return haystack.includes(query.toLocaleLowerCase());
}
interface AppSectionProps {
apps: ResolvedApp[];
catalogueUrl: string;
hiddenIds: readonly string[];
id: string;
onDetails: (id: string) => void;
onHide: (id: string) => void;
onPin: (id: string) => void;
pinned: boolean;
title: string;
}
function AppSection({
apps,
catalogueUrl,
hiddenIds,
id,
onDetails,
onHide,
onPin,
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)}
onDetails={() => onDetails(app.id)}
onPin={() => onPin(app.id)}
onHide={() => onHide(app.id)}
/>
))}
</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 [shownHiddenIds, setShownHiddenIds] = useState<
readonly string[] | null
>(null);
const [overlay, setOverlay] = useState<PortalOverlay>(null);
const hiddenToggle = useRef<HTMLInputElement>(null);
const [preferences, setPreferences, storageAvailable] = usePreferences();
const closeOverlay = useCallback(() => setOverlay(null), []);
const showHidden =
preferences.hidden.length > 0 &&
shownHiddenIds?.some((id) => preferences.hidden.includes(id)) === true;
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
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]);
const detailsApp =
overlay?.kind === 'details'
? apps.find((app) => app.id === overlay.appId)
: undefined;
const overlayOpen = overlay?.kind === 'help' || detailsApp !== undefined;
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, 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) {
const keepCardVisible = field === 'pinned' || showHidden;
if (field === 'hidden' && showHidden && !preferences.hidden.includes(id)) {
setShownHiddenIds((current) => [...new Set([...(current ?? []), id])]);
}
setPreferences((current) => ({
...current,
[field]: current[field].includes(id)
? current[field].filter((item) => item !== id)
: [...current[field], id],
}));
window.requestAnimationFrame(() => {
if (!keepCardVisible) {
hiddenToggle.current?.focus();
return;
}
const action = field === 'pinned' ? 'pin' : 'visibility';
[
...document.querySelectorAll<HTMLButtonElement>(
`[data-card-action="${action}"]`
),
]
.find((button) => button.dataset.appId === id)
?.focus();
});
}
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
);
setPreferences((current) => ({
...current,
order: reorderWithinIds(current.order, activeId, overId, peers),
}));
}
function clearFilters() {
setQuery('');
setCategory('');
setActiveTag('');
setShownHiddenIds(null);
}
return (
<>
<div
className="site-shell"
inert={overlayOpen}
aria-hidden={overlayOpen || undefined}
>
<a className="skip-link" href="#main-content">
Skip to tools
</a>
<ToolboxHeader
className="portal-header"
title="Toolbox"
subtitle="Local-first browser tools"
theme={preferences.theme}
homeHref={loaded?.homeUrl ?? './'}
homeLabel="add·ideas Toolbox"
brandIconUrl="./favicon.svg"
helpAction={{ onClick: () => setOverlay({ kind: 'help' }) }}
sourceHref={PORTAL_SOURCE}
sourceLabel="Toolbox source on Gitea"
personalizeContent={
<PreferencesPanel
preferences={preferences}
storageAvailable={storageAvailable}
onChange={setPreferences}
/>
}
apps={headerApps}
allAppsHref="#main-content"
/>
<main id="main-content">
<section className="hero" aria-labelledby="page-title">
<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>
</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>
{activeTag && (
<button
className="active-filter"
type="button"
onClick={() => setActiveTag('')}
>
#{activeTag} <span aria-hidden="true">×</span>
<span className="visually-hidden">Clear tag filter</span>
</button>
)}
<label
className={`hidden-toggle${
preferences.hidden.length === 0
? ' hidden-toggle--disabled'
: ''
}`}
>
<input
ref={hiddenToggle}
type="checkbox"
checked={showHidden}
disabled={preferences.hidden.length === 0}
onChange={(event) =>
setShownHiddenIds(
event.target.checked ? [...preferences.hidden] : null
)
}
/>
Show hidden ({preferences.hidden.length})
</label>
</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 && (
<>
{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={clearFilters}>
Clear filters
</button>
</section>
)}
{visibleApps.length > 0 && (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<div className="app-sections" aria-label="Available tools">
<AppSection
id="pinned-apps"
title="Pinned"
apps={pinnedApps}
pinned
catalogueUrl={loaded.catalogueUrl.href}
hiddenIds={preferences.hidden}
onDetails={(id) =>
setOverlay({ kind: 'details', appId: id })
}
onPin={(id) => toggleListItem('pinned', id)}
onHide={(id) => toggleListItem('hidden', id)}
/>
<AppSection
id="regular-apps"
title="Tools"
apps={regularApps}
pinned={false}
catalogueUrl={loaded.catalogueUrl.href}
hiddenIds={preferences.hidden}
onDetails={(id) =>
setOverlay({ kind: 'details', appId: id })
}
onPin={(id) => toggleListItem('pinned', id)}
onHide={(id) => toggleListItem('hidden', id)}
/>
</div>
</DndContext>
)}
{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.2.9 ·{' '}
<a
href="https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.9.1"
target="_blank"
rel="noopener noreferrer"
>
Source and license
</a>
</span>
</footer>
</div>
<HelpPanel open={overlay?.kind === 'help'} onClose={closeOverlay} />
{detailsApp && loaded ? (
<AppDetailsPanel
app={detailsApp}
catalogueUrl={loaded.catalogueUrl.href}
contextualUrl={contextualLaunchUrl}
pinned={preferences.pinned.includes(detailsApp.id)}
hidden={preferences.hidden.includes(detailsApp.id)}
activeTag={activeTag}
onCategory={(nextCategory) => {
setCategory(nextCategory);
closeOverlay();
}}
onTag={(tag) => {
setActiveTag((current) => (current === tag ? '' : tag));
closeOverlay();
}}
onClose={closeOverlay}
/>
) : null}
</>
);
}