feat: add compact Toolbox tiles and details overlay

This commit is contained in:
2026-07-23 13:02:29 +02:00
parent 803397ff52
commit 2c13c56362
20 changed files with 1009 additions and 486 deletions

View File

@@ -16,8 +16,9 @@ import {
ToolboxHeader,
type ToolboxHeaderApp,
} from '@add-ideas/toolbox-shell-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AppCard } from './components/AppCard';
import { AppDetailsPanel } from './components/AppDetailsPanel';
import { HelpPanel } from './components/HelpPanel';
import { Logo } from './components/Logo';
import { PreferencesPanel } from './components/PreferencesPanel';
@@ -34,6 +35,9 @@ type LoadState =
| { status: 'error'; message: string }
| { status: 'ready'; value: LoadedCatalogue };
type PortalOverlay =
{ kind: 'help' } | { kind: 'details'; appId: string } | null;
function appMatches(
app: ResolvedApp,
query: string,
@@ -50,29 +54,25 @@ function appMatches(
}
interface AppSectionProps {
activeTag: string;
apps: ResolvedApp[];
catalogueUrl: string;
hiddenIds: readonly string[];
id: string;
onCategory: (category: string) => void;
onDetails: (id: string) => void;
onHide: (id: string) => void;
onPin: (id: string) => void;
onTag: (tag: string) => void;
pinned: boolean;
title: string;
}
function AppSection({
activeTag,
apps,
catalogueUrl,
hiddenIds,
id,
onCategory,
onDetails,
onHide,
onPin,
onTag,
pinned,
title,
}: AppSectionProps) {
@@ -97,11 +97,9 @@ function AppSection({
contextualUrl={contextualLaunchUrl}
pinned={pinned}
hidden={hiddenIds.includes(app.id)}
activeTag={activeTag}
onCategory={onCategory}
onDetails={() => onDetails(app.id)}
onPin={() => onPin(app.id)}
onHide={() => onHide(app.id)}
onTag={onTag}
/>
))}
</div>
@@ -119,9 +117,10 @@ export default function App() {
const [shownHiddenIds, setShownHiddenIds] = useState<
readonly string[] | null
>(null);
const [helpOpen, setHelpOpen] = useState(false);
const [overlay, setOverlay] = useState<PortalOverlay>(null);
const hiddenToggle = useRef<HTMLInputElement>(null);
const [preferences, setPreferences, storageAvailable] = usePreferences();
const closeHelp = useCallback(() => setHelpOpen(false), []);
const closeOverlay = useCallback(() => setOverlay(null), []);
const showHidden =
preferences.hidden.length > 0 &&
shownHiddenIds?.some((id) => preferences.hidden.includes(id)) === true;
@@ -150,6 +149,11 @@ export default function App() {
const loaded = loadState.status === 'ready' ? loadState.value : undefined;
const apps = useMemo(() => loaded?.apps ?? [], [loaded]);
const detailsApp =
overlay?.kind === 'details'
? apps.find((app) => app.id === overlay.appId)
: undefined;
const overlayOpen = overlay?.kind === 'help' || detailsApp !== undefined;
useEffect(() => {
if (!loaded) return;
@@ -206,6 +210,7 @@ export default function App() {
}));
function toggleListItem(field: 'pinned' | 'hidden', id: string) {
const keepCardVisible = field === 'pinned' || showHidden;
if (field === 'hidden' && showHidden && !preferences.hidden.includes(id)) {
setShownHiddenIds((current) => [...new Set([...(current ?? []), id])]);
}
@@ -215,6 +220,20 @@ export default function App() {
? current[field].filter((item) => item !== id)
: [...current[field], id],
}));
window.requestAnimationFrame(() => {
if (!keepCardVisible) {
hiddenToggle.current?.focus();
return;
}
const action = field === 'pinned' ? 'pin' : 'visibility';
[
...document.querySelectorAll<HTMLButtonElement>(
`[data-card-action="${action}"]`
),
]
.find((button) => button.dataset.appId === id)
?.focus();
});
}
function handleDragEnd(event: DragEndEvent) {
@@ -243,8 +262,8 @@ export default function App() {
<>
<div
className="site-shell"
inert={helpOpen}
aria-hidden={helpOpen || undefined}
inert={overlayOpen}
aria-hidden={overlayOpen || undefined}
>
<a className="skip-link" href="#main-content">
Skip to tools
@@ -257,7 +276,7 @@ export default function App() {
homeHref={loaded?.homeUrl ?? './'}
homeLabel="add·ideas Toolbox"
brandIconUrl="./favicon.svg"
helpAction={{ onClick: () => setHelpOpen(true) }}
helpAction={{ onClick: () => setOverlay({ kind: 'help' }) }}
sourceHref={PORTAL_SOURCE}
sourceLabel="Toolbox source on Gitea"
personalizeContent={
@@ -331,6 +350,7 @@ export default function App() {
}`}
>
<input
ref={hiddenToggle}
type="checkbox"
checked={showHidden}
disabled={preferences.hidden.length === 0}
@@ -415,30 +435,26 @@ export default function App() {
title="Pinned"
apps={pinnedApps}
pinned
activeTag={activeTag}
catalogueUrl={loaded.catalogueUrl.href}
hiddenIds={preferences.hidden}
onCategory={setCategory}
onDetails={(id) =>
setOverlay({ kind: 'details', appId: id })
}
onPin={(id) => toggleListItem('pinned', id)}
onHide={(id) => toggleListItem('hidden', id)}
onTag={(tag) =>
setActiveTag((current) => (current === tag ? '' : tag))
}
/>
<AppSection
id="regular-apps"
title="Tools"
apps={regularApps}
pinned={false}
activeTag={activeTag}
catalogueUrl={loaded.catalogueUrl.href}
hiddenIds={preferences.hidden}
onCategory={setCategory}
onDetails={(id) =>
setOverlay({ kind: 'details', appId: id })
}
onPin={(id) => toggleListItem('pinned', id)}
onHide={(id) => toggleListItem('hidden', id)}
onTag={(tag) =>
setActiveTag((current) => (current === tag ? '' : tag))
}
/>
</div>
</DndContext>
@@ -473,9 +489,9 @@ export default function App() {
</span>
</p>
<span>
Portal v0.2.2 ·{' '}
Portal v0.2.3 ·{' '}
<a
href="https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.4.2"
href="https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.4.3"
target="_blank"
rel="noopener noreferrer"
>
@@ -485,7 +501,26 @@ export default function App() {
</footer>
</div>
<HelpPanel open={helpOpen} onClose={closeHelp} />
<HelpPanel open={overlay?.kind === 'help'} onClose={closeOverlay} />
{detailsApp && loaded ? (
<AppDetailsPanel
app={detailsApp}
catalogueUrl={loaded.catalogueUrl.href}
contextualUrl={contextualLaunchUrl}
pinned={preferences.pinned.includes(detailsApp.id)}
hidden={preferences.hidden.includes(detailsApp.id)}
activeTag={activeTag}
onCategory={(nextCategory) => {
setCategory(nextCategory);
closeOverlay();
}}
onTag={(tag) => {
setActiveTag((current) => (current === tag ? '' : tag));
closeOverlay();
}}
onClose={closeOverlay}
/>
) : null}
</>
);
}