Skip to content

Commit 0207299

Browse files
authored
Merge pull request #73 from ownpilot/refactor/profilepage-components
refactor(ui): extract ProfilePage presentational sub-components
2 parents e16b0a2 + 430701b commit 0207299

2 files changed

Lines changed: 182 additions & 180 deletions

File tree

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/**
2+
* ProfilePage presentational sub-components.
3+
*
4+
* Extracted from ProfilePage.tsx — self-contained, props-only widgets with no
5+
* dependency on the page's state: a progress ring, a stat card, a titled
6+
* section card, and a tag input.
7+
*/
8+
9+
import { useState } from 'react';
10+
import { X, type User } from '../components/icons';
11+
12+
export function ProgressRing({
13+
progress,
14+
size = 80,
15+
strokeWidth = 8,
16+
color = 'text-primary',
17+
}: {
18+
progress: number;
19+
size?: number;
20+
strokeWidth?: number;
21+
color?: string;
22+
}) {
23+
const radius = (size - strokeWidth) / 2;
24+
const circumference = radius * 2 * Math.PI;
25+
const offset = circumference - (progress / 100) * circumference;
26+
27+
return (
28+
<div className="relative" style={{ width: size, height: size }}>
29+
<svg className="transform -rotate-90 w-full h-full">
30+
<circle
31+
cx={size / 2}
32+
cy={size / 2}
33+
r={radius}
34+
stroke="currentColor"
35+
strokeWidth={strokeWidth}
36+
fill="transparent"
37+
className="text-bg-tertiary dark:text-dark-bg-tertiary"
38+
/>
39+
<circle
40+
cx={size / 2}
41+
cy={size / 2}
42+
r={radius}
43+
stroke="currentColor"
44+
strokeWidth={strokeWidth}
45+
fill="transparent"
46+
strokeDasharray={circumference}
47+
strokeDashoffset={offset}
48+
strokeLinecap="round"
49+
className={`${color} transition-all duration-500 ease-out`}
50+
/>
51+
</svg>
52+
<div className="absolute inset-0 flex items-center justify-center">
53+
<span className="text-lg font-bold text-text-primary dark:text-dark-text-primary">
54+
{progress}%
55+
</span>
56+
</div>
57+
</div>
58+
);
59+
}
60+
61+
export function StatCard({
62+
icon: Icon,
63+
label,
64+
value,
65+
color,
66+
trend,
67+
}: {
68+
icon: typeof User;
69+
label: string;
70+
value: string | number;
71+
color: string;
72+
trend?: { value: number; positive: boolean };
73+
}) {
74+
return (
75+
<div className="p-4 bg-bg-secondary dark:bg-dark-bg-secondary rounded-xl border border-border dark:border-dark-border hover:border-primary/30 transition-colors">
76+
<div className="flex items-center gap-3 mb-2">
77+
<div className={`p-2 rounded-lg ${color}`}>
78+
<Icon className="w-4 h-4" />
79+
</div>
80+
<span className="text-sm text-text-muted dark:text-dark-text-muted">{label}</span>
81+
</div>
82+
<div className="flex items-end gap-2">
83+
<span className="text-2xl font-bold text-text-primary dark:text-dark-text-primary">
84+
{value}
85+
</span>
86+
{trend && (
87+
<span className={`text-xs mb-1 ${trend.positive ? 'text-success' : 'text-error'}`}>
88+
{trend.positive ? '+' : ''}
89+
{trend.value}%
90+
</span>
91+
)}
92+
</div>
93+
</div>
94+
);
95+
}
96+
97+
export function SectionCard({
98+
title,
99+
icon: Icon,
100+
children,
101+
action,
102+
}: {
103+
title: string;
104+
icon: typeof User;
105+
children: React.ReactNode;
106+
action?: { label: string; onClick: () => void };
107+
}) {
108+
return (
109+
<div className="p-5 bg-bg-secondary dark:bg-dark-bg-secondary rounded-xl border border-border dark:border-dark-border">
110+
<div className="flex items-center justify-between mb-4">
111+
<div className="flex items-center gap-2">
112+
<Icon className="w-5 h-5 text-primary" />
113+
<h3 className="font-semibold text-text-primary dark:text-dark-text-primary">{title}</h3>
114+
</div>
115+
{action && (
116+
<button onClick={action.onClick} className="text-xs text-primary hover:underline">
117+
{action.label}
118+
</button>
119+
)}
120+
</div>
121+
{children}
122+
</div>
123+
);
124+
}
125+
126+
export function TagInput({
127+
tags,
128+
onAdd,
129+
onRemove,
130+
placeholder,
131+
color = 'primary',
132+
}: {
133+
tags: string[];
134+
onAdd: (tag: string) => void;
135+
onRemove: (tag: string) => void;
136+
placeholder: string;
137+
color?: 'primary' | 'success' | 'warning' | 'error';
138+
}) {
139+
const [input, setInput] = useState('');
140+
141+
const colorClasses = {
142+
primary: 'bg-primary/10 text-primary border-primary/20',
143+
success: 'bg-success/10 text-success border-success/20',
144+
warning: 'bg-warning/10 text-warning border-warning/20',
145+
error: 'bg-error/10 text-error border-error/20',
146+
};
147+
148+
const handleKeyDown = (e: React.KeyboardEvent) => {
149+
if (e.key === 'Enter' && input.trim()) {
150+
e.preventDefault();
151+
onAdd(input.trim());
152+
setInput('');
153+
}
154+
};
155+
156+
return (
157+
<div className="space-y-2">
158+
<div className="flex flex-wrap gap-2">
159+
{tags.map((tag) => (
160+
<span
161+
key={tag}
162+
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-sm border ${colorClasses[color]}`}
163+
>
164+
{tag}
165+
<button onClick={() => onRemove(tag)} className="hover:opacity-70">
166+
<X className="w-3 h-3" />
167+
</button>
168+
</span>
169+
))}
170+
</div>
171+
<input
172+
type="text"
173+
value={input}
174+
onChange={(e) => setInput(e.target.value)}
175+
onKeyDown={handleKeyDown}
176+
placeholder={placeholder}
177+
className="w-full px-3 py-2 bg-bg-tertiary dark:bg-dark-bg-tertiary border border-border dark:border-dark-border rounded-lg text-sm text-text-primary dark:text-dark-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/50"
178+
/>
179+
</div>
180+
);
181+
}

packages/ui/src/pages/ProfilePage.tsx

Lines changed: 1 addition & 180 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ import {
3030
Clock,
3131
Trash2,
3232
Save,
33-
X,
3433
CheckCircle2,
3534
AlertCircle,
3635
Zap,
@@ -59,185 +58,7 @@ import {
5958
LANGUAGES,
6059
} from './ProfilePage.constants';
6160
import type { QuickSetupData, EditableSection, TabId } from './ProfilePage.constants';
62-
63-
// =============================================================================
64-
// Types
65-
// =============================================================================
66-
67-
// =============================================================================
68-
// Utility Components
69-
// =============================================================================
70-
71-
function ProgressRing({
72-
progress,
73-
size = 80,
74-
strokeWidth = 8,
75-
color = 'text-primary',
76-
}: {
77-
progress: number;
78-
size?: number;
79-
strokeWidth?: number;
80-
color?: string;
81-
}) {
82-
const radius = (size - strokeWidth) / 2;
83-
const circumference = radius * 2 * Math.PI;
84-
const offset = circumference - (progress / 100) * circumference;
85-
86-
return (
87-
<div className="relative" style={{ width: size, height: size }}>
88-
<svg className="transform -rotate-90 w-full h-full">
89-
<circle
90-
cx={size / 2}
91-
cy={size / 2}
92-
r={radius}
93-
stroke="currentColor"
94-
strokeWidth={strokeWidth}
95-
fill="transparent"
96-
className="text-bg-tertiary dark:text-dark-bg-tertiary"
97-
/>
98-
<circle
99-
cx={size / 2}
100-
cy={size / 2}
101-
r={radius}
102-
stroke="currentColor"
103-
strokeWidth={strokeWidth}
104-
fill="transparent"
105-
strokeDasharray={circumference}
106-
strokeDashoffset={offset}
107-
strokeLinecap="round"
108-
className={`${color} transition-all duration-500 ease-out`}
109-
/>
110-
</svg>
111-
<div className="absolute inset-0 flex items-center justify-center">
112-
<span className="text-lg font-bold text-text-primary dark:text-dark-text-primary">
113-
{progress}%
114-
</span>
115-
</div>
116-
</div>
117-
);
118-
}
119-
120-
function StatCard({
121-
icon: Icon,
122-
label,
123-
value,
124-
color,
125-
trend,
126-
}: {
127-
icon: typeof User;
128-
label: string;
129-
value: string | number;
130-
color: string;
131-
trend?: { value: number; positive: boolean };
132-
}) {
133-
return (
134-
<div className="p-4 bg-bg-secondary dark:bg-dark-bg-secondary rounded-xl border border-border dark:border-dark-border hover:border-primary/30 transition-colors">
135-
<div className="flex items-center gap-3 mb-2">
136-
<div className={`p-2 rounded-lg ${color}`}>
137-
<Icon className="w-4 h-4" />
138-
</div>
139-
<span className="text-sm text-text-muted dark:text-dark-text-muted">{label}</span>
140-
</div>
141-
<div className="flex items-end gap-2">
142-
<span className="text-2xl font-bold text-text-primary dark:text-dark-text-primary">
143-
{value}
144-
</span>
145-
{trend && (
146-
<span className={`text-xs mb-1 ${trend.positive ? 'text-success' : 'text-error'}`}>
147-
{trend.positive ? '+' : ''}
148-
{trend.value}%
149-
</span>
150-
)}
151-
</div>
152-
</div>
153-
);
154-
}
155-
156-
function SectionCard({
157-
title,
158-
icon: Icon,
159-
children,
160-
action,
161-
}: {
162-
title: string;
163-
icon: typeof User;
164-
children: React.ReactNode;
165-
action?: { label: string; onClick: () => void };
166-
}) {
167-
return (
168-
<div className="p-5 bg-bg-secondary dark:bg-dark-bg-secondary rounded-xl border border-border dark:border-dark-border">
169-
<div className="flex items-center justify-between mb-4">
170-
<div className="flex items-center gap-2">
171-
<Icon className="w-5 h-5 text-primary" />
172-
<h3 className="font-semibold text-text-primary dark:text-dark-text-primary">{title}</h3>
173-
</div>
174-
{action && (
175-
<button onClick={action.onClick} className="text-xs text-primary hover:underline">
176-
{action.label}
177-
</button>
178-
)}
179-
</div>
180-
{children}
181-
</div>
182-
);
183-
}
184-
185-
function TagInput({
186-
tags,
187-
onAdd,
188-
onRemove,
189-
placeholder,
190-
color = 'primary',
191-
}: {
192-
tags: string[];
193-
onAdd: (tag: string) => void;
194-
onRemove: (tag: string) => void;
195-
placeholder: string;
196-
color?: 'primary' | 'success' | 'warning' | 'error';
197-
}) {
198-
const [input, setInput] = useState('');
199-
200-
const colorClasses = {
201-
primary: 'bg-primary/10 text-primary border-primary/20',
202-
success: 'bg-success/10 text-success border-success/20',
203-
warning: 'bg-warning/10 text-warning border-warning/20',
204-
error: 'bg-error/10 text-error border-error/20',
205-
};
206-
207-
const handleKeyDown = (e: React.KeyboardEvent) => {
208-
if (e.key === 'Enter' && input.trim()) {
209-
e.preventDefault();
210-
onAdd(input.trim());
211-
setInput('');
212-
}
213-
};
214-
215-
return (
216-
<div className="space-y-2">
217-
<div className="flex flex-wrap gap-2">
218-
{tags.map((tag) => (
219-
<span
220-
key={tag}
221-
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-sm border ${colorClasses[color]}`}
222-
>
223-
{tag}
224-
<button onClick={() => onRemove(tag)} className="hover:opacity-70">
225-
<X className="w-3 h-3" />
226-
</button>
227-
</span>
228-
))}
229-
</div>
230-
<input
231-
type="text"
232-
value={input}
233-
onChange={(e) => setInput(e.target.value)}
234-
onKeyDown={handleKeyDown}
235-
placeholder={placeholder}
236-
className="w-full px-3 py-2 bg-bg-tertiary dark:bg-dark-bg-tertiary border border-border dark:border-dark-border rounded-lg text-sm text-text-primary dark:text-dark-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/50"
237-
/>
238-
</div>
239-
);
240-
}
61+
import { ProgressRing, StatCard, SectionCard, TagInput } from './ProfilePage.components';
24162

24263
// =============================================================================
24364
// Main Component

0 commit comments

Comments
 (0)