From 7e4fe83d0e1c91637f7529ee0441b27f2a301e98 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 23:10:42 +0000 Subject: [PATCH 1/7] Add Figma files browser page Adds a new Figma Files page to the dashboard that lets users enter their Figma Personal Access Token and Team ID to browse team projects and list files within each project, with thumbnail previews and links to open files. - Server: two new proxy endpoints for Figma REST API (team projects, project files) - Frontend: FigmaFiles page component with project/file listing UI - Navigation: Figma Files entry added to sidebar https://claude.ai/code/session_017XFr4va8CC4YQSf2AcmMVy --- server/index.js | 50 +++++++++++ src/App.tsx | 2 + src/components/Sidebar.tsx | 1 + src/pages/FigmaFiles.tsx | 179 +++++++++++++++++++++++++++++++++++++ src/types/index.ts | 22 ++++- 5 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 src/pages/FigmaFiles.tsx diff --git a/server/index.js b/server/index.js index fdee8c8..df69c9f 100644 --- a/server/index.js +++ b/server/index.js @@ -329,6 +329,56 @@ 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 }) + } +}) + 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..11314cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ 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 { type Page } from './types' import { useApi } from './hooks/useApi' import { type TableInfo } from './types' @@ -18,6 +19,7 @@ export default function App() { case 'tables': return case 'query': return case 'activity': return + case 'figma': return } } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 9b42a13..e2a5a40 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -11,6 +11,7 @@ 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: '✦' }, ] export default function Sidebar({ activePage, onNavigate, tableCount }: Props) { diff --git a/src/pages/FigmaFiles.tsx b/src/pages/FigmaFiles.tsx new file mode 100644 index 0000000..a978151 --- /dev/null +++ b/src/pages/FigmaFiles.tsx @@ -0,0 +1,179 @@ +import { useState } from 'react' +import { type FigmaProject, type FigmaFile } from '../types' + +const API_BASE = 'http://localhost:3001' + +export default function FigmaFiles() { + const [token, setToken] = useState('') + const [teamId, setTeamId] = useState('') + const [projects, setProjects] = useState([]) + const [selectedProject, setSelectedProject] = useState(null) + const [files, setFiles] = useState([]) + const [loadingProjects, setLoadingProjects] = useState(false) + const [loadingFiles, setLoadingFiles] = useState(false) + const [error, setError] = useState(null) + + async function fetchProjects() { + if (!token || !teamId) return + setError(null) + setProjects([]) + setFiles([]) + setSelectedProject(null) + setLoadingProjects(true) + try { + const res = await fetch(`${API_BASE}/api/figma/teams/${teamId}/projects`, { + headers: { 'X-Figma-Token': token }, + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || `Error ${res.status}`) + setProjects(data.projects || []) + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + } finally { + setLoadingProjects(false) + } + } + + async function fetchFiles(project: FigmaProject) { + setSelectedProject(project) + setFiles([]) + setError(null) + setLoadingFiles(true) + try { + const res = await fetch(`${API_BASE}/api/figma/projects/${project.id}/files`, { + headers: { 'X-Figma-Token': token }, + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || `Error ${res.status}`) + setFiles(data.files || []) + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + } finally { + setLoadingFiles(false) + } + } + + return ( +
+
+

Figma Files

+

Browse your Figma team projects and files

+
+ + {/* Credentials */} +
+

Connection

+
+
+ + setToken(e.target.value)} + placeholder="figd_..." + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-blue-500" + /> +
+
+ + setTeamId(e.target.value)} + placeholder="123456789" + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-blue-500" + /> +
+
+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* Projects */} + {projects.length > 0 && ( +
+

+ Projects ({projects.length}) +

+
+ {projects.map(project => ( + + ))} +
+
+ )} + + {/* Files */} + {selectedProject && ( +
+

+ Files in {selectedProject.name} + {!loadingFiles && ( + ({files.length}) + )} +

+ + {loadingFiles && ( +
Loading files…
+ )} + + {!loadingFiles && files.length === 0 && ( +
No files found in this project.
+ )} + + {!loadingFiles && files.length > 0 && ( + + )} +
+ )} +
+ ) +} diff --git a/src/types/index.ts b/src/types/index.ts index 387a09d..e2cfb3a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -44,4 +44,24 @@ export interface ActivityLog { created_at: string } -export type Page = 'overview' | 'tables' | 'query' | 'activity' +export type Page = 'overview' | 'tables' | 'query' | 'activity' | 'figma' + +export interface FigmaProject { + id: string + name: string +} + +export interface FigmaFile { + key: string + name: string + thumbnail_url: string + last_modified: string +} + +export interface FigmaProjectsResponse { + projects: FigmaProject[] +} + +export interface FigmaFilesResponse { + files: FigmaFile[] +} From 368cf3cf4a4a779bb6eae018242ca148487635e5 Mon Sep 17 00:00:00 2001 From: ssutanto Date: Sun, 22 Mar 2026 19:01:10 -0500 Subject: [PATCH 2/7] feat: implement MoO Portal design system and Fluent 2 components - Add MoO Portal page with dark navy glassmorphism design (Overview, Benefits, Claims) - Implement full Fluent 2 component library: Button, Input, Select, Checkbox, Toggle, Badge, Avatar, Spinner, ProgressBar, Tag, MessageBar, Tabs - Add ComponentDemo page showcasing all Fluent 2 components - Update FigmaFiles page to support direct Project ID browsing - Add CLAUDE.md referencing Microsoft Fluent 2 as design system source of truth - Load Inter font for MoO Portal typography Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 22 ++ index.html | 5 +- src/App.tsx | 4 + src/components/Avatar.tsx | 43 ++++ src/components/Badge.tsx | 38 ++++ src/components/Button.tsx | 62 ++++++ src/components/Checkbox.tsx | 42 ++++ src/components/Input.tsx | 36 +++ src/components/MessageBar.tsx | 35 +++ src/components/ProgressBar.tsx | 43 ++++ src/components/Select.tsx | 33 +++ src/components/Sidebar.tsx | 2 + src/components/Spinner.tsx | 32 +++ src/components/Tabs.tsx | 39 ++++ src/components/Tag.tsx | 30 +++ src/components/Toggle.tsx | 21 ++ src/pages/ComponentDemo.tsx | 199 +++++++++++++++++ src/pages/FigmaFiles.tsx | 91 ++++++-- src/pages/MoOPortal.tsx | 385 +++++++++++++++++++++++++++++++++ src/types/index.ts | 2 +- 20 files changed, 1146 insertions(+), 18 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/components/Avatar.tsx create mode 100644 src/components/Badge.tsx create mode 100644 src/components/Button.tsx create mode 100644 src/components/Checkbox.tsx create mode 100644 src/components/Input.tsx create mode 100644 src/components/MessageBar.tsx create mode 100644 src/components/ProgressBar.tsx create mode 100644 src/components/Select.tsx create mode 100644 src/components/Spinner.tsx create mode 100644 src/components/Tabs.tsx create mode 100644 src/components/Tag.tsx create mode 100644 src/components/Toggle.tsx create mode 100644 src/pages/ComponentDemo.tsx create mode 100644 src/pages/MoOPortal.tsx 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/src/App.tsx b/src/App.tsx index 11314cc..de3345c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,8 @@ 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 { type Page } from './types' import { useApi } from './hooks/useApi' import { type TableInfo } from './types' @@ -20,6 +22,8 @@ export default function App() { case 'query': return case 'activity': return case 'figma': return + case 'components': return + case 'moo': return } } diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx new file mode 100644 index 0000000..1ae9e40 --- /dev/null +++ b/src/components/Avatar.tsx @@ -0,0 +1,43 @@ +type AvatarSize = 20 | 24 | 28 | 32 | 36 | 40 | 48 | 56 | 64 | 72 | 96 | 120 + +interface AvatarProps { + name?: string + image?: string + size?: AvatarSize + shape?: 'circular' | 'square' + color?: 'brand' | 'auto' +} + +const sizeMap: Record = { + 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 e2a5a40..38170a4 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -12,6 +12,8 @@ const navItems: { id: Page; label: string; icon: string }[] = [ { 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' }, ] 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 ( +