diff --git a/apps/server/internal/database/migrations/000006_user_roles.down.sql b/apps/server/internal/database/migrations/000006_user_roles.down.sql new file mode 100644 index 0000000..0198c38 --- /dev/null +++ b/apps/server/internal/database/migrations/000006_user_roles.down.sql @@ -0,0 +1,2 @@ +-- SQLite cannot drop columns without rebuilding the table. Keep role on rollback. + diff --git a/apps/server/internal/database/migrations/000006_user_roles.up.sql b/apps/server/internal/database/migrations/000006_user_roles.up.sql new file mode 100644 index 0000000..9cd522b --- /dev/null +++ b/apps/server/internal/database/migrations/000006_user_roles.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'viewer'; + diff --git a/apps/server/internal/docs/repository.go b/apps/server/internal/docs/repository.go index d47328e..1942dbe 100644 --- a/apps/server/internal/docs/repository.go +++ b/apps/server/internal/docs/repository.go @@ -14,13 +14,22 @@ type Repository struct { } type DocumentRecord struct { - ID string - Path string - CurrentHash string - Title string - Tags []string - CreatedAt time.Time - UpdatedAt time.Time + ID string `json:"id"` + Path string `json:"path"` + CurrentHash string `json:"currentHash"` + Title string `json:"title"` + Tags []string `json:"tags"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type UserRecord struct { + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"displayName"` + Role string `json:"role"` + CreatedAt time.Time `json:"createdAt"` + LastSeenAt time.Time `json:"lastSeenAt,omitempty"` } type AttachmentRecord struct { @@ -151,6 +160,82 @@ func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error return records, rows.Err() } +func (r *Repository) ListUsers(ctx context.Context) ([]UserRecord, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, email, COALESCE(display_name, ''), COALESCE(role, 'viewer'), created_at, COALESCE(last_seen_at, '') + FROM users + ORDER BY created_at DESC + `) + if err != nil { + return nil, fmt.Errorf("list users: %w", err) + } + defer rows.Close() + + var users []UserRecord + for rows.Next() { + var ( + user UserRecord + created string + lastSeen string + ) + if err := rows.Scan(&user.ID, &user.Email, &user.DisplayName, &user.Role, &created, &lastSeen); err != nil { + return nil, fmt.Errorf("scan user: %w", err) + } + createdAt, err := time.Parse(time.RFC3339, created) + if err != nil { + return nil, fmt.Errorf("parse user created_at: %w", err) + } + user.CreatedAt = createdAt + if lastSeen != "" { + parsed, err := time.Parse(time.RFC3339, lastSeen) + if err != nil { + return nil, fmt.Errorf("parse user last_seen_at: %w", err) + } + user.LastSeenAt = parsed + } + users = append(users, user) + } + + return users, rows.Err() +} + +func (r *Repository) UpsertUser(ctx context.Context, user UserRecord) (UserRecord, error) { + now := time.Now().UTC() + if user.ID == "" { + user.ID = "user:" + user.Email + } + if user.Role == "" { + user.Role = "viewer" + } + if user.DisplayName == "" { + user.DisplayName = user.Email + } + user.CreatedAt = now + user.LastSeenAt = now + + if _, err := r.db.ExecContext(ctx, ` + INSERT INTO users (id, email, display_name, role, created_at, last_seen_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(email) DO UPDATE SET + display_name = excluded.display_name, + role = excluded.role, + last_seen_at = excluded.last_seen_at + `, user.ID, user.Email, user.DisplayName, user.Role, now.Format(time.RFC3339), now.Format(time.RFC3339)); err != nil { + return UserRecord{}, fmt.Errorf("upsert user %s: %w", user.Email, err) + } + + users, err := r.ListUsers(ctx) + if err != nil { + return UserRecord{}, err + } + for _, candidate := range users { + if candidate.Email == user.Email { + return candidate, nil + } + } + return UserRecord{}, sql.ErrNoRows +} + func (r *Repository) SaveAttachment(ctx context.Context, record AttachmentRecord) error { if _, err := r.db.ExecContext(ctx, ` INSERT INTO attachments (hash, original_name, content_type, size_bytes, created_at) diff --git a/apps/server/internal/httpserver/admin_handlers.go b/apps/server/internal/httpserver/admin_handlers.go new file mode 100644 index 0000000..c60b3d6 --- /dev/null +++ b/apps/server/internal/httpserver/admin_handlers.go @@ -0,0 +1,139 @@ +package httpserver + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/tim/md-hub-secure/apps/server/internal/docs" +) + +type adminUserInput struct { + Email string `json:"email"` + DisplayName string `json:"displayName"` + Role string `json:"role"` +} + +func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) { + var input adminUserInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + + user, ok := s.normalizeAdminUserInput(input) + if !ok { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "email is required"}) + return + } + if user.Role == "viewer" { + user.Role = "admin" + } + + created, err := s.repository.UpsertUser(r.Context(), user) + if err != nil { + writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "user": created, + "session": map[string]any{ + "mode": "foundation", + "createdAt": time.Now().UTC(), + }, + }) +} + +func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) { + users, err := s.repository.ListUsers(r.Context()) + if err != nil { + writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"users": users}) +} + +func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) { + var input adminUserInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + + user, ok := s.normalizeAdminUserInput(input) + if !ok { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "email is required"}) + return + } + + created, err := s.repository.UpsertUser(r.Context(), user) + if err != nil { + writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSONWithStatus(w, http.StatusCreated, map[string]any{"user": created}) +} + +func (s *Server) handleAdminWorkspace(w http.ResponseWriter, r *http.Request) { + documents, err := s.documents.ListDocuments(r.Context()) + if err != nil { + writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + users, err := s.repository.ListUsers(r.Context()) + if err != nil { + writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "workspace": map[string]any{ + "sourceDir": s.config.Content.SourceDir, + "storeDir": s.config.Content.StoreDir, + "databasePath": s.config.Database.Path, + "documentCount": len(documents), + "userCount": len(users), + "webDistDir": s.config.Web.DistDir, + }, + "documents": documents, + }) +} + +func (s *Server) handleAdminWorkspaceSync(w http.ResponseWriter, r *http.Request) { + changes, err := s.documents.SyncSourceDir(r.Context()) + if err != nil { + writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "changes": changes, + "syncedAt": time.Now().UTC(), + }) +} + +func (s *Server) normalizeAdminUserInput(input adminUserInput) (docs.UserRecord, bool) { + email := strings.TrimSpace(strings.ToLower(input.Email)) + if email == "" { + return docs.UserRecord{}, false + } + + role := strings.TrimSpace(strings.ToLower(input.Role)) + switch role { + case "admin", "editor", "viewer": + default: + role = "viewer" + } + + displayName := strings.TrimSpace(input.DisplayName) + if displayName == "" { + displayName = email + } + + return docs.UserRecord{ + Email: email, + DisplayName: displayName, + Role: role, + }, true +} diff --git a/apps/server/internal/httpserver/app_files.go b/apps/server/internal/httpserver/app_files.go new file mode 100644 index 0000000..54367d1 --- /dev/null +++ b/apps/server/internal/httpserver/app_files.go @@ -0,0 +1,26 @@ +package httpserver + +import ( + "net/http" + "os" + "path/filepath" + "strings" +) + +func (s *Server) appFileServer() http.Handler { + files := http.FileServer(http.Dir(s.config.Web.DistDir)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cleanPath := filepath.Clean(strings.TrimPrefix(r.URL.Path, "/")) + if cleanPath == "." { + cleanPath = "index.html" + } + + target := filepath.Join(s.config.Web.DistDir, cleanPath) + if info, err := os.Stat(target); err == nil && !info.IsDir() { + files.ServeHTTP(w, r) + return + } + + http.ServeFile(w, r, filepath.Join(s.config.Web.DistDir, "index.html")) + }) +} diff --git a/apps/server/internal/httpserver/server.go b/apps/server/internal/httpserver/server.go index 07a2eb8..ed1e784 100644 --- a/apps/server/internal/httpserver/server.go +++ b/apps/server/internal/httpserver/server.go @@ -76,6 +76,11 @@ func New(deps Dependencies) (http.Handler, error) { router.Get("/", server.handleIndex) router.Get("/health", server.handleHealth) router.Get("/ws", server.handleWebSocket) + router.Post("/api/admin/login", server.handleAdminLogin) + router.Get("/api/admin/users", server.handleAdminUsers) + router.Post("/api/admin/users", server.handleAdminCreateUser) + router.Get("/api/admin/workspace", server.handleAdminWorkspace) + router.Post("/api/admin/workspace/sync", server.handleAdminWorkspaceSync) router.Get("/docs", server.handleDocsIndex) router.Get("/docs/*", server.handleDocument) router.Post("/api/uploads", server.handleUpload) @@ -83,12 +88,17 @@ func New(deps Dependencies) (http.Handler, error) { router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(mustSub("static")))) if server.webEnabled { - router.Handle("/app/*", http.StripPrefix("/app/", http.FileServer(http.Dir(deps.Config.Web.DistDir)))) + router.Get("/app", server.handleAppRedirect) + router.Handle("/app/*", http.StripPrefix("/app/", server.appFileServer())) } return router, nil } +func (s *Server) handleAppRedirect(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/app/", http.StatusMovedPermanently) +} + func mustSub(path string) http.FileSystem { sub, err := fsSub(path) if err != nil { diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 3603de5..83acf47 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -8,6 +8,7 @@ "name": "md-hub-secure-web", "version": "0.1.0", "dependencies": { + "lucide-preact": "^1.14.0", "preact": "^10.27.2", "preact-iso": "^2.9.3" }, @@ -1678,6 +1679,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-preact": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/lucide-preact/-/lucide-preact-1.14.0.tgz", + "integrity": "sha512-d3N5MpFSukscLyPPtZEeYvSIWohLEj/2OJvjrYovWOTKa40bsHaALX9sIDS6skSu+J6bwYRqfwX6GAxlErfSmQ==", + "license": "ISC", + "peerDependencies": { + "preact": "^10.27.2" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/apps/web/package.json b/apps/web/package.json index e512c27..73f0778 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,6 +10,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "lucide-preact": "^1.14.0", "preact": "^10.27.2", "preact-iso": "^2.9.3" }, diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index aa6865d..6638210 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -1,31 +1,370 @@ -export function App() { - return ( -
-
-

MD Hub Secure

-

Preact foundation for future hydrated features

-

- The Go server is the source of truth for rendering document pages. - This app is the scaffold for search, sync, comments, and design - tooling that will hydrate over time. -

-
+import { + Activity, + Database, + FolderGit2, + LogIn, + LogOut, + RefreshCw, + Shield, + UserPlus, + Users, +} from "lucide-preact"; +import { useEffect, useState } from "preact/hooks"; -
-
-

Current scope

-

- Routing, bundling, TypeScript, and styles are in place for later - feature islands. -

-
-
-

Next milestone

-

- Sync protocol UI, offline cache status, and browser-side search. -

-
+type Role = "admin" | "editor" | "viewer"; + +type User = { + id: string; + email: string; + displayName: string; + role: Role; + createdAt: string; + lastSeenAt?: string; +}; + +type DocumentRecord = { + path: string; + title: string; + currentHash: string; + updatedAt: string; +}; + +type Workspace = { + sourceDir: string; + storeDir: string; + databasePath: string; + documentCount: number; + userCount: number; + webDistDir: string; +}; + +type WorkspaceResponse = { + workspace: Workspace; + documents: DocumentRecord[]; +}; + +type Session = { + user: User; +}; + +const sessionKey = "mdhub.admin.session"; + +export function App() { + const [session, setSession] = useState(() => readSession()); + const [users, setUsers] = useState([]); + const [workspace, setWorkspace] = useState(null); + const [status, setStatus] = useState("Idle"); + + async function refresh() { + if (!session) { + return; + } + setStatus("Refreshing"); + const [usersResponse, workspaceResponse] = await Promise.all([ + api<{ users: User[] }>("/api/admin/users"), + api("/api/admin/workspace"), + ]); + setUsers(usersResponse.users); + setWorkspace(workspaceResponse); + setStatus("Current"); + } + + useEffect(() => { + void refresh(); + }, [session?.user.email]); + + async function handleLogin(email: string, displayName: string) { + const response = await api<{ user: User }>("/api/admin/login", { + method: "POST", + body: JSON.stringify({ email, displayName, role: "admin" }), + }); + const next = { user: response.user }; + localStorage.setItem(sessionKey, JSON.stringify(next)); + setSession(next); + } + + async function handleCreateUser(input: { + email: string; + displayName: string; + role: Role; + }) { + await api<{ user: User }>("/api/admin/users", { + method: "POST", + body: JSON.stringify(input), + }); + await refresh(); + } + + async function handleSync() { + setStatus("Syncing"); + await api("/api/admin/workspace/sync", { method: "POST" }); + await refresh(); + } + + function logout() { + localStorage.removeItem(sessionKey); + setSession(null); + setUsers([]); + setWorkspace(null); + setStatus("Idle"); + } + + if (!session) { + return ; + } + + return ( +
+ + +
+
+
+

Admin

+

Workspace control

+
+
+ + + {status} + + +
+
+ + +
); } + +function LoginScreen(props: { + onLogin: (email: string, displayName: string) => Promise; +}) { + const [email, setEmail] = useState("admin@example.com"); + const [displayName, setDisplayName] = useState("Admin"); + const [error, setError] = useState(""); + + async function submit(event: Event) { + event.preventDefault(); + setError(""); + try { + await props.onLogin(email, displayName); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Login failed"); + } + } + + return ( +
+ +
+ ); +} + +function WorkspacePanel(props: { data: WorkspaceResponse | null }) { + const workspace = props.data?.workspace; + return ( +
+
+
+

Workspace

+

Storage and documents

+
+ +
+ +
+ + + + +
+ +
+
+ Document + Hash + Updated +
+ {(props.data?.documents ?? []).map((document) => ( +
+ + {document.title} + + {document.currentHash.slice(0, 12)} + {formatDate(document.updatedAt)} +
+ ))} +
+
+ ); +} + +function UsersPanel(props: { + users: User[]; + onCreateUser: (input: { + email: string; + displayName: string; + role: Role; + }) => Promise; +}) { + const [email, setEmail] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [role, setRole] = useState("viewer"); + + async function submit(event: Event) { + event.preventDefault(); + await props.onCreateUser({ email, displayName, role }); + setEmail(""); + setDisplayName(""); + setRole("viewer"); + } + + return ( +
+
+
+

Users

+

Accounts and roles

+
+ +
+ +
+ setEmail(event.currentTarget.value)} + required + /> + setDisplayName(event.currentTarget.value)} + required + /> + + +
+ +
+ {props.users.map((user) => ( +
+
+ {user.displayName} + {user.email} +
+ {user.role} +
+ ))} +
+
+ ); +} + +function Metric(props: { label: string; value: string | number }) { + return ( +
+ {props.label} + {props.value} +
+ ); +} + +async function api(path: string, init?: RequestInit): Promise { + const response = await fetch(path, { + ...init, + headers: { + "Content-Type": "application/json", + ...(init?.headers ?? {}), + }, + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(text || response.statusText); + } + return response.json() as Promise; +} + +function readSession(): Session | null { + try { + const raw = localStorage.getItem(sessionKey); + return raw ? (JSON.parse(raw) as Session) : null; + } catch { + return null; + } +} + +function formatDate(value: string) { + if (!value) { + return ""; + } + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(value)); +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 2195d52..3d371eb 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -34,58 +34,315 @@ body { linear-gradient(180deg, #faf7f2 0%, var(--bg) 100%); } -.shell { - max-width: 960px; - margin: 0 auto; - padding: 4rem 1.5rem 5rem; +button, +input, +select { + font: inherit; } -.hero { - padding: 2rem; - border: 1px solid var(--border); - border-radius: 28px; - background: var(--surface); - backdrop-filter: blur(10px); - box-shadow: var(--shadow); +button { + cursor: pointer; +} + +a { + color: var(--accent); +} + +code { + font-family: ui-monospace, SFMono-Regular, monospace; +} + +.admin-shell { + display: grid; + grid-template-columns: 15rem minmax(0, 1fr); + min-height: 100vh; +} + +.admin-sidebar { + display: flex; + flex-direction: column; + gap: 1.5rem; + padding: 1.25rem; + border-right: 1px solid var(--border); + background: rgba(255, 255, 255, 0.5); + backdrop-filter: blur(12px); +} + +.brand, +.admin-sidebar nav a, +.ghost-button, +.primary-button, +.status-pill { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.brand { + color: var(--text); + font-weight: 700; + text-decoration: none; +} + +.admin-sidebar nav { + display: grid; + gap: 0.35rem; +} + +.admin-sidebar nav a, +.ghost-button { + min-height: 2.4rem; + padding: 0.55rem 0.65rem; + border: 0; + border-radius: 0.5rem; + color: var(--text); + background: transparent; + text-decoration: none; +} + +.admin-sidebar nav a.is-active, +.admin-sidebar nav a:hover, +.ghost-button:hover { + color: var(--accent); + background: var(--accent-soft, rgba(25, 71, 229, 0.1)); +} + +.ghost-button { + margin-top: auto; +} + +.admin-main { + min-width: 0; + padding: 1.5rem; +} + +.admin-topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +.admin-topbar h1, +.panel h2, +.login-panel h1 { + margin: 0; +} + +.topbar-actions { + display: flex; + gap: 0.65rem; + align-items: center; } .eyebrow { - margin: 0 0 0.75rem; + margin: 0 0 0.35rem; color: var(--accent); font-family: ui-monospace, SFMono-Regular, monospace; - font-size: 0.82rem; - letter-spacing: 0.18em; + font-size: 0.75rem; + letter-spacing: 0.12em; text-transform: uppercase; } -.hero h1 { - margin: 0; - font-size: clamp(2.25rem, 5vw, 4rem); - line-height: 0.95; +.status-pill, +.role-pill { + min-height: 2.2rem; + padding: 0.35rem 0.6rem; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--muted); + background: var(--surface); } -.lede { - max-width: 42rem; - margin: 1.5rem 0 0; - font-size: 1.1rem; - line-height: 1.7; +.primary-button { + min-height: 2.4rem; + padding: 0.55rem 0.8rem; + border: 0; + border-radius: 0.5rem; + color: white; + background: var(--accent); +} + +.panel, +.login-panel { + margin-bottom: 1rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: var(--surface); + box-shadow: var(--shadow); +} + +.panel-header { + display: flex; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.75rem; + margin-bottom: 1rem; +} + +.metric { + min-width: 0; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: rgba(255, 255, 255, 0.5); +} + +.metric span, +.user-row span, +.table-row span { color: var(--muted); } -.grid { +.metric strong { + display: block; + margin-top: 0.35rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.document-table, +.user-list { display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); - gap: 1rem; - margin-top: 1.5rem; + gap: 0.35rem; } -.card { - padding: 1.25rem; +.table-row { + display: grid; + grid-template-columns: minmax(11rem, 1fr) 8rem 10rem; + gap: 0.75rem; + align-items: center; + min-height: 2.35rem; + padding: 0.4rem 0.55rem; + border-radius: 0.4rem; +} + +.table-row--head { + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 0.76rem; + text-transform: uppercase; +} + +.table-row:not(.table-row--head), +.user-row { + background: rgba(255, 255, 255, 0.48); +} + +.table-row > * { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-form { + display: grid; + grid-template-columns: minmax(10rem, 1fr) minmax(10rem, 1fr) 8rem auto; + gap: 0.65rem; + margin-bottom: 1rem; +} + +.user-form input, +.user-form select, +.login-panel input { + width: 100%; + min-height: 2.4rem; + padding: 0.5rem 0.65rem; border: 1px solid var(--border); - border-radius: 20px; - background: var(--surface-strong); + border-radius: 0.5rem; + color: var(--text); + background: rgba(255, 255, 255, 0.72); } -.card h2 { - margin-top: 0; +.user-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.65rem 0.75rem; + border-radius: 0.5rem; +} + +.user-row div { + display: grid; + gap: 0.15rem; +} + +.login-shell { + display: grid; + min-height: 100vh; + place-items: center; + padding: 1rem; +} + +.login-panel { + width: min(24rem, 100%); + display: grid; + gap: 0.85rem; +} + +.login-panel label { + display: grid; + gap: 0.35rem; + color: var(--muted); +} + +.login-mark { + display: inline-flex; + width: 2.6rem; + height: 2.6rem; + align-items: center; + justify-content: center; + border-radius: 0.5rem; + color: white; + background: var(--accent); +} + +.form-error { + margin: 0; + color: #a43131; +} + +@media (max-width: 920px) { + .admin-shell { + grid-template-columns: 1fr; + } + + .admin-sidebar { + position: sticky; + top: 0; + z-index: 2; + flex-direction: row; + align-items: center; + } + + .admin-sidebar nav { + display: flex; + margin-left: auto; + } + + .ghost-button { + margin-top: 0; + } + + .metric-grid, + .user-form, + .table-row { + grid-template-columns: 1fr; + } + + .admin-topbar { + align-items: flex-start; + flex-direction: column; + } } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index a6bba0c..fc6701c 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vite"; import preact from "@preact/preset-vite"; export default defineConfig({ + base: "/app/", plugins: [preact()], build: { outDir: "dist",