From ce1efd1c13c91bc993a2af136e5c3d86201c5852 Mon Sep 17 00:00:00 2001 From: Richie Date: Tue, 9 Jun 2026 08:44:00 +1000 Subject: [PATCH] Add shared SDC app shell (SdcTopBar + ApiSettings + unified credentials) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workstream D: a shared top bar for the SDC tool suite — app name (left), API settings cog, and the suite app directory (grid) — composed on the existing TopBar. Adds an ApiSettings dialog and sdc_api_key/sdc_api_endpoint credential helpers (shared once across all tools, with legacy-key migration). lucide-react becomes a peer dependency. Bump to 0.2.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 3 +- .../ApiSettings/ApiSettings.stories.tsx | 25 +++ .../organisms/ApiSettings/ApiSettings.tsx | 146 ++++++++++++++++++ src/components/organisms/ApiSettings/index.ts | 2 + .../organisms/SdcTopBar/SdcTopBar.stories.tsx | 20 +++ .../organisms/SdcTopBar/SdcTopBar.tsx | 118 ++++++++++++++ src/components/organisms/SdcTopBar/index.ts | 2 + src/index.ts | 17 ++ src/lib/credentials.ts | 65 ++++++++ vite.lib.config.ts | 6 +- 10 files changed, 400 insertions(+), 4 deletions(-) create mode 100644 src/components/organisms/ApiSettings/ApiSettings.stories.tsx create mode 100644 src/components/organisms/ApiSettings/ApiSettings.tsx create mode 100644 src/components/organisms/ApiSettings/index.ts create mode 100644 src/components/organisms/SdcTopBar/SdcTopBar.stories.tsx create mode 100644 src/components/organisms/SdcTopBar/SdcTopBar.tsx create mode 100644 src/components/organisms/SdcTopBar/index.ts create mode 100644 src/lib/credentials.ts diff --git a/package.json b/package.json index 1c02195..3a209d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@richiesnitch/ads3-design-system", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "repository": { "type": "git", @@ -33,6 +33,7 @@ "build-storybook": "storybook build" }, "peerDependencies": { + "lucide-react": "^1.16.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/src/components/organisms/ApiSettings/ApiSettings.stories.tsx b/src/components/organisms/ApiSettings/ApiSettings.stories.tsx new file mode 100644 index 0000000..e0e6f22 --- /dev/null +++ b/src/components/organisms/ApiSettings/ApiSettings.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { useState } from 'react' +import { ApiSettings } from './ApiSettings' +import { Button } from '@/components/atoms/Button' + +const meta: Meta = { + title: 'Organisms/ApiSettings', + component: ApiSettings, + tags: ['autodocs'], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => { + const [open, setOpen] = useState(true) + return ( + <> + + setOpen(false)} /> + + ) + }, +} diff --git a/src/components/organisms/ApiSettings/ApiSettings.tsx b/src/components/organisms/ApiSettings/ApiSettings.tsx new file mode 100644 index 0000000..e8afd39 --- /dev/null +++ b/src/components/organisms/ApiSettings/ApiSettings.tsx @@ -0,0 +1,146 @@ +import { useState, useEffect } from 'react' +import { + Dialog, + DialogHeader, + DialogTitle, + DialogDescription, + DialogContent, + DialogFooter, +} from '@/components/molecules/Dialog' +import { Button } from '@/components/atoms/Button' +import { Input } from '@/components/atoms/Input' +import { Alert } from '@/components/molecules/Alert' +import { Badge } from '@/components/atoms/Badge' +import { + getEndpoint, + getApiKey, + saveCredentials, + testConnection, +} from '@/lib/credentials' + +export interface ApiSettingsProps { + open: boolean + onClose: () => void + /** Called after credentials are saved (e.g. to refresh a "no API key" badge). */ + onSaved?: () => void +} + +/** + * Shared API-settings dialog for the SDC tool suite. Reads/writes the suite-wide + * `sdc_api_endpoint` + `sdc_api_key` so the key is entered once across all tools. + */ +export function ApiSettings({ open, onClose, onSaved }: ApiSettingsProps) { + const [endpoint, setEndpoint] = useState('') + const [apiKey, setApiKey] = useState('') + const [status, setStatus] = useState<{ type: 'success' | 'error' | 'info'; message: string } | null>(null) + const [testing, setTesting] = useState(false) + const [models, setModels] = useState([]) + + useEffect(() => { + if (open) { + setEndpoint(getEndpoint()) + setApiKey(getApiKey()) + setStatus(null) + setModels([]) + } + }, [open]) + + function handleSave() { + if (!endpoint.trim()) { + setStatus({ type: 'error', message: 'Please enter a gateway endpoint.' }) + return + } + if (!apiKey.trim()) { + setStatus({ type: 'error', message: 'Please enter an API key.' }) + return + } + saveCredentials(endpoint, apiKey) + onSaved?.() + setStatus({ type: 'success', message: 'Credentials saved. They apply across all SDC tools.' }) + } + + async function handleTest() { + if (!endpoint.trim() || !apiKey.trim()) { + setStatus({ type: 'error', message: 'Enter an endpoint and key first.' }) + return + } + saveCredentials(endpoint, apiKey) + onSaved?.() + setTesting(true) + setStatus({ type: 'info', message: 'Testing connection…' }) + setModels([]) + try { + const result = await testConnection(endpoint, apiKey) + setModels(result.models) + setStatus({ + type: 'success', + message: 'Connected. ' + result.models.length + ' model(s) available.', + }) + } catch (e) { + setStatus({ type: 'error', message: (e as Error).message }) + } finally { + setTesting(false) + } + } + + return ( + + + API Settings + + Configure your gateway connection. This is shared across all SDC tools. + + + +
+ setEndpoint(e.target.value)} + /> + setApiKey(e.target.value)} + /> + + {status && ( + setStatus(null)} + > + {status.message} + + )} + + {models.length > 0 && ( +
+

Available Models

+
+ {models.map((m) => ( + {m} + ))} +
+
+ )} +
+
+ + + + +
+ ) +} diff --git a/src/components/organisms/ApiSettings/index.ts b/src/components/organisms/ApiSettings/index.ts new file mode 100644 index 0000000..17d7923 --- /dev/null +++ b/src/components/organisms/ApiSettings/index.ts @@ -0,0 +1,2 @@ +export { ApiSettings } from './ApiSettings' +export type { ApiSettingsProps } from './ApiSettings' diff --git a/src/components/organisms/SdcTopBar/SdcTopBar.stories.tsx b/src/components/organisms/SdcTopBar/SdcTopBar.stories.tsx new file mode 100644 index 0000000..e90054c --- /dev/null +++ b/src/components/organisms/SdcTopBar/SdcTopBar.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { SdcTopBar } from './SdcTopBar' + +const meta: Meta = { + title: 'Organisms/SdcTopBar', + component: SdcTopBar, + tags: ['autodocs'], + parameters: { layout: 'fullscreen' }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { appName: 'Status Report', activeTool: 'status-report' }, +} + +export const Synthesiser: Story = { + args: { appName: 'Research Synthesiser', activeTool: 'synthesiser' }, +} diff --git a/src/components/organisms/SdcTopBar/SdcTopBar.tsx b/src/components/organisms/SdcTopBar/SdcTopBar.tsx new file mode 100644 index 0000000..4bc9a20 --- /dev/null +++ b/src/components/organisms/SdcTopBar/SdcTopBar.tsx @@ -0,0 +1,118 @@ +import { useState, type ReactNode } from 'react' +import { Settings, Grid3x3 } from 'lucide-react' +import { TopBar } from '@/components/organisms/TopBar' +import { IconButton } from '@/components/atoms/IconButton' +import { Badge } from '@/components/atoms/Badge' +import { Popover, PopoverTrigger, PopoverContent } from '@/components/molecules/Popover' +import { List, ListItem, ListSubheader, ListDivider } from '@/components/atoms/List' +import { ApiSettings } from '@/components/organisms/ApiSettings' +import { hasCredentials as checkCredentials } from '@/lib/credentials' + +export interface SdcTool { + /** Stable identifier, used to mark the current tool active. */ + slug: string + label: string + /** Relative link from one tool folder to another, e.g. '../SDC Status Report/index.html'. */ + href: string + group: string +} + +/** The SDC tool suite directory. Adding a tool here updates the menu in every app. */ +export const SDC_TOOLS: SdcTool[] = [ + { slug: 'synthesiser', label: 'Research Synthesiser', group: 'HCD Tools', href: '../SDC Project Synthesiser/index.html' }, + { slug: 'data-synthesis', label: 'Data Synthesis', group: 'HCD Tools', href: '../SDC Data Synthesis/index.html' }, + { slug: 'persona', label: 'Persona Builder', group: 'HCD Tools', href: '../SDC Persona Builder/index.html' }, + { slug: 'case-study', label: 'Case Study Generator', group: 'HCD Tools', href: '../SDC Case Study Generator/index.html' }, + { slug: 'charter', label: 'Project Charter', group: 'Project Management', href: '../SDC Project Charter/index.html' }, + { slug: 'timeline', label: 'Timeline Builder', group: 'Project Management', href: '../SDC Timeline Builder/index.html' }, + { slug: 'status-report', label: 'Status Report', group: 'Project Management', href: '../SDC Status Report/index.html' }, +] + +export interface SdcTopBarProps { + /** App name shown top-left. */ + appName: string + /** Slug of the current tool, marked active and non-navigating in the menu. */ + activeTool?: string + /** Optional logo node rendered before the app name. */ + logo?: ReactNode + /** Override the tool list (defaults to the full SDC suite). */ + tools?: SdcTool[] +} + +/** + * Shared top bar for the SDC tool suite: app name (left), API settings (cog), and the + * suite app directory (grid). Wraps the ADS TopBar; uses the shared `sdc_*` credentials. + */ +export function SdcTopBar({ appName, activeTool, logo, tools = SDC_TOOLS }: SdcTopBarProps) { + const [settingsOpen, setSettingsOpen] = useState(false) + const [toolsOpen, setToolsOpen] = useState(false) + const [hasCreds, setHasCreds] = useState(() => checkCredentials()) + + // Group tools preserving first-seen group order. + const groups: { name: string; items: SdcTool[] }[] = [] + for (const t of tools) { + let g = groups.find((x) => x.name === t.group) + if (!g) { g = { name: t.group, items: [] }; groups.push(g) } + g.items.push(t) + } + + return ( + <> + +
+ {!hasCreds && No API key} + + } + aria-label="API settings" + variant="tertiary" + className="!text-white hover:!bg-white/10" + onClick={() => setSettingsOpen(true)} + /> + + + + } + aria-label="SDC AI Tools" + variant="tertiary" + className="!text-white hover:!bg-white/10" + /> + + +
+ + {groups.map((group) => ( +
+ {group.name} + {group.items.map((item) => { + const isActive = item.slug === activeTool + return ( + isActive && setToolsOpen(false)} + > + {item.label} + + ) + })} + +
+ ))} +
+
+
+
+
+
+ + setSettingsOpen(false)} + onSaved={() => setHasCreds(checkCredentials())} + /> + + ) +} diff --git a/src/components/organisms/SdcTopBar/index.ts b/src/components/organisms/SdcTopBar/index.ts new file mode 100644 index 0000000..06ef54e --- /dev/null +++ b/src/components/organisms/SdcTopBar/index.ts @@ -0,0 +1,2 @@ +export { SdcTopBar, SDC_TOOLS } from './SdcTopBar' +export type { SdcTopBarProps, SdcTool } from './SdcTopBar' diff --git a/src/index.ts b/src/index.ts index 0ba38f0..c16d954 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,17 @@ // Utilities export { cn } from './lib/utils' +// SDC suite: shared credentials (sdc_api_key / sdc_api_endpoint) +export { + getApiKey, + getEndpoint, + saveCredentials, + hasCredentials, + migrateLegacyCredentials, + testConnection, +} from './lib/credentials' +export type { TestConnectionResult } from './lib/credentials' + // Atoms export { Button } from './components/atoms/Button' export type { ButtonProps } from './components/atoms/Button' @@ -112,6 +123,12 @@ export type { DataTableProps, DataTableColumn } from './components/molecules/Dat export { TopBar } from './components/organisms/TopBar' export type { TopBarProps } from './components/organisms/TopBar' +export { SdcTopBar, SDC_TOOLS } from './components/organisms/SdcTopBar' +export type { SdcTopBarProps, SdcTool } from './components/organisms/SdcTopBar' + +export { ApiSettings } from './components/organisms/ApiSettings' +export type { ApiSettingsProps } from './components/organisms/ApiSettings' + export { SideNav, SideNavItem, SideNavGroup, SideNavDivider } from './components/organisms/SideNav' export type { SideNavProps, diff --git a/src/lib/credentials.ts b/src/lib/credentials.ts new file mode 100644 index 0000000..bdbfcdf --- /dev/null +++ b/src/lib/credentials.ts @@ -0,0 +1,65 @@ +// Suite-wide API credentials, shared across all SDC tools on one origin via localStorage. +// Enter the key once (in any tool) and every tool picks it up. + +const LS_ENDPOINT = 'sdc_api_endpoint' +const LS_KEY = 'sdc_api_key' + +export function getEndpoint(): string { + return (localStorage.getItem(LS_ENDPOINT) ?? '').replace(/\/+$/, '') +} + +export function getApiKey(): string { + return localStorage.getItem(LS_KEY) ?? '' +} + +export function saveCredentials(endpoint: string, apiKey: string): void { + localStorage.setItem(LS_ENDPOINT, endpoint.replace(/\/+$/, '')) + localStorage.setItem(LS_KEY, apiKey) +} + +export function hasCredentials(): boolean { + return !!(getEndpoint() && getApiKey()) +} + +// One-time migration: if the unified key isn't set yet but a tool's old per-app key exists, +// copy it across so existing users aren't logged out. Call once on app startup with the +// tool's legacy localStorage key names. Returns true if a migration happened. +export function migrateLegacyCredentials(legacyApiKeyName: string, legacyEndpointName?: string): boolean { + if (getApiKey()) return false + const oldKey = localStorage.getItem(legacyApiKeyName) + if (!oldKey) return false + const oldEndpoint = legacyEndpointName ? localStorage.getItem(legacyEndpointName) : null + saveCredentials((oldEndpoint ?? getEndpoint()).replace(/\/+$/, ''), oldKey) + return true +} + +export interface TestConnectionResult { + models: string[] +} + +// Generic OpenAI-compatible credential check: lists models at /v1/models. +export async function testConnection(endpoint: string, apiKey: string): Promise { + const base = endpoint.replace(/\/+$/, '') + if (!base) throw new Error('Please enter a gateway endpoint.') + if (!apiKey) throw new Error('Please enter an API key.') + + let res: Response + try { + res = await fetch(base + '/v1/models', { + headers: { Authorization: 'Bearer ' + apiKey }, + }) + } catch { + throw new Error('Could not reach the gateway. Check the endpoint URL and your network.') + } + if (res.status === 401 || res.status === 403) { + throw new Error('Authentication failed (HTTP ' + res.status + '). Check your API key.') + } + if (!res.ok) { + throw new Error('Connection failed (HTTP ' + res.status + ').') + } + const data = await res.json().catch(() => ({})) + const models: string[] = Array.isArray(data?.data) + ? data.data.map((m: { id?: string }) => m.id).filter((id: unknown): id is string => typeof id === 'string') + : [] + return { models } +} diff --git a/vite.lib.config.ts b/vite.lib.config.ts index 773e19e..231b098 100644 --- a/vite.lib.config.ts +++ b/vite.lib.config.ts @@ -34,9 +34,9 @@ export default defineConfig({ fileName: () => 'index.js', }, rollupOptions: { - // React is a peer dep — externalise it (and the automatic JSX runtime) to avoid bundling a - // second copy. Everything else (clsx, tailwind-merge, @floating-ui) is small and bundled. - external: ['react', 'react-dom', 'react/jsx-runtime'], + // React + lucide-react are peer deps (consumer apps already have them) — externalise to avoid + // bundling a second copy. Everything else (clsx, tailwind-merge, @floating-ui) is small and bundled. + external: ['react', 'react-dom', 'react/jsx-runtime', 'lucide-react'], output: { preserveModules: false }, }, sourcemap: true,