Refactor UI components for improved spacing; add UserProfile component for user info display

This commit is contained in:
2025-07-25 17:35:07 +00:00
committed by GitHub
parent 74b9648eef
commit 25e9bd6912
11 changed files with 309 additions and 87 deletions

View File

@@ -1 +1 @@
NEXT_PUBLIC_COMMIT_SHA=86e198a NEXT_PUBLIC_COMMIT_SHA=74b9648

View File

@@ -143,7 +143,7 @@ export function CacheManagement() {
}; };
return ( return (
<Card className="break-inside-avoid"> <Card className="break-inside-avoid py-5">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" /> <Database className="h-5 w-5" />

View File

@@ -38,7 +38,7 @@ export function SettingsManagement() {
}; };
return ( return (
<Card> <Card className="py-5">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Settings className="h-5 w-5" /> <Settings className="h-5 w-5" />

View File

@@ -153,7 +153,7 @@ export function SidebarCustomization() {
return ( return (
<Card> <Card className="py-5">
<CardHeader> <CardHeader>
<CardTitle>Sidebar Customization</CardTitle> <CardTitle>Sidebar Customization</CardTitle>
<CardDescription> <CardDescription>

View File

@@ -10,6 +10,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { Play, Heart, Music, Shuffle } from 'lucide-react'; import { Play, Heart, Music, Shuffle } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { UserProfile } from './UserProfile';
interface SongRecommendationsProps { interface SongRecommendationsProps {
userName?: string; userName?: string;
@@ -196,12 +197,18 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
{isMobile ? 'Here are some albums you might enjoy' : 'Here are some songs you might enjoy'} {isMobile ? 'Here are some albums you might enjoy' : 'Here are some songs you might enjoy'}
</p> </p>
</div> </div>
{(isMobile ? recommendedAlbums.length > 0 : recommendedSongs.length > 0) && !isMobile && ( <div className="flex items-center gap-3">
<Button onClick={handleShuffleAll} variant="outline" size="sm"> {/* Mobile User Profile */}
<Shuffle className="w-4 h-4 mr-2" /> {isMobile && <UserProfile variant="mobile" />}
Shuffle All
</Button> {/* Shuffle All Button (Desktop only) */}
)} {(isMobile ? recommendedAlbums.length > 0 : recommendedSongs.length > 0) && !isMobile && (
<Button onClick={handleShuffleAll} variant="outline" size="sm">
<Shuffle className="w-4 h-4 mr-2" />
Shuffle All
</Button>
)}
</div>
</div> </div>
{isMobile ? ( {isMobile ? (

View File

@@ -0,0 +1,209 @@
'use client';
import React, { useState, useEffect } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { User, ChevronDown, Settings, LogOut } from 'lucide-react';
import { useNavidrome } from '@/app/components/NavidromeContext';
import { getGravatarUrl } from '@/lib/gravatar';
import { User as NavidromeUser } from '@/lib/navidrome';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
interface UserProfileProps {
variant?: 'desktop' | 'mobile';
}
export function UserProfile({ variant = 'desktop' }: UserProfileProps) {
const { api, isConnected } = useNavidrome();
const [userInfo, setUserInfo] = useState<NavidromeUser | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchUserInfo = async () => {
if (!api || !isConnected) {
setLoading(false);
return;
}
try {
const user = await api.getUserInfo();
setUserInfo(user);
} catch (error) {
console.error('Failed to fetch user info:', error);
} finally {
setLoading(false);
}
};
fetchUserInfo();
}, [api, isConnected]);
const handleLogout = () => {
// Clear Navidrome config and reload
localStorage.removeItem('navidrome-config');
window.location.reload();
};
if (!userInfo) {
if (variant === 'desktop') {
return (
<Link href="/settings">
<Button variant="ghost" size="sm" className="gap-2">
<User className="w-4 h-4" />
</Button>
</Link>
);
} else {
return (
<Link href="/settings">
<Button variant="ghost" size="sm" className="gap-2">
<User className="w-4 h-4" />
</Button>
</Link>
);
}
}
const gravatarUrl = userInfo.email
? getGravatarUrl(userInfo.email, variant === 'desktop' ? 32 : 48, 'identicon')
: null;
if (variant === 'desktop') {
// Desktop: Only show profile icon
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-1 h-auto p-2">
{gravatarUrl ? (
<Image
src={gravatarUrl}
alt={`${userInfo.username}'s avatar`}
width={16}
height={16}
className="rounded-full"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
}}
/>
) : (
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
<User className="w-4 h-4 text-primary" />
</div>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="flex items-center gap-2 p-2">
{gravatarUrl ? (
<Image
src={gravatarUrl}
alt={`${userInfo.username}'s avatar`}
width={16}
height={16}
className="rounded-full"
/>
) : (
<div className="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-primary" />
</div>
)}
<div>
<p className="text-sm font-medium">{userInfo.username}</p>
{userInfo.email && (
<p className="text-xs text-muted-foreground">{userInfo.email}</p>
)}
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/settings" className="flex items-center gap-2">
<Settings className="w-4 h-4" />
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleLogout}
className="flex items-center gap-2 text-red-600 focus:text-red-600"
>
<LogOut className="w-4 h-4" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
} else {
// Mobile: Show only icon with dropdown
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-1 h-auto p-2">
{gravatarUrl ? (
<Image
src={gravatarUrl}
alt={`${userInfo.username}'s avatar`}
width={16}
height={16}
className="rounded-full"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
}}
/>
) : (
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
<User className="w-4 h-4 text-primary" />
</div>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="flex items-center gap-2 p-2">
{gravatarUrl ? (
<Image
src={gravatarUrl}
alt={`${userInfo.username}'s avatar`}
width={16}
height={16}
className="rounded-full"
/>
) : (
<div className="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-primary" />
</div>
)}
<div>
<p className="text-sm font-medium">{userInfo.username}</p>
{userInfo.email && (
<p className="text-xs text-muted-foreground">{userInfo.email}</p>
)}
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/settings" className="flex items-center gap-2">
<Settings className="w-4 h-4" />
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleLogout}
className="flex items-center gap-2 text-red-600 focus:text-red-600"
>
<LogOut className="w-4 h-4" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
}

View File

@@ -2,6 +2,7 @@ import { useCallback } from "react";
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Image from "next/image"; import Image from "next/image";
import { Github, Mail, Menu as MenuIcon, X } from "lucide-react" import { Github, Mail, Menu as MenuIcon, X } from "lucide-react"
import { UserProfile } from "@/app/components/UserProfile";
import { import {
Menubar, Menubar,
MenubarCheckboxItem, MenubarCheckboxItem,
@@ -332,6 +333,13 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
</Menubar> </Menubar>
)} )}
{/* User Profile - Desktop only */}
{!isMobile && (
<div className="ml-auto">
<UserProfile variant="desktop" />
</div>
)}
</div> </div>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>

View File

@@ -12,6 +12,8 @@ import { useSearchParams } from 'next/navigation';
import { useAudioPlayer } from './components/AudioPlayerContext'; import { useAudioPlayer } from './components/AudioPlayerContext';
import { SongRecommendations } from './components/SongRecommendations'; import { SongRecommendations } from './components/SongRecommendations';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { useIsMobile } from '@/hooks/use-mobile';
import { UserProfile } from './components/UserProfile';
type TimeOfDay = 'morning' | 'afternoon' | 'evening'; type TimeOfDay = 'morning' | 'afternoon' | 'evening';
@@ -24,6 +26,7 @@ function MusicPageContent() {
const [favoriteAlbums, setFavoriteAlbums] = useState<Album[]>([]); const [favoriteAlbums, setFavoriteAlbums] = useState<Album[]>([]);
const [favoritesLoading, setFavoritesLoading] = useState(true); const [favoritesLoading, setFavoritesLoading] = useState(true);
const [shortcutProcessed, setShortcutProcessed] = useState(false); const [shortcutProcessed, setShortcutProcessed] = useState(false);
const isMobile = useIsMobile();
useEffect(() => { useEffect(() => {
if (albums.length > 0) { if (albums.length > 0) {

View File

@@ -47,7 +47,6 @@ const SettingsPage = () => {
// Client-side hydration state // Client-side hydration state
const [isClient, setIsClient] = useState(false); const [isClient, setIsClient] = useState(false);
const [isDev, setIsDev] = useState(false);
// Check if Navidrome is configured via environment variables // Check if Navidrome is configured via environment variables
const hasEnvConfig = React.useMemo(() => { const hasEnvConfig = React.useMemo(() => {
@@ -63,7 +62,6 @@ const SettingsPage = () => {
// Initialize client-side state after hydration // Initialize client-side state after hydration
useEffect(() => { useEffect(() => {
setIsClient(true); setIsClient(true);
setIsDev(process.env.NODE_ENV === 'development');
// Initialize form data with config values // Initialize form data with config values
setFormData({ setFormData({
@@ -335,39 +333,6 @@ const SettingsPage = () => {
} }
}; };
const handleDebugClearStorage = () => {
if (!isClient) return;
try {
// Preserve Navidrome config
const navidromeConfig = localStorage.getItem('navidrome-config');
// Clear all localStorage
localStorage.clear();
// Restore Navidrome config if it existed
if (navidromeConfig) {
localStorage.setItem('navidrome-config', navidromeConfig);
}
toast({
title: "Debug: Storage Cleared",
description: "All localStorage cleared except Navidrome config. Page will reload.",
});
// Reload the page to reset all state
setTimeout(() => {
window.location.reload();
}, 1500);
} catch (error) {
toast({
title: "Debug: Clear Failed",
description: "Failed to clear localStorage: " + (error instanceof Error ? error.message : "Unknown error"),
variant: "destructive"
});
}
};
return ( return (
<div className="p-6 pb-24 w-full"> <div className="p-6 pb-24 w-full">
{!isClient ? ( {!isClient ? (
@@ -388,7 +353,7 @@ const SettingsPage = () => {
style={{ columnFill: 'balance' }}> style={{ columnFill: 'balance' }}>
{!hasEnvConfig && ( {!hasEnvConfig && (
<Card className="mb-6 break-inside-avoid"> <Card className="mb-6 break-inside-avoid py-5">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<FaServer className="w-5 h-5" /> <FaServer className="w-5 h-5" />
@@ -477,7 +442,7 @@ const SettingsPage = () => {
)} )}
{hasEnvConfig && ( {hasEnvConfig && (
<Card className="mb-6 break-inside-avoid"> <Card className="mb-6 break-inside-avoid py-5">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<FaServer className="w-5 h-5" /> <FaServer className="w-5 h-5" />
@@ -504,7 +469,7 @@ const SettingsPage = () => {
</Card> </Card>
)} )}
<Card className="mb-6 break-inside-avoid"> <Card className="mb-6 break-inside-avoid py-5">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<FaLastfm className="w-5 h-5" /> <FaLastfm className="w-5 h-5" />
@@ -582,7 +547,7 @@ const SettingsPage = () => {
</CardContent> </CardContent>
</Card> */} </Card> */}
<Card className="mb-6 break-inside-avoid"> <Card className="mb-6 break-inside-avoid py-5">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Settings className="w-5 h-5" /> <Settings className="w-5 h-5" />
@@ -637,7 +602,7 @@ const SettingsPage = () => {
</CardContent> </CardContent>
</Card> </Card>
<Card className="mb-6 break-inside-avoid"> {/* <Card className="mb-6 break-inside-avoid py-5">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<FaLastfm className="w-5 h-5" /> <FaLastfm className="w-5 h-5" />
@@ -730,7 +695,7 @@ const SettingsPage = () => {
</> </>
)} )}
</CardContent> </CardContent>
</Card> </Card> */}
{/* Sidebar Customization */} {/* Sidebar Customization */}
<div className="break-inside-avoid mb-6"> <div className="break-inside-avoid mb-6">
@@ -747,41 +712,7 @@ const SettingsPage = () => {
<CacheManagement /> <CacheManagement />
</div> </div>
{/* Debug Tools - Only in Development */} <Card className="mb-6 break-inside-avoid py-5">
{isDev && (
<Card className="mb-6 break-inside-avoid border-orange-200 dark:border-orange-800">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-orange-600 dark:text-orange-400">
<FaCog className="w-5 h-5" />
Debug Tools (Dev Only)
</CardTitle>
<CardDescription>
Development tools for debugging and testing
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="p-4 bg-orange-50 dark:bg-orange-950/20 rounded-lg border border-orange-200 dark:border-orange-800">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-orange-800 dark:text-orange-200">Clear Storage</h4>
<p className="text-sm text-orange-600 dark:text-orange-400">
Clears all localStorage except Navidrome config
</p>
</div>
<Button
onClick={handleDebugClearStorage}
variant="destructive"
size="sm"
>
Clear Storage
</Button>
</div>
</div>
</CardContent>
</Card>
)}
<Card className="mb-6 break-inside-avoid">
<CardHeader> <CardHeader>
<CardTitle>Appearance</CardTitle> <CardTitle>Appearance</CardTitle>
<CardDescription> <CardDescription>
@@ -830,7 +761,7 @@ const SettingsPage = () => {
</Card> </Card>
{/* Theme Preview */} {/* Theme Preview */}
<Card className="mb-6 break-inside-avoid"> <Card className="mb-6 break-inside-avoid py-5">
<CardHeader> <CardHeader>
<CardTitle>Preview</CardTitle> <CardTitle>Preview</CardTitle>
<CardDescription> <CardDescription>

38
lib/gravatar.ts Normal file
View File

@@ -0,0 +1,38 @@
import crypto from 'crypto';
/**
* Generate a Gravatar URL from an email address
* @param email - The email address
* @param size - The size of the image (default: 80)
* @param defaultImage - Default image type if no Gravatar found (default: 'identicon')
* @returns The Gravatar URL
*/
export function getGravatarUrl(
email: string,
size: number = 80,
defaultImage: string = 'identicon'
): string {
// Normalize email: trim whitespace and convert to lowercase
const normalizedEmail = email.trim().toLowerCase();
// Generate MD5 hash of the email
const hash = crypto.createHash('md5').update(normalizedEmail).digest('hex');
// Construct the Gravatar URL
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=${defaultImage}`;
}
/**
* Generate a Gravatar URL with retina support (2x size)
* @param email - The email address
* @param size - The base size of the image
* @param defaultImage - Default image type if no Gravatar found
* @returns The Gravatar URL at 2x resolution
*/
export function getGravatarUrlRetina(
email: string,
size: number = 80,
defaultImage: string = 'identicon'
): string {
return getGravatarUrl(email, size * 2, defaultImage);
}

View File

@@ -110,6 +110,26 @@ export interface ArtistInfo {
similarArtist?: Artist[]; similarArtist?: Artist[];
} }
export interface User {
username: string;
email?: string;
scrobblingEnabled: boolean;
maxBitRate?: number;
adminRole: boolean;
settingsRole: boolean;
downloadRole: boolean;
uploadRole: boolean;
playlistRole: boolean;
coverArtRole: boolean;
commentRole: boolean;
podcastRole: boolean;
streamRole: boolean;
jukeboxRole: boolean;
shareRole: boolean;
videoConversionRole: boolean;
avatarLastChanged?: string;
}
class NavidromeAPI { class NavidromeAPI {
private config: NavidromeConfig; private config: NavidromeConfig;
private clientName = 'miceclient'; private clientName = 'miceclient';
@@ -171,6 +191,12 @@ class NavidromeAPI {
} }
} }
async getUserInfo(): Promise<User> {
const response = await this.makeRequest('getUser', { username: this.config.username });
const userData = response.user as User;
return userData;
}
async getArtists(): Promise<Artist[]> { async getArtists(): Promise<Artist[]> {
const response = await this.makeRequest('getArtists'); const response = await this.makeRequest('getArtists');
const artists: Artist[] = []; const artists: Artist[] = [];