Skip to content

Commit 927cd4d

Browse files
authored
Merge pull request #2 from dbhurley/feat/user-dashboard
feat: Add user dashboard with skill management
2 parents 2a28f75 + bfa59ca commit 927cd4d

File tree

5 files changed

+301
-3
lines changed

5 files changed

+301
-3
lines changed

src/components/Header.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,9 @@ export default function Header() {
166166
</button>
167167
</DropdownMenuTrigger>
168168
<DropdownMenuContent align="end">
169+
<DropdownMenuItem asChild>
170+
<Link to="/dashboard">Dashboard</Link>
171+
</DropdownMenuItem>
169172
<DropdownMenuItem asChild>
170173
<Link to="/settings">Settings</Link>
171174
</DropdownMenuItem>

src/routeTree.gen.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { Route as UploadRouteImport } from './routes/upload'
1313
import { Route as StarsRouteImport } from './routes/stars'
1414
import { Route as SettingsRouteImport } from './routes/settings'
1515
import { Route as SearchRouteImport } from './routes/search'
16+
import { Route as DashboardRouteImport } from './routes/dashboard'
1617
import { Route as AdminRouteImport } from './routes/admin'
1718
import { Route as IndexRouteImport } from './routes/index'
1819
import { Route as SkillsIndexRouteImport } from './routes/skills/index'
@@ -41,6 +42,11 @@ const SearchRoute = SearchRouteImport.update({
4142
path: '/search',
4243
getParentRoute: () => rootRouteImport,
4344
} as any)
45+
const DashboardRoute = DashboardRouteImport.update({
46+
id: '/dashboard',
47+
path: '/dashboard',
48+
getParentRoute: () => rootRouteImport,
49+
} as any)
4450
const AdminRoute = AdminRouteImport.update({
4551
id: '/admin',
4652
path: '/admin',
@@ -80,6 +86,7 @@ const OwnerSlugRoute = OwnerSlugRouteImport.update({
8086
export interface FileRoutesByFullPath {
8187
'/': typeof IndexRoute
8288
'/admin': typeof AdminRoute
89+
'/dashboard': typeof DashboardRoute
8390
'/search': typeof SearchRoute
8491
'/settings': typeof SettingsRoute
8592
'/stars': typeof StarsRoute
@@ -93,6 +100,7 @@ export interface FileRoutesByFullPath {
93100
export interface FileRoutesByTo {
94101
'/': typeof IndexRoute
95102
'/admin': typeof AdminRoute
103+
'/dashboard': typeof DashboardRoute
96104
'/search': typeof SearchRoute
97105
'/settings': typeof SettingsRoute
98106
'/stars': typeof StarsRoute
@@ -107,6 +115,7 @@ export interface FileRoutesById {
107115
__root__: typeof rootRouteImport
108116
'/': typeof IndexRoute
109117
'/admin': typeof AdminRoute
118+
'/dashboard': typeof DashboardRoute
110119
'/search': typeof SearchRoute
111120
'/settings': typeof SettingsRoute
112121
'/stars': typeof StarsRoute
@@ -122,6 +131,7 @@ export interface FileRouteTypes {
122131
fullPaths:
123132
| '/'
124133
| '/admin'
134+
| '/dashboard'
125135
| '/search'
126136
| '/settings'
127137
| '/stars'
@@ -135,6 +145,7 @@ export interface FileRouteTypes {
135145
to:
136146
| '/'
137147
| '/admin'
148+
| '/dashboard'
138149
| '/search'
139150
| '/settings'
140151
| '/stars'
@@ -148,6 +159,7 @@ export interface FileRouteTypes {
148159
| '__root__'
149160
| '/'
150161
| '/admin'
162+
| '/dashboard'
151163
| '/search'
152164
| '/settings'
153165
| '/stars'
@@ -162,6 +174,7 @@ export interface FileRouteTypes {
162174
export interface RootRouteChildren {
163175
IndexRoute: typeof IndexRoute
164176
AdminRoute: typeof AdminRoute
177+
DashboardRoute: typeof DashboardRoute
165178
SearchRoute: typeof SearchRoute
166179
SettingsRoute: typeof SettingsRoute
167180
StarsRoute: typeof StarsRoute
@@ -203,6 +216,13 @@ declare module '@tanstack/react-router' {
203216
preLoaderRoute: typeof SearchRouteImport
204217
parentRoute: typeof rootRouteImport
205218
}
219+
'/dashboard': {
220+
id: '/dashboard'
221+
path: '/dashboard'
222+
fullPath: '/dashboard'
223+
preLoaderRoute: typeof DashboardRouteImport
224+
parentRoute: typeof rootRouteImport
225+
}
206226
'/admin': {
207227
id: '/admin'
208228
path: '/admin'
@@ -258,6 +278,7 @@ declare module '@tanstack/react-router' {
258278
const rootRouteChildren: RootRouteChildren = {
259279
IndexRoute: IndexRoute,
260280
AdminRoute: AdminRoute,
281+
DashboardRoute: DashboardRoute,
261282
SearchRoute: SearchRoute,
262283
SettingsRoute: SettingsRoute,
263284
StarsRoute: StarsRoute,

src/routes/dashboard.tsx

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { createFileRoute, Link } from '@tanstack/react-router'
2+
import { useQuery } from 'convex/react'
3+
import { Package, Plus, Upload } from 'lucide-react'
4+
import { api } from '../../convex/_generated/api'
5+
import type { Doc } from '../../convex/_generated/dataModel'
6+
7+
export const Route = createFileRoute('/dashboard')({
8+
component: Dashboard,
9+
})
10+
11+
function Dashboard() {
12+
const me = useQuery(api.users.me)
13+
const mySkills = useQuery(
14+
api.skills.list,
15+
me?._id ? { ownerUserId: me._id, limit: 100 } : 'skip',
16+
)
17+
18+
if (!me) {
19+
return (
20+
<main className="section">
21+
<div className="card">Sign in to access your dashboard.</div>
22+
</main>
23+
)
24+
}
25+
26+
const skills = mySkills ?? []
27+
const ownerHandle = me.handle ?? null
28+
29+
return (
30+
<main className="section">
31+
<div className="dashboard-header">
32+
<h1 className="section-title" style={{ margin: 0 }}>
33+
My Skills
34+
</h1>
35+
<Link to="/upload" className="btn btn-primary">
36+
<Plus className="h-4 w-4" aria-hidden="true" />
37+
Upload New Skill
38+
</Link>
39+
</div>
40+
41+
{skills.length === 0 ? (
42+
<div className="card dashboard-empty">
43+
<Package className="dashboard-empty-icon" aria-hidden="true" />
44+
<h2>No skills yet</h2>
45+
<p>Upload your first skill to share it with the community.</p>
46+
<Link to="/upload" className="btn btn-primary">
47+
<Upload className="h-4 w-4" aria-hidden="true" />
48+
Upload a Skill
49+
</Link>
50+
</div>
51+
) : (
52+
<div className="dashboard-grid">
53+
{skills.map((skill) => (
54+
<SkillCard key={skill._id} skill={skill} ownerHandle={ownerHandle} />
55+
))}
56+
</div>
57+
)}
58+
</main>
59+
)
60+
}
61+
62+
function SkillCard({
63+
skill,
64+
ownerHandle,
65+
}: {
66+
skill: Doc<'skills'>
67+
ownerHandle: string | null
68+
}) {
69+
return (
70+
<div className="dashboard-skill-card">
71+
<div className="dashboard-skill-info">
72+
{ownerHandle ? (
73+
<Link
74+
to="/$owner/$slug"
75+
params={{ owner: ownerHandle, slug: skill.slug }}
76+
className="dashboard-skill-name"
77+
>
78+
{skill.displayName}
79+
</Link>
80+
) : (
81+
<Link to="/skills/$slug" params={{ slug: skill.slug }} className="dashboard-skill-name">
82+
{skill.displayName}
83+
</Link>
84+
)}
85+
<span className="dashboard-skill-slug">/{skill.slug}</span>
86+
{skill.summary && (
87+
<p className="dashboard-skill-description">{skill.summary}</p>
88+
)}
89+
<div className="dashboard-skill-stats">
90+
<span>{skill.stats.downloads}</span>
91+
<span>{skill.stats.stars}</span>
92+
<span>{skill.stats.versions} v</span>
93+
</div>
94+
</div>
95+
<div className="dashboard-skill-actions">
96+
<Link
97+
to="/upload"
98+
search={{ updateSlug: skill.slug }}
99+
className="btn btn-sm"
100+
>
101+
<Upload className="h-3 w-3" aria-hidden="true" />
102+
New Version
103+
</Link>
104+
{ownerHandle ? (
105+
<Link
106+
to="/$owner/$slug"
107+
params={{ owner: ownerHandle, slug: skill.slug }}
108+
className="btn btn-ghost btn-sm"
109+
>
110+
View
111+
</Link>
112+
) : (
113+
<Link to="/skills/$slug" params={{ slug: skill.slug }} className="btn btn-ghost btn-sm">
114+
View
115+
</Link>
116+
)}
117+
</div>
118+
</div>
119+
)
120+
}

src/routes/upload.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { createFileRoute, useNavigate } from '@tanstack/react-router'
2-
import { useAction, useConvexAuth, useMutation } from 'convex/react'
1+
import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router'
2+
import { useAction, useConvexAuth, useMutation, useQuery } from 'convex/react'
33
import { useEffect, useMemo, useRef, useState } from 'react'
44
import semver from 'semver'
55
import { api } from '../../convex/_generated/api'
@@ -16,17 +16,25 @@ import {
1616
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
1717

1818
export const Route = createFileRoute('/upload')({
19+
validateSearch: (search) => ({
20+
updateSlug: typeof search.updateSlug === 'string' ? search.updateSlug : undefined,
21+
}),
1922
component: Upload,
2023
})
2124

2225
export function Upload() {
2326
const { isAuthenticated } = useConvexAuth()
27+
const { updateSlug } = useSearch({ from: '/upload' })
2428
const generateUploadUrl = useMutation(api.uploads.generateUploadUrl)
2529
const publishVersion = useAction(api.skills.publishVersion)
2630
const generateChangelogPreview = useAction(api.skills.generateChangelogPreview)
31+
const existingSkill = useQuery(
32+
api.skills.getBySlug,
33+
updateSlug ? { slug: updateSlug } : 'skip',
34+
)
2735
const [hasAttempted, setHasAttempted] = useState(false)
2836
const [files, setFiles] = useState<File[]>([])
29-
const [slug, setSlug] = useState('')
37+
const [slug, setSlug] = useState(updateSlug ?? '')
3038
const [displayName, setDisplayName] = useState('')
3139
const [version, setVersion] = useState('1.0.0')
3240
const [tags, setTags] = useState('latest')
@@ -79,6 +87,14 @@ export function Upload() {
7987
const trimmedName = displayName.trim()
8088
const trimmedChangelog = changelog.trim()
8189

90+
useEffect(() => {
91+
if (!existingSkill?.skill || !existingSkill?.latestVersion) return
92+
setSlug(existingSkill.skill.slug)
93+
setDisplayName(existingSkill.skill.displayName)
94+
const nextVersion = semver.inc(existingSkill.latestVersion.version, 'patch')
95+
if (nextVersion) setVersion(nextVersion)
96+
}, [existingSkill])
97+
8298
useEffect(() => {
8399
if (changelogTouchedRef.current) return
84100
if (trimmedChangelog) return

0 commit comments

Comments
 (0)