mirror of https://github.com/btahir/next-beats
mvp
parent
efa9cf3d3f
commit
9cf1adcadb
@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"printWidth": 80,
|
||||
"useTabs": false
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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 (
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
||||
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
||||
<li className="mb-2">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
|
||||
app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li>Save and see your changes instantly.</li>
|
||||
</ol>
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
<div className="pointer-events-none absolute inset-0 opacity-10 mix-blend-screen">
|
||||
{staticPoints.map((point, i) => (
|
||||
<div key={i} className="absolute h-px w-px bg-white" style={point} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<string>(
|
||||
'lofi-theme',
|
||||
'dark'
|
||||
)
|
||||
const [effectsVolume, setEffectsVolume] = useLocalStorage(
|
||||
'lofi-effects-volume',
|
||||
0.5
|
||||
)
|
||||
const [customChannels, setCustomChannels] = useLocalStorage<Channel[]>(
|
||||
'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<any>(null)
|
||||
const audioRefs = useRef<{ [key: string]: AudioCache }>({})
|
||||
const [activeEffects, setActiveEffects] = useState<Set<string>>(new Set())
|
||||
const [loadingEffects, setLoadingEffects] = useState<Set<string>>(new Set())
|
||||
const [isAddingChannel, setIsAddingChannel] = useState(false)
|
||||
const [newChannel, setNewChannel] = useState<Channel>({
|
||||
name: '',
|
||||
url: '',
|
||||
description: '',
|
||||
creator: '',
|
||||
isCustom: true,
|
||||
})
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<number | null>(
|
||||
null
|
||||
)
|
||||
const [isEditingChannel, setIsEditingChannel] = useState<number | null>(null)
|
||||
const [editingChannel, setEditingChannel] = useState<Channel>({
|
||||
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<void>((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<Channel[]>(() => {
|
||||
// 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 (
|
||||
<div
|
||||
className={styles['theme-container']}
|
||||
data-theme={mounted ? currentTheme : 'dark'}
|
||||
>
|
||||
<div className="flex min-h-screen w-full items-start justify-center bg-[var(--lofi-background)] p-4 transition-colors duration-500 sm:items-center sm:p-8">
|
||||
<div className="w-full max-w-4xl space-y-8 py-4">
|
||||
{/* Retro TV */}
|
||||
<div className="shadow-[var(--lofi-accent)]/30 relative aspect-video overflow-hidden rounded-2xl border-4 border-[var(--lofi-border)] bg-black shadow-md transition-all duration-500">
|
||||
<div className="absolute inset-0">
|
||||
{mounted && <StaticEffect />}
|
||||
{mounted && (
|
||||
// @ts-ignore
|
||||
<ReactPlayer
|
||||
ref={playerRef}
|
||||
url={allChannels[currentChannel]?.url || ''}
|
||||
playing={isPlaying}
|
||||
volume={volume}
|
||||
loop
|
||||
width="100%"
|
||||
height="100%"
|
||||
onProgress={handleProgress}
|
||||
onError={(error: Error) =>
|
||||
console.error('Player error:', error)
|
||||
}
|
||||
config={{
|
||||
playerVars: {
|
||||
controls: 0,
|
||||
modestbranding: 1,
|
||||
iv_load_policy: 3,
|
||||
rel: 0,
|
||||
showinfo: 0,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="absolute top-0 left-0 right-0 bg-gradient-to-b from-black/70 to-transparent p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-2 w-2 animate-pulse rounded-full bg-red-500" />
|
||||
<span className="font-mono text-xs text-red-400">LIVE</span>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-white/80">
|
||||
CH{currentChannel + 1}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="animate-scan absolute bottom-0 left-0 right-0 h-px bg-white/10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Controls Section */}
|
||||
<div className="space-y-6 rounded-xl bg-[var(--lofi-card)] p-4 transition-colors duration-500 sm:p-6">
|
||||
{/* Channel Information */}
|
||||
<div className="relative space-y-1 px-2 font-mono text-[var(--lofi-text-primary)]">
|
||||
{/* Settings button */}
|
||||
<div className="absolute top-0 right-0 flex justify-center">
|
||||
<button
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
className="rounded-full bg-[var(--lofi-button-bg)] p-2 text-[var(--lofi-button-text)] transition-colors hover:bg-[var(--lofi-button-hover)]"
|
||||
>
|
||||
<Settings size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{mounted ? (
|
||||
<>
|
||||
<h2 className="text-xl font-bold">
|
||||
{allChannels[currentChannel].name}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--lofi-text-secondary)]">
|
||||
{allChannels[currentChannel].description}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--lofi-accent)]">
|
||||
by {allChannels[currentChannel].creator}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-xl font-bold">
|
||||
{DEFAULT_CHANNELS[0].name}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--lofi-text-secondary)]">
|
||||
{DEFAULT_CHANNELS[0].description}
|
||||
</p>
|
||||
<p className="text-sm text-purple-400">
|
||||
by {DEFAULT_CHANNELS[0].creator}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Channel Buttons */}
|
||||
{mounted && (
|
||||
<div className="-mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0">
|
||||
<ChannelButtons
|
||||
channels={allChannels}
|
||||
currentChannel={currentChannel}
|
||||
setCurrentChannel={setCurrentChannel}
|
||||
currentTheme={currentTheme}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls Container */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
{/* Left Side - Playback Controls */}
|
||||
{mounted && (
|
||||
<PlaybackControls
|
||||
isPlaying={isPlaying}
|
||||
setIsPlaying={setIsPlaying}
|
||||
volume={volume}
|
||||
setVolume={handleVolumeChange}
|
||||
changeChannel={changeChannel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Right Side - Channel Management */}
|
||||
{mounted && (
|
||||
<ChannelManagement
|
||||
isAddingChannel={isAddingChannel}
|
||||
setIsAddingChannel={setIsAddingChannel}
|
||||
newChannel={newChannel}
|
||||
setNewChannel={setNewChannel}
|
||||
saveChannel={handleSaveChannel}
|
||||
currentTheme={currentTheme}
|
||||
currentChannel={currentChannel}
|
||||
handleEditChannel={handleEditChannel}
|
||||
setShowDeleteConfirm={setShowDeleteConfirm}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="px-2">
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-[var(--lofi-card-hover)]">
|
||||
<div
|
||||
className="h-full bg-[var(--lofi-accent)] transition-all duration-300"
|
||||
style={{ width: `${played * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sound Effects Section - Separated into its own card */}
|
||||
{mounted && (
|
||||
<div className="rounded-xl bg-[var(--lofi-card)] p-4 transition-colors duration-500 sm:p-6">
|
||||
<SoundEffectsControls
|
||||
activeEffects={activeEffects}
|
||||
toggleEffect={toggleEffect}
|
||||
effectsVolume={effectsVolume}
|
||||
setEffectsVolume={handleEffectsVolumeChange}
|
||||
effectVolumes={effectVolumes}
|
||||
setEffectVolumes={setEffectVolumes}
|
||||
currentTheme={currentTheme}
|
||||
customEffects={customEffects}
|
||||
setCustomEffects={setCustomEffects}
|
||||
loadingEffects={loadingEffects}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
{/* Channel Edit Modal */}
|
||||
{isEditingChannel !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div
|
||||
className={`w-full max-w-md rounded-lg bg-[var(--lofi-card)] p-6`}
|
||||
>
|
||||
<h3
|
||||
className={`mb-4 text-lg font-bold text-[var(--lofi-text-primary)]`}
|
||||
>
|
||||
Edit Channel {isEditingChannel + 1}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Channel Name"
|
||||
value={editingChannel.name}
|
||||
onChange={(e) =>
|
||||
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)]`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="YouTube URL"
|
||||
value={editingChannel.url}
|
||||
onChange={(e) =>
|
||||
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)]`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Description"
|
||||
value={editingChannel.description}
|
||||
onChange={(e) =>
|
||||
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)]`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Creator"
|
||||
value={editingChannel.creator}
|
||||
onChange={(e) =>
|
||||
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)]`}
|
||||
/>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={() => setIsEditingChannel(null)}
|
||||
className={`rounded-full px-3 py-1 text-xs text-[var(--lofi-text-primary)] hover:text-[var(--lofi-text-secondary)]`}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveEditedChannel}
|
||||
className="flex items-center space-x-2 rounded-full bg-[var(--lofi-button-bg)] px-3 py-1 text-xs text-[var(--lofi-button-text)] hover:bg-[var(--lofi-button-hover)]"
|
||||
>
|
||||
<Save size={14} />
|
||||
<span>Save Changes</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteConfirm !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div
|
||||
className={`w-full max-w-sm rounded-lg bg-[var(--lofi-card)] p-6`}
|
||||
>
|
||||
<h3
|
||||
className={`mb-4 text-lg font-bold text-[var(--lofi-text-primary)]`}
|
||||
>
|
||||
Delete Channel
|
||||
</h3>
|
||||
{allChannels.length <= 1 ? (
|
||||
<p className="mb-4 text-sm text-[var(--lofi-text-secondary)]">
|
||||
Cannot delete the last remaining channel.
|
||||
</p>
|
||||
) : (
|
||||
<p className={`mb-4 text-sm text-[var(--lofi-text-secondary)]`}>
|
||||
Are you sure you want to delete "
|
||||
{allChannels[showDeleteConfirm].name}"?
|
||||
{showDeleteConfirm <
|
||||
DEFAULT_CHANNELS.length - hiddenDefaultChannels.length &&
|
||||
' This will hide the default channel.'}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(null)}
|
||||
className={`rounded-full px-3 py-1 text-xs text-[var(--lofi-text-primary)] hover:text-[var(--lofi-text-secondary)]`}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteChannel(showDeleteConfirm)}
|
||||
className={`flex items-center space-x-2 rounded-full ${
|
||||
allChannels.length <= 1
|
||||
? 'cursor-not-allowed bg-[var(--lofi-button-bg)]'
|
||||
: 'bg-[var(--lofi-button-bg)] hover:bg-[var(--lofi-button-hover)]'
|
||||
} px-3 py-1 text-xs text-[var(--lofi-button-text)]`}
|
||||
disabled={allChannels.length <= 1}
|
||||
>
|
||||
<X size={14} />
|
||||
<span>Delete Channel</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingsModal
|
||||
isOpen={isSettingsOpen}
|
||||
onClose={() => setIsSettingsOpen(false)}
|
||||
currentTheme={currentTheme}
|
||||
setCurrentTheme={handleThemeChange}
|
||||
onResetDefaults={handleResetDefaults}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default EnhancedLofiPlayer
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -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<ChannelButtonsProps> = ({
|
||||
channels,
|
||||
currentChannel,
|
||||
setCurrentChannel,
|
||||
currentTheme,
|
||||
}) => (
|
||||
<div className="flex space-x-2 overflow-x-auto pb-2">
|
||||
{channels.map((channel, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setCurrentChannel(idx)}
|
||||
className={`flex-shrink-0 rounded-[var(--lofi-button-radius)] px-3 py-1 font-mono text-xs shadow-[var(--lofi-card-shadow)] transition-colors ${
|
||||
currentChannel === idx
|
||||
? 'bg-[var(--lofi-accent)] text-white hover:bg-[var(--lofi-accent-hover)]'
|
||||
: 'bg-[var(--lofi-button-bg)] text-[var(--lofi-button-text)] hover:bg-[var(--lofi-button-hover)]'
|
||||
}`}
|
||||
>
|
||||
CH{idx + 1} {channel.isCustom && '★'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
export default ChannelButtons
|
||||
@ -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<ChannelManagementProps> = ({
|
||||
isAddingChannel,
|
||||
setIsAddingChannel,
|
||||
newChannel,
|
||||
setNewChannel,
|
||||
saveChannel,
|
||||
currentTheme,
|
||||
currentChannel,
|
||||
handleEditChannel,
|
||||
setShowDeleteConfirm,
|
||||
}) => (
|
||||
<div
|
||||
className={
|
||||
isAddingChannel
|
||||
? 'fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4'
|
||||
: 'flex items-center space-x-2'
|
||||
}
|
||||
>
|
||||
{!isAddingChannel ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleEditChannel(currentChannel)}
|
||||
className="rounded-[var(--lofi-button-radius)] bg-[var(--lofi-button-bg)] p-2 text-[var(--lofi-button-text)] shadow-[var(--lofi-card-shadow)] transition-colors hover:bg-[var(--lofi-button-hover)]"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(currentChannel)}
|
||||
className="rounded-[var(--lofi-button-radius)] bg-[var(--lofi-button-bg)] p-2 text-[var(--lofi-button-text)] shadow-[var(--lofi-card-shadow)] transition-colors hover:bg-[var(--lofi-button-hover)]"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsAddingChannel(true)}
|
||||
className="rounded-[var(--lofi-button-radius)] bg-[var(--lofi-button-bg)] p-2 text-[var(--lofi-button-text)] shadow-[var(--lofi-card-shadow)] transition-colors hover:bg-[var(--lofi-button-hover)]"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full max-w-md space-y-3 rounded-[var(--lofi-card-radius)] bg-[var(--lofi-card)] p-6 shadow-[var(--lofi-card-shadow)]">
|
||||
<h3 className="text-lg font-bold text-[var(--lofi-text-primary)]">
|
||||
Add New Channel
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Channel Name"
|
||||
value={newChannel.name}
|
||||
onChange={(e) =>
|
||||
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)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="YouTube URL"
|
||||
value={newChannel.url}
|
||||
onChange={(e) =>
|
||||
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)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Description"
|
||||
value={newChannel.description}
|
||||
onChange={(e) =>
|
||||
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)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Creator"
|
||||
value={newChannel.creator}
|
||||
onChange={(e) =>
|
||||
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)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={() => setIsAddingChannel(false)}
|
||||
className="rounded-[var(--lofi-button-radius)] px-3 py-1 text-xs text-[var(--lofi-text-secondary)] hover:text-[var(--lofi-text-primary)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={saveChannel}
|
||||
className="flex items-center space-x-2 rounded-[var(--lofi-button-radius)] bg-[var(--lofi-accent)] px-3 py-1 text-xs text-white shadow-[var(--lofi-card-shadow)] hover:bg-[var(--lofi-accent-hover)]"
|
||||
>
|
||||
<Save size={14} />
|
||||
<span>Save Channel</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
export default ChannelManagement
|
||||
@ -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<PlaybackControlsProps> = ({
|
||||
isPlaying,
|
||||
setIsPlaying,
|
||||
volume,
|
||||
setVolume,
|
||||
changeChannel,
|
||||
}) => (
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left Side - Playback Controls */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => changeChannel('prev')}
|
||||
className="rounded-[var(--lofi-button-radius)] p-2 text-[var(--lofi-text-primary)] shadow-[var(--lofi-card-shadow)] hover:bg-[var(--lofi-card-hover)]"
|
||||
>
|
||||
<SkipBack size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsPlaying(!isPlaying)}
|
||||
className="rounded-[var(--lofi-button-radius)] p-2 text-[var(--lofi-text-primary)] shadow-[var(--lofi-card-shadow)] hover:bg-[var(--lofi-card-hover)]"
|
||||
>
|
||||
{isPlaying ? <Pause size={16} /> : <Play size={16} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => changeChannel('next')}
|
||||
className="rounded-[var(--lofi-button-radius)] p-2 text-[var(--lofi-text-primary)] shadow-[var(--lofi-card-shadow)] hover:bg-[var(--lofi-card-hover)]"
|
||||
>
|
||||
<SkipForward size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Volume Control */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setVolume(volume === 0 ? 0.7 : 0)}
|
||||
className="rounded-[var(--lofi-button-radius)] p-2 text-[var(--lofi-text-primary)] shadow-[var(--lofi-card-shadow)] hover:bg-[var(--lofi-card-hover)]"
|
||||
>
|
||||
{volume === 0 ? (
|
||||
<VolumeX size={16} />
|
||||
) : volume < 0.5 ? (
|
||||
<Volume1 size={16} />
|
||||
) : (
|
||||
<Volume2 size={16} />
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
||||
className="w-16 accent-[var(--lofi-accent)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default PlaybackControls
|
||||
@ -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<SettingsModalProps> = ({
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-md rounded-[var(--lofi-card-radius)] bg-[var(--lofi-card)] p-6 shadow-[var(--lofi-card-shadow)]">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold text-[var(--lofi-text-primary)]">
|
||||
Settings
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-[var(--lofi-button-radius)] p-2 text-[var(--lofi-text-secondary)] hover:bg-[var(--lofi-card-hover)]"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<label
|
||||
htmlFor="theme-select"
|
||||
className="block text-sm font-medium text-[var(--lofi-text-primary)]"
|
||||
>
|
||||
Theme
|
||||
</label>
|
||||
<select
|
||||
id="theme-select"
|
||||
value={currentTheme}
|
||||
onChange={(e) => setCurrentTheme(e.target.value)}
|
||||
className="w-full rounded-[var(--lofi-button-radius)] border border-[var(--lofi-border)] bg-[var(--lofi-card-hover)] px-3 py-2 text-[var(--lofi-text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--lofi-accent)]"
|
||||
>
|
||||
{Object.values(themes).map((theme) => (
|
||||
<option key={theme.id} value={theme.id}>
|
||||
{theme.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={handleResetDefaults}
|
||||
className="flex w-full items-center justify-center space-x-2 rounded-[var(--lofi-button-radius)] bg-[var(--lofi-button-bg)] py-2 text-[var(--lofi-button-text)] shadow-[var(--lofi-card-shadow)] transition-colors hover:bg-[var(--lofi-button-hover)]"
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
<span>Restore Defaults</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsModal
|
||||
@ -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<string>
|
||||
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<string>
|
||||
}
|
||||
|
||||
const SoundEffectsControls: React.FC<SoundEffectsControlsProps> = ({
|
||||
activeEffects,
|
||||
toggleEffect,
|
||||
effectsVolume,
|
||||
setEffectsVolume,
|
||||
effectVolumes,
|
||||
setEffectVolumes,
|
||||
currentTheme,
|
||||
customEffects,
|
||||
setCustomEffects,
|
||||
loadingEffects,
|
||||
}) => {
|
||||
const [isAddingEffect, setIsAddingEffect] = useState(false)
|
||||
const [newEffect, setNewEffect] = useState<CustomSoundEffect>({
|
||||
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 (
|
||||
<div
|
||||
key={effect.id}
|
||||
className={`relative flex flex-col rounded-[var(--lofi-card-radius)] p-3 shadow-[var(--lofi-card-shadow)] transition-colors ${
|
||||
isActive
|
||||
? 'bg-[var(--lofi-card)] ring-1 ring-[var(--lofi-accent)] ring-opacity-50'
|
||||
: 'bg-[var(--lofi-card-hover)]'
|
||||
}`}
|
||||
>
|
||||
{effect.isYoutube && isActive && (
|
||||
<div className="hidden">
|
||||
<ReactPlayer
|
||||
url={effect.file}
|
||||
playing={isActive}
|
||||
volume={effectVolumes[effect.id] * effectsVolume}
|
||||
loop
|
||||
config={{
|
||||
youtube: {
|
||||
playerVars: { controls: 0 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => toggleEffect(effect.id)}
|
||||
disabled={isLoading}
|
||||
className={`rounded-[var(--lofi-button-radius)] p-1.5 shadow-[var(--lofi-card-shadow)] transition-colors focus:outline-none ${
|
||||
isLoading
|
||||
? 'opacity-50'
|
||||
: isActive
|
||||
? 'bg-[var(--lofi-accent)] text-white'
|
||||
: 'bg-[var(--lofi-button-bg)] text-[var(--lofi-button-text)] hover:bg-[var(--lofi-button-hover)]'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-zinc-300 border-t-transparent" />
|
||||
) : (
|
||||
<effect.icon size={16} />
|
||||
)}
|
||||
</button>
|
||||
<span className="font-mono text-xs text-[var(--lofi-text-primary)]">
|
||||
{effect.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-mono text-xs text-[var(--lofi-text-secondary)]">
|
||||
{Math.round(effectVolumes[effect.id] * 100)}%
|
||||
</span>
|
||||
{effect.isCustom && (
|
||||
<button
|
||||
onClick={() => handleDeleteEffect(effect.id)}
|
||||
className="rounded-md bg-[var(--lofi-button-bg)] p-1 text-[var(--lofi-button-text)] hover:bg-[var(--lofi-button-hover)] focus:outline-none"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={effectVolumes[effect.id]}
|
||||
onChange={(e) =>
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="mb-4 flex flex-row justify-between gap-4 sm:items-center">
|
||||
<h3 className="mt-2 font-mono text-sm text-[var(--lofi-text-primary)]">
|
||||
Effects
|
||||
</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setIsAddingEffect(true)}
|
||||
className="rounded-full bg-[var(--lofi-button-bg)] p-2 text-[var(--lofi-button-text)] hover:bg-[var(--lofi-button-hover)] focus:outline-none"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
<span className="hidden font-mono text-xs text-[var(--lofi-text-secondary)] sm:inline">
|
||||
Master Volume
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={effectsVolume}
|
||||
onChange={(e) => 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)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{allEffects.map((effect) => renderSoundEffect(effect))}
|
||||
</div>
|
||||
|
||||
{isAddingEffect && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-md space-y-3 rounded-[var(--lofi-card-radius)] bg-[var(--lofi-card)] p-6 shadow-[var(--lofi-card-shadow)]">
|
||||
<h3 className="text-lg font-bold text-[var(--lofi-text-primary)]">
|
||||
Add Sound Effect
|
||||
</h3>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Effect Name"
|
||||
value={newEffect.name}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="YouTube URL"
|
||||
value={newEffect.file}
|
||||
onChange={(e) => {
|
||||
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 && <p className="text-xs text-red-500">{urlError}</p>}
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={() => setIsAddingEffect(false)}
|
||||
className="rounded-[var(--lofi-button-radius)] px-3 py-1 text-xs text-[var(--lofi-text-secondary)] hover:text-[var(--lofi-text-primary)] focus:outline-none"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddEffect}
|
||||
className="flex items-center space-x-2 rounded-[var(--lofi-button-radius)] bg-[var(--lofi-accent)] px-3 py-1 text-xs text-white shadow-[var(--lofi-card-shadow)] hover:bg-[var(--lofi-accent-hover)]"
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span>Add Effect</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SoundEffectsControls
|
||||
@ -0,0 +1,37 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
export function useLocalStorage<T>(key: string, initialValue: T) {
|
||||
// Initialize state with a function to avoid unnecessary localStorage access during SSR
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
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
|
||||
}
|
||||
@ -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',
|
||||
},
|
||||
}
|
||||
@ -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',
|
||||
},
|
||||
]
|
||||
@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 391 B |
@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 128 B |
@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
Before Width: | Height: | Size: 385 B |
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue