feat: release toolbox portal 0.1.0
This commit is contained in:
122
src/App.test.tsx
Normal file
122
src/App.test.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import App from './App';
|
||||
import { PREFERENCES_KEY } from './preferences';
|
||||
import { catalogue, catalogueFetch } from './test/fixtures';
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('portal UI', () => {
|
||||
it('renders app cards and persists accessible pin/hide controls', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'PDF Workbench' })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Local processing · no file uploads declared · no telemetry declared'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
const sourceLinks = screen.getAllByRole('link', {
|
||||
name: 'Source and license',
|
||||
});
|
||||
expect(sourceLinks).toHaveLength(2);
|
||||
expect(
|
||||
sourceLinks.find(
|
||||
(link) =>
|
||||
link.getAttribute('href') ===
|
||||
'https://git.add-ideas.de/zemion/pdf-tools'
|
||||
)
|
||||
).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
expect(
|
||||
sourceLinks.find((link) =>
|
||||
link.getAttribute('href')?.endsWith('/toolbox-portal/src/tag/v0.1.0')
|
||||
)
|
||||
).toBeDefined();
|
||||
const launch = screen.getByRole('link', { name: /open tool/i });
|
||||
expect(
|
||||
new URL(launch.getAttribute('href')!).searchParams.get('toolbox')
|
||||
).toContain('toolbox.catalog.json');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Pin PDF Workbench' }));
|
||||
await waitFor(() =>
|
||||
expect(localStorage.getItem(PREFERENCES_KEY)).toContain(
|
||||
'de.add-ideas.pdf-tools'
|
||||
)
|
||||
);
|
||||
expect(screen.getByText('Pinned')).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Hide PDF Workbench' })
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole('heading', { name: 'PDF Workbench' })
|
||||
).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /show hidden/i }));
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'PDF Workbench' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a recoverable catalogue error', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response('no', { status: 503 }))
|
||||
.mockImplementation(catalogueFetch());
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
render(<App />);
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'Toolbox unavailable' })
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'PDF Workbench' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a dedicated empty state for a valid catalogue', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
catalogueFetch({
|
||||
'/toolbox.catalog.json': Response.json({ ...catalogue, apps: [] }),
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<App />);
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'No tools yet' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('contains preference focus and restores it to Personalize', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<App />);
|
||||
const personalize = screen.getByRole('button', { name: 'Personalize' });
|
||||
|
||||
await user.click(personalize);
|
||||
const dialog = screen.getByRole('dialog', {
|
||||
name: 'Personalize your toolbox',
|
||||
});
|
||||
const close = screen.getByRole('button', { name: 'Close preferences' });
|
||||
expect(close).toHaveFocus();
|
||||
expect(container.querySelector('.site-shell')).toHaveAttribute('inert');
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(screen.getByText(/does not transmit them/i)).toHaveTextContent(
|
||||
/Trusted code in another Toolbox app on this site can access same-origin storage/i
|
||||
);
|
||||
|
||||
const reset = screen.getByRole('button', { name: 'Reset' });
|
||||
reset.focus();
|
||||
await user.tab();
|
||||
expect(close).toHaveFocus();
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(personalize).toHaveFocus();
|
||||
});
|
||||
});
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
102
src/catalogue.test.ts
Normal file
102
src/catalogue.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { contextualLaunchUrl, loadCatalogue } from './catalogue';
|
||||
import { catalogue, catalogueFetch } from './test/fixtures';
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('catalogue loading', () => {
|
||||
it('loads and resolves an internal app using the shared contract', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
|
||||
const loaded = await loadCatalogue('/toolbox.catalog.json');
|
||||
expect(loaded.catalogue.name).toBe('add·ideas Toolbox');
|
||||
expect(loaded.homeUrl).toBe('http://localhost:3000/');
|
||||
expect(loaded.apps).toHaveLength(1);
|
||||
expect(loaded.apps[0]).toMatchObject({
|
||||
id: 'de.add-ideas.pdf-tools',
|
||||
name: 'PDF Workbench',
|
||||
entryUrl: 'http://localhost:3000/apps/pdf/',
|
||||
sourceUrl: 'https://git.add-ideas.de/zemion/pdf-tools',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps valid apps available when another manifest fails', async () => {
|
||||
const twoApps = {
|
||||
...catalogue,
|
||||
apps: [
|
||||
...catalogue.apps,
|
||||
{ manifest: './apps/missing/toolbox-app.json', enabled: true },
|
||||
],
|
||||
};
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
catalogueFetch({
|
||||
'/toolbox.catalog.json': Response.json(twoApps),
|
||||
})
|
||||
)
|
||||
);
|
||||
const loaded = await loadCatalogue('/toolbox.catalog.json');
|
||||
expect(loaded.apps.map((app) => app.id)).toEqual([
|
||||
'de.add-ideas.pdf-tools',
|
||||
]);
|
||||
expect(loaded.unavailable).toHaveLength(1);
|
||||
expect(loaded.unavailable[0]?.reason).toMatch(/HTTP 404/);
|
||||
});
|
||||
|
||||
it('rejects cross-origin manifest references', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
catalogueFetch({
|
||||
'/toolbox.catalog.json': Response.json({
|
||||
...catalogue,
|
||||
apps: [
|
||||
{
|
||||
manifest: 'https://example.invalid/toolbox-app.json',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
)
|
||||
);
|
||||
await expect(loadCatalogue('/toolbox.catalog.json')).rejects.toThrow(
|
||||
/share the page origin/i
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a cross-origin catalogue home', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
catalogueFetch({
|
||||
'/toolbox.catalog.json': Response.json({
|
||||
...catalogue,
|
||||
home: 'https://example.invalid/',
|
||||
}),
|
||||
})
|
||||
)
|
||||
);
|
||||
await expect(loadCatalogue('/toolbox.catalog.json')).rejects.toThrow(
|
||||
/share the page origin/i
|
||||
);
|
||||
});
|
||||
|
||||
it('adds catalogue context only to same-origin launch links', () => {
|
||||
const contextual = new URL(
|
||||
contextualLaunchUrl(
|
||||
'http://localhost:3000/apps/pdf/',
|
||||
'http://localhost:3000/toolbox.catalog.json'
|
||||
)
|
||||
);
|
||||
expect(contextual.searchParams.get('toolbox')).toBe(
|
||||
'http://localhost:3000/toolbox.catalog.json'
|
||||
);
|
||||
expect(
|
||||
contextualLaunchUrl(
|
||||
'https://example.com/tool',
|
||||
'http://localhost:3000/toolbox.catalog.json'
|
||||
)
|
||||
).toBe('https://example.com/tool');
|
||||
});
|
||||
});
|
||||
143
src/catalogue.ts
Normal file
143
src/catalogue.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
contextualizeToolboxLink,
|
||||
fetchToolboxAppManifest,
|
||||
fetchToolboxCatalog,
|
||||
requireSameOrigin,
|
||||
resolveToolboxApp,
|
||||
resolveWebUrl,
|
||||
type ToolboxCatalogEntry,
|
||||
type ToolboxCatalogExternalEntry,
|
||||
type ToolboxCatalogManifestEntry,
|
||||
} from '@add-ideas/toolbox-contract';
|
||||
import type { LoadedCatalogue, ResolvedApp, UnavailableApp } from './types';
|
||||
|
||||
function isInternal(
|
||||
entry: ToolboxCatalogEntry
|
||||
): entry is ToolboxCatalogManifestEntry {
|
||||
return 'manifest' in entry;
|
||||
}
|
||||
|
||||
function fallbackId(entry: ToolboxCatalogExternalEntry, entryUrl: URL): string {
|
||||
const slug = entry.name
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
let hash = 2166136261;
|
||||
for (const character of `${entry.name}\0${entryUrl.href}`) {
|
||||
hash ^= character.codePointAt(0) ?? 0;
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return `external:${slug || 'tool'}:${(hash >>> 0).toString(36)}`;
|
||||
}
|
||||
|
||||
export async function loadCatalogue(
|
||||
catalogueHref: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<LoadedCatalogue> {
|
||||
const pageUrl = resolveWebUrl(document.baseURI);
|
||||
const catalogueUrl = requireSameOrigin(
|
||||
resolveWebUrl(catalogueHref, pageUrl),
|
||||
pageUrl,
|
||||
'Toolbox catalogue'
|
||||
);
|
||||
const catalogue = await fetchToolboxCatalog(catalogueUrl, {
|
||||
signal,
|
||||
expectedOrigin: pageUrl,
|
||||
});
|
||||
const enabled = catalogue.apps.filter((entry) => entry.enabled);
|
||||
const results = await Promise.all(
|
||||
enabled.map(
|
||||
async (
|
||||
entry
|
||||
): Promise<{ app?: ResolvedApp; unavailable?: UnavailableApp }> => {
|
||||
if (!isInternal(entry)) {
|
||||
const entryUrl = resolveWebUrl(entry.entry, catalogueUrl);
|
||||
return {
|
||||
app: {
|
||||
id: fallbackId(entry, entryUrl),
|
||||
name: entry.name,
|
||||
description: 'External tool',
|
||||
entryUrl: entryUrl.href,
|
||||
categories: [],
|
||||
tags: [],
|
||||
launchModes: [entry.launch],
|
||||
isExternal: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const manifestUrl = requireSameOrigin(
|
||||
resolveWebUrl(entry.manifest, catalogueUrl),
|
||||
pageUrl,
|
||||
'Application manifest'
|
||||
);
|
||||
try {
|
||||
const manifest = await fetchToolboxAppManifest(manifestUrl, {
|
||||
signal,
|
||||
expectedOrigin: pageUrl,
|
||||
});
|
||||
const resolved = resolveToolboxApp(manifest, manifestUrl, pageUrl);
|
||||
return {
|
||||
app: {
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
description: manifest.description,
|
||||
entryUrl: resolved.entryUrl.href,
|
||||
iconUrl: resolved.iconUrl.href,
|
||||
categories: manifest.categories,
|
||||
tags: manifest.tags,
|
||||
launchModes: manifest.integration.launchModes,
|
||||
isExternal: false,
|
||||
manifestUrl: resolved.manifestUrl.href,
|
||||
sourceUrl: manifest.source?.repository,
|
||||
requirements: manifest.requirements,
|
||||
privacy: manifest.privacy,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
return {
|
||||
unavailable: {
|
||||
id: `unavailable:${manifestUrl.href}`,
|
||||
name: manifestUrl.pathname.split('/').at(-2) ?? 'Unknown app',
|
||||
manifestUrl: manifestUrl.href,
|
||||
reason:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown manifest error.',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const apps = results.flatMap((result) => (result.app ? [result.app] : []));
|
||||
const ids = new Set<string>();
|
||||
for (const app of apps) {
|
||||
if (ids.has(app.id)) throw new Error(`Duplicate app id: ${app.id}.`);
|
||||
ids.add(app.id);
|
||||
}
|
||||
|
||||
return {
|
||||
catalogue,
|
||||
catalogueUrl,
|
||||
homeUrl: requireSameOrigin(
|
||||
resolveWebUrl(catalogue.home, catalogueUrl),
|
||||
pageUrl,
|
||||
'Toolbox home'
|
||||
).href,
|
||||
apps,
|
||||
unavailable: results.flatMap((result) =>
|
||||
result.unavailable ? [result.unavailable] : []
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function contextualLaunchUrl(
|
||||
entryHref: string,
|
||||
catalogueHref: string
|
||||
): string {
|
||||
return contextualizeToolboxLink(entryHref, catalogueHref);
|
||||
}
|
||||
172
src/components/AppCard.tsx
Normal file
172
src/components/AppCard.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import type { ResolvedApp } from '../types';
|
||||
|
||||
interface AppCardProps {
|
||||
app: ResolvedApp;
|
||||
catalogueUrl: string;
|
||||
hidden: boolean;
|
||||
pinned: boolean;
|
||||
first: boolean;
|
||||
last: boolean;
|
||||
onHide: () => void;
|
||||
onMove: (direction: -1 | 1) => void;
|
||||
onPin: () => void;
|
||||
contextualUrl: (entryUrl: string, catalogueUrl: string) => string;
|
||||
}
|
||||
|
||||
function RequirementSummary({ app }: { app: ResolvedApp }) {
|
||||
if (!app.requirements) return null;
|
||||
const requirements = [
|
||||
app.requirements.secureContext && 'Secure context',
|
||||
app.requirements.workers && 'Web workers',
|
||||
app.requirements.indexedDb && 'IndexedDB',
|
||||
app.requirements.crossOriginIsolated && 'Cross-origin isolation',
|
||||
].filter(Boolean);
|
||||
if (requirements.length === 0) return null;
|
||||
return <p className="requirements">Needs {requirements.join(' · ')}</p>;
|
||||
}
|
||||
|
||||
function PrivacySummary({ app }: { app: ResolvedApp }) {
|
||||
if (!app.privacy)
|
||||
return (
|
||||
<p className="privacy-summary">
|
||||
External destination · review its privacy terms
|
||||
</p>
|
||||
);
|
||||
if (app.privacy.label)
|
||||
return <p className="privacy-summary">{app.privacy.label}</p>;
|
||||
const processing = {
|
||||
local: 'Local processing',
|
||||
remote: 'Remote processing',
|
||||
mixed: 'Local and remote capabilities',
|
||||
}[app.privacy.processing];
|
||||
return (
|
||||
<p className="privacy-summary">
|
||||
{processing} ·{' '}
|
||||
{app.privacy.fileUploads
|
||||
? 'file/data transmission declared'
|
||||
: 'no file uploads declared'}{' '}
|
||||
· {app.privacy.telemetry ? 'telemetry declared' : 'no telemetry declared'}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppCard({
|
||||
app,
|
||||
catalogueUrl,
|
||||
hidden,
|
||||
pinned,
|
||||
first,
|
||||
last,
|
||||
onHide,
|
||||
onMove,
|
||||
onPin,
|
||||
contextualUrl,
|
||||
}: AppCardProps) {
|
||||
const launchUrl = app.isExternal
|
||||
? app.entryUrl
|
||||
: contextualUrl(app.entryUrl, catalogueUrl);
|
||||
const target = app.launchModes.includes('navigate') ? undefined : '_blank';
|
||||
return (
|
||||
<article
|
||||
className={`app-card${hidden ? ' app-card--hidden' : ''}`}
|
||||
data-testid={`app-card-${app.id}`}
|
||||
>
|
||||
<div className="app-card__top">
|
||||
<div className="app-card__icon" aria-hidden="true">
|
||||
{app.iconUrl ? (
|
||||
<img src={app.iconUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
<span>{app.name.slice(0, 1).toLocaleUpperCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="app-card__actions"
|
||||
aria-label={`Personalize ${app.name}`}
|
||||
>
|
||||
<button
|
||||
className={`icon-button${pinned ? ' is-active' : ''}`}
|
||||
type="button"
|
||||
aria-label={pinned ? `Unpin ${app.name}` : `Pin ${app.name}`}
|
||||
aria-pressed={pinned}
|
||||
title={pinned ? 'Unpin' : 'Pin'}
|
||||
onClick={onPin}
|
||||
>
|
||||
<span aria-hidden="true">⌁</span>
|
||||
</button>
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
aria-label={`Move ${app.name} earlier`}
|
||||
disabled={first}
|
||||
onClick={() => onMove(-1)}
|
||||
>
|
||||
<span aria-hidden="true">←</span>
|
||||
</button>
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
aria-label={`Move ${app.name} later`}
|
||||
disabled={last}
|
||||
onClick={() => onMove(1)}
|
||||
>
|
||||
<span aria-hidden="true">→</span>
|
||||
</button>
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
aria-label={hidden ? `Show ${app.name}` : `Hide ${app.name}`}
|
||||
onClick={onHide}
|
||||
>
|
||||
<span aria-hidden="true">{hidden ? '○' : '—'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="app-card__body">
|
||||
<div className="title-line">
|
||||
<h2>{app.name}</h2>
|
||||
{pinned && <span className="status-chip">Pinned</span>}
|
||||
{hidden && (
|
||||
<span className="status-chip status-chip--muted">Hidden</span>
|
||||
)}
|
||||
{app.isExternal && (
|
||||
<span className="status-chip status-chip--muted">External</span>
|
||||
)}
|
||||
</div>
|
||||
<p>{app.description}</p>
|
||||
<PrivacySummary app={app} />
|
||||
<RequirementSummary app={app} />
|
||||
<div className="tag-row" aria-label="Categories">
|
||||
{app.categories.slice(0, 3).map((category) => (
|
||||
<span key={category}>{category}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<footer className="app-card__footer">
|
||||
<div className="app-card__meta">
|
||||
<span className="version">
|
||||
{app.version ? `v${app.version}` : 'External service'}
|
||||
</span>
|
||||
{app.sourceUrl && (
|
||||
<a
|
||||
className="source-link"
|
||||
href={app.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Source and license
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
className="launch-button"
|
||||
href={launchUrl}
|
||||
target={target}
|
||||
rel={target ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{target ? 'Open in new tab' : 'Open tool'}{' '}
|
||||
<span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
36
src/components/Logo.tsx
Normal file
36
src/components/Logo.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
interface LogoProps {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function Logo({ size = 38 }: LogoProps) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="logo"
|
||||
height={size}
|
||||
viewBox="0 0 48 48"
|
||||
width={size}
|
||||
>
|
||||
<path
|
||||
d="M7 14.5 24 5l17 9.5v19L24 43 7 33.5z"
|
||||
fill="currentColor"
|
||||
opacity=".12"
|
||||
/>
|
||||
<path
|
||||
d="m8 15 16 9 16-9M24 24v18"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<path
|
||||
d="m16 10.5 16 9v9L24 33l-8-4.5z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
237
src/components/PreferencesPanel.tsx
Normal file
237
src/components/PreferencesPanel.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
|
||||
import {
|
||||
defaultPreferences,
|
||||
parsePreferences,
|
||||
type Preferences,
|
||||
} from '../preferences';
|
||||
import type { ThemeMode } from '../types';
|
||||
|
||||
interface PreferencesPanelProps {
|
||||
open: boolean;
|
||||
preferences: Preferences;
|
||||
storageAvailable: boolean;
|
||||
onChange: (preferences: Preferences) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PreferencesPanel({
|
||||
open,
|
||||
preferences,
|
||||
storageAvailable,
|
||||
onChange,
|
||||
onClose,
|
||||
}: PreferencesPanelProps) {
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const panel = useRef<HTMLElement>(null);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const returnFocus =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
const dialog = panel.current;
|
||||
const focusableElements = () =>
|
||||
dialog
|
||||
? [...dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)].filter(
|
||||
(element) => !element.hasAttribute('disabled')
|
||||
)
|
||||
: [];
|
||||
|
||||
focusableElements()[0]?.focus();
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
|
||||
const elements = focusableElements();
|
||||
if (elements.length === 0) {
|
||||
event.preventDefault();
|
||||
dialog?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const first = elements[0]!;
|
||||
const last = elements.at(-1)!;
|
||||
const activeElement = document.activeElement;
|
||||
if (
|
||||
event.shiftKey &&
|
||||
(activeElement === first || !dialog?.contains(activeElement))
|
||||
) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (
|
||||
!event.shiftKey &&
|
||||
(activeElement === last || !dialog?.contains(activeElement))
|
||||
) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
returnFocus?.focus();
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function setTheme(theme: ThemeMode) {
|
||||
onChange({ ...preferences, theme });
|
||||
}
|
||||
|
||||
function exportPreferences() {
|
||||
const blob = new Blob([`${JSON.stringify(preferences, null, 2)}\n`], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = 'toolbox-preferences.json';
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setMessage('Preferences exported.');
|
||||
}
|
||||
|
||||
async function importPreferences(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
try {
|
||||
const imported = parsePreferences(JSON.parse(await file.text()));
|
||||
onChange(imported);
|
||||
setMessage('Preferences imported.');
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
error instanceof Error
|
||||
? `Import failed: ${error.message}`
|
||||
: 'Import failed.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="dialog-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => event.target === event.currentTarget && onClose()}
|
||||
>
|
||||
<section
|
||||
ref={panel}
|
||||
className="preferences-panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="preferences-title"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<p className="eyebrow">On this device</p>
|
||||
<h2 id="preferences-title">Personalize your toolbox</h2>
|
||||
</div>
|
||||
<button
|
||||
className="close-button"
|
||||
type="button"
|
||||
aria-label="Close preferences"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{!storageAvailable && (
|
||||
<p className="inline-warning" role="status">
|
||||
Browser storage is unavailable. Changes will last only until this
|
||||
page closes.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<fieldset className="theme-choice">
|
||||
<legend>Appearance</legend>
|
||||
<div>
|
||||
{(['system', 'light', 'dark'] as const).map((theme) => (
|
||||
<button
|
||||
key={theme}
|
||||
type="button"
|
||||
aria-pressed={preferences.theme === theme}
|
||||
className={preferences.theme === theme ? 'is-selected' : ''}
|
||||
onClick={() => setTheme(theme)}
|
||||
>
|
||||
{theme.slice(0, 1).toLocaleUpperCase() + theme.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="preference-summary">
|
||||
<div>
|
||||
<strong>{preferences.pinned.length}</strong>
|
||||
<span>Pinned</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{preferences.hidden.length}</strong>
|
||||
<span>Hidden</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{preferences.order.length}</strong>
|
||||
<span>Ordered</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="preferences-copy">
|
||||
The portal stores pins, visibility, order, and appearance in this
|
||||
browser and does not transmit them. Trusted code in another Toolbox
|
||||
app on this site can access same-origin storage. Export a JSON backup
|
||||
to move them to another device.
|
||||
</p>
|
||||
|
||||
<div className="preferences-actions">
|
||||
<button type="button" onClick={exportPreferences}>
|
||||
Export JSON
|
||||
</button>
|
||||
<button type="button" onClick={() => fileInput.current?.click()}>
|
||||
Import JSON
|
||||
</button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
className="visually-hidden"
|
||||
type="file"
|
||||
tabIndex={-1}
|
||||
accept="application/json,.json"
|
||||
onChange={importPreferences}
|
||||
/>
|
||||
<button
|
||||
className="danger-button"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange({ ...defaultPreferences });
|
||||
setMessage('Preferences reset.');
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<p className="action-message" role="status" aria-live="polite">
|
||||
{message}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled]):not([type="hidden"]):not([tabindex="-1"])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
78
src/preferences.test.ts
Normal file
78
src/preferences.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
PREFERENCES_KEY,
|
||||
cleanPreferences,
|
||||
defaultPreferences,
|
||||
moveId,
|
||||
parsePreferences,
|
||||
readPreferences,
|
||||
sortAppIds,
|
||||
writePreferences,
|
||||
} from './preferences';
|
||||
|
||||
describe('preferences', () => {
|
||||
it('round-trips namespaced settings', () => {
|
||||
const preferences = {
|
||||
...defaultPreferences,
|
||||
pinned: ['app.two'],
|
||||
order: ['app.two', 'app.one'],
|
||||
hidden: ['app.one'],
|
||||
theme: 'dark' as const,
|
||||
};
|
||||
writePreferences(preferences);
|
||||
expect(localStorage.getItem(PREFERENCES_KEY)).toContain('app.two');
|
||||
expect(readPreferences()).toEqual(preferences);
|
||||
});
|
||||
|
||||
it('falls back safely for corrupt stored JSON', () => {
|
||||
localStorage.setItem(PREFERENCES_KEY, '{nope');
|
||||
expect(readPreferences()).toEqual(defaultPreferences);
|
||||
});
|
||||
|
||||
it('validates imported JSON and removes duplicate ids', () => {
|
||||
expect(
|
||||
parsePreferences({
|
||||
version: 1,
|
||||
pinned: ['app.one', 'app.one'],
|
||||
order: [],
|
||||
hidden: [],
|
||||
theme: 'system',
|
||||
}).pinned
|
||||
).toEqual(['app.one']);
|
||||
expect(() => parsePreferences({ version: 2 })).toThrow(/version/i);
|
||||
expect(() =>
|
||||
parsePreferences({
|
||||
version: 1,
|
||||
pinned: 'app.one',
|
||||
order: [],
|
||||
hidden: [],
|
||||
theme: 'system',
|
||||
})
|
||||
).toThrow(/pinned/i);
|
||||
});
|
||||
|
||||
it('cleans stale ids, pins first, and supports personal ordering', () => {
|
||||
const cleaned = cleanPreferences(
|
||||
{
|
||||
version: 1,
|
||||
pinned: ['app.three', 'missing'],
|
||||
hidden: ['missing'],
|
||||
order: ['app.two', 'missing'],
|
||||
theme: 'system',
|
||||
},
|
||||
['app.one', 'app.two', 'app.three']
|
||||
);
|
||||
expect(cleaned.order).toEqual(['app.two', 'app.one', 'app.three']);
|
||||
expect(cleaned.hidden).toEqual([]);
|
||||
expect(sortAppIds(['app.one', 'app.two', 'app.three'], cleaned)).toEqual([
|
||||
'app.three',
|
||||
'app.two',
|
||||
'app.one',
|
||||
]);
|
||||
expect(moveId(cleaned.order, 'app.one', -1)).toEqual([
|
||||
'app.one',
|
||||
'app.two',
|
||||
'app.three',
|
||||
]);
|
||||
});
|
||||
});
|
||||
110
src/preferences.ts
Normal file
110
src/preferences.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { ThemeMode } from './types';
|
||||
|
||||
export const PREFERENCES_KEY = '@add-ideas/toolbox-portal:v1:preferences';
|
||||
|
||||
export interface Preferences {
|
||||
version: 1;
|
||||
pinned: string[];
|
||||
order: string[];
|
||||
hidden: string[];
|
||||
theme: ThemeMode;
|
||||
}
|
||||
|
||||
export const defaultPreferences: Preferences = {
|
||||
version: 1,
|
||||
pinned: [],
|
||||
order: [],
|
||||
hidden: [],
|
||||
theme: 'system',
|
||||
};
|
||||
|
||||
function uniqueStrings(value: unknown, field: string): string[] {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.some((item) => typeof item !== 'string' || item.length === 0)
|
||||
)
|
||||
throw new Error(`${field} must be an array of non-empty strings.`);
|
||||
return [...new Set(value)];
|
||||
}
|
||||
|
||||
export function parsePreferences(value: unknown): Preferences {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error('Preferences must be a JSON object.');
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (candidate.version !== 1)
|
||||
throw new Error('Unsupported preferences version.');
|
||||
const theme = candidate.theme;
|
||||
if (!['light', 'dark', 'system'].includes(String(theme)))
|
||||
throw new Error('Invalid theme preference.');
|
||||
return {
|
||||
version: 1,
|
||||
pinned: uniqueStrings(candidate.pinned, 'pinned'),
|
||||
order: uniqueStrings(candidate.order, 'order'),
|
||||
hidden: uniqueStrings(candidate.hidden, 'hidden'),
|
||||
theme: theme as ThemeMode,
|
||||
};
|
||||
}
|
||||
|
||||
export function readPreferences(storage: Storage = localStorage): Preferences {
|
||||
const value = storage.getItem(PREFERENCES_KEY);
|
||||
if (!value) return { ...defaultPreferences };
|
||||
try {
|
||||
return parsePreferences(JSON.parse(value));
|
||||
} catch {
|
||||
return { ...defaultPreferences };
|
||||
}
|
||||
}
|
||||
|
||||
export function writePreferences(
|
||||
preferences: Preferences,
|
||||
storage: Storage = localStorage
|
||||
): void {
|
||||
storage.setItem(PREFERENCES_KEY, JSON.stringify(preferences));
|
||||
}
|
||||
|
||||
export function cleanPreferences(
|
||||
preferences: Preferences,
|
||||
appIds: string[]
|
||||
): Preferences {
|
||||
const known = new Set(appIds);
|
||||
return {
|
||||
...preferences,
|
||||
pinned: preferences.pinned.filter((id) => known.has(id)),
|
||||
hidden: preferences.hidden.filter((id) => known.has(id)),
|
||||
order: [
|
||||
...preferences.order.filter((id) => known.has(id)),
|
||||
...appIds.filter((id) => !preferences.order.includes(id)),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function sortAppIds(
|
||||
appIds: string[],
|
||||
preferences: Preferences
|
||||
): string[] {
|
||||
const order = new Map(preferences.order.map((id, index) => [id, index]));
|
||||
const pinned = new Set(preferences.pinned);
|
||||
return [...appIds].sort((left, right) => {
|
||||
const pinDifference = Number(pinned.has(right)) - Number(pinned.has(left));
|
||||
if (pinDifference) return pinDifference;
|
||||
return (
|
||||
(order.get(left) ?? Number.MAX_SAFE_INTEGER) -
|
||||
(order.get(right) ?? Number.MAX_SAFE_INTEGER)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function moveId(
|
||||
order: string[],
|
||||
id: string,
|
||||
direction: -1 | 1
|
||||
): string[] {
|
||||
const current = order.indexOf(id);
|
||||
if (current < 0) return order;
|
||||
const target = current + direction;
|
||||
if (target < 0 || target >= order.length) return order;
|
||||
const next = [...order];
|
||||
[next[current], next[target]] = [next[target]!, next[current]!];
|
||||
return next;
|
||||
}
|
||||
814
src/styles.css
Normal file
814
src/styles.css
Normal file
@@ -0,0 +1,814 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
font-family: Inter, Avenir, 'Segoe UI', ui-sans-serif, system-ui, sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
--bg: #f6f7fb;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #eff2f8;
|
||||
--text: #17203b;
|
||||
--muted: #667085;
|
||||
--line: #dfe3ec;
|
||||
--brand: #28366d;
|
||||
--brand-hover: #1c2858;
|
||||
--brand-soft: #e9edff;
|
||||
--accent: #78d8ca;
|
||||
--danger: #a63243;
|
||||
--shadow: 0 16px 44px rgba(24, 34, 68, 0.08);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
--bg: #0f1422;
|
||||
--surface: #171d2d;
|
||||
--surface-soft: #20283a;
|
||||
--text: #edf1fb;
|
||||
--muted: #a9b3ca;
|
||||
--line: #303a50;
|
||||
--brand: #a9b7ff;
|
||||
--brand-hover: #c3ccff;
|
||||
--brand-soft: #252f55;
|
||||
--accent: #66cdbd;
|
||||
--danger: #ff9dac;
|
||||
--shadow: 0 18px 48px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root[data-theme='system'] {
|
||||
color-scheme: dark;
|
||||
--bg: #0f1422;
|
||||
--surface: #171d2d;
|
||||
--surface-soft: #20283a;
|
||||
--text: #edf1fb;
|
||||
--muted: #a9b3ca;
|
||||
--line: #303a50;
|
||||
--brand: #a9b7ff;
|
||||
--brand-hover: #c3ccff;
|
||||
--brand-soft: #252f55;
|
||||
--accent: #66cdbd;
|
||||
--danger: #ff9dac;
|
||||
--shadow: 0 18px 48px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--bg);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 8% 4%,
|
||||
color-mix(in srgb, var(--accent) 13%, transparent) 0,
|
||||
transparent 28rem
|
||||
),
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
a,
|
||||
input,
|
||||
select {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
summary:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--accent) 70%, var(--brand));
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.site-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
top: 0.75rem;
|
||||
left: 0.75rem;
|
||||
padding: 0.7rem 1rem;
|
||||
border-radius: 0.55rem;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
transform: translateY(-160%);
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
|
||||
background: color-mix(in srgb, var(--bg) 88%, transparent);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.header-inner,
|
||||
main,
|
||||
.site-footer {
|
||||
width: min(1160px, calc(100% - 2.5rem));
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
min-height: 74px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand .logo {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.brand > span {
|
||||
display: grid;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-family: Inter, Avenir, 'Segoe UI', ui-sans-serif, sans-serif;
|
||||
font-size: 1.03rem;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.brand small {
|
||||
margin-top: 0.22rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.17em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.settings-button,
|
||||
.state-card button,
|
||||
.preferences-actions button {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.65rem;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-button {
|
||||
padding: 0.62rem 0.82rem;
|
||||
}
|
||||
|
||||
.settings-button:hover,
|
||||
.state-card button:hover,
|
||||
.preferences-actions button:hover {
|
||||
border-color: var(--brand);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hero {
|
||||
max-width: 750px;
|
||||
padding: clamp(4.5rem, 9vw, 8.5rem) 0 clamp(3rem, 6vw, 5.2rem);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--brand);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-family: Inter, Avenir, 'Segoe UI', ui-sans-serif, sans-serif;
|
||||
font-size: clamp(2.65rem, 7vw, 5.65rem);
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.065em;
|
||||
}
|
||||
|
||||
.hero h1 span {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
max-width: 575px;
|
||||
margin: 1.75rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: clamp(1rem, 2vw, 1.18rem);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.tool-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.search-field,
|
||||
.category-field {
|
||||
display: flex;
|
||||
min-height: 46px;
|
||||
align-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.search-field {
|
||||
width: min(360px, 100%);
|
||||
padding: 0 0.85rem;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.search-field > span {
|
||||
color: var(--muted);
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.search-field input,
|
||||
.category-field select {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.search-field input::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.category-field {
|
||||
min-width: 175px;
|
||||
padding: 0 0.65rem;
|
||||
}
|
||||
|
||||
.category-field select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hidden-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin-left: auto;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.hidden-toggle input {
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
accent-color: var(--brand);
|
||||
}
|
||||
|
||||
.app-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 330px), 1fr));
|
||||
gap: 1.1rem;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-card {
|
||||
min-height: 340px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1.2rem;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
transform 180ms ease,
|
||||
box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.app-card:hover {
|
||||
border-color: color-mix(in srgb, var(--brand) 35%, var(--line));
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 20px 54px rgba(24, 34, 68, 0.13);
|
||||
}
|
||||
|
||||
.app-card--hidden {
|
||||
border-style: dashed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.app-card__top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 1.35rem 1.35rem 0;
|
||||
}
|
||||
|
||||
.app-card__icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: 0.95rem;
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand);
|
||||
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
|
||||
font-size: 1.45rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.app-card__icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.app-card__actions {
|
||||
display: flex;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.icon-button,
|
||||
.close-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-button:hover:not(:disabled),
|
||||
.icon-button.is-active {
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.icon-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.app-card__body {
|
||||
flex: 1;
|
||||
padding: 1.25rem 1.35rem 1rem;
|
||||
}
|
||||
|
||||
.title-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.title-line h2 {
|
||||
margin: 0;
|
||||
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
|
||||
font-size: 1.3rem;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.status-chip,
|
||||
.tag-row span {
|
||||
border-radius: 999px;
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
padding: 0.22rem 0.48rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-chip--muted {
|
||||
background: var(--surface-soft);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.app-card__body > p {
|
||||
margin: 0.75rem 0 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app-card__body .requirements {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.app-card__body .privacy-summary {
|
||||
color: var(--text);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.tag-row span {
|
||||
padding: 0.28rem 0.52rem;
|
||||
background: var(--surface-soft);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.app-card__footer {
|
||||
min-height: 68px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 1.35rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.version {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.app-card__meta {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.source-link,
|
||||
.site-footer a {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.launch-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.62rem 0.82rem;
|
||||
border-radius: 0.65rem;
|
||||
background: var(--brand);
|
||||
color: var(--surface);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .launch-button {
|
||||
color: #11182a;
|
||||
}
|
||||
|
||||
.launch-button:hover {
|
||||
background: var(--brand-hover);
|
||||
}
|
||||
|
||||
.state-card {
|
||||
max-width: 600px;
|
||||
margin: 0 auto 5rem;
|
||||
padding: 3rem 2rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1.2rem;
|
||||
background: var(--surface);
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.state-card h2 {
|
||||
margin: 0.8rem 0 0.35rem;
|
||||
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.state-card p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.state-card button {
|
||||
margin-top: 1.25rem;
|
||||
padding: 0.7rem 1rem;
|
||||
}
|
||||
|
||||
.state-icon {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.state-card--error .state-icon {
|
||||
background: color-mix(in srgb, var(--danger) 12%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.loader {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
margin: 0 auto;
|
||||
border: 3px solid var(--line);
|
||||
border-top-color: var(--brand);
|
||||
border-radius: 50%;
|
||||
animation: spin 700ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.manifest-errors {
|
||||
margin: 1.5rem 0 4rem;
|
||||
padding: 1rem 1.2rem;
|
||||
border: 1px solid color-mix(in srgb, var(--danger) 30%, var(--line));
|
||||
border-radius: 0.8rem;
|
||||
background: color-mix(in srgb, var(--danger) 5%, var(--surface));
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.manifest-errors summary {
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.manifest-errors li + li {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
min-height: 110px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-top: 5rem;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.site-footer p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dialog-backdrop {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background: rgba(5, 9, 22, 0.56);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.preferences-panel {
|
||||
width: min(560px, 100%);
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow: auto;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1.2rem;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.preferences-panel > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.preferences-panel h2 {
|
||||
margin: 0;
|
||||
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.preferences-panel .eyebrow {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
flex: 0 0 auto;
|
||||
background: var(--surface-soft);
|
||||
color: var(--text);
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.theme-choice {
|
||||
margin: 1.5rem 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.theme-choice legend {
|
||||
margin-bottom: 0.6rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.theme-choice > div {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.45rem;
|
||||
padding: 0.35rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.theme-choice button {
|
||||
padding: 0.62rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.theme-choice button.is-selected {
|
||||
background: var(--surface);
|
||||
color: var(--brand);
|
||||
box-shadow: 0 2px 8px rgba(20, 30, 60, 0.09);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.preference-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.preference-summary > div {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
padding: 0.9rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.7rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preference-summary strong {
|
||||
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.preference-summary span,
|
||||
.preferences-copy,
|
||||
.action-message {
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.preferences-copy {
|
||||
margin: 1.1rem 0;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.preferences-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.preferences-actions button {
|
||||
padding: 0.62rem 0.78rem;
|
||||
}
|
||||
|
||||
.preferences-actions .danger-button {
|
||||
margin-left: auto;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.action-message {
|
||||
min-height: 1.2em;
|
||||
margin: 0.8rem 0 0;
|
||||
}
|
||||
|
||||
.inline-warning {
|
||||
padding: 0.7rem 0.8rem;
|
||||
border-radius: 0.6rem;
|
||||
background: color-mix(in srgb, var(--danger) 8%, var(--surface));
|
||||
color: var(--danger);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.header-inner,
|
||||
main,
|
||||
.site-footer {
|
||||
width: min(100% - 1.4rem, 1160px);
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
min-height: 66px;
|
||||
}
|
||||
|
||||
.settings-button {
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.settings-button span {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding-top: 3.8rem;
|
||||
}
|
||||
|
||||
.tool-controls {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-field,
|
||||
.category-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hidden-toggle {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.app-card {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
min-height: 130px;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
61
src/test/fixtures.ts
Normal file
61
src/test/fixtures.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type {
|
||||
ToolboxAppManifest,
|
||||
ToolboxCatalog,
|
||||
} from '@add-ideas/toolbox-contract';
|
||||
|
||||
export const pdfManifest: ToolboxAppManifest = {
|
||||
schemaVersion: 1,
|
||||
id: 'de.add-ideas.pdf-tools',
|
||||
name: 'PDF Workbench',
|
||||
version: '0.3.4',
|
||||
description: 'Page-level PDF operations performed locally in the browser.',
|
||||
entry: './',
|
||||
icon: './favicon.svg',
|
||||
categories: ['documents', 'pdf'],
|
||||
tags: ['merge', 'split'],
|
||||
integration: {
|
||||
contextVersion: 1,
|
||||
launchModes: ['navigate', 'new-tab'],
|
||||
embedding: 'unsupported',
|
||||
},
|
||||
requirements: {
|
||||
secureContext: true,
|
||||
workers: true,
|
||||
indexedDb: true,
|
||||
crossOriginIsolated: false,
|
||||
},
|
||||
privacy: {
|
||||
processing: 'local',
|
||||
fileUploads: false,
|
||||
telemetry: false,
|
||||
},
|
||||
source: {
|
||||
repository: 'https://git.add-ideas.de/zemion/pdf-tools',
|
||||
license: 'AGPL-3.0-only',
|
||||
},
|
||||
};
|
||||
|
||||
export const catalogue: ToolboxCatalog = {
|
||||
schemaVersion: 1,
|
||||
id: 'de.add-ideas.toolbox',
|
||||
name: 'add·ideas Toolbox',
|
||||
home: './',
|
||||
theme: { mode: 'system', brand: 'add·ideas' },
|
||||
apps: [{ manifest: './apps/pdf/toolbox-app.json', enabled: true }],
|
||||
};
|
||||
|
||||
export function catalogueFetch(overrides: Record<string, Response> = {}) {
|
||||
return async (input: string | URL | Request): Promise<Response> => {
|
||||
const url = new URL(
|
||||
input instanceof Request ? input.url : String(input),
|
||||
document.baseURI
|
||||
);
|
||||
const custom = overrides[url.pathname];
|
||||
if (custom) return custom.clone();
|
||||
if (url.pathname.endsWith('/toolbox.catalog.json'))
|
||||
return Response.json(catalogue);
|
||||
if (url.pathname.endsWith('/apps/pdf/toolbox-app.json'))
|
||||
return Response.json(pdfManifest);
|
||||
return new Response('Not found', { status: 404 });
|
||||
};
|
||||
}
|
||||
10
src/test/setup.ts
Normal file
10
src/test/setup.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { afterEach } from 'vitest';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
if (typeof localStorage !== 'undefined') localStorage.clear();
|
||||
if (typeof document !== 'undefined')
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
});
|
||||
39
src/types.ts
Normal file
39
src/types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type {
|
||||
ToolboxAppManifest,
|
||||
ToolboxCatalog,
|
||||
ToolboxRequirements,
|
||||
} from '@add-ideas/toolbox-contract';
|
||||
|
||||
export type ThemeMode = ToolboxCatalog['theme']['mode'];
|
||||
|
||||
export interface ResolvedApp {
|
||||
id: string;
|
||||
name: string;
|
||||
version?: string;
|
||||
description: string;
|
||||
entryUrl: string;
|
||||
iconUrl?: string;
|
||||
categories: readonly string[];
|
||||
tags: readonly string[];
|
||||
launchModes: ToolboxAppManifest['integration']['launchModes'];
|
||||
isExternal: boolean;
|
||||
manifestUrl?: string;
|
||||
sourceUrl?: string;
|
||||
requirements?: ToolboxRequirements;
|
||||
privacy?: ToolboxAppManifest['privacy'];
|
||||
}
|
||||
|
||||
export interface UnavailableApp {
|
||||
id: string;
|
||||
name: string;
|
||||
manifestUrl: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface LoadedCatalogue {
|
||||
catalogue: ToolboxCatalog;
|
||||
catalogueUrl: URL;
|
||||
homeUrl: string;
|
||||
apps: ResolvedApp[];
|
||||
unavailable: UnavailableApp[];
|
||||
}
|
||||
47
src/usePreferences.ts
Normal file
47
src/usePreferences.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
PREFERENCES_KEY,
|
||||
defaultPreferences,
|
||||
readPreferences,
|
||||
writePreferences,
|
||||
type Preferences,
|
||||
} from './preferences';
|
||||
|
||||
interface InitialPreferences {
|
||||
preferences: Preferences;
|
||||
storageAvailable: boolean;
|
||||
}
|
||||
|
||||
function initializePreferences(): InitialPreferences {
|
||||
try {
|
||||
const preferences = readPreferences();
|
||||
const probeKey = `${PREFERENCES_KEY}:probe`;
|
||||
localStorage.setItem(probeKey, '1');
|
||||
localStorage.removeItem(probeKey);
|
||||
return { preferences, storageAvailable: true };
|
||||
} catch {
|
||||
return { preferences: { ...defaultPreferences }, storageAvailable: false };
|
||||
}
|
||||
}
|
||||
|
||||
export function usePreferences(): [
|
||||
Preferences,
|
||||
(next: Preferences | ((current: Preferences) => Preferences)) => void,
|
||||
boolean,
|
||||
] {
|
||||
const [initial] = useState(initializePreferences);
|
||||
const [preferences, setPreferences] = useState<Preferences>(
|
||||
initial.preferences
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
writePreferences(preferences);
|
||||
} catch {
|
||||
// The UI already reports storage that was unavailable during initialization.
|
||||
// A later quota/privacy-mode failure leaves this session's in-memory choices intact.
|
||||
}
|
||||
}, [preferences]);
|
||||
|
||||
return [preferences, setPreferences, initial.storageAvailable];
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user