feat: bootstrap clawdhub

This commit is contained in:
Peter Steinberger
2026-01-03 18:14:33 +01:00
parent fa4859bc88
commit 8df19780ea
56 changed files with 4794 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
# Frontend
VITE_CONVEX_URL=
VITE_CONVEX_SITE_URL=
SITE_URL=http://localhost:3000
CONVEX_SITE_URL=
# Convex Auth (GitHub OAuth App)
AUTH_GITHUB_ID=
AUTH_GITHUB_SECRET=
# Convex Auth JWT keys (generated via @convex-dev/auth CLI)
JWT_PRIVATE_KEY=
JWKS=
# Embeddings
OPENAI_API_KEY=
+15
View File
@@ -0,0 +1,15 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
.vercel
convex/_generated
count.txt
.env
.nitro
.tanstack
.wrangler
.output
.vinxi
todos.json
+10
View File
@@ -0,0 +1,10 @@
{
"ignorePatterns": [
"node_modules",
"dist",
"coverage",
"convex/_generated",
".tanstack",
"public"
]
}
+45
View File
@@ -0,0 +1,45 @@
# ClawdHub
Minimal skill registry powered by TanStack Start + Convex.
## Quick start
```bash
bun install
cp .env.local.example .env.local
bun --bun run dev
```
In another terminal:
```bash
bunx convex dev
```
## Convex Auth setup
```bash
bunx auth --deployment-name <deployment> --web-server-url http://localhost:3000
```
This writes `JWT_PRIVATE_KEY` + `JWKS` to the deployment and prints the values for local `.env.local`.
## Environment
- `VITE_CONVEX_URL`: Convex deployment URL (`https://<deployment>.convex.cloud`).
- `VITE_CONVEX_SITE_URL`: Convex site URL (`https://<deployment>.convex.site`).
- `CONVEX_SITE_URL`: same as `VITE_CONVEX_SITE_URL` (auth + cookies).
- `SITE_URL`: App URL (local: `http://localhost:3000`).
- `AUTH_GITHUB_ID` / `AUTH_GITHUB_SECRET`: GitHub OAuth App.
- `JWT_PRIVATE_KEY` / `JWKS`: Convex Auth keys.
- `OPENAI_API_KEY`: embeddings.
## Scripts
```bash
bun run dev
bun run build
bun run test
bun run coverage
bun run lint
```
+28
View File
@@ -0,0 +1,28 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.0/schema.json",
"files": {
"ignore": ["node_modules", "dist", "coverage", "convex/_generated", ".tanstack", "public"]
},
"organizeImports": {
"enabled": true
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "asNeeded",
"trailingCommas": "all"
}
}
}
+1253
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
{
"functions": "convex"
}
+8
View File
@@ -0,0 +1,8 @@
export default {
providers: [
{
domain: process.env.CONVEX_SITE_URL,
applicationID: 'convex',
},
],
}
+19
View File
@@ -0,0 +1,19 @@
import GitHub from '@auth/core/providers/github'
import { convexAuth } from '@convex-dev/auth/server'
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
providers: [
GitHub({
clientId: process.env.AUTH_GITHUB_ID ?? '',
clientSecret: process.env.AUTH_GITHUB_SECRET ?? '',
profile(profile) {
return {
id: String(profile.id),
name: profile.login,
email: profile.email ?? undefined,
image: profile.avatar_url,
}
},
}),
],
})
+86
View File
@@ -0,0 +1,86 @@
import { v } from 'convex/values'
import { mutation, query } from './_generated/server'
import { assertRole, requireUser } from './lib/access'
export const listBySkill = query({
args: { skillId: v.id('skills'), limit: v.optional(v.number()) },
handler: async (ctx, args) => {
const limit = args.limit ?? 50
const comments = await ctx.db
.query('comments')
.withIndex('by_skill', (q) => q.eq('skillId', args.skillId))
.order('desc')
.take(limit)
const results = [] as Array<{ comment: any; user: any }>
for (const comment of comments) {
if (comment.softDeletedAt) continue
const user = await ctx.db.get(comment.userId)
results.push({ comment, user })
}
return results
},
})
export const add = mutation({
args: { skillId: v.id('skills'), body: v.string() },
handler: async (ctx, args) => {
const { userId } = await requireUser(ctx)
const body = args.body.trim()
if (!body) throw new Error('Comment body required')
const skill = await ctx.db.get(args.skillId)
if (!skill) throw new Error('Skill not found')
await ctx.db.insert('comments', {
skillId: args.skillId,
userId,
body,
createdAt: Date.now(),
softDeletedAt: undefined,
deletedBy: undefined,
})
await ctx.db.patch(skill._id, {
stats: { ...skill.stats, comments: skill.stats.comments + 1 },
updatedAt: Date.now(),
})
},
})
export const remove = mutation({
args: { commentId: v.id('comments') },
handler: async (ctx, args) => {
const { user } = await requireUser(ctx)
const comment = await ctx.db.get(args.commentId)
if (!comment) throw new Error('Comment not found')
if (comment.softDeletedAt) return
const isOwner = comment.userId === user._id
if (!isOwner) {
assertRole(user, ['admin', 'moderator'])
}
await ctx.db.patch(comment._id, {
softDeletedAt: Date.now(),
deletedBy: user._id,
})
const skill = await ctx.db.get(comment.skillId)
if (skill) {
await ctx.db.patch(skill._id, {
stats: { ...skill.stats, comments: Math.max(0, skill.stats.comments - 1) },
updatedAt: Date.now(),
})
}
await ctx.db.insert('auditLogs', {
actorUserId: user._id,
action: 'comment.delete',
targetType: 'comment',
targetId: comment._id,
metadata: { skillId: comment.skillId },
createdAt: Date.now(),
})
},
})
+75
View File
@@ -0,0 +1,75 @@
import { httpAction, mutation } from './_generated/server'
import { api } from './_generated/api'
import { zipSync } from 'fflate'
import { v } from 'convex/values'
export const downloadZip = httpAction(async (ctx, request) => {
const url = new URL(request.url)
const slug = url.searchParams.get('slug')?.trim().toLowerCase()
const versionParam = url.searchParams.get('version')?.trim()
const tagParam = url.searchParams.get('tag')?.trim()
if (!slug) {
return new Response('Missing slug', { status: 400 })
}
const skillResult = await ctx.runQuery(api.skills.getBySlug, { slug })
if (!skillResult?.skill) {
return new Response('Skill not found', { status: 404 })
}
const skill = skillResult.skill
let version = skillResult.latestVersion
if (versionParam) {
version = await ctx.runQuery(api.skills.getVersionBySkillAndVersion, {
skillId: skill._id,
version: versionParam,
})
} else if (tagParam) {
const versionId = skill.tags[tagParam]
if (versionId) {
version = await ctx.runQuery(api.skills.getVersionById, { versionId })
}
}
if (!version) {
return new Response('Version not found', { status: 404 })
}
if (version.softDeletedAt) {
return new Response('Version not available', { status: 410 })
}
const files: Record<string, Uint8Array> = {}
for (const file of version.files) {
const blob = await ctx.storage.get(file.storageId)
if (!blob) continue
const buffer = new Uint8Array(await blob.arrayBuffer())
files[file.path] = buffer
}
const zipData = zipSync(files, { level: 6 })
await ctx.runMutation(api.downloads.increment, { skillId: skill._id })
return new Response(zipData, {
status: 200,
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${slug}-${version.version}.zip"`,
'Cache-Control': 'private, max-age=60',
},
})
})
export const increment = mutation({
args: { skillId: v.id('skills') },
handler: async (ctx, args) => {
const skill = await ctx.db.get(args.skillId)
if (!skill) return
await ctx.db.patch(skill._id, {
stats: { ...skill.stats, downloads: skill.stats.downloads + 1 },
updatedAt: Date.now(),
})
},
})
+15
View File
@@ -0,0 +1,15 @@
import { httpRouter } from 'convex/server'
import { auth } from './auth'
import { downloadZip } from './downloads'
const http = httpRouter()
auth.addHttpRoutes(http)
http.route({
path: '/api/download',
method: 'GET',
handler: downloadZip,
})
export default http
+28
View File
@@ -0,0 +1,28 @@
import { getAuthUserId } from '@convex-dev/auth/server'
import type { ActionCtx, MutationCtx, QueryCtx } from '../_generated/server'
import type { Doc } from '../_generated/dataModel'
import { api } from '../_generated/api'
export type Role = 'admin' | 'moderator' | 'user'
export async function requireUser(ctx: MutationCtx | QueryCtx) {
const userId = await getAuthUserId(ctx)
if (!userId) throw new Error('Unauthorized')
const user = await ctx.db.get(userId)
if (!user || user.deletedAt) throw new Error('User not found')
return { userId, user }
}
export async function requireUserFromAction(ctx: ActionCtx) {
const userId = await getAuthUserId(ctx)
if (!userId) throw new Error('Unauthorized')
const user = await ctx.runQuery(api.users.getById, { userId })
if (!user || user.deletedAt) throw new Error('User not found')
return { userId, user: user as Doc<'users'> }
}
export function assertRole(user: Doc<'users'>, allowed: Role[]) {
if (!user.role || !allowed.includes(user.role as Role)) {
throw new Error('Forbidden')
}
}
+31
View File
@@ -0,0 +1,31 @@
export const EMBEDDING_MODEL = 'text-embedding-3-small'
export const EMBEDDING_DIMENSIONS = 1536
export async function generateEmbedding(text: string) {
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey) throw new Error('OPENAI_API_KEY is not configured')
const response = await fetch('https://api.openai.com/v1/embeddings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: EMBEDDING_MODEL,
input: text,
}),
})
if (!response.ok) {
const message = await response.text()
throw new Error(`Embedding failed: ${message}`)
}
const payload = (await response.json()) as {
data?: Array<{ embedding: number[] }>
}
const embedding = payload.data?.[0]?.embedding
if (!embedding) throw new Error('Embedding missing from response')
return embedding
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import {
buildEmbeddingText,
isTextFile,
parseClawdisMetadata,
parseFrontmatter,
sanitizePath,
} from './skills'
describe('skills utils', () => {
it('parses frontmatter', () => {
const frontmatter = parseFrontmatter(`---\nname: demo\ndescription: Hello\n---\nBody`)
expect(frontmatter.name).toBe('demo')
expect(frontmatter.description).toBe('Hello')
})
it('parses clawdis metadata', () => {
const frontmatter = parseFrontmatter(
`---\nmetadata: {"clawdis":{"requires":{"bins":["rg"]},"emoji":"🦞"}}\n---\nBody`,
)
const clawdis = parseClawdisMetadata(frontmatter)
expect(clawdis?.emoji).toBe('🦞')
expect(clawdis?.requires?.bins).toEqual(['rg'])
})
it('sanitizes file paths', () => {
expect(sanitizePath('good/file.md')).toBe('good/file.md')
expect(sanitizePath('../bad/file.md')).toBeNull()
expect(sanitizePath('')).toBeNull()
})
it('detects text files', () => {
expect(isTextFile('SKILL.md')).toBe(true)
expect(isTextFile('image.png')).toBe(false)
expect(isTextFile('note.txt', 'text/plain')).toBe(true)
})
it('builds embedding text', () => {
const frontmatter = { name: 'Demo', description: 'Hello' }
const text = buildEmbeddingText({
frontmatter,
readme: 'Readme body',
otherFiles: [{ path: 'a.txt', content: 'File text' }],
})
expect(text).toContain('Demo')
expect(text).toContain('Readme body')
expect(text).toContain('a.txt')
})
})
+235
View File
@@ -0,0 +1,235 @@
export type ParsedSkillFrontmatter = Record<string, string>
export type SkillInstallSpec = {
id?: string
kind: 'brew' | 'node' | 'go' | 'uv'
label?: string
bins?: string[]
formula?: string
package?: string
module?: string
}
export type ClawdisSkillMetadata = {
always?: boolean
skillKey?: string
primaryEnv?: string
emoji?: string
homepage?: string
os?: string[]
requires?: {
bins?: string[]
anyBins?: string[]
env?: string[]
config?: string[]
}
install?: SkillInstallSpec[]
}
const FRONTMATTER_START = '---'
const TEXT_EXTENSIONS = new Set([
'md',
'mdx',
'txt',
'json',
'json5',
'yaml',
'yml',
'toml',
'js',
'cjs',
'mjs',
'ts',
'tsx',
'jsx',
'py',
'sh',
'rb',
'go',
'rs',
'swift',
'kt',
'java',
'cs',
'cpp',
'c',
'h',
'hpp',
'sql',
'csv',
'ini',
'cfg',
'env',
'xml',
'html',
'css',
'scss',
'sass',
])
export function parseFrontmatter(content: string): ParsedSkillFrontmatter {
const frontmatter: ParsedSkillFrontmatter = {}
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
if (!normalized.startsWith(FRONTMATTER_START)) return frontmatter
const endIndex = normalized.indexOf(`\n${FRONTMATTER_START}`, 3)
if (endIndex === -1) return frontmatter
const block = normalized.slice(4, endIndex)
for (const line of block.split('\n')) {
const match = line.match(/^([\w-]+):\s*(.*)$/)
if (!match) continue
const key = match[1]
const rawValue = match[2].trim()
if (!key || !rawValue) continue
frontmatter[key] = stripQuotes(rawValue)
}
return frontmatter
}
export function getFrontmatterValue(frontmatter: ParsedSkillFrontmatter, key: string) {
const raw = frontmatter[key]
return typeof raw === 'string' ? raw : undefined
}
export function parseClawdisMetadata(frontmatter: ParsedSkillFrontmatter) {
const raw = getFrontmatterValue(frontmatter, 'metadata')
if (!raw) return undefined
try {
const parsed = JSON.parse(raw) as { clawdis?: unknown }
if (!parsed || typeof parsed !== 'object') return undefined
const clawdis = (parsed as { clawdis?: unknown }).clawdis
if (!clawdis || typeof clawdis !== 'object') return undefined
const clawdisObj = clawdis as Record<string, unknown>
const requiresRaw =
typeof clawdisObj.requires === 'object' && clawdisObj.requires !== null
? (clawdisObj.requires as Record<string, unknown>)
: undefined
const installRaw = Array.isArray(clawdisObj.install)
? (clawdisObj.install as unknown[])
: []
const install = installRaw
.map((entry) => parseInstallSpec(entry))
.filter((entry): entry is SkillInstallSpec => Boolean(entry))
const osRaw = normalizeStringList(clawdisObj.os)
const metadata: ClawdisSkillMetadata = {
always: typeof clawdisObj.always === 'boolean' ? clawdisObj.always : undefined,
emoji: typeof clawdisObj.emoji === 'string' ? clawdisObj.emoji : undefined,
homepage: typeof clawdisObj.homepage === 'string' ? clawdisObj.homepage : undefined,
skillKey: typeof clawdisObj.skillKey === 'string' ? clawdisObj.skillKey : undefined,
primaryEnv:
typeof clawdisObj.primaryEnv === 'string' ? clawdisObj.primaryEnv : undefined,
os: osRaw.length > 0 ? osRaw : undefined,
requires: requiresRaw
? {
bins: normalizeStringList(requiresRaw.bins),
anyBins: normalizeStringList(requiresRaw.anyBins),
env: normalizeStringList(requiresRaw.env),
config: normalizeStringList(requiresRaw.config),
}
: undefined,
install: install.length > 0 ? install : undefined,
}
return metadata
} catch {
return undefined
}
}
export function isTextFile(path: string, contentType?: string | null) {
const trimmed = path.trim().toLowerCase()
if (!trimmed) return false
const parts = trimmed.split('.')
const extension = parts.length > 1 ? parts.at(-1) ?? '' : ''
if (contentType) {
if (contentType.startsWith('text/')) return true
if (
[
'application/json',
'application/xml',
'application/yaml',
'application/x-yaml',
'application/toml',
'application/javascript',
'application/typescript',
'application/markdown',
].includes(contentType)
) {
return true
}
}
if (extension && TEXT_EXTENSIONS.has(extension)) return true
return false
}
export function sanitizePath(path: string) {
const trimmed = path.trim().replace(/^\/+/, '')
if (!trimmed || trimmed.includes('..') || trimmed.includes('\\')) {
return null
}
return trimmed
}
export function buildEmbeddingText(params: {
frontmatter: ParsedSkillFrontmatter
readme: string
otherFiles: Array<{ path: string; content: string }>
maxChars?: number
}) {
const { frontmatter, readme, otherFiles, maxChars = 200_000 } = params
const headerParts = [
frontmatter.name,
frontmatter.description,
frontmatter.homepage,
frontmatter.website,
frontmatter.url,
frontmatter.emoji,
].filter(Boolean)
const fileParts = otherFiles.map((file) => `# ${file.path}\n${file.content}`)
const raw = [headerParts.join('\n'), readme, ...fileParts].filter(Boolean).join('\n\n')
if (raw.length <= maxChars) return raw
return raw.slice(0, maxChars)
}
function stripQuotes(value: string) {
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
return value.slice(1, -1)
}
return value
}
function normalizeStringList(input: unknown): string[] {
if (!input) return []
if (Array.isArray(input)) {
return input.map((value) => String(value).trim()).filter(Boolean)
}
if (typeof input === 'string') {
return input
.split(',')
.map((value) => value.trim())
.filter(Boolean)
}
return []
}
function parseInstallSpec(input: unknown): SkillInstallSpec | undefined {
if (!input || typeof input !== 'object') return undefined
const raw = input as Record<string, unknown>
const kindRaw = typeof raw.kind === 'string' ? raw.kind : typeof raw.type === 'string' ? raw.type : ''
const kind = kindRaw.trim().toLowerCase()
if (kind !== 'brew' && kind !== 'node' && kind !== 'go' && kind !== 'uv') return undefined
const spec: SkillInstallSpec = { kind: kind as SkillInstallSpec['kind'] }
if (typeof raw.id === 'string') spec.id = raw.id
if (typeof raw.label === 'string') spec.label = raw.label
const bins = normalizeStringList(raw.bins)
if (bins.length > 0) spec.bins = bins
if (typeof raw.formula === 'string') spec.formula = raw.formula
if (typeof raw.package === 'string') spec.package = raw.package
if (typeof raw.module === 'string') spec.module = raw.module
return spec
}
+139
View File
@@ -0,0 +1,139 @@
import { defineSchema, defineTable } from 'convex/server'
import { v } from 'convex/values'
import { authTables } from '@convex-dev/auth/server'
import { EMBEDDING_DIMENSIONS } from './lib/embeddings'
const users = defineTable({
name: v.optional(v.string()),
image: v.optional(v.string()),
email: v.optional(v.string()),
emailVerificationTime: v.optional(v.number()),
phone: v.optional(v.string()),
phoneVerificationTime: v.optional(v.number()),
isAnonymous: v.optional(v.boolean()),
handle: v.optional(v.string()),
displayName: v.optional(v.string()),
bio: v.optional(v.string()),
role: v.optional(v.union(v.literal('admin'), v.literal('moderator'), v.literal('user'))),
deletedAt: v.optional(v.number()),
createdAt: v.optional(v.number()),
updatedAt: v.optional(v.number()),
})
.index('email', ['email'])
.index('phone', ['phone'])
.index('handle', ['handle'])
const skills = defineTable({
slug: v.string(),
displayName: v.string(),
summary: v.optional(v.string()),
ownerUserId: v.id('users'),
latestVersionId: v.optional(v.id('skillVersions')),
tags: v.record(v.string(), v.id('skillVersions')),
badges: v.object({
redactionApproved: v.optional(
v.object({
byUserId: v.id('users'),
at: v.number(),
}),
),
}),
batch: v.optional(v.string()),
stats: v.object({
downloads: v.number(),
stars: v.number(),
versions: v.number(),
comments: v.number(),
}),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_slug', ['slug'])
.index('by_owner', ['ownerUserId'])
.index('by_updated', ['updatedAt'])
.index('by_batch', ['batch'])
const skillVersions = defineTable({
skillId: v.id('skills'),
version: v.string(),
changelog: v.string(),
files: v.array(
v.object({
path: v.string(),
size: v.number(),
storageId: v.id('_storage'),
sha256: v.string(),
contentType: v.optional(v.string()),
}),
),
parsed: v.object({
frontmatter: v.record(v.string(), v.string()),
metadata: v.optional(v.any()),
clawdis: v.optional(v.any()),
}),
createdBy: v.id('users'),
createdAt: v.number(),
softDeletedAt: v.optional(v.number()),
})
.index('by_skill', ['skillId'])
.index('by_skill_version', ['skillId', 'version'])
const skillEmbeddings = defineTable({
skillId: v.id('skills'),
versionId: v.id('skillVersions'),
ownerId: v.id('users'),
embedding: v.array(v.number()),
isLatest: v.boolean(),
isApproved: v.boolean(),
visibility: v.string(),
updatedAt: v.number(),
})
.index('by_skill', ['skillId'])
.index('by_version', ['versionId'])
.vectorIndex('by_embedding', {
vectorField: 'embedding',
dimensions: EMBEDDING_DIMENSIONS,
filterFields: ['visibility'],
})
const comments = defineTable({
skillId: v.id('skills'),
userId: v.id('users'),
body: v.string(),
createdAt: v.number(),
softDeletedAt: v.optional(v.number()),
deletedBy: v.optional(v.id('users')),
})
.index('by_skill', ['skillId'])
.index('by_user', ['userId'])
const stars = defineTable({
skillId: v.id('skills'),
userId: v.id('users'),
createdAt: v.number(),
})
.index('by_skill', ['skillId'])
.index('by_user', ['userId'])
.index('by_skill_user', ['skillId', 'userId'])
const auditLogs = defineTable({
actorUserId: v.id('users'),
action: v.string(),
targetType: v.string(),
targetId: v.string(),
metadata: v.optional(v.any()),
createdAt: v.number(),
})
.index('by_actor', ['actorUserId'])
.index('by_target', ['targetType', 'targetId'])
export default defineSchema({
...authTables,
users,
skills,
skillVersions,
skillEmbeddings,
comments,
stars,
auditLogs,
})
+59
View File
@@ -0,0 +1,59 @@
import { v, type Id } from 'convex/values'
import { action, query } from './_generated/server'
import { api } from './_generated/api'
import { generateEmbedding } from './lib/embeddings'
export const searchSkills = action({
args: {
query: v.string(),
limit: v.optional(v.number()),
approvedOnly: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const query = args.query.trim()
if (!query) return []
const vector = await generateEmbedding(query)
const results = await ctx.vectorSearch('skillEmbeddings', 'by_embedding', {
vector,
limit: args.limit ?? 10,
filter: (q) =>
args.approvedOnly
? q.eq('visibility', 'latest-approved')
: q.or(q.eq('visibility', 'latest'), q.eq('visibility', 'latest-approved')),
})
const hydrated = await ctx.runQuery(api.search.hydrateResults, {
embeddingIds: results.map((result) => result._id),
})
const scoreById = new Map(results.map((result) => [result._id, result._score]))
return hydrated
.map((entry) => ({
...entry,
score: scoreById.get(entry.embeddingId) ?? 0,
}))
.filter((entry) => entry.skill)
},
})
export const hydrateResults = query({
args: { embeddingIds: v.array(v.id('skillEmbeddings')) },
handler: async (ctx, args) => {
const entries = [] as Array<{
embeddingId: Id<'skillEmbeddings'>
skill: unknown
version: unknown
}>
for (const embeddingId of args.embeddingIds) {
const embedding = await ctx.db.get(embeddingId)
if (!embedding) continue
const skill = await ctx.db.get(embedding.skillId)
const version = await ctx.db.get(embedding.versionId)
entries.push({ embeddingId, skill, version })
}
return entries
},
})
+455
View File
@@ -0,0 +1,455 @@
import { v, type Id } from 'convex/values'
import { action, internalMutation, mutation, query } from './_generated/server'
import { internal } from './_generated/api'
import { requireUser, requireUserFromAction, assertRole } from './lib/access'
import { generateEmbedding } from './lib/embeddings'
import {
buildEmbeddingText,
getFrontmatterValue,
isTextFile,
parseClawdisMetadata,
parseFrontmatter,
sanitizePath,
} from './lib/skills'
import semver from 'semver'
const MAX_TOTAL_BYTES = 50 * 1024 * 1024
const MAX_FILES_FOR_EMBEDDING = 40
export const getBySlug = query({
args: { slug: v.string() },
handler: async (ctx, args) => {
const skill = await ctx.db
.query('skills')
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
.unique()
if (!skill) return null
const latestVersion = skill.latestVersionId
? await ctx.db.get(skill.latestVersionId)
: null
const owner = await ctx.db.get(skill.ownerUserId)
return { skill, latestVersion, owner }
},
})
export const list = query({
args: {
batch: v.optional(v.string()),
ownerUserId: v.optional(v.id('users')),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const limit = args.limit ?? 24
if (args.batch) {
return ctx.db
.query('skills')
.withIndex('by_batch', (q) => q.eq('batch', args.batch))
.order('desc')
.take(limit)
}
if (args.ownerUserId) {
return ctx.db
.query('skills')
.withIndex('by_owner', (q) => q.eq('ownerUserId', args.ownerUserId))
.order('desc')
.take(limit)
}
return ctx.db.query('skills').order('desc').take(limit)
},
})
export const listVersions = query({
args: { skillId: v.id('skills'), limit: v.optional(v.number()) },
handler: async (ctx, args) => {
const limit = args.limit ?? 20
return ctx.db
.query('skillVersions')
.withIndex('by_skill', (q) => q.eq('skillId', args.skillId))
.order('desc')
.take(limit)
},
})
export const getVersionById = query({
args: { versionId: v.id('skillVersions') },
handler: async (ctx, args) => ctx.db.get(args.versionId),
})
export const getVersionBySkillAndVersion = query({
args: { skillId: v.id('skills'), version: v.string() },
handler: async (ctx, args) => {
return ctx.db
.query('skillVersions')
.withIndex('by_skill_version', (q) => q.eq('skillId', args.skillId).eq('version', args.version))
.unique()
},
})
export const publishVersion = action({
args: {
slug: v.string(),
displayName: v.string(),
version: v.string(),
changelog: v.string(),
tags: v.optional(v.array(v.string())),
files: v.array(
v.object({
path: v.string(),
size: v.number(),
storageId: v.id('_storage'),
sha256: v.string(),
contentType: v.optional(v.string()),
}),
),
},
handler: async (ctx, args) => {
await requireUserFromAction(ctx)
const version = args.version.trim()
const slug = args.slug.trim().toLowerCase()
const displayName = args.displayName.trim()
if (!slug || !displayName) throw new Error('Slug and display name required')
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
throw new Error('Slug must be lowercase and url-safe')
}
if (!semver.valid(version)) {
throw new Error('Version must be valid semver')
}
if (!args.changelog.trim()) {
throw new Error('Changelog is required')
}
const sanitizedFiles = args.files.map((file) => ({
...file,
path: sanitizePath(file.path),
}))
if (sanitizedFiles.some((file) => !file.path)) {
throw new Error('Invalid file paths')
}
if (
sanitizedFiles.some(
(file) => !isTextFile(file.path ?? '', file.contentType ?? undefined),
)
) {
throw new Error('Only text-based files are allowed')
}
const totalBytes = sanitizedFiles.reduce((sum, file) => sum + file.size, 0)
if (totalBytes > MAX_TOTAL_BYTES) {
throw new Error('Skill bundle exceeds 50MB limit')
}
const readmeFile = sanitizedFiles.find(
(file) => file.path?.toLowerCase() === 'skill.md' || file.path?.toLowerCase() === 'skills.md',
)
if (!readmeFile) throw new Error('SKILL.md is required')
const readmeText = await fetchText(ctx, readmeFile.storageId)
const frontmatter = parseFrontmatter(readmeText)
const clawdis = parseClawdisMetadata(frontmatter)
const metadataRaw = getFrontmatterValue(frontmatter, 'metadata')
const metadata = metadataRaw ? safeJson(metadataRaw) : undefined
const otherFiles = [] as Array<{ path: string; content: string }>
for (const file of sanitizedFiles) {
if (!file.path || file.path.toLowerCase().endsWith('.md')) continue
if (!isTextFile(file.path, file.contentType ?? undefined)) continue
const content = await fetchText(ctx, file.storageId)
otherFiles.push({ path: file.path, content })
if (otherFiles.length >= MAX_FILES_FOR_EMBEDDING) break
}
const embeddingText = buildEmbeddingText({
frontmatter,
readme: readmeText,
otherFiles,
})
const embedding = await generateEmbedding(embeddingText)
return ctx.runMutation(internal.skills.insertVersion, {
slug,
displayName,
version,
changelog: args.changelog.trim(),
tags: args.tags?.map((tag) => tag.trim()).filter(Boolean),
files: sanitizedFiles.map((file) => ({
...file,
path: file.path ?? '',
})),
parsed: {
frontmatter,
metadata,
clawdis,
},
embedding,
})
},
})
export const getReadme = action({
args: { versionId: v.id('skillVersions') },
handler: async (ctx, args) => {
const version = await ctx.runQuery(api.skills.getVersionById, {
versionId: args.versionId,
})
if (!version) throw new Error('Version not found')
const readmeFile = version.files.find(
(file) => file.path.toLowerCase() === 'skill.md' || file.path.toLowerCase() === 'skills.md',
)
if (!readmeFile) throw new Error('SKILL.md not found')
const text = await fetchText(ctx, readmeFile.storageId)
return { path: readmeFile.path, text }
},
})
export const updateTags = mutation({
args: {
skillId: v.id('skills'),
tags: v.array(v.object({ tag: v.string(), versionId: v.id('skillVersions') })),
},
handler: async (ctx, args) => {
const { user } = await requireUser(ctx)
const skill = await ctx.db.get(args.skillId)
if (!skill) throw new Error('Skill not found')
if (skill.ownerUserId !== user._id) {
assertRole(user, ['admin', 'moderator'])
}
const nextTags = { ...skill.tags }
for (const entry of args.tags) {
nextTags[entry.tag] = entry.versionId
}
const latestEntry = args.tags.find((entry) => entry.tag === 'latest')
await ctx.db.patch(skill._id, {
tags: nextTags,
latestVersionId: latestEntry ? latestEntry.versionId : skill.latestVersionId,
updatedAt: Date.now(),
})
if (latestEntry) {
const embeddings = await ctx.db
.query('skillEmbeddings')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.collect()
for (const embedding of embeddings) {
const isLatest = embedding.versionId === latestEntry.versionId
await ctx.db.patch(embedding._id, {
isLatest,
visibility: visibilityFor(isLatest, embedding.isApproved),
updatedAt: Date.now(),
})
}
}
},
})
export const setRedactionApproved = mutation({
args: { skillId: v.id('skills'), approved: v.boolean() },
handler: async (ctx, args) => {
const { user } = await requireUser(ctx)
assertRole(user, ['admin', 'moderator'])
const skill = await ctx.db.get(args.skillId)
if (!skill) throw new Error('Skill not found')
const badge = args.approved
? { byUserId: user._id, at: Date.now() }
: undefined
await ctx.db.patch(skill._id, {
badges: { ...skill.badges, redactionApproved: badge },
updatedAt: Date.now(),
})
const embeddings = await ctx.db
.query('skillEmbeddings')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.collect()
for (const embedding of embeddings) {
await ctx.db.patch(embedding._id, {
isApproved: Boolean(badge),
visibility: visibilityFor(embedding.isLatest, Boolean(badge)),
updatedAt: Date.now(),
})
}
await ctx.db.insert('auditLogs', {
actorUserId: user._id,
action: args.approved ? 'badge.set' : 'badge.unset',
targetType: 'skill',
targetId: skill._id,
metadata: { badge: 'redactionApproved', approved: args.approved },
createdAt: Date.now(),
})
},
})
export const setBatch = mutation({
args: { skillId: v.id('skills'), batch: v.optional(v.string()) },
handler: async (ctx, args) => {
const { user } = await requireUser(ctx)
assertRole(user, ['admin', 'moderator'])
const skill = await ctx.db.get(args.skillId)
if (!skill) throw new Error('Skill not found')
await ctx.db.patch(skill._id, {
batch: args.batch?.trim() || undefined,
updatedAt: Date.now(),
})
await ctx.db.insert('auditLogs', {
actorUserId: user._id,
action: 'batch.set',
targetType: 'skill',
targetId: skill._id,
metadata: { batch: args.batch?.trim() ?? null },
createdAt: Date.now(),
})
},
})
export const insertVersion = internalMutation({
args: {
slug: v.string(),
displayName: v.string(),
version: v.string(),
changelog: v.string(),
tags: v.optional(v.array(v.string())),
files: v.array(
v.object({
path: v.string(),
size: v.number(),
storageId: v.id('_storage'),
sha256: v.string(),
contentType: v.optional(v.string()),
}),
),
parsed: v.object({
frontmatter: v.record(v.string(), v.string()),
metadata: v.optional(v.any()),
clawdis: v.optional(v.any()),
}),
embedding: v.array(v.number()),
},
handler: async (ctx, args) => {
const { userId, user } = await requireUser(ctx)
let skill = await ctx.db
.query('skills')
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
.unique()
if (skill && skill.ownerUserId !== userId) {
throw new Error('Only the owner can publish updates')
}
const now = Date.now()
if (!skill) {
const summary = getFrontmatterValue(args.parsed.frontmatter, 'description')
const skillId = await ctx.db.insert('skills', {
slug: args.slug,
displayName: args.displayName,
summary: summary ?? undefined,
ownerUserId: userId,
latestVersionId: undefined,
tags: {},
badges: { redactionApproved: undefined },
stats: { downloads: 0, stars: 0, versions: 0, comments: 0 },
createdAt: now,
updatedAt: now,
})
skill = await ctx.db.get(skillId)
}
if (!skill) throw new Error('Skill creation failed')
const existingVersion = await ctx.db
.query('skillVersions')
.withIndex('by_skill_version', (q) =>
q.eq('skillId', skill._id).eq('version', args.version),
)
.unique()
if (existingVersion) {
throw new Error('Version already exists')
}
const versionId = await ctx.db.insert('skillVersions', {
skillId: skill._id,
version: args.version,
changelog: args.changelog,
files: args.files,
parsed: args.parsed,
createdBy: userId,
createdAt: now,
softDeletedAt: undefined,
})
const nextTags: Record<string, Id<'skillVersions'>> = { ...skill.tags }
nextTags.latest = versionId
for (const tag of args.tags ?? []) {
nextTags[tag] = versionId
}
const latestBefore = skill.latestVersionId
await ctx.db.patch(skill._id, {
displayName: args.displayName,
summary: getFrontmatterValue(args.parsed.frontmatter, 'description') ?? skill.summary,
latestVersionId: versionId,
tags: nextTags,
stats: { ...skill.stats, versions: skill.stats.versions + 1 },
updatedAt: now,
})
const embeddingId = await ctx.db.insert('skillEmbeddings', {
skillId: skill._id,
versionId,
ownerId: userId,
embedding: args.embedding,
isLatest: true,
isApproved: Boolean(skill.badges.redactionApproved),
visibility: visibilityFor(true, Boolean(skill.badges.redactionApproved)),
updatedAt: now,
})
if (latestBefore) {
const previousEmbedding = await ctx.db
.query('skillEmbeddings')
.withIndex('by_version', (q) => q.eq('versionId', latestBefore))
.unique()
if (previousEmbedding) {
await ctx.db.patch(previousEmbedding._id, {
isLatest: false,
visibility: visibilityFor(false, previousEmbedding.isApproved),
updatedAt: now,
})
}
}
return { skillId: skill._id, versionId, embeddingId }
},
})
async function fetchText(
ctx: { storage: { get: (id: Id<'_storage'>) => Promise<Blob | null> } },
storageId: Id<'_storage'>,
) {
const blob = await ctx.storage.get(storageId)
if (!blob) throw new Error('File missing in storage')
return blob.text()
}
function safeJson(value: string) {
try {
return JSON.parse(value)
} catch {
return undefined
}
}
function visibilityFor(isLatest: boolean, isApproved: boolean) {
if (isLatest && isApproved) return 'latest-approved'
if (isLatest) return 'latest'
if (isApproved) return 'archived-approved'
return 'archived'
}
+69
View File
@@ -0,0 +1,69 @@
import { v } from 'convex/values'
import { mutation, query } from './_generated/server'
import { requireUser } from './lib/access'
export const isStarred = query({
args: { skillId: v.id('skills') },
handler: async (ctx, args) => {
const { userId } = await requireUser(ctx)
const existing = await ctx.db
.query('stars')
.withIndex('by_skill_user', (q) => q.eq('skillId', args.skillId).eq('userId', userId))
.unique()
return Boolean(existing)
},
})
export const toggle = mutation({
args: { skillId: v.id('skills') },
handler: async (ctx, args) => {
const { userId } = await requireUser(ctx)
const skill = await ctx.db.get(args.skillId)
if (!skill) throw new Error('Skill not found')
const existing = await ctx.db
.query('stars')
.withIndex('by_skill_user', (q) => q.eq('skillId', args.skillId).eq('userId', userId))
.unique()
if (existing) {
await ctx.db.delete(existing._id)
await ctx.db.patch(skill._id, {
stats: { ...skill.stats, stars: Math.max(0, skill.stats.stars - 1) },
updatedAt: Date.now(),
})
return { starred: false }
}
await ctx.db.insert('stars', {
skillId: args.skillId,
userId,
createdAt: Date.now(),
})
await ctx.db.patch(skill._id, {
stats: { ...skill.stats, stars: skill.stats.stars + 1 },
updatedAt: Date.now(),
})
return { starred: true }
},
})
export const listByUser = query({
args: { userId: v.id('users'), limit: v.optional(v.number()) },
handler: async (ctx, args) => {
const limit = args.limit ?? 50
const stars = await ctx.db
.query('stars')
.withIndex('by_user', (q) => q.eq('userId', args.userId))
.order('desc')
.take(limit)
const skills = [] as any[]
for (const star of stars) {
const skill = await ctx.db.get(star.skillId)
if (skill) skills.push(skill)
}
return skills
},
})
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"moduleResolution": "Bundler",
"skipLibCheck": true
}
}
+10
View File
@@ -0,0 +1,10 @@
import { mutation } from './_generated/server'
import { requireUser } from './lib/access'
export const generateUploadUrl = mutation({
args: {},
handler: async (ctx) => {
await requireUser(ctx)
return ctx.storage.generateUploadUrl()
},
})
+110
View File
@@ -0,0 +1,110 @@
import { v } from 'convex/values'
import { mutation, query } from './_generated/server'
import { requireUser, assertRole } from './lib/access'
import { getAuthUserId } from '@convex-dev/auth/server'
const DEFAULT_ROLE = 'user'
const ADMIN_HANDLE = 'steipete'
export const getById = query({
args: { userId: v.id('users') },
handler: async (ctx, args) => ctx.db.get(args.userId),
})
export const me = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx)
if (!userId) return null
const user = await ctx.db.get(userId)
if (!user || user.deletedAt) return null
return user
},
})
export const ensure = mutation({
args: {},
handler: async (ctx) => {
const { userId, user } = await requireUser(ctx)
const now = Date.now()
const updates: Record<string, unknown> = {}
const handle = user.handle ?? user.name ?? user.email?.split('@')[0]
if (!user.handle && handle) updates.handle = handle
if (!user.displayName) updates.displayName = handle
if (!user.role) {
updates.role = handle === ADMIN_HANDLE ? 'admin' : DEFAULT_ROLE
}
if (!user.createdAt) updates.createdAt = user._creationTime
updates.updatedAt = now
if (Object.keys(updates).length > 0) {
await ctx.db.patch(userId, updates)
}
return ctx.db.get(userId)
},
})
export const updateProfile = mutation({
args: {
displayName: v.string(),
bio: v.optional(v.string()),
},
handler: async (ctx, args) => {
const { userId } = await requireUser(ctx)
await ctx.db.patch(userId, {
displayName: args.displayName.trim(),
bio: args.bio?.trim(),
updatedAt: Date.now(),
})
},
})
export const deleteAccount = mutation({
args: {},
handler: async (ctx) => {
const { userId } = await requireUser(ctx)
await ctx.db.patch(userId, {
deletedAt: Date.now(),
updatedAt: Date.now(),
})
},
})
export const list = query({
args: { limit: v.optional(v.number()) },
handler: async (ctx, args) => {
const { user } = await requireUser(ctx)
assertRole(user, ['admin'])
const limit = args.limit ?? 50
return ctx.db.query('users').order('desc').take(limit)
},
})
export const getByHandle = query({
args: { handle: v.string() },
handler: async (ctx, args) => {
return ctx.db
.query('users')
.withIndex('handle', (q) => q.eq('handle', args.handle))
.unique()
},
})
export const setRole = mutation({
args: { userId: v.id('users'), role: v.union(v.literal('admin'), v.literal('moderator'), v.literal('user')) },
handler: async (ctx, args) => {
const { user } = await requireUser(ctx)
assertRole(user, ['admin'])
await ctx.db.patch(args.userId, { role: args.role, updatedAt: Date.now() })
await ctx.db.insert('auditLogs', {
actorUserId: user._id,
action: 'role.change',
targetType: 'user',
targetId: args.userId,
metadata: { role: args.role },
createdAt: Date.now(),
})
},
})
+130
View File
@@ -0,0 +1,130 @@
---
summary: "ClawdHub spec: skills registry, versioning, vector search, moderation"
read_when:
- Bootstrapping ClawdHub
- Implementing schema/auth/search/versioning
- Reviewing API and upload/download flows
---
# ClawdHub — product + implementation spec (v1)
## Goals
- Minimal, fast SPA for browsing and publishing agent skills.
- Skills stored in Convex (files + metadata + versions + stats).
- GitHub OAuth login; optional GitHub App repo sync later.
- Vector-based search over skill text + metadata.
- Versioning, tags (`latest` + user tags), changelog, rollback (tag movement).
- Public read access; upload requires auth.
- Moderation: badges + comment delete; audit everything.
## Non-goals (v1)
- Paid features, private skills, or binary assets.
- GitHub App sync (future phase).
## Core objects
### User
- `authId` (from Convex Auth provider)
- `handle` (GitHub login)
- `name`, `bio`
- `avatarUrl` (GitHub, fallback gravatar)
- `role`: `admin | moderator | user`
- `createdAt`, `updatedAt`
### Skill
- `slug` (unique)
- `displayName`
- `ownerUserId`
- `summary` (from SKILL.md frontmatter `description`)
- `latestVersionId`
- `latestTagVersionId` (for `latest` tag)
- `tags` map: `{ tag -> versionId }`
- `badges`: `{ redactionApproved?: { byUserId, at } }`
- `stats`: `{ downloads, stars, versions, comments }`
- `status`: `active` only (soft-delete on version/comment only)
- `createdAt`, `updatedAt`
### SkillVersion
- `skillId`
- `version` (semver string)
- `tag` (string, optional; `latest` always maintained separately)
- `changelog` (required)
- `files`: list of file metadata
- `path`, `size`, `storageId`, `sha256`
- `parsed` (metadata extracted from SKILL.md)
- `vectorDocId` (if using RAG component) OR `embeddingId`
- `createdBy`, `createdAt`
- `softDeletedAt` (nullable)
### Parsed Skill Metadata
From SKILL.md frontmatter + AgentSkills + Clawdis extensions:
- `name`, `description`, `homepage`, `website`, `url`, `emoji`
- `metadata.clawdis`: `always`, `skillKey`, `primaryEnv`, `emoji`, `homepage`, `os`,
`requires` (`bins`, `anyBins`, `env`, `config`), `install[]`
### Comment
- `skillId`, `userId`, `body`
- `softDeletedAt`, `deletedBy`
- `createdAt`
### Star
- `skillId`, `userId`, `createdAt`
### AuditLog
- `actorUserId`
- `action` (enum: `badge.set`, `badge.unset`, `comment.delete`, `role.change`)
- `targetType` / `targetId`
- `metadata` (json)
- `createdAt`
## Auth + roles
- Convex Auth with GitHub OAuth App.
- Default role `user`; bootstrap `steipete` to `admin` on first login.
- Admin UI to promote/demote roles; all changes logged.
## Upload flow (50MB per version)
1) Client requests upload session.
2) Client uploads each file via Convex upload URLs (no binaries, text only).
3) Client submits metadata + file list + changelog + version + tags.
4) Server validates:
- total size ≤ 50MB
- file extensions/text content
- SKILL.md exists and frontmatter parseable
- version uniqueness
5) Server stores files + metadata, sets `latest` tag, updates stats.
## Versioning + tags
- Each upload is a new `SkillVersion`.
- `latest` tag always points to most recent version unless user re-tags.
- Rollback: move `latest` (and optionally other tags) to an older version.
- Changelog required for any update.
## Search
- Vector search over: SKILL.md + other text files + metadata summary.
- Convex embeddings + vector index.
- Filters: tag, owner, `redactionApproved` only, min stars, updatedAt.
## Download API
- JSON API for skill metadata + versions.
- Download endpoint returns zip of a version (HTTP action).
- Soft-delete versions; downloads remain for non-deleted versions only.
## UI (SPA)
- Home: search + filters + trending/featured + “Highlighted” batch.
- Skill detail: README render, files list, version history, tags, stats, badges.
- Upload/edit: file picker + version + tag + changelog.
- Account settings: name + delete account (soft delete).
- Admin: user role management + badge approvals + audit log.
## Testing + quality
- Vitest 4 with >80% coverage.
- Lint: Biome + Oxlint (type-aware).
## Vercel
- Env vars: Convex deployment URLs + GitHub OAuth client + OpenAI key (if used).
- SPA feel: client-side transitions, prefetching, optimistic UI.
## Open questions (carry forward)
- Embeddings provider key + rate limits.
- Zip generation memory limits (optimize with streaming if needed).
- GitHub App repo sync (phase 2).
+60
View File
@@ -0,0 +1,60 @@
{
"name": "clawdhub",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --bun vite dev --port 3000",
"build": "bun --bun vite build",
"preview": "bun --bun vite preview",
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage",
"lint": "bun run lint:biome && bun run lint:oxlint",
"lint:biome": "biome check .",
"lint:oxlint": "oxlint --type-aware --tsconfig ./tsconfig.json ./src ./convex",
"format": "biome format --write ."
},
"dependencies": {
"@auth/core": "^0.34.3",
"@convex-dev/auth": "^0.0.90",
"@fontsource/bricolage-grotesque": "^5.2.10",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/manrope": "^5.2.8",
"@tailwindcss/vite": "^4.0.6",
"@tanstack/react-devtools": "^0.7.0",
"@tanstack/react-router": "^1.132.0",
"@tanstack/react-router-devtools": "^1.132.0",
"@tanstack/react-router-ssr-query": "^1.131.7",
"@tanstack/react-start": "^1.132.0",
"@tanstack/router-plugin": "^1.132.0",
"convex": "^1.31.2",
"fflate": "^0.8.2",
"lucide-react": "^0.561.0",
"nitro": "latest",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"semver": "^7.7.3",
"tailwindcss": "^4.0.6",
"vite-tsconfig-paths": "^6.0.2"
},
"devDependencies": {
"@biomejs/biome": "^2.3.10",
"@tanstack/devtools-vite": "^0.3.11",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"@types/node": "^25.0.3",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.4",
"@vitest/coverage-v8": "^4.0.16",
"jsdom": "^27.0.0",
"oxlint": "^1.36.0",
"oxlint-tsgolint": "^0.10.1",
"typescript": "^5.7.2",
"vite": "^7.1.7",
"vitest": "^4",
"web-vitals": "^5.1.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

+25
View File
@@ -0,0 +1,25 @@
{
"short_name": "TanStack App",
"name": "Create TanStack App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
+3
View File
@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

+21
View File
@@ -0,0 +1,21 @@
import { ConvexAuthProvider } from '@convex-dev/auth/react'
import { convex } from '../convex/client'
import { UserBootstrap } from './UserBootstrap'
export function AppProviders({ children }: { children: React.ReactNode }) {
if (typeof window === 'undefined') {
return <>{children}</>
}
return (
<ConvexAuthProvider
client={convex}
replaceURL={(relativeUrl) => {
window.history.replaceState(null, '', relativeUrl)
}}
>
<UserBootstrap />
{children}
</ConvexAuthProvider>
)
}
+18
View File
@@ -0,0 +1,18 @@
import { useEffect, useState } from 'react'
export function ClientOnly({
children,
fallback = null,
}: {
children: React.ReactNode
fallback?: React.ReactNode
}) {
const [ready, setReady] = useState(false)
useEffect(() => {
setReady(true)
}, [])
if (!ready) return <>{fallback}</>
return <>{children}</>
}
+60
View File
@@ -0,0 +1,60 @@
import { Link } from '@tanstack/react-router'
import { useAuthActions } from '@convex-dev/auth/react'
import { useConvexAuth, useQuery } from 'convex/react'
import { api } from '../../convex/_generated/api'
import { gravatarUrl } from '../lib/gravatar'
export default function Header() {
const { isAuthenticated, isLoading } = useConvexAuth()
const { signIn, signOut } = useAuthActions()
const me = useQuery(api.users.me)
const avatar = me?.image ?? (me?.email ? gravatarUrl(me.email) : undefined)
return (
<header className="navbar">
<div className="navbar-inner">
<Link to="/" className="brand">
<span className="brand-mark" />
ClawdHub
</Link>
<nav className="nav-links">
<Link to="/upload">Upload</Link>
<Link to="/search">Search</Link>
{me ? <Link to="/stars">Stars</Link> : null}
{me?.role === 'admin' || me?.role === 'moderator' ? (
<Link to="/admin">Admin</Link>
) : null}
</nav>
<div className="nav-actions">
{isAuthenticated && me ? (
<>
<Link to="/settings" className="btn">
<span className="mono">@{me.handle ?? me.displayName ?? 'user'}</span>
</Link>
<button className="btn" type="button" onClick={() => void signOut()}>
Sign out
</button>
{avatar ? (
<img
src={avatar}
alt={me.displayName ?? me.name ?? 'User avatar'}
style={{ width: 36, height: 36, borderRadius: '50%' }}
/>
) : null}
</>
) : (
<button
className="btn btn-primary"
type="button"
disabled={isLoading}
onClick={() => void signIn('github')}
>
Sign in with GitHub
</button>
)}
</div>
</div>
</header>
)
}
+17
View File
@@ -0,0 +1,17 @@
import { useMutation, useConvexAuth } from 'convex/react'
import { useEffect, useRef } from 'react'
import { api } from '../../convex/_generated/api'
export function UserBootstrap() {
const { isAuthenticated, isLoading } = useConvexAuth()
const ensureUser = useMutation(api.users.ensure)
const didRun = useRef(false)
useEffect(() => {
if (isLoading || !isAuthenticated || didRun.current) return
didRun.current = true
void ensureUser()
}, [isAuthenticated, isLoading, ensureUser])
return null
}
+3
View File
@@ -0,0 +1,3 @@
import { ConvexReactClient } from 'convex/react'
export const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string)
+9
View File
@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest'
import { gravatarUrl } from './gravatar'
describe('gravatarUrl', () => {
it('generates a stable hash', () => {
const url = gravatarUrl('MyEmailAddress@example.com ')
expect(url).toContain('0bc83cb571cd1c50ba6f3e8a78ef1346')
})
})
+158
View File
@@ -0,0 +1,158 @@
function md5cycle(x: number[], k: number[]) {
let [a, b, c, d] = x
a = ff(a, b, c, d, k[0], 7, -680876936)
d = ff(d, a, b, c, k[1], 12, -389564586)
c = ff(c, d, a, b, k[2], 17, 606105819)
b = ff(b, c, d, a, k[3], 22, -1044525330)
a = ff(a, b, c, d, k[4], 7, -176418897)
d = ff(d, a, b, c, k[5], 12, 1200080426)
c = ff(c, d, a, b, k[6], 17, -1473231341)
b = ff(b, c, d, a, k[7], 22, -45705983)
a = ff(a, b, c, d, k[8], 7, 1770035416)
d = ff(d, a, b, c, k[9], 12, -1958414417)
c = ff(c, d, a, b, k[10], 17, -42063)
b = ff(b, c, d, a, k[11], 22, -1990404162)
a = ff(a, b, c, d, k[12], 7, 1804603682)
d = ff(d, a, b, c, k[13], 12, -40341101)
c = ff(c, d, a, b, k[14], 17, -1502002290)
b = ff(b, c, d, a, k[15], 22, 1236535329)
a = gg(a, b, c, d, k[1], 5, -165796510)
d = gg(d, a, b, c, k[6], 9, -1069501632)
c = gg(c, d, a, b, k[11], 14, 643717713)
b = gg(b, c, d, a, k[0], 20, -373897302)
a = gg(a, b, c, d, k[5], 5, -701558691)
d = gg(d, a, b, c, k[10], 9, 38016083)
c = gg(c, d, a, b, k[15], 14, -660478335)
b = gg(b, c, d, a, k[4], 20, -405537848)
a = gg(a, b, c, d, k[9], 5, 568446438)
d = gg(d, a, b, c, k[14], 9, -1019803690)
c = gg(c, d, a, b, k[3], 14, -187363961)
b = gg(b, c, d, a, k[8], 20, 1163531501)
a = gg(a, b, c, d, k[13], 5, -1444681467)
d = gg(d, a, b, c, k[2], 9, -51403784)
c = gg(c, d, a, b, k[7], 14, 1735328473)
b = gg(b, c, d, a, k[12], 20, -1926607734)
a = hh(a, b, c, d, k[5], 4, -378558)
d = hh(d, a, b, c, k[8], 11, -2022574463)
c = hh(c, d, a, b, k[11], 16, 1839030562)
b = hh(b, c, d, a, k[14], 23, -35309556)
a = hh(a, b, c, d, k[1], 4, -1530992060)
d = hh(d, a, b, c, k[4], 11, 1272893353)
c = hh(c, d, a, b, k[7], 16, -155497632)
b = hh(b, c, d, a, k[10], 23, -1094730640)
a = hh(a, b, c, d, k[13], 4, 681279174)
d = hh(d, a, b, c, k[0], 11, -358537222)
c = hh(c, d, a, b, k[3], 16, -722521979)
b = hh(b, c, d, a, k[6], 23, 76029189)
a = hh(a, b, c, d, k[9], 4, -640364487)
d = hh(d, a, b, c, k[12], 11, -421815835)
c = hh(c, d, a, b, k[15], 16, 530742520)
b = hh(b, c, d, a, k[2], 23, -995338651)
a = ii(a, b, c, d, k[0], 6, -198630844)
d = ii(d, a, b, c, k[7], 10, 1126891415)
c = ii(c, d, a, b, k[14], 15, -1416354905)
b = ii(b, c, d, a, k[5], 21, -57434055)
a = ii(a, b, c, d, k[12], 6, 1700485571)
d = ii(d, a, b, c, k[3], 10, -1894986606)
c = ii(c, d, a, b, k[10], 15, -1051523)
b = ii(b, c, d, a, k[1], 21, -2054922799)
a = ii(a, b, c, d, k[8], 6, 1873313359)
d = ii(d, a, b, c, k[15], 10, -30611744)
c = ii(c, d, a, b, k[6], 15, -1560198380)
b = ii(b, c, d, a, k[13], 21, 1309151649)
a = ii(a, b, c, d, k[4], 6, -145523070)
d = ii(d, a, b, c, k[11], 10, -1120210379)
c = ii(c, d, a, b, k[2], 15, 718787259)
b = ii(b, c, d, a, k[9], 21, -343485551)
x[0] = add32(a, x[0])
x[1] = add32(b, x[1])
x[2] = add32(c, x[2])
x[3] = add32(d, x[3])
}
function cmn(q: number, a: number, b: number, x: number, s: number, t: number) {
a = add32(add32(a, q), add32(x, t))
return add32((a << s) | (a >>> (32 - s)), b)
}
function ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
return cmn((b & c) | (~b & d), a, b, x, s, t)
}
function gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
return cmn((b & d) | (c & ~d), a, b, x, s, t)
}
function hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
return cmn(b ^ c ^ d, a, b, x, s, t)
}
function ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
return cmn(c ^ (b | ~d), a, b, x, s, t)
}
function md51(s: string) {
const n = s.length
const state = [1732584193, -271733879, -1732584194, 271733878]
let i
for (i = 64; i <= n; i += 64) {
md5cycle(state, md5blk(s.substring(i - 64, i)))
}
s = s.substring(i - 64)
const tail = Array(16).fill(0) as number[]
for (i = 0; i < s.length; i += 1) {
tail[i >> 2] |= s.charCodeAt(i) << ((i % 4) << 3)
}
tail[i >> 2] |= 0x80 << ((i % 4) << 3)
if (i > 55) {
md5cycle(state, tail)
for (let j = 0; j < 16; j += 1) tail[j] = 0
}
tail[14] = n * 8
md5cycle(state, tail)
return state
}
function md5blk(s: string) {
const md5blks: number[] = []
for (let i = 0; i < 64; i += 4) {
md5blks[i >> 2] =
s.charCodeAt(i) +
(s.charCodeAt(i + 1) << 8) +
(s.charCodeAt(i + 2) << 16) +
(s.charCodeAt(i + 3) << 24)
}
return md5blks
}
function rhex(n: number) {
const s = '0123456789abcdef'
let j = 0
let out = ''
for (; j < 4; j += 1) {
out += s.charAt((n >> (j * 8 + 4)) & 0x0f) + s.charAt((n >> (j * 8)) & 0x0f)
}
return out
}
function hex(x: number[]) {
for (let i = 0; i < x.length; i += 1) {
x[i] = Number(x[i])
}
return x.map(rhex).join('')
}
function add32(a: number, b: number) {
return (a + b) & 0xffffffff
}
export function gravatarUrl(email: string, size = 160) {
const normalized = email.trim().toLowerCase()
const hash = hex(md51(normalized))
return `https://www.gravatar.com/avatar/${hash}?d=identicon&s=${size}`
}
+12
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

