diff --git a/CHEATSHEET.md b/CHEATSHEET.md new file mode 100644 index 0000000..8214f53 --- /dev/null +++ b/CHEATSHEET.md @@ -0,0 +1,48 @@ +# Claude Code Cheat Sheet + +## Starting Up + +### Step 1 — Open Terminal +- **Mac:** `Cmd + Space` → type "Terminal" → Enter + +### Step 2 — Start the dev server (Tab 1) +```bash +cd /Users/ssutanto/AI-prototype +npm run dev +``` +Starts React frontend at **http://localhost:5173** and Express API at **http://localhost:3001**. + +### Step 3 — Open a second tab (`Cmd + T`) + +### Step 4 — Launch Claude Code (Tab 2) +```bash +cd /Users/ssutanto/AI-prototype +claude +``` + +### Step 5 — Open the app +``` +http://localhost:5173 +``` + +--- + +## Quick Reference + +| What | Command | +|---|---| +| Start everything | `npm run dev` | +| Launch Claude Code | `claude` | +| Check git status | `git status` | +| Push current branch | `git push` | +| List open PRs | `gh pr list` | +| Merge PR (squash) | `gh pr merge --squash --delete-branch` | + +--- + +## Tips + +- **Figma MCP** — open the Figma desktop app *before* starting Claude Code so the MCP server at `http://127.0.0.1:3845/mcp` is available +- **Two terminals** — keep Tab 1 running `npm run dev` at all times; use Tab 2 for Claude Code +- **gh CLI not found in Claude's shell** — if Claude can't run `gh`, run the command yourself in your terminal with `! gh ...` +- **Git push** — Claude can commit but may not have GitHub credentials; run `git push` yourself if it fails diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2503d4a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,22 @@ +# Project Guidelines + +## Design System + +This project uses **Microsoft Fluent 2 Web** as the single source of truth for all UI design. + +- **Figma file**: https://www.figma.com/design/mSjELbJg0DugPqSB0JwocX/Microsoft-Fluent-2-Web--MCP- +- **File key**: `mSjELbJg0DugPqSB0JwocX` + +### Rules for building UI + +- Always reference the Figma design system above when building or modifying any interface component +- Use the Figma MCP tools to inspect components, tokens, and styles from this file before writing code +- Match spacing, typography, colors, and component patterns from Fluent 2 — do not invent custom styles +- When in doubt about a component's appearance or behaviour, look it up in the Figma file first + +### How to use Figma MCP + +The Figma MCP server is configured at `http://127.0.0.1:3845/mcp`. Use it to: +- Inspect components by name (e.g. Button, Input, Card) +- Extract design tokens (colors, spacing, typography) +- Get accurate implementation details before writing code diff --git a/index.html b/index.html index 6b48604..2fc9a32 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,10 @@ - Database Dashboard + MoO Portal + + +
diff --git a/public/moo-styles.json b/public/moo-styles.json new file mode 100644 index 0000000..54318fa --- /dev/null +++ b/public/moo-styles.json @@ -0,0 +1,33 @@ +{ + "colorStyles": [ + { "name": "Primary/Main", "color": "#0055A4", "description": "Primary brand blue" }, + { "name": "Background/Navy", "color": "#0A1128", "description": "Main app shell" }, + { "name": "Background/Slate", "color": "#101820", "description": "Main content area" }, + { "name": "Text/Primary", "color": "#F8FAFC", "description": "Headings and body" }, + { "name": "Text/Secondary", "color": "#64748B", "description": "Labels, captions" }, + { "name": "Status/Success", "color": "#108981", "description": "Active, approved" }, + { "name": "Status/Warning", "color": "#F59E0B", "description": "Pending, required" }, + { "name": "Status/Error", "color": "#EF4444", "description": "Denied, errors" }, + { "name": "Status/Inactive", "color": "#64748B", "description": "Inactive items" }, + { "name": "Border/Default", "color": "#FFFFFF14", "description": "Card border 8% white" }, + { "name": "Border/Focus", "color": "#0055A480", "description": "Focus border 50% primary" }, + { "name": "Surface/Card", "color": "#FFFFFF0D", "description": "Card surface 5% white" }, + { "name": "Surface/Hover", "color": "#FFFFFF1A", "description": "Hover surface 10% white" }, + { "name": "Surface/Nav", "color": "#0A1128CC", "description": "Nav sidebar 80% navy" } + ], + "textStyles": [ + { "name": "Heading/H1", "fontFamily": "Inter", "fontSize": 36, "fontWeight": 700, "lineHeight": 40, "description": "Page title" }, + { "name": "Heading/H2", "fontFamily": "Inter", "fontSize": 24, "fontWeight": 600, "lineHeight": 32, "description": "Section heading" }, + { "name": "Heading/H3", "fontFamily": "Inter", "fontSize": 18, "fontWeight": 500, "lineHeight": 28, "description": "Card title" }, + { "name": "Body/Base", "fontFamily": "Inter", "fontSize": 16, "fontWeight": 400, "lineHeight": 24, "description": "Body text" }, + { "name": "Body/Small", "fontFamily": "Inter", "fontSize": 14, "fontWeight": 500, "lineHeight": 20, "description": "Labels, secondary" }, + { "name": "Body/XSmall", "fontFamily": "Inter", "fontSize": 12, "fontWeight": 400, "lineHeight": 16, "description": "Captions, metadata" }, + { "name": "Label/Badge", "fontFamily": "Inter", "fontSize": 11, "fontWeight": 600, "lineHeight": 16, "description": "Badges, tags" } + ], + "effectStyles": [ + { "name": "Elevation/Card", "type": "LAYER_BLUR", "radius": 16, "description": "Base card blur" }, + { "name": "Elevation/Hover", "type": "LAYER_BLUR", "radius": 20, "description": "Hover/active blur" }, + { "name": "Elevation/Modal", "type": "LAYER_BLUR", "radius": 24, "description": "Modal/dropdown blur" }, + { "name": "Elevation/Nav", "type": "LAYER_BLUR", "radius": 30, "description": "Nav sidebar blur" } + ] +} diff --git a/public/moo-tokens-colors.json b/public/moo-tokens-colors.json new file mode 100644 index 0000000..2179a6a --- /dev/null +++ b/public/moo-tokens-colors.json @@ -0,0 +1,30 @@ +{ + "color": { + "primary": { + "main": { "$value": "#0055A4", "$type": "color", "$description": "Primary brand blue" } + }, + "background": { + "navy": { "$value": "#0A1128", "$type": "color", "$description": "Main app shell" }, + "slate": { "$value": "#101820", "$type": "color", "$description": "Main content area" } + }, + "text": { + "primary": { "$value": "#F8FAFC", "$type": "color", "$description": "Headings and body" }, + "secondary":{ "$value": "#64748B", "$type": "color", "$description": "Labels, captions" } + }, + "status": { + "success": { "$value": "#108981", "$type": "color", "$description": "Active, approved" }, + "warning": { "$value": "#F59E0B", "$type": "color", "$description": "Pending, required" }, + "error": { "$value": "#EF4444", "$type": "color", "$description": "Denied, errors" }, + "inactive": { "$value": "#64748B", "$type": "color", "$description": "Inactive items" } + }, + "border": { + "default": { "$value": "#FFFFFF", "$type": "color", "$description": "Card border — set opacity to 8% in Figma" }, + "focus": { "$value": "#0055A4", "$type": "color", "$description": "Focus border — set opacity to 50% in Figma" } + }, + "surface": { + "card": { "$value": "#FFFFFF", "$type": "color", "$description": "Card surface — set opacity to 5% in Figma" }, + "hover": { "$value": "#FFFFFF", "$type": "color", "$description": "Hover surface — set opacity to 10% in Figma" }, + "nav": { "$value": "#0A1128", "$type": "color", "$description": "Nav sidebar — set opacity to 80% in Figma" } + } + } +} diff --git a/public/moo-tokens-test.json b/public/moo-tokens-test.json new file mode 100644 index 0000000..3d80370 --- /dev/null +++ b/public/moo-tokens-test.json @@ -0,0 +1,26 @@ +{ + "collections": [ + { + "name": "Colors", + "modes": [{ "modeId": "1:0", "name": "Default" }], + "variables": [ + { + "id": "1:1", + "name": "primary/main", + "resolvedType": "COLOR", + "valuesByMode": { + "1:0": { "r": 0, "g": 0.333, "b": 0.643, "a": 1 } + } + }, + { + "id": "1:2", + "name": "status/success", + "resolvedType": "COLOR", + "valuesByMode": { + "1:0": { "r": 0.063, "g": 0.537, "b": 0.506, "a": 1 } + } + } + ] + } + ] +} diff --git a/public/moo-tokens.json b/public/moo-tokens.json new file mode 100644 index 0000000..ab5d05e --- /dev/null +++ b/public/moo-tokens.json @@ -0,0 +1,93 @@ +{ + "color": { + "primary": { + "main": { "$value": "#0055A4", "$type": "color", "$description": "Primary brand blue" } + }, + "background": { + "navy": { "$value": "#0A1128", "$type": "color", "$description": "Main app shell" }, + "slate": { "$value": "#101820", "$type": "color", "$description": "Main content area" } + }, + "text": { + "primary": { "$value": "#F8FAFC", "$type": "color", "$description": "Headings and body" }, + "secondary":{ "$value": "#64748B", "$type": "color", "$description": "Labels, captions, hints" } + }, + "status": { + "success": { "$value": "#108981", "$type": "color", "$description": "Active, approved" }, + "warning": { "$value": "#F59E0B", "$type": "color", "$description": "Pending, required" }, + "error": { "$value": "#EF4444", "$type": "color", "$description": "Denied, errors" }, + "inactive": { "$value": "#64748B", "$type": "color", "$description": "Inactive items" } + }, + "border": { + "default": { "$value": "#FFFFFF", "$type": "color", "$description": "Card border — set opacity to 8% in Figma" }, + "focus": { "$value": "#0055A4", "$type": "color", "$description": "Focus border — set opacity to 50% in Figma" } + }, + "surface": { + "card": { "$value": "#FFFFFF", "$type": "color", "$description": "Card surface — set opacity to 5% in Figma" }, + "hover": { "$value": "#FFFFFF", "$type": "color", "$description": "Hover surface — set opacity to 10% in Figma" }, + "nav": { "$value": "#0A1128", "$type": "color", "$description": "Nav sidebar — set opacity to 80% in Figma" } + } + }, + "typography": { + "fontFamily": { + "primary": { "$value": "Inter", "$type": "string", "$description": "Primary sans-serif" } + }, + "fontWeight": { + "regular": { "$value": 400, "$type": "number", "$description": "Body text" }, + "medium": { "$value": 500, "$type": "number", "$description": "Labels, buttons" }, + "semibold": { "$value": 600, "$type": "number", "$description": "Headings, card titles" }, + "bold": { "$value": 700, "$type": "number", "$description": "Page titles, key values" } + }, + "fontSize": { + "4xl": { "$value": 36, "$type": "number", "$description": "H1 / Page title" }, + "2xl": { "$value": 24, "$type": "number", "$description": "H2 / Section heading" }, + "lg": { "$value": 18, "$type": "number", "$description": "H3 / Card title" }, + "base": { "$value": 16, "$type": "number", "$description": "Body text" }, + "sm": { "$value": 14, "$type": "number", "$description": "Labels" }, + "xs": { "$value": 12, "$type": "number", "$description": "Captions" }, + "2xs": { "$value": 11, "$type": "number", "$description": "Badges, tags" } + }, + "lineHeight": { + "tight": { "$value": 40, "$type": "number", "$description": "H1" }, + "snug": { "$value": 32, "$type": "number", "$description": "H2" }, + "normal": { "$value": 28, "$type": "number", "$description": "H3" }, + "relaxed": { "$value": 24, "$type": "number", "$description": "Body" }, + "loose": { "$value": 20, "$type": "number", "$description": "Small" } + } + }, + "spacing": { + "1": { "$value": 4, "$type": "number", "$description": "4px — extra tight" }, + "2": { "$value": 8, "$type": "number", "$description": "8px — tight" }, + "3": { "$value": 12, "$type": "number", "$description": "12px — compact" }, + "4": { "$value": 16, "$type": "number", "$description": "16px — base" }, + "6": { "$value": 24, "$type": "number", "$description": "24px — comfortable" }, + "8": { "$value": 32, "$type": "number", "$description": "32px — spacious" }, + "12": { "$value": 48, "$type": "number", "$description": "48px — section gap" } + }, + "borderRadius": { + "sm": { "$value": 4, "$type": "number", "$description": "Badges, tags" }, + "md": { "$value": 8, "$type": "number", "$description": "Buttons, inputs" }, + "lg": { "$value": 12, "$type": "number", "$description": "Cards" }, + "xl": { "$value": 16, "$type": "number", "$description": "Modals, drawers" }, + "full": { "$value": 9999, "$type": "number", "$description": "Pills, avatars" } + }, + "component": { + "button": { + "height-sm": { "$value": 32, "$type": "number" }, + "height-md": { "$value": 40, "$type": "number" }, + "height-lg": { "$value": 48, "$type": "number" }, + "padding-sm":{ "$value": 12, "$type": "number" }, + "padding-md":{ "$value": 16, "$type": "number" }, + "padding-lg":{ "$value": 24, "$type": "number" }, + "radius": { "$value": 8, "$type": "number" } + }, + "input": { + "height": { "$value": 40, "$type": "number" }, + "padding": { "$value": 12, "$type": "number" }, + "radius": { "$value": 8, "$type": "number" } + }, + "card": { + "padding": { "$value": 24, "$type": "number" }, + "radius": { "$value": 12, "$type": "number" } + } + } +} diff --git a/server/index.js b/server/index.js index fdee8c8..747dbed 100644 --- a/server/index.js +++ b/server/index.js @@ -329,6 +329,204 @@ app.get('/api/activity', (req, res) => { } }) +// ─── Figma Routes ───────────────────────────────────────────────────────────── + +// List projects in a Figma team +app.get('/api/figma/teams/:teamId/projects', async (req, res) => { + const { teamId } = req.params + const token = req.headers['x-figma-token'] + + if (!token) { + return res.status(401).json({ error: 'Missing X-Figma-Token header' }) + } + + try { + const response = await fetch(`https://api.figma.com/v1/teams/${teamId}/projects`, { + headers: { 'X-Figma-Token': token }, + }) + if (!response.ok) { + const body = await response.json().catch(() => ({})) + return res.status(response.status).json({ error: body.err || `Figma API error ${response.status}` }) + } + const data = await response.json() + res.json(data) + } catch (err) { + res.status(500).json({ error: err.message }) + } +}) + +// List files in a Figma project +app.get('/api/figma/projects/:projectId/files', async (req, res) => { + const { projectId } = req.params + const token = req.headers['x-figma-token'] + + if (!token) { + return res.status(401).json({ error: 'Missing X-Figma-Token header' }) + } + + try { + const response = await fetch(`https://api.figma.com/v1/projects/${projectId}/files`, { + headers: { 'X-Figma-Token': token }, + }) + if (!response.ok) { + const body = await response.json().catch(() => ({})) + return res.status(response.status).json({ error: body.err || `Figma API error ${response.status}` }) + } + const data = await response.json() + res.json(data) + } catch (err) { + res.status(500).json({ error: err.message }) + } +}) + +// Push variables to Figma file via Variables API +app.post('/api/figma/push-variables', async (req, res) => { + const token = req.headers['x-figma-token'] + const { fileId } = req.body + + if (!token) return res.status(401).json({ error: 'Missing X-Figma-Token header' }) + if (!fileId) return res.status(400).json({ error: 'Missing fileId in request body' }) + + // Build Figma Variables API payload from MoO tokens + const collections = [ + { action: 'CREATE', id: 'col-colors', name: 'Colors' }, + { action: 'CREATE', id: 'col-typography', name: 'Typography' }, + { action: 'CREATE', id: 'col-spacing', name: 'Spacing' }, + { action: 'CREATE', id: 'col-radius', name: 'Border Radius' }, + { action: 'CREATE', id: 'col-components', name: 'Components' }, + ] + + const modes = [ + { action: 'CREATE', id: 'mode-colors', name: 'Default', variableCollectionId: 'col-colors' }, + { action: 'CREATE', id: 'mode-typography', name: 'Default', variableCollectionId: 'col-typography' }, + { action: 'CREATE', id: 'mode-spacing', name: 'Default', variableCollectionId: 'col-spacing' }, + { action: 'CREATE', id: 'mode-radius', name: 'Default', variableCollectionId: 'col-radius' }, + { action: 'CREATE', id: 'mode-components', name: 'Default', variableCollectionId: 'col-components' }, + ] + + function hexToRgb(hex) { + const h = hex.replace('#', '') + const hasAlpha = h.length === 8 + return { + r: parseInt(h.substring(0, 2), 16) / 255, + g: parseInt(h.substring(2, 4), 16) / 255, + b: parseInt(h.substring(4, 6), 16) / 255, + a: hasAlpha ? parseInt(h.substring(6, 8), 16) / 255 : 1, + } + } + + const colorVars = [ + { name: 'primary/main', hex: '#0055A4' }, + { name: 'background/navy', hex: '#0A1128' }, + { name: 'background/slate', hex: '#101820' }, + { name: 'text/primary', hex: '#F8FAFC' }, + { name: 'text/secondary', hex: '#64748B' }, + { name: 'status/success', hex: '#108981' }, + { name: 'status/warning', hex: '#F59E0B' }, + { name: 'status/error', hex: '#EF4444' }, + { name: 'status/inactive', hex: '#64748B' }, + { name: 'border/default', hex: '#FFFFFF14' }, + { name: 'border/focus', hex: '#0055A480' }, + { name: 'surface/card', hex: '#FFFFFF0D' }, + { name: 'surface/hover', hex: '#FFFFFF1A' }, + { name: 'surface/nav', hex: '#0A1128CC' }, + ] + + const typographyVars = [ + { name: 'fontFamily/primary', value: 'Inter', type: 'STRING' }, + { name: 'fontWeight/regular', value: 400, type: 'FLOAT' }, + { name: 'fontWeight/medium', value: 500, type: 'FLOAT' }, + { name: 'fontWeight/semibold', value: 600, type: 'FLOAT' }, + { name: 'fontWeight/bold', value: 700, type: 'FLOAT' }, + { name: 'fontSize/4xl', value: 36, type: 'FLOAT' }, + { name: 'fontSize/2xl', value: 24, type: 'FLOAT' }, + { name: 'fontSize/lg', value: 18, type: 'FLOAT' }, + { name: 'fontSize/base', value: 16, type: 'FLOAT' }, + { name: 'fontSize/sm', value: 14, type: 'FLOAT' }, + { name: 'fontSize/xs', value: 12, type: 'FLOAT' }, + { name: 'lineHeight/tight', value: 40, type: 'FLOAT' }, + { name: 'lineHeight/normal', value: 28, type: 'FLOAT' }, + { name: 'lineHeight/relaxed', value: 24, type: 'FLOAT' }, + ] + + const spacingVars = [1,2,3,4,6,8,12].map((n, i) => ({ + name: `spacing/${n}`, + value: [4,8,12,16,24,32,48][i], + type: 'FLOAT', + })) + + const radiusVars = [ + { name: 'radius/sm', value: 4, type: 'FLOAT' }, + { name: 'radius/md', value: 8, type: 'FLOAT' }, + { name: 'radius/lg', value: 12, type: 'FLOAT' }, + { name: 'radius/xl', value: 16, type: 'FLOAT' }, + { name: 'radius/full', value: 9999, type: 'FLOAT' }, + ] + + const componentVars = [ + { name: 'button/height-sm', value: 32, type: 'FLOAT' }, + { name: 'button/height-md', value: 40, type: 'FLOAT' }, + { name: 'button/height-lg', value: 48, type: 'FLOAT' }, + { name: 'input/height', value: 40, type: 'FLOAT' }, + { name: 'card/padding', value: 24, type: 'FLOAT' }, + { name: 'card/radius', value: 12, type: 'FLOAT' }, + ] + + const variables = [ + ...colorVars.map((v, i) => ({ + action: 'CREATE', id: `var-color-${i}`, name: v.name, + resolvedType: 'COLOR', variableCollectionId: 'col-colors', + })), + ...typographyVars.map((v, i) => ({ + action: 'CREATE', id: `var-typo-${i}`, name: v.name, + resolvedType: v.type, variableCollectionId: 'col-typography', + })), + ...spacingVars.map((v, i) => ({ + action: 'CREATE', id: `var-space-${i}`, name: v.name, + resolvedType: 'FLOAT', variableCollectionId: 'col-spacing', + })), + ...radiusVars.map((v, i) => ({ + action: 'CREATE', id: `var-radius-${i}`, name: v.name, + resolvedType: 'FLOAT', variableCollectionId: 'col-radius', + })), + ...componentVars.map((v, i) => ({ + action: 'CREATE', id: `var-comp-${i}`, name: v.name, + resolvedType: 'FLOAT', variableCollectionId: 'col-components', + })), + ] + + const variableModeValues = [ + ...colorVars.map((v, i) => ({ + variableId: `var-color-${i}`, modeId: 'mode-colors', value: hexToRgb(v.hex), + })), + ...typographyVars.map((v, i) => ({ + variableId: `var-typo-${i}`, modeId: 'mode-typography', value: v.value, + })), + ...spacingVars.map((v, i) => ({ + variableId: `var-space-${i}`, modeId: 'mode-spacing', value: v.value, + })), + ...radiusVars.map((v, i) => ({ + variableId: `var-radius-${i}`, modeId: 'mode-radius', value: v.value, + })), + ...componentVars.map((v, i) => ({ + variableId: `var-comp-${i}`, modeId: 'mode-components', value: v.value, + })), + ] + + try { + const response = await fetch(`https://api.figma.com/v1/files/${fileId}/variables`, { + method: 'POST', + headers: { 'X-Figma-Token': token, 'Content-Type': 'application/json' }, + body: JSON.stringify({ variableCollections: collections, variableModes: modes, variables, variableModeValues }), + }) + const data = await response.json() + if (!response.ok) return res.status(response.status).json({ error: data.err || data.message || 'Figma API error' }) + res.json({ success: true, message: `Pushed ${variables.length} variables across ${collections.length} collections` }) + } catch (err) { + res.status(500).json({ error: err.message }) + } +}) + app.listen(PORT, () => { console.log(`Database Dashboard API running on http://localhost:${PORT}`) }) diff --git a/src/App.tsx b/src/App.tsx index b7c0842..271ca61 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,11 @@ import Overview from './pages/Overview' import Tables from './pages/Tables' import QueryEditor from './pages/QueryEditor' import ActivityLogPage from './pages/ActivityLog' +import FigmaFiles from './pages/FigmaFiles' +import ComponentDemo from './pages/ComponentDemo' +import MoOPortal from './pages/MoOPortal' +import AuthWireframes from './pages/AuthWireframes' +import LeaveWizard from './pages/LeaveWizard' import { type Page } from './types' import { useApi } from './hooks/useApi' import { type TableInfo } from './types' @@ -18,9 +23,25 @@ export default function App() { case 'tables': return case 'query': return case 'activity': return + case 'figma': return + case 'components': return + case 'moo': return + case 'auth': return } } + if (page === 'auth') { + return + } + + if (page === 'leave') { + return setPage('overview')} /> + } + + if (page === 'moo') { + return setPage('overview')} /> + } + return (
= { + 20: 'w-5 h-5 text-[8px]', 24: 'w-6 h-6 text-[9px]', 28: 'w-7 h-7 text-[10px]', + 32: 'w-8 h-8 text-xs', 36: 'w-9 h-9 text-xs', 40: 'w-10 h-10 text-sm', + 48: 'w-12 h-12 text-sm', 56: 'w-14 h-14 text-base', 64: 'w-16 h-16 text-lg', + 72: 'w-[72px] h-[72px] text-xl', 96: 'w-24 h-24 text-2xl', 120: 'w-[120px] h-[120px] text-3xl', +} + +const colors = ['#0078D4','#107C10','#8764B8','#D83B01','#038387','#CA5010','#C239B3','#0099BC'] + +function getColor(name: string) { + let hash = 0 + for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash) + return colors[Math.abs(hash) % colors.length] +} + +function initials(name: string) { + const parts = name.trim().split(/\s+/) + return parts.length >= 2 ? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() : name.slice(0, 2).toUpperCase() +} + +export default function Avatar({ name = '', image, size = 32, shape = 'circular', color = 'auto' }: AvatarProps) { + const bg = color === 'brand' ? '#0078D4' : getColor(name || 'U') + const shapeClass = shape === 'circular' ? 'rounded-full' : 'rounded' + return ( +
+ {image + ? {name} + : {name ? initials(name) : '?'} + } +
+ ) +} diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx new file mode 100644 index 0000000..32bb5e9 --- /dev/null +++ b/src/components/Badge.tsx @@ -0,0 +1,38 @@ +import { type ReactNode } from 'react' + +type BadgeColor = 'brand' | 'success' | 'warning' | 'danger' | 'informative' | 'subtle' +type BadgeSize = 'small' | 'medium' | 'large' +type BadgeAppearance = 'filled' | 'ghost' | 'outline' | 'tint' + +interface BadgeProps { + children: ReactNode + color?: BadgeColor + size?: BadgeSize + appearance?: BadgeAppearance + shape?: 'rounded' | 'circular' | 'square' +} + +const colorMap: Record> = { + brand: { filled: 'bg-[#0078D4] text-white', ghost: 'text-[#0078D4]', outline: 'border border-[#0078D4] text-[#0078D4]', tint: 'bg-[#DEECF9] text-[#0078D4]' }, + success: { filled: 'bg-[#107C10] text-white', ghost: 'text-[#107C10]', outline: 'border border-[#107C10] text-[#107C10]', tint: 'bg-[#DFF6DD] text-[#107C10]' }, + warning: { filled: 'bg-[#D83B01] text-white', ghost: 'text-[#D83B01]', outline: 'border border-[#D83B01] text-[#D83B01]', tint: 'bg-[#FED9CC] text-[#D83B01]' }, + danger: { filled: 'bg-[#A4262C] text-white', ghost: 'text-[#A4262C]', outline: 'border border-[#A4262C] text-[#A4262C]', tint: 'bg-[#FDE7E9] text-[#A4262C]' }, + informative: { filled: 'bg-[#0027B4] text-white', ghost: 'text-[#0027B4]', outline: 'border border-[#0027B4] text-[#0027B4]', tint: 'bg-[#EEF0FB] text-[#0027B4]' }, + subtle: { filled: 'bg-[#E1DFDD] text-[#201F1E]', ghost: 'text-[#605E5C]', outline: 'border border-[#8A8886] text-[#605E5C]', tint: 'bg-[#F3F2F1] text-[#605E5C]' }, +} + +const sizeMap: Record = { + small: 'text-[10px] px-1 h-4 min-w-[16px]', + medium: 'text-xs px-1.5 h-5 min-w-[20px]', + large: 'text-sm px-2 h-6 min-w-[24px]', +} + +const shapeMap = { rounded: 'rounded', circular: 'rounded-full', square: 'rounded-none' } + +export default function Badge({ children, color = 'brand', size = 'medium', appearance = 'filled', shape = 'rounded' }: BadgeProps) { + return ( + + {children} + + ) +} diff --git a/src/components/Button.tsx b/src/components/Button.tsx new file mode 100644 index 0000000..9ef78b5 --- /dev/null +++ b/src/components/Button.tsx @@ -0,0 +1,62 @@ +import { type ButtonHTMLAttributes, type ReactNode } from 'react' + +type Variant = 'primary' | 'default' | 'outline' | 'subtle' | 'transparent' +type Size = 'small' | 'medium' | 'large' + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: Variant + size?: Size + icon?: ReactNode + iconPosition?: 'before' | 'after' + children?: ReactNode +} + +const variantStyles: Record = { + primary: + 'bg-[#0078D4] text-white hover:bg-[#106EBE] active:bg-[#005A9E] border border-transparent focus-visible:outline-[#0078D4]', + default: + 'bg-white text-[#201F1E] hover:bg-[#F3F2F1] active:bg-[#EDEBE9] border border-[#8A8886] focus-visible:outline-[#0078D4]', + outline: + 'bg-transparent text-[#0078D4] hover:bg-[#F3F2F1] active:bg-[#EDEBE9] border border-[#0078D4] focus-visible:outline-[#0078D4]', + subtle: + 'bg-transparent text-[#201F1E] hover:bg-[#F3F2F1] active:bg-[#EDEBE9] border border-transparent focus-visible:outline-[#0078D4]', + transparent: + 'bg-transparent text-[#0078D4] hover:bg-transparent active:bg-transparent underline border border-transparent focus-visible:outline-[#0078D4]', +} + +const sizeStyles: Record = { + small: 'h-6 px-2 text-xs gap-1 rounded', + medium: 'h-8 px-3 text-sm gap-1.5 rounded', + large: 'h-10 px-4 text-sm gap-2 rounded', +} + +export default function Button({ + variant = 'default', + size = 'medium', + icon, + iconPosition = 'before', + children, + disabled, + className = '', + ...props +}: ButtonProps) { + return ( + + ) +} diff --git a/src/components/Checkbox.tsx b/src/components/Checkbox.tsx new file mode 100644 index 0000000..cf25f7b --- /dev/null +++ b/src/components/Checkbox.tsx @@ -0,0 +1,42 @@ +import { type InputHTMLAttributes } from 'react' + +interface CheckboxProps extends Omit, 'type'> { + label?: string + hint?: string +} + +export default function Checkbox({ label, hint, disabled, className = '', id, ...props }: CheckboxProps) { + const inputId = id || label?.toLowerCase().replace(/\s+/g, '-') + return ( + + ) +} diff --git a/src/components/Input.tsx b/src/components/Input.tsx new file mode 100644 index 0000000..0dffaa7 --- /dev/null +++ b/src/components/Input.tsx @@ -0,0 +1,36 @@ +import { type InputHTMLAttributes, type ReactNode } from 'react' + +interface InputProps extends InputHTMLAttributes { + label?: string + hint?: string + error?: string + contentBefore?: ReactNode + contentAfter?: ReactNode +} + +export default function Input({ label, hint, error, contentBefore, contentAfter, className = '', id, ...props }: InputProps) { + const inputId = id || label?.toLowerCase().replace(/\s+/g, '-') + return ( +
+ {label && ( + + )} +
+ {contentBefore && {contentBefore}} + + {contentAfter && {contentAfter}} +
+ {error && {error}} + {!error && hint && {hint}} +
+ ) +} diff --git a/src/components/MessageBar.tsx b/src/components/MessageBar.tsx new file mode 100644 index 0000000..ed2bb13 --- /dev/null +++ b/src/components/MessageBar.tsx @@ -0,0 +1,35 @@ +import { type ReactNode, useState } from 'react' + +type Intent = 'info' | 'success' | 'warning' | 'error' + +interface MessageBarProps { + intent?: Intent + children: ReactNode + title?: string + dismissible?: boolean +} + +const config: Record = { + info: { bar: 'bg-[#EEF0FB] border-[#0027B4]', icon: 'text-[#0027B4]', emoji: 'ℹ' }, + success: { bar: 'bg-[#DFF6DD] border-[#107C10]', icon: 'text-[#107C10]', emoji: '✓' }, + warning: { bar: 'bg-[#FFF4CE] border-[#D83B01]', icon: 'text-[#D83B01]', emoji: '⚠' }, + error: { bar: 'bg-[#FDE7E9] border-[#A4262C]', icon: 'text-[#A4262C]', emoji: '✕' }, +} + +export default function MessageBar({ intent = 'info', children, title, dismissible = false }: MessageBarProps) { + const [dismissed, setDismissed] = useState(false) + if (dismissed) return null + const { bar, icon, emoji } = config[intent] + return ( +
+ {emoji} +
+ {title &&
{title}
} +
{children}
+
+ {dismissible && ( + + )} +
+ ) +} diff --git a/src/components/ProgressBar.tsx b/src/components/ProgressBar.tsx new file mode 100644 index 0000000..8c52bd5 --- /dev/null +++ b/src/components/ProgressBar.tsx @@ -0,0 +1,43 @@ +interface ProgressBarProps { + value?: number // 0–100, undefined = indeterminate + thickness?: 'medium' | 'large' + color?: 'brand' | 'success' | 'warning' | 'error' + label?: string + hint?: string +} + +const colorMap = { + brand: 'bg-[#0078D4]', + success: 'bg-[#107C10]', + warning: 'bg-[#D83B01]', + error: 'bg-[#A4262C]', +} + +const thicknessMap = { medium: 'h-1', large: 'h-2' } + +export default function ProgressBar({ value, thickness = 'medium', color = 'brand', label, hint }: ProgressBarProps) { + const indeterminate = value === undefined + return ( +
+ {(label || hint) && ( +
+ {label && {label}} + {hint && {hint}} +
+ )} +
+ {indeterminate ? ( +
+ ) : ( +
+ )} +
+ {!indeterminate && value !== undefined && ( + {Math.round(value)}% + )} + +
+ ) +} diff --git a/src/components/Select.tsx b/src/components/Select.tsx new file mode 100644 index 0000000..c8c6007 --- /dev/null +++ b/src/components/Select.tsx @@ -0,0 +1,33 @@ +import { type SelectHTMLAttributes } from 'react' + +interface SelectOption { label: string; value: string } + +interface SelectProps extends SelectHTMLAttributes { + label?: string + hint?: string + error?: string + options: SelectOption[] + placeholder?: string +} + +export default function Select({ label, hint, error, options, placeholder, id, className = '', ...props }: SelectProps) { + const selectId = id || label?.toLowerCase().replace(/\s+/g, '-') + return ( +
+ {label && } +
+ + +
+ {error && {error}} + {!error && hint && {hint}} +
+ ) +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 9b42a13..35f1d72 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -11,6 +11,11 @@ const navItems: { id: Page; label: string; icon: string }[] = [ { id: 'tables', label: 'Tables', icon: '⊞' }, { id: 'query', label: 'Query Editor', icon: '❯_' }, { id: 'activity', label: 'Activity Log', icon: '≡' }, + { id: 'figma', label: 'Figma Files', icon: '✦' }, + { id: 'components', label: 'Components', icon: '⬡' }, + { id: 'moo', label: 'MoO Portal', icon: 'M' }, + { id: 'auth', label: 'Auth Wireframes', icon: '🔑' }, + { id: 'leave', label: 'Leave Wizard', icon: '📋' }, ] export default function Sidebar({ activePage, onNavigate, tableCount }: Props) { diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx new file mode 100644 index 0000000..4dad6fc --- /dev/null +++ b/src/components/Spinner.tsx @@ -0,0 +1,32 @@ +type SpinnerSize = 'tiny' | 'extra-small' | 'small' | 'medium' | 'large' | 'extra-large' | 'huge' + +interface SpinnerProps { + size?: SpinnerSize + label?: string + labelPosition?: 'above' | 'below' | 'before' | 'after' +} + +const sizeMap: Record = { + 'tiny': 'w-2 h-2 border-[1.5px]', + 'extra-small': 'w-3 h-3 border-2', + 'small': 'w-4 h-4 border-2', + 'medium': 'w-5 h-5 border-2', + 'large': 'w-8 h-8 border-[3px]', + 'extra-large': 'w-12 h-12 border-4', + 'huge': 'w-16 h-16 border-4', +} + +export default function Spinner({ size = 'medium', label, labelPosition = 'after' }: SpinnerProps) { + const spinner = ( + + ) + if (!label) return spinner + const isVertical = labelPosition === 'above' || labelPosition === 'below' + return ( +
+ {(labelPosition === 'above' || labelPosition === 'before') && {label}} + {spinner} + {(labelPosition === 'below' || labelPosition === 'after') && {label}} +
+ ) +} diff --git a/src/components/Tabs.tsx b/src/components/Tabs.tsx new file mode 100644 index 0000000..9d67350 --- /dev/null +++ b/src/components/Tabs.tsx @@ -0,0 +1,39 @@ +import { type ReactNode, useState } from 'react' + +interface Tab { + id: string + label: string + icon?: ReactNode + content: ReactNode +} + +interface TabsProps { + tabs: Tab[] + defaultTab?: string + size?: 'small' | 'medium' | 'large' +} + +const sizeMap = { small: 'text-xs px-2 py-1.5', medium: 'text-sm px-3 py-2', large: 'text-base px-4 py-2.5' } + +export default function Tabs({ tabs, defaultTab, size = 'medium' }: TabsProps) { + const [active, setActive] = useState(defaultTab || tabs[0]?.id) + const activeTab = tabs.find(t => t.id === active) + return ( +
+
+ {tabs.map(tab => ( + + ))} +
+
{activeTab?.content}
+
+ ) +} diff --git a/src/components/Tag.tsx b/src/components/Tag.tsx new file mode 100644 index 0000000..0a2f45e --- /dev/null +++ b/src/components/Tag.tsx @@ -0,0 +1,30 @@ +import { type ReactNode } from 'react' + +interface TagProps { + children: ReactNode + onDismiss?: () => void + size?: 'small' | 'medium' | 'large' + appearance?: 'filled' | 'outline' | 'brand' + disabled?: boolean +} + +const sizeMap = { small: 'h-5 text-xs px-1.5 gap-1', medium: 'h-6 text-xs px-2 gap-1.5', large: 'h-8 text-sm px-3 gap-2' } +const appearanceMap = { + filled: 'bg-[#E1DFDD] text-[#201F1E] border border-transparent', + outline: 'bg-white text-[#201F1E] border border-[#8A8886]', + brand: 'bg-[#DEECF9] text-[#0078D4] border border-transparent', +} + +export default function Tag({ children, onDismiss, size = 'medium', appearance = 'filled', disabled }: TagProps) { + return ( + + {children} + {onDismiss && ( + + )} + + ) +} diff --git a/src/components/Toggle.tsx b/src/components/Toggle.tsx new file mode 100644 index 0000000..b9a4ff4 --- /dev/null +++ b/src/components/Toggle.tsx @@ -0,0 +1,21 @@ +import { type InputHTMLAttributes } from 'react' + +interface ToggleProps extends Omit, 'type'> { + label?: string + labelPosition?: 'before' | 'after' +} + +export default function Toggle({ label, labelPosition = 'after', disabled, id, ...props }: ToggleProps) { + const inputId = id || label?.toLowerCase().replace(/\s+/g, '-') + return ( +