feat: 🌸

This commit is contained in:
codytseng
2025-07-18 23:25:47 +08:00
parent 74e04e1c7d
commit e91b2648cc
41 changed files with 756 additions and 92 deletions

View File

@@ -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>
)
}

View File

@@ -9,20 +9,42 @@ import {
import { DEFAULT_NIP_96_SERVICE, NIP_96_SERVICE } from '@/constants'
import { simplifyUrl } from '@/lib/url'
import { useMediaUploadService } from '@/providers/MediaUploadServiceProvider'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import BlossomServerListSetting from './BlossomServerListSetting'
const BLOSSOM = 'blossom'
export default function MediaUploadServiceSetting() {
const { t } = useTranslation()
const { service, updateService } = useMediaUploadService()
const { serviceConfig, updateServiceConfig } = useMediaUploadService()
const selectedValue = useMemo(() => {
if (serviceConfig.type === 'blossom') {
return BLOSSOM
}
return serviceConfig.service
}, [serviceConfig])
const handleSelectedValueChange = (value: string) => {
if (value === BLOSSOM) {
return updateServiceConfig({ type: 'blossom' })
}
return updateServiceConfig({ type: 'nip96', service: value })
}
return (
<div className="space-y-2">
<Label htmlFor="media-upload-service-select">{t('Media upload service')}</Label>
<Select defaultValue={DEFAULT_NIP_96_SERVICE} value={service} onValueChange={updateService}>
<Select
defaultValue={DEFAULT_NIP_96_SERVICE}
value={selectedValue}
onValueChange={handleSelectedValueChange}
>
<SelectTrigger id="media-upload-service-select" className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={BLOSSOM}>{t('Blossom')}</SelectItem>
{NIP_96_SERVICE.map((url) => (
<SelectItem key={url} value={url}>
{simplifyUrl(url)}
@@ -30,6 +52,8 @@ export default function MediaUploadServiceSetting() {
))}
</SelectContent>
</Select>
{selectedValue === BLOSSOM && <BlossomServerListSetting />}
</div>
)
}