feat: add compact Toolbox tiles and details overlay

This commit is contained in:
2026-07-23 13:02:29 +02:00
parent 803397ff52
commit 2c13c56362
20 changed files with 1009 additions and 486 deletions

View File

@@ -0,0 +1,172 @@
import { useId } from 'react';
import type { ResolvedApp } from '../types';
import { shortToolTitle } from '../toolPresentation';
import { ModalPanel } from './ModalPanel';
interface AppDetailsPanelProps {
activeTag: string;
app: ResolvedApp;
catalogueUrl: string;
hidden: boolean;
pinned: boolean;
contextualUrl: (entryUrl: string, catalogueUrl: string) => string;
onCategory: (category: string) => void;
onClose: () => void;
onTag: (tag: string) => void;
}
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 AppDetailsPanel({
activeTag,
app,
catalogueUrl,
hidden,
pinned,
contextualUrl,
onCategory,
onClose,
onTag,
}: AppDetailsPanelProps) {
const titleId = useId();
const launchUrl = app.isExternal
? app.entryUrl
: contextualUrl(app.entryUrl, catalogueUrl);
const target = app.launchModes.includes('navigate') ? undefined : '_blank';
return (
<ModalPanel
className="app-info-panel"
labelledBy={titleId}
onClose={onClose}
>
<header className="app-info-panel__header">
<div className="app-info-panel__identity">
<div className="app-info-panel__icon" aria-hidden="true">
{app.iconUrl ? (
<img src={app.iconUrl} alt="" />
) : (
<span>{app.name.slice(0, 1).toLocaleUpperCase()}</span>
)}
</div>
<div>
<p className="eyebrow">Tool information</p>
<div className="title-line">
<h2 id={titleId}>{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>
</div>
</div>
<button
className="close-button"
type="button"
aria-label={`Close information for ${app.name}`}
onClick={onClose}
>
×
</button>
</header>
<div className="app-info-panel__body">
<p className="app-info-panel__description">{app.description}</p>
<PrivacySummary app={app} />
<RequirementSummary app={app} />
{app.categories.length > 0 && (
<div className="category-row" role="group" aria-label="Categories">
{app.categories.slice(0, 3).map((category) => (
<button
type="button"
key={category}
onClick={() => onCategory(category)}
>
{category}
</button>
))}
</div>
)}
{app.tags.length > 0 && (
<div className="tag-row" role="group" aria-label="Tags">
{app.tags.map((tag) => (
<button
type="button"
key={tag}
aria-pressed={activeTag === tag}
onClick={() => onTag(tag)}
>
#{tag}
</button>
))}
</div>
)}
</div>
<footer className="app-info-panel__footer">
<div className="app-info-panel__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="app-info-panel__launch"
href={launchUrl}
target={target}
rel={target ? 'noopener noreferrer' : undefined}
>
Open {shortToolTitle(app)} <span aria-hidden="true"></span>
</a>
</footer>
</ModalPanel>
);
}