content page
This commit is contained in:
parent
36b2c56987
commit
381ed6270e
8 changed files with 59 additions and 5 deletions
|
|
@ -41,6 +41,11 @@ type Category struct {
|
||||||
Name string
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Badge struct {
|
||||||
|
ID uint
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
type ContentHistory struct {
|
type ContentHistory struct {
|
||||||
ID uint
|
ID uint
|
||||||
ContentID uint
|
ContentID uint
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,17 @@ func (r *GORMContentRepository) ListCategories(ctx context.Context) ([]domain.Ca
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
func (r *GORMContentRepository) ListBadges(ctx context.Context) ([]domain.Badge, error) {
|
||||||
|
var rows []persistence.BadgeModel
|
||||||
|
if err := r.db.WithContext(ctx).Order("name ASC").Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]domain.Badge, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
out[i] = domain.Badge{ID: rows[i].ID, Name: rows[i].Name}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
func (r *GORMContentRepository) FindBySlug(ctx context.Context, slug string) (domain.Content, error) {
|
func (r *GORMContentRepository) FindBySlug(ctx context.Context, slug string) (domain.Content, error) {
|
||||||
var row persistence.ContentModel
|
var row persistence.ContentModel
|
||||||
if err := r.db.WithContext(ctx).Preload("Category").Preload("Badges").Where("slug = ?", slug).First(&row).Error; err != nil {
|
if err := r.db.WithContext(ctx).Preload("Category").Preload("Badges").Where("slug = ?", slug).First(&row).Error; err != nil {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ type ISessionRepository interface {
|
||||||
type IContentRepository interface {
|
type IContentRepository interface {
|
||||||
List(context.Context, int, int, string, []string) ([]domain.Content, error)
|
List(context.Context, int, int, string, []string) ([]domain.Content, error)
|
||||||
ListCategories(context.Context) ([]domain.Category, error)
|
ListCategories(context.Context) ([]domain.Category, error)
|
||||||
|
ListBadges(context.Context) ([]domain.Badge, error)
|
||||||
FindBySlug(context.Context, string) (domain.Content, error)
|
FindBySlug(context.Context, string) (domain.Content, error)
|
||||||
Update(context.Context, domain.Content, domain.ContentHistory) error
|
Update(context.Context, domain.Content, domain.ContentHistory) error
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import (
|
||||||
type IContentService interface {
|
type IContentService interface {
|
||||||
List(context.Context, int, int, string, []string) ([]domain.Content, error)
|
List(context.Context, int, int, string, []string) ([]domain.Content, error)
|
||||||
ListCategories(context.Context) ([]domain.Category, error)
|
ListCategories(context.Context) ([]domain.Category, error)
|
||||||
|
ListBadges(context.Context) ([]domain.Badge, error)
|
||||||
Get(context.Context, string) (domain.Content, error)
|
Get(context.Context, string) (domain.Content, error)
|
||||||
Update(context.Context, domain.Content, domain.Session) (domain.Content, error)
|
Update(context.Context, domain.Content, domain.Session) (domain.Content, error)
|
||||||
}
|
}
|
||||||
|
|
@ -40,6 +41,9 @@ func (s *ContentService) List(ctx context.Context, limit, offset int, category s
|
||||||
func (s *ContentService) ListCategories(ctx context.Context) ([]domain.Category, error) {
|
func (s *ContentService) ListCategories(ctx context.Context) ([]domain.Category, error) {
|
||||||
return s.repo.ListCategories(ctx)
|
return s.repo.ListCategories(ctx)
|
||||||
}
|
}
|
||||||
|
func (s *ContentService) ListBadges(ctx context.Context) ([]domain.Badge, error) {
|
||||||
|
return s.repo.ListBadges(ctx)
|
||||||
|
}
|
||||||
func (s *ContentService) Get(ctx context.Context, slug string) (domain.Content, error) {
|
func (s *ContentService) Get(ctx context.Context, slug string) (domain.Content, error) {
|
||||||
if !validSlug(slug) {
|
if !validSlug(slug) {
|
||||||
return domain.Content{}, errors.New("invalid slug")
|
return domain.Content{}, errors.New("invalid slug")
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ func (h *Handler) Routes(origins string) http.Handler {
|
||||||
mux.HandleFunc("/api/protected", h.protected)
|
mux.HandleFunc("/api/protected", h.protected)
|
||||||
mux.HandleFunc("/api/content", h.contentList)
|
mux.HandleFunc("/api/content", h.contentList)
|
||||||
mux.HandleFunc("/api/content/categories", h.contentCategories)
|
mux.HandleFunc("/api/content/categories", h.contentCategories)
|
||||||
|
mux.HandleFunc("/api/content/badges", h.contentBadges)
|
||||||
mux.HandleFunc("/api/content/", h.contentDetail)
|
mux.HandleFunc("/api/content/", h.contentDetail)
|
||||||
return cors(mux, origins)
|
return cors(mux, origins)
|
||||||
}
|
}
|
||||||
|
|
@ -209,6 +210,26 @@ func (h *Handler) contentCategories(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
jsonWrite(w, 200, map[string]any{"items": categories})
|
jsonWrite(w, 200, map[string]any{"items": categories})
|
||||||
}
|
}
|
||||||
|
func (h *Handler) contentBadges(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.content == nil {
|
||||||
|
jsonWrite(w, 503, map[string]string{"error": "content unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := h.sessionForRequest(r); !ok {
|
||||||
|
jsonWrite(w, 401, map[string]string{"error": "authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
jsonWrite(w, http.StatusMethodNotAllowed, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
badges, err := h.content.ListBadges(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
jsonWrite(w, 500, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonWrite(w, 200, map[string]any{"items": badges})
|
||||||
|
}
|
||||||
func (h *Handler) contentDetail(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) contentDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.content == nil {
|
if h.content == nil {
|
||||||
jsonWrite(w, 503, map[string]string{"error": "content unavailable"})
|
jsonWrite(w, 503, map[string]string{"error": "content unavailable"})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Link, Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'
|
import { Link, Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { getContent, listCategories, listContent, updateContent, type Category, type Content, type ContentUpdate } from './api'
|
import { getContent, listBadges, listCategories, listContent, updateContent, type Badge, type Category, type Content, type ContentUpdate } from './api'
|
||||||
import { useAuth } from './auth'
|
import { useAuth } from './auth'
|
||||||
|
|
||||||
function Shell({ children }: { children: React.ReactNode }) {
|
function Shell({ children }: { children: React.ReactNode }) {
|
||||||
|
|
@ -47,6 +47,7 @@ function ContentList() {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
|
const [badgeOptions, setBadgeOptions] = useState<Badge[]>([])
|
||||||
const [category, setCategory] = useState('')
|
const [category, setCategory] = useState('')
|
||||||
const [badges, setBadges] = useState<string[]>([])
|
const [badges, setBadges] = useState<string[]>([])
|
||||||
const [badgeInput, setBadgeInput] = useState('')
|
const [badgeInput, setBadgeInput] = useState('')
|
||||||
|
|
@ -75,7 +76,11 @@ function ContentList() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
useEffect(() => { void listCategories().then(setCategories).catch(() => setCategories([])) }, [])
|
useEffect(() => {
|
||||||
|
void Promise.all([listCategories(), listBadges()])
|
||||||
|
.then(([nextCategories, nextBadges]) => { setCategories(nextCategories); setBadgeOptions(nextBadges) })
|
||||||
|
.catch(() => { setCategories([]); setBadgeOptions([]) })
|
||||||
|
}, [])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadingMore.current = false
|
loadingMore.current = false
|
||||||
setItems([])
|
setItems([])
|
||||||
|
|
@ -102,13 +107,14 @@ function ContentList() {
|
||||||
<label className="badge-filter">Badges
|
<label className="badge-filter">Badges
|
||||||
<div className="badge-input">
|
<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>)}
|
{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 => {
|
<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
|
if (event.key !== 'Enter') return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const badge = badgeInput.trim().toLowerCase()
|
const badge = badgeInput.trim().toLowerCase()
|
||||||
if (badge && !badges.includes(badge)) setBadges(current => [...current, badge])
|
if (badge && !badges.includes(badge)) setBadges(current => [...current, badge])
|
||||||
setBadgeInput('')
|
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>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -115,12 +115,18 @@ export async function listContent(cursor?: string | null, category = '', badges:
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Category = { id: string; slug: string; name: string }
|
export type Category = { id: string; slug: string; name: string }
|
||||||
|
export type Badge = { id: string; name: string }
|
||||||
|
|
||||||
export async function listCategories(): Promise<Category[]> {
|
export async function listCategories(): Promise<Category[]> {
|
||||||
const response = await contentRequest<{ items?: Array<Category & { ID?: string; Slug?: string; Name?: string }> }>('/categories')
|
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 ?? '' }))
|
return (response.items ?? []).map(item => ({ id: item.id ?? item.ID ?? '', slug: item.slug ?? item.Slug ?? '', name: item.name ?? item.Name ?? '' }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listBadges(): Promise<Badge[]> {
|
||||||
|
const response = await contentRequest<{ items?: Array<Badge & { ID?: string; Name?: string }> }>('/badges')
|
||||||
|
return (response.items ?? []).map(item => ({ id: item.id ?? item.ID ?? '', name: item.name ?? item.Name ?? '' }))
|
||||||
|
}
|
||||||
|
|
||||||
export function getContent(id: string): Promise<Content> {
|
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 }))
|
return contentRequest<Content>(`/${encodeURIComponent(id)}`).then(value => normalizeContent(value as Content & { ID?: string; Slug?: string; Title?: string; Summary?: string; Body?: string; Status?: string }))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ p { line-height: 1.6; }
|
||||||
.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, .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; }
|
.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; }
|
.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-grid { display: grid; grid-template-columns: 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 { 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; }
|
.content-card h2 { margin: 12px 0 8px; font-size: 1.3rem; letter-spacing: -.025em; }
|
||||||
.content-card h2 a { color: #172033; text-decoration: none; }
|
.content-card h2 a { color: #172033; text-decoration: none; }
|
||||||
|
|
@ -60,5 +60,5 @@ p { line-height: 1.6; }
|
||||||
.inline-button { margin-top: 22px; }
|
.inline-button { margin-top: 22px; }
|
||||||
.protected-card { text-align: center; }
|
.protected-card { text-align: center; }
|
||||||
@media (max-width: 480px) { .shell { padding: 0 16px; } .card { padding: 28px 22px; } }
|
@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) { .content-page, .detail-page { padding-top: 3vh; } .edit-button { position: static; margin-top: 10px; } }
|
||||||
@media (max-width: 640px) { .filters { grid-template-columns: 1fr; } }
|
@media (max-width: 640px) { .filters { grid-template-columns: 1fr; } }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue