Merge pull request #6 from sillyangel/development

Development -> Main
This commit is contained in:
2025-06-19 23:13:51 -05:00
committed by GitHub
17 changed files with 276 additions and 425 deletions

View File

@@ -2,7 +2,8 @@
NEXT_PUBLIC_NAVIDROME_URL=http://localhost:4533
NEXT_PUBLIC_NAVIDROME_USERNAME=your_username
NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password
NEXT_PUBLIC_POSTHOG_KEY=KEY
NEXT_PUBLIC_POSTHOG_HOST=HOSTURL
# Example for external server:
# NEXT_PUBLIC_NAVIDROME_URL=https://your-navidrome-server.com
# NEXT_PUBLIC_NAVIDROME_USERNAME=your_username

View File

@@ -1,34 +0,0 @@
name: Run Jest Tests
on:
push:
branches:
- main
paths:
- 'apps/data/albums.ts'
- 'apps/data/artists.ts'
pull_request:
branches:
- main
paths:
- 'apps/data/albums.ts'
- 'apps/data/artists.ts'
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm install
- name: Run Jest tests
run: npm test

View File

@@ -1,145 +0,0 @@
# Migration Summary: Firebase → Navidrome/Subsonic
## ✅ Completed Migration Tasks
### 🗑️ Removed Legacy Systems
- [x] **Firebase Dependencies**: Removed firebase, react-firebase-hooks packages
- [x] **Static Data Files**: Moved `app/data/` (albums.ts, artists.ts, playlists.ts) to backup
- [x] **Firebase Config**: Moved `app/firebase/` directory to backup
- [x] **Authentication System**: Removed Firebase Auth integration
- [x] **Database Connections**: Removed Firestore database calls
### 🚀 Implemented Navidrome Integration
- [x] **Navidrome API Client** (`lib/navidrome.ts`)
- Subsonic API authentication with token-based security
- All major endpoints: ping, getArtists, getAlbums, getAlbum, search3, etc.
- Stream URL generation for audio playback
- Cover art URL generation with size parameters
- Star/unstar functionality for favorites
- Scrobbling support for play tracking
- [x] **React Context Provider** (`app/components/NavidromeContext.tsx`)
- Global state management for music library data
- Loading states for UI feedback
- Error handling and connection testing
- Data fetching with automatic refresh
- CRUD operations for playlists
### 🎵 Updated Audio System
- [x] **AudioPlayerContext** - Completely rewritten for Navidrome
- Real audio streaming instead of static file URLs
- Queue management with Navidrome song objects
- Automatic scrobbling when tracks play
- Track conversion from Navidrome Song to playable Track format
- [x] **AudioPlayer Component**
- Updated to handle Navidrome streaming URLs
- Dynamic cover art from Navidrome getCoverArt API
- Proper track metadata display (artist, album, duration)
### 🎨 Updated UI Components
- [x] **AlbumArtwork Component**
- Uses Navidrome Album interface
- Dynamic cover art with getCoverArt API
- Context menu integration with Navidrome playlists
- Proper album metadata display (year, genre, song count)
- [x] **ArtistIcon Component**
- Uses Navidrome Artist interface
- Artist cover art support
- Album count display
- Star/unstar functionality in context menu
### 📄 Updated Pages
- [x] **Main Page** (`app/page.tsx`)
- Uses NavidromeContext for album data
- Loading states with skeleton UI
- Error handling for connection issues
- Recent and newest album sections
- [x] **Album Detail Page** (`app/album/[id]/page.tsx`)
- Fetches album and songs from Navidrome
- Real-time song playback with streaming
- Star/unstar album functionality
- Proper track listing with metadata
- [x] **Artist Page** (`app/artist/[artist]/page.tsx`)
- Artist details from Navidrome API
- Dynamic album grid for artist
- Star/unstar artist functionality
- Modern gradient header design
- [x] **Library Pages**
- `app/library/albums/page.tsx` - Shows all albums in grid layout
- `app/library/artists/page.tsx` - Shows all artists in grid layout
- `app/library/playlists/page.tsx` - Playlist management with CRUD operations
### 🔧 Configuration & Documentation
- [x] **Environment Configuration**
- `.env.example` with Navidrome connection settings
- Removed Firebase environment variables from package.json
- [x] **Documentation**
- `NAVIDROME_MIGRATION.md` - Detailed migration guide
- Updated `README.md` with new setup instructions
- Feature documentation and troubleshooting
- [x] **Type Safety**
- TypeScript interfaces matching Subsonic API responses
- Proper error handling throughout the application
- Type-safe component props and context values
### 🧪 Testing
- [x] **Test Suite** (`__tests__/navidrome.test.ts`)
- API client functionality tests
- TypeScript interface validation
- URL generation testing
- Configuration validation
## 🎯 Key Benefits Achieved
### **Real Music Streaming**
- Replaced static MP3 URLs with dynamic Navidrome streaming
- Support for multiple audio formats and bitrates
- Proper audio metadata from music files
### **Dynamic Library**
- No more manual JSON file management
- Auto-discovery of new music added to Navidrome
- Real-time library updates
### **Enhanced Features**
- Scrobbling for play tracking
- Star/favorite functionality
- Playlist management (create, edit, delete)
- Search across entire music library
- High-quality album artwork
### **Better Architecture**
- Removed Firebase dependency completely
- Self-hosted music solution
- Standards-based Subsonic API integration
- Type-safe development with proper interfaces
## 🔄 Migration Path
1. **Backup**: Old Firebase and static data moved to `-old` directories
2. **Dependencies**: Firebase packages removed, crypto built-in used
3. **Environment**: New `.env.local` needed with Navidrome credentials
4. **Data Flow**: `Static JSON → Firebase → Navidrome API`
5. **Authentication**: `Firebase Auth → Navidrome Server Authentication`
6. **Streaming**: `Static Files → Navidrome Stream API`
## 🚦 Ready for Production
The application is now fully migrated and ready for use with any Navidrome server. All core functionality has been preserved and enhanced:
- ✅ Browse music library (albums, artists, songs)
- ✅ Audio playback with queue management
- ✅ Search functionality
- ✅ Playlist management
- ✅ Favorites/starring
- ✅ Responsive design
- ✅ Error handling and loading states
**Next Steps**: Set up Navidrome server and configure connection in `.env.local`

View File

@@ -10,9 +10,10 @@ import { Progress } from '@/components/ui/progress';
import { useToast } from '@/hooks/use-toast';
export const AudioPlayer: React.FC = () => {
const { currentTrack, playPreviousTrack, addToQueue, playNextTrack, clearQueue } = useAudioPlayer();
const { currentTrack, playPreviousTrack, addToQueue, playNextTrack, clearQueue, queue } = useAudioPlayer();
const router = useRouter();
const audioRef = useRef<HTMLAudioElement>(null);
const preloadAudioRef = useRef<HTMLAudioElement>(null);
const [progress, setProgress] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [showVolumeSlider, setShowVolumeSlider] = useState(false);
@@ -31,6 +32,19 @@ export const AudioPlayer: React.FC = () => {
useEffect(() => {
setIsClient(true);
// Load saved volume
const savedVolume = localStorage.getItem('navidrome-volume');
if (savedVolume) {
try {
const volumeValue = parseFloat(savedVolume);
if (volumeValue >= 0 && volumeValue <= 1) {
setVolume(volumeValue);
}
} catch (error) {
console.error('Failed to parse saved volume:', error);
}
}
// Clean up old localStorage entries with track IDs
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
@@ -42,6 +56,16 @@ export const AudioPlayer: React.FC = () => {
keysToRemove.forEach(key => localStorage.removeItem(key));
}, []);
// Apply volume to audio element when volume changes
useEffect(() => {
const audioCurrent = audioRef.current;
if (audioCurrent) {
audioCurrent.volume = volume;
}
// Save volume to localStorage
localStorage.setItem('navidrome-volume', volume.toString());
}, [volume]);
// Save position when component unmounts or track changes
useEffect(() => {
const audioCurrent = audioRef.current;
@@ -248,9 +272,6 @@ export const AudioPlayer: React.FC = () => {
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newVolume = parseFloat(e.target.value);
setVolume(newVolume);
if (audioCurrent) {
audioCurrent.volume = newVolume;
}
};
function formatTime(seconds: number): string {
@@ -304,6 +325,7 @@ export const AudioPlayer: React.FC = () => {
</div>
</div>
<audio ref={audioRef} hidden />
<audio ref={preloadAudioRef} hidden preload="metadata" />
</div>
);
}
@@ -357,6 +379,7 @@ export const AudioPlayer: React.FC = () => {
</div>
</div>
<audio ref={audioRef} hidden />
<audio ref={preloadAudioRef} hidden preload="metadata" />
{/* Full Screen Player */}
<FullScreenPlayer

View File

@@ -69,7 +69,10 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
const savedCurrentTrack = localStorage.getItem('navidrome-currentTrack');
if (savedCurrentTrack) {
try {
setCurrentTrack(JSON.parse(savedCurrentTrack));
const track = JSON.parse(savedCurrentTrack);
// Clear autoPlay flag when loading from localStorage to prevent auto-play on refresh
track.autoPlay = false;
setCurrentTrack(track);
} catch (error) {
console.error('Failed to parse saved current track:', error);
}
@@ -78,7 +81,9 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
useEffect(() => {
if (currentTrack) {
localStorage.setItem('navidrome-currentTrack', JSON.stringify(currentTrack));
// Remove autoPlay flag when saving to localStorage
const { autoPlay, ...trackToSave } = currentTrack;
localStorage.setItem('navidrome-currentTrack', JSON.stringify(trackToSave));
} else {
localStorage.removeItem('navidrome-currentTrack');
}
@@ -282,11 +287,11 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
}
// Play the first shuffled track and set the rest as queue
playTrack(shuffledTracks[0]);
playTrack(shuffledTracks[0], true); // Enable autoplay
setQueue(shuffledTracks.slice(1));
} else {
// Normal order: play first track and set the rest as queue
playTrack(tracks[0]);
playTrack(tracks[0], true); // Enable autoplay
setQueue(tracks.slice(1));
}
}
@@ -332,11 +337,11 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
}
setQueue(remainingTracks);
playTrack(tracks[startingIndex]);
playTrack(tracks[startingIndex], true); // Enable autoplay
} else {
// Normal order: set the remaining tracks after the starting track as queue
setQueue(tracks.slice(startingIndex + 1));
playTrack(tracks[startingIndex]);
playTrack(tracks[startingIndex], true); // Enable autoplay
}
toast({
@@ -360,8 +365,8 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
const targetTrack = queue[index];
// Remove all tracks before the target track (including the target track)
setQueue((prevQueue) => prevQueue.slice(index + 1));
// Play the target track
playTrack(targetTrack);
// Play the target track with autoplay enabled
playTrack(targetTrack, true);
}
}, [queue, playTrack]);
@@ -445,11 +450,11 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
}
// Play the first shuffled track and set the rest as queue
playTrack(shuffledTracks[0]);
playTrack(shuffledTracks[0], true); // Enable autoplay
setQueue(shuffledTracks.slice(1));
} else {
// Normal order: play first track and set the rest as queue
playTrack(allTracks[0]);
playTrack(allTracks[0], true); // Enable autoplay
setQueue(allTracks.slice(1));
}
}

View File

@@ -0,0 +1,8 @@
'use client';
import { useViewportThemeColor } from '@/hooks/use-viewport-theme-color';
export default function DynamicViewportTheme() {
useViewportThemeColor();
return null;
}

View File

@@ -5,6 +5,7 @@ import Image from 'next/image';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { Progress } from '@/components/ui/progress';
import { lrcLibClient } from '@/lib/lrclib';
import Link from 'next/link';
import {
FaPlay,
FaPause,
@@ -333,19 +334,15 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
<h1 className="text-lg sm:text-xl lg:text-3xl font-bold text-foreground mb-2 line-clamp-2 leading-tight">
{currentTrack.name}
</h1>
<p className="text-base sm:text-lg lg:text-xl text-foreground/80 mb-1 line-clamp-1">{currentTrack.artist}</p>
<Link href={`/album/${currentTrack.artistId}`} className="text-base sm:text-lg lg:text-xl text-foreground/80 mb-1 line-clamp-1">
{currentTrack.artist}
</Link>
</div>
{/* Progress */}
<div className="w-full max-w-sm lg:max-w-md mb-4 lg:mb-6 px-4 flex-shrink-0">
<div
className="h-2 bg-white/20 rounded-full cursor-pointer relative overflow-hidden"
onClick={handleSeek}
>
<div
className="h-full bg-foreground transition-all duration-150"
style={{ width: `${progress}%` }}
/>
<div className="w-full" onClick={handleSeek}>
<Progress value={progress} className="h-2 cursor-pointer" />
</div>
<div className="flex justify-between text-sm text-foreground/60 mt-2">
<span>{formatTime(currentTime)}</span>

View File

@@ -0,0 +1,50 @@
"use client"
import posthog from "posthog-js"
import { PostHogProvider as PHProvider, usePostHog } from "posthog-js/react"
import { Suspense, useEffect } from "react"
import { usePathname, useSearchParams } from "next/navigation"
function PathnameTracker() {
const posthogClient = usePostHog()
const pathname = usePathname()
const searchParams = useSearchParams()
useEffect(() => {
if (posthogClient) {
posthogClient.capture('$pageview', {
path: pathname + (searchParams.toString() ? `?${searchParams.toString()}` : ''),
})
}
}, [posthogClient, pathname, searchParams])
return null
}
function SuspendedPostHogPageView() {
return (
<Suspense fallback={null}>
<PathnameTracker />
</Suspense>
)
}
export function PostHogProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
api_host: "/ingest",
ui_host: "https://us.posthog.com",
capture_pageview: 'history_change',
capture_pageleave: true,
capture_exceptions: true,
debug: process.env.NODE_ENV === "development",
})
}, [])
return (
<PHProvider client={posthog}>
<SuspendedPostHogPageView />
{children}
</PHProvider>
)
}

