aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Tom van der Lee <tom@vanderlee.io>2026-07-30 16:03:32 +0200
committerGravatar Tom van der Lee <tom@vanderlee.io>2026-07-30 16:03:32 +0200
commit2d22158dd95f76399f5716d556be60c77da04a46 (patch)
tree15fca3adae0f44674fe06bc141879804659c3664
parentaa56bc05285981d57c5adb2cee3c4e898fb2a702 (diff)
downloadserver-2d22158dd95f76399f5716d556be60c77da04a46.tar.gz
server-2d22158dd95f76399f5716d556be60c77da04a46.tar.bz2
server-2d22158dd95f76399f5716d556be60c77da04a46.zip
Admin ui
-rw-r--r--authentication/endpoints.py10
-rw-r--r--authentication/templates/base.html18
-rw-r--r--authentication/templates/login.html21
-rw-r--r--authentication/templates/register.html33
-rw-r--r--management/endpoints.py22
-rw-r--r--management/templates/base.html17
-rw-r--r--management/templates/dashboard.html16
-rw-r--r--management/templates/users.html32
-rw-r--r--templates/_styles.html539
-rw-r--r--templates/global.html19
-rw-r--r--templates/macros.html49
11 files changed, 722 insertions, 54 deletions
diff --git a/authentication/endpoints.py b/authentication/endpoints.py
index 6877383..3af49fc 100644
--- a/authentication/endpoints.py
+++ b/authentication/endpoints.py
@@ -6,6 +6,7 @@ from uuid import uuid7
6 6
7from fastapi import FastAPI, Request, Form, Query 7from fastapi import FastAPI, Request, Form, Query
8from pydantic import BaseModel, ValidationError, model_validator, EmailStr 8from pydantic import BaseModel, ValidationError, model_validator, EmailStr
9from sqlalchemy import func
9from sqlmodel import select 10from sqlmodel import select
10from starlette.authentication import requires 11from starlette.authentication import requires
11from starlette.middleware.authentication import AuthenticationMiddleware 12from starlette.middleware.authentication import AuthenticationMiddleware
@@ -106,10 +107,17 @@ class RegisterForm(BaseModel):
106 return self 107 return self
107 108
108 109
110def is_first_run() -> bool:
111 with get_session_context() as session:
112 user_count = session.exec(select(func.count(User.id)))
113 return user_count.first() == 0
114
115
109async def base_register(request: Request, form: RegisterForm | None = None, errors: dict[str, str] | None = None): 116async def base_register(request: Request, form: RegisterForm | None = None, errors: dict[str, str] | None = None):
110 return templates.TemplateResponse(request, 'register.html', context={ 117 return templates.TemplateResponse(request, 'register.html', context={
111 'form': form, 118 'form': form,
112 'errors': errors, 119 'errors': errors,
120 'is_first_run': is_first_run(),
113 }) 121 })
114 122
115 123
@@ -145,7 +153,7 @@ async def post_register(request: Request, email: Annotated[str, Form()] = '', pa
145 return RedirectResponse(next, status_code=303) 153 return RedirectResponse(next, status_code=303)
146 154
147 except ValidationError as exc: 155 except ValidationError as exc:
148 errors.update({e['loc'][0]: e['msg'] for e in exc.errors()}) 156 errors.update({(e['loc'][0] if e['loc'] else 'verify_password'): e['msg'] for e in exc.errors()})
149 157
150 return await base_register(request, form, errors) 158 return await base_register(request, form, errors)
151 159
diff --git a/authentication/templates/base.html b/authentication/templates/base.html
index 9874313..ae6cb64 100644
--- a/authentication/templates/base.html
+++ b/authentication/templates/base.html
@@ -1,4 +1,20 @@
1{% extends 'global.html' %} 1{% extends 'global.html' %}
2{% import 'macros.html' as ui %}
2{% block template %} 3{% block template %}
3 {% block content %}{% endblock %} 4<div class="auth-page">
5 <div class="auth-shell">
6 <div class="auth-hero">
7 <div class="auth-hero__brand">{{ ui.connector_icon(20) }} ttun</div>
8 <div class="auth-hero__diagram">
9 <span class="mono">local:3000</span>
10 <span class="connector">{{ ui.connector_icon(20) }}</span>
11 <span class="mono">your-app.ttun.dev</span>
12 </div>
13 <p class="auth-hero__tagline">Self-hosted tunnel proxy. Expose a local port through a public URL you control.</p>
14 </div>
15 <div class="auth-form">
16 {% block content %}{% endblock %}
17 </div>
18 </div>
19</div>
4{% endblock %} 20{% endblock %}
diff --git a/authentication/templates/login.html b/authentication/templates/login.html
index 2db2512..dcc0efd 100644
--- a/authentication/templates/login.html
+++ b/authentication/templates/login.html
@@ -1,18 +1,13 @@
1{% extends "./base.html" %} 1{% extends "./base.html" %}
2{% import 'macros.html' as ui %}
2 3
3{% block content %} 4{% block content %}
5 <h1>Sign in</h1>
6 <p class="auth-form__intro">Enter your credentials to manage this server.</p>
4 <form method="post"> 7 <form method="post">
5 8 {{ ui.field('username', label='Username', value=form.username if form else '', error=errors.username if errors else '') }}
6 <input type="text" name="username" {% if form %}value="{{ form.username }}"{% endif %} placeholder="username" /> 9 {{ ui.field('password', type='password', label='Password', error=errors.password if errors else '') }}
7 {% if errors and errors.username %} 10 {{ ui.button('Sign in') }}
8 {{ errors.username }}
9 {% endif %}
10
11
12 <input type="password" name="password" />
13 {% if errors and errors.password%}
14 {{ errors.password }}
15 {% endif %}
16 <button type="submit">Login</button>
17 </form> 11 </form>
18{% endblock %} 12 <p class="auth-switch">New here? <a href="/auth/register/">Create an account</a></p>
13{% endblock %}
diff --git a/authentication/templates/register.html b/authentication/templates/register.html
index 576db78..a1959eb 100644
--- a/authentication/templates/register.html
+++ b/authentication/templates/register.html
@@ -1,24 +1,19 @@
1{% extends "./base.html" %} 1{% extends "./base.html" %}
2{% import 'macros.html' as ui %}
2 3
3{% block content %} 4{% block content %}
5 {% if is_first_run %}
6 <h1>Set up your ttun server</h1>
7 <p class="auth-form__intro">This will be the first account, with full access to manage this server.</p>
8 {% else %}
9 <h1>Create your account</h1>
10 <p class="auth-form__intro">This account will be able to sign in and manage this server.</p>
11 {% endif %}
4 <form method="post"> 12 <form method="post">
5 13 {{ ui.field('email', type='email', label='Email', value=form.email if form else '', error=errors.email if errors else '') }}
6 <input type="email" name="email" {% if form %}value="{{ form.email }}"{% endif %} placeholder="email" /> 14 {{ ui.field('password', type='password', label='Password', error=errors.password if errors else '') }}
7 {% if errors and errors.email%} 15 {{ ui.field('verify_password', type='password', label='Confirm password', error=errors.verify_password if errors else '') }}
8 {{ errors.email }} 16 {{ ui.button('Create account') }}
9 {% endif %}
10
11
12 <input type="password" name="password" />
13 {% if errors and errors.password%}
14 {{ errors.password }}
15 {% endif %}
16
17 <input type="password" name="verify_password" />
18 {% if errors and errors.password%}
19 {{ errors.verify_password }}
20 {% endif %}
21
22 <button type="submit">Register</button>
23 </form> 17 </form>
24{% endblock %} 18 <p class="auth-switch">Already have an account? <a href="/auth/login/">Sign in</a></p>
19{% endblock %}
diff --git a/management/endpoints.py b/management/endpoints.py
index 47602d6..ef44114 100644
--- a/management/endpoints.py
+++ b/management/endpoints.py
@@ -1,7 +1,9 @@
1from functools import wraps
2from urllib.parse import urlencode
3
1from fastapi import FastAPI, Request 4from fastapi import FastAPI, Request
2from sqlalchemy import func 5from sqlalchemy import func
3from sqlmodel import select 6from sqlmodel import select
4from starlette.authentication import requires
5from starlette.middleware.authentication import AuthenticationMiddleware 7from starlette.middleware.authentication import AuthenticationMiddleware
6from starlette.middleware.sessions import SessionMiddleware 8from starlette.middleware.sessions import SessionMiddleware
7from starlette.responses import RedirectResponse 9from starlette.responses import RedirectResponse
@@ -21,6 +23,20 @@ templates = Jinja2Templates(directory=[
21 'management/templates' 23 'management/templates'
22]) 24])
23 25
26
27def requires_session(func):
28 """Redirects to an absolute login URL instead of Starlette's `requires(redirect=...)`,
29 which resolves the redirect target via `request.url_for()` against whichever router is
30 bound into `request.scope` -- a lookup that only reaches `authentication`'s routes when
31 `management` happens to be mounted under a shared root app."""
32 @wraps(func)
33 async def wrapper(request: Request, *args, **kwargs):
34 if not {'authenticated', 'session'}.issubset(request.auth.scopes):
35 next_param = urlencode({'next': str(request.url)})
36 return RedirectResponse(f'/auth/login/?{next_param}', status_code=303)
37 return await func(request, *args, **kwargs)
38 return wrapper
39
24@management.get('/') 40@management.get('/')
25async def index(request: Request): 41async def index(request: Request):
26 with get_session_context() as session: 42 with get_session_context() as session:
@@ -34,12 +50,12 @@ async def index(request: Request):
34 50
35 51
36@management.get('/dashboard/') 52@management.get('/dashboard/')
37@requires(['authenticated', 'session'], redirect='get_login') 53@requires_session
38async def dashboard(request: Request): 54async def dashboard(request: Request):
39 return templates.TemplateResponse(request, 'dashboard.html') 55 return templates.TemplateResponse(request, 'dashboard.html')
40 56
41@management.get('/users/') 57@management.get('/users/')
42@requires(['authenticated', 'session'], redirect='get_login') 58@requires_session
43async def users(request: Request): 59async def users(request: Request):
44 with get_session_context() as session: 60 with get_session_context() as session:
45 query = select(User) 61 query = select(User)
diff --git a/management/templates/base.html b/management/templates/base.html
index 0b9da11..7ba0adb 100644
--- a/management/templates/base.html
+++ b/management/templates/base.html
@@ -1,7 +1,18 @@
1{% extends 'global.html' %} 1{% extends 'global.html' %}
2{% import 'macros.html' as ui %}
2{% block template %} 3{% block template %}
3 <nav> 4 <nav class="nav">
4 <a href="/auth/logout/">Logout</a> 5 <div class="nav__brand">{{ ui.connector_icon(18) }} ttun</div>
6 <div class="nav__links">
7 <a class="nav__link{% if request.url.path == '/management/dashboard/' %} nav__link--active{% endif %}" href="/management/dashboard/">Dashboard</a>
8 <a class="nav__link{% if request.url.path == '/management/users/' %} nav__link--active{% endif %}" href="/management/users/">Users</a>
9 </div>
10 <div class="nav__actions">
11 {{ ui.status_badge('Session active', 'good') }}
12 <a class="nav__link" href="/auth/logout/">Sign out</a>
13 </div>
5 </nav> 14 </nav>
6 {% block content %}{% endblock %} 15 <div class="content">
16 {% block content %}{% endblock %}
17 </div>
7{% endblock %} 18{% endblock %}
diff --git a/management/templates/dashboard.html b/management/templates/dashboard.html
index 201aa48..4babba9 100644
--- a/management/templates/dashboard.html
+++ b/management/templates/dashboard.html
@@ -1,9 +1,13 @@
1{% extends 'base.html' %} 1{% extends 'base.html' %}
2
3{% block content %} 2{% block content %}
4<div> 3 <div class="content__header">
5 <ul> 4 <span class="eyebrow">Dashboard</span>
6 <li><a href="/management/users/">Users</a></li> 5 <h1>Manage this server</h1>
7 </ul> 6 </div>
8</div> 7 <div class="module-grid">
8 <a class="module-card" href="/management/users/">
9 <div class="module-card__title">Users</div>
10 <p class="module-card__desc">Manage who can sign in to this server.</p>
11 </a>
12 </div>
9{% endblock %} 13{% endblock %}
diff --git a/management/templates/users.html b/management/templates/users.html
index 272d2b4..3cf9103 100644
--- a/management/templates/users.html
+++ b/management/templates/users.html
@@ -1,11 +1,27 @@
1{% extends 'base.html' %} 1{% extends 'base.html' %}
2
3{% block content %} 2{% block content %}
4<div> 3 <div class="content__header">
5 <ul> 4 <span class="eyebrow">{{ users|length }} account{{ 's' if users|length != 1 else '' }}</span>
6 {% for user in users %} 5 <h1>Users</h1>
7 <li>{{ user.username }}</li> 6 </div>
8 {% endfor %} 7 <div class="table-wrap">
9 </ul> 8 <table>
10</div> 9 <thead>
10 <tr>
11 <th>Username</th>
12 <th>Email</th>
13 <th>Created</th>
14 </tr>
15 </thead>
16 <tbody>
17 {% for user in users %}
18 <tr>
19 <td>{{ user.username }}</td>
20 <td>{{ user.email }}</td>
21 <td class="muted">{{ user.created.strftime('%Y-%m-%d %H:%M') }}</td>
22 </tr>
23 {% endfor %}
24 </tbody>
25 </table>
26 </div>
11{% endblock %} 27{% endblock %}
diff --git a/templates/_styles.html b/templates/_styles.html
new file mode 100644
index 0000000..f68b9c0
--- /dev/null
+++ b/templates/_styles.html
@@ -0,0 +1,539 @@
1:root,
2:root[data-theme="dark"] {
3 --bg: #12161c;
4 --surface: #1a2029;
5 --surface-raised: #212836;
6 --border: #2a323d;
7 --text: #e7ebf1;
8 --text-muted: #8891a0;
9 --accent: #f5a623;
10 --accent-strong: #ffbb47;
11 --accent-ink: #1a1305;
12 --status-good: #3ddc84;
13 --status-bad: #e5484d;
14 color-scheme: dark;
15}
16
17:root[data-theme="light"] {
18 --bg: #eef1f6;
19 --surface: #ffffff;
20 --surface-raised: #f7f9fc;
21 --border: #d7dce3;
22 --text: #12161c;
23 --text-muted: #5b6472;
24 --accent: #b9750f;
25 --accent-strong: #8f5c0c;
26 --accent-ink: #fff8ec;
27 --status-good: #1e9a5a;
28 --status-bad: #c53328;
29 color-scheme: light;
30}
31
32*,
33*::before,
34*::after {
35 box-sizing: border-box;
36}
37
38html {
39 -webkit-text-size-adjust: 100%;
40}
41
42body {
43 background: var(--bg);
44 color: var(--text);
45 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
46 font-size: 15px;
47 line-height: 1.6;
48 min-height: 100vh;
49}
50
51body,
52.pulse-line,
53.theme-toggle,
54.field__input,
55.btn,
56.module-card {
57 transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
58}
59
60.mono {
61 font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
62}
63
64.eyebrow {
65 font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
66 font-size: 11px;
67 letter-spacing: 0.08em;
68 text-transform: uppercase;
69 color: var(--text-muted);
70}
71
72.muted {
73 color: var(--text-muted);
74}
75
76.small {
77 font-size: 13px;
78}
79
80a {
81 color: var(--accent);
82 text-decoration: none;
83}
84
85a:hover {
86 text-decoration: underline;
87}
88
89h1 {
90 font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
91 font-size: 26px;
92 font-weight: 600;
93 letter-spacing: -0.01em;
94}
95
96h2 {
97 font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
98 font-size: 18px;
99 font-weight: 600;
100}
101
102/* signature element: one-time handshake pulse on load */
103.pulse-line {
104 position: fixed;
105 top: 0;
106 left: 0;
107 right: 0;
108 height: 2px;
109 z-index: 40;
110 background: linear-gradient(90deg, transparent, var(--accent), transparent);
111 background-size: 200% 100%;
112 animation: pulse-sweep 1.4s ease-out 1;
113}
114
115@keyframes pulse-sweep {
116 from { background-position: 160% 0; }
117 to { background-position: -60% 0; }
118}
119
120/* theme toggle */
121.theme-toggle {
122 position: fixed;
123 top: 16px;
124 right: 16px;
125 z-index: 50;
126 display: flex;
127 align-items: center;
128 justify-content: center;
129 width: 36px;
130 height: 36px;
131 border-radius: 8px;
132 border: 1px solid var(--border);
133 background: var(--surface);
134 color: var(--text-muted);
135 cursor: pointer;
136}
137
138.theme-toggle:hover {
139 color: var(--accent);
140 border-color: var(--accent);
141}
142
143.theme-toggle:focus-visible {
144 outline: 2px solid var(--accent);
145 outline-offset: 2px;
146}
147
148.theme-toggle .icon-sun { display: none; }
149.theme-toggle .icon-moon { display: block; }
150:root[data-theme="light"] .theme-toggle .icon-sun { display: block; }
151:root[data-theme="light"] .theme-toggle .icon-moon { display: none; }
152
153/* focus ring, used everywhere */
154a:focus-visible,
155button:focus-visible,
156input:focus-visible {
157 outline: 2px solid var(--accent);
158 outline-offset: 2px;
159}
160
161/* buttons */
162.btn {
163 font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
164 font-size: 14px;
165 font-weight: 600;
166 padding: 10px 18px;
167 border-radius: 8px;
168 border: 1px solid transparent;
169 cursor: pointer;
170 display: inline-flex;
171 align-items: center;
172 gap: 8px;
173}
174
175.btn--primary {
176 background: var(--accent);
177 color: var(--accent-ink);
178}
179
180.btn--primary:hover {
181 background: var(--accent-strong);
182}
183
184.btn--full {
185 width: 100%;
186 justify-content: center;
187}
188
189/* form fields */
190.field {
191 margin-bottom: 18px;
192}
193
194.field__label {
195 display: block;
196 font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
197 font-size: 11px;
198 letter-spacing: 0.08em;
199 text-transform: uppercase;
200 color: var(--text-muted);
201 margin-bottom: 6px;
202}
203
204.field__input {
205 width: 100%;
206 font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
207 font-size: 14px;
208 padding: 10px 12px;
209 background: var(--bg);
210 color: var(--text);
211 border: 1px solid var(--border);
212 border-radius: 8px;
213}
214
215.field__input:focus {
216 outline: 2px solid var(--accent);
217 outline-offset: 1px;
218 border-color: var(--accent);
219}
220
221.field__input--error {
222 border-color: var(--status-bad);
223}
224
225.field__error {
226 margin-top: 6px;
227 font-size: 13px;
228 color: var(--status-bad);
229}
230
231/* auth split-panel shell */
232.auth-page {
233 min-height: 100vh;
234 display: flex;
235 align-items: center;
236 justify-content: center;
237 padding: 32px 16px;
238}
239
240.auth-shell {
241 display: grid;
242 grid-template-columns: 1fr 1fr;
243 max-width: 880px;
244 width: 100%;
245 border: 1px solid var(--border);
246 border-radius: 16px;
247 overflow: hidden;
248}
249
250.auth-hero {
251 background: var(--surface-raised);
252 padding: 40px 32px;
253 display: flex;
254 flex-direction: column;
255 justify-content: space-between;
256 gap: 32px;
257 border-right: 1px solid var(--border);
258}
259
260.auth-hero__brand {
261 display: flex;
262 align-items: center;
263 gap: 10px;
264 color: var(--accent);
265 font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
266 font-size: 18px;
267 font-weight: 600;
268}
269
270.auth-hero__diagram {
271 display: flex;
272 align-items: center;
273 gap: 10px;
274 font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
275 font-size: 13px;
276 color: var(--text);
277}
278
279.auth-hero__diagram .connector {
280 color: var(--accent);
281 flex: 1;
282}
283
284.auth-hero__diagram span.mono {
285 background: var(--surface);
286 border: 1px solid var(--border);
287 padding: 4px 8px;
288 border-radius: 6px;
289 white-space: nowrap;
290}
291
292.auth-hero__tagline {
293 font-size: 14px;
294 color: var(--text-muted);
295 line-height: 1.5;
296}
297
298.auth-form {
299 background: var(--surface);
300 padding: 40px 32px;
301 display: flex;
302 flex-direction: column;
303 justify-content: center;
304}
305
306.auth-form h1 {
307 margin-bottom: 4px;
308}
309
310.auth-form__intro {
311 color: var(--text-muted);
312 font-size: 14px;
313 margin-bottom: 24px;
314}
315
316.auth-switch {
317 margin-top: 16px;
318 font-size: 13px;
319 color: var(--text-muted);
320}
321
322/* management top bar */
323.nav {
324 display: flex;
325 align-items: center;
326 justify-content: space-between;
327 gap: 16px;
328 padding: 14px 24px;
329 background: var(--surface-raised);
330 border-bottom: 1px solid var(--border);
331 flex-wrap: wrap;
332}
333
334.nav__brand {
335 display: flex;
336 align-items: center;
337 gap: 8px;
338 color: var(--accent);
339 font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
340 font-weight: 600;
341 font-size: 16px;
342}
343
344.nav__links {
345 display: flex;
346 align-items: center;
347 gap: 20px;
348}
349
350.nav__link {
351 font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
352 font-size: 12px;
353 letter-spacing: 0.05em;
354 text-transform: uppercase;
355 color: var(--text-muted);
356 padding-bottom: 2px;
357 border-bottom: 2px solid transparent;
358}
359
360.nav__link:hover {
361 color: var(--text);
362 text-decoration: none;
363}
364
365.nav__link--active {
366 color: var(--accent);
367 border-bottom-color: var(--accent);
368}
369
370.nav__actions {
371 display: flex;
372 align-items: center;
373 gap: 20px;
374 margin-left: auto;
375}
376
377.nav__status {
378 display: flex;
379 align-items: center;
380 gap: 8px;
381}
382
383.status-dot {
384 width: 8px;
385 height: 8px;
386 border-radius: 50%;
387 display: inline-block;
388}
389
390.status-dot--good {
391 background: var(--status-good);
392 box-shadow: 0 0 0 3px color-mix(in srgb, var(--status-good) 25%, transparent);
393}
394
395.status-dot--bad {
396 background: var(--status-bad);
397 box-shadow: 0 0 0 3px color-mix(in srgb, var(--status-bad) 25%, transparent);
398}
399
400/* content area */
401.content {
402 max-width: 960px;
403 margin: 0 auto;
404 padding: 40px 24px;
405}
406
407.content__header {
408 margin-bottom: 28px;
409}
410
411.content__header h1 {
412 margin-top: 4px;
413}
414
415/* dashboard module grid */
416.module-grid {
417 display: grid;
418 grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
419 gap: 16px;
420}
421
422.module-card {
423 display: block;
424 background: var(--surface);
425 border: 1px solid var(--border);
426 border-radius: 12px;
427 padding: 20px;
428}
429
430.module-card:hover {
431 border-color: var(--accent);
432 background: var(--surface-raised);
433 text-decoration: none;
434}
435
436.module-card__title {
437 font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
438 font-size: 15px;
439 font-weight: 600;
440 color: var(--text);
441 margin-bottom: 6px;
442}
443
444.module-card__desc {
445 font-size: 13px;
446 color: var(--text-muted);
447 line-height: 1.5;
448}
449
450.module-card__meta {
451 margin-top: 12px;
452 font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
453 font-size: 12px;
454 color: var(--accent);
455}
456
457/* data table */
458.table-wrap {
459 overflow-x: auto;
460 border: 1px solid var(--border);
461 border-radius: 12px;
462}
463
464table {
465 width: 100%;
466 border-collapse: collapse;
467}
468
469thead th {
470 text-align: left;
471 font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
472 font-size: 11px;
473 letter-spacing: 0.08em;
474 text-transform: uppercase;
475 color: var(--text-muted);
476 padding: 12px 16px;
477 border-bottom: 1px solid var(--border);
478 background: var(--surface-raised);
479}
480
481tbody td {
482 padding: 12px 16px;
483 border-bottom: 1px solid var(--border);
484 font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
485 font-size: 13px;
486}
487
488tbody tr:last-child td {
489 border-bottom: none;
490}
491
492tbody tr:hover {
493 background: var(--surface-raised);
494}
495
496@media (max-width: 720px) {
497 .auth-shell {
498 grid-template-columns: 1fr;
499 }
500
501 .auth-hero {
502 border-right: none;
503 border-bottom: 1px solid var(--border);
504 padding: 24px;
505 }
506
507 .auth-hero__diagram {
508 display: none;
509 }
510
511 .auth-form {
512 padding: 24px;
513 }
514
515 .nav {
516 gap: 12px;
517 }
518
519 .nav__actions {
520 margin-left: 0;
521 }
522}
523
524@media (prefers-reduced-motion: reduce) {
525 .pulse-line {
526 animation: none;
527 background: var(--accent);
528 opacity: 0.6;
529 }
530
531 body,
532 .pulse-line,
533 .theme-toggle,
534 .field__input,
535 .btn,
536 .module-card {
537 transition: none;
538 }
539}
diff --git a/templates/global.html b/templates/global.html
index d9bd29d..97212df 100644
--- a/templates/global.html
+++ b/templates/global.html
@@ -1,8 +1,17 @@
1{% import 'macros.html' as ui %}
1<!DOCTYPE html> 2<!DOCTYPE html>
2<html lang="en"> 3<html lang="en">
3<head> 4<head>
4 <meta charset="UTF-8"> 5 <meta charset="UTF-8">
6 <meta name="viewport" content="width=device-width, initial-scale=1">
5 <title>TTUN</title> 7 <title>TTUN</title>
8 <script>
9 (function () {
10 var stored = localStorage.getItem('ttun-theme');
11 var theme = stored || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
12 document.documentElement.setAttribute('data-theme', theme);
13 })();
14 </script>
6 <style> 15 <style>
7/* http://meyerweb.com/eric/tools/css/reset/ 16/* http://meyerweb.com/eric/tools/css/reset/
8 v2.0 | 20110126 17 v2.0 | 20110126
@@ -52,9 +61,19 @@ table {
52 border-collapse: collapse; 61 border-collapse: collapse;
53 border-spacing: 0; 62 border-spacing: 0;
54} 63}
64{% include '_styles.html' %}
55 </style> 65 </style>
56</head> 66</head>
57<body> 67<body>
68 <div class="pulse-line" aria-hidden="true"></div>
69 <button class="theme-toggle" type="button" aria-label="Toggle color theme" onclick="
70 var next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
71 document.documentElement.setAttribute('data-theme', next);
72 localStorage.setItem('ttun-theme', next);
73 ">
74 {{ ui.sun_icon() }}
75 {{ ui.moon_icon() }}
76 </button>
58 {% block template %}{% endblock %} 77 {% block template %}{% endblock %}
59</body> 78</body>
60</html> 79</html>
diff --git a/templates/macros.html b/templates/macros.html
new file mode 100644
index 0000000..7c35f5e
--- /dev/null
+++ b/templates/macros.html
@@ -0,0 +1,49 @@
1{% macro connector_icon(size=16) %}
2<svg width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" aria-hidden="true">
3 <circle cx="4" cy="12" r="3" fill="currentColor" />
4 <line x1="7" y1="12" x2="17" y2="12" stroke="currentColor" stroke-width="2" stroke-dasharray="1 4" stroke-linecap="round" />
5 <circle cx="20" cy="12" r="3" fill="currentColor" />
6</svg>
7{% endmacro %}
8
9{% macro sun_icon(size=16) %}
10<svg class="icon-sun" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" aria-hidden="true">
11 <circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="2" />
12 <path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
13</svg>
14{% endmacro %}
15
16{% macro moon_icon(size=16) %}
17<svg class="icon-moon" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" aria-hidden="true">
18 <path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
19</svg>
20{% endmacro %}
21
22{% macro field(name, type='text', label='', value='', error='', placeholder='') %}
23<div class="field">
24 <label class="field__label" for="field-{{ name }}">{{ label }}</label>
25 <input
26 class="field__input{% if error %} field__input--error{% endif %}"
27 type="{{ type }}"
28 id="field-{{ name }}"
29 name="{{ name }}"
30 value="{{ value or '' }}"
31 placeholder="{{ placeholder }}"
32 {% if error %}aria-invalid="true" aria-describedby="field-{{ name }}-error"{% endif %}
33 />
34 {% if error %}
35 <p class="field__error" id="field-{{ name }}-error">{{ error }}</p>
36 {% endif %}
37</div>
38{% endmacro %}
39
40{% macro button(text, variant='primary', type='submit') %}
41<button class="btn btn--{{ variant }} btn--full" type="{{ type }}">{{ text }}</button>
42{% endmacro %}
43
44{% macro status_badge(label, variant='good') %}
45<span class="nav__status">
46 <span class="status-dot status-dot--{{ variant }}" aria-hidden="true"></span>
47 <span class="mono small muted">{{ label }}</span>
48</span>
49{% endmacro %}