Merge pull request #7 from sillyangel/v1
Add Docker support, switch to date-based versioning, and enhance starred songs and Last.fm integration - Added Docker configuration with health checks - Adopted date-based versioning (e.g., 2025.07.02) - Added starred songs management to AlbumPage - Introduced ArtistBio with Last.fm data and popular songs - Enhanced AudioPlayer with standalone Last.fm scrobbling - Simplified tracklist UI and removed unused elements - Cleaned up unused imports and deprecated config files
This commit is contained in:
25
.dockerignore
Normal file
25
.dockerignore
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
.env.local
|
||||||
|
.env.example
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
*.tgz
|
||||||
|
*.tar.gz
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.turbo
|
||||||
|
.github
|
||||||
|
4xnored.png
|
||||||
21
.env.docker
Normal file
21
.env.docker
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Docker Environment Configuration
|
||||||
|
# Copy this file to .env and modify the values as needed
|
||||||
|
|
||||||
|
# Host configuration
|
||||||
|
HOST_PORT=3000
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# Navidrome Server Configuration (OPTIONAL)
|
||||||
|
# If not provided, the app will prompt you to configure these settings
|
||||||
|
# NAVIDROME_URL=http://localhost:4533
|
||||||
|
# NAVIDROME_USERNAME=your_username
|
||||||
|
# NAVIDROME_PASSWORD=your_password
|
||||||
|
|
||||||
|
# PostHog Analytics (optional)
|
||||||
|
POSTHOG_KEY=
|
||||||
|
POSTHOG_HOST=
|
||||||
|
|
||||||
|
# Example for external Navidrome server:
|
||||||
|
# NAVIDROME_URL=https://your-navidrome-server.com
|
||||||
|
# NAVIDROME_USERNAME=your_username
|
||||||
|
# NAVIDROME_PASSWORD=your_password
|
||||||
@@ -1 +1 @@
|
|||||||
NEXT_PUBLIC_COMMIT_SHA=e88d8b2
|
NEXT_PUBLIC_COMMIT_SHA=a854604
|
||||||
|
|||||||
39
.idx/dev.nix
39
.idx/dev.nix
@@ -1,39 +0,0 @@
|
|||||||
{ pkgs, ... }: {
|
|
||||||
|
|
||||||
# Which nixpkgs channel to use.
|
|
||||||
channel = "stable-23.11"; # or "unstable"
|
|
||||||
|
|
||||||
# Use https://search.nixos.org/packages to find packages
|
|
||||||
packages = [
|
|
||||||
pkgs.corepack
|
|
||||||
];
|
|
||||||
|
|
||||||
# Sets environment variables in the workspace
|
|
||||||
# env = {
|
|
||||||
# SOME_ENV_VAR = "hello";
|
|
||||||
# };
|
|
||||||
|
|
||||||
# Search for the extensions you want on https://open-vsx.org/ and use "publisher.id"
|
|
||||||
# idx.extensions = [
|
|
||||||
# "angular.ng-template"
|
|
||||||
# ];
|
|
||||||
|
|
||||||
# Enable previews and customize configuration
|
|
||||||
idx.previews = {
|
|
||||||
enable = true;
|
|
||||||
previews = {
|
|
||||||
web = {
|
|
||||||
command = [
|
|
||||||
"pnpm"
|
|
||||||
"run"
|
|
||||||
"dev"
|
|
||||||
"--port"
|
|
||||||
"$PORT"
|
|
||||||
];
|
|
||||||
manager = "web";
|
|
||||||
# Optionally, specify a directory that contains your web app
|
|
||||||
# cwd = "app/client";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"IDX.aI.enableInlineCompletion": true,
|
|
||||||
"IDX.aI.enableCodebaseIndexing": true
|
|
||||||
}
|
|
||||||
171
DOCKER.md
Normal file
171
DOCKER.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# Docker Deployment
|
||||||
|
|
||||||
|
This application can be easily deployed using Docker with configurable environment variables.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Using Docker Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run using pre-built image (app will prompt for Navidrome config)
|
||||||
|
docker run -p 3000:3000 ghcr.io/sillyangel/mice:latest
|
||||||
|
|
||||||
|
# Or build locally
|
||||||
|
docker build -t mice .
|
||||||
|
docker run -p 3000:3000 mice
|
||||||
|
|
||||||
|
# Run with pre-configured Navidrome settings
|
||||||
|
docker run -p 3000:3000 \
|
||||||
|
-e NEXT_PUBLIC_NAVIDROME_URL=http://your-navidrome-server:4533 \
|
||||||
|
-e NEXT_PUBLIC_NAVIDROME_USERNAME=your_username \
|
||||||
|
-e NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password \
|
||||||
|
-e PORT=3000 \
|
||||||
|
ghcr.io/sillyangel/mice:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Docker Compose
|
||||||
|
|
||||||
|
1. Copy the environment template:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.docker .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Edit `.env` with your configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start the application:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: The default docker-compose.yml uses the pre-built image `ghcr.io/sillyangel/mice:latest`.
|
||||||
|
|
||||||
|
For local development, you can use the override example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp docker-compose.override.yml.example docker-compose.override.yml
|
||||||
|
# This will build locally instead of using the pre-built image
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Options
|
||||||
|
|
||||||
|
All configuration is done through environment variables. If Navidrome server configuration is not provided via environment variables, the application will automatically prompt you to configure it within the client interface.
|
||||||
|
|
||||||
|
### Optional Variables
|
||||||
|
|
||||||
|
- `NEXT_PUBLIC_NAVIDROME_URL`: URL of your Navidrome server (optional - app will prompt if not set)
|
||||||
|
- `NEXT_PUBLIC_NAVIDROME_USERNAME`: Navidrome username (optional - app will prompt if not set)
|
||||||
|
- `NEXT_PUBLIC_NAVIDROME_PASSWORD`: Navidrome password (optional - app will prompt if not set)
|
||||||
|
- `PORT`: Port for the application to listen on (default: `3000`)
|
||||||
|
- `HOST_PORT`: Host port to map to container port (docker-compose only, default: `3000`)
|
||||||
|
- `NEXT_PUBLIC_POSTHOG_KEY`: PostHog analytics key (optional)
|
||||||
|
- `NEXT_PUBLIC_POSTHOG_HOST`: PostHog analytics host (optional)
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Basic Setup (App will prompt for configuration)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using pre-built image - app will ask for Navidrome server details on first launch
|
||||||
|
docker run -p 3000:3000 ghcr.io/sillyangel/mice:latest
|
||||||
|
|
||||||
|
# Or build locally
|
||||||
|
docker build -t mice .
|
||||||
|
docker run -p 3000:3000 mice
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre-configured Development Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -p 3000:3000 \
|
||||||
|
-e NEXT_PUBLIC_NAVIDROME_URL=http://localhost:4533 \
|
||||||
|
-e NEXT_PUBLIC_NAVIDROME_USERNAME=admin \
|
||||||
|
-e NEXT_PUBLIC_NAVIDROME_PASSWORD=admin \
|
||||||
|
ghcr.io/sillyangel/mice:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre-configured Production Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -p 80:3000 \
|
||||||
|
-e NEXT_PUBLIC_NAVIDROME_URL=https://music.yourdomain.com \
|
||||||
|
-e NEXT_PUBLIC_NAVIDROME_USERNAME=your_user \
|
||||||
|
-e NEXT_PUBLIC_NAVIDROME_PASSWORD=your_secure_password \
|
||||||
|
-e PORT=3000 \
|
||||||
|
--restart unless-stopped \
|
||||||
|
ghcr.io/sillyangel/mice:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Environment File
|
||||||
|
|
||||||
|
#### Option 1: Let the app prompt for configuration
|
||||||
|
|
||||||
|
Create a minimal `.env` file:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PORT=3000
|
||||||
|
HOST_PORT=80
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Option 2: Pre-configure Navidrome settings
|
||||||
|
|
||||||
|
Create a `.env` file with Navidrome configuration:
|
||||||
|
|
||||||
|
```env
|
||||||
|
NAVIDROME_URL=https://music.yourdomain.com
|
||||||
|
NAVIDROME_USERNAME=your_user
|
||||||
|
NAVIDROME_PASSWORD=your_secure_password
|
||||||
|
PORT=3000
|
||||||
|
HOST_PORT=80
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run either way:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Health Check
|
||||||
|
|
||||||
|
The Docker Compose setup includes a health check that verifies the application is responding correctly. You can check the health status with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Connection refused**: Ensure your Navidrome server is accessible from the Docker container
|
||||||
|
2. **Authentication failed**: Verify your username and password are correct
|
||||||
|
3. **Port conflicts**: Change the `HOST_PORT` if port 3000 is already in use
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
View application logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Docker run
|
||||||
|
docker logs <container_name>
|
||||||
|
|
||||||
|
# Docker compose
|
||||||
|
docker-compose logs -f mice
|
||||||
|
```
|
||||||
|
|
||||||
|
### Container Shell Access
|
||||||
|
|
||||||
|
Access the container for debugging:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Docker run
|
||||||
|
docker exec -it <container_name> sh
|
||||||
|
|
||||||
|
# Docker compose
|
||||||
|
docker-compose exec mice sh
|
||||||
|
```
|
||||||
38
Dockerfile
Normal file
38
Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Use Node.js 22 Alpine for smaller image size
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
# Install pnpm globally
|
||||||
|
RUN npm install -g pnpm@9.15.3
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Set default environment variables (can be overridden at runtime)
|
||||||
|
# Navidrome configuration is optional - app will prompt if not provided
|
||||||
|
ENV NEXT_PUBLIC_NAVIDROME_URL=""
|
||||||
|
ENV NEXT_PUBLIC_NAVIDROME_USERNAME=""
|
||||||
|
ENV NEXT_PUBLIC_NAVIDROME_PASSWORD=""
|
||||||
|
ENV NEXT_PUBLIC_POSTHOG_KEY=""
|
||||||
|
ENV NEXT_PUBLIC_POSTHOG_HOST=""
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
# Generate git commit hash for build info (fallback if not available)
|
||||||
|
RUN echo "NEXT_PUBLIC_COMMIT_SHA=docker-build" > .env.local
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Expose the port
|
||||||
|
EXPOSE $PORT
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
CMD ["sh", "-c", "pnpm start -p $PORT"]
|
||||||
40
README.md
40
README.md
@@ -44,12 +44,14 @@ pnpm install
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
Edit `.env.local` with your Navidrome server details:
|
Edit `.env` with your Navidrome server details:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
NEXT_PUBLIC_NAVIDROME_URL=http://localhost:4533
|
NEXT_PUBLIC_NAVIDROME_URL=http://localhost:4533
|
||||||
NEXT_PUBLIC_NAVIDROME_USERNAME=your_username
|
NEXT_PUBLIC_NAVIDROME_USERNAME=your_username
|
||||||
NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password
|
NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password
|
||||||
|
NEXT_PUBLIC_POSTHOG_KEY=phc_XXXXXXXXXXXXXXXXXX
|
||||||
|
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Run the development server**
|
3. **Run the development server**
|
||||||
@@ -60,6 +62,42 @@ pnpm dev
|
|||||||
|
|
||||||
Open [http://localhost:40625](http://localhost:40625) in your browser.
|
Open [http://localhost:40625](http://localhost:40625) in your browser.
|
||||||
|
|
||||||
|
## Docker Deployment
|
||||||
|
|
||||||
|
For easy deployment using Docker:
|
||||||
|
|
||||||
|
### Quick Docker Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run using pre-built image (app will prompt for Navidrome configuration)
|
||||||
|
docker run -p 3000:3000 ghcr.io/sillyangel/mice:latest
|
||||||
|
|
||||||
|
# Or build locally
|
||||||
|
docker build -t mice .
|
||||||
|
docker run -p 3000:3000 mice
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy environment template and configure
|
||||||
|
cp .env.docker .env
|
||||||
|
# Edit .env with your settings (optional - app can prompt)
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre-configured Docker Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -p 3000:3000 \
|
||||||
|
-e NEXT_PUBLIC_NAVIDROME_URL=http://your-navidrome-server:4533 \
|
||||||
|
-e NEXT_PUBLIC_NAVIDROME_USERNAME=your_username \
|
||||||
|
-e NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password \
|
||||||
|
ghcr.io/sillyangel/mice:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
📖 **For detailed Docker configuration, environment variables, troubleshooting, and advanced setups, see [DOCKER.md](./DOCKER.md)**
|
||||||
|
|
||||||
## Migration from Firebase
|
## Migration from Firebase
|
||||||
|
|
||||||
This project was migrated from Firebase to Navidrome. See [NAVIDROME_MIGRATION.md](./NAVIDROME_MIGRATION.md) for detailed migration notes and troubleshooting.
|
This project was migrated from Firebase to Navidrome. See [NAVIDROME_MIGRATION.md](./NAVIDROME_MIGRATION.md) for detailed migration notes and troubleshooting.
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import { useParams } from 'next/navigation';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Album, Song } from '@/lib/navidrome';
|
import { Album, Song } from '@/lib/navidrome';
|
||||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||||
import { Play, Heart, User, Plus } from 'lucide-react';
|
import { Play, Heart } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { PlusIcon } from "@radix-ui/react-icons";
|
|
||||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext'
|
import { useAudioPlayer } from '@/app/components/AudioPlayerContext'
|
||||||
import Loading from "@/app/components/loading";
|
import Loading from "@/app/components/loading";
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
@@ -20,8 +19,9 @@ export default function AlbumPage() {
|
|||||||
const [tracklist, setTracklist] = useState<Song[]>([]);
|
const [tracklist, setTracklist] = useState<Song[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isStarred, setIsStarred] = useState(false);
|
const [isStarred, setIsStarred] = useState(false);
|
||||||
|
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||||
const { getAlbum, starItem, unstarItem } = useNavidrome();
|
const { getAlbum, starItem, unstarItem } = useNavidrome();
|
||||||
const { playTrack, addAlbumToQueue, playAlbum, playAlbumFromTrack, addToQueue, currentTrack } = useAudioPlayer();
|
const { playTrack, addAlbumToQueue, playAlbum, playAlbumFromTrack, currentTrack } = useAudioPlayer();
|
||||||
const api = getNavidromeAPI();
|
const api = getNavidromeAPI();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -34,6 +34,13 @@ export default function AlbumPage() {
|
|||||||
setAlbum(albumData.album);
|
setAlbum(albumData.album);
|
||||||
setTracklist(albumData.songs);
|
setTracklist(albumData.songs);
|
||||||
setIsStarred(!!albumData.album.starred);
|
setIsStarred(!!albumData.album.starred);
|
||||||
|
|
||||||
|
// Initialize starred songs state
|
||||||
|
const starredSongIds = new Set(
|
||||||
|
albumData.songs.filter(song => song.starred).map(song => song.id)
|
||||||
|
);
|
||||||
|
setStarredSongs(starredSongIds);
|
||||||
|
|
||||||
console.log(`Album found: ${albumData.album.name}`);
|
console.log(`Album found: ${albumData.album.name}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch album:', error);
|
console.error('Failed to fetch album:', error);
|
||||||
@@ -63,6 +70,26 @@ export default function AlbumPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSongStar = async (song: Song) => {
|
||||||
|
try {
|
||||||
|
const isCurrentlyStarred = starredSongs.has(song.id);
|
||||||
|
|
||||||
|
if (isCurrentlyStarred) {
|
||||||
|
await unstarItem(song.id, 'song');
|
||||||
|
setStarredSongs(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(song.id);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await starItem(song.id, 'song');
|
||||||
|
setStarredSongs(prev => new Set(prev).add(song.id));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to star/unstar song:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
}
|
}
|
||||||
@@ -80,26 +107,6 @@ export default function AlbumPage() {
|
|||||||
console.error('Failed to play album from track:', error);
|
console.error('Failed to play album from track:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleAddToQueue = (song: Song) => {
|
|
||||||
if (!api) {
|
|
||||||
console.error('Navidrome API not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const track = {
|
|
||||||
id: song.id,
|
|
||||||
name: song.title,
|
|
||||||
url: api.getStreamUrl(song.id),
|
|
||||||
artist: song.artist,
|
|
||||||
album: song.album,
|
|
||||||
duration: song.duration,
|
|
||||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
|
||||||
albumId: song.albumId,
|
|
||||||
artistId: song.artistId
|
|
||||||
};
|
|
||||||
|
|
||||||
addToQueue(track);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isCurrentlyPlaying = (song: Song): boolean => {
|
const isCurrentlyPlaying = (song: Song): boolean => {
|
||||||
return currentTrack?.id === song.id;
|
return currentTrack?.id === song.id;
|
||||||
@@ -160,23 +167,15 @@ export default function AlbumPage() {
|
|||||||
{tracklist.map((song, index) => (
|
{tracklist.map((song, index) => (
|
||||||
<div
|
<div
|
||||||
key={song.id}
|
key={song.id}
|
||||||
className={`group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors ${
|
className={`group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors`}
|
||||||
isCurrentlyPlaying(song) ? 'bg-accent/50 border-l-4 border-primary' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => handlePlayClick(song)}
|
onClick={() => handlePlayClick(song)}
|
||||||
>
|
>
|
||||||
{/* Track Number / Play Indicator */}
|
{/* Track Number / Play Indicator */}
|
||||||
<div className="w-8 text-center text-sm text-muted-foreground mr-3">
|
<div className="w-8 text-center text-sm text-muted-foreground mr-3">
|
||||||
{isCurrentlyPlaying(song) ? (
|
|
||||||
<div className="w-4 h-4 mx-auto">
|
|
||||||
<div className="w-full h-full bg-primary rounded-full animate-pulse" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
<>
|
||||||
<span className="group-hover:hidden">{song.track || index + 1}</span>
|
<span className="group-hover:hidden">{song.track || index + 1}</span>
|
||||||
<Play className="w-4 h-4 mx-auto hidden group-hover:block" />
|
<Play className="w-4 h-4 mx-auto hidden group-hover:block" />
|
||||||
</>
|
</>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Song Info */}
|
{/* Song Info */}
|
||||||
@@ -190,7 +189,6 @@ export default function AlbumPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center text-sm text-muted-foreground">
|
<div className="flex items-center text-sm text-muted-foreground">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<User className="w-3 h-3" />
|
|
||||||
<span className="truncate">{song.artist}</span>
|
<span className="truncate">{song.artist}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,17 +200,20 @@ export default function AlbumPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center space-x-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center space-x-2 group-hover:opacity-100 transition-opacity">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleAddToQueue(song);
|
handleSongStar(song);
|
||||||
}}
|
}}
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Heart
|
||||||
|
className={`w-4 h-4 ${starredSongs.has(song.id) ? 'text-primary' : 'text-gray-500'}`}
|
||||||
|
fill={starredSongs.has(song.id) ? 'var(--primary)' : 'none'}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { Album, Artist } from '@/lib/navidrome';
|
import { Album, Artist, Song } from '@/lib/navidrome';
|
||||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||||
import { AlbumArtwork } from '@/app/components/album-artwork';
|
import { AlbumArtwork } from '@/app/components/album-artwork';
|
||||||
|
import { PopularSongs } from '@/app/components/PopularSongs';
|
||||||
|
import { SimilarArtists } from '@/app/components/SimilarArtists';
|
||||||
|
import { ArtistBio } from '@/app/components/ArtistBio';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Heart, Play } from 'lucide-react';
|
import { Heart, Play } from 'lucide-react';
|
||||||
@@ -17,6 +20,7 @@ export default function ArtistPage() {
|
|||||||
const { artist: artistId } = useParams();
|
const { artist: artistId } = useParams();
|
||||||
const [isStarred, setIsStarred] = useState(false);
|
const [isStarred, setIsStarred] = useState(false);
|
||||||
const [artistAlbums, setArtistAlbums] = useState<Album[]>([]);
|
const [artistAlbums, setArtistAlbums] = useState<Album[]>([]);
|
||||||
|
const [popularSongs, setPopularSongs] = useState<Song[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [artist, setArtist] = useState<Artist | null>(null);
|
const [artist, setArtist] = useState<Artist | null>(null);
|
||||||
const [isPlayingArtist, setIsPlayingArtist] = useState(false);
|
const [isPlayingArtist, setIsPlayingArtist] = useState(false);
|
||||||
@@ -29,11 +33,19 @@ export default function ArtistPage() {
|
|||||||
const fetchArtistData = async () => {
|
const fetchArtistData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
if (artistId) {
|
if (artistId && api) {
|
||||||
const artistData = await getArtist(artistId as string);
|
const artistData = await getArtist(artistId as string);
|
||||||
setArtist(artistData.artist);
|
setArtist(artistData.artist);
|
||||||
setArtistAlbums(artistData.albums);
|
setArtistAlbums(artistData.albums);
|
||||||
setIsStarred(!!artistData.artist.starred);
|
setIsStarred(!!artistData.artist.starred);
|
||||||
|
|
||||||
|
// Fetch popular songs for the artist
|
||||||
|
try {
|
||||||
|
const songs = await api.getArtistTopSongs(artistData.artist.name, 10);
|
||||||
|
setPopularSongs(songs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch popular songs:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch artist data:', error);
|
console.error('Failed to fetch artist data:', error);
|
||||||
@@ -42,7 +54,7 @@ export default function ArtistPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchArtistData();
|
fetchArtistData();
|
||||||
}, [artistId, getArtist]);
|
}, [artistId, getArtist, api]);
|
||||||
|
|
||||||
const handleStar = async () => {
|
const handleStar = async () => {
|
||||||
if (!artist) return;
|
if (!artist) return;
|
||||||
@@ -135,10 +147,18 @@ export default function ArtistPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* About Section */}
|
||||||
|
<ArtistBio artistName={artist.name} />
|
||||||
|
|
||||||
|
{/* Popular Songs Section */}
|
||||||
|
{popularSongs.length > 0 && (
|
||||||
|
<PopularSongs songs={popularSongs} artistName={artist.name} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Albums Section */}
|
{/* Albums Section */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">Albums</h2>
|
<h2 className="text-2xl font-semibold tracking-tight">Discography</h2>
|
||||||
<ScrollArea>
|
<ScrollArea>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4 pb-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4 pb-4">
|
||||||
{artistAlbums.map((album) => (
|
{artistAlbums.map((album) => (
|
||||||
@@ -155,6 +175,12 @@ export default function ArtistPage() {
|
|||||||
<ScrollBar orientation="horizontal" />
|
<ScrollBar orientation="horizontal" />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Similar Artists Section */}
|
||||||
|
<SimilarArtists artistName={artist.name} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -119,8 +119,8 @@ export default function BrowsePage() {
|
|||||||
<ArtistIcon
|
<ArtistIcon
|
||||||
key={artist.id}
|
key={artist.id}
|
||||||
artist={artist}
|
artist={artist}
|
||||||
className="flex-shrink-0"
|
className="flex-shrink-0 overflow-hidden"
|
||||||
size={150}
|
size={190}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
106
app/components/ArtistBio.tsx
Normal file
106
app/components/ArtistBio.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { lastFmAPI } from '@/lib/lastfm-api';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ChevronDown, ChevronUp, ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ArtistBioProps {
|
||||||
|
artistName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArtistBio({ artistName }: ArtistBioProps) {
|
||||||
|
const [bio, setBio] = useState<string>('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [lastFmUrl, setLastFmUrl] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchArtistInfo = async () => {
|
||||||
|
if (!lastFmAPI.isAvailable()) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const artistInfo = await lastFmAPI.getArtistInfo(artistName);
|
||||||
|
if (artistInfo?.bio?.summary) {
|
||||||
|
// Clean up the bio text (remove HTML tags and Last.fm links)
|
||||||
|
let cleanBio = artistInfo.bio.summary
|
||||||
|
.replace(/<[^>]*>/g, '') // Remove HTML tags
|
||||||
|
.replace(/\s+/g, ' ') // Normalize whitespace
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Remove the "Read more on Last.fm" part
|
||||||
|
cleanBio = cleanBio.replace(/Read more on Last\.fm.*$/i, '').trim();
|
||||||
|
|
||||||
|
setBio(cleanBio);
|
||||||
|
setLastFmUrl(`https://www.last.fm/music/${encodeURIComponent(artistName)}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch artist bio:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchArtistInfo();
|
||||||
|
}, [artistName]);
|
||||||
|
|
||||||
|
if (!lastFmAPI.isAvailable() || loading || !bio) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldTruncate = bio.length > 300;
|
||||||
|
const displayBio = shouldTruncate && !expanded ? bio.substring(0, 300) + '...' : bio;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-2xl font-semibold tracking-tight">About</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
{displayBio}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{shouldTruncate && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="text-xs h-7 px-2"
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<>
|
||||||
|
<ChevronUp className="h-3 w-3 mr-1" />
|
||||||
|
Show less
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="h-3 w-3 mr-1" />
|
||||||
|
Show more
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lastFmUrl && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
className="text-xs h-7 px-2"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={lastFmUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3 w-3 mr-1" />
|
||||||
|
Last.fm
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
import { useAudioPlayer, Track } from '@/app/components/AudioPlayerContext';
|
||||||
import { FullScreenPlayer } from '@/app/components/FullScreenPlayer';
|
import { FullScreenPlayer } from '@/app/components/FullScreenPlayer';
|
||||||
import { FaPlay, FaPause, FaVolumeHigh, FaForward, FaBackward, FaCompress, FaVolumeXmark, FaExpand, FaShuffle } from "react-icons/fa6";
|
import { FaPlay, FaPause, FaVolumeHigh, FaForward, FaBackward, FaCompress, FaVolumeXmark, FaExpand, FaShuffle } from "react-icons/fa6";
|
||||||
|
import { Heart } from 'lucide-react';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { useLastFmScrobbler } from '@/hooks/use-lastfm-scrobbler';
|
import { useLastFmScrobbler } from '@/hooks/use-lastfm-scrobbler';
|
||||||
|
import { useStandaloneLastFm } from '@/hooks/use-standalone-lastfm';
|
||||||
|
|
||||||
export const AudioPlayer: React.FC = () => {
|
export const AudioPlayer: React.FC = () => {
|
||||||
const { currentTrack, playPreviousTrack, addToQueue, playNextTrack, clearQueue, queue, toggleShuffle, shuffle } = useAudioPlayer();
|
const { currentTrack, playPreviousTrack, addToQueue, playNextTrack, clearQueue, queue, toggleShuffle, shuffle, toggleCurrentTrackStar } = useAudioPlayer();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const preloadAudioRef = useRef<HTMLAudioElement>(null);
|
const preloadAudioRef = useRef<HTMLAudioElement>(null);
|
||||||
@@ -24,14 +26,49 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
const audioCurrent = audioRef.current;
|
const audioCurrent = audioRef.current;
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// Last.fm scrobbler integration
|
// Last.fm scrobbler integration (Navidrome)
|
||||||
const {
|
const {
|
||||||
onTrackStart,
|
onTrackStart: navidromeOnTrackStart,
|
||||||
onTrackPlay,
|
onTrackPlay: navidromeOnTrackPlay,
|
||||||
onTrackPause,
|
onTrackPause: navidromeOnTrackPause,
|
||||||
onTrackProgress,
|
onTrackProgress: navidromeOnTrackProgress,
|
||||||
onTrackEnd,
|
onTrackEnd: navidromeOnTrackEnd,
|
||||||
} = useLastFmScrobbler();
|
} = useLastFmScrobbler();
|
||||||
|
|
||||||
|
// Standalone Last.fm integration
|
||||||
|
const {
|
||||||
|
onTrackStart: standaloneOnTrackStart,
|
||||||
|
onTrackPlay: standaloneOnTrackPlay,
|
||||||
|
onTrackPause: standaloneOnTrackPause,
|
||||||
|
onTrackProgress: standaloneOnTrackProgress,
|
||||||
|
onTrackEnd: standaloneOnTrackEnd,
|
||||||
|
} = useStandaloneLastFm();
|
||||||
|
|
||||||
|
// Combined Last.fm handlers
|
||||||
|
const onTrackStart = useCallback((track: Track) => {
|
||||||
|
navidromeOnTrackStart(track);
|
||||||
|
standaloneOnTrackStart(track);
|
||||||
|
}, [navidromeOnTrackStart, standaloneOnTrackStart]);
|
||||||
|
|
||||||
|
const onTrackPlay = useCallback((track: Track) => {
|
||||||
|
navidromeOnTrackPlay(track);
|
||||||
|
standaloneOnTrackPlay(track);
|
||||||
|
}, [navidromeOnTrackPlay, standaloneOnTrackPlay]);
|
||||||
|
|
||||||
|
const onTrackPause = useCallback((currentTime: number) => {
|
||||||
|
navidromeOnTrackPause(currentTime);
|
||||||
|
standaloneOnTrackPause(currentTime);
|
||||||
|
}, [navidromeOnTrackPause, standaloneOnTrackPause]);
|
||||||
|
|
||||||
|
const onTrackProgress = useCallback((track: Track, currentTime: number, duration: number) => {
|
||||||
|
navidromeOnTrackProgress(track, currentTime, duration);
|
||||||
|
standaloneOnTrackProgress(track, currentTime, duration);
|
||||||
|
}, [navidromeOnTrackProgress, standaloneOnTrackProgress]);
|
||||||
|
|
||||||
|
const onTrackEnd = useCallback((track: Track, currentTime: number, duration: number) => {
|
||||||
|
navidromeOnTrackEnd(track, currentTime, duration);
|
||||||
|
standaloneOnTrackEnd(track, currentTime, duration);
|
||||||
|
}, [navidromeOnTrackEnd, standaloneOnTrackEnd]);
|
||||||
|
|
||||||
const handleOpenQueue = () => {
|
const handleOpenQueue = () => {
|
||||||
setIsFullScreen(false);
|
setIsFullScreen(false);
|
||||||
@@ -333,14 +370,27 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
height={40}
|
height={40}
|
||||||
className="w-10 h-10 rounded-md flex-shrink-0"
|
className="w-10 h-10 rounded-md flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0 mx-3 group">
|
<div className="flex-1 min-w-0 mx-3">
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
<p className="font-semibold text-sm whitespace-nowrap group-hover:animate-scroll">
|
<p className="font-semibold text-sm whitespace-nowrap animate-infinite-scroll">
|
||||||
{currentTrack.name}
|
{currentTrack.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground truncate">{currentTrack.artist}</p>
|
<p className="text-xs text-muted-foreground truncate">{currentTrack.artist}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Heart icon for favoriting */}
|
||||||
|
<button
|
||||||
|
className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors mr-2"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleCurrentTrackStar();
|
||||||
|
}}
|
||||||
|
title={currentTrack.starred ? 'Remove from favorites' : 'Add to favorites'}
|
||||||
|
>
|
||||||
|
<Heart
|
||||||
|
className={`w-4 h-4 ${currentTrack.starred ? 'text-primary fill-primary' : 'text-gray-400'}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
<div className="flex items-center justify-center space-x-2">
|
<div className="flex items-center justify-center space-x-2">
|
||||||
<button className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors" onClick={playPreviousTrack}>
|
<button className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors" onClick={playPreviousTrack}>
|
||||||
<FaBackward className="w-3 h-3" />
|
<FaBackward className="w-3 h-3" />
|
||||||
@@ -363,52 +413,84 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
// Compact floating player (default state)
|
// Compact floating player (default state)
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-4 left-4 right-4 z-50">
|
<div className="fixed bottom-4 left-4 right-4 z-50">
|
||||||
<div className="bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg p-3 pb-0 cursor-pointer hover:scale-[1.01] transition-transform">
|
<div className="bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg p-3 cursor-pointer hover:scale-[1.01] transition-transform">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center">
|
||||||
|
{/* Track info */}
|
||||||
<div className="flex items-center flex-1 min-w-0">
|
<div className="flex items-center flex-1 min-w-0">
|
||||||
<Image
|
<Image
|
||||||
src={currentTrack.coverArt || '/default-user.jpg'}
|
src={currentTrack.coverArt || '/default-user.jpg'}
|
||||||
alt={currentTrack.name}
|
alt={currentTrack.name}
|
||||||
width={40}
|
width={48}
|
||||||
height={40}
|
height={48}
|
||||||
className="w-10 h-10 rounded-md mr-3 flex-shrink-0"
|
className="w-12 h-12 rounded-md mr-4 flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-semibold truncate text-sm">{currentTrack.name}</p>
|
<p className="font-semibold truncate text-base">{currentTrack.name}</p>
|
||||||
<p className="text-xs text-muted-foreground truncate">{currentTrack.artist}</p>
|
<p className="text-sm text-muted-foreground truncate">{currentTrack.artist}</p>
|
||||||
</div>
|
</div>
|
||||||
{/* faviorte icon or smthing here */}
|
|
||||||
</div>
|
</div>
|
||||||
{/* Control buttons */}
|
|
||||||
<button
|
{/* Center section with controls and progress */}
|
||||||
onClick={toggleShuffle} className={`p-1.5 hover:bg-gray-700/50 rounded-full transition-colors ${ shuffle ? 'text-primary bg-primary/20' : '' }`} title={shuffle ? 'Shuffle On - Queue is shuffled' : 'Shuffle Off - Click to shuffle queue'}>
|
<div className="flex flex-col items-center flex-1 justify-center">
|
||||||
<FaShuffle className="w-3 h-3" />
|
{/* Control buttons */}
|
||||||
|
<div className="flex items-center justify-center space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={toggleShuffle}
|
||||||
|
className={`p-2 hover:bg-gray-700/50 rounded-full transition-colors ${shuffle ? 'text-primary bg-primary/20' : ''}`}
|
||||||
|
title={shuffle ? 'Shuffle On - Queue is shuffled' : 'Shuffle Off - Click to shuffle queue'}
|
||||||
|
>
|
||||||
|
<FaShuffle className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center justify-center space-x-2">
|
<button className="p-2 hover:bg-gray-700/50 rounded-full transition-colors" onClick={playPreviousTrack}>
|
||||||
<button className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors" onClick={playPreviousTrack}>
|
<FaBackward className="w-4 h-4" />
|
||||||
<FaBackward className="w-3 h-3" />
|
</button>
|
||||||
</button>
|
<button className="p-3 hover:bg-gray-700/50 rounded-full transition-colors" onClick={togglePlayPause}>
|
||||||
<button className="p-2 hover:bg-gray-700/50 rounded-full transition-colors" onClick={togglePlayPause}>
|
{isPlaying ? <FaPause className="w-5 h-5" /> : <FaPlay className="w-5 h-5" />}
|
||||||
{isPlaying ? <FaPause className="w-4 h-4" /> : <FaPlay className="w-4 h-4" />}
|
</button>
|
||||||
</button>
|
<button className="p-2 hover:bg-gray-700/50 rounded-full transition-colors" onClick={playNextTrack}>
|
||||||
<button className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors" onClick={playNextTrack}>
|
<FaForward className="w-4 h-4" />
|
||||||
<FaForward className="w-3 h-3" />
|
</button>
|
||||||
</button>
|
<button
|
||||||
</div>
|
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors flex items-center justify-center"
|
||||||
<div className="flex items-center space-x-1 ml-2">
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleCurrentTrackStar();
|
||||||
|
}}
|
||||||
|
title={currentTrack.starred ? 'Remove from favorites' : 'Add to favorites'}
|
||||||
|
>
|
||||||
|
<Heart
|
||||||
|
className={`w-5 h-5 ${currentTrack.starred ? 'text-primary fill-primary' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
{/* <div className="flex items-center space-x-2 w-80">
|
||||||
|
<span className="text-xs text-muted-foreground w-8 text-right">
|
||||||
|
{formatTime(audioCurrent?.currentTime ?? 0)}
|
||||||
|
</span>
|
||||||
|
<Progress value={progress} className="flex-1 cursor-pointer h-1" onClick={handleProgressClick}/>
|
||||||
|
<span className="text-xs text-muted-foreground w-8">
|
||||||
|
{formatTime(audioCurrent?.duration ?? 0)}
|
||||||
|
</span>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side buttons */}
|
||||||
|
<div className="flex items-center justify-end space-x-2 flex-1">
|
||||||
<button
|
<button
|
||||||
className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors"
|
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors"
|
||||||
onClick={() => setIsFullScreen(true)}
|
onClick={() => setIsFullScreen(true)}
|
||||||
title="Full Screen"
|
title="Full Screen"
|
||||||
>
|
>
|
||||||
<FaExpand className="w-3 h-3" />
|
<FaExpand className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors"
|
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors"
|
||||||
onClick={() => setIsMinimized(true)}
|
onClick={() => setIsMinimized(true)}
|
||||||
title="Minimize"
|
title="Minimize"
|
||||||
>
|
>
|
||||||
<FaCompress className="w-3 h-3" />
|
<FaCompress className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -424,16 +506,4 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// {/* Progress bar */}
|
|
||||||
// <div className="flex items-center space-x-2">
|
|
||||||
// <span className="text-xs text-muted-foreground w-8 text-right">
|
|
||||||
// {formatTime(audioCurrent?.currentTime ?? 0)}
|
|
||||||
// </span>
|
|
||||||
// <Progress value={progress} className="flex-1 cursor-pointer h-1" onClick={handleProgressClick}/>
|
|
||||||
// <span className="text-xs text-muted-foreground w-8">
|
|
||||||
// {formatTime(audioCurrent?.duration ?? 0)}
|
|
||||||
// </span>
|
|
||||||
// </div>
|
|
||||||
@@ -16,6 +16,7 @@ export interface Track {
|
|||||||
albumId: string;
|
albumId: string;
|
||||||
artistId: string;
|
artistId: string;
|
||||||
autoPlay?: boolean; // Flag to control auto-play
|
autoPlay?: boolean; // Flag to control auto-play
|
||||||
|
starred?: boolean; // Flag for starred/favorited tracks
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AudioPlayerContextProps {
|
interface AudioPlayerContextProps {
|
||||||
@@ -39,6 +40,8 @@ interface AudioPlayerContextProps {
|
|||||||
playArtist: (artistId: string) => Promise<void>;
|
playArtist: (artistId: string) => Promise<void>;
|
||||||
playedTracks: Track[];
|
playedTracks: Track[];
|
||||||
clearHistory: () => void;
|
clearHistory: () => void;
|
||||||
|
toggleCurrentTrackStar: () => Promise<void>;
|
||||||
|
updateTrackStarred: (trackId: string, starred: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AudioPlayerContext = createContext<AudioPlayerContextProps | undefined>(undefined);
|
const AudioPlayerContext = createContext<AudioPlayerContextProps | undefined>(undefined);
|
||||||
@@ -104,7 +107,8 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
duration: song.duration,
|
duration: song.duration,
|
||||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||||
albumId: song.albumId,
|
albumId: song.albumId,
|
||||||
artistId: song.artistId
|
artistId: song.artistId,
|
||||||
|
starred: !!song.starred
|
||||||
};
|
};
|
||||||
}, [api]);
|
}, [api]);
|
||||||
|
|
||||||
@@ -577,7 +581,75 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
shuffleAllAlbums,
|
shuffleAllAlbums,
|
||||||
playArtist,
|
playArtist,
|
||||||
playedTracks,
|
playedTracks,
|
||||||
clearHistory
|
clearHistory,
|
||||||
|
toggleCurrentTrackStar: async () => {
|
||||||
|
if (!currentTrack || !api) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error",
|
||||||
|
description: "No track currently playing or API not configured",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStarredStatus = !currentTrack.starred;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (newStarredStatus) {
|
||||||
|
await api.star(currentTrack.id, 'song');
|
||||||
|
} else {
|
||||||
|
await api.unstar(currentTrack.id, 'song');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the current track state
|
||||||
|
setCurrentTrack((prev) => prev ? { ...prev, starred: newStarredStatus } : null);
|
||||||
|
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update track starred status:', error);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to update track favorite status",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateTrackStarred: async (trackId: string, starred: boolean) => {
|
||||||
|
if (!api) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Configuration Required",
|
||||||
|
description: "Please configure Navidrome connection in settings",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (starred) {
|
||||||
|
await api.star(trackId, 'song');
|
||||||
|
} else {
|
||||||
|
await api.unstar(trackId, 'song');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the current track state if it matches the updated track
|
||||||
|
if (currentTrack?.id === trackId) {
|
||||||
|
setCurrentTrack((prev) => prev ? { ...prev, starred } : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also update queue if the track is in there
|
||||||
|
setQueue((prev) => prev.map(track =>
|
||||||
|
track.id === trackId ? { ...track, starred } : track
|
||||||
|
));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update track starred status:', error);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to update track favorite status",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}), [
|
}), [
|
||||||
currentTrack,
|
currentTrack,
|
||||||
queue,
|
queue,
|
||||||
@@ -598,7 +670,9 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
shuffleAllAlbums,
|
shuffleAllAlbums,
|
||||||
playArtist,
|
playArtist,
|
||||||
playedTracks,
|
playedTracks,
|
||||||
clearHistory
|
clearHistory,
|
||||||
|
api,
|
||||||
|
toast
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { lrcLibClient } from '@/lib/lrclib';
|
import { lrcLibClient } from '@/lib/lrclib';
|
||||||
@@ -19,8 +20,15 @@ import {
|
|||||||
FaQuoteLeft,
|
FaQuoteLeft,
|
||||||
FaListUl
|
FaListUl
|
||||||
} from "react-icons/fa6";
|
} from "react-icons/fa6";
|
||||||
|
import { Heart } from 'lucide-react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from "@/components/ui/context-menu";
|
||||||
|
|
||||||
interface LyricLine {
|
interface LyricLine {
|
||||||
time: number;
|
time: number;
|
||||||
@@ -34,7 +42,8 @@ interface FullScreenPlayerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onClose, onOpenQueue }) => {
|
export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onClose, onOpenQueue }) => {
|
||||||
const { currentTrack, playPreviousTrack, playNextTrack, shuffle, toggleShuffle } = useAudioPlayer();
|
const { currentTrack, playPreviousTrack, playNextTrack, shuffle, toggleShuffle, toggleCurrentTrackStar } = useAudioPlayer();
|
||||||
|
const router = useRouter();
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [volume, setVolume] = useState(1);
|
const [volume, setVolume] = useState(1);
|
||||||
@@ -334,8 +343,11 @@ 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">
|
<h1 className="text-lg sm:text-xl lg:text-3xl font-bold text-foreground mb-2 line-clamp-2 leading-tight">
|
||||||
{currentTrack.name}
|
{currentTrack.name}
|
||||||
</h1>
|
</h1>
|
||||||
<Link href={`/album/${currentTrack.artistId}`} className="text-base sm:text-lg lg:text-xl text-foreground/80 mb-1 line-clamp-1">
|
<Link href={`/artist/${currentTrack.artistId}`} className="text-base sm:text-lg lg:text-xl text-foreground/80 mb-1 line-clamp-1">
|
||||||
{currentTrack.artist}
|
{currentTrack.artist}
|
||||||
|
</Link>
|
||||||
|
<Link href={`/album/${currentTrack.albumId}`} className="text-sm sm:text-base lg:text-lg text-foreground/60 line-clamp-1 cursor-pointer hover:underline">
|
||||||
|
{currentTrack.album}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -384,17 +396,17 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
|||||||
<FaForward className="w-4 h-4 sm:w-5 sm:h-5" />
|
<FaForward className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{lyrics.length > 0 && (
|
<button
|
||||||
<button
|
onClick={toggleCurrentTrackStar}
|
||||||
onClick={() => setShowLyrics(!showLyrics)}
|
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors"
|
||||||
className={`p-2 hover:bg-gray-700/50 rounded-full transition-colors ${
|
title={currentTrack?.starred ? 'Remove from favorites' : 'Add to favorites'}
|
||||||
showLyrics ? 'text-primary bg-primary/20' : 'text-gray-500'
|
>
|
||||||
}`}
|
<Heart
|
||||||
title={showLyrics ? 'Hide Lyrics' : 'Show Lyrics'}
|
className={`w-4 h-4 sm:w-5 sm:h-5 ${currentTrack?.starred ? 'text-primary fill-primary' : 'text-gray-400'}`}
|
||||||
>
|
/>
|
||||||
<FaQuoteLeft className="w-4 h-4 sm:w-5 sm:h-5" />
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -410,6 +422,17 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{lyrics.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLyrics(!showLyrics)}
|
||||||
|
className={`p-2 hover:bg-gray-700/50 rounded-full transition-colors ${
|
||||||
|
showLyrics ? 'text-primary bg-primary/20' : 'text-gray-500'
|
||||||
|
}`}
|
||||||
|
title={showLyrics ? 'Hide Lyrics' : 'Show Lyrics'}
|
||||||
|
>
|
||||||
|
<FaQuoteLeft className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{showVolumeSlider && (
|
{showVolumeSlider && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { getNavidromeAPI, Album, Artist, Song, Playlist, AlbumInfo, ArtistInfo }
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
interface NavidromeContextType {
|
interface NavidromeContextType {
|
||||||
|
// API instance
|
||||||
|
api: ReturnType<typeof getNavidromeAPI>;
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
albums: Album[];
|
albums: Album[];
|
||||||
artists: Artist[];
|
artists: Artist[];
|
||||||
@@ -387,6 +390,9 @@ export const NavidromeProvider: React.FC<NavidromeProviderProps> = ({ children }
|
|||||||
}, [api, refreshData]);
|
}, [api, refreshData]);
|
||||||
|
|
||||||
const value: NavidromeContextType = {
|
const value: NavidromeContextType = {
|
||||||
|
// API instance
|
||||||
|
api,
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
albums,
|
albums,
|
||||||
artists,
|
artists,
|
||||||
|
|||||||
151
app/components/PopularSongs.tsx
Normal file
151
app/components/PopularSongs.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
'use client';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { Song } from '@/lib/navidrome';
|
||||||
|
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Play, Heart } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||||
|
import { getNavidromeAPI } from '@/lib/navidrome';
|
||||||
|
|
||||||
|
interface PopularSongsProps {
|
||||||
|
songs: Song[];
|
||||||
|
artistName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PopularSongs({ songs, artistName }: PopularSongsProps) {
|
||||||
|
const { playTrack } = useAudioPlayer();
|
||||||
|
const { starItem, unstarItem } = useNavidrome();
|
||||||
|
const [songStates, setSongStates] = useState<Record<string, boolean>>(() => {
|
||||||
|
const initial: Record<string, boolean> = {};
|
||||||
|
songs.forEach(song => {
|
||||||
|
initial[song.id] = !!song.starred;
|
||||||
|
});
|
||||||
|
return initial;
|
||||||
|
});
|
||||||
|
const api = getNavidromeAPI();
|
||||||
|
|
||||||
|
const songToTrack = (song: Song) => {
|
||||||
|
if (!api) {
|
||||||
|
throw new Error('Navidrome API not configured');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: song.id,
|
||||||
|
name: song.title,
|
||||||
|
url: api.getStreamUrl(song.id),
|
||||||
|
artist: song.artist,
|
||||||
|
album: song.album,
|
||||||
|
duration: song.duration,
|
||||||
|
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||||
|
albumId: song.albumId,
|
||||||
|
artistId: song.artistId,
|
||||||
|
starred: !!song.starred
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlaySong = async (song: Song) => {
|
||||||
|
try {
|
||||||
|
const track = songToTrack(song);
|
||||||
|
playTrack(track, true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to play song:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleStar = async (song: Song) => {
|
||||||
|
try {
|
||||||
|
const isStarred = songStates[song.id];
|
||||||
|
if (isStarred) {
|
||||||
|
await unstarItem(song.id, 'song');
|
||||||
|
setSongStates(prev => ({ ...prev, [song.id]: false }));
|
||||||
|
} else {
|
||||||
|
await starItem(song.id, 'song');
|
||||||
|
setSongStates(prev => ({ ...prev, [song.id]: true }));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to star/unstar song:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number): string => {
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (songs.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-2xl font-semibold tracking-tight">Popular Songs</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{songs.map((song, index) => (
|
||||||
|
<div
|
||||||
|
key={song.id}
|
||||||
|
className="flex items-center space-x-4 p-3 rounded-lg hover:bg-muted/50 group"
|
||||||
|
>
|
||||||
|
{/* Rank */}
|
||||||
|
<div className="w-8 text-sm text-muted-foreground text-center">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Album Art */}
|
||||||
|
<div className="relative w-12 h-12 bg-muted rounded-md overflow-hidden flex-shrink-0">
|
||||||
|
{song.coverArt && api && (
|
||||||
|
<Image
|
||||||
|
src={api.getCoverArtUrl(song.coverArt, 96)}
|
||||||
|
alt={song.album}
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 p-0 text-white hover:bg-white/20"
|
||||||
|
onClick={() => handlePlaySong(song)}
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Song Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium text-sm truncate">{song.title}</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">{song.album}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Play Count */}
|
||||||
|
{song.playCount && song.playCount > 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{song.playCount.toLocaleString()} plays
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Duration */}
|
||||||
|
<div className="text-xs text-muted-foreground w-12 text-right">
|
||||||
|
{formatDuration(song.duration)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Star Button */}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={() => handleToggleStar(song)}
|
||||||
|
>
|
||||||
|
<Heart
|
||||||
|
className={`h-4 w-4 ${songStates[song.id] ? 'text-red-500 fill-red-500' : 'text-muted-foreground'}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { NavidromeProvider, useNavidrome } from "../components/NavidromeContext"
|
|||||||
import { NavidromeConfigProvider } from "../components/NavidromeConfigContext";
|
import { NavidromeConfigProvider } from "../components/NavidromeConfigContext";
|
||||||
import { ThemeProvider } from "../components/ThemeProvider";
|
import { ThemeProvider } from "../components/ThemeProvider";
|
||||||
import { PostHogProvider } from "../components/PostHogProvider";
|
import { PostHogProvider } from "../components/PostHogProvider";
|
||||||
|
import { WhatsNewPopup } from "../components/WhatsNewPopup";
|
||||||
import Ihateserverside from "./ihateserverside";
|
import Ihateserverside from "./ihateserverside";
|
||||||
import DynamicViewportTheme from "./DynamicViewportTheme";
|
import DynamicViewportTheme from "./DynamicViewportTheme";
|
||||||
import { LoginForm } from "./start-screen";
|
import { LoginForm } from "./start-screen";
|
||||||
@@ -13,19 +14,56 @@ import Image from "next/image";
|
|||||||
|
|
||||||
function NavidromeErrorBoundary({ children }: { children: React.ReactNode }) {
|
function NavidromeErrorBoundary({ children }: { children: React.ReactNode }) {
|
||||||
const { error } = useNavidrome();
|
const { error } = useNavidrome();
|
||||||
if (error) {
|
|
||||||
|
// Check if this is a first-time user
|
||||||
|
const hasCompletedOnboarding = typeof window !== 'undefined'
|
||||||
|
? localStorage.getItem('onboarding-completed')
|
||||||
|
: false;
|
||||||
|
|
||||||
|
// Simple check: has config in localStorage or environment
|
||||||
|
const hasAnyConfig = React.useMemo(() => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
|
||||||
|
// Check localStorage config
|
||||||
|
const savedConfig = localStorage.getItem('navidrome-config');
|
||||||
|
if (savedConfig) {
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(savedConfig);
|
||||||
|
if (config.serverUrl && config.username && config.password) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Invalid config, continue to env check
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check environment variables (visible on client side with NEXT_PUBLIC_)
|
||||||
|
if (process.env.NEXT_PUBLIC_NAVIDROME_URL &&
|
||||||
|
process.env.NEXT_PUBLIC_NAVIDROME_USERNAME &&
|
||||||
|
process.env.NEXT_PUBLIC_NAVIDROME_PASSWORD) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Show start screen ONLY if:
|
||||||
|
// 1. First-time user (no onboarding completed), OR
|
||||||
|
// 2. User has completed onboarding BUT there's an error AND no config exists
|
||||||
|
const shouldShowStartScreen = !hasCompletedOnboarding || (hasCompletedOnboarding && error && !hasAnyConfig);
|
||||||
|
|
||||||
|
if (shouldShowStartScreen) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
||||||
{/* top right add the logo located in /icon-192.png here and the word mice */}
|
|
||||||
<div className="absolute top-4 left-4 flex items-center space-x-2">
|
<div className="absolute top-4 left-4 flex items-center space-x-2">
|
||||||
<Image src="/icon-192.png" alt="Logo" width={32} height={32} className="h-8 w-8" />
|
<Image src="/icon-192.png" alt="Logo" width={32} height={32} className="h-8 w-8" />
|
||||||
<span className="text-xl font-semibold">mice | navidrome client</span>
|
<span className="text-xl font-semibold">mice | navidrome client</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
@@ -43,6 +81,7 @@ export default function RootLayoutClient({ children }: { children: React.ReactNo
|
|||||||
<Ihateserverside>
|
<Ihateserverside>
|
||||||
{children}
|
{children}
|
||||||
</Ihateserverside>
|
</Ihateserverside>
|
||||||
|
<WhatsNewPopup />
|
||||||
</AudioPlayerProvider>
|
</AudioPlayerProvider>
|
||||||
</NavidromeErrorBoundary>
|
</NavidromeErrorBoundary>
|
||||||
</NavidromeProvider>
|
</NavidromeProvider>
|
||||||
|
|||||||
96
app/components/SimilarArtists.tsx
Normal file
96
app/components/SimilarArtists.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { lastFmAPI } from '@/lib/lastfm-api';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface SimilarArtist {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
image?: Array<{
|
||||||
|
'#text': string;
|
||||||
|
size: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SimilarArtistsProps {
|
||||||
|
artistName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SimilarArtists({ artistName }: SimilarArtistsProps) {
|
||||||
|
const [similarArtists, setSimilarArtists] = useState<SimilarArtist[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSimilarArtists = async () => {
|
||||||
|
if (!lastFmAPI.isAvailable()) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const similar = await lastFmAPI.getSimilarArtists(artistName, 6);
|
||||||
|
if (similar?.artist) {
|
||||||
|
setSimilarArtists(similar.artist);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch similar artists:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSimilarArtists();
|
||||||
|
}, [artistName]);
|
||||||
|
|
||||||
|
const getArtistImage = (artist: SimilarArtist): string => {
|
||||||
|
if (!artist.image || artist.image.length === 0) {
|
||||||
|
return '/default-user.jpg';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get medium or large image
|
||||||
|
const mediumImage = artist.image.find(img => img.size === 'medium' || img.size === 'large');
|
||||||
|
const anyImage = artist.image[artist.image.length - 1]; // Fallback to last image
|
||||||
|
|
||||||
|
return mediumImage?.['#text'] || anyImage?.['#text'] || '/default-user.jpg';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!lastFmAPI.isAvailable() || loading || similarArtists.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-2xl font-semibold tracking-tight">Fans also like</h2>
|
||||||
|
<ScrollArea>
|
||||||
|
<div className="flex space-x-4 pb-4">
|
||||||
|
{similarArtists.map((artist) => (
|
||||||
|
<Link
|
||||||
|
key={artist.name}
|
||||||
|
href={`/artist/${encodeURIComponent(artist.name)}`}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
<div className="w-32 space-y-2 group cursor-pointer">
|
||||||
|
<div className="relative w-32 h-32 bg-muted rounded-full overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={getArtistImage(artist)}
|
||||||
|
alt={artist.name}
|
||||||
|
width={128}
|
||||||
|
height={128}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium truncate group-hover:text-primary transition-colors">
|
||||||
|
{artist.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ScrollBar orientation="horizontal" />
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
app/components/WhatsNewPopup.tsx
Normal file
217
app/components/WhatsNewPopup.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
|
||||||
|
// Current app version from package.json
|
||||||
|
const APP_VERSION = '2025.07.02';
|
||||||
|
|
||||||
|
// Changelog data - add new versions at the top
|
||||||
|
const CHANGELOG = [
|
||||||
|
{
|
||||||
|
version: '2025.07.02',
|
||||||
|
title: 'July Mini Update',
|
||||||
|
changes: [
|
||||||
|
'New Favorites inside of the Home Page',
|
||||||
|
'Server Status Indicator removed for better performance',
|
||||||
|
'New Album Artwork component for consistency (along with the artists)'
|
||||||
|
],
|
||||||
|
breaking: [],
|
||||||
|
fixes: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '2025.07.01',
|
||||||
|
title: 'July New Month Update',
|
||||||
|
changes: [
|
||||||
|
'Integrated standalone Last.fm scrobbling support',
|
||||||
|
'Added collapsible sidebar with icon-only mode',
|
||||||
|
'Improved search and browsing experience',
|
||||||
|
'Added history tracking for played songs',
|
||||||
|
'New Library Artist Page',
|
||||||
|
'Enhanced audio player with better controls',
|
||||||
|
'Added settings page for customization options',
|
||||||
|
'Introduced Whats New popup for version updates',
|
||||||
|
'Improved UI consistency with new Badge component',
|
||||||
|
'New Favorites page with album, song, and artist sections',
|
||||||
|
'New Favortites inside of the Home Page',
|
||||||
|
'Server Status Indicator removed for better performance',
|
||||||
|
],
|
||||||
|
breaking: [],
|
||||||
|
fixes: [
|
||||||
|
'Fixed issue with audio player not resuming playback after pause',
|
||||||
|
'Resolved bug with search results not displaying correctly',
|
||||||
|
'Improved performance for large libraries',
|
||||||
|
'Fixed layout issues on smaller screens',
|
||||||
|
'Resolved scrobbling issues with Last.fm integration'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// Example previous version
|
||||||
|
{
|
||||||
|
version: '2025.06.15',
|
||||||
|
title: 'June Final Update',
|
||||||
|
changes: [
|
||||||
|
'Added dark mode toggle',
|
||||||
|
'Improved playlist management',
|
||||||
|
],
|
||||||
|
breaking: [],
|
||||||
|
fixes: [
|
||||||
|
'Fixed login bug',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
type TabType = 'latest' | 'archive';
|
||||||
|
|
||||||
|
export function WhatsNewPopup() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [tab, setTab] = useState<TabType>('latest');
|
||||||
|
const [selectedArchive, setSelectedArchive] = useState(CHANGELOG[1]?.version || '');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hasCompletedOnboarding = localStorage.getItem('onboarding-completed');
|
||||||
|
if (!hasCompletedOnboarding) return;
|
||||||
|
const lastShownVersion = localStorage.getItem('whats-new-last-shown');
|
||||||
|
if (lastShownVersion !== APP_VERSION) {
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
localStorage.setItem('whats-new-last-shown', APP_VERSION);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentVersionChangelog = CHANGELOG.find(entry => entry.version === APP_VERSION);
|
||||||
|
const archiveChangelogs = CHANGELOG.filter(entry => entry.version !== APP_VERSION);
|
||||||
|
|
||||||
|
// For archive, show selected version
|
||||||
|
const archiveChangelog = archiveChangelogs.find(entry => entry.version === selectedArchive) || archiveChangelogs[0];
|
||||||
|
|
||||||
|
if (!currentVersionChangelog) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderChangelog = (changelog: typeof CHANGELOG[0]) => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{changelog.title && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">{changelog.title}</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{changelog.changes.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-md font-medium mb-3 flex items-center gap-2">
|
||||||
|
✨ New Features & Improvements
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{changelog.changes.map((change, index) => (
|
||||||
|
<li key={index} className="flex items-start gap-2">
|
||||||
|
<span className="text-green-500 mt-1">•</span>
|
||||||
|
<span className="text-sm">{change}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{changelog.fixes.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-md font-medium mb-3 flex items-center gap-2">
|
||||||
|
🐛 Bug Fixes
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{changelog.fixes.map((fix, index) => (
|
||||||
|
<li key={index} className="flex items-start gap-2">
|
||||||
|
<span className="text-blue-500 mt-1">•</span>
|
||||||
|
<span className="text-sm">{fix}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{changelog.breaking.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-md font-medium mb-3 flex items-center gap-2">
|
||||||
|
⚠️ Breaking Changes
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{changelog.breaking.map((breaking, index) => (
|
||||||
|
<li key={index} className="flex items-start gap-2">
|
||||||
|
<span className="text-red-500 mt-1">•</span>
|
||||||
|
<span className="text-sm">{breaking}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh]">
|
||||||
|
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||||
|
<div>
|
||||||
|
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
What's New in Mice
|
||||||
|
<Badge variant="outline">
|
||||||
|
{tab === 'latest' ? currentVersionChangelog.version : archiveChangelog?.version}
|
||||||
|
</Badge>
|
||||||
|
</DialogTitle>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<Button
|
||||||
|
variant={tab === 'latest' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTab('latest')}
|
||||||
|
>
|
||||||
|
Latest
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={tab === 'archive' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTab('archive')}
|
||||||
|
disabled={archiveChangelogs.length === 0}
|
||||||
|
>
|
||||||
|
Archive
|
||||||
|
</Button>
|
||||||
|
{tab === 'archive' && archiveChangelogs.length > 0 && (
|
||||||
|
<select
|
||||||
|
className="ml-2 border rounded px-2 py-1 text-sm"
|
||||||
|
value={selectedArchive}
|
||||||
|
onChange={e => setSelectedArchive(e.target.value)}
|
||||||
|
>
|
||||||
|
{archiveChangelogs.map(entry => (
|
||||||
|
<option key={entry.version} value={entry.version}>
|
||||||
|
{entry.version}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="max-h-[60vh] pr-4">
|
||||||
|
{tab === 'latest'
|
||||||
|
? renderChangelog(currentVersionChangelog)
|
||||||
|
: archiveChangelog && renderChangelog(archiveChangelog)}
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<div className="flex justify-center pt-4">
|
||||||
|
<Button onClick={handleClose}>
|
||||||
|
Got it!
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -16,11 +16,16 @@ import {
|
|||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "../../components/ui/context-menu"
|
} from "../../components/ui/context-menu"
|
||||||
|
|
||||||
import { Album } from "@/lib/navidrome"
|
|
||||||
import { useNavidrome } from "./NavidromeContext"
|
import { useNavidrome } from "./NavidromeContext"
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useAudioPlayer } from "@/app/components/AudioPlayerContext";
|
import { useAudioPlayer, Track } from "@/app/components/AudioPlayerContext";
|
||||||
import { getNavidromeAPI } from "@/lib/navidrome";
|
import { getNavidromeAPI } from "@/lib/navidrome";
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { ArtistIcon } from "@/app/components/artist-icon";
|
||||||
|
import { Heart, Music, Disc, Mic, Play } from "lucide-react";
|
||||||
|
import { Album, Artist, Song } from "@/lib/navidrome";
|
||||||
|
|
||||||
interface AlbumArtworkProps extends React.HTMLAttributes<HTMLDivElement> {
|
interface AlbumArtworkProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
album: Album
|
album: Album
|
||||||
@@ -37,10 +42,10 @@ export function AlbumArtwork({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: AlbumArtworkProps) {
|
}: AlbumArtworkProps) {
|
||||||
|
const { api, isConnected } = useNavidrome();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { addAlbumToQueue } = useAudioPlayer();
|
const { addAlbumToQueue, playTrack, addToQueue } = useAudioPlayer();
|
||||||
const { playlists, starItem, unstarItem } = useNavidrome();
|
const { playlists, starItem, unstarItem } = useNavidrome();
|
||||||
const api = getNavidromeAPI();
|
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
router.push(`/album/${album.id}`);
|
router.push(`/album/${album.id}`);
|
||||||
@@ -57,6 +62,47 @@ export function AlbumArtwork({
|
|||||||
starItem(album.id, 'album');
|
starItem(album.id, 'album');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePlayAlbum = async (album: Album) => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const songs = await api.getAlbumSongs(album.id);
|
||||||
|
if (songs.length > 0) {
|
||||||
|
const tracks = songs.map((song: Song) => ({
|
||||||
|
id: song.id,
|
||||||
|
name: song.title,
|
||||||
|
artist: song.artist,
|
||||||
|
album: song.album,
|
||||||
|
albumId: song.albumId,
|
||||||
|
artistId: song.artistId,
|
||||||
|
url: api.getStreamUrl(song.id),
|
||||||
|
duration: song.duration,
|
||||||
|
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt) : undefined,
|
||||||
|
starred: !!song.starred
|
||||||
|
}));
|
||||||
|
|
||||||
|
playTrack(tracks[0]);
|
||||||
|
tracks.slice(1).forEach((track: Track) => addToQueue(track));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to play album:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFavorite = async (id: string, type: 'song' | 'album' | 'artist', isStarred: boolean) => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isStarred) {
|
||||||
|
await api.unstar(id, type);
|
||||||
|
} else {
|
||||||
|
await api.star(id, type);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to toggle favorite:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
// Get cover art URL with proper fallback
|
// Get cover art URL with proper fallback
|
||||||
const coverArtUrl = album.coverArt && api
|
const coverArtUrl = album.coverArt && api
|
||||||
? api.getCoverArtUrl(album.coverArt, 300)
|
? api.getCoverArtUrl(album.coverArt, 300)
|
||||||
@@ -66,7 +112,34 @@ export function AlbumArtwork({
|
|||||||
<div className={cn("space-y-3", className)} {...props}>
|
<div className={cn("space-y-3", className)} {...props}>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger>
|
<ContextMenuTrigger>
|
||||||
<div onClick={handleClick} className="overflow-hidden rounded-md">
|
<Card key={album.id} className="overflow-hidden cursor-pointer" onClick={() => handleClick()}>
|
||||||
|
<div className="aspect-square relative group">
|
||||||
|
{album.coverArt && api ? (
|
||||||
|
<Image
|
||||||
|
src={api.getCoverArtUrl(album.coverArt)}
|
||||||
|
alt={album.name}
|
||||||
|
fill
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
sizes="(max-width: 768px) 100vw, 300px"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-muted rounded flex items-center justify-center">
|
||||||
|
<Disc className="w-12 h-12 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||||
|
<Play className="w-6 h-6 mx-auto hidden group-hover:block" onClick={() => handlePlayAlbum(album)}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<h3 className="font-semibold truncate">{album.name}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground truncate " onClick={() => router.push(album.artistId)}>{album.artist}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{album.songCount} songs • {Math.floor(album.duration / 60)} min
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/* <div onClick={handleClick} className="overflow-hidden rounded-md">
|
||||||
<Image
|
<Image
|
||||||
src={coverArtUrl}
|
src={coverArtUrl}
|
||||||
alt={album.name}
|
alt={album.name}
|
||||||
@@ -78,7 +151,7 @@ export function AlbumArtwork({
|
|||||||
aspectRatio === "portrait" ? "aspect-[3/4]" : "aspect-square"
|
aspectRatio === "portrait" ? "aspect-[3/4]" : "aspect-square"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div> */}
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent className="w-40">
|
<ContextMenuContent className="w-40">
|
||||||
<ContextMenuItem onClick={handleStar}>
|
<ContextMenuItem onClick={handleStar}>
|
||||||
@@ -122,12 +195,6 @@ export function AlbumArtwork({
|
|||||||
<ContextMenuItem>Share</ContextMenuItem>
|
<ContextMenuItem>Share</ContextMenuItem>
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
<div className="space-y-1 text-sm" >
|
|
||||||
<p className="font-medium leading-none" onClick={handleClick}>{album.name}</p>
|
|
||||||
<p className="text-xs text-muted-foreground underline">
|
|
||||||
<Link href={`/artist/${album.artistId}`}>{album.artist}</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { PlusCircledIcon } from "@radix-ui/react-icons"
|
import { PlusCircledIcon } from "@radix-ui/react-icons"
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
@@ -15,20 +14,23 @@ import {
|
|||||||
ContextMenuSubTrigger,
|
ContextMenuSubTrigger,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "../../components/ui/context-menu"
|
} from "../../components/ui/context-menu"
|
||||||
|
|
||||||
import { Artist } from "@/lib/navidrome"
|
|
||||||
import { useNavidrome } from "./NavidromeContext"
|
|
||||||
import { useAudioPlayer } from "@/app/components/AudioPlayerContext";
|
import { useAudioPlayer } from "@/app/components/AudioPlayerContext";
|
||||||
import { getNavidromeAPI } from "@/lib/navidrome";
|
import { getNavidromeAPI } from "@/lib/navidrome";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||||
|
import { Artist } from '@/lib/navidrome';
|
||||||
|
|
||||||
interface ArtistIconProps extends React.HTMLAttributes<HTMLDivElement> {
|
interface ArtistIconProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
artist: Artist
|
artist: Artist
|
||||||
size?: number
|
size?: number
|
||||||
|
imageOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ArtistIcon({
|
export function ArtistIcon({
|
||||||
artist,
|
artist,
|
||||||
size = 150,
|
size = 150,
|
||||||
|
imageOnly = false,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: ArtistIconProps) {
|
}: ArtistIconProps) {
|
||||||
@@ -57,11 +59,53 @@ export function ArtistIcon({
|
|||||||
? api.getCoverArtUrl(artist.coverArt, 200)
|
? api.getCoverArtUrl(artist.coverArt, 200)
|
||||||
: '/default-user.jpg';
|
: '/default-user.jpg';
|
||||||
|
|
||||||
|
// If imageOnly is true, return just the image without context menu or text
|
||||||
|
if (imageOnly) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("overflow-hidden rounded-full cursor-pointer flex-shrink-0", className)}
|
||||||
|
onClick={handleClick}
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={artistImageUrl}
|
||||||
|
alt={artist.name}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
className="w-full h-full object-cover transition-all hover:scale-105"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-3", className)} {...props}>
|
<div className={cn("space-y-3", className)} {...props}>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger>
|
<ContextMenuTrigger>
|
||||||
<div
|
<Card key={artist.id} className="overflow-hidden cursor-pointer" onClick={() => handleClick()}>
|
||||||
|
<div
|
||||||
|
className="aspect-square relative group"
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
>
|
||||||
|
<div className="w-full h-full">
|
||||||
|
<Image
|
||||||
|
src={artist.coverArt && api ? api.getCoverArtUrl(artist.coverArt, 200) : '/placeholder-artist.png'}
|
||||||
|
alt={artist.name}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
className="object-cover w-full h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<h3 className="font-semibold truncate">{artist.name}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{artist.albumCount} albums
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/* <div
|
||||||
className="overflow-hidden rounded-full cursor-pointer flex-shrink-0"
|
className="overflow-hidden rounded-full cursor-pointer flex-shrink-0"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
style={{ width: size, height: size }}
|
style={{ width: size, height: size }}
|
||||||
@@ -73,7 +117,7 @@ export function ArtistIcon({
|
|||||||
height={size}
|
height={size}
|
||||||
className="w-full h-full object-cover transition-all hover:scale-105"
|
className="w-full h-full object-cover transition-all hover:scale-105"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div> */}
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent className="w-40">
|
<ContextMenuContent className="w-40">
|
||||||
<ContextMenuItem onClick={handleStar}>
|
<ContextMenuItem onClick={handleStar}>
|
||||||
@@ -117,10 +161,6 @@ export function ArtistIcon({
|
|||||||
<ContextMenuItem>Share</ContextMenuItem>
|
<ContextMenuItem>Share</ContextMenuItem>
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
<div className="space-y-1 text-sm" onClick={handleClick}>
|
|
||||||
<p className="font-medium leading-none text-center">{artist.name}</p>
|
|
||||||
<p className="text-xs text-muted-foreground text-center">{artist.albumCount} albums</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Menu } from "@/app/components/menu";
|
import { Menu } from "@/app/components/menu";
|
||||||
import { Sidebar } from "@/app/components/sidebar";
|
import { Sidebar } from "@/app/components/sidebar";
|
||||||
import { useNavidrome } from "@/app/components/NavidromeContext";
|
import { useNavidrome } from "@/app/components/NavidromeContext";
|
||||||
@@ -15,13 +15,73 @@ const Ihateserverside: React.FC<IhateserversideProps> = ({ children }) => {
|
|||||||
const [isSidebarVisible, setIsSidebarVisible] = useState(true);
|
const [isSidebarVisible, setIsSidebarVisible] = useState(true);
|
||||||
const [isStatusBarVisible, setIsStatusBarVisible] = useState(true);
|
const [isStatusBarVisible, setIsStatusBarVisible] = useState(true);
|
||||||
const [isSidebarHidden, setIsSidebarHidden] = useState(false);
|
const [isSidebarHidden, setIsSidebarHidden] = useState(false);
|
||||||
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||||
|
const [isClient, setIsClient] = useState(false);
|
||||||
const { playlists } = useNavidrome();
|
const { playlists } = useNavidrome();
|
||||||
|
|
||||||
|
// Handle client-side hydration
|
||||||
|
useEffect(() => {
|
||||||
|
setIsClient(true);
|
||||||
|
const savedCollapsed = localStorage.getItem('sidebar-collapsed') === 'true';
|
||||||
|
setIsSidebarCollapsed(savedCollapsed);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleSidebarCollapse = () => {
|
||||||
|
const newCollapsed = !isSidebarCollapsed;
|
||||||
|
setIsSidebarCollapsed(newCollapsed);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('sidebar-collapsed', newCollapsed.toString());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleTransitionEnd = () => {
|
const handleTransitionEnd = () => {
|
||||||
if (!isSidebarVisible) {
|
if (!isSidebarVisible) {
|
||||||
setIsSidebarHidden(true); // This will fully hide the sidebar after transition
|
setIsSidebarHidden(true); // This will fully hide the sidebar after transition
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!isClient) {
|
||||||
|
// Return a basic layout during SSR to match initial client render
|
||||||
|
return (
|
||||||
|
<div className="hidden md:flex md:flex-col md:h-screen">
|
||||||
|
{/* Top Menu */}
|
||||||
|
<div
|
||||||
|
className="sticky z-10 bg-background border-b"
|
||||||
|
style={{
|
||||||
|
left: 'env(titlebar-area-x, 0)',
|
||||||
|
top: 'env(titlebar-area-y, 0)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu
|
||||||
|
toggleSidebar={() => setIsSidebarVisible(!isSidebarVisible)}
|
||||||
|
isSidebarVisible={isSidebarVisible}
|
||||||
|
toggleStatusBar={() => setIsStatusBarVisible(!isStatusBarVisible)}
|
||||||
|
isStatusBarVisible={isStatusBarVisible}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
<div className="w-64 flex-shrink-0 border-r transition-all duration-200">
|
||||||
|
<Sidebar
|
||||||
|
playlists={playlists}
|
||||||
|
className="h-full overflow-y-auto"
|
||||||
|
collapsed={false}
|
||||||
|
onToggle={toggleSidebarCollapse}
|
||||||
|
onTransitionEnd={handleTransitionEnd}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div>{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Audio Player */}
|
||||||
|
<AudioPlayer />
|
||||||
|
<Toaster />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="hidden md:flex md:flex-col md:h-screen">
|
<div className="hidden md:flex md:flex-col md:h-screen">
|
||||||
{/* Top Menu */}
|
{/* Top Menu */}
|
||||||
@@ -43,10 +103,12 @@ const Ihateserverside: React.FC<IhateserversideProps> = ({ children }) => {
|
|||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
{isSidebarVisible && (
|
{isSidebarVisible && (
|
||||||
<div className="w-64 flex-shrink-0 border-r">
|
<div className={`${isSidebarCollapsed ? 'w-16' : 'w-64'} flex-shrink-0 border-r transition-all duration-200`}>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
playlists={playlists}
|
playlists={playlists}
|
||||||
className="h-full overflow-y-auto"
|
className="h-full overflow-y-auto"
|
||||||
|
collapsed={isSidebarCollapsed}
|
||||||
|
onToggle={toggleSidebarCollapse}
|
||||||
onTransitionEnd={handleTransitionEnd}
|
onTransitionEnd={handleTransitionEnd}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,14 +4,31 @@ import React from 'react';
|
|||||||
|
|
||||||
const Loading: React.FC = () => {
|
const Loading: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex flex-col items-center">
|
||||||
<div className="text-center">
|
<svg
|
||||||
<div className="loader ease-linear rounded-full border-4 border-t-4 border-gray-200 h-12 w-12 mb-4"></div>
|
className="animate-spin h-12 w-12 text-primary/50 mb-4"
|
||||||
<p>Loading...</p>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
</div>
|
fill="none"
|
||||||
</div>
|
viewBox="0 0 24 24"
|
||||||
</>
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="text-white text-lg font-medium">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const { isConnected } = useNavidrome();
|
const { isConnected } = useNavidrome();
|
||||||
|
const [isClient, setIsClient] = useState(false);
|
||||||
|
const [navidromeUrl, setNavidromeUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
// For this demo, we'll show connection status instead of user auth
|
// For this demo, we'll show connection status instead of user auth
|
||||||
const connectionStatus = isConnected ? "Connected to Navidrome" : "Not connected";
|
const connectionStatus = isConnected ? "Connected to Navidrome" : "Not connected";
|
||||||
@@ -57,6 +59,29 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
|
|||||||
setIsFullScreen(!isFullScreen)
|
setIsFullScreen(!isFullScreen)
|
||||||
}, [isFullScreen])
|
}, [isFullScreen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsClient(true);
|
||||||
|
|
||||||
|
// Get Navidrome URL from localStorage
|
||||||
|
const config = localStorage.getItem("navidrome-config");
|
||||||
|
if (config) {
|
||||||
|
try {
|
||||||
|
const { serverUrl } = JSON.parse(config);
|
||||||
|
if (serverUrl) {
|
||||||
|
// Remove protocol (http:// or https://) and trailing slash
|
||||||
|
const prettyUrl = serverUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
||||||
|
setNavidromeUrl(prettyUrl);
|
||||||
|
} else {
|
||||||
|
setNavidromeUrl(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setNavidromeUrl(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setNavidromeUrl(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key === ',') {
|
if ((event.metaKey || event.ctrlKey) && event.key === ',') {
|
||||||
@@ -73,23 +98,20 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
if (isClient) {
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
if (isClient) {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [router, toggleSidebar, handleFullScreen]);
|
}, [router, toggleSidebar, handleFullScreen, isClient]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full ml-2">
|
||||||
<div className="flex items-center gap-2 mx-2">
|
|
||||||
<span
|
|
||||||
className={`h-2 w-2 rounded-full ${isConnected ? "bg-green-500" : "bg-red-400"}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ marginRight: '0.24rem' }} className="border-r-4 w-0"><p className="invisible">j</p></div>
|
|
||||||
|
|
||||||
<Menubar
|
<Menubar
|
||||||
className="rounded-none border-b border-none px-0 lg:px-0 flex-1"
|
className="rounded-none border-b border-none px-0 lg:px-0 flex-1"
|
||||||
style={{
|
style={{
|
||||||
@@ -107,7 +129,7 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
|
|||||||
Preferences <MenubarShortcut>⌘,</MenubarShortcut>
|
Preferences <MenubarShortcut>⌘,</MenubarShortcut>
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
<MenubarSeparator />
|
<MenubarSeparator />
|
||||||
<MenubarItem onClick={() => window.close()}>
|
<MenubarItem onClick={() => isClient && window.close()}>
|
||||||
Quit Music <MenubarShortcut>⌘Q</MenubarShortcut>
|
Quit Music <MenubarShortcut>⌘Q</MenubarShortcut>
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
</MenubarContent>
|
</MenubarContent>
|
||||||
@@ -288,30 +310,21 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs text-muted-foreground">Navidrome URL</span>
|
<span className="text-xs text-muted-foreground">Navidrome URL</span>
|
||||||
<span className="text-xs truncate max-w-[160px] text-right">
|
<span className="text-xs truncate max-w-[160px] text-right">
|
||||||
{typeof window !== "undefined"
|
{!isClient ? (
|
||||||
? (() => {
|
<span className="italic text-gray-400">Loading...</span>
|
||||||
const config = localStorage.getItem("navidrome-config");
|
) : navidromeUrl ? (
|
||||||
if (config) {
|
navidromeUrl
|
||||||
try {
|
) : (
|
||||||
const { serverUrl } = JSON.parse(config);
|
<span className="italic text-gray-400">Not set</span>
|
||||||
if (serverUrl) {
|
)}
|
||||||
// Remove protocol (http:// or https://) and trailing slash
|
|
||||||
const prettyUrl = serverUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
||||||
return prettyUrl;
|
|
||||||
}
|
|
||||||
return <span className="italic text-gray-400">Not set</span>;
|
|
||||||
} catch {
|
|
||||||
return <span className="italic text-gray-400">Invalid config</span>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return <span className="italic text-gray-400">Not set</span>;
|
|
||||||
})()
|
|
||||||
: <span className="italic text-gray-400">Not available</span>}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
<div className="flex flex-col items-center gap-1 mt-2">
|
<div className="flex flex-col items-center gap-1 mt-2">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Commit: {process.env.NEXT_PUBLIC_COMMIT_SHA || 'unknown'}
|
||||||
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">Copyright © {new Date().getFullYear()} <a
|
<span className="text-xs text-muted-foreground">Copyright © {new Date().getFullYear()} <a
|
||||||
href="https://github.com/sillyangel"
|
href="https://github.com/sillyangel"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -7,32 +7,66 @@ import { Button } from "../../components/ui/button";
|
|||||||
import { ScrollArea } from "../../components/ui/scroll-area";
|
import { ScrollArea } from "../../components/ui/scroll-area";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Playlist } from "@/lib/navidrome";
|
import { Playlist } from "@/lib/navidrome";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> {
|
interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
playlists: Playlist[];
|
playlists: Playlist[];
|
||||||
|
collapsed?: boolean;
|
||||||
|
onToggle?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ className, playlists }: SidebarProps) {
|
export function Sidebar({ className, playlists, collapsed = false, onToggle }: SidebarProps) {
|
||||||
const isRoot = usePathname() === "/";
|
const pathname = usePathname();
|
||||||
const isBrowse = usePathname() === "/browse";
|
|
||||||
const isSearch = usePathname() === "/search";
|
// Define all routes and their active states
|
||||||
const isAlbums = usePathname() === "/library/albums";
|
const routes = {
|
||||||
const isArtists = usePathname() === "/library/artists";
|
isRoot: pathname === "/",
|
||||||
const isQueue = usePathname() === "/queue";
|
isBrowse: pathname === "/browse",
|
||||||
const isRadio = usePathname() === "/radio";
|
isSearch: pathname === "/search",
|
||||||
const isHistory = usePathname() === "/history";
|
isQueue: pathname === "/queue",
|
||||||
const isSongs = usePathname() === "/library/songs"; const isPlaylists = usePathname() === "/library/playlists";
|
isRadio: pathname === "/radio",
|
||||||
|
isPlaylists: pathname === "/library/playlists",
|
||||||
|
isSongs: pathname === "/library/songs",
|
||||||
|
isArtists: pathname === "/library/artists",
|
||||||
|
isAlbums: pathname === "/library/albums",
|
||||||
|
isHistory: pathname === "/history",
|
||||||
|
isFavorites: pathname === "/favorites",
|
||||||
|
isSettings: pathname === "/settings",
|
||||||
|
// Handle dynamic routes
|
||||||
|
isAlbumPage: pathname.startsWith("/album/"),
|
||||||
|
isArtistPage: pathname.startsWith("/artist/"),
|
||||||
|
isPlaylistPage: pathname.startsWith("/playlist/"),
|
||||||
|
isNewPage: pathname === "/new",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to determine if any sidebar route is active
|
||||||
|
// This prevents highlights on pages not defined in sidebar
|
||||||
|
const isAnySidebarRouteActive = Object.values(routes).some(Boolean);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("pb-6", className)}>
|
<div className={cn("pb-23 relative", className)}>
|
||||||
<div className="space-y-4 py-4">
|
{/* Collapse/Expand Button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="absolute top-2 right-2 z-10 h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4 pt-6">
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<p className="mb-2 px-4 text-lg font-semibold tracking-tight">
|
<p className={cn("mb-2 px-4 text-lg font-semibold tracking-tight", collapsed && "sr-only")}>
|
||||||
Discover
|
Discover
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<Button variant={isRoot ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
<Button
|
||||||
|
variant={routes.isRoot ? "secondary" : "ghost"}
|
||||||
|
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||||
|
title={collapsed ? "Listen Now" : undefined}
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -41,16 +75,20 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
|||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
className="mr-2 h-4 w-4"
|
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||||
>
|
>
|
||||||
<circle cx="12" cy="12" r="10" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
<polygon points="10 8 16 12 10 16 10 8" />
|
<polygon points="10 8 16 12 10 16 10 8" />
|
||||||
</svg>
|
</svg>
|
||||||
Listen Now
|
{!collapsed && "Listen Now"}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/browse">
|
<Link href="/browse">
|
||||||
<Button variant={isBrowse ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
<Button
|
||||||
|
variant={routes.isBrowse ? "secondary" : "ghost"}
|
||||||
|
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||||
|
title={collapsed ? "Browse" : undefined}
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -59,18 +97,22 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
|||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
className="mr-2 h-4 w-4"
|
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||||
>
|
>
|
||||||
<rect width="7" height="7" x="3" y="3" rx="1" />
|
<rect width="7" height="7" x="3" y="3" rx="1" />
|
||||||
<rect width="7" height="7" x="14" y="3" rx="1" />
|
<rect width="7" height="7" x="14" y="3" rx="1" />
|
||||||
<rect width="7" height="7" x="14" y="14" rx="1" />
|
<rect width="7" height="7" x="14" y="14" rx="1" />
|
||||||
<rect width="7" height="7" x="3" y="14" rx="1" />
|
<rect width="7" height="7" x="3" y="14" rx="1" />
|
||||||
</svg>
|
</svg>
|
||||||
Browse
|
{!collapsed && "Browse"}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/search">
|
<Link href="/search">
|
||||||
<Button variant={isSearch ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
<Button
|
||||||
|
variant={routes.isSearch ? "secondary" : "ghost"}
|
||||||
|
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||||
|
title={collapsed ? "Search" : undefined}
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -79,16 +121,20 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
|||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
className="mr-2 h-4 w-4"
|
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||||
>
|
>
|
||||||
<circle cx="11" cy="11" r="8" />
|
<circle cx="11" cy="11" r="8" />
|
||||||
<path d="m21 21-4.35-4.35" />
|
<path d="m21 21-4.35-4.35" />
|
||||||
</svg>
|
</svg>
|
||||||
Search
|
{!collapsed && "Search"}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/queue">
|
<Link href="/queue">
|
||||||
<Button variant={isQueue ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
<Button
|
||||||
|
variant={routes.isQueue ? "secondary" : "ghost"}
|
||||||
|
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||||
|
title={collapsed ? "Queue" : undefined}
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -97,15 +143,19 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
|||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
className="mr-2 h-4 w-4"
|
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||||
>
|
>
|
||||||
<path d="M3 6h18M3 12h18M3 18h18" />
|
<path d="M3 6h18M3 12h18M3 18h18" />
|
||||||
</svg>
|
</svg>
|
||||||
Queue
|
{!collapsed && "Queue"}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/radio">
|
<Link href="/radio">
|
||||||
<Button variant={isRadio ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
<Button
|
||||||
|
variant={routes.isRadio ? "secondary" : "ghost"}
|
||||||
|
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||||
|
title={collapsed ? "Radio" : undefined}
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -114,7 +164,7 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
|||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
className="mr-2 h-4 w-4"
|
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||||
>
|
>
|
||||||
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/>
|
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/>
|
||||||
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/>
|
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/>
|
||||||
@@ -122,19 +172,23 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
|||||||
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/>
|
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/>
|
||||||
<path d="M19.1 4.9C23 8.8 23 15.2 19.1 19.1"/>
|
<path d="M19.1 4.9C23 8.8 23 15.2 19.1 19.1"/>
|
||||||
</svg>
|
</svg>
|
||||||
Radio
|
{!collapsed && "Radio"}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-0 pt-0">
|
||||||
<p className="mb-2 px-4 text-lg font-semibold tracking-tight">
|
<p className={cn("mb-2 px-4 text-lg font-semibold tracking-tight", collapsed && "sr-only")}>
|
||||||
Library
|
Library
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Link href="/library/playlists">
|
<Link href="/library/playlists">
|
||||||
<Button variant={isPlaylists ? "secondary" : "ghost"} className="w-full justify-start mb-1">
|
<Button
|
||||||
|
variant={routes.isPlaylists ? "secondary" : "ghost"}
|
||||||
|
className={cn("w-full justify-start mb-1", collapsed && "justify-center px-2")}
|
||||||
|
title={collapsed ? "Playlists" : undefined}
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -143,7 +197,7 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
|||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
className="mr-2 h-4 w-4"
|
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||||
>
|
>
|
||||||
<path d="M21 15V6" />
|
<path d="M21 15V6" />
|
||||||
<path d="M18.5 18a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z" />
|
<path d="M18.5 18a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z" />
|
||||||
@@ -151,29 +205,59 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
|||||||
<path d="M16 6H3" />
|
<path d="M16 6H3" />
|
||||||
<path d="M12 18H3" />
|
<path d="M12 18H3" />
|
||||||
</svg>
|
</svg>
|
||||||
Playlists
|
{!collapsed && "Playlists"}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/library/songs">
|
<Link href="/library/songs">
|
||||||
<Button variant={isSongs ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
<Button
|
||||||
<svg className="mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
variant={routes.isSongs ? "secondary" : "ghost"}
|
||||||
|
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||||
|
title={collapsed ? "Songs" : undefined}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
<circle cx="8" cy="18" r="4" />
|
<circle cx="8" cy="18" r="4" />
|
||||||
<path d="M12 18V2l7 4" />
|
<path d="M12 18V2l7 4" />
|
||||||
</svg>
|
</svg>
|
||||||
Songs
|
{!collapsed && "Songs"}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/library/artists">
|
<Link href="/library/artists">
|
||||||
<Button variant={isArtists ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
<Button
|
||||||
<svg className="mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" >
|
variant={routes.isArtists ? "secondary" : "ghost"}
|
||||||
|
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||||
|
title={collapsed ? "Artists" : undefined}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
<path d="m12 8-9.04 9.06a2.82 2.82 0 1 0 3.98 3.98L16 12" />
|
<path d="m12 8-9.04 9.06a2.82 2.82 0 1 0 3.98 3.98L16 12" />
|
||||||
<circle cx="17" cy="7" r="5" />
|
<circle cx="17" cy="7" r="5" />
|
||||||
</svg>
|
</svg>
|
||||||
Artists
|
{!collapsed && "Artists"}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/library/albums">
|
<Link href="/library/albums">
|
||||||
<Button variant={isAlbums ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
<Button
|
||||||
|
variant={routes.isAlbums ? "secondary" : "ghost"}
|
||||||
|
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||||
|
title={collapsed ? "Albums" : undefined}
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -182,18 +266,22 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
|||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
className="mr-2 h-4 w-4"
|
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||||
>
|
>
|
||||||
<path d="m16 6 4 14" />
|
<path d="m16 6 4 14" />
|
||||||
<path d="M12 6v14" />
|
<path d="M12 6v14" />
|
||||||
<path d="M8 8v12" />
|
<path d="M8 8v12" />
|
||||||
<path d="M4 4v16" />
|
<path d="M4 4v16" />
|
||||||
</svg>
|
</svg>
|
||||||
Albums
|
{!collapsed && "Albums"}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/history">
|
<Link href="/history">
|
||||||
<Button variant={isHistory ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
<Button
|
||||||
|
variant={routes.isHistory ? "secondary" : "ghost"}
|
||||||
|
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||||
|
title={collapsed ? "History" : undefined}
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -202,17 +290,64 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
|||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
className="mr-2 h-4 w-4"
|
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||||
>
|
>
|
||||||
<path d="M12 2C6.48 2 2 6.48 2 12c0 5.52 4.48 10 10 10 5.52 0 10-4.48 10-10 0-5.52-4.48-10-10-10Z" />
|
<path d="M12 2C6.48 2 2 6.48 2 12c0 5.52 4.48 10 10 10 5.52 0 10-4.48 10-10 0-5.52-4.48-10-10-10Z" />
|
||||||
<path d="M12 8v4l4 2" />
|
<path d="M12 8v4l4 2" />
|
||||||
</svg>
|
</svg>
|
||||||
History
|
{!collapsed && "History"}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/favorites">
|
||||||
|
<Button
|
||||||
|
variant={routes.isFavorites ? "secondary" : "ghost"}
|
||||||
|
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||||
|
title={collapsed ? "Favorites" : undefined}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||||
|
>
|
||||||
|
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||||
|
</svg>
|
||||||
|
{!collapsed && "Favorites"}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="px-3">
|
||||||
|
<div className="space-y-0">
|
||||||
|
<Link href="/settings">
|
||||||
|
<Button
|
||||||
|
variant={routes.isSettings ? "secondary" : "ghost"}
|
||||||
|
className={cn("w-full justify-start mb-2", collapsed && "justify-center px-2")}
|
||||||
|
title={collapsed ? "Settings" : undefined}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={cn("h-4 w-4", !collapsed && "mr-2")}
|
||||||
|
>
|
||||||
|
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
{!collapsed && "Settings"}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
@@ -13,16 +13,18 @@ import {
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { useNavidromeConfig } from '@/app/components/NavidromeConfigContext';
|
import { useNavidromeConfig } from '@/app/components/NavidromeConfigContext';
|
||||||
import { useTheme } from '@/app/components/ThemeProvider';
|
import { useTheme } from '@/app/components/ThemeProvider';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaPalette, FaLastfm } from 'react-icons/fa';
|
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaPalette, FaLastfm, FaBars } from 'react-icons/fa';
|
||||||
|
|
||||||
export function LoginForm({
|
export function LoginForm({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div">) {
|
}: React.ComponentProps<"div">) {
|
||||||
const [step, setStep] = useState<'login' | 'settings'>('login');
|
const [step, setStep] = useState<'login' | 'settings'>('login');
|
||||||
|
const [canSkipNavidrome, setCanSkipNavidrome] = useState(false);
|
||||||
const { config, updateConfig, testConnection } = useNavidromeConfig();
|
const { config, updateConfig, testConnection } = useNavidromeConfig();
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -43,6 +45,85 @@ export function LoginForm({
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// New settings
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return localStorage.getItem('sidebar-collapsed') === 'true';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [standaloneLastfmEnabled, setStandaloneLastfmEnabled] = useState(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return localStorage.getItem('standalone-lastfm-enabled') === 'true';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if Navidrome is configured via environment variables
|
||||||
|
const hasEnvConfig = React.useMemo(() => {
|
||||||
|
return !!(process.env.NEXT_PUBLIC_NAVIDROME_URL &&
|
||||||
|
process.env.NEXT_PUBLIC_NAVIDROME_USERNAME &&
|
||||||
|
process.env.NEXT_PUBLIC_NAVIDROME_PASSWORD);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Check if Navidrome is already working on component mount
|
||||||
|
const checkNavidromeConnection = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
// First check if there's a working API instance
|
||||||
|
const { getNavidromeAPI } = await import('@/lib/navidrome');
|
||||||
|
const api = getNavidromeAPI();
|
||||||
|
|
||||||
|
if (api) {
|
||||||
|
// Test the existing API
|
||||||
|
const success = await api.ping();
|
||||||
|
if (success) {
|
||||||
|
setCanSkipNavidrome(true);
|
||||||
|
|
||||||
|
// Get the current config to populate form
|
||||||
|
if (config.serverUrl && config.username && config.password) {
|
||||||
|
setFormData({
|
||||||
|
serverUrl: config.serverUrl,
|
||||||
|
username: config.username,
|
||||||
|
password: config.password
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is first-time setup and Navidrome is working, skip to settings
|
||||||
|
const hasCompletedOnboarding = localStorage.getItem('onboarding-completed');
|
||||||
|
if (!hasCompletedOnboarding) {
|
||||||
|
setStep('settings');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no working API, check if we have config that just needs testing
|
||||||
|
if (config.serverUrl && config.username && config.password) {
|
||||||
|
const success = await testConnection(config);
|
||||||
|
if (success) {
|
||||||
|
setCanSkipNavidrome(true);
|
||||||
|
setFormData({
|
||||||
|
serverUrl: config.serverUrl,
|
||||||
|
username: config.username,
|
||||||
|
password: config.password
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasCompletedOnboarding = localStorage.getItem('onboarding-completed');
|
||||||
|
if (!hasCompletedOnboarding) {
|
||||||
|
setStep('settings');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Navidrome connection check failed, will show config step');
|
||||||
|
}
|
||||||
|
}, [config, setStep, setFormData, setCanSkipNavidrome, testConnection]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkNavidromeConnection();
|
||||||
|
}, [checkNavidromeConnection]);
|
||||||
|
|
||||||
const handleInputChange = (field: string, value: string) => {
|
const handleInputChange = (field: string, value: string) => {
|
||||||
setFormData(prev => ({ ...prev, [field]: value }));
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
};
|
};
|
||||||
@@ -104,8 +185,13 @@ export function LoginForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleFinishSetup = () => {
|
const handleFinishSetup = () => {
|
||||||
// Save scrobbling preference
|
// Save all settings
|
||||||
localStorage.setItem('lastfm-scrobbling-enabled', scrobblingEnabled.toString());
|
localStorage.setItem('lastfm-scrobbling-enabled', scrobblingEnabled.toString());
|
||||||
|
localStorage.setItem('sidebar-collapsed', sidebarCollapsed.toString());
|
||||||
|
localStorage.setItem('standalone-lastfm-enabled', standaloneLastfmEnabled.toString());
|
||||||
|
|
||||||
|
// Mark onboarding as complete
|
||||||
|
localStorage.setItem('onboarding-completed', '1.1.0');
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Setup Complete",
|
title: "Setup Complete",
|
||||||
@@ -126,7 +212,9 @@ export function LoginForm({
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FaPalette className="w-5 h-5" />
|
||||||
Customize Your Experience
|
Customize Your Experience
|
||||||
|
{canSkipNavidrome && <Badge variant="outline">Step 1 of 1</Badge>}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Configure your preferences to get started
|
Configure your preferences to get started
|
||||||
@@ -155,6 +243,29 @@ export function LoginForm({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar Settings */}
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<FaBars className="w-4 h-4" />
|
||||||
|
Sidebar Layout
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={sidebarCollapsed ? "collapsed" : "expanded"}
|
||||||
|
onValueChange={(value) => setSidebarCollapsed(value === "collapsed")}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="expanded">Expanded (with labels)</SelectItem>
|
||||||
|
<SelectItem value="collapsed">Collapsed (icons only)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
You can always toggle this later using the button in the sidebar
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Last.fm Scrobbling */}
|
{/* Last.fm Scrobbling */}
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<Label className="flex items-center gap-2">
|
<Label className="flex items-center gap-2">
|
||||||
@@ -180,18 +291,45 @@ export function LoginForm({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Standalone Last.fm */}
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<FaLastfm className="w-4 h-4" />
|
||||||
|
Standalone Last.fm (Advanced)
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={standaloneLastfmEnabled ? "enabled" : "disabled"}
|
||||||
|
onValueChange={(value) => setStandaloneLastfmEnabled(value === "enabled")}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="enabled">Enabled</SelectItem>
|
||||||
|
<SelectItem value="disabled">Disabled</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{standaloneLastfmEnabled
|
||||||
|
? "Direct Last.fm API integration (configure in Settings later)"
|
||||||
|
: "Use only Navidrome's Last.fm integration"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Button onClick={handleFinishSetup} className="w-full">
|
<Button onClick={handleFinishSetup} className="w-full">
|
||||||
<FaCheck className="w-4 h-4 mr-2" />
|
<FaCheck className="w-4 h-4 mr-2" />
|
||||||
Complete Setup
|
Complete Setup
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{!hasEnvConfig && (
|
||||||
variant="outline"
|
<Button
|
||||||
className="w-full"
|
variant="outline"
|
||||||
onClick={() => setStep('login')}
|
className="w-full"
|
||||||
>
|
onClick={() => setStep('login')}
|
||||||
Back to Connection Settings
|
>
|
||||||
</Button>
|
{canSkipNavidrome ? "Review Connection Settings" : "Back to Connection Settings"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -205,10 +343,17 @@ export function LoginForm({
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FaServer className="w-5 h-5" />
|
||||||
Connect to Navidrome
|
Connect to Navidrome
|
||||||
|
{canSkipNavidrome && <Badge variant="outline">{hasEnvConfig ? "Configured via .env" : "Already Connected"}</Badge>}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Enter your Navidrome server details to get started
|
{canSkipNavidrome
|
||||||
|
? hasEnvConfig
|
||||||
|
? "Your Navidrome connection is configured via environment variables."
|
||||||
|
: "Your Navidrome connection is working. You can proceed to customize your settings."
|
||||||
|
: "Enter your Navidrome server details to get started"
|
||||||
|
}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -269,6 +414,17 @@ export function LoginForm({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{canSkipNavidrome && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setStep('settings')}
|
||||||
|
>
|
||||||
|
Skip to Settings
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
301
app/favorites/page.tsx
Normal file
301
app/favorites/page.tsx
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { useNavidrome } from "@/app/components/NavidromeContext";
|
||||||
|
import { AlbumArtwork } from "@/app/components/album-artwork";
|
||||||
|
import { ArtistIcon } from "@/app/components/artist-icon";
|
||||||
|
import { Album, Artist, Song } from "@/lib/navidrome";
|
||||||
|
import { Heart, Music, Disc, Mic, Play } from "lucide-react";
|
||||||
|
import { useAudioPlayer, Track } from "@/app/components/AudioPlayerContext";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
const FavoritesPage = () => {
|
||||||
|
const { api, isConnected } = useNavidrome();
|
||||||
|
const { playTrack, addToQueue } = useAudioPlayer();
|
||||||
|
const [favoriteAlbums, setFavoriteAlbums] = useState<Album[]>([]);
|
||||||
|
const [favoriteSongs, setFavoriteSongs] = useState<Song[]>([]);
|
||||||
|
const [favoriteArtists, setFavoriteArtists] = useState<Artist[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadFavorites = async () => {
|
||||||
|
if (!api || !isConnected) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [albums, songs, artists] = await Promise.all([
|
||||||
|
api.getAlbums('starred', 100),
|
||||||
|
api.getStarred2(),
|
||||||
|
api.getArtists()
|
||||||
|
]);
|
||||||
|
|
||||||
|
setFavoriteAlbums(albums);
|
||||||
|
|
||||||
|
// Filter starred songs and artists from the starred2 response
|
||||||
|
if (songs.starred2) {
|
||||||
|
setFavoriteSongs(songs.starred2.song || []);
|
||||||
|
setFavoriteArtists((songs.starred2.artist || []).filter((artist: Artist) => artist.starred));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load favorites:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadFavorites();
|
||||||
|
}, [api, isConnected]);
|
||||||
|
|
||||||
|
const handlePlaySong = (song: Song) => {
|
||||||
|
playTrack({
|
||||||
|
id: song.id,
|
||||||
|
name: song.title,
|
||||||
|
artist: song.artist,
|
||||||
|
album: song.album,
|
||||||
|
albumId: song.albumId,
|
||||||
|
artistId: song.artistId,
|
||||||
|
url: api?.getStreamUrl(song.id) || '',
|
||||||
|
duration: song.duration,
|
||||||
|
coverArt: song.coverArt ? api?.getCoverArtUrl(song.coverArt) : undefined,
|
||||||
|
starred: !!song.starred
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlayAlbum = async (album: Album) => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const songs = await api.getAlbumSongs(album.id);
|
||||||
|
if (songs.length > 0) {
|
||||||
|
const tracks = songs.map((song: Song) => ({
|
||||||
|
id: song.id,
|
||||||
|
name: song.title,
|
||||||
|
artist: song.artist,
|
||||||
|
album: song.album,
|
||||||
|
albumId: song.albumId,
|
||||||
|
artistId: song.artistId,
|
||||||
|
url: api.getStreamUrl(song.id),
|
||||||
|
duration: song.duration,
|
||||||
|
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt) : undefined,
|
||||||
|
starred: !!song.starred
|
||||||
|
}));
|
||||||
|
|
||||||
|
playTrack(tracks[0]);
|
||||||
|
tracks.slice(1).forEach((track: Track) => addToQueue(track));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to play album:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFavorite = async (id: string, type: 'song' | 'album' | 'artist', isStarred: boolean) => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isStarred) {
|
||||||
|
await api.unstar(id, type);
|
||||||
|
} else {
|
||||||
|
await api.star(id, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh favorites
|
||||||
|
if (type === 'album') {
|
||||||
|
const albums = await api.getAlbums('starred', 100);
|
||||||
|
setFavoriteAlbums(albums);
|
||||||
|
} else if (type === 'song') {
|
||||||
|
const songs = await api.getStarred2();
|
||||||
|
setFavoriteSongs(songs.starred2?.song || []);
|
||||||
|
} else if (type === 'artist') {
|
||||||
|
const songs = await api.getStarred2();
|
||||||
|
setFavoriteArtists((songs.starred2?.artist || []).filter((artist: Artist) => artist.starred));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to toggle favorite:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isConnected) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-muted-foreground">Please connect to your Navidrome server to view favorites.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6 pb-24">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight">Favorites</h1>
|
||||||
|
<p className="text-muted-foreground">Your starred albums, songs, and artists</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="albums" className="space-y-6">
|
||||||
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
|
<TabsTrigger value="albums" className="flex items-center gap-2">
|
||||||
|
<Disc className="w-4 h-4" />
|
||||||
|
Albums ({favoriteAlbums.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="songs" className="flex items-center gap-2">
|
||||||
|
<Music className="w-4 h-4" />
|
||||||
|
Songs ({favoriteSongs.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="artists" className="flex items-center gap-2">
|
||||||
|
<Mic className="w-4 h-4" />
|
||||||
|
Artists ({favoriteArtists.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="albums" className="space-y-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-muted-foreground">Loading favorite albums...</p>
|
||||||
|
</div>
|
||||||
|
) : favoriteAlbums.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Heart className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||||||
|
<p className="text-muted-foreground">No favorite albums yet</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">Star albums to see them here</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
|
{favoriteAlbums.map((album) => (
|
||||||
|
<Card key={album.id} className="overflow-hidden">
|
||||||
|
<div className="aspect-square relative group">
|
||||||
|
{album.coverArt && api ? (
|
||||||
|
<Image
|
||||||
|
src={api.getCoverArtUrl(album.coverArt)}
|
||||||
|
alt={album.name}
|
||||||
|
fill
|
||||||
|
className="w-full h-full object-cover rounded"
|
||||||
|
sizes="(max-width: 768px) 100vw, 300px"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-muted rounded flex items-center justify-center">
|
||||||
|
<Disc className="w-12 h-12 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||||
|
<Play className="w-12 h-12 mx-auto hidden group-hover:block" onClick={() => handlePlayAlbum(album)}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<h3 className="font-semibold truncate">{album.name}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground truncate">{album.artist}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{album.songCount} songs • {Math.floor(album.duration / 60)} min
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="songs" className="space-y-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-muted-foreground">Loading favorite songs...</p>
|
||||||
|
</div>
|
||||||
|
) : favoriteSongs.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Heart className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||||||
|
<p className="text-muted-foreground">No favorite songs yet</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">Star songs to see them here</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{favoriteSongs.map((song, index) => (
|
||||||
|
<div key={song.id} className="flex items-center gap-4 p-3 rounded-lg hover:bg-muted/50 group">
|
||||||
|
<div className="w-8 text-sm text-muted-foreground text-center">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 relative flex-shrink-0">
|
||||||
|
{song.coverArt && api ? (
|
||||||
|
<Image
|
||||||
|
src={api.getCoverArtUrl(song.coverArt)}
|
||||||
|
alt={song.album}
|
||||||
|
fill
|
||||||
|
className="rounded object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-muted rounded flex items-center justify-center">
|
||||||
|
<Music className="w-6 h-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium truncate">{song.title}</p>
|
||||||
|
<p className="text-sm text-muted-foreground truncate">{song.artist}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{song.album}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100">
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => handlePlaySong(song)}>
|
||||||
|
Play
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => toggleFavorite(song.id, 'song', !!song.starred)}
|
||||||
|
>
|
||||||
|
<Heart className={`w-4 h-4 ${song.starred ? 'fill-red-500 text-red-500' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="artists" className="space-y-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-muted-foreground">Loading favorite artists...</p>
|
||||||
|
</div>
|
||||||
|
) : favoriteArtists.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Heart className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||||||
|
<p className="text-muted-foreground">No favorite artists yet</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">Star artists to see them here</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-7">
|
||||||
|
{favoriteArtists.map((artist) => (
|
||||||
|
<Card key={artist.id} className="overflow-hidden">
|
||||||
|
<CardContent className="p-3 text-center">
|
||||||
|
<div className="w-24 h-24 mx-auto mb-4">
|
||||||
|
<Image
|
||||||
|
src={artist.coverArt && api ? api.getCoverArtUrl(artist.coverArt, 200) : '/placeholder-artist.png'}
|
||||||
|
alt={artist.name}
|
||||||
|
width={250}
|
||||||
|
height={250}
|
||||||
|
className="object-cover w-full h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold truncate">{artist.name}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{artist.albumCount} albums
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FavoritesPage;
|
||||||
@@ -15,6 +15,10 @@ body {
|
|||||||
.animate-scroll {
|
.animate-scroll {
|
||||||
animation: scroll 8s linear infinite;
|
animation: scroll 8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.animate-infinite-scroll {
|
||||||
|
animation: infiniteScroll 10s linear infinite;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes scroll {
|
@keyframes scroll {
|
||||||
@@ -26,6 +30,15 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes infiniteScroll {
|
||||||
|
0% {
|
||||||
|
transform: translateX(15%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(-215%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 240 10% 3.9%;
|
--background: 240 10% 3.9%;
|
||||||
@@ -284,9 +297,10 @@ body {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
:focus-visible { outline-color: var(rgb(59 130 246)); }
|
:focus-visible { outline-color: rgb(59, 130, 246); }
|
||||||
::selection { background-color: var(rgb(59 130 246)); }
|
::selection { background-color: rgb(59, 130, 246); }
|
||||||
::marker { color: var(rgb(59 130 246)); }
|
::marker { color: rgb(59, 130, 246); }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
|
|||||||
@@ -7,17 +7,34 @@ import { Separator } from "@/components/ui/separator";
|
|||||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { ArtistIcon } from '@/app/components/artist-icon';
|
import { ArtistIcon } from '@/app/components/artist-icon';
|
||||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||||
import { Artist } from '@/lib/navidrome';
|
import { Artist } from '@/lib/navidrome';
|
||||||
import Loading from '@/app/components/loading';
|
import Loading from '@/app/components/loading';
|
||||||
import { Search } from 'lucide-react';
|
import { Search, Heart } from 'lucide-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
export default function ArtistPage() {
|
export default function ArtistPage() {
|
||||||
const { artists, isLoading } = useNavidrome();
|
const { artists, isLoading, api, starItem, unstarItem } = useNavidrome();
|
||||||
const [filteredArtists, setFilteredArtists] = useState<Artist[]>([]);
|
const [filteredArtists, setFilteredArtists] = useState<Artist[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [sortBy, setSortBy] = useState<'name' | 'albumCount'>('name');
|
const [sortBy, setSortBy] = useState<'name' | 'albumCount'>('name');
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const toggleFavorite = async (artistId: string, isStarred: boolean) => {
|
||||||
|
if (isStarred) {
|
||||||
|
await unstarItem(artistId, 'artist');
|
||||||
|
} else {
|
||||||
|
await starItem(artistId, 'artist');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewArtist = (artist: Artist) => {
|
||||||
|
router.push(`/artist/${artist.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (artists.length > 0) {
|
if (artists.length > 0) {
|
||||||
@@ -87,14 +104,29 @@ export default function ArtistPage() {
|
|||||||
<Separator className="my-4" />
|
<Separator className="my-4" />
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<ScrollArea>
|
<ScrollArea>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4 pb-4">
|
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 cursor-pointer">
|
||||||
{filteredArtists.map((artist) => (
|
{filteredArtists.map((artist) => (
|
||||||
<ArtistIcon
|
<Card key={artist.id} className="overflow-hidden">
|
||||||
key={artist.id}
|
<div className="aspect-square relative group cursor-pointer" onClick={() => handleViewArtist(artist)}>
|
||||||
artist={artist}
|
<div className="w-full h-full">
|
||||||
className="flex justify-center"
|
<Image
|
||||||
size={150}
|
src={artist.coverArt && api ? api.getCoverArtUrl(artist.coverArt, 200) : '/placeholder-artist.png'}
|
||||||
/>
|
alt={artist.name}
|
||||||
|
width={290}
|
||||||
|
height={290}
|
||||||
|
className="object-cover w-full h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<h3 className="font-semibold truncate">{artist.name}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{artist.albumCount} albums
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<ScrollBar orientation="horizontal" />
|
<ScrollBar orientation="horizontal" />
|
||||||
|
|||||||
@@ -116,7 +116,8 @@ export default function SongsPage() {
|
|||||||
duration: song.duration,
|
duration: song.duration,
|
||||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||||
albumId: song.albumId,
|
albumId: song.albumId,
|
||||||
artistId: song.artistId
|
artistId: song.artistId,
|
||||||
|
starred: !!song.starred
|
||||||
};
|
};
|
||||||
|
|
||||||
playTrack(track);
|
playTrack(track);
|
||||||
@@ -136,7 +137,8 @@ export default function SongsPage() {
|
|||||||
duration: song.duration,
|
duration: song.duration,
|
||||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||||
albumId: song.albumId,
|
albumId: song.albumId,
|
||||||
artistId: song.artistId
|
artistId: song.artistId,
|
||||||
|
starred: !!song.starred
|
||||||
};
|
};
|
||||||
|
|
||||||
addToQueue(track);
|
addToQueue(track);
|
||||||
|
|||||||
66
app/page.tsx
66
app/page.tsx
@@ -11,9 +11,11 @@ import { useNavidromeConfig } from './components/NavidromeConfigContext';
|
|||||||
|
|
||||||
type TimeOfDay = 'morning' | 'afternoon' | 'evening';
|
type TimeOfDay = 'morning' | 'afternoon' | 'evening';
|
||||||
export default function MusicPage() {
|
export default function MusicPage() {
|
||||||
const { albums, isLoading } = useNavidrome();
|
const { albums, isLoading, api, isConnected } = useNavidrome();
|
||||||
const [recentAlbums, setRecentAlbums] = useState<Album[]>([]);
|
const [recentAlbums, setRecentAlbums] = useState<Album[]>([]);
|
||||||
const [newestAlbums, setNewestAlbums] = useState<Album[]>([]);
|
const [newestAlbums, setNewestAlbums] = useState<Album[]>([]);
|
||||||
|
const [favoriteAlbums, setFavoriteAlbums] = useState<Album[]>([]);
|
||||||
|
const [favoritesLoading, setFavoritesLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (albums.length > 0) {
|
if (albums.length > 0) {
|
||||||
@@ -25,6 +27,24 @@ export default function MusicPage() {
|
|||||||
}
|
}
|
||||||
}, [albums]);
|
}, [albums]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadFavoriteAlbums = async () => {
|
||||||
|
if (!api || !isConnected) return;
|
||||||
|
|
||||||
|
setFavoritesLoading(true);
|
||||||
|
try {
|
||||||
|
const starredAlbums = await api.getAlbums('starred', 20); // Limit to 20 for homepage
|
||||||
|
setFavoriteAlbums(starredAlbums);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load favorite albums:', error);
|
||||||
|
} finally {
|
||||||
|
setFavoritesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadFavoriteAlbums();
|
||||||
|
}, [api, isConnected]);
|
||||||
|
|
||||||
// Get greeting and time of day
|
// Get greeting and time of day
|
||||||
const hour = new Date().getHours();
|
const hour = new Date().getHours();
|
||||||
const greeting = hour < 12 ? 'Good morning' : 'Good afternoon';
|
const greeting = hour < 12 ? 'Good morning' : 'Good afternoon';
|
||||||
@@ -88,7 +108,7 @@ export default function MusicPage() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
// Loading skeletons
|
// Loading skeletons
|
||||||
Array.from({ length: 6 }).map((_, i) => (
|
Array.from({ length: 6 }).map((_, i) => (
|
||||||
<div key={i} className="w-[220px] h-[220px] bg-muted animate-pulse rounded-md flex-shrink-0" />
|
<div key={i} className="w-[220px] h-[320px] bg-muted animate-pulse rounded-md flex-shrink-0" />
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
recentAlbums.map((album) => (
|
recentAlbums.map((album) => (
|
||||||
@@ -106,6 +126,46 @@ export default function MusicPage() {
|
|||||||
<ScrollBar orientation="horizontal" />
|
<ScrollBar orientation="horizontal" />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Favorite Albums Section */}
|
||||||
|
{favoriteAlbums.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="mt-6 space-y-1">
|
||||||
|
<p className="text-2xl font-semibold tracking-tight">
|
||||||
|
Favorite Albums
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Your starred albums collection.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<div className="relative">
|
||||||
|
<ScrollArea>
|
||||||
|
<div className="flex space-x-4 pb-4">
|
||||||
|
{favoritesLoading ? (
|
||||||
|
// Loading skeletons
|
||||||
|
Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="w-[220px] h-[320px] bg-muted animate-pulse rounded-md flex-shrink-0" />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
favoriteAlbums.map((album) => (
|
||||||
|
<AlbumArtwork
|
||||||
|
key={album.id}
|
||||||
|
album={album}
|
||||||
|
className="w-[220px] flex-shrink-0"
|
||||||
|
aspectRatio="square"
|
||||||
|
width={220}
|
||||||
|
height={220}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ScrollBar orientation="horizontal" />
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-6 space-y-1">
|
<div className="mt-6 space-y-1">
|
||||||
<p className="text-2xl font-semibold tracking-tight">
|
<p className="text-2xl font-semibold tracking-tight">
|
||||||
Your Library
|
Your Library
|
||||||
@@ -121,7 +181,7 @@ export default function MusicPage() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
// Loading skeletons
|
// Loading skeletons
|
||||||
Array.from({ length: 10 }).map((_, i) => (
|
Array.from({ length: 10 }).map((_, i) => (
|
||||||
<div key={i} className="w-[220px] h-[200px] bg-muted animate-pulse rounded-md flex-shrink-0" />
|
<div key={i} className="w-[220px] h-[320px] bg-muted animate-pulse rounded-md flex-shrink-0" />
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
newestAlbums.map((album) => (
|
newestAlbums.map((album) => (
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ export default function PlaylistPage() {
|
|||||||
duration: song.duration,
|
duration: song.duration,
|
||||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||||
albumId: song.albumId,
|
albumId: song.albumId,
|
||||||
artistId: song.artistId
|
artistId: song.artistId,
|
||||||
|
starred: !!song.starred
|
||||||
};
|
};
|
||||||
playTrack(track);
|
playTrack(track);
|
||||||
};
|
};
|
||||||
@@ -78,7 +79,8 @@ export default function PlaylistPage() {
|
|||||||
duration: song.duration,
|
duration: song.duration,
|
||||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||||
albumId: song.albumId,
|
albumId: song.albumId,
|
||||||
artistId: song.artistId
|
artistId: song.artistId,
|
||||||
|
starred: !!song.starred
|
||||||
};
|
};
|
||||||
addToQueue(track);
|
addToQueue(track);
|
||||||
};
|
};
|
||||||
@@ -98,7 +100,8 @@ export default function PlaylistPage() {
|
|||||||
duration: song.duration,
|
duration: song.duration,
|
||||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||||
albumId: song.albumId,
|
albumId: song.albumId,
|
||||||
artistId: song.artistId
|
artistId: song.artistId,
|
||||||
|
starred: !!song.starred
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Play the first track and add the rest to queue
|
// Play the first track and add the rest to queue
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Play, X, User, Disc, Trash2, SkipForward } from 'lucide-react';
|
import { Play, X, Disc, Trash2, SkipForward } from 'lucide-react';
|
||||||
|
|
||||||
const QueuePage: React.FC = () => {
|
const QueuePage: React.FC = () => {
|
||||||
const { queue, currentTrack, removeTrackFromQueue, clearQueue, skipToTrackInQueue } = useAudioPlayer();
|
const { queue, currentTrack, removeTrackFromQueue, clearQueue, skipToTrackInQueue } = useAudioPlayer();
|
||||||
@@ -46,7 +46,7 @@ const QueuePage: React.FC = () => {
|
|||||||
{currentTrack && (
|
{currentTrack && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h2 className="text-lg font-semibold">Now Playing</h2>
|
<h2 className="text-lg font-semibold">Now Playing</h2>
|
||||||
<div className="p-4 bg-accent/30 rounded-lg border-l-4 border-primary">
|
<div className="p-4 bg-accent/30 rounded-lg">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{/* Album Art */}
|
{/* Album Art */}
|
||||||
<div className="w-16 h-16 mr-4 flex-shrink-0">
|
<div className="w-16 h-16 mr-4 flex-shrink-0">
|
||||||
@@ -65,21 +65,13 @@ const QueuePage: React.FC = () => {
|
|||||||
<p className="font-semibold text-lg text-primary truncate">
|
<p className="font-semibold text-lg text-primary truncate">
|
||||||
{currentTrack.name}
|
{currentTrack.name}
|
||||||
</p>
|
</p>
|
||||||
<div className="w-3 h-3 bg-primary rounded-full animate-pulse flex-shrink-0" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center text-sm text-muted-foreground space-x-4">
|
<div className="flex items-center text-sm text-muted-foreground space-x-4">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<User className="w-3 h-3" />
|
|
||||||
<Link href={`/artist/${currentTrack.artistId}`} className="truncate hover:text-primary hover:underline">
|
<Link href={`/artist/${currentTrack.artistId}`} className="truncate hover:text-primary hover:underline">
|
||||||
{currentTrack.artist}
|
{currentTrack.artist}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Disc className="w-3 h-3" />
|
|
||||||
<Link href={`/album/${currentTrack.albumId}`} className="truncate hover:text-primary hover:underline">
|
|
||||||
{currentTrack.album}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -122,14 +114,8 @@ const QueuePage: React.FC = () => {
|
|||||||
className="group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors"
|
className="group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors"
|
||||||
onClick={() => skipToTrackInQueue(index)}
|
onClick={() => skipToTrackInQueue(index)}
|
||||||
>
|
>
|
||||||
{/* Track Number / Play Indicator */}
|
{/* Album Art with Play Indicator */}
|
||||||
<div className="w-8 text-center text-sm text-muted-foreground mr-3">
|
<div className="w-12 h-12 mr-4 flex-shrink-0 relative">
|
||||||
<span className="group-hover:hidden">{index + 1}</span>
|
|
||||||
<Play className="w-4 h-4 mx-auto hidden group-hover:block" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Album Art */}
|
|
||||||
<div className="w-12 h-12 mr-4 flex-shrink-0">
|
|
||||||
<Image
|
<Image
|
||||||
src={track.coverArt || '/default-user.jpg'}
|
src={track.coverArt || '/default-user.jpg'}
|
||||||
alt={track.album}
|
alt={track.album}
|
||||||
@@ -137,6 +123,9 @@ const QueuePage: React.FC = () => {
|
|||||||
height={48}
|
height={48}
|
||||||
className="w-full h-full object-cover rounded-md"
|
className="w-full h-full object-cover rounded-md"
|
||||||
/>
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/50 rounded-md opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<Play className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Song Info */}
|
{/* Song Info */}
|
||||||
@@ -146,7 +135,6 @@ const QueuePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center text-sm text-muted-foreground space-x-4">
|
<div className="flex items-center text-sm text-muted-foreground space-x-4">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<User className="w-3 h-3" />
|
|
||||||
<Link
|
<Link
|
||||||
href={`/artist/${track.artistId}`}
|
href={`/artist/${track.artistId}`}
|
||||||
className="truncate hover:text-primary hover:underline"
|
className="truncate hover:text-primary hover:underline"
|
||||||
@@ -155,16 +143,6 @@ const QueuePage: React.FC = () => {
|
|||||||
{track.artist}
|
{track.artist}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Disc className="w-3 h-3" />
|
|
||||||
<Link
|
|
||||||
href={`/album/${track.albumId}`}
|
|
||||||
className="truncate hover:text-primary hover:underline"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{track.album}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ export default function SearchPage() {
|
|||||||
duration: song.duration,
|
duration: song.duration,
|
||||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||||
albumId: song.albumId,
|
albumId: song.albumId,
|
||||||
artistId: song.artistId
|
artistId: song.artistId,
|
||||||
|
starred: !!song.starred
|
||||||
};
|
};
|
||||||
|
|
||||||
playTrack(track);
|
playTrack(track);
|
||||||
@@ -86,7 +87,8 @@ export default function SearchPage() {
|
|||||||
duration: song.duration,
|
duration: song.duration,
|
||||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||||
albumId: song.albumId,
|
albumId: song.albumId,
|
||||||
artistId: song.artistId
|
artistId: song.artistId,
|
||||||
|
starred: !!song.starred
|
||||||
};
|
};
|
||||||
|
|
||||||
addToQueue(track);
|
addToQueue(track);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -9,12 +9,15 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { useTheme } from '@/app/components/ThemeProvider';
|
import { useTheme } from '@/app/components/ThemeProvider';
|
||||||
import { useNavidromeConfig } from '@/app/components/NavidromeConfigContext';
|
import { useNavidromeConfig } from '@/app/components/NavidromeConfigContext';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm } from 'react-icons/fa';
|
import { useStandaloneLastFm } from '@/hooks/use-standalone-lastfm';
|
||||||
|
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog } from 'react-icons/fa';
|
||||||
|
import { Settings, ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
const SettingsPage = () => {
|
const SettingsPage = () => {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { config, updateConfig, isConnected, testConnection, clearConfig } = useNavidromeConfig();
|
const { config, updateConfig, isConnected, testConnection, clearConfig } = useNavidromeConfig();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { isEnabled: isStandaloneLastFmEnabled, getCredentials, getAuthUrl, getSessionKey } = useStandaloneLastFm();
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
serverUrl: config.serverUrl,
|
serverUrl: config.serverUrl,
|
||||||
@@ -24,7 +27,7 @@ const SettingsPage = () => {
|
|||||||
const [isTesting, setIsTesting] = useState(false);
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
|
|
||||||
// Last.fm scrobbling settings
|
// Last.fm scrobbling settings (Navidrome integration)
|
||||||
const [scrobblingEnabled, setScrobblingEnabled] = useState(() => {
|
const [scrobblingEnabled, setScrobblingEnabled] = useState(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
return localStorage.getItem('lastfm-scrobbling-enabled') === 'true';
|
return localStorage.getItem('lastfm-scrobbling-enabled') === 'true';
|
||||||
@@ -32,6 +35,49 @@ const SettingsPage = () => {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Standalone Last.fm settings
|
||||||
|
const [standaloneLastFmEnabled, setStandaloneLastFmEnabled] = useState(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return localStorage.getItem('standalone-lastfm-enabled') === 'true';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [lastFmCredentials, setLastFmCredentials] = useState({
|
||||||
|
apiKey: '',
|
||||||
|
apiSecret: '',
|
||||||
|
sessionKey: '',
|
||||||
|
username: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if Navidrome is configured via environment variables
|
||||||
|
const hasEnvConfig = React.useMemo(() => {
|
||||||
|
return !!(process.env.NEXT_PUBLIC_NAVIDROME_URL &&
|
||||||
|
process.env.NEXT_PUBLIC_NAVIDROME_USERNAME &&
|
||||||
|
process.env.NEXT_PUBLIC_NAVIDROME_PASSWORD);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sidebar settings
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return localStorage.getItem('sidebar-collapsed') === 'true';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load Last.fm credentials on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const credentials = getCredentials();
|
||||||
|
if (credentials) {
|
||||||
|
setLastFmCredentials({
|
||||||
|
apiKey: credentials.apiKey,
|
||||||
|
apiSecret: credentials.apiSecret,
|
||||||
|
sessionKey: credentials.sessionKey || '',
|
||||||
|
username: credentials.username || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [getCredentials]);
|
||||||
|
|
||||||
const handleInputChange = (field: string, value: string) => {
|
const handleInputChange = (field: string, value: string) => {
|
||||||
setFormData(prev => ({ ...prev, [field]: value }));
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
setHasUnsavedChanges(true);
|
setHasUnsavedChanges(true);
|
||||||
@@ -134,24 +180,116 @@ const SettingsPage = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStandaloneLastFmToggle = (enabled: boolean) => {
|
||||||
|
setStandaloneLastFmEnabled(enabled);
|
||||||
|
localStorage.setItem('standalone-lastfm-enabled', enabled.toString());
|
||||||
|
toast({
|
||||||
|
title: enabled ? "Standalone Last.fm Enabled" : "Standalone Last.fm Disabled",
|
||||||
|
description: enabled
|
||||||
|
? "Direct Last.fm integration enabled"
|
||||||
|
: "Standalone Last.fm integration disabled",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSidebarToggle = (collapsed: boolean) => {
|
||||||
|
setSidebarCollapsed(collapsed);
|
||||||
|
localStorage.setItem('sidebar-collapsed', collapsed.toString());
|
||||||
|
toast({
|
||||||
|
title: collapsed ? "Sidebar Collapsed" : "Sidebar Expanded",
|
||||||
|
description: collapsed
|
||||||
|
? "Sidebar will show only icons"
|
||||||
|
: "Sidebar will show full labels",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger a custom event to notify the sidebar component
|
||||||
|
window.dispatchEvent(new CustomEvent('sidebar-toggle', { detail: { collapsed } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLastFmAuth = () => {
|
||||||
|
if (!lastFmCredentials.apiKey) {
|
||||||
|
toast({
|
||||||
|
title: "API Key Required",
|
||||||
|
description: "Please enter your Last.fm API key first.",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authUrl = getAuthUrl(lastFmCredentials.apiKey);
|
||||||
|
window.open(authUrl, '_blank');
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Last.fm Authorization",
|
||||||
|
description: "Please authorize the application in the opened window and return here.",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLastFmCredentialsSave = () => {
|
||||||
|
if (!lastFmCredentials.apiKey || !lastFmCredentials.apiSecret) {
|
||||||
|
toast({
|
||||||
|
title: "Missing Credentials",
|
||||||
|
description: "Please enter both API key and secret.",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('lastfm-credentials', JSON.stringify(lastFmCredentials));
|
||||||
|
toast({
|
||||||
|
title: "Credentials Saved",
|
||||||
|
description: "Last.fm credentials have been saved locally.",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLastFmSessionComplete = async (token: string) => {
|
||||||
|
try {
|
||||||
|
const { sessionKey, username } = await getSessionKey(
|
||||||
|
token,
|
||||||
|
lastFmCredentials.apiKey,
|
||||||
|
lastFmCredentials.apiSecret
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedCredentials = {
|
||||||
|
...lastFmCredentials,
|
||||||
|
sessionKey,
|
||||||
|
username
|
||||||
|
};
|
||||||
|
|
||||||
|
setLastFmCredentials(updatedCredentials);
|
||||||
|
localStorage.setItem('lastfm-credentials', JSON.stringify(updatedCredentials));
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Last.fm Authentication Complete",
|
||||||
|
description: `Successfully authenticated as ${username}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Authentication Failed",
|
||||||
|
description: error instanceof Error ? error.message : "Failed to complete Last.fm authentication",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6 max-w-2xl">
|
<div className="container mx-auto p-6 pb-24 max-w-2xl">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-semibold tracking-tight">Settings</h1>
|
<h1 className="text-3xl font-semibold tracking-tight">Settings</h1>
|
||||||
<p className="text-muted-foreground">Customize your music experience</p>
|
<p className="text-muted-foreground">Customize your music experience</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
{!hasEnvConfig && (
|
||||||
<CardHeader>
|
<Card>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardHeader>
|
||||||
<FaServer className="w-5 h-5" />
|
<CardTitle className="flex items-center gap-2">
|
||||||
Navidrome Server
|
<FaServer className="w-5 h-5" />
|
||||||
</CardTitle>
|
Navidrome Server
|
||||||
<CardDescription>
|
</CardTitle>
|
||||||
Configure connection to your Navidrome music server
|
<CardDescription>
|
||||||
</CardDescription>
|
Configure connection to your Navidrome music server
|
||||||
</CardHeader>
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="server-url">Server URL</Label>
|
<Label htmlFor="server-url">Server URL</Label>
|
||||||
@@ -228,6 +366,35 @@ const SettingsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasEnvConfig && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FaServer className="w-5 h-5" />
|
||||||
|
Navidrome Server
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Using environment variables configuration
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||||
|
<FaCheck className="w-4 h-4 text-green-600" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="text-green-600 font-medium">Configured via Environment Variables</p>
|
||||||
|
<p className="text-green-600">Server: {process.env.NEXT_PUBLIC_NAVIDROME_URL}</p>
|
||||||
|
<p className="text-green-600">Username: {process.env.NEXT_PUBLIC_NAVIDROME_USERNAME}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Your Navidrome connection is configured through environment variables.
|
||||||
|
Contact your administrator to change these settings.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -276,6 +443,167 @@ const SettingsPage = () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* <Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FaCog className="w-5 h-5" />
|
||||||
|
Application Settings
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
General application preferences and setup
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>First-Time Setup</Label>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
localStorage.removeItem('onboarding-completed');
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
|
Run Setup Wizard Again
|
||||||
|
</Button>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Re-run the initial setup wizard to configure your preferences from scratch
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card> */}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Settings className="w-5 h-5" />
|
||||||
|
Sidebar Settings
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Customize sidebar appearance and behavior
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="sidebar-mode">Sidebar Mode</Label>
|
||||||
|
<Select
|
||||||
|
value={sidebarCollapsed ? "collapsed" : "expanded"}
|
||||||
|
onValueChange={(value) => handleSidebarToggle(value === "collapsed")}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="sidebar-mode">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="expanded">Expanded (with labels)</SelectItem>
|
||||||
|
<SelectItem value="collapsed">Collapsed (icons only)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<p><strong>Expanded:</strong> Shows full navigation labels</p>
|
||||||
|
<p><strong>Collapsed:</strong> Shows only icons with tooltips</p>
|
||||||
|
<p className="mt-3"><strong>Note:</strong> You can also toggle the sidebar using the collapse button in the sidebar.</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FaLastfm className="w-5 h-5" />
|
||||||
|
Standalone Last.fm Integration
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Direct Last.fm scrobbling without Navidrome configuration
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="standalone-lastfm-enabled">Enable Standalone Last.fm</Label>
|
||||||
|
<Select
|
||||||
|
value={standaloneLastFmEnabled ? "enabled" : "disabled"}
|
||||||
|
onValueChange={(value) => handleStandaloneLastFmToggle(value === "enabled")}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="standalone-lastfm-enabled">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="enabled">Enabled</SelectItem>
|
||||||
|
<SelectItem value="disabled">Disabled</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{standaloneLastFmEnabled && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="lastfm-api-key">Last.fm API Key</Label>
|
||||||
|
<Input
|
||||||
|
id="lastfm-api-key"
|
||||||
|
type="text"
|
||||||
|
placeholder="Your Last.fm API key"
|
||||||
|
value={lastFmCredentials.apiKey}
|
||||||
|
onChange={(e) => setLastFmCredentials(prev => ({ ...prev, apiKey: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="lastfm-api-secret">Last.fm API Secret</Label>
|
||||||
|
<Input
|
||||||
|
id="lastfm-api-secret"
|
||||||
|
type="password"
|
||||||
|
placeholder="Your Last.fm API secret"
|
||||||
|
value={lastFmCredentials.apiSecret}
|
||||||
|
onChange={(e) => setLastFmCredentials(prev => ({ ...prev, apiSecret: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lastFmCredentials.sessionKey ? (
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||||
|
<FaCheck className="w-4 h-4 text-green-600" />
|
||||||
|
<span className="text-sm text-green-600">
|
||||||
|
Authenticated as {lastFmCredentials.username}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800">
|
||||||
|
<FaTimes className="w-4 h-4 text-yellow-600" />
|
||||||
|
<span className="text-sm text-yellow-600">Not authenticated</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={handleLastFmCredentialsSave} variant="outline">
|
||||||
|
Save Credentials
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleLastFmAuth} disabled={!lastFmCredentials.apiKey || !lastFmCredentials.apiSecret}>
|
||||||
|
<ExternalLink className="w-4 h-4 mr-2" />
|
||||||
|
Authorize with Last.fm
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<p><strong>Setup Instructions:</strong></p>
|
||||||
|
<ol className="list-decimal list-inside space-y-1 ml-2">
|
||||||
|
<li>Create a Last.fm API account at <a href="https://www.last.fm/api" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">last.fm/api</a></li>
|
||||||
|
<li>Enter your API key and secret above</li>
|
||||||
|
<li>Save credentials and click "Authorize with Last.fm"</li>
|
||||||
|
<li>Complete the authorization process</li>
|
||||||
|
</ol>
|
||||||
|
<p className="mt-3"><strong>Features:</strong></p>
|
||||||
|
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||||
|
<li>Direct scrobbling to Last.fm (independent of Navidrome)</li>
|
||||||
|
<li>"Now Playing" updates</li>
|
||||||
|
<li>Follows Last.fm scrobbling rules (30s minimum or 50% played)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Appearance</CardTitle>
|
<CardTitle>Appearance</CardTitle>
|
||||||
|
|||||||
36
components/ui/badge.tsx
Normal file
36
components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -31,8 +31,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
|||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||||
>(({ className, children, ...props }, ref) => (
|
hideCloseButton?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, children, hideCloseButton = false, ...props }, ref) => (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
@@ -44,10 +46,12 @@ const DialogContent = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
{!hideCloseButton && (
|
||||||
<X className="h-4 w-4" />
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
<span className="sr-only">Close</span>
|
<X className="h-4 w-4" />
|
||||||
</DialogPrimitive.Close>
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
))
|
))
|
||||||
|
|||||||
25
docker-compose.override.yml.example
Normal file
25
docker-compose.override.yml.example
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Docker Compose Override Example for Local Development
|
||||||
|
# This file shows how to override the default docker-compose.yml for local development
|
||||||
|
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
mice:
|
||||||
|
# Override to build locally instead of using pre-built image
|
||||||
|
build: .
|
||||||
|
image: mice:local
|
||||||
|
|
||||||
|
# Enable Navidrome configuration for development
|
||||||
|
environment:
|
||||||
|
- NEXT_PUBLIC_NAVIDROME_URL=http://localhost:4533
|
||||||
|
- NEXT_PUBLIC_NAVIDROME_USERNAME=admin
|
||||||
|
- NEXT_PUBLIC_NAVIDROME_PASSWORD=admin
|
||||||
|
- NEXT_PUBLIC_POSTHOG_KEY=${POSTHOG_KEY:-}
|
||||||
|
- NEXT_PUBLIC_POSTHOG_HOST=${POSTHOG_HOST:-}
|
||||||
|
- PORT=${PORT:-3000}
|
||||||
|
|
||||||
|
# Mount source code for development (optional)
|
||||||
|
# volumes:
|
||||||
|
# - .:/app
|
||||||
|
# - /app/node_modules
|
||||||
|
# - /app/.next
|
||||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
mice:
|
||||||
|
image: ghcr.io/sillyangel/mice:latest
|
||||||
|
ports:
|
||||||
|
- "${HOST_PORT:-3000}:${PORT:-3000}"
|
||||||
|
environment:
|
||||||
|
# Navidrome Server Configuration (OPTIONAL)
|
||||||
|
# Uncomment and configure these if you want to pre-configure the server connection
|
||||||
|
# If not set, the app will prompt you to configure these settings on first launch
|
||||||
|
# - NEXT_PUBLIC_NAVIDROME_URL=${NAVIDROME_URL:-}
|
||||||
|
# - NEXT_PUBLIC_NAVIDROME_USERNAME=${NAVIDROME_USERNAME:-}
|
||||||
|
# - NEXT_PUBLIC_NAVIDROME_PASSWORD=${NAVIDROME_PASSWORD:-}
|
||||||
|
|
||||||
|
# PostHog Analytics (optional)
|
||||||
|
- NEXT_PUBLIC_POSTHOG_KEY=${POSTHOG_KEY:-}
|
||||||
|
- NEXT_PUBLIC_POSTHOG_HOST=${POSTHOG_HOST:-}
|
||||||
|
|
||||||
|
# Application Port
|
||||||
|
- PORT=${PORT:-3000}
|
||||||
|
|
||||||
|
# Optional: Add a health check
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:${PORT:-3000}"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
244
hooks/use-standalone-lastfm.ts
Normal file
244
hooks/use-standalone-lastfm.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
interface LastFmCredentials {
|
||||||
|
apiKey: string;
|
||||||
|
apiSecret: string;
|
||||||
|
sessionKey?: string;
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScrobbleState {
|
||||||
|
trackId: string | null;
|
||||||
|
hasScrobbled: boolean;
|
||||||
|
hasUpdatedNowPlaying: boolean;
|
||||||
|
playStartTime: number;
|
||||||
|
lastPlayedDuration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Track {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
artist: string;
|
||||||
|
albumName?: string;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStandaloneLastFm() {
|
||||||
|
const scrobbleStateRef = useRef<ScrobbleState>({
|
||||||
|
trackId: null,
|
||||||
|
hasScrobbled: false,
|
||||||
|
hasUpdatedNowPlaying: false,
|
||||||
|
playStartTime: 0,
|
||||||
|
lastPlayedDuration: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getCredentials = (): LastFmCredentials | null => {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
|
||||||
|
const stored = localStorage.getItem('lastfm-credentials');
|
||||||
|
if (!stored) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(stored);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEnabled = () => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
const enabled = localStorage.getItem('standalone-lastfm-enabled');
|
||||||
|
const credentials = getCredentials();
|
||||||
|
return enabled === 'true' && credentials?.sessionKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateApiSignature = (params: Record<string, string>, secret: string): string => {
|
||||||
|
const sortedParams = Object.keys(params)
|
||||||
|
.sort()
|
||||||
|
.map(key => `${key}${params[key]}`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
// In a real implementation, you'd use a proper crypto library
|
||||||
|
// For demo purposes, this is a simplified version
|
||||||
|
return btoa(sortedParams + secret).substring(0, 32);
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeLastFmRequest = async (method: string, params: Record<string, string>): Promise<any> => {
|
||||||
|
const credentials = getCredentials();
|
||||||
|
if (!credentials) throw new Error('No Last.fm credentials');
|
||||||
|
|
||||||
|
const requestParams: Record<string, string> = {
|
||||||
|
...params,
|
||||||
|
method,
|
||||||
|
api_key: credentials.apiKey,
|
||||||
|
sk: credentials.sessionKey || '',
|
||||||
|
format: 'json'
|
||||||
|
};
|
||||||
|
|
||||||
|
const signature = generateApiSignature(requestParams, credentials.apiSecret);
|
||||||
|
requestParams.api_sig = signature;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
Object.entries(requestParams).forEach(([key, value]) => {
|
||||||
|
formData.append(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch('https://ws.audioscrobbler.com/2.0/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Last.fm API error: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateNowPlaying = useCallback(async (track: Track) => {
|
||||||
|
if (!isEnabled()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await makeLastFmRequest('track.updateNowPlaying', {
|
||||||
|
track: track.name,
|
||||||
|
artist: track.artist,
|
||||||
|
album: track.albumName || '',
|
||||||
|
duration: track.duration.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
scrobbleStateRef.current.hasUpdatedNowPlaying = true;
|
||||||
|
console.log('Updated now playing on Last.fm:', track.name);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update now playing on Last.fm:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrobbleTrack = useCallback(async (track: Track, timestamp?: number) => {
|
||||||
|
if (!isEnabled()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await makeLastFmRequest('track.scrobble', {
|
||||||
|
'track[0]': track.name,
|
||||||
|
'artist[0]': track.artist,
|
||||||
|
'album[0]': track.albumName || '',
|
||||||
|
'timestamp[0]': (timestamp || Math.floor(Date.now() / 1000)).toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Scrobbled track to Last.fm:', track.name);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to scrobble track to Last.fm:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const shouldScrobble = (playedDuration: number, totalDuration: number): boolean => {
|
||||||
|
// Last.fm scrobbling rules:
|
||||||
|
// - At least 30 seconds played OR
|
||||||
|
// - At least half the track played (whichever is lower)
|
||||||
|
const minimumTime = Math.min(30, totalDuration / 2);
|
||||||
|
return playedDuration >= minimumTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTrackStart = useCallback(async (track: Track) => {
|
||||||
|
// Reset scrobble state for new track
|
||||||
|
scrobbleStateRef.current = {
|
||||||
|
trackId: track.id,
|
||||||
|
hasScrobbled: false,
|
||||||
|
hasUpdatedNowPlaying: false,
|
||||||
|
playStartTime: Date.now(),
|
||||||
|
lastPlayedDuration: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update now playing on Last.fm
|
||||||
|
await updateNowPlaying(track);
|
||||||
|
}, [updateNowPlaying]);
|
||||||
|
|
||||||
|
const onTrackPlay = useCallback(async (track: Track) => {
|
||||||
|
scrobbleStateRef.current.playStartTime = Date.now();
|
||||||
|
|
||||||
|
// Update now playing if we haven't already for this track
|
||||||
|
if (!scrobbleStateRef.current.hasUpdatedNowPlaying || scrobbleStateRef.current.trackId !== track.id) {
|
||||||
|
await onTrackStart(track);
|
||||||
|
}
|
||||||
|
}, [onTrackStart]);
|
||||||
|
|
||||||
|
const onTrackPause = useCallback((currentTime: number) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const sessionDuration = (now - scrobbleStateRef.current.playStartTime) / 1000;
|
||||||
|
scrobbleStateRef.current.lastPlayedDuration += sessionDuration;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTrackProgress = useCallback(async (track: Track, currentTime: number, duration: number) => {
|
||||||
|
if (!isEnabled() || scrobbleStateRef.current.hasScrobbled) return;
|
||||||
|
|
||||||
|
// Calculate total played time
|
||||||
|
const now = Date.now();
|
||||||
|
const currentSessionDuration = (now - scrobbleStateRef.current.playStartTime) / 1000;
|
||||||
|
const totalPlayedDuration = scrobbleStateRef.current.lastPlayedDuration + currentSessionDuration;
|
||||||
|
|
||||||
|
// Check if we should scrobble
|
||||||
|
if (shouldScrobble(totalPlayedDuration, duration)) {
|
||||||
|
await scrobbleTrack(track);
|
||||||
|
scrobbleStateRef.current.hasScrobbled = true;
|
||||||
|
}
|
||||||
|
}, [scrobbleTrack]);
|
||||||
|
|
||||||
|
const onTrackEnd = useCallback(async (track: Track, currentTime: number, duration: number) => {
|
||||||
|
if (!isEnabled()) return;
|
||||||
|
|
||||||
|
// Calculate final played duration
|
||||||
|
const now = Date.now();
|
||||||
|
const finalSessionDuration = (now - scrobbleStateRef.current.playStartTime) / 1000;
|
||||||
|
const totalPlayedDuration = scrobbleStateRef.current.lastPlayedDuration + finalSessionDuration;
|
||||||
|
|
||||||
|
// Scrobble if we haven't already and the track qualifies
|
||||||
|
if (!scrobbleStateRef.current.hasScrobbled && shouldScrobble(totalPlayedDuration, duration)) {
|
||||||
|
await scrobbleTrack(track);
|
||||||
|
}
|
||||||
|
}, [scrobbleTrack]);
|
||||||
|
|
||||||
|
const getAuthUrl = (apiKey: string): string => {
|
||||||
|
return `http://www.last.fm/api/auth/?api_key=${apiKey}&cb=${encodeURIComponent(window.location.origin + '/settings')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSessionKey = async (token: string, apiKey: string, apiSecret: string): Promise<{ sessionKey: string; username: string }> => {
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
method: 'auth.getSession',
|
||||||
|
token,
|
||||||
|
api_key: apiKey,
|
||||||
|
format: 'json'
|
||||||
|
};
|
||||||
|
|
||||||
|
const signature = generateApiSignature(params, apiSecret);
|
||||||
|
const url = new URL('https://ws.audioscrobbler.com/2.0/');
|
||||||
|
Object.entries({ ...params, api_sig: signature }).forEach(([key, value]) => {
|
||||||
|
url.searchParams.append(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(url.toString());
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Last.fm auth error: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(data.message || 'Last.fm authentication failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionKey: data.session.key,
|
||||||
|
username: data.session.name
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onTrackStart,
|
||||||
|
onTrackPlay,
|
||||||
|
onTrackPause,
|
||||||
|
onTrackProgress,
|
||||||
|
onTrackEnd,
|
||||||
|
isEnabled,
|
||||||
|
getCredentials,
|
||||||
|
getAuthUrl,
|
||||||
|
getSessionKey
|
||||||
|
};
|
||||||
|
}
|
||||||
154
lib/lastfm-api.ts
Normal file
154
lib/lastfm-api.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
interface LastFmCredentials {
|
||||||
|
apiKey: string;
|
||||||
|
apiSecret: string;
|
||||||
|
sessionKey?: string;
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LastFmArtistInfo {
|
||||||
|
name: string;
|
||||||
|
bio?: {
|
||||||
|
summary: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
stats?: {
|
||||||
|
listeners: string;
|
||||||
|
playcount: string;
|
||||||
|
};
|
||||||
|
similar?: {
|
||||||
|
artist: Array<{
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
image: Array<{
|
||||||
|
'#text': string;
|
||||||
|
size: string;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
tags?: {
|
||||||
|
tag: Array<{
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
image?: Array<{
|
||||||
|
'#text': string;
|
||||||
|
size: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LastFmTopTracks {
|
||||||
|
track: Array<{
|
||||||
|
name: string;
|
||||||
|
playcount: string;
|
||||||
|
listeners: string;
|
||||||
|
artist: {
|
||||||
|
name: string;
|
||||||
|
mbid: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
image: Array<{
|
||||||
|
'#text': string;
|
||||||
|
size: string;
|
||||||
|
}>;
|
||||||
|
'@attr': {
|
||||||
|
rank: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LastFmAPI {
|
||||||
|
private baseUrl = 'https://ws.audioscrobbler.com/2.0/';
|
||||||
|
|
||||||
|
private getCredentials(): LastFmCredentials | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
|
||||||
|
const stored = localStorage.getItem('lastfm-credentials');
|
||||||
|
if (!stored) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(stored);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async makeRequest(method: string, params: Record<string, string>): Promise<Record<string, unknown>> {
|
||||||
|
const credentials = this.getCredentials();
|
||||||
|
if (!credentials?.apiKey) {
|
||||||
|
throw new Error('No Last.fm API key available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(this.baseUrl);
|
||||||
|
url.searchParams.append('method', method);
|
||||||
|
url.searchParams.append('api_key', credentials.apiKey);
|
||||||
|
url.searchParams.append('format', 'json');
|
||||||
|
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
url.searchParams.append(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(url.toString());
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Last.fm API error: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(data.message || 'Last.fm API error');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getArtistInfo(artistName: string): Promise<LastFmArtistInfo | null> {
|
||||||
|
try {
|
||||||
|
const data = await this.makeRequest('artist.getInfo', {
|
||||||
|
artist: artistName,
|
||||||
|
autocorrect: '1'
|
||||||
|
});
|
||||||
|
|
||||||
|
return (data.artist as LastFmArtistInfo) || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch artist info from Last.fm:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getArtistTopTracks(artistName: string, limit: number = 10): Promise<LastFmTopTracks | null> {
|
||||||
|
try {
|
||||||
|
const data = await this.makeRequest('artist.getTopTracks', {
|
||||||
|
artist: artistName,
|
||||||
|
limit: limit.toString(),
|
||||||
|
autocorrect: '1'
|
||||||
|
});
|
||||||
|
|
||||||
|
return (data.toptracks as LastFmTopTracks) || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch artist top tracks from Last.fm:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSimilarArtists(artistName: string, limit: number = 6): Promise<LastFmArtistInfo['similar'] | null> {
|
||||||
|
try {
|
||||||
|
const data = await this.makeRequest('artist.getSimilar', {
|
||||||
|
artist: artistName,
|
||||||
|
limit: limit.toString(),
|
||||||
|
autocorrect: '1'
|
||||||
|
});
|
||||||
|
|
||||||
|
return (data.similarartists as LastFmArtistInfo['similar']) || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch similar artists from Last.fm:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isAvailable(): boolean {
|
||||||
|
const credentials = this.getCredentials();
|
||||||
|
return !!credentials?.apiKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lastFmAPI = new LastFmAPI();
|
||||||
@@ -482,6 +482,47 @@ class NavidromeAPI {
|
|||||||
});
|
});
|
||||||
return response.albumInfo2 as AlbumInfo;
|
return response.albumInfo2 as AlbumInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getStarred2(): Promise<{ starred2: { song?: Song[]; album?: Album[]; artist?: Artist[] } }> {
|
||||||
|
try {
|
||||||
|
const response = await this.makeRequest('getStarred2');
|
||||||
|
return response as { starred2: { song?: Song[]; album?: Album[]; artist?: Artist[] } };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get starred items:', error);
|
||||||
|
return { starred2: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAlbumSongs(albumId: string): Promise<Song[]> {
|
||||||
|
try {
|
||||||
|
const response = await this.makeRequest('getAlbum', { id: albumId });
|
||||||
|
const albumData = response.album as { song?: Song[] };
|
||||||
|
return albumData?.song || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get album songs:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getArtistTopSongs(artistName: string, limit: number = 10): Promise<Song[]> {
|
||||||
|
try {
|
||||||
|
// Search for songs by the artist and return them sorted by play count
|
||||||
|
const searchResult = await this.search2(artistName, 0, 0, limit * 3);
|
||||||
|
|
||||||
|
// Filter songs that are actually by this artist (exact match)
|
||||||
|
const artistSongs = searchResult.songs.filter(song =>
|
||||||
|
song.artist.toLowerCase() === artistName.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort by play count (descending) and limit results
|
||||||
|
return artistSongs
|
||||||
|
.sort((a, b) => (b.playCount || 0) - (a.playCount || 0))
|
||||||
|
.slice(0, limit);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get artist top songs:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance management
|
// Singleton instance management
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const nextConfig = {
|
|||||||
hostname: "**",
|
hostname: "**",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
qualities: [ 45, 75, 85, 90, 100 ]
|
||||||
},
|
},
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
|
|||||||
90
package-lock.json
generated
90
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-avatar": "^1.1.1",
|
"@radix-ui/react-avatar": "^1.1.1",
|
||||||
"@radix-ui/react-context-menu": "^2.2.2",
|
"@radix-ui/react-context-menu": "^2.2.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.2",
|
"@radix-ui/react-dialog": "^1.1.2",
|
||||||
@@ -19,8 +20,7 @@
|
|||||||
"@radix-ui/react-scroll-area": "^1.2.1",
|
"@radix-ui/react-scroll-area": "^1.2.1",
|
||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
"@radix-ui/react-slider": "^1.3.5",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
|
||||||
"@radix-ui/react-tabs": "^1.1.1",
|
"@radix-ui/react-tabs": "^1.1.1",
|
||||||
"@radix-ui/react-toast": "^1.2.4",
|
"@radix-ui/react-toast": "^1.2.4",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
@@ -28,11 +28,11 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"colorthief": "^2.6.0",
|
"colorthief": "^2.6.0",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"next": "^15.0.3",
|
"next": "15.3.4",
|
||||||
"posthog-js": "^1.255.0",
|
"posthog-js": "^1.255.0",
|
||||||
"posthog-node": "^5.1.1",
|
"posthog-node": "^5.1.1",
|
||||||
"react": "^19",
|
"react": "19.1.0",
|
||||||
"react-dom": "^19",
|
"react-dom": "19.1.0",
|
||||||
"react-hook-form": "^7.53.2",
|
"react-hook-form": "^7.53.2",
|
||||||
"react-icons": "^5.3.0",
|
"react-icons": "^5.3.0",
|
||||||
"tailwind-merge": "^2.5.4",
|
"tailwind-merge": "^2.5.4",
|
||||||
@@ -41,11 +41,11 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.4",
|
"@types/node": "^22.10.4",
|
||||||
"@types/react": "^19.0.4",
|
"@types/react": "19.1.8",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "19.1.6",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"eslint": "^9.17",
|
"eslint": "^9.17",
|
||||||
"eslint-config-next": "15.1.4",
|
"eslint-config-next": "15.3.4",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.4.15",
|
"tailwindcss": "^3.4.15",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
@@ -863,9 +863,9 @@
|
|||||||
"integrity": "sha512-ZkdYzBseS6UjYzz6ylVKPOK+//zLWvD6Ta+vpoye8cW11AjiQjGYVibF0xuvT4L0iJfAPfZLFidaEzAOywyOAQ=="
|
"integrity": "sha512-ZkdYzBseS6UjYzz6ylVKPOK+//zLWvD6Ta+vpoye8cW11AjiQjGYVibF0xuvT4L0iJfAPfZLFidaEzAOywyOAQ=="
|
||||||
},
|
},
|
||||||
"node_modules/@next/eslint-plugin-next": {
|
"node_modules/@next/eslint-plugin-next": {
|
||||||
"version": "15.1.4",
|
"version": "15.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.3.4.tgz",
|
||||||
"integrity": "sha512-HwlEXwCK3sr6zmVGEvWBjW9tBFs1Oe6hTmTLoFQtpm4As5HCdu8jfSE0XJOp7uhfEGLniIx8yrGxEWwNnY0fmQ==",
|
"integrity": "sha512-lBxYdj7TI8phbJcLSAqDt57nIcobEign5NYIKCiy0hXQhrUbTqLqOaSDi568U6vFg4hJfBdZYsG4iP/uKhCqgg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-glob": "3.3.1"
|
"fast-glob": "3.3.1"
|
||||||
@@ -1051,6 +1051,33 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
|
||||||
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="
|
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-alert-dialog": {
|
||||||
|
"version": "1.1.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.14.tgz",
|
||||||
|
"integrity": "sha512-IOZfZ3nPvN6lXpJTBCunFQPRSvK8MDgSc1FB85xnIpUKOw9en0dJj8JmCAxV7BiZdtYlUpmrQjoTFkVYtdoWzQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.2",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-dialog": "1.1.14",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-arrow": {
|
"node_modules/@radix-ui/react-arrow": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||||
@@ -1655,39 +1682,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-slider": {
|
|
||||||
"version": "1.3.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.5.tgz",
|
|
||||||
"integrity": "sha512-rkfe2pU2NBAYfGaxa3Mqosi7VZEWX5CxKaanRv0vZd4Zhl9fvQrg0VM93dv3xGLGfrHuoTRF3JXH8nb9g+B3fw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/number": "1.1.1",
|
|
||||||
"@radix-ui/primitive": "1.1.2",
|
|
||||||
"@radix-ui/react-collection": "1.1.7",
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.2",
|
|
||||||
"@radix-ui/react-context": "1.1.2",
|
|
||||||
"@radix-ui/react-direction": "1.1.1",
|
|
||||||
"@radix-ui/react-primitive": "2.1.3",
|
|
||||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
|
||||||
"@radix-ui/react-use-layout-effect": "1.1.1",
|
|
||||||
"@radix-ui/react-use-previous": "1.1.1",
|
|
||||||
"@radix-ui/react-use-size": "1.1.1"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"@types/react-dom": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
|
||||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@types/react-dom": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-slot": {
|
"node_modules/@radix-ui/react-slot": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
@@ -3651,12 +3645,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-config-next": {
|
"node_modules/eslint-config-next": {
|
||||||
"version": "15.1.4",
|
"version": "15.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.3.4.tgz",
|
||||||
"integrity": "sha512-u9+7lFmfhKNgGjhQ9tBeyCFsPJyq0SvGioMJBngPC7HXUpR0U+ckEwQR48s7TrRNHra1REm6evGL2ie38agALg==",
|
"integrity": "sha512-WqeumCq57QcTP2lYlV6BRUySfGiBYEXlQ1L0mQ+u4N4X4ZhUVSSQ52WtjqHv60pJ6dD7jn+YZc0d1/ZSsxccvg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/eslint-plugin-next": "15.1.4",
|
"@next/eslint-plugin-next": "15.3.4",
|
||||||
"@rushstack/eslint-patch": "^1.10.3",
|
"@rushstack/eslint-patch": "^1.10.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
"@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||||
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||||
|
|||||||
22
package.json
22
package.json
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "mice-reworked",
|
"name": "mice-reworked",
|
||||||
"version": "1.0.0",
|
"version": "2025.07.02",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "echo NEXT_PUBLIC_COMMIT_SHA=$(git rev-parse --short HEAD) > .env.local",
|
"predev": "echo NEXT_PUBLIC_COMMIT_SHA=$(git rev-parse --short HEAD) > .env.local",
|
||||||
"dev": "next dev -p 40625",
|
"dev": "next dev --turbopack -p 40625",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 40625",
|
"start": "next start -p 40625",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
@@ -30,11 +30,11 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"colorthief": "^2.6.0",
|
"colorthief": "^2.6.0",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"next": "^15.0.3",
|
"next": "15.3.4",
|
||||||
"posthog-js": "^1.255.0",
|
"posthog-js": "^1.255.0",
|
||||||
"posthog-node": "^5.1.1",
|
"posthog-node": "^5.1.1",
|
||||||
"react": "^19",
|
"react": "19.1.0",
|
||||||
"react-dom": "^19",
|
"react-dom": "19.1.0",
|
||||||
"react-hook-form": "^7.53.2",
|
"react-hook-form": "^7.53.2",
|
||||||
"react-icons": "^5.3.0",
|
"react-icons": "^5.3.0",
|
||||||
"tailwind-merge": "^2.5.4",
|
"tailwind-merge": "^2.5.4",
|
||||||
@@ -43,14 +43,18 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.4",
|
"@types/node": "^22.10.4",
|
||||||
"@types/react": "^19.0.4",
|
"@types/react": "19.1.8",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "19.1.6",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"eslint": "^9.17",
|
"eslint": "^9.17",
|
||||||
"eslint-config-next": "15.1.4",
|
"eslint-config-next": "15.3.4",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.4.15",
|
"tailwindcss": "^3.4.15",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a"
|
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a",
|
||||||
|
"overrides": {
|
||||||
|
"@types/react": "19.1.8",
|
||||||
|
"@types/react-dom": "19.1.6"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user