@wrikka/create-docs 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/config.md +1 -1
- package/docs/data-sources.md +1 -1
- package/docs/deploy.md +1 -1
- package/docs/features.md +1 -1
- package/docs/markdown.md +1 -1
- package/docs/theming.md +1 -1
- package/docs/translate.md +46 -0
- package/package.json +7 -5
- package/scripts/generate-docs-index.ts +3 -3
- package/scripts/translate.ts +127 -0
- package/src/adapters/github/github-pull.ts +23 -0
- package/src/runtime/components/ApiReference.tsx +332 -0
- package/src/runtime/components/ContextMenu.tsx +88 -0
- package/src/runtime/components/MobileBottomNav.tsx +3 -3
- package/src/runtime/components/SidebarNav.tsx +190 -3
- package/src/runtime/components/TopNav.tsx +163 -49
- package/src/runtime/config.ts +24 -1
- package/src/runtime/content.ts +17 -15
- package/src/runtime/index.ts +6 -0
- package/src/runtime/pages/ApiEndpointPage.tsx +54 -141
- package/src/runtime/pages/DocPage.tsx +10 -1
- package/src/runtime/pages/PluginsPage.tsx +38 -13
- package/src/runtime/pages/SearchPage.tsx +374 -0
- package/src/runtime/pages/ShowcasePage.tsx +143 -15
- package/src/runtime/pages/TranslatePage.tsx +338 -0
- package/src/runtime/plugins-catalog.ts +170 -0
- package/src/runtime/router.tsx +23 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { useNavigate, useSearch } from "@tanstack/solid-router";
|
|
2
|
+
import {
|
|
3
|
+
createEffect,
|
|
4
|
+
createMemo,
|
|
5
|
+
createResource,
|
|
6
|
+
createSignal,
|
|
7
|
+
For,
|
|
8
|
+
Show,
|
|
9
|
+
} from "solid-js";
|
|
10
|
+
import { useDocs } from "../context";
|
|
11
|
+
import { searchDocs, useCollections } from "../data";
|
|
12
|
+
import type { SearchResult } from "../types";
|
|
13
|
+
|
|
14
|
+
/** Split text into highlighted parts matching any query term. */
|
|
15
|
+
function highlightParts(text: string, query: string) {
|
|
16
|
+
const terms = query
|
|
17
|
+
.trim()
|
|
18
|
+
.split(/\s+/)
|
|
19
|
+
.filter(Boolean)
|
|
20
|
+
.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
21
|
+
if (!terms.length || !text) return [{ text, match: false }];
|
|
22
|
+
const re = new RegExp(`(${terms.join("|")})`, "gi");
|
|
23
|
+
const parts: { text: string; match: boolean }[] = [];
|
|
24
|
+
let last = 0;
|
|
25
|
+
for (const m of text.matchAll(re)) {
|
|
26
|
+
const i = m.index ?? 0;
|
|
27
|
+
if (i > last) parts.push({ text: text.slice(last, i), match: false });
|
|
28
|
+
parts.push({ text: m[0], match: true });
|
|
29
|
+
last = i + m[0].length;
|
|
30
|
+
}
|
|
31
|
+
if (last < text.length) parts.push({ text: text.slice(last), match: false });
|
|
32
|
+
return parts;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function Highlighted(props: { text: string; query: string }) {
|
|
36
|
+
return (
|
|
37
|
+
<For each={highlightParts(props.text, props.query)}>
|
|
38
|
+
{(p) =>
|
|
39
|
+
p.match ? (
|
|
40
|
+
<mark class="bg-warning/30 text-foreground rounded-sm px-0.5">
|
|
41
|
+
{p.text}
|
|
42
|
+
</mark>
|
|
43
|
+
) : (
|
|
44
|
+
p.text
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
</For>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function stripMarkdown(md: string): string {
|
|
52
|
+
return md
|
|
53
|
+
.replace(/```[\s\S]*?```/g, " [code block] ")
|
|
54
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
|
|
55
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
56
|
+
.replace(/^#{1,6}\s+/gm, "")
|
|
57
|
+
.replace(/^>\s?/gm, "")
|
|
58
|
+
.replace(/[*_`~]/g, "")
|
|
59
|
+
.trim();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Extract context windows around query matches in content. */
|
|
63
|
+
function matchExcerpts(content: string, query: string, max = 5): string[] {
|
|
64
|
+
const terms = query.trim().split(/\s+/).filter(Boolean);
|
|
65
|
+
if (!terms.length) return [];
|
|
66
|
+
const plain = stripMarkdown(content);
|
|
67
|
+
const re = new RegExp(
|
|
68
|
+
`(${terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})`,
|
|
69
|
+
"gi",
|
|
70
|
+
);
|
|
71
|
+
const excerpts: string[] = [];
|
|
72
|
+
for (const m of plain.matchAll(re)) {
|
|
73
|
+
const i = m.index ?? 0;
|
|
74
|
+
const start = Math.max(0, i - 80);
|
|
75
|
+
const end = Math.min(plain.length, i + m[0].length + 120);
|
|
76
|
+
excerpts.push(
|
|
77
|
+
`${start > 0 ? "…" : ""}${plain.slice(start, end)}${end < plain.length ? "…" : ""}`,
|
|
78
|
+
);
|
|
79
|
+
if (excerpts.length >= max) break;
|
|
80
|
+
}
|
|
81
|
+
return excerpts;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function SearchPage() {
|
|
85
|
+
const config = useDocs();
|
|
86
|
+
const navigate = useNavigate();
|
|
87
|
+
const collections = useCollections();
|
|
88
|
+
const search = useSearch({ strict: false }) as () => { q?: string };
|
|
89
|
+
const [query, setQuery] = createSignal(search().q ?? "");
|
|
90
|
+
const [collection, setCollection] = createSignal<string | null>(null);
|
|
91
|
+
const [selected, setSelected] = createSignal(0);
|
|
92
|
+
|
|
93
|
+
const [results] = createResource(query, async (q) => {
|
|
94
|
+
const term = q.trim();
|
|
95
|
+
if (term.length < 2) return [] as SearchResult[];
|
|
96
|
+
try {
|
|
97
|
+
return await searchDocs(config, term);
|
|
98
|
+
} catch {
|
|
99
|
+
return [] as SearchResult[];
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const filtered = createMemo(() => {
|
|
104
|
+
const c = collection();
|
|
105
|
+
const list = results() ?? [];
|
|
106
|
+
return c ? list.filter((r) => r.collection === c) : list;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const groups = createMemo((): [string, SearchResult[], number][] => {
|
|
110
|
+
const map = new Map<string, SearchResult[]>();
|
|
111
|
+
for (const r of filtered()) {
|
|
112
|
+
const g = map.get(r.collection) ?? [];
|
|
113
|
+
g.push(r);
|
|
114
|
+
map.set(r.collection, g);
|
|
115
|
+
}
|
|
116
|
+
let offset = 0;
|
|
117
|
+
return [...map.entries()].map(([col, items]) => {
|
|
118
|
+
const entry: [string, SearchResult[], number] = [col, items, offset];
|
|
119
|
+
offset += items.length;
|
|
120
|
+
return entry;
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const flat = () => filtered();
|
|
125
|
+
const current = () => flat()[selected()];
|
|
126
|
+
|
|
127
|
+
const [preview] = createResource(
|
|
128
|
+
() => {
|
|
129
|
+
const r = current();
|
|
130
|
+
return r ? { collection: r.collection, id: r.id } : null;
|
|
131
|
+
},
|
|
132
|
+
async (sel) => {
|
|
133
|
+
try {
|
|
134
|
+
const doc = await config.dataSource.get(sel.collection, sel.id);
|
|
135
|
+
return doc.content ?? "";
|
|
136
|
+
} catch {
|
|
137
|
+
return "";
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const collectionLabel = (id: string) =>
|
|
143
|
+
collections()?.find((c) => c.id === id)?.label ?? id;
|
|
144
|
+
|
|
145
|
+
const open = (r: SearchResult) => {
|
|
146
|
+
navigate({
|
|
147
|
+
to: "/$collection/$docId",
|
|
148
|
+
params: { collection: r.collection, docId: r.id },
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
createEffect(() => {
|
|
153
|
+
setSelected(0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const onKey = (e: KeyboardEvent) => {
|
|
157
|
+
if (e.key === "ArrowDown") {
|
|
158
|
+
e.preventDefault();
|
|
159
|
+
setSelected((i) => Math.min(i + 1, flat().length - 1));
|
|
160
|
+
}
|
|
161
|
+
if (e.key === "ArrowUp") {
|
|
162
|
+
e.preventDefault();
|
|
163
|
+
setSelected((i) => Math.max(i - 1, 0));
|
|
164
|
+
}
|
|
165
|
+
if (e.key === "Enter" && current()) open(current()!);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<div class="max-w-7xl mx-auto px-6 py-8 pb-24" onKeyDown={onKey}>
|
|
170
|
+
<div class="flex items-center gap-3 mb-6">
|
|
171
|
+
<span class="i-mdi:magnify text-3xl text-primary" aria-hidden="true" />
|
|
172
|
+
<div>
|
|
173
|
+
<h1 class="text-2xl font-bold m-0">Search</h1>
|
|
174
|
+
<p class="text-muted text-sm m-0">
|
|
175
|
+
Full-text search across all collections.
|
|
176
|
+
</p>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<label class="flex items-center gap-2 px-4 h-12 rounded-xl border border-border bg-surface focus-within:border-focus transition-colors mb-4">
|
|
181
|
+
<span class="i-mdi:magnify text-muted text-lg" aria-hidden="true" />
|
|
182
|
+
<input
|
|
183
|
+
ref={(el) => queueMicrotask(() => el.focus())}
|
|
184
|
+
type="search"
|
|
185
|
+
value={query()}
|
|
186
|
+
onInput={(e) => setQuery(e.currentTarget.value)}
|
|
187
|
+
placeholder="Search documentation…"
|
|
188
|
+
aria-label="Search documentation"
|
|
189
|
+
class="flex-1 bg-transparent outline-none border-none text-base text-foreground placeholder:text-muted"
|
|
190
|
+
/>
|
|
191
|
+
<Show when={query()}>
|
|
192
|
+
<button
|
|
193
|
+
type="button"
|
|
194
|
+
onClick={() => setQuery("")}
|
|
195
|
+
aria-label="Clear search"
|
|
196
|
+
class="w-7 h-7 inline-flex items-center justify-center rounded text-muted hover:text-foreground cursor-pointer border-none bg-transparent"
|
|
197
|
+
>
|
|
198
|
+
<span class="i-mdi:close" aria-hidden="true" />
|
|
199
|
+
</button>
|
|
200
|
+
</Show>
|
|
201
|
+
</label>
|
|
202
|
+
|
|
203
|
+
<div class="flex flex-wrap items-center gap-2 mb-6">
|
|
204
|
+
<button
|
|
205
|
+
type="button"
|
|
206
|
+
onClick={() => setCollection(null)}
|
|
207
|
+
class={`px-3 h-8 rounded-full text-xs border transition-colors cursor-pointer ${
|
|
208
|
+
collection() === null
|
|
209
|
+
? "bg-primary text-primary-foreground border-primary"
|
|
210
|
+
: "bg-surface text-muted border-border hover:border-focus"
|
|
211
|
+
}`}
|
|
212
|
+
>
|
|
213
|
+
All
|
|
214
|
+
</button>
|
|
215
|
+
<For each={[...new Set((results() ?? []).map((r) => r.collection))]}>
|
|
216
|
+
{(c) => (
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
onClick={() => setCollection(c)}
|
|
220
|
+
class={`px-3 h-8 rounded-full text-xs border transition-colors cursor-pointer ${
|
|
221
|
+
collection() === c
|
|
222
|
+
? "bg-primary text-primary-foreground border-primary"
|
|
223
|
+
: "bg-surface text-muted border-border hover:border-focus"
|
|
224
|
+
}`}
|
|
225
|
+
>
|
|
226
|
+
{collectionLabel(c)}
|
|
227
|
+
</button>
|
|
228
|
+
)}
|
|
229
|
+
</For>
|
|
230
|
+
<span class="ml-auto text-xs text-muted">
|
|
231
|
+
{filtered().length} result{filtered().length === 1 ? "" : "s"}
|
|
232
|
+
</span>
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
<Show
|
|
236
|
+
when={query().trim().length >= 2}
|
|
237
|
+
fallback={
|
|
238
|
+
<div class="flex flex-col items-center gap-3 py-20 rounded-xl border border-dashed border-border text-muted">
|
|
239
|
+
<span class="i-mdi:text-search text-5xl" aria-hidden="true" />
|
|
240
|
+
<p class="m-0 text-sm">Type at least 2 characters to search.</p>
|
|
241
|
+
<p class="m-0 text-xs">
|
|
242
|
+
Use ↑↓ to navigate results, Enter to open, or press Ctrl K for the
|
|
243
|
+
command palette.
|
|
244
|
+
</p>
|
|
245
|
+
</div>
|
|
246
|
+
}
|
|
247
|
+
>
|
|
248
|
+
<div class="grid grid-cols-1 lg:grid-cols-[320px_minmax(0,1fr)] gap-6">
|
|
249
|
+
{/* Results sidebar */}
|
|
250
|
+
<aside class="rounded-xl border border-border bg-surface/30 overflow-hidden self-start max-h-[70vh] overflow-y-auto">
|
|
251
|
+
<Show when={results.loading}>
|
|
252
|
+
<div class="p-6 text-sm text-muted text-center">Searching…</div>
|
|
253
|
+
</Show>
|
|
254
|
+
<Show when={!results.loading && filtered().length === 0}>
|
|
255
|
+
<div class="p-6 text-sm text-muted text-center">
|
|
256
|
+
No results for "{query()}"
|
|
257
|
+
</div>
|
|
258
|
+
</Show>
|
|
259
|
+
<For each={groups()}>
|
|
260
|
+
{([col, items, start]) => {
|
|
261
|
+
return (
|
|
262
|
+
<div>
|
|
263
|
+
<div class="px-3 py-2 text-[10px] uppercase tracking-wider font-semibold text-muted border-b border-border bg-background/60 sticky top-0">
|
|
264
|
+
{collectionLabel(col)}
|
|
265
|
+
<span class="ml-1 font-normal">({items.length})</span>
|
|
266
|
+
</div>
|
|
267
|
+
<ul class="list-none m-0 p-1">
|
|
268
|
+
<For each={items}>
|
|
269
|
+
{(r, i) => {
|
|
270
|
+
const idx = start + i();
|
|
271
|
+
return (
|
|
272
|
+
<li>
|
|
273
|
+
<button
|
|
274
|
+
type="button"
|
|
275
|
+
onClick={() => setSelected(idx)}
|
|
276
|
+
onDblClick={() => open(r)}
|
|
277
|
+
class={`w-full text-left px-3 py-2.5 rounded-md cursor-pointer border-none transition-colors ${
|
|
278
|
+
selected() === idx
|
|
279
|
+
? "bg-primary/10"
|
|
280
|
+
: "bg-transparent hover:bg-surface"
|
|
281
|
+
}`}
|
|
282
|
+
>
|
|
283
|
+
<div class="text-sm font-medium text-foreground truncate">
|
|
284
|
+
<Highlighted text={r.title} query={query()} />
|
|
285
|
+
</div>
|
|
286
|
+
<Show when={r.snippet}>
|
|
287
|
+
<p class="text-xs text-muted m-0 mt-0.5 line-clamp-2">
|
|
288
|
+
<Highlighted
|
|
289
|
+
text={r.snippet}
|
|
290
|
+
query={query()}
|
|
291
|
+
/>
|
|
292
|
+
</p>
|
|
293
|
+
</Show>
|
|
294
|
+
</button>
|
|
295
|
+
</li>
|
|
296
|
+
);
|
|
297
|
+
}}
|
|
298
|
+
</For>
|
|
299
|
+
</ul>
|
|
300
|
+
</div>
|
|
301
|
+
);
|
|
302
|
+
}}
|
|
303
|
+
</For>
|
|
304
|
+
</aside>
|
|
305
|
+
|
|
306
|
+
{/* Content preview with highlights */}
|
|
307
|
+
<section class="rounded-xl border border-border bg-surface/30 min-h-64 max-h-[70vh] overflow-y-auto">
|
|
308
|
+
<Show
|
|
309
|
+
when={current()}
|
|
310
|
+
fallback={
|
|
311
|
+
<div class="flex flex-col items-center gap-2 p-10 text-muted text-sm">
|
|
312
|
+
<span
|
|
313
|
+
class="i-mdi:file-search-outline text-4xl"
|
|
314
|
+
aria-hidden="true"
|
|
315
|
+
/>
|
|
316
|
+
Select a result to preview matching content.
|
|
317
|
+
</div>
|
|
318
|
+
}
|
|
319
|
+
>
|
|
320
|
+
{(r) => (
|
|
321
|
+
<div class="p-5">
|
|
322
|
+
<div class="flex items-start gap-3 pb-4 mb-4 border-b border-border">
|
|
323
|
+
<div class="min-w-0 flex-1">
|
|
324
|
+
<div class="text-[10px] uppercase tracking-wide text-muted mb-1">
|
|
325
|
+
{collectionLabel(r().collection)}
|
|
326
|
+
</div>
|
|
327
|
+
<h2 class="text-lg font-semibold text-foreground m-0">
|
|
328
|
+
<Highlighted text={r().title} query={query()} />
|
|
329
|
+
</h2>
|
|
330
|
+
</div>
|
|
331
|
+
<button
|
|
332
|
+
type="button"
|
|
333
|
+
onClick={() => open(r())}
|
|
334
|
+
class="px-3 h-9 inline-flex items-center gap-1.5 rounded-md bg-primary text-primary-foreground text-sm hover:bg-primary-hover transition-colors cursor-pointer border-none shrink-0"
|
|
335
|
+
>
|
|
336
|
+
Open page
|
|
337
|
+
<span class="i-mdi:arrow-right" aria-hidden="true" />
|
|
338
|
+
</button>
|
|
339
|
+
</div>
|
|
340
|
+
<Show
|
|
341
|
+
when={!preview.loading}
|
|
342
|
+
fallback={
|
|
343
|
+
<p class="text-sm text-muted">Loading preview…</p>
|
|
344
|
+
}
|
|
345
|
+
>
|
|
346
|
+
<Show
|
|
347
|
+
when={matchExcerpts(preview() ?? "", query()).length}
|
|
348
|
+
fallback={
|
|
349
|
+
<p class="text-sm text-muted leading-relaxed whitespace-pre-wrap m-0">
|
|
350
|
+
{stripMarkdown(preview() ?? "").slice(0, 800) ||
|
|
351
|
+
"No preview available."}
|
|
352
|
+
</p>
|
|
353
|
+
}
|
|
354
|
+
>
|
|
355
|
+
<div class="flex flex-col gap-3">
|
|
356
|
+
<For each={matchExcerpts(preview() ?? "", query())}>
|
|
357
|
+
{(ex) => (
|
|
358
|
+
<p class="text-sm text-foreground/90 leading-relaxed m-0 p-3 rounded-lg bg-background/60 border border-border/60">
|
|
359
|
+
<Highlighted text={ex} query={query()} />
|
|
360
|
+
</p>
|
|
361
|
+
)}
|
|
362
|
+
</For>
|
|
363
|
+
</div>
|
|
364
|
+
</Show>
|
|
365
|
+
</Show>
|
|
366
|
+
</div>
|
|
367
|
+
)}
|
|
368
|
+
</Show>
|
|
369
|
+
</section>
|
|
370
|
+
</div>
|
|
371
|
+
</Show>
|
|
372
|
+
</div>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
@@ -1,26 +1,59 @@
|
|
|
1
|
+
import { Link } from "@tanstack/solid-router";
|
|
1
2
|
import type { JSX } from "solid-js";
|
|
2
3
|
import { createMemo, createSignal, For, Show } from "solid-js";
|
|
3
4
|
import type { ShowcaseInfo } from "../config";
|
|
4
5
|
import { useDocs } from "../context";
|
|
5
6
|
|
|
6
|
-
function Card(props: {
|
|
7
|
+
function Card(props: {
|
|
8
|
+
item: ShowcaseInfo;
|
|
9
|
+
onDetails?: () => void;
|
|
10
|
+
children: JSX.Element;
|
|
11
|
+
}) {
|
|
7
12
|
const classes =
|
|
8
|
-
"group border border-border rounded-xl overflow-hidden bg-surface/30 hover:border-focus transition-all hover:-translate-y-0.5 hover:shadow-lg flex flex-col";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
"group border border-border rounded-xl overflow-hidden bg-surface/30 hover:border-focus transition-all hover:-translate-y-0.5 hover:shadow-lg flex flex-col text-left";
|
|
14
|
+
const { item } = props;
|
|
15
|
+
if (item.collection && item.docId) {
|
|
16
|
+
return (
|
|
17
|
+
<Link
|
|
18
|
+
to="/$collection/$docId"
|
|
19
|
+
params={{ collection: item.collection, docId: item.docId }}
|
|
20
|
+
class={`${classes} no-underline`}
|
|
21
|
+
>
|
|
22
|
+
{props.children}
|
|
23
|
+
</Link>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
if (item.link?.startsWith("/")) {
|
|
27
|
+
return (
|
|
28
|
+
<Link to={item.link} class={`${classes} no-underline`}>
|
|
29
|
+
{props.children}
|
|
30
|
+
</Link>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
if (item.link) {
|
|
34
|
+
return (
|
|
35
|
+
<a
|
|
36
|
+
href={item.link}
|
|
37
|
+
target="_blank"
|
|
38
|
+
rel="noreferrer"
|
|
39
|
+
class={`${classes} no-underline`}
|
|
40
|
+
>
|
|
41
|
+
{props.children}
|
|
42
|
+
</a>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return (
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
onClick={props.onDetails}
|
|
49
|
+
class={`${classes} cursor-pointer bg-transparent p-0 font-inherit`}
|
|
15
50
|
>
|
|
16
51
|
{props.children}
|
|
17
|
-
</
|
|
18
|
-
) : (
|
|
19
|
-
<div class={classes}>{props.children}</div>
|
|
52
|
+
</button>
|
|
20
53
|
);
|
|
21
54
|
}
|
|
22
55
|
|
|
23
|
-
function ShowcaseCard(props: { item: ShowcaseInfo }) {
|
|
56
|
+
function ShowcaseCard(props: { item: ShowcaseInfo; onDetails?: () => void }) {
|
|
24
57
|
const { item } = props;
|
|
25
58
|
const coverStyle = () => {
|
|
26
59
|
if (item.image) return {};
|
|
@@ -31,7 +64,7 @@ function ShowcaseCard(props: { item: ShowcaseInfo }) {
|
|
|
31
64
|
};
|
|
32
65
|
|
|
33
66
|
return (
|
|
34
|
-
<Card
|
|
67
|
+
<Card item={item} onDetails={props.onDetails}>
|
|
35
68
|
<div
|
|
36
69
|
class="relative h-44 w-full bg-background border-b border-border overflow-hidden"
|
|
37
70
|
style={coverStyle()}
|
|
@@ -94,6 +127,7 @@ export function ShowcasePage() {
|
|
|
94
127
|
const allItems = () => config.showcase ?? [];
|
|
95
128
|
const [search, setSearch] = createSignal("");
|
|
96
129
|
const [tag, setTag] = createSignal<string | null>(null);
|
|
130
|
+
const [detailItem, setDetailItem] = createSignal<ShowcaseInfo | null>(null);
|
|
97
131
|
|
|
98
132
|
const allTags = createMemo(() => {
|
|
99
133
|
const set = new Set<string>();
|
|
@@ -182,7 +216,10 @@ export function ShowcasePage() {
|
|
|
182
216
|
<section class="mb-10">
|
|
183
217
|
<h2 class="text-lg font-semibold mb-3 text-muted">Featured</h2>
|
|
184
218
|
<div class="sm:max-w-2xl">
|
|
185
|
-
<ShowcaseCard
|
|
219
|
+
<ShowcaseCard
|
|
220
|
+
item={featured()!}
|
|
221
|
+
onDetails={() => setDetailItem(featured()!)}
|
|
222
|
+
/>
|
|
186
223
|
</div>
|
|
187
224
|
</section>
|
|
188
225
|
</Show>
|
|
@@ -195,8 +232,99 @@ export function ShowcasePage() {
|
|
|
195
232
|
</Show>
|
|
196
233
|
|
|
197
234
|
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
|
198
|
-
<For each={rest()}>
|
|
235
|
+
<For each={rest()}>
|
|
236
|
+
{(item) => (
|
|
237
|
+
<ShowcaseCard item={item} onDetails={() => setDetailItem(item)} />
|
|
238
|
+
)}
|
|
239
|
+
</For>
|
|
199
240
|
</div>
|
|
241
|
+
|
|
242
|
+
<Show when={detailItem()}>
|
|
243
|
+
{(item) => (
|
|
244
|
+
<div
|
|
245
|
+
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm"
|
|
246
|
+
onClick={() => setDetailItem(null)}
|
|
247
|
+
>
|
|
248
|
+
<div
|
|
249
|
+
role="dialog"
|
|
250
|
+
aria-modal="true"
|
|
251
|
+
aria-label={item().label}
|
|
252
|
+
class="w-full max-w-lg rounded-xl border border-border bg-surface shadow-2xl overflow-hidden"
|
|
253
|
+
onClick={(e) => e.stopPropagation()}
|
|
254
|
+
>
|
|
255
|
+
<div class="relative h-40 bg-background border-b border-border flex items-center justify-center">
|
|
256
|
+
<Show
|
|
257
|
+
when={item().image}
|
|
258
|
+
fallback={
|
|
259
|
+
<span
|
|
260
|
+
class={`${item().icon ?? "i-mdi:view-dashboard"} text-7xl text-primary/60`}
|
|
261
|
+
aria-hidden="true"
|
|
262
|
+
/>
|
|
263
|
+
}
|
|
264
|
+
>
|
|
265
|
+
<img
|
|
266
|
+
src={item().image!}
|
|
267
|
+
alt={item().label}
|
|
268
|
+
class="w-full h-full object-cover"
|
|
269
|
+
/>
|
|
270
|
+
</Show>
|
|
271
|
+
<button
|
|
272
|
+
type="button"
|
|
273
|
+
onClick={() => setDetailItem(null)}
|
|
274
|
+
aria-label="Close"
|
|
275
|
+
class="absolute top-3 right-3 w-8 h-8 inline-flex items-center justify-center rounded-md bg-surface/80 border border-border text-muted hover:text-foreground transition-colors cursor-pointer"
|
|
276
|
+
>
|
|
277
|
+
<span class="i-mdi:close" aria-hidden="true" />
|
|
278
|
+
</button>
|
|
279
|
+
</div>
|
|
280
|
+
<div class="p-6">
|
|
281
|
+
<div class="flex items-center gap-2 mb-2">
|
|
282
|
+
<h3 class="text-xl font-semibold m-0">{item().label}</h3>
|
|
283
|
+
<Show when={item().badge}>
|
|
284
|
+
<span class="text-[10px] px-2 py-0.5 rounded-full bg-primary/15 text-primary font-medium">
|
|
285
|
+
{item().badge}
|
|
286
|
+
</span>
|
|
287
|
+
</Show>
|
|
288
|
+
</div>
|
|
289
|
+
<p class="text-sm text-muted leading-relaxed m-0">
|
|
290
|
+
{item().description}
|
|
291
|
+
</p>
|
|
292
|
+
<Show when={item().tags?.length}>
|
|
293
|
+
<div class="flex flex-wrap gap-1.5 mt-4">
|
|
294
|
+
<For each={item().tags}>
|
|
295
|
+
{(t) => (
|
|
296
|
+
<span class="text-[10px] px-2 py-0.5 rounded-full bg-background border border-border text-muted">
|
|
297
|
+
{t}
|
|
298
|
+
</span>
|
|
299
|
+
)}
|
|
300
|
+
</For>
|
|
301
|
+
</div>
|
|
302
|
+
</Show>
|
|
303
|
+
<div class="flex items-center gap-2 mt-6">
|
|
304
|
+
<Show when={item().link}>
|
|
305
|
+
<a
|
|
306
|
+
href={item().link}
|
|
307
|
+
target="_blank"
|
|
308
|
+
rel="noreferrer"
|
|
309
|
+
class="px-4 h-9 inline-flex items-center gap-1.5 rounded-md bg-primary text-primary-foreground text-sm no-underline hover:bg-primary-hover transition-colors"
|
|
310
|
+
>
|
|
311
|
+
<span class="i-mdi:open-in-new" aria-hidden="true" />
|
|
312
|
+
Visit
|
|
313
|
+
</a>
|
|
314
|
+
</Show>
|
|
315
|
+
<button
|
|
316
|
+
type="button"
|
|
317
|
+
onClick={() => setDetailItem(null)}
|
|
318
|
+
class="px-4 h-9 inline-flex items-center rounded-md border border-border text-sm text-muted hover:text-foreground hover:bg-background transition-colors cursor-pointer"
|
|
319
|
+
>
|
|
320
|
+
Close
|
|
321
|
+
</button>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
)}
|
|
327
|
+
</Show>
|
|
200
328
|
</div>
|
|
201
329
|
);
|
|
202
330
|
}
|