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() {
-