Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a26dfd3d6f | |||
| 1db908771f | |||
| 59651b8c24 | |||
| 7ccd48297f |
@@ -11,8 +11,7 @@
|
|||||||
|
|
||||||
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
|
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
|
||||||
|
|
||||||

|

|
||||||
> 📸 *Gallery preview will be updated soon with latest interface*
|
|
||||||
|
|
||||||
## 🌟 Why Choose PicPeak?
|
## 🌟 Why Choose PicPeak?
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.38",
|
"version": "1.0.40",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.38",
|
"version": "1.0.40",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.38",
|
"version": "1.0.40",
|
||||||
"description": "Backend for PicPeak event photo sharing platform",
|
"description": "Backend for PicPeak event photo sharing platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
# Storage Management Scripts
|
|
||||||
|
|
||||||
## check-storage.js
|
|
||||||
|
|
||||||
Checks the storage directory structure and verifies that photo files exist.
|
|
||||||
|
|
||||||
### Usage:
|
|
||||||
```bash
|
|
||||||
# Check overall storage structure
|
|
||||||
node scripts/check-storage.js
|
|
||||||
|
|
||||||
# Check specific event
|
|
||||||
node scripts/check-storage.js wedding-test-gallery-2025-07-14-1
|
|
||||||
```
|
|
||||||
|
|
||||||
### What it checks:
|
|
||||||
- Storage directory structure and permissions
|
|
||||||
- Event directories
|
|
||||||
- Photo files existence
|
|
||||||
- Thumbnail files existence
|
|
||||||
- Database vs filesystem consistency
|
|
||||||
|
|
||||||
## regenerate-thumbnails.js
|
|
||||||
|
|
||||||
Regenerates missing thumbnails for photos in the database.
|
|
||||||
|
|
||||||
### Usage:
|
|
||||||
```bash
|
|
||||||
# Regenerate all missing thumbnails
|
|
||||||
node scripts/regenerate-thumbnails.js
|
|
||||||
|
|
||||||
# Regenerate thumbnails for specific event (by ID)
|
|
||||||
node scripts/regenerate-thumbnails.js 2
|
|
||||||
```
|
|
||||||
|
|
||||||
### What it does:
|
|
||||||
- Scans photos in database
|
|
||||||
- Checks if thumbnails exist
|
|
||||||
- Generates missing thumbnails using Sharp
|
|
||||||
- Updates database with thumbnail paths
|
|
||||||
- Reports success/error statistics
|
|
||||||
|
|
||||||
### Prerequisites:
|
|
||||||
- Node.js environment
|
|
||||||
- Database access
|
|
||||||
- Write permissions to storage directory
|
|
||||||
- Sharp library installed
|
|
||||||
|
|
||||||
## Production Usage
|
|
||||||
|
|
||||||
On your production server:
|
|
||||||
|
|
||||||
1. First, check the storage structure:
|
|
||||||
```bash
|
|
||||||
cd /path/to/picpeak/backend
|
|
||||||
NODE_ENV=production node scripts/check-storage.js wedding-test-gallery-2025-07-14-1
|
|
||||||
```
|
|
||||||
|
|
||||||
2. If thumbnails are missing, regenerate them:
|
|
||||||
```bash
|
|
||||||
NODE_ENV=production node scripts/regenerate-thumbnails.js 2
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: Replace `2` with the actual event ID from your database.
|
|
||||||
|
|
||||||
## cleanup-thumbnails.js
|
|
||||||
|
|
||||||
Cleans up temporary and orphaned thumbnail files.
|
|
||||||
|
|
||||||
### Usage:
|
|
||||||
```bash
|
|
||||||
# Dry run - see what would be deleted
|
|
||||||
node scripts/cleanup-thumbnails.js --dry-run
|
|
||||||
|
|
||||||
# Actually delete orphaned thumbnails
|
|
||||||
node scripts/cleanup-thumbnails.js
|
|
||||||
```
|
|
||||||
|
|
||||||
### What it does:
|
|
||||||
- Identifies temporary thumbnails (thumb_temp_*)
|
|
||||||
- Finds orphaned thumbnails not linked to any photo
|
|
||||||
- Removes unnecessary files to free up space
|
|
||||||
- Reports statistics on cleanup
|
|
||||||
|
|
||||||
## diagnose-thumbnails.js
|
|
||||||
|
|
||||||
Diagnoses why thumbnails might not be showing for a specific event.
|
|
||||||
|
|
||||||
### Usage:
|
|
||||||
```bash
|
|
||||||
node scripts/diagnose-thumbnails.js 2
|
|
||||||
```
|
|
||||||
|
|
||||||
### What it checks:
|
|
||||||
- Thumbnail paths in database vs filesystem
|
|
||||||
- Path format inconsistencies
|
|
||||||
- Missing thumbnail files
|
|
||||||
- Provides SQL to fix path issues
|
|
||||||
|
|
||||||
### Common Issues:
|
|
||||||
1. **Path mismatch**: Database has wrong thumbnail path format
|
|
||||||
2. **Missing files**: Thumbnails were never generated
|
|
||||||
3. **Permission issues**: Web server can't read thumbnail files
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.4 MiB |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.38",
|
"version": "1.0.40",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.38",
|
"version": "1.0.40",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"@tiptap/extension-link": "^2.25.0",
|
"@tiptap/extension-link": "^2.25.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.38",
|
"version": "1.0.40",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -62,9 +62,14 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
|||||||
let imageUrl = src;
|
let imageUrl = src;
|
||||||
|
|
||||||
// Build full URL for the image
|
// Build full URL for the image
|
||||||
const fullImageUrl = imageUrl.startsWith('/') ? buildResourceUrl(imageUrl) : imageUrl;
|
// For API paths that start with /admin, we need to prepend /api
|
||||||
|
const fullImageUrl = imageUrl.startsWith('/admin')
|
||||||
|
? buildResourceUrl(`/api${imageUrl}`)
|
||||||
|
: imageUrl.startsWith('/')
|
||||||
|
? buildResourceUrl(imageUrl)
|
||||||
|
: imageUrl;
|
||||||
|
|
||||||
console.log('Fetching authenticated image:', fullImageUrl);
|
// console.log('Fetching authenticated image:', fullImageUrl);
|
||||||
const response = await fetch(fullImageUrl, {
|
const response = await fetch(fullImageUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`
|
'Authorization': `Bearer ${token}`
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
@@ -33,12 +33,47 @@ export const EventsListPage: React.FC = () => {
|
|||||||
const [selectedEvents, setSelectedEvents] = useState<number[]>([]);
|
const [selectedEvents, setSelectedEvents] = useState<number[]>([]);
|
||||||
// const [showFilters, setShowFilters] = useState(false);
|
// const [showFilters, setShowFilters] = useState(false);
|
||||||
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
|
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
|
||||||
|
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null);
|
||||||
const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false);
|
const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false);
|
||||||
|
|
||||||
// Get filter from URL
|
// Get filter from URL
|
||||||
const statusFilter = searchParams.get('filter') as 'active' | 'archived' | null;
|
const statusFilter = searchParams.get('filter') as 'active' | 'archived' | null;
|
||||||
const isExpiringFilter = searchParams.get('filter') === 'expiring';
|
const isExpiringFilter = searchParams.get('filter') === 'expiring';
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (!target.closest('.dropdown-container')) {
|
||||||
|
setActiveDropdown(null);
|
||||||
|
setDropdownPosition(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (activeDropdown !== null) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
}, [activeDropdown]);
|
||||||
|
|
||||||
|
// Update dropdown position on scroll/resize
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScrollOrResize = () => {
|
||||||
|
if (activeDropdown !== null) {
|
||||||
|
setActiveDropdown(null);
|
||||||
|
setDropdownPosition(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScrollOrResize, true);
|
||||||
|
window.addEventListener('resize', handleScrollOrResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handleScrollOrResize, true);
|
||||||
|
window.removeEventListener('resize', handleScrollOrResize);
|
||||||
|
};
|
||||||
|
}, [activeDropdown]);
|
||||||
|
|
||||||
// Fetch events
|
// Fetch events
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ['admin-events', statusFilter],
|
queryKey: ['admin-events', statusFilter],
|
||||||
@@ -272,8 +307,8 @@ export const EventsListPage: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Events Table */}
|
{/* Events Table */}
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-visible">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto overflow-y-visible">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -347,21 +382,38 @@ export const EventsListPage: React.FC = () => {
|
|||||||
{event.expires_at ? format(parseISO(event.expires_at), 'MMM d, yyyy') : 'N/A'}
|
{event.expires_at ? format(parseISO(event.expires_at), 'MMM d, yyyy') : 'N/A'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right">
|
<td className="px-6 py-4 text-right">
|
||||||
<div className="relative inline-block text-left">
|
<div className="relative inline-block text-left dropdown-container">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveDropdown(activeDropdown === event.id ? null : event.id)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (activeDropdown === event.id) {
|
||||||
|
setActiveDropdown(null);
|
||||||
|
setDropdownPosition(null);
|
||||||
|
} else {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
setActiveDropdown(event.id);
|
||||||
|
setDropdownPosition({
|
||||||
|
top: rect.bottom + window.scrollY,
|
||||||
|
left: rect.right - 224 + window.scrollX // 224px = 14rem (w-56)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="text-neutral-400 hover:text-neutral-600 p-1"
|
className="text-neutral-400 hover:text-neutral-600 p-1"
|
||||||
>
|
>
|
||||||
<MoreVertical className="w-5 h-5" />
|
<MoreVertical className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{activeDropdown === event.id && (
|
{activeDropdown === event.id && dropdownPosition && (
|
||||||
<div className="absolute right-0 z-10 mt-2 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5">
|
<div
|
||||||
|
className="fixed z-50 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5"
|
||||||
|
style={{ top: `${dropdownPosition.top}px`, left: `${dropdownPosition.left}px` }}
|
||||||
|
>
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate(`/admin/events/${event.id}`);
|
navigate(`/admin/events/${event.id}`);
|
||||||
setActiveDropdown(null);
|
setActiveDropdown(null);
|
||||||
|
setDropdownPosition(null);
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
@@ -374,7 +426,10 @@ export const EventsListPage: React.FC = () => {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
||||||
onClick={() => setActiveDropdown(null)}
|
onClick={() => {
|
||||||
|
setActiveDropdown(null);
|
||||||
|
setDropdownPosition(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-4 h-4" />
|
<ExternalLink className="w-4 h-4" />
|
||||||
{t('events.viewGallery')}
|
{t('events.viewGallery')}
|
||||||
@@ -385,6 +440,7 @@ export const EventsListPage: React.FC = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
archiveMutation.mutate(event.id);
|
archiveMutation.mutate(event.id);
|
||||||
setActiveDropdown(null);
|
setActiveDropdown(null);
|
||||||
|
setDropdownPosition(null);
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
@@ -397,6 +453,7 @@ export const EventsListPage: React.FC = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
toast.info(t('events.downloadArchiveSoon'));
|
toast.info(t('events.downloadArchiveSoon'));
|
||||||
setActiveDropdown(null);
|
setActiveDropdown(null);
|
||||||
|
setDropdownPosition(null);
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
@@ -409,6 +466,7 @@ export const EventsListPage: React.FC = () => {
|
|||||||
if (confirm(t('events.deleteEventConfirm'))) {
|
if (confirm(t('events.deleteEventConfirm'))) {
|
||||||
deleteMutation.mutate(event.id);
|
deleteMutation.mutate(event.id);
|
||||||
setActiveDropdown(null);
|
setActiveDropdown(null);
|
||||||
|
setDropdownPosition(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
|
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export const settingsService = {
|
|||||||
formData.append('logo', file);
|
formData.append('logo', file);
|
||||||
|
|
||||||
const response = await api.post<{ logoUrl: string }>(
|
const response = await api.post<{ logoUrl: string }>(
|
||||||
'/api/admin/settings/logo',
|
'/admin/settings/logo',
|
||||||
formData,
|
formData,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
@@ -118,7 +118,7 @@ export const settingsService = {
|
|||||||
formData.append('favicon', file);
|
formData.append('favicon', file);
|
||||||
|
|
||||||
const response = await api.post<{ faviconUrl: string }>(
|
const response = await api.post<{ faviconUrl: string }>(
|
||||||
'/api/admin/settings/favicon',
|
'/admin/settings/favicon',
|
||||||
formData,
|
formData,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
@@ -136,7 +136,7 @@ export const settingsService = {
|
|||||||
formData.append('watermarkLogo', file);
|
formData.append('watermarkLogo', file);
|
||||||
|
|
||||||
const response = await api.post<{ watermarkLogoUrl: string }>(
|
const response = await api.post<{ watermarkLogoUrl: string }>(
|
||||||
'/api/admin/settings/branding/watermark-logo',
|
'/admin/settings/branding/watermark-logo',
|
||||||
formData,
|
formData,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
Reference in New Issue
Block a user