diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 1d00982..dd6b64b 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -26,6 +26,7 @@ import { Route as ClientsIndexRouteImport } from './routes/clients.index' import { Route as CasesIndexRouteImport } from './routes/cases.index' import { Route as SettingsWorkflowsRouteImport } from './routes/settings.workflows' import { Route as SettingsWorkflowRouteImport } from './routes/settings.workflow' +import { Route as SettingsProfileRouteImport } from './routes/settings.profile' import { Route as SettingsFeesRouteImport } from './routes/settings.fees' import { Route as InvoicesInvoiceIdRouteImport } from './routes/invoices.$invoiceId' import { Route as ContactsContactIdRouteImport } from './routes/contacts.$contactId' @@ -124,6 +125,11 @@ const SettingsWorkflowRoute = SettingsWorkflowRouteImport.update({ path: '/workflow', getParentRoute: () => SettingsRoute, } as any) +const SettingsProfileRoute = SettingsProfileRouteImport.update({ + id: '/profile', + path: '/profile', + getParentRoute: () => SettingsRoute, +} as any) const SettingsFeesRoute = SettingsFeesRouteImport.update({ id: '/fees', path: '/fees', @@ -199,6 +205,7 @@ export interface FileRoutesByFullPath { '/contacts/$contactId': typeof ContactsContactIdRoute '/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute '/settings/fees': typeof SettingsFeesRoute + '/settings/profile': typeof SettingsProfileRoute '/settings/workflow': typeof SettingsWorkflowRoute '/settings/workflows': typeof SettingsWorkflowsRoute '/cases/': typeof CasesIndexRoute @@ -229,6 +236,7 @@ export interface FileRoutesByTo { '/contacts/$contactId': typeof ContactsContactIdRoute '/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute '/settings/fees': typeof SettingsFeesRoute + '/settings/profile': typeof SettingsProfileRoute '/settings/workflow': typeof SettingsWorkflowRoute '/settings/workflows': typeof SettingsWorkflowsRoute '/cases': typeof CasesIndexRoute @@ -261,6 +269,7 @@ export interface FileRoutesById { '/contacts/$contactId': typeof ContactsContactIdRoute '/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute '/settings/fees': typeof SettingsFeesRoute + '/settings/profile': typeof SettingsProfileRoute '/settings/workflow': typeof SettingsWorkflowRoute '/settings/workflows': typeof SettingsWorkflowsRoute '/cases/': typeof CasesIndexRoute @@ -294,6 +303,7 @@ export interface FileRouteTypes { | '/contacts/$contactId' | '/invoices/$invoiceId' | '/settings/fees' + | '/settings/profile' | '/settings/workflow' | '/settings/workflows' | '/cases/' @@ -324,6 +334,7 @@ export interface FileRouteTypes { | '/contacts/$contactId' | '/invoices/$invoiceId' | '/settings/fees' + | '/settings/profile' | '/settings/workflow' | '/settings/workflows' | '/cases' @@ -355,6 +366,7 @@ export interface FileRouteTypes { | '/contacts/$contactId' | '/invoices/$invoiceId' | '/settings/fees' + | '/settings/profile' | '/settings/workflow' | '/settings/workflows' | '/cases/' @@ -523,6 +535,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsWorkflowRouteImport parentRoute: typeof SettingsRoute } + '/settings/profile': { + id: '/settings/profile' + path: '/profile' + fullPath: '/settings/profile' + preLoaderRoute: typeof SettingsProfileRouteImport + parentRoute: typeof SettingsRoute + } '/settings/fees': { id: '/settings/fees' path: '/fees' @@ -612,6 +631,7 @@ declare module '@tanstack/react-router' { interface SettingsRouteChildren { SettingsFeesRoute: typeof SettingsFeesRoute + SettingsProfileRoute: typeof SettingsProfileRoute SettingsWorkflowRoute: typeof SettingsWorkflowRoute SettingsWorkflowsRoute: typeof SettingsWorkflowsRoute SettingsIndexRoute: typeof SettingsIndexRoute @@ -619,6 +639,7 @@ interface SettingsRouteChildren { const SettingsRouteChildren: SettingsRouteChildren = { SettingsFeesRoute: SettingsFeesRoute, + SettingsProfileRoute: SettingsProfileRoute, SettingsWorkflowRoute: SettingsWorkflowRoute, SettingsWorkflowsRoute: SettingsWorkflowsRoute, SettingsIndexRoute: SettingsIndexRoute, @@ -658,3 +679,12 @@ const rootRouteChildren: RootRouteChildren = { export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/src/routes/settings.profile.tsx b/src/routes/settings.profile.tsx new file mode 100644 index 0000000..d7a5db9 --- /dev/null +++ b/src/routes/settings.profile.tsx @@ -0,0 +1,271 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useEffect, useRef, useState } from "react"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/lib/auth"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Loader2, Upload, Trash2, User as UserIcon } from "lucide-react"; +import { toast } from "sonner"; + +export const Route = createFileRoute("/settings/profile")({ + component: ProfilePage, +}); + +const EMPTY = { + full_name: "", + title: "", + phone: "", + bio: "", + timezone: "", + avatar_url: "" as string | null | "", +}; + +function initials(name: string, email: string) { + const src = name?.trim() || email || ""; + return src.split(/\s+/).map((p) => p[0]).filter(Boolean).slice(0, 2).join("").toUpperCase(); +} + +function ProfilePage() { + const { user } = useAuth(); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [uploading, setUploading] = useState(false); + const [form, setForm] = useState({ ...EMPTY }); + const [email, setEmail] = useState(""); + const fileInput = useRef(null); + + const load = async () => { + if (!user) return; + setLoading(true); + const { data } = await supabase + .from("profiles") + .select("*") + .eq("id", user.id) + .maybeSingle(); + if (data) { + setEmail(data.email || user.email || ""); + setForm({ + full_name: data.full_name ?? "", + title: (data as any).title ?? "", + phone: (data as any).phone ?? "", + bio: (data as any).bio ?? "", + timezone: (data as any).timezone ?? "", + avatar_url: (data as any).avatar_url ?? "", + }); + } + setLoading(false); + }; + + useEffect(() => { + load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user?.id]); + + const update = (k: keyof typeof EMPTY, v: string) => + setForm((f) => ({ ...f, [k]: v })); + + const onAvatarFile = async (file: File) => { + if (!user) return; + if (file.size > 5 * 1024 * 1024) { + toast.error("Image must be under 5 MB"); + return; + } + setUploading(true); + const ext = (file.name.split(".").pop() || "png").toLowerCase(); + // Path must start with the user's id for the storage policy to allow it. + const path = `${user.id}/avatar-${Date.now()}.${ext}`; + const { error } = await supabase.storage + .from("avatars") + .upload(path, file, { upsert: true, contentType: file.type, cacheControl: "3600" }); + if (error) { + setUploading(false); + toast.error("Upload failed", { description: error.message }); + return; + } + const { data: pub } = supabase.storage.from("avatars").getPublicUrl(path); + const url = pub.publicUrl; + // Persist immediately so it appears in the sidebar without a save click. + const { error: upErr } = await supabase + .from("profiles") + .update({ avatar_url: url }) + .eq("id", user.id); + setUploading(false); + if (upErr) { + toast.error("Could not save avatar", { description: upErr.message }); + return; + } + setForm((f) => ({ ...f, avatar_url: url })); + toast.success("Profile photo updated"); + }; + + const removeAvatar = async () => { + if (!user) return; + const { error } = await supabase + .from("profiles") + .update({ avatar_url: null }) + .eq("id", user.id); + if (error) { + toast.error("Could not remove", { description: error.message }); + return; + } + setForm((f) => ({ ...f, avatar_url: "" })); + toast.success("Photo removed"); + }; + + const save = async () => { + if (!user) return; + setSaving(true); + const { error } = await supabase + .from("profiles") + .update({ + full_name: form.full_name || "", + title: form.title || null, + phone: form.phone || null, + bio: form.bio || null, + timezone: form.timezone || null, + }) + .eq("id", user.id); + setSaving(false); + if (error) { + toast.error("Could not save", { description: error.message }); + return; + } + toast.success("Profile saved"); + load(); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ + + + Profile photo + + + + + {form.avatar_url ? : null} + + {initials(form.full_name, email)} + + +
+ { + const f = e.target.files?.[0]; + if (f) onAvatarFile(f); + e.target.value = ""; + }} + /> +
+ + {form.avatar_url && ( + + )} +
+

PNG or JPG, up to 5 MB.

+
+
+
+ + + + Personal information + + + + update("full_name", e.target.value)} + /> + + + + + + update("title", e.target.value)} + placeholder="Attorney, Paralegal, etc." + /> + + + update("phone", e.target.value)} + /> + + + update("timezone", e.target.value)} + placeholder="America/New_York" + /> + + +