geoaccess app

This commit is contained in:
Alessio Ganzarolli 2025-08-02 04:50:15 +02:00
parent 02fa10c2d5
commit 51f507811c
18 changed files with 577 additions and 129 deletions

View file

@ -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<boolean | null>(null);
useEffect(() => {
setCurrent(mocks.nowPlaying)
}, [])
setCurrent(mocks.nowPlaying);
}, []);
// if (accessGranted === false) {
// console.log("access denied.");
// return null;
// }
return (
<IonApp>
{/* {current && <Background imageUrl={current.coverUrl} color={current.color}/>} */}
<LocationAccessChecker onAccessChecked={setAccessGranted} />
<IonReactRouter>
<Tab />
</IonReactRouter>

3
app/src/api_endpoints.ts Normal file
View file

@ -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`

View file

@ -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;
}

View file

@ -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;

View file

@ -13,6 +13,7 @@
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
h1.queue {

View file

@ -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<QueueProps> = ({ songs }) => {
const [showModal, setShowModal] = useState(false);
const [searchText, setSearchText] = useState("");
const handleUpVote = () => {};
return (
<div className="queue-container">
<div className="queue-header">
<h1 className="queue">Up next</h1>
<IonButton
fill="clear"
onClick={() => setShowModal(true)}
className="icon-button"
>
<IonIcon icon={add} />
</IonButton>
<Plus
size={24}
color="white"
strokeWidth={2}
onClick={() => {
setShowModal(true);
}}
/>
</div>
{songs.map((song, index) => (
<div className="song-item" key={index}>
<img
className="cover"
src={song.coverUrl}
alt={`${song.title} cover`}
/>
<div className="text-info">
<div className="title">{song.title}</div>
<div className="artist">{song.artist}</div>
{songs.map((song, index) => {
const isUpvoted = song.upvoted ?? false;
const votes = song.upvotes ?? 0;
return (
<div className="song-item" key={index}>
<img
className="cover"
src={song.coverUrl}
alt={`${song.title} cover`}
/>
<div className="text-info">
<div className="title">{song.title}</div>
<div className="artist">{song.artist}</div>
</div>
<div
className="upvote-container"
style={{
display: "flex",
alignItems: "center",
gap: "6px",
marginLeft: "auto",
cursor: "pointer",
}}
onClick={handleUpVote}
>
<span
style={{ fontWeight: "600", color: "#444", userSelect: "none" }}
>
{votes}
</span>
<ArrowBigUp
size={24}
strokeWidth={2.5}
color={isUpvoted ? "#ff6600" : "#999"}
fill={isUpvoted ? "#ff6600" : "none"}
/>
</div>
</div>
</div>
))}
);
})}
<IonModal isOpen={showModal} onDidDismiss={() => setShowModal(false)}>
<IonHeader>

View file

@ -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 (
<IonPage>
<IonContent className="ion-padding">
<h1>Test UseCoda</h1>
{loading && (
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 10 }}>
<IonSpinner name="crescent" />
</div>
)}
{error && (
<IonText color="danger">
<p>{error}</p>
</IonText>
)}
<IonButton expand="block" onClick={fetchRecords} disabled={loading}>
Ricarica Record
</IonButton>
<h2>Aggiungi Nuovo Record</h2>
<IonInput
placeholder="Title"
value={title}
onIonChange={(e) => setTitle(e.detail.value!)}
/>
<IonInput
placeholder="Artista"
value={artista}
onIonChange={(e) => setArtista(e.detail.value!)}
/>
<IonInput
placeholder="Cover URL"
value={coverUrl}
onIonChange={(e) => setCoverUrl(e.detail.value!)}
/>
<IonInput
placeholder="Color (es. #ff0000)"
value={color}
onIonChange={(e) => setColor(e.detail.value!)}
/>
<IonButton expand="block" onClick={handleAdd} disabled={loading}>
Aggiungi
</IonButton>
<h2>Lista Record</h2>
<IonList>
{records.length === 0 && !loading && <p>Nessun record disponibile</p>}
{records.map((r) => (
<IonItem key={r.id} style={{ borderLeft: `5px solid ${r.color}` }}>
<IonLabel>
<h3>{r.title}</h3>
<p>Artista: {r.artista}</p>
<img
src={r.coverUrl}
alt={r.title}
style={{ width: 50, height: 50, objectFit: 'cover', marginBottom: 5 }}
/>
<p>Voti: {r.voti}</p>
</IonLabel>
<IonButton size="small" onClick={() => updateVoti(r.id, 1)} disabled={loading}>
+1
</IonButton>
<IonButton size="small" onClick={() => updateVoti(r.id, -1)} disabled={loading}>
-1
</IonButton>
<IonButton color="danger" size="small" onClick={() => deleteRecord(r.id)} disabled={loading}>
Elimina
</IonButton>
</IonItem>
))}
</IonList>
</IonContent>
</IonPage>
);
};
export default TestCoda;

135
app/src/hooks/GeoAccess.tsx Normal file
View file

@ -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<boolean | null>(null);
const [error, setError] = useState<string | null>(null);
const [accuracy, setAccuracy] = useState<number | null>(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 (
<IonPage>
<IonContent className="ion-padding" fullscreen>
<IonSpinner name="crescent" />
<IonText>Verifica posizione in corso...</IonText>
</IonContent>
</IonPage>
);
}
if (accessGranted === false) {
return (
<IonPage>
<IonContent className="ion-padding" fullscreen>
<IonText color="danger">
<h2>Accesso non consentito</h2>
<p>La tua posizione non permette luso dellapp.</p>
{error && <p>Errore: {error}</p>}
{accuracy !== null && (
<p>Precisione posizione: {accuracy.toFixed(1)} metri</p>
)}
</IonText>
<IonButton onClick={() => window.location.reload()}>
Riprova
</IonButton>
</IonContent>
</IonPage>
);
}
return null; // accesso consentito, niente da mostrare
};
export default LocationAccessChecker;

113
app/src/hooks/useCoda.tsx Normal file
View file

@ -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<Record[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<Record, "id">) => {
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,
};
};

6
app/src/pages/Tab.css Normal file
View file

@ -0,0 +1,6 @@
.gradient-background {
--background: none !important;
/* height: 100%; */
padding-top: 25px;
padding-bottom: 25px;
}

View file

@ -1,6 +0,0 @@
.tab-1-container {
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
}

View file

@ -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 (
<IonPage>
<IonContent fullscreen className="transparent-content">
<div className="tab-1-container">
{current && (
<NowPlayingDisc
coverUrl={current.coverUrl}
title={current.title}
artist={current.artist}
color={current.color}
/>
)}
</div>
</IonContent>
</IonPage>
);
};
export default Tab1;

View file

View file

@ -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 (
<IonPage>
<IonContent fullscreen className="transparent-content">
<Queue songs={mocks.queue} />
</IonContent>
</IonPage>
);
};
export default Tab2;

View file

@ -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 (
<IonPage>
<IonContent fullscreen>
{current && (
<NowPlayingTab
data={current}
startTime={now - 15000} // es. iniziata 15 sec fa
duration={180000} // 3 minuti
/>
)}
<IonContent fullscreen className="">
<div
className="gradient-background"
style={{
background: `linear-gradient(to bottom, ${current.color}, ${darkerColor})`,
}}
>
{current && (
<NowPlayingTab
data={current}
startTime={now - 15000}
duration={180000}
/>
)}
</div>
<TestCoda />
<Queue songs={mocks.queue} />
</IonContent>
</IonPage>

46
app/src/utils.tsx Normal file
View file

@ -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}%)`;
}