Refactor UI components for improved spacing; add UserProfile component for user info display
This commit is contained in:
@@ -1 +1 @@
|
|||||||
NEXT_PUBLIC_COMMIT_SHA=86e198a
|
NEXT_PUBLIC_COMMIT_SHA=74b9648
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
209
app/components/UserProfile.tsx
Normal file
209
app/components/UserProfile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
38
lib/gravatar.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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[] = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user