384 lines
13 KiB
TypeScript
384 lines
13 KiB
TypeScript
import { useState, useEffect } from 'react'
|
||
import { configStorage } from '@/lib/configStorage'
|
||
import type { Nip95Config } from '@/lib/configStorageTypes'
|
||
import { t } from '@/lib/i18n'
|
||
import { userConfirm } from '@/lib/userConfirm'
|
||
|
||
interface Nip95ConfigManagerProps {
|
||
onConfigChange?: () => void
|
||
}
|
||
|
||
export function Nip95ConfigManager({ onConfigChange }: Nip95ConfigManagerProps): React.ReactElement {
|
||
const [apis, setApis] = useState<Nip95Config[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [editingId, setEditingId] = useState<string | null>(null)
|
||
const [newUrl, setNewUrl] = useState('')
|
||
const [showAddForm, setShowAddForm] = useState(false)
|
||
const [draggedId, setDraggedId] = useState<string | null>(null)
|
||
const [dragOverId, setDragOverId] = useState<string | null>(null)
|
||
|
||
useEffect(() => {
|
||
void loadApis()
|
||
}, [])
|
||
|
||
async function loadApis(): Promise<void> {
|
||
try {
|
||
setLoading(true)
|
||
setError(null)
|
||
const config = await configStorage.getConfig()
|
||
setApis(config.nip95Apis.sort((a, b) => a.priority - b.priority))
|
||
} catch (e) {
|
||
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.loadFailed')
|
||
setError(errorMessage)
|
||
console.error('Error loading NIP-95 APIs:', e)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
async function handleToggleEnabled(id: string, enabled: boolean): Promise<void> {
|
||
try {
|
||
await configStorage.updateNip95Api(id, { enabled })
|
||
await loadApis()
|
||
onConfigChange?.()
|
||
} catch (e) {
|
||
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.updateFailed')
|
||
setError(errorMessage)
|
||
console.error('Error updating NIP-95 API:', e)
|
||
}
|
||
}
|
||
|
||
async function handleUpdatePriorities(newOrder: Nip95Config[]): Promise<void> {
|
||
try {
|
||
// Update priorities based on new order (priority = index + 1, lower number = higher priority)
|
||
const updatePromises = newOrder.map((api, index) => {
|
||
const newPriority = index + 1
|
||
if (api.priority !== newPriority) {
|
||
return configStorage.updateNip95Api(api.id, { priority: newPriority })
|
||
}
|
||
return Promise.resolve()
|
||
})
|
||
|
||
await Promise.all(updatePromises)
|
||
await loadApis()
|
||
onConfigChange?.()
|
||
} catch (e) {
|
||
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.priorityFailed')
|
||
setError(errorMessage)
|
||
console.error('Error updating priorities:', e)
|
||
}
|
||
}
|
||
|
||
function handleDragStart(e: React.DragEvent<HTMLDivElement>, id: string): void {
|
||
setDraggedId(id)
|
||
const { dataTransfer } = e
|
||
dataTransfer.effectAllowed = 'move'
|
||
dataTransfer.setData('text/plain', id)
|
||
}
|
||
|
||
function handleDragOver(e: React.DragEvent<HTMLDivElement>, id: string): void {
|
||
e.preventDefault()
|
||
const { dataTransfer } = e
|
||
dataTransfer.dropEffect = 'move'
|
||
setDragOverId(id)
|
||
}
|
||
|
||
function handleDragLeave(): void {
|
||
setDragOverId(null)
|
||
}
|
||
|
||
function handleDrop(e: React.DragEvent<HTMLDivElement>, targetId: string): void {
|
||
e.preventDefault()
|
||
setDragOverId(null)
|
||
|
||
if (!draggedId || draggedId === targetId) {
|
||
setDraggedId(null)
|
||
return
|
||
}
|
||
|
||
const draggedIndex = apis.findIndex((api) => api.id === draggedId)
|
||
const targetIndex = apis.findIndex((api) => api.id === targetId)
|
||
|
||
if (draggedIndex === -1 || targetIndex === -1) {
|
||
setDraggedId(null)
|
||
return
|
||
}
|
||
|
||
// Reorder the array
|
||
const newApis = [...apis]
|
||
const removed = newApis[draggedIndex]
|
||
if (!removed) {
|
||
setDraggedId(null)
|
||
return
|
||
}
|
||
newApis.splice(draggedIndex, 1)
|
||
newApis.splice(targetIndex, 0, removed)
|
||
|
||
setApis(newApis)
|
||
setDraggedId(null)
|
||
|
||
// Update priorities based on new order
|
||
void handleUpdatePriorities(newApis)
|
||
}
|
||
|
||
function DragHandle(): React.ReactElement {
|
||
return (
|
||
<div className="flex flex-col gap-1 cursor-grab active:cursor-grabbing text-cyber-accent/50 hover:text-neon-cyan transition-colors">
|
||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
||
<circle cx="2" cy="2" r="1.5" />
|
||
<circle cx="6" cy="2" r="1.5" />
|
||
<circle cx="10" cy="2" r="1.5" />
|
||
<circle cx="2" cy="6" r="1.5" />
|
||
<circle cx="6" cy="6" r="1.5" />
|
||
<circle cx="10" cy="6" r="1.5" />
|
||
<circle cx="2" cy="10" r="1.5" />
|
||
<circle cx="6" cy="10" r="1.5" />
|
||
<circle cx="10" cy="10" r="1.5" />
|
||
</svg>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
async function handleUpdateUrl(id: string, url: string): Promise<void> {
|
||
try {
|
||
await configStorage.updateNip95Api(id, { url })
|
||
await loadApis()
|
||
setEditingId(null)
|
||
onConfigChange?.()
|
||
} catch (e) {
|
||
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.urlFailed')
|
||
setError(errorMessage)
|
||
console.error('Error updating URL:', e)
|
||
}
|
||
}
|
||
|
||
async function handleAddApi(): Promise<void> {
|
||
if (!newUrl.trim()) {
|
||
setError(t('settings.nip95.error.urlRequired'))
|
||
return
|
||
}
|
||
|
||
try {
|
||
// Validate URL format - throws if invalid
|
||
void new URL(newUrl)
|
||
await configStorage.addNip95Api(newUrl.trim(), false)
|
||
setNewUrl('')
|
||
setShowAddForm(false)
|
||
await loadApis()
|
||
onConfigChange?.()
|
||
} catch (e) {
|
||
if (e instanceof TypeError && e.message.includes('Invalid URL')) {
|
||
setError(t('settings.nip95.error.invalidUrl'))
|
||
} else {
|
||
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.addFailed')
|
||
setError(errorMessage)
|
||
}
|
||
console.error('Error adding NIP-95 API:', e)
|
||
}
|
||
}
|
||
|
||
async function handleRemoveApi(id: string): Promise<void> {
|
||
if (!userConfirm(t('settings.nip95.remove.confirm'))) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
await configStorage.removeNip95Api(id)
|
||
await loadApis()
|
||
onConfigChange?.()
|
||
} catch (e) {
|
||
const errorMessage = e instanceof Error ? e.message : t('settings.nip95.error.removeFailed')
|
||
setError(errorMessage)
|
||
console.error('Error removing NIP-95 API:', e)
|
||
}
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="text-center py-8 text-neon-cyan">
|
||
<div>{t('settings.nip95.loading')}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{error && (
|
||
<div className="bg-red-900/30 border border-red-500/50 rounded p-4 text-red-300">
|
||
{error}
|
||
<button
|
||
onClick={() => setError(null)}
|
||
className="ml-4 text-red-400 hover:text-red-200"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-between items-center">
|
||
<h2 className="text-2xl font-bold text-neon-cyan">{t('settings.nip95.title')}</h2>
|
||
<button
|
||
onClick={() => setShowAddForm(!showAddForm)}
|
||
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border border-neon-cyan/50 rounded transition-colors"
|
||
>
|
||
{showAddForm ? t('settings.nip95.add.cancel') : t('settings.nip95.addButton')}
|
||
</button>
|
||
</div>
|
||
|
||
{showAddForm && (
|
||
<div className="bg-cyber-dark border border-neon-cyan/30 rounded p-4 space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-cyber-accent mb-2">
|
||
{t('settings.nip95.add.url')}
|
||
</label>
|
||
<input
|
||
type="url"
|
||
value={newUrl}
|
||
onChange={(e) => setNewUrl(e.target.value)}
|
||
placeholder={t('settings.nip95.add.placeholder')}
|
||
className="w-full px-4 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
|
||
/>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => void handleAddApi()}
|
||
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan border border-neon-cyan/50 rounded transition-colors"
|
||
>
|
||
{t('settings.nip95.add.add')}
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setShowAddForm(false)
|
||
setNewUrl('')
|
||
setError(null)
|
||
}}
|
||
className="px-4 py-2 bg-cyber-accent/20 hover:bg-cyber-accent/30 text-cyber-accent border border-cyber-accent/50 rounded transition-colors"
|
||
>
|
||
{t('settings.nip95.add.cancel')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-4">
|
||
{apis.length === 0 ? (
|
||
<div className="text-center py-8 text-cyber-accent">
|
||
{t('settings.nip95.empty')}
|
||
</div>
|
||
) : (
|
||
apis.map((api, index) => (
|
||
<div
|
||
key={api.id}
|
||
onDragOver={(e) => {
|
||
handleDragOver(e, api.id)
|
||
}}
|
||
onDragLeave={handleDragLeave}
|
||
onDrop={(e) => {
|
||
handleDrop(e, api.id)
|
||
}}
|
||
className={`bg-cyber-dark border rounded p-4 space-y-3 transition-all ${getApiCardClassName(api.id, draggedId, dragOverId)}`}
|
||
>
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div className="flex items-center gap-3 flex-1">
|
||
<div
|
||
className="drag-handle cursor-grab active:cursor-grabbing"
|
||
draggable
|
||
onDragStart={(e) => {
|
||
handleDragStart(e, api.id)
|
||
e.stopPropagation()
|
||
}}
|
||
onMouseDown={(e) => {
|
||
e.stopPropagation()
|
||
}}
|
||
>
|
||
<DragHandle />
|
||
</div>
|
||
<div className="flex-1">
|
||
{editingId === api.id ? (
|
||
<div className="space-y-2">
|
||
<input
|
||
type="url"
|
||
defaultValue={api.url}
|
||
onBlur={(e) => {
|
||
if (e.target.value !== api.url) {
|
||
void handleUpdateUrl(api.id, e.target.value)
|
||
} else {
|
||
setEditingId(null)
|
||
}
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
e.currentTarget.blur()
|
||
} else if (e.key === 'Escape') {
|
||
setEditingId(null)
|
||
}
|
||
}}
|
||
className="w-full px-3 py-2 bg-cyber-darker border border-neon-cyan/50 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
|
||
autoFocus
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div
|
||
className="text-neon-cyan cursor-pointer hover:text-neon-green transition-colors"
|
||
onClick={() => setEditingId(api.id)}
|
||
title={t('settings.nip95.list.editUrl')}
|
||
>
|
||
{api.url}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={api.enabled}
|
||
onChange={(e) => void handleToggleEnabled(api.id, e.target.checked)}
|
||
className="w-4 h-4 text-neon-cyan bg-cyber-darker border-cyber-accent rounded focus:ring-neon-cyan"
|
||
/>
|
||
<span className="text-sm text-cyber-accent">
|
||
{api.enabled ? t('settings.nip95.list.enabled') : t('settings.nip95.list.disabled')}
|
||
</span>
|
||
</label>
|
||
<button
|
||
onClick={() => void handleRemoveApi(api.id)}
|
||
className="px-3 py-1 text-sm bg-red-900/30 hover:bg-red-900/50 text-red-300 border border-red-500/50 rounded transition-colors"
|
||
title={t('settings.nip95.list.remove')}
|
||
>
|
||
{t('settings.nip95.list.remove')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-xs text-cyber-accent/70">
|
||
<span>
|
||
{t('settings.nip95.list.priorityLabel', { priority: index + 1, id: api.id })}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
<div className="text-sm text-cyber-accent space-y-2">
|
||
<p>
|
||
<strong>{t('settings.nip95.note.title')}</strong> {t('settings.nip95.note.priority')}
|
||
</p>
|
||
<p>
|
||
{t('settings.nip95.note.fallback')}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function getApiCardClassName(apiId: string, draggedId: string | null, dragOverId: string | null): string {
|
||
if (draggedId === apiId) {
|
||
return 'opacity-50 border-neon-cyan'
|
||
}
|
||
if (dragOverId === apiId) {
|
||
return 'border-neon-green shadow-lg'
|
||
}
|
||
return 'border-neon-cyan/30'
|
||
}
|