diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..1cf417f --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "es5", + "tabWidth": 2, + "printWidth": 80, + "useTabs": false +} diff --git a/app/globals.css b/app/globals.css deleted file mode 100644 index 6b717ad..0000000 --- a/app/globals.css +++ /dev/null @@ -1,21 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -:root { - --background: #ffffff; - --foreground: #171717; -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - -body { - color: var(--foreground); - background: var(--background); - font-family: Arial, Helvetica, sans-serif; -} diff --git a/app/layout.tsx b/app/layout.tsx index a36cde0..a731710 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,27 +1,27 @@ -import type { Metadata } from "next"; -import localFont from "next/font/local"; -import "./globals.css"; +import type { Metadata } from 'next' +import localFont from 'next/font/local' +import '@/styles/globals.css' const geistSans = localFont({ - src: "./fonts/GeistVF.woff", - variable: "--font-geist-sans", - weight: "100 900", -}); + src: './fonts/GeistVF.woff', + variable: '--font-geist-sans', + weight: '100 900', +}) const geistMono = localFont({ - src: "./fonts/GeistMonoVF.woff", - variable: "--font-geist-mono", - weight: "100 900", -}); + src: './fonts/GeistMonoVF.woff', + variable: '--font-geist-mono', + weight: '100 900', +}) export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", -}; + title: 'Create Next App', + description: 'Generated by create next app', +} export default function RootLayout({ children, }: Readonly<{ - children: React.ReactNode; + children: React.ReactNode }>) { return ( @@ -31,5 +31,5 @@ export default function RootLayout({ {children} - ); + ) } diff --git a/app/page.tsx b/app/page.tsx index 9007252..022e17f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,101 +1,847 @@ -import Image from "next/image"; +'use client' + +import React, { useState, useRef, useEffect, useMemo } from 'react' +import dynamic from 'next/dynamic' +import { X, Save, Settings } from 'lucide-react' +import { soundEffects, DEFAULT_CHANNELS } from '@/lib/lofi_data' +import ChannelButtons from '@/components/ChannelButtons' +import PlaybackControls from '@/components/PlaybackControls' +import ChannelManagement from '@/components/ChannelManagement' +import SoundEffectsControls from '@/components/SoundEffectsControls' +import { useLocalStorage } from '@/hooks/useLocalStorage' +import { Channel, CustomSoundEffect } from '@/types/lofi' +import SettingsModal from '@/components/SettingsModal' +import styles from '@/styles/Lofi.module.css' + +const ReactPlayer = dynamic(() => import('react-player/youtube'), { + ssr: false, +}) + +// Type Definitions +type AudioCache = { + audio: HTMLAudioElement + loaded: boolean +} + +const StaticEffect = () => { + const [staticPoints, setStaticPoints] = useState< + { left: string; top: string; opacity: number }[] + >([]) + + useEffect(() => { + setStaticPoints( + Array.from({ length: 100 }, () => ({ + left: `${Math.random() * 100}%`, + top: `${Math.random() * 100}%`, + opacity: Math.random() * 0.5, + })) + ) + }, []) -export default function Home() { return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - app/page.tsx - - . -
  2. -
  3. Save and see your changes instantly.
  4. -
- -
- - Vercel logomark - Deploy now - - - Read our docs - +
+ {staticPoints.map((point, i) => ( +
+ ))} +
+ ) +} + +const EnhancedLofiPlayer = () => { + const [mounted, setMounted] = useState(false) + const [currentChannel, setCurrentChannel] = useState(0) + const [isPlaying, setIsPlaying] = useState(true) + const [volume, setVolume] = useLocalStorage('lofi-volume', 0.7) + const [played, setPlayed] = useState(0) + const [currentTheme, setCurrentTheme] = useLocalStorage( + 'lofi-theme', + 'dark' + ) + const [effectsVolume, setEffectsVolume] = useLocalStorage( + 'lofi-effects-volume', + 0.5 + ) + const [customChannels, setCustomChannels] = useLocalStorage( + 'customChannels', + [] + ) + const [hiddenDefaultChannels, setHiddenDefaultChannels] = useLocalStorage< + number[] + >('hiddenDefaultChannels', []) + const [effectVolumes, setEffectVolumes] = useLocalStorage<{ + [key: string]: number + }>( + 'lofi-effect-volumes', + Object.fromEntries(soundEffects.map((effect) => [effect.id, 0.5])) + ) + const [customEffects, setCustomEffects] = useLocalStorage< + CustomSoundEffect[] + >('customSoundEffects', []) + + const playerRef = useRef(null) + const audioRefs = useRef<{ [key: string]: AudioCache }>({}) + const [activeEffects, setActiveEffects] = useState>(new Set()) + const [loadingEffects, setLoadingEffects] = useState>(new Set()) + const [isAddingChannel, setIsAddingChannel] = useState(false) + const [newChannel, setNewChannel] = useState({ + name: '', + url: '', + description: '', + creator: '', + isCustom: true, + }) + const [showDeleteConfirm, setShowDeleteConfirm] = useState( + null + ) + const [isEditingChannel, setIsEditingChannel] = useState(null) + const [editingChannel, setEditingChannel] = useState({ + name: '', + url: '', + description: '', + creator: '', + isCustom: true, + }) + const [isSettingsOpen, setIsSettingsOpen] = useState(false) + + const isBrowser = typeof window !== 'undefined' + + useEffect(() => { + if (!isBrowser) return + setMounted(true) + }, [isBrowser]) + + useEffect(() => { + Object.entries(audioRefs.current).forEach(([effectId, cache]) => { + if (cache?.audio) { + cache.audio.volume = effectVolumes[effectId] * effectsVolume + } + }) + }, [effectVolumes, effectsVolume]) + + // Function to load audio on demand + const loadAudio = async (effectId: string) => { + const effect = soundEffects.find((e) => e.id === effectId) + if (!effect || effect.isYoutube) return + + if (loadingEffects.has(effectId) || audioRefs.current[effectId]?.loaded) { + return + } + + setLoadingEffects((prev) => new Set(prev).add(effectId)) + + try { + const audio = new Audio() + + const loadPromise = new Promise((resolve, reject) => { + audio.addEventListener('canplaythrough', () => resolve(), { + once: true, + }) + audio.addEventListener( + 'error', + (error: ErrorEvent) => { + reject( + new Error( + `Audio loading error: ${error.message || 'Unknown error'}` + ) + ) + }, + { once: true } + ) + + audio.src = effect.file + audio.load() + }) + + await loadPromise + + audio.loop = true + audio.volume = effectVolumes[effectId] * effectsVolume + + audioRefs.current[effectId] = { + audio, + loaded: true, + } + } catch (error) { + console.error(`Failed to load audio for ${effectId}:`, error) + } finally { + setLoadingEffects((prev) => { + const next = new Set(prev) + next.delete(effectId) + return next + }) + } + } + + // Toggle effect function + const toggleEffect = async (effectId: string) => { + const effect = + soundEffects.find((e) => e.id === effectId) || + customEffects.find((e) => e.id === effectId) + + if (!effect) return + + try { + if (effect.isYoutube) { + // For YouTube effects, just toggle the active state + setActiveEffects((prev) => { + const newEffects = new Set(prev) + if (newEffects.has(effectId)) { + newEffects.delete(effectId) + } else { + newEffects.add(effectId) + } + return newEffects + }) + return + } + + // Handle native audio effects + if (!audioRefs.current[effectId]?.loaded) { + await loadAudio(effectId) + } + + const audioCache = audioRefs.current[effectId] + if (!audioCache?.audio) return + + setActiveEffects((prev) => { + const newEffects = new Set(prev) + if (newEffects.has(effectId)) { + newEffects.delete(effectId) + audioCache.audio.pause() + } else { + newEffects.add(effectId) + audioCache.audio.play().catch((error) => { + console.error('Error playing audio:', error) + newEffects.delete(effectId) + }) + } + return newEffects + }) + } catch (error) { + console.error('Error toggling effect:', error) + // Clear loading state and active state on error + setLoadingEffects((prev) => { + const next = new Set(prev) + next.delete(effectId) + return next + }) + setActiveEffects((prev) => { + const next = new Set(prev) + next.delete(effectId) + return next + }) + } + } + + const handleProgress = (state: { played: number }) => { + if (!isPlaying) return + setPlayed(state.played) + } + + const handleChannelChange = (index: number) => { + setCurrentChannel(index) + setPlayed(0) + } + + const handleVolumeChange = (newVolume: number) => { + setVolume(newVolume) + } + + const handleEffectsVolumeChange = (newVolume: number) => { + setEffectsVolume(newVolume) + // Update all active effect volumes + Object.entries(audioRefs.current).forEach(([effectId, cache]) => { + if (cache?.audio) { + cache.audio.volume = effectVolumes[effectId] * newVolume + } + }) + } + + const handleThemeChange = (theme: string) => { + setCurrentTheme(theme) + } + + const handleAddChannel = () => { + if (!newChannel.name || !newChannel.url) { + alert('Channel Name and URL are required.') + return + } + + const updatedChannels: Channel[] = [ + ...customChannels, + { ...newChannel, isCustom: true }, + ] + setCustomChannels(updatedChannels) + setIsAddingChannel(false) + setNewChannel({ + name: '', + url: '', + description: '', + creator: '', + isCustom: true, + }) + } + + const handleDeleteChannel = (channelIndex: number) => { + const channelToDelete = allChannels[channelIndex] + if (!channelToDelete) return + + let newChannelIndex = channelIndex + + if ( + !channelToDelete.isCustom && + typeof channelToDelete.originalIndex === 'number' + ) { + // It's a default channel + if (!hiddenDefaultChannels.includes(channelToDelete.originalIndex)) { + const updatedHidden = [ + ...hiddenDefaultChannels, + channelToDelete.originalIndex, + ] + setHiddenDefaultChannels(updatedHidden) + } + } else { + // It's a custom channel + const updatedChannels = customChannels.filter( + (channel) => + channel.name !== channelToDelete.name || + channel.url !== channelToDelete.url + ) + setCustomChannels(updatedChannels) + } + + // Switch channel if needed + if (channelIndex === currentChannel) { + newChannelIndex = Math.max(0, channelIndex - 1) + setCurrentChannel(newChannelIndex) + } + + setShowDeleteConfirm(null) + } + + const changeChannel = (direction: 'next' | 'prev') => { + setCurrentChannel((prev) => { + if (direction === 'next') { + return (prev + 1) % allChannels.length + } else { + return (prev - 1 + allChannels.length) % allChannels.length + } + }) + } + + const allChannels = useMemo(() => { + // Get visible default channels + const visibleDefaultChannels = DEFAULT_CHANNELS.map((channel, index) => ({ + ...channel, + isCustom: false, + originalIndex: index, + })).filter((_, index) => !hiddenDefaultChannels.includes(index)) + + // Add custom channels + return [...visibleDefaultChannels, ...customChannels] + }, [customChannels, hiddenDefaultChannels]) + + const handleSaveChannel = () => { + if (!newChannel.name || !newChannel.url) { + alert('Channel Name and URL are required.') + return + } + + const updatedChannels: Channel[] = [ + ...customChannels, + { ...newChannel, isCustom: true }, + ] + setCustomChannels(updatedChannels) + setIsAddingChannel(false) + setNewChannel({ + name: '', + url: '', + description: '', + creator: '', + isCustom: true, + }) + } + + const handleEditChannel = (channelIndex: number) => { + console.log('Starting edit for channel:', { + channelIndex, + channel: allChannels[channelIndex], + }) + const channel = allChannels[channelIndex] + setEditingChannel({ ...channel }) + setIsEditingChannel(channelIndex) + } + + const handleSaveEditedChannel = () => { + if (!editingChannel.name || !editingChannel.url) { + alert('Channel Name and URL are required.') + return + } + + const channelToEdit = allChannels[isEditingChannel ?? -1] + if (!channelToEdit) return + + if (channelToEdit.isCustom) { + // Editing a custom channel + const customIndex = customChannels.findIndex( + (channel) => + channel.name === channelToEdit.name && + channel.url === channelToEdit.url + ) + + if (customIndex !== -1) { + const updatedChannels = [...customChannels] + updatedChannels[customIndex] = { ...editingChannel, isCustom: true } + setCustomChannels(updatedChannels) + } + } else if (typeof channelToEdit.originalIndex === 'number') { + // Editing a default channel - hide default and add as custom + if (!hiddenDefaultChannels.includes(channelToEdit.originalIndex)) { + const updatedHidden = [ + ...hiddenDefaultChannels, + channelToEdit.originalIndex, + ] + setHiddenDefaultChannels(updatedHidden) + } + + const updatedChannels = [ + ...customChannels, + { ...editingChannel, isCustom: true }, + ] + setCustomChannels(updatedChannels) + } + + setIsEditingChannel(null) + setEditingChannel({ + name: '', + url: '', + description: '', + creator: '', + isCustom: true, + }) + } + + const handleResetDefaults = () => { + if ( + window.confirm( + 'Are you sure you want to reset to default settings? This will remove all custom channels and effects.' + ) + ) { + setCustomChannels([]) + setHiddenDefaultChannels([]) + setCustomEffects([]) + setCurrentChannel(0) + setVolume(0.7) + setEffectsVolume(0.5) + setEffectVolumes( + Object.fromEntries(soundEffects.map((effect) => [effect.id, 0.5])) + ) + setCurrentTheme('dark') + } + } + + // Add useEffect to initialize and handle theme changes + useEffect(() => { + if (typeof window !== 'undefined') { + // Get theme from localStorage directly to ensure immediate application + const savedTheme = localStorage.getItem('lofi-theme') || 'dark' + + // Apply theme to root element + document.documentElement.dataset.theme = savedTheme + + // Update state if different + if (currentTheme !== savedTheme) { + setCurrentTheme(savedTheme) + } + } + }, []) // Run only on mount + + // Add useEffect to handle theme changes + useEffect(() => { + if (currentTheme) { + // Update root element when theme changes + document.documentElement.dataset.theme = currentTheme + // Also update localStorage directly + localStorage.setItem('lofi-theme', currentTheme) + } + }, [currentTheme]) + + const handleLoadEffect = async (effectId: string, file: string) => { + if (audioRefs.current[effectId]?.loaded) return + + setLoadingEffects((prev) => new Set([...prev, effectId])) + + try { + const audio = new Audio(file) + await audio.load() + audio.loop = true + audioRefs.current[effectId] = { audio, loaded: true } + } catch (error) { + console.error(`Error loading effect ${effectId}:`, error) + } finally { + setLoadingEffects((prev) => { + const next = new Set(prev) + next.delete(effectId) + return next + }) + } + } + + const handleToggleEffect = async (effectId: string, file: string) => { + if (!audioRefs.current[effectId]?.loaded) { + await handleLoadEffect(effectId, file) + } + + const audio = audioRefs.current[effectId]?.audio + if (!audio) return + + if (activeEffects.has(effectId)) { + audio.pause() + setActiveEffects((prev) => { + const next = new Set(prev) + next.delete(effectId) + return next + }) + } else { + audio.volume = effectVolumes[effectId] * effectsVolume + audio.play() + setActiveEffects((prev) => new Set([...prev, effectId])) + } + } + + const handleEffectVolumeChange = (effectId: string, volume: number) => { + const newVolumes = { ...effectVolumes, [effectId]: volume } + setEffectVolumes(newVolumes) + + const audio = audioRefs.current[effectId]?.audio + if (audio) { + audio.volume = volume * effectsVolume + } + } + + const handleAddCustomEffect = (effect: CustomSoundEffect) => { + setCustomEffects((prev) => [...prev, effect]) + } + + const handleRemoveCustomEffect = (effectId: string) => { + setCustomEffects((prev) => prev.filter((effect) => effect.id !== effectId)) + + // Stop and remove the audio if it's playing + const audio = audioRefs.current[effectId]?.audio + if (audio) { + audio.pause() + delete audioRefs.current[effectId] + } + + setActiveEffects((prev) => { + const next = new Set(prev) + next.delete(effectId) + return next + }) + } + + return ( +
+
+
+ {/* Retro TV */} +
+
+ {mounted && } + {mounted && ( + // @ts-ignore + + console.error('Player error:', error) + } + config={{ + playerVars: { + controls: 0, + modestbranding: 1, + iv_load_policy: 3, + rel: 0, + showinfo: 0, + }, + }} + /> + )} +
+
+
+
+ LIVE +
+ + CH{currentChannel + 1} + +
+
+
+
+
+ + {/* Main Controls Section */} +
+ {/* Channel Information */} +
+ {/* Settings button */} +
+ +
+ {mounted ? ( + <> +

+ {allChannels[currentChannel].name} +

+

+ {allChannels[currentChannel].description} +

+

+ by {allChannels[currentChannel].creator} +

+ + ) : ( + <> +

+ {DEFAULT_CHANNELS[0].name} +

+

+ {DEFAULT_CHANNELS[0].description} +

+

+ by {DEFAULT_CHANNELS[0].creator} +

+ + )} +
+ + {/* Channel Buttons */} + {mounted && ( +
+ +
+ )} + + {/* Controls Container */} +
+
+ {/* Left Side - Playback Controls */} + {mounted && ( + + )} + + {/* Right Side - Channel Management */} + {mounted && ( + + )} +
+ + {/* Progress Bar */} +
+
+
+
+
+
+
+ + {/* Sound Effects Section - Separated into its own card */} + {mounted && ( +
+ +
+ )}
-
- + + {/* Channel Edit Modal */} + {isEditingChannel !== null && ( +
+
+

+ Edit Channel {isEditingChannel + 1} +

+
+ + setEditingChannel({ + ...editingChannel, + name: e.target.value, + }) + } + className={`w-full rounded-lg bg-[var(--lofi-card-hover)] px-3 py-2 text-sm text-[var(--lofi-text-primary)] placeholder:text-[var(--lofi-text-secondary)]`} + /> + + setEditingChannel({ + ...editingChannel, + url: e.target.value, + }) + } + className={`w-full rounded-lg bg-[var(--lofi-card-hover)] px-3 py-2 text-sm text-[var(--lofi-text-primary)] placeholder:text-[var(--lofi-text-secondary)]`} + /> + + setEditingChannel({ + ...editingChannel, + description: e.target.value, + }) + } + className={`w-full rounded-lg bg-[var(--lofi-card-hover)] px-3 py-2 text-sm text-[var(--lofi-text-primary)] placeholder:text-[var(--lofi-text-secondary)]`} + /> + + setEditingChannel({ + ...editingChannel, + creator: e.target.value, + }) + } + className={`w-full rounded-lg bg-[var(--lofi-card-hover)] px-3 py-2 text-sm text-[var(--lofi-text-primary)] placeholder:text-[var(--lofi-text-secondary)]`} + /> +
+ + +
+
+
+
+ )} + + {/* Delete Confirmation Modal */} + {showDeleteConfirm !== null && ( +
+
+

+ Delete Channel +

+ {allChannels.length <= 1 ? ( +

+ Cannot delete the last remaining channel. +

+ ) : ( +

+ Are you sure you want to delete " + {allChannels[showDeleteConfirm].name}"? + {showDeleteConfirm < + DEFAULT_CHANNELS.length - hiddenDefaultChannels.length && + ' This will hide the default channel.'} +

+ )} +
+ + +
+
+
+ )} + + setIsSettingsOpen(false)} + currentTheme={currentTheme} + setCurrentTheme={handleThemeChange} + onResetDefaults={handleResetDefaults} + /> +
- ); + ) } + +export default EnhancedLofiPlayer diff --git a/components.json b/components.json new file mode 100644 index 0000000..dea737b --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/components/ChannelButtons.tsx b/components/ChannelButtons.tsx new file mode 100644 index 0000000..6818a61 --- /dev/null +++ b/components/ChannelButtons.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import { Channel } from '@/types/lofi' + +interface ChannelButtonsProps { + channels: Channel[] + currentChannel: number + setCurrentChannel: (index: number) => void + currentTheme: string +} + +const ChannelButtons: React.FC = ({ + channels, + currentChannel, + setCurrentChannel, + currentTheme, +}) => ( +
+ {channels.map((channel, idx) => ( + + ))} +
+) + +export default ChannelButtons diff --git a/components/ChannelManagement.tsx b/components/ChannelManagement.tsx new file mode 100644 index 0000000..7e13742 --- /dev/null +++ b/components/ChannelManagement.tsx @@ -0,0 +1,119 @@ +import React from 'react' +import { Edit2, X, Plus, Save } from 'lucide-react' +import { Channel } from '@/types/lofi' + +interface ChannelManagementProps { + isAddingChannel: boolean + setIsAddingChannel: (adding: boolean) => void + newChannel: Channel + setNewChannel: (channel: Channel) => void + saveChannel: () => void + currentTheme: string + currentChannel: number + handleEditChannel: (index: number) => void + setShowDeleteConfirm: (channelIndex: number) => void +} + +const ChannelManagement: React.FC = ({ + isAddingChannel, + setIsAddingChannel, + newChannel, + setNewChannel, + saveChannel, + currentTheme, + currentChannel, + handleEditChannel, + setShowDeleteConfirm, +}) => ( +
+ {!isAddingChannel ? ( +
+ + + +
+ ) : ( +
+

+ Add New Channel +

+
+ + setNewChannel({ ...newChannel, name: e.target.value }) + } + className="rounded-[var(--lofi-button-radius)] bg-[var(--lofi-card-hover)] px-3 py-2 text-sm text-[var(--lofi-text-primary)] placeholder:text-[var(--lofi-text-secondary)]" + /> + + setNewChannel({ ...newChannel, url: e.target.value }) + } + className="rounded-[var(--lofi-button-radius)] bg-[var(--lofi-card-hover)] px-3 py-2 text-sm text-[var(--lofi-text-primary)] placeholder:text-[var(--lofi-text-secondary)]" + /> + + setNewChannel({ ...newChannel, description: e.target.value }) + } + className="rounded-[var(--lofi-button-radius)] bg-[var(--lofi-card-hover)] px-3 py-2 text-sm text-[var(--lofi-text-primary)] placeholder:text-[var(--lofi-text-secondary)]" + /> + + setNewChannel({ ...newChannel, creator: e.target.value }) + } + className="rounded-[var(--lofi-button-radius)] bg-[var(--lofi-card-hover)] px-3 py-2 text-sm text-[var(--lofi-text-primary)] placeholder:text-[var(--lofi-text-secondary)]" + /> +
+
+ + +
+
+ )} +
+) + +export default ChannelManagement diff --git a/components/PlaybackControls.tsx b/components/PlaybackControls.tsx new file mode 100644 index 0000000..01041f2 --- /dev/null +++ b/components/PlaybackControls.tsx @@ -0,0 +1,77 @@ +import React from 'react' +import { + SkipBack, + Play, + Pause, + SkipForward, + VolumeX, + Volume1, + Volume2, +} from 'lucide-react' + +interface PlaybackControlsProps { + isPlaying: boolean + setIsPlaying: (playing: boolean) => void + volume: number + setVolume: (vol: number) => void + changeChannel: (direction: 'next' | 'prev') => void +} + +const PlaybackControls: React.FC = ({ + isPlaying, + setIsPlaying, + volume, + setVolume, + changeChannel, +}) => ( +
+ {/* Left Side - Playback Controls */} +
+ + + +
+ + {/* Right Side - Volume Control */} +
+ + setVolume(parseFloat(e.target.value))} + className="w-16 accent-[var(--lofi-accent)]" + /> +
+
+) + +export default PlaybackControls diff --git a/components/SettingsModal.tsx b/components/SettingsModal.tsx new file mode 100644 index 0000000..6404a63 --- /dev/null +++ b/components/SettingsModal.tsx @@ -0,0 +1,92 @@ +import React from 'react' +import { X, RotateCcw } from 'lucide-react' +import { themes } from '@/lib/lofi-themes' + +interface SettingsModalProps { + isOpen: boolean + onClose: () => void + currentTheme: string + setCurrentTheme: (themeId: string) => void + onResetDefaults: () => void +} + +const SettingsModal: React.FC = ({ + isOpen, + onClose, + currentTheme, + setCurrentTheme, + onResetDefaults, +}) => { + const handleResetDefaults = () => { + if ( + window.confirm( + 'This will reset all settings to their defaults. Continue?' + ) + ) { + localStorage.removeItem('lofi-volume') + localStorage.removeItem('lofi-theme') + localStorage.removeItem('lofi-effects-volume') + localStorage.removeItem('lofi-effect-volumes') + localStorage.removeItem('customChannels') + localStorage.removeItem('hiddenDefaultChannels') + localStorage.removeItem('customSoundEffects') + + window.location.reload() + } + } + + if (!isOpen) return null + + return ( +
+
+
+

+ Settings +

+ +
+ +
+
+ + +
+ +
+ +
+
+
+
+ ) +} + +export default SettingsModal diff --git a/components/SoundEffectsControls.tsx b/components/SoundEffectsControls.tsx new file mode 100644 index 0000000..779598b --- /dev/null +++ b/components/SoundEffectsControls.tsx @@ -0,0 +1,301 @@ +import React, { useState, useEffect } from 'react' +import { Plus, Music, X } from 'lucide-react' +import { soundEffects } from '@/lib/lofi_data' +import { SoundEffect, CustomSoundEffect } from '@/types/lofi' +import dynamic from 'next/dynamic' + +const ReactPlayer = dynamic(() => import('react-player/youtube'), { + ssr: false, +}) as any + +interface SoundEffectsControlsProps { + activeEffects: Set + toggleEffect: (effectId: string) => void + effectsVolume: number + setEffectsVolume: (vol: number) => void + effectVolumes: { [key: string]: number } + setEffectVolumes: (volumes: { [key: string]: number }) => void + currentTheme: string + customEffects: CustomSoundEffect[] + setCustomEffects: (effects: CustomSoundEffect[]) => void + loadingEffects: Set +} + +const SoundEffectsControls: React.FC = ({ + activeEffects, + toggleEffect, + effectsVolume, + setEffectsVolume, + effectVolumes, + setEffectVolumes, + currentTheme, + customEffects, + setCustomEffects, + loadingEffects, +}) => { + const [isAddingEffect, setIsAddingEffect] = useState(false) + const [newEffect, setNewEffect] = useState({ + id: '', + name: '', + file: '', + isYoutube: true, + }) + const [urlError, setUrlError] = useState('') + + const allEffects = [ + ...soundEffects.map((effect) => ({ + ...effect, + isYoutube: false, + })), + ...customEffects.map((effect) => ({ + ...effect, + icon: Music, + isCustom: true, + })), + ] + + const validateYoutubeUrl = (url: string): boolean => { + try { + const urlObj = new URL(url) + return ( + urlObj.hostname === 'www.youtube.com' || + urlObj.hostname === 'youtube.com' || + urlObj.hostname === 'youtu.be' + ) + } catch { + return false + } + } + + const handleAddEffect = async () => { + if (!newEffect.name || !newEffect.file) { + alert('Please provide both name and YouTube URL') + return + } + + if (!validateYoutubeUrl(newEffect.file)) { + alert('Please provide a valid YouTube URL') + return + } + + const effectId = `custom_${Date.now()}` + const newCustomEffect: CustomSoundEffect = { + id: effectId, + name: newEffect.name, + file: newEffect.file, + isYoutube: true, + } + + setCustomEffects([...customEffects, newCustomEffect]) + const defaultVolume = 0.5 + setEffectVolumes({ + ...effectVolumes, + [effectId]: defaultVolume, + }) + setIsAddingEffect(false) + setNewEffect({ id: '', name: '', file: '', isYoutube: true }) + } + + const handleDeleteEffect = (effectId: string) => { + setCustomEffects(customEffects.filter((effect) => effect.id !== effectId)) + const newVolumes = { ...effectVolumes } + delete newVolumes[effectId] + setEffectVolumes(newVolumes) + if (activeEffects.has(effectId)) { + toggleEffect(effectId) + } + } + + const renderSoundEffect = (effect: SoundEffect) => { + const isActive = activeEffects.has(effect.id) + const isLoading = loadingEffects.has(effect.id) + + return ( +
+ {effect.isYoutube && isActive && ( +
+ +
+ )} + +
+
+ + + {effect.name} + +
+
+ + {Math.round(effectVolumes[effect.id] * 100)}% + + {effect.isCustom && ( + + )} +
+
+ + setEffectVolumes({ + ...effectVolumes, + [effect.id]: parseFloat(e.target.value), + }) + } + className="w-full focus:outline-none [&::-moz-range-thumb]:bg-[var(--lofi-accent)] [&::-webkit-slider-thumb]:bg-[var(--lofi-accent)]" + style={{ + accentColor: 'var(--lofi-accent)', + }} + disabled={!isActive} + /> +
+ ) + } + + useEffect(() => { + const defaultVolume = 0.5 + const newVolumes = { ...effectVolumes } + let hasChanges = false + + allEffects.forEach((effect) => { + if (effectVolumes[effect.id] === undefined) { + newVolumes[effect.id] = defaultVolume + hasChanges = true + } + }) + + if (hasChanges) { + setEffectVolumes(newVolumes) + } + }, [allEffects, effectVolumes]) + + return ( +
+
+

+ Effects +

+
+ + + Master Volume + + setEffectsVolume(parseFloat(e.target.value))} + className="w-32 focus:outline-none sm:w-20 [&::-moz-range-thumb]:bg-[var(--lofi-accent)] [&::-webkit-slider-thumb]:bg-[var(--lofi-accent)]" + style={{ + accentColor: 'var(--lofi-accent)', + }} + /> +
+
+ +
+ {allEffects.map((effect) => renderSoundEffect(effect))} +
+ + {isAddingEffect && ( +
+
+

+ Add Sound Effect +

+ + setNewEffect({ ...newEffect, name: e.target.value }) + } + className="w-full rounded-[var(--lofi-button-radius)] bg-[var(--lofi-card-hover)] px-3 py-2 text-sm text-[var(--lofi-text-primary)] placeholder:text-[var(--lofi-text-secondary)] focus:outline-none" + /> +
+ { + setNewEffect({ ...newEffect, file: e.target.value }) + setUrlError('') + }} + className={`w-full rounded-[var(--lofi-button-radius)] bg-[var(--lofi-card-hover)] px-3 py-2 text-sm text-[var(--lofi-text-primary)] placeholder:text-[var(--lofi-text-secondary)] focus:outline-none ${ + urlError ? 'border border-red-500' : '' + }`} + /> + {urlError &&

{urlError}

} +
+
+ + +
+
+
+ )} +
+ ) +} + +export default SoundEffectsControls diff --git a/hooks/useLocalStorage.ts b/hooks/useLocalStorage.ts new file mode 100644 index 0000000..bac5303 --- /dev/null +++ b/hooks/useLocalStorage.ts @@ -0,0 +1,37 @@ +import { useState, useCallback } from 'react' + +export function useLocalStorage(key: string, initialValue: T) { + // Initialize state with a function to avoid unnecessary localStorage access during SSR + const [storedValue, setStoredValue] = useState(() => { + if (typeof window === 'undefined') { + return initialValue + } + try { + const item = window.localStorage.getItem(key) + return item ? (typeof initialValue === 'string' ? item as T : JSON.parse(item)) : initialValue + } catch (error) { + console.error(`Error reading localStorage key "${key}":`, error) + return initialValue + } + }) + + // Memoize the setValue function to prevent unnecessary re-renders + const setValue = useCallback((value: T | ((val: T) => T)) => { + try { + const valueToStore = value instanceof Function ? value(storedValue) : value + setStoredValue(valueToStore) + + if (typeof window !== 'undefined') { + if (typeof valueToStore === 'string') { + window.localStorage.setItem(key, valueToStore) + } else { + window.localStorage.setItem(key, JSON.stringify(valueToStore)) + } + } + } catch (error) { + console.error(`Error setting localStorage key "${key}":`, error) + } + }, [key, storedValue]) + + return [storedValue, setValue] as const +} diff --git a/lib/lofi-themes.ts b/lib/lofi-themes.ts new file mode 100644 index 0000000..a8df471 --- /dev/null +++ b/lib/lofi-themes.ts @@ -0,0 +1,43 @@ +export type Theme = { + id: string + name: string +} + +export const themes: { [key: string]: Theme } = { + dark: { + id: 'dark', + name: 'Dark', + }, + light: { + id: 'light', + name: 'Light', + }, + midnight: { + id: 'midnight', + name: 'Midnight', + }, + metallic: { + id: 'metallic', + name: 'Metallic', + }, + vintage: { + id: 'vintage', + name: 'Vintage', + }, + cyberpunk: { + id: 'cyberpunk', + name: 'Cyberpunk', + }, + forest: { + id: 'forest', + name: 'Forest', + }, + ocean: { + id: 'ocean', + name: 'Ocean', + }, + sunset: { + id: 'sunset', + name: 'Sunset', + }, +} diff --git a/lib/lofi_data.ts b/lib/lofi_data.ts new file mode 100644 index 0000000..6fff36d --- /dev/null +++ b/lib/lofi_data.ts @@ -0,0 +1,82 @@ +import { Cloud, Wind, Coffee, Users, Monitor, Power } from 'lucide-react' +import { Channel, SoundEffect } from '@/types/lofi' + +export const DEFAULT_CHANNELS: Channel[] = [ + { + name: 'Lofi Girl', + url: 'https://www.youtube.com/watch?v=jfKfPfyJRdk', + description: 'Beats to relax/study to', + creator: 'Lofi Girl', + }, + { + name: 'Chillhop Radio', + url: 'https://www.youtube.com/watch?v=5yx6BWlEVcY', + description: 'jazzy & lofi hip hop beats', + creator: 'Chillhop Music', + }, + { + name: 'Chilled Raccoon', + url: 'https://www.youtube.com/watch?v=7NOSDKb0HlU', + description: 'late night lofi mix', + creator: 'Chilled Raccoon', + }, + { + name: 'Smooth Jazz', + url: 'https://www.youtube.com/watch?v=HhqWd3Axq9Y', + description: 'warm jazz music at coffee shop', + creator: 'Relax Jazz Cafe', + }, + { + name: 'Tokyo night drive', + url: 'https://www.youtube.com/watch?v=Lcdi9O2XB4E', + description: 'lofi hiphop + chill + beats', + creator: 'Tokyo Tones', + }, + { + name: 'Japan Cafe Vibe', + url: 'https://www.youtube.com/watch?v=bRnTGwCbr3E', + description: 'Lofi Music to sleep,relax,study...', + creator: 'Healing Me', + }, +] + +export const channels: Channel[] = [...DEFAULT_CHANNELS] + +export const soundEffects: SoundEffect[] = [ + { + id: 'rain', + name: 'Rain', + icon: Cloud, + file: '/sounds/rain.mp3', + }, + { + id: 'wind', + name: 'Soft Wind', + icon: Wind, + file: '/sounds/wind.mp3', + }, + { + id: 'cafe', + name: 'Cafe Ambience', + icon: Coffee, + file: '/sounds/cafe.mp3', + }, + { + id: 'people', + name: 'Distant Voices', + icon: Users, + file: '/sounds/people.mp3', + }, + { + id: 'keyboard', + name: 'Keyboard Typing', + icon: Monitor, + file: '/sounds/keyboard.mp3', + }, + { + id: 'whitenoise', + name: 'White Noise', + icon: Power, + file: '/sounds/whitenoise.mp3', + }, +] diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 0000000..bd0c391 --- /dev/null +++ b/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/package-lock.json b/package-lock.json index 41b867d..ea49eac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,15 @@ "name": "next-beats", "version": "0.1.0", "dependencies": { + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "lucide-react": "^0.460.0", "next": "15.0.3", "react": "19.0.0-rc-66855b96-20241106", - "react-dom": "19.0.0-rc-66855b96-20241106" + "react-dom": "19.0.0-rc-66855b96-20241106", + "react-player": "^2.16.0", + "tailwind-merge": "^2.5.4", + "tailwindcss-animate": "^1.0.7" }, "devDependencies": { "@types/node": "^20", @@ -1514,11 +1520,38 @@ "node": ">= 6" } }, + "node_modules/class-variance-authority": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.0.tgz", + "integrity": "sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==", + "dependencies": { + "clsx": "2.0.0" + }, + "funding": { + "url": "https://joebell.co.uk" + } + }, + "node_modules/class-variance-authority/node_modules/clsx": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", + "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==", + "engines": { + "node": ">=6" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -1687,6 +1720,14 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -3321,8 +3362,7 @@ "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { "version": "4.1.0", @@ -3436,6 +3476,11 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true }, + "node_modules/load-script": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/load-script/-/load-script-1.0.0.tgz", + "integrity": "sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -3461,7 +3506,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -3475,6 +3519,19 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true }, + "node_modules/lucide-react": { + "version": "0.460.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.460.0.tgz", + "integrity": "sha512-BVtq/DykVeIvRTJvRAgCsOwaGL8Un3Bxh8MbDxMhEWlZay3T4IpEKDEpwt5KZ0KJMHzgm6jrltxlT5eXOWXDHg==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3660,7 +3717,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -4111,7 +4167,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -4166,11 +4221,30 @@ "react": "19.0.0-rc-66855b96-20241106" } }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==" + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/react-player": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/react-player/-/react-player-2.16.0.tgz", + "integrity": "sha512-mAIPHfioD7yxO0GNYVFD1303QFtI3lyyQZLY229UEAp/a10cSW+hPcakg0Keq8uWJxT2OiT/4Gt+Lc9bD6bJmQ==", + "dependencies": { + "deepmerge": "^4.0.0", + "load-script": "^1.0.0", + "memoize-one": "^5.1.1", + "prop-types": "^15.7.2", + "react-fast-compare": "^3.0.1" + }, + "peerDependencies": { + "react": ">=16.6.0" + } }, "node_modules/read-cache": { "version": "1.0.0", @@ -4837,6 +4911,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwind-merge": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.4.tgz", + "integrity": "sha512-0q8cfZHMu9nuYP/b5Shb7Y7Sh1B7Nnl5GqNr1U+n2p6+mybvRtayrQ+0042Z5byvTA8ihjlP8Odo8/VnHbZu4Q==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "3.4.15", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.15.tgz", @@ -4874,6 +4957,14 @@ "node": ">=14.0.0" } }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, "node_modules/tailwindcss/node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", diff --git a/package.json b/package.json index b3721fb..1645295 100644 --- a/package.json +++ b/package.json @@ -9,18 +9,24 @@ "lint": "next lint" }, "dependencies": { + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "lucide-react": "^0.460.0", + "next": "15.0.3", "react": "19.0.0-rc-66855b96-20241106", "react-dom": "19.0.0-rc-66855b96-20241106", - "next": "15.0.3" + "react-player": "^2.16.0", + "tailwind-merge": "^2.5.4", + "tailwindcss-animate": "^1.0.7" }, "devDependencies": { - "typescript": "^5", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "15.0.3", "postcss": "^8", "tailwindcss": "^3.4.1", - "eslint": "^8", - "eslint-config-next": "15.0.3" + "typescript": "^5" } } diff --git a/public/file.svg b/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/sounds/cafe.mp3 b/public/sounds/cafe.mp3 new file mode 100644 index 0000000..b15842f Binary files /dev/null and b/public/sounds/cafe.mp3 differ diff --git a/public/sounds/keyboard.mp3 b/public/sounds/keyboard.mp3 new file mode 100644 index 0000000..dec1305 Binary files /dev/null and b/public/sounds/keyboard.mp3 differ diff --git a/public/sounds/people.mp3 b/public/sounds/people.mp3 new file mode 100644 index 0000000..d8a4b81 Binary files /dev/null and b/public/sounds/people.mp3 differ diff --git a/public/sounds/rain.mp3 b/public/sounds/rain.mp3 new file mode 100644 index 0000000..b8adf7f Binary files /dev/null and b/public/sounds/rain.mp3 differ diff --git a/public/sounds/whitenoise.mp3 b/public/sounds/whitenoise.mp3 new file mode 100644 index 0000000..df9cc70 Binary files /dev/null and b/public/sounds/whitenoise.mp3 differ diff --git a/public/sounds/wind.mp3 b/public/sounds/wind.mp3 new file mode 100644 index 0000000..4393ca8 Binary files /dev/null and b/public/sounds/wind.mp3 differ diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/window.svg b/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/styles/Lofi.module.css b/styles/Lofi.module.css new file mode 100644 index 0000000..d821d5f --- /dev/null +++ b/styles/Lofi.module.css @@ -0,0 +1,166 @@ +.theme-container { + /* Dark theme (default) */ + --lofi-background: #18181b; + --lofi-card: #27272a; + --lofi-card-hover: #3f3f46; + --lofi-border: #3f3f46; + --lofi-text-primary: #e4e4e7; + --lofi-text-secondary: #a1a1aa; + --lofi-button-bg: #3f3f46; + --lofi-button-hover: #52525b; + --lofi-button-text: #e4e4e7; + --lofi-accent: #7e22ce; + --lofi-accent-hover: #9333ea; + --lofi-card-radius: 0.75rem; + --lofi-button-radius: 0.5rem; + --lofi-card-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); +} + +.theme-container[data-theme='light'] { + --lofi-background: #f4f4f5; + --lofi-card: #e4e4e7; + --lofi-card-hover: #d4d4d8; + --lofi-border: #d4d4d8; + --lofi-text-primary: #18181b; + --lofi-text-secondary: #71717a; + --lofi-button-bg: #d4d4d8; + --lofi-button-hover: #a1a1aa; + --lofi-button-text: #27272a; + --lofi-accent: #7e22ce; + --lofi-accent-hover: #9333ea; + --lofi-card-radius: 0.75rem; + --lofi-button-radius: 0.5rem; + --lofi-card-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); +} + +.theme-container[data-theme='vintage'] { + --lofi-background: #f8e3c4; /* Warm cream background */ + --lofi-card: #e6d0b0; /* Lighter cream for cards */ + --lofi-card-hover: #d4be9c; /* Slightly darker on hover */ + --lofi-border: #8b6d5c; /* Rich brown border */ + --lofi-text-primary: #4a3628; /* Deep brown text */ + --lofi-text-secondary: #6b5a4c; /* Medium brown text */ + --lofi-button-bg: #c17f59; /* Terracotta buttons */ + --lofi-button-hover: #d4956f; /* Lighter terracotta on hover */ + --lofi-button-text: #f8e3c4; /* Light cream text */ + --lofi-accent: #8b6d5c; /* Rich brown accent */ + --lofi-accent-hover: #9d7e6d; /* Lighter brown on hover */ + --lofi-card-radius: 0.5rem; /* Subtle rounding */ + --lofi-button-radius: 0.25rem; /* Minimal button rounding */ + --lofi-card-shadow: 0 4px 12px rgba(139, 109, 92, 0.2), + 0 0 0 1px rgba(139, 109, 92, 0.15), inset 0 1px 0 0 rgba(248, 227, 196, 0.5), + 2px 2px 0 rgba(139, 109, 92, 0.1); +} + +.theme-container[data-theme='metallic'] { + --lofi-background: #0f1215; /* Nearly black background */ + --lofi-card: #1a1f25; /* Dark gunmetal */ + --lofi-card-hover: #252b33; /* Lighter gunmetal on hover */ + --lofi-border: #3a424d; /* Medium steel */ + --lofi-text-primary: #e2e8f0; /* Bright steel */ + --lofi-text-secondary: #94a3b8; /* Muted steel */ + --lofi-button-bg: #2d343d; /* Dark steel buttons */ + --lofi-button-hover: #3a424d; /* Medium steel on hover */ + --lofi-button-text: #e2e8f0; /* Bright steel text */ + --lofi-accent: #718096; /* Cool gray accent */ + --lofi-accent-hover: #8b97aa; /* Lighter steel on hover */ + --lofi-card-radius: 0.25rem; /* Sharp corners for metallic feel */ + --lofi-button-radius: 0.125rem; /* Very subtle button rounding */ + --lofi-card-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), + 0 0 0 1px rgba(255, 255, 255, 0.08), + inset 0 1px 0 0 rgba(255, 255, 255, 0.05), 0 0 20px rgba(0, 0, 0, 0.2); +} + +.theme-container[data-theme='midnight'] { + --lofi-background: #0a0c10; /* Darker than default dark theme */ + --lofi-card: #141820; /* Deep blue-tinted dark */ + --lofi-card-hover: #1c2230; /* Slightly lighter on hover */ + --lofi-border: #252d3d; /* Subtle blue-gray border */ + --lofi-text-primary: #e2e8f0; /* Soft white with blue tint */ + --lofi-text-secondary: #94a3b8; /* Muted blue-gray */ + --lofi-button-bg: #252d3d; /* Deep blue-gray */ + --lofi-button-hover: #2f3b4f; /* Lighter blue-gray on hover */ + --lofi-button-text: #e2e8f0; /* Same as text-primary */ + --lofi-accent: #3b82f6; /* Bright blue accent */ + --lofi-accent-hover: #60a5fa; /* Lighter blue on hover */ + --lofi-card-radius: 0.75rem; + --lofi-button-radius: 0.5rem; + --lofi-card-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), + 0 0 0 1px rgba(255, 255, 255, 0.05), + inset 0 1px 0 0 rgba(255, 255, 255, 0.02); +} + +.theme-container[data-theme='cyberpunk'] { + --lofi-background: #0f0326; /* Deep purple background */ + --lofi-card: #1a0940; /* Richer purple for cards */ + --lofi-card-hover: #2a0c66; /* Brighter purple on hover */ + --lofi-border: #ff2a6d; /* Neon pink border */ + --lofi-text-primary: #05ffa1; /* Bright cyan text */ + --lofi-text-secondary: #14acc2; /* Muted cyan */ + --lofi-button-bg: #2a0c66; /* Deep purple buttons */ + --lofi-button-hover: #3d1a75; /* Lighter purple on hover */ + --lofi-button-text: #05ffa1; /* Cyan button text */ + --lofi-accent: #ff2a6d; /* Neon pink accent */ + --lofi-accent-hover: #ff4081; /* Lighter pink on hover */ + --lofi-card-radius: 0.25rem; /* Sharp corners for cyber feel */ + --lofi-button-radius: 0rem; /* Square buttons */ + --lofi-card-shadow: 0 0 10px rgba(255, 42, 109, 0.3), + 0 0 20px rgba(5, 255, 161, 0.1), inset 0 0 2px rgba(255, 42, 109, 0.5); +} + +.theme-container[data-theme='forest'] { + --lofi-background: #1a2f1c; /* Deep forest green */ + --lofi-card: #2a412c; /* Darker green for cards */ + --lofi-card-hover: #35513e; /* Lighter green on hover */ + --lofi-border: #4a6b4d; /* Muted green border */ + --lofi-text-primary: #d4e5d4; /* Soft green-white */ + --lofi-text-secondary: #95ab96; /* Muted sage */ + --lofi-button-bg: #3c5a3e; /* Forest green buttons */ + --lofi-button-hover: #4a714d; /* Lighter green on hover */ + --lofi-button-text: #d4e5d4; /* Light text */ + --lofi-accent: #7fb069; /* Moss green accent */ + --lofi-accent-hover: #96c37c; /* Lighter moss on hover */ + --lofi-card-radius: 1.5rem; /* Organic, rounded corners */ + --lofi-button-radius: 1rem; /* Soft buttons */ + --lofi-card-shadow: 0 4px 12px rgba(0, 0, 0, 0.2), + 0 0 0 1px rgba(126, 176, 105, 0.1), + inset 0 1px 0 0 rgba(212, 229, 212, 0.05); +} + +.theme-container[data-theme='ocean'] { + --lofi-background: #0a192f; /* Deep ocean blue */ + --lofi-card: #112240; /* Darker blue for cards */ + --lofi-card-hover: #1a365d; /* Lighter blue on hover */ + --lofi-border: #234876; /* Medium blue border */ + --lofi-text-primary: #e6f1ff; /* Soft blue-white */ + --lofi-text-secondary: #8892b0; /* Muted blue-gray */ + --lofi-button-bg: #1a365d; /* Ocean blue buttons */ + --lofi-button-hover: #234876; /* Lighter blue on hover */ + --lofi-button-text: #e6f1ff; /* Light text */ + --lofi-accent: #64ffda; /* Aqua accent */ + --lofi-accent-hover: #88fff0; /* Lighter aqua on hover */ + --lofi-card-radius: 0.5rem; /* Gentle waves */ + --lofi-button-radius: 0.25rem; /* Subtle curves */ + --lofi-card-shadow: 0 4px 15px rgba(100, 255, 218, 0.07), + 0 0 0 1px rgba(100, 255, 218, 0.1), + inset 0 1px 0 0 rgba(230, 241, 255, 0.05); +} + +.theme-container[data-theme='sunset'] { + --lofi-background: #2d1b2d; /* Deep purple-red */ + --lofi-card: #3d2438; /* Darker mauve for cards */ + --lofi-card-hover: #4d2d47; /* Lighter mauve on hover */ + --lofi-border: #ff6b6b; /* Coral border */ + --lofi-text-primary: #ffd6d6; /* Soft pink-white */ + --lofi-text-secondary: #c4a5a5; /* Muted pink */ + --lofi-button-bg: #ff6b6b; /* Coral buttons */ + --lofi-button-hover: #ff8787; /* Lighter coral on hover */ + --lofi-button-text: #2d1b2d; /* Dark text on buttons */ + --lofi-accent: #ffc145; /* Golden accent */ + --lofi-accent-hover: #ffd175; /* Lighter gold on hover */ + --lofi-card-radius: 1rem; /* Soft corners */ + --lofi-button-radius: 0.75rem; /* Rounded buttons */ + --lofi-card-shadow: 0 4px 12px rgba(255, 107, 107, 0.1), + 0 0 0 1px rgba(255, 107, 107, 0.1), + inset 0 1px 0 0 rgba(255, 214, 214, 0.05); +} diff --git a/styles/globals.css b/styles/globals.css new file mode 100644 index 0000000..a23ac26 --- /dev/null +++ b/styles/globals.css @@ -0,0 +1,72 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + font-family: Arial, Helvetica, sans-serif; +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + } + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/tailwind.config.ts b/tailwind.config.ts index 1362b88..773b1e6 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,18 +1,62 @@ import type { Config } from "tailwindcss"; export default { - content: [ + darkMode: ["class"], + content: [ "./pages/**/*.{js,ts,jsx,tsx,mdx}", "./components/**/*.{js,ts,jsx,tsx,mdx}", "./app/**/*.{js,ts,jsx,tsx,mdx}", ], theme: { - extend: { - colors: { - background: "var(--background)", - foreground: "var(--foreground)", - }, - }, + extend: { + colors: { + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))' + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))' + }, + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))' + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))' + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))' + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))' + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))' + }, + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + chart: { + '1': 'hsl(var(--chart-1))', + '2': 'hsl(var(--chart-2))', + '3': 'hsl(var(--chart-3))', + '4': 'hsl(var(--chart-4))', + '5': 'hsl(var(--chart-5))' + } + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)' + } + } }, - plugins: [], + plugins: [require("tailwindcss-animate")], } satisfies Config; diff --git a/types/lofi.ts b/types/lofi.ts new file mode 100644 index 0000000..c244bca --- /dev/null +++ b/types/lofi.ts @@ -0,0 +1,25 @@ +export interface Channel { + originalIndex?: number + name: string + url: string + description: string + creator: string + isCustom?: boolean +} + +export interface SoundEffect { + id: string + name: string + icon: any + file: string + isCustom?: boolean + isYoutube?: boolean +} + +export interface CustomSoundEffect { + id: string + name: string + file: string + isCustom?: boolean + isYoutube?: boolean +}