Initial YakPanel commit
This commit is contained in:
697
YakPanel-server/frontend/src/pages/SitePage.tsx
Normal file
697
YakPanel-server/frontend/src/pages/SitePage.tsx
Normal file
@@ -0,0 +1,697 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
apiRequest,
|
||||
createSite,
|
||||
getSite,
|
||||
updateSite,
|
||||
setSiteStatus,
|
||||
deleteSite,
|
||||
createSiteBackup,
|
||||
listSiteBackups,
|
||||
restoreSiteBackup,
|
||||
downloadSiteBackup,
|
||||
listSiteRedirects,
|
||||
addSiteRedirect,
|
||||
deleteSiteRedirect,
|
||||
siteGitClone,
|
||||
siteGitPull,
|
||||
} from '../api/client'
|
||||
import { Plus, Trash2, Download, Archive, RotateCcw, Pencil, Play, Square, Redirect, GitBranch } from 'lucide-react'
|
||||
|
||||
interface Site {
|
||||
id: number
|
||||
name: string
|
||||
path: string
|
||||
status: number
|
||||
ps: string
|
||||
project_type: string
|
||||
domain_count: number
|
||||
addtime: string | null
|
||||
}
|
||||
|
||||
export function SitePage() {
|
||||
const [sites, setSites] = useState<Site[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [creatingError, setCreatingError] = useState('')
|
||||
const [backupSiteId, setBackupSiteId] = useState<number | null>(null)
|
||||
const [backups, setBackups] = useState<{ filename: string; size: number }[]>([])
|
||||
const [backupLoading, setBackupLoading] = useState(false)
|
||||
const [editSiteId, setEditSiteId] = useState<number | null>(null)
|
||||
const [editForm, setEditForm] = useState<{ domains: string; path: string; ps: string; php_version: string; force_https: boolean } | null>(null)
|
||||
const [editLoading, setEditLoading] = useState(false)
|
||||
const [editError, setEditError] = useState('')
|
||||
const [statusLoading, setStatusLoading] = useState<number | null>(null)
|
||||
const [redirectSiteId, setRedirectSiteId] = useState<number | null>(null)
|
||||
const [redirects, setRedirects] = useState<{ id: number; source: string; target: string; code: number }[]>([])
|
||||
const [redirectSource, setRedirectSource] = useState('')
|
||||
const [redirectTarget, setRedirectTarget] = useState('')
|
||||
const [redirectCode, setRedirectCode] = useState(301)
|
||||
const [redirectAdding, setRedirectAdding] = useState(false)
|
||||
const [gitSiteId, setGitSiteId] = useState<number | null>(null)
|
||||
const [gitUrl, setGitUrl] = useState('')
|
||||
const [gitBranch, setGitBranch] = useState('main')
|
||||
const [gitAction, setGitAction] = useState<'clone' | 'pull' | null>(null)
|
||||
const [gitLoading, setGitLoading] = useState(false)
|
||||
|
||||
const loadSites = () => {
|
||||
setLoading(true)
|
||||
apiRequest<Site[]>('/site/list')
|
||||
.then(setSites)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadSites()
|
||||
}, [])
|
||||
|
||||
const handleCreate = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget
|
||||
const name = (form.elements.namedItem('name') as HTMLInputElement).value.trim()
|
||||
const domainsStr = (form.elements.namedItem('domains') as HTMLInputElement).value.trim()
|
||||
const path = (form.elements.namedItem('path') as HTMLInputElement).value.trim()
|
||||
const ps = (form.elements.namedItem('ps') as HTMLInputElement).value.trim()
|
||||
|
||||
if (!name || !domainsStr) {
|
||||
setCreatingError('Name and domain(s) are required')
|
||||
return
|
||||
}
|
||||
const domains = domainsStr.split(/[\s,]+/).filter(Boolean)
|
||||
const php_version = (form.elements.namedItem('php_version') as HTMLSelectElement)?.value || '74'
|
||||
const force_https = (form.elements.namedItem('force_https') as HTMLInputElement)?.checked || false
|
||||
setCreating(true)
|
||||
setCreatingError('')
|
||||
createSite({ name, domains, path: path || undefined, ps: ps || undefined, php_version, force_https })
|
||||
.then(() => {
|
||||
setShowCreate(false)
|
||||
form.reset()
|
||||
loadSites()
|
||||
})
|
||||
.catch((err) => setCreatingError(err.message))
|
||||
.finally(() => setCreating(false))
|
||||
}
|
||||
|
||||
const handleDelete = (id: number, name: string) => {
|
||||
if (!confirm(`Delete site "${name}"? This cannot be undone.`)) return
|
||||
deleteSite(id)
|
||||
.then(loadSites)
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
const openBackupModal = (siteId: number) => {
|
||||
setBackupSiteId(siteId)
|
||||
setBackups([])
|
||||
listSiteBackups(siteId)
|
||||
.then((data) => setBackups(data.backups))
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
const handleCreateBackup = () => {
|
||||
if (!backupSiteId) return
|
||||
setBackupLoading(true)
|
||||
createSiteBackup(backupSiteId)
|
||||
.then(() => listSiteBackups(backupSiteId).then((d) => setBackups(d.backups)))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setBackupLoading(false))
|
||||
}
|
||||
|
||||
const handleRestore = (filename: string) => {
|
||||
if (!backupSiteId || !confirm(`Restore from ${filename}? This will overwrite existing files.`)) return
|
||||
setBackupLoading(true)
|
||||
restoreSiteBackup(backupSiteId, filename)
|
||||
.then(() => setBackupSiteId(null))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setBackupLoading(false))
|
||||
}
|
||||
|
||||
const handleDownloadBackup = (filename: string) => {
|
||||
if (!backupSiteId) return
|
||||
downloadSiteBackup(backupSiteId, filename).catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
const openEditModal = (siteId: number) => {
|
||||
setEditSiteId(siteId)
|
||||
setEditError('')
|
||||
getSite(siteId)
|
||||
.then((s) => setEditForm({
|
||||
domains: (s.domains || []).join(', '),
|
||||
path: s.path || '',
|
||||
ps: s.ps || '',
|
||||
php_version: s.php_version || '74',
|
||||
force_https: !!(s.force_https && s.force_https !== 0),
|
||||
}))
|
||||
.catch((err) => setEditError(err.message))
|
||||
}
|
||||
|
||||
const handleEdit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
if (!editSiteId || !editForm) return
|
||||
const domains = editForm.domains.split(/[\s,]+/).filter(Boolean)
|
||||
if (domains.length === 0) {
|
||||
setEditError('At least one domain is required')
|
||||
return
|
||||
}
|
||||
setEditLoading(true)
|
||||
setEditError('')
|
||||
updateSite(editSiteId, {
|
||||
domains,
|
||||
path: editForm.path || undefined,
|
||||
ps: editForm.ps || undefined,
|
||||
php_version: editForm.php_version,
|
||||
force_https: editForm.force_https,
|
||||
})
|
||||
.then(() => {
|
||||
setEditSiteId(null)
|
||||
setEditForm(null)
|
||||
loadSites()
|
||||
})
|
||||
.catch((err) => setEditError(err.message))
|
||||
.finally(() => setEditLoading(false))
|
||||
}
|
||||
|
||||
const handleSetStatus = (siteId: number, enable: boolean) => {
|
||||
setStatusLoading(siteId)
|
||||
setSiteStatus(siteId, enable)
|
||||
.then(loadSites)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setStatusLoading(null))
|
||||
}
|
||||
|
||||
const openRedirectModal = (siteId: number) => {
|
||||
setRedirectSiteId(siteId)
|
||||
setRedirectSource('')
|
||||
setRedirectTarget('')
|
||||
setRedirectCode(301)
|
||||
listSiteRedirects(siteId)
|
||||
.then(setRedirects)
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
const handleAddRedirect = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!redirectSiteId || !redirectSource.trim() || !redirectTarget.trim()) return
|
||||
setRedirectAdding(true)
|
||||
addSiteRedirect(redirectSiteId, redirectSource.trim(), redirectTarget.trim(), redirectCode)
|
||||
.then(() => listSiteRedirects(redirectSiteId).then(setRedirects))
|
||||
.then(() => { setRedirectSource(''); setRedirectTarget('') })
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setRedirectAdding(false))
|
||||
}
|
||||
|
||||
const handleDeleteRedirect = (redirectId: number) => {
|
||||
if (!redirectSiteId) return
|
||||
deleteSiteRedirect(redirectSiteId, redirectId)
|
||||
.then(() => listSiteRedirects(redirectSiteId).then(setRedirects))
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-gray-500">Loading...</div>
|
||||
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Website</h1>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Site
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Create Site</h2>
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
{creatingError && (
|
||||
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">
|
||||
{creatingError}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Site Name
|
||||
</label>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="example.com"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Domain(s) <span className="text-gray-500">(comma or space separated)</span>
|
||||
</label>
|
||||
<input
|
||||
name="domains"
|
||||
type="text"
|
||||
placeholder="example.com www.example.com"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Path <span className="text-gray-500">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
name="path"
|
||||
type="text"
|
||||
placeholder="/www/wwwroot/example.com"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
PHP Version
|
||||
</label>
|
||||
<select
|
||||
name="php_version"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="74">7.4</option>
|
||||
<option value="80">8.0</option>
|
||||
<option value="81">8.1</option>
|
||||
<option value="82">8.2</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input name="force_https" type="checkbox" id="create_force_https" className="rounded" />
|
||||
<label htmlFor="create_force_https" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Force HTTPS (redirect HTTP to HTTPS)
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Note <span className="text-gray-500">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
name="ps"
|
||||
type="text"
|
||||
placeholder="My website"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreate(false)}
|
||||
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg font-medium"
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Name</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Path</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Domains</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Type</th>
|
||||
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{sites.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
|
||||
No sites yet. Click "Add Site" to create one.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sites.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td className="px-4 py-2 text-gray-900 dark:text-white">{s.name}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{s.path}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{s.domain_count}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{s.project_type}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<span className="flex gap-1 justify-end">
|
||||
{s.status === 1 ? (
|
||||
<button
|
||||
onClick={() => handleSetStatus(s.id, false)}
|
||||
disabled={statusLoading === s.id}
|
||||
className="p-2 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded disabled:opacity-50"
|
||||
title="Stop"
|
||||
>
|
||||
<Square className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleSetStatus(s.id, true)}
|
||||
disabled={statusLoading === s.id}
|
||||
className="p-2 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 rounded disabled:opacity-50"
|
||||
title="Start"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setGitSiteId(s.id); setGitAction('clone'); setGitUrl(''); setGitBranch('main') }}
|
||||
className="p-2 text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 rounded"
|
||||
title="Git Deploy"
|
||||
>
|
||||
<GitBranch className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openRedirectModal(s.id)}
|
||||
className="p-2 text-purple-600 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded"
|
||||
title="Redirects"
|
||||
>
|
||||
<Redirect className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditModal(s.id)}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openBackupModal(s.id)}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
|
||||
title="Backup"
|
||||
>
|
||||
<Archive className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(s.id, s.name)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{gitSiteId && gitAction && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Git Deploy</h2>
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGitAction('clone')}
|
||||
className={`px-3 py-1.5 rounded text-sm ${gitAction === 'clone' ? 'bg-emerald-600 text-white' : 'bg-gray-200 dark:bg-gray-700'}`}
|
||||
>
|
||||
Clone
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGitAction('pull')}
|
||||
className={`px-3 py-1.5 rounded text-sm ${gitAction === 'pull' ? 'bg-emerald-600 text-white' : 'bg-gray-200 dark:bg-gray-700'}`}
|
||||
>
|
||||
Pull
|
||||
</button>
|
||||
</div>
|
||||
{gitAction === 'clone' ? (
|
||||
<form onSubmit={handleGitClone} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Repository URL</label>
|
||||
<input
|
||||
value={gitUrl}
|
||||
onChange={(e) => setGitUrl(e.target.value)}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Branch</label>
|
||||
<input
|
||||
value={gitBranch}
|
||||
onChange={(e) => setGitBranch(e.target.value)}
|
||||
placeholder="main"
|
||||
className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Site path must be empty. This will clone the repo into the site directory.</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={() => { setGitSiteId(null); setGitAction(null) }} className="px-4 py-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700">Cancel</button>
|
||||
<button type="submit" disabled={gitLoading} className="px-4 py-2 bg-emerald-600 text-white rounded-lg disabled:opacity-50">
|
||||
{gitLoading ? 'Cloning...' : 'Clone'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">Pull latest changes from the remote repository.</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={() => { setGitSiteId(null); setGitAction(null) }} className="px-4 py-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700">Cancel</button>
|
||||
<button onClick={handleGitPull} disabled={gitLoading} className="px-4 py-2 bg-emerald-600 text-white rounded-lg disabled:opacity-50">
|
||||
{gitLoading ? 'Pulling...' : 'Pull'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{redirectSiteId && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-lg">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Redirects</h2>
|
||||
<form onSubmit={handleAddRedirect} className="space-y-2 mb-4">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input
|
||||
value={redirectSource}
|
||||
onChange={(e) => setRedirectSource(e.target.value)}
|
||||
placeholder="/old-path"
|
||||
className="flex-1 min-w-[100px] px-3 py-2 border rounded-lg bg-white dark:bg-gray-700"
|
||||
/>
|
||||
<span className="self-center text-gray-500">→</span>
|
||||
<input
|
||||
value={redirectTarget}
|
||||
onChange={(e) => setRedirectTarget(e.target.value)}
|
||||
placeholder="/new-path or https://..."
|
||||
className="flex-1 min-w-[100px] px-3 py-2 border rounded-lg bg-white dark:bg-gray-700"
|
||||
/>
|
||||
<select
|
||||
value={redirectCode}
|
||||
onChange={(e) => setRedirectCode(Number(e.target.value))}
|
||||
className="px-3 py-2 border rounded-lg bg-white dark:bg-gray-700"
|
||||
>
|
||||
<option value={301}>301</option>
|
||||
<option value={302}>302</option>
|
||||
</select>
|
||||
<button type="submit" disabled={redirectAdding} className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{redirects.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">No redirects</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{redirects.map((r) => (
|
||||
<li key={r.id} className="flex items-center justify-between gap-2 text-sm py-1 border-b dark:border-gray-700">
|
||||
<span className="font-mono truncate">{r.source}</span>
|
||||
<span className="text-gray-500">→</span>
|
||||
<span className="font-mono truncate flex-1">{r.target}</span>
|
||||
<span className="text-gray-400">{r.code}</span>
|
||||
<button onClick={() => handleDeleteRedirect(r.id)} className="p-1 text-red-600 hover:bg-red-50 rounded">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button onClick={() => setRedirectSiteId(null)} className="px-4 py-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editSiteId && editForm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Edit Site</h2>
|
||||
<form onSubmit={handleEdit} className="space-y-4">
|
||||
{editError && (
|
||||
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">
|
||||
{editError}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Domain(s) <span className="text-gray-500">(comma or space separated)</span>
|
||||
</label>
|
||||
<input
|
||||
value={editForm.domains}
|
||||
onChange={(e) => setEditForm({ ...editForm, domains: e.target.value })}
|
||||
type="text"
|
||||
placeholder="example.com www.example.com"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Path <span className="text-gray-500">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
value={editForm.path}
|
||||
onChange={(e) => setEditForm({ ...editForm, path: e.target.value })}
|
||||
type="text"
|
||||
placeholder="/www/wwwroot/example.com"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">PHP Version</label>
|
||||
<select
|
||||
value={editForm.php_version}
|
||||
onChange={(e) => setEditForm({ ...editForm, php_version: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="74">7.4</option>
|
||||
<option value="80">8.0</option>
|
||||
<option value="81">8.1</option>
|
||||
<option value="82">8.2</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="edit_force_https"
|
||||
checked={editForm.force_https}
|
||||
onChange={(e) => setEditForm({ ...editForm, force_https: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<label htmlFor="edit_force_https" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Force HTTPS
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Note <span className="text-gray-500">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
value={editForm.ps}
|
||||
onChange={(e) => setEditForm({ ...editForm, ps: e.target.value })}
|
||||
type="text"
|
||||
placeholder="My website"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditSiteId(null); setEditForm(null) }}
|
||||
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={editLoading}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg font-medium"
|
||||
>
|
||||
{editLoading ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backupSiteId && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-lg">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Site Backup</h2>
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={handleCreateBackup}
|
||||
disabled={backupLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50"
|
||||
>
|
||||
<Archive className="w-4 h-4" />
|
||||
{backupLoading ? 'Creating...' : 'Create Backup'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Existing backups</h3>
|
||||
{backups.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">No backups yet</p>
|
||||
) : (
|
||||
<ul className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{backups.map((b) => (
|
||||
<li key={b.filename} className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="truncate font-mono text-gray-700 dark:text-gray-300 flex-1">{b.filename}</span>
|
||||
<span className="text-gray-500 text-xs flex-shrink-0">{formatSize(b.size)}</span>
|
||||
<span className="flex gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleDownloadBackup(b.filename)}
|
||||
className="p-1.5 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRestore(b.filename)}
|
||||
disabled={backupLoading}
|
||||
className="p-1.5 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded disabled:opacity-50"
|
||||
title="Restore"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => setBackupSiteId(null)}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user