Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb3751cb52 | |||
| 9fda54bd06 | |||
| 0d77a3a0a8 | |||
| 0618b78725 | |||
| 0178e71c67 |
@@ -72,6 +72,14 @@ docker-compose up -d
|
|||||||
# Access at http://localhost:3005
|
# Access at http://localhost:3005
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Note on Docker file permissions (PUID/PGID)
|
||||||
|
- When using bind mounts (e.g., `./storage`, `./data`, `./logs`, `./events`), ensure the container user can write to these host folders. The backend runs as a non‑root user by default.
|
||||||
|
- Set `PUID` and `PGID` in your `.env` to match your host user’s UID/GID (run `id -u` and `id -g` on the host). Compose maps the container user to these values.
|
||||||
|
- Example in `.env`:
|
||||||
|
- `PUID=1000`
|
||||||
|
- `PGID=1000`
|
||||||
|
- Without this, creating events, uploads, thumbnails, or logs can fail with “Permission denied”.
|
||||||
|
|
||||||
## 📖 Documentation
|
## 📖 Documentation
|
||||||
|
|
||||||
- 📘 [**Deployment Guide**](DEPLOYMENT_GUIDE.md) - Detailed installation instructions
|
- 📘 [**Deployment Guide**](DEPLOYMENT_GUIDE.md) - Detailed installation instructions
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.123",
|
"version": "1.0.124",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.123",
|
"version": "1.0.124",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.850.0",
|
"@aws-sdk/client-s3": "^3.850.0",
|
||||||
"@aws-sdk/lib-storage": "^3.850.0",
|
"@aws-sdk/lib-storage": "^3.850.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.123",
|
"version": "1.0.124",
|
||||||
"description": "Backend for PicPeak event photo sharing platform",
|
"description": "Backend for PicPeak event photo sharing platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.123",
|
"version": "1.0.125",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.123",
|
"version": "1.0.125",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"@tiptap/extension-character-count": "^2.26.1",
|
"@tiptap/extension-character-count": "^2.26.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.123",
|
"version": "1.0.125",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
if (e) {
|
if (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
|
// Auto-enable selection mode when selecting via checkbox
|
||||||
|
if (!isSelectionMode) {
|
||||||
|
setIsSelectionMode(true);
|
||||||
|
}
|
||||||
const newSelected = new Set(selectedPhotos);
|
const newSelected = new Set(selectedPhotos);
|
||||||
if (newSelected.has(photoId)) {
|
if (newSelected.has(photoId)) {
|
||||||
newSelected.delete(photoId);
|
newSelected.delete(photoId);
|
||||||
@@ -126,7 +129,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
{isSelectionMode ? 'Cancel Selection' : 'Select Photos'}
|
{isSelectionMode ? 'Cancel Selection' : 'Select Photos'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{isSelectionMode && (
|
{(isSelectionMode || selectedPhotos.size > 0) && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -167,25 +170,32 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
|
data-testid={`admin-photo-tile-${photo.id}`}
|
||||||
className={`relative group cursor-pointer rounded-lg overflow-hidden bg-neutral-100 transition-opacity ${
|
className={`relative group cursor-pointer rounded-lg overflow-hidden bg-neutral-100 transition-opacity ${
|
||||||
isSelectionMode ? 'ring-2 ring-offset-2 ' + (selectedPhotos.has(photo.id) ? 'ring-primary-500' : 'ring-transparent') : ''
|
isSelectionMode ? 'ring-2 ring-offset-2 ' + (selectedPhotos.has(photo.id) ? 'ring-primary-500' : 'ring-transparent') : ''
|
||||||
} ${isDeleting ? 'opacity-50' : ''}`}
|
} ${isDeleting ? 'opacity-50' : ''}`}
|
||||||
onClick={() => !isDeleting && (isSelectionMode ? handlePhotoSelect(photo.id) : onPhotoClick(photo, index))}
|
onClick={() => !isDeleting && onPhotoClick(photo, index)}
|
||||||
>
|
>
|
||||||
{/* Selection Checkbox */}
|
{/* Selection Checkbox (top-right) */}
|
||||||
{isSelectionMode && (
|
<button
|
||||||
<div className="absolute top-2 left-2 z-10">
|
type="button"
|
||||||
<div className={`w-6 h-6 rounded border-2 flex items-center justify-center ${
|
aria-label={`Select ${photo.filename}`}
|
||||||
selectedPhotos.has(photo.id)
|
role="checkbox"
|
||||||
? 'bg-primary-500 border-primary-500'
|
aria-checked={selectedPhotos.has(photo.id)}
|
||||||
: 'bg-white/80 border-neutral-300'
|
data-testid={`admin-photo-checkbox-${photo.id}`}
|
||||||
}`}>
|
className={`absolute top-2 right-2 z-20 transition-opacity ${
|
||||||
{selectedPhotos.has(photo.id) && (
|
selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||||
<Check className="w-4 h-4 text-white" />
|
}`}
|
||||||
)}
|
onClick={(e) => handlePhotoSelect(photo.id, e)}
|
||||||
</div>
|
>
|
||||||
|
<div className={`w-6 h-6 rounded border-2 flex items-center justify-center ${
|
||||||
|
selectedPhotos.has(photo.id)
|
||||||
|
? 'bg-primary-600 border-primary-600'
|
||||||
|
: 'bg-white/90 border-white'
|
||||||
|
}`}>
|
||||||
|
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</button>
|
||||||
|
|
||||||
{/* Thumbnail */}
|
{/* Thumbnail */}
|
||||||
<div className="aspect-square">
|
<div className="aspect-square">
|
||||||
@@ -240,7 +250,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
|
|
||||||
{/* Category Badge */}
|
{/* Category Badge */}
|
||||||
{photo.category_name && (
|
{photo.category_name && (
|
||||||
<div className="absolute top-2 right-2">
|
<div className="absolute right-2" style={{ top: (selectedPhotos.has(photo.id) || isSelectionMode) ? '40px' : '8px' }}>
|
||||||
<span className="px-2 py-1 text-xs font-medium bg-white/90 text-neutral-700 rounded">
|
<span className="px-2 py-1 text-xs font-medium bg-white/90 text-neutral-700 rounded">
|
||||||
{photo.category_name}
|
{photo.category_name}
|
||||||
</span>
|
</span>
|
||||||
@@ -276,4 +286,4 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -81,6 +81,14 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePhotoSelect = (photoId: number) => {
|
const handlePhotoSelect = (photoId: number) => {
|
||||||
|
// Auto-enable selection mode when selecting via checkbox
|
||||||
|
if (!isSelectionMode) {
|
||||||
|
if (parentToggleSelectionMode) {
|
||||||
|
parentToggleSelectionMode();
|
||||||
|
} else {
|
||||||
|
setLocalSelectionMode(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
const newSelected = new Set(selectedPhotos);
|
const newSelected = new Set(selectedPhotos);
|
||||||
if (newSelected.has(photoId)) {
|
if (newSelected.has(photoId)) {
|
||||||
newSelected.delete(photoId);
|
newSelected.delete(photoId);
|
||||||
@@ -279,4 +287,4 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface GridPhotoProps {
|
|||||||
isSelectionMode: boolean;
|
isSelectionMode: boolean;
|
||||||
onClick: (e: React.MouseEvent) => void;
|
onClick: (e: React.MouseEvent) => void;
|
||||||
onDownload: (e: React.MouseEvent) => void;
|
onDownload: (e: React.MouseEvent) => void;
|
||||||
|
onToggleSelect: () => void;
|
||||||
animationType?: string;
|
animationType?: string;
|
||||||
allowDownloads?: boolean;
|
allowDownloads?: boolean;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
@@ -26,6 +27,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
|||||||
isSelectionMode,
|
isSelectionMode,
|
||||||
onClick,
|
onClick,
|
||||||
onDownload,
|
onDownload,
|
||||||
|
onToggleSelect,
|
||||||
animationType = 'fade',
|
animationType = 'fade',
|
||||||
allowDownloads = true,
|
allowDownloads = true,
|
||||||
slug,
|
slug,
|
||||||
@@ -105,13 +107,22 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isSelectionMode && (
|
{/* Selection Checkbox (visible on hover or when selected) */}
|
||||||
<div className={`absolute top-2 right-2 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>
|
<button
|
||||||
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}>
|
type="button"
|
||||||
{isSelected && <Check className="w-4 h-4 text-white" />}
|
aria-label={`Select ${photo.filename}`}
|
||||||
</div>
|
role="checkbox"
|
||||||
|
aria-checked={isSelected}
|
||||||
|
data-testid={`gallery-photo-checkbox-${photo.id}`}
|
||||||
|
className={`absolute top-2 right-2 z-20 transition-opacity ${
|
||||||
|
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||||
|
}`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
|
||||||
|
>
|
||||||
|
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
|
||||||
|
{isSelected && <Check className="w-4 h-4 text-white" />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</button>
|
||||||
|
|
||||||
{/* Feedback Indicators */}
|
{/* Feedback Indicators */}
|
||||||
{feedbackEnabled && (photo.comment_count > 0 || photo.average_rating > 0 || photo.like_count > 0) && (
|
{feedbackEnabled && (photo.comment_count > 0 || photo.average_rating > 0 || photo.like_count > 0) && (
|
||||||
@@ -187,13 +198,8 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
photo={photo}
|
photo={photo}
|
||||||
isSelected={selectedPhotos.has(photo.id)}
|
isSelected={selectedPhotos.has(photo.id)}
|
||||||
isSelectionMode={isSelectionMode}
|
isSelectionMode={isSelectionMode}
|
||||||
onClick={() => {
|
onClick={() => onPhotoClick(index)}
|
||||||
if (isSelectionMode && onPhotoSelect) {
|
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
|
||||||
onPhotoSelect(photo.id);
|
|
||||||
} else {
|
|
||||||
onPhotoClick(index);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDownload={(e) => onDownload(photo, e)}
|
onDownload={(e) => onDownload(photo, e)}
|
||||||
animationType={animation}
|
animationType={animation}
|
||||||
allowDownloads={allowDownloads}
|
allowDownloads={allowDownloads}
|
||||||
@@ -205,4 +211,4 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -152,13 +152,7 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
|||||||
<div
|
<div
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
className="relative group cursor-pointer aspect-square"
|
className="relative group cursor-pointer aspect-square"
|
||||||
onClick={() => {
|
onClick={() => onPhotoClick(actualIndex)}
|
||||||
if (isSelectionMode && onPhotoSelect) {
|
|
||||||
onPhotoSelect(photo.id);
|
|
||||||
} else {
|
|
||||||
onPhotoClick(actualIndex);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<AuthenticatedImage
|
<AuthenticatedImage
|
||||||
src={photo.thumbnail_url || photo.url}
|
src={photo.thumbnail_url || photo.url}
|
||||||
@@ -198,17 +192,26 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isSelectionMode && (
|
{/* Selection Checkbox (visible on hover or when selected) */}
|
||||||
<div className={`absolute top-2 right-2 ${selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>
|
<button
|
||||||
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}>
|
type="button"
|
||||||
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
|
aria-label={`Select ${photo.filename}`}
|
||||||
</div>
|
role="checkbox"
|
||||||
|
aria-checked={selectedPhotos.has(photo.id)}
|
||||||
|
data-testid={`gallery-photo-checkbox-${photo.id}`}
|
||||||
|
className={`absolute top-2 right-2 z-20 transition-opacity ${
|
||||||
|
selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||||
|
}`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photo.id); }}
|
||||||
|
>
|
||||||
|
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
|
||||||
|
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface MasonryPhotoProps {
|
|||||||
isSelectionMode: boolean;
|
isSelectionMode: boolean;
|
||||||
onClick: (e: React.MouseEvent) => void;
|
onClick: (e: React.MouseEvent) => void;
|
||||||
onDownload: (e: React.MouseEvent) => void;
|
onDownload: (e: React.MouseEvent) => void;
|
||||||
|
onToggleSelect: () => void;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
allowDownloads?: boolean;
|
allowDownloads?: boolean;
|
||||||
feedbackEnabled?: boolean;
|
feedbackEnabled?: boolean;
|
||||||
@@ -22,6 +23,7 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
|||||||
isSelectionMode,
|
isSelectionMode,
|
||||||
onClick,
|
onClick,
|
||||||
onDownload,
|
onDownload,
|
||||||
|
onToggleSelect,
|
||||||
style,
|
style,
|
||||||
allowDownloads = true,
|
allowDownloads = true,
|
||||||
feedbackEnabled = false
|
feedbackEnabled = false
|
||||||
@@ -104,13 +106,22 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isSelectionMode && (
|
{/* Selection Checkbox (visible on hover or when selected) */}
|
||||||
<div className={`absolute top-2 right-2 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>
|
<button
|
||||||
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}>
|
type="button"
|
||||||
{isSelected && <Check className="w-4 h-4 text-white" />}
|
aria-label={`Select ${photo.filename}`}
|
||||||
</div>
|
role="checkbox"
|
||||||
|
aria-checked={isSelected}
|
||||||
|
data-testid={`gallery-photo-checkbox-${photo.id}`}
|
||||||
|
className={`absolute top-2 right-2 z-20 transition-opacity ${
|
||||||
|
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||||
|
}`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
|
||||||
|
>
|
||||||
|
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
|
||||||
|
{isSelected && <Check className="w-4 h-4 text-white" />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</button>
|
||||||
|
|
||||||
{photo.type === 'collage' && (
|
{photo.type === 'collage' && (
|
||||||
<div className="absolute bottom-2 left-2">
|
<div className="absolute bottom-2 left-2">
|
||||||
@@ -182,14 +193,9 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
photo={photo}
|
photo={photo}
|
||||||
isSelected={selectedPhotos.has(photo.id)}
|
isSelected={selectedPhotos.has(photo.id)}
|
||||||
isSelectionMode={isSelectionMode}
|
isSelectionMode={isSelectionMode}
|
||||||
onClick={() => {
|
onClick={() => onPhotoClick(originalIndex)}
|
||||||
if (isSelectionMode && onPhotoSelect) {
|
|
||||||
onPhotoSelect(photo.id);
|
|
||||||
} else {
|
|
||||||
onPhotoClick(originalIndex);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDownload={(e) => onDownload(photo, e)}
|
onDownload={(e) => onDownload(photo, e)}
|
||||||
|
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
|
||||||
allowDownloads={allowDownloads}
|
allowDownloads={allowDownloads}
|
||||||
feedbackEnabled={feedbackEnabled}
|
feedbackEnabled={feedbackEnabled}
|
||||||
/>
|
/>
|
||||||
@@ -199,4 +205,4 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface MosaicPhotoProps {
|
|||||||
isSelectionMode: boolean;
|
isSelectionMode: boolean;
|
||||||
onClick: (e: React.MouseEvent) => void;
|
onClick: (e: React.MouseEvent) => void;
|
||||||
onDownload: (e: React.MouseEvent) => void;
|
onDownload: (e: React.MouseEvent) => void;
|
||||||
|
onToggleSelect: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
allowDownloads?: boolean;
|
allowDownloads?: boolean;
|
||||||
}
|
}
|
||||||
@@ -21,6 +22,7 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
|||||||
isSelectionMode,
|
isSelectionMode,
|
||||||
onClick,
|
onClick,
|
||||||
onDownload,
|
onDownload,
|
||||||
|
onToggleSelect,
|
||||||
className = '',
|
className = '',
|
||||||
allowDownloads = true
|
allowDownloads = true
|
||||||
}) => {
|
}) => {
|
||||||
@@ -69,13 +71,22 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isSelectionMode && (
|
{/* Selection Checkbox (visible on hover or when selected) */}
|
||||||
<div className={`absolute top-2 right-2 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>
|
<button
|
||||||
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}>
|
type="button"
|
||||||
{isSelected && <Check className="w-4 h-4 text-white" />}
|
aria-label={`Select ${photo.filename}`}
|
||||||
</div>
|
role="checkbox"
|
||||||
|
aria-checked={isSelected}
|
||||||
|
data-testid={`gallery-photo-checkbox-${photo.id}`}
|
||||||
|
className={`absolute top-2 right-2 z-20 transition-opacity ${
|
||||||
|
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||||
|
}`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
|
||||||
|
>
|
||||||
|
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
|
||||||
|
{isSelected && <Check className="w-4 h-4 text-white" />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</button>
|
||||||
|
|
||||||
{photo.type === 'collage' && (
|
{photo.type === 'collage' && (
|
||||||
<div className="absolute bottom-2 left-2">
|
<div className="absolute bottom-2 left-2">
|
||||||
@@ -136,8 +147,9 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
photo={photo0}
|
photo={photo0}
|
||||||
isSelected={selectedPhotos.has(photo0.id)}
|
isSelected={selectedPhotos.has(photo0.id)}
|
||||||
isSelectionMode={isSelectionMode}
|
isSelectionMode={isSelectionMode}
|
||||||
onClick={() => handlePhotoClick(idx0, photo0.id)}
|
onClick={() => onPhotoClick(idx0)}
|
||||||
onDownload={(e) => onDownload(photo0, e)}
|
onDownload={(e) => onDownload(photo0, e)}
|
||||||
|
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo0.id)}
|
||||||
className="col-span-1"
|
className="col-span-1"
|
||||||
allowDownloads={allowDownloads}
|
allowDownloads={allowDownloads}
|
||||||
/>
|
/>
|
||||||
@@ -148,22 +160,24 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
photo={photo1}
|
photo={photo1}
|
||||||
isSelected={selectedPhotos.has(photo1.id)}
|
isSelected={selectedPhotos.has(photo1.id)}
|
||||||
isSelectionMode={isSelectionMode}
|
isSelectionMode={isSelectionMode}
|
||||||
onClick={() => handlePhotoClick(idx1, photo1.id)}
|
onClick={() => onPhotoClick(idx1)}
|
||||||
onDownload={(e) => onDownload(photo1, e)}
|
onDownload={(e) => onDownload(photo1, e)}
|
||||||
className=""
|
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo1.id)}
|
||||||
allowDownloads={allowDownloads}
|
className=""
|
||||||
/>
|
allowDownloads={allowDownloads}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{photo2 && (
|
{photo2 && (
|
||||||
<MosaicPhoto
|
<MosaicPhoto
|
||||||
photo={photo2}
|
photo={photo2}
|
||||||
isSelected={selectedPhotos.has(photo2.id)}
|
isSelected={selectedPhotos.has(photo2.id)}
|
||||||
isSelectionMode={isSelectionMode}
|
isSelectionMode={isSelectionMode}
|
||||||
onClick={() => handlePhotoClick(idx2, photo2.id)}
|
onClick={() => onPhotoClick(idx2)}
|
||||||
onDownload={(e) => onDownload(photo2, e)}
|
onDownload={(e) => onDownload(photo2, e)}
|
||||||
className=""
|
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo2.id)}
|
||||||
allowDownloads={allowDownloads}
|
className=""
|
||||||
/>
|
allowDownloads={allowDownloads}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -209,8 +223,9 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
photo={photo0}
|
photo={photo0}
|
||||||
isSelected={selectedPhotos.has(photo0.id)}
|
isSelected={selectedPhotos.has(photo0.id)}
|
||||||
isSelectionMode={isSelectionMode}
|
isSelectionMode={isSelectionMode}
|
||||||
onClick={() => handlePhotoClick(idx0, photo0.id)}
|
onClick={() => onPhotoClick(idx0)}
|
||||||
onDownload={(e) => onDownload(photo0, e)}
|
onDownload={(e) => onDownload(photo0, e)}
|
||||||
|
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo0.id)}
|
||||||
className="col-span-2"
|
className="col-span-2"
|
||||||
allowDownloads={allowDownloads}
|
allowDownloads={allowDownloads}
|
||||||
/>
|
/>
|
||||||
@@ -221,22 +236,24 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
photo={photo1}
|
photo={photo1}
|
||||||
isSelected={selectedPhotos.has(photo1.id)}
|
isSelected={selectedPhotos.has(photo1.id)}
|
||||||
isSelectionMode={isSelectionMode}
|
isSelectionMode={isSelectionMode}
|
||||||
onClick={() => handlePhotoClick(idx1, photo1.id)}
|
onClick={() => onPhotoClick(idx1)}
|
||||||
onDownload={(e) => onDownload(photo1, e)}
|
onDownload={(e) => onDownload(photo1, e)}
|
||||||
className=""
|
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo1.id)}
|
||||||
allowDownloads={allowDownloads}
|
className=""
|
||||||
/>
|
allowDownloads={allowDownloads}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{photo2 && (
|
{photo2 && (
|
||||||
<MosaicPhoto
|
<MosaicPhoto
|
||||||
photo={photo2}
|
photo={photo2}
|
||||||
isSelected={selectedPhotos.has(photo2.id)}
|
isSelected={selectedPhotos.has(photo2.id)}
|
||||||
isSelectionMode={isSelectionMode}
|
isSelectionMode={isSelectionMode}
|
||||||
onClick={() => handlePhotoClick(idx2, photo2.id)}
|
onClick={() => onPhotoClick(idx2)}
|
||||||
onDownload={(e) => onDownload(photo2, e)}
|
onDownload={(e) => onDownload(photo2, e)}
|
||||||
className=""
|
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo2.id)}
|
||||||
allowDownloads={allowDownloads}
|
className=""
|
||||||
/>
|
allowDownloads={allowDownloads}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,8 +280,9 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
photo={photo}
|
photo={photo}
|
||||||
isSelected={selectedPhotos.has(photo.id)}
|
isSelected={selectedPhotos.has(photo.id)}
|
||||||
isSelectionMode={isSelectionMode}
|
isSelectionMode={isSelectionMode}
|
||||||
onClick={() => handlePhotoClick(index, photo.id)}
|
onClick={() => onPhotoClick(index)}
|
||||||
onDownload={(e) => onDownload(photo, e)}
|
onDownload={(e) => onDownload(photo, e)}
|
||||||
|
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
|
||||||
className="aspect-square"
|
className="aspect-square"
|
||||||
allowDownloads={allowDownloads}
|
allowDownloads={allowDownloads}
|
||||||
/>
|
/>
|
||||||
@@ -282,4 +300,4 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
{renderMosaicLayout()}
|
{renderMosaicLayout()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -90,13 +90,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
<div
|
<div
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
className="relative group cursor-pointer aspect-square"
|
className="relative group cursor-pointer aspect-square"
|
||||||
onClick={() => {
|
onClick={() => onPhotoClick(actualIndex)}
|
||||||
if (isSelectionMode && onPhotoSelect) {
|
|
||||||
onPhotoSelect(photo.id);
|
|
||||||
} else {
|
|
||||||
onPhotoClick(actualIndex);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<AuthenticatedImage
|
<AuthenticatedImage
|
||||||
src={photo.thumbnail_url || photo.url}
|
src={photo.thumbnail_url || photo.url}
|
||||||
@@ -141,13 +135,22 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isSelectionMode && (
|
{/* Selection Checkbox (visible on hover or when selected) */}
|
||||||
<div className={`absolute top-2 right-2 ${selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>
|
<button
|
||||||
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}>
|
type="button"
|
||||||
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
|
aria-label={`Select ${photo.filename}`}
|
||||||
</div>
|
role="checkbox"
|
||||||
|
aria-checked={selectedPhotos.has(photo.id)}
|
||||||
|
data-testid={`gallery-photo-checkbox-${photo.id}`}
|
||||||
|
className={`absolute top-2 right-2 z-20 transition-opacity ${
|
||||||
|
selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||||
|
}`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photo.id); }}
|
||||||
|
>
|
||||||
|
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
|
||||||
|
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -157,4 +160,4 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+18
-1
@@ -352,7 +352,7 @@ setup_docker_installation() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
log_step "Creating application directory at $app_dir"
|
log_step "Creating application directory at $app_dir"
|
||||||
mkdir -p "$app_dir"/{storage/events/{active,archived},logs,backup,config}
|
mkdir -p "$app_dir"/{storage/events/{active,archived},logs,backup,config,data,events}
|
||||||
|
|
||||||
# Clone repository
|
# Clone repository
|
||||||
log_step "Downloading PicPeak..."
|
log_step "Downloading PicPeak..."
|
||||||
@@ -363,6 +363,16 @@ setup_docker_installation() {
|
|||||||
git clone "$REPO_URL" "$app_dir"
|
git clone "$REPO_URL" "$app_dir"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Determine host user for container mapping (PUID/PGID)
|
||||||
|
local host_uid host_gid
|
||||||
|
if [[ -n "${SUDO_USER:-}" ]]; then
|
||||||
|
host_uid=$(id -u "$SUDO_USER" 2>/dev/null || echo 1000)
|
||||||
|
host_gid=$(id -g "$SUDO_USER" 2>/dev/null || echo 1000)
|
||||||
|
else
|
||||||
|
host_uid=$(id -u 2>/dev/null || echo 1000)
|
||||||
|
host_gid=$(id -g 2>/dev/null || echo 1000)
|
||||||
|
fi
|
||||||
|
|
||||||
# Generate secrets
|
# Generate secrets
|
||||||
local jwt_secret=$(generate_jwt_secret)
|
local jwt_secret=$(generate_jwt_secret)
|
||||||
local db_password=$(generate_password)
|
local db_password=$(generate_password)
|
||||||
@@ -382,6 +392,10 @@ JWT_SECRET=$jwt_secret
|
|||||||
# Admin
|
# Admin
|
||||||
ADMIN_EMAIL=$ADMIN_EMAIL
|
ADMIN_EMAIL=$ADMIN_EMAIL
|
||||||
|
|
||||||
|
# Runtime user mapping for Docker bind mounts
|
||||||
|
PUID=$host_uid
|
||||||
|
PGID=$host_gid
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DB_HOST=postgres
|
DB_HOST=postgres
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
@@ -415,6 +429,9 @@ ENABLE_EXPIRATION_CHECKER=true
|
|||||||
ENABLE_EMAIL_SERVICE=true
|
ENABLE_EMAIL_SERVICE=true
|
||||||
DEFAULT_EXPIRY_DAYS=30
|
DEFAULT_EXPIRY_DAYS=30
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
# Ensure bind mounts are writable by mapped user
|
||||||
|
chown -R "$host_uid":"$host_gid" "$app_dir"/storage "$app_dir"/logs "$app_dir"/backup "$app_dir"/data "$app_dir"/events 2>/dev/null || true
|
||||||
|
|
||||||
# Create docker-compose.yml if it doesn't exist
|
# Create docker-compose.yml if it doesn't exist
|
||||||
if [[ ! -f "$app_dir/docker-compose.yml" ]]; then
|
if [[ ! -f "$app_dir/docker-compose.yml" ]]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user