@marlinjai/clearify 1.20.0 → 2.1.0

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.
@@ -1,5 +1,5 @@
1
1
  import React, { useEffect, useState, useCallback } from 'react';
2
- import type { HubProject } from '../../types/index.js';
2
+ import type { HubProject, HubProjectSource, HubProjectPlacement } from '../../types/index.js';
3
3
  import { Modal } from './components/Modal.js';
4
4
  import { ConfirmDialog } from './components/ConfirmDialog.js';
5
5
  import { useToast, ToastContainer } from './components/Toast.js';
@@ -16,23 +16,141 @@ const statusColors: Record<string, { bg: string; text: string }> = {
16
16
  };
17
17
 
18
18
  /* ------------------------------------------------------------------ */
19
- /* Empty default */
19
+ /* Draft form shape */
20
20
  /* ------------------------------------------------------------------ */
21
- function emptyProject(): HubProject {
21
+ /**
22
+ * The form state carries every field for every kind so the user can flip
23
+ * between kinds without losing input. We clean it to the discriminated-
24
+ * union shape before saving.
25
+ */
26
+ interface DraftProject {
27
+ name: string;
28
+ description: string;
29
+ status: 'active' | 'beta' | 'planned' | 'deprecated';
30
+ icon?: string;
31
+ group?: string;
32
+ tags?: string[];
33
+ repo?: string;
34
+ sourceKind: HubProjectSource['kind'];
35
+ gitRepo: string;
36
+ gitRef: string;
37
+ gitPath: string;
38
+ urlUrl: string;
39
+ inlineMarkdown: string;
40
+ placementKind: HubProjectPlacement['kind'];
41
+ cardHref: string;
42
+ tabSections: 'all' | 'public';
43
+ nestedInto: string;
44
+ nestedDocsPath: string;
45
+ nestedGroup: string;
46
+ }
47
+
48
+ function emptyDraft(): DraftProject {
22
49
  return {
23
50
  name: '',
24
51
  description: '',
25
- mode: 'link',
26
52
  status: 'active',
27
53
  icon: '',
28
54
  group: '',
29
- href: '',
30
55
  tags: [],
31
- git: { repo: '', ref: '' },
32
- embedSections: 'all',
33
- injectInto: '',
34
- docsPath: 'docs',
56
+ repo: '',
57
+ sourceKind: 'none',
58
+ gitRepo: '',
59
+ gitRef: 'main',
60
+ gitPath: 'docs/public',
61
+ urlUrl: '',
62
+ inlineMarkdown: '',
63
+ placementKind: 'card',
64
+ cardHref: '',
65
+ tabSections: 'public',
66
+ nestedInto: '',
67
+ nestedDocsPath: 'docs',
68
+ nestedGroup: '',
69
+ };
70
+ }
71
+
72
+ function projectToDraft(p: HubProject): DraftProject {
73
+ const d = emptyDraft();
74
+ d.name = p.name;
75
+ d.description = p.description;
76
+ d.status = p.status ?? 'active';
77
+ d.icon = p.icon ?? '';
78
+ d.group = p.group ?? '';
79
+ d.tags = p.tags ? [...p.tags] : [];
80
+ d.repo = p.repo ?? '';
81
+
82
+ d.sourceKind = p.source.kind;
83
+ if (p.source.kind === 'git') {
84
+ d.gitRepo = p.source.repo;
85
+ d.gitRef = p.source.ref ?? 'main';
86
+ d.gitPath = p.source.path ?? 'docs/public';
87
+ } else if (p.source.kind === 'url') {
88
+ d.urlUrl = p.source.url;
89
+ } else if (p.source.kind === 'inline') {
90
+ d.inlineMarkdown = p.source.markdown;
91
+ }
92
+
93
+ d.placementKind = p.placement.kind;
94
+ if (p.placement.kind === 'card') {
95
+ d.cardHref = p.placement.href;
96
+ } else if (p.placement.kind === 'tab') {
97
+ const s = p.placement.sections;
98
+ d.tabSections = s === 'all' ? 'all' : 'public';
99
+ } else if (p.placement.kind === 'nested') {
100
+ d.nestedInto = p.placement.into;
101
+ d.nestedDocsPath = p.placement.docsPath ?? 'docs';
102
+ d.nestedGroup = p.placement.group ?? '';
103
+ }
104
+ return d;
105
+ }
106
+
107
+ function draftToProject(d: DraftProject): HubProject {
108
+ let source: HubProjectSource;
109
+ if (d.sourceKind === 'git') {
110
+ const g: Extract<HubProjectSource, { kind: 'git' }> = {
111
+ kind: 'git',
112
+ repo: d.gitRepo,
113
+ };
114
+ if (d.gitRef && d.gitRef !== 'main') g.ref = d.gitRef;
115
+ if (d.gitPath) g.path = d.gitPath;
116
+ source = g;
117
+ } else if (d.sourceKind === 'url') {
118
+ source = { kind: 'url', url: d.urlUrl };
119
+ } else if (d.sourceKind === 'inline') {
120
+ source = { kind: 'inline', markdown: d.inlineMarkdown };
121
+ } else {
122
+ source = { kind: 'none' };
123
+ }
124
+
125
+ let placement: HubProjectPlacement;
126
+ if (d.placementKind === 'tab') {
127
+ const t: Extract<HubProjectPlacement, { kind: 'tab' }> = { kind: 'tab' };
128
+ if (d.tabSections && d.tabSections !== 'public') t.sections = d.tabSections;
129
+ placement = t;
130
+ } else if (d.placementKind === 'nested') {
131
+ const n: Extract<HubProjectPlacement, { kind: 'nested' }> = {
132
+ kind: 'nested',
133
+ into: d.nestedInto,
134
+ };
135
+ if (d.nestedDocsPath && d.nestedDocsPath !== 'docs') n.docsPath = d.nestedDocsPath;
136
+ if (d.nestedGroup) n.group = d.nestedGroup;
137
+ placement = n;
138
+ } else {
139
+ placement = { kind: 'card', href: d.cardHref };
140
+ }
141
+
142
+ const project: HubProject = {
143
+ name: d.name.trim(),
144
+ description: d.description.trim(),
145
+ source,
146
+ placement,
35
147
  };
148
+ if (d.status && d.status !== 'active') project.status = d.status;
149
+ if (d.icon) project.icon = d.icon;
150
+ if (d.group) project.group = d.group;
151
+ if (d.tags && d.tags.length > 0) project.tags = d.tags;
152
+ if (d.repo) project.repo = d.repo;
153
+ return project;
36
154
  }
37
155
 
38
156
  /* ------------------------------------------------------------------ */
@@ -70,144 +188,138 @@ const primaryBtnStyle: React.CSSProperties = {
70
188
  };
71
189
 
72
190
  /* ------------------------------------------------------------------ */
73
- /* Clean project for saving */
191
+ /* Summary of the source+placement combo for the list view */
74
192
  /* ------------------------------------------------------------------ */
75
- function cleanProject(draft: HubProject, original?: HubProject): HubProject {
76
- const cleaned: HubProject = { ...draft };
77
- const mode = cleaned.mode ?? 'link';
78
- if (mode === 'link') {
79
- delete cleaned.git;
80
- delete cleaned.embedSections;
81
- delete cleaned.injectInto;
82
- delete cleaned.docsPath;
83
- // Don't add mode: 'link' if original didn't have it (it's the default)
84
- if (!original?.mode) delete cleaned.mode;
85
- } else if (mode === 'embed') {
86
- delete cleaned.href;
87
- delete cleaned.injectInto;
88
- delete cleaned.docsPath;
89
- } else if (mode === 'inject') {
90
- delete cleaned.href;
91
- delete cleaned.embedSections;
92
- }
93
- if (!cleaned.icon) delete cleaned.icon;
94
- if (!cleaned.group) delete cleaned.group;
95
- if (!cleaned.href) delete cleaned.href;
96
- if (cleaned.tags && cleaned.tags.length === 0) delete cleaned.tags;
97
- if (cleaned.git && !cleaned.git.repo) delete cleaned.git;
98
- if (cleaned.git && !cleaned.git.ref) delete cleaned.git.ref;
99
- if (cleaned.status === 'active' && !original?.status) delete cleaned.status;
100
-
101
- // Preserve original field ordering: rebuild using original's key order first
102
- if (original) {
103
- const ordered: any = {};
104
- for (const key of Object.keys(original)) {
105
- if (key in cleaned) ordered[key] = (cleaned as any)[key];
106
- }
107
- for (const key of Object.keys(cleaned)) {
108
- if (!(key in ordered)) ordered[key] = (cleaned as any)[key];
109
- }
110
- return ordered as HubProject;
111
- }
112
- return cleaned;
193
+ function combo(project: HubProject): string {
194
+ return `${project.source.kind} + ${project.placement.kind}`;
113
195
  }
114
196
 
115
197
  /* ------------------------------------------------------------------ */
116
198
  /* Project form */
117
199
  /* ------------------------------------------------------------------ */
118
200
  function ProjectForm({
119
- project,
201
+ draft,
120
202
  onChange,
121
203
  onSave,
122
204
  saving,
123
205
  }: {
124
- project: HubProject;
125
- onChange: (p: HubProject) => void;
206
+ draft: DraftProject;
207
+ onChange: (d: DraftProject) => void;
126
208
  onSave: () => void;
127
209
  saving: boolean;
128
210
  }) {
129
- const mode = project.mode ?? 'link';
130
-
131
- const set = <K extends keyof HubProject>(key: K, value: HubProject[K]) =>
132
- onChange({ ...project, [key]: value });
211
+ const set = <K extends keyof DraftProject>(key: K, value: DraftProject[K]) =>
212
+ onChange({ ...draft, [key]: value });
133
213
 
134
214
  return (
135
215
  <div style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
136
216
  <div>
137
217
  <label style={labelStyle}>Name *</label>
138
- <input style={inputStyle} value={project.name} onChange={(e) => set('name', e.target.value)} placeholder="my-project" />
218
+ <input style={inputStyle} value={draft.name} onChange={(e) => set('name', e.target.value)} placeholder="my-project" />
139
219
  </div>
140
220
  <div>
141
221
  <label style={labelStyle}>Description *</label>
142
- <textarea style={{ ...inputStyle, minHeight: 72, resize: 'vertical' }} value={project.description} onChange={(e) => set('description', e.target.value)} placeholder="A short project description" />
222
+ <textarea style={{ ...inputStyle, minHeight: 72, resize: 'vertical' }} value={draft.description} onChange={(e) => set('description', e.target.value)} placeholder="A short project description" />
143
223
  </div>
144
224
  <div>
145
225
  <label style={labelStyle}>Icon</label>
146
- <input style={inputStyle} value={project.icon ?? ''} onChange={(e) => set('icon', e.target.value)} placeholder="e.g. book, code, rocket" />
226
+ <input style={inputStyle} value={draft.icon ?? ''} onChange={(e) => set('icon', e.target.value)} placeholder="e.g. book, code, rocket" />
147
227
  </div>
148
228
  <div style={{ display: 'flex', gap: '0.75rem' }}>
149
- <div style={{ flex: 1 }}>
150
- <label style={labelStyle}>Mode</label>
151
- <select style={inputStyle} value={mode} onChange={(e) => set('mode', e.target.value as HubProject['mode'])}>
152
- <option value="link">Link</option>
153
- <option value="embed">Embed</option>
154
- <option value="inject">Inject</option>
155
- </select>
156
- </div>
157
229
  <div style={{ flex: 1 }}>
158
230
  <label style={labelStyle}>Status</label>
159
- <select style={inputStyle} value={project.status ?? 'active'} onChange={(e) => set('status', e.target.value as HubProject['status'])}>
231
+ <select style={inputStyle} value={draft.status} onChange={(e) => set('status', e.target.value as DraftProject['status'])}>
160
232
  <option value="active">Active</option>
161
233
  <option value="beta">Beta</option>
162
234
  <option value="planned">Planned</option>
163
235
  <option value="deprecated">Deprecated</option>
164
236
  </select>
165
237
  </div>
166
- </div>
167
- <div>
168
- <label style={labelStyle}>Group</label>
169
- <input style={inputStyle} value={project.group ?? ''} onChange={(e) => set('group', e.target.value)} placeholder="e.g. backend, frontend" />
238
+ <div style={{ flex: 1 }}>
239
+ <label style={labelStyle}>Group</label>
240
+ <input style={inputStyle} value={draft.group ?? ''} onChange={(e) => set('group', e.target.value)} placeholder="e.g. backend, frontend" />
241
+ </div>
170
242
  </div>
171
243
 
172
- {mode === 'link' && (
173
- <div>
174
- <label style={labelStyle}>URL (href)</label>
175
- <input style={inputStyle} value={project.href ?? ''} onChange={(e) => set('href', e.target.value)} placeholder="https://example.com/docs" />
244
+ <div style={{ display: 'flex', gap: '0.75rem' }}>
245
+ <div style={{ flex: 1 }}>
246
+ <label style={labelStyle}>Source</label>
247
+ <select
248
+ style={inputStyle}
249
+ value={draft.sourceKind}
250
+ onChange={(e) => set('sourceKind', e.target.value as HubProjectSource['kind'])}
251
+ >
252
+ <option value="none">none (no content pulled)</option>
253
+ <option value="git">git (sparse clone)</option>
254
+ <option value="url" disabled>url (reserved)</option>
255
+ <option value="inline" disabled>inline (reserved)</option>
256
+ </select>
176
257
  </div>
177
- )}
258
+ <div style={{ flex: 1 }}>
259
+ <label style={labelStyle}>Placement</label>
260
+ <select
261
+ style={inputStyle}
262
+ value={draft.placementKind}
263
+ onChange={(e) => set('placementKind', e.target.value as HubProjectPlacement['kind'])}
264
+ >
265
+ <option value="card">card (grid entry, link out)</option>
266
+ <option value="tab">tab (full hub tab)</option>
267
+ <option value="nested">nested (into existing section)</option>
268
+ </select>
269
+ </div>
270
+ </div>
178
271
 
179
- {(mode === 'embed' || mode === 'inject') && (
272
+ {draft.sourceKind === 'git' && (
180
273
  <>
181
274
  <div>
182
- <label style={labelStyle}>Git Repo</label>
183
- <input style={inputStyle} value={project.git?.repo ?? ''} onChange={(e) => set('git', { ...project.git, repo: e.target.value })} placeholder="org/repo" />
275
+ <label style={labelStyle}>Git repo *</label>
276
+ <input style={inputStyle} value={draft.gitRepo} onChange={(e) => set('gitRepo', e.target.value)} placeholder="https://github.com/org/repo.git" />
184
277
  </div>
185
- <div>
186
- <label style={labelStyle}>Git Ref</label>
187
- <input style={inputStyle} value={project.git?.ref ?? ''} onChange={(e) => set('git', { ...project.git, repo: project.git?.repo ?? '', ref: e.target.value })} placeholder="main" />
278
+ <div style={{ display: 'flex', gap: '0.75rem' }}>
279
+ <div style={{ flex: 1 }}>
280
+ <label style={labelStyle}>Git ref</label>
281
+ <input style={inputStyle} value={draft.gitRef} onChange={(e) => set('gitRef', e.target.value)} placeholder="main" />
282
+ </div>
283
+ <div style={{ flex: 1 }}>
284
+ <label style={labelStyle}>Git path</label>
285
+ <input style={inputStyle} value={draft.gitPath} onChange={(e) => set('gitPath', e.target.value)} placeholder="docs/public" />
286
+ </div>
188
287
  </div>
189
288
  </>
190
289
  )}
191
290
 
192
- {mode === 'embed' && (
291
+ {draft.placementKind === 'card' && (
193
292
  <div>
194
- <label style={labelStyle}>Embed Sections</label>
195
- <select style={inputStyle} value={typeof project.embedSections === 'string' ? project.embedSections : 'all'} onChange={(e) => set('embedSections', e.target.value as 'all' | 'public')}>
196
- <option value="all">All</option>
197
- <option value="public">Public</option>
293
+ <label style={labelStyle}>Card href *</label>
294
+ <input style={inputStyle} value={draft.cardHref} onChange={(e) => set('cardHref', e.target.value)} placeholder="https://example.com/docs" />
295
+ </div>
296
+ )}
297
+
298
+ {draft.placementKind === 'tab' && (
299
+ <div>
300
+ <label style={labelStyle}>Tab sections</label>
301
+ <select style={inputStyle} value={draft.tabSections} onChange={(e) => set('tabSections', e.target.value as 'all' | 'public')}>
302
+ <option value="public">public (non-draft)</option>
303
+ <option value="all">all</option>
198
304
  </select>
199
305
  </div>
200
306
  )}
201
307
 
202
- {mode === 'inject' && (
308
+ {draft.placementKind === 'nested' && (
203
309
  <>
204
310
  <div>
205
- <label style={labelStyle}>Inject Into</label>
206
- <input style={inputStyle} value={project.injectInto ?? ''} onChange={(e) => set('injectInto', e.target.value)} placeholder="section-id" />
311
+ <label style={labelStyle}>Nest into (section label) *</label>
312
+ <input style={inputStyle} value={draft.nestedInto} onChange={(e) => set('nestedInto', e.target.value)} placeholder="Documentation" />
207
313
  </div>
208
- <div>
209
- <label style={labelStyle}>Docs Path</label>
210
- <input style={inputStyle} value={project.docsPath ?? 'docs'} onChange={(e) => set('docsPath', e.target.value)} placeholder="docs" />
314
+ <div style={{ display: 'flex', gap: '0.75rem' }}>
315
+ <div style={{ flex: 1 }}>
316
+ <label style={labelStyle}>Docs path (in cloned repo)</label>
317
+ <input style={inputStyle} value={draft.nestedDocsPath} onChange={(e) => set('nestedDocsPath', e.target.value)} placeholder="docs" />
318
+ </div>
319
+ <div style={{ flex: 1 }}>
320
+ <label style={labelStyle}>Nested group (optional)</label>
321
+ <input style={inputStyle} value={draft.nestedGroup} onChange={(e) => set('nestedGroup', e.target.value)} placeholder="Services" />
322
+ </div>
211
323
  </div>
212
324
  </>
213
325
  )}
@@ -216,7 +328,7 @@ function ProjectForm({
216
328
  <label style={labelStyle}>Tags (comma-separated)</label>
217
329
  <input
218
330
  style={inputStyle}
219
- value={(project.tags ?? []).join(', ')}
331
+ value={(draft.tags ?? []).join(', ')}
220
332
  onChange={(e) => set('tags', e.target.value.split(',').map((t) => t.trim()).filter(Boolean))}
221
333
  placeholder="api, docs, internal"
222
334
  />
@@ -224,7 +336,7 @@ function ProjectForm({
224
336
 
225
337
  <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '0.25rem' }}>
226
338
  <button style={primaryBtnStyle} onClick={onSave} disabled={saving}>
227
- {saving ? 'Saving\u2026' : 'Save Project'}
339
+ {saving ? 'Saving...' : 'Save Project'}
228
340
  </button>
229
341
  </div>
230
342
  </div>
@@ -243,7 +355,7 @@ export function ProjectsManager() {
243
355
 
244
356
  // modal state
245
357
  const [editIndex, setEditIndex] = useState<number | null>(null); // null = closed, -1 = new
246
- const [draft, setDraft] = useState<HubProject>(emptyProject());
358
+ const [draft, setDraft] = useState<DraftProject>(emptyDraft());
247
359
  const [saving, setSaving] = useState(false);
248
360
 
249
361
  // delete confirm
@@ -318,9 +430,20 @@ export function ProjectsManager() {
318
430
  show('Name and description are required', 'error');
319
431
  return;
320
432
  }
433
+ if (draft.sourceKind === 'git' && !draft.gitRepo.trim()) {
434
+ show('Git repo is required for git source', 'error');
435
+ return;
436
+ }
437
+ if (draft.placementKind === 'card' && !draft.cardHref.trim()) {
438
+ show('Card href is required for card placement', 'error');
439
+ return;
440
+ }
441
+ if (draft.placementKind === 'nested' && !draft.nestedInto.trim()) {
442
+ show('Nest-into section label is required for nested placement', 'error');
443
+ return;
444
+ }
321
445
 
322
- const original = editIndex !== null && editIndex >= 0 ? projects[editIndex] : undefined;
323
- const cleaned = cleanProject(draft, original);
446
+ const cleaned = draftToProject(draft);
324
447
  const next = [...projects];
325
448
  if (editIndex === -1) {
326
449
  next.push(cleaned);
@@ -360,18 +483,13 @@ export function ProjectsManager() {
360
483
 
361
484
  /* ---------- open modal ---------- */
362
485
  const openAdd = () => {
363
- setDraft(emptyProject());
486
+ setDraft(emptyDraft());
364
487
  setEditIndex(-1);
365
488
  };
366
489
 
367
490
  const openEdit = (index: number) => {
368
491
  const p = projects[index];
369
- setDraft({
370
- ...emptyProject(),
371
- ...p,
372
- git: p.git ? { ...p.git } : { repo: '', ref: '' },
373
- tags: p.tags ? [...p.tags] : [],
374
- });
492
+ setDraft(projectToDraft(p));
375
493
  setEditIndex(index);
376
494
  };
377
495
 
@@ -415,7 +533,7 @@ export function ProjectsManager() {
415
533
  <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
416
534
  <thead>
417
535
  <tr style={{ backgroundColor: 'var(--clearify-bg-secondary)', borderBottom: '1px solid var(--clearify-border)' }}>
418
- {['Name', 'Description', 'Mode', 'Group', 'Status', ''].map((h) => (
536
+ {['Name', 'Description', 'Source + Placement', 'Group', 'Status', ''].map((h) => (
419
537
  <th key={h} style={{ textAlign: 'left', padding: '0.625rem 0.75rem', fontWeight: 600, color: 'var(--clearify-text-secondary)', fontSize: '0.8125rem' }}>{h}</th>
420
538
  ))}
421
539
  </tr>
@@ -438,8 +556,8 @@ export function ProjectsManager() {
438
556
  <td style={{ padding: '0.625rem 0.75rem', color: 'var(--clearify-text-secondary)', maxWidth: 250, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
439
557
  {p.description}
440
558
  </td>
441
- <td style={{ padding: '0.625rem 0.75rem', color: 'var(--clearify-text-secondary)' }}>{p.mode ?? 'link'}</td>
442
- <td style={{ padding: '0.625rem 0.75rem', color: 'var(--clearify-text-secondary)' }}>{p.group ?? '\u2014'}</td>
559
+ <td style={{ padding: '0.625rem 0.75rem', color: 'var(--clearify-text-secondary)' }}>{combo(p)}</td>
560
+ <td style={{ padding: '0.625rem 0.75rem', color: 'var(--clearify-text-secondary)' }}>{p.group ?? '-'}</td>
443
561
  <td style={{ padding: '0.625rem 0.75rem' }}>
444
562
  <span style={{ display: 'inline-block', padding: '0.125rem 0.5rem', borderRadius: '9999px', fontSize: '0.75rem', fontWeight: 600, backgroundColor: colors.bg, color: colors.text }}>
445
563
  {st}
@@ -512,7 +630,7 @@ export function ProjectsManager() {
512
630
 
513
631
  {/* Edit / Add modal */}
514
632
  <Modal open={editIndex !== null} onClose={() => setEditIndex(null)} title={editIndex === -1 ? 'Add Project' : `Edit: ${editIndex !== null && editIndex >= 0 ? projects[editIndex]?.name ?? '' : ''}`}>
515
- <ProjectForm project={draft} onChange={setDraft} onSave={handleSave} saving={saving} />
633
+ <ProjectForm draft={draft} onChange={setDraft} onSave={handleSave} saving={saving} />
516
634
  </Modal>
517
635
 
518
636
  {/* Delete confirmation */}
@@ -8,6 +8,22 @@ interface ProjectGridProps {
8
8
  children?: React.ReactNode;
9
9
  }
10
10
 
11
+ function projectHref(project: HubProject): string | undefined {
12
+ if (project.placement.kind === 'card') return project.placement.href;
13
+ if (project.placement.kind === 'tab') {
14
+ // Default to the project's own tab path on the hub
15
+ const slug = project.name
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9]+/g, '-')
18
+ .replace(/^-|-$/g, '');
19
+ return `/${slug}`;
20
+ }
21
+ if (project.placement.kind === 'nested') {
22
+ return `/${project.placement.into}`;
23
+ }
24
+ return undefined;
25
+ }
26
+
11
27
  function ProjectGridInner({ cols, projects }: { cols: number; projects: HubProject[] }) {
12
28
  return (
13
29
  <div
@@ -23,7 +39,7 @@ function ProjectGridInner({ cols, projects }: { cols: number; projects: HubProje
23
39
  key={project.name}
24
40
  name={project.name}
25
41
  description={project.description}
26
- href={project.href}
42
+ href={projectHref(project)}
27
43
  repo={project.repo}
28
44
  status={project.status}
29
45
  icon={project.icon}