diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..fa561a0 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "Next.js dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 3000 + } + ] +} diff --git a/app/dashboard/BadgesModal.tsx b/app/dashboard/BadgesModal.tsx new file mode 100644 index 0000000..e201c6c --- /dev/null +++ b/app/dashboard/BadgesModal.tsx @@ -0,0 +1,602 @@ +"use client"; + +import { AnimatePresence, LayoutGroup, Reorder, motion } from "framer-motion"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Award, Check, GripVertical, Loader2, Save, X } from "lucide-react"; +import { useToast } from "@/components/Toast"; +import { useScrollLock } from "@/lib/useScrollLock"; +import { cn } from "@/lib/cn"; +import type { Auth } from "@/lib/auth"; +import { getSigner } from "@/lib/nostrSigner"; +import { + publishProfileBadges, + type AwardedBadge, + type ProfileBadges, +} from "@/lib/nostrBadges"; + +export default function BadgesModal({ + open, + onClose, + auth, + badges, + loading, + profileBadges, + onProfileBadgesUpdated, +}: { + open: boolean; + onClose: () => void; + auth: Auth; + badges: AwardedBadge[]; + loading: boolean; + profileBadges: ProfileBadges | null; + onProfileBadgesUpdated: (pb: ProfileBadges) => void; +}) { + const { push: pushToast } = useToast(); + const [selected, setSelected] = useState([]); + const [saving, setSaving] = useState(false); + + useScrollLock(open); + + // Hydrate selection from the live profile_badges whenever the modal opens. + useEffect(() => { + if (!open) return; + setSelected(profileBadges?.aTags ?? []); + }, [open, profileBadges]); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && !saving) onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose, saving]); + + // Positional comparison: adding, removing AND reordering all count as + // changes, because the `selected` order maps 1:1 onto the published NIP-58 + // event's tag order. + const currentATags = useMemo( + () => profileBadges?.aTags ?? [], + [profileBadges], + ); + const dirty = useMemo(() => { + if (selected.length !== currentATags.length) return true; + return selected.some((a, i) => a !== currentATags[i]); + }, [selected, currentATags]); + + function toggle(aTag: string) { + if (saving) return; + setSelected((prev) => + prev.includes(aTag) ? prev.filter((a) => a !== aTag) : [...prev, aTag], + ); + } + + // Partition into worn (selected) and unworn. Worn follow the user-defined + // `selected` order (which is what gets published to NIP-58). Unworn fall + // back to the raw discovery order from `badges`. + const wornBadges = useMemo(() => { + const byATag = new Map(badges.map((b) => [b.aTag, b])); + return selected + .map((a) => byATag.get(a)) + .filter((b): b is AwardedBadge => Boolean(b)); + }, [badges, selected]); + const unwornBadges = useMemo( + () => badges.filter((b) => !selected.includes(b.aTag)), + [badges, selected], + ); + // Only the subset of `selected` that has a resolved badge; passed to the + // reorder group so we don't expose unresolved aTags (they'd have no UI + // element to drag). + const wornATags = useMemo( + () => wornBadges.map((b) => b.aTag), + [wornBadges], + ); + + function handleReorder(next: string[]) { + // Preserve any previously-selected aTags whose badge hasn't resolved — + // tack them onto the end so they survive the publish. + const unresolvedTail = selected.filter((a) => !wornATags.includes(a)); + setSelected([...next, ...unresolvedTail]); + } + + async function save() { + if (!dirty || saving) return; + setSaving(true); + let signer: Awaited> | null = null; + try { + signer = await getSigner(auth, { + onAuthUrl: (url) => { + pushToast({ + kind: "info", + title: "Autorizá en tu bunker", + description: url, + duration: 15000, + }); + try { + window.open(url, "_blank", "noopener,noreferrer"); + } catch { + /* popup blocked */ + } + }, + }); + const worn = selected + .map((a) => badges.find((b) => b.aTag === a)) + .filter((b): b is AwardedBadge => Boolean(b)); + const result = await publishProfileBadges(signer, worn); + const ok = result.relays.filter((r) => r.ok).length; + pushToast({ + kind: "success", + title: "Badges actualizados", + description: `Publicado en ${ok}/${result.relays.length} relays.`, + }); + onProfileBadgesUpdated({ + aTags: worn.map((b) => b.aTag), + eventIdByATag: Object.fromEntries(worn.map((b) => [b.aTag, b.awardId])), + eventId: result.signed.id, + eventCreatedAt: result.signed.created_at, + }); + onClose(); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + pushToast({ + kind: "error", + title: "No se pudo guardar", + description: msg, + duration: 12000, + }); + } finally { + signer?.close?.().catch(() => {}); + setSaving(false); + } + } + + return ( + + {open && ( + + !saving && onClose()} + className="absolute inset-0 bg-black/80 backdrop-blur-md" + /> + +
+ +
+
+
+ +
+
+

+ Mis badges +

+

