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
return
- WelcomeSign in to CMS
- Sign in securely with your external identity provider.
+ Welcome, developerSign 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
+
: <>{item.status}{item.title}
{item.summary}
{canEdit &&
}
{item.body}
>}
+ : <>{item.status}{item.title}
{item.summary}
{item.badges && item.badges.length > 0 &&
{item.badges.map(badge => {badge})}
}{canEdit &&
}
{item.body}
>}
}
diff --git a/ui/app/src/api.ts b/ui/app/src/api.ts
index 275cc0c..5c0f08e 100644
--- a/ui/app/src/api.ts
+++ b/ui/app/src/api.ts
@@ -100,10 +100,11 @@ async function contentRequest(path = '', init?: RequestInit): Promise {
return (await response.json()) as T
}
-export async function listContent(cursor?: string | null, category = '', badges: string[] = []): Promise {
+export async function listContent(cursor?: string | null, category = '', status = '', badges: string[] = []): Promise {
const params = new URLSearchParams()
if (cursor) params.set('offset', cursor)
if (category) params.set('category', category)
+ if (status) params.set('status', status)
badges.forEach(badge => params.append('badge', badge))
const query = params.toString() ? `?${params.toString()}` : ''
const response = await contentRequest(query)
@@ -131,7 +132,7 @@ 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 }))
}
-export type ContentUpdate = Pick
+export type ContentUpdate = Pick
export function updateContent(id: string, content: ContentUpdate): Promise {
return contentRequest(`/${encodeURIComponent(id)}`, {
diff --git a/ui/app/src/styles.css b/ui/app/src/styles.css
index e6a5046..a0236f2 100644
--- a/ui/app/src/styles.css
+++ b/ui/app/src/styles.css
@@ -1,64 +1,84 @@
-:root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #172033; background: #f5f7fb; font-synthesis: none; }
+:root {
+ font-family: Inter, ui-sans-serif, system-ui, sans-serif;
+ color: #cdd6f4;
+ background: #1e1e2e;
+ font-synthesis: none;
+}
* { box-sizing: border-box; }
-body { margin: 0; min-width: 320px; }
-button, a { font: inherit; }
-.shell { min-height: 100vh; max-width: 960px; margin: auto; padding: 0 24px 64px; }
-.header { height: 76px; display: flex; align-items: center; justify-content: space-between; }
-.brand { color: #172033; font-size: 1.15rem; font-weight: 750; text-decoration: none; letter-spacing: -.02em; }
-.text-button { border: 0; background: transparent; color: #536176; cursor: pointer; padding: 8px; }
-.card { margin: 10vh auto 0; max-width: 440px; padding: 40px; background: white; border: 1px solid #e3e8f0; border-radius: 16px; box-shadow: 0 14px 40px #1720330b; }
+body {
+ margin: 0;
+ min-width: 320px;
+ background: radial-gradient(circle at 85% -10%, #302b50 0, transparent 38rem), #1e1e2e;
+}
+button, a, input, textarea, select { font: inherit; }
+button, select { cursor: pointer; }
+.shell { min-height: 100vh; max-width: 1080px; margin: auto; padding: 0 28px 72px; }
+.header { height: 94px; display: flex; align-items: center; justify-content: space-between; }
+.brand { position: relative; color: #cdd6f4; font-size: 1.15rem; font-weight: 750; text-decoration: none; letter-spacing: -.02em; }
+.brand:hover { color: #cba6f7; }
+.brand-prompt { color: #a6e3a1; font-family: ui-monospace, SFMono-Regular, monospace; }
+.brand-cursor { color: #f9e2af; font-family: ui-monospace, SFMono-Regular, monospace; animation: blink 1.2s steps(2, start) infinite; }
+.brand small { display: block; margin: 5px 0 0 22px; color: #7f849c; font-size: .68rem; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
+@keyframes blink { 50% { opacity: 0; } }
+.text-button { border: 1px solid #45475a; border-radius: 8px; background: #313244; color: #bac2de; cursor: pointer; padding: 8px 12px; }
+.text-button:hover { border-color: #89b4fa; color: #cdd6f4; }
+.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; font-size: clamp(1.7rem, 4vw, 2.25rem); letter-spacing: -.04em; }
-p { line-height: 1.6; }
-.muted { color: #657286; }
+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: #536fc4; font-size: .75rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
+.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 #ccd4e0; color: #172033; background: #fff; }
-.google-button:hover { background: #f8fafc; border-color: #9ba8bb; }
-.google-button span { margin-right: 10px; color: #4285f4; font-weight: 800; }
-.secondary-button { margin-top: 20px; border: 0; background: #edf1f8; color: #344563; }
-.error { padding: 10px 12px; border-radius: 8px; color: #a32d2d; background: #fff0f0; font-size: .9rem; }
-.profile { margin-top: 24px; padding: 14px; border-radius: 8px; background: #f5f7fb; color: #536176; }
-.content-page, .detail-page { padding-top: 6vh; }
+.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; }
+.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; }
.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; }
+.page-heading h1 { color: #cdd6f4; }
+.filters { display: grid; grid-template-columns: minmax(150px, .25fr) minmax(180px, .35fr) 1fr; gap: 14px; margin-bottom: 24px; padding: 16px; background: #242438; border: 1px solid #45475a; border-radius: 14px; }
+.filters label { display: grid; gap: 7px; color: #bac2de; font-size: .85rem; font-weight: 650; }
+.filters select, .badge-input { width: 100%; min-height: 43px; border: 1px solid #585b70; border-radius: 8px; padding: 6px 9px; background: #313244; color: #cdd6f4; font: inherit; font-weight: 400; }
+.filters select:focus, .badge-input:focus-within { outline: 2px solid #89b4fa; outline-offset: 1px; }
.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-input input { min-width: 180px; flex: 1; border: 0; outline: 0; padding: 5px; color: #cdd6f4; background: transparent; font: inherit; }
+.badge-input input::placeholder { color: #7f849c; }
+.filter-pill, .badge { display: inline-flex; align-items: center; gap: 4px; border-radius: 999px; padding: 4px 9px; color: #cba6f7; background: #3b3151; font-size: .78rem; font-weight: 650; }
+.filter-pill button { border: 0; padding: 0 2px; color: #cba6f7; 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: 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; }
-.content-card h2 a:hover, .read-link:hover { color: #536fc4; }
-.content-card p { color: #657286; margin: 0 0 18px; }
-.status { display: inline-block; color: #536fc4; background: #eef2ff; border-radius: 999px; padding: 4px 9px; font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
-.read-link, .back-link { color: #344f9b; font-weight: 650; text-decoration: none; }
+.content-card { padding: 28px; background: #242438; border: 1px solid #45475a; border-radius: 14px; box-shadow: 0 12px 34px #11111b33; }
+.content-card:hover { border-color: #585b70; box-shadow: 0 15px 40px #11111b55; }
+.content-card h2 { margin: 12px 0 8px; font-size: 1.35rem; letter-spacing: -.025em; }
+.content-card h2 a { color: #f5e0e8; text-decoration: none; }
+.content-card h2 a:hover, .read-link:hover { color: #89b4fa; }
+.content-card p { color: #a6adc8; margin: 0 0 18px; }
+.status { display: inline-block; color: #94e2d5; background: #193d3a; border-radius: 999px; padding: 4px 9px; font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
+.read-link, .back-link { color: #89b4fa; font-weight: 650; text-decoration: none; }
.list-status { text-align: center; min-height: 54px; padding: 14px; }
.empty-state { max-width: none; text-align: center; margin-top: 0; }
-.detail-page { max-width: 720px; margin: auto; }
+.detail-page { max-width: 760px; margin: auto; }
.back-link { display: inline-block; margin-bottom: 32px; }
-.detail-heading { position: relative; padding-bottom: 26px; border-bottom: 1px solid #e3e8f0; }
+.detail-heading { position: relative; padding-bottom: 26px; border-bottom: 1px solid #45475a; }
.detail-heading h1 { margin-top: 14px; }
.detail-heading .muted { font-size: 1.1rem; }
.edit-button { position: absolute; top: 0; right: 0; }
-.body-copy { white-space: pre-wrap; line-height: 1.8; padding-top: 28px; }
+.body-copy { white-space: pre-wrap; line-height: 1.8; padding-top: 28px; color: #bac2de; }
.editor { display: grid; gap: 18px; }
-.editor label { display: grid; gap: 7px; color: #344563; font-size: .9rem; font-weight: 650; }
-.editor input, .editor textarea, .editor select { width: 100%; border: 1px solid #ccd4e0; border-radius: 8px; padding: 11px 12px; background: #fff; color: #172033; font: inherit; font-weight: 400; }
+.editor label { display: grid; gap: 7px; color: #bac2de; font-size: .9rem; font-weight: 650; }
+.editor input, .editor textarea, .editor select { width: 100%; border: 1px solid #585b70; border-radius: 8px; padding: 11px 12px; background: #313244; color: #cdd6f4; font: inherit; font-weight: 400; }
+.editor input:focus, .editor textarea:focus, .editor select:focus { outline: 2px solid #89b4fa; outline-offset: 1px; }
.editor textarea { resize: vertical; }
.actions { display: flex; gap: 10px; margin-top: 4px; }
.primary-button, .secondary-button { border: 0; border-radius: 8px; min-height: 42px; padding: 0 17px; cursor: pointer; font-weight: 650; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; }
-.primary-button { background: #536fc4; color: #fff; }
-.primary-button:hover { background: #405bac; }
+.primary-button { background: #cba6f7; color: #1e1e2e; }
+.primary-button:hover { background: #d8b9ff; }
.primary-button:disabled { opacity: .6; cursor: wait; }
-.actions .secondary-button { margin: 0; background: #edf1f8; color: #344563; }
+.actions .secondary-button { margin: 0; background: #45475a; color: #cdd6f4; }
.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-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: 480px) { .shell { padding: 0 16px 56px; } .card { padding: 28px 22px; } .brand small { font-size: .58rem; } }
+@media (max-width: 640px) { .content-page, .detail-page { padding-top: 3vh; } .edit-button { position: static; margin-top: 10px; } .filters { grid-template-columns: 1fr; } }