diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f650315
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,27 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
\ No newline at end of file
diff --git a/app/about/page.tsx b/app/about/page.tsx
new file mode 100644
index 0000000..eb086f2
--- /dev/null
+++ b/app/about/page.tsx
@@ -0,0 +1,199 @@
+"use client"
+
+import { Github, Music, Heart, Code } from "lucide-react"
+import { Sidebar } from "@/components/app/sidebar"
+import { PlayerBar } from "@/components/app/player-bar"
+import { MobileNav } from "@/components/app/mobile-nav"
+import { Button } from "@/components/ui/button"
+
+export default function AboutPage() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
qstream
+
High-quality music streaming
+
+
+
+
+
+
+ About qstream
+
+
+ qstream is a modern music streaming application built with Next.js and React, designed to provide a
+ seamless and elegant music listening experience. With support for high-quality audio streaming,
+ qstream delivers crystal-clear sound for your favorite tracks.
+
+
+ The application features a clean, intuitive interface that makes discovering and playing music
+ effortless. Search for your favorite artists and albums, explore their discographies, and enjoy
+ uninterrupted playback with our advanced audio player.
+
+
+
+
+
+
+
+ Features
+
+
+
+
+ ✓
+
+
+ High-Quality Audio Streaming - Experience your music
+ in FLAC quality with seamless playback
+
+
+
+
+ ✓
+
+
+ Advanced Search - Quickly find albums, artists, and
+ tracks with our powerful search engine
+
+
+
+
+ ✓
+
+
+ Persistent Playback - Audio continues seamlessly as
+ you navigate between pages
+
+
+
+
+ ✓
+
+
+ Queue Management - Build and manage your playback
+ queue with repeat and shuffle options
+
+
+
+
+ ✓
+
+
+ Customizable Themes - Choose from multiple color
+ themes to personalize your experience
+
+
+
+
+ ✓
+
+
+ Responsive Design - Optimized for desktop, tablet,
+ and mobile devices
+
+
+
+
+
+
+ Technology Stack
+
+
+
Frontend
+
+ • Next.js 15 (App Router)
+ • React 19
+ • TypeScript
+ • Tailwind CSS v4
+
+
+
+
Features
+
+ • Zustand (State Management)
+ • SWR (Data Fetching)
+ • shadcn/ui Components
+ • Web Audio API
+
+
+
+
+
+
+
+
+ Open Source
+
+
+
+ qstream is an open-source project built with modern web technologies. We believe in transparency and
+ community-driven development. Check out the source code, contribute, or report issues on GitHub.
+
+
+
+
+
+
+
+
+
+
+
+
Built with passion
+
+ qstream was created to demonstrate modern web development practices and provide a beautiful,
+ functional music streaming experience. We hope you enjoy using it as much as we enjoyed building
+ it.
+
+
+
+
+
+
+ © 2025 qstream. No rights reserved.
+
+ Music provided by qobuz-dl. This is a demonstration project for educational purposes.
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/album/[id]/page.tsx b/app/album/[id]/page.tsx
new file mode 100644
index 0000000..f7687cb
--- /dev/null
+++ b/app/album/[id]/page.tsx
@@ -0,0 +1,137 @@
+"use client"
+
+import { useEffect } from "react"
+import { useParams, useRouter } from "next/navigation"
+import { ArrowLeft, Play } from "lucide-react"
+import { useSettingsStore } from "@/store/settings-store"
+import { usePlayerStore } from "@/store/player-store"
+import { Sidebar } from "@/components/app/sidebar"
+import { PlayerBar } from "@/components/app/player-bar"
+import { MobileNav } from "@/components/app/mobile-nav"
+import { TrackList } from "@/components/app/track-list"
+import { LoadingSpinner } from "@/components/app/loading-spinner"
+import { EmptyState } from "@/components/app/empty-state"
+import { Button } from "@/components/ui/button"
+import { useAlbum } from "@/hooks/use-album"
+import { albumToPlayerTracks } from "@/lib/api"
+import { formatDate } from "@/lib/utils"
+
+export default function AlbumPage() {
+ const params = useParams()
+ const router = useRouter()
+ const theme = useSettingsStore((state) => state.theme)
+ const { loadAndPlayQueue } = usePlayerStore((state) => state.actions)
+
+ const albumId = params.id as string
+ const { album, isLoading, error } = useAlbum(albumId)
+
+ useEffect(() => {
+ if (theme !== "custom") {
+ document.documentElement.setAttribute("data-theme", theme)
+ } else {
+ document.documentElement.removeAttribute("data-theme")
+ }
+ document.documentElement.classList.add("dark")
+ }, [theme])
+
+ const handlePlayAlbum = () => {
+ if (album) {
+ const tracks = albumToPlayerTracks(album)
+ loadAndPlayQueue(tracks, 0)
+ }
+ }
+
+ const handleArtistClick = () => {
+ if (album) {
+ router.push(`/artist/${album.artist.id}`)
+ }
+ }
+
+ if (isLoading) {
+ return (
+
+ )
+ }
+
+ if (error || !album) {
+ return (
+
+ )
+ }
+
+ const tracks = albumToPlayerTracks(album)
+
+ return (
+
+
+
+
+
+
+
router.back()} className="mb-6">
+
+ Back
+
+
+
+
+
+
+
+
+
+
Album
+
{album.title}
+
+
+
+
+ {album.artist.name}
+
+ •
+ {formatDate(album.release_date_original)}
+ •
+ {album.tracks.items.length} tracks
+
+
+
+
+ Play Album
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/artist/[id]/page.tsx b/app/artist/[id]/page.tsx
new file mode 100644
index 0000000..71c1b9b
--- /dev/null
+++ b/app/artist/[id]/page.tsx
@@ -0,0 +1,186 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import { useParams, useRouter } from "next/navigation"
+import { ArrowLeft, Play, ChevronDown, ChevronUp } from "lucide-react"
+import { useSettingsStore } from "@/store/settings-store"
+import { usePlayerStore } from "@/store/player-store"
+import { Sidebar } from "@/components/app/sidebar"
+import { PlayerBar } from "@/components/app/player-bar"
+import { MobileNav } from "@/components/app/mobile-nav"
+import { TrackList } from "@/components/app/track-list"
+import { AlbumCard } from "@/components/app/album-card"
+import { LoadingSpinner } from "@/components/app/loading-spinner"
+import { EmptyState } from "@/components/app/empty-state"
+import { Button } from "@/components/ui/button"
+import { useArtist } from "@/hooks/use-artist"
+import { topTracksToPlayerTracks } from "@/lib/api"
+
+export default function ArtistPage() {
+ const params = useParams()
+ const router = useRouter()
+ const theme = useSettingsStore((state) => state.theme)
+ const { loadAndPlayQueue } = usePlayerStore((state) => state.actions)
+ const [isBioExpanded, setIsBioExpanded] = useState(false)
+
+ const artistId = Number.parseInt(params.id as string)
+ const { artist, isLoading, error } = useArtist(artistId)
+
+ useEffect(() => {
+ if (theme !== "custom") {
+ document.documentElement.setAttribute("data-theme", theme)
+ } else {
+ document.documentElement.removeAttribute("data-theme")
+ }
+ document.documentElement.classList.add("dark")
+ }, [theme])
+
+ const handlePlayTopTracks = () => {
+ if (artist && artist.top_tracks.length > 0) {
+ const tracks = topTracksToPlayerTracks(artist.top_tracks, artist.name.display)
+ loadAndPlayQueue(tracks, 0)
+ }
+ }
+
+ const handleAlbumClick = (albumId: string) => {
+ router.push(`/album/${albumId}`)
+ }
+
+ if (isLoading) {
+ return (
+
+ )
+ }
+
+ if (error || !artist) {
+ return (
+
+ )
+ }
+
+ const topTracks = topTracksToPlayerTracks(artist.top_tracks, artist.name.display)
+
+ const bioText = artist.biography?.content || ""
+ const bioLines = bioText.split("\n").filter((line) => line.trim())
+ const shouldTruncate = bioLines.length > 3
+ const displayBio = shouldTruncate && !isBioExpanded ? bioLines.slice(0, 3).join("\n") : bioText
+
+ return (
+
+
+
+
+
+
+
router.back()} className="mb-6">
+
+ Back
+
+
+
+
Artist
+
{artist.name.display}
+
+ {bioText && (
+
+
+
+ {displayBio}
+
+ {shouldTruncate && !isBioExpanded && (
+
+ )}
+
+ {shouldTruncate && (
+
setIsBioExpanded(!isBioExpanded)} className="mt-2">
+ {isBioExpanded ? (
+ <>
+
+ Show less
+ >
+ ) : (
+ <>
+
+ Read more
+ >
+ )}
+
+ )}
+
+ )}
+
+
+ {topTracks.length > 0 && (
+
+
+
Top Tracks
+
+
+ Play All
+
+
+
+
+
+
+ )}
+
+ {artist.releases.map((release) => {
+ if (release.items.length === 0) return null
+
+ return (
+
+
+ {release.type}s {release.has_more && (Top) }
+
+
+ {release.items.map((item) => (
+
handleAlbumClick(item.id)}
+ />
+ ))}
+
+
+ )
+ })}
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/globals.css b/app/globals.css
new file mode 100644
index 0000000..222593e
--- /dev/null
+++ b/app/globals.css
@@ -0,0 +1,177 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@custom-variant dark (&:is(.dark *));
+
+:root {
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --destructive-foreground: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --radius: 0.625rem;
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.145 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.145 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.985 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.396 0.141 25.723);
+ --destructive-foreground: oklch(0.637 0.237 25.331);
+ --border: oklch(0.269 0 0);
+ --input: oklch(0.269 0 0);
+ --ring: oklch(0.439 0 0);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(0.269 0 0);
+ --sidebar-ring: oklch(0.439 0 0);
+}
+
+[data-theme="zinc"] {
+ --background: 240 10% 3.9%;
+ --foreground: 0 0% 98%;
+ --card: 240 10% 5%;
+ --card-foreground: 0 0% 98%;
+ --primary: 0 0% 98%;
+ --primary-foreground: 240 10% 3.9%;
+ --secondary: 240 3.7% 15.9%;
+ --secondary-foreground: 0 0% 98%;
+ --muted: 240 3.7% 15.9%;
+ --muted-foreground: 240 5% 64.9%;
+ --accent: 240 3.7% 15.9%;
+ --accent-foreground: 0 0% 98%;
+ --border: 240 3.7% 15.9%;
+ --input: 240 3.7% 15.9%;
+ --ring: 240 5% 64.9%;
+}
+
+[data-theme="slate"] {
+ --background: 222.2 84% 4.9%;
+ --foreground: 210 40% 98%;
+ --card: 222.2 84% 6%;
+ --card-foreground: 210 40% 98%;
+ --primary: 210 40% 98%;
+ --primary-foreground: 222.2 47.4% 11.2%;
+ --secondary: 217.2 32.6% 17.5%;
+ --secondary-foreground: 210 40% 98%;
+ --muted: 217.2 32.6% 17.5%;
+ --muted-foreground: 215 20.2% 65.1%;
+ --accent: 217.2 32.6% 17.5%;
+ --accent-foreground: 210 40% 98%;
+ --border: 217.2 32.6% 17.5%;
+ --input: 217.2 32.6% 17.5%;
+ --ring: 215 20.2% 65.1%;
+}
+
+[data-theme="rose"] {
+ --background: 0 0% 3.9%;
+ --foreground: 0 0% 98%;
+ --card: 0 0% 5%;
+ --card-foreground: 0 0% 98%;
+ --primary: 346.8 77.2% 49.8%;
+ --primary-foreground: 355.7 100% 97.3%;
+ --secondary: 240 3.7% 15.9%;
+ --secondary-foreground: 0 0% 98%;
+ --muted: 0 0% 15%;
+ --muted-foreground: 240 5% 64.9%;
+ --accent: 346.8 77.2% 49.8%;
+ --accent-foreground: 355.7 100% 97.3%;
+ --border: 240 3.7% 15.9%;
+ --input: 240 3.7% 15.9%;
+ --ring: 346.8 77.2% 49.8%;
+}
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/app/layout.tsx b/app/layout.tsx
new file mode 100644
index 0000000..6e0a29d
--- /dev/null
+++ b/app/layout.tsx
@@ -0,0 +1,35 @@
+import type React from "react"
+import type { Metadata } from "next"
+import { Inter, Playfair_Display } from "next/font/google"
+import { Providers } from "@/components/app/providers"
+import "./globals.css"
+
+const inter = Inter({
+ subsets: ["latin"],
+ variable: "--font-inter",
+})
+
+const playfair = Playfair_Display({
+ subsets: ["latin"],
+ variable: "--font-playfair",
+})
+
+export const metadata: Metadata = {
+ title: "qstream - Music Player",
+ description: "A modern, cross-platform music streaming player",
+ generator: 'v0.app'
+}
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return (
+
+
+ {children}
+
+
+ )
+}
diff --git a/app/loading.tsx b/app/loading.tsx
new file mode 100644
index 0000000..f15322a
--- /dev/null
+++ b/app/loading.tsx
@@ -0,0 +1,3 @@
+export default function Loading() {
+ return null
+}
diff --git a/app/page.tsx b/app/page.tsx
new file mode 100644
index 0000000..022b278
--- /dev/null
+++ b/app/page.tsx
@@ -0,0 +1,91 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import { useRouter } from "next/navigation"
+import { SearchIcon } from "lucide-react"
+import { Sidebar } from "@/components/app/sidebar"
+import { PlayerBar } from "@/components/app/player-bar"
+import { MobileNav } from "@/components/app/mobile-nav"
+import { AlbumCard } from "@/components/app/album-card"
+import { LoadingSpinner } from "@/components/app/loading-spinner"
+import { EmptyState } from "@/components/app/empty-state"
+import { Input } from "@/components/ui/input"
+import { useSearch } from "@/hooks/use-search"
+
+export default function Home() {
+ const router = useRouter()
+ const [query, setQuery] = useState("")
+ const [debouncedQuery, setDebouncedQuery] = useState("")
+
+ const { albums, isLoading, error } = useSearch(debouncedQuery)
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setDebouncedQuery(query)
+ }, 500)
+
+ return () => clearTimeout(timer)
+ }, [query])
+
+ const handleAlbumClick = (albumId: string) => {
+ router.push(`/album/${albumId}`)
+ }
+
+ return (
+
+
+
+
+
+
+
+
Search
+
+
+ setQuery(e.target.value)}
+ className="pl-10"
+ autoFocus
+ />
+
+
+
+ {isLoading &&
}
+
+ {error && (
+
+ )}
+
+ {!isLoading && !error && albums && albums.length === 0 && debouncedQuery && (
+
+ )}
+
+ {!isLoading && !error && albums && albums.length > 0 && (
+
+
Albums
+
+ {albums.map((album) => (
+
handleAlbumClick(album.id)} />
+ ))}
+
+
+ )}
+
+ {!debouncedQuery && !isLoading && (
+
+ )}
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/search/loading.tsx b/app/search/loading.tsx
new file mode 100644
index 0000000..f15322a
--- /dev/null
+++ b/app/search/loading.tsx
@@ -0,0 +1,3 @@
+export default function Loading() {
+ return null
+}
diff --git a/app/settings/page.tsx b/app/settings/page.tsx
new file mode 100644
index 0000000..384709b
--- /dev/null
+++ b/app/settings/page.tsx
@@ -0,0 +1,180 @@
+"use client"
+
+import { useState } from "react"
+import { useRouter } from "next/navigation"
+import { ArrowLeft, Check } from "lucide-react"
+import { useSettingsStore } from "@/store/settings-store"
+import { Sidebar } from "@/components/app/sidebar"
+import { PlayerBar } from "@/components/app/player-bar"
+import { MobileNav } from "@/components/app/mobile-nav"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import type { Theme } from "@/lib/types"
+
+export default function SettingsPage() {
+ const router = useRouter()
+ const theme = useSettingsStore((state) => state.theme)
+ const apiBaseUrl = useSettingsStore((state) => state.apiBaseUrl)
+ const setTheme = useSettingsStore((state) => state.actions.setTheme)
+ const setStoredApiBaseUrl = useSettingsStore((state) => state.actions.setApiBaseUrl)
+
+ const [localApiUrl, setLocalApiUrl] = useState(apiBaseUrl)
+ const [isSaved, setIsSaved] = useState(false)
+
+ const handleThemeChange = (newTheme: Theme) => {
+ setTheme(newTheme)
+ }
+
+ const handleSaveApiUrl = () => {
+ setStoredApiBaseUrl(localApiUrl)
+ setIsSaved(true)
+ setTimeout(() => setIsSaved(false), 2000)
+ }
+
+ const themes: Array<{ value: Theme; label: string; description: string }> = [
+ { value: "default", label: "Default", description: "Classic dark theme" },
+ { value: "zinc", label: "Zinc", description: "Neutral gray tones" },
+ { value: "slate", label: "Slate", description: "Cool blue-gray" },
+ { value: "rose", label: "Rose", description: "Warm rose accents" },
+ ]
+
+ return (
+
+
+
+
+
+
+
router.back()} className="mb-6">
+
+ Back
+
+
+
Settings
+
+
+
+
+
API Configuration
+
Configure the music API endpoint
+
+
+
+
API Base URL
+
+ setLocalApiUrl(e.target.value)}
+ placeholder="https://qqdl.site/api"
+ className="flex-1"
+ />
+
+ {isSaved ? (
+ <>
+
+ Saved
+ >
+ ) : (
+ "Save"
+ )}
+
+
+
+ Default: https://qqdl.site/api | EU: https://eu.qqdl.site/api
+
+
+
+
+
+
+
Appearance
+
Customize the look and feel of qstream
+
+
+
+
Theme
+
+ {themes.map((themeOption) => (
+
handleThemeChange(themeOption.value)}
+ className={`flex flex-col gap-2 rounded-lg border-2 p-4 text-left transition-colors ${
+ theme === themeOption.value
+ ? "border-primary bg-accent"
+ : "border-border hover:border-muted-foreground"
+ }`}
+ >
+
+ {themeOption.label}
+ {theme === themeOption.value && }
+
+ {themeOption.description}
+
+
+ {themeOption.value === "default" && (
+ <>
+
+
+
+ >
+ )}
+ {themeOption.value === "zinc" && (
+ <>
+
+
+
+ >
+ )}
+ {themeOption.value === "slate" && (
+ <>
+
+
+
+ >
+ )}
+ {themeOption.value === "rose" && (
+ <>
+
+
+
+ >
+ )}
+
+
+ ))}
+
+
+
+
+
+
+
About
+
Information about qstream
+
+
+
+
+
+ Version
+ 1.0.0
+
+
+ Platform
+ Web
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/components.json b/components.json
new file mode 100644
index 0000000..4ee62ee
--- /dev/null
+++ b/components.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "app/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "iconLibrary": "lucide"
+}
diff --git a/components/app/album-card.tsx b/components/app/album-card.tsx
new file mode 100644
index 0000000..dbaf1ef
--- /dev/null
+++ b/components/app/album-card.tsx
@@ -0,0 +1,35 @@
+"use client"
+
+import Image from "next/image"
+import type { Album } from "@/lib/types"
+import { formatDate } from "@/lib/utils"
+
+interface AlbumCardProps {
+ album: Album
+ onClick: () => void
+}
+
+export const AlbumCard = ({ album, onClick }: AlbumCardProps) => {
+ return (
+
+
+
+
+
+
+
{album.title}
+
{album.artist.name}
+
{formatDate(album.release_date_original)}
+
+
+ )
+}
diff --git a/components/app/empty-state.tsx b/components/app/empty-state.tsx
new file mode 100644
index 0000000..d162248
--- /dev/null
+++ b/components/app/empty-state.tsx
@@ -0,0 +1,16 @@
+import { Music } from "lucide-react"
+
+interface EmptyStateProps {
+ title: string
+ description: string
+}
+
+export const EmptyState = ({ title, description }: EmptyStateProps) => {
+ return (
+
+
+
{title}
+
{description}
+
+ )
+}
diff --git a/components/app/loading-spinner.tsx b/components/app/loading-spinner.tsx
new file mode 100644
index 0000000..577e488
--- /dev/null
+++ b/components/app/loading-spinner.tsx
@@ -0,0 +1,7 @@
+export const LoadingSpinner = () => {
+ return (
+
+ )
+}
diff --git a/components/app/mobile-nav.tsx b/components/app/mobile-nav.tsx
new file mode 100644
index 0000000..e36b404
--- /dev/null
+++ b/components/app/mobile-nav.tsx
@@ -0,0 +1,39 @@
+"use client"
+
+import { Search, Settings, Info } from "lucide-react"
+import { useRouter, usePathname } from "next/navigation"
+
+export const MobileNav = () => {
+ const router = useRouter()
+ const pathname = usePathname()
+
+ const navItems = [
+ { icon: Search, label: "Search", path: "/" },
+ { icon: Info, label: "About", path: "/about" },
+ { icon: Settings, label: "Settings", path: "/settings" },
+ ]
+
+ return (
+
+
+ {navItems.map((item) => {
+ const Icon = item.icon
+ const isActive = pathname === item.path
+
+ return (
+ router.push(item.path)}
+ className={`flex flex-col items-center gap-1 px-4 py-2 ${
+ isActive ? "text-primary" : "text-muted-foreground"
+ }`}
+ >
+
+ {item.label}
+
+ )
+ })}
+
+
+ )
+}
diff --git a/components/app/player-bar.tsx b/components/app/player-bar.tsx
new file mode 100644
index 0000000..0ec31ba
--- /dev/null
+++ b/components/app/player-bar.tsx
@@ -0,0 +1,183 @@
+"use client"
+
+import { useState } from "react"
+import {
+ Play,
+ Pause,
+ SkipBack,
+ SkipForward,
+ Shuffle,
+ Repeat,
+ Repeat1,
+ Volume2,
+ VolumeX,
+ ListMusic,
+ Download,
+} from "lucide-react"
+import { usePlayerStore } from "@/store/player-store"
+import { formatDuration } from "@/lib/utils"
+import { Slider } from "@/components/ui/slider"
+import { useAudioPlayer } from "@/hooks/use-audio-player"
+import { QueuePanel } from "./queue-panel"
+
+export const PlayerBar = () => {
+ useAudioPlayer()
+ const [isQueueOpen, setIsQueueOpen] = useState(false)
+
+ const isPlaying = usePlayerStore((state) => state.isPlaying)
+ const currentTrack = usePlayerStore((state) => state.currentTrack)
+ const playbackPosition = usePlayerStore((state) => state.playbackPosition)
+ const volume = usePlayerStore((state) => state.volume)
+ const repeatMode = usePlayerStore((state) => state.repeatMode)
+ const isShuffled = usePlayerStore((state) => state.isShuffled)
+ const streamUrl = usePlayerStore((state) => state.streamUrl)
+
+ const { playPause, nextTrack, prevTrack, seekTo, setVolume, toggleShuffle, cycleRepeatMode } = usePlayerStore(
+ (state) => state.actions,
+ )
+
+ const handleSeek = (value: number[]) => {
+ seekTo(value[0])
+ }
+
+ const handleVolumeChange = (value: number[]) => {
+ setVolume(value[0])
+ }
+
+ const handleDownload = () => {
+ if (streamUrl && currentTrack) {
+ const link = document.createElement("a")
+ link.href = streamUrl
+ link.download = `${currentTrack.artistName} - ${currentTrack.title}.flac`
+ link.click()
+ }
+ }
+
+ const RepeatIcon = repeatMode === "one" ? Repeat1 : Repeat
+
+ return (
+ <>
+
+
+
+ {currentTrack ? (
+ <>
+
+
+
+
+
{currentTrack.title}
+
{currentTrack.artistName}
+
+ >
+ ) : (
+ <>
+
+
+
No track playing
+
qstream
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isPlaying ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ {formatDuration(playbackPosition)}
+
+
+ {currentTrack ? formatDuration(currentTrack.duration) : "0:00"}
+
+
+
+
+
+
+
+
+
+
setIsQueueOpen(!isQueueOpen)}
+ className="text-muted-foreground transition-colors hover:text-foreground"
+ >
+
+
+
+
+ {volume === 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ setIsQueueOpen(false)} />
+ >
+ )
+}
diff --git a/components/app/providers.tsx b/components/app/providers.tsx
new file mode 100644
index 0000000..2afb3e1
--- /dev/null
+++ b/components/app/providers.tsx
@@ -0,0 +1,29 @@
+"use client"
+
+import type React from "react"
+
+import { useEffect } from "react"
+import { useSettingsStore } from "@/store/settings-store"
+import { setApiBaseUrl } from "@/lib/api"
+
+export function Providers({ children }: { children: React.ReactNode }) {
+ const theme = useSettingsStore((state) => state.theme)
+ const apiBaseUrl = useSettingsStore((state) => state.apiBaseUrl)
+
+ useEffect(() => {
+ setApiBaseUrl(apiBaseUrl)
+ console.log("[v0] Initialized API base URL:", apiBaseUrl)
+ }, [apiBaseUrl])
+
+ useEffect(() => {
+ document.documentElement.classList.add("dark")
+
+ if (theme !== "custom") {
+ document.documentElement.setAttribute("data-theme", theme)
+ } else {
+ document.documentElement.removeAttribute("data-theme")
+ }
+ }, [theme])
+
+ return <>{children}>
+}
diff --git a/components/app/queue-panel.tsx b/components/app/queue-panel.tsx
new file mode 100644
index 0000000..ffc2354
--- /dev/null
+++ b/components/app/queue-panel.tsx
@@ -0,0 +1,81 @@
+"use client"
+
+import { X } from "lucide-react"
+import { usePlayerStore } from "@/store/player-store"
+import { formatDuration } from "@/lib/utils"
+
+interface QueuePanelProps {
+ isOpen: boolean
+ onClose: () => void
+}
+
+export const QueuePanel = ({ isOpen, onClose }: QueuePanelProps) => {
+ const queue = usePlayerStore((state) => state.queue)
+ const currentTrack = usePlayerStore((state) => state.currentTrack)
+ const currentIndex = usePlayerStore((state) => state.currentIndex)
+ const { loadAndPlayQueue } = usePlayerStore((state) => state.actions)
+
+ if (!isOpen) return null
+
+ const handleTrackClick = (index: number) => {
+ loadAndPlayQueue(queue, index)
+ }
+
+ return (
+
+
+
+
Queue
+
+
+
+
+
+
+ {queue.length === 0 ? (
+
+ ) : (
+
+ {queue.map((track, index) => {
+ const isCurrentTrack = index === currentIndex
+
+ return (
+
handleTrackClick(index)}
+ className={`flex items-center gap-3 p-3 transition-colors hover:bg-accent ${
+ isCurrentTrack ? "bg-accent" : ""
+ }`}
+ >
+
+
+
+
+
+
+ {track.title}
+
+ {track.artistName}
+
+
+ {formatDuration(track.duration)}
+
+ )
+ })}
+
+ )}
+
+
+
+ )
+}
diff --git a/components/app/sidebar.tsx b/components/app/sidebar.tsx
new file mode 100644
index 0000000..113db11
--- /dev/null
+++ b/components/app/sidebar.tsx
@@ -0,0 +1,45 @@
+"use client"
+
+import { Search, Settings, Info } from "lucide-react"
+import { useRouter, usePathname } from "next/navigation"
+
+export const Sidebar = () => {
+ const router = useRouter()
+ const pathname = usePathname()
+
+ const navItems = [
+ { icon: Search, label: "Search", path: "/" },
+ { icon: Info, label: "About", path: "/about" },
+ { icon: Settings, label: "Settings", path: "/settings" },
+ ]
+
+ return (
+
+
+
qstream
+
+
+
+ {navItems.map((item) => {
+ const Icon = item.icon
+ const isActive = pathname === item.path
+
+ return (
+ router.push(item.path)}
+ className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
+ isActive
+ ? "bg-accent text-accent-foreground"
+ : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
+ }`}
+ >
+
+ {item.label}
+
+ )
+ })}
+
+
+ )
+}
diff --git a/components/app/track-list.tsx b/components/app/track-list.tsx
new file mode 100644
index 0000000..76efbce
--- /dev/null
+++ b/components/app/track-list.tsx
@@ -0,0 +1,74 @@
+"use client"
+
+import { Play } from "lucide-react"
+import type { PlayerTrack } from "@/lib/types"
+import { formatDuration } from "@/lib/utils"
+import { usePlayerStore } from "@/store/player-store"
+
+interface TrackListProps {
+ tracks: PlayerTrack[]
+ showArtwork?: boolean
+}
+
+export const TrackList = ({ tracks, showArtwork = false }: TrackListProps) => {
+ const currentTrack = usePlayerStore((state) => state.currentTrack)
+ const isPlaying = usePlayerStore((state) => state.isPlaying)
+ const { loadAndPlayQueue } = usePlayerStore((state) => state.actions)
+
+ const handleTrackClick = (index: number) => {
+ loadAndPlayQueue(tracks, index)
+ }
+
+ return (
+
+ {tracks.map((track, index) => {
+ const isCurrentTrack = currentTrack?.id === track.id
+ const isActive = isCurrentTrack && isPlaying
+
+ return (
+
handleTrackClick(index)}
+ className="group flex items-center gap-4 rounded-md px-4 py-3 transition-colors hover:bg-accent"
+ >
+
+ {isActive ? (
+
+ ) : (
+
{index + 1}
+ )}
+
+
+
+ {showArtwork && (
+
+
+
+ )}
+
+
+
+ {track.title}
+
+ {track.artistName}
+
+
+ {!showArtwork && {track.albumTitle}
}
+
+ {formatDuration(track.duration)}
+
+ )
+ })}
+
+ )
+}
diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx
new file mode 100644
index 0000000..55c2f6e
--- /dev/null
+++ b/components/theme-provider.tsx
@@ -0,0 +1,11 @@
+'use client'
+
+import * as React from 'react'
+import {
+ ThemeProvider as NextThemesProvider,
+ type ThemeProviderProps,
+} from 'next-themes'
+
+export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
+ return {children}
+}
diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx
new file mode 100644
index 0000000..e538a33
--- /dev/null
+++ b/components/ui/accordion.tsx
@@ -0,0 +1,66 @@
+'use client'
+
+import * as React from 'react'
+import * as AccordionPrimitive from '@radix-ui/react-accordion'
+import { ChevronDownIcon } from 'lucide-react'
+
+import { cn } from '@/lib/utils'
+
+function Accordion({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function AccordionItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AccordionTrigger({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ svg]:rotate-180',
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+ )
+}
+
+function AccordionContent({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..9704452
--- /dev/null
+++ b/components/ui/alert-dialog.tsx
@@ -0,0 +1,157 @@
+'use client'
+
+import * as React from 'react'
+import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
+
+import { cn } from '@/lib/utils'
+import { buttonVariants } from '@/components/ui/button'
+
+function AlertDialog({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function AlertDialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ )
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogCancel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+}
diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx
new file mode 100644
index 0000000..e6751ab
--- /dev/null
+++ b/components/ui/alert.tsx
@@ -0,0 +1,66 @@
+import * as React from 'react'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+
+const alertVariants = cva(
+ 'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
+ {
+ variants: {
+ variant: {
+ default: 'bg-card text-card-foreground',
+ destructive:
+ 'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+)
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<'div'> & VariantProps) {
+ return (
+
+ )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/components/ui/aspect-ratio.tsx b/components/ui/aspect-ratio.tsx
new file mode 100644
index 0000000..40bb120
--- /dev/null
+++ b/components/ui/aspect-ratio.tsx
@@ -0,0 +1,11 @@
+'use client'
+
+import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio'
+
+function AspectRatio({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+export { AspectRatio }
diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx
new file mode 100644
index 0000000..aa98465
--- /dev/null
+++ b/components/ui/avatar.tsx
@@ -0,0 +1,53 @@
+'use client'
+
+import * as React from 'react'
+import * as AvatarPrimitive from '@radix-ui/react-avatar'
+
+import { cn } from '@/lib/utils'
+
+function Avatar({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarImage({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx
new file mode 100644
index 0000000..fc4126b
--- /dev/null
+++ b/components/ui/badge.tsx
@@ -0,0 +1,46 @@
+import * as React from 'react'
+import { Slot } from '@radix-ui/react-slot'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+
+const badgeVariants = cva(
+ 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
+ {
+ variants: {
+ variant: {
+ default:
+ 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
+ secondary:
+ 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
+ destructive:
+ 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
+ outline:
+ 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+)
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'span'> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : 'span'
+
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000..1750ff2
--- /dev/null
+++ b/components/ui/breadcrumb.tsx
@@ -0,0 +1,109 @@
+import * as React from 'react'
+import { Slot } from '@radix-ui/react-slot'
+import { ChevronRight, MoreHorizontal } from 'lucide-react'
+
+import { cn } from '@/lib/utils'
+
+function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
+ return
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
+ return (
+
+ )
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
+ return (
+
+ )
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<'a'> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot : 'a'
+
+ return (
+
+ )
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
+ return (
+
+ )
+}
+
+function BreadcrumbSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<'li'>) {
+ return (
+ svg]:size-3.5', className)}
+ {...props}
+ >
+ {children ?? }
+
+ )
+}
+
+function BreadcrumbEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<'span'>) {
+ return (
+
+
+ More
+
+ )
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+}
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
new file mode 100644
index 0000000..815443b
--- /dev/null
+++ b/components/ui/button.tsx
@@ -0,0 +1,59 @@
+import * as React from 'react'
+import { Slot } from '@radix-ui/react-slot'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default:
+ 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
+ destructive:
+ 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
+ outline:
+ 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
+ secondary:
+ 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
+ ghost:
+ 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ size: {
+ default: 'h-9 px-4 py-2 has-[>svg]:px-3',
+ sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
+ lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
+ icon: 'size-9',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ },
+)
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'button'> &
+ VariantProps & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot : 'button'
+
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/components/ui/calendar.tsx b/components/ui/calendar.tsx
new file mode 100644
index 0000000..eaa373e
--- /dev/null
+++ b/components/ui/calendar.tsx
@@ -0,0 +1,213 @@
+'use client'
+
+import * as React from 'react'
+import {
+ ChevronDownIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+} from 'lucide-react'
+import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'
+
+import { cn } from '@/lib/utils'
+import { Button, buttonVariants } from '@/components/ui/button'
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = 'label',
+ buttonVariant = 'ghost',
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps & {
+ buttonVariant?: React.ComponentProps['variant']
+}) {
+ const defaultClassNames = getDefaultClassNames()
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className,
+ )}
+ captionLayout={captionLayout}
+ formatters={{
+ formatMonthDropdown: (date) =>
+ date.toLocaleString('default', { month: 'short' }),
+ ...formatters,
+ }}
+ classNames={{
+ root: cn('w-fit', defaultClassNames.root),
+ months: cn(
+ 'flex gap-4 flex-col md:flex-row relative',
+ defaultClassNames.months,
+ ),
+ month: cn('flex flex-col w-full gap-4', defaultClassNames.month),
+ nav: cn(
+ 'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
+ defaultClassNames.nav,
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ 'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
+ defaultClassNames.button_previous,
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ 'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
+ defaultClassNames.button_next,
+ ),
+ month_caption: cn(
+ 'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)',
+ defaultClassNames.month_caption,
+ ),
+ dropdowns: cn(
+ 'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5',
+ defaultClassNames.dropdowns,
+ ),
+ dropdown_root: cn(
+ 'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md',
+ defaultClassNames.dropdown_root,
+ ),
+ dropdown: cn(
+ 'absolute bg-popover inset-0 opacity-0',
+ defaultClassNames.dropdown,
+ ),
+ caption_label: cn(
+ 'select-none font-medium',
+ captionLayout === 'label'
+ ? 'text-sm'
+ : 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
+ defaultClassNames.caption_label,
+ ),
+ table: 'w-full border-collapse',
+ weekdays: cn('flex', defaultClassNames.weekdays),
+ weekday: cn(
+ 'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
+ defaultClassNames.weekday,
+ ),
+ week: cn('flex w-full mt-2', defaultClassNames.week),
+ week_number_header: cn(
+ 'select-none w-(--cell-size)',
+ defaultClassNames.week_number_header,
+ ),
+ week_number: cn(
+ 'text-[0.8rem] select-none text-muted-foreground',
+ defaultClassNames.week_number,
+ ),
+ day: cn(
+ 'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none',
+ defaultClassNames.day,
+ ),
+ range_start: cn(
+ 'rounded-l-md bg-accent',
+ defaultClassNames.range_start,
+ ),
+ range_middle: cn('rounded-none', defaultClassNames.range_middle),
+ range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
+ today: cn(
+ 'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
+ defaultClassNames.today,
+ ),
+ outside: cn(
+ 'text-muted-foreground aria-selected:text-muted-foreground',
+ defaultClassNames.outside,
+ ),
+ disabled: cn(
+ 'text-muted-foreground opacity-50',
+ defaultClassNames.disabled,
+ ),
+ hidden: cn('invisible', defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => {
+ return (
+
+ )
+ },
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === 'left') {
+ return (
+
+ )
+ }
+
+ if (orientation === 'right') {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+ },
+ DayButton: CalendarDayButton,
+ WeekNumber: ({ children, ...props }) => {
+ return (
+
+
+ {children}
+
+
+ )
+ },
+ ...components,
+ }}
+ {...props}
+ />
+ )
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ ...props
+}: React.ComponentProps) {
+ const defaultClassNames = getDefaultClassNames()
+
+ const ref = React.useRef(null)
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus()
+ }, [modifiers.focused])
+
+ return (
+ span]:text-xs [&>span]:opacity-70',
+ defaultClassNames.day,
+ className,
+ )}
+ {...props}
+ />
+ )
+}
+
+export { Calendar, CalendarDayButton }
diff --git a/components/ui/card.tsx b/components/ui/card.tsx
new file mode 100644
index 0000000..b1cee30
--- /dev/null
+++ b/components/ui/card.tsx
@@ -0,0 +1,92 @@
+import * as React from 'react'
+
+import { cn } from '@/lib/utils'
+
+function Card({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/components/ui/carousel.tsx b/components/ui/carousel.tsx
new file mode 100644
index 0000000..d4a768e
--- /dev/null
+++ b/components/ui/carousel.tsx
@@ -0,0 +1,241 @@
+'use client'
+
+import * as React from 'react'
+import useEmblaCarousel, {
+ type UseEmblaCarouselType,
+} from 'embla-carousel-react'
+import { ArrowLeft, ArrowRight } from 'lucide-react'
+
+import { cn } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
+
+type CarouselApi = UseEmblaCarouselType[1]
+type UseCarouselParameters = Parameters
+type CarouselOptions = UseCarouselParameters[0]
+type CarouselPlugin = UseCarouselParameters[1]
+
+type CarouselProps = {
+ opts?: CarouselOptions
+ plugins?: CarouselPlugin
+ orientation?: 'horizontal' | 'vertical'
+ setApi?: (api: CarouselApi) => void
+}
+
+type CarouselContextProps = {
+ carouselRef: ReturnType[0]
+ api: ReturnType[1]
+ scrollPrev: () => void
+ scrollNext: () => void
+ canScrollPrev: boolean
+ canScrollNext: boolean
+} & CarouselProps
+
+const CarouselContext = React.createContext(null)
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext)
+
+ if (!context) {
+ throw new Error('useCarousel must be used within a ')
+ }
+
+ return context
+}
+
+function Carousel({
+ orientation = 'horizontal',
+ opts,
+ setApi,
+ plugins,
+ className,
+ children,
+ ...props
+}: React.ComponentProps<'div'> & CarouselProps) {
+ const [carouselRef, api] = useEmblaCarousel(
+ {
+ ...opts,
+ axis: orientation === 'horizontal' ? 'x' : 'y',
+ },
+ plugins,
+ )
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false)
+ const [canScrollNext, setCanScrollNext] = React.useState(false)
+
+ const onSelect = React.useCallback((api: CarouselApi) => {
+ if (!api) return
+ setCanScrollPrev(api.canScrollPrev())
+ setCanScrollNext(api.canScrollNext())
+ }, [])
+
+ const scrollPrev = React.useCallback(() => {
+ api?.scrollPrev()
+ }, [api])
+
+ const scrollNext = React.useCallback(() => {
+ api?.scrollNext()
+ }, [api])
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === 'ArrowLeft') {
+ event.preventDefault()
+ scrollPrev()
+ } else if (event.key === 'ArrowRight') {
+ event.preventDefault()
+ scrollNext()
+ }
+ },
+ [scrollPrev, scrollNext],
+ )
+
+ React.useEffect(() => {
+ if (!api || !setApi) return
+ setApi(api)
+ }, [api, setApi])
+
+ React.useEffect(() => {
+ if (!api) return
+ onSelect(api)
+ api.on('reInit', onSelect)
+ api.on('select', onSelect)
+
+ return () => {
+ api?.off('select', onSelect)
+ }
+ }, [api, onSelect])
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+function CarouselContent({ className, ...props }: React.ComponentProps<'div'>) {
+ const { carouselRef, orientation } = useCarousel()
+
+ return (
+
+ )
+}
+
+function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) {
+ const { orientation } = useCarousel()
+
+ return (
+
+ )
+}
+
+function CarouselPrevious({
+ className,
+ variant = 'outline',
+ size = 'icon',
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel()
+
+ return (
+
+
+ Previous slide
+
+ )
+}
+
+function CarouselNext({
+ className,
+ variant = 'outline',
+ size = 'icon',
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollNext, canScrollNext } = useCarousel()
+
+ return (
+
+
+ Next slide
+
+ )
+}
+
+export {
+ type CarouselApi,
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselPrevious,
+ CarouselNext,
+}
diff --git a/components/ui/chart.tsx b/components/ui/chart.tsx
new file mode 100644
index 0000000..421fe58
--- /dev/null
+++ b/components/ui/chart.tsx
@@ -0,0 +1,353 @@
+'use client'
+
+import * as React from 'react'
+import * as RechartsPrimitive from 'recharts'
+
+import { cn } from '@/lib/utils'
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: '', dark: '.dark' } as const
+
+export type ChartConfig = {
+ [k in string]: {
+ label?: React.ReactNode
+ icon?: React.ComponentType
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record }
+ )
+}
+
+type ChartContextProps = {
+ config: ChartConfig
+}
+
+const ChartContext = React.createContext(null)
+
+function useChart() {
+ const context = React.useContext(ChartContext)
+
+ if (!context) {
+ throw new Error('useChart must be used within a ')
+ }
+
+ return context
+}
+
+function ChartContainer({
+ id,
+ className,
+ children,
+ config,
+ ...props
+}: React.ComponentProps<'div'> & {
+ config: ChartConfig
+ children: React.ComponentProps<
+ typeof RechartsPrimitive.ResponsiveContainer
+ >['children']
+}) {
+ const uniqueId = React.useId()
+ const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ )
+}
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(
+ ([, config]) => config.theme || config.color,
+ )
+
+ if (!colorConfig.length) {
+ return null
+ }
+
+ return (
+