geoaccess app
This commit is contained in:
parent
02fa10c2d5
commit
51f507811c
18 changed files with 577 additions and 129 deletions
|
@ -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
3
app/src/api_endpoints.ts
Normal 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`
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h1.queue {
|
||||
|
|
|
@ -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>
|
||||
|
|
119
app/src/components/testCodaDbMongo.tsx
Normal file
119
app/src/components/testCodaDbMongo.tsx
Normal 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
135
app/src/hooks/GeoAccess.tsx
Normal 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 l’uso dell’app.</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
113
app/src/hooks/useCoda.tsx
Normal 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
6
app/src/pages/Tab.css
Normal file
|
@ -0,0 +1,6 @@
|
|||
.gradient-background {
|
||||
--background: none !important;
|
||||
/* height: 100%; */
|
||||
padding-top: 25px;
|
||||
padding-bottom: 25px;
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
.tab-1-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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
46
app/src/utils.tsx
Normal 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}%)`;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue