Initial commit - Church Music Database
This commit is contained in:
26
new-site/frontend/index.html
Normal file
26
new-site/frontend/index.html
Normal 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
7824
new-site/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
new-site/frontend/package.json
Normal file
51
new-site/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
new-site/frontend/postcss.config.js
Normal file
6
new-site/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
50
new-site/frontend/src/App.jsx
Normal file
50
new-site/frontend/src/App.jsx
Normal 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;
|
||||
33
new-site/frontend/src/TestApp.jsx
Normal file
33
new-site/frontend/src/TestApp.jsx
Normal 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;
|
||||
298
new-site/frontend/src/components/LyricsRichTextEditor.jsx
Normal file
298
new-site/frontend/src/components/LyricsRichTextEditor.jsx
Normal 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;
|
||||
25
new-site/frontend/src/components/ProtectedRoute.jsx
Normal file
25
new-site/frontend/src/components/ProtectedRoute.jsx
Normal 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;
|
||||
}
|
||||
107
new-site/frontend/src/components/home/ProfileSelector.jsx
Normal file
107
new-site/frontend/src/components/home/ProfileSelector.jsx
Normal 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;
|
||||
255
new-site/frontend/src/components/home/QuickActions.jsx
Normal file
255
new-site/frontend/src/components/home/QuickActions.jsx
Normal 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;
|
||||
292
new-site/frontend/src/components/home/SongSearchPanel.jsx
Normal file
292
new-site/frontend/src/components/home/SongSearchPanel.jsx
Normal 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;
|
||||
217
new-site/frontend/src/components/home/WorshipListsPanel.jsx
Normal file
217
new-site/frontend/src/components/home/WorshipListsPanel.jsx
Normal 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;
|
||||
139
new-site/frontend/src/components/navigation/MobileNav.jsx
Normal file
139
new-site/frontend/src/components/navigation/MobileNav.jsx
Normal 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;
|
||||
228
new-site/frontend/src/components/navigation/Navbar.jsx
Normal file
228
new-site/frontend/src/components/navigation/Navbar.jsx
Normal 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;
|
||||
135
new-site/frontend/src/context/AuthContext.jsx
Normal file
135
new-site/frontend/src/context/AuthContext.jsx
Normal 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;
|
||||
52
new-site/frontend/src/context/ThemeContext.jsx
Normal file
52
new-site/frontend/src/context/ThemeContext.jsx
Normal 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;
|
||||
143
new-site/frontend/src/hooks/useData.js
Normal file
143
new-site/frontend/src/hooks/useData.js
Normal 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 };
|
||||
259
new-site/frontend/src/hooks/useDataFetch.js
Normal file
259
new-site/frontend/src/hooks/useDataFetch.js
Normal 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();
|
||||
}
|
||||
43
new-site/frontend/src/hooks/useLocalStorage.js
Normal file
43
new-site/frontend/src/hooks/useLocalStorage.js
Normal 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;
|
||||
64
new-site/frontend/src/hooks/useMediaQuery.js
Normal file
64
new-site/frontend/src/hooks/useMediaQuery.js
Normal 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;
|
||||
193
new-site/frontend/src/index.css
Normal file
193
new-site/frontend/src/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
326
new-site/frontend/src/layouts/MainLayout.jsx
Normal file
326
new-site/frontend/src/layouts/MainLayout.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
new-site/frontend/src/main.jsx
Normal file
34
new-site/frontend/src/main.jsx
Normal 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>,
|
||||
);
|
||||
765
new-site/frontend/src/pages/AdminPage.jsx
Normal file
765
new-site/frontend/src/pages/AdminPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
257
new-site/frontend/src/pages/DatabasePage.jsx
Normal file
257
new-site/frontend/src/pages/DatabasePage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
598
new-site/frontend/src/pages/HomePage.jsx
Normal file
598
new-site/frontend/src/pages/HomePage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
404
new-site/frontend/src/pages/LoginPage.jsx
Normal file
404
new-site/frontend/src/pages/LoginPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
395
new-site/frontend/src/pages/ProfilesPage.jsx
Normal file
395
new-site/frontend/src/pages/ProfilesPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
236
new-site/frontend/src/pages/SettingsPage.jsx
Normal file
236
new-site/frontend/src/pages/SettingsPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
790
new-site/frontend/src/pages/SongEditorPage.jsx
Normal file
790
new-site/frontend/src/pages/SongEditorPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1541
new-site/frontend/src/pages/SongViewPage.jsx
Normal file
1541
new-site/frontend/src/pages/SongViewPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
1015
new-site/frontend/src/pages/WorshipListsPage.jsx
Normal file
1015
new-site/frontend/src/pages/WorshipListsPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
590
new-site/frontend/src/stores/dataStore.js
Normal file
590
new-site/frontend/src/stores/dataStore.js
Normal 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);
|
||||
35
new-site/frontend/src/utils/api.js
Normal file
35
new-site/frontend/src/utils/api.js
Normal 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;
|
||||
214
new-site/frontend/src/utils/biometric.js
Normal file
214
new-site/frontend/src/utils/biometric.js
Normal 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;
|
||||
};
|
||||
275
new-site/frontend/src/utils/chordEngine.js
Normal file
275
new-site/frontend/src/utils/chordEngine.js
Normal 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,
|
||||
};
|
||||
778
new-site/frontend/src/utils/chordSheetUtils.js
Normal file
778
new-site/frontend/src/utils/chordSheetUtils.js
Normal 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,
|
||||
};
|
||||
371
new-site/frontend/src/utils/chordUtils.js
Normal file
371
new-site/frontend/src/utils/chordUtils.js
Normal 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,
|
||||
};
|
||||
29
new-site/frontend/src/utils/debounce.js
Normal file
29
new-site/frontend/src/utils/debounce.js
Normal 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;
|
||||
165
new-site/frontend/src/utils/documentParser.js
Normal file
165
new-site/frontend/src/utils/documentParser.js
Normal 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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
76
new-site/frontend/tailwind.config.js
Normal file
76
new-site/frontend/tailwind.config.js
Normal 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: [],
|
||||
};
|
||||
46
new-site/frontend/vite.config.js
Normal file
46
new-site/frontend/vite.config.js
Normal 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",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user