285 lines
9.9 KiB
TypeScript
285 lines
9.9 KiB
TypeScript
import { useState, useEffect } from 'react'
|
||
import { configStorage } from '@/lib/configStorage'
|
||
import type { Nip95Config } from '@/lib/configStorageTypes'
|
||
|
||
interface Nip95ConfigManagerProps {
|
||
onConfigChange?: () => void
|
||
}
|
||
|
||
export function Nip95ConfigManager({ onConfigChange }: Nip95ConfigManagerProps) {
|
||
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)
|
||
|
||
useEffect(() => {
|
||
void loadApis()
|
||
}, [])
|
||
|
||
async function loadApis() {
|
||
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 : 'Failed to load NIP-95 APIs'
|
||
setError(errorMessage)
|
||
console.error('Error loading NIP-95 APIs:', e)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
async function handleToggleEnabled(id: string, enabled: boolean) {
|
||
try {
|
||
await configStorage.updateNip95Api(id, { enabled })
|
||
await loadApis()
|
||
onConfigChange?.()
|
||
} catch (e) {
|
||
const errorMessage = e instanceof Error ? e.message : 'Failed to update API'
|
||
setError(errorMessage)
|
||
console.error('Error updating NIP-95 API:', e)
|
||
}
|
||
}
|
||
|
||
async function handleUpdatePriority(id: string, priority: number) {
|
||
try {
|
||
await configStorage.updateNip95Api(id, { priority })
|
||
await loadApis()
|
||
onConfigChange?.()
|
||
} catch (e) {
|
||
const errorMessage = e instanceof Error ? e.message : 'Failed to update priority'
|
||
setError(errorMessage)
|
||
console.error('Error updating priority:', e)
|
||
}
|
||
}
|
||
|
||
async function handleUpdateUrl(id: string, url: string) {
|
||
try {
|
||
await configStorage.updateNip95Api(id, { url })
|
||
await loadApis()
|
||
setEditingId(null)
|
||
onConfigChange?.()
|
||
} catch (e) {
|
||
const errorMessage = e instanceof Error ? e.message : 'Failed to update URL'
|
||
setError(errorMessage)
|
||
console.error('Error updating URL:', e)
|
||
}
|
||
}
|
||
|
||
async function handleAddApi() {
|
||
if (!newUrl.trim()) {
|
||
setError('URL is required')
|
||
return
|
||
}
|
||
|
||
try {
|
||
// Validate URL format
|
||
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('Invalid URL format')
|
||
} else {
|
||
const errorMessage = e instanceof Error ? e.message : 'Failed to add API'
|
||
setError(errorMessage)
|
||
}
|
||
console.error('Error adding NIP-95 API:', e)
|
||
}
|
||
}
|
||
|
||
async function handleRemoveApi(id: string) {
|
||
if (!confirm('Are you sure you want to remove this endpoint?')) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
await configStorage.removeNip95Api(id)
|
||
await loadApis()
|
||
onConfigChange?.()
|
||
} catch (e) {
|
||
const errorMessage = e instanceof Error ? e.message : 'Failed to remove API'
|
||
setError(errorMessage)
|
||
console.error('Error removing NIP-95 API:', e)
|
||
}
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="text-center py-8 text-neon-cyan">
|
||
<div>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">NIP-95 Upload Endpoints</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 ? 'Cancel' : '+ Add Endpoint'}
|
||
</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">
|
||
Endpoint URL
|
||
</label>
|
||
<input
|
||
type="url"
|
||
value={newUrl}
|
||
onChange={(e) => setNewUrl(e.target.value)}
|
||
placeholder="https://example.com/upload"
|
||
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"
|
||
>
|
||
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"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-4">
|
||
{apis.length === 0 ? (
|
||
<div className="text-center py-8 text-cyber-accent">
|
||
No NIP-95 endpoints configured
|
||
</div>
|
||
) : (
|
||
apis.map((api) => (
|
||
<div
|
||
key={api.id}
|
||
className="bg-cyber-dark border border-neon-cyan/30 rounded p-4 space-y-3"
|
||
>
|
||
<div className="flex items-start justify-between gap-4">
|
||
<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="Click to edit URL"
|
||
>
|
||
{api.url}
|
||
</div>
|
||
)}
|
||
<div className="text-sm text-cyber-accent mt-1">
|
||
Priority: {api.priority} | ID: {api.id}
|
||
</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 ? 'Enabled' : '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="Remove endpoint"
|
||
>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-4">
|
||
<label className="flex items-center gap-2">
|
||
<span className="text-sm text-cyber-accent">Priority:</span>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={api.priority}
|
||
onChange={(e) => {
|
||
const priority = parseInt(e.target.value, 10)
|
||
if (!isNaN(priority) && priority > 0) {
|
||
void handleUpdatePriority(api.id, priority)
|
||
}
|
||
}}
|
||
className="w-20 px-2 py-1 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
|
||
/>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
<div className="text-sm text-cyber-accent space-y-2">
|
||
<p>
|
||
<strong>Note:</strong> Endpoints are tried in priority order (lower number = higher priority).
|
||
Only enabled endpoints will be used for uploads.
|
||
</p>
|
||
<p>
|
||
If an endpoint fails, the next enabled endpoint will be tried automatically.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|