feat: add admin dashboard
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- SQLite cannot drop columns without rebuilding the table. Keep role on rollback.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'viewer';
|
||||
|
||||
@@ -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)
|
||||
|
||||
139
apps/server/internal/httpserver/admin_handlers.go
Normal file
139
apps/server/internal/httpserver/admin_handlers.go
Normal 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
|
||||
}
|
||||
26
apps/server/internal/httpserver/app_files.go
Normal file
26
apps/server/internal/httpserver/app_files.go
Normal 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"))
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
10
apps/web/package-lock.json
generated
10
apps/web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-preact": "^1.14.0",
|
||||
"preact": "^10.27.2",
|
||||
"preact-iso": "^2.9.3"
|
||||
},
|
||||
|
||||
@@ -1,31 +1,370 @@
|
||||
export function App() {
|
||||
return (
|
||||
<main class="shell">
|
||||
<section class="hero">
|
||||
<p class="eyebrow">MD Hub Secure</p>
|
||||
<h1>Preact foundation for future hydrated features</h1>
|
||||
<p class="lede">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
import {
|
||||
Activity,
|
||||
Database,
|
||||
FolderGit2,
|
||||
LogIn,
|
||||
LogOut,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from "lucide-preact";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
<section class="grid">
|
||||
<article class="card">
|
||||
<h2>Current scope</h2>
|
||||
<p>
|
||||
Routing, bundling, TypeScript, and styles are in place for later
|
||||
feature islands.
|
||||
</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>Next milestone</h2>
|
||||
<p>
|
||||
Sync protocol UI, offline cache status, and browser-side search.
|
||||
</p>
|
||||
</article>
|
||||
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<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>
|
||||
</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));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineConfig } from "vite";
|
||||
import preact from "@preact/preset-vite";
|
||||
|
||||
export default defineConfig({
|
||||
base: "/app/",
|
||||
plugins: [preact()],
|
||||
build: {
|
||||
outDir: "dist",
|
||||
|
||||
Reference in New Issue
Block a user