vl/demos/webapp.html
| 1 | <!doctype html> |
| 2 | <html><head><meta charset="utf-8"><title>Acme Workspace</title> |
| 3 | <style> |
| 4 | body { font-family: "DejaVu Sans", Arial, sans-serif; margin: 0; background: #f4f6f9; color: #1d2733; } |
| 5 | .top { background: #24324a; color: #fff; padding: 12px 20px; display: flex; align-items: center; gap: 18px; font-size: 18px; } |
| 6 | .top .brand { font-weight: bold; margin-right: auto; } |
| 7 | .main { padding: 18px 24px; } |
| 8 | .btn { display: inline-block; padding: 9px 16px; margin: 4px 6px 4px 0; border-radius: 6px; border: 1px solid #c5ccd6; background: #fff; cursor: pointer; font-size: 15px; } |
| 9 | .top .btn { color: #1d2733; } |
| 10 | .btn.primary { background: #2f6fde; color: #fff; border-color: #2f6fde; } |
| 11 | .btn.danger { color: #b3261e; } |
| 12 | .row { background: #fff; border: 1px solid #dde2e8; border-radius: 6px; padding: 10px 14px; margin: 6px 0; cursor: pointer; display: flex; gap: 16px; font-size: 15px; } |
| 13 | .row b { width: 170px; } |
| 14 | .grid { display: grid; grid-template-columns: repeat(3, 230px); gap: 14px; } |
| 15 | .card { background: #fff; border: 1px solid #dde2e8; border-radius: 8px; padding: 12px; cursor: pointer; font-size: 15px; } |
| 16 | .card .img { height: 70px; border-radius: 6px; margin-bottom: 8px; } |
| 17 | .swatch { display: inline-block; width: 34px; height: 34px; border-radius: 50%; margin: 6px 10px 6px 0; border: 3px solid #fff; box-shadow: 0 0 0 1px #aaa; cursor: pointer; } |
| 18 | .swatch.sel { box-shadow: 0 0 0 3px #1d2733; } |
| 19 | .size { display: inline-block; padding: 8px 14px; border: 1px solid #aab; margin-right: 8px; border-radius: 4px; cursor: pointer; background: #fff; } |
| 20 | .size.sel { background: #1d2733; color: #fff; } |
| 21 | .side { width: 190px; float: left; } |
| 22 | .side .item { padding: 10px 12px; margin: 4px 0; border-radius: 6px; cursor: pointer; background: #fff; border: 1px solid #dde2e8; } |
| 23 | .side .item.sel { background: #dfe9fb; border-color: #2f6fde; } |
| 24 | .panel { margin-left: 215px; background: #fff; border: 1px solid #dde2e8; border-radius: 8px; padding: 16px 20px; min-height: 300px; } |
| 25 | .toggle { display: flex; align-items: center; justify-content: space-between; width: 470px; padding: 10px 0; border-bottom: 1px solid #eee; font-size: 15px; } |
| 26 | .sw { width: 52px; height: 28px; border-radius: 14px; background: #c9ced6; position: relative; cursor: pointer; } |
| 27 | .sw::after { content: ""; position: absolute; width: 22px; height: 22px; border-radius: 50%; background: #fff; top: 3px; left: 3px; } |
| 28 | .sw.on { background: #2e9d57; } .sw.on::after { left: 27px; } |
| 29 | .toast { margin-top: 14px; color: #2e9d57; font-weight: bold; } |
| 30 | h2 { margin: 6px 0 12px; font-size: 21px; } |
| 31 | </style></head> |
| 32 | <body><div id="app"></div> |
| 33 | <script> |
| 34 | // A tiny three-app workspace (mail, shop, settings) driven by window.S; every interactive element carries data-click. |
| 35 | const P = new URLSearchParams(location.search); |
| 36 | let seed = +(P.get("seed") || 1); |
| 37 | function rnd() { seed = (seed * 1103515245 + 12345) % 2147483648; return seed / 2147483648; } |
| 38 | function shuffle(a) { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } |
| 39 | const app = P.get("app"); |
| 40 | const S = window.S = { app, view: "home", archived: [], deleted: [], cart: [], ordered: false, sel: {}, section: null, saved: {}, toggles: {} }; |
| 41 | const NAMES = shuffle(["Alice Chen", "Bob Martin", "Carla Diaz", "David Kim", "Emma Rossi", "Farid Khan", "Grace Lee", "Hugo Weber"]).slice(0, 6); |
| 42 | const TOPICS = shuffle(["invoice for March", "team offsite", "contract renewal", "server outage", "lunch on Friday", "design review", "travel booking", "quarterly report"]).slice(0, 6); |
| 43 | const MAILS = NAMES.map((n, i) => ({ id: i, from: n, subject: TOPICS[i] })); |
| 44 | const PRODUCTS = shuffle(["T-shirt", "Hoodie", "Sneakers", "Backpack", "Cap", "Jacket"]).map((n, i) => ({ id: i, name: n, price: 19 + 7 * i })); |
| 45 | const COLORS = { red: "#d93a3a", blue: "#2f6fde", green: "#2e9d57", black: "#222222", yellow: "#e8c21b" }; |
| 46 | const SIZES = ["S", "M", "L", "XL"]; |
| 47 | const SECTIONS = { Profile: ["Show online status", "Public profile"], Security: ["Two-factor authentication", "Login alerts"], |
| 48 | Notifications: ["Email digests", "Push notifications"], Appearance: ["Dark mode", "Compact layout"] }; |
| 49 | const secOrder = shuffle(Object.keys(SECTIONS)); |
| 50 | for (const s in SECTIONS) for (const t of SECTIONS[s]) S.toggles[t] = false; |
| 51 | window.WORLD = { MAILS, PRODUCTS, COLORS: Object.keys(COLORS), SIZES, SECTIONS }; |
| 52 | function el(html) { document.getElementById("app").innerHTML = html; } |
| 53 | function click(k) { return `data-click="${k}"`; } |
| 54 | function header(title) { return `<div class="top"><span class="brand">${title}</span><span class="btn" ${click("nav:help")}>Help</span><span class="btn" ${click("nav:account")}>Account</span></div>`; } |
| 55 | function render() { |
| 56 | if (app === "mail") { |
| 57 | if (S.view === "home") { |
| 58 | const rows = MAILS.filter(m => !S.archived.includes(m.id) && !S.deleted.includes(m.id)) |
| 59 | .map(m => `<div class="row" ${click("open:" + m.id)}><b>${m.from}</b><span>${m.subject}</span></div>`).join(""); |
| 60 | el(header("Acme Mail") + `<div class="main"><h2>Inbox</h2><span class="btn primary" ${click("compose")}>Compose</span>${rows}</div>`); |
| 61 | } else { |
| 62 | const m = MAILS[S.open]; |
| 63 | el(header("Acme Mail") + `<div class="main"><span class="btn" ${click("back")}>← Back to inbox</span><h2>${m.subject}</h2> |
| 64 | <p>From: <b>${m.from}</b></p><p style="width:640px">Hi, please see the details about the ${m.subject} below. Let me know if you have questions.</p> |
| 65 | <span class="btn" ${click("reply")}>Reply</span><span class="btn" ${click("forward")}>Forward</span> |
| 66 | <span class="btn" ${click("archive")}>Archive</span><span class="btn danger" ${click("delete")}>Delete</span><span class="btn" ${click("unread")}>Mark unread</span></div>`); |
| 67 | } |
| 68 | } else if (app === "shop") { |
| 69 | const cartBtn = `<span class="btn" ${click("cart")}>Cart (${S.cart.length})</span>`; |
| 70 | if (S.view === "home") { |
| 71 | const cards = PRODUCTS.map(p => `<div class="card" ${click("product:" + p.id)}><div class="img" style="background:#e3e8ef"></div><b>${p.name}</b><br>$${p.price}</div>`).join(""); |
| 72 | el(header("Acme Shop") + `<div class="main">${cartBtn}<h2>New arrivals</h2><div class="grid">${cards}</div></div>`); |
| 73 | } else if (S.view === "product") { |
| 74 | const p = PRODUCTS[S.product]; |
| 75 | const sw = Object.entries(COLORS).map(([n, c]) => `<span class="swatch ${S.sel.color === n ? "sel" : ""}" style="background:${c}" ${click("color:" + n)}></span>`).join(""); |
| 76 | const sz = SIZES.map(s => `<span class="size ${S.sel.size === s ? "sel" : ""}" ${click("size:" + s)}>${s}</span>`).join(""); |
| 77 | el(header("Acme Shop") + `<div class="main"><span class="btn" ${click("back")}>← All products</span>${cartBtn}<h2>${p.name} — $${p.price}</h2> |
| 78 | <p>Colour:</p><div>${sw}</div><p>Size:</p><div>${sz}</div><br><span class="btn primary" ${click("add")}>Add to cart</span> |
| 79 | ${S.added ? '<div class="toast">Added to cart</div>' : ""}</div>`); |
| 80 | } else if (S.view === "cart") { |
| 81 | const items = S.cart.map(c => `<div class="row"><b>${c.name}</b><span>${c.color}, size ${c.size}</span></div>`).join("") || "<p>Your cart is empty.</p>"; |
| 82 | el(header("Acme Shop") + `<div class="main"><span class="btn" ${click("back")}>← Continue shopping</span><h2>Your cart</h2>${items} |
| 83 | <span class="btn primary" ${click("checkout")}>Checkout</span><span class="btn danger" ${click("empty")}>Empty cart</span> |
| 84 | ${S.ordered ? '<div class="toast">Order placed. Thank you!</div>' : ""}</div>`); |
| 85 | } |
| 86 | } else if (app === "settings") { |
| 87 | const side = secOrder.map(s => `<div class="item ${S.section === s ? "sel" : ""}" ${click("section:" + s)}>${s}</div>`).join(""); |
| 88 | let panel = "<p>Select a section on the left.</p>"; |
| 89 | if (S.section) { |
| 90 | panel = `<h2>${S.section}</h2>` + SECTIONS[S.section].map(t => `<div class="toggle"><span>${t}</span><span class="sw ${S.toggles[t] ? "on" : ""}" ${click("toggle:" + t)}></span></div>`).join("") |
| 91 | + `<br><span class="btn primary" ${click("save")}>Save changes</span><span class="btn" ${click("cancel")}>Cancel</span>` |
| 92 | + (S.justSaved ? '<div class="toast">Settings saved</div>' : ""); |
| 93 | } |
| 94 | el(header("Acme Settings") + `<div class="main"><div class="side">${side}</div><div class="panel">${panel}</div></div>`); |
| 95 | } |
| 96 | } |
| 97 | function act(k) { |
| 98 | const [a, v] = k.split(/:(.*)/s); |
| 99 | S.justSaved = false; S.added = false; |
| 100 | if (a === "open") { S.view = "mail"; S.open = +v; } |
| 101 | else if (a === "back") { S.view = "home"; } |
| 102 | else if (a === "archive") { S.archived.push(S.open); S.view = "home"; } |
| 103 | else if (a === "delete") { S.deleted.push(S.open); S.view = "home"; } |
| 104 | else if (a === "product") { S.view = "product"; S.product = +v; S.sel = {}; } |
| 105 | else if (a === "color") { S.sel.color = v; } |
| 106 | else if (a === "size") { S.sel.size = v; } |
| 107 | else if (a === "add") { if (S.sel.color && S.sel.size) { S.cart.push({ name: PRODUCTS[S.product].name, color: S.sel.color, size: S.sel.size }); S.added = true; } } |
| 108 | else if (a === "cart") { S.view = "cart"; } |
| 109 | else if (a === "checkout") { if (S.cart.length) S.ordered = true; } |
| 110 | else if (a === "empty") { S.cart = []; S.ordered = false; } |
| 111 | else if (a === "section") { S.section = v; } |
| 112 | else if (a === "toggle") { S.toggles[v] = !S.toggles[v]; } |
| 113 | else if (a === "save") { S.saved = Object.assign({}, S.toggles); S.justSaved = true; } |
| 114 | else if (a === "cancel") { for (const t in S.toggles) S.toggles[t] = !!S.saved[t]; } |
| 115 | render(); |
| 116 | } |
| 117 | document.addEventListener("click", e => { const t = e.target.closest("[data-click]"); if (t) act(t.getAttribute("data-click")); }); |
| 118 | render(); |
| 119 | </script></body></html> |
| 120 | |