jimmu 1.0.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.
Files changed (94) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +279 -0
  4. data/examples/bbs/app.rb +21 -0
  5. data/examples/bbs/public/css/style.css +15 -0
  6. data/examples/bbs/views/404.erb +1 -0
  7. data/examples/bbs/views/500.erb +1 -0
  8. data/examples/bbs/views/index.erb +29 -0
  9. data/examples/bbs/views/layout.erb +15 -0
  10. data/examples/blog/app.rb +28 -0
  11. data/examples/blog/public/css/style.css +18 -0
  12. data/examples/blog/views/404.erb +1 -0
  13. data/examples/blog/views/500.erb +1 -0
  14. data/examples/blog/views/index.erb +9 -0
  15. data/examples/blog/views/layout.erb +15 -0
  16. data/examples/blog/views/new.erb +18 -0
  17. data/examples/blog/views/post/[id].erb +17 -0
  18. data/examples/chatroom/app.rb +44 -0
  19. data/examples/chatroom/public/css/style.css +15 -0
  20. data/examples/chatroom/views/404.erb +1 -0
  21. data/examples/chatroom/views/500.erb +1 -0
  22. data/examples/chatroom/views/index.erb +18 -0
  23. data/examples/chatroom/views/layout.erb +12 -0
  24. data/examples/chatroom/views/room/[name].erb +47 -0
  25. data/examples/counter/app.rb +15 -0
  26. data/examples/counter/public/css/style.css +8 -0
  27. data/examples/counter/public/js/app.js +11 -0
  28. data/examples/counter/views/404.erb +1 -0
  29. data/examples/counter/views/500.erb +1 -0
  30. data/examples/counter/views/api/increment.erb +5 -0
  31. data/examples/counter/views/api/reset.erb +4 -0
  32. data/examples/counter/views/index.erb +8 -0
  33. data/examples/counter/views/layout.erb +13 -0
  34. data/examples/hello-world/app.rb +6 -0
  35. data/examples/hello-world/public/css/style.css +3 -0
  36. data/examples/hello-world/views/404.erb +2 -0
  37. data/examples/hello-world/views/500.erb +1 -0
  38. data/examples/hello-world/views/greet/[name].erb +2 -0
  39. data/examples/hello-world/views/index.erb +3 -0
  40. data/examples/hello-world/views/layout.erb +12 -0
  41. data/examples/image-upload/app.rb +22 -0
  42. data/examples/image-upload/public/css/style.css +11 -0
  43. data/examples/image-upload/views/404.erb +1 -0
  44. data/examples/image-upload/views/500.erb +1 -0
  45. data/examples/image-upload/views/index.erb +31 -0
  46. data/examples/image-upload/views/layout.erb +15 -0
  47. data/examples/json-api/app.rb +27 -0
  48. data/examples/json-api/views/404.erb +2 -0
  49. data/examples/json-api/views/500.erb +2 -0
  50. data/examples/json-api/views/api/notes/[id].erb +33 -0
  51. data/examples/json-api/views/api/notes.erb +20 -0
  52. data/examples/json-api/views/index.erb +13 -0
  53. data/examples/login-demo/app.rb +31 -0
  54. data/examples/login-demo/public/css/style.css +12 -0
  55. data/examples/login-demo/views/404.erb +1 -0
  56. data/examples/login-demo/views/500.erb +1 -0
  57. data/examples/login-demo/views/index.erb +6 -0
  58. data/examples/login-demo/views/layout.erb +24 -0
  59. data/examples/login-demo/views/login.erb +22 -0
  60. data/examples/login-demo/views/logout.erb +4 -0
  61. data/examples/login-demo/views/mypage/index.erb +3 -0
  62. data/examples/login-demo/views/signup.erb +24 -0
  63. data/examples/poll/app.rb +55 -0
  64. data/examples/poll/public/css/style.css +10 -0
  65. data/examples/poll/public/js/app.js +24 -0
  66. data/examples/poll/views/404.erb +1 -0
  67. data/examples/poll/views/500.erb +1 -0
  68. data/examples/poll/views/index.erb +6 -0
  69. data/examples/poll/views/layout.erb +13 -0
  70. data/examples/todo/app.rb +21 -0
  71. data/examples/todo/public/css/style.css +17 -0
  72. data/examples/todo/views/404.erb +1 -0
  73. data/examples/todo/views/500.erb +1 -0
  74. data/examples/todo/views/delete/[id].erb +4 -0
  75. data/examples/todo/views/index.erb +27 -0
  76. data/examples/todo/views/layout.erb +15 -0
  77. data/examples/todo/views/toggle/[id].erb +5 -0
  78. data/examples/url-shortener/app.rb +39 -0
  79. data/examples/url-shortener/public/css/style.css +13 -0
  80. data/examples/url-shortener/views/404.erb +1 -0
  81. data/examples/url-shortener/views/500.erb +1 -0
  82. data/examples/url-shortener/views/[code].erb +10 -0
  83. data/examples/url-shortener/views/api/shorten.erb +13 -0
  84. data/examples/url-shortener/views/index.erb +35 -0
  85. data/examples/url-shortener/views/layout.erb +15 -0
  86. data/examples/visitor-counter/app.rb +22 -0
  87. data/examples/visitor-counter/public/css/style.css +4 -0
  88. data/examples/visitor-counter/views/404.erb +1 -0
  89. data/examples/visitor-counter/views/500.erb +1 -0
  90. data/examples/visitor-counter/views/index.erb +11 -0
  91. data/examples/visitor-counter/views/layout.erb +12 -0
  92. data/exe/jimmu +5 -0
  93. data/lib/jimmu.rb +1916 -0
  94. metadata +143 -0
