@astratra/saas-kit-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Astratra
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # @astratra/saas-kit-ui
2
+
3
+ Interface React reutilisable pour demarrer rapidement un dashboard SaaS
4
+ Astratra. Le package expose une application dashboard complete et quelques
5
+ primitives (`AuthProvider`, `useAuth`, `apiFetch`) pour composer une interface
6
+ plus specifique.
7
+
8
+ ## Installation
9
+
10
+ ```bash
11
+ npm install @astratra/saas-kit-ui react react-dom
12
+ ```
13
+
14
+ Dans le repo Astratra, `examples/dashboard-ui` montre son integration avec un
15
+ backend `@astratra/saas-kit`.
16
+
17
+ ## Utilisation
18
+
19
+ ```jsx
20
+ import React from 'react';
21
+ import { createRoot } from 'react-dom/client';
22
+ import { AstratraDashboardApp } from '@astratra/saas-kit-ui';
23
+
24
+ createRoot(document.getElementById('root')).render(
25
+ <React.StrictMode>
26
+ <AstratraDashboardApp />
27
+ </React.StrictMode>
28
+ );
29
+ ```
30
+
31
+ Par defaut, l'interface appelle `http://localhost:4000`. Avec Vite, definis
32
+ `VITE_API_URL` pour pointer vers ton backend :
33
+
34
+ ```bash
35
+ VITE_API_URL=http://localhost:4000 npm run dev
36
+ ```
37
+
38
+ ## Surface exportee
39
+
40
+ - `AstratraDashboardApp` : dashboard complet login/users/settings/overview.
41
+ - `AuthProvider` et `useAuth` : session bearer token en memoire.
42
+ - `apiFetch` et `ApiError` : client minimal compatible avec les reponses
43
+ `apiResponse` d'`@astratra/core`.
44
+
45
+ Le package ne contient pas de serveur Express et ne fixe aucun domaine metier.
46
+ Le backend reste fourni par `@astratra/saas-kit` ou par ton application.
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@astratra/saas-kit-ui",
3
+ "version": "0.1.0",
4
+ "description": "Reusable React dashboard shell for Astratra SaaS applications.",
5
+ "type": "module",
6
+ "main": "src/index.jsx",
7
+ "module": "src/index.jsx",
8
+ "exports": {
9
+ ".": "./src/index.jsx",
10
+ "./api": "./src/lib/api.js",
11
+ "./styles.css": "./src/styles/global.css"
12
+ },
13
+ "files": [
14
+ "src"
15
+ ],
16
+ "scripts": {
17
+ "test": "node --test __tests__/*.test.js"
18
+ },
19
+ "keywords": [
20
+ "astratra",
21
+ "react",
22
+ "dashboard",
23
+ "saas"
24
+ ],
25
+ "license": "MIT",
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "peerDependencies": {
30
+ "react": ">=18",
31
+ "react-dom": ">=18"
32
+ },
33
+ "sideEffects": [
34
+ "**/*.css"
35
+ ]
36
+ }
package/src/App.jsx ADDED
@@ -0,0 +1,38 @@
1
+ import { useState } from 'react';
2
+ import { AuthProvider, useAuth } from './auth/AuthContext.jsx';
3
+ import Layout from './components/Layout.jsx';
4
+ import Dashboard from './screens/Dashboard.jsx';
5
+ import Login from './screens/Login.jsx';
6
+ import Settings from './screens/Settings.jsx';
7
+ import Users from './screens/Users.jsx';
8
+ import './styles/global.css';
9
+
10
+ const VIEWS = {
11
+ dashboard: Dashboard,
12
+ users: Users,
13
+ settings: Settings
14
+ };
15
+
16
+ function GuardedApp() {
17
+ const [view, setView] = useState('dashboard');
18
+ const auth = useAuth();
19
+
20
+ if (!auth.isAuthenticated) {
21
+ return <Login onLoggedIn={() => setView('dashboard')} />;
22
+ }
23
+
24
+ const Screen = VIEWS[view] || Dashboard;
25
+ return (
26
+ <Layout activeView={view} onNavigate={setView}>
27
+ <Screen />
28
+ </Layout>
29
+ );
30
+ }
31
+
32
+ export default function AstratraDashboardApp() {
33
+ return (
34
+ <AuthProvider>
35
+ <GuardedApp />
36
+ </AuthProvider>
37
+ );
38
+ }
@@ -0,0 +1,9 @@
1
+ <svg width="256" height="256" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Astratra">
2
+ <rect width="48" height="48" fill="#17201b" />
3
+ <path
4
+ fill-rule="evenodd"
5
+ clip-rule="evenodd"
6
+ d="M24 8 L6 40 L42 40 Z M24 19 L16 31 L32 31 Z"
7
+ fill="#d9ff66"
8
+ />
9
+ </svg>
@@ -0,0 +1,49 @@
1
+ import { createContext, useCallback, useContext, useMemo, useState } from 'react';
2
+ import { apiFetch } from '../lib/api.js';
3
+
4
+ const AuthContext = createContext(null);
5
+
6
+ export function AuthProvider({ children }) {
7
+ const [token, setToken] = useState(null);
8
+ const [user, setUser] = useState(null);
9
+
10
+ const logout = useCallback(() => {
11
+ setToken(null);
12
+ setUser(null);
13
+ }, []);
14
+
15
+ const login = useCallback(async ({ email, password }) => {
16
+ const data = await apiFetch('/auth/login', {
17
+ method: 'POST',
18
+ body: { email, password }
19
+ });
20
+ setToken(data.token);
21
+ setUser(data.user);
22
+ return data.user;
23
+ }, []);
24
+
25
+ const request = useCallback((path, options = {}) => apiFetch(path, {
26
+ ...options,
27
+ token,
28
+ onUnauthorized: logout
29
+ }), [logout, token]);
30
+
31
+ const value = useMemo(() => ({
32
+ isAuthenticated: Boolean(token),
33
+ token,
34
+ user,
35
+ login,
36
+ logout,
37
+ request
38
+ }), [login, logout, request, token, user]);
39
+
40
+ return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
41
+ }
42
+
43
+ export function useAuth() {
44
+ const value = useContext(AuthContext);
45
+ if (!value) {
46
+ throw new Error('useAuth must be used inside AuthProvider.');
47
+ }
48
+ return value;
49
+ }
@@ -0,0 +1,53 @@
1
+ import { useAuth } from '../auth/AuthContext.jsx';
2
+ import logoUrl from '../assets/logo.svg';
3
+ import styles from './Layout.module.css';
4
+
5
+ const NAV_ITEMS = [
6
+ { id: 'dashboard', index: '01', label: 'Dashboard' },
7
+ { id: 'users', index: '02', label: 'Users' },
8
+ { id: 'settings', index: '03', label: 'Settings' }
9
+ ];
10
+
11
+ export default function Layout({ activeView, children, onNavigate }) {
12
+ const { logout, user } = useAuth();
13
+
14
+ return (
15
+ <div className={styles.shell}>
16
+ <aside className={styles.sidebar}>
17
+ <div className={styles.brand}>
18
+ <img src={logoUrl} alt="Astratra" className={styles.brandMark} />
19
+ <span>
20
+ <strong>Astratra</strong>
21
+ <small>saas_starter@v0.1.0</small>
22
+ </span>
23
+ </div>
24
+
25
+ <nav className={styles.nav} aria-label="Primary">
26
+ {NAV_ITEMS.map((item) => (
27
+ <button
28
+ key={item.id}
29
+ type="button"
30
+ className={item.id === activeView ? styles.activeNavItem : styles.navItem}
31
+ onClick={() => onNavigate(item.id)}
32
+ >
33
+ <span className={styles.navIndex}>{item.index}</span>
34
+ {item.label}
35
+ </button>
36
+ ))}
37
+ </nav>
38
+
39
+ <div className={styles.status}>
40
+ <span className={styles.statusDot} aria-hidden="true" />
41
+ session active
42
+ </div>
43
+
44
+ <div className={styles.account}>
45
+ <span>{user?.email}</span>
46
+ <small>role: {user?.role}</small>
47
+ <button type="button" onClick={logout}>[ logout ]</button>
48
+ </div>
49
+ </aside>
50
+ <main className={styles.content}>{children}</main>
51
+ </div>
52
+ );
53
+ }
@@ -0,0 +1,196 @@
1
+ .shell {
2
+ min-height: 100vh;
3
+ display: grid;
4
+ grid-template-columns: 260px minmax(0, 1fr);
5
+ background: transparent;
6
+ color: var(--cream);
7
+ }
8
+
9
+ .sidebar {
10
+ display: flex;
11
+ flex-direction: column;
12
+ gap: 24px;
13
+ padding: 24px 20px;
14
+ background: var(--panel);
15
+ border-right: 1px solid var(--line);
16
+ }
17
+
18
+ .brand {
19
+ display: flex;
20
+ align-items: center;
21
+ gap: 12px;
22
+ padding-bottom: 20px;
23
+ border-bottom: 1px solid var(--line);
24
+ }
25
+
26
+ .brandMark {
27
+ width: 38px;
28
+ height: 38px;
29
+ flex: none;
30
+ }
31
+
32
+ .brand strong,
33
+ .brand small,
34
+ .account span,
35
+ .account small {
36
+ display: block;
37
+ }
38
+
39
+ .brand strong {
40
+ font-size: 0.95rem;
41
+ letter-spacing: 0.02em;
42
+ }
43
+
44
+ .brand small,
45
+ .account small {
46
+ color: var(--muted);
47
+ margin-top: 3px;
48
+ font-size: 0.72rem;
49
+ }
50
+
51
+ .nav {
52
+ display: grid;
53
+ gap: 4px;
54
+ }
55
+
56
+ .navItem,
57
+ .activeNavItem {
58
+ display: flex;
59
+ align-items: center;
60
+ gap: 10px;
61
+ min-height: 40px;
62
+ border: 0;
63
+ border-left: 2px solid transparent;
64
+ padding: 0 12px;
65
+ text-align: left;
66
+ font: inherit;
67
+ font-size: 0.85rem;
68
+ cursor: pointer;
69
+ transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease;
70
+ }
71
+
72
+ .navIndex {
73
+ color: var(--muted);
74
+ font-size: 0.75rem;
75
+ }
76
+
77
+ .navItem {
78
+ background: transparent;
79
+ color: var(--muted);
80
+ }
81
+
82
+ .navItem:hover {
83
+ color: var(--cream);
84
+ background: var(--panel-raised);
85
+ }
86
+
87
+ .activeNavItem {
88
+ background: var(--panel-raised);
89
+ color: var(--lime);
90
+ border-left-color: var(--lime);
91
+ }
92
+
93
+ .activeNavItem .navIndex {
94
+ color: var(--lime-dim);
95
+ }
96
+
97
+ .status {
98
+ display: flex;
99
+ align-items: center;
100
+ gap: 8px;
101
+ font-size: 0.72rem;
102
+ color: var(--muted);
103
+ text-transform: uppercase;
104
+ letter-spacing: 0.05em;
105
+ }
106
+
107
+ .statusDot {
108
+ width: 7px;
109
+ height: 7px;
110
+ border-radius: 50%;
111
+ background: var(--lime);
112
+ box-shadow: 0 0 0 0 rgba(217, 255, 102, 0.6);
113
+ animation: pulse 2.2s ease-out infinite;
114
+ }
115
+
116
+ @keyframes pulse {
117
+ 0% {
118
+ box-shadow: 0 0 0 0 rgba(217, 255, 102, 0.5);
119
+ }
120
+ 70% {
121
+ box-shadow: 0 0 0 6px rgba(217, 255, 102, 0);
122
+ }
123
+ 100% {
124
+ box-shadow: 0 0 0 0 rgba(217, 255, 102, 0);
125
+ }
126
+ }
127
+
128
+ .account {
129
+ margin-top: auto;
130
+ display: grid;
131
+ gap: 10px;
132
+ padding-top: 16px;
133
+ border-top: 1px solid var(--line);
134
+ font-size: 0.82rem;
135
+ }
136
+
137
+ .account span {
138
+ overflow: hidden;
139
+ text-overflow: ellipsis;
140
+ white-space: nowrap;
141
+ }
142
+
143
+ .account button {
144
+ text-align: left;
145
+ border: 1px solid var(--line-bright);
146
+ background: transparent;
147
+ color: var(--muted);
148
+ padding: 8px 10px;
149
+ font: inherit;
150
+ font-size: 0.8rem;
151
+ }
152
+
153
+ .account button:hover {
154
+ color: var(--error);
155
+ border-color: var(--error);
156
+ }
157
+
158
+ .content {
159
+ min-width: 0;
160
+ padding: 40px;
161
+ }
162
+
163
+ @media (max-width: 760px) {
164
+ .shell {
165
+ grid-template-columns: 1fr;
166
+ }
167
+
168
+ .sidebar {
169
+ position: static;
170
+ gap: 16px;
171
+ }
172
+
173
+ .nav {
174
+ grid-template-columns: repeat(3, minmax(0, 1fr));
175
+ gap: 8px;
176
+ }
177
+
178
+ .navItem,
179
+ .activeNavItem {
180
+ border-left: 0;
181
+ border-bottom: 2px solid transparent;
182
+ justify-content: center;
183
+ }
184
+
185
+ .activeNavItem {
186
+ border-bottom-color: var(--lime);
187
+ }
188
+
189
+ .status {
190
+ display: none;
191
+ }
192
+
193
+ .content {
194
+ padding: 20px;
195
+ }
196
+ }
@@ -0,0 +1,6 @@
1
+ import styles from './Notice.module.css';
2
+
3
+ export default function Notice({ tone = 'neutral', children }) {
4
+ if (!children) return null;
5
+ return <div className={`${styles.notice} ${styles[tone]}`}>{children}</div>;
6
+ }
@@ -0,0 +1,43 @@
1
+ .notice {
2
+ position: relative;
3
+ padding: 12px 14px 12px 34px;
4
+ border: 1px solid var(--line-bright);
5
+ background: var(--panel);
6
+ color: var(--cream);
7
+ font-size: 0.82rem;
8
+ line-height: 1.5;
9
+ }
10
+
11
+ .notice::before {
12
+ content: "!";
13
+ position: absolute;
14
+ top: 12px;
15
+ left: 14px;
16
+ color: var(--muted);
17
+ }
18
+
19
+ .error {
20
+ border-color: var(--error);
21
+ background: var(--error-bg);
22
+ color: var(--cream);
23
+ }
24
+
25
+ .error::before {
26
+ content: "x";
27
+ color: var(--error);
28
+ }
29
+
30
+ .success {
31
+ border-color: var(--success);
32
+ background: var(--success-bg);
33
+ color: var(--cream);
34
+ }
35
+
36
+ .success::before {
37
+ content: ">";
38
+ color: var(--success);
39
+ }
40
+
41
+ .neutral {
42
+ border-color: var(--line-bright);
43
+ }
package/src/index.jsx ADDED
@@ -0,0 +1,9 @@
1
+ export { default, default as AstratraDashboardApp } from './App.jsx';
2
+ export { AuthProvider, useAuth } from './auth/AuthContext.jsx';
3
+ export { ApiError, apiFetch } from './lib/api.js';
4
+ export { default as Layout } from './components/Layout.jsx';
5
+ export { default as Notice } from './components/Notice.jsx';
6
+ export { default as DashboardScreen } from './screens/Dashboard.jsx';
7
+ export { default as LoginScreen } from './screens/Login.jsx';
8
+ export { default as SettingsScreen } from './screens/Settings.jsx';
9
+ export { default as UsersScreen } from './screens/Users.jsx';
package/src/lib/api.js ADDED
@@ -0,0 +1,65 @@
1
+ const DEFAULT_API_URL = 'http://localhost:4000';
2
+
3
+ export class ApiError extends Error {
4
+ constructor(message, { status, data, error } = {}) {
5
+ super(message);
6
+ this.name = 'ApiError';
7
+ this.status = status;
8
+ this.data = data;
9
+ this.error = error;
10
+ }
11
+ }
12
+
13
+ function joinUrl(apiUrl, path) {
14
+ const base = String(apiUrl || DEFAULT_API_URL).replace(/\/+$/, '');
15
+ const suffix = String(path || '').startsWith('/') ? path : `/${path}`;
16
+ return `${base}${suffix}`;
17
+ }
18
+
19
+ async function readPayload(response) {
20
+ const text = await response.text();
21
+ if (!text) return null;
22
+
23
+ try {
24
+ return JSON.parse(text);
25
+ } catch {
26
+ throw new ApiError('The API returned an invalid JSON response.', {
27
+ status: response.status,
28
+ data: text
29
+ });
30
+ }
31
+ }
32
+
33
+ export async function apiFetch(path, {
34
+ apiUrl = import.meta.env?.VITE_API_URL || DEFAULT_API_URL,
35
+ token,
36
+ method = 'GET',
37
+ body,
38
+ fetcher = fetch,
39
+ onUnauthorized
40
+ } = {}) {
41
+ const headers = {};
42
+ if (body !== undefined) headers['content-type'] = 'application/json';
43
+ if (token) headers.authorization = `Bearer ${token}`;
44
+
45
+ const response = await fetcher(joinUrl(apiUrl, path), {
46
+ method,
47
+ headers,
48
+ body: body === undefined ? undefined : JSON.stringify(body)
49
+ });
50
+ const payload = await readPayload(response);
51
+
52
+ if (response.status === 401 && typeof onUnauthorized === 'function') {
53
+ onUnauthorized();
54
+ }
55
+
56
+ if (!response.ok || payload?.success === false) {
57
+ throw new ApiError(payload?.message || payload?.error || `Request failed with status ${response.status}.`, {
58
+ status: response.status,
59
+ data: payload?.data,
60
+ error: payload?.error
61
+ });
62
+ }
63
+
64
+ return payload?.data;
65
+ }
@@ -0,0 +1,60 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { useAuth } from '../auth/AuthContext.jsx';
3
+ import Notice from '../components/Notice.jsx';
4
+ import styles from './Screen.module.css';
5
+
6
+ export default function Dashboard() {
7
+ const { request } = useAuth();
8
+ const [summary, setSummary] = useState(null);
9
+ const [error, setError] = useState('');
10
+
11
+ useEffect(() => {
12
+ let active = true;
13
+ request('/dashboard/summary')
14
+ .then((data) => {
15
+ if (active) setSummary(data);
16
+ })
17
+ .catch((apiError) => {
18
+ if (active) setError(apiError.message);
19
+ });
20
+ return () => {
21
+ active = false;
22
+ };
23
+ }, [request]);
24
+
25
+ const roles = Object.entries(summary?.roleBreakdown || {});
26
+ const maxRoleCount = Math.max(1, ...roles.map(([, count]) => count));
27
+
28
+ return (
29
+ <section className={styles.screen}>
30
+ <header className={styles.header}>
31
+ <p>Overview</p>
32
+ <h1>Dashboard</h1>
33
+ </header>
34
+ <Notice tone="error">{error}</Notice>
35
+ {!summary && !error ? <p className={styles.muted}>Loading dashboard...</p> : null}
36
+ {summary ? (
37
+ <div className={styles.grid}>
38
+ <article className={styles.statCard}>
39
+ <span>Total users</span>
40
+ <strong>{summary.userCount}</strong>
41
+ </article>
42
+ <article className={styles.wideCard}>
43
+ <span>Role breakdown</span>
44
+ <div className={styles.bars}>
45
+ {roles.map(([role, count]) => (
46
+ <div key={role} className={styles.barRow}>
47
+ <span>{role}</span>
48
+ <div className={styles.track}>
49
+ <div style={{ width: `${(count / maxRoleCount) * 100}%` }} />
50
+ </div>
51
+ <strong>{count}</strong>
52
+ </div>
53
+ ))}
54
+ </div>
55
+ </article>
56
+ </div>
57
+ ) : null}
58
+ </section>
59
+ );
60
+ }
@@ -0,0 +1,57 @@
1
+ import { useState } from 'react';
2
+ import { useAuth } from '../auth/AuthContext.jsx';
3
+ import Notice from '../components/Notice.jsx';
4
+ import styles from './Login.module.css';
5
+
6
+ export default function Login({ onLoggedIn }) {
7
+ const { login } = useAuth();
8
+ const [email, setEmail] = useState('owner@example.test');
9
+ const [password, setPassword] = useState('password');
10
+ const [error, setError] = useState('');
11
+ const [loading, setLoading] = useState(false);
12
+
13
+ async function handleSubmit(event) {
14
+ event.preventDefault();
15
+ setError('');
16
+ setLoading(true);
17
+ try {
18
+ await login({ email, password });
19
+ onLoggedIn();
20
+ } catch (apiError) {
21
+ setError(apiError.message);
22
+ } finally {
23
+ setLoading(false);
24
+ }
25
+ }
26
+
27
+ return (
28
+ <main className={styles.page}>
29
+ <section className={styles.panel} aria-labelledby="login-title">
30
+ <div className={styles.frame} aria-hidden="true" />
31
+ <div className={styles.identity}>
32
+ <span className={styles.tag}>astratra/saas-kit</span>
33
+ <span className={styles.tagMuted}>v0.1.0</span>
34
+ </div>
35
+ <h1 id="login-title">
36
+ <span className={styles.prompt}>&gt;</span> sign_in
37
+ <span className={styles.cursor} aria-hidden="true" />
38
+ </h1>
39
+ <p className={styles.copy}>
40
+ // authenticate with the seeded owner account to inspect the generic SaaS API screens
41
+ </p>
42
+ <Notice tone="error">{error}</Notice>
43
+ <form className={styles.form} onSubmit={handleSubmit}>
44
+ <label>
45
+ email
46
+ <input value={email} onChange={(event) => setEmail(event.target.value)} type="email" required />
47
+ </label>
48
+ <label>
49
+ password
50
+ <input value={password} onChange={(event) => setPassword(event.target.value)} type="password" required />
51
+ </label>
52
+ <button type="submit" disabled={loading}>{loading ? 'authenticating...' : '[ sign in ]'}</button>
53
+ </form>
54
+ </section>
55
+ </main>
56
+ );
57
+ }
@@ -0,0 +1,163 @@
1
+ .page {
2
+ min-height: 100vh;
3
+ display: grid;
4
+ place-items: center;
5
+ padding: 24px;
6
+ background:
7
+ radial-gradient(circle at 20% -10%, rgba(217, 255, 102, 0.08), transparent 40%),
8
+ var(--bg);
9
+ }
10
+
11
+ .panel {
12
+ position: relative;
13
+ width: min(440px, 100%);
14
+ display: grid;
15
+ gap: 18px;
16
+ padding: 40px;
17
+ background: var(--panel);
18
+ color: var(--cream);
19
+ border: 1px solid var(--line);
20
+ animation: rise 420ms ease both;
21
+ }
22
+
23
+ @keyframes rise {
24
+ from {
25
+ opacity: 0;
26
+ transform: translateY(10px);
27
+ }
28
+ to {
29
+ opacity: 1;
30
+ transform: translateY(0);
31
+ }
32
+ }
33
+
34
+ .frame {
35
+ position: absolute;
36
+ inset: -1px;
37
+ pointer-events: none;
38
+ }
39
+
40
+ .frame::before,
41
+ .frame::after {
42
+ content: "";
43
+ position: absolute;
44
+ width: 18px;
45
+ height: 18px;
46
+ border: 2px solid var(--lime);
47
+ }
48
+
49
+ .frame::before {
50
+ top: -2px;
51
+ left: -2px;
52
+ border-right: 0;
53
+ border-bottom: 0;
54
+ }
55
+
56
+ .frame::after {
57
+ bottom: -2px;
58
+ right: -2px;
59
+ border-left: 0;
60
+ border-top: 0;
61
+ }
62
+
63
+ .identity {
64
+ display: flex;
65
+ align-items: baseline;
66
+ gap: 8px;
67
+ font-size: 0.75rem;
68
+ }
69
+
70
+ .tag {
71
+ color: var(--lime);
72
+ }
73
+
74
+ .tagMuted {
75
+ color: var(--muted);
76
+ }
77
+
78
+ h1 {
79
+ margin: 0;
80
+ display: flex;
81
+ align-items: center;
82
+ font-size: 2rem;
83
+ line-height: 1;
84
+ font-weight: 500;
85
+ }
86
+
87
+ .prompt {
88
+ color: var(--lime);
89
+ margin-right: 10px;
90
+ }
91
+
92
+ .cursor {
93
+ display: inline-block;
94
+ width: 0.55em;
95
+ height: 1.1em;
96
+ margin-left: 8px;
97
+ background: var(--lime);
98
+ animation: blink 1s steps(1) infinite;
99
+ }
100
+
101
+ @keyframes blink {
102
+ 50% {
103
+ opacity: 0;
104
+ }
105
+ }
106
+
107
+ .copy {
108
+ margin: 0;
109
+ color: var(--muted);
110
+ font-size: 0.85rem;
111
+ line-height: 1.6;
112
+ }
113
+
114
+ .form {
115
+ display: grid;
116
+ gap: 14px;
117
+ }
118
+
119
+ .form label {
120
+ display: grid;
121
+ gap: 6px;
122
+ font-size: 0.78rem;
123
+ color: var(--muted);
124
+ text-transform: lowercase;
125
+ }
126
+
127
+ .form input {
128
+ min-height: 42px;
129
+ border: 1px solid var(--line-bright);
130
+ background: var(--bg);
131
+ color: var(--cream);
132
+ padding: 0 12px;
133
+ font: inherit;
134
+ }
135
+
136
+ .form input:focus {
137
+ border-color: var(--lime);
138
+ }
139
+
140
+ .form button {
141
+ min-height: 44px;
142
+ border: 1px solid var(--lime);
143
+ background: var(--lime);
144
+ color: var(--bg);
145
+ font: inherit;
146
+ font-weight: 700;
147
+ margin-top: 6px;
148
+ }
149
+
150
+ .form button:disabled {
151
+ cursor: wait;
152
+ opacity: 0.6;
153
+ }
154
+
155
+ @media (max-width: 520px) {
156
+ .panel {
157
+ padding: 28px;
158
+ }
159
+
160
+ h1 {
161
+ font-size: 1.6rem;
162
+ }
163
+ }
@@ -0,0 +1,248 @@
1
+ .screen {
2
+ display: grid;
3
+ gap: 24px;
4
+ animation: fadeUp 320ms ease both;
5
+ }
6
+
7
+ @keyframes fadeUp {
8
+ from {
9
+ opacity: 0;
10
+ transform: translateY(6px);
11
+ }
12
+ to {
13
+ opacity: 1;
14
+ transform: translateY(0);
15
+ }
16
+ }
17
+
18
+ .header {
19
+ display: grid;
20
+ gap: 6px;
21
+ padding-bottom: 20px;
22
+ border-bottom: 1px solid var(--line);
23
+ }
24
+
25
+ .header p {
26
+ margin: 0;
27
+ color: var(--lime-dim);
28
+ font-size: 0.78rem;
29
+ letter-spacing: 0.03em;
30
+ }
31
+
32
+ .header p::before {
33
+ content: "// ";
34
+ color: var(--muted);
35
+ }
36
+
37
+ .header h1 {
38
+ margin: 0;
39
+ font-size: 2.4rem;
40
+ line-height: 1;
41
+ font-weight: 500;
42
+ text-transform: lowercase;
43
+ }
44
+
45
+ .muted {
46
+ color: var(--muted);
47
+ }
48
+
49
+ .grid {
50
+ display: grid;
51
+ grid-template-columns: minmax(200px, 280px) minmax(0, 1fr);
52
+ gap: 16px;
53
+ }
54
+
55
+ .statCard,
56
+ .wideCard {
57
+ position: relative;
58
+ background: var(--panel);
59
+ border: 1px solid var(--line);
60
+ padding: 22px;
61
+ animation: fadeUp 380ms ease both;
62
+ }
63
+
64
+ .statCard::before,
65
+ .statCard::after,
66
+ .wideCard::before,
67
+ .wideCard::after {
68
+ content: "";
69
+ position: absolute;
70
+ width: 10px;
71
+ height: 10px;
72
+ border: 1px solid var(--line-bright);
73
+ }
74
+
75
+ .statCard::before,
76
+ .wideCard::before {
77
+ top: -1px;
78
+ left: -1px;
79
+ border-right: 0;
80
+ border-bottom: 0;
81
+ }
82
+
83
+ .statCard::after,
84
+ .wideCard::after {
85
+ bottom: -1px;
86
+ right: -1px;
87
+ border-left: 0;
88
+ border-top: 0;
89
+ }
90
+
91
+ .wideCard {
92
+ animation-delay: 60ms;
93
+ }
94
+
95
+ .statCard span,
96
+ .wideCard > span {
97
+ color: var(--muted);
98
+ font-size: 0.78rem;
99
+ text-transform: lowercase;
100
+ }
101
+
102
+ .statCard strong {
103
+ display: block;
104
+ margin-top: 14px;
105
+ font-size: 4rem;
106
+ line-height: 1;
107
+ font-weight: 500;
108
+ color: var(--lime);
109
+ font-variant-numeric: tabular-nums;
110
+ }
111
+
112
+ .bars {
113
+ display: grid;
114
+ gap: 14px;
115
+ margin-top: 20px;
116
+ }
117
+
118
+ .barRow {
119
+ display: grid;
120
+ grid-template-columns: 90px minmax(80px, 1fr) 30px;
121
+ align-items: center;
122
+ gap: 12px;
123
+ font-size: 0.85rem;
124
+ }
125
+
126
+ .track {
127
+ height: 6px;
128
+ background: var(--line);
129
+ overflow: hidden;
130
+ }
131
+
132
+ .track div {
133
+ height: 100%;
134
+ background: var(--lime);
135
+ }
136
+
137
+ .inlineForm {
138
+ display: grid;
139
+ grid-template-columns: minmax(180px, 1fr) 140px 160px auto;
140
+ gap: 10px;
141
+ }
142
+
143
+ .inlineForm input,
144
+ .inlineForm select,
145
+ .settingRow input {
146
+ min-height: 42px;
147
+ border: 1px solid var(--line-bright);
148
+ padding: 0 12px;
149
+ background: var(--panel);
150
+ color: var(--cream);
151
+ font: inherit;
152
+ }
153
+
154
+ .inlineForm input:focus,
155
+ .inlineForm select:focus,
156
+ .settingRow input:focus {
157
+ border-color: var(--lime);
158
+ }
159
+
160
+ .inlineForm button,
161
+ .settingRow button {
162
+ min-height: 42px;
163
+ border: 1px solid var(--lime);
164
+ padding: 0 16px;
165
+ background: var(--lime);
166
+ color: var(--bg);
167
+ font: inherit;
168
+ font-weight: 700;
169
+ }
170
+
171
+ .inlineForm button:hover,
172
+ .settingRow button:hover {
173
+ background: var(--cream);
174
+ border-color: var(--cream);
175
+ }
176
+
177
+ .inlineForm button:active,
178
+ .settingRow button:active {
179
+ transform: translateY(1px);
180
+ }
181
+
182
+ .tableWrap {
183
+ overflow-x: auto;
184
+ border: 1px solid var(--line);
185
+ background: var(--panel);
186
+ }
187
+
188
+ table {
189
+ width: 100%;
190
+ border-collapse: collapse;
191
+ font-size: 0.88rem;
192
+ }
193
+
194
+ th,
195
+ td {
196
+ padding: 14px 16px;
197
+ text-align: left;
198
+ border-bottom: 1px solid var(--line);
199
+ }
200
+
201
+ tbody tr {
202
+ transition: background-color 120ms ease;
203
+ }
204
+
205
+ tbody tr:hover {
206
+ background: rgba(217, 255, 102, 0.05);
207
+ }
208
+
209
+ th {
210
+ color: var(--muted);
211
+ font-size: 0.72rem;
212
+ text-transform: lowercase;
213
+ font-weight: 400;
214
+ }
215
+
216
+ .settingsList {
217
+ display: grid;
218
+ gap: 10px;
219
+ }
220
+
221
+ .settingRow {
222
+ display: grid;
223
+ grid-template-columns: minmax(0, 1fr) auto;
224
+ gap: 10px;
225
+ align-items: end;
226
+ padding: 16px;
227
+ background: var(--panel);
228
+ border: 1px solid var(--line);
229
+ }
230
+
231
+ .settingRow label {
232
+ display: grid;
233
+ gap: 6px;
234
+ font-size: 0.82rem;
235
+ color: var(--muted);
236
+ }
237
+
238
+ @media (max-width: 860px) {
239
+ .grid,
240
+ .inlineForm,
241
+ .settingRow {
242
+ grid-template-columns: 1fr;
243
+ }
244
+
245
+ .header h1 {
246
+ font-size: 2rem;
247
+ }
248
+ }
@@ -0,0 +1,78 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { useAuth } from '../auth/AuthContext.jsx';
3
+ import Notice from '../components/Notice.jsx';
4
+ import styles from './Screen.module.css';
5
+
6
+ export default function Settings() {
7
+ const { request } = useAuth();
8
+ const [settings, setSettings] = useState({});
9
+ const [drafts, setDrafts] = useState({});
10
+ const [error, setError] = useState('');
11
+ const [success, setSuccess] = useState('');
12
+
13
+ async function loadSettings() {
14
+ setError('');
15
+ try {
16
+ const data = await request('/settings');
17
+ setSettings(data || {});
18
+ setDrafts(Object.fromEntries(Object.entries(data || {}).map(([key, value]) => [key, JSON.stringify(value)])));
19
+ } catch (apiError) {
20
+ setError(apiError.status === 403 ? 'Not authorized to manage settings.' : apiError.message);
21
+ }
22
+ }
23
+
24
+ useEffect(() => {
25
+ loadSettings();
26
+ }, []);
27
+
28
+ async function saveSetting(key) {
29
+ setError('');
30
+ setSuccess('');
31
+ let value = drafts[key];
32
+ try {
33
+ value = JSON.parse(drafts[key]);
34
+ } catch {
35
+ value = drafts[key];
36
+ }
37
+
38
+ try {
39
+ const updated = await request(`/settings/${encodeURIComponent(key)}`, {
40
+ method: 'PATCH',
41
+ body: { value }
42
+ });
43
+ setSettings({ ...settings, [updated.key]: updated.value });
44
+ setDrafts({ ...drafts, [updated.key]: JSON.stringify(updated.value) });
45
+ setSuccess(`${updated.key} saved.`);
46
+ } catch (apiError) {
47
+ setError(apiError.status === 403 ? 'Not authorized to update settings.' : apiError.message);
48
+ }
49
+ }
50
+
51
+ const rows = Object.entries(settings);
52
+
53
+ return (
54
+ <section className={styles.screen}>
55
+ <header className={styles.header}>
56
+ <p>Configuration</p>
57
+ <h1>Settings</h1>
58
+ </header>
59
+ <Notice tone="error">{error}</Notice>
60
+ <Notice tone="success">{success}</Notice>
61
+ {rows.length === 0 && !error ? <p className={styles.muted}>No settings yet.</p> : null}
62
+ <div className={styles.settingsList}>
63
+ {rows.map(([key]) => (
64
+ <div className={styles.settingRow} key={key}>
65
+ <label>
66
+ <span>{key}</span>
67
+ <input
68
+ value={drafts[key] ?? ''}
69
+ onChange={(event) => setDrafts({ ...drafts, [key]: event.target.value })}
70
+ />
71
+ </label>
72
+ <button type="button" onClick={() => saveSetting(key)}>Save</button>
73
+ </div>
74
+ ))}
75
+ </div>
76
+ </section>
77
+ );
78
+ }
@@ -0,0 +1,100 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { useAuth } from '../auth/AuthContext.jsx';
3
+ import Notice from '../components/Notice.jsx';
4
+ import styles from './Screen.module.css';
5
+
6
+ export default function Users() {
7
+ const { request } = useAuth();
8
+ const [users, setUsers] = useState([]);
9
+ const [error, setError] = useState('');
10
+ const [success, setSuccess] = useState('');
11
+ const [form, setForm] = useState({ email: '', role: 'member', password: 'password' });
12
+ const [loading, setLoading] = useState(true);
13
+
14
+ async function loadUsers() {
15
+ setLoading(true);
16
+ setError('');
17
+ try {
18
+ const data = await request('/users?limit=50&offset=0');
19
+ setUsers(data.items || []);
20
+ } catch (apiError) {
21
+ setError(apiError.status === 403 ? 'Not authorized to manage users.' : apiError.message);
22
+ } finally {
23
+ setLoading(false);
24
+ }
25
+ }
26
+
27
+ useEffect(() => {
28
+ loadUsers();
29
+ }, []);
30
+
31
+ async function createUser(event) {
32
+ event.preventDefault();
33
+ setError('');
34
+ setSuccess('');
35
+ try {
36
+ await request('/users', { method: 'POST', body: form });
37
+ setForm({ email: '', role: 'member', password: 'password' });
38
+ setSuccess('User created.');
39
+ await loadUsers();
40
+ } catch (apiError) {
41
+ setError(apiError.status === 403 ? 'Not authorized to create users.' : apiError.message);
42
+ }
43
+ }
44
+
45
+ return (
46
+ <section className={styles.screen}>
47
+ <header className={styles.header}>
48
+ <p>Administration</p>
49
+ <h1>Users</h1>
50
+ </header>
51
+ <Notice tone="error">{error}</Notice>
52
+ <Notice tone="success">{success}</Notice>
53
+ <form className={styles.inlineForm} onSubmit={createUser}>
54
+ <input
55
+ aria-label="Email"
56
+ placeholder="email@example.test"
57
+ type="email"
58
+ value={form.email}
59
+ onChange={(event) => setForm({ ...form, email: event.target.value })}
60
+ required
61
+ />
62
+ <select
63
+ aria-label="Role"
64
+ value={form.role}
65
+ onChange={(event) => setForm({ ...form, role: event.target.value })}
66
+ >
67
+ <option value="member">member</option>
68
+ <option value="admin">admin</option>
69
+ <option value="owner">owner</option>
70
+ </select>
71
+ <input
72
+ aria-label="Password"
73
+ value={form.password}
74
+ onChange={(event) => setForm({ ...form, password: event.target.value })}
75
+ required
76
+ />
77
+ <button type="submit">Create user</button>
78
+ </form>
79
+ {loading ? <p className={styles.muted}>Loading users...</p> : null}
80
+ <div className={styles.tableWrap}>
81
+ <table>
82
+ <thead>
83
+ <tr>
84
+ <th>Email</th>
85
+ <th>Role</th>
86
+ </tr>
87
+ </thead>
88
+ <tbody>
89
+ {users.map((user) => (
90
+ <tr key={user.id}>
91
+ <td>{user.email}</td>
92
+ <td>{user.role || 'unknown'}</td>
93
+ </tr>
94
+ ))}
95
+ </tbody>
96
+ </table>
97
+ </div>
98
+ </section>
99
+ );
100
+ }
@@ -0,0 +1,98 @@
1
+ :root {
2
+ --bg: #0d1310;
3
+ --panel: #141b17;
4
+ --panel-raised: #1a231d;
5
+ --line: #2e3a31;
6
+ --line-bright: #46584c;
7
+ --lime: #d9ff66;
8
+ --lime-dim: #a8c94f;
9
+ --cream: #f4f1e6;
10
+ --muted: #7c8a7a;
11
+ --error: #ff6b5c;
12
+ --error-bg: #241512;
13
+ --success: #8ce99a;
14
+ --success-bg: #10201a;
15
+ --mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
16
+ }
17
+
18
+ * {
19
+ box-sizing: border-box;
20
+ }
21
+
22
+ html {
23
+ color-scheme: dark;
24
+ }
25
+
26
+ body {
27
+ margin: 0;
28
+ min-width: 320px;
29
+ font-family: var(--mono);
30
+ color: var(--cream);
31
+ background:
32
+ repeating-linear-gradient(
33
+ to bottom,
34
+ rgba(217, 255, 102, 0.025),
35
+ rgba(217, 255, 102, 0.025) 1px,
36
+ transparent 1px,
37
+ transparent 3px
38
+ ),
39
+ var(--bg);
40
+ font-synthesis: none;
41
+ text-rendering: optimizeLegibility;
42
+ -webkit-font-smoothing: antialiased;
43
+ -moz-osx-font-smoothing: grayscale;
44
+ }
45
+
46
+ button,
47
+ input,
48
+ select {
49
+ font: inherit;
50
+ color: inherit;
51
+ }
52
+
53
+ button {
54
+ cursor: pointer;
55
+ }
56
+
57
+ button:focus-visible,
58
+ input:focus-visible,
59
+ select:focus-visible {
60
+ outline: 2px solid var(--lime);
61
+ outline-offset: 2px;
62
+ }
63
+
64
+ ::selection {
65
+ background: var(--lime);
66
+ color: var(--bg);
67
+ }
68
+
69
+ ::-webkit-scrollbar {
70
+ width: 10px;
71
+ height: 10px;
72
+ }
73
+
74
+ ::-webkit-scrollbar-track {
75
+ background: var(--bg);
76
+ }
77
+
78
+ ::-webkit-scrollbar-thumb {
79
+ background: var(--line-bright);
80
+ }
81
+
82
+ h1,
83
+ h2,
84
+ h3 {
85
+ font-family: var(--mono);
86
+ letter-spacing: 0;
87
+ }
88
+
89
+ @media (prefers-reduced-motion: reduce) {
90
+ *,
91
+ *::before,
92
+ *::after {
93
+ animation-duration: 1ms !important;
94
+ animation-iteration-count: 1 !important;
95
+ scroll-behavior: auto !important;
96
+ transition-duration: 1ms !important;
97
+ }
98
+ }