add favorites, add tinder playlists

This commit is contained in:
2026-02-06 23:38:34 +01:00
parent 11c3f141d5
commit bc22a6b5a0
7 changed files with 552 additions and 178 deletions

View File

@@ -21,6 +21,7 @@ import DiscoverPage from "./pages/Discover/DiscoverPage";
import HomePage from "./pages/Home/HomePage";
import LibraryPage from "./pages/Library/LibraryPage";
import PlaylistsPage from "./pages/Playlists/PlaylistsPage";
import PlaylistDetailPage from "./pages/Playlists/PlaylistDetailPage";
import SettingsPage from "./pages/Settings/SettingsPage";
import SettingsDetailPage from "./pages/Settings/SettingsDetailPage";
@@ -36,6 +37,11 @@ export default function App() {
<Route exact path="/home" component={HomePage} />
<Route exact path="/library" component={LibraryPage} />
<Route exact path="/playlists" component={PlaylistsPage} />
<Route
exact
path="/playlists/:playlistId"
component={PlaylistDetailPage}
/>
<Route exact path="/discover" component={DiscoverPage} />
<Route exact path="/settings" component={SettingsPage} />
<Route

View File

@@ -15,29 +15,12 @@ import {
IonTitle,
IonToolbar,
} from "@ionic/react";
import { swapVerticalOutline } from "ionicons/icons";
import { heart, heartOutline, swapVerticalOutline } from "ionicons/icons";
import { useEffect, useMemo, useState } from "react";
import { db } from "../../services/Database";
import { db, type Game } from "../../services/Database";
import "./LibraryPage.css";
type SteamGame = {
id: string;
title: string;
platform?: string;
lastPlayed?: string | null;
playtimeHours?: number;
url?: string;
source?: string;
};
type SourceConfig = {
name: string;
label: string;
platform: string;
file: string;
};
const formatDate = (value?: string | null) => {
if (!value) return "-";
return new Date(value).toLocaleDateString("de");
@@ -46,8 +29,8 @@ const formatDate = (value?: string | null) => {
const normalizeTitle = (title: string) =>
title.toLowerCase().replace(/[:\-]/g, " ").replace(/\s+/g, " ").trim();
const mergeGames = (allGames: SteamGame[]) => {
const map = new Map<string, SteamGame>();
const mergeGames = (allGames: Game[]) => {
const map = new Map<string, Game>();
allGames.forEach((game) => {
const key = normalizeTitle(game.title);
@@ -72,7 +55,8 @@ const mergeGames = (allGames: SteamGame[]) => {
};
export default function LibraryPage() {
const [games, setGames] = useState<SteamGame[]>([]);
const [games, setGames] = useState<Game[]>([]);
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchText, setSearchText] = useState("");
@@ -92,11 +76,14 @@ export default function LibraryPage() {
try {
setLoading(true);
// Lade Spiele aus IndexedDB
const dbGames = await db.getGames();
const [dbGames, favPlaylist] = await Promise.all([
db.getGames(),
db.getPlaylist("favorites"),
]);
if (active) {
setGames(dbGames);
setFavoriteIds(new Set(favPlaylist?.gameIds ?? []));
setError(null);
}
} catch (err) {
@@ -118,7 +105,7 @@ export default function LibraryPage() {
const totalPlaytime = useMemo(() => {
return games.reduce(
(sum: number, game: SteamGame) => sum + (game.playtimeHours ?? 0),
(sum: number, game: Game) => sum + (game.playtimeHours ?? 0),
0,
);
}, [games]);
@@ -180,6 +167,20 @@ export default function LibraryPage() {
return filteredAndSortedGames.slice(0, displayCount);
}, [filteredAndSortedGames, displayCount]);
const handleToggleFavorite = async (gameId: string) => {
if (favoriteIds.has(gameId)) {
await db.removeGameFromPlaylist("favorites", gameId);
setFavoriteIds((prev) => {
const next = new Set(prev);
next.delete(gameId);
return next;
});
} else {
await db.addGameToPlaylist("favorites", gameId);
setFavoriteIds((prev) => new Set(prev).add(gameId));
}
};
return (
<IonPage>
<IonHeader translucent>
@@ -277,6 +278,17 @@ export default function LibraryPage() {
target="_blank"
rel="noreferrer"
>
<IonIcon
icon={favoriteIds.has(game.id) ? heart : heartOutline}
slot="start"
color={favoriteIds.has(game.id) ? "danger" : "medium"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleToggleFavorite(game.id);
}}
style={{ cursor: "pointer", fontSize: "22px" }}
/>
<IonLabel>
<h2>{game.title}</h2>
<p>Zuletzt gespielt: {formatDate(game.lastPlayed)}</p>

View File

@@ -0,0 +1,29 @@
.detail-loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.detail-name-edit {
padding: 0 16px;
}
.detail-name-edit .name-input {
font-size: 18px;
font-weight: 600;
}
.detail-search {
padding: 0 4px;
}
.action-icon {
cursor: pointer;
font-size: 22px;
}
.source-badge {
font-size: 11px;
font-weight: 500;
}

View File

@@ -0,0 +1,281 @@
import {
IonBackButton,
IonBadge,
IonButtons,
IonContent,
IonHeader,
IonIcon,
IonInput,
IonItem,
IonItemOption,
IonItemOptions,
IonItemSliding,
IonLabel,
IonList,
IonListHeader,
IonPage,
IonSearchbar,
IonSpinner,
IonTitle,
IonToolbar,
} from "@ionic/react";
import { useState, useEffect, useMemo, useCallback } from "react";
import { useParams } from "react-router-dom";
import {
addCircleOutline,
heart,
heartOutline,
removeCircleOutline,
} from "ionicons/icons";
import { db, type Playlist, type Game } from "../../services/Database";
import "./PlaylistDetailPage.css";
export default function PlaylistDetailPage() {
const { playlistId } = useParams<{ playlistId: string }>();
const [playlist, setPlaylist] = useState<Playlist | null>(null);
const [games, setGames] = useState<Game[]>([]);
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [searchText, setSearchText] = useState("");
const [editName, setEditName] = useState("");
const load = useCallback(async () => {
const [pl, allGames, favPlaylist] = await Promise.all([
db.getPlaylist(playlistId),
db.getGames(),
db.getPlaylist("favorites"),
]);
setPlaylist(pl);
setGames(allGames);
setFavoriteIds(new Set(favPlaylist?.gameIds ?? []));
if (pl) setEditName(pl.name);
setLoading(false);
}, [playlistId]);
useEffect(() => {
load();
}, [load]);
const playlistGames = useMemo(() => {
if (!playlist) return [];
return playlist.gameIds
.map((id) => games.find((g) => g.id === id))
.filter(Boolean) as Game[];
}, [playlist, games]);
const searchResults = useMemo(() => {
if (!searchText.trim() || !playlist) return [];
const term = searchText.toLowerCase();
const inPlaylist = new Set(playlist.gameIds);
return games
.filter(
(g) => g.title.toLowerCase().includes(term) && !inPlaylist.has(g.id),
)
.slice(0, 20);
}, [searchText, games, playlist]);
const handleAddGame = async (gameId: string) => {
await db.addGameToPlaylist(playlistId, gameId);
const updated = await db.getPlaylist(playlistId);
setPlaylist(updated);
};
const handleRemoveGame = async (gameId: string) => {
await db.removeGameFromPlaylist(playlistId, gameId);
const updated = await db.getPlaylist(playlistId);
setPlaylist(updated);
};
const handleToggleFavorite = async (gameId: string) => {
if (favoriteIds.has(gameId)) {
await db.removeGameFromPlaylist("favorites", gameId);
setFavoriteIds((prev) => {
const next = new Set(prev);
next.delete(gameId);
return next;
});
} else {
await db.addGameToPlaylist("favorites", gameId);
setFavoriteIds((prev) => new Set(prev).add(gameId));
}
// If we're viewing the favorites playlist, reload it
if (playlistId === "favorites") {
const updated = await db.getPlaylist("favorites");
setPlaylist(updated);
}
};
const handleNameBlur = async () => {
if (!playlist || playlist.isStatic) return;
const trimmed = editName.trim() || "Neue Liste";
if (trimmed !== playlist.name) {
await db.updatePlaylist(playlistId, { name: trimmed });
setPlaylist((prev) => (prev ? { ...prev, name: trimmed } : prev));
}
};
if (loading) {
return (
<IonPage>
<IonHeader translucent>
<IonToolbar>
<IonButtons slot="start">
<IonBackButton defaultHref="/playlists" />
</IonButtons>
<IonTitle>Playlist</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<div className="detail-loading">
<IonSpinner name="crescent" />
</div>
</IonContent>
</IonPage>
);
}
if (!playlist) {
return (
<IonPage>
<IonHeader translucent>
<IonToolbar>
<IonButtons slot="start">
<IonBackButton defaultHref="/playlists" />
</IonButtons>
<IonTitle>Nicht gefunden</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<p className="ion-padding ion-text-center">
Playlist nicht gefunden.
</p>
</IonContent>
</IonPage>
);
}
return (
<IonPage>
<IonHeader translucent>
<IonToolbar>
<IonButtons slot="start">
<IonBackButton defaultHref="/playlists" />
</IonButtons>
<IonTitle>{playlist.name}</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<IonHeader collapse="condense">
<IonToolbar>
<IonTitle size="large">{playlist.name}</IonTitle>
</IonToolbar>
</IonHeader>
{/* Editable name for custom playlists */}
{!playlist.isStatic && (
<div className="detail-name-edit">
<IonInput
value={editName}
placeholder="Playlist-Name"
onIonInput={(e) => setEditName(e.detail.value ?? "")}
onIonBlur={handleNameBlur}
className="name-input"
/>
</div>
)}
{/* Search to add games */}
<div className="detail-search">
<IonSearchbar
placeholder="Spiel hinzufügen..."
value={searchText}
onIonInput={(e) => setSearchText(e.detail.value ?? "")}
debounce={200}
/>
</div>
{searchResults.length > 0 && (
<IonList inset>
<IonListHeader>
<IonLabel>Suchergebnisse</IonLabel>
</IonListHeader>
{searchResults.map((game) => (
<IonItem key={game.id}>
<IonLabel>
<h2>{game.title}</h2>
{game.source && (
<p>
<IonBadge color="medium" className="source-badge">
{game.source}
</IonBadge>
</p>
)}
</IonLabel>
<IonIcon
icon={addCircleOutline}
slot="end"
color="primary"
onClick={() => handleAddGame(game.id)}
className="action-icon"
/>
</IonItem>
))}
</IonList>
)}
{/* Playlist games */}
<IonList inset>
<IonListHeader>
<IonLabel>
{playlist.gameIds.length}{" "}
{playlist.gameIds.length === 1 ? "Spiel" : "Spiele"}
</IonLabel>
</IonListHeader>
{playlistGames.length === 0 ? (
<IonItem>
<IonLabel color="medium" className="ion-text-center">
Keine Spiele in dieser Playlist
</IonLabel>
</IonItem>
) : (
playlistGames.map((game) => (
<IonItemSliding key={game.id}>
<IonItem>
<IonIcon
icon={favoriteIds.has(game.id) ? heart : heartOutline}
slot="start"
color={favoriteIds.has(game.id) ? "danger" : "medium"}
onClick={() => handleToggleFavorite(game.id)}
className="action-icon"
/>
<IonLabel>
<h2>{game.title}</h2>
{game.source && (
<p>
<IonBadge color="medium" className="source-badge">
{game.source}
</IonBadge>
</p>
)}
</IonLabel>
</IonItem>
<IonItemOptions side="end">
<IonItemOption
color="danger"
onClick={() => handleRemoveGame(game.id)}
>
<IonIcon
slot="icon-only"
icon={removeCircleOutline}
/>
</IonItemOption>
</IonItemOptions>
</IonItemSliding>
))
)}
</IonList>
</IonContent>
</IonPage>
);
}

View File

@@ -1,38 +1,11 @@
.playlists-content {
--padding-top: 16px;
--padding-start: 16px;
--padding-end: 16px;
}
.playlists-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 1rem;
}
.playlists-container {
padding-bottom: 20px;
}
.playlists-empty {
color: #8e8e93;
font-style: italic;
margin: 0;
}
ion-card {
margin-bottom: 16px;
}
ion-card-title {
display: flex;
align-items: center;
}
ion-item {
--padding-start: 16px;
--padding-end: 16px;
.chevron-icon {
font-size: 16px;
margin-left: 4px;
}

View File

@@ -1,65 +1,95 @@
import {
IonBadge,
IonButton,
IonButtons,
IonContent,
IonHeader,
IonIcon,
IonItem,
IonItemOption,
IonItemOptions,
IonItemSliding,
IonLabel,
IonList,
IonListHeader,
IonPage,
IonSpinner,
IonTitle,
IonToolbar,
IonList,
IonItem,
IonLabel,
IonCard,
IonCardHeader,
IonCardTitle,
IonCardContent,
IonBadge,
IonIcon,
IonSpinner,
} from "@ionic/react";
import { useState, useEffect } from "react";
import { gameControllerOutline, closeCircleOutline } from "ionicons/icons";
import { db, type Playlist, type Game } from "../../services/Database";
import { useState, useEffect, useCallback } from "react";
import { useHistory } from "react-router-dom";
import {
addOutline,
chevronForward,
heartOutline,
gameControllerOutline,
thumbsDownOutline,
listOutline,
trashOutline,
} from "ionicons/icons";
import { db, type Playlist } from "../../services/Database";
import "./PlaylistsPage.css";
const STATIC_ORDER = ["favorites", "want-to-play", "not-interesting"];
const STATIC_ICONS: Record<string, string> = {
favorites: heartOutline,
"want-to-play": gameControllerOutline,
"not-interesting": thumbsDownOutline,
};
export default function PlaylistsPage() {
const [playlists, setPlaylists] = useState<Playlist[]>([]);
const [games, setGames] = useState<Game[]>([]);
const [loading, setLoading] = useState(true);
const history = useHistory();
useEffect(() => {
let active = true;
const load = async () => {
try {
setLoading(true);
const [dbPlaylists, dbGames] = await Promise.all([
db.getPlaylists(),
db.getGames(),
]);
if (active) {
setPlaylists(dbPlaylists);
setGames(dbGames);
}
} finally {
if (active) setLoading(false);
}
};
load();
return () => {
active = false;
};
const load = useCallback(async () => {
const all = await db.getPlaylists();
setPlaylists(all);
setLoading(false);
}, []);
const getGameById = (gameId: string) => {
return games.find((g) => g.id === gameId);
useEffect(() => {
load();
}, [load]);
// Reload when returning to this page (ionViewWillEnter equivalent)
useEffect(() => {
const unlisten = history.listen((location) => {
if (location.pathname === "/playlists") {
load();
}
});
return unlisten;
}, [history, load]);
const staticPlaylists = STATIC_ORDER.map((id) =>
playlists.find((p) => p.id === id),
).filter(Boolean) as Playlist[];
const customPlaylists = playlists
.filter((p) => !p.isStatic)
.sort(
(a, b) =>
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
const handleAdd = async () => {
const id = `custom-${Date.now()}`;
await db.createPlaylist({
id,
name: "Neue Liste",
gameIds: [],
isStatic: false,
createdAt: new Date().toISOString(),
});
history.push(`/playlists/${id}`);
};
const handleRemoveGame = async (playlistId: string, gameId: string) => {
await db.removeGameFromPlaylist(playlistId, gameId);
const updatedPlaylists = await db.getPlaylists();
setPlaylists(updatedPlaylists);
const handleDelete = async (id: string) => {
await db.deletePlaylist(id);
await load();
};
return (
@@ -67,9 +97,14 @@ export default function PlaylistsPage() {
<IonHeader translucent>
<IonToolbar>
<IonTitle>Playlists</IonTitle>
<IonButtons slot="end">
<IonButton onClick={handleAdd}>
<IonIcon slot="icon-only" icon={addOutline} />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent fullscreen className="playlists-content">
<IonContent fullscreen>
<IonHeader collapse="condense">
<IonToolbar>
<IonTitle size="large">Playlists</IonTitle>
@@ -81,65 +116,79 @@ export default function PlaylistsPage() {
<IonSpinner name="crescent" />
</div>
) : (
<div className="playlists-container">
{playlists.map((playlist) => (
<IonCard key={playlist.id}>
<IonCardHeader>
<IonCardTitle>
{playlist.name}
<IonBadge color="primary" style={{ marginLeft: "8px" }}>
{playlist.gameIds.length}
</IonBadge>
</IonCardTitle>
</IonCardHeader>
<IonCardContent>
{playlist.gameIds.length === 0 ? (
<p className="playlists-empty">
Keine Spiele in dieser Playlist
</p>
) : (
<IonList>
{playlist.gameIds.map((gameId) => {
const game = getGameById(gameId);
if (!game) return null;
return (
<IonItem key={gameId}>
<IonIcon
icon={gameControllerOutline}
slot="start"
color="primary"
/>
<IonLabel>
<h2>{game.title}</h2>
{game.source && (
<p>
<IonBadge
color="medium"
style={{ fontSize: "10px" }}
>
{game.source}
</IonBadge>
</p>
)}
</IonLabel>
<IonIcon
icon={closeCircleOutline}
slot="end"
color="medium"
onClick={() =>
handleRemoveGame(playlist.id, gameId)
}
style={{ cursor: "pointer" }}
/>
</IonItem>
);
})}
</IonList>
)}
</IonCardContent>
</IonCard>
))}
</div>
<>
<IonList inset>
{staticPlaylists.map((playlist) => (
<IonItem
key={playlist.id}
routerLink={`/playlists/${playlist.id}`}
detail={false}
>
<IonIcon
icon={STATIC_ICONS[playlist.id]}
slot="start"
color="primary"
/>
<IonLabel>{playlist.name}</IonLabel>
<IonBadge color="medium" slot="end">
{playlist.gameIds.length}
</IonBadge>
<IonIcon
icon={chevronForward}
slot="end"
color="medium"
className="chevron-icon"
/>
</IonItem>
))}
</IonList>
<IonList inset>
<IonListHeader>
<IonLabel>Eigene Playlists</IonLabel>
</IonListHeader>
{customPlaylists.length === 0 ? (
<IonItem>
<IonLabel color="medium" className="ion-text-center">
Keine eigenen Playlists
</IonLabel>
</IonItem>
) : (
customPlaylists.map((playlist) => (
<IonItemSliding key={playlist.id}>
<IonItem
routerLink={`/playlists/${playlist.id}`}
detail={false}
>
<IonIcon
icon={listOutline}
slot="start"
color="primary"
/>
<IonLabel>{playlist.name}</IonLabel>
<IonBadge color="medium" slot="end">
{playlist.gameIds.length}
</IonBadge>
<IonIcon
icon={chevronForward}
slot="end"
color="medium"
className="chevron-icon"
/>
</IonItem>
<IonItemOptions side="end">
<IonItemOption
color="danger"
onClick={() => handleDelete(playlist.id)}
>
<IonIcon slot="icon-only" icon={trashOutline} />
</IonItemOption>
</IonItemOptions>
</IonItemSliding>
))
)}
</IonList>
</>
)}
</IonContent>
</IonPage>

View File

@@ -229,27 +229,23 @@ class Database {
private async initStaticPlaylists(): Promise<void> {
const playlists = await this.getPlaylists();
const hasWantToPlay = playlists.some((p) => p.id === "want-to-play");
const hasNotInteresting = playlists.some((p) => p.id === "not-interesting");
if (!hasWantToPlay) {
await this.createPlaylist({
id: "want-to-play",
name: "Want to Play",
gameIds: [],
isStatic: true,
createdAt: new Date().toISOString(),
});
}
const statics: Array<{ id: string; name: string }> = [
{ id: "favorites", name: "Favoriten" },
{ id: "want-to-play", name: "Want to Play" },
{ id: "not-interesting", name: "Not Interesting" },
];
if (!hasNotInteresting) {
await this.createPlaylist({
id: "not-interesting",
name: "Not Interesting",
gameIds: [],
isStatic: true,
createdAt: new Date().toISOString(),
});
for (const s of statics) {
if (!playlists.some((p) => p.id === s.id)) {
await this.createPlaylist({
id: s.id,
name: s.name,
gameIds: [],
isStatic: true,
createdAt: new Date().toISOString(),
});
}
}
}
@@ -312,6 +308,34 @@ class Database {
playlist.gameIds = playlist.gameIds.filter((id) => id !== gameId);
await this.createPlaylist(playlist);
}
async updatePlaylist(
id: string,
updates: Partial<Pick<Playlist, "name">>,
): Promise<void> {
const playlist = await this.getPlaylist(id);
if (!playlist) throw new Error(`Playlist ${id} not found`);
Object.assign(playlist, updates);
await this.createPlaylist(playlist);
}
async deletePlaylist(id: string): Promise<void> {
const playlist = await this.getPlaylist(id);
if (!playlist) return;
if (playlist.isStatic) throw new Error("Cannot delete static playlist");
if (!this.db) await this.init();
return new Promise((resolve, reject) => {
const tx = this.db!.transaction("playlists", "readwrite");
const store = tx.objectStore("playlists");
const request = store.delete(id);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
}
// Singleton