From d4b947af47194506a540699f18398a70260225bd Mon Sep 17 00:00:00 2001 From: DB Hurley Date: Mon, 5 Jan 2026 17:34:54 -0500 Subject: [PATCH] feat: Add user dashboard with skill management - Add /dashboard route showing user's published skills - Add 'Dashboard' link to user dropdown menu in header - Skills display name, slug, description, stats (downloads, stars, versions) - 'New Version' button links to upload with pre-populated slug - Upload route accepts ?updateSlug param to pre-fill form for updates - Auto-bumps version number when updating existing skill - Responsive design for mobile - Empty state with call-to-action for new users --- bun.lock | 11 ++- src/components/Header.tsx | 3 + src/routeTree.gen.ts | 21 ++++++ src/routes/dashboard.tsx | 90 +++++++++++++++++++++++++ src/routes/upload.tsx | 27 +++++++- src/styles.css | 138 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 281 insertions(+), 9 deletions(-) create mode 100644 src/routes/dashboard.tsx diff --git a/bun.lock b/bun.lock index 9ec664a6ee..17836850d0 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "clawdhub", @@ -18,7 +17,7 @@ "@tanstack/react-router-devtools": "^1.144.0", "@tanstack/react-start": "^1.145.3", "@tanstack/router-plugin": "^1.145.2", - "clawdhub-schema": "^0.0.1", + "clawdhub-schema": "^0.0.2", "clsx": "^2.1.1", "convex": "^1.31.2", "fflate": "^0.8.2", @@ -54,13 +53,13 @@ }, "packages/clawdhub": { "name": "clawdhub", - "version": "0.0.1", + "version": "0.0.4", "bin": { - "clawdhub": "./bin/clawdhub.js", + "clawdhub": "bin/clawdhub.js", }, "dependencies": { "@clack/prompts": "^0.11.0", - "clawdhub-schema": "^0.0.1", + "arktype": "^2.1.29", "commander": "^14.0.2", "fflate": "^0.8.2", "ignore": "^7.0.5", @@ -76,7 +75,7 @@ }, "packages/schema": { "name": "clawdhub-schema", - "version": "0.0.1", + "version": "0.0.2", "dependencies": { "arktype": "^2.1.29", }, diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 2ac9c5535c..5d4e89b716 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -90,6 +90,9 @@ export default function Header() { + + Dashboard + Settings diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 9e74dd16bd..5aa2d13557 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as UploadRouteImport } from './routes/upload' import { Route as StarsRouteImport } from './routes/stars' import { Route as SettingsRouteImport } from './routes/settings' import { Route as SearchRouteImport } from './routes/search' +import { Route as DashboardRouteImport } from './routes/dashboard' import { Route as AdminRouteImport } from './routes/admin' import { Route as IndexRouteImport } from './routes/index' import { Route as SkillsIndexRouteImport } from './routes/skills/index' @@ -41,6 +42,11 @@ const SearchRoute = SearchRouteImport.update({ path: '/search', getParentRoute: () => rootRouteImport, } as any) +const DashboardRoute = DashboardRouteImport.update({ + id: '/dashboard', + path: '/dashboard', + getParentRoute: () => rootRouteImport, +} as any) const AdminRoute = AdminRouteImport.update({ id: '/admin', path: '/admin', @@ -80,6 +86,7 @@ const OwnerSlugRoute = OwnerSlugRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/admin': typeof AdminRoute + '/dashboard': typeof DashboardRoute '/search': typeof SearchRoute '/settings': typeof SettingsRoute '/stars': typeof StarsRoute @@ -93,6 +100,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/admin': typeof AdminRoute + '/dashboard': typeof DashboardRoute '/search': typeof SearchRoute '/settings': typeof SettingsRoute '/stars': typeof StarsRoute @@ -107,6 +115,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/admin': typeof AdminRoute + '/dashboard': typeof DashboardRoute '/search': typeof SearchRoute '/settings': typeof SettingsRoute '/stars': typeof StarsRoute @@ -122,6 +131,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/admin' + | '/dashboard' | '/search' | '/settings' | '/stars' @@ -135,6 +145,7 @@ export interface FileRouteTypes { to: | '/' | '/admin' + | '/dashboard' | '/search' | '/settings' | '/stars' @@ -148,6 +159,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/admin' + | '/dashboard' | '/search' | '/settings' | '/stars' @@ -162,6 +174,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute AdminRoute: typeof AdminRoute + DashboardRoute: typeof DashboardRoute SearchRoute: typeof SearchRoute SettingsRoute: typeof SettingsRoute StarsRoute: typeof StarsRoute @@ -203,6 +216,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SearchRouteImport parentRoute: typeof rootRouteImport } + '/dashboard': { + id: '/dashboard' + path: '/dashboard' + fullPath: '/dashboard' + preLoaderRoute: typeof DashboardRouteImport + parentRoute: typeof rootRouteImport + } '/admin': { id: '/admin' path: '/admin' @@ -258,6 +278,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AdminRoute: AdminRoute, + DashboardRoute: DashboardRoute, SearchRoute: SearchRoute, SettingsRoute: SettingsRoute, StarsRoute: StarsRoute, diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx new file mode 100644 index 0000000000..e25256f283 --- /dev/null +++ b/src/routes/dashboard.tsx @@ -0,0 +1,90 @@ +import { createFileRoute, Link } from '@tanstack/react-router' +import { useQuery } from 'convex/react' +import { Package, Plus, Upload } from 'lucide-react' +import { api } from '../../convex/_generated/api' + +export const Route = createFileRoute('/dashboard')({ + component: Dashboard, +}) + +function Dashboard() { + const me = useQuery(api.users.me) + const mySkills = useQuery( + api.skills.list, + me?._id ? { ownerUserId: me._id, limit: 100 } : 'skip', + ) + + if (!me) { + return ( +
+
Sign in to access your dashboard.
+
+ ) + } + + const skills = mySkills ?? [] + + return ( +
+
+

My Skills

+ +
+ + {skills.length === 0 ? ( +
+
+ ) : ( +
+ {skills.map((skill) => ( + + ))} +
+ )} +
+ ) +} + +function SkillCard({ skill }: { skill: { _id: string; slug: string; displayName: string; description?: string; downloadCount?: number; starCount?: number; versionCount?: number } }) { + return ( +
+
+ + {skill.displayName} + + /{skill.slug} + {skill.description && ( +

{skill.description}

+ )} +
+ ⤓ {skill.downloadCount ?? 0} + ★ {skill.starCount ?? 0} + {skill.versionCount ?? 1} v +
+
+
+ +
+
+ ) +} diff --git a/src/routes/upload.tsx b/src/routes/upload.tsx index cc8117533c..4ba9a63b3b 100644 --- a/src/routes/upload.tsx +++ b/src/routes/upload.tsx @@ -1,6 +1,6 @@ -import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router' import { isTextContentType, TEXT_FILE_EXTENSION_SET } from 'clawdhub-schema' -import { useAction, useConvexAuth, useMutation } from 'convex/react' +import { useAction, useConvexAuth, useMutation, useQuery } from 'convex/react' import { useEffect, useMemo, useRef, useState } from 'react' import semver from 'semver' import { api } from '../../convex/_generated/api' @@ -9,18 +9,39 @@ import { expandFiles } from '../lib/uploadFiles' const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ export const Route = createFileRoute('/upload')({ + validateSearch: (search) => ({ + updateSlug: typeof search.updateSlug === 'string' ? search.updateSlug : undefined, + }), component: Upload, }) export function Upload() { const { isAuthenticated } = useConvexAuth() + const { updateSlug } = useSearch({ from: '/upload' }) const generateUploadUrl = useMutation(api.uploads.generateUploadUrl) const publishVersion = useAction(api.skills.publishVersion) + const existingSkill = useQuery( + api.skills.getBySlug, + updateSlug ? { slug: updateSlug } : 'skip', + ) const [hasAttempted, setHasAttempted] = useState(false) const [files, setFiles] = useState([]) - const [slug, setSlug] = useState('') + const [slug, setSlug] = useState(updateSlug ?? '') const [displayName, setDisplayName] = useState('') const [version, setVersion] = useState('1.0.0') + const isUpdate = Boolean(updateSlug && existingSkill) + + // Pre-populate form when updating existing skill + useEffect(() => { + if (existingSkill?.skill && existingSkill?.latestVersion) { + setSlug(existingSkill.skill.slug) + setDisplayName(existingSkill.skill.displayName) + // Bump version automatically + const currentVersion = existingSkill.latestVersion.version + const nextVersion = semver.inc(currentVersion, 'patch') + if (nextVersion) setVersion(nextVersion) + } + }, [existingSkill]) const [tags, setTags] = useState('latest') const [changelog, setChangelog] = useState('') const [status, setStatus] = useState(null) diff --git a/src/styles.css b/src/styles.css index 9c8ff9ee9a..567bcadd6f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1517,3 +1517,141 @@ html.theme-transition::view-transition-new(theme) { animation: none !important; } } + +/* Dashboard styles */ +.dashboard-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} + +.dashboard-header h1 { + font-size: 1.5rem; + font-weight: 600; + margin: 0; +} + +.dashboard-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 48px 24px; + gap: 16px; +} + +.dashboard-empty h2 { + font-size: 1.25rem; + font-weight: 600; + margin: 0; +} + +.dashboard-empty p { + color: var(--color-muted); + margin: 0; +} + +.dashboard-grid { + display: flex; + flex-direction: column; + gap: 12px; +} + +.dashboard-skill-card { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + padding: 16px 20px; + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 12px; + transition: border-color 0.15s ease; +} + +.dashboard-skill-card:hover { + border-color: var(--color-accent); +} + +.dashboard-skill-info { + flex: 1; + min-width: 0; +} + +.dashboard-skill-name { + font-weight: 600; + font-size: 1.1rem; + color: var(--color-text); + text-decoration: none; +} + +.dashboard-skill-name:hover { + color: var(--color-accent); +} + +.dashboard-skill-slug { + font-family: var(--font-mono); + font-size: 0.85rem; + color: var(--color-muted); + margin-left: 8px; +} + +.dashboard-skill-description { + font-size: 0.9rem; + color: var(--color-muted); + margin: 8px 0 0; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.dashboard-skill-stats { + display: flex; + gap: 12px; + margin-top: 8px; + font-size: 0.8rem; + color: var(--color-muted); +} + +.dashboard-skill-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.btn-sm { + padding: 6px 12px; + font-size: 0.85rem; + gap: 4px; +} + +.btn-ghost { + background: transparent; + border: 1px solid var(--card-border); + color: var(--color-text); +} + +.btn-ghost:hover { + background: var(--card-bg); + border-color: var(--color-accent); +} + +@media (max-width: 640px) { + .dashboard-skill-card { + flex-direction: column; + gap: 12px; + } + + .dashboard-skill-actions { + width: 100%; + } + + .dashboard-skill-actions .btn { + flex: 1; + justify-content: center; + } +}