Files
toolbox-portal/src/components/AppCard.tsx

173 lines
5.1 KiB
TypeScript

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>
);
}