feat: add admin dashboard

This commit is contained in:
2026-04-29 09:44:57 -04:00
parent e319a5d092
commit baf7a497eb
11 changed files with 939 additions and 67 deletions

View File

@@ -0,0 +1,2 @@
-- SQLite cannot drop columns without rebuilding the table. Keep role on rollback.

View File

@@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'viewer';

View File

@@ -14,13 +14,22 @@ type Repository struct {
} }
type DocumentRecord struct { type DocumentRecord struct {
ID string ID string `json:"id"`
Path string Path string `json:"path"`
CurrentHash string CurrentHash string `json:"currentHash"`
Title string Title string `json:"title"`
Tags []string Tags []string `json:"tags"`
CreatedAt time.Time CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time 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 { type AttachmentRecord struct {
@@ -151,6 +160,82 @@ func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error
return records, rows.Err() 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 { func (r *Repository) SaveAttachment(ctx context.Context, record AttachmentRecord) error {
if _, err := r.db.ExecContext(ctx, ` if _, err := r.db.ExecContext(ctx, `
INSERT INTO attachments (hash, original_name, content_type, size_bytes, created_at) INSERT INTO attachments (hash, original_name, content_type, size_bytes, created_at)

View File

@@ -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
}

View File

@@ -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"))
})
}

View File

@@ -76,6 +76,11 @@ func New(deps Dependencies) (http.Handler, error) {
router.Get("/", server.handleIndex) router.Get("/", server.handleIndex)
router.Get("/health", server.handleHealth) router.Get("/health", server.handleHealth)
router.Get("/ws", server.handleWebSocket) 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.handleDocsIndex)
router.Get("/docs/*", server.handleDocument) router.Get("/docs/*", server.handleDocument)
router.Post("/api/uploads", server.handleUpload) 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")))) router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(mustSub("static"))))
if server.webEnabled { 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 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 { func mustSub(path string) http.FileSystem {
sub, err := fsSub(path) sub, err := fsSub(path)
if err != nil { if err != nil {

View File

@@ -8,6 +8,7 @@
"name": "md-hub-secure-web", "name": "md-hub-secure-web",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"lucide-preact": "^1.14.0",
"preact": "^10.27.2", "preact": "^10.27.2",
"preact-iso": "^2.9.3" "preact-iso": "^2.9.3"
}, },
@@ -1678,6 +1679,15 @@
"yallist": "^3.0.2" "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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",

View File

@@ -10,6 +10,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"lucide-preact": "^1.14.0",
"preact": "^10.27.2", "preact": "^10.27.2",
"preact-iso": "^2.9.3" "preact-iso": "^2.9.3"
}, },

View File

@@ -1,31 +1,370 @@
export function App() { import {
return ( Activity,
<main class="shell"> Database,
<section class="hero"> FolderGit2,
<p class="eyebrow">MD Hub Secure</p> LogIn,
<h1>Preact foundation for future hydrated features</h1> LogOut,
<p class="lede"> RefreshCw,
The Go server is the source of truth for rendering document pages. Shield,
This app is the scaffold for search, sync, comments, and design UserPlus,
tooling that will hydrate over time. Users,
</p> } from "lucide-preact";
</section> import { useEffect, useState } from "preact/hooks";
<section class="grid"> type Role = "admin" | "editor" | "viewer";
<article class="card">
<h2>Current scope</h2> type User = {
<p> id: string;
Routing, bundling, TypeScript, and styles are in place for later email: string;
feature islands. displayName: string;
</p> role: Role;
</article> createdAt: string;
<article class="card"> lastSeenAt?: string;
<h2>Next milestone</h2> };
<p>
Sync protocol UI, offline cache status, and browser-side search. type DocumentRecord = {
</p> path: string;
</article> 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<Session | null>(() => readSession());
const [users, setUsers] = useState<User[]>([]);
const [workspace, setWorkspace] = useState<WorkspaceResponse | null>(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<WorkspaceResponse>("/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 <LoginScreen onLogin={handleLogin} />;
}
return (
<main class="admin-shell">
<aside class="admin-sidebar">
<a class="brand" href="/docs">
<Shield size={20} />
<span>MD Hub Secure</span>
</a>
<nav>
<a class="is-active" href="#workspace">
<FolderGit2 size={17} />
<span>Workspace</span>
</a>
<a href="#users">
<Users size={17} />
<span>Users</span>
</a>
</nav>
<button class="ghost-button" type="button" onClick={logout}>
<LogOut size={16} />
<span>Sign out</span>
</button>
</aside>
<section class="admin-main">
<header class="admin-topbar">
<div>
<p class="eyebrow">Admin</p>
<h1>Workspace control</h1>
</div>
<div class="topbar-actions">
<span class="status-pill">
<Activity size={14} />
{status}
</span>
<button type="button" class="primary-button" onClick={handleSync}>
<RefreshCw size={16} />
Sync
</button>
</div>
</header>
<WorkspacePanel data={workspace} />
<UsersPanel users={users} onCreateUser={handleCreateUser} />
</section> </section>
</main> </main>
); );
} }
function LoginScreen(props: {
onLogin: (email: string, displayName: string) => Promise<void>;
}) {
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 (
<main class="login-shell">
<form class="login-panel" onSubmit={submit}>
<div class="login-mark">
<Shield size={22} />
</div>
<h1>Admin login</h1>
<label>
Email
<input
type="email"
value={email}
onInput={(event) => setEmail(event.currentTarget.value)}
required
/>
</label>
<label>
Display name
<input
type="text"
value={displayName}
onInput={(event) => setDisplayName(event.currentTarget.value)}
required
/>
</label>
{error && <p class="form-error">{error}</p>}
<button type="submit" class="primary-button">
<LogIn size={16} />
Sign in
</button>
</form>
</main>
);
}
function WorkspacePanel(props: { data: WorkspaceResponse | null }) {
const workspace = props.data?.workspace;
return (
<section id="workspace" class="panel">
<div class="panel-header">
<div>
<p class="eyebrow">Workspace</p>
<h2>Storage and documents</h2>
</div>
<Database size={19} />
</div>
<div class="metric-grid">
<Metric label="Documents" value={workspace?.documentCount ?? 0} />
<Metric label="Users" value={workspace?.userCount ?? 0} />
<Metric label="Database" value={workspace?.databasePath ?? "Loading"} />
<Metric label="Content" value={workspace?.sourceDir ?? "Loading"} />
</div>
<div class="document-table">
<div class="table-row table-row--head">
<span>Document</span>
<span>Hash</span>
<span>Updated</span>
</div>
{(props.data?.documents ?? []).map((document) => (
<div class="table-row" key={document.path}>
<a href={`/docs/${document.path.replace(/\.md$/, "")}`}>
{document.title}
</a>
<code>{document.currentHash.slice(0, 12)}</code>
<span>{formatDate(document.updatedAt)}</span>
</div>
))}
</div>
</section>
);
}
function UsersPanel(props: {
users: User[];
onCreateUser: (input: {
email: string;
displayName: string;
role: Role;
}) => Promise<void>;
}) {
const [email, setEmail] = useState("");
const [displayName, setDisplayName] = useState("");
const [role, setRole] = useState<Role>("viewer");
async function submit(event: Event) {
event.preventDefault();
await props.onCreateUser({ email, displayName, role });
setEmail("");
setDisplayName("");
setRole("viewer");
}
return (
<section id="users" class="panel">
<div class="panel-header">
<div>
<p class="eyebrow">Users</p>
<h2>Accounts and roles</h2>
</div>
<Users size={19} />
</div>
<form class="user-form" onSubmit={submit}>
<input
type="email"
placeholder="email@example.com"
value={email}
onInput={(event) => setEmail(event.currentTarget.value)}
required
/>
<input
type="text"
placeholder="Display name"
value={displayName}
onInput={(event) => setDisplayName(event.currentTarget.value)}
required
/>
<select
value={role}
onChange={(event) => setRole(event.currentTarget.value as Role)}
>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
<button type="submit" class="primary-button">
<UserPlus size={16} />
Add
</button>
</form>
<div class="user-list">
{props.users.map((user) => (
<article class="user-row" key={user.id}>
<div>
<strong>{user.displayName}</strong>
<span>{user.email}</span>
</div>
<span class="role-pill">{user.role}</span>
</article>
))}
</div>
</section>
);
}
function Metric(props: { label: string; value: string | number }) {
return (
<article class="metric">
<span>{props.label}</span>
<strong>{props.value}</strong>
</article>
);
}
async function api<T = unknown>(path: string, init?: RequestInit): Promise<T> {
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<T>;
}
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));
}

View File

@@ -34,58 +34,315 @@ body {
linear-gradient(180deg, #faf7f2 0%, var(--bg) 100%); linear-gradient(180deg, #faf7f2 0%, var(--bg) 100%);
} }
.shell { button,
max-width: 960px; input,
margin: 0 auto; select {
padding: 4rem 1.5rem 5rem; font: inherit;
} }
.hero { button {
padding: 2rem; cursor: pointer;
border: 1px solid var(--border); }
border-radius: 28px;
background: var(--surface); a {
backdrop-filter: blur(10px); color: var(--accent);
box-shadow: var(--shadow); }
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 { .eyebrow {
margin: 0 0 0.75rem; margin: 0 0 0.35rem;
color: var(--accent); color: var(--accent);
font-family: ui-monospace, SFMono-Regular, monospace; font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.82rem; font-size: 0.75rem;
letter-spacing: 0.18em; letter-spacing: 0.12em;
text-transform: uppercase; text-transform: uppercase;
} }
.hero h1 { .status-pill,
margin: 0; .role-pill {
font-size: clamp(2.25rem, 5vw, 4rem); min-height: 2.2rem;
line-height: 0.95; padding: 0.35rem 0.6rem;
border: 1px solid var(--border);
border-radius: 999px;
color: var(--muted);
background: var(--surface);
} }
.lede { .primary-button {
max-width: 42rem; min-height: 2.4rem;
margin: 1.5rem 0 0; padding: 0.55rem 0.8rem;
font-size: 1.1rem; border: 0;
line-height: 1.7; 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); 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; display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 0.35rem;
gap: 1rem;
margin-top: 1.5rem;
} }
.card { .table-row {
padding: 1.25rem; 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: 1px solid var(--border);
border-radius: 20px; border-radius: 0.5rem;
background: var(--surface-strong); color: var(--text);
background: rgba(255, 255, 255, 0.72);
} }
.card h2 { .user-row {
margin-top: 0; 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;
}
} }

View File

@@ -2,6 +2,7 @@ import { defineConfig } from "vite";
import preact from "@preact/preset-vite"; import preact from "@preact/preset-vite";
export default defineConfig({ export default defineConfig({
base: "/app/",
plugins: [preact()], plugins: [preact()],
build: { build: {
outDir: "dist", outDir: "dist",