Skip to content

Commit 17fb3ee

Browse files
committed
refactor: port utilities to contract package and add reusable UI components
1 parent 9e80c9c commit 17fb3ee

17 files changed

Lines changed: 230 additions & 136 deletions

apps/web/src/components/auth/EmailForm.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useState } from 'react'
44
import Link from 'next/link'
55
import { authClient } from '@/lib/auth-client'
66
import { isValidEmail } from '@/lib/validation'
7+
import ErrorMessage from '@/components/ui/ErrorMessage'
78

89
interface EmailFormProps {
910
heading: string
@@ -86,7 +87,7 @@ export default function EmailForm({
8687
disabled={loading}
8788
/>
8889

89-
{error && <p className="auth-error" role="alert">{error}</p>}
90+
{error && <ErrorMessage message={error} className="auth-error" role="alert" />}
9091

9192
<button type="submit" className="auth-button" disabled={loading || !email.trim()}>
9293
{loading ? 'Sending...' : buttonText}

apps/web/src/components/auth/VerifyOtpForm.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useState, useRef, useEffect } from 'react'
44
import { authClient } from '@/lib/auth-client'
5+
import ErrorMessage from '@/components/ui/ErrorMessage'
56

67
const OTP_LENGTH = 6
78

@@ -156,7 +157,7 @@ export default function VerifyOtpForm() {
156157
))}
157158
</div>
158159

159-
{error && <p className="auth-error" role="alert">{error}</p>}
160+
{error && <ErrorMessage message={error} className="auth-error" role="alert" />}
160161
{resent && <p className="auth-success" role="status">Code resent</p>}
161162

162163
<button type="submit" className="auth-button" disabled={loading || !isComplete}>

apps/web/src/components/dashboard/ApiKeyManager.tsx

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
import { useState, useEffect } from 'react'
44
import { authClient } from '@/lib/auth-client'
55
import { formatShortDate } from '@/lib/format'
6+
import CopyButton from '@/components/ui/CopyButton'
7+
import EmptyState from '@/components/ui/EmptyState'
8+
import ErrorMessage from '@/components/ui/ErrorMessage'
69

710
interface ApiKey {
811
id: string
@@ -19,7 +22,6 @@ export default function ApiKeyManager() {
1922
const [newKeyName, setNewKeyName] = useState('')
2023
const [showCreate, setShowCreate] = useState(false)
2124
const [newKeyValue, setNewKeyValue] = useState<string | null>(null)
22-
const [copied, setCopied] = useState(false)
2325
const [revoking, setRevoking] = useState<Set<string>>(new Set())
2426

2527
useEffect(() => {
@@ -91,13 +93,6 @@ export default function ApiKeyManager() {
9193
}
9294
}
9395

94-
async function handleCopy() {
95-
if (!newKeyValue) return
96-
await navigator.clipboard.writeText(newKeyValue)
97-
setCopied(true)
98-
setTimeout(() => setCopied(false), 2000)
99-
}
100-
10196
return (
10297
<div>
10398
<div className="dash-page-header">
@@ -113,7 +108,7 @@ export default function ApiKeyManager() {
113108
</button>
114109
</div>
115110

116-
{error && <p className="dash-error">{error}</p>}
111+
{error && <ErrorMessage message={error} />}
117112

118113
{newKeyValue && (
119114
<div className="dash-key-reveal">
@@ -122,9 +117,7 @@ export default function ApiKeyManager() {
122117
</p>
123118
<div className="dash-key-reveal-row">
124119
<code className="dash-key-reveal-value">{newKeyValue}</code>
125-
<button className="dash-action-btn" onClick={handleCopy}>
126-
{copied ? 'Copied' : 'Copy'}
127-
</button>
120+
<CopyButton text={newKeyValue} />
128121
</div>
129122
<button
130123
className="dash-link-btn"
@@ -160,11 +153,9 @@ export default function ApiKeyManager() {
160153
)}
161154

162155
{loading ? (
163-
<div className="dash-empty">Loading API keys...</div>
156+
<EmptyState message="Loading API keys..." />
164157
) : keys.length === 0 ? (
165-
<div className="dash-empty">
166-
No API keys yet. Create one to authenticate SDK and CLI requests.
167-
</div>
158+
<EmptyState message="No API keys yet. Create one to authenticate SDK and CLI requests." />
168159
) : (
169160
<div className="dash-table-wrap">
170161
<table className="dash-table">

apps/web/src/components/dashboard/OrgSettings.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import { useState, useEffect } from 'react'
44
import { authClient } from '@/lib/auth-client'
5+
import EmptyState from '@/components/ui/EmptyState'
6+
import ErrorMessage from '@/components/ui/ErrorMessage'
57

68
interface OrgData {
79
id: string
@@ -133,7 +135,7 @@ export default function OrgSettings() {
133135
}
134136

135137
if (loading) {
136-
return <div className="dash-empty">Loading organization settings...</div>
138+
return <EmptyState message="Loading organization settings..." />
137139
}
138140

139141
if (!org) {
@@ -142,9 +144,7 @@ export default function OrgSettings() {
142144
<div className="dash-page-header">
143145
<h1 className="dash-page-title">Settings</h1>
144146
</div>
145-
<div className="dash-empty">
146-
No organization found. You may need to create or join one.
147-
</div>
147+
<EmptyState message="No organization found. You may need to create or join one." />
148148
</div>
149149
)
150150
}
@@ -155,7 +155,7 @@ export default function OrgSettings() {
155155
<h1 className="dash-page-title">Settings</h1>
156156
</div>
157157

158-
{error && <p className="dash-error">{error}</p>}
158+
{error && <ErrorMessage message={error} />}
159159

160160
{/* Org name */}
161161
<section className="dash-section">

apps/web/src/components/dashboard/SandboxList.tsx

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,11 @@
33
import { useState, useEffect, useCallback } from 'react'
44
import { apiFetch } from '@/lib/api'
55
import { formatRelativeTime } from '@/lib/format'
6+
import StatusBadge from '@/components/ui/StatusBadge'
7+
import EmptyState from '@/components/ui/EmptyState'
8+
import ErrorMessage from '@/components/ui/ErrorMessage'
69
import type { SandboxSummary, ListSandboxesResponse, SandboxStatus } from '@sandchest/contract'
710

8-
const STATUS_COLORS: Record<SandboxStatus, string> = {
9-
queued: 'var(--color-text-weak)',
10-
provisioning: 'hsl(40, 80%, 60%)',
11-
running: 'hsl(140, 60%, 50%)',
12-
stopping: 'hsl(40, 80%, 60%)',
13-
stopped: 'var(--color-text-weak)',
14-
failed: 'hsl(0, 70%, 60%)',
15-
deleted: 'var(--color-text-weak)',
16-
}
17-
1811
const FILTER_OPTIONS: Array<{ label: string; value: SandboxStatus | '' }> = [
1912
{ label: 'All', value: '' },
2013
{ label: 'Running', value: 'running' },
@@ -101,14 +94,14 @@ export default function SandboxList() {
10194
))}
10295
</div>
10396

104-
{error && <p className="dash-error">{error}</p>}
97+
{error && <ErrorMessage message={error} />}
10598

10699
{loading ? (
107-
<div className="dash-empty">Loading sandboxes...</div>
100+
<EmptyState message="Loading sandboxes..." />
108101
) : sandboxes.length === 0 ? (
109-
<div className="dash-empty">
110-
{statusFilter ? `No ${statusFilter} sandboxes found.` : 'No sandboxes yet. Create one using the SDK or CLI.'}
111-
</div>
102+
<EmptyState
103+
message={statusFilter ? `No ${statusFilter} sandboxes found.` : 'No sandboxes yet. Create one using the SDK or CLI.'}
104+
/>
112105
) : (
113106
<>
114107
<div className="dash-table-wrap">
@@ -137,12 +130,7 @@ export default function SandboxList() {
137130
</a>
138131
</td>
139132
<td>
140-
<span
141-
className="dash-status"
142-
style={{ color: STATUS_COLORS[sb.status] }}
143-
>
144-
{sb.status}
145-
</span>
133+
<StatusBadge status={sb.status} className="dash-status" />
146134
</td>
147135
<td className="dash-text-weak">{sb.image}</td>
148136
<td className="dash-text-weak">{sb.profile}</td>

apps/web/src/components/replay/ReplayViewer.tsx

Lines changed: 16 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,16 @@
33
import { useState, useEffect, useCallback } from 'react'
44
import Link from 'next/link'
55
import { apiFetch } from '@/lib/api'
6-
import { formatRelativeTime, formatDuration, formatCmd } from '@/lib/format'
6+
import { formatRelativeTime, formatDuration, formatCmd, formatBytes } from '@/lib/format'
7+
import StatusBadge from '@/components/ui/StatusBadge'
8+
import EmptyState from '@/components/ui/EmptyState'
79
import type {
810
ReplayBundle,
911
ReplayExec,
1012
ReplayArtifact,
1113
ExecOutputEntry,
1214
} from '@sandchest/contract'
1315

14-
const STATUS_COLORS: Record<string, string> = {
15-
queued: 'var(--color-text-weak)',
16-
provisioning: 'hsl(40, 80%, 60%)',
17-
running: 'hsl(140, 60%, 50%)',
18-
stopping: 'hsl(40, 80%, 60%)',
19-
stopped: 'var(--color-text-weak)',
20-
failed: 'hsl(0, 70%, 60%)',
21-
deleted: 'var(--color-text-weak)',
22-
in_progress: 'hsl(140, 60%, 50%)',
23-
complete: 'var(--color-text-weak)',
24-
}
25-
2616
function ExecCard({ exec }: { exec: ReplayExec }) {
2717
const [expanded, setExpanded] = useState(false)
2818
const [output, setOutput] = useState<ExecOutputEntry[] | null>(null)
@@ -106,8 +96,7 @@ function ExecCard({ exec }: { exec: ReplayExec }) {
10696
{exec.resource_usage && (
10797
<div className="replay-exec-resources">
10898
CPU: {exec.resource_usage.cpu_ms}ms | Memory:{' '}
109-
{(exec.resource_usage.peak_memory_bytes / 1024 / 1024).toFixed(1)}
110-
MB
99+
{formatBytes(exec.resource_usage.peak_memory_bytes)}
111100
</div>
112101
)}
113102
</div>
@@ -117,13 +106,6 @@ function ExecCard({ exec }: { exec: ReplayExec }) {
117106
}
118107

119108
function ArtifactRow({ artifact }: { artifact: ReplayArtifact }) {
120-
const sizeStr =
121-
artifact.bytes < 1024
122-
? `${artifact.bytes}B`
123-
: artifact.bytes < 1048576
124-
? `${(artifact.bytes / 1024).toFixed(1)}KB`
125-
: `${(artifact.bytes / 1048576).toFixed(1)}MB`
126-
127109
return (
128110
<tr>
129111
<td>
@@ -136,7 +118,7 @@ function ArtifactRow({ artifact }: { artifact: ReplayArtifact }) {
136118
</a>
137119
</td>
138120
<td className="replay-text-weak">{artifact.mime}</td>
139-
<td className="replay-text-weak">{sizeStr}</td>
121+
<td className="replay-text-weak">{formatBytes(artifact.bytes)}</td>
140122
</tr>
141123
)
142124
}
@@ -177,7 +159,7 @@ export default function ReplayViewer({ sandboxId }: ReplayViewerProps) {
177159
if (loading) {
178160
return (
179161
<div className="replay-container">
180-
<div className="replay-loading">Loading replay...</div>
162+
<EmptyState message="Loading replay..." className="replay-loading" />
181163
</div>
182164
)
183165
}
@@ -210,12 +192,11 @@ export default function ReplayViewer({ sandboxId }: ReplayViewerProps) {
210192
<span className="replay-sandbox-id">{bundle.sandbox_id}</span>
211193
</div>
212194
<div className="replay-header-right">
213-
<span
195+
<StatusBadge
196+
status={bundle.status}
197+
label={bundle.status === 'in_progress' ? 'live' : 'complete'}
214198
className="replay-status"
215-
style={{ color: STATUS_COLORS[bundle.status] ?? 'var(--color-text)' }}
216-
>
217-
{bundle.status === 'in_progress' ? 'live' : 'complete'}
218-
</span>
199+
/>
219200
</div>
220201
</header>
221202

@@ -283,7 +264,7 @@ export default function ReplayViewer({ sandboxId }: ReplayViewerProps) {
283264
{activeTab === 'execs' && (
284265
<div className="replay-exec-list">
285266
{bundle.execs.length === 0 ? (
286-
<div className="replay-empty">No executions recorded.</div>
267+
<EmptyState message="No executions recorded." className="replay-empty" />
287268
) : (
288269
bundle.execs.map((exec) => (
289270
<ExecCard key={exec.exec_id} exec={exec} />
@@ -295,7 +276,7 @@ export default function ReplayViewer({ sandboxId }: ReplayViewerProps) {
295276
{activeTab === 'sessions' && (
296277
<div className="replay-session-list">
297278
{bundle.sessions.length === 0 ? (
298-
<div className="replay-empty">No sessions recorded.</div>
279+
<EmptyState message="No sessions recorded." className="replay-empty" />
299280
) : (
300281
<table className="replay-table">
301282
<thead>
@@ -319,15 +300,9 @@ export default function ReplayViewer({ sandboxId }: ReplayViewerProps) {
319300
{formatRelativeTime(session.created_at)}
320301
</td>
321302
<td>
322-
<span
323-
style={{
324-
color: session.destroyed_at
325-
? 'var(--color-text-weak)'
326-
: 'hsl(140, 60%, 50%)',
327-
}}
328-
>
329-
{session.destroyed_at ? 'destroyed' : 'active'}
330-
</span>
303+
<StatusBadge
304+
status={session.destroyed_at ? 'destroyed' : 'active'}
305+
/>
331306
</td>
332307
</tr>
333308
))}
@@ -340,7 +315,7 @@ export default function ReplayViewer({ sandboxId }: ReplayViewerProps) {
340315
{activeTab === 'artifacts' && (
341316
<div className="replay-artifact-list">
342317
{bundle.artifacts.length === 0 ? (
343-
<div className="replay-empty">No artifacts collected.</div>
318+
<EmptyState message="No artifacts collected." className="replay-empty" />
344319
) : (
345320
<table className="replay-table">
346321
<thead>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
5+
interface CopyButtonProps {
6+
text: string
7+
label?: string | undefined
8+
copiedLabel?: string | undefined
9+
className?: string | undefined
10+
}
11+
12+
export default function CopyButton({
13+
text,
14+
label = 'Copy',
15+
copiedLabel = 'Copied',
16+
className = 'dash-action-btn',
17+
}: CopyButtonProps) {
18+
const [copied, setCopied] = useState(false)
19+
20+
async function handleCopy() {
21+
await navigator.clipboard.writeText(text)
22+
setCopied(true)
23+
setTimeout(() => setCopied(false), 2000)
24+
}
25+
26+
return (
27+
<button className={className} onClick={handleCopy}>
28+
{copied ? copiedLabel : label}
29+
</button>
30+
)
31+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
interface EmptyStateProps {
2+
message: string
3+
className?: string | undefined
4+
}
5+
6+
export default function EmptyState({ message, className }: EmptyStateProps) {
7+
return <div className={className ?? 'dash-empty'}>{message}</div>
8+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
interface ErrorMessageProps {
2+
message: string
3+
className?: string | undefined
4+
role?: string | undefined
5+
}
6+
7+
export default function ErrorMessage({ message, className, role }: ErrorMessageProps) {
8+
return (
9+
<p className={className ?? 'dash-error'} role={role}>
10+
{message}
11+
</p>
12+
)
13+
}

0 commit comments

Comments
 (0)