diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 6638210..b6083f1 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -1,9 +1,15 @@ import { - Activity, + ChevronRight, + Clock3, Database, + FileText, + Folder, FolderGit2, + FolderOpen, LogIn, LogOut, + PanelLeftClose, + PanelLeftOpen, RefreshCw, Shield, UserPlus, @@ -12,6 +18,7 @@ import { import { useEffect, useState } from "preact/hooks"; type Role = "admin" | "editor" | "viewer"; +type View = "workspace" | "users"; type User = { id: string; @@ -47,32 +54,66 @@ type Session = { user: User; }; +type BrowserItem = { + type: "folder" | "document"; + name: string; + path: string; + document?: DocumentRecord; + indexDocument?: DocumentRecord; +}; + +type BrowserColumn = { + title: string; + prefix: string; + items: BrowserItem[]; +}; + const sessionKey = "mdhub.admin.session"; export function App() { const [session, setSession] = useState(() => readSession()); + const [activeView, setActiveView] = useState("workspace"); + const [sidebarOpen, setSidebarOpen] = useState(true); const [users, setUsers] = useState([]); const [workspace, setWorkspace] = useState(null); - const [status, setStatus] = useState("Idle"); + const [syncing, setSyncing] = useState(false); + const [lastSyncedAt, setLastSyncedAt] = useState(""); + const [activePath, setActivePath] = useState(""); + const [selectedUserId, setSelectedUserId] = useState(""); 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"); + setLastSyncedAt(new Date().toISOString()); } useEffect(() => { void refresh(); }, [session?.user.email]); + useEffect(() => { + const documents = workspace?.documents ?? []; + if ( + documents.length > 0 && + !isKnownDocumentLocation(documents, activePath) + ) { + setActivePath(documents[0].path); + } + }, [workspace?.documents, activePath]); + + useEffect(() => { + if (users.length > 0 && !users.some((user) => user.id === selectedUserId)) { + setSelectedUserId(users[0].id); + } + }, [users, selectedUserId]); + async function handleLogin(email: string, displayName: string) { const response = await api<{ user: User }>("/api/admin/login", { method: "POST", @@ -88,17 +129,22 @@ export function App() { displayName: string; role: Role; }) { - await api<{ user: User }>("/api/admin/users", { + const response = await api<{ user: User }>("/api/admin/users", { method: "POST", body: JSON.stringify(input), }); await refresh(); + setSelectedUserId(response.user.id); } async function handleSync() { - setStatus("Syncing"); - await api("/api/admin/workspace/sync", { method: "POST" }); - await refresh(); + setSyncing(true); + try { + await api("/api/admin/workspace/sync", { method: "POST" }); + await refresh(); + } finally { + setSyncing(false); + } } function logout() { @@ -106,56 +152,122 @@ export function App() { setSession(null); setUsers([]); setWorkspace(null); - setStatus("Idle"); + setActivePath(""); + setSelectedUserId(""); } if (!session) { return ; } + const selectedDocument = selectDocument( + workspace?.documents ?? [], + activePath, + ); + const selectedUser = + users.find((user) => user.id === selectedUserId) ?? users[0]; + return ( -
+
-
+
-

Admin

-

Workspace control

+ + + {activeView === "workspace" ? "Documents" : "Accounts"} +
- - - {status} + {activeView === "workspace" && ( + + )} + + + {syncing + ? "Syncing" + : lastSyncedAt + ? `Synced ${formatTime(lastSyncedAt)}` + : "Not synced"} -
- - + {activeView === "workspace" ? ( + + ) : ( + + )}
); @@ -182,7 +294,7 @@ function LoginScreen(props: {
@@ -213,47 +325,170 @@ function LoginScreen(props: { ); } -function WorkspacePanel(props: { data: WorkspaceResponse | null }) { - const workspace = props.data?.workspace; +function WorkspaceView(props: { + data: WorkspaceResponse | null; + activePath: string; + selectedDocument?: DocumentRecord; + onSelectPath: (path: string) => void; +}) { + const documents = props.data?.documents ?? []; + const columns = buildDocumentColumns(documents, props.activePath); + return ( -
-
-
-

Workspace

-

Storage and documents

-
- -
- -
- - - - -
- -
-
- Document - Hash - Updated -
- {(props.data?.documents ?? []).map((document) => ( -
- - {document.title} - - {document.currentHash.slice(0, 12)} - {formatDate(document.updatedAt)} +
+
+ {columns.map((column) => ( +
+
{column.title}
+
+ {column.items.map((item) => ( + + ))} + {column.items.length === 0 && ( +

No documents

+ )} +
))} -
-
+ + + + ); } -function UsersPanel(props: { +function WorkspaceDetails(props: { + workspace?: Workspace; + document?: DocumentRecord; +}) { + return ( + + ); +} + +function UsersView(props: { users: User[]; + selectedUser?: User; + onSelectUser: (id: string) => void; + onCreateUser: (input: { + email: string; + displayName: string; + role: Role; + }) => Promise; +}) { + return ( +
+
+
Users
+
+ {props.users.map((user) => ( + + ))} +
+
+ + +
+ ); +} + +function UserDetails(props: { + selectedUser?: User; onCreateUser: (input: { email: string; displayName: string; @@ -273,16 +508,13 @@ function UsersPanel(props: { } return ( -
-
-
-

Users

-

Accounts and roles

-
- -
+
+
+
Role
+
{props.selectedUser.role}
+
+
+
Created
+
{formatDate(props.selectedUser.createdAt)}
+
+ + + )} + ); } -function Metric(props: { label: string; value: string | number }) { +function buildDocumentColumns( + documents: DocumentRecord[], + activePath: string, +): BrowserColumn[] { + const activeSegments = activePath.split("/").filter(Boolean); + const prefixes = [""]; + const maxFolderDepth = activePath.endsWith(".md") + ? activeSegments.length - 1 + : activeSegments.length; + + for (let index = 0; index < maxFolderDepth; index += 1) { + prefixes.push(activeSegments.slice(0, index + 1).join("/")); + } + + return prefixes.map((prefix) => ({ + title: prefix ? folderLabel(prefix) : "Documents", + prefix, + items: collectItems(documents, prefix), + })); +} + +function collectItems( + documents: DocumentRecord[], + prefix: string, +): BrowserItem[] { + const items = new Map(); + const prefixWithSlash = prefix ? `${prefix}/` : ""; + + for (const document of documents) { + if (!document.path.startsWith(prefixWithSlash)) { + continue; + } + + const remainder = document.path.slice(prefixWithSlash.length); + if (!remainder || remainder === "index.md") { + continue; + } + + const [nextSegment, ...rest] = remainder.split("/"); + if (rest.length > 0) { + const folderPath = `${prefixWithSlash}${nextSegment}`; + const indexDocument = documents.find( + (candidate) => candidate.path === `${folderPath}/index.md`, + ); + items.set(folderPath, { + type: "folder", + name: folderLabel(nextSegment), + path: folderPath, + indexDocument, + }); + continue; + } + + items.set(document.path, { + type: "document", + name: document.title || fileLabel(document.path), + path: document.path, + document, + }); + } + + return [...items.values()].sort((left, right) => { + if (left.type !== right.type) { + return left.type === "folder" ? -1 : 1; + } + return left.name.localeCompare(right.name); + }); +} + +function selectDocument(documents: DocumentRecord[], activePath: string) { + if (!activePath) { + return documents[0]; + } return ( -
- {props.label} - {props.value} -
+ documents.find((document) => document.path === activePath) ?? + documents.find((document) => document.path === `${activePath}/index.md`) ); } +function isKnownDocumentLocation(documents: DocumentRecord[], path: string) { + if (!path) { + return false; + } + return documents.some( + (document) => + document.path === path || + document.path === `${path}/index.md` || + document.path.startsWith(`${path}/`), + ); +} + +function isActiveItem(item: BrowserItem, activePath: string) { + return ( + activePath === item.path || + activePath === item.indexDocument?.path || + activePath.startsWith(`${item.path}/`) + ); +} + +function documentHref(path: string) { + return `/docs/${path.replace(/(^|\/)index\.md$/, "").replace(/\.md$/, "")}`; +} + +function folderLabel(path: string) { + return fileLabel(path.split("/").filter(Boolean).at(-1) ?? path); +} + +function fileLabel(path: string) { + return path + .replace(/\.md$/, "") + .replace(/[-_]/g, " ") + .replace(/\b\w/g, (match) => match.toUpperCase()); +} + async function api(path: string, init?: RequestInit): Promise { const response = await fetch(path, { ...init, @@ -368,3 +713,10 @@ function formatDate(value: string) { timeStyle: "short", }).format(new Date(value)); } + +function formatTime(value: string) { + return new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", + }).format(new Date(value)); +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 3d371eb..6059151 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1,14 +1,14 @@ :root { color-scheme: light; --bg: #f4f1ea; - --surface: rgba(255, 255, 255, 0.78); + --surface: rgba(255, 255, 255, 0.76); --surface-strong: #ffffff; --text: #202227; - --muted: #5f6471; + --muted: #626775; --accent: #1947e5; - --accent-2: #0f8a6c; + --accent-soft: rgba(25, 71, 229, 0.1); --border: rgba(32, 34, 39, 0.12); - --shadow: 0 20px 60px rgba(25, 71, 229, 0.12); + --shadow: 0 18px 54px rgba(25, 71, 229, 0.1); font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif; } @@ -23,15 +23,15 @@ body { background: radial-gradient( circle at top left, - rgba(25, 71, 229, 0.16), - transparent 36% + rgba(25, 71, 229, 0.12), + transparent 34% ), radial-gradient( circle at bottom right, - rgba(15, 138, 108, 0.18), + rgba(15, 138, 108, 0.14), transparent 30% ), - linear-gradient(180deg, #faf7f2 0%, var(--bg) 100%); + linear-gradient(180deg, #fbf8f2 0%, var(--bg) 100%); } button, @@ -44,6 +44,11 @@ button { cursor: pointer; } +button:disabled { + cursor: wait; + opacity: 0.72; +} + a { color: var(--accent); } @@ -54,228 +59,341 @@ code { .admin-shell { display: grid; - grid-template-columns: 15rem minmax(0, 1fr); + grid-template-columns: 11rem minmax(0, 1fr); min-height: 100vh; + transition: grid-template-columns 160ms ease; +} + +.admin-shell.is-collapsed { + grid-template-columns: 4rem minmax(0, 1fr); } .admin-sidebar { display: flex; + min-width: 0; flex-direction: column; - gap: 1.5rem; - padding: 1.25rem; + gap: 1rem; + padding: 0.8rem; border-right: 1px solid var(--border); - background: rgba(255, 255, 255, 0.5); - backdrop-filter: blur(12px); + background: rgba(255, 255, 255, 0.55); + backdrop-filter: blur(14px); } +.sidebar-header, +.compact-topbar, +.topbar-actions, +.detail-header, .brand, -.admin-sidebar nav a, -.ghost-button, +.nav-list button, +.nav-button, +.icon-button, .primary-button, -.status-pill { - display: inline-flex; +.sync-label, +.list-item { + display: flex; align-items: center; - gap: 0.5rem; +} + +.sidebar-header { + justify-content: space-between; + gap: 0.4rem; } .brand { + min-width: 0; + gap: 0.5rem; color: var(--text); font-weight: 700; text-decoration: none; } -.admin-sidebar nav { - display: grid; - gap: 0.35rem; +.brand span, +.nav-list span, +.nav-button span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.admin-sidebar nav a, -.ghost-button { - min-height: 2.4rem; - padding: 0.55rem 0.65rem; +.icon-button, +.nav-list button, +.nav-button { 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 { +.icon-button { + justify-content: center; + width: 2rem; + height: 2rem; + flex: 0 0 auto; + border-radius: 0.45rem; +} + +.icon-button:hover, +.nav-list button:hover, +.nav-button:hover, +.nav-list button.is-active { color: var(--accent); - background: var(--accent-soft, rgba(25, 71, 229, 0.1)); + background: var(--accent-soft); } -.ghost-button { +.nav-list { + display: grid; + gap: 0.25rem; +} + +.nav-list button, +.nav-button { + min-width: 0; + min-height: 2.25rem; + gap: 0.55rem; + padding: 0.45rem 0.55rem; + border-radius: 0.5rem; + text-align: left; +} + +.nav-button { margin-top: auto; } +.admin-shell.is-collapsed .brand span, +.admin-shell.is-collapsed .nav-list span, +.admin-shell.is-collapsed .nav-button span { + display: none; +} + +.admin-shell.is-collapsed .sidebar-header, +.admin-shell.is-collapsed .nav-list button, +.admin-shell.is-collapsed .nav-button { + justify-content: center; +} + .admin-main { min-width: 0; - padding: 1.5rem; + padding: 0.85rem; } -.admin-topbar { - display: flex; - align-items: center; +.compact-topbar { + min-height: 3rem; justify-content: space-between; gap: 1rem; - margin-bottom: 1rem; + margin-bottom: 0.8rem; } -.admin-topbar h1, -.panel h2, -.login-panel h1 { - margin: 0; +.compact-topbar > div:first-child { + display: grid; + gap: 0.1rem; } -.topbar-actions { - display: flex; - gap: 0.65rem; - align-items: center; -} - -.eyebrow { - margin: 0 0 0.35rem; +.section-label { color: var(--accent); font-family: ui-monospace, SFMono-Regular, monospace; - font-size: 0.75rem; + font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; } -.status-pill, -.role-pill { - min-height: 2.2rem; - padding: 0.35rem 0.6rem; +.topbar-actions { + gap: 0.55rem; +} + +.sync-label { + gap: 0.4rem; + min-height: 2.1rem; + padding: 0.25rem 0.55rem; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); - background: var(--surface); + background: rgba(255, 255, 255, 0.58); + font-size: 0.92rem; } .primary-button { - min-height: 2.4rem; - padding: 0.55rem 0.8rem; + justify-content: center; + gap: 0.45rem; + min-height: 2.25rem; + padding: 0.45rem 0.75rem; border: 0; border-radius: 0.5rem; color: white; background: var(--accent); } -.panel, +.sync-button { + width: 2.25rem; + padding-inline: 0; + flex: 0 0 auto; +} + +.sync-button span { + display: none; +} + +.workspace-grid { + display: grid; + grid-template-columns: minmax(12rem, 1fr) clamp(11.5rem, 20vw, 15rem); + gap: 0.65rem; + min-height: calc(100vh - 4.8rem); +} + +.column-browser { + display: grid; + grid-auto-columns: minmax(11rem, 1fr); + grid-auto-flow: column; + gap: 0.65rem; + min-width: 0; + overflow-x: auto; +} + +.browser-column, +.detail-panel, .login-panel { - margin-bottom: 1rem; - padding: 1rem; + min-width: 0; border: 1px solid var(--border); - border-radius: 0.5rem; + border-radius: 0.55rem; 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); -} - -.metric strong { - display: block; - margin-top: 0.35rem; +.browser-column, +.detail-panel { overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; } -.document-table, -.user-list { - display: grid; - gap: 0.35rem; -} - -.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 { +.browser-column > header, +.detail-header { + min-height: 2.5rem; + padding: 0.7rem 0.8rem; + border-bottom: 1px solid var(--border); color: var(--muted); font-family: ui-monospace, SFMono-Regular, monospace; - font-size: 0.76rem; + font-size: 0.72rem; + letter-spacing: 0.1em; text-transform: uppercase; } -.table-row:not(.table-row--head), -.user-row { - background: rgba(255, 255, 255, 0.48); +.detail-header { + gap: 0.45rem; } -.table-row > * { +.item-list { + display: grid; + gap: 0.15rem; + padding: 0.45rem; +} + +.list-item { + width: 100%; min-width: 0; + gap: 0.45rem; + min-height: 2.35rem; + padding: 0.45rem 0.5rem; + border: 0; + border-radius: 0.42rem; + color: var(--text); + background: transparent; + text-align: left; +} + +.list-item:hover, +.list-item.is-active { + color: var(--accent); + background: var(--accent-soft); +} + +.list-item span { + min-width: 0; + flex: 1; 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; +.list-item small { + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 0.72rem; + text-transform: uppercase; } -.user-form input, -.user-form select, +.user-column { + min-height: 22rem; +} + +.detail-panel { + padding-bottom: 0.9rem; +} + +.detail-panel h2 { + margin: 0.85rem 0.85rem 0.35rem; + font-size: 1.45rem; + line-height: 1.05; +} + +.open-link { + display: inline-flex; + margin: 0 0.85rem 0.7rem; + font-size: 0.95rem; +} + +.detail-list { + display: grid; + gap: 0.5rem; + margin: 0; + padding: 0.85rem; +} + +.detail-list.compact { + margin-top: 0.35rem; + border-top: 1px solid var(--border); +} + +.detail-list div { + min-width: 0; +} + +.detail-list dt { + margin-bottom: 0.16rem; + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.detail-list dd { + min-width: 0; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stack-form { + display: grid; + gap: 0.5rem; + padding: 0.85rem; + border-bottom: 1px solid var(--border); +} + +.stack-form input, +.stack-form select, .login-panel input { width: 100%; - min-height: 2.4rem; - padding: 0.5rem 0.65rem; + min-height: 2.25rem; + padding: 0.45rem 0.6rem; border: 1px solid var(--border); - border-radius: 0.5rem; + border-radius: 0.45rem; color: var(--text); background: rgba(255, 255, 255, 0.72); } -.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; +.empty-state { + margin: 0; + padding: 0.8rem; + color: var(--muted); } .login-shell { @@ -286,21 +404,27 @@ code { } .login-panel { - width: min(24rem, 100%); display: grid; - gap: 0.85rem; + width: min(23rem, 100%); + gap: 0.75rem; + padding: 1rem; +} + +.login-panel h1 { + margin: 0; + font-size: 1.6rem; } .login-panel label { display: grid; - gap: 0.35rem; + gap: 0.3rem; color: var(--muted); } .login-mark { display: inline-flex; - width: 2.6rem; - height: 2.6rem; + width: 2.4rem; + height: 2.4rem; align-items: center; justify-content: center; border-radius: 0.5rem; @@ -313,8 +437,9 @@ code { color: #a43131; } -@media (max-width: 920px) { - .admin-shell { +@media (max-width: 1080px) { + .admin-shell, + .admin-shell.is-collapsed { grid-template-columns: 1fr; } @@ -326,23 +451,54 @@ code { align-items: center; } - .admin-sidebar nav { + .nav-list { display: flex; - margin-left: auto; } - .ghost-button { + .nav-button { margin-top: 0; } - .metric-grid, - .user-form, - .table-row { + .admin-shell.is-collapsed .brand span, + .admin-shell.is-collapsed .nav-list span, + .admin-shell.is-collapsed .nav-button span { + display: inline; + } + + .workspace-grid { grid-template-columns: 1fr; } - .admin-topbar { + .sync-label { + display: none; + } + + .column-browser { + min-height: 18rem; + } +} + +@media (max-width: 640px) { + .admin-sidebar, + .compact-topbar { align-items: flex-start; flex-direction: column; } + + .sidebar-header, + .topbar-actions, + .nav-list { + width: 100%; + } + + .nav-list button, + .nav-button, + .primary-button { + width: 100%; + } + + .column-browser { + grid-auto-flow: row; + grid-auto-columns: auto; + } }