diff --git a/app/package-lock.json b/app/package-lock.json index 26bd89c..f5b88f8 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -19,10 +19,12 @@ "@types/react-router-dom": "^5.3.3", "ionicons": "^7.4.0", "jotai": "^2.12.5", + "lucide-react": "^0.536.0", "react": "19.0.0", "react-dom": "19.0.0", "react-router": "^5.3.4", - "react-router-dom": "^5.3.4" + "react-router-dom": "^5.3.4", + "tinycolor2": "^1.6.0" }, "devDependencies": { "@capacitor/cli": "7.4.2", @@ -8484,6 +8486,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.536.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.536.0.tgz", + "integrity": "sha512-2PgvNa9v+qz4Jt/ni8vPLt4jwoFybXHuubQT8fv4iCW5TjDxkbZjNZZHa485ad73NSEn/jdsEtU57eE1g+ma8A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -10814,6 +10825,12 @@ "dev": true, "license": "MIT" }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/tinypool": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.7.0.tgz", diff --git a/app/package.json b/app/package.json index c6400a2..419b61b 100644 --- a/app/package.json +++ b/app/package.json @@ -23,10 +23,12 @@ "@types/react-router-dom": "^5.3.3", "ionicons": "^7.4.0", "jotai": "^2.12.5", + "lucide-react": "^0.536.0", "react": "19.0.0", "react-dom": "19.0.0", "react-router": "^5.3.4", - "react-router-dom": "^5.3.4" + "react-router-dom": "^5.3.4", + "tinycolor2": "^1.6.0" }, "devDependencies": { "@capacitor/cli": "7.4.2", diff --git a/app/src/App.tsx b/app/src/App.tsx index 49b6d77..31fe302 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,30 +1,26 @@ -import './App.css'; -import './theme/variables.css'; -import '@ionic/react/css/core.css'; -import '@ionic/react/css/display.css'; -import '@ionic/react/css/flex-utils.css'; -import '@ionic/react/css/float-elements.css'; -import '@ionic/react/css/normalize.css'; -import '@ionic/react/css/padding.css'; -import '@ionic/react/css/palettes/dark.system.css'; -import '@ionic/react/css/structure.css'; -import '@ionic/react/css/text-alignment.css'; -import '@ionic/react/css/text-transformation.css'; -import '@ionic/react/css/typography.css'; +import "./App.css"; +import "./theme/variables.css"; +import "@ionic/react/css/core.css"; +import "@ionic/react/css/display.css"; +import "@ionic/react/css/flex-utils.css"; +import "@ionic/react/css/float-elements.css"; +import "@ionic/react/css/normalize.css"; +import "@ionic/react/css/padding.css"; +import "@ionic/react/css/palettes/dark.system.css"; +import "@ionic/react/css/structure.css"; +import "@ionic/react/css/text-alignment.css"; +import "@ionic/react/css/text-transformation.css"; +import "@ionic/react/css/typography.css"; -import { IonApp, IonIcon, IonLabel, IonRouterOutlet, IonTabBar, IonTabButton, IonTabs, setupIonicReact } from '@ionic/react'; -import { IonReactRouter } from '@ionic/react-router'; -import { list, musicalNote, search } from 'ionicons/icons'; -import { useAtom } from 'jotai'; -import { useEffect } from 'react'; -import { Redirect, Route } from 'react-router-dom'; +import { IonApp, setupIonicReact } from "@ionic/react"; +import { IonReactRouter } from "@ionic/react-router"; +import { useAtom } from "jotai"; +import { useEffect, useState } from "react"; -import { currentTrackAtom } from './atoms'; -import { mocks } from './mocks'; -import Tab1 from './pages/Tab1'; -import Tab2 from './pages/Tab2'; -import Tab3 from './pages/Tab3'; -import Tab from './pages/Tab3'; +import { currentTrackAtom } from "./atoms"; +import { mocks } from "./mocks"; +import Tab from "./pages/Tab3"; +import LocationAccessChecker from "./hooks/GeoAccess"; /* Core CSS required for Ionic components to work properly */ /* Basic CSS for apps built with Ionic */ @@ -35,15 +31,21 @@ import Tab from './pages/Tab3'; setupIonicReact(); const App: React.FC = () => { - const [current, setCurrent] = useAtom(currentTrackAtom) + const [current, setCurrent] = useAtom(currentTrackAtom); + const [accessGranted, setAccessGranted] = useState(null); useEffect(() => { - setCurrent(mocks.nowPlaying) - }, []) + setCurrent(mocks.nowPlaying); + }, []); + + // if (accessGranted === false) { + // console.log("access denied."); + // return null; + // } return ( - {/* {current && } */} + diff --git a/app/src/api_endpoints.ts b/app/src/api_endpoints.ts new file mode 100644 index 0000000..b0aa98a --- /dev/null +++ b/app/src/api_endpoints.ts @@ -0,0 +1,3 @@ +export const API_BASE_URL = "https://fbbb261497e3.ngrok-free.app"; +export const GEO_ACCESS_API = `${API_BASE_URL}/geo-access/real`; +export const MONGO_DB_CODA_API = `${API_BASE_URL}/queue` diff --git a/app/src/components/NowPlayingTab.css b/app/src/components/NowPlayingTab.css index 957b8b1..c2e3860 100644 --- a/app/src/components/NowPlayingTab.css +++ b/app/src/components/NowPlayingTab.css @@ -1,5 +1,4 @@ .now-playing-cover { - margin-top: 25px; margin-left: 25px; width: calc(100% - 50px); height: auto; @@ -15,7 +14,7 @@ left: 0; right: 0; height: 80px; - background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.4)); + /* background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.4)); */ pointer-events: none; } @@ -35,7 +34,7 @@ .now-playing-artist { font-size: 1.1rem; font-weight: 600; - color: var(--text-shy); + color: var(--text-supporting); margin-top: 0; opacity: 0.8; } diff --git a/app/src/components/NowPlayingTab.tsx b/app/src/components/NowPlayingTab.tsx index 3195cee..fcfb68c 100644 --- a/app/src/components/NowPlayingTab.tsx +++ b/app/src/components/NowPlayingTab.tsx @@ -1,6 +1,6 @@ -import { IonContent } from "@ionic/react"; -import React, { useEffect, useState } from "react"; -import "./NowPlayingTab.css"; +import './NowPlayingTab.css'; + +import React, { useEffect, useState } from 'react'; export interface NowPlayingData { coverUrl: string; diff --git a/app/src/components/Queue.css b/app/src/components/Queue.css index 23a327a..541cca1 100644 --- a/app/src/components/Queue.css +++ b/app/src/components/Queue.css @@ -13,6 +13,7 @@ display: flex; align-items: center; justify-content: space-between; + margin-bottom: 20px; } h1.queue { diff --git a/app/src/components/Queue.tsx b/app/src/components/Queue.tsx index 248ff91..3d60f38 100644 --- a/app/src/components/Queue.tsx +++ b/app/src/components/Queue.tsx @@ -1,13 +1,25 @@ -import './Queue.css'; +import "./Queue.css"; -import { IonButton, IonContent, IonHeader, IonIcon, IonModal, IonSearchbar, IonTitle, IonToolbar } from '@ionic/react'; -import { add } from 'ionicons/icons'; -import React, { useState } from 'react'; +import { + IonButton, + IonContent, + IonHeader, + IonModal, + IonSearchbar, + IonTitle, + IonToolbar, +} from "@ionic/react"; + +import React, { useState } from "react"; + +import { ArrowBigUp, Plus } from "lucide-react"; interface NowPlayingDiscProps { coverUrl: string; title: string; artist: string; + upvoted?: boolean; + upvotes?: number; } interface QueueProps { @@ -18,32 +30,64 @@ const Queue: React.FC = ({ songs }) => { const [showModal, setShowModal] = useState(false); const [searchText, setSearchText] = useState(""); + const handleUpVote = () => {}; + return (

