Initial commit - Church Music Database

This commit is contained in:
2026-01-27 18:04:50 -06:00
commit d367261867
336 changed files with 103545 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes, viewport-fit=cover" />
<meta name="theme-color" content="#3b82f6" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta
name="description"
content="Worship Platform - Modern worship management for churches"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>Worship Platform</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

7824
new-site/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
{
"name": "worship-platform-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite --port 5100",
"build": "vite build",
"preview": "vite preview --port 5100",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@simplewebauthn/browser": "^10.0.0",
"@tiptap/extension-color": "^3.17.1",
"@tiptap/extension-highlight": "^3.17.1",
"@tiptap/extension-text-align": "^3.17.1",
"@tiptap/extension-text-style": "^3.17.1",
"@tiptap/extension-underline": "^3.17.1",
"@tiptap/react": "^3.17.1",
"@tiptap/starter-kit": "^3.17.1",
"axios": "^1.6.5",
"chordsheetjs": "^13.0.4",
"framer-motion": "^11.0.3",
"idb": "^8.0.0",
"lucide-react": "^0.312.0",
"mammoth": "^1.11.0",
"pdfjs-dist": "^5.4.530",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-router-dom": "^6.21.3",
"tonal": "^6.4.3",
"zustand": "^4.5.0"
},
"devDependencies": {
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"vite": "^5.0.12"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,50 @@
import { Routes, Route } from "react-router-dom";
import MainLayout from "@layouts/MainLayout";
import HomePage from "@pages/HomePage";
import DatabasePage from "@pages/DatabasePage";
import WorshipListsPage from "@pages/WorshipListsPage";
import SongViewPage from "@pages/SongViewPage";
import SongEditorPage from "@pages/SongEditorPage";
import ProfilesPage from "@pages/ProfilesPage";
import SettingsPage from "@pages/SettingsPage";
import AdminPage from "@pages/AdminPage";
import LoginPage from "@pages/LoginPage";
import ProtectedRoute from "@components/ProtectedRoute";
import { AuthProvider } from "@context/AuthContext";
import { ThemeProvider } from "@context/ThemeContext";
function App() {
return (
<AuthProvider>
<ThemeProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<ProtectedRoute>
<MainLayout />
</ProtectedRoute>
}
>
<Route index element={<HomePage />} />
<Route path="database" element={<DatabasePage />} />
<Route path="worship-lists" element={<WorshipListsPage />} />
<Route
path="worship-lists/:listId"
element={<WorshipListsPage />}
/>
<Route path="song/new" element={<SongEditorPage />} />
<Route path="song/edit/:id" element={<SongEditorPage />} />
<Route path="song/:songId" element={<SongViewPage />} />
<Route path="profiles" element={<ProfilesPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="admin" element={<AdminPage />} />
</Route>
</Routes>
</ThemeProvider>
</AuthProvider>
);
}
export default App;

View File

@@ -0,0 +1,33 @@
// Minimal test component
function TestApp() {
return (
<div
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #3b82f6, #8b5cf6)",
color: "white",
fontFamily: "Inter, sans-serif",
}}
>
<div
style={{
background: "rgba(255,255,255,0.2)",
padding: "40px 60px",
borderRadius: "20px",
backdropFilter: "blur(10px)",
textAlign: "center",
}}
>
<h1 style={{ fontSize: "2.5rem", marginBottom: "10px" }}>
🎵 Worship Platform
</h1>
<p style={{ opacity: 0.9 }}>React is working!</p>
</div>
</div>
);
}
export default TestApp;

View File

@@ -0,0 +1,298 @@
import React, { useEffect } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import { StarterKit } from "@tiptap/starter-kit";
import { Underline } from "@tiptap/extension-underline";
import { TextAlign } from "@tiptap/extension-text-align";
import { Highlight } from "@tiptap/extension-highlight";
import { Color } from "@tiptap/extension-color";
import { TextStyle } from "@tiptap/extension-text-style";
import { useTheme } from "@context/ThemeContext";
import {
Bold,
Italic,
Underline as UnderlineIcon,
AlignLeft,
AlignCenter,
AlignRight,
Highlighter,
Type,
Undo,
Redo,
List,
ListOrdered,
} from "lucide-react";
const LyricsRichTextEditor = ({ content, onChange, placeholder }) => {
const { isDark } = useTheme();
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: false, // Disable headings for lyrics
}),
Underline,
TextAlign.configure({
types: ["paragraph"],
}),
Highlight.configure({
multicolor: true,
}),
Color,
TextStyle,
],
content: content || "",
onUpdate: ({ editor }) => {
// Get text content only, not HTML
const text = editor.getText();
onChange(text);
},
editorProps: {
attributes: {
class: `prose prose-sm max-w-none focus:outline-none min-h-[400px] px-4 py-3 font-mono leading-relaxed whitespace-pre-wrap ${
isDark ? "prose-invert" : ""
}`,
},
// Handle paste to preserve plain text formatting
handlePaste: (view, event) => {
const text = event.clipboardData?.getData("text/plain");
if (text) {
// Insert as plain text, preserving line breaks and spacing
const { state } = view;
const { tr } = state;
tr.insertText(text);
view.dispatch(tr);
return true; // Prevent default paste
}
return false;
},
},
});
// Update content when prop changes
useEffect(() => {
if (editor && content !== editor.getText()) {
// Set content as plain text
editor.commands.setContent(
content ? `<p>${content.replace(/\n/g, "<br>")}</p>` : "",
);
}
}, [content, editor]);
if (!editor) {
return null;
}
const ToolbarButton = ({ onClick, active, disabled, children, title }) => (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
className={`p-2 rounded transition-all ${
active
? isDark
? "bg-cyan-500/30 text-cyan-400"
: "bg-cyan-100 text-cyan-700"
: isDark
? "bg-white/10 text-gray-300 hover:bg-white/20"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
>
{children}
</button>
);
const ColorButton = ({ color, label }) => (
<button
type="button"
onClick={() => editor.chain().focus().setColor(color).run()}
title={label}
className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 ${
editor.isActive("textStyle", { color })
? "border-white ring-2 ring-offset-1 ring-cyan-500"
: isDark
? "border-white/20"
: "border-gray-300"
}`}
style={{ backgroundColor: color }}
/>
);
return (
<div
className={`rounded-lg border overflow-hidden ${
isDark ? "border-white/10 bg-white/5" : "border-gray-200 bg-white"
}`}
>
{/* Toolbar */}
<div
className={`flex flex-wrap items-center gap-1 p-2 border-b ${
isDark ? "border-white/10 bg-white/5" : "border-gray-200 bg-gray-50"
}`}
>
{/* Text Formatting */}
<div className="flex items-center gap-1 mr-2">
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive("bold")}
title="Bold (Ctrl+B)"
>
<Bold size={16} />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive("italic")}
title="Italic (Ctrl+I)"
>
<Italic size={16} />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleUnderline().run()}
active={editor.isActive("underline")}
title="Underline (Ctrl+U)"
>
<UnderlineIcon size={16} />
</ToolbarButton>
</div>
{/* Separator */}
<div className={`w-px h-6 ${isDark ? "bg-white/20" : "bg-gray-300"}`} />
{/* Text Alignment */}
<div className="flex items-center gap-1 mx-2">
<ToolbarButton
onClick={() => editor.chain().focus().setTextAlign("left").run()}
active={editor.isActive({ textAlign: "left" })}
title="Align Left"
>
<AlignLeft size={16} />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setTextAlign("center").run()}
active={editor.isActive({ textAlign: "center" })}
title="Align Center"
>
<AlignCenter size={16} />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setTextAlign("right").run()}
active={editor.isActive({ textAlign: "right" })}
title="Align Right"
>
<AlignRight size={16} />
</ToolbarButton>
</div>
{/* Separator */}
<div className={`w-px h-6 ${isDark ? "bg-white/20" : "bg-gray-300"}`} />
{/* Lists */}
<div className="flex items-center gap-1 mx-2">
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive("bulletList")}
title="Bullet List"
>
<List size={16} />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive("orderedList")}
title="Numbered List"
>
<ListOrdered size={16} />
</ToolbarButton>
</div>
{/* Separator */}
<div className={`w-px h-6 ${isDark ? "bg-white/20" : "bg-gray-300"}`} />
{/* Highlight */}
<div className="flex items-center gap-1 mx-2">
<ToolbarButton
onClick={() =>
editor.chain().focus().toggleHighlight({ color: "#fef08a" }).run()
}
active={editor.isActive("highlight")}
title="Highlight"
>
<Highlighter size={16} />
</ToolbarButton>
</div>
{/* Separator */}
<div className={`w-px h-6 ${isDark ? "bg-white/20" : "bg-gray-300"}`} />
{/* Text Colors */}
<div className="flex items-center gap-1.5 mx-2">
<Type
size={14}
className={isDark ? "text-gray-400" : "text-gray-500"}
/>
<ColorButton color="#ef4444" label="Red" />
<ColorButton color="#f97316" label="Orange" />
<ColorButton color="#eab308" label="Yellow" />
<ColorButton color="#22c55e" label="Green" />
<ColorButton color="#3b82f6" label="Blue" />
<ColorButton color="#8b5cf6" label="Purple" />
<ColorButton color="#ec4899" label="Pink" />
<button
type="button"
onClick={() => editor.chain().focus().unsetColor().run()}
title="Remove Color"
className={`px-2 py-1 rounded text-xs ${
isDark
? "bg-white/10 text-gray-300 hover:bg-white/20"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
Reset
</button>
</div>
{/* Separator */}
<div className={`w-px h-6 ${isDark ? "bg-white/20" : "bg-gray-300"}`} />
{/* Undo/Redo */}
<div className="flex items-center gap-1 mx-2">
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Undo (Ctrl+Z)"
>
<Undo size={16} />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Redo (Ctrl+Y)"
>
<Redo size={16} />
</ToolbarButton>
</div>
</div>
{/* Editor Content */}
<EditorContent
editor={editor}
className={`${isDark ? "text-white" : "text-gray-900"}`}
/>
{/* Footer hint */}
<div
className={`px-4 py-2 text-xs border-t ${
isDark
? "border-white/10 bg-white/5 text-gray-500"
: "border-gray-200 bg-gray-50 text-gray-400"
}`}
>
💡 Use keyboard shortcuts: <strong>Ctrl+B</strong> Bold,{" "}
<strong>Ctrl+I</strong> Italic, <strong>Ctrl+U</strong> Underline,{" "}
<strong>Ctrl+Z</strong> Undo | 🎸 Paste lyrics with chords on line above
- they'll be auto-detected and positioned!
</div>
</div>
);
};
export default LyricsRichTextEditor;

View File

@@ -0,0 +1,25 @@
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "@context/AuthContext";
export default function ProtectedRoute({ children }) {
const { isAuthenticated, loading } = useAuth();
const location = useLocation();
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 border-4 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-white/60">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
// Redirect to login page, but save the location they were trying to access
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}

View File

@@ -0,0 +1,107 @@
import { motion } from "framer-motion";
import { useAuth } from "@context/AuthContext";
import { Check, Plus, ChevronLeft, ChevronRight } from "lucide-react";
import { useState, useRef } from "react";
function ProfileSelector() {
const { user, switchProfile } = useAuth();
const scrollRef = useRef(null);
const [profiles] = useState([
{ id: "1", name: "Pastor John", avatar: "👨‍💼", isActive: true },
{ id: "2", name: "Sarah", avatar: "👩‍🎤", isActive: false },
{ id: "3", name: "Mike", avatar: "🎛️", isActive: false },
{ id: "4", name: "Lisa", avatar: "🙋‍♀️", isActive: false },
{ id: "5", name: "David", avatar: "🎹", isActive: false },
]);
const [selectedId, setSelectedId] = useState("1");
const handleSelect = async (profileId) => {
setSelectedId(profileId);
// await switchProfile(profileId)
};
const scroll = (direction) => {
if (scrollRef.current) {
const scrollAmount = direction === "left" ? -150 : 150;
scrollRef.current.scrollBy({ left: scrollAmount, behavior: "smooth" });
}
};
return (
<div className="glass-card p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-600">Active Profile</h3>
<div className="flex gap-1">
<button
onClick={() => scroll("left")}
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
>
<ChevronLeft className="w-4 h-4 text-gray-500" />
</button>
<button
onClick={() => scroll("right")}
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
>
<ChevronRight className="w-4 h-4 text-gray-500" />
</button>
</div>
</div>
<div
ref={scrollRef}
className="flex gap-3 overflow-x-auto scrollbar-hide pb-1 -mx-1 px-1"
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
>
{profiles.map((profile) => (
<motion.button
key={profile.id}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => handleSelect(profile.id)}
className={`flex items-center gap-3 px-4 py-2.5 rounded-xl whitespace-nowrap transition-all flex-shrink-0 ${
selectedId === profile.id
? "bg-primary-100 border-2 border-primary-500"
: "bg-gray-50 border-2 border-transparent hover:bg-gray-100"
}`}
>
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center text-xl ${
selectedId === profile.id ? "bg-primary-200" : "bg-gray-200"
}`}
>
{profile.avatar}
</div>
<div className="text-left">
<p
className={`font-medium ${selectedId === profile.id ? "text-primary-700" : "text-gray-800"}`}
>
{profile.name}
</p>
{selectedId === profile.id && (
<span className="text-xs text-primary-600 flex items-center gap-1">
<Check className="w-3 h-3" />
Active
</span>
)}
</div>
</motion.button>
))}
{/* Add Profile Button */}
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl border-2 border-dashed border-gray-300 hover:border-primary-400 text-gray-500 hover:text-primary-600 transition-all flex-shrink-0"
>
<div className="w-10 h-10 rounded-xl bg-gray-100 flex items-center justify-center">
<Plus className="w-5 h-5" />
</div>
<span className="font-medium">Add</span>
</motion.button>
</div>
</div>
);
}
export default ProfileSelector;

View File

@@ -0,0 +1,255 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import {
Upload,
Plus,
FileText,
Music,
X,
Check,
AlertCircle,
} from "lucide-react";
import toast from "react-hot-toast";
function QuickActions() {
const navigate = useNavigate();
const [showUploadModal, setShowUploadModal] = useState(false);
const [dragActive, setDragActive] = useState(false);
const [uploading, setUploading] = useState(false);
const [uploadedFile, setUploadedFile] = useState(null);
const handleDrag = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFile(e.dataTransfer.files[0]);
}
};
const handleFileInput = (e) => {
if (e.target.files && e.target.files[0]) {
handleFile(e.target.files[0]);
}
};
const handleFile = async (file) => {
// Check file type
const validTypes = ["text/plain", "application/pdf", ".docx", ".doc"];
const isValid = validTypes.some(
(type) => file.type.includes(type) || file.name.endsWith(type),
);
if (
!isValid &&
!file.name.endsWith(".txt") &&
!file.name.endsWith(".pdf")
) {
toast.error("Please upload a .txt, .pdf, or .docx file");
return;
}
setUploadedFile(file);
setUploading(true);
// Simulate upload
await new Promise((r) => setTimeout(r, 1500));
setUploading(false);
toast.success("File uploaded successfully!");
};
const handleProcessFile = () => {
// Navigate to song editor with parsed content
setShowUploadModal(false);
setUploadedFile(null);
navigate("/song/new");
toast.success("Creating new song from lyrics...");
};
return (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Upload Lyrics Tile */}
<motion.div
whileHover={{ scale: 1.02, y: -4 }}
whileTap={{ scale: 0.98 }}
onClick={() => setShowUploadModal(true)}
className="glass-card p-6 cursor-pointer group overflow-hidden relative"
>
<div className="absolute inset-0 bg-gradient-to-br from-purple-500/10 to-pink-500/10 opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="relative">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-purple-400 to-pink-500 flex items-center justify-center mb-4 shadow-soft group-hover:shadow-lg transition-shadow">
<Upload className="w-7 h-7 text-white" />
</div>
<h3 className="text-lg font-semibold text-gray-800 mb-1">
Upload Lyrics
</h3>
<p className="text-gray-500 text-sm">
Import lyrics from .txt, .pdf, or .docx files
</p>
</div>
</motion.div>
{/* Create New Song Tile */}
<motion.div
whileHover={{ scale: 1.02, y: -4 }}
whileTap={{ scale: 0.98 }}
onClick={() => navigate("/song/new")}
className="glass-card p-6 cursor-pointer group overflow-hidden relative"
>
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/10 to-cyan-500/10 opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="relative">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-400 to-cyan-500 flex items-center justify-center mb-4 shadow-soft group-hover:shadow-lg transition-shadow">
<Plus className="w-7 h-7 text-white" />
</div>
<h3 className="text-lg font-semibold text-gray-800 mb-1">
Create New Song
</h3>
<p className="text-gray-500 text-sm">
Start from scratch with our chord editor
</p>
</div>
</motion.div>
</div>
{/* Upload Modal */}
<AnimatePresence>
{showUploadModal && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50"
onClick={() => {
setShowUploadModal(false);
setUploadedFile(null);
}}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-lg glass-card p-6 z-50"
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-gray-800">
Upload Lyrics
</h2>
<button
onClick={() => {
setShowUploadModal(false);
setUploadedFile(null);
}}
className="p-2 hover:bg-gray-100 rounded-xl transition-colors"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
{!uploadedFile ? (
<div
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
className={`border-2 border-dashed rounded-2xl p-8 text-center transition-colors ${
dragActive
? "border-primary-500 bg-primary-50"
: "border-gray-300 hover:border-gray-400"
}`}
>
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gray-100 flex items-center justify-center">
<FileText className="w-8 h-8 text-gray-400" />
</div>
<p className="text-gray-600 mb-2">
Drag and drop your file here, or{" "}
<label className="text-primary-600 font-medium cursor-pointer hover:underline">
browse
<input
type="file"
accept=".txt,.pdf,.doc,.docx"
onChange={handleFileInput}
className="hidden"
/>
</label>
</p>
<p className="text-sm text-gray-400">
Supports .txt, .pdf, .docx files
</p>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-xl">
<div className="w-12 h-12 rounded-xl bg-green-100 flex items-center justify-center">
{uploading ? (
<div className="w-5 h-5 border-2 border-green-600 border-t-transparent rounded-full animate-spin" />
) : (
<Check className="w-6 h-6 text-green-600" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-800 truncate">
{uploadedFile.name}
</p>
<p className="text-sm text-gray-500">
{uploading ? "Uploading..." : "Ready to process"}
</p>
</div>
{!uploading && (
<button
onClick={() => setUploadedFile(null)}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
>
<X className="w-4 h-4 text-gray-500" />
</button>
)}
</div>
<div className="flex items-start gap-3 p-3 bg-blue-50 rounded-xl">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-blue-700">
We'll try to detect sections (Verse, Chorus, etc.) and
existing chord notations automatically.
</p>
</div>
<div className="flex gap-3">
<button
onClick={() => setUploadedFile(null)}
className="btn-ghost flex-1"
>
Upload Different File
</button>
<button
onClick={handleProcessFile}
disabled={uploading}
className="btn-primary flex-1"
>
<Music className="w-4 h-4 mr-2" />
Create Song
</button>
</div>
</div>
)}
</motion.div>
</>
)}
</AnimatePresence>
</>
);
}
export default QuickActions;

View File

