initial project setup
Fastify + Prisma backend, React + Vite frontend, Docker deployment. Multi-board feedback platform with anonymous cookie auth, passkey upgrade path, ALTCHA spam protection, plugin system, and full privacy-first architecture.
This commit is contained in:
154
packages/web/src/pages/ActivityFeed.tsx
Normal file
154
packages/web/src/pages/ActivityFeed.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../lib/api'
|
||||
|
||||
interface Activity {
|
||||
id: string
|
||||
type: 'post_created' | 'status_changed' | 'comment_added' | 'admin_response' | 'vote'
|
||||
postId: string
|
||||
postTitle: string
|
||||
boardSlug: string
|
||||
boardName: string
|
||||
actorName: string
|
||||
detail?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
post_created: 'created a post',
|
||||
status_changed: 'changed status',
|
||||
comment_added: 'commented',
|
||||
admin_response: 'responded',
|
||||
vote: 'voted on',
|
||||
}
|
||||
|
||||
const typeIcons: Record<string, JSX.Element> = {
|
||||
post_created: (
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
),
|
||||
status_changed: (
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
),
|
||||
comment_added: (
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
),
|
||||
admin_response: (
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
),
|
||||
vote: (
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
|
||||
export default function ActivityFeed() {
|
||||
const [activities, setActivities] = useState<Activity[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [boardFilter, setBoardFilter] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams()
|
||||
if (boardFilter) params.set('board', boardFilter)
|
||||
if (typeFilter) params.set('type', typeFilter)
|
||||
|
||||
api.get<Activity[]>(`/activity?${params}`)
|
||||
.then(setActivities)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [boardFilter, typeFilter])
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||
<h1
|
||||
className="text-2xl font-bold mb-6"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
Activity
|
||||
</h1>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3 mb-6">
|
||||
<select
|
||||
className="input"
|
||||
style={{ maxWidth: 200 }}
|
||||
value={boardFilter}
|
||||
onChange={(e) => setBoardFilter(e.target.value)}
|
||||
>
|
||||
<option value="">All boards</option>
|
||||
</select>
|
||||
<select
|
||||
className="input"
|
||||
style={{ maxWidth: 200 }}
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
>
|
||||
<option value="">All types</option>
|
||||
<option value="post_created">Posts</option>
|
||||
<option value="comment_added">Comments</option>
|
||||
<option value="status_changed">Status changes</option>
|
||||
<option value="admin_response">Admin responses</option>
|
||||
<option value="vote">Votes</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div
|
||||
className="w-6 h-6 border-2 rounded-full"
|
||||
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
|
||||
/>
|
||||
</div>
|
||||
) : activities.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>No activity yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{activities.map((a) => (
|
||||
<Link
|
||||
key={a.id}
|
||||
to={`/b/${a.boardSlug}/post/${a.postId}`}
|
||||
className="flex items-start gap-3 p-3 rounded-lg"
|
||||
style={{ transition: 'background 200ms ease-out' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--surface-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<div
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0 mt-0.5"
|
||||
style={{
|
||||
background: a.type === 'admin_response' ? 'var(--admin-subtle)' : 'var(--accent-subtle)',
|
||||
color: a.type === 'admin_response' ? 'var(--admin-accent)' : 'var(--accent)',
|
||||
}}
|
||||
>
|
||||
{typeIcons[a.type]}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<span style={{ color: 'var(--text)' }}>{a.actorName}</span>
|
||||
{' '}{typeLabels[a.type] || a.type}{' '}
|
||||
<span style={{ color: 'var(--text)' }}>{a.postTitle}</span>
|
||||
</p>
|
||||
{a.detail && (
|
||||
<p className="text-xs mt-0.5 truncate" style={{ color: 'var(--text-tertiary)' }}>{a.detail}</p>
|
||||
)}
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{a.boardName} - {new Date(a.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
194
packages/web/src/pages/BoardFeed.tsx
Normal file
194
packages/web/src/pages/BoardFeed.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { api } from '../lib/api'
|
||||
import PostCard from '../components/PostCard'
|
||||
import PostForm from '../components/PostForm'
|
||||
import VoteBudget from '../components/VoteBudget'
|
||||
import EmptyState from '../components/EmptyState'
|
||||
|
||||
interface Post {
|
||||
id: string
|
||||
title: string
|
||||
excerpt?: string
|
||||
type: 'feature' | 'bug' | 'general'
|
||||
status: string
|
||||
voteCount: number
|
||||
commentCount: number
|
||||
authorName: string
|
||||
createdAt: string
|
||||
boardSlug: string
|
||||
hasVoted?: boolean
|
||||
}
|
||||
|
||||
interface Board {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface Budget {
|
||||
used: number
|
||||
total: number
|
||||
resetsAt?: string
|
||||
}
|
||||
|
||||
type SortOption = 'newest' | 'top' | 'trending'
|
||||
type StatusFilter = 'all' | 'OPEN' | 'PLANNED' | 'IN_PROGRESS' | 'DONE' | 'DECLINED'
|
||||
|
||||
export default function BoardFeed() {
|
||||
const { boardSlug } = useParams<{ boardSlug: string }>()
|
||||
const [board, setBoard] = useState<Board | null>(null)
|
||||
const [posts, setPosts] = useState<Post[]>([])
|
||||
const [budget, setBudget] = useState<Budget>({ used: 0, total: 10 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [sort, setSort] = useState<SortOption>('newest')
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||
const [search, setSearch] = useState('')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
const fetchPosts = useCallback(async () => {
|
||||
if (!boardSlug) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ sort })
|
||||
if (statusFilter !== 'all') params.set('status', statusFilter)
|
||||
if (search) params.set('q', search)
|
||||
|
||||
const [b, p, bud] = await Promise.all([
|
||||
api.get<Board>(`/boards/${boardSlug}`),
|
||||
api.get<Post[]>(`/boards/${boardSlug}/posts?${params}`),
|
||||
api.get<Budget>(`/boards/${boardSlug}/budget`).catch(() => ({ used: 0, total: 10 })),
|
||||
])
|
||||
setBoard(b)
|
||||
setPosts(p)
|
||||
setBudget(bud as Budget)
|
||||
} catch {
|
||||
setPosts([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [boardSlug, sort, statusFilter, search])
|
||||
|
||||
useEffect(() => { fetchPosts() }, [fetchPosts])
|
||||
|
||||
const handleVote = async (postId: string) => {
|
||||
try {
|
||||
await api.post(`/posts/${postId}/vote`)
|
||||
fetchPosts()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const sortOptions: { value: SortOption; label: string }[] = [
|
||||
{ value: 'newest', label: 'Newest' },
|
||||
{ value: 'top', label: 'Top Voted' },
|
||||
{ value: 'trending', label: 'Trending' },
|
||||
]
|
||||
|
||||
const statuses: { value: StatusFilter; label: string }[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'OPEN', label: 'Open' },
|
||||
{ value: 'PLANNED', label: 'Planned' },
|
||||
{ value: 'IN_PROGRESS', label: 'In Progress' },
|
||||
{ value: 'DONE', label: 'Done' },
|
||||
{ value: 'DECLINED', label: 'Declined' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
{board && (
|
||||
<div className="mb-6">
|
||||
<h1
|
||||
className="text-2xl font-bold mb-1"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
{board.name}
|
||||
</h1>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{board.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Budget */}
|
||||
<div className="mb-4">
|
||||
<VoteBudget used={budget.used} total={budget.total} resetsAt={budget.resetsAt} />
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Search posts..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{sortOptions.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
onClick={() => setSort(o.value)}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-medium"
|
||||
style={{
|
||||
background: sort === o.value ? 'var(--accent-subtle)' : 'transparent',
|
||||
color: sort === o.value ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||
transition: 'all 200ms ease-out',
|
||||
}}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status filter */}
|
||||
<div className="flex gap-1 mb-6 overflow-x-auto pb-1">
|
||||
{statuses.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
onClick={() => setStatusFilter(s.value)}
|
||||
className="px-3 py-1 rounded-full text-xs font-medium whitespace-nowrap"
|
||||
style={{
|
||||
background: statusFilter === s.value ? 'var(--surface-hover)' : 'transparent',
|
||||
color: statusFilter === s.value ? 'var(--text)' : 'var(--text-tertiary)',
|
||||
border: `1px solid ${statusFilter === s.value ? 'var(--border-hover)' : 'var(--border)'}`,
|
||||
transition: 'all 200ms ease-out',
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Post form */}
|
||||
{boardSlug && (
|
||||
<div className="mb-4">
|
||||
<PostForm boardSlug={boardSlug} onSubmit={fetchPosts} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Posts */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div
|
||||
className="w-6 h-6 border-2 rounded-full"
|
||||
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
|
||||
/>
|
||||
</div>
|
||||
) : posts.length === 0 ? (
|
||||
<EmptyState
|
||||
onAction={() => setShowForm(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{posts.map((post) => (
|
||||
<PostCard key={post.id} post={post} onVote={handleVote} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
142
packages/web/src/pages/BoardIndex.tsx
Normal file
142
packages/web/src/pages/BoardIndex.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../lib/api'
|
||||
|
||||
interface Board {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
description: string
|
||||
postCount: number
|
||||
openCount: number
|
||||
archived: boolean
|
||||
}
|
||||
|
||||
export default function BoardIndex() {
|
||||
const [boards, setBoards] = useState<Board[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showArchived, setShowArchived] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Board[]>('/boards')
|
||||
.then(setBoards)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const active = boards.filter((b) => !b.archived)
|
||||
const archived = boards.filter((b) => b.archived)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div
|
||||
className="w-6 h-6 border-2 rounded-full"
|
||||
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1
|
||||
className="text-3xl font-bold mb-2"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
Feedback Boards
|
||||
</h1>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Choose a board to browse or submit feedback
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{active.map((board, i) => (
|
||||
<Link
|
||||
key={board.id}
|
||||
to={`/b/${board.slug}`}
|
||||
className="card p-5 block group"
|
||||
style={{
|
||||
animation: `fadeIn 200ms ease-out ${i * 80}ms both`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center text-lg font-bold"
|
||||
style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
background: 'var(--accent-subtle)',
|
||||
color: 'var(--accent)',
|
||||
}}
|
||||
>
|
||||
{board.name.charAt(0)}
|
||||
</div>
|
||||
<svg
|
||||
width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
||||
style={{ color: 'var(--text-tertiary)', transition: 'transform 200ms ease-out' }}
|
||||
className="group-hover:translate-x-0.5"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2
|
||||
className="text-base font-semibold mb-1"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
{board.name}
|
||||
</h2>
|
||||
<p className="text-sm mb-3 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
{board.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<span>{board.postCount} posts</span>
|
||||
<span>{board.openCount} open</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{archived.length > 0 && (
|
||||
<div className="mt-10">
|
||||
<button
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
className="flex items-center gap-2 text-sm mb-4"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
<svg
|
||||
width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
||||
style={{
|
||||
transition: 'transform 200ms ease-out',
|
||||
transform: showArchived ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||
}}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
Archived boards ({archived.length})
|
||||
</button>
|
||||
|
||||
{showArchived && (
|
||||
<div className="grid gap-3 md:grid-cols-2 fade-in">
|
||||
{archived.map((board) => (
|
||||
<Link
|
||||
key={board.id}
|
||||
to={`/b/${board.slug}`}
|
||||
className="card p-4 block opacity-60"
|
||||
>
|
||||
<h3 className="text-sm font-medium mb-1" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}>
|
||||
{board.name}
|
||||
</h3>
|
||||
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{board.postCount} posts - archived
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
205
packages/web/src/pages/IdentitySettings.tsx
Normal file
205
packages/web/src/pages/IdentitySettings.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { api } from '../lib/api'
|
||||
|
||||
export default function IdentitySettings() {
|
||||
const auth = useAuth()
|
||||
const [name, setName] = useState(auth.displayName)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [showDelete, setShowDelete] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [showPasskey, setShowPasskey] = useState(false)
|
||||
|
||||
const saveName = async () => {
|
||||
if (!name.trim()) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await auth.updateProfile({ displayName: name })
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
} catch {} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const data = await api.get<unknown>('/me/export')
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'echoboard-data.json'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true)
|
||||
try {
|
||||
await auth.deleteIdentity()
|
||||
window.location.href = '/'
|
||||
} catch {} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto px-4 py-8">
|
||||
<h1
|
||||
className="text-2xl font-bold mb-6"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
Settings
|
||||
</h1>
|
||||
|
||||
{/* Display name */}
|
||||
<div className="card p-5 mb-4">
|
||||
<h2
|
||||
className="text-sm font-semibold mb-3"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
Display Name
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
onClick={saveName}
|
||||
disabled={saving}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{saving ? 'Saving...' : saved ? 'Saved' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Identity status */}
|
||||
<div className="card p-5 mb-4">
|
||||
<h2
|
||||
className="text-sm font-semibold mb-3"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
Identity
|
||||
</h2>
|
||||
<div
|
||||
className="flex items-center gap-3 p-3 rounded-lg mb-3"
|
||||
style={{ background: 'var(--bg)' }}
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center"
|
||||
style={{
|
||||
background: auth.isPasskeyUser ? 'rgba(34, 197, 94, 0.15)' : 'var(--accent-subtle)',
|
||||
color: auth.isPasskeyUser ? 'var(--success)' : 'var(--accent)',
|
||||
}}
|
||||
>
|
||||
{auth.isPasskeyUser ? (
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text)' }}>
|
||||
{auth.isPasskeyUser ? 'Passkey registered' : 'Cookie-based identity'}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{auth.isPasskeyUser
|
||||
? 'Your identity is secured with a passkey'
|
||||
: 'Your identity is tied to this browser cookie'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!auth.isPasskeyUser && (
|
||||
<button onClick={() => setShowPasskey(true)} className="btn btn-primary w-full">
|
||||
Upgrade to passkey
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Data */}
|
||||
<div className="card p-5 mb-4">
|
||||
<h2
|
||||
className="text-sm font-semibold mb-3"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
Your Data
|
||||
</h2>
|
||||
<p className="text-sm mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||
Export all your data in JSON format.
|
||||
</p>
|
||||
<button onClick={handleExport} className="btn btn-secondary">
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Export Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Danger zone */}
|
||||
<div className="card p-5" style={{ borderColor: 'rgba(239, 68, 68, 0.2)' }}>
|
||||
<h2
|
||||
className="text-sm font-semibold mb-3"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--error)' }}
|
||||
>
|
||||
Danger Zone
|
||||
</h2>
|
||||
<p className="text-sm mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||
This will permanently delete your identity and all associated data. This cannot be undone.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowDelete(true)}
|
||||
className="btn text-sm"
|
||||
style={{ background: 'rgba(239, 68, 68, 0.15)', color: 'var(--error)' }}
|
||||
>
|
||||
Delete my identity
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
{showDelete && (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center"
|
||||
onClick={() => setShowDelete(false)}
|
||||
>
|
||||
<div className="absolute inset-0 fade-in" style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }} />
|
||||
<div
|
||||
className="relative w-full max-w-sm mx-4 p-6 rounded-xl shadow-2xl slide-up"
|
||||
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-lg font-bold mb-2" style={{ fontFamily: 'var(--font-heading)', color: 'var(--error)' }}>
|
||||
Delete Identity
|
||||
</h3>
|
||||
<p className="text-sm mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||
Are you sure? All your posts, votes, and data will be permanently removed. This action cannot be reversed.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => setShowDelete(false)} className="btn btn-secondary flex-1">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="btn flex-1"
|
||||
style={{ background: 'var(--error)', color: 'white', opacity: deleting ? 0.6 : 1 }}
|
||||
>
|
||||
{deleting ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
94
packages/web/src/pages/MySubmissions.tsx
Normal file
94
packages/web/src/pages/MySubmissions.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../lib/api'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
|
||||
interface Post {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
status: string
|
||||
voteCount: number
|
||||
commentCount: number
|
||||
boardSlug: string
|
||||
boardName: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export default function MySubmissions() {
|
||||
const [posts, setPosts] = useState<Post[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Post[]>('/me/posts')
|
||||
.then(setPosts)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||
<h1
|
||||
className="text-2xl font-bold mb-6"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
My Posts
|
||||
</h1>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div
|
||||
className="w-6 h-6 border-2 rounded-full"
|
||||
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
|
||||
/>
|
||||
</div>
|
||||
) : posts.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-sm mb-4" style={{ color: 'var(--text-tertiary)' }}>
|
||||
You have not submitted any posts yet
|
||||
</p>
|
||||
<Link to="/" className="btn btn-primary">Browse boards</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{posts.map((post) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
to={`/b/${post.boardSlug}/post/${post.id}`}
|
||||
className="card p-4 flex items-center gap-4"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{post.boardName}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded capitalize"
|
||||
style={{
|
||||
background: post.type === 'bug' ? 'rgba(239, 68, 68, 0.15)' : 'var(--accent-subtle)',
|
||||
color: post.type === 'bug' ? 'var(--error)' : 'var(--accent)',
|
||||
}}
|
||||
>
|
||||
{post.type}
|
||||
</span>
|
||||
</div>
|
||||
<h3
|
||||
className="text-sm font-medium truncate"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
{post.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<span>{post.voteCount} votes</span>
|
||||
<span>{post.commentCount} comments</span>
|
||||
<span>{new Date(post.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={post.status} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
241
packages/web/src/pages/PostDetail.tsx
Normal file
241
packages/web/src/pages/PostDetail.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { api } from '../lib/api'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import Timeline from '../components/Timeline'
|
||||
|
||||
interface Post {
|
||||
id: string
|
||||
title: string
|
||||
body: string
|
||||
type: string
|
||||
status: string
|
||||
voteCount: number
|
||||
hasVoted: boolean
|
||||
authorName: string
|
||||
createdAt: string
|
||||
boardSlug: string
|
||||
boardName: string
|
||||
stepsToReproduce?: string
|
||||
expected?: string
|
||||
actual?: string
|
||||
}
|
||||
|
||||
interface TimelineEntry {
|
||||
id: string
|
||||
type: 'status_change' | 'admin_response' | 'comment'
|
||||
authorName: string
|
||||
content: string
|
||||
oldStatus?: string
|
||||
newStatus?: string
|
||||
createdAt: string
|
||||
reactions?: { emoji: string; count: number; hasReacted: boolean }[]
|
||||
isAdmin?: boolean
|
||||
}
|
||||
|
||||
export default function PostDetail() {
|
||||
const { boardSlug, postId } = useParams()
|
||||
const [post, setPost] = useState<Post | null>(null)
|
||||
const [timeline, setTimeline] = useState<TimelineEntry[]>([])
|
||||
const [comment, setComment] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchPost = async () => {
|
||||
if (!postId) return
|
||||
try {
|
||||
const [p, t] = await Promise.all([
|
||||
api.get<Post>(`/posts/${postId}`),
|
||||
api.get<TimelineEntry[]>(`/posts/${postId}/timeline`),
|
||||
])
|
||||
setPost(p)
|
||||
setTimeline(t)
|
||||
} catch {} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchPost() }, [postId])
|
||||
|
||||
const handleVote = async () => {
|
||||
if (!postId) return
|
||||
try {
|
||||
await api.post(`/posts/${postId}/vote`)
|
||||
fetchPost()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const handleComment = async () => {
|
||||
if (!postId || !comment.trim()) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await api.post(`/posts/${postId}/comments`, { content: comment })
|
||||
setComment('')
|
||||
fetchPost()
|
||||
} catch {} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReact = async (entryId: string, emoji: string) => {
|
||||
try {
|
||||
await api.post(`/timeline/${entryId}/react`, { emoji })
|
||||
fetchPost()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div
|
||||
className="w-6 h-6 border-2 rounded-full"
|
||||
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!post) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-16 text-center">
|
||||
<h2 className="text-lg font-semibold mb-2" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}>
|
||||
Post not found
|
||||
</h2>
|
||||
<Link to={`/b/${boardSlug}`} className="btn btn-secondary mt-4">
|
||||
Back to board
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 mb-6 text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Link to="/" className="hover:underline">Home</Link>
|
||||
<span>/</span>
|
||||
<Link to={`/b/${post.boardSlug}`} className="hover:underline">{post.boardName}</Link>
|
||||
<span>/</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{post.title}</span>
|
||||
</div>
|
||||
|
||||
{/* Post header */}
|
||||
<div className="card p-6 mb-6 fade-in">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Vote button */}
|
||||
<button
|
||||
onClick={handleVote}
|
||||
className="flex flex-col items-center gap-1 px-3 py-2 rounded-lg shrink-0"
|
||||
style={{
|
||||
background: post.hasVoted ? 'var(--accent-subtle)' : 'var(--surface-hover)',
|
||||
color: post.hasVoted ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||
transition: 'all 200ms ease-out',
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
<span className="text-sm font-semibold">{post.voteCount}</span>
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded capitalize"
|
||||
style={{
|
||||
background: post.type === 'bug' ? 'rgba(239, 68, 68, 0.15)' : 'var(--accent-subtle)',
|
||||
color: post.type === 'bug' ? 'var(--error)' : 'var(--accent)',
|
||||
}}
|
||||
>
|
||||
{post.type}
|
||||
</span>
|
||||
<StatusBadge status={post.status} />
|
||||
</div>
|
||||
|
||||
<h1
|
||||
className="text-xl font-bold mb-2"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
<div className="text-xs mb-4" style={{ color: 'var(--text-tertiary)' }}>
|
||||
by {post.authorName} - {new Date(post.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
|
||||
<div className="text-sm whitespace-pre-wrap mb-4" style={{ color: 'var(--text-secondary)', lineHeight: 1.7 }}>
|
||||
{post.body}
|
||||
</div>
|
||||
|
||||
{/* Bug report fields */}
|
||||
{post.type === 'bug' && (
|
||||
<div className="grid gap-3 md:grid-cols-1">
|
||||
{post.stepsToReproduce && (
|
||||
<div className="p-3 rounded-lg" style={{ background: 'var(--bg)' }}>
|
||||
<div className="text-xs font-medium mb-1" style={{ color: 'var(--text-tertiary)' }}>Steps to Reproduce</div>
|
||||
<div className="text-sm whitespace-pre-wrap" style={{ color: 'var(--text-secondary)' }}>{post.stepsToReproduce}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{post.expected && (
|
||||
<div className="p-3 rounded-lg" style={{ background: 'var(--bg)' }}>
|
||||
<div className="text-xs font-medium mb-1" style={{ color: 'var(--text-tertiary)' }}>Expected</div>
|
||||
<div className="text-sm whitespace-pre-wrap" style={{ color: 'var(--text-secondary)' }}>{post.expected}</div>
|
||||
</div>
|
||||
)}
|
||||
{post.actual && (
|
||||
<div className="p-3 rounded-lg" style={{ background: 'var(--bg)' }}>
|
||||
<div className="text-xs font-medium mb-1" style={{ color: 'var(--text-tertiary)' }}>Actual</div>
|
||||
<div className="text-sm whitespace-pre-wrap" style={{ color: 'var(--text-secondary)' }}>{post.actual}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
{timeline.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h2
|
||||
className="text-sm font-semibold mb-4"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
Activity
|
||||
</h2>
|
||||
<Timeline entries={timeline} onReact={handleReact} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comment form */}
|
||||
<div className="card p-4">
|
||||
<h3
|
||||
className="text-sm font-semibold mb-3"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
Add a comment
|
||||
</h3>
|
||||
<textarea
|
||||
className="input mb-3"
|
||||
rows={3}
|
||||
placeholder="Write your comment..."
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
style={{ resize: 'vertical' }}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleComment}
|
||||
disabled={submitting || !comment.trim()}
|
||||
className="btn btn-primary"
|
||||
style={{ opacity: submitting || !comment.trim() ? 0.5 : 1 }}
|
||||
>
|
||||
{submitting ? 'Posting...' : 'Post Comment'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
163
packages/web/src/pages/PrivacyPage.tsx
Normal file
163
packages/web/src/pages/PrivacyPage.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from '../lib/api'
|
||||
|
||||
interface DataField {
|
||||
field: string
|
||||
purpose: string
|
||||
retention: string
|
||||
deletable: boolean
|
||||
}
|
||||
|
||||
interface Manifest {
|
||||
fields: DataField[]
|
||||
cookieInfo: string
|
||||
dataLocation: string
|
||||
thirdParties: string[]
|
||||
}
|
||||
|
||||
export default function PrivacyPage() {
|
||||
const [manifest, setManifest] = useState<Manifest | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Manifest>('/privacy/data-manifest')
|
||||
.then(setManifest)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||
<h1
|
||||
className="text-2xl font-bold mb-2"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
Privacy
|
||||
</h1>
|
||||
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
|
||||
Here is exactly what data this Echoboard instance collects and why.
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div
|
||||
className="w-6 h-6 border-2 rounded-full"
|
||||
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
|
||||
/>
|
||||
</div>
|
||||
) : manifest ? (
|
||||
<>
|
||||
{/* Quick summary */}
|
||||
<div className="card p-5 mb-6">
|
||||
<h2
|
||||
className="text-base font-semibold mb-3"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
The short version
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<li className="flex items-start gap-2">
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--success)" strokeWidth={2.5} className="shrink-0 mt-0.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
No tracking scripts, no analytics, no third-party cookies
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--success)" strokeWidth={2.5} className="shrink-0 mt-0.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
All data stays on this server - {manifest.dataLocation}
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--success)" strokeWidth={2.5} className="shrink-0 mt-0.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
No external fonts or resources loaded
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--success)" strokeWidth={2.5} className="shrink-0 mt-0.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
You can delete everything at any time
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Cookie info */}
|
||||
<div className="card p-5 mb-6">
|
||||
<h2
|
||||
className="text-base font-semibold mb-2"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
Cookies
|
||||
</h2>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{manifest.cookieInfo}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Data fields */}
|
||||
<div className="card p-5 mb-6">
|
||||
<h2
|
||||
className="text-base font-semibold mb-4"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
What we store
|
||||
</h2>
|
||||
<div className="flex flex-col gap-3">
|
||||
{manifest.fields.map((f) => (
|
||||
<div
|
||||
key={f.field}
|
||||
className="p-3 rounded-lg"
|
||||
style={{ background: 'var(--bg)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text)' }}>
|
||||
{f.field}
|
||||
</span>
|
||||
{f.deletable && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded" style={{ background: 'rgba(34, 197, 94, 0.15)', color: 'var(--success)' }}>
|
||||
deletable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
{f.purpose}
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Retained: {f.retention}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Third parties */}
|
||||
{manifest.thirdParties.length > 0 && (
|
||||
<div className="card p-5 mb-6">
|
||||
<h2
|
||||
className="text-base font-semibold mb-2"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
Third parties
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{manifest.thirdParties.map((tp) => (
|
||||
<li key={tp} className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
- {tp}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="card p-5">
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
This Echoboard instance is self-hosted and privacy-focused. No tracking, no external services, no data sharing. Your identity is cookie-based unless you register a passkey. You can delete all your data at any time from the settings page.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
260
packages/web/src/pages/admin/AdminBoards.tsx
Normal file
260
packages/web/src/pages/admin/AdminBoards.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../../lib/api'
|
||||
|
||||
interface Board {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
description: string
|
||||
postCount: number
|
||||
archived: boolean
|
||||
voteBudget: number
|
||||
voteResetSchedule: string
|
||||
}
|
||||
|
||||
export default function AdminBoards() {
|
||||
const [boards, setBoards] = useState<Board[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editBoard, setEditBoard] = useState<Board | null>(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
voteBudget: 10,
|
||||
voteResetSchedule: 'monthly',
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const fetchBoards = async () => {
|
||||
try {
|
||||
const b = await api.get<Board[]>('/admin/boards')
|
||||
setBoards(b)
|
||||
} catch {} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchBoards() }, [])
|
||||
|
||||
const resetForm = () => {
|
||||
setForm({ name: '', slug: '', description: '', voteBudget: 10, voteResetSchedule: 'monthly' })
|
||||
setEditBoard(null)
|
||||
setShowCreate(false)
|
||||
}
|
||||
|
||||
const openEdit = (b: Board) => {
|
||||
setEditBoard(b)
|
||||
setForm({
|
||||
name: b.name,
|
||||
slug: b.slug,
|
||||
description: b.description,
|
||||
voteBudget: b.voteBudget,
|
||||
voteResetSchedule: b.voteResetSchedule,
|
||||
})
|
||||
setShowCreate(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editBoard) {
|
||||
await api.put(`/admin/boards/${editBoard.id}`, form)
|
||||
} else {
|
||||
await api.post('/admin/boards', form)
|
||||
}
|
||||
resetForm()
|
||||
fetchBoards()
|
||||
} catch {} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleArchive = async (id: string, archived: boolean) => {
|
||||
try {
|
||||
await api.patch(`/admin/boards/${id}`, { archived: !archived })
|
||||
fetchBoards()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1
|
||||
className="text-2xl font-bold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}
|
||||
>
|
||||
Boards
|
||||
</h1>
|
||||
<div className="flex gap-2">
|
||||
<Link to="/admin" className="btn btn-ghost text-sm">Back</Link>
|
||||
<button onClick={() => { resetForm(); setShowCreate(true) }} className="btn btn-admin text-sm">
|
||||
New Board
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div
|
||||
className="w-6 h-6 border-2 rounded-full"
|
||||
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--admin-accent)', animation: 'spin 0.6s linear infinite' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{boards.map((board) => (
|
||||
<div
|
||||
key={board.id}
|
||||
className="card p-4 flex items-center gap-4"
|
||||
style={{ opacity: board.archived ? 0.5 : 1 }}
|
||||
>
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center text-sm font-bold shrink-0"
|
||||
style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
background: 'var(--admin-subtle)',
|
||||
color: 'var(--admin-accent)',
|
||||
}}
|
||||
>
|
||||
{board.name.charAt(0)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}>
|
||||
{board.name}
|
||||
</h3>
|
||||
{board.archived && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded" style={{ background: 'var(--border)', color: 'var(--text-tertiary)' }}>
|
||||
archived
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs truncate" style={{ color: 'var(--text-tertiary)' }}>
|
||||
/{board.slug} - {board.postCount} posts - Budget: {board.voteBudget}/{board.voteResetSchedule}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<button onClick={() => openEdit(board)} className="btn btn-ghost text-xs px-2 py-1" style={{ color: 'var(--admin-accent)' }}>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleArchive(board.id, board.archived)}
|
||||
className="btn btn-ghost text-xs px-2 py-1"
|
||||
style={{ color: board.archived ? 'var(--success)' : 'var(--warning)' }}
|
||||
>
|
||||
{board.archived ? 'Restore' : 'Archive'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{boards.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-sm mb-4" style={{ color: 'var(--text-tertiary)' }}>No boards yet</p>
|
||||
<button onClick={() => setShowCreate(true)} className="btn btn-admin">Create first board</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit modal */}
|
||||
{showCreate && (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center"
|
||||
onClick={resetForm}
|
||||
>
|
||||
<div className="absolute inset-0 fade-in" style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }} />
|
||||
<div
|
||||
className="relative w-full max-w-md mx-4 p-6 rounded-xl shadow-2xl slide-up"
|
||||
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-base font-bold mb-4" style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}>
|
||||
{editBoard ? 'Edit Board' : 'New Board'}
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Name</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.name}
|
||||
onChange={(e) => {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
name: e.target.value,
|
||||
slug: editBoard ? f.slug : slugify(e.target.value),
|
||||
}))
|
||||
}}
|
||||
placeholder="Feature Requests"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Slug</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.slug}
|
||||
onChange={(e) => setForm((f) => ({ ...f, slug: e.target.value }))}
|
||||
placeholder="feature-requests"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Description</label>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={2}
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||
placeholder="What is this board for?"
|
||||
style={{ resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Vote Budget</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.voteBudget}
|
||||
onChange={(e) => setForm((f) => ({ ...f, voteBudget: parseInt(e.target.value) || 10 }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Reset Schedule</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.voteResetSchedule}
|
||||
onChange={(e) => setForm((f) => ({ ...f, voteResetSchedule: e.target.value }))}
|
||||
>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="never">Never</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button onClick={resetForm} className="btn btn-secondary flex-1">Cancel</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !form.name.trim() || !form.slug.trim()}
|
||||
className="btn btn-admin flex-1"
|
||||
style={{ opacity: saving ? 0.6 : 1 }}
|
||||
>
|
||||
{saving ? 'Saving...' : editBoard ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
135
packages/web/src/pages/admin/AdminDashboard.tsx
Normal file
135
packages/web/src/pages/admin/AdminDashboard.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../../lib/api'
|
||||
|
||||
interface Stats {
|
||||
totalPosts: number
|
||||
byStatus: Record<string, number>
|
||||
thisWeek: number
|
||||
topUnresolved: { id: string; title: string; voteCount: number; boardSlug: string }[]
|
||||
}
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Stats>('/admin/stats')
|
||||
.then(setStats)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const statCards = stats ? [
|
||||
{ label: 'Total Posts', value: stats.totalPosts, color: 'var(--admin-accent)' },
|
||||
{ label: 'This Week', value: stats.thisWeek, color: 'var(--accent)' },
|
||||
{ label: 'Open', value: stats.byStatus['OPEN'] || 0, color: 'var(--warning)' },
|
||||
{ label: 'In Progress', value: stats.byStatus['IN_PROGRESS'] || 0, color: 'var(--info)' },
|
||||
{ label: 'Done', value: stats.byStatus['DONE'] || 0, color: 'var(--success)' },
|
||||
{ label: 'Declined', value: stats.byStatus['DECLINED'] || 0, color: 'var(--error)' },
|
||||
] : []
|
||||
|
||||
const navLinks = [
|
||||
{ to: '/admin/posts', label: 'Manage Posts', desc: 'View, filter, and respond to all posts' },
|
||||
{ to: '/admin/boards', label: 'Manage Boards', desc: 'Create, edit, and archive feedback boards' },
|
||||
]
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div
|
||||
className="w-6 h-6 border-2 rounded-full"
|
||||
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--admin-accent)', animation: 'spin 0.6s linear infinite' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1
|
||||
className="text-2xl font-bold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}
|
||||
>
|
||||
Dashboard
|
||||
</h1>
|
||||
<Link to="/" className="btn btn-ghost text-sm">
|
||||
View public site
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mb-8">
|
||||
{statCards.map((s) => (
|
||||
<div key={s.label} className="card p-4 fade-in">
|
||||
<div className="text-2xl font-bold mb-1" style={{ fontFamily: 'var(--font-heading)', color: s.color }}>
|
||||
{s.value}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{s.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Nav links */}
|
||||
<div className="grid md:grid-cols-2 gap-3 mb-8">
|
||||
{navLinks.map((link) => (
|
||||
<Link key={link.to} to={link.to} className="card p-5 block group">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3
|
||||
className="text-sm font-semibold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}
|
||||
>
|
||||
{link.label}
|
||||
</h3>
|
||||
<svg
|
||||
width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
||||
style={{ color: 'var(--text-tertiary)', transition: 'transform 200ms ease-out' }}
|
||||
className="group-hover:translate-x-0.5"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{link.desc}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Top unresolved */}
|
||||
{stats && stats.topUnresolved.length > 0 && (
|
||||
<div>
|
||||
<h2
|
||||
className="text-sm font-semibold mb-3"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
Most Voted Unresolved
|
||||
</h2>
|
||||
<div className="flex flex-col gap-1">
|
||||
{stats.topUnresolved.map((p) => (
|
||||
<Link
|
||||
key={p.id}
|
||||
to={`/b/${p.boardSlug}/post/${p.id}`}
|
||||
className="flex items-center gap-3 p-3 rounded-lg"
|
||||
style={{ transition: 'background 200ms ease-out' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--surface-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<span
|
||||
className="text-sm font-semibold w-8 text-center"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
>
|
||||
{p.voteCount}
|
||||
</span>
|
||||
<span className="text-sm flex-1 truncate" style={{ color: 'var(--text)' }}>
|
||||
{p.title}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
86
packages/web/src/pages/admin/AdminLogin.tsx
Normal file
86
packages/web/src/pages/admin/AdminLogin.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../../lib/api'
|
||||
|
||||
export default function AdminLogin() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const nav = useNavigate()
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!email || !password) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
await api.post('/admin/login', { email, password })
|
||||
nav('/admin')
|
||||
} catch {
|
||||
setError('Invalid credentials')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4" style={{ background: 'var(--bg)' }}>
|
||||
<div
|
||||
className="w-full max-w-sm p-6 rounded-xl shadow-xl fade-in"
|
||||
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
>
|
||||
<div className="text-center mb-6">
|
||||
<h1
|
||||
className="text-xl font-bold mb-1"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}
|
||||
>
|
||||
Admin Login
|
||||
</h1>
|
||||
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Echoboard administration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="flex flex-col gap-3">
|
||||
<input
|
||||
className="input"
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
style={{ borderColor: error ? 'var(--error)' : undefined }}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
style={{ borderColor: error ? 'var(--error)' : undefined }}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs" style={{ color: 'var(--error)' }}>{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn w-full mt-1"
|
||||
style={{
|
||||
background: 'var(--admin-accent)',
|
||||
color: '#141420',
|
||||
opacity: loading ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
259
packages/web/src/pages/admin/AdminPosts.tsx
Normal file
259
packages/web/src/pages/admin/AdminPosts.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../../lib/api'
|
||||
import StatusBadge from '../../components/StatusBadge'
|
||||
|
||||
interface Post {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
status: string
|
||||
voteCount: number
|
||||
commentCount: number
|
||||
authorName: string
|
||||
boardSlug: string
|
||||
boardName: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type SortField = 'createdAt' | 'voteCount' | 'status'
|
||||
|
||||
const allStatuses = ['OPEN', 'UNDER_REVIEW', 'PLANNED', 'IN_PROGRESS', 'DONE', 'DECLINED']
|
||||
|
||||
export default function AdminPosts() {
|
||||
const [posts, setPosts] = useState<Post[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [sortBy, setSortBy] = useState<SortField>('createdAt')
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [actionPost, setActionPost] = useState<Post | null>(null)
|
||||
const [newStatus, setNewStatus] = useState('')
|
||||
const [response, setResponse] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const fetchPosts = async () => {
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams({ sort: sortBy })
|
||||
if (statusFilter) params.set('status', statusFilter)
|
||||
if (search) params.set('q', search)
|
||||
|
||||
try {
|
||||
const p = await api.get<Post[]>(`/admin/posts?${params}`)
|
||||
setPosts(p)
|
||||
} catch {} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchPosts() }, [sortBy, statusFilter, search])
|
||||
|
||||
const handleStatusChange = async () => {
|
||||
if (!actionPost || !newStatus) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.patch(`/admin/posts/${actionPost.id}`, {
|
||||
status: newStatus,
|
||||
response: response || undefined,
|
||||
})
|
||||
setActionPost(null)
|
||||
setNewStatus('')
|
||||
setResponse('')
|
||||
fetchPosts()
|
||||
} catch {} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Delete this post?')) return
|
||||
try {
|
||||
await api.delete(`/admin/posts/${id}`)
|
||||
fetchPosts()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1
|
||||
className="text-2xl font-bold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}
|
||||
>
|
||||
Posts
|
||||
</h1>
|
||||
<Link to="/admin" className="btn btn-ghost text-sm">Back to dashboard</Link>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3 mb-6">
|
||||
<input
|
||||
className="input flex-1 min-w-[200px]"
|
||||
placeholder="Search posts..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="input"
|
||||
style={{ maxWidth: 160 }}
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
{allStatuses.map((s) => (
|
||||
<option key={s} value={s}>{s.replace('_', ' ')}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="input"
|
||||
style={{ maxWidth: 160 }}
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as SortField)}
|
||||
>
|
||||
<option value="createdAt">Newest</option>
|
||||
<option value="voteCount">Most Voted</option>
|
||||
<option value="status">Status</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div
|
||||
className="w-6 h-6 border-2 rounded-full"
|
||||
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--admin-accent)', animation: 'spin 0.6s linear infinite' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<th className="text-left px-4 py-3 font-medium text-xs" style={{ color: 'var(--text-tertiary)' }}>Title</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-xs" style={{ color: 'var(--text-tertiary)' }}>Board</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-xs" style={{ color: 'var(--text-tertiary)' }}>Status</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-xs" style={{ color: 'var(--text-tertiary)' }}>Votes</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-xs" style={{ color: 'var(--text-tertiary)' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{posts.map((post) => (
|
||||
<tr
|
||||
key={post.id}
|
||||
style={{ borderBottom: '1px solid var(--border)' }}
|
||||
className="group"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
to={`/b/${post.boardSlug}/post/${post.id}`}
|
||||
className="font-medium hover:underline"
|
||||
style={{ color: 'var(--text)' }}
|
||||
>
|
||||
{post.title}
|
||||
</Link>
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{post.authorName} - {new Date(post.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
{post.boardName}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge status={post.status} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right" style={{ color: 'var(--accent)' }}>
|
||||
{post.voteCount}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => { setActionPost(post); setNewStatus(post.status) }}
|
||||
className="btn btn-ghost text-xs px-2 py-1"
|
||||
style={{ color: 'var(--admin-accent)' }}
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(post.id)}
|
||||
className="btn btn-ghost text-xs px-2 py-1"
|
||||
style={{ color: 'var(--error)' }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{posts.length === 0 && (
|
||||
<div className="text-center py-8 text-sm" style={{ color: 'var(--text-tertiary)' }}>
|
||||
No posts found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action modal */}
|
||||
{actionPost && (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center"
|
||||
onClick={() => setActionPost(null)}
|
||||
>
|
||||
<div className="absolute inset-0 fade-in" style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }} />
|
||||
<div
|
||||
className="relative w-full max-w-md mx-4 p-6 rounded-xl shadow-2xl slide-up"
|
||||
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-base font-bold mb-1" style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}>
|
||||
Manage Post
|
||||
</h3>
|
||||
<p className="text-sm mb-4 truncate" style={{ color: 'var(--text-secondary)' }}>
|
||||
{actionPost.title}
|
||||
</p>
|
||||
|
||||
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
className="input mb-4"
|
||||
value={newStatus}
|
||||
onChange={(e) => setNewStatus(e.target.value)}
|
||||
>
|
||||
{allStatuses.map((s) => (
|
||||
<option key={s} value={s}>{s.replace('_', ' ')}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Admin Response (optional)
|
||||
</label>
|
||||
<textarea
|
||||
className="input mb-4"
|
||||
rows={3}
|
||||
placeholder="Add a public response..."
|
||||
value={response}
|
||||
onChange={(e) => setResponse(e.target.value)}
|
||||
style={{ resize: 'vertical' }}
|
||||
/>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => setActionPost(null)} className="btn btn-secondary flex-1">Cancel</button>
|
||||
<button
|
||||
onClick={handleStatusChange}
|
||||
disabled={saving}
|
||||
className="btn btn-admin flex-1"
|
||||
style={{ opacity: saving ? 0.6 : 1 }}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Update'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user