diff --git a/README.md b/README.md index c772e7a..b0fe93e 100644 --- a/README.md +++ b/README.md @@ -1,121 +1,103 @@ - # World Video Guide -A personal web app that lets you **explore the world through geolocated YouTube videos by country**. -Users can suggest content, while **moderators/admins** review it (approve/reject) before it appears publicly on the map. - -## Features +**World Video Guide** è un'applicazione web interattiva progettata per esplorare e scoprire contenuti video da tutto il mondo. Attraverso una mappa globale, gli utenti possono navigare tra le nazioni, visualizzare video suggeriti dalla community e partecipare a discussioni. Il progetto è stato sviluppato con un'architettura moderna e tecnologie all'avanguardia, ponendo un forte accento sull'esperienza utente, la reattività e la scalabilità. -- **Interactive map** (world-atlas) with countries highlighted based on the number of approved videos. -- **Country overlay** with an approved video feed and **category** filters. -- **Video suggestion** (YouTube URL) with multiple categories + custom category. -- **Firebase Authentication**: Google, GitHub, Apple, Email/Password. -- **Moderation**: pending queue, approve/reject with reason. -- **User profile**: stats, suggested videos list, approved/rejected history, video removal. -- **Roles**: `user`, `moderator`, `admin` (admin only: user/role management). +L'applicazione è concepita come una **Progressive Web App (PWA)**, garantendo un'esperienza nativa su dispositivi mobili, funzionalità offline e notifiche push per un coinvolgimento continuo. -## Stack +## Sito Online -- **Vite** + **React 19** + **TypeScript** -- **Tailwind CSS v4** -- **Firebase** (Auth + Firestore) + **Firebase Hosting** -- **react-router-dom** (routing) -- **react19-simple-maps** (map) -- **Framer Motion** (UI animations) + Swiper (region selector on mobile) +Il sito è attualmente online grazie a **Firebase Hosting** e puoi provarlo al seguente link: [https://prova-mappa-f1d90.web.app/](https://prova-mappa-f1d90.web.app/). -## Running Locally +## Architettura e Stack Tecnologico -### Requirements +Il progetto si basa su un'architettura client-server disaccoppiata, con un frontend reattivo e un backend serverless robusto. -- Node.js (recommended **>= 20 LTS**) -- A Firebase project with Auth + Firestore enabled +### Frontend -### Setup +* **React 19**: Utilizzato per costruire un'interfaccia utente dinamica e component-based. L'uso di React Hooks (come `useState`, `useEffect`, `useRef`) permette una gestione dello stato e del ciclo di vita dei componenti pulita ed efficiente. +* **TypeScript**: Garantisce un codice robusto, manutenibile e con type-safety, riducendo i bug in fase di sviluppo. +* **Vite**: Un build tool di nuova generazione che offre un'esperienza di sviluppo estremamente rapida grazie al suo server di sviluppo nativo ESM e al bundling ottimizzato per la produzione. +* **Tailwind CSS**: Un framework CSS utility-first che permette di creare design complessi e reattivi direttamente nell'HTML, garantendo coerenza stilistica e rapidità di sviluppo. +* **Framer Motion**: Una libreria di animazione per React che consente di creare animazioni fluide e complesse con un'API dichiarativa e intuitiva. +* **React Router**: Per la gestione del routing lato client, permettendo una navigazione fluida tra le diverse sezioni dell'applicazione (Home, Profilo, Admin). +* **Vite PWA Plugin**: Per trasformare l'applicazione in una Progressive Web App, gestendo il service worker, la cache e il manifest dell'app. -1) Install dependencies: +### Backend & Servizi -```bash -npm ci -``` +* **Firebase**: Una piattaforma completa di Google che fornisce i seguenti servizi: + * **Firestore**: Un database NoSQL flessibile e scalabile utilizzato per memorizzare tutti i dati dell'applicazione, come utenti, video, commenti, categorie e segnalazioni. Le query in tempo reale garantiscono che l'interfaccia utente sia sempre sincronizzata con il database. + * **Firebase Authentication**: Gestisce l'autenticazione degli utenti tramite provider multipli (Google, GitHub, Email/Password) in modo sicuro e scalabile. + * **Firebase Cloud Messaging**: Utilizzato per inviare notifiche push multi-dispositivo, permettendo agli utenti di rimanere aggiornati sui nuovi video nei paesi che seguono. +* **Render (Backend Service)**: Un servizio di hosting per un piccolo backend Node.js/Express che gestisce la logica di iscrizione e invio delle notifiche push, interfacciandosi con Firebase Cloud Messaging. -2) Configure environment variables: +## Feature Principali -- Create a `.env.local` file starting from `.env.example`. -- Fill in the values using Firebase Console -> Project settings -> Your apps -> (Web app config). +L'applicazione offre un'ampia gamma di funzionalità pensate per la community e gli amministratori. -```bash -cp .env.example .env.local -``` - -3) Start in development mode: - -```bash -npm run dev -``` +### Per gli Utenti -## Available Scripts +* **Esplorazione Globale**: Una mappa del mondo interattiva (`@vnedyalk0v/react19-simple-maps`) permette di selezionare i paesi e visualizzare i video associati. +* **Autenticazione Multi-Provider**: Login semplice e sicuro tramite Google, GitHub o credenziali email/password. +* **Suggerimento di Video**: Gli utenti autenticati possono suggerire nuovi video per ogni paese, che verranno poi sottoposti a revisione. +* **Chat per Paese**: Ogni nazione ha una chat dedicata dove gli utenti possono discutere e scambiarsi opinioni. +* **Notifiche Push**: Gli utenti possono iscriversi alle notifiche per i loro paesi preferiti e ricevere un avviso quando un nuovo video viene approvato. Il sistema supporta notifiche su più dispositivi per lo stesso utente. +* **Profilo Utente**: Una pagina personale dove gli utenti possono visualizzare le statistiche sui video suggeriti (in attesa, approvati, rifiutati) e gestire i paesi seguiti. +* **Segnalazione di Contenuti**: Possibilità di segnalare commenti e video inappropriati per la revisione da parte dei moderatori. +* **PWA (Progressive Web App)**: L'applicazione è installabile sulla home screen dei dispositivi mobili e offre un' ottima esperienza offline. -- `npm run dev` — starts the development server -- `npm run build` — production build (TypeScript + Vite) -- `npm run preview` — build preview -- `npm run lint` — ESLint +### Per gli Amministratori -## Firebase: Recommended Configuration +* **Pannello di Amministrazione**: Un'area riservata per moderatori e amministratori con strumenti per la gestione dei contenuti. +* **Revisione dei Video**: Un'interfaccia per approvare o rifiutare i video suggeriti dagli utenti. In caso di rifiuto, è possibile specificare una motivazione. +* **Gestione Dinamica delle Categorie**: + * Gli amministratori possono modificare le categorie associate a un video prima di approvarlo. + * Se una categoria non esiste, viene creata dinamicamente al momento dell'approvazione del video. + * **Gestione degli Alias**: È possibile unire categorie "grezze" (es. "cucina tipica") a categorie ufficiali (es. "cibo") creando un alias. I futuri suggerimenti con lo stesso nome verranno automaticamente associati alla categoria corretta. +* **Gestione delle Segnalazioni**: Liste separate per visualizzare e gestire i commenti e i video segnalati dagli utenti. +* **Gestione Utenti**: Visualizzazione di tutti gli utenti registrati con la possibilità di modificarne il ruolo (user, moderator, admin). -### Authentication +## Istruzioni per l'Installazione Locale -Enable the providers you want to use: +Per eseguire il progetto in locale, segui questi passaggi: -- Google -- GitHub -- Apple (`apple.com` via OAuth provider) -- Email/Password +1. **Clona il repository:** + ```bash + git clone https://github.com/DaPrato4/world-video-guide + cd world-video-guide + ``` -Note: if you enable GitHub/Apple, you must also configure the **OAuth redirect URIs** in the provider settings. +2. **Installa le dipendenze:** + Assicurati di avere Node.js (versione 18 o successiva) e npm installati. + ```bash + npm install + ``` -### Firestore: Used Collections +3. **Configura le variabili d'ambiente:** + Crea un file `.env` nella root del progetto e copia il contenuto del template qui sotto. -The app relies on three collections: +4. **Avvia il server di sviluppo:** + ```bash + npm run dev + ``` + L'applicazione sarà disponibile all'indirizzo `http://localhost:5173`. -- `users` (doc id = `uid`) - - `uid`, `email`, `displayName`, `role`, `photoURL` - - `stats`: `pendingVideos`, `approvedVideos`, `rejectedVideos`, `suggestedVideos` -- `videos` - - `url` (YouTube) - - `countryCode` (numeric, e.g. `380`) - - `status`: `pending | approved | rejected` - - `categories`: `string[]` - - `submittedBy`: `uid` - - `createdAt` (Date/Timestamp) - - `rejectionReason` (only if rejected) -- `categories` - - documents with a `category` field (label shown in the UI) +### Template per il file `.env` -### Indexes +Crea un file chiamato `.env` nella directory principale del progetto e inserisci le tue credenziali Firebase. -In the user profile, a query `where(submittedBy == uid) + orderBy(createdAt desc)` is executed. -If Firestore requires it, create the **composite index** suggested by the error link in the console. +```env +# Firebase Configuration +VITE_FIREBASE_API_KEY="YOUR_API_KEY" +VITE_FIREBASE_AUTH_DOMAIN="YOUR_AUTH_DOMAIN" +VITE_FIREBASE_PROJECT_ID="YOUR_PROJECT_ID" +VITE_FIREBASE_STORAGE_BUCKET="YOUR_STORAGE_BUCKET" +VITE_FIREBASE_MESSAGING_SENDER_ID="YOUR_MESSAGING_SENDER_ID" +VITE_FIREBASE_APP_ID="YOUR_APP_ID" +VITE_FIREBASE_VAPID_KEY="YOUR_VAPID_KEY_FOR_FCM" -## Deploy (Firebase Hosting) -The project is already configured as an SPA (rewrite to `index.html`). - -```bash -npm run build -firebase deploy --only hosting ``` -If you want to target your own Firebase project: - -- edit `.firebaserc` or use `firebase use --add` - -## Technical Notes - -- Video metadata (title/thumbnail) is fetched through **YouTube oEmbed**. -- Country info (name/flag/capital coordinates) comes from **REST Countries**. -- The map uses the **world-atlas** dataset (TopoJSON) fetched from a CDN. - -## License - -MIT - see [LICENSE](LICENSE). +--- +Questo progetto rappresenta una dimostrazione completa di come integrare diverse tecnologie moderne per creare un'applicazione web ricca di funzionalità, scalabile e manutenibile. diff --git a/public/screenshot-1.png b/public/screenshot-1.png deleted file mode 100644 index 75ab941..0000000 Binary files a/public/screenshot-1.png and /dev/null differ diff --git a/public/screenshot-2.png b/public/screenshot-2.png deleted file mode 100644 index 75ab941..0000000 Binary files a/public/screenshot-2.png and /dev/null differ diff --git a/src/App.tsx b/src/App.tsx index ea5fc4f..abdbc44 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -91,8 +91,6 @@ export default function App() { ); if (addedCountries.length > 0 || removedCountries.length > 0) { - console.log("Rilevate nuove iscrizioni da un altro dispositivo:", addedCountries); - console.log("Rilevate cancellazioni da un altro dispositivo:", removedCountries); try { const currentToken = await requestForToken(); @@ -122,7 +120,6 @@ export default function App() { }) }); } - console.log("Dispositivo auto-sincronizzato con i nuovi paesi!"); } } catch (error) { console.error("Errore di auto-sincronizzazione in background:", error); @@ -139,6 +136,8 @@ export default function App() { photoURL: firebaseUser.photoURL ?? "", stats: userData.stats || { pendingVideos: 0, approvedVideos: 0, rejectedVideos: 0, suggestedVideos: 0 }, subscriptions: userData.subscriptions || [], + reportedComments: userData.reportedComments || [], + reportedVideos: userData.reportedVideos || [], }); } else { // Se il documento non esiste, lo creiamo (fatto una sola volta) @@ -150,6 +149,8 @@ export default function App() { photoURL: firebaseUser.photoURL ?? "", stats: { pendingVideos: 0, approvedVideos: 0, rejectedVideos: 0, suggestedVideos: 0 }, subscriptions: [], + reportedComments: [], + reportedVideos: [], }; setDoc(userDocRef, newUser); setUser(newUser); diff --git a/src/components/admin/CategoryEditor.tsx b/src/components/admin/CategoryEditor.tsx index b59365e..c799316 100644 --- a/src/components/admin/CategoryEditor.tsx +++ b/src/components/admin/CategoryEditor.tsx @@ -28,7 +28,10 @@ export default function CategoryEditor({ const nextCategory = customCategory.trim().toLowerCase(); if (!nextCategory) return; - setEditedCategories((current) => (current.includes(nextCategory) ? current : [...current, nextCategory])); + setEditedCategories((current) => + current.some((item) => item.toLocaleLowerCase() === nextCategory) + ? current + : [...current, nextCategory]); setCustomCategory(""); }; diff --git a/src/components/admin/CommentReportsList.tsx b/src/components/admin/CommentReportsList.tsx new file mode 100644 index 0000000..baabd5d --- /dev/null +++ b/src/components/admin/CommentReportsList.tsx @@ -0,0 +1,184 @@ + +import { FaExclamationTriangle } from "react-icons/fa"; +import { useEffect, useState } from "react"; +import { arrayRemove, collection, deleteDoc, doc, documentId, getDocs, query, updateDoc, where } from "firebase/firestore"; +import { db } from "../../firebase"; + +interface Reporter { + uid: string; + displayName?: string; + email?: string; +} + +interface ReportItem { + commentId: string; + commentText?: string; + countryCode?: number | string; + commenterName?: string; + reporters: Reporter[]; + countryName?: string; + countryFlagUrl?: string; +} + +export default function CommentReportsList() { + + const [reports, setReports] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const loadReports = async () => { + try { + const q = query(collection(db, "users"), where("reportedComments", "!=", [])); + const pendingReports = await getDocs(q); + const fetchedReports: ReportItem[] = pendingReports.docs.map(doc => { + const data = doc.data(); + return data.reportedComments.map((commentId: string) => ({ + commentId: commentId, + reporters: [{ uid: doc.id, displayName: data.displayName }] + })); + }).flat(); + + const reportPromises = fetchedReports.map(async (report) => { + try { + const q = query(collection(db, "comments"), where(documentId(), "==", report.commentId)); + const snapshot = await getDocs(q); + if (!snapshot.empty) { + const commentData = snapshot.docs[0].data() as { userDisplayName: string; text: string; countryCode: number | string }; + return { + ...report, + commenterName: commentData.userDisplayName, + commentText: commentData.text, + countryCode: commentData.countryCode + } as ReportItem; + } + return report; + } catch (error) { + console.error("Errore nel recupero dei dettagli del commento:", error); + return report; + } + }); + + const reportInfo: ReportItem[] = await Promise.all(reportPromises); + + const countryPromises = reportInfo.map(async (report) => { + if (report.countryCode) { + try { + const countryCode = String(report.countryCode).padStart(3, '0'); + const res = await fetch(`https://restcountries.com/v3.1/alpha/${countryCode}`); + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + const countryData = await res.json(); + return { + ...report, + countryName: countryData[0]?.name?.common || 'N/D', + countryFlagUrl: countryData[0]?.flags?.svg || null + }; + } catch (error) { + console.error("Errore nel recupero dei dettagli del paese:", error); + return { + ...report, + countryName: 'N/D', + countryFlagUrl: null + }; + } + } + return report; + }); + + const reportInfoWithCountries = await Promise.all(countryPromises); + + setReports(reportInfoWithCountries); + setLoading(false); + } catch (error) { + console.error("Errore nel caricamento delle segnalazioni commenti:", error); + setLoading(false); + } + }; + + loadReports(); + }, []); + + + const handleRemoveComment = async (commentId: string) => { + try { + await deleteDoc(doc(db, "comments", commentId)); + handleCancelReport(commentId); + } catch (error) { + console.error("Errore nella rimozione del commento:", error); + } + }; + + const handleCancelReport = async (commentId: string) => { + try { + await getDocs(query(collection(db, "users"), where("reportedComments", "array-contains", commentId))).then(snapshot => { + snapshot.forEach(doc => { + const userRef = doc.ref; + updateDoc(userRef, { reportedComments: arrayRemove(commentId) }); + }); + }); + setReports(prev => prev.filter(r => r.commentId !== commentId)); + } catch (error) { + console.error("Errore nell'annullamento della segnalazione:", error); + } + + }; + + return ( +
+
+
+
+ +
+
+

