Initial commit: noticeboard
This commit is contained in:
commit
d91e8ac96f
21 changed files with 807 additions and 0 deletions
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>
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue