feat: app working mongodb queue

This commit is contained in:
Alessio Ganzarolli 2025-08-02 06:15:42 +02:00
parent 01b7fde48e
commit 46c08d8540
12 changed files with 231 additions and 141 deletions

43
app/package-lock.json generated
View file

@ -17,6 +17,7 @@
"@ionic/react-router": "^8.5.0",
"@types/react-router": "^5.1.20",
"@types/react-router-dom": "^5.3.3",
"framer-motion": "^12.23.12",
"ionicons": "^7.4.0",
"jotai": "^2.12.5",
"lucide-react": "^0.536.0",
@ -6560,6 +6561,33 @@
"node": ">= 6"
}
},
"node_modules/framer-motion": {
"version": "12.23.12",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.12.tgz",
"integrity": "sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.23.12",
"motion-utils": "^12.23.6",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fs-extra": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz",
@ -8725,6 +8753,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/motion-dom": {
"version": "12.23.12",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.12.tgz",
"integrity": "sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.23.6"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View file

@ -21,6 +21,7 @@
"@ionic/react-router": "^8.5.0",
"@types/react-router": "^5.1.20",
"@types/react-router-dom": "^5.3.3",
"framer-motion": "^12.23.12",
"ionicons": "^7.4.0",
"jotai": "^2.12.5",
"lucide-react": "^0.536.0",

View file

@ -32,7 +32,7 @@ setupIonicReact();
const App: React.FC = () => {
const [current, setCurrent] = useAtom(currentTrackAtom);
const [accessGranted, setAccessGranted] = useState<boolean | null>(null);
// const [accessGranted, setAccessGranted] = useState<boolean | null>(null);
useEffect(() => {
setCurrent(mocks.nowPlaying);
@ -44,8 +44,9 @@ const App: React.FC = () => {
// }
return (
<IonApp>
<LocationAccessChecker onAccessChecked={setAccessGranted} />
{/* <LocationAccessChecker onAccessChecked={setAccessGranted} /> */}
<IonReactRouter>
<Tab />
</IonReactRouter>

View file

@ -1,5 +1,7 @@
import { atom } from 'jotai';
import { NowPlayingData } from '../mocks';
import { QueueItem } from '../models';
export const themeAtom = atom<'light' | 'dark' | 'red'>('light');
export const currentTrackAtom = atom<NowPlayingData>()
export const codaRecordsAtom = atom<QueueItem[]>([]);

View file

@ -6,6 +6,7 @@
display: block;
position: relative;
border-radius: 8px;
}
.now-playing-overlay {

View file

@ -11,10 +11,11 @@ import {
} from "@ionic/react";
import React, { useState } from "react";
import { ArrowBigUp, Plus } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
interface NowPlayingDiscProps {
id: string;
coverUrl: string;
title: string;
artist: string;
@ -24,13 +25,17 @@ interface NowPlayingDiscProps {
interface QueueProps {
songs: NowPlayingDiscProps[];
onUpvote: (id: string) => void;
}
const Queue: React.FC<QueueProps> = ({ songs }) => {
const Queue: React.FC<QueueProps> = ({ songs, onUpvote }) => {
const [showModal, setShowModal] = useState(false);
const [searchText, setSearchText] = useState("");
const handleUpVote = () => {};
// Ordina i brani in ordine decrescente per upvotes
const sortedSongs = [...songs].sort(
(a, b) => (b.upvotes ?? 0) - (a.upvotes ?? 0)
);
return (
<div className="queue-container">
@ -40,64 +45,72 @@ const Queue: React.FC<QueueProps> = ({ songs }) => {
size={24}
color="white"
strokeWidth={2}
onClick={() => {
setShowModal(true);
}}
onClick={() => setShowModal(true)}
/>
</div>
{songs.map((song, index) => {
const isUpvoted = song.upvoted ?? false;
const votes = song.upvotes ?? 0;
<AnimatePresence>
{sortedSongs.map((song) => {
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}
return (
<motion.div
layout
key={song.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="song-item"
>
<span
style={{ fontWeight: "600", color: "#444", userSelect: "none" }}
>
{votes}
</span>
<ArrowBigUp
size={24}
strokeWidth={2.5}
color={isUpvoted ? "#ff6600" : "#999"}
fill={isUpvoted ? "#ff6600" : "none"}
<img
className="cover"
src={song.coverUrl}
alt={`${song.title} cover`}
/>
</div>
</div>
);
})}
<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={() => onUpvote(song.id)}
>
<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>
</motion.div>
);
})}
</AnimatePresence>
<IonModal isOpen={showModal} onDidDismiss={() => setShowModal(false)}>
<IonHeader>
<IonToolbar>
<IonTitle>Add Songs</IonTitle>
<IonButton
slot="end"
fill="clear"
onClick={() => setShowModal(false)}
>
<IonButton slot="end" fill="clear" onClick={() => setShowModal(false)}>
Close
</IonButton>
</IonToolbar>

View file

@ -9,6 +9,9 @@ import {
IonButton,
IonSpinner,
IonText,
IonGrid,
IonRow,
IonCol,
} from '@ionic/react';
import { useCoda } from '../hooks/useCoda';
@ -24,15 +27,18 @@ const TestCoda: React.FC = () => {
} = useCoda();
// Stati per i nuovi record da aggiungere
const [title, setTitle] = useState('');
const [titolo, setTitolo] = 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('');
if (!titolo || !artista || !coverUrl || !color) {
alert('Compila tutti i campi prima di aggiungere');
return;
}
addRecord({ titolo, artista, coverUrl, color, voti: 0 });
setTitolo('');
setArtista('');
setCoverUrl('');
setColor('');
@ -61,9 +67,9 @@ const TestCoda: React.FC = () => {
<h2>Aggiungi Nuovo Record</h2>
<IonInput
placeholder="Title"
value={title}
onIonChange={(e) => setTitle(e.detail.value!)}
placeholder="Titolo"
value={titolo}
onIonChange={(e) => setTitolo(e.detail.value!)}
/>
<IonInput
placeholder="Artista"
@ -88,26 +94,39 @@ const TestCoda: React.FC = () => {
<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
key={r.id}
style={{ borderLeft: `5px solid ${r.color}`, alignItems: 'center' }}
>
<IonGrid>
<IonRow>
<IonCol size="3">
<img
src={r.coverUrl}
alt={r.titolo}
style={{ width: '100%', height: 60, objectFit: 'cover', borderRadius: 4 }}
/>
</IonCol>
<IonCol size="5">
<IonLabel>
<h3>{r.titolo}</h3>
<p>Artista: {r.artista}</p>
<p>Voti: {r.voti}</p>
</IonLabel>
</IonCol>
<IonCol size="4" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<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>
</IonCol>
</IonRow>
</IonGrid>
</IonItem>
))}
</IonList>

View file

@ -1,9 +1,11 @@
import { useState, useEffect } from "react";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { MONGO_DB_CODA_API } from "../api_endpoints";
import { codaRecordsAtom } from "../atoms";
const API_URL = MONGO_DB_CODA_API;
export interface Record {
export interface QueueItem {
id: string;
titolo: string;
coverUrl: string;
@ -13,15 +15,15 @@ export interface Record {
}
export const useCoda = () => {
const [records, setRecords] = useState<Record[]>([]);
const [records, setRecords] = useAtom(codaRecordsAtom);
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");
const msg = await res.text();
throw new Error(msg || "Errore nella chiamata fetch");
}
return res.json();
};
@ -30,12 +32,8 @@ export const useCoda = () => {
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");
}
setRecords(response.data);
setError(null);
} catch (e: any) {
setError(e.message || "Errore fetch");
} finally {
@ -43,20 +41,25 @@ export const useCoda = () => {
}
};
const addRecord = async (record: Omit<Record, "id">) => {
useEffect(() => {
fetchRecords(); // chiamata iniziale
const interval = setInterval(() => {
fetchRecords(); // polling ogni 5 secondi
}, 5000);
return () => clearInterval(interval); // pulizia su unmount
}, []);
const addRecord = async (record: Omit<QueueItem, "id">) => {
try {
const body = JSON.stringify(record);
const response = await fetchJson(`${API_URL}/add`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
body: JSON.stringify(record),
});
if (response.success) {
setRecords((prev) => [...prev, response.data]);
setError(null);
} else {
throw new Error("Errore aggiunta record");
}
setRecords((prev) => [...prev, response.data]);
setError(null);
} catch (e: any) {
setError(e.message || "Errore add");
}
@ -64,43 +67,28 @@ export const useCoda = () => {
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");
}
await fetchJson(`${API_URL}/delete/${id}`, { method: "DELETE" });
setRecords((prev) => prev.filter((r) => r.id !== id));
setError(null);
} catch (e: any) {
setError(e.message || "Errore delete");
}
};
const updateVoti = async (id: string, delta: 1 | -1) => {
const updateVoti = async (id: string, increment: boolean) => {
try {
const response = await fetchJson(`${API_URL}/vote/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ increment: delta === 1 }),
body: JSON.stringify({ increment }),
});
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");
}
setRecords((prev) => prev.map((r) => (r.id === id ? response.data : r)));
setError(null);
} catch (e: any) {
setError(e.message || "Errore update voti");
}
};
useEffect(() => {
fetchRecords();
}, []);
return {
records,
loading,

8
app/src/models.tsx Normal file
View file

@ -0,0 +1,8 @@
export interface QueueItem {
id: string;
titolo: string;
coverUrl: string;
artista: string;
color: string;
voti: number;
}

View file

@ -3,4 +3,5 @@
/* height: 100%; */
padding-top: 25px;
padding-bottom: 25px;
border-radius: 0 0 20px 20px;
}

View file

View file

@ -1,42 +1,55 @@
import { IonContent, IonPage } from "@ionic/react";
import { useAtom } from "jotai";
import React from "react";
import './Tab.css';
import { currentTrackAtom } from "../atoms";
import NowPlayingTab from "../components/NowPlayingTab";
import Queue from "../components/Queue";
import { mocks } from "../mocks";
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 { useCoda } from '../hooks/useCoda';
import { darkenAndSaturate } from '../utils';
import "./Tab.css";
import { darkenAndSaturate } from "../utils";
import TestCoda from "../components/testCodaDbMongo";
const Tab: React.FC = () => {
const { records, loading, error, updateVoti } = useCoda();
const [current] = useAtom(currentTrackAtom);
const now = Date.now();
// if (loading) return <div>Loading...</div>;
// if (error) return <div>Error: {error}</div>;
// if (records.length === 0) return <div>No songs in queue</div>;
if (!current) return null;
const darkerColor = darkenAndSaturate(current.color, 40, 100);
// Mappa i record nel formato richiesto da Queue
const songs = records.map((r) => ({
id: r.id,
coverUrl: r.coverUrl,
title: r.titolo,
artist: r.artista,
upvotes: r.voti,
upvoted: false,
}));
return (
<IonPage>
<IonContent fullscreen className="">
<IonContent fullscreen>
<div
className="gradient-background"
style={{
background: `linear-gradient(to bottom, ${current.color}, ${darkerColor})`,
}}
>
{current && (
<NowPlayingTab
data={current}
startTime={now - 15000}
duration={180000}
/>
)}
<NowPlayingTab data={current} startTime={now - 15000} duration={180000} />
</div>
<TestCoda />
<Queue songs={mocks.queue} />
<Queue
songs={songs}
onUpvote={(id) => {
updateVoti(id, true); // Incrementa voti
}}
/>
</IonContent>
</IonPage>
);