catpuccing moka

This commit is contained in:
Daniel Noulet 2026-09-19 15:24:27 +02:00
parent 381ed6270e
commit ead8819e39
8 changed files with 154 additions and 64 deletions

View file

@ -63,12 +63,15 @@ func (r *GORMSessionRepository) FindValid(ctx context.Context, t string) (domain
type GORMContentRepository struct{ db *gorm.DB } type GORMContentRepository struct{ db *gorm.DB }
func NewContentRepository(db *gorm.DB) IContentRepository { return &GORMContentRepository{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 var rows []persistence.ContentModel
query := r.db.WithContext(ctx).Model(&persistence.ContentModel{}).Preload("Category").Preload("Badges") query := r.db.WithContext(ctx).Model(&persistence.ContentModel{}).Preload("Category").Preload("Badges")
if category != "" { if category != "" {
query = query.Where("category_id IN (SELECT id FROM categories WHERE slug = ?)", 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 { 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) 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 { 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 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 return tx.Create(&persistence.ContentHistoryModel{ContentID: h.ContentID, Operation: h.Operation, ActorID: h.ActorID, ActorAdminID: h.ActorAdminID, CreatedAt: h.CreatedAt, Snapshot: h.Snapshot}).Error
}) })
} }

View file

@ -19,7 +19,7 @@ type ISessionRepository interface {
FindValid(context.Context, string) (domain.Session, error) FindValid(context.Context, string) (domain.Session, error)
} }
type IContentRepository interface { 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) ListCategories(context.Context) ([]domain.Category, error)
ListBadges(context.Context) ([]domain.Badge, error) ListBadges(context.Context) ([]domain.Badge, error)
FindBySlug(context.Context, string) (domain.Content, error) FindBySlug(context.Context, string) (domain.Content, error)

View file

@ -12,7 +12,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, []string) ([]domain.Content, error)
ListCategories(context.Context) ([]domain.Category, error) ListCategories(context.Context) ([]domain.Category, error)
ListBadges(context.Context) ([]domain.Badge, error) ListBadges(context.Context) ([]domain.Badge, error)
Get(context.Context, string) (domain.Content, error) Get(context.Context, string) (domain.Content, error)
@ -26,7 +26,7 @@ type ContentService struct {
func NewContentService(r repository.IContentRepository, a *AuthService) *ContentService { func NewContentService(r repository.IContentRepository, a *AuthService) *ContentService {
return &ContentService{repo: r, auth: a} 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 { if limit < 1 {
limit = 20 limit = 20
} }
@ -36,7 +36,7 @@ func (s *ContentService) List(ctx context.Context, limit, offset int, category s
if offset < 0 { if offset < 0 {
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) { func (s *ContentService) ListCategories(ctx context.Context) ([]domain.Category, error) {
return s.repo.ListCategories(ctx) 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.CreatedAt = old.CreatedAt
c.CreatedBy = old.CreatedBy c.CreatedBy = old.CreatedBy
c.UpdatedAt = time.Now() c.UpdatedAt = time.Now()
c.Badges = normalizeBadges(c.Badges)
if session.UserID != nil { if session.UserID != nil {
c.UpdatedBy = *session.UserID c.UpdatedBy = *session.UserID
} }
snapshot, _ := json.Marshal(c) snapshot, _ := json.Marshal(c)
h := domain.ContentHistory{ContentID: c.ID, Operation: "updated", CreatedAt: c.UpdatedAt, Snapshot: string(snapshot), ActorID: session.UserID, ActorAdminID: session.AdminID} 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 { 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 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]+)*$`) var slugRE = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
func validSlug(s string) bool { return len(s) <= 200 && slugRE.MatchString(s) } func validSlug(s string) bool { return len(s) <= 200 && slugRE.MatchString(s) }

View file

@ -183,7 +183,11 @@ func (h *Handler) contentList(w http.ResponseWriter, r *http.Request) {
filtered = append(filtered, badge) 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 { if e != nil {
jsonWrite(w, 500, map[string]string{"error": e.Error()}) jsonWrite(w, 500, map[string]string{"error": e.Error()})
return return

View file

@ -3,8 +3,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f8fafc" /> <meta name="theme-color" content="#1e1e2e" />
<title>CMS</title> <title>Urpi's backlog</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -6,7 +6,7 @@ import { useAuth } from './auth'
function Shell({ children }: { children: React.ReactNode }) { function Shell({ children }: { children: React.ReactNode }) {
const { user, signOut } = useAuth() const { user, signOut } = useAuth()
return <main className="shell"> return <main className="shell">
<header className="header"><Link className="brand" to="/">CMS</Link>{user && <button className="text-button" onClick={() => void signOut()}>Sign out</button>}</header> <header className="header"><Link className="brand" to="/"><span className="brand-prompt">~/</span> Urpi's backlog<span className="brand-cursor">_</span><small>linux · go · java · ideas</small></Link>{user && <button className="text-button" onClick={() => void signOut()}>Sign out</button>}</header>
{children} {children}
</main> </main>
} }
@ -16,8 +16,8 @@ function Login() {
if (user) return <Navigate to="/protected" replace /> if (user) return <Navigate to="/protected" replace />
if (loading) return <Shell><div className="card"><p>Checking your session…</p></div></Shell> if (loading) return <Shell><div className="card"><p>Checking your session…</p></div></Shell>
return <Shell><section className="card auth-card"> return <Shell><section className="card auth-card">
<span className="eyebrow">Welcome</span><h1>Sign in to CMS</h1> <span className="eyebrow">Welcome, developer</span><h1>Sign in to Urpi's backlog</h1>
<p className="muted">Sign in securely with your external identity provider.</p> <p className="muted">A cozy backlog for Linux, Go, Java, and the ideas between them.</p>
{error && <p className="error" role="alert">{error}</p>} {error && <p className="error" role="alert">{error}</p>}
<button className="google-button" onClick={() => void signIn()}><span aria-hidden="true">G</span> Continue with Google</button> <button className="google-button" onClick={() => void signIn()}><span aria-hidden="true">G</span> Continue with Google</button>
</section></Shell> </section></Shell>
@ -49,19 +49,21 @@ function ContentList() {
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [badgeOptions, setBadgeOptions] = useState<Badge[]>([]) const [badgeOptions, setBadgeOptions] = useState<Badge[]>([])
const [category, setCategory] = useState('') const [category, setCategory] = useState('')
const [status, setStatus] = useState('')
const [badges, setBadges] = useState<string[]>([]) const [badges, setBadges] = useState<string[]>([])
const [badgeInput, setBadgeInput] = useState('') const [badgeInput, setBadgeInput] = useState('')
const loadingMore = useRef(false) const loadingMore = useRef(false)
const requestVersion = useRef(0) const requestVersion = useRef(0)
const sentinel = useRef<HTMLDivElement>(null) const sentinel = useRef<HTMLDivElement>(null)
const filters = { category, status, badges }
const load = async (next = false) => { const load = async (next = false, activeFilters = filters) => {
if (loadingMore.current || (next && !cursor)) return if (loadingMore.current || (next && !cursor)) return
const version = next ? requestVersion.current : ++requestVersion.current const version = next ? requestVersion.current : ++requestVersion.current
loadingMore.current = true loadingMore.current = true
setLoading(true) setLoading(true)
try { 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 if (version !== requestVersion.current) return
const page = Array.isArray(response) ? response : response.items ?? response.content ?? response.data ?? [] const page = Array.isArray(response) ? response : response.items ?? response.content ?? response.data ?? []
setItems(current => next ? [...current, ...page] : page) setItems(current => next ? [...current, ...page] : page)
@ -85,19 +87,28 @@ function ContentList() {
loadingMore.current = false loadingMore.current = false
setItems([]) setItems([])
setCursor(undefined) setCursor(undefined)
void load() void load(false, { category, status, badges })
}, [category, badges.join(',')]) }, [category, status, badges.join(',')])
useEffect(() => { useEffect(() => {
const node = sentinel.current const node = sentinel.current
if (!node) return if (!node) return
const observer = new IntersectionObserver(entries => { if (entries[0]?.isIntersecting) void load(true) }, { rootMargin: '240px' }) const observer = new IntersectionObserver(entries => { if (entries[0]?.isIntersecting) void load(true) }, { rootMargin: '240px' })
observer.observe(node) observer.observe(node)
return () => observer.disconnect() return () => observer.disconnect()
}, [cursor]) }, [cursor, category, status, badges.join(',')])
return <Shell><section className="content-page"> return <Shell><section className="content-page">
<div className="page-heading"><div><span className="eyebrow">Journal</span><h1>Latest content</h1></div></div> <div className="page-heading"><div><span className="eyebrow">Journal</span><h1>Latest content</h1></div></div>
<div className="filters" aria-label="Content filters"> <div className="filters" aria-label="Content filters">
<label className="status-filter">Status
<select value={status} onChange={event => setStatus(event.target.value)}>
<option value="">All statuses</option>
<option value="published">Published</option>
<option value="draft">Draft</option>
<option value="review">Review</option>
<option value="archived">Archived</option>
</select>
</label>
<label className="category-filter">Category <label className="category-filter">Category
<select value={category} onChange={event => setCategory(event.target.value)}> <select value={category} onChange={event => setCategory(event.target.value)}>
<option value="">All categories</option> <option value="">All categories</option>
@ -135,11 +146,19 @@ function ContentDetail() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [badgeOptions, setBadgeOptions] = useState<Badge[]>([])
const [badgeInput, setBadgeInput] = useState('')
const navigate = useNavigate() const navigate = useNavigate()
useEffect(() => { useEffect(() => {
let active = true let active = true
setLoading(true) setLoading(true)
void getContent(id).then(value => { if (active) { setItem(value); setDraft({ title: value.title, summary: value.summary, body: value.body, status: value.status }) } }) void Promise.all([getContent(id), listBadges()]).then(([value, options]) => {
if (active) {
setItem(value)
setBadgeOptions(options)
setDraft({ title: value.title, summary: value.summary, body: value.body, status: value.status, badges: value.badges ?? [] })
}
})
.catch(err => active && setError(err instanceof Error ? err.message : 'Unable to load content.')) .catch(err => active && setError(err instanceof Error ? err.message : 'Unable to load content.'))
.finally(() => active && setLoading(false)) .finally(() => active && setLoading(false))
return () => { active = false } return () => { active = false }
@ -148,7 +167,7 @@ function ContentDetail() {
if (error || !item || !draft) return <Shell><div className="card"><p className="error" role="alert">{error ?? 'Content not found.'}</p><Link to="/content">Back to content</Link></div></Shell> if (error || !item || !draft) return <Shell><div className="card"><p className="error" role="alert">{error ?? 'Content not found.'}</p><Link to="/content">Back to content</Link></div></Shell>
const save = async () => { const save = async () => {
setSaving(true); setError(null) setSaving(true); setError(null)
try { const saved = await updateContent(id, draft); setItem(saved); setDraft({ title: saved.title, summary: saved.summary, body: saved.body, status: saved.status }); setEditing(false) } try { const saved = await updateContent(id, draft); setItem(saved); setDraft({ title: saved.title, summary: saved.summary, body: saved.body, status: saved.status, badges: saved.badges ?? [] }); setEditing(false) }
catch (err) { setError(err instanceof Error ? err.message : 'Unable to save changes.') } catch (err) { setError(err instanceof Error ? err.message : 'Unable to save changes.') }
finally { setSaving(false) } finally { setSaving(false) }
} }
@ -160,8 +179,21 @@ function ContentDetail() {
<label>Summary<textarea rows={3} value={draft.summary} onChange={e => setDraft({ ...draft, summary: e.target.value })} /></label> <label>Summary<textarea rows={3} value={draft.summary} onChange={e => setDraft({ ...draft, summary: e.target.value })} /></label>
<label>Body<textarea rows={12} value={draft.body} onChange={e => setDraft({ ...draft, body: e.target.value })} /></label> <label>Body<textarea rows={12} value={draft.body} onChange={e => setDraft({ ...draft, body: e.target.value })} /></label>
<label>Status<select value={draft.status} onChange={e => setDraft({ ...draft, status: e.target.value })}><option value="draft">Draft</option><option value="published">Published</option><option value="archived">Archived</option></select></label> <label>Status<select value={draft.status} onChange={e => setDraft({ ...draft, status: e.target.value })}><option value="draft">Draft</option><option value="published">Published</option><option value="archived">Archived</option></select></label>
<label>Badges
<div className="badge-input">
{(draft.badges ?? []).map(badge => <span className="filter-pill" key={badge}>{badge}<button type="button" aria-label={`Remove ${badge}`} onClick={() => setDraft({ ...draft, badges: (draft.badges ?? []).filter(value => value !== badge) })}>×</button></span>)}
<input list="detail-badge-options" value={badgeInput} placeholder="Type a badge and press Enter" onChange={e => setBadgeInput(e.target.value)} onKeyDown={e => {
if (e.key !== 'Enter') return
e.preventDefault()
const badge = badgeInput.trim().toLowerCase()
if (badge && !(draft.badges ?? []).includes(badge)) setDraft({ ...draft, badges: [...(draft.badges ?? []), badge] })
setBadgeInput('')
}} />
<datalist id="detail-badge-options">{badgeOptions.filter(option => !(draft.badges ?? []).includes(option.name)).map(option => <option value={option.name} key={option.id} />)}</datalist>
</div>
</label>
<div className="actions"><button className="primary-button" disabled={saving} onClick={() => void save()}>{saving ? 'Saving…' : 'Save changes'}</button><button className="secondary-button" onClick={() => setEditing(false)}>Cancel</button></div> <div className="actions"><button className="primary-button" disabled={saving} onClick={() => void save()}>{saving ? 'Saving…' : 'Save changes'}</button><button className="secondary-button" onClick={() => setEditing(false)}>Cancel</button></div>
</div> : <><div className="detail-heading"><span className="status">{item.status}</span><h1>{item.title}</h1><p className="muted">{item.summary}</p>{canEdit && <button className="primary-button edit-button" onClick={() => setEditing(true)}>Edit</button>}</div><div className="body-copy">{item.body}</div></>} </div> : <><div className="detail-heading"><span className="status">{item.status}</span><h1>{item.title}</h1><p className="muted">{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>}{canEdit && <button className="primary-button edit-button" onClick={() => setEditing(true)}>Edit</button>}</div><div className="body-copy">{item.body}</div></>}
</article></Shell> </article></Shell>
} }

View file

@ -100,10 +100,11 @@ async function contentRequest<T>(path = '', init?: RequestInit): Promise<T> {
return (await response.json()) as T return (await response.json()) as T
} }
export async function listContent(cursor?: string | null, category = '', badges: string[] = []): Promise<ContentListResponse> { export async function listContent(cursor?: string | null, category = '', status = '', badges: string[] = []): Promise<ContentListResponse> {
const params = new URLSearchParams() const params = new URLSearchParams()
if (cursor) params.set('offset', cursor) if (cursor) params.set('offset', cursor)
if (category) params.set('category', category) if (category) params.set('category', category)
if (status) params.set('status', status)
badges.forEach(badge => params.append('badge', badge)) badges.forEach(badge => params.append('badge', badge))
const query = params.toString() ? `?${params.toString()}` : '' 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) const response = await contentRequest<ContentListResponse | (Content & { ID?: string; Slug?: string; Title?: string; Summary?: string; Body?: string; Status?: string })[]>(query)
@ -131,7 +132,7 @@ 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 }))
} }
export type ContentUpdate = Pick<Content, 'title' | 'summary' | 'body' | 'status'> export type ContentUpdate = Pick<Content, 'title' | 'summary' | 'body' | 'status' | 'badges'>
export function updateContent(id: string, content: ContentUpdate): Promise<Content> { export function updateContent(id: string, content: ContentUpdate): Promise<Content> {
return contentRequest<Content>(`/${encodeURIComponent(id)}`, { return contentRequest<Content>(`/${encodeURIComponent(id)}`, {

View file

@ -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; } * { box-sizing: border-box; }
body { margin: 0; min-width: 320px; } body {
button, a { font: inherit; } margin: 0;
.shell { min-height: 100vh; max-width: 960px; margin: auto; padding: 0 24px 64px; } min-width: 320px;
.header { height: 76px; display: flex; align-items: center; justify-content: space-between; } background: radial-gradient(circle at 85% -10%, #302b50 0, transparent 38rem), #1e1e2e;
.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; } button, a, input, textarea, select { font: inherit; }
.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; } 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; } .auth-card { text-align: center; }
h1 { margin: 10px 0 12px; font-size: clamp(1.7rem, 4vw, 2.25rem); letter-spacing: -.04em; } h1 { margin: 10px 0 12px; color: #f5e0e8; font-size: clamp(1.7rem, 4vw, 2.4rem); letter-spacing: -.04em; }
p { line-height: 1.6; } p { line-height: 1.65; }
.muted { color: #657286; } .muted { color: #a6adc8; }
.small { font-size: .875rem; } .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, .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 { margin-top: 22px; border: 1px solid #585b70; color: #cdd6f4; background: #313244; }
.google-button:hover { background: #f8fafc; border-color: #9ba8bb; } .google-button:hover { background: #45475a; border-color: #89b4fa; }
.google-button span { margin-right: 10px; color: #4285f4; font-weight: 800; } .google-button span { margin-right: 10px; color: #89b4fa; font-weight: 800; }
.secondary-button { margin-top: 20px; border: 0; background: #edf1f8; color: #344563; } .secondary-button { margin-top: 20px; border: 0; background: #45475a; color: #cdd6f4; }
.error { padding: 10px 12px; border-radius: 8px; color: #a32d2d; background: #fff0f0; font-size: .9rem; } .error { padding: 10px 12px; border-radius: 8px; color: #f38ba8; background: #3b2634; font-size: .9rem; }
.profile { margin-top: 24px; padding: 14px; border-radius: 8px; background: #f5f7fb; color: #536176; } .profile { margin-top: 24px; padding: 14px; border-radius: 8px; background: #313244; color: #a6adc8; }
.content-page, .detail-page { padding-top: 6vh; } .content-page, .detail-page { padding-top: 5vh; }
.page-heading { display: flex; justify-content: space-between; align-items: end; margin-bottom: 28px; } .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; } .page-heading h1 { color: #cdd6f4; }
.filters label { display: grid; gap: 7px; color: #344563; font-size: .85rem; font-weight: 650; } .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 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; } .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 { 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; } .badge-input input { min-width: 180px; flex: 1; border: 0; outline: 0; padding: 5px; color: #cdd6f4; background: transparent; 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; } .badge-input input::placeholder { color: #7f849c; }
.filter-pill button { border: 0; padding: 0 2px; color: #536fc4; background: transparent; cursor: pointer; font-size: 1rem; line-height: 1; } .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; } .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-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: 28px; background: #242438; border: 1px solid #45475a; border-radius: 14px; box-shadow: 0 12px 34px #11111b33; }
.content-card h2 { margin: 12px 0 8px; font-size: 1.3rem; letter-spacing: -.025em; } .content-card:hover { border-color: #585b70; box-shadow: 0 15px 40px #11111b55; }
.content-card h2 a { color: #172033; text-decoration: none; } .content-card h2 { margin: 12px 0 8px; font-size: 1.35rem; letter-spacing: -.025em; }
.content-card h2 a:hover, .read-link:hover { color: #536fc4; } .content-card h2 a { color: #f5e0e8; text-decoration: none; }
.content-card p { color: #657286; margin: 0 0 18px; } .content-card h2 a:hover, .read-link:hover { color: #89b4fa; }
.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; } .content-card p { color: #a6adc8; margin: 0 0 18px; }
.read-link, .back-link { color: #344f9b; font-weight: 650; text-decoration: none; } .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; } .list-status { text-align: center; min-height: 54px; padding: 14px; }
.empty-state { max-width: none; text-align: center; margin-top: 0; } .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; } .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 h1 { margin-top: 14px; }
.detail-heading .muted { font-size: 1.1rem; } .detail-heading .muted { font-size: 1.1rem; }
.edit-button { position: absolute; top: 0; right: 0; } .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 { display: grid; gap: 18px; }
.editor label { display: grid; gap: 7px; color: #344563; font-size: .9rem; font-weight: 650; } .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 #ccd4e0; border-radius: 8px; padding: 11px 12px; background: #fff; color: #172033; font: inherit; font-weight: 400; } .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; } .editor textarea { resize: vertical; }
.actions { display: flex; gap: 10px; margin-top: 4px; } .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, .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 { background: #cba6f7; color: #1e1e2e; }
.primary-button:hover { background: #405bac; } .primary-button:hover { background: #d8b9ff; }
.primary-button:disabled { opacity: .6; cursor: wait; } .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; } .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 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; } } @media (max-width: 640px) { .content-page, .detail-page { padding-top: 3vh; } .edit-button { position: static; margin-top: 10px; } .filters { grid-template-columns: 1fr; } }
@media (max-width: 640px) { .filters { grid-template-columns: 1fr; } }