pilothub 0.0.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/.env.local.example +19 -0
- package/.github/workflows/ci.yml +40 -0
- package/.oxlintrc.json +3 -0
- package/AGENTS.md +45 -0
- package/CHANGELOG.md +138 -0
- package/DEPRECATIONS.md +7 -0
- package/LICENSE +21 -0
- package/README.md +150 -0
- package/biome.json +41 -0
- package/convex/_generated/api.d.ts +153 -0
- package/convex/_generated/api.js +23 -0
- package/convex/_generated/dataModel.d.ts +60 -0
- package/convex/_generated/server.d.ts +143 -0
- package/convex/_generated/server.js +93 -0
- package/convex/auth.config.ts +8 -0
- package/convex/auth.ts +19 -0
- package/convex/comments.ts +88 -0
- package/convex/crons.ts +34 -0
- package/convex/devSeed.ts +459 -0
- package/convex/devSeedExtra.ts +541 -0
- package/convex/downloads.ts +78 -0
- package/convex/githubBackups.ts +170 -0
- package/convex/githubBackupsNode.ts +183 -0
- package/convex/githubImport.ts +317 -0
- package/convex/githubSoulBackups.ts +170 -0
- package/convex/githubSoulBackupsNode.ts +186 -0
- package/convex/http.ts +194 -0
- package/convex/httpApi.handlers.test.ts +488 -0
- package/convex/httpApi.test.ts +70 -0
- package/convex/httpApi.ts +305 -0
- package/convex/httpApiV1.handlers.test.ts +584 -0
- package/convex/httpApiV1.ts +1172 -0
- package/convex/leaderboards.ts +39 -0
- package/convex/lib/access.ts +36 -0
- package/convex/lib/apiTokenAuth.ts +36 -0
- package/convex/lib/badges.ts +50 -0
- package/convex/lib/changelog.test.ts +34 -0
- package/convex/lib/changelog.ts +278 -0
- package/convex/lib/embeddings.ts +38 -0
- package/convex/lib/githubBackup.ts +443 -0
- package/convex/lib/githubImport.test.ts +247 -0
- package/convex/lib/githubImport.ts +425 -0
- package/convex/lib/githubSoulBackup.ts +443 -0
- package/convex/lib/leaderboards.ts +103 -0
- package/convex/lib/moderation.ts +42 -0
- package/convex/lib/public.ts +89 -0
- package/convex/lib/searchText.test.ts +46 -0
- package/convex/lib/searchText.ts +27 -0
- package/convex/lib/skillBackfill.test.ts +34 -0
- package/convex/lib/skillBackfill.ts +67 -0
- package/convex/lib/skillPublish.test.ts +28 -0
- package/convex/lib/skillPublish.ts +284 -0
- package/convex/lib/skillStats.ts +80 -0
- package/convex/lib/skills.test.ts +197 -0
- package/convex/lib/skills.ts +273 -0
- package/convex/lib/soulChangelog.ts +273 -0
- package/convex/lib/soulPublish.ts +236 -0
- package/convex/lib/tokens.test.ts +33 -0
- package/convex/lib/tokens.ts +51 -0
- package/convex/lib/webhooks.test.ts +91 -0
- package/convex/lib/webhooks.ts +112 -0
- package/convex/maintenance.test.ts +270 -0
- package/convex/maintenance.ts +840 -0
- package/convex/rateLimits.ts +50 -0
- package/convex/schema.ts +472 -0
- package/convex/search.test.ts +12 -0
- package/convex/search.ts +254 -0
- package/convex/seed.test.ts +37 -0
- package/convex/seed.ts +254 -0
- package/convex/seedSouls.ts +111 -0
- package/convex/skillStatEvents.ts +568 -0
- package/convex/skills.ts +1606 -0
- package/convex/soulComments.ts +88 -0
- package/convex/soulDownloads.ts +14 -0
- package/convex/soulStars.ts +71 -0
- package/convex/souls.ts +570 -0
- package/convex/stars.ts +108 -0
- package/convex/statsMaintenance.ts +205 -0
- package/convex/telemetry.ts +434 -0
- package/convex/tokens.ts +88 -0
- package/convex/tsconfig.json +7 -0
- package/convex/uploads.ts +20 -0
- package/convex/users.ts +122 -0
- package/convex/webhooks.ts +50 -0
- package/convex.json +3 -0
- package/docs/README.md +32 -0
- package/docs/api.md +51 -0
- package/docs/architecture.md +61 -0
- package/docs/auth.md +54 -0
- package/docs/cli.md +117 -0
- package/docs/deploy.md +78 -0
- package/docs/diffing.md +84 -0
- package/docs/github-import.md +171 -0
- package/docs/http-api.md +187 -0
- package/docs/manual-testing.md +64 -0
- package/docs/mintlify.md +43 -0
- package/docs/quickstart.md +120 -0
- package/docs/skill-format.md +58 -0
- package/docs/soul-format.md +37 -0
- package/docs/spec.md +177 -0
- package/docs/telemetry.md +91 -0
- package/docs/troubleshooting.md +49 -0
- package/docs/webhook.md +51 -0
- package/e2e/menu-smoke.pw.test.ts +49 -0
- package/e2e/pilothub.e2e.test.ts +494 -0
- package/e2e/search-exact.pw.test.ts +97 -0
- package/package.json +84 -0
- package/packages/pilothub/LICENSE +22 -0
- package/packages/pilothub/README.md +57 -0
- package/packages/pilothub/bin/pilothub.js +2 -0
- package/packages/pilothub/package.json +41 -0
- package/packages/pilothub/src/browserAuth.test.ts +96 -0
- package/packages/pilothub/src/browserAuth.ts +174 -0
- package/packages/pilothub/src/cli/buildInfo.ts +94 -0
- package/packages/pilothub/src/cli/commands/auth.ts +97 -0
- package/packages/pilothub/src/cli/commands/delete.test.ts +73 -0
- package/packages/pilothub/src/cli/commands/delete.ts +83 -0
- package/packages/pilothub/src/cli/commands/publish.test.ts +122 -0
- package/packages/pilothub/src/cli/commands/publish.ts +108 -0
- package/packages/pilothub/src/cli/commands/skills.test.ts +191 -0
- package/packages/pilothub/src/cli/commands/skills.ts +380 -0
- package/packages/pilothub/src/cli/commands/star.ts +46 -0
- package/packages/pilothub/src/cli/commands/sync.test.ts +310 -0
- package/packages/pilothub/src/cli/commands/sync.ts +200 -0
- package/packages/pilothub/src/cli/commands/syncHelpers.test.ts +26 -0
- package/packages/pilothub/src/cli/commands/syncHelpers.ts +427 -0
- package/packages/pilothub/src/cli/commands/syncTypes.ts +27 -0
- package/packages/pilothub/src/cli/commands/unstar.ts +48 -0
- package/packages/pilothub/src/cli/helpStyle.ts +45 -0
- package/packages/pilothub/src/cli/pilotbotConfig.test.ts +159 -0
- package/packages/pilothub/src/cli/pilotbotConfig.ts +147 -0
- package/packages/pilothub/src/cli/registry.test.ts +63 -0
- package/packages/pilothub/src/cli/registry.ts +43 -0
- package/packages/pilothub/src/cli/scanSkills.test.ts +64 -0
- package/packages/pilothub/src/cli/scanSkills.ts +84 -0
- package/packages/pilothub/src/cli/slug.ts +16 -0
- package/packages/pilothub/src/cli/types.ts +12 -0
- package/packages/pilothub/src/cli/ui.ts +75 -0
- package/packages/pilothub/src/cli.ts +311 -0
- package/packages/pilothub/src/config.ts +36 -0
- package/packages/pilothub/src/discovery.test.ts +75 -0
- package/packages/pilothub/src/discovery.ts +19 -0
- package/packages/pilothub/src/http.test.ts +156 -0
- package/packages/pilothub/src/http.ts +301 -0
- package/packages/pilothub/src/schema/ark.ts +29 -0
- package/packages/pilothub/src/schema/index.ts +5 -0
- package/packages/pilothub/src/schema/routes.ts +22 -0
- package/packages/pilothub/src/schema/schemas.ts +260 -0
- package/packages/pilothub/src/schema/textFiles.test.ts +23 -0
- package/packages/pilothub/src/schema/textFiles.ts +66 -0
- package/packages/pilothub/src/skills.test.ts +191 -0
- package/packages/pilothub/src/skills.ts +172 -0
- package/packages/pilothub/src/types.ts +10 -0
- package/packages/pilothub/tsconfig.json +14 -0
- package/packages/schema/README.md +3 -0
- package/packages/schema/dist/ark.d.ts +4 -0
- package/packages/schema/dist/ark.js +26 -0
- package/packages/schema/dist/ark.js.map +1 -0
- package/packages/schema/dist/index.d.ts +5 -0
- package/packages/schema/dist/index.js +5 -0
- package/packages/schema/dist/index.js.map +1 -0
- package/packages/schema/dist/routes.d.ts +21 -0
- package/packages/schema/dist/routes.js +22 -0
- package/packages/schema/dist/routes.js.map +1 -0
- package/packages/schema/dist/schemas.d.ts +297 -0
- package/packages/schema/dist/schemas.js +243 -0
- package/packages/schema/dist/schemas.js.map +1 -0
- package/packages/schema/dist/textFiles.d.ts +5 -0
- package/packages/schema/dist/textFiles.js +66 -0
- package/packages/schema/dist/textFiles.js.map +1 -0
- package/packages/schema/package.json +26 -0
- package/packages/schema/src/ark.ts +29 -0
- package/packages/schema/src/index.ts +5 -0
- package/packages/schema/src/routes.ts +22 -0
- package/packages/schema/src/schemas.test.ts +123 -0
- package/packages/schema/src/schemas.ts +287 -0
- package/packages/schema/src/textFiles.test.ts +23 -0
- package/packages/schema/src/textFiles.ts +66 -0
- package/packages/schema/tsconfig.json +15 -0
- package/pilothub +46 -0
- package/playwright.config.ts +33 -0
- package/public/.well-known/pilothub.json +6 -0
- package/public/api/v1/openapi.json +379 -0
- package/public/favicon.ico +0 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +25 -0
- package/public/og.png +0 -0
- package/public/og.svg +98 -0
- package/public/pilot-logo.png +0 -0
- package/public/pilot-mark.png +0 -0
- package/public/robots.txt +3 -0
- package/public/tanstack-circle-logo.png +0 -0
- package/public/tanstack-word-logo-white.svg +1 -0
- package/scripts/check-peer-deps.ts +56 -0
- package/scripts/docs-list.ts +148 -0
- package/scripts/run-playwright-local.sh +14 -0
- package/server/og/fetchSkillOgMeta.ts +27 -0
- package/server/og/fetchSoulOgMeta.ts +27 -0
- package/server/og/ogAssets.ts +80 -0
- package/server/og/skillOgSvg.test.ts +59 -0
- package/server/og/skillOgSvg.ts +258 -0
- package/server/og/soulOgSvg.ts +209 -0
- package/server/routes/og/skill.png.ts +103 -0
- package/server/routes/og/soul.png.ts +111 -0
- package/src/__tests__/skill-detail-page.test.tsx +86 -0
- package/src/__tests__/skills-index.test.tsx +145 -0
- package/src/__tests__/upload.route.test.tsx +228 -0
- package/src/components/AppProviders.tsx +19 -0
- package/src/components/ClientOnly.tsx +18 -0
- package/src/components/Footer.tsx +29 -0
- package/src/components/Header.tsx +295 -0
- package/src/components/InstallSwitcher.tsx +53 -0
- package/src/components/SkillCard.tsx +36 -0
- package/src/components/SkillDetailPage.tsx +817 -0
- package/src/components/SkillDiffCard.tsx +485 -0
- package/src/components/SoulCard.tsx +19 -0
- package/src/components/SoulDetailPage.tsx +263 -0
- package/src/components/UserBootstrap.tsx +18 -0
- package/src/components/ui/dropdown-menu.tsx +67 -0
- package/src/components/ui/toggle-group.tsx +35 -0
- package/src/convex/client.ts +3 -0
- package/src/lib/badges.ts +29 -0
- package/src/lib/diffing.test.ts +163 -0
- package/src/lib/diffing.ts +106 -0
- package/src/lib/gravatar.test.ts +9 -0
- package/src/lib/gravatar.ts +158 -0
- package/src/lib/og.test.ts +142 -0
- package/src/lib/og.ts +156 -0
- package/src/lib/publicUser.ts +39 -0
- package/src/lib/roles.ts +19 -0
- package/src/lib/site.test.ts +130 -0
- package/src/lib/site.ts +84 -0
- package/src/lib/theme-transition.test.ts +134 -0
- package/src/lib/theme-transition.ts +134 -0
- package/src/lib/theme.test.tsx +88 -0
- package/src/lib/theme.ts +43 -0
- package/src/lib/uploadFiles.jsdom.test.ts +33 -0
- package/src/lib/uploadFiles.test.ts +123 -0
- package/src/lib/uploadFiles.ts +245 -0
- package/src/lib/uploadUtils.test.ts +78 -0
- package/src/lib/uploadUtils.ts +93 -0
- package/src/lib/useAuthStatus.ts +12 -0
- package/src/lib/utils.test.ts +9 -0
- package/src/lib/utils.ts +6 -0
- package/src/logo.svg +12 -0
- package/src/routeTree.gen.ts +345 -0
- package/src/router.tsx +17 -0
- package/src/routes/$owner/$slug.tsx +55 -0
- package/src/routes/__root.tsx +136 -0
- package/src/routes/admin.tsx +11 -0
- package/src/routes/cli/auth.tsx +168 -0
- package/src/routes/dashboard.tsx +97 -0
- package/src/routes/import.tsx +415 -0
- package/src/routes/index.tsx +252 -0
- package/src/routes/management.tsx +529 -0
- package/src/routes/settings.tsx +203 -0
- package/src/routes/skills/index.tsx +422 -0
- package/src/routes/souls/$slug.tsx +55 -0
- package/src/routes/souls/index.tsx +243 -0
- package/src/routes/stars.tsx +68 -0
- package/src/routes/u/$handle.tsx +307 -0
- package/src/routes/upload/utils.ts +81 -0
- package/src/routes/upload.tsx +499 -0
- package/src/styles.css +2718 -0
- package/tsconfig.json +24 -0
- package/tsconfig.oxlint.json +16 -0
- package/vercel.json +8 -0
- package/vite.config.ts +48 -0
- package/vitest.config.ts +47 -0
- package/vitest.e2e.config.ts +11 -0
- package/vitest.setup.ts +1 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
2
|
+
import { useMutation, useQuery } from 'convex/react'
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { api } from '../../convex/_generated/api'
|
|
5
|
+
import type { Id } from '../../convex/_generated/dataModel'
|
|
6
|
+
import { gravatarUrl } from '../lib/gravatar'
|
|
7
|
+
|
|
8
|
+
export const Route = createFileRoute('/settings')({
|
|
9
|
+
component: Settings,
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
function Settings() {
|
|
13
|
+
const me = useQuery(api.users.me)
|
|
14
|
+
const updateProfile = useMutation(api.users.updateProfile)
|
|
15
|
+
const deleteAccount = useMutation(api.users.deleteAccount)
|
|
16
|
+
const tokens = useQuery(api.tokens.listMine) as
|
|
17
|
+
| Array<{
|
|
18
|
+
_id: Id<'apiTokens'>
|
|
19
|
+
label: string
|
|
20
|
+
prefix: string
|
|
21
|
+
createdAt: number
|
|
22
|
+
lastUsedAt?: number
|
|
23
|
+
revokedAt?: number
|
|
24
|
+
}>
|
|
25
|
+
| undefined
|
|
26
|
+
const createToken = useMutation(api.tokens.create)
|
|
27
|
+
const revokeToken = useMutation(api.tokens.revoke)
|
|
28
|
+
const [displayName, setDisplayName] = useState('')
|
|
29
|
+
const [bio, setBio] = useState('')
|
|
30
|
+
const [status, setStatus] = useState<string | null>(null)
|
|
31
|
+
const [tokenLabel, setTokenLabel] = useState('CLI token')
|
|
32
|
+
const [newToken, setNewToken] = useState<string | null>(null)
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!me) return
|
|
36
|
+
setDisplayName(me.displayName ?? '')
|
|
37
|
+
setBio(me.bio ?? '')
|
|
38
|
+
}, [me])
|
|
39
|
+
|
|
40
|
+
if (!me) {
|
|
41
|
+
return (
|
|
42
|
+
<main className="section">
|
|
43
|
+
<div className="card">Sign in to access settings.</div>
|
|
44
|
+
</main>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const avatar = me.image ?? (me.email ? gravatarUrl(me.email, 160) : undefined)
|
|
49
|
+
const identityName = me.displayName ?? me.name ?? me.handle ?? 'Profile'
|
|
50
|
+
const handle = me.handle ?? (me.email ? me.email.split('@')[0] : undefined)
|
|
51
|
+
|
|
52
|
+
async function onSave(event: React.FormEvent) {
|
|
53
|
+
event.preventDefault()
|
|
54
|
+
await updateProfile({ displayName, bio })
|
|
55
|
+
setStatus('Saved.')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function onDelete() {
|
|
59
|
+
const ok = window.confirm('Soft delete your account? This cannot be undone.')
|
|
60
|
+
if (!ok) return
|
|
61
|
+
await deleteAccount()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function onCreateToken() {
|
|
65
|
+
const label = tokenLabel.trim() || 'CLI token'
|
|
66
|
+
const result = await createToken({ label })
|
|
67
|
+
setNewToken(result.token)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<main className="section settings-shell">
|
|
72
|
+
<h1 className="section-title">Settings</h1>
|
|
73
|
+
<div className="card settings-profile">
|
|
74
|
+
<div className="settings-avatar">
|
|
75
|
+
{avatar ? (
|
|
76
|
+
<img src={avatar} alt={identityName} />
|
|
77
|
+
) : (
|
|
78
|
+
<span>{identityName[0]?.toUpperCase() ?? 'U'}</span>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
<div className="settings-profile-body">
|
|
82
|
+
<div className="settings-name">{identityName}</div>
|
|
83
|
+
{handle ? <div className="settings-handle">@{handle}</div> : null}
|
|
84
|
+
{me.email ? <div className="settings-email">{me.email}</div> : null}
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
<form className="card settings-card" onSubmit={onSave}>
|
|
88
|
+
<label className="settings-field">
|
|
89
|
+
<span>Display name</span>
|
|
90
|
+
<input
|
|
91
|
+
className="settings-input"
|
|
92
|
+
value={displayName}
|
|
93
|
+
onChange={(event) => setDisplayName(event.target.value)}
|
|
94
|
+
/>
|
|
95
|
+
</label>
|
|
96
|
+
<label className="settings-field">
|
|
97
|
+
<span>Bio</span>
|
|
98
|
+
<textarea
|
|
99
|
+
className="settings-input"
|
|
100
|
+
rows={5}
|
|
101
|
+
value={bio}
|
|
102
|
+
onChange={(event) => setBio(event.target.value)}
|
|
103
|
+
placeholder="Tell people what you're building."
|
|
104
|
+
/>
|
|
105
|
+
</label>
|
|
106
|
+
<div className="settings-actions">
|
|
107
|
+
<button className="btn btn-primary settings-save" type="submit">
|
|
108
|
+
Save
|
|
109
|
+
</button>
|
|
110
|
+
{status ? <div className="stat">{status}</div> : null}
|
|
111
|
+
</div>
|
|
112
|
+
</form>
|
|
113
|
+
|
|
114
|
+
<div className="card settings-card">
|
|
115
|
+
<h2 className="section-title danger-title" style={{ marginTop: 0 }}>
|
|
116
|
+
API tokens
|
|
117
|
+
</h2>
|
|
118
|
+
<p className="section-subtitle">
|
|
119
|
+
Use these tokens for the `pilothub` CLI. Tokens are shown once on creation.
|
|
120
|
+
</p>
|
|
121
|
+
|
|
122
|
+
<div className="settings-field">
|
|
123
|
+
<span>Label</span>
|
|
124
|
+
<input
|
|
125
|
+
className="settings-input"
|
|
126
|
+
value={tokenLabel}
|
|
127
|
+
onChange={(event) => setTokenLabel(event.target.value)}
|
|
128
|
+
placeholder="CLI token"
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
<div className="settings-actions">
|
|
132
|
+
<button
|
|
133
|
+
className="btn btn-primary settings-save"
|
|
134
|
+
type="button"
|
|
135
|
+
onClick={() => void onCreateToken()}
|
|
136
|
+
>
|
|
137
|
+
Create token
|
|
138
|
+
</button>
|
|
139
|
+
{newToken ? (
|
|
140
|
+
<div className="stat" style={{ overflowX: 'auto' }}>
|
|
141
|
+
<div style={{ marginBottom: 8 }}>Copy this token now:</div>
|
|
142
|
+
<code>{newToken}</code>
|
|
143
|
+
</div>
|
|
144
|
+
) : null}
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
{(tokens ?? []).length ? (
|
|
148
|
+
<div style={{ display: 'grid', gap: 10, marginTop: 16 }}>
|
|
149
|
+
{(tokens ?? []).map((token) => (
|
|
150
|
+
<div
|
|
151
|
+
key={token._id}
|
|
152
|
+
className="stat"
|
|
153
|
+
style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}
|
|
154
|
+
>
|
|
155
|
+
<div>
|
|
156
|
+
<div>
|
|
157
|
+
<strong>{token.label}</strong>{' '}
|
|
158
|
+
<span style={{ opacity: 0.7 }}>({token.prefix}…)</span>
|
|
159
|
+
</div>
|
|
160
|
+
<div style={{ opacity: 0.7 }}>
|
|
161
|
+
Created {formatDate(token.createdAt)}
|
|
162
|
+
{token.lastUsedAt ? ` · Used ${formatDate(token.lastUsedAt)}` : ''}
|
|
163
|
+
{token.revokedAt ? ` · Revoked ${formatDate(token.revokedAt)}` : ''}
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
<div>
|
|
167
|
+
<button
|
|
168
|
+
className="btn"
|
|
169
|
+
type="button"
|
|
170
|
+
disabled={Boolean(token.revokedAt)}
|
|
171
|
+
onClick={() => void revokeToken({ tokenId: token._id })}
|
|
172
|
+
>
|
|
173
|
+
{token.revokedAt ? 'Revoked' : 'Revoke'}
|
|
174
|
+
</button>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
))}
|
|
178
|
+
</div>
|
|
179
|
+
) : (
|
|
180
|
+
<p className="section-subtitle" style={{ marginTop: 16 }}>
|
|
181
|
+
No tokens yet.
|
|
182
|
+
</p>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<div className="card danger-card">
|
|
187
|
+
<h2 className="section-title danger-title">Danger zone</h2>
|
|
188
|
+
<p className="section-subtitle">Soft delete your account. Skills remain public.</p>
|
|
189
|
+
<button className="btn btn-danger" type="button" onClick={() => void onDelete()}>
|
|
190
|
+
Delete account
|
|
191
|
+
</button>
|
|
192
|
+
</div>
|
|
193
|
+
</main>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function formatDate(value: number) {
|
|
198
|
+
try {
|
|
199
|
+
return new Date(value).toLocaleString()
|
|
200
|
+
} catch {
|
|
201
|
+
return String(value)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { createFileRoute, Link } from '@tanstack/react-router'
|
|
2
|
+
import { useAction } from 'convex/react'
|
|
3
|
+
import { usePaginatedQuery } from 'convex-helpers/react'
|
|
4
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
5
|
+
import { api } from '../../../convex/_generated/api'
|
|
6
|
+
import type { Doc } from '../../../convex/_generated/dataModel'
|
|
7
|
+
import { SkillCard } from '../../components/SkillCard'
|
|
8
|
+
import { getSkillBadges, isSkillHighlighted } from '../../lib/badges'
|
|
9
|
+
import type { PublicSkill } from '../../lib/publicUser'
|
|
10
|
+
|
|
11
|
+
const sortKeys = ['newest', 'downloads', 'installs', 'stars', 'name', 'updated'] as const
|
|
12
|
+
const pageSize = 25
|
|
13
|
+
type SortKey = (typeof sortKeys)[number]
|
|
14
|
+
type SortDir = 'asc' | 'desc'
|
|
15
|
+
|
|
16
|
+
function parseSort(value: unknown): SortKey {
|
|
17
|
+
if (typeof value !== 'string') return 'newest'
|
|
18
|
+
if ((sortKeys as readonly string[]).includes(value)) return value as SortKey
|
|
19
|
+
return 'newest'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseDir(value: unknown, sort: SortKey): SortDir {
|
|
23
|
+
if (value === 'asc' || value === 'desc') return value
|
|
24
|
+
return sort === 'name' ? 'asc' : 'desc'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type SkillListEntry = {
|
|
28
|
+
skill: PublicSkill
|
|
29
|
+
latestVersion: Doc<'skillVersions'> | null
|
|
30
|
+
ownerHandle?: string | null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type SkillSearchEntry = {
|
|
34
|
+
skill: PublicSkill
|
|
35
|
+
version: Doc<'skillVersions'> | null
|
|
36
|
+
score: number
|
|
37
|
+
ownerHandle?: string | null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function buildSkillHref(skill: PublicSkill, ownerHandle?: string | null) {
|
|
41
|
+
const owner = ownerHandle?.trim() || String(skill.ownerUserId)
|
|
42
|
+
return `/${encodeURIComponent(owner)}/${encodeURIComponent(skill.slug)}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const Route = createFileRoute('/skills/')({
|
|
46
|
+
validateSearch: (search) => {
|
|
47
|
+
return {
|
|
48
|
+
q: typeof search.q === 'string' && search.q.trim() ? search.q : undefined,
|
|
49
|
+
sort: typeof search.sort === 'string' ? parseSort(search.sort) : undefined,
|
|
50
|
+
dir: search.dir === 'asc' || search.dir === 'desc' ? search.dir : undefined,
|
|
51
|
+
highlighted:
|
|
52
|
+
search.highlighted === '1' || search.highlighted === 'true' || search.highlighted === true
|
|
53
|
+
? true
|
|
54
|
+
: undefined,
|
|
55
|
+
view: search.view === 'cards' || search.view === 'list' ? search.view : undefined,
|
|
56
|
+
focus: search.focus === 'search' ? 'search' : undefined,
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
component: SkillsIndex,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
export function SkillsIndex() {
|
|
63
|
+
const navigate = Route.useNavigate()
|
|
64
|
+
const search = Route.useSearch()
|
|
65
|
+
const sort = search.sort ?? 'newest'
|
|
66
|
+
const dir = parseDir(search.dir, sort)
|
|
67
|
+
const view = search.view ?? 'list'
|
|
68
|
+
const highlightedOnly = search.highlighted ?? false
|
|
69
|
+
const [query, setQuery] = useState(search.q ?? '')
|
|
70
|
+
const searchSkills = useAction(api.search.searchSkills)
|
|
71
|
+
const [searchResults, setSearchResults] = useState<Array<SkillSearchEntry>>([])
|
|
72
|
+
const [searchLimit, setSearchLimit] = useState(pageSize)
|
|
73
|
+
const [isSearching, setIsSearching] = useState(false)
|
|
74
|
+
const searchRequest = useRef(0)
|
|
75
|
+
const loadMoreRef = useRef<HTMLDivElement | null>(null)
|
|
76
|
+
|
|
77
|
+
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
78
|
+
const trimmedQuery = useMemo(() => query.trim(), [query])
|
|
79
|
+
const hasQuery = trimmedQuery.length > 0
|
|
80
|
+
const searchKey = trimmedQuery ? `${trimmedQuery}::${highlightedOnly ? '1' : '0'}` : ''
|
|
81
|
+
|
|
82
|
+
// Use convex-helpers usePaginatedQuery for better cache behavior
|
|
83
|
+
const {
|
|
84
|
+
results: paginatedResults,
|
|
85
|
+
status: paginationStatus,
|
|
86
|
+
loadMore: loadMorePaginated,
|
|
87
|
+
} = usePaginatedQuery(api.skills.listPublicPageV2, hasQuery ? 'skip' : {}, {
|
|
88
|
+
initialNumItems: pageSize,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// Derive loading states from pagination status
|
|
92
|
+
// status: 'LoadingFirstPage' | 'CanLoadMore' | 'LoadingMore' | 'Exhausted'
|
|
93
|
+
const isLoadingList = paginationStatus === 'LoadingFirstPage'
|
|
94
|
+
const canLoadMoreList = paginationStatus === 'CanLoadMore'
|
|
95
|
+
const isLoadingMoreList = paginationStatus === 'LoadingMore'
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
setQuery(search.q ?? '')
|
|
99
|
+
}, [search.q])
|
|
100
|
+
|
|
101
|
+
// Auto-focus search input when focus=search param is present
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (search.focus === 'search' && searchInputRef.current) {
|
|
104
|
+
searchInputRef.current.focus()
|
|
105
|
+
// Clear the focus param from URL to avoid re-focusing on navigation
|
|
106
|
+
void navigate({ search: (prev) => ({ ...prev, focus: undefined }), replace: true })
|
|
107
|
+
}
|
|
108
|
+
}, [search.focus, navigate])
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (!searchKey) {
|
|
112
|
+
setSearchResults([])
|
|
113
|
+
setIsSearching(false)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
setSearchResults([])
|
|
117
|
+
setSearchLimit(pageSize)
|
|
118
|
+
}, [searchKey])
|
|
119
|
+
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (!hasQuery) return
|
|
122
|
+
searchRequest.current += 1
|
|
123
|
+
const requestId = searchRequest.current
|
|
124
|
+
setIsSearching(true)
|
|
125
|
+
const handle = window.setTimeout(() => {
|
|
126
|
+
void (async () => {
|
|
127
|
+
try {
|
|
128
|
+
const data = (await searchSkills({
|
|
129
|
+
query: trimmedQuery,
|
|
130
|
+
highlightedOnly,
|
|
131
|
+
limit: searchLimit,
|
|
132
|
+
})) as Array<SkillSearchEntry>
|
|
133
|
+
if (requestId === searchRequest.current) {
|
|
134
|
+
setSearchResults(data)
|
|
135
|
+
}
|
|
136
|
+
} finally {
|
|
137
|
+
if (requestId === searchRequest.current) {
|
|
138
|
+
setIsSearching(false)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
})()
|
|
142
|
+
}, 220)
|
|
143
|
+
return () => window.clearTimeout(handle)
|
|
144
|
+
}, [hasQuery, highlightedOnly, searchLimit, searchSkills, trimmedQuery])
|
|
145
|
+
|
|
146
|
+
const baseItems = useMemo(() => {
|
|
147
|
+
if (hasQuery) {
|
|
148
|
+
return searchResults.map((entry) => ({
|
|
149
|
+
skill: entry.skill,
|
|
150
|
+
latestVersion: entry.version,
|
|
151
|
+
ownerHandle: entry.ownerHandle ?? null,
|
|
152
|
+
}))
|
|
153
|
+
}
|
|
154
|
+
// paginatedResults is an array of page items from usePaginatedQuery
|
|
155
|
+
return paginatedResults as Array<SkillListEntry>
|
|
156
|
+
}, [hasQuery, paginatedResults, searchResults])
|
|
157
|
+
|
|
158
|
+
const filtered = useMemo(
|
|
159
|
+
() => baseItems.filter((entry) => (highlightedOnly ? isSkillHighlighted(entry.skill) : true)),
|
|
160
|
+
[baseItems, highlightedOnly],
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
const sorted = useMemo(() => {
|
|
164
|
+
const multiplier = dir === 'asc' ? 1 : -1
|
|
165
|
+
const results = [...filtered]
|
|
166
|
+
results.sort((a, b) => {
|
|
167
|
+
switch (sort) {
|
|
168
|
+
case 'downloads':
|
|
169
|
+
return (a.skill.stats.downloads - b.skill.stats.downloads) * multiplier
|
|
170
|
+
case 'installs':
|
|
171
|
+
return (
|
|
172
|
+
((a.skill.stats.installsAllTime ?? 0) - (b.skill.stats.installsAllTime ?? 0)) *
|
|
173
|
+
multiplier
|
|
174
|
+
)
|
|
175
|
+
case 'stars':
|
|
176
|
+
return (a.skill.stats.stars - b.skill.stats.stars) * multiplier
|
|
177
|
+
case 'updated':
|
|
178
|
+
return (a.skill.updatedAt - b.skill.updatedAt) * multiplier
|
|
179
|
+
case 'name':
|
|
180
|
+
return (
|
|
181
|
+
(a.skill.displayName.localeCompare(b.skill.displayName) ||
|
|
182
|
+
a.skill.slug.localeCompare(b.skill.slug)) * multiplier
|
|
183
|
+
)
|
|
184
|
+
default:
|
|
185
|
+
return (a.skill.createdAt - b.skill.createdAt) * multiplier
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
return results
|
|
189
|
+
}, [dir, filtered, sort])
|
|
190
|
+
|
|
191
|
+
const isLoadingSkills = hasQuery ? isSearching && searchResults.length === 0 : isLoadingList
|
|
192
|
+
const canLoadMore = hasQuery
|
|
193
|
+
? !isSearching && searchResults.length === searchLimit && searchResults.length > 0
|
|
194
|
+
: canLoadMoreList
|
|
195
|
+
const isLoadingMore = hasQuery ? isSearching && searchResults.length > 0 : isLoadingMoreList
|
|
196
|
+
const canAutoLoad = typeof IntersectionObserver !== 'undefined'
|
|
197
|
+
|
|
198
|
+
const loadMore = useCallback(() => {
|
|
199
|
+
if (isLoadingMore || !canLoadMore) return
|
|
200
|
+
if (hasQuery) {
|
|
201
|
+
setSearchLimit((value) => value + pageSize)
|
|
202
|
+
} else {
|
|
203
|
+
loadMorePaginated(pageSize)
|
|
204
|
+
}
|
|
205
|
+
}, [canLoadMore, hasQuery, isLoadingMore, loadMorePaginated])
|
|
206
|
+
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (!canLoadMore || typeof IntersectionObserver === 'undefined') return
|
|
209
|
+
const target = loadMoreRef.current
|
|
210
|
+
if (!target) return
|
|
211
|
+
const observer = new IntersectionObserver(
|
|
212
|
+
(entries) => {
|
|
213
|
+
if (entries.some((entry) => entry.isIntersecting)) {
|
|
214
|
+
loadMore()
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
{ rootMargin: '200px' },
|
|
218
|
+
)
|
|
219
|
+
observer.observe(target)
|
|
220
|
+
return () => observer.disconnect()
|
|
221
|
+
}, [canLoadMore, loadMore])
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<main className="section">
|
|
225
|
+
<header className="skills-header">
|
|
226
|
+
<div>
|
|
227
|
+
<h1 className="section-title" style={{ marginBottom: 8 }}>
|
|
228
|
+
Skills
|
|
229
|
+
</h1>
|
|
230
|
+
<p className="section-subtitle" style={{ marginBottom: 0 }}>
|
|
231
|
+
{isLoadingSkills
|
|
232
|
+
? 'Loading skills…'
|
|
233
|
+
: `Browse the skill library${highlightedOnly ? ' (highlighted)' : ''}.`}
|
|
234
|
+
</p>
|
|
235
|
+
</div>
|
|
236
|
+
<div className="skills-toolbar">
|
|
237
|
+
<div className="skills-search">
|
|
238
|
+
<input
|
|
239
|
+
ref={searchInputRef}
|
|
240
|
+
className="skills-search-input"
|
|
241
|
+
value={query}
|
|
242
|
+
onChange={(event) => {
|
|
243
|
+
const next = event.target.value
|
|
244
|
+
const trimmed = next.trim()
|
|
245
|
+
setQuery(next)
|
|
246
|
+
void navigate({
|
|
247
|
+
search: (prev) => ({ ...prev, q: trimmed ? next : undefined }),
|
|
248
|
+
replace: true,
|
|
249
|
+
})
|
|
250
|
+
}}
|
|
251
|
+
placeholder="Filter by name, slug, or summary…"
|
|
252
|
+
/>
|
|
253
|
+
</div>
|
|
254
|
+
<div className="skills-toolbar-row">
|
|
255
|
+
<button
|
|
256
|
+
className={`search-filter-button${highlightedOnly ? ' is-active' : ''}`}
|
|
257
|
+
type="button"
|
|
258
|
+
aria-pressed={highlightedOnly}
|
|
259
|
+
onClick={() => {
|
|
260
|
+
void navigate({
|
|
261
|
+
search: (prev) => ({
|
|
262
|
+
...prev,
|
|
263
|
+
highlighted: highlightedOnly ? undefined : true,
|
|
264
|
+
}),
|
|
265
|
+
replace: true,
|
|
266
|
+
})
|
|
267
|
+
}}
|
|
268
|
+
>
|
|
269
|
+
Highlighted
|
|
270
|
+
</button>
|
|
271
|
+
<select
|
|
272
|
+
className="skills-sort"
|
|
273
|
+
value={sort}
|
|
274
|
+
onChange={(event) => {
|
|
275
|
+
const sort = parseSort(event.target.value)
|
|
276
|
+
void navigate({
|
|
277
|
+
search: (prev) => ({
|
|
278
|
+
...prev,
|
|
279
|
+
sort,
|
|
280
|
+
dir: parseDir(prev.dir, sort),
|
|
281
|
+
}),
|
|
282
|
+
replace: true,
|
|
283
|
+
})
|
|
284
|
+
}}
|
|
285
|
+
aria-label="Sort skills"
|
|
286
|
+
>
|
|
287
|
+
<option value="newest">Newest</option>
|
|
288
|
+
<option value="updated">Recently updated</option>
|
|
289
|
+
<option value="downloads">Downloads</option>
|
|
290
|
+
<option value="installs">Installs</option>
|
|
291
|
+
<option value="stars">Stars</option>
|
|
292
|
+
<option value="name">Name</option>
|
|
293
|
+
</select>
|
|
294
|
+
<button
|
|
295
|
+
className="skills-dir"
|
|
296
|
+
type="button"
|
|
297
|
+
aria-label={`Sort direction ${dir}`}
|
|
298
|
+
onClick={() => {
|
|
299
|
+
void navigate({
|
|
300
|
+
search: (prev) => ({
|
|
301
|
+
...prev,
|
|
302
|
+
dir: parseDir(prev.dir, sort) === 'asc' ? 'desc' : 'asc',
|
|
303
|
+
}),
|
|
304
|
+
replace: true,
|
|
305
|
+
})
|
|
306
|
+
}}
|
|
307
|
+
>
|
|
308
|
+
{dir === 'asc' ? '↑' : '↓'}
|
|
309
|
+
</button>
|
|
310
|
+
<button
|
|
311
|
+
className={`skills-view${view === 'cards' ? ' is-active' : ''}`}
|
|
312
|
+
type="button"
|
|
313
|
+
onClick={() => {
|
|
314
|
+
void navigate({
|
|
315
|
+
search: (prev) => ({
|
|
316
|
+
...prev,
|
|
317
|
+
view: prev.view === 'cards' ? undefined : 'cards',
|
|
318
|
+
}),
|
|
319
|
+
replace: true,
|
|
320
|
+
})
|
|
321
|
+
}}
|
|
322
|
+
>
|
|
323
|
+
{view === 'cards' ? 'List' : 'Cards'}
|
|
324
|
+
</button>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
</header>
|
|
328
|
+
|
|
329
|
+
{isLoadingSkills ? (
|
|
330
|
+
<div className="card">
|
|
331
|
+
<div className="loading-indicator">Loading skills…</div>
|
|
332
|
+
</div>
|
|
333
|
+
) : sorted.length === 0 ? (
|
|
334
|
+
<div className="card">No skills match that filter.</div>
|
|
335
|
+
) : view === 'cards' ? (
|
|
336
|
+
<div className="grid">
|
|
337
|
+
{sorted.map((entry) => {
|
|
338
|
+
const skill = entry.skill
|
|
339
|
+
const isPlugin = Boolean(entry.latestVersion?.parsed?.pilotbot?.nix?.plugin)
|
|
340
|
+
const skillHref = buildSkillHref(skill, entry.ownerHandle)
|
|
341
|
+
return (
|
|
342
|
+
<SkillCard
|
|
343
|
+
key={skill._id}
|
|
344
|
+
skill={skill}
|
|
345
|
+
href={skillHref}
|
|
346
|
+
badge={getSkillBadges(skill)}
|
|
347
|
+
chip={isPlugin ? 'Plugin bundle (nix)' : undefined}
|
|
348
|
+
summaryFallback="Agent-ready skill pack."
|
|
349
|
+
meta={
|
|
350
|
+
<div className="stat">
|
|
351
|
+
⭐ {skill.stats.stars} · ⤓ {skill.stats.downloads} · ⤒{' '}
|
|
352
|
+
{skill.stats.installsAllTime ?? 0}
|
|
353
|
+
</div>
|
|
354
|
+
}
|
|
355
|
+
/>
|
|
356
|
+
)
|
|
357
|
+
})}
|
|
358
|
+
</div>
|
|
359
|
+
) : (
|
|
360
|
+
<div className="skills-list">
|
|
361
|
+
{sorted.map((entry) => {
|
|
362
|
+
const skill = entry.skill
|
|
363
|
+
const isPlugin = Boolean(entry.latestVersion?.parsed?.pilotbot?.nix?.plugin)
|
|
364
|
+
const skillHref = buildSkillHref(skill, entry.ownerHandle)
|
|
365
|
+
return (
|
|
366
|
+
<Link key={skill._id} className="skills-row" to={skillHref}>
|
|
367
|
+
<div className="skills-row-main">
|
|
368
|
+
<div className="skills-row-title">
|
|
369
|
+
<span>{skill.displayName}</span>
|
|
370
|
+
<span className="skills-row-slug">/{skill.slug}</span>
|
|
371
|
+
{getSkillBadges(skill).map((badge) => (
|
|
372
|
+
<span key={badge} className="tag">
|
|
373
|
+
{badge}
|
|
374
|
+
</span>
|
|
375
|
+
))}
|
|
376
|
+
{isPlugin ? (
|
|
377
|
+
<span className="tag tag-accent tag-compact">Plugin bundle (nix)</span>
|
|
378
|
+
) : null}
|
|
379
|
+
</div>
|
|
380
|
+
<div className="skills-row-summary">
|
|
381
|
+
{skill.summary ?? 'No summary provided.'}
|
|
382
|
+
</div>
|
|
383
|
+
{isPlugin ? (
|
|
384
|
+
<div className="skills-row-meta">
|
|
385
|
+
Bundle includes SKILL.md, CLI, and config.
|
|
386
|
+
</div>
|
|
387
|
+
) : null}
|
|
388
|
+
</div>
|
|
389
|
+
<div className="skills-row-metrics">
|
|
390
|
+
<span>⤓ {skill.stats.downloads}</span>
|
|
391
|
+
<span>⤒ {skill.stats.installsAllTime ?? 0}</span>
|
|
392
|
+
<span>★ {skill.stats.stars}</span>
|
|
393
|
+
<span>{skill.stats.versions} v</span>
|
|
394
|
+
</div>
|
|
395
|
+
</Link>
|
|
396
|
+
)
|
|
397
|
+
})}
|
|
398
|
+
</div>
|
|
399
|
+
)}
|
|
400
|
+
|
|
401
|
+
{canLoadMore ? (
|
|
402
|
+
<div
|
|
403
|
+
ref={canAutoLoad ? loadMoreRef : null}
|
|
404
|
+
className="card"
|
|
405
|
+
style={{ marginTop: 16, display: 'flex', justifyContent: 'center' }}
|
|
406
|
+
>
|
|
407
|
+
{canAutoLoad ? (
|
|
408
|
+
isLoadingMore ? (
|
|
409
|
+
'Loading more…'
|
|
410
|
+
) : (
|
|
411
|
+
'Scroll to load more'
|
|
412
|
+
)
|
|
413
|
+
) : (
|
|
414
|
+
<button className="btn" type="button" onClick={loadMore} disabled={isLoadingMore}>
|
|
415
|
+
{isLoadingMore ? 'Loading…' : 'Load more'}
|
|
416
|
+
</button>
|
|
417
|
+
)}
|
|
418
|
+
</div>
|
|
419
|
+
) : null}
|
|
420
|
+
</main>
|
|
421
|
+
)
|
|
422
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
2
|
+
import { SoulDetailPage } from '../../components/SoulDetailPage'
|
|
3
|
+
import { buildSoulMeta, fetchSoulMeta } from '../../lib/og'
|
|
4
|
+
|
|
5
|
+
export const Route = createFileRoute('/souls/$slug')({
|
|
6
|
+
loader: async ({ params }) => {
|
|
7
|
+
const data = await fetchSoulMeta(params.slug)
|
|
8
|
+
return {
|
|
9
|
+
owner: data?.owner ?? null,
|
|
10
|
+
displayName: data?.displayName ?? null,
|
|
11
|
+
summary: data?.summary ?? null,
|
|
12
|
+
version: data?.version ?? null,
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
head: ({ params, loaderData }) => {
|
|
16
|
+
const meta = buildSoulMeta({
|
|
17
|
+
slug: params.slug,
|
|
18
|
+
owner: loaderData?.owner ?? null,
|
|
19
|
+
displayName: loaderData?.displayName,
|
|
20
|
+
summary: loaderData?.summary,
|
|
21
|
+
version: loaderData?.version ?? null,
|
|
22
|
+
})
|
|
23
|
+
return {
|
|
24
|
+
links: [
|
|
25
|
+
{
|
|
26
|
+
rel: 'canonical',
|
|
27
|
+
href: meta.url,
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
meta: [
|
|
31
|
+
{ title: meta.title },
|
|
32
|
+
{ name: 'description', content: meta.description },
|
|
33
|
+
{ property: 'og:title', content: meta.title },
|
|
34
|
+
{ property: 'og:description', content: meta.description },
|
|
35
|
+
{ property: 'og:type', content: 'website' },
|
|
36
|
+
{ property: 'og:url', content: meta.url },
|
|
37
|
+
{ property: 'og:image', content: meta.image },
|
|
38
|
+
{ property: 'og:image:width', content: '1200' },
|
|
39
|
+
{ property: 'og:image:height', content: '630' },
|
|
40
|
+
{ property: 'og:image:alt', content: meta.title },
|
|
41
|
+
{ name: 'twitter:card', content: 'summary_large_image' },
|
|
42
|
+
{ name: 'twitter:title', content: meta.title },
|
|
43
|
+
{ name: 'twitter:description', content: meta.description },
|
|
44
|
+
{ name: 'twitter:image', content: meta.image },
|
|
45
|
+
{ name: 'twitter:image:alt', content: meta.title },
|
|
46
|
+
],
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
component: SoulDetail,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
function SoulDetail() {
|
|
53
|
+
const { slug } = Route.useParams()
|
|
54
|
+
return <SoulDetailPage slug={slug} />
|
|
55
|
+
}
|