Initial commit: noticeboard

This commit is contained in:
jtricerolph 2026-07-01 12:09:54 +00:00
commit d91e8ac96f
21 changed files with 807 additions and 0 deletions

11
frontend/Dockerfile Normal file
View 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
View 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
View 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
View 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"
}
}

View 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
View 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>
)
}

View 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',
}

View 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
View 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
View 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
View 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
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
base: '/notices/',
plugins: [react()],
})