feat: social graph features
This commit is contained in:
@@ -18,12 +18,40 @@
|
||||
import { useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { ImagePlus, Save, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { auth, AuthError, type User } from "@/lib/auth";
|
||||
import { COUNTRIES } from "@/lib/countries";
|
||||
import { SAMPRADAYAS } from "@/lib/sampradayas";
|
||||
import { compressImage } from "@/lib/compress-image";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { DatePicker } from "@/components/ui/date-picker";
|
||||
|
||||
/** Normalises a stored date/timestamp to the "YYYY-MM-DD" an
|
||||
* <input type="date"> expects. Returns "" for null. */
|
||||
function dateInput(s: string | null): string {
|
||||
return s ? s.slice(0, 10) : "";
|
||||
}
|
||||
|
||||
// Option lists for the comboboxes. "" is a real, selectable value meaning
|
||||
// "not specified" for the optional fields.
|
||||
const COUNTRY_OPTIONS: ComboboxOption[] = [
|
||||
{ value: "", label: "Not specified" },
|
||||
...COUNTRIES.map((c) => ({ value: c.code, label: c.name })),
|
||||
];
|
||||
const SAMPRADAYA_OPTIONS: ComboboxOption[] = [
|
||||
{ value: "", label: "Not specified" },
|
||||
...SAMPRADAYAS.map((s) => ({ value: s.code, label: s.name })),
|
||||
];
|
||||
const GENDER_OPTIONS: ComboboxOption[] = [
|
||||
{ value: "", label: "Prefer not to say" },
|
||||
{ value: "male", label: "Male" },
|
||||
{ value: "female", label: "Female" },
|
||||
{ value: "other", label: "Other" },
|
||||
];
|
||||
|
||||
export function ProfileEditForm({
|
||||
user,
|
||||
@@ -38,29 +66,81 @@ export function ProfileEditForm({
|
||||
const [name, setName] = useState(user.name);
|
||||
const [handle, setHandle] = useState(user.handle);
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(user.avatar_url);
|
||||
const [country, setCountry] = useState<string>(user.country ?? "");
|
||||
// Devotional details. Each "" means "not specified".
|
||||
const [sampradaya, setSampradaya] = useState<string>(user.sampradaya ?? "");
|
||||
const [gender, setGender] = useState<string>(user.gender ?? "");
|
||||
// `?? false` guards against a cached user object from before this field
|
||||
// existed (undefined → uncontrolled checkbox → React warning).
|
||||
const [initiated, setInitiated] = useState<boolean>(user.initiated ?? false);
|
||||
const [initiatedAt, setInitiatedAt] = useState<string>(
|
||||
dateInput(user.initiated_at),
|
||||
);
|
||||
const [rounds, setRounds] = useState<string>(
|
||||
user.daily_chanting_rounds != null ? String(user.daily_chanting_rounds) : "",
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Originals normalised for comparison. The initiation date only counts
|
||||
// when the devotee is marked initiated — un-checking it clears the date.
|
||||
const origRounds =
|
||||
user.daily_chanting_rounds != null ? String(user.daily_chanting_rounds) : "";
|
||||
const effInitiatedAt = initiated ? initiatedAt : "";
|
||||
|
||||
const dirty =
|
||||
name.trim() !== user.name ||
|
||||
handle.trim() !== user.handle ||
|
||||
avatarUrl !== user.avatar_url;
|
||||
avatarUrl !== user.avatar_url ||
|
||||
country !== (user.country ?? "") ||
|
||||
sampradaya !== (user.sampradaya ?? "") ||
|
||||
gender !== (user.gender ?? "") ||
|
||||
initiated !== (user.initiated ?? false) ||
|
||||
effInitiatedAt !== dateInput(user.initiated_at) ||
|
||||
rounds !== origRounds;
|
||||
|
||||
async function onSubmit(e: React.SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
// Client-side guard for the round count (the BFF re-checks 1–1008).
|
||||
if (rounds !== "") {
|
||||
const n = Number(rounds);
|
||||
if (!Number.isInteger(n) || n < 1 || n > 1008) {
|
||||
setError("Daily chanting rounds must be a whole number between 1 and 1008.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
// Patch only what's changed — keeps the audit log tidy and avoids
|
||||
// bumping updated_at when the user just clicked "Save" without edits.
|
||||
const updated = await auth.updateProfile(
|
||||
name.trim() !== user.name ? name.trim() : undefined,
|
||||
avatarUrl === user.avatar_url ? undefined : (avatarUrl ?? ""),
|
||||
handle.trim() !== user.handle ? handle.trim() : undefined,
|
||||
);
|
||||
// Empty strings clear the nullable fields; 0 clears the round count.
|
||||
const patch: Parameters<typeof auth.updateProfile>[0] = {};
|
||||
if (name.trim() !== user.name) patch.name = name.trim();
|
||||
if (avatarUrl !== user.avatar_url) patch.avatar_url = avatarUrl ?? "";
|
||||
if (handle.trim() !== user.handle) patch.handle = handle.trim();
|
||||
if (country !== (user.country ?? "")) patch.country = country;
|
||||
// "" clears the lineage; a code sets it.
|
||||
if (sampradaya !== (user.sampradaya ?? "")) patch.sampradaya = sampradaya;
|
||||
if (gender !== (user.gender ?? "")) patch.gender = gender;
|
||||
if (initiated !== (user.initiated ?? false)) patch.initiated = initiated;
|
||||
if (effInitiatedAt !== dateInput(user.initiated_at)) {
|
||||
patch.initiated_at = effInitiatedAt;
|
||||
}
|
||||
if (rounds !== origRounds) {
|
||||
patch.daily_chanting_rounds = rounds === "" ? 0 : Number(rounds);
|
||||
}
|
||||
|
||||
const updated = await auth.updateProfile(patch);
|
||||
// Update the cached user in place and STAY on the edit form. The
|
||||
// form's fields re-baseline against the new `user` prop (so it's no
|
||||
// longer "dirty"), and we keep the ?edit=1 URL rather than bouncing
|
||||
// back to the profile view. Exit is via Cancel/✕.
|
||||
setUser(updated);
|
||||
onDone();
|
||||
toast.success("Profile saved");
|
||||
} catch (err) {
|
||||
setError(err instanceof AuthError ? err.message : "Could not save.");
|
||||
} finally {
|
||||
@@ -89,7 +169,7 @@ export function ProfileEditForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-6 rounded-2xl bg-card ring-1 ring-foreground/10 p-5 sm:p-6">
|
||||
<form onSubmit={onSubmit} className="space-y-8 rounded-2xl bg-card ring-1 ring-foreground/10 p-5 sm:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-[family-name:var(--font-title)] text-xl sm:text-2xl font-semibold text-foreground">
|
||||
Edit profile
|
||||
@@ -139,7 +219,10 @@ export function ProfileEditForm({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 pt-6 border-t border-border">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Account
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="profile-name">Display name</Label>
|
||||
<Input
|
||||
@@ -153,8 +236,17 @@ export function ProfileEditForm({
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="profile-handle">Handle</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">@</span>
|
||||
{/* "@" sits inside the field as a fixed, non-editable prefix —
|
||||
pointer-events-none lets clicks fall through to the input, and
|
||||
the input is padded so the text never overlaps it. The stored
|
||||
value is still just the handle (no leading @). */}
|
||||
<div className="relative">
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground"
|
||||
>
|
||||
@
|
||||
</span>
|
||||
<Input
|
||||
id="profile-handle"
|
||||
value={handle}
|
||||
@@ -162,6 +254,7 @@ export function ProfileEditForm({
|
||||
maxLength={32}
|
||||
pattern="[a-zA-Z0-9._-]+"
|
||||
required
|
||||
className="pl-7"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -169,6 +262,23 @@ export function ProfileEditForm({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="profile-country">Country</Label>
|
||||
<Combobox
|
||||
id="profile-country"
|
||||
ariaLabel="Country"
|
||||
options={COUNTRY_OPTIONS}
|
||||
value={country}
|
||||
onChange={setCountry}
|
||||
placeholder="Not specified"
|
||||
searchPlaceholder="Search countries…"
|
||||
emptyText="No matching country."
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Shown on your profile's “About this account” panel.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="profile-email">Email</Label>
|
||||
{user.email ? (
|
||||
@@ -184,9 +294,92 @@ export function ProfileEditForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-6 border-t border-border">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Devotional details
|
||||
</h3>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="profile-sampradaya">Sampradāya</Label>
|
||||
<Combobox
|
||||
id="profile-sampradaya"
|
||||
ariaLabel="Sampradāya"
|
||||
options={SAMPRADAYA_OPTIONS}
|
||||
value={sampradaya}
|
||||
onChange={setSampradaya}
|
||||
placeholder="Not specified"
|
||||
searchPlaceholder="Search sampradāya…"
|
||||
emptyText="No matching sampradāya."
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional. Leave blank if you'd rather not say.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="profile-gender">Gender</Label>
|
||||
<Combobox
|
||||
id="profile-gender"
|
||||
ariaLabel="Gender"
|
||||
options={GENDER_OPTIONS}
|
||||
value={gender}
|
||||
onChange={setGender}
|
||||
searchable={false}
|
||||
placeholder="Prefer not to say"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Label htmlFor="profile-initiated">Initiated</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Have you received initiation (dīkṣā)?
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
id="profile-initiated"
|
||||
type="checkbox"
|
||||
checked={initiated}
|
||||
onChange={(e) => setInitiated(e.target.checked)}
|
||||
className="size-5 shrink-0 accent-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{initiated ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="profile-initiated-at">Initiation date</Label>
|
||||
<DatePicker
|
||||
id="profile-initiated-at"
|
||||
ariaLabel="Initiation date"
|
||||
value={initiatedAt}
|
||||
onChange={setInitiatedAt}
|
||||
max={new Date().toISOString().slice(0, 10)}
|
||||
placeholder="Select initiation date"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="profile-rounds">Daily chanting rounds</Label>
|
||||
<Input
|
||||
id="profile-rounds"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={1008}
|
||||
value={rounds}
|
||||
onChange={(e) => setRounds(e.target.value)}
|
||||
placeholder="e.g. 16"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your daily japa vow (1–1008). Leave blank if you'd rather not say.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
||||
<div className="flex items-center justify-end gap-2 pt-6 border-t border-border">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
Reference in New Issue
Block a user