Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb3751cb52 | |||
| 9fda54bd06 | |||
| 0d77a3a0a8 | |||
| 0618b78725 | |||
| 0178e71c67 | |||
| aa9b3a0227 | |||
| 410a33fecf | |||
| 05ebaaeedb | |||
| 84d0f63d36 | |||
| 6a4b549d9f | |||
| f3604b438b | |||
| 531831e84b | |||
| 90bb21e38b | |||
| 2f1a137342 | |||
| adf576fbe1 | |||
| 4264026bbe | |||
| 24b4a314a9 | |||
| ba825823a0 | |||
| fb16b7bbb8 | |||
| 8404125ff0 | |||
| 61ad2d61c1 | |||
| 9fd6b44487 | |||
| 9fe10bcce2 | |||
| f2abb40987 | |||
| 3697344cd0 | |||
| 4aa0ff705f | |||
| dc482e614a | |||
| 448882cfef | |||
| 7f9cb33a40 | |||
| 798f6211e0 | |||
| b992b151d3 | |||
| 87b8414e44 | |||
| ee13556c5c | |||
| afeb35a446 | |||
| ab324f1928 |
@@ -53,6 +53,12 @@ VITE_API_URL=/api
|
|||||||
# Timezone
|
# Timezone
|
||||||
TZ=UTC
|
TZ=UTC
|
||||||
|
|
||||||
|
# Runtime user mapping for Docker (optional)
|
||||||
|
# Set these to your host user's UID/GID to avoid permission issues on bind mounts.
|
||||||
|
# Run `id -u` and `id -g` on host to get values. Defaults to 1001.
|
||||||
|
PUID=1001
|
||||||
|
PGID=1001
|
||||||
|
|
||||||
# Analytics (Optional - Umami)
|
# Analytics (Optional - Umami)
|
||||||
VITE_UMAMI_URL=
|
VITE_UMAMI_URL=
|
||||||
VITE_UMAMI_WEBSITE_ID=
|
VITE_UMAMI_WEBSITE_ID=
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Log in to Container Registry
|
- name: Log in to Container Registry
|
||||||
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
|
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
|
||||||
|
id: login-ghcr
|
||||||
|
continue-on-error: true
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
@@ -73,7 +75,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
file: ./backend/Dockerfile
|
file: ./backend/Dockerfile
|
||||||
push: ${{ github.event_name != 'pull_request' || github.event.inputs.push == 'true' }}
|
# Always build; only push when registry login succeeded
|
||||||
|
push: ${{ (github.event_name != 'pull_request' || github.event.inputs.push == 'true') && steps.login-ghcr.outcome == 'success' }}
|
||||||
tags: ${{ steps.meta-backend.outputs.tags }}
|
tags: ${{ steps.meta-backend.outputs.tags }}
|
||||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
@@ -86,7 +89,7 @@ jobs:
|
|||||||
VERSION=${{ steps.meta-backend.outputs.version }}
|
VERSION=${{ steps.meta-backend.outputs.version }}
|
||||||
|
|
||||||
- name: Run Trivy vulnerability scanner
|
- name: Run Trivy vulnerability scanner
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
|
||||||
uses: aquasecurity/trivy-action@master
|
uses: aquasecurity/trivy-action@master
|
||||||
with:
|
with:
|
||||||
image-ref: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
|
image-ref: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
|
||||||
@@ -96,7 +99,7 @@ jobs:
|
|||||||
timeout: '10m'
|
timeout: '10m'
|
||||||
|
|
||||||
- name: Upload Trivy scan results to GitHub Security tab
|
- name: Upload Trivy scan results to GitHub Security tab
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
|
||||||
uses: github/codeql-action/upload-sarif@v3
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
with:
|
with:
|
||||||
sarif_file: 'trivy-backend.sarif'
|
sarif_file: 'trivy-backend.sarif'
|
||||||
@@ -120,6 +123,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Log in to Container Registry
|
- name: Log in to Container Registry
|
||||||
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
|
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
|
||||||
|
id: login-ghcr
|
||||||
|
continue-on-error: true
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
@@ -150,7 +155,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
file: ./frontend/Dockerfile
|
file: ./frontend/Dockerfile
|
||||||
push: ${{ github.event_name != 'pull_request' || github.event.inputs.push == 'true' }}
|
# Always build; only push when registry login succeeded
|
||||||
|
push: ${{ (github.event_name != 'pull_request' || github.event.inputs.push == 'true') && steps.login-ghcr.outcome == 'success' }}
|
||||||
tags: ${{ steps.meta-frontend.outputs.tags }}
|
tags: ${{ steps.meta-frontend.outputs.tags }}
|
||||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
@@ -163,7 +169,7 @@ jobs:
|
|||||||
VERSION=${{ steps.meta-frontend.outputs.version }}
|
VERSION=${{ steps.meta-frontend.outputs.version }}
|
||||||
|
|
||||||
- name: Run Trivy vulnerability scanner
|
- name: Run Trivy vulnerability scanner
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
|
||||||
uses: aquasecurity/trivy-action@master
|
uses: aquasecurity/trivy-action@master
|
||||||
with:
|
with:
|
||||||
image-ref: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
|
image-ref: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
|
||||||
@@ -173,7 +179,7 @@ jobs:
|
|||||||
timeout: '10m'
|
timeout: '10m'
|
||||||
|
|
||||||
- name: Upload Trivy scan results to GitHub Security tab
|
- name: Upload Trivy scan results to GitHub Security tab
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
|
||||||
uses: github/codeql-action/upload-sarif@v3
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
with:
|
with:
|
||||||
sarif_file: 'trivy-frontend.sarif'
|
sarif_file: 'trivy-frontend.sarif'
|
||||||
@@ -220,4 +226,4 @@ jobs:
|
|||||||
echo "- PR number (for pull requests)" >> $GITHUB_STEP_SUMMARY
|
echo "- PR number (for pull requests)" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- Version tags (for releases)" >> $GITHUB_STEP_SUMMARY
|
echo "- Version tags (for releases)" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- Short SHA with branch prefix" >> $GITHUB_STEP_SUMMARY
|
echo "- Short SHA with branch prefix" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- \`latest\` (for main branch)" >> $GITHUB_STEP_SUMMARY
|
echo "- \`latest\` (for main branch)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ Unlike expensive SaaS solutions, PicPeak gives you:
|
|||||||
|
|
||||||
### For Photographers
|
### For Photographers
|
||||||
- 📁 **Drag & Drop Upload** - Simply drop photos into folders
|
- 📁 **Drag & Drop Upload** - Simply drop photos into folders
|
||||||
|
- 🔗 **External Media (Reference Mode)** - Browse and import from a read‑only external folder library without copying originals
|
||||||
- ⏰ **Auto-Expiring Galleries** - Set expiration dates (default: 30 days)
|
- ⏰ **Auto-Expiring Galleries** - Set expiration dates (default: 30 days)
|
||||||
- 🔐 **Password Protection** - Secure client galleries
|
- 🔐 **Password Protection** - Secure client galleries
|
||||||
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
|
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
|
||||||
@@ -45,6 +46,7 @@ Unlike expensive SaaS solutions, PicPeak gives you:
|
|||||||
### Technical Excellence
|
### Technical Excellence
|
||||||
- 🐳 **Docker Ready** - Deploy in minutes
|
- 🐳 **Docker Ready** - Deploy in minutes
|
||||||
- 🔄 **Auto-Processing** - Automatic thumbnail generation
|
- 🔄 **Auto-Processing** - Automatic thumbnail generation
|
||||||
|
- 🗂️ **Reference Library Support** - Point PicPeak at `EXTERNAL_MEDIA_ROOT` to reference existing originals, index quickly, and generate thumbnails on demand
|
||||||
- 💾 **Smart Storage** - Automatic archiving of expired galleries
|
- 💾 **Smart Storage** - Automatic archiving of expired galleries
|
||||||
- 🛡️ **Security First** - JWT auth, rate limiting, CORS protection
|
- 🛡️ **Security First** - JWT auth, rate limiting, CORS protection
|
||||||
- 📈 **Scalable** - From small studios to large agencies
|
- 📈 **Scalable** - From small studios to large agencies
|
||||||
@@ -70,9 +72,18 @@ 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
|
||||||
|
- Includes the new [External Media Library](DEPLOYMENT_GUIDE.md#external-media-library) reference mode
|
||||||
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
|
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
|
||||||
- 📜 [**License**](LICENSE) - MIT License
|
- 📜 [**License**](LICENSE) - MIT License
|
||||||
- 🔒 [**Security**](SECURITY.md) - Security policies
|
- 🔒 [**Security**](SECURITY.md) - Security policies
|
||||||
@@ -192,9 +203,10 @@ These features are currently in beta testing and may have limited functionality
|
|||||||
| Feature | Description | Priority | Status |
|
| Feature | Description | Priority | Status |
|
||||||
|---------|-------------|----------|---------|
|
|---------|-------------|----------|---------|
|
||||||
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
|
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
|
||||||
|
| **External Media Library (Reference Mode)** | Use an external folder library as a read‑only source with import and on‑demand thumbnail generation | High | ✅ Implemented |
|
||||||
| **Gallery Templates** | Additional gallery layouts and themes (masonry, slideshow, story-style) for different event types | Medium | 🔄 Open |
|
| **Gallery Templates** | Additional gallery layouts and themes (masonry, slideshow, story-style) for different event types | Medium | 🔄 Open |
|
||||||
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
|
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
|
||||||
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented (not tested) |
|
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented |
|
||||||
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | 🔄 Open |
|
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | 🔄 Open |
|
||||||
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | 📋 Planned |
|
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | 📋 Planned |
|
||||||
|
|
||||||
@@ -233,4 +245,4 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
|
|||||||
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
|
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
|
||||||
<a href="DEPLOYMENT_GUIDE.md">Documentation</a> •
|
<a href="DEPLOYMENT_GUIDE.md">Documentation</a> •
|
||||||
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
|
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
+9
-9
@@ -8,7 +8,7 @@ This guide provides easy installation instructions for PicPeak on Linux servers
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Download and run the unified setup script
|
# Download and run the unified setup script
|
||||||
curl -fsSL https://raw.githubusercontent.com/yourusername/wedding-photo-sharing/main/scripts/setup.sh -o setup.sh && \
|
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/setup.sh -o setup.sh && \
|
||||||
chmod +x setup.sh && \
|
chmod +x setup.sh && \
|
||||||
sudo ./setup.sh
|
sudo ./setup.sh
|
||||||
```
|
```
|
||||||
@@ -305,9 +305,9 @@ docker compose restart
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Native Configuration
|
### Native Configuration
|
||||||
Edit `/opt/picpeak/backend/.env`:
|
Edit `/opt/picpeak/app/backend/.env`:
|
||||||
```bash
|
```bash
|
||||||
sudo nano /opt/picpeak/backend/.env
|
sudo nano /opt/picpeak/app/backend/.env
|
||||||
sudo systemctl restart picpeak-backend
|
sudo systemctl restart picpeak-backend
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -369,7 +369,7 @@ tar -czf photos-backup.tar.gz storage/events/
|
|||||||
#### Native:
|
#### Native:
|
||||||
```bash
|
```bash
|
||||||
# Database backup
|
# Database backup
|
||||||
sudo cp /opt/picpeak/backend/database.sqlite /backup/database-$(date +%Y%m%d).sqlite
|
sudo cp /opt/picpeak/app/backend/data/photo_sharing.db /backup/database-$(date +%Y%m%d).sqlite
|
||||||
|
|
||||||
# Photos backup
|
# Photos backup
|
||||||
sudo tar -czf /backup/photos-$(date +%Y%m%d).tar.gz /opt/picpeak/events/
|
sudo tar -czf /backup/photos-$(date +%Y%m%d).tar.gz /opt/picpeak/events/
|
||||||
@@ -446,7 +446,7 @@ ls -la ~/picpeak/storage/events/
|
|||||||
docker exec picpeak-backend node scripts/reset-admin-password.js
|
docker exec picpeak-backend node scripts/reset-admin-password.js
|
||||||
|
|
||||||
# Native
|
# Native
|
||||||
cd /opt/picpeak/backend
|
cd /opt/picpeak/app/backend
|
||||||
sudo -u picpeak node scripts/reset-admin-password.js
|
sudo -u picpeak node scripts/reset-admin-password.js
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -458,11 +458,11 @@ sudo -u picpeak node scripts/reset-admin-password.js
|
|||||||
- Installation: `/tmp/picpeak-setup-*.log`
|
- Installation: `/tmp/picpeak-setup-*.log`
|
||||||
|
|
||||||
2. **Documentation:**
|
2. **Documentation:**
|
||||||
- [Full Documentation](https://github.com/yourusername/wedding-photo-sharing)
|
- [Full Documentation](https://github.com/the-luap/picpeak)
|
||||||
- [Deployment Guide](./DEPLOYMENT_GUIDE.md)
|
- [Deployment Guide](./DEPLOYMENT_GUIDE.md)
|
||||||
|
|
||||||
3. **Support:**
|
3. **Support:**
|
||||||
- [GitHub Issues](https://github.com/yourusername/wedding-photo-sharing/issues)
|
- [GitHub Issues](https://github.com/the-luap/picpeak/issues)
|
||||||
- Include: Error messages, system info (`uname -a`), installation method
|
- Include: Error messages, system info (`uname -a`), installation method
|
||||||
|
|
||||||
## 🔒 Security Best Practices
|
## 🔒 Security Best Practices
|
||||||
@@ -498,7 +498,7 @@ services:
|
|||||||
### Native Optimization
|
### Native Optimization
|
||||||
```bash
|
```bash
|
||||||
# Increase Node.js memory
|
# Increase Node.js memory
|
||||||
echo "NODE_OPTIONS=--max-old-space-size=2048" >> /opt/picpeak/backend/.env
|
echo "NODE_OPTIONS=--max-old-space-size=2048" >> /opt/picpeak/app/backend/.env
|
||||||
sudo systemctl restart picpeak-backend
|
sudo systemctl restart picpeak-backend
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -540,4 +540,4 @@ sudo ./setup.sh --native \
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**PicPeak Setup v1.0** | [Documentation](https://github.com/yourusername/wedding-photo-sharing) | [Support](https://github.com/yourusername/wedding-photo-sharing/issues)
|
**PicPeak Setup v1.0** | [Documentation](https://github.com/the-luap/picpeak) | [Support](https://github.com/the-luap/picpeak/issues)
|
||||||
|
|||||||
+32
-24
@@ -26,29 +26,37 @@ const config = {
|
|||||||
|
|
||||||
production: {
|
production: {
|
||||||
client: process.env.DATABASE_CLIENT || 'pg',
|
client: process.env.DATABASE_CLIENT || 'pg',
|
||||||
connection: {
|
// Support both Postgres and SQLite in production based on DATABASE_CLIENT
|
||||||
host: process.env.DB_HOST || 'db',
|
connection: (process.env.DATABASE_CLIENT || 'pg') === 'pg'
|
||||||
port: process.env.DB_PORT || 5432,
|
? {
|
||||||
user: process.env.DB_USER || 'picpeak',
|
host: process.env.DB_HOST || 'db',
|
||||||
password: process.env.DB_PASSWORD,
|
port: process.env.DB_PORT || 5432,
|
||||||
database: process.env.DB_NAME || 'picpeak',
|
user: process.env.DB_USER || 'picpeak',
|
||||||
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
|
password: process.env.DB_PASSWORD,
|
||||||
// Connection stability settings
|
database: process.env.DB_NAME || 'picpeak',
|
||||||
connectionTimeoutMillis: 30000,
|
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
|
||||||
idleTimeoutMillis: 30000,
|
// Connection stability settings
|
||||||
keepAlive: true,
|
connectionTimeoutMillis: 30000,
|
||||||
keepAliveInitialDelayMillis: 0
|
idleTimeoutMillis: 30000,
|
||||||
},
|
keepAlive: true,
|
||||||
pool: {
|
keepAliveInitialDelayMillis: 0
|
||||||
min: 5,
|
}
|
||||||
max: 25,
|
: {
|
||||||
acquireTimeoutMillis: 60000,
|
filename: path.join(__dirname, process.env.DATABASE_PATH || './data/photo_sharing.db')
|
||||||
createTimeoutMillis: 60000,
|
},
|
||||||
idleTimeoutMillis: 30000,
|
useNullAsDefault: (process.env.DATABASE_CLIENT || 'pg') !== 'pg',
|
||||||
reapIntervalMillis: 1000,
|
pool: (process.env.DATABASE_CLIENT || 'pg') === 'pg'
|
||||||
createRetryIntervalMillis: 200,
|
? {
|
||||||
propagateCreateError: false
|
min: 5,
|
||||||
},
|
max: 25,
|
||||||
|
acquireTimeoutMillis: 60000,
|
||||||
|
createTimeoutMillis: 60000,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
reapIntervalMillis: 1000,
|
||||||
|
createRetryIntervalMillis: 200,
|
||||||
|
propagateCreateError: false
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
migrations: {
|
migrations: {
|
||||||
directory: './migrations'
|
directory: './migrations'
|
||||||
},
|
},
|
||||||
@@ -56,4 +64,4 @@ const config = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = config[process.env.NODE_ENV || 'development'];
|
module.exports = config[process.env.NODE_ENV || 'development'];
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.110",
|
"version": "1.0.124",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.110",
|
"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.110",
|
"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": {
|
||||||
|
|||||||
+75
-20
@@ -12,6 +12,7 @@ logger.info('Server starting up', {
|
|||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const helmet = require('helmet');
|
const helmet = require('helmet');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
@@ -42,25 +43,36 @@ const PORT = process.env.PORT || 3000;
|
|||||||
app.set('trust proxy', 'loopback, linklocal, uniquelocal');
|
app.set('trust proxy', 'loopback, linklocal, uniquelocal');
|
||||||
|
|
||||||
// Security middleware with custom CSP
|
// Security middleware with custom CSP
|
||||||
|
// In native HTTP installs, do NOT force HTTPS for subresources.
|
||||||
|
const enableHsts = process.env.ENABLE_HSTS === 'true';
|
||||||
|
const cspDirectives = {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
scriptSrc: ["'self'", "'unsafe-inline'"], // Required for React
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'", "https:"], // Required for styled components
|
||||||
|
imgSrc: ["'self'", "data:", "https:", "blob:"], // Allow data URLs and external images
|
||||||
|
connectSrc: ["'self'"], // API connections
|
||||||
|
fontSrc: ["'self'", "https:", "data:"], // Web fonts
|
||||||
|
objectSrc: ["'none'"], // Disable plugins
|
||||||
|
mediaSrc: ["'self'"], // Audio/video
|
||||||
|
frameSrc: ["'none'"], // Disable iframes
|
||||||
|
};
|
||||||
|
// Only upgrade insecure requests when HSTS explicitly enabled (HTTPS deployment)
|
||||||
|
if (enableHsts) {
|
||||||
|
// In helmet, an empty array enables the directive
|
||||||
|
cspDirectives.upgradeInsecureRequests = [];
|
||||||
|
}
|
||||||
|
|
||||||
app.use(helmet({
|
app.use(helmet({
|
||||||
contentSecurityPolicy: {
|
contentSecurityPolicy: {
|
||||||
directives: {
|
// Avoid helmet adding defaults like upgrade-insecure-requests when not desired
|
||||||
defaultSrc: ["'self'"],
|
useDefaults: false,
|
||||||
scriptSrc: ["'self'", "'unsafe-inline'"], // Required for React
|
directives: cspDirectives,
|
||||||
styleSrc: ["'self'", "'unsafe-inline'", "https:"], // Required for styled components
|
|
||||||
imgSrc: ["'self'", "data:", "https:", "blob:"], // Allow data URLs and external images
|
|
||||||
connectSrc: ["'self'"], // API connections
|
|
||||||
fontSrc: ["'self'", "https:", "data:"], // Web fonts
|
|
||||||
objectSrc: ["'none'"], // Disable plugins
|
|
||||||
mediaSrc: ["'self'"], // Audio/video
|
|
||||||
frameSrc: ["'none'"], // Disable iframes
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
hsts: {
|
hsts: enableHsts ? {
|
||||||
maxAge: 31536000, // 1 year
|
maxAge: 31536000, // 1 year
|
||||||
includeSubDomains: true,
|
includeSubDomains: true,
|
||||||
preload: true
|
preload: true
|
||||||
},
|
} : false,
|
||||||
permittedCrossDomainPolicies: false,
|
permittedCrossDomainPolicies: false,
|
||||||
referrerPolicy: { policy: "strict-origin-when-cross-origin" }
|
referrerPolicy: { policy: "strict-origin-when-cross-origin" }
|
||||||
}));
|
}));
|
||||||
@@ -72,14 +84,14 @@ app.use((req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// CORS configuration
|
// CORS configuration (apply only to API routes)
|
||||||
const corsOptions = {
|
const corsOptions = {
|
||||||
origin: function (origin, callback) {
|
origin: function (origin, callback) {
|
||||||
const allowedOrigins = [
|
const allowedOrigins = [
|
||||||
process.env.FRONTEND_URL || 'http://localhost:3005',
|
process.env.FRONTEND_URL || 'http://localhost:3005',
|
||||||
process.env.ADMIN_URL || 'http://localhost:3005'
|
process.env.ADMIN_URL || 'http://localhost:3005'
|
||||||
];
|
];
|
||||||
|
|
||||||
// In development, also allow localhost origins
|
// In development, also allow localhost origins
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
allowedOrigins.push(
|
allowedOrigins.push(
|
||||||
@@ -89,18 +101,22 @@ const corsOptions = {
|
|||||||
'http://localhost:3000' // Direct backend access
|
'http://localhost:3000' // Direct backend access
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow requests with no origin (like mobile apps or curl)
|
// Allow requests with no origin (like curl) and allow-listed origins
|
||||||
if (!origin || allowedOrigins.indexOf(origin) !== -1) {
|
if (!origin || allowedOrigins.indexOf(origin) !== -1) {
|
||||||
callback(null, true);
|
callback(null, true);
|
||||||
} else {
|
} else {
|
||||||
callback(new Error('Not allowed by CORS'));
|
// Do not error globally; just omit CORS headers on disallowed origins
|
||||||
|
callback(null, false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
credentials: true
|
credentials: true
|
||||||
};
|
};
|
||||||
|
|
||||||
app.use(cors(corsOptions));
|
// Only attach CORS to API endpoints, not static assets
|
||||||
|
app.use('/api', cors(corsOptions));
|
||||||
|
// Handle preflight explicitly for API paths
|
||||||
|
app.options('/api/*', cors(corsOptions));
|
||||||
|
|
||||||
// Initialize rate limiters (they will be created dynamically)
|
// Initialize rate limiters (they will be created dynamically)
|
||||||
let generalRateLimiter;
|
let generalRateLimiter;
|
||||||
@@ -124,6 +140,22 @@ async function initializeRateLimiters() {
|
|||||||
app.use(express.json({ limit: '100mb' }));
|
app.use(express.json({ limit: '100mb' }));
|
||||||
app.use(express.urlencoded({ extended: true, limit: '100mb' }));
|
app.use(express.urlencoded({ extended: true, limit: '100mb' }));
|
||||||
|
|
||||||
|
// Request logging for API routes (with timestamps)
|
||||||
|
const apiRequestLogger = (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const started = Date.now();
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
logger.info(`[${ts}] ${req.method} ${req.originalUrl}`);
|
||||||
|
res.on('finish', () => {
|
||||||
|
const ms = Date.now() - started;
|
||||||
|
const tsDone = new Date().toISOString();
|
||||||
|
logger.info(`[${tsDone}] ${req.method} ${req.originalUrl} -> ${res.statusCode} (${ms}ms)`);
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
app.use('/api', apiRequestLogger);
|
||||||
|
|
||||||
// Maintenance mode middleware - add after body parsing but before routes
|
// Maintenance mode middleware - add after body parsing but before routes
|
||||||
app.use(maintenanceMiddleware);
|
app.use(maintenanceMiddleware);
|
||||||
|
|
||||||
@@ -143,6 +175,7 @@ const secureStatic = require('./src/middleware/secureStatic');
|
|||||||
|
|
||||||
// Get storage path from environment or use default
|
// Get storage path from environment or use default
|
||||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../storage');
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../storage');
|
||||||
|
process.env.EXTERNAL_MEDIA_ROOT = process.env.EXTERNAL_MEDIA_ROOT || '/external-media';
|
||||||
|
|
||||||
// Static file serving for photos (protected)
|
// Static file serving for photos (protected)
|
||||||
app.use('/photos', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'events/active')));
|
app.use('/photos', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'events/active')));
|
||||||
@@ -199,7 +232,8 @@ app.get('/health', async (req, res) => {
|
|||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/events', eventRoutes);
|
app.use('/api/events', eventRoutes);
|
||||||
|
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
|
||||||
// Gallery routes - main routes first, then feedback routes
|
// Gallery routes - main routes first, then feedback routes
|
||||||
app.use('/api/gallery', galleryRoutes);
|
app.use('/api/gallery', galleryRoutes);
|
||||||
app.use('/api/gallery', require('./src/routes/galleryFeedback'));
|
app.use('/api/gallery', require('./src/routes/galleryFeedback'));
|
||||||
@@ -217,6 +251,27 @@ app.use('/api/public', require('./src/routes/publicCMS'));
|
|||||||
app.use('/api/images', require('./src/routes/protectedImages'));
|
app.use('/api/images', require('./src/routes/protectedImages'));
|
||||||
app.use('/api/secure-images', secureImagesRoutes);
|
app.use('/api/secure-images', secureImagesRoutes);
|
||||||
|
|
||||||
|
// Optional: Serve built frontend (native installs)
|
||||||
|
try {
|
||||||
|
const serveFrontendEnv = process.env.SERVE_FRONTEND; // 'true' | 'false' | undefined
|
||||||
|
const frontendDir = process.env.FRONTEND_DIR || path.join(__dirname, '../frontend/dist');
|
||||||
|
const indexPath = path.join(frontendDir, 'index.html');
|
||||||
|
// Auto-serve when dist exists unless explicitly disabled
|
||||||
|
const shouldServe = (serveFrontendEnv === 'true') || ((serveFrontendEnv === undefined || serveFrontendEnv === 'auto') && fs.existsSync(indexPath));
|
||||||
|
if (shouldServe) {
|
||||||
|
logger.info(`Serving frontend from ${frontendDir}`);
|
||||||
|
app.use(express.static(frontendDir));
|
||||||
|
// SPA fallback for non-API routes
|
||||||
|
app.get([ '/', '/admin', '/admin/*', '/gallery/*' ], (req, res) => {
|
||||||
|
res.sendFile(indexPath);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.info('Frontend static serving disabled or dist not found', { serveFrontendEnv, frontendDir });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Failed to enable frontend static serving', { error: e.message });
|
||||||
|
}
|
||||||
|
|
||||||
// Error handling middleware
|
// Error handling middleware
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
console.error('EXPRESS ERROR HANDLER:', err);
|
console.error('EXPRESS ERROR HANDLER:', err);
|
||||||
|
|||||||
@@ -1,7 +1,28 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
const knex = require('knex');
|
const knex = require('knex');
|
||||||
const knexConfig = require('../../knexfile');
|
const knexConfig = require('../../knexfile');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
// Ensure SQLite directory exists when using file-based DB (native installs)
|
||||||
|
try {
|
||||||
|
const isPostgres = knexConfig && knexConfig.client === 'pg';
|
||||||
|
if (!isPostgres && knexConfig && knexConfig.connection) {
|
||||||
|
const filename = typeof knexConfig.connection === 'object'
|
||||||
|
? knexConfig.connection.filename
|
||||||
|
: (typeof knexConfig.connection === 'string' ? knexConfig.connection : null);
|
||||||
|
if (filename && typeof filename === 'string') {
|
||||||
|
const dir = path.dirname(filename);
|
||||||
|
if (dir && dir !== '.') {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Non-fatal: log and continue; SQLite will fail later if still missing
|
||||||
|
try { logger.warn('SQLite directory ensure failed', { error: e.message }); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
// Create database connection with built-in retry logic
|
// Create database connection with built-in retry logic
|
||||||
const db = knex(knexConfig);
|
const db = knex(knexConfig);
|
||||||
|
|
||||||
@@ -312,4 +333,4 @@ async function logActivity(activityType, metadata = {}, eventId = null, actor =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { db, initializeDatabase, logActivity, withRetry };
|
module.exports = { db, initializeDatabase, logActivity, withRetry };
|
||||||
|
|||||||
+6
-1
@@ -29,6 +29,11 @@ services:
|
|||||||
- ADMIN_URL=${ADMIN_URL:-http://localhost:3001}
|
- ADMIN_URL=${ADMIN_URL:-http://localhost:3001}
|
||||||
- TZ=${TZ:-UTC}
|
- TZ=${TZ:-UTC}
|
||||||
- STORAGE_PATH=/app/storage
|
- STORAGE_PATH=/app/storage
|
||||||
|
# Optional: run container as matching host user to avoid bind mount permission issues
|
||||||
|
- PUID=${PUID:-1001}
|
||||||
|
- PGID=${PGID:-1001}
|
||||||
|
# Use host-matching user ID/GID so bind-mounted folders are writable
|
||||||
|
user: "${PUID:-1001}:${PGID:-1001}"
|
||||||
volumes:
|
volumes:
|
||||||
- ./events:/app/events
|
- ./events:/app/events
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
@@ -122,4 +127,4 @@ volumes:
|
|||||||
|
|
||||||
networks:
|
networks:
|
||||||
picpeak-network:
|
picpeak-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.110",
|
"version": "1.0.125",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.110",
|
"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.110",
|
"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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { api } from '../config/api';
|
||||||
|
|
||||||
|
export interface ExternalEntry { name: string; type: 'dir' | 'file'; size?: number; mtime?: string }
|
||||||
|
|
||||||
|
export const externalMediaService = {
|
||||||
|
async list(pathRel: string = ''): Promise<{ path: string; entries: ExternalEntry[]; canNavigateUp: boolean }> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (pathRel) params.set('path', pathRel);
|
||||||
|
const res = await api.get(`/admin/external-media/list?${params.toString()}`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async importEvent(eventId: number, externalPath: string, options?: { recursive?: boolean; map?: { individual?: string; collages?: string } }): Promise<{ imported: number; skipped: number; thumbnailsQueued: number }> {
|
||||||
|
const res = await api.post(`/admin/external-media/events/${eventId}/import-external`, {
|
||||||
|
external_path: externalPath,
|
||||||
|
recursive: options?.recursive ?? true,
|
||||||
|
map: options?.map
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+212
-59
@@ -13,7 +13,7 @@ IFS=$'\n\t'
|
|||||||
# Script configuration
|
# Script configuration
|
||||||
readonly SCRIPT_VERSION="2.0.0"
|
readonly SCRIPT_VERSION="2.0.0"
|
||||||
readonly APP_NAME="PicPeak"
|
readonly APP_NAME="PicPeak"
|
||||||
readonly REPO_URL="https://github.com/yourusername/wedding-photo-sharing"
|
readonly REPO_URL="https://github.com/the-luap/picpeak.git"
|
||||||
readonly NODE_VERSION="20"
|
readonly NODE_VERSION="20"
|
||||||
readonly MIN_RAM_DOCKER=2048
|
readonly MIN_RAM_DOCKER=2048
|
||||||
readonly MIN_RAM_NATIVE=1024
|
readonly MIN_RAM_NATIVE=1024
|
||||||
@@ -44,7 +44,6 @@ INSTALL_METHOD="" # docker or native
|
|||||||
OS_TYPE=""
|
OS_TYPE=""
|
||||||
OS_VERSION=""
|
OS_VERSION=""
|
||||||
PACKAGE_MANAGER=""
|
PACKAGE_MANAGER=""
|
||||||
ADMIN_PASSWORD=""
|
|
||||||
ADMIN_EMAIL="admin@example.com"
|
ADMIN_EMAIL="admin@example.com"
|
||||||
DOMAIN_NAME=""
|
DOMAIN_NAME=""
|
||||||
SMTP_HOST=""
|
SMTP_HOST=""
|
||||||
@@ -61,6 +60,38 @@ UNINSTALL_MODE=false
|
|||||||
# Helper Functions
|
# Helper Functions
|
||||||
################################################################################
|
################################################################################
|
||||||
|
|
||||||
|
# Run a command as the application user, even if sudo is not available
|
||||||
|
run_as_user() {
|
||||||
|
local cmd="$*"
|
||||||
|
if [[ "$(id -u)" -ne 0 ]]; then
|
||||||
|
# Already non-root; just run
|
||||||
|
bash -lc "$cmd"
|
||||||
|
return $?
|
||||||
|
fi
|
||||||
|
if command_exists sudo; then
|
||||||
|
sudo -H -u "$NATIVE_APP_USER" bash -lc "$cmd"
|
||||||
|
elif command_exists runuser; then
|
||||||
|
runuser -u "$NATIVE_APP_USER" -- bash -lc "$cmd"
|
||||||
|
else
|
||||||
|
su -s /bin/bash - "$NATIVE_APP_USER" -c "$cmd"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prompt for admin email interactively (unless provided or unattended)
|
||||||
|
prompt_admin_email() {
|
||||||
|
if [[ -n "$ADMIN_EMAIL" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [[ "$UNATTENDED" == "true" ]]; then
|
||||||
|
ADMIN_EMAIL="admin@example.com"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
echo "Please enter the admin email address (used for the initial admin account):"
|
||||||
|
read -p "Admin email [admin@example.com]: " ADMIN_EMAIL
|
||||||
|
ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com}
|
||||||
|
}
|
||||||
|
|
||||||
print_banner() {
|
print_banner() {
|
||||||
echo -e "${PURPLE}"
|
echo -e "${PURPLE}"
|
||||||
echo "╔════════════════════════════════════════════════════════════════════════╗"
|
echo "╔════════════════════════════════════════════════════════════════════════╗"
|
||||||
@@ -110,11 +141,16 @@ generate_jwt_secret() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get_available_ram_mb() {
|
get_available_ram_mb() {
|
||||||
if command_exists free; then
|
# Prefer /proc/meminfo (always available on Linux), fallback to free(1)
|
||||||
free -m | awk '/^Mem:/{print $2}'
|
if [[ -r /proc/meminfo ]]; then
|
||||||
else
|
awk '/^MemTotal:/ { printf "%d\n", $2/1024 }' /proc/meminfo
|
||||||
echo "0"
|
return
|
||||||
fi
|
fi
|
||||||
|
if command_exists free; then
|
||||||
|
free -m | awk '/^Mem:/ {print $2}'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
echo "0"
|
||||||
}
|
}
|
||||||
|
|
||||||
get_available_disk_gb() {
|
get_available_disk_gb() {
|
||||||
@@ -316,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..."
|
||||||
@@ -327,11 +363,20 @@ 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)
|
||||||
local redis_password=$(generate_password)
|
local redis_password=$(generate_password)
|
||||||
[[ -z "$ADMIN_PASSWORD" ]] && ADMIN_PASSWORD=$(generate_password)
|
|
||||||
|
|
||||||
# Create .env file
|
# Create .env file
|
||||||
log_step "Creating configuration..."
|
log_step "Creating configuration..."
|
||||||
@@ -346,7 +391,10 @@ JWT_SECRET=$jwt_secret
|
|||||||
|
|
||||||
# Admin
|
# Admin
|
||||||
ADMIN_EMAIL=$ADMIN_EMAIL
|
ADMIN_EMAIL=$ADMIN_EMAIL
|
||||||
ADMIN_PASSWORD=$ADMIN_PASSWORD
|
|
||||||
|
# Runtime user mapping for Docker bind mounts
|
||||||
|
PUID=$host_uid
|
||||||
|
PGID=$host_gid
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DB_HOST=postgres
|
DB_HOST=postgres
|
||||||
@@ -373,7 +421,7 @@ SMTP_FROM=${SMTP_USER:-noreply@localhost}
|
|||||||
|
|
||||||
# URLs
|
# URLs
|
||||||
FRONTEND_URL=${DOMAIN_NAME:+https://$DOMAIN_NAME}
|
FRONTEND_URL=${DOMAIN_NAME:+https://$DOMAIN_NAME}
|
||||||
ADMIN_URL=${DOMAIN_NAME:+https://$DOMAIN_NAME/admin}
|
ADMIN_URL=${DOMAIN_NAME:+https://$DOMAIN_NAME}
|
||||||
|
|
||||||
# Features
|
# Features
|
||||||
ENABLE_FILE_WATCHER=true
|
ENABLE_FILE_WATCHER=true
|
||||||
@@ -381,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
|
||||||
@@ -551,29 +602,55 @@ setup_native_installation() {
|
|||||||
|
|
||||||
# Create application directory
|
# Create application directory
|
||||||
log_step "Creating application directory..."
|
log_step "Creating application directory..."
|
||||||
mkdir -p "$NATIVE_APP_DIR"/{backend,events/{active,archived},logs,config}
|
mkdir -p "$NATIVE_APP_DIR"/{app,events/{active,archived},logs,config}
|
||||||
|
chown -R $NATIVE_APP_USER:$NATIVE_APP_USER "$NATIVE_APP_DIR"
|
||||||
|
|
||||||
# Clone repository
|
# Clone repository
|
||||||
log_step "Downloading PicPeak..."
|
log_step "Downloading PicPeak..."
|
||||||
if [[ -d "$NATIVE_APP_DIR/backend/.git" ]]; then
|
if [[ -d "$NATIVE_APP_DIR/app/.git" ]]; then
|
||||||
cd "$NATIVE_APP_DIR/backend"
|
cd "$NATIVE_APP_DIR/app"
|
||||||
git pull
|
# Ensure correct remote and update even if history was rewritten
|
||||||
|
run_as_user "git config --global --add safe.directory $NATIVE_APP_DIR/app" || true
|
||||||
|
run_as_user "git remote set-url origin $REPO_URL" || true
|
||||||
|
run_as_user "git fetch --all --prune" || true
|
||||||
|
# Prefer checking out remote main and hard resetting to avoid merge prompts
|
||||||
|
if ! run_as_user "git checkout -B main origin/main"; then
|
||||||
|
run_as_user "git checkout main" || true
|
||||||
|
run_as_user "git reset --hard origin/main"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
git clone "$REPO_URL" "$NATIVE_APP_DIR/backend"
|
run_as_user "git clone $REPO_URL $NATIVE_APP_DIR/app" || {
|
||||||
|
run_as_user "git config --global --add safe.directory $NATIVE_APP_DIR/app"
|
||||||
|
run_as_user "git clone $REPO_URL $NATIVE_APP_DIR/app"
|
||||||
|
}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
log_step "Installing dependencies..."
|
log_step "Installing dependencies..."
|
||||||
cd "$NATIVE_APP_DIR/backend"
|
# The repository root contains both backend/ and frontend/
|
||||||
|
# Install backend production dependencies
|
||||||
|
cd "$NATIVE_APP_DIR/app/backend"
|
||||||
npm install --production
|
npm install --production
|
||||||
|
# Ensure SQLite data directory exists for native installs
|
||||||
|
mkdir -p "$NATIVE_APP_DIR/app/backend/data"
|
||||||
|
|
||||||
|
# Build frontend for native serving
|
||||||
|
log_step "Building frontend..."
|
||||||
|
if [[ -d "$NATIVE_APP_DIR/app/frontend" ]]; then
|
||||||
|
cd "$NATIVE_APP_DIR/app/frontend"
|
||||||
|
# Try ci (faster/clean) then fallback to install
|
||||||
|
run_as_user "npm ci --include=dev" || run_as_user "npm install"
|
||||||
|
run_as_user "npm run build"
|
||||||
|
else
|
||||||
|
log_warn "Frontend directory not found; admin UI will not be served by backend"
|
||||||
|
fi
|
||||||
|
|
||||||
# Generate secrets
|
# Generate secrets
|
||||||
local jwt_secret=$(generate_jwt_secret)
|
local jwt_secret=$(generate_jwt_secret)
|
||||||
[[ -z "$ADMIN_PASSWORD" ]] && ADMIN_PASSWORD=$(generate_password)
|
|
||||||
|
|
||||||
# Create .env file
|
# Create .env file
|
||||||
log_step "Creating configuration..."
|
log_step "Creating configuration..."
|
||||||
cat > "$NATIVE_APP_DIR/backend/.env" <<EOF
|
cat > "$NATIVE_APP_DIR/app/backend/.env" <<EOF
|
||||||
# PicPeak Native Configuration
|
# PicPeak Native Configuration
|
||||||
# Generated: $(date)
|
# Generated: $(date)
|
||||||
|
|
||||||
@@ -584,14 +661,14 @@ JWT_SECRET=$jwt_secret
|
|||||||
|
|
||||||
# Admin
|
# Admin
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_PASSWORD=$ADMIN_PASSWORD
|
|
||||||
ADMIN_EMAIL=$ADMIN_EMAIL
|
ADMIN_EMAIL=$ADMIN_EMAIL
|
||||||
|
|
||||||
# Database
|
# Database (native uses SQLite by default)
|
||||||
DATABASE_PATH=$NATIVE_APP_DIR/backend/database.sqlite
|
DATABASE_CLIENT=sqlite3
|
||||||
|
DATABASE_PATH=$NATIVE_APP_DIR/app/backend/data/photo_sharing.db
|
||||||
|
|
||||||
# Storage
|
# Storage root (thumbnails/uploads live under this path)
|
||||||
PHOTOS_DIR=$NATIVE_APP_DIR/events
|
STORAGE_PATH=$NATIVE_APP_DIR
|
||||||
|
|
||||||
# Email
|
# Email
|
||||||
SMTP_ENABLED=${SMTP_HOST:+true}
|
SMTP_ENABLED=${SMTP_HOST:+true}
|
||||||
@@ -603,7 +680,7 @@ SMTP_FROM=${SMTP_USER:-noreply@localhost}
|
|||||||
|
|
||||||
# URLs
|
# URLs
|
||||||
FRONTEND_URL=${DOMAIN_NAME:+https://$DOMAIN_NAME}
|
FRONTEND_URL=${DOMAIN_NAME:+https://$DOMAIN_NAME}
|
||||||
ADMIN_URL=${DOMAIN_NAME:+https://$DOMAIN_NAME/admin}
|
ADMIN_URL=${DOMAIN_NAME:+https://$DOMAIN_NAME}
|
||||||
|
|
||||||
# Features
|
# Features
|
||||||
ENABLE_FILE_WATCHER=true
|
ENABLE_FILE_WATCHER=true
|
||||||
@@ -614,16 +691,20 @@ DEFAULT_EXPIRY_DAYS=30
|
|||||||
# Logging
|
# Logging
|
||||||
LOG_DIR=$NATIVE_APP_DIR/logs
|
LOG_DIR=$NATIVE_APP_DIR/logs
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Frontend serving (native installs)
|
||||||
|
SERVE_FRONTEND=true
|
||||||
|
FRONTEND_DIR=$NATIVE_APP_DIR/app/frontend/dist
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Set permissions
|
# Set permissions
|
||||||
chown -R $NATIVE_APP_USER:$NATIVE_APP_USER "$NATIVE_APP_DIR"
|
chown -R $NATIVE_APP_USER:$NATIVE_APP_USER "$NATIVE_APP_DIR"
|
||||||
chmod 600 "$NATIVE_APP_DIR/backend/.env"
|
chmod 600 "$NATIVE_APP_DIR/app/backend/.env"
|
||||||
|
|
||||||
# Run database migrations
|
# Run database migrations
|
||||||
log_step "Initializing database..."
|
log_step "Initializing database..."
|
||||||
cd "$NATIVE_APP_DIR/backend"
|
cd "$NATIVE_APP_DIR/app/backend"
|
||||||
sudo -u $NATIVE_APP_USER npm run migrate
|
run_as_user "npm run migrate"
|
||||||
|
|
||||||
# Create systemd services
|
# Create systemd services
|
||||||
create_systemd_services
|
create_systemd_services
|
||||||
@@ -636,8 +717,15 @@ EOF
|
|||||||
# Start services
|
# Start services
|
||||||
log_step "Starting services..."
|
log_step "Starting services..."
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable picpeak-backend picpeak-workers
|
systemctl enable picpeak-backend
|
||||||
systemctl start picpeak-backend picpeak-workers
|
# Stop/remove legacy workers service if present
|
||||||
|
if systemctl list-unit-files | grep -q '^picpeak-workers.service'; then
|
||||||
|
systemctl disable picpeak-workers || true
|
||||||
|
systemctl stop picpeak-workers || true
|
||||||
|
rm -f /etc/systemd/system/picpeak-workers.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
fi
|
||||||
|
systemctl start picpeak-backend
|
||||||
|
|
||||||
log_success "Native installation completed!"
|
log_success "Native installation completed!"
|
||||||
}
|
}
|
||||||
@@ -654,7 +742,7 @@ After=network.target
|
|||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=$NATIVE_APP_USER
|
User=$NATIVE_APP_USER
|
||||||
WorkingDirectory=$NATIVE_APP_DIR/backend
|
WorkingDirectory=$NATIVE_APP_DIR/app/backend
|
||||||
Environment="NODE_ENV=production"
|
Environment="NODE_ENV=production"
|
||||||
ExecStart=/usr/bin/node server.js
|
ExecStart=/usr/bin/node server.js
|
||||||
Restart=always
|
Restart=always
|
||||||
@@ -675,7 +763,7 @@ After=network.target picpeak-backend.service
|
|||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=$NATIVE_APP_USER
|
User=$NATIVE_APP_USER
|
||||||
WorkingDirectory=$NATIVE_APP_DIR/backend
|
WorkingDirectory=$NATIVE_APP_DIR/app/backend
|
||||||
Environment="NODE_ENV=production"
|
Environment="NODE_ENV=production"
|
||||||
ExecStart=/usr/bin/node src/services/workerManager.js
|
ExecStart=/usr/bin/node src/services/workerManager.js
|
||||||
Restart=always
|
Restart=always
|
||||||
@@ -845,8 +933,32 @@ print_success_message() {
|
|||||||
echo
|
echo
|
||||||
echo "🔐 Admin Credentials:"
|
echo "🔐 Admin Credentials:"
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
echo -e "Email: ${CYAN}$ADMIN_EMAIL${NC}"
|
# Read from ADMIN_CREDENTIALS.txt when available
|
||||||
echo -e "Password: ${CYAN}$ADMIN_PASSWORD${NC}"
|
local cred_file email_line pass_line admin_email_val admin_pass_val
|
||||||
|
if [[ "$INSTALL_METHOD" == "docker" ]]; then
|
||||||
|
cred_file="$app_dir/data/ADMIN_CREDENTIALS.txt"
|
||||||
|
else
|
||||||
|
cred_file="$NATIVE_APP_DIR/app/backend/data/ADMIN_CREDENTIALS.txt"
|
||||||
|
fi
|
||||||
|
if [[ -f "$cred_file" ]]; then
|
||||||
|
email_line=$(grep -m1 '^Email:' "$cred_file" || true)
|
||||||
|
pass_line=$(grep -m1 '^Password:' "$cred_file" || true)
|
||||||
|
admin_email_val=${email_line#Email: }
|
||||||
|
admin_pass_val=${pass_line#Password: }
|
||||||
|
if [[ -n "$admin_email_val" ]]; then
|
||||||
|
echo -e "Email: ${CYAN}$admin_email_val${NC}"
|
||||||
|
else
|
||||||
|
echo -e "Email: ${CYAN}$ADMIN_EMAIL${NC}"
|
||||||
|
fi
|
||||||
|
if [[ -n "$admin_pass_val" ]]; then
|
||||||
|
echo -e "Password: ${CYAN}$admin_pass_val${NC}"
|
||||||
|
else
|
||||||
|
echo -e "Password: ${YELLOW}(see $cred_file)${NC}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "Email: ${CYAN}$ADMIN_EMAIL${NC}"
|
||||||
|
echo -e "Password: ${YELLOW}(credentials file not found)${NC}"
|
||||||
|
fi
|
||||||
echo
|
echo
|
||||||
echo -e "${YELLOW}⚠️ IMPORTANT: Change the admin password on first login!${NC}"
|
echo -e "${YELLOW}⚠️ IMPORTANT: Change the admin password on first login!${NC}"
|
||||||
|
|
||||||
@@ -870,16 +982,16 @@ print_success_message() {
|
|||||||
echo "🔧 Service Commands:"
|
echo "🔧 Service Commands:"
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
echo "View logs: sudo journalctl -u picpeak-backend -f"
|
echo "View logs: sudo journalctl -u picpeak-backend -f"
|
||||||
echo "Stop: sudo systemctl stop picpeak-backend picpeak-workers"
|
echo "Stop: sudo systemctl stop picpeak-backend"
|
||||||
echo "Start: sudo systemctl start picpeak-backend picpeak-workers"
|
echo "Start: sudo systemctl start picpeak-backend"
|
||||||
echo "Status: sudo systemctl status picpeak-backend"
|
echo "Status: sudo systemctl status picpeak-backend"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "📚 Documentation:"
|
echo "📚 Documentation:"
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
echo "Setup Guide: https://github.com/yourusername/wedding-photo-sharing/blob/main/SIMPLE_SETUP.md"
|
echo "Setup Guide: https://github.com/the-luap/picpeak/blob/main/SIMPLE_SETUP.md"
|
||||||
echo "Full Docs: https://github.com/yourusername/wedding-photo-sharing"
|
echo "Full Docs: https://github.com/the-luap/picpeak"
|
||||||
echo
|
echo
|
||||||
echo -e "${GREEN}✨ Setup complete! Visit the admin panel to start creating galleries.${NC}"
|
echo -e "${GREEN}✨ Setup complete! Visit the admin panel to start creating galleries.${NC}"
|
||||||
}
|
}
|
||||||
@@ -890,16 +1002,31 @@ print_success_message() {
|
|||||||
|
|
||||||
update_installation() {
|
update_installation() {
|
||||||
print_header "Updating PicPeak"
|
print_header "Updating PicPeak"
|
||||||
|
|
||||||
# Detect existing installation
|
# Prefer explicit native install detection first
|
||||||
if [[ -d "$DOCKER_APP_DIR" ]] || [[ -d "/home/${SUDO_USER:-}/picpeak" ]]; then
|
native_detected=false
|
||||||
INSTALL_METHOD="docker"
|
docker_detected=false
|
||||||
update_docker_installation
|
|
||||||
elif [[ -d "$NATIVE_APP_DIR" ]]; then
|
# Native detection: app/backend exists OR systemd unit present
|
||||||
|
if [[ -d "$NATIVE_APP_DIR/app/backend" ]]; then
|
||||||
|
native_detected=true
|
||||||
|
elif command -v systemctl >/dev/null 2>&1 && systemctl list-unit-files | grep -q '^picpeak-backend.service'; then
|
||||||
|
native_detected=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Docker detection: docker app dir or user home picpeak dir exists
|
||||||
|
if [[ -d "$DOCKER_APP_DIR" ]] || [[ -n "${SUDO_USER:-}" && -d "/home/${SUDO_USER}/picpeak" ]]; then
|
||||||
|
docker_detected=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$native_detected" == true ]]; then
|
||||||
INSTALL_METHOD="native"
|
INSTALL_METHOD="native"
|
||||||
update_native_installation
|
update_native_installation
|
||||||
|
elif [[ "$docker_detected" == true ]]; then
|
||||||
|
INSTALL_METHOD="docker"
|
||||||
|
update_docker_installation
|
||||||
else
|
else
|
||||||
die "No existing PicPeak installation found"
|
die "No existing PicPeak installation found (native dir $NATIVE_APP_DIR/app/backend or docker dir $DOCKER_APP_DIR not present)"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -932,23 +1059,51 @@ update_native_installation() {
|
|||||||
log_step "Updating native installation..."
|
log_step "Updating native installation..."
|
||||||
|
|
||||||
# Stop services
|
# Stop services
|
||||||
systemctl stop picpeak-backend picpeak-workers
|
systemctl stop picpeak-backend || true
|
||||||
|
if systemctl list-unit-files | grep -q '^picpeak-workers.service'; then
|
||||||
|
systemctl stop picpeak-workers || true
|
||||||
|
fi
|
||||||
|
|
||||||
# Backup current configuration
|
# Backup current configuration
|
||||||
cp "$NATIVE_APP_DIR/backend/.env" "$NATIVE_APP_DIR/backend/.env.backup-$(date +%Y%m%d-%H%M%S)"
|
if [[ -f "$NATIVE_APP_DIR/app/backend/.env" ]]; then
|
||||||
|
cp "$NATIVE_APP_DIR/app/backend/.env" "$NATIVE_APP_DIR/app/backend/.env.backup-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
fi
|
||||||
|
|
||||||
# Pull latest code
|
# Pull latest code
|
||||||
cd "$NATIVE_APP_DIR/backend"
|
cd "$NATIVE_APP_DIR/app"
|
||||||
sudo -u $NATIVE_APP_USER git pull
|
run_as_user "git config --global --add safe.directory $NATIVE_APP_DIR/app" || true
|
||||||
|
run_as_user "git remote set-url origin $REPO_URL" || true
|
||||||
|
run_as_user "git fetch --all --prune"
|
||||||
|
if ! run_as_user "git checkout -B main origin/main"; then
|
||||||
|
run_as_user "git checkout main" || true
|
||||||
|
run_as_user "git reset --hard origin/main"
|
||||||
|
fi
|
||||||
|
|
||||||
# Update dependencies
|
# Update backend dependencies
|
||||||
sudo -u $NATIVE_APP_USER npm install --production
|
cd "$NATIVE_APP_DIR/app/backend"
|
||||||
|
run_as_user "npm install --production"
|
||||||
|
|
||||||
# Run migrations
|
# Run migrations
|
||||||
sudo -u $NATIVE_APP_USER npm run migrate
|
run_as_user "npm run migrate"
|
||||||
|
|
||||||
|
# Rebuild frontend (ensure admin UI for native installs)
|
||||||
|
if [[ -d "$NATIVE_APP_DIR/app/frontend" ]]; then
|
||||||
|
log_step "Rebuilding frontend..."
|
||||||
|
cd "$NATIVE_APP_DIR/app/frontend"
|
||||||
|
run_as_user "npm ci --include=dev" || run_as_user "npm install"
|
||||||
|
run_as_user "npm run build"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure env has frontend serving flags
|
||||||
|
if ! grep -q '^SERVE_FRONTEND=' "$NATIVE_APP_DIR/app/backend/.env"; then
|
||||||
|
echo "SERVE_FRONTEND=true" >> "$NATIVE_APP_DIR/app/backend/.env"
|
||||||
|
fi
|
||||||
|
if ! grep -q '^FRONTEND_DIR=' "$NATIVE_APP_DIR/app/backend/.env"; then
|
||||||
|
echo "FRONTEND_DIR=$NATIVE_APP_DIR/app/frontend/dist" >> "$NATIVE_APP_DIR/app/backend/.env"
|
||||||
|
fi
|
||||||
|
|
||||||
# Restart services
|
# Restart services
|
||||||
systemctl start picpeak-backend picpeak-workers
|
systemctl restart picpeak-backend
|
||||||
|
|
||||||
log_success "Native installation updated successfully!"
|
log_success "Native installation updated successfully!"
|
||||||
}
|
}
|
||||||
@@ -1042,10 +1197,6 @@ parse_arguments() {
|
|||||||
ADMIN_EMAIL="$2"
|
ADMIN_EMAIL="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--admin-password)
|
|
||||||
ADMIN_PASSWORD="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--smtp-host)
|
--smtp-host)
|
||||||
SMTP_HOST="$2"
|
SMTP_HOST="$2"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -1103,7 +1254,6 @@ Options:
|
|||||||
--unattended Run without prompts
|
--unattended Run without prompts
|
||||||
--domain DOMAIN Set domain name for HTTPS
|
--domain DOMAIN Set domain name for HTTPS
|
||||||
--email EMAIL Admin email address
|
--email EMAIL Admin email address
|
||||||
--admin-password Admin password (auto-generated if not set)
|
|
||||||
--smtp-host HOST SMTP server hostname
|
--smtp-host HOST SMTP server hostname
|
||||||
--smtp-port PORT SMTP server port
|
--smtp-port PORT SMTP server port
|
||||||
--smtp-user USER SMTP username
|
--smtp-user USER SMTP username
|
||||||
@@ -1126,7 +1276,7 @@ Examples:
|
|||||||
|
|
||||||
# Fully automated Docker setup
|
# Fully automated Docker setup
|
||||||
sudo $0 --docker --unattended --domain photos.example.com \\
|
sudo $0 --docker --unattended --domain photos.example.com \\
|
||||||
--email admin@example.com --admin-password SecurePass123 \\
|
--email admin@example.com \\
|
||||||
--smtp-host smtp.gmail.com --smtp-port 587 \\
|
--smtp-host smtp.gmail.com --smtp-port 587 \\
|
||||||
--smtp-user user@gmail.com --smtp-pass app-password \\
|
--smtp-user user@gmail.com --smtp-pass app-password \\
|
||||||
--enable-ssl
|
--enable-ssl
|
||||||
@@ -1166,6 +1316,9 @@ main() {
|
|||||||
# Check system requirements
|
# Check system requirements
|
||||||
check_system_requirements
|
check_system_requirements
|
||||||
|
|
||||||
|
# Prompt for admin email (design choice: always ask unless provided)
|
||||||
|
prompt_admin_email
|
||||||
|
|
||||||
# Configure email (optional)
|
# Configure email (optional)
|
||||||
configure_email
|
configure_email
|
||||||
|
|
||||||
@@ -1181,4 +1334,4 @@ main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Run main function
|
# Run main function
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
Reference in New Issue
Block a user