diff --git a/internal/repository/gorm.go b/internal/repository/gorm.go index a43a30a..44385bd 100644 --- a/internal/repository/gorm.go +++ b/internal/repository/gorm.go @@ -63,12 +63,15 @@ func (r *GORMSessionRepository) FindValid(ctx context.Context, t string) (domain type GORMContentRepository struct{ db *gorm.DB } func NewContentRepository(db *gorm.DB) IContentRepository { return &GORMContentRepository{db} } -func (r *GORMContentRepository) List(ctx context.Context, limit, offset int, category string, badges []string) ([]domain.Content, error) { +func (r *GORMContentRepository) List(ctx context.Context, limit, offset int, category, status string, badges []string) ([]domain.Content, error) { var rows []persistence.ContentModel query := r.db.WithContext(ctx).Model(&persistence.ContentModel{}).Preload("Category").Preload("Badges") if category != "" { query = query.Where("category_id IN (SELECT id FROM categories WHERE slug = ?)", category) } + if status != "" { + query = query.Where("status = ?", status) + } for _, badge := range badges { query = query.Where("EXISTS (SELECT 1 FROM content_badges cb JOIN badges b ON b.id = cb.badge_id WHERE cb.content_id = content.id AND b.name = ?)", badge) } @@ -113,6 +116,17 @@ func (r *GORMContentRepository) Update(ctx context.Context, d domain.Content, h if err := tx.Model(&persistence.ContentModel{}).Where("id = ?", d.ID).Updates(map[string]any{"slug": d.Slug, "title": d.Title, "summary": d.Summary, "body": d.Body, "status": d.Status, "published": d.Published, "updated_at": d.UpdatedAt, "updated_by": d.UpdatedBy}).Error; err != nil { return err } + if err := tx.Exec("DELETE FROM content_badges WHERE content_id = ?", d.ID).Error; err != nil { + return err + } + for _, badge := range d.Badges { + if err := tx.Exec("INSERT INTO badges (name) VALUES (?) ON CONFLICT (name) DO NOTHING", badge).Error; err != nil { + return err + } + if err := tx.Exec("INSERT INTO content_badges (content_id, badge_id) SELECT ?, id FROM badges WHERE name = ? ON CONFLICT DO NOTHING", d.ID, badge).Error; err != nil { + return err + } + } return tx.Create(&persistence.ContentHistoryModel{ContentID: h.ContentID, Operation: h.Operation, ActorID: h.ActorID, ActorAdminID: h.ActorAdminID, CreatedAt: h.CreatedAt, Snapshot: h.Snapshot}).Error }) } diff --git a/internal/repository/interfaces.go b/internal/repository/interfaces.go index b9b97f3..2312be1 100644 --- a/internal/repository/interfaces.go +++ b/internal/repository/interfaces.go @@ -19,7 +19,7 @@ type ISessionRepository interface { FindValid(context.Context, string) (domain.Session, error) } type IContentRepository interface { - List(context.Context, int, int, string, []string) ([]domain.Content, error) + List(context.Context, int, int, string, string, []string) ([]domain.Content, error) ListCategories(context.Context) ([]domain.Category, error) ListBadges(context.Context) ([]domain.Badge, error) FindBySlug(context.Context, string) (domain.Content, error) diff --git a/internal/service/content.go b/internal/service/content.go index 8fbaa40..0304e80 100644 --- a/internal/service/content.go +++ b/internal/service/content.go @@ -12,7 +12,7 @@ import ( ) type IContentService interface { - List(context.Context, int, int, string, []string) ([]domain.Content, error) + List(context.Context, int, int, string, string, []string) ([]domain.Content, error) ListCategories(context.Context) ([]domain.Category, error) ListBadges(context.Context) ([]domain.Badge, error) Get(context.Context, string) (domain.Content, error) @@ -26,7 +26,7 @@ type ContentService struct { func NewContentService(r repository.IContentRepository, a *AuthService) *ContentService { return &ContentService{repo: r, auth: a} } -func (s *ContentService) List(ctx context.Context, limit, offset int, category string, badges []string) ([]domain.Content, error) { +func (s *ContentService) List(ctx context.Context, limit, offset int, category, status string, badges []string) ([]domain.Content, error) { if limit < 1 { limit = 20 } @@ -36,7 +36,7 @@ func (s *ContentService) List(ctx context.Context, limit, offset int, category s if offset < 0 { offset = 0 } - return s.repo.List(ctx, limit, offset, category, badges) + return s.repo.List(ctx, limit, offset, category, status, badges) } func (s *ContentService) ListCategories(ctx context.Context) ([]domain.Category, error) { return s.repo.ListCategories(ctx) @@ -65,9 +65,11 @@ func (s *ContentService) Update(ctx context.Context, c domain.Content, session d c.CreatedAt = old.CreatedAt c.CreatedBy = old.CreatedBy c.UpdatedAt = time.Now() + c.Badges = normalizeBadges(c.Badges) if session.UserID != nil { c.UpdatedBy = *session.UserID } + snapshot, _ := json.Marshal(c) h := domain.ContentHistory{ContentID: c.ID, Operation: "updated", CreatedAt: c.UpdatedAt, Snapshot: string(snapshot), ActorID: session.UserID, ActorAdminID: session.AdminID} if err = s.repo.Update(ctx, c, h); err != nil { @@ -76,6 +78,23 @@ func (s *ContentService) Update(ctx context.Context, c domain.Content, session d return c, nil } +func normalizeBadges(values []string) []string { + result := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" || len(value) > 100 { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result +} + var slugRE = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) func validSlug(s string) bool { return len(s) <= 200 && slugRE.MatchString(s) } diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 764d5a8..64a02a6 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -183,7 +183,11 @@ func (h *Handler) contentList(w http.ResponseWriter, r *http.Request) { filtered = append(filtered, badge) } } - items, e := h.content.List(r.Context(), limit, offset, strings.TrimSpace(strings.ToLower(r.URL.Query().Get("category"))), filtered) + items, e := h.content.List(r.Context(), limit, offset, + strings.TrimSpace(strings.ToLower(r.URL.Query().Get("category"))), + strings.TrimSpace(strings.ToLower(r.URL.Query().Get("status"))), + filtered, + ) if e != nil { jsonWrite(w, 500, map[string]string{"error": e.Error()}) return diff --git a/ui/app/index.html b/ui/app/index.html index 64b6ef3..899e8c9 100644 --- a/ui/app/index.html +++ b/ui/app/index.html @@ -3,8 +3,8 @@ - - CMS + + Urpi's backlog
diff --git a/ui/app/src/App.tsx b/ui/app/src/App.tsx index 68a1c6e..e45901f 100644 --- a/ui/app/src/App.tsx +++ b/ui/app/src/App.tsx @@ -6,7 +6,7 @@ import { useAuth } from './auth' function Shell({ children }: { children: React.ReactNode }) { const { user, signOut } = useAuth() return
-
CMS{user && }
+
~/ Urpi's backlog_linux · go · java · ideas{user && }
{children}
} @@ -16,8 +16,8 @@ function Login() { if (user) return if (loading) return

Checking your session…

return
- Welcome

Sign in to CMS

-

Sign in securely with your external identity provider.

+ Welcome, developer

Sign in to Urpi's backlog

+

A cozy backlog for Linux, Go, Java, and the ideas between them.

{error &&

{error}

}
@@ -49,19 +49,21 @@ function ContentList() { const [categories, setCategories] = useState([]) const [badgeOptions, setBadgeOptions] = useState([]) const [category, setCategory] = useState('') + const [status, setStatus] = useState('') const [badges, setBadges] = useState([]) const [badgeInput, setBadgeInput] = useState('') const loadingMore = useRef(false) const requestVersion = useRef(0) const sentinel = useRef(null) + const filters = { category, status, badges } - const load = async (next = 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, category, badges) + 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) @@ -85,19 +87,28 @@ function ContentList() { loadingMore.current = false setItems([]) setCursor(undefined) - void load() - }, [category, badges.join(',')]) + 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]) + }, [cursor, category, status, badges.join(',')]) return
Journal

Latest content

+