+ NIP-58 · {badges.length} otorgado + {badges.length === 1 ? "" : "s"} + {selected.length > 0 && ( + <> + {" · "} + + {selected.length} usado + {selected.length === 1 ? "" : "s"} en perfil + + + )} +

+
+
+ +
+ +
+ {loading && badges.length === 0 ? ( +
+ + + buscando badges en relays… + +
+ ) : badges.length === 0 ? ( +
+ +

+ Todavía no recibiste badges +

+

+ Cuando alguien te otorgue un badge NIP-58 lo vas a ver acá + y vas a poder elegir cuáles mostrar en tu perfil. +

+
+ ) : ( + +
+ + + {unwornBadges.length > 0 && ( + + + {unwornBadges.map((b) => ( + toggle(b.aTag)} + disabled={saving} + /> + ))} + + + )} +
+
+ )} +
+ +
+
+ {dirty + ? "Hay cambios sin guardar" + : "Sincronizado con tu perfil"} +
+
+ + +
+
+ + + )} + + ); +} + +/** "En el perfil" section — draggable reorder via Framer Motion's Reorder. + * The values passed to Reorder.Group are the aTags (strings) which maps + * directly to the `selected` state; `onReorder` receives the re-ordered + * aTags which we merge back into the selection. */ +function WornSection({ + badges, + values, + onReorder, + onToggle, + disabled, +}: { + badges: AwardedBadge[]; + values: string[]; + onReorder: (next: string[]) => void; + onToggle: (aTag: string) => void; + disabled: boolean; +}) { + const accent: "success" | "muted" = "success"; + return ( +
+
+

+ En el perfil +

+ + {badges.length} + + {badges.length > 1 && ( + + + arrastrá para reordenar + + )} +
+ {badges.length === 0 ? ( +
+ Todavía no elegiste ningún badge para mostrar. +
+ ) : ( + + + {badges.map((b) => ( + onToggle(b.aTag)} + /> + ))} + + + )} +
+ ); +} + +/** + * A single worn badge, wrapped in a `Reorder.Item` so drag-to-reorder works. + * + * Framer Motion's `Reorder.Item` captures pointer events for drag detection + * and swallows native clicks. Its built-in `onTap` *should* fire on a + * pointer release without drag, but in practice (especially with a parent + * `LayoutGroup` + `layoutId` cross-section animation) it can miss clicks. + * We therefore add our own manual tap discriminator: remember + * pointer-down position + time, and on pointer-up fire the toggle if the + * release happened close enough (in space and time) to count as a tap. + */ +function DraggableWornBadge({ + badge, + disabled, + onToggle, +}: { + badge: AwardedBadge; + disabled: boolean; + onToggle: () => void; +}) { + const tapStart = useRef<{ x: number; y: number } | null>(null); + // Distance-based discriminator: anything under this counts as a tap, + // otherwise Framer's drag kicked in. No time gate — users can press and + // hold without triggering a toggle. + const TAP_MAX_DISTANCE = 6; // px + const name = badge.definition?.name ?? badge.definition?.d ?? "Badge"; + + return ( + { + tapStart.current = { x: e.clientX, y: e.clientY }; + }} + onPointerUp={(e) => { + const start = tapStart.current; + tapStart.current = null; + if (disabled || !start) return; + const dist = Math.hypot(e.clientX - start.x, e.clientY - start.y); + if (dist <= TAP_MAX_DISTANCE) { + onToggle(); + } + }} + onPointerCancel={() => { + tapStart.current = null; + }} + role="button" + aria-pressed={true} + aria-label={`Quitar del perfil: ${name}`} + title={name} + className={cn( + "relative aspect-square touch-none select-none rounded-lg overflow-hidden border transition-[border-color,box-shadow] border-success/40 shadow-[0_0_0_1px_rgba(34,197,94,0.18)] group", + disabled ? "cursor-progress" : "cursor-grab active:cursor-grabbing", + )} + > + + + ); +} + +/** Presentational badge content — shared by the draggable Reorder.Item + * (worn) and the plain motion.button (unworn). No motion/event wiring; + * the parent owns click/drag behavior. */ +function BadgeVisual({ + badge, + selected, +}: { + badge: AwardedBadge; + selected: boolean; +}) { + const def = badge.definition; + const [imgOk, setImgOk] = useState(true); + const image = imgOk ? (def?.thumb ?? def?.image) : null; + const name = def?.name || def?.d || "Badge"; + return ( + <> +
+ {image ? ( + // eslint-disable-next-line @next/next/no-img-element + {name} setImgOk(false)} + /> + ) : ( +
+ +
+ )} + + {selected && ( + + + + )} + +
+
+ {name} +
+
+ + ); +} + +/** Section wrapper that titles + counts a group of badge tiles. The grid + * itself is sized with `auto-fill` so the tiles stay tight regardless of + * how many badges fall into this section. */ +function BadgeSection({ + title, + count, + empty, + accent, + children, +}: { + title: string; + count: number; + empty?: string; + accent: "success" | "muted"; + children: React.ReactNode; +}) { + return ( +
+
+

+ {title} +

+ + {count} + +
+ {count === 0 && empty ? ( +
+ {empty} +
+ ) : ( + + {children} + + )} +
+ ); +} + +/** + * Compact badge tile — 50% smaller than before. Single click toggles + * selection. Selected tiles display a green check overlay in the top-right + * corner; unselected tiles render at 50% opacity and pop to full opacity on + * hover. + * + * Uses a Framer Motion `layoutId` so that when a badge moves between the + * two sections (worn ↔ unworn) on toggle, it animates across the grid + * rather than popping in/out abruptly. + */ +function MiniBadgeTile({ + badge, + selected, + onToggle, + disabled, +}: { + badge: AwardedBadge; + selected: boolean; + onToggle: () => void; + disabled: boolean; +}) { + const def = badge.definition; + const name = def?.name || def?.d || "Badge"; + return ( + + + + ); +} diff --git a/app/dashboard/DashboardClient.tsx b/app/dashboard/DashboardClient.tsx index 8030812..6964202 100644 --- a/app/dashboard/DashboardClient.tsx +++ b/app/dashboard/DashboardClient.tsx @@ -1,13 +1,12 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; -import { motion } from "framer-motion"; +import { AnimatePresence, LayoutGroup, motion } from "framer-motion"; import { Copy, Check, - LogOut, Key, Shield, Globe, @@ -18,14 +17,55 @@ import { Radio, Loader2, ExternalLink, + FolderGit2, + ArrowRight, + Award, + Pencil, + Save, + X, + Upload, + Image as ImageIcon, + Plus, + Trash2, + ArrowDownUp, + ArrowDown, + ArrowUp, } from "lucide-react"; -import { useAuth, clearAuth, type Auth } from "@/lib/auth"; +import { useAuth, type Auth } from "@/lib/auth"; import { useNostrProfile, + publishNostrProfile, DEFAULT_PROFILE_RELAYS, type NostrProfile as BaseNostrProfile, } from "@/lib/nostrProfile"; +import { + useRelayList, + publishRelayList, + normalizeRelayUrl, + SUGGESTED_RELAYS, + type RelayEntry, + type RelayMarker, +} from "@/lib/nostrRelays"; +import { + useUserBadges, + useProfileBadges, + type AwardedBadge, + type ProfileBadges, +} from "@/lib/nostrBadges"; +import { getSigner } from "@/lib/nostrSigner"; +import { + uploadToBlossom, + fetchBlobWithProgress, + DEFAULT_BLOSSOM_SERVERS, + type UploadProgress, +} from "@/lib/blossom"; +import { useToast } from "@/components/Toast"; +import { useScrollLock } from "@/lib/useScrollLock"; import { cn } from "@/lib/cn"; +import BadgesModal from "./BadgesModal"; +import ImageCropModal, { + type CropResult, +} from "@/components/ImageCropModal"; type DashboardProfile = BaseNostrProfile & { pubkey: string; @@ -52,6 +92,33 @@ export default function DashboardClient() { loading, } = useNostrProfile(auth?.pubkey, relays); + const { badges, loading: badgesLoading } = useUserBadges(auth?.pubkey); + const { profileBadges, override: setProfileBadges } = useProfileBadges( + auth?.pubkey, + ); + const [badgesModalOpen, setBadgesModalOpen] = useState(false); + const [profileEditorOpen, setProfileEditorOpen] = useState(false); + const [relaysEditorOpen, setRelaysEditorOpen] = useState(false); + + // Badges the user has chosen to wear — intersect profile_badges with actually + // resolved awards so we never render a broken tile. + const wornBadges = useMemo(() => { + if (!profileBadges) return []; + const byATag = new Map(badges.map((b) => [b.aTag, b])); + return profileBadges.aTags + .map((a) => byATag.get(a)) + .filter((b): b is AwardedBadge => Boolean(b)); + }, [profileBadges, badges]); + + const wornATags = useMemo( + () => new Set(wornBadges.map((b) => b.aTag)), + [wornBadges], + ); + const unwornBadges = useMemo( + () => badges.filter((b) => !wornATags.has(b.aTag)), + [badges, wornATags], + ); + const profile: DashboardProfile | null = useMemo(() => { if (!auth) return null; if (!baseProfile) return { pubkey: auth.pubkey }; @@ -86,11 +153,6 @@ export default function DashboardClient() { })(); }, [auth]); - function logout() { - clearAuth(); - router.push("/"); - } - if (!ready) { return (
@@ -109,7 +171,10 @@ export default function DashboardClient() { auth={auth} profile={profile} loading={loading} - onLogout={logout} + wornBadges={wornBadges} + onOpenBadges={() => setBadgesModalOpen(true)} + onEditProfile={() => setProfileEditorOpen(true)} + hasAnyBadge={badges.length > 0} /> {error && ( @@ -128,6 +193,39 @@ export default function DashboardClient() { )} + setBadgesModalOpen(true)} + totalCount={badges.length} + wornCount={wornBadges.length} + /> + + +
+
+
+ +
+
+
+ NIP-78 · FIRMADOS POR VOS +
+
+ Mis proyectos +
+
+ Creá y editá tu portfolio. Se guarda firmado en relays + Nostr — vos sos dueño de tu data. +
+
+ +
+ + }>
@@ -162,74 +260,11 @@ export default function DashboardClient() {
- }> -
-
- Método - - {auth.method === "nip07" ? ( - <> - - NIP-07 - - ) : ( - <> - - NIP-46 - - )} - -
- {auth.method === "nip46" && auth.bunker?.pubkey && ( -
- Bunker - - {shorten(auth.bunker.pubkey)} - -
- )} - {profile?.createdAt && ( -
- - - Perfil actualizado - - - {new Date(profile.createdAt * 1000).toLocaleDateString( - "es-AR", - { day: "2-digit", month: "short", year: "numeric" }, - )} - -
- )} -
- -
- - }> -
    - {relaysUsed.map((r) => ( -
  • - - {r.replace("wss://", "")} -
  • - ))} - {relaysUsed.length === 0 && ( -
  • - Conectando… -
  • - )} -
-
+ setRelaysEditorOpen(true)} + />
+ + setBadgesModalOpen(false)} + auth={auth} + badges={badges} + loading={badgesLoading} + profileBadges={profileBadges} + onProfileBadgesUpdated={(pb) => setProfileBadges(pb)} + /> + + setProfileEditorOpen(false)} + auth={auth} + current={baseProfile} + relays={relays} + /> + + setRelaysEditorOpen(false)} + auth={auth} + publishRelays={relays} + />
); } @@ -279,12 +339,18 @@ function ProfileHeader({ auth, profile, loading, - onLogout, + wornBadges, + onOpenBadges, + onEditProfile, + hasAnyBadge, }: { auth: Auth; profile: DashboardProfile | null; loading: boolean; - onLogout: () => void; + wornBadges: AwardedBadge[]; + onOpenBadges: () => void; + onEditProfile: () => void; + hasAnyBadge: boolean; }) { const displayName = profile?.display_name || profile?.name || shorten(auth.pubkey); @@ -298,25 +364,42 @@ function ProfileHeader({ className="flex flex-col sm:flex-row sm:items-end gap-4 sm:gap-6" >
-
- {profile?.picture ? ( - {displayName} { - const img = e.currentTarget as HTMLImageElement; - img.style.display = "none"; - }} - /> - ) : loading ? ( -
- ) : ( -
- {displayName.slice(0, 2).toUpperCase()} +
@@ -333,6 +416,15 @@ function ProfileHeader({ )} +
{profile?.name && @{handle}} @@ -347,6 +439,11 @@ function ProfileHeader({ NIP-07 + ) : auth.method === "local" ? ( + + + LOCAL + ) : ( @@ -354,17 +451,19 @@ function ProfileHeader({ )}
-
-
- + {/* Badges worn in profile */} + {(wornBadges.length > 0 || hasAnyBadge) && ( +
+ +
+ )}
+ ); } @@ -469,3 +568,1815 @@ function InfoRow({ function shorten(pubkey: string) { return `${pubkey.slice(0, 8)}…${pubkey.slice(-6)}`; } + +function BadgesCard({ + badges, + loading, + onManage, + totalCount, + wornCount, +}: { + badges: AwardedBadge[]; + loading: boolean; + onManage: () => void; + totalCount: number; + wornCount: number; +}) { + if (!loading && totalCount === 0) return null; + + return ( +
+
+
+ + + +

+ Otros badges +

+ {totalCount > 0 && ( + + NIP-58 · {totalCount} + {wornCount > 0 && <> · {wornCount} en perfil} + + )} +
+
+ {loading && ( + + )} + +
+
+ + {badges.length === 0 ? ( +
+ {totalCount > 0 + ? "Todos tus badges están en el perfil." + : "Buscando badges en relays…"} +
+ ) : ( + + )} +
+ ); +} + +/** Compact horizontal row of badge chips. Shows up to `MAX_VISIBLE` badges; + * the rest collapse into a "+N" overflow chip that opens the manage modal. */ +function BadgeStrip({ + badges, + onManage, + maxVisible = 8, +}: { + badges: AwardedBadge[]; + onManage: () => void; + maxVisible?: number; +}) { + const visible = badges.slice(0, maxVisible); + const extra = badges.length - visible.length; + return ( +
+ {visible.map((b) => ( + + ))} + {extra > 0 && ( + + )} +
+ ); +} + +function CompactBadgeTile({ + badge, + onClick, +}: { + badge: AwardedBadge; + onClick?: () => void; +}) { + const def = badge.definition; + const [imgOk, setImgOk] = useState(true); + const image = imgOk ? (def?.thumb ?? def?.image) : null; + const name = def?.name || def?.d || "Badge"; + const date = new Date(badge.awardedAt * 1000).toLocaleDateString("es-AR", { + month: "short", + year: "numeric", + }); + return ( + + ); +} + +/** + * Worn-badges strip. + * + * Flow: + * 1. All badges render as skeletons stacked on the right side of the pill. + * 2. Each image is preloaded via a hidden ; as it fires `onLoad` the + * badge hops to the left-stack (via Framer Motion `layoutId` tweens). + * 3. Every badge selected for the profile is shown — no arbitrary slice. + * + * Size is doubled (h-6 → h-12) so the details of each badge are legible. + */ +function WornBadgesRow({ + badges, + onOpen, + empty, +}: { + badges: AwardedBadge[]; + onOpen: () => void; + empty: boolean; +}) { + const [loadedIds, setLoadedIds] = useState>(new Set()); + const [brokenIds, setBrokenIds] = useState>(new Set()); + + function markLoaded(id: string) { + setLoadedIds((prev) => { + if (prev.has(id)) return prev; + const next = new Set(prev); + next.add(id); + return next; + }); + } + function markBroken(id: string) { + setBrokenIds((prev) => { + if (prev.has(id)) return prev; + const next = new Set(prev); + next.add(id); + return next; + }); + // Still "resolve" the badge so it doesn't sit in the skeleton column + // forever — we'll render the fallback Award icon in the left stack. + markLoaded(id); + } + + // Mark badges without any image immediately as "loaded" (they show a + // fallback icon so there's nothing to wait for). + useEffect(() => { + for (const b of badges) { + const src = b.definition?.thumb ?? b.definition?.image; + if (!src) markLoaded(b.awardId); + } + }, [badges]); + + if (empty) { + return ( + + ); + } + + const loaded = badges.filter((b) => loadedIds.has(b.awardId)); + const loading = badges.filter((b) => !loadedIds.has(b.awardId)); + + return ( +
+ {/* Hidden preloaders — they trigger onLoad once the browser caches the + blob, which is the signal to flip the chip into the loaded column. */} +
+ {badges.map((b) => { + const src = b.definition?.thumb ?? b.definition?.image; + if (!src || loadedIds.has(b.awardId)) return null; + return ( + // eslint-disable-next-line @next/next/no-img-element + markLoaded(b.awardId)} + onError={() => markBroken(b.awardId)} + /> + ); + })} +
+ + +
+ ); +} + +function BadgePill({ + badge, + state, +}: { + badge: AwardedBadge; + state: "loading" | "loaded" | "broken"; +}) { + const def = badge.definition; + const name = def?.name || def?.d || "Badge"; + const image = state === "loaded" ? (def?.thumb ?? def?.image) : null; + // Two sizes: ~12px (25% of final) while fetching the image, 48px once the + // badge has settled (loaded or irrecoverably broken). Framer's `layout` + // handles the size transition; we tune the spring so the growth feels + // *bouncy* when the image finally lands. + const isSettled = state !== "loading"; + return ( + + {image ? ( + + ) : state === "broken" ? ( + + + + ) : ( + // Skeleton dot — shimmer only (no watermark icon at 12px, too busy) + + )} + + ); +} + +/* ──────────────────────── Profile editor modal ─────────────────────── */ + +type ProfileForm = { + display_name: string; + name: string; + about: string; + picture: string; + banner: string; + nip05: string; + lud16: string; + website: string; +}; + +function emptyProfileForm(): ProfileForm { + return { + display_name: "", + name: "", + about: "", + picture: "", + banner: "", + nip05: "", + lud16: "", + website: "", + }; +} + +function profileToForm(p: BaseNostrProfile | null): ProfileForm { + if (!p) return emptyProfileForm(); + return { + display_name: p.display_name ?? "", + name: p.name ?? "", + about: p.about ?? "", + picture: p.picture ?? "", + banner: p.banner ?? "", + nip05: p.nip05 ?? "", + lud16: p.lud16 ?? "", + website: p.website ?? "", + }; +} + +type Phase = "idle" | "uploading-avatar" | "uploading-banner" | "signing" | "publishing" | "done"; + +function ProfileEditorModal({ + open, + onClose, + auth, + current, + relays, +}: { + open: boolean; + onClose: () => void; + auth: Auth; + current: BaseNostrProfile | null; + relays: string[]; +}) { + const { push: pushToast } = useToast(); + const [form, setForm] = useState(() => profileToForm(current)); + const [phase, setPhase] = useState("idle"); + const [phaseDetail, setPhaseDetail] = useState(null); + const [uploadState, setUploadState] = useState<{ + target: "picture" | "banner" | null; + server: string; + state: UploadProgress["state"]; + error?: string; + }>({ target: null, server: "", state: "ok" }); + const [error, setError] = useState(null); + + const avatarInputRef = useRef(null); + const bannerInputRef = useRef(null); + + // The file picker feeds into a crop step first — we open the crop modal + // with the selected file + target aspect, and only after the user + // confirms the crop do we actually hit Blossom. + const [cropSource, setCropSource] = useState<{ + target: "picture" | "banner"; + file: File; + } | null>(null); + + // Live upload overlay: while bytes are flying to Blossom we show the + // local cropped blob progressively revealing the preview L→R using the + // real upload percent. Once the upload finishes we fade the overlay out + // (so the swap to the remote URL never flashes), then unmount. + const [uploadReveal, setUploadReveal] = useState<{ + target: "picture" | "banner"; + localUrl: string; + /** 0..100 */ + percent: number; + /** When true the overlay fades to opacity 0 via CSS transition + * instead of just unmounting instantly. */ + fading?: boolean; + } | null>(null); + + // Ref mirror of cropSource so side-effect callbacks (handleCropConfirm) + // can read the latest value without relying on functional setter + // closures — which get double-invoked under React Strict Mode, causing + // `doUpload` to fire twice and clobber its own cleanup. + const cropSourceRef = useRef(cropSource); + useEffect(() => { + cropSourceRef.current = cropSource; + }, [cropSource]); + + useScrollLock(open); + + // Re-hydrate the form from current profile every time the modal opens. + useEffect(() => { + if (!open) return; + setForm(profileToForm(current)); + setPhase("idle"); + setPhaseDetail(null); + setError(null); + setUploadState({ target: null, server: "", state: "ok" }); + }, [open, current]); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && phase === "idle") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose, phase]); + + // Belt-and-suspenders: if the modal unmounts mid-upload, make sure we + // don't leak the blob URL used for the live reveal. + useEffect(() => { + return () => { + setUploadReveal((prev) => { + if (prev) URL.revokeObjectURL(prev.localUrl); + return null; + }); + }; + }, []); + + const busy = phase !== "idle"; + + function pickedFile(target: "picture" | "banner", file: File) { + if (!file.type.startsWith("image/")) { + pushToast({ + kind: "error", + title: "Archivo inválido", + description: "Solo imágenes (JPG, PNG, WebP, GIF).", + }); + return; + } + // Open the crop modal; actual upload happens in onConfirm below. + setCropSource({ target, file }); + } + + function handleCropConfirm(result: CropResult) { + const current = cropSourceRef.current; + setCropSource(null); + if (!current) return; + const cropped = new File([result.blob], result.filename, { + type: result.type, + }); + doUpload(current.target, cropped); + } + + async function doUpload(target: "picture" | "banner", file: File) { + setError(null); + setPhase(target === "picture" ? "uploading-avatar" : "uploading-banner"); + setPhaseDetail("firmando autorización Blossom…"); + setUploadState({ + target, + server: DEFAULT_BLOSSOM_SERVERS[0], + state: "signing", + }); + // Kick off the live-reveal overlay: the cropped blob is available + // locally immediately, so we can start showing it at percent=0 before + // a single byte has left the browser. + const localUrl = URL.createObjectURL(file); + setUploadReveal({ target, localUrl, percent: 0 }); + let signer: Awaited> | null = null; + try { + signer = await getSigner(auth, { + onAuthUrl: (url) => { + pushToast({ + kind: "info", + title: "Autorizá la firma en tu bunker", + description: url, + duration: 20000, + }); + try { + window.open(url, "_blank", "noopener,noreferrer"); + } catch { + /* popup blocked */ + } + }, + }); + // The reveal bar represents the full "image is ready" experience: + // · 0–65% = bytes being uploaded to Blossom (real XHR progress) + // · 65–100 = bytes being downloaded back from the CDN so the + // preview has actually landed on the user's browser + // Splitting it this way means the user sees one continuous sweep + // from crop → visible-remote image. + const UPLOAD_SHARE = 65; + const desc = await uploadToBlossom(file, signer, { + onProgress: (p) => { + setUploadState({ target, ...p }); + if (p.state === "uploading") { + setPhaseDetail(`subiendo a ${p.server.replace("https://", "")}`); + if (typeof p.percent === "number") { + setUploadReveal((prev) => + prev && prev.target === target + ? { + ...prev, + percent: Math.min( + UPLOAD_SHARE, + Math.round(p.percent! * UPLOAD_SHARE), + ), + } + : prev, + ); + } + } else if (p.state === "error") { + setPhaseDetail( + `falló ${p.server.replace("https://", "")}, probando siguiente…`, + ); + } + }, + }); + // Upload phase done — pin the reveal to the hand-off line. + setUploadReveal((prev) => + prev && prev.target === target + ? { ...prev, percent: UPLOAD_SHARE } + : prev, + ); + setPhaseDetail( + `bajando desde ${desc.server.replace("https://", "")}…`, + ); + setUploadState({ + target, + server: desc.server, + state: "uploading", + }); + + // Phase 2: fetch the remote blob so progress reflects real wait time. + // Best-effort — if the server blocks CORS or the connection hiccups + // we still treat the upload as a success (the URL is published + // either way). The HTTP cache warms up during this phase, so when + // the preview `` switches to the remote URL right after, it + // renders instantly with no flicker. + try { + await fetchBlobWithProgress(desc.url, (p) => { + if (typeof p.percent === "number") { + const ui = + UPLOAD_SHARE + Math.round(p.percent * (100 - UPLOAD_SHARE)); + setUploadReveal((prev) => + prev && prev.target === target + ? { ...prev, percent: ui } + : prev, + ); + } + }); + } catch (e) { + console.warn("[labs] remote image download progress failed", e); + } + + // Snap to 100% so the reveal visibly completes even if we bailed + // out of the fetch progress early. The overlay is now fully + // covering the preview (clip-path inset = 0 on all sides). + setUploadReveal((prev) => + prev && prev.target === target ? { ...prev, percent: 100 } : prev, + ); + + // Pre-decode the remote URL into an before we swap the form's + // src so the swap lands on already-painted pixels (no flash of + // the previous image while the new one loads). The HTTP cache was + // primed by the fetch above, so this usually resolves in a few ms. + await new Promise((resolve) => { + const pre = new Image(); + let settled = false; + const done = () => { + if (settled) return; + settled = true; + resolve(); + }; + pre.onload = done; + pre.onerror = done; + // Fail-safe: don't hang forever if the decode stalls. + window.setTimeout(done, 4000); + pre.src = desc.url; + }); + + setForm((prev) => ({ ...prev, [target]: desc.url })); + pushToast({ + kind: "success", + title: + target === "picture" ? "Avatar subido" : "Banner subido", + description: `Guardado en ${desc.server.replace("https://", "")}.`, + }); + + // Give React one commit + the browser a beat to paint the new + // `` under the overlay, then start a CSS opacity fade. + // This sequence guarantees no "flash of old image" during swap: + // · overlay at percent=100 covers everything + // · setForm swaps the bg src (preloaded, so instant) + // · overlay fades opacity 1→0 over 350ms + // · at the end we unmount + await new Promise((res) => window.setTimeout(res, 80)); + setUploadReveal((prev) => + prev && prev.target === target ? { ...prev, fading: true } : prev, + ); + await new Promise((res) => window.setTimeout(res, 380)); + setUploadReveal((prev) => + prev && prev.target === target ? null : prev, + ); + URL.revokeObjectURL(localUrl); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setError(msg); + pushToast({ + kind: "error", + title: "No se pudo subir la imagen", + description: msg.split("\n")[0], + duration: 12000, + }); + setUploadReveal((prev) => + prev && prev.target === target ? null : prev, + ); + URL.revokeObjectURL(localUrl); + } finally { + signer?.close?.().catch(() => {}); + setPhase("idle"); + setPhaseDetail(null); + } + } + + function pickFile(target: "picture" | "banner") { + const ref = target === "picture" ? avatarInputRef : bannerInputRef; + ref.current?.click(); + } + + async function handleSave() { + setError(null); + setPhase("signing"); + setPhaseDetail(null); + let signer: Awaited> | null = null; + try { + signer = await getSigner(auth, { + onAuthUrl: (url) => { + pushToast({ + kind: "info", + title: "Autorizá la firma en tu bunker", + description: url, + duration: 20000, + }); + try { + window.open(url, "_blank", "noopener,noreferrer"); + } catch { + /* popup blocked */ + } + }, + }); + setPhase("publishing"); + setPhaseDetail(`${relays.length} relays`); + const res = await publishNostrProfile(signer, form, relays); + const okCount = res.relays.filter((r) => r.ok).length; + setPhase("done"); + setPhaseDetail(`${okCount}/${res.relays.length} relays`); + pushToast({ + kind: "success", + title: "Perfil actualizado", + description: `Publicado en ${okCount}/${res.relays.length} relays.`, + }); + onClose(); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setError(msg); + pushToast({ + kind: "error", + title: "No se pudo guardar el perfil", + description: msg.split("\n")[0], + duration: 12000, + }); + } finally { + signer?.close?.().catch(() => {}); + setPhase("idle"); + setPhaseDetail(null); + } + } + + const saveLabel = + phase === "signing" + ? "Esperando firma…" + : phase === "publishing" + ? `Publicando${phaseDetail ? ` en ${phaseDetail}` : "…"}` + : phase === "uploading-avatar" + ? "Subiendo avatar…" + : phase === "uploading-banner" + ? "Subiendo banner…" + : phase === "done" + ? `Publicado ${phaseDetail ?? ""}` + : "Guardar"; + + return ( + <> + + {open && ( + + !busy && onClose()} + className="absolute inset-0 bg-black/80 backdrop-blur-md" + /> + +
+ +
+
+

Editar perfil

+

+ Kind:0 firmado y replicado en {relays.length} relays · imágenes + en Blossom +

+
+ +
+ +
+ {/* Banner upload area */} +
+ +
+ +
+
+ { + const file = e.target.files?.[0]; + e.currentTarget.value = ""; + if (file) pickedFile("banner", file); + }} + /> + +
+ {/* Avatar upload */} +
+ pickFile("picture")} + reveal={ + uploadReveal?.target === "picture" + ? { + localUrl: uploadReveal.localUrl, + percent: uploadReveal.percent, + fading: uploadReveal.fading, + } + : null + } + /> + { + const file = e.target.files?.[0]; + e.currentTarget.value = ""; + if (file) pickedFile("picture", file); + }} + /> +
+ {uploadState.target && uploadState.state !== "ok" && ( +
+ {uploadState.state === "signing" + ? "firmando autorización…" + : uploadState.state === "uploading" + ? `subiendo a ${uploadState.server.replace("https://", "")}` + : uploadState.state === "error" + ? `✗ ${uploadState.server.replace("https://", "")}: ${uploadState.error?.slice(0, 80)}` + : ""} +
+ )} +
+
+ +
+ + setForm((p) => ({ ...p, display_name: v })) + } + placeholder="Satoshi N." + disabled={busy} + /> + setForm((p) => ({ ...p, name: v }))} + placeholder="satoshi" + disabled={busy} + /> +
+ + setForm((p) => ({ ...p, about: v }))} + placeholder="Construyendo sobre Bitcoin, Lightning y Nostr…" + disabled={busy} + multiline + /> + +
+ setForm((p) => ({ ...p, nip05: v }))} + placeholder="vos@dominio.com" + disabled={busy} + mono + /> + setForm((p) => ({ ...p, lud16: v }))} + placeholder="vos@getalby.com" + disabled={busy} + mono + /> +
+ + setForm((p) => ({ ...p, website: v }))} + placeholder="https://…" + disabled={busy} + mono + /> + +
+ + URLs avanzadas + +
+ setForm((p) => ({ ...p, picture: v }))} + placeholder="https://…" + disabled={busy} + mono + /> + setForm((p) => ({ ...p, banner: v }))} + placeholder="https://…" + disabled={busy} + mono + /> +
+
+ + {error && ( +
+ {error.split("\n")[0]} +
+ )} +
+
+ +
+ + +
+ + + )} + + {/* Sibling to the outer AnimatePresence, not a child. Only mount the + crop modal while we actually have a source file — keeps Framer's + internal AnimatePresence from ever getting stuck in a stale + "present but opacity:0" state between uploads. */} + {cropSource && ( + setCropSource(null)} + /> + )} + + ); +} + +/** Upload-reveal descriptor — the local cropped blob URL + the 0..100 + * percent of bytes already uploaded. While this is set, the preview shows + * the new image progressively wiping over the old one left→right in sync + * with the real upload progress. `fading` triggers a CSS opacity + * transition so the final handoff to the remote URL is smooth. */ +type UploadReveal = { + localUrl: string; + percent: number; + fading?: boolean; +}; + +function BannerPreview({ + src, + uploading, + reveal, +}: { + src?: string; + uploading: boolean; + reveal: UploadReveal | null; +}) { + return ( +
+ {src ? ( + // eslint-disable-next-line @next/next/no-img-element + banner { + (e.currentTarget as HTMLImageElement).style.display = "none"; + }} + /> + ) : ( +
+ + Sin banner +
+ )} + {reveal && ( + + )} + {uploading && !reveal && ( +
+ +
+ )} +
+ ); +} + +/** + * Progress-bar reveal. The cropped local blob is drawn on top of the + * existing preview, but clipped to the left `percent%` — so as the real + * upload progresses, the new image sweeps in from the left replacing the + * old one. A thin glowing hairline marks the reveal edge and a small + * percent pill in the corner makes the numeric progress explicit. + * + * `fading=true` triggers a CSS opacity fade-out — used once the background + * `` has been swapped to the remote URL so the handoff is smooth + * (no flash of the previous image). + */ +function RevealOverlay({ + localUrl, + percent, + fading, +}: { + localUrl: string; + percent: number; + fading?: boolean; +}) { + const clamped = Math.max(0, Math.min(100, percent)); + return ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ {/* Glowing reveal edge */} +
0 && clamped < 100 ? 1 : 0, + }} + /> + {/* Percent pill */} +
+ + {clamped}% +
+
+ ); +} + +function AvatarPicker({ + picture, + name, + uploading, + disabled, + onPick, + reveal, +}: { + picture?: string; + name: string; + uploading: boolean; + disabled: boolean; + onPick: () => void; + reveal: UploadReveal | null; +}) { + const initials = name.slice(0, 2).toUpperCase(); + return ( + + ); +} + +function TextField({ + label, + hint, + value, + onChange, + placeholder, + disabled, + multiline, + mono, +}: { + label: string; + hint?: string; + value: string; + onChange: (v: string) => void; + placeholder?: string; + disabled?: boolean; + multiline?: boolean; + mono?: boolean; +}) { + const inputClass = cn( + "w-full px-3 py-2.5 rounded-lg bg-white/[0.03] border border-border focus:border-bitcoin/50 focus:bg-white/[0.05] transition-colors text-sm placeholder:text-foreground-subtle", + mono && "font-mono", + multiline && "resize-none", + ); + return ( +