418 lines
19 KiB
TypeScript
418 lines
19 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { ChevronDown, ChevronUp, Bot, Bug, RotateCcw, AlertTriangle } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { RequestToVanishDialog } from '@/components/RequestToVanishDialog';
|
|
import { useAppContext } from '@/hooks/useAppContext';
|
|
import { useShakespeare, type Model } from '@/hooks/useShakespeare';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
|
|
/** The build-time default DSN from the environment variable. */
|
|
const DEFAULT_SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN || '';
|
|
|
|
export function AdvancedSettings() {
|
|
const { config, updateConfig } = useAppContext();
|
|
const { toast } = useToast();
|
|
const { updateSettings } = useEncryptedSettings();
|
|
const { user } = useCurrentUser();
|
|
const { getAvailableModels } = useShakespeare();
|
|
const [aiOpen, setAiOpen] = useState(false);
|
|
const [aiModels, setAiModels] = useState<Model[]>([]);
|
|
const [aiModelsLoading, setAiModelsLoading] = useState(false);
|
|
const [systemOpen, setSystemOpen] = useState(true);
|
|
const [sentryOpen, setSentryOpen] = useState(false);
|
|
const [dangerOpen, setDangerOpen] = useState(false);
|
|
const [vanishDialogOpen, setVanishDialogOpen] = useState(false);
|
|
const [statsPubkey, setStatsPubkey] = useState(config.nip85StatsPubkey);
|
|
const [faviconUrl, setFaviconUrl] = useState(config.faviconUrl);
|
|
const [linkPreviewUrl, setLinkPreviewUrl] = useState(config.linkPreviewUrl);
|
|
const [corsProxy, setCorsProxy] = useState(config.corsProxy);
|
|
const [sentryDsn, setSentryDsn] = useState(config.sentryDsn);
|
|
|
|
// Fetch AI models when the section opens
|
|
useEffect(() => {
|
|
if (!aiOpen || !user || aiModels.length > 0) return;
|
|
let cancelled = false;
|
|
setAiModelsLoading(true);
|
|
getAvailableModels()
|
|
.then((response) => {
|
|
if (cancelled) return;
|
|
const sorted = response.data.sort((a, b) => {
|
|
const costA = parseFloat(a.pricing.prompt) + parseFloat(a.pricing.completion);
|
|
const costB = parseFloat(b.pricing.prompt) + parseFloat(b.pricing.completion);
|
|
return costA - costB;
|
|
});
|
|
setAiModels(sorted);
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => { if (!cancelled) setAiModelsLoading(false); });
|
|
return () => { cancelled = true; };
|
|
}, [aiOpen, user, aiModels.length, getAvailableModels]);
|
|
|
|
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' });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
{/* Dork AI Section */}
|
|
{user && (
|
|
<div>
|
|
<Collapsible open={aiOpen} onOpenChange={setAiOpen}>
|
|
<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="flex items-center gap-2 text-base font-semibold">
|
|
<Bot className="h-4 w-4" />
|
|
Dork
|
|
</span>
|
|
{aiOpen ? (
|
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
</Button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="px-4 py-4 space-y-4 border-b border-border">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="ai-model">Model</Label>
|
|
<Select
|
|
value={config.aiModel || (aiModels.length > 0 ? aiModels[0].id : '')}
|
|
onValueChange={(value) => {
|
|
updateConfig(() => ({ aiModel: value }));
|
|
toast({ title: 'AI model updated' });
|
|
}}
|
|
disabled={aiModelsLoading || aiModels.length === 0}
|
|
>
|
|
<SelectTrigger id="ai-model">
|
|
<SelectValue placeholder={aiModelsLoading ? 'Loading models...' : 'Select model'} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{aiModels.map((model) => {
|
|
const totalCost = parseFloat(model.pricing.prompt) + parseFloat(model.pricing.completion);
|
|
const isFree = totalCost === 0;
|
|
return (
|
|
<SelectItem key={model.id} value={model.id}>
|
|
<span className="flex items-center gap-1.5">
|
|
{model.name}
|
|
{isFree && (
|
|
<span className="text-[10px] font-medium text-green-600 dark:text-green-400 bg-green-500/10 px-1 rounded">
|
|
FREE
|
|
</span>
|
|
)}
|
|
</span>
|
|
</SelectItem>
|
|
);
|
|
})}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-muted-foreground">
|
|
Choose which AI model Dork uses for chat responses.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</div>
|
|
)}
|
|
|
|
{/* System Section (includes Stats Source) */}
|
|
<div>
|
|
<Collapsible open={systemOpen} onOpenChange={setSystemOpen}>
|
|
<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>
|
|
{systemOpen ? (
|
|
<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">
|
|
|
|
{/* Stats Source */}
|
|
<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-base md: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>
|
|
|
|
{/* 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={async () => {
|
|
const trimmed = faviconUrl.trim();
|
|
if (trimmed && trimmed !== config.faviconUrl) {
|
|
updateConfig(() => ({ faviconUrl: trimmed }));
|
|
if (user) await updateSettings.mutateAsync({ faviconUrl: trimmed });
|
|
toast({ title: 'Favicon URL updated' });
|
|
}
|
|
}}
|
|
placeholder="https://ditto.pub/api/favicon/{hostname}"
|
|
className="font-mono text-base md:text-sm"
|
|
/>
|
|
<div className="text-xs text-muted-foreground mt-2">
|
|
<span className="font-medium">Default: </span>
|
|
<span className="font-mono break-all">https://ditto.pub/api/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={async () => {
|
|
const trimmed = linkPreviewUrl.trim();
|
|
if (trimmed && trimmed !== config.linkPreviewUrl) {
|
|
updateConfig(() => ({ linkPreviewUrl: trimmed }));
|
|
if (user) await updateSettings.mutateAsync({ linkPreviewUrl: trimmed });
|
|
toast({ title: 'Link preview URL updated' });
|
|
}
|
|
}}
|
|
placeholder="https://ditto.pub/api/link-preview/{url}"
|
|
className="font-mono text-base md:text-sm"
|
|
/>
|
|
<div className="text-xs text-muted-foreground mt-2">
|
|
<span className="font-medium">Default: </span>
|
|
<span className="font-mono break-all">https://ditto.pub/api/link-preview/{'{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={async () => {
|
|
const trimmed = corsProxy.trim();
|
|
if (trimmed && trimmed !== config.corsProxy) {
|
|
updateConfig(() => ({ corsProxy: trimmed }));
|
|
if (user) await updateSettings.mutateAsync({ corsProxy: trimmed });
|
|
toast({ title: 'CORS proxy updated' });
|
|
}
|
|
}}
|
|
placeholder="https://proxy.shakespeare.diy/?url={href}"
|
|
className="font-mono text-base md: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>
|
|
|
|
{/* Error Reporting Section */}
|
|
<div>
|
|
<Collapsible open={sentryOpen} onOpenChange={setSentryOpen}>
|
|
<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="flex items-center gap-2 text-base font-semibold">
|
|
<Bug className="h-4 w-4" />
|
|
Error Reporting
|
|
</span>
|
|
{sentryOpen ? (
|
|
<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">
|
|
|
|
{/* Share error reports toggle */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="sentry-enabled" className="text-sm font-medium">
|
|
Share error reports
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
Help improve this app by automatically sending crash and error reports.
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
id="sentry-enabled"
|
|
checked={config.sentryEnabled}
|
|
onCheckedChange={(checked) => {
|
|
updateConfig((current) => ({ ...current, sentryEnabled: checked }));
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Sentry DSN */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<Label htmlFor="sentry-dsn" className="text-sm font-medium">
|
|
Sentry DSN
|
|
{sentryDsn !== DEFAULT_SENTRY_DSN && (
|
|
<span className="ml-2 inline-block w-2 h-2 rounded-full bg-yellow-400" title="Modified from default" />
|
|
)}
|
|
</Label>
|
|
{sentryDsn !== DEFAULT_SENTRY_DSN && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 w-6 p-0"
|
|
title="Restore to default"
|
|
onClick={async () => {
|
|
setSentryDsn(DEFAULT_SENTRY_DSN);
|
|
updateConfig((current) => ({ ...current, sentryDsn: DEFAULT_SENTRY_DSN }));
|
|
if (user) await updateSettings.mutateAsync({ sentryDsn: DEFAULT_SENTRY_DSN });
|
|
toast({ title: 'Sentry DSN restored to default' });
|
|
}}
|
|
>
|
|
<RotateCcw className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
|
Sentry Data Source Name (DSN) for error reporting. Leave empty to disable Sentry.
|
|
</p>
|
|
<Input
|
|
id="sentry-dsn"
|
|
value={sentryDsn}
|
|
onChange={(e) => setSentryDsn(e.target.value)}
|
|
onBlur={async () => {
|
|
const trimmed = sentryDsn.trim();
|
|
if (trimmed !== config.sentryDsn) {
|
|
updateConfig((current) => ({ ...current, sentryDsn: trimmed }));
|
|
if (user) await updateSettings.mutateAsync({ sentryDsn: trimmed });
|
|
toast({ title: trimmed ? 'Sentry DSN updated' : 'Sentry DSN cleared' });
|
|
}
|
|
}}
|
|
placeholder="https://examplePublicKey@o0.ingest.sentry.io/0"
|
|
className="font-mono text-base md:text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</div>
|
|
|
|
{/* Danger Zone Section — only when logged in */}
|
|
{user && (
|
|
<div>
|
|
<Collapsible open={dangerOpen} onOpenChange={setDangerOpen}>
|
|
<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="flex items-center gap-2 text-base font-semibold text-destructive">
|
|
<AlertTriangle className="h-4 w-4" />
|
|
Danger Zone
|
|
</span>
|
|
{dangerOpen ? (
|
|
<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-destructive rounded-full" />
|
|
</Button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="px-3 pt-3 pb-4 space-y-4">
|
|
<div className="rounded-lg border border-destructive/30 p-4 space-y-3">
|
|
<div>
|
|
<h3 className="text-sm font-medium">Delete Account</h3>
|
|
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
|
Permanently delete your data from the network, including your profile,
|
|
posts, reactions, and direct messages. This action is irreversible.
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
|
onClick={() => setVanishDialogOpen(true)}
|
|
>
|
|
Delete Account
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
|
|
<RequestToVanishDialog
|
|
open={vanishDialogOpen}
|
|
onOpenChange={setVanishDialogOpen}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|