content page
This commit is contained in:
parent
fd2877da9e
commit
36b2c56987
14 changed files with 590 additions and 20 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import { getContent, listContent, updateContent, type Content, type ContentUpdate } from './api'
|
||||
import { getContent, listCategories, listContent, updateContent, type Category, type Content, type ContentUpdate } from './api'
|
||||
import { useAuth } from './auth'
|
||||
|
||||
function Shell({ children }: { children: React.ReactNode }) {
|
||||
|
|
@ -36,6 +36,7 @@ function ContentCard({ item }: { item: Content }) {
|
|||
<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>
|
||||
}
|
||||
|
|
@ -45,15 +46,22 @@ function ContentList() {
|
|||
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 [category, setCategory] = useState('')
|
||||
const [badges, setBadges] = useState<string[]>([])
|
||||
const [badgeInput, setBadgeInput] = useState('')
|
||||
const loadingMore = useRef(false)
|
||||
const requestVersion = useRef(0)
|
||||
const sentinel = useRef<HTMLDivElement>(null)
|
||||
|
||||
const load = async (next = false) => {
|
||||
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)
|
||||
const response = await listContent(next ? cursor : undefined, category, 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))
|
||||
|
|
@ -61,11 +69,19 @@ function ContentList() {
|
|||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to load content.')
|
||||
} finally {
|
||||
loadingMore.current = false
|
||||
setLoading(false)
|
||||
if (version === requestVersion.current) {
|
||||
loadingMore.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
useEffect(() => { void load() }, [])
|
||||
useEffect(() => { void listCategories().then(setCategories).catch(() => setCategories([])) }, [])
|
||||
useEffect(() => {
|
||||
loadingMore.current = false
|
||||
setItems([])
|
||||
setCursor(undefined)
|
||||
void load()
|
||||
}, [category, badges.join(',')])
|
||||
useEffect(() => {
|
||||
const node = sentinel.current
|
||||
if (!node) return
|
||||
|
|
@ -76,6 +92,26 @@ function ContentList() {
|
|||
|
||||
return <Shell><section className="content-page">
|
||||
<div className="page-heading"><div><span className="eyebrow">Journal</span><h1>Latest content</h1></div></div>
|
||||
<div className="filters" aria-label="Content filters">
|
||||
<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 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('')
|
||||
}} />
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ export type Content = {
|
|||
summary: string
|
||||
body: string
|
||||
status: string
|
||||
category?: string
|
||||
badges?: string[]
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
|
@ -81,8 +83,8 @@ export type ContentListResponse = {
|
|||
limit?: number
|
||||
}
|
||||
|
||||
function normalizeContent(value: Content & { ID?: string; Slug?: string; Title?: string; Summary?: string; Body?: string; Status?: string }): Content {
|
||||
return { id: value.id ?? value.Slug ?? String(value.ID ?? ''), title: value.title ?? value.Title ?? '', summary: value.summary ?? value.Summary ?? '', body: value.body ?? value.Body ?? '', status: value.status ?? value.Status ?? '', createdAt: value.createdAt, updatedAt: value.updatedAt }
|
||||
function normalizeContent(value: Content & { ID?: string; Slug?: string; Title?: string; Summary?: string; Body?: string; Status?: string; Category?: string; Badges?: string[] }): Content {
|
||||
return { id: value.id ?? value.Slug ?? String(value.ID ?? ''), title: value.title ?? value.Title ?? '', summary: value.summary ?? value.Summary ?? '', body: value.body ?? value.Body ?? '', status: value.status ?? value.Status ?? '', category: value.category ?? value.Category, badges: value.badges ?? value.Badges ?? [], createdAt: value.createdAt, updatedAt: value.updatedAt }
|
||||
}
|
||||
|
||||
async function contentRequest<T>(path = '', init?: RequestInit): Promise<T> {
|
||||
|
|
@ -98,8 +100,12 @@ async function contentRequest<T>(path = '', init?: RequestInit): Promise<T> {
|
|||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
export async function listContent(cursor?: string | null): Promise<ContentListResponse> {
|
||||
const query = cursor ? `?offset=${encodeURIComponent(cursor)}` : ''
|
||||
export async function listContent(cursor?: string | null, category = '', badges: string[] = []): Promise<ContentListResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (cursor) params.set('offset', cursor)
|
||||
if (category) params.set('category', category)
|
||||
badges.forEach(badge => params.append('badge', badge))
|
||||
const query = params.toString() ? `?${params.toString()}` : ''
|
||||
const response = await contentRequest<ContentListResponse | (Content & { ID?: string; Slug?: string; Title?: string; Summary?: string; Body?: string; Status?: string })[]>(query)
|
||||
if (Array.isArray(response)) return { items: response.map(normalizeContent), nextCursor: null }
|
||||
const items = (response.items ?? response.content ?? response.data ?? []).map(normalizeContent)
|
||||
|
|
@ -108,6 +114,13 @@ export async function listContent(cursor?: string | null): Promise<ContentListRe
|
|||
return { ...response, items, nextCursor: items.length >= limit ? String(offset + items.length) : null }
|
||||
}
|
||||
|
||||
export type Category = { id: string; slug: string; name: string }
|
||||
|
||||
export async function listCategories(): Promise<Category[]> {
|
||||
const response = await contentRequest<{ items?: Array<Category & { ID?: string; Slug?: string; Name?: string }> }>('/categories')
|
||||
return (response.items ?? []).map(item => ({ id: item.id ?? item.ID ?? '', slug: item.slug ?? item.Slug ?? '', name: item.name ?? item.Name ?? '' }))
|
||||
}
|
||||
|
||||
export function getContent(id: string): Promise<Content> {
|
||||
return contentRequest<Content>(`/${encodeURIComponent(id)}`).then(value => normalizeContent(value as Content & { ID?: string; Slug?: string; Title?: string; Summary?: string; Body?: string; Status?: string }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,14 @@ p { line-height: 1.6; }
|
|||
.profile { margin-top: 24px; padding: 14px; border-radius: 8px; background: #f5f7fb; color: #536176; }
|
||||
.content-page, .detail-page { padding-top: 6vh; }
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: end; margin-bottom: 28px; }
|
||||
.filters { display: grid; grid-template-columns: minmax(180px, .35fr) 1fr; gap: 14px; margin-bottom: 24px; padding: 16px; background: #fff; border: 1px solid #e3e8f0; border-radius: 14px; }
|
||||
.filters label { display: grid; gap: 7px; color: #344563; font-size: .85rem; font-weight: 650; }
|
||||
.filters select, .badge-input { width: 100%; min-height: 43px; border: 1px solid #ccd4e0; border-radius: 8px; padding: 6px 9px; background: #fff; color: #172033; font: inherit; font-weight: 400; }
|
||||
.badge-input { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
|
||||
.badge-input input { min-width: 180px; flex: 1; border: 0; outline: 0; padding: 5px; color: #172033; font: inherit; }
|
||||
.filter-pill, .badge { display: inline-flex; align-items: center; gap: 4px; border-radius: 999px; padding: 4px 9px; color: #344f9b; background: #eef2ff; font-size: .78rem; font-weight: 650; }
|
||||
.filter-pill button { border: 0; padding: 0 2px; color: #536fc4; background: transparent; cursor: pointer; font-size: 1rem; line-height: 1; }
|
||||
.badge-list { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px; }
|
||||
.content-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
|
||||
.content-card { padding: 26px; background: #fff; border: 1px solid #e3e8f0; border-radius: 14px; box-shadow: 0 10px 30px #17203308; }
|
||||
.content-card h2 { margin: 12px 0 8px; font-size: 1.3rem; letter-spacing: -.025em; }
|
||||
|
|
@ -53,3 +61,4 @@ p { line-height: 1.6; }
|
|||
.protected-card { text-align: center; }
|
||||
@media (max-width: 480px) { .shell { padding: 0 16px; } .card { padding: 28px 22px; } }
|
||||
@media (max-width: 640px) { .content-grid { grid-template-columns: 1fr; } .content-page, .detail-page { padding-top: 3vh; } .edit-button { position: static; margin-top: 10px; } }
|
||||
@media (max-width: 640px) { .filters { grid-template-columns: 1fr; } }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue