Skip to content

Commit 4a0e250

Browse files
committed
stylings
1 parent d8a4aa6 commit 4a0e250

8 files changed

Lines changed: 260 additions & 192 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { useState, useEffect, useRef } from "react";
2+
3+
interface Collection {
4+
ownerSlug: string;
5+
slug: string;
6+
name: string;
7+
description?: string;
8+
}
9+
10+
export default function CollectionExplorer() {
11+
const [query, setQuery] = useState("");
12+
const [collections, setCollections] = useState<Collection[]>([]);
13+
const [loading, setLoading] = useState(true);
14+
const timerRef = useRef<ReturnType<typeof setTimeout>>();
15+
16+
async function load(q = "") {
17+
const params = new URLSearchParams();
18+
if (q) params.set("q", q);
19+
try {
20+
const res = await fetch(`/api/collections?${params}`);
21+
const data = await res.json();
22+
setCollections(data);
23+
} catch {
24+
setCollections([]);
25+
}
26+
setLoading(false);
27+
}
28+
29+
useEffect(() => {
30+
load();
31+
}, []);
32+
33+
function handleInput(value: string) {
34+
setQuery(value);
35+
clearTimeout(timerRef.current);
36+
timerRef.current = setTimeout(() => load(value), 300);
37+
}
38+
39+
return (
40+
<>
41+
<div className="flex gap-3 mb-6">
42+
<input
43+
type="search"
44+
placeholder="Search collections..."
45+
className="flex-1 bg-parchment border border-rule px-3 py-2 text-sm font-mono placeholder:text-ink-muted focus:outline-none focus:border-ink"
46+
value={query}
47+
onChange={(e) => handleInput(e.target.value)}
48+
/>
49+
</div>
50+
51+
<div className="space-y-2">
52+
{loading ? (
53+
<p className="text-sm text-ink-muted">Loading...</p>
54+
) : collections.length === 0 ? (
55+
<p className="text-sm text-ink-muted">No collections found.</p>
56+
) : (
57+
collections.map((c) => (
58+
<a
59+
key={`${c.ownerSlug}/${c.slug}`}
60+
href={`/${c.ownerSlug}/${c.slug}`}
61+
className="block border border-rule p-3 hover:bg-parchment-dark transition-colors"
62+
>
63+
<div className="flex items-center gap-2 mb-1">
64+
<span className="font-semibold text-sm">
65+
{c.ownerSlug}/{c.slug}
66+
</span>
67+
</div>
68+
<p className="text-xs text-ink-muted">{c.description ?? c.name}</p>
69+
</a>
70+
))
71+
)}
72+
</div>
73+
</>
74+
);
75+
}

