dynamic plugin system, toast notifications, board delete, gitea-sync plugin rewrite, granular locking fixes
This commit is contained in:
@@ -11,6 +11,7 @@ import { IconExternalLink, IconRss, IconX, IconFilter, IconChevronDown, IconPlus
|
||||
import BoardIcon from '../components/BoardIcon'
|
||||
import Dropdown from '../components/Dropdown'
|
||||
import { solveAltcha } from '../lib/altcha'
|
||||
import { useToast } from '../hooks/useToast'
|
||||
|
||||
interface Post {
|
||||
id: string
|
||||
@@ -100,6 +101,7 @@ export default function BoardFeed() {
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
const [filtersOpen, setFiltersOpen] = useState(false)
|
||||
const [categories, setCategories] = useState<{ id: string; name: string; slug: string }[]>([])
|
||||
const toast = useToast()
|
||||
const [subscribed, setSubscribed] = useState(false)
|
||||
const [subLoading, setSubLoading] = useState(false)
|
||||
|
||||
@@ -157,6 +159,7 @@ export default function BoardFeed() {
|
||||
if (subscribed) {
|
||||
await api.delete(`/boards/${boardSlug}/subscribe`)
|
||||
setSubscribed(false)
|
||||
toast.info('Unsubscribed from notifications')
|
||||
} else {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
alert('Push notifications are not supported in this browser')
|
||||
@@ -176,8 +179,11 @@ export default function BoardFeed() {
|
||||
keys: { p256dh: json.keys!.p256dh, auth: json.keys!.auth },
|
||||
})
|
||||
setSubscribed(true)
|
||||
toast.success('Subscribed to board notifications')
|
||||
}
|
||||
} catch {} finally {
|
||||
} catch {
|
||||
toast.error('Failed to update subscription')
|
||||
} finally {
|
||||
setSubLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useAuth } from '../hooks/useAuth'
|
||||
import { useDocumentTitle } from '../hooks/useDocumentTitle'
|
||||
import { useFocusTrap } from '../hooks/useFocusTrap'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
import { useToast } from '../hooks/useToast'
|
||||
import { api } from '../lib/api'
|
||||
import { solveAltcha } from '../lib/altcha'
|
||||
import { IconCheck, IconLock, IconKey, IconDownload, IconCamera, IconTrash, IconShieldCheck, IconArrowBack } from '@tabler/icons-react'
|
||||
@@ -20,6 +21,7 @@ interface PasskeyInfo {
|
||||
export default function IdentitySettings() {
|
||||
useDocumentTitle('Settings')
|
||||
const confirm = useConfirm()
|
||||
const toast = useToast()
|
||||
const auth = useAuth()
|
||||
const [name, setName] = useState(auth.displayName)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -90,8 +92,11 @@ export default function IdentitySettings() {
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setAvatarUrl(data.avatarUrl + '?t=' + Date.now())
|
||||
toast.success('Avatar updated')
|
||||
}
|
||||
} catch {} finally {
|
||||
} catch {
|
||||
toast.error('Failed to upload avatar')
|
||||
} finally {
|
||||
setUploadingAvatar(false)
|
||||
if (avatarInputRef.current) avatarInputRef.current.value = ''
|
||||
}
|
||||
@@ -101,7 +106,10 @@ export default function IdentitySettings() {
|
||||
try {
|
||||
await api.delete('/me/avatar')
|
||||
setAvatarUrl(null)
|
||||
} catch {}
|
||||
toast.success('Avatar removed')
|
||||
} catch {
|
||||
toast.error('Failed to remove avatar')
|
||||
}
|
||||
}
|
||||
|
||||
const saveName = async () => {
|
||||
@@ -112,7 +120,10 @@ export default function IdentitySettings() {
|
||||
await auth.updateProfile({ displayName: name, altcha })
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
} catch {} finally {
|
||||
toast.success('Display name saved')
|
||||
} catch {
|
||||
toast.error('Failed to save name')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
@@ -130,6 +141,7 @@ export default function IdentitySettings() {
|
||||
await api.post('/auth/recover', { phrase: clean, altcha })
|
||||
setRedeemSuccess(true)
|
||||
auth.refresh()
|
||||
toast.success('Identity recovered')
|
||||
setTimeout(() => {
|
||||
setShowRedeemInput(false)
|
||||
setRedeemPhrase('')
|
||||
@@ -160,8 +172,11 @@ export default function IdentitySettings() {
|
||||
try {
|
||||
const altcha = await solveAltcha()
|
||||
await auth.deleteIdentity(altcha)
|
||||
toast.info('Identity deleted')
|
||||
window.location.href = '/'
|
||||
} catch {} finally {
|
||||
} catch {
|
||||
toast.error('Failed to delete identity')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import MarkdownEditor from '../components/MarkdownEditor'
|
||||
import FileUpload from '../components/FileUpload'
|
||||
import { solveAltcha } from '../lib/altcha'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
import { useToast } from '../hooks/useToast'
|
||||
import { IMPORTANCE_OPTIONS } from '../components/PostCard'
|
||||
|
||||
interface ImportanceCounts {
|
||||
@@ -181,6 +182,7 @@ export default function PostDetail() {
|
||||
const auth = useAuth()
|
||||
const admin = useAdmin()
|
||||
const confirm = useConfirm()
|
||||
const toast = useToast()
|
||||
const [post, setPost] = useState<Post | null>(null)
|
||||
useDocumentTitle(post?.title)
|
||||
const [boardName, setBoardName] = useState('')
|
||||
@@ -236,12 +238,15 @@ export default function PostDetail() {
|
||||
try {
|
||||
if (wasVoted) {
|
||||
await api.delete(`/boards/${boardSlug}/posts/${postId}/vote`)
|
||||
toast.success('Vote removed')
|
||||
} else {
|
||||
const altcha = await solveAltcha('light')
|
||||
await api.post(`/boards/${boardSlug}/posts/${postId}/vote`, { altcha })
|
||||
toast.success('Vote added')
|
||||
}
|
||||
} catch {
|
||||
setPost({ ...post, voted: wasVoted, voteCount: post.voteCount })
|
||||
toast.error('Failed to vote')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,7 +283,10 @@ export default function PostDetail() {
|
||||
setReplyTo(null)
|
||||
setCommentAttachments([])
|
||||
fetchPost()
|
||||
} catch {} finally {
|
||||
toast.success('Comment posted')
|
||||
} catch {
|
||||
toast.error('Failed to post comment')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
@@ -313,7 +321,10 @@ export default function PostDetail() {
|
||||
try {
|
||||
const res = await api.put<{ isEditLocked: boolean }>(`/admin/posts/${postId}/lock-edits`)
|
||||
setPost({ ...post, isEditLocked: res.isEditLocked })
|
||||
} catch {}
|
||||
toast.success(res.isEditLocked ? 'Post edits locked' : 'Post edits unlocked')
|
||||
} catch {
|
||||
toast.error('Failed to toggle edit lock')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleThreadLock = async (lockVoting?: boolean) => {
|
||||
@@ -324,7 +335,10 @@ export default function PostDetail() {
|
||||
{ lockVoting }
|
||||
)
|
||||
setPost({ ...post, isThreadLocked: res.isThreadLocked, isVotingLocked: res.isVotingLocked })
|
||||
} catch {}
|
||||
toast.success(res.isThreadLocked ? 'Thread locked' : 'Thread unlocked')
|
||||
} catch {
|
||||
toast.error('Failed to toggle thread lock')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCommentEditLock = async (commentId: string) => {
|
||||
@@ -366,7 +380,10 @@ export default function PostDetail() {
|
||||
}
|
||||
setHistoryModal(null)
|
||||
fetchPost()
|
||||
} catch {}
|
||||
toast.success('Version restored')
|
||||
} catch {
|
||||
toast.error('Failed to restore version')
|
||||
}
|
||||
}
|
||||
|
||||
const startEdit = () => {
|
||||
@@ -386,7 +403,10 @@ export default function PostDetail() {
|
||||
})
|
||||
setEditing(false)
|
||||
fetchPost()
|
||||
} catch {} finally {
|
||||
toast.success('Post updated')
|
||||
} catch {
|
||||
toast.error('Failed to update post')
|
||||
} finally {
|
||||
setEditSaving(false)
|
||||
}
|
||||
}
|
||||
@@ -625,8 +645,11 @@ export default function PostDetail() {
|
||||
if (!await confirm('Delete this post?')) return
|
||||
try {
|
||||
await api.delete(`/boards/${boardSlug}/posts/${postId}`)
|
||||
toast.success('Post deleted')
|
||||
navigate(`/b/${boardSlug}`)
|
||||
} catch {}
|
||||
} catch {
|
||||
toast.error('Failed to delete post')
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2.5 action-btn"
|
||||
style={{
|
||||
|
||||
@@ -3,6 +3,9 @@ import { Link } from 'react-router-dom'
|
||||
import { api } from '../../lib/api'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
import { useFocusTrap } from '../../hooks/useFocusTrap'
|
||||
import { useAdmin } from '../../hooks/useAdmin'
|
||||
import { useConfirm } from '../../hooks/useConfirm'
|
||||
import { useToast } from '../../hooks/useToast'
|
||||
import Dropdown from '../../components/Dropdown'
|
||||
import NumberInput from '../../components/NumberInput'
|
||||
import IconPicker from '../../components/IconPicker'
|
||||
@@ -26,6 +29,9 @@ interface Board {
|
||||
|
||||
export default function AdminBoards() {
|
||||
useDocumentTitle('Manage Boards')
|
||||
const { isSuperAdmin } = useAdmin()
|
||||
const confirm = useConfirm()
|
||||
const toast = useToast()
|
||||
const [boards, setBoards] = useState<Board[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editBoard, setEditBoard] = useState<Board | null>(null)
|
||||
@@ -84,12 +90,16 @@ export default function AdminBoards() {
|
||||
try {
|
||||
if (editBoard) {
|
||||
await api.put(`/admin/boards/${editBoard.id}`, form)
|
||||
toast.success('Board updated')
|
||||
} else {
|
||||
await api.post('/admin/boards', form)
|
||||
toast.success('Board created')
|
||||
}
|
||||
resetForm()
|
||||
fetchBoards()
|
||||
} catch {} finally {
|
||||
} catch {
|
||||
toast.error('Failed to save board')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
@@ -98,7 +108,24 @@ export default function AdminBoards() {
|
||||
try {
|
||||
await api.put(`/admin/boards/${id}`, { isArchived: !isArchived })
|
||||
fetchBoards()
|
||||
} catch {}
|
||||
toast.info(isArchived ? 'Board restored' : 'Board archived')
|
||||
} catch {
|
||||
toast.error('Failed to update board')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (board: Board) => {
|
||||
const ok = await confirm(
|
||||
`Permanently delete "${board.name}" and all its posts, comments, and votes? This cannot be undone.`
|
||||
)
|
||||
if (!ok) return
|
||||
try {
|
||||
await api.delete(`/admin/boards/${board.id}`)
|
||||
fetchBoards()
|
||||
toast.success('Board deleted')
|
||||
} catch {
|
||||
toast.error('Failed to delete board')
|
||||
}
|
||||
}
|
||||
|
||||
const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
@@ -165,6 +192,15 @@ export default function AdminBoards() {
|
||||
>
|
||||
{board.isArchived ? 'Restore' : 'Archive'}
|
||||
</button>
|
||||
{isSuperAdmin && (
|
||||
<button
|
||||
onClick={() => handleDelete(board)}
|
||||
className="action-btn text-xs px-2"
|
||||
style={{ minHeight: 44, color: 'var(--error)' }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -291,7 +327,7 @@ export default function AdminBoards() {
|
||||
onChange={(v) => setForm((f) => ({ ...f, rssFeedCount: v }))}
|
||||
min={1}
|
||||
max={200}
|
||||
style={{ width: 100 }}
|
||||
style={{ minWidth: 120 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from '../../lib/api'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
import { useToast } from '../../hooks/useToast'
|
||||
|
||||
interface Category {
|
||||
id: string
|
||||
@@ -10,6 +11,7 @@ interface Category {
|
||||
|
||||
export default function AdminCategories() {
|
||||
useDocumentTitle('Categories')
|
||||
const toast = useToast()
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [name, setName] = useState('')
|
||||
const [slug, setSlug] = useState('')
|
||||
@@ -33,8 +35,10 @@ export default function AdminCategories() {
|
||||
setName('')
|
||||
setSlug('')
|
||||
fetch()
|
||||
toast.success('Category created')
|
||||
} catch {
|
||||
setError('Failed to create category')
|
||||
toast.error('Failed to create category')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +46,10 @@ export default function AdminCategories() {
|
||||
try {
|
||||
await api.delete(`/admin/categories/${id}`)
|
||||
fetch()
|
||||
} catch {}
|
||||
toast.success('Category deleted')
|
||||
} catch {
|
||||
toast.error('Failed to delete category')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { IconPlus, IconTrash, IconPencil } from '@tabler/icons-react'
|
||||
import { api } from '../../lib/api'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
import { useConfirm } from '../../hooks/useConfirm'
|
||||
import { useToast } from '../../hooks/useToast'
|
||||
import MarkdownEditor from '../../components/MarkdownEditor'
|
||||
import Dropdown from '../../components/Dropdown'
|
||||
|
||||
@@ -25,6 +26,7 @@ interface Entry {
|
||||
export default function AdminChangelog() {
|
||||
useDocumentTitle('Manage Changelog')
|
||||
const confirm = useConfirm()
|
||||
const toast = useToast()
|
||||
const [entries, setEntries] = useState<Entry[]>([])
|
||||
const [boards, setBoards] = useState<Board[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -73,8 +75,10 @@ export default function AdminChangelog() {
|
||||
setEditId(null)
|
||||
setShowForm(false)
|
||||
fetchEntries()
|
||||
toast.success('Changelog entry saved')
|
||||
} catch {
|
||||
setError('Failed to save entry')
|
||||
toast.error('Failed to save entry')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +87,10 @@ export default function AdminChangelog() {
|
||||
try {
|
||||
await api.delete(`/admin/changelog/${id}`)
|
||||
fetchEntries()
|
||||
} catch {}
|
||||
toast.success('Entry deleted')
|
||||
} catch {
|
||||
toast.error('Failed to delete entry')
|
||||
}
|
||||
}
|
||||
|
||||
const startEdit = (entry: Entry) => {
|
||||
|
||||
472
packages/web/src/pages/admin/AdminPlugins.tsx
Normal file
472
packages/web/src/pages/admin/AdminPlugins.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { IconPlug, IconUpload, IconTrash, IconSettings, IconAlertTriangle, IconRefresh } from '@tabler/icons-react'
|
||||
import { api } from '../../lib/api'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
import { useConfirm } from '../../hooks/useConfirm'
|
||||
import { useToast } from '../../hooks/useToast'
|
||||
|
||||
interface ConfigField {
|
||||
key: string
|
||||
type: 'text' | 'password' | 'number' | 'boolean' | 'select'
|
||||
label: string
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
options?: Array<{ value: string; label: string }>
|
||||
}
|
||||
|
||||
interface PluginRecord {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
description: string | null
|
||||
author: string | null
|
||||
enabled: boolean
|
||||
config: Record<string, unknown>
|
||||
configSchema: ConfigField[]
|
||||
installedAt: string
|
||||
}
|
||||
|
||||
export default function AdminPlugins() {
|
||||
useDocumentTitle('Plugins')
|
||||
const confirm = useConfirm()
|
||||
const toast = useToast()
|
||||
|
||||
const [plugins, setPlugins] = useState<PluginRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showUpload, setShowUpload] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const [configEditing, setConfigEditing] = useState<string | null>(null)
|
||||
const [configClosing, setConfigClosing] = useState<string | null>(null)
|
||||
const [configValues, setConfigValues] = useState<Record<string, unknown>>({})
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const fetchPlugins = useCallback(() => {
|
||||
api.get<{ plugins: PluginRecord[] }>('/admin/plugins')
|
||||
.then((r) => setPlugins(r.plugins))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchPlugins() }, [fetchPlugins])
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
if (!file.name.endsWith('.zip')) {
|
||||
setError('Only .zip files are allowed')
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
setError('')
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const r = await fetch('/api/v1/admin/plugins/upload', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({}))
|
||||
throw new Error((body as { error?: string }).error || 'Upload failed')
|
||||
}
|
||||
setShowUpload(false)
|
||||
fetchPlugins()
|
||||
toast.success('Plugin installed')
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Upload failed')
|
||||
toast.error('Failed to install plugin')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const togglePlugin = async (id: string, enabled: boolean) => {
|
||||
try {
|
||||
await api.put(`/admin/plugins/${id}/${enabled ? 'disable' : 'enable'}`)
|
||||
fetchPlugins()
|
||||
toast.success(enabled ? 'Plugin disabled' : 'Plugin enabled')
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to toggle plugin')
|
||||
toast.error('Failed to toggle plugin')
|
||||
}
|
||||
}
|
||||
|
||||
const deletePlugin = async (id: string, name: string) => {
|
||||
if (!await confirm(`Delete plugin "${name}"? This removes all plugin data.`)) return
|
||||
try {
|
||||
await api.delete(`/admin/plugins/${id}`)
|
||||
fetchPlugins()
|
||||
toast.success('Plugin deleted')
|
||||
} catch {
|
||||
setError('Failed to delete plugin')
|
||||
toast.error('Failed to delete plugin')
|
||||
}
|
||||
}
|
||||
|
||||
const [syncing, setSyncing] = useState<string | null>(null)
|
||||
const [syncResult, setSyncResult] = useState('')
|
||||
|
||||
const triggerSync = async (pluginId: string) => {
|
||||
setSyncing(pluginId)
|
||||
setSyncResult('')
|
||||
try {
|
||||
const res = await fetch(`/api/v1/plugins/${pluginId}/sync`, { method: 'POST', credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || 'Sync failed')
|
||||
setSyncResult(`Synced ${data.synced ?? 0} repos (${data.created ?? 0} created, ${data.updated ?? 0} updated)`)
|
||||
toast.success(`Sync complete - ${data.synced ?? 0} repos`)
|
||||
} catch (e: unknown) {
|
||||
setSyncResult(e instanceof Error ? e.message : 'Sync failed')
|
||||
} finally {
|
||||
setSyncing(null)
|
||||
}
|
||||
}
|
||||
|
||||
const closeConfig = () => {
|
||||
if (!configEditing) return
|
||||
setConfigClosing(configEditing)
|
||||
setTimeout(() => { setConfigEditing(null); setConfigClosing(null) }, 200)
|
||||
}
|
||||
|
||||
const saveConfig = async (id: string) => {
|
||||
try {
|
||||
await api.put(`/admin/plugins/${id}/config`, configValues)
|
||||
closeConfig()
|
||||
fetchPlugins()
|
||||
toast.success('Config saved')
|
||||
} catch {
|
||||
setError('Failed to save config')
|
||||
toast.error('Failed to save config')
|
||||
}
|
||||
}
|
||||
|
||||
const openConfig = (plugin: PluginRecord) => {
|
||||
if (configEditing === plugin.id) {
|
||||
closeConfig()
|
||||
return
|
||||
}
|
||||
setConfigClosing(null)
|
||||
setConfigEditing(plugin.id)
|
||||
setConfigValues({ ...plugin.config })
|
||||
}
|
||||
|
||||
const setConfigField = (key: string, value: unknown) => {
|
||||
setConfigValues((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) uploadFile(file)
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragOver(true)
|
||||
}
|
||||
|
||||
const formatDate = (iso: string) => {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 780, margin: '0 auto', padding: '32px 24px' }}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1
|
||||
className="font-bold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
|
||||
>
|
||||
Plugins
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowUpload(!showUpload)}
|
||||
className="btn btn-admin flex items-center gap-1"
|
||||
aria-expanded={showUpload}
|
||||
style={{ fontSize: 'var(--text-sm)' }}
|
||||
>
|
||||
<IconUpload size={14} stroke={2} />
|
||||
Upload Plugin
|
||||
</button>
|
||||
<Link to="/admin" className="btn btn-ghost" style={{ fontSize: 'var(--text-sm)' }}>Back</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warning banner */}
|
||||
<div
|
||||
className="flex items-center gap-3 p-3 rounded-lg mb-5"
|
||||
role="alert"
|
||||
style={{
|
||||
background: 'rgba(245, 158, 11, 0.08)',
|
||||
border: '1px solid rgba(245, 158, 11, 0.25)',
|
||||
color: 'var(--warning)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
}}
|
||||
>
|
||||
<IconAlertTriangle size={18} stroke={2} style={{ flexShrink: 0 }} />
|
||||
Plugins run with full server access. Only install plugins you trust.
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-xs mb-4" style={{ color: 'var(--error)' }}>{error}</p>
|
||||
)}
|
||||
|
||||
{/* Upload section */}
|
||||
{showUpload && (
|
||||
<div className="card p-4 mb-6">
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{
|
||||
border: `2px dashed ${dragOver ? 'var(--admin-accent)' : 'var(--border)'}`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '32px 24px',
|
||||
textAlign: 'center',
|
||||
cursor: uploading ? 'wait' : 'pointer',
|
||||
background: dragOver ? 'var(--admin-subtle)' : 'transparent',
|
||||
transition: 'all var(--duration-fast) ease-out',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) uploadFile(file)
|
||||
}}
|
||||
/>
|
||||
{uploading ? (
|
||||
<>
|
||||
<div className="progress-bar mb-3" />
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>Uploading...</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconUpload size={28} stroke={1.5} style={{ color: 'var(--text-tertiary)', margin: '0 auto 8px' }} />
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text)' }}>
|
||||
Drop a .zip file here or click to browse
|
||||
</p>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Only .zip plugin packages are accepted
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button onClick={() => setShowUpload(false)} className="btn btn-ghost" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plugin list */}
|
||||
{loading ? (
|
||||
<div>
|
||||
<div className="progress-bar mb-4" />
|
||||
{[0, 1].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 mb-1" style={{ opacity: 1 - i * 0.3 }}>
|
||||
<div className="skeleton h-4" style={{ width: '50%' }} />
|
||||
<div className="skeleton h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : plugins.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-12" style={{ textAlign: 'center' }}>
|
||||
<div
|
||||
className="flex items-center justify-center mb-4"
|
||||
style={{
|
||||
width: 56, height: 56,
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
background: 'var(--surface-hover)',
|
||||
}}
|
||||
>
|
||||
<IconPlug size={24} stroke={1.5} style={{ color: 'var(--text-tertiary)' }} />
|
||||
</div>
|
||||
<p className="font-medium mb-1" style={{ color: 'var(--text)', fontSize: 'var(--text-base)' }}>
|
||||
No plugins installed
|
||||
</p>
|
||||
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
|
||||
Upload a plugin package to get started
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{plugins.map((plugin) => (
|
||||
<div key={plugin.id}>
|
||||
<div
|
||||
className="card p-4"
|
||||
style={{ opacity: plugin.enabled ? 1 : 0.6 }}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0 mr-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||
{plugin.name}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded"
|
||||
style={{ background: 'var(--surface-hover)', color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
v{plugin.version}
|
||||
</span>
|
||||
{!plugin.enabled && (
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded"
|
||||
style={{ background: 'rgba(245, 158, 11, 0.1)', color: 'var(--warning)' }}
|
||||
>
|
||||
disabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{plugin.description && (
|
||||
<p className="text-xs mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
{plugin.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{plugin.author && <span>by {plugin.author}</span>}
|
||||
<span>Installed {formatDate(plugin.installedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => openConfig(plugin)}
|
||||
className="action-btn"
|
||||
aria-label={`Configure ${plugin.name}`}
|
||||
aria-expanded={configEditing === plugin.id}
|
||||
style={{ minHeight: 44, color: 'var(--text-tertiary)', background: 'none', border: 'none', cursor: 'pointer', padding: '0 8px' }}
|
||||
>
|
||||
<IconSettings size={16} stroke={2} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => togglePlugin(plugin.id, plugin.enabled)}
|
||||
className="action-btn text-xs px-2 rounded"
|
||||
style={{ minHeight: 44, color: plugin.enabled ? 'var(--warning)' : 'var(--success)', background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
{plugin.enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deletePlugin(plugin.id, plugin.name)}
|
||||
className="action-btn"
|
||||
aria-label={`Delete ${plugin.name}`}
|
||||
style={{ minHeight: 44, color: 'var(--error)', background: 'none', border: 'none', cursor: 'pointer', padding: '0 8px' }}
|
||||
>
|
||||
<IconTrash size={14} stroke={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Config editor */}
|
||||
{configEditing === plugin.id && (
|
||||
<div className={configClosing === plugin.id ? 'expand-out' : 'expand-in'} style={{ borderTop: '1px solid var(--border)', padding: '16px', marginTop: 12 }}>
|
||||
{plugin.configSchema && plugin.configSchema.length > 0 ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{plugin.configSchema.map((field) => (
|
||||
<div key={field.key}>
|
||||
<label
|
||||
htmlFor={`cfg-${plugin.id}-${field.key}`}
|
||||
style={{ display: 'block', color: 'var(--text-secondary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}
|
||||
>
|
||||
{field.label}
|
||||
{field.required && <span style={{ color: 'var(--error)' }}> *</span>}
|
||||
</label>
|
||||
{field.type === 'boolean' ? (
|
||||
<label className="flex items-center gap-2" style={{ cursor: 'pointer', fontSize: 'var(--text-sm)', color: 'var(--text-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!configValues[field.key]}
|
||||
onChange={(e) => setConfigField(field.key, e.target.checked)}
|
||||
/>
|
||||
{field.placeholder || 'Enabled'}
|
||||
</label>
|
||||
) : field.type === 'select' && field.options ? (
|
||||
<select
|
||||
id={`cfg-${plugin.id}-${field.key}`}
|
||||
className="input w-full"
|
||||
value={String(configValues[field.key] ?? '')}
|
||||
onChange={(e) => setConfigField(field.key, e.target.value)}
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{field.options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
id={`cfg-${plugin.id}-${field.key}`}
|
||||
className="input w-full"
|
||||
type={field.type === 'number' ? 'number' : field.type === 'password' ? 'password' : 'text'}
|
||||
value={String(configValues[field.key] ?? '')}
|
||||
onChange={(e) => setConfigField(field.key, field.type === 'number' ? Number(e.target.value) : e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`config-${plugin.id}`}
|
||||
className="text-xs font-medium block mb-2"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
Plugin configuration (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
id={`config-${plugin.id}`}
|
||||
className="input w-full"
|
||||
rows={6}
|
||||
value={JSON.stringify(configValues, null, 2)}
|
||||
onChange={(e) => { try { setConfigValues(JSON.parse(e.target.value)) } catch {} }}
|
||||
style={{ fontFamily: 'var(--font-mono, monospace)', fontSize: 'var(--text-xs)', resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<button onClick={() => saveConfig(plugin.id)} className="btn btn-admin" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
Save
|
||||
</button>
|
||||
{plugin.enabled && (
|
||||
<button
|
||||
onClick={() => triggerSync(plugin.id)}
|
||||
disabled={syncing === plugin.id}
|
||||
className="btn btn-secondary flex items-center gap-1.5"
|
||||
style={{ fontSize: 'var(--text-sm)', opacity: syncing === plugin.id ? 0.6 : 1 }}
|
||||
>
|
||||
<IconRefresh size={14} stroke={2} className={syncing === plugin.id ? 'spin' : ''} />
|
||||
{syncing === plugin.id ? 'Syncing...' : 'Sync now'}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={closeConfig} className="btn btn-ghost" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{syncResult && (
|
||||
<p className="mt-2" style={{ fontSize: 'var(--text-xs)', color: syncResult.includes('fail') || syncResult.includes('error') || syncResult.includes('missing') ? 'var(--error)' : 'var(--success)' }}>
|
||||
{syncResult}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { api } from '../../lib/api'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
import { useFocusTrap } from '../../hooks/useFocusTrap'
|
||||
import { useConfirm } from '../../hooks/useConfirm'
|
||||
import { useToast } from '../../hooks/useToast'
|
||||
import { IconPin, IconPinnedOff, IconNote, IconTrash, IconGitMerge, IconX, IconTag, IconArrowsExchange, IconUserPlus } from '@tabler/icons-react'
|
||||
import MarkdownEditor from '../../components/MarkdownEditor'
|
||||
import StatusBadge from '../../components/StatusBadge'
|
||||
@@ -54,6 +55,7 @@ interface StatusOption {
|
||||
export default function AdminPosts() {
|
||||
useDocumentTitle('Manage Posts')
|
||||
const confirm = useConfirm()
|
||||
const toast = useToast()
|
||||
const [posts, setPosts] = useState<Post[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [sortBy, setSortBy] = useState<SortField>('createdAt')
|
||||
@@ -124,10 +126,14 @@ export default function AdminPosts() {
|
||||
setBulkBusy(true)
|
||||
setBulkStatusOpen(false)
|
||||
try {
|
||||
const count = selected.size
|
||||
await api.post('/admin/posts/bulk-status', { postIds: [...selected], status })
|
||||
setSelected(new Set())
|
||||
fetchPosts()
|
||||
} catch {} finally {
|
||||
toast.success(`${count} post${count > 1 ? 's' : ''} updated`)
|
||||
} catch {
|
||||
toast.error('Failed to update posts')
|
||||
} finally {
|
||||
setBulkBusy(false)
|
||||
}
|
||||
}
|
||||
@@ -136,10 +142,14 @@ export default function AdminPosts() {
|
||||
setBulkBusy(true)
|
||||
setBulkTagOpen(false)
|
||||
try {
|
||||
const count = selected.size
|
||||
await api.post('/admin/posts/bulk-tag', { postIds: [...selected], tagId, action: 'add' })
|
||||
setSelected(new Set())
|
||||
fetchPosts()
|
||||
} catch {} finally {
|
||||
toast.success(`Tag added to ${count} post${count > 1 ? 's' : ''}`)
|
||||
} catch {
|
||||
toast.error('Failed to add tag')
|
||||
} finally {
|
||||
setBulkBusy(false)
|
||||
}
|
||||
}
|
||||
@@ -148,10 +158,14 @@ export default function AdminPosts() {
|
||||
if (!await confirm(`Delete ${selected.size} post${selected.size > 1 ? 's' : ''}? This cannot be undone.`)) return
|
||||
setBulkBusy(true)
|
||||
try {
|
||||
const count = selected.size
|
||||
await api.post('/admin/posts/bulk-delete', { postIds: [...selected] })
|
||||
setSelected(new Set())
|
||||
fetchPosts()
|
||||
} catch {} finally {
|
||||
toast.success(`${count} post${count > 1 ? 's' : ''} deleted`)
|
||||
} catch {
|
||||
toast.error('Failed to delete posts')
|
||||
} finally {
|
||||
setBulkBusy(false)
|
||||
}
|
||||
}
|
||||
@@ -204,7 +218,10 @@ export default function AdminPosts() {
|
||||
setResponse('')
|
||||
setSelectedTagIds([])
|
||||
fetchPosts()
|
||||
} catch {} finally {
|
||||
toast.success('Status updated')
|
||||
} catch {
|
||||
toast.error('Failed to update status')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
@@ -244,7 +261,10 @@ export default function AdminPosts() {
|
||||
setActionPost(null)
|
||||
setMergeTarget('')
|
||||
fetchPosts()
|
||||
} catch {} finally {
|
||||
toast.success('Posts merged')
|
||||
} catch {
|
||||
toast.error('Failed to merge posts')
|
||||
} finally {
|
||||
setMerging(false)
|
||||
}
|
||||
}
|
||||
@@ -253,7 +273,9 @@ export default function AdminPosts() {
|
||||
try {
|
||||
await api.put(`/admin/posts/${id}/pin`)
|
||||
fetchPosts()
|
||||
} catch {}
|
||||
} catch {
|
||||
toast.error('Failed to pin post')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
@@ -261,7 +283,10 @@ export default function AdminPosts() {
|
||||
try {
|
||||
await api.delete(`/admin/posts/${id}`)
|
||||
fetchPosts()
|
||||
} catch {}
|
||||
toast.success('Post deleted')
|
||||
} catch {
|
||||
toast.error('Failed to delete post')
|
||||
}
|
||||
}
|
||||
|
||||
const openProxyModal = async () => {
|
||||
@@ -298,8 +323,10 @@ export default function AdminPosts() {
|
||||
})
|
||||
resetProxy()
|
||||
fetchPosts()
|
||||
toast.success('Post created')
|
||||
} catch {
|
||||
setProxyError('Failed to submit. Check required fields.')
|
||||
toast.error('Failed to create post')
|
||||
} finally {
|
||||
setProxySaving(false)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../../lib/api'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
import { useToast } from '../../hooks/useToast'
|
||||
|
||||
interface SiteSettings {
|
||||
appName: string
|
||||
@@ -29,6 +30,7 @@ export default function AdminSettings() {
|
||||
useDocumentTitle('Branding')
|
||||
const [form, setForm] = useState<SiteSettings>(defaults)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const toast = useToast()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
@@ -46,7 +48,10 @@ export default function AdminSettings() {
|
||||
await api.put('/admin/site-settings', form)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
} catch {} finally {
|
||||
toast.success('Branding saved')
|
||||
} catch {
|
||||
toast.error('Failed to save branding')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { api } from '../../lib/api'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
import { useToast } from '../../hooks/useToast'
|
||||
import { IconPalette, IconGripVertical, IconCheck, IconPlus, IconTrash } from '@tabler/icons-react'
|
||||
import Dropdown from '../../components/Dropdown'
|
||||
|
||||
@@ -49,6 +50,7 @@ export default function AdminStatuses() {
|
||||
const [boards, setBoards] = useState<Board[]>([])
|
||||
const [selectedBoardId, setSelectedBoardId] = useState('')
|
||||
const [statuses, setStatuses] = useState<StatusEntry[]>([])
|
||||
const toast = useToast()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
@@ -210,6 +212,7 @@ export default function AdminStatuses() {
|
||||
await api.put(`/admin/boards/${selectedBoardId}/statuses`, { statuses: payload })
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
toast.success('Statuses saved')
|
||||
} catch (e: unknown) {
|
||||
const msg = e && typeof e === 'object' && 'body' in e
|
||||
? ((e as { body: { error?: string } }).body?.error || 'Save failed')
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
|
||||
import { api } from '../../lib/api'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
import { useConfirm } from '../../hooks/useConfirm'
|
||||
import { useToast } from '../../hooks/useToast'
|
||||
|
||||
interface Tag {
|
||||
id: string
|
||||
@@ -19,6 +20,7 @@ const PRESET_COLORS = [
|
||||
export default function AdminTags() {
|
||||
useDocumentTitle('Tags')
|
||||
const confirm = useConfirm()
|
||||
const toast = useToast()
|
||||
const [tags, setTags] = useState<Tag[]>([])
|
||||
const [name, setName] = useState('')
|
||||
const [color, setColor] = useState('#6366F1')
|
||||
@@ -44,8 +46,10 @@ export default function AdminTags() {
|
||||
await api.post('/admin/tags', { name: name.trim(), color })
|
||||
setName('')
|
||||
fetchTags()
|
||||
toast.success('Tag created')
|
||||
} catch {
|
||||
setError('Failed to create tag')
|
||||
toast.error('Failed to create tag')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,8 +59,10 @@ export default function AdminTags() {
|
||||
await api.put(`/admin/tags/${editId}`, { name: editName.trim(), color: editColor })
|
||||
setEditId(null)
|
||||
fetchTags()
|
||||
toast.success('Tag updated')
|
||||
} catch {
|
||||
setError('Failed to update tag')
|
||||
toast.error('Failed to update tag')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +71,10 @@ export default function AdminTags() {
|
||||
try {
|
||||
await api.delete(`/admin/tags/${id}`)
|
||||
fetchTags()
|
||||
} catch {}
|
||||
toast.success('Tag deleted')
|
||||
} catch {
|
||||
toast.error('Failed to delete tag')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { api } from '../../lib/api'
|
||||
import { useAdmin } from '../../hooks/useAdmin'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
import { useConfirm } from '../../hooks/useConfirm'
|
||||
import { useToast } from '../../hooks/useToast'
|
||||
import Dropdown from '../../components/Dropdown'
|
||||
import { IconCopy, IconCheck, IconTrash, IconKey, IconPlus, IconInfoCircle, IconX, IconCamera } from '@tabler/icons-react'
|
||||
import Avatar from '../../components/Avatar'
|
||||
@@ -84,6 +85,7 @@ export default function AdminTeam() {
|
||||
const admin = useAdmin()
|
||||
const auth = useAuth()
|
||||
const confirm = useConfirm()
|
||||
const toast = useToast()
|
||||
|
||||
const [members, setMembers] = useState<TeamMember[]>([])
|
||||
const [invites, setInvites] = useState<PendingInvite[]>([])
|
||||
@@ -152,8 +154,11 @@ export default function AdminTeam() {
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setAvatarUrl(data.avatarUrl + '?t=' + Date.now())
|
||||
toast.success('Avatar updated')
|
||||
}
|
||||
} catch {} finally {
|
||||
} catch {
|
||||
toast.error('Failed to upload avatar')
|
||||
} finally {
|
||||
setUploadingAvatar(false)
|
||||
if (avatarInputRef.current) avatarInputRef.current.value = ''
|
||||
}
|
||||
@@ -163,7 +168,10 @@ export default function AdminTeam() {
|
||||
try {
|
||||
await api.delete('/me/avatar')
|
||||
setAvatarUrl(null)
|
||||
} catch {}
|
||||
toast.success('Avatar removed')
|
||||
} catch {
|
||||
toast.error('Failed to remove avatar')
|
||||
}
|
||||
}
|
||||
|
||||
const removeMember = async (id: string, name: string | null) => {
|
||||
@@ -171,8 +179,10 @@ export default function AdminTeam() {
|
||||
try {
|
||||
await api.delete(`/admin/team/${id}`)
|
||||
fetchMembers()
|
||||
toast.success('Member removed')
|
||||
} catch {
|
||||
setError('Failed to remove member')
|
||||
toast.error('Failed to remove member')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,8 +191,10 @@ export default function AdminTeam() {
|
||||
try {
|
||||
await api.delete(`/admin/team/invites/${id}`)
|
||||
fetchInvites()
|
||||
toast.success('Invite revoked')
|
||||
} catch {
|
||||
setError('Failed to revoke invite')
|
||||
toast.error('Failed to revoke invite')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,8 +203,10 @@ export default function AdminTeam() {
|
||||
try {
|
||||
const res = await api.post<{ recoveryPhrase: string }>(`/admin/team/${id}/recovery`)
|
||||
alert(`New recovery phrase:\n\n${res.recoveryPhrase}\n\nSave this - it won't be shown again.`)
|
||||
toast.info('Recovery phrase generated')
|
||||
} catch {
|
||||
setError('Failed to regenerate recovery phrase')
|
||||
toast.error('Failed to regenerate recovery phrase')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,8 +221,10 @@ export default function AdminTeam() {
|
||||
generateRecovery: inviteRecovery,
|
||||
})
|
||||
setInviteResult(res)
|
||||
toast.success('Invite created')
|
||||
} catch {
|
||||
setError('Failed to create invite')
|
||||
toast.error('Failed to create invite')
|
||||
} finally {
|
||||
setInviteLoading(false)
|
||||
}
|
||||
@@ -230,8 +246,10 @@ export default function AdminTeam() {
|
||||
admin.refresh()
|
||||
setProfileSaved(true)
|
||||
setTimeout(() => setProfileSaved(false), 2000)
|
||||
toast.success('Profile saved')
|
||||
} catch {
|
||||
setError('Failed to update profile')
|
||||
toast.error('Failed to save profile')
|
||||
} finally {
|
||||
setProfileSaving(false)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { api } from '../../lib/api'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
import { useToast } from '../../hooks/useToast'
|
||||
import {
|
||||
IconTemplate, IconGripVertical, IconCheck, IconPlus, IconTrash,
|
||||
IconPencil, IconX, IconStar, IconChevronDown, IconChevronUp,
|
||||
@@ -211,6 +212,7 @@ export default function AdminTemplates() {
|
||||
const [boards, setBoards] = useState<Board[]>([])
|
||||
const [selectedBoardId, setSelectedBoardId] = useState('')
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const toast = useToast()
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// drag state
|
||||
@@ -309,8 +311,10 @@ export default function AdminTemplates() {
|
||||
}
|
||||
fetchTemplates()
|
||||
closeModal()
|
||||
toast.success('Template saved')
|
||||
} catch {
|
||||
setModalError('Failed to save template')
|
||||
toast.error('Failed to save template')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -320,8 +324,10 @@ export default function AdminTemplates() {
|
||||
try {
|
||||
await api.delete(`/admin/templates/${id}`)
|
||||
setTemplates((prev) => prev.filter((t) => t.id !== id))
|
||||
toast.success('Template deleted')
|
||||
} catch {
|
||||
setError('Failed to delete template')
|
||||
toast.error('Failed to delete template')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { IconPlus, IconTrash } from '@tabler/icons-react'
|
||||
import { api } from '../../lib/api'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
import { useConfirm } from '../../hooks/useConfirm'
|
||||
import { useToast } from '../../hooks/useToast'
|
||||
|
||||
interface Webhook {
|
||||
id: string
|
||||
@@ -23,6 +24,7 @@ const ALL_EVENTS = [
|
||||
export default function AdminWebhooks() {
|
||||
useDocumentTitle('Webhooks')
|
||||
const confirm = useConfirm()
|
||||
const toast = useToast()
|
||||
const [webhooks, setWebhooks] = useState<Webhook[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
@@ -49,8 +51,10 @@ export default function AdminWebhooks() {
|
||||
setUrl('')
|
||||
setShowForm(false)
|
||||
fetchWebhooks()
|
||||
toast.success('Webhook created')
|
||||
} catch {
|
||||
setError('Failed to create webhook')
|
||||
toast.error('Failed to create webhook')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +62,10 @@ export default function AdminWebhooks() {
|
||||
try {
|
||||
await api.put(`/admin/webhooks/${id}`, { active: !active })
|
||||
fetchWebhooks()
|
||||
} catch {}
|
||||
toast.success(active ? 'Webhook disabled' : 'Webhook enabled')
|
||||
} catch {
|
||||
toast.error('Failed to update webhook')
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (id: string) => {
|
||||
@@ -66,7 +73,10 @@ export default function AdminWebhooks() {
|
||||
try {
|
||||
await api.delete(`/admin/webhooks/${id}`)
|
||||
fetchWebhooks()
|
||||
} catch {}
|
||||
toast.success('Webhook deleted')
|
||||
} catch {
|
||||
toast.error('Failed to delete webhook')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user