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:
parent
7e5b122565
commit
4e3c4a1277
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue