diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..d91f958
--- /dev/null
+++ b/.env.example
@@ -0,0 +1 @@
+DATABASE_URL="file:./dev.db"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..bca9b18
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,46 @@
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# local env files
+.env*.local
+.env
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
+
+# prisma
+prisma/dev.db
+prisma/dev.db-journal
+
+# python
+__pycache__/
+*.py[cod]
+*$py.class
+.venv/
+venv/
+env/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..eb541a6
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,57 @@
+# Build stage
+FROM node:20-alpine AS builder
+
+WORKDIR /app
+
+# Copy package files
+COPY package*.json ./
+COPY prisma ./prisma/
+
+# Install dependencies
+RUN npm ci
+
+# Copy source code
+COPY . .
+
+# Generate Prisma Client
+RUN npx prisma generate
+
+# Build Next.js application
+RUN npm run build
+
+# Production stage
+FROM node:20-alpine AS runner
+
+# Install Python and yt-dlp dependencies
+RUN apk add --no-cache python3 py3-pip ffmpeg
+
+# Install yt-dlp
+RUN pip3 install --no-cache-dir yt-dlp --break-system-packages
+
+WORKDIR /app
+
+ENV NODE_ENV production
+ENV NEXT_TELEMETRY_DISABLED 1
+
+# Create non-root user
+RUN addgroup --system --gid 1001 nodejs
+RUN adduser --system --uid 1001 nextjs
+
+# Copy necessary files from builder
+COPY --from=builder /app/public ./public
+COPY --from=builder /app/.next/standalone ./
+COPY --from=builder /app/.next/static ./.next/static
+COPY --from=builder /app/prisma ./prisma
+COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
+
+# Create database directory
+RUN mkdir -p /app/prisma && chown -R nextjs:nodejs /app
+
+USER nextjs
+
+EXPOSE 3000
+
+ENV PORT 3000
+ENV HOSTNAME "0.0.0.0"
+
+CMD ["node", "server.js"]
diff --git a/app/api/admin/update/route.ts b/app/api/admin/update/route.ts
new file mode 100644
index 0000000..ac55b83
--- /dev/null
+++ b/app/api/admin/update/route.ts
@@ -0,0 +1,19 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { updateYtDlp } from '@/lib/ytdlp'
+
+export async function POST(request: NextRequest) {
+ try {
+ const result = await updateYtDlp()
+
+ return NextResponse.json({
+ success: true,
+ message: result
+ })
+ } catch (error) {
+ console.error('Error updating yt-dlp:', error)
+ return NextResponse.json(
+ { error: 'Failed to update yt-dlp' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/playlists/[id]/route.ts b/app/api/playlists/[id]/route.ts
new file mode 100644
index 0000000..adce2e3
--- /dev/null
+++ b/app/api/playlists/[id]/route.ts
@@ -0,0 +1,62 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { prisma } from '@/lib/prisma'
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const { id } = await params
+
+ await prisma.playlist.delete({
+ where: { id },
+ })
+
+ console.log('[API] Playlist deleted:', id)
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ console.error('[API] Error deleting playlist:', error)
+ return NextResponse.json(
+ { error: 'Failed to delete playlist' },
+ { status: 500 }
+ )
+ }
+}
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const { id } = await params
+
+ const playlist = await prisma.playlist.findUnique({
+ where: { id },
+ include: {
+ playlistSongs: {
+ include: {
+ song: true,
+ },
+ orderBy: {
+ addedAt: 'asc',
+ },
+ },
+ },
+ })
+
+ if (!playlist) {
+ return NextResponse.json(
+ { error: 'Playlist not found' },
+ { status: 404 }
+ )
+ }
+
+ return NextResponse.json(playlist)
+ } catch (error) {
+ console.error('[API] Error fetching playlist:', error)
+ return NextResponse.json(
+ { error: 'Failed to fetch playlist' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/playlists/[id]/songs/[songId]/route.ts b/app/api/playlists/[id]/songs/[songId]/route.ts
new file mode 100644
index 0000000..7e5f1c3
--- /dev/null
+++ b/app/api/playlists/[id]/songs/[songId]/route.ts
@@ -0,0 +1,29 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { prisma } from '@/lib/prisma'
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string; songId: string }> }
+) {
+ try {
+ const { id, songId } = await params
+
+ await prisma.playlistSong.delete({
+ where: {
+ playlistId_songId: {
+ playlistId: id,
+ songId: songId,
+ },
+ },
+ })
+
+ console.log('[API] Song removed from playlist')
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ console.error('[API] Error removing song from playlist:', error)
+ return NextResponse.json(
+ { error: 'Failed to remove song from playlist' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/playlists/[id]/songs/route.ts b/app/api/playlists/[id]/songs/route.ts
new file mode 100644
index 0000000..b4b942e
--- /dev/null
+++ b/app/api/playlists/[id]/songs/route.ts
@@ -0,0 +1,52 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { prisma } from '@/lib/prisma'
+
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const { id } = await params
+ const { songId } = await request.json()
+
+ if (!songId) {
+ return NextResponse.json(
+ { error: 'songId is required' },
+ { status: 400 }
+ )
+ }
+
+ // Check if already in playlist
+ const existing = await prisma.playlistSong.findUnique({
+ where: {
+ playlistId_songId: {
+ playlistId: id,
+ songId: songId,
+ },
+ },
+ })
+
+ if (existing) {
+ return NextResponse.json(
+ { error: 'Song already in playlist' },
+ { status: 400 }
+ )
+ }
+
+ const playlistSong = await prisma.playlistSong.create({
+ data: {
+ playlistId: id,
+ songId: songId,
+ },
+ })
+
+ console.log('[API] Song added to playlist:', playlistSong.id)
+ return NextResponse.json(playlistSong)
+ } catch (error) {
+ console.error('[API] Error adding song to playlist:', error)
+ return NextResponse.json(
+ { error: 'Failed to add song to playlist' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/playlists/route.ts b/app/api/playlists/route.ts
new file mode 100644
index 0000000..acb885c
--- /dev/null
+++ b/app/api/playlists/route.ts
@@ -0,0 +1,51 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { prisma } from '@/lib/prisma'
+
+export async function GET() {
+ try {
+ const playlists = await prisma.playlist.findMany({
+ orderBy: { createdAt: 'desc' },
+ include: {
+ playlistSongs: {
+ include: {
+ song: true,
+ },
+ },
+ },
+ })
+
+ return NextResponse.json(playlists)
+ } catch (error) {
+ console.error('[API] Error fetching playlists:', error)
+ return NextResponse.json(
+ { error: 'Failed to fetch playlists' },
+ { status: 500 }
+ )
+ }
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const { name } = await request.json()
+
+ if (!name) {
+ return NextResponse.json(
+ { error: 'Name is required' },
+ { status: 400 }
+ )
+ }
+
+ const playlist = await prisma.playlist.create({
+ data: { name },
+ })
+
+ console.log('[API] Playlist created:', playlist.id)
+ return NextResponse.json(playlist)
+ } catch (error) {
+ console.error('[API] Error creating playlist:', error)
+ return NextResponse.json(
+ { error: 'Failed to create playlist' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/songs/[id]/route.ts b/app/api/songs/[id]/route.ts
new file mode 100644
index 0000000..cbac529
--- /dev/null
+++ b/app/api/songs/[id]/route.ts
@@ -0,0 +1,27 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { prisma } from '@/lib/prisma'
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const { id } = await params
+
+ // Instead of deleting, mark as not in library
+ // This keeps the song in playlists but removes it from main library
+ await prisma.song.update({
+ where: { id },
+ data: { isInLibrary: false },
+ })
+
+ console.log('[API] Song removed from library:', id)
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ console.error('[API] Error removing song from library:', error)
+ return NextResponse.json(
+ { error: 'Failed to remove song from library' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/songs/route.ts b/app/api/songs/route.ts
new file mode 100644
index 0000000..8009c1a
--- /dev/null
+++ b/app/api/songs/route.ts
@@ -0,0 +1,80 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { prisma } from '@/lib/prisma'
+import { getYoutubeMetadata } from '@/lib/ytdlp'
+
+export async function POST(request: NextRequest) {
+ try {
+ const { url } = await request.json()
+
+ if (!url) {
+ return NextResponse.json(
+ { error: 'URL is required' },
+ { status: 400 }
+ )
+ }
+
+ console.log('[API] Adding song from URL:', url)
+
+ // Get metadata from YouTube using yt-dlp
+ const metadata = await getYoutubeMetadata(url)
+ console.log('[API] Got metadata:', metadata)
+
+ // Check if song already exists
+ const existingSong = await prisma.song.findUnique({
+ where: { youtubeId: metadata.id },
+ })
+
+ if (existingSong) {
+ console.log('[API] Song already exists:', existingSong.id)
+
+ // If song was previously removed from library, restore it
+ if (!existingSong.isInLibrary) {
+ const updatedSong = await prisma.song.update({
+ where: { id: existingSong.id },
+ data: { isInLibrary: true },
+ })
+ console.log('[API] Song restored to library:', updatedSong.id)
+ return NextResponse.json(updatedSong)
+ }
+
+ return NextResponse.json(existingSong)
+ }
+
+ // Create new song in database
+ const song = await prisma.song.create({
+ data: {
+ youtubeId: metadata.id,
+ title: metadata.title,
+ channelName: metadata.channel,
+ thumbnail: metadata.thumbnail,
+ duration: metadata.duration,
+ },
+ })
+
+ console.log('[API] Song created successfully:', song.id)
+ return NextResponse.json(song)
+ } catch (error) {
+ console.error('[API] Error adding song:', error)
+ return NextResponse.json(
+ { error: 'Failed to add song' },
+ { status: 500 }
+ )
+ }
+}
+
+export async function GET() {
+ try {
+ const songs = await prisma.song.findMany({
+ where: { isInLibrary: true },
+ orderBy: { addedAt: 'desc' },
+ })
+
+ return NextResponse.json(songs)
+ } catch (error) {
+ console.error('Error fetching songs:', error)
+ return NextResponse.json(
+ { error: 'Failed to fetch songs' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/stream/[id]/route.ts b/app/api/stream/[id]/route.ts
new file mode 100644
index 0000000..780d030
--- /dev/null
+++ b/app/api/stream/[id]/route.ts
@@ -0,0 +1,47 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { prisma } from '@/lib/prisma'
+import { getStreamUrl } from '@/lib/ytdlp'
+import { streamCache } from '@/lib/cache'
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const { id } = await params
+
+ // Get song from database
+ const song = await prisma.song.findUnique({
+ where: { id },
+ })
+
+ if (!song) {
+ return NextResponse.json(
+ { error: 'Song not found' },
+ { status: 404 }
+ )
+ }
+
+ // Check cache first
+ let streamUrl = streamCache.get(song.youtubeId)
+
+ if (!streamUrl) {
+ console.log('[Stream] Cache miss for:', song.youtubeId)
+ // Get dynamic stream URL using yt-dlp
+ streamUrl = await getStreamUrl(song.youtubeId)
+ // Cache the URL
+ streamCache.set(song.youtubeId, streamUrl)
+ } else {
+ console.log('[Stream] Cache hit for:', song.youtubeId)
+ }
+
+ // Redirect to the stream URL
+ return NextResponse.redirect(streamUrl)
+ } catch (error) {
+ console.error('Error streaming song:', error)
+ return NextResponse.json(
+ { error: 'Failed to get stream URL' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/globals.css b/app/globals.css
new file mode 100644
index 0000000..033aef5
--- /dev/null
+++ b/app/globals.css
@@ -0,0 +1,18 @@
+@import "tailwindcss";
+@import "./slider.css";
+
+@keyframes slide-in-right {
+ from {
+ transform: translateX(100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+.animate-slide-in-right {
+ animation: slide-in-right 0.3s ease-out;
+}
+
diff --git a/app/icon.svg b/app/icon.svg
new file mode 100644
index 0000000..97d2b2d
--- /dev/null
+++ b/app/icon.svg
@@ -0,0 +1,7 @@
+
+
+
diff --git a/app/layout.tsx b/app/layout.tsx
new file mode 100644
index 0000000..3dad083
--- /dev/null
+++ b/app/layout.tsx
@@ -0,0 +1,29 @@
+import type { Metadata } from 'next'
+import { ToastProvider } from '@/components/Toast'
+import { PlayerProvider } from '@/components/PlayerContext'
+import GlobalPlayer from '@/components/GlobalPlayer'
+import './globals.css'
+
+export const metadata: Metadata = {
+ title: 'Bitify - Self-hosted youtube music player',
+ description: 'Self-hosted YouTube music streaming app',
+}
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+
+ )
+}
diff --git a/app/page.tsx b/app/page.tsx
new file mode 100644
index 0000000..6d63a7c
--- /dev/null
+++ b/app/page.tsx
@@ -0,0 +1,167 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import Navbar from '@/components/Navbar'
+import Header from '@/components/Header'
+import SongCard from '@/components/SongCard'
+import { useToast } from '@/components/Toast'
+import { usePlayer } from '@/components/PlayerContext'
+
+interface Song {
+ id: string
+ youtubeId: string
+ title: string
+ channelName: string
+ thumbnail: string
+ duration: number
+}
+
+interface Playlist {
+ id: string
+ name: string
+}
+
+export default function Home() {
+ const [songs, setSongs] = useState([])
+ const [playlists, setPlaylists] = useState([])
+ const [isLoading, setIsLoading] = useState(false)
+ const { showToast } = useToast()
+ const { playSong } = usePlayer()
+
+ // Fetch songs and playlists on mount
+ useEffect(() => {
+ fetchSongs()
+ fetchPlaylists()
+ }, [])
+
+ const fetchPlaylists = async () => {
+ try {
+ const response = await fetch('/api/playlists')
+ if (response.ok) {
+ const data = await response.json()
+ setPlaylists(data)
+ }
+ } catch (error) {
+ console.error('Error fetching playlists:', error)
+ }
+ }
+
+ const fetchSongs = async () => {
+ try {
+ const response = await fetch('/api/songs')
+ if (response.ok) {
+ const data = await response.json()
+ setSongs(data)
+ }
+ } catch (error) {
+ console.error('Error fetching songs:', error)
+ }
+ }
+
+ const handleAddSong = async (url: string) => {
+ setIsLoading(true)
+ try {
+ const response = await fetch('/api/songs', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ url }),
+ })
+
+ if (response.ok) {
+ const newSong = await response.json()
+ setSongs((prev) => [newSong, ...prev])
+ showToast('Song added successfully', 'success')
+ } else {
+ showToast('Failed to add song. Please check the URL.', 'error')
+ }
+ } catch (error) {
+ console.error('Error adding song:', error)
+ showToast('An error occurred while adding the song.', 'error')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handlePlaySong = (song: Song) => {
+ const index = songs.findIndex((s) => s.id === song.id)
+ playSong(song, index, songs, false)
+ }
+
+ const handleDeleteSong = async (songId: string) => {
+ try {
+ const response = await fetch(`/api/songs/${songId}`, {
+ method: 'DELETE',
+ })
+
+ if (response.ok) {
+ // Remove song from state
+ setSongs((prev) => prev.filter((s) => s.id !== songId))
+ showToast('Song deleted', 'success')
+ } else {
+ showToast('Error deleting song', 'error')
+ }
+ } catch (error) {
+ console.error('Error deleting song:', error)
+ showToast('Error deleting song', 'error')
+ }
+ }
+
+ const handleAddToPlaylist = async (songId: string, playlistId: string) => {
+ try {
+ const response = await fetch(`/api/playlists/${playlistId}/songs`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ songId }),
+ })
+
+ if (response.ok) {
+ showToast('Song added to playlist', 'success')
+ } else {
+ const data = await response.json()
+ showToast(data.error || 'Error adding song to playlist', 'error')
+ }
+ } catch (error) {
+ console.error('Error adding to playlist:', error)
+ showToast('Error adding song to playlist', 'error')
+ }
+ }
+
+ return (
+
+
+
+
+
+ {songs.length === 0 ? (
+
+
+
+ No songs yet
+
+
+ Add your first song by pasting a YouTube URL
+
+
+
+ ) : (
+
+ {songs.map((song) => (
+
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/app/playlists/page.tsx b/app/playlists/page.tsx
new file mode 100644
index 0000000..f83aa92
--- /dev/null
+++ b/app/playlists/page.tsx
@@ -0,0 +1,313 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { Plus, Trash2, Music, Play } from 'lucide-react'
+import Navbar from '@/components/Navbar'
+import SongCard from '@/components/SongCard'
+import { useToast } from '@/components/Toast'
+import { usePlayer } from '@/components/PlayerContext'
+
+interface Song {
+ id: string
+ youtubeId: string
+ title: string
+ channelName: string
+ thumbnail: string
+ duration: number
+}
+
+interface PlaylistSong {
+ id: string
+ song: Song
+}
+
+interface Playlist {
+ id: string
+ name: string
+ playlistSongs: PlaylistSong[]
+}
+
+export default function PlaylistsPage() {
+ const [playlists, setPlaylists] = useState([])
+ const [selectedPlaylist, setSelectedPlaylist] = useState(null)
+ const [newPlaylistName, setNewPlaylistName] = useState('')
+ const [showCreateForm, setShowCreateForm] = useState(false)
+ const [playlistSongs, setPlaylistSongs] = useState([])
+ const { showToast } = useToast()
+ const { playSong, isPlaylist, isShuffled, toggleShuffle } = usePlayer()
+
+ useEffect(() => {
+ fetchPlaylists()
+ }, [])
+
+ useEffect(() => {
+ if (selectedPlaylist) {
+ const songs = selectedPlaylist.playlistSongs.map((ps) => ps.song)
+ setPlaylistSongs(songs)
+ }
+ }, [selectedPlaylist])
+
+ const fetchPlaylists = async () => {
+ try {
+ const response = await fetch('/api/playlists')
+ if (response.ok) {
+ const data = await response.json()
+ setPlaylists(data)
+ }
+ } catch (error) {
+ console.error('Error fetching playlists:', error)
+ }
+ }
+
+ const handleCreatePlaylist = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!newPlaylistName.trim()) return
+
+ try {
+ const response = await fetch('/api/playlists', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ name: newPlaylistName }),
+ })
+
+ if (response.ok) {
+ const playlist = await response.json()
+ setPlaylists((prev) => [{ ...playlist, playlistSongs: [] }, ...prev])
+ setNewPlaylistName('')
+ setShowCreateForm(false)
+ showToast('Playlist created', 'success')
+ }
+ } catch (error) {
+ console.error('Error creating playlist:', error)
+ showToast('Error creating playlist', 'error')
+ }
+ }
+
+ const handleDeletePlaylist = async (playlistId: string) => {
+ if (!confirm('¿Eliminar esta playlist?')) return
+
+ try {
+ const response = await fetch(`/api/playlists/${playlistId}`, {
+ method: 'DELETE',
+ })
+
+ if (response.ok) {
+ setPlaylists((prev) => prev.filter((p) => p.id !== playlistId))
+ if (selectedPlaylist?.id === playlistId) {
+ setSelectedPlaylist(null)
+ }
+ showToast('Playlist deleted', 'success')
+ }
+ } catch (error) {
+ console.error('Error deleting playlist:', error)
+ showToast('Error deleting playlist', 'error')
+ }
+ }
+
+ const handleRemoveSongFromPlaylist = async (songId: string) => {
+ if (!selectedPlaylist) return
+
+ try {
+ const response = await fetch(
+ `/api/playlists/${selectedPlaylist.id}/songs/${songId}`,
+ {
+ method: 'DELETE',
+ }
+ )
+
+ if (response.ok) {
+ // Update selected playlist
+ const updatedPlaylistSongs = selectedPlaylist.playlistSongs.filter(
+ (ps) => ps.song.id !== songId
+ )
+ const updatedPlaylist = {
+ ...selectedPlaylist,
+ playlistSongs: updatedPlaylistSongs,
+ }
+ setSelectedPlaylist(updatedPlaylist)
+
+ // Update playlists list
+ setPlaylists((prev) =>
+ prev.map((p) =>
+ p.id === selectedPlaylist.id ? updatedPlaylist : p
+ )
+ )
+ showToast('Song removed from playlist', 'success')
+ }
+ } catch (error) {
+ console.error('Error removing song from playlist:', error)
+ showToast('Error removing song from playlist', 'error')
+ }
+ }
+
+ const handlePlaySong = (song: Song) => {
+ const index = playlistSongs.findIndex((s) => s.id === song.id)
+ playSong(song, index, playlistSongs, true)
+ }
+
+ const handlePlayPlaylist = (playlist: Playlist) => {
+ const songs = playlist.playlistSongs.map((ps) => ps.song)
+ if (songs.length > 0) {
+ setPlaylistSongs(songs)
+ playSong(songs[0], 0, songs, true)
+ setSelectedPlaylist(playlist)
+ }
+ }
+
+
+
+ return (
+
+
+
+
+ {/* Playlists sidebar */}
+
+
+ {!showCreateForm ? (
+
+ ) : (
+
+ )}
+
+
+
+ {playlists.length === 0 ? (
+
+ No playlists
+
+ ) : (
+ playlists.map((playlist) => (
+
+
setSelectedPlaylist(playlist)}
+ className="flex-1 min-w-0 cursor-pointer"
+ >
+
+ {playlist.name}
+
+
+ {playlist.playlistSongs.length} song
+ {playlist.playlistSongs.length !== 1 ? 's' : ''}
+
+
+
+ {playlist.playlistSongs.length > 0 && (
+
+ )}
+
+
+
+ ))
+ )}
+
+
+
+ {/* Playlist content */}
+
+ {!selectedPlaylist ? (
+
+
+
+
+ Select a playlist
+
+
+ Or create a new one to start
+
+
+
+ ) : selectedPlaylist.playlistSongs.length === 0 ? (
+
+
+
+
+ Empty playlist
+
+
+ Add songs from the home page
+
+
+
+ ) : (
+
+
+ {selectedPlaylist.name}
+
+
+ {selectedPlaylist.playlistSongs.map((ps) => (
+
+ ))}
+
+
+ )}
+
+
+
+ )
+}
diff --git a/app/slider.css b/app/slider.css
new file mode 100644
index 0000000..a00b5b8
--- /dev/null
+++ b/app/slider.css
@@ -0,0 +1,61 @@
+/* Custom slider styles for PlayerBar */
+input[type='range'] {
+ -webkit-appearance: none;
+ appearance: none;
+ background: transparent;
+ cursor: pointer;
+}
+
+/* Webkit (Chrome, Safari, Edge) */
+input[type='range']::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: #ffffff;
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity 0.2s;
+ position: relative;
+ z-index: 20;
+ border: 2px solid #ffffff;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
+ margin-top: -6.5px; /* (14px - 1px) / 2 = centra el thumb de 14px con la barra de 1px */
+}
+
+input[type='range']:hover::-webkit-slider-thumb,
+input[type='range']:active::-webkit-slider-thumb {
+ opacity: 1;
+}
+
+input[type='range']::-webkit-slider-runnable-track {
+ background: transparent;
+ height: 1px;
+ cursor: pointer;
+}
+
+/* Firefox */
+input[type='range']::-moz-range-thumb {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: #ffffff;
+ cursor: pointer;
+ border: 2px solid #ffffff;
+ opacity: 0;
+ transition: opacity 0.2s;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
+}
+
+input[type='range']:hover::-moz-range-thumb,
+input[type='range']:active::-moz-range-thumb {
+ opacity: 1;
+}
+
+input[type='range']::-moz-range-track {
+ background: transparent;
+ height: 1px;
+ cursor: pointer;
+}
+
diff --git a/components/GlobalPlayer.tsx b/components/GlobalPlayer.tsx
new file mode 100644
index 0000000..f48e16e
--- /dev/null
+++ b/components/GlobalPlayer.tsx
@@ -0,0 +1,22 @@
+'use client'
+
+import { usePlayer } from './PlayerContext'
+import PlayerBar from './PlayerBar'
+
+export default function GlobalPlayer() {
+ const { currentSong, currentIndex, playlist, isPlaylist, isShuffled, playNext, playPrevious, toggleShuffle } = usePlayer()
+
+ const nextSong = currentIndex < playlist.length - 1 ? playlist[currentIndex + 1] : null
+
+ return (
+ 0 ? playPrevious : undefined}
+ isPlaylist={isPlaylist}
+ isShuffled={isShuffled}
+ onToggleShuffle={isPlaylist ? toggleShuffle : undefined}
+ nextSong={nextSong}
+ />
+ )
+}
diff --git a/components/Header.tsx b/components/Header.tsx
new file mode 100644
index 0000000..b7bcd2c
--- /dev/null
+++ b/components/Header.tsx
@@ -0,0 +1,47 @@
+'use client'
+
+import { useState } from 'react'
+import { Plus } from 'lucide-react'
+
+interface HeaderProps {
+ onAddSong: (url: string) => void
+ isLoading?: boolean
+}
+
+export default function Header({ onAddSong, isLoading }: HeaderProps) {
+ const [url, setUrl] = useState('')
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault()
+ if (url.trim()) {
+ onAddSong(url)
+ setUrl('')
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/components/Navbar.tsx b/components/Navbar.tsx
new file mode 100644
index 0000000..9ef2cb7
--- /dev/null
+++ b/components/Navbar.tsx
@@ -0,0 +1,107 @@
+'use client'
+
+import { usePathname } from 'next/navigation'
+import Link from 'next/link'
+import { Home, ListMusic, Github, Star } from 'lucide-react'
+
+export default function Navbar() {
+ const pathname = usePathname()
+
+ const isActive = (path: string) => pathname === path
+
+ return (
+
+ )
+}
diff --git a/components/PlayerBar.tsx b/components/PlayerBar.tsx
new file mode 100644
index 0000000..2c51097
--- /dev/null
+++ b/components/PlayerBar.tsx
@@ -0,0 +1,324 @@
+'use client'
+
+import { useState, useRef, useEffect } from 'react'
+import Image from 'next/image'
+import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Shuffle } from 'lucide-react'
+
+interface Song {
+ id: string
+ youtubeId: string
+ title: string
+ channelName: string
+ thumbnail: string
+ duration: number
+}
+
+interface PlayerBarProps {
+ currentSong: Song | null
+ onNext?: () => void
+ onPrevious?: () => void
+ isPlaylist?: boolean
+ isShuffled?: boolean
+ onToggleShuffle?: () => void
+ nextSong?: Song | null
+}
+
+export default function PlayerBar({
+ currentSong,
+ onNext,
+ onPrevious,
+ isPlaylist = false,
+ isShuffled = false,
+ onToggleShuffle,
+ nextSong
+}: PlayerBarProps) {
+ const [isPlaying, setIsPlaying] = useState(false)
+ const [currentTime, setCurrentTime] = useState(0)
+ const [duration, setDuration] = useState(0)
+ const [volume, setVolume] = useState(1)
+ const [isMuted, setIsMuted] = useState(false)
+ const audioRef = useRef(null)
+
+ useEffect(() => {
+ if (currentSong && audioRef.current) {
+ const audio = audioRef.current
+
+ // Reset state when changing songs
+ setCurrentTime(0)
+ setDuration(0)
+ setIsPlaying(false)
+
+ audio.src = `/api/stream/${currentSong.id}`
+ audio.load() // Force reload to ensure metadata is loaded
+
+ // Try to get duration after a short delay to ensure metadata is loaded
+ const checkDuration = () => {
+ if (audio.duration && !isNaN(audio.duration) && isFinite(audio.duration)) {
+ setDuration(audio.duration)
+ }
+ }
+
+ // Check duration immediately and after load
+ const loadedHandler = () => {
+ checkDuration()
+ audio.play().then(() => {
+ setIsPlaying(true)
+ }).catch(err => console.error('Error playing:', err))
+ }
+
+ audio.addEventListener('loadedmetadata', loadedHandler)
+
+ // Fallback if loadedmetadata doesn't fire
+ const timeoutId = setTimeout(() => {
+ checkDuration()
+ if (audio.paused) {
+ audio.play().then(() => {
+ setIsPlaying(true)
+ }).catch(err => console.error('Error playing:', err))
+ }
+ }, 100)
+
+ return () => {
+ audio.removeEventListener('loadedmetadata', loadedHandler)
+ clearTimeout(timeoutId)
+ }
+ }
+ }, [currentSong])
+
+ // Pre-load next song
+ useEffect(() => {
+ if (nextSong) {
+ // Pre-fetch the stream URL to populate cache
+ fetch(`/api/stream/${nextSong.id}`, { method: 'HEAD' }).catch(() => {})
+ }
+ }, [nextSong])
+
+ useEffect(() => {
+ const audio = audioRef.current
+ if (!audio) return
+
+ const updateTime = () => setCurrentTime(audio.currentTime)
+ const updateDuration = () => {
+ if (audio.duration && !isNaN(audio.duration) && isFinite(audio.duration)) {
+ setDuration(audio.duration)
+ }
+ }
+ const handleEnded = () => {
+ setIsPlaying(false)
+ if (onNext) onNext()
+ }
+ const handleCanPlay = () => {
+ // Also update duration when audio can play
+ updateDuration()
+ }
+
+ audio.addEventListener('timeupdate', updateTime)
+ audio.addEventListener('loadedmetadata', updateDuration)
+ audio.addEventListener('durationchange', updateDuration)
+ audio.addEventListener('canplay', handleCanPlay)
+ audio.addEventListener('ended', handleEnded)
+
+ return () => {
+ audio.removeEventListener('timeupdate', updateTime)
+ audio.removeEventListener('loadedmetadata', updateDuration)
+ audio.removeEventListener('durationchange', updateDuration)
+ audio.removeEventListener('canplay', handleCanPlay)
+ audio.removeEventListener('ended', handleEnded)
+ }
+ }, [onNext])
+
+ const togglePlay = () => {
+ if (audioRef.current) {
+ if (isPlaying) {
+ audioRef.current.pause()
+ } else {
+ audioRef.current.play()
+ }
+ setIsPlaying(!isPlaying)
+ }
+ }
+
+ const handleSeek = (e: React.ChangeEvent) => {
+ const time = parseFloat(e.target.value)
+ setCurrentTime(time)
+ if (audioRef.current) {
+ audioRef.current.currentTime = time
+ }
+ }
+
+ const handleVolumeChange = (e: React.ChangeEvent) => {
+ const vol = parseFloat(e.target.value)
+ setVolume(vol)
+ if (audioRef.current) {
+ audioRef.current.volume = vol
+ }
+ if (vol === 0) {
+ setIsMuted(true)
+ } else {
+ setIsMuted(false)
+ }
+ }
+
+ const toggleMute = () => {
+ if (audioRef.current) {
+ if (isMuted) {
+ audioRef.current.volume = volume
+ setIsMuted(false)
+ } else {
+ audioRef.current.volume = 0
+ setIsMuted(true)
+ }
+ }
+ }
+
+ const formatTime = (time: number): string => {
+ if (isNaN(time)) return '0:00'
+ const mins = Math.floor(time / 60)
+ const secs = Math.floor(time % 60)
+ return `${mins}:${secs.toString().padStart(2, '0')}`
+ }
+
+ if (!currentSong) {
+ return (
+
+
+ Select a song to start playing
+
+
+ )
+ }
+
+ return (
+ <>
+
+
+
+ {/* Song Info - Always on top in mobile */}
+
+
+
+
+
+
+ {currentSong.title}
+
+
+ {currentSong.channelName}
+
+
+
+ {/* Volume - Desktop only, on the right */}
+
+
+
+
+
+
+ {/* Controls - Centered */}
+
+
+ {isPlaylist && onToggleShuffle && (
+
+ )}
+
+
+
+
+
+
+
+
+ {/* Progress Bar */}
+
+
+ {formatTime(currentTime)}
+
+
+
+
0 ? (currentTime / duration) * 100 : 0}%` }}
+ />
+
+
+
+ {formatTime(duration)}
+
+
+
+
+
+ >
+ )
+}
diff --git a/components/PlayerContext.tsx b/components/PlayerContext.tsx
new file mode 100644
index 0000000..1d7518c
--- /dev/null
+++ b/components/PlayerContext.tsx
@@ -0,0 +1,131 @@
+'use client'
+
+import { createContext, useContext, useState, ReactNode } from 'react'
+
+interface Song {
+ id: string
+ youtubeId: string
+ title: string
+ channelName: string
+ thumbnail: string
+ duration: number
+}
+
+interface PlayerContextType {
+ currentSong: Song | null
+ currentIndex: number
+ playlist: Song[]
+ isPlaylist: boolean
+ isShuffled: boolean
+ playSong: (song: Song, index?: number, songList?: Song[], fromPlaylist?: boolean) => void
+ playNext: () => void
+ playPrevious: () => void
+ toggleShuffle: () => void
+ setPlaylist: (songs: Song[]) => void
+ clearPlayer: () => void
+}
+
+const PlayerContext = createContext
(undefined)
+
+export function PlayerProvider({ children }: { children: ReactNode }) {
+ const [currentSong, setCurrentSong] = useState(null)
+ const [currentIndex, setCurrentIndex] = useState(-1)
+ const [playlist, setPlaylistState] = useState([])
+ const [isPlaylist, setIsPlaylist] = useState(false)
+ const [isShuffled, setIsShuffled] = useState(false)
+ const [originalOrder, setOriginalOrder] = useState([])
+
+ const playSong = (song: Song, index: number = 0, songList: Song[] = [], fromPlaylist: boolean = false) => {
+ setCurrentSong(song)
+ setCurrentIndex(index)
+ setPlaylistState(songList)
+ setIsPlaylist(fromPlaylist)
+ if (fromPlaylist) {
+ setOriginalOrder(songList)
+ }
+ }
+
+ const playNext = () => {
+ if (currentIndex < playlist.length - 1) {
+ const nextIndex = currentIndex + 1
+ setCurrentSong(playlist[nextIndex])
+ setCurrentIndex(nextIndex)
+ }
+ }
+
+ const playPrevious = () => {
+ if (currentIndex > 0) {
+ const prevIndex = currentIndex - 1
+ setCurrentSong(playlist[prevIndex])
+ setCurrentIndex(prevIndex)
+ }
+ }
+
+ const toggleShuffle = () => {
+ if (!currentSong || !isPlaylist) return
+
+ if (!isShuffled) {
+ // Enable shuffle
+ const currentSongData = playlist[currentIndex]
+ const otherSongs = playlist.filter((_, i) => i !== currentIndex)
+
+ // Shuffle other songs
+ for (let i = otherSongs.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1))
+ ;[otherSongs[i], otherSongs[j]] = [otherSongs[j], otherSongs[i]]
+ }
+
+ const shuffledPlaylist = [currentSongData, ...otherSongs]
+ setPlaylistState(shuffledPlaylist)
+ setCurrentIndex(0)
+ setIsShuffled(true)
+ } else {
+ // Disable shuffle - restore original order
+ const currentSongId = currentSong.id
+ const originalIndex = originalOrder.findIndex(s => s.id === currentSongId)
+ setPlaylistState(originalOrder)
+ setCurrentIndex(originalIndex)
+ setIsShuffled(false)
+ }
+ }
+
+ const setPlaylist = (songs: Song[]) => {
+ setPlaylistState(songs)
+ }
+
+ const clearPlayer = () => {
+ setCurrentSong(null)
+ setCurrentIndex(-1)
+ setPlaylistState([])
+ setIsPlaylist(false)
+ setIsShuffled(false)
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function usePlayer() {
+ const context = useContext(PlayerContext)
+ if (context === undefined) {
+ throw new Error('usePlayer must be used within a PlayerProvider')
+ }
+ return context
+}
diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx
new file mode 100644
index 0000000..2117c6d
--- /dev/null
+++ b/components/Sidebar.tsx
@@ -0,0 +1,32 @@
+'use client'
+
+import { Home, ListMusic } from 'lucide-react'
+import Link from 'next/link'
+
+export default function Sidebar() {
+ return (
+
+ )
+}
diff --git a/components/SongCard.tsx b/components/SongCard.tsx
new file mode 100644
index 0000000..3463ff6
--- /dev/null
+++ b/components/SongCard.tsx
@@ -0,0 +1,168 @@
+'use client'
+
+import { useState } from 'react'
+import Image from 'next/image'
+import { Play, Trash2, ListPlus } from 'lucide-react'
+
+interface Song {
+ id: string
+ youtubeId: string
+ title: string
+ channelName: string
+ thumbnail: string
+ duration: number
+}
+
+interface Playlist {
+ id: string
+ name: string
+}
+
+interface SongCardProps {
+ song: Song
+ onPlay: (song: Song) => void
+ onDelete: (songId: string) => void
+ playlists?: Playlist[]
+ onAddToPlaylist?: (songId: string, playlistId: string) => void
+}
+
+function formatDuration(seconds: number): string {
+ const mins = Math.floor(seconds / 60)
+ const secs = seconds % 60
+ return `${mins}:${secs.toString().padStart(2, '0')}`
+}
+
+export default function SongCard({ song, onPlay, onDelete, playlists, onAddToPlaylist }: SongCardProps) {
+ const [showPlaylistMenu, setShowPlaylistMenu] = useState(false)
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
+
+ const handleDelete = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ setShowDeleteConfirm(true)
+ }
+
+ const confirmDelete = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ onDelete(song.id)
+ setShowDeleteConfirm(false)
+ }
+
+ const cancelDelete = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ setShowDeleteConfirm(false)
+ }
+
+ const handleAddToPlaylist = (e: React.MouseEvent, playlistId: string) => {
+ e.stopPropagation()
+ if (onAddToPlaylist) {
+ onAddToPlaylist(song.id, playlistId)
+ }
+ setShowPlaylistMenu(false)
+ }
+
+ const togglePlaylistMenu = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ setShowPlaylistMenu(!showPlaylistMenu)
+ }
+
+ return (
+
+ {/* Delete confirmation modal */}
+ {showDeleteConfirm && (
+
+
e.stopPropagation()}
+ >
+
Delete song?
+
Are you sure you want to delete "{song.title}"?
+
+
+
+
+
+
+ )}
+
+
+ {playlists && playlists.length > 0 && onAddToPlaylist && (
+
+
+
+ {showPlaylistMenu && (
+
+ {playlists.map((playlist) => (
+
+ ))}
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ {song.title}
+
+
+
+
+ {song.channelName}
+
+
+ {formatDuration(song.duration)}
+
+
+
+ )
+}
diff --git a/components/Toast.tsx b/components/Toast.tsx
new file mode 100644
index 0000000..a338586
--- /dev/null
+++ b/components/Toast.tsx
@@ -0,0 +1,74 @@
+'use client'
+
+import { createContext, useContext, useState, ReactNode } from 'react'
+import { X, CheckCircle, AlertCircle, Info } from 'lucide-react'
+
+type ToastType = 'success' | 'error' | 'info'
+
+interface Toast {
+ id: number
+ message: string
+ type: ToastType
+}
+
+interface ToastContextType {
+ showToast: (message: string, type?: ToastType) => void
+}
+
+const ToastContext = createContext(undefined)
+
+export function ToastProvider({ children }: { children: ReactNode }) {
+ const [toasts, setToasts] = useState([])
+
+ const showToast = (message: string, type: ToastType = 'info') => {
+ const id = Date.now()
+ setToasts((prev) => [...prev, { id, message, type }])
+
+ setTimeout(() => {
+ setToasts((prev) => prev.filter((toast) => toast.id !== id))
+ }, 3000)
+ }
+
+ const removeToast = (id: number) => {
+ setToasts((prev) => prev.filter((toast) => toast.id !== id))
+ }
+
+ return (
+
+ {children}
+
+ {toasts.map((toast) => (
+
+ {toast.type === 'success' &&
}
+ {toast.type === 'error' &&
}
+ {toast.type === 'info' &&
}
+
{toast.message}
+
+
+ ))}
+
+
+ )
+}
+
+export function useToast() {
+ const context = useContext(ToastContext)
+ if (!context) {
+ throw new Error('useToast must be used within ToastProvider')
+ }
+ return context
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..522c622
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,23 @@
+version: '3.8'
+
+services:
+ bitify:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: bitify
+ ports:
+ - "3000:3000"
+ volumes:
+ # Persist database
+ - ./prisma:/app/prisma
+ # Optional: persist yt-dlp cache
+ - yt-dlp-cache:/home/nextjs/.cache/yt-dlp
+ environment:
+ - NODE_ENV=production
+ - DATABASE_URL=file:/app/prisma/dev.db
+ restart: unless-stopped
+ command: sh -c "npx prisma db push && node server.js"
+
+volumes:
+ yt-dlp-cache:
diff --git a/lib/cache.ts b/lib/cache.ts
new file mode 100644
index 0000000..2adcce7
--- /dev/null
+++ b/lib/cache.ts
@@ -0,0 +1,35 @@
+interface CacheEntry {
+ url: string
+ timestamp: number
+}
+
+class StreamCache {
+ private cache = new Map()
+ private cacheDuration = 3600000 // 1 hour in milliseconds
+
+ set(youtubeId: string, url: string) {
+ this.cache.set(youtubeId, {
+ url,
+ timestamp: Date.now(),
+ })
+ }
+
+ get(youtubeId: string): string | null {
+ const entry = this.cache.get(youtubeId)
+ if (!entry) return null
+
+ // Check if cache is still valid
+ if (Date.now() - entry.timestamp > this.cacheDuration) {
+ this.cache.delete(youtubeId)
+ return null
+ }
+
+ return entry.url
+ }
+
+ clear() {
+ this.cache.clear()
+ }
+}
+
+export const streamCache = new StreamCache()
diff --git a/lib/prisma.ts b/lib/prisma.ts
new file mode 100644
index 0000000..af2a01e
--- /dev/null
+++ b/lib/prisma.ts
@@ -0,0 +1,9 @@
+import { PrismaClient } from '@prisma/client'
+
+const globalForPrisma = globalThis as unknown as {
+ prisma: PrismaClient | undefined
+}
+
+export const prisma = globalForPrisma.prisma ?? new PrismaClient()
+
+if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
diff --git a/lib/ytdlp.ts b/lib/ytdlp.ts
new file mode 100644
index 0000000..f3bb4ec
--- /dev/null
+++ b/lib/ytdlp.ts
@@ -0,0 +1,148 @@
+import { spawn } from 'child_process'
+import { join } from 'path'
+
+// Detect yt-dlp command based on environment
+const getYtDlpCommand = () => {
+ // In development on Windows with venv
+ if (process.platform === 'win32' && process.env.NODE_ENV !== 'production') {
+ return join(process.cwd(), '.venv', 'Scripts', 'yt-dlp.exe')
+ }
+ // In production or Unix systems
+ return 'yt-dlp'
+}
+
+const YT_DLP = getYtDlpCommand()
+
+// Helper function to execute yt-dlp with arguments
+async function executeYtDlp(args: string[], timeout: number = 30000): Promise {
+ return new Promise((resolve, reject) => {
+ console.log('[yt-dlp] Executing:', YT_DLP, 'with args:', args)
+ const childProcess = spawn(YT_DLP, args)
+ let stdout = ''
+ let stderr = ''
+ let isResolved = false
+
+ // Set timeout
+ const timeoutId = setTimeout(() => {
+ if (!isResolved) {
+ console.error('[yt-dlp] Timeout reached, killing process')
+ childProcess.kill()
+ reject(new Error('Process timeout after ' + timeout + 'ms'))
+ }
+ }, timeout)
+
+ childProcess.stdout.on('data', (data) => {
+ stdout += data.toString()
+ })
+
+ childProcess.stderr.on('data', (data) => {
+ stderr += data.toString()
+ })
+
+ childProcess.on('close', (code) => {
+ if (isResolved) return
+ isResolved = true
+ clearTimeout(timeoutId)
+
+ console.log('[yt-dlp] Exit code:', code)
+ if (stderr) console.log('[yt-dlp] Stderr:', stderr)
+ if (code === 0) {
+ console.log('[yt-dlp] Success, output length:', stdout.length)
+ resolve(stdout)
+ } else {
+ console.error('[yt-dlp] Error:', stderr || `Process exited with code ${code}`)
+ reject(new Error(stderr || `Process exited with code ${code}`))
+ }
+ })
+
+ childProcess.on('error', (error) => {
+ if (isResolved) return
+ isResolved = true
+ clearTimeout(timeoutId)
+ console.error('[yt-dlp] Spawn error:', error)
+ reject(error)
+ })
+ })
+}
+
+export interface YtDlpMetadata {
+ id: string
+ title: string
+ channel: string
+ thumbnail: string
+ duration: number
+}
+
+export async function getYoutubeMetadata(url: string): Promise {
+ try {
+ console.log('[getYoutubeMetadata] Fetching metadata for URL:', url)
+ const stdout = await executeYtDlp([
+ '--dump-json',
+ '--no-warnings',
+ '--no-playlist', // Only extract the video, not the playlist
+ '--flat-playlist', // Don't extract playlist info
+ url
+ ], 60000) // 60 second timeout
+
+ const data = JSON.parse(stdout)
+ console.log('[getYoutubeMetadata] Successfully parsed metadata for:', data.title)
+ console.log('[getYoutubeMetadata] Available thumbnails:', data.thumbnails?.length || 0)
+
+ // Get best thumbnail URL - prefer high quality thumbnails
+ let thumbnail = ''
+ if (data.thumbnails && Array.isArray(data.thumbnails) && data.thumbnails.length > 0) {
+ // Find the highest quality thumbnail
+ const bestThumbnail = data.thumbnails
+ .filter((t: any) => t.url)
+ .sort((a: any, b: any) => {
+ const widthA = a.width || 0
+ const widthB = b.width || 0
+ return widthB - widthA
+ })[0]
+ thumbnail = bestThumbnail?.url || data.thumbnails[0]?.url || ''
+ }
+
+ // Fallback to standard YouTube thumbnail URLs
+ if (!thumbnail && data.id) {
+ thumbnail = `https://i.ytimg.com/vi/${data.id}/hqdefault.jpg`
+ }
+
+ console.log('[getYoutubeMetadata] Selected thumbnail URL:', thumbnail)
+
+ return {
+ id: data.id,
+ title: data.title,
+ channel: data.channel || data.uploader || 'Unknown',
+ thumbnail,
+ duration: data.duration || 0,
+ }
+ } catch (error) {
+ console.error('[getYoutubeMetadata] Error:', error)
+ throw new Error(`Failed to get metadata: ${error}`)
+ }
+}
+
+export async function getStreamUrl(youtubeId: string): Promise {
+ try {
+ const stdout = await executeYtDlp([
+ '-g',
+ '-f',
+ 'bestaudio',
+ `https://www.youtube.com/watch?v=${youtubeId}`
+ ])
+
+ return stdout.trim()
+ } catch (error) {
+ throw new Error(`Failed to get stream URL: ${error}`)
+ }
+}
+
+export async function updateYtDlp(): Promise {
+ try {
+ const stdout = await executeYtDlp(['-U'])
+ return stdout.trim()
+ } catch (error) {
+ throw new Error(`Failed to update yt-dlp: ${error}`)
+ }
+}
+
diff --git a/next.config.js b/next.config.js
new file mode 100644
index 0000000..69b84b3
--- /dev/null
+++ b/next.config.js
@@ -0,0 +1,31 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ images: {
+ remotePatterns: [
+ {
+ protocol: 'https',
+ hostname: 'i.ytimg.com',
+ },
+ {
+ protocol: 'https',
+ hostname: 'img.youtube.com',
+ },
+ {
+ protocol: 'https',
+ hostname: 'i9.ytimg.com',
+ },
+ {
+ protocol: 'https',
+ hostname: '**.ytimg.com',
+ },
+ ],
+ },
+ experimental: {
+ serverActions: {
+ bodySizeLimit: '2mb',
+ },
+ },
+ output: 'standalone',
+}
+
+module.exports = nextConfig
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..6c451ac
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1800 @@
+{
+ "name": "bitify",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "bitify",
+ "version": "1.0.0",
+ "dependencies": {
+ "@prisma/client": "^5.22.0",
+ "lucide-react": "^0.454.0",
+ "next": "^15.0.3",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4.0.0",
+ "@types/node": "^22.9.0",
+ "@types/react": "^18.3.12",
+ "@types/react-dom": "^18.3.1",
+ "postcss": "^8.4.49",
+ "prisma": "^5.22.0",
+ "tailwindcss": "^4.0.0",
+ "typescript": "^5.6.3"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
+ "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
+ "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@next/env": {
+ "version": "15.5.9",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz",
+ "integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==",
+ "license": "MIT"
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "15.5.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz",
+ "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "15.5.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz",
+ "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "15.5.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz",
+ "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "15.5.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz",
+ "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "15.5.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz",
+ "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "15.5.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz",
+ "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "15.5.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz",
+ "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "15.5.7",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz",
+ "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@prisma/client": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
+ "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=16.13"
+ },
+ "peerDependencies": {
+ "prisma": "*"
+ },
+ "peerDependenciesMeta": {
+ "prisma": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@prisma/debug": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
+ "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/engines": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
+ "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
+ "devOptional": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "5.22.0",
+ "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
+ "@prisma/fetch-engine": "5.22.0",
+ "@prisma/get-platform": "5.22.0"
+ }
+ },
+ "node_modules/@prisma/engines-version": {
+ "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
+ "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
+ "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/fetch-engine": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
+ "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "5.22.0",
+ "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
+ "@prisma/get-platform": "5.22.0"
+ }
+ },
+ "node_modules/@prisma/get-platform": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
+ "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "5.22.0"
+ }
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.15",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
+ "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
+ "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "enhanced-resolve": "^5.18.3",
+ "jiti": "^2.6.1",
+ "lightningcss": "1.30.2",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.1.18"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
+ "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.1.18",
+ "@tailwindcss/oxide-darwin-arm64": "4.1.18",
+ "@tailwindcss/oxide-darwin-x64": "4.1.18",
+ "@tailwindcss/oxide-freebsd-x64": "4.1.18",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
+ "@tailwindcss/oxide-linux-x64-musl": "4.1.18",
+ "@tailwindcss/oxide-wasm32-wasi": "4.1.18",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
+ "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
+ "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
+ "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
+ "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
+ "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
+ "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
+ "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
+ "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
+ "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
+ "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1",
+ "@emnapi/wasi-threads": "^1.1.0",
+ "@napi-rs/wasm-runtime": "^1.1.0",
+ "@tybys/wasm-util": "^0.10.1",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
+ "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
+ "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/postcss": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz",
+ "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "@tailwindcss/node": "4.1.18",
+ "@tailwindcss/oxide": "4.1.18",
+ "postcss": "^8.4.41",
+ "tailwindcss": "4.1.18"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "22.19.3",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
+ "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.27",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
+ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001760",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz",
+ "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "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==",
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.18.4",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
+ "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/jiti": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "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==",
+ "license": "MIT"
+ },
+ "node_modules/lightningcss": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
+ "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.30.2",
+ "lightningcss-darwin-arm64": "1.30.2",
+ "lightningcss-darwin-x64": "1.30.2",
+ "lightningcss-freebsd-x64": "1.30.2",
+ "lightningcss-linux-arm-gnueabihf": "1.30.2",
+ "lightningcss-linux-arm64-gnu": "1.30.2",
+ "lightningcss-linux-arm64-musl": "1.30.2",
+ "lightningcss-linux-x64-gnu": "1.30.2",
+ "lightningcss-linux-x64-musl": "1.30.2",
+ "lightningcss-win32-arm64-msvc": "1.30.2",
+ "lightningcss-win32-x64-msvc": "1.30.2"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
+ "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
+ "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
+ "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
+ "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
+ "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
+ "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
+ "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
+ "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
+ "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
+ "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
+ "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "0.454.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.454.0.tgz",
+ "integrity": "sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/next": {
+ "version": "15.5.9",
+ "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz",
+ "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "15.5.9",
+ "@swc/helpers": "0.5.15",
+ "caniuse-lite": "^1.0.30001579",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.6"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "15.5.7",
+ "@next/swc-darwin-x64": "15.5.7",
+ "@next/swc-linux-arm64-gnu": "15.5.7",
+ "@next/swc-linux-arm64-musl": "15.5.7",
+ "@next/swc-linux-x64-gnu": "15.5.7",
+ "@next/swc-linux-x64-musl": "15.5.7",
+ "@next/swc-win32-arm64-msvc": "15.5.7",
+ "@next/swc-win32-x64-msvc": "15.5.7",
+ "sharp": "^0.34.3"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0",
+ "@playwright/test": "^1.51.1",
+ "babel-plugin-react-compiler": "*",
+ "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "sass": "^1.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@playwright/test": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/next/node_modules/postcss": {
+ "version": "8.4.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prisma": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
+ "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
+ "devOptional": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/engines": "5.22.0"
+ },
+ "bin": {
+ "prisma": "build/index.js"
+ },
+ "engines": {
+ "node": ">=16.13"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.3"
+ }
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/styled-jsx": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
+ "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
+ "license": "MIT",
+ "dependencies": {
+ "client-only": "0.0.1"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
+ "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
+ "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..bd0602a
--- /dev/null
+++ b/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "bitify",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "prisma generate && next build",
+ "start": "next start",
+ "lint": "next lint",
+ "prisma:generate": "prisma generate",
+ "prisma:push": "prisma db push"
+ },
+ "dependencies": {
+ "@prisma/client": "^5.22.0",
+ "lucide-react": "^0.454.0",
+ "next": "^15.0.3",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4.0.0",
+ "@types/node": "^22.9.0",
+ "@types/react": "^18.3.12",
+ "@types/react-dom": "^18.3.1",
+ "postcss": "^8.4.49",
+ "prisma": "^5.22.0",
+ "tailwindcss": "^4.0.0",
+ "typescript": "^5.6.3"
+ }
+}
diff --git a/postcss.config.mjs b/postcss.config.mjs
new file mode 100644
index 0000000..61e3684
--- /dev/null
+++ b/postcss.config.mjs
@@ -0,0 +1,7 @@
+const config = {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};
+
+export default config;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
new file mode 100644
index 0000000..9b90de9
--- /dev/null
+++ b/prisma/schema.prisma
@@ -0,0 +1,44 @@
+// This is your Prisma schema file,
+// learn more about it in the docs: https://pris.ly/d/prisma-schema
+
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "sqlite"
+ url = "file:./dev.db"
+}
+
+model Song {
+ id String @id @default(uuid())
+ youtubeId String @unique
+ title String
+ channelName String
+ thumbnail String
+ duration Int // in seconds
+ addedAt DateTime @default(now())
+ isInLibrary Boolean @default(true) // If false, only exists in playlists
+
+ playlistSongs PlaylistSong[]
+}
+
+model Playlist {
+ id String @id @default(uuid())
+ name String
+ createdAt DateTime @default(now())
+
+ playlistSongs PlaylistSong[]
+}
+
+model PlaylistSong {
+ id String @id @default(uuid())
+ playlistId String
+ songId String
+ addedAt DateTime @default(now())
+
+ playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
+ song Song @relation(fields: [songId], references: [id], onDelete: Cascade)
+
+ @@unique([playlistId, songId])
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..d8b9323
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}