mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
208 lines
8.9 KiB
TypeScript
208 lines
8.9 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { Button } from './components/Button';
|
|
import { ConfirmDialog } from './components/ConfirmDialog';
|
|
import { LivePreview } from './components/LivePreview';
|
|
import { ModalShell } from './components/ModalShell';
|
|
import { Onboarding } from './components/Onboarding';
|
|
import { PaneSplitHandle } from './components/PaneSplitHandle';
|
|
import { Icon } from './components/Icon';
|
|
import { IconButton } from './components/IconButton';
|
|
import { PaneToggleStrip } from './components/PaneToggleStrip';
|
|
import { ResizeHandle } from './components/ResizeHandle';
|
|
import { SnippetLibrary } from './components/SnippetLibrary';
|
|
import { SpecEditor } from './components/SpecEditor';
|
|
import { ThemeToggle } from './components/ThemeToggle';
|
|
import { Toaster } from './components/Toaster';
|
|
import { openModal, setConfirm } from './modals/ModalCoordinator';
|
|
import { confirm } from './stores/ConfirmStore';
|
|
import { usePanesStore } from './stores/PanesStore';
|
|
import { useSnippetStore } from './stores/SnippetStore';
|
|
import { exportWorkspace, importWorkspace } from './services/transfer';
|
|
import styles from './App.module.css';
|
|
|
|
/**
|
|
* Application shell — the three-pane workspace from spec §01A
|
|
* (library · editor · preview) under a fixed header.
|
|
*
|
|
* The center editor flexes; the library and preview carry remembered widths and
|
|
* are resized via the drag handles between them (spec §01A). Each pane can be
|
|
* shown/hidden from the toggle strip (the leftmost rail); a hidden pane frees its
|
|
* space and the rest redistribute — when the editor is hidden the two side panes
|
|
* flex proportionally to their remembered widths.
|
|
*
|
|
* When the library is empty, this whole pane chrome (strip + panes) is replaced
|
|
* by the full-width onboarding canvas (spec §02 → First-Run & Empty Workspace).
|
|
*/
|
|
export function App() {
|
|
const libraryWidth = usePanesStore((s) => s.libraryWidth);
|
|
const previewWidth = usePanesStore((s) => s.previewWidth);
|
|
const libraryVisible = usePanesStore((s) => s.libraryVisible);
|
|
const editorVisible = usePanesStore((s) => s.editorVisible);
|
|
const previewVisible = usePanesStore((s) => s.previewVisible);
|
|
// An empty library is the onboarding surface: the whole pane chrome (toggle
|
|
// strip, library list, editor, preview) is replaced by the full-width Onboarding
|
|
// canvas (spec §02 → First-Run & Empty Workspace).
|
|
const hasSnippets = useSnippetStore((s) => s.snippets.length > 0);
|
|
|
|
// Side-pane sizing: fixed remembered width while the editor (the flex filler) is
|
|
// present; when it's hidden, the side panes grow proportionally to those widths
|
|
// so they fill the freed space (spec §01A → "redistributing proportionally").
|
|
const sideStyle = (width: number): React.CSSProperties =>
|
|
editorVisible ? { width } : { flex: `${width} 1 0` };
|
|
// Hidden file input driving Import — the header button proxies its click so the
|
|
// browser file picker is the only chrome (spec §08 → no intermediate dialog).
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Route the modal coordinator's discard prompt through the in-app confirm
|
|
// dialog (docs/architecture/03 → "The coordinator seam").
|
|
useEffect(() => {
|
|
setConfirm((message) => confirm({ title: 'Discard changes?', message, danger: true }));
|
|
}, []);
|
|
|
|
const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
// Reset the input so picking the same file again still fires onChange.
|
|
e.target.value = '';
|
|
if (file) void importWorkspace(file);
|
|
};
|
|
|
|
return (
|
|
<div className={styles.app}>
|
|
{/* Skip link (WCAG 2.4.1 / GOV.UK): the first focusable element, hidden
|
|
until focused, lets keyboard users bypass the header into the work area. */}
|
|
<a className={styles.skipLink} href="#main">
|
|
Skip to content
|
|
</a>
|
|
<header className={styles.header}>
|
|
<h1 className={styles.title}>Astrolabe</h1>
|
|
<span className={styles.version}>v{__APP_VERSION__}</span>
|
|
<span className={styles.spacer} />
|
|
{/* Header actions establish a hierarchy (Carbon button/usage → one
|
|
high-emphasis action per region; Carbon UI-shell header → global
|
|
actions are a right-aligned row of icon-only buttons). The utilities
|
|
are IconButtons — tooltips carry the full action, accessible names
|
|
scope it ("Export workspace" vs the preview pane's per-chart
|
|
"Export") — then a divider sets off Support (feedback + the one
|
|
donation solicitation), given a soft-accent wash — and the theme
|
|
toggle. */}
|
|
<IconButton
|
|
label="Datasets"
|
|
onClick={() => openModal('datasets')}
|
|
aria-keyshortcuts="Meta+K Control+K"
|
|
title="Datasets (⌘/Ctrl+K)"
|
|
>
|
|
<Icon name="dataset" />
|
|
</IconButton>
|
|
<IconButton
|
|
label="Import workspace"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
title="Import a workspace JSON file"
|
|
>
|
|
<Icon name="import" />
|
|
</IconButton>
|
|
<IconButton
|
|
label="Export workspace"
|
|
onClick={() => exportWorkspace()}
|
|
title="Export your workspace to a JSON file"
|
|
>
|
|
<Icon name="export" />
|
|
</IconButton>
|
|
<IconButton
|
|
label="About"
|
|
onClick={() => openModal('about')}
|
|
title="About, keyboard shortcuts, and privacy information"
|
|
>
|
|
<Icon name="info" />
|
|
</IconButton>
|
|
<span className={styles.headerDivider} aria-hidden="true" />
|
|
<Button
|
|
variant="soft-accent"
|
|
onClick={() => openModal('donate')}
|
|
title="Send feedback or support Ukraine's defense"
|
|
>
|
|
Support
|
|
</Button>
|
|
<ThemeToggle />
|
|
{/* Hidden picker for Import; restricted to JSON (spec §08). */}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="application/json,.json"
|
|
className={styles.hiddenInput}
|
|
onChange={handleImportFile}
|
|
aria-hidden="true"
|
|
tabIndex={-1}
|
|
/>
|
|
</header>
|
|
|
|
{/* tabIndex -1 makes the landmark a focus target for the skip link. */}
|
|
<main id="main" className={styles.panes} tabIndex={-1}>
|
|
{hasSnippets ? (
|
|
<>
|
|
{/* Always-present rail: shows/hides panes and shortcuts to Datasets (§01A). */}
|
|
<PaneToggleStrip />
|
|
{libraryVisible && (
|
|
<section
|
|
id="pane-library"
|
|
className={styles.pane}
|
|
style={sideStyle(libraryWidth)}
|
|
aria-label="Snippet library"
|
|
>
|
|
<SnippetLibrary />
|
|
</section>
|
|
)}
|
|
{/* A resize handle sits between any two adjacent visible panes. With the
|
|
editor present it flanks the editor (it absorbs the drag); with the
|
|
editor hidden, the library and preview become adjacent and share a
|
|
single split handle between them (spec §01A). */}
|
|
{libraryVisible && editorVisible && (
|
|
<ResizeHandle side="library" label="Resize snippet library" />
|
|
)}
|
|
{libraryVisible && previewVisible && !editorVisible && (
|
|
<PaneSplitHandle label="Resize library and preview" />
|
|
)}
|
|
{editorVisible && (
|
|
<section id="pane-editor" className={styles.paneEditor} aria-label="Spec editor">
|
|
<SpecEditor />
|
|
</section>
|
|
)}
|
|
{editorVisible && previewVisible && (
|
|
<ResizeHandle side="preview" label="Resize live preview" />
|
|
)}
|
|
{previewVisible && (
|
|
<section
|
|
id="pane-preview"
|
|
className={styles.pane}
|
|
style={sideStyle(previewWidth)}
|
|
aria-label="Live preview"
|
|
>
|
|
<LivePreview />
|
|
</section>
|
|
)}
|
|
</>
|
|
) : (
|
|
// Empty library → the onboarding canvas takes the full workspace. The
|
|
// pane chrome (toggle strip, library list, editor, preview) is hidden:
|
|
// with no snippets, Create/Search/Sort/Storage and the pane toggles have
|
|
// nothing to act on, so the welcome gets the whole width (spec §02).
|
|
<section className={styles.paneOnboarding} aria-label="Getting started">
|
|
<Onboarding />
|
|
</section>
|
|
)}
|
|
</main>
|
|
|
|
{/* The one feature modal (Datasets / Extract / …), rendered from the
|
|
registry by the shared shell. At most one open at a time (spec §01C). */}
|
|
<ModalShell />
|
|
|
|
{/* Global confirmation layer — sits above the feature-modal shell so a
|
|
discard-changes prompt can appear over an open modal. */}
|
|
<ConfirmDialog />
|
|
|
|
{/* Non-blocking notifications (failed saves, etc.) — top-right toasts,
|
|
layered above the confirm backdrop so a failure stays visible. */}
|
|
<Toaster />
|
|
</div>
|
|
);
|
|
}
|