feat: redesign legacy fullscreen tool catalog

This commit is contained in:
Anthony Stirling 2025-10-04 20:40:09 +01:00
parent 7a13d42116
commit 0740ad946c
4 changed files with 478 additions and 50 deletions

View File

@ -1963,10 +1963,14 @@ viewer.totalPages=Total Pages
toolPanel.modeToggle.sidebar=Switch to advanced sidebar
toolPanel.modeToggle.fullscreen=Switch to legacy fullscreen
toolPanel.overlay.title=All tools
toolPanel.overlay.subtitle=Browse and launch tools in the legacy fullscreen catalog.
toolPanel.overlay.subtitle=Browse every tool in the legacy fullscreen catalog.
toolPanel.overlay.close=Close
toolPanel.overlay.totalLabel_one={{count}} tool available
toolPanel.overlay.totalLabel_other={{count}} tools available
toolPanel.overlay.layoutLabel=Layout
toolPanel.overlay.layoutCompact=Compact grid
toolPanel.overlay.layoutDetailed=Detailed cards
toolPanel.overlay.matchedSynonym=Matches "{{text}}"
toolPanel.overlayHint=Select a tool to open it in the workspace.
toolPanel.modePrompt.title=Choose your tools view
toolPanel.modePrompt.description=Preview both layouts and choose how you want to explore Stirling PDF tools.

View File

@ -17,38 +17,66 @@
.tool-panel-overlay--closing {
opacity: 0;
transform: translateX(-10%);
transform: translateX(-6%);
pointer-events: none;
}
.tool-panel-overlay__paper {
flex: 1;
max-width: 72rem;
max-width: 96rem;
margin: 2rem;
display: flex;
flex-direction: column;
background: var(--bg-background);
border-radius: 1.25rem;
border-radius: 1.5rem;
overflow: hidden;
box-shadow: var(--shadow-overlay, 0 24px 64px rgba(15, 23, 42, 0.32));
}
.tool-panel-overlay__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
padding: 1.75rem;
gap: 1rem;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-elevated);
}
.tool-panel-overlay__search {
padding: 1rem 1.5rem;
display: flex;
gap: 1.25rem;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.75rem;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-toolbar);
flex-wrap: wrap;
}
.tool-panel-overlay__search-input {
flex: 1 1 22rem;
min-width: 16rem;
}
.tool-panel-overlay__search-input .search-input-container {
width: min(32rem, 100%);
width: 100%;
}
.tool-panel-overlay__search-controls {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.tool-panel-overlay__layout-toggle {
display: flex;
align-items: center;
}
.tool-panel-overlay__layout-toggle .mantine-SegmentedControl-control {
font-weight: 500;
}
.tool-panel-overlay__body {
@ -61,21 +89,160 @@
height: 100%;
}
.tool-panel-overlay__results,
.tool-panel-overlay__picker {
padding: 1.5rem;
min-height: 100%;
}
.tool-panel-overlay__picker {
.tool-panel-overlay__content {
padding: 2rem;
display: flex;
flex-direction: column;
background: var(--bg-toolbar);
border-radius: 1rem;
gap: 2.5rem;
}
.tool-panel-overlay__picker .tool-picker-scrollable {
padding-bottom: 2rem;
.tool-panel-overlay__section {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.tool-panel-overlay__section-header {
display: flex;
align-items: baseline;
gap: 0.5rem;
color: var(--text-strong);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.tool-panel-overlay__grid {
display: grid;
gap: 1.25rem;
}
.tool-panel-overlay__grid--compact {
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
}
.tool-panel-overlay__grid--detailed {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
}
.tool-panel-overlay__tile-link,
.tool-panel-overlay__tile-button {
all: unset;
display: block;
}
.tool-panel-overlay__tile-button {
cursor: pointer;
}
.tool-panel-overlay__tile-button[aria-disabled="true"],
.tool-panel-overlay__tile-link[aria-disabled="true"] {
pointer-events: none;
}
.tool-panel-overlay__tile {
width: 100%;
height: 100%;
background: var(--bg-elevated);
border: 1px solid var(--border-subtle);
border-radius: 1.25rem;
padding: 1.25rem;
display: flex;
gap: 1rem;
color: var(--text-primary);
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
.tool-panel-overlay__tile[data-variant="compact"] {
flex-direction: column;
align-items: flex-start;
min-height: 10.5rem;
}
.tool-panel-overlay__tile[data-variant="detailed"] {
flex-direction: row;
align-items: flex-start;
min-height: 8.75rem;
}
.tool-panel-overlay__tile[data-selected="true"] {
border-color: var(--accent-primary, var(--mantine-color-pink-6));
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-primary, var(--mantine-color-pink-6)) 25%, transparent);
}
.tool-panel-overlay__tile[data-disabled="true"] {
opacity: 0.45;
}
.tool-panel-overlay__tile:hover:not([data-disabled="true"]) {
transform: translateY(-4px);
border-color: color-mix(in srgb, var(--accent-primary, var(--mantine-color-pink-6)) 35%, var(--border-subtle));
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.18);
}
.tool-panel-overlay__tile-icon {
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 0.9rem;
background: color-mix(in srgb, var(--accent-primary, var(--mantine-color-pink-6)) 8%, transparent);
color: var(--tools-text-and-icon-color);
flex-shrink: 0;
}
.tool-panel-overlay__tile-icon svg,
.tool-panel-overlay__tile-icon span {
font-size: 1.9rem;
}
.tool-panel-overlay__tile-body {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.tool-panel-overlay__tile-name {
font-size: 1rem;
line-height: 1.35;
}
.tool-panel-overlay__tile-description {
line-height: 1.45;
}
.tool-panel-overlay__tile-match {
font-size: 0.75rem;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.tool-panel-overlay__tile-hotkey {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: var(--mantine-color-dimmed);
}
.tool-panel-overlay__empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 12rem;
border: 1px dashed var(--border-subtle);
border-radius: 1.25rem;
background: rgba(148, 163, 184, 0.04);
}
@media (max-width: 1200px) {
.tool-panel-overlay__paper {
margin: 1.5rem;
}
.tool-panel-overlay__content {
padding: 1.5rem;
}
}
@media (max-width: 1024px) {
@ -83,4 +250,19 @@
margin: 0;
border-radius: 0;
}
.tool-panel-overlay__header,
.tool-panel-overlay__search {
padding: 1.25rem;
}
}
@media (max-width: 640px) {
.tool-panel-overlay__grid--compact {
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
}
.tool-panel-overlay__grid--detailed {
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
}
}

View File

@ -1,21 +1,34 @@
import { useEffect, useMemo, useState } from 'react';
import { ActionIcon, Badge, Group, Paper, ScrollArea, Text, Tooltip } from '@mantine/core';
import { ActionIcon, Badge, Group, Paper, ScrollArea, SegmentedControl, Text, Tooltip } from '@mantine/core';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import ViewSidebarRoundedIcon from '@mui/icons-material/ViewSidebarRounded';
import DashboardCustomizeRoundedIcon from '@mui/icons-material/DashboardCustomizeRounded';
import { useTranslation } from 'react-i18next';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import ToolSearch from './toolPicker/ToolSearch';
import ToolPicker from './ToolPicker';
import SearchResults from './SearchResults';
import { ToolId } from '../../types/toolId';
import { useToolSections } from '../../hooks/useToolSections';
import NoToolsFound from './shared/NoToolsFound';
import ToolPanelOverlayTile from './ToolPanelOverlayTile';
import { getSubcategoryLabel } from '../../data/toolsTaxonomy';
import './ToolPanelOverlay.css';
type LayoutVariant = 'compact' | 'detailed';
interface ToolPanelOverlayProps {
isOpen: boolean;
}
const EXIT_ANIMATION_MS = 320;
const LAYOUT_STORAGE_KEY = 'toolPanelOverlayLayout';
const getInitialLayout = (): LayoutVariant => {
if (typeof window === 'undefined') {
return 'compact';
}
const stored = window.localStorage.getItem(LAYOUT_STORAGE_KEY);
return stored === 'detailed' ? 'detailed' : 'compact';
};
export default function ToolPanelOverlay({ isOpen }: ToolPanelOverlayProps) {
const { t } = useTranslation();
@ -33,6 +46,14 @@ export default function ToolPanelOverlay({ isOpen }: ToolPanelOverlayProps) {
const [shouldRender, setShouldRender] = useState(isOpen);
const [isClosing, setIsClosing] = useState(false);
const [layout, setLayout] = useState<LayoutVariant>(getInitialLayout);
const { sections, searchGroups } = useToolSections(filteredTools, searchQuery);
useEffect(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(LAYOUT_STORAGE_KEY, layout);
}, [layout]);
useEffect(() => {
if (isOpen) {
@ -69,6 +90,24 @@ export default function ToolPanelOverlay({ isOpen }: ToolPanelOverlayProps) {
const showSearchResults = useMemo(() => searchQuery.trim().length > 0, [searchQuery]);
const totalToolCount = showSearchResults ? filteredTools.length : Object.keys(toolRegistry).length;
const matchedTextMap = useMemo(() => {
const map = new Map<string, string>();
filteredTools.forEach(({ item: [id], matchedText }) => {
if (matchedText) {
map.set(id, matchedText);
}
});
return map;
}, [filteredTools]);
const subcategoryGroups = useMemo(() => {
if (showSearchResults) {
return searchGroups;
}
const allSection = sections.find(section => section.key === 'all');
return allSection ? allSection.subcategories : [];
}, [searchGroups, sections, showSearchResults]);
if (!shouldRender) {
return null;
}
@ -82,6 +121,8 @@ export default function ToolPanelOverlay({ isOpen }: ToolPanelOverlayProps) {
? t('toolPanel.modeToggle.sidebar', 'Switch to advanced sidebar')
: t('toolPanel.modeToggle.fullscreen', 'Switch to legacy fullscreen');
const layoutLabel = t('toolPanel.overlay.layoutLabel', 'Layout');
return (
<div
className={`tool-panel-overlay ${isClosing || !isOpen ? 'tool-panel-overlay--closing' : 'tool-panel-overlay--open'}`}
@ -96,7 +137,7 @@ export default function ToolPanelOverlay({ isOpen }: ToolPanelOverlayProps) {
{t('toolPanel.overlay.title', 'All tools')}
</Text>
<Text size="sm" c="dimmed">
{t('toolPanel.overlay.subtitle', 'Browse and launch tools in the legacy fullscreen catalog.')}
{t('toolPanel.overlay.subtitle', 'Browse every tool in the legacy fullscreen catalog.')}
</Text>
</div>
<Group gap="xs">
@ -130,44 +171,69 @@ export default function ToolPanelOverlay({ isOpen }: ToolPanelOverlayProps) {
</header>
<div className="tool-panel-overlay__search">
<Group justify="space-between" align="center">
<div className="tool-panel-overlay__search-input">
<ToolSearch
value={searchQuery}
onChange={setSearchQuery}
toolRegistry={toolRegistry}
mode="filter"
autoFocus
<div className="tool-panel-overlay__search-input">
<ToolSearch
value={searchQuery}
onChange={setSearchQuery}
toolRegistry={toolRegistry}
mode="filter"
autoFocus
/>
</div>
<div className="tool-panel-overlay__search-controls">
<div className="tool-panel-overlay__layout-toggle">
<SegmentedControl
value={layout}
onChange={value => setLayout(value as LayoutVariant)}
size="sm"
aria-label={layoutLabel}
data={[
{ label: t('toolPanel.overlay.layoutCompact', 'Compact grid'), value: 'compact' },
{ label: t('toolPanel.overlay.layoutDetailed', 'Detailed cards'), value: 'detailed' },
]}
/>
</div>
<Badge variant="light" size="lg" radius="sm">
{t('toolPanel.overlay.totalLabel', '{{count}} tools available', {
{t('toolPanel.overlay.totalLabel', {
count: totalToolCount,
defaultValue: '{{count}} tools available',
})}
</Badge>
</Group>
</div>
</div>
<div className="tool-panel-overlay__body">
<ScrollArea className="tool-panel-overlay__scroll" type="always">
{showSearchResults ? (
<div className="tool-panel-overlay__results">
<SearchResults
filteredTools={filteredTools}
onSelect={(id) => handleToolSelect(id as ToolId)}
searchQuery={searchQuery}
/>
</div>
) : (
<div className="tool-panel-overlay__picker">
<ToolPicker
selectedToolKey={selectedToolKey}
onSelect={(id) => handleToolSelect(id as ToolId)}
filteredTools={filteredTools}
isSearching={showSearchResults}
/>
</div>
)}
<div className="tool-panel-overlay__content">
{subcategoryGroups.length === 0 ? (
<div className="tool-panel-overlay__empty">
<NoToolsFound />
</div>
) : (
subcategoryGroups.map(group => (
<section key={group.subcategoryId} className="tool-panel-overlay__section">
<header className="tool-panel-overlay__section-header">
<Text fw={600} size="sm">
{getSubcategoryLabel(t, group.subcategoryId)}
</Text>
</header>
<div className={`tool-panel-overlay__grid tool-panel-overlay__grid--${layout}`}>
{group.tools.map(({ id, tool }) => (
<ToolPanelOverlayTile
key={id}
id={id}
tool={tool}
layout={layout}
onSelect={toolId => handleToolSelect(toolId as ToolId)}
isSelected={selectedToolKey === id}
matchedSynonym={matchedTextMap.get(id)}
/>
))}
</div>
</section>
))
)}
</div>
</ScrollArea>
</div>
</Paper>

View File

@ -0,0 +1,176 @@
import React, { useMemo } from 'react';
import { Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ToolRegistryEntry } from '../../data/toolsTaxonomy';
import { Tooltip } from '../shared/Tooltip';
import { useToolNavigation } from '../../hooks/useToolNavigation';
import { handleUnlessSpecialClick } from '../../utils/clickHandlers';
import { useHotkeys } from '../../contexts/HotkeyContext';
import HotkeyDisplay from '../hotkeys/HotkeyDisplay';
interface ToolPanelOverlayTileProps {
id: string;
tool: ToolRegistryEntry;
layout: 'compact' | 'detailed';
onSelect: (id: string) => void;
isSelected: boolean;
matchedSynonym?: string;
}
const ToolPanelOverlayTile: React.FC<ToolPanelOverlayTileProps> = ({
id,
tool,
layout,
onSelect,
isSelected,
matchedSynonym,
}) => {
const { t } = useTranslation();
const { getToolNavigation } = useToolNavigation();
const { hotkeys } = useHotkeys();
const isUnavailable = !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
const binding = hotkeys[id];
const navProps = !isUnavailable && !tool.link ? getToolNavigation(id, tool) : null;
const tooltipContent = useMemo(() => {
if (layout !== 'compact') {
return null;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<span>{tool.description}</span>
{binding && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem' }}>
<span style={{ color: 'var(--mantine-color-dimmed)', fontWeight: 500 }}>
{t('settings.hotkeys.shortcut', 'Shortcut')}
</span>
<HotkeyDisplay binding={binding} />
</div>
)}
</div>
);
}, [binding, layout, t, tool.description]);
const iconNode = useMemo(() => {
if (!tool.icon) {
return null;
}
if (React.isValidElement(tool.icon)) {
const existingStyle = (tool.icon.props as { style?: React.CSSProperties }).style || {};
return React.cloneElement(tool.icon, {
style: {
...existingStyle,
fontSize: layout === 'compact' ? '1.75rem' : '2rem',
},
});
}
return tool.icon;
}, [layout, tool.icon]);
const handleSelect = () => {
if (isUnavailable) return;
if (tool.link) {
window.open(tool.link, '_blank', 'noopener,noreferrer');
return;
}
onSelect(id);
};
const handleButtonClick = (event: React.MouseEvent) => {
handleUnlessSpecialClick(event, handleSelect);
};
const matchedLine = matchedSynonym
? t('toolPanel.overlay.matchedSynonym', 'Matches "{{text}}"', { text: matchedSynonym })
: null;
const content = (
<div
className="tool-panel-overlay__tile"
data-variant={layout}
data-selected={isSelected}
data-disabled={isUnavailable || undefined}
>
<div className="tool-panel-overlay__tile-icon" aria-hidden>
{iconNode}
</div>
<div className="tool-panel-overlay__tile-body">
<Text fw={600} size="sm" className="tool-panel-overlay__tile-name">
{tool.name}
</Text>
{layout === 'detailed' && (
<Text size="sm" c="dimmed" className="tool-panel-overlay__tile-description">
{tool.description}
</Text>
)}
{matchedLine && (
<Text size="xs" c="dimmed" className="tool-panel-overlay__tile-match">
{matchedLine}
</Text>
)}
{layout === 'detailed' && binding && (
<div className="tool-panel-overlay__tile-hotkey">
<span>{t('settings.hotkeys.shortcut', 'Shortcut')}</span>
<HotkeyDisplay binding={binding} />
</div>
)}
</div>
</div>
);
const wrappedContent = layout === 'compact' && tooltipContent ? (
<Tooltip content={tooltipContent} position="top" arrow>
{content}
</Tooltip>
) : (
content
);
if (navProps) {
return (
<a
href={navProps.href}
onClick={navProps.onClick}
className="tool-panel-overlay__tile-link"
aria-disabled={isUnavailable}
data-variant={layout}
>
{wrappedContent}
</a>
);
}
if (tool.link && !isUnavailable) {
return (
<a
href={tool.link}
target="_blank"
rel="noopener noreferrer"
onClick={handleButtonClick}
className="tool-panel-overlay__tile-link"
data-variant={layout}
>
{wrappedContent}
</a>
);
}
return (
<button
type="button"
onClick={handleButtonClick}
className="tool-panel-overlay__tile-button"
data-variant={layout}
aria-disabled={isUnavailable}
>
{wrappedContent}
</button>
);
};
export default ToolPanelOverlayTile;