Fixed thumbnails ratio. Preparing user preference management

This commit is contained in:
2025-10-08 23:19:17 +02:00
parent 707507b128
commit 264dac077c
9 changed files with 935 additions and 90 deletions

View File

@@ -174,6 +174,8 @@ class UserRepository:
update_data["role"] = user_update.role
if user_update.is_active is not None:
update_data["is_active"] = user_update.is_active
if user_update.preferences is not None:
update_data["preferences"] = user_update.preferences
# Remove None values from update data
clean_update_data = {k: v for k, v in update_data.items() if v is not None}

View File

@@ -105,6 +105,7 @@ class UserUpdate(BaseModel):
password: Optional[str] = None
role: Optional[UserRole] = None
is_active: Optional[bool] = None
preferences: Optional[dict] = None
@field_validator('username')
@classmethod
@@ -130,6 +131,7 @@ class UserInDB(BaseModel):
hashed_password: str
role: UserRole
is_active: bool = True
preferences: dict = Field(default_factory=dict)
created_at: datetime
updated_at: datetime

View File

@@ -184,3 +184,18 @@ class UserService:
bool: True if user exists, False otherwise
"""
return self.user_repository.user_exists(username)
def get_preference(self, user_id: str, preference):
user = self.get_user_by_id(user_id)
if user is None:
return None
return user.preferences.get(preference, None)
def set_preference(self, user_id: str, preference, value):
user = self.get_user_by_id(user_id)
if user is None:
return None
user.preferences[preference] = value
self.user_repository.update_user(user_id, UserUpdate(preferences=user.preferences))
return self.get_user_by_id(user_id)

View File

@@ -18,7 +18,9 @@
/* Main Content Area */
.mainContent {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
min-height: 0; /* Important for flex to work properly with scrolling */
}
/* Main Content Inner Container */
@@ -26,5 +28,9 @@
max-width: 80rem; /* container max-width */
margin-left: auto;
margin-right: auto;
padding: 2rem 1rem;
padding: 0.5rem 1rem;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0; /* Important for flex to work properly with scrolling */
}

View File

@@ -4,7 +4,7 @@
* Supports different view modes: small, large, and detail
*/
import React, {memo} from 'react';
import React, {memo, useState, useEffect} from 'react';
import {API_BASE_URL} from "../../utils/api.js";
/**
@@ -40,11 +40,67 @@ const formatDate = (dateString) => {
*/
const buildFullUrl = (relativePath) => {
if (!relativePath) return '';
// Use the base URL from your API configuration
const baseUrl = import.meta.env.VITE_API_BASE_URL || API_BASE_URL;
return `${baseUrl}${relativePath}`;
};
/**
* Hook to load protected images with bearer token
* @param {string} url - Image URL
* @returns {Object} { imageSrc, loading, error }
*/
const useProtectedImage = (url) => {
const [imageSrc, setImageSrc] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
if (!url) {
setLoading(false);
return;
}
let objectUrl;
const fetchImage = async () => {
try {
const token = localStorage.getItem('access_token');
const fullUrl = buildFullUrl(url);
const response = await fetch(fullUrl, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error('Failed to load image');
}
const blob = await response.blob();
objectUrl = URL.createObjectURL(blob);
setImageSrc(objectUrl);
setLoading(false);
} catch (err) {
console.error('Error loading thumbnail:', err);
setError(true);
setLoading(false);
}
};
fetchImage();
// Cleanup: revoke object URL on unmount
return () => {
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [url]);
return { imageSrc, loading, error };
};
/**
* DocumentCard component
* @param {Object} props
@@ -57,6 +113,9 @@ const buildFullUrl = (relativePath) => {
const DocumentCard = memo(({document, viewMode, onEdit, onDelete}) => {
const {name, originalFileType, thumbnailUrl, pageCount, fileSize, createdAt, tags, categories} = document;
// Load protected image
const { imageSrc, loading, error } = useProtectedImage(thumbnailUrl);
// Determine card classes based on view mode
const getCardClasses = () => {
const baseClasses = 'card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow group relative';
@@ -74,51 +133,67 @@ const DocumentCard = memo(({document, viewMode, onEdit, onDelete}) => {
};
// Render thumbnail with hover actions
const renderThumbnail = () => (
<figure className="relative overflow-hidden">
<img
src={buildFullUrl(thumbnailUrl)}
alt={`${name}`}
className={`w-[200px] object-cover ${
viewMode === 'small' ? 'h-32' : viewMode === 'large' ? 'h-48' : 'h-64'
}`}
loading="lazy"
/>
const renderThumbnail = () => {
const heightClass = viewMode === 'small' ? 'h-48' : viewMode === 'large' ? 'h-64' : 'h-64';
{/* Hover overlay with actions */}
<div className="absolute top-2 right-2 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
className="btn btn-sm btn-circle btn-primary"
onClick={onEdit}
aria-label="Edit document"
title="Edit"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
<button
className="btn btn-sm btn-circle btn-error"
onClick={onDelete}
aria-label="Delete document"
title="Delete"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
return (
<figure className="relative overflow-hidden">
{loading ? (
<div className={`w-[200px] ${heightClass} bg-gray-200 animate-pulse flex items-center justify-center`}>
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
) : error ? (
<div className={`w-[200px] ${heightClass} bg-gray-300 flex flex-col items-center justify-center`}>
<svg className="w-8 h-8 text-gray-500 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-gray-500 text-xs">Failed to load</span>
</div>
) : (
<img
src={imageSrc}
alt={`${name}`}
className={`object-cover ${heightClass}`}
/>
)}
{/* File type badge */}
<div className="absolute bottom-2 left-2">
<span className="badge badge-accent badge-sm">{originalFileType}</span>
</div>
</figure>
);
{/* Hover overlay with actions */}
<div className="absolute top-2 right-2 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
className="btn btn-sm btn-circle btn-primary"
onClick={onEdit}
aria-label="Edit document"
title="Edit"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
<button
className="btn btn-sm btn-circle btn-error"
onClick={onDelete}
aria-label="Delete document"
title="Delete"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
{/* File type badge */}
<div className="absolute bottom-2 left-2">
<span className="badge badge-accent badge-sm">{originalFileType}</span>
</div>
</figure>
);
};
// Render card body based on view mode
const renderCardBody = () => {

View File

@@ -90,7 +90,7 @@ const DocumentGallery = () => {
// Loading state
if (loading) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<div className="flex justify-center items-center min-h-[400px] ">
<span className="loading loading-spinner loading-lg"></span>
</div>
);
@@ -122,11 +122,10 @@ const DocumentGallery = () => {
}
return (
<div>
{/* Header with view mode switcher */}
<div className="flex justify-between items-center mb-6">
<div className="h-full flex flex-col">
{/* Header with view mode switcher - Always visible */}
<div className="flex justify-between items-center mb-6 flex-shrink-0">
<div>
<h2 className="text-2xl font-bold">Documents</h2>
<p className="text-gray-500">{documents.length} document{documents.length !== 1 ? 's' : ''}</p>
</div>
<ViewModeSwitcher
@@ -135,26 +134,28 @@ const DocumentGallery = () => {
/>
</div>
{/* Document grid/list */}
<div className={getGridClasses()}>
{documents.map(document => (
viewMode === 'detail' ? (
<DocumentDetailView
key={document.id}
document={document}
onEdit={() => handleEditClick(document)}
onDelete={() => handleDeleteClick(document)}
/>
) : (
<DocumentCard
key={document.id}
document={document}
viewMode={viewMode}
onEdit={() => handleEditClick(document)}
onDelete={() => handleDeleteClick(document)}
/>
)
))}
{/* Document grid/list - Scrollable */}
<div className="flex-1 overflow-y-auto">
<div className={getGridClasses()}>
{documents.map(document => (
viewMode === 'detail' ? (
<DocumentDetailView
key={document.id}
document={document}
onEdit={() => handleEditClick(document)}
onDelete={() => handleDeleteClick(document)}
/>
) : (
<DocumentCard
key={document.id}
document={document}
viewMode={viewMode}
onEdit={() => handleEditClick(document)}
onDelete={() => handleDeleteClick(document)}
/>
)
))}
</div>
</div>
{/* Modals */}

View File

@@ -4,6 +4,8 @@
*/
import React from 'react';
import {FaList} from "react-icons/fa6";
import {FaTh, FaThLarge} from "react-icons/fa";
/**
* @typedef {'small' | 'large' | 'detail'} ViewMode
@@ -18,27 +20,30 @@ import React from 'react';
*/
const ViewModeSwitcher = ({ currentMode, onModeChange }) => {
const modes = [
{ id: 'small', label: 'Small', icon: '⊞' },
{ id: 'large', label: 'Large', icon: '⊡' },
{ id: 'detail', label: 'Detail', icon: '☰' }
{ id: 'small', label: 'Small', icon: FaTh },
{ id: 'large', label: 'Large', icon: FaThLarge },
{ id: 'detail', label: 'Detail', icon: FaList }
];
return (
<div className="flex gap-2">
{modes.map(mode => (
<button
key={mode.id}
onClick={() => onModeChange(mode.id)}
className={`btn btn-sm ${
currentMode === mode.id ? 'btn-primary' : 'btn-ghost'
}`}
aria-label={`Switch to ${mode.label} view`}
title={`${mode.label} view`}
>
<span className="text-lg">{mode.icon}</span>
<span className="hidden sm:inline ml-1">{mode.label}</span>
</button>
))}
{modes.map(mode => {
const IconComponent = mode.icon;
return (
<button
key={mode.id}
onClick={() => onModeChange(mode.id)}
className={`btn btn-sm ${
currentMode === mode.id ? 'btn-primary' : 'btn-ghost'
}`}
aria-label={`Switch to ${mode.label} view`}
title={`${mode.label} view`}
>
<IconComponent />
<span className="hidden sm:inline ml-1">{mode.label}</span>
</button>
);
})}
</div>
);
};

View File

@@ -12,7 +12,7 @@ import DocumentGallery from '../components/documents/DocumentGallery';
*/
const DocumentsPage = () => {
return (
<div className="container mx-auto px-4 py-8">
<div className="h-full flex flex-col">
<DocumentGallery />
</div>
);