+171
View File
@@ -0,0 +1,171 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as UploadRouteImport } from './routes/upload'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as SearchRouteImport } from './routes/search'
import { Route as AdminRouteImport } from './routes/admin'
import { Route as IndexRouteImport } from './routes/index'
import { Route as SkillsSlugRouteImport } from './routes/skills/$slug'
const UploadRoute = UploadRouteImport.update({
id: '/upload',
path: '/upload',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsRoute = SettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => rootRouteImport,
} as any)
const SearchRoute = SearchRouteImport.update({
id: '/search',
path: '/search',
getParentRoute: () => rootRouteImport,
} as any)
const AdminRoute = AdminRouteImport.update({
id: '/admin',
path: '/admin',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const SkillsSlugRoute = SkillsSlugRouteImport.update({
id: '/skills/$slug',
path: '/skills/$slug',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/admin': typeof AdminRoute
'/search': typeof SearchRoute
'/settings': typeof SettingsRoute
'/upload': typeof UploadRoute
'/skills/$slug': typeof SkillsSlugRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/admin': typeof AdminRoute
'/search': typeof SearchRoute
'/settings': typeof SettingsRoute
'/upload': typeof UploadRoute
'/skills/$slug': typeof SkillsSlugRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/admin': typeof AdminRoute
'/search': typeof SearchRoute
'/settings': typeof SettingsRoute
'/upload': typeof UploadRoute
'/skills/$slug': typeof SkillsSlugRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/admin'
| '/search'
| '/settings'
| '/upload'
| '/skills/$slug'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/admin' | '/search' | '/settings' | '/upload' | '/skills/$slug'
id:
| '__root__'
| '/'
| '/admin'
| '/search'
| '/settings'
| '/upload'
| '/skills/$slug'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AdminRoute: typeof AdminRoute
SearchRoute: typeof SearchRoute
SettingsRoute: typeof SettingsRoute
UploadRoute: typeof UploadRoute
SkillsSlugRoute: typeof SkillsSlugRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/upload': {
id: '/upload'
path: '/upload'
fullPath: '/upload'
preLoaderRoute: typeof UploadRouteImport
parentRoute: typeof rootRouteImport
}
'/settings': {
id: '/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof SettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/search': {
id: '/search'
path: '/search'
fullPath: '/search'
preLoaderRoute: typeof SearchRouteImport
parentRoute: typeof rootRouteImport
}
'/admin': {
id: '/admin'
path: '/admin'
fullPath: '/admin'
preLoaderRoute: typeof AdminRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/skills/$slug': {
id: '/skills/$slug'
path: '/skills/$slug'
fullPath: '/skills/$slug'
preLoaderRoute: typeof SkillsSlugRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AdminRoute: AdminRoute,
SearchRoute: SearchRoute,
SettingsRoute: SettingsRoute,
UploadRoute: UploadRoute,
SkillsSlugRoute: SkillsSlugRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}
+17
View File
@@ -0,0 +1,17 @@
import { createRouter } from '@tanstack/react-router'
// Import the generated route tree
import { routeTree } from './routeTree.gen'
// Create a new router instance
export const getRouter = () => {
const router = createRouter({
routeTree,
context: {},
scrollRestoration: true,
defaultPreloadStaleTime: 0,
})
return router
}
+70
View File
@@ -0,0 +1,70 @@
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'
import Header from '../components/Header'
import { AppProviders } from '../components/AppProviders'
import { ClientOnly } from '../components/ClientOnly'
import appCss from '../styles.css?url'
export const Route = createRootRoute({
head: () => ({
meta: [
{
charSet: 'utf-8',
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{
title: 'ClawdHub',
},
{
name: 'description',
content: 'ClawdHub — a fast skill registry for agents, with vector search.',
},
],
links: [
{
rel: 'stylesheet',
href: appCss,
},
],
}),
shellComponent: RootDocument,
})
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<ClientOnly>
<AppProviders>
<Header />
{children}
{import.meta.env.DEV ? (
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
) : null}
</AppProviders>
</ClientOnly>
<Scripts />
</body>
</html>
)
}
+94
View File
@@ -0,0 +1,94 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMutation, useQuery } from 'convex/react'
import { api } from '../../convex/_generated/api'
export const Route = createFileRoute('/admin')({
component: Admin,
})
function Admin() {
const users = useQuery(api.users.list, { limit: 50 })
const skills = useQuery(api.skills.list, { limit: 20 })
const setRole = useMutation(api.users.setRole)
const setApproved = useMutation(api.skills.setRedactionApproved)
const setBatch = useMutation(api.skills.setBatch)
if (!users) {
return (
<main className="section">
<div className="card">Admin only.</div>
</main>
)
}
return (
<main className="section">
<h1 className="section-title">Admin console</h1>
<p className="section-subtitle">Promote users and curate skills.</p>
<div className="card">
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
Users
</h2>
<div style={{ display: 'grid', gap: 10, marginTop: 12 }}>
{users.map((user) => (
<div key={user._id} className="stat" style={{ justifyContent: 'space-between' }}>
<span className="mono">@{user.handle ?? user.name ?? 'user'}</span>
<select
value={user.role ?? 'user'}
onChange={(event) =>
void setRole({ userId: user._id, role: event.target.value as any })
}
>
<option value="user">User</option>
<option value="moderator">Moderator</option>
<option value="admin">Admin</option>
</select>
</div>
))}
</div>
</div>
<div className="card" style={{ marginTop: 20 }}>
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
Skills
</h2>
<div style={{ display: 'grid', gap: 10, marginTop: 12 }}>
{(skills ?? []).map((skill) => (
<div key={skill._id} className="stat" style={{ justifyContent: 'space-between' }}>
<Link to="/skills/$slug" params={{ slug: skill.slug }}>
{skill.displayName}
</Link>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn"
type="button"
onClick={() =>
void setApproved({
skillId: skill._id,
approved: !skill.badges.redactionApproved,
})
}
>
{skill.badges.redactionApproved ? 'Revoke' : 'Approve'}
</button>
<button
className="btn"
type="button"
onClick={() =>
void setBatch({
skillId: skill._id,
batch: skill.batch === 'highlighted' ? undefined : 'highlighted',
})
}
>
{skill.batch === 'highlighted' ? 'Unhighlight' : 'Highlight'}
</button>
</div>
</div>
))}
</div>
</div>
</main>
)
}
+99
View File
@@ -0,0 +1,99 @@
import { Link, createFileRoute } from '@tanstack/react-router'
import { useQuery } from 'convex/react'
import { api } from '../../convex/_generated/api'
export const Route = createFileRoute('/')({
component: Home,
})
function Home() {
const highlighted = useQuery(api.skills.list, { batch: 'highlighted', limit: 6 }) ?? []
const latest = useQuery(api.skills.list, { limit: 12 }) ?? []
return (
<main className="app-shell">
<section className="hero">
<div className="hero-inner">
<div className="fade-up" data-delay="1">
<span className="hero-badge">Lobster-light. Agent-right.</span>
<h1 className="hero-title">ClawdHub, the skill dock for sharp agents.</h1>
<p className="hero-subtitle">
Upload AgentSkills bundles, version them like npm, and make them searchable with
vectors. No gatekeeping, just signal.
</p>
<div style={{ display: 'flex', gap: 12, marginTop: 20 }}>
<Link to="/upload" className="btn btn-primary">
Publish a skill
</Link>
<Link to="/search" className="btn">
Explore search
</Link>
</div>
</div>
<div className="hero-card fade-up" data-delay="2">
<div className="search-bar">
<span className="mono">/</span>
<input
className="search-input"
placeholder="Search skills, tags, or capabilities"
disabled
/>
<Link to="/search" className="btn">
Search
</Link>
</div>
<div style={{ display: 'grid', gap: 12, marginTop: 18 }}>
<div className="stat">Vector search · Latest tags · 50MB per version</div>
<div className="stat">Redaction badges · Soft delete · Rollback ready</div>
</div>
</div>
</div>
</section>
<section className="section">
<h2 className="section-title">Highlighted batch</h2>
<p className="section-subtitle">Curated signal approved and easy to trust.</p>
<div className="grid">
{highlighted.length === 0 ? (
<div className="card">No highlighted skills yet.</div>
) : (
highlighted.map((skill) => (
<Link key={skill._id} to="/skills/$slug" params={{ slug: skill.slug }} className="card">
<div className="tag">Highlighted</div>
<h3 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
{skill.displayName}
</h3>
<p className="section-subtitle" style={{ margin: 0 }}>
{skill.summary ?? 'A fresh skill bundle.'}
</p>
<div className="stat"> {skill.stats.stars} · {skill.stats.downloads}</div>
</Link>
))
)}
</div>
</section>
<section className="section">
<h2 className="section-title">Latest drops</h2>
<p className="section-subtitle">Newest uploads across the registry.</p>
<div className="grid">
{latest.length === 0 ? (
<div className="card">No skills yet. Be the first.</div>
) : (
latest.map((skill) => (
<Link key={skill._id} to="/skills/$slug" params={{ slug: skill.slug }} className="card">
<div className="stat">{skill.summary ?? 'Agent-ready skill pack.'}</div>
<h3 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
{skill.displayName}
</h3>
<div className="stat">
{skill.stats.versions} versions · {skill.stats.downloads} downloads
</div>
</Link>
))
)}
</div>
</section>
</main>
)
}
+82
View File
@@ -0,0 +1,82 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useAction } from 'convex/react'
import { useState } from 'react'
import { api } from '../../convex/_generated/api'
export const Route = createFileRoute('/search')({
component: Search,
})
function Search() {
const searchSkills = useAction(api.search.searchSkills)
const [query, setQuery] = useState('')
const [approvedOnly, setApprovedOnly] = useState(true)
const [results, setResults] = useState<
Array<{ skill: any; version: any; score: number }>
>([])
const [isSearching, setIsSearching] = useState(false)
async function onSubmit(event: React.FormEvent) {
event.preventDefault()
if (!query.trim()) return
setIsSearching(true)
try {
const data = await searchSkills({ query, approvedOnly })
setResults(data as any)
} finally {
setIsSearching(false)
}
}
return (
<main className="section">
<h1 className="section-title">Vector search</h1>
<p className="section-subtitle">Ask for capabilities, get skill packs.</p>
<form onSubmit={onSubmit} className="search-bar" style={{ marginBottom: 20 }}>
<input
className="search-input"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="e.g. summarize PDFs, book travel, scrape web"
/>
<button className="btn btn-primary" type="submit" disabled={isSearching}>
{isSearching ? 'Searching…' : 'Search'}
</button>
</form>
<label style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<input
type="checkbox"
checked={approvedOnly}
onChange={(event) => setApprovedOnly(event.target.checked)}
/>
Only redaction-approved skills
</label>
<div className="grid" style={{ marginTop: 24 }}>
{results.length === 0 ? (
<div className="card">No results yet. Try a different prompt.</div>
) : (
results.map((result) => (
<Link
key={result.skill._id}
to="/skills/$slug"
params={{ slug: result.skill.slug }}
className="card"
>
<div className="tag">Score {(result.score ?? 0).toFixed(2)}</div>
<h3 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
{result.skill.displayName}
</h3>
<p className="section-subtitle" style={{ margin: 0 }}>
{result.skill.summary ?? 'Skill pack'}
</p>
{result.skill.badges?.redactionApproved ? (
<div className="tag">Redaction approved</div>
) : null}
</Link>
))
)}
</div>
</main>
)
}
+82
View File
@@ -0,0 +1,82 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery } from 'convex/react'
import { useEffect, useState } from 'react'
import { api } from '../../convex/_generated/api'
export const Route = createFileRoute('/settings')({
component: Settings,
})
function Settings() {
const me = useQuery(api.users.me)
const updateProfile = useMutation(api.users.updateProfile)
const deleteAccount = useMutation(api.users.deleteAccount)
const [displayName, setDisplayName] = useState('')
const [bio, setBio] = useState('')
const [status, setStatus] = useState<string | null>(null)
useEffect(() => {
if (!me) return
setDisplayName(me.displayName ?? '')
setBio(me.bio ?? '')
}, [me])
if (!me) {
return (
<main className="section">
<div className="card">Sign in to access settings.</div>
</main>
)
}
async function onSave(event: React.FormEvent) {
event.preventDefault()
await updateProfile({ displayName, bio })
setStatus('Saved.')
}
async function onDelete() {
const ok = window.confirm('Soft delete your account? This cannot be undone.')
if (!ok) return
await deleteAccount()
}
return (
<main className="section">
<h1 className="section-title">Settings</h1>
<form className="card" onSubmit={onSave} style={{ display: 'grid', gap: 16 }}>
<label>
Display name
<input
className="search-input"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
/>
</label>
<label>
Bio
<textarea
className="search-input"
rows={3}
value={bio}
onChange={(event) => setBio(event.target.value)}
/>
</label>
<button className="btn btn-primary" type="submit">
Save
</button>
{status ? <div className="stat">{status}</div> : null}
</form>
<div className="card" style={{ marginTop: 20 }}>
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
Danger zone
</h2>
<p className="section-subtitle">Soft delete your account. Skills remain public.</p>
<button className="btn" type="button" onClick={() => void onDelete()}>
Delete account
</button>
</div>
</main>
)
}
+261
View File
@@ -0,0 +1,261 @@
import { createFileRoute } from '@tanstack/react-router'
import { useAction, useConvexAuth, useMutation, useQuery } from 'convex/react'
import { useEffect, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { api } from '../../../convex/_generated/api'
export const Route = createFileRoute('/skills/$slug')({
component: SkillDetail,
})
function SkillDetail() {
const { slug } = Route.useParams()
const { isAuthenticated } = useConvexAuth()
const me = useQuery(api.users.me)
const result = useQuery(api.skills.getBySlug, { slug })
const toggleStar = useMutation(api.stars.toggle)
const addComment = useMutation(api.comments.add)
const removeComment = useMutation(api.comments.remove)
const updateTags = useMutation(api.skills.updateTags)
const getReadme = useAction(api.skills.getReadme)
const [readme, setReadme] = useState<string | null>(null)
const [comment, setComment] = useState('')
const [tagName, setTagName] = useState('latest')
const [tagVersionId, setTagVersionId] = useState<string | null>(null)
const skill = result?.skill
const owner = result?.owner
const latestVersion = result?.latestVersion
const versions = useQuery(
api.skills.listVersions,
skill ? { skillId: skill._id, limit: 10 } : 'skip',
)
const isStarred = useQuery(
api.stars.isStarred,
isAuthenticated && skill ? { skillId: skill._id } : 'skip',
)
const comments = useQuery(
api.comments.listBySkill,
skill ? { skillId: skill._id, limit: 50 } : 'skip',
)
const canManage =
Boolean(me && skill && (me._id === skill.ownerUserId || ['admin', 'moderator'].includes(me.role ?? '')))
const versionById = new Map((versions ?? []).map((version) => [version._id, version]))
useEffect(() => {
if (!latestVersion) return
void getReadme({ versionId: latestVersion._id }).then((data) => {
setReadme(data.text)
})
}, [latestVersion?._id, getReadme])
useEffect(() => {
if (!tagVersionId && latestVersion) {
setTagVersionId(latestVersion._id)
}
}, [latestVersion, tagVersionId])
if (!skill) {
return (
<main className="section">
<div className="card">Skill not found.</div>
</main>
)
}
return (
<main className="section">
<div className="grid" style={{ gridTemplateColumns: '2fr 1fr' }}>
<div style={{ display: 'grid', gap: 16 }}>
<div className="card">
<h1 className="section-title" style={{ margin: 0 }}>
{skill.displayName}
</h1>
<p className="section-subtitle">{skill.summary ?? 'No summary provided.'}</p>
<div className="stat">
{skill.stats.stars} · {skill.stats.downloads} · v{latestVersion?.version}
</div>
{owner?.handle ? (
<div className="stat">
by <a href={`/u/${owner.handle}`}>@{owner.handle}</a>
</div>
) : null}
{skill.badges.redactionApproved ? (
<div className="tag">Redaction approved</div>
) : null}
{isAuthenticated ? (
<button
className="btn"
type="button"
onClick={() => void toggleStar({ skillId: skill._id })}
>
{isStarred ? 'Unstar' : 'Star'}
</button>
) : null}
</div>
<div className="card">
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
SKILL.md
</h2>
<div className="markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{readme ?? 'Loading…'}
</ReactMarkdown>
</div>
</div>
<div className="card">
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
Comments
</h2>
{isAuthenticated ? (
<form
onSubmit={(event) => {
event.preventDefault()
if (!comment.trim()) return
void addComment({ skillId: skill._id, body: comment.trim() }).then(() =>
setComment(''),
)
}}
style={{ display: 'grid', gap: 10, marginTop: 12 }}
>
<textarea
className="search-input"
rows={2}
value={comment}
onChange={(event) => setComment(event.target.value)}
placeholder="Leave a note…"
/>
<button className="btn" type="submit">
Post comment
</button>
</form>
) : (
<p className="section-subtitle">Sign in to comment.</p>
)}
<div style={{ display: 'grid', gap: 12, marginTop: 16 }}>
{(comments ?? []).length === 0 ? (
<div className="stat">No comments yet.</div>
) : (
(comments ?? []).map((entry: any) => (
<div key={entry.comment._id} className="stat" style={{ alignItems: 'flex-start' }}>
<div>
<strong>@{entry.user?.handle ?? entry.user?.name ?? 'user'}</strong>
<div style={{ color: '#5c554e' }}>{entry.comment.body}</div>
</div>
{isAuthenticated &&
me &&
(me._id === entry.comment.userId ||
me.role === 'admin' ||
me.role === 'moderator') ? (
<button
className="btn"
type="button"
onClick={() => void removeComment({ commentId: entry.comment._id })}
>
Delete
</button>
) : null}
</div>
))
)}
</div>
</div>
</div>
<div style={{ display: 'grid', gap: 16 }}>
<div className="card">
<h3 className="section-title" style={{ fontSize: '1.1rem', margin: 0 }}>
Versions
</h3>
<div style={{ display: 'grid', gap: 8 }}>
{(versions ?? []).map((version) => (
<div key={version._id} className="stat" style={{ alignItems: 'flex-start' }}>
<div>
<div>
v{version.version} · {new Date(version.createdAt).toLocaleDateString()}
</div>
<div style={{ color: '#5c554e' }}>{version.changelog}</div>
</div>
<a
className="btn"
href={`${import.meta.env.VITE_CONVEX_SITE_URL}/api/download?slug=${skill.slug}&version=${version.version}`}
>
Zip
</a>
</div>
))}
</div>
</div>
<div className="card">
<h3 className="section-title" style={{ fontSize: '1.1rem', margin: 0 }}>
Tags
</h3>
<div style={{ display: 'grid', gap: 8 }}>
{Object.entries(skill.tags ?? {}).map(([tag, versionId]) => (
<div key={tag} className="stat">
<strong>{tag}</strong>
<span>
{versionById.get(versionId)?.version ?? versionId}
</span>
</div>
))}
</div>
</div>
{canManage ? (
<div className="card">
<h3 className="section-title" style={{ fontSize: '1.1rem', margin: 0 }}>
Rollback / tag
</h3>
<form
onSubmit={(event) => {
event.preventDefault()
if (!tagName.trim() || !tagVersionId) return
void updateTags({
skillId: skill._id,
tags: [{ tag: tagName.trim(), versionId: tagVersionId as any }],
})
}}
style={{ display: 'grid', gap: 10, marginTop: 10 }}
>
<input
className="search-input"
value={tagName}
onChange={(event) => setTagName(event.target.value)}
placeholder="latest"
/>
<select
className="search-input"
value={tagVersionId ?? ''}
onChange={(event) => setTagVersionId(event.target.value)}
>
{(versions ?? []).map((version) => (
<option key={version._id} value={version._id}>
v{version.version}
</option>
))}
</select>
<button className="btn" type="submit">
Update tag
</button>
</form>
</div>
) : null}
<div className="card">
<h3 className="section-title" style={{ fontSize: '1.1rem', margin: 0 }}>
Download
</h3>
<a
className="btn btn-primary"
href={`${import.meta.env.VITE_CONVEX_SITE_URL}/api/download?slug=${skill.slug}`}
>
Download zip
</a>
</div>
</div>
</div>
</main>
)
}
+44
View File
@@ -0,0 +1,44 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from 'convex/react'
import { api } from '../../convex/_generated/api'
export const Route = createFileRoute('/stars')({
component: Stars,
})
function Stars() {
const me = useQuery(api.users.me)
const skills = useQuery(
api.stars.listByUser,
me ? { userId: me._id, limit: 50 } : 'skip',
)
if (!me) {
return (
<main className="section">
<div className="card">Sign in to see your highlights.</div>
</main>
)
}
return (
<main className="section">
<h1 className="section-title">Your highlights</h1>
<p className="section-subtitle">Skills youve starred for quick access.</p>
<div className="grid">
{(skills ?? []).length === 0 ? (
<div className="card">No stars yet.</div>
) : (
(skills ?? []).map((skill) => (
<Link key={skill._id} to="/skills/$slug" params={{ slug: skill.slug }} className="card">
<h3 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
{skill.displayName}
</h3>
<div className="stat"> {skill.stats.stars}</div>
</Link>
))
)}
</div>
</main>
)
}
+45
View File
@@ -0,0 +1,45 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from 'convex/react'
import { api } from '../../../convex/_generated/api'
export const Route = createFileRoute('/u/$handle')({
component: UserProfile,
})
function UserProfile() {
const { handle } = Route.useParams()
const user = useQuery(api.users.getByHandle, { handle })
const skills = useQuery(
api.stars.listByUser,
user ? { userId: user._id, limit: 50 } : 'skip',
)
if (!user) {
return (
<main className="section">
<div className="card">User not found.</div>
</main>
)
}
return (
<main className="section">
<h1 className="section-title">@{user.handle ?? user.name}</h1>
<p className="section-subtitle">Highlighted skills</p>
<div className="grid">
{(skills ?? []).length === 0 ? (
<div className="card">No highlights yet.</div>
) : (
(skills ?? []).map((skill) => (
<Link key={skill._id} to="/skills/$slug" params={{ slug: skill.slug }} className="card">
<h3 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
{skill.displayName}
</h3>
<div className="stat"> {skill.stats.stars}</div>
</Link>
))
)}
</div>
</main>
)
}
+172
View File
@@ -0,0 +1,172 @@
import { createFileRoute } from '@tanstack/react-router'
import { useAction, useConvexAuth, useMutation } from 'convex/react'
import { useState } from 'react'
import { api } from '../../convex/_generated/api'
export const Route = createFileRoute('/upload')({
component: Upload,
})
function Upload() {
const { isAuthenticated } = useConvexAuth()
const generateUploadUrl = useMutation(api.uploads.generateUploadUrl)
const publishVersion = useAction(api.skills.publishVersion)
const [files, setFiles] = useState<File[]>([])
const [slug, setSlug] = useState('')
const [displayName, setDisplayName] = useState('')
const [version, setVersion] = useState('1.0.0')
const [tags, setTags] = useState('latest')
const [changelog, setChangelog] = useState('')
const [status, setStatus] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
if (!isAuthenticated) {
return (
<main className="section">
<div className="card">Sign in to upload a skill.</div>
</main>
)
}
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (files.length === 0) return
setError(null)
const totalBytes = files.reduce((sum, file) => sum + file.size, 0)
if (totalBytes > 50 * 1024 * 1024) {
setError('Total size exceeds 50MB per version.')
return
}
if (!files.some((file) => file.name.toLowerCase() === 'skill.md' || file.name.toLowerCase() === 'skills.md')) {
setError('SKILL.md is required.')
return
}
setStatus('Uploading files…')
const uploaded = [] as Array<{
path: string
size: number
storageId: string
sha256: string
contentType?: string
}>
for (const file of files) {
const uploadUrl = await generateUploadUrl()
const storageId = await uploadFile(uploadUrl, file)
const sha256 = await hashFile(file)
const path = file.webkitRelativePath || file.name
uploaded.push({
path,
size: file.size,
storageId,
sha256,
contentType: file.type || undefined,
})
}
setStatus('Publishing version…')
await publishVersion({
slug,
displayName,
version,
changelog,
tags: tags
.split(',')
.map((tag) => tag.trim())
.filter(Boolean),
files: uploaded,
})
setStatus('Published.')
}
return (
<main className="section">
<h1 className="section-title">Publish a skill</h1>
<p className="section-subtitle">Bundle SKILL.md + text files, then ship.</p>
<form className="card" onSubmit={handleSubmit} style={{ display: 'grid', gap: 16 }}>
<label>
Slug
<input
className="search-input"
value={slug}
onChange={(event) => setSlug(event.target.value)}
placeholder="my-skill-pack"
/>
</label>
<label>
Display name
<input
className="search-input"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
placeholder="My Skill Pack"
/>
</label>
<label>
Version
<input
className="search-input"
value={version}
onChange={(event) => setVersion(event.target.value)}
placeholder="1.0.0"
/>
</label>
<label>
Tags (comma-separated)
<input
className="search-input"
value={tags}
onChange={(event) => setTags(event.target.value)}
placeholder="latest, beta"
/>
</label>
<label>
Changelog
<textarea
className="search-input"
rows={3}
value={changelog}
onChange={(event) => setChangelog(event.target.value)}
placeholder="What changed in this version?"
/>
</label>
<label>
Files (must include SKILL.md)
<input
type="file"
multiple
onChange={(event) => setFiles(Array.from(event.target.files ?? []))}
/>
</label>
<button className="btn btn-primary" type="submit">
Publish
</button>
{error ? <div className="stat" style={{ color: '#b84a3a' }}>{error}</div> : null}
{status ? <div className="stat">{status}</div> : null}
</form>
</main>
)
}
async function uploadFile(uploadUrl: string, file: File) {
const response = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': file.type || 'application/octet-stream' },
body: file,
})
if (!response.ok) {
throw new Error(`Upload failed: ${await response.text()}`)
}
const payload = (await response.json()) as { storageId: string }
return payload.storageId
}
async function hashFile(file: File) {
const buffer = await file.arrayBuffer()
const hash = await crypto.subtle.digest('SHA-256', buffer)
const bytes = new Uint8Array(hash)
return Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('')
}
+304
View File
@@ -0,0 +1,304 @@
@import "tailwindcss";
@import "@fontsource/bricolage-grotesque/latin.css";
@import "@fontsource/manrope/latin.css";
@import "@fontsource/ibm-plex-mono/latin.css";
:root {
color-scheme: light;
--bg: #f8f5ef;
--bg-soft: #fdfaf4;
--ink: #1d1a17;
--ink-soft: #4c463f;
--accent: #ff6b4a;
--accent-deep: #e54f31;
--seafoam: #2bc6a4;
--gold: #f0c46a;
--line: rgba(29, 26, 23, 0.12);
--shadow: 0 24px 60px rgba(29, 26, 23, 0.1);
--radius-lg: 28px;
--radius-md: 18px;
--radius-sm: 12px;
--font-display: "Bricolage Grotesque", "Manrope", sans-serif;
--font-body: "Manrope", "Bricolage Grotesque", sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", "Courier New", monospace;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--font-body);
color: var(--ink);
background: radial-gradient(1200px 800px at 20% -10%, #fff1d8 0%, transparent 60%),
radial-gradient(900px 600px at 90% 10%, #ffe8e0 0%, transparent 60%), var(--bg);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
color: inherit;
text-decoration: none;
}
code {
font-family: var(--font-mono);
}
.app-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.navbar {
position: sticky;
top: 0;
z-index: 10;
background: rgba(248, 245, 239, 0.85);
backdrop-filter: blur(18px);
border-bottom: 1px solid var(--line);
}
.navbar-inner {
max-width: 1200px;
margin: 0 auto;
padding: 20px 24px;
display: flex;
align-items: center;
gap: 20px;
}
.brand {
font-family: var(--font-display);
font-size: 1.4rem;
letter-spacing: -0.03em;
display: flex;
align-items: center;
gap: 10px;
}
.brand-mark {
width: 36px;
height: 36px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #ffd3c2 0%, #ff6b4a 60%, #d1492f 100%);
box-shadow: inset 0 0 0 4px rgba(255, 255, 255, 0.5);
}
.nav-links {
display: flex;
gap: 16px;
font-size: 0.95rem;
color: var(--ink-soft);
}
.nav-actions {
margin-left: auto;
display: flex;
gap: 12px;
align-items: center;
}
.btn {
border: 1px solid var(--line);
padding: 10px 16px;
border-radius: 999px;
background: white;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 8px;
transition: transform 0.2s ease, box-shadow 0.2s ease, border 0.2s ease;
}
.btn:hover {
transform: translateY(-1px);
box-shadow: 0 10px 20px rgba(29, 26, 23, 0.12);
}
.btn-primary {
background: linear-gradient(135deg, var(--accent), var(--accent-deep));
color: white;
border: none;
}
.hero {
position: relative;
overflow: hidden;
padding: 80px 24px 60px;
}
.hero-inner {
max-width: 1200px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 40px;
align-items: center;
}
.hero-title {
font-family: var(--font-display);
font-size: clamp(2.6rem, 4vw, 4rem);
letter-spacing: -0.03em;
margin-bottom: 16px;
}
.hero-subtitle {
font-size: 1.1rem;
color: var(--ink-soft);
line-height: 1.6;
}
.hero-card {
background: var(--bg-soft);
border-radius: var(--radius-lg);
padding: 28px;
box-shadow: var(--shadow);
border: 1px solid rgba(255, 107, 74, 0.15);
}
.hero-badge {
display: inline-flex;
align-items: center;
gap: 8px;
background: rgba(255, 107, 74, 0.12);
color: var(--accent-deep);
border-radius: 999px;
padding: 6px 14px;
font-weight: 600;
font-size: 0.85rem;
}
.section {
max-width: 1200px;
margin: 0 auto;
padding: 32px 24px 72px;
}
.section-title {
font-family: var(--font-display);
font-size: 1.8rem;
letter-spacing: -0.02em;
margin-bottom: 10px;
}
.section-subtitle {
color: var(--ink-soft);
margin-bottom: 24px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 20px;
}
.card {
background: white;
border-radius: var(--radius-md);
border: 1px solid var(--line);
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card:hover {
transform: translateY(-3px);
box-shadow: 0 16px 30px rgba(29, 26, 23, 0.12);
}
.tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 999px;
background: rgba(43, 198, 164, 0.16);
color: #1a6b5b;
font-size: 0.8rem;
font-weight: 600;
}
.stat {
display: flex;
gap: 10px;
align-items: center;
font-size: 0.9rem;
color: var(--ink-soft);
}
.search-bar {
display: flex;
gap: 12px;
padding: 14px 16px;
border-radius: 16px;
border: 1px solid var(--line);
background: white;
align-items: center;
}
.search-input {
border: none;
outline: none;
flex: 1;
font-size: 1rem;
background: transparent;
}
.mono {
font-family: var(--font-mono);
}
.markdown {
line-height: 1.7;
color: #3f3a34;
}
.markdown h1,
.markdown h2,
.markdown h3 {
font-family: var(--font-display);
margin-top: 1.4rem;
}
.markdown a {
color: var(--accent-deep);
}
.markdown code {
background: rgba(255, 107, 74, 0.12);
padding: 2px 6px;
border-radius: 8px;
}
.fade-up {
animation: fadeUp 0.6s ease forwards;
opacity: 0;
transform: translateY(12px);
}
.fade-up[data-delay='1'] {
animation-delay: 0.1s;
}
.fade-up[data-delay='2'] {
animation-delay: 0.2s;
}
.fade-up[data-delay='3'] {
animation-delay: 0.3s;
}
@keyframes fadeUp {
to {
opacity: 1;
transform: translateY(0);
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"include": ["**/*.ts", "**/*.tsx"],
"compilerOptions": {
"target": "ES2022",
"jsx": "react-jsx",
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": false,
"noEmit": true,
/* Linting */
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import { devtools } from '@tanstack/devtools-vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
import viteTsConfigPaths from 'vite-tsconfig-paths'
import tailwindcss from '@tailwindcss/vite'
import { nitro } from 'nitro/vite'
const config = defineConfig({
plugins: [
devtools(),
nitro(),
// this is the plugin that enables path aliases
viteTsConfigPaths({
projects: ['./tsconfig.json'],
}),
tailwindcss(),
tanstackStart(),
viteReact(),
],
})
export default config
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
lines: 80,
functions: 80,
branches: 80,
statements: 80,
include: ['src/lib/**/*.{ts,tsx}', 'convex/lib/**/*.ts'],
exclude: ['node_modules/', 'dist/', 'coverage/', 'convex/_generated/'],
},
},
})
+1
View File
@@ -0,0 +1 @@
// Vitest setup (intentionally minimal for now)