loukai-app 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +558 -0
- package/bin/loukai.js +32 -0
- package/package.json +243 -0
- package/src/main/appState.js +250 -0
- package/src/main/audioEngine.js +478 -0
- package/src/main/creator/conversionService.js +503 -0
- package/src/main/creator/downloadManager.js +1128 -0
- package/src/main/creator/ffmpegService.js +487 -0
- package/src/main/creator/installLogger.js +51 -0
- package/src/main/creator/keyDetection.js +212 -0
- package/src/main/creator/llmService.js +370 -0
- package/src/main/creator/lrclibService.js +340 -0
- package/src/main/creator/python/crepe_runner.py +189 -0
- package/src/main/creator/python/demucs_runner.py +158 -0
- package/src/main/creator/python/whisper_runner.py +172 -0
- package/src/main/creator/pythonRunner.js +268 -0
- package/src/main/creator/stemBuilder.js +491 -0
- package/src/main/creator/systemChecker.js +474 -0
- package/src/main/handlers/appHandlers.js +45 -0
- package/src/main/handlers/audioHandlers.js +33 -0
- package/src/main/handlers/autotuneHandlers.js +28 -0
- package/src/main/handlers/canvasHandlers.js +84 -0
- package/src/main/handlers/creatorHandlers.js +159 -0
- package/src/main/handlers/editorHandlers.js +98 -0
- package/src/main/handlers/effectsHandlers.js +100 -0
- package/src/main/handlers/fileHandlers.js +45 -0
- package/src/main/handlers/index.js +78 -0
- package/src/main/handlers/libraryHandlers.js +96 -0
- package/src/main/handlers/mixerHandlers.js +64 -0
- package/src/main/handlers/playerHandlers.js +39 -0
- package/src/main/handlers/preferencesHandlers.js +46 -0
- package/src/main/handlers/queueHandlers.js +81 -0
- package/src/main/handlers/rendererHandlers.js +63 -0
- package/src/main/handlers/settingsHandlers.js +42 -0
- package/src/main/handlers/webServerHandlers.js +105 -0
- package/src/main/main.js +2351 -0
- package/src/main/preload.js +252 -0
- package/src/main/settingsManager.js +139 -0
- package/src/main/statePersistence.js +193 -0
- package/src/main/utils/pathValidator.js +112 -0
- package/src/main/webServer.js +2535 -0
- package/src/native/autotune.js +417 -0
- package/src/renderer/adapters/ElectronBridge.js +677 -0
- package/src/renderer/canvas.html +80 -0
- package/src/renderer/components/App.jsx +303 -0
- package/src/renderer/components/AppRoot.jsx +37 -0
- package/src/renderer/components/AudioDeviceSettings.jsx +145 -0
- package/src/renderer/components/EffectsPanelWrapper.jsx +267 -0
- package/src/renderer/components/MixerTab.jsx +233 -0
- package/src/renderer/components/MixerTabWrapper.jsx +31 -0
- package/src/renderer/components/PortalSelect.jsx +239 -0
- package/src/renderer/components/QueueTab.jsx +116 -0
- package/src/renderer/components/RequestsListWrapper.jsx +78 -0
- package/src/renderer/components/ServerTab.jsx +472 -0
- package/src/renderer/components/SongInfoBarWrapper.jsx +77 -0
- package/src/renderer/components/StatusBar.jsx +92 -0
- package/src/renderer/components/TabNavigation.jsx +77 -0
- package/src/renderer/components/TransportControlsWrapper.jsx +69 -0
- package/src/renderer/components/creator/CreateTab.jsx +1236 -0
- package/src/renderer/dist/assets/kaiPlayer-CoMx__a_.js +2 -0
- package/src/renderer/dist/assets/kaiPlayer-CoMx__a_.js.map +1 -0
- package/src/renderer/dist/assets/microphoneEngine-BaCUhhQc.js +2 -0
- package/src/renderer/dist/assets/microphoneEngine-BaCUhhQc.js.map +1 -0
- package/src/renderer/dist/assets/player-DVrqp7N5.js +3 -0
- package/src/renderer/dist/assets/player-DVrqp7N5.js.map +1 -0
- package/src/renderer/dist/assets/songLoaders-BaTgGib4.js +2 -0
- package/src/renderer/dist/assets/songLoaders-BaTgGib4.js.map +1 -0
- package/src/renderer/dist/assets/webrtcManager-BhCHWceK.js +2 -0
- package/src/renderer/dist/assets/webrtcManager-BhCHWceK.js.map +1 -0
- package/src/renderer/dist/js/autoTuneWorklet.js +224 -0
- package/src/renderer/dist/js/micPitchDetectorWorklet.js +137 -0
- package/src/renderer/dist/js/musicAnalysisWorklet.js +216 -0
- package/src/renderer/dist/js/phaseVocoderWorklet.js +341 -0
- package/src/renderer/dist/js/soundtouch-worklet.js +1395 -0
- package/src/renderer/dist/renderer.css +1 -0
- package/src/renderer/dist/renderer.js +62 -0
- package/src/renderer/dist/renderer.js.map +1 -0
- package/src/renderer/dist/renderer.woff2 +0 -0
- package/src/renderer/hooks/useKeyboardShortcuts.js +154 -0
- package/src/renderer/index.html +24 -0
- package/src/renderer/index.html.backup +372 -0
- package/src/renderer/js/PlayerInterface.js +267 -0
- package/src/renderer/js/autoTuneWorklet.js +224 -0
- package/src/renderer/js/butterchurnVerify.js +46 -0
- package/src/renderer/js/canvas-app.js +114 -0
- package/src/renderer/js/cdgPlayer.js +685 -0
- package/src/renderer/js/kaiPlayer.js +1200 -0
- package/src/renderer/js/karaokeRenderer.js +3392 -0
- package/src/renderer/js/micPitchDetectorWorklet.js +137 -0
- package/src/renderer/js/microphoneEngine.js +656 -0
- package/src/renderer/js/musicAnalysisWorklet.js +216 -0
- package/src/renderer/js/phaseVocoderWorklet.js +341 -0
- package/src/renderer/js/player.js +232 -0
- package/src/renderer/js/referencePitchTracker.js +130 -0
- package/src/renderer/js/songLoaders.js +334 -0
- package/src/renderer/js/soundtouch-worklet.js +1395 -0
- package/src/renderer/js/webrtcManager.js +511 -0
- package/src/renderer/lib/butterchurn.min.js +6739 -0
- package/src/renderer/lib/butterchurnPresets.min.js +1 -0
- package/src/renderer/lib/cdgraphics-wrapper.js +16 -0
- package/src/renderer/lib/cdgraphics.js +299 -0
- package/src/renderer/public/js/autoTuneWorklet.js +224 -0
- package/src/renderer/public/js/micPitchDetectorWorklet.js +137 -0
- package/src/renderer/public/js/musicAnalysisWorklet.js +216 -0
- package/src/renderer/public/js/phaseVocoderWorklet.js +341 -0
- package/src/renderer/public/js/soundtouch-worklet.js +1395 -0
- package/src/renderer/react-entry.jsx +44 -0
- package/src/renderer/styles/tailwind.css +106 -0
- package/src/renderer/utils/qrCodeGenerator.js +98 -0
- package/src/renderer/vite.config.js +31 -0
- package/src/shared/adapters/BridgeInterface.js +195 -0
- package/src/shared/components/EffectsPanel.jsx +177 -0
- package/src/shared/components/LibraryPanel.jsx +701 -0
- package/src/shared/components/LineDetailCanvas.jsx +167 -0
- package/src/shared/components/LyricLine.jsx +505 -0
- package/src/shared/components/LyricRejection.jsx +84 -0
- package/src/shared/components/LyricSuggestion.jsx +80 -0
- package/src/shared/components/LyricsEditorCanvas.jsx +271 -0
- package/src/shared/components/MixerPanel.jsx +94 -0
- package/src/shared/components/PlayerControls.jsx +206 -0
- package/src/shared/components/PortalSelect.jsx +239 -0
- package/src/shared/components/QueueList.jsx +365 -0
- package/src/shared/components/QuickSearch.jsx +126 -0
- package/src/shared/components/RequestsList.jsx +121 -0
- package/src/shared/components/SongEditor.jsx +1362 -0
- package/src/shared/components/SongInfoBar.jsx +81 -0
- package/src/shared/components/ThemeToggle.jsx +106 -0
- package/src/shared/components/Toast.jsx +30 -0
- package/src/shared/components/VisualizationSettings.jsx +243 -0
- package/src/shared/constants.js +95 -0
- package/src/shared/context/BridgeContext.jsx +32 -0
- package/src/shared/contexts/AudioContext.jsx +37 -0
- package/src/shared/contexts/PlayerContext.jsx +66 -0
- package/src/shared/contexts/SettingsContext.jsx +50 -0
- package/src/shared/defaults.js +158 -0
- package/src/shared/formatUtils.js +59 -0
- package/src/shared/formatUtils.test.js +207 -0
- package/src/shared/hooks/useAppState.js +97 -0
- package/src/shared/hooks/useAudioEngine.js +264 -0
- package/src/shared/hooks/usePlayer.js +89 -0
- package/src/shared/hooks/useSettingsPersistence.js +74 -0
- package/src/shared/hooks/useWebRTC.js +118 -0
- package/src/shared/ipcContracts.js +299 -0
- package/src/shared/package.json +3 -0
- package/src/shared/services/creatorService.js +373 -0
- package/src/shared/services/creatorService.test.js +413 -0
- package/src/shared/services/editorService.js +213 -0
- package/src/shared/services/editorService.test.js +219 -0
- package/src/shared/services/effectsService.js +271 -0
- package/src/shared/services/effectsService.test.js +418 -0
- package/src/shared/services/libraryService.js +438 -0
- package/src/shared/services/libraryService.test.js +474 -0
- package/src/shared/services/mixerService.js +172 -0
- package/src/shared/services/mixerService.test.js +399 -0
- package/src/shared/services/playerService.js +221 -0
- package/src/shared/services/playerService.test.js +357 -0
- package/src/shared/services/preferencesService.js +219 -0
- package/src/shared/services/queueService.js +226 -0
- package/src/shared/services/queueService.test.js +430 -0
- package/src/shared/services/requestsService.js +155 -0
- package/src/shared/services/requestsService.test.js +362 -0
- package/src/shared/services/serverSettingsService.js +151 -0
- package/src/shared/services/settingsService.js +257 -0
- package/src/shared/services/settingsService.test.js +295 -0
- package/src/shared/state/StateManager.js +263 -0
- package/src/shared/utils/audio.js +42 -0
- package/src/shared/utils/format.js +32 -0
- package/src/shared/utils/lyricsUtils.js +162 -0
- package/src/test/setup.js +40 -0
- package/src/utils/cdgLoader.js +180 -0
- package/src/utils/m4aLoader.js +333 -0
- package/src/web/App.jsx +578 -0
- package/src/web/adapters/WebBridge.js +428 -0
- package/src/web/components/PlayerSettingsPanel.jsx +231 -0
- package/src/web/components/SongSearch.jsx +180 -0
- package/src/web/dist/assets/index-0H-RnRrV.js +51 -0
- package/src/web/dist/assets/index-0H-RnRrV.js.map +1 -0
- package/src/web/dist/assets/index-DYW2zB0u.css +1 -0
- package/src/web/dist/index.html +15 -0
- package/src/web/index.html +14 -0
- package/src/web/main.jsx +10 -0
- package/src/web/package-lock.json +1765 -0
- package/src/web/pages/SongRequestPage.jsx +619 -0
- package/src/web/styles/tailwind.css +68 -0
- package/src/web/vite.config.js +27 -0
- package/static/fonts/material-icons.woff2 +0 -0
- package/static/images/butterchurn-screenshots/Aderrasi - Potion of Spirits.png +0 -0
- package/static/images/butterchurn-screenshots/Aderrasi - Songflower _Moss Posy_.png +0 -0
- package/static/images/butterchurn-screenshots/Aderrasi - Storm of the Eye _Thunder_ - mash0000 - quasi pseudo meta concentrics.png +0 -0
- package/static/images/butterchurn-screenshots/Aderrasi _ Geiss - Airhandler _Kali Mix_ - Canvas Mix.png +0 -0
- package/static/images/butterchurn-screenshots/An AdamFX n Martin Infusion 2 flexi - Why The Sky Looks Diffrent Today - AdamFx n Martin Infusion - Tack Tile Disfunction B.png +0 -0
- package/static/images/butterchurn-screenshots/Cope - The Neverending Explosion of Red Liquid Fire.png +0 -0
- proton lights __Krash_s beat code_ _Phat_remix02b.png +0 -0
- package/static/images/butterchurn-screenshots/Eo_S_ _ Phat - cubetrace - v2.png +0 -0
- package/static/images/butterchurn-screenshots/Eo_S_ _ Zylot - skylight _Stained Glass Majesty mix_.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - alien fish pond.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - area 51.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - infused with the spiral.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - mindblob _shiny mix_.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - mindblob mix.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - predator-prey-spirals.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - smashing fractals _acid etching mix_.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - truly soft piece of software - this is generic texturing _Jelly_ .png +0 -0
- package/static/images/butterchurn-screenshots/Flexi _ Martin - astral projection.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi _ Martin - cascading decay swing.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi _ amandio c - piercing 05 - Kopie _2_ - Kopie.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi _ stahlregen - jelly showoff parade.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi_ fishbrain_ Geiss _ Martin - tokamak witchery.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi_ martin _ geiss - dedicated to the sherwin maxawow.png +0 -0
- package/static/images/butterchurn-screenshots/Fumbling_Foo _ Flexi_ Martin_ Orb_ Unchained - Star Nova v7b.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss - Cauldron - painterly 2 _saturation remix_.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss - Reaction Diffusion 2.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss - Spiral Artifact.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss - Thumb Drum.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss _ Flexi _ Martin - disconnected.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss_ Flexi _ Stahlregen - Thumbdrum Tokamak _crossfiring aftermath jelly mashup_.png +0 -0
- package/static/images/butterchurn-screenshots/Goody - The Wild Vort.png +0 -0
- package/static/images/butterchurn-screenshots/Idiot - Star Of Annon.png +0 -0
- package/static/images/butterchurn-screenshots/Krash _ Illusion - Spiral Movement.png +0 -0
- package/static/images/butterchurn-screenshots/Martin - QBikal - Surface Turbulence IIb.png +0 -0
- package/static/images/butterchurn-screenshots/Martin - acid wiring.png +0 -0
- package/static/images/butterchurn-screenshots/Martin - charisma.png +0 -0
- package/static/images/butterchurn-screenshots/Martin - liquid arrows.png +0 -0
- package/static/images/butterchurn-screenshots/Milk Artist At our Best - FED - SlowFast Ft AdamFX n Martin - HD CosmoFX.png +0 -0
- package/static/images/butterchurn-screenshots/ORB - Waaa.png +0 -0
- package/static/images/butterchurn-screenshots/Phat_fiShbRaiN_Eo_S_Mandala_Chasers_remix.png +0 -0
- package/static/images/butterchurn-screenshots/Rovastar - Oozing Resistance.png +0 -0
- package/static/images/butterchurn-screenshots/Rovastar _ Loadus _ Geiss - FractalDrop _Triple Mix_.png +0 -0
- package/static/images/butterchurn-screenshots/TonyMilkdrop - Leonardo Da Vinci_s Balloon _Flexi - merry-go-round _ techstyle_.png +0 -0
- package/static/images/butterchurn-screenshots/TonyMilkdrop - Magellan_s Nebula _Flexi - you enter first _ multiverse_.png +0 -0
- package/static/images/butterchurn-screenshots/Unchained - Rewop.png +0 -0
- package/static/images/butterchurn-screenshots/Unchained - Unified Drag 2.png +0 -0
- package/static/images/butterchurn-screenshots/Unchained _ Rovastar - Wormhole Pillars _Hall of Shadows mix_.png +0 -0
- package/static/images/butterchurn-screenshots/Zylot - Paint Spill _Music Reactive Paint Mix_.png +0 -0
- package/static/images/butterchurn-screenshots/Zylot - Star Ornament.png +0 -0
- package/static/images/butterchurn-screenshots/Zylot - True Visionary _Final Mix_.png +0 -0
- package/static/images/butterchurn-screenshots/_Aderrasi - Wanderer in Curved Space - mash0000 - faclempt kibitzing meshuggana schmaltz _Geiss color mix_.png +0 -0
- package/static/images/butterchurn-screenshots/_Geiss - Artifact 01.png +0 -0
- package/static/images/butterchurn-screenshots/_Geiss - Desert Rose 2.png +0 -0
- package/static/images/butterchurn-screenshots/_Geiss - untitled.png +0 -0
- package/static/images/butterchurn-screenshots/_Mig_049.png +0 -0
- package/static/images/butterchurn-screenshots/_Mig_085.png +0 -0
- package/static/images/butterchurn-screenshots/_Rovastar _ Geiss - Hurricane Nightmare _Posterize Mix_.png +0 -0
- package/static/images/butterchurn-screenshots/___ Royal - Mashup _197_.png +0 -0
- package/static/images/butterchurn-screenshots/___ Royal - Mashup _220_.png +0 -0
- package/static/images/butterchurn-screenshots/___ Royal - Mashup _431_.png +0 -0
- package/static/images/butterchurn-screenshots/cope _ martin - mother-of-pearl.png +0 -0
- package/static/images/butterchurn-screenshots/fiShbRaiN _ Flexi - witchcraft 2_0.png +0 -0
- package/static/images/butterchurn-screenshots/flexi - bouncing balls _double mindblob neon mix_.png +0 -0
- package/static/images/butterchurn-screenshots/flexi - mom_ why the sky looks different today.png +0 -0
- package/static/images/butterchurn-screenshots/flexi - patternton_ district of media_ capitol of the united abstractions of fractopia.png +0 -0
- package/static/images/butterchurn-screenshots/flexi - swing out on the spiral.png +0 -0
- package/static/images/butterchurn-screenshots/flexi - what is the matrix.png +0 -0
- package/static/images/butterchurn-screenshots/flexi _ amandio c - organic _random mashup_.png +0 -0
- package/static/images/butterchurn-screenshots/flexi _ amandio c - organic12-3d-2_milk.png +0 -0
- package/static/images/butterchurn-screenshots/flexi _ fishbrain - neon mindblob grafitti.png +0 -0
- package/static/images/butterchurn-screenshots/flexi _ geiss - pogo cubes vs_ tokamak vs_ game of life _stahls jelly 4_5 finish_.png +0 -0
- package/static/images/butterchurn-screenshots/high-altitude basket unraveling - singh grooves nitrogen argon nz_.png +0 -0
- package/static/images/butterchurn-screenshots/martin - The Bridge of Khazad-Dum.png +0 -0
- package/static/images/butterchurn-screenshots/martin - angel flight.png +0 -0
- package/static/images/butterchurn-screenshots/martin - another kind of groove.png +0 -0
- package/static/images/butterchurn-screenshots/martin - bombyx mori.png +0 -0
- package/static/images/butterchurn-screenshots/martin - castle in the air.png +0 -0
- package/static/images/butterchurn-screenshots/martin - chain breaker.png +0 -0
- package/static/images/butterchurn-screenshots/martin - disco mix 4.png +0 -0
- package/static/images/butterchurn-screenshots/martin - extreme heat.png +0 -0
- package/static/images/butterchurn-screenshots/martin - frosty caves 2.png +0 -0
- package/static/images/butterchurn-screenshots/martin - fruit machine.png +0 -0
- package/static/images/butterchurn-screenshots/martin - ghost city.png +0 -0
- package/static/images/butterchurn-screenshots/martin - glass corridor.png +0 -0
- package/static/images/butterchurn-screenshots/martin - infinity _2010 update_.png +0 -0
- package/static/images/butterchurn-screenshots/martin - mandelbox explorer - high speed demo version.png +0 -0
- package/static/images/butterchurn-screenshots/martin - mucus cervix.png +0 -0
- package/static/images/butterchurn-screenshots/martin - reflections on black tiles.png +0 -0
- package/static/images/butterchurn-screenshots/martin - stormy sea _2010 update_.png +0 -0
- package/static/images/butterchurn-screenshots/martin - witchcraft reloaded.png +0 -0
- package/static/images/butterchurn-screenshots/martin _ flexi - diamond cutter _prismaticvortex_com_ - camille - i wish i wish i wish i was constrained.png +0 -0
- package/static/images/butterchurn-screenshots/martin _shadow harlequins shape code_ - fata morgana.png +0 -0
- package/static/images/butterchurn-screenshots/martin_ flexi_ fishbrain _ sto - enterstate _random mashup_.png +0 -0
- package/static/images/butterchurn-screenshots/sawtooth grin roam.png +0 -0
- package/static/images/butterchurn-screenshots/shifter - dark tides bdrv mix 2.png +0 -0
- package/static/images/butterchurn-screenshots/suksma - Rovastar - Sunflower Passion _Enlightment Mix__Phat_edit _ flexi und martin shaders - circumflex in character classes in regular expression.png +0 -0
- package/static/images/butterchurn-screenshots/suksma - heretical crosscut playpen.png +0 -0
- package/static/images/butterchurn-screenshots/suksma - uninitialized variabowl _hydroponic chronic_.png +0 -0
- package/static/images/butterchurn-screenshots/suksma - vector exp 1 - couldn_t not.png +0 -0
- package/static/images/butterchurn-screenshots/yin - 191 - Temporal singularities.png +0 -0
- package/static/images/logo-512.png +0 -0
- package/static/images/logo.png +0 -0
- package/static/loukai-logo.png +0 -0
- package/static/screenshot-generator.html +610 -0
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'react';
|
|
2
|
+
import { io } from 'socket.io-client';
|
|
3
|
+
import { getFormatIcon, formatDuration } from '../../shared/formatUtils.js';
|
|
4
|
+
import { Toast } from '../../shared/components/Toast.jsx';
|
|
5
|
+
import { ThemeToggle } from '../../shared/components/ThemeToggle.jsx';
|
|
6
|
+
|
|
7
|
+
export function SongRequestPage() {
|
|
8
|
+
const [userName, setUserName] = useState(null);
|
|
9
|
+
const [nameInput, setNameInput] = useState('');
|
|
10
|
+
const [serverName, setServerName] = useState('Loukai Karaoke');
|
|
11
|
+
const [allowRequests, setAllowRequests] = useState(true);
|
|
12
|
+
const [songs, setSongs] = useState([]);
|
|
13
|
+
const [availableLetters, setAvailableLetters] = useState([]);
|
|
14
|
+
const [currentLetter, setCurrentLetter] = useState('A');
|
|
15
|
+
const [currentPage, setCurrentPage] = useState(1);
|
|
16
|
+
const [totalPages, setTotalPages] = useState(1);
|
|
17
|
+
const [queue, setQueue] = useState([]);
|
|
18
|
+
const [quickSearchTerm, setQuickSearchTerm] = useState('');
|
|
19
|
+
const [quickSearchResults, setQuickSearchResults] = useState([]);
|
|
20
|
+
const [showQuickSearch, setShowQuickSearch] = useState(false);
|
|
21
|
+
const [selectedSong, setSelectedSong] = useState(null);
|
|
22
|
+
const [showRequestModal, setShowRequestModal] = useState(false);
|
|
23
|
+
const [requestMessage, setRequestMessage] = useState('');
|
|
24
|
+
const [toast, setToast] = useState(null);
|
|
25
|
+
|
|
26
|
+
const socketRef = useRef(null);
|
|
27
|
+
const quickSearchRef = useRef(null);
|
|
28
|
+
|
|
29
|
+
const showToast = (message, type = 'info') => {
|
|
30
|
+
setToast({ message, type });
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Load user name from localStorage on mount
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const storedName = localStorage.getItem('karaoke-user-name');
|
|
36
|
+
if (storedName && storedName.trim()) {
|
|
37
|
+
setUserName(storedName.trim());
|
|
38
|
+
}
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
// Initialize socket connection
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
socketRef.current = io();
|
|
44
|
+
|
|
45
|
+
socketRef.current.on('queue-update', (data) => {
|
|
46
|
+
setQueue(data.queue || []);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return () => {
|
|
50
|
+
socketRef.current?.disconnect();
|
|
51
|
+
};
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
// Load server info when user is set
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!userName) return;
|
|
57
|
+
|
|
58
|
+
fetch('/api/info')
|
|
59
|
+
.then((res) => res.json())
|
|
60
|
+
.then((info) => {
|
|
61
|
+
setServerName(info.serverName || 'Loukai Karaoke');
|
|
62
|
+
setAllowRequests(info.allowRequests !== false);
|
|
63
|
+
document.title = `${info.serverName || 'Karaoke'} - Song Requests`;
|
|
64
|
+
})
|
|
65
|
+
.catch((err) => console.error('Failed to load server info:', err));
|
|
66
|
+
}, [userName]);
|
|
67
|
+
|
|
68
|
+
// Load available letters when user is set
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!userName) return;
|
|
71
|
+
|
|
72
|
+
fetch('/api/letters')
|
|
73
|
+
.then((res) => res.json())
|
|
74
|
+
.then((data) => {
|
|
75
|
+
const letters = data.letters || [];
|
|
76
|
+
setAvailableLetters(letters);
|
|
77
|
+
const firstLetter = letters.includes('A') ? 'A' : letters[0];
|
|
78
|
+
if (firstLetter) {
|
|
79
|
+
loadLetterPage(firstLetter, 1);
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
.catch((err) => console.error('Failed to load letters:', err));
|
|
83
|
+
}, [userName]);
|
|
84
|
+
|
|
85
|
+
// Load queue periodically
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (!userName) return;
|
|
88
|
+
|
|
89
|
+
const loadQueue = () => {
|
|
90
|
+
fetch('/api/queue')
|
|
91
|
+
.then((res) => res.json())
|
|
92
|
+
.then((data) => setQueue(data.queue || []))
|
|
93
|
+
.catch((err) => console.error('Failed to load queue:', err));
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
loadQueue();
|
|
97
|
+
const interval = setInterval(loadQueue, 10000);
|
|
98
|
+
return () => clearInterval(interval);
|
|
99
|
+
}, [userName]);
|
|
100
|
+
|
|
101
|
+
// Quick search handler
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!quickSearchTerm.trim()) {
|
|
104
|
+
setQuickSearchResults([]);
|
|
105
|
+
setShowQuickSearch(false);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const search = async () => {
|
|
110
|
+
try {
|
|
111
|
+
const res = await fetch(`/api/search?q=${encodeURIComponent(quickSearchTerm)}`);
|
|
112
|
+
const data = await res.json();
|
|
113
|
+
setQuickSearchResults(data.results || []);
|
|
114
|
+
setShowQuickSearch(true);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.error('Search failed:', err);
|
|
117
|
+
setQuickSearchResults([]);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const debounce = setTimeout(search, 300);
|
|
122
|
+
return () => clearTimeout(debounce);
|
|
123
|
+
}, [quickSearchTerm]);
|
|
124
|
+
|
|
125
|
+
// Click outside to close quick search
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
const handleClickOutside = (e) => {
|
|
128
|
+
if (quickSearchRef.current && !quickSearchRef.current.contains(e.target)) {
|
|
129
|
+
setShowQuickSearch(false);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
134
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
135
|
+
}, []);
|
|
136
|
+
|
|
137
|
+
const loadLetterPage = async (letter, page) => {
|
|
138
|
+
try {
|
|
139
|
+
const res = await fetch(
|
|
140
|
+
`/api/songs/letter/${encodeURIComponent(letter)}?page=${page}&limit=50`
|
|
141
|
+
);
|
|
142
|
+
const data = await res.json();
|
|
143
|
+
|
|
144
|
+
setSongs(data.songs || []);
|
|
145
|
+
setCurrentLetter(letter);
|
|
146
|
+
setCurrentPage(page);
|
|
147
|
+
setTotalPages(data.pagination?.totalPages || 1);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.error('Failed to load songs:', err);
|
|
150
|
+
setSongs([]);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const handleNameSubmit = () => {
|
|
155
|
+
const name = nameInput.trim();
|
|
156
|
+
if (name) {
|
|
157
|
+
setUserName(name);
|
|
158
|
+
localStorage.setItem('karaoke-user-name', name);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const handleRequestSong = (song) => {
|
|
163
|
+
if (!allowRequests) return;
|
|
164
|
+
setSelectedSong(song);
|
|
165
|
+
setRequestMessage('');
|
|
166
|
+
setShowRequestModal(true);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const submitRequest = async () => {
|
|
170
|
+
if (!selectedSong) return;
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const res = await fetch('/api/request', {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: { 'Content-Type': 'application/json' },
|
|
176
|
+
body: JSON.stringify({
|
|
177
|
+
songId: selectedSong.path,
|
|
178
|
+
requesterName: userName,
|
|
179
|
+
message: requestMessage,
|
|
180
|
+
}),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
if (res.ok) {
|
|
184
|
+
const data = await res.json();
|
|
185
|
+
setShowRequestModal(false);
|
|
186
|
+
setSelectedSong(null);
|
|
187
|
+
setRequestMessage('');
|
|
188
|
+
showToast(data.message || 'Request submitted!', 'success');
|
|
189
|
+
} else {
|
|
190
|
+
const error = await res.json();
|
|
191
|
+
showToast(error.error || 'Request failed', 'error');
|
|
192
|
+
}
|
|
193
|
+
} catch (err) {
|
|
194
|
+
console.error('Request failed:', err);
|
|
195
|
+
showToast('Failed to submit request', 'error');
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// Smart pagination - show limited page numbers around current page
|
|
200
|
+
const getPageNumbers = () => {
|
|
201
|
+
const maxButtons = 7; // Show max 7 page buttons
|
|
202
|
+
if (totalPages <= maxButtons) {
|
|
203
|
+
// Show all pages if total is small
|
|
204
|
+
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const pages = [];
|
|
208
|
+
const halfRange = Math.floor((maxButtons - 3) / 2); // Reserve 3 for first, last, and ellipsis
|
|
209
|
+
|
|
210
|
+
// Always show first page
|
|
211
|
+
pages.push(1);
|
|
212
|
+
|
|
213
|
+
let startPage = Math.max(2, currentPage - halfRange);
|
|
214
|
+
let endPage = Math.min(totalPages - 1, currentPage + halfRange);
|
|
215
|
+
|
|
216
|
+
// Adjust if we're near the beginning
|
|
217
|
+
if (currentPage <= halfRange + 2) {
|
|
218
|
+
endPage = Math.min(maxButtons - 1, totalPages - 1);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Adjust if we're near the end
|
|
222
|
+
if (currentPage >= totalPages - halfRange - 1) {
|
|
223
|
+
startPage = Math.max(2, totalPages - maxButtons + 2);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Add ellipsis if needed before
|
|
227
|
+
if (startPage > 2) {
|
|
228
|
+
pages.push('...');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Add middle pages
|
|
232
|
+
for (let i = startPage; i <= endPage; i++) {
|
|
233
|
+
pages.push(i);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Add ellipsis if needed after
|
|
237
|
+
if (endPage < totalPages - 1) {
|
|
238
|
+
pages.push('...');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Always show last page
|
|
242
|
+
if (totalPages > 1) {
|
|
243
|
+
pages.push(totalPages);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return pages;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const allLetters = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''), '#'];
|
|
250
|
+
|
|
251
|
+
// Name prompt modal
|
|
252
|
+
if (!userName) {
|
|
253
|
+
return (
|
|
254
|
+
<div className="fixed top-0 left-0 w-full h-full bg-black/90 flex items-center justify-center z-[1000]">
|
|
255
|
+
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 max-w-md w-[90%] text-center border border-gray-300 dark:border-gray-600">
|
|
256
|
+
<div className="text-3xl font-bold mb-6 text-gray-900 dark:text-white">
|
|
257
|
+
Loukai Karaoke
|
|
258
|
+
</div>
|
|
259
|
+
<div className="text-gray-600 dark:text-gray-300 mb-8 leading-relaxed">
|
|
260
|
+
Please enter your name to request songs
|
|
261
|
+
</div>
|
|
262
|
+
<input
|
|
263
|
+
type="text"
|
|
264
|
+
className="w-full px-3 py-3 border-2 border-gray-300 dark:border-gray-600 rounded-lg bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-white text-base mb-6 focus:outline-none focus:border-blue-600 dark:focus:border-blue-500"
|
|
265
|
+
placeholder="Your name..."
|
|
266
|
+
maxLength={50}
|
|
267
|
+
value={nameInput}
|
|
268
|
+
onChange={(e) => setNameInput(e.target.value)}
|
|
269
|
+
onKeyPress={(e) => e.key === 'Enter' && nameInput.trim() && handleNameSubmit()}
|
|
270
|
+
autoFocus
|
|
271
|
+
/>
|
|
272
|
+
<button
|
|
273
|
+
className="bg-blue-600 text-white border-none px-6 py-3 rounded-lg text-base cursor-pointer w-full hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
|
274
|
+
disabled={!nameInput.trim()}
|
|
275
|
+
onClick={handleNameSubmit}
|
|
276
|
+
>
|
|
277
|
+
Continue
|
|
278
|
+
</button>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Main content
|
|
285
|
+
return (
|
|
286
|
+
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white">
|
|
287
|
+
<div className="bg-white dark:bg-gray-800 py-8 px-8 text-center border-b-2 border-blue-600 dark:border-blue-500 relative">
|
|
288
|
+
<div className="absolute top-4 right-4">
|
|
289
|
+
<ThemeToggle />
|
|
290
|
+
</div>
|
|
291
|
+
<h1 className="m-0 mb-2 text-4xl text-gray-900 dark:text-white">{serverName}</h1>
|
|
292
|
+
<div className="text-gray-600 dark:text-gray-400 text-lg">Request your favorite songs!</div>
|
|
293
|
+
</div>
|
|
294
|
+
|
|
295
|
+
<div className="max-w-6xl mx-auto p-5">
|
|
296
|
+
{/* Quick Search */}
|
|
297
|
+
<div
|
|
298
|
+
className="bg-white dark:bg-gray-800 rounded-lg p-4 mb-5 border border-gray-200 dark:border-gray-700 relative"
|
|
299
|
+
ref={quickSearchRef}
|
|
300
|
+
>
|
|
301
|
+
<div className="text-base mb-2 flex items-center gap-2 text-gray-900 dark:text-white">
|
|
302
|
+
<span className="material-icons">search</span>
|
|
303
|
+
<span>Quick Song Search</span>
|
|
304
|
+
</div>
|
|
305
|
+
<input
|
|
306
|
+
type="text"
|
|
307
|
+
className="w-full px-3 py-3 bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md text-gray-900 dark:text-white text-base focus:outline-none focus:border-blue-600 dark:focus:border-blue-500 focus:bg-white dark:focus:bg-gray-600"
|
|
308
|
+
placeholder="Search songs to request..."
|
|
309
|
+
value={quickSearchTerm}
|
|
310
|
+
onChange={(e) => setQuickSearchTerm(e.target.value)}
|
|
311
|
+
onFocus={() => quickSearchTerm && setShowQuickSearch(true)}
|
|
312
|
+
/>
|
|
313
|
+
{showQuickSearch && (
|
|
314
|
+
<div className="absolute top-full left-4 right-4 bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 border-t-0 rounded-b-md max-h-[300px] overflow-y-auto z-[1000] -mt-px">
|
|
315
|
+
{quickSearchResults.length === 0 ? (
|
|
316
|
+
<div className="p-5 text-center text-gray-500 dark:text-gray-400">
|
|
317
|
+
No songs found
|
|
318
|
+
</div>
|
|
319
|
+
) : (
|
|
320
|
+
quickSearchResults.slice(0, 8).map((song) => (
|
|
321
|
+
<div
|
|
322
|
+
key={song.path}
|
|
323
|
+
className="p-3.5 cursor-pointer border-b border-gray-200 dark:border-gray-600 flex justify-between items-start gap-3 transition-colors hover:bg-gray-200 dark:hover:bg-gray-600 last:border-b-0"
|
|
324
|
+
onClick={() => {
|
|
325
|
+
handleRequestSong(song);
|
|
326
|
+
setShowQuickSearch(false);
|
|
327
|
+
setQuickSearchTerm('');
|
|
328
|
+
}}
|
|
329
|
+
>
|
|
330
|
+
<div className="flex-1 flex flex-col items-start">
|
|
331
|
+
<div className="flex items-baseline gap-3 flex-wrap">
|
|
332
|
+
<div className="font-semibold text-[0.95rem] inline-flex items-center gap-2 text-gray-900 dark:text-white">
|
|
333
|
+
<span className="text-xs">{getFormatIcon(song.format)}</span>
|
|
334
|
+
{song.title}
|
|
335
|
+
</div>
|
|
336
|
+
<div className="text-[0.9rem] text-gray-600 dark:text-gray-400">
|
|
337
|
+
{song.artist}
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
{(song.album || song.year || song.genre) && (
|
|
341
|
+
<div className="text-[0.82rem] text-gray-500 dark:text-gray-500 mt-1">
|
|
342
|
+
{song.album && <span>{song.album}</span>}
|
|
343
|
+
{song.year && (
|
|
344
|
+
<span className="before:content-['_•_'] before:text-gray-400 dark:before:text-gray-600">
|
|
345
|
+
{song.year}
|
|
346
|
+
</span>
|
|
347
|
+
)}
|
|
348
|
+
{song.genre && (
|
|
349
|
+
<span className="before:content-['_•_'] before:text-gray-400 dark:before:text-gray-600">
|
|
350
|
+
{song.genre}
|
|
351
|
+
</span>
|
|
352
|
+
)}
|
|
353
|
+
</div>
|
|
354
|
+
)}
|
|
355
|
+
</div>
|
|
356
|
+
<span className="text-gray-500 dark:text-gray-400 text-[0.9rem] font-medium">
|
|
357
|
+
{formatDuration(song.duration)}
|
|
358
|
+
</span>
|
|
359
|
+
</div>
|
|
360
|
+
))
|
|
361
|
+
)}
|
|
362
|
+
</div>
|
|
363
|
+
)}
|
|
364
|
+
</div>
|
|
365
|
+
|
|
366
|
+
{/* Songs Section */}
|
|
367
|
+
<div className="bg-white dark:bg-gray-800 rounded-lg p-5 mb-5 border border-gray-200 dark:border-gray-700">
|
|
368
|
+
<div className="flex justify-between items-center mb-4">
|
|
369
|
+
<div className="text-xl font-semibold text-gray-900 dark:text-white">
|
|
370
|
+
Available Songs
|
|
371
|
+
</div>
|
|
372
|
+
<div className="text-gray-500 dark:text-gray-400 text-sm">{songs.length} songs</div>
|
|
373
|
+
</div>
|
|
374
|
+
|
|
375
|
+
{/* Alphabet Navigation */}
|
|
376
|
+
<div className="mb-4">
|
|
377
|
+
<div className="text-sm text-gray-600 dark:text-gray-300 mb-2">Browse by Artist:</div>
|
|
378
|
+
<div className="flex flex-wrap gap-1.5">
|
|
379
|
+
{allLetters.map((letter) => {
|
|
380
|
+
const hasContent = availableLetters.includes(letter);
|
|
381
|
+
return (
|
|
382
|
+
<button
|
|
383
|
+
key={letter}
|
|
384
|
+
className={`px-3 py-2 rounded border transition-all text-sm min-w-[40px] ${
|
|
385
|
+
currentLetter === letter
|
|
386
|
+
? 'bg-blue-600 dark:bg-blue-500 border-blue-600 dark:border-blue-500 text-white'
|
|
387
|
+
: hasContent
|
|
388
|
+
? 'bg-gray-100 dark:bg-gray-700 border-gray-200 dark:border-gray-600 text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-600 hover:border-blue-600 dark:hover:border-blue-500'
|
|
389
|
+
: 'bg-gray-100 dark:bg-gray-700 border-gray-200 dark:border-gray-600 text-gray-900 dark:text-white opacity-30 cursor-not-allowed'
|
|
390
|
+
}`}
|
|
391
|
+
disabled={!hasContent}
|
|
392
|
+
onClick={() => loadLetterPage(letter, 1)}
|
|
393
|
+
>
|
|
394
|
+
{letter}
|
|
395
|
+
</button>
|
|
396
|
+
);
|
|
397
|
+
})}
|
|
398
|
+
</div>
|
|
399
|
+
</div>
|
|
400
|
+
|
|
401
|
+
{/* Songs List */}
|
|
402
|
+
<div className="max-h-[500px] overflow-y-auto">
|
|
403
|
+
{songs.length === 0 ? (
|
|
404
|
+
<div className="text-center py-16 px-5 text-gray-500 dark:text-gray-500">
|
|
405
|
+
<div className="material-icons text-6xl mb-5 opacity-30">library_music</div>
|
|
406
|
+
<div>No songs found</div>
|
|
407
|
+
</div>
|
|
408
|
+
) : (
|
|
409
|
+
songs.map((song) => (
|
|
410
|
+
<div
|
|
411
|
+
key={song.path}
|
|
412
|
+
className="flex justify-between items-start p-4 bg-gray-100 dark:bg-gray-700 rounded-md mb-2 transition-colors gap-4"
|
|
413
|
+
>
|
|
414
|
+
<div className="flex-1 flex flex-col items-start">
|
|
415
|
+
<div className="flex items-baseline gap-3 flex-wrap">
|
|
416
|
+
<div className="font-semibold text-base text-gray-900 dark:text-white inline-flex items-center gap-2">
|
|
417
|
+
{getFormatIcon(song.format)} {song.title}
|
|
418
|
+
</div>
|
|
419
|
+
<div className="text-[0.95rem] text-gray-600 dark:text-gray-400">
|
|
420
|
+
{song.artist}
|
|
421
|
+
</div>
|
|
422
|
+
</div>
|
|
423
|
+
{(song.album || song.year || song.genre) && (
|
|
424
|
+
<div className="text-[0.85rem] text-gray-500 dark:text-gray-500 mt-1">
|
|
425
|
+
{song.album && <span>{song.album}</span>}
|
|
426
|
+
{song.year && (
|
|
427
|
+
<span className="before:content-['_•_'] before:text-gray-400 dark:before:text-gray-600">
|
|
428
|
+
{song.year}
|
|
429
|
+
</span>
|
|
430
|
+
)}
|
|
431
|
+
{song.genre && (
|
|
432
|
+
<span className="before:content-['_•_'] before:text-gray-400 dark:before:text-gray-600">
|
|
433
|
+
{song.genre}
|
|
434
|
+
</span>
|
|
435
|
+
)}
|
|
436
|
+
</div>
|
|
437
|
+
)}
|
|
438
|
+
</div>
|
|
439
|
+
<div className="flex items-start gap-4 flex-shrink-0 pt-0.5">
|
|
440
|
+
<span className="text-gray-500 dark:text-gray-400 text-sm font-medium min-w-[45px] text-right">
|
|
441
|
+
{formatDuration(song.duration)}
|
|
442
|
+
</span>
|
|
443
|
+
<button
|
|
444
|
+
className="px-5 py-2.5 bg-blue-600 dark:bg-blue-500 border-none rounded-md text-white cursor-pointer flex items-center gap-1.5 font-medium text-[0.95rem] transition-all whitespace-nowrap hover:bg-blue-700 dark:hover:bg-blue-600 hover:-translate-y-px disabled:bg-gray-500 disabled:cursor-not-allowed disabled:opacity-50"
|
|
445
|
+
disabled={!allowRequests}
|
|
446
|
+
onClick={() => handleRequestSong(song)}
|
|
447
|
+
>
|
|
448
|
+
<span className="material-icons" style={{ fontSize: 18 }}>
|
|
449
|
+
add
|
|
450
|
+
</span>
|
|
451
|
+
Request
|
|
452
|
+
</button>
|
|
453
|
+
</div>
|
|
454
|
+
</div>
|
|
455
|
+
))
|
|
456
|
+
)}
|
|
457
|
+
</div>
|
|
458
|
+
|
|
459
|
+
{/* Page Navigation */}
|
|
460
|
+
{totalPages > 1 && (
|
|
461
|
+
<div className="flex justify-center items-center gap-2 mt-4 p-3 bg-gray-100 dark:bg-gray-700 rounded-md flex-wrap">
|
|
462
|
+
<button
|
|
463
|
+
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded text-gray-900 dark:text-white cursor-pointer text-sm transition-colors hover:bg-gray-200 dark:hover:bg-gray-600 hover:border-blue-600 dark:hover:border-blue-500 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
464
|
+
onClick={() => loadLetterPage(currentLetter, currentPage - 1)}
|
|
465
|
+
disabled={currentPage === 1}
|
|
466
|
+
>
|
|
467
|
+
Previous
|
|
468
|
+
</button>
|
|
469
|
+
<div className="flex gap-1 items-center">
|
|
470
|
+
{getPageNumbers().map((page, index) => {
|
|
471
|
+
if (page === '...') {
|
|
472
|
+
return (
|
|
473
|
+
<span
|
|
474
|
+
key={`ellipsis-${index}`}
|
|
475
|
+
className="px-2 text-gray-500 dark:text-gray-400 text-sm"
|
|
476
|
+
>
|
|
477
|
+
...
|
|
478
|
+
</span>
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
return (
|
|
482
|
+
<button
|
|
483
|
+
key={page}
|
|
484
|
+
className={`px-3 py-2 border rounded cursor-pointer text-sm min-w-[40px] transition-all ${
|
|
485
|
+
page === currentPage
|
|
486
|
+
? 'bg-blue-600 dark:bg-blue-500 border-blue-600 dark:border-blue-500 font-semibold text-white'
|
|
487
|
+
: 'bg-gray-100 dark:bg-gray-700 border-gray-200 dark:border-gray-600 text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-600 hover:border-blue-600 dark:hover:border-blue-500'
|
|
488
|
+
}`}
|
|
489
|
+
onClick={() => loadLetterPage(currentLetter, page)}
|
|
490
|
+
>
|
|
491
|
+
{page}
|
|
492
|
+
</button>
|
|
493
|
+
);
|
|
494
|
+
})}
|
|
495
|
+
</div>
|
|
496
|
+
<span className="text-gray-500 dark:text-gray-400 text-[0.85rem] ml-2">
|
|
497
|
+
({songs.length} songs)
|
|
498
|
+
</span>
|
|
499
|
+
<button
|
|
500
|
+
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded text-gray-900 dark:text-white cursor-pointer text-sm transition-colors hover:bg-gray-200 dark:hover:bg-gray-600 hover:border-blue-600 dark:hover:border-blue-500 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
501
|
+
onClick={() => loadLetterPage(currentLetter, currentPage + 1)}
|
|
502
|
+
disabled={currentPage === totalPages}
|
|
503
|
+
>
|
|
504
|
+
Next
|
|
505
|
+
</button>
|
|
506
|
+
</div>
|
|
507
|
+
)}
|
|
508
|
+
</div>
|
|
509
|
+
|
|
510
|
+
{/* Queue Section */}
|
|
511
|
+
<div className="bg-white dark:bg-gray-800 rounded-lg p-5 border border-gray-200 dark:border-gray-700">
|
|
512
|
+
<div className="text-xl font-semibold mb-4 flex items-center gap-2 text-gray-900 dark:text-white">
|
|
513
|
+
<span className="material-icons">queue_music</span>
|
|
514
|
+
Queue ({queue.length})
|
|
515
|
+
</div>
|
|
516
|
+
<ul className="list-none p-0 m-0">
|
|
517
|
+
{queue.length === 0 ? (
|
|
518
|
+
<div className="text-center py-16 px-5 text-gray-500 dark:text-gray-500">
|
|
519
|
+
<div className="material-icons text-6xl mb-5 opacity-30">queue_music</div>
|
|
520
|
+
<div>Queue is empty</div>
|
|
521
|
+
</div>
|
|
522
|
+
) : (
|
|
523
|
+
queue.map((item, index) => (
|
|
524
|
+
<li
|
|
525
|
+
key={item.id}
|
|
526
|
+
className="flex justify-between items-center p-3 bg-gray-100 dark:bg-gray-700 rounded-md mb-2"
|
|
527
|
+
>
|
|
528
|
+
<div className="flex items-center">
|
|
529
|
+
<div className="w-8 h-8 flex items-center justify-center bg-blue-600 dark:bg-blue-500 rounded-full font-semibold mr-3 text-white">
|
|
530
|
+
{index + 1}
|
|
531
|
+
</div>
|
|
532
|
+
<div className="flex-1">
|
|
533
|
+
<div className="font-medium mb-1 text-gray-900 dark:text-white">
|
|
534
|
+
{item.title}
|
|
535
|
+
</div>
|
|
536
|
+
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
537
|
+
{item.artist} • Singer: {item.requester}
|
|
538
|
+
</div>
|
|
539
|
+
</div>
|
|
540
|
+
</div>
|
|
541
|
+
<span className="text-gray-500 dark:text-gray-400 text-sm">
|
|
542
|
+
{formatDuration(item.duration)}
|
|
543
|
+
</span>
|
|
544
|
+
</li>
|
|
545
|
+
))
|
|
546
|
+
)}
|
|
547
|
+
</ul>
|
|
548
|
+
</div>
|
|
549
|
+
|
|
550
|
+
{/* Footer */}
|
|
551
|
+
<footer className="mt-8 py-6 text-center">
|
|
552
|
+
<a
|
|
553
|
+
href="https://loukai.app"
|
|
554
|
+
target="_blank"
|
|
555
|
+
rel="noopener noreferrer"
|
|
556
|
+
className="inline-flex items-center gap-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors text-sm"
|
|
557
|
+
>
|
|
558
|
+
<img src="/static/loukai-logo.png" alt="Loukai" className="w-6 h-6 rounded" />
|
|
559
|
+
<span>Powered by Loukai</span>
|
|
560
|
+
</a>
|
|
561
|
+
</footer>
|
|
562
|
+
</div>
|
|
563
|
+
|
|
564
|
+
{/* Request Modal */}
|
|
565
|
+
{showRequestModal && selectedSong && (
|
|
566
|
+
<div
|
|
567
|
+
className="fixed top-0 left-0 w-full h-full bg-black/80 z-[1000] flex items-center justify-center"
|
|
568
|
+
onClick={() => setShowRequestModal(false)}
|
|
569
|
+
>
|
|
570
|
+
<div
|
|
571
|
+
className="bg-white dark:bg-gray-800 rounded-lg p-8 max-w-lg w-[90%] border border-gray-200 dark:border-gray-700"
|
|
572
|
+
onClick={(e) => e.stopPropagation()}
|
|
573
|
+
>
|
|
574
|
+
<div className="mb-5">
|
|
575
|
+
<div className="text-2xl mb-2 text-gray-900 dark:text-white">Request Song</div>
|
|
576
|
+
<div className="text-gray-900 dark:text-white text-xl font-bold mb-2 p-2.5 bg-gray-100 dark:bg-gray-700 rounded-md border-l-4 border-blue-600 dark:border-blue-500">
|
|
577
|
+
{selectedSong.title} - {selectedSong.artist}
|
|
578
|
+
</div>
|
|
579
|
+
</div>
|
|
580
|
+
|
|
581
|
+
<div className="mb-5">
|
|
582
|
+
<label className="block mb-2 text-gray-600 dark:text-gray-300 text-sm">
|
|
583
|
+
Your Name
|
|
584
|
+
</label>
|
|
585
|
+
<div className="px-3 py-2 bg-gray-100 dark:bg-gray-900 border border-gray-300 dark:border-gray-600 rounded-md text-gray-900 dark:text-white font-medium">
|
|
586
|
+
{userName}
|
|
587
|
+
</div>
|
|
588
|
+
</div>
|
|
589
|
+
|
|
590
|
+
<div className="mb-5">
|
|
591
|
+
<label className="block mb-2 text-gray-600 dark:text-gray-300 text-sm">
|
|
592
|
+
Message (optional)
|
|
593
|
+
</label>
|
|
594
|
+
<textarea
|
|
595
|
+
className="w-full px-2.5 py-2.5 bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded text-gray-900 dark:text-white text-base resize-y min-h-[80px] focus:outline-none focus:border-blue-600 dark:focus:border-blue-500"
|
|
596
|
+
placeholder="Any special requests or notes..."
|
|
597
|
+
value={requestMessage}
|
|
598
|
+
onChange={(e) => setRequestMessage(e.target.value)}
|
|
599
|
+
maxLength={200}
|
|
600
|
+
/>
|
|
601
|
+
</div>
|
|
602
|
+
|
|
603
|
+
<div className="flex gap-2.5 justify-end">
|
|
604
|
+
<button className="btn btn-secondary" onClick={() => setShowRequestModal(false)}>
|
|
605
|
+
Cancel
|
|
606
|
+
</button>
|
|
607
|
+
<button className="btn btn-primary" onClick={submitRequest}>
|
|
608
|
+
Submit Request
|
|
609
|
+
</button>
|
|
610
|
+
</div>
|
|
611
|
+
</div>
|
|
612
|
+
</div>
|
|
613
|
+
)}
|
|
614
|
+
|
|
615
|
+
{/* Toast notifications */}
|
|
616
|
+
{toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
|
|
617
|
+
</div>
|
|
618
|
+
);
|
|
619
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/* Tailwind CSS directives */
|
|
2
|
+
@tailwind base;
|
|
3
|
+
@tailwind components;
|
|
4
|
+
@tailwind utilities;
|
|
5
|
+
|
|
6
|
+
/* Custom layer for web-specific components */
|
|
7
|
+
@layer components {
|
|
8
|
+
/* Button base styles */
|
|
9
|
+
.btn {
|
|
10
|
+
@apply px-4 py-2 rounded-md font-medium transition-all duration-200;
|
|
11
|
+
@apply bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white border border-gray-200 dark:border-gray-600;
|
|
12
|
+
@apply hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.btn-primary {
|
|
16
|
+
@apply bg-blue-600 dark:bg-blue-500 border-blue-600 dark:border-blue-500 text-white;
|
|
17
|
+
@apply hover:bg-blue-700 dark:hover:bg-blue-600;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.btn-secondary {
|
|
21
|
+
@apply bg-transparent text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700;
|
|
22
|
+
@apply hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.btn-sm {
|
|
26
|
+
@apply px-3 py-1.5 text-sm;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.btn-xs {
|
|
30
|
+
@apply px-2 py-0.5 text-xs min-h-[24px];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* Input styles */
|
|
34
|
+
.input {
|
|
35
|
+
@apply w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md;
|
|
36
|
+
@apply text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-500 transition-colors;
|
|
37
|
+
@apply focus:outline-none focus:border-blue-600 dark:focus:border-blue-500 focus:ring-1 focus:ring-blue-600 dark:focus:ring-blue-500;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* Card styles */
|
|
41
|
+
.card {
|
|
42
|
+
@apply bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-5;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* Badge styles */
|
|
46
|
+
.badge {
|
|
47
|
+
@apply inline-flex items-center justify-center min-w-[20px] h-5 px-1.5;
|
|
48
|
+
@apply bg-red-600 text-white rounded-full text-[11px] font-semibold leading-none;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* Dark mode base styles */
|
|
53
|
+
body {
|
|
54
|
+
@apply bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* Custom scrollbar styles */
|
|
58
|
+
::-webkit-scrollbar {
|
|
59
|
+
@apply w-2 h-2;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
::-webkit-scrollbar-track {
|
|
63
|
+
@apply bg-gray-100 dark:bg-gray-800 rounded;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
::-webkit-scrollbar-thumb {
|
|
67
|
+
@apply bg-gray-300 dark:bg-gray-600 rounded hover:bg-gray-400 dark:hover:bg-gray-500;
|
|
68
|
+
}
|