2ac9960b31
Replace the single tabbed Settings page with a landing menu that navigates to dedicated sub-pages, following the Agora pattern: - Settings landing: card-based menu with icons and descriptions - Profile: wraps EditProfileForm (auth-gated) - Appearance: Theme sub-tab with visual theme selector (light/dark/black/pink + custom placeholder) and Content sub-tab with existing content settings - Wallet: dedicated page for NWC wallet management (auth-gated) - Notifications: push notification enable/disable toggle (auth-gated) - Advanced: network relays, stats source, and system settings (wallet section removed since it has its own page now)
384 lines
17 KiB
TypeScript
384 lines
17 KiB
TypeScript
import { useState } from 'react';
|
|
import { ChevronDown, ChevronUp, Upload, X } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { RelayListManager } from '@/components/RelayListManager';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useAppContext } from '@/hooks/useAppContext';
|
|
import { useToast } from '@/hooks/useToast';
|
|
|
|
export function AdvancedSettings() {
|
|
const { user } = useCurrentUser();
|
|
const { config, updateConfig } = useAppContext();
|
|
const { toast } = useToast();
|
|
const [relaysOpen, setRelaysOpen] = useState(false);
|
|
const [statsOpen, setStatsOpen] = useState(false);
|
|
const [servicesOpen, setServicesOpen] = useState(false);
|
|
const [statsPubkey, setStatsPubkey] = useState(config.nip85StatsPubkey);
|
|
const [newBlossomUrl, setNewBlossomUrl] = useState('');
|
|
const [defaultZapComment, setDefaultZapComment] = useState(config.defaultZapComment);
|
|
const [faviconUrl, setFaviconUrl] = useState(config.faviconUrl);
|
|
const [linkPreviewUrl, setLinkPreviewUrl] = useState(config.linkPreviewUrl);
|
|
const [corsProxy, setCorsProxy] = useState(config.corsProxy);
|
|
|
|
const handleStatsPubkeyChange = (value: string) => {
|
|
setStatsPubkey(value);
|
|
if (value.length === 64 && /^[0-9a-f]{64}$/i.test(value)) {
|
|
updateConfig(() => ({ nip85StatsPubkey: value.toLowerCase() }));
|
|
toast({ title: 'Stats source updated', description: 'Using NIP-85 stats from this pubkey.' });
|
|
} else if (value.length === 0) {
|
|
updateConfig(() => ({ nip85StatsPubkey: '' }));
|
|
toast({ title: 'Stats source cleared' });
|
|
}
|
|
};
|
|
|
|
const handleAddBlossomServer = () => {
|
|
const trimmed = newBlossomUrl.trim();
|
|
if (!trimmed) return;
|
|
|
|
// Normalize URL
|
|
let url: string;
|
|
try {
|
|
url = new URL(trimmed).toString();
|
|
} catch {
|
|
try {
|
|
url = new URL(`https://${trimmed}`).toString();
|
|
} catch {
|
|
toast({ title: 'Invalid URL', variant: 'destructive' });
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Check for duplicates
|
|
if (config.blossomServers.includes(url)) {
|
|
toast({ title: 'Server already added', variant: 'destructive' });
|
|
return;
|
|
}
|
|
|
|
updateConfig(() => ({ blossomServers: [...config.blossomServers, url] }));
|
|
setNewBlossomUrl('');
|
|
toast({ title: 'Blossom server added' });
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
{/* Intro */}
|
|
<div className="px-3 pt-2 pb-4">
|
|
<h2 className="text-sm font-semibold">Advanced Settings</h2>
|
|
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
|
Power user features including wallet connections and network relay management.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Network (Relays) Section — only when logged in */}
|
|
{user && (
|
|
<div>
|
|
<Collapsible open={relaysOpen} onOpenChange={setRelaysOpen}>
|
|
<CollapsibleTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
className="relative w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none"
|
|
>
|
|
<span className="text-base font-semibold">Network</span>
|
|
{relaysOpen ? (
|
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
|
</Button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="pb-4">
|
|
<RelayListManager />
|
|
|
|
{/* Blossom Servers */}
|
|
<div className="pt-4 pb-4">
|
|
<div className="px-3 space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<Upload className="h-5 w-5 text-muted-foreground" />
|
|
<h3 className="text-sm font-medium">Blossom Servers</h3>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
File upload servers for media attachments. Files are uploaded to the first available server.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Server list */}
|
|
<div className="mt-3">
|
|
{config.blossomServers.length === 0 ? (
|
|
<div className="text-xs text-muted-foreground py-8 text-center">
|
|
No Blossom servers configured. Add a server below.
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1">
|
|
{config.blossomServers.map((server) => (
|
|
<div
|
|
key={server}
|
|
className="flex items-center gap-3 py-2.5 px-3 hover:bg-muted/20 transition-colors"
|
|
>
|
|
<Upload className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
<span className="font-mono text-xs flex-1 truncate" title={server}>
|
|
{(() => {
|
|
try {
|
|
const parsed = new URL(server);
|
|
return parsed.host + (parsed.pathname === '/' ? '' : parsed.pathname);
|
|
} catch {
|
|
return server;
|
|
}
|
|
})()}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => {
|
|
const updated = config.blossomServers.filter((s) => s !== server);
|
|
updateConfig(() => ({ blossomServers: updated }));
|
|
toast({ title: 'Blossom server removed' });
|
|
}}
|
|
className="size-7 text-muted-foreground hover:text-destructive hover:bg-transparent shrink-0"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Add server form */}
|
|
<div className="px-3 mt-3">
|
|
<div className="flex gap-2">
|
|
<div className="flex-1">
|
|
<Label htmlFor="new-blossom-url" className="sr-only">
|
|
Blossom Server URL
|
|
</Label>
|
|
<Input
|
|
id="new-blossom-url"
|
|
value={newBlossomUrl}
|
|
onChange={(e) => setNewBlossomUrl(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
handleAddBlossomServer();
|
|
}
|
|
}}
|
|
placeholder="https://blossom.example.com/"
|
|
className="h-9 text-sm font-mono"
|
|
/>
|
|
</div>
|
|
<Button
|
|
onClick={handleAddBlossomServer}
|
|
disabled={!newBlossomUrl.trim()}
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-9 shrink-0 text-xs"
|
|
>
|
|
<Upload className="h-3.5 w-3.5 mr-1.5" />
|
|
Add
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</div>
|
|
)}
|
|
|
|
{/* Stats Source Section */}
|
|
<div>
|
|
<Collapsible open={statsOpen} onOpenChange={setStatsOpen}>
|
|
<CollapsibleTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
className="relative w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none"
|
|
>
|
|
<span className="text-base font-semibold">Stats Source</span>
|
|
{statsOpen ? (
|
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
|
</Button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="px-3 pt-3 pb-4 space-y-3">
|
|
<div>
|
|
<Label htmlFor="stats-pubkey" className="text-sm font-medium">
|
|
NIP-85 Stats Pubkey
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
|
Trusted pubkey for pre-computed engagement stats (likes, reposts, comments).
|
|
</p>
|
|
<Input
|
|
id="stats-pubkey"
|
|
value={statsPubkey}
|
|
onChange={(e) => handleStatsPubkeyChange(e.target.value)}
|
|
placeholder="Enter 64-character hex pubkey"
|
|
className="font-mono text-sm"
|
|
maxLength={64}
|
|
/>
|
|
{statsPubkey && statsPubkey.length !== 64 && (
|
|
<p className="text-xs text-destructive mt-1">
|
|
Pubkey must be exactly 64 hexadecimal characters
|
|
</p>
|
|
)}
|
|
<div className="text-xs text-muted-foreground mt-2">
|
|
<span className="font-medium">Default: </span>
|
|
<span className="font-mono break-all">5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</div>
|
|
|
|
{/* System Section */}
|
|
<div>
|
|
<Collapsible open={servicesOpen} onOpenChange={setServicesOpen}>
|
|
<CollapsibleTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
className="relative w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none"
|
|
>
|
|
<span className="text-base font-semibold">System</span>
|
|
{servicesOpen ? (
|
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
|
</Button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="px-3 pt-3 pb-4 space-y-5">
|
|
{/* Default Zap Comment */}
|
|
<div>
|
|
<Label htmlFor="default-zap-comment" className="text-sm font-medium">
|
|
Default Zap Comment
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
|
Pre-filled comment when sending zaps.
|
|
</p>
|
|
<Input
|
|
id="default-zap-comment"
|
|
value={defaultZapComment}
|
|
onChange={(e) => {
|
|
setDefaultZapComment(e.target.value);
|
|
}}
|
|
onBlur={() => {
|
|
if (defaultZapComment !== config.defaultZapComment) {
|
|
updateConfig(() => ({ defaultZapComment }));
|
|
toast({ title: 'Default zap comment updated' });
|
|
}
|
|
}}
|
|
placeholder="Zapped with Ditto!"
|
|
className="text-sm"
|
|
/>
|
|
<div className="text-xs text-muted-foreground mt-2">
|
|
<span className="font-medium">Default: </span>
|
|
<span>Zapped with Ditto!</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Favicon URL */}
|
|
<div>
|
|
<Label htmlFor="favicon-url" className="text-sm font-medium">
|
|
Favicon URL
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
|
URI template for fetching site favicons. Supports RFC 6570 variables: <code className="bg-muted px-1 rounded">{'{href}'}</code>, <code className="bg-muted px-1 rounded">{'{hostname}'}</code>, <code className="bg-muted px-1 rounded">{'{origin}'}</code>, etc.
|
|
</p>
|
|
<Input
|
|
id="favicon-url"
|
|
value={faviconUrl}
|
|
onChange={(e) => {
|
|
setFaviconUrl(e.target.value);
|
|
}}
|
|
onBlur={() => {
|
|
const trimmed = faviconUrl.trim();
|
|
if (trimmed && trimmed !== config.faviconUrl) {
|
|
updateConfig(() => ({ faviconUrl: trimmed }));
|
|
toast({ title: 'Favicon URL updated' });
|
|
}
|
|
}}
|
|
placeholder="https://fetch.ditto.pub/favicon/{hostname}"
|
|
className="font-mono text-sm"
|
|
/>
|
|
<div className="text-xs text-muted-foreground mt-2">
|
|
<span className="font-medium">Default: </span>
|
|
<span className="font-mono break-all">https://fetch.ditto.pub/favicon/{'{hostname}'}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Link Preview URL */}
|
|
<div>
|
|
<Label htmlFor="link-preview-url" className="text-sm font-medium">
|
|
Link Preview URL
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
|
URI template for fetching link previews (returns OEmbed JSON). Supports RFC 6570 variables: <code className="bg-muted px-1 rounded">{'{url}'}</code>, <code className="bg-muted px-1 rounded">{'{hostname}'}</code>, <code className="bg-muted px-1 rounded">{'{origin}'}</code>, etc.
|
|
</p>
|
|
<Input
|
|
id="link-preview-url"
|
|
value={linkPreviewUrl}
|
|
onChange={(e) => {
|
|
setLinkPreviewUrl(e.target.value);
|
|
}}
|
|
onBlur={() => {
|
|
const trimmed = linkPreviewUrl.trim();
|
|
if (trimmed && trimmed !== config.linkPreviewUrl) {
|
|
updateConfig(() => ({ linkPreviewUrl: trimmed }));
|
|
toast({ title: 'Link preview URL updated' });
|
|
}
|
|
}}
|
|
placeholder="https://fetch.ditto.pub/link/{url}"
|
|
className="font-mono text-sm"
|
|
/>
|
|
<div className="text-xs text-muted-foreground mt-2">
|
|
<span className="font-medium">Default: </span>
|
|
<span className="font-mono break-all">https://fetch.ditto.pub/link/{'{url}'}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* CORS Proxy */}
|
|
<div>
|
|
<Label htmlFor="cors-proxy" className="text-sm font-medium">
|
|
CORS Proxy
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
|
Proxy for cross-origin requests (NIP-05 fallback). Use <code className="bg-muted px-1 rounded">{'{href}'}</code> as a placeholder for the target URL.
|
|
</p>
|
|
<Input
|
|
id="cors-proxy"
|
|
value={corsProxy}
|
|
onChange={(e) => {
|
|
setCorsProxy(e.target.value);
|
|
}}
|
|
onBlur={() => {
|
|
const trimmed = corsProxy.trim();
|
|
if (trimmed && trimmed !== config.corsProxy) {
|
|
updateConfig(() => ({ corsProxy: trimmed }));
|
|
toast({ title: 'CORS proxy updated' });
|
|
}
|
|
}}
|
|
placeholder="https://proxy.shakespeare.diy/?url={href}"
|
|
className="font-mono text-sm"
|
|
/>
|
|
<div className="text-xs text-muted-foreground mt-2">
|
|
<span className="font-medium">Default: </span>
|
|
<span className="font-mono break-all">https://proxy.shakespeare.diy/?url={'{href}'}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|