Files
toolbox-portal/src/components/AppCard.tsx
T
2026-07-23 14:41:21 +02:00

195 lines
5.4 KiB
TypeScript

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 {
app: ResolvedApp;
catalogueUrl: string;
hidden: boolean;
pinned: boolean;
onDetails: () => void;
onHide: () => void;
onPin: () => void;
contextualUrl: (entryUrl: string, catalogueUrl: string) => string;
}
function MoveIcon() {
return (
<svg viewBox="0 0 20 80" 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" />
<circle cx="7" cy="20" r="1.2" />
<circle cx="13" cy="20" r="1.2" />
<circle cx="7" cy="25" r="1.2" />
<circle cx="13" cy="25" r="1.2" />
<circle cx="7" cy="30" r="1.2" />
<circle cx="13" cy="30" r="1.2" />
<circle cx="7" cy="35" r="1.2" />
<circle cx="13" cy="35" r="1.2" />
<circle cx="7" cy="40" r="1.2" />
<circle cx="13" cy="40" r="1.2" />
<circle cx="7" cy="45" r="1.2" />
<circle cx="13" cy="45" r="1.2" />
<circle cx="7" cy="50" r="1.2" />
<circle cx="13" cy="50" r="1.2" />
<circle cx="7" cy="55" r="1.2" />
<circle cx="13" cy="55" r="1.2" />
<circle cx="7" cy="60" r="1.2" />
<circle cx="13" cy="60" r="1.2" />
<circle cx="7" cy="65" r="1.2" />
<circle cx="13" cy="65" r="1.2" />
<circle cx="7" cy="70" r="1.2" />
<circle cx="13" cy="70" r="1.2" />
<circle cx="7" cy="75" r="1.2" />
<circle cx="13" cy="75" r="1.2" />
</svg>
);
}
function LightBulbIcon() {
return (
<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>
);
}
export function AppCard({
app,
catalogueUrl,
hidden,
pinned,
onDetails,
onHide,
onPin,
contextualUrl,
}: AppCardProps) {
const titleId = useId();
const {
attributes,
isDragging,
listeners,
setActivatorNodeRef,
setNodeRef,
transform,
transition,
} = useSortable({ id: app.id });
const launchUrl = app.isExternal
? app.entryUrl
: contextualUrl(app.entryUrl, catalogueUrl);
const target = app.launchModes.includes('navigate') ? undefined : '_blank';
return (
<article
ref={setNodeRef}
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 }}
>
<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" />
) : (
<span>{app.name.slice(0, 1).toLocaleUpperCase()}</span>
)}
</div>
<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}
>
<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>
</article>
);
}