Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
409 lines
14 KiB
TypeScript
409 lines
14 KiB
TypeScript
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, PenLine } 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 | "",
|
|
signature_block: "",
|
|
signature_image_path: "" 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 [uploadingSig, setUploadingSig] = useState(false);
|
|
const [form, setForm] = useState({ ...EMPTY });
|
|
const [email, setEmail] = useState("");
|
|
const fileInput = useRef<HTMLInputElement>(null);
|
|
const sigInput = useRef<HTMLInputElement>(null);
|
|
|
|
// Derive a public preview URL for the saved signature image
|
|
const signatureUrl = form.signature_image_path
|
|
? supabase.storage.from("avatars").getPublicUrl(form.signature_image_path).data.publicUrl
|
|
: "";
|
|
|
|
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 || "");
|
|
const d = data as any;
|
|
setForm({
|
|
full_name: data.full_name ?? "",
|
|
title: d.title ?? "",
|
|
phone: d.phone ?? "",
|
|
bio: d.bio ?? "",
|
|
timezone: d.timezone ?? "",
|
|
avatar_url: d.avatar_url ?? "",
|
|
signature_block: d.signature_block ?? "",
|
|
signature_image_path: d.signature_image_path ?? "",
|
|
});
|
|
}
|
|
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 onSignatureFile = async (file: File) => {
|
|
if (!user) return;
|
|
if (file.size > 2 * 1024 * 1024) {
|
|
toast.error("Signature image must be under 2 MB");
|
|
return;
|
|
}
|
|
setUploadingSig(true);
|
|
const ext = (file.name.split(".").pop() || "png").toLowerCase();
|
|
const path = `${user.id}/signature-${Date.now()}.${ext}`;
|
|
const { error } = await supabase.storage
|
|
.from("avatars")
|
|
.upload(path, file, { upsert: true, contentType: file.type, cacheControl: "3600" });
|
|
if (error) {
|
|
setUploadingSig(false);
|
|
toast.error("Upload failed", { description: error.message });
|
|
return;
|
|
}
|
|
const { error: upErr } = await supabase
|
|
.from("profiles")
|
|
.update({ signature_image_path: path } as any)
|
|
.eq("id", user.id);
|
|
setUploadingSig(false);
|
|
if (upErr) {
|
|
toast.error("Could not save signature", { description: upErr.message });
|
|
return;
|
|
}
|
|
setForm((f) => ({ ...f, signature_image_path: path }));
|
|
toast.success("Signature image saved");
|
|
};
|
|
|
|
const removeSignatureImage = async () => {
|
|
if (!user) return;
|
|
const { error } = await supabase
|
|
.from("profiles")
|
|
.update({ signature_image_path: null } as any)
|
|
.eq("id", user.id);
|
|
if (error) {
|
|
toast.error("Could not remove", { description: error.message });
|
|
return;
|
|
}
|
|
setForm((f) => ({ ...f, signature_image_path: "" }));
|
|
toast.success("Signature image 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,
|
|
signature_block: form.signature_block || null,
|
|
} as any)
|
|
.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 (
|
|
<div className="flex justify-center py-16">
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 max-w-3xl">
|
|
<Card className="border-border/60">
|
|
<CardHeader>
|
|
<CardTitle className="font-serif text-base flex items-center gap-2">
|
|
<UserIcon className="h-4 w-4 text-muted-foreground" /> Profile photo
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-wrap items-center gap-6">
|
|
<Avatar className="h-24 w-24">
|
|
{form.avatar_url ? <AvatarImage src={form.avatar_url} alt="Avatar" /> : null}
|
|
<AvatarFallback className="text-lg">
|
|
{initials(form.full_name, email)}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex flex-col gap-2">
|
|
<input
|
|
ref={fileInput}
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const f = e.target.files?.[0];
|
|
if (f) onAvatarFile(f);
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => fileInput.current?.click()}
|
|
disabled={uploading}
|
|
>
|
|
{uploading ? (
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
) : (
|
|
<Upload className="h-4 w-4 mr-2" />
|
|
)}
|
|
Upload photo
|
|
</Button>
|
|
{form.avatar_url && (
|
|
<Button variant="ghost" size="sm" onClick={removeAvatar}>
|
|
<Trash2 className="h-4 w-4 mr-2" /> Remove
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">PNG or JPG, up to 5 MB.</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-border/60">
|
|
<CardHeader>
|
|
<CardTitle className="font-serif text-base">Personal information</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-4 sm:grid-cols-2">
|
|
<Field label="Full name">
|
|
<Input
|
|
value={form.full_name}
|
|
onChange={(e) => update("full_name", e.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field label="Email">
|
|
<Input value={email} disabled />
|
|
</Field>
|
|
<Field label="Job title">
|
|
<Input
|
|
value={form.title}
|
|
onChange={(e) => update("title", e.target.value)}
|
|
placeholder="Attorney, Paralegal, etc."
|
|
/>
|
|
</Field>
|
|
<Field label="Phone">
|
|
<Input
|
|
value={form.phone}
|
|
onChange={(e) => update("phone", e.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field label="Timezone">
|
|
<Input
|
|
value={form.timezone}
|
|
onChange={(e) => update("timezone", e.target.value)}
|
|
placeholder="America/New_York"
|
|
/>
|
|
</Field>
|
|
<Field label="Bio" className="sm:col-span-2">
|
|
<Textarea
|
|
rows={4}
|
|
value={form.bio}
|
|
onChange={(e) => update("bio", e.target.value)}
|
|
placeholder="A short introduction shown on your profile."
|
|
/>
|
|
</Field>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-border/60">
|
|
<CardHeader>
|
|
<CardTitle className="font-serif text-base flex items-center gap-2">
|
|
<PenLine className="h-4 w-4 text-muted-foreground" /> Attorney signature
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-5">
|
|
<p className="text-xs text-muted-foreground">
|
|
Used in pleadings. The signature line is drawn automatically; the block
|
|
below appears under it. Optionally upload a scanned signature image to
|
|
place above the line.
|
|
</p>
|
|
|
|
<Field label="Signature block">
|
|
<Textarea
|
|
rows={6}
|
|
value={form.signature_block}
|
|
onChange={(e) => update("signature_block", e.target.value)}
|
|
placeholder={`Jane Q. Attorney, Esq.\nFlorida Bar No. 123456\nSmith & Associates, P.A.\n123 Main St., Suite 200\nMiami, FL 33131\n(305) 555-0100 · jane@smithlaw.com`}
|
|
className="font-mono text-sm"
|
|
/>
|
|
</Field>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">Signature image (optional)</Label>
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
<div className="border rounded-md bg-white p-3 min-w-[220px] min-h-[80px] flex items-center justify-center">
|
|
{signatureUrl ? (
|
|
<img
|
|
src={signatureUrl}
|
|
alt="Signature"
|
|
className="max-h-[80px] max-w-[260px] object-contain"
|
|
/>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground italic">
|
|
No signature image
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
<input
|
|
ref={sigInput}
|
|
type="file"
|
|
accept="image/png,image/jpeg"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const f = e.target.files?.[0];
|
|
if (f) onSignatureFile(f);
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => sigInput.current?.click()}
|
|
disabled={uploadingSig}
|
|
>
|
|
{uploadingSig ? (
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
) : (
|
|
<Upload className="h-4 w-4 mr-2" />
|
|
)}
|
|
Upload signature
|
|
</Button>
|
|
{form.signature_image_path && (
|
|
<Button variant="ghost" size="sm" onClick={removeSignatureImage}>
|
|
<Trash2 className="h-4 w-4 mr-2" /> Remove
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
PNG or JPG with transparent or white background, up to 2 MB.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="flex justify-end">
|
|
<Button onClick={save} disabled={saving}>
|
|
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
|
Save changes
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Field({
|
|
label,
|
|
className,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
className?: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className={`space-y-1.5 ${className ?? ""}`}>
|
|
<Label className="text-xs">{label}</Label>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|