View File

@@ -1,19 +1,14 @@
import { SpeedInsights } from "@vercel/speed-insights/next";
import React from 'react';
import { Analytics } from "@vercel/analytics/react";
import localFont from "next/font/local";
import "./globals.css";
import { AudioPlayerProvider } from "./components/AudioPlayerContext";
import { NavidromeProvider } from "./components/NavidromeContext";
import { NavidromeConfigProvider } from "./components/NavidromeConfigContext";
import { ThemeProvider } from "./components/ThemeProvider";
import { PostHogProvider } from "./components/PostHogProvider";
import { Metadata } from "next";
import type { Viewport } from 'next';
import Ihateserverside from './components/ihateserverside';
export const viewport: Viewport = {
themeColor: 'black',
};
import DynamicViewportTheme from './components/DynamicViewportTheme';
export const metadata: Metadata = {
title: {
@@ -71,25 +66,37 @@ export default function Layout({ children }: LayoutProps) {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
}
// Set initial theme color based on theme
const themeColors = {
blue: '#0f0f23',
violet: '#0c0a2e'
};
const metaThemeColor = document.createElement('meta');
metaThemeColor.name = 'theme-color';
metaThemeColor.content = themeColors[theme];
document.head.appendChild(metaThemeColor);
})();
`,
}}
/>
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background`}>
<ThemeProvider>
<NavidromeConfigProvider>
<NavidromeProvider>
<AudioPlayerProvider>
<SpeedInsights />
<Analytics />
<Ihateserverside>
{children}
</Ihateserverside>
</AudioPlayerProvider>
</NavidromeProvider>
</NavidromeConfigProvider>
</ThemeProvider>
<PostHogProvider>
<ThemeProvider>
<DynamicViewportTheme />
<NavidromeConfigProvider>
<NavidromeProvider>
<AudioPlayerProvider>
<Ihateserverside>
{children}
</Ihateserverside>
</AudioPlayerProvider>
</NavidromeProvider>
</NavidromeConfigProvider>
</ThemeProvider>
</PostHogProvider>
</body>
</html>
);

View File

@@ -0,0 +1,29 @@
'use client';
import { useEffect } from 'react';
import { useTheme } from '@/app/components/ThemeProvider';
import { getThemeBackgroundColor } from '@/lib/theme-colors';
export function useViewportThemeColor() {
const { theme } = useTheme();
useEffect(() => {
// Update the theme-color meta tag dynamically
const updateThemeColor = () => {
const themeColor = getThemeBackgroundColor(theme);
// Find existing theme-color meta tag or create one
let metaThemeColor = document.querySelector('meta[name="theme-color"]') as HTMLMetaElement;
if (!metaThemeColor) {
metaThemeColor = document.createElement('meta');
metaThemeColor.name = 'theme-color';
document.head.appendChild(metaThemeColor);
}
metaThemeColor.content = themeColor;
};
updateThemeColor();
}, [theme]);
}

View File

@@ -1,7 +0,0 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
};

11
lib/posthog.ts Normal file
View File

@@ -0,0 +1,11 @@
import { PostHog } from "posthog-node"
export default function PostHogClient() {
const posthogClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
capture_pageview: 'history_change',
flushAt: 1,
flushInterval: 0,
})
return posthogClient
}

29
lib/theme-colors.ts Normal file
View File