@@ -0,0 +1,292 @@
import { useState, useCallback, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import {
Search,
Music,
Plus,
ChevronRight,
Clock,
Star,
Globe,
X,
ExternalLink,
} from "lucide-react";
import debounce from "@utils/debounce";
import { useSongs } from "@hooks/useDataFetch";
function SongSearchPanel() {
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState("");
const [showOnlineModal, setShowOnlineModal] = useState(false);
const [onlineSearchQuery, setOnlineSearchQuery] = useState("");
// Use cached songs from global store
const { songs, loading } = useSongs();
// Get recent songs (most recently created)
const recentSongs = useMemo(() => {
return [...songs]
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
.slice(0, 5);
}, [songs]);
// Local search on cached songs (no API call)
const results = useMemo(() => {
if (!searchQuery.trim()) return [];
const query = searchQuery.toLowerCase();
return songs
.filter(
(song) =>
song.title?.toLowerCase().includes(query) ||
song.artist?.toLowerCase().includes(query) ||
song.singer?.toLowerCase().includes(query),
)
.slice(0, 20);
}, [searchQuery, songs]);
const isSearching = loading && searchQuery.trim().length > 0;
const handleSearch = (value) => {
setSearchQuery(value);
};
const handleSelectSong = (songId) => {
navigate(`/song/${songId}`);
};
const openOnlineSearch = () => {
setShowOnlineModal(true);
setOnlineSearchQuery(searchQuery);
};
const searchOnline = (service) => {
const query = encodeURIComponent(onlineSearchQuery || searchQuery);
const urls = {
ultimate: `https://www.ultimate-guitar.com/search.php?search_type=title&value=${query}`,
chordify: `https://chordify.net/search/${query}`,
google: `https://www.google.com/search?q=${query}+chords+lyrics`,
};
window.open(urls[service], "_blank");
};
return (
<div className="glass-card p-6 h-full">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Search className="w-5 h-5 text-primary-600" />
<h3 className="font-semibold text-gray-800">Search Songs</h3>
</div>
<button
onClick={() => navigate("/database")}
className="text-sm text-primary-600 hover:text-primary-700 font-medium flex items-center gap-1"
>
Browse All
<ChevronRight className="w-4 h-4" />
</button>
</div>
{/* Search Input */}
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search by title, artist, or key..."
className="input-glass pl-10"
/>
{isSearching && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="w-4 h-4 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
{/* Search Results */}
<AnimatePresence mode="wait">
{searchQuery && results.length > 0 ? (
<motion.div
key="results"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="space-y-2"
>
<p className="text-xs text-gray-500 mb-2">
{results.length} results found
</p>
{results.map((song) => (
<motion.div
key={song.id}
layout
onClick={() => handleSelectSong(song.id)}
className="flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 cursor-pointer transition-colors"
>
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center">
<Music className="w-5 h-5 text-white" />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-medium text-gray-800 truncate">
{song.title}
</h4>
<p className="text-sm text-gray-500 truncate">
{song.artist}
</p>
</div>
<span className="px-2 py-1 bg-amber-100 text-amber-700 text-xs font-bold rounded-lg">
{song.key_chord || song.key || "—"}
</span>
</motion.div>
))}
</motion.div>
) : searchQuery && !isSearching ? (
<motion.div
key="no-results"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="text-center py-6"
>
<p className="text-gray-500 mb-3">
No songs found for "{searchQuery}"
</p>
<div className="flex flex-col gap-2">
<button
onClick={openOnlineSearch}
className="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white font-medium rounded-lg flex items-center justify-center gap-2 transition-colors"
>
<Globe size={16} />
Search Online
</button>
<button
onClick={() => navigate("/song/new")}
className="px-4 py-2 text-primary-600 border border-primary-600 hover:bg-primary-50 font-medium rounded-lg flex items-center justify-center gap-2 transition-colors"
>
<Plus size={16} />
Create New Song
</button>
</div>
</motion.div>
) : (
<motion.div
key="recent"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{/* Recent Songs */}
<div className="flex items-center gap-2 mb-3">
<Clock className="w-4 h-4 text-gray-400" />
<span className="text-sm font-medium text-gray-600">
Recent Songs
</span>
</div>
<div className="space-y-2">
{recentSongs.map((song) => (
<div
key={song.id}
onClick={() => handleSelectSong(song.id)}
className="flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 cursor-pointer transition-colors"
>
<div className="w-10 h-10 rounded-xl bg-gray-100 flex items-center justify-center">
<Music className="w-5 h-5 text-gray-500" />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-medium text-gray-800 truncate">
{song.title}
</h4>
<p className="text-sm text-gray-500 truncate">
{song.artist}
</p>
</div>
<span className="px-2 py-1 bg-amber-100 text-amber-700 text-xs font-bold rounded-lg">
{song.key_chord || song.key || "—"}
</span>
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Online Search Modal */}
{showOnlineModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="max-w-md w-full bg-white rounded-2xl p-6 shadow-2xl"
>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-gray-900 flex items-center gap-2">
<Globe size={24} className="text-blue-500" />
Search Online
</h2>
<button
onClick={() => setShowOnlineModal(false)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X size={20} className="text-gray-500" />
</button>
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Song to search
</label>
<input
type="text"
value={onlineSearchQuery}
onChange={(e) => setOnlineSearchQuery(e.target.value)}
placeholder="Enter song title or artist..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
autoFocus
/>
</div>
<div className="space-y-3">
<p className="text-sm text-gray-600 mb-3">
Choose where to search:
</p>
<button
onClick={() => searchOnline("ultimate")}
className="w-full px-4 py-3 bg-gradient-to-r from-orange-500 to-red-500 hover:from-orange-600 hover:to-red-600 text-white font-medium rounded-lg flex items-center justify-between transition-all shadow-lg hover:shadow-xl"
>
<span>Ultimate Guitar</span>
<ExternalLink size={16} />
</button>
<button
onClick={() => searchOnline("chordify")}
className="w-full px-4 py-3 bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 text-white font-medium rounded-lg flex items-center justify-between transition-all shadow-lg hover:shadow-xl"
>
<span>Chordify</span>
<ExternalLink size={16} />
</button>
<button
onClick={() => searchOnline("google")}
className="w-full px-4 py-3 bg-gradient-to-r from-green-500 to-teal-500 hover:from-green-600 hover:to-teal-600 text-white font-medium rounded-lg flex items-center justify-between transition-all shadow-lg hover:shadow-xl"
>
<span>Google Search</span>
<ExternalLink size={16} />
</button>
</div>
<div className="mt-6 p-4 bg-blue-50 rounded-lg">
<p className="text-xs text-gray-700">
<strong>Tip:</strong> Copy the chords and lyrics from the
website, then paste them into a new song. Our system will
auto-detect the chords!
</p>
</div>
</motion.div>
</div>
)}
</div>
);
}
export default SongSearchPanel;

View File

@@ -0,0 +1,217 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import {
Plus,
List,
MoreVertical,
Play,
Copy,
Trash2,
Edit,
Calendar,
ChevronRight,
Music,
} from "lucide-react";
function WorshipListsPanel() {
const navigate = useNavigate();
const [lists, setLists] = useState([
{
id: "1",
name: "Sunday Morning",
date: "Jan 26, 2026",
songs: 5,
status: "upcoming",
},
{
id: "2",
name: "Wednesday Night",
date: "Jan 22, 2026",
songs: 4,
status: "past",
},
{
id: "3",
name: "Youth Service",
date: "Jan 24, 2026",
songs: 6,
status: "past",
},
]);
const [showMenu, setShowMenu] = useState(null);
const handleCreateList = () => {
navigate("/worship-lists");
};
const handleOpenList = (id) => {
navigate(`/worship-lists/${id}`);
};
const handleDuplicateList = (id) => {
const list = lists.find((l) => l.id === id);
if (list) {
const newList = {
...list,
id: Date.now().toString(),
name: `${list.name} (Copy)`,
};
setLists([...lists, newList]);
}
setShowMenu(null);
};
const handleDeleteList = (id) => {
setLists(lists.filter((l) => l.id !== id));
setShowMenu(null);
};
return (
<div className="glass-card p-6 h-full">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<List className="w-5 h-5 text-primary-600" />
<h3 className="font-semibold text-gray-800">Worship Lists</h3>
</div>
<button
onClick={handleCreateList}
className="p-2 hover:bg-primary-50 rounded-xl text-primary-600 transition-colors"
>
<Plus className="w-5 h-5" />
</button>
</div>
<div className="space-y-3">
<AnimatePresence>
{lists.map((list) => (
<motion.div
key={list.id}
layout
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
className="group relative"
>
<div
onClick={() => handleOpenList(list.id)}
className="flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 cursor-pointer transition-colors"
>
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center ${
list.status === "upcoming"
? "bg-primary-100"
: "bg-gray-100"
}`}
>
<Music
className={`w-5 h-5 ${
list.status === "upcoming"
? "text-primary-600"
: "text-gray-500"
}`}
/>
</div>
<div className="flex-1 min-w-0">
<h4 className="font-medium text-gray-800 truncate">
{list.name}
</h4>
<div className="flex items-center gap-2 text-sm text-gray-500">
<Calendar className="w-3.5 h-3.5" />
<span>{list.date}</span>
<span></span>
<span>{list.songs} songs</span>
</div>
</div>
{list.status === "upcoming" && (
<span className="px-2 py-1 bg-primary-100 text-primary-700 text-xs font-medium rounded-lg">
Upcoming
</span>
)}
<button
onClick={(e) => {
e.stopPropagation();
setShowMenu(showMenu === list.id ? null : list.id);
}}
className="p-1.5 hover:bg-gray-100 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity"
>
<MoreVertical className="w-4 h-4 text-gray-500" />
</button>
</div>
{/* Dropdown Menu */}
<AnimatePresence>
{showMenu === list.id && (
<motion.div
initial={{ opacity: 0, scale: 0.95, y: -10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -10 }}
className="absolute right-0 top-full mt-1 z-10 w-40 glass-card py-1 shadow-lg"
>
<button
onClick={(e) => {
e.stopPropagation();
handleOpenList(list.id);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-left text-gray-700 hover:bg-gray-50"
>
<Edit className="w-4 h-4" />
Edit
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDuplicateList(list.id);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-left text-gray-700 hover:bg-gray-50"
>
<Copy className="w-4 h-4" />
Duplicate
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteList(list.id);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-left text-red-600 hover:bg-red-50"
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</motion.div>
)}
</AnimatePresence>
</motion.div>
))}
</AnimatePresence>
{lists.length === 0 && (
<div className="text-center py-8">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
<List className="w-6 h-6 text-gray-400" />
</div>
<p className="text-gray-500 mb-3">No worship lists yet</p>
<button onClick={handleCreateList} className="btn-primary text-sm">
Create First List
</button>
</div>
)}
</div>
{lists.length > 0 && (
<button
onClick={() => navigate("/worship-lists")}
className="w-full mt-4 py-2 text-primary-600 text-sm font-medium hover:bg-primary-50 rounded-xl transition-colors flex items-center justify-center gap-1"
>
View All Lists
<ChevronRight className="w-4 h-4" />
</button>
)}
</div>
);
}
export default WorshipListsPanel;

View File

@@ -0,0 +1,139 @@
import { NavLink } from "react-router-dom";
import { motion } from "framer-motion";
import { Home, Database, ListMusic, Users, Menu } from "lucide-react";
import { useState } from "react";
const navItems = [
{ path: "/", label: "Home", icon: Home },
{ path: "/database", label: "Database", icon: Database },
{ path: "/worship-lists", label: "Lists", icon: ListMusic },
{ path: "/profiles", label: "Profiles", icon: Users },
];
function MobileNav() {
const [showMore, setShowMore] = useState(false);
return (
<>
{/* Bottom Navigation Bar */}
<nav className="fixed bottom-0 left-0 right-0 z-50 glass-nav bg-white/95 border-t border-gray-200 safe-area-pb">
<div className="flex items-center justify-around h-16 px-2">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) => `
flex flex-col items-center justify-center flex-1 py-2 rounded-xl
transition-all duration-200
${isActive ? "text-primary-600" : "text-gray-500"}
`}
>
{({ isActive }) => (
<>
<div
className={`relative p-1.5 rounded-xl transition-colors ${isActive ? "bg-primary-100" : ""}`}
>
<item.icon className="w-5 h-5" />
{isActive && (
<motion.div
layoutId="mobile-nav-indicator"
className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 bg-primary-500 rounded-full"
transition={{
type: "spring",
stiffness: 500,
damping: 30,
}}
/>
)}
</div>
<span className="text-[10px] font-medium mt-0.5">
{item.label}
</span>
</>
)}
</NavLink>
))}
{/* More Menu */}
<button
onClick={() => setShowMore(!showMore)}
className={`flex flex-col items-center justify-center flex-1 py-2 rounded-xl
transition-all duration-200
${showMore ? "text-primary-600" : "text-gray-500"}
`}
>
<div
className={`p-1.5 rounded-xl transition-colors ${showMore ? "bg-primary-100" : ""}`}
>
<Menu className="w-5 h-5" />
</div>
<span className="text-[10px] font-medium mt-0.5">More</span>
</button>
</div>
</nav>
{/* More Menu Overlay */}
{showMore && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40"
onClick={() => setShowMore(false)}
/>
<motion.div
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
className="fixed bottom-16 left-0 right-0 z-50 bg-white rounded-t-3xl shadow-lg safe-area-pb"
>
<div className="p-4 grid grid-cols-3 gap-4">
<NavLink
to="/settings"
onClick={() => setShowMore(false)}
className="flex flex-col items-center gap-2 p-4 rounded-2xl bg-gray-50 hover:bg-gray-100 transition-colors"
>
<div className="w-12 h-12 rounded-xl bg-primary-100 flex items-center justify-center">
<span className="text-xl"></span>
</div>
<span className="text-sm font-medium text-gray-700">
Settings
</span>
</NavLink>
<NavLink
to="/admin"
onClick={() => setShowMore(false)}
className="flex flex-col items-center gap-2 p-4 rounded-2xl bg-gray-50 hover:bg-gray-100 transition-colors"
>
<div className="w-12 h-12 rounded-xl bg-red-100 flex items-center justify-center">
<span className="text-xl">🛡</span>
</div>
<span className="text-sm font-medium text-gray-700">Admin</span>
</NavLink>
<button
onClick={() => setShowMore(false)}
className="flex flex-col items-center gap-2 p-4 rounded-2xl bg-gray-50 hover:bg-gray-100 transition-colors"
>
<div className="w-12 h-12 rounded-xl bg-green-100 flex items-center justify-center">
<span className="text-xl">📤</span>
</div>
<span className="text-sm font-medium text-gray-700">
Upload
</span>
</button>
</div>
{/* Handle */}
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-10 h-1 bg-gray-300 rounded-full" />
</motion.div>
</>
)}
</>
);
}
export default MobileNav;

View File

@@ -0,0 +1,228 @@
import { NavLink, useNavigate } from "react-router-dom";
import { motion } from "framer-motion";
import {
Home,
Database,
ListMusic,
Users,
Settings,
Shield,
User,
LogOut,
ChevronDown,
Wifi,
WifiOff,
} from "lucide-react";
import { useState, useRef, useEffect } from "react";
import { useAuth } from "@context/AuthContext";
const navItems = [
{ path: "/", label: "Home", icon: Home },
{ path: "/database", label: "Database", icon: Database },
{ path: "/worship-lists", label: "Worship Lists", icon: ListMusic },
{ path: "/profiles", label: "Profiles", icon: Users },
{ path: "/settings", label: "Settings", icon: Settings },
];
function Navbar() {
const { user, logout, isOnline, isAdmin } = useAuth();
const [showProfileMenu, setShowProfileMenu] = useState(false);
const profileMenuRef = useRef(null);
const navigate = useNavigate();
// Close menu when clicking outside
useEffect(() => {
function handleClickOutside(event) {
if (
profileMenuRef.current &&
!profileMenuRef.current.contains(event.target)
) {
setShowProfileMenu(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleLogout = async () => {
await logout();
navigate("/login");
};
return (
<header className="fixed top-0 left-0 right-0 z-50 glass-nav">
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-16">
{/* Left: Host/Server Name */}
<div className="flex items-center space-x-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center shadow-soft">
<ListMusic className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="font-semibold text-gray-800 text-sm">
Worship Platform
</h1>
<p className="text-xs text-gray-500">House of Praise</p>
</div>
</div>
{/* Center: Navigation */}
<nav className="hidden md:flex items-center space-x-1">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) => `
relative px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200
${
isActive
? "text-primary-600 bg-primary-50"
: "text-gray-600 hover:text-gray-900 hover:bg-gray-100"
}
`}
>
{({ isActive }) => (
<>
<span className="flex items-center gap-2">
<item.icon className="w-4 h-4" />
{item.label}
</span>
{isActive && (
<motion.div
layoutId="nav-indicator"
className="absolute bottom-0 left-2 right-2 h-0.5 bg-primary-500 rounded-full"
transition={{
type: "spring",
stiffness: 500,
damping: 30,
}}
/>
)}
</>
)}
</NavLink>
))}
{/* Admin link - only show for admins */}
{isAdmin && (
<NavLink
to="/admin"
className={({ isActive }) => `
relative px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200
${
isActive
? "text-primary-600 bg-primary-50"
: "text-gray-600 hover:text-gray-900 hover:bg-gray-100"
}
`}
>
<span className="flex items-center gap-2">
<Shield className="w-4 h-4" />
Admin
</span>
</NavLink>
)}
</nav>
{/* Right: Status & Profile */}
<div className="flex items-center space-x-4">
{/* Online Status */}
<div
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium
${
isOnline
? "bg-green-100 text-green-700"
: "bg-red-100 text-red-700"
}`}
>
{isOnline ? (
<>
<Wifi className="w-3.5 h-3.5" />
<span>Online</span>
</>
) : (
<>
<WifiOff className="w-3.5 h-3.5" />
<span>Offline</span>
</>
)}
</div>
{/* Profile Menu */}
<div className="relative" ref={profileMenuRef}>
<button
onClick={() => setShowProfileMenu(!showProfileMenu)}
className="flex items-center gap-2 px-2 py-1.5 rounded-xl hover:bg-gray-100 transition-colors"
>
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center text-white text-sm font-medium shadow-soft">
{user?.name?.charAt(0) || "U"}
</div>
<ChevronDown
className={`w-4 h-4 text-gray-500 transition-transform ${showProfileMenu ? "rotate-180" : ""}`}
/>
</button>
{/* Dropdown Menu */}
<AnimatePresence>
{showProfileMenu && (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.95 }}
transition={{ duration: 0.15 }}
className="absolute right-0 mt-2 w-56 glass-card py-2 shadow-lg"
>
<div className="px-4 py-3 border-b border-gray-100">
<p className="font-medium text-gray-800">
{user?.name || "Guest"}
</p>
<p className="text-sm text-gray-500">
{user?.email || "Not logged in"}
</p>
</div>
<div className="py-1">
<button
onClick={() => {
setShowProfileMenu(false);
navigate("/profiles");
}}
className="flex items-center gap-3 w-full px-4 py-2.5 text-left text-gray-700 hover:bg-gray-50 transition-colors"
>
<User className="w-4 h-4" />
<span>Switch Profile</span>
</button>
<button
onClick={() => {
setShowProfileMenu(false);
navigate("/settings");
}}
className="flex items-center gap-3 w-full px-4 py-2.5 text-left text-gray-700 hover:bg-gray-50 transition-colors"
>
<Settings className="w-4 h-4" />
<span>Settings</span>
</button>
</div>
<div className="border-t border-gray-100 pt-1">
<button
onClick={handleLogout}
className="flex items-center gap-3 w-full px-4 py-2.5 text-left text-red-600 hover:bg-red-50 transition-colors"
>
<LogOut className="w-4 h-4" />
<span>Sign Out</span>
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
</div>
</header>
);
}
export default Navbar;

View File

@@ -0,0 +1,135 @@
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
} from "react";
import api from "@utils/api";
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true); // Start as true to check auth
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
// Check for existing session on mount
const checkAuth = async () => {
const token = localStorage.getItem("authToken");
if (token) {
try {
const response = await api.get("/auth/me");
setUser(response.data.user);
} catch (error) {
console.error("Auth check failed:", error);
localStorage.removeItem("authToken");
}
}
setLoading(false); // Done checking
};
checkAuth();
// Online status listener
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);
const login = useCallback(async (username, password) => {
const response = await api.post("/auth/login", { username, password });
const { token, user: userData } = response.data;
localStorage.setItem("authToken", token);
setUser(userData);
return userData;
}, []);
const loginWithGoogle = useCallback(async (googleToken) => {
const response = await api.post("/auth/google", { token: googleToken });
const { token, user: userData } = response.data;
localStorage.setItem("authToken", token);
setUser(userData);
return userData;
}, []);
const loginWithBiometric = useCallback(async () => {
// WebAuthn authentication flow
const response = await api.post("/auth/webauthn/authenticate-options");
const options = response.data;
// Use SimpleWebAuthn browser
const { startAuthentication } = await import("@simplewebauthn/browser");
const authResponse = await startAuthentication(options);
const verifyResponse = await api.post(
"/auth/webauthn/authenticate",
authResponse,
);
const { token, user: userData } = verifyResponse.data;
localStorage.setItem("authToken", token);
setUser(userData);
return userData;
}, []);
const registerBiometric = useCallback(async () => {
const optionsResponse = await api.post("/auth/webauthn/register-options");
const options = optionsResponse.data;
const { startRegistration } = await import("@simplewebauthn/browser");
const regResponse = await startRegistration(options);
await api.post("/auth/webauthn/register", regResponse);
return true;
}, []);
const logout = useCallback(async () => {
try {
await api.post("/auth/logout");
} catch (error) {
console.error("Logout error:", error);
} finally {
localStorage.removeItem("authToken");
setUser(null);
}
}, []);
const switchProfile = useCallback(async (profileId) => {
const response = await api.post("/auth/switch-profile", { profileId });
setUser(response.data.user);
return response.data.user;
}, []);
const value = {
user,
loading,
isOnline,
login,
loginWithGoogle,
loginWithBiometric,
registerBiometric,
logout,
switchProfile,
isAuthenticated: !!user,
isAdmin: user?.role === "admin",
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
export default AuthContext;

View File

@@ -0,0 +1,52 @@
import { createContext, useContext, useState, useEffect } from "react";
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() => {
const saved = localStorage.getItem("theme");
return saved || "dark"; // Default to dark theme
});
const [accentColor, setAccentColor] = useState(() => {
const saved = localStorage.getItem("accentColor");
return saved || "blue";
});
useEffect(() => {
localStorage.setItem("theme", theme);
document.documentElement.classList.toggle("dark", theme === "dark");
}, [theme]);
useEffect(() => {
localStorage.setItem("accentColor", accentColor);
document.documentElement.setAttribute("data-accent", accentColor);
}, [accentColor]);
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
const value = {
theme,
setTheme,
toggleTheme,
accentColor,
setAccentColor,
isDark: theme === "dark",
};
return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}
export default ThemeContext;

View File

@@ -0,0 +1,143 @@
/**
* Custom hooks for accessing cached data from the store
* These hooks provide a clean interface for components to fetch and use data
*/
import { useEffect } from "react";
import useDataStore from "../stores/dataStore";
/**
* Hook to get all songs with caching
* @returns {Object} { songs, loading, error, refetch }
*/
export function useSongs() {
const songs = useDataStore((state) => state.songs);
const loading = useDataStore((state) => state.songsLoading);
const error = useDataStore((state) => state.songsError);
const fetchSongs = useDataStore((state) => state.fetchSongs);
useEffect(() => {
fetchSongs();
}, [fetchSongs]);
return {
songs,
loading,
error,
refetch: () => fetchSongs(true),
};
}
/**
* Hook to get a single song by ID
* @param {number|string} id - Song ID
* @returns {Object} { song, loading, error }
*/
export function useSong(id) {
const songDetails = useDataStore((state) => state.songDetails);
const loading = useDataStore((state) => state.songDetailsLoading);
const error = useDataStore((state) => state.songDetailsError);
const fetchSongDetail = useDataStore((state) => state.fetchSongDetail);
useEffect(() => {
if (id) {
fetchSongDetail(id);
}
}, [id, fetchSongDetail]);
return {
song: songDetails[id] || null,
loading,
error,
};
}
/**
* Hook to get all worship lists with caching
* @returns {Object} { lists, loading, error, refetch }
*/
export function useLists() {
const lists = useDataStore((state) => state.lists);
const loading = useDataStore((state) => state.listsLoading);
const error = useDataStore((state) => state.listsError);
const fetchLists = useDataStore((state) => state.fetchLists);
useEffect(() => {
fetchLists();
}, [fetchLists]);
return {
lists,
loading,
error,
refetch: () => fetchLists(true),
};
}
/**
* Hook to get all profiles with caching
* @returns {Object} { profiles, loading, error, refetch }
*/
export function useProfiles() {
const profiles = useDataStore((state) => state.profiles);
const loading = useDataStore((state) => state.profilesLoading);
const error = useDataStore((state) => state.profilesError);
const fetchProfiles = useDataStore((state) => state.fetchProfiles);
useEffect(() => {
fetchProfiles();
}, [fetchProfiles]);
return {
profiles,
loading,
error,
refetch: () => fetchProfiles(true),
};
}
/**
* Hook to get dashboard stats with caching
* @returns {Object} { stats, loading, error, refetch }
*/
export function useStats() {
const stats = useDataStore((state) => state.stats);
const loading = useDataStore((state) => state.statsLoading);
const error = useDataStore((state) => state.statsError);
const fetchStats = useDataStore((state) => state.fetchStats);
useEffect(() => {
fetchStats();
}, [fetchStats]);
return {
stats,
loading,
error,
refetch: () => fetchStats(true),
};
}
/**
* Hook to access invalidation functions
* Use these after mutations to refresh data
* @returns {Object} { invalidateSongs, invalidateLists, invalidateProfiles, invalidateStats, invalidateAll }
*/
export function useInvalidate() {
const invalidateSongs = useDataStore((state) => state.invalidateSongs);
const invalidateLists = useDataStore((state) => state.invalidateLists);
const invalidateProfiles = useDataStore((state) => state.invalidateProfiles);
const invalidateStats = useDataStore((state) => state.invalidateStats);
const invalidateAll = useDataStore((state) => state.invalidateAll);
return {
invalidateSongs,
invalidateLists,
invalidateProfiles,
invalidateStats,
invalidateAll,
};
}
// Export the store itself for direct access when needed
export { useDataStore };

View File

@@ -0,0 +1,259 @@
/**
* Custom hooks for data fetching with caching
*
* These hooks provide a simple interface to fetch data with automatic caching.
* They follow the same pattern as React Query / SWR for familiarity.
*/
import { useEffect, useCallback } from "react";
import useDataStore from "@stores/dataStore";
/**
* Hook to fetch and use songs data
* @param {Object} options - { enabled: true, refetchOnMount: false }
* @returns {{ songs: Array, loading: boolean, error: string|null, refetch: Function }}
*/
export function useSongs(options = {}) {
const { enabled = true, refetchOnMount = false } = options;
const songs = useDataStore((state) => state.songs);
const loading = useDataStore((state) => state.songsLoading);
const error = useDataStore((state) => state.songsError);
const fetchSongs = useDataStore((state) => state.fetchSongs);
useEffect(() => {
if (enabled) {
fetchSongs(refetchOnMount);
}
}, [enabled, refetchOnMount, fetchSongs]);
const refetch = useCallback(() => fetchSongs(true), [fetchSongs]);
return { songs, loading, error, refetch };
}
/**
* Hook to fetch and use a single song
* @param {string} id - Song ID
* @param {Object} options - { enabled: true }
* @returns {{ song: Object|null, loading: boolean, refetch: Function }}
*/
export function useSong(id, options = {}) {
const { enabled = true } = options;
const songDetails = useDataStore((state) => state.songDetails);
const loadingMap = useDataStore((state) => state.songDetailsLoading);
const fetchSongDetail = useDataStore((state) => state.fetchSongDetail);
const song = songDetails[id]?.data || null;
const loading = loadingMap[id] || false;
useEffect(() => {
if (enabled && id) {
fetchSongDetail(id);
}
}, [enabled, id, fetchSongDetail]);
const refetch = useCallback(
() => fetchSongDetail(id, true),
[fetchSongDetail, id],
);
return { song, loading, refetch };
}
/**
* Hook to fetch and use worship lists
* @param {Object} options - { enabled: true, refetchOnMount: false }
* @returns {{ lists: Array, loading: boolean, error: string|null, refetch: Function }}
*/
export function useLists(options = {}) {
const { enabled = true, refetchOnMount = false } = options;
const lists = useDataStore((state) => state.lists);
const loading = useDataStore((state) => state.listsLoading);
const error = useDataStore((state) => state.listsError);
const fetchLists = useDataStore((state) => state.fetchLists);
useEffect(() => {
if (enabled) {
fetchLists(refetchOnMount);
}
}, [enabled, refetchOnMount, fetchLists]);
const refetch = useCallback(() => fetchLists(true), [fetchLists]);
return { lists, loading, error, refetch };
}
/**
* Hook to fetch and use songs in a specific list
* @param {string} listId - List ID
* @param {Object} options - { enabled: true }
* @returns {{ songs: Array, loading: boolean, refetch: Function }}
*/
export function useListSongs(listId, options = {}) {
const { enabled = true } = options;
const listDetails = useDataStore((state) => state.listDetails);
const loadingMap = useDataStore((state) => state.listDetailsLoading);
const fetchListDetail = useDataStore((state) => state.fetchListDetail);
const songs = listDetails[listId]?.songs || [];
const loading = loadingMap[listId] || false;
useEffect(() => {
if (enabled && listId) {
fetchListDetail(listId);
}
}, [enabled, listId, fetchListDetail]);
const refetch = useCallback(
() => fetchListDetail(listId, true),
[fetchListDetail, listId],
);
return { songs, loading, refetch };
}
/**
* Hook to fetch and use profiles
* @param {Object} options - { enabled: true, refetchOnMount: false }
* @returns {{ profiles: Array, loading: boolean, error: string|null, refetch: Function }}
*/
export function useProfiles(options = {}) {
const { enabled = true, refetchOnMount = false } = options;
const profiles = useDataStore((state) => state.profiles);
const loading = useDataStore((state) => state.profilesLoading);
const error = useDataStore((state) => state.profilesError);
const fetchProfiles = useDataStore((state) => state.fetchProfiles);
useEffect(() => {
if (enabled) {
fetchProfiles(refetchOnMount);
}
}, [enabled, refetchOnMount, fetchProfiles]);
const refetch = useCallback(() => fetchProfiles(true), [fetchProfiles]);
return { profiles, loading, error, refetch };
}
/**
* Hook to fetch and use stats
* @param {Object} options - { enabled: true, refetchOnMount: false }
* @returns {{ stats: Object, loading: boolean, refetch: Function }}
*/
export function useStats(options = {}) {
const { enabled = true, refetchOnMount = false } = options;
const stats = useDataStore((state) => state.stats);
const loading = useDataStore((state) => state.statsLoading);
const fetchStats = useDataStore((state) => state.fetchStats);
useEffect(() => {
if (enabled) {
fetchStats(refetchOnMount);
}
}, [enabled, refetchOnMount, fetchStats]);
const refetch = useCallback(() => fetchStats(true), [fetchStats]);
return { stats, loading, refetch };
}
/**
* Hook for searching songs (with debouncing built-in)
* @param {string} query - Search query
* @param {Object} options - { debounceMs: 300 }
* @returns {{ results: Array, loading: boolean }}
*/
export function useSearch(query, options = {}) {
const { debounceMs = 300 } = options;
const searchSongs = useDataStore((state) => state.searchSongs);
const searchCache = useDataStore((state) => state.searchCache);
const loading = useDataStore((state) => state.searchLoading);
const trimmedQuery = query?.trim().toLowerCase() || "";
const results = searchCache[trimmedQuery]?.results || [];
useEffect(() => {
if (!trimmedQuery) return;
const timer = setTimeout(() => {
searchSongs(trimmedQuery);
}, debounceMs);
return () => clearTimeout(timer);
}, [trimmedQuery, debounceMs, searchSongs]);
return { results, loading };
}
/**
* Hook to get cache mutation functions
* @returns Object with cache mutation functions
*/
export function useDataMutations() {
const updateSongInCache = useDataStore((state) => state.updateSongInCache);
const addSongToCache = useDataStore((state) => state.addSongToCache);
const removeSongFromCache = useDataStore(
(state) => state.removeSongFromCache,
);
const invalidateSongs = useDataStore((state) => state.invalidateSongs);
const invalidateSongDetail = useDataStore(
(state) => state.invalidateSongDetail,
);
const updateListInCache = useDataStore((state) => state.updateListInCache);
const addListToCache = useDataStore((state) => state.addListToCache);
const removeListFromCache = useDataStore(
(state) => state.removeListFromCache,
);
const invalidateLists = useDataStore((state) => state.invalidateLists);
const invalidateListDetail = useDataStore(
(state) => state.invalidateListDetail,
);
const invalidateProfiles = useDataStore((state) => state.invalidateProfiles);
const invalidateAll = useDataStore((state) => state.invalidateAll);
return {
// Songs
updateSongInCache,
addSongToCache,
removeSongFromCache,
invalidateSongs,
invalidateSongDetail,
// Lists
updateListInCache,
addListToCache,
removeListFromCache,
invalidateLists,
invalidateListDetail,
// General
invalidateProfiles,
invalidateAll,
};
}
/**
* Hook to prefetch data on app mount
*/
export function usePrefetch() {
const prefetch = useDataStore((state) => state.prefetch);
useEffect(() => {
prefetch();
}, [prefetch]);
}
/**
* Hook to get cache status (for debugging)
*/
export function useCacheStatus() {
const getCacheStatus = useDataStore((state) => state.getCacheStatus);
return getCacheStatus();
}

View File

@@ -0,0 +1,43 @@
import { useState, useEffect, useCallback } from "react";
export function useLocalStorage(key, initialValue) {
// Get stored value or use initial value
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
// Silent fail - return initial value
return initialValue;
}
});
// Update localStorage when value changes
const setValue = useCallback(
(value) => {
try {
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
// Silent fail - localStorage might be full or disabled
}
},
[key, storedValue],
);
// Remove from localStorage
const removeValue = useCallback(() => {
try {
window.localStorage.removeItem(key);
setStoredValue(initialValue);
} catch (error) {
// Silent fail
}
}, [key, initialValue]);
return [storedValue, setValue, removeValue];
}
export default useLocalStorage;

View File

@@ -0,0 +1,64 @@
import { useState, useEffect } from "react";
export function useMediaQuery(query) {
const [matches, setMatches] = useState(() => {
if (typeof window !== "undefined") {
return window.matchMedia(query).matches;
}
return false;
});
useEffect(() => {
const mediaQuery = window.matchMedia(query);
const handleChange = (event) => {
setMatches(event.matches);
};
// Add listener
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener("change", handleChange);
} else {
// Fallback for older browsers
mediaQuery.addListener(handleChange);
}
// Set initial value
setMatches(mediaQuery.matches);
// Cleanup
return () => {
if (mediaQuery.removeEventListener) {
mediaQuery.removeEventListener("change", handleChange);
} else {
mediaQuery.removeListener(handleChange);
}
};
}, [query]);
return matches;
}
// Predefined breakpoints
export function useIsMobile() {
return useMediaQuery("(max-width: 767px)");
}
export function useIsTablet() {
return useMediaQuery("(min-width: 768px) and (max-width: 1023px)");
}
export function useIsDesktop() {
return useMediaQuery("(min-width: 1024px)");
}
export function useBreakpoint() {
const isMobile = useMediaQuery("(max-width: 767px)");
const isTablet = useMediaQuery("(min-width: 768px) and (max-width: 1023px)");
if (isMobile) return "mobile";
if (isTablet) return "tablet";
return "desktop";
}
export default useMediaQuery;

View File