Up next

- setShowModal(true)} - className="icon-button" - > - - + { + setShowModal(true); + }} + />
- {songs.map((song, index) => ( -
- {`${song.title} -
-
{song.title}
-
{song.artist}
+ {songs.map((song, index) => { + const isUpvoted = song.upvoted ?? false; + const votes = song.upvotes ?? 0; + + return ( +
+ {`${song.title} +
+
{song.title}
+
{song.artist}
+
+ +
+ + {votes} + + +
-
- ))} + ); + })} setShowModal(false)}> diff --git a/app/src/components/testCodaDbMongo.tsx b/app/src/components/testCodaDbMongo.tsx new file mode 100644 index 0000000..3e2ad3c --- /dev/null +++ b/app/src/components/testCodaDbMongo.tsx @@ -0,0 +1,119 @@ +import React, { useState } from 'react'; +import { + IonPage, + IonContent, + IonList, + IonItem, + IonLabel, + IonInput, + IonButton, + IonSpinner, + IonText, +} from '@ionic/react'; +import { useCoda } from '../hooks/useCoda'; + +const TestCoda: React.FC = () => { + const { + records, + loading, + error, + fetchRecords, + addRecord, + deleteRecord, + updateVoti, + } = useCoda(); + + // Stati per i nuovi record da aggiungere + const [title, setTitle] = useState(''); + const [artista, setArtista] = useState(''); + const [coverUrl, setCoverUrl] = useState(''); + const [color, setColor] = useState(''); + + const handleAdd = () => { + if (!title || !artista || !coverUrl || !color) return alert('Completa tutti i campi'); + addRecord({ title, artista, coverUrl, color, voti: 0 }); + setTitle(''); + setArtista(''); + setCoverUrl(''); + setColor(''); + }; + + return ( + + +

Test UseCoda

+ + {loading && ( +
+ +
+ )} + + {error && ( + +

{error}

+
+ )} + + + Ricarica Record + + +

Aggiungi Nuovo Record

+ setTitle(e.detail.value!)} + /> + setArtista(e.detail.value!)} + /> + setCoverUrl(e.detail.value!)} + /> + setColor(e.detail.value!)} + /> + + Aggiungi + + +

