diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 43e6d440d7..f129fc18aa 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -166,6 +166,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..c245459ba7 --- /dev/null +++ b/src/routes/dashboard.tsx @@ -0,0 +1,120 @@ +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' +import type { Doc } from '../../convex/_generated/dataModel' + +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 ?? [] + const ownerHandle = me.handle ?? null + + return ( +
+
+

+ My Skills +

+ +
+ + {skills.length === 0 ? ( +
+
+ ) : ( +
+ {skills.map((skill) => ( + + ))} +
+ )} +
+ ) +} + +function SkillCard({ + skill, + ownerHandle, +}: { + skill: Doc<'skills'> + ownerHandle: string | null +}) { + return ( +
+
+ {ownerHandle ? ( + + {skill.displayName} + + ) : ( + + {skill.displayName} + + )} + /{skill.slug} + {skill.summary && ( +

{skill.summary}

+ )} +
+ ⤓ {skill.stats.downloads} + ★ {skill.stats.stars} + {skill.stats.versions} v +
+
+
+ +
+
+ ) +} diff --git a/src/routes/upload.tsx b/src/routes/upload.tsx index 0cab803ca3..7cdc5b755b 100644 --- a/src/routes/upload.tsx +++ b/src/routes/upload.tsx @@ -1,5 +1,5 @@ -import { createFileRoute, useNavigate } from '@tanstack/react-router' -import { useAction, useConvexAuth, useMutation } from 'convex/react' +import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router' +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' @@ -16,17 +16,25 @@ import { 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 generateChangelogPreview = useAction(api.skills.generateChangelogPreview) + 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 [tags, setTags] = useState('latest') @@ -79,6 +87,14 @@ export function Upload() { const trimmedName = displayName.trim() const trimmedChangelog = changelog.trim() + useEffect(() => { + if (!existingSkill?.skill || !existingSkill?.latestVersion) return + setSlug(existingSkill.skill.slug) + setDisplayName(existingSkill.skill.displayName) + const nextVersion = semver.inc(existingSkill.latestVersion.version, 'patch') + if (nextVersion) setVersion(nextVersion) + }, [existingSkill]) + useEffect(() => { if (changelogTouchedRef.current) return if (trimmedChangelog) return diff --git a/src/styles.css b/src/styles.css index 21e86fcbe5..326fcf604e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2271,3 +2271,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-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 48px 24px; + gap: 16px; +} + +.dashboard-empty-icon { + width: 48px; + height: 48px; + color: var(--ink-soft); +} + +.dashboard-empty h2 { + font-size: 1.25rem; + font-weight: 600; + margin: 0; +} + +.dashboard-empty p { + color: var(--ink-soft); + 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(--surface); + border: 1px solid var(--line); + border-radius: 16px; + transition: border-color 0.15s ease; +} + +.dashboard-skill-card:hover { + border-color: rgba(255, 107, 74, 0.45); +} + +.dashboard-skill-info { + flex: 1; + min-width: 0; +} + +.dashboard-skill-name { + font-weight: 600; + font-size: 1.1rem; + color: var(--ink); + text-decoration: none; +} + +.dashboard-skill-name:hover { + color: var(--accent); +} + +.dashboard-skill-slug { + font-family: var(--font-mono); + font-size: 0.85rem; + color: var(--ink-soft); + margin-left: 8px; +} + +.dashboard-skill-description { + font-size: 0.9rem; + color: var(--ink-soft); + 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(--ink-soft); +} + +.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(--line); + color: var(--ink); +} + +.btn-ghost:hover { + background: var(--surface-muted); + border-color: rgba(255, 107, 74, 0.45); +} + +@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; + } +}