mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-29 00:01:22 +00:00
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>
This commit is contained in:
1 parent
8f5789bf93
commit
8b6214a0f5
62 files changed
+4986
-1133
No files matched your search
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { BookOpen, CloudOff, FileText, Search } from 'lucide-react';
|
||||
import { initialDocs, syncDocs, type DocEntry, type DocsState } from './docs-store';
|
||||
|
||||
marked.setOptions({ gfm: true, breaks: false });
|
||||
|
||||
function renderMarkdown(markdown: string): string {
|
||||
const html = marked.parse(markdown, { async: false });
|
||||
return DOMPurify.sanitize(typeof html === 'string' ? html : '', {
|
||||
USE_PROFILES: { html: true },
|
||||
});
|
||||
}
|
||||
|
||||
function excerpt(markdown: string, length = 140): string {
|
||||
const text = markdown
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/[#>*`\-[\]()]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return text.length > length ? `${text.slice(0, length)}…` : text;
|
||||
}
|
||||
|
||||
export function DocsBrowser() {
|
||||
const [state, setState] = useState<DocsState>(() => initialDocs());
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
syncDocs(setState);
|
||||
}, []);
|
||||
|
||||
const docs = state.docs;
|
||||
|
||||
const sections = useMemo(() => {
|
||||
const grouped = new Map<string, DocEntry[]>();
|
||||
for (const doc of docs) {
|
||||
const list = grouped.get(doc.section) ?? [];
|
||||
list.push(doc);
|
||||
grouped.set(doc.section, list);
|
||||
}
|
||||
return [...grouped.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [docs]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return null;
|
||||
return docs.filter(
|
||||
(doc) =>
|
||||
doc.title.toLowerCase().includes(q) ||
|
||||
doc.path.toLowerCase().includes(q) ||
|
||||
doc.markdown.toLowerCase().includes(q),
|
||||
);
|
||||
}, [docs, query]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => docs.find((doc) => doc.path === selectedPath) ?? null,
|
||||
[docs, selectedPath],
|
||||
);
|
||||
|
||||
const selectedHtml = useMemo(
|
||||
() => (selected ? renderMarkdown(selected.markdown) : ''),
|
||||
[selected],
|
||||
);
|
||||
|
||||
const sourceBadge =
|
||||
state.source === 'github'
|
||||
? { label: 'live · github', cls: 'text-[var(--success)]' }
|
||||
: state.source === 'cache'
|
||||
? { label: `cached · ${state.syncedAt ? new Date(state.syncedAt).toLocaleDateString() : 'offline'}`, cls: 'text-[var(--warning)]' }
|
||||
: { label: 'bundled copy', cls: 'text-[var(--text-tertiary)]' };
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[480px] overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
|
||||
<div className="flex w-[300px] shrink-0 flex-col border-r border-[var(--border-subtle)] max-md:w-full">
|
||||
<div className="border-b border-[var(--border-subtle)] p-3">
|
||||
<div className="flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2.5 py-1.5">
|
||||
<Search size={13} className="shrink-0 text-[var(--text-tertiary)]" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search docs…"
|
||||
className="w-full bg-transparent text-[13px] text-[var(--text-primary)] outline-none placeholder:text-[var(--text-tertiary)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between px-0.5">
|
||||
<span className={`text-[10.5px] font-medium uppercase tracking-wider ${sourceBadge.cls}`}>
|
||||
{sourceBadge.label}
|
||||
</span>
|
||||
<span className="text-[10.5px] text-[var(--text-tertiary)]">{docs.length} docs</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{filtered ? (
|
||||
filtered.length > 0 ? (
|
||||
filtered.map((doc) => (
|
||||
<DocListItem key={doc.path} doc={doc} active={doc.path === selectedPath} onSelect={setSelectedPath} showSection />
|
||||
))
|
||||
) : (
|
||||
<p className="px-2 py-6 text-center text-xs text-[var(--text-tertiary)]">No docs match “{query}”.</p>
|
||||
)
|
||||
) : (
|
||||
sections.map(([section, entries]) => (
|
||||
<div key={section} className="mb-3">
|
||||
<p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-tertiary)]">
|
||||
{section}
|
||||
</p>
|
||||
{entries.map((doc) => (
|
||||
<DocListItem key={doc.path} doc={doc} active={doc.path === selectedPath} onSelect={setSelectedPath} />
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 overflow-y-auto max-md:hidden">
|
||||
{selected ? (
|
||||
<article className="docs-article px-8 py-6">
|
||||
<p className="mb-1 text-[11px] font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
|
||||
{selected.section} / {selected.path}
|
||||
</p>
|
||||
<div dangerouslySetInnerHTML={{ __html: selectedHtml }} />
|
||||
</article>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<BookOpen size={28} className="text-[var(--text-tertiary)]" />
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Select a document</p>
|
||||
<p className="max-w-sm text-xs leading-relaxed text-[var(--text-tertiary)]">
|
||||
Documentation is bundled with the app and refreshes from GitHub in the background.
|
||||
{!state.syncedAt ? ' Currently showing the bundled copy.' : ''}
|
||||
</p>
|
||||
{state.source !== 'github' ? (
|
||||
<p className="flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)]">
|
||||
<CloudOff size={12} /> Offline-safe — updates apply automatically when a connection is available.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile: tapping a doc replaces the list */}
|
||||
{selected ? (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--bg-base)] md:hidden">
|
||||
<div className="flex items-center gap-2 border-b border-[var(--border-subtle)] p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedPath(null)}
|
||||
className="rounded-[var(--radius-md)] px-2.5 py-1.5 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<span className="truncate text-xs text-[var(--text-tertiary)]">{selected.title}</span>
|
||||
</div>
|
||||
<article className="docs-article flex-1 overflow-y-auto px-5 py-4">
|
||||
<div dangerouslySetInnerHTML={{ __html: selectedHtml }} />
|
||||
</article>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocListItem({
|
||||
doc,
|
||||
active,
|
||||
onSelect,
|
||||
showSection = false,
|
||||
}: {
|
||||
doc: DocEntry;
|
||||
active: boolean;
|
||||
onSelect: (path: string) => void;
|
||||
showSection?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(doc.path)}
|
||||
className={`mb-0.5 w-full rounded-[var(--radius-md)] px-2.5 py-2 text-left transition-colors ${
|
||||
active
|
||||
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<FileText size={13} className="shrink-0 opacity-60" />
|
||||
<span className="truncate text-[13px] font-medium">{doc.title}</span>
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate pl-5 text-[11px] text-[var(--text-tertiary)]">
|
||||
{showSection ? `${doc.section} · ` : ''}{excerpt(doc.markdown, 80)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
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));
|
||||
}
|
||||
Reference in new issue
Block a user