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.