41 changed files with 755 additions and 91 deletions
@ -0,0 +1,177 @@ |
|||||||
|
import { Badge } from '@/components/ui/badge' |
||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { Input } from '@/components/ui/input' |
||||||
|
import { Separator } from '@/components/ui/separator' |
||||||
|
import { RECOMMENDED_BLOSSOM_SERVERS } from '@/constants' |
||||||
|
import { createBlossomServerListDraftEvent } from '@/lib/draft-event' |
||||||
|
import { extractServersFromTags } from '@/lib/event' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import client from '@/services/client.service' |
||||||
|
import { AlertCircle, ArrowUpToLine, Loader, X } from 'lucide-react' |
||||||
|
import { Event } from 'nostr-tools' |
||||||
|
import { useEffect, useMemo, useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
export default function BlossomServerListSetting() { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { pubkey, publish } = useNostr() |
||||||
|
const [blossomServerListEvent, setBlossomServerListEvent] = useState<Event | null>(null) |
||||||
|
const serverUrls = useMemo(() => { |
||||||
|
return extractServersFromTags(blossomServerListEvent ? blossomServerListEvent.tags : []) |
||||||
|
}, [blossomServerListEvent]) |
||||||
|
const [url, setUrl] = useState('') |
||||||
|
const [removingIndex, setRemovingIndex] = useState(-1) |
||||||
|
const [movingIndex, setMovingIndex] = useState(-1) |
||||||
|
const [adding, setAdding] = useState(false) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const init = async () => { |
||||||
|
if (!pubkey) { |
||||||
|
setBlossomServerListEvent(null) |
||||||
|
return |
||||||
|
} |
||||||
|
const event = await client.fetchBlossomServerListEvent(pubkey) |
||||||
|
setBlossomServerListEvent(event) |
||||||
|
} |
||||||
|
init() |
||||||
|
}, [pubkey]) |
||||||
|
|
||||||
|
const addBlossomUrl = async (url: string) => { |
||||||
|
if (!url || adding || removingIndex >= 0 || movingIndex >= 0) return |
||||||
|
setAdding(true) |
||||||
|
try { |
||||||
|
const draftEvent = createBlossomServerListDraftEvent([...serverUrls, url]) |
||||||
|
const newEvent = await publish(draftEvent) |
||||||
|
await client.updateBlossomServerListEventCache(newEvent) |
||||||
|
setBlossomServerListEvent(newEvent) |
||||||
|
setUrl('') |
||||||
|
} catch (error) { |
||||||
|
console.error('Failed to add Blossom URL:', error) |
||||||
|
} finally { |
||||||
|
setAdding(false) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const handleUrlInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { |
||||||
|
if (event.key === 'Enter') { |
||||||
|
event.preventDefault() |
||||||
|
addBlossomUrl(url) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const removeBlossomUrl = async (idx: number) => { |
||||||
|
if (removingIndex >= 0 || adding || movingIndex >= 0) return |
||||||
|
setRemovingIndex(idx) |
||||||
|
try { |
||||||
|
const draftEvent = createBlossomServerListDraftEvent(serverUrls.filter((_, i) => i !== idx)) |
||||||
|
const newEvent = await publish(draftEvent) |
||||||
|
await client.updateBlossomServerListEventCache(newEvent) |
||||||
|
setBlossomServerListEvent(newEvent) |
||||||
|
} catch (error) { |
||||||
|
console.error('Failed to remove Blossom URL:', error) |
||||||
|
} finally { |
||||||
|
setRemovingIndex(-1) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const moveToTop = async (idx: number) => { |
||||||
|
if (removingIndex >= 0 || adding || movingIndex >= 0 || idx === 0) return |
||||||
|
setMovingIndex(idx) |
||||||
|
try { |
||||||
|
const newUrls = [serverUrls[idx], ...serverUrls.filter((_, i) => i !== idx)] |
||||||
|
const draftEvent = createBlossomServerListDraftEvent(newUrls) |
||||||
|
const newEvent = await publish(draftEvent) |
||||||
|
await client.updateBlossomServerListEventCache(newEvent) |
||||||
|
setBlossomServerListEvent(newEvent) |
||||||
|
} catch (error) { |
||||||
|
console.error('Failed to move Blossom URL to top:', error) |
||||||
|
} finally { |
||||||
|
setMovingIndex(-1) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="space-y-2"> |
||||||
|
<div className="text-sm font-medium">{t('Blossom server URLs')}</div> |
||||||
|
{serverUrls.length === 0 && ( |
||||||
|
<div className="flex flex-col gap-1 text-sm border rounded-lg p-2 bg-muted text-muted-foreground"> |
||||||
|
<div className="font-medium flex gap-2 items-center"> |
||||||
|
<AlertCircle className="size-4" /> |
||||||
|
{t('You need to add at least one media server in order to upload media files.')} |
||||||
|
</div> |
||||||
|
<Separator className="bg-muted-foreground my-2" /> |
||||||
|
<div className="font-medium">{t('Recommended blossom servers')}:</div> |
||||||
|
<div className="flex flex-col"> |
||||||
|
{RECOMMENDED_BLOSSOM_SERVERS.map((recommendedUrl) => ( |
||||||
|
<Button |
||||||
|
variant="link" |
||||||
|
key={recommendedUrl} |
||||||
|
onClick={() => addBlossomUrl(recommendedUrl)} |
||||||
|
disabled={removingIndex >= 0 || adding || movingIndex >= 0} |
||||||
|
className="w-fit p-0 text-muted-foreground hover:text-foreground h-fit" |
||||||
|
> |
||||||
|
{recommendedUrl} |
||||||
|
</Button> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
{serverUrls.map((url, idx) => ( |
||||||
|
<div |
||||||
|
key={url} |
||||||
|
className={cn( |
||||||
|
'flex items-center justify-between gap-2 pl-3 pr-1 py-1 border rounded-lg', |
||||||
|
idx === 0 && 'border-primary' |
||||||
|
)} |
||||||
|
> |
||||||
|
<a |
||||||
|
href={url} |
||||||
|
target="_blank" |
||||||
|
rel="noopener noreferrer" |
||||||
|
className="truncate hover:underline" |
||||||
|
> |
||||||
|
{url} |
||||||
|
</a> |
||||||
|
<div className="flex items-center gap-2"> |
||||||
|
{idx > 0 ? ( |
||||||
|
<Button |
||||||
|
variant="ghost" |
||||||
|
size="icon" |
||||||
|
onClick={() => moveToTop(idx)} |
||||||
|
title={t('Move to top')} |
||||||
|
disabled={removingIndex >= 0 || adding || movingIndex >= 0} |
||||||
|
className="text-muted-foreground" |
||||||
|
> |
||||||
|
{movingIndex === idx ? <Loader className="animate-spin" /> : <ArrowUpToLine />} |
||||||
|
</Button> |
||||||
|
) : ( |
||||||
|
<Badge>{t('Preferred')}</Badge> |
||||||
|
)} |
||||||
|
<Button |
||||||
|
variant="ghost-destructive" |
||||||
|
size="icon" |
||||||
|
onClick={() => removeBlossomUrl(idx)} |
||||||
|
title={t('Remove')} |
||||||
|
disabled={removingIndex >= 0 || adding || movingIndex >= 0} |
||||||
|
> |
||||||
|
{removingIndex === idx ? <Loader className="animate-spin" /> : <X />} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
<div className="flex items-center gap-2"> |
||||||
|
<Input |
||||||
|
value={url} |
||||||
|
onChange={(e) => setUrl(e.target.value)} |
||||||
|
placeholder={t('Enter Blossom server URL')} |
||||||
|
onKeyDown={handleUrlInputKeyDown} |
||||||
|
/> |
||||||
|
<Button type="button" onClick={() => addBlossomUrl(url)} title={t('Add')}> |
||||||
|
{adding && <Loader className="animate-spin" />} |
||||||
|
{t('Add')} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
Loading…
Reference in new issue