Initial commit: noticeboard
This commit is contained in:
commit
d91e8ac96f
21 changed files with 807 additions and 0 deletions
15
.env.example
Normal file
15
.env.example
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Shared PostgreSQL on internal network (same at all sites)
|
||||
DATABASE_URL=postgresql://noticeboard:CHANGE_ME@10.10.10.100:5432/noticeboard_db
|
||||
|
||||
# Shared HS256 secret — generate once, same value on all app LXCs
|
||||
CENTRAL_AUTH_SECRET=CHANGE_ME
|
||||
|
||||
# Offsite access restriction — supports:
|
||||
# 203.0.113.1 static public IP
|
||||
# 203.0.113.0/28 CIDR range
|
||||
# hnf.dyndns.org DDNS hostname (resolved + cached 5 min)
|
||||
# disabled no restriction (use during setup/testing)
|
||||
OFFICE_IP_CHECK=disabled
|
||||
|
||||
# Port the frontend nginx listens on (NPM proxies to this)
|
||||
FRONTEND_PORT=3080
|
||||
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
.pnp/
|
||||
.pnp.js
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
.next/
|
||||
out/
|
||||
|
||||
# Environment / secrets
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
!.env.example
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Docker volumes (if any are mounted locally)
|
||||
postgres-data/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
|
||||
# Temp
|
||||
*.tar.gz
|
||||
*.tmp
|
||||
7
backend/Dockerfile
Normal file
7
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
RUN npm install --omit=dev
|
||||
COPY src ./src
|
||||
EXPOSE 3001
|
||||
CMD ["node", "src/index.js"]
|
||||
16
backend/package.json
Normal file
16
backend/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "hnf-noticeboard-backend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^9.4.0",
|
||||
"@fastify/cors": "^9.0.1",
|
||||
"fastify": "^4.28.1",
|
||||
"jose": "^5.9.6",
|
||||
"pg": "^8.13.1"
|
||||
}
|
||||
}
|
||||
79
backend/src/auth.js
Normal file
79
backend/src/auth.js
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { jwtVerify } from 'jose'
|
||||
import dns from 'dns/promises'
|
||||
|
||||
const APP_SLUG = process.env.APP_SLUG || 'noticeboard'
|
||||
const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '')
|
||||
|
||||
// Offsite IP check — supports static IP, CIDR, DDNS hostname, or 'disabled'
|
||||
const OFFICE_IP_CHECK = process.env.OFFICE_IP_CHECK || 'disabled'
|
||||
|
||||
let cachedOfficeIP = null
|
||||
let cacheExpiry = 0
|
||||
|
||||
function ipInCidr(ip, cidr) {
|
||||
const [range, bits] = cidr.split('/')
|
||||
const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0
|
||||
const ipInt = ip.split('.').reduce((acc, oct) => (acc << 8) + parseInt(oct), 0) >>> 0
|
||||
const rangeInt = range.split('.').reduce((acc, oct) => (acc << 8) + parseInt(oct), 0) >>> 0
|
||||
return (ipInt & mask) === (rangeInt & mask)
|
||||
}
|
||||
|
||||
async function resolveOfficeIP() {
|
||||
if (OFFICE_IP_CHECK === 'disabled') return null
|
||||
if (Date.now() < cacheExpiry && cachedOfficeIP) return cachedOfficeIP
|
||||
|
||||
const isHostname = /[a-zA-Z]/.test(OFFICE_IP_CHECK) && !OFFICE_IP_CHECK.includes('/')
|
||||
|
||||
if (isHostname) {
|
||||
try {
|
||||
const addrs = await dns.resolve4(OFFICE_IP_CHECK)
|
||||
cachedOfficeIP = addrs[0]
|
||||
cacheExpiry = Date.now() + 5 * 60 * 1000
|
||||
} catch {
|
||||
// DNS failed — keep last known IP if we have one, don't lock everyone out
|
||||
}
|
||||
} else {
|
||||
cachedOfficeIP = OFFICE_IP_CHECK
|
||||
cacheExpiry = Infinity
|
||||
}
|
||||
|
||||
return cachedOfficeIP
|
||||
}
|
||||
|
||||
export async function requireAuth(request, reply) {
|
||||
const token = request.cookies?.hnf_session
|
||||
if (!token) return reply.status(401).send({ error: 'Not authenticated' })
|
||||
|
||||
let payload
|
||||
try {
|
||||
const { payload: p } = await jwtVerify(token, secret)
|
||||
payload = p
|
||||
} catch {
|
||||
return reply.status(401).send({ error: 'Invalid session' })
|
||||
}
|
||||
|
||||
if (!payload.apps?.includes(APP_SLUG)) {
|
||||
return reply.status(403).send({ error: 'No permission for this app' })
|
||||
}
|
||||
|
||||
// Offsite check — only for users without offsite_allowed flag
|
||||
if (!payload.offsite_allowed) {
|
||||
const officeIP = await resolveOfficeIP()
|
||||
if (officeIP) {
|
||||
const clientIP = request.headers['x-real-ip'] || request.ip
|
||||
const onsite = officeIP.includes('/')
|
||||
? ipInCidr(clientIP, officeIP)
|
||||
: clientIP === officeIP
|
||||
|
||||
if (!onsite) {
|
||||
return reply.status(403).send({ error: 'Access restricted to site network' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
request.user = {
|
||||
email: payload.sub,
|
||||
name: payload.name,
|
||||
is_admin: payload.is_admin ?? false,
|
||||
}
|
||||
}
|
||||
23
backend/src/db.js
Normal file
23
backend/src/db.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import pg from 'pg'
|
||||
|
||||
const { Pool } = pg
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
})
|
||||
|
||||
export async function initDb() {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS notices (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'general',
|
||||
author_name TEXT NOT NULL,
|
||||
author_email TEXT NOT NULL,
|
||||
pinned BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`)
|
||||
}
|
||||
25
backend/src/index.js
Normal file
25
backend/src/index.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import Fastify from 'fastify'
|
||||
import cookie from '@fastify/cookie'
|
||||
import cors from '@fastify/cors'
|
||||
import { initDb } from './db.js'
|
||||
import { noticeRoutes } from './routes/notices.js'
|
||||
|
||||
const app = Fastify({ logger: true, trustProxy: true })
|
||||
|
||||
await app.register(cookie)
|
||||
await app.register(cors, {
|
||||
origin: process.env.CORS_ORIGIN || false,
|
||||
credentials: true,
|
||||
})
|
||||
|
||||
app.get('/health', async () => ({ status: 'healthy' }))
|
||||
|
||||
await app.register(noticeRoutes)
|
||||
|
||||
try {
|
||||
await initDb()
|
||||
await app.listen({ port: 3001, host: '0.0.0.0' })
|
||||
} catch (err) {
|
||||
app.log.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
59
backend/src/routes/notices.js
Normal file
59
backend/src/routes/notices.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { pool } from '../db.js'
|
||||
import { requireAuth } from '../auth.js'
|
||||
|
||||
export async function noticeRoutes(app) {
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
||||
app.get('/api/notices', async (request) => {
|
||||
const { category } = request.query
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM notices
|
||||
WHERE (expires_at IS NULL OR expires_at > $1)
|
||||
${category && category !== 'all' ? 'AND category = $2' : ''}
|
||||
ORDER BY pinned DESC, created_at DESC`,
|
||||
category && category !== 'all' ? [now, category] : [now]
|
||||
)
|
||||
return result.rows
|
||||
})
|
||||
|
||||
app.post('/api/notices', async (request, reply) => {
|
||||
if (!request.user.is_admin) return reply.status(403).send({ error: 'Admins only' })
|
||||
|
||||
const { title, body, category = 'general', pinned = false, expires_at = null } = request.body
|
||||
|
||||
if (!title?.trim() || !body?.trim()) {
|
||||
return reply.status(400).send({ error: 'Title and body are required' })
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO notices (title, body, category, author_name, author_email, pinned, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||
[title.trim(), body.trim(), category, request.user.name, request.user.email, pinned, expires_at || null]
|
||||
)
|
||||
return reply.status(201).send(result.rows[0])
|
||||
})
|
||||
|
||||
app.patch('/api/notices/:id/pin', async (request, reply) => {
|
||||
if (!request.user.is_admin) return reply.status(403).send({ error: 'Admins only' })
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE notices SET pinned = NOT pinned WHERE id = $1 RETURNING *`,
|
||||
[request.params.id]
|
||||
)
|
||||
if (!result.rows.length) return reply.status(404).send({ error: 'Not found' })
|
||||
return result.rows[0]
|
||||
})
|
||||
|
||||
app.delete('/api/notices/:id', async (request, reply) => {
|
||||
if (!request.user.is_admin) return reply.status(403).send({ error: 'Admins only' })
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM notices WHERE id = $1 RETURNING id`,
|
||||
[request.params.id]
|
||||
)
|
||||
if (!result.rows.length) return reply.status(404).send({ error: 'Not found' })
|
||||
return reply.status(204).send()
|
||||
})
|
||||
}
|
||||
27
docker-compose.yml
Normal file
27
docker-compose.yml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
||||
- APP_SLUG=noticeboard
|
||||
- OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-disabled}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health || exit 1"]
|
||||
interval: 10s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3080}:80"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
default:
|
||||
driver: bridge
|
||||
11
frontend/Dockerfile
Normal file
11
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html/notices
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
14
frontend/index.html
Normal file
14
frontend/index.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="manifest" href="/notices/manifest.json" />
|
||||
<meta name="theme-color" content="#1e3a5f" />
|
||||
<title>Noticeboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
37
frontend/nginx.conf
Normal file
37
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
location = /notices/manifest.json {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /notices/api/ {
|
||||
proxy_pass http://backend:3001/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location /notices/health {
|
||||
proxy_pass http://backend:3001/health;
|
||||
}
|
||||
|
||||
location ~* /notices/.*\.(js|css|png|ico|svg|woff2?)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /notices/ {
|
||||
add_header Cache-Control "no-cache" always;
|
||||
try_files $uri $uri/ /notices/index.html;
|
||||
}
|
||||
|
||||
location = / {
|
||||
return 301 /notices/;
|
||||
}
|
||||
}
|
||||
22
frontend/package.json
Normal file
22
frontend/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "hnf-noticeboard-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
13
frontend/public/manifest.json
Normal file
13
frontend/public/manifest.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "HNF Noticeboard",
|
||||
"short_name": "Notices",
|
||||
"start_url": "/notices/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"theme_color": "#1e3a5f",
|
||||
"background_color": "#0f1f35",
|
||||
"icons": [
|
||||
{ "src": "/notices/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/notices/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
10
frontend/src/App.tsx
Normal file
10
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { AuthGate } from './components/AuthGate'
|
||||
import { NoticeBoard } from './components/NoticeBoard'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthGate>
|
||||
{user => <NoticeBoard user={user} />}
|
||||
</AuthGate>
|
||||
)
|
||||
}
|
||||
124
frontend/src/components/AuthGate.tsx
Normal file
124
frontend/src/components/AuthGate.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface User {
|
||||
email: string
|
||||
name: string
|
||||
is_admin: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
children: (user: User) => React.ReactNode
|
||||
}
|
||||
|
||||
export function AuthGate({ children }: Props) {
|
||||
const [state, setState] = useState<'checking' | 'authed' | 'login'>('checking')
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/verify?app=noticeboard', { credentials: 'include' })
|
||||
.then(async r => {
|
||||
if (r.ok) {
|
||||
const data = await r.json()
|
||||
setUser(data)
|
||||
setState('authed')
|
||||
} else {
|
||||
setState('login')
|
||||
}
|
||||
})
|
||||
.catch(() => setState('login'))
|
||||
}, [])
|
||||
|
||||
async function login(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
setError('Invalid email or password')
|
||||
return
|
||||
}
|
||||
const verify = await fetch('/api/auth/verify?app=noticeboard', { credentials: 'include' })
|
||||
if (verify.ok) {
|
||||
const data = await verify.json()
|
||||
setUser(data)
|
||||
setState('authed')
|
||||
} else {
|
||||
setError("You don't have access to this app.")
|
||||
}
|
||||
} catch {
|
||||
setError('Connection error — please try again')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (state === 'checking') {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100dvh' }}>
|
||||
<div style={{ color: 'var(--text-muted)' }}>Loading…</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state === 'login') {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
justifyContent: 'center', height: '100dvh', padding: '1.5rem',
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'var(--surface)', borderRadius: 'var(--radius)',
|
||||
padding: '2rem', width: '100%', maxWidth: '360px',
|
||||
border: '1px solid var(--surface-2)',
|
||||
}}>
|
||||
<h1 style={{ fontSize: '1.4rem', marginBottom: '0.25rem', color: 'var(--gold)' }}>
|
||||
Noticeboard
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: '1.5rem' }}>
|
||||
Hotel Number Four
|
||||
</p>
|
||||
<form onSubmit={login} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<input
|
||||
type="email" value={email} onChange={e => setEmail(e.target.value)}
|
||||
placeholder="Email" required autoComplete="email"
|
||||
style={inputStyle}
|
||||
/>
|
||||
<input
|
||||
type="password" value={password} onChange={e => setPassword(e.target.value)}
|
||||
placeholder="Password" required autoComplete="current-password"
|
||||
style={inputStyle}
|
||||
/>
|
||||
{error && <p style={{ color: 'var(--danger)', fontSize: '0.875rem' }}>{error}</p>}
|
||||
<button type="submit" disabled={loading} style={btnStyle}>
|
||||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{children(user!)}</>
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
background: 'var(--navy-dark)', border: '1px solid var(--surface-2)',
|
||||
borderRadius: '6px', color: 'var(--text)', padding: '0.625rem 0.75rem',
|
||||
fontSize: '1rem', width: '100%', outline: 'none',
|
||||
}
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
background: 'var(--gold)', color: 'var(--navy-dark)', border: 'none',
|
||||
borderRadius: '6px', padding: '0.625rem', fontSize: '1rem',
|
||||
fontWeight: 600, marginTop: '0.25rem',
|
||||
}
|
||||
221
frontend/src/components/NoticeBoard.tsx
Normal file
221
frontend/src/components/NoticeBoard.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { useEffect, useReducer, useState } from 'react'
|
||||
|
||||
interface Notice {
|
||||
id: number
|
||||
title: string
|
||||
body: string
|
||||
category: string
|
||||
author_name: string
|
||||
pinned: boolean
|
||||
created_at: string
|
||||
expires_at: string | null
|
||||
}
|
||||
|
||||
interface User { email: string; name: string; is_admin: boolean }
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'general', label: 'General' },
|
||||
{ value: 'kitchen', label: 'Kitchen' },
|
||||
{ value: 'housekeeping', label: 'Housekeeping' },
|
||||
{ value: 'management', label: 'Management' },
|
||||
]
|
||||
|
||||
export function NoticeBoard({ user }: { user: User }) {
|
||||
const [notices, setNotices] = useState<Notice[]>([])
|
||||
const [category, setCategory] = useState('all')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [, forceRefresh] = useReducer(x => x + 1, 0)
|
||||
|
||||
useEffect(() => {
|
||||
const params = category !== 'all' ? `?category=${category}` : ''
|
||||
fetch(`/api/notices${params}`, { credentials: 'include' })
|
||||
.then(r => r.json())
|
||||
.then(setNotices)
|
||||
}, [category, forceRefresh])
|
||||
|
||||
async function togglePin(id: number) {
|
||||
await fetch(`/api/notices/${id}/pin`, { method: 'PATCH', credentials: 'include' })
|
||||
forceRefresh()
|
||||
}
|
||||
|
||||
async function deleteNotice(id: number) {
|
||||
if (!confirm('Delete this notice?')) return
|
||||
await fetch(`/api/notices/${id}`, { method: 'DELETE', credentials: 'include' })
|
||||
forceRefresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '720px', margin: '0 auto', padding: '1rem' }}>
|
||||
<header style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
marginBottom: '1.25rem', paddingBottom: '1rem',
|
||||
borderBottom: '1px solid var(--surface-2)',
|
||||
}}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '1.25rem', color: 'var(--gold)' }}>Noticeboard</h1>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{user.name}</p>
|
||||
</div>
|
||||
{user.is_admin && (
|
||||
<button onClick={() => setShowForm(v => !v)} style={{
|
||||
background: showForm ? 'var(--surface-2)' : 'var(--gold)',
|
||||
color: showForm ? 'var(--text)' : 'var(--navy-dark)',
|
||||
border: 'none', borderRadius: '6px', padding: '0.5rem 1rem',
|
||||
fontSize: '0.875rem', fontWeight: 600,
|
||||
}}>
|
||||
{showForm ? 'Cancel' : '+ Post'}
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{showForm && user.is_admin && (
|
||||
<PostForm user={user} onPosted={() => { setShowForm(false); forceRefresh() }} />
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||
{CATEGORIES.map(c => (
|
||||
<button key={c.value} onClick={() => setCategory(c.value)} style={{
|
||||
background: category === c.value ? 'var(--gold)' : 'var(--surface)',
|
||||
color: category === c.value ? 'var(--navy-dark)' : 'var(--text-muted)',
|
||||
border: '1px solid var(--surface-2)', borderRadius: '20px',
|
||||
padding: '0.3rem 0.875rem', fontSize: '0.8rem', fontWeight: 600,
|
||||
}}>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{notices.length === 0 && (
|
||||
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '3rem 0', fontSize: '0.9rem' }}>
|
||||
No notices yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{notices.map(n => (
|
||||
<div key={n.id} style={{
|
||||
background: 'var(--surface)', borderRadius: 'var(--radius)',
|
||||
padding: '1rem 1.125rem',
|
||||
border: n.pinned ? '1px solid var(--gold)' : '1px solid var(--surface-2)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.375rem' }}>
|
||||
{n.pinned && (
|
||||
<span style={{ fontSize: '0.7rem', background: 'var(--gold)', color: 'var(--navy-dark)',
|
||||
borderRadius: '4px', padding: '0.1rem 0.4rem', fontWeight: 700 }}>
|
||||
PINNED
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: '0.7rem', background: 'var(--surface-2)', color: 'var(--text-muted)',
|
||||
borderRadius: '4px', padding: '0.1rem 0.4rem', textTransform: 'uppercase' }}>
|
||||
{n.category}
|
||||
</span>
|
||||
</div>
|
||||
<h2 style={{ fontSize: '1rem', marginBottom: '0.4rem' }}>{n.title}</h2>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', lineHeight: 1.5, whiteSpace: 'pre-wrap' }}>
|
||||
{n.body}
|
||||
</p>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.75rem', marginTop: '0.75rem' }}>
|
||||
{n.author_name} · {new Date(n.created_at).toLocaleDateString('en-GB', {
|
||||
day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
})}
|
||||
{n.expires_at && ` · expires ${new Date(n.expires_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}`}
|
||||
</p>
|
||||
</div>
|
||||
{user.is_admin && (
|
||||
<div style={{ display: 'flex', gap: '0.4rem', flexShrink: 0 }}>
|
||||
<button onClick={() => togglePin(n.id)} title={n.pinned ? 'Unpin' : 'Pin'} style={iconBtn}>
|
||||
{n.pinned ? '📌' : '📍'}
|
||||
</button>
|
||||
<button onClick={() => deleteNotice(n.id)} title="Delete" style={{ ...iconBtn, color: 'var(--danger)' }}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PostForm({ user, onPosted }: { user: User; onPosted: () => void }) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [category, setCategory] = useState('general')
|
||||
const [pinned, setPinned] = useState(false)
|
||||
const [expires, setExpires] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch('/api/notices', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, body, category, pinned, expires_at: expires || null }),
|
||||
})
|
||||
if (res.ok) onPosted()
|
||||
else setError('Failed to post notice')
|
||||
} catch {
|
||||
setError('Connection error')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} style={{
|
||||
background: 'var(--surface)', borderRadius: 'var(--radius)',
|
||||
padding: '1.25rem', marginBottom: '1.25rem',
|
||||
border: '1px solid var(--gold)', display: 'flex', flexDirection: 'column', gap: '0.75rem',
|
||||
}}>
|
||||
<h2 style={{ fontSize: '0.9rem', color: 'var(--gold)', marginBottom: '0.25rem' }}>New Notice</h2>
|
||||
<input value={title} onChange={e => setTitle(e.target.value)}
|
||||
placeholder="Title" required style={inputStyle} />
|
||||
<textarea value={body} onChange={e => setBody(e.target.value)}
|
||||
placeholder="Notice content…" required rows={4}
|
||||
style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.5 }} />
|
||||
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<select value={category} onChange={e => setCategory(e.target.value)} style={{ ...inputStyle, flex: 1 }}>
|
||||
{CATEGORIES.filter(c => c.value !== 'all').map(c => (
|
||||
<option key={c.value} value={c.value}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<input type="date" value={expires} onChange={e => setExpires(e.target.value)}
|
||||
style={{ ...inputStyle, flex: 1 }} title="Expires (optional)" />
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.875rem', cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={pinned} onChange={e => setPinned(e.target.checked)} />
|
||||
Pin to top
|
||||
</label>
|
||||
{error && <p style={{ color: 'var(--danger)', fontSize: '0.875rem' }}>{error}</p>}
|
||||
<button type="submit" disabled={submitting} style={submitBtn}>
|
||||
{submitting ? 'Posting…' : 'Post notice'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
background: 'var(--navy-dark)', border: '1px solid var(--surface-2)',
|
||||
borderRadius: '6px', color: 'var(--text)', padding: '0.6rem 0.75rem',
|
||||
fontSize: '0.9rem', width: '100%', outline: 'none',
|
||||
}
|
||||
|
||||
const iconBtn: React.CSSProperties = {
|
||||
background: 'none', border: 'none', fontSize: '1rem',
|
||||
padding: '0.25rem', borderRadius: '4px', color: 'var(--text-muted)',
|
||||
lineHeight: 1,
|
||||
}
|
||||
|
||||
const submitBtn: React.CSSProperties = {
|
||||
background: 'var(--gold)', color: 'var(--navy-dark)', border: 'none',
|
||||
borderRadius: '6px', padding: '0.625rem', fontSize: '0.9rem', fontWeight: 600,
|
||||
}
|
||||
27
frontend/src/index.css
Normal file
27
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--navy: #1e3a5f;
|
||||
--navy-dark: #0f1f35;
|
||||
--gold: #c9a84c;
|
||||
--gold-light: #e8c96d;
|
||||
--surface: #1a2d47;
|
||||
--surface-2: #243d5c;
|
||||
--text: #e8edf2;
|
||||
--text-muted: #8ba3bc;
|
||||
--danger: #e05252;
|
||||
--radius: 10px;
|
||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--navy-dark);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
15
frontend/tsconfig.json
Normal file
15
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
frontend/vite.config.ts
Normal file
7
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
base: '/notices/',
|
||||
plugins: [react()],
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue