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
+106 -142
View File
@@ -1,5 +1,7 @@
import { useId } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { shortToolDescription, shortToolTitle } from '../toolPresentation';
import type { ResolvedApp } from '../types';
interface AppCardProps {
@@ -7,48 +9,50 @@ interface AppCardProps {
catalogueUrl: string;
hidden: boolean;
pinned: boolean;
activeTag: string;
onCategory: (category: string) => void;
onDetails: () => void;
onHide: () => void;
onPin: () => void;
onTag: (tag: string) => 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 MoveIcon() {
return (
<svg viewBox="0 0 20 20" aria-hidden="true">
<circle cx="7" cy="5" r="1.2" />
<circle cx="13" cy="5" r="1.2" />
<circle cx="7" cy="10" r="1.2" />
<circle cx="13" cy="10" r="1.2" />
<circle cx="7" cy="15" r="1.2" />
<circle cx="13" cy="15" r="1.2" />
</svg>
);
}
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];
function LightBulbIcon() {
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>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 18h6M10 21h4" />
<path d="M8.4 14.8A6 6 0 1 1 15.6 14.8c-.9.7-1.4 1.4-1.6 2.2h-4c-.2-.8-.7-1.5-1.6-2.2Z" />
</svg>
);
}
function PinIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 3h8l-1 6 3 3v2H6v-2l3-3-1-6Z" />
<path d="M12 14v7" />
</svg>
);
}
function VisibilityIcon({ hidden }: { hidden: boolean }) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 12s3.2-5 9-5 9 5 9 5-3.2 5-9 5-9-5-9-5Z" />
<circle cx="12" cy="12" r="2.3" />
{!hidden && <path d="m4 4 16 16" />}
</svg>
);
}
@@ -57,13 +61,12 @@ export function AppCard({
catalogueUrl,
hidden,
pinned,
activeTag,
onCategory,
onDetails,
onHide,
onPin,
onTag,
contextualUrl,
}: AppCardProps) {
const titleId = useId();
const {
attributes,
isDragging,
@@ -84,10 +87,23 @@ export function AppCard({
className={`app-card${hidden ? ' app-card--hidden' : ''}${
isDragging ? ' app-card--dragging' : ''
}`}
aria-labelledby={titleId}
data-testid={`app-card-${app.id}`}
style={{ transform: CSS.Transform.toString(transform), transition }}
>
<div className="app-card__top">
<button
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className="drag-handle"
type="button"
aria-label={`Reorder ${app.name}`}
title="Drag to reorder"
>
<MoveIcon />
</button>
<div className="app-card__face">
<div className="app-card__icon" aria-hidden="true">
{app.iconUrl ? (
<img src={app.iconUrl} alt="" loading="lazy" />
@@ -95,112 +111,60 @@ export function AppCard({
<span>{app.name.slice(0, 1).toLocaleUpperCase()}</span>
)}
</div>
<div
className="app-card__actions"
aria-label={`Personalize ${app.name}`}
<h3 id={titleId}>
<a
className="app-card__launch-link"
href={launchUrl}
target={target}
rel={target ? 'noopener noreferrer' : undefined}
aria-label={`Open ${app.name}`}
>
{shortToolTitle(app)}
</a>
</h3>
<p>{shortToolDescription(app)}</p>
</div>
<div
className="app-card__actions"
role="group"
aria-label={`Actions for ${app.name}`}
>
<button
className="icon-button info-button"
type="button"
aria-label={`More information about ${app.name}`}
aria-haspopup="dialog"
title="More information"
data-details-trigger={app.id}
onClick={onDetails}
>
<button
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className="icon-button drag-handle"
type="button"
aria-label={`Reorder ${app.name}`}
title="Drag to reorder"
>
<span aria-hidden="true"></span>
</button>
<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={hidden ? `Show ${app.name}` : `Hide ${app.name}`}
onClick={onHide}
>
<span aria-hidden="true">{hidden ? '○' : '—'}</span>
</button>
</div>
<LightBulbIcon />
</button>
<button
className={`icon-button pin-button${pinned ? ' is-active' : ''}`}
type="button"
data-app-id={app.id}
data-card-action="pin"
aria-label={pinned ? `Unpin ${app.name}` : `Pin ${app.name}`}
aria-pressed={pinned}
title={pinned ? 'Unpin' : 'Pin'}
onClick={onPin}
>
<PinIcon />
</button>
<button
className="icon-button visibility-button"
type="button"
data-app-id={app.id}
data-card-action="visibility"
aria-label={hidden ? `Show ${app.name}` : `Hide ${app.name}`}
title={hidden ? 'Show' : 'Hide'}
onClick={onHide}
>
<VisibilityIcon hidden={hidden} />
</button>
</div>
<div className="app-card__body">
<div className="title-line">
<h3>
<a
className="app-card__launch-link"
href={launchUrl}
target={target}
rel={target ? 'noopener noreferrer' : undefined}
>
{app.name}
</a>
</h3>
{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} />
{app.categories.length > 0 && (
<div className="category-row" 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" 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-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>
<span className="open-hint">
Open tool <span aria-hidden="true"></span>
</span>
</footer>
</article>
);
}