feat(cluster-page): enhance user experience with scrolling management and rating updates

- Implement body and HTML overflow management to prevent scrolling when the cluster page is active
- Update rating handling to support deletion of existing ratings when rating is set to zero
- Add file size formatting utility for improved display of media sizes
- Refactor UI components for better layout and responsiveness, including adjustments to card and tab styles
- Ensure consistent styling and spacing across various elements for a polished look
This commit is contained in:
tigeren 2025-10-18 17:52:36 +00:00
parent 7e5b122565
commit 4e3c4a1277
2 changed files with 160 additions and 79 deletions

View File

@ -49,6 +49,20 @@ export default function ClusterPage({ params }: ClusterPageProps) {
}
}, [clusterId]);
// Prevent body scrolling when cluster page is active
useEffect(() => {
const originalBodyOverflow = document.body.style.overflow;
const originalHtmlOverflow = document.documentElement.style.overflow;
document.body.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalBodyOverflow;
document.documentElement.style.overflow = originalHtmlOverflow;
};
}, []);
const fetchClusterData = async () => {
try {
setLoading(true);
@ -124,20 +138,46 @@ export default function ClusterPage({ params }: ClusterPageProps) {
const handleRate = async (id: number, rating: number) => {
try {
const res = await fetch('/api/stars', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mediaId: id, rating })
});
if (res.ok) {
// Refresh data
fetchClusterData();
if (rating === 0) {
// For unstarring (rating = 0), we need to delete the existing rating
// First, get the current rating record to find the star ID
const getResponse = await fetch(`/api/stars/${id}`);
if (getResponse.ok) {
const data = await getResponse.json();
if (data.hasRating) {
// We need to get the actual star record ID to delete it
// Since the API structure doesn't return star ID, we'll use a different approach
// Delete by media_id instead of star id
await fetch(`/api/stars`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mediaId: id })
});
}
}
} else {
// For setting/updating a rating
await fetch(`/api/stars`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mediaId: id, rating })
});
}
// Refresh data
fetchClusterData();
} catch (error) {
console.error('Error rating:', error);
}
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const getIconComponent = (iconName: string) => {
const icons: Record<string, any> = {
folder: Folder,
@ -191,98 +231,95 @@ export default function ClusterPage({ params }: ClusterPageProps) {
const IconComponent = getIconComponent(cluster.icon);
return (
<div className="min-h-screen bg-zinc-950">
<div className="h-screen bg-zinc-950 overflow-hidden flex flex-col">
{/* Header */}
<div className="bg-zinc-900 border-b border-zinc-800">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center gap-4 mb-4">
<Button
variant="ghost"
size="sm"
onClick={() => router.back()}
className="text-zinc-400 hover:text-white"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
</div>
<div className="flex items-start gap-4">
<div
className="w-16 h-16 rounded-xl flex items-center justify-center shadow-lg flex-shrink-0"
style={{ backgroundColor: `${cluster.color}20` }}
>
<IconComponent className="h-8 w-8" style={{ color: cluster.color }} />
</div>
<div className="flex-1">
<h1 className="text-3xl font-bold text-white mb-2">{cluster.name}</h1>
{cluster.description && (
<p className="text-zinc-400 text-lg">{cluster.description}</p>
)}
<div className="flex gap-3 mt-3 text-sm text-zinc-500">
<span>{libraries.length} {libraries.length === 1 ? 'library' : 'libraries'}</span>
{stats && (
<>
<span></span>
<span>{stats.total_media} items</span>
<span></span>
<span>{formatSize(stats.total_size)}</span>
</>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => router.back()}
className="text-zinc-400 hover:text-white"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<div
className="w-10 h-10 rounded-lg flex items-center justify-center shadow-lg flex-shrink-0"
style={{ backgroundColor: `${cluster.color}20` }}
>
<IconComponent className="h-5 w-5" style={{ color: cluster.color }} />
</div>
<div>
<h1 className="text-xl font-bold text-white">{cluster.name}</h1>
{cluster.description && (
<p className="text-zinc-400 text-sm">{cluster.description}</p>
)}
</div>
</div>
{stats && (
<div className="flex gap-4 text-sm text-zinc-500">
<span>{libraries.length} {libraries.length === 1 ? 'library' : 'libraries'}</span>
<span></span>
<span>{stats.total_media} items</span>
<span></span>
<span>{formatSize(stats.total_size)}</span>
</div>
)}
</div>
</div>
</div>
{/* Stats Cards */}
{stats && (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-zinc-900 border-zinc-800 p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-red-600/20 rounded-lg flex items-center justify-center">
<Film className="h-5 w-5 text-red-400" />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card className="bg-zinc-900 border-zinc-800 p-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-red-600/20 rounded-lg flex items-center justify-center">
<Film className="h-4 w-4 text-red-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">{stats.video_count || 0}</p>
<p className="text-sm text-zinc-400">Videos</p>
<p className="text-lg font-bold text-white">{stats.video_count || 0}</p>
<p className="text-xs text-zinc-400">Videos</p>
</div>
</div>
</Card>
<Card className="bg-zinc-900 border-zinc-800 p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-600/20 rounded-lg flex items-center justify-center">
<ImageIcon className="h-5 w-5 text-green-400" />
<Card className="bg-zinc-900 border-zinc-800 p-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-green-600/20 rounded-lg flex items-center justify-center">
<ImageIcon className="h-4 w-4 text-green-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">{stats.photo_count || 0}</p>
<p className="text-sm text-zinc-400">Photos</p>
<p className="text-lg font-bold text-white">{stats.photo_count || 0}</p>
<p className="text-xs text-zinc-400">Photos</p>
</div>
</div>
</Card>
<Card className="bg-zinc-900 border-zinc-800 p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-600/20 rounded-lg flex items-center justify-center">
<FileText className="h-5 w-5 text-blue-400" />
<Card className="bg-zinc-900 border-zinc-800 p-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-600/20 rounded-lg flex items-center justify-center">
<FileText className="h-4 w-4 text-blue-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">{stats.text_count || 0}</p>
<p className="text-sm text-zinc-400">Texts</p>
<p className="text-lg font-bold text-white">{stats.text_count || 0}</p>
<p className="text-xs text-zinc-400">Texts</p>
</div>
</div>
</Card>
<Card className="bg-zinc-900 border-zinc-800 p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-600/20 rounded-lg flex items-center justify-center">
<HardDrive className="h-5 w-5 text-purple-400" />
<Card className="bg-zinc-900 border-zinc-800 p-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-purple-600/20 rounded-lg flex items-center justify-center">
<HardDrive className="h-4 w-4 text-purple-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">{formatSize(stats.total_size || 0)}</p>
<p className="text-sm text-zinc-400">Storage</p>
<p className="text-lg font-bold text-white">{formatSize(stats.total_size || 0)}</p>
<p className="text-xs text-zinc-400">Storage</p>
</div>
</div>
</Card>
@ -291,12 +328,12 @@ export default function ClusterPage({ params }: ClusterPageProps) {
)}
{/* Tabs */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="border-b border-zinc-800">
<nav className="flex gap-4">
<div className="bg-zinc-950 border-b border-zinc-800 flex-shrink-0">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<nav className="flex gap-2">
<button
onClick={() => setActiveTab('folders')}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'folders'
? 'border-primary text-primary'
: 'border-transparent text-zinc-400 hover:text-zinc-300'
@ -307,7 +344,7 @@ export default function ClusterPage({ params }: ClusterPageProps) {
</button>
<button
onClick={() => setActiveTab('videos')}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'videos'
? 'border-primary text-primary'
: 'border-transparent text-zinc-400 hover:text-zinc-300'
@ -318,7 +355,7 @@ export default function ClusterPage({ params }: ClusterPageProps) {
</button>
<button
onClick={() => setActiveTab('photos')}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'photos'
? 'border-primary text-primary'
: 'border-transparent text-zinc-400 hover:text-zinc-300'
@ -329,7 +366,7 @@ export default function ClusterPage({ params }: ClusterPageProps) {
</button>
<button
onClick={() => setActiveTab('texts')}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'texts'
? 'border-primary text-primary'
: 'border-transparent text-zinc-400 hover:text-zinc-300'
@ -340,7 +377,7 @@ export default function ClusterPage({ params }: ClusterPageProps) {
</button>
<button
onClick={() => setActiveTab('stats')}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'stats'
? 'border-primary text-primary'
: 'border-transparent text-zinc-400 hover:text-zinc-300'
@ -354,7 +391,8 @@ export default function ClusterPage({ params }: ClusterPageProps) {
</div>
{/* Content */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex-1 overflow-y-auto">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{activeTab === 'folders' && cluster && (
<ClusterFolderView
clusterId={clusterId}
@ -451,6 +489,7 @@ export default function ClusterPage({ params }: ClusterPageProps) {
</Card>
</div>
)}
</div>
</div>
{/* Video Player Modal */}
@ -462,8 +501,11 @@ export default function ClusterPage({ params }: ClusterPageProps) {
setIsPlayerOpen(false);
setSelectedVideo(null);
}}
playerType="modal"
useArtPlayer={true}
showBookmarks={true}
showRatings={true}
formatFileSize={formatFileSize}
onBookmark={handleBookmark}
onUnbookmark={handleUnbookmark}
onRate={handleRate}

View File

@ -60,7 +60,7 @@ export default function ArtPlayerWrapper({
const [localAvgRating, setLocalAvgRating] = useState(avgRating);
const hlsErrorHandlerRef = useRef<HLSErrorHandler | null>(null);
// Prevent body scroll when video player is open
// Prevent ALL scrolling when video player is open
useEffect(() => {
if (isOpen) {
// Save current body overflow and apply overflow hidden
@ -68,13 +68,52 @@ export default function ArtPlayerWrapper({
const originalOverflowX = document.body.style.overflowX;
const originalOverflowY = document.body.style.overflowY;
// Completely disable outer container scrolling
document.body.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
// Also prevent scrolling on specific scrollable containers
const scrollableContainers = [
// VirtualizedFolderGrid containers
...document.querySelectorAll('.h-full.relative.overflow-hidden'),
// InfiniteVirtualGrid containers
...document.querySelectorAll('.flex-1.relative.overflow-hidden'),
// Any container with FixedSizeGrid
...document.querySelectorAll('[class*="FixedSizeGrid"]'),
// Any container with custom-scrollbar class
...document.querySelectorAll('.custom-scrollbar'),
// Main content containers
...document.querySelectorAll('.min-h-screen'),
...document.querySelectorAll('.max-w-7xl')
];
const originalStyles: Array<{ element: HTMLElement; overflow: string; overflowX: string; overflowY: string }> = [];
scrollableContainers.forEach(container => {
const element = container as HTMLElement;
originalStyles.push({
element,
overflow: element.style.overflow,
overflowX: element.style.overflowX,
overflowY: element.style.overflowY
});
element.style.overflow = 'hidden';
});
return () => {
// Restore original overflow styles
document.body.style.overflow = originalOverflow;
document.body.style.overflowX = originalOverflowX;
document.body.style.overflowY = originalOverflowY;
document.documentElement.style.overflow = '';
// Restore scrollable containers
originalStyles.forEach(({ element, overflow, overflowX, overflowY }) => {
element.style.overflow = overflow;
element.style.overflowX = overflowX;
element.style.overflowY = overflowY;
});
};
}
}, [isOpen]);