195 lines
5.4 KiB
TypeScript
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>
|
|
);
|
|
}
|