2026-06-18 19:26:48 +00:00
|
|
|
import { useEffect, useRef, useState } from 'react'
|
|
|
|
|
import { api, ApiError, type Integration } from '../lib/api'
|
2026-06-18 20:08:30 +00:00
|
|
|
import { useAuth } from '../lib/AuthContext'
|
2026-06-18 18:44:26 +00:00
|
|
|
import {
|
|
|
|
|
User,
|
|
|
|
|
Palette,
|
|
|
|
|
Plug,
|
|
|
|
|
Bell,
|
|
|
|
|
Database,
|
|
|
|
|
Info,
|
|
|
|
|
Eye,
|
|
|
|
|
EyeOff,
|
|
|
|
|
Check,
|
|
|
|
|
Download,
|
|
|
|
|
Upload,
|
|
|
|
|
Trash2,
|
|
|
|
|
RotateCcw,
|
2026-06-18 18:50:43 +00:00
|
|
|
Camera,
|
2026-06-18 18:44:26 +00:00
|
|
|
} from 'lucide-react'
|
|
|
|
|
|
|
|
|
|
const navSections = [
|
|
|
|
|
{ id: 'profile', label: 'Profile', icon: User },
|
|
|
|
|
{ id: 'appearance', label: 'Appearance', icon: Palette },
|
|
|
|
|
{ id: 'integrations', label: 'Integrations', icon: Plug },
|
|
|
|
|
{ id: 'notifications', label: 'Notifications', icon: Bell },
|
|
|
|
|
{ id: 'data', label: 'Data & Backup', icon: Database },
|
|
|
|
|
{ id: 'about', label: 'About', icon: Info },
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
const accentColors = [
|
|
|
|
|
{ name: 'Gold', color: '#C8A434' },
|
|
|
|
|
{ name: 'Teal', color: '#2DD4BF' },
|
|
|
|
|
{ name: 'Purple', color: '#A855F7' },
|
|
|
|
|
{ name: 'Blue', color: '#3B82F6' },
|
|
|
|
|
{ name: 'Green', color: '#2ECC71' },
|
|
|
|
|
{ name: 'Red', color: '#E74C3C' },
|
|
|
|
|
]
|
|
|
|
|
|
2026-06-18 19:26:48 +00:00
|
|
|
type FieldDef = { key: string; label: string; secret?: boolean }
|
2026-06-18 18:44:26 +00:00
|
|
|
|
2026-06-18 19:26:48 +00:00
|
|
|
const integrationTypeDefs: { type: string; name: string; fields: FieldDef[] }[] = [
|
|
|
|
|
{ type: 'proxmox', name: 'Proxmox', fields: [{ key: 'baseUrl', label: 'Host URL' }, { key: 'apiKey', label: 'API Token', secret: true }] },
|
|
|
|
|
{ type: 'docker', name: 'Docker', fields: [{ key: 'baseUrl', label: 'Socket / Remote URL' }] },
|
|
|
|
|
{ type: 'netbird', name: 'NetBird', fields: [{ key: 'apiKey', label: 'API Key', secret: true }] },
|
|
|
|
|
{ type: 'cloudflare', name: 'Cloudflare', fields: [{ key: 'apiKey', label: 'API Token', secret: true }, { key: 'zoneId', label: 'Zone ID' }] },
|
|
|
|
|
{ type: 'aws', name: 'AWS', fields: [{ key: 'accessKey', label: 'Access Key' }, { key: 'secretKey', label: 'Secret', secret: true }, { key: 'region', label: 'Region' }] },
|
|
|
|
|
{ type: 'uptime_kuma', name: 'Uptime Kuma', fields: [{ key: 'baseUrl', label: 'URL' }, { key: 'apiKey', label: 'API Key', secret: true }] },
|
|
|
|
|
{ type: 'weather', name: 'Weather API', fields: [{ key: 'location', label: 'Location' }, { key: 'units', label: 'Units' }] },
|
2026-06-18 18:44:26 +00:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
const cardBase: React.CSSProperties = {
|
|
|
|
|
backgroundColor: 'rgba(10, 10, 12, 0.92)',
|
|
|
|
|
border: '1px solid rgba(200, 164, 52, 0.08)',
|
|
|
|
|
borderRadius: '12px',
|
|
|
|
|
padding: '22px',
|
|
|
|
|
position: 'relative',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sectionTitle: React.CSSProperties = {
|
|
|
|
|
fontSize: '11px',
|
|
|
|
|
textTransform: 'uppercase',
|
|
|
|
|
letterSpacing: '1.5px',
|
|
|
|
|
color: '#7A7D85',
|
|
|
|
|
fontWeight: 500,
|
|
|
|
|
marginBottom: '16px',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const labelStyle: React.CSSProperties = {
|
|
|
|
|
fontSize: '11px',
|
|
|
|
|
color: '#7A7D85',
|
|
|
|
|
marginBottom: '6px',
|
|
|
|
|
display: 'block',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const inputStyle: React.CSSProperties = {
|
|
|
|
|
width: '100%',
|
|
|
|
|
height: '34px',
|
|
|
|
|
borderRadius: '8px',
|
|
|
|
|
border: '1px solid rgba(200,164,52,0.12)',
|
|
|
|
|
backgroundColor: 'rgba(255,255,255,0.03)',
|
|
|
|
|
color: '#E8E6E0',
|
|
|
|
|
fontSize: '12px',
|
|
|
|
|
padding: '0 12px',
|
|
|
|
|
outline: 'none',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Toggle({ on, onClick }: { on: boolean; onClick: () => void }) {
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
className="cursor-pointer border-none"
|
|
|
|
|
style={{
|
|
|
|
|
width: '38px',
|
|
|
|
|
height: '20px',
|
|
|
|
|
borderRadius: '10px',
|
|
|
|
|
backgroundColor: on ? '#C8A434' : 'rgba(255,255,255,0.08)',
|
|
|
|
|
position: 'relative',
|
|
|
|
|
transition: 'background-color 0.2s ease',
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<span
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
top: '2px',
|
|
|
|
|
left: on ? '20px' : '2px',
|
|
|
|
|
width: '16px',
|
|
|
|
|
height: '16px',
|
|
|
|
|
borderRadius: '50%',
|
|
|
|
|
backgroundColor: '#0A0B0D',
|
|
|
|
|
transition: 'left 0.2s ease',
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</button>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 20:08:30 +00:00
|
|
|
function GoldButton({ children, danger, onClick, disabled }: { children: React.ReactNode; danger?: boolean; onClick?: () => void; disabled?: boolean }) {
|
2026-06-18 18:44:26 +00:00
|
|
|
return (
|
|
|
|
|
<button
|
2026-06-18 20:08:30 +00:00
|
|
|
onClick={onClick}
|
|
|
|
|
disabled={disabled}
|
2026-06-18 18:44:26 +00:00
|
|
|
className="flex items-center gap-2 cursor-pointer transition-colors whitespace-nowrap"
|
|
|
|
|
style={{
|
|
|
|
|
fontSize: '12px',
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
color: danger ? '#E74C3C' : '#0A0B0D',
|
|
|
|
|
backgroundColor: danger ? 'transparent' : '#C8A434',
|
|
|
|
|
border: danger ? '1px solid rgba(231,76,60,0.4)' : 'none',
|
|
|
|
|
borderRadius: '8px',
|
|
|
|
|
padding: '9px 16px',
|
|
|
|
|
boxShadow: danger ? 'none' : '0 0 14px rgba(200,164,52,0.2)',
|
2026-06-18 20:08:30 +00:00
|
|
|
opacity: disabled ? 0.6 : 1,
|
2026-06-18 18:44:26 +00:00
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{children}
|
|
|
|
|
</button>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ProfileSection() {
|
2026-06-18 20:08:30 +00:00
|
|
|
const { user, setUser } = useAuth()
|
2026-06-18 18:50:43 +00:00
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
2026-06-18 20:08:30 +00:00
|
|
|
const [displayName, setDisplayName] = useState(user?.display_name ?? '')
|
|
|
|
|
const [email, setEmail] = useState(user?.email ?? '')
|
|
|
|
|
const [avatar, setAvatar] = useState<string | null>(user?.avatar_data_url ?? null)
|
|
|
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
|
const [savedMsg, setSavedMsg] = useState('')
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setDisplayName(user?.display_name ?? '')
|
|
|
|
|
setEmail(user?.email ?? '')
|
|
|
|
|
setAvatar(user?.avatar_data_url ?? null)
|
|
|
|
|
}, [user])
|
|
|
|
|
|
|
|
|
|
const initials = (displayName || user?.username || '?').slice(0, 2).toUpperCase()
|
2026-06-18 18:50:43 +00:00
|
|
|
|
|
|
|
|
function handleAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
|
|
|
const file = e.target.files?.[0]
|
|
|
|
|
if (!file) return
|
|
|
|
|
const reader = new FileReader()
|
|
|
|
|
reader.onload = () => setAvatar(reader.result as string)
|
|
|
|
|
reader.readAsDataURL(file)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 20:08:30 +00:00
|
|
|
async function handleSave() {
|
|
|
|
|
setSaving(true)
|
|
|
|
|
setSavedMsg('')
|
|
|
|
|
try {
|
|
|
|
|
const { user: updated } = await api.updateMe({ displayName, email, avatarDataUrl: avatar })
|
|
|
|
|
setUser(updated)
|
|
|
|
|
setSavedMsg('Saved')
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setSavedMsg(err instanceof ApiError ? err.message : 'Failed to save')
|
|
|
|
|
} finally {
|
|
|
|
|
setSaving(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 18:44:26 +00:00
|
|
|
return (
|
|
|
|
|
<div style={cardBase}>
|
|
|
|
|
<h3 style={sectionTitle}>Profile</h3>
|
|
|
|
|
<div className="flex items-center gap-4" style={{ marginBottom: '24px' }}>
|
|
|
|
|
<div
|
2026-06-18 18:50:43 +00:00
|
|
|
onClick={() => fileInputRef.current?.click()}
|
|
|
|
|
className="relative rounded-full border-2 flex items-center justify-center font-bold cursor-pointer group"
|
|
|
|
|
style={{
|
|
|
|
|
width: '64px',
|
|
|
|
|
height: '64px',
|
|
|
|
|
borderColor: '#C8A434',
|
|
|
|
|
color: '#C8A434',
|
|
|
|
|
fontSize: '20px',
|
|
|
|
|
backgroundColor: 'rgba(200,164,52,0.08)',
|
|
|
|
|
backgroundImage: avatar ? `url(${avatar})` : undefined,
|
|
|
|
|
backgroundSize: 'cover',
|
|
|
|
|
backgroundPosition: 'center',
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
}}
|
|
|
|
|
title="Upload photo"
|
2026-06-18 18:44:26 +00:00
|
|
|
>
|
2026-06-18 20:08:30 +00:00
|
|
|
{!avatar && initials}
|
2026-06-18 18:50:43 +00:00
|
|
|
<div
|
|
|
|
|
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
|
|
|
|
style={{ backgroundColor: 'rgba(0,0,0,0.55)' }}
|
|
|
|
|
>
|
|
|
|
|
<Camera size={18} color="#E8E6E0" />
|
|
|
|
|
</div>
|
2026-06-18 18:44:26 +00:00
|
|
|
</div>
|
2026-06-18 18:50:43 +00:00
|
|
|
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleAvatarChange} className="hidden" />
|
2026-06-18 18:44:26 +00:00
|
|
|
<div>
|
2026-06-18 20:08:30 +00:00
|
|
|
<span style={{ fontSize: '15px', color: '#E8E6E0', fontWeight: 600 }}>{displayName || user?.username}</span>
|
|
|
|
|
<br />
|
|
|
|
|
<span style={{ fontSize: '12px', color: '#7A7D85' }}>{email || 'No email set'}</span>
|
2026-06-18 18:44:26 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid grid-cols-2 gap-4" style={{ marginBottom: '20px' }}>
|
|
|
|
|
<div>
|
|
|
|
|
<label style={labelStyle}>Display Name</label>
|
2026-06-18 20:08:30 +00:00
|
|
|
<input style={inputStyle} value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
|
2026-06-18 18:44:26 +00:00
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label style={labelStyle}>Email</label>
|
2026-06-18 20:08:30 +00:00
|
|
|
<input style={inputStyle} value={email} onChange={(e) => setEmail(e.target.value)} />
|
2026-06-18 18:44:26 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-06-18 20:08:30 +00:00
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<GoldButton onClick={handleSave} disabled={saving}>
|
|
|
|
|
<Check size={14} />
|
|
|
|
|
{saving ? 'Saving…' : 'Save Changes'}
|
|
|
|
|
</GoldButton>
|
|
|
|
|
{savedMsg && <span style={{ fontSize: '12px', color: '#7A7D85' }}>{savedMsg}</span>}
|
|
|
|
|
</div>
|
2026-06-18 18:44:26 +00:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function AppearanceSection() {
|
|
|
|
|
const [theme, setTheme] = useState<'dark' | 'light'>('dark')
|
|
|
|
|
const [accent, setAccent] = useState('Gold')
|
|
|
|
|
const [fontSize, setFontSize] = useState(13)
|
|
|
|
|
const [radius, setRadius] = useState(12)
|
|
|
|
|
const [sidebarExpanded, setSidebarExpanded] = useState(true)
|
|
|
|
|
const [animations, setAnimations] = useState(true)
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={cardBase}>
|
|
|
|
|
<h3 style={sectionTitle}>Appearance</h3>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center justify-between" style={{ marginBottom: '20px' }}>
|
|
|
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Theme</span>
|
|
|
|
|
<div className="flex items-center gap-1" style={{ backgroundColor: 'rgba(255,255,255,0.03)', borderRadius: '8px', padding: '3px' }}>
|
|
|
|
|
{(['dark', 'light'] as const).map((t) => (
|
|
|
|
|
<button
|
|
|
|
|
key={t}
|
|
|
|
|
onClick={() => setTheme(t)}
|
|
|
|
|
className="cursor-pointer border-none capitalize"
|
|
|
|
|
style={{
|
|
|
|
|
fontSize: '11px',
|
|
|
|
|
padding: '6px 14px',
|
|
|
|
|
borderRadius: '6px',
|
|
|
|
|
color: theme === t ? '#0A0B0D' : '#7A7D85',
|
|
|
|
|
backgroundColor: theme === t ? '#C8A434' : 'transparent',
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{t}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div style={{ marginBottom: '20px' }}>
|
|
|
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0', marginBottom: '10px', display: 'block' }}>Accent Color</span>
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
{accentColors.map((a) => (
|
|
|
|
|
<button
|
|
|
|
|
key={a.name}
|
|
|
|
|
onClick={() => setAccent(a.name)}
|
|
|
|
|
title={a.name}
|
|
|
|
|
className="cursor-pointer border-none rounded-full flex items-center justify-center"
|
|
|
|
|
style={{
|
|
|
|
|
width: '28px',
|
|
|
|
|
height: '28px',
|
|
|
|
|
backgroundColor: a.color,
|
|
|
|
|
outline: accent === a.name ? `2px solid ${a.color}` : 'none',
|
|
|
|
|
outlineOffset: '3px',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{accent === a.name && <Check size={14} color="#0A0B0D" />}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div style={{ marginBottom: '20px' }}>
|
|
|
|
|
<div className="flex items-center justify-between" style={{ marginBottom: '8px' }}>
|
|
|
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Font Size</span>
|
|
|
|
|
<span style={{ fontSize: '11px', color: '#C8A434' }}>{fontSize}px</span>
|
|
|
|
|
</div>
|
|
|
|
|
<input
|
|
|
|
|
type="range"
|
|
|
|
|
min={12}
|
|
|
|
|
max={16}
|
|
|
|
|
value={fontSize}
|
|
|
|
|
onChange={(e) => setFontSize(Number(e.target.value))}
|
|
|
|
|
className="w-full"
|
|
|
|
|
style={{ accentColor: '#C8A434' }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div style={{ marginBottom: '20px' }}>
|
|
|
|
|
<div className="flex items-center justify-between" style={{ marginBottom: '8px' }}>
|
|
|
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Card Border Radius</span>
|
|
|
|
|
<span style={{ fontSize: '11px', color: '#C8A434' }}>{radius}px</span>
|
|
|
|
|
</div>
|
|
|
|
|
<input
|
|
|
|
|
type="range"
|
|
|
|
|
min={4}
|
|
|
|
|
max={16}
|
|
|
|
|
value={radius}
|
|
|
|
|
onChange={(e) => setRadius(Number(e.target.value))}
|
|
|
|
|
className="w-full"
|
|
|
|
|
style={{ accentColor: '#C8A434' }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
|
|
|
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Sidebar Expanded by Default</span>
|
|
|
|
|
<Toggle on={sidebarExpanded} onClick={() => setSidebarExpanded((v) => !v)} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Animations</span>
|
|
|
|
|
<Toggle on={animations} onClick={() => setAnimations((v) => !v)} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function IntegrationsSection() {
|
2026-06-18 19:26:48 +00:00
|
|
|
const [integrations, setIntegrations] = useState<Integration[] | null>(null)
|
2026-06-18 18:44:26 +00:00
|
|
|
const [revealed, setRevealed] = useState<Set<string>>(new Set())
|
2026-06-18 19:26:48 +00:00
|
|
|
const [drafts, setDrafts] = useState<Record<string, Record<string, string>>>({})
|
|
|
|
|
const [statusMsg, setStatusMsg] = useState<Record<string, string>>({})
|
|
|
|
|
const [busy, setBusy] = useState<Set<string>>(new Set())
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
api.listIntegrations().then(({ integrations }) => setIntegrations(integrations))
|
|
|
|
|
}, [])
|
2026-06-18 18:44:26 +00:00
|
|
|
|
|
|
|
|
function toggleReveal(key: string) {
|
|
|
|
|
setRevealed((prev) => {
|
|
|
|
|
const next = new Set(prev)
|
|
|
|
|
if (next.has(key)) next.delete(key)
|
|
|
|
|
else next.add(key)
|
|
|
|
|
return next
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 19:26:48 +00:00
|
|
|
function setBusyFlag(type: string, value: boolean) {
|
|
|
|
|
setBusy((prev) => {
|
|
|
|
|
const next = new Set(prev)
|
|
|
|
|
if (value) next.add(type)
|
|
|
|
|
else next.delete(type)
|
|
|
|
|
return next
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setDraftField(type: string, fieldKey: string, value: string) {
|
|
|
|
|
setDrafts((prev) => ({ ...prev, [type]: { ...prev[type], [fieldKey]: value } }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleSave(def: (typeof integrationTypeDefs)[number], existing: Integration | undefined) {
|
|
|
|
|
setBusyFlag(def.type, true)
|
|
|
|
|
setStatusMsg((prev) => ({ ...prev, [def.type]: '' }))
|
|
|
|
|
try {
|
|
|
|
|
const draft = drafts[def.type] ?? {}
|
|
|
|
|
const config: Record<string, string> = {}
|
|
|
|
|
const secrets: Record<string, string> = {}
|
|
|
|
|
for (const f of def.fields) {
|
|
|
|
|
const value = draft[f.key]
|
|
|
|
|
if (value === undefined) continue
|
|
|
|
|
if (f.secret) secrets[f.key] = value
|
|
|
|
|
else config[f.key] = value
|
|
|
|
|
}
|
|
|
|
|
let integration: Integration
|
|
|
|
|
if (existing) {
|
|
|
|
|
;({ integration } = await api.updateIntegration(existing.id, { config, secrets }))
|
|
|
|
|
} else {
|
|
|
|
|
;({ integration } = await api.createIntegration({ type: def.type, name: def.name, config, secrets }))
|
|
|
|
|
}
|
|
|
|
|
setIntegrations((prev) => {
|
|
|
|
|
const others = (prev ?? []).filter((i) => i.id !== integration.id)
|
|
|
|
|
return [...others, integration]
|
|
|
|
|
})
|
|
|
|
|
setStatusMsg((prev) => ({ ...prev, [def.type]: 'Saved' }))
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setStatusMsg((prev) => ({ ...prev, [def.type]: err instanceof ApiError ? err.message : 'Save failed' }))
|
|
|
|
|
} finally {
|
|
|
|
|
setBusyFlag(def.type, false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleTest(def: (typeof integrationTypeDefs)[number], existing: Integration | undefined) {
|
|
|
|
|
if (!existing) {
|
|
|
|
|
setStatusMsg((prev) => ({ ...prev, [def.type]: 'Save the integration before testing' }))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
setBusyFlag(def.type, true)
|
|
|
|
|
try {
|
|
|
|
|
const result = await api.testIntegration(existing.id)
|
|
|
|
|
setStatusMsg((prev) => ({ ...prev, [def.type]: result.message }))
|
|
|
|
|
const { integrations } = await api.listIntegrations()
|
|
|
|
|
setIntegrations(integrations)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setStatusMsg((prev) => ({ ...prev, [def.type]: err instanceof ApiError ? err.message : 'Test failed' }))
|
|
|
|
|
} finally {
|
|
|
|
|
setBusyFlag(def.type, false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!integrations) {
|
|
|
|
|
return (
|
|
|
|
|
<div style={cardBase}>
|
|
|
|
|
<p style={{ fontSize: '12px', color: '#7A7D85' }}>Loading integrations…</p>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 18:44:26 +00:00
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col gap-4">
|
2026-06-18 19:26:48 +00:00
|
|
|
{integrationTypeDefs.map((def) => {
|
|
|
|
|
const existing = integrations.find((i) => i.type === def.type)
|
|
|
|
|
const online = existing?.status === 'connected'
|
|
|
|
|
const draft = drafts[def.type] ?? {}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div key={def.type} style={cardBase}>
|
|
|
|
|
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
|
|
|
|
|
<div className="flex items-center gap-2.5">
|
|
|
|
|
<span
|
|
|
|
|
style={{
|
|
|
|
|
width: '8px',
|
|
|
|
|
height: '8px',
|
|
|
|
|
borderRadius: '50%',
|
|
|
|
|
backgroundColor: online ? '#2ECC71' : '#4A4D55',
|
|
|
|
|
boxShadow: online ? '0 0 6px rgba(46,204,113,0.6)' : 'none',
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 600 }}>{def.name}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{statusMsg[def.type] && (
|
|
|
|
|
<span style={{ fontSize: '11px', color: '#7A7D85' }}>{statusMsg[def.type]}</span>
|
|
|
|
|
)}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleSave(def, existing)}
|
|
|
|
|
disabled={busy.has(def.type)}
|
|
|
|
|
className="cursor-pointer border-none"
|
|
|
|
|
style={{
|
|
|
|
|
fontSize: '11px',
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
color: '#0A0B0D',
|
|
|
|
|
backgroundColor: '#C8A434',
|
|
|
|
|
borderRadius: '6px',
|
|
|
|
|
padding: '6px 12px',
|
|
|
|
|
opacity: busy.has(def.type) ? 0.6 : 1,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Save
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleTest(def, existing)}
|
|
|
|
|
disabled={busy.has(def.type)}
|
|
|
|
|
className="cursor-pointer border-none"
|
|
|
|
|
style={{
|
|
|
|
|
fontSize: '11px',
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
color: '#C8A434',
|
|
|
|
|
backgroundColor: 'rgba(200,164,52,0.08)',
|
|
|
|
|
border: '1px solid rgba(200,164,52,0.2)',
|
|
|
|
|
borderRadius: '6px',
|
|
|
|
|
padding: '6px 12px',
|
|
|
|
|
opacity: busy.has(def.type) ? 0.6 : 1,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Test Connection
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-06-18 18:44:26 +00:00
|
|
|
</div>
|
2026-06-18 19:26:48 +00:00
|
|
|
<div className="grid grid-cols-3 gap-4">
|
|
|
|
|
{def.fields.map((f) => {
|
|
|
|
|
const key = `${def.type}-${f.key}`
|
|
|
|
|
const isRevealed = revealed.has(key)
|
|
|
|
|
const savedValue = f.secret ? '' : existing?.config[f.key] ?? ''
|
|
|
|
|
const value = draft[f.key] ?? savedValue
|
|
|
|
|
return (
|
|
|
|
|
<div key={key}>
|
|
|
|
|
<label style={labelStyle}>{f.label}</label>
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<input
|
|
|
|
|
style={inputStyle}
|
|
|
|
|
type={f.secret && !isRevealed ? 'password' : 'text'}
|
|
|
|
|
value={value}
|
|
|
|
|
onChange={(e) => setDraftField(def.type, f.key, e.target.value)}
|
|
|
|
|
placeholder={f.secret && existing ? '••••••••••••' : 'Not configured'}
|
|
|
|
|
/>
|
|
|
|
|
{f.secret && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => toggleReveal(key)}
|
|
|
|
|
className="absolute cursor-pointer border-none bg-transparent"
|
|
|
|
|
style={{ right: '8px', top: '50%', transform: 'translateY(-50%)', color: '#7A7D85' }}
|
|
|
|
|
>
|
|
|
|
|
{isRevealed ? <EyeOff size={13} /> : <Eye size={13} />}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-06-18 18:44:26 +00:00
|
|
|
</div>
|
2026-06-18 19:26:48 +00:00
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
2026-06-18 18:44:26 +00:00
|
|
|
</div>
|
2026-06-18 19:26:48 +00:00
|
|
|
)
|
|
|
|
|
})}
|
2026-06-18 18:44:26 +00:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function NotificationsSection() {
|
|
|
|
|
const [enabled, setEnabled] = useState(true)
|
|
|
|
|
const [email, setEmail] = useState(true)
|
|
|
|
|
const [push, setPush] = useState(false)
|
|
|
|
|
const [sound, setSound] = useState(true)
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={cardBase}>
|
|
|
|
|
<h3 style={sectionTitle}>Notifications</h3>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center justify-between" style={{ marginBottom: '20px' }}>
|
|
|
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Enable Notifications</span>
|
|
|
|
|
<Toggle on={enabled} onClick={() => setEnabled((v) => !v)} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div style={{ marginBottom: '20px' }}>
|
|
|
|
|
<label style={labelStyle}>Alert Threshold</label>
|
|
|
|
|
<select style={{ ...inputStyle, width: '220px' }} defaultValue="all">
|
|
|
|
|
<option value="all">All</option>
|
|
|
|
|
<option value="critical">Critical Only</option>
|
|
|
|
|
<option value="warning">Warning & Above</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
|
|
|
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Email Notifications</span>
|
|
|
|
|
<Toggle on={email} onClick={() => setEmail((v) => !v)} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
|
|
|
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Browser Push</span>
|
|
|
|
|
<Toggle on={push} onClick={() => setPush((v) => !v)} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center justify-between" style={{ marginBottom: '8px' }}>
|
|
|
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Sound</span>
|
|
|
|
|
<Toggle on={sound} onClick={() => setSound((v) => !v)} />
|
|
|
|
|
</div>
|
|
|
|
|
{sound && (
|
|
|
|
|
<input type="range" min={0} max={100} defaultValue={70} className="w-full" style={{ accentColor: '#C8A434' }} />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function DataBackupSection() {
|
|
|
|
|
return (
|
|
|
|
|
<div style={cardBase}>
|
|
|
|
|
<h3 style={sectionTitle}>Data & Backup</h3>
|
|
|
|
|
<div className="flex flex-col gap-3" style={{ maxWidth: '320px' }}>
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Export Bookmarks (JSON)</span>
|
|
|
|
|
<GoldButton><Download size={13} /> Export</GoldButton>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Import Bookmarks (JSON)</span>
|
|
|
|
|
<GoldButton><Upload size={13} /> Import</GoldButton>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Export Settings</span>
|
|
|
|
|
<GoldButton><Download size={13} /> Export</GoldButton>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="border-t" style={{ borderColor: 'rgba(231,76,60,0.15)', margin: '8px 0' }} />
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Clear Cache</span>
|
|
|
|
|
<GoldButton danger><Trash2 size={13} /> Clear</GoldButton>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Reset to Defaults</span>
|
|
|
|
|
<GoldButton danger><RotateCcw size={13} /> Reset</GoldButton>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function AboutSection() {
|
|
|
|
|
const rows: [string, string][] = [
|
|
|
|
|
['App', 'ArchNest Dashboard v1.0.0'],
|
|
|
|
|
['Author', 'Samuel James'],
|
|
|
|
|
['Repo', 'github.com/SamuelSJames/archnest'],
|
|
|
|
|
['Stack', 'React 19, Vite, TypeScript'],
|
|
|
|
|
['License', 'MIT'],
|
|
|
|
|
]
|
|
|
|
|
return (
|
|
|
|
|
<div style={cardBase}>
|
|
|
|
|
<h3 style={sectionTitle}>About</h3>
|
|
|
|
|
<div className="flex flex-col gap-3">
|
|
|
|
|
{rows.map(([label, value]) => (
|
|
|
|
|
<div key={label} className="flex items-center justify-between">
|
|
|
|
|
<span style={{ fontSize: '12px', color: '#7A7D85' }}>{label}</span>
|
|
|
|
|
<span style={{ fontSize: '12px', color: '#E8E6E0' }}>{value}</span>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sectionComponents: Record<string, () => JSX.Element> = {
|
|
|
|
|
profile: ProfileSection,
|
|
|
|
|
appearance: AppearanceSection,
|
|
|
|
|
integrations: IntegrationsSection,
|
|
|
|
|
notifications: NotificationsSection,
|
|
|
|
|
data: DataBackupSection,
|
|
|
|
|
about: AboutSection,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function Settings() {
|
|
|
|
|
const [active, setActive] = useState('profile')
|
|
|
|
|
const ActiveSection = sectionComponents[active]
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-full w-full gap-5">
|
|
|
|
|
{/* Settings nav */}
|
|
|
|
|
<div className="flex flex-col gap-1 shrink-0" style={{ width: '200px' }}>
|
|
|
|
|
{navSections.map((s) => {
|
|
|
|
|
const Icon = s.icon
|
|
|
|
|
const isActive = active === s.id
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={s.id}
|
|
|
|
|
onClick={() => setActive(s.id)}
|
|
|
|
|
className="flex items-center gap-2.5 cursor-pointer border-none bg-transparent transition-colors"
|
|
|
|
|
style={{
|
|
|
|
|
fontSize: '13px',
|
|
|
|
|
fontWeight: 500,
|
|
|
|
|
padding: '10px 14px',
|
|
|
|
|
borderRadius: '8px',
|
|
|
|
|
color: isActive ? '#C8A434' : '#7A7D85',
|
|
|
|
|
backgroundColor: isActive ? 'rgba(200,164,52,0.1)' : 'transparent',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Icon size={15} />
|
|
|
|
|
{s.label}
|
|
|
|
|
</button>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Content */}
|
|
|
|
|
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'none' }}>
|
|
|
|
|
<ActiveSection />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|