"use client"; import { ArrowLeftIcon, BellIcon, CheckIcon, CreditCardIcon, KeyRoundIcon, MoveIcon, PaletteIcon, RotateCcwIcon, SettingsIcon, ShieldIcon, UploadIcon, UserIcon, UsersIcon, XIcon, ZoomInIcon, ZoomOutIcon, } from "lucide-react"; import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from "motion/react"; import { type ComponentType, type KeyboardEvent, type PointerEvent, type ReactNode, useEffect, useId, useMemo, useRef, useState, } from "react"; import { cn } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, DialogDescription, DialogFooter, DialogHeader, DialogPanel, DialogPopup, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Slider } from "@/components/ui/slider"; import { Spinner } from "@/components/ui/spinner"; import { Textarea } from "@/components/ui/textarea"; export type AvatarPalette = "dusk" | "tide" | "ember" | "moss"; export interface AvatarCrop { palette: AvatarPalette; x: number; y: number; zoom: number; } export interface ProfileDraft { fullName: string; publicName: string; username: string; email: string; bio: string; timeZone: string; avatar: AvatarCrop | null; } export interface ProfileZone { id: string; city: string; } export interface ProfileSettingsProps { className?: string; profile?: ProfileDraft; domain?: string; takenUsernames?: string[]; bioLimit?: number; zones?: ProfileZone[]; onSave?: (profile: ProfileDraft) => void | Promise; onNavigate?: (section: string) => void; } type UsernameStatus = "current" | "checking" | "available" | "taken" | "invalid"; const easeOut = [0.23, 1, 0.32, 1] as const; const spring = { type: "spring", stiffness: 520, damping: 42 } as const; const press = "transition-[background-color,color,box-shadow,scale] duration-150 ease-out motion-safe:active:scale-[0.97]"; const stageSpan = 136; const zoomRange = [1, 2.4] as const; const artBleed = 1.3; const palettes: Record = { dusk: { label: "Dusk", stops: ["text-indigo-600", "text-fuchsia-500", "text-amber-300"], swatch: "from-indigo-600 via-fuchsia-500 to-amber-300", }, tide: { label: "Tide", stops: ["text-blue-700", "text-teal-400", "text-lime-200"], swatch: "from-blue-700 via-teal-400 to-lime-200", }, ember: { label: "Ember", stops: ["text-rose-700", "text-orange-500", "text-yellow-300"], swatch: "from-rose-700 via-orange-500 to-yellow-300", }, moss: { label: "Moss", stops: ["text-emerald-800", "text-emerald-500", "text-lime-300"], swatch: "from-emerald-800 via-emerald-500 to-lime-300", }, }; const navGroups: { label: string; items: { id: string; label: string; icon: ComponentType<{ className?: string }> }[] }[] = [ { label: "Account", items: [ { id: "profile", label: "Profile", icon: UserIcon }, { id: "appearance", label: "Appearance", icon: PaletteIcon }, { id: "notifications", label: "Notifications", icon: BellIcon }, { id: "security", label: "Security", icon: ShieldIcon }, ], }, { label: "Workspace", items: [ { id: "general", label: "General", icon: SettingsIcon }, { id: "members", label: "Members & roles", icon: UsersIcon }, { id: "billing", label: "Billing", icon: CreditCardIcon }, ], }, { label: "Developer", items: [{ id: "api", label: "API keys & webhooks", icon: KeyRoundIcon }], }, ]; const defaultZones: ProfileZone[] = [ { id: "Pacific/Honolulu", city: "Honolulu" }, { id: "America/Los_Angeles", city: "Los Angeles" }, { id: "America/Denver", city: "Denver" }, { id: "America/Chicago", city: "Chicago" }, { id: "America/New_York", city: "New York" }, { id: "America/Sao_Paulo", city: "São Paulo" }, { id: "Europe/London", city: "London" }, { id: "Europe/Lisbon", city: "Lisbon" }, { id: "Europe/Stockholm", city: "Stockholm" }, { id: "Europe/Berlin", city: "Berlin" }, { id: "Africa/Lagos", city: "Lagos" }, { id: "Africa/Nairobi", city: "Nairobi" }, { id: "Asia/Dubai", city: "Dubai" }, { id: "Asia/Kolkata", city: "Mumbai" }, { id: "Asia/Singapore", city: "Singapore" }, { id: "Asia/Tokyo", city: "Tokyo" }, { id: "Australia/Sydney", city: "Sydney" }, { id: "Pacific/Auckland", city: "Auckland" }, ]; const defaultProfile: ProfileDraft = { fullName: "Ada Lindqvist", publicName: "Ada", username: "adalindqvist", email: "ada@ridgeline.app", bio: "Design engineer on the platform team. I care about fast tools, tidy changelogs and good coffee.", timeZone: "Europe/Stockholm", avatar: { palette: "dusk", x: 0, y: 0, zoom: 1 }, }; const defaultTaken = ["ada", "adal", "lindqvist", "admin", "support", "hello", "team", "ridgeline", "design"]; function useReducedMotionSafe() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && Boolean(prefers); } function useSafeId() { return useId().replace(/[^a-zA-Z0-9_-]/g, ""); } function initialsOf(name: string) { const parts = name.trim().split(/\s+/).filter(Boolean); if (parts.length === 0) return "?"; return (parts.length > 1 ? parts[0][0] + parts[parts.length - 1][0] : parts[0][0]).toUpperCase(); } function clampCrop(crop: AvatarCrop): AvatarCrop { const zoom = Math.max(zoomRange[0], Math.min(zoomRange[1], crop.zoom)); const limit = 50 * (zoom * artBleed - 1); const clamp = (v: number) => Math.max(-limit, Math.min(limit, v)); return { ...crop, zoom, x: clamp(crop.x), y: clamp(crop.y) }; } function artTransform(crop: AvatarCrop) { return `translate(${50 + crop.x} ${50 + crop.y}) scale(${crop.zoom * artBleed}) translate(-50 -50)`; } function sameCrop(a: AvatarCrop | null, b: AvatarCrop | null) { if (!a || !b) return a === b; return a.palette === b.palette && a.x === b.x && a.y === b.y && a.zoom === b.zoom; } function validateUsername(value: string): string | null { if (value.length < 3) return "Use at least 3 characters."; if (value.length > 24) return "Keep it to 24 characters or fewer."; if (!/^[a-z0-9-]+$/.test(value)) return "Use lowercase letters, numbers and hyphens."; if (/^-|-$/.test(value)) return "Start and end with a letter or number."; return null; } function suggestionsFor(value: string, fullName: string, taken: Set) { const base = value.replace(/[^a-z0-9]/g, "").slice(0, 16) || "ada"; const last = fullName.trim().split(/\s+/).slice(1).join("").toLowerCase().replace(/[^a-z]/g, ""); return [last ? `${base}-${last[0]}` : `${base}-hq`, `${base}${base.length + 21}`, `hey-${base}`, `${base}-studio`] .filter((s) => !taken.has(s) && !validateUsername(s) && s !== value) .slice(0, 3); } function offsetMinutes(timeZone: string, date: Date) { const part = new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName: "shortOffset" }) .formatToParts(date) .find((p) => p.type === "timeZoneName")?.value; const match = part?.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/); if (!match) return 0; const sign = match[1] === "-" ? -1 : 1; return sign * (Number(match[2]) * 60 + Number(match[3] ?? 0)); } function formatOffset(minutes: number) { if (minutes === 0) return "UTC"; const abs = Math.abs(minutes); const m = abs % 60; return `UTC${minutes > 0 ? "+" : "−"}${Math.floor(abs / 60)}${m ? `:${String(m).padStart(2, "0")}` : ""}`; } function formatDiff(minutes: number) { if (minutes === 0) return "same as your browser"; const abs = Math.abs(minutes); const m = abs % 60; return `${Math.floor(abs / 60)}h${m ? ` ${m}m` : ""} ${minutes > 0 ? "ahead of" : "behind"} your browser`; } function AvatarArt({ palette, initials, uid }: { palette: AvatarPalette; initials: string; uid: string }) { const p = palettes[palette]; return ( <> {initials} ); } function ProfileAvatar({ crop, initials, className }: { crop: AvatarCrop | null; initials: string; className?: string }) { const uid = useSafeId(); if (!crop) { return ( {initials} ); } return ( ); } function CropStage({ crop, initials, onChange, }: { crop: AvatarCrop; initials: string; onChange: (crop: AvatarCrop) => void; }) { const uid = useSafeId(); const ref = useRef(null); const drag = useRef<{ px: number; py: number; x: number; y: number } | null>(null); const [dragging, setDragging] = useState(false); const thirds = [100 / 3, 200 / 3]; const onPointerDown = (e: PointerEvent) => { e.currentTarget.setPointerCapture(e.pointerId); drag.current = { px: e.clientX, py: e.clientY, x: crop.x, y: crop.y }; setDragging(true); }; const onPointerMove = (e: PointerEvent) => { const start = drag.current; const el = ref.current; if (!start || !el) return; const k = stageSpan / el.clientWidth; onChange(clampCrop({ ...crop, x: start.x + (e.clientX - start.px) * k, y: start.y + (e.clientY - start.py) * k })); }; const endDrag = () => { drag.current = null; setDragging(false); }; const onKeyDown = (e: KeyboardEvent) => { const step = e.shiftKey ? 8 : 2; const moves: Record = { ArrowLeft: [-step, 0, 0], ArrowRight: [step, 0, 0], ArrowUp: [0, -step, 0], ArrowDown: [0, step, 0], "+": [0, 0, 0.1], "=": [0, 0, 0.1], "-": [0, 0, -0.1], }; const move = moves[e.key]; if (!move) return; e.preventDefault(); onChange(clampCrop({ ...crop, x: crop.x + move[0], y: crop.y + move[1], zoom: crop.zoom + move[2] })); }; return (
{thirds.map((t) => ( ))} Drag to reposition
); } function CropDialog({ open, initial, initials, onOpenChange, onApply, }: { open: boolean; initial: AvatarCrop; initials: string; onOpenChange: (open: boolean) => void; onApply: (crop: AvatarCrop) => void; }) { const [crop, setCrop] = useState(initial); const initialRef = useRef(initial); initialRef.current = initial; useEffect(() => { if (open) setCrop(initialRef.current); }, [open]); return ( Adjust photo Drag to reposition, then zoom to frame it.
"Zoom"} max={zoomRange[1]} min={zoomRange[0]} onValueChange={(v) => setCrop((c) => clampCrop({ ...c, zoom: Array.isArray(v) ? v[0] : v }))} step={0.01} value={crop.zoom} />
Image
{(Object.keys(palettes) as AvatarPalette[]).map((key) => { const active = crop.palette === key; return ( ); })}
); } function useUnwrappedAngle(angle: number | null) { const reduce = useReducedMotionSafe(); const mv = useMotionValue(0); const last = useRef(null); useEffect(() => { if (angle === null) return; if (last.current === null || reduce) { last.current = angle; mv.set(angle); return; } const delta = ((((angle - last.current) % 360) + 540) % 360) - 180; last.current += delta; const controls = animate(mv, last.current, { type: "spring", stiffness: 140, damping: 18 }); return () => controls.stop(); }, [angle, reduce, mv]); return mv; } function MiniClock({ hours, minutes }: { hours: number | null; minutes: number | null }) { const ready = hours !== null && minutes !== null; const hour = useUnwrappedAngle(ready ? (hours % 12) * 30 + minutes * 0.5 : null); const minute = useUnwrappedAngle(ready ? minutes * 6 : null); const night = ready && (hours < 7 || hours >= 19); return ( ); } function DirtyDot({ show }: { show: boolean }) { return ( {show && ( )} ); } function Field({ label, help, htmlFor, dirty, aside, children, }: { label: string; help?: string; htmlFor?: string; dirty?: boolean; aside?: ReactNode; children: ReactNode; }) { return (
{help && {help}}
{aside}
{children}
); } function Section({ title, description, children }: { title: string; description?: string; children: ReactNode }) { return (

{title}

{description &&

{description}

} {children}
); } function SettingsShell({ active, account, onNavigate, overlay, children, }: { active: string; account: ReactNode; onNavigate?: (section: string) => void; overlay?: ReactNode; children: ReactNode; }) { const items = navGroups.flatMap((g) => g.items); return ( <>
Settings
{children}
{overlay}
); } function UsernameStatusLine({ status, message, suggestions, onPick, id, }: { status: UsernameStatus; message: string | null; suggestions: string[]; onPick: (value: string) => void; id: string; }) { const reduce = useReducedMotionSafe(); const content: Record = { current: This is your current username., checking: Checking availability…, available: ( Available ), taken: ( Taken, try {suggestions.map((s, i) => ( onPick(s)} transition={{ duration: 0.18, ease: easeOut, delay: reduce ? 0 : 0.03 + i * 0.03 }} type="button" > {s} ))} ), invalid: {message}, }; return (
{content[status]}
); } export function ProfileSettings({ className, profile = defaultProfile, domain = "ridgeline.app", takenUsernames = defaultTaken, bioLimit = 160, zones = defaultZones, onSave, onNavigate, }: ProfileSettingsProps) { const uid = useSafeId(); const reduce = useReducedMotionSafe(); const [baseline, setBaseline] = useState(profile); const [draft, setDraft] = useState(profile); const [cropOpen, setCropOpen] = useState(false); const [editingEmail, setEditingEmail] = useState(false); const [checked, setChecked] = useState<{ value: string; taken: boolean } | null>(null); const [offsets, setOffsets] = useState | null>(null); const [localOffset, setLocalOffset] = useState(null); const [clock, setClock] = useState<{ time: string; day: string; hours: number; minutes: number } | null>(null); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); const savedTimer = useRef | null>(null); const alive = useRef(true); const emailRef = useRef(null); const taken = useMemo(() => new Set(takenUsernames.map((h) => h.toLowerCase())), [takenUsernames]); const usernameError = draft.username === baseline.username ? null : validateUsername(draft.username); useEffect(() => { if (draft.username === baseline.username || validateUsername(draft.username)) return; if (checked?.value === draft.username) return; const value = draft.username; const timer = setTimeout(() => setChecked({ value, taken: taken.has(value) }), 650); return () => clearTimeout(timer); }, [draft.username, baseline.username, checked, taken]); useEffect(() => { const now = new Date(); setOffsets(Object.fromEntries(zones.map((z) => [z.id, offsetMinutes(z.id, now)]))); setLocalOffset(-now.getTimezoneOffset()); }, [zones]); useEffect(() => { const tick = () => { const now = new Date(); const parts = new Intl.DateTimeFormat("en-GB", { timeZone: draft.timeZone, hour: "2-digit", minute: "2-digit", weekday: "short", hourCycle: "h23", }).formatToParts(now); const get = (type: string) => parts.find((p) => p.type === type)?.value ?? ""; const hours = Number(get("hour")) % 24; const minutes = Number(get("minute")); setClock({ time: `${get("hour")}:${get("minute")}`, day: get("weekday"), hours, minutes }); }; tick(); const interval = setInterval(tick, 15000); return () => clearInterval(interval); }, [draft.timeZone]); useEffect(() => { alive.current = true; return () => { alive.current = false; if (savedTimer.current) clearTimeout(savedTimer.current); }; }, []); useEffect(() => { if (editingEmail) emailRef.current?.focus(); }, [editingEmail]); const usernameStatus: UsernameStatus = draft.username === baseline.username ? "current" : usernameError ? "invalid" : checked?.value === draft.username ? checked.taken ? "taken" : "available" : "checking"; const dirty = { avatar: !sameCrop(draft.avatar, baseline.avatar), fullName: draft.fullName !== baseline.fullName, publicName: draft.publicName !== baseline.publicName, username: draft.username !== baseline.username, email: draft.email !== baseline.email, bio: draft.bio !== baseline.bio, timeZone: draft.timeZone !== baseline.timeZone, }; const dirtyCount = Object.values(dirty).filter(Boolean).length; const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(draft.email); const blocker = !draft.fullName.trim() ? "Add your full name" : usernameStatus === "invalid" ? "Fix your username" : usernameStatus === "taken" ? "That username is taken" : usernameStatus === "checking" ? "Checking username…" : !emailValid ? "Enter a valid email" : draft.bio.length > bioLimit ? "Bio is too long" : null; const zone = zones.find((z) => z.id === draft.timeZone) ?? zones[0]; const zoneOffset = offsets?.[zone.id] ?? null; const suggestions = usernameStatus === "taken" ? suggestionsFor(draft.username, draft.fullName, taken) : []; const initials = initialsOf(draft.fullName || baseline.fullName); const set = (key: K, value: ProfileDraft[K]) => setDraft((d) => ({ ...d, [key]: value })); const bioLeft = bioLimit - draft.bio.length; const discard = () => { setDraft(baseline); setEditingEmail(false); }; const save = async () => { if (blocker || saving) return; setSaving(true); const snapshot = draft; await Promise.all([onSave?.(snapshot), new Promise((r) => setTimeout(r, 900))]); if (!alive.current) return; setSaving(false); setBaseline(snapshot); setEditingEmail(false); setChecked(null); setSaved(true); if (savedTimer.current) clearTimeout(savedTimer.current); savedTimer.current = setTimeout(() => setSaved(false), 1600); }; const ids = { fullName: `${uid}-full`, publicName: `${uid}-public`, username: `${uid}-username`, email: `${uid}-email`, bio: `${uid}-bio`, zone: `${uid}-zone`, }; const barVisible = dirtyCount > 0 || saved; return (
{draft.fullName.trim() || baseline.fullName} {baseline.email}
} active="profile" onNavigate={onNavigate} overlay={ {barVisible && (
{saved && dirtyCount === 0 ? ( Profile saved ) : ( Unsaved changes You have unsaved changes {blocker && !saving && {blocker}} )}
)}
} >

Profile

Manage how you appear to your team across Ridgeline.

Profile picture
PNG or JPG, up to 2 MB.
set("fullName", e.target.value)} value={draft.fullName} /> set("publicName", e.target.value)} placeholder={draft.fullName.split(" ")[0]} value={draft.publicName} />
{domain}/ set("username", e.target.value.toLowerCase().replace(/\s+/g, "-"))} spellCheck={false} value={draft.username} /> {usernameStatus === "checking" && } {usernameStatus === "available" && } {(usernameStatus === "taken" || usernameStatus === "invalid") && ( )} { setChecked({ value, taken: false }); set("username", value); }} status={usernameStatus} suggestions={suggestions} />
setEditingEmail(true)} size="xs" variant="ghost"> Change ) } dirty={dirty.email} help={dirty.email ? "We’ll send a link to confirm the new address." : "Used for sign-in and notifications."} htmlFor={ids.email} label="Email" > set("email", e.target.value.trim())} readOnly={!editingEmail} ref={emailRef} type="email" value={draft.email} /> {dirty.email ? ( Unverified ) : ( Verified )} {draft.bio.length}/{bioLimit} } dirty={dirty.bio} help="A sentence or two about what you work on." htmlFor={ids.bio} label="Bio" >