feat: enhance folder viewer and grid layout for improved usability
- Updated path display logic to show full paths or truncated versions based on length, enhancing clarity for users navigating directories. - Adjusted grid layout and item dimensions for better responsiveness and visual consistency across different screen sizes. - Improved title formatting and added line clamping to prevent overflow, ensuring a cleaner presentation of media item names. - Refined star rating component to support additional size options, allowing for better integration with various UI elements.
This commit is contained in:
parent
6c289e591d
commit
efd5e70e1f
|
|
@ -108,12 +108,12 @@ const FolderViewerPage = () => {
|
||||||
const filename = path.substring(lastSeparatorIndex + 1);
|
const filename = path.substring(lastSeparatorIndex + 1);
|
||||||
|
|
||||||
// If directory is short enough, show it all
|
// If directory is short enough, show it all
|
||||||
if (directory.length <= 30) {
|
if (directory.length <= 80) {
|
||||||
return `${directory}/${filename}`;
|
return `${directory}/${filename}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Truncate directory with ellipsis in the middle
|
// Truncate directory with ellipsis in the middle
|
||||||
const maxDirLength = 25;
|
const maxDirLength = 75;
|
||||||
const startLength = Math.floor(maxDirLength / 2);
|
const startLength = Math.floor(maxDirLength / 2);
|
||||||
const endLength = maxDirLength - startLength - 3; // -3 for "..."
|
const endLength = maxDirLength - startLength - 3; // -3 for "..."
|
||||||
|
|
||||||
|
|
@ -124,6 +124,18 @@ const FolderViewerPage = () => {
|
||||||
return `${truncatedDir}/${filename}`;
|
return `${truncatedDir}/${filename}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatTitle = (title: string) => {
|
||||||
|
if (!title) return '';
|
||||||
|
|
||||||
|
// If title is short enough, return as is
|
||||||
|
if (title.length <= 40) {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For longer titles, truncate to prevent awkward third-line display
|
||||||
|
return title.length > 60 ? title.substring(0, 60) + '...' : title;
|
||||||
|
};
|
||||||
|
|
||||||
const getLibraryRoot = (currentPath: string): string | null => {
|
const getLibraryRoot = (currentPath: string): string | null => {
|
||||||
if (!currentPath || libraries.length === 0) return null;
|
if (!currentPath || libraries.length === 0) return null;
|
||||||
|
|
||||||
|
|
@ -351,7 +363,7 @@ const FolderViewerPage = () => {
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="col-span-full text-center py-12">
|
<div className="col-span-full text-center py-12">
|
||||||
<div className="max-w-sm mx-auto">
|
<div className="max-w-sm mx-auto">
|
||||||
|
|
@ -374,7 +386,7 @@ const FolderViewerPage = () => {
|
||||||
|
|
||||||
{!error && Array.isArray(items) && items.map((item) => (
|
{!error && Array.isArray(items) && items.map((item) => (
|
||||||
<div key={item.name}
|
<div key={item.name}
|
||||||
className={`group relative bg-white dark:bg-slate-800 rounded-xl shadow-sm hover:shadow-lg transition-all duration-300 hover:-translate-y-1 overflow-hidden ${(item.type === 'video' || item.type === 'photo') ? 'cursor-pointer' : ''}`}>
|
className={`group relative bg-white dark:bg-slate-800 rounded-xl shadow-sm hover:shadow-lg transition-all duration-300 hover:-translate-y-1 overflow-hidden min-h-[280px] ${(item.type === 'video' || item.type === 'photo') ? 'cursor-pointer' : ''}`}>
|
||||||
<Link href={item.isDirectory ? `/folder-viewer?path=${item.path}` : '#'}
|
<Link href={item.isDirectory ? `/folder-viewer?path=${item.path}` : '#'}
|
||||||
className="block"
|
className="block"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|
@ -396,44 +408,29 @@ const FolderViewerPage = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : isMediaFile(item) ? (
|
) : isMediaFile(item) ? (
|
||||||
<div className={`relative overflow-hidden ${item.type === 'video' ? 'aspect-video' : 'aspect-[4/3]'}`}>
|
<div className="relative overflow-hidden aspect-[5/3] bg-black rounded-t-xl">
|
||||||
<img
|
<img
|
||||||
src={item.thumbnail || (item.type === 'video' ? '/placeholder-video.svg' : '/placeholder-photo.svg')}
|
src={item.thumbnail || (item.type === 'video' ? '/placeholder-video.svg' : '/placeholder-photo.svg')}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
className="w-full h-full object-contain transition-transform duration-300 group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
|
||||||
<div className="absolute bottom-2 left-2 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
|
||||||
{item.type === 'video' ? (
|
|
||||||
<div className="bg-white/90 backdrop-blur-sm rounded-full p-2 shadow-lg">
|
|
||||||
<Film className="h-4 w-4 text-slate-800" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="bg-white/90 backdrop-blur-sm rounded-full p-2 shadow-lg">
|
|
||||||
<Image className="h-4 w-4 text-slate-800" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{item.type === 'video' && (
|
{item.type === 'video' && (
|
||||||
<>
|
<>
|
||||||
{/* Always visible small play icon */}
|
<div className="absolute top-2 right-2 bg-black/60 text-white rounded-full p-1">
|
||||||
<div className="absolute top-2 right-2 bg-black/70 backdrop-blur-sm rounded-full p-1.5">
|
<Play className="h-3 w-3" />
|
||||||
<Play className="h-3 w-3 text-white" />
|
|
||||||
</div>
|
</div>
|
||||||
{/* Large play button on hover */}
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
|
||||||
<div className="bg-white/90 backdrop-blur-sm rounded-full p-3 shadow-lg">
|
|
||||||
<Play className="h-6 w-6 text-slate-800" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Loading overlay when video is being opened */}
|
|
||||||
{isVideoLoading && selectedVideo?.id === item.id && (
|
{isVideoLoading && selectedVideo?.id === item.id && (
|
||||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center">
|
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-white border-t-transparent"></div>
|
<div className="animate-spin rounded-full h-6 w-6 border-2 border-white border-t-transparent"></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{item.type === 'photo' && (
|
||||||
|
<div className="absolute top-2 right-2 bg-black/60 text-white rounded-full p-1">
|
||||||
|
<Image className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-100 to-slate-200 dark:from-slate-700 dark:to-slate-800 flex items-center justify-center"
|
<div className="absolute inset-0 bg-gradient-to-br from-slate-100 to-slate-200 dark:from-slate-700 dark:to-slate-800 flex items-center justify-center"
|
||||||
|
|
@ -445,32 +442,27 @@ const FolderViewerPage = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3">
|
<div className="p-3 flex flex-col h-28 bg-slate-50 dark:bg-slate-800/50 border-t border-slate-200 dark:border-slate-700">
|
||||||
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100 truncate mb-1">{item.name}</p>
|
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100 line-clamp-2 leading-tight mb-1.5 min-h-[2rem]" title={item.name}>{formatTitle(item.name)}</p>
|
||||||
|
|
||||||
{/* Star Rating for media files */}
|
<div className="flex items-center justify-between text-xs mb-1.5">
|
||||||
{isMediaFile(item) && (item.avg_rating || 0) > 0 && (
|
<span className="text-slate-600 dark:text-slate-400">{formatFileSize(item.size)}</span>
|
||||||
<div className="mb-2">
|
{isMediaFile(item) && (item.avg_rating || 0) > 0 && (
|
||||||
<StarRating
|
<StarRating
|
||||||
rating={item.avg_rating || 0}
|
rating={item.avg_rating || 0}
|
||||||
count={item.star_count || 0}
|
count={item.star_count || 0}
|
||||||
size="sm"
|
size="xs"
|
||||||
showCount={true}
|
showCount={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-xs text-slate-600 dark:text-slate-400">{formatFileSize(item.size)}</p>
|
|
||||||
{!item.isDirectory && (
|
|
||||||
<p
|
|
||||||
className="text-xs text-slate-500 dark:text-slate-500 truncate ml-2 cursor-help"
|
|
||||||
title={item.path}
|
|
||||||
>
|
|
||||||
{formatFilePath(item.path)}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
className="text-xs text-slate-500 dark:text-slate-400 line-clamp-2 leading-tight cursor-help flex-1"
|
||||||
|
title={item.path}
|
||||||
|
>
|
||||||
|
{formatFilePath(item.path)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ interface InfiniteVirtualGridProps {
|
||||||
onRate: (id: number, rating: number) => Promise<void>;
|
onRate: (id: number, rating: number) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ITEM_HEIGHT = 300;
|
const ITEM_HEIGHT = 220;
|
||||||
const ITEMS_PER_BATCH = 50;
|
const ITEMS_PER_BATCH = 50;
|
||||||
const BUFFER_SIZE = 200;
|
const BUFFER_SIZE = 200;
|
||||||
|
|
||||||
|
|
@ -354,7 +354,7 @@ export default function InfiniteVirtualGrid({
|
||||||
className="group hover:shadow-lg transition-all duration-300 hover:-translate-y-1 cursor-pointer border-border overflow-hidden h-full"
|
className="group hover:shadow-lg transition-all duration-300 hover:-translate-y-1 cursor-pointer border-border overflow-hidden h-full"
|
||||||
onClick={() => onItemClick(item)}
|
onClick={() => onItemClick(item)}
|
||||||
>
|
>
|
||||||
<div className={`relative overflow-hidden bg-muted ${type === 'video' ? 'aspect-video' : 'aspect-square'}`}>
|
<div className="relative overflow-hidden bg-muted aspect-video">
|
||||||
<img
|
<img
|
||||||
src={item.thumbnail || (type === 'video' ? "/placeholder-video.svg" : "/placeholder-photo.svg")}
|
src={item.thumbnail || (type === 'video' ? "/placeholder-video.svg" : "/placeholder-photo.svg")}
|
||||||
alt={item.title}
|
alt={item.title}
|
||||||
|
|
@ -384,34 +384,54 @@ export default function InfiniteVirtualGrid({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardContent className="p-3">
|
<CardContent className="p-2">
|
||||||
<h3 className="font-medium text-foreground text-sm line-clamp-2 mb-2 group-hover:text-primary transition-colors">
|
<div className="flex items-start justify-between min-h-[2rem]">
|
||||||
{item.title || item.path.split('/').pop()}
|
<div className="flex-1 min-w-0">
|
||||||
</h3>
|
<h3 className="font-medium text-foreground text-xs line-clamp-2 group-hover:text-primary transition-colors leading-tight">
|
||||||
|
{item.title || item.path.split('/').pop()}
|
||||||
|
</h3>
|
||||||
|
|
||||||
{(item.avg_rating > 0 || item.star_count > 0) && (
|
{(item.avg_rating > 0 || item.star_count > 0) && (
|
||||||
<div className="mb-2">
|
<div className="mt-0.5">
|
||||||
<StarRating
|
<StarRating
|
||||||
rating={item.avg_rating || 0}
|
rating={item.avg_rating || 0}
|
||||||
count={item.star_count}
|
count={item.star_count}
|
||||||
size="sm"
|
size="xs"
|
||||||
showCount={true}
|
showCount={false}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex gap-1 ml-1 flex-shrink-0">
|
||||||
<div className="flex items-center gap-1">
|
{type === 'video' && item.bookmark_count > 0 && (
|
||||||
<HardDrive className="h-3 w-3" />
|
<div className="text-xs text-yellow-500">
|
||||||
<span>{formatFileSize(item.size)}</span>
|
<Bookmark className="h-2.5 w-2.5 fill-yellow-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p
|
|
||||||
className="text-xs text-muted-foreground mt-1 line-clamp-1 cursor-help"
|
<div className="mt-1 space-y-0.5">
|
||||||
title={item.path}
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
>
|
<div className="flex items-center gap-1">
|
||||||
{formatFilePath(item.path)}
|
<HardDrive className="h-2.5 w-2.5" />
|
||||||
</p>
|
<span>{formatFileSize(item.size)}</span>
|
||||||
|
</div>
|
||||||
|
{type === 'video' && item.bookmark_count > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{item.bookmark_count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
className="text-xs text-muted-foreground line-clamp-1 cursor-help hover:text-foreground transition-colors"
|
||||||
|
title={item.path}
|
||||||
|
>
|
||||||
|
{formatFilePath(item.path)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { cn } from '@/lib/utils';
|
||||||
interface StarRatingProps {
|
interface StarRatingProps {
|
||||||
rating: number;
|
rating: number;
|
||||||
count?: number;
|
count?: number;
|
||||||
size?: 'sm' | 'md' | 'lg';
|
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||||
showCount?: boolean;
|
showCount?: boolean;
|
||||||
interactive?: boolean;
|
interactive?: boolean;
|
||||||
onRate?: (rating: number) => void;
|
onRate?: (rating: number) => void;
|
||||||
|
|
@ -23,6 +23,7 @@ export function StarRating({
|
||||||
className
|
className
|
||||||
}: StarRatingProps) {
|
}: StarRatingProps) {
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
|
xs: 'h-2.5 w-2.5',
|
||||||
sm: 'h-3 w-3',
|
sm: 'h-3 w-3',
|
||||||
md: 'h-4 w-4',
|
md: 'h-4 w-4',
|
||||||
lg: 'h-5 w-5'
|
lg: 'h-5 w-5'
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue