Files
Containr/app/frontend/src/features/docs/DocsBrowser.tsx
T
Tomáš DvořákandDevin f88b4f1bf3 fix(ui): UI/UX pass — connectivity probe, mobile overflow, real metrics, deep links (#85)
Found during a browser-driven audit of the whole app:

- connectivity banner showed permanently: pingServer() resolved
  undefined, which React Query treats as an error; returns boolean now
- Vite dev proxy now forwards /health so the probe actually measures
  the backend, and the API target is env-overridable (VITE_API_PROXY)
- auth-session removed from the persisted query cache — stale sessions
  no longer rehydrate into a false signed-in flash
- workspace Observability tab no longer shows fabricated random metrics
  ("87K Active User", fake performance/speed-test cards); the dashboard
  now aggregates real per-service metrics (CPU, memory, network, instances)
- service detail tabs write ?section= so tab state is shareable/deep-linked
- Security page auto-selects the first project instead of rendering blank
- People page user-creation form no longer overflows its card
  (minmax(0,1fr) grid tracks; same fix on env-var rows)
- page padding is responsive (px-4 sm:px-8) so mobile keeps content width;
  Projects header wraps instead of clipping the "+ new project" button
- template deploy modal shows a loading state while the session resolves
  instead of flashing the sign-in prompt
- audit log table strips the CIDR suffix from displayed IPs
- docs list excerpts no longer repeat the document title
- activity filter placeholder "Search search…" → "Search events…"
- workspace view tabs get more room: ⌘K button now waits for lg:

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 10:56:33 +02:00

199 lines
7.7 KiB
TypeScript

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(/^#[^\n]*\n/, ' ')
.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-4 py-6 sm:px-8">
<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>
);
}