diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index b8f66f6..8f95859 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@cloudflare/vite-plugin": "^1.26.0", + "@quickhub/icons": "workspace:*", "@quickhub/ui": "workspace:*", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-devtools": "latest", @@ -32,6 +33,7 @@ "better-auth": "^1.6.0", "drizzle-orm": "^0.45.2", "lucide-react": "^0.545.0", + "next-themes": "^0.4.6", "octokit": "^5.0.5", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/apps/dashboard/src/components/layouts/dashboard-layout.tsx b/apps/dashboard/src/components/layouts/dashboard-layout.tsx new file mode 100644 index 0000000..d671b24 --- /dev/null +++ b/apps/dashboard/src/components/layouts/dashboard-layout.tsx @@ -0,0 +1,19 @@ +import { getRouteApi, Outlet } from "@tanstack/react-router"; +import { DashboardTopbar } from "./dashboard-topbar"; + +const routeApi = getRouteApi("/_protected"); + +export function DashboardLayout() { + const { user } = routeApi.useRouteContext(); + + return ( +
+ +
+
+ +
+
+
+ ); +} diff --git a/apps/dashboard/src/components/layouts/dashboard-topbar.tsx b/apps/dashboard/src/components/layouts/dashboard-topbar.tsx new file mode 100644 index 0000000..59e61f7 --- /dev/null +++ b/apps/dashboard/src/components/layouts/dashboard-topbar.tsx @@ -0,0 +1,156 @@ +import { + GitPullRequestIcon, + HomeIcon, + IssuesIcon, + MoonIcon, + MoreHorizontalIcon, + ReviewsIcon, + SunIcon, + SystemIcon, +} from "@quickhub/icons"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@quickhub/ui/components/avatar"; +import { Button } from "@quickhub/ui/components/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@quickhub/ui/components/dropdown-menu"; +import { Link } from "@tanstack/react-router"; +import { useTheme } from "next-themes"; +import { signOut } from "#/lib/auth.client"; + +interface DashboardTopbarProps { + user: { + name?: string | null; + email: string; + image?: string | null; + }; +} + +const navItems = [ + { to: "/", label: "Overview", icon: HomeIcon }, + { to: "/pull-requests", label: "Pull Requests", icon: GitPullRequestIcon }, + { to: "/issues", label: "Issues", icon: IssuesIcon }, + { to: "/reviews", label: "Reviews", icon: ReviewsIcon }, +] as const; + +const themeOptions = [ + { value: "light", icon: SunIcon, label: "Light" }, + { value: "dark", icon: MoonIcon, label: "Dark" }, + { value: "system", icon: SystemIcon, label: "System" }, +] as const; + +export function DashboardTopbar({ user }: DashboardTopbarProps) { + const { theme, setTheme } = useTheme(); + const displayName = user.name ?? user.email; + const initials = displayName + .split(" ") + .map((part) => part[0]) + .join("") + .slice(0, 2) + .toUpperCase(); + + return ( + + ); +} diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index efc0fe4..82bfb57 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -11,8 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as LoginRouteImport } from './routes/login' import { Route as ProtectedRouteImport } from './routes/_protected' -import { Route as IndexRouteImport } from './routes/index' -import { Route as ProtectedDashboardRouteImport } from './routes/_protected/dashboard' +import { Route as ProtectedIndexRouteImport } from './routes/_protected/index' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' const LoginRoute = LoginRouteImport.update({ @@ -24,14 +23,9 @@ const ProtectedRoute = ProtectedRouteImport.update({ id: '/_protected', getParentRoute: () => rootRouteImport, } as any) -const IndexRoute = IndexRouteImport.update({ +const ProtectedIndexRoute = ProtectedIndexRouteImport.update({ id: '/', path: '/', - getParentRoute: () => rootRouteImport, -} as any) -const ProtectedDashboardRoute = ProtectedDashboardRouteImport.update({ - id: '/dashboard', - path: '/dashboard', getParentRoute: () => ProtectedRoute, } as any) const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ @@ -41,41 +35,31 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ } as any) export interface FileRoutesByFullPath { - '/': typeof IndexRoute + '/': typeof ProtectedIndexRoute '/login': typeof LoginRoute - '/dashboard': typeof ProtectedDashboardRoute '/api/auth/$': typeof ApiAuthSplatRoute } export interface FileRoutesByTo { - '/': typeof IndexRoute '/login': typeof LoginRoute - '/dashboard': typeof ProtectedDashboardRoute + '/': typeof ProtectedIndexRoute '/api/auth/$': typeof ApiAuthSplatRoute } export interface FileRoutesById { __root__: typeof rootRouteImport - '/': typeof IndexRoute '/_protected': typeof ProtectedRouteWithChildren '/login': typeof LoginRoute - '/_protected/dashboard': typeof ProtectedDashboardRoute + '/_protected/': typeof ProtectedIndexRoute '/api/auth/$': typeof ApiAuthSplatRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/login' | '/dashboard' | '/api/auth/$' + fullPaths: '/' | '/login' | '/api/auth/$' fileRoutesByTo: FileRoutesByTo - to: '/' | '/login' | '/dashboard' | '/api/auth/$' - id: - | '__root__' - | '/' - | '/_protected' - | '/login' - | '/_protected/dashboard' - | '/api/auth/$' + to: '/login' | '/' | '/api/auth/$' + id: '__root__' | '/_protected' | '/login' | '/_protected/' | '/api/auth/$' fileRoutesById: FileRoutesById } export interface RootRouteChildren { - IndexRoute: typeof IndexRoute ProtectedRoute: typeof ProtectedRouteWithChildren LoginRoute: typeof LoginRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute @@ -97,18 +81,11 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProtectedRouteImport parentRoute: typeof rootRouteImport } - '/': { - id: '/' + '/_protected/': { + id: '/_protected/' path: '/' fullPath: '/' - preLoaderRoute: typeof IndexRouteImport - parentRoute: typeof rootRouteImport - } - '/_protected/dashboard': { - id: '/_protected/dashboard' - path: '/dashboard' - fullPath: '/dashboard' - preLoaderRoute: typeof ProtectedDashboardRouteImport + preLoaderRoute: typeof ProtectedIndexRouteImport parentRoute: typeof ProtectedRoute } '/api/auth/$': { @@ -122,11 +99,11 @@ declare module '@tanstack/react-router' { } interface ProtectedRouteChildren { - ProtectedDashboardRoute: typeof ProtectedDashboardRoute + ProtectedIndexRoute: typeof ProtectedIndexRoute } const ProtectedRouteChildren: ProtectedRouteChildren = { - ProtectedDashboardRoute: ProtectedDashboardRoute, + ProtectedIndexRoute: ProtectedIndexRoute, } const ProtectedRouteWithChildren = ProtectedRoute._addFileChildren( @@ -134,7 +111,6 @@ const ProtectedRouteWithChildren = ProtectedRoute._addFileChildren( ) const rootRouteChildren: RootRouteChildren = { - IndexRoute: IndexRoute, ProtectedRoute: ProtectedRouteWithChildren, LoginRoute: LoginRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, diff --git a/apps/dashboard/src/routes/__root.tsx b/apps/dashboard/src/routes/__root.tsx index 09a9bb0..fd50291 100644 --- a/apps/dashboard/src/routes/__root.tsx +++ b/apps/dashboard/src/routes/__root.tsx @@ -7,6 +7,7 @@ import { } from "@tanstack/react-router"; import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; import { Agentation } from "agentation"; +import { ThemeProvider } from "next-themes"; import appCss from "../styles.css?url"; @@ -48,5 +49,9 @@ function RootDocument({ children }: { children: React.ReactNode }) { } function RootComponent() { - return ; + return ( + + + + ); } diff --git a/apps/dashboard/src/routes/_protected.tsx b/apps/dashboard/src/routes/_protected.tsx index 1784981..c7b69ed 100644 --- a/apps/dashboard/src/routes/_protected.tsx +++ b/apps/dashboard/src/routes/_protected.tsx @@ -1,4 +1,5 @@ -import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { DashboardLayout } from "#/components/layouts/dashboard-layout"; import { getSession } from "#/lib/auth.functions"; export const Route = createFileRoute("/_protected")({ @@ -12,5 +13,5 @@ export const Route = createFileRoute("/_protected")({ } return { user: session.user, session: session.session }; }, - component: () => , + component: DashboardLayout, }); diff --git a/apps/dashboard/src/routes/_protected/dashboard.tsx b/apps/dashboard/src/routes/_protected/dashboard.tsx deleted file mode 100644 index 03159a8..0000000 --- a/apps/dashboard/src/routes/_protected/dashboard.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { - Avatar, - AvatarFallback, - AvatarImage, -} from "@quickhub/ui/components/avatar"; -import { Badge } from "@quickhub/ui/components/badge"; -import { Button } from "@quickhub/ui/components/button"; -import { - Card, - CardDescription, - CardHeader, - CardTitle, -} from "@quickhub/ui/components/card"; -import { Logo } from "@quickhub/ui/components/logo"; -import { createFileRoute } from "@tanstack/react-router"; -import { signOut } from "#/lib/auth.client"; - -export const Route = createFileRoute("/_protected/dashboard")({ - component: DashboardPage, -}); - -function DashboardPage() { - const { user } = Route.useRouteContext(); - const displayName = user.name ?? user.email ?? "QuickHub user"; - const initials = displayName - .split(" ") - .map((part) => part[0]) - .join("") - .slice(0, 2) - .toUpperCase(); - - return ( -
-
-
- -
-

