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

15
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}

View 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
View 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
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()],
})