feat: redesign Toolbox navigation and personalization

This commit is contained in:
2026-07-23 00:33:03 +02:00
parent 587aadb79b
commit 67ba5e3eca
25 changed files with 1075 additions and 427 deletions
+74 -40
View File
@@ -1,3 +1,5 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { ResolvedApp } from '../types';
interface AppCardProps {
@@ -5,11 +7,11 @@ interface AppCardProps {
catalogueUrl: string;
hidden: boolean;
pinned: boolean;
first: boolean;
last: boolean;
activeTag: string;
onCategory: (category: string) => void;
onHide: () => void;
onMove: (direction: -1 | 1) => void;
onPin: () => void;
onTag: (tag: string) => void;
contextualUrl: (entryUrl: string, catalogueUrl: string) => string;
}
@@ -55,21 +57,35 @@ export function AppCard({
catalogueUrl,
hidden,
pinned,
first,
last,
activeTag,
onCategory,
onHide,
onMove,
onPin,
onTag,
contextualUrl,
}: AppCardProps) {
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
className={`app-card${hidden ? ' app-card--hidden' : ''}`}
ref={setNodeRef}
className={`app-card${hidden ? ' app-card--hidden' : ''}${
isDragging ? ' app-card--dragging' : ''
}`}
data-testid={`app-card-${app.id}`}
style={{ transform: CSS.Transform.toString(transform), transition }}
>
<div className="app-card__top">
<div className="app-card__icon" aria-hidden="true">
@@ -83,6 +99,17 @@ export function AppCard({
className="app-card__actions"
aria-label={`Personalize ${app.name}`}
>
<button
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className="icon-button drag-handle"
type="button"
aria-label={`Reorder ${app.name}`}
title="Drag to reorder"
>
<span aria-hidden="true"></span>
</button>
<button
className={`icon-button${pinned ? ' is-active' : ''}`}
type="button"
@@ -93,24 +120,6 @@ export function AppCard({
>
<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"
@@ -123,7 +132,16 @@ export function AppCard({
</div>
<div className="app-card__body">
<div className="title-line">
<h2>{app.name}</h2>
<h3>
<a
className="app-card__launch-link"
href={launchUrl}
target={target}
rel={target ? 'noopener noreferrer' : undefined}
>
{app.name}
</a>
</h3>
{pinned && <span className="status-chip">Pinned</span>}
{hidden && (
<span className="status-chip status-chip--muted">Hidden</span>
@@ -135,11 +153,33 @@ export function AppCard({
<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>
{app.categories.length > 0 && (
<div className="category-row" 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" 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-card__footer">
<div className="app-card__meta">
@@ -157,15 +197,9 @@ export function AppCard({
</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>
<span className="open-hint">
Open tool <span aria-hidden="true"></span>
</span>
</footer>
</article>
);