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