@@ -0,0 +1,29 @@
// Theme color utilities for dynamic viewport theme color
export const themeColors = {
blue: {
background: 'hsl(240, 10%, 3.9%)', // Dark blue background
hex: '#09090b' // Hex equivalent for theme-color
},
violet: {
background: 'hsl(224, 71.4%, 4.1%)', // Dark violet background
hex: '#030712' // Hex equivalent for theme-color
}
} as const;
export type Theme = keyof typeof themeColors;
export function getThemeBackgroundColor(theme: Theme): string {
return themeColors[theme].hex;
}
export function hslToHex(h: number, s: number, l: number): string {
l /= 100;
const a = s * Math.min(l, 1 - l) / 100;
const f = (n: number) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color).toString(16).padStart(2, '0');
};
return `#${f(0)}${f(8)}${f(4)}`;
}

View File

@@ -46,6 +46,24 @@ const nextConfig = {
},
];
},
async rewrites() {
return [
{
source: '/ingest/static/:path*',
destination: 'https://us-assets.i.posthog.com/static/:path*',
},
{
source: '/ingest/:path*',
destination: 'https://us.i.posthog.com/:path*',
},
{
source: '/ingest/decide',
destination: 'https://us.i.posthog.com/decide',
},
];
},
// This is required to support PostHog trailing slash API requests
skipTrailingSlashRedirect: true,
};
export default nextConfig;
export default nextConfig;

View File