@@ -0,0 +1,47 @@
1
+ <div class="wrap chat">
2
+ <h1>#<%= CGI.escapeHTML(params[:name]) %> <span id="online" class="badge"></span></h1>
3
+ <div id="log" class="log"></div>
4
+ <form id="send-form">
5
+ <input type="text" id="msg" placeholder="Message" autocomplete="off" required>
6
+ <button type="submit">Send</button>
7
+ </form>
8
+ <p><a href="/">&larr; leave</a></p>
9
+ </div>
10
+ <script>
11
+ var room = <%= params[:name].to_json %>;
12
+ var params = new URLSearchParams(window.location.search);
13
+ var name = params.get('name') || ('Guest' + Math.floor(Math.random() * 1000));
14
+ var proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
15
+ var ws = new WebSocket(proto + '//' + window.location.host + '/ws/' + encodeURIComponent(room) + '?name=' + encodeURIComponent(name));
16
+ var log = document.getElementById('log');
17
+ var online = document.getElementById('online');
18
+
19
+ function addLine(cls, html) {
20
+ var div = document.createElement('div');
21
+ div.className = 'line ' + cls;
22
+ div.innerHTML = html;
23
+ log.appendChild(div);
24
+ log.scrollTop = log.scrollHeight;
25
+ }
26
+
27
+ ws.onmessage = function (event) {
28
+ var data = JSON.parse(event.data);
29
+ if (data.type === 'system') {
30
+ addLine('system', data.text);
31
+ if (typeof data.count === 'number') online.textContent = data.count + ' online';
32
+ } else {
33
+ var safe = document.createElement('div');
34
+ safe.textContent = data.text;
35
+ addLine('message', '<strong>' + data.author + ':</strong> ' + safe.innerHTML);
36
+ }
37
+ };
38
+ ws.onclose = function () { addLine('system', 'Disconnected.'); };
39
+
40
+ document.getElementById('send-form').addEventListener('submit', function (e) {
41
+ e.preventDefault();
42
+ var input = document.getElementById('msg');
43
+ if (!input.value.trim()) return;
44
+ ws.send(input.value);
45
+ input.value = '';
46
+ });
47
+ </script>
@@ -0,0 +1,15 @@
1
+ # Click counter.
2
+ # Demonstrates: a tiny JSON API called from client-side JS (fetch),
3
+ # and SQLite used to persist a single value across server restarts.
4
+
5
+ port 3000
6
+
7
+ db = sqlite "db/counter.db"
8
+ db.exec "CREATE TABLE IF NOT EXISTS counters (name TEXT PRIMARY KEY, value INTEGER NOT NULL)"
9
+ db.exec "INSERT OR IGNORE INTO counters(name, value) VALUES ('clicks', 0)"
10
+
11
+ before do
12
+ log "#{request.method} #{request.path}"
13
+ end
14
+
15
+ run
@@ -0,0 +1,8 @@
1
+ :root{color-scheme:light dark}
2
+ body{font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#111;color:#fff}
3
+ .wrap{text-align:center}
4
+ .count{font-size:96px;font-weight:800;font-variant-numeric:tabular-nums;margin:10px 0}
5
+ button{font-size:16px;padding:12px 28px;border-radius:8px;border:0;background:#4a7dff;color:#fff;cursor:pointer;margin:4px;font-weight:600}
6
+ button.reset{background:#333}
7
+ .hint{max-width:420px;color:#888;font-size:13px;margin-top:24px}
8
+ code{background:#222;padding:2px 6px;border-radius:4px}
@@ -0,0 +1,11 @@
1
+ var countEl = document.getElementById('count');
2
+ document.getElementById('btn').addEventListener('click', function () {
3
+ fetch('/api/increment', { method: 'POST' })
4
+ .then(function (r) { return r.json(); })
5
+ .then(function (data) { countEl.textContent = data.value; });
6
+ });
7
+ document.getElementById('reset').addEventListener('click', function () {
8
+ fetch('/api/reset', { method: 'POST' })
9
+ .then(function (r) { return r.json(); })
10
+ .then(function (data) { countEl.textContent = data.value; });
11
+ });
@@ -0,0 +1 @@
1
+ <h1>404</h1>
@@ -0,0 +1 @@
1
+ <h1>500</h1>
@@ -0,0 +1,5 @@
1
+ <%
2
+ db.exec("UPDATE counters SET value = value + 1 WHERE name = 'clicks'")
3
+ value = db.query("SELECT value FROM counters WHERE name = 'clicks'").first[:value]
4
+ json(value: value)
5
+ %>
@@ -0,0 +1,4 @@
1
+ <%
2
+ db.exec("UPDATE counters SET value = 0 WHERE name = 'clicks'")
3
+ json(value: 0)
4
+ %>
@@ -0,0 +1,8 @@
1
+ <% count = db.query("SELECT value FROM counters WHERE name = 'clicks'").first[:value] %>
2
+ <div class="wrap">
3
+ <h1>Click Counter</h1>
4
+ <div class="count" id="count"><%= count %></div>
5
+ <button id="btn">Click me</button>
6
+ <button id="reset" class="reset">Reset</button>
7
+ <p class="hint">The count is stored in SQLite, so it survives a server restart. The button calls a small JSON API (<code>POST /api/increment</code>) via <code>fetch()</code>.</p>
8
+ </div>
@@ -0,0 +1,13 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Jimmu Counter</title>
7
+ <link rel="stylesheet" href="/css/style.css">
8
+ </head>
9
+ <body>
10
+ <%= yield %>
11
+ <script src="/js/app.js"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,6 @@
1
+ # The smallest possible Jimmu app.
2
+ # Run with: jimmu server (from inside this directory)
3
+
4
+ port 3000
5
+
6
+ run
@@ -0,0 +1,3 @@
1
+ body{font-family:-apple-system,sans-serif;max-width:640px;margin:60px auto;padding:0 20px;line-height:1.6;color:#222}
2
+ code{background:#eee;padding:2px 6px;border-radius:4px}
3
+ a{color:#3b6fe0}
@@ -0,0 +1,2 @@
1
+ <h1>404 - Not Found</h1>
2
+ <p><a href="/">Go home</a></p>
@@ -0,0 +1 @@
1
+ <h1>500 - Server Error</h1>
@@ -0,0 +1,2 @@
1
+ <h1>Hello, <%= params[:name] %>!</h1>
2
+ <p><a href="/">&larr; back</a></p>
@@ -0,0 +1,3 @@
1
+ <h1>Hello, Jimmu!</h1>
2
+ <p>This page is <code>views/index.erb</code>. Try editing it -- changes appear on refresh, no restart needed.</p>
3
+ <p>Try a dynamic route: <a href="/greet/world">/greet/world</a> or <a href="/greet/<%= CGI.escape('大和') %>">/greet/大和</a></p>
@@ -0,0 +1,12 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Hello, Jimmu</title>
7
+ <link rel="stylesheet" href="/css/style.css">
8
+ </head>
9
+ <body>
10
+ <%= yield %>
11
+ </body>
12
+ </html>
@@ -0,0 +1,22 @@
1
+ # Image upload gallery.
2
+ # Demonstrates: multipart/form-data file uploads (params[:image]),
3
+ # saving uploaded files under public/ so they're served as static
4
+ # files, and SQLite for the gallery listing.
5
+
6
+ port 3000
7
+
8
+ db = sqlite "db/gallery.db"
9
+ db.exec <<~SQL
10
+ CREATE TABLE IF NOT EXISTS images (
11
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
12
+ filename TEXT NOT NULL,
13
+ original_name TEXT NOT NULL,
14
+ uploaded_at TEXT NOT NULL
15
+ )
16
+ SQL
17
+
18
+ before do
19
+ log "#{request.method} #{request.path}"
20
+ end
21
+
22
+ run
@@ -0,0 +1,11 @@
1
+ :root{color-scheme:light dark}
2
+ *{box-sizing:border-box}
3
+ body{font-family:-apple-system,sans-serif;background:#fafafa;color:#1a1a1a;margin:0}
4
+ .wrap{max-width:760px;margin:0 auto;padding:40px 20px}
5
+ .upload-form{display:flex;gap:10px;align-items:center;margin-bottom:28px;background:#fff;padding:16px;border-radius:10px;box-shadow:0 1px 3px rgba(0,0,0,.08)}
6
+ .upload-form button{padding:10px 20px;border-radius:6px;border:0;background:#3b6fe0;color:#fff;font-weight:600;cursor:pointer}
7
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:14px}
8
+ .grid figure{margin:0;background:#fff;border-radius:10px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08)}
9
+ .grid img{width:100%;height:140px;object-fit:cover;display:block}
10
+ .grid figcaption{font-size:12px;color:#777;padding:8px 10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
11
+ .empty{color:#999;text-align:center;padding:40px 0}
@@ -0,0 +1 @@
1
+ <h1>404</h1>
@@ -0,0 +1 @@
1
+ <h1>500</h1>
@@ -0,0 +1,31 @@
1
+ <%
2
+ if request.method == "POST"
3
+ file = params[:image]
4
+ if file && !file.empty? && file.content_type.to_s.start_with?("image/")
5
+ ext = File.extname(file.filename)
6
+ ext = ".bin" if ext.empty?
7
+ stored_name = "#{Time.now.strftime('%Y%m%d%H%M%S')}-#{SecureRandom.hex(4)}#{ext}"
8
+ file.save(File.join("public/uploads", stored_name))
9
+ db.exec("INSERT INTO images(filename, original_name, uploaded_at) VALUES (?, ?, ?)",
10
+ stored_name, file.filename, Time.now.strftime("%Y-%m-%d %H:%M"))
11
+ else
12
+ status 422
13
+ end
14
+ redirect "/"
15
+ end
16
+ images = db.query("SELECT * FROM images ORDER BY id DESC")
17
+ %>
18
+ <form method="post" action="/" enctype="multipart/form-data" class="upload-form">
19
+ <input type="file" name="image" accept="image/*" required>
20
+ <button type="submit">Upload</button>
21
+ </form>
22
+
23
+ <div class="grid">
24
+ <% images.each do |img| %>
25
+ <figure>
26
+ <img src="/uploads/<%= img[:filename] %>" alt="<%= CGI.escapeHTML(img[:original_name]) %>">
27
+ <figcaption><%= CGI.escapeHTML(img[:original_name]) %></figcaption>
28
+ </figure>
29
+ <% end %>
30
+ </div>
31
+ <% if images.empty? %><p class="empty">No images yet -- upload one above.</p><% end %>
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Jimmu Gallery</title>
7
+ <link rel="stylesheet" href="/css/style.css">
8
+ </head>
9
+ <body>
10
+ <div class="wrap">
11
+ <h1>Image Gallery</h1>
12
+ <%= yield %>
13
+ </div>
14
+ </body>
15
+ </html>
@@ -0,0 +1,27 @@
1
+ # Headless JSON API (no HTML views except a tiny docs index).
2
+ # Demonstrates: building a REST-ish API purely with json()/text(),
3
+ # request.method for verb-based branching within one file-routed
4
+ # endpoint, and status codes.
5
+ #
6
+ # GET /api/notes -> list all notes
7
+ # POST /api/notes -> create a note (JSON body: {"text": "..."})
8
+ # GET /api/notes/[id] -> fetch one note
9
+ # PUT /api/notes/[id] -> update a note (JSON body: {"text": "..."})
10
+ # DELETE /api/notes/[id] -> delete a note
11
+
12
+ port 3000
13
+
14
+ db = sqlite "db/notes.db"
15
+ db.exec <<~SQL
16
+ CREATE TABLE IF NOT EXISTS notes (
17
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
18
+ text TEXT NOT NULL,
19
+ created_at TEXT NOT NULL
20
+ )
21
+ SQL
22
+
23
+ before do
24
+ log "#{request.method} #{request.path}"
25
+ end
26
+
27
+ run
@@ -0,0 +1,2 @@
1
+ <% layout false %>
2
+ <% json(error: "not found") %>
@@ -0,0 +1,2 @@
1
+ <% layout false %>
2
+ <% json(error: "internal server error") %>
@@ -0,0 +1,33 @@
1
+ <%
2
+ note = db.query("SELECT * FROM notes WHERE id = ?", params[:id]).first
3
+ case request.method
4
+ when "GET"
5
+ if note
6
+ json(note)
7
+ else
8
+ status 404
9
+ json(error: "not found")
10
+ end
11
+ when "PUT"
12
+ if note.nil?
13
+ status 404
14
+ json(error: "not found")
15
+ else
16
+ text = params[:text].to_s.strip
17
+ if text.empty?
18
+ status 422
19
+ json(error: "text is required")
20
+ else
21
+ db.exec("UPDATE notes SET text = ? WHERE id = ?", text, params[:id])
22
+ json(id: params[:id].to_i, text: text)
23
+ end
24
+ end
25
+ when "DELETE"
26
+ db.exec("DELETE FROM notes WHERE id = ?", params[:id])
27
+ status 204
28
+ text ""
29
+ else
30
+ status 405
31
+ json(error: "method not allowed")
32
+ end
33
+ %>
@@ -0,0 +1,20 @@
1
+ <%
2
+ case request.method
3
+ when "GET"
4
+ notes = db.query("SELECT * FROM notes ORDER BY id DESC")
5
+ json(notes: notes)
6
+ when "POST"
7
+ text = params[:text].to_s.strip
8
+ if text.empty?
9
+ status 422
10
+ json(error: "text is required")
11
+ else
12
+ db.exec("INSERT INTO notes(text, created_at) VALUES (?, ?)", text, Time.now.to_s)
13
+ status 201
14
+ json(id: db.last_insert_rowid, text: text)
15
+ end
16
+ else
17
+ status 405
18
+ json(error: "method not allowed")
19
+ end
20
+ %>
@@ -0,0 +1,13 @@
1
+ <% layout false %>
2
+ <!DOCTYPE html>
3
+ <html><head><meta charset="UTF-8"><title>Jimmu JSON API demo</title>
4
+ <style>body{font-family:-apple-system,sans-serif;max-width:640px;margin:60px auto;padding:0 20px;line-height:1.7}code,pre{background:#f2f2f2;padding:2px 6px;border-radius:4px}pre{padding:14px}</style>
5
+ </head><body>
6
+ <h1>Notes API</h1>
7
+ <p>This example has no real HTML pages -- it's a small JSON REST API. Try it with curl:</p>
8
+ <pre>curl <%= request.header('host') %>/api/notes
9
+ curl -X POST <%= request.header('host') %>/api/notes -d '{"text":"hello"}' -H 'Content-Type: application/json'
10
+ curl <%= request.header('host') %>/api/notes/1
11
+ curl -X PUT <%= request.header('host') %>/api/notes/1 -d '{"text":"updated"}' -H 'Content-Type: application/json'
12
+ curl -X DELETE <%= request.header('host') %>/api/notes/1</pre>
13
+ </body></html>
@@ -0,0 +1,31 @@
1
+ # Session-based login demo.
2
+ # Demonstrates: session used for authentication state, before-hook
3
+ # route protection, and hashing passwords with the Ruby stdlib
4
+ # (no external gem needed).
5
+
6
+ require "digest/sha2"
7
+
8
+ port 3000
9
+
10
+ db = sqlite "db/users.db"
11
+ db.exec <<~SQL
12
+ CREATE TABLE IF NOT EXISTS users (
13
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
14
+ username TEXT UNIQUE NOT NULL,
15
+ password_hash TEXT NOT NULL
16
+ )
17
+ SQL
18
+
19
+ def hash_password(password)
20
+ Digest::SHA256.hexdigest("jimmu-demo-salt:" + password)
21
+ end
22
+
23
+ before do
24
+ log "#{request.method} #{request.path}"
25
+ next unless request.path.start_with?("/mypage")
26
+ if session[:user_id].nil?
27
+ redirect "/login"
28
+ end
29
+ end
30
+
31
+ run
@@ -0,0 +1,12 @@
1
+ :root{color-scheme:light dark}
2
+ *{box-sizing:border-box}
3
+ body{font-family:-apple-system,sans-serif;background:#fafafa;color:#1a1a1a;margin:0}
4
+ .wrap{max-width:480px;margin:0 auto;padding:40px 20px}
5
+ nav{display:flex;gap:14px;align-items:center;margin-bottom:30px;font-size:14px;padding-bottom:14px;border-bottom:1px solid #ddd}
6
+ nav a{color:#3b6fe0;text-decoration:none}
7
+ .who{color:#888;margin-left:auto}
8
+ .auth-form{display:flex;flex-direction:column;gap:10px;margin-top:16px}
9
+ .auth-form input{padding:10px;border-radius:6px;border:1px solid #ccc;font:inherit}
10
+ .auth-form button{padding:10px;border-radius:6px;border:0;background:#222;color:#fff;font-weight:600;cursor:pointer}
11
+ .error{background:#fde8e8;color:#a33;padding:10px 14px;border-radius:6px;font-size:14px}
12
+ code{background:#eee;padding:2px 6px;border-radius:4px}
@@ -0,0 +1 @@
1
+ <h1>404</h1>
@@ -0,0 +1 @@
1
+ <h1>500</h1>
@@ -0,0 +1,6 @@
1
+ <h1>Jimmu session auth demo</h1>
2
+ <% if session[:user_id] %>
3
+ <p>You're signed in. Visit your <a href="/mypage">protected page</a>.</p>
4
+ <% else %>
5
+ <p><a href="/signup">Create an account</a> or <a href="/login">log in</a> to see a protected page.</p>
6
+ <% end %>
@@ -0,0 +1,24 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Jimmu Login Demo</title>
7
+ <link rel="stylesheet" href="/css/style.css">
8
+ </head>
9
+ <body>
10
+ <div class="wrap">
11
+ <nav>
12
+ <a href="/">Home</a>
13
+ <% if session[:user_id] %>
14
+ <span class="who">signed in as <%= session[:username] %></span>
15
+ <a href="/logout">Log out</a>
16
+ <% else %>
17
+ <a href="/login">Log in</a>
18
+ <a href="/signup">Sign up</a>
19
+ <% end %>
20
+ </nav>
21
+ <%= yield %>
22
+ </div>
23
+ </body>
24
+ </html>
@@ -0,0 +1,22 @@
1
+ <%
2
+ error = nil
3
+ if request.method == "POST"
4
+ username = params[:username].to_s.strip
5
+ password = params[:password].to_s
6
+ user = db.query("SELECT * FROM users WHERE username = ?", username).first
7
+ if user && user[:password_hash] == hash_password(password)
8
+ session[:user_id] = user[:id]
9
+ session[:username] = user[:username]
10
+ redirect "/mypage"
11
+ else
12
+ error = "Invalid username or password."
13
+ end
14
+ end
15
+ %>
16
+ <h1>Log in</h1>
17
+ <% if error %><p class="error"><%= CGI.escapeHTML(error) %></p><% end %>
18
+ <form method="post" action="/login" class="auth-form">
19
+ <input type="text" name="username" placeholder="Username" required value="<%= CGI.escapeHTML(params[:username].to_s) %>">
20
+ <input type="password" name="password" placeholder="Password" required>
21
+ <button type="submit">Log in</button>
22
+ </form>
@@ -0,0 +1,4 @@
1
+ <%
2
+ session.clear
3
+ redirect "/"
4
+ %>
@@ -0,0 +1,3 @@
1
+ <h1>Welcome, <%= CGI.escapeHTML(session[:username].to_s) %></h1>
2
+ <p>This page is only reachable when signed in -- the <code>before</code> hook in <code>app.rb</code> redirects to <code>/login</code> otherwise.</p>
3
+ <p>Your user id is <code><%= session[:user_id] %></code>.</p>
@@ -0,0 +1,24 @@
1
+ <%
2
+ error = nil
3
+ if request.method == "POST"
4
+ username = params[:username].to_s.strip
5
+ password = params[:password].to_s
6
+ if username.empty? || password.length < 4
7
+ error = "Username required, password must be at least 4 characters."
8
+ elsif db.query("SELECT 1 AS x FROM users WHERE username = ?", username).any?
9
+ error = "That username is taken."
10
+ else
11
+ db.exec("INSERT INTO users(username, password_hash) VALUES (?, ?)", username, hash_password(password))
12
+ session[:user_id] = db.last_insert_rowid
13
+ session[:username] = username
14
+ redirect "/mypage"
15
+ end
16
+ end
17
+ %>
18
+ <h1>Sign up</h1>
19
+ <% if error %><p class="error"><%= CGI.escapeHTML(error) %></p><% end %>
20
+ <form method="post" action="/signup" class="auth-form">
21
+ <input type="text" name="username" placeholder="Username" required value="<%= CGI.escapeHTML(params[:username].to_s) %>">
22
+ <input type="password" name="password" placeholder="Password (min 4 chars)" required>
23
+ <button type="submit">Create account</button>
24
+ </form>
@@ -0,0 +1,55 @@
1
+ # Live poll.
2
+ # Demonstrates: WebSocket used for live-updating UI (not just chat),
3
+ # SQLite for vote storage, and broadcasting results to every
4
+ # connected client whenever a new vote comes in.
5
+
6
+ port 3000
7
+
8
+ db = sqlite "db/poll.db"
9
+ db.exec <<~SQL
10
+ CREATE TABLE IF NOT EXISTS votes (
11
+ option_key TEXT PRIMARY KEY,
12
+ label TEXT NOT NULL,
13
+ count INTEGER NOT NULL DEFAULT 0
14
+ )
15
+ SQL
16
+ [["ruby", "Ruby"], ["python", "Python"], ["js", "JavaScript"], ["other", "Something else"]].each do |key, label|
17
+ db.exec("INSERT OR IGNORE INTO votes(option_key, label, count) VALUES (?, ?, 0)", key, label)
18
+ end
19
+
20
+ WATCHERS = []
21
+ WATCHERS_LOCK = Mutex.new
22
+
23
+ def results_json(db)
24
+ rows = db.query("SELECT option_key, label, count FROM votes ORDER BY rowid")
25
+ total = rows.sum { |r| r[:count] }
26
+ { total: total, options: rows.map { |r| { key: r[:option_key], label: r[:label], count: r[:count] } } }.to_json
27
+ end
28
+
29
+ def broadcast_results(db)
30
+ payload = results_json(db)
31
+ WATCHERS_LOCK.synchronize { WATCHERS.dup }.each { |w| w.send(payload) if w.open? }
32
+ end
33
+
34
+ before do
35
+ log "#{request.method} #{request.path}"
36
+ end
37
+
38
+ websocket "/ws/poll" do
39
+ WATCHERS_LOCK.synchronize { WATCHERS << self }
40
+ send(results_json(db))
41
+
42
+ on_message do |raw|
43
+ data = JSON.parse(raw) rescue nil
44
+ key = data && data["vote"]
45
+ next unless key
46
+ changed = db.exec("UPDATE votes SET count = count + 1 WHERE option_key = ?", key)
47
+ broadcast_results(db) if changed > 0
48
+ end
49
+
50
+ on_close do
51
+ WATCHERS_LOCK.synchronize { WATCHERS.delete(self) }
52
+ end
53
+ end
54
+
55
+ run
@@ -0,0 +1,10 @@
1
+ :root{color-scheme:light dark}
2
+ *{box-sizing:border-box}
3
+ body{font-family:-apple-system,sans-serif;background:#f6f7fb;color:#1a1a1a;margin:0}
4
+ .wrap{max-width:520px;margin:0 auto;padding:48px 20px}
5
+ .sub{color:#777;font-size:14px;margin-bottom:28px}
6
+ .option{margin-bottom:14px}
7
+ .option button{width:100%;text-align:left;padding:14px 16px;border-radius:8px;border:1px solid #ddd;background:#fff;cursor:pointer;font-size:15px;position:relative;overflow:hidden}
8
+ .option button .bar{position:absolute;left:0;top:0;bottom:0;background:#dbe6ff;z-index:0}
9
+ .option button .label{position:relative;z-index:1;display:flex;justify-content:space-between}
10
+ .total{text-align:center;color:#888;font-size:13px;margin-top:20px}
@@ -0,0 +1,24 @@
1
+ var proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
2
+ var ws = new WebSocket(proto + '//' + window.location.host + '/ws/poll');
3
+ var optionsEl = document.getElementById('options');
4
+ var totalEl = document.getElementById('total');
5
+
6
+ function render(data) {
7
+ optionsEl.innerHTML = '';
8
+ data.options.forEach(function (opt) {
9
+ var pct = data.total > 0 ? Math.round((opt.count / data.total) * 100) : 0;
10
+ var div = document.createElement('div');
11
+ div.className = 'option';
12
+ div.innerHTML = '<button data-key="' + opt.key + '"><span class="bar" style="width:' + pct + '%"></span>' +
13
+ '<span class="label"><span>' + opt.label + '</span><span>' + opt.count + ' (' + pct + '%)</span></span></button>';
14
+ optionsEl.appendChild(div);
15
+ });
16
+ totalEl.textContent = data.total + ' vote' + (data.total === 1 ? '' : 's') + ' total';
17
+ }
18
+
19
+ ws.onmessage = function (event) { render(JSON.parse(event.data)); };
20
+ optionsEl.addEventListener('click', function (e) {
21
+ var btn = e.target.closest('button');
22
+ if (!btn) return;
23
+ ws.send(JSON.stringify({ vote: btn.getAttribute('data-key') }));
24
+ });
@@ -0,0 +1 @@
1
+ <div class="wrap"><h1>404</h1></div>
@@ -0,0 +1 @@
1
+ <div class="wrap"><h1>500</h1></div>
@@ -0,0 +1,6 @@
1
+ <div class="wrap">
2
+ <h1>What's your favorite language?</h1>
3
+ <p class="sub">Results update live for everyone watching -- open this page in two tabs and vote in one.</p>
4
+ <div id="options"></div>
5
+ <p class="total" id="total"></p>
6
+ </div>
@@ -0,0 +1,13 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Jimmu Live Poll</title>
7
+ <link rel="stylesheet" href="/css/style.css">
8
+ </head>
9
+ <body>
10
+ <%= yield %>
11
+ <script src="/js/app.js"></script>
12
+ </body>
13
+ </html>