mirror of
https://github.com/hoornet/vega.git
synced 2026-07-01 06:19:01 -07:00
Refactor: split overgrown files into focused modules
Split client.ts (1036 lines) into 11 domain modules under lib/nostr/ — core, notes, social, articles, engagement, dms, bookmarks, muting, search, relays, trending. Barrel index.ts re-exports all; zero consumer import changes. Extract ProfileView sub-components (ImageField, Nip05Field, EditProfileForm, ProfileMediaGallery), NoteContent renderers (TextSegments, MediaCards), and NoteCard actions (NoteActions, InlineReplyBox). All component files now ≤270 lines, all lib files ≤300.
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import { useState } from "react";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { invalidateProfileCache } from "../../hooks/useProfile";
|
||||
import { publishProfile } from "../../lib/nostr";
|
||||
import { ImageField } from "./ImageField";
|
||||
import { Nip05Field } from "./Nip05Field";
|
||||
|
||||
export function EditProfileForm({ pubkey, onSaved }: { pubkey: string; onSaved: () => void }) {
|
||||
const { profile, fetchOwnProfile } = useUserStore();
|
||||
const [name, setName] = useState(profile?.name || "");
|
||||
const [displayName, setDisplayName] = useState(profile?.displayName || "");
|
||||
const [about, setAbout] = useState(profile?.about || "");
|
||||
const [picture, setPicture] = useState(profile?.picture || "");
|
||||
const [banner, setBanner] = useState(profile?.banner || "");
|
||||
const [website, setWebsite] = useState(profile?.website || "");
|
||||
const [nip05, setNip05] = useState(profile?.nip05 || "");
|
||||
const [lud16, setLud16] = useState(profile?.lud16 || "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
invalidateProfileCache(pubkey);
|
||||
await publishProfile({
|
||||
name: name.trim() || undefined,
|
||||
display_name: displayName.trim() || undefined,
|
||||
about: about.trim() || undefined,
|
||||
picture: picture.trim() || undefined,
|
||||
banner: banner.trim() || undefined,
|
||||
website: website.trim() || undefined,
|
||||
nip05: nip05.trim() || undefined,
|
||||
lud16: lud16.trim() || undefined,
|
||||
});
|
||||
await fetchOwnProfile();
|
||||
setSaved(true);
|
||||
setTimeout(onSaved, 1000);
|
||||
} catch (err) {
|
||||
setError(`Failed to save: ${err}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const field = (label: string, value: string, onChange: (v: string) => void, placeholder = "") => (
|
||||
<div>
|
||||
<label className="text-text-dim text-[10px] block mb-1">{label}</label>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-bg border border-border px-3 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent/50"
|
||||
style={{ WebkitUserSelect: "text", userSelect: "text" } as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4 border-b border-border">
|
||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||
{field("Display name", displayName, setDisplayName, "Square that Circle")}
|
||||
{field("Username", name, setName, "squarethecircle")}
|
||||
<Nip05Field value={nip05} onChange={setNip05} pubkey={pubkey} />
|
||||
{field("Lightning address (lud16)", lud16, setLud16, "you@walletofsatoshi.com")}
|
||||
{field("Website", website, setWebsite, "https://…")}
|
||||
<ImageField label="Profile picture" value={picture} onChange={setPicture} />
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="text-text-dim text-[10px] block mb-1">Bio</label>
|
||||
<textarea
|
||||
value={about}
|
||||
onChange={(e) => setAbout(e.target.value)}
|
||||
placeholder="Tell people about yourself…"
|
||||
rows={3}
|
||||
className="w-full bg-bg border border-border px-3 py-1.5 text-text text-[12px] resize-none focus:outline-none focus:border-accent/50"
|
||||
style={{ WebkitUserSelect: "text", userSelect: "text" } as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<ImageField label="Banner image" value={banner} onChange={setBanner} />
|
||||
</div>
|
||||
{error && <p className="text-danger text-[11px] mb-2">{error}</p>}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || saved}
|
||||
className="px-4 py-1.5 text-[11px] bg-accent hover:bg-accent-hover text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saved ? "saved ✓" : saving ? "saving…" : "save profile"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { uploadImage } from "../../lib/upload";
|
||||
|
||||
export function ImageField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
setUploadError(null);
|
||||
try {
|
||||
const url = await uploadImage(file);
|
||||
onChange(url);
|
||||
} catch (err) {
|
||||
setUploadError(String(err));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="text-text-dim text-[10px] block mb-1">{label}</label>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="https://… or click upload →"
|
||||
className="flex-1 bg-bg border border-border px-3 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent/50"
|
||||
style={{ WebkitUserSelect: "text", userSelect: "text" } as React.CSSProperties}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="px-2 py-1.5 text-[10px] border border-border text-text-dim hover:text-accent hover:border-accent/40 transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
|
||||
title="Upload from your computer"
|
||||
>
|
||||
{uploading ? "uploading…" : "upload"}
|
||||
</button>
|
||||
</div>
|
||||
{uploadError && <p className="text-danger text-[10px] mt-1">{uploadError}</p>}
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={handleFile} className="hidden" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Nip05Status = "idle" | "checking" | "valid" | "mismatch" | "notfound";
|
||||
|
||||
export function Nip05Field({ value, onChange, pubkey }: { value: string; onChange: (v: string) => void; pubkey: string }) {
|
||||
const [status, setStatus] = useState<Nip05Status>("idle");
|
||||
|
||||
useEffect(() => {
|
||||
if (!value.includes("@")) { setStatus("idle"); return; }
|
||||
setStatus("checking");
|
||||
const t = setTimeout(async () => {
|
||||
const [name, domain] = value.trim().split("@");
|
||||
if (!name || !domain) { setStatus("notfound"); return; }
|
||||
try {
|
||||
const resp = await fetch(`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`);
|
||||
const data = await resp.json();
|
||||
const resolved = data.names?.[name];
|
||||
if (!resolved) setStatus("notfound");
|
||||
else if (resolved === pubkey) setStatus("valid");
|
||||
else setStatus("mismatch");
|
||||
} catch {
|
||||
setStatus("notfound");
|
||||
}
|
||||
}, 900);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, pubkey]);
|
||||
|
||||
const badge = {
|
||||
idle: null,
|
||||
checking: <span className="text-text-dim text-[10px]">checking…</span>,
|
||||
valid: <span className="text-success text-[10px]">✓ verified</span>,
|
||||
mismatch: <span className="text-danger text-[10px]">✗ pubkey mismatch</span>,
|
||||
notfound: <span className="text-danger text-[10px]">✗ not found</span>,
|
||||
}[status];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline gap-2 mb-1">
|
||||
<label className="text-text-dim text-[10px]">NIP-05 verified name</label>
|
||||
{badge}
|
||||
</div>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="you@domain.com"
|
||||
className="w-full bg-bg border border-border px-3 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent/50"
|
||||
style={{ WebkitUserSelect: "text", userSelect: "text" } as React.CSSProperties}
|
||||
/>
|
||||
<p className="text-text-dim text-[10px] mt-1">
|
||||
Proves your identity via a domain you control.{" "}
|
||||
<a
|
||||
href="https://nostr.how/en/guides/get-verified"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:text-accent-hover transition-colors"
|
||||
>
|
||||
How to get verified ↗
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useState } from "react";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
import { parseContent } from "../../lib/parsing";
|
||||
import { ImageLightbox } from "../shared/ImageLightbox";
|
||||
|
||||
const MEDIA_SEGMENT_TYPES = new Set(["image", "video", "audio", "youtube", "vimeo"]);
|
||||
|
||||
interface MediaItem {
|
||||
type: "image" | "video" | "audio";
|
||||
url: string;
|
||||
thumbnailId?: string;
|
||||
noteId: string;
|
||||
}
|
||||
|
||||
function extractMediaItems(notes: NDKEvent[]): MediaItem[] {
|
||||
const items: MediaItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const note of notes) {
|
||||
const segments = parseContent(note.content);
|
||||
for (const seg of segments) {
|
||||
if (!MEDIA_SEGMENT_TYPES.has(seg.type)) continue;
|
||||
if (seen.has(seg.value)) continue;
|
||||
seen.add(seg.value);
|
||||
if (seg.type === "image") {
|
||||
items.push({ type: "image", url: seg.value, noteId: note.id! });
|
||||
} else if (seg.type === "video" || seg.type === "youtube" || seg.type === "vimeo") {
|
||||
items.push({ type: "video", url: seg.value, thumbnailId: seg.mediaId, noteId: note.id! });
|
||||
} else if (seg.type === "audio") {
|
||||
items.push({ type: "audio", url: seg.value, noteId: note.id! });
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function ProfileMediaGallery({ notes, loading }: { notes: NDKEvent[]; loading: boolean }) {
|
||||
const { openThread } = useUIStore();
|
||||
const [lightboxIdx, setLightboxIdx] = useState<number | null>(null);
|
||||
|
||||
if (loading) {
|
||||
return <div className="px-4 py-8 text-text-dim text-[12px] text-center">Loading media…</div>;
|
||||
}
|
||||
|
||||
const items = extractMediaItems(notes);
|
||||
const imageUrls = items.filter((i) => i.type === "image").map((i) => i.url);
|
||||
|
||||
if (items.length === 0) {
|
||||
return <div className="px-4 py-8 text-text-dim text-[12px] text-center">No media found.</div>;
|
||||
}
|
||||
|
||||
const openNote = (noteId: string) => {
|
||||
const note = notes.find((n) => n.id === noteId);
|
||||
if (note) openThread(note, "profile");
|
||||
};
|
||||
|
||||
let imageIndex = 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 xl:grid-cols-3 gap-1 p-2">
|
||||
{items.map((item, idx) => {
|
||||
if (item.type === "image") {
|
||||
const currentImageIdx = imageIndex++;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="aspect-square overflow-hidden bg-bg-raised cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => setLightboxIdx(currentImageIdx)}
|
||||
>
|
||||
<img
|
||||
src={item.url}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (item.type === "video") {
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="aspect-square overflow-hidden bg-bg-raised cursor-pointer hover:opacity-80 transition-opacity relative flex items-center justify-center"
|
||||
onClick={() => openNote(item.noteId)}
|
||||
>
|
||||
{item.thumbnailId ? (
|
||||
<img
|
||||
src={`https://img.youtube.com/vi/${item.thumbnailId}/mqdefault.jpg`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-bg-raised" />
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="w-10 h-10 rounded-full bg-black/60 flex items-center justify-center text-white text-lg">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// audio
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="aspect-square overflow-hidden bg-bg-raised cursor-pointer hover:opacity-80 transition-opacity flex items-center justify-center"
|
||||
onClick={() => openNote(item.noteId)}
|
||||
>
|
||||
<div className="text-center">
|
||||
<span className="text-3xl text-text-dim">♪</span>
|
||||
<p className="text-text-dim text-[10px] mt-1 px-2 truncate">{item.url.split("/").pop()}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{lightboxIdx !== null && (
|
||||
<ImageLightbox
|
||||
images={imageUrls}
|
||||
index={lightboxIdx}
|
||||
onClose={() => setLightboxIdx(null)}
|
||||
onNavigate={(i) => setLightboxIdx(i)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,218 +1,17 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { useMuteStore } from "../../stores/mute";
|
||||
import { useProfile, invalidateProfileCache } from "../../hooks/useProfile";
|
||||
import { fetchUserNotesNIP65, fetchAuthorArticles, publishProfile, getNDK } from "../../lib/nostr";
|
||||
import { parseContent } from "../../lib/parsing";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import { fetchUserNotesNIP65, fetchAuthorArticles, getNDK } from "../../lib/nostr";
|
||||
import { shortenPubkey } from "../../lib/utils";
|
||||
import { uploadImage } from "../../lib/upload";
|
||||
import { NoteCard } from "../feed/NoteCard";
|
||||
import { ArticleCard } from "../article/ArticleCard";
|
||||
import { ZapModal } from "../zap/ZapModal";
|
||||
import { ImageLightbox } from "../shared/ImageLightbox";
|
||||
|
||||
// ── Profile helper sub-components ────────────────────────────────────────────
|
||||
|
||||
function ImageField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
setUploadError(null);
|
||||
try {
|
||||
const url = await uploadImage(file);
|
||||
onChange(url);
|
||||
} catch (err) {
|
||||
setUploadError(String(err));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="text-text-dim text-[10px] block mb-1">{label}</label>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="https://… or click upload →"
|
||||
className="flex-1 bg-bg border border-border px-3 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent/50"
|
||||
style={{ WebkitUserSelect: "text", userSelect: "text" } as React.CSSProperties}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="px-2 py-1.5 text-[10px] border border-border text-text-dim hover:text-accent hover:border-accent/40 transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
|
||||
title="Upload from your computer"
|
||||
>
|
||||
{uploading ? "uploading…" : "upload"}
|
||||
</button>
|
||||
</div>
|
||||
{uploadError && <p className="text-danger text-[10px] mt-1">{uploadError}</p>}
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={handleFile} className="hidden" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Nip05Status = "idle" | "checking" | "valid" | "mismatch" | "notfound";
|
||||
|
||||
function Nip05Field({ value, onChange, pubkey }: { value: string; onChange: (v: string) => void; pubkey: string }) {
|
||||
const [status, setStatus] = useState<Nip05Status>("idle");
|
||||
|
||||
useEffect(() => {
|
||||
if (!value.includes("@")) { setStatus("idle"); return; }
|
||||
setStatus("checking");
|
||||
const t = setTimeout(async () => {
|
||||
const [name, domain] = value.trim().split("@");
|
||||
if (!name || !domain) { setStatus("notfound"); return; }
|
||||
try {
|
||||
const resp = await fetch(`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`);
|
||||
const data = await resp.json();
|
||||
const resolved = data.names?.[name];
|
||||
if (!resolved) setStatus("notfound");
|
||||
else if (resolved === pubkey) setStatus("valid");
|
||||
else setStatus("mismatch");
|
||||
} catch {
|
||||
setStatus("notfound");
|
||||
}
|
||||
}, 900);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, pubkey]);
|
||||
|
||||
const badge = {
|
||||
idle: null,
|
||||
checking: <span className="text-text-dim text-[10px]">checking…</span>,
|
||||
valid: <span className="text-success text-[10px]">✓ verified</span>,
|
||||
mismatch: <span className="text-danger text-[10px]">✗ pubkey mismatch</span>,
|
||||
notfound: <span className="text-danger text-[10px]">✗ not found</span>,
|
||||
}[status];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline gap-2 mb-1">
|
||||
<label className="text-text-dim text-[10px]">NIP-05 verified name</label>
|
||||
{badge}
|
||||
</div>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="you@domain.com"
|
||||
className="w-full bg-bg border border-border px-3 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent/50"
|
||||
style={{ WebkitUserSelect: "text", userSelect: "text" } as React.CSSProperties}
|
||||
/>
|
||||
<p className="text-text-dim text-[10px] mt-1">
|
||||
Proves your identity via a domain you control.{" "}
|
||||
<a
|
||||
href="https://nostr.how/en/guides/get-verified"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:text-accent-hover transition-colors"
|
||||
>
|
||||
How to get verified ↗
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditProfileForm({ pubkey, onSaved }: { pubkey: string; onSaved: () => void }) {
|
||||
const { profile, fetchOwnProfile } = useUserStore();
|
||||
const [name, setName] = useState(profile?.name || "");
|
||||
const [displayName, setDisplayName] = useState(profile?.displayName || "");
|
||||
const [about, setAbout] = useState(profile?.about || "");
|
||||
const [picture, setPicture] = useState(profile?.picture || "");
|
||||
const [banner, setBanner] = useState(profile?.banner || "");
|
||||
const [website, setWebsite] = useState(profile?.website || "");
|
||||
const [nip05, setNip05] = useState(profile?.nip05 || "");
|
||||
const [lud16, setLud16] = useState(profile?.lud16 || "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
invalidateProfileCache(pubkey);
|
||||
await publishProfile({
|
||||
name: name.trim() || undefined,
|
||||
display_name: displayName.trim() || undefined,
|
||||
about: about.trim() || undefined,
|
||||
picture: picture.trim() || undefined,
|
||||
banner: banner.trim() || undefined,
|
||||
website: website.trim() || undefined,
|
||||
nip05: nip05.trim() || undefined,
|
||||
lud16: lud16.trim() || undefined,
|
||||
});
|
||||
await fetchOwnProfile();
|
||||
setSaved(true);
|
||||
setTimeout(onSaved, 1000);
|
||||
} catch (err) {
|
||||
setError(`Failed to save: ${err}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const field = (label: string, value: string, onChange: (v: string) => void, placeholder = "") => (
|
||||
<div>
|
||||
<label className="text-text-dim text-[10px] block mb-1">{label}</label>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-bg border border-border px-3 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent/50"
|
||||
style={{ WebkitUserSelect: "text", userSelect: "text" } as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4 border-b border-border">
|
||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||
{field("Display name", displayName, setDisplayName, "Square that Circle")}
|
||||
{field("Username", name, setName, "squarethecircle")}
|
||||
<Nip05Field value={nip05} onChange={setNip05} pubkey={pubkey} />
|
||||
{field("Lightning address (lud16)", lud16, setLud16, "you@walletofsatoshi.com")}
|
||||
{field("Website", website, setWebsite, "https://…")}
|
||||
<ImageField label="Profile picture" value={picture} onChange={setPicture} />
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="text-text-dim text-[10px] block mb-1">Bio</label>
|
||||
<textarea
|
||||
value={about}
|
||||
onChange={(e) => setAbout(e.target.value)}
|
||||
placeholder="Tell people about yourself…"
|
||||
rows={3}
|
||||
className="w-full bg-bg border border-border px-3 py-1.5 text-text text-[12px] resize-none focus:outline-none focus:border-accent/50"
|
||||
style={{ WebkitUserSelect: "text", userSelect: "text" } as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<ImageField label="Banner image" value={banner} onChange={setBanner} />
|
||||
</div>
|
||||
{error && <p className="text-danger text-[11px] mb-2">{error}</p>}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || saved}
|
||||
className="px-4 py-1.5 text-[11px] bg-accent hover:bg-accent-hover text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saved ? "saved ✓" : saving ? "saving…" : "save profile"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { EditProfileForm } from "./EditProfileForm";
|
||||
import { ProfileMediaGallery } from "./ProfileMediaGallery";
|
||||
|
||||
export function ProfileView() {
|
||||
const { selectedPubkey, goBack, openDM } = useUIStore();
|
||||
@@ -467,130 +266,3 @@ export function ProfileView() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Media gallery sub-component ──────────────────────────────────────────────
|
||||
|
||||
const MEDIA_SEGMENT_TYPES = new Set(["image", "video", "audio", "youtube", "vimeo"]);
|
||||
|
||||
interface MediaItem {
|
||||
type: "image" | "video" | "audio";
|
||||
url: string;
|
||||
thumbnailId?: string;
|
||||
noteId: string;
|
||||
}
|
||||
|
||||
function extractMediaItems(notes: NDKEvent[]): MediaItem[] {
|
||||
const items: MediaItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const note of notes) {
|
||||
const segments = parseContent(note.content);
|
||||
for (const seg of segments) {
|
||||
if (!MEDIA_SEGMENT_TYPES.has(seg.type)) continue;
|
||||
if (seen.has(seg.value)) continue;
|
||||
seen.add(seg.value);
|
||||
if (seg.type === "image") {
|
||||
items.push({ type: "image", url: seg.value, noteId: note.id! });
|
||||
} else if (seg.type === "video" || seg.type === "youtube" || seg.type === "vimeo") {
|
||||
items.push({ type: "video", url: seg.value, thumbnailId: seg.mediaId, noteId: note.id! });
|
||||
} else if (seg.type === "audio") {
|
||||
items.push({ type: "audio", url: seg.value, noteId: note.id! });
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function ProfileMediaGallery({ notes, loading }: { notes: NDKEvent[]; loading: boolean }) {
|
||||
const { openThread } = useUIStore();
|
||||
const [lightboxIdx, setLightboxIdx] = useState<number | null>(null);
|
||||
|
||||
if (loading) {
|
||||
return <div className="px-4 py-8 text-text-dim text-[12px] text-center">Loading media…</div>;
|
||||
}
|
||||
|
||||
const items = extractMediaItems(notes);
|
||||
const imageUrls = items.filter((i) => i.type === "image").map((i) => i.url);
|
||||
|
||||
if (items.length === 0) {
|
||||
return <div className="px-4 py-8 text-text-dim text-[12px] text-center">No media found.</div>;
|
||||
}
|
||||
|
||||
const openNote = (noteId: string) => {
|
||||
const note = notes.find((n) => n.id === noteId);
|
||||
if (note) openThread(note, "profile");
|
||||
};
|
||||
|
||||
let imageIndex = 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 xl:grid-cols-3 gap-1 p-2">
|
||||
{items.map((item, idx) => {
|
||||
if (item.type === "image") {
|
||||
const currentImageIdx = imageIndex++;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="aspect-square overflow-hidden bg-bg-raised cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => setLightboxIdx(currentImageIdx)}
|
||||
>
|
||||
<img
|
||||
src={item.url}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (item.type === "video") {
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="aspect-square overflow-hidden bg-bg-raised cursor-pointer hover:opacity-80 transition-opacity relative flex items-center justify-center"
|
||||
onClick={() => openNote(item.noteId)}
|
||||
>
|
||||
{item.thumbnailId ? (
|
||||
<img
|
||||
src={`https://img.youtube.com/vi/${item.thumbnailId}/mqdefault.jpg`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-bg-raised" />
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="w-10 h-10 rounded-full bg-black/60 flex items-center justify-center text-white text-lg">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// audio
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="aspect-square overflow-hidden bg-bg-raised cursor-pointer hover:opacity-80 transition-opacity flex items-center justify-center"
|
||||
onClick={() => openNote(item.noteId)}
|
||||
>
|
||||
<div className="text-center">
|
||||
<span className="text-3xl text-text-dim">♪</span>
|
||||
<p className="text-text-dim text-[10px] mt-1 px-2 truncate">{item.url.split("/").pop()}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{lightboxIdx !== null && (
|
||||
<ImageLightbox
|
||||
images={imageUrls}
|
||||
index={lightboxIdx}
|
||||
onClose={() => setLightboxIdx(null)}
|
||||
onNavigate={(i) => setLightboxIdx(i)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user