@@ -6,8 +6,7 @@
"dev": "next dev -p 40625",
"build": "next build",
"start": "next start -p 40625",
"lint": "next lint",
"test": "jest"
"lint": "next lint"
},
"dependencies": {
"@hookform/resolvers": "^3.9.1",
@@ -25,8 +24,6 @@
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.4",
"@vercel/analytics": "^1.4.1",
"@vercel/speed-insights": "^1.1.0",
"axios": "^1.7.7",
"chalk": "^5.3.0",
"class-variance-authority": "^0.7.0",
@@ -34,6 +31,8 @@
"colorthief": "^2.6.0",
"lucide-react": "^0.469.0",
"next": "^15.0.3",
"posthog-js": "^1.255.0",
"posthog-node": "^5.1.1",
"react": "^19",
"react-dom": "^19",
"react-hook-form": "^7.53.2",
@@ -49,7 +48,6 @@
"@types/react-dom": "^19.0.2",
"eslint": "^9.17",
"eslint-config-next": "15.1.4",
"jest": "^29.7.0",
"postcss": "^8",
"tailwindcss": "^3.4.15",
"ts-jest": "^29.2.5",

118
pnpm-lock.yaml generated
View File

@@ -53,12 +53,6 @@ importers:
'@radix-ui/react-toast':
specifier: ^1.2.4
version: 1.2.4(@types/react-dom@19.0.2(@types/react@19.0.4))(@types/react@19.0.4)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@vercel/analytics':
specifier: ^1.4.1
version: 1.4.1(next@15.1.4(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
'@vercel/speed-insights':
specifier: ^1.1.0
version: 1.1.0(next@15.1.4(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
axios:
specifier: ^1.7.7
version: 1.7.9
@@ -80,6 +74,12 @@ importers:
next:
specifier: ^15.0.3
version: 15.1.4(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
posthog-js:
specifier: ^1.255.0
version: 1.255.0
posthog-node:
specifier: ^5.1.1
version: 5.1.1
react:
specifier: ^19
version: 19.0.0
@@ -120,9 +120,6 @@ importers:
eslint-config-next:
specifier: 15.1.4
version: 15.1.4(eslint@9.17.0(jiti@1.21.7))(typescript@5.7.3)
jest:
specifier: ^29.7.0
version: 29.7.0(@types/node@22.10.5)
postcss:
specifier: ^8
version: 8.4.49
@@ -1212,55 +1209,6 @@ packages:
resolution: {integrity: sha512-fzmjU8CHK853V/avYZAvuVut3ZTfwN5YtMaoi+X9Y9MA9keaWNHC3zEQ9zvyX/7Hj+5JkNyK1l7TOR2hevHB6Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vercel/analytics@1.4.1':
resolution: {integrity: sha512-ekpL4ReX2TH3LnrRZTUKjHHNpNy9S1I7QmS+g/RQXoSUQ8ienzosuX7T9djZ/s8zPhBx1mpHP/Rw5875N+zQIQ==}
peerDependencies:
'@remix-run/react': ^2
'@sveltejs/kit': ^1 || ^2
next: '>= 13'
react: ^18 || ^19 || ^19.0.0-rc
svelte: '>= 4'
vue: ^3
vue-router: ^4
peerDependenciesMeta:
'@remix-run/react':
optional: true
'@sveltejs/kit':
optional: true
next:
optional: true
react:
optional: true
svelte:
optional: true
vue:
optional: true
vue-router:
optional: true
'@vercel/speed-insights@1.1.0':
resolution: {integrity: sha512-rAXxuhhO4mlRGC9noa5F7HLMtGg8YF1zAN6Pjd1Ny4pII4cerhtwSG4vympbCl+pWkH7nBS9kVXRD4FAn54dlg==}
peerDependencies:
'@sveltejs/kit': ^1 || ^2
next: '>= 13'
react: ^18 || ^19 || ^19.0.0-rc
svelte: '>= 4'
vue: ^3
vue-router: ^4
peerDependenciesMeta:
'@sveltejs/kit':
optional: true
next:
optional: true
react:
optional: true
svelte:
optional: true
vue:
optional: true
vue-router:
optional: true
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -1545,6 +1493,9 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
core-js@3.43.0:
resolution: {integrity: sha512-N6wEbTTZSYOY2rYAn85CuvWWkCK6QweMn7/4Nr3w+gDBeBhk/x4EJeY6FPo4QzDoJZxVTv8U7CMvgWk6pOHHqA==}
create-jest@29.7.0:
resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -1880,6 +1831,9 @@ packages:
fb-watchman@2.0.2:
resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==}
fflate@0.4.8:
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
@@ -2773,6 +2727,24 @@ packages:
resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==}
engines: {node: ^10 || ^12 || >=14}
posthog-js@1.255.0:
resolution: {integrity: sha512-2ZZKrGB1Ih425IoPvmiDYN+BcDJvNJvVGRrey2ARR4UJ85oB+sNCJAx6DuwIlvsIQTe8QjuUhxrHlxAT5/7IMA==}
peerDependencies:
'@rrweb/types': 2.0.0-alpha.17
rrweb-snapshot: 2.0.0-alpha.17
peerDependenciesMeta:
'@rrweb/types':
optional: true
rrweb-snapshot:
optional: true
posthog-node@5.1.1:
resolution: {integrity: sha512-6VISkNdxO24ehXiDA4dugyCSIV7lpGVaEu5kn/dlAj+SJ1lgcDru9PQ8p/+GSXsXVxohd1t7kHL2JKc9NoGb0w==}
engines: {node: '>=20'}
preact@10.26.9:
resolution: {integrity: sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA==}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -3284,6 +3256,9 @@ packages:
walker@1.0.8:
resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==}
web-vitals@4.2.4:
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -4532,16 +4507,6 @@ snapshots:
'@typescript-eslint/types': 8.19.1
eslint-visitor-keys: 4.2.0
'@vercel/analytics@1.4.1(next@15.1.4(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)':
optionalDependencies:
next: 15.1.4(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react: 19.0.0
'@vercel/speed-insights@1.1.0(next@15.1.4(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)':
optionalDependencies:
next: 15.1.4(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react: 19.0.0
acorn-jsx@5.3.2(acorn@8.14.0):
dependencies:
acorn: 8.14.0
@@ -4874,6 +4839,8 @@ snapshots:
convert-source-map@2.0.0: {}
core-js@3.43.0: {}
create-jest@29.7.0(@types/node@22.10.5):
dependencies:
'@jest/types': 29.6.3
@@ -5351,6 +5318,8 @@ snapshots:
dependencies:
bser: 2.1.1
fflate@0.4.8: {}
file-entry-cache@8.0.0:
dependencies:
flat-cache: 4.0.1
@@ -6418,6 +6387,17 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
posthog-js@1.255.0:
dependencies:
core-js: 3.43.0
fflate: 0.4.8
preact: 10.26.9
web-vitals: 4.2.4
posthog-node@5.1.1: {}
preact@10.26.9: {}
prelude-ls@1.2.1: {}
pretty-format@29.7.0:
@@ -7005,6 +6985,8 @@ snapshots:
dependencies:
makeerror: 1.0.12
web-vitals@4.2.4: {}
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0

121
theme.md
View File

@@ -1,121 +0,0 @@
# Violet
```css
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--primary: 262.1 83.3% 57.8%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 262.1 83.3% 57.8%;
--radius: 0.65rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--primary: 263.4 70% 50.4%;
--primary-foreground: 210 20% 98%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--ring: 263.4 70% 50.4%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
```
# Blue
```css@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 0 0% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--hover: 240 27% 11%;
}
}
```