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 (

{title}

{apps.length}
app.id)} strategy={rectSortingStrategy} >
{apps.map((app) => (
); } export default function App() { const [loadState, setLoadState] = useState({ 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(null); const hiddenToggle = useRef(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( `[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 ( <>
Skip to tools setOverlay({ kind: 'help' }) }} sourceHref={PORTAL_SOURCE} sourceLabel="Toolbox source on Gitea" personalizeContent={ } apps={headerApps} allAppsHref="#main-content" />

Privacy details up front · useful by default

Your document tools,
in one calm place.

Focused browser utilities with a privacy disclosure for each tool. Choose one and keep your work moving.

{loaded && (
{activeTag && ( )}
)} {loadState.status === 'loading' && (
)} {loadState.status === 'error' && (

Toolbox unavailable

{loadState.message}

)} {loaded && ( <> {apps.length === 0 && loaded.unavailable.length === 0 && (

No tools yet

The catalogue is valid but does not contain any enabled apps.

)} {apps.length > 0 && visibleApps.length === 0 && (

No matching tools

Try another search or category, or show your hidden tools.

)} {visibleApps.length > 0 && (
setOverlay({ kind: 'details', appId: id }) } onPin={(id) => toggleListItem('pinned', id)} onHide={(id) => toggleListItem('hidden', id)} /> setOverlay({ kind: 'details', appId: id }) } onPin={(id) => toggleListItem('pinned', id)} onHide={(id) => toggleListItem('hidden', id)} />
)} {loaded.unavailable.length > 0 && (
{loaded.unavailable.length} configured{' '} {loaded.unavailable.length === 1 ? 'tool is' : 'tools are'}{' '} unavailable
    {loaded.unavailable.map((app) => (
  • {app.name}: {app.reason}
  • ))}
)} )}

{' '} Privacy differs by tool. Review each disclosure before opening. Apps open as full pages—never embedded.

Portal v0.2.9 ·{' '} Source and license
{detailsApp && loaded ? (