story-research-zapwall/components/Nip95ConfigManager.tsx
2026-01-07 12:37:23 +01:00

378 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', id)
}
function handleDragOver(e: React.DragEvent<HTMLDivElement>, id: string): void {
e.preventDefault()
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 ${
draggedId === api.id
? 'opacity-50 border-neon-cyan'
: dragOverId === api.id
? 'border-neon-green shadow-lg'
: 'border-neon-cyan/30'
}`}
>
<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>
)
}