changing layout
This commit is contained in:
parent
17b5f751e2
commit
b1c6aa659b
37 changed files with 695 additions and 1018 deletions
|
|
@ -1,6 +1,8 @@
|
|||
# Leave empty in development; Vite proxies API requests to localhost:8080.
|
||||
# Set this to the public backend URL when the frontend is hosted separately.
|
||||
#
|
||||
# There is no sign-in endpoint: the upstream SSO proxy authenticates the owner
|
||||
# and the backend trusts the header it forwards.
|
||||
VITE_API_BASE_URL=
|
||||
VITE_GOOGLE_AUTH_ENDPOINT=/api/auth/google
|
||||
VITE_SESSION_ENDPOINT=/api/auth/session
|
||||
VITE_LOGOUT_ENDPOINT=/api/auth/logout
|
||||
VITE_CONTENT_ENDPOINT=/api/content
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@
|
|||
This Vite/React app lives in `ui/app` and is intentionally independent of the Go backend. Run
|
||||
`npm install`, then `npm run dev` or `npm run build`.
|
||||
|
||||
The API defaults to same-origin `/api/auth/session`, `/api/auth/google`, and
|
||||
`/api/auth/logout`. Set `VITE_API_BASE_URL` for a separate backend, or override the endpoint
|
||||
paths with `VITE_SESSION_ENDPOINT`, `VITE_GOOGLE_AUTH_ENDPOINT`, and `VITE_LOGOUT_ENDPOINT`.
|
||||
The Google endpoint may return JSON containing `user` and an optional `token`/`accessToken`, or a
|
||||
redirect response. Sessions use cookies (`credentials: include`) and tokens are retained only for
|
||||
the current browser tab. Provider-specific login is isolated in `src/api.ts` so Facebook can be
|
||||
added without changing the routes or UI.
|
||||
There is no sign-in screen. Content is public and read-only for anonymous visitors; the Edit
|
||||
button appears only when the backend reports `canEdit: true`, which happens when the upstream SSO
|
||||
proxy asserted the owner's identity on the request. `/api/auth/session` returns
|
||||
`{ "authenticated": bool, "canEdit": bool }` and never creates a session.
|
||||
|
||||
The API defaults to same-origin `/api/auth/session` and `/api/content`. Set `VITE_API_BASE_URL`
|
||||
for a separate backend, or override the endpoint paths with `VITE_SESSION_ENDPOINT` and
|
||||
`VITE_CONTENT_ENDPOINT`. Requests use cookies (`credentials: include`) so the edge proxy cookie
|
||||
travels with them.
|
||||
|
|
@ -1,219 +1,297 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import { getContent, listBadges, listCategories, listContent, updateContent, type Badge, type Category, type Content, type ContentUpdate } from './api'
|
||||
import { useAuth } from './auth'
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
import {Link, Navigate, Route, Routes, useParams} from 'react-router-dom'
|
||||
import {
|
||||
type Badge,
|
||||
type Category,
|
||||
type Content,
|
||||
type ContentUpdate,
|
||||
getContent,
|
||||
listBadges,
|
||||
listCategories,
|
||||
listContent,
|
||||
updateContent
|
||||
} from './api'
|
||||
import {useAuth} from './auth'
|
||||
|
||||
function Shell({ children }: { children: React.ReactNode }) {
|
||||
const { user, signOut } = useAuth()
|
||||
const [dark, setDark] = useState(() => window.localStorage.getItem('urpi-theme') !== 'light')
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
|
||||
window.localStorage.setItem('urpi-theme', dark ? 'dark' : 'light')
|
||||
}, [dark])
|
||||
return <main className="shell">
|
||||
<header className="header"><Link className="brand" to="/"><span className="brand-prompt">~/</span> Urpi's backlog<span className="brand-cursor">_</span><small>linux · go · java · ideas</small></Link><div className="header-actions"><button className="theme-button" type="button" aria-label={`Switch to ${dark ? 'light' : 'dark'} theme`} onClick={() => setDark(value => !value)}>{dark ? '☀ Light' : '☾ Dark'}</button>{user && <button className="text-button" onClick={() => void signOut()}>Sign out</button>}</div></header>
|
||||
{children}
|
||||
</main>
|
||||
function Shell({children}: { children: React.ReactNode }) {
|
||||
const {authenticated, loading} = useAuth()
|
||||
const [dark, setDark] = useState(() => window.localStorage.getItem('urpi-theme') !== 'light')
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
|
||||
window.localStorage.setItem('urpi-theme', dark ? 'dark' : 'light')
|
||||
}, [dark])
|
||||
return <main className="shell">
|
||||
<header className="header"><Link className="brand" to="/content"><span className="brand-prompt">~/</span> Urpi's
|
||||
backlog<span className="brand-cursor">_</span><small>linux · go · java · ideas</small></Link>
|
||||
<div className="header-actions">{!loading &&
|
||||
<span className="mode-badge">{authenticated ? 'Owner · SSO' : 'Anonymous · read-only'}</span>}
|
||||
<button className="theme-button" type="button" aria-label={`Switch to ${dark ? 'light' : 'dark'} theme`}
|
||||
onClick={() => setDark(value => !value)}>{dark ? '☀ Light' : '☾ Dark'}</button>
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</main>
|
||||
}
|
||||
|
||||
function Login() {
|
||||
const { user, loading, error, signIn } = useAuth()
|
||||
if (user) return <Navigate to="/protected" replace />
|
||||
if (loading) return <Shell><div className="card"><p>Checking your session…</p></div></Shell>
|
||||
return <Shell><section className="card auth-card">
|
||||
<span className="eyebrow">Welcome, developer</span><h1>Sign in to Urpi's backlog</h1>
|
||||
<p className="muted">A cozy backlog for Linux, Go, Java, and the ideas between them.</p>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
<button className="google-button" onClick={() => void signIn()}><span aria-hidden="true">G</span> Continue with Google</button>
|
||||
</section></Shell>
|
||||
}
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth()
|
||||
const location = useLocation()
|
||||
if (loading) return <Shell><div className="card"><p>Loading…</p></div></Shell>
|
||||
if (!user) return <Navigate to="/" state={{ from: location }} replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function ContentCard({ item }: { item: Content }) {
|
||||
return <article className="content-card">
|
||||
<span className="status">{item.status}</span>
|
||||
<h2><Link to={`/content/${item.id}`}>{item.title}</Link></h2>
|
||||
<p>{item.summary}</p>
|
||||
{item.badges && item.badges.length > 0 && <div className="badge-list">{item.badges.map(badge => <span className="badge" key={badge}>{badge}</span>)}</div>}
|
||||
<Link className="read-link" to={`/content/${item.id}`}>Read more <span aria-hidden="true">→</span></Link>
|
||||
</article>
|
||||
function ContentCard({item}: { item: Content }) {
|
||||
return <article className="content-card">
|
||||
<span className="status">{item.status}</span>
|
||||
<h2><Link to={`/content/${item.id}`}>{item.title}</Link></h2>
|
||||
<p>{item.summary}</p>
|
||||
{item.badges && item.badges.length > 0 &&
|
||||
<div className="badge-list">{item.badges.map(badge => <span className="badge"
|
||||
key={badge}>{badge}</span>)}</div>}
|
||||
<Link className="read-link" to={`/content/${item.id}`}>Read more <span aria-hidden="true">→</span></Link>
|
||||
</article>
|
||||
}
|
||||
|
||||
function ContentList() {
|
||||
const [items, setItems] = useState<Content[]>([])
|
||||
const [cursor, setCursor] = useState<string | null | undefined>(undefined)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [badgeOptions, setBadgeOptions] = useState<Badge[]>([])
|
||||
const [category, setCategory] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [badges, setBadges] = useState<string[]>([])
|
||||
const [badgeInput, setBadgeInput] = useState('')
|
||||
const loadingMore = useRef(false)
|
||||
const requestVersion = useRef(0)
|
||||
const sentinel = useRef<HTMLDivElement>(null)
|
||||
const filters = { category, status, badges }
|
||||
const [items, setItems] = useState<Content[]>([])
|
||||
const [cursor, setCursor] = useState<string | null | undefined>(undefined)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [badgeOptions, setBadgeOptions] = useState<Badge[]>([])
|
||||
const [category, setCategory] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [badges, setBadges] = useState<string[]>([])
|
||||
const [badgeInput, setBadgeInput] = useState('')
|
||||
const loadingMore = useRef(false)
|
||||
const requestVersion = useRef(0)
|
||||
const sentinel = useRef<HTMLDivElement>(null)
|
||||
const filters = {category, status, badges}
|
||||
|
||||
const load = async (next = false, activeFilters = filters) => {
|
||||
if (loadingMore.current || (next && !cursor)) return
|
||||
const version = next ? requestVersion.current : ++requestVersion.current
|
||||
loadingMore.current = true
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await listContent(next ? cursor : undefined, activeFilters.category, activeFilters.status, activeFilters.badges)
|
||||
if (version !== requestVersion.current) return
|
||||
const page = Array.isArray(response) ? response : response.items ?? response.content ?? response.data ?? []
|
||||
setItems(current => next ? [...current, ...page] : page)
|
||||
setCursor(Array.isArray(response) ? null : response.nextCursor ?? response.cursor ?? (response.hasMore ? undefined : null))
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to load content.')
|
||||
} finally {
|
||||
if (version === requestVersion.current) {
|
||||
loadingMore.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
const load = async (next = false, activeFilters = filters) => {
|
||||
if (loadingMore.current || (next && !cursor)) return
|
||||
const version = next ? requestVersion.current : ++requestVersion.current
|
||||
loadingMore.current = true
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await listContent(next ? cursor : undefined, activeFilters.category, activeFilters.status, activeFilters.badges)
|
||||
if (version !== requestVersion.current) return
|
||||
const page = Array.isArray(response) ? response : response.items ?? response.content ?? response.data ?? []
|
||||
setItems(current => next ? [...current, ...page] : page)
|
||||
setCursor(Array.isArray(response) ? null : response.nextCursor ?? response.cursor ?? (response.hasMore ? undefined : null))
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to load content.')
|
||||
} finally {
|
||||
if (version === requestVersion.current) {
|
||||
loadingMore.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
void Promise.all([listCategories(), listBadges()])
|
||||
.then(([nextCategories, nextBadges]) => { setCategories(nextCategories); setBadgeOptions(nextBadges) })
|
||||
.catch(() => { setCategories([]); setBadgeOptions([]) })
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
loadingMore.current = false
|
||||
setItems([])
|
||||
setCursor(undefined)
|
||||
void load(false, { category, status, badges })
|
||||
}, [category, status, badges.join(',')])
|
||||
useEffect(() => {
|
||||
const node = sentinel.current
|
||||
if (!node) return
|
||||
const observer = new IntersectionObserver(entries => { if (entries[0]?.isIntersecting) void load(true) }, { rootMargin: '240px' })
|
||||
observer.observe(node)
|
||||
return () => observer.disconnect()
|
||||
}, [cursor, category, status, badges.join(',')])
|
||||
|
||||
return <Shell><section className="content-page">
|
||||
<div className="page-heading"><div><span className="eyebrow">$ cd ~/backlog && ls</span></div></div>
|
||||
<div className="filters" aria-label="Content filters">
|
||||
<label className="status-filter">Status
|
||||
<select value={status} onChange={event => setStatus(event.target.value)}>
|
||||
<option value="">All statuses</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="review">Review</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="category-filter">Category
|
||||
<select value={category} onChange={event => setCategory(event.target.value)}>
|
||||
<option value="">All categories</option>
|
||||
{categories.map(item => <option value={item.slug} key={item.slug}>{item.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="badge-filter">Badges
|
||||
<div className="badge-input">
|
||||
{badges.map(badge => <span className="filter-pill" key={badge}>{badge}<button type="button" aria-label={`Remove ${badge}`} onClick={() => setBadges(current => current.filter(value => value !== badge))}>×</button></span>)}
|
||||
<input list="badge-options" value={badgeInput} placeholder="Type a badge and press Enter" onChange={event => setBadgeInput(event.target.value)} onKeyDown={event => {
|
||||
if (event.key !== 'Enter') return
|
||||
event.preventDefault()
|
||||
const badge = badgeInput.trim().toLowerCase()
|
||||
if (badge && !badges.includes(badge)) setBadges(current => [...current, badge])
|
||||
setBadgeInput('')
|
||||
}} />
|
||||
<datalist id="badge-options">{badgeOptions.filter(item => !badges.includes(item.name)).map(item => <option value={item.name} key={item.id}>{item.name}</option>)}</datalist>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
{!loading && !error && items.length === 0 && <div className="card empty-state"><h2>No content yet</h2><p className="muted">There is nothing published here yet.</p></div>}
|
||||
<div className="content-grid">{items.map(item => <ContentCard item={item} key={item.id} />)}</div>
|
||||
<div ref={sentinel} className="list-status" aria-live="polite">{loading ? <p className="muted">Loading content…</p> : cursor ? <p className="muted">Loading more…</p> : items.length > 0 ? <p className="muted">You’ve reached the end.</p> : null}</div>
|
||||
</section></Shell>
|
||||
useEffect(() => {
|
||||
void Promise.all([listCategories(), listBadges()])
|
||||
.then(([nextCategories, nextBadges]) => {
|
||||
setCategories(nextCategories);
|
||||
setBadgeOptions(nextBadges)
|
||||
})
|
||||
.catch(() => {
|
||||
setCategories([]);
|
||||
setBadgeOptions([])
|
||||
})
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
loadingMore.current = false
|
||||
setItems([])
|
||||
setCursor(undefined)
|
||||
void load(false, {category, status, badges})
|
||||
}, [category, status, badges.join(',')])
|
||||
useEffect(() => {
|
||||
const node = sentinel.current
|
||||
if (!node) return
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
if (entries[0]?.isIntersecting) void load(true)
|
||||
}, {rootMargin: '240px'})
|
||||
observer.observe(node)
|
||||
return () => observer.disconnect()
|
||||
}, [cursor, category, status, badges.join(',')])
|
||||
return <Shell>
|
||||
<section className="content-page">
|
||||
<div className="page-heading">
|
||||
<div><span className="eyebrow">$ cd ~/backlog && ls</span></div>
|
||||
</div>
|
||||
<div className="filters" aria-label="Content filters">
|
||||
<label className="status-filter">Status
|
||||
<select value={status} onChange={event => setStatus(event.target.value)}>
|
||||
<option value="">All statuses</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="review">Review</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="category-filter">Category
|
||||
<select value={category} onChange={event => setCategory(event.target.value)}>
|
||||
<option value="">All categories</option>
|
||||
{categories.map(item => <option value={item.slug} key={item.slug}>{item.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="badge-filter">Badges
|
||||
<div className="badge-input">
|
||||
{badges.map(badge => <span className="filter-pill" key={badge}>{badge}
|
||||
<button type="button" aria-label={`Remove ${badge}`}
|
||||
onClick={() => setBadges(current => current.filter(value => value !== badge))}>×</button></span>)}
|
||||
<input list="badge-options" value={badgeInput} placeholder="Type a badge and press Enter"
|
||||
onChange={event => setBadgeInput(event.target.value)} onKeyDown={event => {
|
||||
if (event.key !== 'Enter') return
|
||||
event.preventDefault()
|
||||
const badge = badgeInput.trim().toLowerCase()
|
||||
if (badge && !badges.includes(badge)) setBadges(current => [...current, badge])
|
||||
setBadgeInput('')
|
||||
}}/>
|
||||
<datalist
|
||||
id="badge-options">{badgeOptions.filter(item => !badges.includes(item.name)).map(item =>
|
||||
<option value={item.name} key={item.id}>{item.name}</option>)}</datalist>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
{!loading && !error && items.length === 0 &&
|
||||
<div className="card empty-state"><h2>No content yet</h2><p className="muted">There is nothing published
|
||||
here yet.</p></div>}
|
||||
<div className="content-grid">{items.map(item => <ContentCard item={item} key={item.id}/>)}</div>
|
||||
<div ref={sentinel} className="list-status" aria-live="polite">{loading ?
|
||||
<p className="muted">Loading content…</p> : cursor ?
|
||||
<p className="muted">Loading more…</p> : items.length > 0 ?
|
||||
<p className="muted">You’ve reached the end.</p> : null}</div>
|
||||
</section>
|
||||
</Shell>
|
||||
}
|
||||
|
||||
function ContentDetail() {
|
||||
const { id = '' } = useParams()
|
||||
const { user } = useAuth()
|
||||
const canEdit = Boolean(user?.capabilities?.canEdit)
|
||||
const [item, setItem] = useState<Content | null>(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState<ContentUpdate | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [badgeOptions, setBadgeOptions] = useState<Badge[]>([])
|
||||
const [badgeInput, setBadgeInput] = useState('')
|
||||
const navigate = useNavigate()
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setLoading(true)
|
||||
void Promise.all([getContent(id), listBadges()]).then(([value, options]) => {
|
||||
if (active) {
|
||||
setItem(value)
|
||||
setBadgeOptions(options)
|
||||
setDraft({ title: value.title, summary: value.summary, body: value.body, status: value.status, badges: value.badges ?? [] })
|
||||
}
|
||||
})
|
||||
.catch(err => active && setError(err instanceof Error ? err.message : 'Unable to load content.'))
|
||||
.finally(() => active && setLoading(false))
|
||||
return () => { active = false }
|
||||
}, [id])
|
||||
if (loading) return <Shell><div className="card"><p>Loading content…</p></div></Shell>
|
||||
if (error || !item || !draft) return <Shell><div className="card"><p className="error" role="alert">{error ?? 'Content not found.'}</p><Link to="/content">Back to content</Link></div></Shell>
|
||||
const save = async () => {
|
||||
setSaving(true); setError(null)
|
||||
try { const saved = await updateContent(id, draft); setItem(saved); setDraft({ title: saved.title, summary: saved.summary, body: saved.body, status: saved.status, badges: saved.badges ?? [] }); setEditing(false) }
|
||||
catch (err) { setError(err instanceof Error ? err.message : 'Unable to save changes.') }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
return <Shell><article className="detail-page">
|
||||
<Link className="back-link" to="/content">← All content</Link>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
{editing ? <div className="editor">
|
||||
<label>Title<input value={draft.title} onChange={e => setDraft({ ...draft, title: e.target.value })} /></label>
|
||||
<label>Summary<textarea rows={3} value={draft.summary} onChange={e => setDraft({ ...draft, summary: e.target.value })} /></label>
|
||||
<label>Body<textarea rows={12} value={draft.body} onChange={e => setDraft({ ...draft, body: e.target.value })} /></label>
|
||||
<label>Status<select value={draft.status} onChange={e => setDraft({ ...draft, status: e.target.value })}><option value="draft">Draft</option><option value="published">Published</option><option value="archived">Archived</option></select></label>
|
||||
<label>Badges
|
||||
<div className="badge-input">
|
||||
{(draft.badges ?? []).map(badge => <span className="filter-pill" key={badge}>{badge}<button type="button" aria-label={`Remove ${badge}`} onClick={() => setDraft({ ...draft, badges: (draft.badges ?? []).filter(value => value !== badge) })}>×</button></span>)}
|
||||
<input list="detail-badge-options" value={badgeInput} placeholder="Type a badge and press Enter" onChange={e => setBadgeInput(e.target.value)} onKeyDown={e => {
|
||||
if (e.key !== 'Enter') return
|
||||
e.preventDefault()
|
||||
const badge = badgeInput.trim().toLowerCase()
|
||||
if (badge && !(draft.badges ?? []).includes(badge)) setDraft({ ...draft, badges: [...(draft.badges ?? []), badge] })
|
||||
setBadgeInput('')
|
||||
}} />
|
||||
<datalist id="detail-badge-options">{badgeOptions.filter(option => !(draft.badges ?? []).includes(option.name)).map(option => <option value={option.name} key={option.id} />)}</datalist>
|
||||
</div>
|
||||
</label>
|
||||
<div className="actions"><button className="primary-button" disabled={saving} onClick={() => void save()}>{saving ? 'Saving…' : 'Save changes'}</button><button className="secondary-button" onClick={() => setEditing(false)}>Cancel</button></div>
|
||||
</div> : <><div className="detail-heading"><span className="status">{item.status}</span><h1>{item.title}</h1><p className="muted">{item.summary}</p>{item.badges && item.badges.length > 0 && <div className="badge-list">{item.badges.map(badge => <span className="badge" key={badge}>{badge}</span>)}</div>}{canEdit && <button className="primary-button edit-button" onClick={() => setEditing(true)}>Edit</button>}</div><div className="body-copy">{item.body}</div></>}
|
||||
</article></Shell>
|
||||
const {id = ''} = useParams()
|
||||
const {canEdit} = useAuth()
|
||||
const [item, setItem] = useState<Content | null>(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState<ContentUpdate | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [badgeOptions, setBadgeOptions] = useState<Badge[]>([])
|
||||
const [badgeInput, setBadgeInput] = useState('')
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setLoading(true)
|
||||
void Promise.all([getContent(id), listBadges()]).then(([value, options]) => {
|
||||
if (active) {
|
||||
setItem(value)
|
||||
setBadgeOptions(options)
|
||||
setDraft({
|
||||
title: value.title,
|
||||
summary: value.summary,
|
||||
body: value.body,
|
||||
status: value.status,
|
||||
badges: value.badges ?? []
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(err => active && setError(err instanceof Error ? err.message : 'Unable to load content.'))
|
||||
.finally(() => active && setLoading(false))
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [id])
|
||||
if (loading) return <Shell>
|
||||
<div className="card"><p>Loading content…</p></div>
|
||||
</Shell>
|
||||
if (error || !item || !draft) return <Shell>
|
||||
<div className="card"><p className="error" role="alert">{error ?? 'Content not found.'}</p><Link to="/content">Back
|
||||
to content</Link></div>
|
||||
</Shell>
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
setError(null)
|
||||
try {
|
||||
const saved = await updateContent(id, draft);
|
||||
setItem(saved);
|
||||
setDraft({
|
||||
title: saved.title,
|
||||
summary: saved.summary,
|
||||
body: saved.body,
|
||||
status: saved.status,
|
||||
badges: saved.badges ?? []
|
||||
});
|
||||
setEditing(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to save changes.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
return <Shell>
|
||||
<article className="detail-page">
|
||||
<Link className="back-link" to="/content">← All content</Link>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
{editing ? <div className="editor">
|
||||
<label>Title<input value={draft.title}
|
||||
onChange={e => setDraft({...draft, title: e.target.value})}/></label>
|
||||
<label>Summary<textarea rows={3} value={draft.summary}
|
||||
onChange={e => setDraft({...draft, summary: e.target.value})}/></label>
|
||||
<label>Body<textarea rows={12} value={draft.body}
|
||||
onChange={e => setDraft({...draft, body: e.target.value})}/></label>
|
||||
<label>Status<select value={draft.status} onChange={e => setDraft({...draft, status: e.target.value})}>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select></label>
|
||||
<label>Badges
|
||||
<div className="badge-input">
|
||||
{(draft.badges ?? []).map(badge => <span className="filter-pill" key={badge}>{badge}
|
||||
<button type="button" aria-label={`Remove ${badge}`} onClick={() => setDraft({
|
||||
...draft,
|
||||
badges: (draft.badges ?? []).filter(value => value !== badge)
|
||||
})}>×</button></span>)}
|
||||
<input list="detail-badge-options" value={badgeInput} placeholder="Type a badge and press Enter"
|
||||
onChange={e => setBadgeInput(e.target.value)} onKeyDown={e => {
|
||||
if (e.key !== 'Enter') return
|
||||
e.preventDefault()
|
||||
const badge = badgeInput.trim().toLowerCase()
|
||||
if (badge && !(draft.badges ?? []).includes(badge)) setDraft({
|
||||
...draft,
|
||||
badges: [...(draft.badges ?? []), badge]
|
||||
})
|
||||
setBadgeInput('')
|
||||
}}/>
|
||||
<datalist
|
||||
id="detail-badge-options">{badgeOptions.filter(option => !(draft.badges ?? []).includes(option.name)).map(option =>
|
||||
<option value={option.name} key={option.id}/>)}</datalist>
|
||||
</div>
|
||||
</label>
|
||||
<div className="actions">
|
||||
<button className="primary-button" disabled={saving}
|
||||
onClick={() => void save()}>{saving ? 'Saving…' : 'Save changes'}</button>
|
||||
<button className="secondary-button" onClick={() => setEditing(false)}>Cancel</button>
|
||||
</div>
|
||||
</div> : <>
|
||||
<div className="detail-heading"><span className="status">{item.status}</span><h1>{item.title}</h1><p
|
||||
className="muted">{item.summary}</p>{item.badges && item.badges.length > 0 &&
|
||||
<div className="badge-list">{item.badges.map(badge => <span className="badge"
|
||||
key={badge}>{badge}</span>)}</div>}{canEdit &&
|
||||
<button className="primary-button edit-button" onClick={() => setEditing(true)}>Edit</button>}</div>
|
||||
<div className="body-copy">{item.body}</div>
|
||||
</>}
|
||||
</article>
|
||||
</Shell>
|
||||
}
|
||||
|
||||
function ProtectedTest() {
|
||||
const { user } = useAuth()
|
||||
return <Shell><section className="card protected-card"><span className="eyebrow">Authenticated</span><h1>Protected content</h1><p>You are signed in and can access protected content.</p><div className="profile">{user?.email ?? user?.name ? `Signed in as ${user.email ?? user.name}` : 'Your session is active.'}</div><Link className="primary-button inline-button" to="/content">Browse content</Link></section></Shell>
|
||||
return <Shell>
|
||||
<section className="card protected-card"><span className="eyebrow">Owner session</span><h1>Protected
|
||||
content</h1><p>Your SSO proxy asserted the owner identity for this request.</p><Link
|
||||
className="primary-button inline-button" to="/content">Browse content</Link></section>
|
||||
</Shell>
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return <Routes>
|
||||
<Route path="/" element={<Login />} />
|
||||
<Route path="/protected" element={<RequireAuth><ProtectedTest /></RequireAuth>} />
|
||||
<Route path="/test" element={<RequireAuth><ProtectedTest /></RequireAuth>} />
|
||||
<Route path="/content" element={<RequireAuth><ContentList /></RequireAuth>} />
|
||||
<Route path="/content/:id" element={<RequireAuth><ContentDetail /></RequireAuth>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
return <Routes>
|
||||
<Route path="/" element={<Navigate to="/content" replace/>}/>
|
||||
<Route path="/protected" element={<ProtectedTest/>}/>
|
||||
<Route path="/test" element={<ProtectedTest/>}/>
|
||||
<Route path="/content" element={<ContentList/>}/>
|
||||
<Route path="/content/:id" element={<ContentDetail/>}/>
|
||||
<Route path="*" element={<Navigate to="/content" replace/>}/>
|
||||
</Routes>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,21 +6,16 @@ export type User = {
|
|||
capabilities?: Record<string, boolean>
|
||||
}
|
||||
|
||||
// The backend never signs users in: an upstream SSO proxy asserts the owner's
|
||||
// identity. The session endpoint only reports whether that identity is present.
|
||||
export type SessionResponse = {
|
||||
authenticated?: boolean
|
||||
user?: User
|
||||
capabilities?: Record<string, boolean>
|
||||
canEdit?: boolean
|
||||
token?: string
|
||||
accessToken?: string
|
||||
sessionToken?: string
|
||||
}
|
||||
|
||||
const baseUrl = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, '')
|
||||
export const apiConfig = {
|
||||
google: import.meta.env.VITE_GOOGLE_AUTH_ENDPOINT ?? '/api/auth/google',
|
||||
session: import.meta.env.VITE_SESSION_ENDPOINT ?? '/api/auth/session',
|
||||
logout: import.meta.env.VITE_LOGOUT_ENDPOINT ?? '/api/auth/logout',
|
||||
content: import.meta.env.VITE_CONTENT_ENDPOINT ?? '/api/content',
|
||||
}
|
||||
|
||||
|
|
@ -34,32 +29,13 @@ export async function requestSession(): Promise<SessionResponse | null> {
|
|||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
if (response.status === 401 || response.status === 404) return null
|
||||
if (response.status === 404) return null
|
||||
if (!response.ok) throw new Error('Unable to check your session.')
|
||||
const value = (await response.json()) as SessionResponse & { CanEdit?: boolean; User?: User }
|
||||
if (value.User && !value.user) value.user = value.User
|
||||
const value = (await response.json()) as SessionResponse & { CanEdit?: boolean }
|
||||
if (value.canEdit === undefined && value.CanEdit !== undefined) value.canEdit = value.CanEdit
|
||||
if (value.user) value.user = normalizeUser(value.user)
|
||||
return value
|
||||
}
|
||||
|
||||
function normalizeUser(value: User & { ID?: string; Email?: string; Name?: string; Editor?: boolean }): User {
|
||||
return { id: value.id ?? value.ID, email: value.email ?? value.Email, name: value.name ?? value.Name, picture: value.picture, capabilities: { ...(value.capabilities ?? {}), ...(value.Editor === true ? { canEdit: true } : {}) } }
|
||||
}
|
||||
|
||||
export async function signInWithGoogle(): Promise<SessionResponse> {
|
||||
window.location.assign(apiUrl(apiConfig.google))
|
||||
return {}
|
||||
}
|
||||
|
||||
export async function signOut() {
|
||||
await fetch(apiUrl(apiConfig.logout), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
export type Content = {
|
||||
id: string
|
||||
title: string
|
||||
|
|
@ -94,7 +70,7 @@ async function contentRequest<T>(path = '', init?: RequestInit): Promise<T> {
|
|||
headers: { Accept: 'application/json', ...(init?.body ? { 'Content-Type': 'application/json' } : {}), ...init?.headers },
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) throw new Error('Please sign in to view content.')
|
||||
if (response.status === 401) throw new Error('Your SSO session is required to edit content.')
|
||||
throw new Error((await response.text()) || 'Unable to load content.')
|
||||
}
|
||||
return (await response.json()) as T
|
||||
|
|
@ -139,4 +115,4 @@ export function updateContent(id: string, content: ContentUpdate): Promise<Conte
|
|||
method: 'PUT',
|
||||
body: JSON.stringify(content),
|
||||
}).then(value => normalizeContent(value as Content & { ID?: string; Slug?: string; Title?: string; Summary?: string; Body?: string; Status?: string }))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +1,21 @@
|
|||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { requestSession, signInWithGoogle, signOut, type User } from './api'
|
||||
import { requestSession } from './api'
|
||||
|
||||
// There is no sign-in or sign-out: the upstream SSO proxy decides whether the
|
||||
// current request carries the owner's identity.
|
||||
type AuthState = {
|
||||
user: User | null
|
||||
loading: boolean
|
||||
authenticated: boolean
|
||||
canEdit: boolean
|
||||
error: string | null
|
||||
signIn: () => Promise<void>
|
||||
signOut: () => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthState | undefined>(undefined)
|
||||
|
||||
function getUser(result: { authenticated?: boolean; user?: User; capabilities?: Record<string, boolean>; canEdit?: boolean; token?: string; accessToken?: string; sessionToken?: string }) {
|
||||
const token = result.token ?? result.accessToken ?? result.sessionToken
|
||||
if (token) sessionStorage.setItem('cms.auth.token', token)
|
||||
if (result.authenticated === false) return null
|
||||
if (!result.user && !token && result.authenticated !== true) return null
|
||||
return { ...(result.user ?? {}), capabilities: { ...result.capabilities, ...(result.canEdit !== undefined ? { canEdit: result.canEdit } : {}), ...result.user?.capabilities } }
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [authenticated, setAuthenticated] = useState(false)
|
||||
const [canEdit, setCanEdit] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
|
|
@ -28,10 +23,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
try {
|
||||
setError(null)
|
||||
const session = await requestSession()
|
||||
setUser(session ? getUser(session) : null)
|
||||
setAuthenticated(session?.authenticated === true)
|
||||
setCanEdit(session?.canEdit === true)
|
||||
} catch (err) {
|
||||
setUser(null)
|
||||
setError(err instanceof Error ? err.message : 'Unable to check your session.')
|
||||
setAuthenticated(false)
|
||||
setCanEdit(false)
|
||||
setError(err instanceof Error ? err.message : 'Unable to check your SSO session.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
|
@ -39,23 +36,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
useEffect(() => { void refresh() }, [refresh])
|
||||
|
||||
const signIn = useCallback(async () => {
|
||||
setError(null)
|
||||
try {
|
||||
const result = await signInWithGoogle()
|
||||
setUser(getUser(result))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Sign-in failed.')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const logOut = useCallback(async () => {
|
||||
await signOut().catch(() => undefined)
|
||||
sessionStorage.removeItem('cms.auth.token')
|
||||
setUser(null)
|
||||
}, [])
|
||||
|
||||
const value = useMemo(() => ({ user, loading, error, signIn, signOut: logOut }), [user, loading, error, signIn, logOut])
|
||||
const value = useMemo(() => ({ loading, authenticated, canEdit, error, refresh }), [loading, authenticated, canEdit, error, refresh])
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
|
|
@ -63,4 +44,4 @@ export function useAuth() {
|
|||
const context = useContext(AuthContext)
|
||||
if (!context) throw new Error('useAuth must be used inside AuthProvider')
|
||||
return context
|
||||
}
|
||||
}
|
||||
|
|
@ -25,18 +25,14 @@ button, select { cursor: pointer; }
|
|||
.text-button:hover { border-color: #89b4fa; color: #cdd6f4; }
|
||||
.theme-button { border: 1px solid #45475a; border-radius: 8px; background: #313244; color: #bac2de; padding: 8px 12px; }
|
||||
.theme-button:hover { border-color: #cba6f7; color: #f5e0e8; }
|
||||
.mode-badge { border: 1px solid #45475a; border-radius: 999px; padding: 6px 11px; color: #a6adc8; background: #242438; font-size: .7rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
|
||||
.card { margin: 10vh auto 0; max-width: 500px; padding: 42px; background: #242438; border: 1px solid #45475a; border-radius: 16px; box-shadow: 0 18px 50px #11111b66; }
|
||||
.auth-card { text-align: center; }
|
||||
h1 { margin: 10px 0 12px; color: #f5e0e8; font-size: clamp(1.7rem, 4vw, 2.4rem); letter-spacing: -.04em; }
|
||||
p { line-height: 1.65; }
|
||||
.muted { color: #a6adc8; }
|
||||
.small { font-size: .875rem; }
|
||||
.eyebrow { color: #cba6f7; font-size: .75rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
|
||||
.google-button, .secondary-button { width: 100%; min-height: 48px; border-radius: 9px; cursor: pointer; font-weight: 650; }
|
||||
.google-button { margin-top: 22px; border: 1px solid #585b70; color: #cdd6f4; background: #313244; }
|
||||
.google-button:hover { background: #45475a; border-color: #89b4fa; }
|
||||
.google-button span { margin-right: 10px; color: #89b4fa; font-weight: 800; }
|
||||
.secondary-button { margin-top: 20px; border: 0; background: #45475a; color: #cdd6f4; }
|
||||
.secondary-button { width: 100%; min-height: 48px; margin-top: 20px; border: 0; border-radius: 9px; background: #45475a; color: #cdd6f4; cursor: pointer; font-weight: 650; }
|
||||
.error { padding: 10px 12px; border-radius: 8px; color: #f38ba8; background: #3b2634; font-size: .9rem; }
|
||||
.profile { margin-top: 24px; padding: 14px; border-radius: 8px; background: #313244; color: #a6adc8; }
|
||||
.content-page, .detail-page { padding-top: 5vh; }
|
||||
|
|
@ -97,6 +93,7 @@ html[data-theme="light"] .text-button,
|
|||
html[data-theme="light"] .theme-button { border-color: #ccd0da; background: #e6e9ef; color: #5c5f77; }
|
||||
html[data-theme="light"] .text-button:hover,
|
||||
html[data-theme="light"] .theme-button:hover { border-color: #8839ef; color: #4c4f69; }
|
||||
html[data-theme="light"] .mode-badge { border-color: #ccd0da; background: #e6e9ef; color: #6c6f85; }
|
||||
html[data-theme="light"] .card,
|
||||
html[data-theme="light"] .filters,
|
||||
html[data-theme="light"] .content-card { background: #ffffff; border-color: #ccd0da; box-shadow: 0 12px 34px #4c4f6914; }
|
||||
|
|
@ -105,13 +102,11 @@ html[data-theme="light"] .content-card h2 a { color: #4c4f69; }
|
|||
html[data-theme="light"] .muted,
|
||||
html[data-theme="light"] .content-card p { color: #6c6f85; }
|
||||
html[data-theme="light"] .eyebrow { color: #8839ef; }
|
||||
html[data-theme="light"] .google-button,
|
||||
html[data-theme="light"] .filters select,
|
||||
html[data-theme="light"] .badge-input,
|
||||
html[data-theme="light"] .editor input,
|
||||
html[data-theme="light"] .editor textarea,
|
||||
html[data-theme="light"] .editor select { border-color: #ccd0da; background: #eff1f5; color: #4c4f69; }
|
||||
html[data-theme="light"] .google-button:hover { background: #e6e9ef; border-color: #1e66f5; }
|
||||
html[data-theme="light"] .profile,
|
||||
html[data-theme="light"] .secondary-button,
|
||||
html[data-theme="light"] .actions .secondary-button { background: #e6e9ef; color: #5c5f77; }
|
||||
|
|
|
|||
3
ui/app/src/vite-env.d.ts
vendored
3
ui/app/src/vite-env.d.ts
vendored
|
|
@ -2,9 +2,8 @@
|
|||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string
|
||||
readonly VITE_GOOGLE_AUTH_ENDPOINT?: string
|
||||
readonly VITE_SESSION_ENDPOINT?: string
|
||||
readonly VITE_LOGOUT_ENDPOINT?: string
|
||||
readonly VITE_CONTENT_ENDPOINT?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
|
|
|||
|
|
@ -9,10 +9,6 @@ export default defineConfig({
|
|||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/admin': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/health': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue