Skip to content

Commit b3b4e25

Browse files
committed
Initial schemas ui
1 parent 4409995 commit b3b4e25

8 files changed

Lines changed: 556 additions & 1 deletion

File tree

src/components/CollectionNav.astro

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ interface Props {
44
collection: string;
55
isPublic?: boolean;
66
isOwner?: boolean;
7-
active: "overview" | "versions" | "settings";
7+
active: "overview" | "versions" | "schemas" | "settings";
88
versionLabel?: string; // e.g. "6.0.0" — shown as an extra tab between Versions and Settings
99
}
1010
@@ -34,6 +34,7 @@ const inactiveClass = `${linkClass} border-transparent text-ink-muted hover:text
3434
{versionLabel && (
3535
<span class={activeClass}>{versionLabel}</span>
3636
)}
37+
<a href={`/${owner}/${collection}/schemas`} class={active === "schemas" ? activeClass : inactiveClass}>Schemas</a>
3738
{isOwner && (
3839
<a href={`/${owner}/${collection}/settings`} class={`${active === "settings" ? activeClass : inactiveClass} ml-auto`}>Settings</a>
3940
)}

src/components/SchemaBrowser.tsx

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { useState, useEffect, useRef } from "react";
2+
3+
interface SchemaResult {
4+
id: string;
5+
schema: Record<string, unknown>;
6+
schemaHash: string;
7+
createdAt: string;
8+
labels: string[];
9+
}
10+
11+
export default function SchemaBrowser() {
12+
const [query, setQuery] = useState("");
13+
const [filterType, setFilterType] = useState<"q" | "label" | "slug">("q");
14+
const [schemas, setSchemas] = useState<SchemaResult[]>([]);
15+
const [loading, setLoading] = useState(true);
16+
const timerRef = useRef<ReturnType<typeof setTimeout>>();
17+
18+
async function load(q = "", type = filterType) {
19+
setLoading(true);
20+
const params = new URLSearchParams();
21+
if (q) params.set(type, q);
22+
params.set("limit", "50");
23+
try {
24+
const res = await fetch(`/api/schemas?${params}`);
25+
const data = await res.json();
26+
setSchemas(Array.isArray(data) ? data : data.id ? [data] : []);
27+
} catch {
28+
setSchemas([]);
29+
}
30+
setLoading(false);
31+
}
32+
33+
useEffect(() => {
34+
load();
35+
}, []);
36+
37+
function handleInput(value: string) {
38+
setQuery(value);
39+
clearTimeout(timerRef.current);
40+
timerRef.current = setTimeout(() => load(value, filterType), 300);
41+
}
42+
43+
function handleFilterChange(type: "q" | "label" | "slug") {
44+
setFilterType(type);
45+
if (query) {
46+
clearTimeout(timerRef.current);
47+
timerRef.current = setTimeout(() => load(query, type), 100);
48+
}
49+
}
50+
51+
return (
52+
<>
53+
<div className="flex gap-2 mb-6">
54+
<input
55+
type="search"
56+
placeholder={
57+
filterType === "q"
58+
? "Search schema content..."
59+
: filterType === "label"
60+
? "Search by label..."
61+
: "Search by type name..."
62+
}
63+
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"
64+
value={query}
65+
onChange={(e) => handleInput(e.target.value)}
66+
/>
67+
<div className="flex border border-rule rounded overflow-hidden text-xs">
68+
<button
69+
onClick={() => handleFilterChange("q")}
70+
className={`px-3 py-2 transition-colors ${filterType === "q" ? "bg-ink text-parchment" : "hover:bg-parchment-dark"}`}
71+
>
72+
Content
73+
</button>
74+
<button
75+
onClick={() => handleFilterChange("slug")}
76+
className={`px-3 py-2 border-l border-rule transition-colors ${filterType === "slug" ? "bg-ink text-parchment" : "hover:bg-parchment-dark"}`}
77+
>
78+
Type
79+
</button>
80+
<button
81+
onClick={() => handleFilterChange("label")}
82+
className={`px-3 py-2 border-l border-rule transition-colors ${filterType === "label" ? "bg-ink text-parchment" : "hover:bg-parchment-dark"}`}
83+
>
84+
Label
85+
</button>
86+
</div>
87+
</div>
88+
89+
<div className="space-y-2">
90+
{loading ? (
91+
<p className="text-sm text-ink-muted py-8 text-center">Loading...</p>
92+
) : schemas.length === 0 ? (
93+
<p className="text-sm text-ink-muted py-8 text-center">No schemas found.</p>
94+
) : (
95+
schemas.map((s) => {
96+
const properties = (s.schema as any)?.properties ?? {};
97+
const fieldNames = Object.keys(properties);
98+
const isPrivate = (s.schema as any)?.private === true;
99+
100+
return (
101+
<a
102+
key={s.id}
103+
href={`/schemas/${s.id}`}
104+
className="block border border-rule p-4 hover:bg-parchment-dark/50 transition-colors"
105+
>
106+
<div className="flex items-center justify-between mb-2">
107+
<div className="flex items-center gap-2">
108+
<code className="font-mono text-xs text-ink-muted">
109+
{s.schemaHash.slice(0, 12)}
110+
</code>
111+
{isPrivate && (
112+
<span className="text-[11px] border border-rule px-1.5 py-0.5 text-ink-muted">
113+
private
114+
</span>
115+
)}
116+
</div>
117+
<span className="text-[11px] text-ink-muted">
118+
{new Date(s.createdAt).toLocaleDateString("en-US", {
119+
month: "short",
120+
day: "numeric",
121+
year: "numeric",
122+
})}
123+
</span>
124+
</div>
125+
126+
{/* Field summary */}
127+
<div className="flex flex-wrap gap-1.5 mb-2">
128+
{fieldNames.slice(0, 8).map((name) => (
129+
<span
130+
key={name}
131+
className="text-[11px] font-mono bg-parchment-dark border border-rule px-1.5 py-0.5 rounded"
132+
>
133+
{name}
134+
</span>
135+
))}
136+
{fieldNames.length > 8 && (
137+
<span className="text-[11px] text-ink-muted px-1.5 py-0.5">
138+
+{fieldNames.length - 8} more
139+
</span>
140+
)}
141+
</div>
142+
143+
{/* Labels */}
144+
{s.labels.length > 0 && (
145+
<div className="flex flex-wrap gap-1">
146+
{s.labels.map((label) => (
147+
<span
148+
key={label}
149+
className="text-[11px] text-link bg-blue-50 border border-blue-200 px-1.5 py-0.5 rounded"
150+
>
151+
{label}
152+
</span>
153+
))}
154+
</div>
155+
)}
156+
</a>
157+
);
158+
})
159+
)}
160+
</div>
161+
</>
162+
);
163+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { useState } from "react";
2+
3+
interface Label {
4+
label: string;
5+
createdAt: string;
6+
}
7+
8+
interface Props {
9+
schemaId: string;
10+
initialLabels: Label[];
11+
}
12+
13+
export default function SchemaLabelManager({ schemaId, initialLabels }: Props) {
14+
const [labels, setLabels] = useState<Label[]>(initialLabels);
15+
const [newLabel, setNewLabel] = useState("");
16+
const [error, setError] = useState("");
17+
const [adding, setAdding] = useState(false);
18+
19+
async function addLabel(e: React.FormEvent) {
20+
e.preventDefault();
21+
if (!newLabel.trim()) return;
22+
setAdding(true);
23+
setError("");
24+
25+
try {
26+
const res = await fetch(`/api/schemas/${schemaId}/labels`, {
27+
method: "POST",
28+
headers: { "Content-Type": "application/json" },
29+
body: JSON.stringify({ label: newLabel.trim() }),
30+
});
31+
32+
if (res.status === 401) {
33+
setError("Login required");
34+
return;
35+
}
36+
if (!res.ok) {
37+
const data = await res.json();
38+
setError(data.error ?? "Failed to add label");
39+
return;
40+
}
41+
42+
const data = await res.json();
43+
if (data.status === "created") {
44+
setLabels([...labels, { label: newLabel.trim(), createdAt: new Date().toISOString() }]);
45+
}
46+
setNewLabel("");
47+
} catch {
48+
setError("Network error");
49+
} finally {
50+
setAdding(false);
51+
}
52+
}
53+
54+
async function removeLabel(label: string) {
55+
try {
56+
const res = await fetch(`/api/schemas/${schemaId}/labels/${encodeURIComponent(label)}`, {
57+
method: "DELETE",
58+
});
59+
60+
if (res.status === 401) {
61+
setError("Login required");
62+
return;
63+
}
64+
if (res.ok) {
65+
setLabels(labels.filter((l) => l.label !== label));
66+
setError("");
67+
} else {
68+
const data = await res.json();
69+
setError(data.error ?? "Failed to remove label");
70+
}
71+
} catch {
72+
setError("Network error");
73+
}
74+
}
75+
76+
return (
77+
<div className="mb-6">
78+
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-muted mb-2">Labels</h2>
79+
80+
{/* Existing labels */}
81+
{labels.length > 0 && (
82+
<div className="flex flex-wrap gap-2 mb-3">
83+
{labels.map((l) => (
84+
<span
85+
key={l.label}
86+
className="inline-flex items-center gap-2 text-sm bg-parchment border border-rule px-3 py-1.5 rounded group"
87+
>
88+
<span className="text-ink">{l.label}</span>
89+
<span className="text-[11px] text-ink-muted">
90+
{new Date(l.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric" })}
91+
</span>
92+
<button
93+
onClick={() => removeLabel(l.label)}
94+
className="text-ink-muted hover:text-red-600 transition-colors ml-1 opacity-0 group-hover:opacity-100"
95+
title="Remove label"
96+
>
97+
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
98+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
99+
</svg>
100+
</button>
101+
</span>
102+
))}
103+
</div>
104+
)}
105+
106+
{labels.length === 0 && (
107+
<p className="text-xs text-ink-muted mb-3">No labels yet.</p>
108+
)}
109+
110+
{/* Add label form */}
111+
<form onSubmit={addLabel} className="flex items-center gap-2">
112+
<input
113+
type="text"
114+
placeholder="Add label (e.g. schema.org/Person)"
115+
className="bg-parchment border border-rule px-2.5 py-1.5 text-xs font-mono placeholder:text-ink-muted focus:outline-none focus:border-ink w-64"
116+
value={newLabel}
117+
onChange={(e) => setNewLabel(e.target.value)}
118+
/>
119+
<button
120+
type="submit"
121+
disabled={adding || !newLabel.trim()}
122+
className="text-xs border border-rule px-2.5 py-1.5 hover:bg-parchment-dark disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
123+
>
124+
{adding ? "Adding..." : "Add"}
125+
</button>
126+
</form>
127+
128+
{error && <p className="text-xs text-red-600 mt-1.5">{error}</p>}
129+
</div>
130+
);
131+
}

src/db/seed.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ async function seed() {
7070
await db.delete(schema.versionSchemas);
7171
await db.delete(schema.versionFiles);
7272
await db.delete(schema.files);
73+
await db.delete(schema.schemaLabels);
7374
await db.delete(schema.schemas);
7475
await db.delete(schema.versions);
7576
await db.delete(schema.collections);

src/layouts/Base.astro

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ if (sessionCookie) {
5555
</a>
5656
<div class="flex items-center gap-5 text-sm text-ink-muted">
5757
<a href="/explore" class="hover:text-ink transition-colors">Explore</a>
58+
<a href="/schemas" class="hover:text-ink transition-colors">Schemas</a>
5859
<a href="/docs" class="hover:text-ink transition-colors">Docs</a>
5960
<a href="/blog" class="hover:text-ink transition-colors">Blog</a>
6061
{currentUser ? (

0 commit comments

Comments
 (0)