430 lines
15 KiB
TypeScript
430 lines
15 KiB
TypeScript
import { useState, useEffect } from 'react'
|
||
import { configStorage } from '@/lib/configStorage'
|
||
import type { RelayConfig } from '@/lib/configStorageTypes'
|
||
import { t } from '@/lib/i18n'
|
||
import { userConfirm } from '@/lib/userConfirm'
|
||
import { relaySessionManager } from '@/lib/relaySessionManager'
|
||
|
||
interface RelayManagerProps {
|
||
onConfigChange?: () => void
|
||
}
|
||
|
||
export function RelayManager({ onConfigChange }: RelayManagerProps): React.ReactElement {
|
||
const [relays, setRelays] = useState<RelayConfig[]>([])
|
||
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 loadRelays()
|
||
}, [])
|
||
|
||
async function loadRelays(): Promise<void> {
|
||
try {
|
||
setLoading(true)
|
||
setError(null)
|
||
const config = await configStorage.getConfig()
|
||
|
||
// Get failed relays from session manager and disable them in config
|
||
const failedRelays = relaySessionManager.getFailedRelays()
|
||
if (failedRelays.length > 0) {
|
||
let hasChanges = false
|
||
for (const relayUrl of failedRelays) {
|
||
// Find the relay config by URL
|
||
const relayConfig = config.relays.find((r) => r.url === relayUrl)
|
||
if (relayConfig?.enabled) {
|
||
// Disable the failed relay
|
||
await configStorage.updateRelay(relayConfig.id, { enabled: false })
|
||
hasChanges = true
|
||
}
|
||
}
|
||
// Reload config if we made changes
|
||
if (hasChanges) {
|
||
const updatedConfig = await configStorage.getConfig()
|
||
setRelays(updatedConfig.relays.sort((a, b) => a.priority - b.priority))
|
||
} else {
|
||
setRelays(config.relays.sort((a, b) => a.priority - b.priority))
|
||
}
|
||
} else {
|
||
setRelays(config.relays.sort((a, b) => a.priority - b.priority))
|
||
}
|
||
} catch (e) {
|
||
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.loadFailed')
|
||
setError(errorMessage)
|
||
console.error('Error loading relays:', e)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
async function handleToggleEnabled(id: string, enabled: boolean): Promise<void> {
|
||
try {
|
||
await configStorage.updateRelay(id, { enabled })
|
||
await loadRelays()
|
||
onConfigChange?.()
|
||
} catch (e) {
|
||
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.updateFailed')
|
||
setError(errorMessage)
|
||
console.error('Error updating relay:', e)
|
||
}
|
||
}
|
||
|
||
async function handleUpdatePriorities(newOrder: RelayConfig[]): Promise<void> {
|
||
try {
|
||
const updatePromises = newOrder.map((relay, index) => {
|
||
const newPriority = index + 1
|
||
if (relay.priority !== newPriority) {
|
||
return configStorage.updateRelay(relay.id, { priority: newPriority })
|
||
}
|
||
return Promise.resolve()
|
||
})
|
||
|
||
await Promise.all(updatePromises)
|
||
await loadRelays()
|
||
onConfigChange?.()
|
||
} catch (e) {
|
||
const errorMessage = e instanceof Error ? e.message : t('settings.relay.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 = relays.findIndex((relay) => relay.id === draggedId)
|
||
const targetIndex = relays.findIndex((relay) => relay.id === targetId)
|
||
|
||
if (draggedIndex === -1 || targetIndex === -1) {
|
||
setDraggedId(null)
|
||
return
|
||
}
|
||
|
||
const newRelays = [...relays]
|
||
const removed = newRelays[draggedIndex]
|
||
if (!removed) {
|
||
setDraggedId(null)
|
||
return
|
||
}
|
||
newRelays.splice(draggedIndex, 1)
|
||
newRelays.splice(targetIndex, 0, removed)
|
||
|
||
setRelays(newRelays)
|
||
setDraggedId(null)
|
||
|
||
void handleUpdatePriorities(newRelays)
|
||
}
|
||
|
||
function handleDragEnd(): void {
|
||
setDraggedId(null)
|
||
}
|
||
|
||
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.updateRelay(id, { url })
|
||
await loadRelays()
|
||
setEditingId(null)
|
||
onConfigChange?.()
|
||
} catch (e) {
|
||
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.urlFailed')
|
||
setError(errorMessage)
|
||
console.error('Error updating URL:', e)
|
||
}
|
||
}
|
||
|
||
async function handleAddRelay(): Promise<void> {
|
||
if (!newUrl.trim()) {
|
||
setError(t('settings.relay.error.urlRequired'))
|
||
return
|
||
}
|
||
|
||
try {
|
||
// Normalize URL (add wss:// if missing)
|
||
let normalizedUrl = newUrl.trim()
|
||
if (!normalizedUrl.startsWith('ws://') && !normalizedUrl.startsWith('wss://')) {
|
||
normalizedUrl = `wss://${normalizedUrl}`
|
||
}
|
||
|
||
// Validate URL format - throws if invalid
|
||
void new URL(normalizedUrl)
|
||
await configStorage.addRelay(normalizedUrl, true)
|
||
setNewUrl('')
|
||
setShowAddForm(false)
|
||
await loadRelays()
|
||
onConfigChange?.()
|
||
} catch (e) {
|
||
if (e instanceof TypeError && e.message.includes('Invalid URL')) {
|
||
setError(t('settings.relay.error.invalidUrl'))
|
||
} else {
|
||
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.addFailed')
|
||
setError(errorMessage)
|
||
}
|
||
console.error('Error adding relay:', e)
|
||
}
|
||
}
|
||
|
||
async function handleRemoveRelay(id: string): Promise<void> {
|
||
const confirmed = await userConfirm(t('settings.relay.remove.confirm'))
|
||
if (!confirmed) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
await configStorage.removeRelay(id)
|
||
await loadRelays()
|
||
onConfigChange?.()
|
||
} catch (e) {
|
||
const errorMessage = e instanceof Error ? e.message : t('settings.relay.error.removeFailed')
|
||
setError(errorMessage)
|
||
console.error('Error removing relay:', e)
|
||
}
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="text-center py-8 text-neon-cyan">
|
||
<div>{t('settings.relay.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.relay.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.relay.add.cancel') : t('settings.relay.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.relay.add.url')}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={newUrl}
|
||
onChange={(e) => {
|
||
setNewUrl(e.target.value)
|
||
}}
|
||
placeholder={t('settings.relay.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 handleAddRelay()
|
||
}}
|
||
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.relay.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.relay.add.cancel')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-4">
|
||
{relays.length === 0 ? (
|
||
<div className="text-center py-8 text-cyber-accent">
|
||
{t('settings.relay.empty')}
|
||
</div>
|
||
) : (
|
||
relays.map((relay) => (
|
||
<div
|
||
key={relay.id}
|
||
onDragOver={(e) => {
|
||
handleDragOver(e, relay.id)
|
||
}}
|
||
onDragLeave={handleDragLeave}
|
||
onDrop={(e) => {
|
||
handleDrop(e, relay.id)
|
||
}}
|
||
className={`bg-cyber-dark border rounded p-4 space-y-3 transition-all ${
|
||
(() => {
|
||
if (draggedId === relay.id) {
|
||
return 'opacity-50 border-neon-cyan'
|
||
}
|
||
if (dragOverId === relay.id) {
|
||
return 'border-neon-green shadow-lg'
|
||
}
|
||
return '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, relay.id)
|
||
}}
|
||
onDragEnd={handleDragEnd}
|
||
onMouseDown={(e) => {
|
||
e.stopPropagation()
|
||
}}
|
||
>
|
||
<DragHandle />
|
||
</div>
|
||
<div className="flex-1">
|
||
{editingId === relay.id ? (
|
||
<div className="space-y-2">
|
||
<input
|
||
type="text"
|
||
defaultValue={relay.url}
|
||
onBlur={(e) => {
|
||
if (e.target.value !== relay.url) {
|
||
void handleUpdateUrl(relay.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(relay.id)
|
||
}}
|
||
title={t('settings.relay.list.editUrl')}
|
||
>
|
||
{relay.url}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{relay.lastSyncDate && (
|
||
<div className="text-xs text-cyber-accent/70 mt-1">
|
||
{t('settings.relay.list.lastSync')}: {new Date(relay.lastSyncDate).toLocaleString()}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={relay.enabled}
|
||
onChange={(e) => {
|
||
void handleToggleEnabled(relay.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">
|
||
{relay.enabled ? t('settings.relay.list.enabled') : t('settings.relay.list.disabled')}
|
||
</span>
|
||
</label>
|
||
<button
|
||
onClick={() => {
|
||
void handleRemoveRelay(relay.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.relay.list.remove')}
|
||
>
|
||
{t('settings.relay.list.remove')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
<div className="text-sm text-cyber-accent space-y-2">
|
||
<p>
|
||
<strong>{t('settings.relay.note.title')}</strong> {t('settings.relay.note.priority')}
|
||
</p>
|
||
<p>
|
||
{t('settings.relay.note.rotation')}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|