global push notifications toggle, inline status change on post detail
This commit is contained in:
@@ -8,7 +8,8 @@ import StatusBadge from '../components/StatusBadge'
|
||||
import Timeline from '../components/Timeline'
|
||||
import type { TimelineEntry } from '../components/Timeline'
|
||||
import PluginSlot from '../components/PluginSlot'
|
||||
import { IconChevronUp, IconBug, IconBulb, IconEdit, IconTrash, IconPin, IconX, IconLock, IconLockOpen, IconHistory, IconLoader2, IconEye, IconMessageOff, IconMessage } from '@tabler/icons-react'
|
||||
import { IconChevronUp, IconBug, IconBulb, IconEdit, IconTrash, IconPin, IconX, IconLock, IconLockOpen, IconHistory, IconLoader2, IconEye, IconMessageOff, IconMessage, IconCheck } from '@tabler/icons-react'
|
||||
import Dropdown from '../components/Dropdown'
|
||||
import EditHistoryModal from '../components/EditHistoryModal'
|
||||
import Avatar from '../components/Avatar'
|
||||
import Markdown from '../components/Markdown'
|
||||
@@ -202,6 +203,30 @@ export default function PostDetail() {
|
||||
const [historyLoading, setHistoryLoading] = useState(false)
|
||||
const [showThreadLockDialog, setShowThreadLockDialog] = useState(false)
|
||||
const [threadLockVoting, setThreadLockVoting] = useState(false)
|
||||
const [boardStatuses, setBoardStatuses] = useState<{ status: string; label: string }[]>([])
|
||||
const [statusChanging, setStatusChanging] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!boardSlug) return
|
||||
api.get<{ statuses: { status: string; label: string }[] }>(`/boards/${boardSlug}/statuses`)
|
||||
.then((r) => setBoardStatuses(r.statuses))
|
||||
.catch(() => {})
|
||||
}, [boardSlug])
|
||||
|
||||
const changeStatus = async (newStatus: string) => {
|
||||
if (!postId || !post || newStatus === post.status) return
|
||||
setStatusChanging(true)
|
||||
try {
|
||||
await api.put(`/admin/posts/${postId}/status`, { status: newStatus })
|
||||
setPost({ ...post, status: newStatus })
|
||||
fetchPost()
|
||||
toast.success('Status updated')
|
||||
} catch {
|
||||
toast.error('Failed to update status')
|
||||
} finally {
|
||||
setStatusChanging(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPost = async () => {
|
||||
if (!boardSlug || !postId) return
|
||||
@@ -523,7 +548,18 @@ export default function PostDetail() {
|
||||
{typeLabel}
|
||||
</span>
|
||||
{post.isPinned && <IconPin size={14} stroke={2} style={{ color: 'var(--accent)' }} />}
|
||||
<StatusBadge status={post.status} />
|
||||
{admin.isAdmin && boardStatuses.length > 0 ? (
|
||||
<div style={{ minWidth: 140 }}>
|
||||
<Dropdown
|
||||
value={post.status}
|
||||
options={boardStatuses.map((s) => ({ value: s.status, label: s.label }))}
|
||||
onChange={changeStatus}
|
||||
aria-label="Change status"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<StatusBadge status={post.status} />
|
||||
)}
|
||||
{post.category && (
|
||||
<span
|
||||
className="px-2 py-0.5"
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../../lib/api'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
import { IconChevronRight, IconFileText, IconLayoutGrid, IconTag, IconTrash } from '@tabler/icons-react'
|
||||
import { useToast } from '../../hooks/useToast'
|
||||
import { IconChevronRight, IconFileText, IconLayoutGrid, IconTag, IconTrash, IconBell, IconBellOff } from '@tabler/icons-react'
|
||||
import type { Icon } from '@tabler/icons-react'
|
||||
|
||||
interface Stats {
|
||||
@@ -15,16 +16,58 @@ interface Stats {
|
||||
|
||||
export default function AdminDashboard() {
|
||||
useDocumentTitle('Admin')
|
||||
const toast = useToast()
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [globalNotif, setGlobalNotif] = useState(false)
|
||||
const [notifLoading, setNotifLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Stats>('/admin/stats')
|
||||
.then(setStats)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
api.get<{ subscribed: boolean }>('/push/global-subscription')
|
||||
.then((r) => setGlobalNotif(r.subscribed))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const toggleGlobalNotif = async () => {
|
||||
if (notifLoading) return
|
||||
setNotifLoading(true)
|
||||
try {
|
||||
if (globalNotif) {
|
||||
await api.delete('/push/global-subscribe')
|
||||
setGlobalNotif(false)
|
||||
toast.info('Global notifications disabled')
|
||||
} else {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
toast.error('Push notifications not supported in this browser')
|
||||
return
|
||||
}
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') {
|
||||
toast.warning('Notification permission denied')
|
||||
return
|
||||
}
|
||||
const reg = await navigator.serviceWorker.ready
|
||||
const vapid = await api.get<{ publicKey: string }>('/push/vapid')
|
||||
const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: vapid.publicKey })
|
||||
const json = sub.toJSON()
|
||||
await api.post('/push/global-subscribe', {
|
||||
endpoint: json.endpoint,
|
||||
keys: { p256dh: json.keys!.p256dh, auth: json.keys!.auth },
|
||||
})
|
||||
setGlobalNotif(true)
|
||||
toast.success('Global notifications enabled for all boards')
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to toggle notifications')
|
||||
} finally {
|
||||
setNotifLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const statCards = stats ? [
|
||||
{ label: 'Total Posts', value: stats.totalPosts, color: 'var(--admin-accent)' },
|
||||
{ label: 'This Week', value: stats.thisWeek, color: 'var(--accent)' },
|
||||
@@ -84,6 +127,32 @@ export default function AdminDashboard() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Global notifications */}
|
||||
<div className="card card-static p-4 mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||
All-board notifications
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
Get notified when anyone posts on any board
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleGlobalNotif}
|
||||
disabled={notifLoading}
|
||||
className="btn btn-secondary flex items-center gap-1.5"
|
||||
style={{
|
||||
fontSize: 'var(--text-xs)',
|
||||
color: globalNotif ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
opacity: notifLoading ? 0.6 : 1,
|
||||
}}
|
||||
aria-label={globalNotif ? 'Disable all-board notifications' : 'Enable all-board notifications'}
|
||||
>
|
||||
{globalNotif ? <IconBell size={14} stroke={2} /> : <IconBellOff size={14} stroke={2} />}
|
||||
{globalNotif ? 'Enabled' : 'Disabled'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Posts by status bar chart */}
|
||||
{stats && stats.totalPosts > 0 && (
|
||||
<div className="card p-5 mb-8">
|
||||
|
||||
Reference in New Issue
Block a user