f811245f90
Remove the spellbook-themed settings index: drop the "Codex of Configuration" heading, the gradient ornaments with ✦/◆ dividers, the sigil that appeared after two minutes of inactivity, and the IntroImage illustration tiles on every section row and sub-page intro block. The index is now a flat divider-separated list of labels and one-line descriptions, with breathing room on both sides. Delete the Magic settings page, its CursorFireEffect overlay, the animate-sigil-glow / animate-pulse-slow keyframes, the magicMouse AppConfig flag (schema, default, test fixture), and the /settings/magic route. Delete the now-unreferenced IntroImage component and the ten *-intro.png assets it masked. Disable content types that don't fit an activist tool by default: vines, treasures (geocaches + found logs), colors, decks, webxdc, birdstar (detections / birdex / constellations), emoji packs, custom emojis, user statuses, music, podcasts, and development. They remain available in settings — just off out of the box. Highlights is bumped on by default to pair with Articles. Posts, comments, reposts, articles, highlights, events, polls, communities, people lists, badges, photos, videos, and voice messages stay on.
1048 lines
38 KiB
TypeScript
1048 lines
38 KiB
TypeScript
import { useSeoMeta } from '@unhead/react';
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
Loader2, Plus, Trash2, ChevronDown,
|
|
Wallet, Upload, Music, ImageIcon, Film, Mail, Link2, Pencil, Eye, EyeOff, Copy, Check, Download, KeyRound, AlertTriangle, CloudSun,
|
|
} from 'lucide-react';
|
|
import { nip19 } from 'nostr-tools';
|
|
import { useNostrLogin } from '@nostrify/react/login';
|
|
|
|
import { saveNsec } from '@/lib/credentialManager';
|
|
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
|
import { Navigate } from 'react-router-dom';
|
|
import { useForm, useFieldArray } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { z } from 'zod';
|
|
import { NSchema as n } from '@nostrify/nostrify';
|
|
import type { NostrMetadata } from '@nostrify/nostrify';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
|
|
import { ProfileCard } from '@/components/ProfileCard';
|
|
import { ProfileRightSidebar } from '@/components/ProfileRightSidebar';
|
|
import { PageHeader } from '@/components/PageHeader';
|
|
import { HelpTip } from '@/components/HelpTip';
|
|
import { ImageCropDialog } from '@/components/ImageCropDialog';
|
|
import { SortableList, SortableItem } from '@/components/SortableList';
|
|
import { useAppContext } from '@/hooks/useAppContext';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
|
import { useUploadFile } from '@/hooks/useUploadFile';
|
|
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Switch } from '@/components/ui/switch';
|
|
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from '@/components/ui/form';
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from '@/components/ui/collapsible';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
|
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
|
|
const WALLET_TICKERS = [
|
|
'$BTC', '$ETH', '$SOL', '$XMR', '$LTC', '$DOGE', '$ADA', '$DOT', '$XRP', '$MATIC',
|
|
] as const;
|
|
|
|
/** Bare tickers used only for detection (strips leading $). */
|
|
const BARE_TICKERS = WALLET_TICKERS.map((t) => t.slice(1));
|
|
|
|
// ── Field preset templates ────────────────────────────────────────────────────
|
|
|
|
interface FieldPreset {
|
|
id: string;
|
|
label: string;
|
|
description: string;
|
|
icon: React.ComponentType<{ className?: string }>;
|
|
/** Default label to pre-fill when adding this field type. */
|
|
defaultLabel: string;
|
|
/** The form field type. */
|
|
type: 'text' | 'wallet' | 'media';
|
|
/** File accept attribute for the file picker (media types only). */
|
|
accept?: string;
|
|
/** Human-readable format list shown in tooltips. */
|
|
formatHint?: string;
|
|
/** Placeholder for the value input. */
|
|
valuePlaceholder?: string;
|
|
}
|
|
|
|
const FIELD_PRESETS: FieldPreset[] = [
|
|
{
|
|
id: 'music',
|
|
label: 'Music',
|
|
description: 'Upload a song or audio clip',
|
|
icon: Music,
|
|
defaultLabel: '\u{1F3B6}',
|
|
type: 'media',
|
|
accept: 'audio/*',
|
|
formatHint: 'MP3, OGG, WAV, FLAC, AAC, M4A, Opus',
|
|
valuePlaceholder: 'Upload audio or paste direct file link',
|
|
},
|
|
{
|
|
id: 'photo',
|
|
label: 'Photo',
|
|
description: 'Upload an image',
|
|
icon: ImageIcon,
|
|
defaultLabel: '\u{1F4F8}',
|
|
type: 'media',
|
|
accept: 'image/*',
|
|
formatHint: 'JPG, PNG, GIF, WebP, SVG, AVIF',
|
|
valuePlaceholder: 'Upload image or paste direct file link',
|
|
},
|
|
{
|
|
id: 'video',
|
|
label: 'Video',
|
|
description: 'Upload a video clip',
|
|
icon: Film,
|
|
defaultLabel: '\u{1F3AC}',
|
|
type: 'media',
|
|
accept: 'video/*',
|
|
formatHint: 'MP4, WebM, MOV',
|
|
valuePlaceholder: 'Upload video or paste direct file link',
|
|
},
|
|
{
|
|
id: 'email',
|
|
label: 'Email',
|
|
description: 'Contact email address',
|
|
icon: Mail,
|
|
defaultLabel: 'Email',
|
|
type: 'text',
|
|
valuePlaceholder: 'you@example.com',
|
|
},
|
|
{
|
|
id: 'wallet',
|
|
label: 'Wallet',
|
|
description: 'Cryptocurrency wallet address',
|
|
icon: Wallet,
|
|
defaultLabel: '$BTC',
|
|
type: 'wallet',
|
|
valuePlaceholder: 'Address',
|
|
},
|
|
{
|
|
id: 'link',
|
|
label: 'Link',
|
|
description: 'Link to any website or profile',
|
|
icon: Link2,
|
|
defaultLabel: '',
|
|
type: 'text',
|
|
valuePlaceholder: 'https://...',
|
|
},
|
|
{
|
|
id: 'weather',
|
|
label: 'Weather',
|
|
description: 'Connect a Nostr weather station',
|
|
icon: CloudSun,
|
|
defaultLabel: 'Weather',
|
|
type: 'text',
|
|
valuePlaceholder: 'npub1... or naddr1... (#station-id optional)',
|
|
},
|
|
];
|
|
|
|
/** The "Custom" preset — always shown last, separated by a divider. */
|
|
const CUSTOM_PRESET: FieldPreset = {
|
|
id: 'custom',
|
|
label: 'Custom',
|
|
description: 'Create any custom field',
|
|
icon: Pencil,
|
|
defaultLabel: '',
|
|
type: 'text',
|
|
valuePlaceholder: 'Value or URL',
|
|
};
|
|
|
|
/** Find a preset's format hint from its accept filter. */
|
|
function getFormatHintForAccept(accept: string | undefined): string | undefined {
|
|
if (!accept) return undefined;
|
|
const preset = FIELD_PRESETS.find((p) => p.accept === accept);
|
|
return preset?.formatHint;
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
/** Infer the field type from stored label/value when loading from existing data. */
|
|
function inferFieldType(label: string, value: string): 'text' | 'wallet' | 'media' {
|
|
const bare = label.replace(/^\$/, '').toUpperCase();
|
|
if (BARE_TICKERS.includes(bare)) return 'wallet';
|
|
// Known media file extensions
|
|
if (/^https?:\/\/.+\.(jpe?g|png|gif|webp|svg|avif|mp4|webm|mov|mp3|ogg|wav|flac)(\?.*)?$/i.test(value)) return 'media';
|
|
// Blossom-style URLs: path is a long hex hash (SHA-256), optionally with an extension
|
|
if (/^https?:\/\/.+\/[0-9a-f]{64}(\.\w+)?$/i.test(value)) return 'media';
|
|
return 'text';
|
|
}
|
|
|
|
/** Extension patterns for each media accept category. */
|
|
const AUDIO_EXT = /\.(mp3|mpga|ogg|oga|wav|flac|aac|m4a|opus|weba|webm|spx|caf)(\?.*)?$/i;
|
|
const IMAGE_EXT = /\.(jpe?g|png|gif|webp|svg|avif)(\?.*)?$/i;
|
|
const VIDEO_EXT = /\.(mp4|webm|mov|qt)(\?.*)?$/i;
|
|
|
|
/**
|
|
* Check whether a pasted URL matches the expected file type for a media field.
|
|
* Returns a warning message if the URL looks wrong, or undefined if it's fine.
|
|
* Only warns when the value looks like a URL — empty/non-URL values return undefined.
|
|
*/
|
|
function getMediaMismatchWarning(value: string, accept: string | undefined): string | undefined {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return undefined;
|
|
// Only check if it looks like a URL
|
|
if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) return undefined;
|
|
|
|
// Blossom-style URLs (hex hash path) are always fine — type can't be determined from URL
|
|
if (/^https?:\/\/.+\/[0-9a-f]{64}(\.\w+)?$/i.test(trimmed)) return undefined;
|
|
|
|
// Check if URL has a recognizable file extension at all
|
|
const hasAudioExt = AUDIO_EXT.test(trimmed);
|
|
const hasImageExt = IMAGE_EXT.test(trimmed);
|
|
const hasVideoExt = VIDEO_EXT.test(trimmed);
|
|
const hasKnownExt = hasAudioExt || hasImageExt || hasVideoExt;
|
|
|
|
if (accept === 'audio/*') {
|
|
if (hasKnownExt && !hasAudioExt) {
|
|
return 'This URL doesn\u2019t point to an audio file. Upload an audio file or use a direct link ending in .mp3, .ogg, .wav, etc.';
|
|
}
|
|
if (!hasKnownExt) {
|
|
return 'This URL may not work as an audio player. For best results, upload a file using the button or paste a direct link to an audio file.';
|
|
}
|
|
}
|
|
|
|
if (accept === 'image/*') {
|
|
if (hasKnownExt && !hasImageExt) {
|
|
return 'This URL doesn\u2019t point to an image. Upload an image or use a direct link ending in .jpg, .png, .webp, etc.';
|
|
}
|
|
if (!hasKnownExt) {
|
|
return 'This URL may not display as an image. For best results, upload a file using the button or paste a direct link to an image file.';
|
|
}
|
|
}
|
|
|
|
if (accept === 'video/*') {
|
|
if (hasKnownExt && !hasVideoExt) {
|
|
return 'This URL doesn\u2019t point to a video. Upload a video or use a direct link ending in .mp4, .webm, .mov, etc.';
|
|
}
|
|
if (!hasKnownExt) {
|
|
return 'This URL may not display as a video. For best results, upload a file using the button or paste a direct link to a video file.';
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
/** Infer a file-accept filter from an existing field's value URL. */
|
|
function inferAcceptFromValue(value: string): string | undefined {
|
|
if (/\.(mp3|mpga|ogg|oga|wav|flac|aac|m4a|opus|weba|webm|spx|caf)(\?.*)?$/i.test(value)) return 'audio/*';
|
|
if (/\.(jpe?g|png|gif|webp|svg|avif)(\?.*)?$/i.test(value)) return 'image/*';
|
|
if (/\.(mp4|webm|mov|qt)(\?.*)?$/i.test(value)) return 'video/*';
|
|
return undefined;
|
|
}
|
|
|
|
// ── Schema ────────────────────────────────────────────────────────────────────
|
|
|
|
const formSchema = n.metadata().extend({
|
|
fields: z.array(z.object({
|
|
label: z.string(),
|
|
value: z.string(),
|
|
type: z.enum(['text', 'wallet', 'media']),
|
|
/** Client-side only — file accept filter for the file picker (not persisted). */
|
|
accept: z.string().optional(),
|
|
/** Client-side only — placeholder text for the value input (not persisted). */
|
|
placeholder: z.string().optional(),
|
|
})).optional(),
|
|
});
|
|
|
|
type FormValues = z.infer<typeof formSchema>;
|
|
|
|
type CropState = {
|
|
imageSrc: string;
|
|
aspect: number;
|
|
field: 'picture' | 'banner';
|
|
title: string;
|
|
};
|
|
|
|
// ── Sortable field row ─────────────────────────────────────────────────────
|
|
|
|
interface SortableFieldRowProps {
|
|
id: string;
|
|
index: number;
|
|
type: 'text' | 'wallet' | 'media';
|
|
accept?: string;
|
|
valuePlaceholder?: string;
|
|
isUploading?: boolean;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
control: any;
|
|
onRemove: () => void;
|
|
onMediaPick: () => void;
|
|
onTickerChange: (ticker: string) => void;
|
|
}
|
|
|
|
function SortableFieldRow({ id, index, type, accept, valuePlaceholder, isUploading: fieldUploading, control, onRemove, onMediaPick, onTickerChange }: SortableFieldRowProps) {
|
|
const formatHint = type === 'media' ? getFormatHintForAccept(accept) : undefined;
|
|
|
|
return (
|
|
<SortableItem id={id} className="items-start" gripClassName="w-6 h-9">
|
|
<div className="grid grid-cols-[1fr,2fr,auto] gap-2 items-start">
|
|
{/* Label column — varies by type */}
|
|
{type === 'wallet' ? (
|
|
<FormField
|
|
control={control}
|
|
name={`fields.${index}.label`}
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<Select value={field.value} onValueChange={(v) => { field.onChange(v); onTickerChange(v); }}>
|
|
<FormControl>
|
|
<SelectTrigger className="h-9">
|
|
<SelectValue placeholder="Ticker" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
{WALLET_TICKERS.map((t) => (
|
|
<SelectItem key={t} value={t}>{t}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
) : (
|
|
<FormField
|
|
control={control}
|
|
name={`fields.${index}.label`}
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormControl>
|
|
<Input placeholder="Label" {...field} className="h-9" />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{/* Value column — media gets upload button with tooltip, others get text input */}
|
|
{type === 'media' ? (
|
|
<FormField
|
|
control={control}
|
|
name={`fields.${index}.value`}
|
|
render={({ field }) => {
|
|
const mismatchWarning = getMediaMismatchWarning(field.value, accept);
|
|
return (
|
|
<FormItem>
|
|
<div className="flex gap-1.5">
|
|
<FormControl>
|
|
<Input placeholder={valuePlaceholder || 'Upload file or paste direct file link'} {...field} className="h-9 flex-1 min-w-0" readOnly={false} />
|
|
</FormControl>
|
|
{fieldUploading ? (
|
|
<div className="flex items-center justify-center h-9 w-9 shrink-0">
|
|
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
className="h-9 w-9 shrink-0"
|
|
onClick={onMediaPick}
|
|
>
|
|
<Upload className="size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" className="text-xs max-w-52">
|
|
{formatHint ? (
|
|
<span>Choose file to upload<br /><span className="text-muted-foreground">{formatHint}</span></span>
|
|
) : (
|
|
<span>Choose a media file to upload</span>
|
|
)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
{mismatchWarning && (
|
|
<p className="flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-500 mt-1 leading-snug">
|
|
<AlertTriangle className="size-3.5 shrink-0 mt-0.5" />
|
|
<span>{mismatchWarning}</span>
|
|
</p>
|
|
)}
|
|
<FormMessage />
|
|
</FormItem>
|
|
);
|
|
}}
|
|
/>
|
|
) : (
|
|
<FormField
|
|
control={control}
|
|
name={`fields.${index}.value`}
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormControl>
|
|
<Input placeholder={type === 'wallet' ? 'Address' : 'Value or URL'} {...field} className="h-9" />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{/* Delete button */}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={onRemove}
|
|
className="h-9 w-9 text-destructive hover:text-destructive"
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</SortableItem>
|
|
);
|
|
}
|
|
|
|
// ── Main component ────────────────────────────────────────────────────────────
|
|
|
|
export function ProfileSettings() {
|
|
const { user, metadata, event } = useCurrentUser();
|
|
const { config } = useAppContext();
|
|
const queryClient = useQueryClient();
|
|
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
|
|
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
|
const { toast } = useToast();
|
|
|
|
const [cropState, setCropState] = useState<CropState | null>(null);
|
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
const [showMobilePreview, setShowMobilePreview] = useState(false);
|
|
const [uploadingFieldIndex, setUploadingFieldIndex] = useState<number>(-1);
|
|
|
|
useSeoMeta({
|
|
title: `Profile | Settings | ${config.appName}`,
|
|
description: `Edit your ${config.appName} profile`,
|
|
});
|
|
|
|
// Parse existing custom fields from raw event
|
|
const parseFields = (): Array<{ label: string; value: string; type: 'text' | 'wallet' | 'media'; accept?: string }> => {
|
|
if (!event) return [];
|
|
try {
|
|
const parsed = JSON.parse(event.content);
|
|
if (Array.isArray(parsed.fields)) {
|
|
return parsed.fields
|
|
.filter((f: unknown) => Array.isArray(f) && f.length >= 2)
|
|
.map((f: string[]) => {
|
|
const type = inferFieldType(f[0], f[1]);
|
|
// Ensure wallet labels carry the $ prefix so the Select value matches (e.g. "BTC" → "$BTC")
|
|
const label = type === 'wallet' && !f[0].startsWith('$')
|
|
? `$${f[0].toUpperCase()}`
|
|
: f[0];
|
|
const accept = type === 'media' ? inferAcceptFromValue(f[1]) : undefined;
|
|
return { label, value: f[1], type, accept };
|
|
});
|
|
}
|
|
} catch { /* ignore */ }
|
|
return [];
|
|
};
|
|
|
|
const form = useForm<FormValues>({
|
|
resolver: zodResolver(formSchema),
|
|
defaultValues: {
|
|
name: '', about: '', picture: '', banner: '',
|
|
website: '', nip05: '', lud16: '', bot: false, fields: [],
|
|
},
|
|
});
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const { fields, append, remove, move } = useFieldArray({ control: form.control as any, name: 'fields' });
|
|
|
|
const handleFieldReorder = useCallback((reordered: typeof fields) => {
|
|
// Map reordered items back to move() calls by finding the first mismatch
|
|
const oldIndex = fields.findIndex((f, i) => f.id !== reordered[i]?.id);
|
|
if (oldIndex === -1) return;
|
|
const newIndex = reordered.findIndex((f) => f.id === fields[oldIndex].id);
|
|
if (newIndex === -1) return;
|
|
move(oldIndex, newIndex);
|
|
}, [fields, move]);
|
|
|
|
// Media field upload — dynamic accept attribute per field
|
|
const mediaInputRef = useRef<HTMLInputElement>(null);
|
|
const pendingMediaIndex = useRef<number>(-1);
|
|
const handleMediaPick = (index: number) => {
|
|
pendingMediaIndex.current = index;
|
|
// Dynamically set the accept attribute based on the field's preset
|
|
const fieldAccept = form.getValues(`fields.${index}.accept`);
|
|
if (mediaInputRef.current) {
|
|
mediaInputRef.current.accept = fieldAccept || 'image/*,video/*,audio/*';
|
|
}
|
|
mediaInputRef.current?.click();
|
|
};
|
|
const handleMediaFileChosen = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
e.target.value = '';
|
|
const index = pendingMediaIndex.current;
|
|
if (index < 0) return;
|
|
setUploadingFieldIndex(index);
|
|
try {
|
|
const [[, url]] = await uploadFile(file);
|
|
form.setValue(`fields.${index}.value`, url, { shouldDirty: true });
|
|
toast({ title: 'Uploaded', description: 'Media file uploaded' });
|
|
} catch {
|
|
toast({ title: 'Upload failed', description: 'Please try again.', variant: 'destructive' });
|
|
} finally {
|
|
setUploadingFieldIndex(-1);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (metadata) {
|
|
form.reset({
|
|
name: metadata.name ?? '',
|
|
about: metadata.about ?? '',
|
|
picture: metadata.picture ?? '',
|
|
banner: metadata.banner ?? '',
|
|
website: metadata.website ?? '',
|
|
nip05: metadata.nip05 ?? '',
|
|
lud16: metadata.lud16 ?? '',
|
|
bot: metadata.bot ?? false,
|
|
fields: parseFields(),
|
|
});
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [metadata, event]);
|
|
|
|
// Live values for the card preview
|
|
const watched = form.watch();
|
|
const cardMetadata: Partial<NostrMetadata> = {
|
|
name: watched.name,
|
|
about: watched.about,
|
|
picture: watched.picture,
|
|
banner: watched.banner,
|
|
website: watched.website,
|
|
nip05: watched.nip05,
|
|
lud16: watched.lud16,
|
|
bot: watched.bot,
|
|
};
|
|
|
|
// Live sidebar preview fields — computed from watched form values
|
|
const previewFields = useMemo(() => {
|
|
const result: Array<{ label: string; value: string }> = [];
|
|
// Add website if present
|
|
if (watched.website?.trim()) {
|
|
result.push({ label: 'Website', value: watched.website.trim() });
|
|
}
|
|
// Add custom fields that have both label and value
|
|
if (watched.fields) {
|
|
for (const f of watched.fields) {
|
|
if (f.label.trim() && f.value.trim()) {
|
|
result.push({ label: f.label, value: f.value });
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}, [watched.website, watched.fields]);
|
|
|
|
// Card onChange: patch individual fields
|
|
const handleCardChange = (patch: Partial<NostrMetadata>) => {
|
|
for (const [k, v] of Object.entries(patch)) {
|
|
form.setValue(k as keyof FormValues, v as string, { shouldDirty: true });
|
|
}
|
|
};
|
|
|
|
// Image pick: open crop dialog
|
|
const pickInputRef = useRef<HTMLInputElement>(null);
|
|
const pendingField = useRef<'picture' | 'banner'>('picture');
|
|
|
|
const handlePickImage = (field: 'picture' | 'banner') => {
|
|
pendingField.current = field;
|
|
pickInputRef.current?.click();
|
|
};
|
|
|
|
const handleFileChosen = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
e.target.value = '';
|
|
const field = pendingField.current;
|
|
setCropState({
|
|
imageSrc: URL.createObjectURL(file),
|
|
aspect: field === 'picture' ? 1 : 3,
|
|
field,
|
|
title: field === 'picture' ? 'Crop Profile Picture' : 'Crop Banner',
|
|
});
|
|
};
|
|
|
|
const handleCropConfirm = async (blob: Blob) => {
|
|
if (!cropState) return;
|
|
const { field, imageSrc } = cropState;
|
|
URL.revokeObjectURL(imageSrc);
|
|
setCropState(null);
|
|
try {
|
|
const file = new File([blob], `${field}.jpg`, { type: 'image/jpeg' });
|
|
const [[, url]] = await uploadFile(file);
|
|
form.setValue(field, url, { shouldDirty: true });
|
|
toast({ title: 'Uploaded', description: `${field === 'picture' ? 'Profile picture' : 'Banner'} updated` });
|
|
} catch {
|
|
toast({ title: 'Upload failed', description: 'Please try again.', variant: 'destructive' });
|
|
}
|
|
};
|
|
|
|
const handleCropCancel = () => {
|
|
if (cropState) URL.revokeObjectURL(cropState.imageSrc);
|
|
setCropState(null);
|
|
};
|
|
|
|
// Handle adding a field from a preset
|
|
const handleAddPreset = (preset: FieldPreset) => {
|
|
append({
|
|
label: preset.defaultLabel,
|
|
value: '',
|
|
type: preset.type,
|
|
accept: preset.accept,
|
|
placeholder: preset.valuePlaceholder,
|
|
});
|
|
};
|
|
|
|
const onSubmit = async (values: FormValues) => {
|
|
if (!user) return;
|
|
try {
|
|
const { fields: customFields, ...standardMetadata } = values;
|
|
const data: Record<string, unknown> = { ...metadata, ...standardMetadata };
|
|
|
|
// Strip any legacy avatar shape from old Ditto-style profiles
|
|
delete data.shape;
|
|
|
|
for (const key in data) {
|
|
if (data[key] === '') delete data[key];
|
|
}
|
|
if (customFields && customFields.length > 0) {
|
|
const nonEmpty = customFields.filter((f) => f.label.trim() && f.value.trim());
|
|
if (nonEmpty.length > 0) data.fields = nonEmpty.map((f) => [f.label, f.value]);
|
|
}
|
|
await publishEvent({ kind: 0, content: JSON.stringify(data) });
|
|
queryClient.invalidateQueries({ queryKey: ['logins'] });
|
|
queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] });
|
|
toast({ title: 'Profile saved' });
|
|
} catch {
|
|
toast({ title: 'Error', description: 'Failed to save profile.', variant: 'destructive' });
|
|
}
|
|
};
|
|
|
|
// Inject live sidebar preview into the app's right sidebar slot
|
|
useLayoutOptions({
|
|
rightSidebar: <ProfileRightSidebar fields={previewFields} pubkey={user?.pubkey} />,
|
|
});
|
|
|
|
if (!user) return <Navigate to="/settings" replace />;
|
|
|
|
const busy = isPending || isUploading;
|
|
|
|
return (
|
|
<main className="min-h-screen">
|
|
{/* Hidden file input for avatar/banner */}
|
|
<input
|
|
ref={pickInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={handleFileChosen}
|
|
/>
|
|
{/* Hidden file input for media fields — accept is set dynamically */}
|
|
<input
|
|
ref={mediaInputRef}
|
|
type="file"
|
|
accept="image/*,video/*,audio/*"
|
|
className="hidden"
|
|
onChange={handleMediaFileChosen}
|
|
/>
|
|
|
|
{/* Crop dialog */}
|
|
{cropState && (
|
|
<ImageCropDialog
|
|
open
|
|
imageSrc={cropState.imageSrc}
|
|
aspect={cropState.aspect}
|
|
title={cropState.title}
|
|
onCancel={handleCropCancel}
|
|
onCrop={handleCropConfirm}
|
|
/>
|
|
)}
|
|
|
|
{/* Header */}
|
|
<PageHeader
|
|
title="Profile"
|
|
backTo="/settings"
|
|
alwaysShowBack
|
|
titleContent={
|
|
<div className="flex-1 min-w-0">
|
|
<h1 className="text-xl font-bold leading-tight">Profile</h1>
|
|
<p className="text-sm text-muted-foreground">Your Nostr identity is portable — it goes wherever you go.</p>
|
|
</div>
|
|
}
|
|
>
|
|
<Button type="submit" form="profile-settings-form" size="sm" className="shrink-0 rounded-full font-bold px-5" disabled={busy}>
|
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : 'Save'}
|
|
</Button>
|
|
</PageHeader>
|
|
|
|
<Form {...form}>
|
|
<form id="profile-settings-form" onSubmit={form.handleSubmit(onSubmit)} className="max-w-xl mx-auto px-4 pb-10 space-y-6">
|
|
|
|
{/* Intro */}
|
|
<div className="px-3 pt-2 pb-2">
|
|
<h2 className="text-sm font-semibold">Your Identity</h2>
|
|
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
|
Tap any field on the card to edit. Click your avatar or banner to upload and crop a new image.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Interactive profile card */}
|
|
<ProfileCard
|
|
pubkey={user.pubkey}
|
|
metadata={cardMetadata}
|
|
onChange={handleCardChange}
|
|
onPickImage={handlePickImage}
|
|
onRemoveAvatar={() => form.setValue('picture', '', { shouldDirty: true })}
|
|
/>
|
|
|
|
{isUploading && (
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Loader2 className="size-3.5 animate-spin" />
|
|
Uploading…
|
|
</div>
|
|
)}
|
|
|
|
{/* Profile fields */}
|
|
<div>
|
|
<h2 className="text-sm font-medium py-2 flex items-center gap-1">
|
|
Profile Fields
|
|
<HelpTip faqId="profile-fields" iconSize="size-3.5" />
|
|
</h2>
|
|
|
|
<div className="space-y-3 pt-1">
|
|
{/* Website — always first */}
|
|
<FormField
|
|
control={form.control}
|
|
name="website"
|
|
render={({ field }) => (
|
|
<div className="grid grid-cols-[auto,1fr,2fr,auto] gap-2 items-center">
|
|
<div className="w-6" />
|
|
<div className="flex items-center h-9 px-3 text-sm text-muted-foreground">
|
|
<span>Website</span>
|
|
</div>
|
|
<Input placeholder="https://yourwebsite.com" {...field} className="h-9" />
|
|
<div className="size-9" />
|
|
</div>
|
|
)}
|
|
/>
|
|
|
|
{/* Lightning address */}
|
|
<FormField
|
|
control={form.control}
|
|
name="lud16"
|
|
render={({ field }) => (
|
|
<div className="grid grid-cols-[auto,1fr,2fr,auto] gap-2 items-center">
|
|
<div className="w-6" />
|
|
<div className="flex items-center h-9 px-3 text-sm text-muted-foreground gap-1">
|
|
<span>Lightning</span>
|
|
<HelpTip faqId="what-are-zaps" iconSize="size-3.5" />
|
|
</div>
|
|
<Input placeholder="you@walletofsatoshi.com" {...field} className="h-9" />
|
|
<div className="size-9" />
|
|
</div>
|
|
)}
|
|
/>
|
|
|
|
<SortableList
|
|
items={fields}
|
|
getItemId={(field) => field.id}
|
|
onReorder={handleFieldReorder}
|
|
className="space-y-3"
|
|
renderItem={(field, index) => (
|
|
<SortableFieldRow
|
|
key={field.id}
|
|
id={field.id}
|
|
index={index}
|
|
type={form.watch(`fields.${index}.type`) ?? 'text'}
|
|
accept={form.watch(`fields.${index}.accept`)}
|
|
valuePlaceholder={form.watch(`fields.${index}.placeholder`)}
|
|
isUploading={uploadingFieldIndex === index}
|
|
control={form.control}
|
|
onRemove={() => remove(index)}
|
|
onMediaPick={() => handleMediaPick(index)}
|
|
onTickerChange={(ticker) => form.setValue(`fields.${index}.label`, ticker, { shouldDirty: true })}
|
|
/>
|
|
)}
|
|
/>
|
|
|
|
{/* Add field — visible pill buttons */}
|
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
|
{[...FIELD_PRESETS, CUSTOM_PRESET].map((preset) => {
|
|
const Icon = preset.icon;
|
|
return (
|
|
<Tooltip key={preset.id}>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 rounded-full px-3 text-xs gap-1.5"
|
|
onClick={() => handleAddPreset(preset)}
|
|
>
|
|
<Plus className="size-3 text-muted-foreground" />
|
|
<Icon className="size-3.5 text-muted-foreground" />
|
|
{preset.label}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">
|
|
{preset.description}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile sidebar preview — visible only below widgets where the real sidebar is hidden */}
|
|
<div className="lg:hidden">
|
|
<Collapsible open={showMobilePreview} onOpenChange={setShowMobilePreview}>
|
|
<CollapsibleTrigger asChild>
|
|
<Button type="button" variant="ghost" className="w-full justify-between px-0 h-auto hover:bg-transparent hover:text-foreground">
|
|
<span className="text-sm font-medium flex items-center gap-1.5">
|
|
<Eye className="size-3.5" />
|
|
Profile Fields Preview
|
|
</span>
|
|
<ChevronDown className="size-4 text-muted-foreground transition-transform duration-200 [[data-state=open]_&]:rotate-180" strokeWidth={4} />
|
|
</Button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-3">
|
|
<div className="rounded-xl border bg-card/50 overflow-hidden">
|
|
<ProfileRightSidebar
|
|
fields={previewFields}
|
|
className="relative w-full flex flex-col h-auto max-h-[60vh] overflow-y-auto"
|
|
/>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</div>
|
|
|
|
{/* Advanced */}
|
|
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
|
<CollapsibleTrigger asChild>
|
|
<Button type="button" variant="ghost" className="w-full justify-between px-0 h-auto hover:bg-transparent hover:text-foreground">
|
|
<span className="text-sm font-medium">Advanced</span>
|
|
<ChevronDown className="size-4 text-muted-foreground transition-transform duration-200 [[data-state=open]_&]:rotate-180" strokeWidth={4} />
|
|
</Button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-3 space-y-4">
|
|
<FormField
|
|
control={form.control}
|
|
name="bot"
|
|
render={({ field }) => (
|
|
<FormItem className="flex items-center justify-between rounded-lg border p-3">
|
|
<div>
|
|
<FormLabel className="text-sm">Bot Account</FormLabel>
|
|
<FormDescription className="text-xs">Mark this account as automated</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
{/* Your Key — private-key backup. Rendered inside Advanced but is not part of the form. */}
|
|
<div className="pt-2">
|
|
<BackupKeySection />
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
|
|
</form>
|
|
</Form>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
// ── Backup Key section ────────────────────────────────────────────────────────
|
|
|
|
function BackupKeySection() {
|
|
const { logins } = useNostrLogin();
|
|
const { config } = useAppContext();
|
|
const { toast } = useToast();
|
|
const current = logins[0];
|
|
|
|
const [showKey, setShowKey] = useState(false);
|
|
const [copied, setCopied] = useState(false);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
const heading = (
|
|
<div className="flex items-center gap-2 pb-1">
|
|
<KeyRound className="size-4 text-primary/70" />
|
|
<h2 className="text-sm font-semibold">Your Key</h2>
|
|
</div>
|
|
);
|
|
|
|
// Not applicable for extension / bunker logins — key isn't available in Ditto.
|
|
if (!current) return null;
|
|
|
|
if (current.type === 'extension') {
|
|
return (
|
|
<div>
|
|
{heading}
|
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
You're signed in with a browser extension (NIP-07). Your secret key is stored there — manage or export it from the extension itself.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (current.type === 'bunker') {
|
|
return (
|
|
<div>
|
|
{heading}
|
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
You're signed in with a remote signer (NIP-46). Your secret key is held by that signer and cannot be exported from {config.appName}.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (current.type !== 'nsec') {
|
|
// Unknown future login type — don't guess.
|
|
return null;
|
|
}
|
|
|
|
const nsec = current.data.nsec;
|
|
const npub = nip19.npubEncode(current.pubkey);
|
|
|
|
const handleCopy = async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(nsec);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 1500);
|
|
} catch {
|
|
toast({
|
|
title: 'Copy failed',
|
|
description: 'Could not access the clipboard. Reveal the key and copy it manually.',
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleBackup = async () => {
|
|
if (isSaving) return;
|
|
setIsSaving(true);
|
|
try {
|
|
const result = await saveNsec(npub, nsec, config.appName);
|
|
if (result === 'saved-to-file') {
|
|
toast({
|
|
title: 'Secret key saved',
|
|
description: 'Your secret key was saved to the Documents folder on your device.',
|
|
});
|
|
} else if (result === 'saved') {
|
|
toast({ title: 'Secret key saved' });
|
|
}
|
|
// 'dismissed' is a deliberate user choice — no toast.
|
|
} catch {
|
|
toast({
|
|
title: 'Save failed',
|
|
description: 'Could not save the key. Please copy it manually.',
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{heading}
|
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
This secret key controls your account on {config.appName}. Anyone with it can post and impersonate you. Store it in a password manager or somewhere else only you can access.
|
|
</p>
|
|
|
|
<div className="relative">
|
|
<Input
|
|
type={showKey ? 'text' : 'password'}
|
|
value={nsec}
|
|
readOnly
|
|
onFocus={(e) => e.currentTarget.select()}
|
|
onClick={(e) => e.currentTarget.select()}
|
|
className="pr-20 font-mono text-base md:text-sm"
|
|
aria-label="Your secret key"
|
|
/>
|
|
<div className="absolute right-0 top-0 h-full flex items-center">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-full px-2 hover:bg-transparent"
|
|
onClick={handleCopy}
|
|
aria-label="Copy secret key"
|
|
>
|
|
{copied ? (
|
|
<Check className="h-4 w-4 text-emerald-600" />
|
|
) : (
|
|
<Copy className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-full px-2 hover:bg-transparent"
|
|
onClick={() => setShowKey((v) => !v)}
|
|
aria-label={showKey ? 'Hide secret key' : 'Reveal secret key'}
|
|
>
|
|
{showKey ? (
|
|
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<Eye className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{showKey && (
|
|
<div className="p-3 bg-amber-50 dark:bg-amber-950/20 rounded-lg border border-amber-200 dark:border-amber-800 animate-in fade-in slide-in-from-top-1 duration-200">
|
|
<p className="text-xs text-amber-900 dark:text-amber-300 leading-relaxed">
|
|
NEVER share your secret key with anyone. Avoid screenshotting it or pasting it anywhere except a password manager. If shared, others will be able to access your account.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<Button
|
|
type="button"
|
|
size="lg"
|
|
className="w-full gap-2 rounded-full h-12"
|
|
onClick={handleBackup}
|
|
disabled={isSaving}
|
|
>
|
|
{isSaving ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 animate-spin" /> Saving…
|
|
</>
|
|
) : (
|
|
<>
|
|
<Download className="w-4 h-4" /> Back Up Key
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|