diff --git a/firestore.rules b/firestore.rules index c3b7bbe..62ad569 100644 --- a/firestore.rules +++ b/firestore.rules @@ -6,7 +6,7 @@ rules_version = '2'; // Deploy with: firebase deploy --only firestore:rules // // Collections in use: -// Youworship_songs — song catalog (served via public API) +// youworship_songs — song catalog (served via public API) // bible_chapters — daily Bible verses (public) // Youworship_users/{uid} — favorites / playlists / recently played // Youworship_users/{uid}/devices/{deviceId} — YouWorship Connect registry @@ -37,7 +37,7 @@ service cloud.firestore { // ───────────────────────────────────────────────────────────── // Public content — browsable without an account. // ───────────────────────────────────────────────────────────── - match /Youworship_songs/{songId} { + match /youworship_songs/{songId} { // Songs are public; the API also serves them. allow read: if true; diff --git a/package-lock.json b/package-lock.json index 608eee5..9a9ac27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "firebase": "^12.16.0", "firebase-admin": "^14.2.0", "framer-motion": "^12.42.2", + "fuse.js": "^7.5.0", "jwks-rsa": "^3.1.0", "lucide-react": "^1.24.0", "music-metadata": "^11.14.0", @@ -443,6 +444,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/js": { "version": "9.39.5", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", @@ -3513,16 +3538,17 @@ } }, "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "devOptional": true, + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -5017,6 +5043,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -5137,7 +5187,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -5147,6 +5197,24 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "optional": true, + "peer": true + }, "node_modules/fast-xml-builder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz", @@ -5550,6 +5618,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.5.0.tgz", + "integrity": "sha512-sQtrEfA+ez/3G0cCZecF70oqpCRttCexYUG4mUrtWL49ULUzUyxokt5kyqwtKzj1270RaKih+hcP3qLcumccow==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/krisk" + } + }, "node_modules/gaxios": { "version": "6.7.1", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", @@ -6924,11 +7005,12 @@ "license": "MIT" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "devOptional": true, - "license": "MIT" + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -8814,7 +8896,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8962,6 +9044,17 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", @@ -10226,7 +10319,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" diff --git a/package.json b/package.json index f1dd2ac..ac3ccd7 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "firebase": "^12.16.0", "firebase-admin": "^14.2.0", "framer-motion": "^12.42.2", + "fuse.js": "^7.5.0", "jwks-rsa": "^3.1.0", "lucide-react": "^1.24.0", "music-metadata": "^11.14.0", @@ -40,7 +41,7 @@ "tailwindcss": "^4" }, "optionalDependencies": { - "lightningcss-linux-x64-gnu": "1.32.0", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.2" + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "lightningcss-linux-x64-gnu": "1.32.0" } } 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..a5019ec 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,25 +60,30 @@ 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; } async function fetchDurations() { - console.log("⏱️ Fetching YouTube durations for Youworship_songs...\n"); + console.log("⏱️ Fetching YouTube durations for youworship_songs...\n"); try { const app = initializeApp(firebaseConfig); const db = getFirestore(app); - const snap = await getDocs(collection(db, "Youworship_songs")); + const snap = await getDocs(collection(db, "youworship_songs")); console.log(`📦 Found ${snap.docs.length} documents.`); 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); @@ -121,7 +133,7 @@ async function fetchDurations() { for (const result of results) { if (result.status === "fulfilled" && result.value.seconds) { const { id, title, seconds } = result.value; - const ref = doc(db, "Youworship_songs", id); + const ref = doc(db, "youworship_songs", id); fbBatch.update(ref, { duration: seconds, updatedAt: new Date().toISOString(), diff --git a/scripts/fetch-youtube-durations.mjs b/scripts/fetch-youtube-durations.mjs index 1a12a05..c9a728b 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) { @@ -77,20 +83,23 @@ async function fetchDuration(youtubeId, retries = 2) { } async function fetchDurations() { - console.log("⏱️ Fetching YouTube durations for Youworship_songs...\n"); + console.log("⏱️ Fetching YouTube durations for youworship_songs...\n"); try { const app = initializeApp(firebaseConfig); const db = getFirestore(app); - const snap = await getDocs(collection(db, "Youworship_songs")); + const snap = await getDocs(collection(db, "youworship_songs")); console.log(`📦 Found ${snap.docs.length} documents.\n`); 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); @@ -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."); @@ -120,17 +131,21 @@ async function fetchDurations() { try { const seconds = await fetchDuration(youtubeId); if (seconds) { - const ref = doc(db, "Youworship_songs", id); + const ref = doc(db, "youworship_songs", id); batch.update(ref, { duration: seconds, updatedAt: new Date().toISOString(), }); 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..a1d61a8 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, @@ -23,12 +27,12 @@ const firebaseConfig = { }; async function fixImageUrls() { - console.log("🔍 Scanning and repairing Youworship_songs image URLs...\n"); + console.log("🔍 Scanning and repairing youworship_songs image URLs...\n"); try { const app = initializeApp(firebaseConfig); const db = getFirestore(app); - const colRef = collection(db, "Youworship_songs"); + const colRef = collection(db, "youworship_songs"); const querySnapshot = await getDocs(colRef); const batch = writeBatch(db); @@ -36,14 +40,23 @@ async function fixImageUrls() { querySnapshot.forEach((docSnap) => { const data = docSnap.data(); - const docRef = doc(db, "Youworship_songs", docSnap.id); + const docRef = doc(db, "youworship_songs", docSnap.id); let needsUpdate = false; 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..69b23f4 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, @@ -21,12 +31,14 @@ const db = getFirestore(app); async function main() { console.log("🔍 Scanning all songs for Supabase image URL issues..."); - const snap = await getDocs(collection(db, "Youworship_songs")); + const snap = await getDocs(collection(db, "youworship_songs")); console.log(`Loaded ${snap.docs.length} documents.`); 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..a5e4df3 100644 --- a/scripts/migrate-songs.mjs +++ b/scripts/migrate-songs.mjs @@ -1,7 +1,7 @@ /** - * Firestore Youworship_songs Schema Migration Script + * Firestore youworship_songs Schema Migration Script * - * Migrates documents in 'Youworship_songs' (or from 'songs') + * Migrates documents in 'youworship_songs' (or from 'songs') * to the NEW structured schema format. * * Usage: @@ -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,26 +187,29 @@ 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); const db = getFirestore(app); - // Fetch all documents from Youworship_songs - console.log("📥 Reading documents from 'Youworship_songs'..."); - const songsRef = collection(db, "Youworship_songs"); + // Fetch all documents from youworship_songs + console.log("📥 Reading documents from 'youworship_songs'..."); + const songsRef = collection(db, "youworship_songs"); 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,15 +231,20 @@ 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); + const targetRef = doc(db, "youworship_songs", targetDocId); batch.set(targetRef, newSchemaData, { merge: false }); 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 +256,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..48133be 100644 --- a/scripts/process-worship-images.js +++ b/scripts/process-worship-images.js @@ -9,13 +9,14 @@ * b. Upload to Supabase Storage bucket (`song-images`) with upsert: true. * c. Retrieve public URL. * d. Append to image-metadata.json. - * 3. Sync public URLs back to Firestore collection `Youworship_songs`: - * a. Query all documents in `Youworship_songs`. + * 3. Sync public URLs back to Firestore collection `youworship_songs`: + * a. Query all documents in `youworship_songs`. * b. Normalize titles for accurate matching. * 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,13 +24,17 @@ 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"; const BUCKET_NAME = "song-images"; const METADATA_FILE = "image-metadata.json"; -const FIRESTORE_COLLECTION = "Youworship_songs"; +const FIRESTORE_COLLECTION = "youworship_songs"; // WebP Compression Settings const WEBP_QUALITY = 80; @@ -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}`); } @@ -253,16 +282,18 @@ 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'..."); + // 2. Fetch all songs from youworship_songs and sync URLs + 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..f72a76f 100644 --- a/scripts/remove-unsplash-image.mjs +++ b/scripts/remove-unsplash-image.mjs @@ -1,5 +1,5 @@ /** - * Remove the specific Unsplash fallback image from all Youworship_songs docs. + * Remove the specific Unsplash fallback image from all youworship_songs docs. * * The URL to remove: https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4 * (matched by the photo ID, ignoring query param differences) @@ -21,7 +21,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, @@ -39,13 +43,13 @@ function hasUnsplash(value) { } async function removeUnsplashImage() { - console.log("🔍 Scanning Youworship_songs for Unsplash fallback image...\n"); + console.log("🔍 Scanning youworship_songs for Unsplash fallback image...\n"); try { const app = initializeApp(firebaseConfig); const db = getFirestore(app); - const snap = await getDocs(collection(db, "Youworship_songs")); + const snap = await getDocs(collection(db, "youworship_songs")); console.log(`📦 Found ${snap.docs.length} documents.\n`); const updates = []; @@ -56,7 +60,11 @@ async function removeUnsplashImage() { if (hasUnsplash(data.imageUrl)) updateFields.imageUrl = ""; if (hasUnsplash(data.coverUrl)) updateFields.coverUrl = ""; - if (data.media && typeof data.media === "object" && hasUnsplash(data.media.image)) { + if ( + data.media && + typeof data.media === "object" && + hasUnsplash(data.media.image) + ) { updateFields["media.image"] = ""; } @@ -78,7 +86,7 @@ async function removeUnsplashImage() { for (let i = 0; i < updates.length; i++) { const { id, title, updateFields } = updates[i]; - const ref = doc(db, "Youworship_songs", id); + const ref = doc(db, "youworship_songs", id); batch.update(ref, { ...updateFields, @@ -86,7 +94,9 @@ async function removeUnsplashImage() { }); ops++; - console.log(` [${i + 1}/${updates.length}] 🧹 "${title}" → ${JSON.stringify(updateFields)}`); + console.log( + ` [${i + 1}/${updates.length}] 🧹 "${title}" → ${JSON.stringify(updateFields)}`, + ); if (ops >= BATCH_SIZE) { await batch.commit(); @@ -104,7 +114,6 @@ async function removeUnsplashImage() { console.log("=================================================="); console.log(`🎉 Done! Cleaned ${updates.length} song(s).`); console.log("=================================================="); - } catch (error) { console.error("\n❌ Failed:", error); process.exit(1); 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..250bd11 100644 --- a/scripts/sync-youtube-urls.mjs +++ b/scripts/sync-youtube-urls.mjs @@ -1,7 +1,7 @@ /** - * Sync YouTube URLs from legacy 'songs' collection to active 'Youworship_songs' + * Sync YouTube URLs from legacy 'songs' collection to active 'youworship_songs' * - * For each song in Youworship_songs missing a YouTube URL, looks up the + * For each song in youworship_songs missing a YouTube URL, looks up the * matching song in the legacy 'songs' collection and copies it over. * * Matching strategy: @@ -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,11 +90,13 @@ 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..."); - const activeSnap = await getDocs(collection(db, "Youworship_songs")); + // ── 2. Read active 'youworship_songs' collection ──────────── + console.log("📥 Reading active 'youworship_songs' collection..."); + const activeSnap = await getDocs(collection(db, "youworship_songs")); console.log(` Found ${activeSnap.docs.length} active documents.\n`); // ── 3. Find songs that need a YouTube URL and have a legacy match ── @@ -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`); @@ -142,7 +159,7 @@ async function syncYoutubeUrls() { for (let i = 0; i < needsSync.length; i++) { const { id, url, data, method } = needsSync[i]; - const ref = doc(db, "Youworship_songs", id); + const ref = doc(db, "youworship_songs", id); batch.update(ref, { "media.video": url, @@ -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..bfd35f1 100644 --- a/scripts/update-durations.mjs +++ b/scripts/update-durations.mjs @@ -1,14 +1,14 @@ /** * Update Durations Script * - * Fetches every song from Firebase Youworship_songs, detects the actual + * Fetches every song from Firebase youworship_songs, detects the actual * duration from YouTube videos or direct audio files, and updates the * Firestore document with the correct duration in seconds. * * 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; @@ -131,11 +141,11 @@ async function updateDurations() { console.log("⏱️ Updating song durations from media files...\n"); // Fetch all songs - const songsRef = collection(db, "Youworship_songs"); + const songsRef = collection(db, "youworship_songs"); const snapshot = await getDocs(songsRef); const songs = snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() })); - console.log(`📥 Found ${songs.length} song(s) in Youworship_songs.\n`); + console.log(`📥 Found ${songs.length} song(s) in youworship_songs.\n`); let updated = 0; let skipped = 0; @@ -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; @@ -191,7 +208,7 @@ async function updateDurations() { if (result) { try { - const songRef = doc(db, "Youworship_songs", song.id); + const songRef = doc(db, "youworship_songs", song.id); await updateDoc(songRef, { duration: result.duration, updatedAt: new Date().toISOString(), @@ -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..dec7abb 100644 --- a/src/app/admin/page.js +++ b/src/app/admin/page.js @@ -29,17 +29,12 @@ import { Edit3, ListMusic, RefreshCw, + ArrowUpDown, } from "lucide-react"; import NextImage from "next/image"; +import { CATEGORIES_DATA } from "@/components/categories/categoryData"; -// ─── Constants ───────────────────────────────────────────────────── -const CATEGORIES = [ - "Morning Worship", "Prayer Songs", "Worship Songs", "Praise Songs", - "Salvation Songs", "Faith & Hope Songs", "Repentance Songs", "Holy Spirit Songs", - "Healing & Miracle Songs", "Bible-Based Songs", "Youth Songs", "Communion Songs", - "Christmas Songs", "Good Friday Songs", "Easter Songs", "Baptism Songs", - "Marriage Songs", "Thanksgiving Songs", -]; +const CATEGORIES = CATEGORIES_DATA.map((c) => c.nameEn); const LANGUAGES = [ { value: "te", label: "Telugu", nativeLabel: "తెలుగు" }, @@ -291,7 +286,7 @@ function EditSongModal({ song, onClose, onSaveSuccess, getIdToken }) { nameEnglish: form.artistNameEnglish.trim() || "Unknown Artist" }, language: form.language === "te" ? "Telugu" : (form.language === "hi" ? "Hindi" : "English"), - category: form.categories.length > 0 ? form.categories : ["Praise & Worship"], + category: form.categories.length > 0 ? form.categories : ["Praise"], album: form.album.trim() || null, year: parseInt(form.year, 10) || new Date().getFullYear(), duration: durationNum, @@ -531,13 +526,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 +592,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 +637,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 = () => { @@ -677,7 +735,7 @@ export default function AdminPage() { nameEnglish: form.artistNameEnglish.trim() || "Unknown Artist" }, language: form.language === "te" ? "Telugu" : (form.language === "hi" ? "Hindi" : "English"), - category: form.categories.length > 0 ? form.categories : ["Praise & Worship"], + category: form.categories.length > 0 ? form.categories : ["Praise"], album: form.album.trim() || null, year: parseInt(form.year, 10) || new Date().getFullYear(), duration: durationNum, @@ -748,7 +806,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 +830,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 +845,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()) { @@ -801,15 +859,45 @@ export default function AdminPage() { } if (selectedCategory) { const categories = Array.isArray(s.category) ? s.category : (s.category ? [s.category] : []); - const matched = categories.some(cat => cat.toLowerCase() === selectedCategory.toLowerCase()); + const catObj = CATEGORIES_DATA.find(c => c.nameEn === selectedCategory); + const matched = categories.some(cat => { + if (cat.toLowerCase() === selectedCategory.toLowerCase()) return true; + if (catObj) { + if (catObj.nameTe && cat.toLowerCase() === catObj.nameTe.toLowerCase()) return true; + if (Array.isArray(catObj.legacyNames) && catObj.legacyNames.some(ln => ln.toLowerCase() === cat.toLowerCase())) return true; + } + return false; + }); if (!matched) return false; } 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 +938,7 @@ export default function AdminPage() { - @@ -1090,6 +1178,18 @@ export default function AdminPage() { ))} + {/* Sort By */} +
+ + +
@@ -1097,7 +1197,7 @@ export default function AdminPage() { {/* Library Section Header (Mockup Style) */}

CURRENT LIBRARY ({songs.length})

- @@ -1120,6 +1220,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 */} @@ -1132,9 +1233,23 @@ export default function AdminPage() {
{/* Info */}
-

{song.title}

+
+

{song.title}

+ {!(song.audioUrl || song.media?.audio || song.youtubeId) && ( + 🔇 + )} + {!(song.youtubeId || song.media?.video) && ( + 🚫🎥 + )} +

{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 +1297,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/api/admin/songs/[id]/route.js b/src/app/api/admin/songs/[id]/route.js index 3a5aaca..a866de1 100644 --- a/src/app/api/admin/songs/[id]/route.js +++ b/src/app/api/admin/songs/[id]/route.js @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { getFirebaseAdmin, verifyAdminAuth, COLLECTION_NAME, generateSongId, FieldValue } from "@/lib/admin-firebase"; +import { songService } from "@/services/songService"; // firebase-admin requires Node.js APIs (crypto, net) and must NOT run on the // Edge runtime. Explicitly opt into the Node.js runtime. @@ -128,6 +129,8 @@ export async function PUT(request, { params }) { await songRef.update(updateData); } + songService.invalidateCache(); + return NextResponse.json({ success: true, id: newId, @@ -183,6 +186,8 @@ export async function DELETE(request, { params }) { await songRef.delete(); + songService.invalidateCache(); + return NextResponse.json({ success: true, id, diff --git a/src/app/api/admin/songs/route.js b/src/app/api/admin/songs/route.js index 8c8e342..6fe4c55 100644 --- a/src/app/api/admin/songs/route.js +++ b/src/app/api/admin/songs/route.js @@ -1,5 +1,12 @@ import { NextResponse } from "next/server"; -import { getFirebaseAdmin, verifyAdminAuth, COLLECTION_NAME, generateSongId, FieldValue } from "@/lib/admin-firebase"; +import { + getFirebaseAdmin, + verifyAdminAuth, + COLLECTION_NAME, + generateSongId, + FieldValue, +} from "@/lib/admin-firebase"; +import { songService } from "@/services/songService"; // firebase-admin requires Node.js APIs (crypto, net) and must NOT run on the // Edge runtime. Explicitly opt into the Node.js runtime. @@ -9,7 +16,7 @@ export const dynamic = "force-dynamic"; /** * GET /api/admin/songs - * List all songs from the Youworship_songs collection. + * List all songs from the youworship_songs collection. */ export async function GET(request) { try { @@ -28,21 +35,25 @@ export async function GET(request) { })); // Sort alphabetically in-memory to avoid needing a composite Firestore index - songs.sort((a, b) => (a.title || "").localeCompare(b.title || "", undefined, { sensitivity: "base" })); + songs.sort((a, b) => + (a.title || "").localeCompare(b.title || "", undefined, { + sensitivity: "base", + }), + ); return NextResponse.json({ songs }); } catch (error) { console.error("Error listing songs:", error); return NextResponse.json( { error: error.stack || error.message || "Failed to list songs." }, - { status: 500 } + { status: 500 }, ); } } /** * POST /api/admin/songs - * Create a new song in the Youworship_songs collection. + * Create a new song in the youworship_songs collection. * Document ID is auto-generated as title-artistName. */ export async function POST(request) { @@ -55,12 +66,14 @@ export async function POST(request) { const body = await request.json(); if (!body.title || !body.title.trim()) { - return NextResponse.json({ error: "Song title is required." }, { status: 400 }); + return NextResponse.json( + { error: "Song title is required." }, + { status: 400 }, + ); } const db = getFirebaseAdmin(); - let artistName = "Unknown Artist"; let artistNameEnglish = ""; let artistId = null; @@ -81,14 +94,24 @@ export async function POST(request) { inputLyrics = body.lyrics; } else if (typeof body.lyrics === "string" && body.lyrics.trim()) { inputLyrics = [ - { language: "te", format: "original", title: "తెలుగు", content: body.lyrics.trim(), isDefault: true } + { + language: "te", + format: "original", + title: "తెలుగు", + content: body.lyrics.trim(), + isDefault: true, + }, ]; - if (body.englishLyrics && typeof body.englishLyrics === "string" && body.englishLyrics.trim()) { + if ( + body.englishLyrics && + typeof body.englishLyrics === "string" && + body.englishLyrics.trim() + ) { inputLyrics.push({ language: "en", format: "transliteration", title: "Romanized", - content: body.englishLyrics.trim() + content: body.englishLyrics.trim(), }); } } @@ -99,7 +122,9 @@ export async function POST(request) { const existingDoc = await songsRef.doc(customId).get(); if (existingDoc.exists) { const existingData = existingDoc.data() || {}; - const existingLyrics = Array.isArray(existingData.lyrics) ? existingData.lyrics : []; + const existingLyrics = Array.isArray(existingData.lyrics) + ? existingData.lyrics + : []; // Merge lyrics arrays by matching language const lyricsMap = {}; @@ -117,31 +142,72 @@ export async function POST(request) { } // Merge categories, tags, and media - const mergedCategories = Array.from(new Set([ - ...(Array.isArray(body.category) ? body.category : (body.category ? [body.category] : [])), - ...(Array.isArray(existingData.category) ? existingData.category : (existingData.category ? [existingData.category] : [])) - ])); + const mergedCategories = Array.from( + new Set([ + ...(Array.isArray(body.category) + ? body.category + : body.category + ? [body.category] + : []), + ...(Array.isArray(existingData.category) + ? existingData.category + : existingData.category + ? [existingData.category] + : []), + ]), + ); - const mergedTags = Array.from(new Set([ - ...(Array.isArray(body.tags) ? body.tags : []), - ...(Array.isArray(existingData.tags) ? existingData.tags : []) - ])); + const mergedTags = Array.from( + new Set([ + ...(Array.isArray(body.tags) ? body.tags : []), + ...(Array.isArray(existingData.tags) ? existingData.tags : []), + ]), + ); const updateData = { title: body.title.trim(), - titleEnglish: body.titleEnglish ? body.titleEnglish.trim() : (existingData.titleEnglish || ""), - artist: { id: artistId, name: artistName, nameEnglish: artistNameEnglish || existingData.artist?.nameEnglish || "" }, + titleEnglish: body.titleEnglish + ? body.titleEnglish.trim() + : existingData.titleEnglish || "", + artist: { + id: artistId, + name: artistName, + nameEnglish: + artistNameEnglish || existingData.artist?.nameEnglish || "", + }, language: body.language || existingData.language || "te", category: mergedCategories, - album: body.album !== undefined ? body.album : (existingData.album || null), - year: typeof body.year === "number" ? body.year : (existingData.year || new Date().getFullYear()), - duration: typeof body.duration === "number" ? body.duration : (existingData.duration || null), + album: + body.album !== undefined ? body.album : existingData.album || null, + year: + typeof body.year === "number" + ? body.year + : existingData.year || new Date().getFullYear(), + duration: + typeof body.duration === "number" + ? body.duration + : existingData.duration || null, tags: mergedTags, lyrics: mergedLyrics, media: { - image: body.media?.image || body.imageUrl || existingData.media?.image || existingData.imageUrl || "", - audio: body.media?.audio || body.audioUrl || existingData.media?.audio || existingData.audioUrl || "", - video: body.media?.video || body.videoUrl || existingData.media?.video || existingData.videoUrl || "", + image: + body.media?.image || + body.imageUrl || + existingData.media?.image || + existingData.imageUrl || + "", + audio: + body.media?.audio || + body.audioUrl || + existingData.media?.audio || + existingData.audioUrl || + "", + video: + body.media?.video || + body.videoUrl || + existingData.media?.video || + existingData.videoUrl || + "", }, updatedBy: auth.uid, updatedAt: FieldValue.serverTimestamp(), @@ -151,7 +217,7 @@ export async function POST(request) { return NextResponse.json( { success: true, id: customId, message: "Song updated successfully." }, - { status: 200 } + { status: 200 }, ); } @@ -160,11 +226,20 @@ export async function POST(request) { title: body.title.trim(), titleEnglish: body.titleEnglish ? body.titleEnglish.trim() : "", slug: customId, - artist: { id: artistId, name: artistName, nameEnglish: artistNameEnglish }, + artist: { + id: artistId, + name: artistName, + nameEnglish: artistNameEnglish, + }, language: body.language || "te", - category: Array.isArray(body.category) ? body.category : (body.category ? [body.category] : []), + category: Array.isArray(body.category) + ? body.category + : body.category + ? [body.category] + : [], album: body.album || null, - year: typeof body.year === "number" ? body.year : new Date().getFullYear(), + year: + typeof body.year === "number" ? body.year : new Date().getFullYear(), duration: typeof body.duration === "number" ? body.duration : null, tags: Array.isArray(body.tags) ? body.tags : [], lyrics: inputLyrics, @@ -176,16 +251,17 @@ export async function POST(request) { await songsRef.doc(customId).set(songData); + songService.invalidateCache(); + return NextResponse.json( { success: true, id: customId, message: "Song added successfully." }, - { status: 201 } + { status: 201 }, ); - } catch (error) { console.error("Error adding song:", error); return NextResponse.json( { error: error.message || "Failed to add song." }, - { status: 500 } + { status: 500 }, ); } } @@ -204,7 +280,10 @@ export async function PUT(request) { const { searchParams } = new URL(request.url); const id = searchParams.get("id"); if (!id) { - return NextResponse.json({ error: "Song ID is required." }, { status: 400 }); + return NextResponse.json( + { error: "Song ID is required." }, + { status: 400 }, + ); } const body = await request.json(); @@ -221,7 +300,8 @@ export async function PUT(request) { const updateData = {}; if (body.title !== undefined) updateData.title = body.title.trim(); - if (body.titleEnglish !== undefined) updateData.titleEnglish = body.titleEnglish.trim(); + if (body.titleEnglish !== undefined) + updateData.titleEnglish = body.titleEnglish.trim(); // Update artist info if (body.artist !== undefined) { @@ -235,19 +315,26 @@ export async function PUT(request) { } else if (typeof body.artist === "string" && body.artist.trim()) { artistName = body.artist.trim(); } - updateData.artist = { id: artistId, name: artistName, nameEnglish: artistNameEnglish }; + updateData.artist = { + id: artistId, + name: artistName, + nameEnglish: artistNameEnglish, + }; } if (body.language !== undefined) updateData.language = body.language; if (body.category !== undefined) { updateData.category = Array.isArray(body.category) ? body.category - : (body.category ? [body.category] : []); + : body.category + ? [body.category] + : []; } if (body.album !== undefined) updateData.album = body.album; if (body.year !== undefined) updateData.year = body.year; if (body.duration !== undefined) { - updateData.duration = typeof body.duration === "number" ? body.duration : null; + updateData.duration = + typeof body.duration === "number" ? body.duration : null; } if (body.tags !== undefined) updateData.tags = body.tags; if (body.lyrics !== undefined) { @@ -256,14 +343,24 @@ export async function PUT(request) { finalLyrics = body.lyrics; } else if (typeof body.lyrics === "string" && body.lyrics.trim()) { finalLyrics = [ - { language: "te", format: "original", title: "తెలుగు", content: body.lyrics.trim(), isDefault: true } + { + language: "te", + format: "original", + title: "తెలుగు", + content: body.lyrics.trim(), + isDefault: true, + }, ]; - if (body.englishLyrics && typeof body.englishLyrics === "string" && body.englishLyrics.trim()) { + if ( + body.englishLyrics && + typeof body.englishLyrics === "string" && + body.englishLyrics.trim() + ) { finalLyrics.push({ language: "en", format: "transliteration", title: "Romanized", - content: body.englishLyrics.trim() + content: body.englishLyrics.trim(), }); } } @@ -273,7 +370,8 @@ export async function PUT(request) { // Regenerate slug/id if title or artist name changed const title = updateData.title ?? existingData.title; - const artistName = updateData.artist?.name ?? existingData.artist?.name ?? "Unknown Artist"; + const artistName = + updateData.artist?.name ?? existingData.artist?.name ?? "Unknown Artist"; const newId = generateSongId(title, artistName); updateData.id = newId; updateData.slug = newId; @@ -288,7 +386,7 @@ export async function PUT(request) { if (newDoc.exists) { return NextResponse.json( { error: "A song with this title and artist name already exists." }, - { status: 400 } + { status: 400 }, ); } @@ -303,6 +401,8 @@ export async function PUT(request) { await songRef.update(updateData); } + songService.invalidateCache(); + return NextResponse.json({ success: true, id: newId, @@ -320,7 +420,7 @@ export async function PUT(request) { code: error.code || "unknown", stack: error.stack, }, - { status: 500 } + { status: 500 }, ); } } @@ -339,7 +439,10 @@ export async function DELETE(request) { const { searchParams } = new URL(request.url); const id = searchParams.get("id"); if (!id) { - return NextResponse.json({ error: "Song ID is required." }, { status: 400 }); + return NextResponse.json( + { error: "Song ID is required." }, + { status: 400 }, + ); } const db = getFirebaseAdmin(); @@ -352,6 +455,8 @@ export async function DELETE(request) { await songRef.delete(); + songService.invalidateCache(); + return NextResponse.json({ success: true, id, @@ -369,7 +474,7 @@ export async function DELETE(request) { code: error.code || "unknown", stack: error.stack, }, - { status: 500 } + { status: 500 }, ); } } diff --git a/src/app/api/search/lyrics/route.js b/src/app/api/search/lyrics/route.js new file mode 100644 index 0000000..8ca828a --- /dev/null +++ b/src/app/api/search/lyrics/route.js @@ -0,0 +1,190 @@ +import { NextResponse } from "next/server"; +import Fuse from "fuse.js"; +import { songService } from "@/services/songService"; + +// In-memory search index cache +let _searchIndex = null; +let _indexSongsTs = 0; +const INDEX_TTL_MS = 60_000; // rebuild index every 60s + +/** + * Build a flat, searchable dataset from songs. + * Each entry is one line of lyrics + metadata so Fuse can match individual lines. + */ +function buildSearchEntries(songs) { + const entries = []; + for (const song of songs) { + // Extract all lyrics text from the lyrics array + const lyricsBlocks = Array.isArray(song.lyrics) ? song.lyrics : []; + for (const block of lyricsBlocks) { + const content = block?.content || ""; + if (!content) continue; + const lines = content.split("\n").filter((l) => l.trim()); + for (const line of lines) { + entries.push({ + songId: song.id, + songTitle: song.title, + songTitleEnglish: song.titleEnglish || "", + teluguTitle: song.teluguTitle || song.title, + artist: song.artistName || song.artist || "Unknown Artist", + artistEnglish: song.artistNameEnglish || "", + language: block.language || song.language || "te", + imageUrl: song.imageUrl || song.coverUrl || "", + slug: song.slug || song.id, + duration: song.duration || "0:00", + line: line.trim(), + }); + } + } + + // Also index title and artist for title/artist fuzzy search + entries.push({ + songId: song.id, + songTitle: song.title, + songTitleEnglish: song.titleEnglish || "", + teluguTitle: song.teluguTitle || song.title, + artist: song.artistName || song.artist || "Unknown Artist", + artistEnglish: song.artistNameEnglish || "", + language: song.language || "te", + imageUrl: song.imageUrl || song.coverUrl || "", + slug: song.slug || song.id, + duration: song.duration || "0:00", + line: `__TITLE__ ${song.title} ${song.titleEnglish || ""} ${song.teluguTitle || ""}`, + }); + entries.push({ + songId: song.id, + songTitle: song.title, + songTitleEnglish: song.titleEnglish || "", + teluguTitle: song.teluguTitle || song.title, + artist: song.artistName || song.artist || "Unknown Artist", + artistEnglish: song.artistNameEnglish || "", + language: song.language || "te", + imageUrl: song.imageUrl || song.coverUrl || "", + slug: song.slug || song.id, + duration: song.duration || "0:00", + line: `__ARTIST__ ${song.artistName || song.artist || ""} ${song.artistNameEnglish || ""}`, + }); + } + return entries; +} + +function getSearchIndex(songs) { + if (_searchIndex && Date.now() - _indexSongsTs < INDEX_TTL_MS) { + return _searchIndex; + } + + const entries = buildSearchEntries(songs); + _searchIndex = new Fuse(entries, { + keys: [ + { name: "line", weight: 1.0 }, + { name: "songTitle", weight: 0.4 }, + { name: "songTitleEnglish", weight: 0.4 }, + { name: "artist", weight: 0.3 }, + { name: "artistEnglish", weight: 0.3 }, + ], + threshold: 0.35, // fuzzy tolerance (0 = exact, 1 = match anything) + distance: 200, + includeMatches: true, + minMatchCharLength: 2, + ignoreLocation: true, // search the whole string, not just prefix + }); + _indexSongsTs = Date.now(); + return _searchIndex; +} + +export async function GET(request) { + try { + const { searchParams } = new URL(request.url); + const q = searchParams.get("q") || ""; + const lang = searchParams.get("lang") || ""; // optional language filter + const page = parseInt(searchParams.get("page") || "1", 10); + const perPage = parseInt(searchParams.get("perPage") || "30", 10); + + if (!q.trim()) { + return NextResponse.json({ results: [], total: 0, query: "" }); + } + + // Get full songs from cache (includes lyrics) + const songs = await songService.getAllSongs(); + const index = getSearchIndex(songs); + + let fuseResults = index.search(q.trim()); + + // Optional language filter + if (lang) { + const langLower = lang.toLowerCase(); + fuseResults = fuseResults.filter((r) => { + const rLang = (r.item.language || "").toLowerCase(); + return rLang === langLower || rLang.startsWith(langLower); + }); + } + + const total = fuseResults.length; + + // Deduplicate by songId — group all matching lines per song + const songMap = new Map(); + for (const result of fuseResults) { + const entry = result.item; + const songId = entry.songId; + + if (!songMap.has(songId)) { + songMap.set(songId, { + songId, + title: entry.songTitle, + titleEnglish: entry.songTitleEnglish, + teluguTitle: entry.teluguTitle, + artist: entry.artist, + artistEnglish: entry.artistEnglish, + language: entry.language, + imageUrl: entry.imageUrl, + slug: entry.slug, + duration: entry.duration, + matchedLines: [], + score: result.score ?? 1, + }); + } + + const songEntry = songMap.get(songId); + + // Track the best (lowest) score per song + if ((result.score ?? 1) < songEntry.score) { + songEntry.score = result.score ?? 1; + } + + // Add the matching line with highlight positions + if (!entry.line.startsWith("__TITLE__") && !entry.line.startsWith("__ARTIST__")) { + const matchData = result.matches?.find((m) => m.key === "line"); + songEntry.matchedLines.push({ + text: entry.line, + indices: matchData?.indices || [], + language: entry.language, + }); + } + } + + // Convert map to array, sort by score (best first), then limit matched lines per song + const results = Array.from(songMap.values()) + .sort((a, b) => a.score - b.score) + .slice((page - 1) * perPage, page * perPage) + .map((song) => ({ + ...song, + // Keep only top 3 matched lines per song to limit payload + matchedLines: song.matchedLines.slice(0, 3), + })); + + return NextResponse.json({ + results, + total, + query: q.trim(), + page, + perPage, + hasMore: page * perPage < total, + }); + } catch (error) { + console.error("❌ [lyrics-search] Error:", error); + return NextResponse.json( + { error: "Search failed", results: [], total: 0 }, + { status: 500 } + ); + } +} diff --git a/src/app/api/songs/route.js b/src/app/api/songs/route.js index bf92b56..76166bc 100644 --- a/src/app/api/songs/route.js +++ b/src/app/api/songs/route.js @@ -4,17 +4,63 @@ export const dynamic = "force-dynamic"; /** * GET /api/songs - * Optional server-side API route returning all songs from Youworship_songs + * + * Supports cursor-based (keyset) pagination with optional filters: + * GET /api/songs → first page (20 songs) + * GET /api/songs?limit=30 → first page with custom size + * GET /api/songs?cursor= → next page after cursor + * GET /api/songs?category=Praise & Worship → category-filtered first page + * GET /api/songs?letter=A → letter-filtered first page + * + * Response shape: + * { songs, count, cursor, hasMore, filter: { category?, letter? } } */ -export async function GET() { +export async function GET(request) { try { - const songs = await songService.getAllSongs(); - return Response.json({ songs, count: songs.length }); + const { searchParams } = new URL(request.url); + const limitParam = Math.min(Math.max(parseInt(searchParams.get("limit")) || 20, 1), 100); + const cursor = searchParams.get("cursor"); + const category = searchParams.get("category"); + const letter = searchParams.get("letter"); + + let result; + + if (category) { + // Category-filtered pagination + result = cursor + ? await songService.getCategorySongsPageAfter(category, cursor, limitParam) + : await songService.getCategorySongsPage(category, limitParam); + } else if (letter) { + // Letter-filtered pagination + result = cursor + ? await songService.getLetterSongsPageAfter(letter, cursor, limitParam) + : await songService.getLetterSongsPage(letter, limitParam); + } else { + // All songs pagination + result = cursor + ? await songService.getSongsPageAfter(cursor, limitParam) + : await songService.getSongsPage(limitParam); + } + + return Response.json( + { + songs: result.songs, + count: result.songs.length, + cursor: result.cursor, + hasMore: result.hasMore, + filter: { category: category || null, letter: letter || null }, + }, + { + headers: { + "Cache-Control": "public, s-maxage=60, max-age=30, stale-while-revalidate=60", + }, + }, + ); } catch (error) { console.error("GET /api/songs error:", error); return Response.json( - { error: error.message || "Failed to fetch songs from Youworship_songs" }, - { status: 500 } + { error: error.message || "Failed to fetch songs from youworship_songs" }, + { status: 500 }, ); } } diff --git a/src/app/globals.css b/src/app/globals.css index ca23e1e..ea0d7eb 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -365,3 +365,30 @@ input:-webkit-autofill:focus { .landing-cta:active { transform: translateY(0) scale(0.98); } + +/* Voice Search Wave Animations */ +@keyframes voice-wave-grow { + 0%, 100% { height: 8px; } + 50% { height: 28px; } +} + +.animate-voice-wave-1 { + animation: voice-wave-grow 0.8s ease-in-out infinite; + animation-delay: 0.1s; +} +.animate-voice-wave-2 { + animation: voice-wave-grow 0.8s ease-in-out infinite; + animation-delay: 0.25s; +} +.animate-voice-wave-3 { + animation: voice-wave-grow 0.8s ease-in-out infinite; + animation-delay: 0.4s; +} +.animate-voice-wave-4 { + animation: voice-wave-grow 0.8s ease-in-out infinite; + animation-delay: 0.15s; +} +.animate-voice-wave-5 { + animation: voice-wave-grow 0.8s ease-in-out infinite; + animation-delay: 0.3s; +} diff --git a/src/app/home/page.js b/src/app/home/page.js index 6ddaf64..99feb63 100644 --- a/src/app/home/page.js +++ b/src/app/home/page.js @@ -11,6 +11,8 @@ import HeroCarousel from "@/components/home/HeroCarousel"; import VerseOfTheWeek from "@/components/home/VerseOfTheWeek"; import RecentlyPlayed from "@/components/home/RecentlyPlayed"; import SearchResults from "@/components/layout/SearchResults"; +import LyricsSearchResults from "@/components/search/LyricsSearchResults"; +import { useLyricsSearch } from "@/hooks/useLyricsSearch"; import SongArtwork from "@/components/ui/SongArtwork"; import CategoryExplorer from "@/components/categories/CategoryExplorer"; import { FullAppSkeleton } from "@/components/ui/SongSkeleton"; @@ -29,7 +31,8 @@ import { TrendingUp, Library, X, - Music + Music, + Mic } from "lucide-react"; const searchableText = (value) => { @@ -66,12 +69,39 @@ function HomeContent() { showFullHome, } = useAudio(); - const { searchQuery, setSearchQuery, showFullResults, setShowFullResults } = useSearch(); + const { searchQuery, setSearchQuery, showFullResults, setShowFullResults, searchMode, setSearchMode, triggerVoiceSearch } = useSearch(); + + // Lyrics search (used when searchMode === "lyrics") + const { + query: lyricsQuery, + setQuery: setLyricsQuery, + results: lyricsResults, + total: lyricsTotal, + loading: lyricsLoading, + error: lyricsError, + hasMore: lyricsHasMore, + loadMore: lyricsLoadMore, + clear: clearLyricsSearch, + } = useLyricsSearch({ debounceMs: 350 }); + + // Sync searchQuery -> lyricsQuery when in lyrics mode + useEffect(() => { + if (searchMode === "lyrics" && searchQuery) { + setLyricsQuery(searchQuery); + setShowFullResults(true); + } + }, [searchMode]); const commitMobileSearch = useCallback((value) => { setSearchQuery(value); - if (value) setShowFullResults(true); - }, [setSearchQuery, setShowFullResults]); + if (value) { + setShowFullResults(true); + } + // Always update lyrics query when in lyrics mode + if (searchMode === "lyrics") { + setLyricsQuery(value || ""); + } + }, [setSearchQuery, setShowFullResults, searchMode, setLyricsQuery]); const { inputValue: mobileInputValue, @@ -84,6 +114,11 @@ function HomeContent() { debounceMs: 250, }); + const onMobileSearchChange = useCallback((e) => { + handleMobileSearchChange(e); + setLyricsQuery(e.target.value || ""); + }, [handleMobileSearchChange, setLyricsQuery]); + const [selectedLetter, setSelectedLetter] = useState(null); const [isCreatingPlaylist, setIsCreatingPlaylist] = useState(false); const [newPlaylistName, setNewPlaylistName] = useState(""); @@ -144,6 +179,12 @@ function HomeContent() { setSelectedLetter(null); } } + + // Sync searchMode param + const smParam = searchParams.get("searchMode"); + if (smParam === "lyrics" && searchMode !== "lyrics") { + setSearchMode("lyrics"); + } }, [searchParams]); // Sync 2: context state changes -> browser URL parameters @@ -341,14 +382,38 @@ function HomeContent() { {/* Songs Section or Search Results */} {activeTab !== "categories" && ( searchQuery && showFullResults ? ( -
- +
+ {filteredSongs.length > 0 && ( + + )} + {lyricsResults.length > 0 && ( +
0 ? "border-t border-line/30 pt-6" : ""}`}> + +
+ )} + {filteredSongs.length === 0 && lyricsResults.length === 0 && !lyricsLoading && ( +
+ +

No matches found

+

+ We couldn't find any songs or lyrics matching “{searchQuery}”. +

+
+ )}
) : (

Search

-
- - setIsSearchFocused(true)} - onChange={(e) => { - handleMobileSearchChange(e); - }} - className="w-full h-11 pl-12 pr-10 text-sm bg-card-hover rounded-lg focus:outline-none focus:bg-line transition-all duration-150 text-title placeholder-muted border-none font-medium shadow-inner" - /> +
+
+ + setIsSearchFocused(true)} + onChange={(e) => { + onMobileSearchChange(e); + }} + className="w-full h-11 pl-12 pr-10 text-sm bg-card-hover rounded-lg focus:outline-none focus:bg-line transition-all duration-150 text-title placeholder-muted border-none font-medium shadow-inner" + /> +
+ + {/* Mobile Mic Button */} +
) : ( /* Focused / active search sticky layout */ -
+
{ - handleMobileSearchChange(e); + onMobileSearchChange(e); }} className="w-full h-11 pl-12 pr-10 text-sm bg-card-hover rounded-lg focus:outline-none transition-all duration-150 text-title placeholder-muted border-none font-medium" /> @@ -447,12 +523,22 @@ function HomeContent() { )}
+ + {/* Mobile Mic Button */} + + @@ -518,58 +604,86 @@ function HomeContent() { ) : ( /* Inline Search Results */ -
-
- Results - {mobileSearchResults.length} found -
- {mobileSearchResults.length === 0 ? ( +
+ {/* 1. Songs metadata matches */} + {mobileSearchResults.length > 0 && ( +
+
+ Songs + {mobileSearchResults.length} found +
+
+ {mobileSearchResults.map((song) => { + const isCurrent = currentSong?.id === song.id; + return ( +
{ + playSong(song); + router.push(`/song/${encodeURIComponent(song.slug || song.id)}`); + }} + className={`flex items-center gap-3 p-2 rounded-xl active:bg-card-hover transition-colors cursor-pointer ${ + isCurrent ? "bg-card-hover border border-line" : "" + }`} + > +
+ +
+
+ + {song.teluguTitle || song.title} + + + {song.titleEnglish} + +
+
+ {isCurrent && isPlaying ? ( +
+ + + +
+ ) : ( + song.duration + )} +
+
+ ); + })} +
+
+ )} + + {/* 2. Lyrics matches */} + {lyricsResults.length > 0 && ( +
+ +
+ )} + + {/* Loading state */} + {lyricsLoading && mobileSearchResults.length === 0 && lyricsResults.length === 0 && (
-

No matches found

-

Try another spelling or word

+

Searching...

- ) : ( -
- {mobileSearchResults.map((song) => { - const isCurrent = currentSong?.id === song.id; - return ( -
{ - playSong(song); - router.push(`/song/${encodeURIComponent(song.slug || song.id)}`); - }} - className={`flex items-center gap-3 p-2 rounded-xl active:bg-card-hover transition-colors cursor-pointer ${ - isCurrent ? "bg-card-hover border border-line" : "" - }`} - > -
- -
-
- - {song.teluguTitle || song.title} - - - {song.titleEnglish} - -
-
- {isCurrent && isPlaying ? ( -
- - - -
- ) : ( - song.duration - )} -
-
- ); - })} + )} + + {/* Empty state */} + {mobileSearchResults.length === 0 && lyricsResults.length === 0 && !lyricsLoading && ( +
+

No matches found

+

Try another spelling or phrase

)}
@@ -834,6 +948,7 @@ function HomeContent() {
)}
+
); } diff --git a/src/app/page.js b/src/app/page.js index eb173e5..414a029 100644 --- a/src/app/page.js +++ b/src/app/page.js @@ -1,7 +1,7 @@ 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"; +import SongsPrefetcher from "@/components/landing/SongsPrefetcher"; /** * Landing / Welcome screen — shown at the root URL (youworship.world). @@ -10,14 +10,34 @@ import { ArrowRight } from "lucide-react"; * subtitle, tagline and a glassmorphism "Explore Songs" CTA that * opens the application at /home. * + * The landing gate (src/proxy.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)) { @@ -29,6 +49,31 @@ export default async function LandingPage({ searchParams }) { return (
+ {/* Inline script: starts fetching songs BEFORE React hydrates. + By the time the user clicks "Explore Songs" the data is ready. */} +