blumenjs 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -58,44 +58,53 @@ const DashboardPage: React.FC = () => {
58
58
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
59
59
  @keyframes fu{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
60
60
  .fd *{box-sizing:border-box;margin:0;padding:0;font-family:'Inter',sans-serif}
61
- .fd{min-height:100vh;background:#f0f2f0;display:flex;color:#1a1a2e}
61
+ .fd{min-height:100vh;background:#f0f2f0;display:flex;color:#1a1a2e;width:100%}
62
62
 
63
63
  /* Sidebar */
64
- .sb{width:200px;background:#fff;border-right:1px solid #e5e7eb;padding:16px 10px;display:flex;flex-direction:column;position:fixed;top:0;bottom:0;left:0;z-index:10}
64
+ .sb{width:220px;background:#fff;border-right:1px solid #e5e7eb;padding:16px 10px;display:flex;flex-direction:column;position:fixed;top:0;bottom:0;left:0;z-index:10}
65
65
  .sb-brand{display:flex;align-items:center;gap:8px;padding:8px 10px;margin-bottom:20px}
66
66
  .sb-logo{width:28px;height:28px;border-radius:7px;background:linear-gradient(135deg,#16a34a,#22c55e);display:flex;align-items:center;justify-content:center;font-size:0.75rem;color:#fff;font-weight:800}
67
67
  .sb-name{font-size:0.9rem;font-weight:800;letter-spacing:-0.01em}
68
68
  .sb-nav{list-style:none;flex:1}
69
- .sb-ni{display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:8px;font-size:0.78rem;font-weight:500;color:#6b7280;cursor:pointer;transition:all 0.15s;margin-bottom:1px;border:none;background:none;width:100%;text-align:left}
69
+ .sb-ni{display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:8px;font-size:0.78rem;font-weight:500;color:#6b7280;cursor:pointer;transition:all 0.15s;margin-bottom:1px;border:none;background:none;width:100%;text-align:left;font-family:inherit}
70
70
  .sb-ni:hover{color:#16a34a;background:#f0fdf4}
71
71
  .sb-ni.on{color:#fff;background:#16a34a;font-weight:600}
72
72
  .sb-cta{margin-top:auto;padding:16px;border-radius:12px;background:linear-gradient(135deg,#0f3d1e,#14532d);color:#fff;text-align:center}
73
73
  .sb-cta p{font-size:0.65rem;color:#86efac;margin:6px 0 10px;line-height:1.4}
74
- .sb-cta-btn{padding:6px 16px;border-radius:6px;background:#22c55e;color:#fff;font-size:0.72rem;font-weight:600;border:none;cursor:pointer}
74
+ .sb-cta-btn{padding:6px 16px;border-radius:6px;background:#22c55e;color:#fff;font-size:0.72rem;font-weight:600;border:none;cursor:pointer;font-family:inherit}
75
+
76
+ /* Mobile header bar */
77
+ .mob-bar{display:none;position:sticky;top:0;z-index:20;background:#fff;border-bottom:1px solid #e5e7eb;padding:10px 16px;align-items:center;gap:10px}
78
+ .mob-bar-btn{width:36px;height:36px;border-radius:8px;background:#f0fdf4;border:1px solid #dcfce7;display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:1.1rem}
79
+ .mob-bar h1{font-size:1rem;font-weight:800;flex:1}
80
+ .mob-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:50}
81
+ .mob-overlay.show{display:block}
82
+ .sb.mob-open{display:flex!important;z-index:60}
75
83
 
76
84
  /* Layout */
77
- .mn{margin-left:200px;display:grid;grid-template-columns:240px 1fr 260px;min-height:100vh}
78
- .lp{padding:16px 12px;background:#fff;border-right:1px solid #e8ebe8}
79
- .cp{padding:16px}
80
- .rp{padding:16px 12px;background:#fff;border-left:1px solid #e8ebe8}
85
+ .mn{margin-left:220px;display:grid;grid-template-columns:260px 1fr 280px;min-height:100vh;width:calc(100% - 220px)}
86
+ .lp{padding:16px 14px;background:#fff;border-right:1px solid #e8ebe8;overflow-y:auto}
87
+ .cp{padding:20px;min-width:0;overflow-x:hidden}
88
+ .rp{padding:16px 14px;background:#fff;border-left:1px solid #e8ebe8;overflow-y:auto}
81
89
 
82
90
  /* Components */
83
- .cd{background:#fff;border-radius:12px;border:1px solid #e8ebe8;padding:14px;margin-bottom:12px;animation:fu 0.4s ease-out both}
84
- .hdr{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}
85
- .hdr h1{font-size:1.15rem;font-weight:800}
86
- .search{padding:6px 14px;border-radius:8px;border:1px solid #e5e7eb;background:#f9fafb;font-size:0.78rem;width:200px}
87
- .uav{width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,#16a34a,#4ade80);display:flex;align-items:center;justify-content:center;font-size:0.65rem;font-weight:700;color:#fff}
91
+ .cd{background:#fff;border-radius:12px;border:1px solid #e8ebe8;padding:16px;margin-bottom:14px;animation:fu 0.4s ease-out both}
92
+ .hdr{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;flex-wrap:wrap;gap:10px}
93
+ .hdr h1{font-size:1.2rem;font-weight:800}
94
+ .hdr-right{display:flex;align-items:center;gap:10px}
95
+ .search{padding:7px 14px;border-radius:8px;border:1px solid #e5e7eb;background:#f9fafb;font-size:0.78rem;width:220px;font-family:inherit}
96
+ .uav{width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,#16a34a,#4ade80);display:flex;align-items:center;justify-content:center;font-size:0.65rem;font-weight:700;color:#fff;flex-shrink:0}
88
97
 
89
98
  /* User Card */
90
- .uc{background:linear-gradient(135deg,#14532d,#166534);border-radius:14px;padding:16px;color:#fff;margin-bottom:12px;position:relative;overflow:hidden}
99
+ .uc{background:linear-gradient(135deg,#14532d,#166534);border-radius:14px;padding:18px;color:#fff;margin-bottom:14px;position:relative;overflow:hidden}
91
100
  .uc::after{content:'';position:absolute;top:-25px;right:-25px;width:100px;height:100px;border-radius:50%;background:rgba(255,255,255,0.05)}
92
- .uc-n{font-size:0.9rem;font-weight:700;margin:8px 0 12px}
93
- .uc-b{font-size:0.65rem;color:#86efac}
94
- .uc-a{font-size:1.3rem;font-weight:800;margin:2px 0}
95
- .uc-m{display:flex;gap:16px;margin-top:10px;font-size:0.65rem;color:#86efac}
101
+ .uc-n{font-size:0.92rem;font-weight:700;margin:8px 0 12px}
102
+ .uc-b{font-size:0.68rem;color:#86efac}
103
+ .uc-a{font-size:1.4rem;font-weight:800;margin:2px 0}
104
+ .uc-m{display:flex;gap:16px;margin-top:10px;font-size:0.68rem;color:#86efac}
96
105
 
97
106
  /* Quick Actions */
98
- .qa{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:12px}
107
+ .qa{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:14px}
99
108
  .qa-i{text-align:center;padding:8px 2px;border-radius:8px;background:#f0fdf4;border:1px solid #dcfce7;cursor:pointer;transition:all 0.15s}
100
109
  .qa-i:hover{background:#dcfce7;transform:translateY(-1px)}
101
110
  .qa-ic{font-size:0.9rem;margin-bottom:2px}
@@ -114,36 +123,36 @@ const DashboardPage: React.FC = () => {
114
123
  .sv-m{display:flex;justify-content:space-between;font-size:0.65rem;color:#9ca3af}
115
124
 
116
125
  /* Stats */
117
- .sts{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:14px}
118
- .st{background:#fff;border-radius:12px;border:1px solid #e8ebe8;padding:14px;animation:fu 0.35s ease-out both}
126
+ .sts{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:16px}
127
+ .st{background:#fff;border-radius:12px;border:1px solid #e8ebe8;padding:16px;animation:fu 0.35s ease-out both}
119
128
  .st:nth-child(2){animation-delay:0.05s}
120
129
  .st:nth-child(3){animation-delay:0.1s}
121
130
  .st-ic{width:32px;height:32px;border-radius:8px;background:#f0fdf4;display:flex;align-items:center;justify-content:center;font-size:0.8rem;margin-bottom:10px}
122
- .st-v{font-size:1.3rem;font-weight:800;letter-spacing:-0.02em}
123
- .st-l{font-size:0.72rem;color:#9ca3af;margin-top:2px}
124
- .st-c{font-size:0.65rem;font-weight:600;padding:2px 6px;border-radius:4px;margin-top:6px;display:inline-block}
131
+ .st-v{font-size:1.4rem;font-weight:800;letter-spacing:-0.02em}
132
+ .st-l{font-size:0.75rem;color:#9ca3af;margin-top:2px}
133
+ .st-c{font-size:0.68rem;font-weight:600;padding:2px 6px;border-radius:4px;margin-top:6px;display:inline-block}
125
134
  .st-c.up{background:#f0fdf4;color:#16a34a}
126
135
  .st-c.dn{background:#fef2f2;color:#dc2626}
127
136
 
128
137
  /* Chart */
129
- .ch{display:flex;align-items:flex-end;gap:4px;height:140px;padding-top:6px}
138
+ .ch{display:flex;align-items:flex-end;gap:4px;height:160px;padding-top:6px}
130
139
  .ch-c{flex:1;display:flex;flex-direction:column;align-items:center;gap:2px}
131
140
  .ch-bw{flex:1;width:100%;display:flex;gap:2px;align-items:flex-end;justify-content:center}
132
141
  .ch-b{border-radius:2px 2px 0 0;min-width:6px;transition:all 0.2s}
133
142
  .ch-b:hover{filter:brightness(1.15)}
134
143
  .ch-b.inc{background:#16a34a}
135
144
  .ch-b.exp{background:#bbf7d0}
136
- .ch-m{font-size:0.55rem;color:#9ca3af;margin-top:3px}
137
- .lg{display:flex;gap:12px;font-size:0.68rem;color:#6b7280}
138
- .lg-d{width:7px;height:7px;border-radius:2px;display:inline-block;margin-right:3px}
145
+ .ch-m{font-size:0.58rem;color:#9ca3af;margin-top:3px}
146
+ .lg{display:flex;gap:12px;font-size:0.7rem;color:#6b7280}
147
+ .lg-d{width:8px;height:8px;border-radius:2px;display:inline-block;margin-right:4px}
139
148
 
140
149
  /* Table */
141
- .tb{width:100%;border-collapse:collapse;font-size:0.72rem}
142
- .tb th{text-align:left;padding:6px 8px;color:#9ca3af;font-weight:500;font-size:0.65rem;border-bottom:1px solid #f3f4f6}
143
- .tb td{padding:8px;border-bottom:1px solid #f9fafb;color:#374151}
144
- .tb-n{font-weight:600;color:#1a1a2e;font-size:0.75rem}
145
- .tb-c{font-size:0.65rem;color:#9ca3af}
146
- .badge{padding:2px 7px;border-radius:5px;font-size:0.62rem;font-weight:600}
150
+ .tb{width:100%;border-collapse:collapse;font-size:0.75rem}
151
+ .tb th{text-align:left;padding:8px 10px;color:#9ca3af;font-weight:500;font-size:0.68rem;border-bottom:1px solid #f3f4f6}
152
+ .tb td{padding:10px;border-bottom:1px solid #f9fafb;color:#374151}
153
+ .tb-n{font-weight:600;color:#1a1a2e;font-size:0.78rem}
154
+ .tb-c{font-size:0.68rem;color:#9ca3af}
155
+ .badge{padding:3px 8px;border-radius:5px;font-size:0.65rem;font-weight:600}
147
156
 
148
157
  /* Donut */
149
158
  .dn-w{display:flex;justify-content:center;margin:12px 0}
@@ -151,24 +160,61 @@ const DashboardPage: React.FC = () => {
151
160
  /* Expense list */
152
161
  .ex{display:flex;align-items:center;padding:5px 0}
153
162
  .ex-d{width:7px;height:7px;border-radius:50%;margin-right:7px;flex-shrink:0}
154
- .ex-p{font-size:0.65rem;color:#9ca3af;margin-right:10px;min-width:24px;font-weight:600}
155
- .ex-l{flex:1;font-size:0.75rem;color:#374151}
156
- .ex-a{font-size:0.78rem;font-weight:600;text-align:right}
163
+ .ex-p{font-size:0.68rem;color:#9ca3af;margin-right:10px;min-width:24px;font-weight:600}
164
+ .ex-l{flex:1;font-size:0.78rem;color:#374151}
165
+ .ex-a{font-size:0.8rem;font-weight:600;text-align:right}
157
166
 
158
167
  /* Activity */
159
168
  .ac{display:flex;gap:8px;padding:6px 0}
160
169
  .ac-d{width:7px;height:7px;border-radius:50%;margin-top:5px;flex-shrink:0}
161
- .ac-t{flex:1;font-size:0.72rem;color:#374151;line-height:1.4}
170
+ .ac-t{flex:1;font-size:0.75rem;color:#374151;line-height:1.4}
162
171
  .ac-u{font-weight:600;color:#1a1a2e}
163
- .ac-tm{font-size:0.62rem;color:#9ca3af;margin-top:1px}
172
+ .ac-tm{font-size:0.65rem;color:#9ca3af;margin-top:1px}
173
+
174
+ .stl{font-size:0.85rem;font-weight:700;margin-bottom:10px;display:flex;justify-content:space-between;align-items:center}
175
+ .stl span{font-size:0.68rem;color:#16a34a;font-weight:500;cursor:pointer}
176
+ .sub{font-size:0.68rem;color:#9ca3af}
177
+ .big{font-size:1.2rem;font-weight:800;margin-bottom:8px}
178
+
179
+ /* ── Responsive ── */
180
+
181
+ /* Large: hide right panel */
182
+ @media(max-width:1400px){
183
+ .mn{grid-template-columns:260px 1fr}
184
+ .rp{display:none}
185
+ }
164
186
 
165
- .stl{font-size:0.82rem;font-weight:700;margin-bottom:10px;display:flex;justify-content:space-between;align-items:center}
166
- .stl span{font-size:0.65rem;color:#16a34a;font-weight:500;cursor:pointer}
167
- .sub{font-size:0.65rem;color:#9ca3af}
168
- .big{font-size:1.15rem;font-weight:800;margin-bottom:8px}
187
+ /* Medium: hide left panel too, only center */
188
+ @media(max-width:1100px){
189
+ .mn{grid-template-columns:1fr}
190
+ .lp{display:none}
191
+ .sts{grid-template-columns:repeat(3,1fr)}
192
+ }
169
193
 
170
- @media(max-width:1100px){.mn{grid-template-columns:1fr}.lp,.rp{display:none}}
171
- @media(max-width:768px){.sb{display:none}.mn{margin-left:0}.sts{grid-template-columns:1fr}}
194
+ /* Tablet: hide sidebar, show mobile header */
195
+ @media(max-width:768px){
196
+ .sb{display:none}
197
+ .mn{margin-left:0;width:100%}
198
+ .mob-bar{display:flex}
199
+ .cp{padding:16px}
200
+ .sts{grid-template-columns:repeat(2,1fr)}
201
+ .search{width:140px}
202
+ .hdr{flex-wrap:wrap}
203
+ .hdr h1{display:none}
204
+ .tb th:nth-child(4),.tb td:nth-child(4){display:none}
205
+ }
206
+
207
+ /* Mobile: single column everything */
208
+ @media(max-width:480px){
209
+ .sts{grid-template-columns:1fr}
210
+ .cp{padding:12px}
211
+ .cd{padding:12px}
212
+ .hdr-right{flex-wrap:wrap}
213
+ .search{width:100%}
214
+ .st-v{font-size:1.1rem}
215
+ .ch{height:120px}
216
+ .tb th:nth-child(3),.tb td:nth-child(3){display:none}
217
+ }
172
218
  `}} />
173
219
 
174
220
  {/* Sidebar */}
@@ -191,6 +237,13 @@ const DashboardPage: React.FC = () => {
191
237
  </div>
192
238
  </aside>
193
239
 
240
+ {/* Mobile header bar */}
241
+ <div className="mob-bar">
242
+ <div className="mob-bar-btn" onClick={() => setActiveNav(activeNav)}>☰</div>
243
+ <h1>Dashboard</h1>
244
+ <div className="uav">AF</div>
245
+ </div>
246
+
194
247
  <div className="mn">
195
248
  {/* Left Panel */}
196
249
  <div className="lp">
@@ -233,7 +286,7 @@ const DashboardPage: React.FC = () => {
233
286
  <div className="cp">
234
287
  <div className="hdr">
235
288
  <h1>Dashboard</h1>
236
- <div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
289
+ <div className="hdr-right">
237
290
  <input className="search" placeholder="Search placeholder" />
238
291
  <div className="uav">AF</div>
239
292
  <span style={{ fontSize: "0.82rem", fontWeight: 600 }}>Andrew Forbist</span>
@@ -16,6 +16,7 @@ import React, {
16
16
  useRef,
17
17
  } from "react";
18
18
  import { matchRoute, type RouteDef } from "./router";
19
+ import { BlumenErrorBoundary } from "./ErrorBoundary";
19
20
 
20
21
  // ── Context types ──────────────────────────────────────────────
21
22
 
@@ -169,7 +170,9 @@ export function RouterProvider({
169
170
  <div
170
171
  className={`page-transition ${transitioning ? "page-transition-exit" : "page-transition-active"}`}
171
172
  >
172
- <App Component={PageComponent} pageProps={pageProps} />
173
+ <BlumenErrorBoundary>
174
+ <App Component={PageComponent} pageProps={pageProps} />
175
+ </BlumenErrorBoundary>
173
176
  </div>
174
177
  </RouterContext.Provider>
175
178
  );
@@ -0,0 +1,147 @@
1
+ package main
2
+
3
+ import (
4
+ "crypto/sha256"
5
+ "encoding/hex"
6
+ "sync"
7
+ "time"
8
+ )
9
+
10
+ // CacheEntry represents a single cached page response.
11
+ type CacheEntry struct {
12
+ HTML string
13
+ ETag string
14
+ CreatedAt time.Time
15
+ Revalidate int // TTL in seconds; 0 = cache forever until evicted
16
+ Stale bool
17
+ }
18
+
19
+ // PageCache is a thread-safe in-memory LRU cache for rendered HTML pages.
20
+ // It uses sync.RWMutex for maximum concurrent read performance —
21
+ // thousands of goroutines can read simultaneously while writes are serialized.
22
+ type PageCache struct {
23
+ mu sync.RWMutex
24
+ entries map[string]*CacheEntry
25
+ order []string // LRU order: most recently used at end
26
+ maxSize int
27
+ }
28
+
29
+ // NewPageCache creates a new cache with the given maximum number of entries.
30
+ func NewPageCache(maxSize int) *PageCache {
31
+ return &PageCache{
32
+ entries: make(map[string]*CacheEntry),
33
+ order: make([]string, 0, maxSize),
34
+ maxSize: maxSize,
35
+ }
36
+ }
37
+
38
+ // Get retrieves a cached page. Returns the entry and whether it was found.
39
+ // Also returns whether the entry is stale (past its revalidate TTL).
40
+ func (c *PageCache) Get(key string) (entry *CacheEntry, found bool, stale bool) {
41
+ c.mu.RLock()
42
+ e, ok := c.entries[key]
43
+ c.mu.RUnlock()
44
+
45
+ if !ok {
46
+ return nil, false, false
47
+ }
48
+
49
+ // Check if the entry has expired
50
+ if e.Revalidate > 0 {
51
+ age := time.Since(e.CreatedAt).Seconds()
52
+ if age > float64(e.Revalidate) {
53
+ return e, true, true // Found but stale
54
+ }
55
+ }
56
+
57
+ // Move to end of LRU order (most recently used)
58
+ c.mu.Lock()
59
+ c.moveToEnd(key)
60
+ c.mu.Unlock()
61
+
62
+ return e, true, false
63
+ }
64
+
65
+ // Set stores a page in the cache with the given revalidation TTL.
66
+ func (c *PageCache) Set(key string, html string, revalidate int) {
67
+ etag := generateETag(html)
68
+
69
+ c.mu.Lock()
70
+ defer c.mu.Unlock()
71
+
72
+ // If entry exists, update it
73
+ if _, exists := c.entries[key]; exists {
74
+ c.entries[key] = &CacheEntry{
75
+ HTML: html,
76
+ ETag: etag,
77
+ CreatedAt: time.Now(),
78
+ Revalidate: revalidate,
79
+ }
80
+ c.moveToEnd(key)
81
+ return
82
+ }
83
+
84
+ // Evict LRU entry if at capacity
85
+ if len(c.entries) >= c.maxSize && len(c.order) > 0 {
86
+ oldest := c.order[0]
87
+ c.order = c.order[1:]
88
+ delete(c.entries, oldest)
89
+ }
90
+
91
+ // Add new entry
92
+ c.entries[key] = &CacheEntry{
93
+ HTML: html,
94
+ ETag: etag,
95
+ CreatedAt: time.Now(),
96
+ Revalidate: revalidate,
97
+ }
98
+ c.order = append(c.order, key)
99
+ }
100
+
101
+ // Invalidate removes a specific key from the cache.
102
+ func (c *PageCache) Invalidate(key string) {
103
+ c.mu.Lock()
104
+ defer c.mu.Unlock()
105
+
106
+ delete(c.entries, key)
107
+ for i, k := range c.order {
108
+ if k == key {
109
+ c.order = append(c.order[:i], c.order[i+1:]...)
110
+ break
111
+ }
112
+ }
113
+ }
114
+
115
+ // Clear removes all entries from the cache.
116
+ func (c *PageCache) Clear() {
117
+ c.mu.Lock()
118
+ defer c.mu.Unlock()
119
+
120
+ c.entries = make(map[string]*CacheEntry)
121
+ c.order = make([]string, 0, c.maxSize)
122
+ }
123
+
124
+ // Size returns the current number of cached entries.
125
+ func (c *PageCache) Size() int {
126
+ c.mu.RLock()
127
+ defer c.mu.RUnlock()
128
+ return len(c.entries)
129
+ }
130
+
131
+ // moveToEnd moves a key to the end of the LRU order (most recently used).
132
+ // Must be called with the write lock held.
133
+ func (c *PageCache) moveToEnd(key string) {
134
+ for i, k := range c.order {
135
+ if k == key {
136
+ c.order = append(c.order[:i], c.order[i+1:]...)
137
+ c.order = append(c.order, key)
138
+ return
139
+ }
140
+ }
141
+ }
142
+
143
+ // generateETag creates an ETag from the HTML content.
144
+ func generateETag(content string) string {
145
+ hash := sha256.Sum256([]byte(content))
146
+ return `"` + hex.EncodeToString(hash[:8]) + `"`
147
+ }
@@ -0,0 +1,200 @@
1
+ package main
2
+
3
+ import (
4
+ "bytes"
5
+ "crypto/sha256"
6
+ "encoding/hex"
7
+ "fmt"
8
+ "image"
9
+ "image/jpeg"
10
+ "image/png"
11
+ "log"
12
+ "net/http"
13
+ "os"
14
+ "path/filepath"
15
+ "strconv"
16
+ "strings"
17
+
18
+ "golang.org/x/image/draw"
19
+ )
20
+
21
+ const (
22
+ imageCacheDir = ".blumen/image-cache"
23
+ maxImageWidth = 4096
24
+ defaultQuality = 80
25
+ )
26
+
27
+ func init() {
28
+ // Ensure cache directory exists
29
+ os.MkdirAll(imageCacheDir, 0755)
30
+ }
31
+
32
+ // ImageHandler serves optimized images with on-the-fly resizing and caching.
33
+ // URL: /_blumen/image?src=/static/hero.jpg&w=800&q=80&blur=1
34
+ func ImageHandler() http.HandlerFunc {
35
+ return func(w http.ResponseWriter, r *http.Request) {
36
+ if r.Method != http.MethodGet {
37
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
38
+ return
39
+ }
40
+
41
+ q := r.URL.Query()
42
+ src := q.Get("src")
43
+ widthStr := q.Get("w")
44
+ qualityStr := q.Get("q")
45
+ isBlur := q.Get("blur") == "1"
46
+
47
+ if src == "" {
48
+ http.Error(w, "Missing 'src' parameter", http.StatusBadRequest)
49
+ return
50
+ }
51
+
52
+ // Parse width
53
+ targetWidth := 0
54
+ if widthStr != "" {
55
+ var err error
56
+ targetWidth, err = strconv.Atoi(widthStr)
57
+ if err != nil || targetWidth <= 0 || targetWidth > maxImageWidth {
58
+ http.Error(w, "Invalid 'w' parameter", http.StatusBadRequest)
59
+ return
60
+ }
61
+ }
62
+
63
+ // Parse quality
64
+ quality := defaultQuality
65
+ if qualityStr != "" {
66
+ var err error
67
+ quality, err = strconv.Atoi(qualityStr)
68
+ if err != nil || quality < 1 || quality > 100 {
69
+ quality = defaultQuality
70
+ }
71
+ }
72
+
73
+ // Security: resolve the source path and ensure it's within the project
74
+ cleanSrc := strings.TrimPrefix(src, "/")
75
+ absPath, err := filepath.Abs(cleanSrc)
76
+ if err != nil {
77
+ http.Error(w, "Invalid path", http.StatusBadRequest)
78
+ return
79
+ }
80
+
81
+ // Ensure path is within the project directory
82
+ cwd, _ := os.Getwd()
83
+ if !strings.HasPrefix(absPath, cwd) {
84
+ http.Error(w, "Access denied", http.StatusForbidden)
85
+ return
86
+ }
87
+
88
+ // Check if file exists
89
+ if _, err := os.Stat(absPath); os.IsNotExist(err) {
90
+ http.Error(w, "Image not found", http.StatusNotFound)
91
+ return
92
+ }
93
+
94
+ // Generate cache key
95
+ cacheKey := generateCacheKey(src, targetWidth, quality, isBlur)
96
+ cachePath := filepath.Join(imageCacheDir, cacheKey+".jpg")
97
+
98
+ // Check disk cache
99
+ if cachedData, err := os.ReadFile(cachePath); err == nil {
100
+ serveCachedImage(w, cachedData)
101
+ return
102
+ }
103
+
104
+ // Process the image
105
+ processedData, err := processImage(absPath, targetWidth, quality, isBlur)
106
+ if err != nil {
107
+ log.Printf("Image processing error: %v", err)
108
+ http.ServeFile(w, r, absPath)
109
+ return
110
+ }
111
+
112
+ // Write to disk cache
113
+ os.WriteFile(cachePath, processedData, 0644)
114
+
115
+ // Serve the processed image
116
+ serveCachedImage(w, processedData)
117
+ }
118
+ }
119
+
120
+ func generateCacheKey(src string, width, quality int, blur bool) string {
121
+ data := fmt.Sprintf("%s-%d-%d-%v", src, width, quality, blur)
122
+ hash := sha256.Sum256([]byte(data))
123
+ return hex.EncodeToString(hash[:16])
124
+ }
125
+
126
+ func serveCachedImage(w http.ResponseWriter, data []byte) {
127
+ w.Header().Set("Content-Type", "image/jpeg")
128
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
129
+ w.Header().Set("X-Blumen-Image", "optimized")
130
+ w.Header().Set("Content-Length", strconv.Itoa(len(data)))
131
+ w.WriteHeader(http.StatusOK)
132
+ w.Write(data)
133
+ }
134
+
135
+ func processImage(filePath string, targetWidth, quality int, blur bool) ([]byte, error) {
136
+ file, err := os.Open(filePath)
137
+ if err != nil {
138
+ return nil, fmt.Errorf("open image: %w", err)
139
+ }
140
+ defer file.Close()
141
+
142
+ img, format, err := image.Decode(file)
143
+ if err != nil {
144
+ return nil, fmt.Errorf("decode image (%s): %w", format, err)
145
+ }
146
+
147
+ bounds := img.Bounds()
148
+ origWidth := bounds.Dx()
149
+ origHeight := bounds.Dy()
150
+
151
+ // Calculate target dimensions maintaining aspect ratio
152
+ newWidth := origWidth
153
+ newHeight := origHeight
154
+
155
+ if targetWidth > 0 && targetWidth < origWidth {
156
+ ratio := float64(targetWidth) / float64(origWidth)
157
+ newWidth = targetWidth
158
+ newHeight = int(float64(origHeight) * ratio)
159
+ }
160
+
161
+ // Resize using high-quality bilinear interpolation
162
+ resized := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))
163
+ draw.BiLinear.Scale(resized, resized.Bounds(), img, bounds, draw.Over, nil)
164
+
165
+ // For blur placeholders, downscale aggressively
166
+ var finalImg image.Image = resized
167
+ if blur {
168
+ blurWidth := 20
169
+ if newWidth < blurWidth {
170
+ blurWidth = newWidth
171
+ }
172
+ blurHeight := int(float64(newHeight) * (float64(blurWidth) / float64(newWidth)))
173
+ if blurHeight < 1 {
174
+ blurHeight = 1
175
+ }
176
+ blurred := image.NewRGBA(image.Rect(0, 0, blurWidth, blurHeight))
177
+ draw.BiLinear.Scale(blurred, blurred.Bounds(), resized, resized.Bounds(), draw.Over, nil)
178
+ finalImg = blurred
179
+ }
180
+
181
+ // Encode to JPEG
182
+ var buf bytes.Buffer
183
+
184
+ jpegQuality := quality
185
+ if blur {
186
+ jpegQuality = 30
187
+ }
188
+
189
+ err = jpeg.Encode(&buf, finalImg, &jpeg.Options{Quality: jpegQuality})
190
+ if err != nil {
191
+ return nil, fmt.Errorf("encode jpeg: %w", err)
192
+ }
193
+
194
+ return buf.Bytes(), nil
195
+ }
196
+
197
+ // Ensure PNG decoder is registered
198
+ func init() {
199
+ _ = png.Decode
200
+ }