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
@@ -0,0 +1,104 @@
"use client"
import { useState, useEffect } from "react"
import { Bookmark, Loader2 } from "lucide-react"
import { motion, AnimatePresence } from "motion/react"
import { useSession } from "@/lib/auth-client"
interface BookmarkButtonProps {
gameId: string
className?: string
}
export function BookmarkButton({ gameId, className = "" }: BookmarkButtonProps) {
const { data: session } = useSession()
const [isSaved, setIsSaved] = useState(false)
const [isLoading, setIsLoading] = useState(() => !session)
const [isToggling, setIsToggling] = useState(false)
useEffect(() => {
if (!session) return
async function checkSaved() {
try {
const res = await fetch(`/api/user/me/saved-games/check/${gameId}`)
if (res.ok) {
const data = await res.json()
setIsSaved(data.saved)
}
} catch (err) {
console.error("Failed to check saved status:", err)
} finally {
setIsLoading(false)
}
}
checkSaved()
}, [session, gameId])
const toggleSave = async () => {
if (!session || isToggling) return
setIsToggling(true)
try {
if (isSaved) {
const res = await fetch(`/api/user/me/saved-games/${gameId}`, {
method: "DELETE",
})
if (res.ok) {
setIsSaved(false)
}
} else {
const res = await fetch("/api/user/me/saved-games", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ gameId }),
})
if (res.ok) {
setIsSaved(true)
}
}
} catch (err) {
console.error("Failed to toggle saved game:", err)
} finally {
setIsToggling(false)
}
}
if (!session || isLoading) {
return null
}
return (
<motion.button
whileTap={{ scale: 0.9 }}
onClick={toggleSave}
disabled={isToggling}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors cursor-pointer ${
isSaved
? "bg-primary/10 border-primary/30 text-primary"
: "bg-text/5 border-border text-text/50 hover:text-text hover:border-primary/30"
} ${className}`}
>
<AnimatePresence mode="wait">
{isToggling ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<motion.div
key={isSaved ? "saved" : "not-saved"}
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
exit={{ scale: 0.8 }}
>
<Bookmark
className={`h-4 w-4 ${isSaved ? "fill-primary" : ""}`}
/>
</motion.div>
)}
</AnimatePresence>
<span className="text-sm font-medium">
{isSaved ? "Saved" : "Save"}
</span>
</motion.button>
)
}
@@ -0,0 +1,133 @@
"use client"
import { useState, useEffect } from "react"
import Image from "next/image"
import Link from "next/link"
import { Bookmark, Loader2, X, Gamepad2 } from "lucide-react"
import { motion, AnimatePresence } from "motion/react"
interface SavedGame {
id: string
gameId: string
createdAt: string
gameTitle: string
gameHeaderImage: string | null
gameCapsuleImage: string | null
gameSteamAppId: number | null
}
export function SavedGamesGrid() {
const [games, setGames] = useState<SavedGame[]>([])
const [isLoading, setIsLoading] = useState(true)
const [removingId, setRemovingId] = useState<string | null>(null)
useEffect(() => {
async function fetchSaved() {
try {
const res = await fetch("/api/user/me/saved-games")
if (res.ok) {
setGames(await res.json())
}
} catch (err) {
console.error("Failed to fetch saved games:", err)
} finally {
setIsLoading(false)
}
}
fetchSaved()
}, [])
const removeGame = async (id: string, gameId: string) => {
setRemovingId(id)
try {
const res = await fetch(`/api/user/me/saved-games/${gameId}`, {
method: "DELETE",
})
if (res.ok) {
setGames((prev) => prev.filter((g) => g.id !== id))
}
} catch (err) {
console.error("Failed to remove saved game:", err)
} finally {
setRemovingId(null)
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
)
}
if (games.length === 0) {
return (
<div className="text-center py-12 text-text/40">
<Bookmark className="h-8 w-8 mx-auto mb-2" />
<p>No saved games yet</p>
<p className="text-xs mt-1">
Save games from their detail pages to see them here
</p>
</div>
)
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<AnimatePresence>
{games.map((game) => (
<motion.div
key={game.id}
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="relative group"
>
<Link
href={`/game/${game.gameId}`}
className="block rounded-xl overflow-hidden border border-border hover:border-primary/30 transition-colors"
>
{game.gameHeaderImage ? (
<Image
src={game.gameHeaderImage}
alt={game.gameTitle}
width={300}
height={140}
unoptimized
className="w-full h-32 object-cover"
/>
) : (
<div className="w-full h-32 bg-text/5 flex items-center justify-center">
<Gamepad2 className="h-8 w-8 text-text/20" />
</div>
)}
<div className="p-3">
<p className="text-sm font-medium truncate">
{game.gameTitle}
</p>
<p className="text-xs text-text/40 mt-1">
Saved {new Date(game.createdAt).toLocaleDateString()}
</p>
</div>
</Link>
<button
onClick={() => removeGame(game.id, game.gameId)}
disabled={removingId === game.id}
className="absolute top-2 right-2 p-1.5 rounded-full bg-black/50 text-text/70 hover:text-red-400 hover:bg-black/70 transition-colors opacity-0 group-hover:opacity-100 cursor-pointer disabled:cursor-not-allowed"
>
{removingId === game.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<X className="h-3 w-3" />
)}
</button>
</motion.div>
))}
</AnimatePresence>
</div>
)
}