From 381ed6270ef72acd89d0e413f6609022d89ee4d7 Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Sat, 19 Sep 2026 15:14:16 +0200 Subject: [PATCH] content page --- internal/domain/models.go | 5 +++++ internal/repository/gorm.go | 11 +++++++++++ internal/repository/interfaces.go | 1 + internal/service/content.go | 4 ++++ internal/web/handlers.go | 21 +++++++++++++++++++++ ui/app/src/App.tsx | 12 +++++++++--- ui/app/src/api.ts | 6 ++++++ ui/app/src/styles.css | 4 ++-- 8 files changed, 59 insertions(+), 5 deletions(-) diff --git a/internal/domain/models.go b/internal/domain/models.go index 5e4437e..8e4545f 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -41,6 +41,11 @@ type Category struct { Name string } +type Badge struct { + ID uint + Name string +} + type ContentHistory struct { ID uint ContentID uint diff --git a/internal/repository/gorm.go b/internal/repository/gorm.go index 2b653f4..a43a30a 100644 --- a/internal/repository/gorm.go +++ b/internal/repository/gorm.go @@ -90,6 +90,17 @@ func (r *GORMContentRepository) ListCategories(ctx context.Context) ([]domain.Ca } 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) { var row persistence.ContentModel if err := r.db.WithContext(ctx).Preload("Category").Preload("Badges").Where("slug = ?", slug).First(&row).Error; err != nil { diff --git a/internal/repository/interfaces.go b/internal/repository/interfaces.go index b6ef44f..b9b97f3 100644 --- a/internal/repository/interfaces.go +++ b/internal/repository/interfaces.go @@ -21,6 +21,7 @@ type ISessionRepository interface { type IContentRepository interface { List(context.Context, int, int, string, []string) ([]domain.Content, error) ListCategories(context.Context) ([]domain.Category, error) + ListBadges(context.Context) ([]domain.Badge, error) FindBySlug(context.Context, string) (domain.Content, error) Update(context.Context, domain.Content, domain.ContentHistory) error } diff --git a/internal/service/content.go b/internal/service/content.go index 59d7ad0..8fbaa40 100644 --- a/internal/service/content.go +++ b/internal/service/content.go @@ -14,6 +14,7 @@ import ( type IContentService interface { List(context.Context, int, int, string, []string) ([]domain.Content, error) ListCategories(context.Context) ([]domain.Category, error) + ListBadges(context.Context) ([]domain.Badge, error) Get(context.Context, string) (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) { 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) { if !validSlug(slug) { return domain.Content{}, errors.New("invalid slug") diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 3987d6b..764d5a8 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -38,6 +38,7 @@ func (h *Handler) Routes(origins string) http.Handler { mux.HandleFunc("/api/protected", h.protected) mux.HandleFunc("/api/content", h.contentList) mux.HandleFunc("/api/content/categories", h.contentCategories) + mux.HandleFunc("/api/content/badges", h.contentBadges) mux.HandleFunc("/api/content/", h.contentDetail) 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}) } +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) { if h.content == nil { jsonWrite(w, 503, map[string]string{"error": "content unavailable"}) diff --git a/ui/app/src/App.tsx b/ui/app/src/App.tsx index 9318215..68a1c6e 100644 --- a/ui/app/src/App.tsx +++ b/ui/app/src/App.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' 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' function Shell({ children }: { children: React.ReactNode }) { @@ -47,6 +47,7 @@ function ContentList() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [categories, setCategories] = useState([]) + const [badgeOptions, setBadgeOptions] = useState([]) const [category, setCategory] = useState('') const [badges, setBadges] = 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(() => { loadingMore.current = false setItems([]) @@ -102,13 +107,14 @@ function ContentList() { diff --git a/ui/app/src/api.ts b/ui/app/src/api.ts index 79a7847..275cc0c 100644 --- a/ui/app/src/api.ts +++ b/ui/app/src/api.ts @@ -115,12 +115,18 @@ export async function listContent(cursor?: string | null, category = '', badges: } export type Category = { id: string; slug: string; name: string } +export type Badge = { id: string; name: string } export async function listCategories(): Promise { const response = await contentRequest<{ items?: Array }>('/categories') 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 { + const response = await contentRequest<{ items?: Array }>('/badges') + return (response.items ?? []).map(item => ({ id: item.id ?? item.ID ?? '', name: item.name ?? item.Name ?? '' })) +} + export function getContent(id: string): Promise { return contentRequest(`/${encodeURIComponent(id)}`).then(value => normalizeContent(value as Content & { ID?: string; Slug?: string; Title?: string; Summary?: string; Body?: string; Status?: string })) } diff --git a/ui/app/src/styles.css b/ui/app/src/styles.css index 10ad5cc..e6a5046 100644 --- a/ui/app/src/styles.css +++ b/ui/app/src/styles.css @@ -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 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-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 h2 { margin: 12px 0 8px; font-size: 1.3rem; letter-spacing: -.025em; } .content-card h2 a { color: #172033; text-decoration: none; } @@ -60,5 +60,5 @@ p { line-height: 1.6; } .inline-button { margin-top: 22px; } .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) { .content-page, .detail-page { padding-top: 3vh; } .edit-button { position: static; margin-top: 10px; } } @media (max-width: 640px) { .filters { grid-template-columns: 1fr; } }