173 lines
5.1 KiB
TypeScript
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>
|
|
);
|
|
}
|