feat(01-10): SvelteKit frontend scaffold with Catppuccin theme and clients
- SvelteKit SPA with adapter-static, prerender, SSR disabled - Catppuccin Mocha/Latte theme CSS with semantic color tokens - WebSocket client with auto-reconnect and exponential backoff - HTTP API client with JWT auth and 401 handling - Auth state store with localStorage persistence (Svelte 5 runes) - Tournament state store handling all WS message types (Svelte 5 runes) - PIN login page with numpad, 48px touch targets - Updated Makefile frontend target for real SvelteKit build Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9ce05f6c67
commit
47e1f19edd
43 changed files with 3230 additions and 42 deletions
6
Makefile
6
Makefile
|
|
@ -18,11 +18,7 @@ test:
|
|||
CGO_ENABLED=1 go test ./...
|
||||
|
||||
frontend:
|
||||
@mkdir -p frontend/build
|
||||
@if [ ! -f frontend/build/index.html ]; then \
|
||||
echo '<!DOCTYPE html><html><head><title>Felt</title></head><body><h1>Felt</h1><p>Loading...</p></body></html>' > frontend/build/index.html; \
|
||||
fi
|
||||
@echo "Frontend build complete (stub)"
|
||||
cd frontend && npm install && npm run build
|
||||
|
||||
all: frontend build
|
||||
|
||||
|
|
|
|||
2
frontend/.gitignore
vendored
Normal file
2
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
.svelte-kit/
|
||||
1
frontend/build/_app/env.js
Normal file
1
frontend/build/_app/env.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const env={}
|
||||
1
frontend/build/_app/immutable/assets/0.LvVNMuLM.css
Normal file
1
frontend/build/_app/immutable/assets/0.LvVNMuLM.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
[data-theme=mocha],:root{--ctp-rosewater: #f5e0dc;--ctp-flamingo: #f2cdcd;--ctp-pink: #f5c2e7;--ctp-mauve: #cba6f7;--ctp-red: #f38ba8;--ctp-maroon: #eba0ac;--ctp-peach: #fab387;--ctp-yellow: #f9e2af;--ctp-green: #a6e3a1;--ctp-teal: #94e2d5;--ctp-sky: #89dceb;--ctp-sapphire: #74c7ec;--ctp-blue: #89b4fa;--ctp-lavender: #b4befe;--ctp-text: #cdd6f4;--ctp-subtext1: #bac2de;--ctp-subtext0: #a6adc8;--ctp-overlay2: #9399b2;--ctp-overlay1: #7f849c;--ctp-overlay0: #6c7086;--ctp-surface2: #585b70;--ctp-surface1: #45475a;--ctp-surface0: #313244;--ctp-base: #1e1e2e;--ctp-mantle: #181825;--ctp-crust: #11111b;--color-bg: var(--ctp-base);--color-bg-elevated: var(--ctp-mantle);--color-bg-sunken: var(--ctp-crust);--color-surface: var(--ctp-surface0);--color-surface-hover: var(--ctp-surface1);--color-surface-active: var(--ctp-surface2);--color-text: var(--ctp-text);--color-text-secondary: var(--ctp-subtext1);--color-text-muted: var(--ctp-subtext0);--color-primary: var(--ctp-blue);--color-success: var(--ctp-green);--color-warning: var(--ctp-yellow);--color-error: var(--ctp-red);--color-accent: var(--ctp-mauve);--color-border: var(--ctp-surface1);--color-overlay: var(--ctp-overlay0);--color-felt: var(--ctp-green);--color-card: var(--ctp-text);--color-bounty: var(--ctp-pink);--color-prize: var(--ctp-yellow);--color-chip: var(--ctp-peach);--color-clock: var(--ctp-sapphire);--color-break: var(--ctp-teal);--color-elimination: var(--ctp-red)}[data-theme=latte]{--ctp-rosewater: #dc8a78;--ctp-flamingo: #dd7878;--ctp-pink: #ea76cb;--ctp-mauve: #8839ef;--ctp-red: #d20f39;--ctp-maroon: #e64553;--ctp-peach: #fe640b;--ctp-yellow: #df8e1d;--ctp-green: #40a02b;--ctp-teal: #179299;--ctp-sky: #04a5e5;--ctp-sapphire: #209fb5;--ctp-blue: #1e66f5;--ctp-lavender: #7287fd;--ctp-text: #4c4f69;--ctp-subtext1: #5c5f77;--ctp-subtext0: #6c6f85;--ctp-overlay2: #7c7f93;--ctp-overlay1: #8c8fa1;--ctp-overlay0: #9ca0b0;--ctp-surface2: #acb0be;--ctp-surface1: #bcc0cc;--ctp-surface0: #ccd0da;--ctp-base: #eff1f5;--ctp-mantle: #e6e9ef;--ctp-crust: #dce0e8}:root{--font-body: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--font-mono: "JetBrains Mono", "Fira Code", ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, "DejaVu Sans Mono", monospace;--text-xs: .75rem;--text-sm: .875rem;--text-base: 1rem;--text-lg: 1.125rem;--text-xl: 1.25rem;--text-2xl: 1.5rem;--text-3xl: 1.875rem;--text-4xl: 2.25rem;--leading-tight: 1.25;--leading-normal: 1.5;--leading-relaxed: 1.75;--space-1: .25rem;--space-2: .5rem;--space-3: .75rem;--space-4: 1rem;--space-6: 1.5rem;--space-8: 2rem;--space-12: 3rem;--radius-sm: .25rem;--radius-md: .5rem;--radius-lg: .75rem;--radius-xl: 1rem;--radius-full: 9999px;--shadow-sm: 0 1px 2px rgba(0, 0, 0, .3);--shadow-md: 0 4px 6px rgba(0, 0, 0, .3);--shadow-lg: 0 10px 15px rgba(0, 0, 0, .3);--transition-fast: .1s ease;--transition-normal: .2s ease;--transition-slow: .3s ease;--touch-target: 48px}*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}html{font-family:var(--font-body);font-size:16px;line-height:var(--leading-normal);color:var(--color-text);background-color:var(--color-bg);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;scrollbar-gutter:stable}body{min-height:100dvh;color:var(--color-text);background-color:var(--color-bg)}button,a,input,select,textarea,[role=button],[role=tab],[role=menuitem]{touch-action:manipulation}.touch-target,button,[role=button],[role=tab]{min-height:var(--touch-target);min-width:var(--touch-target)}button:active,[role=button]:active,[role=tab]:active,.touch-target:active{transform:scale(.97);opacity:.9;transition:transform var(--transition-fast),opacity var(--transition-fast)}:focus-visible{outline:2px solid var(--color-primary);outline-offset:2px}:focus:not(:focus-visible){outline:none}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--color-bg)}::-webkit-scrollbar-thumb{background:var(--color-surface-active);border-radius:var(--radius-full)}::-webkit-scrollbar-thumb:hover{background:var(--color-overlay)}*{scrollbar-width:thin;scrollbar-color:var(--color-surface-active) var(--color-bg)}.font-mono{font-family:var(--font-mono)}.timer,.number,.blinds,.chips,.currency{font-family:var(--font-mono);font-variant-numeric:tabular-nums}.visually-hidden{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|
||||
1
frontend/build/_app/immutable/assets/2.lCQ6RyE1.css
Normal file
1
frontend/build/_app/immutable/assets/2.lCQ6RyE1.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
.container.svelte-1uha8ag{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100dvh;padding:1rem;gap:.5rem}h1.svelte-1uha8ag{font-size:2rem;font-weight:700;color:var(--color-primary)}.text-secondary.svelte-1uha8ag{color:var(--color-text-secondary)}
|
||||
1
frontend/build/_app/immutable/assets/3.C7Q2VT44.css
Normal file
1
frontend/build/_app/immutable/assets/3.C7Q2VT44.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
.login-container.svelte-1x05zx6{display:flex;align-items:center;justify-content:center;min-height:100dvh;padding:var(--space-4);background-color:var(--color-bg)}.login-card.svelte-1x05zx6{width:100%;max-width:360px;display:flex;flex-direction:column;align-items:center;gap:var(--space-6)}.logo.svelte-1x05zx6{text-align:center}.logo.svelte-1x05zx6 h1:where(.svelte-1x05zx6){font-size:var(--text-4xl);font-weight:700;color:var(--color-primary);letter-spacing:-.02em}.subtitle.svelte-1x05zx6{color:var(--color-text-secondary);font-size:var(--text-sm);margin-top:var(--space-1)}.pin-display.svelte-1x05zx6{display:flex;gap:var(--space-3);padding:var(--space-4) 0}.pin-dot.svelte-1x05zx6{width:16px;height:16px;border-radius:var(--radius-full);border:2px solid var(--color-surface-active);background-color:transparent;transition:background-color var(--transition-fast),border-color var(--transition-fast)}.pin-dot.filled.svelte-1x05zx6{background-color:var(--color-primary);border-color:var(--color-primary)}.error-message.svelte-1x05zx6{color:var(--color-error);font-size:var(--text-sm);text-align:center;padding:var(--space-2) var(--space-4);background-color:color-mix(in srgb,var(--color-error) 10%,transparent);border-radius:var(--radius-md);width:100%}.numpad.svelte-1x05zx6{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-3);width:100%}.numpad-btn.svelte-1x05zx6{display:flex;align-items:center;justify-content:center;height:64px;font-size:var(--text-2xl);font-weight:600;color:var(--color-text);background-color:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);cursor:pointer;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;transition:background-color var(--transition-fast)}.numpad-btn.svelte-1x05zx6:hover:not(:disabled){background-color:var(--color-surface-hover)}.numpad-btn.svelte-1x05zx6:disabled{opacity:.4;cursor:not-allowed;transform:none}.numpad-fn.svelte-1x05zx6{font-size:var(--text-sm);font-weight:500;color:var(--color-text-secondary)}.submit-btn.svelte-1x05zx6{width:100%;height:56px;font-size:var(--text-lg);font-weight:600;color:var(--color-bg);background-color:var(--color-primary);border:none;border-radius:var(--radius-lg);cursor:pointer;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;transition:background-color var(--transition-fast),opacity var(--transition-fast)}.submit-btn.svelte-1x05zx6:hover:not(:disabled){opacity:.9}.submit-btn.svelte-1x05zx6:disabled{opacity:.4;cursor:not-allowed;transform:none}
|
||||
1
frontend/build/_app/immutable/chunks/B6M6q2Zo.js
Normal file
1
frontend/build/_app/immutable/chunks/B6M6q2Zo.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
import{N as p,m as u,O as c,P as f,Q as E,T as g,R as w,h as d,j as s,S as N,c as y,U as M,f as x,V as A}from"./Ym0WvvUy.js";var l;const i=((l=globalThis==null?void 0:globalThis.window)==null?void 0:l.trustedTypes)&&globalThis.window.trustedTypes.createPolicy("svelte-trusted-html",{createHTML:t=>t});function L(t){return(i==null?void 0:i.createHTML(t))??t}function O(t){var r=p("template");return r.innerHTML=L(t.replaceAll("<!>","<!---->")),r.content}function n(t,r){var e=c;e.nodes===null&&(e.nodes={start:t,end:r,a:null,t:null})}function b(t,r){var e=(r&g)!==0,m=(r&w)!==0,a,v=!t.startsWith("<!>");return()=>{if(d)return n(s,null),s;a===void 0&&(a=O(v?t:"<!>"+t),e||(a=f(a)));var o=m||E?document.importNode(a,!0):a.cloneNode(!0);if(e){var T=f(o),h=o.lastChild;n(T,h)}else n(o,o);return o}}function C(t=""){if(!d){var r=u(t+"");return n(r,r),r}var e=s;return e.nodeType!==M?(e.before(e=u()),x(e)):A(e),n(e,e),e}function I(){if(d)return n(s,null),s;var t=document.createDocumentFragment(),r=document.createComment(""),e=u();return t.append(r,e),n(r,e),t}function S(t,r){if(d){var e=c;((e.f&N)===0||e.nodes.end===null)&&(e.nodes.end=s),y();return}t!==null&&t.before(r)}const P="5";var _;typeof window<"u"&&((_=window.__svelte??(window.__svelte={})).v??(_.v=new Set)).add(P);export{S as a,n as b,I as c,b as f,C as t};
|
||||
1
frontend/build/_app/immutable/chunks/B9dvBo0E.js
Normal file
1
frontend/build/_app/immutable/chunks/B9dvBo0E.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
import{b as T,h as o,c as b,E as v,r as p,H as A,d as E,e as R,f as g,i as l,j as m}from"./Ym0WvvUy.js";import{B as d}from"./Da6yQRl8.js";function H(_,u,c=!1){var i;o&&(i=m,b());var n=new d(_),h=c?v:0;function f(a,s){if(o){var r=p(i),e;if(r===A?e=0:r===E?e=!1:e=parseInt(r.substring(1)),a!==e){var t=R();g(t),n.anchor=t,l(!1),n.ensure(a,s),l(!0);return}}n.ensure(a,s)}T(()=>{var a=!1;u((s,r=0)=>{a=!0,f(r,s)}),a||f(!1,null)},h)}export{H as i};
|
||||
1
frontend/build/_app/immutable/chunks/Bfwrz3i4.js
Normal file
1
frontend/build/_app/immutable/chunks/Bfwrz3i4.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
import{D as o,B as t,M as c,F as u}from"./Ym0WvvUy.js";function l(n){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function r(n){t===null&&l(),c&&t.l!==null?a(t).m.push(n):o(()=>{const e=u(n);if(typeof e=="function")return e})}function a(n){var e=n.l;return e.u??(e.u={a:[],b:[],m:[]})}export{r as o};
|
||||
1
frontend/build/_app/immutable/chunks/DMqvp7vx.js
Normal file
1
frontend/build/_app/immutable/chunks/DMqvp7vx.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
var h=e=>{throw TypeError(e)};var m=(e,t,o)=>t.has(e)||h("Cannot "+o);var r=(e,t,o)=>(m(e,t,"read from private field"),o?o.call(e):t.get(e)),l=(e,t,o)=>t.has(e)?h("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,o);import{s as c,g,a as u}from"./Ym0WvvUy.js";const n="felt_token",i="felt_operator";var a,s;class p{constructor(){l(this,a,c(null));l(this,s,c(null));typeof window<"u"&&this.loadFromStorage()}get token(){return g(r(this,a))}set token(t){u(r(this,a),t,!0)}get operator(){return g(r(this,s))}set operator(t){u(r(this,s),t,!0)}get isAuthenticated(){return this.token!==null}get isAdmin(){var t;return((t=this.operator)==null?void 0:t.role)==="admin"}get isFloor(){var t;return["admin","floor"].includes(((t=this.operator)==null?void 0:t.role)??"")}login(t,o){this.token=t,this.operator=o,this.saveToStorage()}logout(){this.token=null,this.operator=null,this.clearStorage()}loadFromStorage(){try{const t=localStorage.getItem(n),o=localStorage.getItem(i);t&&o&&(this.token=t,this.operator=JSON.parse(o))}catch(t){console.warn("auth: failed to load from storage:",t),this.clearStorage()}}saveToStorage(){try{this.token&&this.operator&&(localStorage.setItem(n,this.token),localStorage.setItem(i,JSON.stringify(this.operator)))}catch(t){console.warn("auth: failed to save to storage:",t)}}clearStorage(){try{localStorage.removeItem(n),localStorage.removeItem(i)}catch(t){console.warn("auth: failed to clear storage:",t)}}}a=new WeakMap,s=new WeakMap;const f=new p;export{f as a};
|
||||
1
frontend/build/_app/immutable/chunks/Da6yQRl8.js
Normal file
1
frontend/build/_app/immutable/chunks/Da6yQRl8.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
var B=Object.defineProperty;var g=i=>{throw TypeError(i)};var D=(i,e,s)=>e in i?B(i,e,{enumerable:!0,configurable:!0,writable:!0,value:s}):i[e]=s;var w=(i,e,s)=>D(i,typeof e!="symbol"?e+"":e,s),y=(i,e,s)=>e.has(i)||g("Cannot "+s);var t=(i,e,s)=>(y(i,e,"read from private field"),s?s.call(i):e.get(i)),l=(i,e,s)=>e.has(i)?g("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(i):e.set(i,s),M=(i,e,s,a)=>(y(i,e,"write to private field"),a?a.call(i,s):e.set(i,s),s);import{k as F,l as b,p as j,m as x,n as A,o as q,h as C,j as S,q as z,t as E}from"./Ym0WvvUy.js";var h,n,r,u,p,_,v;class I{constructor(e,s=!0){w(this,"anchor");l(this,h,new Map);l(this,n,new Map);l(this,r,new Map);l(this,u,new Set);l(this,p,!0);l(this,_,e=>{if(t(this,h).has(e)){var s=t(this,h).get(e),a=t(this,n).get(s);if(a)F(a),t(this,u).delete(s);else{var c=t(this,r).get(s);c&&(t(this,n).set(s,c.effect),t(this,r).delete(s),c.fragment.lastChild.remove(),this.anchor.before(c.fragment),a=c.effect)}for(const[f,o]of t(this,h)){if(t(this,h).delete(f),f===e)break;const d=t(this,r).get(o);d&&(b(d.effect),t(this,r).delete(o))}for(const[f,o]of t(this,n)){if(f===s||t(this,u).has(f))continue;const d=()=>{if(Array.from(t(this,h).values()).includes(f)){var k=document.createDocumentFragment();z(o,k),k.append(x()),t(this,r).set(f,{effect:o,fragment:k})}else b(o);t(this,u).delete(f),t(this,n).delete(f)};t(this,p)||!a?(t(this,u).add(f),j(o,d,!1)):d()}}});l(this,v,e=>{t(this,h).delete(e);const s=Array.from(t(this,h).values());for(const[a,c]of t(this,r))s.includes(a)||(b(c.effect),t(this,r).delete(a))});this.anchor=e,M(this,p,s)}ensure(e,s){var a=q,c=E();if(s&&!t(this,n).has(e)&&!t(this,r).has(e))if(c){var f=document.createDocumentFragment(),o=x();f.append(o),t(this,r).set(e,{effect:A(()=>s(o)),fragment:f})}else t(this,n).set(e,A(()=>s(this.anchor)));if(t(this,h).set(a,e),c){for(const[d,m]of t(this,n))d===e?a.unskip_effect(m):a.skip_effect(m);for(const[d,m]of t(this,r))d===e?a.unskip_effect(m.effect):a.skip_effect(m.effect);a.oncommit(t(this,_)),a.ondiscard(t(this,v))}else C&&(this.anchor=S),t(this,_).call(this,a)}}h=new WeakMap,n=new WeakMap,r=new WeakMap,u=new WeakMap,p=new WeakMap,_=new WeakMap,v=new WeakMap;export{I as B};
|
||||
1
frontend/build/_app/immutable/chunks/De6rLmuB.js
Normal file
1
frontend/build/_app/immutable/chunks/De6rLmuB.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
import{B as g,C as d,D as c,F as m,G as i,I as b,g as p,J as v,K as h,L as k}from"./Ym0WvvUy.js";function x(n=!1){const s=g,e=s.l.u;if(!e)return;let f=()=>v(s.s);if(n){let a=0,t={};const _=h(()=>{let l=!1;const r=s.s;for(const o in r)r[o]!==t[o]&&(t[o]=r[o],l=!0);return l&&a++,a});f=()=>p(_)}e.b.length&&d(()=>{u(s,f),i(e.b)}),c(()=>{const a=m(()=>e.m.map(b));return()=>{for(const t of a)typeof t=="function"&&t()}}),e.a.length&&c(()=>{u(s,f),i(e.a)})}function u(n,s){if(n.l.s)for(const e of n.l.s)p(e);s()}k();export{x as i};
|
||||
1
frontend/build/_app/immutable/chunks/Ym0WvvUy.js
Normal file
1
frontend/build/_app/immutable/chunks/Ym0WvvUy.js
Normal file
File diff suppressed because one or more lines are too long
2
frontend/build/_app/immutable/chunks/dTRRgeF-.js
Normal file
2
frontend/build/_app/immutable/chunks/dTRRgeF-.js
Normal file
File diff suppressed because one or more lines are too long
1
frontend/build/_app/immutable/chunks/giww_vF6.js
Normal file
1
frontend/build/_app/immutable/chunks/giww_vF6.js
Normal file
File diff suppressed because one or more lines are too long
2
frontend/build/_app/immutable/entry/app.DWnDWHgs.js
Normal file
2
frontend/build/_app/immutable/entry/app.DWnDWHgs.js
Normal file
File diff suppressed because one or more lines are too long
1
frontend/build/_app/immutable/entry/start.Cw5np0_P.js
Normal file
1
frontend/build/_app/immutable/entry/start.Cw5np0_P.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
import{l as o,a as r}from"../chunks/giww_vF6.js";export{o as load_css,r as start};
|
||||
1
frontend/build/_app/immutable/nodes/0.CNxjQThJ.js
Normal file
1
frontend/build/_app/immutable/nodes/0.CNxjQThJ.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
import{c as s,a as c}from"../chunks/B6M6q2Zo.js";import{b as l,E as p,v as i}from"../chunks/Ym0WvvUy.js";import{B as m}from"../chunks/Da6yQRl8.js";function u(n,r,...e){var o=new m(n);l(()=>{const t=r()??null;o.ensure(t,t&&(a=>t(a,...e)))},p)}const f=!0,_=!1,g=Object.freeze(Object.defineProperty({__proto__:null,prerender:f,ssr:_},Symbol.toStringTag,{value:"Module"}));function h(n,r){var e=s(),o=i(e);u(o,()=>r.children),c(n,e)}export{h as component,g as universal};
|
||||
1
frontend/build/_app/immutable/nodes/1.DQmxvu2E.js
Normal file
1
frontend/build/_app/immutable/nodes/1.DQmxvu2E.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
import{a as h,f as g}from"../chunks/B6M6q2Zo.js";import{i as v}from"../chunks/De6rLmuB.js";import{u as l,v as d,A as x,w as _,y as e,z as o,x as $}from"../chunks/Ym0WvvUy.js";import{s as p}from"../chunks/dTRRgeF-.js";import{s as k,p as m}from"../chunks/giww_vF6.js";const b={get error(){return m.error},get status(){return m.status}};k.updated.check;const i=b;var w=g("<h1> </h1> <p> </p>",1);function q(f,n){l(n,!1),v();var t=w(),r=d(t),c=e(r,!0);o(r);var s=$(r,2),u=e(s,!0);o(s),x(()=>{var a;p(c,i.status),p(u,(a=i.error)==null?void 0:a.message)}),h(f,t),_()}export{q as component};
|
||||
1
frontend/build/_app/immutable/nodes/2.CwK2tdGg.js
Normal file
1
frontend/build/_app/immutable/nodes/2.CwK2tdGg.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
import{c as u,a as e,f as p}from"../chunks/B6M6q2Zo.js";import{i as _}from"../chunks/De6rLmuB.js";import{o as d}from"../chunks/Bfwrz3i4.js";import{u as x,v as y,w as A,x as b,y as m,z as n,A as k}from"../chunks/Ym0WvvUy.js";import{s as w}from"../chunks/dTRRgeF-.js";import{i as z}from"../chunks/B9dvBo0E.js";import{a as o}from"../chunks/DMqvp7vx.js";import{g as F}from"../chunks/giww_vF6.js";var M=p('<main class="container svelte-1uha8ag"><h1 class="svelte-1uha8ag">Felt</h1> <p class="text-secondary svelte-1uha8ag">Tournament management system</p> <p> </p></main>'),O=p('<main class="container svelte-1uha8ag"><p>Redirecting to login...</p></main>');function E(l,f){x(f,!1),d(()=>{o.isAuthenticated||F("/login")}),_();var s=u(),c=y(s);{var v=a=>{var t=M(),r=b(m(t),4),h=m(r);n(r),n(t),k(()=>{var i;return w(h,`Welcome, ${((i=o.operator)==null?void 0:i.name)??"Operator"??""}`)}),e(a,t)},g=a=>{var t=O();e(a,t)};z(c,a=>{o.isAuthenticated?a(v):a(g,!1)})}e(l,s),A()}export{E as component};
|
||||
2
frontend/build/_app/immutable/nodes/3.BjUxkFGX.js
Normal file
2
frontend/build/_app/immutable/nodes/3.BjUxkFGX.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import{a as P,f as K,t as ue}from"../chunks/B6M6q2Zo.js";import{o as ke}from"../chunks/Bfwrz3i4.js";import{m as V,b as Ne,av as we,h as I,f as X,P as ze,c as Ce,g as h,r as Se,d as Ie,e as ce,i as j,j as G,am as ye,at as Me,ac as de,o as De,aw as y,n as Z,ax as He,t as Le,ay as Oe,az as Pe,as as ae,aA as Re,aB as Fe,aC as Be,Y as ve,aD as Ue,k as Ae,p as Te,aE as Q,_ as $e,aF as qe,aG as Ge,aq as Ye,l as Je,an as Ke,aH as Ve,aI as Xe,aJ as je,u as Qe,A as Y,w as We,aK as Ze,y as R,x as L,z as O,s as W,a as w}from"../chunks/Ym0WvvUy.js";import{d as er,e as rr,a as B,s as pe}from"../chunks/dTRRgeF-.js";import{i as ge}from"../chunks/B9dvBo0E.js";import{a as J}from"../chunks/DMqvp7vx.js";import{g as ee}from"../chunks/giww_vF6.js";function he(e,t){return t}function tr(e,t,r){for(var a=[],i=t.length,l,s=t.length,d=0;d<i;d++){let m=t[d];Te(m,()=>{if(l){if(l.pending.delete(m),l.done.add(m),l.pending.size===0){var g=e.outrogroups;re(ae(l.done)),g.delete(l),g.size===0&&(e.outrogroups=null)}}else s-=1},!1)}if(s===0){var f=a.length===0&&r!==null;if(f){var p=r,c=p.parentNode;Ye(c),c.append(p),e.items.clear()}re(t,!f)}else l={pending:new Set(t),done:new Set},(e.outrogroups??(e.outrogroups=new Set)).add(l)}function re(e,t=!0){for(var r=0;r<e.length;r++)Je(e[r],t)}var _e;function me(e,t,r,a,i,l=null){var s=e,d=new Map,f=(t&we)!==0;if(f){var p=e;s=I?X(ze(p)):p.appendChild(V())}I&&Ce();var c=null,m=Oe(()=>{var u=r();return Pe(u)?u:u==null?[]:ae(u)}),g,_=!0;function T(){n.fallback=c,ar(n,g,s,t,a),c!==null&&(g.length===0?(c.f&y)===0?Ae(c):(c.f^=y,q(c,null,s)):Te(c,()=>{c=null}))}var M=Ne(()=>{g=h(m);var u=g.length;let k=!1;if(I){var N=Se(s)===Ie;N!==(u===0)&&(s=ce(),X(s),j(!1),k=!0)}for(var b=new Set,A=De,D=Le(),E=0;E<u;E+=1){I&&G.nodeType===ye&&G.data===Me&&(s=G,k=!0,j(!1));var z=g[E],o=a(z,E),v=_?null:d.get(o);v?(v.v&&de(v.v,z),v.i&&de(v.i,E),D&&A.unskip_effect(v.e)):(v=nr(d,_?s:_e??(_e=V()),z,o,E,i,t,r),_||(v.e.f|=y),d.set(o,v)),b.add(o)}if(u===0&&l&&!c&&(_?c=Z(()=>l(s)):(c=Z(()=>l(_e??(_e=V()))),c.f|=y)),u>b.size&&He(),I&&u>0&&X(ce()),!_)if(D){for(const[x,C]of d)b.has(x)||A.skip_effect(C.e);A.oncommit(T),A.ondiscard(()=>{})}else T();k&&j(!0),h(m)}),n={effect:M,items:d,outrogroups:null,fallback:c};_=!1,I&&(s=G)}function U(e){for(;e!==null&&(e.f&qe)===0;)e=e.next;return e}function ar(e,t,r,a,i){var v,x,C,F,ne,ie,se,oe,le;var l=(a&Ge)!==0,s=t.length,d=e.items,f=U(e.effect.first),p,c=null,m,g=[],_=[],T,M,n,u;if(l)for(u=0;u<s;u+=1)T=t[u],M=i(T,u),n=d.get(M).e,(n.f&y)===0&&((x=(v=n.nodes)==null?void 0:v.a)==null||x.measure(),(m??(m=new Set)).add(n));for(u=0;u<s;u+=1){if(T=t[u],M=i(T,u),n=d.get(M).e,e.outrogroups!==null)for(const S of e.outrogroups)S.pending.delete(n),S.done.delete(n);if((n.f&y)!==0)if(n.f^=y,n===f)q(n,null,r);else{var k=c?c.next:f;n===e.effect.last&&(e.effect.last=n.prev),n.prev&&(n.prev.next=n.next),n.next&&(n.next.prev=n.prev),H(e,c,n),H(e,n,k),q(n,k,r),c=n,g=[],_=[],f=U(c.next);continue}if((n.f&Q)!==0&&(Ae(n),l&&((F=(C=n.nodes)==null?void 0:C.a)==null||F.unfix(),(m??(m=new Set)).delete(n))),n!==f){if(p!==void 0&&p.has(n)){if(g.length<_.length){var N=_[0],b;c=N.prev;var A=g[0],D=g[g.length-1];for(b=0;b<g.length;b+=1)q(g[b],N,r);for(b=0;b<_.length;b+=1)p.delete(_[b]);H(e,A.prev,D.next),H(e,c,A),H(e,D,N),f=N,c=D,u-=1,g=[],_=[]}else p.delete(n),q(n,f,r),H(e,n.prev,n.next),H(e,n,c===null?e.effect.first:c.next),H(e,c,n),c=n;continue}for(g=[],_=[];f!==null&&f!==n;)(p??(p=new Set)).add(f),_.push(f),f=U(f.next);if(f===null)continue}(n.f&y)===0&&g.push(n),c=n,f=U(n.next)}if(e.outrogroups!==null){for(const S of e.outrogroups)S.pending.size===0&&(re(ae(S.done)),(ne=e.outrogroups)==null||ne.delete(S));e.outrogroups.size===0&&(e.outrogroups=null)}if(f!==null||p!==void 0){var E=[];if(p!==void 0)for(n of p)(n.f&Q)===0&&E.push(n);for(;f!==null;)(f.f&Q)===0&&f!==e.fallback&&E.push(f),f=U(f.next);var z=E.length;if(z>0){var o=(a&we)!==0&&s===0?r:null;if(l){for(u=0;u<z;u+=1)(se=(ie=E[u].nodes)==null?void 0:ie.a)==null||se.measure();for(u=0;u<z;u+=1)(le=(oe=E[u].nodes)==null?void 0:oe.a)==null||le.fix()}tr(e,E,o)}}l&&$e(()=>{var S,fe;if(m!==void 0)for(n of m)(fe=(S=n.nodes)==null?void 0:S.a)==null||fe.apply()})}function nr(e,t,r,a,i,l,s,d){var f=(s&Re)!==0?(s&Fe)===0?Be(r,!1,!1):ve(r):null,p=(s&Ue)!==0?ve(i):null;return{v:f,i:p,e:Z(()=>(l(t,f??r,p??i,d),()=>{e.delete(a)}))}}function q(e,t,r){if(e.nodes)for(var a=e.nodes.start,i=e.nodes.end,l=t&&(t.f&y)===0?t.nodes.start:r;a!==null;){var s=Ke(a);if(l.before(a),a===i)return;a=s}}function H(e,t,r){t===null?e.effect.first=r:t.next=r,r===null?e.effect.last=t:r.prev=t}const be=[...`
|
||||
\r\f \v\uFEFF`];function ir(e,t,r){var a=""+e;if(r){for(var i of Object.keys(r))if(r[i])a=a?a+" "+i:i;else if(a.length)for(var l=i.length,s=0;(s=a.indexOf(i,s))>=0;){var d=s+l;(s===0||be.includes(a[s-1]))&&(d===a.length||be.includes(a[d]))?a=(s===0?"":a.substring(0,s))+a.substring(d+1):s=d}}return a===""?null:a}function sr(e,t,r,a,i,l){var s=e.__className;if(I||s!==r||s===void 0){var d=ir(r,a,l);(!I||d!==e.getAttribute("class"))&&(d==null?e.removeAttribute("class"):e.className=d),e.__className=r}else if(l&&i!==l)for(var f in l){var p=!!l[f];(i==null||p!==!!i[f])&&e.classList.toggle(f,p)}return l}const or=Symbol("is custom element"),lr=Symbol("is html");function Ee(e,t,r,a){var i=fr(e);I&&(i[t]=e.getAttribute(t)),i[t]!==(i[t]=r)&&(r==null?e.removeAttribute(t):typeof r!="string"&&ur(e).includes(t)?e[t]=r:e.setAttribute(t,r))}function fr(e){return e.__attributes??(e.__attributes={[or]:e.nodeName.includes("-"),[lr]:e.namespaceURI===Ve})}var xe=new Map;function ur(e){var t=e.getAttribute("is")||e.nodeName,r=xe.get(t);if(r)return r;xe.set(t,r=[]);for(var a,i=e,l=Element.prototype;l!==i;){a=je(i);for(var s in a)a[s].set&&r.push(s);i=Xe(i)}return r}class te extends Error{constructor(t,r,a){const i=typeof a=="object"&&a!==null&&"error"in a?a.error:r;super(i),this.status=t,this.statusText=r,this.body=a,this.name="ApiError"}}function cr(){return`${window.location.origin}/api/v1`}function dr(e){const t={Accept:"application/json"};e&&(t["Content-Type"]="application/json");const r=J.token;return r&&(t.Authorization=`Bearer ${r}`),t}async function vr(e){if(e.status===401)throw J.logout(),await ee("/login"),new te(401,"Unauthorized",{error:"Session expired"});if(!e.ok){let t;try{t=await e.json()}catch{t={error:e.statusText}}throw new te(e.status,e.statusText,t)}if(e.status!==204)return e.json()}async function $(e,t,r){const a=`${cr()}${t}`,i={method:e,headers:dr(r!==void 0),credentials:"same-origin"};r!==void 0&&(i.body=JSON.stringify(r));const l=await fetch(a,i);return vr(l)}const pr={get(e){return $("GET",e)},post(e,t){return $("POST",e,t)},put(e,t){return $("PUT",e,t)},patch(e,t){return $("PATCH",e,t)},delete(e){return $("DELETE",e)}};var gr=K("<div></div>"),hr=K('<div class="error-message svelte-1x05zx6" role="alert"> </div>'),_r=K('<button class="numpad-btn touch-target svelte-1x05zx6"> </button>'),mr=K('<main class="login-container svelte-1x05zx6"><div class="login-card svelte-1x05zx6"><div class="logo svelte-1x05zx6"><h1 class="svelte-1x05zx6">Felt</h1> <p class="subtitle svelte-1x05zx6">Tournament Manager</p></div> <div class="pin-display svelte-1x05zx6" role="status"></div> <!> <div class="numpad svelte-1x05zx6"><!> <button class="numpad-btn numpad-fn touch-target svelte-1x05zx6" aria-label="Clear PIN">CLR</button> <button class="numpad-btn touch-target svelte-1x05zx6" aria-label="Digit 0">0</button> <button class="numpad-btn numpad-fn touch-target svelte-1x05zx6" aria-label="Delete last digit">DEL</button></div> <button class="submit-btn touch-target svelte-1x05zx6"><!></button></div></main>');function Nr(e,t){Qe(t,!0);let r=W(""),a=W(""),i=W(!1);const l=6;ke(()=>{J.isAuthenticated&&ee("/")});function s(o){h(r).length>=l||(w(r,h(r)+o),w(a,""))}function d(){w(r,h(r).slice(0,-1),!0),w(a,"")}function f(){w(r,""),w(a,"")}async function p(){if(!(h(r).length<4||h(i))){w(i,!0),w(a,"");try{const o=await pr.post("/auth/login",{pin:h(r)});J.login(o.token,{id:o.operator.id,name:o.operator.name,role:o.operator.role}),await ee("/")}catch(o){o instanceof te?o.status===429?w(a,"Too many attempts. Please wait."):o.status===401?w(a,"Invalid PIN. Try again."):w(a,o.message,!0):w(a,"Connection error. Check your network."),w(r,"")}finally{w(i,!1)}}}function c(o){o.key>="0"&&o.key<="9"?s(o.key):o.key==="Backspace"?d():o.key==="Enter"?p():o.key==="Escape"&&f()}var m=mr();rr("keydown",Ze,c);var g=R(m),_=L(R(g),2);me(_,21,()=>Array(l),he,(o,v,x)=>{var C=gr();let F;Y(()=>F=sr(C,1,"pin-dot svelte-1x05zx6",null,F,{filled:x<h(r).length})),P(o,C)}),O(_);var T=L(_,2);{var M=o=>{var v=hr(),x=R(v,!0);O(v),Y(()=>pe(x,h(a))),P(o,v)};ge(T,o=>{h(a)&&o(M)})}var n=L(T,2),u=R(n);me(u,16,()=>["1","2","3","4","5","6","7","8","9"],he,(o,v)=>{var x=_r(),C=R(x,!0);O(x),Y(()=>{x.disabled=h(i)||h(r).length>=l,Ee(x,"aria-label",`Digit ${v??""}`),pe(C,v)}),B("click",x,()=>s(v)),P(o,x)});var k=L(u,2),N=L(k,2),b=L(N,2);O(n);var A=L(n,2),D=R(A);{var E=o=>{var v=ue("Signing in...");P(o,v)},z=o=>{var v=ue("Sign In");P(o,v)};ge(D,o=>{h(i)?o(E):o(z,!1)})}O(A),O(g),O(m),Y(()=>{Ee(_,"aria-label",`PIN entered: ${h(r).length??""} digits`),k.disabled=h(i),N.disabled=h(i)||h(r).length>=l,b.disabled=h(i)||h(r).length===0,A.disabled=h(r).length<4||h(i)}),B("click",k,f),B("click",N,()=>s("0")),B("click",b,d),B("click",A,p),P(e,m),We()}er(["click"]);export{Nr as component};
|
||||
1
frontend/build/_app/version.json
Normal file
1
frontend/build/_app/version.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":"1772333625386"}
|
||||
BIN
frontend/build/favicon.png
Normal file
BIN
frontend/build/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
|
|
@ -1,38 +1,39 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Felt</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background-color: #1e1e2e;
|
||||
color: #cdd6f4;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
.loading {
|
||||
text-align: center;
|
||||
}
|
||||
.loading h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.loading p {
|
||||
font-size: 1rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="loading">
|
||||
<h1>Felt</h1>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</body>
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="mocha">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<link rel="icon" href="/favicon.png" />
|
||||
<title>Felt</title>
|
||||
<link href="/_app/immutable/entry/start.Cw5np0_P.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/giww_vF6.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/Ym0WvvUy.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/Bfwrz3i4.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.DWnDWHgs.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/dTRRgeF-.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/B6M6q2Zo.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/B9dvBo0E.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/Da6yQRl8.js" rel="modulepreload">
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div style="display: contents">
|
||||
<script>
|
||||
{
|
||||
__sveltekit_1rgg0vt = {
|
||||
base: ""
|
||||
};
|
||||
|
||||
const element = document.currentScript.parentElement;
|
||||
|
||||
Promise.all([
|
||||
import("/_app/immutable/entry/start.Cw5np0_P.js"),
|
||||
import("/_app/immutable/entry/app.DWnDWHgs.js")
|
||||
]).then(([kit, app]) => {
|
||||
kit.start(app, element);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
39
frontend/build/login.html
Normal file
39
frontend/build/login.html
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<!doctype html>
|
||||
<html lang="en" data-theme="mocha">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<link rel="icon" href="./favicon.png" />
|
||||
<title>Felt</title>
|
||||
<link href="./_app/immutable/entry/start.Cw5np0_P.js" rel="modulepreload">
|
||||
<link href="./_app/immutable/chunks/giww_vF6.js" rel="modulepreload">
|
||||
<link href="./_app/immutable/chunks/Ym0WvvUy.js" rel="modulepreload">
|
||||
<link href="./_app/immutable/chunks/Bfwrz3i4.js" rel="modulepreload">
|
||||
<link href="./_app/immutable/entry/app.DWnDWHgs.js" rel="modulepreload">
|
||||
<link href="./_app/immutable/chunks/dTRRgeF-.js" rel="modulepreload">
|
||||
<link href="./_app/immutable/chunks/B6M6q2Zo.js" rel="modulepreload">
|
||||
<link href="./_app/immutable/chunks/B9dvBo0E.js" rel="modulepreload">
|
||||
<link href="./_app/immutable/chunks/Da6yQRl8.js" rel="modulepreload">
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div style="display: contents">
|
||||
<script>
|
||||
{
|
||||
__sveltekit_1rgg0vt = {
|
||||
base: new URL(".", location).pathname.slice(0, -1)
|
||||
};
|
||||
|
||||
const element = document.currentScript.parentElement;
|
||||
|
||||
Promise.all([
|
||||
import("./_app/immutable/entry/start.Cw5np0_P.js"),
|
||||
import("./_app/immutable/entry/app.DWnDWHgs.js")
|
||||
]).then(([kit, app]) => {
|
||||
kit.start(app, element);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1627
frontend/package-lock.json
generated
Normal file
1627
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "felt-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
138
frontend/src/app.css
Normal file
138
frontend/src/app.css
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
@import '$lib/theme/catppuccin.css';
|
||||
|
||||
/* ============================================
|
||||
Reset
|
||||
============================================ */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Base styles
|
||||
============================================ */
|
||||
html {
|
||||
font-family: var(--font-body);
|
||||
font-size: 16px;
|
||||
line-height: var(--leading-normal);
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
/* Prevent layout shift from scrollbar */
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Touch targets & interaction
|
||||
Poker room: TD using phone with one hand
|
||||
============================================ */
|
||||
button,
|
||||
a,
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
[role='button'],
|
||||
[role='tab'],
|
||||
[role='menuitem'] {
|
||||
/* Prevent double-tap zoom on mobile */
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* Minimum 48px touch target for all interactive elements */
|
||||
.touch-target,
|
||||
button,
|
||||
[role='button'],
|
||||
[role='tab'] {
|
||||
min-height: var(--touch-target);
|
||||
min-width: var(--touch-target);
|
||||
}
|
||||
|
||||
/* Active/pressed state for tactile feedback */
|
||||
button:active,
|
||||
[role='button']:active,
|
||||
[role='tab']:active,
|
||||
.touch-target:active {
|
||||
transform: scale(0.97);
|
||||
opacity: 0.9;
|
||||
transition: transform var(--transition-fast), opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
/* Focus visible for keyboard accessibility */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Remove default focus ring for mouse/touch users */
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Scrollbar styling (dark theme)
|
||||
============================================ */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-surface-active);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-overlay);
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-surface-active) var(--color-bg);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Typography helpers
|
||||
============================================ */
|
||||
.font-mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Timer/number display — always monospace */
|
||||
.timer,
|
||||
.number,
|
||||
.blinds,
|
||||
.chips,
|
||||
.currency {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Common utility classes
|
||||
============================================ */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
13
frontend/src/app.d.ts
vendored
Normal file
13
frontend/src/app.d.ts
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
13
frontend/src/app.html
Normal file
13
frontend/src/app.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en" data-theme="mocha">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<title>Felt</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body>
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
124
frontend/src/lib/api.ts
Normal file
124
frontend/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* HTTP API client for the Felt backend.
|
||||
*
|
||||
* Auto-detects base URL from current host, attaches JWT from auth store,
|
||||
* handles 401 responses by clearing auth state and redirecting to login.
|
||||
*/
|
||||
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
/** Typed API error with status code and message. */
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly statusText: string,
|
||||
public readonly body: unknown
|
||||
) {
|
||||
const msg = typeof body === 'object' && body !== null && 'error' in body
|
||||
? (body as { error: string }).error
|
||||
: statusText;
|
||||
super(msg);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Base URL for API requests — auto-detected from current host. */
|
||||
function getBaseUrl(): string {
|
||||
return `${window.location.origin}/api/v1`;
|
||||
}
|
||||
|
||||
/** Build headers with JWT auth and content type. */
|
||||
function buildHeaders(hasBody: boolean): HeadersInit {
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/json'
|
||||
};
|
||||
|
||||
if (hasBody) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const token = auth.token;
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/** Handle API response — parse JSON, handle errors. */
|
||||
async function handleResponse<T>(response: Response): Promise<T> {
|
||||
if (response.status === 401) {
|
||||
// Token expired or invalid — clear auth and redirect to login
|
||||
auth.logout();
|
||||
await goto('/login');
|
||||
throw new ApiError(401, 'Unauthorized', { error: 'Session expired' });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
body = { error: response.statusText };
|
||||
}
|
||||
throw new ApiError(response.status, response.statusText, body);
|
||||
}
|
||||
|
||||
// Handle 204 No Content
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/** Perform an API request. */
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const url = `${getBaseUrl()}${path}`;
|
||||
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: buildHeaders(body !== undefined),
|
||||
credentials: 'same-origin'
|
||||
};
|
||||
|
||||
if (body !== undefined) {
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, init);
|
||||
return handleResponse<T>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP API client.
|
||||
*
|
||||
* All methods auto-attach JWT from auth store and handle 401 responses.
|
||||
*/
|
||||
export const api = {
|
||||
/** GET request. */
|
||||
get<T>(path: string): Promise<T> {
|
||||
return request<T>('GET', path);
|
||||
},
|
||||
|
||||
/** POST request with JSON body. */
|
||||
post<T>(path: string, body?: unknown): Promise<T> {
|
||||
return request<T>('POST', path, body);
|
||||
},
|
||||
|
||||
/** PUT request with JSON body. */
|
||||
put<T>(path: string, body?: unknown): Promise<T> {
|
||||
return request<T>('PUT', path, body);
|
||||
},
|
||||
|
||||
/** PATCH request with JSON body. */
|
||||
patch<T>(path: string, body?: unknown): Promise<T> {
|
||||
return request<T>('PATCH', path, body);
|
||||
},
|
||||
|
||||
/** DELETE request. */
|
||||
delete<T>(path: string): Promise<T> {
|
||||
return request<T>('DELETE', path);
|
||||
}
|
||||
};
|
||||
109
frontend/src/lib/stores/auth.svelte.ts
Normal file
109
frontend/src/lib/stores/auth.svelte.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* Authentication state using Svelte 5 runes.
|
||||
*
|
||||
* Manages JWT token and operator info with localStorage persistence.
|
||||
* Token is stored in localStorage (acceptable while Leaf is local-network only).
|
||||
*
|
||||
* TODO Phase 7: Migrate token storage from localStorage to HttpOnly cookies
|
||||
* for XSS protection when Netbird reverse proxy is in place.
|
||||
*/
|
||||
|
||||
const TOKEN_KEY = 'felt_token';
|
||||
const OPERATOR_KEY = 'felt_operator';
|
||||
|
||||
/** Operator role type. */
|
||||
export type OperatorRole = 'admin' | 'floor' | 'viewer';
|
||||
|
||||
/** Operator information from JWT claims. */
|
||||
export interface Operator {
|
||||
id: string;
|
||||
name: string;
|
||||
role: OperatorRole;
|
||||
}
|
||||
|
||||
class AuthState {
|
||||
token = $state<string | null>(null);
|
||||
operator = $state<Operator | null>(null);
|
||||
|
||||
constructor() {
|
||||
// Load persisted state on initialization
|
||||
if (typeof window !== 'undefined') {
|
||||
this.loadFromStorage();
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the user is authenticated. */
|
||||
get isAuthenticated(): boolean {
|
||||
return this.token !== null;
|
||||
}
|
||||
|
||||
/** Whether the user has admin role. */
|
||||
get isAdmin(): boolean {
|
||||
return this.operator?.role === 'admin';
|
||||
}
|
||||
|
||||
/** Whether the user has floor or admin role. */
|
||||
get isFloor(): boolean {
|
||||
return ['admin', 'floor'].includes(this.operator?.role ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set authentication state after successful login.
|
||||
*
|
||||
* @param token - JWT token string
|
||||
* @param operator - Operator info from token claims
|
||||
*/
|
||||
login(token: string, operator: Operator): void {
|
||||
this.token = token;
|
||||
this.operator = operator;
|
||||
this.saveToStorage();
|
||||
}
|
||||
|
||||
/** Clear authentication state (logout or 401). */
|
||||
logout(): void {
|
||||
this.token = null;
|
||||
this.operator = null;
|
||||
this.clearStorage();
|
||||
}
|
||||
|
||||
/** Load persisted auth from localStorage. */
|
||||
private loadFromStorage(): void {
|
||||
try {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
const operatorJson = localStorage.getItem(OPERATOR_KEY);
|
||||
|
||||
if (token && operatorJson) {
|
||||
this.token = token;
|
||||
this.operator = JSON.parse(operatorJson) as Operator;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('auth: failed to load from storage:', err);
|
||||
this.clearStorage();
|
||||
}
|
||||
}
|
||||
|
||||
/** Save auth state to localStorage. */
|
||||
private saveToStorage(): void {
|
||||
try {
|
||||
if (this.token && this.operator) {
|
||||
localStorage.setItem(TOKEN_KEY, this.token);
|
||||
localStorage.setItem(OPERATOR_KEY, JSON.stringify(this.operator));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('auth: failed to save to storage:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear auth from localStorage. */
|
||||
private clearStorage(): void {
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(OPERATOR_KEY);
|
||||
} catch (err) {
|
||||
console.warn('auth: failed to clear storage:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton auth state instance. */
|
||||
export const auth = new AuthState();
|
||||
326
frontend/src/lib/stores/tournament.svelte.ts
Normal file
326
frontend/src/lib/stores/tournament.svelte.ts
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
/**
|
||||
* Tournament state using Svelte 5 runes.
|
||||
*
|
||||
* Handles real-time WebSocket messages from the backend and maintains
|
||||
* reactive state for all tournament data: clock, players, tables,
|
||||
* financials, activity feed, rankings, and table balance status.
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// Types
|
||||
// ============================================
|
||||
|
||||
export interface ClockSnapshot {
|
||||
level: number;
|
||||
name: string;
|
||||
small_blind: number;
|
||||
big_blind: number;
|
||||
ante: number;
|
||||
elapsed_seconds: number;
|
||||
remaining_seconds: number;
|
||||
is_break: boolean;
|
||||
is_paused: boolean;
|
||||
next_break_in_seconds: number | null;
|
||||
}
|
||||
|
||||
export type PlayerStatus = 'registered' | 'active' | 'eliminated' | 'away';
|
||||
|
||||
export interface Player {
|
||||
id: string;
|
||||
name: string;
|
||||
status: PlayerStatus;
|
||||
table_id: string | null;
|
||||
seat: number | null;
|
||||
chips: number;
|
||||
rebuys: number;
|
||||
addons: number;
|
||||
bounty: number;
|
||||
finish_position: number | null;
|
||||
eliminated_by: string | null;
|
||||
}
|
||||
|
||||
export interface Table {
|
||||
id: string;
|
||||
number: number;
|
||||
seats: number;
|
||||
players: string[]; // player IDs
|
||||
is_final_table: boolean;
|
||||
is_break_table: boolean;
|
||||
}
|
||||
|
||||
export interface FinancialSummary {
|
||||
total_buyin: number;
|
||||
total_rebuys: number;
|
||||
total_addons: number;
|
||||
total_collected: number;
|
||||
prize_pool: number;
|
||||
house_fee: number;
|
||||
paid_positions: number;
|
||||
payouts: PayoutEntry[];
|
||||
}
|
||||
|
||||
export interface PayoutEntry {
|
||||
position: number;
|
||||
amount: number;
|
||||
player_id: string | null;
|
||||
player_name: string | null;
|
||||
}
|
||||
|
||||
export interface ActivityEntry {
|
||||
id: string;
|
||||
type: string;
|
||||
message: string;
|
||||
player_id: string | null;
|
||||
player_name: string | null;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface PlayerRanking {
|
||||
position: number;
|
||||
player_id: string;
|
||||
player_name: string;
|
||||
chips: number;
|
||||
bounties: number;
|
||||
}
|
||||
|
||||
export interface BalanceStatus {
|
||||
is_balanced: boolean;
|
||||
moves_needed: BalanceMove[];
|
||||
max_diff: number;
|
||||
}
|
||||
|
||||
export interface BalanceMove {
|
||||
player_id: string;
|
||||
player_name: string;
|
||||
from_table: number;
|
||||
to_table: number;
|
||||
to_seat: number;
|
||||
}
|
||||
|
||||
export interface WSMessage {
|
||||
type: string;
|
||||
tournament_id?: string;
|
||||
data: unknown;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// State
|
||||
// ============================================
|
||||
|
||||
class TournamentState {
|
||||
/** Current tournament ID. */
|
||||
id = $state<string | null>(null);
|
||||
|
||||
/** Clock/timer state. */
|
||||
clock = $state<ClockSnapshot | null>(null);
|
||||
|
||||
/** All players. */
|
||||
players = $state<Player[]>([]);
|
||||
|
||||
/** All tables. */
|
||||
tables = $state<Table[]>([]);
|
||||
|
||||
/** Financial summary. */
|
||||
financials = $state<FinancialSummary | null>(null);
|
||||
|
||||
/** Activity feed (most recent first). */
|
||||
activity = $state<ActivityEntry[]>([]);
|
||||
|
||||
/** Player rankings by chip count. */
|
||||
rankings = $state<PlayerRanking[]>([]);
|
||||
|
||||
/** Table balance status. */
|
||||
balanceStatus = $state<BalanceStatus | null>(null);
|
||||
|
||||
/** Maximum activity feed entries to keep. */
|
||||
private maxActivityEntries = 100;
|
||||
|
||||
// ============================================
|
||||
// Derived state
|
||||
// ============================================
|
||||
|
||||
/** Count of active (not eliminated) players. */
|
||||
get remainingPlayers(): number {
|
||||
return this.players.filter((p) => p.status === 'active').length;
|
||||
}
|
||||
|
||||
/** Total registered players. */
|
||||
get totalPlayers(): number {
|
||||
return this.players.length;
|
||||
}
|
||||
|
||||
/** Active tables count. */
|
||||
get activeTables(): number {
|
||||
return this.tables.filter((t) => t.players.length > 0).length;
|
||||
}
|
||||
|
||||
/** Whether tables are balanced. */
|
||||
get isBalanced(): boolean {
|
||||
return this.balanceStatus?.is_balanced ?? true;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// WebSocket message handler
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Handle an incoming WebSocket message and update state.
|
||||
*
|
||||
* Message types match the Go backend's broadcast types:
|
||||
* - clock.tick: Timer update every second
|
||||
* - state.snapshot: Full state load (on connect or refresh)
|
||||
* - player.*: Player-related events
|
||||
* - table.*: Table-related events
|
||||
* - financial.*: Financial updates
|
||||
* - balance.*: Table balance events
|
||||
*/
|
||||
handleMessage(msg: WSMessage): void {
|
||||
switch (msg.type) {
|
||||
// Clock
|
||||
case 'clock.tick':
|
||||
this.clock = msg.data as ClockSnapshot;
|
||||
break;
|
||||
case 'clock.level_change':
|
||||
this.clock = msg.data as ClockSnapshot;
|
||||
break;
|
||||
case 'clock.paused':
|
||||
if (this.clock) this.clock.is_paused = true;
|
||||
break;
|
||||
case 'clock.resumed':
|
||||
if (this.clock) this.clock.is_paused = false;
|
||||
break;
|
||||
|
||||
// Full state snapshot
|
||||
case 'state.snapshot':
|
||||
this.loadFullState(msg.data as FullSnapshot);
|
||||
break;
|
||||
|
||||
// Players
|
||||
case 'player.registered':
|
||||
this.addOrUpdatePlayer(msg.data as Player);
|
||||
break;
|
||||
case 'player.seated':
|
||||
this.addOrUpdatePlayer(msg.data as Player);
|
||||
break;
|
||||
case 'player.bust':
|
||||
case 'player.eliminated':
|
||||
this.addOrUpdatePlayer(msg.data as Player);
|
||||
break;
|
||||
case 'player.rebuy':
|
||||
case 'player.addon':
|
||||
this.addOrUpdatePlayer(msg.data as Player);
|
||||
break;
|
||||
case 'player.moved':
|
||||
this.addOrUpdatePlayer(msg.data as Player);
|
||||
break;
|
||||
|
||||
// Tables
|
||||
case 'table.created':
|
||||
this.addOrUpdateTable(msg.data as Table);
|
||||
break;
|
||||
case 'table.broken':
|
||||
this.removeTable((msg.data as { id: string }).id);
|
||||
break;
|
||||
case 'table.updated':
|
||||
this.addOrUpdateTable(msg.data as Table);
|
||||
break;
|
||||
|
||||
// Financials
|
||||
case 'financial.updated':
|
||||
this.financials = msg.data as FinancialSummary;
|
||||
break;
|
||||
|
||||
// Rankings
|
||||
case 'rankings.updated':
|
||||
this.rankings = msg.data as PlayerRanking[];
|
||||
break;
|
||||
|
||||
// Balance
|
||||
case 'balance.updated':
|
||||
this.balanceStatus = msg.data as BalanceStatus;
|
||||
break;
|
||||
|
||||
// Activity
|
||||
case 'activity.new':
|
||||
this.addActivity(msg.data as ActivityEntry);
|
||||
break;
|
||||
|
||||
// Connection
|
||||
case 'connected':
|
||||
console.log('tournament: connected to server');
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`tournament: unknown message type: ${msg.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset all state. */
|
||||
reset(): void {
|
||||
this.id = null;
|
||||
this.clock = null;
|
||||
this.players = [];
|
||||
this.tables = [];
|
||||
this.financials = null;
|
||||
this.activity = [];
|
||||
this.rankings = [];
|
||||
this.balanceStatus = null;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Private helpers
|
||||
// ============================================
|
||||
|
||||
private loadFullState(snapshot: FullSnapshot): void {
|
||||
this.id = snapshot.id ?? this.id;
|
||||
this.clock = snapshot.clock ?? null;
|
||||
this.players = snapshot.players ?? [];
|
||||
this.tables = snapshot.tables ?? [];
|
||||
this.financials = snapshot.financials ?? null;
|
||||
this.activity = snapshot.activity ?? [];
|
||||
this.rankings = snapshot.rankings ?? [];
|
||||
this.balanceStatus = snapshot.balance_status ?? null;
|
||||
}
|
||||
|
||||
private addOrUpdatePlayer(player: Player): void {
|
||||
const idx = this.players.findIndex((p) => p.id === player.id);
|
||||
if (idx >= 0) {
|
||||
this.players[idx] = player;
|
||||
} else {
|
||||
this.players.push(player);
|
||||
}
|
||||
}
|
||||
|
||||
private addOrUpdateTable(table: Table): void {
|
||||
const idx = this.tables.findIndex((t) => t.id === table.id);
|
||||
if (idx >= 0) {
|
||||
this.tables[idx] = table;
|
||||
} else {
|
||||
this.tables.push(table);
|
||||
}
|
||||
}
|
||||
|
||||
private removeTable(tableId: string): void {
|
||||
this.tables = this.tables.filter((t) => t.id !== tableId);
|
||||
}
|
||||
|
||||
private addActivity(entry: ActivityEntry): void {
|
||||
this.activity = [entry, ...this.activity].slice(0, this.maxActivityEntries);
|
||||
}
|
||||
}
|
||||
|
||||
/** Full state snapshot received on WebSocket connect. */
|
||||
interface FullSnapshot {
|
||||
id?: string;
|
||||
clock?: ClockSnapshot;
|
||||
players?: Player[];
|
||||
tables?: Table[];
|
||||
financials?: FinancialSummary;
|
||||
activity?: ActivityEntry[];
|
||||
rankings?: PlayerRanking[];
|
||||
balance_status?: BalanceStatus;
|
||||
}
|
||||
|
||||
/** Singleton tournament state instance. */
|
||||
export const tournament = new TournamentState();
|
||||
163
frontend/src/lib/theme/catppuccin.css
Normal file
163
frontend/src/lib/theme/catppuccin.css
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
/*
|
||||
* Catppuccin Theme for Felt
|
||||
* https://catppuccin.com/
|
||||
*
|
||||
* Mocha (dark) — default
|
||||
* Latte (light) — alternate
|
||||
*
|
||||
* All 26 base colors + semantic mappings + poker-specific tokens
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
Mocha (dark theme) — default
|
||||
============================================ */
|
||||
[data-theme='mocha'],
|
||||
:root {
|
||||
/* Base colors */
|
||||
--ctp-rosewater: #f5e0dc;
|
||||
--ctp-flamingo: #f2cdcd;
|
||||
--ctp-pink: #f5c2e7;
|
||||
--ctp-mauve: #cba6f7;
|
||||
--ctp-red: #f38ba8;
|
||||
--ctp-maroon: #eba0ac;
|
||||
--ctp-peach: #fab387;
|
||||
--ctp-yellow: #f9e2af;
|
||||
--ctp-green: #a6e3a1;
|
||||
--ctp-teal: #94e2d5;
|
||||
--ctp-sky: #89dceb;
|
||||
--ctp-sapphire: #74c7ec;
|
||||
--ctp-blue: #89b4fa;
|
||||
--ctp-lavender: #b4befe;
|
||||
|
||||
/* Surface colors */
|
||||
--ctp-text: #cdd6f4;
|
||||
--ctp-subtext1: #bac2de;
|
||||
--ctp-subtext0: #a6adc8;
|
||||
--ctp-overlay2: #9399b2;
|
||||
--ctp-overlay1: #7f849c;
|
||||
--ctp-overlay0: #6c7086;
|
||||
--ctp-surface2: #585b70;
|
||||
--ctp-surface1: #45475a;
|
||||
--ctp-surface0: #313244;
|
||||
--ctp-base: #1e1e2e;
|
||||
--ctp-mantle: #181825;
|
||||
--ctp-crust: #11111b;
|
||||
|
||||
/* Semantic color mappings */
|
||||
--color-bg: var(--ctp-base);
|
||||
--color-bg-elevated: var(--ctp-mantle);
|
||||
--color-bg-sunken: var(--ctp-crust);
|
||||
--color-surface: var(--ctp-surface0);
|
||||
--color-surface-hover: var(--ctp-surface1);
|
||||
--color-surface-active: var(--ctp-surface2);
|
||||
--color-text: var(--ctp-text);
|
||||
--color-text-secondary: var(--ctp-subtext1);
|
||||
--color-text-muted: var(--ctp-subtext0);
|
||||
--color-primary: var(--ctp-blue);
|
||||
--color-success: var(--ctp-green);
|
||||
--color-warning: var(--ctp-yellow);
|
||||
--color-error: var(--ctp-red);
|
||||
--color-accent: var(--ctp-mauve);
|
||||
--color-border: var(--ctp-surface1);
|
||||
--color-overlay: var(--ctp-overlay0);
|
||||
|
||||
/* Poker-specific semantic tokens */
|
||||
--color-felt: var(--ctp-green);
|
||||
--color-card: var(--ctp-text);
|
||||
--color-bounty: var(--ctp-pink);
|
||||
--color-prize: var(--ctp-yellow);
|
||||
--color-chip: var(--ctp-peach);
|
||||
--color-clock: var(--ctp-sapphire);
|
||||
--color-break: var(--ctp-teal);
|
||||
--color-elimination: var(--ctp-red);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Latte (light theme) — alternate
|
||||
============================================ */
|
||||
[data-theme='latte'] {
|
||||
/* Base colors */
|
||||
--ctp-rosewater: #dc8a78;
|
||||
--ctp-flamingo: #dd7878;
|
||||
--ctp-pink: #ea76cb;
|
||||
--ctp-mauve: #8839ef;
|
||||
--ctp-red: #d20f39;
|
||||
--ctp-maroon: #e64553;
|
||||
--ctp-peach: #fe640b;
|
||||
--ctp-yellow: #df8e1d;
|
||||
--ctp-green: #40a02b;
|
||||
--ctp-teal: #179299;
|
||||
--ctp-sky: #04a5e5;
|
||||
--ctp-sapphire: #209fb5;
|
||||
--ctp-blue: #1e66f5;
|
||||
--ctp-lavender: #7287fd;
|
||||
|
||||
/* Surface colors */
|
||||
--ctp-text: #4c4f69;
|
||||
--ctp-subtext1: #5c5f77;
|
||||
--ctp-subtext0: #6c6f85;
|
||||
--ctp-overlay2: #7c7f93;
|
||||
--ctp-overlay1: #8c8fa1;
|
||||
--ctp-overlay0: #9ca0b0;
|
||||
--ctp-surface2: #acb0be;
|
||||
--ctp-surface1: #bcc0cc;
|
||||
--ctp-surface0: #ccd0da;
|
||||
--ctp-base: #eff1f5;
|
||||
--ctp-mantle: #e6e9ef;
|
||||
--ctp-crust: #dce0e8;
|
||||
|
||||
/* Semantic mappings auto-inherit from --ctp-* variables */
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Typography
|
||||
============================================ */
|
||||
:root {
|
||||
/* Font stacks */
|
||||
--font-body: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
|
||||
|
||||
/* Font sizes */
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-3xl: 1.875rem;
|
||||
--text-4xl: 2.25rem;
|
||||
|
||||
/* Line heights */
|
||||
--leading-tight: 1.25;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.75;
|
||||
|
||||
/* Spacing */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-6: 1.5rem;
|
||||
--space-8: 2rem;
|
||||
--space-12: 3rem;
|
||||
|
||||
/* Border radius */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.3);
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 100ms ease;
|
||||
--transition-normal: 200ms ease;
|
||||
--transition-slow: 300ms ease;
|
||||
|
||||
/* Touch target minimum */
|
||||
--touch-target: 48px;
|
||||
}
|
||||
199
frontend/src/lib/ws.ts
Normal file
199
frontend/src/lib/ws.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
/**
|
||||
* WebSocket client with auto-reconnect and exponential backoff.
|
||||
*
|
||||
* Connects to the Go backend's /ws endpoint with JWT authentication
|
||||
* via query parameter (browser WebSocket API limitation).
|
||||
*/
|
||||
|
||||
export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
|
||||
|
||||
export interface WSMessage {
|
||||
type: string;
|
||||
tournament_id?: string;
|
||||
data: unknown;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
type MessageHandler = (msg: WSMessage) => void;
|
||||
type StateHandler = (state: ConnectionState) => void;
|
||||
|
||||
const MIN_RECONNECT_DELAY = 1000; // 1s
|
||||
const MAX_RECONNECT_DELAY = 30000; // 30s
|
||||
const BACKOFF_MULTIPLIER = 2;
|
||||
|
||||
export class WebSocketClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private url: string = '';
|
||||
private token: string = '';
|
||||
private tournamentID: string = '';
|
||||
private reconnectDelay: number = MIN_RECONNECT_DELAY;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private intentionalClose: boolean = false;
|
||||
private messageHandlers: MessageHandler[] = [];
|
||||
private stateHandlers: StateHandler[] = [];
|
||||
private _state: ConnectionState = 'disconnected';
|
||||
|
||||
/** Current connection state. */
|
||||
get state(): ConnectionState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the WebSocket server.
|
||||
*
|
||||
* @param token - JWT token for authentication
|
||||
* @param tournamentID - Optional tournament scope
|
||||
*/
|
||||
connect(token: string, tournamentID?: string): void {
|
||||
this.token = token;
|
||||
this.tournamentID = tournamentID ?? '';
|
||||
this.intentionalClose = false;
|
||||
|
||||
// Auto-detect protocol: http -> ws, https -> wss
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
|
||||
let wsUrl = `${protocol}//${host}/ws?token=${encodeURIComponent(token)}`;
|
||||
if (this.tournamentID) {
|
||||
wsUrl += `&tournament=${encodeURIComponent(this.tournamentID)}`;
|
||||
}
|
||||
this.url = wsUrl;
|
||||
|
||||
this.doConnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a specific tournament's updates.
|
||||
* Reconnects with the new tournament scope.
|
||||
*/
|
||||
subscribe(tournamentID: string): void {
|
||||
const wasConnected = this._state === 'connected';
|
||||
this.tournamentID = tournamentID;
|
||||
|
||||
if (wasConnected && this.token) {
|
||||
this.disconnect();
|
||||
this.connect(this.token, tournamentID);
|
||||
}
|
||||
}
|
||||
|
||||
/** Disconnect from the WebSocket server. */
|
||||
disconnect(): void {
|
||||
this.intentionalClose = true;
|
||||
this.clearReconnectTimer();
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close(1000, 'client disconnect');
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.setState('disconnected');
|
||||
}
|
||||
|
||||
/** Send a JSON message to the server. */
|
||||
send(message: Record<string, unknown>): void {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
} else {
|
||||
console.warn('ws: cannot send, not connected');
|
||||
}
|
||||
}
|
||||
|
||||
/** Register a message handler. Returns unsubscribe function. */
|
||||
onMessage(handler: MessageHandler): () => void {
|
||||
this.messageHandlers.push(handler);
|
||||
return () => {
|
||||
this.messageHandlers = this.messageHandlers.filter((h) => h !== handler);
|
||||
};
|
||||
}
|
||||
|
||||
/** Register a connection state change handler. Returns unsubscribe function. */
|
||||
onStateChange(handler: StateHandler): () => void {
|
||||
this.stateHandlers.push(handler);
|
||||
return () => {
|
||||
this.stateHandlers = this.stateHandlers.filter((h) => h !== handler);
|
||||
};
|
||||
}
|
||||
|
||||
private doConnect(): void {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.setState('connecting');
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(this.url);
|
||||
} catch (err) {
|
||||
console.error('ws: failed to create WebSocket:', err);
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('ws: connected');
|
||||
this.setState('connected');
|
||||
this.reconnectDelay = MIN_RECONNECT_DELAY; // Reset backoff on success
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const msg: WSMessage = JSON.parse(event.data as string);
|
||||
for (const handler of this.messageHandlers) {
|
||||
handler(msg);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('ws: failed to parse message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = (event: CloseEvent) => {
|
||||
console.log(`ws: closed (code=${event.code}, reason=${event.reason})`);
|
||||
this.ws = null;
|
||||
|
||||
if (!this.intentionalClose) {
|
||||
this.scheduleReconnect();
|
||||
} else {
|
||||
this.setState('disconnected');
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (event: Event) => {
|
||||
console.error('ws: error:', event);
|
||||
// onclose will fire after onerror, which handles reconnect
|
||||
};
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
this.setState('reconnecting');
|
||||
|
||||
this.clearReconnectTimer();
|
||||
|
||||
console.log(`ws: reconnecting in ${this.reconnectDelay}ms`);
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.doConnect();
|
||||
}, this.reconnectDelay);
|
||||
|
||||
// Exponential backoff with cap
|
||||
this.reconnectDelay = Math.min(this.reconnectDelay * BACKOFF_MULTIPLIER, MAX_RECONNECT_DELAY);
|
||||
}
|
||||
|
||||
private clearReconnectTimer(): void {
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private setState(state: ConnectionState): void {
|
||||
if (this._state !== state) {
|
||||
this._state = state;
|
||||
for (const handler of this.stateHandlers) {
|
||||
handler(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton WebSocket client instance. */
|
||||
export const wsClient = new WebSocketClient();
|
||||
7
frontend/src/routes/+layout.svelte
Normal file
7
frontend/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
2
frontend/src/routes/+layout.ts
Normal file
2
frontend/src/routes/+layout.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const prerender = true;
|
||||
export const ssr = false; // SPA mode, no SSR
|
||||
45
frontend/src/routes/+page.svelte
Normal file
45
frontend/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<script lang="ts">
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
onMount(() => {
|
||||
if (!auth.isAuthenticated) {
|
||||
goto('/login');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if auth.isAuthenticated}
|
||||
<main class="container">
|
||||
<h1>Felt</h1>
|
||||
<p class="text-secondary">Tournament management system</p>
|
||||
<p>Welcome, {auth.operator?.name ?? 'Operator'}</p>
|
||||
</main>
|
||||
{:else}
|
||||
<main class="container">
|
||||
<p>Redirecting to login...</p>
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100dvh;
|
||||
padding: 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
302
frontend/src/routes/login/+page.svelte
Normal file
302
frontend/src/routes/login/+page.svelte
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
<script lang="ts">
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
// PIN state
|
||||
let pin = $state('');
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
const MAX_PIN_LENGTH = 6;
|
||||
|
||||
// Redirect if already logged in
|
||||
onMount(() => {
|
||||
if (auth.isAuthenticated) {
|
||||
goto('/');
|
||||
}
|
||||
});
|
||||
|
||||
function appendDigit(digit: string): void {
|
||||
if (pin.length >= MAX_PIN_LENGTH) return;
|
||||
pin += digit;
|
||||
error = '';
|
||||
}
|
||||
|
||||
function deleteDigit(): void {
|
||||
pin = pin.slice(0, -1);
|
||||
error = '';
|
||||
}
|
||||
|
||||
function clearPin(): void {
|
||||
pin = '';
|
||||
error = '';
|
||||
}
|
||||
|
||||
async function submitPin(): Promise<void> {
|
||||
if (pin.length < 4 || loading) return;
|
||||
|
||||
loading = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
const result = await api.post<{
|
||||
token: string;
|
||||
operator: { id: string; name: string; role: string };
|
||||
}>('/auth/login', { pin });
|
||||
|
||||
auth.login(result.token, {
|
||||
id: result.operator.id,
|
||||
name: result.operator.name,
|
||||
role: result.operator.role as 'admin' | 'floor' | 'viewer'
|
||||
});
|
||||
|
||||
await goto('/');
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.status === 429) {
|
||||
error = 'Too many attempts. Please wait.';
|
||||
} else if (err.status === 401) {
|
||||
error = 'Invalid PIN. Try again.';
|
||||
} else {
|
||||
error = err.message;
|
||||
}
|
||||
} else {
|
||||
error = 'Connection error. Check your network.';
|
||||
}
|
||||
pin = '';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Submit on Enter key
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
if (event.key >= '0' && event.key <= '9') {
|
||||
appendDigit(event.key);
|
||||
} else if (event.key === 'Backspace') {
|
||||
deleteDigit();
|
||||
} else if (event.key === 'Enter') {
|
||||
submitPin();
|
||||
} else if (event.key === 'Escape') {
|
||||
clearPin();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<main class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="logo">
|
||||
<h1>Felt</h1>
|
||||
<p class="subtitle">Tournament Manager</p>
|
||||
</div>
|
||||
|
||||
<!-- PIN dots display -->
|
||||
<div class="pin-display" role="status" aria-label="PIN entered: {pin.length} digits">
|
||||
{#each Array(MAX_PIN_LENGTH) as _, i}
|
||||
<div class="pin-dot" class:filled={i < pin.length}></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
{#if error}
|
||||
<div class="error-message" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Number pad -->
|
||||
<div class="numpad">
|
||||
{#each ['1', '2', '3', '4', '5', '6', '7', '8', '9'] as digit}
|
||||
<button
|
||||
class="numpad-btn touch-target"
|
||||
onclick={() => appendDigit(digit)}
|
||||
disabled={loading || pin.length >= MAX_PIN_LENGTH}
|
||||
aria-label="Digit {digit}"
|
||||
>
|
||||
{digit}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<button
|
||||
class="numpad-btn numpad-fn touch-target"
|
||||
onclick={clearPin}
|
||||
disabled={loading}
|
||||
aria-label="Clear PIN"
|
||||
>
|
||||
CLR
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="numpad-btn touch-target"
|
||||
onclick={() => appendDigit('0')}
|
||||
disabled={loading || pin.length >= MAX_PIN_LENGTH}
|
||||
aria-label="Digit 0"
|
||||
>
|
||||
0
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="numpad-btn numpad-fn touch-target"
|
||||
onclick={deleteDigit}
|
||||
disabled={loading || pin.length === 0}
|
||||
aria-label="Delete last digit"
|
||||
>
|
||||
DEL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Submit button -->
|
||||
<button
|
||||
class="submit-btn touch-target"
|
||||
onclick={submitPin}
|
||||
disabled={pin.length < 4 || loading}
|
||||
>
|
||||
{#if loading}
|
||||
Signing in...
|
||||
{:else}
|
||||
Sign In
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.login-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100dvh;
|
||||
padding: var(--space-4);
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.logo {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
font-size: var(--text-4xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
/* PIN dots */
|
||||
.pin-display {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) 0;
|
||||
}
|
||||
|
||||
.pin-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 2px solid var(--color-surface-active);
|
||||
background-color: transparent;
|
||||
transition: background-color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.pin-dot.filled {
|
||||
background-color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.error-message {
|
||||
color: var(--color-error);
|
||||
font-size: var(--text-sm);
|
||||
text-align: center;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background-color: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Number pad */
|
||||
.numpad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.numpad-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 64px;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.numpad-btn:hover:not(:disabled) {
|
||||
background-color: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.numpad-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.numpad-fn {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Submit button */
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-bg);
|
||||
background-color: var(--color-primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
</style>
|
||||
BIN
frontend/static/favicon.png
Normal file
BIN
frontend/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
17
frontend/svelte.config.js
Normal file
17
frontend/svelte.config.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import adapter from '@sveltejs/adapter-static';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
pages: 'build',
|
||||
assets: 'build',
|
||||
fallback: 'index.html', // SPA fallback
|
||||
precompress: false,
|
||||
strict: true
|
||||
}),
|
||||
paths: { base: '' }
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
14
frontend/tsconfig.json
Normal file
14
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
6
frontend/vite.config.ts
Normal file
6
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue