feat: social graph features

This commit is contained in:
gosangam
2026-06-04 19:30:50 +05:30
parent 0f433c5708
commit 684ea2ae97
16 changed files with 2504 additions and 119 deletions
+205 -12
View File
@@ -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&apos;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&apos;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&apos;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"