src/components/DocsSearch.tsx

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { useState, useEffect, useRef } from "react";
2+
3+
interface DocEntry {
4+
title: string;
5+
href: string;
6+
headings: string[];
7+
}
8+
9+
const docs: DocEntry[] = [
10+
{ title: "Overview", href: "/docs", headings: ["Getting started", "API reference", "Self-hosting"] },
11+
{ title: "Concepts", href: "/docs/concepts", headings: ["Collection", "Version", "Record", "File", "Accounts"] },
12+
{ title: "Quickstart", href: "/docs/quickstart", headings: ["Create an account", "Create an API key", "Create a collection", "Push a version", "Read it back", "Push an update", "Diff versions", "Working with files", "Next steps"] },
13+
{ title: "Integration Guide", href: "/docs/integration", headings: ["What is Underlay?", "Core Concepts", "Authentication", "The Push Flow", "Record Format", "First Push Example", "Mapping a SQL Database", "Versioning", "API Reference", "Error Handling", "Source Code"] },
14+
{ title: "Self-hosting", href: "/docs/self-host", headings: ["Requirements", "Quick start with Docker", "Environment variables", "Production deployment", "Secrets management", "CI/CD", "Backups", "Reverse proxy"] },
15+
{ title: "Accounts API", href: "/docs/api/accounts", headings: ["Authentication", "POST /api/accounts/signup", "POST /api/accounts/login", "POST /api/accounts/logout", "GET /api/accounts/me", "GET /api/accounts/:slug", "POST /api/accounts/keys", "GET /api/accounts/keys", "DELETE /api/accounts/keys/:id"] },
16+
{ title: "Collections API", href: "/docs/api/collections", headings: ["GET /api/collections", "POST /api/accounts/:owner/collections", "GET /api/collections/:owner/:slug", "PATCH /api/collections/:owner/:slug", "DELETE /api/collections/:owner/:slug", "GET /api/accounts/:owner/collections"] },
17+
{ title: "Versions API", href: "/docs/api/versions", headings: ["POST /api/collections/:owner/:slug/versions", "GET /api/collections/:owner/:slug/versions", "GET /api/collections/:owner/:slug/versions/latest", "GET /api/collections/:owner/:slug/versions/:n", "GET /api/collections/:owner/:slug/versions/:n/records", "GET /api/collections/:owner/:slug/versions/:n/manifest", "GET /api/collections/:owner/:slug/versions/:n/diff"] },
18+
{ title: "Files API", href: "/docs/api/files", headings: ["HEAD /api/collections/:owner/:slug/files/:hash", "GET /api/collections/:owner/:slug/files/:hash", "PUT /api/collections/:owner/:slug/files/:hash", "File references in records"] },
19+
];
20+
21+
export default function DocsSearch() {
22+
const [query, setQuery] = useState("");
23+
const [showResults, setShowResults] = useState(false);
24+
const rootRef = useRef<HTMLDivElement>(null);
25+
26+
const q = query.trim().toLowerCase();
27+
28+
const matches: { title: string; href: string; context?: string }[] = [];
29+
if (q) {
30+
for (const doc of docs) {
31+
const titleMatch = doc.title.toLowerCase().includes(q);
32+
const headingMatches = doc.headings.filter((h) => h.toLowerCase().includes(q));
33+
if (titleMatch) {
34+
matches.push({ title: doc.title, href: doc.href });
35+
}
36+
for (const h of headingMatches) {
37+
if (!titleMatch || headingMatches.length > 0) {
38+
const slug = h.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
39+
matches.push({ title: doc.title, href: `${doc.href}#${slug}`, context: h });
40+
}
41+
}
42+
}
43+
}
44+
45+
useEffect(() => {
46+
function handleClickOutside(e: MouseEvent) {
47+
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
48+
setShowResults(false);
49+
}
50+
}
51+
document.addEventListener("click", handleClickOutside);
52+
return () => document.removeEventListener("click", handleClickOutside);
53+
}, []);
54+
55+
function handleKeyDown(e: React.KeyboardEvent) {
56+
if (e.key === "Escape") {
57+
setShowResults(false);
58+
(e.target as HTMLInputElement).blur();
59+
}
60+
}
61+
62+
return (
63+
<div ref={rootRef} className="docs-search-box">
64+
<input
65+
type="text"
66+
placeholder="Search docs..."
67+
autoComplete="off"
68+
className="w-full border border-rule bg-parchment px-2.5 py-1.5 text-xs placeholder:text-ink-muted/50 focus:outline-none focus:border-ink-muted"
69+
value={query}
70+
onChange={(e) => {
71+
setQuery(e.target.value);
72+
setShowResults(true);
73+
}}
74+
onKeyDown={handleKeyDown}
75+
/>
76+
{showResults && q && (
77+
<div id="docs-search-results">
78+
{matches.length === 0 ? (
79+
<div className="docs-search-empty">No results</div>
80+
) : (
81+
matches.slice(0, 12).map((m, i) => (
82+
<a key={i} href={m.href} className="docs-search-result">
83+
<span className="docs-search-result-title">{m.title}</span>
84+
{m.context && <span className="docs-search-result-context">{m.context}</span>}
85+
</a>
86+
))
87+
)}
88+
</div>
89+
)}
90+
</div>
91+
);
92+
}

src/components/UserMenu.tsx

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { useState, useEffect, useRef } from "react";
2+
3+
interface UserMenuProps {
4+
slug: string;
5+
}
6+
7+
export default function UserMenu({ slug }: UserMenuProps) {
8+
const [open, setOpen] = useState(false);
9+
const rootRef = useRef<HTMLDivElement>(null);
10+
const hideTimeout = useRef<ReturnType<typeof setTimeout>>();
11+
12+
function show() {
13+
clearTimeout(hideTimeout.current);
14+
setOpen(true);
15+
}
16+
17+
function scheduleHide() {
18+
hideTimeout.current = setTimeout(() => setOpen(false), 150);
19+
}
20+
21+
useEffect(() => {
22+
function handleClickOutside(e: MouseEvent) {
23+
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
24+
setOpen(false);
25+
}
26+
}
27+
document.addEventListener("click", handleClickOutside);
28+
return () => document.removeEventListener("click", handleClickOutside);
29+
}, []);
30+
31+
return (
32+
<div
33+
ref={rootRef}
34+
className="relative"
35+
onMouseEnter={show}
36+
onMouseLeave={scheduleHide}
37+
>
38+
<button
39+
className="hover:text-ink transition-colors font-medium text-ink cursor-pointer"
40+
type="button"
41+
onClick={() => setOpen((v) => !v)}
42+
>
43+
{slug}
44+
</button>
45+
{open && (
46+
<div className="absolute right-0 top-full pt-1 z-50">
47+
<div className="bg-parchment border border-rule shadow-sm min-w-40">
48+
<a
49+
href={`/${slug}`}
50+
className="block px-3 py-2 text-sm text-ink-light hover:bg-parchment-dark transition-colors"
51+
>
52+
Your Profile
53+
</a>
54+
<a
55+
href="/dashboard"
56+
className="block px-3 py-2 text-sm text-ink-light hover:bg-parchment-dark transition-colors"
57+
>
58+
Dashboard
59+
</a>
60+
<hr className="border-rule" />
61+
<a
62+
href="/logout"
63+
className="block px-3 py-2 text-sm text-ink-muted hover:bg-parchment-dark transition-colors"
64+
>
65+
Sign out
66+
</a>
67+
</div>
68+
</div>
69+
)}
70+
</div>
71+
);
72+
}

src/layouts/Base.astro

Lines changed: 5 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
---
2+
import UserMenu from "@/components/UserMenu";
3+
24
interface Props {
35
title: string;
46
description?: string | undefined;
@@ -37,7 +39,7 @@ if (sessionCookie) {
3739
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap"
3840
rel="stylesheet"
3941
/>
40-
<style>@import "../styles/global.css";</style>
42+
<style is:global>@import "../styles/global.css";</style>
4143
</head>
4244
<body class="min-h-screen font-sans text-[15px] leading-relaxed">
4345
<header class="border-b border-rule">
@@ -55,28 +57,9 @@ if (sessionCookie) {
5557
<a href="/docs" class="hover:text-ink transition-colors">Docs</a>
5658
<a href="/blog" class="hover:text-ink transition-colors">Blog</a>
5759
{currentUser ? (
58-
<div class="relative" id="user-menu">
59-
<button
60-
class="hover:text-ink transition-colors font-medium text-ink cursor-pointer"
61-
id="user-menu-btn"
62-
type="button"
63-
>
64-
{currentUser.slug}
65-
</button>
66-
<div class="hidden absolute right-0 top-full pt-1 z-50" id="user-menu-popover">
67-
<div class="bg-parchment border border-rule shadow-sm min-w-40">
68-
<a href={`/${currentUser.slug}`} class="block px-3 py-2 text-sm text-ink-light hover:bg-parchment-dark transition-colors">Your Profile</a>
69-
<a href="/dashboard" class="block px-3 py-2 text-sm text-ink-light hover:bg-parchment-dark transition-colors">Dashboard</a>
70-
<hr class="border-rule" />
71-
<a href="/logout" class="block px-3 py-2 text-sm text-ink-muted hover:bg-parchment-dark transition-colors">Sign out</a>
72-
</div>
73-
</div>
74-
</div>
60+
<UserMenu slug={currentUser.slug} client:load />
7561
) : (
76-
<>
77-
<a href="/login" class="hover:text-ink transition-colors">Log in</a>
78-
<a href="/signup" class="hover:text-ink transition-colors font-medium text-ink">Sign up</a>
79-
</>
62+
<a href="/login" class="hover:text-ink transition-colors">Log in</a>
8063
)}
8164
</div>
8265
</nav>
@@ -102,35 +85,3 @@ if (sessionCookie) {
10285
</body>
10386
</html>
10487

105-
<script>
106-
const menuBtn = document.getElementById("user-menu-btn");
107-
const menuPopover = document.getElementById("user-menu-popover");
108-
const menuRoot = document.getElementById("user-menu");
109-
110-
if (menuBtn && menuPopover && menuRoot) {
111-
let hideTimeout: ReturnType<typeof setTimeout>;
112-
113-
function show() {
114-
clearTimeout(hideTimeout);
115-
menuPopover!.classList.remove("hidden");
116-
}
117-
118-
function scheduleHide() {
119-
hideTimeout = setTimeout(() => {
120-
menuPopover!.classList.add("hidden");
121-
}, 150);
122-
}
123-
124-
menuRoot.addEventListener("mouseenter", show);
125-
menuRoot.addEventListener("mouseleave", scheduleHide);
126-
menuBtn.addEventListener("click", () => {
127-
menuPopover!.classList.toggle("hidden");
128-
});
129-
130-
document.addEventListener("click", (e) => {
131-
if (!menuRoot.contains(e.target as Node)) {
132-
menuPopover!.classList.add("hidden");
133-
}
134-
});
135-
}
136-
</script>

0 commit comments

Comments
 (0)