From adf3d13ab088b49ef30058a3d245679d7dd8f1fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Fri, 6 Feb 2026 15:36:56 +0100 Subject: [PATCH] add tinder view --- package-lock.json | 114 ++++++++++-- package.json | 4 +- src/pages/Discover/DiscoverPage.css | 205 ++++++++++++++++++++- src/pages/Discover/DiscoverPage.tsx | 268 +++++++++++++++++++++++++++- 4 files changed, 567 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4cf7476..f2bb99c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,13 @@ "dependencies": { "@ionic/react": "^8.0.0", "@ionic/react-router": "^8.0.0", + "@react-spring/web": "^9.7.5", "ionicons": "^7.2.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router": "^5.3.4", - "react-router-dom": "^5.3.4" + "react-router-dom": "^5.3.4", + "react-tinder-card": "^1.6.4" }, "devDependencies": { "@types/react": "^18.2.0", @@ -58,7 +60,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1539,6 +1540,78 @@ "dev": true, "license": "MIT" }, + "node_modules/@react-spring/animated": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.5.tgz", + "integrity": "sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==", + "license": "MIT", + "dependencies": { + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.5.tgz", + "integrity": "sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.7.5", + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.5.tgz", + "integrity": "sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw==", + "license": "MIT" + }, + "node_modules/@react-spring/shared": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.5.tgz", + "integrity": "sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw==", + "license": "MIT", + "dependencies": { + "@react-spring/rafz": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/types": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.5.tgz", + "integrity": "sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==", + "license": "MIT" + }, + "node_modules/@react-spring/web": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-9.7.5.tgz", + "integrity": "sha512-lmvqGwpe+CSttsWNZVr+Dg62adtKhauGwLyGE/RRyZ8AAMLgb9x3NDMA5RMElXo+IMyTkPp7nxTB8ZQlmhb6JQ==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.7.5", + "@react-spring/core": "~9.7.5", + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -2003,7 +2076,6 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2100,7 +2172,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2448,6 +2519,12 @@ "node": ">=0.10.0" } }, + "node_modules/p-sleep": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/p-sleep/-/p-sleep-1.1.0.tgz", + "integrity": "sha512-bwP3GKZirBUYMtiUuBrheLUQdRXVeE/pmHOaLpNJzNfAD4b5AjDn6l823brXcQFade4G/g7GMNQ3KV86E8EaEw==", + "license": "MIT" + }, "node_modules/path-to-regexp": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", @@ -2516,7 +2593,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2529,7 +2605,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -2559,7 +2634,6 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -2580,7 +2654,6 @@ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz", "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -2594,6 +2667,28 @@ "react": ">=15" } }, + "node_modules/react-tinder-card": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/react-tinder-card/-/react-tinder-card-1.6.4.tgz", + "integrity": "sha512-IC6YXoBZ+51jm7XsT8i+8G/ov8rvAob+kBRdp9unQyjsLc7jmuYb1cNfu95Q3mdFDgwE0AzTIyl1o2Klm61+aQ==", + "license": "MIT", + "dependencies": { + "p-sleep": "^1.1.0" + }, + "peerDependencies": { + "@react-spring/native": "^9.5.5", + "@react-spring/web": "^9.5.5", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@react-spring/native": { + "optional": true + }, + "@react-spring/web": { + "optional": true + } + } + }, "node_modules/resolve-pathname": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz", @@ -2905,7 +3000,6 @@ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -2953,7 +3047,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -3015,7 +3108,6 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "bin": { "workerd": "bin/workerd" }, diff --git a/package.json b/package.json index d286280..ba0d272 100644 --- a/package.json +++ b/package.json @@ -20,11 +20,13 @@ "dependencies": { "@ionic/react": "^8.0.0", "@ionic/react-router": "^8.0.0", + "@react-spring/web": "^9.7.5", "ionicons": "^7.2.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router": "^5.3.4", - "react-router-dom": "^5.3.4" + "react-router-dom": "^5.3.4", + "react-tinder-card": "^1.6.4" }, "devDependencies": { "@types/react": "^18.2.0", diff --git a/src/pages/Discover/DiscoverPage.css b/src/pages/Discover/DiscoverPage.css index f7a9997..3a3a0d9 100644 --- a/src/pages/Discover/DiscoverPage.css +++ b/src/pages/Discover/DiscoverPage.css @@ -4,20 +4,215 @@ --padding-end: 16px; } -.discover-placeholder { +/* States: loading, empty */ +.discover-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + text-align: center; + gap: 0.5rem; +} + +.discover-state p { + margin: 0; + color: #8e8e93; +} + +.discover-state-hint { + font-size: 0.85rem; +} + +/* Done state */ +.discover-done { background: #ffffff; border-radius: 20px; padding: 2rem; text-align: center; box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08); + margin-top: 2rem; } -.discover-placeholder h2 { - margin: 0 0 0.5rem; +.discover-done h2 { + margin: 0 0 1.5rem; font-size: 1.5rem; } -.discover-placeholder p { - margin: 0; +.discover-done-stats { + display: flex; + justify-content: center; + gap: 2rem; + margin-bottom: 1.5rem; +} + +.discover-done-stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; +} + +.discover-done-stat strong { + font-size: 2rem; + font-weight: 700; +} + +.discover-done-stat span { + font-size: 0.85rem; color: #8e8e93; } + +/* Progress bar */ +.discover-progress { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.discover-progress span { + font-size: 0.85rem; + color: #8e8e93; + white-space: nowrap; +} + +.discover-progress-bar { + flex: 1; + height: 4px; + background: #e5e5ea; + border-radius: 2px; + overflow: hidden; +} + +.discover-progress-fill { + height: 100%; + background: var(--ion-color-primary, #0a84ff); + border-radius: 2px; + transition: width 0.3s ease; +} + +/* Card stack */ +.discover-stack { + position: relative; + width: 100%; + aspect-ratio: 3 / 4; + max-height: 55vh; + margin: 0 auto; +} + +.discover-stack > div { + position: absolute; + width: 100%; + height: 100%; +} + +/* Game card */ +.discover-card { + position: absolute; + width: 100%; + height: 100%; + background: #ffffff; + border-radius: 20px; + padding: 1.75rem; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + justify-content: center; + user-select: none; + cursor: grab; + overflow: hidden; +} + +.discover-card:active { + cursor: grabbing; +} + +.discover-card-behind { + pointer-events: none; +} + +/* Card content */ +.discover-card-source { + margin-bottom: 1rem; +} + +.discover-card-title { + font-size: 1.5rem; + font-weight: 700; + margin: 0 0 1.5rem; + line-height: 1.2; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.discover-card-details { + display: flex; + gap: 1.5rem; + margin-top: auto; +} + +.discover-card-detail { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.discover-card-detail-label { + font-size: 0.75rem; + color: #8e8e93; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.discover-card-detail-value { + font-size: 1rem; + font-weight: 600; +} + +/* Action buttons */ +.discover-actions { + display: flex; + justify-content: center; + gap: 2rem; + padding: 1.5rem 0; +} + +.discover-action-btn { + width: 64px; + height: 64px; + border-radius: 50%; + border: 2px solid; + background: #ffffff; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.75rem; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +.discover-action-btn:active { + transform: scale(0.92); +} + +.discover-action-skip { + border-color: #ff3b30; + color: #ff3b30; +} + +.discover-action-skip:hover { + background: #fff5f5; +} + +.discover-action-like { + border-color: #34c759; + color: #34c759; +} + +.discover-action-like:hover { + background: #f0faf3; +} diff --git a/src/pages/Discover/DiscoverPage.tsx b/src/pages/Discover/DiscoverPage.tsx index ed9843f..9547b03 100644 --- a/src/pages/Discover/DiscoverPage.tsx +++ b/src/pages/Discover/DiscoverPage.tsx @@ -1,21 +1,152 @@ import { + IonAlert, + IonBadge, + IonButton, IonContent, IonHeader, + IonIcon, IonPage, + IonSpinner, IonTitle, IonToolbar, } from "@ionic/react"; +import { + closeOutline, + checkmarkOutline, + refreshOutline, +} from "ionicons/icons"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import TinderCard from "react-tinder-card"; +import { db, type Game } from "../../services/Database"; import "./DiscoverPage.css"; +type SwipeResults = Record; + +const formatDate = (value?: string | null) => { + if (!value) return "-"; + return new Date(value).toLocaleDateString("de"); +}; + +const formatPlaytime = (hours?: number) => { + if (!hours) return "0 h"; + if (hours < 1) return `${Math.round(hours * 60)} min`; + return `${hours.toFixed(1)} h`; +}; + export default function DiscoverPage() { + const [games, setGames] = useState([]); + const [swipeResults, setSwipeResults] = useState({}); + const [loading, setLoading] = useState(true); + const [showResetAlert, setShowResetAlert] = useState(false); + + const cardRefs = useRef>(new Map()); + + useEffect(() => { + let active = true; + + const load = async () => { + try { + setLoading(true); + const [dbGames, savedResults] = await Promise.all([ + db.getGames(), + db.getSetting("swipe_results"), + ]); + + if (active) { + setGames(dbGames); + setSwipeResults(savedResults || {}); + } + } finally { + if (active) setLoading(false); + } + }; + + load(); + return () => { + active = false; + }; + }, []); + + const unseenGames = useMemo( + () => games.filter((g) => !(g.id in swipeResults)), + [games, swipeResults], + ); + + const saveSwipe = useCallback( + async (gameId: string, decision: "skip" | "interested") => { + const updated = { ...swipeResults, [gameId]: decision }; + setSwipeResults(updated); + await db.setSetting("swipe_results", updated); + }, + [swipeResults], + ); + + const handleSwipe = useCallback( + (direction: string, gameId: string) => { + const decision = direction === "right" ? "interested" : "skip"; + saveSwipe(gameId, decision); + }, + [saveSwipe], + ); + + const swipeButton = useCallback( + (direction: "left" | "right") => { + if (unseenGames.length === 0) return; + const topGame = unseenGames[unseenGames.length - 1]; + const topIndex = games.indexOf(topGame); + const ref = cardRefs.current.get(topIndex); + if (ref) { + ref.swipe(direction); + } + }, + [unseenGames, games], + ); + + const handleReset = useCallback(async () => { + setSwipeResults({}); + await db.setSetting("swipe_results", {}); + }, []); + + const totalSwiped = Object.keys(swipeResults).length; + const interestedCount = Object.values(swipeResults).filter( + (v) => v === "interested", + ).length; + const skippedCount = totalSwiped - interestedCount; + return ( Entdecken + {totalSwiped > 0 && ( + setShowResetAlert(true)} + color="medium" + > + + + )} + + setShowResetAlert(false)} + header="Zurucksetzen?" + message={`Alle ${totalSwiped} Swipe-Entscheidungen werden geloscht.`} + buttons={[ + { text: "Abbrechen", role: "cancel" }, + { + text: "Zurucksetzen", + role: "destructive", + handler: handleReset, + }, + ]} + /> + @@ -23,13 +154,136 @@ export default function DiscoverPage() { -
-

Swipe & Entdecke

-

- Tinder-Style: Screenshots ansehen, bewerten und deinen perfekten - Gaming-Stack aufbauen. -

-
+ {loading ? ( +
+ +

Lade Spiele ...

+
+ ) : games.length === 0 ? ( +
+

Keine Spiele vorhanden.

+

+ Importiere zuerst Spiele in den Einstellungen. +

+
+ ) : unseenGames.length === 0 ? ( +
+

Alle Spiele gesehen!

+
+
+ {interestedCount} + Interessiert +
+
+ {skippedCount} + Ubersprungen +
+
+ + + Nochmal starten + +
+ ) : ( + <> +
+ + {totalSwiped} / {games.length} Spiele + +
+
+
+
+ +
+ {unseenGames + .slice(-3) + .map((game, i, arr) => { + const globalIndex = games.indexOf(game); + const stackPosition = arr.length - 1 - i; + return ( + { + if (ref) { + cardRefs.current.set(globalIndex, ref); + } else { + cardRefs.current.delete(globalIndex); + } + }} + key={game.id} + onSwipe={(dir: string) => + handleSwipe(dir, game.id) + } + preventSwipe={["up", "down"]} + swipeRequirementType="position" + swipeThreshold={80} + > +
0 ? "discover-card-behind" : ""}`} + style={{ + zIndex: arr.length - stackPosition, + transform: `scale(${1 - stackPosition * 0.04}) translateY(${stackPosition * 12}px)`, + }} + > +
+ + {game.source ?? "Unbekannt"} + +
+

+ {game.title} +

+
+
+ + Spielzeit + + + {formatPlaytime( + game.playtimeHours, + )} + +
+
+ + Zuletzt gespielt + + + {formatDate(game.lastPlayed)} + +
+
+
+
+ ); + })} +
+ +
+ + +
+ + )} );