@@ -0,0 +1,193 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Base Styles */
:root {
--color-primary: #3b82f6;
--color-primary-dark: #2563eb;
--color-glass-white: rgba(255, 255, 255, 0.1);
--color-glass-dark: rgba(0, 0, 0, 0.1);
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background 0.3s ease;
/* Mobile optimization */
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
overscroll-behavior-y: none;
}
/* Light mode */
html:not(.dark) body {
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 50%, #f1f5f9 100%);
}
/* Dark mode */
html.dark body {
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
transition: background 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
/* Glassmorphism Utilities */
@layer utilities {
.glass {
@apply bg-white/10 backdrop-blur-md border border-white/20 shadow-glass;
}
.glass-dark {
@apply bg-black/10 backdrop-blur-md border border-black/10 shadow-glass;
}
.glass-card {
@apply bg-white/90 backdrop-blur-lg border border-white/50 shadow-soft rounded-2xl;
}
.glass-nav {
@apply bg-white/80 backdrop-blur-xl border-b border-white/30 shadow-soft;
}
.btn-primary {
@apply px-6 py-2.5 bg-primary-600 text-white rounded-xl font-medium
hover:bg-primary-700 active:scale-[0.98] transition-all duration-200
shadow-soft hover:shadow-soft-lg;
}
.btn-secondary {
@apply px-6 py-2.5 bg-white/20 backdrop-blur-sm text-white rounded-xl font-medium
border border-white/30 hover:bg-white/30 active:scale-[0.98]
transition-all duration-200;
}
.btn-ghost {
@apply px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-xl font-medium
transition-all duration-200;
}
.input-glass {
@apply w-full px-4 py-3 bg-white/80 backdrop-blur-sm border border-gray-200
rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500/50
focus:border-primary-500 transition-all duration-200 placeholder:text-gray-400;
}
.card-hover {
@apply transition-all duration-300 hover:shadow-soft-lg hover:-translate-y-1;
}
}
/* Page Transitions */
.page-enter {
opacity: 0;
transform: translateY(10px);
}
.page-enter-active {
opacity: 1;
transform: translateY(0);
transition: opacity 0.3s ease-out, transform 0.3s ease-out;
}
.page-exit {
opacity: 1;
transform: translateY(0);
}
.page-exit-active {
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.2s ease-in, transform 0.2s ease-in;
}
/* Selection */
::selection {
background-color: rgba(59, 130, 246, 0.3);
color: inherit;
}
/* Focus Visible */
:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
/* Prevent text selection on drag */
.no-select {
user-select: none;
-webkit-user-select: none;
}
/* Mobile Touch Optimization */
@media (max-width: 768px) {
/* Larger touch targets */
button:not(.no-touch-target),
a:not(.no-touch-target),
input[type="checkbox"],
input[type="radio"] {
min-height: 44px;
}
/* Better spacing for mobile */
body {
font-size: 16px; /* Prevents iOS zoom on focus */
}
input, textarea, select {
font-size: 16px; /* Prevents iOS zoom */
}
/* Smooth scrolling on mobile */
* {
-webkit-overflow-scrolling: touch;
}
}
/* iPhone X and later (notch support) */
@supports (padding: max(0px)) {
body {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
}
/* Tablet and Desktop refinements */
@media (min-width: 768px) and (max-width: 1024px) {
/* iPad specific adjustments */
.container {
padding-left: 2rem;
padding-right: 2rem;
}
}

View File

@@ -0,0 +1,326 @@
import { useState } from "react";
import { Link, useLocation, Outlet } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import {
Home,
Music,
ListMusic,
Users,
Settings,
Menu,
X,
ChevronRight,
Wifi,
WifiOff,
LogOut,
User,
Sun,
Moon,
Shield,
} from "lucide-react";
import { useAuth } from "@context/AuthContext";
import { useTheme } from "@context/ThemeContext";
const navLinks = [
{ path: "/", label: "Home", icon: Home },
{ path: "/database", label: "Songs", icon: Music },
{ path: "/worship-lists", label: "Lists", icon: ListMusic },
{ path: "/profiles", label: "Profiles", icon: Users },
{ path: "/admin", label: "Admin", icon: Shield },
{ path: "/settings", label: "Settings", icon: Settings },
];
export default function MainLayout() {
const location = useLocation();
const { user, logout } = useAuth();
const { theme, toggleTheme, isDark } = useTheme();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isOnline] = useState(navigator.onLine);
const isActive = (path) => {
if (path === "/") return location.pathname === "/";
return location.pathname.startsWith(path);
};
// Theme-aware classes
const textPrimary = isDark ? "text-white" : "text-gray-900";
const textSecondary = isDark ? "text-white/60" : "text-gray-600";
const textMuted = isDark ? "text-white/50" : "text-gray-500";
return (
<div
className={`min-h-screen transition-colors duration-300 ${
isDark
? "bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900"
: "bg-gradient-to-br from-gray-50 via-white to-gray-100"
}`}
>
{/* Navbar */}
<nav
className={`sticky top-0 z-40 backdrop-blur-xl border-b transition-colors duration-300 ${
isDark
? "bg-slate-900/80 border-white/10"
: "bg-white/80 border-gray-200"
}`}
role="navigation"
aria-label="Main navigation"
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link to="/" className="flex items-center gap-3 group">
<motion.div
className="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-500 to-purple-600
flex items-center justify-center shadow-lg shadow-violet-500/25"
whileHover={{ scale: 1.05, rotate: 5 }}
whileTap={{ scale: 0.95 }}
>
<Music className="text-white" size={22} />
</motion.div>
<div className="hidden sm:block">
<h1
className={`text-lg font-bold group-hover:text-violet-500 transition-colors ${textPrimary}`}
>
HOP Worship
</h1>
<p className={`text-xs -mt-0.5 ${textMuted}`}>Song Manager</p>
</div>
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-1">
{navLinks.map(({ path, label, icon: Icon }) => (
<Link
key={path}
to={path}
className={`relative px-4 py-2 rounded-lg flex items-center gap-2
transition-all duration-300 group
${
isActive(path)
? textPrimary
: `${textSecondary} ${isDark ? "hover:text-white hover:bg-white/5" : "hover:text-gray-900 hover:bg-gray-100"}`
}`}
>
<Icon
size={18}
className={
isActive(path)
? "text-violet-500"
: "group-hover:text-violet-500"
}
/>
<span className="text-sm font-medium">{label}</span>
{isActive(path) && (
<motion.div
layoutId="navbar-indicator"
className={`absolute inset-0 rounded-lg -z-10 ${isDark ? "bg-white/10" : "bg-violet-100"}`}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
/>
)}
</Link>
))}
</div>
{/* Right Side */}
<div className="flex items-center gap-3">
{/* Theme Toggle */}
<button
onClick={toggleTheme}
className={`p-2 rounded-lg transition-colors ${
isDark
? "text-white/60 hover:text-white hover:bg-white/10"
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
}`}
aria-label={
isDark ? "Switch to Light Mode" : "Switch to Dark Mode"
}
title={isDark ? "Switch to Light Mode" : "Switch to Dark Mode"}
>
{isDark ? (
<Sun size={20} aria-hidden="true" />
) : (
<Moon size={20} aria-hidden="true" />
)}
</button>
{/* Online Status */}
<div
className={`hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-full text-xs
${isOnline ? "bg-emerald-500/20 text-emerald-500" : "bg-red-500/20 text-red-500"}`}
role="status"
aria-label={isOnline ? "Online" : "Offline"}
>
{isOnline ? (
<Wifi size={14} aria-hidden="true" />
) : (
<WifiOff size={14} aria-hidden="true" />
)}
<span>{isOnline ? "Online" : "Offline"}</span>
</div>
{/* User Menu */}
{user && (
<div className="hidden sm:flex items-center gap-2">
<div
className="w-8 h-8 rounded-full bg-gradient-to-br from-violet-500 to-purple-600
flex items-center justify-center text-white text-sm font-medium"
aria-label={`Logged in as ${user.name || user.username}`}
>
{user.name?.[0] || user.username?.[0] || "U"}
</div>
<button
onClick={logout}
className={`p-2 rounded-lg transition-colors ${
isDark
? "text-white/50 hover:text-white hover:bg-white/10"
: "text-gray-400 hover:text-gray-700 hover:bg-gray-100"
}`}
aria-label="Logout"
title="Logout"
>
<LogOut size={18} aria-hidden="true" />
</button>
</div>
)}
{/* Mobile Menu Button */}
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className={`md:hidden p-2 rounded-lg transition-colors ${
isDark
? "text-white/70 hover:text-white hover:bg-white/10"
: "text-gray-600 hover:text-gray-900 hover:bg-gray-100"
}`}
aria-label={mobileMenuOpen ? "Close menu" : "Open menu"}
aria-expanded={mobileMenuOpen}
aria-controls="mobile-menu"
>
{mobileMenuOpen ? (
<X size={24} aria-hidden="true" />
) : (
<Menu size={24} aria-hidden="true" />
)}
</button>
</div>
</div>
</div>
{/* Mobile Menu */}
<AnimatePresence>
{mobileMenuOpen && (
<motion.div
id="mobile-menu"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className={`md:hidden border-t overflow-hidden ${isDark ? "border-white/10" : "border-gray-200"}`}
role="menu"
aria-label="Mobile navigation menu"
>
<div className="p-4 space-y-1">
{navLinks.map(({ path, label, icon: Icon }, index) => (
<motion.div
key={path}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
>
<Link
to={path}
onClick={() => setMobileMenuOpen(false)}
className={`flex items-center justify-between p-3 rounded-xl transition-all
${
isActive(path)
? isDark
? "bg-violet-500/20 text-white border border-violet-500/30"
: "bg-violet-100 text-violet-900 border border-violet-200"
: isDark
? "text-white/60 hover:text-white hover:bg-white/5"
: "text-gray-600 hover:text-gray-900 hover:bg-gray-100"
}`}
>
<div className="flex items-center gap-3">
<Icon
size={20}
className={isActive(path) ? "text-violet-500" : ""}
/>
<span className="font-medium">{label}</span>
</div>
<ChevronRight
size={18}
className={isDark ? "text-white/30" : "text-gray-400"}
/>
</Link>
</motion.div>
))}
{/* Mobile User Section */}
{user && (
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: navLinks.length * 0.05 }}
className={`mt-4 pt-4 border-t ${isDark ? "border-white/10" : "border-gray-200"}`}
>
<div className="flex items-center justify-between p-3">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-full bg-gradient-to-br from-violet-500 to-purple-600
flex items-center justify-center text-white font-medium"
>
{user.name?.[0] || user.username?.[0] || "U"}
</div>
<div>
<p className={`font-medium ${textPrimary}`}>
{user.name || user.username}
</p>
<p className={`text-sm ${textMuted}`}>
{user.role || "User"}
</p>
</div>
</div>
<button
onClick={logout}
className={`p-2 rounded-lg transition-colors ${
isDark
? "text-red-400 hover:bg-red-500/10"
: "text-red-500 hover:bg-red-50"
}`}
>
<LogOut size={20} />
</button>
</div>
</motion.div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</nav>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
<Outlet />
</motion.div>
</AnimatePresence>
</main>
{/* Footer */}
<footer className={`mt-auto py-6 text-center text-sm ${textMuted}`}>
<p>© {new Date().getFullYear()} House of Prayer Worship Ministry</p>
</footer>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { Toaster } from "react-hot-toast";
import App from "./App.jsx";
import TestApp from "./TestApp.jsx";
import "./index.css";
// Use TestApp temporarily to verify React works
const USE_TEST = false;
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
{USE_TEST ? (
<TestApp />
) : (
<BrowserRouter>
<App />
<Toaster
position="top-right"
toastOptions={{
duration: 3000,
style: {
background: "rgba(255, 255, 255, 0.95)",
backdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.2)",
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.1)",
},
}}
/>
</BrowserRouter>
)}
</React.StrictMode>,
);

View File

@@ -0,0 +1,765 @@
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import {
Music,
Users,
ListMusic,
Download,
Upload,
Settings,
UserPlus,
Trash2,
Edit,
X,
Check,
AlertCircle,
Fingerprint,
Eye,
EyeOff,
} from "lucide-react";
import api from "@utils/api";
import { useTheme } from "@context/ThemeContext";
import { useStats, useDataMutations } from "@hooks/useDataFetch";
import {
isBiometricAvailable,
registerBiometric,
storeBiometricCredential,
} from "@utils/biometric";
import toast from "react-hot-toast";
export default function AdminPage() {
const { isDark } = useTheme();
const navigate = useNavigate();
const fileInputRef = useRef(null);
// Use cached stats from global store
const { stats, refetch: refetchStats } = useStats();
const { invalidateAll } = useDataMutations();
const [users, setUsers] = useState([]);
const [showUserModal, setShowUserModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [notification, setNotification] = useState(null);
const [isExporting, setIsExporting] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [userForm, setUserForm] = useState({
username: "",
password: "",
role: "user",
biometric_enabled: false,
});
// Theme-aware classes
const textPrimary = isDark ? "text-white" : "text-gray-900";
const textSecondary = isDark ? "text-white/70" : "text-gray-600";
const textMuted = isDark ? "text-white/50" : "text-gray-500";
const bgCard = isDark
? "bg-white/10 border-white/20"
: "bg-white border-gray-200 shadow-sm";
const inputBg = isDark
? "bg-white/10 border-white/20 text-white placeholder-white/50"
: "bg-white border-gray-300 text-gray-900 placeholder-gray-400";
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
try {
const response = await api.get("/admin/users");
if (response.data.success) {
setUsers(response.data.users);
}
} catch (err) {
console.error("Failed to fetch users:", err);
}
};
const showNotification = (message, type = "success") => {
setNotification({ message, type });
setTimeout(() => setNotification(null), 4000);
};
// Export functions
const handleExport = async (type) => {
setIsExporting(true);
try {
const response = await api.get(`/admin/export/${type}`, {
responseType: "blob",
});
const blob = new Blob([response.data], { type: "application/json" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${type}-export-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
showNotification(`Successfully exported ${type}`, "success");
} catch (err) {
console.error("Export error:", err);
showNotification(`Failed to export ${type}`, "error");
} finally {
setIsExporting(false);
}
};
// Import functions
const handleImportClick = () => {
setShowImportModal(true);
};
const handleFileSelect = async (event) => {
const file = event.target.files[0];
if (!file) return;
setIsImporting(true);
const formData = new FormData();
formData.append("file", file);
try {
const response = await api.post("/admin/import/songs", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
if (response.data.success) {
showNotification(response.data.message, "success");
fetchStats();
} else {
showNotification(response.data.message || "Import failed", "error");
}
} catch (err) {
console.error("Import error:", err);
showNotification(
"Failed to import songs: " +
(err.response?.data?.message || err.message),
"error",
);
} finally {
setIsImporting(false);
setShowImportModal(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
// User management functions
const handleCreateUser = () => {
setEditingUser(null);
setUserForm({
username: "",
password: "",
role: "user",
biometric_enabled: false,
});
setShowUserModal(true);
};
const handleEditUser = (user) => {
setEditingUser(user);
setUserForm({
username: user.username,
password: "",
role: user.role,
biometric_enabled: user.biometric_enabled || false,
});
setShowUserModal(true);
};
const handleSaveUser = async () => {
try {
if (editingUser) {
const updates = { ...userForm };
if (!updates.password) delete updates.password;
const response = await api.put(
`/admin/users/${editingUser.id}`,
updates,
);
if (response.data.success) {
showNotification("User updated successfully", "success");
}
} else {
if (!userForm.username || !userForm.password) {
showNotification("Username and password are required", "error");
return;
}
const response = await api.post("/admin/users", userForm);
if (response.data.success) {
showNotification("User created successfully", "success");
}
}
fetchUsers();
setShowUserModal(false);
} catch (err) {
console.error("Save user error:", err);
showNotification(
err.response?.data?.message || "Failed to save user",
"error",
);
}
};
const handleDeleteUser = async (user) => {
if (!confirm(`Are you sure you want to delete user "${user.username}"?`))
return;
try {
const response = await api.delete(`/admin/users/${user.id}`);
if (response.data.success) {
showNotification("User deleted successfully", "success");
fetchUsers();
}
} catch (err) {
console.error("Delete user error:", err);
showNotification("Failed to delete user", "error");
}
};
const handleToggleBiometric = async (user) => {
if (!user.biometric_enabled) {
// Enabling biometric - need to register
const available = await isBiometricAvailable();
if (!available) {
showNotification(
"Biometric authentication not available on this device",
"error",
);
return;
}
try {
showNotification(
"Please authenticate with your device biometric...",
"info",
);
// Register biometric credential
const credentialData = await registerBiometric(user.username, user.id);
// Send to backend
const response = await api.post(`/auth/biometric-register`, {
username: user.username,
credentialId: credentialData.id,
publicKey: credentialData.response.attestationObject,
});
if (response.data.success) {
// Store credential ID locally
storeBiometricCredential(user.username, credentialData.id);
showNotification(
`Biometric authentication enabled for ${user.username}`,
"success",
);
fetchUsers();
}
} catch (err) {
console.error("Biometric registration error:", err);
showNotification(
err.message || "Failed to register biometric",
"error",
);
}
} else {
// Disabling biometric
try {
const response = await api.post(`/admin/users/${user.id}/biometric`, {
enable: false,
});
if (response.data.success) {
showNotification(
`Biometric disabled for ${user.username}`,
"success",
);
fetchUsers();
}
} catch (err) {
console.error("Biometric disable error:", err);
showNotification("Failed to disable biometric", "error");
}
}
};
return (
<div className="space-y-6">
{/* Notification */}
{notification && (
<div
className={`fixed top-4 right-4 z-50 px-6 py-3 rounded-xl shadow-lg flex items-center gap-3 animate-slide-in ${
notification.type === "success"
? "bg-emerald-500 text-white"
: "bg-red-500 text-white"
}`}
>
{notification.type === "success" ? (
<Check size={20} />
) : (
<AlertCircle size={20} />
)}
{notification.message}
</div>
)}
<h1 className={`text-3xl font-bold ${textPrimary}`}>Admin Dashboard</h1>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className={`backdrop-blur-lg rounded-xl p-6 border ${bgCard}`}>
<div className="flex items-center gap-3 mb-2">
<div
className={`w-10 h-10 rounded-lg flex items-center justify-center ${isDark ? "bg-cyan-500/20" : "bg-cyan-100"}`}
>
<Music size={20} className="text-cyan-500" />
</div>
<h3 className={`text-sm ${textMuted}`}>Total Songs</h3>
</div>
<p className={`text-3xl font-bold ${textPrimary}`}>{stats.songs}</p>
</div>
<div className={`backdrop-blur-lg rounded-xl p-6 border ${bgCard}`}>
<div className="flex items-center gap-3 mb-2">
<div
className={`w-10 h-10 rounded-lg flex items-center justify-center ${isDark ? "bg-violet-500/20" : "bg-violet-100"}`}
>
<Users size={20} className="text-violet-500" />
</div>
<h3 className={`text-sm ${textMuted}`}>Profiles</h3>
</div>
<p className={`text-3xl font-bold ${textPrimary}`}>
{stats.profiles}
</p>
</div>
<div className={`backdrop-blur-lg rounded-xl p-6 border ${bgCard}`}>
<div className="flex items-center gap-3 mb-2">
<div
className={`w-10 h-10 rounded-lg flex items-center justify-center ${isDark ? "bg-amber-500/20" : "bg-amber-100"}`}
>
<ListMusic size={20} className="text-amber-500" />
</div>
<h3 className={`text-sm ${textMuted}`}>Worship Lists</h3>
</div>
<p className={`text-3xl font-bold ${textPrimary}`}>{stats.lists}</p>
</div>
</div>
{/* Admin Actions */}
<div className={`backdrop-blur-lg rounded-xl p-6 border ${bgCard}`}>
<h2 className={`text-xl font-semibold ${textPrimary} mb-4`}>
Quick Actions
</h2>
<div className="grid md:grid-cols-2 gap-4">
{/* Export Data */}
<div className="relative">
<button
onClick={() => handleExport("all")}
disabled={isExporting}
className={`w-full p-4 rounded-xl text-left transition-all flex items-start gap-4 ${
isDark
? "bg-blue-500/20 hover:bg-blue-500/30 border border-blue-400/30"
: "bg-blue-50 hover:bg-blue-100 border border-blue-200"
} ${isExporting ? "opacity-50 cursor-not-allowed" : ""}`}
>
<div
className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? "bg-blue-500/30" : "bg-blue-200"}`}
>
<Download size={24} className="text-blue-500" />
</div>
<div>
<h4 className={`font-medium ${textPrimary}`}>
{isExporting ? "Exporting..." : "Export Data"}
</h4>
<p className={`text-sm ${textMuted}`}>
Download full database backup
</p>
</div>
</button>
<div className="mt-2 flex gap-2 flex-wrap">
<button
onClick={() => handleExport("songs")}
className={`text-xs px-3 py-1.5 rounded-lg ${
isDark
? "bg-blue-500/10 text-blue-300 hover:bg-blue-500/20"
: "bg-blue-100 text-blue-700 hover:bg-blue-200"
}`}
>
Songs Only
</button>
<button
onClick={() => handleExport("profiles")}
className={`text-xs px-3 py-1.5 rounded-lg ${
isDark
? "bg-blue-500/10 text-blue-300 hover:bg-blue-500/20"
: "bg-blue-100 text-blue-700 hover:bg-blue-200"
}`}
>
Profiles Only
</button>
<button
onClick={() => handleExport("lists")}
className={`text-xs px-3 py-1.5 rounded-lg ${
isDark
? "bg-blue-500/10 text-blue-300 hover:bg-blue-500/20"
: "bg-blue-100 text-blue-700 hover:bg-blue-200"
}`}
>
Lists Only
</button>
</div>
</div>
{/* Import Songs */}
<button
onClick={handleImportClick}
disabled={isImporting}
className={`p-4 rounded-xl text-left transition-all flex items-start gap-4 ${
isDark
? "bg-emerald-500/20 hover:bg-emerald-500/30 border border-emerald-400/30"
: "bg-emerald-50 hover:bg-emerald-100 border border-emerald-200"
} ${isImporting ? "opacity-50 cursor-not-allowed" : ""}`}
>
<div
className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? "bg-emerald-500/30" : "bg-emerald-200"}`}
>
<Upload size={24} className="text-emerald-500" />
</div>
<div>
<h4 className={`font-medium ${textPrimary}`}>
{isImporting ? "Importing..." : "Import Songs"}
</h4>
<p className={`text-sm ${textMuted}`}>
Bulk import from JSON file
</p>
</div>
</button>
{/* Manage Users */}
<button
onClick={handleCreateUser}
className={`p-4 rounded-xl text-left transition-all flex items-start gap-4 ${
isDark
? "bg-violet-500/20 hover:bg-violet-500/30 border border-violet-400/30"
: "bg-violet-50 hover:bg-violet-100 border border-violet-200"
}`}
>
<div
className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? "bg-violet-500/30" : "bg-violet-200"}`}
>
<UserPlus size={24} className="text-violet-500" />
</div>
<div>
<h4 className={`font-medium ${textPrimary}`}>Add New User</h4>
<p className={`text-sm ${textMuted}`}>Create new user account</p>
</div>
</button>
{/* System Settings */}
<button
onClick={() => navigate("/settings")}
className={`p-4 rounded-xl text-left transition-all flex items-start gap-4 ${
isDark
? "bg-amber-500/20 hover:bg-amber-500/30 border border-amber-400/30"
: "bg-amber-50 hover:bg-amber-100 border border-amber-200"
}`}
>
<div
className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? "bg-amber-500/30" : "bg-amber-200"}`}
>
<Settings size={24} className="text-amber-500" />
</div>
<div>
<h4 className={`font-medium ${textPrimary}`}>System Settings</h4>
<p className={`text-sm ${textMuted}`}>Configure system options</p>
</div>
</button>
</div>
</div>
{/* User List */}
{users.length > 0 && (
<div className={`backdrop-blur-lg rounded-xl p-6 border ${bgCard}`}>
<h2 className={`text-xl font-semibold ${textPrimary} mb-4`}>
User Accounts
</h2>
<div className="space-y-3">
{users.map((user) => (
<div
key={user.id}
className={`flex items-center justify-between p-4 rounded-xl ${
isDark ? "bg-white/5" : "bg-gray-50"
}`}
>
<div className="flex items-center gap-4">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center ${
isDark ? "bg-violet-500/20" : "bg-violet-100"
}`}
>
<Users size={18} className="text-violet-500" />
</div>
<div>
<p className={`font-medium ${textPrimary}`}>
{user.username}
</p>
<div className="flex items-center gap-2">
<span
className={`text-xs px-2 py-0.5 rounded-full ${
user.role === "admin"
? "bg-amber-500/20 text-amber-500"
: "bg-cyan-500/20 text-cyan-500"
}`}
>
{user.role}
</span>
{user.biometric_enabled && (
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-500 flex items-center gap-1">
<Fingerprint size={12} />
Biometric
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleToggleBiometric(user)}
className={`p-2.5 sm:p-2 rounded-lg touch-manipulation ${
isDark ? "hover:bg-white/10" : "hover:bg-gray-200"
}`}
title={
user.biometric_enabled
? "Disable biometric"
: "Enable biometric"
}
>
<Fingerprint
size={20}
className={
user.biometric_enabled ? "text-emerald-500" : textMuted
}
/>
</button>
<button
onClick={() => handleEditUser(user)}
className={`p-2.5 sm:p-2 rounded-lg touch-manipulation ${isDark ? "hover:bg-white/10" : "hover:bg-gray-200"}`}
>
<Edit size={20} className={textSecondary} />
</button>
<button
onClick={() => handleDeleteUser(user)}
className={`p-2.5 sm:p-2 rounded-lg touch-manipulation ${isDark ? "hover:bg-red-500/20" : "hover:bg-red-100"}`}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileSelect}
className="hidden"
/>
{/* Import Modal */}
{showImportModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div
className={`w-full max-w-md mx-4 rounded-2xl p-6 ${
isDark ? "bg-slate-800" : "bg-white"
}`}
>
<div className="flex items-center justify-between mb-4">
<h3 className={`text-xl font-semibold ${textPrimary}`}>
Import Songs
</h3>
<button
onClick={() => setShowImportModal(false)}
className={`p-2 rounded-lg ${isDark ? "hover:bg-white/10" : "hover:bg-gray-100"}`}
>
<X size={20} className={textSecondary} />
</button>
</div>
<p className={`mb-4 ${textSecondary}`}>
Select a JSON file containing song data. The file should have an
array of songs with title, artist, lyrics, and other fields.
</p>
<div
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
isDark
? "border-white/20 hover:border-emerald-400/50 hover:bg-emerald-500/10"
: "border-gray-300 hover:border-emerald-400 hover:bg-emerald-50"
}`}
onClick={() => fileInputRef.current?.click()}
>
<Upload size={40} className={`mx-auto mb-3 ${textMuted}`} />
<p className={textPrimary}>Click to select file</p>
<p className={`text-sm ${textMuted}`}>or drag and drop</p>
</div>
</div>
</div>
)}
{/* User Modal */}
{showUserModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div
className={`w-full max-w-md mx-4 rounded-2xl p-6 ${
isDark ? "bg-slate-800" : "bg-white"
}`}
>
<div className="flex items-center justify-between mb-4">
<h3 className={`text-xl font-semibold ${textPrimary}`}>
{editingUser ? "Edit User" : "Create New User"}
</h3>
<button
onClick={() => setShowUserModal(false)}
className={`p-2 rounded-lg ${isDark ? "hover:bg-white/10" : "hover:bg-gray-100"}`}
>
<X size={20} className={textSecondary} />
</button>
</div>
<div className="space-y-4">
<div>
<label
className={`block text-sm font-medium mb-1 ${textSecondary}`}
>
Username
</label>
<input
type="text"
value={userForm.username}
onChange={(e) =>
setUserForm({ ...userForm, username: e.target.value })
}
className={`w-full px-4 py-2 rounded-lg border ${inputBg} focus:outline-none focus:ring-2 focus:ring-violet-500`}
placeholder="Enter username"
/>
</div>
<div>
<label
className={`block text-sm font-medium mb-1 ${textSecondary}`}
>
Password {editingUser && "(leave blank to keep current)"}
</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={userForm.password}
onChange={(e) =>
setUserForm({ ...userForm, password: e.target.value })
}
className={`w-full px-4 py-2 pr-10 rounded-lg border ${inputBg} focus:outline-none focus:ring-2 focus:ring-violet-500`}
placeholder="Enter password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className={`absolute right-3 top-1/2 -translate-y-1/2 ${textMuted}`}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
<div>
<label
className={`block text-sm font-medium mb-1 ${textSecondary}`}
>
Role
</label>
<select
value={userForm.role}
onChange={(e) =>
setUserForm({ ...userForm, role: e.target.value })
}
className={`w-full px-4 py-2 rounded-lg border ${inputBg} focus:outline-none focus:ring-2 focus:ring-violet-500`}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div className="flex items-center gap-3">
<button
onClick={() =>
setUserForm({
...userForm,
biometric_enabled: !userForm.biometric_enabled,
})
}
className={`relative w-12 h-7 rounded-full transition-colors ${
userForm.biometric_enabled
? "bg-emerald-500"
: isDark
? "bg-white/20"
: "bg-gray-300"
}`}
>
<div
className={`absolute top-1 w-5 h-5 bg-white rounded-full shadow-md transition-transform ${
userForm.biometric_enabled ? "left-6" : "left-1"
}`}
/>
</button>
<span className={textSecondary}>
<Fingerprint size={16} className="inline mr-1" />
Enable Biometric Authentication
</span>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={() => setShowUserModal(false)}
className={`flex-1 py-2 px-4 rounded-lg font-medium ${
isDark
? "bg-white/10 text-white hover:bg-white/20"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
Cancel
</button>
<button
onClick={handleSaveUser}
className="flex-1 py-2 px-4 rounded-lg font-medium bg-violet-500 text-white hover:bg-violet-600"
>
{editingUser ? "Save Changes" : "Create User"}
</button>
</div>
</div>
</div>
)}
{/* CSS for animations */}
<style>{`
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in {
animation: slide-in 0.3s ease-out;
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,257 @@
import { useState, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { Search, Music, Mic2, Filter, Grid, List } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { useTheme } from "@context/ThemeContext";
import { useSongs } from "@hooks/useDataFetch";
export default function DatabasePage() {
const navigate = useNavigate();
const { isDark } = useTheme();
const { songs, loading } = useSongs();
const [searchQuery, setSearchQuery] = useState("");
const [viewMode, setViewMode] = useState("grid"); // grid or list
const filteredSongs = useMemo(() => {
if (!searchQuery.trim()) return songs;
const query = searchQuery.toLowerCase();
return songs.filter(
(song) =>
song.title?.toLowerCase().includes(query) ||
song.artist?.toLowerCase().includes(query) ||
song.singer?.toLowerCase().includes(query) ||
song.key_chord?.toLowerCase().includes(query),
);
}, [songs, searchQuery]);
// Theme-aware classes
const textPrimary = isDark ? "text-white" : "text-gray-900";
const textSecondary = isDark ? "text-white/70" : "text-gray-600";
const textMuted = isDark ? "text-white/50" : "text-gray-500";
return (
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<h1 className={`text-3xl font-bold ${textPrimary} mb-2`}>
Song Database
</h1>
<p className={textMuted}>{songs.length} songs available</p>
</div>
{/* View Toggle */}
<div
className={`flex items-center gap-1 p-1 rounded-lg ${isDark ? "bg-white/5" : "bg-gray-100"}`}
>
<button
onClick={() => setViewMode("grid")}
className={`p-2 rounded-md transition-colors ${
viewMode === "grid"
? isDark
? "bg-white/10 text-white"
: "bg-white text-gray-900 shadow-sm"
: textMuted
}`}
>
<Grid size={18} />
</button>
<button
onClick={() => setViewMode("list")}
className={`p-2 rounded-md transition-colors ${
viewMode === "list"
? isDark
? "bg-white/10 text-white"
: "bg-white text-gray-900 shadow-sm"
: textMuted
}`}
>
<List size={18} />
</button>
</div>
</div>
{/* Search Bar */}
<div className="relative">
<Search
className={`absolute left-4 top-1/2 -translate-y-1/2 ${textMuted}`}
size={20}
/>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by title, artist, or key..."
className={`w-full pl-12 pr-4 py-4 rounded-xl text-lg outline-none transition-all
${
isDark
? "bg-white/5 border border-white/10 focus:border-cyan-500/50 focus:bg-white/10 text-white placeholder-white/40"
: "bg-gray-50 border border-gray-200 focus:border-cyan-500 focus:bg-white text-gray-900 placeholder-gray-400"
}`}
/>
</div>
</div>
{/* Loading State */}
{loading && (
<div className={`text-center py-20 ${textMuted}`}>
<div className="w-12 h-12 border-2 border-t-cyan-500 border-cyan-500/20 rounded-full animate-spin mx-auto mb-4"></div>
<p>Loading songs...</p>
</div>
)}
{/* No Results */}
{!loading && filteredSongs.length === 0 && (
<div className={`text-center py-20 ${textMuted}`}>
<Music size={48} className="mx-auto mb-4 opacity-50" />
<p className="text-lg mb-2">No songs found</p>
<p className="text-sm">Try a different search term</p>
</div>
)}
{/* Grid View */}
{!loading && viewMode === "grid" && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
>
<AnimatePresence mode="popLayout">
{filteredSongs.map((song, index) => (
<motion.div
key={song.id}
layout
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ delay: index * 0.02, duration: 0.3 }}
onClick={() => navigate(`/song/${song.id}`)}
className={`group cursor-pointer p-5 rounded-xl transition-all
${
isDark
? "bg-gradient-to-br from-white/5 to-white/[0.02] border border-white/10 hover:border-cyan-500/40 hover:bg-white/10"
: "bg-gradient-to-br from-slate-50 to-stone-100 border border-gray-200 hover:border-cyan-400 hover:shadow-lg shadow-sm"
}`}
>
{/* Icon & Key Badge */}
<div className="flex items-start justify-between mb-4">
<div
className={`w-12 h-12 rounded-xl ${isDark ? "bg-cyan-500/20" : "bg-cyan-100"} flex items-center justify-center group-hover:scale-110 transition-transform`}
>
<Music size={22} className="text-cyan-500" />
</div>
{song.key_chord && (
<span
className={`px-3 py-1.5 rounded-lg text-sm font-bold
${
isDark
? "bg-amber-500/20 text-amber-400"
: "bg-amber-100 text-amber-700"
}`}
>
{song.key_chord}
</span>
)}
</div>
{/* Title */}
<h3
className={`text-lg font-semibold ${textPrimary} mb-2 truncate group-hover:text-cyan-500 transition-colors`}
>
{song.title}
</h3>
{/* Artist */}
<div
className={`flex items-center gap-2 ${textSecondary} text-sm mb-3`}
>
<Mic2 size={14} />
<span className="truncate">
{song.artist || song.singer || "Unknown artist"}
</span>
</div>
{/* Band / Additional Info */}
{song.band && (
<p className={`text-xs ${textMuted} truncate`}>{song.band}</p>
)}
</motion.div>
))}
</AnimatePresence>
</motion.div>
)}
{/* List View */}
{!loading && viewMode === "list" && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-2"
>
{/* Header Row */}
<div
className={`grid grid-cols-12 gap-4 px-4 py-2 text-sm font-medium ${textMuted}`}
>
<div className="col-span-5 sm:col-span-4">Title</div>
<div className="col-span-4 sm:col-span-3">Artist</div>
<div className="col-span-3 sm:col-span-2">Key</div>
<div className="hidden sm:block sm:col-span-3">Band</div>
</div>
<AnimatePresence mode="popLayout">
{filteredSongs.map((song, index) => (
<motion.div
key={song.id}
layout
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ delay: index * 0.015, duration: 0.25 }}
onClick={() => navigate(`/song/${song.id}`)}
className={`grid grid-cols-12 gap-4 px-4 py-4 rounded-xl cursor-pointer transition-all
${
isDark
? "bg-white/5 hover:bg-white/10 border border-white/5 hover:border-cyan-500/30"
: "bg-slate-50 hover:bg-slate-100 border border-gray-200 hover:border-cyan-400 shadow-sm"
}`}
>
<div
className={`col-span-5 sm:col-span-4 font-medium ${textPrimary} truncate`}
>
{song.title}
</div>
<div
className={`col-span-4 sm:col-span-3 ${textSecondary} truncate`}
>
{song.artist || song.singer || "—"}
</div>
<div className="col-span-3 sm:col-span-2">
{song.key_chord ? (
<span
className={`inline-block px-2 py-1 rounded text-xs font-bold
${
isDark
? "bg-amber-500/20 text-amber-400"
: "bg-amber-100 text-amber-700"
}`}
>
{song.key_chord}
</span>
) : (
<span className={textMuted}></span>
)}
</div>
<div
className={`hidden sm:block sm:col-span-3 ${textMuted} truncate`}
>
{song.band || "—"}
</div>
</motion.div>
))}
</AnimatePresence>
</motion.div>
)}
</div>
);
}

View File

@@ -0,0 +1,598 @@
import { useState, useEffect, useMemo } from "react";
import { Link, useNavigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import {
Music,
Search,
Plus,
X,
FileText,
Upload,
Mic2,
ListMusic,
Sparkles,
ChevronRight,
Calendar,
} from "lucide-react";
import { useTheme } from "@context/ThemeContext";
import { parseDocument } from "@utils/documentParser";
import {
useSongs,
useLists,
useStats,
useDataMutations,
} from "@hooks/useDataFetch";
// Smooth animation variants
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.08, delayChildren: 0.05, duration: 0.4 },
},
};
const tileVariants = {
hidden: { opacity: 0, y: 15 },
visible: {
opacity: 1,
y: 0,
transition: { type: "tween", ease: "easeOut", duration: 0.4 },
},
};
const modalVariants = {
hidden: { opacity: 0, scale: 0.95 },
visible: {
opacity: 1,
scale: 1,
transition: { type: "tween", ease: "easeOut", duration: 0.2 },
},
exit: { opacity: 0, scale: 0.95, transition: { duration: 0.15 } },
};
export default function HomePage() {
const navigate = useNavigate();
const { isDark } = useTheme();
// Use cached data from the global store
const { stats, loading: statsLoading } = useStats();
const { songs, loading: songsLoading } = useSongs();
const { lists: worshipLists, loading: listsLoading } = useLists();
const [searchQuery, setSearchQuery] = useState("");
const [activeModal, setActiveModal] = useState(null);
const [uploading, setUploading] = useState(false);
// Combined loading state
const loading = statsLoading && songsLoading && listsLoading;
// Local search on cached songs (no API call needed)
const searchResults = useMemo(() => {
if (!searchQuery.trim()) return [];
const query = searchQuery.toLowerCase();
return songs
.filter(
(song) =>
song.title?.toLowerCase().includes(query) ||
song.artist?.toLowerCase().includes(query) ||
song.singer?.toLowerCase().includes(query) ||
song.lyrics?.toLowerCase().includes(query),
)
.slice(0, 10);
}, [searchQuery, songs]);
const handleFileUpload = async (event) => {
const file = event.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const parsed = await parseDocument(file);
// Navigate to create page with parsed data
navigate("/song/new", {
state: {
uploadedData: {
title: parsed.title || file.name.replace(/\.[^/.]+$/, ""),
lyrics: parsed.lyrics,
chords: parsed.chords,
key_chord: parsed.key,
artist: parsed.artist,
},
},
});
} catch (error) {
console.error("Failed to parse document:", error);
alert(error.message || "Failed to parse document");
} finally {
setUploading(false);
event.target.value = ""; // Reset input
}
};
const closeModal = () => {
setActiveModal(null);
setSearchQuery("");
setSearchResults([]);
};
// Theme-aware classes
const textPrimary = isDark ? "text-white" : "text-gray-900";
const textSecondary = isDark ? "text-white/60" : "text-gray-600";
const textMuted = isDark ? "text-white/50" : "text-gray-500";
return (
<div className="min-h-[calc(100vh-12rem)]">
{/* Stats Bar */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
className="flex justify-center gap-8 sm:gap-12 mb-10"
>
{[
{ label: "Songs", value: stats.songs, color: "text-cyan-500" },
{
label: "Profiles",
value: stats.profiles,
color: "text-violet-500",
},
{ label: "Lists", value: stats.lists, color: "text-amber-500" },
].map((stat) => (
<div key={stat.label} className="text-center">
<div className={`text-3xl sm:text-4xl font-bold ${stat.color}`}>
{loading ? "—" : stat.value}
</div>
<div className={`${textSecondary} text-sm mt-1`}>{stat.label}</div>
</div>
))}
</motion.div>
{/* Main Tiles Grid */}
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="grid grid-cols-1 lg:grid-cols-2 gap-5 mb-5"
>
{/* Left Tile: Worship Lists */}
<motion.div
variants={tileVariants}
onClick={() => setActiveModal("worship")}
className={`group cursor-pointer relative overflow-hidden rounded-2xl
${
isDark
? "bg-gradient-to-br from-violet-600/25 via-purple-600/20 to-fuchsia-600/25 border-white/10 hover:border-violet-400/50"
: "bg-gradient-to-br from-violet-200 via-purple-100 to-fuchsia-200 border-violet-300 hover:border-violet-500"
}
border backdrop-blur-xl p-6 sm:p-8 min-h-[240px]
hover:shadow-xl hover:shadow-violet-500/20
transition-all duration-300 ease-out`}
>
<div className="relative z-10">
<div className="flex items-start justify-between mb-5">
<div
className={`w-14 h-14 rounded-xl ${isDark ? "bg-violet-500/30" : "bg-violet-500/25"} flex items-center justify-center`}
>
<ListMusic className="text-violet-600" size={28} />
</div>
<div
className={`flex items-center gap-2 px-3 py-1.5 rounded-full ${isDark ? "bg-violet-500/20 text-violet-300" : "bg-violet-500/20 text-violet-800"} text-base font-semibold`}
>
<span>{stats.lists} lists</span>
</div>
</div>
<h2
className={`text-2xl sm:text-3xl font-bold ${textPrimary} mb-3`}
>
Worship Lists
</h2>
<p className={`${textMuted} mb-5 text-base sm:text-lg`}>
Create and manage your Sunday service setlists
</p>
<div className="flex items-center text-violet-600 font-semibold text-base">
<span>Open Manager</span>
<ChevronRight
size={20}
className="ml-1 group-hover:translate-x-1 transition-transform"
/>
</div>
</div>
</motion.div>
{/* Right Tile: Search Songs */}
<motion.div
variants={tileVariants}
onClick={() => setActiveModal("search")}
className={`group cursor-pointer relative overflow-hidden rounded-2xl
${
isDark
? "bg-gradient-to-br from-cyan-600/25 via-blue-600/20 to-sky-600/25 border-white/10 hover:border-cyan-400/50"
: "bg-gradient-to-br from-cyan-200 via-blue-100 to-sky-200 border-cyan-300 hover:border-cyan-500"
}
border backdrop-blur-xl p-6 sm:p-8 min-h-[240px]
hover:shadow-xl hover:shadow-cyan-500/20
transition-all duration-300 ease-out`}
>
<div className="relative z-10">
<div className="flex items-start justify-between mb-5">
<div
className={`w-14 h-14 rounded-xl ${isDark ? "bg-cyan-500/30" : "bg-cyan-500/25"} flex items-center justify-center`}
>
<Search className="text-cyan-600" size={28} />
</div>
<div
className={`flex items-center gap-2 px-3 py-1.5 rounded-full ${isDark ? "bg-cyan-500/20 text-cyan-300" : "bg-cyan-500/20 text-cyan-800"} text-base font-semibold`}
>
<span>{stats.songs} songs</span>
</div>
</div>
<h2
className={`text-2xl sm:text-3xl font-bold ${textPrimary} mb-3`}
>
Search Songs
</h2>
<p className={`${textMuted} mb-5 text-base sm:text-lg`}>
Find songs by title, artist, or lyrics
</p>
<div className="flex items-center text-cyan-600 font-semibold text-base">
<span>Start Searching</span>
<ChevronRight
size={20}
className="ml-1 group-hover:translate-x-1 transition-transform"
/>
</div>
</div>
</motion.div>
</motion.div>
{/* Bottom Tile: Add New Song */}
<motion.div
variants={tileVariants}
initial="hidden"
animate="visible"
onClick={() => setActiveModal("upload")}
className={`group cursor-pointer relative overflow-hidden rounded-2xl
${
isDark
? "bg-gradient-to-r from-emerald-600/25 via-teal-600/20 to-green-600/25 border-white/10 hover:border-emerald-400/50"
: "bg-gradient-to-r from-emerald-200 via-teal-100 to-green-200 border-emerald-300 hover:border-emerald-500"
}
border backdrop-blur-xl p-6 sm:p-8
hover:shadow-xl hover:shadow-emerald-500/20
transition-all duration-300 ease-out`}
>
<div className="relative z-10 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-5">
<div
className={`w-14 h-14 rounded-xl ${isDark ? "bg-emerald-500/30" : "bg-emerald-500/25"} flex items-center justify-center`}
>
<Plus className="text-emerald-600" size={28} />
</div>
<div>
<h2
className={`text-2xl sm:text-3xl font-bold ${textPrimary} mb-1`}
>
Add New Song
</h2>
<p className={`${textMuted} text-base sm:text-lg`}>
Create a new song or upload lyrics
</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className={`hidden sm:flex items-center gap-3 ${textMuted}`}>
<div
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-base ${isDark ? "bg-white/5" : "bg-white/80"}`}
>
<Upload size={18} />
<span>Upload</span>
</div>
<div
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-base ${isDark ? "bg-white/5" : "bg-white/80"}`}
>
<FileText size={18} />
<span>Create</span>
</div>
</div>
<div
className={`w-12 h-12 rounded-full ${isDark ? "bg-emerald-500/30" : "bg-emerald-500/25"} flex items-center justify-center group-hover:bg-emerald-500/40 transition-colors`}
>
<ChevronRight className="text-emerald-600" size={24} />
</div>
</div>
</div>
</motion.div>
{/* Modals */}
<AnimatePresence>
{activeModal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={closeModal}
>
<motion.div
variants={modalVariants}
initial="hidden"
animate="visible"
exit="exit"
onClick={(e) => e.stopPropagation()}
className={`w-full max-w-2xl max-h-[80vh] overflow-hidden rounded-2xl shadow-2xl
${isDark ? "bg-slate-900 border border-white/20" : "bg-white border border-gray-200"}`}
>
<div
className={`flex items-center justify-between p-5 border-b ${isDark ? "border-white/10" : "border-gray-200"}`}
>
<h3
className={`text-lg font-bold ${textPrimary} flex items-center gap-3`}
>
{activeModal === "worship" && (
<>
<ListMusic className="text-violet-500" size={20} />{" "}
Worship Lists
</>
)}
{activeModal === "search" && (
<>
<Search className="text-cyan-500" size={20} /> Search
Songs
</>
)}
{activeModal === "upload" && (
<>
<Plus className="text-emerald-500" size={20} /> Add New
Song
</>
)}
</h3>
<button
onClick={closeModal}
className={`p-2 rounded-lg transition-colors ${isDark ? "hover:bg-white/10" : "hover:bg-gray-100"}`}
>
<X className={textSecondary} size={18} />
</button>
</div>
<div className="p-5 overflow-y-auto max-h-[calc(80vh-80px)]">
{activeModal === "worship" && (
<div className="space-y-3">
{worshipLists.length === 0 ? (
<div className={`text-center py-10 ${textMuted}`}>
<ListMusic
size={40}
className="mx-auto mb-3 opacity-50"
/>
<p>No worship lists yet</p>
<Link
to="/worship-lists"
className={`inline-block mt-4 px-5 py-2 rounded-lg transition-colors text-sm
${isDark ? "bg-violet-500/20 hover:bg-violet-500/30 text-violet-300" : "bg-violet-100 hover:bg-violet-200 text-violet-700"}`}
>
Create First List
</Link>
</div>
) : (
worshipLists.map((list) => (
<div
key={list.id}
className={`flex items-center justify-between p-4 rounded-xl transition-all cursor-pointer
${
isDark
? "bg-white/5 hover:bg-white/10 border border-white/5 hover:border-violet-500/30"
: "bg-gray-50 hover:bg-gray-100 border border-gray-200 hover:border-violet-400"
}`}
onClick={() => navigate(`/worship-lists/${list.id}`)}
>
<div className="flex items-center gap-4">
<div
className={`w-10 h-10 rounded-lg ${isDark ? "bg-violet-500/20" : "bg-violet-100"} flex items-center justify-center`}
>
<Calendar size={18} className="text-violet-500" />
</div>
<div>
<h4 className={`font-medium ${textPrimary}`}>
{list.date}
</h4>
<p className={`text-sm ${textMuted}`}>
{list.profile_name || "No leader"} {" "}
{list.song_count || 0} songs
</p>
</div>
</div>
<ChevronRight size={18} className={textMuted} />
</div>
))
)}
<Link
to="/worship-lists"
className={`block text-center py-3 rounded-xl transition-colors text-sm
${isDark ? "bg-violet-500/15 hover:bg-violet-500/25 text-violet-300" : "bg-violet-100 hover:bg-violet-200 text-violet-700"}`}
>
View All Lists
</Link>
</div>
)}
{activeModal === "search" && (
<div className="space-y-4">
<div className="relative">
<Search
className={`absolute left-4 top-1/2 -translate-y-1/2 ${textMuted}`}
size={18}
/>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by title, artist, or lyrics..."
className={`w-full pl-11 pr-4 py-3.5 rounded-xl outline-none transition-all text-sm
${
isDark
? "bg-white/5 border border-white/10 focus:border-cyan-500/50 focus:bg-white/10 text-white placeholder-white/40"
: "bg-gray-50 border border-gray-200 focus:border-cyan-500 focus:bg-white text-gray-900 placeholder-gray-400"
}`}
autoFocus
/>
</div>
{searchResults.length > 0 ? (
<div className="space-y-2">
{searchResults.map((song) => (
<div
key={song.id}
className={`p-4 rounded-xl transition-all cursor-pointer
${
isDark
? "bg-white/5 hover:bg-white/10 border border-white/5 hover:border-cyan-500/30"
: "bg-gray-50 hover:bg-gray-100 border border-gray-200 hover:border-cyan-400"
}`}
onClick={() => navigate(`/song/${song.id}`)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className={`font-medium ${textPrimary}`}>
{song.title}
</h4>
{/* Show Key/Chord Badge */}
{song.key_chord && (
<span
className={`px-2 py-0.5 rounded text-xs font-bold
${
isDark
? "bg-amber-500/20 text-amber-400"
: "bg-amber-100 text-amber-700"
}`}
>
{song.key_chord}
</span>
)}
</div>
<p className={`text-sm ${textMuted}`}>
{song.artist ||
song.singer ||
"Unknown artist"}
</p>
{/* Show chord progression if available */}
{song.chords && (
<p
className={`text-xs mt-1 ${isDark ? "text-cyan-400/70" : "text-cyan-600/70"}`}
>
Chords: {song.chords}
</p>
)}
</div>
<Mic2
size={14}
className="text-cyan-500/50 mt-1"
/>
</div>
{song.lyrics && (
<p
className={`mt-2 text-sm ${textMuted} line-clamp-2`}
>
{song.lyrics.substring(0, 100)}...
</p>
)}
</div>
))}
</div>
) : (
searchQuery && (
<div className={`text-center py-8 ${textMuted}`}>
<Search
size={28}
className="mx-auto mb-2 opacity-50"
/>
<p className="text-sm">No songs found</p>
</div>
)
)}
{!searchQuery && (
<div className={`text-center py-8 ${textMuted}`}>
<Sparkles
size={28}
className="mx-auto mb-2 opacity-50"
/>
<p className="text-sm">
Start typing to search {stats.songs} songs
</p>
</div>
)}
<Link
to="/database"
className={`block text-center py-3 rounded-xl transition-colors text-sm
${isDark ? "bg-cyan-500/15 hover:bg-cyan-500/25 text-cyan-300" : "bg-cyan-100 hover:bg-cyan-200 text-cyan-700"}`}
>
Browse Full Database
</Link>
</div>
)}
{activeModal === "upload" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div
onClick={() => navigate("/song/new")}
className={`p-5 rounded-xl cursor-pointer transition-all group
${
isDark
? "bg-gradient-to-br from-emerald-500/10 to-teal-500/10 border border-emerald-500/20 hover:border-emerald-500/40"
: "bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-200 hover:border-emerald-400"
}`}
>
<div
className={`w-12 h-12 rounded-xl ${isDark ? "bg-emerald-500/20" : "bg-emerald-100"} flex items-center justify-center mb-4 group-hover:bg-emerald-500/30 transition-colors`}
>
<FileText size={24} className="text-emerald-500" />
</div>
<h4
className={`text-base font-semibold ${textPrimary} mb-1`}
>
Create New
</h4>
<p className={`text-sm ${textMuted}`}>
Start fresh with a blank song
</p>
</div>
<label
htmlFor="file-upload"
className={`p-5 rounded-xl cursor-pointer transition-all group
${
isDark
? "bg-gradient-to-br from-blue-500/10 to-indigo-500/10 border border-blue-500/20 hover:border-blue-500/40"
: "bg-gradient-to-br from-blue-50 to-indigo-50 border border-blue-200 hover:border-blue-400"
}`}
>
<input
id="file-upload"
type="file"
accept=".pdf,.docx,.txt"
onChange={handleFileUpload}
className="hidden"
disabled={uploading}
/>
<div
className={`w-12 h-12 rounded-xl ${isDark ? "bg-blue-500/20" : "bg-blue-100"} flex items-center justify-center mb-4 group-hover:bg-blue-500/30 transition-colors`}
>
<Upload size={24} className="text-blue-500" />
</div>
<h4
className={`text-base font-semibold ${textPrimary} mb-1`}
>
Upload File
</h4>
<p className={`text-sm ${textMuted}`}>
{uploading ? "Parsing..." : "PDF, Word, or TXT"}
</p>
</label>
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,404 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "@context/AuthContext";
import { useTheme } from "@context/ThemeContext";
import toast from "react-hot-toast";
import { Music2, Fingerprint, Eye, Lock, User, Mail } from "lucide-react";
import {
isBiometricAvailable,
getBiometricType,
authenticateWithBiometric,
getBiometricCredential,
hasBiometricRegistered,
} from "@utils/biometric";
export default function LoginPage() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [biometricAvailable, setBiometricAvailable] = useState(false);
const [biometricType, setBiometricType] = useState("");
const [showForgotPassword, setShowForgotPassword] = useState(false);
const { login } = useAuth();
const { isDark } = useTheme();
const navigate = useNavigate();
// Check biometric availability on mount
useEffect(() => {
checkBiometric();
}, []);
const checkBiometric = async () => {
const available = await isBiometricAvailable();
setBiometricAvailable(available);
if (available) {
setBiometricType(getBiometricType());
}
};
// Handle form submission
const handleSubmit = async (e) => {
e.preventDefault();
if (!username || !password) {
setError("Please enter username and password");
return;
}
setLoading(true);
setError("");
try {
await login(username, password);
toast.success("Welcome back!");
navigate("/");
} catch (err) {
setError(err.message || "Invalid credentials");
} finally {
setLoading(false);
}
};
// Handle biometric login
const handleBiometricLogin = async () => {
if (!username) {
toast.error("Please enter your username first");
return;
}
if (!hasBiometricRegistered(username)) {
toast.error(
"Biometric authentication not set up for this account. Please login with password first.",
);
return;
}
setLoading(true);
setError("");
try {
const credentialId = getBiometricCredential(username);
const assertion = await authenticateWithBiometric(credentialId);
// Send assertion to server for verification
const response = await fetch("/api/auth/biometric-login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, assertion }),
});
const data = await response.json();
if (data.success) {
// Store token and navigate
localStorage.setItem("authToken", data.token);
toast.success(`Welcome back! Authenticated with ${biometricType}`);
navigate("/");
} else {
setError(data.message || "Biometric authentication failed");
}
} catch (err) {
setError(err.message || "Biometric authentication failed");
toast.error("Biometric authentication failed");
} finally {
setLoading(false);
}
};
// Handle Enter key press
const handleKeyPress = (e) => {
if (e.key === "Enter") {
handleSubmit(e);
}
};
return (
<div
className={`min-h-screen flex items-center justify-center px-4 py-8 ${
isDark
? "bg-gradient-to-br from-slate-950 via-purple-950 to-slate-950"
: "bg-gradient-to-br from-blue-50 via-purple-50 to-blue-50"
}`}
>
<div className="w-full max-w-md">
{/* Main Login Card */}
<div
className={`backdrop-blur-xl rounded-3xl p-8 sm:p-10 shadow-2xl ${
isDark
? "bg-white/10 border border-white/20"
: "bg-white/90 border border-gray-200"
}`}
>
{/* Logo and Title */}
<div className="text-center mb-8">
<div
className={`inline-flex items-center justify-center w-20 h-20 rounded-2xl mb-4 ${
isDark ? "bg-purple-500/20" : "bg-purple-100"
}`}
>
<Music2
size={40}
className={isDark ? "text-purple-400" : "text-purple-600"}
/>
</div>
<h1
className={`text-3xl sm:text-4xl font-bold mb-2 ${
isDark ? "text-white" : "text-gray-900"
}`}
>
Worship
</h1>
<p
className={`text-sm ${
isDark ? "text-white/60" : "text-gray-600"
}`}
>
House of Praise Music Platform
</p>
</div>
{/* Error Message */}
{error && (
<div
role="alert"
aria-live="assertive"
className="bg-red-500/10 border border-red-500/30 text-red-500 px-4 py-3 rounded-xl mb-6 text-center text-sm"
>
{error}
</div>
)}
{/* Login Form */}
<form
onSubmit={handleSubmit}
className="space-y-5"
aria-label="Login form"
>
{/* Username Field */}
<div>
<label
htmlFor="username-input"
className={`block text-sm font-medium mb-2 ${
isDark ? "text-white/80" : "text-gray-700"
}`}
>
Username
</label>
<div className="relative">
<User
size={20}
className={`absolute left-4 top-1/2 -translate-y-1/2 ${
isDark ? "text-white/40" : "text-gray-400"
}`}
aria-hidden="true"
/>
<input
id="username-input"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyPress={handleKeyPress}
className={`w-full pl-12 pr-4 py-3.5 rounded-xl border text-base transition-all focus:outline-none focus:ring-2 touch-manipulation ${
isDark
? "bg-white/5 border-white/20 text-white placeholder-white/40 focus:border-purple-500/50 focus:ring-purple-500/20"
: "bg-white border-gray-300 text-gray-900 placeholder-gray-400 focus:border-purple-500 focus:ring-purple-500/20"
}`}
placeholder="Enter username"
autoComplete="username"
aria-required="true"
aria-describedby={error ? "login-error" : undefined}
autoFocus
/>
</div>
</div>
{/* Password Field */}
<div>
<label
htmlFor="password-input"
className={`block text-sm font-medium mb-2 ${
isDark ? "text-white/80" : "text-gray-700"
}`}
>
Password
</label>
<div className="relative">
<Lock
size={20}
className={`absolute left-4 top-1/2 -translate-y-1/2 ${
isDark ? "text-white/40" : "text-gray-400"
}`}
aria-hidden="true"
/>
<input
id="password-input"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyPress={handleKeyPress}
className={`w-full pl-12 pr-4 py-3.5 rounded-xl border text-base transition-all focus:outline-none focus:ring-2 touch-manipulation ${
isDark
? "bg-white/5 border-white/20 text-white placeholder-white/40 focus:border-purple-500/50 focus:ring-purple-500/20"
: "bg-white border-gray-300 text-gray-900 placeholder-gray-400 focus:border-purple-500 focus:ring-purple-500/20"
}`}
placeholder="Enter password"
autoComplete="current-password"
aria-required="true"
aria-describedby={error ? "login-error" : undefined}
/>
</div>
</div>
{/* Forgot Password Link */}
<div className="flex justify-end">
<button
type="button"
onClick={() => setShowForgotPassword(true)}
className={`text-sm font-medium transition-colors touch-manipulation ${
isDark
? "text-purple-400 hover:text-purple-300"
: "text-purple-600 hover:text-purple-700"
}`}
>
Forgot password?
</button>
</div>
{/* Login Button */}
<button
type="submit"
disabled={loading}
className={`w-full py-3.5 rounded-xl font-semibold text-base transition-all transform active:scale-[0.98] touch-manipulation ${
loading ? "opacity-50 cursor-not-allowed" : "hover:shadow-lg"
} ${
isDark
? "bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-500 hover:to-blue-500 text-white"
: "bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 text-white shadow-md"
}`}
aria-busy={loading}
aria-label={
loading ? "Signing in, please wait" : "Sign in to your account"
}
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<div
className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"
aria-hidden="true"
></div>
Signing in...
</span>
) : (
"Sign In"
)}
</button>
</form>
{/* Biometric Login */}
{biometricAvailable && (
<>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div
className={`w-full border-t ${
isDark ? "border-white/10" : "border-gray-200"
}`}
></div>
</div>
<div className="relative flex justify-center text-sm">
<span
className={`px-4 ${
isDark
? "bg-gray-900/50 text-white/60"
: "bg-white text-gray-500"
}`}
>
Or continue with
</span>
</div>
</div>
<button
type="button"
onClick={handleBiometricLogin}
disabled={loading || !username}
className={`w-full py-3.5 rounded-xl font-medium text-base transition-all transform active:scale-[0.98] flex items-center justify-center gap-3 touch-manipulation ${
loading || !username ? "opacity-50 cursor-not-allowed" : ""
} ${
isDark
? "bg-white/10 hover:bg-white/20 text-white border border-white/20"
: "bg-gray-100 hover:bg-gray-200 text-gray-900 border border-gray-300"
}`}
>
<Fingerprint size={20} />
<span>Biometric Authentication</span>
</button>
</>
)}
</div>
{/* Footer */}
<p
className={`text-center mt-6 text-sm ${
isDark ? "text-white/50" : "text-gray-600"
}`}
>
© 2026 House of Praise Church. All rights reserved.
</p>
</div>
{/* Forgot Password Modal */}
{showForgotPassword && (
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 z-50"
onClick={() => setShowForgotPassword(false)}
>
<div
className={`max-w-md w-full rounded-2xl p-8 ${
isDark ? "bg-gray-800" : "bg-white"
}`}
onClick={(e) => e.stopPropagation()}
>
<div className="text-center mb-6">
<div
className={`inline-flex items-center justify-center w-16 h-16 rounded-full mb-4 ${
isDark ? "bg-blue-500/20" : "bg-blue-100"
}`}
>
<Mail
size={32}
className={isDark ? "text-blue-400" : "text-blue-600"}
/>
</div>
<h3
className={`text-2xl font-bold mb-2 ${
isDark ? "text-white" : "text-gray-900"
}`}
>
Reset Password
</h3>
<p
className={`text-sm ${
isDark ? "text-white/60" : "text-gray-600"
}`}
>
Contact your administrator to reset your password
</p>
</div>
<button
onClick={() => setShowForgotPassword(false)}
className={`w-full py-3 rounded-xl font-medium transition-all touch-manipulation ${
isDark
? "bg-blue-600 hover:bg-blue-700 text-white"
: "bg-blue-600 hover:bg-blue-700 text-white"
}`}
>
Close
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,395 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Music, User, Mail, Key, Plus, Trash2, X } from "lucide-react";
import api from "@utils/api";
import { useTheme } from "@context/ThemeContext";
import { useProfiles, useDataMutations } from "@hooks/useDataFetch";
export default function ProfilesPage() {
const navigate = useNavigate();
const { isDark } = useTheme();
// Use cached data from global store
const {
profiles,
loading: profilesLoading,
refetch: refetchProfiles,
} = useProfiles();
const { invalidateProfiles } = useDataMutations();
const [selectedProfile, setSelectedProfile] = useState(null);
const [profileSongs, setProfileSongs] = useState([]);
const [showAddModal, setShowAddModal] = useState(false);
const [newProfile, setNewProfile] = useState({
name: "",
email: "",
default_key: "C",
});
const [deleting, setDeleting] = useState(false);
const [loading, setLoading] = useState(true);
// Update loading state based on cache
useEffect(() => {
if (!profilesLoading) {
setLoading(false);
}
}, [profilesLoading]);
// Theme-aware classes
const textPrimary = isDark ? "text-white" : "text-gray-900";
const textSecondary = isDark ? "text-white/70" : "text-gray-600";
const textMuted = isDark ? "text-white/50" : "text-gray-500";
const bgCard = isDark
? "bg-white/10 border-white/20"
: "bg-white border-gray-200 shadow-sm";
const bgCardHover = isDark ? "hover:bg-white/15" : "hover:bg-gray-50";
const bgSongItem = isDark ? "bg-white/5" : "bg-gray-50";
const handleSelectProfile = async (profile) => {
setSelectedProfile(profile);
try {
const response = await api.get(`/profiles/${profile.id}`);
if (response.data.success) {
setProfileSongs(response.data.songs || []);
}
} catch (err) {
console.error("Failed to fetch profile songs:", err);
setProfileSongs([]);
}
};
const handleAddProfile = async () => {
if (!newProfile.name.trim()) {
alert("Profile name is required");
return;
}
try {
const response = await api.post("/profiles", newProfile);
if (response.data.success) {
// Invalidate cache and refetch
invalidateProfiles();
refetchProfiles();
setShowAddModal(false);
setNewProfile({ name: "", email: "", default_key: "C" });
}
} catch (err) {
console.error("Failed to add profile:", err);
alert("Failed to add profile");
}
};
const handleDeleteProfile = async () => {
if (!selectedProfile) return;
if (!confirm(`Are you sure you want to delete ${selectedProfile.name}?`)) {
return;
}
setDeleting(true);
try {
const response = await api.delete(`/profiles/${selectedProfile.id}`);
if (response.data.success) {
// Invalidate cache and refetch
invalidateProfiles();
refetchProfiles();
setSelectedProfile(null);
setProfileSongs([]);
}
} catch (err) {
console.error("Failed to delete profile:", err);
alert("Failed to delete profile");
} finally {
setDeleting(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className={`text-xl ${textMuted}`}>Loading profiles...</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className={`text-3xl font-bold ${textPrimary}`}>Band Profiles</h1>
<p className={textMuted}>{profiles.length} musicians</p>
</div>
<div className="flex gap-2">
{selectedProfile && (
<button
onClick={handleDeleteProfile}
disabled={deleting}
className="flex items-center gap-2 px-5 py-2.5 bg-red-500 hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-xl transition-colors"
>
<Trash2 size={18} />
<span>{deleting ? "Deleting..." : "Delete Profile"}</span>
</button>
)}
<button
onClick={() => setShowAddModal(true)}
className="flex items-center gap-2 px-5 py-2.5 bg-blue-500 hover:bg-blue-600 text-white font-medium rounded-xl transition-colors"
>
<Plus size={18} />
<span>Add Profile</span>
</button>
</div>
</div>
<div className="grid lg:grid-cols-3 gap-6">
{/* Profiles List */}
<div className="lg:col-span-1 space-y-3">
{profiles.length === 0 ? (
<div
className={`backdrop-blur-lg rounded-xl p-6 border text-center ${bgCard}`}
>
<User size={40} className={`mx-auto mb-3 ${textMuted}`} />
<p className={textMuted}>No profiles yet</p>
</div>
) : (
profiles.map((profile) => (
<button
key={profile.id}
onClick={() => handleSelectProfile(profile)}
className={`w-full text-left backdrop-blur-lg rounded-xl p-4
border transition-all ${
selectedProfile?.id === profile.id
? `border-blue-400 ${isDark ? "bg-blue-500/20" : "bg-blue-50"}`
: `${bgCard} ${bgCardHover}`
}`}
>
<div className="flex items-center space-x-3">
<div
className="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600
rounded-full flex items-center justify-center text-white text-xl font-bold"
>
{profile.name?.charAt(0)?.toUpperCase() || "?"}
</div>
<div>
<h3 className={`text-lg font-semibold ${textPrimary}`}>
{profile.name}
</h3>
<p className={`text-sm ${textMuted}`}>
Key: {profile.default_key || "C"}
</p>
</div>
</div>
</button>
))
)}
</div>
{/* Profile Details */}
<div className="lg:col-span-2">
{selectedProfile ? (
<div className={`backdrop-blur-lg rounded-xl p-6 border ${bgCard}`}>
<div className="flex items-center space-x-4 mb-6">
<div
className="w-20 h-20 bg-gradient-to-br from-blue-500 to-purple-600
rounded-full flex items-center justify-center text-white text-3xl font-bold"
>
{selectedProfile.name?.charAt(0)?.toUpperCase() || "?"}
</div>
<div>
<h2 className={`text-2xl font-bold ${textPrimary}`}>
{selectedProfile.name}
</h2>
<div className={`flex items-center gap-2 ${textSecondary}`}>
<Key size={14} />
<span>
Default Key: {selectedProfile.default_key || "C"}
</span>
</div>
{selectedProfile.email && (
<div
className={`flex items-center gap-2 text-sm ${textMuted}`}
>
<Mail size={12} />
<span>{selectedProfile.email}</span>
</div>
)}
</div>
</div>
{/* Profile's Songs */}
<div>
<h3 className={`text-lg font-semibold ${textPrimary} mb-4`}>
Assigned Songs ({profileSongs.length})
</h3>
{profileSongs.length > 0 ? (
<div className="space-y-2 max-h-96 overflow-y-auto">
{profileSongs.map((song) => (
<div
key={song.id}
onClick={() => navigate(`/song/${song.id}`)}
className={`flex items-center justify-between rounded-lg p-4 cursor-pointer transition-all ${bgSongItem} ${isDark ? "hover:bg-white/10" : "hover:bg-gray-100 hover:shadow-sm"} border ${isDark ? "border-transparent" : "border-gray-200"}`}
>
<div className="flex items-center gap-3">
<div
className={`w-10 h-10 rounded-lg flex items-center justify-center ${isDark ? "bg-cyan-500/20" : "bg-cyan-100"}`}
>
<Music size={18} className="text-cyan-500" />
</div>
<div>
<p
className={`font-medium text-base ${textPrimary}`}
>
{song.title}
</p>
<p className={`text-sm ${textMuted}`}>
{song.artist}
</p>
</div>
</div>
<span
className={`px-3 py-1 rounded-lg text-sm font-bold ${isDark ? "bg-amber-500/20 text-amber-400" : "bg-amber-100 text-amber-700"}`}
>
{song.preferred_key ||
selectedProfile.default_key ||
"C"}
</span>
</div>
))}
</div>
) : (
<p className={textMuted}>No songs assigned to this profile</p>
)}
</div>
</div>
) : (
<div
className={`backdrop-blur-lg rounded-xl p-12 border flex items-center justify-center ${bgCard}`}
>
<div className="text-center">
<User size={48} className={`mx-auto mb-4 ${textMuted}`} />
<p className={`text-lg ${textMuted}`}>
Select a profile to view details
</p>
</div>
</div>
)}
</div>
</div>
{/* Add Profile Modal */}
{showAddModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div
className={`max-w-md w-full rounded-xl p-6 ${isDark ? "bg-slate-800 border border-white/10" : "bg-white border border-gray-200"}`}
>
<div className="flex justify-between items-center mb-4">
<h2 className={`text-xl font-bold ${textPrimary}`}>
Add New Profile
</h2>
<button
onClick={() => {
setShowAddModal(false);
setNewProfile({ name: "", email: "", default_key: "C" });
}}
className={`p-2 rounded-lg ${isDark ? "hover:bg-white/10" : "hover:bg-gray-100"}`}
>
<X size={20} className={textMuted} />
</button>
</div>
<div className="space-y-4">
<div>
<label
className={`block text-sm font-medium mb-2 ${textSecondary}`}
>
Name *
</label>
<input
type="text"
value={newProfile.name}
onChange={(e) =>
setNewProfile({ ...newProfile, name: e.target.value })
}
placeholder="Enter profile name"
className={`w-full px-4 py-2 rounded-lg border ${isDark ? "bg-white/5 border-white/10 text-white placeholder-white/40" : "bg-white border-gray-200 text-gray-900 placeholder-gray-400"}`}
/>
</div>
<div>
<label
className={`block text-sm font-medium mb-2 ${textSecondary}`}
>
Email
</label>
<input
type="email"
value={newProfile.email}
onChange={(e) =>
setNewProfile({ ...newProfile, email: e.target.value })
}
placeholder="optional@email.com"
className={`w-full px-4 py-2 rounded-lg border ${isDark ? "bg-white/5 border-white/10 text-white placeholder-white/40" : "bg-white border-gray-200 text-gray-900 placeholder-gray-400"}`}
/>
</div>
<div>
<label
className={`block text-sm font-medium mb-2 ${textSecondary}`}
>
Default Key
</label>
<select
value={newProfile.default_key}
onChange={(e) =>
setNewProfile({
...newProfile,
default_key: e.target.value,
})
}
className={`w-full px-4 py-2 rounded-lg border ${isDark ? "bg-white/5 border-white/10 text-white" : "bg-white border-gray-200 text-gray-900"}`}
>
{[
"C",
"C#",
"D",
"Eb",
"E",
"F",
"F#",
"G",
"Ab",
"A",
"Bb",
"B",
].map((key) => (
<option key={key} value={key}>
{key}
</option>
))}
</select>
</div>
<div className="flex gap-2 pt-2">
<button
onClick={() => {
setShowAddModal(false);
setNewProfile({ name: "", email: "", default_key: "C" });
}}
className={`flex-1 px-4 py-2 rounded-lg ${isDark ? "bg-white/10 hover:bg-white/20" : "bg-gray-100 hover:bg-gray-200"} ${textPrimary}`}
>
Cancel
</button>
<button
onClick={handleAddProfile}
className="flex-1 px-4 py-2 rounded-lg bg-blue-500 hover:bg-blue-600 text-white font-medium"
>
Add Profile
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,236 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTheme } from "@context/ThemeContext";
import { useAuth } from "@context/AuthContext";
import { Sun, Moon, Palette, Bell, Lock, Database, ChevronRight, LogOut, User } from "lucide-react";
import toast from "react-hot-toast";
export default function SettingsPage() {
const { theme, toggleTheme, isDark, accentColor, setAccentColor } = useTheme();
const { user, logout } = useAuth();
const navigate = useNavigate();
const [notifications, setNotifications] = useState(true);
const handleLogout = async () => {
try {
await logout();
toast.success("Logged out successfully");
navigate("/login");
} catch (error) {
toast.error("Logout failed");
}
};
const accentColors = [
{ name: 'violet', color: 'bg-violet-500', value: 'violet' },
{ name: 'blue', color: 'bg-blue-500', value: 'blue' },
{ name: 'cyan', color: 'bg-cyan-500', value: 'cyan' },
{ name: 'emerald', color: 'bg-emerald-500', value: 'emerald' },
{ name: 'amber', color: 'bg-amber-500', value: 'amber' },
{ name: 'rose', color: 'bg-rose-500', value: 'rose' },
];
const cardClasses = `rounded-2xl p-6 border backdrop-blur-lg transition-colors ${
isDark
? 'bg-white/10 border-white/20'
: 'bg-white border-gray-200 shadow-sm'
}`;
const labelClasses = `font-medium ${isDark ? 'text-white' : 'text-gray-900'}`;
const subLabelClasses = `text-sm ${isDark ? 'text-white/60' : 'text-gray-500'}`;
return (
<div className="max-w-2xl mx-auto space-y-6 pb-10">
<div>
<h1 className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-gray-900'}`}>
Settings
</h1>
<p className={subLabelClasses}>Customize your experience</p>
</div>
{/* Appearance */}
<div className={cardClasses}>
<h2 className={`text-xl font-semibold mb-5 flex items-center gap-2 ${isDark ? 'text-white' : 'text-gray-900'}`}>
<Palette size={22} className="text-violet-500" />
Appearance
</h2>
{/* Theme Toggle */}
<div className="flex items-center justify-between py-4 border-b border-white/10">
<div>
<p className={labelClasses}>Theme</p>
<p className={subLabelClasses}>Toggle between light and dark mode</p>
</div>
<button
onClick={toggleTheme}
className={`flex items-center gap-3 px-5 py-2.5 rounded-xl font-medium transition-all ${
isDark
? 'bg-amber-500/20 text-amber-300 hover:bg-amber-500/30'
: 'bg-slate-800 text-slate-100 hover:bg-slate-700'
}`}
>
{isDark ? (
<>
<Sun size={18} />
Light Mode
</>
) : (
<>
<Moon size={18} />
Dark Mode
</>
)}
</button>
</div>
{/* Accent Color */}
<div className="py-4">
<div className="mb-3">
<p className={labelClasses}>Accent Color</p>
<p className={subLabelClasses}>Choose your preferred accent color</p>
</div>
<div className="flex flex-wrap gap-3">
{accentColors.map(({ name, color, value }) => (
<button
key={value}
onClick={() => setAccentColor(value)}
className={`w-10 h-10 rounded-xl ${color} transition-transform hover:scale-110
${accentColor === value ? 'ring-2 ring-offset-2 ring-offset-slate-900 ring-white' : ''}`}
title={name}
/>
))}
</div>
</div>
</div>
{/* Notifications */}
<div className={cardClasses}>
<h2 className={`text-xl font-semibold mb-5 flex items-center gap-2 ${isDark ? 'text-white' : 'text-gray-900'}`}>
<Bell size={22} className="text-cyan-500" />
Notifications
</h2>
<div className="flex items-center justify-between">
<div>
<p className={labelClasses}>Push Notifications</p>
<p className={subLabelClasses}>Get notified about worship list updates</p>
</div>
<button
onClick={() => setNotifications(!notifications)}
className={`relative w-14 h-8 rounded-full transition-colors ${
notifications ? 'bg-emerald-500' : 'bg-white/20'
}`}
>
<div
className={`absolute top-1 w-6 h-6 bg-white rounded-full shadow-md transition-transform ${
notifications ? 'left-7' : 'left-1'
}`}
/>
</button>
</div>
</div>
{/* Security */}
<div className={cardClasses}>
<h2 className={`text-xl font-semibold mb-5 flex items-center gap-2 ${isDark ? 'text-white' : 'text-gray-900'}`}>
<Lock size={22} className="text-rose-500" />
Security
</h2>
<div className="space-y-3">
<button className={`w-full flex items-center justify-between p-4 rounded-xl transition-colors ${
isDark ? 'hover:bg-white/5' : 'hover:bg-gray-50'
}`}>
<div className="text-left">
<p className={labelClasses}>Change Password</p>
<p className={subLabelClasses}>Update your account password</p>
</div>
<ChevronRight size={20} className={isDark ? 'text-white/40' : 'text-gray-400'} />
</button>
<button className={`w-full flex items-center justify-between p-4 rounded-xl transition-colors ${
isDark ? 'hover:bg-white/5' : 'hover:bg-gray-50'
}`}>
<div className="text-left">
<p className={labelClasses}>Biometric Login</p>
<p className={subLabelClasses}>Use Face ID or fingerprint</p>
</div>
<ChevronRight size={20} className={isDark ? 'text-white/40' : 'text-gray-400'} />
</button>
</div>
</div>
{/* Data */}
<div className={cardClasses}>
<h2 className={`text-xl font-semibold mb-5 flex items-center gap-2 ${isDark ? 'text-white' : 'text-gray-900'}`}>
<Database size={22} className="text-amber-500" />
Data
</h2>
<div className="space-y-3">
<button className={`w-full flex items-center justify-between p-4 rounded-xl transition-colors ${
isDark ? 'hover:bg-white/5' : 'hover:bg-gray-50'
}`}>
<div className="text-left">
<p className={labelClasses}>Export Songs</p>
<p className={subLabelClasses}>Download all songs as JSON</p>
</div>
<ChevronRight size={20} className={isDark ? 'text-white/40' : 'text-gray-400'} />
</button>
<button className={`w-full flex items-center justify-between p-4 rounded-xl transition-colors ${
isDark ? 'hover:bg-white/5' : 'hover:bg-gray-50'
}`}>
<div className="text-left">
<p className={labelClasses}>Clear Cache</p>
<p className={subLabelClasses}>Free up storage space</p>
</div>
<ChevronRight size={20} className={isDark ? 'text-white/40' : 'text-gray-400'} />
</button>
</div>
</div>
{/* About */}
<div className={cardClasses}>
<h2 className={`text-xl font-semibold mb-4 ${isDark ? 'text-white' : 'text-gray-900'}`}>
About
</h2>
<div className={`space-y-2 ${isDark ? 'text-white/70' : 'text-gray-600'}`}>
<p className="font-semibold">HOP Worship Platform</p>
<p>Version 2.0.0</p>
<p>House of Praise Music Ministry</p>
<p className="text-sm mt-4 opacity-70">
© 2026 House of Praise. All rights reserved.
</p>
</div>
</div>
{/* Account Section */}
<div className={cardClasses}>
<h2 className={`text-xl font-semibold mb-5 flex items-center gap-2 ${isDark ? 'text-white' : 'text-gray-900'}`}>
<User size={22} className="text-blue-500" />
Account
</h2>
{/* User Info */}
<div className={`p-4 rounded-xl mb-4 ${isDark ? 'bg-white/5' : 'bg-gray-50'}`}>
<p className={`text-sm ${subLabelClasses}`}>Logged in as</p>
<p className={`font-semibold ${labelClasses}`}>
{user?.name || user?.username || 'User'}
</p>
<p className={`text-xs ${subLabelClasses}`}>
{user?.role === 'admin' ? 'Administrator' : 'User'}
</p>
</div>
{/* Logout Button */}
<button
onClick={handleLogout}
className={`w-full flex items-center justify-center gap-3 p-4 rounded-xl font-medium transition-all transform active:scale-[0.98] ${
isDark
? 'bg-red-500/20 hover:bg-red-500/30 text-red-400 border border-red-500/30'
: 'bg-red-50 hover:bg-red-100 text-red-600 border border-red-200'
}`}
>
<LogOut size={20} />
<span>Sign Out</span>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,790 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { useParams, useNavigate, useLocation } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import {
Save,
X,
Music,
Mic2,
Home,
Wand2,
Check,
RotateCcw,
ArrowLeft,
AlertCircle,
} from "lucide-react";
import api from "@utils/api";
import { useTheme } from "@context/ThemeContext";
import LyricsRichTextEditor from "@components/LyricsRichTextEditor";
// Chord definitions
const CHORD_ROOTS = [
"C",
"C#",
"D",
"D#",
"E",
"F",
"F#",
"G",
"G#",
"A",
"A#",
"B",
];
const CHORD_TYPES = [
"",
"m",
"7",
"m7",
"maj7",
"dim",
"aug",
"sus2",
"sus4",
"add9",
];
// Common chord progressions
const COMMON_PROGRESSIONS = [
{ name: "I-V-vi-IV (Pop)", chords: ["C", "G", "Am", "F"] },
{ name: "I-IV-V-I (Blues)", chords: ["G", "C", "D", "G"] },
{ name: "vi-IV-I-V (Emotional)", chords: ["Am", "F", "C", "G"] },
{ name: "I-vi-IV-V (50s)", chords: ["C", "Am", "F", "G"] },
{ name: "ii-V-I (Jazz)", chords: ["Dm7", "G7", "Cmaj7"] },
{ name: "I-V-vi-iii-IV (Canon)", chords: ["D", "A", "Bm", "F#m", "G"] },
];
// Section headers to skip when applying chords
const SECTION_HEADERS = [
"verse",
"chorus",
"bridge",
"pre-chorus",
"prechorus",
"pre chorus",
"intro",
"outro",
"interlude",
"hook",
"tag",
"ending",
"instrumental",
"v1",
"v2",
"v3",
"v4",
"c1",
"c2",
"c3",
"ch1",
"ch2",
"ch3",
"verse 1",
"verse 2",
"verse 3",
"verse 4",
"chorus 1",
"chorus 2",
"chorus 3",
"bridge 1",
"bridge 2",
"[verse]",
"[chorus]",
"[bridge]",
"[intro]",
"[outro]",
"(verse)",
"(chorus)",
"(bridge)",
"(intro)",
"(outro)",
];
export default function SongEditorPage() {
const { id } = useParams();
const navigate = useNavigate();
const location = useLocation();
const { isDark } = useTheme();
// Check if we're creating a new song (no id in params)
const isNew = !id;
// Check for uploaded data from location state
const uploadedData = location.state?.uploadedData;
const [form, setForm] = useState({
title: uploadedData?.title || "",
artist: uploadedData?.artist || "",
singer: "",
band: "",
key_chord: uploadedData?.key_chord || uploadedData?.chords || "",
lyrics: uploadedData?.lyrics || "",
});
const [loading, setLoading] = useState(!isNew && !uploadedData);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(isNew ? "" : "");
// Chord Progression State
const [showChordPanel, setShowChordPanel] = useState(false);
const [selectedProgression, setSelectedProgression] = useState(null);
const [customProgression, setCustomProgression] = useState([]);
const [progressionMode, setProgressionMode] = useState("preset");
// Fetch existing song if editing
useEffect(() => {
console.log("SongEditorPage mounted - isNew:", isNew, "id:", id);
if (!isNew) {
const fetchSong = async () => {
try {
const res = await api.get(`/songs/${id}`);
if (res.data.success) {
setForm(res.data.song);
setError(""); // Clear any previous errors
}
} catch (err) {
console.error("Error loading song:", err);
setError("Failed to load song");
} finally {
setLoading(false);
}
};
fetchSong();
} else {
// For new songs, ensure loading is false and clear any errors
console.log("Creating new song - clearing errors");
setLoading(false);
setError("");
}
}, [id, isNew]);
// Check if a line is a section header
const isSectionHeader = (line) => {
const trimmed = line.trim().toLowerCase();
if (!trimmed) return false;
if (SECTION_HEADERS.some((h) => trimmed === h || trimmed === h + ":"))
return true;
if (
/^[\[\(]?(?:verse|chorus|bridge|intro|outro|pre-?chorus|interlude|hook|tag|ending|instrumental|v\d|c\d|ch\d)[\d\s]*[\]\)]?:?$/i.test(
trimmed,
)
) {
return true;
}
return false;
};
// Apply chord progression to lyrics
const applyChordProgression = useCallback((lyrics, progression) => {
if (!lyrics || !progression || progression.length === 0) return lyrics;
// First remove existing chords
const cleanLyrics = lyrics.replace(/\[[^\]]+\]/g, "");
const lines = cleanLyrics.split("\n");
let chordIndex = 0;
const processedLines = lines.map((line) => {
const trimmed = line.trim();
if (!trimmed || isSectionHeader(trimmed)) return line;
const chord = progression[chordIndex % progression.length];
chordIndex++;
return `[${chord}]${line}`;
});
return processedLines.join("\n");
}, []);
// Handle apply progression
const handleApplyProgression = () => {
const progression =
progressionMode === "preset"
? selectedProgression?.chords
: customProgression;
if (!progression || progression.length === 0) return;
const newLyrics = applyChordProgression(form.lyrics, progression);
setForm({ ...form, lyrics: newLyrics });
setShowChordPanel(false);
};
// Remove all chords
const handleRemoveChords = () => {
const cleanLyrics = (form.lyrics || "").replace(/\[[^\]]+\]/g, "");
setForm({ ...form, lyrics: cleanLyrics });
};
// Add chord to custom progression
const addToCustomProgression = (root) => {
setCustomProgression((prev) => [...prev, root]);
};
// Remove chord from custom progression
const removeFromCustomProgression = (index) => {
setCustomProgression((prev) => prev.filter((_, i) => i !== index));
};
// Convert pasted chord-over-lyrics format to embedded [Chord] format
const convertChordsOverLyrics = (text) => {
if (!text) return { lyrics: "", detectedChords: [] };
const lines = text.split("\n");
const result = [];
const allChords = new Set();
// Chord pattern: line with mostly chords and spaces
const chordPattern =
/^[\sA-G#b/()m\d]*[A-G][#b]?(?:m|maj|min|dim|aug|sus|add|2|4|5|6|7|9|11|13)*[\sA-G#b/()m\d]*$/;
const singleChordPattern =
/([A-G][#b]?(?:m|maj|min|dim|aug|sus|add)?[0-9]*)/g;
console.log("🎵 Converting chords-over-lyrics format...");
console.log("Total lines:", lines.length);
for (let i = 0; i < lines.length; i++) {
const currentLine = lines[i];
const nextLine = lines[i + 1];
// Check if current line is a chord line (has chords, next line is lyrics)
const isChordLine =
currentLine &&
currentLine.trim().length > 0 &&
chordPattern.test(currentLine.trim()) &&
nextLine !== undefined;
if (isChordLine && nextLine) {
console.log(`Line ${i} is chord line:`, currentLine);
console.log(`Line ${i + 1} is lyrics:`, nextLine);
// Extract chords with their positions
const chords = [];
let match;
const chordRegex = /([A-G][#b]?(?:m|maj|min|dim|aug|sus|add)?[0-9]*)/g;
while ((match = chordRegex.exec(currentLine)) !== null) {
chords.push({
chord: match[1],
position: match.index,
});
allChords.add(match[1]);
}
console.log("Detected chords:", chords);
// Build lyric line with embedded chords
let embeddedLine = "";
let lastPos = 0;
chords.forEach(({ chord, position }) => {
// Get text from lyrics line up to chord position
const textUpToChord = nextLine.substring(lastPos, position);
embeddedLine += textUpToChord;
// Add chord in brackets
embeddedLine += `[${chord}]`;
lastPos = position;
});
// Add remaining lyrics
embeddedLine += nextLine.substring(lastPos);
console.log("Embedded line:", embeddedLine);
result.push(embeddedLine);
// Skip the lyrics line since we processed it
i++;
} else if (!isChordLine) {
// Regular line (not a chord line)
result.push(currentLine);
}
// Skip standalone chord lines that don't have lyrics below
}
console.log("✅ Conversion complete!");
console.log("Detected chords:", Array.from(allChords));
console.log("Result lyrics:", result.join("\n"));
return {
lyrics: result.join("\n"),
detectedChords: Array.from(allChords),
};
};
// Save song
const handleSave = async () => {
if (!form.title.trim()) {
setError("Title is required");
return;
}
setSaving(true);
setError("");
try {
// Strip HTML tags from lyrics if any
let cleanLyrics = form.lyrics || "";
const tempDiv = document.createElement("div");
tempDiv.innerHTML = cleanLyrics;
cleanLyrics = tempDiv.textContent || tempDiv.innerText || cleanLyrics;
// Convert chord-over-lyrics format to embedded [Chord] format
const { lyrics: convertedLyrics, detectedChords } =
convertChordsOverLyrics(cleanLyrics);
// Auto-detect key from first chord if key not set
let songKey = form.key_chord;
if (!songKey && detectedChords.length > 0) {
songKey = detectedChords[0];
}
// Build the data to save
const saveData = {
...form,
lyrics: convertedLyrics,
key_chord: songKey || form.key_chord || "",
chords: detectedChords.join(", "),
};
let res;
if (isNew) {
res = await api.post("/songs", saveData);
} else {
res = await api.put(`/songs/${id}`, saveData);
}
if (res.data.success) {
navigate(`/song/${res.data.song?.id || id}`);
} else {
setError(res.data.message || "Failed to save song");
}
} catch (err) {
setError(err.response?.data?.message || "Failed to save song");
} finally {
setSaving(false);
}
};
// Theme-aware classes
const textPrimary = isDark ? "text-white" : "text-gray-900";
const textSecondary = isDark ? "text-white/70" : "text-gray-600";
const textMuted = isDark ? "text-white/50" : "text-gray-500";
const bgCard = isDark ? "bg-slate-800/80" : "bg-white";
const borderColor = isDark ? "border-white/10" : "border-gray-200";
const inputClass = `w-full px-4 py-3 rounded-lg outline-none transition-all
${
isDark
? "bg-white/5 border border-white/10 focus:border-cyan-500/50 text-white placeholder-white/40"
: "bg-gray-50 border border-gray-200 focus:border-cyan-500 text-gray-900 placeholder-gray-400"
}`;
if (loading) {
return (
<div
className={`min-h-screen flex items-center justify-center ${textMuted}`}
>
<div className="text-center">
<div className="w-12 h-12 border-2 border-t-cyan-500 border-cyan-500/20 rounded-full animate-spin mx-auto mb-4"></div>
<p>Loading song...</p>
</div>
</div>
);
}
return (
<div className="max-w-4xl mx-auto pb-20">
{/* Header */}
<div className="mb-6">
<button
onClick={() => navigate(-1)}
className={`flex items-center gap-2 mb-4 ${textMuted} hover:text-cyan-500 transition-colors`}
>
<ArrowLeft size={16} />
<span className="text-sm">Back</span>
</button>
<div className="flex items-start justify-between gap-4">
<div>
<h1
className={`text-2xl sm:text-3xl font-bold ${textPrimary} mb-2`}
>
{isNew ? "Create New Song" : "Edit Song"}
</h1>
<p className={textMuted}>
{isNew
? "Add a new song to your database"
: `Editing: ${form.title || "Untitled"}`}
</p>
</div>
<button
onClick={() => setShowChordPanel(!showChordPanel)}
className={`p-2.5 rounded-lg transition-colors
${
showChordPanel
? "bg-violet-500 text-white"
: isDark
? "bg-white/10 text-white hover:bg-white/20"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
<Wand2 size={18} />
</button>
</div>
</div>
{/* Error Message */}
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6 p-4 rounded-lg bg-red-500/10 border border-red-500/30 flex items-center gap-3 text-red-500"
>
<AlertCircle size={18} />
<span className="text-sm">{error}</span>
<button onClick={() => setError("")} className="ml-auto">
<X size={16} />
</button>
</motion.div>
)}
{/* Chord Progression Panel */}
<AnimatePresence>
{showChordPanel && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className={`mb-6 rounded-xl overflow-hidden border ${borderColor} ${bgCard}`}
>
<div className="p-5">
<div className="flex items-center justify-between mb-4">
<h3
className={`font-semibold ${textPrimary} flex items-center gap-2`}
>
<Wand2 size={18} className="text-violet-500" />
Apply Chord Progression
</h3>
<button
onClick={() => setShowChordPanel(false)}
className={`p-1.5 rounded-lg ${isDark ? "hover:bg-white/10" : "hover:bg-gray-100"}`}
>
<X size={16} className={textMuted} />
</button>
</div>
{/* Mode Toggle */}
<div
className={`flex gap-2 mb-4 p-1 rounded-lg ${isDark ? "bg-white/5" : "bg-gray-100"}`}
>
<button
onClick={() => setProgressionMode("preset")}
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors
${
progressionMode === "preset"
? isDark
? "bg-white/10 text-white"
: "bg-white text-gray-900 shadow-sm"
: textMuted
}`}
>
Common Progressions
</button>
<button
onClick={() => setProgressionMode("custom")}
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-colors
${
progressionMode === "custom"
? isDark
? "bg-white/10 text-white"
: "bg-white text-gray-900 shadow-sm"
: textMuted
}`}
>
Build Custom
</button>
</div>
{/* Preset Progressions */}
{progressionMode === "preset" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mb-4">
{COMMON_PROGRESSIONS.map((prog, idx) => (
<button
key={idx}
onClick={() => setSelectedProgression(prog)}
className={`p-3 rounded-lg text-left transition-all border
${
selectedProgression === prog
? isDark
? "bg-violet-500/20 border-violet-500/50 text-white"
: "bg-violet-100 border-violet-400 text-violet-900"
: isDark
? "bg-white/5 border-white/10 hover:bg-white/10 text-white"
: "bg-gray-50 border-gray-200 hover:bg-gray-100 text-gray-900"
}`}
>
<div className="font-medium text-sm mb-1">
{prog.name}
</div>
<div
className={`text-xs ${isDark ? "text-white/60" : "text-gray-500"}`}
>
{prog.chords.join(" → ")}
</div>
</button>
))}
</div>
)}
{/* Custom Progression Builder */}
{progressionMode === "custom" && (
<div className="mb-4">
<div
className={`min-h-[48px] p-3 rounded-lg mb-3 flex flex-wrap gap-2 items-center
${isDark ? "bg-white/5 border border-white/10" : "bg-gray-50 border border-gray-200"}`}
>
{customProgression.length === 0 ? (
<span className={textMuted + " text-sm"}>
Click chords below to build progression...
</span>
) : (
customProgression.map((chord, idx) => (
<span
key={idx}
onClick={() => removeFromCustomProgression(idx)}
className={`px-3 py-1.5 rounded-lg text-sm font-bold cursor-pointer transition-colors
${
isDark
? "bg-amber-500/20 text-amber-400 hover:bg-red-500/20 hover:text-red-400"
: "bg-amber-100 text-amber-700 hover:bg-red-100 hover:text-red-700"
}`}
>
{chord} ×
</span>
))
)}
</div>
<div className="grid grid-cols-6 sm:grid-cols-12 gap-1">
{CHORD_ROOTS.map((root) => (
<button
key={root}
onClick={() => addToCustomProgression(root)}
className={`p-2 rounded text-sm font-medium transition-colors
${
isDark
? "bg-white/5 hover:bg-white/15 text-white"
: "bg-gray-100 hover:bg-gray-200 text-gray-900"
}`}
>
{root}
</button>
))}
</div>
<div className="grid grid-cols-5 gap-1 mt-2">
{CHORD_TYPES.slice(1).map((type) => (
<button
key={type}
onClick={() =>
customProgression.length > 0 &&
setCustomProgression((prev) => {
const updated = [...prev];
const last = updated[updated.length - 1];
if (
!CHORD_TYPES.slice(1).some((t) =>
last.endsWith(t),
)
) {
updated[updated.length - 1] = last + type;
}
return updated;
})
}
className={`p-2 rounded text-xs font-medium transition-colors
${
isDark
? "bg-white/5 hover:bg-white/15 text-white"
: "bg-gray-100 hover:bg-gray-200 text-gray-900"
}`}
>
+{type}
</button>
))}
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex flex-wrap gap-2">
<button
onClick={handleApplyProgression}
disabled={
(progressionMode === "preset" && !selectedProgression) ||
(progressionMode === "custom" &&
customProgression.length === 0)
}
className="px-4 py-2 rounded-lg text-sm font-medium bg-violet-500 hover:bg-violet-600 text-white transition-colors flex items-center gap-2 disabled:opacity-40"
>
<Check size={14} />
Apply to Lyrics
</button>
<button
onClick={handleRemoveChords}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2
${
isDark
? "bg-red-500/20 hover:bg-red-500/30 text-red-400"
: "bg-red-100 hover:bg-red-200 text-red-700"
}`}
>
<RotateCcw size={14} />
Remove All Chords
</button>
</div>
<p className={`mt-3 text-xs ${textMuted}`}>
💡 Chords apply to each lyric line. Section headers are
automatically skipped.
</p>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Form */}
<div className={`rounded-2xl p-6 sm:p-8 border ${borderColor} ${bgCard}`}>
<div className="space-y-5">
{/* Title */}
<div>
<label
className={`block text-sm font-medium ${textSecondary} mb-2`}
>
Title <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
placeholder="Enter song title..."
className={inputClass}
/>
</div>
{/* Artist & Key Row */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label
className={`block text-sm font-medium ${textSecondary} mb-2`}
>
Artist
</label>
<input
type="text"
value={form.artist}
onChange={(e) => setForm({ ...form, artist: e.target.value })}
placeholder="Original artist..."
className={inputClass}
/>
</div>
<div>
<label
className={`block text-sm font-medium ${textSecondary} mb-2`}
>
Key
</label>
<select
value={form.key_chord}
onChange={(e) =>
setForm({ ...form, key_chord: e.target.value })
}
className={inputClass}
>
<option value="">Select key...</option>
{CHORD_ROOTS.map((root) => (
<option key={root} value={root}>
{root}
</option>
))}
{CHORD_ROOTS.map((root) => (
<option key={root + "m"} value={root + "m"}>
{root}m
</option>
))}
</select>
</div>
</div>
{/* Singer & Band Row */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label
className={`block text-sm font-medium ${textSecondary} mb-2`}
>
Singer
</label>
<input
type="text"
value={form.singer}
onChange={(e) => setForm({ ...form, singer: e.target.value })}
placeholder="Who sings this at your church..."
className={inputClass}
/>
</div>
<div>
<label
className={`block text-sm font-medium ${textSecondary} mb-2`}
>
Band
</label>
<input
type="text"
value={form.band}
onChange={(e) => setForm({ ...form, band: e.target.value })}
placeholder="Hillsong, Bethel, Elevation, etc..."
className={inputClass}
/>
</div>
</div>
{/* Lyrics - Rich Text Editor */}
<div>
<label
className={`block text-sm font-medium ${textSecondary} mb-2`}
>
Lyrics
<span className={`ml-2 text-xs ${textMuted}`}>
Copy and paste from any source with formatting preserved
</span>
</label>
<LyricsRichTextEditor
content={form.lyrics || ""}
onChange={(html) => setForm({ ...form, lyrics: html })}
placeholder="Enter your lyrics here... You can paste from Word, websites, or type directly with full formatting support."
/>
</div>
{/* Actions */}
<div className="flex gap-3 pt-4">
<button
onClick={handleSave}
disabled={saving}
className="flex-1 py-3 rounded-lg bg-cyan-500 hover:bg-cyan-600 disabled:opacity-50 text-white font-medium transition-colors flex items-center justify-center gap-2"
>
<Save size={18} />
{saving ? "Saving..." : isNew ? "Create Song" : "Save Changes"}
</button>
<button
onClick={() => navigate(-1)}
className={`px-6 py-3 rounded-lg transition-colors
${
isDark
? "bg-white/10 hover:bg-white/20 text-white"
: "bg-gray-100 hover:bg-gray-200 text-gray-900"
}`}
>
Cancel
</button>
</div>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,590 @@
/**
* Global Data Store with Caching and Request Deduplication
*
* This store provides centralized data management for the worship platform.
* Features:
* - In-memory caching with configurable TTL (time-to-live)
* - Request deduplication (prevents multiple identical API calls)
* - Stale-while-revalidate pattern
* - Automatic cache invalidation on mutations
* - Optimistic updates for better UX
*/
import { create } from "zustand";
import api from "@utils/api";
// Cache TTL configuration (in milliseconds)
const CACHE_TTL = {
songs: 5 * 60 * 1000, // 5 minutes - songs don't change often
lists: 2 * 60 * 1000, // 2 minutes - lists may change more frequently
profiles: 5 * 60 * 1000, // 5 minutes - profiles rarely change
stats: 1 * 60 * 1000, // 1 minute - stats are quick to fetch
songDetail: 5 * 60 * 1000, // 5 minutes - individual song details
};
// Track in-flight requests to prevent duplicates
const inFlightRequests = new Map();
/**
* Execute a request with deduplication
* If the same request is already in flight, return that promise instead
*/
const deduplicatedFetch = async (key, fetchFn) => {
// If request is already in flight, return existing promise
if (inFlightRequests.has(key)) {
return inFlightRequests.get(key);
}
// Create new request promise
const requestPromise = fetchFn().finally(() => {
// Clean up after request completes
inFlightRequests.delete(key);
});
inFlightRequests.set(key, requestPromise);
return requestPromise;
};
/**
* Check if cached data is still valid
*/
const isCacheValid = (lastFetched, ttl) => {
if (!lastFetched) return false;
return Date.now() - lastFetched < ttl;
};
/**
* Main data store using Zustand
*/
const useDataStore = create((set, get) => ({
// ================== SONGS ==================
songs: [],
songsLastFetched: null,
songsLoading: false,
songsError: null,
/**
* Fetch all songs with caching
* @param {boolean} force - Force refresh even if cache is valid
*/
fetchSongs: async (force = false) => {
const state = get();
// Return cached data if still valid
if (!force && isCacheValid(state.songsLastFetched, CACHE_TTL.songs)) {
return state.songs;
}
// Set loading state (but keep existing data for stale-while-revalidate)
set({ songsLoading: true, songsError: null });
try {
const songs = await deduplicatedFetch("songs", async () => {
const res = await api.get("/songs?limit=200");
if (res.data.success) {
return res.data.songs;
}
throw new Error("Failed to fetch songs");
});
set({
songs,
songsLastFetched: Date.now(),
songsLoading: false,
});
return songs;
} catch (error) {
set({ songsError: error.message, songsLoading: false });
return state.songs; // Return stale data on error
}
},
/**
* Get a single song - checks local cache first
*/
getSong: (id) => {
const state = get();
return state.songs.find((s) => s.id === id) || null;
},
/**
* Invalidate songs cache (call after mutations)
*/
invalidateSongs: () => {
set({ songsLastFetched: null });
},
/**
* Update a song in the local cache (optimistic update)
*/
updateSongInCache: (songId, updates) => {
set((state) => ({
songs: state.songs.map((song) =>
song.id === songId ? { ...song, ...updates } : song,
),
}));
},
/**
* Add a song to the local cache
*/
addSongToCache: (song) => {
set((state) => ({
songs: [song, ...state.songs],
}));
},
/**
* Remove a song from the local cache
*/
removeSongFromCache: (songId) => {
set((state) => ({
songs: state.songs.filter((song) => song.id !== songId),
}));
},
// ================== SONG DETAILS ==================
songDetails: {}, // Map of song id -> { data, lastFetched }
songDetailsLoading: {},
/**
* Fetch a single song's full details
*/
fetchSongDetail: async (id, force = false) => {
const state = get();
const cached = state.songDetails[id];
// Return cached data if still valid
if (
!force &&
cached &&
isCacheValid(cached.lastFetched, CACHE_TTL.songDetail)
) {
return cached.data;
}
// Set loading state
set((state) => ({
songDetailsLoading: { ...state.songDetailsLoading, [id]: true },
}));
try {
const song = await deduplicatedFetch(`song-${id}`, async () => {
const res = await api.get(`/songs/${id}`);
if (res.data.success) {
return res.data.song;
}
throw new Error("Failed to fetch song");
});
set((state) => ({
songDetails: {
...state.songDetails,
[id]: { data: song, lastFetched: Date.now() },
},
songDetailsLoading: { ...state.songDetailsLoading, [id]: false },
}));
// Also update the song in the songs list if it exists
const existingIndex = get().songs.findIndex((s) => s.id === id);
if (existingIndex !== -1) {
set((state) => ({
songs: state.songs.map((s) => (s.id === id ? { ...s, ...song } : s)),
}));
}
return song;
} catch (error) {
set((state) => ({
songDetailsLoading: { ...state.songDetailsLoading, [id]: false },
}));
return cached?.data || null;
}
},
/**
* Invalidate a specific song's cache
*/
invalidateSongDetail: (id) => {
set((state) => ({
songDetails: {
...state.songDetails,
[id]: { ...state.songDetails[id], lastFetched: null },
},
}));
},
// ================== WORSHIP LISTS ==================
lists: [],
listsLastFetched: null,
listsLoading: false,
listsError: null,
/**
* Fetch all worship lists
*/
fetchLists: async (force = false) => {
const state = get();
if (!force && isCacheValid(state.listsLastFetched, CACHE_TTL.lists)) {
return state.lists;
}
set({ listsLoading: true, listsError: null });
try {
const lists = await deduplicatedFetch("lists", async () => {
const res = await api.get("/lists");
if (res.data.success) {
return res.data.lists;
}
throw new Error("Failed to fetch lists");
});
set({
lists,
listsLastFetched: Date.now(),
listsLoading: false,
});
return lists;
} catch (error) {
set({ listsError: error.message, listsLoading: false });
return state.lists;
}
},
/**
* Invalidate lists cache
*/
invalidateLists: () => {
set({ listsLastFetched: null });
},
/**
* Update a list in the local cache
*/
updateListInCache: (listId, updates) => {
set((state) => ({
lists: state.lists.map((list) =>
list.id === listId ? { ...list, ...updates } : list,
),
}));
},
/**
* Add a list to the local cache
*/
addListToCache: (list) => {
set((state) => ({
lists: [list, ...state.lists],
}));
},
/**
* Remove a list from the local cache
*/
removeListFromCache: (listId) => {
set((state) => ({
lists: state.lists.filter((list) => list.id !== listId),
}));
},
// ================== LIST DETAILS (songs in a list) ==================
listDetails: {}, // Map of list id -> { songs, lastFetched }
listDetailsLoading: {},
/**
* Fetch songs for a specific list
*/
fetchListDetail: async (listId, force = false) => {
const state = get();
const cached = state.listDetails[listId];
if (!force && cached && isCacheValid(cached.lastFetched, CACHE_TTL.lists)) {
return cached.songs;
}
set((state) => ({
listDetailsLoading: { ...state.listDetailsLoading, [listId]: true },
}));
try {
const songs = await deduplicatedFetch(`list-${listId}`, async () => {
const res = await api.get(`/lists/${listId}`);
if (res.data.success) {
return res.data.songs || [];
}
throw new Error("Failed to fetch list details");
});
set((state) => ({
listDetails: {
...state.listDetails,
[listId]: { songs, lastFetched: Date.now() },
},
listDetailsLoading: { ...state.listDetailsLoading, [listId]: false },
}));
return songs;
} catch (error) {
set((state) => ({
listDetailsLoading: { ...state.listDetailsLoading, [listId]: false },
}));
return cached?.songs || [];
}
},
/**
* Invalidate a specific list's details
*/
invalidateListDetail: (listId) => {
set((state) => ({
listDetails: {
...state.listDetails,
[listId]: { ...state.listDetails[listId], lastFetched: null },
},
}));
},
// ================== PROFILES ==================
profiles: [],
profilesLastFetched: null,
profilesLoading: false,
profilesError: null,
/**
* Fetch all profiles
*/
fetchProfiles: async (force = false) => {
const state = get();
if (!force && isCacheValid(state.profilesLastFetched, CACHE_TTL.profiles)) {
return state.profiles;
}
set({ profilesLoading: true, profilesError: null });
try {
const profiles = await deduplicatedFetch("profiles", async () => {
const res = await api.get("/profiles");
if (res.data.success) {
return res.data.profiles || [];
}
throw new Error("Failed to fetch profiles");
});
set({
profiles,
profilesLastFetched: Date.now(),
profilesLoading: false,
});
return profiles;
} catch (error) {
set({ profilesError: error.message, profilesLoading: false });
return state.profiles;
}
},
/**
* Invalidate profiles cache
*/
invalidateProfiles: () => {
set({ profilesLastFetched: null });
},
// ================== STATS ==================
stats: { songs: 0, profiles: 0, lists: 0 },
statsLastFetched: null,
statsLoading: false,
/**
* Fetch stats
*/
fetchStats: async (force = false) => {
const state = get();
if (!force && isCacheValid(state.statsLastFetched, CACHE_TTL.stats)) {
return state.stats;
}
set({ statsLoading: true });
try {
const stats = await deduplicatedFetch("stats", async () => {
const res = await api.get("/stats");
if (res.data.success) {
return res.data.stats;
}
throw new Error("Failed to fetch stats");
});
set({
stats,
statsLastFetched: Date.now(),
statsLoading: false,
});
return stats;
} catch (error) {
set({ statsLoading: false });
return state.stats;
}
},
// ================== SEARCH ==================
searchCache: {}, // Map of query -> { results, timestamp }
searchLoading: false,
/**
* Search songs with caching
*/
searchSongs: async (query) => {
const state = get();
const trimmedQuery = query.trim().toLowerCase();
if (!trimmedQuery) {
return [];
}
// Check search cache (short TTL of 30 seconds)
const cached = state.searchCache[trimmedQuery];
if (cached && Date.now() - cached.timestamp < 30000) {
return cached.results;
}
// First, try to search locally in the songs array
const localResults = state.songs
.filter(
(song) =>
song.title?.toLowerCase().includes(trimmedQuery) ||
song.artist?.toLowerCase().includes(trimmedQuery) ||
song.singer?.toLowerCase().includes(trimmedQuery),
)
.slice(0, 20);
// If we have songs cached and get results, use local search
if (state.songs.length > 0 && localResults.length > 0) {
set((state) => ({
searchCache: {
...state.searchCache,
[trimmedQuery]: { results: localResults, timestamp: Date.now() },
},
}));
return localResults;
}
// Otherwise, fall back to API search
set({ searchLoading: true });
try {
const results = await deduplicatedFetch(
`search-${trimmedQuery}`,
async () => {
const res = await api.get(
`/songs/search?q=${encodeURIComponent(trimmedQuery)}`,
);
if (res.data.success) {
return res.data.songs || [];
}
throw new Error("Search failed");
},
);
set((state) => ({
searchCache: {
...state.searchCache,
[trimmedQuery]: { results, timestamp: Date.now() },
},
searchLoading: false,
}));
return results;
} catch (error) {
set({ searchLoading: false });
return localResults; // Fall back to local results
}
},
/**
* Clear search cache
*/
clearSearchCache: () => {
set({ searchCache: {} });
},
// ================== GLOBAL CACHE MANAGEMENT ==================
/**
* Invalidate all caches (use after major changes)
*/
invalidateAll: () => {
set({
songsLastFetched: null,
listsLastFetched: null,
profilesLastFetched: null,
statsLastFetched: null,
songDetails: {},
listDetails: {},
searchCache: {},
});
},
/**
* Prefetch common data (call on app initialization)
*/
prefetch: async () => {
// Fetch in parallel
await Promise.all([
get().fetchSongs(),
get().fetchLists(),
get().fetchStats(),
]);
},
/**
* Get cache status (for debugging)
*/
getCacheStatus: () => {
const state = get();
return {
songs: {
count: state.songs.length,
lastFetched: state.songsLastFetched,
valid: isCacheValid(state.songsLastFetched, CACHE_TTL.songs),
},
lists: {
count: state.lists.length,
lastFetched: state.listsLastFetched,
valid: isCacheValid(state.listsLastFetched, CACHE_TTL.lists),
},
profiles: {
count: state.profiles.length,
lastFetched: state.profilesLastFetched,
valid: isCacheValid(state.profilesLastFetched, CACHE_TTL.profiles),
},
stats: {
lastFetched: state.statsLastFetched,
valid: isCacheValid(state.statsLastFetched, CACHE_TTL.stats),
},
};
},
}));
export default useDataStore;
// Export individual selectors for better performance
export const useSongs = () => useDataStore((state) => state.songs);
export const useSongsLoading = () =>
useDataStore((state) => state.songsLoading);
export const useLists = () => useDataStore((state) => state.lists);
export const useListsLoading = () =>
useDataStore((state) => state.listsLoading);
export const useProfiles = () => useDataStore((state) => state.profiles);
export const useProfilesLoading = () =>
useDataStore((state) => state.profilesLoading);
export const useStats = () => useDataStore((state) => state.stats);
export const useStatsLoading = () =>
useDataStore((state) => state.statsLoading);

View File

@@ -0,0 +1,35 @@
import axios from "axios";
const api = axios.create({
baseURL: "/api",
timeout: 30000, // 30 seconds for slow operations like saving large lyrics
headers: {
"Content-Type": "application/json",
},
});
// Request interceptor to add auth token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem("authToken");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error),
);
// Response interceptor for error handling
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem("authToken");
window.location.href = "/login";
}
return Promise.reject(error);
},
);
export default api;

View File

@@ -0,0 +1,214 @@
/**
* Biometric Authentication Utility
* Supports Face ID, Touch ID, Windows Hello, and Android Biometric
*/
// Check if biometric authentication is available
export const isBiometricAvailable = async () => {
// Check if browser supports Web Authentication API
if (!window.PublicKeyCredential) {
return false;
}
try {
// Check if platform authenticator is available (Face ID, Touch ID, etc.)
const available =
await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
return available;
} catch (error) {
// Silently fail for production
return false;
}
};
// Get biometric type description
export const getBiometricType = () => {
const ua = navigator.userAgent;
if (/iPhone|iPad|iPod/.test(ua)) {
// iOS devices - could be Face ID or Touch ID
return "Biometric Authentication";
} else if (/Android/.test(ua)) {
return "Biometric Authentication";
} else if (/Windows/.test(ua)) {
return "Biometric Authentication";
} else if (/Mac/.test(ua)) {
return "Biometric Authentication";
}
return "Biometric Authentication";
};
// Register biometric credential
export const registerBiometric = async (username, userId) => {
try {
// Generate challenge from server (in production, get this from server)
const challenge = new Uint8Array(32);
crypto.getRandomValues(challenge);
const publicKeyCredentialCreationOptions = {
challenge,
rp: {
name: "HOP Worship Platform",
id: window.location.hostname,
},
user: {
id: new TextEncoder().encode(userId.toString()),
name: username,
displayName: username,
},
pubKeyCredParams: [
{ alg: -7, type: "public-key" }, // ES256
{ alg: -257, type: "public-key" }, // RS256
],
authenticatorSelection: {
authenticatorAttachment: "platform", // Use platform authenticator (Face ID, Touch ID, etc.)
userVerification: "required",
requireResidentKey: false,
},
timeout: 60000,
attestation: "none",
};
const credential = await navigator.credentials.create({
publicKey: publicKeyCredentialCreationOptions,
});
// Convert credential to base64 for storage
const credentialData = {
id: credential.id,
rawId: arrayBufferToBase64(credential.rawId),
type: credential.type,
response: {
attestationObject: arrayBufferToBase64(
credential.response.attestationObject,
),
clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON),
},
};
return credentialData;
} catch (error) {
console.error("Biometric registration error:", error);
throw new Error(getBiometricErrorMessage(error));
}
};
// Authenticate using biometric
export const authenticateWithBiometric = async (credentialId) => {
try {
// Generate challenge
const challenge = new Uint8Array(32);
crypto.getRandomValues(challenge);
const publicKeyCredentialRequestOptions = {
challenge,
allowCredentials: credentialId
? [
{
id: base64ToArrayBuffer(credentialId),
type: "public-key",
transports: ["internal"],
},
]
: [],
userVerification: "required",
timeout: 60000,
};
const assertion = await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions,
});
// Convert assertion to base64
const assertionData = {
id: assertion.id,
rawId: arrayBufferToBase64(assertion.rawId),
type: assertion.type,
response: {
authenticatorData: arrayBufferToBase64(
assertion.response.authenticatorData,
),
clientDataJSON: arrayBufferToBase64(assertion.response.clientDataJSON),
signature: arrayBufferToBase64(assertion.response.signature),
userHandle: assertion.response.userHandle
? arrayBufferToBase64(assertion.response.userHandle)
: null,
},
};
return assertionData;
} catch (error) {
console.error("Biometric authentication error:", error);
throw new Error(getBiometricErrorMessage(error));
}
};
// Helper: Convert ArrayBuffer to Base64
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
// Helper: Convert Base64 to ArrayBuffer
function base64ToArrayBuffer(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
// Get user-friendly error message
function getBiometricErrorMessage(error) {
if (error.name === "NotAllowedError") {
return "Biometric authentication was cancelled";
} else if (error.name === "NotSupportedError") {
return "Biometric authentication is not supported on this device";
} else if (error.name === "SecurityError") {
return "Biometric authentication failed due to security restrictions";
} else if (error.name === "AbortError") {
return "Biometric authentication was aborted";
} else if (error.name === "InvalidStateError") {
return "Biometric credential already registered";
} else if (error.name === "NotReadableError") {
return "Biometric sensor is not readable";
}
return error.message || "Biometric authentication failed";
}
// Store biometric credential ID locally
export const storeBiometricCredential = (username, credentialId) => {
const credentials = getBiometricCredentials();
credentials[username] = credentialId;
localStorage.setItem("biometric_credentials", JSON.stringify(credentials));
};
// Get biometric credential ID for user
export const getBiometricCredential = (username) => {
const credentials = getBiometricCredentials();
return credentials[username] || null;
};
// Get all stored biometric credentials
export const getBiometricCredentials = () => {
const stored = localStorage.getItem("biometric_credentials");
return stored ? JSON.parse(stored) : {};
};
// Remove biometric credential
export const removeBiometricCredential = (username) => {
const credentials = getBiometricCredentials();
delete credentials[username];
localStorage.setItem("biometric_credentials", JSON.stringify(credentials));
};
// Check if user has biometric registered
export const hasBiometricRegistered = (username) => {
return getBiometricCredential(username) !== null;
};

View File

@@ -0,0 +1,275 @@
// Chord Engine - Industry-standard chord transposition and parsing
const NOTES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
const FLAT_NOTES = [
"C",
"Db",
"D",
"Eb",
"E",
"F",
"Gb",
"G",
"Ab",
"A",
"Bb",
"B",
];
// Keywords to ignore when parsing chords (section markers)
const SECTION_KEYWORDS = [
"verse",
"chorus",
"pre-chorus",
"prechorus",
"bridge",
"intro",
"outro",
"tag",
"coda",
"interlude",
"instrumental",
"hook",
"refrain",
"vamp",
"ending",
"turnaround",
"solo",
"break",
];
// Chord quality patterns
const CHORD_QUALITIES = {
major: "",
minor: "m",
diminished: "dim",
augmented: "aug",
suspended2: "sus2",
suspended4: "sus4",
dominant7: "7",
major7: "maj7",
minor7: "m7",
diminished7: "dim7",
augmented7: "aug7",
add9: "add9",
add11: "add11",
6: "6",
m6: "m6",
9: "9",
m9: "m9",
11: "11",
13: "13",
};
// Regex to match chord patterns
const CHORD_REGEX =
/^([A-G][#b]?)(m|min|maj|dim|aug|sus[24]?|add[0-9]+|[0-9]+)?([0-9]*)?(\/[A-G][#b]?)?$/i;
/**
* Parse a potential chord string
* @param {string} text - Text that might be a chord
* @returns {object|null} Parsed chord object or null if not a valid chord
*/
export function parseChord(text) {
const trimmed = text.trim();
// Check if it's a section keyword
if (
SECTION_KEYWORDS.some((keyword) => trimmed.toLowerCase().includes(keyword))
) {
return null;
}
const match = trimmed.match(CHORD_REGEX);
if (!match) return null;
const [, root, quality = "", extension = "", bass = ""] = match;
return {
root: normalizeNote(root),
quality,
extension,
bass: bass ? normalizeNote(bass.slice(1)) : null,
original: trimmed,
};
}
/**
* Normalize note name (handle enharmonics)
* @param {string} note - Note name
* @returns {string} Normalized note name
*/
function normalizeNote(note) {
const upper = note.charAt(0).toUpperCase() + note.slice(1);
// Convert flats to sharps for internal processing
const flatIndex = FLAT_NOTES.indexOf(upper);
if (flatIndex !== -1) {
return NOTES[flatIndex];
}
return upper;
}
/**
* Get the semitone index of a note
* @param {string} note - Note name
* @returns {number} Semitone index (0-11)
*/
function getNoteIndex(note) {
const normalized = normalizeNote(note);
const index = NOTES.indexOf(normalized);
return index !== -1 ? index : FLAT_NOTES.indexOf(normalized);
}
/**
* Transpose a single chord by a number of semitones
* @param {string} chord - Chord string
* @param {number} semitones - Number of semitones to transpose
* @param {boolean} useFlats - Whether to use flats instead of sharps
* @returns {string} Transposed chord string
*/
export function transposeChord(chord, semitones, useFlats = false) {
const parsed = parseChord(chord);
if (!parsed) return chord;
const noteArray = useFlats ? FLAT_NOTES : NOTES;
const rootIndex = getNoteIndex(parsed.root);
const newRootIndex = (rootIndex + semitones + 12) % 12;
const newRoot = noteArray[newRootIndex];
let result = newRoot + parsed.quality + parsed.extension;
if (parsed.bass) {
const bassIndex = getNoteIndex(parsed.bass);
const newBassIndex = (bassIndex + semitones + 12) % 12;
result += "/" + noteArray[newBassIndex];
}
return result;
}
/**
* Get semitones between two keys
* @param {string} fromKey - Starting key
* @param {string} toKey - Target key
* @returns {number} Number of semitones
*/
export function getSemitonesBetween(fromKey, toKey) {
const fromIndex = getNoteIndex(fromKey);
const toIndex = getNoteIndex(toKey);
return (toIndex - fromIndex + 12) % 12;
}
/**
* Transpose all chords in a lyrics string
* @param {string} lyrics - Lyrics with chords in brackets [C] or above words
* @param {number} semitones - Number of semitones to transpose
* @param {boolean} useFlats - Whether to use flats
* @returns {string} Transposed lyrics
*/
export function transposeLyrics(lyrics, semitones, useFlats = false) {
// Handle chords in brackets [C]
const bracketPattern = /\[([^\]]+)\]/g;
return lyrics.replace(bracketPattern, (match, chord) => {
const transposed = transposeChord(chord, semitones, useFlats);
return `[${transposed}]`;
});
}
/**
* Extract all unique chords from lyrics
* @param {string} lyrics - Lyrics with chords
* @returns {string[]} Array of unique chords
*/
export function extractChords(lyrics) {
const bracketPattern = /\[([^\]]+)\]/g;
const chords = new Set();
let match;
while ((match = bracketPattern.exec(lyrics)) !== null) {
const chord = match[1];
if (parseChord(chord)) {
chords.add(chord);
}
}
return Array.from(chords);
}
/**
* Detect the likely key of a song based on its chords
* @param {string[]} chords - Array of chords
* @returns {string} Detected key
*/
export function detectKey(chords) {
if (chords.length === 0) return "C";
// Simple heuristic: first and last chords often indicate the key
const firstChord = parseChord(chords[0]);
const lastChord = parseChord(chords[chords.length - 1]);
// If they match, high confidence
if (firstChord && lastChord && firstChord.root === lastChord.root) {
return firstChord.root + (firstChord.quality.includes("m") ? "m" : "");
}
// Otherwise use first chord
if (firstChord) {
return firstChord.root + (firstChord.quality.includes("m") ? "m" : "");
}
return "C";
}
/**
* Format chord for display (convert internal format to display format)
* @param {string} chord - Chord in internal format
* @param {object} options - Display options
* @returns {string} Formatted chord
*/
export function formatChord(chord, options = {}) {
const { useFlats = false, useSuperscript = false } = options;
let formatted = chord;
if (useFlats) {
NOTES.forEach((sharp, i) => {
if (sharp.includes("#")) {
formatted = formatted.replace(sharp, FLAT_NOTES[i]);
}
});
}
return formatted;
}
/**
* Get all keys for key selector
* @param {boolean} useFlats - Whether to use flats
* @returns {string[]} Array of keys
*/
export function getAllKeys(useFlats = false) {
const notes = useFlats ? FLAT_NOTES : NOTES;
const keys = [];
notes.forEach((note) => {
keys.push(note); // Major
keys.push(note + "m"); // Minor
});
return keys;
}
export default {
parseChord,
transposeChord,
transposeLyrics,
extractChords,
detectKey,
formatChord,
getAllKeys,
getSemitonesBetween,
NOTES,
FLAT_NOTES,
SECTION_KEYWORDS,
};

View File

@@ -0,0 +1,778 @@
/**
* Comprehensive Chord Sheet Utility
* Uses ChordSheetJS for parsing/rendering and Tonal for music theory
*/
import ChordSheetJS from "chordsheetjs";
import { Note, Scale, Chord, Progression } from "tonal";
// ============================================
// COMPLETE CHORD TYPE DEFINITIONS
// ============================================
// All root notes (chromatic scale)
export const ROOT_NOTES = [
"C",
"C#",
"Db",
"D",
"D#",
"Eb",
"E",
"F",
"F#",
"Gb",
"G",
"G#",
"Ab",
"A",
"A#",
"Bb",
"B",
];
// All chord qualities/types
export const CHORD_QUALITIES = [
{ symbol: "", name: "Major" },
{ symbol: "m", name: "Minor" },
{ symbol: "dim", name: "Diminished" },
{ symbol: "aug", name: "Augmented" },
{ symbol: "7", name: "Dominant 7th" },
{ symbol: "maj7", name: "Major 7th" },
{ symbol: "m7", name: "Minor 7th" },
{ symbol: "dim7", name: "Diminished 7th" },
{ symbol: "m7b5", name: "Half-Diminished" },
{ symbol: "aug7", name: "Augmented 7th" },
{ symbol: "6", name: "Major 6th" },
{ symbol: "m6", name: "Minor 6th" },
{ symbol: "9", name: "Dominant 9th" },
{ symbol: "maj9", name: "Major 9th" },
{ symbol: "m9", name: "Minor 9th" },
{ symbol: "11", name: "Dominant 11th" },
{ symbol: "13", name: "Dominant 13th" },
{ symbol: "sus2", name: "Suspended 2nd" },
{ symbol: "sus4", name: "Suspended 4th" },
{ symbol: "7sus4", name: "7th Suspended 4th" },
{ symbol: "add9", name: "Add 9" },
{ symbol: "add11", name: "Add 11" },
{ symbol: "5", name: "Power Chord" },
{ symbol: "2", name: "Add 2" },
{ symbol: "4", name: "Add 4" },
];
// Generate ALL possible chords (root + quality combinations)
export const ALL_CHORDS = [];
ROOT_NOTES.forEach((root) => {
CHORD_QUALITIES.forEach((quality) => {
ALL_CHORDS.push({
chord: root + quality.symbol,
root: root,
quality: quality.symbol,
name: `${root} ${quality.name}`,
});
});
});
// ============================================
// KEY SIGNATURES WITH DIATONIC PROGRESSIONS
// ============================================
// Major key diatonic chords (I, ii, iii, IV, V, vi, vii°)
export const MAJOR_KEY_PROGRESSIONS = {
C: ["C", "Dm", "Em", "F", "G", "Am", "Bdim"],
"C#": ["C#", "D#m", "E#m", "F#", "G#", "A#m", "B#dim"],
Db: ["Db", "Ebm", "Fm", "Gb", "Ab", "Bbm", "Cdim"],
D: ["D", "Em", "F#m", "G", "A", "Bm", "C#dim"],
"D#": ["D#", "E#m", "F##m", "G#", "A#", "B#m", "C##dim"],
Eb: ["Eb", "Fm", "Gm", "Ab", "Bb", "Cm", "Ddim"],
E: ["E", "F#m", "G#m", "A", "B", "C#m", "D#dim"],
F: ["F", "Gm", "Am", "Bb", "C", "Dm", "Edim"],
"F#": ["F#", "G#m", "A#m", "B", "C#", "D#m", "E#dim"],
Gb: ["Gb", "Abm", "Bbm", "Cb", "Db", "Ebm", "Fdim"],
G: ["G", "Am", "Bm", "C", "D", "Em", "F#dim"],
"G#": ["G#", "A#m", "B#m", "C#", "D#", "E#m", "F##dim"],
Ab: ["Ab", "Bbm", "Cm", "Db", "Eb", "Fm", "Gdim"],
A: ["A", "Bm", "C#m", "D", "E", "F#m", "G#dim"],
"A#": ["A#", "B#m", "C##m", "D#", "E#", "F##m", "G##dim"],
Bb: ["Bb", "Cm", "Dm", "Eb", "F", "Gm", "Adim"],
B: ["B", "C#m", "D#m", "E", "F#", "G#m", "A#dim"],
};
// Minor key diatonic chords (i, ii°, III, iv, v, VI, VII)
export const MINOR_KEY_PROGRESSIONS = {
Am: ["Am", "Bdim", "C", "Dm", "Em", "F", "G"],
"A#m": ["A#m", "B#dim", "C#", "D#m", "E#m", "F#", "G#"],
Bbm: ["Bbm", "Cdim", "Db", "Ebm", "Fm", "Gb", "Ab"],
Bm: ["Bm", "C#dim", "D", "Em", "F#m", "G", "A"],
Cm: ["Cm", "Ddim", "Eb", "Fm", "Gm", "Ab", "Bb"],
"C#m": ["C#m", "D#dim", "E", "F#m", "G#m", "A", "B"],
Dm: ["Dm", "Edim", "F", "Gm", "Am", "Bb", "C"],
"D#m": ["D#m", "E#dim", "F#", "G#m", "A#m", "B", "C#"],
Ebm: ["Ebm", "Fdim", "Gb", "Abm", "Bbm", "Cb", "Db"],
Em: ["Em", "F#dim", "G", "Am", "Bm", "C", "D"],
Fm: ["Fm", "Gdim", "Ab", "Bbm", "Cm", "Db", "Eb"],
"F#m": ["F#m", "G#dim", "A", "Bm", "C#m", "D", "E"],
Gm: ["Gm", "Adim", "Bb", "Cm", "Dm", "Eb", "F"],
"G#m": ["G#m", "A#dim", "B", "C#m", "D#m", "E", "F#"],
};
// All keys for dropdown
export const ALL_KEYS = [
// Major keys
{ value: "C", label: "C Major", type: "major" },
{ value: "C#", label: "C# Major", type: "major" },
{ value: "Db", label: "Db Major", type: "major" },
{ value: "D", label: "D Major", type: "major" },
{ value: "Eb", label: "Eb Major", type: "major" },
{ value: "E", label: "E Major", type: "major" },
{ value: "F", label: "F Major", type: "major" },
{ value: "F#", label: "F# Major", type: "major" },
{ value: "Gb", label: "Gb Major", type: "major" },
{ value: "G", label: "G Major", type: "major" },
{ value: "Ab", label: "Ab Major", type: "major" },
{ value: "A", label: "A Major", type: "major" },
{ value: "Bb", label: "Bb Major", type: "major" },
{ value: "B", label: "B Major", type: "major" },
// Minor keys
{ value: "Am", label: "A Minor", type: "minor" },
{ value: "A#m", label: "A# Minor", type: "minor" },
{ value: "Bbm", label: "Bb Minor", type: "minor" },
{ value: "Bm", label: "B Minor", type: "minor" },
{ value: "Cm", label: "C Minor", type: "minor" },
{ value: "C#m", label: "C# Minor", type: "minor" },
{ value: "Dm", label: "D Minor", type: "minor" },
{ value: "D#m", label: "D# Minor", type: "minor" },
{ value: "Ebm", label: "Eb Minor", type: "minor" },
{ value: "Em", label: "E Minor", type: "minor" },
{ value: "Fm", label: "F Minor", type: "minor" },
{ value: "F#m", label: "F# Minor", type: "minor" },
{ value: "Gm", label: "G Minor", type: "minor" },
{ value: "G#m", label: "G# Minor", type: "minor" },
];
// ============================================
// CHORD SHEET PARSING & RENDERING
// ============================================
/**
* Parse ChordPro format to structured song object
* ChordPro format: [C]Amazing [G]grace how [Am]sweet
*/
export function parseChordPro(content) {
const parser = new ChordSheetJS.ChordProParser();
try {
return parser.parse(content);
} catch (e) {
return null;
}
}
/**
* Parse Ultimate Guitar / plain text format
* Format with chords on separate lines above lyrics
*/
export function parsePlainText(content) {
const parser = new ChordSheetJS.ChordsOverWordsParser();
try {
return parser.parse(content);
} catch (e) {
return null;
}
}
/**
* Render song to ChordPro format
*/
export function renderToChordPro(song) {
const formatter = new ChordSheetJS.ChordProFormatter();
return formatter.format(song);
}
/**
* Render song to HTML with chords ABOVE lyrics
*/
export function renderToHtml(song) {
const formatter = new ChordSheetJS.HtmlTableFormatter();
return formatter.format(song);
}
/**
* Render song to plain text with chords above lyrics
*/
export function renderToText(song) {
const formatter = new ChordSheetJS.TextFormatter();
return formatter.format(song);
}
// ============================================
// TRANSPOSITION USING CHORDSHEETJS
// ============================================
/**
* Transpose a song by semitones
*/
export function transposeSong(song, semitones) {
if (!song || semitones === 0) return song;
return song.transpose(semitones);
}
/**
* Transpose from one key to another
*/
export function transposeToKey(song, fromKey, toKey) {
const semitones = getSemitonesBetweenKeys(fromKey, toKey);
return transposeSong(song, semitones);
}
/**
* Calculate semitones between two keys
*/
export function getSemitonesBetweenKeys(fromKey, toKey) {
const chromatic = [
"C",
"C#",
"D",
"D#",
"E",
"F",
"F#",
"G",
"G#",
"A",
"A#",
"B",
];
const flatToSharp = {
Db: "C#",
Eb: "D#",
Fb: "E",
Gb: "F#",
Ab: "G#",
Bb: "A#",
Cb: "B",
};
// Extract root note (remove minor suffix if present)
const fromRoot = fromKey.replace(/m$/, "");
const toRoot = toKey.replace(/m$/, "");
// Normalize to sharp notation
const fromNorm = flatToSharp[fromRoot] || fromRoot;
const toNorm = flatToSharp[toRoot] || toRoot;
const fromIdx = chromatic.indexOf(fromNorm);
const toIdx = chromatic.indexOf(toNorm);
if (fromIdx === -1 || toIdx === -1) return 0;
return (toIdx - fromIdx + 12) % 12;
}
// ============================================
// CHORD PROGRESSION GENERATION
// ============================================
/**
* Get diatonic chords for a key
*/
export function getDiatonicChords(key) {
const isMinor = key.endsWith("m");
if (isMinor) {
return MINOR_KEY_PROGRESSIONS[key] || MINOR_KEY_PROGRESSIONS["Am"];
}
return MAJOR_KEY_PROGRESSIONS[key] || MAJOR_KEY_PROGRESSIONS["C"];
}
/**
* Get common chord progressions for a key
*/
export function getCommonProgressions(key) {
const chords = getDiatonicChords(key);
const isMinor = key.endsWith("m");
// Roman numeral positions
// Major: I=0, ii=1, iii=2, IV=3, V=4, vi=5, vii°=6
// Minor: i=0, ii°=1, III=2, iv=3, v=4, VI=5, VII=6
return {
"I-IV-V-I": [chords[0], chords[3], chords[4], chords[0]],
"I-V-vi-IV": [chords[0], chords[4], chords[5], chords[3]],
"I-vi-IV-V": [chords[0], chords[5], chords[3], chords[4]],
"ii-V-I": [chords[1], chords[4], chords[0]],
"I-IV-vi-V": [chords[0], chords[3], chords[5], chords[4]],
"vi-IV-I-V": [chords[5], chords[3], chords[0], chords[4]],
"I-ii-IV-V": [chords[0], chords[1], chords[3], chords[4]],
"I-iii-IV-V": [chords[0], chords[2], chords[3], chords[4]],
};
}
// ============================================
// LYRICS + CHORD POSITIONING
// ============================================
/**
* Parse lyrics with embedded [Chord] markers OR chords-above-lyrics format
* for rendering chords ABOVE lyrics
*
* Input format: [C]Amazing [G]grace how [Am]sweet
* OR:
* Am F G
* Lord prepare me
*
* Output: Array of Array of { chord: string|null, text: string }
*/
export function parseLyricsWithChords(lyrics) {
if (!lyrics) return [];
const lines = lyrics.split("\n");
const result = [];
// Pattern to detect if a line is ONLY chords (chords-above-lyrics format)
const chordOnlyPattern =
/^[\sA-G#b/()m\d]*[A-G][#b]?(?:m|maj|min|dim|aug|sus|add|2|4|5|6|7|9|11|13)*[\sA-G#b/()m\d]*$/;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const nextLine = lines[i + 1];
// Check if this line is a chord line (contains only chords and spaces)
const isChordLine =
line.trim().length > 0 &&
chordOnlyPattern.test(line.trim()) &&
nextLine !== undefined &&
!/^[A-G][#b]?(?:m|maj|min|dim|aug|sus|add)?[0-9]*[\s]*$/.test(
nextLine.trim(),
);
if (isChordLine && nextLine) {
// This is chords-above-lyrics format
// Parse chord positions and merge with lyrics
const segments = [];
const chordRegex = /([A-G][#b]?(?:m|maj|min|dim|aug|sus|add)?[0-9]*)/g;
const chords = [];
let match;
while ((match = chordRegex.exec(line)) !== null) {
chords.push({
chord: match[1],
position: match.index,
});
}
if (chords.length > 0) {
let lastPos = 0;
chords.forEach((chordObj, idx) => {
const { chord, position } = chordObj;
const textBefore = nextLine.substring(lastPos, position);
if (textBefore) {
segments.push({ chord: null, text: textBefore });
}
// Get text after this chord until next chord or end
const nextChordPos = chords[idx + 1]
? chords[idx + 1].position
: nextLine.length;
const textAfter = nextLine.substring(position, nextChordPos);
segments.push({
chord: chord,
text: textAfter || " ",
});
lastPos = nextChordPos;
});
// Add any remaining text
if (lastPos < nextLine.length) {
segments.push({ chord: null, text: nextLine.substring(lastPos) });
}
result.push(segments);
i++; // Skip the lyrics line since we processed it
continue;
}
}
// Check for embedded [Chord] format
const hasEmbeddedChords =
/\[([A-Ga-g][#b]?(?:m|M|maj|min|dim|aug|sus|add|2|4|5|6|7|9|11|13)*(?:\/[A-Ga-g][#b]?)?)\]/.test(
line,
);
if (hasEmbeddedChords) {
// Parse embedded [Chord] markers
const segments = [];
const regex =
/\[([A-Ga-g][#b]?(?:m|M|maj|min|dim|aug|sus|add|2|4|5|6|7|9|11|13)*(?:\/[A-Ga-g][#b]?)?)\]/g;
let lastIndex = 0;
let chordMatch;
while ((chordMatch = regex.exec(line)) !== null) {
// Text before this chord (no chord above it)
if (chordMatch.index > lastIndex) {
const textBefore = line.substring(lastIndex, chordMatch.index);
if (textBefore) {
segments.push({ chord: null, text: textBefore });
}
}
// Find text after chord until next chord or end
const afterChordIdx = chordMatch.index + chordMatch[0].length;
const nextMatch = regex.exec(line);
const nextIdx = nextMatch ? nextMatch.index : line.length;
regex.lastIndex = afterChordIdx; // Reset to continue from after chord
const textAfter = line.substring(afterChordIdx, nextIdx);
segments.push({
chord: chordMatch[1],
text: textAfter || " ",
});
lastIndex = nextIdx;
// If we peeked ahead, go back
if (nextMatch) {
regex.lastIndex = nextMatch.index;
}
}
// Remaining text
if (lastIndex < line.length) {
segments.push({ chord: null, text: line.substring(lastIndex) });
}
// If no chords found, whole line is plain text
if (segments.length === 0) {
segments.push({ chord: null, text: line });
}
result.push(segments);
} else {
// Plain text line (no chords)
result.push([{ chord: null, text: line }]);
}
}
return result;
}
/**
* Transpose chord markers in lyrics text - handles BOTH formats:
* 1. Embedded [Chord] format
* 2. Chords-above-lyrics format (chord-only lines)
*/
export function transposeLyricsText(lyrics, fromKey, toKey) {
if (!lyrics || fromKey === toKey) return lyrics;
const semitones = getSemitonesBetweenKeys(fromKey, toKey);
if (semitones === 0) return lyrics;
// Use flat notation for flat keys
const flatKeys = [
"F",
"Bb",
"Eb",
"Ab",
"Db",
"Gb",
"Dm",
"Gm",
"Cm",
"Fm",
"Bbm",
"Ebm",
];
const useFlats = flatKeys.includes(toKey);
// Split into lines to handle both formats
const lines = lyrics.split("\n");
const chordOnlyPattern =
/^[\sA-G#b/()m\d]*[A-G][#b]?(?:m|maj|min|dim|aug|sus|add|2|4|5|6|7|9|11|13)*[\sA-G#b/()m\d]*$/;
const transposedLines = lines.map((line) => {
// Check if this is a chord-only line (chords-above-lyrics format)
if (line.trim().length > 0 && chordOnlyPattern.test(line.trim())) {
// Transpose all standalone chords in this line
return line.replace(
/([A-G][#b]?(?:m|maj|min|dim|aug|sus|add)?[0-9]*)/g,
(match) => {
return transposeChordName(match, semitones, useFlats);
},
);
}
// Otherwise, transpose embedded [Chord] patterns
return line.replace(
/\[([A-Ga-g][#b]?(?:m|M|maj|min|dim|aug|sus|add|2|4|5|6|7|9|11|13)*(?:\/[A-Ga-g][#b]?)?)\]/g,
(match, chord) => {
const transposed = transposeChordName(chord, semitones, useFlats);
return `[${transposed}]`;
},
);
});
return transposedLines.join("\n");
}
/**
* Transpose a single chord name by semitones
*/
export function transposeChordName(chord, semitones, useFlats = false) {
if (!chord || semitones === 0) return chord;
// Handle slash chords (e.g., C/G)
if (chord.includes("/")) {
const [main, bass] = chord.split("/");
return (
transposeChordName(main, semitones, useFlats) +
"/" +
transposeChordName(bass, semitones, useFlats)
);
}
// Parse root and quality
const match = chord.match(/^([A-Ga-g][#b]?)(.*)$/);
if (!match) return chord;
const [, root, quality] = match;
const sharpScale = [
"C",
"C#",
"D",
"D#",
"E",
"F",
"F#",
"G",
"G#",
"A",
"A#",
"B",
];
const flatScale = [
"C",
"Db",
"D",
"Eb",
"E",
"F",
"Gb",
"G",
"Ab",
"A",
"Bb",
"B",
];
const flatToSharp = {
Db: "C#",
Eb: "D#",
Fb: "E",
Gb: "F#",
Ab: "G#",
Bb: "A#",
Cb: "B",
};
// Normalize root to sharp
const normRoot = root[0].toUpperCase() + (root.slice(1) || "");
const sharpRoot = flatToSharp[normRoot] || normRoot;
// Find index
let idx = sharpScale.indexOf(sharpRoot);
if (idx === -1) return chord;
// Transpose
const newIdx = (idx + semitones + 12) % 12;
const scale = useFlats ? flatScale : sharpScale;
const newRoot = scale[newIdx];
return newRoot + quality;
}
/**
* Detect the original key from lyrics content
*/
export function detectKeyFromLyrics(lyrics) {
if (!lyrics) return "C";
// Find all chords in the lyrics
const chordMatches = lyrics.match(
/\[([A-Ga-g][#b]?(?:m|M|maj|min|dim|aug|sus|add|2|4|5|6|7|9|11|13)*)\]/g,
);
if (!chordMatches || chordMatches.length === 0) return "C";
// Extract just the chord names
const chords = chordMatches.map((m) => m.replace(/[\[\]]/g, ""));
// The first chord is often the key
const firstChord = chords[0];
// Check if it's minor
if (firstChord.includes("m") && !firstChord.includes("maj")) {
return firstChord.match(/^[A-Ga-g][#b]?m/)?.[0] || "Am";
}
// Return root as major key
return firstChord.match(/^[A-Ga-g][#b]?/)?.[0] || "C";
}
/**
* Insert chord markers into plain lyrics at specified positions
*/
export function insertChordsIntoLyrics(lyrics, chordPositions) {
// chordPositions: Array of { line: number, position: number, chord: string }
if (!lyrics || !chordPositions || chordPositions.length === 0) return lyrics;
const lines = lyrics.split("\n");
// Group by line
const byLine = {};
chordPositions.forEach((cp) => {
if (!byLine[cp.line]) byLine[cp.line] = [];
byLine[cp.line].push(cp);
});
// Process each line
return lines
.map((line, lineIdx) => {
const lineChords = byLine[lineIdx];
if (!lineChords || lineChords.length === 0) return line;
// Sort by position descending to insert from end
lineChords.sort((a, b) => b.position - a.position);
let result = line;
lineChords.forEach((cp) => {
const pos = Math.min(cp.position, result.length);
result = result.slice(0, pos) + `[${cp.chord}]` + result.slice(pos);
});
return result;
})
.join("\n");
}
/**
* Convert ChordsOverWords format to ChordPro inline format
*/
export function convertChordsOverWordsToInline(content) {
if (!content) return content;
const lines = content.split("\n");
const result = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const nextLine = lines[i + 1];
// Check if current line is mostly chords
if (isChordLine(line) && nextLine && !isChordLine(nextLine)) {
// Merge chord line with lyrics line
const merged = mergeChordLineWithLyrics(line, nextLine);
result.push(merged);
i++; // Skip the next line
} else if (!isChordLine(line)) {
result.push(line);
}
}
return result.join("\n");
}
/**
* Check if a line is primarily chord notation
*/
function isChordLine(line) {
if (!line || line.trim().length === 0) return false;
// Remove chord patterns and see what's left
const withoutChords = line.replace(
/[A-Ga-g][#b]?(?:m|M|maj|min|dim|aug|sus|add|2|4|5|6|7|9|11|13)*/g,
"",
);
const originalLength = line.replace(/\s/g, "").length;
const remainingLength = withoutChords.replace(/\s/g, "").length;
// If most content was chords, it's a chord line
return remainingLength < originalLength * 0.3;
}
/**
* Merge a chord line with the lyrics line below it
*/
function mergeChordLineWithLyrics(chordLine, lyricsLine) {
const chordPositions = [];
// Find all chords and their positions
const regex =
/([A-Ga-g][#b]?(?:m|M|maj|min|dim|aug|sus|add|2|4|5|6|7|9|11|13)*(?:\/[A-Ga-g][#b]?)?)/g;
let match;
while ((match = regex.exec(chordLine)) !== null) {
chordPositions.push({
chord: match[1],
position: match.index,
});
}
// Sort by position
chordPositions.sort((a, b) => a.position - b.position);
// Insert chords into lyrics at corresponding positions
let result = "";
let lastPos = 0;
for (const { chord, position } of chordPositions) {
const lyricsPos = Math.min(position, lyricsLine.length);
result += lyricsLine.substring(lastPos, lyricsPos) + `[${chord}]`;
lastPos = lyricsPos;
}
result += lyricsLine.substring(lastPos);
return result;
}
// ============================================
// EXPORT DEFAULT
// ============================================
export default {
// Constants
ROOT_NOTES,
CHORD_QUALITIES,
ALL_CHORDS,
ALL_KEYS,
MAJOR_KEY_PROGRESSIONS,
MINOR_KEY_PROGRESSIONS,
// Parsing
parseChordPro,
parsePlainText,
parseLyricsWithChords,
// Rendering
renderToChordPro,
renderToHtml,
renderToText,
// Transposition
transposeSong,
transposeToKey,
transposeLyricsText,
transposeChordName,
getSemitonesBetweenKeys,
// Progressions
getDiatonicChords,
getCommonProgressions,
// Utilities
detectKeyFromLyrics,
insertChordsIntoLyrics,
convertChordsOverWordsToInline,
};

View File

@@ -0,0 +1,371 @@
/**
* Chord Transposition Utility
* Uses tonal.js for accurate music theory-based transposition
*/
import { Note, Interval, Chord } from "tonal";
// All chromatic notes for transposition
const CHROMATIC_SCALE = [
"C",
"C#",
"D",
"D#",
"E",
"F",
"F#",
"G",
"G#",
"A",
"A#",
"B",
];
const CHROMATIC_FLATS = [
"C",
"Db",
"D",
"Eb",
"E",
"F",
"Gb",
"G",
"Ab",
"A",
"Bb",
"B",
];
// Common key signatures (for UI display)
export const KEY_OPTIONS = [
{ value: "C", label: "C Major" },
{ value: "C#", label: "C# / Db Major" },
{ value: "D", label: "D Major" },
{ value: "D#", label: "D# / Eb Major" },
{ value: "E", label: "E Major" },
{ value: "F", label: "F Major" },
{ value: "F#", label: "F# / Gb Major" },
{ value: "G", label: "G Major" },
{ value: "G#", label: "G# / Ab Major" },
{ value: "A", label: "A Major" },
{ value: "A#", label: "A# / Bb Major" },
{ value: "B", label: "B Major" },
{ value: "Cm", label: "C Minor" },
{ value: "Dm", label: "D Minor" },
{ value: "Em", label: "E Minor" },
{ value: "Fm", label: "F Minor" },
{ value: "Gm", label: "G Minor" },
{ value: "Am", label: "A Minor" },
{ value: "Bm", label: "B Minor" },
];
/**
* Get the semitone value of a note (0-11)
*/
function getNoteIndex(note) {
const normalized = note.replace(/b/g, "").replace(/#/g, "");
let index = CHROMATIC_SCALE.indexOf(normalized.toUpperCase());
// Handle sharps
if (note.includes("#")) {
index = (index + 1) % 12;
}
// Handle flats
if (note.includes("b") || note.includes("♭")) {
index = (index - 1 + 12) % 12;
}
return index;
}
/**
* Calculate semitone difference between two keys
*/
export function getSemitoneDistance(fromKey, toKey) {
const fromRoot = fromKey.replace(/m$/, ""); // Remove minor suffix
const toRoot = toKey.replace(/m$/, "");
const fromIndex = getNoteIndex(fromRoot);
const toIndex = getNoteIndex(toRoot);
return (toIndex - fromIndex + 12) % 12;
}
/**
* Transpose a single chord by semitones
* @param {string} chord - The chord to transpose (e.g., "Am7", "F#m", "Cmaj7")
* @param {number} semitones - Number of semitones to transpose
* @param {boolean} useFlats - Whether to use flats instead of sharps
* @returns {string} - The transposed chord
*/
export function transposeChord(chord, semitones, useFlats = false) {
if (!chord || semitones === 0) return chord;
// Parse the chord to extract root and quality
const match = chord.match(/^([A-Ga-g][#b♯♭]?)(.*)$/);
if (!match) return chord;
const [, root, quality] = match;
// Get current note index
const currentIndex = getNoteIndex(root);
// Calculate new index
const newIndex = (currentIndex + semitones + 12) % 12;
// Get new root note
const scale = useFlats ? CHROMATIC_FLATS : CHROMATIC_SCALE;
const newRoot = scale[newIndex];
// Preserve original case
const finalRoot =
root === root.toLowerCase() ? newRoot.toLowerCase() : newRoot;
return finalRoot + quality;
}
/**
* Transpose all chords in a string
* @param {string} text - Text containing chords in brackets or standalone
* @param {number} semitones - Semitones to transpose
* @param {boolean} useFlats - Use flats instead of sharps
*/
export function transposeText(text, semitones, useFlats = false) {
if (!text || semitones === 0) return text;
// Match chords in various formats: [Am7], (Cmaj7), or standalone chord patterns
const chordPattern =
/(\[?)([A-G][#b♯♭]?(?:m|maj|min|dim|aug|sus|add)?[0-9]?(?:\/[A-G][#b♯♭]?)?)(\]?)/g;
return text.replace(chordPattern, (match, open, chord, close) => {
// Handle slash chords (e.g., C/G)
if (chord.includes("/")) {
const [main, bass] = chord.split("/");
const transposedMain = transposeChord(main, semitones, useFlats);
const transposedBass = transposeChord(bass, semitones, useFlats);
return `${open}${transposedMain}/${transposedBass}${close}`;
}
return `${open}${transposeChord(chord, semitones, useFlats)}${close}`;
});
}
/**
* Parse lyrics with inline chords
* Chord format: [Chord] before the word/syllable it applies to
* Example: "[Am]Amazing [G]grace how [D]sweet the [Am]sound"
*
* @param {string} lyrics - Raw lyrics with chords
* @returns {Array} - Array of lines, each containing segments with chord/text pairs
*/
export function parseLyricsWithChords(lyrics) {
if (!lyrics) return [];
const lines = lyrics.split("\n");
return lines.map((line) => {
const segments = [];
let currentPosition = 0;
// Match chord patterns like [Am], [G7], [F#m], etc.
const chordRegex =
/\[([A-G][#b♯♭]?(?:m|maj|min|dim|aug|sus|add|M)?[0-9]*(?:\/[A-G][#b♯♭]?)?)\]/g;
let match;
while ((match = chordRegex.exec(line)) !== null) {
// Add text before this chord (if any)
if (match.index > currentPosition) {
const textBefore = line.substring(currentPosition, match.index);
if (textBefore) {
// This text has no chord above it
segments.push({ chord: null, text: textBefore });
}
}
// Find the text after the chord until the next chord or end of line
const chordEnd = match.index + match[0].length;
const nextChordMatch = line.substring(chordEnd).match(/\[[A-G]/);
const nextChordIndex = nextChordMatch
? chordEnd + nextChordMatch.index
: line.length;
const textAfter = line.substring(chordEnd, nextChordIndex);
segments.push({
chord: match[1],
text: textAfter || " ", // At least a space for positioning
});
currentPosition = nextChordIndex;
}
// Add remaining text without chord
if (currentPosition < line.length) {
const remaining = line.substring(currentPosition);
if (remaining) {
segments.push({ chord: null, text: remaining });
}
}
// If no chords found, the whole line is text
if (segments.length === 0) {
segments.push({ chord: null, text: line });
}
return segments;
});
}
/**
* Convert plain lyrics with chord lines to inline format
* Handles the common format where chords are on their own line above lyrics
*
* Example input:
* Am G D
* Amazing grace how sweet
*
* Output:
* [Am]Amazing [G]grace how [D]sweet
*/
export function convertChordLinestoInline(lyrics) {
if (!lyrics) return lyrics;
const lines = lyrics.split("\n");
const result = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const nextLine = lines[i + 1];
// Check if this line is a chord line (contains mostly chord patterns)
if (isChordLine(line) && nextLine && !isChordLine(nextLine)) {
// Merge chord line with lyric line below
const merged = mergeChordAndLyricLines(line, nextLine);
result.push(merged);
i++; // Skip the lyric line since we merged it
} else if (!isChordLine(line)) {
// Regular lyric or section header
result.push(line);
}
// Skip standalone chord lines without lyrics below
}
return result.join("\n");
}
/**
* Check if a line contains primarily chords
*/
function isChordLine(line) {
if (!line || line.trim().length === 0) return false;
// Remove all chord patterns and see what's left
const withoutChords = line
.replace(
/[A-G][#b♯♭]?(?:m|maj|min|dim|aug|sus|add|M)?[0-9]*(?:\/[A-G][#b♯♭]?)?/g,
"",
)
.trim();
const originalContent = line.replace(/\s+/g, "");
// If removing chords leaves very little, it's a chord line
return withoutChords.length < originalContent.length * 0.3;
}
/**
* Merge a chord line with the lyric line below it
*/
function mergeChordAndLyricLines(chordLine, lyricLine) {
const chordPositions = [];
// Find all chords and their positions
const chordRegex =
/([A-G][#b♯♭]?(?:m|maj|min|dim|aug|sus|add|M)?[0-9]*(?:\/[A-G][#b♯♭]?)?)/g;
let match;
while ((match = chordRegex.exec(chordLine)) !== null) {
chordPositions.push({
chord: match[1],
position: match.index,
});
}
// Sort by position (in case regex finds them out of order)
chordPositions.sort((a, b) => a.position - b.position);
// Build the merged line by inserting chords into the lyric
let result = "";
let lastPos = 0;
for (const { chord, position } of chordPositions) {
// Add lyrics up to this chord position
const lyricsBefore = lyricLine.substring(
lastPos,
Math.min(position, lyricLine.length),
);
result += lyricsBefore + `[${chord}]`;
lastPos = Math.min(position, lyricLine.length);
}
// Add remaining lyrics
result += lyricLine.substring(lastPos);
return result;
}
/**
* Extract the original key from song data
*/
export function extractOriginalKey(song) {
if (!song) return "C";
// Check various possible fields
if (song.key_chord)
return song.key_chord.replace(/\s+/g, "").split(/[,\/]/)[0];
if (song.chords) return song.chords.replace(/\s+/g, "").split(/[,\/\s]/)[0];
if (song.original_key) return song.original_key;
if (song.key) return song.key;
// Try to detect from lyrics
const lyrics = song.lyrics || "";
const firstChord = lyrics.match(/\[([A-G][#b♯♭]?m?)\]/);
if (firstChord) return firstChord[1];
return "C"; // Default
}
/**
* Transpose entire lyrics to a new key
*/
export function transposeLyrics(lyrics, fromKey, toKey) {
if (!lyrics || fromKey === toKey) return lyrics;
const semitones = getSemitoneDistance(fromKey, toKey);
// Determine if we should use flats based on the target key
const flatKeys = [
"F",
"Bb",
"Eb",
"Ab",
"Db",
"Gb",
"Dm",
"Gm",
"Cm",
"Fm",
"Bbm",
"Ebm",
];
const useFlats = flatKeys.includes(toKey);
return transposeText(lyrics, semitones, useFlats);
}
export default {
KEY_OPTIONS,
transposeChord,
transposeText,
parseLyricsWithChords,
convertChordLinestoInline,
getSemitoneDistance,
transposeLyrics,
extractOriginalKey,
};

View File

@@ -0,0 +1,29 @@
/**
* Creates a debounced function that delays invoking func until after wait milliseconds
* have elapsed since the last time the debounced function was invoked.
*/
export function debounce(func, wait = 300) {
let timeoutId = null;
const debounced = (...args) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
func.apply(this, args);
timeoutId = null;
}, wait);
};
debounced.cancel = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
};
return debounced;
}
export default debounce;

View File

@@ -0,0 +1,165 @@
/**
* Parse chord sheet from plain text
* Detects chords above lyrics and section headers
*/
export function parseChordSheet(text) {
const lines = text.split("\n");
const result = {
title: "",
artist: "",
key: "",
sections: [],
chords: new Set(),
lyrics: "",
};
let currentSection = { type: "", lines: [] };
let lyricsLines = [];
// Common section headers
const sectionPattern =
/^\s*[\[\(]?(verse|chorus|bridge|pre-?chorus|intro|outro|interlude|hook|tag|ending|v\d|c\d|ch\d)[\d\s]*[\]\)]?\s*:?\s*$/i;
// Chord pattern - detects chords on a line
const chordLinePattern = /^[\s\w#b/]+$/;
const chordPattern = /([A-G][#b]?(?:m|maj|min|dim|aug|sus|add)?[0-9]*)/g;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// Skip empty lines
if (!trimmed) {
lyricsLines.push("");
continue;
}
// Check for section headers
if (sectionPattern.test(trimmed)) {
if (currentSection.lines.length > 0) {
result.sections.push({ ...currentSection });
}
currentSection = {
type: trimmed.replace(/[\[\]\(\):]/g, "").trim(),
lines: [],
};
lyricsLines.push(trimmed);
continue;
}
// Check if line contains only chords
const nextLine = i + 1 < lines.length ? lines[i + 1] : "";
const isChordLine =
chordLinePattern.test(trimmed) &&
trimmed.match(chordPattern) &&
nextLine.trim() &&
!chordLinePattern.test(nextLine.trim());
if (isChordLine) {
// Extract chords from this line
const chords = trimmed.match(chordPattern);
if (chords) {
chords.forEach((chord) => result.chords.add(chord));
}
// Map chords to positions in the next line
const lyricLine = nextLine;
let chordedLine = "";
let lastPos = 0;
// Find chord positions
const chordPositions = [];
let tempLine = trimmed;
let pos = 0;
for (const chord of trimmed.split(/\s+/).filter((c) => c.trim())) {
const chordMatch = chord.match(chordPattern);
if (chordMatch) {
const chordPos = trimmed.indexOf(chord, pos);
chordPositions.push({ chord, position: chordPos });
pos = chordPos + chord.length;
}
}
// Build lyrics with embedded chords
chordPositions.forEach((item, idx) => {
const { chord, position } = item;
const nextPos = chordPositions[idx + 1]?.position || lyricLine.length;
const lyricPart = lyricLine.substring(position, nextPos).trim();
if (lyricPart) {
chordedLine += `[${chord}]${lyricPart} `;
}
});
lyricsLines.push(chordedLine.trim() || `[${chords[0]}]`);
currentSection.lines.push(chordedLine.trim());
// Skip the next line since we processed it
i++;
continue;
}
// Regular lyric line
lyricsLines.push(line);
currentSection.lines.push(line);
}
if (currentSection.lines.length > 0) {
result.sections.push(currentSection);
}
result.lyrics = lyricsLines.join("\n");
result.chords = Array.from(result.chords).join(" ");
// Try to detect key from first chord
if (result.chords) {
const firstChord = result.chords.split(" ")[0];
result.key = firstChord;
}
return result;
}
/**
* Parse Word document (.docx) using mammoth
*/
export async function parseWordDocument(file) {
// For now, read as text and parse
const text = await file.text();
return parseChordSheet(text);
}
/**
* Parse PDF document
*/
export async function parsePDFDocument(file) {
// For now, read as text and parse
const text = await file.text();
return parseChordSheet(text);
}
/**
* Auto-detect file type and parse
*/
export async function parseDocument(file) {
const fileType = file.type;
const fileName = file.name.toLowerCase();
if (fileType === "application/pdf" || fileName.endsWith(".pdf")) {
return parsePDFDocument(file);
} else if (
fileType ===
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
fileName.endsWith(".docx")
) {
return parseWordDocument(file);
} else if (fileType === "text/plain" || fileName.endsWith(".txt")) {
const text = await file.text();
return parseChordSheet(text);
} else {
throw new Error(
"Unsupported file type. Please upload PDF, Word, or TXT files.",
);
}
}

View File

@@ -0,0 +1,76 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "class",
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
fontFamily: {
sans: [
"Inter",
"SF Pro Display",
"-apple-system",
"BlinkMacSystemFont",
"sans-serif",
],
},
colors: {
primary: {
50: "#eff6ff",
100: "#dbeafe",
200: "#bfdbfe",
300: "#93c5fd",
400: "#60a5fa",
500: "#3b82f6",
600: "#2563eb",
700: "#1d4ed8",
800: "#1e40af",
900: "#1e3a8a",
950: "#172554",
},
glass: {
white: "rgba(255, 255, 255, 0.1)",
dark: "rgba(0, 0, 0, 0.1)",
},
},
backdropBlur: {
xs: "2px",
},
boxShadow: {
glass: "0 8px 32px 0 rgba(31, 38, 135, 0.15)",
"glass-inset": "inset 0 0 60px rgba(255, 255, 255, 0.05)",
soft: "0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04)",
"soft-lg": "0 10px 40px -15px rgba(0, 0, 0, 0.1)",
},
animation: {
"fade-in": "fadeIn 0.3s ease-out",
"slide-up": "slideUp 0.3s ease-out",
"slide-down": "slideDown 0.3s ease-out",
"scale-in": "scaleIn 0.2s ease-out",
"pulse-subtle": "pulseSubtle 2s ease-in-out infinite",
},
keyframes: {
fadeIn: {
"0%": { opacity: "0" },
"100%": { opacity: "1" },
},
slideUp: {
"0%": { opacity: "0", transform: "translateY(10px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
slideDown: {
"0%": { opacity: "0", transform: "translateY(-10px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
scaleIn: {
"0%": { opacity: "0", transform: "scale(0.95)" },
"100%": { opacity: "1", transform: "scale(1)" },
},
pulseSubtle: {
"0%, 100%": { opacity: "1" },
"50%": { opacity: "0.7" },
},
},
},
},
plugins: [],
};

View File

@@ -0,0 +1,46 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5100,
strictPort: true,
host: true, // Listen on all addresses
allowedHosts: [
".ddns.net",
"houseofprayer.ddns.net",
"localhost",
".localhost",
"192.168.10.130",
"127.0.0.1",
],
proxy: {
"/api": {
target: "http://localhost:8080",
changeOrigin: true,
},
},
hmr: {
protocol: "wss",
host: "houseofprayer.ddns.net",
port: 443,
clientPort: 443,
},
},
resolve: {
alias: {
"@": "/src",
"@components": "/src/components",
"@pages": "/src/pages",
"@layouts": "/src/layouts",
"@hooks": "/src/hooks",
"@utils": "/src/utils",
"@animations": "/src/animations",
"@themes": "/src/themes",
"@assets": "/src/assets",
"@context": "/src/context",
"@stores": "/src/stores",
},
},
});