diff --git a/.env.local b/.env.local index 3b9275d..8dce141 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -NEXT_PUBLIC_COMMIT_SHA=86e198a +NEXT_PUBLIC_COMMIT_SHA=74b9648 diff --git a/app/components/CacheManagement.tsx b/app/components/CacheManagement.tsx index decca08..72dd8af 100644 --- a/app/components/CacheManagement.tsx +++ b/app/components/CacheManagement.tsx @@ -143,7 +143,7 @@ export function CacheManagement() { }; return ( - + diff --git a/app/components/SettingsManagement.tsx b/app/components/SettingsManagement.tsx index 4eb0d79..8454999 100644 --- a/app/components/SettingsManagement.tsx +++ b/app/components/SettingsManagement.tsx @@ -38,7 +38,7 @@ export function SettingsManagement() { }; return ( - + diff --git a/app/components/SidebarCustomization.tsx b/app/components/SidebarCustomization.tsx index 95e2788..5c69d66 100644 --- a/app/components/SidebarCustomization.tsx +++ b/app/components/SidebarCustomization.tsx @@ -153,7 +153,7 @@ export function SidebarCustomization() { return ( - + Sidebar Customization diff --git a/app/components/SongRecommendations.tsx b/app/components/SongRecommendations.tsx index 3bf56d2..59dfa32 100644 --- a/app/components/SongRecommendations.tsx +++ b/app/components/SongRecommendations.tsx @@ -10,6 +10,7 @@ import { Card, CardContent } from '@/components/ui/card'; import { Play, Heart, Music, Shuffle } from 'lucide-react'; import Image from 'next/image'; import Link from 'next/link'; +import { UserProfile } from './UserProfile'; interface SongRecommendationsProps { 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 ? recommendedAlbums.length > 0 : recommendedSongs.length > 0) && !isMobile && ( - - )} +
+ {/* Mobile User Profile */} + {isMobile && } + + {/* Shuffle All Button (Desktop only) */} + {(isMobile ? recommendedAlbums.length > 0 : recommendedSongs.length > 0) && !isMobile && ( + + )} +
{isMobile ? ( diff --git a/app/components/UserProfile.tsx b/app/components/UserProfile.tsx new file mode 100644 index 0000000..7d646cd --- /dev/null +++ b/app/components/UserProfile.tsx @@ -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(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 ( + + + + ); + } else { + return ( + + + + ); + } + } + + const gravatarUrl = userInfo.email + ? getGravatarUrl(userInfo.email, variant === 'desktop' ? 32 : 48, 'identicon') + : null; + + if (variant === 'desktop') { + // Desktop: Only show profile icon + return ( + + + + + +
+ {gravatarUrl ? ( + {`${userInfo.username}'s + ) : ( +
+ +
+ )} +
+

{userInfo.username}

+ {userInfo.email && ( +

{userInfo.email}

+ )} +
+
+ + + + + Settings + + + + + + Logout + +
+
+ ); + } else { + // Mobile: Show only icon with dropdown + return ( + + + + + +
+ {gravatarUrl ? ( + {`${userInfo.username}'s + ) : ( +
+ +
+ )} +
+

{userInfo.username}

+ {userInfo.email && ( +

{userInfo.email}

+ )} +
+
+ + + + + Settings + + + + + + Logout + +
+
+ ); + } +} diff --git a/app/components/menu.tsx b/app/components/menu.tsx index 55ff638..66f94bb 100644 --- a/app/components/menu.tsx +++ b/app/components/menu.tsx @@ -2,6 +2,7 @@ import { useCallback } from "react"; import { useRouter } from 'next/navigation'; import Image from "next/image"; import { Github, Mail, Menu as MenuIcon, X } from "lucide-react" +import { UserProfile } from "@/app/components/UserProfile"; import { Menubar, MenubarCheckboxItem, @@ -332,6 +333,13 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu )} + {/* User Profile - Desktop only */} + {!isMobile && ( +
+ +
+ )} + diff --git a/app/page.tsx b/app/page.tsx index 5b86eb4..bcd75d4 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -12,6 +12,8 @@ import { useSearchParams } from 'next/navigation'; import { useAudioPlayer } from './components/AudioPlayerContext'; import { SongRecommendations } from './components/SongRecommendations'; import { Skeleton } from '@/components/ui/skeleton'; +import { useIsMobile } from '@/hooks/use-mobile'; +import { UserProfile } from './components/UserProfile'; type TimeOfDay = 'morning' | 'afternoon' | 'evening'; @@ -24,6 +26,7 @@ function MusicPageContent() { const [favoriteAlbums, setFavoriteAlbums] = useState([]); const [favoritesLoading, setFavoritesLoading] = useState(true); const [shortcutProcessed, setShortcutProcessed] = useState(false); + const isMobile = useIsMobile(); useEffect(() => { if (albums.length > 0) { diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 2eb2ed7..7599b37 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -47,7 +47,6 @@ const SettingsPage = () => { // Client-side hydration state const [isClient, setIsClient] = useState(false); - const [isDev, setIsDev] = useState(false); // Check if Navidrome is configured via environment variables const hasEnvConfig = React.useMemo(() => { @@ -63,7 +62,6 @@ const SettingsPage = () => { // Initialize client-side state after hydration useEffect(() => { setIsClient(true); - setIsDev(process.env.NODE_ENV === 'development'); // Initialize form data with config values 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 (
{!isClient ? ( @@ -388,7 +353,7 @@ const SettingsPage = () => { style={{ columnFill: 'balance' }}> {!hasEnvConfig && ( - + @@ -477,7 +442,7 @@ const SettingsPage = () => { )} {hasEnvConfig && ( - + @@ -504,7 +469,7 @@ const SettingsPage = () => { )} - + @@ -582,7 +547,7 @@ const SettingsPage = () => { */} - + @@ -637,7 +602,7 @@ const SettingsPage = () => { - + {/* @@ -730,7 +695,7 @@ const SettingsPage = () => { )} - + */} {/* Sidebar Customization */}
@@ -747,41 +712,7 @@ const SettingsPage = () => {
- {/* Debug Tools - Only in Development */} - {isDev && ( - - - - - Debug Tools (Dev Only) - - - Development tools for debugging and testing - - - -
-
-
-

Clear Storage

-

- Clears all localStorage except Navidrome config -

-
- -
-
-
-
- )} - - + Appearance @@ -830,7 +761,7 @@ const SettingsPage = () => { {/* Theme Preview */} - + Preview diff --git a/lib/gravatar.ts b/lib/gravatar.ts new file mode 100644 index 0000000..34d6ba9 --- /dev/null +++ b/lib/gravatar.ts @@ -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); +} diff --git a/lib/navidrome.ts b/lib/navidrome.ts index 17a1d05..f5909c6 100644 --- a/lib/navidrome.ts +++ b/lib/navidrome.ts @@ -110,6 +110,26 @@ export interface ArtistInfo { 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 { private config: NavidromeConfig; private clientName = 'miceclient'; @@ -171,6 +191,12 @@ class NavidromeAPI { } } + async getUserInfo(): Promise { + const response = await this.makeRequest('getUser', { username: this.config.username }); + const userData = response.user as User; + return userData; + } + async getArtists(): Promise { const response = await this.makeRequest('getArtists'); const artists: Artist[] = [];