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