Fixed thumbnails ratio. Preparing user preference management
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user