@@ -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
|
||||
|
||||
34
.github/workflows/jest.yml
vendored
34
.github/workflows/jest.yml
vendored
@@ -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
|
||||
@@ -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`
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
8
app/components/DynamicViewportTheme.tsx
Normal file
8
app/components/DynamicViewportTheme.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useViewportThemeColor } from '@/hooks/use-viewport-theme-color';
|
||||
|
||||
export default function DynamicViewportTheme() {
|
||||
useViewportThemeColor();
|
||||
return null;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
50
app/components/PostHogProvider.tsx
Normal file
50
app/components/PostHogProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
29
hooks/use-viewport-theme-color.ts
Normal file
29
hooks/use-viewport-theme-color.ts
Normal 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]);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/$1',
|
||||
},
|
||||
};
|
||||
11
lib/posthog.ts
Normal file
11
lib/posthog.ts
Normal 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
29
lib/theme-colors.ts
Normal 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)}`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
118
pnpm-lock.yaml
generated
@@ -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
121
theme.md
@@ -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%;
|
||||
}
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user