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")({
|
||||
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"
|
||||
|
||||
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