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:
133
src/features/playlists/components/playlist-detail.tsx
Normal file
133
src/features/playlists/components/playlist-detail.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
76
src/features/playlists/components/playlists-list.tsx
Normal file
76
src/features/playlists/components/playlists-list.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
62
src/features/playlists/hooks/use-playlist-detail.ts
Normal file
62
src/features/playlists/hooks/use-playlist-detail.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/features/playlists/hooks/use-playlists.ts
Normal file
1
src/features/playlists/hooks/use-playlists.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { usePlaylists } from "@/shared/db/hooks"
|
||||||
@@ -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")({
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
|
import { PlaylistsList } from "@/features/playlists/components/playlists-list"
|
||||||
|
import { t } from "@/shared/i18n"
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
|
||||||
export const Route = createFileRoute("/playlists/")({
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user