Files
Containr/app/frontend/src/features/docs/docs-store.ts
T
Tomas DvorakandDevin 8b6214a0f5 feat: public platform, admin console, templates, landing docs
Projects become publicly browsable after admin approval; management and
secret-adjacent reads (logs, variables, exec, connection URLs) require
owner/member/admin auth, and platform controls sit behind RequireAdmin.

- Backend: OptionalAuth middleware, read/authed/admin route groups,
  projects.is_approved + service_templates.owner_id migration,
  visibility-aware sqlc queries, admin overview/users/approval endpoints,
  EnsureAdminAccount bootstrap (ADMIN_* env), 30 seeded templates with
  owner CRUD, managed-database connection URLs gated owner+admin, fix
  DB.Stats() infinite recursion.
- Frontend: expandable persisted sidebar + clickable user menu, public
  landing page with searchable docs browser (GitHub sync, bundled
  snapshot + localStorage fallback), DocsBrowser shared with /docs,
  template create/edit/JSON-upload for owned templates, database create
  dialog, admin console (stats, approvals, project edit/delete, user
  admin toggles), HA/Security/Usage polish with dead CSS tokens mapped
  to real theme vars, anonymous-safe gating on every mutation control.
- Tests: safe window.localStorage access fixes happy-dom crashes in
  PlatformShell and canvas storage.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 19:58:59 +02:00

122 lines
3.7 KiB
TypeScript

import { bundledDocs, type BundledDoc } from '@/generated/docs-snapshot';
export type DocEntry = BundledDoc;
export type DocsState = {
docs: DocEntry[];
source: 'bundled' | 'cache' | 'github';
syncedAt: string | null;
};
const CACHE_KEY = 'containr.docs.v1';
const REPO = 'Dvorinka/Containr';
const BRANCH = 'main';
const SKIP_DIRS = new Set(['archive', 'design', 'superpowers']);
type CachePayload = { syncedAt: string; docs: DocEntry[] };
function readCache(): CachePayload | null {
try {
const raw = localStorage.getItem(CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as CachePayload;
if (!Array.isArray(parsed.docs) || typeof parsed.syncedAt !== 'string') return null;
return parsed;
} catch {
return null;
}
}
function writeCache(payload: CachePayload) {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(payload));
} catch {
// Storage full or unavailable — bundled docs still cover us.
}
}
function titleOf(markdown: string, fallback: string): string {
const match = markdown.match(/^#\s+(.+)$/m);
if (match) return match[1].replace(/[#*`]/g, '').trim();
return fallback
.replace(/\.md$/i, '')
.replace(/[-_]+/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}
type GitHubTreeEntry = { path?: string; type?: string };
async function fetchGitHubDocs(signal: AbortSignal): Promise<DocEntry[]> {
const treeRes = await fetch(
`https://api.github.com/repos/${REPO}/git/trees/${BRANCH}?recursive=1`,
{ signal, headers: { Accept: 'application/vnd.github+json' } },
);
if (!treeRes.ok) throw new Error(`tree fetch failed: ${treeRes.status}`);
const tree = (await treeRes.json()) as { tree?: GitHubTreeEntry[]; truncated?: boolean };
const paths = (tree.tree ?? [])
.filter(
(e) =>
e.type === 'blob' &&
typeof e.path === 'string' &&
e.path.startsWith('docs/') &&
e.path.toLowerCase().endsWith('.md') &&
!e.path.split('/').some((seg, i) => i > 0 && SKIP_DIRS.has(seg)),
)
.map((e) => e.path as string);
const docs = await Promise.all(
paths.map(async (path) => {
const res = await fetch(
`https://raw.githubusercontent.com/${REPO}/${BRANCH}/${path}`,
{ signal },
);
if (!res.ok) throw new Error(`doc fetch failed: ${path}`);
const markdown = await res.text();
const rel = path.slice('docs/'.length);
return {
path: rel,
section: rel.includes('/') ? rel.split('/')[0] : 'docs',
title: titleOf(markdown, rel.split('/').pop() ?? rel),
markdown,
};
}),
);
return docs;
}
/**
* Synchronous initial state — cache wins over the bundled snapshot so a
* previously synced copy survives offline reloads.
*/
export function initialDocs(): DocsState {
const cached = readCache();
return cached
? { docs: cached.docs, source: 'cache', syncedAt: cached.syncedAt }
: { docs: bundledDocs, source: 'bundled', syncedAt: null };
}
/**
* Background GitHub sync. `onUpdate` fires when fresh content lands so the
* UI can re-render without a reload; failures keep the current copy.
*/
export function syncDocs(onUpdate: (state: DocsState) => void): void {
if (typeof navigator !== 'undefined' && !navigator.onLine) {
return;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
fetchGitHubDocs(controller.signal)
.then((docs) => {
const syncedAt = new Date().toISOString();
writeCache({ syncedAt, docs });
onUpdate({ docs, source: 'github', syncedAt });
})
.catch(() => {
// Offline or rate-limited — bundled/cache copy already displayed.
})
.finally(() => clearTimeout(timeout));
}