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]); }, [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 () => { const fetchClusterData = async () => {
try { try {
setLoading(true); setLoading(true);
@ -124,20 +138,46 @@ export default function ClusterPage({ params }: ClusterPageProps) {
const handleRate = async (id: number, rating: number) => { const handleRate = async (id: number, rating: number) => {
try { try {
const res = await fetch('/api/stars', { 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mediaId: id, rating }) body: JSON.stringify({ mediaId: id, rating })
}); });
if (res.ok) { }
// Refresh data // Refresh data
fetchClusterData(); fetchClusterData();
}
} catch (error) { } catch (error) {
console.error('Error rating:', 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 getIconComponent = (iconName: string) => {
const icons: Record<string, any> = { const icons: Record<string, any> = {
folder: Folder, folder: Folder,
@ -191,11 +231,12 @@ export default function ClusterPage({ params }: ClusterPageProps) {
const IconComponent = getIconComponent(cluster.icon); const IconComponent = getIconComponent(cluster.icon);
return ( return (
<div className="min-h-screen bg-zinc-950"> <div className="h-screen bg-zinc-950 overflow-hidden flex flex-col">
{/* Header */} {/* Header */}
<div className="bg-zinc-900 border-b border-zinc-800"> <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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
<div className="flex items-center gap-4 mb-4"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -205,84 +246,80 @@ export default function ClusterPage({ params }: ClusterPageProps) {
<ArrowLeft className="h-4 w-4 mr-2" /> <ArrowLeft className="h-4 w-4 mr-2" />
Back Back
</Button> </Button>
</div>
<div className="flex items-start gap-4">
<div <div
className="w-16 h-16 rounded-xl flex items-center justify-center shadow-lg flex-shrink-0" className="w-10 h-10 rounded-lg flex items-center justify-center shadow-lg flex-shrink-0"
style={{ backgroundColor: `${cluster.color}20` }} style={{ backgroundColor: `${cluster.color}20` }}
> >
<IconComponent className="h-8 w-8" style={{ color: cluster.color }} /> <IconComponent className="h-5 w-5" style={{ color: cluster.color }} />
</div> </div>
<div className="flex-1"> <div>
<h1 className="text-3xl font-bold text-white mb-2">{cluster.name}</h1> <h1 className="text-xl font-bold text-white">{cluster.name}</h1>
{cluster.description && ( {cluster.description && (
<p className="text-zinc-400 text-lg">{cluster.description}</p> <p className="text-zinc-400 text-sm">{cluster.description}</p>
)} )}
<div className="flex gap-3 mt-3 text-sm text-zinc-500"> </div>
<span>{libraries.length} {libraries.length === 1 ? 'library' : 'libraries'}</span> </div>
{stats && ( {stats && (
<> <div className="flex gap-4 text-sm text-zinc-500">
<span>{libraries.length} {libraries.length === 1 ? 'library' : 'libraries'}</span>
<span></span> <span></span>
<span>{stats.total_media} items</span> <span>{stats.total_media} items</span>
<span></span> <span></span>
<span>{formatSize(stats.total_size)}</span> <span>{formatSize(stats.total_size)}</span>
</> </div>
)} )}
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
{/* Stats Cards */} {/* Stats Cards */}
{stats && ( {stats && (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card className="bg-zinc-900 border-zinc-800 p-4"> <Card className="bg-zinc-900 border-zinc-800 p-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<div className="w-10 h-10 bg-red-600/20 rounded-lg flex items-center justify-center"> <div className="w-8 h-8 bg-red-600/20 rounded-lg flex items-center justify-center">
<Film className="h-5 w-5 text-red-400" /> <Film className="h-4 w-4 text-red-400" />
</div> </div>
<div> <div>
<p className="text-2xl font-bold text-white">{stats.video_count || 0}</p> <p className="text-lg font-bold text-white">{stats.video_count || 0}</p>
<p className="text-sm text-zinc-400">Videos</p> <p className="text-xs text-zinc-400">Videos</p>
</div> </div>
</div> </div>
</Card> </Card>
<Card className="bg-zinc-900 border-zinc-800 p-4"> <Card className="bg-zinc-900 border-zinc-800 p-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<div className="w-10 h-10 bg-green-600/20 rounded-lg flex items-center justify-center"> <div className="w-8 h-8 bg-green-600/20 rounded-lg flex items-center justify-center">
<ImageIcon className="h-5 w-5 text-green-400" /> <ImageIcon className="h-4 w-4 text-green-400" />
</div> </div>
<div> <div>
<p className="text-2xl font-bold text-white">{stats.photo_count || 0}</p> <p className="text-lg font-bold text-white">{stats.photo_count || 0}</p>
<p className="text-sm text-zinc-400">Photos</p> <p className="text-xs text-zinc-400">Photos</p>
</div> </div>
</div> </div>
</Card> </Card>
<Card className="bg-zinc-900 border-zinc-800 p-4"> <Card className="bg-zinc-900 border-zinc-800 p-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<div className="w-10 h-10 bg-blue-600/20 rounded-lg flex items-center justify-center"> <div className="w-8 h-8 bg-blue-600/20 rounded-lg flex items-center justify-center">
<FileText className="h-5 w-5 text-blue-400" /> <FileText className="h-4 w-4 text-blue-400" />
</div> </div>
<div> <div>
<p className="text-2xl font-bold text-white">{stats.text_count || 0}</p> <p className="text-lg font-bold text-white">{stats.text_count || 0}</p>
<p className="text-sm text-zinc-400">Texts</p> <p className="text-xs text-zinc-400">Texts</p>
</div> </div>
</div> </div>
</Card> </Card>
<Card className="bg-zinc-900 border-zinc-800 p-4"> <Card className="bg-zinc-900 border-zinc-800 p-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<div className="w-10 h-10 bg-purple-600/20 rounded-lg flex items-center justify-center"> <div className="w-8 h-8 bg-purple-600/20 rounded-lg flex items-center justify-center">
<HardDrive className="h-5 w-5 text-purple-400" /> <HardDrive className="h-4 w-4 text-purple-400" />
</div> </div>
<div> <div>
<p className="text-2xl font-bold text-white">{formatSize(stats.total_size || 0)}</p> <p className="text-lg font-bold text-white">{formatSize(stats.total_size || 0)}</p>
<p className="text-sm text-zinc-400">Storage</p> <p className="text-xs text-zinc-400">Storage</p>
</div> </div>
</div> </div>
</Card> </Card>
@ -291,12 +328,12 @@ export default function ClusterPage({ params }: ClusterPageProps) {
)} )}
{/* Tabs */} {/* Tabs */}
<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"> <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-2">
<nav className="flex gap-4">
<button <button
onClick={() => setActiveTab('folders')} 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' activeTab === 'folders'
? 'border-primary text-primary' ? 'border-primary text-primary'
: 'border-transparent text-zinc-400 hover:text-zinc-300' : 'border-transparent text-zinc-400 hover:text-zinc-300'
@ -307,7 +344,7 @@ export default function ClusterPage({ params }: ClusterPageProps) {
</button> </button>
<button <button
onClick={() => setActiveTab('videos')} 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' activeTab === 'videos'
? 'border-primary text-primary' ? 'border-primary text-primary'
: 'border-transparent text-zinc-400 hover:text-zinc-300' : 'border-transparent text-zinc-400 hover:text-zinc-300'
@ -318,7 +355,7 @@ export default function ClusterPage({ params }: ClusterPageProps) {
</button> </button>
<button <button
onClick={() => setActiveTab('photos')} 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' activeTab === 'photos'
? 'border-primary text-primary' ? 'border-primary text-primary'
: 'border-transparent text-zinc-400 hover:text-zinc-300' : 'border-transparent text-zinc-400 hover:text-zinc-300'
@ -329,7 +366,7 @@ export default function ClusterPage({ params }: ClusterPageProps) {
</button> </button>
<button <button
onClick={() => setActiveTab('texts')} 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' activeTab === 'texts'
? 'border-primary text-primary' ? 'border-primary text-primary'
: 'border-transparent text-zinc-400 hover:text-zinc-300' : 'border-transparent text-zinc-400 hover:text-zinc-300'
@ -340,7 +377,7 @@ export default function ClusterPage({ params }: ClusterPageProps) {
</button> </button>
<button <button
onClick={() => setActiveTab('stats')} 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' activeTab === 'stats'
? 'border-primary text-primary' ? 'border-primary text-primary'
: 'border-transparent text-zinc-400 hover:text-zinc-300' : 'border-transparent text-zinc-400 hover:text-zinc-300'
@ -354,6 +391,7 @@ export default function ClusterPage({ params }: ClusterPageProps) {
</div> </div>
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{activeTab === 'folders' && cluster && ( {activeTab === 'folders' && cluster && (
<ClusterFolderView <ClusterFolderView
@ -452,6 +490,7 @@ export default function ClusterPage({ params }: ClusterPageProps) {
</div> </div>
)} )}
</div> </div>
</div>
{/* Video Player Modal */} {/* Video Player Modal */}
{isPlayerOpen && selectedVideo && ( {isPlayerOpen && selectedVideo && (
@ -462,8 +501,11 @@ export default function ClusterPage({ params }: ClusterPageProps) {
setIsPlayerOpen(false); setIsPlayerOpen(false);
setSelectedVideo(null); setSelectedVideo(null);
}} }}
playerType="modal"
useArtPlayer={true}
showBookmarks={true} showBookmarks={true}
showRatings={true} showRatings={true}
formatFileSize={formatFileSize}
onBookmark={handleBookmark} onBookmark={handleBookmark}
onUnbookmark={handleUnbookmark} onUnbookmark={handleUnbookmark}
onRate={handleRate} onRate={handleRate}

View File

@ -60,7 +60,7 @@ export default function ArtPlayerWrapper({
const [localAvgRating, setLocalAvgRating] = useState(avgRating); const [localAvgRating, setLocalAvgRating] = useState(avgRating);
const hlsErrorHandlerRef = useRef<HLSErrorHandler | null>(null); const hlsErrorHandlerRef = useRef<HLSErrorHandler | null>(null);
// Prevent body scroll when video player is open // Prevent ALL scrolling when video player is open
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
// Save current body overflow and apply overflow hidden // Save current body overflow and apply overflow hidden
@ -68,13 +68,52 @@ export default function ArtPlayerWrapper({
const originalOverflowX = document.body.style.overflowX; const originalOverflowX = document.body.style.overflowX;
const originalOverflowY = document.body.style.overflowY; const originalOverflowY = document.body.style.overflowY;
// Completely disable outer container scrolling
document.body.style.overflow = 'hidden'; 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 () => { return () => {
// Restore original overflow styles // Restore original overflow styles
document.body.style.overflow = originalOverflow; document.body.style.overflow = originalOverflow;
document.body.style.overflowX = originalOverflowX; document.body.style.overflowX = originalOverflowX;
document.body.style.overflowY = originalOverflowY; 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]); }, [isOpen]);