add playlists feature: static + custom playlists, CRUD, add/remove games

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 14:34:25 +01:00
parent e51a01123e
commit 7e71098658
6 changed files with 302 additions and 3 deletions

View File

@@ -0,0 +1,133 @@
import { GameCard } from "@/features/games/components/game-card"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { t } from "@/shared/i18n"
import { useNavigate } from "@tanstack/react-router"
import { Plus, Trash2 } from "lucide-react"
import { useState } from "react"
import { usePlaylistDetail } from "../hooks/use-playlist-detail"
interface PlaylistDetailProps {
playlistId: string
}
export function PlaylistDetail({ playlistId }: PlaylistDetailProps) {
const {
playlist,
games,
loading,
reload,
searchText,
setSearchText,
searchResults,
addGame,
removeGame,
rename,
deletePlaylist,
} = usePlaylistDetail(playlistId)
const navigate = useNavigate()
const [editingName, setEditingName] = useState(false)
const [name, setName] = useState("")
if (loading || !playlist) return null
const isCustom = !playlist.is_static
const handleStartRename = () => {
setName(playlist.name)
setEditingName(true)
}
const handleFinishRename = () => {
if (name.trim() && name !== playlist.name) {
rename(name.trim())
}
setEditingName(false)
}
const handleDelete = async () => {
if (!window.confirm(`Delete "${playlist.name}"?`)) return
await deletePlaylist()
navigate({ to: "/playlists" })
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
{editingName ? (
<Input
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={handleFinishRename}
onKeyDown={(e) => e.key === "Enter" && handleFinishRename()}
autoFocus
className="text-xl font-bold"
/>
) : (
<h2
className={`text-xl font-bold ${isCustom ? "cursor-pointer" : ""}`}
onClick={isCustom ? handleStartRename : undefined}
onKeyDown={undefined}
role={isCustom ? "button" : undefined}
tabIndex={isCustom ? 0 : undefined}
>
{playlist.name}
</h2>
)}
{isCustom && (
<Button variant="ghost" size="icon" onClick={handleDelete}>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
<div>
<Input
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder={t("playlists.addGames")}
/>
{searchResults.length > 0 && (
<div className="mt-2 space-y-1">
{searchResults.map((game) => (
<button
key={game.id}
type="button"
className="flex w-full cursor-pointer items-center justify-between rounded-lg border p-3"
onClick={() => {
addGame(game)
setSearchText("")
}}
>
<span className="truncate text-sm">{game.title}</span>
<Plus className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
))}
</div>
)}
</div>
{games.length === 0 ? (
<p className="py-4 text-center text-muted-foreground">{t("playlists.noGames")}</p>
) : (
<div className="space-y-3">
{games.map((game) => (
<div key={game.id} className="flex items-start gap-2">
<div className="flex-1">
<GameCard game={game} onUpdate={reload} />
</div>
<Button
variant="ghost"
size="icon"
className="mt-4 shrink-0"
onClick={() => removeGame(game.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,76 @@
import { Button } from "@/shared/components/ui/button"
import { Card } from "@/shared/components/ui/card"
import { usePlaylistMutations, usePlaylists } from "@/shared/db/hooks"
import { t } from "@/shared/i18n"
import { Link } from "@tanstack/react-router"
import { Heart, ListMusic, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react"
const staticIcons: Record<string, React.ComponentType<{ className?: string }>> = {
favorites: Heart,
"want-to-play": ThumbsUp,
"not-interesting": ThumbsDown,
}
export function PlaylistsList() {
const { playlists, reload } = usePlaylists()
const { createPlaylist, deletePlaylist } = usePlaylistMutations()
const handleCreate = async () => {
await createPlaylist("New Playlist")
reload()
}
const handleDelete = async (id: string) => {
await deletePlaylist(id)
reload()
}
const staticPlaylists = playlists.filter((p) => p.is_static)
const customPlaylists = playlists.filter((p) => !p.is_static)
return (
<div className="space-y-4">
<div className="space-y-2">
{staticPlaylists.map((p) => {
const Icon = staticIcons[p.id] ?? ListMusic
return (
<Link key={p.id} to="/playlists/$playlistId" params={{ playlistId: p.id }}>
<Card className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
<Icon className="h-5 w-5 text-muted-foreground" />
<span className="font-medium">{p.name}</span>
</div>
<span className="text-sm text-muted-foreground">{p.game_count}</span>
</Card>
</Link>
)
})}
</div>
{customPlaylists.length > 0 && (
<div className="space-y-2">
{customPlaylists.map((p) => (
<div key={p.id} className="flex items-center gap-2">
<Link to="/playlists/$playlistId" params={{ playlistId: p.id }} className="flex-1">
<Card className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
<ListMusic className="h-5 w-5 text-muted-foreground" />
<span className="font-medium">{p.name}</span>
</div>
<span className="text-sm text-muted-foreground">{p.game_count}</span>
</Card>
</Link>
<Button variant="ghost" size="icon" onClick={() => handleDelete(p.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
<Button onClick={handleCreate} variant="secondary" className="w-full">
{t("playlists.create")}
</Button>
</div>
)
}

View File

@@ -0,0 +1,62 @@
import { useGames, usePlaylist, usePlaylistMutations } from "@/shared/db/hooks"
import type { Game } from "@/shared/db/schema"
import { useCallback, useMemo, useState } from "react"
export function usePlaylistDetail(id: string) {
const { playlist, games, loading, reload } = usePlaylist(id)
const { addGame, removeGame, renamePlaylist, deletePlaylist } = usePlaylistMutations()
const { games: allGames } = useGames()
const [searchText, setSearchText] = useState("")
const gameIds = useMemo(() => new Set(games.map((g) => g.id)), [games])
const searchResults = useMemo(() => {
if (!searchText) return []
const q = searchText.toLowerCase()
return allGames
.filter((g) => !gameIds.has(g.id) && g.title.toLowerCase().includes(q))
.slice(0, 20)
}, [allGames, gameIds, searchText])
const handleAddGame = useCallback(
async (game: Game) => {
await addGame(id, game.id)
reload()
},
[id, addGame, reload],
)
const handleRemoveGame = useCallback(
async (gameId: string) => {
await removeGame(id, gameId)
reload()
},
[id, removeGame, reload],
)
const handleRename = useCallback(
async (name: string) => {
await renamePlaylist(id, name)
reload()
},
[id, renamePlaylist, reload],
)
const handleDelete = useCallback(async () => {
await deletePlaylist(id)
}, [id, deletePlaylist])
return {
playlist,
games,
loading,
reload,
searchText,
setSearchText,
searchResults,
addGame: handleAddGame,
removeGame: handleRemoveGame,
rename: handleRename,
deletePlaylist: handleDelete,
}
}

View File

@@ -0,0 +1 @@
export { usePlaylists } from "@/shared/db/hooks"

View File

@@ -1,5 +1,21 @@
import { createFileRoute } from "@tanstack/react-router"
import { PlaylistDetail } from "@/features/playlists/components/playlist-detail"
import { Link, createFileRoute } from "@tanstack/react-router"
import { ArrowLeft } from "lucide-react"
export const Route = createFileRoute("/playlists/$playlistId")({
component: () => <div>Playlist Detail</div>,
component: PlaylistDetailPage,
})
function PlaylistDetailPage() {
const { playlistId } = Route.useParams()
return (
<div className="mx-auto max-w-lg p-4">
<Link to="/playlists" className="mb-4 flex items-center gap-1 text-sm text-muted-foreground">
<ArrowLeft className="h-4 w-4" />
Back
</Link>
<PlaylistDetail playlistId={playlistId} />
</div>
)
}

View File

@@ -1,5 +1,16 @@
import { PlaylistsList } from "@/features/playlists/components/playlists-list"
import { t } from "@/shared/i18n"
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/playlists/")({
component: () => <div>Playlists</div>,
component: PlaylistsPage,
})
function PlaylistsPage() {
return (
<div className="mx-auto max-w-lg p-4">
<h1 className="mb-6 text-2xl font-bold">{t("playlists.title")}</h1>
<PlaylistsList />
</div>
)
}