From b249672b0986f1d517c6ec752c1f94867d8d64ed Mon Sep 17 00:00:00 2001 From: annapareddypraveenkumar Date: Mon, 10 Aug 2026 18:36:22 +0530 Subject: [PATCH 01/15] fix: override jose to 4.15.9 to resolve ERR_REQUIRE_ESM on hosted server --- package-lock.json | 20 ++++++++++---------- package.json | 3 +++ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index dee5813..0a1a3e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "tailwindcss": "^4" }, "engines": { - "node": "22.x" + "node": "24.x" }, "optionalDependencies": { "lightningcss-linux-x64-gnu": "1.32.0" @@ -6281,15 +6281,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6455,6 +6446,15 @@ "node": "^20.19.0 || ^22.12.0 || >= 23.0.0" } }, + "node_modules/jwks-rsa/node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/jwks-rsa/node_modules/lru-cache": { "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", diff --git a/package.json b/package.json index 1add509..4191fe7 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,9 @@ "ytdl-core": "^4.11.5", "zod": "^4.4.3" }, + "overrides": { + "jose": "4.15.9" + }, "devDependencies": { "@tailwindcss/postcss": "^4", "babel-plugin-react-compiler": "1.0.0", From 3e4b0fde1ab809330c3276ff62177a6637d14ba2 Mon Sep 17 00:00:00 2001 From: annapareddypraveenkumar Date: Mon, 10 Aug 2026 18:51:59 +0530 Subject: [PATCH 02/15] fix: use modular subpath imports for firebase-admin to prevent CJS/ESM bundling conflicts --- src/lib/admin-firebase.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lib/admin-firebase.js b/src/lib/admin-firebase.js index 25aab70..8bc3174 100644 --- a/src/lib/admin-firebase.js +++ b/src/lib/admin-firebase.js @@ -16,7 +16,7 @@ import dns from "node:dns"; dns.setDefaultResultOrder("ipv4first"); -import * as admin from "firebase-admin"; +import { initializeApp, getApps, cert } from "firebase-admin/app"; import { getAuth } from "firebase-admin/auth"; import { getFirestore, FieldValue } from "firebase-admin/firestore"; @@ -62,11 +62,11 @@ export function getFirebaseAdmin() { } // Singleton guard: never call initializeApp() more than once. - if (admin.getApps().length === 0) { + if (getApps().length === 0) { if (projectId && clientEmail && privateKey) { try { - admin.initializeApp({ - credential: admin.cert({ projectId, clientEmail, privateKey }), + initializeApp({ + credential: cert({ projectId, clientEmail, privateKey }), }); } catch (certError) { console.error("Firebase Admin initialization with cert failed:", certError); @@ -160,4 +160,4 @@ export function generateSongId(title, artistName) { return (title || "").trim().replace(/\s+/g, "-").replace(/[\/\\]/g, "-"); } -export { admin, COLLECTION_NAME, FieldValue }; +export { COLLECTION_NAME, FieldValue }; From f46bf4c3a5c15549aa08dee455cfdf245a06a69f Mon Sep 17 00:00:00 2001 From: annapareddypraveenkumar Date: Wed, 12 Aug 2026 17:02:11 +0530 Subject: [PATCH 03/15] update admin firebase file --- src/lib/admin-firebase.js | 82 ++++++++++++++++++++++++--------------- 1 file changed, 50 insertions(+), 32 deletions(-) diff --git a/src/lib/admin-firebase.js b/src/lib/admin-firebase.js index 8bc3174..462b915 100644 --- a/src/lib/admin-firebase.js +++ b/src/lib/admin-firebase.js @@ -16,7 +16,7 @@ import dns from "node:dns"; dns.setDefaultResultOrder("ipv4first"); -import { initializeApp, getApps, cert } from "firebase-admin/app"; +import * as admin from "firebase-admin"; import { getAuth } from "firebase-admin/auth"; import { getFirestore, FieldValue } from "firebase-admin/firestore"; @@ -34,63 +34,72 @@ export function getFirebaseAdmin() { if (globalRef.__firebaseAdminDb) return globalRef.__firebaseAdminDb; const projectId = - process.env.FIREBASE_PROJECT_ID || process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID; + process.env.FIREBASE_PROJECT_ID || + process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID; const clientEmail = process.env.FIREBASE_CLIENT_EMAIL; let privateKey = process.env.FIREBASE_PRIVATE_KEY; if (privateKey) { + // Vercel/most hosts store the key with literal "\n" sequences (backslash + n) + // instead of real newlines. Convert those back into real newlines. + // Also strip a single pair of wrapping double quotes, which some dashboards + // add automatically when you paste a multi-line-looking value. privateKey = privateKey.trim(); - // Remove wrapping quotes/commas from copy-paste - privateKey = privateKey.replace(/^[^a-zA-Z0-9+/=_-]+|[^a-zA-Z0-9+/=_-]+$/g, ""); - - // Fix leftover 'n' from '\n' if header was removed - if (privateKey.startsWith("nMII")) { - privateKey = "-----BEGIN PRIVATE KEY-----\n" + privateKey.substring(1); - } - - // Ensure BEGIN/END headers exist - if (!privateKey.startsWith("-----BEGIN PRIVATE KEY-----")) { - privateKey = "-----BEGIN PRIVATE KEY-----\n" + privateKey; + if (privateKey.startsWith('"') && privateKey.endsWith('"')) { + privateKey = privateKey.slice(1, -1); } - if (!privateKey.endsWith("-----END PRIVATE KEY-----")) { - privateKey = privateKey + "\n-----END PRIVATE KEY-----"; - } - - // Convert literal "\n" sequences into real newlines (Vercel env vars) - privateKey = privateKey.replace(/\\n/g, "\n"); + privateKey = privateKey.replace(/\\n/g, "\n").trim(); } // Singleton guard: never call initializeApp() more than once. - if (getApps().length === 0) { + if (admin.getApps().length === 0) { if (projectId && clientEmail && privateKey) { + // Fail fast with a clear message if the key clearly isn't a real PEM key, + // instead of letting OpenSSL throw an opaque ERR_OSSL_UNSUPPORTED later. + if ( + !privateKey.includes("-----BEGIN PRIVATE KEY-----") || + !privateKey.includes("-----END PRIVATE KEY-----") + ) { + throw new Error( + "FIREBASE_PRIVATE_KEY does not look like a valid PEM private key " + + "(missing -----BEGIN/END PRIVATE KEY----- markers). " + + "Re-copy the 'private_key' field from your Firebase service account JSON " + + "exactly as-is into the environment variable.", + ); + } + try { - initializeApp({ - credential: cert({ projectId, clientEmail, privateKey }), + admin.initializeApp({ + credential: admin.cert({ projectId, clientEmail, privateKey }), }); } catch (certError) { - console.error("Firebase Admin initialization with cert failed:", certError); + console.error( + "Firebase Admin initialization with cert failed:", + certError, + ); throw new Error( `Firebase Admin cert initialization failed: ${certError.message}. ` + - "Please verify your environment credentials (FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL, FIREBASE_PRIVATE_KEY)." + "Please verify your environment credentials (FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL, FIREBASE_PRIVATE_KEY).", ); } } else { const missing = []; - if (!projectId) missing.push("FIREBASE_PROJECT_ID / NEXT_PUBLIC_FIREBASE_PROJECT_ID"); + if (!projectId) + missing.push("FIREBASE_PROJECT_ID / NEXT_PUBLIC_FIREBASE_PROJECT_ID"); if (!clientEmail) missing.push("FIREBASE_CLIENT_EMAIL"); if (!privateKey) missing.push("FIREBASE_PRIVATE_KEY"); throw new Error( "Firebase Admin credentials are not fully configured. " + - `Missing environment variables: ${missing.join(", ")}. ` + - "Please check your hosting provider's dashboard and set these values." + `Missing environment variables: ${missing.join(", ")}. ` + + "Please check your hosting provider's dashboard and set these values.", ); } } console.log( `[Firebase Admin] initialized (project: ${projectId || "default"}, ` + - `cert: ${Boolean(projectId && clientEmail && privateKey)})` + `cert: ${Boolean(projectId && clientEmail && privateKey)})`, ); const db = getFirestore(); @@ -118,7 +127,10 @@ export function getAdminAuth() { export async function verifyAdminAuth(request) { const authHeader = request.headers.get("authorization"); if (!authHeader || !authHeader.startsWith("Bearer ")) { - return { authorized: false, error: "Missing or invalid authorization header" }; + return { + authorized: false, + error: "Missing or invalid authorization header", + }; } try { @@ -141,7 +153,10 @@ export async function verifyAdminAuth(request) { decodedToken.admin === true; if (!isAdmin) { - return { authorized: false, error: "User does not have admin privileges" }; + return { + authorized: false, + error: "User does not have admin privileges", + }; } return { authorized: true, uid: decodedToken.uid, email }; @@ -157,7 +172,10 @@ export async function verifyAdminAuth(request) { */ export function generateSongId(title, artistName) { // Use song title only, replace spaces and slashes with hyphens to ensure it is route-safe - return (title || "").trim().replace(/\s+/g, "-").replace(/[\/\\]/g, "-"); + return (title || "") + .trim() + .replace(/\s+/g, "-") + .replace(/[\/\\]/g, "-"); } -export { COLLECTION_NAME, FieldValue }; +export { admin, COLLECTION_NAME, FieldValue }; From 6177f915b74735d9e71f8c327a3c534d84c76980 Mon Sep 17 00:00:00 2001 From: annapareddypraveenkumar Date: Wed, 12 Aug 2026 18:53:12 +0530 Subject: [PATCH 04/15] changes in all songs --- scripts/compress-upload.js | 7 +- scripts/fetch-songs.mjs | 28 +++- scripts/fetch-youtube-durations.cjs | 26 +++- scripts/fetch-youtube-durations.mjs | 28 +++- scripts/fix-all-image-urls.mjs | 39 ++++- scripts/inspect-songs.mjs | 20 ++- scripts/migrate-songs.mjs | 68 ++++++--- scripts/process-worship-images.js | 107 +++++++++----- scripts/remove-unsplash-image.mjs | 3 +- scripts/seed-bible-chapters.mjs | 26 +++- scripts/seed-firestore.mjs | 39 +++-- scripts/sync-youtube-urls.mjs | 34 ++++- scripts/update-durations.mjs | 55 ++++--- src/app/admin/page.js | 179 ++++++++++++++++++----- src/app/page.js | 38 +++-- src/app/song/[id]/page.js | 3 +- src/components/landing/EnterAppButton.js | 30 ++++ src/components/layout/AppLayout.js | 28 +--- src/lib/admin-firebase.js | 9 ++ src/middleware.js | 60 ++++++++ 20 files changed, 618 insertions(+), 209 deletions(-) create mode 100644 src/components/landing/EnterAppButton.js create mode 100644 src/middleware.js diff --git a/scripts/compress-upload.js b/scripts/compress-upload.js index a927f00..6963ad7 100644 --- a/scripts/compress-upload.js +++ b/scripts/compress-upload.js @@ -13,7 +13,8 @@ * node scripts/compress-upload.js */ -require("dotenv").config({ path: ".env.local" }); +require("dotenv").config({ path: ".env" }); +require("dotenv").config({ path: ".env.local", override: true }); const fs = require("fs"); const path = require("path"); @@ -173,7 +174,7 @@ async function compressToBuffer(inputPath) { // ─── Step 3: Upload Buffer Directly to Supabase Storage ────────────────────── async function uploadBufferToSupabase(buffer, storagePath) { if (!supabase) { - throw new Error("Supabase client is not configured. Please set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SECRET_KEY in .env.local"); + throw new Error("Supabase client is not configured. Please set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SECRET_KEY in your env files"); } const safeKey = getSafeStorageKey(storagePath); @@ -237,7 +238,7 @@ async function processImages() { } if (!supabase) { - console.warn("⚠️ Supabase credentials missing in .env.local. Uploads will be simulated."); + console.warn("⚠️ Supabase credentials missing in env files. Uploads will be simulated."); } const relativeFilePaths = getFilesRecursively(inputFolder); diff --git a/scripts/fetch-songs.mjs b/scripts/fetch-songs.mjs index 5c2c0f9..901d1dd 100644 --- a/scripts/fetch-songs.mjs +++ b/scripts/fetch-songs.mjs @@ -8,10 +8,15 @@ import { fileURLToPath } from "url"; import path from "path"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -dotenv.config({ path: path.resolve(__dirname, "../.env.local") }); +dotenv.config({ path: path.resolve(__dirname, "../.env") }); +dotenv.config({ + path: path.resolve(__dirname, "../.env.local"), + override: true, +}); const { initializeApp } = await import("firebase/app"); -const { getFirestore, collection, getDocs, query, limit } = await import("firebase/firestore"); +const { getFirestore, collection, getDocs, query, limit } = + await import("firebase/firestore"); const firebaseConfig = { apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, @@ -35,7 +40,9 @@ const app = initializeApp(firebaseConfig); const db = getFirestore(app); async function main() { - console.log("🔍 Fetching up to 3 songs from Firebase 'songs' collection...\n"); + console.log( + "🔍 Fetching up to 3 songs from Firebase 'songs' collection...\n", + ); const q = query(collection(db, "songs"), limit(3)); const snap = await getDocs(q); @@ -59,9 +66,18 @@ async function main() { // Print the full structure with types for (const [key, value] of Object.entries(data)) { - const type = Array.isArray(value) ? `Array(${value.length})` : typeof value; + const type = Array.isArray(value) + ? `Array(${value.length})` + : typeof value; const preview = Array.isArray(value) - ? `[${value.slice(0, 2).map(v => typeof v === 'string' ? `"${v.substring(0, 30)}..."` : JSON.stringify(v)).join(", ")}${value.length > 2 ? "..." : ""}]` + ? `[${value + .slice(0, 2) + .map((v) => + typeof v === "string" + ? `"${v.substring(0, 30)}..."` + : JSON.stringify(v), + ) + .join(", ")}${value.length > 2 ? "..." : ""}]` : typeof value === "string" ? `"${value.substring(0, 60)}${value.length > 60 ? "..." : ""}"` : JSON.stringify(value); @@ -74,7 +90,7 @@ async function main() { process.exit(0); } -main().catch(err => { +main().catch((err) => { console.error("❌ Error:", err.message); process.exit(1); }); diff --git a/scripts/fetch-youtube-durations.cjs b/scripts/fetch-youtube-durations.cjs index 925fb1a..ea1f688 100644 --- a/scripts/fetch-youtube-durations.cjs +++ b/scripts/fetch-youtube-durations.cjs @@ -21,7 +21,10 @@ const { const ytdl = require("@distube/ytdl-core"); dotenv.config({ path: path.resolve(__dirname, "../.env") }); -dotenv.config({ path: path.resolve(__dirname, "../.env.local") }); +dotenv.config({ + path: path.resolve(__dirname, "../.env.local"), + override: true, +}); const firebaseConfig = { apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, @@ -57,7 +60,9 @@ function getYoutubeUrl(data) { } async function fetchDuration(youtubeId) { - const info = await ytdl.getInfo(`https://www.youtube.com/watch?v=${youtubeId}`); + const info = await ytdl.getInfo( + `https://www.youtube.com/watch?v=${youtubeId}`, + ); const seconds = Number(info.videoDetails.lengthSeconds); return seconds && seconds > 0 ? seconds : null; } @@ -75,7 +80,10 @@ async function fetchDurations() { const needsDuration = []; for (const docSnap of snap.docs) { const data = docSnap.data(); - const hasDuration = data.duration !== undefined && data.duration !== null && Number(data.duration) > 0; + const hasDuration = + data.duration !== undefined && + data.duration !== null && + Number(data.duration) > 0; if (hasDuration) continue; const youtubeUrl = getYoutubeUrl(data); @@ -106,13 +114,17 @@ async function fetchDurations() { const start = b * CONCURRENT_BATCH; const chunk = needsDuration.slice(start, start + CONCURRENT_BATCH); - console.log(`─── Batch ${b + 1}/${batchCount} (${chunk.length} songs) ───`); + console.log( + `─── Batch ${b + 1}/${batchCount} (${chunk.length} songs) ───`, + ); const results = await Promise.allSettled( chunk.map((song) => - fetchDuration(song.youtubeId) - .then((seconds) => ({ ...song, seconds })) - ) + fetchDuration(song.youtubeId).then((seconds) => ({ + ...song, + seconds, + })), + ), ); const fbBatch = writeBatch(db); diff --git a/scripts/fetch-youtube-durations.mjs b/scripts/fetch-youtube-durations.mjs index 1a12a05..ee32c65 100644 --- a/scripts/fetch-youtube-durations.mjs +++ b/scripts/fetch-youtube-durations.mjs @@ -25,7 +25,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); const ytdl = require("@distube/ytdl-core"); -dotenv.config({ path: path.resolve(__dirname, "../.env.local") }); +dotenv.config({ path: path.resolve(__dirname, "../.env") }); +dotenv.config({ + path: path.resolve(__dirname, "../.env.local"), + override: true, +}); const firebaseConfig = { apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, @@ -61,7 +65,9 @@ function getYoutubeUrl(data) { async function fetchDuration(youtubeId, retries = 2) { for (let attempt = 0; attempt <= retries; attempt++) { try { - const info = await ytdl.getInfo(`https://www.youtube.com/watch?v=${youtubeId}`); + const info = await ytdl.getInfo( + `https://www.youtube.com/watch?v=${youtubeId}`, + ); const seconds = Number(info.videoDetails.lengthSeconds); if (seconds && seconds > 0) return seconds; } catch (err) { @@ -90,7 +96,10 @@ async function fetchDurations() { for (const docSnap of snap.docs) { const data = docSnap.data(); - const hasDuration = data.duration !== undefined && data.duration !== null && Number(data.duration) > 0; + const hasDuration = + data.duration !== undefined && + data.duration !== null && + Number(data.duration) > 0; if (hasDuration) continue; const youtubeUrl = getYoutubeUrl(data); @@ -100,7 +109,9 @@ async function fetchDurations() { needsDuration.push({ id: docSnap.id, title: data.title, youtubeId }); } - console.log(`🎯 Songs missing duration with YouTube ID: ${needsDuration.length}\n`); + console.log( + `🎯 Songs missing duration with YouTube ID: ${needsDuration.length}\n`, + ); if (needsDuration.length === 0) { console.log("✅ All songs already have durations."); @@ -127,10 +138,14 @@ async function fetchDurations() { }); ops++; successCount++; - console.log(` [${i + 1}/${total}] ✅ "${title}" → ${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, "0")} (${seconds}s)`); + console.log( + ` [${i + 1}/${total}] ✅ "${title}" → ${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, "0")} (${seconds}s)`, + ); } else { failCount++; - console.log(` [${i + 1}/${total}] ⚠️ "${title}" → could not get duration`); + console.log( + ` [${i + 1}/${total}] ⚠️ "${title}" → could not get duration`, + ); } } catch (err) { failCount++; @@ -158,7 +173,6 @@ async function fetchDurations() { console.log(` Success: ${successCount}`); console.log(` Failed: ${failCount}`); console.log("======================================================"); - } catch (error) { console.error("\n❌ Script failed:", error); process.exit(1); diff --git a/scripts/fix-all-image-urls.mjs b/scripts/fix-all-image-urls.mjs index 0743578..323fa19 100644 --- a/scripts/fix-all-image-urls.mjs +++ b/scripts/fix-all-image-urls.mjs @@ -11,7 +11,11 @@ import { } from "firebase/firestore"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -dotenv.config({ path: path.resolve(__dirname, "../.env.local") }); +dotenv.config({ path: path.resolve(__dirname, "../.env") }); +dotenv.config({ + path: path.resolve(__dirname, "../.env.local"), + override: true, +}); const firebaseConfig = { apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, @@ -41,9 +45,18 @@ async function fixImageUrls() { const updatedFields = {}; // 1. Check and fix missing .webp extension in Supabase image URLs - if (data.media && typeof data.media === "object" && typeof data.media.image === "string") { + if ( + data.media && + typeof data.media === "object" && + typeof data.media.image === "string" + ) { const img = data.media.image; - if (img.includes("supabase.co") && !img.endsWith(".webp") && !img.endsWith(".png") && !img.endsWith(".jpg")) { + if ( + img.includes("supabase.co") && + !img.endsWith(".webp") && + !img.endsWith(".png") && + !img.endsWith(".jpg") + ) { const fixedImg = img + ".webp"; updatedFields["media.image"] = fixedImg; needsUpdate = true; @@ -54,9 +67,17 @@ async function fixImageUrls() { } // 2. Check and clear Unsplash 404 images - if (data.media && typeof data.media === "object" && typeof data.media.image === "string") { + if ( + data.media && + typeof data.media === "object" && + typeof data.media.image === "string" + ) { const img = data.media.image; - if (img.includes("unsplash.com") && (img.includes("photo-1594979222473") || img.includes("photo-1593011378399"))) { + if ( + img.includes("unsplash.com") && + (img.includes("photo-1594979222473") || + img.includes("photo-1593011378399")) + ) { updatedFields["media.image"] = ""; needsUpdate = true; console.log(`🧹 Clearing 404 Unsplash image for "${data.title}":`); @@ -72,9 +93,13 @@ async function fixImageUrls() { if (updateCount > 0) { await batch.commit(); - console.log(`\n✅ Successfully committed ${updateCount} document updates to Firestore.`); + console.log( + `\n✅ Successfully committed ${updateCount} document updates to Firestore.`, + ); } else { - console.log("\n✨ No malformed image URLs were found. Everything is clean!"); + console.log( + "\n✨ No malformed image URLs were found. Everything is clean!", + ); } } catch (error) { console.error("❌ Error running repair script:", error); diff --git a/scripts/inspect-songs.mjs b/scripts/inspect-songs.mjs index c7bd44d..d89393e 100644 --- a/scripts/inspect-songs.mjs +++ b/scripts/inspect-songs.mjs @@ -2,10 +2,20 @@ import dotenv from "dotenv"; import { fileURLToPath } from "url"; import path from "path"; import { initializeApp } from "firebase/app"; -import { getFirestore, collection, getDocs, limit, query } from "firebase/firestore"; +import { + getFirestore, + collection, + getDocs, + limit, + query, +} from "firebase/firestore"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -dotenv.config({ path: path.resolve(__dirname, "../.env.local") }); +dotenv.config({ path: path.resolve(__dirname, "../.env") }); +dotenv.config({ + path: path.resolve(__dirname, "../.env.local"), + override: true, +}); const firebaseConfig = { apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, @@ -26,7 +36,9 @@ async function main() { for (const doc of snap.docs) { const data = doc.data(); - const urls = [data.imageUrl, data.coverUrl, data.media?.image].filter(Boolean); + const urls = [data.imageUrl, data.coverUrl, data.media?.image].filter( + Boolean, + ); for (const url of urls) { if (url.includes("unsplash.com")) { @@ -43,5 +55,3 @@ async function main() { } main().catch(console.error); - - diff --git a/scripts/migrate-songs.mjs b/scripts/migrate-songs.mjs index 972a740..04ea34e 100644 --- a/scripts/migrate-songs.mjs +++ b/scripts/migrate-songs.mjs @@ -17,12 +17,16 @@ import { collection, getDocs, doc, - writeBatch + writeBatch, } from "firebase/firestore"; -// Load environment variables from .env.local +// Load environment variables from .env and .env.local const __dirname = path.dirname(fileURLToPath(import.meta.url)); -dotenv.config({ path: path.resolve(__dirname, "../.env.local") }); +dotenv.config({ path: path.resolve(__dirname, "../.env") }); +dotenv.config({ + path: path.resolve(__dirname, "../.env.local"), + override: true, +}); // ─── Firebase Configuration ────────────────────────────────────────────────── const firebaseConfig = { @@ -66,7 +70,7 @@ function transformLyrics(rawTeluguLyrics, rawEnglishLyrics) { format: "original", title: "తెలుగు", content: rawTeluguLyrics.trim(), - isDefault: true + isDefault: true, }); } @@ -76,7 +80,7 @@ function transformLyrics(rawTeluguLyrics, rawEnglishLyrics) { format: "transliteration", title: "Romanized", content: rawEnglishLyrics.trim(), - isDefault: lyricsArr.length === 0 + isDefault: lyricsArr.length === 0, }); } @@ -86,7 +90,7 @@ function transformLyrics(rawTeluguLyrics, rawEnglishLyrics) { format: "original", title: "తెలుగు", content: "", - isDefault: true + isDefault: true, }); } @@ -98,10 +102,11 @@ function transformLyrics(rawTeluguLyrics, rawEnglishLyrics) { */ function transformToNewStructuredSchema(docId, oldData) { const now = new Date().toISOString(); - - const artistName = typeof oldData.artist === "object" - ? oldData.artist?.name || "Unknown Artist" - : (oldData.artist || "Unknown Artist"); + + const artistName = + typeof oldData.artist === "object" + ? oldData.artist?.name || "Unknown Artist" + : oldData.artist || "Unknown Artist"; const title = oldData.title || ""; const slug = oldData.slug || generateSlug(title, artistName, docId); @@ -121,7 +126,10 @@ function transformToNewStructuredSchema(docId, oldData) { if (Array.isArray(oldData.tags)) { tagsArr = oldData.tags; } else if (typeof oldData.tags === "string") { - tagsArr = oldData.tags.split(",").map(t => t.trim()).filter(Boolean); + tagsArr = oldData.tags + .split(",") + .map((t) => t.trim()) + .filter(Boolean); } else { tagsArr = ["Worship", "Devotional"]; } @@ -143,7 +151,10 @@ function transformToNewStructuredSchema(docId, oldData) { }; // Year - const year = oldData.year !== undefined && oldData.year !== null ? Number(oldData.year) : 2026; + const year = + oldData.year !== undefined && oldData.year !== null + ? Number(oldData.year) + : 2026; // Duration in seconds const durationSec = parseDurationToSeconds(oldData.duration); @@ -154,7 +165,8 @@ function transformToNewStructuredSchema(docId, oldData) { lyricsArr = oldData.lyrics; } else { const rawTe = typeof oldData.lyrics === "string" ? oldData.lyrics : ""; - const rawEn = typeof oldData.englishLyrics === "string" ? oldData.englishLyrics : ""; + const rawEn = + typeof oldData.englishLyrics === "string" ? oldData.englishLyrics : ""; lyricsArr = transformLyrics(rawTe, rawEn); } @@ -164,7 +176,7 @@ function transformToNewStructuredSchema(docId, oldData) { slug, artist: { id: oldData.artist?.id || null, - name: artistName + name: artistName, }, language: langCode, category: categoryArr, @@ -175,14 +187,16 @@ function transformToNewStructuredSchema(docId, oldData) { lyrics: lyricsArr, media: mediaObj, createdAt: oldData.createdAt || now, - updatedAt: now + updatedAt: now, }; } // ─── Main Migration Function ───────────────────────────────────────────────── async function updateCollectionToNewSchema() { - console.log("🚀 Updating 'Youworship_songs' to the NEW Structured Schema...\n"); + console.log( + "🚀 Updating 'Youworship_songs' to the NEW Structured Schema...\n", + ); try { const app = initializeApp(firebaseConfig); @@ -194,7 +208,9 @@ async function updateCollectionToNewSchema() { const snapshot = await getDocs(songsRef); if (snapshot.empty) { - console.log("⚠️ Collection 'Youworship_songs' is empty. Trying fallback 'songs' collection..."); + console.log( + "⚠️ Collection 'Youworship_songs' is empty. Trying fallback 'songs' collection...", + ); const oldSnap = await getDocs(collection(db, "songs")); if (oldSnap.empty) { console.log("⚠️ No documents found in 'songs' either."); @@ -216,7 +232,10 @@ async function updateCollectionToNewSchema() { const oldData = docSnap.data(); const targetDocId = oldData.id || docSnap.id; - const newSchemaData = transformToNewStructuredSchema(targetDocId, oldData); + const newSchemaData = transformToNewStructuredSchema( + targetDocId, + oldData, + ); const targetRef = doc(db, "Youworship_songs", targetDocId); batch.set(targetRef, newSchemaData, { merge: false }); @@ -224,7 +243,9 @@ async function updateCollectionToNewSchema() { operationCount++; successCount++; - console.log(`[${i + 1}/${totalDocs}] Updated schema for: "${targetDocId}"`); + console.log( + `[${i + 1}/${totalDocs}] Updated schema for: "${targetDocId}"`, + ); if (operationCount === BATCH_SIZE) { console.log(`\n💾 Committing batch of ${operationCount} documents...`); @@ -236,15 +257,18 @@ async function updateCollectionToNewSchema() { } if (operationCount > 0) { - console.log(`\n💾 Committing final batch of ${operationCount} documents...`); + console.log( + `\n💾 Committing final batch of ${operationCount} documents...`, + ); await batch.commit(); console.log("✅ Final batch commit successful.\n"); } console.log("=================================================="); - console.log(`🎉 SUCCESS: Updated ${successCount} document(s) to NEW Schema in 'Youworship_songs'!`); + console.log( + `🎉 SUCCESS: Updated ${successCount} document(s) to NEW Schema in 'Youworship_songs'!`, + ); console.log("=================================================="); - } catch (error) { console.error("\n❌ Schema update failed with error:", error); process.exit(1); diff --git a/scripts/process-worship-images.js b/scripts/process-worship-images.js index ec495e0..623fe00 100644 --- a/scripts/process-worship-images.js +++ b/scripts/process-worship-images.js @@ -15,7 +15,8 @@ * c. Update `imageUrl` and `media.image` for matched songs in Firestore. */ -require("dotenv").config({ path: ".env.local" }); +require("dotenv").config({ path: ".env" }); +require("dotenv").config({ path: ".env.local", override: true }); const fs = require("fs"); const path = require("path"); @@ -23,7 +24,11 @@ const sharp = require("sharp"); const { createClient } = require("@supabase/supabase-js"); const adminModule = require("firebase-admin"); const { getFirestore, FieldValue } = require("firebase-admin/firestore"); -const admin = adminModule.getApps ? adminModule : (adminModule.apps ? adminModule : (adminModule.default || adminModule)); +const admin = adminModule.getApps + ? adminModule + : adminModule.apps + ? adminModule + : adminModule.default || adminModule; // ─── Configuration & Directories ────────────────────────────────────────────── const inputFolder = "./public/song pictures"; @@ -37,36 +42,43 @@ const WEBP_EFFORT = 4; // ─── Supabase Client Initialization ────────────────────────────────────────── const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; -const supabaseSecretKey = process.env.SUPABASE_SECRET_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY; +const supabaseSecretKey = + process.env.SUPABASE_SECRET_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY; if (!supabaseUrl || !supabaseSecretKey) { - console.error("❌ Supabase credentials missing in .env.local!"); + console.error("❌ Supabase credentials missing in env files!"); process.exit(1); } const supabase = createClient(supabaseUrl, supabaseSecretKey); // ─── Firebase Admin Initialization ─────────────────────────────────────────── let db = null; -const firebaseProjectId = process.env.FIREBASE_PROJECT_ID || process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID; +const firebaseProjectId = + process.env.FIREBASE_PROJECT_ID || + process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID; const firebaseClientEmail = process.env.FIREBASE_CLIENT_EMAIL; let firebasePrivateKey = process.env.FIREBASE_PRIVATE_KEY; if (firebasePrivateKey) { firebasePrivateKey = firebasePrivateKey.trim(); - firebasePrivateKey = firebasePrivateKey.replace(/^[^a-zA-Z0-9+\/=_-]+|[^a-zA-Z0-9+\/=_-]+$/g, ''); - if (firebasePrivateKey.startsWith('nMII')) { - firebasePrivateKey = '-----BEGIN PRIVATE KEY-----\n' + firebasePrivateKey.substring(1); + firebasePrivateKey = firebasePrivateKey.replace( + /^[^a-zA-Z0-9+\/=_-]+|[^a-zA-Z0-9+\/=_-]+$/g, + "", + ); + if (firebasePrivateKey.startsWith("nMII")) { + firebasePrivateKey = + "-----BEGIN PRIVATE KEY-----\n" + firebasePrivateKey.substring(1); } - if (!firebasePrivateKey.startsWith('-----BEGIN PRIVATE KEY-----')) { - firebasePrivateKey = '-----BEGIN PRIVATE KEY-----\n' + firebasePrivateKey; + if (!firebasePrivateKey.startsWith("-----BEGIN PRIVATE KEY-----")) { + firebasePrivateKey = "-----BEGIN PRIVATE KEY-----\n" + firebasePrivateKey; } - if (!firebasePrivateKey.endsWith('-----END PRIVATE KEY-----')) { - firebasePrivateKey = firebasePrivateKey + '\n-----END PRIVATE KEY-----'; + if (!firebasePrivateKey.endsWith("-----END PRIVATE KEY-----")) { + firebasePrivateKey = firebasePrivateKey + "\n-----END PRIVATE KEY-----"; } firebasePrivateKey = firebasePrivateKey.replace(/\\n/g, "\n"); } -const apps = admin.getApps ? admin.getApps() : (admin.apps || []); +const apps = admin.getApps ? admin.getApps() : admin.apps || []; if (apps.length === 0) { if (firebaseProjectId && firebaseClientEmail && firebasePrivateKey) { try { @@ -83,7 +95,7 @@ if (apps.length === 0) { process.exit(1); } } else { - console.error("❌ Firebase Admin credentials missing in .env.local!"); + console.error("❌ Firebase Admin credentials missing in env files!"); process.exit(1); } } else { @@ -145,9 +157,7 @@ async function uploadToSupabase(buffer, storagePath) { function getPublicUrl(storagePath) { const safeKey = getSafeStorageKey(storagePath); - const { data } = supabase.storage - .from(BUCKET_NAME) - .getPublicUrl(safeKey); + const { data } = supabase.storage.from(BUCKET_NAME).getPublicUrl(safeKey); return data.publicUrl; } @@ -180,16 +190,28 @@ async function run() { metadataList = JSON.parse(fs.readFileSync(metadataPath, "utf-8")); console.log(`Loaded ${metadataList.length} existing metadata items.`); } catch (err) { - console.warn(`⚠️ Error reading metadata file: ${err.message}. Starting fresh.`); + console.warn( + `⚠️ Error reading metadata file: ${err.message}. Starting fresh.`, + ); } } - const files = fs.readdirSync(inputFolder) - .filter(f => f.endsWith(".png") || f.endsWith(".jpg") || f.endsWith(".jpeg") || f.endsWith(".webp")); + const files = fs + .readdirSync(inputFolder) + .filter( + (f) => + f.endsWith(".png") || + f.endsWith(".jpg") || + f.endsWith(".jpeg") || + f.endsWith(".webp"), + ); console.log(`Found ${files.length} images in "${inputFolder}"`); - let nextId = metadataList.length > 0 ? Math.max(...metadataList.map(item => item.id)) + 1 : 1; + let nextId = + metadataList.length > 0 + ? Math.max(...metadataList.map((item) => item.id)) + 1 + : 1; const imageMap = {}; // original filename -> publicUrl // 1. Process local images @@ -201,7 +223,9 @@ async function run() { const uploadedFileName = title + ".webp"; // Check if already in metadata - let existingItem = metadataList.find(item => item.originalFileName === file); + let existingItem = metadataList.find( + (item) => item.originalFileName === file, + ); if (existingItem && existingItem.uploaded && existingItem.publicUrl) { console.log(`[${i + 1}/${files.length}] Already processed: ${file}`); @@ -213,7 +237,9 @@ async function run() { try { // Compress to WebP const compRes = await compressToWebP(inputPath); - console.log(` Compressed: ${formatBytes(compRes.originalSize)} -> ${formatBytes(compRes.compressedSize)}`); + console.log( + ` Compressed: ${formatBytes(compRes.originalSize)} -> ${formatBytes(compRes.compressedSize)}`, + ); // Upload to Supabase const storagePath = uploadedFileName; @@ -230,7 +256,7 @@ async function run() { bucket: BUCKET_NAME, path: storagePath, publicUrl: publicUrl, - uploaded: true + uploaded: true, }; if (existingItem) { @@ -244,8 +270,11 @@ async function run() { imageMap[file] = publicUrl; // Save metadata JSON on every iteration to be safe - fs.writeFileSync(metadataPath, JSON.stringify(metadataList, null, 2), "utf-8"); - + fs.writeFileSync( + metadataPath, + JSON.stringify(metadataList, null, 2), + "utf-8", + ); } catch (err) { console.error(` ❌ Error processing ${file}: ${err.message}`); } @@ -254,15 +283,17 @@ async function run() { console.log("\n📄 Metadata JSON file saved."); // 2. Fetch all songs from Youworship_songs and sync URLs - console.log("\n📥 Fetching all songs from Firestore collection 'Youworship_songs'..."); + console.log( + "\n📥 Fetching all songs from Firestore collection 'Youworship_songs'...", + ); const songsSnap = await db.collection(FIRESTORE_COLLECTION).get(); console.log(`Loaded ${songsSnap.docs.length} song documents from Firestore.`); - const dbSongs = songsSnap.docs.map(doc => ({ + const dbSongs = songsSnap.docs.map((doc) => ({ id: doc.id, ref: doc.ref, title: doc.data().title, - media: doc.data().media || {} + media: doc.data().media || {}, })); console.log("\n🔄 Syncing image URLs to Firestore documents..."); @@ -277,24 +308,32 @@ async function run() { const normalizedFile = normalizeTitle(fileTitle); // Look for exact normalized title match - const match = dbSongs.find(s => normalizeTitle(s.title) === normalizedFile); + const match = dbSongs.find( + (s) => normalizeTitle(s.title) === normalizedFile, + ); if (match) { matchedCount++; // Check if document already has the same URL to avoid redundant writes if (match.media.image === publicUrl) { - console.log(`✨ Song "${match.title}" (ID: ${match.id}) already has correct image URL in Firestore.`); + console.log( + `✨ Song "${match.title}" (ID: ${match.id}) already has correct image URL in Firestore.`, + ); continue; } - console.log(`🛠️ Updating Firestore for "${match.title}" (ID: ${match.id})`); + console.log( + `🛠️ Updating Firestore for "${match.title}" (ID: ${match.id})`, + ); await match.ref.update({ imageUrl: publicUrl, - "media.image": publicUrl + "media.image": publicUrl, }); updatedCount++; } else { - console.warn(`⚠️ No matching song found in Youworship_songs for image file: "${fileTitle}"`); + console.warn( + `⚠️ No matching song found in Youworship_songs for image file: "${fileTitle}"`, + ); } } diff --git a/scripts/remove-unsplash-image.mjs b/scripts/remove-unsplash-image.mjs index 41aa5a5..8d56d7a 100644 --- a/scripts/remove-unsplash-image.mjs +++ b/scripts/remove-unsplash-image.mjs @@ -21,7 +21,8 @@ import { } from "firebase/firestore"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -dotenv.config({ path: path.resolve(__dirname, "../.env.local") }); +dotenv.config({ path: path.resolve(__dirname, "../.env") }); +dotenv.config({ path: path.resolve(__dirname, "../.env.local"), override: true }); const firebaseConfig = { apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, diff --git a/scripts/seed-bible-chapters.mjs b/scripts/seed-bible-chapters.mjs index aad006e..489c725 100644 --- a/scripts/seed-bible-chapters.mjs +++ b/scripts/seed-bible-chapters.mjs @@ -7,16 +7,20 @@ * Run: node scripts/seed-bible-chapters.mjs * * Prerequisites: - * 1. Update SongHub/.env.local with real Firebase credentials + * 1. Update SongHub/.env.local or SongHub/.env with real Firebase credentials */ import dotenv from "dotenv"; import { fileURLToPath } from "url"; import path from "path"; -// Load .env.local +// Load .env and .env.local const __dirname = path.dirname(fileURLToPath(import.meta.url)); -dotenv.config({ path: path.resolve(__dirname, "../.env.local") }); +dotenv.config({ path: path.resolve(__dirname, "../.env") }); +dotenv.config({ + path: path.resolve(__dirname, "../.env.local"), + override: true, +}); const { initializeApp } = await import("firebase/app"); const { getFirestore, doc, setDoc } = await import("firebase/firestore"); @@ -40,8 +44,8 @@ if (missing.length > 0) { console.error( "❌ Firebase credentials not configured or still using dummy values.\n" + ` Missing/Invalid: ${missing.join(", ")}\n\n` + - "👉 Update SongHub/.env.local with your real Firebase project credentials first.\n" + - " Get them from: https://console.firebase.google.com → Project Settings → Web App" + "👉 Update SongHub/.env.local or SongHub/.env with your real Firebase project credentials first.\n" + + " Get them from: https://console.firebase.google.com → Project Settings → Web App", ); process.exit(1); } @@ -85,16 +89,22 @@ async function seed() { successCount++; } catch (error) { - console.error(` ❌ [${i + 1}/${VERSES.length}] ${verseId} — ${error.message}`); + console.error( + ` ❌ [${i + 1}/${VERSES.length}] ${verseId} — ${error.message}`, + ); errorCount++; } } console.log("\n" + "─".repeat(50)); if (errorCount === 0) { - console.log(`\n🎉 Success! ${successCount} verse(s) seeded into bible_chapters.\n`); + console.log( + `\n🎉 Success! ${successCount} verse(s) seeded into bible_chapters.\n`, + ); } else { - console.log(`\n⚠️ ${successCount} verse(s) seeded, ${errorCount} error(s).\n`); + console.log( + `\n⚠️ ${successCount} verse(s) seeded, ${errorCount} error(s).\n`, + ); } // Save a metadata document with total count diff --git a/scripts/seed-firestore.mjs b/scripts/seed-firestore.mjs index 04169fc..ae68303 100644 --- a/scripts/seed-firestore.mjs +++ b/scripts/seed-firestore.mjs @@ -7,7 +7,7 @@ * with realistic favorites, playlists, and recently played data. * * Prerequisites: - * 1. Update SongHub/.env.local with real Firebase credentials + * 1. Update SongHub/.env.local or SongHub/.env with real Firebase credentials * 2. Enable Email/Password auth in Firebase Console (optional, for login) */ @@ -15,9 +15,13 @@ import dotenv from "dotenv"; import { fileURLToPath } from "url"; import path from "path"; -// Load .env.local +// Load .env and .env.local const __dirname = path.dirname(fileURLToPath(import.meta.url)); -dotenv.config({ path: path.resolve(__dirname, "../.env.local") }); +dotenv.config({ path: path.resolve(__dirname, "../.env") }); +dotenv.config({ + path: path.resolve(__dirname, "../.env.local"), + override: true, +}); const { initializeApp } = await import("firebase/app"); const { getFirestore, doc, setDoc } = await import("firebase/firestore"); @@ -41,8 +45,8 @@ if (missing.length > 0) { console.error( "❌ Firebase credentials not configured or still using dummy values.\n" + ` Missing/Invalid: ${missing.join(", ")}\n\n` + - "👉 Update SongHub/.env.local with your real Firebase project credentials first.\n" + - " Get them from: https://console.firebase.google.com → Project Settings → Web App" + "👉 Update SongHub/.env.local or SongHub/.env with your real Firebase project credentials first.\n" + + " Get them from: https://console.firebase.google.com → Project Settings → Web App", ); process.exit(1); } @@ -67,7 +71,16 @@ const testUsers = [ displayName: "Praveen Kumar", photoURL: "https://api.dicebear.com/7.x/avataaars/svg?seed=praveen", data: { - favorites: ["9", "10", "60", "108", "162", "200", "208", "adavi-chetla-naduma"], + favorites: [ + "9", + "10", + "60", + "108", + "162", + "200", + "208", + "adavi-chetla-naduma", + ], playlists: [ { id: "pl-morning", @@ -192,7 +205,7 @@ async function seed() { ` ✅ ${user.displayName.padEnd(16)} (${user.email}) — ` + `${user.data.favorites.length} favorites, ` + `${user.data.playlists.length} playlists, ` + - `${user.data.recentlyPlayed.length} recently played` + `${user.data.recentlyPlayed.length} recently played`, ); successCount++; } catch (error) { @@ -203,9 +216,13 @@ async function seed() { console.log("\n" + "─".repeat(50)); if (errorCount === 0) { - console.log(`\n🎉 Success! ${successCount} user(s) seeded into Youworship_users.\n`); + console.log( + `\n🎉 Success! ${successCount} user(s) seeded into Youworship_users.\n`, + ); } else { - console.log(`\n⚠️ ${successCount} user(s) seeded, ${errorCount} error(s).\n`); + console.log( + `\n⚠️ ${successCount} user(s) seeded, ${errorCount} error(s).\n`, + ); } console.log("📋 To create Firebase Auth users for testing, use:"); @@ -214,7 +231,9 @@ async function seed() { testUsers.forEach((u) => { console.log(` - ${u.email} (password: Test123!)`); }); - console.log(" 3. Or enable Email/Password sign-in and use the Sign Up flow instead.\n"); + console.log( + " 3. Or enable Email/Password sign-in and use the Sign Up flow instead.\n", + ); process.exit(0); } diff --git a/scripts/sync-youtube-urls.mjs b/scripts/sync-youtube-urls.mjs index 9008463..10bd221 100644 --- a/scripts/sync-youtube-urls.mjs +++ b/scripts/sync-youtube-urls.mjs @@ -25,7 +25,11 @@ import { } from "firebase/firestore"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -dotenv.config({ path: path.resolve(__dirname, "../.env.local") }); +dotenv.config({ path: path.resolve(__dirname, "../.env") }); +dotenv.config({ + path: path.resolve(__dirname, "../.env.local"), + override: true, +}); const firebaseConfig = { apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, @@ -52,7 +56,9 @@ function getArtistName(data) { } async function syncYoutubeUrls() { - console.log("🎵 Syncing YouTube URLs from legacy 'songs' → 'Youworship_songs'\n"); + console.log( + "🎵 Syncing YouTube URLs from legacy 'songs' → 'Youworship_songs'\n", + ); try { const app = initializeApp(firebaseConfig); @@ -84,7 +90,9 @@ async function syncYoutubeUrls() { } } - console.log(` Legacy lookup built: ${legacyById.size} by ID, ${legacyByTitleArtist.size} by title+artist\n`); + console.log( + ` Legacy lookup built: ${legacyById.size} by ID, ${legacyByTitleArtist.size} by title+artist\n`, + ); // ── 2. Read active 'Youworship_songs' collection ────────────────── console.log("📥 Reading active 'Youworship_songs' collection..."); @@ -116,9 +124,16 @@ async function syncYoutubeUrls() { } if (matchUrl) { - needsSync.push({ id: activeId, url: matchUrl, data, method: matchMethod }); + needsSync.push({ + id: activeId, + url: matchUrl, + data, + method: matchMethod, + }); } else { - console.log(` ❌ No legacy match: "${data.title}" — ${getArtistName(data)}`); + console.log( + ` ❌ No legacy match: "${data.title}" — ${getArtistName(data)}`, + ); noMatch++; } } @@ -126,7 +141,9 @@ async function syncYoutubeUrls() { console.log(`\n📊 Summary:`); console.log(` Total active songs: ${activeSnap.docs.length}`); console.log(` Already has YouTube: ${alreadyHasYoutube}`); - console.log(` Missing YouTube URL: ${activeSnap.docs.length - alreadyHasYoutube}`); + console.log( + ` Missing YouTube URL: ${activeSnap.docs.length - alreadyHasYoutube}`, + ); console.log(` Matched in legacy: ${needsSync.length}`); console.log(` No legacy match: ${noMatch}\n`); @@ -150,7 +167,9 @@ async function syncYoutubeUrls() { }); ops++; - console.log(` [${i + 1}/${needsSync.length}] ✅ "${data.title}" ← ${url} (via ${method})`); + console.log( + ` [${i + 1}/${needsSync.length}] ✅ "${data.title}" ← ${url} (via ${method})`, + ); if (ops >= BATCH_SIZE) { console.log(`\n💾 Committing batch of ${ops}...`); @@ -170,7 +189,6 @@ async function syncYoutubeUrls() { console.log("======================================================"); console.log(`🎉 Done! Synced ${needsSync.length} YouTube URL(s).`); console.log("======================================================"); - } catch (error) { console.error("\n❌ Sync failed:", error); process.exit(1); diff --git a/scripts/update-durations.mjs b/scripts/update-durations.mjs index c198adf..9b79835 100644 --- a/scripts/update-durations.mjs +++ b/scripts/update-durations.mjs @@ -8,7 +8,7 @@ * Run: node scripts/update-durations.mjs * * Prerequisites: - * 1. Update SongHub/.env.local with real Firebase credentials + * 1. Update SongHub/.env.local or SongHub/.env with real Firebase credentials * 2. music-metadata & ytdl-core packages installed */ @@ -18,16 +18,15 @@ import path from "path"; import { setTimeout as sleep } from "timers/promises"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -dotenv.config({ path: path.resolve(__dirname, "../.env.local") }); +dotenv.config({ path: path.resolve(__dirname, "../.env") }); +dotenv.config({ + path: path.resolve(__dirname, "../.env.local"), + override: true, +}); const { initializeApp } = await import("firebase/app"); -const { - getFirestore, - collection, - getDocs, - doc, - updateDoc, -} = await import("firebase/firestore"); +const { getFirestore, collection, getDocs, doc, updateDoc } = + await import("firebase/firestore"); const { parseFile } = await import("music-metadata"); const ytdlCore = await import("ytdl-core"); const ytdl = ytdlCore.default || ytdlCore; @@ -46,7 +45,10 @@ const missing = Object.entries(firebaseConfig) .filter(([, v]) => !v || v.startsWith("AIzaSyDummy")) .map(([k]) => k); if (missing.length > 0) { - console.error("❌ Firebase credentials not configured.\nMissing:", missing.join(", ")); + console.error( + "❌ Firebase credentials not configured.\nMissing:", + missing.join(", "), + ); process.exit(1); } @@ -60,7 +62,10 @@ function formatDuration(seconds) { const secs = Math.round(seconds); const m = Math.floor(secs / 60); const s = secs % 60; - return { duration: secs, durationFormatted: `${m}:${s.toString().padStart(2, "0")}` }; + return { + duration: secs, + durationFormatted: `${m}:${s.toString().padStart(2, "0")}`, + }; } function extractYouTubeId(url) { @@ -112,14 +117,19 @@ async function getAudioDuration(audioUrl) { }); if (response.ok) { const buffer = Buffer.from(await response.arrayBuffer()); - const metadata = await parseFile(buffer, path.extname(audioUrl) || ".mp3"); + const metadata = await parseFile( + buffer, + path.extname(audioUrl) || ".mp3", + ); const seconds = metadata.format.duration; if (seconds && seconds > 0) { return formatDuration(seconds); } } } catch (streamErr) { - console.warn(` ⚠️ Audio metadata failed: ${streamErr.message.slice(0, 80)}`); + console.warn( + ` ⚠️ Audio metadata failed: ${streamErr.message.slice(0, 80)}`, + ); } } return null; @@ -144,7 +154,8 @@ async function updateDurations() { for (let i = 0; i < songs.length; i++) { const song = songs[i]; const rawAudio = song.media?.audio || song.audioUrl || ""; - const rawVideo = song.media?.video || song.videoUrl || song.youtubeUrl || ""; + const rawVideo = + song.media?.video || song.videoUrl || song.youtubeUrl || ""; // Skip if already has a valid duration (number or formatted string) const existingDuration = song.duration; @@ -155,7 +166,9 @@ async function updateDurations() { existingDuration !== "0:00"); if (hasValidDuration) { - console.log(` ⏭️ [${i + 1}/${songs.length}] ${song.title || song.id} — already has duration (${existingDuration})`); + console.log( + ` ⏭️ [${i + 1}/${songs.length}] ${song.title || song.id} — already has duration (${existingDuration})`, + ); skipped++; continue; } @@ -164,12 +177,16 @@ async function updateDurations() { const mediaUrl = rawAudio || rawVideo; if (!mediaUrl) { - console.log(` ⏭️ [${i + 1}/${songs.length}] ${song.title || song.id} — no media URL`); + console.log( + ` ⏭️ [${i + 1}/${songs.length}] ${song.title || song.id} — no media URL`, + ); skipped++; continue; } - process.stdout.write(` 🔍 [${i + 1}/${songs.length}] ${song.title || song.id}... `); + process.stdout.write( + ` 🔍 [${i + 1}/${songs.length}] ${song.title || song.id}... `, + ); let result = null; @@ -218,7 +235,9 @@ async function updateDurations() { console.log(` 📝 Total: ${songs.length}\n`); if (errors > 0) { - console.log("⚠️ Some songs could not be processed. Check the logs above for details.\n"); + console.log( + "⚠️ Some songs could not be processed. Check the logs above for details.\n", + ); process.exit(1); } diff --git a/src/app/admin/page.js b/src/app/admin/page.js index e1e1fd7..39c8bd3 100644 --- a/src/app/admin/page.js +++ b/src/app/admin/page.js @@ -29,6 +29,7 @@ import { Edit3, ListMusic, RefreshCw, + ArrowUpDown, } from "lucide-react"; import NextImage from "next/image"; @@ -531,13 +532,55 @@ function EditSongModal({ song, onClose, onSaveSuccess, getIdToken }) { ); } +// ─── Sort timestamp helper ───────────────────────────────────────── +// Firestore timestamps arrive from the admin API as serialized +// { _seconds, _nanoseconds } objects (or ISO strings). Normalize to ms. +function toTimeMs(val) { + if (!val) return 0; + if (typeof val === "number") return val; + if (typeof val === "string") { + const t = Date.parse(val); + return Number.isNaN(t) ? 0 : t; + } + if (typeof val === "object") { + if (typeof val._seconds === "number") return val._seconds * 1000; + if (typeof val.seconds === "number") return val.seconds * 1000; + if (typeof val.toMillis === "function") return val.toMillis(); + } + return 0; +} + +// Format a timestamp (same inputs as toTimeMs) into a short readable date. +function formatDate(val) { + const ms = toTimeMs(val); + if (!ms) return ""; + try { + return new Date(ms).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); + } catch (e) { + return ""; + } +} + // ─── Main Admin Dashboard ────────────────────────────────────────── export default function AdminPage() { const { user, isAuthenticated, loading, firestoreData } = useAuth(); const router = useRouter(); const mountedRef = useRef(true); + const songsLoadedRef = useRef(false); + const songsFetchPromiseRef = useRef(null); + const songsRef = useRef([]); - useEffect(() => { return () => { mountedRef.current = false; }; }, []); + useEffect(() => { + // StrictMode in dev mounts -> unmounts -> remounts, so we must reset + // the flag to true on every mount. Otherwise mountedRef stays false and + // fetchSongs() silently skips its state updates (infinite loading). + mountedRef.current = true; + return () => { mountedRef.current = false; }; + }, []); const scrollToTop = () => { if (typeof window !== "undefined") { @@ -555,6 +598,7 @@ export default function AdminPage() { const [searchQuery, setSearchQuery] = useState(""); const [selectedLanguage, setSelectedLanguage] = useState(""); const [selectedCategory, setSelectedCategory] = useState(""); + const [sortBy, setSortBy] = useState("title-asc"); // "title-asc" | "recent" | "title-desc" | "year-desc" | "year-asc" | "artist-asc" const [currentPage, setCurrentPage] = useState(1); const [saving, setSaving] = useState(false); const [success, setSuccess] = useState(null); // string message @@ -599,37 +643,57 @@ export default function AdminPage() { return user.getIdToken(); }, [user]); - const fetchSongs = useCallback(async () => { - if (!user) return; - setSongsLoading(true); - setError(null); - try { - const token = await getIdToken(); - const res = await fetchWithTimeout("/api/admin/songs", { - headers: { Authorization: `Bearer ${token}` }, - cache: "no-store", - }); - const data = await safeParseResponse(res, "Failed to fetch songs"); - if (mountedRef.current) { - setSongs(data.songs || []); - } - } catch (err) { - console.error("Error fetching songs:", err); - if (mountedRef.current) { - setError(err.message); - } - } finally { - if (mountedRef.current) { - setSongsLoading(false); - } + const fetchSongs = useCallback(async (options = {}) => { + if (!user) return []; + + const { force = false } = options; + if (!force && songsLoadedRef.current) { + return songsRef.current; } - }, [user, getIdToken]); - useEffect(() => { - if (isAuthenticated) { - fetchSongs(); + if (songsFetchPromiseRef.current) { + return songsFetchPromiseRef.current; } - }, [isAuthenticated, fetchSongs]); + + const requestPromise = (async () => { + setSongsLoading(true); + setError(null); + try { + const token = await getIdToken(); + const res = await fetchWithTimeout("/api/admin/songs", { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }); + const data = await safeParseResponse(res, "Failed to fetch songs"); + const nextSongs = Array.isArray(data?.songs) + ? data.songs + : Array.isArray(data) + ? data + : []; + + if (mountedRef.current) { + setSongs(nextSongs); + } + songsRef.current = nextSongs; + songsLoadedRef.current = true; + return nextSongs; + } catch (err) { + console.error("Error fetching songs:", err); + if (mountedRef.current) { + setError(err.message); + } + return []; + } finally { + songsFetchPromiseRef.current = null; + if (mountedRef.current) { + setSongsLoading(false); + } + } + })(); + + songsFetchPromiseRef.current = requestPromise; + return requestPromise; + }, [user, getIdToken]); // ─── Build Song Data from Form ────────────────────────────────── const buildSongData = () => { @@ -748,7 +812,7 @@ export default function AdminPage() { resetForm(); setSuccess(editingId ? "Song updated successfully!" : "Song added successfully!"); setActiveTab("list"); - fetchSongs(); + fetchSongs({ force: true }); scrollToTop(); setTimeout(() => { if (mountedRef.current) setSuccess(null); }, 5000); } catch (err) { @@ -772,7 +836,7 @@ export default function AdminPage() { await safeParseResponse(res, "Failed to delete song"); setDeleteTarget(null); setSuccess("Song deleted successfully!"); - fetchSongs(); + fetchSongs({ force: true }); setTimeout(() => { if (mountedRef.current) setSuccess(null); }, 5000); } catch (err) { setError(err.message); @@ -787,7 +851,7 @@ export default function AdminPage() { // ─── Filtered Songs & Pagination ─────────────────────────────── useEffect(() => { setCurrentPage(1); - }, [searchQuery, selectedLanguage, selectedCategory]); + }, [searchQuery, selectedLanguage, selectedCategory, sortBy]); const filteredSongs = songs.filter((s) => { if (searchQuery.trim()) { @@ -807,9 +871,31 @@ export default function AdminPage() { return true; }); + // Sort the filtered list based on the selected sort option + const sortedSongs = [...filteredSongs].sort((a, b) => { + switch (sortBy) { + case "title-asc": + return (a.title || "").localeCompare(b.title || "", undefined, { sensitivity: "base" }); + case "title-desc": + return (b.title || "").localeCompare(a.title || "", undefined, { sensitivity: "base" }); + case "year-desc": + return (Number(b.year) || 0) - (Number(a.year) || 0); + case "year-asc": + return (Number(a.year) || 0) - (Number(b.year) || 0); + case "artist-asc": + return (a.artist?.name || "").localeCompare(b.artist?.name || "", undefined, { sensitivity: "base" }); + case "recent": + default: + return ( + toTimeMs(b.updatedAt) - toTimeMs(a.updatedAt) || + toTimeMs(b.createdAt) - toTimeMs(a.createdAt) + ); + } + }); + const itemsPerPage = 15; - const totalPages = Math.ceil(filteredSongs.length / itemsPerPage); - const paginatedSongs = filteredSongs.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage); + const totalPages = Math.ceil(sortedSongs.length / itemsPerPage); + const paginatedSongs = sortedSongs.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage); // ─── Auth Guards ──────────────────────────────────────────────── if (loading) return
; @@ -850,7 +936,7 @@ export default function AdminPage() { - @@ -1090,6 +1176,18 @@ export default function AdminPage() { ))} + {/* Sort By */} +
+ + +
@@ -1097,7 +1195,7 @@ export default function AdminPage() { {/* Library Section Header (Mockup Style) */}

CURRENT LIBRARY ({songs.length})

- @@ -1120,6 +1218,7 @@ export default function AdminPage() { const yearInfo = song.year ? ` - ${song.year}` : ""; const langLabel = LANGUAGES.find(l => l.value === song.language)?.label || song.language?.toUpperCase() || "Telugu"; const subtitle = `${artist}${yearInfo} • ${langLabel}`; + const updatedLabel = sortBy === "recent" ? formatDate(song.updatedAt || song.createdAt) : ""; return (
{/* Thumbnail */} @@ -1135,6 +1234,12 @@ export default function AdminPage() {

{song.title}

{subtitle}

+ {/* Updated date (visible proof for the Recently Updated sort) */} + {updatedLabel && ( + + {updatedLabel} + + )} {/* Duration */} {song.duration ? `${Math.floor(song.duration / 60)}:${(song.duration % 60).toString().padStart(2, "0")}` : ""} @@ -1182,7 +1287,7 @@ export default function AdminPage() { song={editingSongForModal} onClose={() => setEditingSongForModal(null)} onSaveSuccess={() => { - fetchSongs(); + fetchSongs({ force: true }); setEditingSongForModal(null); setSuccess("Song updated successfully!"); setTimeout(() => { if (mountedRef.current) setSuccess(null); }, 5000); diff --git a/src/app/page.js b/src/app/page.js index eb173e5..c92162e 100644 --- a/src/app/page.js +++ b/src/app/page.js @@ -1,7 +1,6 @@ import Image from "next/image"; -import Link from "next/link"; import { redirect } from "next/navigation"; -import { ArrowRight } from "lucide-react"; +import EnterAppButton from "@/components/landing/EnterAppButton"; /** * Landing / Welcome screen — shown at the root URL (youworship.world). @@ -10,14 +9,34 @@ import { ArrowRight } from "lucide-react"; * subtitle, tagline and a glassmorphism "Explore Songs" CTA that * opens the application at /home. * + * The landing gate (src/middleware.js) sends every public app URL here with a + * `redirect` param (e.g. a shared song link → /?redirect=%2Fsong%2Fxyz), so the + * CTA opens the visitor's intended destination after they click through. + * * Legacy deep links (e.g. /?tab=..., /?q=..., /?auth=...) that the app * previously generated are forwarded to /home so bookmarks, shared links * and the auth modal keep working after the move. */ -const APP_PARAMS = ["tab", "q", "category", "playlistId", "auth", "redirect"]; +const APP_PARAMS = ["tab", "q", "category", "playlistId", "auth"]; export default async function LandingPage({ searchParams }) { const params = await searchParams; + + // Destination the visitor was originally heading to (set by the landing + // gate). Decode first, then only accept same-origin paths (no protocol- + // relative / absolute URLs) to avoid open-redirect tricks. + let redirectTo = null; + if (typeof params?.redirect === "string" && params.redirect) { + try { + const decoded = decodeURIComponent(params.redirect); + if (decoded.startsWith("/") && !decoded.startsWith("//")) { + redirectTo = decoded; + } + } catch { + // Malformed encoding → ignore. + } + } + if (params && APP_PARAMS.some((key) => params[key] !== undefined)) { const query = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { @@ -113,13 +132,12 @@ export default async function LandingPage({ searchParams }) { className="landing-fade-up mt-7 md:mt-9" style={{ animationDelay: "0.6s" }} > - - Explore Songs - - + + {redirectTo && ( +

+ You were sent to a song — open it after entering. +

+ )}
diff --git a/src/app/song/[id]/page.js b/src/app/song/[id]/page.js index f217e3c..c6dd926 100644 --- a/src/app/song/[id]/page.js +++ b/src/app/song/[id]/page.js @@ -6,7 +6,6 @@ import { useRouter } from "next/navigation"; import { ArrowLeft, Music, - Video, FileText, Plus, Share2, @@ -768,7 +767,7 @@ function SongPageContent({ params }) { className="w-9 h-9 rounded-full border border-line bg-card text-muted hover:text-title hover:bg-card-hover flex items-center justify-center transition-all hover:scale-105 active:scale-95 cursor-pointer shadow-sm flex-shrink-0" title="Watch Video" > -