Skip to content

Commit 22aa7a3

Browse files
committed
wallpaper + lane tint
Signed-off-by: Mark Rai <markraidc@gmail.com>
1 parent bda7701 commit 22aa7a3

26 files changed

Lines changed: 1393 additions & 26 deletions

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,19 @@
33
> **Upgrades:** No breaking changes in **3.7.x** / **3.8.x** / **3.9.x** / **3.10.x** / **3.11.x** unless noted below.
44
55

6+
## [3.11.5] - 2026-04-05
7+
8+
### Features
9+
10+
- **Wallpaper** - Optional built-in image at **`/wallpapers/default.jpg`**: empty preference tries to load it; if the file is missing or fails to load, wallpaper stays **off** (no bundled placeholder). **Builtin** mode is client-only in **localStorage**; server prefs remain **off** / **color** / **image** as before.
11+
- **Settings → Customization** - When a wallpaper is active, the Settings dialog uses a lighter **backdrop** and a slightly translucent panel so the same background shows through; tuned for readability (stronger panel and backdrop than the first pass).
12+
13+
### Improvements
14+
15+
- **Board** — Lane **column** backgrounds use a light **`color-mix`** tint from each workflow lane’s **`color`** when the API provides it (**`col--lane-tint`** / **`--lane-accent`**), so custom lane keys and themed projects match the header again—not only the five fixed **`data-column`** CSS rules in light mode.
16+
17+
---
18+
619
## [3.11.4] - 2026-04-05
720

821
### Features

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<p align="center">
22
<img width="372" src="internal/httpapi/web/githublogo.png" alt="scrumboy logo" />
33
<br />
4-
<img src="https://img.shields.io/badge/version-v3.11.4-blue" alt="version" />
4+
<img src="https://img.shields.io/badge/version-v3.11.5-blue" alt="version" />
55
<a href="LICENSE">
66
<img src="https://img.shields.io/badge/license-AGPL--v3-orange" alt="license" />
77
</a>

cmd/scrumboy/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ func main() {
9292
Logger: logger,
9393
MaxRequestBody: cfg.MaxRequestBodyBytes,
9494
ScrumboyMode: cfg.ScrumboyMode,
95+
DataDir: cfg.DataDir,
9596
MCPHandler: mcp.New(st, mcp.Options{Mode: cfg.ScrumboyMode}),
9697
EncryptionKey: encKey,
9798
OIDCService: oidcSvc,

internal/httpapi/routing.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,11 @@ func (s *Server) handleUser(w http.ResponseWriter, r *http.Request, rest []strin
272272
return
273273
}
274274

275+
if len(rest) >= 1 && rest[0] == "wallpaper" {
276+
s.handleUserWallpaper(w, r, userID, rest)
277+
return
278+
}
279+
275280
if rest[0] == "preferences" {
276281
s.handleUserPreferences(w, r, userID)
277282
return
@@ -312,6 +317,26 @@ func (s *Server) handleUserPreferences(w http.ResponseWriter, r *http.Request, u
312317
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "missing key", nil)
313318
return
314319
}
320+
if in.Key == wallpaperPrefKey {
321+
p, err := store.ParseWallpaperPref(in.Value)
322+
if err != nil {
323+
writeStoreErr(w, err, true)
324+
return
325+
}
326+
if p.Mode == "image" {
327+
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "image wallpaper must be uploaded via POST /api/user/wallpaper/image", map[string]any{"field": "value"})
328+
return
329+
}
330+
if err := s.store.SetUserPreference(ctx, userID, in.Key, in.Value); err != nil {
331+
writeStoreErr(w, err, true)
332+
return
333+
}
334+
if p.Mode == "off" || p.Mode == "color" {
335+
s.deleteUserWallpaperFile(userID)
336+
}
337+
w.WriteHeader(http.StatusNoContent)
338+
return
339+
}
315340
if err := s.store.SetUserPreference(ctx, userID, in.Key, in.Value); err != nil {
316341
writeStoreErr(w, err, true)
317342
return

internal/httpapi/server.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ type Options struct {
2222
Logger *log.Logger
2323
MaxRequestBody int64
2424
ScrumboyMode string // "full" or "anonymous"
25+
// DataDir is the instance data directory (SQLite lives here; also used for per-user wallpaper files).
26+
// Empty disables wallpaper upload/serve (returns 503 for those routes).
27+
DataDir string
2528
AuthRateLimit *ratelimit.Limiter
2629
MCPHandler http.Handler
2730
// EncryptionKey is the HMAC secret for password reset tokens. Required for admin password reset.
@@ -66,6 +69,8 @@ type Server struct {
6669
vapidPublicKey string
6770
pushVapidConfigured bool // both public and private keys non-empty; subscribe and push notify use this
6871
pushDebug bool
72+
73+
dataDir string // user-wallpapers storage; empty = disabled
6974
}
7075

7176
type storeAPI interface {
@@ -287,6 +292,7 @@ func NewServer(st storeAPI, opts Options) *Server {
287292
logger: logger,
288293
maxBody: maxBody,
289294
mode: mode,
295+
dataDir: strings.TrimSpace(opts.DataDir),
290296
hub: hub,
291297
sink: hub,
292298
fanout: fanout,

internal/httpapi/user_wallpaper.go

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
package httpapi
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"image"
9+
"image/gif"
10+
"image/jpeg"
11+
"image/png"
12+
"io"
13+
"net/http"
14+
"os"
15+
"path/filepath"
16+
"strconv"
17+
"strings"
18+
"time"
19+
20+
"scrumboy/internal/store"
21+
)
22+
23+
const (
24+
wallpaperPrefKey = "wallpaper"
25+
wallpaperMaxBytes = 6 << 20 // 6 MiB raw multipart
26+
wallpaperJPEGQuality = 85
27+
wallpaperMaxDim = 2560
28+
)
29+
30+
// userWallpaperFilePath returns the on-disk path for the user's normalized JPEG wallpaper.
31+
func (s *Server) userWallpaperFilePath(userID int64) string {
32+
return filepath.Join(s.dataDir, "user-wallpapers", fmt.Sprintf("%d.jpg", userID))
33+
}
34+
35+
func (s *Server) ensureWallpaperDir() error {
36+
dir := filepath.Join(s.dataDir, "user-wallpapers")
37+
return os.MkdirAll(dir, 0o700)
38+
}
39+
40+
func (s *Server) deleteUserWallpaperFile(userID int64) {
41+
path := s.userWallpaperFilePath(userID)
42+
_ = os.Remove(path)
43+
}
44+
45+
// handleUserWallpaper routes DELETE /api/user/wallpaper, GET/POST /api/user/wallpaper/image
46+
// rest is like ["wallpaper"] or ["wallpaper","image"] (path after /api/user/).
47+
func (s *Server) handleUserWallpaper(w http.ResponseWriter, r *http.Request, userID int64, rest []string) {
48+
if s.dataDir == "" {
49+
writeError(w, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "wallpaper storage not configured", nil)
50+
return
51+
}
52+
53+
ctx := s.requestContext(r)
54+
55+
switch {
56+
case len(rest) == 1 && rest[0] == "wallpaper" && r.Method == http.MethodDelete:
57+
s.deleteUserWallpaper(w, r, ctx, userID)
58+
return
59+
60+
case len(rest) == 2 && rest[0] == "wallpaper" && rest[1] == "image" && r.Method == http.MethodGet:
61+
s.serveUserWallpaperImage(w, r, ctx, userID)
62+
return
63+
64+
case len(rest) == 2 && rest[0] == "wallpaper" && rest[1] == "image" && r.Method == http.MethodPost:
65+
s.uploadUserWallpaperImage(w, r, ctx, userID)
66+
return
67+
68+
default:
69+
writeError(w, http.StatusNotFound, "NOT_FOUND", "not found", nil)
70+
}
71+
}
72+
73+
func (s *Server) wallpaperPrefModeImage(ctx context.Context, userID int64) (store.WallpaperPref, bool) {
74+
raw, err := s.store.GetUserPreference(ctx, userID, wallpaperPrefKey)
75+
if err != nil || strings.TrimSpace(raw) == "" {
76+
return store.WallpaperPref{}, false
77+
}
78+
p, err := store.ParseWallpaperPref(raw)
79+
if err != nil || p.Mode != "image" {
80+
return store.WallpaperPref{}, false
81+
}
82+
return p, true
83+
}
84+
85+
func (s *Server) serveUserWallpaperImage(w http.ResponseWriter, r *http.Request, ctx context.Context, userID int64) {
86+
p, ok := s.wallpaperPrefModeImage(ctx, userID)
87+
if !ok {
88+
writeError(w, http.StatusNotFound, "NOT_FOUND", "no wallpaper image", nil)
89+
return
90+
}
91+
path := s.userWallpaperFilePath(userID)
92+
f, err := os.Open(path)
93+
if err != nil {
94+
if os.IsNotExist(err) {
95+
writeError(w, http.StatusNotFound, "NOT_FOUND", "wallpaper file missing", nil)
96+
return
97+
}
98+
writeInternal(w, err)
99+
return
100+
}
101+
defer f.Close()
102+
103+
w.Header().Set("Content-Type", "image/jpeg")
104+
w.Header().Set("Cache-Control", "private, max-age=3600")
105+
w.Header().Set("ETag", strconv.FormatInt(p.Rev, 10))
106+
if inm := r.Header.Get("If-None-Match"); inm != "" && inm == strconv.FormatInt(p.Rev, 10) {
107+
w.WriteHeader(http.StatusNotModified)
108+
return
109+
}
110+
w.WriteHeader(http.StatusOK)
111+
_, _ = io.Copy(w, f)
112+
}
113+
114+
func decodeWallpaperUpload(data []byte, contentType string) (image.Image, error) {
115+
ct := strings.ToLower(strings.TrimSpace(contentType))
116+
r := bytes.NewReader(data)
117+
switch {
118+
case strings.Contains(ct, "jpeg"), strings.Contains(ct, "jpg"):
119+
return jpeg.Decode(r)
120+
case strings.Contains(ct, "png"):
121+
return png.Decode(r)
122+
case strings.Contains(ct, "gif"):
123+
return gif.Decode(r)
124+
default:
125+
return nil, fmt.Errorf("use JPEG, PNG, or GIF")
126+
}
127+
}
128+
129+
func encodeWallpaperJPEG(img image.Image) (*bytes.Buffer, error) {
130+
bounds := img.Bounds()
131+
dx := bounds.Dx()
132+
dy := bounds.Dy()
133+
if dx <= 0 || dy <= 0 {
134+
return nil, fmt.Errorf("invalid dimensions")
135+
}
136+
if dx > wallpaperMaxDim || dy > wallpaperMaxDim {
137+
scale := float64(wallpaperMaxDim) / float64(max(dx, dy))
138+
newW := int(float64(dx) * scale)
139+
newH := int(float64(dy) * scale)
140+
if newW < 1 {
141+
newW = 1
142+
}
143+
if newH < 1 {
144+
newH = 1
145+
}
146+
img = resizeNearest(img, newW, newH)
147+
}
148+
var buf bytes.Buffer
149+
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: wallpaperJPEGQuality}); err != nil {
150+
return nil, err
151+
}
152+
return &buf, nil
153+
}
154+
155+
func max(a, b int) int {
156+
if a > b {
157+
return a
158+
}
159+
return b
160+
}
161+
162+
// resizeNearest simple resize for large photos (good enough for background wallpaper).
163+
func resizeNearest(src image.Image, newW, newH int) image.Image {
164+
b := src.Bounds()
165+
dst := image.NewRGBA(image.Rect(0, 0, newW, newH))
166+
xratio := float64(b.Dx()) / float64(newW)
167+
yratio := float64(b.Dy()) / float64(newH)
168+
for y := 0; y < newH; y++ {
169+
for x := 0; x < newW; x++ {
170+
sx := int(float64(x) * xratio)
171+
sy := int(float64(y) * yratio)
172+
if sx >= b.Dx() {
173+
sx = b.Dx() - 1
174+
}
175+
if sy >= b.Dy() {
176+
sy = b.Dy() - 1
177+
}
178+
dst.Set(x, y, src.At(b.Min.X+sx, b.Min.Y+sy))
179+
}
180+
}
181+
return dst
182+
}
183+
184+
func (s *Server) uploadUserWallpaperImage(w http.ResponseWriter, r *http.Request, ctx context.Context, userID int64) {
185+
if err := r.ParseMultipartForm(wallpaperMaxBytes); err != nil {
186+
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "invalid multipart form", nil)
187+
return
188+
}
189+
file, hdr, err := r.FormFile("file")
190+
if err != nil {
191+
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "missing file field", map[string]any{"field": "file"})
192+
return
193+
}
194+
defer file.Close()
195+
196+
ct := hdr.Header.Get("Content-Type")
197+
if ct == "" {
198+
ct = "application/octet-stream"
199+
}
200+
if !strings.HasPrefix(strings.ToLower(ct), "image/") {
201+
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "file must be an image", map[string]any{"field": "file"})
202+
return
203+
}
204+
205+
data, err := io.ReadAll(io.LimitReader(file, wallpaperMaxBytes))
206+
if err != nil {
207+
writeInternal(w, err)
208+
return
209+
}
210+
img, err := decodeWallpaperUpload(data, ct)
211+
if err != nil {
212+
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error(), map[string]any{"field": "file"})
213+
return
214+
}
215+
216+
buf, err := encodeWallpaperJPEG(img)
217+
if err != nil {
218+
writeInternal(w, err)
219+
return
220+
}
221+
if buf.Len() > 3<<20 {
222+
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "processed image too large", nil)
223+
return
224+
}
225+
226+
if err := s.ensureWallpaperDir(); err != nil {
227+
writeInternal(w, err)
228+
return
229+
}
230+
path := s.userWallpaperFilePath(userID)
231+
tmp := path + ".tmp"
232+
if err := os.WriteFile(tmp, buf.Bytes(), 0o600); err != nil {
233+
writeInternal(w, err)
234+
return
235+
}
236+
if err := os.Rename(tmp, path); err != nil {
237+
_ = os.Remove(tmp)
238+
writeInternal(w, err)
239+
return
240+
}
241+
242+
rev := time.Now().UTC().UnixMilli()
243+
pref := store.WallpaperPref{V: store.WallpaperPrefVersion, Mode: "image", Rev: rev}
244+
b, err := json.Marshal(pref)
245+
if err != nil {
246+
writeInternal(w, err)
247+
return
248+
}
249+
if err := s.store.SetUserPreference(ctx, userID, wallpaperPrefKey, string(b)); err != nil {
250+
writeStoreErr(w, err, true)
251+
return
252+
}
253+
254+
writeJSON(w, http.StatusOK, map[string]any{"rev": rev, "mode": "image"})
255+
}
256+
257+
func (s *Server) deleteUserWallpaper(w http.ResponseWriter, r *http.Request, ctx context.Context, userID int64) {
258+
s.deleteUserWallpaperFile(userID)
259+
off := store.WallpaperPref{V: store.WallpaperPrefVersion, Mode: "off"}
260+
b, err := json.Marshal(off)
261+
if err != nil {
262+
writeInternal(w, err)
263+
return
264+
}
265+
if err := s.store.SetUserPreference(ctx, userID, wallpaperPrefKey, string(b)); err != nil {
266+
writeStoreErr(w, err, true)
267+
return
268+
}
269+
w.WriteHeader(http.StatusNoContent)
270+
}

internal/httpapi/web/app.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { app, toast, todoDialog, todoForm, todoDialogTitle, todoTitle, todoBody, todoTags, todoStatus, todoEstimationPoints, deleteTodoBtn, closeTodoBtn, settingsDialog, closeSettingsBtn } from './dist/dom/elements.js';
44
import { initTheme, handleThemeChange, getStoredTheme, THEME_SYSTEM, THEME_DARK, THEME_LIGHT } from './dist/theme.js';
5+
import { initWallpaper } from './dist/wallpaper.js';
56
import { escapeHTML, showToast } from './dist/utils.js';
67
import { apiFetch } from './dist/api.js';
78
import { navigate, router } from './dist/router.js';
@@ -26,6 +27,7 @@ registerPwaGlobals();
2627

2728
// Initialize theme on page load
2829
initTheme();
30+
initWallpaper();
2931

3032
// Setup context menu handlers (one-time, global)
3133
setupContextMenuCloseHandler();

0 commit comments

Comments
 (0)