Commit6dc82d27Recorded18 Feb 2026Repositorysigil-web-demo
feat(web): Add Vinyl Vault demo application
Message
A full CRUD app showcasing sigil-web features: routing, forms with validation, data tables, pagination, search with loading states, flash messages, modal dialogs, and inline CSS via sigil-css. Uses SQLite for persistence with 24 seeded vinyl records spanning multiple genres.
Changed
assets/sigil-web-demo/css/style.css | 444 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
package.sgl | 21 ++++++
run.sgl | 4 ++
src/sigil/web/demo.sgl | 240 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/sigil/web/demo/db.sgl | 143 +++++++++++++++++++++++++++++++++++++++++
src/sigil/web/demo/views.sgl | 548 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 1400 insertions(+)Diff
assets/sigil-web-demo/css/style.cssadded
@@ -0,0 +1,444 @@
+1
/* Vinyl Vault - sigil-web demo stylesheet */+2
+3
/* ============================================================+4
Base & Layout+5
============================================================ */+6
+7
* {+8
margin: 0;+9
padding: 0;+10
box-sizing: border-box;+11
}+12
+13
body {+14
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,+15
Helvetica, Arial, sans-serif;+16
line-height: 1.6;+17
color: #1a1a1a;+18
background: #fafafa;+19
}+20
+21
.container {+22
max-width: 900px;+23
margin: 0 auto;+24
padding: 2rem 1rem;+25
}+26
+27
h1 { margin-bottom: 1rem; }+28
h2 { margin: 1.5rem 0 0.75rem; }+29
p { margin-bottom: 0.75rem; }+30
a { color: #2563eb; text-decoration: none; }+31
a:hover { text-decoration: underline; }+32
+33
/* ============================================================+34
Navigation+35
============================================================ */+36
+37
.nav-bar {+38
background: #1e293b;+39
color: #f8fafc;+40
padding: 0 1rem;+41
}+42
+43
.nav-content {+44
max-width: 900px;+45
margin: 0 auto;+46
display: flex;+47
align-items: center;+48
justify-content: space-between;+49
height: 3.5rem;+50
}+51
+52
.nav-brand {+53
color: #f8fafc;+54
font-weight: 700;+55
font-size: 1.25rem;+56
}+57
+58
.nav-brand:hover {+59
color: #e2e8f0;+60
text-decoration: none;+61
}+62
+63
.nav-links {+64
list-style: none;+65
display: flex;+66
gap: 1.5rem;+67
}+68
+69
.nav-links a {+70
color: #cbd5e1;+71
font-size: 0.9rem;+72
}+73
+74
.nav-links a:hover {+75
color: #f8fafc;+76
text-decoration: none;+77
}+78
+79
/* ============================================================+80
Flash Messages+81
============================================================ */+82
+83
#sg-flash-container {+84
max-width: 900px;+85
margin: 0 auto;+86
padding: 0 1rem;+87
}+88
+89
.sg-flash {+90
padding: 0.75rem 1rem;+91
margin: 0.5rem 0;+92
border-left: 4px solid #94a3b8;+93
border-radius: 0 4px 4px 0;+94
background: #f1f5f9;+95
}+96
+97
.sg-flash-success {+98
border-left-color: #22c55e;+99
background: #f0fdf4;+100
color: #166534;+101
}+102
+103
.sg-flash-error {+104
border-left-color: #ef4444;+105
background: #fef2f2;+106
color: #991b1b;+107
}+108
+109
.sg-flash-warning {+110
border-left-color: #f59e0b;+111
background: #fffbeb;+112
color: #92400e;+113
}+114
+115
.sg-flash-info {+116
border-left-color: #3b82f6;+117
background: #eff6ff;+118
color: #1e40af;+119
}+120
+121
/* ============================================================+122
Loading Indicator+123
============================================================ */+124
+125
.sg-loading {+126
display: none;+127
width: 1rem;+128
height: 1rem;+129
border: 2px solid #e2e8f0;+130
border-top-color: #3b82f6;+131
border-radius: 50%;+132
animation: sg-spin 0.6s linear infinite;+133
}+134
+135
.sg-loading.active {+136
display: inline-block;+137
}+138
+139
@keyframes sg-spin {+140
to { transform: rotate(360deg); }+141
}+142
+143
/* ============================================================+144
Modal Dialogs+145
============================================================ */+146
+147
dialog {+148
border: none;+149
border-radius: 8px;+150
padding: 1.5rem;+151
max-width: 480px;+152
width: 90%;+153
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);+154
}+155
+156
dialog::backdrop {+157
background: rgba(0, 0, 0, 0.5);+158
}+159
+160
dialog h2 {+161
margin: 0 0 1rem;+162
}+163
+164
.modal-actions {+165
display: flex;+166
gap: 0.5rem;+167
justify-content: flex-end;+168
margin-top: 1.5rem;+169
}+170
+171
/* ============================================================+172
Tables+173
============================================================ */+174
+175
table {+176
width: 100%;+177
border-collapse: collapse;+178
margin: 1rem 0;+179
background: #fff;+180
border-radius: 4px;+181
overflow: hidden;+182
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);+183
}+184
+185
thead th {+186
background: #f8fafc;+187
text-align: left;+188
padding: 0.75rem 1rem;+189
font-weight: 600;+190
font-size: 0.85rem;+191
text-transform: uppercase;+192
letter-spacing: 0.05em;+193
color: #64748b;+194
border-bottom: 2px solid #e2e8f0;+195
}+196
+197
tbody td {+198
padding: 0.75rem 1rem;+199
border-bottom: 1px solid #f1f5f9;+200
}+201
+202
tbody tr:hover {+203
background: #f8fafc;+204
}+205
+206
tbody tr:nth-child(even) {+207
background: #fafbfc;+208
}+209
+210
tbody tr:nth-child(even):hover {+211
background: #f1f5f9;+212
}+213
+214
/* ============================================================+215
Pagination+216
============================================================ */+217
+218
.sg-paginator {+219
margin: 1.5rem 0;+220
}+221
+222
.sg-paginator ul {+223
list-style: none;+224
display: flex;+225
gap: 0.25rem;+226
flex-wrap: wrap;+227
align-items: center;+228
}+229
+230
.sg-paginator li a,+231
.sg-paginator li span {+232
display: inline-block;+233
padding: 0.4rem 0.75rem;+234
border: 1px solid #e2e8f0;+235
border-radius: 4px;+236
font-size: 0.9rem;+237
text-decoration: none;+238
}+239
+240
.sg-paginator li a:hover {+241
background: #f1f5f9;+242
text-decoration: none;+243
}+244
+245
.sg-paginator .current {+246
background: #2563eb;+247
color: #fff;+248
border-color: #2563eb;+249
font-weight: 600;+250
}+251
+252
.sg-paginator .disabled {+253
color: #94a3b8;+254
cursor: default;+255
border-color: #f1f5f9;+256
}+257
+258
/* ============================================================+259
Buttons+260
============================================================ */+261
+262
.btn, button[type="submit"] {+263
display: inline-block;+264
padding: 0.5rem 1.25rem;+265
background: #2563eb;+266
color: #fff;+267
border: none;+268
border-radius: 4px;+269
font-size: 0.9rem;+270
cursor: pointer;+271
text-decoration: none;+272
line-height: 1.5;+273
}+274
+275
.btn:hover, button[type="submit"]:hover {+276
background: #1d4ed8;+277
text-decoration: none;+278
color: #fff;+279
}+280
+281
.btn-small {+282
padding: 0.25rem 0.75rem;+283
font-size: 0.8rem;+284
}+285
+286
.btn-danger {+287
background: #dc2626;+288
}+289
+290
.btn-danger:hover {+291
background: #b91c1c;+292
}+293
+294
/* ============================================================+295
Forms+296
============================================================ */+297
+298
.form-fields > div {+299
margin-bottom: 1rem;+300
}+301
+302
.form-fields label {+303
display: block;+304
font-weight: 600;+305
margin-bottom: 0.25rem;+306
font-size: 0.9rem;+307
color: #374151;+308
}+309
+310
.form-fields input[type="text"],+311
.form-fields input[type="email"],+312
.form-fields input[type="password"],+313
.form-fields select,+314
.form-fields textarea {+315
display: block;+316
width: 100%;+317
padding: 0.5rem 0.75rem;+318
border: 1px solid #d1d5db;+319
border-radius: 4px;+320
font-size: 0.95rem;+321
font-family: inherit;+322
line-height: 1.5;+323
background: #fff;+324
}+325
+326
.form-fields input:focus,+327
.form-fields select:focus,+328
.form-fields textarea:focus {+329
outline: none;+330
border-color: #2563eb;+331
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);+332
}+333
+334
.field-error {+335
color: #dc2626;+336
font-size: 0.8rem;+337
margin-top: 0.25rem;+338
display: block;+339
}+340
+341
button[type="submit"] {+342
margin-top: 1rem;+343
}+344
+345
.search-form {+346
display: flex;+347
gap: 0.5rem;+348
align-items: flex-end;+349
margin-bottom: 1rem;+350
}+351
+352
.search-form > div {+353
flex: 1;+354
}+355
+356
.search-form input {+357
width: 100%;+358
padding: 0.5rem 0.75rem;+359
border: 1px solid #d1d5db;+360
border-radius: 4px;+361
font-size: 0.95rem;+362
}+363
+364
/* ============================================================+365
Record Detail+366
============================================================ */+367
+368
.record-detail {+369
display: grid;+370
grid-template-columns: 8rem 1fr;+371
gap: 0.5rem 1rem;+372
margin: 1.5rem 0;+373
background: #fff;+374
padding: 1.5rem;+375
border-radius: 4px;+376
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);+377
}+378
+379
.record-detail dt {+380
font-weight: 600;+381
color: #64748b;+382
font-size: 0.85rem;+383
text-transform: uppercase;+384
letter-spacing: 0.05em;+385
}+386
+387
.record-detail dd {+388
color: #1e293b;+389
}+390
+391
.detail-actions {+392
display: flex;+393
gap: 0.5rem;+394
margin-top: 1.5rem;+395
}+396
+397
/* ============================================================+398
Stats Grid+399
============================================================ */+400
+401
.stats-grid {+402
display: grid;+403
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));+404
gap: 1rem;+405
margin: 1rem 0;+406
}+407
+408
.stat-card {+409
background: #fff;+410
padding: 1.25rem;+411
border-radius: 4px;+412
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);+413
text-align: center;+414
}+415
+416
.stat-number {+417
font-size: 1.5rem;+418
font-weight: 700;+419
color: #2563eb;+420
margin-bottom: 0.25rem;+421
overflow: hidden;+422
text-overflow: ellipsis;+423
white-space: nowrap;+424
}+425
+426
.stat-label {+427
font-size: 0.8rem;+428
text-transform: uppercase;+429
letter-spacing: 0.05em;+430
color: #64748b;+431
}+432
+433
/* ============================================================+434
Loading State Utilities+435
============================================================ */+436
+437
.opacity-50 {+438
opacity: 0.5;+439
pointer-events: none;+440
}+441
+442
.cursor-wait {+443
cursor: wait;+444
}package.sgladded
@@ -0,0 +1,21 @@
+1
;;; sigil-web-demo - Demo web application showcasing sigil-web features+2
;;;+3
;;; A vinyl record collection CRUD app ("Vinyl Vault") demonstrating+4
;;; routing, forms, data tables, pagination, flash messages, modals,+5
;;; SSE streaming, and server-driven UI.+6
+7
(package+8
name: "sigil-web-demo"+9
version: "0.5.0"+10
description: "Demo web application for sigil-web"+11
url: "https://codeberg.org/sigil/sigil"+12
license: "BSD-3-Clause"+13
authors: (list "David Wilson <[email protected]>")+14
+15
dependencies: (list+16
(from-workspace name: "sigil-stdlib")+17
(from-workspace name: "sigil-http")+18
(from-workspace name: "sigil-web")+19
(from-workspace name: "sigil-sqlite")+20
(from-workspace name: "sigil-sxml")+21
(from-workspace name: "sigil-css")))run.sgladded
@@ -0,0 +1,4 @@
+1
;; Vinyl Vault - sigil-web demo application+2
;; Usage: ./build/dev/bin/sigil run packages/sigil-web-demo/run.sgl+3
(import (sigil web demo))+4
(start-demo)src/sigil/web/demo.sgladded
@@ -0,0 +1,240 @@
+1
;;; (sigil web demo) - Vinyl Vault demo application+2
;;;+3
;;; Entry point for the sigil-web demo CRUD app. Defines routes,+4
;;; handlers, middleware, and starts the HTTP server.+5
+6
(define-library (sigil web demo)+7
(import (sigil core)+8
(sigil string)+9
(sigil math)+10
(sigil time)+11
(sigil async)+12
(sigil sxml)+13
(sigil http)+14
(sigil http response)+15
(sigil http request)+16
(sigil web)+17
(sigil web ui)+18
(sigil sqlite)+19
(sigil web demo db)+20
(sigil web demo views))+21
+22
(export start-demo)+23
+24
(begin+25
+26
;; ============================================================+27
;; Middleware+28
;; ============================================================+29
+30
(define (with-db handler db)+31
(lambda (req)+32
(handler (http-request-with-context req 'db db))))+33
+34
;; ============================================================+35
;; Helpers+36
;; ============================================================+37
+38
(define (render-page title body-sxml)+39
(string-append "<!DOCTYPE html>\n"+40
(sxml->html (page-layout title body-sxml))))+41
+42
(define (db-from-req req)+43
(http-request-context-ref req 'db))+44
+45
(define (parse-id req)+46
(string->number (path-param req "id")))+47
+48
(define (parse-query-params req)+49
(if (http-request-query req)+50
(parse-form-urlencoded (http-request-query req))+51
'()))+52
+53
;; Simple validation: returns alist of errors or #f+54
(define (validate-record params)+55
(let ((errors '()))+56
(when (or (not (assoc-ref 'title params))+57
(string=? (string-trim (assoc-ref 'title params)) ""))+58
(set! errors (cons '(title . "Title is required") errors)))+59
(when (or (not (assoc-ref 'artist params))+60
(string=? (string-trim (assoc-ref 'artist params)) ""))+61
(set! errors (cons '(artist . "Artist is required") errors)))+62
(let ((y (assoc-ref 'year params)))+63
(when (and y (not (string=? y ""))+64
(not (string->number y)))+65
(set! errors (cons '(year . "Year must be a number") errors))))+66
(if (null? errors) #f errors)))+67
+68
;; Get stats for the home page+69
(define (get-stats db)+70
(let* ((total (count-records db #f))+71
(genre-rows (sqlite-query db+72
"SELECT DISTINCT genre FROM records WHERE genre IS NOT NULL AND genre != ''"))+73
(latest (sqlite-query-row db+74
"SELECT title FROM records ORDER BY id DESC LIMIT 1")))+75
(values total genre-rows latest)))+76
+77
;; ============================================================+78
;; Page Handlers (GET, return full HTML)+79
;; ============================================================+80
+81
(define (home-handler req)+82
(let ((db (db-from-req req)))+83
(let-values (((total genre-rows latest) (get-stats db)))+84
(http-response/html 200+85
(render-page "Home"+86
(home-page total genre-rows latest))))))+87
+88
(define (records-handler req)+89
(let* ((db (db-from-req req))+90
(params (parse-query-params req))+91
(page (or (and (assoc-ref 'page params)+92
(string->number (assoc-ref 'page params)))+93
1))+94
(search (assoc-ref 'q params #f))+95
(per-page 10)+96
(total (count-records db search))+97
(total-pages (max 1 (ceiling (/ total per-page))))+98
(rows (list-records db page per-page search)))+99
(http-response/html 200+100
(render-page "Records"+101
(records-list-page rows page total-pages search)))))+102
+103
(define (new-record-handler req)+104
(http-response/html 200+105
(render-page "New Record"+106
(record-form-page #f #f))))+107
+108
(define (show-record-handler req)+109
(let* ((db (db-from-req req))+110
(id (parse-id req))+111
(record (get-record db id)))+112
(if record+113
(http-response/html 200+114
(render-page (assoc-ref 'title record)+115
(record-detail-page record)))+116
(http-response/html 404+117
(render-page "Not Found"+118
'(div (h1 "Record Not Found")+119
(p "The record you're looking for doesn't exist.")+120
(a (@ (href "/records")) "Back to collection")))))))+121
+122
(define (edit-record-handler req)+123
(let* ((db (db-from-req req))+124
(id (parse-id req))+125
(record (get-record db id)))+126
(if record+127
(http-response/html 200+128
(render-page "Edit Record"+129
(record-form-page record #f)))+130
(http-response/html 404+131
(render-page "Not Found"+132
'(div (h1 "Record Not Found")+133
(p "The record you're looking for doesn't exist.")+134
(a (@ (href "/records")) "Back to collection")))))))+135
+136
;; ============================================================+137
;; Action Handlers (POST/DELETE, return SSE batch)+138
;; ============================================================+139
+140
(define (create-record-handler req)+141
(let* ((db (db-from-req req))+142
(params (parse-form-data req))+143
(errors (validate-record params)))+144
(if errors+145
(http-response/html 422+146
(render-page "New Record"+147
(record-form-page #f errors)))+148
(begin+149
(sleep 0.5)+150
(let ((new-id (create-record db params)))+151
(sse-response-batch+152
(sse-flash type: 'success+153
message: "Record added successfully!"+154
remove-after: 3000)+155
(sse-redirect+156
url: (string-append "/records/"+157
(number->string new-id)))))))))+158
+159
(define (update-record-handler req)+160
(let* ((db (db-from-req req))+161
(id (parse-id req))+162
(params (parse-form-data req))+163
(errors (validate-record params)))+164
(if errors+165
(let ((record (get-record db id)))+166
(http-response/html 422+167
(render-page "Edit Record"+168
(record-form-page record errors))))+169
(begin+170
(sleep 0.5)+171
(update-record db id params)+172
(sse-response-batch+173
(sse-flash type: 'success+174
message: "Record updated successfully!"+175
remove-after: 3000)+176
(sse-redirect+177
url: (string-append "/records/"+178
(number->string id))))))))+179
+180
(define (delete-record-handler req)+181
(let* ((db (db-from-req req))+182
(id (parse-id req)))+183
(delete-record db id)+184
(sse-response-batch+185
(sse-flash type: 'success+186
message: "Record deleted."+187
remove-after: 3000)+188
(sse-redirect url: "/records"))))+189
+190
;; ============================================================+191
;; Search/Pagination Handler (GET, return partial)+192
;; ============================================================+193
+194
(define (search-handler req)+195
(sleep 1)+196
(let* ((db (db-from-req req))+197
(params (parse-query-params req))+198
(page (or (and (assoc-ref 'page params)+199
(string->number (assoc-ref 'page params)))+200
1))+201
(search (assoc-ref 'q params #f))+202
(per-page 10)+203
(total (count-records db search))+204
(total-pages (max 1 (ceiling (/ total per-page))))+205
(rows (list-records db page per-page search)))+206
(sigil-ui-response+207
target: "#records-list"+208
content: (records-table-partial rows page total-pages search))))+209
+210
;; ============================================================+211
;; Application Setup+212
;; ============================================================+213
+214
(define (make-app db)+215
(-> (routes+216
(router+217
(route method: GET pattern: "/" handler: home-handler)+218
(route method: GET pattern: "/records" handler: records-handler)+219
(route method: GET pattern: "/records/search" handler: search-handler)+220
(route method: GET pattern: "/records/new" handler: new-record-handler)+221
(route method: GET pattern: "/records/:id" handler: show-record-handler)+222
(route method: GET pattern: "/records/:id/edit" handler: edit-record-handler)+223
(route method: POST pattern: "/records" handler: create-record-handler)+224
(route method: POST pattern: "/records/:id" handler: update-record-handler)+225
(route method: DELETE pattern: "/records/:id" handler: delete-record-handler)+226
(route method: GET pattern: "/js/sigil-web-ui.js" handler: (sigil-web-ui-handler))))+227
(with-db _ db)+228
(with-logging)+229
(with-content-type default: "text/html; charset=utf-8")+230
(with-not-found)))+231
+232
;;; Start the demo server.+233
(define (start-demo)+234
(let ((db (sqlite-open "vinyl-vault.db")))+235
(init-db db)+236
(display "Vinyl Vault running at http://localhost:8080\n")+237
(with-async+238
(http-serve (make-app db) port: 8080))))+239
+240
))src/sigil/web/demo/db.sgladded
@@ -0,0 +1,143 @@
+1
;;; (sigil web demo db) - Database schema and CRUD operations+2
;;;+3
;;; Manages a SQLite database of vinyl records with create, read,+4
;;; update, delete, search, and pagination support.+5
+6
(define-library (sigil web demo db)+7
(import (sigil core)+8
(sigil sqlite))+9
+10
(export+11
init-db+12
list-records+13
count-records+14
get-record+15
create-record+16
update-record+17
delete-record)+18
+19
(begin+20
+21
;; ============================================================+22
;; Schema and Seeding+23
;; ============================================================+24
+25
;;; Initialize the database: create table and seed sample data.+26
(define (init-db db)+27
(sqlite-exec db+28
"CREATE TABLE IF NOT EXISTS records (+29
id INTEGER PRIMARY KEY AUTOINCREMENT,+30
title TEXT NOT NULL,+31
artist TEXT NOT NULL,+32
year INTEGER,+33
genre TEXT,+34
format TEXT DEFAULT 'LP',+35
notes TEXT,+36
created_at TEXT DEFAULT (datetime('now'))+37
)")+38
;; Seed if empty+39
(let ((rows (sqlite-query db "SELECT COUNT(*) AS cnt FROM records")))+40
(when (and (pair? rows) (= 0 (assoc-ref 'cnt (car rows))))+41
(seed-records db))))+42
+43
;; Insert sample records+44
(define (seed-records db)+45
(for-each+46
(lambda (rec)+47
(sqlite-run db+48
"INSERT INTO records (title, artist, year, genre, format) VALUES (?, ?, ?, ?, ?)"+49
(list-ref rec 0) (list-ref rec 1) (list-ref rec 2)+50
(list-ref rec 3) (list-ref rec 4)))+51
'(("Kind of Blue" "Miles Davis" 1959 "Jazz" "LP")+52
("Rumours" "Fleetwood Mac" 1977 "Rock" "LP")+53
("Purple Rain" "Prince" 1984 "Pop/Funk" "LP")+54
("Illmatic" "Nas" 1994 "Hip-Hop" "LP")+55
("Selected Ambient Works 85-92" "Aphex Twin" 1992 "Electronic" "LP")+56
("What's Going On" "Marvin Gaye" 1971 "Soul" "LP")+57
("OK Computer" "Radiohead" 1997 "Alternative" "LP")+58
("Midnight Marauders" "A Tribe Called Quest" 1993 "Hip-Hop" "LP")+59
("The Miseducation of Lauryn Hill" "Lauryn Hill" 1998 "R&B" "LP")+60
("Blue Train" "John Coltrane" 1958 "Jazz" "LP")+61
("Nevermind" "Nirvana" 1991 "Alternative" "LP")+62
("Abbey Road" "The Beatles" 1969 "Rock" "LP")+63
("Aquemini" "OutKast" 1998 "Hip-Hop" "LP")+64
("Homogenic" "Bjork" 1997 "Electronic" "LP")+65
("Songs in the Key of Life" "Stevie Wonder" 1976 "Soul" "LP")+66
("London Calling" "The Clash" 1979 "Punk" "LP")+67
("Remain in Light" "Talking Heads" 1980 "Rock" "LP")+68
("Dummy" "Portishead" 1994 "Electronic" "LP")+69
("The Low End Theory" "A Tribe Called Quest" 1991 "Hip-Hop" "LP")+70
("Loveless" "My Bloody Valentine" 1991 "Alternative" "LP")+71
("Bitches Brew" "Miles Davis" 1970 "Jazz" "LP")+72
("Superfly" "Curtis Mayfield" 1972 "Soul" "LP")+73
("Disintegration" "The Cure" 1989 "Alternative" "LP")+74
("Parallel Lines" "Blondie" 1978 "Rock" "7\""))))+75
+76
;; ============================================================+77
;; CRUD Operations+78
;; ============================================================+79
+80
;;; List records with pagination and optional search.+81
;;; Returns list of alist rows.+82
(define (list-records db page per-page search)+83
(let ((offset (* (- page 1) per-page)))+84
(if search+85
(let ((pattern (string-append "%" search "%")))+86
(sqlite-query db+87
"SELECT * FROM records WHERE title LIKE ? OR artist LIKE ? ORDER BY id LIMIT ? OFFSET ?"+88
pattern pattern per-page offset))+89
(sqlite-query db+90
"SELECT * FROM records ORDER BY id LIMIT ? OFFSET ?"+91
per-page offset))))+92
+93
;;; Count records with optional search filter.+94
(define (count-records db search)+95
(let ((rows (if search+96
(let ((pattern (string-append "%" search "%")))+97
(sqlite-query db+98
"SELECT COUNT(*) AS cnt FROM records WHERE title LIKE ? OR artist LIKE ?"+99
pattern pattern))+100
(sqlite-query db "SELECT COUNT(*) AS cnt FROM records"))))+101
(if (pair? rows)+102
(assoc-ref 'cnt (car rows))+103
0)))+104
+105
;;; Get a single record by id.+106
(define (get-record db id)+107
(sqlite-query-row db "SELECT * FROM records WHERE id = ?" id))+108
+109
;;; Create a new record from form params alist. Returns the new id.+110
(define (create-record db params)+111
(sqlite-run db+112
"INSERT INTO records (title, artist, year, genre, format, notes) VALUES (?, ?, ?, ?, ?, ?)"+113
(assoc-ref 'title params)+114
(assoc-ref 'artist params)+115
(let ((y (assoc-ref 'year params)))+116
(if (and y (not (string=? y "")))+117
(string->number y)+118
#f))+119
(assoc-ref 'genre params)+120
(or (assoc-ref 'format params) "LP")+121
(assoc-ref 'notes params))+122
(sqlite-last-insert-rowid db))+123
+124
;;; Update a record by id from form params alist.+125
(define (update-record db id params)+126
(sqlite-run db+127
"UPDATE records SET title = ?, artist = ?, year = ?, genre = ?, format = ?, notes = ? WHERE id = ?"+128
(assoc-ref 'title params)+129
(assoc-ref 'artist params)+130
(let ((y (assoc-ref 'year params)))+131
(if (and y (not (string=? y "")))+132
(string->number y)+133
#f))+134
(assoc-ref 'genre params)+135
(or (assoc-ref 'format params) "LP")+136
(assoc-ref 'notes params)+137
id))+138
+139
;;; Delete a record by id.+140
(define (delete-record db id)+141
(sqlite-run db "DELETE FROM records WHERE id = ?" id))+142
+143
))src/sigil/web/demo/views.sgladded
@@ -0,0 +1,548 @@
+1
;;; (sigil web demo views) - HTML layout, pages, and partials+2
;;;+3
;;; All view functions return SXML. Full pages are wrapped in+4
;;; `page-layout`; partials are bare fragments for morph responses.+5
+6
(define-library (sigil web demo views)+7
(import (sigil core)+8
(sigil string)+9
(sigil sxml)+10
(sigil css)+11
(sigil web ui))+12
+13
(export+14
page-layout+15
home-page+16
records-list-page+17
record-detail-page+18
record-form-page+19
records-table-partial+20
stats-partial+21
record-form)+22
+23
(begin+24
+25
;; ============================================================+26
;; Genre and Format Options+27
;; ============================================================+28
+29
(define genre-options+30
'(("" . "Select genre...")+31
("Jazz" . "Jazz")+32
("Rock" . "Rock")+33
("Pop/Funk" . "Pop/Funk")+34
("Hip-Hop" . "Hip-Hop")+35
("Electronic" . "Electronic")+36
("Soul" . "Soul")+37
("Alternative" . "Alternative")+38
("Classical" . "Classical")+39
("Blues" . "Blues")+40
("R&B" . "R&B")+41
("Country" . "Country")+42
("Folk" . "Folk")+43
("Metal" . "Metal")+44
("Punk" . "Punk")+45
("Reggae" . "Reggae")))+46
+47
(define format-options+48
'(("LP" . "LP")+49
("7\"" . "7\"")+50
("12\"" . "12\"")+51
("10\"" . "10\"")+52
("CD" . "CD")))+53
+54
;; ============================================================+55
;; Styles+56
;; ============================================================+57
+58
(define app-styles+59
(css->string+60
;; Base & Layout+61
(css "*"+62
(margin "0")+63
(padding "0")+64
(box-sizing "border-box"))+65
(css "body"+66
(font-family "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif")+67
(line-height "1.6")+68
(color "#1a1a1a")+69
(background "#fafafa"))+70
(css ".container"+71
(max-width "900px")+72
(margin "0 auto")+73
(padding "2rem 1rem"))+74
(css "h1" (margin-bottom "1rem"))+75
(css "h2" (margin "1.5rem 0 0.75rem"))+76
(css "p" (margin-bottom "0.75rem"))+77
(css "a" (color "#2563eb") (text-decoration "none"))+78
(css "a:hover" (text-decoration "underline"))+79
+80
;; Navigation+81
(css ".nav-bar"+82
(background "#1e293b")+83
(color "#f8fafc")+84
(padding "0 1rem"))+85
(css ".nav-content"+86
(max-width "900px")+87
(margin "0 auto")+88
(display "flex")+89
(align-items "center")+90
(justify-content "space-between")+91
(height "3.5rem"))+92
(css ".nav-brand"+93
(color "#f8fafc")+94
(font-weight "700")+95
(font-size "1.25rem"))+96
(css ".nav-brand:hover"+97
(color "#e2e8f0")+98
(text-decoration "none"))+99
(css ".nav-links"+100
(list-style "none")+101
(display "flex")+102
(gap "1.5rem"))+103
(css ".nav-links a"+104
(color "#cbd5e1")+105
(font-size "0.9rem"))+106
(css ".nav-links a:hover"+107
(color "#f8fafc")+108
(text-decoration "none"))+109
+110
;; Flash Messages+111
(css "#sg-flash-container"+112
(max-width "900px")+113
(margin "0 auto")+114
(padding "0 1rem"))+115
(css ".sg-flash"+116
(padding "0.75rem 1rem")+117
(margin "0.5rem 0")+118
(border-left "4px solid #94a3b8")+119
(border-radius "0 4px 4px 0")+120
(background "#f1f5f9"))+121
(css ".sg-flash-success"+122
(border-left-color "#22c55e")+123
(background "#f0fdf4")+124
(color "#166534"))+125
(css ".sg-flash-error"+126
(border-left-color "#ef4444")+127
(background "#fef2f2")+128
(color "#991b1b"))+129
(css ".sg-flash-warning"+130
(border-left-color "#f59e0b")+131
(background "#fffbeb")+132
(color "#92400e"))+133
(css ".sg-flash-info"+134
(border-left-color "#3b82f6")+135
(background "#eff6ff")+136
(color "#1e40af"))+137
+138
;; Loading Indicator (spinning vinyl record)+139
(css ".sg-loading"+140
(display "none")+141
(width "2rem")+142
(height "2rem")+143
(border-radius "50%")+144
(background "radial-gradient(circle, #444 18%, #111 20%, #222 45%, #111 47%, #333 49%, #111 51%, #222 80%, #111 100%)")+145
(box-shadow "0 0 0 2px #333")+146
(animation "sg-spin 1s linear infinite")+147
(vertical-align "middle")+148
(margin-left "0.5rem"))+149
(css ".sg-loading.active"+150
(display "inline-block"))+151
(css-keyframes "sg-spin"+152
(to (transform "rotate(360deg)")))+153
(css ".sg-action-loading"+154
(opacity "0.5")+155
(pointer-events "none")+156
(transition "opacity 0.15s ease"))+157
+158
;; Modal Dialogs+159
(css "dialog"+160
(border "none")+161
(border-radius "8px")+162
(padding "1.5rem")+163
(max-width "480px")+164
(width "90%")+165
(margin "auto")+166
(position "fixed")+167
(top "50%")+168
(left "50%")+169
(transform "translate(-50%, -50%)")+170
(box-shadow "0 20px 60px rgba(0, 0, 0, 0.3)"))+171
(css-raw "dialog::backdrop { background: rgba(0, 0, 0, 0.5); }")+172
(css "dialog h2"+173
(margin "0 0 1rem"))+174
(css ".modal-actions"+175
(display "flex")+176
(gap "0.5rem")+177
(justify-content "flex-end")+178
(margin-top "1.5rem"))+179
+180
;; Tables+181
(css ".table-wrapper"+182
(width "100%")+183
(overflow-x "auto")+184
(-webkit-overflow-scrolling "touch"))+185
(css "table"+186
(width "100%")+187
(border-collapse "collapse")+188
(margin "1rem 0")+189
(background "#fff")+190
(border-radius "4px")+191
(overflow "hidden")+192
(box-shadow "0 1px 3px rgba(0, 0, 0, 0.1)"))+193
(css "thead th"+194
(background "#f8fafc")+195
(text-align "left")+196
(padding "0.75rem 1rem")+197
(font-weight "600")+198
(font-size "0.85rem")+199
(text-transform "uppercase")+200
(letter-spacing "0.05em")+201
(color "#64748b")+202
(border-bottom "2px solid #e2e8f0"))+203
(css "tbody td"+204
(padding "0.75rem 1rem")+205
(border-bottom "1px solid #f1f5f9"))+206
(css "tbody tr:hover"+207
(background "#f8fafc"))+208
(css "tbody tr:nth-child(even)"+209
(background "#fafbfc"))+210
(css "tbody tr:nth-child(even):hover"+211
(background "#f1f5f9"))+212
+213
;; Pagination+214
(css ".sg-paginator"+215
(margin "1.5rem 0"))+216
(css ".sg-paginator ul"+217
(list-style "none")+218
(display "flex")+219
(gap "0.25rem")+220
(flex-wrap "wrap")+221
(align-items "center"))+222
(css-raw ".sg-paginator li a, .sg-paginator li span { display: inline-block; padding: 0.4rem 0.75rem; border: 1px solid #e2e8f0; border-radius: 4px; font-size: 0.9rem; text-decoration: none; }")+223
(css ".sg-paginator li a:hover"+224
(background "#f1f5f9")+225
(text-decoration "none"))+226
(css ".sg-paginator .current"+227
(background "#2563eb")+228
(color "#fff")+229
(border-color "#2563eb")+230
(font-weight "600"))+231
(css ".sg-paginator .disabled"+232
(color "#94a3b8")+233
(cursor "default")+234
(border-color "#f1f5f9"))+235
+236
;; Buttons+237
(css-raw ".btn, button[type=\"submit\"] { display: inline-block; padding: 0.5rem 1.25rem; background: #2563eb; color: #fff; border: none; border-radius: 4px; font-size: 0.9rem; cursor: pointer; text-decoration: none; line-height: 1.5; }")+238
(css-raw ".btn:hover, button[type=\"submit\"]:hover { background: #1d4ed8; text-decoration: none; color: #fff; }")+239
(css ".btn-small"+240
(padding "0.25rem 0.75rem")+241
(font-size "0.8rem"))+242
(css ".btn-danger"+243
(background "#dc2626"))+244
(css ".btn-danger:hover"+245
(background "#b91c1c"))+246
+247
;; Forms+248
(css ".form-fields > div"+249
(margin-bottom "1rem"))+250
(css ".form-fields label"+251
(display "block")+252
(font-weight "600")+253
(margin-bottom "0.25rem")+254
(font-size "0.9rem")+255
(color "#374151"))+256
(css-raw ".form-fields input[type=\"text\"], .form-fields input[type=\"email\"], .form-fields input[type=\"password\"], .form-fields select, .form-fields textarea { display: block; width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 4px; font-size: 0.95rem; font-family: inherit; line-height: 1.5; background: #fff; }")+257
(css-raw ".form-fields input:focus, .form-fields select:focus, .form-fields textarea:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); }")+258
(css ".field-error"+259
(color "#dc2626")+260
(font-size "0.8rem")+261
(margin-top "0.25rem")+262
(display "block"))+263
(css-raw "button[type=\"submit\"] { margin-top: 1rem; }")+264
(css ".search-form"+265
(display "flex")+266
(gap "0.5rem")+267
(align-items "flex-end")+268
(margin-bottom "1rem"))+269
(css ".search-form > div"+270
(flex "1"))+271
(css ".search-form input"+272
(width "100%")+273
(padding "0.5rem 0.75rem")+274
(border "1px solid #d1d5db")+275
(border-radius "4px")+276
(font-size "0.95rem"))+277
+278
;; Record Detail+279
(css ".record-detail"+280
(display "grid")+281
(grid-template-columns "8rem 1fr")+282
(gap "0.5rem 1rem")+283
(margin "1.5rem 0")+284
(background "#fff")+285
(padding "1.5rem")+286
(border-radius "4px")+287
(box-shadow "0 1px 3px rgba(0, 0, 0, 0.1)"))+288
(css ".record-detail dt"+289
(font-weight "600")+290
(color "#64748b")+291
(font-size "0.85rem")+292
(text-transform "uppercase")+293
(letter-spacing "0.05em"))+294
(css ".record-detail dd"+295
(color "#1e293b"))+296
(css ".detail-actions"+297
(display "flex")+298
(gap "0.5rem")+299
(margin-top "1.5rem"))+300
+301
;; Stats Grid+302
(css ".stats-grid"+303
(display "grid")+304
(grid-template-columns "repeat(auto-fit, minmax(200px, 1fr))")+305
(gap "1rem")+306
(margin "1rem 0"))+307
(css ".stat-card"+308
(background "#fff")+309
(padding "1.25rem")+310
(border-radius "4px")+311
(box-shadow "0 1px 3px rgba(0, 0, 0, 0.1)")+312
(text-align "center"))+313
(css ".stat-number"+314
(font-size "1.5rem")+315
(font-weight "700")+316
(color "#2563eb")+317
(margin-bottom "0.25rem")+318
(overflow "hidden")+319
(text-overflow "ellipsis")+320
(white-space "nowrap"))+321
(css ".stat-label"+322
(font-size "0.8rem")+323
(text-transform "uppercase")+324
(letter-spacing "0.05em")+325
(color "#64748b"))+326
+327
;; Loading State Utilities+328
(css ".opacity-50"+329
(opacity "0.5")+330
(pointer-events "none"))+331
(css ".cursor-wait"+332
(cursor "wait"))))+333
+334
;; ============================================================+335
;; Layout+336
;; ============================================================+337
+338
;;; Full HTML page shell.+339
(define (page-layout title body-content)+340
`(html+341
(head+342
(meta (@ (charset "utf-8")))+343
(meta (@ (name "viewport")+344
(content "width=device-width, initial-scale=1")))+345
,@(sigil-web-ui-head)+346
(style ,app-styles)+347
(title ,(string-append title " - Vinyl Vault")))+348
(body+349
(nav (@ (class "nav-bar"))+350
(div (@ (class "nav-content"))+351
(a (@ (href "/") (class "nav-brand")) "Vinyl Vault")+352
(ul (@ (class "nav-links"))+353
(li (a (@ (href "/records")) "Collection"))+354
(li (a (@ (href "/records/new")) "Add Record")))))+355
(div (@ (id "sg-flash-container")))+356
(main (@ (class "container"))+357
,body-content))))+358
+359
;; ============================================================+360
;; Pages+361
;; ============================================================+362
+363
;;; Home page with welcome text and stats.+364
(define (home-page total genre-counts latest)+365
`(div+366
(h1 "Welcome to Vinyl Vault")+367
(p "Your personal vinyl record collection tracker. "+368
"Browse your collection, add new records, and keep "+369
"notes on your favorites.")+370
(h2 "Collection Stats")+371
,(stats-partial total genre-counts latest)+372
(p (a (@ (href "/records")) "Browse your collection"))))+373
+374
;;; Stats partial for SSE updates.+375
(define (stats-partial total genre-counts latest)+376
`(div (@ (id "live-stats") (class "stats-grid"))+377
(div (@ (class "stat-card"))+378
(div (@ (class "stat-number")) ,(number->string total))+379
(div (@ (class "stat-label")) "Total Records"))+380
(div (@ (class "stat-card"))+381
(div (@ (class "stat-number"))+382
,(number->string (length genre-counts)))+383
(div (@ (class "stat-label")) "Genres"))+384
(div (@ (class "stat-card"))+385
(div (@ (class "stat-number"))+386
,(if latest+387
(or (assoc-ref 'title latest) "—")+388
"—"))+389
(div (@ (class "stat-label")) "Latest Addition"))))+390
+391
;;; Records list page with search and data table.+392
(define (records-list-page rows page total-pages search)+393
`(div+394
(h1 "Record Collection")+395
,(sg-form+396
`(,(sg-text-field name: "q"+397
value: (or search "")+398
placeholder: "Search by title or artist...")+399
,(sg-submit-button label: "Search")+400
,@(if search+401
(list `(a (@ (href "/records") (class "btn")) "Clear"))+402
'())+403
,(sg-loading-indicator id: "search-spinner"))+404
action: "/records/search"+405
method: "get"+406
target: "#records-list"+407
loading: "opacity-50"+408
class: "search-form")+409
(div (@ (id "records-list"))+410
,(records-table-partial rows page total-pages search))))+411
+412
;;; Records table + paginator partial.+413
(define (records-table-partial rows page total-pages search)+414
`(div+415
(div (@ (class "table-wrapper"))+416
,(sg-data-table+417
columns: '((title "Title")+418
(artist "Artist")+419
(year "Year")+420
(genre "Genre")+421
(format "Format"))+422
rows: rows+423
row-actions: (list+424
(sg-table-action "View"+425
action: "/records/{id}"+426
method: "get"+427
class: "btn btn-small")+428
(sg-table-action "Edit"+429
action: "/records/{id}/edit"+430
method: "get"+431
class: "btn btn-small"))+432
empty-message: "No records found."))+433
,@(if (> total-pages 1)+434
(list+435
(sg-paginator current-page: page+436
total-pages: total-pages+437
base-url: "/records/search"+438
target: "#records-list"+439
params: (if search+440
(list (cons 'q search))+441
'())))+442
'())))+443
+444
;;; Record detail page.+445
(define (record-detail-page record)+446
(let ((id (number->string (assoc-ref 'id record)))+447
(title (or (assoc-ref 'title record) ""))+448
(artist (or (assoc-ref 'artist record) ""))+449
(year (assoc-ref 'year record))+450
(genre (or (assoc-ref 'genre record) ""))+451
(fmt (or (assoc-ref 'format record) ""))+452
(notes (or (assoc-ref 'notes record) ""))+453
(created (or (assoc-ref 'created_at record) "")))+454
`(div+455
(h1 ,title)+456
(dl (@ (class "record-detail"))+457
(dt "Artist") (dd ,artist)+458
(dt "Year") (dd ,(if year (number->string year) "—"))+459
(dt "Genre") (dd ,(if (string=? genre "") "—" genre))+460
(dt "Format") (dd ,fmt)+461
,@(if (and notes (not (string=? notes "")))+462
`((dt "Notes") (dd ,notes))+463
'())+464
(dt "Added") (dd ,created))+465
(div (@ (class "detail-actions"))+466
(a (@ (href ,(string-append "/records/" id "/edit"))+467
(class "btn")) "Edit")+468
,(sg-modal-trigger "Delete"+469
target: "#delete-modal"+470
class: "btn btn-danger"))+471
,(sg-modal+472
(list+473
`(p "Are you sure you want to delete "+474
(strong ,title) "? This cannot be undone.")+475
`(div (@ (class "modal-actions"))+476
,(sg-button "Delete"+477
action: (string-append "/records/" id)+478
method: "delete"+479
class: "btn btn-danger")+480
,(sg-modal-close "Cancel" class: "btn")))+481
id: "delete-modal"+482
title: "Delete Record"))))+483
+484
;;; Record form page (new or edit).+485
(define (record-form-page record errors)+486
(let ((editing? (and record (assoc-ref 'id record))))+487
`(div+488
(h1 ,(if editing? "Edit Record" "New Record"))+489
,(let ((action (if editing?+490
(string-append "/records/"+491
(number->string (assoc-ref 'id record)))+492
"/records")))+493
(sg-form+494
(list (record-form record errors)+495
(sg-submit-button label: (if editing? "Update Record" "Add Record")+496
loading: "opacity-50 cursor-wait"))+497
action: action+498
method: "post")))))+499
Showing the first 500 of 549 diff lines for this file. This diff is INCOMPLETE; read the file or clone the repository for the rest.