Segnalazioni Commenti

+

{reports.length} segnalazione{reports.length !== 1 ? 'i' : ''}

+
+
+
+ + {loading ? ( +
+ {[1,2].map(n =>
)} +
+ ) : reports.length === 0 ? ( +
+

Nessuna segnalazione

+

Non ci sono commenti segnalati da revisionare

+
+ ) : ( +
+ <> + {reports.map(r => ( +
+
+
+
Autore commento: {r.commenterName || 'Utente sconosciuto'}
+
{r.commentText}
+
Segnalato da: {r.reporters.map(p => p.displayName || p.uid).join(', ')}
+
+ + {String(r.countryCode ((e.currentTarget as HTMLImageElement).style.display = 'none')} + /> + + Paese: {r.countryName ?? 'N/D'} +
+
+
+ + +
+
+
+ ))} + +
+ )} +
+ ); +} diff --git a/src/components/admin/VideoReportsList.tsx b/src/components/admin/VideoReportsList.tsx new file mode 100644 index 0000000..d693133 --- /dev/null +++ b/src/components/admin/VideoReportsList.tsx @@ -0,0 +1,208 @@ +import { FaExclamationTriangle } from "react-icons/fa"; +import { useEffect, useState } from "react"; +import { arrayRemove, collection, deleteDoc, doc, documentId, getDocs, query, updateDoc, where } from "firebase/firestore"; +import { db } from "../../firebase"; + +interface Reporter { + uid: string; + displayName?: string; + email?: string; +} + +interface ReportVideoItem { + videoId: string; + title?: string; + thumbnailUrl?: string | null; + countryCode?: number | string; + submitterName?: string; + reporters: Reporter[]; + countryName?: string; + countryFlagUrl?: string | null; +} + +export default function VideoReportsList() { + + const [reports, setReports] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const loadReports = async () => { + try { + const q = query(collection(db, "users"), where("reportedVideos", "!=", [])); + const pendingReports = await getDocs(q); + const fetchedReports: ReportVideoItem[] = pendingReports.docs.map(doc => { + const data = doc.data(); + return data.reportedVideos.map((videoId: string) => ({ + videoId: videoId, + reporters: [{ uid: doc.id, displayName: data.displayName }] + })); + }).flat(); + + const reportPromises = fetchedReports.map(async (report) => { + try { + const q = query(collection(db, "videos"), where(documentId(), "==", report.videoId)); + const snapshot = await getDocs(q); + if (!snapshot.empty) { + const videoData: any = snapshot.docs[0].data(); + // Try to get submitter displayName + let submitterName = "Utente sconosciuto"; + try { + if (videoData?.submittedBy) { + const userQ = query(collection(db, "users"), where("uid", "==", videoData.submittedBy)); + const userSnap = await getDocs(userQ); + if (!userSnap.empty) submitterName = (userSnap.docs[0].data() as any).displayName || submitterName; + } + } catch (e) { + console.error("Errore nel recupero del submitter:", e); + } + + const videoMetadata = await fetch(`https://www.youtube.com/oembed?url=${videoData.url}&format=json`).then(res => res.json()).catch(() => null); + + return { + ...report, + title: videoMetadata?.title || "Titolo non disponibile", + thumbnailUrl: videoMetadata?.thumbnail_url || null, + submitterName, + countryCode: videoData.countryCode + } as ReportVideoItem; + } + return report; + } catch (error) { + console.error("Errore nel recupero dei dettagli del video:", error); + return report; + } + }); + + const reportInfo: ReportVideoItem[] = await Promise.all(reportPromises); + + const countryPromises = reportInfo.map(async (report) => { + if (report.countryCode) { + try { + const countryCode = String(report.countryCode).padStart(3, '0'); + const res = await fetch(`https://restcountries.com/v3.1/alpha/${countryCode}`); + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + const countryData = await res.json(); + return { + ...report, + countryName: countryData[0]?.name?.common || 'N/D', + countryFlagUrl: countryData[0]?.flags?.svg || null + }; + } catch (error) { + console.error("Errore nel recupero dei dettagli del paese:", error); + return { + ...report, + countryName: 'N/D', + countryFlagUrl: null + }; + } + } + return report; + }); + + const reportInfoWithCountries = await Promise.all(countryPromises); + + setReports(reportInfoWithCountries); + setLoading(false); + } catch (error) { + console.error("Errore nel caricamento delle segnalazioni video:", error); + setLoading(false); + } + }; + + loadReports(); + }, []); + + + const handleRemoveVideo = async (videoId: string) => { + try { + await deleteDoc(doc(db, "videos", videoId)); + handleCancelReport(videoId); + } catch (error) { + console.error("Errore nella rimozione del video:", error); + } + }; + + const handleCancelReport = async (videoId: string) => { + try { + await getDocs(query(collection(db, "users"), where("reportedVideos", "array-contains", videoId))).then(snapshot => { + snapshot.forEach(doc => { + const userRef = doc.ref; + updateDoc(userRef, { reportedVideos: arrayRemove(videoId) }); + }); + }); + setReports(prev => prev.filter(r => r.videoId !== videoId)); + } catch (error) { + console.error("Errore nell'annullamento della segnalazione:", error); + } + }; + + return ( +
+
+
+
+ +
+
+

Segnalazioni Video

+

{reports.length} segnalazione{reports.length !== 1 ? 'i' : ''}

+
+
+
+ + {loading ? ( +
+ {[1,2].map(n =>
)} +
+ ) : reports.length === 0 ? ( +
+

Nessuna segnalazione

+

Non ci sono video segnalati da revisionare

+
+ ) : ( +
+ <> + {reports.map(r => ( +
+
+
+
+ {r.thumbnailUrl ? ( + {r.title} ((e.currentTarget as HTMLImageElement).style.display = 'none')} /> + ) : ( +
+ )} +
+
Titolo:
+
{r.title}
+
Proposto da: {r.submitterName || 'Utente sconosciuto'}
+
+
+
Segnalato da: {r.reporters.map(p => p.displayName || p.uid).join(', ')}
+
+ + {String(r.countryCode ((e.currentTarget as HTMLImageElement).style.display = 'none')} + /> + + Paese: {r.countryName ?? 'N/D'} +
+
+
+ + +
+
+
+ ))} + +
+ )} +
+ ); +} diff --git a/src/components/common/Header.tsx b/src/components/common/Header.tsx index 1a78cbb..6fce58e 100644 --- a/src/components/common/Header.tsx +++ b/src/components/common/Header.tsx @@ -18,9 +18,11 @@ export default function Header({ user, page }: { user: user | null; page: string const [isLoginOpen, setIsLoginOpen] = useState(false); const [alert, setAlert] = useState<{ type: "success" | "error"; message: string } | null>(null); + const [isLoggingOut, setIsLoggingOut] = useState(false); const logOut = async () => { + setIsLoggingOut(true); // 1. Fase di pulizia: Disiscrizione da tutti i Topic if (user) { try { @@ -39,9 +41,8 @@ export default function Header({ user, page }: { user: user | null; page: string }); } } catch (error) { - // L'utente non ha mai accettato le notifiche o è offline. - // Va bene così, non blocchiamo il logout. - console.log("Nessun token attivo per la disiscrizione al logout."); + // L'utente non ha mai accettato le notifiche o è offline. Non è un errore critico, quindi lo logghiamo ma non blocchiamo la disconnessione. + console.warn("Non è stato possibile disiscrivere dalle notifiche (l'utente potrebbe non averle accettate o essere offline):", error); } } @@ -53,6 +54,7 @@ export default function Header({ user, page }: { user: user | null; page: string console.error("Errore disconnessione:", error); setAlert({ type: "error", message: "Si è verificato un errore durante la disconnessione." }); } + setIsLoggingOut(false); }; return ( @@ -105,7 +107,12 @@ export default function Header({ user, page }: { user: user | null; page: string
{user ? ( - logOut()} align="right" /> + logOut()} + align="right" + isLoggingOut={isLoggingOut} + /> ) : ( - -
@@ -67,16 +101,13 @@ export default function UserMenu({ user, onLogout, align = "right" }: Props) {
- {user.photoURL && !imgError ? ( - {user.displayName setImgError(true)} - /> - ) : ( - {initials || } - )} + setImgError(true)} + />
{user.displayName || user.email}
diff --git a/src/components/home/CountryChat.tsx b/src/components/home/CountryChat.tsx new file mode 100644 index 0000000..7c5b31d --- /dev/null +++ b/src/components/home/CountryChat.tsx @@ -0,0 +1,195 @@ +import { useEffect, useState, useRef } from "react"; +import { motion } from "framer-motion"; +import { FiAlertTriangle, FiMessageCircle, FiX } from "react-icons/fi"; +import { collection, addDoc, query, where, getDocs, orderBy, serverTimestamp, doc, updateDoc, arrayUnion } from "firebase/firestore"; +import { db } from "../../firebase"; +import type { user } from "../../types"; + +interface CountryChatProps { + countryId: number; + countryName: string; + user: user | null; + onClose: () => void; + onAlert: (message: string, isSuccess: boolean) => void; +} + +export default function CountryChat({ countryId, countryName, user, onClose, onAlert}: CountryChatProps) { + const [comments, setComments] = useState([]); + const [newComment, setNewComment] = useState(""); + const [isLoadingComments, setIsLoadingComments] = useState(false); + + // Per far scorrere la chat in basso in automatico + const chatEndRef = useRef(null); + + const fetchComments = async () => { + setIsLoadingComments(true); + try { + const q = query( + collection(db, "comments"), + where("countryCode", "==", countryId), + orderBy("createdAt", "asc") + ); + const snapshot = await getDocs(q); + const fetched = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); + setComments(fetched); + } catch (error) { + console.error("Errore caricamento commenti:", error); + } finally { + setIsLoadingComments(false); + } + }; + + useEffect(() => { + fetchComments(); + }, [countryId]); + + // Scroll automatico verso il basso quando i commenti cambiano + useEffect(() => { + chatEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [comments]); + + const handleAddComment = async (e: any) => { + e.preventDefault(); + if (!user || !newComment.trim()) return; + + //controllo connessione internet + if (!navigator.onLine) { + onAlert("Sei offline. Riprova quando sei connesso a internet.", false); + return; + } + + try { + await addDoc(collection(db, "comments"), { + countryCode: countryId, + userId: user.uid, + userDisplayName: user.displayName || "Utente Anonimo", + text: newComment.trim(), + createdAt: serverTimestamp() + }); + setNewComment(""); + fetchComments(); // Ricarica la lista per vedere il nuovo commento + onAlert("Commento inviato con successo!", true); + } catch (error) { + console.error("Errore invio commento:", error); + onAlert("Errore nell'invio del commento.", false); + } + }; + + const handleReportComment = async (commentId: string) => { + if (!user) { + onAlert("Devi essere loggato per segnalare un commento.", false); + return; + } + + try { + const reportedComments = user.reportedComments ?? []; + + if (reportedComments.length >= 5) { + onAlert("Hai raggiunto il limite di segnalazioni. Per favore, attendi prima di segnalare altri commenti.", false); + return; + } + + if (reportedComments.includes(commentId)) { + onAlert("Hai già segnalato questo commento.", false); + return; + } + + const commentRef = doc(db, "users", user.uid); + await updateDoc(commentRef, { + reportedComments: arrayUnion(commentId) + }); + onAlert("Commento segnalato. Grazie per il tuo feedback!", true); + } catch (error) { + console.error("Errore segnalazione commento:", error); + onAlert("Errore nella segnalazione del commento.", false); + } + }; + + return ( + e.stopPropagation()} + > + {/* Header Chat */} +
+

+ Commenti {countryName} +

+ +
+ + {/* Lista Commenti */} +
+ {isLoadingComments ? ( +
Caricamento commenti...
+ ) : comments.length === 0 ? ( +
+ +

Ancora nessun commento.
Rompi il ghiaccio!

+
+ ) : ( + comments.map(c => ( +
+ {user ? ( +
+ + +
+ ) : null } + +
+ {c.userDisplayName} +
+ {c.text} +
+
+
+ )) + )} + {/* Elemento invisibile per forzare lo scroll in basso */} +
+
+ + {/* Form Inserimento Commento */} +
+ {user ? ( +
+ setNewComment(e.target.value)} + placeholder="Scrivi un messaggio..." + className="flex-1 bg-neutral-950 border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white focus:outline-none focus:border-blue-500 transition-colors" + /> + +
+ ) : ( +
+ Devi accedere per partecipare alla discussione. +
+ )} +
+ + ); +} \ No newline at end of file diff --git a/src/components/home/CountryOverlay.tsx b/src/components/home/CountryOverlay.tsx index db7346e..2562aec 100644 --- a/src/components/home/CountryOverlay.tsx +++ b/src/components/home/CountryOverlay.tsx @@ -1,23 +1,27 @@ import { useEffect, useState } from "react"; import { AnimatePresence, motion } from "framer-motion"; -import type { video } from "../../types"; +import type { Country, user, video } from "../../types"; import { collection, addDoc, doc, updateDoc, increment, query, where, getDocs } from "firebase/firestore"; import { db } from "../../firebase"; import SuggestVideoModal from "./SuggestVideoOverlay"; import Alert from "../common/Alert"; -import { FiFolderMinus, FiPlus, FiVideo, FiUser, FiBell, FiBellOff } from "react-icons/fi"; +import { FiFolderMinus, FiPlus, FiVideo, FiUser, FiBell, FiBellOff, FiAlertTriangle } from "react-icons/fi"; +import { FaPlay } from "react-icons/fa"; import { TbWorld } from "react-icons/tb"; import { getToken } from "firebase/messaging"; import { messaging } from "../../firebase"; import { EXPRESS_API_URL } from "../../apiConfig"; +import { FiMessageCircle } from "react-icons/fi"; // Assicurati di averla importata +import CountryChat from "./CountryChat"; // Il nuovo componente + interface CountryOverlayProps { - country: any; + country: Country; videos: video[]; onClose: () => void; - user: any; + user: user | null; onLogin: () => void; flagUrl?: string; } @@ -35,6 +39,8 @@ export default function CountryOverlay({ country, videos : videosWithoutMetadata const countryName = country.itName || country.name || "Nome sconosciuto"; const countryVideos = videos; + const [isChatOpen, setIsChatOpen] = useState(false); + // Quando il componente si monta, recupera titolo e thumbnail per ciascuno usando l'endpoint oEmbed di YouTube useEffect(() => { // Aggiorna i metadati dei video (titolo e thumbnail) usando l'endpoint oEmbed di YouTube @@ -155,7 +161,7 @@ export default function CountryOverlay({ country, videos : videosWithoutMetadata headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ country: country.name, - uid: user.uid, + uid: user?.uid, token: currentToken }) }); @@ -220,29 +226,55 @@ export default function CountryOverlay({ country, videos : videosWithoutMetadata }; function selectCategory(cat: string) { - if (cat === "all") { - setSelectedCategories([]); - setFilteredVideos(videos); - return; - } + if (cat === "all") { + setSelectedCategories([]); + setFilteredVideos(videos); + return; + } - const nextSelected = selectedCategories.includes(cat) - ? selectedCategories.filter((c) => c !== cat) - : [...selectedCategories, cat]; + const nextSelected = selectedCategories.includes(cat) + ? selectedCategories.filter((c) => c !== cat) + : [...selectedCategories, cat]; - setSelectedCategories(nextSelected); + setSelectedCategories(nextSelected); - if (nextSelected.length === 0) { - setFilteredVideos(videos); + if (nextSelected.length === 0) { + setFilteredVideos(videos); + return; + } + + setFilteredVideos( + videos.filter((v) => + nextSelected.some((c) => v.categories?.includes(c)) + ) + ); + } + + const handleReportVideo = (videoId: string) => { + try { + // Logica per segnalare un video (es. aggiungere l'ID del video a una lista di segnalazioni dell'utente) + if (!user) { + setAlert({ type: "error", message: "Devi accedere per segnalare un video." }); return; } - - setFilteredVideos( - videos.filter((v) => - nextSelected.some((c) => v.categories?.includes(c)) - ) - ); + if (user?.reportedVideos?.includes(videoId)) { + setAlert({ type: "error", message: "Hai già segnalato questo video." }); + return; + } + if (user.reportedVideos && user.reportedVideos.length >= 5) { + setAlert({ type: "error", message: "Hai raggiunto il limite di 5 segnalazioni giornaliere." }); + return; + } + const userRef = doc(db, "users", user.uid); + updateDoc(userRef, { + reportedVideos: [...(user.reportedVideos || []), videoId] + }); + setAlert({ type: "success", message: "Video segnalato. Grazie per il tuo feedback!" }); + } catch (error) { + console.error("Errore segnalazione video:", error); + setAlert({ type: "error", message: "Si è verificato un errore durante la segnalazione del video. Riprova più tardi." }); } + }; return ( @@ -281,196 +313,249 @@ export default function CountryOverlay({ country, videos : videosWithoutMetadata type: "spring", bounce: 0.5 }} - className="bg-neutral-900 w-full max-w-6xl h-full md:h-[85vh] rounded-xl md:rounded-3xl flex flex-col md:flex-row overflow-hidden shadow-2xl border border-white/10" + className=" w-full max-w-6xl 2xl:max-w-full h-full md:h-[85vh] flex flex-row gap-2 md:flex-row overflow-hidden justify-center items-center" onClick={(e) => e.stopPropagation()} > - {/* --- SIDEBAR SINISTRA (Identità e Navigazione) --- */} - +
+ - {/* --- AREA CONTENUTO DESTRA (Feed Video) --- */} -
- {/* Header superiore del contenuto */} + {/* --- AREA CONTENUTO DESTRA (Feed Video) --- */} +
+ {/* Header superiore del contenuto */}
-
-

Discovery Feed

-

Contenuti selezionati per {countryName}

-
-
- - {filteredVideos.filter(v => v.status === "approved").length} Video trovati - -
-
- - {/* Griglia Video con Scroll */} -
- {countryVideos.length > 0 ? ( -
- {filteredVideos - .filter(v => v.status === "approved") - .map(v => ( -
{ - if (navigator.onLine) { - window.open(v.url, "_blank"); - }else{ - setAlert({ type: "error", message: "Non puoi aprire il video mentre sei offline. Riconnettiti a internet e riprova." }); - } - }} - > -
- {v.thumbnail ? ( - {v.title} - ) : ( -
🎬
- )} -
-
-
- +
+

Discovery Feed

+

Contenuti selezionati per {countryName}

+
+
+ + {filteredVideos.filter(v => v.status === "approved").length} Video trovati + +
+ + + {/* Griglia Video con Scroll */} +
+ {countryVideos.length > 0 ? ( +
+ {filteredVideos + .filter(v => v.status === "approved") + .map(v => ( +
{ + if (navigator.onLine) { + window.open(v.url, "_blank"); + }else{ + setAlert({ type: "error", message: "Non puoi aprire il video mentre sei offline. Riconnettiti a internet e riprova." }); + } + }} + > +
+ {v.thumbnail ? ( + {v.title} + ) : ( +
+ )} +
+
+ +
+
+
+
+
+
+

+ {v.title || "Video senza titolo"} +

+
-
-

- {v.title || "Video senza titolo"} -

-
-
- ))} -
- ) : ( -
- -

Ancora nessun video qui.

-

Sii il primo a contribuire alla guida!

-
- )} -
-
+ ))} +
+ ) : ( +
+ +

Ancora nessun video qui.

+

Sii il primo a contribuire alla guida!

+
+ )} +
+ + {/* BOTTONE CHAT FLUTTUANTE AZZURRO */} + + + + + + +
+
+ setIsChatOpen(false)} + onAlert={(message, isSuccess) => setAlert({ type: isSuccess ? "success" : "error", message })} + /> +
+ {/* IL NOSTRO NUOVO COMPONENTE */} + + {(isChatOpen) && ( +
setIsChatOpen(false)} + > + setIsChatOpen(false)} + onAlert={(message, isSuccess) => setAlert({ type: isSuccess ? "success" : "error", message })} + /> +
+ )} +
{isSuggestOverlayOpen && ( { setSelectedCategories((current) => - current.includes(category) + current.some(item => item.toLocaleLowerCase() === category.toLocaleLowerCase()) ? current.filter((item) => item !== category) : [...current, category] ); @@ -51,7 +51,9 @@ export default function SuggestVideoModal({ onClose, onSubmit, countryName }: S if (!nextCategory) return; setSelectedCategories((current) => - current.includes(nextCategory) ? current : [...current, nextCategory] + current.some((item) => item.toLocaleLowerCase() === nextCategory) + ? current + : [...current, nextCategory] ); setCustomCategory(""); }; diff --git a/src/components/profile/UserVideoCard.tsx b/src/components/profile/UserVideoCard.tsx index ac155e7..b01f288 100644 --- a/src/components/profile/UserVideoCard.tsx +++ b/src/components/profile/UserVideoCard.tsx @@ -1,4 +1,4 @@ -import { FaPlay, FaExclamationCircle } from "react-icons/fa"; +import { FaPlay } from "react-icons/fa"; import type { video } from "../../types/index"; interface VideoCardProps { @@ -81,21 +81,6 @@ export default function VideoCard({ video, deleting, onDelete }: VideoCardProps)
- - {/* Motivo del rifiuto (se presente) */} - {video.status === "rejected" && video.rejectionReason && ( -
- -
-

- Motivazione del rifiuto -

-

- "{video.rejectionReason}" -

-
-
- )}
diff --git a/src/components/profile/UserVideoList.tsx b/src/components/profile/UserVideoList.tsx index 8dca509..4809d2f 100644 --- a/src/components/profile/UserVideoList.tsx +++ b/src/components/profile/UserVideoList.tsx @@ -12,7 +12,8 @@ import { } from "firebase/firestore"; import { db } from "../../firebase"; import type { user, video } from "../../types"; -import { FiArrowRight, FiPlayCircle } from "react-icons/fi"; +import { FiArrowRight, FiPlayCircle, FiX } from "react-icons/fi"; +import { FaExclamationCircle } from "react-icons/fa"; import UserVideoCard from "./UserVideoCard"; interface UserVideoListProps { @@ -29,6 +30,10 @@ export default function UserVideoList({ const [error, setError] = useState(null); const [filterStatus, setFilterStatus] = useState<"all" | "pending" | "approved" | "rejected">("all"); const [deletingVideo, setDeletingVideo] = useState(null); + const [rejectionOverlay, setRejectionOverlay] = useState<{ + title: string; + reason: string; + } | null>(null); // Carica i video suggeriti dall'utente @@ -237,6 +242,7 @@ export default function UserVideoList({ } return ( + <>
{/* SEZIONE 1: VIDEO SUGGERITI (PENDING) */}
@@ -361,6 +367,22 @@ export default function UserVideoList({ {formatDate(videoDate)}

+ {video.status === "rejected" && video.rejectionReason && ( + + )} + {/* Actions */} )}
+ + {rejectionOverlay && ( +
setRejectionOverlay(null)} + > +
e.stopPropagation()} + > +
+
+ +

+ Motivo del rifiuto +

+
+ +
+ +

+ {rejectionOverlay.title} +

+

+ "{rejectionOverlay.reason}" +

+
+
+ )} + ); } diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx index cd966a3..ddbc006 100644 --- a/src/pages/Admin.tsx +++ b/src/pages/Admin.tsx @@ -2,6 +2,8 @@ import { Link } from "react-router-dom"; import { useEffect, useState } from "react"; import { db } from "../firebase"; import { collection, query, where, getDocs, updateDoc, doc, increment, arrayUnion, setDoc } from "firebase/firestore"; +import CommentReportsList from "../components/admin/CommentReportsList"; +import VideoReportsList from "../components/admin/VideoReportsList"; import type { user, video } from "../types/index" import Header from "../components/common/Header"; import VideoList from "../components/admin/VideoList"; @@ -109,7 +111,7 @@ export default function Admin({ user }: { user: user | null }) { } const videoRef = doc(db, "videos", videoId); - const updateData: any = { status: newStatus }; + const updateData: { [key: string]: any } = { status: newStatus }; if (newStatus === "rejected" && reason) { updateData.rejectionReason = reason; } @@ -212,6 +214,10 @@ export default function Admin({ user }: { user: user | null }) { onAddAlias={handleAddAlias} />
+
+ + +
{user.role === "admin" && (
(europe); + const [selectedRegion, setSelectedRegion] = useState(europe); - const [geoData, setGeoData] = useState(null); + const [geoData, setGeoData] = useState(null); const [countriesData, setCountriesData] = useState([]); useEffect(() => { diff --git a/src/types/index.ts b/src/types/index.ts index 4a4b596..88d8831 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,7 +1,7 @@ import type { Coordinates } from "@vnedyalk0v/react19-simple-maps"; export interface Country { - id: string; // codice numerico (es. "380") + id: number; // codice numerico (es. "380") name: string; // nome universale itName?: string; // eventuale traduzione italiana (se diversa da name) flagUrl?: string; // url bandiera PNG @@ -42,6 +42,8 @@ export interface user{ role: "user" | "moderator" | "admin"; photoURL?: string; subscriptions?: string[]; + reportedComments?: string[]; // Array di ID dei commenti segnalati + reportedVideos?: string[]; // Array di ID dei video segnalati stats?: { pendingVideos: number; approvedVideos: number; diff --git a/vite.config.ts b/vite.config.ts index 3c8a2cf..8a4c4d1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -20,18 +20,16 @@ export default defineConfig({ '/pwa-512x512.png', '/pwa-maskable-192x192.png', '/pwa-maskable-512x512.png', - '/screenshot-1.png', - '/screenshot-2.png', '/offline.html', '/badge-72x72.png' ], manifest: { - name: 'World Video Guide', // Il nome visualizzato nel banner di installazione - short_name: 'WorldVGuide', // Versione abbreviata per la schermata home + name: 'World Video Guide', + short_name: 'WorldGuide', description: 'Esplora video da tutto il mondo', - theme_color: '#171717', // Il colore del tema (da abbinare al tuo CSS) - display: 'standalone', // Fa sembrare l'app un'app nativa, nascondendo l'interfaccia del browser - start_url: '/', // Indica al browser da quale pagina iniziare + theme_color: '#171717', + display: 'standalone', + start_url: '/', icons: [ { src: '/pwa-192x192.png', @@ -63,19 +61,6 @@ export default defineConfig({ type: 'image/png', purpose: 'monochrome' } - ], - screenshots: [ - { - src: '/screenshot-1.png', - sizes: '512x512', - type: 'image/png', - form_factor: 'wide' - }, - { - src: '/screenshot-2.png', - sizes: '512x512', - type: 'image/png' - } ] }, injectManifest: {