e.currentTarget.style.opacity = '0.85'}
+ onMouseLeave={(e) => e.currentTarget.style.opacity = '1'}
>
Open
{ e.currentTarget.style.background = 'rgba(239,68,68,0.08)'; e.currentTarget.style.color = '#ef4444'; e.currentTarget.style.borderColor = 'rgba(239,68,68,0.3)'; }}
+ onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-muted)'; e.currentTarget.style.borderColor = 'var(--bg-border)'; }}
>
Delete
@@ -37,3 +68,4 @@ const SessionCard = ({ session, onDelete }) => {
};
export default SessionCard;
+
diff --git a/client/src/components/YouTubePlayer.jsx b/client/src/components/YouTubePlayer.jsx
new file mode 100644
index 0000000..24caf1a
--- /dev/null
+++ b/client/src/components/YouTubePlayer.jsx
@@ -0,0 +1,42 @@
+import React from 'react';
+
+/**
+ * Extracts the YouTube video ID from a watch URL or short URL.
+ * Supports:
+ * https://www.youtube.com/watch?v=VIDEO_ID
+ * https://youtu.be/VIDEO_ID
+ */
+const getYouTubeId = (url) => {
+ const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&?/]+)/);
+ return match ? match[1] : null;
+};
+
+/**
+ * YouTubePlayer — renders a YouTube video as an embedded iframe.
+ * Used exclusively when beatSource === 'youtube'.
+ * Never feeds YouTube URLs into HTMLAudioElement.
+ */
+const YouTubePlayer = ({ url }) => {
+ const videoId = getYouTubeId(url);
+
+ if (!videoId) {
+ return (
+
+ Invalid YouTube URL
+
+ );
+ }
+
+ return (
+
VIDEO
+ );
+};
+
+export default YouTubePlayer;
diff --git a/client/src/components/ui/BpmInput.jsx b/client/src/components/ui/BpmInput.jsx
new file mode 100644
index 0000000..a65a510
--- /dev/null
+++ b/client/src/components/ui/BpmInput.jsx
@@ -0,0 +1,109 @@
+import { useRef } from 'react';
+
+export default function BpmInput({ bpm, setBpm }) {
+ const holdRef = useRef(null);
+
+ const startHold = (dir) => {
+ // immediate first step
+ setBpm(prev => Math.min(220, Math.max(40, prev + dir)));
+ // after 380ms initial delay, fire every 60ms
+ holdRef.current = setTimeout(() => {
+ holdRef.current = setInterval(() => {
+ setBpm(prev => Math.min(220, Math.max(40, prev + dir)));
+ }, 60);
+ }, 380);
+ };
+
+ const stopHold = () => {
+ clearTimeout(holdRef.current);
+ clearInterval(holdRef.current);
+ holdRef.current = null;
+ };
+
+ return (
+
+
+
+
{
+ const val = Number(e.target.value);
+ if (!isNaN(val)) setBpm(Math.min(220, Math.max(40, val)));
+ }}
+ style={{
+ width: '48px',
+ padding: '4px',
+ textAlign: 'center',
+ outline: 'none',
+ fontFamily: 'monospace',
+ fontSize: '14px',
+ background: 'transparent',
+ border: 'none',
+ color: 'var(--text-main)',
+ appearance: 'textfield',
+ MozAppearance: 'textfield',
+ }}
+ />
+
+
+
startHold(1)}
+ onMouseUp={stopHold}
+ onMouseLeave={stopHold}
+ tabIndex={-1}
+ >▲
+
+
startHold(-1)}
+ onMouseUp={stopHold}
+ onMouseLeave={stopHold}
+ tabIndex={-1}
+ >▼
+
+
+ );
+}
diff --git a/client/src/components/ui/Dropdown.jsx b/client/src/components/ui/Dropdown.jsx
new file mode 100644
index 0000000..c5aa8ad
--- /dev/null
+++ b/client/src/components/ui/Dropdown.jsx
@@ -0,0 +1,101 @@
+import React, { useState, useRef, useEffect } from 'react';
+
+const Dropdown = ({ value, onChange, options, placeholder = "Select...", className = "", compact = false }) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const dropdownRef = useRef(null);
+
+ // Close dropdown when clicking outside
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
+ setIsOpen(false);
+ }
+ };
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ const selectedOption = options.find(opt => opt.value === value);
+
+ return (
+
+ {/* Trigger */}
+
setIsOpen(!isOpen)}
+ className="w-full flex items-center justify-between transition-all duration-200 text-left outline-none"
+ style={{
+ background: 'var(--bg-elevated)',
+ border: '1px solid var(--bg-border)',
+ padding: compact ? '6px 10px' : '10px 14px',
+ borderRadius: '8px',
+ color: 'var(--text-main)',
+ borderColor: isOpen ? 'var(--accent-primary)' : 'var(--bg-border)',
+ boxShadow: isOpen ? '0 0 12px rgba(99,102,241,0.2)' : 'none'
+ }}
+ >
+
+ {selectedOption ? selectedOption.label : placeholder}
+
+
+
+
+
+
+ {/* Menu */}
+
+
+ {options.map((opt) => {
+ const isSelected = opt.value === value;
+ return (
+
{
+ onChange(opt.value);
+ setIsOpen(false);
+ }}
+ className={`truncate text-sm transition-colors duration-150 cursor-pointer`}
+ style={{
+ padding: '10px 12px',
+ borderRadius: '6px',
+ background: isSelected ? 'var(--accent-primary)' : 'transparent',
+ color: isSelected ? '#ffffff' : 'var(--text-main)',
+ fontWeight: isSelected ? 600 : 500,
+ opacity: isSelected ? 0.9 : 1
+ }}
+ onMouseEnter={(e) => {
+ if (!isSelected) e.currentTarget.style.background = 'var(--bg-surface)';
+ }}
+ onMouseLeave={(e) => {
+ if (!isSelected) e.currentTarget.style.background = 'transparent';
+ }}
+ >
+ {opt.label}
+
+ );
+ })}
+
+
+
+ );
+};
+
+export default Dropdown;
+
diff --git a/client/src/components/ui/ServerWarmingOverlay.jsx b/client/src/components/ui/ServerWarmingOverlay.jsx
new file mode 100644
index 0000000..fb69e28
--- /dev/null
+++ b/client/src/components/ui/ServerWarmingOverlay.jsx
@@ -0,0 +1,167 @@
+import { useEffect, useState } from 'react';
+
+const MESSAGES = [
+ { delay: 0, text: 'Connecting to server...' },
+ { delay: 4000, text: 'Server is waking up...' },
+ { delay: 9000, text: 'Almost there, hang tight ☕' },
+ { delay: 18000,text: 'This takes ~30s on a cold start — almost done!' },
+ { delay: 30000,text: 'Still going… won\'t be long now.' },
+];
+
+/**
+ * Full-screen overlay shown while waiting for the Render server
+ * to wake up on a cold start. Keeps users informed so they don't leave.
+ *
+ * Props:
+ * visible — boolean, whether to show the overlay
+ * onCancel — called when user clicks "Cancel"
+ */
+export default function ServerWarmingOverlay({ visible, onCancel }) {
+ const [msgIndex, setMsgIndex] = useState(0);
+ const [elapsed, setElapsed] = useState(0);
+
+ useEffect(() => {
+ if (!visible) {
+ setMsgIndex(0);
+ setElapsed(0);
+ return;
+ }
+
+ // Tick elapsed seconds
+ const ticker = setInterval(() => setElapsed(s => s + 1), 1000);
+
+ // Schedule message transitions
+ const timers = MESSAGES.slice(1).map((m, i) =>
+ setTimeout(() => setMsgIndex(i + 1), m.delay)
+ );
+
+ return () => {
+ clearInterval(ticker);
+ timers.forEach(clearTimeout);
+ };
+ }, [visible]);
+
+ if (!visible) return null;
+
+ return (
+
+
+
+ {/* Spinner */}
+
+ {/* Outer ring */}
+
+ {/* Inner pulse dot */}
+
+
+
+ {/* Message */}
+
+ {MESSAGES[msgIndex].text}
+
+
+ {/* Elapsed time hint */}
+ {elapsed >= 5 && (
+
+ {elapsed}s elapsed
+
+ )}
+ {elapsed < 5 &&
}
+
+ {/* Progress dots */}
+
+ {[0, 1, 2].map(i => (
+
+ ))}
+
+
+ {/* Cancel */}
+
+ Cancel
+
+
+ );
+}
diff --git a/client/src/context/AudioEngineContext.jsx b/client/src/context/AudioEngineContext.jsx
new file mode 100644
index 0000000..4c012d7
--- /dev/null
+++ b/client/src/context/AudioEngineContext.jsx
@@ -0,0 +1,200 @@
+import React, { createContext, useContext, useRef, useState, useEffect, useCallback } from 'react';
+
+/**
+ * AudioEngineContext provides a centralized single source of truth for audio playback.
+ * It abstracts away the differences between an HTMLAudioElement (uploaded files)
+ * and a YouTube Iframe API player.
+ */
+const AudioEngineContext = createContext(null);
+
+export const useAudioEngine = () => {
+ const context = useContext(AudioEngineContext);
+ if (!context) {
+ throw new Error('useAudioEngine must be used within an AudioEngineProvider');
+ }
+ return context;
+};
+
+export const AudioEngineProvider = ({ children }) => {
+ // --- STATE ---
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [duration, setDuration] = useState(0);
+ const [sourceType, setSourceType] = useState(null); // 'audio' | 'youtube' | null
+
+ // --- REFS ---
+ // We use refs to hold the actual player instances outside of the React render cycle
+ const audioRef = useRef(null);
+ const ytPlayerRef = useRef(null);
+ const animationRef = useRef(null);
+
+ // --- SYNC LOOP (requestAnimationFrame) ---
+ const cancelSync = useCallback(() => {
+ if (animationRef.current) {
+ cancelAnimationFrame(animationRef.current);
+ animationRef.current = null;
+ }
+ }, []);
+
+ const syncTime = useCallback(() => {
+ if (!isPlaying) return;
+
+ if (sourceType === 'audio' && audioRef.current) {
+ setCurrentTime(audioRef.current.currentTime);
+ } else if (sourceType === 'youtube' && ytPlayerRef.current && ytPlayerRef.current.getCurrentTime) {
+ // YouTube's getCurrentTime can be slightly delayed, but we poll it continuously
+ setCurrentTime(ytPlayerRef.current.getCurrentTime());
+ }
+
+ // Recursively call for the next frame
+ animationRef.current = requestAnimationFrame(syncTime);
+ }, [isPlaying, sourceType]);
+
+ // Start/Stop syncing loop whenever play state changes
+ useEffect(() => {
+ cancelSync();
+ if (isPlaying) {
+ animationRef.current = requestAnimationFrame(syncTime);
+ }
+ return () => cancelSync();
+ }, [isPlaying, syncTime, cancelSync]);
+
+ // --- API METHODS ---
+
+ /**
+ * Loads a new source into the engine.
+ * @param {string} type - 'audio' | 'youtube'
+ * @param {string} url - Audio URL (if type === 'audio')
+ * @param {object} ytPlayerInstance - YouTube player ref (if type === 'youtube' and using react-youtube)
+ */
+ const loadSource = useCallback((type, url = null, ytPlayerInstance = null) => {
+ // Reset State
+ setSourceType(type);
+ setIsPlaying(false);
+ setCurrentTime(0);
+ setDuration(0);
+ cancelSync();
+
+ if (type === 'audio') {
+ if (!audioRef.current) {
+ audioRef.current = new Audio();
+ }
+
+ const audio = audioRef.current;
+ audio.src = url;
+ audio.load();
+
+ // Audio Event Listeners (One-time setup)
+ audio.onloadedmetadata = () => {
+ setDuration(audio.duration);
+ };
+ audio.onended = () => {
+ setIsPlaying(false);
+ setCurrentTime(audio.duration); // Pin to end
+ };
+ audio.onplay = () => setIsPlaying(true);
+ audio.onpause = () => setIsPlaying(false);
+
+ } else if (type === 'youtube') {
+ if (ytPlayerInstance) {
+ ytPlayerRef.current = ytPlayerInstance;
+ // If the video is already loaded, get its duration immediately
+ if (typeof ytPlayerInstance.getDuration === 'function') {
+ setDuration(ytPlayerInstance.getDuration() || 0);
+ }
+ }
+
+ // Stop underlying native audio if it was running previously
+ if (audioRef.current) {
+ audioRef.current.pause();
+ audioRef.current.src = '';
+ }
+ }
+ }, [cancelSync]);
+
+ const play = useCallback(() => {
+ if (!sourceType) return;
+
+ if (sourceType === 'audio' && audioRef.current) {
+ audioRef.current.play().catch(console.error);
+ } else if (sourceType === 'youtube' && ytPlayerRef.current && ytPlayerRef.current.playVideo) {
+ ytPlayerRef.current.playVideo();
+ }
+ // Note: State `isPlaying` relies on the native event listeners,
+ // but we cautiously trigger it here for immediate UI responsiveness.
+ setIsPlaying(true);
+ }, [sourceType]);
+
+ const pause = useCallback(() => {
+ if (!sourceType) return;
+
+ if (sourceType === 'audio' && audioRef.current) {
+ audioRef.current.pause();
+ } else if (sourceType === 'youtube' && ytPlayerRef.current && ytPlayerRef.current.pauseVideo) {
+ ytPlayerRef.current.pauseVideo();
+ }
+ setIsPlaying(false);
+ }, [sourceType]);
+
+ const seek = useCallback((time) => {
+ if (!sourceType) return;
+
+ // Immediately update local state to avoid visual lag
+ setCurrentTime(time);
+
+ if (sourceType === 'audio' && audioRef.current) {
+ audioRef.current.currentTime = time;
+ } else if (sourceType === 'youtube' && ytPlayerRef.current && ytPlayerRef.current.seekTo) {
+ ytPlayerRef.current.seekTo(time, true); // true = allow seeking ahead of buffered video
+ }
+ }, [sourceType]);
+
+ // --- YOUTUBE SPECIFIC HANDLERS ---
+ // Expose these handlers to be attached to your YouTube Iframe wrapper component
+ const onYtReady = useCallback((event) => {
+ const player = event.target;
+ ytPlayerRef.current = player;
+ setDuration(player.getDuration() || 0);
+ }, []);
+
+ const onYtStateChange = useCallback((event) => {
+ // YouTube Player States:
+ // 1 = PLAYING, 2 = PAUSED, 0 = ENDED
+ if (event.data === 1) {
+ setIsPlaying(true);
+ setDuration(event.target.getDuration() || 0);
+ } else if (event.data === 2) {
+ setIsPlaying(false);
+ } else if (event.data === 0) {
+ setIsPlaying(false);
+ if (ytPlayerRef.current) {
+ setCurrentTime(ytPlayerRef.current.getDuration() || 0);
+ }
+ }
+ }, []);
+
+ // --- CONTEXT VALUE ---
+ const value = {
+ // State
+ isPlaying,
+ currentTime,
+ duration,
+ sourceType,
+ // Operations
+ play,
+ pause,
+ seek,
+ loadSource,
+ // YT Bindings
+ ytHandlers: {
+ onReady: onYtReady,
+ onStateChange: onYtStateChange
+ }
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/client/src/hooks/useAudioEngine.js b/client/src/hooks/useAudioEngine.js
new file mode 100644
index 0000000..0efa731
--- /dev/null
+++ b/client/src/hooks/useAudioEngine.js
@@ -0,0 +1,81 @@
+import { useRef, useState, useEffect, useCallback } from 'react';
+
+/**
+ * Core Audio Engine strictly for precise HTMLAudioElement playback.
+ *
+ * @param {string} url - The URL of the uploaded audio file
+ * @returns {object} API to control playback and access native refs
+ */
+export function useAudioEngine(url) {
+ const audioRef = useRef(null);
+
+ // Strict State: We only track transport state and duration.
+ // Current time is deliberately excluded from React state to prevent 60fps React thrashing.
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [duration, setDuration] = useState(0);
+
+ // Initialize and clean up the native HTMLAudioElement
+ useEffect(() => {
+ if (!url) return;
+
+ if (!audioRef.current) {
+ audioRef.current = new Audio();
+ }
+
+ const audio = audioRef.current;
+
+ // Attempt to clear previous src if it changes
+ audio.pause();
+ audio.src = url;
+ audio.load();
+
+ // Native event bindings
+ const handleLoadedMetadata = () => setDuration(audio.duration);
+ const handleEnded = () => setIsPlaying(false);
+ const handlePlay = () => setIsPlaying(true);
+ const handlePause = () => setIsPlaying(false);
+
+ audio.addEventListener('loadedmetadata', handleLoadedMetadata);
+ audio.addEventListener('ended', handleEnded);
+ audio.addEventListener('play', handlePlay);
+ audio.addEventListener('pause', handlePause);
+
+ return () => {
+ audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
+ audio.removeEventListener('ended', handleEnded);
+ audio.removeEventListener('play', handlePlay);
+ audio.removeEventListener('pause', handlePause);
+ audio.pause();
+ };
+ }, [url]);
+
+ // Command API
+ const play = useCallback(() => {
+ if (audioRef.current) {
+ audioRef.current.play().catch((err) => {
+ console.error("Playback failed:", err);
+ });
+ }
+ }, []);
+
+ const pause = useCallback(() => {
+ if (audioRef.current) {
+ audioRef.current.pause();
+ }
+ }, []);
+
+ const seek = useCallback((time) => {
+ if (audioRef.current && isFinite(time)) {
+ audioRef.current.currentTime = time;
+ }
+ }, []);
+
+ return {
+ audioRef, // Direct native ref for ultra-fast rAF reads
+ play,
+ pause,
+ seek,
+ isPlaying,
+ duration
+ };
+}
diff --git a/client/src/hooks/useServerWarmup.js b/client/src/hooks/useServerWarmup.js
new file mode 100644
index 0000000..7ac2da1
--- /dev/null
+++ b/client/src/hooks/useServerWarmup.js
@@ -0,0 +1,25 @@
+import { useEffect, useRef } from 'react';
+
+const API_URL = import.meta.env.VITE_API_URL || 'https://draft16.onrender.com/api';
+
+/**
+ * Silently pings the backend health endpoint when the component mounts.
+ * This pre-warms the Render free-tier server so the cold start
+ * happens in the background rather than blocking the user.
+ */
+export function useServerWarmup() {
+ const pinged = useRef(false);
+
+ useEffect(() => {
+ if (pinged.current) return;
+ pinged.current = true;
+
+ fetch(`${API_URL}/health`, {
+ method: 'GET',
+ // Don't wait long — just enough to trigger the wake-up
+ signal: AbortSignal.timeout?.(30_000),
+ }).catch(() => {
+ // Silently ignore — this is a best-effort warmup
+ });
+ }, []);
+}
diff --git a/client/src/index.css b/client/src/index.css
index 62cf3b2..be79f8c 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -1,17 +1,246 @@
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;500;600;700;800&display=swap');
@import "tailwindcss";
+@theme {
+ --font-sans: 'Inter', sans-serif;
+ --font-display: 'Outfit', sans-serif;
+}
+
@custom-variant dark (&:is(.dark *));
+/* ─── Studio Inspired Theme ──────────────────────────────────── */
+:root {
+ --bg-main: #F1F5F3;
+ --bg-surface: #FAFCFB;
+ --bg-elevated: #FFFFFF;
+ --bg-border: #DDE6E2;
+ --text-main: #172421;
+ --text-muted: #6C7D79;
+ --brand-dark: #0F1A17;
+ --accent-primary:#2F7A6B;
+ --accent-hover: #3C8F7E;
+ --accent-warm: #D0875C;
+ --bg-hover: rgba(47, 122, 107, 0.08);
+
+ --shadow-soft: 0 4px 20px rgba(0, 0, 0, 0.06);
+ --shadow-hover: 0 8px 28px rgba(0, 0, 0, 0.10);
+ --accent-glow: 0 0 0 2px rgba(47, 122, 107, 0.15);
+
+ --take-bg: rgba(0, 0, 0, 0.02);
+ --take-bg-playing: rgba(0, 0, 0, 0.04);
+ --take-bg-hover: rgba(0, 0, 0, 0.03);
+ --take-border: rgba(0, 0, 0, 0.06);
+ --take-btn-bg: rgba(0, 0, 0, 0.06);
+ --take-btn-hover: rgba(0, 0, 0, 0.12);
+ --take-text: #111111;
+ --take-time: rgba(0, 0, 0, 0.45);
+ --take-progress-bg: rgba(0, 0, 0, 0.08);
+ --take-header-color: #6b7280;
+
+ --take-container-bg: var(--editor-bg);
+ --take-container-border: var(--editor-border);
+ --take-container-shadow: 0 6px 18px rgba(0, 0, 0, 0.03);
+
+ --editor-bg: var(--bg-surface);
+ --editor-bg-solid:var(--bg-surface);
+ --editor-shadow: 0 8px 24px rgba(0, 0, 0, 0.04);
+ --editor-border: rgba(0, 0, 0, 0.06);
+
+ --tab-inactive: rgba(0, 0, 0, 0.5);
+ --tab-hover: #111111;
+ --tab-active: var(--text-main);
+ --tab-active-shadow: none;
+ --tab-container-border: transparent;
+ --tab-hover-bg: rgba(0, 0, 0, 0.04);
+
+ --nav-heading: var(--accent-primary);
+ --nav-item: var(--text-main);
+ --nav-active: var(--accent-primary);
+}
+
+.dark {
+ --bg-main: #0f1115;
+ --bg-surface: #151821;
+ --bg-elevated: #1b1f2a;
+ --bg-border: rgba(255, 255, 255, 0.06);
+ --text-main: #ffffff;
+ --text-secondary:rgba(255, 255, 255, 0.7);
+ --text-muted: rgba(255, 255, 255, 0.45);
+ --brand-dark: #ffffff;
+ --accent-primary:#22c55e;
+ --accent-hover: #16a34a;
+ --accent-warm: #D0875C;
+ --bg-hover: rgba(255, 255, 255, 0.05);
+
+ --shadow-soft: 0 4px 20px rgba(0, 0, 0, 0.3);
+ --shadow-hover: 0 8px 28px rgba(0, 0, 0, 0.5);
+ --accent-glow: 0 0 0 2px rgba(34, 197, 94, 0.25);
+
+ --take-container-bg: var(--editor-bg);
+ --take-container-border: var(--editor-border);
+ --take-container-shadow: 0 10px 30px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.02);
+ --take-header-color: rgba(255, 255, 255, 0.8);
+
+ --take-bg: transparent;
+ --take-bg-playing:rgba(0, 0, 0, 0.02);
+ --take-bg-hover: rgba(0, 0, 0, 0.02);
+ --take-border: rgba(0, 0, 0, 0.05);
+ --take-btn-bg: rgba(0, 0, 0, 0.06);
+ --take-btn-hover:rgba(0, 0, 0, 0.12);
+ --take-progress-bg: rgba(0, 0, 0, 0.1);
+ --take-text: #111111;
+ --take-time: rgba(0, 0, 0, 0.5);
+
+ --editor-bg: #1b1f2a;
+ --editor-bg-solid:#1b1f2a;
+ --editor-shadow: 0 20px 50px rgba(0, 0, 0, 0.7), inset 0 1px 0 rgba(255, 255, 255, 0.03);
+ --editor-border: rgba(255, 255, 255, 0.06);
+
+ --tab-inactive: rgba(255, 255, 255, 0.45);
+ --tab-hover: #ffffff;
+ --tab-active: #ffffff;
+ --tab-active-shadow: 0 -2px 10px rgba(0, 0, 0, 0.4);
+ --tab-container-border: rgba(255, 255, 255, 0.06);
+ --tab-hover-bg: rgba(255, 255, 255, 0.05);
+
+ --nav-heading: rgba(255, 255, 255, 0.8);
+ --nav-item: rgba(255, 255, 255, 0.6);
+ --nav-active: #ffffff;
+
+
+
+ --take-bg: #121826;
+ --take-bg-playing: #121826;
+ --take-bg-hover: #161c28;
+ --take-border: transparent;
+ --take-btn-bg: rgba(255, 255, 255, 0.05);
+ --take-btn-hover: rgba(255, 255, 255, 0.15);
+ --take-progress-bg: rgba(255, 255, 255, 0.1);
+ --take-text: rgba(255, 255, 255, 0.9);
+ --take-time: rgba(255, 255, 255, 0.5);
+}
+
+/* ─── Dropdown shadows ───────────────────────────────────────── */
+.dropdown-menu {
+ box-shadow: 0 4px 12px rgba(0,0,0,0.08);
+}
+
+.dark .dropdown-menu {
+ box-shadow: 0 4px 16px rgba(0,0,0,0.4);
+}
+
+/* ─── Base ───────────────────────────────────────────────────── */
body {
margin: 0;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
- 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
- sans-serif;
+ font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
+ background-color: var(--bg-main);
+ color: var(--text-main);
+ transition: background-color 0.3s ease, color 0.3s ease;
}
code {
- font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
- monospace;
+ font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
+}
+
+/* ─── Global Glow & Elevation ───────────────────────────────── */
+input:focus,
+textarea:focus,
+select:focus,
+button:focus-visible {
+ box-shadow: var(--accent-glow) !important;
+ outline: none !important;
+ border-color: var(--accent-primary) !important;
+}
+
+.cm-editor.cm-focused {
+ outline: none !important;
+}
+
+/* ─── Global Animation Helpers ───────────────────────────────── */
+.animate-pulse {
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: .5; }
+}
+
+/* ─── Draft Add Button ─────────────────────────────────────── */
+.draft-add {
+ color: rgba(0, 0, 0, 0.65);
+ font-weight: 500;
+ transition: all 0.2s ease;
+ margin-left: 8px;
+ cursor: pointer;
+ background: transparent;
+ border: none;
+}
+
+.draft-add:hover {
+ color: var(--accent-primary);
+ transform: translateY(-1px) scale(1.03);
+}
+
+.draft-add:active {
+ transform: translateY(0) scale(0.98);
+}
+
+.dark .draft-add {
+ color: rgba(255, 255, 255, 0.65);
+}
+
+/* ─── Global Custom Scrollbars ───────────────────────────────── */
+
+* {
+ /* Firefox */
+ scrollbar-width: thin;
+ scrollbar-color: rgba(0, 0, 0, 0.15) transparent;
+
+ /* Edge Legacy / IE */
+ -ms-overflow-style: none;
+}
+
+.dark * {
+ scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
+}
+
+/* Webkit (Chrome, Edge, Safari, Brave) */
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background: rgba(0, 0, 0, 0.15);
+ border-radius: 999px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: rgba(0, 0, 0, 0.3);
+}
+
+.dark ::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.15);
+}
+
+.dark ::-webkit-scrollbar-thumb:hover {
+ background: rgba(255, 255, 255, 0.3);
+}
+
+/* ─── Remove default number input spinners ───────────────────── */
+input[type="number"]::-webkit-inner-spin-button,
+input[type="number"]::-webkit-outer-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+
+input[type="number"] {
+ -moz-appearance: textfield;
}
diff --git a/client/src/pages/AuthError.jsx b/client/src/pages/AuthError.jsx
new file mode 100644
index 0000000..578f597
--- /dev/null
+++ b/client/src/pages/AuthError.jsx
@@ -0,0 +1,25 @@
+import { Link } from 'react-router-dom';
+
+const AuthError = () => {
+ return (
+
+
+
Authentication Failed
+
+ There was a problem signing you in with Google.
+ Please try again or use another method.
+
+
+ Back to Login
+
+
+
+ );
+};
+
+export default AuthError;
+
diff --git a/client/src/pages/AuthSuccess.jsx b/client/src/pages/AuthSuccess.jsx
new file mode 100644
index 0000000..b67e77a
--- /dev/null
+++ b/client/src/pages/AuthSuccess.jsx
@@ -0,0 +1,34 @@
+import { useEffect } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import { setToken } from '../utils/auth';
+
+const AuthSuccess = () => {
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ const token = searchParams.get('token');
+
+ if (token) {
+ setToken(token);
+ // Clean up the URL so the token doesn't remain in history
+ window.history.replaceState({}, document.title, '/dashboard');
+ navigate('/dashboard', { replace: true });
+ } else {
+ // If directly hit without a token
+ navigate('/login', { replace: true });
+ }
+ }, [searchParams, navigate]);
+
+ return (
+
+ );
+};
+
+export default AuthSuccess;
+
diff --git a/client/src/pages/Dashboard.jsx b/client/src/pages/Dashboard.jsx
index 4b8e318..c1f0e64 100644
--- a/client/src/pages/Dashboard.jsx
+++ b/client/src/pages/Dashboard.jsx
@@ -1,33 +1,39 @@
import { useNavigate } from 'react-router-dom';
import { Link } from 'react-router-dom';
-import { useEffect } from 'react';
+import { useEffect, useState } from 'react';
import SessionCard from '../components/SessionCard';
import { getToken } from '../utils/auth';
import useSessions from '../hooks/useSessions';
+import Dropdown from '../components/ui/Dropdown';
+import { Search, Mic } from 'lucide-react';
const Dashboard = () => {
const navigate = useNavigate();
const { sessions, sortedSessions, loading, error, query, setQuery, sortOption, setSortOption, removeSession } = useSessions();
+ const [sessionToDelete, setSessionToDelete] = useState(null);
useEffect(() => {
if (!getToken()) {
navigate('/login');
}
- }, []);
+ }, [navigate]);
return (
-
+
{/* Header Section */}
-
+
-
Your Sessions
-
Manage your songwriting drafts
+
Your Sessions
+
Manage your songwriting drafts and studio takes.
e.currentTarget.style.opacity = '0.85'}
+ onMouseLeave={(e) => e.currentTarget.style.opacity = '1'}
>
Create New Session
@@ -35,51 +41,125 @@ const Dashboard = () => {
{/* Search + Sort */}
{!loading && !error && (
-
-
setQuery(e.target.value)}
- placeholder="Search sessions..."
- className="w-full max-w-md border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none bg-white dark:bg-gray-800 text-gray-900 dark:text-white transition-colors"
- />
-
setSortOption(e.target.value)}
- className="border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2 ml-4 focus:ring-2 focus:ring-blue-500 outline-none bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 transition-colors"
- >
- Newest
- Oldest
- A–Z
- Z–A
-
+
+
+ setQuery(e.target.value)}
+ placeholder="Search sessions..."
+ className="w-full rounded-lg pl-10 pr-4 py-3 outline-none transition-all"
+ style={{ background: 'var(--bg-elevated)', border: '1px solid var(--bg-border)', color: 'var(--text-main)' }}
+ onFocus={(e) => e.target.style.borderColor = 'var(--accent-primary)'}
+ onBlur={(e) => e.target.style.borderColor = 'var(--bg-border)'}
+ />
+
+
+
+
+
+ setSortOption(val)}
+ options={[
+ { value: 'newest', label: 'Newest' },
+ { value: 'oldest', label: 'Oldest' },
+ { value: 'az', label: 'A–Z' },
+ { value: 'za', label: 'Z–A' }
+ ]}
+ />
+
)}
{/* Content Section */}
{loading ? (
-
Loading sessions...
+
) : error ? (
-
{error}
+
) : sessions.length === 0 ? (
-
-
No sessions yet.
-
Start writing your first track by creating a new session.
+
+
+
+
+
No sessions yet
+
Start your next track by creating a new session. Upload a beat, set the BPM, and write your draft.
+
+ Start Writing
+
) : sortedSessions.length === 0 ? (
-
-
No sessions match your search.
-
Try a different keyword.
+
+
No matching sessions
+
Try adjusting your search query.
) : (
-
+
{sortedSessions.map((session) => (
-
+ setSessionToDelete(session)} />
))}
)}
+
+ {/* Delete Confirmation Modal */}
+ {sessionToDelete && (
+
+
setSessionToDelete(null)} />
+
+
+
Delete Session
+
+ Are you sure you want to delete "{sessionToDelete.title} "? This action cannot be undone.
+
+
+ setSessionToDelete(null)}
+ className="flex-1 py-2.5 rounded-xl font-medium transition-colors text-sm"
+ style={{ background: 'transparent', border: '1px solid var(--bg-border)', color: 'var(--text-muted)' }}
+ onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-main)'; }}
+ onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-muted)'; }}
+ >
+ Cancel
+
+ {
+ removeSession(sessionToDelete._id);
+ setSessionToDelete(null);
+ }}
+ className="flex-1 py-2.5 rounded-xl font-medium text-white transition-all shadow-sm text-sm"
+ style={{ background: '#ef4444' }}
+ onMouseEnter={(e) => e.currentTarget.style.background = '#dc2626'}
+ onMouseLeave={(e) => e.currentTarget.style.background = '#ef4444'}
+ >
+ Delete
+
+
+
+
+ )}
);
};
diff --git a/client/src/pages/Home.jsx b/client/src/pages/Home.jsx
index d6d7abf..dd9a39b 100644
--- a/client/src/pages/Home.jsx
+++ b/client/src/pages/Home.jsx
@@ -1,16 +1,65 @@
-import { Link } from 'react-router-dom';
+import { useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { guestLogin } from '../services/authService';
+import { setToken } from '../utils/auth';
const Home = () => {
+ const [loading, setLoading] = useState(false);
+ const navigate = useNavigate();
+
+ const handleGuestLogin = async () => {
+ setLoading(true);
+ try {
+ const data = await guestLogin();
+ setToken(data.token);
+ navigate('/dashboard');
+ } catch (err) {
+ console.error('Guest login failed:', err);
+ setLoading(false);
+ }
+ };
+
return (
-
-
Welcome to Draft16
-
Where 16s Are Born.
-
- Get Started
- Login
-
+
+
+
+ Professional songwriting workspace.
+
+
+ The ultimate drafting workspace for lyricists. Write verses, record takes, play beats, and perfect your flow in a distraction-free environment.
+
+
+
+
+ e.currentTarget.style.opacity = '0.85'}
+ onMouseLeave={(e) => e.currentTarget.style.opacity = '1'}
+ >
+ Start Writing for Free
+
+
+ Sign In
+
+
+
+
+ {loading ? 'Creating workspace...' : 'Try without an account →'}
+
+
+
);
};
export default Home;
+
diff --git a/client/src/pages/Login.jsx b/client/src/pages/Login.jsx
index c5c8ca2..94f45e7 100644
--- a/client/src/pages/Login.jsx
+++ b/client/src/pages/Login.jsx
@@ -1,15 +1,21 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
-import { login } from '../services/authService';
+import { login, guestLogin } from '../services/authService';
import { setToken } from '../utils/auth';
+import { useServerWarmup } from '../hooks/useServerWarmup';
+import ServerWarmingOverlay from '../components/ui/ServerWarmingOverlay';
const Login = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
+ const [warmingUp, setWarmingUp] = useState(false);
const navigate = useNavigate();
+ // Silently ping backend on mount to pre-warm the Render server
+ useServerWarmup();
+
const handleSubmit = async (e) => {
e.preventDefault();
setError(null);
@@ -26,33 +32,131 @@ const Login = () => {
}
};
+ const handleGoogleLogin = () => {
+ setWarmingUp(true);
+ // Small delay so overlay renders before the redirect takes over
+ setTimeout(() => {
+ window.location.href = `${import.meta.env.VITE_API_URL || 'https://draft16.onrender.com/api'}/auth/google`;
+ }, 300);
+ };
+
+ const handleGuestLogin = async () => {
+ setError(null);
+ setLoading(true);
+
+ try {
+ const data = await guestLogin();
+ setToken(data.token);
+ navigate('/dashboard');
+ } catch (err) {
+ setError(err.response?.data?.message || 'Guest login failed');
+ } finally {
+ setLoading(false);
+ }
+ };
+
return (
-
-
-
Welcome Back
+ <>
+
setWarmingUp(false)} />
+
+
+
Welcome Back
- {error &&
{error}
}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Google Login Button (Primary) */}
+
e.currentTarget.style.borderColor = 'var(--text-muted)'}
+ onMouseLeave={(e) => e.currentTarget.style.borderColor = 'var(--bg-border)'}
+ >
+ {/* Minimal Google 'G' SVG */}
+
+
+
+
+
+
+ Continue with Google
+
+
+ {/* Guest Login Button */}
+
{
+ if (!loading) {
+ e.currentTarget.style.borderColor = 'var(--text-muted)';
+ e.currentTarget.style.color = 'var(--text-main)';
+ }
+ }}
+ onMouseLeave={(e) => {
+ if (!loading) {
+ e.currentTarget.style.borderColor = 'var(--bg-border)';
+ e.currentTarget.style.color = 'var(--text-muted)';
+ }
+ }}
+ >
+
+
+
+
+ Continue as Guest
+
+
+ {/* Divider */}
+
+ {/* Email Form (Secondary) */}
+ >
);
};
export default Login;
+
diff --git a/client/src/pages/NewSession.jsx b/client/src/pages/NewSession.jsx
index 6c33570..653e885 100644
--- a/client/src/pages/NewSession.jsx
+++ b/client/src/pages/NewSession.jsx
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { createSession } from '../services/sessionService';
+import Dropdown from '../components/ui/Dropdown';
const NewSession = () => {
const navigate = useNavigate();
@@ -24,14 +25,12 @@ const NewSession = () => {
const sessionData = {
title,
- lyrics: '', // Start empty
+ lyrics: '',
beatSource,
beatUrl
};
const newSession = await createSession(sessionData);
-
- // Redirect straight to the editor for this new session
navigate(`/sessions/${newSession._id}`);
} catch (err) {
@@ -41,66 +40,75 @@ const NewSession = () => {
};
return (
-
-
+
+
-
- ← Back to Dashboard
+
+
← Back to Dashboard
-
Start New Session
-
Set up your workspace for a new track.
+
Start New Session
+
Set up your workspace for a new track.
{error && (
-