Lista Record

+ + {records.length === 0 && !loading &&

Nessun record disponibile

} + {records.map((r) => ( + + +

{r.title}

+

Artista: {r.artista}

+ {r.title} +

Voti: {r.voti}

+
+ updateVoti(r.id, 1)} disabled={loading}> + +1 + + updateVoti(r.id, -1)} disabled={loading}> + -1 + + deleteRecord(r.id)} disabled={loading}> + Elimina + +
+ ))} +
+
+
+ ); +}; + +export default TestCoda; diff --git a/app/src/hooks/GeoAccess.tsx b/app/src/hooks/GeoAccess.tsx new file mode 100644 index 0000000..befc747 --- /dev/null +++ b/app/src/hooks/GeoAccess.tsx @@ -0,0 +1,135 @@ +import React, { useEffect, useState } from "react"; +import { + IonPage, + IonContent, + IonSpinner, + IonText, + IonButton, +} from "@ionic/react"; +import { GEO_ACCESS_API } from "../api_endpoints"; + +const MAX_ACCEPTABLE_ACCURACY = 100; // metri + +const LocationAccessChecker: React.FC<{ + onAccessChecked?: (granted: boolean) => void; +}> = ({ onAccessChecked }) => { + const [loading, setLoading] = useState(true); + const [accessGranted, setAccessGranted] = useState(null); + const [error, setError] = useState(null); + const [accuracy, setAccuracy] = useState(null); + + useEffect(() => { + const checkLocationAccess = async () => { + if (!navigator.geolocation) { + setError("Geolocation non supportata dal browser"); + setAccessGranted(false); + setLoading(false); + onAccessChecked?.(false); + return; + } + + navigator.geolocation.getCurrentPosition( + async (position) => { + const latitude = position.coords.latitude; + const longitude = position.coords.longitude; + const positionAccuracy = position.coords.accuracy; + + console.log("Lat:", latitude); + console.log("Lng:", longitude); + console.log("Accuracy (m):", positionAccuracy); + setAccuracy(positionAccuracy); + + if (positionAccuracy > MAX_ACCEPTABLE_ACCURACY) { + setError( + `Precisione troppo bassa: ${positionAccuracy.toFixed(1)} metri` + ); + setAccessGranted(false); + setLoading(false); + onAccessChecked?.(false); + return; + } + + const coordinates = { + coords: [latitude, longitude], + }; + + try { + const response = await fetch(GEO_ACCESS_API, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(coordinates), + }); + + if (!response.ok) { + throw new Error("Errore nella risposta dal server"); + } + + const data = await response.json(); + + if (data.success === true) { + setAccessGranted(true); + onAccessChecked?.(true); + } else { + setAccessGranted(false); + onAccessChecked?.(false); + } + } catch (e) { + setError("Errore di rete o server"); + setAccessGranted(false); + onAccessChecked?.(false); + } finally { + setLoading(false); + } + }, + (err) => { + setError(`Errore geolocalizzazione: ${err.message}`); + setAccessGranted(false); + setLoading(false); + onAccessChecked?.(false); + }, + { + enableHighAccuracy: true, + timeout: 10000, + maximumAge: 60000, + } + ); + }; + + checkLocationAccess(); + }, [onAccessChecked]); + + if (loading) { + return ( + + + + Verifica posizione in corso... + + + ); + } + + if (accessGranted === false) { + return ( + + + +

Accesso non consentito

+

La tua posizione non permette l’uso dell’app.

+ {error &&

Errore: {error}

} + {accuracy !== null && ( +

Precisione posizione: {accuracy.toFixed(1)} metri

+ )} +
+ window.location.reload()}> + Riprova + +
+
+ ); + } + + return null; // accesso consentito, niente da mostrare +}; + +export default LocationAccessChecker; diff --git a/app/src/hooks/useCoda.tsx b/app/src/hooks/useCoda.tsx new file mode 100644 index 0000000..313a55b --- /dev/null +++ b/app/src/hooks/useCoda.tsx @@ -0,0 +1,113 @@ +import { useState, useEffect } from "react"; +import { MONGO_DB_CODA_API } from "../api_endpoints"; + +const API_URL = MONGO_DB_CODA_API; + +export interface Record { + id: string; + titolo: string; + coverUrl: string; + artista: string; + color: string; + voti: number; +} + +export const useCoda = () => { + const [records, setRecords] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchJson = async (input: RequestInfo, init?: RequestInit) => { + const res = await fetch(input, init); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || "Errore fetch"); + } + return res.json(); + }; + + const fetchRecords = async () => { + setLoading(true); + try { + const response = await fetchJson(`${API_URL}/read`); + if (response.success) { + setRecords(response.data); + setError(null); + } else { + throw new Error("Errore nella risposta del server"); + } + } catch (e: any) { + setError(e.message || "Errore fetch"); + } finally { + setLoading(false); + } + }; + + const addRecord = async (record: Omit) => { + try { + const body = JSON.stringify(record); + const response = await fetchJson(`${API_URL}/add`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + if (response.success) { + setRecords((prev) => [...prev, response.data]); + setError(null); + } else { + throw new Error("Errore aggiunta record"); + } + } catch (e: any) { + setError(e.message || "Errore add"); + } + }; + + const deleteRecord = async (id: string) => { + try { + const response = await fetchJson(`${API_URL}/delete/${id}`, { + method: "DELETE", + }); + if (response.success) { + setRecords((prev) => prev.filter((r) => r.id !== id)); + setError(null); + } else { + throw new Error("Errore eliminazione record"); + } + } catch (e: any) { + setError(e.message || "Errore delete"); + } + }; + + const updateVoti = async (id: string, delta: 1 | -1) => { + try { + const response = await fetchJson(`${API_URL}/vote/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ increment: delta === 1 }), + }); + if (response.success) { + const updatedItem: Record = response.data; + setRecords((prev) => prev.map((r) => (r.id === id ? updatedItem : r))); + setError(null); + } else { + throw new Error("Errore aggiornamento voti"); + } + } catch (e: any) { + setError(e.message || "Errore update voti"); + } + }; + + useEffect(() => { + fetchRecords(); + }, []); + + return { + records, + loading, + error, + fetchRecords, + addRecord, + deleteRecord, + updateVoti, + }; +}; diff --git a/app/src/pages/Tab.css b/app/src/pages/Tab.css new file mode 100644 index 0000000..defdac7 --- /dev/null +++ b/app/src/pages/Tab.css @@ -0,0 +1,6 @@ +.gradient-background { + --background: none !important; + /* height: 100%; */ + padding-top: 25px; + padding-bottom: 25px; +} diff --git a/app/src/pages/Tab1.css b/app/src/pages/Tab1.css deleted file mode 100644 index a2109c6..0000000 --- a/app/src/pages/Tab1.css +++ /dev/null @@ -1,6 +0,0 @@ -.tab-1-container { - display: flex; - flex-direction: column; - justify-content: center; - width: 100%; -} \ No newline at end of file diff --git a/app/src/pages/Tab1.tsx b/app/src/pages/Tab1.tsx deleted file mode 100644 index 3885a74..0000000 --- a/app/src/pages/Tab1.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import "./Tab1.css"; - -import { IonContent, IonPage } from "@ionic/react"; -import { useAtom } from "jotai"; - -import { currentTrackAtom } from "../atoms"; -import NowPlayingDisc from "../components/NowPlayingDisc"; - -const Tab1: React.FC = () => { - const [current] = useAtom(currentTrackAtom); - - return ( - - -
- {current && ( - - )} -
-
-
- ); -}; - -export default Tab1; diff --git a/app/src/pages/Tab2.css b/app/src/pages/Tab2.css deleted file mode 100644 index e69de29..0000000 diff --git a/app/src/pages/Tab2.tsx b/app/src/pages/Tab2.tsx deleted file mode 100644 index 8d392d4..0000000 --- a/app/src/pages/Tab2.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import Queue from '../components/Queue'; -import { mocks } from '../mocks'; -import './Tab2.css'; - -import { IonContent, IonPage } from '@ionic/react'; - -const Tab2: React.FC = () => { - return ( - - - - - - ); -}; - -export default Tab2; diff --git a/app/src/pages/Tab3.tsx b/app/src/pages/Tab3.tsx index d6228b6..73c3f49 100644 --- a/app/src/pages/Tab3.tsx +++ b/app/src/pages/Tab3.tsx @@ -1,27 +1,41 @@ -import { IonContent, IonPage } from '@ionic/react'; -import { useAtom } from 'jotai'; -import React from 'react'; +import { IonContent, IonPage } from "@ionic/react"; +import { useAtom } from "jotai"; +import React from "react"; -import { currentTrackAtom } from '../atoms'; -import NowPlayingTab from '../components/NowPlayingTab'; -import Queue from '../components/Queue'; -import { mocks } from '../mocks'; +import { currentTrackAtom } from "../atoms"; +import NowPlayingTab from "../components/NowPlayingTab"; +import Queue from "../components/Queue"; +import { mocks } from "../mocks"; +import "./Tab.css"; +import { darkenAndSaturate } from "../utils"; +import TestCoda from "../components/testCodaDbMongo"; const Tab: React.FC = () => { const [current] = useAtom(currentTrackAtom); - const now = Date.now(); + if (!current) return null; + + const darkerColor = darkenAndSaturate(current.color, 40, 100); + return ( - - {current && ( - - )} + +
+ {current && ( + + )} +
+
diff --git a/app/src/utils.tsx b/app/src/utils.tsx new file mode 100644 index 0000000..8f49a2c --- /dev/null +++ b/app/src/utils.tsx @@ -0,0 +1,46 @@ +export function darkenAndSaturate(color: string, darkenPercent = 20, saturatePercent = 20): string { + const ctx = document.createElement("canvas").getContext("2d"); + if (!ctx) return color; + + ctx.fillStyle = color; + const rgb = ctx.fillStyle; // browser normalizza il colore + ctx.fillStyle = rgb; + const computed = ctx.fillStyle; + + const temp = document.createElement("div"); + temp.style.color = computed; + document.body.appendChild(temp); + const rgbStr = getComputedStyle(temp).color; + document.body.removeChild(temp); + + const match = rgbStr.match(/rgba?\((\d+), (\d+), (\d+)/); + if (!match) return color; + + let [r, g, b] = match.slice(1).map(Number); + + // Convert to HSL + const rNorm = r / 255, gNorm = g / 255, bNorm = b / 255; + const max = Math.max(rNorm, gNorm, bNorm), min = Math.min(rNorm, gNorm, bNorm); + let h = 0, s = 0, l = (max + min) / 2; + + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case rNorm: h = (gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0); break; + case gNorm: h = (bNorm - rNorm) / d + 2; break; + case bNorm: h = (rNorm - gNorm) / d + 4; break; + } + h /= 6; + } + + // Modify saturation and lightness + s = Math.min(1, s + saturatePercent / 100); + l = Math.max(0, l - darkenPercent / 100); + + const hDeg = Math.round(h * 360); + const sPerc = Math.round(s * 100); + const lPerc = Math.round(l * 100); + + return `hsl(${hDeg}, ${sPerc}%, ${lPerc}%)`; +}