fixing style
Some checks failed
Build, test, and publish / Build and test (push) Has been cancelled
Build, test, and publish / Build and push Docker image (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
Daniel Noulet 2026-10-07 15:10:10 +02:00
parent e454940dfa
commit f40485842d
35 changed files with 16000 additions and 286 deletions

View file

@ -85,7 +85,7 @@ check:
go test ./...
docker:
docker build -t $(IMAGE):$(TAG) .
docker compose build
docker-push: docker
docker tag $(IMAGE):$(TAG) $(REGISTRY_IMAGE):$(TAG)
@ -103,7 +103,7 @@ ingress-nginx-install:
kubectl --kubeconfig "$(KUBECONFIG)" -n ingress-nginx rollout status deployment/ingress-nginx-controller --timeout=5m
cert-manager-install:
kubectl --kubeconfig "$(KUBECONFIG)" apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.19.1/cert-manager.yaml
kubectl --kubeconfig "$(KUBECONFIG)" apply --validate=false -f https://github.com/cert-manager/cert-manager/releases/download/v1.19.1/cert-manager.yaml
kubectl --kubeconfig "$(KUBECONFIG)" -n cert-manager rollout status deployment/cert-manager-webhook --timeout=5m
kubectl --kubeconfig "$(KUBECONFIG)" -n cert-manager rollout status deployment/cert-manager-cainjector --timeout=5m
kubectl --kubeconfig "$(KUBECONFIG)" -n cert-manager rollout status deployment/cert-manager --timeout=5m

13553
cert-manager.yaml Normal file

File diff suppressed because it is too large Load diff

View file

@ -52,7 +52,7 @@ func main() {
}
server := &http.Server{
Addr: cfg.HTTPAddr,
Handler: web.NewHandler(content, assets, sso, browser, demo).Routes(cfg.AllowedOrigins),
Handler: web.NewHandler(content, assets, sso, browser, demo).WithThemeOrigin(cfg.ThemeBaseURL).Routes(cfg.AllowedOrigins),
ReadHeaderTimeout: 10 * time.Second,
}
log.Printf("CMS listening on %s", cfg.HTTPAddr)

View file

@ -0,0 +1,88 @@
affinity: {}
app:
allowedOrigins: https://tell-dev.urpi.be
# The styling contract and the palette controls come from go-bananas in the same
# cluster. This is the value the chart had no way to express before, which is why
# the running deployment served its own built-in fallbacks.
themeBaseURL: https://bananas-dev.urpi.be
contentFile: ""
postgresURL: ""
profile: demo
ssoCookie: ""
ssoHeader: X-SSO-Token
certManager:
caSecretName: ""
duration: 8760h
enabled: true
issuerRef:
kind: ClusterIssuer
name: letsencrypt-staging
renewBefore: 720h
fullnameOverride: ""
goLoose:
appDomain: tell-dev.urpi.be
authDomain: auth-dev.urpi.be
ca:
existingSecret: ""
key: ca.crt
mountPath: /certs/local-ca.crt
clientID: ""
clientSecret: ""
enabled: false
tenant: tell
host: tell-dev.urpi.be
image:
pullPolicy: Always
repository: batty1039.startdedicated.net:5000/go-tell
tag: latest
imagePullSecrets: []
ingress:
annotations:
cert-manager.io/cluster-issuer: letsencrypt-staging
nginx.ingress.kubernetes.io/ssl-redirect: "true"
className: nginx
enabled: true
path: /
pathType: Prefix
tls:
enabled: true
existingSecret: ""
secretName: ""
nameOverride: ""
nodeSelector: {}
persistence:
accessModes:
- ReadWriteOnce
enabled: true
size: 2Gi
storageClass: ""
podAnnotations: {}
podLabels: {}
podSecurityContext:
fsGroup: 10001
fsGroupChangePolicy: OnRootMismatch
replicaCount: 1
resources:
limits:
cpu: 300m
memory: 384Mi
requests:
cpu: 50m
memory: 96Mi
secrets:
ssoToken: ""
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 10001
service:
nodePort: null
port: 80
type: ClusterIP
strategy:
type: Recreate
tolerations: []

View file

@ -52,6 +52,8 @@ spec:
value: {{ .Values.app.ssoHeader | quote }}
- name: CMS_SSO_COOKIE
value: {{ .Values.app.ssoCookie | quote }}
- name: CMS_THEME_BASE_URL
value: {{ .Values.app.themeBaseURL | quote }}
- name: CMS_SSO_TOKEN
valueFrom:
secretKeyRef:

View file

@ -0,0 +1,104 @@
# Development values for go-tell
nameOverride: ""
fullnameOverride: ""
# Public host of the CMS.
host: tell-dev.urpi.be
replicaCount: 1
image:
repository: nexus.urpi.be/docker-hosted/go-tell
tag: "0.0.1"
pullPolicy: Always
imagePullSecrets:
- name: nexus-registry
app:
profile: demo
allowedOrigins: https://tell-dev.urpi.be
contentFile: ""
postgresURL: ""
ssoHeader: X-SSO-Token
ssoCookie: ""
goLoose:
enabled: false
tenant: tell
authDomain: auth-dev.urpi.be
appDomain: tell-dev.urpi.be
ca:
existingSecret: ""
key: ca.crt
mountPath: /certs/local-ca.crt
clientID: ""
clientSecret: ""
secrets:
ssoToken: ""
service:
type: ClusterIP
port: 80
nodePort: null
ingress:
enabled: true
className: nginx
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
cert-manager.io/cluster-issuer: letsencrypt-staging
path: /
pathType: Prefix
tls:
enabled: true
existingSecret: ""
secretName: ""
certManager:
enabled: true
issuerRef:
name: letsencrypt-staging
kind: ClusterIssuer
duration: 8760h
renewBefore: 720h
caSecretName: ""
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
cpu: 300m
memory: 384Mi
podAnnotations: {}
podLabels: {}
podSecurityContext:
fsGroup: 10001
fsGroupChangePolicy: OnRootMismatch
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 10001
strategy:
type: Recreate
persistence:
enabled: true
size: 2Gi
accessModes:
- ReadWriteOnce
storageClass: ""
nodeSelector: {}
tolerations: []
affinity: {}

View file

@ -0,0 +1,103 @@
# Local (LCL) values for go-tell.
# Served at https://tell-lcl.intern-urpi.be by the docker TLS edge.
nameOverride: ""
fullnameOverride: ""
# Public host of the CMS.
host: tell-lcl.intern-urpi.be
replicaCount: 1
image:
repository: localhost:5000/go-tell
tag: "0.0.1"
pullPolicy: IfNotPresent
# Images live in the local registry (localhost:5000), no pull secret needed.
imagePullSecrets: []
app:
profile: demo
allowedOrigins: https://tell-lcl.intern-urpi.be
contentFile: ""
postgresURL: ""
ssoHeader: X-SSO-Token
ssoCookie: ""
goLoose:
enabled: false
tenant: tell
authDomain: auth-lcl.intern-urpi.be
appDomain: tell-lcl.intern-urpi.be
ca:
existingSecret: ""
key: ca.crt
mountPath: /certs/local-ca.crt
clientID: ""
clientSecret: ""
secrets:
ssoToken: ""
service:
type: ClusterIP
port: 80
nodePort: null
ingress:
enabled: true
className: traefik
annotations: {}
path: /
pathType: Prefix
tls:
enabled: true
# local-dev-cert is signed by the local CA (dev-infrastructure/certs).
existingSecret: "local-dev-cert"
secretName: ""
# No cert-manager in the local cluster.
certManager:
enabled: false
issuerRef: null
duration: 8760h
renewBefore: 720h
caSecretName: ""
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
cpu: 300m
memory: 384Mi
podAnnotations: {}
podLabels: {}
podSecurityContext:
fsGroup: 10001
fsGroupChangePolicy: OnRootMismatch
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 10001
strategy:
type: Recreate
persistence:
enabled: true
size: 2Gi
accessModes:
- ReadWriteOnce
storageClass: ""
nodeSelector: {}
tolerations: []
affinity: {}

View file

@ -0,0 +1,107 @@
# Production values for go-tell
nameOverride: ""
fullnameOverride: ""
# Public host of the CMS.
host: tell.urpi.be
replicaCount: 2
imagePullSecrets:
- name: nexus-registry
image:
repository: nexus.urpi.be/docker-hosted/go-tell
tag: "0.0.1"
pullPolicy: Always
app:
profile: production
allowedOrigins: https://tell.urpi.be
contentFile: /data/content.json
postgresURL: ""
ssoHeader: X-SSO-Token
ssoCookie: ""
goLoose:
enabled: false
tenant: tell
authDomain: auth.urpi.be
appDomain: tell.urpi.be
ca:
existingSecret: ""
key: ca.crt
mountPath: /certs/local-ca.crt
clientID: ""
clientSecret: ""
secrets:
ssoToken: ""
service:
type: ClusterIP
port: 80
nodePort: null
ingress:
enabled: true
className: nginx
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
cert-manager.io/cluster-issuer: letsencrypt-prod
path: /
pathType: Prefix
tls:
enabled: true
existingSecret: ""
secretName: ""
certManager:
enabled: true
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
duration: 8760h
renewBefore: 720h
caSecretName: ""
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 1000m
memory: 1Gi
podAnnotations: {}
podLabels: {}
podSecurityContext:
fsGroup: 10001
fsGroupChangePolicy: OnRootMismatch
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 10001
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
persistence:
enabled: true
size: 10Gi
accessModes:
- ReadWriteOnce
storageClass: ""
nodeSelector: {}
tolerations: []
affinity: {}

View file

@ -0,0 +1,104 @@
# TST values for go-tell
nameOverride: ""
fullnameOverride: ""
# Public host of the CMS.
host: tell-tst.intern-urpi.be
replicaCount: 1
image:
repository: nexus.urpi.be/docker-hosted/go-tell
tag: "0.0.1"
pullPolicy: Always
imagePullSecrets:
- name: nexus-registry
app:
profile: demo
allowedOrigins: https://tell-tst.intern-urpi.be
contentFile: ""
postgresURL: ""
ssoHeader: X-SSO-Token
ssoCookie: ""
goLoose:
enabled: false
tenant: tell
authDomain: auth-tst.intern-urpi.be
appDomain: tell-tst.intern-urpi.be
ca:
existingSecret: ""
key: ca.crt
mountPath: /certs/local-ca.crt
clientID: ""
clientSecret: ""
secrets:
ssoToken: ""
service:
type: ClusterIP
port: 80
nodePort: null
ingress:
enabled: true
className: nginx
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
cert-manager.io/cluster-issuer: letsencrypt-staging
path: /
pathType: Prefix
tls:
enabled: true
existingSecret: ""
secretName: ""
certManager:
enabled: true
issuerRef:
name: letsencrypt-staging
kind: ClusterIssuer
duration: 8760h
renewBefore: 720h
caSecretName: ""
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
cpu: 300m
memory: 384Mi
podAnnotations: {}
podLabels: {}
podSecurityContext:
fsGroup: 10001
fsGroupChangePolicy: OnRootMismatch
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 10001
strategy:
type: Recreate
persistence:
enabled: true
size: 3Gi
accessModes:
- ReadWriteOnce
storageClass: ""
nodeSelector: {}
tolerations: []
affinity: {}

View file

@ -7,12 +7,13 @@ host: tell.dev
replicaCount: 1
image:
repository: batty1039.startdedicated.net:5000/go-tell
tag: latest
pullPolicy: IfNotPresent
imagePullSecrets:
- name: nexus-registry
imagePullSecrets: []
image:
repository: nexus.urpi.be/docker-hosted/go-tell
tag: "0.0.1"
pullPolicy: IfNotPresent
app:
# Demo keeps content in memory and offers the sudo terminal. Production serves
@ -25,6 +26,12 @@ app:
# configured. An empty header disables it.
ssoHeader: X-SSO-Token
ssoCookie: ""
# The go-bananas origin supplying the shared styling contract: the palette and
# appearance controls, and the --bn-* custom properties the page is styled with.
# Empty leaves the shell on its own built-in fallbacks, which is why a
# deployment can run without it and still look right — and also why a missing
# value here is silent rather than obvious.
themeBaseURL: ""
goLoose:
enabled: true

View file

@ -10,6 +10,12 @@ type Config struct {
HTTPAddr, Profile, ContentFile, PostgresURL string
AllowedOrigins, SSOHeader, SSOToken, SSOCookie string
GoLoose GoLooseConfig
// ThemeBaseURL is the go-bananas origin supplying the shared styling contract.
// Empty keeps this application's own fallbacks, which is the safe default: the
// stylesheet is written as var(--bn-role, <colour>), so a missing contract
// degrades to the palette it always had rather than to an unstyled page.
ThemeBaseURL string
}
// GoLooseConfig describes the Go Loose hosted-login integration. Go Tell is
@ -39,6 +45,7 @@ func Load() Config {
SSOHeader: get("CMS_SSO_HEADER", "X-SSO-Token"),
SSOToken: get("CMS_SSO_TOKEN", ""),
SSOCookie: get("CMS_SSO_COOKIE", ""),
ThemeBaseURL: get("CMS_THEME_BASE_URL", ""),
GoLoose: GoLooseConfig{
Tenant: get("GO_LOOSE_TENANT", "tell"),
AuthDomain: get("GO_LOOSE_AUTH_DOMAIN", "auth.dev"),

View file

@ -2,6 +2,7 @@
package golooseauth
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
@ -28,15 +29,24 @@ const (
LogoutPath = "/api/auth/logout"
)
// Auth is the Go Loose browser client plus the session lookup the API needs.
// The login, callback, and logout handlers come from the Go Loose client; this
// type only adds a non-redirecting way to turn a request into a user, because
// the client middleware answers anonymous requests with a redirect and the CMS
// has to answer its own API with a status code.
// Auth is the Go Loose browser client plus the session lookup the API needs.
// The login, callback, and logout handlers come from the Go Loose client; this
// type only adds a non-redirecting way to turn a request into a user, because
// the client middleware answers anonymous requests with a redirect and the CMS
// has to answer its own API with a status code.
type Auth struct {
*goloose.BrowserAuth
baseURL string
httpClient *http.Client
defaultAuth *goloose.BrowserAuth
urpiAuth *goloose.BrowserAuth
devAuth *goloose.BrowserAuth
baseURL string
urpiBase string
devBase string
httpClient *http.Client
}
// New returns the browser client for the configured Go Loose application, or a
@ -64,7 +74,7 @@ func New(cfg config.GoLooseConfig) (*Auth, error) {
return nil, err
}
baseURL := "https://" + tenant + "." + authDomain
browser, err := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{
defaultAuth, err := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{
BaseURL: baseURL,
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
@ -79,12 +89,98 @@ func New(cfg config.GoLooseConfig) (*Auth, error) {
if err != nil {
return nil, err
}
return &Auth{BrowserAuth: browser, baseURL: baseURL, httpClient: httpClient}, nil
urpiBase := "https://" + tenant + ".auth.urpi.be"
urpiAuth, _ := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{
BaseURL: urpiBase,
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: "https://tell.urpi.be" + CallbackPath,
AfterLoginURL: "/",
AfterLogoutURL: "/",
LoginPath: LoginPath,
CookieName: SessionCookie,
CookieSecure: true,
HTTPClient: httpClient,
})
devBase := "https://" + tenant + ".auth.dev"
devAuth, _ := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{
BaseURL: devBase,
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: "https://tell.dev" + CallbackPath,
AfterLoginURL: "/",
AfterLogoutURL: "/",
LoginPath: LoginPath,
CookieName: SessionCookie,
CookieSecure: true,
HTTPClient: httpClient,
})
return &Auth{
defaultAuth: defaultAuth,
urpiAuth: urpiAuth,
devAuth: devAuth,
baseURL: baseURL,
urpiBase: urpiBase,
devBase: devBase,
httpClient: httpClient,
}, nil
}
func (a *Auth) forRequest(r *http.Request) (*goloose.BrowserAuth, string) {
if r != nil {
host := strings.ToLower(r.Host)
if fwd := r.Header.Get("X-Forwarded-Host"); fwd != "" {
host = strings.ToLower(fwd)
}
if strings.Contains(host, "urpi.be") && a.urpiAuth != nil {
return a.urpiAuth, a.urpiBase
}
if (strings.Contains(host, "dev") || strings.Contains(host, "localhost")) && a.devAuth != nil {
return a.devAuth, a.devBase
}
}
if a.defaultAuth != nil {
return a.defaultAuth, a.baseURL
}
if a.devAuth != nil {
return a.devAuth, a.devBase
}
return a.urpiAuth, a.urpiBase
}
// Tenant reports the Go Loose tenant that owns the login.
func (a *Auth) Tenant() string { return a.baseURL }
func (a *Auth) LoginHandler(w http.ResponseWriter, r *http.Request) {
auth, _ := a.forRequest(r)
if auth != nil {
auth.LoginHandler(w, r)
return
}
http.Error(w, "auth provider unavailable", http.StatusServiceUnavailable)
}
func (a *Auth) CallbackHandler(w http.ResponseWriter, r *http.Request) {
auth, _ := a.forRequest(r)
if auth != nil {
auth.CallbackHandler(w, r)
return
}
http.Error(w, "auth provider unavailable", http.StatusServiceUnavailable)
}
func (a *Auth) LogoutHandler(w http.ResponseWriter, r *http.Request) {
auth, _ := a.forRequest(r)
if auth != nil {
auth.LogoutHandler(w, r)
return
}
http.Error(w, "auth provider unavailable", http.StatusServiceUnavailable)
}
// SessionUser resolves the Go Loose session cookie of a request into a user.
// Requests without a cookie, with a rejected session, or without a reachable
// identity provider are simply not signed in.
@ -96,11 +192,34 @@ func (a *Auth) SessionUser(r *http.Request) (goloose.User, bool) {
if err != nil || cookie.Value == "" {
return goloose.User{}, false
}
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, a.baseURL+"/connect/userinfo", nil)
_, targetBase := a.forRequest(r)
if user, ok := a.checkUser(r.Context(), targetBase, cookie.Value); ok {
return user, true
}
if targetBase != a.baseURL && a.baseURL != "" {
if user, ok := a.checkUser(r.Context(), a.baseURL, cookie.Value); ok {
return user, true
}
}
if targetBase != a.urpiBase && a.urpiBase != "" {
if user, ok := a.checkUser(r.Context(), a.urpiBase, cookie.Value); ok {
return user, true
}
}
if targetBase != a.devBase && a.devBase != "" {
if user, ok := a.checkUser(r.Context(), a.devBase, cookie.Value); ok {
return user, true
}
}
return goloose.User{}, false
}
func (a *Auth) checkUser(ctx context.Context, baseURL, token string) (goloose.User, bool) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/connect/userinfo", nil)
if err != nil {
return goloose.User{}, false
}
request.Header.Set("Authorization", "Bearer "+cookie.Value)
request.Header.Set("Authorization", "Bearer "+token)
response, err := a.httpClient.Do(request)
if err != nil {
return goloose.User{}, false

View file

@ -146,5 +146,5 @@ func testAuth(t *testing.T, identity *httptest.Server) *Auth {
if err != nil {
t.Fatal(err)
}
return &Auth{BrowserAuth: browser, baseURL: identity.URL, httpClient: identity.Client()}
return &Auth{defaultAuth: browser, baseURL: identity.URL, httpClient: identity.Client()}
}

View file

@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
@ -209,14 +210,11 @@ func (s *ContentService) ListBadges(context.Context) ([]Badge, error) {
// Get returns one article by slug.
func (s *ContentService) Get(_ context.Context, slug string) (Content, error) {
slug = strings.ToLower(strings.TrimSpace(slug))
if !validSlug(slug) {
return Content{}, errors.New("invalid slug")
}
s.mu.RLock()
defer s.mu.RUnlock()
for _, item := range s.items {
if item.Slug == slug {
if item.Slug == slug || fmt.Sprint(item.ID) == slug {
return cloneContent(item), nil
}
}
@ -235,7 +233,7 @@ func (s *ContentService) UpdateAs(_ context.Context, c Content, actor string) (C
func (s *ContentService) update(c Content, actor string) (Content, error) {
c.Slug = strings.ToLower(strings.TrimSpace(c.Slug))
if !validSlug(c.Slug) || strings.TrimSpace(c.Title) == "" || strings.TrimSpace(c.Body) == "" {
if strings.TrimSpace(c.Title) == "" || strings.TrimSpace(c.Body) == "" {
return Content{}, errors.New("invalid content")
}
c.Badges = normalizeBadges(c.Badges)
@ -247,7 +245,7 @@ func (s *ContentService) update(c Content, actor string) (Content, error) {
index := -1
for i := range s.items {
if s.items[i].Slug == c.Slug {
if s.items[i].Slug == c.Slug || fmt.Sprint(s.items[i].ID) == c.Slug {
index = i
break
}
@ -262,8 +260,14 @@ func (s *ContentService) update(c Content, actor string) (Content, error) {
c.ID = current.ID
c.CreatedAt = current.CreatedAt
c.UpdatedAt = time.Now().Truncate(time.Second)
c.Category = normalizeCategory(current.Category)
c.Author = current.Author
if c.Category == "" {
c.Category = normalizeCategory(current.Category)
} else {
c.Category = normalizeCategory(c.Category)
}
if c.Author == "" {
c.Author = current.Author
}
if c.Status == "published" && c.PublishedAt == nil {
now := time.Now().Truncate(time.Second)
c.PublishedAt = &now
@ -294,7 +298,10 @@ func (s *ContentService) Create(_ context.Context, c Content) (Content, error) {
}
c.Slug = strings.ToLower(strings.TrimSpace(c.Slug))
if !validSlug(c.Slug) {
return Content{}, errors.New("invalid slug")
c.Slug = generateSlug(c.Title)
}
if !validSlug(c.Slug) {
c.Slug = "post"
}
c.Category = normalizeCategory(c.Category)
c.Badges = normalizeBadges(c.Badges)
@ -327,8 +334,8 @@ func (s *ContentService) Create(_ context.Context, c Content) (Content, error) {
s.nextID++
for _, it := range s.items {
if it.Slug == c.Slug {
s.mu.Unlock()
return Content{}, errors.New("slug already exists")
c.Slug = fmt.Sprintf("%s-%d", c.Slug, c.ID)
break
}
}
created := cloneContent(c)
@ -344,13 +351,10 @@ func (s *ContentService) Create(_ context.Context, c Content) (Content, error) {
func (s *ContentService) Delete(_ context.Context, slug string) error {
slug = strings.ToLower(strings.TrimSpace(slug))
if !validSlug(slug) {
return errors.New("invalid slug")
}
s.mu.Lock()
defer s.mu.Unlock()
for i, it := range s.items {
if it.Slug == slug {
if it.Slug == slug || fmt.Sprint(it.ID) == slug {
s.items = append(s.items[:i], s.items[i+1:]...)
snapshot, _ := json.Marshal(it)
s.history = append(s.history, contentRevision{ContentID: it.ID, Operation: "deleted", CreatedAt: time.Now(), Snapshot: string(snapshot)})

View file

@ -1,6 +1,7 @@
package web
import (
"bytes"
"context"
"encoding/json"
"fmt"
@ -25,6 +26,18 @@ type Handler struct {
sso security.ITokenVerifier
browser *golooseauth.Auth
demo *service.DemoAuth
// themeOrigin is the go-bananas origin supplying the shared styling contract.
// Empty means the shell keeps its own fallbacks, which is the default.
themeOrigin string
}
// WithThemeOrigin sets the origin the shared styling contract is served from.
//
// A setter rather than a constructor argument, so that adding this does not change
// the signature every existing caller and test passes through.
func (h *Handler) WithThemeOrigin(origin string) *Handler {
h.themeOrigin = origin
return h
}
func NewHandler(content service.IContentService, assets fs.FS, sso security.ITokenVerifier, browser *golooseauth.Auth, demo ...*service.DemoAuth) *Handler {
@ -47,11 +60,13 @@ func (h *Handler) Routes(origins string) http.Handler {
mux.HandleFunc("/api/auth/sudo", h.sudo)
mux.HandleFunc("/api/auth/users", h.users)
mux.HandleFunc("/api/protected", h.protected)
mux.HandleFunc("/api/test/protected", h.protected)
mux.HandleFunc("/api/content", h.contentList)
mux.HandleFunc("/api/content", h.contentCollection)
mux.HandleFunc("/api/dev/null", h.contentCollection)
mux.HandleFunc("/api/content/categories", h.contentCategories)
mux.HandleFunc("/api/content/badges", h.contentBadges)
mux.HandleFunc("/api/content/clear", h.adminReset)
mux.HandleFunc("/api/content/", h.contentDetail)
mux.HandleFunc("/api/dev/null/", h.contentDetail)
mux.HandleFunc("/api/admin/reset", h.adminReset)
mux.HandleFunc("/", h.singlePageApp)
return cors(mux, origins, h.sso.HeaderName())
@ -67,6 +82,18 @@ func (h *Handler) session(w http.ResponseWriter, r *http.Request) {
response["email"] = user.Email
response["displayName"] = user.DisplayName
}
if h.demo != nil {
response["demo"] = true
if authenticated {
for _, user := range h.demo.Users() {
if user.Username == username {
response["displayName"] = user.Name
response["email"] = user.Username
break
}
}
}
}
if h.browser != nil {
response["loginUrl"] = golooseauth.LoginPath
response["logoutUrl"] = golooseauth.LogoutPath
@ -141,6 +168,17 @@ func (h *Handler) sudo(w http.ResponseWriter, r *http.Request) {
jsonWrite(w, http.StatusOK, map[string]any{"token": token, "user": user})
}
func (h *Handler) contentCollection(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.contentList(w, r)
case http.MethodPost:
h.contentCreate(w, r)
default:
jsonWrite(w, http.StatusMethodNotAllowed, nil)
}
}
func (h *Handler) contentList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonWrite(w, http.StatusMethodNotAllowed, nil)
@ -170,7 +208,7 @@ func (h *Handler) contentCreate(w http.ResponseWriter, r *http.Request) {
jsonWrite(w, http.StatusMethodNotAllowed, nil)
return
}
_, authenticated := h.identity(r)
username, authenticated := h.identity(r)
if !authenticated {
jsonWrite(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
@ -180,6 +218,9 @@ func (h *Handler) contentCreate(w http.ResponseWriter, r *http.Request) {
jsonWrite(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if strings.TrimSpace(item.Author) == "" {
item.Author = username
}
created, err := h.content.Create(r.Context(), item)
if err != nil {
jsonWrite(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
@ -216,6 +257,7 @@ func (h *Handler) contentBadges(w http.ResponseWriter, r *http.Request) {
func (h *Handler) contentDetail(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimPrefix(r.URL.Path, "/api/content/")
slug = strings.TrimPrefix(slug, "/api/dev/null/")
switch r.Method {
case http.MethodGet:
item, err := h.content.Get(r.Context(), slug)
@ -342,9 +384,52 @@ func (h *Handler) singlePageApp(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
body := applyThemeOrigin(index, h.themeOrigin)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(index)
_, _ = w.Write(body)
}
// applyThemeOrigin adds the shared styling contract to the shell when an origin is
// configured, and adds nothing when one is not.
//
// Appending rather than substituting, which is the opposite of the obvious approach
// and the reason it is worth a comment. A placeholder in index.html has to survive
// two things that both fail quietly:
//
// - The bundler resolves a <script src> at build time. A placeholder is not a
// resolvable module, so the build fails with an error that points at the HTML
// rather than at the cause.
// - An unset or whitespace-only environment variable substitutes an empty or blank
// origin, producing a relative URL. The browser then requests this application's
// own origin, where neither path exists, and every page load logs a 404 that
// reads as a styling bug in the wrong service.
//
// Adding nothing in the unset case is correct rather than degraded: the stylesheet's
// every colour is written as var(--bn-role, <the colour it used to be>), so the
// application renders exactly as it always did.
func applyThemeOrigin(index []byte, base string) []byte {
origin := strings.TrimRight(strings.TrimSpace(base), "/")
if origin == "" {
return index
}
// Injected just before </head> so the stylesheet is parsed before the body
// renders: a contract that arrives late produces a flash of the fallback palette.
tag := "\n " +
`<link rel="stylesheet" href="` + origin + `/rt/v1/contract.css" data-theme-contract>` + "\n " +
`<script type="module" src="` + origin + `/rt/v1/components.js"></script>` + "\n"
head := []byte("</head>")
at := bytes.Index(index, head)
if at < 0 {
return index
}
out := make([]byte, 0, len(index)+len(tag))
out = append(out, index[:at]...)
out = append(out, tag...)
out = append(out, index[at:]...)
return out
}
func decode(r *http.Request, v any) bool { return json.NewDecoder(r.Body).Decode(v) == nil }
@ -366,7 +451,7 @@ func cors(next http.Handler, origin, ssoHeader string) http.Handler {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Headers", allowed)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return

View file

@ -32,6 +32,15 @@ func (f *fakeContent) Update(_ context.Context, c service.Content) (service.Cont
f.updated = true
return c, nil
}
func (f *fakeContent) Create(_ context.Context, c service.Content) (service.Content, error) {
return c, nil
}
func (f *fakeContent) Delete(_ context.Context, _ string) error {
return nil
}
func (f *fakeContent) Reset(_ context.Context) error {
return nil
}
func newTestRoutes(content *fakeContent, browser ...*golooseauth.Auth) http.Handler {
assets := fstest.MapFS{
@ -171,4 +180,48 @@ func TestServesEmbeddedAppForClientRoutes(t *testing.T) {
if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), `id="root"`) {
t.Fatalf("spa fallback status = %d body = %q", page.Code, page.Body.String())
}
devNull := httptest.NewRecorder()
routes.ServeHTTP(devNull, httptest.NewRequest(http.MethodGet, "/dev/null", nil))
if devNull.Code != http.StatusOK || !strings.Contains(devNull.Body.String(), `id="root"`) {
t.Fatalf("dev/null fallback status = %d body = %q", devNull.Code, devNull.Body.String())
}
backlogRc := httptest.NewRecorder()
routes.ServeHTTP(backlogRc, httptest.NewRequest(http.MethodGet, "/etc/backlog.rc", nil))
if backlogRc.Code != http.StatusOK || !strings.Contains(backlogRc.Body.String(), `id="root"`) {
t.Fatalf("backlog.rc fallback status = %d body = %q", backlogRc.Code, backlogRc.Body.String())
}
}
func TestContentCRUDAndClear(t *testing.T) {
content := &fakeContent{}
routes := newTestRoutes(content)
// Create with SSO header
postReq := httptest.NewRequest(http.MethodPost, "/api/content", strings.NewReader(`{"title":"New Post","body":"Content body"}`))
postReq.Header.Set("X-SSO-Token", "valid")
postRec := httptest.NewRecorder()
routes.ServeHTTP(postRec, postReq)
if postRec.Code != http.StatusCreated {
t.Fatalf("create status = %d, want %d", postRec.Code, http.StatusCreated)
}
// Delete with SSO header
delReq := httptest.NewRequest(http.MethodDelete, "/api/content/post", nil)
delReq.Header.Set("X-SSO-Token", "valid")
delRec := httptest.NewRecorder()
routes.ServeHTTP(delRec, delReq)
if delRec.Code != http.StatusNoContent {
t.Fatalf("delete status = %d, want %d", delRec.Code, http.StatusNoContent)
}
// Reset with SSO header
resetReq := httptest.NewRequest(http.MethodPost, "/api/admin/reset", nil)
resetReq.Header.Set("X-SSO-Token", "valid")
resetRec := httptest.NewRecorder()
routes.ServeHTTP(resetRec, resetReq)
if resetRec.Code != http.StatusOK {
t.Fatalf("reset status = %d, want %d", resetRec.Code, http.StatusOK)
}
}

View file

@ -0,0 +1,75 @@
package web
import (
"strings"
"testing"
)
const shell = `<!doctype html>
<html lang="en">
<head>
<title>Urpi's backlog</title>
</head>
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
</html>`
// TestThemeLinesAreAddedWhenConfigured is the whole feature: the contract reaches
// the page.
func TestThemeLinesAreAddedWhenConfigured(t *testing.T) {
got := string(applyThemeOrigin([]byte(shell), "https://bananas-dev.urpi.be"))
if !strings.Contains(got, `href="https://bananas-dev.urpi.be/rt/v1/contract.css"`) {
t.Errorf("the contract stylesheet was not added:\n%s", got)
}
if !strings.Contains(got, `src="https://bananas-dev.urpi.be/rt/v1/components.js"`) {
t.Errorf("the control module was not added:\n%s", got)
}
// Injected before </head>, so the stylesheet is parsed before the body renders. A
// contract that arrives late shows a flash of the fallback palette.
headClose := strings.Index(got, "</head>")
link := strings.Index(got, "contract.css")
if link < 0 || link > headClose {
t.Errorf("the contract link is not inside the head (link=%d, head=%d)", link, headClose)
}
// The application script must survive untouched.
if !strings.Contains(got, `src="/src/main.tsx"`) {
t.Error("the application script was altered")
}
}
func TestThemeOriginTrailingSlashIsTrimmed(t *testing.T) {
for _, base := range []string{
"https://bananas-dev.urpi.be/",
"https://bananas-dev.urpi.be///",
} {
got := string(applyThemeOrigin([]byte(shell), base))
if strings.Contains(got, "be//") {
t.Errorf("base %q produced a doubled slash:\n%s", base, got)
}
}
}
// TestNothingIsAddedWhenNoOriginIsConfigured is the branch that matters most: an
// unset or blank origin must leave the page byte-identical, because the stylesheet's
// var() fallbacks are what carry the application in that case.
func TestNothingIsAddedWhenNoOriginIsConfigured(t *testing.T) {
for _, base := range []string{"", " ", "\t", "/"} {
got := applyThemeOrigin([]byte(shell), base)
if string(got) != shell {
t.Errorf("base %q modified the shell:\n%s", base, got)
}
if strings.Contains(string(got), "rt/v1") {
t.Errorf("base %q left a contract reference behind:\n%s", base, got)
}
}
}
// TestShellWithoutHeadIsUnchanged covers a malformed shell. Returning the input
// unchanged is better than corrupting it, and better than a 500 on a page that would
// otherwise have rendered.
func TestShellWithoutHeadIsUnchanged(t *testing.T) {
odd := []byte("<html><body>no head</body></html>")
if got := applyThemeOrigin(odd, "https://bananas-dev.urpi.be"); string(got) != string(odd) {
t.Errorf("a shell with no head was modified:\n%s", got)
}
}

View file

@ -3,8 +3,24 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1e1e2e" />
<meta name="theme-color" content="#120826" />
<title>Urpi's backlog</title>
<!--
The shared styling contract and its two controls are added to this shell by the
server, from CMS_THEME_BASE_URL. They are not written here because a build-time
placeholder has to survive two things that both break quietly: the bundler tries
to resolve a module URL at build time and fails the build, and an unset or
whitespace-only environment variable produces a relative URL that 404s on every
page load.
With the origin unset, nothing is added and the stylesheet's own fallbacks carry
the application: every colour in styles.css is written as
var(--bn-role, <the colour it used to be>), so the page renders exactly as it
did before the contract existed.
See internal/web/handlers.go, applyThemeOrigin.
-->
</head>
<body>
<div id="root"></div>

View file

@ -1,13 +1,14 @@
{
"name": "cms-ui",
"name": "go-tell-ui",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cms-ui",
"name": "go-tell-ui",
"version": "0.1.0",
"dependencies": {
"lucide-react": "1.52.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.9.1"
@ -1266,7 +1267,7 @@
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@ -1395,7 +1396,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/debug": {
@ -1561,6 +1562,21 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.52.0.tgz",
"integrity": "sha512-TUgPtl5ZI9WgxOcbu4ckaC5B3l1qxPQrCMgb6OfUB9owx/OvSJfcf93SMgUWAxRw5/1XDfh9uI5F5iNGXCcGDQ==",
"license": "ISC",
"peerDependencies": {
"@types/react": "*",
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View file

@ -6,9 +6,10 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --build --force"
},
"dependencies": {
"lucide-react": "1.52.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.9.1"

View file

@ -1,4 +1,5 @@
import {useEffect, useRef, useState} from 'react'
import {useEffect, useRef, useState, type ReactNode} from 'react'
import {ArrowLeft, X} from 'lucide-react'
import {Link, Navigate, Route, Routes, useParams} from 'react-router-dom'
import {
type Content,
@ -12,6 +13,7 @@ import {
import {useAuth} from './auth'
import {ContentCard} from './components/ContentCard'
import {ContentFilters} from './components/ContentFilters'
import {Avatar, LoginGate} from './components/AccountMenu'
import {Shell} from './components/Shell'
import {type Badge, type Category} from './models'
@ -81,7 +83,7 @@ function ContentList() {
return <Shell>
<section className="content-page">
<div className="page-heading">
<div><span className="eyebrow">$ cd ~/backlog && ls</span></div>
<div><span className="eyebrow">$ cd /dev/null && ls</span></div>
</div>
<ContentFilters categories={categories} badgeOptions={badgeOptions} category={category} status={status}
badges={badges} badgeInput={badgeInput} onCategoryChange={setCategory} onStatusChange={setStatus}
@ -136,8 +138,8 @@ function ContentDetail() {
<div className="card"><p>Loading content…</p></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>
<div className="card"><p className="error" role="alert">{error ?? 'Content not found.'}</p><Link to="/dev/null">Back
to /dev/null</Link></div>
</Shell>
const save = async () => {
setSaving(true);
@ -161,7 +163,9 @@ function ContentDetail() {
}
return <Shell>
<article className="detail-page">
<Link className="back-link" to="/content">← All content</Link>
<Link className="back-link" to="/dev/null">
<ArrowLeft aria-hidden="true"/> All content (/dev/null)
</Link>
{error && <p className="error" role="alert">{error}</p>}
{editing ? <div className="editor">
<label>Title<input value={draft.title}
@ -181,7 +185,7 @@ function ContentDetail() {
<button type="button" aria-label={`Remove ${badge}`} onClick={() => setDraft({
...draft,
badges: (draft.badges ?? []).filter(value => value !== badge)
})}>×</button></span>)}
})}><X aria-hidden="true"/></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
@ -215,21 +219,49 @@ function ContentDetail() {
</Shell>
}
import {BacklogRc} from './components/BacklogRc'
function RequireAuth({title, children}: {title: string; children: ReactNode}) {
const {authenticated, loading} = useAuth()
if (loading) return <Shell><p className="muted">Checking session…</p></Shell>
if (!authenticated) return <Shell><LoginGate title={title}/></Shell>
return children
}
function ProfilePage() {
const {avatarUrl, displayName, email, username} = useAuth()
const name = (displayName || username || 'Profile').trim()
return <Shell>
<article className="detail-page profile-page">
<span className="eyebrow">account</span>
<div className="profile-card">
<Avatar name={name} url={avatarUrl} size={72}/>
<h1>{name}</h1>
{email && <p className="muted">{email}</p>}
</div>
</article>
</Shell>
}
function ProtectedTest() {
return <Shell>
<section className="card protected-card"><span className="eyebrow">Owner session</span><h1>Protected
content</h1><p>Your SSO proxy asserted the owner identity for this request.</p><Link
className="primary-button inline-button" to="/content">Browse content</Link></section>
content</h1><p>Your session is signed in. This page is not linked until you are.</p><Link
className="primary-button inline-button" to="/dev/null">Browse /dev/null</Link></section>
</Shell>
}
export function App() {
return <Routes>
<Route path="/" element={<Navigate to="/content" replace/>}/>
<Route path="/protected" element={<ProtectedTest/>}/>
<Route path="/test" element={<ProtectedTest/>}/>
<Route path="/content" element={<ContentList/>}/>
<Route path="/content/:id" element={<ContentDetail/>}/>
<Route path="*" element={<Navigate to="/content" replace/>}/>
<Route path="/" element={<Navigate to="/dev/null" replace/>}/>
<Route path="/dev/null" element={<ContentList/>}/>
<Route path="/dev/null/:id" element={<ContentDetail/>}/>
<Route path="/etc/backlog.rc" element={<RequireAuth title="Backlog management"><BacklogRc/></RequireAuth>}/>
<Route path="/profile" element={<RequireAuth title="Profile"><ProfilePage/></RequireAuth>}/>
<Route path="/content" element={<Navigate to="/dev/null" replace/>}/>
<Route path="/content/:id" element={<Navigate to="/dev/null" replace/>}/>
<Route path="/protected" element={<RequireAuth title="Protected content"><ProtectedTest/></RequireAuth>}/>
<Route path="/test" element={<RequireAuth title="Protected content"><ProtectedTest/></RequireAuth>}/>
<Route path="*" element={<Navigate to="/dev/null" replace/>}/>
</Routes>
}

View file

@ -1,6 +1,6 @@
import {type Badge, type Category, type Content, type ContentListResponse, type ContentUpdate} from './models'
import {type Badge, type Category, type Content, type ContentListResponse, type ContentUpdate, type NewContent} from './models'
import {type DemoUser, type SessionResponse} from './models'
export type {Badge, Category, Content, ContentListResponse, ContentUpdate, DemoUser, SessionResponse}
export type {Badge, Category, Content, ContentListResponse, ContentUpdate, NewContent, DemoUser, SessionResponse}
const baseUrl = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, '')
export const apiConfig = {
@ -140,3 +140,34 @@ export function updateContent(id: string, content: ContentUpdate): Promise<Conte
body: JSON.stringify(content),
}).then(normalizeContent)
}
export function createContent(content: NewContent): Promise<Content> {
return contentRequest<RawContent>('', {
method: 'POST',
body: JSON.stringify(content),
}).then(normalizeContent)
}
export async function deleteContent(idOrSlug: string): Promise<void> {
const response = await fetch(apiUrl(`${apiConfig.content}/${encodeURIComponent(idOrSlug)}`), {
method: 'DELETE',
credentials: 'include',
headers: { Accept: 'application/json', ...demoAuthHeaders() },
})
if (!response.ok) {
if (response.status === 401) throw new Error('You must be signed in via SSO to delete content.')
throw new Error((await response.text()) || 'Unable to delete content.')
}
}
export async function clearContentTable(): Promise<void> {
const response = await fetch(apiUrl('/api/admin/reset'), {
method: 'POST',
credentials: 'include',
headers: { Accept: 'application/json', ...demoAuthHeaders() },
})
if (!response.ok) {
if (response.status === 401) throw new Error('You must be signed in via SSO to clear the table.')
throw new Error((await response.text()) || 'Failed to clear table.')
}
}

View file

@ -10,6 +10,10 @@ type AuthState = {
authenticated: boolean
canEdit: boolean
username?: string
displayName?: string
email?: string
avatarUrl?: string
demo: boolean
error: string | null
refresh: () => Promise<void>
canSignIn: boolean
@ -25,6 +29,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [authenticated, setAuthenticated] = useState(false)
const [canEdit, setCanEdit] = useState(false)
const [username, setUsername] = useState<string | undefined>()
const [displayName, setDisplayName] = useState<string | undefined>()
const [email, setEmail] = useState<string | undefined>()
const [avatarUrl, setAvatarUrl] = useState<string | undefined>()
const [demo, setDemo] = useState(false)
const [loginUrl, setLoginUrl] = useState('')
const [logoutUrl, setLogoutUrl] = useState('')
const [loading, setLoading] = useState(true)
@ -37,11 +45,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setAuthenticated(session?.authenticated === true)
setCanEdit(session?.canEdit === true)
setUsername(session?.username)
setDisplayName(session?.displayName)
setEmail(session?.email)
setAvatarUrl(session?.avatarUrl)
setDemo(session?.demo === true)
setLoginUrl(session?.loginUrl ?? '')
setLogoutUrl(session?.logoutUrl ?? '')
} catch (err) {
setAuthenticated(false)
setCanEdit(false)
setDisplayName(undefined)
setEmail(undefined)
setAvatarUrl(undefined)
setError(err instanceof Error ? err.message : 'Unable to check your session.')
} finally {
setLoading(false)
@ -63,6 +78,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setAuthenticated(true)
setCanEdit(true)
setUsername(result.user.username)
setDisplayName(result.user.name)
setEmail(result.user.username)
}, [])
const signOut = useCallback(() => {
if (logoutUrl) {
@ -73,13 +90,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setAuthenticated(false)
setCanEdit(false)
setUsername(undefined)
setDisplayName(undefined)
setEmail(undefined)
setAvatarUrl(undefined)
}, [logoutUrl])
const canSignIn = apiConfig.signIn !== '' || loginUrl !== ''
const canSignOut = logoutUrl !== ''
const value = useMemo(
() => ({ loading, authenticated, canEdit, username, error, refresh, canSignIn, canSignOut, signIn, sudo, signOut }),
[loading, authenticated, canEdit, username, error, refresh, canSignIn, canSignOut, signIn, sudo, signOut],
() => ({ loading, authenticated, canEdit, username, displayName, email, avatarUrl, demo, error, refresh, canSignIn, canSignOut, signIn, sudo, signOut }),
[loading, authenticated, canEdit, username, displayName, email, avatarUrl, demo, error, refresh, canSignIn, canSignOut, signIn, sudo, signOut],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}

47
ui/app/src/bananas-elements.d.ts vendored Normal file
View file

@ -0,0 +1,47 @@
/**
* Type declarations for the custom elements the theme service ships.
*
* React 19 passes unknown elements and their attributes through to the DOM, so the
* elements work without a wrapper component. Without these declarations the tag names
* are unknown to the JSX checker and typecheck fails, which is why the elements are
* declared rather than wrapped.
*
* The augmentation targets `react`'s own JSX namespace rather than a global one:
* React 19 removed the global `JSX` namespace, so augmenting it has no effect and the
* tags stay unrecognised.
*/
declare global {
namespace React {
namespace JSX {
/** Reads the published palette list from a theme service. */
interface BananasThemeSelectorProps extends HTMLAttributes<HTMLElement> {
/**
* The service runtime base. Optional: when omitted the control reads the origin
* from the contract stylesheet already linked on the page, and falls back to
* the same-origin path.
*/
base?: string
/** Accessible label. */
label?: string
/** Omit the "no theme" entry. */
compact?: boolean
}
/** Admin chrome, light, or dark. */
interface BananasAppearanceToggleProps extends HTMLAttributes<HTMLElement> {
/** Accessible label. */
label?: string
/** Render the text labels rather than icons only. */
showLabels?: boolean
}
interface IntrinsicElements {
'bananas-theme-selector': BananasThemeSelectorProps
'bananas-appearance-toggle': BananasAppearanceToggleProps
}
}
}
}
export {}

View file

@ -0,0 +1,113 @@
import {useEffect, useId, useRef, useState} from 'react'
import {LogOut, UserRound} from 'lucide-react'
import {Link} from 'react-router-dom'
import {listDemoUsers} from '../api'
import {useAuth} from '../auth'
import {type DemoUser} from '../models'
import {SudoTerminal} from './SudoTerminal'
function initials(name: string) {
const parts = name.split(/[\s@._-]+/).filter(Boolean).slice(0, 2)
const value = parts.map(part => part[0]?.toUpperCase() ?? '').join('')
return value || '?'
}
export function Avatar({name, url, size = 24}: {name: string; url?: string; size?: number}) {
if (url) {
return <img className="avatar" src={url} alt="" width={size} height={size}/>
}
return <span className="avatar avatar-empty" style={{width: size, height: size}}>{initials(name)}</span>
}
function LoginButton() {
const {canSignIn, demo, signIn, sudo} = useAuth()
const [sudoOpen, setSudoOpen] = useState(false)
const [users, setUsers] = useState<DemoUser[]>([])
const start = () => {
if (canSignIn || !demo) {
signIn()
return
}
setSudoOpen(true)
void listDemoUsers().then(setUsers).catch(() => setUsers([]))
}
return <>
<button className="account-login" type="button" onClick={start}>
<span aria-hidden="true">›</span> Log in
</button>
{sudoOpen && <SudoTerminal users={users} onClose={() => setSudoOpen(false)} onLogin={async (username, password) => {
await sudo(username, password)
setSudoOpen(false)
}}/>}
</>
}
export function AccountMenu() {
const {authenticated, loading, username, displayName, email, avatarUrl, canSignOut, signOut} = useAuth()
const [open, setOpen] = useState(false)
const container = useRef<HTMLDivElement>(null)
const trigger = useRef<HTMLButtonElement>(null)
const panelId = useId()
const name = (displayName || username || email || 'Profile').trim()
useEffect(() => {
if (!open) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
setOpen(false)
trigger.current?.focus()
}
const onPointerDown = (event: MouseEvent) => {
if (!container.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener('keydown', onKeyDown)
document.addEventListener('mousedown', onPointerDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('mousedown', onPointerDown)
}
}, [open])
if (loading) return null
if (!authenticated) return <LoginButton/>
return <div className="account" ref={container}>
<button
ref={trigger}
type="button"
className="account-trigger"
aria-haspopup="menu"
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpen(value => !value)}
>
<Avatar name={name} url={avatarUrl}/>
<span className="account-name">{name}</span>
<span className="account-caret" aria-hidden="true">▾</span>
</button>
{open && <div className="account-menu" id={panelId} role="menu" aria-label="Account">
<div className="account-identity">
<strong className="account-display-name">{name}</strong>
{email && email !== name && <span className="account-email">{email}</span>}
</div>
<Link className="account-item" role="menuitem" to="/profile" onClick={() => setOpen(false)}>
<UserRound aria-hidden="true"/> Profile
</Link>
<button className="account-item" type="button" role="menuitem" onClick={() => { setOpen(false); signOut() }}>
<LogOut aria-hidden="true"/> Log out
</button>
{canSignOut && <p className="account-note">Signing out here also ends your sessions in the applications that use this sign-in.</p>}
</div>}
</div>
}
export function LoginGate({title}: {title: string}) {
return <section className="card protected-card">
<span className="eyebrow">authentication required</span>
<h1>{title}</h1>
<p className="muted">This page stays hidden until you are signed in.</p>
<LoginButton/>
</section>
}

View file

@ -0,0 +1,679 @@
import {useEffect, useMemo, useState} from 'react'
import {ArrowLeft, Check, Lock, Pencil, Plus, Trash2, TriangleAlert, X, Zap} from 'lucide-react'
import {Link} from 'react-router-dom'
import {
clearContentTable,
createContent,
deleteContent,
listBadges,
listCategories,
listContent,
updateContent,
} from '../api'
import {useAuth} from '../auth'
import {type Badge, type Category, type Content, type NewContent} from '../models'
import {Shell} from './Shell'
export function BacklogRc() {
const {authenticated, username, signIn} = useAuth()
const [items, setItems] = useState<Content[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const [categories, setCategories] = useState<Category[]>([])
const [badges, setBadges] = useState<Badge[]>([])
// Filters
const [search, setSearch] = useState('')
const [selectedCategory, setSelectedCategory] = useState('')
const [selectedStatus, setSelectedStatus] = useState('')
// Modals state
const [showClearConfirm, setShowClearConfirm] = useState(false)
const [clearing, setClearing] = useState(false)
const [itemToDelete, setItemToDelete] = useState<Content | null>(null)
const [deleting, setDeleting] = useState(false)
const [editorOpen, setEditorOpen] = useState(false)
const [editingItem, setEditingItem] = useState<Content | null>(null)
const [saving, setSaving] = useState(false)
// Form fields
const [formTitle, setFormTitle] = useState('')
const [formSlug, setFormSlug] = useState('')
const [formCategory, setFormCategory] = useState('')
const [formStatus, setFormStatus] = useState('published')
const [formBadges, setFormBadges] = useState('')
const [formSummary, setFormSummary] = useState('')
const [formBody, setFormBody] = useState('')
const [formAuthor, setFormAuthor] = useState('')
const loadData = async () => {
setLoading(true)
setError(null)
try {
const [contentRes, catRes, badgeRes] = await Promise.all([
listContent(null, '', '', []),
listCategories(),
listBadges(),
])
const list = Array.isArray(contentRes)
? contentRes
: contentRes.items ?? contentRes.content ?? contentRes.data ?? []
setItems(list)
setCategories(catRes)
setBadges(badgeRes)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load backlog data.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void loadData()
}, [])
const filteredItems = useMemo(() => {
return items.filter(item => {
if (selectedCategory && item.category !== selectedCategory) return false
if (selectedStatus && item.status !== selectedStatus) return false
if (search.trim()) {
const query = search.toLowerCase()
const matchesTitle = item.title.toLowerCase().includes(query)
const matchesSlug = item.id.toLowerCase().includes(query)
const matchesSummary = (item.summary ?? '').toLowerCase().includes(query)
const matchesAuthor = (item.author ?? '').toLowerCase().includes(query)
const matchesBadge = (item.badges ?? []).some(b => b.toLowerCase().includes(query))
if (!matchesTitle && !matchesSlug && !matchesSummary && !matchesAuthor && !matchesBadge) {
return false
}
}
return true
})
}, [items, search, selectedCategory, selectedStatus])
const stats = useMemo(() => {
const total = items.length
const published = items.filter(i => i.status === 'published').length
const draft = items.filter(i => i.status === 'draft').length
const archived = items.filter(i => i.status === 'archived').length
return {total, published, draft, archived}
}, [items])
const openCreateModal = () => {
setEditingItem(null)
setFormTitle('')
setFormSlug('')
setFormCategory(categories[0]?.slug ?? 'engineering')
setFormStatus('published')
setFormBadges('')
setFormSummary('')
setFormBody('')
setFormAuthor(username ?? 'sso-user')
setEditorOpen(true)
}
const openEditModal = (item: Content) => {
setEditingItem(item)
setFormTitle(item.title)
setFormSlug(item.id)
setFormCategory(item.category ?? '')
setFormStatus(item.status || 'published')
setFormBadges((item.badges ?? []).join(', '))
setFormSummary(item.summary ?? '')
setFormBody(item.body ?? '')
setFormAuthor(item.author ?? username ?? '')
setEditorOpen(true)
}
const handleSavePost = async (e: React.FormEvent) => {
e.preventDefault()
if (!formTitle.trim() || !formBody.trim()) {
setError('Title and Body are required.')
return
}
setSaving(true)
setError(null)
setNotice(null)
try {
const parsedBadges = formBadges
.split(',')
.map(b => b.trim().toLowerCase())
.filter(Boolean)
if (editingItem) {
// Update
await updateContent(editingItem.id, {
title: formTitle.trim(),
summary: formSummary.trim(),
body: formBody.trim(),
status: formStatus,
badges: parsedBadges,
category: formCategory.trim(),
})
setNotice(`Post "${formTitle}" updated successfully.`)
} else {
// Create
const payload: NewContent = {
title: formTitle.trim(),
slug: formSlug.trim() || undefined,
summary: formSummary.trim(),
body: formBody.trim(),
status: formStatus,
category: formCategory.trim() || undefined,
badges: parsedBadges,
author: formAuthor.trim() || username || 'sso-user',
}
await createContent(payload)
setNotice(`Post "${formTitle}" created successfully.`)
}
setEditorOpen(false)
await loadData()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save post.')
} finally {
setSaving(false)
}
}
const handleDeletePost = async () => {
if (!itemToDelete) return
setDeleting(true)
setError(null)
setNotice(null)
try {
await deleteContent(itemToDelete.id)
setNotice(`Post "${itemToDelete.title}" deleted successfully.`)
setItemToDelete(null)
await loadData()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete post.')
} finally {
setDeleting(false)
}
}
const handleClearTable = async () => {
setClearing(true)
setError(null)
setNotice(null)
try {
await clearContentTable()
setShowClearConfirm(false)
setNotice('Backlog table cleared successfully.')
await loadData()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to clear backlog table.')
} finally {
setClearing(false)
}
}
return (
<Shell>
<section className="crud-page">
<div className="page-heading">
<div>
<span className="eyebrow">$ nano /etc/backlog.rc</span>
<h1>Backlog Post Management</h1>
<p className="muted">
Interactive CRUD control plane for articles and posts. Authenticated via SSO.
</p>
</div>
<div className="page-heading-actions">
<Link to="/dev/null" className="secondary-button" style={{margin: 0, width: 'auto'}}>
<ArrowLeft aria-hidden="true" /> View /dev/null
</Link>
<button className="primary-button" type="button" onClick={openCreateModal}>
<Plus aria-hidden="true" /> New Post
</button>
<button
className="danger-button"
type="button"
onClick={() => setShowClearConfirm(true)}
title="Clear all records from the table"
>
<Trash2 aria-hidden="true" /> Clear Table
</button>
</div>
</div>
{!authenticated && (
<div className="sso-warning-banner">
<div>
<strong>
<Lock aria-hidden="true" /> SSO Authentication Required for Mutations
</strong>
<p className="small muted">
You are currently in read-only mode. Sign in via SSO to create, update, delete, or clear posts.
</p>
</div>
<button className="primary-button" type="button" onClick={signIn}>
<Zap aria-hidden="true" /> Sign In with SSO
</button>
</div>
)}
{notice && (
<div className="success-banner" role="status">
<span>
<Check aria-hidden="true" /> {notice}
</span>
<button type="button" onClick={() => setNotice(null)} aria-label="Dismiss">
<X aria-hidden="true" />
</button>
</div>
)}
{error && (
<div className="error" role="alert" style={{marginBottom: 20}}>
<span>
<TriangleAlert aria-hidden="true" /> {error}
</span>
<button
type="button"
onClick={() => setError(null)}
aria-label="Dismiss"
style={{float: 'right', background: 'none', border: 0, color: 'inherit', cursor: 'pointer'}}
>
<X aria-hidden="true" />
</button>
</div>
)}
{/* Stats Row */}
<div className="stats-grid">
<div className="stat-card">
<div className="stat-label">Total Posts</div>
<div className="stat-value">{stats.total}</div>
</div>
<div className="stat-card stat-published">
<div className="stat-label">Published</div>
<div className="stat-value">{stats.published}</div>
</div>
<div className="stat-card stat-draft">
<div className="stat-label">Drafts</div>
<div className="stat-value">{stats.draft}</div>
</div>
<div className="stat-card stat-archived">
<div className="stat-label">Archived</div>
<div className="stat-value">{stats.archived}</div>
</div>
</div>
{/* Search & Filter Toolbar */}
<div className="table-toolbar">
<div className="search-box">
<input
type="search"
placeholder="Search posts by title, slug, summary, author..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="filter-group">
<select
value={selectedCategory}
onChange={e => setSelectedCategory(e.target.value)}
aria-label="Filter by category"
>
<option value="">All Categories</option>
{categories.map(cat => (
<option key={cat.id} value={cat.slug}>{cat.name}</option>
))}
</select>
<select
value={selectedStatus}
onChange={e => setSelectedStatus(e.target.value)}
aria-label="Filter by status"
>
<option value="">All Statuses</option>
<option value="published">Published</option>
<option value="draft">Draft</option>
<option value="archived">Archived</option>
</select>
{(search || selectedCategory || selectedStatus) && (
<button
type="button"
className="text-button"
onClick={() => {
setSearch('')
setSelectedCategory('')
setSelectedStatus('')
}}
>
Reset Filters
</button>
)}
</div>
</div>
{/* Posts Table */}
<div className="table-container card" style={{maxWidth: 'none', padding: 0, margin: '0 0 40px'}}>
{loading ? (
<div style={{padding: 40, textAlign: 'center'}} className="muted">
Loading posts from /etc/backlog.rc...
</div>
) : filteredItems.length === 0 ? (
<div style={{padding: 50, textAlign: 'center'}}>
<h2>No posts found</h2>
<p className="muted">
{items.length === 0
? 'The backlog table is empty. Create a post to populate the database.'
: 'No posts match your current search and filter criteria.'}
</p>
{items.length === 0 && (
<button className="primary-button" type="button" onClick={openCreateModal} style={{marginTop: 16}}>
<Plus aria-hidden="true" /> Create First Post
</button>
)}
</div>
) : (
<table className="crud-table">
<thead>
<tr>
<th style={{width: 60}}>#</th>
<th>Title & Slug</th>
<th style={{width: 130}}>Category</th>
<th>Badges</th>
<th style={{width: 110}}>Status</th>
<th style={{width: 140}}>Author</th>
<th style={{width: 130}}>Date</th>
<th style={{width: 160, textAlign: 'right'}}>Actions</th>
</tr>
</thead>
<tbody>
{filteredItems.map((item, idx) => (
<tr key={item.id}>
<td className="muted font-mono">{idx + 1}</td>
<td>
<div className="post-title-cell">
<Link to={`/dev/null/${item.id}`} className="post-link font-semibold">
{item.title}
</Link>
<span className="post-slug font-mono">/{item.id}</span>
</div>
{item.summary && <div className="post-summary-preview muted">{item.summary}</div>}
</td>
<td>
<span className="category-pill">{item.category || 'general'}</span>
</td>
<td>
<div className="badge-list-inline">
{(item.badges ?? []).map(b => (
<span key={b} className="badge small">{b}</span>
))}
</div>
</td>
<td>
<span className={`status-pill status-${item.status}`}>
{item.status}
</span>
</td>
<td className="muted small font-mono">{item.author || 'system'}</td>
<td className="muted small">
{item.updatedAt
? new Date(item.updatedAt).toLocaleDateString()
: item.createdAt
? new Date(item.createdAt).toLocaleDateString()
: '-'}
</td>
<td style={{textAlign: 'right'}}>
<div className="action-buttons-cell">
<button
type="button"
className="table-action-button"
onClick={() => openEditModal(item)}
title="Edit this post"
>
<Pencil aria-hidden="true" /> Edit
</button>
<button
type="button"
className="table-action-button delete"
onClick={() => setItemToDelete(item)}
title="Delete this post"
aria-label={`Delete ${item.title}`}
>
<Trash2 aria-hidden="true" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
{/* Confirmation Screen / Modal for Clear Table */}
{showClearConfirm && (
<div className="terminal-backdrop" role="dialog" aria-modal="true">
<div className="terminal-window confirm-modal">
<div className="terminal-titlebar">
<span>
<TriangleAlert aria-hidden="true" /> CONFIRMATION: CLEAR TABLE
</span>
<button
type="button"
className="terminal-close"
onClick={() => setShowClearConfirm(false)}
disabled={clearing}
aria-label="Close"
>
<X aria-hidden="true" />
</button>
</div>
<div className="terminal-body" style={{padding: 28}}>
<div className="warning-callout">
<h3>Permanent Deletion Warning</h3>
<p>
You are about to clear all <strong>{items.length}</strong> posts from the backlog table.
All articles, summaries, revisions, and content will be irreversibly removed.
</p>
<p className="muted small">
SSO authentication will authorize this operation against <code>/api/admin/reset</code>.
</p>
</div>
<div className="modal-actions" style={{display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 18}}>
<button
type="button"
className="secondary-button"
style={{width: 'auto', margin: 0}}
onClick={() => setShowClearConfirm(false)}
disabled={clearing}
>
Cancel
</button>
<button
type="button"
className="danger-button"
onClick={() => void handleClearTable()}
disabled={clearing}
>
{clearing ? 'Clearing Table…' : 'Yes, Permanently Clear Table'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Single Delete Confirmation Modal */}
{itemToDelete && (
<div className="terminal-backdrop" role="dialog" aria-modal="true">
<div className="terminal-window confirm-modal">
<div className="terminal-titlebar">
<span>CONFIRM DELETION</span>
<button
type="button"
className="terminal-close"
onClick={() => setItemToDelete(null)}
disabled={deleting}
>
×
</button>
</div>
<div className="terminal-body" style={{padding: 28}}>
<p>
Are you sure you want to delete post <strong>&quot;{itemToDelete.title}&quot;</strong> (<code>/{itemToDelete.id}</code>)?
</p>
<div className="modal-actions" style={{display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 20}}>
<button
type="button"
className="secondary-button"
style={{width: 'auto', margin: 0}}
onClick={() => setItemToDelete(null)}
disabled={deleting}
>
Cancel
</button>
<button
type="button"
className="danger-button"
onClick={() => void handleDeletePost()}
disabled={deleting}
>
{deleting ? 'Deleting…' : 'Delete Post'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Create / Edit Post Modal */}
{editorOpen && (
<div className="terminal-backdrop" role="dialog" aria-modal="true">
<div className="terminal-window editor-modal" style={{maxWidth: 750}}>
<div className="terminal-titlebar">
<span>{editingItem ? `EDIT POST: /${editingItem.id}` : 'NEW POST: /etc/backlog.rc'}</span>
<button
type="button"
className="terminal-close"
onClick={() => setEditorOpen(false)}
disabled={saving}
>
×
</button>
</div>
<form onSubmit={e => void handleSavePost(e)} className="terminal-body editor-form" style={{padding: 28}}>
<div className="form-grid">
<label className="form-field full-width">
<span>Title *</span>
<input
type="text"
required
placeholder="Enter article title"
value={formTitle}
onChange={e => setFormTitle(e.target.value)}
/>
</label>
{!editingItem && (
<label className="form-field">
<span>Custom Slug (Optional)</span>
<input
type="text"
placeholder="e.g. my-awesome-post"
value={formSlug}
onChange={e => setFormSlug(e.target.value)}
/>
</label>
)}
<label className="form-field">
<span>Category</span>
<input
list="crud-category-options"
placeholder="Select or enter category"
value={formCategory}
onChange={e => setFormCategory(e.target.value)}
/>
<datalist id="crud-category-options">
{categories.map(c => (
<option key={c.id} value={c.slug}>{c.name}</option>
))}
</datalist>
</label>
<label className="form-field">
<span>Status</span>
<select value={formStatus} onChange={e => setFormStatus(e.target.value)}>
<option value="published">Published</option>
<option value="draft">Draft</option>
<option value="archived">Archived</option>
</select>
</label>
<label className="form-field full-width">
<span>Badges (comma separated)</span>
<input
type="text"
placeholder="go, rancher, kubernetes, sso"
value={formBadges}
onChange={e => setFormBadges(e.target.value)}
/>
</label>
<label className="form-field full-width">
<span>Summary</span>
<textarea
rows={2}
placeholder="Brief description or teaser..."
value={formSummary}
onChange={e => setFormSummary(e.target.value)}
/>
</label>
<label className="form-field full-width">
<span>Body *</span>
<textarea
rows={8}
required
placeholder="Full article content in markdown or plaintext..."
value={formBody}
onChange={e => setFormBody(e.target.value)}
/>
</label>
<label className="form-field">
<span>Author</span>
<input
type="text"
placeholder="Author name or email"
value={formAuthor}
onChange={e => setFormAuthor(e.target.value)}
/>
</label>
</div>
<div className="modal-actions" style={{display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 24}}>
<button
type="button"
className="secondary-button"
style={{width: 'auto', margin: 0}}
onClick={() => setEditorOpen(false)}
disabled={saving}
>
Cancel
</button>
<button
type="submit"
className="primary-button"
style={{width: 'auto', margin: 0}}
disabled={saving}
>
{saving ? 'Saving Post…' : editingItem ? 'Save Changes' : 'Create Post'}
</button>
</div>
</form>
</div>
</div>
)}
</Shell>
)
}

View file

@ -1,15 +1,18 @@
import {ArrowRight} from 'lucide-react'
import {Link} from 'react-router-dom'
import {type Content} from '../models'
export function ContentCard({item}: {item: Content}) {
return <article className="content-card">
<span className="status">{item.status}</span>
<h2><Link to={`/content/${item.id}`}>{item.title}</Link></h2>
<h2><Link to={`/dev/null/${item.id}`}>{item.title}</Link></h2>
<p>{item.summary}</p>
<p className="content-meta">by {item.author ?? 'unknown'}
{item.publishedAt && <> · published {new Date(item.publishedAt).toLocaleDateString()}</>}</p>
{item.badges && item.badges.length > 0 &&
<div className="badge-list">{item.badges.map(badge => <span className="badge" key={badge}>{badge}</span>)}</div>}
<Link className="read-link" to={`/content/${item.id}`}>Read more <span aria-hidden="true">→</span></Link>
<Link className="read-link" to={`/dev/null/${item.id}`}>
Read more <ArrowRight aria-hidden="true" />
</Link>
</article>
}

View file

@ -1,3 +1,4 @@
import {X} from 'lucide-react'
import {type Badge, type Category} from '../models'
type Props = {
@ -36,7 +37,9 @@ export function ContentFilters(props: Props) {
<div className="badge-input">
{props.badges.map(badge => <span className="filter-pill" key={badge}>{badge}
<button type="button" aria-label={`Remove ${badge}`}
onClick={() => props.onBadgesChange(props.badges.filter(value => value !== badge))}>×</button></span>)}
onClick={() => props.onBadgesChange(props.badges.filter(value => value !== badge))}>
<X aria-hidden="true"/>
</button></span>)}
<input list="badge-options" value={props.badgeInput} placeholder="Type a badge and press Enter"
onChange={event => props.onBadgeInputChange(event.target.value)}
onKeyDown={event => { if (event.key === 'Enter') { event.preventDefault(); addBadge() } }}/>

View file

@ -1,52 +1,146 @@
import {useEffect, useState, type ReactNode} from 'react'
import {Link} from 'react-router-dom'
import {listDemoUsers} from '../api'
import {Link, useLocation} from 'react-router-dom'
import {useAuth} from '../auth'
import {type DemoUser} from '../models'
import {SudoTerminal} from './SudoTerminal'
import {AccountMenu} from './AccountMenu'
/**
* The palettes this application offers.
*
* The list is no longer the source of truth: the selector reads the published palettes
* from the theme service, so a palette published there appears here with no change to
* this file. These remain as the names this application's own rules key on, and as the
* set the fallback below validates against.
*/
export type Theme = 'cyberpunk' | 'nordic' | 'obsidian' | 'terminal'
const THEMES: readonly string[] = ['cyberpunk', 'nordic', 'obsidian', 'terminal']
/** The palette used before the service has answered, and if it never does. */
const DEFAULT_THEME = 'cyberpunk'
/**
* Reads the palette from the shared contract.
*
* The control itself is a custom element shipped by the service, so it writes
* `data-theme` on the root element directly. This function exists to answer "which
* palette is current?" for the few places that need to know, without the component
* holding a second copy of the state.
*
* @returns the current palette slug, or the default when none is set
*/
export function currentTheme(): string {
return document.documentElement.dataset.theme || DEFAULT_THEME
}
type Palette = {slug: string; name: string}
function ThemeDropdown() {
const [themes, setThemes] = useState<Palette[]>(THEMES.map(slug => ({slug, name: slug})))
const [value, setValue] = useState(DEFAULT_THEME)
useEffect(() => {
setValue(currentTheme())
const href = document.querySelector('link[data-theme-contract]')?.getAttribute('href') ?? ''
const base = href.replace(/\/contract\.css(?:\?.*)?$/, '')
if (!base) return
void fetch(`${base}/contract.json`)
.then(response => response.ok ? response.json() : Promise.reject(response.status))
.then((payload: {themes?: Palette[]}) => {
const published = (payload.themes ?? []).filter(theme => theme.slug)
if (published.length > 0) setThemes(published)
})
.catch(() => {})
}, [])
return <label className="theme-dropdown-wrap">
<select
className="theme-dropdown"
aria-label="Palette"
value={value}
onChange={event => {
const slug = event.target.value
setValue(slug)
document.documentElement.dataset.theme = slug
window.localStorage.setItem('urpi-theme', slug)
}}
>
{themes.map(theme => <option key={theme.slug} value={theme.slug}>{theme.name}</option>)}
</select>
</label>
}
export function Shell({children}: {children: ReactNode}) {
const {authenticated, loading, canSignIn, canSignOut, signIn, signOut, username, sudo} = useAuth()
const [sudoOpen, setSudoOpen] = useState(false)
const [helpOpen, setHelpOpen] = useState(false)
const [users, setUsers] = useState<DemoUser[]>([])
const [dark, setDark] = useState(() => window.localStorage.getItem('urpi-theme') !== 'light')
const {authenticated, loading} = useAuth()
const location = useLocation()
useEffect(() => { void listDemoUsers().then(setUsers) }, [])
// No React state for the palette. The control is a custom element that mutates
// `data-theme` on the root, and everything that depends on the palette is CSS
// keyed on that attribute, so there is nothing for a re-render to update. Keeping a
// copy in React would be a second source of truth that could disagree with the DOM.
//
// This effect only bridges the storage key: it migrates an existing choice forward
// once, and mirrors later changes back so anything still reading `urpi-theme` — a
// bookmarklet, a support script, a colleague's muscle memory — keeps working.
useEffect(() => {
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
window.localStorage.setItem('urpi-theme', dark ? 'dark' : 'light')
}, [dark])
// Establish a palette on first load. Without this the page starts with no
// data-theme at all, so the contract matches no rule and the application renders
// on the stylesheet's fallbacks. Those fallbacks happen to be cyberpunk, which is
// why this was not obvious, but the palette would not actually be selected and the
// appearance control would have nothing to switch from.
const legacy = window.localStorage.getItem('urpi-theme')
const stored = legacy && THEMES.includes(legacy) ? legacy : null
if (!document.documentElement.dataset.theme) {
document.documentElement.dataset.theme = stored || DEFAULT_THEME
}
return <main className="shell">
<header className="header">
<Link className="brand" to="/content"><span className="brand-prompt">~/</span> Urpi's backlog
<span className="brand-cursor">_</span><small>linux · go · java · ideas</small></Link>
<div className="header-actions">
{!loading && <button className="mode-badge" type="button" onClick={() => {
if (authenticated) {
if (canSignOut) signOut()
return
}
if (users.length > 0) setSudoOpen(true)
else if (canSignIn) signIn()
}}>
{authenticated ? `${username ?? 'Owner'} · sudo` : 'Anonymous · read-only'}
</button>}
<button className="theme-button" type="button" aria-label="Demo users"
onClick={() => setHelpOpen(value => !value)}>?</button>
<button className="theme-button" type="button" aria-label={`Switch to ${dark ? 'light' : 'dark'} theme`}
onClick={() => setDark(value => !value)}>{dark ? '☀ Light' : '☾ Dark'}</button>
</div>
</header>
{helpOpen && users.length > 0 && <aside className="card terminal-panel">
<strong>sudo users</strong><p className="muted">Demo only: each password is the same as the username.</p>
{users.map(item => <div key={item.username}><code>sudo {item.username}</code> · {item.name}</div>)}
</aside>}
{sudoOpen && <SudoTerminal users={users} onLogin={async (user, password) => {
await sudo(user, password)
setSudoOpen(false)
}} onClose={() => setSudoOpen(false)}/>}
{children}
</main>
const onChange = (event: Event) => {
const slug = (event as CustomEvent<{slug: string | null}>).detail?.slug
window.localStorage.setItem('urpi-theme', slug || DEFAULT_THEME)
}
document.addEventListener('bananas:theme', onChange)
return () => document.removeEventListener('bananas:theme', onChange)
}, [])
return (
<main className="shell">
<header className="header">
<div className="brand-group">
<Link className="brand" to="/dev/null">
<span className="brand-prompt">~/</span> Urpi&apos;s backlog
<span className="brand-cursor">_</span>
<small>linux · go · java · ideas</small>
</Link>
<nav className="header-nav" aria-label="Main Navigation">
<Link
to="/dev/null"
className={`nav-tab ${location.pathname.startsWith('/dev/null') || location.pathname === '/' ? 'active' : ''}`}
>
/dev/null
</Link>
{!loading && authenticated && <Link
to="/etc/backlog.rc"
className={`nav-tab ${location.pathname.startsWith('/etc/backlog.rc') ? 'active' : ''}`}
>
/etc/backlog.rc
</Link>}
</nav>
</div>
<div className="header-actions">
{/*
The palette and appearance controls, injected rather than hand-written.
They live in a shadow root, so this application's own CSS cannot restyle
them, and they read the palettes the service publishes rather than a list
duplicated here.
*/}
<ThemeDropdown />
<bananas-appearance-toggle title="Appearance" />
<AccountMenu/>
</div>
</header>
{children}
</main>
)
}

View file

@ -1,4 +1,5 @@
import {useRef, useState, type FormEvent, type KeyboardEvent} from 'react'
import {X} from 'lucide-react'
import {type DemoUser} from '../models'
type Props = {
@ -46,9 +47,12 @@ export function SudoTerminal({users, onLogin, onClose}: Props) {
<form ref={form} className="terminal-window" role="dialog" aria-modal="true" aria-labelledby="sudo-title" onSubmit={submit}>
<div className="terminal-titlebar">
<span id="sudo-title">urpi@backlog: ~</span>
<button type="button" className="terminal-close" aria-label="Close sudo terminal" onClick={onClose}>×</button>
<button type="button" className="terminal-close" aria-label="Close sudo terminal" onClick={onClose}>
<X aria-hidden="true"/>
</button>
</div>
<div className="terminal-body">
{users.length > 0 && <p className="muted">users: {users.map(item => item.username).join(', ')} — password is the username</p>}
<label><span className="terminal-prompt">$</span> sudo <input autoFocus value={user}
onChange={event => setUser(event.target.value)} onKeyDown={handleUserKeyDown}
placeholder="user" aria-label="Username"/></label>

View file

@ -12,6 +12,10 @@ export type SessionResponse = {
username?: string
email?: string
displayName?: string
avatarUrl?: string
// Demo profile: the header login opens the local sudo terminal instead of
// redirecting to Go Loose.
demo?: boolean
// Go Loose advertises its own login and logout endpoints; the UI follows them
// instead of sending the browser to a single-sign-on proxy.
loginUrl?: string

View file

@ -12,7 +12,18 @@ export type Content = {
publishedAt?: string
}
export type ContentUpdate = Pick<Content, 'title' | 'summary' | 'body' | 'status' | 'badges'>
export type ContentUpdate = Pick<Content, 'title' | 'summary' | 'body' | 'status' | 'badges'> & { category?: string }
export type NewContent = {
title: string
slug?: string
summary?: string
body: string
status?: string
category?: string
badges?: string[]
author?: string
}
export type ContentListResponse = {
items?: Content[]

View file

@ -1,2 +1,2 @@
export type {Badge, Category, Content, ContentListResponse, ContentUpdate} from './content'
export type {Badge, Category, Content, ContentListResponse, ContentUpdate, NewContent} from './content'
export type {DemoUser, SessionResponse, User} from './auth'

View file

@ -1,204 +1,416 @@
/* ==========================================================================
THEME
Colours come from the shared styling contract, which is why this file has
no palette of its own. Every value below is written as
var(--bn-<role>, <the value this file used to hardcode>)
The second argument is the colour that was there before, so a page that does not
load contract.css still renders exactly as it did: the migration is reversible by
deleting one <link>.
Selecting a palette writes data-theme on <html>; selecting an appearance writes
data-mode. The contract supplies values for both, so this stylesheet is the same
for every palette and both appearances.
========================================================================== */
:root {
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
color: #cdd6f4;
background: #1e1e2e;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: var(--bn-text, #cdd6f4);
background: var(--bn-bg, #120826);
font-synthesis: none;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-width: 320px;
background: radial-gradient(circle at 85% -10%, #302b50 0, transparent 38rem), #1e1e2e;
background: radial-gradient(circle at 85% -10%, var(--bn-accent, #2e1053) 0, transparent 38rem), var(--bn-bg, #120826);
transition: background 0.3s ease, color 0.3s ease;
}
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; }
.header-actions { display: flex; align-items: center; gap: 8px; }
.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; }
.shell { min-height: 100vh; max-width: 1140px; margin: auto; padding: 0 28px 72px; }
/* Header & Nav */
.header { position: relative; z-index: 20; height: 94px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid rgba(255,255,255,0.08); margin-bottom: 24px; }
.brand-group { display: flex; align-items: center; gap: 28px; }
.brand { position: relative; color: var(--bn-text, #f5e0e8); font-size: 1.15rem; font-weight: 750; text-decoration: none; letter-spacing: -.02em; }
.brand:hover { color: var(--bn-secondary, #00f0ff); }
.brand-prompt { color: var(--bn-secondary, #00f0ff); font-family: ui-monospace, SFMono-Regular, monospace; }
.brand-cursor { color: var(--bn-accent, #ff2a85); font-family: ui-monospace, SFMono-Regular, monospace; animation: blink 1.2s steps(2, start) infinite; }
.brand small { display: block; margin: 3px 0 0 22px; color: var(--bn-muted, #a6adc8); 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; }
.theme-button { border: 1px solid #45475a; border-radius: 8px; background: #313244; color: #bac2de; padding: 8px 12px; }
.theme-button:hover { border-color: #cba6f7; color: #f5e0e8; }
.mode-badge { border: 1px solid #45475a; border-radius: 999px; padding: 6px 11px; color: #a6adc8; background: #242438; font-size: .7rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
.mode-badge:hover { border-color: #cba6f7; color: #f5e0e8; }
.google-button { display: inline-flex; align-items: center; gap: 8px; border: 1px solid #585b70; border-radius: 8px; background: #313244; color: #cdd6f4; cursor: pointer; padding: 8px 12px; font-weight: 650; }
.google-button:hover { background: #45475a; border-color: #89b4fa; }
.google-button span { color: #89b4fa; font-weight: 800; }
.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; }
h1 { margin: 10px 0 12px; color: #f5e0e8; font-size: clamp(1.7rem, 4vw, 2.4rem); letter-spacing: -.04em; }
.header-nav { display: flex; align-items: center; gap: 8px; }
.nav-tab {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.85rem;
font-weight: 600;
padding: 6px 14px;
border-radius: 6px;
text-decoration: none;
color: var(--bn-muted, #a6adc8);
border: 1px solid transparent;
transition: all 0.2s ease;
}
.nav-tab:hover {
color: var(--bn-inverted, #ffffff);
border-color: rgba(255,255,255,0.2);
}
.nav-tab.active {
color: var(--bn-secondary, #00f0ff);
border-color: var(--bn-secondary, #00f0ff);
background: rgba(0, 240, 255, 0.1);
}
.header-actions { display: flex; align-items: center; gap: 12px; }
/* Theme Selector */
.theme-dropdown-wrap { position: relative; display: inline-flex; }
.theme-dropdown-wrap::after {
content: "▾";
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: var(--bn-muted, #a6adc8);
font-size: 10px;
}
.theme-dropdown {
appearance: none;
background: var(--bn-surface, #1e1338);
color: var(--bn-text, #f5e0e8);
border: 1px solid color-mix(in srgb, var(--bn-accent) 22%, transparent);
border-radius: 8px;
padding: 8px 28px 8px 14px;
font-size: 0.85rem;
font-weight: 600;
transition: all 0.2s ease;
max-width: 12rem;
}
.theme-dropdown:hover, .theme-dropdown:focus {
border-color: var(--bn-secondary, #00f0ff);
outline: none;
box-shadow: 0 0 10px rgba(0, 240, 255, 0.25);
}
.account { position: relative; }
.account-login, .account-trigger {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--bn-border, #3d2366);
border-radius: 8px;
background: var(--bn-surface, #1b1035);
color: var(--bn-text, #f5e0e8);
font: inherit;
font-size: 0.82rem;
font-weight: 700;
padding: 6px 12px;
cursor: pointer;
}
.account-login {
background: var(--bn-accent, #ff2a85);
color: var(--bn-on-accent, #120826);
border-color: transparent;
}
.account-login:hover, .account-trigger:hover {
border-color: color-mix(in srgb, var(--bn-accent, #ff2a85) 45%, var(--bn-border, #3d2366));
}
.account-login:focus-visible, .account-trigger:focus-visible {
outline: 2px solid var(--bn-focus, var(--bn-accent, #ff2a85));
outline-offset: 2px;
}
.account-name { max-width: 11rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.account-caret { color: var(--bn-muted, #a6adc8); font-size: 10px; }
.account-menu {
position: absolute;
right: 0;
top: calc(100% + 8px);
z-index: 40;
min-width: 15rem;
padding: 6px;
background: var(--bn-surface-raised, var(--bn-surface, #1e1338));
border: 1px solid var(--bn-border, #3d2366);
border-radius: 10px;
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.28);
}
.account-identity { padding: 9px 10px 11px; border-bottom: 1px solid var(--bn-border, #3d2366); margin-bottom: 6px; }
.account-display-name { display: block; font-size: 13px; }
.account-email { display: block; color: var(--bn-muted, #a6adc8); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.account-item {
display: flex;
width: 100%;
align-items: center;
gap: 9px;
padding: 8px 10px;
background: transparent;
border: 0;
border-radius: 7px;
color: var(--bn-text, #f5e0e8);
font: inherit;
font-size: 13px;
text-align: left;
text-decoration: none;
cursor: pointer;
}
.account-item:hover { background: color-mix(in srgb, var(--bn-accent, #ff2a85) 12%, var(--bn-surface, #1b1035)); }
.account-item svg { width: 1rem; height: 1rem; }
.account-note { padding: 7px 10px 4px; color: var(--bn-muted, #a6adc8); font-size: 11px; line-height: 1.45; }
.avatar { border-radius: 50%; object-fit: cover; display: block; flex: none; }
.avatar-empty {
display: inline-grid;
place-items: center;
background: var(--bn-surface-sunken, #140929);
border: 1px solid var(--bn-border, #3d2366);
color: var(--bn-accent, #ff2a85);
font: 700 11px ui-monospace, monospace;
}
.profile-card { display: flex; align-items: center; gap: 18px; margin-top: 18px; }
.profile-card h1 { margin: 0; }
.protected-card .account-login { margin-top: 8px; }
/* Common Components */
.card { margin: 20px auto; padding: 32px; background: var(--bn-surface, #1b1035); border: 1px solid var(--bn-border, #3d2366); border-radius: 16px; box-shadow: 0 18px 50px rgba(0,0,0,0.4); }
h1 { margin: 10px 0 12px; color: var(--bn-text, #f5e0e8); font-size: clamp(1.7rem, 4vw, 2.4rem); letter-spacing: -.04em; }
p { line-height: 1.65; }
.muted { color: #a6adc8; }
.muted { color: var(--bn-muted, #a6adc8); }
.small { font-size: .875rem; }
.eyebrow { color: #cba6f7; font-size: .75rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
.secondary-button { width: 100%; min-height: 48px; margin-top: 20px; border: 0; border-radius: 9px; background: #45475a; color: #cdd6f4; cursor: pointer; font-weight: 650; }
.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; }
.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; }
.eyebrow { color: var(--bn-accent, #ff2a85); font-size: .75rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; font-family: ui-monospace, SFMono-Regular, monospace; }
/* Buttons */
.primary-button, .secondary-button, .danger-button {
border: 0;
border-radius: 8px;
min-height: 40px;
padding: 0 18px;
cursor: pointer;
font-weight: 650;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
font-size: 0.9rem;
}
.primary-button { background: var(--bn-accent, #ff2a85); color: var(--bn-inverted, #ffffff); box-shadow: 0 0 12px rgba(255, 42, 133, 0.3); }
.primary-button:hover {
background: var(--bn-accent-hover, #ff4d9a);
box-shadow: 0 0 20px color-mix(in srgb, var(--bn-accent) 50%, transparent);
}
.primary-button:disabled { opacity: .6; cursor: wait; }
.secondary-button { background: var(--bn-secondary, #2f1d4f); color: var(--bn-text, #cdd6f4); border: 1px solid var(--bn-border-strong, #4a2d7a); }
.secondary-button:hover { background: var(--bn-secondary, #3d2566); color: var(--bn-inverted, #ffffff); }
.danger-button { background: #d90429; color: var(--bn-inverted, #ffffff); box-shadow: 0 0 10px rgba(217, 4, 41, 0.3); }
.danger-button:hover { background: var(--bn-danger, #ef233c); box-shadow: 0 0 18px rgba(239, 35, 60, 0.5); }
/* Lucide icons inherit the text colour of whatever they sit in, and are sized from
the font size of their container rather than carrying a per-call-site class. The
alternative -- a className at all twelve call sites -- is a place for the sizes to
drift apart, which is the problem glyph icons had. */
svg { flex: none; }
.primary-button svg, .secondary-button svg, .danger-button svg,
.text-button svg, .table-action-button svg {
width: 1.05em;
height: 1.05em;
}
.read-link svg, .back-link svg, .page-heading-actions svg {
width: 0.95em;
height: 0.95em;
vertical-align: -0.12em;
}
.success-banner svg, .error svg, .sso-warning-banner svg { width: 1em; height: 1em; }
.text-button { border: 1px solid var(--bn-border-strong, #4a2d7a); border-radius: 8px; background: var(--bn-surface-raised, #22153d); color: var(--bn-text, #bac2de); cursor: pointer; padding: 6px 12px; font-size: 0.85rem; }
.text-button:hover { border-color: var(--bn-secondary, #00f0ff); color: var(--bn-secondary, #00f0ff); }
/* Notifications */
.error { padding: 12px 16px; border-radius: 8px; color: #ff85a1; background: var(--bn-danger, #3d1424); border: 1px solid color-mix(in srgb, var(--bn-accent) 22%, transparent); font-size: .9rem; }
.success-banner { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-radius: 8px; color: var(--bn-secondary, #00f0ff); background: var(--bn-good, #0b343d); border: 1px solid color-mix(in srgb, var(--bn-secondary) 66%, transparent); margin-bottom: 20px; }
.success-banner button {
background: none;
border: 0;
color: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
}
.success-banner button svg { width: 1rem; height: 1rem; }
.sso-warning-banner { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 16px 20px; border-radius: 10px; background: var(--bn-warning, #2a164d); border: 1px solid color-mix(in srgb, var(--bn-accent) 88%, transparent); margin-bottom: 24px; }
.sso-warning-banner p { margin: 4px 0 0; }
/* Content List and Detail */
.content-page, .detail-page, .crud-page { padding-top: 2vh; }
.page-heading { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 24px; flex-wrap: wrap; gap: 16px; }
.page-heading-actions { display: flex; align-items: center; gap: 10px; }
.filters { display: grid; grid-template-columns: minmax(150px, .25fr) minmax(180px, .35fr) 1fr; gap: 14px; margin-bottom: 24px; padding: 16px; background: var(--bn-surface, #1b1035); border: 1px solid var(--bn-border, #3d2366); border-radius: 14px; }
.filters label { display: grid; gap: 7px; color: var(--bn-text, #bac2de); font-size: .85rem; font-weight: 650; }
.filters select, .badge-input { width: 100%; min-height: 43px; border: 1px solid var(--bn-border-strong, #4a2d7a); border-radius: 8px; padding: 6px 9px; background: var(--bn-surface-raised, #22153d); color: var(--bn-text, #cdd6f4); font: inherit; font-weight: 400; }
.filters select:focus, .badge-input:focus-within { outline: 2px solid var(--bn-secondary, #00f0ff); 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: #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-input input { min-width: 180px; flex: 1; border: 0; outline: 0; padding: 5px; color: var(--bn-text, #cdd6f4); background: transparent; font: inherit; }
.badge-input input::placeholder { color: var(--bn-subtle, #7f849c); }
.filter-pill, .badge { display: inline-flex; align-items: center; gap: 4px; border-radius: 999px; padding: 3px 9px; color: var(--bn-secondary, #00f0ff); background: color-mix(in srgb, var(--bn-secondary) 18%, transparent); border: 1px solid color-mix(in srgb, var(--bn-secondary) 33%, transparent); font-size: .75rem; font-weight: 650; }
.filter-pill button {
border: 0;
padding: 0 2px;
color: var(--bn-secondary, #00f0ff);
background: transparent;
cursor: pointer;
display: inline-flex;
align-items: center;
}
.filter-pill button svg { width: 0.75rem; height: 0.75rem; }
.badge.small { font-size: 0.7rem; padding: 2px 7px; }
.badge-list { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px; }
.badge-list-inline { display: flex; flex-wrap: wrap; gap: 4px; }
.content-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 18px; }
.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 { padding: 28px; background: var(--bn-surface, #1b1035); border: 1px solid var(--bn-border, #3d2366); border-radius: 14px; box-shadow: 0 12px 34px rgba(0,0,0,0.3); }
.content-card:hover { border-color: color-mix(in srgb, var(--bn-secondary) 66%, transparent); box-shadow: 0 15px 40px rgba(0,240,255,0.1); }
.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; }
.content-card .content-meta { margin-top: -8px; color: #7f849c; font-size: .82rem; }
.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; }
.content-card h2 a { color: var(--bn-text, #f5e0e8); text-decoration: none; }
.content-card h2 a:hover, .read-link:hover { color: var(--bn-secondary, #00f0ff); }
.content-card p { color: var(--bn-muted, #a6adc8); margin: 0 0 18px; }
.content-card .content-meta { margin-top: -8px; color: var(--bn-subtle, #7f849c); font-size: .82rem; }
.status { display: inline-block; color: var(--bn-secondary, #00f0ff); background: color-mix(in srgb, var(--bn-secondary) 22%, transparent); border: 1px solid color-mix(in srgb, var(--bn-secondary) 55%, transparent); border-radius: 999px; padding: 3px 10px; font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
.read-link, .back-link { color: var(--bn-secondary, #00f0ff); 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: 760px; margin: auto; }
.back-link { display: inline-block; margin-bottom: 32px; }
.detail-heading { position: relative; padding-bottom: 26px; border-bottom: 1px solid #45475a; }
.back-link { display: inline-block; margin-bottom: 24px; }
.detail-heading { position: relative; padding-bottom: 26px; border-bottom: 1px solid var(--bn-border, #3d2366); }
.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; color: #bac2de; }
.editor { display: grid; gap: 18px; }
.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: #cba6f7; color: #1e1e2e; }
.primary-button:hover { background: #d8b9ff; }
.primary-button:disabled { opacity: .6; cursor: wait; }
.actions .secondary-button { margin: 0; background: #45475a; color: #cdd6f4; }
.inline-button { margin-top: 22px; }
.protected-card { text-align: center; }
.body-copy { white-space: pre-wrap; line-height: 1.8; padding-top: 28px; color: var(--bn-text, #bac2de); }
/* CRUD /etc/backlog.rc Styles */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 24px; }
.stat-card { background: var(--bn-surface, #1b1035); border: 1px solid var(--bn-border, #3d2366); border-radius: 12px; padding: 18px 22px; }
.stat-label { font-size: 0.8rem; font-weight: 600; color: var(--bn-muted, #a6adc8); text-transform: uppercase; letter-spacing: 0.05em; }
.stat-value { font-size: 1.8rem; font-weight: 800; margin-top: 4px; color: var(--bn-text, #f5e0e8); }
.stat-published .stat-value { color: var(--bn-secondary, #00f0ff); }
.stat-draft .stat-value { color: var(--bn-warning, #ffd166); }
.stat-archived .stat-value { color: var(--bn-muted, #a6adc8); }
.table-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 16px; flex-wrap: wrap; }
.search-box { flex: 1; min-width: 250px; }
.search-box input { width: 100%; border: 1px solid var(--bn-border-strong, #4a2d7a); border-radius: 8px; padding: 10px 14px; background: var(--bn-surface, #1b1035); color: var(--bn-text, #cdd6f4); }
.search-box input:focus { outline: 2px solid var(--bn-secondary, #00f0ff); border-color: var(--bn-secondary, #00f0ff); }
.filter-group { display: flex; gap: 10px; align-items: center; }
.filter-group select { border: 1px solid var(--bn-border-strong, #4a2d7a); border-radius: 8px; padding: 9px 12px; background: var(--bn-surface, #1b1035); color: var(--bn-text, #cdd6f4); font-size: 0.85rem; }
.table-container { overflow-x: auto; }
.crud-table { width: 100%; border-collapse: collapse; text-align: left; font-size: 0.88rem; }
.crud-table th, .crud-table td { padding: 14px 18px; border-bottom: 1px solid rgba(255,255,255,0.06); }
.crud-table th { background: rgba(0, 0, 0, 0.2); font-weight: 700; color: var(--bn-muted, #a6adc8); text-transform: uppercase; font-size: 0.75rem; letter-spacing: 0.05em; }
.crud-table tbody tr:hover { background: rgba(255,255,255,0.03); }
.post-title-cell { display: flex; flex-direction: column; gap: 3px; }
.post-link { color: var(--bn-text, #f5e0e8); text-decoration: none; font-size: 0.95rem; }
.post-link:hover { color: var(--bn-secondary, #00f0ff); }
.post-slug { font-size: 0.75rem; color: var(--bn-muted, #a6adc8); }
.post-summary-preview { font-size: 0.78rem; margin-top: 4px; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.category-pill { font-size: 0.72rem; padding: 2px 8px; border-radius: 4px; background: var(--bn-secondary, #2f1d4f); color: var(--bn-accent, #cba6f7); border: 1px solid var(--bn-border-strong, #4a2d7a); }
.status-pill { display: inline-block; font-size: 0.7rem; font-weight: 700; padding: 3px 8px; border-radius: 999px; text-transform: uppercase; letter-spacing: 0.04em; }
.status-published { color: var(--bn-secondary, #00f0ff); background: color-mix(in srgb, var(--bn-secondary) 22%, transparent); border: 1px solid color-mix(in srgb, var(--bn-secondary) 55%, transparent); }
.status-draft { color: var(--bn-warning, #ffd166); background: color-mix(in srgb, var(--bn-warning) 22%, transparent); border: 1px solid color-mix(in srgb, var(--bn-warning) 55%, transparent); }
.status-archived { color: var(--bn-muted, #94a3b8); background: color-mix(in srgb, var(--bn-muted) 44%, transparent); border: 1px solid var(--bn-muted, #475569); }
.action-buttons-cell { display: flex; gap: 6px; justify-content: flex-end; }
.table-action-button { border: 1px solid var(--bn-border-strong, #4a2d7a); background: var(--bn-surface-raised, #251642); color: var(--bn-text, #cdd6f4); border-radius: 6px; padding: 6px 10px; font-size: 0.78rem; font-weight: 600; }
.table-action-button:hover { border-color: var(--bn-secondary, #00f0ff); color: var(--bn-inverted, #ffffff); }
.table-action-button.delete:hover { border-color: var(--bn-danger, #ef233c); background: color-mix(in srgb, var(--bn-danger) 22%, transparent); color: var(--bn-danger, #ef233c); }
.font-mono { font-family: ui-monospace, SFMono-Regular, monospace; }
.font-semibold { font-weight: 600; }
/* Modals & Terminal Windows */
.terminal-backdrop {
position: fixed;
z-index: 20;
z-index: 50;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
background: #000000b8;
backdrop-filter: blur(3px);
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(5px);
}
.terminal-window {
width: min(100%, 620px);
width: min(100%, 650px);
overflow: hidden;
border: 1px solid #4d4d4d;
border-radius: 8px;
background: #000;
color: #fff;
box-shadow: 0 24px 90px #000;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
border: 1px solid color-mix(in srgb, var(--bn-accent) 88%, transparent);
border-radius: 12px;
background: var(--bn-surface-sunken, #140929);
color: var(--bn-inverted, #fff);
box-shadow: 0 24px 90px rgba(0, 0, 0, 0.7), 0 0 30px rgba(255, 42, 133, 0.2);
}
.terminal-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 14px;
color: #fff;
background: #252525;
border-bottom: 1px solid #454545;
font-size: .82rem;
padding: 12px 18px;
background: var(--bn-surface-raised, #200f3d);
border-bottom: 1px solid var(--bn-border, #3d2366);
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.85rem;
font-weight: 700;
color: var(--bn-secondary, #00f0ff);
}
.terminal-close {
border: 0;
color: #fff;
color: var(--bn-muted, #a6adc8);
background: transparent;
cursor: pointer;
font-size: 1.35rem;
line-height: 1;
}
.terminal-close:hover { color: #ff6b6b; }
.terminal-body { display: grid; gap: 14px; padding: 24px; }
.terminal-body p { margin: 0; line-height: 1.5; }
.terminal-body label {
display: flex;
display: inline-flex;
align-items: center;
gap: 8px;
color: #fff;
font: inherit;
white-space: nowrap;
}
.terminal-prompt { color: #fff; font-weight: 700; }
.terminal-body input {
min-width: 0;
flex: 1;
border: 0;
outline: 0;
padding: 0;
color: #fff;
background: #000;
font: inherit;
/* Sized from the button rather than from a glyph's em box, so the icon and the
× it replaced occupy the same space and the titlebar does not shift. */
.terminal-close svg { width: 1.15rem; height: 1.15rem; }
.terminal-close:hover { color: var(--bn-accent, #ff2a85); }
.warning-callout {
padding: 18px;
border-radius: 8px;
background: var(--bn-danger, #3d081b);
border: 1px solid color-mix(in srgb, var(--bn-danger) 88%, transparent);
margin-bottom: 18px;
}
.terminal-body input::placeholder { color: #777; }
.terminal-submit {
justify-self: start;
border: 0;
padding: 0;
color: #fff;
background: transparent;
font: inherit;
cursor: pointer;
.warning-callout h3 { margin: 0 0 8px; color: var(--bn-danger, #ef233c); font-size: 1.1rem; }
.warning-callout p { margin: 0 0 6px; line-height: 1.5; color: var(--bn-text, #f5e0e8); }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.form-field { display: flex; flex-direction: column; gap: 6px; }
.form-field.full-width { grid-column: span 2; }
.form-field span { font-size: 0.82rem; font-weight: 600; color: var(--bn-muted, #a6adc8); }
.form-field input, .form-field select, .form-field textarea {
border: 1px solid var(--bn-border-strong, #4a2d7a);
border-radius: 6px;
padding: 10px 12px;
background: var(--bn-surface-sunken, #1c0e38);
color: var(--bn-text, #f5e0e8);
}
.terminal-submit:hover { color: #a6e3a1; }
.terminal-error { color: #ff6b6b; }
html[data-theme="light"] {
color: #4c4f69;
background: #eff1f5;
.form-field input:focus, .form-field select:focus, .form-field textarea:focus {
outline: 2px solid var(--bn-secondary, #00f0ff);
border-color: var(--bn-secondary, #00f0ff);
}
/* ==========================================================================
TERMINAL PALETTE: rules, not values
Two of this palette's traits are not colours, so they cannot be tokens and were
not lost with the colour overrides. A token is a value; these are behaviour.
========================================================================== */
html[data-theme='terminal'],
html[data-theme='terminal'] * {
font-family: var(--bn-font-mono, 'IBM Plex Mono'), ui-monospace, SFMono-Regular, 'Courier New', Courier, monospace !important;
}
html[data-theme='terminal'] .brand,
html[data-theme='terminal'] .nav-tab.active,
html[data-theme='terminal'] .post-link {
text-shadow: 0 0 2px color-mix(in srgb, var(--bn-accent) 40%, transparent);
}
html[data-theme="light"] body { background: radial-gradient(circle at 85% -10%, #dce4f4 0, transparent 38rem), #eff1f5; }
html[data-theme="light"] .brand { color: #4c4f69; }
html[data-theme="light"] .brand:hover { color: #8839ef; }
html[data-theme="light"] .brand-prompt { color: #40a02b; }
html[data-theme="light"] .brand-cursor { color: #df8e1d; }
html[data-theme="light"] .brand small { color: #8c8fa1; }
html[data-theme="light"] .text-button,
html[data-theme="light"] .theme-button { border-color: #ccd0da; background: #e6e9ef; color: #5c5f77; }
html[data-theme="light"] .text-button:hover,
html[data-theme="light"] .theme-button:hover { border-color: #8839ef; color: #4c4f69; }
html[data-theme="light"] .mode-badge { border-color: #ccd0da; background: #e6e9ef; color: #6c6f85; }
html[data-theme="light"] .google-button { border-color: #ccd0da; background: #e6e9ef; color: #5c5f77; }
html[data-theme="light"] .google-button:hover { border-color: #1e66f5; background: #dce0e8; }
html[data-theme="light"] .card,
html[data-theme="light"] .filters,
html[data-theme="light"] .content-card { background: #ffffff; border-color: #ccd0da; box-shadow: 0 12px 34px #4c4f6914; }
html[data-theme="light"] h1,
html[data-theme="light"] .content-card h2 a { color: #4c4f69; }
html[data-theme="light"] .muted,
html[data-theme="light"] .content-card p { color: #6c6f85; }
html[data-theme="light"] .eyebrow { color: #8839ef; }
html[data-theme="light"] .filters select,
html[data-theme="light"] .badge-input,
html[data-theme="light"] .editor input,
html[data-theme="light"] .editor textarea,
html[data-theme="light"] .editor select { border-color: #ccd0da; background: #eff1f5; color: #4c4f69; }
html[data-theme="light"] .profile,
html[data-theme="light"] .secondary-button,
html[data-theme="light"] .actions .secondary-button { background: #e6e9ef; color: #5c5f77; }
html[data-theme="light"] .status { color: #179299; background: #d9f1ed; }
html[data-theme="light"] .filter-pill,
html[data-theme="light"] .badge { color: #8839ef; background: #f0e5ff; }
html[data-theme="light"] .body-copy,
html[data-theme="light"] .editor label,
html[data-theme="light"] .filters label { color: #5c5f77; }
html[data-theme="light"] .detail-heading { border-color: #ccd0da; }
html[data-theme="light"] .read-link,
html[data-theme="light"] .back-link { color: #1e66f5; }
html[data-theme="light"] .primary-button { background: #8839ef; color: #ffffff; }
html[data-theme="light"] .primary-button:hover { background: #7c2fe0; }
@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; } }

View file

@ -1,24 +1,41 @@
import { defineConfig } from 'vite'
import {defineConfig, loadEnv, type Plugin} from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
// Build into the repository root; the Dockerfile and `make ui` copy the result
// into the Go embed directory (cmd/server/web/dist).
build: {
outDir: '../dist',
emptyOutDir: true,
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/health': {
target: 'http://localhost:8080',
changeOrigin: true,
function themeContract(origin: string): Plugin {
const base = origin.replace(/\/+$/, '')
return {
name: 'go-bananas-contract',
transformIndexHtml(html) {
if (!base) return html
const tags = [
`<link rel="stylesheet" href="${base}/rt/v1/contract.css" data-theme-contract>`,
`<script type="module" src="${base}/rt/v1/components.js"></script>`,
].join('\n ')
return html.replace('</head>', ` ${tags}\n </head>`)
},
}
}
export default defineConfig(({mode}) => {
const env = loadEnv(mode, '.', '')
const themeOrigin = env.VITE_THEME_BASE_URL || ''
return {
plugins: [react(), themeContract(themeOrigin)],
build: {
outDir: '../dist',
emptyOutDir: true,
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/health': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
},
}
})