- QuickHub -

-

Workspace

-
-
- - - -
- - - {initials} - -
-
- - Welcome, {displayName} - - Connected -
- - The dashboard now pulls from the shared Circle-derived - component library in @quickhub/ui. - -
-
- -
-
- -
- - - Theme - - Circle tokens now define the workspace palette and surface - language. - - - - - - Primitives - - Shared buttons, cards, badges, dialogs, forms, and more now live - in the UI package. - - - - - - Next step - - Build QuickHub-specific layouts on top of these imported - foundation pieces. - - - -
-
-
- ); -} diff --git a/apps/dashboard/src/routes/_protected/index.tsx b/apps/dashboard/src/routes/_protected/index.tsx new file mode 100644 index 0000000..b56024d --- /dev/null +++ b/apps/dashboard/src/routes/_protected/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/_protected/")({ + component: OverviewPage, +}); + +function OverviewPage() { + return
; +} diff --git a/apps/dashboard/src/routes/index.tsx b/apps/dashboard/src/routes/index.tsx deleted file mode 100644 index 559d987..0000000 --- a/apps/dashboard/src/routes/index.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { Badge } from "@quickhub/ui/components/badge"; -import { Button } from "@quickhub/ui/components/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@quickhub/ui/components/card"; -import { Logo } from "@quickhub/ui/components/logo"; -import { createFileRoute, Link } from "@tanstack/react-router"; -import { getSession } from "#/lib/auth.functions"; -import { getUserRepos } from "#/lib/github.functions"; - -export const Route = createFileRoute("/")({ - beforeLoad: async () => { - const session = await getSession(); - return { session }; - }, - loader: async ({ context }) => { - if (!context.session) return { repos: [] }; - const repos = await getUserRepos(); - return { repos }; - }, - component: Home, -}); - -function Home() { - const { session } = Route.useRouteContext(); - const { repos } = Route.useLoaderData(); - const ctaCopy = session ? "Open dashboard" : "Continue with GitHub"; - const ctaLink = session ? "/dashboard" : "/login"; - - return ( -
-
-
- - -
-
- -
-

- QuickHub -

-

- Placeholder logo -

-
-
- - Shared UI baseline - -
-
- - QuickHub, now on a shared UI foundation. - - - Circle's tokens and primitives are now the baseline for - QuickHub, so the app can grow from a consistent design system - instead of ad hoc styles. - -
-
- - -

- Shared theme tokens live in @quickhub/ui and can - now drive future screens. -

-
-
- - - - Base kit - - Imported primitives ready to extend. - - - -
- Theme tokens - active -
-
- 32 UI primitives - shared -
-
- Dashboard wired - validated -
-
-
-
- - {repos.length > 0 && ( -
-
-

- Recent repositories -

-

- Current data rendered with the shared card, badge, and button - language. -

-
-
- {repos.map((repo) => ( - - -
-
- - - {repo.name} - - - - {repo.description ?? - "A focused repository surfaced from your GitHub account."} - -
- {repo.isPrivate ? ( - Private - ) : ( - Public - )} -
-
- -
- {repo.language ? {repo.language} : null} - {repo.stars ? {repo.stars} stars : null} -
- -
-
- ))} -
-
- )} -
-
- ); -} diff --git a/apps/dashboard/src/routes/login.tsx b/apps/dashboard/src/routes/login.tsx index fca9a33..fed8861 100644 --- a/apps/dashboard/src/routes/login.tsx +++ b/apps/dashboard/src/routes/login.tsx @@ -14,7 +14,7 @@ import { getSession } from "#/lib/auth.functions"; export const Route = createFileRoute("/login")({ beforeLoad: async () => { const session = await getSession(); - if (session) throw redirect({ to: "/dashboard" }); + if (session) throw redirect({ to: "/" }); }, component: LoginPage, }); diff --git a/biome.jsonc b/biome.jsonc index 7bd5617..81df3ef 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,4 +1,16 @@ { "$schema": "https://biomejs.dev/schemas/2.4.5/schema.json", - "extends": ["ultracite/biome/core"] + "extends": ["ultracite/biome/core"], + "overrides": [ + { + "includes": ["packages/icons/**"], + "linter": { + "rules": { + "performance": { + "noBarrelFile": "off" + } + } + } + } + ] } diff --git a/packages/icons/package.json b/packages/icons/package.json new file mode 100644 index 0000000..0699250 --- /dev/null +++ b/packages/icons/package.json @@ -0,0 +1,27 @@ +{ + "name": "@quickhub/icons", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "check-types": "tsc --noEmit", + "lint": "biome lint", + "check": "biome check", + "format": "biome format" + }, + "dependencies": { + "@hugeicons/react": "^0.3.0" + }, + "devDependencies": { + "@biomejs/biome": "2.4.5", + "@quickhub/typescript-config": "workspace:*", + "@types/react": "^19.2.0", + "typescript": "^5.7.2" + }, + "peerDependencies": { + "react": "^19.2.0" + } +} diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts new file mode 100644 index 0000000..4725aeb --- /dev/null +++ b/packages/icons/src/index.ts @@ -0,0 +1,21 @@ +// Re-export icons with stable app-friendly names. +// If we switch from hugeicons to another library, only this file changes. + +export { + AddCircleHalfDotIcon as IssuesIcon, + Bug01Icon as BugIcon, + CheckListIcon as ReviewsIcon, + CodeIcon, + ComputerIcon as SystemIcon, + DashboardSquare01Icon as DashboardIcon, + GitBranchIcon, + GitPullRequestIcon, + Home01Icon as HomeIcon, + InboxIcon, + Moon01Icon as MoonIcon, + MoreHorizontalIcon, + Notification01Icon as NotificationIcon, + Search01Icon as SearchIcon, + Settings01Icon as SettingsIcon, + Sun01Icon as SunIcon, +} from "@hugeicons/react"; diff --git a/packages/icons/tsconfig.json b/packages/icons/tsconfig.json new file mode 100644 index 0000000..fd89060 --- /dev/null +++ b/packages/icons/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@quickhub/typescript-config/react-library.json", + "include": ["src"] +} diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx index 8122c7c..719fb6b 100644 --- a/packages/ui/src/components/button.tsx +++ b/packages/ui/src/components/button.tsx @@ -5,7 +5,7 @@ import type * as React from "react"; import { cn } from "../lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-[13px] font-medium transition-[color,background-color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { diff --git a/packages/ui/src/components/dropdown-menu.tsx b/packages/ui/src/components/dropdown-menu.tsx index bd0d9e9..7f4690d 100644 --- a/packages/ui/src/components/dropdown-menu.tsx +++ b/packages/ui/src/components/dropdown-menu.tsx @@ -74,7 +74,7 @@ function DropdownMenuItem({ data-inset={inset} data-variant={variant} className={cn( - "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 font-medium outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className, )} {...props} @@ -92,7 +92,7 @@ function DropdownMenuCheckboxItem({ ); @@ -178,17 +175,30 @@ function DropdownMenuSeparator({ function DropdownMenuShortcut({ className, + keys, + children, ...props -}: React.ComponentProps<"span">) { +}: React.ComponentProps<"span"> & { + keys?: string[]; +}) { return ( + > + {keys + ? keys.map((key, i) => ( + + {i > 0 && then} + {key} + + )) + : children} + ); } @@ -211,7 +221,7 @@ function DropdownMenuSubTrigger({ data-slot="dropdown-menu-sub-trigger" data-inset={inset} className={cn( - "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8", + "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-md px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8", className, )} {...props} diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index e6cf795..244d352 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -103,7 +103,7 @@ function SelectItem({
{children}
@@ -311,7 +311,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
=16.0.0' + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -4998,6 +5031,10 @@ snapshots: '@fontsource/geist-mono@5.2.7': {} + '@hugeicons/react@0.3.4(react@19.2.4)': + dependencies: + react: 19.2.4 + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5':