refactor: convert to bun workspaces monorepo

- Move web app into apps/web/
- Create packages/shared/ with shared types
- Create plugins/decky-vault/ scaffold
- Root package.json manages workspaces only
This commit is contained in:
2026-06-28 05:20:28 +08:00
parent c4bede20d4
commit cd72b7a948
345 changed files with 488 additions and 126 deletions
+118
View File
@@ -0,0 +1,118 @@
import { notFound } from "next/navigation"
import { db } from "@/lib/db/index"
import { user, performanceEntries, games, gameVersions, hardware } from "@/lib/db/schema"
import { eq, sql, and, desc } from "drizzle-orm"
import { ProfilePageClient } from "./profile-page-client"
// This page needs live data — skip static generation at build time
export const dynamic = "force-dynamic"
export const metadata = {
title: "Profile",
}
export default async function ProfilePage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const [profile] = await db
.select({
id: user.id,
name: user.name,
image: user.image,
role: user.role,
createdAt: user.createdAt,
emailVerified: user.emailVerified,
})
.from(user)
.where(eq(user.id, id))
.limit(1)
if (!profile) {
notFound()
}
// Count contributions
const [{ count: contributions }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(performanceEntries)
.where(and(
eq(performanceEntries.userId, id),
eq(performanceEntries.isRemoved, false)
))
// Count verified entries
const [{ count: verifiedEntries }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(performanceEntries)
.where(and(
eq(performanceEntries.userId, id),
eq(performanceEntries.isRemoved, false),
sql`${performanceEntries.verifiedAt} IS NOT NULL`
))
// Calculate reputation (upvotes - downvotes)
const reputationResult = await db
.select({
totalUpvotes: sql<number>`coalesce(sum(${performanceEntries.upvotes}), 0)::int`,
totalDownvotes: sql<number>`coalesce(sum(${performanceEntries.downvotes}), 0)::int`,
})
.from(performanceEntries)
.where(and(
eq(performanceEntries.userId, id),
eq(performanceEntries.isRemoved, false),
))
// Fetch recent contributions (last 5)
const recentContributions = await db
.select({
id: performanceEntries.id,
fpsAvg: performanceEntries.fpsAvg,
fpsLow: performanceEntries.fpsLow,
fpsHigh: performanceEntries.fpsHigh,
hardwareSlug: performanceEntries.hardwareSlug,
hardwareName: hardware.name,
upscalerType: performanceEntries.upscalerType,
upscalerVersion: performanceEntries.upscalerVersion,
frameGenMethod: performanceEntries.frameGenMethod,
verifiedAt: performanceEntries.verifiedAt,
createdAt: performanceEntries.createdAt,
gameTitle: games.title,
gameId: games.id,
gameHeaderImage: games.headerImage,
})
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.innerJoin(games, eq(gameVersions.gameId, games.id))
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
.where(and(
eq(performanceEntries.userId, id),
eq(performanceEntries.isRemoved, false)
))
.orderBy(desc(performanceEntries.createdAt))
.limit(5)
return (
<ProfilePageClient
profile={{
...profile,
createdAt: profile.createdAt.toISOString(),
contributions,
verifiedEntries,
reputation: Math.max(0,
(reputationResult[0]?.totalUpvotes ?? 0) -
(reputationResult[0]?.totalDownvotes ?? 0)
),
verified: !!profile.emailVerified,
}}
recentContributions={recentContributions.map((e) => ({
...e,
createdAt: e.createdAt.toISOString(),
verifiedAt: e.verifiedAt?.toISOString() ?? null,
}))}
/>
)
}
@@ -0,0 +1,60 @@
"use client"
import { ProfileHeader } from "@/components/profile/profile-header"
import { StatsRow } from "@/components/profile/stats-row"
import { ContributionList } from "@/components/profile/contribution-list"
import type { ContributionEntry } from "@/types/api"
interface ProfilePageClientProps {
profile: {
id: string
name: string
image: string | null
role: string | null
createdAt: string
contributions: number
verifiedEntries: number
reputation: number
verified: boolean
}
recentContributions: ContributionEntry[]
}
export function ProfilePageClient({ profile, recentContributions }: ProfilePageClientProps) {
return (
<div className="w-full flex flex-col gap-8 pb-16">
<div className="px-4 md:px-[10svw]">
<div className="max-w-7xl mx-auto">
<ProfileHeader
name={profile.name}
role={profile.role}
verified={profile.verified}
createdAt={profile.createdAt}
image={profile.image}
/>
</div>
</div>
<div className="px-4 md:px-[10svw]">
<div className="max-w-7xl mx-auto">
<StatsRow
contributions={profile.contributions}
verifiedEntries={profile.verifiedEntries}
reputation={profile.reputation}
/>
</div>
</div>
<div className="px-4 md:px-[10svw]">
<div className="max-w-7xl mx-auto">
<h2 className="text-lg font-semibold mb-4">Recent Contributions</h2>
<ContributionList
entries={recentContributions}
showViewAll
totalCount={profile.contributions}
/>
</div>
</div>
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next"
export const metadata: Metadata = {
title: "Profile",
description:
"Your DeckyVault profile — saved games, benchmark contributions, and performance history.",
robots: { index: false, follow: false },
}
export default function ProfileLayout({ children }: { children: React.ReactNode }) {
return children
}
+193
View File
@@ -0,0 +1,193 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { useSession } from "@/lib/auth-client"
import { ProfileHeader } from "@/components/profile/profile-header"
import { StatsRow } from "@/components/profile/stats-row"
import { ContributionList } from "@/components/profile/contribution-list"
import { SavedGamesGrid } from "@/components/saved-games/saved-games-grid"
import { Bookmark, Settings, Loader2, TrendingUp } from "lucide-react"
import { SettingsContainer } from "@/components/profile/settings-container"
import { motion } from "motion/react"
import type { ContributionEntry } from "@/types/api"
type Tab = "overview" | "saved" | "settings"
export default function ProfilePage() {
const router = useRouter()
const { data: session, isPending: isSessionLoading } = useSession()
const [activeTab, setActiveTab] = useState<Tab>("overview")
const [profile, setProfile] = useState<{
id: string
name: string
email: string
image: string | null
role: string | null
createdAt: string
contributions: number
verifiedEntries: number
reputation: number
verified: boolean
} | null>(null)
const [contributions, setContributions] = useState<ContributionEntry[]>([])
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
if (!isSessionLoading && !session) {
router.push(`/login?redirect=${encodeURIComponent(window.location.pathname)}`)
}
}, [session, isSessionLoading, router])
useEffect(() => {
if (!session) return
async function fetchProfile() {
try {
const [profileRes, contribRes] = await Promise.all([
fetch("/api/user/me"),
fetch(
"/api/user/profile/" +
session!.user.id +
"/contributions?limit=10",
),
])
if (profileRes.ok) {
setProfile(await profileRes.json())
}
if (contribRes.ok) {
const data = await contribRes.json()
setContributions(data.data)
}
} catch (err) {
console.error("Failed to fetch profile:", err)
} finally {
setIsLoading(false)
}
}
fetchProfile()
}, [session])
if (isSessionLoading || isLoading) {
return (
<div className='flex items-center justify-center min-h-[50vh]'>
<Loader2 className='h-8 w-8 animate-spin text-primary' />
</div>
)
}
if (!session || !profile) {
return null
}
const handleImageChange = (url: string | null) => {
setProfile((prev) => (prev ? { ...prev, image: url } : null))
}
const tabs: { id: Tab; label: string; icon: typeof Bookmark }[] = [
{ id: "overview", label: "Overview", icon: TrendingUp },
{ id: "saved", label: "Saved Games", icon: Bookmark },
{ id: "settings", label: "Settings", icon: Settings },
]
return (
<div className='w-full flex flex-col gap-8 pt-4'>
{/* Profile header section */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className='px-4 md:px-[10svw]'
>
<div className='max-w-7xl mx-auto'>
<ProfileHeader
name={profile.name}
email={profile.email}
role={profile.role}
verified={profile.verified}
createdAt={profile.createdAt}
image={profile.image}
/>
</div>
</motion.div>
{/* Stats section */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
className='px-4 md:px-[10svw]'
>
<div className='max-w-7xl mx-auto'>
<StatsRow
contributions={profile.contributions}
verifiedEntries={profile.verifiedEntries}
reputation={profile.reputation}
/>
</div>
</motion.div>
{/* Tabs + content section */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.15 }}
className='px-4 md:px-[10svw]'
>
<div className='max-w-7xl mx-auto'>
{/* Tabs */}
<div className='flex gap-1 border-b border-border'>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px cursor-pointer ${
activeTab === tab.id
? "border-primary text-primary"
: "border-transparent text-text/50 hover:text-text/70"
}`}
>
<tab.icon className='h-4 w-4' />
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
className='pt-6'
>
{activeTab === "overview" && (
<div>
<h2 className='text-lg font-semibold mb-4'>
Recent Contributions
</h2>
<ContributionList entries={contributions} />
</div>
)}
{activeTab === "saved" && <SavedGamesGrid />}
{activeTab === "settings" && profile && (
<SettingsContainer
name={profile.name}
email={profile.email}
role={profile.role}
createdAt={profile.createdAt}
image={profile.image}
userId={profile.id}
onImageChange={handleImageChange}
/>
)}
</motion.div>
</div>
</motion.div>
</div>
)
}