527 lines
17 KiB
TypeScript
527 lines
17 KiB
TypeScript
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 pages—never 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}
|
||
</>
|
||
);
|
||
}
|