Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf705674d5 | |||
| fee369a503 | |||
| ad75818566 | |||
| 65f2c8610d | |||
| 517128fd99 | |||
| 55c8384a25 | |||
| 0064122eff | |||
| 0c783c66d0 | |||
| 47e2351dab | |||
| e1aca6b00c | |||
| 2cb6577f26 | |||
| c51d756503 | |||
| c48b9780df | |||
| 618e2695fd | |||
| a289f97a31 | |||
| 7387a5e9f9 | |||
| 4615a5d795 | |||
| f926cd3adf | |||
| 96b05b5e0c | |||
| 99e47785e4 | |||
| 94f10e1645 | |||
| fe4a476e41 | |||
| e9f92e66d0 | |||
| 58f4217756 | |||
| 76a466c077 | |||
| 9dc3777985 | |||
| e006d73831 | |||
| 63e88c8324 | |||
| 105167fb57 | |||
| 22cc40617f | |||
| 7f28917795 | |||
| 77af2a8415 | |||
| 4c42b4c601 | |||
| 856cdc214c | |||
| 15b244292f | |||
| 558a966f85 | |||
| 1238db58c2 | |||
| 0502ed34c9 | |||
| 1f417c7e30 | |||
| a401fbdc54 | |||
| 247e154afe | |||
| cbfd84ddea | |||
| dc1419c051 | |||
| 2624ea6130 | |||
| 08eeac66eb | |||
| 11769219e4 | |||
| 7750170832 | |||
| 833591681a | |||
| b77e60c37c | |||
| 30f6780484 | |||
| aa39e132aa | |||
| f6a79c815e | |||
| 3c6837bd90 | |||
| 62dcaf8555 | |||
| 4ed35f1b16 | |||
| 5eff7dd4a6 | |||
| abbcdb1113 | |||
| 17fc40e65d | |||
| a54a2c0fda | |||
| febacb79ad | |||
| c7875102c5 | |||
| 3dc013d7b1 | |||
| b7c8953cb4 | |||
| d6adde4e09 | |||
| 0bf4764a07 | |||
| 08da01f021 | |||
| b4b09c1650 | |||
| b2ae5f18ad | |||
| 4a7a3bba07 | |||
| b3f240b2a5 | |||
| ba0bf11a1d | |||
| d5790ad635 | |||
| e6757bd51b | |||
| 8588133a4e | |||
| 4b18077573 | |||
| c584369d5d | |||
| 95939d57e6 | |||
| be58146dc7 | |||
| 45ce98806d | |||
| 23b7a848ab | |||
| 0fe6d738b2 | |||
| 3f73d44c5a | |||
| 1773ed5f95 |
-42
@@ -108,48 +108,6 @@ steps:
|
|||||||
- VERSION=${DRONE_TAG}
|
- VERSION=${DRONE_TAG}
|
||||||
- VITE_API_URL=${VITE_API_URL:-/api}
|
- VITE_API_URL=${VITE_API_URL:-/api}
|
||||||
|
|
||||||
# -------- NEW: Create GitHub Release --------
|
|
||||||
- name: github-release
|
|
||||||
image: plugins/github-release
|
|
||||||
settings:
|
|
||||||
api_key:
|
|
||||||
from_secret: GITHUB_TOKEN
|
|
||||||
title: "PicPeak ${DRONE_TAG}"
|
|
||||||
note: |
|
|
||||||
# PicPeak ${DRONE_TAG}
|
|
||||||
|
|
||||||
## 🐳 Docker Images
|
|
||||||
|
|
||||||
This release includes Docker images published to GitHub Container Registry:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Backend
|
|
||||||
docker pull ghcr.io/the-luap/picpeak-backend:${DRONE_TAG}
|
|
||||||
docker pull ghcr.io/the-luap/picpeak-backend:latest
|
|
||||||
|
|
||||||
# Frontend
|
|
||||||
docker pull ghcr.io/the-luap/picpeak-frontend:${DRONE_TAG}
|
|
||||||
docker pull ghcr.io/the-luap/picpeak-frontend:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📦 What's New
|
|
||||||
|
|
||||||
See the [README](https://github.com/the-luap/picpeak#readme) for features and documentation.
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clone and deploy
|
|
||||||
git clone https://github.com/the-luap/picpeak.git
|
|
||||||
cd picpeak
|
|
||||||
|
|
||||||
# Use the tagged version
|
|
||||||
docker-compose -f docker-compose.prod.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
For detailed deployment instructions, see the [Deployment Guide](https://github.com/the-luap/picpeak/blob/main/DEPLOYMENT.md).
|
|
||||||
|
|
||||||
trigger:
|
trigger:
|
||||||
event:
|
event:
|
||||||
|
|||||||
@@ -10,120 +10,18 @@ jobs:
|
|||||||
mirror:
|
mirror:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository with full history
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Full history needed for finding the commit
|
fetch-depth: 0 # Full history for proper mirroring
|
||||||
|
|
||||||
- name: Setup Git
|
- name: Setup Git
|
||||||
run: |
|
run: |
|
||||||
git config --global user.name "the-luap"
|
git config --global user.name "the-luap"
|
||||||
git config --global user.email "paul-nothaft@hotmail.de"
|
git config --global user.email "paul-nothaft@hotmail.de"
|
||||||
|
|
||||||
- name: Debug - Show current branch and status
|
|
||||||
run: |
|
|
||||||
echo "Current branch:"
|
|
||||||
git branch -a
|
|
||||||
echo "Git status:"
|
|
||||||
git status
|
|
||||||
echo "Remote info:"
|
|
||||||
git remote -v
|
|
||||||
echo "Checking target commit exists:"
|
|
||||||
git show --oneline 7aca927937 || echo "Target commit not found!"
|
|
||||||
|
|
||||||
- name: Create completely new history from specific commit
|
|
||||||
run: |
|
|
||||||
TARGET_COMMIT="7aca927937"
|
|
||||||
|
|
||||||
# Verify the target commit exists
|
|
||||||
if ! git cat-file -e $TARGET_COMMIT^{commit}; then
|
|
||||||
echo "ERROR: Target commit $TARGET_COMMIT does not exist!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ Target commit found: $(git log --oneline -1 $TARGET_COMMIT)"
|
|
||||||
|
|
||||||
# Clean up any existing github-mirror branch
|
|
||||||
git branch -D github-mirror || true
|
|
||||||
|
|
||||||
# Create a completely new orphan branch (no history)
|
|
||||||
git checkout --orphan github-mirror
|
|
||||||
|
|
||||||
# Clear the staging area completely
|
|
||||||
git rm -rf . || true
|
|
||||||
|
|
||||||
# Get the file tree from the target commit and create initial commit
|
|
||||||
echo "Creating new history starting from $TARGET_COMMIT..."
|
|
||||||
git read-tree $TARGET_COMMIT
|
|
||||||
git commit -m "Initial commit - imported from $(git log --oneline -1 $TARGET_COMMIT)"
|
|
||||||
|
|
||||||
echo "✅ Created new initial commit: $(git log --oneline -1)"
|
|
||||||
|
|
||||||
# Now get all commits after the target commit and apply their changes
|
|
||||||
COMMITS_AFTER_TARGET=$(git rev-list --reverse --no-merges $TARGET_COMMIT..main)
|
|
||||||
|
|
||||||
if [ -n "$COMMITS_AFTER_TARGET" ]; then
|
|
||||||
echo "📋 Applying changes from commits after $TARGET_COMMIT (excluding Claude commits):"
|
|
||||||
|
|
||||||
for commit in $COMMITS_AFTER_TARGET; do
|
|
||||||
# Get the commit author name
|
|
||||||
COMMIT_AUTHOR_NAME=$(git log --format="%an" -n 1 $commit)
|
|
||||||
|
|
||||||
# Skip commits by Claude
|
|
||||||
if [ "$COMMIT_AUTHOR_NAME" = "Claude" ]; then
|
|
||||||
echo "⚠️ Skipping commit by Claude: $(git log --oneline -1 $commit)"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Processing: $(git log --oneline -1 $commit)"
|
|
||||||
|
|
||||||
# Get the commit message and author info
|
|
||||||
COMMIT_MSG=$(git log --format="%B" -n 1 $commit)
|
|
||||||
COMMIT_AUTHOR=$(git log --format="%an <%ae>" -n 1 $commit)
|
|
||||||
COMMIT_DATE=$(git log --format="%ad" -n 1 $commit)
|
|
||||||
|
|
||||||
# Apply the changes from this commit
|
|
||||||
if git diff-tree --no-commit-id --name-only -r $commit | xargs -I {} git show $commit:{} > /dev/null 2>&1; then
|
|
||||||
# Apply file changes
|
|
||||||
git checkout $commit -- . || true
|
|
||||||
|
|
||||||
# Stage all changes
|
|
||||||
git add -A
|
|
||||||
|
|
||||||
# Only commit if there are changes
|
|
||||||
if ! git diff --cached --quiet; then
|
|
||||||
# Create new commit with original metadata but new SHA
|
|
||||||
GIT_AUTHOR_NAME=$(echo "$COMMIT_AUTHOR" | cut -d'<' -f1 | xargs)
|
|
||||||
GIT_AUTHOR_EMAIL=$(echo "$COMMIT_AUTHOR" | cut -d'<' -f2 | cut -d'>' -f1)
|
|
||||||
GIT_AUTHOR_DATE="$COMMIT_DATE"
|
|
||||||
|
|
||||||
export GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_AUTHOR_DATE
|
|
||||||
git commit -m "$COMMIT_MSG"
|
|
||||||
echo "✅ Applied changes as new commit: $(git log --oneline -1)"
|
|
||||||
else
|
|
||||||
echo "⚠️ No changes to commit for $commit"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "⚠️ Skipping problematic commit $commit"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "✅ Finished creating new history"
|
|
||||||
else
|
|
||||||
echo "✅ No commits after target commit - history starts fresh"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== New History Summary ==="
|
|
||||||
echo "Total commits in new history: $(git rev-list --count github-mirror)"
|
|
||||||
echo "History starts with: $(git log --oneline --reverse | head -1)"
|
|
||||||
echo "Latest commit: $(git log --oneline -1)"
|
|
||||||
|
|
||||||
- name: Remove sensitive files and directories
|
- name: Remove sensitive files and directories
|
||||||
run: |
|
run: |
|
||||||
# Switch to the github-mirror branch
|
|
||||||
git checkout github-mirror
|
|
||||||
|
|
||||||
echo "Current files before cleanup:"
|
echo "Current files before cleanup:"
|
||||||
ls -la | head -10 || true
|
ls -la | head -10 || true
|
||||||
echo "..."
|
echo "..."
|
||||||
@@ -164,18 +62,6 @@ jobs:
|
|||||||
echo "Final file structure (top level):"
|
echo "Final file structure (top level):"
|
||||||
ls -la | head -10 || true
|
ls -la | head -10 || true
|
||||||
|
|
||||||
- name: Verify completely new history
|
|
||||||
run: |
|
|
||||||
git checkout github-mirror
|
|
||||||
echo "=== Final History Verification ==="
|
|
||||||
echo "Total commits in new github-mirror branch: $(git rev-list --count github-mirror)"
|
|
||||||
echo ""
|
|
||||||
echo "Complete commit history (should start from target commit content):"
|
|
||||||
git log --oneline --reverse
|
|
||||||
echo ""
|
|
||||||
echo "⚠️ Note: This is a completely NEW history with new commit SHAs"
|
|
||||||
echo "🔍 Original target commit content preserved but with new commit ID"
|
|
||||||
|
|
||||||
- name: Check GitHub token
|
- name: Check GitHub token
|
||||||
env:
|
env:
|
||||||
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
||||||
@@ -187,13 +73,10 @@ jobs:
|
|||||||
echo "GitHub token is available (length: ${#GITHUBTOKEN})"
|
echo "GitHub token is available (length: ${#GITHUBTOKEN})"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Force push completely new history to GitHub
|
- name: Push to GitHub
|
||||||
env:
|
env:
|
||||||
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
||||||
run: |
|
run: |
|
||||||
# Switch to github-mirror branch
|
|
||||||
git checkout github-mirror
|
|
||||||
|
|
||||||
# Remove existing github remote if it exists
|
# Remove existing github remote if it exists
|
||||||
git remote remove github || true
|
git remote remove github || true
|
||||||
|
|
||||||
@@ -204,17 +87,13 @@ jobs:
|
|||||||
echo "GitHub remote added:"
|
echo "GitHub remote added:"
|
||||||
git remote -v
|
git remote -v
|
||||||
|
|
||||||
# Force push the completely new history to GitHub main
|
# Push to GitHub main branch
|
||||||
echo "🔥 FORCE PUSHING completely new history to GitHub..."
|
echo "Pushing to GitHub..."
|
||||||
echo "⚠️ This will COMPLETELY REPLACE all history on GitHub!"
|
git push github main --force
|
||||||
git push github github-mirror:main --force
|
echo "✅ Push to GitHub completed!"
|
||||||
echo "✅ Force push completed - GitHub now has completely new history!"
|
|
||||||
|
|
||||||
- name: Workflow completed
|
- name: Workflow completed
|
||||||
run: |
|
run: |
|
||||||
echo "✅ Mirror to GitHub workflow completed successfully!"
|
echo "✅ Mirror to GitHub workflow completed successfully!"
|
||||||
echo "🔥 COMPLETE HISTORY REPLACEMENT: GitHub now has entirely new history"
|
echo "📊 Repository mirrored to: https://github.com/the-luap/picpeak"
|
||||||
echo "📊 History starts from commit content: 7aca927937"
|
echo "🔒 Sensitive files have been removed from the mirror"
|
||||||
echo "🔍 Check https://github.com/the-luap/picpeak to verify the new history"
|
|
||||||
echo "📈 Total commits pushed: $(git rev-list --count github-mirror)"
|
|
||||||
echo "🆕 All commit SHAs are NEW - no connection to previous history"
|
|
||||||
@@ -48,6 +48,10 @@ coverage/
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
|
|
||||||
|
# Backup and test directories
|
||||||
|
backups/
|
||||||
|
test-archiver/
|
||||||
|
|
||||||
# Keep directory structure
|
# Keep directory structure
|
||||||
!storage/events/active/.gitkeep
|
!storage/events/active/.gitkeep
|
||||||
!storage/events/archived/.gitkeep
|
!storage/events/archived/.gitkeep
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# Backup Version Tracking Implementation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Version tracking has been added to the backup system to ensure safe restoration by tracking application versions, Node.js versions, and database schema versions at the time of backup.
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### 1. Database Schema Changes (Migration 034)
|
||||||
|
|
||||||
|
Added version tracking columns to backup tables:
|
||||||
|
|
||||||
|
#### `database_backup_runs` table:
|
||||||
|
- `app_version` - Application version from package.json
|
||||||
|
- `node_version` - Node.js runtime version
|
||||||
|
- `db_schema_version` - Latest migration name
|
||||||
|
- `environment_info` - JSON with additional environment details
|
||||||
|
|
||||||
|
#### `backup_runs` table:
|
||||||
|
- `app_version` - Application version
|
||||||
|
- `node_version` - Node.js version
|
||||||
|
- `db_schema_version` - Database schema version
|
||||||
|
- `manifest_info` - Summary of manifest information
|
||||||
|
|
||||||
|
#### New `restore_history` table:
|
||||||
|
Tracks all restore attempts with comprehensive version information:
|
||||||
|
- Backup versions vs current versions
|
||||||
|
- Compatibility check results
|
||||||
|
- Warnings and errors
|
||||||
|
- Restore outcome
|
||||||
|
|
||||||
|
### 2. Version Information Captured
|
||||||
|
|
||||||
|
During each backup, the system now records:
|
||||||
|
- **Application Version**: From `package.json` (e.g., "1.0.77")
|
||||||
|
- **Node.js Version**: Runtime version (e.g., "v18.17.0")
|
||||||
|
- **Database Schema**: Latest migration file (e.g., "034_add_version_to_backups.js")
|
||||||
|
- **Environment Info**: Platform, architecture, environment mode
|
||||||
|
|
||||||
|
### 3. Backup Services Updated
|
||||||
|
|
||||||
|
#### Database Backup Service (`databaseBackup.js`):
|
||||||
|
- Records version info when creating backups
|
||||||
|
- Includes versions in statistics JSON
|
||||||
|
- New method: `checkVersionCompatibility()` for restore safety
|
||||||
|
- New method: `getCurrentSchemaVersion()` to track migrations
|
||||||
|
|
||||||
|
#### File Backup Service (`backupService.js`):
|
||||||
|
- Records version info in backup_runs table
|
||||||
|
- Integrates with manifest system
|
||||||
|
- Stores manifest summary with version details
|
||||||
|
|
||||||
|
### 4. Existing Manifest System
|
||||||
|
|
||||||
|
The `backupManifest.js` already provides comprehensive version tracking:
|
||||||
|
- Application version and Node.js version
|
||||||
|
- System information (OS, platform, architecture)
|
||||||
|
- Database schema version
|
||||||
|
- Detailed file and database metadata
|
||||||
|
|
||||||
|
### 5. Version Compatibility Checking
|
||||||
|
|
||||||
|
When restoring, the system can now:
|
||||||
|
- Compare backup version vs current version
|
||||||
|
- Detect major/minor version differences
|
||||||
|
- Identify schema mismatches
|
||||||
|
- Provide warnings and recommendations
|
||||||
|
|
||||||
|
### 6. Configuration Settings
|
||||||
|
|
||||||
|
New backup settings for version control:
|
||||||
|
- `backup_require_version_match` - Enforce exact version matching
|
||||||
|
- `backup_allow_minor_version_mismatch` - Allow same major version
|
||||||
|
- `backup_warn_on_version_mismatch` - Show warnings on mismatch
|
||||||
|
- `backup_check_schema_compatibility` - Validate schema versions
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Creating Backups
|
||||||
|
Backups automatically capture version information - no changes needed to existing backup workflows.
|
||||||
|
|
||||||
|
### Checking Version Before Restore
|
||||||
|
|
||||||
|
1. **For Database Backups**:
|
||||||
|
```javascript
|
||||||
|
const compatibility = await databaseBackupService.checkVersionCompatibility({
|
||||||
|
app_version: '1.0.75',
|
||||||
|
node_version: 'v16.14.0',
|
||||||
|
db_schema_version: '032_add_feedback.js'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!compatibility.compatible) {
|
||||||
|
console.error('Version mismatch:', compatibility.errors);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **For File Backups**:
|
||||||
|
Check the manifest file which contains all version information:
|
||||||
|
```bash
|
||||||
|
cat /backup/path/manifest-backup-20250122-123456.json | jq '.application'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restore History
|
||||||
|
All restore attempts are logged in the `restore_history` table with:
|
||||||
|
- Version compatibility results
|
||||||
|
- Warnings encountered
|
||||||
|
- Success/failure status
|
||||||
|
- Who performed the restore
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always Check Compatibility**: Before restoring, verify version compatibility
|
||||||
|
2. **Document Version Changes**: Keep changelog updated with breaking changes
|
||||||
|
3. **Test Restores**: Regularly test restore procedures in staging
|
||||||
|
4. **Monitor Warnings**: Even if compatible, review warnings before proceeding
|
||||||
|
5. **Keep Backups Organized**: Label backups with version info in filename
|
||||||
|
|
||||||
|
## Migration Instructions
|
||||||
|
|
||||||
|
1. Run the new migration:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm run migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Existing backups will show "unknown" for version fields
|
||||||
|
3. New backups will automatically include version information
|
||||||
|
4. The system remains backward compatible with old backups
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Version Mismatch Errors
|
||||||
|
- Check current app version: `cat backend/package.json | grep version`
|
||||||
|
- Check Node version: `node --version`
|
||||||
|
- Check latest migration: `SELECT name FROM knex_migrations ORDER BY id DESC LIMIT 1`
|
||||||
|
|
||||||
|
### Restore Failures
|
||||||
|
- Review `restore_history` table for detailed error messages
|
||||||
|
- Check version compatibility warnings
|
||||||
|
- Consider using same version environment for critical restores
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **Automated Version Matching**: Docker containers with specific versions
|
||||||
|
2. **Migration Rollback**: Support for downgrading schema safely
|
||||||
|
3. **Version Matrix**: Compatibility matrix for different version combinations
|
||||||
|
4. **Restore Wizard**: UI for guided restore with compatibility checks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Implementation Date**: January 2025
|
||||||
|
**Current Version**: 1.0.77
|
||||||
|
**Status**: Production Ready
|
||||||
@@ -94,6 +94,20 @@ Perfect for:
|
|||||||
- **Email**: SMTP with customizable templates
|
- **Email**: SMTP with customizable templates
|
||||||
- **Analytics**: Privacy-focused with Umami integration
|
- **Analytics**: Privacy-focused with Umami integration
|
||||||
|
|
||||||
|
## 💻 System Requirements
|
||||||
|
|
||||||
|
### Minimum Requirements
|
||||||
|
- **CPU**: 2 CPU cores
|
||||||
|
- **RAM**: 2GB minimum
|
||||||
|
- **Storage**: 20GB minimum (plus photo storage needs)
|
||||||
|
- **OS**: Linux (Ubuntu 20.04+), macOS, or Windows with WSL2
|
||||||
|
- **Node.js**: v18.0.0 or higher
|
||||||
|
- **Database**: SQLite (included) or PostgreSQL 12+
|
||||||
|
|
||||||
|
### Docker Requirements (Recommended)
|
||||||
|
- **Docker**: v20.10.0+
|
||||||
|
- **Docker Compose**: v2.0.0+
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome.
|
We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome.
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ exports.up = async function(knex) {
|
|||||||
await knex('app_settings').insert({
|
await knex('app_settings').insert({
|
||||||
setting_key: 'general_default_welcome_message',
|
setting_key: 'general_default_welcome_message',
|
||||||
setting_value: JSON.stringify('Thank you for using our photo sharing service! We hope you enjoy your photos.'),
|
setting_value: JSON.stringify('Thank you for using our photo sharing service! We hope you enjoy your photos.'),
|
||||||
setting_type: 'general',
|
setting_type: 'general'
|
||||||
updated_at: new Date()
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ exports.up = async function(knex) {
|
|||||||
|
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
await knex('app_settings').insert({
|
await knex('app_settings').insert({
|
||||||
...setting,
|
...setting
|
||||||
updated_at: knex.fn.now()
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
exports.up = function(knex) {
|
|
||||||
return knex.schema
|
|
||||||
// Add new settings to app_settings table
|
|
||||||
.table('app_settings', table => {
|
|
||||||
// S3 configuration enhancements
|
|
||||||
table.boolean('backup_s3_force_path_style').defaultTo(false).comment('Force path-style S3 URLs (for MinIO/self-hosted)');
|
|
||||||
table.boolean('backup_s3_ssl_enabled').defaultTo(true).comment('Enable SSL/TLS for S3 connections');
|
|
||||||
table.string('backup_s3_prefix', 255).comment('S3 key prefix for organizing backups');
|
|
||||||
|
|
||||||
// Backup features
|
|
||||||
table.boolean('backup_incremental').defaultTo(false).comment('Enable incremental backups');
|
|
||||||
table.boolean('backup_include_database').defaultTo(true).comment('Include database dumps in backups');
|
|
||||||
table.boolean('backup_manifest_enabled').defaultTo(true).comment('Generate backup manifests');
|
|
||||||
table.enum('backup_manifest_format', ['json', 'yaml']).defaultTo('json').comment('Manifest file format');
|
|
||||||
table.boolean('backup_encryption_enabled').defaultTo(false).comment('Enable backup encryption');
|
|
||||||
table.string('backup_database_schedule', 100).comment('Separate cron schedule for database-only backups');
|
|
||||||
})
|
|
||||||
|
|
||||||
// Enhance backup_runs table
|
|
||||||
.table('backup_runs', table => {
|
|
||||||
// Manifest tracking
|
|
||||||
table.string('manifest_path', 500).comment('Path to backup manifest file');
|
|
||||||
table.uuid('manifest_id').comment('Unique identifier for the manifest');
|
|
||||||
table.enum('manifest_format', ['json', 'yaml']).comment('Format of the manifest file');
|
|
||||||
|
|
||||||
// Incremental backup support
|
|
||||||
table.integer('parent_backup_id').unsigned().references('id').inTable('backup_runs').onDelete('SET NULL').comment('Parent backup for incremental backups');
|
|
||||||
table.enum('backup_mode', ['full', 'incremental', 'database']).defaultTo('full').comment('Type of backup performed');
|
|
||||||
|
|
||||||
// Add indexes for better query performance
|
|
||||||
table.index(['backup_mode', 'status'], 'idx_backup_runs_mode_status');
|
|
||||||
table.index(['parent_backup_id'], 'idx_backup_runs_parent');
|
|
||||||
table.index(['created_at', 'backup_mode'], 'idx_backup_runs_created_mode');
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create backup_manifest table for storing detailed manifest metadata
|
|
||||||
.createTable('backup_manifest', table => {
|
|
||||||
table.increments('id').primary();
|
|
||||||
table.integer('backup_run_id').unsigned().notNullable().references('id').inTable('backup_runs').onDelete('CASCADE');
|
|
||||||
table.uuid('manifest_id').notNullable().unique().comment('Unique identifier matching backup_runs.manifest_id');
|
|
||||||
table.string('version', 20).notNullable().defaultTo('1.0.0').comment('Manifest schema version');
|
|
||||||
table.enum('format', ['json', 'yaml']).notNullable().defaultTo('json');
|
|
||||||
|
|
||||||
// Backup metadata
|
|
||||||
table.timestamp('backup_start').notNullable();
|
|
||||||
table.timestamp('backup_end').notNullable();
|
|
||||||
table.bigInteger('total_size').unsigned().comment('Total size of backup in bytes');
|
|
||||||
table.integer('file_count').unsigned().comment('Number of files in backup');
|
|
||||||
table.integer('photo_count').unsigned().comment('Number of photos backed up');
|
|
||||||
table.integer('event_count').unsigned().comment('Number of events backed up');
|
|
||||||
|
|
||||||
// Incremental backup metadata
|
|
||||||
table.boolean('is_incremental').defaultTo(false);
|
|
||||||
table.uuid('parent_manifest_id').comment('Parent manifest ID for incremental backups');
|
|
||||||
table.timestamp('incremental_since').comment('Timestamp for incremental backup baseline');
|
|
||||||
|
|
||||||
// Content checksums
|
|
||||||
table.string('checksum_algorithm', 50).defaultTo('sha256').comment('Algorithm used for checksums');
|
|
||||||
table.text('manifest_checksum').comment('Checksum of the manifest file itself');
|
|
||||||
|
|
||||||
// Storage information
|
|
||||||
table.string('storage_location', 500).comment('Primary storage location (local path or S3 URI)');
|
|
||||||
table.string('storage_provider', 50).comment('Storage provider (local, s3, etc.)');
|
|
||||||
|
|
||||||
// Encryption metadata
|
|
||||||
table.boolean('is_encrypted').defaultTo(false);
|
|
||||||
table.string('encryption_algorithm', 100).comment('Encryption algorithm used');
|
|
||||||
table.string('encryption_key_id', 255).comment('ID of encryption key used');
|
|
||||||
|
|
||||||
// Additional metadata as JSON
|
|
||||||
table.json('metadata').comment('Additional metadata as JSON');
|
|
||||||
|
|
||||||
// Timestamps
|
|
||||||
table.timestamps(true, true);
|
|
||||||
|
|
||||||
// Indexes
|
|
||||||
table.index(['backup_run_id'], 'idx_manifest_backup_run');
|
|
||||||
table.index(['manifest_id'], 'idx_manifest_uuid');
|
|
||||||
table.index(['parent_manifest_id'], 'idx_manifest_parent');
|
|
||||||
table.index(['backup_start', 'backup_end'], 'idx_manifest_time_range');
|
|
||||||
table.index(['is_incremental', 'created_at'], 'idx_manifest_incremental_created');
|
|
||||||
})
|
|
||||||
|
|
||||||
// Add composite indexes for common query patterns
|
|
||||||
.raw(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_backup_runs_recent_successful
|
|
||||||
ON backup_runs(created_at DESC)
|
|
||||||
WHERE status = 'completed' AND backup_mode = 'full';
|
|
||||||
`)
|
|
||||||
.raw(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_backup_runs_incremental_chain
|
|
||||||
ON backup_runs(parent_backup_id, created_at)
|
|
||||||
WHERE backup_mode = 'incremental';
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.down = function(knex) {
|
|
||||||
return knex.schema
|
|
||||||
// Drop indexes first
|
|
||||||
.raw('DROP INDEX IF EXISTS idx_backup_runs_incremental_chain;')
|
|
||||||
.raw('DROP INDEX IF EXISTS idx_backup_runs_recent_successful;')
|
|
||||||
|
|
||||||
// Drop backup_manifest table
|
|
||||||
.dropTableIfExists('backup_manifest')
|
|
||||||
|
|
||||||
// Remove columns from backup_runs table
|
|
||||||
.table('backup_runs', table => {
|
|
||||||
table.dropIndex(['backup_mode', 'status'], 'idx_backup_runs_mode_status');
|
|
||||||
table.dropIndex(['parent_backup_id'], 'idx_backup_runs_parent');
|
|
||||||
table.dropIndex(['created_at', 'backup_mode'], 'idx_backup_runs_created_mode');
|
|
||||||
|
|
||||||
table.dropColumn('manifest_path');
|
|
||||||
table.dropColumn('manifest_id');
|
|
||||||
table.dropColumn('manifest_format');
|
|
||||||
table.dropColumn('parent_backup_id');
|
|
||||||
table.dropColumn('backup_mode');
|
|
||||||
})
|
|
||||||
|
|
||||||
// Remove columns from app_settings table
|
|
||||||
.table('app_settings', table => {
|
|
||||||
table.dropColumn('backup_s3_force_path_style');
|
|
||||||
table.dropColumn('backup_s3_ssl_enabled');
|
|
||||||
table.dropColumn('backup_s3_prefix');
|
|
||||||
table.dropColumn('backup_incremental');
|
|
||||||
table.dropColumn('backup_include_database');
|
|
||||||
table.dropColumn('backup_manifest_enabled');
|
|
||||||
table.dropColumn('backup_manifest_format');
|
|
||||||
table.dropColumn('backup_encryption_enabled');
|
|
||||||
table.dropColumn('backup_database_schedule');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
const { formatBoolean, parseBoolean } = require('./helpers');
|
// No helpers needed for this migration
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add restore_runs table for tracking restore operations
|
* Add restore_runs table for tracking restore operations
|
||||||
@@ -30,9 +30,9 @@ exports.up = async function(knex) {
|
|||||||
table.string('pre_restore_backup_path', 500); // Path to pre-restore safety backup
|
table.string('pre_restore_backup_path', 500); // Path to pre-restore safety backup
|
||||||
|
|
||||||
// Flags
|
// Flags
|
||||||
table.boolean('is_dry_run').defaultTo(formatBoolean(false));
|
table.boolean('is_dry_run').defaultTo(false);
|
||||||
table.boolean('was_rollback_attempted').defaultTo(formatBoolean(false));
|
table.boolean('was_rollback_attempted').defaultTo(false);
|
||||||
table.boolean('was_successful').defaultTo(formatBoolean(false));
|
table.boolean('was_successful').defaultTo(false);
|
||||||
|
|
||||||
// Operator information
|
// Operator information
|
||||||
table.string('operator_type', 50).defaultTo('manual'); // manual, scheduled, api
|
table.string('operator_type', 50).defaultTo('manual'); // manual, scheduled, api
|
||||||
@@ -59,7 +59,7 @@ exports.up = async function(knex) {
|
|||||||
|
|
||||||
table.bigInteger('file_size');
|
table.bigInteger('file_size');
|
||||||
table.string('checksum', 64);
|
table.string('checksum', 64);
|
||||||
table.boolean('checksum_verified').defaultTo(formatBoolean(false));
|
table.boolean('checksum_verified').defaultTo(false);
|
||||||
|
|
||||||
table.text('error_message');
|
table.text('error_message');
|
||||||
table.timestamp('started_at');
|
table.timestamp('started_at');
|
||||||
@@ -92,60 +92,43 @@ exports.up = async function(knex) {
|
|||||||
await knex('app_settings').insert([
|
await knex('app_settings').insert([
|
||||||
{
|
{
|
||||||
setting_key: 'restore_allow_force',
|
setting_key: 'restore_allow_force',
|
||||||
setting_value: formatBoolean(false),
|
setting_value: JSON.stringify(false),
|
||||||
setting_type: 'restore',
|
setting_type: 'restore'
|
||||||
description: 'Allow force restore with warnings',
|
|
||||||
created_at: knex.fn.now(),
|
|
||||||
updated_at: knex.fn.now()
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
setting_key: 'restore_require_pre_backup',
|
setting_key: 'restore_require_pre_backup',
|
||||||
setting_value: formatBoolean(true),
|
setting_value: JSON.stringify(true),
|
||||||
setting_type: 'restore',
|
setting_type: 'restore'
|
||||||
description: 'Require pre-restore backup',
|
|
||||||
created_at: knex.fn.now(),
|
|
||||||
updated_at: knex.fn.now()
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
setting_key: 'restore_max_file_size_mb',
|
setting_key: 'restore_max_file_size_mb',
|
||||||
setting_value: '5000',
|
setting_value: '5000',
|
||||||
setting_type: 'restore',
|
setting_type: 'restore'
|
||||||
description: 'Maximum file size for restore (MB)',
|
|
||||||
created_at: knex.fn.now(),
|
|
||||||
updated_at: knex.fn.now()
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
setting_key: 'restore_verify_checksums',
|
setting_key: 'restore_verify_checksums',
|
||||||
setting_value: formatBoolean(true),
|
setting_value: JSON.stringify(true),
|
||||||
setting_type: 'restore',
|
setting_type: 'restore'
|
||||||
description: 'Verify file checksums during restore',
|
|
||||||
created_at: knex.fn.now(),
|
|
||||||
updated_at: knex.fn.now()
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
setting_key: 'restore_email_on_completion',
|
setting_key: 'restore_email_on_completion',
|
||||||
setting_value: formatBoolean(true),
|
setting_value: JSON.stringify(true),
|
||||||
setting_type: 'restore',
|
setting_type: 'restore'
|
||||||
description: 'Send email on restore completion',
|
|
||||||
created_at: knex.fn.now(),
|
|
||||||
updated_at: knex.fn.now()
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
setting_key: 'restore_retention_days',
|
setting_key: 'restore_retention_days',
|
||||||
setting_value: '30',
|
setting_value: '30',
|
||||||
setting_type: 'restore',
|
setting_type: 'restore'
|
||||||
description: 'Days to retain restore history',
|
|
||||||
created_at: knex.fn.now(),
|
|
||||||
updated_at: knex.fn.now()
|
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Add new email templates for restore notifications
|
// Add new email templates for restore notifications
|
||||||
const emailTemplates = [
|
const emailTemplates = [
|
||||||
{
|
{
|
||||||
name: 'restore_completed',
|
template_key: 'restore_completed',
|
||||||
subject: '✅ Restore Completed Successfully',
|
subject_en: '✅ Restore Completed Successfully',
|
||||||
body: `<h2>Restore Operation Completed</h2>
|
subject_de: '✅ Wiederherstellung erfolgreich abgeschlossen',
|
||||||
|
body_html_en: `<h2>Restore Operation Completed</h2>
|
||||||
<p>A restore operation has completed successfully.</p>
|
<p>A restore operation has completed successfully.</p>
|
||||||
|
|
||||||
<h3>Details:</h3>
|
<h3>Details:</h3>
|
||||||
@@ -158,36 +141,7 @@ exports.up = async function(knex) {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p>Please verify that all systems are functioning correctly after the restore.</p>`,
|
<p>Please verify that all systems are functioning correctly after the restore.</p>`,
|
||||||
language: 'en',
|
body_html_de: `<h2>Wiederherstellungsvorgang abgeschlossen</h2>
|
||||||
is_active: formatBoolean(true),
|
|
||||||
created_at: knex.fn.now(),
|
|
||||||
updated_at: knex.fn.now()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'restore_failed',
|
|
||||||
subject: '❌ Restore Operation Failed',
|
|
||||||
body: `<h2>Restore Operation Failed</h2>
|
|
||||||
<p>A restore operation has failed and requires attention.</p>
|
|
||||||
|
|
||||||
<h3>Details:</h3>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Restore Type:</strong> {{restore_type}}</li>
|
|
||||||
<li><strong>Error:</strong> {{error_message}}</li>
|
|
||||||
<li><strong>Timestamp:</strong> {{timestamp}}</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p>Please check the system logs for more details and take appropriate action.</p>
|
|
||||||
|
|
||||||
<p><strong>Important:</strong> If a pre-restore backup was created, it may be used for recovery.</p>`,
|
|
||||||
language: 'en',
|
|
||||||
is_active: formatBoolean(true),
|
|
||||||
created_at: knex.fn.now(),
|
|
||||||
updated_at: knex.fn.now()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'restore_completed',
|
|
||||||
subject: '✅ Wiederherstellung erfolgreich abgeschlossen',
|
|
||||||
body: `<h2>Wiederherstellungsvorgang abgeschlossen</h2>
|
|
||||||
<p>Ein Wiederherstellungsvorgang wurde erfolgreich abgeschlossen.</p>
|
<p>Ein Wiederherstellungsvorgang wurde erfolgreich abgeschlossen.</p>
|
||||||
|
|
||||||
<h3>Details:</h3>
|
<h3>Details:</h3>
|
||||||
@@ -200,15 +154,50 @@ exports.up = async function(knex) {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p>Bitte überprüfen Sie, ob alle Systeme nach der Wiederherstellung ordnungsgemäß funktionieren.</p>`,
|
<p>Bitte überprüfen Sie, ob alle Systeme nach der Wiederherstellung ordnungsgemäß funktionieren.</p>`,
|
||||||
language: 'de',
|
body_text_en: `Restore Operation Completed
|
||||||
is_active: formatBoolean(true),
|
|
||||||
created_at: knex.fn.now(),
|
A restore operation has completed successfully.
|
||||||
updated_at: knex.fn.now()
|
|
||||||
|
Details:
|
||||||
|
- Restore Type: {{restore_type}}
|
||||||
|
- Duration: {{duration}}
|
||||||
|
- Files Restored: {{files_restored}}
|
||||||
|
- Backup ID: {{backup_id}}
|
||||||
|
- Timestamp: {{timestamp}}
|
||||||
|
|
||||||
|
Please verify that all systems are functioning correctly after the restore.`,
|
||||||
|
body_text_de: `Wiederherstellungsvorgang abgeschlossen
|
||||||
|
|
||||||
|
Ein Wiederherstellungsvorgang wurde erfolgreich abgeschlossen.
|
||||||
|
|
||||||
|
Details:
|
||||||
|
- Wiederherstellungstyp: {{restore_type}}
|
||||||
|
- Dauer: {{duration}}
|
||||||
|
- Wiederhergestellte Dateien: {{files_restored}}
|
||||||
|
- Backup-ID: {{backup_id}}
|
||||||
|
- Zeitstempel: {{timestamp}}
|
||||||
|
|
||||||
|
Bitte überprüfen Sie, ob alle Systeme nach der Wiederherstellung ordnungsgemäß funktionieren.`,
|
||||||
|
variables: JSON.stringify(['restore_type', 'duration', 'files_restored', 'backup_id', 'timestamp'])
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'restore_failed',
|
template_key: 'restore_failed',
|
||||||
subject: '❌ Wiederherstellungsvorgang fehlgeschlagen',
|
subject_en: '❌ Restore Operation Failed',
|
||||||
body: `<h2>Wiederherstellungsvorgang fehlgeschlagen</h2>
|
subject_de: '❌ Wiederherstellungsvorgang fehlgeschlagen',
|
||||||
|
body_html_en: `<h2>Restore Operation Failed</h2>
|
||||||
|
<p>A restore operation has failed and requires attention.</p>
|
||||||
|
|
||||||
|
<h3>Details:</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Restore Type:</strong> {{restore_type}}</li>
|
||||||
|
<li><strong>Error:</strong> {{error_message}}</li>
|
||||||
|
<li><strong>Timestamp:</strong> {{timestamp}}</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>Please check the system logs for more details and take appropriate action.</p>
|
||||||
|
|
||||||
|
<p><strong>Important:</strong> If a pre-restore backup was created, it may be used for recovery.</p>`,
|
||||||
|
body_html_de: `<h2>Wiederherstellungsvorgang fehlgeschlagen</h2>
|
||||||
<p>Ein Wiederherstellungsvorgang ist fehlgeschlagen und erfordert Ihre Aufmerksamkeit.</p>
|
<p>Ein Wiederherstellungsvorgang ist fehlgeschlagen und erfordert Ihre Aufmerksamkeit.</p>
|
||||||
|
|
||||||
<h3>Details:</h3>
|
<h3>Details:</h3>
|
||||||
@@ -221,10 +210,31 @@ exports.up = async function(knex) {
|
|||||||
<p>Bitte überprüfen Sie die Systemprotokolle für weitere Details und ergreifen Sie entsprechende Maßnahmen.</p>
|
<p>Bitte überprüfen Sie die Systemprotokolle für weitere Details und ergreifen Sie entsprechende Maßnahmen.</p>
|
||||||
|
|
||||||
<p><strong>Wichtig:</strong> Falls ein Backup vor der Wiederherstellung erstellt wurde, kann es zur Wiederherstellung verwendet werden.</p>`,
|
<p><strong>Wichtig:</strong> Falls ein Backup vor der Wiederherstellung erstellt wurde, kann es zur Wiederherstellung verwendet werden.</p>`,
|
||||||
language: 'de',
|
body_text_en: `Restore Operation Failed
|
||||||
is_active: formatBoolean(true),
|
|
||||||
created_at: knex.fn.now(),
|
A restore operation has failed and requires attention.
|
||||||
updated_at: knex.fn.now()
|
|
||||||
|
Details:
|
||||||
|
- Restore Type: {{restore_type}}
|
||||||
|
- Error: {{error_message}}
|
||||||
|
- Timestamp: {{timestamp}}
|
||||||
|
|
||||||
|
Please check the system logs for more details and take appropriate action.
|
||||||
|
|
||||||
|
Important: If a pre-restore backup was created, it may be used for recovery.`,
|
||||||
|
body_text_de: `Wiederherstellungsvorgang fehlgeschlagen
|
||||||
|
|
||||||
|
Ein Wiederherstellungsvorgang ist fehlgeschlagen und erfordert Ihre Aufmerksamkeit.
|
||||||
|
|
||||||
|
Details:
|
||||||
|
- Wiederherstellungstyp: {{restore_type}}
|
||||||
|
- Fehler: {{error_message}}
|
||||||
|
- Zeitstempel: {{timestamp}}
|
||||||
|
|
||||||
|
Bitte überprüfen Sie die Systemprotokolle für weitere Details und ergreifen Sie entsprechende Maßnahmen.
|
||||||
|
|
||||||
|
Wichtig: Falls ein Backup vor der Wiederherstellung erstellt wurde, kann es zur Wiederherstellung verwendet werden.`,
|
||||||
|
variables: JSON.stringify(['restore_type', 'error_message', 'timestamp'])
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -234,7 +244,7 @@ exports.up = async function(knex) {
|
|||||||
exports.down = async function(knex) {
|
exports.down = async function(knex) {
|
||||||
// Remove email templates
|
// Remove email templates
|
||||||
await knex('email_templates')
|
await knex('email_templates')
|
||||||
.whereIn('name', ['restore_completed', 'restore_failed'])
|
.whereIn('template_key', ['restore_completed', 'restore_failed'])
|
||||||
.delete();
|
.delete();
|
||||||
|
|
||||||
// Remove settings
|
// Remove settings
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// No helpers needed for boolean values
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
console.log('Adding gallery feedback tables...');
|
||||||
|
|
||||||
|
// Create event_feedback_settings table
|
||||||
|
await knex.schema.createTable('event_feedback_settings', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||||
|
table.boolean('feedback_enabled').defaultTo(false);
|
||||||
|
table.boolean('allow_ratings').defaultTo(true);
|
||||||
|
table.boolean('allow_likes').defaultTo(true);
|
||||||
|
table.boolean('allow_comments').defaultTo(false);
|
||||||
|
table.boolean('allow_favorites').defaultTo(true);
|
||||||
|
table.boolean('require_name_email').defaultTo(false);
|
||||||
|
table.boolean('moderate_comments').defaultTo(true);
|
||||||
|
table.boolean('show_feedback_to_guests').defaultTo(true);
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||||
|
table.unique(['event_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create photo_feedback table
|
||||||
|
await knex.schema.createTable('photo_feedback', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.integer('photo_id').references('id').inTable('photos').onDelete('CASCADE');
|
||||||
|
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||||
|
table.string('feedback_type', 20).notNullable();
|
||||||
|
table.integer('rating');
|
||||||
|
table.text('comment_text');
|
||||||
|
table.string('guest_name', 100);
|
||||||
|
table.string('guest_email', 255);
|
||||||
|
table.string('guest_identifier', 64);
|
||||||
|
table.string('ip_address', 45);
|
||||||
|
table.text('user_agent');
|
||||||
|
table.boolean('is_approved').defaultTo(true);
|
||||||
|
table.boolean('is_hidden').defaultTo(false);
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
// Add indexes
|
||||||
|
table.index(['photo_id']);
|
||||||
|
table.index(['event_id']);
|
||||||
|
table.index(['feedback_type']);
|
||||||
|
table.index(['guest_identifier']);
|
||||||
|
|
||||||
|
// Add check constraint for rating (PostgreSQL)
|
||||||
|
if (knex.client.config.client === 'pg') {
|
||||||
|
table.check('?? >= 1 AND ?? <= 5', ['rating', 'rating']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create feedback_rate_limits table
|
||||||
|
await knex.schema.createTable('feedback_rate_limits', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.string('identifier', 64).notNullable();
|
||||||
|
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||||
|
table.string('action_type', 20).notNullable();
|
||||||
|
table.integer('action_count').defaultTo(1);
|
||||||
|
table.timestamp('window_start').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
// Add indexes
|
||||||
|
table.index(['identifier', 'event_id', 'action_type']);
|
||||||
|
table.index(['window_start']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create feedback_word_filters table
|
||||||
|
await knex.schema.createTable('feedback_word_filters', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.string('word', 100).notNullable();
|
||||||
|
table.string('severity', 20).defaultTo('moderate');
|
||||||
|
table.boolean('is_active').defaultTo(true);
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
table.unique(['word']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add feedback summary columns to photos table
|
||||||
|
await knex.schema.alterTable('photos', (table) => {
|
||||||
|
table.integer('feedback_count').defaultTo(0);
|
||||||
|
table.integer('like_count').defaultTo(0);
|
||||||
|
table.decimal('average_rating', 3, 2).defaultTo(0);
|
||||||
|
table.integer('favorite_count').defaultTo(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add feedback notification settings to app_settings
|
||||||
|
await knex('app_settings').insert([
|
||||||
|
{
|
||||||
|
setting_key: 'feedback_notification_email',
|
||||||
|
setting_value: JSON.stringify(''),
|
||||||
|
setting_type: 'feedback'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setting_key: 'feedback_rate_limits',
|
||||||
|
setting_value: JSON.stringify({
|
||||||
|
rating: { max: 100, window: 3600 }, // 100 ratings per hour
|
||||||
|
comment: { max: 20, window: 3600 }, // 20 comments per hour
|
||||||
|
like: { max: 200, window: 3600 } // 200 likes per hour
|
||||||
|
}),
|
||||||
|
setting_type: 'feedback'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log('Gallery feedback tables created successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
console.log('Removing gallery feedback tables...');
|
||||||
|
|
||||||
|
// Remove feedback settings from app_settings
|
||||||
|
await knex('app_settings')
|
||||||
|
.whereIn('setting_key', ['feedback_notification_email', 'feedback_rate_limits'])
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
// Remove feedback columns from photos table
|
||||||
|
await knex.schema.alterTable('photos', (table) => {
|
||||||
|
table.dropColumn('feedback_count');
|
||||||
|
table.dropColumn('like_count');
|
||||||
|
table.dropColumn('average_rating');
|
||||||
|
table.dropColumn('favorite_count');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drop tables in reverse order
|
||||||
|
await knex.schema.dropTableIfExists('feedback_word_filters');
|
||||||
|
await knex.schema.dropTableIfExists('feedback_rate_limits');
|
||||||
|
await knex.schema.dropTableIfExists('photo_feedback');
|
||||||
|
await knex.schema.dropTableIfExists('event_feedback_settings');
|
||||||
|
|
||||||
|
console.log('Gallery feedback tables removed');
|
||||||
|
};
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
const { db } = require('../src/database/db');
|
||||||
|
|
||||||
|
async function up() {
|
||||||
|
console.log('Adding version tracking to backup tables...');
|
||||||
|
|
||||||
|
// Add version columns to database_backup_runs table
|
||||||
|
const hasDatabaseBackupRunsTable = await db.schema.hasTable('database_backup_runs');
|
||||||
|
if (hasDatabaseBackupRunsTable) {
|
||||||
|
const hasAppVersion = await db.schema.hasColumn('database_backup_runs', 'app_version');
|
||||||
|
if (!hasAppVersion) {
|
||||||
|
await db.schema.alterTable('database_backup_runs', (table) => {
|
||||||
|
table.string('app_version'); // Application version
|
||||||
|
table.string('node_version'); // Node.js version
|
||||||
|
table.string('db_schema_version'); // Database schema version (migration name)
|
||||||
|
table.json('environment_info'); // Additional environment information
|
||||||
|
});
|
||||||
|
console.log('Added version columns to database_backup_runs table');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add version columns to backup_runs table (file backups)
|
||||||
|
const hasBackupRunsTable = await db.schema.hasTable('backup_runs');
|
||||||
|
if (hasBackupRunsTable) {
|
||||||
|
const hasAppVersion = await db.schema.hasColumn('backup_runs', 'app_version');
|
||||||
|
if (!hasAppVersion) {
|
||||||
|
await db.schema.alterTable('backup_runs', (table) => {
|
||||||
|
table.string('app_version'); // Application version
|
||||||
|
table.string('node_version'); // Node.js version
|
||||||
|
table.string('db_schema_version'); // Database schema version
|
||||||
|
table.json('manifest_info'); // Manifest summary information
|
||||||
|
});
|
||||||
|
console.log('Added version columns to backup_runs table');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add restore tracking table
|
||||||
|
const hasRestoreHistoryTable = await db.schema.hasTable('restore_history');
|
||||||
|
if (!hasRestoreHistoryTable) {
|
||||||
|
await db.schema.createTable('restore_history', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.datetime('started_at').notNullable();
|
||||||
|
table.datetime('completed_at');
|
||||||
|
table.string('status').defaultTo('running'); // running, completed, failed, partial
|
||||||
|
table.string('restore_type'); // database, files, full
|
||||||
|
table.string('backup_id'); // Reference to the backup that was restored
|
||||||
|
table.string('backup_app_version'); // Version of app that created the backup
|
||||||
|
table.string('restore_app_version'); // Version of app performing the restore
|
||||||
|
table.string('backup_node_version'); // Node version that created the backup
|
||||||
|
table.string('restore_node_version'); // Node version performing the restore
|
||||||
|
table.string('backup_schema_version'); // Schema version in the backup
|
||||||
|
table.string('restore_schema_version'); // Current schema version
|
||||||
|
table.json('version_compatibility'); // Compatibility check results
|
||||||
|
table.json('restore_options'); // Options used during restore
|
||||||
|
table.json('statistics'); // Restore statistics
|
||||||
|
table.text('warnings'); // Any warnings during restore
|
||||||
|
table.text('error_message'); // Error details if failed
|
||||||
|
table.string('restored_by'); // User who initiated the restore
|
||||||
|
table.index(['started_at'], 'idx_restore_started');
|
||||||
|
table.index(['backup_id'], 'idx_restore_backup_id');
|
||||||
|
});
|
||||||
|
console.log('Created restore_history table');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add version compatibility settings
|
||||||
|
const versionSettings = [
|
||||||
|
{
|
||||||
|
setting_key: 'backup_require_version_match',
|
||||||
|
setting_value: JSON.stringify(false), // If true, exact version match required for restore
|
||||||
|
setting_type: 'backup'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setting_key: 'backup_allow_minor_version_mismatch',
|
||||||
|
setting_value: JSON.stringify(true), // Allow restoring from same major version
|
||||||
|
setting_type: 'backup'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setting_key: 'backup_warn_on_version_mismatch',
|
||||||
|
setting_value: JSON.stringify(true), // Show warning when versions don't match
|
||||||
|
setting_type: 'backup'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setting_key: 'backup_check_schema_compatibility',
|
||||||
|
setting_value: JSON.stringify(true), // Check if migrations are compatible
|
||||||
|
setting_type: 'backup'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Insert version settings if they don't exist
|
||||||
|
for (const setting of versionSettings) {
|
||||||
|
const exists = await db('app_settings')
|
||||||
|
.where('setting_key', setting.setting_key)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
await db('app_settings').insert(setting);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Version tracking for backups added successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function down() {
|
||||||
|
// Remove version columns from database_backup_runs
|
||||||
|
const hasDatabaseBackupRunsTable = await db.schema.hasTable('database_backup_runs');
|
||||||
|
if (hasDatabaseBackupRunsTable) {
|
||||||
|
await db.schema.alterTable('database_backup_runs', (table) => {
|
||||||
|
table.dropColumn('app_version');
|
||||||
|
table.dropColumn('node_version');
|
||||||
|
table.dropColumn('db_schema_version');
|
||||||
|
table.dropColumn('environment_info');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove version columns from backup_runs
|
||||||
|
const hasBackupRunsTable = await db.schema.hasTable('backup_runs');
|
||||||
|
if (hasBackupRunsTable) {
|
||||||
|
await db.schema.alterTable('backup_runs', (table) => {
|
||||||
|
table.dropColumn('app_version');
|
||||||
|
table.dropColumn('node_version');
|
||||||
|
table.dropColumn('db_schema_version');
|
||||||
|
table.dropColumn('manifest_info');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop restore_history table
|
||||||
|
await db.schema.dropTableIfExists('restore_history');
|
||||||
|
|
||||||
|
// Remove version settings
|
||||||
|
await db('app_settings')
|
||||||
|
.whereIn('setting_key', [
|
||||||
|
'backup_require_version_match',
|
||||||
|
'backup_allow_minor_version_mismatch',
|
||||||
|
'backup_warn_on_version_mismatch',
|
||||||
|
'backup_check_schema_compatibility'
|
||||||
|
])
|
||||||
|
.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { up, down };
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
const { db } = require('../src/database/db');
|
||||||
|
|
||||||
|
async function up() {
|
||||||
|
console.log('Enhancing backup system...');
|
||||||
|
|
||||||
|
// Add new settings to app_settings table if they don't exist
|
||||||
|
const backupSettings = [
|
||||||
|
{
|
||||||
|
setting_key: 'backup_s3_force_path_style',
|
||||||
|
setting_value: JSON.stringify(false),
|
||||||
|
setting_type: 'backup'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setting_key: 'backup_s3_ssl_enabled',
|
||||||
|
setting_value: JSON.stringify(true),
|
||||||
|
setting_type: 'backup'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setting_key: 'backup_s3_prefix',
|
||||||
|
setting_value: JSON.stringify(''),
|
||||||
|
setting_type: 'backup'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setting_key: 'backup_incremental',
|
||||||
|
setting_value: JSON.stringify(false),
|
||||||
|
setting_type: 'backup'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setting_key: 'backup_include_database',
|
||||||
|
setting_value: JSON.stringify(true),
|
||||||
|
setting_type: 'backup'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setting_key: 'backup_encryption_enabled',
|
||||||
|
setting_value: JSON.stringify(false),
|
||||||
|
setting_type: 'backup'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setting_key: 'backup_database_schedule',
|
||||||
|
setting_value: JSON.stringify(''),
|
||||||
|
setting_type: 'backup'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Insert settings if they don't exist
|
||||||
|
for (const setting of backupSettings) {
|
||||||
|
const exists = await db('app_settings')
|
||||||
|
.where('setting_key', setting.setting_key)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
await db('app_settings').insert(setting);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check and add columns to backup_runs table
|
||||||
|
const hasBackupRunsTable = await db.schema.hasTable('backup_runs');
|
||||||
|
if (hasBackupRunsTable) {
|
||||||
|
// Check for existing columns before adding
|
||||||
|
const hasManifestPath = await db.schema.hasColumn('backup_runs', 'manifest_path');
|
||||||
|
const hasManifestId = await db.schema.hasColumn('backup_runs', 'manifest_id');
|
||||||
|
const hasManifestFormat = await db.schema.hasColumn('backup_runs', 'manifest_format');
|
||||||
|
const hasParentBackupId = await db.schema.hasColumn('backup_runs', 'parent_backup_id');
|
||||||
|
const hasBackupMode = await db.schema.hasColumn('backup_runs', 'backup_mode');
|
||||||
|
|
||||||
|
if (!hasManifestPath || !hasManifestId || !hasManifestFormat || !hasParentBackupId || !hasBackupMode) {
|
||||||
|
await db.schema.alterTable('backup_runs', (table) => {
|
||||||
|
if (!hasManifestPath) {
|
||||||
|
table.string('manifest_path', 500).comment('Path to backup manifest file');
|
||||||
|
}
|
||||||
|
if (!hasManifestId) {
|
||||||
|
table.uuid('manifest_id').comment('Unique identifier for the manifest');
|
||||||
|
}
|
||||||
|
if (!hasManifestFormat) {
|
||||||
|
table.enum('manifest_format', ['json', 'yaml']).comment('Format of the manifest file');
|
||||||
|
}
|
||||||
|
if (!hasParentBackupId) {
|
||||||
|
table.integer('parent_backup_id').unsigned().references('id').inTable('backup_runs').onDelete('SET NULL').comment('Parent backup for incremental backups');
|
||||||
|
}
|
||||||
|
if (!hasBackupMode) {
|
||||||
|
table.enum('backup_mode', ['full', 'incremental', 'database']).defaultTo('full').comment('Type of backup performed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add indexes if they don't exist
|
||||||
|
try {
|
||||||
|
await db.raw('CREATE INDEX IF NOT EXISTS idx_backup_runs_mode_status ON backup_runs(backup_mode, status)');
|
||||||
|
await db.raw('CREATE INDEX IF NOT EXISTS idx_backup_runs_parent ON backup_runs(parent_backup_id)');
|
||||||
|
await db.raw('CREATE INDEX IF NOT EXISTS idx_backup_runs_created_mode ON backup_runs(created_at, backup_mode)');
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Note: Some indexes may already exist, continuing...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup_manifest table if it doesn't exist
|
||||||
|
const hasManifestTable = await db.schema.hasTable('backup_manifest');
|
||||||
|
if (!hasManifestTable) {
|
||||||
|
await db.schema.createTable('backup_manifest', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.integer('backup_run_id').unsigned().notNullable().references('id').inTable('backup_runs').onDelete('CASCADE');
|
||||||
|
table.uuid('manifest_id').notNullable().unique().comment('Unique identifier matching backup_runs.manifest_id');
|
||||||
|
table.string('version', 20).notNullable().defaultTo('1.0.0').comment('Manifest schema version');
|
||||||
|
table.enum('format', ['json', 'yaml']).notNullable().defaultTo('json');
|
||||||
|
|
||||||
|
// Backup metadata
|
||||||
|
table.timestamp('backup_start').notNullable();
|
||||||
|
table.timestamp('backup_end').notNullable();
|
||||||
|
table.bigInteger('total_size').unsigned().comment('Total size of backup in bytes');
|
||||||
|
table.integer('file_count').unsigned().comment('Number of files in backup');
|
||||||
|
table.integer('photo_count').unsigned().comment('Number of photos backed up');
|
||||||
|
table.integer('event_count').unsigned().comment('Number of events backed up');
|
||||||
|
|
||||||
|
// Incremental backup metadata
|
||||||
|
table.boolean('is_incremental').defaultTo(false);
|
||||||
|
table.uuid('parent_manifest_id').comment('Parent manifest ID for incremental backups');
|
||||||
|
table.timestamp('incremental_since').comment('Timestamp for incremental backup baseline');
|
||||||
|
|
||||||
|
// Content checksums
|
||||||
|
table.string('checksum_algorithm', 50).defaultTo('sha256').comment('Algorithm used for checksums');
|
||||||
|
table.text('manifest_checksum').comment('Checksum of the manifest file itself');
|
||||||
|
|
||||||
|
// Storage information
|
||||||
|
table.string('storage_location', 500).comment('Primary storage location (local path or S3 URI)');
|
||||||
|
table.string('storage_provider', 50).comment('Storage provider (local, s3, etc.)');
|
||||||
|
|
||||||
|
// Encryption metadata
|
||||||
|
table.boolean('is_encrypted').defaultTo(false);
|
||||||
|
table.string('encryption_algorithm', 100).comment('Encryption algorithm used');
|
||||||
|
table.string('encryption_key_id', 255).comment('ID of encryption key used');
|
||||||
|
|
||||||
|
// Additional metadata as JSON
|
||||||
|
table.json('metadata').comment('Additional metadata as JSON');
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
table.timestamps(true, true);
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
table.index(['backup_run_id'], 'idx_manifest_backup_run');
|
||||||
|
table.index(['manifest_id'], 'idx_manifest_uuid');
|
||||||
|
table.index(['parent_manifest_id'], 'idx_manifest_parent');
|
||||||
|
table.index(['backup_start', 'backup_end'], 'idx_manifest_time_range');
|
||||||
|
table.index(['is_incremental', 'created_at'], 'idx_manifest_incremental_created');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add composite indexes for common query patterns
|
||||||
|
try {
|
||||||
|
await db.raw(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_backup_runs_recent_successful
|
||||||
|
ON backup_runs(created_at DESC)
|
||||||
|
WHERE status = 'completed' AND backup_mode = 'full';
|
||||||
|
`);
|
||||||
|
await db.raw(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_backup_runs_incremental_chain
|
||||||
|
ON backup_runs(parent_backup_id, created_at)
|
||||||
|
WHERE backup_mode = 'incremental';
|
||||||
|
`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Note: Some composite indexes may already exist, continuing...');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Backup system enhancements completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function down() {
|
||||||
|
// Drop indexes first
|
||||||
|
try {
|
||||||
|
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_incremental_chain;');
|
||||||
|
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_recent_successful;');
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors if indexes don't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop backup_manifest table
|
||||||
|
await db.schema.dropTableIfExists('backup_manifest');
|
||||||
|
|
||||||
|
// Remove columns from backup_runs if they exist
|
||||||
|
const hasBackupRunsTable = await db.schema.hasTable('backup_runs');
|
||||||
|
if (hasBackupRunsTable) {
|
||||||
|
const hasBackupMode = await db.schema.hasColumn('backup_runs', 'backup_mode');
|
||||||
|
const hasParentBackupId = await db.schema.hasColumn('backup_runs', 'parent_backup_id');
|
||||||
|
const hasManifestFormat = await db.schema.hasColumn('backup_runs', 'manifest_format');
|
||||||
|
const hasManifestId = await db.schema.hasColumn('backup_runs', 'manifest_id');
|
||||||
|
const hasManifestPath = await db.schema.hasColumn('backup_runs', 'manifest_path');
|
||||||
|
|
||||||
|
if (hasBackupMode || hasParentBackupId || hasManifestFormat || hasManifestId || hasManifestPath) {
|
||||||
|
await db.schema.alterTable('backup_runs', (table) => {
|
||||||
|
if (hasBackupMode) table.dropColumn('backup_mode');
|
||||||
|
if (hasParentBackupId) table.dropColumn('parent_backup_id');
|
||||||
|
if (hasManifestFormat) table.dropColumn('manifest_format');
|
||||||
|
if (hasManifestId) table.dropColumn('manifest_id');
|
||||||
|
if (hasManifestPath) table.dropColumn('manifest_path');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop indexes
|
||||||
|
try {
|
||||||
|
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_mode_status');
|
||||||
|
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_parent');
|
||||||
|
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_created_mode');
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors if indexes don't exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove settings
|
||||||
|
await db('app_settings')
|
||||||
|
.whereIn('setting_key', [
|
||||||
|
'backup_s3_force_path_style',
|
||||||
|
'backup_s3_ssl_enabled',
|
||||||
|
'backup_s3_prefix',
|
||||||
|
'backup_incremental',
|
||||||
|
'backup_include_database',
|
||||||
|
'backup_encryption_enabled',
|
||||||
|
'backup_database_schedule'
|
||||||
|
])
|
||||||
|
.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { up, down };
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.75",
|
"version": "1.0.93",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.75",
|
"version": "1.0.93",
|
||||||
"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.75",
|
"version": "1.0.93",
|
||||||
"description": "Backend for PicPeak event photo sharing platform",
|
"description": "Backend for PicPeak event photo sharing platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -205,6 +205,8 @@ app.use('/api/admin/auth', adminAuthRoutes);
|
|||||||
app.use('/api/admin/system', require('./src/routes/adminSystem'));
|
app.use('/api/admin/system', require('./src/routes/adminSystem'));
|
||||||
app.use('/api/admin/backup', require('./src/routes/adminBackup'));
|
app.use('/api/admin/backup', require('./src/routes/adminBackup'));
|
||||||
app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup'));
|
app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup'));
|
||||||
|
app.use('/api/admin/feedback', require('./src/routes/adminFeedback'));
|
||||||
|
app.use('/api/gallery', require('./src/routes/galleryFeedback'));
|
||||||
app.use('/api/public/settings', require('./src/routes/publicSettings'));
|
app.use('/api/public/settings', require('./src/routes/publicSettings'));
|
||||||
app.use('/api/public', require('./src/routes/publicCMS'));
|
app.use('/api/public', require('./src/routes/publicCMS'));
|
||||||
app.use('/api/images', require('./src/routes/protectedImages'));
|
app.use('/api/images', require('./src/routes/protectedImages'));
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique identifier for the guest
|
||||||
|
*/
|
||||||
|
function generateGuestIdentifier(req) {
|
||||||
|
const ip = req.ip || req.connection.remoteAddress || 'unknown';
|
||||||
|
const userAgent = req.headers['user-agent'] || 'unknown';
|
||||||
|
return crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(`${ip}:${userAgent}`)
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rate limit settings from app_settings
|
||||||
|
*/
|
||||||
|
async function getRateLimitSettings() {
|
||||||
|
try {
|
||||||
|
const settings = await db('app_settings')
|
||||||
|
.where('setting_key', 'feedback_rate_limits')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (settings && settings.setting_value) {
|
||||||
|
return JSON.parse(settings.setting_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default settings
|
||||||
|
return {
|
||||||
|
rating: { max: 100, window: 3600 }, // 100 ratings per hour
|
||||||
|
comment: { max: 20, window: 3600 }, // 20 comments per hour
|
||||||
|
like: { max: 200, window: 3600 }, // 200 likes per hour
|
||||||
|
favorite: { max: 100, window: 3600 } // 100 favorites per hour
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting rate limit settings:', error);
|
||||||
|
// Return defaults on error
|
||||||
|
return {
|
||||||
|
rating: { max: 100, window: 3600 },
|
||||||
|
comment: { max: 20, window: 3600 },
|
||||||
|
like: { max: 200, window: 3600 },
|
||||||
|
favorite: { max: 100, window: 3600 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if action is rate limited
|
||||||
|
*/
|
||||||
|
async function checkRateLimit(identifier, eventId, actionType) {
|
||||||
|
try {
|
||||||
|
const settings = await getRateLimitSettings();
|
||||||
|
const limit = settings[actionType] || { max: 100, window: 3600 };
|
||||||
|
|
||||||
|
// Clean old entries (older than window)
|
||||||
|
const cutoff = new Date(Date.now() - limit.window * 1000);
|
||||||
|
await db('feedback_rate_limits')
|
||||||
|
.where('window_start', '<', cutoff)
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
// Count recent actions
|
||||||
|
const recentActions = await db('feedback_rate_limits')
|
||||||
|
.where({
|
||||||
|
identifier,
|
||||||
|
event_id: eventId,
|
||||||
|
action_type: actionType
|
||||||
|
})
|
||||||
|
.where('window_start', '>', cutoff)
|
||||||
|
.sum('action_count as total')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const currentCount = recentActions?.total || 0;
|
||||||
|
|
||||||
|
if (currentCount >= limit.max) {
|
||||||
|
return {
|
||||||
|
limited: true,
|
||||||
|
limit: limit.max,
|
||||||
|
window: limit.window,
|
||||||
|
current: currentCount,
|
||||||
|
resetAt: new Date(Date.now() + limit.window * 1000)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
limited: false,
|
||||||
|
limit: limit.max,
|
||||||
|
window: limit.window,
|
||||||
|
current: currentCount,
|
||||||
|
remaining: limit.max - currentCount
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error checking rate limit:', error);
|
||||||
|
// Allow action on error to avoid blocking legitimate users
|
||||||
|
return { limited: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record an action for rate limiting
|
||||||
|
*/
|
||||||
|
async function recordAction(identifier, eventId, actionType) {
|
||||||
|
try {
|
||||||
|
await db('feedback_rate_limits').insert({
|
||||||
|
identifier,
|
||||||
|
event_id: eventId,
|
||||||
|
action_type: actionType,
|
||||||
|
action_count: 1,
|
||||||
|
window_start: new Date()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error recording rate limit action:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware factory for feedback rate limiting
|
||||||
|
*/
|
||||||
|
function feedbackRateLimit(actionType) {
|
||||||
|
return async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// Extract event ID from params or body
|
||||||
|
const eventId = req.params.eventId || req.body?.event_id;
|
||||||
|
if (!eventId) {
|
||||||
|
return res.status(400).json({ error: 'Event ID required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate guest identifier
|
||||||
|
const identifier = generateGuestIdentifier(req);
|
||||||
|
req.guestIdentifier = identifier;
|
||||||
|
|
||||||
|
// Check rate limit
|
||||||
|
const rateLimitStatus = await checkRateLimit(identifier, eventId, actionType);
|
||||||
|
|
||||||
|
// Set rate limit headers
|
||||||
|
res.set({
|
||||||
|
'X-RateLimit-Limit': rateLimitStatus.limit,
|
||||||
|
'X-RateLimit-Remaining': rateLimitStatus.remaining || 0,
|
||||||
|
'X-RateLimit-Reset': rateLimitStatus.resetAt ? rateLimitStatus.resetAt.toISOString() : new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rateLimitStatus.limited) {
|
||||||
|
logger.warn(`Rate limit exceeded for ${actionType}`, {
|
||||||
|
identifier: identifier.substring(0, 16) + '...',
|
||||||
|
eventId,
|
||||||
|
actionType
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(429).json({
|
||||||
|
error: 'Too many requests',
|
||||||
|
message: `Rate limit exceeded. Please try again later.`,
|
||||||
|
retryAfter: rateLimitStatus.window
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the action after successful processing
|
||||||
|
res.on('finish', async () => {
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
await recordAction(identifier, eventId, actionType);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in rate limit middleware:', error);
|
||||||
|
// Allow request to proceed on error
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IP-based rate limiting for more strict control
|
||||||
|
*/
|
||||||
|
function strictRateLimit(options = {}) {
|
||||||
|
const {
|
||||||
|
windowMs = 15 * 60 * 1000, // 15 minutes
|
||||||
|
max = 100, // limit each IP to 100 requests per windowMs
|
||||||
|
message = 'Too many requests from this IP, please try again later.',
|
||||||
|
skipSuccessfulRequests = false
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const store = new Map();
|
||||||
|
|
||||||
|
// Clean up old entries periodically
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, data] of store.entries()) {
|
||||||
|
if (data.resetTime < now) {
|
||||||
|
store.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, windowMs);
|
||||||
|
|
||||||
|
return (req, res, next) => {
|
||||||
|
const ip = req.ip || req.connection.remoteAddress;
|
||||||
|
const now = Date.now();
|
||||||
|
const resetTime = now + windowMs;
|
||||||
|
|
||||||
|
let data = store.get(ip);
|
||||||
|
if (!data || data.resetTime < now) {
|
||||||
|
data = {
|
||||||
|
count: 0,
|
||||||
|
resetTime
|
||||||
|
};
|
||||||
|
store.set(ip, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.count >= max) {
|
||||||
|
return res.status(429).json({
|
||||||
|
error: 'Too many requests',
|
||||||
|
message,
|
||||||
|
retryAfter: Math.ceil((data.resetTime - now) / 1000)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!skipSuccessfulRequests || res.statusCode >= 400) {
|
||||||
|
data.count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('X-RateLimit-Limit', max);
|
||||||
|
res.setHeader('X-RateLimit-Remaining', Math.max(0, max - data.count));
|
||||||
|
res.setHeader('X-RateLimit-Reset', new Date(data.resetTime).toISOString());
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
feedbackRateLimit,
|
||||||
|
strictRateLimit,
|
||||||
|
generateGuestIdentifier,
|
||||||
|
checkRateLimit,
|
||||||
|
recordAction
|
||||||
|
};
|
||||||
@@ -42,7 +42,7 @@ router.post('/', adminAuth, [
|
|||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// Validate password strength for gallery
|
// Validate password strength for gallery
|
||||||
const passwordValidation = validatePasswordInContext(password, 'gallery', {
|
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||||
eventName: event_name
|
eventName: event_name
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ router.post('/', adminAuth, [
|
|||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// Validate password strength
|
// Validate password strength
|
||||||
const passwordValidation = validatePasswordInContext(password, 'gallery', {
|
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||||
eventName: event_name
|
eventName: event_name
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,411 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||||
|
const feedbackService = require('../services/feedbackService');
|
||||||
|
const feedbackModeration = require('../services/feedbackModeration');
|
||||||
|
const { db, logActivity } = require('../database/db');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const {
|
||||||
|
validateEventId,
|
||||||
|
validateFeedbackSettings,
|
||||||
|
validateWordFilter,
|
||||||
|
checkValidation
|
||||||
|
} = require('../utils/feedbackValidation');
|
||||||
|
|
||||||
|
// Get event feedback settings
|
||||||
|
router.get('/events/:eventId/feedback-settings',
|
||||||
|
adminAuth,
|
||||||
|
validateEventId,
|
||||||
|
checkValidation,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { eventId } = req.params;
|
||||||
|
|
||||||
|
// Verify event exists and belongs to admin
|
||||||
|
const event = await db('events').where('id', eventId).first();
|
||||||
|
if (!event) {
|
||||||
|
return res.status(404).json({ error: 'Event not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await feedbackService.getEventFeedbackSettings(eventId);
|
||||||
|
res.json(settings);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting feedback settings:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get feedback settings' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update event feedback settings
|
||||||
|
router.put('/events/:eventId/feedback-settings',
|
||||||
|
adminAuth,
|
||||||
|
validateEventId,
|
||||||
|
validateFeedbackSettings,
|
||||||
|
checkValidation,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { eventId } = req.params;
|
||||||
|
const settings = req.body;
|
||||||
|
|
||||||
|
// Verify event exists
|
||||||
|
const event = await db('events').where('id', eventId).first();
|
||||||
|
if (!event) {
|
||||||
|
return res.status(404).json({ error: 'Event not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedSettings = await feedbackService.updateEventFeedbackSettings(eventId, settings);
|
||||||
|
|
||||||
|
await logActivity('feedback_settings_updated', {
|
||||||
|
event_id: eventId,
|
||||||
|
settings: updatedSettings
|
||||||
|
}, eventId, {
|
||||||
|
type: 'admin',
|
||||||
|
id: req.user.id,
|
||||||
|
name: req.user.username
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(updatedSettings);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating feedback settings:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update feedback settings' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get feedback for an event (with filters)
|
||||||
|
router.get('/events/:eventId/feedback',
|
||||||
|
adminAuth,
|
||||||
|
validateEventId,
|
||||||
|
checkValidation,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { eventId } = req.params;
|
||||||
|
const { type, status, photoId, page = 1, limit = 50 } = req.query;
|
||||||
|
|
||||||
|
// Build query
|
||||||
|
let query = db('photo_feedback')
|
||||||
|
.join('photos', 'photo_feedback.photo_id', 'photos.id')
|
||||||
|
.where('photo_feedback.event_id', eventId)
|
||||||
|
.select(
|
||||||
|
'photo_feedback.*',
|
||||||
|
'photos.filename',
|
||||||
|
'photos.path'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (type) {
|
||||||
|
query = query.where('photo_feedback.feedback_type', type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'pending') {
|
||||||
|
query = query.where('photo_feedback.is_approved', false)
|
||||||
|
.where('photo_feedback.is_hidden', false);
|
||||||
|
} else if (status === 'approved') {
|
||||||
|
query = query.where('photo_feedback.is_approved', true);
|
||||||
|
} else if (status === 'hidden') {
|
||||||
|
query = query.where('photo_feedback.is_hidden', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (photoId) {
|
||||||
|
query = query.where('photo_feedback.photo_id', photoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const totalCount = await query.clone().count('photo_feedback.id as count').first();
|
||||||
|
|
||||||
|
const feedback = await query
|
||||||
|
.orderBy('photo_feedback.created_at', 'desc')
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
feedback,
|
||||||
|
pagination: {
|
||||||
|
page: parseInt(page),
|
||||||
|
limit: parseInt(limit),
|
||||||
|
total: totalCount?.count || 0,
|
||||||
|
pages: Math.ceil((totalCount?.count || 0) / limit)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting feedback:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get feedback' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Moderate feedback (approve/hide/reject)
|
||||||
|
router.put('/feedback/:feedbackId/:action',
|
||||||
|
adminAuth,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { feedbackId, action } = req.params;
|
||||||
|
|
||||||
|
if (!['approve', 'hide', 'reject'].includes(action)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid action' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await feedbackService.moderateFeedback(feedbackId, action, req.user.id);
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error moderating feedback:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to moderate feedback' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete feedback
|
||||||
|
router.delete('/feedback/:feedbackId',
|
||||||
|
adminAuth,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { feedbackId } = req.params;
|
||||||
|
|
||||||
|
await feedbackService.deleteFeedback(feedbackId, req.user.id);
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting feedback:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to delete feedback' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get feedback analytics for an event
|
||||||
|
router.get('/events/:eventId/feedback-analytics',
|
||||||
|
adminAuth,
|
||||||
|
validateEventId,
|
||||||
|
checkValidation,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { eventId } = req.params;
|
||||||
|
|
||||||
|
// Get summary statistics
|
||||||
|
const summaryData = await feedbackService.getEventFeedbackSummary(eventId);
|
||||||
|
|
||||||
|
// Calculate average rating and other summary stats
|
||||||
|
const avgRatingResult = await db('photo_feedback')
|
||||||
|
.where('event_id', eventId)
|
||||||
|
.where('feedback_type', 'rating')
|
||||||
|
.avg('rating as average_rating')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const pendingModeration = await db('photo_feedback')
|
||||||
|
.where('event_id', eventId)
|
||||||
|
.where('feedback_type', 'comment')
|
||||||
|
.where('is_approved', false)
|
||||||
|
.where('is_hidden', false)
|
||||||
|
.count('* as count')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
average_rating: parseFloat(avgRatingResult?.average_rating || 0),
|
||||||
|
total_ratings: summaryData.stats?.total_ratings || 0,
|
||||||
|
total_likes: summaryData.stats?.total_likes || 0,
|
||||||
|
total_comments: summaryData.stats?.total_comments || 0,
|
||||||
|
total_favorites: summaryData.stats?.total_favorites || 0,
|
||||||
|
pending_moderation: pendingModeration?.count || 0,
|
||||||
|
total_feedback: (summaryData.stats?.total_ratings || 0) +
|
||||||
|
(summaryData.stats?.total_likes || 0) +
|
||||||
|
(summaryData.stats?.total_comments || 0) +
|
||||||
|
(summaryData.stats?.total_favorites || 0)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get top-rated photos
|
||||||
|
const topRated = await db('photos')
|
||||||
|
.where('event_id', eventId)
|
||||||
|
.where('average_rating', '>', 0)
|
||||||
|
.orderBy('average_rating', 'desc')
|
||||||
|
.orderBy('feedback_count', 'desc')
|
||||||
|
.limit(10)
|
||||||
|
.select('id', 'filename', 'average_rating', 'feedback_count', 'like_count');
|
||||||
|
|
||||||
|
// Get most liked photos
|
||||||
|
const mostLiked = await db('photos')
|
||||||
|
.where('event_id', eventId)
|
||||||
|
.where('like_count', '>', 0)
|
||||||
|
.orderBy('like_count', 'desc')
|
||||||
|
.limit(10)
|
||||||
|
.select('id', 'filename', 'like_count', 'average_rating');
|
||||||
|
|
||||||
|
// Get recent comments
|
||||||
|
const recentComments = await db('photo_feedback')
|
||||||
|
.join('photos', 'photo_feedback.photo_id', 'photos.id')
|
||||||
|
.where('photo_feedback.event_id', eventId)
|
||||||
|
.where('photo_feedback.feedback_type', 'comment')
|
||||||
|
.where('photo_feedback.is_approved', true)
|
||||||
|
.where('photo_feedback.is_hidden', false)
|
||||||
|
.orderBy('photo_feedback.created_at', 'desc')
|
||||||
|
.limit(10)
|
||||||
|
.select(
|
||||||
|
'photo_feedback.comment_text',
|
||||||
|
'photo_feedback.guest_name',
|
||||||
|
'photo_feedback.created_at',
|
||||||
|
'photos.filename'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get feedback timeline (last 7 days)
|
||||||
|
const timeline = await db('photo_feedback')
|
||||||
|
.where('event_id', eventId)
|
||||||
|
.where('created_at', '>', new Date(Date.now() - 7 * 24 * 60 * 60 * 1000))
|
||||||
|
.select(
|
||||||
|
db.raw('DATE(created_at) as date'),
|
||||||
|
db.raw('COUNT(*) as count'),
|
||||||
|
'feedback_type'
|
||||||
|
)
|
||||||
|
.groupBy('date', 'feedback_type')
|
||||||
|
.orderBy('date', 'asc');
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
summary,
|
||||||
|
topRated,
|
||||||
|
mostLiked,
|
||||||
|
recentComments,
|
||||||
|
timeline
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting feedback analytics:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get feedback analytics' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Export feedback data
|
||||||
|
router.get('/events/:eventId/feedback/export',
|
||||||
|
adminAuth,
|
||||||
|
validateEventId,
|
||||||
|
checkValidation,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { eventId } = req.params;
|
||||||
|
const { format = 'json' } = req.query;
|
||||||
|
|
||||||
|
const feedback = await feedbackService.exportEventFeedback(eventId);
|
||||||
|
|
||||||
|
if (format === 'csv') {
|
||||||
|
// Convert to CSV
|
||||||
|
const csv = convertToCSV(feedback);
|
||||||
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="feedback-${eventId}.csv"`);
|
||||||
|
res.send(csv);
|
||||||
|
} else {
|
||||||
|
res.json(feedback);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error exporting feedback:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to export feedback' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get pending moderation items (across all events)
|
||||||
|
router.get('/feedback/pending-moderation',
|
||||||
|
adminAuth,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pending = await feedbackService.getPendingModeration();
|
||||||
|
res.json(pending);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting pending moderation:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get pending moderation' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Word filter management
|
||||||
|
router.get('/feedback/word-filters',
|
||||||
|
adminAuth,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const filters = await feedbackModeration.getAllWordFilters();
|
||||||
|
res.json(filters);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting word filters:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get word filters' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/feedback/word-filters',
|
||||||
|
adminAuth,
|
||||||
|
validateWordFilter,
|
||||||
|
checkValidation,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { word, severity = 'moderate' } = req.body;
|
||||||
|
|
||||||
|
await feedbackModeration.addWordFilter(word, severity);
|
||||||
|
|
||||||
|
await logActivity('word_filter_added', { word, severity }, null, {
|
||||||
|
type: 'admin',
|
||||||
|
id: req.user.id,
|
||||||
|
name: req.user.username
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message === 'Word filter already exists') {
|
||||||
|
return res.status(409).json({ error: error.message });
|
||||||
|
}
|
||||||
|
logger.error('Error adding word filter:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to add word filter' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/feedback/word-filters/:id',
|
||||||
|
adminAuth,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const updates = req.body;
|
||||||
|
|
||||||
|
await feedbackModeration.updateWordFilter(id, updates);
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating word filter:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update word filter' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/feedback/word-filters/:id',
|
||||||
|
adminAuth,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
await feedbackModeration.deleteWordFilter(id);
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting word filter:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to delete word filter' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper function to convert JSON to CSV
|
||||||
|
function convertToCSV(data) {
|
||||||
|
if (!data || data.length === 0) return '';
|
||||||
|
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
const csvHeaders = headers.join(',');
|
||||||
|
|
||||||
|
const csvRows = data.map(row => {
|
||||||
|
return headers.map(header => {
|
||||||
|
const value = row[header];
|
||||||
|
// Escape quotes and wrap in quotes if contains comma
|
||||||
|
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
|
||||||
|
return `"${value.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return value || '';
|
||||||
|
}).join(',');
|
||||||
|
});
|
||||||
|
|
||||||
|
return [csvHeaders, ...csvRows].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -447,9 +447,14 @@ router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
|||||||
if (photo.thumbnail_path) {
|
if (photo.thumbnail_path) {
|
||||||
const thumbPath = path.join(storagePath, 'events/active', photo.thumbnail_path);
|
const thumbPath = path.join(storagePath, 'events/active', photo.thumbnail_path);
|
||||||
try {
|
try {
|
||||||
|
// Check if file exists before attempting to delete
|
||||||
|
await fs.access(thumbPath);
|
||||||
await fs.unlink(thumbPath);
|
await fs.unlink(thumbPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting thumbnail:', error);
|
// Only log if it's not a "file not found" error
|
||||||
|
if (error.code !== 'ENOENT') {
|
||||||
|
console.error('Error deleting thumbnail:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,9 +539,14 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, async (req, res) => {
|
|||||||
if (photo.thumbnail_path) {
|
if (photo.thumbnail_path) {
|
||||||
const thumbPath = path.join(storagePath, photo.thumbnail_path);
|
const thumbPath = path.join(storagePath, photo.thumbnail_path);
|
||||||
try {
|
try {
|
||||||
|
// Check if file exists before attempting to delete
|
||||||
|
await fs.access(thumbPath);
|
||||||
await fs.unlink(thumbPath);
|
await fs.unlink(thumbPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting thumbnail:', error);
|
// Only log if it's not a "file not found" error
|
||||||
|
if (error.code !== 'ENOENT') {
|
||||||
|
console.error('Error deleting thumbnail:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { photoAuth } = require('../middleware/photoAuth');
|
||||||
|
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||||
|
const { feedbackRateLimit, generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
|
||||||
|
const feedbackService = require('../services/feedbackService');
|
||||||
|
const feedbackModeration = require('../services/feedbackModeration');
|
||||||
|
const { db, logActivity } = require('../database/db');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const {
|
||||||
|
validatePhotoId,
|
||||||
|
validateFeedbackSubmission,
|
||||||
|
checkValidation,
|
||||||
|
validateGuestRequirements
|
||||||
|
} = require('../utils/feedbackValidation');
|
||||||
|
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||||
|
|
||||||
|
// Get feedback settings for a gallery
|
||||||
|
router.get('/:slug/feedback-settings',
|
||||||
|
verifyGalleryAccess,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const event = req.event;
|
||||||
|
const settings = await feedbackService.getEventFeedbackSettings(event.id);
|
||||||
|
|
||||||
|
// Only send relevant settings to guests
|
||||||
|
const guestSettings = {
|
||||||
|
feedback_enabled: settings.feedback_enabled,
|
||||||
|
allow_ratings: settings.allow_ratings,
|
||||||
|
allow_likes: settings.allow_likes,
|
||||||
|
allow_comments: settings.allow_comments,
|
||||||
|
allow_favorites: settings.allow_favorites,
|
||||||
|
require_name_email: settings.require_name_email,
|
||||||
|
show_feedback_to_guests: settings.show_feedback_to_guests
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(guestSettings);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting feedback settings:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get feedback settings' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get feedback for a specific photo
|
||||||
|
router.get('/:slug/photos/:photoId/feedback',
|
||||||
|
verifyGalleryAccess,
|
||||||
|
validatePhotoId,
|
||||||
|
checkValidation,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { photoId } = req.params;
|
||||||
|
const event = req.event;
|
||||||
|
const guestIdentifier = generateGuestIdentifier(req);
|
||||||
|
|
||||||
|
// Get feedback settings
|
||||||
|
const settings = await feedbackService.getEventFeedbackSettings(event.id);
|
||||||
|
|
||||||
|
if (!settings.feedback_enabled) {
|
||||||
|
return res.status(403).json({ error: 'Feedback is not enabled for this event' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify photo belongs to event
|
||||||
|
const photo = await db('photos')
|
||||||
|
.where({ id: photoId, event_id: event.id })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!photo) {
|
||||||
|
return res.status(404).json({ error: 'Photo not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get feedback based on settings
|
||||||
|
const options = {
|
||||||
|
approved_only: true,
|
||||||
|
include_hidden: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Include guest's own feedback even if not approved
|
||||||
|
const feedback = await feedbackService.getPhotoFeedback(photoId, options);
|
||||||
|
|
||||||
|
// Get guest's own feedback separately
|
||||||
|
const guestFeedback = await feedbackService.getPhotoFeedback(photoId, {
|
||||||
|
guest_identifier: guestIdentifier
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine and deduplicate
|
||||||
|
const allFeedback = [...feedback];
|
||||||
|
guestFeedback.forEach(gf => {
|
||||||
|
if (!feedback.find(f => f.id === gf.id)) {
|
||||||
|
allFeedback.push({ ...gf, is_mine: true });
|
||||||
|
} else {
|
||||||
|
const index = allFeedback.findIndex(f => f.id === gf.id);
|
||||||
|
allFeedback[index].is_mine = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter based on what guests should see
|
||||||
|
const visibleFeedback = settings.show_feedback_to_guests ? allFeedback :
|
||||||
|
allFeedback.filter(f => f.is_mine);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
feedback: visibleFeedback,
|
||||||
|
summary: {
|
||||||
|
average_rating: photo.average_rating || 0,
|
||||||
|
total_ratings: await db('photo_feedback')
|
||||||
|
.where({ photo_id: photoId, feedback_type: 'rating', is_hidden: false })
|
||||||
|
.count('id as count')
|
||||||
|
.first()
|
||||||
|
.then(r => r.count),
|
||||||
|
like_count: photo.like_count || 0,
|
||||||
|
favorite_count: photo.favorite_count || 0,
|
||||||
|
comment_count: await db('photo_feedback')
|
||||||
|
.where({
|
||||||
|
photo_id: photoId,
|
||||||
|
feedback_type: 'comment',
|
||||||
|
is_approved: true,
|
||||||
|
is_hidden: false
|
||||||
|
})
|
||||||
|
.count('id as count')
|
||||||
|
.first()
|
||||||
|
.then(r => r.count)
|
||||||
|
},
|
||||||
|
my_feedback: {
|
||||||
|
rating: guestFeedback.find(f => f.feedback_type === 'rating')?.rating,
|
||||||
|
liked: !!guestFeedback.find(f => f.feedback_type === 'like'),
|
||||||
|
favorited: !!guestFeedback.find(f => f.feedback_type === 'favorite')
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting photo feedback:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get feedback' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Submit feedback for a photo
|
||||||
|
router.post('/:slug/photos/:photoId/feedback',
|
||||||
|
verifyGalleryAccess,
|
||||||
|
validatePhotoId,
|
||||||
|
validateFeedbackSubmission,
|
||||||
|
checkValidation,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { photoId } = req.params;
|
||||||
|
const event = req.event;
|
||||||
|
const guestIdentifier = generateGuestIdentifier(req);
|
||||||
|
|
||||||
|
// Get feedback settings
|
||||||
|
const settings = await feedbackService.getEventFeedbackSettings(event.id);
|
||||||
|
|
||||||
|
if (!settings.feedback_enabled) {
|
||||||
|
return res.status(403).json({ error: 'Feedback is not enabled for this event' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if specific feedback type is allowed
|
||||||
|
const feedbackType = req.body.feedback_type;
|
||||||
|
const typeAllowed = {
|
||||||
|
rating: settings.allow_ratings,
|
||||||
|
like: settings.allow_likes,
|
||||||
|
comment: settings.allow_comments,
|
||||||
|
favorite: settings.allow_favorites
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!typeAllowed[feedbackType]) {
|
||||||
|
return res.status(403).json({ error: `${feedbackType} feedback is not enabled` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify photo belongs to event
|
||||||
|
const photo = await db('photos')
|
||||||
|
.where({ id: photoId, event_id: event.id })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!photo) {
|
||||||
|
return res.status(404).json({ error: 'Photo not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate guest requirements
|
||||||
|
const guestValidation = await validateGuestRequirements(settings, req.body);
|
||||||
|
if (!guestValidation.valid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Guest information required',
|
||||||
|
errors: guestValidation.errors
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply rate limiting based on feedback type
|
||||||
|
const rateLimitMiddleware = feedbackRateLimit(feedbackType);
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
rateLimitMiddleware(req, res, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we got here and response was sent (rate limited), return
|
||||||
|
if (res.headersSent) return;
|
||||||
|
|
||||||
|
// Prepare feedback data
|
||||||
|
const feedbackData = {
|
||||||
|
feedback_type: feedbackType,
|
||||||
|
rating: req.body.rating,
|
||||||
|
comment_text: req.body.comment_text,
|
||||||
|
guest_name: req.body.guest_name,
|
||||||
|
guest_email: req.body.guest_email,
|
||||||
|
ip_address: req.ip || req.connection.remoteAddress,
|
||||||
|
user_agent: req.headers['user-agent'],
|
||||||
|
moderate_comments: settings.moderate_comments
|
||||||
|
};
|
||||||
|
|
||||||
|
// For comments, check moderation
|
||||||
|
if (feedbackType === 'comment') {
|
||||||
|
// Check user reputation
|
||||||
|
const reputation = await feedbackModeration.checkUserReputation(guestIdentifier, event.id);
|
||||||
|
|
||||||
|
// Moderate the comment
|
||||||
|
const moderationResult = await feedbackModeration.moderateText(req.body.comment_text);
|
||||||
|
|
||||||
|
if (!moderationResult.approved) {
|
||||||
|
// Still save but mark as not approved
|
||||||
|
feedbackData.is_approved = false;
|
||||||
|
logger.warn('Comment flagged for moderation:', {
|
||||||
|
reason: moderationResult.reason,
|
||||||
|
violations: moderationResult.violations
|
||||||
|
});
|
||||||
|
} else if (reputation.autoApprove) {
|
||||||
|
// Trusted user, auto-approve
|
||||||
|
feedbackData.is_approved = true;
|
||||||
|
} else if (settings.moderate_comments) {
|
||||||
|
// Default moderation setting
|
||||||
|
feedbackData.is_approved = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit feedback
|
||||||
|
const result = await feedbackService.submitFeedback(
|
||||||
|
photoId,
|
||||||
|
event.id,
|
||||||
|
feedbackData,
|
||||||
|
guestIdentifier
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
await logActivity(`guest_feedback_${feedbackType}`, {
|
||||||
|
photo_id: photoId,
|
||||||
|
result
|
||||||
|
}, event.id, {
|
||||||
|
type: 'guest',
|
||||||
|
id: guestIdentifier.substring(0, 16),
|
||||||
|
name: req.body.guest_name || 'Anonymous'
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
...result,
|
||||||
|
message: feedbackType === 'comment' && !feedbackData.is_approved ?
|
||||||
|
'Your comment has been submitted for moderation' : undefined
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error submitting feedback:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to submit feedback' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get feedback summary for entire gallery
|
||||||
|
router.get('/:slug/feedback-summary',
|
||||||
|
verifyGalleryAccess,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const event = req.event;
|
||||||
|
|
||||||
|
// Get feedback settings
|
||||||
|
const settings = await feedbackService.getEventFeedbackSettings(event.id);
|
||||||
|
|
||||||
|
if (!settings.feedback_enabled || !settings.show_feedback_to_guests) {
|
||||||
|
return res.json({
|
||||||
|
enabled: false,
|
||||||
|
summary: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = await feedbackService.getEventFeedbackSummary(event.id);
|
||||||
|
|
||||||
|
// Filter data based on what guests should see
|
||||||
|
const guestSummary = {
|
||||||
|
stats: summary.stats,
|
||||||
|
top_rated: summary.photos
|
||||||
|
.filter(p => p.average_rating > 0)
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
filename: p.filename,
|
||||||
|
average_rating: p.average_rating,
|
||||||
|
like_count: p.like_count
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
enabled: true,
|
||||||
|
settings: {
|
||||||
|
allow_ratings: settings.allow_ratings,
|
||||||
|
allow_likes: settings.allow_likes,
|
||||||
|
allow_comments: settings.allow_comments,
|
||||||
|
allow_favorites: settings.allow_favorites
|
||||||
|
},
|
||||||
|
summary: guestSummary
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting feedback summary:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get feedback summary' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get user's own feedback for all photos
|
||||||
|
router.get('/:slug/my-feedback',
|
||||||
|
verifyGalleryAccess,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const event = req.event;
|
||||||
|
const guestIdentifier = generateGuestIdentifier(req);
|
||||||
|
|
||||||
|
const myFeedback = await db('photo_feedback')
|
||||||
|
.join('photos', 'photo_feedback.photo_id', 'photos.id')
|
||||||
|
.where('photo_feedback.event_id', event.id)
|
||||||
|
.where('photo_feedback.guest_identifier', guestIdentifier)
|
||||||
|
.select(
|
||||||
|
'photo_feedback.*',
|
||||||
|
'photos.filename',
|
||||||
|
'photos.path'
|
||||||
|
)
|
||||||
|
.orderBy('photo_feedback.created_at', 'desc');
|
||||||
|
|
||||||
|
res.json(myFeedback);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting user feedback:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get your feedback' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -45,6 +45,7 @@ router.get('/', async (req, res) => {
|
|||||||
theme_config: settingsObject.theme_config || null,
|
theme_config: settingsObject.theme_config || null,
|
||||||
default_language: settingsObject.general_default_language || 'en',
|
default_language: settingsObject.general_default_language || 'en',
|
||||||
enable_analytics: settingsObject.general_enable_analytics !== false,
|
enable_analytics: settingsObject.general_enable_analytics !== false,
|
||||||
|
general_date_format: settingsObject.general_date_format || 'PPP',
|
||||||
enable_recaptcha: settingsObject.security_enable_recaptcha === true || settingsObject.security_enable_recaptcha === 'true',
|
enable_recaptcha: settingsObject.security_enable_recaptcha === true || settingsObject.security_enable_recaptcha === 'true',
|
||||||
recaptcha_site_key: settingsObject.security_recaptcha_site_key || null,
|
recaptcha_site_key: settingsObject.security_recaptcha_site_key || null,
|
||||||
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true',
|
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const path = require('path');
|
|||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { queueEmail } = require('./emailProcessor');
|
const { queueEmail } = require('./emailProcessor');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
const feedbackService = require('./feedbackService');
|
||||||
|
|
||||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
const ACTIVE_PATH = () => path.join(getStoragePath(), 'events/active');
|
const ACTIVE_PATH = () => path.join(getStoragePath(), 'events/active');
|
||||||
@@ -28,6 +29,37 @@ async function archiveEvent(event) {
|
|||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Export feedback data before archiving
|
||||||
|
const feedbackSettings = await feedbackService.getEventFeedbackSettings(event.id);
|
||||||
|
if (feedbackSettings.feedback_enabled) {
|
||||||
|
try {
|
||||||
|
logger.info(`Exporting feedback data for event ${event.slug}`);
|
||||||
|
const feedbackData = await feedbackService.exportEventFeedback(event.id);
|
||||||
|
|
||||||
|
if (feedbackData && feedbackData.length > 0) {
|
||||||
|
// Create feedback JSON file
|
||||||
|
const feedbackJson = JSON.stringify(feedbackData, null, 2);
|
||||||
|
const feedbackJsonPath = path.join(eventPath, 'feedback_data.json');
|
||||||
|
await fs.writeFile(feedbackJsonPath, feedbackJson, 'utf8');
|
||||||
|
|
||||||
|
// Create feedback CSV file
|
||||||
|
const feedbackCsv = convertToCSV(feedbackData);
|
||||||
|
const feedbackCsvPath = path.join(eventPath, 'feedback_data.csv');
|
||||||
|
await fs.writeFile(feedbackCsvPath, feedbackCsv, 'utf8');
|
||||||
|
|
||||||
|
// Create feedback summary
|
||||||
|
const summary = await feedbackService.getEventFeedbackSummary(event.id);
|
||||||
|
const summaryPath = path.join(eventPath, 'feedback_summary.json');
|
||||||
|
await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2), 'utf8');
|
||||||
|
|
||||||
|
logger.info(`Feedback data exported: ${feedbackData.length} entries`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error exporting feedback for event ${event.slug}:`, error);
|
||||||
|
// Continue with archiving even if feedback export fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
output.on('close', async () => {
|
output.on('close', async () => {
|
||||||
logger.info(`Archive created: ${archiveName} (${archive.pointer()} bytes)`);
|
logger.info(`Archive created: ${archiveName} (${archive.pointer()} bytes)`);
|
||||||
|
|
||||||
@@ -67,4 +99,25 @@ async function archiveEvent(event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to convert JSON to CSV
|
||||||
|
function convertToCSV(data) {
|
||||||
|
if (!data || data.length === 0) return '';
|
||||||
|
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
const csvHeaders = headers.join(',');
|
||||||
|
|
||||||
|
const csvRows = data.map(row => {
|
||||||
|
return headers.map(header => {
|
||||||
|
const value = row[header];
|
||||||
|
// Escape quotes and wrap in quotes if contains comma
|
||||||
|
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
|
||||||
|
return `"${value.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return value || '';
|
||||||
|
}).join(',');
|
||||||
|
});
|
||||||
|
|
||||||
|
return [csvHeaders, ...csvRows].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = { archiveEvent };
|
module.exports = { archiveEvent };
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const logger = require('../utils/logger');
|
|||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const backupManifest = require('./backupManifest');
|
const backupManifest = require('./backupManifest');
|
||||||
const S3StorageAdapter = require('./storage/s3Storage');
|
const S3StorageAdapter = require('./storage/s3Storage');
|
||||||
|
const packageJson = require('../../package.json');
|
||||||
|
|
||||||
// Backup job reference
|
// Backup job reference
|
||||||
let backupJob = null;
|
let backupJob = null;
|
||||||
@@ -20,6 +21,21 @@ let isRunning = false;
|
|||||||
// Storage paths
|
// Storage paths
|
||||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current database schema version
|
||||||
|
*/
|
||||||
|
async function getCurrentSchemaVersion() {
|
||||||
|
try {
|
||||||
|
const result = await db('knex_migrations')
|
||||||
|
.orderBy('id', 'desc')
|
||||||
|
.first();
|
||||||
|
return result ? result.name : 'unknown';
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get schema version:', error);
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate file checksum using SHA256
|
* Calculate file checksum using SHA256
|
||||||
*/
|
*/
|
||||||
@@ -646,11 +662,17 @@ async function runBackup() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create backup run record
|
// Get current schema version
|
||||||
|
const schemaVersion = await getCurrentSchemaVersion();
|
||||||
|
|
||||||
|
// Create backup run record with version info
|
||||||
const [runId] = await db('backup_runs').insert({
|
const [runId] = await db('backup_runs').insert({
|
||||||
started_at: startTime,
|
started_at: startTime,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
backup_type: 'scheduled'
|
backup_type: 'scheduled',
|
||||||
|
app_version: packageJson.version,
|
||||||
|
node_version: process.version,
|
||||||
|
db_schema_version: schemaVersion
|
||||||
});
|
});
|
||||||
|
|
||||||
backupRun = { id: runId };
|
backupRun = { id: runId };
|
||||||
@@ -801,7 +823,7 @@ async function runBackup() {
|
|||||||
// Don't fail the entire backup for manifest generation failure
|
// Don't fail the entire backup for manifest generation failure
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update backup run record
|
// Update backup run record with manifest info
|
||||||
await db('backup_runs')
|
await db('backup_runs')
|
||||||
.where('id', runId)
|
.where('id', runId)
|
||||||
.update({
|
.update({
|
||||||
@@ -812,6 +834,16 @@ async function runBackup() {
|
|||||||
duration_seconds: durationSeconds,
|
duration_seconds: durationSeconds,
|
||||||
manifest_path: manifestPath,
|
manifest_path: manifestPath,
|
||||||
manifest_id: manifestPath ? path.basename(manifestPath, path.extname(manifestPath)) : null,
|
manifest_id: manifestPath ? path.basename(manifestPath, path.extname(manifestPath)) : null,
|
||||||
|
manifest_info: manifestSummary ? JSON.stringify({
|
||||||
|
manifest_version: manifestSummary.manifest?.version,
|
||||||
|
backup_id: manifestSummary.backup?.id,
|
||||||
|
system_info: manifestSummary.system,
|
||||||
|
file_count: manifestSummary.files?.count,
|
||||||
|
database_info: {
|
||||||
|
type: manifestSummary.database?.type,
|
||||||
|
schema_version: manifestSummary.database?.schema_version
|
||||||
|
}
|
||||||
|
}) : null,
|
||||||
statistics: JSON.stringify({
|
statistics: JSON.stringify({
|
||||||
totalFilesChecked: files.length,
|
totalFilesChecked: files.length,
|
||||||
filesBackedUp: result.backedUpCount,
|
filesBackedUp: result.backedUpCount,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const knexConfig = require('../../knexfile');
|
|||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { queueEmail } = require('./emailProcessor');
|
const { queueEmail } = require('./emailProcessor');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
const packageJson = require('../../package.json');
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const CHUNK_SIZE = 1024 * 1024; // 1MB chunks for streaming
|
const CHUNK_SIZE = 1024 * 1024; // 1MB chunks for streaming
|
||||||
@@ -312,12 +313,24 @@ class DatabaseBackupService {
|
|||||||
const sqlFile = path.join(destinationPath, `${baseName}.sql`);
|
const sqlFile = path.join(destinationPath, `${baseName}.sql`);
|
||||||
const finalFile = compress ? path.join(destinationPath, `${baseName}.sql.gz`) : sqlFile;
|
const finalFile = compress ? path.join(destinationPath, `${baseName}.sql.gz`) : sqlFile;
|
||||||
|
|
||||||
// Create backup run record
|
// Get current schema version
|
||||||
|
const schemaVersion = await this.getCurrentSchemaVersion();
|
||||||
|
|
||||||
|
// Create backup run record with version info
|
||||||
const [runId] = await db('database_backup_runs').insert({
|
const [runId] = await db('database_backup_runs').insert({
|
||||||
started_at: startTime,
|
started_at: startTime,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
backup_type: this.dbType,
|
backup_type: this.dbType,
|
||||||
destination_path: finalFile
|
destination_path: finalFile,
|
||||||
|
app_version: packageJson.version,
|
||||||
|
node_version: process.version,
|
||||||
|
db_schema_version: schemaVersion,
|
||||||
|
environment_info: JSON.stringify({
|
||||||
|
platform: process.platform,
|
||||||
|
arch: process.arch,
|
||||||
|
node_env: process.env.NODE_ENV || 'production',
|
||||||
|
db_type: this.dbType
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
backupRun = { id: runId };
|
backupRun = { id: runId };
|
||||||
@@ -383,7 +396,10 @@ class DatabaseBackupService {
|
|||||||
compressed: compress,
|
compressed: compress,
|
||||||
validated: validateIntegrity,
|
validated: validateIntegrity,
|
||||||
compressionStats,
|
compressionStats,
|
||||||
tableCount: tableChecksums ? Object.keys(tableChecksums).length : null
|
tableCount: tableChecksums ? Object.keys(tableChecksums).length : null,
|
||||||
|
app_version: packageJson.version,
|
||||||
|
node_version: process.version,
|
||||||
|
db_schema_version: await this.getCurrentSchemaVersion()
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -560,11 +576,82 @@ class DatabaseBackupService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore from backup (careful!)
|
* Get current database schema version
|
||||||
|
*/
|
||||||
|
async getCurrentSchemaVersion() {
|
||||||
|
try {
|
||||||
|
const result = await db('knex_migrations')
|
||||||
|
.orderBy('id', 'desc')
|
||||||
|
.first();
|
||||||
|
return result ? result.name : 'unknown';
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get schema version:', error);
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check version compatibility for restore
|
||||||
|
*/
|
||||||
|
async checkVersionCompatibility(backupInfo) {
|
||||||
|
const currentAppVersion = packageJson.version;
|
||||||
|
const currentNodeVersion = process.version;
|
||||||
|
const currentSchemaVersion = await this.getCurrentSchemaVersion();
|
||||||
|
|
||||||
|
const compatibility = {
|
||||||
|
compatible: true,
|
||||||
|
warnings: [],
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check app version
|
||||||
|
if (backupInfo.app_version !== currentAppVersion) {
|
||||||
|
const backupMajor = backupInfo.app_version?.split('.')[0];
|
||||||
|
const currentMajor = currentAppVersion.split('.')[0];
|
||||||
|
|
||||||
|
if (backupMajor !== currentMajor) {
|
||||||
|
compatibility.errors.push(
|
||||||
|
`Major version mismatch: backup v${backupInfo.app_version}, current v${currentAppVersion}`
|
||||||
|
);
|
||||||
|
compatibility.compatible = false;
|
||||||
|
} else {
|
||||||
|
compatibility.warnings.push(
|
||||||
|
`Minor version difference: backup v${backupInfo.app_version}, current v${currentAppVersion}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Node.js version
|
||||||
|
if (backupInfo.node_version !== currentNodeVersion) {
|
||||||
|
const backupNodeMajor = backupInfo.node_version?.split('.')[0];
|
||||||
|
const currentNodeMajor = currentNodeVersion.split('.')[0];
|
||||||
|
|
||||||
|
if (backupNodeMajor !== currentNodeMajor) {
|
||||||
|
compatibility.warnings.push(
|
||||||
|
`Node.js major version difference: backup ${backupInfo.node_version}, current ${currentNodeVersion}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check schema version
|
||||||
|
if (backupInfo.db_schema_version && backupInfo.db_schema_version !== currentSchemaVersion) {
|
||||||
|
compatibility.warnings.push(
|
||||||
|
`Database schema difference: backup migration '${backupInfo.db_schema_version}', current '${currentSchemaVersion}'`
|
||||||
|
);
|
||||||
|
compatibility.warnings.push(
|
||||||
|
'You may need to run migrations after restore'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return compatibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore from backup (with version checking)
|
||||||
*/
|
*/
|
||||||
async restore(backupPath, options = {}) {
|
async restore(backupPath, options = {}) {
|
||||||
// This is a dangerous operation and should be used with extreme caution
|
// This is a dangerous operation and should be used with extreme caution
|
||||||
throw new Error('Restore functionality not implemented for safety. Please restore manually.');
|
throw new Error('Restore functionality not implemented for safety. Please use restore service or restore manually.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
const { db } = require('../database/db');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
class FeedbackModerationService {
|
||||||
|
constructor() {
|
||||||
|
this.wordFiltersCache = null;
|
||||||
|
this.cacheExpiry = null;
|
||||||
|
this.CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get word filters (with caching)
|
||||||
|
*/
|
||||||
|
async getWordFilters() {
|
||||||
|
try {
|
||||||
|
// Check cache
|
||||||
|
if (this.wordFiltersCache && this.cacheExpiry && Date.now() < this.cacheExpiry) {
|
||||||
|
return this.wordFiltersCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from database
|
||||||
|
const filters = await db('feedback_word_filters')
|
||||||
|
.where('is_active', true)
|
||||||
|
.select('word', 'severity');
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
this.wordFiltersCache = filters;
|
||||||
|
this.cacheExpiry = Date.now() + this.CACHE_DURATION;
|
||||||
|
|
||||||
|
return filters;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting word filters:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear word filters cache
|
||||||
|
*/
|
||||||
|
clearCache() {
|
||||||
|
this.wordFiltersCache = null;
|
||||||
|
this.cacheExpiry = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if text contains inappropriate content
|
||||||
|
*/
|
||||||
|
async moderateText(text) {
|
||||||
|
try {
|
||||||
|
if (!text || typeof text !== 'string') {
|
||||||
|
return { approved: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = await this.getWordFilters();
|
||||||
|
const violations = [];
|
||||||
|
const lowerText = text.toLowerCase();
|
||||||
|
|
||||||
|
for (const filter of filters) {
|
||||||
|
// Create regex for whole word matching
|
||||||
|
const regex = new RegExp(`\\b${this.escapeRegex(filter.word.toLowerCase())}\\b`, 'gi');
|
||||||
|
if (regex.test(lowerText)) {
|
||||||
|
violations.push({
|
||||||
|
word: filter.word,
|
||||||
|
severity: filter.severity
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for severe violations
|
||||||
|
if (violations.some(v => v.severity === 'severe')) {
|
||||||
|
return {
|
||||||
|
approved: false,
|
||||||
|
reason: 'Content contains prohibited words',
|
||||||
|
violations: violations.filter(v => v.severity === 'severe')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for moderate violations
|
||||||
|
if (violations.some(v => v.severity === 'moderate')) {
|
||||||
|
return {
|
||||||
|
approved: false,
|
||||||
|
reason: 'Content requires moderation',
|
||||||
|
violations
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for mild violations (may just flag for review)
|
||||||
|
if (violations.length > 0) {
|
||||||
|
return {
|
||||||
|
approved: true,
|
||||||
|
flagged: true,
|
||||||
|
reason: 'Content contains potentially inappropriate words',
|
||||||
|
violations
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional checks
|
||||||
|
const additionalChecks = this.performAdditionalChecks(text);
|
||||||
|
if (!additionalChecks.passed) {
|
||||||
|
return {
|
||||||
|
approved: false,
|
||||||
|
reason: additionalChecks.reason
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { approved: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error moderating text:', error);
|
||||||
|
// In case of error, err on the side of caution
|
||||||
|
return {
|
||||||
|
approved: false,
|
||||||
|
reason: 'Moderation system error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform additional content checks
|
||||||
|
*/
|
||||||
|
performAdditionalChecks(text) {
|
||||||
|
// Check for excessive caps
|
||||||
|
const capsRatio = (text.match(/[A-Z]/g) || []).length / text.length;
|
||||||
|
if (text.length > 10 && capsRatio > 0.7) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
reason: 'Excessive use of capital letters'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for spam patterns
|
||||||
|
if (this.detectSpamPatterns(text)) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
reason: 'Content appears to be spam'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for excessive special characters
|
||||||
|
const specialCharRatio = (text.match(/[!@#$%^&*()]/g) || []).length / text.length;
|
||||||
|
if (text.length > 10 && specialCharRatio > 0.3) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
reason: 'Excessive use of special characters'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { passed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect common spam patterns
|
||||||
|
*/
|
||||||
|
detectSpamPatterns(text) {
|
||||||
|
const spamPatterns = [
|
||||||
|
/\b(buy|cheap|discount|offer|sale|deal)\s+(now|today|here)/gi,
|
||||||
|
/\b(click|visit|check)\s+(here|link|this)/gi,
|
||||||
|
/\b(viagra|cialis|pills|drugs)\b/gi,
|
||||||
|
/\b(casino|betting|poker|slots)\b/gi,
|
||||||
|
/\b(make|earn)\s+\$?\d+/gi,
|
||||||
|
/https?:\/\/[^\s]+/gi, // URLs (might want to allow in some cases)
|
||||||
|
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, // Email addresses
|
||||||
|
/\b\d{3,}\s?\d{3,}\s?\d{4,}\b/g // Phone numbers
|
||||||
|
];
|
||||||
|
|
||||||
|
return spamPatterns.some(pattern => pattern.test(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape special regex characters
|
||||||
|
*/
|
||||||
|
escapeRegex(string) {
|
||||||
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add word filter
|
||||||
|
*/
|
||||||
|
async addWordFilter(word, severity = 'moderate') {
|
||||||
|
try {
|
||||||
|
await db('feedback_word_filters').insert({
|
||||||
|
word: word.toLowerCase(),
|
||||||
|
severity,
|
||||||
|
is_active: true,
|
||||||
|
created_at: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
this.clearCache();
|
||||||
|
logger.info(`Added word filter: ${word} (${severity})`);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'SQLITE_CONSTRAINT' || error.code === '23505') {
|
||||||
|
throw new Error('Word filter already exists');
|
||||||
|
}
|
||||||
|
logger.error('Error adding word filter:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update word filter
|
||||||
|
*/
|
||||||
|
async updateWordFilter(id, updates) {
|
||||||
|
try {
|
||||||
|
await db('feedback_word_filters')
|
||||||
|
.where('id', id)
|
||||||
|
.update(updates);
|
||||||
|
|
||||||
|
this.clearCache();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating word filter:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete word filter
|
||||||
|
*/
|
||||||
|
async deleteWordFilter(id) {
|
||||||
|
try {
|
||||||
|
await db('feedback_word_filters')
|
||||||
|
.where('id', id)
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
this.clearCache();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting word filter:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all word filters (for admin)
|
||||||
|
*/
|
||||||
|
async getAllWordFilters() {
|
||||||
|
try {
|
||||||
|
return await db('feedback_word_filters')
|
||||||
|
.orderBy('severity', 'desc')
|
||||||
|
.orderBy('word', 'asc');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting all word filters:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize text for display (remove but don't reject)
|
||||||
|
*/
|
||||||
|
sanitizeText(text) {
|
||||||
|
// Remove excessive whitespace
|
||||||
|
text = text.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
// Remove zero-width characters
|
||||||
|
text = text.replace(/[\u200B-\u200D\uFEFF]/g, '');
|
||||||
|
|
||||||
|
// Limit consecutive special characters
|
||||||
|
text = text.replace(/([!?.]){3,}/g, '$1$1');
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user should be rate limited based on previous violations
|
||||||
|
*/
|
||||||
|
async checkUserReputation(guestIdentifier, eventId) {
|
||||||
|
try {
|
||||||
|
// Count recent violations
|
||||||
|
const recentViolations = await db('photo_feedback')
|
||||||
|
.where('guest_identifier', guestIdentifier)
|
||||||
|
.where('event_id', eventId)
|
||||||
|
.where('is_hidden', true)
|
||||||
|
.where('created_at', '>', new Date(Date.now() - 24 * 60 * 60 * 1000)) // Last 24 hours
|
||||||
|
.count('id as count')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
// If user has multiple violations, they might be problematic
|
||||||
|
if (recentViolations && recentViolations.count > 3) {
|
||||||
|
return {
|
||||||
|
trusted: false,
|
||||||
|
reason: 'Multiple recent violations'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check total approved comments
|
||||||
|
const approvedComments = await db('photo_feedback')
|
||||||
|
.where('guest_identifier', guestIdentifier)
|
||||||
|
.where('event_id', eventId)
|
||||||
|
.where('feedback_type', 'comment')
|
||||||
|
.where('is_approved', true)
|
||||||
|
.where('is_hidden', false)
|
||||||
|
.count('id as count')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
// User with many approved comments is trusted
|
||||||
|
if (approvedComments && approvedComments.count > 10) {
|
||||||
|
return {
|
||||||
|
trusted: true,
|
||||||
|
autoApprove: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { trusted: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error checking user reputation:', error);
|
||||||
|
return { trusted: true }; // Default to trusting in case of error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new FeedbackModerationService();
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
const { db, logActivity } = require('../database/db');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
|
||||||
|
class FeedbackService {
|
||||||
|
/**
|
||||||
|
* Get feedback settings for an event
|
||||||
|
*/
|
||||||
|
async getEventFeedbackSettings(eventId) {
|
||||||
|
try {
|
||||||
|
const settings = await db('event_feedback_settings')
|
||||||
|
.where('event_id', eventId)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
// Return default settings if none exist
|
||||||
|
return {
|
||||||
|
event_id: eventId,
|
||||||
|
feedback_enabled: false,
|
||||||
|
allow_ratings: true,
|
||||||
|
allow_likes: true,
|
||||||
|
allow_comments: false,
|
||||||
|
allow_favorites: true,
|
||||||
|
require_name_email: false,
|
||||||
|
moderate_comments: true,
|
||||||
|
show_feedback_to_guests: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting feedback settings:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update feedback settings for an event
|
||||||
|
*/
|
||||||
|
async updateEventFeedbackSettings(eventId, settings) {
|
||||||
|
try {
|
||||||
|
const existing = await db('event_feedback_settings')
|
||||||
|
.where('event_id', eventId)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db('event_feedback_settings')
|
||||||
|
.where('event_id', eventId)
|
||||||
|
.update({
|
||||||
|
...settings,
|
||||||
|
updated_at: new Date()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await db('event_feedback_settings').insert({
|
||||||
|
event_id: eventId,
|
||||||
|
...settings,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await logActivity('feedback_settings_updated', settings, eventId);
|
||||||
|
|
||||||
|
return this.getEventFeedbackSettings(eventId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating feedback settings:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit feedback for a photo
|
||||||
|
*/
|
||||||
|
async submitFeedback(photoId, eventId, feedbackData, guestIdentifier) {
|
||||||
|
try {
|
||||||
|
const { feedback_type, rating, comment_text, guest_name, guest_email, ip_address, user_agent } = feedbackData;
|
||||||
|
|
||||||
|
// Validate feedback type
|
||||||
|
if (!['rating', 'like', 'comment', 'favorite'].includes(feedback_type)) {
|
||||||
|
throw new Error('Invalid feedback type');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if similar feedback already exists (prevent duplicates)
|
||||||
|
if (feedback_type !== 'comment') {
|
||||||
|
const existing = await db('photo_feedback')
|
||||||
|
.where({
|
||||||
|
photo_id: photoId,
|
||||||
|
event_id: eventId,
|
||||||
|
feedback_type,
|
||||||
|
guest_identifier: guestIdentifier
|
||||||
|
})
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (feedback_type === 'rating' && rating !== existing.rating) {
|
||||||
|
// Update existing rating
|
||||||
|
await db('photo_feedback')
|
||||||
|
.where('id', existing.id)
|
||||||
|
.update({
|
||||||
|
rating,
|
||||||
|
updated_at: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.updatePhotoFeedbackStats(photoId);
|
||||||
|
return { id: existing.id, updated: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// For likes and favorites, toggle off if already exists
|
||||||
|
if (feedback_type === 'like' || feedback_type === 'favorite') {
|
||||||
|
await db('photo_feedback')
|
||||||
|
.where('id', existing.id)
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
await this.updatePhotoFeedbackStats(photoId);
|
||||||
|
return { removed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: existing.id, exists: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert new feedback
|
||||||
|
const [id] = await db('photo_feedback').insert({
|
||||||
|
photo_id: photoId,
|
||||||
|
event_id: eventId,
|
||||||
|
feedback_type,
|
||||||
|
rating: feedback_type === 'rating' ? rating : null,
|
||||||
|
comment_text: feedback_type === 'comment' ? comment_text : null,
|
||||||
|
guest_name,
|
||||||
|
guest_email,
|
||||||
|
guest_identifier: guestIdentifier,
|
||||||
|
ip_address,
|
||||||
|
user_agent,
|
||||||
|
is_approved: feedback_type !== 'comment' || !feedbackData.moderate_comments,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update photo stats
|
||||||
|
await this.updatePhotoFeedbackStats(photoId);
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
await logActivity(`photo_${feedback_type}`, { photo_id: photoId }, eventId);
|
||||||
|
|
||||||
|
return { id, created: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error submitting feedback:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get feedback for a photo
|
||||||
|
*/
|
||||||
|
async getPhotoFeedback(photoId, options = {}) {
|
||||||
|
try {
|
||||||
|
const query = db('photo_feedback')
|
||||||
|
.where('photo_id', photoId);
|
||||||
|
|
||||||
|
if (options.feedback_type) {
|
||||||
|
query.where('feedback_type', options.feedback_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.approved_only) {
|
||||||
|
query.where('is_approved', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.include_hidden) {
|
||||||
|
query.where('is_hidden', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.guest_identifier) {
|
||||||
|
query.where('guest_identifier', options.guest_identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedback = await query
|
||||||
|
.orderBy('created_at', 'desc')
|
||||||
|
.select('id', 'feedback_type', 'rating', 'comment_text', 'guest_name', 'created_at');
|
||||||
|
|
||||||
|
return feedback;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting photo feedback:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get feedback summary for an event
|
||||||
|
*/
|
||||||
|
async getEventFeedbackSummary(eventId) {
|
||||||
|
try {
|
||||||
|
const photos = await db('photos')
|
||||||
|
.where('event_id', eventId)
|
||||||
|
.select('id', 'filename', 'feedback_count', 'like_count', 'average_rating', 'favorite_count')
|
||||||
|
.orderBy('average_rating', 'desc')
|
||||||
|
.orderBy('like_count', 'desc');
|
||||||
|
|
||||||
|
const totalStats = await db('photo_feedback')
|
||||||
|
.where('event_id', eventId)
|
||||||
|
.select(
|
||||||
|
db.raw('COUNT(DISTINCT CASE WHEN feedback_type = ? THEN guest_identifier END) as unique_raters', ['rating']),
|
||||||
|
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_ratings', ['rating']),
|
||||||
|
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_likes', ['like']),
|
||||||
|
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_comments', ['comment']),
|
||||||
|
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_favorites', ['favorite'])
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
return {
|
||||||
|
photos,
|
||||||
|
stats: totalStats
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting feedback summary:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update photo feedback statistics
|
||||||
|
*/
|
||||||
|
async updatePhotoFeedbackStats(photoId) {
|
||||||
|
try {
|
||||||
|
// Get aggregated stats
|
||||||
|
const stats = await db('photo_feedback')
|
||||||
|
.where('photo_id', photoId)
|
||||||
|
.where('is_hidden', false)
|
||||||
|
.select(
|
||||||
|
db.raw('COUNT(CASE WHEN feedback_type = ? AND is_approved = ? THEN 1 END) as comment_count', ['comment', formatBoolean(true)]),
|
||||||
|
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as like_count', ['like']),
|
||||||
|
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as favorite_count', ['favorite']),
|
||||||
|
db.raw('AVG(CASE WHEN feedback_type = ? THEN rating END) as average_rating', ['rating']),
|
||||||
|
db.raw('COUNT(DISTINCT guest_identifier) as feedback_count')
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
// Update photo table
|
||||||
|
await db('photos')
|
||||||
|
.where('id', photoId)
|
||||||
|
.update({
|
||||||
|
feedback_count: stats.feedback_count || 0,
|
||||||
|
like_count: stats.like_count || 0,
|
||||||
|
average_rating: stats.average_rating || 0,
|
||||||
|
favorite_count: stats.favorite_count || 0
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating photo feedback stats:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moderate feedback (approve/hide)
|
||||||
|
*/
|
||||||
|
async moderateFeedback(feedbackId, action, adminId) {
|
||||||
|
try {
|
||||||
|
const updates = {
|
||||||
|
updated_at: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (action === 'approve') {
|
||||||
|
updates.is_approved = true;
|
||||||
|
updates.is_hidden = false;
|
||||||
|
} else if (action === 'hide') {
|
||||||
|
updates.is_hidden = true;
|
||||||
|
} else if (action === 'reject') {
|
||||||
|
updates.is_approved = false;
|
||||||
|
updates.is_hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedback = await db('photo_feedback')
|
||||||
|
.where('id', feedbackId)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!feedback) {
|
||||||
|
throw new Error('Feedback not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db('photo_feedback')
|
||||||
|
.where('id', feedbackId)
|
||||||
|
.update(updates);
|
||||||
|
|
||||||
|
// Update photo stats if visibility changed
|
||||||
|
await this.updatePhotoFeedbackStats(feedback.photo_id);
|
||||||
|
|
||||||
|
// Log moderation action
|
||||||
|
await logActivity('feedback_moderated', {
|
||||||
|
feedback_id: feedbackId,
|
||||||
|
action,
|
||||||
|
admin_id: adminId
|
||||||
|
}, feedback.event_id);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error moderating feedback:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete feedback
|
||||||
|
*/
|
||||||
|
async deleteFeedback(feedbackId, adminId) {
|
||||||
|
try {
|
||||||
|
const feedback = await db('photo_feedback')
|
||||||
|
.where('id', feedbackId)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!feedback) {
|
||||||
|
throw new Error('Feedback not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db('photo_feedback')
|
||||||
|
.where('id', feedbackId)
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
// Update photo stats
|
||||||
|
await this.updatePhotoFeedbackStats(feedback.photo_id);
|
||||||
|
|
||||||
|
// Log deletion
|
||||||
|
await logActivity('feedback_deleted', {
|
||||||
|
feedback_id: feedbackId,
|
||||||
|
feedback_type: feedback.feedback_type,
|
||||||
|
admin_id: adminId
|
||||||
|
}, feedback.event_id);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting feedback:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get feedback requiring moderation
|
||||||
|
*/
|
||||||
|
async getPendingModeration(eventId = null) {
|
||||||
|
try {
|
||||||
|
let query = db('photo_feedback')
|
||||||
|
.join('photos', 'photo_feedback.photo_id', 'photos.id')
|
||||||
|
.join('events', 'photo_feedback.event_id', 'events.id')
|
||||||
|
.where('photo_feedback.is_approved', false)
|
||||||
|
.where('photo_feedback.is_hidden', false)
|
||||||
|
.where('photo_feedback.feedback_type', 'comment');
|
||||||
|
|
||||||
|
if (eventId) {
|
||||||
|
query = query.where('photo_feedback.event_id', eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = await query
|
||||||
|
.select(
|
||||||
|
'photo_feedback.*',
|
||||||
|
'photos.filename as photo_filename',
|
||||||
|
'events.event_name'
|
||||||
|
)
|
||||||
|
.orderBy('photo_feedback.created_at', 'desc');
|
||||||
|
|
||||||
|
return pending;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting pending moderation:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export feedback data for an event
|
||||||
|
*/
|
||||||
|
async exportEventFeedback(eventId) {
|
||||||
|
try {
|
||||||
|
const feedback = await db('photo_feedback')
|
||||||
|
.join('photos', 'photo_feedback.photo_id', 'photos.id')
|
||||||
|
.where('photo_feedback.event_id', eventId)
|
||||||
|
.select(
|
||||||
|
'photos.filename',
|
||||||
|
'photo_feedback.feedback_type',
|
||||||
|
'photo_feedback.rating',
|
||||||
|
'photo_feedback.comment_text',
|
||||||
|
'photo_feedback.guest_name',
|
||||||
|
'photo_feedback.guest_email',
|
||||||
|
'photo_feedback.created_at'
|
||||||
|
)
|
||||||
|
.orderBy('photos.filename')
|
||||||
|
.orderBy('photo_feedback.created_at');
|
||||||
|
|
||||||
|
return feedback;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error exporting feedback:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new FeedbackService();
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
const { body, param, validationResult } = require('express-validator');
|
||||||
|
const validator = require('validator');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation rules for feedback submission
|
||||||
|
*/
|
||||||
|
const feedbackValidationRules = {
|
||||||
|
rating: [
|
||||||
|
body('feedback_type').equals('rating'),
|
||||||
|
body('rating')
|
||||||
|
.isInt({ min: 1, max: 5 })
|
||||||
|
.withMessage('Rating must be between 1 and 5'),
|
||||||
|
body('guest_name')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.isLength({ max: 100 })
|
||||||
|
.withMessage('Name must be less than 100 characters'),
|
||||||
|
body('guest_email')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.isEmail()
|
||||||
|
.normalizeEmail()
|
||||||
|
.withMessage('Invalid email address')
|
||||||
|
],
|
||||||
|
|
||||||
|
like: [
|
||||||
|
body('feedback_type').equals('like'),
|
||||||
|
body('guest_name')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.isLength({ max: 100 }),
|
||||||
|
body('guest_email')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.isEmail()
|
||||||
|
.normalizeEmail()
|
||||||
|
],
|
||||||
|
|
||||||
|
favorite: [
|
||||||
|
body('feedback_type').equals('favorite'),
|
||||||
|
body('guest_name')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.isLength({ max: 100 }),
|
||||||
|
body('guest_email')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.isEmail()
|
||||||
|
.normalizeEmail()
|
||||||
|
],
|
||||||
|
|
||||||
|
comment: [
|
||||||
|
body('feedback_type').equals('comment'),
|
||||||
|
body('comment_text')
|
||||||
|
.trim()
|
||||||
|
.notEmpty()
|
||||||
|
.withMessage('Comment cannot be empty')
|
||||||
|
.isLength({ min: 1, max: 1000 })
|
||||||
|
.withMessage('Comment must be between 1 and 1000 characters')
|
||||||
|
.customSanitizer(value => sanitizeComment(value)),
|
||||||
|
body('guest_name')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.isLength({ max: 100 })
|
||||||
|
.withMessage('Name must be less than 100 characters'),
|
||||||
|
body('guest_email')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.isEmail()
|
||||||
|
.normalizeEmail()
|
||||||
|
.withMessage('Invalid email address')
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize comment text
|
||||||
|
*/
|
||||||
|
function sanitizeComment(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
|
||||||
|
// Remove excessive whitespace
|
||||||
|
text = text.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
// Remove zero-width characters
|
||||||
|
text = text.replace(/[\u200B-\u200D\uFEFF]/g, '');
|
||||||
|
|
||||||
|
// Remove control characters
|
||||||
|
text = text.replace(/[\x00-\x1F\x7F]/g, '');
|
||||||
|
|
||||||
|
// Limit consecutive special characters
|
||||||
|
text = text.replace(/([!?.]){4,}/g, '$1$1$1');
|
||||||
|
|
||||||
|
// Remove script tags and other dangerous HTML (basic sanitization)
|
||||||
|
text = text.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
|
||||||
|
text = text.replace(/<iframe[^>]*>[\s\S]*?<\/iframe>/gi, '');
|
||||||
|
text = text.replace(/<object[^>]*>[\s\S]*?<\/object>/gi, '');
|
||||||
|
text = text.replace(/<embed[^>]*>/gi, '');
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate feedback type parameter
|
||||||
|
*/
|
||||||
|
const validateFeedbackType = param('feedbackType')
|
||||||
|
.isIn(['rating', 'like', 'comment', 'favorite'])
|
||||||
|
.withMessage('Invalid feedback type');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate photo ID parameter
|
||||||
|
*/
|
||||||
|
const validatePhotoId = param('photoId')
|
||||||
|
.isInt({ min: 1 })
|
||||||
|
.withMessage('Invalid photo ID');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate event ID parameter
|
||||||
|
*/
|
||||||
|
const validateEventId = param('eventId')
|
||||||
|
.isInt({ min: 1 })
|
||||||
|
.withMessage('Invalid event ID');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get validation rules based on feedback type
|
||||||
|
*/
|
||||||
|
function getValidationRules(feedbackType) {
|
||||||
|
return feedbackValidationRules[feedbackType] || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation middleware for feedback submission
|
||||||
|
*/
|
||||||
|
const validateFeedbackSubmission = [
|
||||||
|
body('feedback_type')
|
||||||
|
.isIn(['rating', 'like', 'comment', 'favorite'])
|
||||||
|
.withMessage('Invalid feedback type'),
|
||||||
|
|
||||||
|
// Conditional validation based on feedback type
|
||||||
|
body('rating')
|
||||||
|
.if(body('feedback_type').equals('rating'))
|
||||||
|
.isInt({ min: 1, max: 5 })
|
||||||
|
.withMessage('Rating must be between 1 and 5'),
|
||||||
|
|
||||||
|
body('comment_text')
|
||||||
|
.if(body('feedback_type').equals('comment'))
|
||||||
|
.trim()
|
||||||
|
.notEmpty()
|
||||||
|
.withMessage('Comment cannot be empty')
|
||||||
|
.isLength({ min: 1, max: 1000 })
|
||||||
|
.withMessage('Comment must be between 1 and 1000 characters')
|
||||||
|
.customSanitizer(value => sanitizeComment(value)),
|
||||||
|
|
||||||
|
body('guest_name')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.isLength({ max: 100 })
|
||||||
|
.withMessage('Name must be less than 100 characters')
|
||||||
|
.matches(/^[a-zA-Z0-9\s\-'.]+$/)
|
||||||
|
.withMessage('Name contains invalid characters'),
|
||||||
|
|
||||||
|
body('guest_email')
|
||||||
|
.optional()
|
||||||
|
.trim()
|
||||||
|
.isEmail()
|
||||||
|
.normalizeEmail()
|
||||||
|
.withMessage('Invalid email address')
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation for feedback settings
|
||||||
|
*/
|
||||||
|
const validateFeedbackSettings = [
|
||||||
|
body('feedback_enabled').optional().isBoolean(),
|
||||||
|
body('allow_ratings').optional().isBoolean(),
|
||||||
|
body('allow_likes').optional().isBoolean(),
|
||||||
|
body('allow_comments').optional().isBoolean(),
|
||||||
|
body('allow_favorites').optional().isBoolean(),
|
||||||
|
body('require_name_email').optional().isBoolean(),
|
||||||
|
body('moderate_comments').optional().isBoolean(),
|
||||||
|
body('show_feedback_to_guests').optional().isBoolean()
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation for word filters
|
||||||
|
*/
|
||||||
|
const validateWordFilter = [
|
||||||
|
body('word')
|
||||||
|
.trim()
|
||||||
|
.notEmpty()
|
||||||
|
.withMessage('Word cannot be empty')
|
||||||
|
.isLength({ min: 2, max: 100 })
|
||||||
|
.withMessage('Word must be between 2 and 100 characters'),
|
||||||
|
body('severity')
|
||||||
|
.optional()
|
||||||
|
.isIn(['mild', 'moderate', 'severe'])
|
||||||
|
.withMessage('Invalid severity level')
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check validation results middleware
|
||||||
|
*/
|
||||||
|
const checkValidation = (req, res, next) => {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Validation failed',
|
||||||
|
errors: errors.array()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate guest identity requirements
|
||||||
|
*/
|
||||||
|
async function validateGuestRequirements(settings, guestData) {
|
||||||
|
if (!settings.require_name_email) {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (!guestData.guest_name || guestData.guest_name.trim().length === 0) {
|
||||||
|
errors.push('Name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!guestData.guest_email || !validator.isEmail(guestData.guest_email)) {
|
||||||
|
errors.push('Valid email is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
errors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
feedbackValidationRules,
|
||||||
|
validateFeedbackType,
|
||||||
|
validatePhotoId,
|
||||||
|
validateEventId,
|
||||||
|
validateFeedbackSubmission,
|
||||||
|
validateFeedbackSettings,
|
||||||
|
validateWordFilter,
|
||||||
|
checkValidation,
|
||||||
|
getValidationRules,
|
||||||
|
sanitizeComment,
|
||||||
|
validateGuestRequirements
|
||||||
|
};
|
||||||
Executable
+163
@@ -0,0 +1,163 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Script to clean git history - removes all commits before July 17, 2025
|
||||||
|
# Safe version: Moves current main to main-old and creates new clean main
|
||||||
|
# WARNING: This will rewrite history!
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "⚠️ WARNING: This script will rewrite git history!"
|
||||||
|
echo "⚠️ All commits before July 17, 2025 will be removed."
|
||||||
|
echo "⚠️ Current main branch will be preserved as main-old"
|
||||||
|
echo ""
|
||||||
|
read -p "Are you sure you want to continue? (type 'yes' to proceed): " confirmation
|
||||||
|
|
||||||
|
if [ "$confirmation" != "yes" ]; then
|
||||||
|
echo "Operation cancelled."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check current state
|
||||||
|
CURRENT_BRANCH=$(git branch --show-current)
|
||||||
|
echo "Current branch: $CURRENT_BRANCH"
|
||||||
|
|
||||||
|
# Check if we're in the middle of a cherry-pick or rebase
|
||||||
|
if [ -d ".git/CHERRY_PICK_HEAD" ] || [ -d ".git/rebase-merge" ] || [ -d ".git/rebase-apply" ]; then
|
||||||
|
echo "ERROR: You're in the middle of a cherry-pick or rebase. Please resolve or abort it first."
|
||||||
|
echo "To abort cherry-pick: git cherry-pick --abort"
|
||||||
|
echo "To abort rebase: git rebase --abort"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clean any previous attempts
|
||||||
|
echo "Cleaning up any previous attempts..."
|
||||||
|
git cherry-pick --abort 2>/dev/null || true
|
||||||
|
git rebase --abort 2>/dev/null || true
|
||||||
|
|
||||||
|
# Check if main-old already exists
|
||||||
|
if git show-ref --verify --quiet refs/heads/main-old; then
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ Branch 'main-old' already exists!"
|
||||||
|
echo "Options:"
|
||||||
|
echo "1. Delete it and continue (previous backup will be lost)"
|
||||||
|
echo "2. Rename it with timestamp and continue"
|
||||||
|
echo "3. Cancel operation"
|
||||||
|
read -p "Choose option (1/2/3): " option
|
||||||
|
|
||||||
|
case $option in
|
||||||
|
1)
|
||||||
|
echo "Deleting existing main-old branch..."
|
||||||
|
git branch -D main-old
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||||
|
NEW_NAME="main-old-${TIMESTAMP}"
|
||||||
|
echo "Renaming existing main-old to ${NEW_NAME}..."
|
||||||
|
git branch -m main-old "${NEW_NAME}"
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
echo "Operation cancelled."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Invalid option. Operation cancelled."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Make sure we're on main branch
|
||||||
|
if [ "$CURRENT_BRANCH" != "main" ]; then
|
||||||
|
echo "Switching to main branch..."
|
||||||
|
git checkout main
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Move current main to main-old
|
||||||
|
echo "Moving current main branch to main-old..."
|
||||||
|
git branch -m main main-old
|
||||||
|
|
||||||
|
# Find the first commit on or after July 17, 2025
|
||||||
|
echo "Finding first commit after July 17, 2025..."
|
||||||
|
FIRST_COMMIT=$(git log --since="2025-07-17" --reverse --format="%H" | head -1)
|
||||||
|
|
||||||
|
if [ -z "$FIRST_COMMIT" ]; then
|
||||||
|
echo "ERROR: No commits found after July 17, 2025"
|
||||||
|
# Restore main branch
|
||||||
|
git branch -m main-old main
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "First commit to keep: $FIRST_COMMIT"
|
||||||
|
echo "Commit details: $(git log --oneline -1 $FIRST_COMMIT)"
|
||||||
|
|
||||||
|
# Count total commits to process
|
||||||
|
TOTAL_COMMITS=$(git log --since="2025-07-17" --oneline | wc -l)
|
||||||
|
echo "Total commits to preserve: $TOTAL_COMMITS"
|
||||||
|
|
||||||
|
# Create new orphan branch for clean main
|
||||||
|
echo "Creating new clean main branch..."
|
||||||
|
git checkout --orphan main
|
||||||
|
|
||||||
|
# Clean the working directory
|
||||||
|
git rm -rf . || true
|
||||||
|
|
||||||
|
# Get the tree from the first commit
|
||||||
|
git checkout $FIRST_COMMIT -- .
|
||||||
|
|
||||||
|
# Create new initial commit with same content
|
||||||
|
ORIGINAL_MESSAGE=$(git log --format="%B" -1 $FIRST_COMMIT)
|
||||||
|
ORIGINAL_AUTHOR=$(git log --format="%an <%ae>" -1 $FIRST_COMMIT)
|
||||||
|
ORIGINAL_DATE=$(git log --format="%ad" -1 $FIRST_COMMIT)
|
||||||
|
|
||||||
|
GIT_AUTHOR_NAME=$(echo "$ORIGINAL_AUTHOR" | cut -d'<' -f1 | xargs)
|
||||||
|
GIT_AUTHOR_EMAIL=$(echo "$ORIGINAL_AUTHOR" | cut -d'<' -f2 | cut -d'>' -f1)
|
||||||
|
GIT_AUTHOR_DATE="$ORIGINAL_DATE"
|
||||||
|
export GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_AUTHOR_DATE
|
||||||
|
|
||||||
|
git add -A
|
||||||
|
git commit -m "Initial commit - Project start (July 17, 2025)
|
||||||
|
|
||||||
|
Original: $ORIGINAL_MESSAGE"
|
||||||
|
|
||||||
|
# Now rebase the rest of the history onto the new main
|
||||||
|
echo "Rebasing remaining commits..."
|
||||||
|
echo "This will linearize the history (merge commits will be flattened)..."
|
||||||
|
|
||||||
|
# Use rebase to apply all commits
|
||||||
|
git rebase --onto main $FIRST_COMMIT main-old || {
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ Rebase encountered conflicts!"
|
||||||
|
echo ""
|
||||||
|
echo "To resolve:"
|
||||||
|
echo "1. Fix the conflicts in the listed files"
|
||||||
|
echo "2. Stage the resolved files: git add <files>"
|
||||||
|
echo "3. Continue rebase: git rebase --continue"
|
||||||
|
echo "4. If you want to abort and restore: git rebase --abort && git branch -D main && git branch -m main-old main"
|
||||||
|
echo ""
|
||||||
|
echo "After successful rebase, your main branch will have the clean history."
|
||||||
|
echo "The old history is preserved in main-old branch."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# If we get here, rebase was successful
|
||||||
|
echo ""
|
||||||
|
echo "✅ New clean history created successfully!"
|
||||||
|
echo "Total commits in new history: $(git rev-list --count HEAD)"
|
||||||
|
echo ""
|
||||||
|
echo "Branch status:"
|
||||||
|
echo " - main: Clean history starting from July 17, 2025"
|
||||||
|
echo " - main-old: Original history with all commits"
|
||||||
|
echo ""
|
||||||
|
echo "To push the new history to remote, run:"
|
||||||
|
echo " git push origin main --force"
|
||||||
|
echo ""
|
||||||
|
echo "To also push the old history backup:"
|
||||||
|
echo " git push origin main-old"
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ WARNING: Force pushing will overwrite the remote repository!"
|
||||||
|
echo "⚠️ Make sure all team members are aware before pushing!"
|
||||||
|
echo ""
|
||||||
|
echo "If you need to restore the original history:"
|
||||||
|
echo " git checkout main-old"
|
||||||
|
echo " git branch -D main"
|
||||||
|
echo " git branch -m main"
|
||||||
Executable
+123
@@ -0,0 +1,123 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Script to clean git history - removes all commits before July 17, 2025
|
||||||
|
# Version 2: Handles merge commits properly
|
||||||
|
# WARNING: This is destructive and will rewrite history!
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "⚠️ WARNING: This script will permanently rewrite git history!"
|
||||||
|
echo "⚠️ All commits before July 17, 2025 will be removed."
|
||||||
|
echo "⚠️ This action cannot be undone!"
|
||||||
|
echo ""
|
||||||
|
read -p "Are you sure you want to continue? (type 'yes' to proceed): " confirmation
|
||||||
|
|
||||||
|
if [ "$confirmation" != "yes" ]; then
|
||||||
|
echo "Operation cancelled."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check current state
|
||||||
|
CURRENT_BRANCH=$(git branch --show-current)
|
||||||
|
echo "Current branch: $CURRENT_BRANCH"
|
||||||
|
|
||||||
|
# Check if we're in the middle of a cherry-pick
|
||||||
|
if [ -d ".git/CHERRY_PICK_HEAD" ]; then
|
||||||
|
echo "ERROR: You're in the middle of a cherry-pick. Please resolve or abort it first."
|
||||||
|
echo "To abort: git cherry-pick --abort"
|
||||||
|
echo "To continue: git cherry-pick --continue"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clean any previous attempts
|
||||||
|
echo "Cleaning up any previous attempts..."
|
||||||
|
git cherry-pick --abort 2>/dev/null || true
|
||||||
|
git checkout main 2>/dev/null || true
|
||||||
|
git branch -D new-main 2>/dev/null || true
|
||||||
|
|
||||||
|
# Create backup branch
|
||||||
|
echo "Creating backup branch..."
|
||||||
|
BACKUP_BRANCH="backup-before-cleanup-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
git checkout -b "$BACKUP_BRANCH"
|
||||||
|
git checkout main
|
||||||
|
|
||||||
|
# Find the first commit on or after July 17, 2025
|
||||||
|
echo "Finding first commit after July 17, 2025..."
|
||||||
|
FIRST_COMMIT=$(git log --since="2025-07-17" --reverse --format="%H" | head -1)
|
||||||
|
|
||||||
|
if [ -z "$FIRST_COMMIT" ]; then
|
||||||
|
echo "ERROR: No commits found after July 17, 2025"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "First commit to keep: $FIRST_COMMIT"
|
||||||
|
echo "Commit details: $(git log --oneline -1 $FIRST_COMMIT)"
|
||||||
|
|
||||||
|
# Count total commits to process
|
||||||
|
TOTAL_COMMITS=$(git log --since="2025-07-17" --oneline | wc -l)
|
||||||
|
echo "Total commits to preserve: $TOTAL_COMMITS"
|
||||||
|
|
||||||
|
# Create new orphan branch
|
||||||
|
echo "Creating new clean history..."
|
||||||
|
git checkout --orphan new-main
|
||||||
|
|
||||||
|
# Clean the working directory
|
||||||
|
git rm -rf . || true
|
||||||
|
|
||||||
|
# Get the tree from the first commit
|
||||||
|
git checkout $FIRST_COMMIT -- .
|
||||||
|
|
||||||
|
# Create new initial commit with same content
|
||||||
|
ORIGINAL_MESSAGE=$(git log --format="%B" -1 $FIRST_COMMIT)
|
||||||
|
ORIGINAL_AUTHOR=$(git log --format="%an <%ae>" -1 $FIRST_COMMIT)
|
||||||
|
ORIGINAL_DATE=$(git log --format="%ad" -1 $FIRST_COMMIT)
|
||||||
|
|
||||||
|
GIT_AUTHOR_NAME=$(echo "$ORIGINAL_AUTHOR" | cut -d'<' -f1 | xargs)
|
||||||
|
GIT_AUTHOR_EMAIL=$(echo "$ORIGINAL_AUTHOR" | cut -d'<' -f2 | cut -d'>' -f1)
|
||||||
|
GIT_AUTHOR_DATE="$ORIGINAL_DATE"
|
||||||
|
export GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_AUTHOR_DATE
|
||||||
|
|
||||||
|
git add -A
|
||||||
|
git commit -m "Initial commit - Project start (July 17, 2025)
|
||||||
|
|
||||||
|
Original: $ORIGINAL_MESSAGE"
|
||||||
|
|
||||||
|
# Now we'll use git rebase instead of cherry-pick to handle merges better
|
||||||
|
echo "Rebasing remaining commits..."
|
||||||
|
echo "This will linearize the history (merge commits will be flattened)..."
|
||||||
|
|
||||||
|
# Get the commit range
|
||||||
|
LAST_COMMIT=$(git rev-parse main)
|
||||||
|
|
||||||
|
# Use rebase to apply all commits
|
||||||
|
git rebase --onto new-main $FIRST_COMMIT main || {
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ Rebase encountered conflicts!"
|
||||||
|
echo ""
|
||||||
|
echo "To resolve:"
|
||||||
|
echo "1. Fix the conflicts in the listed files"
|
||||||
|
echo "2. Stage the resolved files: git add <files>"
|
||||||
|
echo "3. Continue rebase: git rebase --continue"
|
||||||
|
echo "4. If you want to abort: git rebase --abort"
|
||||||
|
echo ""
|
||||||
|
echo "After successful rebase, run:"
|
||||||
|
echo " git branch -D main"
|
||||||
|
echo " git branch -m main"
|
||||||
|
echo " git push origin main --force"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# If we get here, rebase was successful
|
||||||
|
echo ""
|
||||||
|
echo "✅ New history created successfully!"
|
||||||
|
echo "Total commits in new history: $(git rev-list --count HEAD)"
|
||||||
|
echo ""
|
||||||
|
echo "Your backup branch is: $BACKUP_BRANCH"
|
||||||
|
echo ""
|
||||||
|
echo "To finalize the cleanup, run these commands:"
|
||||||
|
echo " git branch -D main"
|
||||||
|
echo " git branch -m main"
|
||||||
|
echo " git push origin main --force"
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ WARNING: Force pushing will overwrite the remote repository!"
|
||||||
|
echo "⚠️ Make sure you have a backup and all team members are aware!"
|
||||||
Executable
+91
@@ -0,0 +1,91 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Script to clean git history - removes all commits before July 17, 2025
|
||||||
|
# WARNING: This is destructive and will rewrite history!
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "⚠️ WARNING: This script will permanently rewrite git history!"
|
||||||
|
echo "⚠️ All commits before July 17, 2025 will be removed."
|
||||||
|
echo "⚠️ This action cannot be undone!"
|
||||||
|
echo ""
|
||||||
|
read -p "Are you sure you want to continue? (type 'yes' to proceed): " confirmation
|
||||||
|
|
||||||
|
if [ "$confirmation" != "yes" ]; then
|
||||||
|
echo "Operation cancelled."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create backup branch
|
||||||
|
echo "Creating backup branch..."
|
||||||
|
git checkout -b backup-before-cleanup-$(date +%Y%m%d-%H%M%S)
|
||||||
|
git checkout main
|
||||||
|
|
||||||
|
# Find the first commit on or after July 17, 2025
|
||||||
|
echo "Finding first commit after July 17, 2025..."
|
||||||
|
FIRST_COMMIT=$(git log --since="2025-07-17" --reverse --format="%H" | head -1)
|
||||||
|
|
||||||
|
if [ -z "$FIRST_COMMIT" ]; then
|
||||||
|
echo "ERROR: No commits found after July 17, 2025"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "First commit to keep: $FIRST_COMMIT"
|
||||||
|
echo "Commit details: $(git log --oneline -1 $FIRST_COMMIT)"
|
||||||
|
|
||||||
|
# Get all commits we want to keep
|
||||||
|
COMMITS_TO_KEEP=$(git log --since="2025-07-17" --reverse --format="%H")
|
||||||
|
COMMIT_COUNT=$(echo "$COMMITS_TO_KEEP" | wc -l)
|
||||||
|
echo "Total commits to preserve: $COMMIT_COUNT"
|
||||||
|
|
||||||
|
# Create new orphan branch
|
||||||
|
echo "Creating new clean history..."
|
||||||
|
git checkout --orphan new-main
|
||||||
|
|
||||||
|
# Clean the working directory
|
||||||
|
git rm -rf . || true
|
||||||
|
|
||||||
|
# Get the tree from the first commit
|
||||||
|
git checkout $FIRST_COMMIT -- .
|
||||||
|
|
||||||
|
# Create new initial commit with same content but new message
|
||||||
|
ORIGINAL_MESSAGE=$(git log --format="%B" -1 $FIRST_COMMIT)
|
||||||
|
ORIGINAL_AUTHOR=$(git log --format="%an <%ae>" -1 $FIRST_COMMIT)
|
||||||
|
ORIGINAL_DATE=$(git log --format="%ad" -1 $FIRST_COMMIT)
|
||||||
|
|
||||||
|
GIT_AUTHOR_NAME=$(echo "$ORIGINAL_AUTHOR" | cut -d'<' -f1 | xargs)
|
||||||
|
GIT_AUTHOR_EMAIL=$(echo "$ORIGINAL_AUTHOR" | cut -d'<' -f2 | cut -d'>' -f1)
|
||||||
|
GIT_AUTHOR_DATE="$ORIGINAL_DATE"
|
||||||
|
export GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_AUTHOR_DATE
|
||||||
|
|
||||||
|
git add -A
|
||||||
|
git commit -m "Initial commit - Project start (July 17, 2025)
|
||||||
|
|
||||||
|
Original: $ORIGINAL_MESSAGE"
|
||||||
|
|
||||||
|
# Cherry-pick remaining commits
|
||||||
|
echo "Applying remaining commits..."
|
||||||
|
REMAINING_COMMITS=$(git log --since="2025-07-17" --reverse --format="%H" $FIRST_COMMIT..main)
|
||||||
|
|
||||||
|
if [ -n "$REMAINING_COMMITS" ]; then
|
||||||
|
for commit in $REMAINING_COMMITS; do
|
||||||
|
echo "Applying: $(git log --oneline -1 $commit)"
|
||||||
|
git cherry-pick $commit || {
|
||||||
|
echo "ERROR: Failed to cherry-pick $commit"
|
||||||
|
echo "You may need to resolve conflicts and continue manually"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ New history created successfully!"
|
||||||
|
echo "Total commits in new history: $(git rev-list --count HEAD)"
|
||||||
|
echo ""
|
||||||
|
echo "To finalize the cleanup, run these commands:"
|
||||||
|
echo " git branch -D main"
|
||||||
|
echo " git branch -m main"
|
||||||
|
echo " git push origin main --force"
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ WARNING: Force pushing will overwrite the remote repository!"
|
||||||
|
echo "⚠️ Make sure you have a backup and all team members are aware!"
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.76",
|
"version": "1.0.93",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.76",
|
"version": "1.0.93",
|
||||||
"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.76",
|
"version": "1.0.93",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
EventsListPage,
|
EventsListPage,
|
||||||
CreateEventPageEnhanced as CreateEventPage,
|
CreateEventPageEnhanced as CreateEventPage,
|
||||||
EventDetailsPage,
|
EventDetailsPage,
|
||||||
|
EventFeedbackPage,
|
||||||
EmailConfigPage,
|
EmailConfigPage,
|
||||||
ArchivesPage,
|
ArchivesPage,
|
||||||
AnalyticsPage,
|
AnalyticsPage,
|
||||||
@@ -120,6 +121,7 @@ function App() {
|
|||||||
<Route path="events" element={<EventsListPage />} />
|
<Route path="events" element={<EventsListPage />} />
|
||||||
<Route path="events/new" element={<CreateEventPage />} />
|
<Route path="events/new" element={<CreateEventPage />} />
|
||||||
<Route path="events/:id" element={<EventDetailsPage />} />
|
<Route path="events/:id" element={<EventDetailsPage />} />
|
||||||
|
<Route path="events/:id/feedback" element={<EventFeedbackPage />} />
|
||||||
<Route path="archives" element={<ArchivesPage />} />
|
<Route path="archives" element={<ArchivesPage />} />
|
||||||
<Route path="email" element={<EmailConfigPage />} />
|
<Route path="email" element={<EmailConfigPage />} />
|
||||||
<Route path="analytics" element={<AnalyticsPage />} />
|
<Route path="analytics" element={<AnalyticsPage />} />
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
||||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const [deletingPhotoId, setDeletingPhotoId] = useState<number | null>(null);
|
const [deletingPhotos, setDeletingPhotos] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
const handlePhotoSelect = (photoId: number, e?: React.MouseEvent) => {
|
const handlePhotoSelect = (photoId: number, e?: React.MouseEvent) => {
|
||||||
if (e) {
|
if (e) {
|
||||||
@@ -54,15 +54,18 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setDeletingPhotoId(photo.id);
|
setDeletingPhotos(prev => new Set(prev).add(photo.id));
|
||||||
try {
|
try {
|
||||||
await photosService.deletePhoto(eventId, photo.id);
|
await photosService.deletePhoto(eventId, photo.id);
|
||||||
toast.success('Photo deleted successfully');
|
toast.success('Photo deleted successfully');
|
||||||
onPhotosDeleted();
|
onPhotosDeleted();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to delete photo');
|
toast.error('Failed to delete photo');
|
||||||
} finally {
|
setDeletingPhotos(prev => {
|
||||||
setDeletingPhotoId(null);
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(photo.id);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,14 +78,18 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
|
const selectedIds = Array.from(selectedPhotos);
|
||||||
|
setDeletingPhotos(new Set(selectedIds));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await photosService.deletePhotos(eventId, Array.from(selectedPhotos));
|
await photosService.deletePhotos(eventId, selectedIds);
|
||||||
toast.success(`${count} photo${count > 1 ? 's' : ''} deleted successfully`);
|
toast.success(`${count} photo${count > 1 ? 's' : ''} deleted successfully`);
|
||||||
setSelectedPhotos(new Set());
|
setSelectedPhotos(new Set());
|
||||||
setIsSelectionMode(false);
|
setIsSelectionMode(false);
|
||||||
onPhotosDeleted();
|
onPhotosDeleted();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to delete photos');
|
toast.error('Failed to delete photos');
|
||||||
|
setDeletingPhotos(new Set());
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
}
|
}
|
||||||
@@ -155,13 +162,15 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
|
|
||||||
{/* Photo Grid */}
|
{/* Photo Grid */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||||
{photos.map((photo, index) => (
|
{photos.map((photo, index) => {
|
||||||
<div
|
const isDeleting = deletingPhotos.has(photo.id);
|
||||||
key={photo.id}
|
return (
|
||||||
className={`relative group cursor-pointer rounded-lg overflow-hidden bg-neutral-100 ${
|
<div
|
||||||
isSelectionMode ? 'ring-2 ring-offset-2 ' + (selectedPhotos.has(photo.id) ? 'ring-primary-500' : 'ring-transparent') : ''
|
key={photo.id}
|
||||||
}`}
|
className={`relative group cursor-pointer rounded-lg overflow-hidden bg-neutral-100 transition-opacity ${
|
||||||
onClick={() => isSelectionMode ? handlePhotoSelect(photo.id) : onPhotoClick(photo, index)}
|
isSelectionMode ? 'ring-2 ring-offset-2 ' + (selectedPhotos.has(photo.id) ? 'ring-primary-500' : 'ring-transparent') : ''
|
||||||
|
} ${isDeleting ? 'opacity-50' : ''}`}
|
||||||
|
onClick={() => !isDeleting && (isSelectionMode ? handlePhotoSelect(photo.id) : onPhotoClick(photo, index))}
|
||||||
>
|
>
|
||||||
{/* Selection Checkbox */}
|
{/* Selection Checkbox */}
|
||||||
{isSelectionMode && (
|
{isSelectionMode && (
|
||||||
@@ -219,8 +228,8 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleDeleteSingle(photo, e)}
|
onClick={(e) => handleDeleteSingle(photo, e)}
|
||||||
className="p-1 text-white hover:bg-white/20 rounded"
|
className="p-1 text-white hover:bg-white/20 rounded disabled:opacity-50"
|
||||||
disabled={deletingPhotoId === photo.id}
|
disabled={isDeleting}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
@@ -238,7 +247,8 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{photos.length === 0 && (
|
{photos.length === 0 && (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Save,
|
Save,
|
||||||
Server,
|
Server,
|
||||||
@@ -11,7 +12,7 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
TestTube,
|
Wifi,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
XCircle,
|
XCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -23,38 +24,40 @@ import {
|
|||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { Button, Card, Input } from '../common';
|
import { Button, Card, Input } from '../common';
|
||||||
|
|
||||||
const destinationTypes = [
|
|
||||||
{
|
|
||||||
id: 'local',
|
|
||||||
name: 'Local Storage',
|
|
||||||
icon: HardDrive,
|
|
||||||
description: 'Store backups on the local server filesystem',
|
|
||||||
fields: ['backup_destination_path']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'rsync',
|
|
||||||
name: 'Remote Server (Rsync)',
|
|
||||||
icon: Server,
|
|
||||||
description: 'Sync backups to a remote server via SSH/Rsync',
|
|
||||||
fields: ['backup_rsync_host', 'backup_rsync_user', 'backup_rsync_path', 'backup_rsync_ssh_key']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 's3',
|
|
||||||
name: 'S3 Compatible Storage',
|
|
||||||
icon: Cloud,
|
|
||||||
description: 'Store backups in Amazon S3 or compatible object storage',
|
|
||||||
fields: ['backup_s3_endpoint', 'backup_s3_bucket', 'backup_s3_access_key', 'backup_s3_secret_key', 'backup_s3_region']
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const scheduleOptions = [
|
|
||||||
{ value: 'hourly', label: 'Every hour' },
|
|
||||||
{ value: 'daily', label: 'Daily' },
|
|
||||||
{ value: 'weekly', label: 'Weekly' },
|
|
||||||
{ value: 'custom', label: 'Custom cron expression' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const destinationTypes = [
|
||||||
|
{
|
||||||
|
id: 'local',
|
||||||
|
name: t('backup.configuration.destinationTypes.local.name'),
|
||||||
|
icon: HardDrive,
|
||||||
|
description: t('backup.configuration.destinationTypes.local.description'),
|
||||||
|
fields: ['backup_destination_path']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rsync',
|
||||||
|
name: t('backup.configuration.destinationTypes.rsync.name'),
|
||||||
|
icon: Server,
|
||||||
|
description: t('backup.configuration.destinationTypes.rsync.description'),
|
||||||
|
fields: ['backup_rsync_host', 'backup_rsync_user', 'backup_rsync_path', 'backup_rsync_ssh_key']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's3',
|
||||||
|
name: t('backup.configuration.destinationTypes.s3.name'),
|
||||||
|
icon: Cloud,
|
||||||
|
description: t('backup.configuration.destinationTypes.s3.description'),
|
||||||
|
fields: ['backup_s3_endpoint', 'backup_s3_bucket', 'backup_s3_access_key', 'backup_s3_secret_key', 'backup_s3_region']
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const scheduleOptions = [
|
||||||
|
{ value: 'hourly', label: t('backup.configuration.schedule.options.hourly') },
|
||||||
|
{ value: 'daily', label: t('backup.configuration.schedule.options.daily') },
|
||||||
|
{ value: 'weekly', label: t('backup.configuration.schedule.options.weekly') },
|
||||||
|
{ value: 'custom', label: t('backup.configuration.schedule.options.custom') }
|
||||||
|
];
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
backup_enabled: false,
|
backup_enabled: false,
|
||||||
backup_destination_type: 'local',
|
backup_destination_type: 'local',
|
||||||
@@ -121,7 +124,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (missingFields.length > 0) {
|
if (missingFields.length > 0) {
|
||||||
toast.error('Please fill in all required fields');
|
toast.error(t('backup.configuration.messages.requiredFields'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,9 +136,9 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
try {
|
try {
|
||||||
// TODO: Implement connection test endpoint
|
// TODO: Implement connection test endpoint
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
toast.success('Connection test successful!');
|
toast.success(t('backup.configuration.messages.connectionSuccess'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Connection test failed: ' + error.message);
|
toast.error(t('backup.configuration.messages.connectionFailed') + ': ' + error.message);
|
||||||
} finally {
|
} finally {
|
||||||
setTestingConnection(false);
|
setTestingConnection(false);
|
||||||
}
|
}
|
||||||
@@ -149,9 +152,9 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-lg font-semibold text-gray-900">Backup Service</h3>
|
<h3 className="text-lg font-semibold text-gray-900">{t('backup.configuration.enableBackup')}</h3>
|
||||||
<p className="mt-1 text-sm text-gray-600">
|
<p className="mt-1 text-sm text-gray-600">
|
||||||
Enable automatic backups to protect your data
|
{t('backup.configuration.enableBackupHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
@@ -168,7 +171,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
|
|
||||||
{/* Destination Configuration */}
|
{/* Destination Configuration */}
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Backup Destination</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">{t('backup.configuration.destinationType')}</h3>
|
||||||
|
|
||||||
{/* Destination Type Selection */}
|
{/* Destination Type Selection */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
@@ -203,17 +206,17 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Backup Directory Path
|
{t('backup.configuration.fields.destinationPath')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.backup_destination_path}
|
value={formData.backup_destination_path}
|
||||||
onChange={(e) => handleChange('backup_destination_path', e.target.value)}
|
onChange={(e) => handleChange('backup_destination_path', e.target.value)}
|
||||||
placeholder="/path/to/backup/directory"
|
placeholder={t('backup.configuration.fields.destinationPathPlaceholder')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
Absolute path where backups will be stored
|
{t('backup.configuration.fields.destinationPathHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -224,50 +227,50 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
SSH Host
|
{t('backup.configuration.fields.rsyncHost')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.backup_rsync_host}
|
value={formData.backup_rsync_host}
|
||||||
onChange={(e) => handleChange('backup_rsync_host', e.target.value)}
|
onChange={(e) => handleChange('backup_rsync_host', e.target.value)}
|
||||||
placeholder="backup.example.com"
|
placeholder={t('backup.configuration.fields.rsyncHostPlaceholder')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
SSH User
|
{t('backup.configuration.fields.rsyncUser')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.backup_rsync_user}
|
value={formData.backup_rsync_user}
|
||||||
onChange={(e) => handleChange('backup_rsync_user', e.target.value)}
|
onChange={(e) => handleChange('backup_rsync_user', e.target.value)}
|
||||||
placeholder="backup-user"
|
placeholder={t('backup.configuration.fields.rsyncUserPlaceholder')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Remote Path
|
{t('backup.configuration.fields.rsyncPath')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.backup_rsync_path}
|
value={formData.backup_rsync_path}
|
||||||
onChange={(e) => handleChange('backup_rsync_path', e.target.value)}
|
onChange={(e) => handleChange('backup_rsync_path', e.target.value)}
|
||||||
placeholder="/home/backup/photo-sharing"
|
placeholder={t('backup.configuration.fields.rsyncPathPlaceholder')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
SSH Private Key (optional)
|
{t('backup.configuration.fields.rsyncSshKey')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<textarea
|
<textarea
|
||||||
value={formData.backup_rsync_ssh_key}
|
value={formData.backup_rsync_ssh_key}
|
||||||
onChange={(e) => handleChange('backup_rsync_ssh_key', e.target.value)}
|
onChange={(e) => handleChange('backup_rsync_ssh_key', e.target.value)}
|
||||||
placeholder="-----BEGIN RSA PRIVATE KEY-----"
|
placeholder={t('backup.configuration.fields.rsyncSshKeyPlaceholder')}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-primary focus:border-primary font-mono text-sm"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-primary focus:border-primary font-mono text-sm"
|
||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
@@ -280,7 +283,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
Leave empty to use system SSH keys
|
{t('backup.configuration.fields.rsyncSshKeyHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -290,7 +293,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
S3 Endpoint URL
|
{t('backup.configuration.fields.s3Endpoint')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -300,13 +303,13 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
Use default for AWS S3, or your provider's endpoint
|
{t('backup.configuration.fields.s3EndpointHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Bucket Name
|
{t('backup.configuration.fields.s3Bucket')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -318,7 +321,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Region
|
{t('backup.configuration.fields.s3Region')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -331,7 +334,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Access Key ID
|
{t('backup.configuration.fields.s3AccessKey')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -343,7 +346,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Secret Access Key
|
{t('backup.configuration.fields.s3SecretKey')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
@@ -379,12 +382,12 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
{testingConnection ? (
|
{testingConnection ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Testing...
|
{t('backup.configuration.testingConnection')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<TestTube className="mr-2 h-4 w-4" />
|
<Wifi className="mr-2 h-4 w-4" />
|
||||||
Test Connection
|
{t('backup.actions.testConnection')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -395,12 +398,12 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
|
|
||||||
{/* Schedule Configuration */}
|
{/* Schedule Configuration */}
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Backup Schedule</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">{t('backup.configuration.schedule.title')}</h3>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Schedule
|
{t('backup.configuration.schedule.scheduleType')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={formData.backup_schedule}
|
value={formData.backup_schedule}
|
||||||
@@ -418,7 +421,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
{formData.backup_schedule === 'custom' && (
|
{formData.backup_schedule === 'custom' && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Cron Expression
|
{t('backup.configuration.schedule.customCron')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -427,14 +430,14 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
placeholder="0 3 * * *"
|
placeholder="0 3 * * *"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
Use standard cron syntax (minute hour day month weekday)
|
{t('backup.configuration.schedule.customCronHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Retention Period (days)
|
{t('backup.configuration.schedule.retention')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -444,7 +447,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
max="365"
|
max="365"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
Backups older than this will be automatically deleted
|
{t('backup.configuration.schedule.retentionHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -452,7 +455,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
|
|
||||||
{/* Backup Content Selection */}
|
{/* Backup Content Selection */}
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Backup Content</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">{t('backup.configuration.whatToBackup.title')}</h3>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
@@ -465,9 +468,9 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
<div className="ml-3">
|
<div className="ml-3">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Database className="h-4 w-4 text-gray-400" />
|
<Database className="h-4 w-4 text-gray-400" />
|
||||||
<span className="text-sm font-medium text-gray-700">Database</span>
|
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.whatToBackup.database')}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500">All application data and settings</p>
|
<p className="text-xs text-gray-500">{t('backup.configuration.whatToBackup.databaseHelp')}</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -481,9 +484,9 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
<div className="ml-3">
|
<div className="ml-3">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Image className="h-4 w-4 text-gray-400" />
|
<Image className="h-4 w-4 text-gray-400" />
|
||||||
<span className="text-sm font-medium text-gray-700">Photos</span>
|
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.whatToBackup.photos')}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500">All uploaded photos and galleries</p>
|
<p className="text-xs text-gray-500">{t('backup.configuration.whatToBackup.photosHelp')}</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -497,9 +500,9 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
<div className="ml-3">
|
<div className="ml-3">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<FileArchive className="h-4 w-4 text-gray-400" />
|
<FileArchive className="h-4 w-4 text-gray-400" />
|
||||||
<span className="text-sm font-medium text-gray-700">Archives</span>
|
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.whatToBackup.archives')}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500">Expired gallery archives</p>
|
<p className="text-xs text-gray-500">{t('backup.configuration.whatToBackup.archivesHelp')}</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -513,9 +516,9 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
<div className="ml-3">
|
<div className="ml-3">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Image className="h-4 w-4 text-gray-400" />
|
<Image className="h-4 w-4 text-gray-400" />
|
||||||
<span className="text-sm font-medium text-gray-700">Thumbnails</span>
|
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.whatToBackup.thumbnails')}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500">Generated thumbnail images (can be regenerated)</p>
|
<p className="text-xs text-gray-500">{t('backup.configuration.whatToBackup.thumbnailsHelp')}</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -523,7 +526,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
|
|
||||||
{/* Advanced Options */}
|
{/* Advanced Options */}
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Advanced Options</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">{t('backup.configuration.advancedOptions.title')}</h3>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
@@ -534,8 +537,8 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||||
/>
|
/>
|
||||||
<div className="ml-3">
|
<div className="ml-3">
|
||||||
<span className="text-sm font-medium text-gray-700">Enable Compression</span>
|
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.advancedOptions.compression')}</span>
|
||||||
<p className="text-xs text-gray-500">Reduce backup size with gzip compression</p>
|
<p className="text-xs text-gray-500">{t('backup.configuration.advancedOptions.compressionHelp')}</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -548,22 +551,22 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||||
/>
|
/>
|
||||||
<div className="ml-3">
|
<div className="ml-3">
|
||||||
<span className="text-sm font-medium text-gray-700">Enable Encryption</span>
|
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.advancedOptions.encryption')}</span>
|
||||||
<p className="text-xs text-gray-500">Encrypt backups with AES-256</p>
|
<p className="text-xs text-gray-500">{t('backup.configuration.advancedOptions.encryptionHelp')}</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{formData.backup_encryption && (
|
{formData.backup_encryption && (
|
||||||
<div className="ml-7">
|
<div className="ml-7">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Encryption Passphrase
|
{t('backup.configuration.advancedOptions.encryptionPassphrase')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
type={showSecrets.encryption_passphrase ? 'text' : 'password'}
|
type={showSecrets.encryption_passphrase ? 'text' : 'password'}
|
||||||
value={formData.backup_encryption_passphrase}
|
value={formData.backup_encryption_passphrase}
|
||||||
onChange={(e) => handleChange('backup_encryption_passphrase', e.target.value)}
|
onChange={(e) => handleChange('backup_encryption_passphrase', e.target.value)}
|
||||||
placeholder="Enter a strong passphrase"
|
placeholder={t('backup.configuration.advancedOptions.encryptionPassphraseHelp')}
|
||||||
required={formData.backup_encryption}
|
required={formData.backup_encryption}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -576,7 +579,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-red-600">
|
<p className="mt-1 text-xs text-red-600">
|
||||||
<AlertCircle className="inline h-3 w-3 mr-1" />
|
<AlertCircle className="inline h-3 w-3 mr-1" />
|
||||||
Store this passphrase securely! You'll need it to restore encrypted backups.
|
{t('backup.configuration.advancedOptions.encryptionPassphraseHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -593,12 +596,12 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
{isSaving ? (
|
{isSaving ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Saving...
|
{t('backup.configuration.savingSettings')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
Save Configuration
|
{t('backup.configuration.saveSettings')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Database,
|
Database,
|
||||||
@@ -46,6 +47,7 @@ const formatBytes = (bytes) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }) => {
|
export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const lastBackup = status?.lastBackup;
|
const lastBackup = status?.lastBackup;
|
||||||
const statistics = lastBackup?.statistics || {};
|
const statistics = lastBackup?.statistics || {};
|
||||||
const isConfigured = config && config.backup_destination_type;
|
const isConfigured = config && config.backup_destination_type;
|
||||||
@@ -53,22 +55,22 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
|||||||
|
|
||||||
// Calculate backup health score
|
// Calculate backup health score
|
||||||
const getHealthScore = () => {
|
const getHealthScore = () => {
|
||||||
if (!lastBackup) return { score: 0, status: 'critical', message: 'No backups found' };
|
if (!lastBackup) return { score: 0, status: 'critical', message: t('backup.dashboard.healthMessages.noBackups') };
|
||||||
|
|
||||||
const hoursSinceBackup = (Date.now() - new Date(lastBackup.created_at)) / (1000 * 60 * 60);
|
const hoursSinceBackup = (Date.now() - new Date(lastBackup.created_at)) / (1000 * 60 * 60);
|
||||||
|
|
||||||
if (lastBackup.status === 'failed') {
|
if (lastBackup.status === 'failed') {
|
||||||
return { score: 0, status: 'critical', message: 'Last backup failed' };
|
return { score: 0, status: 'critical', message: t('backup.dashboard.healthMessages.lastBackupFailed') };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hoursSinceBackup < 24) {
|
if (hoursSinceBackup < 24) {
|
||||||
return { score: 100, status: 'excellent', message: 'Backup is up to date' };
|
return { score: 100, status: 'excellent', message: t('backup.dashboard.healthMessages.upToDate') };
|
||||||
} else if (hoursSinceBackup < 48) {
|
} else if (hoursSinceBackup < 48) {
|
||||||
return { score: 75, status: 'good', message: 'Backup is recent' };
|
return { score: 75, status: 'good', message: t('backup.dashboard.healthMessages.recent') };
|
||||||
} else if (hoursSinceBackup < 168) { // 1 week
|
} else if (hoursSinceBackup < 168) { // 1 week
|
||||||
return { score: 50, status: 'warning', message: 'Backup is getting old' };
|
return { score: 50, status: 'warning', message: t('backup.dashboard.healthMessages.gettingOld') };
|
||||||
} else {
|
} else {
|
||||||
return { score: 25, status: 'critical', message: 'Backup is outdated' };
|
return { score: 25, status: 'critical', message: t('backup.dashboard.healthMessages.outdated') };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,10 +91,10 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
|||||||
<AlertTriangle className="h-5 w-5 text-amber-400 mt-0.5" />
|
<AlertTriangle className="h-5 w-5 text-amber-400 mt-0.5" />
|
||||||
<div className="ml-3">
|
<div className="ml-3">
|
||||||
<h3 className="text-sm font-medium text-amber-800">
|
<h3 className="text-sm font-medium text-amber-800">
|
||||||
Backup Not Configured
|
{t('backup.dashboard.notConfigured.title')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-sm text-amber-700">
|
<p className="mt-1 text-sm text-amber-700">
|
||||||
Please configure backup settings in the Configuration tab before running backups.
|
{t('backup.dashboard.notConfigured.message')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,7 +104,7 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
|||||||
{/* Health Score Card */}
|
{/* Health Score Card */}
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-semibold text-gray-900">Backup Health</h3>
|
<h3 className="text-lg font-semibold text-gray-900">{t('backup.dashboard.health.title')}</h3>
|
||||||
<span className={`px-3 py-1 rounded-full text-sm font-medium bg-${healthColors[health.status]}-100 text-${healthColors[health.status]}-700`}>
|
<span className={`px-3 py-1 rounded-full text-sm font-medium bg-${healthColors[health.status]}-100 text-${healthColors[health.status]}-700`}>
|
||||||
{health.status.charAt(0).toUpperCase() + health.status.slice(1)}
|
{health.status.charAt(0).toUpperCase() + health.status.slice(1)}
|
||||||
</span>
|
</span>
|
||||||
@@ -153,12 +155,12 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
|||||||
{isBackupRunning ? (
|
{isBackupRunning ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Running...
|
{t('backup.dashboard.actions.running')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Play className="mr-2 h-4 w-4" />
|
<Play className="mr-2 h-4 w-4" />
|
||||||
Run Backup Now
|
{t('backup.dashboard.actions.runBackupNow')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -170,23 +172,23 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={FileArchive}
|
icon={FileArchive}
|
||||||
label="Total Backups"
|
label={t('backup.dashboard.stats.totalBackups')}
|
||||||
value={status?.totalBackups || 0}
|
value={status?.totalBackups || 0}
|
||||||
color="blue"
|
color="blue"
|
||||||
subtext={lastBackup ? `Last: ${format(new Date(lastBackup.created_at), 'PP')}` : 'No backups yet'}
|
subtext={lastBackup ? `${t('backup.dashboard.stats.last')}: ${format(new Date(lastBackup.created_at), 'PP')}` : t('backup.dashboard.stats.noBackupsYet')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={HardDrive}
|
icon={HardDrive}
|
||||||
label="Backup Size"
|
label={t('backup.dashboard.stats.backupSize')}
|
||||||
value={formatBytes(statistics.total_size || 0)}
|
value={formatBytes(statistics.total_size || 0)}
|
||||||
color="green"
|
color="green"
|
||||||
subtext={`${statistics.files_processed || 0} files`}
|
subtext={`${statistics.files_processed || 0} ${t('backup.dashboard.stats.files')}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={Clock}
|
icon={Clock}
|
||||||
label="Last Duration"
|
label={t('backup.dashboard.stats.lastDuration')}
|
||||||
value={lastBackup ? `${Math.round(lastBackup.duration_seconds / 60)}m` : 'N/A'}
|
value={lastBackup ? `${Math.round(lastBackup.duration_seconds / 60)}m` : 'N/A'}
|
||||||
color="purple"
|
color="purple"
|
||||||
subtext={lastBackup ? format(new Date(lastBackup.created_at), 'p') : ''}
|
subtext={lastBackup ? format(new Date(lastBackup.created_at), 'p') : ''}
|
||||||
@@ -194,17 +196,17 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
|||||||
|
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={Shield}
|
icon={Shield}
|
||||||
label="Backup Status"
|
label={t('backup.dashboard.stats.backupStatus')}
|
||||||
value={isEnabled ? 'Active' : 'Inactive'}
|
value={isEnabled ? t('backup.dashboard.stats.active') : t('backup.dashboard.stats.inactive')}
|
||||||
color={isEnabled ? 'green' : 'gray'}
|
color={isEnabled ? 'green' : 'gray'}
|
||||||
subtext={config?.backup_destination_type || 'Not configured'}
|
subtext={config?.backup_destination_type || t('backup.dashboard.notConfigured.title')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Activity */}
|
{/* Recent Activity */}
|
||||||
{status?.recentBackups && status.recentBackups.length > 0 && (
|
{status?.recentBackups && status.recentBackups.length > 0 && (
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Recent Backup Activity</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">{t('backup.dashboard.recentActivity.title')}</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{status.recentBackups.slice(0, 5).map((backup) => (
|
{status.recentBackups.slice(0, 5).map((backup) => (
|
||||||
<div key={backup.id} className="flex items-center justify-between py-3 border-b border-gray-100 last:border-0">
|
<div key={backup.id} className="flex items-center justify-between py-3 border-b border-gray-100 last:border-0">
|
||||||
@@ -218,7 +220,7 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
|||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900">
|
<p className="font-medium text-gray-900">
|
||||||
{backup.backup_type} backup
|
{t('backup.dashboard.backupType', { type: backup.backup_type })}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
{format(new Date(backup.created_at), 'PPp')}
|
{format(new Date(backup.created_at), 'PPp')}
|
||||||
@@ -242,7 +244,7 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
|||||||
{/* Storage Status */}
|
{/* Storage Status */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Backup Coverage</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">{t('backup.dashboard.coverage.title')}</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
@@ -252,34 +254,34 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
|||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
statistics.database_backed_up ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700'
|
statistics.database_backed_up ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700'
|
||||||
}`}>
|
}`}>
|
||||||
{statistics.database_backed_up ? 'Backed up' : 'Not backed up'}
|
{statistics.database_backed_up ? t('backup.dashboard.coverage.included') : t('backup.dashboard.coverage.excluded')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Image className="h-5 w-5 text-gray-400" />
|
<Image className="h-5 w-5 text-gray-400" />
|
||||||
<span className="text-gray-700">Photos</span>
|
<span className="text-gray-700">{t('backup.configuration.whatToBackup.photos')}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-sm text-gray-500">
|
||||||
{statistics.photos_backed_up || 0} of {statistics.total_photos || 0}
|
{statistics.photos_backed_up || 0} {t('common.of')} {statistics.total_photos || 0}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<FileArchive className="h-5 w-5 text-gray-400" />
|
<FileArchive className="h-5 w-5 text-gray-400" />
|
||||||
<span className="text-gray-700">Archives</span>
|
<span className="text-gray-700">{t('backup.configuration.whatToBackup.archives')}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-sm text-gray-500">
|
||||||
{statistics.archives_backed_up || 0} files
|
{statistics.archives_backed_up || 0} {t('backup.dashboard.stats.files')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Destination Info</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">{t('backup.dashboard.storageDestination')}</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
{config?.backup_destination_type === 's3' ? (
|
{config?.backup_destination_type === 's3' ? (
|
||||||
@@ -292,8 +294,8 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900">
|
<p className="font-medium text-gray-900">
|
||||||
{config?.backup_destination_type
|
{config?.backup_destination_type
|
||||||
? config.backup_destination_type.toUpperCase()
|
? t(`backup.configuration.destinationTypes.${config.backup_destination_type}.name`)
|
||||||
: 'Not Configured'}
|
: t('backup.dashboard.notConfigured.title')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
{config?.backup_destination_type === 's3' && config?.backup_s3_bucket
|
{config?.backup_destination_type === 's3' && config?.backup_s3_bucket
|
||||||
@@ -302,7 +304,7 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
|||||||
? `Path: ${config.backup_destination_path}`
|
? `Path: ${config.backup_destination_path}`
|
||||||
: config?.backup_destination_type === 'rsync' && config?.backup_rsync_host
|
: config?.backup_destination_type === 'rsync' && config?.backup_rsync_host
|
||||||
? `Host: ${config.backup_rsync_host}`
|
? `Host: ${config.backup_rsync_host}`
|
||||||
: 'No destination set'}
|
: t('backup.dashboard.noDestinationSet')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -312,7 +314,7 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
|||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Info className="h-4 w-4 text-gray-400" />
|
<Info className="h-4 w-4 text-gray-400" />
|
||||||
<span className="text-sm text-gray-600">
|
<span className="text-sm text-gray-600">
|
||||||
Backups retained for {config.backup_retention_days} days
|
{t('backup.configuration.schedule.retentionDays')} {config.backup_retention_days} {t('backup.configuration.schedule.retentionHelp').replace('days (older backups will be automatically deleted)', '')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -41,6 +42,7 @@ const formatBytes = (bytes) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const BackupHistory = () => {
|
export const BackupHistory = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [expandedRows, setExpandedRows] = useState(new Set());
|
const [expandedRows, setExpandedRows] = useState(new Set());
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [filterStatus, setFilterStatus] = useState('all');
|
const [filterStatus, setFilterStatus] = useState('all');
|
||||||
@@ -110,7 +112,7 @@ export const BackupHistory = () => {
|
|||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search backups..."
|
placeholder={t('backup.history.searchPlaceholder')}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
@@ -149,22 +151,22 @@ export const BackupHistory = () => {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-gray-50 border-b border-gray-200">
|
<tr className="bg-gray-50 border-b border-gray-200">
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Status
|
{t('backup.history.columns.status')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Date & Time
|
{t('backup.history.columns.dateTime')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Type
|
{t('backup.history.columns.type')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Size
|
{t('backup.history.columns.size')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Duration
|
{t('backup.history.columns.duration')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Actions
|
{t('backup.history.columns.actions')}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -215,7 +217,7 @@ export const BackupHistory = () => {
|
|||||||
{formatBytes(stats.total_size || 0)}
|
{formatBytes(stats.total_size || 0)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
{stats.files_processed || 0} files
|
{stats.files_processed || 0} {t('backup.dashboard.stats.files')}
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
@@ -228,7 +230,7 @@ export const BackupHistory = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => toggleRowExpansion(backup.id)}
|
onClick={() => toggleRowExpansion(backup.id)}
|
||||||
className="text-gray-400 hover:text-gray-600"
|
className="text-gray-400 hover:text-gray-600"
|
||||||
title="View details"
|
title={t('backup.actions.view')}
|
||||||
>
|
>
|
||||||
{isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
{isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
||||||
</button>
|
</button>
|
||||||
@@ -236,7 +238,7 @@ export const BackupHistory = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => window.open(`/admin/backup/download/${backup.id}`, '_blank')}
|
onClick={() => window.open(`/admin/backup/download/${backup.id}`, '_blank')}
|
||||||
className="text-gray-400 hover:text-gray-600"
|
className="text-gray-400 hover:text-gray-600"
|
||||||
title="Download backup"
|
title={t('backup.actions.download')}
|
||||||
>
|
>
|
||||||
<Download size={20} />
|
<Download size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -244,7 +246,7 @@ export const BackupHistory = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(backup)}
|
onClick={() => handleDelete(backup)}
|
||||||
className="text-gray-400 hover:text-red-600"
|
className="text-gray-400 hover:text-red-600"
|
||||||
title="Delete backup"
|
title={t('backup.actions.delete')}
|
||||||
disabled={deleteMutation.isLoading}
|
disabled={deleteMutation.isLoading}
|
||||||
>
|
>
|
||||||
<Trash2 size={20} />
|
<Trash2 size={20} />
|
||||||
@@ -260,19 +262,19 @@ export const BackupHistory = () => {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{/* Backup Details */}
|
{/* Backup Details */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="font-medium text-gray-900">Backup Details</h4>
|
<h4 className="font-medium text-gray-900">{t('backup.history.details.backupDetails')}</h4>
|
||||||
<div className="text-sm space-y-1">
|
<div className="text-sm space-y-1">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">Destination:</span>
|
<span className="text-gray-500">{t('backup.history.details.destination')}:</span>
|
||||||
<span className="text-gray-900">{backup.destination_type || 'Unknown'}</span>
|
<span className="text-gray-900">{backup.destination_type || 'Unknown'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">Started:</span>
|
<span className="text-gray-500">{t('backup.history.details.started')}:</span>
|
||||||
<span className="text-gray-900">{format(new Date(backup.created_at), 'p')}</span>
|
<span className="text-gray-900">{format(new Date(backup.created_at), 'p')}</span>
|
||||||
</div>
|
</div>
|
||||||
{backup.completed_at && (
|
{backup.completed_at && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">Completed:</span>
|
<span className="text-gray-500">{t('backup.history.details.completed')}:</span>
|
||||||
<span className="text-gray-900">{format(new Date(backup.completed_at), 'p')}</span>
|
<span className="text-gray-900">{format(new Date(backup.completed_at), 'p')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -281,11 +283,11 @@ export const BackupHistory = () => {
|
|||||||
|
|
||||||
{/* Content Backed Up */}
|
{/* Content Backed Up */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="font-medium text-gray-900">Content Backed Up</h4>
|
<h4 className="font-medium text-gray-900">{t('backup.history.details.contentBackedUp')}</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Database className={`h-4 w-4 ${stats.database_backed_up ? 'text-green-500' : 'text-gray-300'}`} />
|
<Database className={`h-4 w-4 ${stats.database_backed_up ? 'text-green-500' : 'text-gray-300'}`} />
|
||||||
<span className="text-sm text-gray-700">Database</span>
|
<span className="text-sm text-gray-700">{t('backup.configuration.whatToBackup.database')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Image className={`h-4 w-4 ${stats.photos_backed_up > 0 ? 'text-green-500' : 'text-gray-300'}`} />
|
<Image className={`h-4 w-4 ${stats.photos_backed_up > 0 ? 'text-green-500' : 'text-gray-300'}`} />
|
||||||
@@ -305,7 +307,7 @@ export const BackupHistory = () => {
|
|||||||
{/* Error Information */}
|
{/* Error Information */}
|
||||||
{backup.error_message && (
|
{backup.error_message && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="font-medium text-red-900">Error Details</h4>
|
<h4 className="font-medium text-red-900">{t('backup.history.details.errorDetails')}</h4>
|
||||||
<p className="text-sm text-red-700 bg-red-50 p-2 rounded">
|
<p className="text-sm text-red-700 bg-red-50 p-2 rounded">
|
||||||
{backup.error_message}
|
{backup.error_message}
|
||||||
</p>
|
</p>
|
||||||
@@ -315,7 +317,7 @@ export const BackupHistory = () => {
|
|||||||
{/* Manifest Path */}
|
{/* Manifest Path */}
|
||||||
{backup.manifest_path && (
|
{backup.manifest_path && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="font-medium text-gray-900">Manifest</h4>
|
<h4 className="font-medium text-gray-900">{t('backup.history.details.manifest')}</h4>
|
||||||
<p className="text-sm text-gray-600 font-mono break-all">
|
<p className="text-sm text-gray-600 font-mono break-all">
|
||||||
{backup.manifest_path}
|
{backup.manifest_path}
|
||||||
</p>
|
</p>
|
||||||
@@ -344,7 +346,7 @@ export const BackupHistory = () => {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
Previous
|
{t('backup.history.pagination.previous')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setCurrentPage(p => Math.min(pagination.pages, p + 1))}
|
onClick={() => setCurrentPage(p => Math.min(pagination.pages, p + 1))}
|
||||||
@@ -352,17 +354,17 @@ export const BackupHistory = () => {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
Next
|
{t('backup.history.pagination.next')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-700">
|
<p className="text-sm text-gray-700">
|
||||||
Showing <span className="font-medium">{(currentPage - 1) * pagination.limit + 1}</span> to{' '}
|
{t('backup.history.pagination.showing', {
|
||||||
<span className="font-medium">
|
from: (currentPage - 1) * pagination.limit + 1,
|
||||||
{Math.min(currentPage * pagination.limit, pagination.total)}
|
to: Math.min(currentPage * pagination.limit, pagination.total),
|
||||||
</span>{' '}
|
total: pagination.total
|
||||||
of <span className="font-medium">{pagination.total}</span> results
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -372,7 +374,7 @@ export const BackupHistory = () => {
|
|||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Previous
|
{t('backup.history.pagination.previous')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{[...Array(Math.min(5, pagination.pages))].map((_, i) => {
|
{[...Array(Math.min(5, pagination.pages))].map((_, i) => {
|
||||||
@@ -397,7 +399,7 @@ export const BackupHistory = () => {
|
|||||||
disabled={currentPage === pagination.pages}
|
disabled={currentPage === pagination.pages}
|
||||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Next
|
{t('backup.history.pagination.next')}
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
MessageSquare,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Trash2,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
User
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { parseISO } from 'date-fns';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
import { Card, Loading, Button } from '../common';
|
||||||
|
import { feedbackService } from '../../services/feedback.service';
|
||||||
|
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||||
|
|
||||||
|
interface FeedbackModerationPanelProps {
|
||||||
|
eventId: number;
|
||||||
|
className?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
maxItems?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = ({
|
||||||
|
eventId,
|
||||||
|
className = '',
|
||||||
|
compact = false,
|
||||||
|
maxItems = 5
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { format } = useLocalizedDate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
|
||||||
|
// Fetch pending feedback
|
||||||
|
const { data: feedbackData, isLoading } = useQuery({
|
||||||
|
queryKey: ['event-feedback-moderation', eventId],
|
||||||
|
queryFn: () => feedbackService.getEventFeedback(eventId.toString(), {
|
||||||
|
type: 'comment',
|
||||||
|
status: 'pending',
|
||||||
|
limit: showAll ? 100 : maxItems
|
||||||
|
}),
|
||||||
|
refetchInterval: 30000 // Refresh every 30 seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
// Moderation mutation
|
||||||
|
const moderateMutation = useMutation({
|
||||||
|
mutationFn: ({ feedbackId, action }: { feedbackId: string; action: 'approve' | 'hide' | 'reject' }) =>
|
||||||
|
feedbackService.moderateFeedback(feedbackId, action),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['event-feedback-moderation', eventId] });
|
||||||
|
toast.success(t('feedback.moderationSuccess'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete mutation
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (feedbackId: string) => feedbackService.deleteFeedback(feedbackId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['event-feedback-moderation', eventId] });
|
||||||
|
toast.success(t('feedback.deleted'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<div className="p-6">
|
||||||
|
<Loading />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingComments = feedbackData?.feedback || [];
|
||||||
|
const hasPending = pendingComments.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900">
|
||||||
|
{t('feedback.pendingModeration', 'Pending Moderation')}
|
||||||
|
</h2>
|
||||||
|
{hasPending && (
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||||
|
{pendingComments.length} {t('feedback.pending', 'pending')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasPending ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-3" />
|
||||||
|
<p className="text-neutral-600">{t('feedback.noPendingComments', 'No comments pending moderation')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{pendingComments.slice(0, showAll ? undefined : maxItems).map((item) => (
|
||||||
|
<div key={item.id} className="border border-neutral-200 rounded-lg p-4 hover:bg-neutral-50">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="w-10 h-10 bg-neutral-100 rounded-full flex items-center justify-center">
|
||||||
|
<User className="w-5 h-5 text-neutral-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="font-medium text-neutral-900">
|
||||||
|
{item.guest_name || t('feedback.anonymous', 'Anonymous')}
|
||||||
|
</span>
|
||||||
|
<span className="text-neutral-500">•</span>
|
||||||
|
<span className="text-neutral-500">
|
||||||
|
{format(parseISO(item.created_at), 'MMM d, h:mm a')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-neutral-700">{item.comment}</p>
|
||||||
|
{item.photo_filename && (
|
||||||
|
<p className="mt-1 text-xs text-neutral-500">
|
||||||
|
{t('feedback.onPhoto', 'On photo')}: {item.photo_filename}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2 mt-3">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
leftIcon={<CheckCircle className="w-4 h-4" />}
|
||||||
|
onClick={() => moderateMutation.mutate({
|
||||||
|
feedbackId: item.id.toString(),
|
||||||
|
action: 'approve'
|
||||||
|
})}
|
||||||
|
isLoading={moderateMutation.isPending}
|
||||||
|
>
|
||||||
|
{t('feedback.approve', 'Approve')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
leftIcon={<EyeOff className="w-4 h-4" />}
|
||||||
|
onClick={() => moderateMutation.mutate({
|
||||||
|
feedbackId: item.id.toString(),
|
||||||
|
action: 'hide'
|
||||||
|
})}
|
||||||
|
isLoading={moderateMutation.isPending}
|
||||||
|
>
|
||||||
|
{t('feedback.hide', 'Hide')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
leftIcon={<Trash2 className="w-4 h-4" />}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(t('feedback.confirmDelete', 'Are you sure you want to delete this comment?'))) {
|
||||||
|
deleteMutation.mutate(item.id.toString());
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isLoading={deleteMutation.isPending}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
{t('common.delete', 'Delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{pendingComments.length > maxItems && !showAll && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAll(true)}
|
||||||
|
className="w-full text-center py-2 text-sm text-primary-600 hover:text-primary-700 font-medium"
|
||||||
|
>
|
||||||
|
{t('feedback.showAll', 'Show all {{count}} pending comments', { count: pendingComments.length })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick link to full feedback page */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-neutral-200">
|
||||||
|
<a
|
||||||
|
href={`/admin/events/${eventId}/feedback`}
|
||||||
|
className="text-sm text-primary-600 hover:text-primary-700 font-medium flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-4 h-4" />
|
||||||
|
{t('feedback.viewAllFeedback', 'View all feedback & settings')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
FeedbackModerationPanel.displayName = 'FeedbackModerationPanel';
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { MessageSquare, Star, Heart, Bookmark, Shield, Eye } from 'lucide-react';
|
||||||
|
import { Card } from '../common';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface FeedbackSettingsProps {
|
||||||
|
settings: FeedbackSettings;
|
||||||
|
onChange: (settings: FeedbackSettings) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeedbackSettings {
|
||||||
|
feedback_enabled: boolean;
|
||||||
|
allow_ratings: boolean;
|
||||||
|
allow_likes: boolean;
|
||||||
|
allow_comments: boolean;
|
||||||
|
allow_favorites: boolean;
|
||||||
|
require_name_email: boolean;
|
||||||
|
moderate_comments: boolean;
|
||||||
|
show_feedback_to_guests: boolean;
|
||||||
|
enable_rate_limiting: boolean;
|
||||||
|
rate_limit_window_minutes?: number;
|
||||||
|
rate_limit_max_requests?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||||
|
settings,
|
||||||
|
onChange,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const handleToggle = (field: keyof FeedbackSettings) => {
|
||||||
|
onChange({
|
||||||
|
...settings,
|
||||||
|
[field]: !settings[field]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNumberChange = (field: keyof FeedbackSettings, value: string) => {
|
||||||
|
const numValue = parseInt(value, 10);
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
onChange({
|
||||||
|
...settings,
|
||||||
|
[field]: numValue
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 flex items-center gap-2">
|
||||||
|
<MessageSquare className="w-5 h-5" />
|
||||||
|
{t('feedback.settings.title', 'Guest Feedback Settings')}
|
||||||
|
</h2>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.feedback_enabled}
|
||||||
|
onChange={() => handleToggle('feedback_enabled')}
|
||||||
|
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-neutral-700">
|
||||||
|
{t('feedback.settings.enableFeedback', 'Enable feedback')}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settings.feedback_enabled && (
|
||||||
|
<>
|
||||||
|
{/* Feedback Types */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-neutral-700">
|
||||||
|
{t('feedback.settings.feedbackTypes', 'Feedback Types')}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.allow_ratings}
|
||||||
|
onChange={() => handleToggle('allow_ratings')}
|
||||||
|
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<Star className="w-5 h-5 text-neutral-600" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-neutral-900">
|
||||||
|
{t('feedback.settings.ratings', 'Star Ratings')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-neutral-500">
|
||||||
|
{t('feedback.settings.ratingsDesc', 'Allow guests to rate photos (1-5 stars)')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.allow_likes}
|
||||||
|
onChange={() => handleToggle('allow_likes')}
|
||||||
|
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<Heart className="w-5 h-5 text-neutral-600" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-neutral-900">
|
||||||
|
{t('feedback.settings.likes', 'Likes')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-neutral-500">
|
||||||
|
{t('feedback.settings.likesDesc', 'Simple like/unlike functionality')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.allow_comments}
|
||||||
|
onChange={() => handleToggle('allow_comments')}
|
||||||
|
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<MessageSquare className="w-5 h-5 text-neutral-600" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-neutral-900">
|
||||||
|
{t('feedback.settings.comments', 'Comments')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-neutral-500">
|
||||||
|
{t('feedback.settings.commentsDesc', 'Text comments on photos')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.allow_favorites}
|
||||||
|
onChange={() => handleToggle('allow_favorites')}
|
||||||
|
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<Bookmark className="w-5 h-5 text-neutral-600" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-neutral-900">
|
||||||
|
{t('feedback.settings.favorites', 'Favorites')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-neutral-500">
|
||||||
|
{t('feedback.settings.favoritesDesc', 'Mark photos as favorites')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-4" />
|
||||||
|
|
||||||
|
{/* Privacy & Moderation */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-neutral-700">
|
||||||
|
{t('feedback.settings.privacyModeration', 'Privacy & Moderation')}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.require_name_email}
|
||||||
|
onChange={() => handleToggle('require_name_email')}
|
||||||
|
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-neutral-900">
|
||||||
|
{t('feedback.settings.requireInfo', 'Require Name & Email')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-neutral-500">
|
||||||
|
{t('feedback.settings.requireInfoDesc', 'Guests must provide name and email to leave feedback')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.moderate_comments}
|
||||||
|
onChange={() => handleToggle('moderate_comments')}
|
||||||
|
disabled={!settings.allow_comments}
|
||||||
|
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500 disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<Shield className="w-5 h-5 text-neutral-600" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-neutral-900">
|
||||||
|
{t('feedback.settings.moderateComments', 'Moderate Comments')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-neutral-500">
|
||||||
|
{t('feedback.settings.moderateCommentsDesc', 'Comments require approval before being visible')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.show_feedback_to_guests}
|
||||||
|
onChange={() => handleToggle('show_feedback_to_guests')}
|
||||||
|
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<Eye className="w-5 h-5 text-neutral-600" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-neutral-900">
|
||||||
|
{t('feedback.settings.showToGuests', 'Show Feedback to Guests')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-neutral-500">
|
||||||
|
{t('feedback.settings.showToGuestsDesc', 'Other guests can see ratings, likes, and approved comments')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-4" />
|
||||||
|
|
||||||
|
{/* Rate Limiting */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.enable_rate_limiting}
|
||||||
|
onChange={() => handleToggle('enable_rate_limiting')}
|
||||||
|
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-neutral-900">
|
||||||
|
{t('feedback.settings.enableRateLimiting', 'Enable Rate Limiting')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-neutral-500">
|
||||||
|
{t('feedback.settings.rateLimitingDesc', 'Prevent spam by limiting feedback frequency')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{settings.enable_rate_limiting && (
|
||||||
|
<div className="grid grid-cols-2 gap-4 ml-7">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-600 mb-1">
|
||||||
|
{t('feedback.settings.timeWindow', 'Time Window (minutes)')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="60"
|
||||||
|
value={settings.rate_limit_window_minutes || 15}
|
||||||
|
onChange={(e) => handleNumberChange('rate_limit_window_minutes', e.target.value)}
|
||||||
|
className="w-full px-3 py-1.5 text-sm border border-neutral-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-600 mb-1">
|
||||||
|
{t('feedback.settings.maxRequests', 'Max Requests')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value={settings.rate_limit_max_requests || 10}
|
||||||
|
onChange={(e) => handleNumberChange('rate_limit_max_requests', e.target.value)}
|
||||||
|
className="w-full px-3 py-1.5 text-sm border border-neutral-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -256,7 +256,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
<div className="flex justify-between text-sm text-neutral-600 mb-1">
|
<div className="flex justify-between text-sm text-neutral-600 mb-1">
|
||||||
<span>
|
<span>
|
||||||
{t('upload.uploading')}
|
{t('upload.uploading')}
|
||||||
{totalChunks > 1 && ` (${t('common.chunk') || 'Chunk'} ${currentChunk}/${totalChunks})`}
|
{totalChunks > 1 && ` (${t('common.chunk')} ${currentChunk}/${totalChunks})`}
|
||||||
</span>
|
</span>
|
||||||
<span>{uploadProgress}%</span>
|
<span>{uploadProgress}%</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -268,7 +268,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
</div>
|
</div>
|
||||||
{totalChunks > 1 && (
|
{totalChunks > 1 && (
|
||||||
<p className="text-xs text-neutral-500 mt-1">
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
{t('upload.uploadingChunks') || `Uploading ${selectedFiles.length} files in ${totalChunks} batches...`}
|
{t('upload.uploadingChunks', { count: selectedFiles.length, total: totalChunks })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -28,47 +29,49 @@ import { useQuery, useMutation } from '@tanstack/react-query';
|
|||||||
import { Button, Card, Input, Loading } from '../common';
|
import { Button, Card, Input, Loading } from '../common';
|
||||||
import { api } from '../../config/api';
|
import { api } from '../../config/api';
|
||||||
|
|
||||||
const steps = [
|
|
||||||
{ id: 'source', title: 'Select Source' },
|
|
||||||
{ id: 'backup', title: 'Choose Backup' },
|
|
||||||
{ id: 'options', title: 'Restore Options' },
|
|
||||||
{ id: 'confirm', title: 'Review & Confirm' },
|
|
||||||
{ id: 'progress', title: 'Restore Progress' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const restoreTypes = [
|
|
||||||
{
|
|
||||||
id: 'full',
|
|
||||||
name: 'Full Restore',
|
|
||||||
description: 'Restore everything including database, photos, and archives',
|
|
||||||
icon: RefreshCw,
|
|
||||||
warning: 'This will replace all current data'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'database',
|
|
||||||
name: 'Database Only',
|
|
||||||
description: 'Restore only the database (settings, events, users)',
|
|
||||||
icon: Database,
|
|
||||||
warning: 'Current database will be replaced'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'files',
|
|
||||||
name: 'Files Only',
|
|
||||||
description: 'Restore only photos and archives',
|
|
||||||
icon: Image,
|
|
||||||
warning: 'Existing files may be overwritten'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'selective',
|
|
||||||
name: 'Selective Restore',
|
|
||||||
description: 'Choose specific items to restore',
|
|
||||||
icon: CheckCircle,
|
|
||||||
warning: 'Only selected items will be restored'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const RestoreWizard = () => {
|
export const RestoreWizard = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ id: 'source', title: t('backup.restore.steps.selectSource') },
|
||||||
|
{ id: 'backup', title: t('backup.restore.steps.chooseBackup') },
|
||||||
|
{ id: 'options', title: t('backup.restore.steps.restoreOptions') },
|
||||||
|
{ id: 'confirm', title: t('backup.restore.steps.reviewConfirm') },
|
||||||
|
{ id: 'progress', title: t('backup.restore.steps.restoreProgress') }
|
||||||
|
];
|
||||||
|
|
||||||
|
const restoreTypes = [
|
||||||
|
{
|
||||||
|
id: 'full',
|
||||||
|
name: t('backup.restore.restoreTypes.full.name'),
|
||||||
|
description: t('backup.restore.restoreTypes.full.description'),
|
||||||
|
icon: RefreshCw,
|
||||||
|
warning: t('backup.restore.restoreTypes.full.warning')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'database',
|
||||||
|
name: t('backup.restore.restoreTypes.database.name'),
|
||||||
|
description: t('backup.restore.restoreTypes.database.description'),
|
||||||
|
icon: Database,
|
||||||
|
warning: t('backup.restore.restoreTypes.database.warning')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'files',
|
||||||
|
name: t('backup.restore.restoreTypes.files.name'),
|
||||||
|
description: t('backup.restore.restoreTypes.files.description'),
|
||||||
|
icon: Image,
|
||||||
|
warning: t('backup.restore.restoreTypes.files.warning')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'selective',
|
||||||
|
name: t('backup.restore.restoreTypes.selective.name'),
|
||||||
|
description: t('backup.restore.restoreTypes.selective.description'),
|
||||||
|
icon: CheckCircle,
|
||||||
|
warning: t('backup.restore.restoreTypes.selective.warning')
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
const [restoreData, setRestoreData] = useState({
|
const [restoreData, setRestoreData] = useState({
|
||||||
source: null,
|
source: null,
|
||||||
sourceConfig: {},
|
sourceConfig: {},
|
||||||
@@ -184,8 +187,8 @@ export const RestoreWizard = () => {
|
|||||||
const renderSourceSelection = () => (
|
const renderSourceSelection = () => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Select Backup Source</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t('backup.restore.source.title')}</h3>
|
||||||
<p className="text-sm text-gray-600">Choose where to restore the backup from</p>
|
<p className="text-sm text-gray-600">{t('backup.restore.source.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
@@ -200,8 +203,8 @@ export const RestoreWizard = () => {
|
|||||||
<HardDrive className={`h-12 w-12 mb-3 mx-auto ${
|
<HardDrive className={`h-12 w-12 mb-3 mx-auto ${
|
||||||
restoreData.source === 'local' ? 'text-primary' : 'text-gray-400'
|
restoreData.source === 'local' ? 'text-primary' : 'text-gray-400'
|
||||||
}`} />
|
}`} />
|
||||||
<h4 className="font-medium text-gray-900">Local Backup</h4>
|
<h4 className="font-medium text-gray-900">{t('backup.restore.source.local.name')}</h4>
|
||||||
<p className="text-xs text-gray-500 mt-1">Restore from local filesystem</p>
|
<p className="text-xs text-gray-500 mt-1">{t('backup.restore.source.local.description')}</p>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -215,8 +218,8 @@ export const RestoreWizard = () => {
|
|||||||
<Cloud className={`h-12 w-12 mb-3 mx-auto ${
|
<Cloud className={`h-12 w-12 mb-3 mx-auto ${
|
||||||
restoreData.source === 's3' ? 'text-primary' : 'text-gray-400'
|
restoreData.source === 's3' ? 'text-primary' : 'text-gray-400'
|
||||||
}`} />
|
}`} />
|
||||||
<h4 className="font-medium text-gray-900">S3 Storage</h4>
|
<h4 className="font-medium text-gray-900">{t('backup.restore.source.s3.name')}</h4>
|
||||||
<p className="text-xs text-gray-500 mt-1">Restore from S3 bucket</p>
|
<p className="text-xs text-gray-500 mt-1">{t('backup.restore.source.s3.description')}</p>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -230,18 +233,18 @@ export const RestoreWizard = () => {
|
|||||||
<Upload className={`h-12 w-12 mb-3 mx-auto ${
|
<Upload className={`h-12 w-12 mb-3 mx-auto ${
|
||||||
restoreData.source === 'upload' ? 'text-primary' : 'text-gray-400'
|
restoreData.source === 'upload' ? 'text-primary' : 'text-gray-400'
|
||||||
}`} />
|
}`} />
|
||||||
<h4 className="font-medium text-gray-900">Upload Backup</h4>
|
<h4 className="font-medium text-gray-900">{t('backup.restore.source.upload.name')}</h4>
|
||||||
<p className="text-xs text-gray-500 mt-1">Upload a backup file</p>
|
<p className="text-xs text-gray-500 mt-1">{t('backup.restore.source.upload.description')}</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Source-specific configuration */}
|
{/* Source-specific configuration */}
|
||||||
{restoreData.source === 's3' && (
|
{restoreData.source === 's3' && (
|
||||||
<Card className="p-4 space-y-4">
|
<Card className="p-4 space-y-4">
|
||||||
<h4 className="font-medium text-gray-900">S3 Configuration</h4>
|
<h4 className="font-medium text-gray-900">{t('backup.restore.source.configuration.s3')}</h4>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Input
|
<Input
|
||||||
placeholder="S3 Endpoint URL"
|
placeholder={t('backup.restore.source.configuration.endpoint')}
|
||||||
value={restoreData.sourceConfig.s3Endpoint || ''}
|
value={restoreData.sourceConfig.s3Endpoint || ''}
|
||||||
onChange={(e) => setRestoreData(prev => ({
|
onChange={(e) => setRestoreData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -249,7 +252,7 @@ export const RestoreWizard = () => {
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Bucket Name"
|
placeholder={t('backup.restore.source.configuration.bucket')}
|
||||||
value={restoreData.sourceConfig.s3Bucket || ''}
|
value={restoreData.sourceConfig.s3Bucket || ''}
|
||||||
onChange={(e) => setRestoreData(prev => ({
|
onChange={(e) => setRestoreData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -257,7 +260,7 @@ export const RestoreWizard = () => {
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Access Key ID"
|
placeholder={t('backup.restore.source.configuration.accessKey')}
|
||||||
value={restoreData.sourceConfig.s3AccessKey || ''}
|
value={restoreData.sourceConfig.s3AccessKey || ''}
|
||||||
onChange={(e) => setRestoreData(prev => ({
|
onChange={(e) => setRestoreData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -266,7 +269,7 @@ export const RestoreWizard = () => {
|
|||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Secret Access Key"
|
placeholder={t('backup.restore.source.configuration.secretKey')}
|
||||||
value={restoreData.sourceConfig.s3SecretKey || ''}
|
value={restoreData.sourceConfig.s3SecretKey || ''}
|
||||||
onChange={(e) => setRestoreData(prev => ({
|
onChange={(e) => setRestoreData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -281,7 +284,7 @@ export const RestoreWizard = () => {
|
|||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<Upload className="h-12 w-12 mx-auto mb-3 text-gray-400" />
|
<Upload className="h-12 w-12 mx-auto mb-3 text-gray-400" />
|
||||||
<p className="text-sm text-gray-600">Upload functionality coming soon</p>
|
<p className="text-sm text-gray-600">{t('backup.restore.source.upload.comingSoon')}</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -291,8 +294,8 @@ export const RestoreWizard = () => {
|
|||||||
const renderBackupSelection = () => (
|
const renderBackupSelection = () => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Choose Backup to Restore</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t('backup.restore.backup.title')}</h3>
|
||||||
<p className="text-sm text-gray-600">Select from available backups</p>
|
<p className="text-sm text-gray-600">{t('backup.restore.backup.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loadingBackups ? (
|
{loadingBackups ? (
|
||||||
@@ -300,7 +303,7 @@ export const RestoreWizard = () => {
|
|||||||
) : availableBackups?.length === 0 ? (
|
) : availableBackups?.length === 0 ? (
|
||||||
<Card className="p-8 text-center">
|
<Card className="p-8 text-center">
|
||||||
<FileArchive className="h-12 w-12 mx-auto mb-3 text-gray-300" />
|
<FileArchive className="h-12 w-12 mx-auto mb-3 text-gray-300" />
|
||||||
<p className="text-gray-500">No backups found in selected source</p>
|
<p className="text-gray-500">{t('backup.restore.backup.noBackupsFound')}</p>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -327,10 +330,10 @@ export const RestoreWizard = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900">
|
<p className="font-medium text-gray-900">
|
||||||
{format(new Date(backup.created_at), 'PPP')} at {format(new Date(backup.created_at), 'p')}
|
{format(new Date(backup.created_at), 'PPP')} {t('backup.restore.backup.at')} {format(new Date(backup.created_at), 'p')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
{backup.backup_type} backup • {formatBytes(backup.total_size || 0)}
|
{t('backup.dashboard.backupType', { type: backup.backup_type })} • {formatBytes(backup.total_size || 0)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -348,13 +351,13 @@ export const RestoreWizard = () => {
|
|||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<Shield className="h-5 w-5 text-amber-600 mt-0.5" />
|
<Shield className="h-5 w-5 text-amber-600 mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-amber-900">Encrypted Backup</p>
|
<p className="text-sm font-medium text-amber-900">{t('backup.restore.backup.encrypted')}</p>
|
||||||
<p className="text-sm text-amber-700 mt-1">
|
<p className="text-sm text-amber-700 mt-1">
|
||||||
You'll need to provide the encryption passphrase to restore this backup.
|
{t('backup.restore.backup.encryptedMessage')}
|
||||||
</p>
|
</p>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter encryption passphrase"
|
placeholder={t('backup.restore.backup.enterPassphrase')}
|
||||||
className="mt-3"
|
className="mt-3"
|
||||||
value={restoreData.encryptionPassphrase}
|
value={restoreData.encryptionPassphrase}
|
||||||
onChange={(e) => setRestoreData(prev => ({
|
onChange={(e) => setRestoreData(prev => ({
|
||||||
@@ -372,8 +375,8 @@ export const RestoreWizard = () => {
|
|||||||
const renderRestoreOptions = () => (
|
const renderRestoreOptions = () => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Restore Options</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t('backup.restore.options.title')}</h3>
|
||||||
<p className="text-sm text-gray-600">Choose what to restore</p>
|
<p className="text-sm text-gray-600">{t('backup.restore.options.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@@ -409,7 +412,7 @@ export const RestoreWizard = () => {
|
|||||||
|
|
||||||
{/* Additional Options */}
|
{/* Additional Options */}
|
||||||
<Card className="p-4 space-y-4">
|
<Card className="p-4 space-y-4">
|
||||||
<h4 className="font-medium text-gray-900">Additional Options</h4>
|
<h4 className="font-medium text-gray-900">{t('backup.restore.options.additionalOptions.title')}</h4>
|
||||||
|
|
||||||
<label className="flex items-start space-x-3">
|
<label className="flex items-start space-x-3">
|
||||||
<input
|
<input
|
||||||
@@ -422,9 +425,9 @@ export const RestoreWizard = () => {
|
|||||||
className="mt-1 h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
className="mt-1 h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-700">Skip Pre-Restore Backup</p>
|
<p className="text-sm font-medium text-gray-700">{t('backup.restore.options.additionalOptions.skipPreBackup')}</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
By default, a backup is created before restore. Check this to skip it.
|
{t('backup.restore.options.additionalOptions.skipPreBackupHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -440,9 +443,9 @@ export const RestoreWizard = () => {
|
|||||||
className="mt-1 h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
className="mt-1 h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-700">Force Restore</p>
|
<p className="text-sm font-medium text-gray-700">{t('backup.restore.options.additionalOptions.force')}</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
Override safety checks and warnings (use with caution)
|
{t('backup.restore.options.additionalOptions.forceHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -453,8 +456,8 @@ export const RestoreWizard = () => {
|
|||||||
const renderConfirmation = () => (
|
const renderConfirmation = () => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Review & Confirm</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t('backup.restore.confirmation.title')}</h3>
|
||||||
<p className="text-sm text-gray-600">Please review your restore configuration</p>
|
<p className="text-sm text-gray-600">{t('backup.restore.confirmation.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{validationResult ? (
|
{validationResult ? (
|
||||||
@@ -476,8 +479,8 @@ export const RestoreWizard = () => {
|
|||||||
validationResult.validation?.isValid ? 'text-green-900' : 'text-red-900'
|
validationResult.validation?.isValid ? 'text-green-900' : 'text-red-900'
|
||||||
}`}>
|
}`}>
|
||||||
{validationResult.validation?.isValid
|
{validationResult.validation?.isValid
|
||||||
? 'Validation Passed'
|
? t('backup.restore.confirmation.validation.passed')
|
||||||
: 'Validation Failed'}
|
: t('backup.restore.confirmation.validation.failed')}
|
||||||
</p>
|
</p>
|
||||||
{validationResult.validation?.errors?.length > 0 && (
|
{validationResult.validation?.errors?.length > 0 && (
|
||||||
<ul className="mt-2 text-sm text-red-700 list-disc list-inside">
|
<ul className="mt-2 text-sm text-red-700 list-disc list-inside">
|
||||||
@@ -493,20 +496,20 @@ export const RestoreWizard = () => {
|
|||||||
{/* Space Check */}
|
{/* Space Check */}
|
||||||
{validationResult.spaceCheck && (
|
{validationResult.spaceCheck && (
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<h4 className="font-medium text-gray-900 mb-3">Storage Space</h4>
|
<h4 className="font-medium text-gray-900 mb-3">{t('backup.restore.confirmation.spaceCheck.title')}</h4>
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">Required:</span>
|
<span className="text-gray-600">{t('backup.restore.confirmation.spaceCheck.required')}:</span>
|
||||||
<span className="font-medium">{formatBytes(validationResult.spaceCheck.required)}</span>
|
<span className="font-medium">{formatBytes(validationResult.spaceCheck.required)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">Available:</span>
|
<span className="text-gray-600">{t('backup.restore.confirmation.spaceCheck.available')}:</span>
|
||||||
<span className="font-medium">{formatBytes(validationResult.spaceCheck.available)}</span>
|
<span className="font-medium">{formatBytes(validationResult.spaceCheck.available)}</span>
|
||||||
</div>
|
</div>
|
||||||
{!validationResult.spaceCheck.sufficient && (
|
{!validationResult.spaceCheck.sufficient && (
|
||||||
<p className="text-red-600 text-xs mt-2">
|
<p className="text-red-600 text-xs mt-2">
|
||||||
<AlertCircle className="inline h-3 w-3 mr-1" />
|
<AlertCircle className="inline h-3 w-3 mr-1" />
|
||||||
Insufficient storage space
|
{t('backup.restore.confirmation.spaceCheck.insufficient')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -515,25 +518,25 @@ export const RestoreWizard = () => {
|
|||||||
|
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<h4 className="font-medium text-gray-900 mb-3">Restore Summary</h4>
|
<h4 className="font-medium text-gray-900 mb-3">{t('backup.restore.confirmation.summary.title')}</h4>
|
||||||
<dl className="space-y-2 text-sm">
|
<dl className="space-y-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<dt className="text-gray-600">Source:</dt>
|
<dt className="text-gray-600">{t('backup.restore.confirmation.summary.source')}:</dt>
|
||||||
<dd className="font-medium capitalize">{restoreData.source}</dd>
|
<dd className="font-medium capitalize">{restoreData.source}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<dt className="text-gray-600">Backup Date:</dt>
|
<dt className="text-gray-600">{t('backup.restore.confirmation.summary.backupDate')}:</dt>
|
||||||
<dd className="font-medium">
|
<dd className="font-medium">
|
||||||
{format(new Date(restoreData.selectedBackup.created_at), 'PPp')}
|
{format(new Date(restoreData.selectedBackup.created_at), 'PPp')}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<dt className="text-gray-600">Restore Type:</dt>
|
<dt className="text-gray-600">{t('backup.restore.confirmation.summary.restoreType')}:</dt>
|
||||||
<dd className="font-medium capitalize">{restoreData.restoreType}</dd>
|
<dd className="font-medium capitalize">{restoreData.restoreType}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<dt className="text-gray-600">Pre-backup:</dt>
|
<dt className="text-gray-600">{t('backup.restore.confirmation.summary.preBackup')}:</dt>
|
||||||
<dd className="font-medium">{restoreData.skipPreBackup ? 'Skipped' : 'Enabled'}</dd>
|
<dd className="font-medium">{restoreData.skipPreBackup ? t('backup.restore.confirmation.summary.skipped') : t('backup.restore.confirmation.summary.enabled')}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -544,11 +547,10 @@ export const RestoreWizard = () => {
|
|||||||
<AlertTriangle className="h-5 w-5 text-amber-400 mt-0.5" />
|
<AlertTriangle className="h-5 w-5 text-amber-400 mt-0.5" />
|
||||||
<div className="ml-3">
|
<div className="ml-3">
|
||||||
<h3 className="text-sm font-medium text-amber-800">
|
<h3 className="text-sm font-medium text-amber-800">
|
||||||
Important Notice
|
{t('backup.restore.confirmation.warning.title')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-sm text-amber-700">
|
<p className="mt-1 text-sm text-amber-700">
|
||||||
This restore operation will replace existing data. Make sure you have a current backup
|
{t('backup.restore.confirmation.warning.message')}
|
||||||
before proceeding. This action cannot be undone.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -557,7 +559,7 @@ export const RestoreWizard = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
|
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
|
||||||
<p className="mt-2 text-sm text-gray-600">Validating restore configuration...</p>
|
<p className="mt-2 text-sm text-gray-600">{t('backup.restore.confirmation.validation.checking')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -570,9 +572,9 @@ export const RestoreWizard = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Restore Progress</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t('backup.restore.progress.title')}</h3>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
{isRunning ? 'Restore in progress...' : 'Restore completed'}
|
{isRunning ? t('backup.restore.progress.inProgress') : t('backup.restore.progress.completed')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -580,7 +582,7 @@ export const RestoreWizard = () => {
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-gray-600">Overall Progress</span>
|
<span className="text-gray-600">{t('backup.restore.progress.overallProgress')}</span>
|
||||||
<span className="font-medium">{progress.percentage || 0}%</span>
|
<span className="font-medium">{progress.percentage || 0}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||||
@@ -591,7 +593,7 @@ export const RestoreWizard = () => {
|
|||||||
</div>
|
</div>
|
||||||
{progress.currentFile && (
|
{progress.currentFile && (
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Current: {progress.currentFile}
|
{t('backup.restore.progress.current')}: {progress.currentFile}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -599,7 +601,7 @@ export const RestoreWizard = () => {
|
|||||||
|
|
||||||
{/* Status Details */}
|
{/* Status Details */}
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h4 className="font-medium text-gray-900 mb-4">Status Details</h4>
|
<h4 className="font-medium text-gray-900 mb-4">{t('backup.restore.progress.statusDetails')}</h4>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{progress.steps?.map((step, idx) => (
|
{progress.steps?.map((step, idx) => (
|
||||||
<div key={idx} className="flex items-center space-x-3">
|
<div key={idx} className="flex items-center space-x-3">
|
||||||
@@ -629,7 +631,7 @@ export const RestoreWizard = () => {
|
|||||||
{/* Logs */}
|
{/* Logs */}
|
||||||
{progress.logs && progress.logs.length > 0 && (
|
{progress.logs && progress.logs.length > 0 && (
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h4 className="font-medium text-gray-900 mb-4">Restore Logs</h4>
|
<h4 className="font-medium text-gray-900 mb-4">{t('backup.restore.progress.restoreLogs')}</h4>
|
||||||
<div className="bg-gray-900 rounded-lg p-4 max-h-64 overflow-y-auto">
|
<div className="bg-gray-900 rounded-lg p-4 max-h-64 overflow-y-auto">
|
||||||
<pre className="text-xs text-gray-300 font-mono">
|
<pre className="text-xs text-gray-300 font-mono">
|
||||||
{progress.logs.join('\n')}
|
{progress.logs.join('\n')}
|
||||||
@@ -645,10 +647,10 @@ export const RestoreWizard = () => {
|
|||||||
<CheckCircle className="h-5 w-5 text-green-400 mt-0.5" />
|
<CheckCircle className="h-5 w-5 text-green-400 mt-0.5" />
|
||||||
<div className="ml-3">
|
<div className="ml-3">
|
||||||
<h3 className="text-sm font-medium text-green-800">
|
<h3 className="text-sm font-medium text-green-800">
|
||||||
Restore Completed Successfully
|
{t('backup.restore.progress.success.title')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-sm text-green-700">
|
<p className="mt-1 text-sm text-green-700">
|
||||||
Your data has been restored. Please verify everything is working correctly.
|
{t('backup.restore.progress.success.message')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -726,7 +728,7 @@ export const RestoreWizard = () => {
|
|||||||
disabled={currentStep === 0 || currentStep === 4}
|
disabled={currentStep === 0 || currentStep === 4}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||||
Back
|
{t('backup.restore.actions.back')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{currentStep < 4 && (
|
{currentStep < 4 && (
|
||||||
@@ -739,12 +741,12 @@ export const RestoreWizard = () => {
|
|||||||
{restoreMutation.isLoading ? (
|
{restoreMutation.isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Starting...
|
{t('backup.restore.actions.starting')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
Start Restore
|
{t('backup.restore.actions.startRestore')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -753,18 +755,18 @@ export const RestoreWizard = () => {
|
|||||||
{validateMutation.isLoading ? (
|
{validateMutation.isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Validating...
|
{t('backup.restore.actions.validating')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
Next
|
{t('backup.restore.actions.next')}
|
||||||
<ChevronRight className="ml-2 h-4 w-4" />
|
<ChevronRight className="ml-2 h-4 w-4" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
Next
|
{t('backup.restore.actions.next')}
|
||||||
<ChevronRight className="ml-2 h-4 w-4" />
|
<ChevronRight className="ml-2 h-4 w-4" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -788,7 +790,7 @@ export const RestoreWizard = () => {
|
|||||||
setValidationResult(null);
|
setValidationResult(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Start New Restore
|
{t('backup.restore.actions.startNewRestore')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,3 +27,5 @@ export { BackupDashboard } from './BackupDashboard';
|
|||||||
export { BackupConfiguration } from './BackupConfiguration';
|
export { BackupConfiguration } from './BackupConfiguration';
|
||||||
export { BackupHistory } from './BackupHistory';
|
export { BackupHistory } from './BackupHistory';
|
||||||
export { RestoreWizard } from './RestoreWizard';
|
export { RestoreWizard } from './RestoreWizard';
|
||||||
|
export { FeedbackSettings } from './FeedbackSettings';
|
||||||
|
export { FeedbackModerationPanel } from './FeedbackModerationPanel';
|
||||||
@@ -18,6 +18,7 @@ import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
|||||||
import { api } from '../../config/api';
|
import { api } from '../../config/api';
|
||||||
import { Upload, Menu } from 'lucide-react';
|
import { Upload, Menu } from 'lucide-react';
|
||||||
import { galleryService } from '../../services/gallery.service';
|
import { galleryService } from '../../services/gallery.service';
|
||||||
|
import { feedbackService } from '../../services/feedback.service';
|
||||||
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
||||||
|
|
||||||
interface GalleryViewProps {
|
interface GalleryViewProps {
|
||||||
@@ -48,6 +49,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
||||||
|
const [feedbackEnabled, setFeedbackEnabled] = useState(false);
|
||||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||||
const { watermarkEnabled } = useWatermarkSettings();
|
const { watermarkEnabled } = useWatermarkSettings();
|
||||||
|
|
||||||
@@ -76,6 +78,25 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch feedback settings
|
||||||
|
const { data: feedbackSettings } = useQuery({
|
||||||
|
queryKey: ['gallery-feedback-settings', event.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
// Use public endpoint to get feedback settings
|
||||||
|
const response = await api.get(`/gallery/${slug}/feedback-settings`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
// If endpoint doesn't exist or returns error, default to disabled
|
||||||
|
return { feedback_enabled: false };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setFeedbackEnabled(data?.feedback_enabled || false);
|
||||||
|
},
|
||||||
|
enabled: !!event.id,
|
||||||
|
});
|
||||||
|
|
||||||
// Apply branding settings
|
// Apply branding settings
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settingsData) {
|
if (settingsData) {
|
||||||
@@ -429,6 +450,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
photos={filteredPhotos}
|
photos={filteredPhotos}
|
||||||
slug={slug}
|
slug={slug}
|
||||||
categoryId={selectedCategoryId}
|
categoryId={selectedCategoryId}
|
||||||
|
feedbackEnabled={feedbackEnabled}
|
||||||
isSelectionMode={isSelectionMode}
|
isSelectionMode={isSelectionMode}
|
||||||
selectedPhotos={selectedPhotos}
|
selectedPhotos={selectedPhotos}
|
||||||
onSelectionChange={setSelectedPhotos}
|
onSelectionChange={setSelectedPhotos}
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { MessageSquare, Send, User, Loader2 } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { feedbackService } from '../../services/feedback.service';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Button, Input } from '../common';
|
||||||
|
import type { PhotoFeedback } from '../../services/feedback.service';
|
||||||
|
|
||||||
|
interface PhotoCommentsProps {
|
||||||
|
photoId: string;
|
||||||
|
gallerySlug: string;
|
||||||
|
comments: PhotoFeedback[];
|
||||||
|
isEnabled: boolean;
|
||||||
|
requireNameEmail: boolean;
|
||||||
|
showToGuests: boolean;
|
||||||
|
onCommentAdded?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
||||||
|
photoId,
|
||||||
|
gallerySlug,
|
||||||
|
comments,
|
||||||
|
isEnabled,
|
||||||
|
requireNameEmail,
|
||||||
|
showToGuests,
|
||||||
|
onCommentAdded
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [showCommentForm, setShowCommentForm] = useState(false);
|
||||||
|
const [commentText, setCommentText] = useState('');
|
||||||
|
const [guestName, setGuestName] = useState('');
|
||||||
|
const [guestEmail, setGuestEmail] = useState('');
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// Auto-resize textarea
|
||||||
|
useEffect(() => {
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.style.height = 'auto';
|
||||||
|
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
||||||
|
}
|
||||||
|
}, [commentText]);
|
||||||
|
|
||||||
|
const submitCommentMutation = useMutation({
|
||||||
|
mutationFn: (data: any) =>
|
||||||
|
feedbackService.submitFeedback(gallerySlug, photoId, {
|
||||||
|
feedback_type: 'comment',
|
||||||
|
comment_text: data.comment_text,
|
||||||
|
guest_name: data.guest_name,
|
||||||
|
guest_email: data.guest_email
|
||||||
|
}),
|
||||||
|
onSuccess: (response) => {
|
||||||
|
setCommentText('');
|
||||||
|
setShowCommentForm(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
|
||||||
|
|
||||||
|
if (response.message) {
|
||||||
|
toast.info(response.message);
|
||||||
|
} else {
|
||||||
|
toast.success(t('feedback.commentSubmitted', 'Comment submitted'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onCommentAdded) {
|
||||||
|
onCommentAdded();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
if (error.response?.status === 429) {
|
||||||
|
toast.error(t('feedback.rateLimited', 'Please wait before commenting again'));
|
||||||
|
} else if (error.response?.data?.errors) {
|
||||||
|
setErrors(error.response.data.errors);
|
||||||
|
} else {
|
||||||
|
toast.error(t('feedback.commentError', 'Failed to submit comment'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmitComment = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setErrors({});
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
if (!commentText.trim()) {
|
||||||
|
newErrors.comment_text = t('feedback.commentRequired', 'Comment is required');
|
||||||
|
}
|
||||||
|
if (requireNameEmail) {
|
||||||
|
if (!guestName.trim()) {
|
||||||
|
newErrors.guest_name = t('feedback.nameRequired', 'Name is required');
|
||||||
|
}
|
||||||
|
if (!guestEmail.trim()) {
|
||||||
|
newErrors.guest_email = t('feedback.emailRequired', 'Email is required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(newErrors).length > 0) {
|
||||||
|
setErrors(newErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitCommentMutation.mutate({
|
||||||
|
comment_text: commentText.trim(),
|
||||||
|
guest_name: guestName.trim(),
|
||||||
|
guest_email: guestEmail.trim()
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isEnabled) return null;
|
||||||
|
|
||||||
|
// Filter comments based on visibility settings
|
||||||
|
const visibleComments = showToGuests
|
||||||
|
? comments.filter(c => c.is_approved && !c.is_hidden)
|
||||||
|
: comments.filter(c => c.is_mine);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Comments Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-900 flex items-center gap-2">
|
||||||
|
<MessageSquare className="w-4 h-4" />
|
||||||
|
{t('feedback.comments', 'Comments')}
|
||||||
|
{visibleComments.length > 0 && (
|
||||||
|
<span className="text-neutral-500">({visibleComments.length})</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
{!showCommentForm && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowCommentForm(true)}
|
||||||
|
>
|
||||||
|
{t('feedback.addComment', 'Add Comment')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comment Form */}
|
||||||
|
{showCommentForm && (
|
||||||
|
<form onSubmit={handleSubmitComment} className="space-y-3 p-3 bg-neutral-50 rounded-lg">
|
||||||
|
{requireNameEmail && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
placeholder={t('feedback.yourName', 'Your name')}
|
||||||
|
value={guestName}
|
||||||
|
onChange={(e) => setGuestName(e.target.value)}
|
||||||
|
error={errors.guest_name}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder={t('feedback.yourEmail', 'Your email')}
|
||||||
|
value={guestEmail}
|
||||||
|
onChange={(e) => setGuestEmail(e.target.value)}
|
||||||
|
error={errors.guest_email}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={commentText}
|
||||||
|
onChange={(e) => setCommentText(e.target.value)}
|
||||||
|
placeholder={t('feedback.writeComment', 'Write a comment...')}
|
||||||
|
className={`w-full px-3 py-2 text-sm border rounded-lg resize-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 ${
|
||||||
|
errors.comment_text ? 'border-red-500' : 'border-neutral-300'
|
||||||
|
}`}
|
||||||
|
rows={2}
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
{errors.comment_text && (
|
||||||
|
<p className="text-xs text-red-600 mt-1">{errors.comment_text}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
{commentText.length}/500
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
leftIcon={submitCommentMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||||
|
disabled={submitCommentMutation.isPending}
|
||||||
|
>
|
||||||
|
{t('feedback.submit', 'Submit')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCommentForm(false);
|
||||||
|
setCommentText('');
|
||||||
|
setErrors({});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('common.cancel', 'Cancel')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comments List */}
|
||||||
|
{visibleComments.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{visibleComments.map((comment) => (
|
||||||
|
<div key={comment.id} className="flex gap-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="w-8 h-8 bg-neutral-200 rounded-full flex items-center justify-center">
|
||||||
|
<User className="w-4 h-4 text-neutral-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-baseline gap-2 mb-1">
|
||||||
|
<span className="text-sm font-medium text-neutral-900">
|
||||||
|
{comment.guest_name || t('feedback.anonymous', 'Anonymous')}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-neutral-500">
|
||||||
|
{format(new Date(comment.created_at), 'PP')}
|
||||||
|
</span>
|
||||||
|
{comment.is_mine && !comment.is_approved && (
|
||||||
|
<span className="text-xs text-orange-600">
|
||||||
|
{t('feedback.pendingApproval', 'Pending approval')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-700 break-words">
|
||||||
|
{comment.comment_text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{visibleComments.length === 0 && !showCommentForm && (
|
||||||
|
<p className="text-sm text-neutral-500 text-center py-4">
|
||||||
|
{t('feedback.noComments', 'No comments yet. Be the first to comment!')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Bookmark } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { feedbackService } from '../../services/feedback.service';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
interface PhotoFavoritesProps {
|
||||||
|
photoId: string;
|
||||||
|
gallerySlug: string;
|
||||||
|
isFavorited: boolean;
|
||||||
|
favoriteCount: number;
|
||||||
|
isEnabled: boolean;
|
||||||
|
onFavoriteChange?: (favorited: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
|
||||||
|
photoId,
|
||||||
|
gallerySlug,
|
||||||
|
isFavorited,
|
||||||
|
favoriteCount,
|
||||||
|
isEnabled,
|
||||||
|
onFavoriteChange
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [animating, setAnimating] = useState(false);
|
||||||
|
|
||||||
|
const submitFavoriteMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
feedbackService.submitFeedback(gallerySlug, photoId, {
|
||||||
|
feedback_type: 'favorite'
|
||||||
|
}),
|
||||||
|
onMutate: async () => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setAnimating(true);
|
||||||
|
// Optimistic update
|
||||||
|
if (onFavoriteChange) {
|
||||||
|
onFavoriteChange(!isFavorited);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
// Revert optimistic update
|
||||||
|
if (onFavoriteChange) {
|
||||||
|
onFavoriteChange(isFavorited);
|
||||||
|
}
|
||||||
|
if (error.response?.status === 429) {
|
||||||
|
toast.error(t('feedback.rateLimited', 'Please wait before favoriting again'));
|
||||||
|
} else {
|
||||||
|
toast.error(t('feedback.favoriteError', 'Failed to update favorite'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
setTimeout(() => setAnimating(false), 300);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFavoriteClick = () => {
|
||||||
|
if (!isEnabled || isSubmitting) return;
|
||||||
|
submitFavoriteMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isEnabled) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleFavoriteClick}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={`group flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
|
||||||
|
isFavorited
|
||||||
|
? 'bg-amber-50 text-amber-600 hover:bg-amber-100'
|
||||||
|
: 'bg-neutral-50 text-neutral-600 hover:bg-neutral-100'
|
||||||
|
} ${isSubmitting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
||||||
|
aria-label={isFavorited ? t('feedback.unfavorite', 'Remove from favorites') : t('feedback.favorite', 'Add to favorites')}
|
||||||
|
>
|
||||||
|
<Bookmark
|
||||||
|
className={`w-5 h-5 transition-all ${
|
||||||
|
animating ? 'scale-125' : 'scale-100'
|
||||||
|
} ${
|
||||||
|
isFavorited ? 'fill-current' : 'group-hover:scale-110'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{favoriteCount > 0 ? favoriteCount : ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { feedbackService } from '../../services/feedback.service';
|
||||||
|
import { PhotoRating } from './PhotoRating';
|
||||||
|
import { PhotoLikes } from './PhotoLikes';
|
||||||
|
import { PhotoComments } from './PhotoComments';
|
||||||
|
import { PhotoFavorites } from './PhotoFavorites';
|
||||||
|
import { Skeleton } from '../common';
|
||||||
|
import type { FeedbackSettings } from '../../services/feedback.service';
|
||||||
|
|
||||||
|
interface PhotoFeedbackProps {
|
||||||
|
photoId: string;
|
||||||
|
gallerySlug: string;
|
||||||
|
className?: string;
|
||||||
|
showComments?: boolean;
|
||||||
|
onFeedbackUpdate?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
||||||
|
photoId,
|
||||||
|
gallerySlug,
|
||||||
|
className = '',
|
||||||
|
showComments = true,
|
||||||
|
onFeedbackUpdate
|
||||||
|
}) => {
|
||||||
|
// Fetch feedback settings for the gallery
|
||||||
|
const { data: settings, isLoading: settingsLoading } = useQuery({
|
||||||
|
queryKey: ['gallery-feedback-settings', gallerySlug],
|
||||||
|
queryFn: () => feedbackService.getGalleryFeedbackSettings(gallerySlug),
|
||||||
|
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch feedback data for the photo
|
||||||
|
const { data: feedbackData, isLoading: feedbackLoading } = useQuery({
|
||||||
|
queryKey: ['photo-feedback', gallerySlug, photoId],
|
||||||
|
queryFn: () => feedbackService.getPhotoFeedback(gallerySlug, photoId),
|
||||||
|
enabled: !!settings?.feedback_enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Local state for optimistic updates
|
||||||
|
const [currentRating, setCurrentRating] = useState(0);
|
||||||
|
const [isLiked, setIsLiked] = useState(false);
|
||||||
|
const [isFavorited, setIsFavorited] = useState(false);
|
||||||
|
const [likeCount, setLikeCount] = useState(0);
|
||||||
|
const [favoriteCount, setFavoriteCount] = useState(0);
|
||||||
|
|
||||||
|
// Update local state when data loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (feedbackData) {
|
||||||
|
setCurrentRating(feedbackData.my_feedback.rating || 0);
|
||||||
|
setIsLiked(feedbackData.my_feedback.liked);
|
||||||
|
setIsFavorited(feedbackData.my_feedback.favorited);
|
||||||
|
setLikeCount(feedbackData.summary.like_count);
|
||||||
|
setFavoriteCount(feedbackData.summary.favorite_count);
|
||||||
|
}
|
||||||
|
}, [feedbackData]);
|
||||||
|
|
||||||
|
// Handle optimistic updates
|
||||||
|
const handleRatingChange = (rating: number) => {
|
||||||
|
setCurrentRating(rating);
|
||||||
|
if (onFeedbackUpdate) onFeedbackUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLikeChange = (liked: boolean) => {
|
||||||
|
setIsLiked(liked);
|
||||||
|
setLikeCount(prev => liked ? prev + 1 : Math.max(0, prev - 1));
|
||||||
|
if (onFeedbackUpdate) onFeedbackUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFavoriteChange = (favorited: boolean) => {
|
||||||
|
setIsFavorited(favorited);
|
||||||
|
setFavoriteCount(prev => favorited ? prev + 1 : Math.max(0, prev - 1));
|
||||||
|
if (onFeedbackUpdate) onFeedbackUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (settingsLoading) {
|
||||||
|
return (
|
||||||
|
<div className={`space-y-3 ${className}`}>
|
||||||
|
<Skeleton className="h-8 w-32" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settings?.feedback_enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAnyFeedbackType = settings.allow_ratings || settings.allow_likes ||
|
||||||
|
settings.allow_comments || settings.allow_favorites;
|
||||||
|
|
||||||
|
if (!hasAnyFeedbackType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`space-y-4 ${className}`}>
|
||||||
|
{/* Rating Section */}
|
||||||
|
{settings.allow_ratings && (
|
||||||
|
<PhotoRating
|
||||||
|
photoId={photoId}
|
||||||
|
gallerySlug={gallerySlug}
|
||||||
|
currentRating={currentRating}
|
||||||
|
averageRating={feedbackData?.summary.average_rating}
|
||||||
|
totalRatings={feedbackData?.summary.total_ratings}
|
||||||
|
isEnabled={true}
|
||||||
|
onRatingChange={handleRatingChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
{(settings.allow_likes || settings.allow_favorites) && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{settings.allow_likes && (
|
||||||
|
<PhotoLikes
|
||||||
|
photoId={photoId}
|
||||||
|
gallerySlug={gallerySlug}
|
||||||
|
isLiked={isLiked}
|
||||||
|
likeCount={likeCount}
|
||||||
|
isEnabled={true}
|
||||||
|
onLikeChange={handleLikeChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{settings.allow_favorites && (
|
||||||
|
<PhotoFavorites
|
||||||
|
photoId={photoId}
|
||||||
|
gallerySlug={gallerySlug}
|
||||||
|
isFavorited={isFavorited}
|
||||||
|
favoriteCount={favoriteCount}
|
||||||
|
isEnabled={true}
|
||||||
|
onFavoriteChange={handleFavoriteChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comments Section */}
|
||||||
|
{settings.allow_comments && showComments && (
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<PhotoComments
|
||||||
|
photoId={photoId}
|
||||||
|
gallerySlug={gallerySlug}
|
||||||
|
comments={feedbackData?.feedback || []}
|
||||||
|
isEnabled={true}
|
||||||
|
requireNameEmail={settings.require_name_email || false}
|
||||||
|
showToGuests={settings.show_feedback_to_guests || false}
|
||||||
|
onCommentAdded={() => {
|
||||||
|
if (onFeedbackUpdate) onFeedbackUpdate();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -15,9 +15,10 @@ interface PhotoGridProps {
|
|||||||
photos: Photo[];
|
photos: Photo[];
|
||||||
slug: string;
|
slug: string;
|
||||||
categoryId?: number | null;
|
categoryId?: number | null;
|
||||||
|
feedbackEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug, categoryId }) => {
|
export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug, categoryId, feedbackEnabled = false }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
|
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
|
||||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
||||||
@@ -204,6 +205,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug, categoryId }
|
|||||||
initialIndex={selectedPhotoIndex}
|
initialIndex={selectedPhotoIndex}
|
||||||
onClose={() => setSelectedPhotoIndex(null)}
|
onClose={() => setSelectedPhotoIndex(null)}
|
||||||
slug={slug}
|
slug={slug}
|
||||||
|
feedbackEnabled={feedbackEnabled}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ interface PhotoGridWithLayoutsProps {
|
|||||||
eventLogo?: string | null;
|
eventLogo?: string | null;
|
||||||
eventDate?: string;
|
eventDate?: string;
|
||||||
expiresAt?: string;
|
expiresAt?: string;
|
||||||
|
feedbackEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||||
@@ -42,6 +43,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
categoryId,
|
categoryId,
|
||||||
isSelectionMode: parentSelectionMode,
|
isSelectionMode: parentSelectionMode,
|
||||||
selectedPhotos: parentSelectedPhotos,
|
selectedPhotos: parentSelectedPhotos,
|
||||||
|
feedbackEnabled,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
onToggleSelectionMode: parentToggleSelectionMode,
|
onToggleSelectionMode: parentToggleSelectionMode,
|
||||||
showSelectionControls = true,
|
showSelectionControls = true,
|
||||||
@@ -259,6 +261,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
initialIndex={selectedPhotoIndex}
|
initialIndex={selectedPhotoIndex}
|
||||||
onClose={() => setSelectedPhotoIndex(null)}
|
onClose={() => setSelectedPhotoIndex(null)}
|
||||||
slug={slug}
|
slug={slug}
|
||||||
|
feedbackEnabled={feedbackEnabled || false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut } from 'lucide-react';
|
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare } from 'lucide-react';
|
||||||
import type { Photo } from '../../types';
|
import type { Photo } from '../../types';
|
||||||
import { useDownloadPhoto } from '../../hooks/useGallery';
|
import { useDownloadPhoto } from '../../hooks/useGallery';
|
||||||
import { AuthenticatedImage } from '../common';
|
import { AuthenticatedImage } from '../common';
|
||||||
|
import { PhotoFeedback } from './PhotoFeedback';
|
||||||
|
|
||||||
interface PhotoLightboxProps {
|
interface PhotoLightboxProps {
|
||||||
photos: Photo[];
|
photos: Photo[];
|
||||||
initialIndex: number;
|
initialIndex: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
feedbackEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||||
@@ -16,6 +18,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
initialIndex,
|
initialIndex,
|
||||||
onClose,
|
onClose,
|
||||||
slug,
|
slug,
|
||||||
|
feedbackEnabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
@@ -23,6 +26,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||||
const [touchDistance, setTouchDistance] = useState<number | null>(null);
|
const [touchDistance, setTouchDistance] = useState<number | null>(null);
|
||||||
|
const [showFeedback, setShowFeedback] = useState(false);
|
||||||
|
|
||||||
const downloadPhotoMutation = useDownloadPhoto();
|
const downloadPhotoMutation = useDownloadPhoto();
|
||||||
const currentPhoto = photos[currentIndex];
|
const currentPhoto = photos[currentIndex];
|
||||||
@@ -224,6 +228,16 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
>
|
>
|
||||||
<Download className="w-5 h-5 text-white" />
|
<Download className="w-5 h-5 text-white" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{feedbackEnabled && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFeedback(!showFeedback)}
|
||||||
|
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
|
||||||
|
aria-label="Toggle feedback"
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-5 h-5 text-white" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -259,6 +273,29 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 text-white text-sm opacity-50 pointer-events-none md:hidden z-20">
|
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 text-white text-sm opacity-50 pointer-events-none md:hidden z-20">
|
||||||
Swipe to navigate
|
Swipe to navigate
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Feedback Panel */}
|
||||||
|
{showFeedback && (
|
||||||
|
<div className="absolute right-0 top-0 bottom-0 w-96 bg-white shadow-xl z-20 overflow-y-auto">
|
||||||
|
<div className="sticky top-0 bg-white border-b px-4 py-3 flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-neutral-900">Photo Feedback</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFeedback(false)}
|
||||||
|
className="p-1 hover:bg-neutral-100 rounded transition-colors"
|
||||||
|
aria-label="Close feedback"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<PhotoFeedback
|
||||||
|
photoId={currentPhoto.id}
|
||||||
|
gallerySlug={slug}
|
||||||
|
showComments={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Heart } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { feedbackService } from '../../services/feedback.service';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
interface PhotoLikesProps {
|
||||||
|
photoId: string;
|
||||||
|
gallerySlug: string;
|
||||||
|
isLiked: boolean;
|
||||||
|
likeCount: number;
|
||||||
|
isEnabled: boolean;
|
||||||
|
onLikeChange?: (liked: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PhotoLikes: React.FC<PhotoLikesProps> = ({
|
||||||
|
photoId,
|
||||||
|
gallerySlug,
|
||||||
|
isLiked,
|
||||||
|
likeCount,
|
||||||
|
isEnabled,
|
||||||
|
onLikeChange
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [animating, setAnimating] = useState(false);
|
||||||
|
|
||||||
|
const submitLikeMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
feedbackService.submitFeedback(gallerySlug, photoId, {
|
||||||
|
feedback_type: 'like'
|
||||||
|
}),
|
||||||
|
onMutate: async () => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setAnimating(true);
|
||||||
|
// Optimistic update
|
||||||
|
if (onLikeChange) {
|
||||||
|
onLikeChange(!isLiked);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
// Revert optimistic update
|
||||||
|
if (onLikeChange) {
|
||||||
|
onLikeChange(isLiked);
|
||||||
|
}
|
||||||
|
if (error.response?.status === 429) {
|
||||||
|
toast.error(t('feedback.rateLimited', 'Please wait before liking again'));
|
||||||
|
} else {
|
||||||
|
toast.error(t('feedback.likeError', 'Failed to update like'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
setTimeout(() => setAnimating(false), 300);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleLikeClick = () => {
|
||||||
|
if (!isEnabled || isSubmitting) return;
|
||||||
|
submitLikeMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isEnabled) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleLikeClick}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={`group flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
|
||||||
|
isLiked
|
||||||
|
? 'bg-red-50 text-red-600 hover:bg-red-100'
|
||||||
|
: 'bg-neutral-50 text-neutral-600 hover:bg-neutral-100'
|
||||||
|
} ${isSubmitting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
||||||
|
aria-label={isLiked ? t('feedback.unlike', 'Unlike') : t('feedback.like', 'Like')}
|
||||||
|
>
|
||||||
|
<Heart
|
||||||
|
className={`w-5 h-5 transition-all ${
|
||||||
|
animating ? 'scale-125' : 'scale-100'
|
||||||
|
} ${
|
||||||
|
isLiked ? 'fill-current' : 'group-hover:scale-110'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{likeCount > 0 ? likeCount : ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Star } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { feedbackService } from '../../services/feedback.service';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
interface PhotoRatingProps {
|
||||||
|
photoId: string;
|
||||||
|
gallerySlug: string;
|
||||||
|
currentRating?: number;
|
||||||
|
averageRating?: number;
|
||||||
|
totalRatings?: number;
|
||||||
|
isEnabled: boolean;
|
||||||
|
onRatingChange?: (rating: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PhotoRating: React.FC<PhotoRatingProps> = ({
|
||||||
|
photoId,
|
||||||
|
gallerySlug,
|
||||||
|
currentRating = 0,
|
||||||
|
averageRating = 0,
|
||||||
|
totalRatings = 0,
|
||||||
|
isEnabled,
|
||||||
|
onRatingChange
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [hoveredRating, setHoveredRating] = useState(0);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const submitRatingMutation = useMutation({
|
||||||
|
mutationFn: (rating: number) =>
|
||||||
|
feedbackService.submitFeedback(gallerySlug, photoId, {
|
||||||
|
feedback_type: 'rating',
|
||||||
|
rating
|
||||||
|
}),
|
||||||
|
onMutate: async (rating) => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
// Optimistic update
|
||||||
|
if (onRatingChange) {
|
||||||
|
onRatingChange(rating);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
|
||||||
|
toast.success(t('feedback.ratingSubmitted', 'Rating submitted'));
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
// Revert optimistic update
|
||||||
|
if (onRatingChange && currentRating) {
|
||||||
|
onRatingChange(currentRating);
|
||||||
|
}
|
||||||
|
if (error.response?.status === 429) {
|
||||||
|
toast.error(t('feedback.rateLimited', 'Please wait before rating again'));
|
||||||
|
} else {
|
||||||
|
toast.error(t('feedback.ratingError', 'Failed to submit rating'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRatingClick = (rating: number) => {
|
||||||
|
if (!isEnabled || isSubmitting) return;
|
||||||
|
|
||||||
|
// If clicking the same rating, remove it
|
||||||
|
const newRating = rating === currentRating ? 0 : rating;
|
||||||
|
submitRatingMutation.mutate(newRating);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isEnabled) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
{/* Star Rating Input */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<button
|
||||||
|
key={star}
|
||||||
|
onClick={() => handleRatingClick(star)}
|
||||||
|
onMouseEnter={() => setHoveredRating(star)}
|
||||||
|
onMouseLeave={() => setHoveredRating(0)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={`p-1 transition-all ${
|
||||||
|
isSubmitting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:scale-110'
|
||||||
|
}`}
|
||||||
|
aria-label={t('feedback.rateStar', 'Rate {{count}} stars', { count: star })}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className={`w-6 h-6 transition-colors ${
|
||||||
|
star <= (hoveredRating || currentRating)
|
||||||
|
? 'fill-yellow-500 text-yellow-500'
|
||||||
|
: 'text-neutral-300 hover:text-yellow-400'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Average Rating Display */}
|
||||||
|
{totalRatings > 0 && (
|
||||||
|
<div className="text-sm text-neutral-600">
|
||||||
|
<span className="font-medium">{averageRating.toFixed(1)}</span>
|
||||||
|
<span className="text-neutral-400 ml-1">
|
||||||
|
({t('feedback.ratingsCount', '{{count}} ratings', { count: totalRatings })})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,3 +6,8 @@ export { CountdownTimer } from './CountdownTimer';
|
|||||||
export { GalleryLayout } from './GalleryLayout';
|
export { GalleryLayout } from './GalleryLayout';
|
||||||
export { PhotoFilterBar } from './PhotoFilterBar';
|
export { PhotoFilterBar } from './PhotoFilterBar';
|
||||||
export { UserPhotoUpload } from './UserPhotoUpload';
|
export { UserPhotoUpload } from './UserPhotoUpload';
|
||||||
|
export { PhotoFeedback } from './PhotoFeedback';
|
||||||
|
export { PhotoRating } from './PhotoRating';
|
||||||
|
export { PhotoLikes } from './PhotoLikes';
|
||||||
|
export { PhotoComments } from './PhotoComments';
|
||||||
|
export { PhotoFavorites } from './PhotoFavorites';
|
||||||
@@ -83,8 +83,8 @@ api.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
// Check if it's an admin route
|
// Check if it's an admin route (but not public endpoints)
|
||||||
const isAdminRoute = error.config?.url?.includes('/admin');
|
const isAdminRoute = error.config?.url?.includes('/admin') && !error.config?.url?.includes('/public/');
|
||||||
const currentPath = window.location.pathname;
|
const currentPath = window.location.pathname;
|
||||||
|
|
||||||
if (isAdminRoute) {
|
if (isAdminRoute) {
|
||||||
|
|||||||
@@ -2,16 +2,25 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns';
|
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns';
|
||||||
import { de, enUS } from 'date-fns/locale';
|
import { de, enUS } from 'date-fns/locale';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { settingsService } from '../services/settings.service';
|
import { publicSettingsService } from '../services/publicSettings.service';
|
||||||
|
|
||||||
|
// Convert old date format strings to new date-fns format
|
||||||
|
const convertDateFormat = (format: string): string => {
|
||||||
|
return format
|
||||||
|
.replace(/DD/g, 'dd') // Days: DD -> dd
|
||||||
|
.replace(/YYYY/g, 'yyyy') // Years: YYYY -> yyyy
|
||||||
|
.replace(/YY/g, 'yy'); // Short years: YY -> yy
|
||||||
|
};
|
||||||
|
|
||||||
export const useLocalizedDate = () => {
|
export const useLocalizedDate = () => {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
|
|
||||||
// Fetch admin settings to get the date format
|
// Fetch public settings to get the date format
|
||||||
const { data: settings } = useQuery({
|
const { data: settings } = useQuery({
|
||||||
queryKey: ['admin-settings-general'],
|
queryKey: ['public-settings'],
|
||||||
queryFn: () => settingsService.getSettingsByType('general'),
|
queryFn: () => publicSettingsService.getPublicSettings(),
|
||||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||||
|
retry: 1, // Only retry once to avoid blocking the UI
|
||||||
});
|
});
|
||||||
|
|
||||||
const getLocale = () => {
|
const getLocale = () => {
|
||||||
@@ -21,7 +30,18 @@ export const useLocalizedDate = () => {
|
|||||||
const format = (date: Date | string, formatStr?: string) => {
|
const format = (date: Date | string, formatStr?: string) => {
|
||||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||||
// Use admin-configured date format if available and no format string provided
|
// Use admin-configured date format if available and no format string provided
|
||||||
const dateFormat = formatStr || settings?.general_date_format || 'PPP';
|
let dateFormat = formatStr;
|
||||||
|
if (!dateFormat && settings?.general_date_format) {
|
||||||
|
// Handle both string and object formats
|
||||||
|
dateFormat = typeof settings.general_date_format === 'string'
|
||||||
|
? settings.general_date_format
|
||||||
|
: settings.general_date_format.format || 'PPP';
|
||||||
|
}
|
||||||
|
dateFormat = dateFormat || 'PPP';
|
||||||
|
|
||||||
|
// Convert old format to new format
|
||||||
|
dateFormat = convertDateFormat(dateFormat);
|
||||||
|
|
||||||
return dateFnsFormat(dateObj, dateFormat, { locale: getLocale() });
|
return dateFnsFormat(dateObj, dateFormat, { locale: getLocale() });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,6 +54,12 @@ export const useLocalizedDate = () => {
|
|||||||
format,
|
format,
|
||||||
formatDistanceToNow,
|
formatDistanceToNow,
|
||||||
locale: getLocale(),
|
locale: getLocale(),
|
||||||
dateFormat: settings?.general_date_format || 'PPP'
|
dateFormat: settings?.general_date_format
|
||||||
|
? convertDateFormat(
|
||||||
|
typeof settings.general_date_format === 'string'
|
||||||
|
? settings.general_date_format
|
||||||
|
: settings.general_date_format.format || 'PPP'
|
||||||
|
)
|
||||||
|
: 'PPP'
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -35,7 +35,8 @@
|
|||||||
"days": "Tage",
|
"days": "Tage",
|
||||||
"customize": "Anpassen",
|
"customize": "Anpassen",
|
||||||
"hide": "Ausblenden",
|
"hide": "Ausblenden",
|
||||||
"unknown": "Unbekannt"
|
"unknown": "Unbekannt",
|
||||||
|
"chunk": "Teil"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"photoCategory": "Fotokategorie",
|
"photoCategory": "Fotokategorie",
|
||||||
@@ -51,7 +52,8 @@
|
|||||||
"uploadPhotos": "Fotos hochladen",
|
"uploadPhotos": "Fotos hochladen",
|
||||||
"maxFilesReached": "Maximal 500 Dateien erlaubt",
|
"maxFilesReached": "Maximal 500 Dateien erlaubt",
|
||||||
"someFilesSkipped": "Einige Dateien wurden übersprungen (500 Dateien Limit)",
|
"someFilesSkipped": "Einige Dateien wurden übersprungen (500 Dateien Limit)",
|
||||||
"tooManyFiles": "Maximal 500 Dateien können gleichzeitig hochgeladen werden"
|
"tooManyFiles": "Maximal 500 Dateien können gleichzeitig hochgeladen werden",
|
||||||
|
"uploadingChunks": "Lade {{count}} Dateien in {{total}} Teilen hoch..."
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
@@ -367,6 +369,11 @@
|
|||||||
"noEventsDescription": "Erstellen Sie Ihre erste Veranstaltung, um zu beginnen.",
|
"noEventsDescription": "Erstellen Sie Ihre erste Veranstaltung, um zu beginnen.",
|
||||||
"eventsSelected": "{{count}} Veranstaltung ausgewählt",
|
"eventsSelected": "{{count}} Veranstaltung ausgewählt",
|
||||||
"eventsSelected_plural": "{{count}} Veranstaltungen ausgewählt",
|
"eventsSelected_plural": "{{count}} Veranstaltungen ausgewählt",
|
||||||
|
"viewDetails": "Details anzeigen",
|
||||||
|
"archiveEventAction": "Veranstaltung archivieren",
|
||||||
|
"downloadArchiveAction": "Archiv herunterladen",
|
||||||
|
"deleteEvent": "Veranstaltung löschen",
|
||||||
|
"deleteEventConfirm": "Sind Sie sicher, dass Sie diese Veranstaltung löschen möchten?",
|
||||||
"bulkArchive": "Archivieren",
|
"bulkArchive": "Archivieren",
|
||||||
"confirmBulkArchive": "Sind Sie sicher, dass Sie {{count}} Veranstaltung(en) archivieren möchten?",
|
"confirmBulkArchive": "Sind Sie sicher, dass Sie {{count}} Veranstaltung(en) archivieren möchten?",
|
||||||
"confirmBulkArchiveDescription": "Diese Aktion kann nicht rückgängig gemacht werden. Archivierte Veranstaltungen sind nicht mehr öffentlich zugänglich.",
|
"confirmBulkArchiveDescription": "Diese Aktion kann nicht rückgängig gemacht werden. Archivierte Veranstaltungen sind nicht mehr öffentlich zugänglich.",
|
||||||
@@ -903,6 +910,404 @@
|
|||||||
"passwordSecurityRequirements": "Passwort erfüllt nicht die Sicherheitsanforderungen",
|
"passwordSecurityRequirements": "Passwort erfüllt nicht die Sicherheitsanforderungen",
|
||||||
"expirationRange": "Ablauf muss zwischen 1 und 365 Tagen liegen"
|
"expirationRange": "Ablauf muss zwischen 1 und 365 Tagen liegen"
|
||||||
},
|
},
|
||||||
|
"backup": {
|
||||||
|
"title": "Backup-Verwaltung",
|
||||||
|
"subtitle": "Verwalten Sie System-Backups, konfigurieren Sie automatisierte Backups und stellen Sie vorherige Backups wieder her.",
|
||||||
|
"tabs": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"configuration": "Konfiguration",
|
||||||
|
"history": "Backup-Verlauf",
|
||||||
|
"restore": "Wiederherstellung"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"inProgress": "Backup läuft...",
|
||||||
|
"lastBackup": "Letztes Backup",
|
||||||
|
"noBackups": "Keine Backups gefunden",
|
||||||
|
"nextBackup": "Nächstes Backup",
|
||||||
|
"notScheduled": "Nicht geplant",
|
||||||
|
"enabled": "Aktiviert",
|
||||||
|
"disabled": "Deaktiviert"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"runBackupNow": "Backup jetzt starten",
|
||||||
|
"starting": "Starte...",
|
||||||
|
"running": "Läuft...",
|
||||||
|
"testConnection": "Verbindung testen",
|
||||||
|
"save": "Konfiguration speichern",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"view": "Details anzeigen",
|
||||||
|
"download": "Herunterladen",
|
||||||
|
"refresh": "Aktualisieren"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"backupHealth": "Backup-Status",
|
||||||
|
"healthStatus": {
|
||||||
|
"excellent": "Ausgezeichnet",
|
||||||
|
"good": "Gut",
|
||||||
|
"warning": "Warnung",
|
||||||
|
"critical": "Kritisch"
|
||||||
|
},
|
||||||
|
"healthMessages": {
|
||||||
|
"noBackups": "Keine Backups gefunden",
|
||||||
|
"failed": "Letztes Backup fehlgeschlagen",
|
||||||
|
"upToDate": "Backup ist aktuell",
|
||||||
|
"recent": "Backup ist aktuell",
|
||||||
|
"old": "Backup wird alt",
|
||||||
|
"outdated": "Backup ist veraltet"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"totalBackups": "Gesamt-Backups",
|
||||||
|
"backupSize": "Backup-Größe",
|
||||||
|
"lastDuration": "Letzte Dauer",
|
||||||
|
"backupStatus": "Backup-Status",
|
||||||
|
"last": "Letztes",
|
||||||
|
"files": "Dateien",
|
||||||
|
"minutes": "{{count}}m",
|
||||||
|
"active": "Aktiv",
|
||||||
|
"inactive": "Inaktiv"
|
||||||
|
},
|
||||||
|
"recentActivity": {
|
||||||
|
"title": "Letzte Backup-Aktivitäten"
|
||||||
|
},
|
||||||
|
"notConfigured": {
|
||||||
|
"title": "Backup nicht konfiguriert",
|
||||||
|
"message": "Bitte konfigurieren Sie die Backup-Einstellungen im Konfiguration-Tab, bevor Sie Backups ausführen."
|
||||||
|
},
|
||||||
|
"coverage": {
|
||||||
|
"database": "Datenbank",
|
||||||
|
"photos": "Fotos",
|
||||||
|
"archives": "Archive",
|
||||||
|
"systemFiles": "Systemdateien",
|
||||||
|
"included": "Enthalten",
|
||||||
|
"excluded": "Ausgeschlossen",
|
||||||
|
"optional": "Optional"
|
||||||
|
},
|
||||||
|
"storageDestination": "Speicherziel",
|
||||||
|
"nextScheduledBackup": "Nächstes geplantes Backup",
|
||||||
|
"backupType": "{{type}} Backup",
|
||||||
|
"noDestinationSet": "Kein Ziel festgelegt"
|
||||||
|
},
|
||||||
|
"configuration": {
|
||||||
|
"enableBackup": "Automatisierte Backups aktivieren",
|
||||||
|
"destinationType": "Backup-Ziel",
|
||||||
|
"destinationTypes": {
|
||||||
|
"local": {
|
||||||
|
"name": "Lokaler Speicher",
|
||||||
|
"description": "Backups auf dem lokalen Server-Dateisystem speichern"
|
||||||
|
},
|
||||||
|
"rsync": {
|
||||||
|
"name": "Remote-Server (Rsync)",
|
||||||
|
"description": "Backups über SSH/Rsync auf einen Remote-Server synchronisieren"
|
||||||
|
},
|
||||||
|
"s3": {
|
||||||
|
"name": "S3-kompatibler Speicher",
|
||||||
|
"description": "Backups in Amazon S3 oder kompatiblem Objektspeicher speichern"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"destinationPath": "Zielpfad",
|
||||||
|
"destinationPathHelp": "Lokaler Verzeichnispfad für Backup-Speicherung",
|
||||||
|
"destinationPathPlaceholder": "/pfad/zum/backup/verzeichnis",
|
||||||
|
"rsyncHost": "Remote-Host",
|
||||||
|
"rsyncHostHelp": "SSH-Hostname oder IP-Adresse",
|
||||||
|
"rsyncHostPlaceholder": "backup.beispiel.de",
|
||||||
|
"rsyncUser": "SSH-Benutzer",
|
||||||
|
"rsyncUserHelp": "Benutzername für SSH-Verbindung",
|
||||||
|
"rsyncUserPlaceholder": "backup-benutzer",
|
||||||
|
"rsyncPath": "Remote-Pfad",
|
||||||
|
"rsyncPathHelp": "Verzeichnispfad auf Remote-Server",
|
||||||
|
"rsyncPathPlaceholder": "/home/backup/foto-sharing",
|
||||||
|
"rsyncSshKey": "SSH Private Key",
|
||||||
|
"rsyncSshKeyHelp": "SSH Private Key für Authentifizierung (optional)",
|
||||||
|
"rsyncSshKeyPlaceholder": "-----BEGIN RSA PRIVATE KEY-----",
|
||||||
|
"s3Endpoint": "S3-Endpunkt",
|
||||||
|
"s3EndpointHelp": "S3 API-Endpunkt (z.B. s3.amazonaws.com)",
|
||||||
|
"s3EndpointPlaceholder": "https://s3.amazonaws.com",
|
||||||
|
"s3Bucket": "Bucket-Name",
|
||||||
|
"s3BucketHelp": "S3-Bucket für Backup-Speicherung",
|
||||||
|
"s3BucketPlaceholder": "mein-backup-bucket",
|
||||||
|
"s3AccessKey": "Zugriffsschlüssel-ID",
|
||||||
|
"s3AccessKeyHelp": "AWS/S3 Zugriffsschlüssel-ID",
|
||||||
|
"s3AccessKeyPlaceholder": "AKIAIOSFODNN7EXAMPLE",
|
||||||
|
"s3SecretKey": "Geheimer Zugriffsschlüssel",
|
||||||
|
"s3SecretKeyHelp": "AWS/S3 geheimer Zugriffsschlüssel",
|
||||||
|
"s3SecretKeyPlaceholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||||
|
"s3Region": "Region",
|
||||||
|
"s3RegionHelp": "S3-Region (z.B. eu-central-1)",
|
||||||
|
"s3RegionPlaceholder": "eu-central-1"
|
||||||
|
},
|
||||||
|
"schedule": {
|
||||||
|
"title": "Backup-Zeitplan",
|
||||||
|
"scheduleType": "Zeitplan-Typ",
|
||||||
|
"scheduleOptions": {
|
||||||
|
"hourly": "Jede Stunde",
|
||||||
|
"daily": "Täglich",
|
||||||
|
"weekly": "Wöchentlich",
|
||||||
|
"custom": "Benutzerdefinierter Cron-Ausdruck"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"hourly": "Jede Stunde",
|
||||||
|
"daily": "Täglich",
|
||||||
|
"weekly": "Wöchentlich",
|
||||||
|
"custom": "Benutzerdefinierter Cron-Ausdruck"
|
||||||
|
},
|
||||||
|
"customCron": "Cron-Ausdruck",
|
||||||
|
"customCronHelp": "Geben Sie einen gültigen Cron-Ausdruck ein (z.B. 0 3 * * *)",
|
||||||
|
"retention": "Aufbewahrungszeitraum",
|
||||||
|
"retentionDays": "Backups aufbewahren für",
|
||||||
|
"retentionHelp": "Tage (ältere Backups werden automatisch gelöscht)"
|
||||||
|
},
|
||||||
|
"whatToBackup": {
|
||||||
|
"title": "Was soll gesichert werden",
|
||||||
|
"database": "Datenbank",
|
||||||
|
"databaseHelp": "Alle Veranstaltungsdaten, Einstellungen und Konfigurationen",
|
||||||
|
"photos": "Fotos",
|
||||||
|
"photosHelp": "Alle hochgeladenen Fotos in aktiven Galerien",
|
||||||
|
"archives": "Archive",
|
||||||
|
"archivesHelp": "Archivierte Veranstaltungs-ZIP-Dateien",
|
||||||
|
"thumbnails": "Miniaturbilder",
|
||||||
|
"thumbnailsHelp": "Generierte Miniaturbilder (können neu erstellt werden)",
|
||||||
|
"tempFiles": "Temporäre Dateien",
|
||||||
|
"tempFilesHelp": "Temporäre Upload- und Verarbeitungsdateien"
|
||||||
|
},
|
||||||
|
"advancedOptions": {
|
||||||
|
"title": "Erweiterte Optionen",
|
||||||
|
"compression": "Komprimierung aktivieren",
|
||||||
|
"compressionHelp": "Backup-Dateien komprimieren, um Speicherplatz zu sparen",
|
||||||
|
"encryption": "Verschlüsselung aktivieren",
|
||||||
|
"encryptionHelp": "Backups für zusätzliche Sicherheit verschlüsseln",
|
||||||
|
"encryptionPassphrase": "Verschlüsselungs-Passphrase",
|
||||||
|
"encryptionPassphraseHelp": "Starke Passphrase für Backup-Verschlüsselung",
|
||||||
|
"confirmPassphrase": "Passphrase bestätigen",
|
||||||
|
"passphrasesDontMatch": "Passphrasen stimmen nicht überein"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"requiredFields": "Bitte füllen Sie alle erforderlichen Felder aus",
|
||||||
|
"invalidCron": "Ungültiger Cron-Ausdruck",
|
||||||
|
"connectionTestFailed": "Verbindungstest fehlgeschlagen",
|
||||||
|
"connectionTestSuccess": "Verbindungstest erfolgreich!"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"requiredFields": "Bitte füllen Sie alle erforderlichen Felder aus",
|
||||||
|
"connectionSuccess": "Verbindungstest erfolgreich!",
|
||||||
|
"connectionFailed": "Verbindungstest fehlgeschlagen"
|
||||||
|
},
|
||||||
|
"testingConnection": "Teste Verbindung...",
|
||||||
|
"saveSettings": "Konfiguration speichern",
|
||||||
|
"savingSettings": "Speichern..."
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"searchPlaceholder": "Backups suchen...",
|
||||||
|
"allStatus": "Alle Status",
|
||||||
|
"status": {
|
||||||
|
"completed": "Abgeschlossen",
|
||||||
|
"failed": "Fehlgeschlagen",
|
||||||
|
"running": "Läuft",
|
||||||
|
"partial": "Teilweise"
|
||||||
|
},
|
||||||
|
"deleteConfirm": "Sind Sie sicher, dass Sie dieses Backup vom {{date}} löschen möchten?",
|
||||||
|
"noBackups": "Keine Backups gefunden",
|
||||||
|
"tableHeaders": {
|
||||||
|
"date": "Datum",
|
||||||
|
"type": "Typ",
|
||||||
|
"status": "Status",
|
||||||
|
"size": "Größe",
|
||||||
|
"duration": "Dauer",
|
||||||
|
"actions": "Aktionen"
|
||||||
|
},
|
||||||
|
"columns": {
|
||||||
|
"status": "Status",
|
||||||
|
"dateTime": "Datum & Zeit",
|
||||||
|
"type": "Typ",
|
||||||
|
"size": "Größe",
|
||||||
|
"duration": "Dauer",
|
||||||
|
"actions": "Aktionen"
|
||||||
|
},
|
||||||
|
"details": "Details",
|
||||||
|
"statistics": "Statistiken",
|
||||||
|
"errors": "Fehler",
|
||||||
|
"backupDetails": {
|
||||||
|
"backupId": "Backup-ID",
|
||||||
|
"startTime": "Startzeit",
|
||||||
|
"endTime": "Endzeit",
|
||||||
|
"destination": "Ziel",
|
||||||
|
"filesProcessed": "Verarbeitete Dateien",
|
||||||
|
"totalSize": "Gesamtgröße",
|
||||||
|
"compressionRatio": "Komprimierungsverhältnis",
|
||||||
|
"errorLog": "Fehlerprotokoll",
|
||||||
|
"noErrors": "Keine Fehler aufgetreten"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"showing": "Zeige {{from}}-{{to}} von {{total}} Backups",
|
||||||
|
"previous": "Zurück",
|
||||||
|
"next": "Weiter"
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"allStatus": "Alle Status",
|
||||||
|
"completed": "Abgeschlossen",
|
||||||
|
"failed": "Fehlgeschlagen",
|
||||||
|
"running": "Läuft",
|
||||||
|
"partial": "Teilweise"
|
||||||
|
},
|
||||||
|
"noBackupsFound": "Keine Backups gefunden",
|
||||||
|
"backupsWillAppear": "Backups werden hier angezeigt, sobald sie erstellt wurden",
|
||||||
|
"messages": {
|
||||||
|
"deleteSuccess": "Backup erfolgreich gelöscht"
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"backupDetails": "Backup-Details",
|
||||||
|
"destination": "Ziel",
|
||||||
|
"started": "Gestartet",
|
||||||
|
"completed": "Abgeschlossen",
|
||||||
|
"contentBackedUp": "Gesicherter Inhalt",
|
||||||
|
"errorDetails": "Fehlerdetails",
|
||||||
|
"manifest": "Manifest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"restore": {
|
||||||
|
"steps": {
|
||||||
|
"selectSource": "Quelle auswählen",
|
||||||
|
"chooseBackup": "Backup auswählen",
|
||||||
|
"restoreOptions": "Wiederherstellungsoptionen",
|
||||||
|
"reviewConfirm": "Überprüfen & Bestätigen",
|
||||||
|
"progress": "Wiederherstellungsfortschritt"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"title": "Backup-Quelle auswählen",
|
||||||
|
"subtitle": "Wählen Sie, woher das Backup wiederhergestellt werden soll",
|
||||||
|
"local": {
|
||||||
|
"name": "Lokales Backup",
|
||||||
|
"description": "Vom lokalen Dateisystem wiederherstellen"
|
||||||
|
},
|
||||||
|
"s3": {
|
||||||
|
"name": "S3-Speicher",
|
||||||
|
"description": "Aus S3-Bucket wiederherstellen"
|
||||||
|
},
|
||||||
|
"upload": {
|
||||||
|
"name": "Backup hochladen",
|
||||||
|
"description": "Eine Backup-Datei hochladen",
|
||||||
|
"comingSoon": "Upload-Funktion kommt bald"
|
||||||
|
},
|
||||||
|
"configuration": {
|
||||||
|
"s3": "S3-Konfiguration",
|
||||||
|
"endpoint": "S3-Endpunkt-URL",
|
||||||
|
"bucket": "Bucket-Name",
|
||||||
|
"accessKey": "Zugriffsschlüssel-ID",
|
||||||
|
"secretKey": "Geheimer Zugriffsschlüssel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backup": {
|
||||||
|
"title": "Backup für Wiederherstellung auswählen",
|
||||||
|
"subtitle": "Aus verfügbaren Backups auswählen",
|
||||||
|
"noBackupsFound": "Keine Backups in ausgewählter Quelle gefunden",
|
||||||
|
"encrypted": "Verschlüsseltes Backup",
|
||||||
|
"encryptedMessage": "Sie müssen die Verschlüsselungs-Passphrase angeben, um dieses Backup wiederherzustellen.",
|
||||||
|
"enterPassphrase": "Verschlüsselungs-Passphrase eingeben",
|
||||||
|
"at": "um"
|
||||||
|
},
|
||||||
|
"restoreTypes": {
|
||||||
|
"full": {
|
||||||
|
"name": "Vollständige Wiederherstellung",
|
||||||
|
"description": "Alles wiederherstellen, einschließlich Datenbank, Fotos und Archive",
|
||||||
|
"warning": "Dies ersetzt alle aktuellen Daten"
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"name": "Nur Datenbank",
|
||||||
|
"description": "Nur die Datenbank wiederherstellen (Einstellungen, Veranstaltungen, Benutzer)",
|
||||||
|
"warning": "Aktuelle Datenbank wird ersetzt"
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"name": "Nur Dateien",
|
||||||
|
"description": "Nur Fotos und Archive wiederherstellen",
|
||||||
|
"warning": "Vorhandene Dateien können überschrieben werden"
|
||||||
|
},
|
||||||
|
"selective": {
|
||||||
|
"name": "Selektive Wiederherstellung",
|
||||||
|
"description": "Bestimmte Elemente zur Wiederherstellung auswählen",
|
||||||
|
"warning": "Nur ausgewählte Elemente werden wiederhergestellt"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"title": "Wiederherstellungsoptionen",
|
||||||
|
"subtitle": "Wählen Sie, was wiederhergestellt werden soll",
|
||||||
|
"additionalOptions": {
|
||||||
|
"title": "Zusätzliche Optionen",
|
||||||
|
"skipPreBackup": "Vor-Wiederherstellungs-Backup überspringen",
|
||||||
|
"skipPreBackupHelp": "Standardmäßig wird vor der Wiederherstellung ein Backup erstellt. Aktivieren Sie dies, um es zu überspringen.",
|
||||||
|
"force": "Wiederherstellung erzwingen",
|
||||||
|
"forceHelp": "Sicherheitsprüfungen und Warnungen überschreiben (mit Vorsicht verwenden)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"confirmation": {
|
||||||
|
"title": "Überprüfen & Bestätigen",
|
||||||
|
"subtitle": "Bitte überprüfen Sie Ihre Wiederherstellungskonfiguration",
|
||||||
|
"validation": {
|
||||||
|
"passed": "Validierung bestanden",
|
||||||
|
"failed": "Validierung fehlgeschlagen",
|
||||||
|
"checking": "Validiere Wiederherstellungskonfiguration..."
|
||||||
|
},
|
||||||
|
"spaceCheck": {
|
||||||
|
"title": "Speicherplatz",
|
||||||
|
"required": "Erforderlich",
|
||||||
|
"available": "Verfügbar",
|
||||||
|
"insufficient": "Unzureichender Speicherplatz"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"title": "Wiederherstellungszusammenfassung",
|
||||||
|
"source": "Quelle",
|
||||||
|
"backupDate": "Backup-Datum",
|
||||||
|
"restoreType": "Wiederherstellungstyp",
|
||||||
|
"preBackup": "Vor-Backup",
|
||||||
|
"enabled": "Aktiviert",
|
||||||
|
"skipped": "Übersprungen"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"title": "Wichtiger Hinweis",
|
||||||
|
"message": "Diese Wiederherstellungsoperation ersetzt vorhandene Daten. Stellen Sie sicher, dass Sie ein aktuelles Backup haben, bevor Sie fortfahren. Diese Aktion kann nicht rückgängig gemacht werden."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"title": "Wiederherstellungsfortschritt",
|
||||||
|
"inProgress": "Wiederherstellung läuft...",
|
||||||
|
"completed": "Wiederherstellung abgeschlossen",
|
||||||
|
"overallProgress": "Gesamtfortschritt",
|
||||||
|
"current": "Aktuell",
|
||||||
|
"statusDetails": "Status-Details",
|
||||||
|
"restoreLogs": "Wiederherstellungsprotokolle",
|
||||||
|
"steps": {
|
||||||
|
"completed": "Abgeschlossen",
|
||||||
|
"running": "Läuft",
|
||||||
|
"failed": "Fehlgeschlagen",
|
||||||
|
"pending": "Ausstehend"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"title": "Wiederherstellung erfolgreich abgeschlossen",
|
||||||
|
"message": "Ihre Daten wurden wiederhergestellt. Bitte überprüfen Sie, ob alles korrekt funktioniert."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"back": "Zurück",
|
||||||
|
"next": "Weiter",
|
||||||
|
"startRestore": "Wiederherstellung starten",
|
||||||
|
"starting": "Starte...",
|
||||||
|
"validating": "Validiere...",
|
||||||
|
"startNewRestore": "Neue Wiederherstellung starten"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"restoreStarted": "Wiederherstellung erfolgreich gestartet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"backupStarted": "Backup erfolgreich gestartet",
|
||||||
|
"backupFailed": "Backup konnte nicht gestartet werden",
|
||||||
|
"configUpdated": "Backup-Konfiguration aktualisiert",
|
||||||
|
"configUpdateFailed": "Konfiguration konnte nicht aktualisiert werden",
|
||||||
|
"backupDeleted": "Backup erfolgreich gelöscht",
|
||||||
|
"deleteFailed": "Backup konnte nicht gelöscht werden",
|
||||||
|
"testEmailSent": "Verbindungstest erfolgreich!",
|
||||||
|
"testEmailFailed": "Verbindungstest fehlgeschlagen"
|
||||||
|
}
|
||||||
|
},
|
||||||
"maintenance": {
|
"maintenance": {
|
||||||
"title": "Systemwartung",
|
"title": "Systemwartung",
|
||||||
"message": "Wir führen derzeit geplante Wartungsarbeiten durch, um unseren Service zu verbessern. Wir sind in Kürze wieder online.",
|
"message": "Wir führen derzeit geplante Wartungsarbeiten durch, um unseren Service zu verbessern. Wir sind in Kürze wieder online.",
|
||||||
|
|||||||
@@ -35,7 +35,8 @@
|
|||||||
"days": "days",
|
"days": "days",
|
||||||
"customize": "Customize",
|
"customize": "Customize",
|
||||||
"hide": "Hide",
|
"hide": "Hide",
|
||||||
"unknown": "Unknown"
|
"unknown": "Unknown",
|
||||||
|
"chunk": "Chunk"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"photoCategory": "Photo Category",
|
"photoCategory": "Photo Category",
|
||||||
@@ -51,7 +52,8 @@
|
|||||||
"uploadPhotos": "Upload Photos",
|
"uploadPhotos": "Upload Photos",
|
||||||
"maxFilesReached": "Maximum 500 files allowed",
|
"maxFilesReached": "Maximum 500 files allowed",
|
||||||
"someFilesSkipped": "Some files were skipped (500 file limit)",
|
"someFilesSkipped": "Some files were skipped (500 file limit)",
|
||||||
"tooManyFiles": "Maximum 500 files can be uploaded at once"
|
"tooManyFiles": "Maximum 500 files can be uploaded at once",
|
||||||
|
"uploadingChunks": "Uploading {{count}} files in {{total}} batches..."
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
@@ -953,6 +955,410 @@
|
|||||||
"datenschutz": "Privacy Policy",
|
"datenschutz": "Privacy Policy",
|
||||||
"pageUpdated": "Page updated successfully"
|
"pageUpdated": "Page updated successfully"
|
||||||
},
|
},
|
||||||
|
"backup": {
|
||||||
|
"title": "Backup Management",
|
||||||
|
"subtitle": "Manage system backups, configure automated backups, and restore from previous backups.",
|
||||||
|
"tabs": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"configuration": "Configuration",
|
||||||
|
"history": "Backup History",
|
||||||
|
"restore": "Restore"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"inProgress": "Backup in progress...",
|
||||||
|
"lastBackup": "Last backup",
|
||||||
|
"noBackups": "No backups found",
|
||||||
|
"nextBackup": "Next backup",
|
||||||
|
"notScheduled": "Not scheduled",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"runBackupNow": "Run Backup Now",
|
||||||
|
"starting": "Starting...",
|
||||||
|
"running": "Running...",
|
||||||
|
"testConnection": "Test Connection",
|
||||||
|
"save": "Save Configuration",
|
||||||
|
"delete": "Delete",
|
||||||
|
"view": "View Details",
|
||||||
|
"download": "Download",
|
||||||
|
"refresh": "Refresh"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"backupHealth": "Backup Health",
|
||||||
|
"healthStatus": {
|
||||||
|
"excellent": "Excellent",
|
||||||
|
"good": "Good",
|
||||||
|
"warning": "Warning",
|
||||||
|
"critical": "Critical"
|
||||||
|
},
|
||||||
|
"health": {
|
||||||
|
"title": "Backup Health"
|
||||||
|
},
|
||||||
|
"healthMessages": {
|
||||||
|
"noBackups": "No backups found",
|
||||||
|
"lastBackupFailed": "Last backup failed",
|
||||||
|
"upToDate": "Backup is up to date",
|
||||||
|
"recent": "Backup is recent",
|
||||||
|
"gettingOld": "Backup is getting old",
|
||||||
|
"outdated": "Backup is outdated"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"totalBackups": "Total Backups",
|
||||||
|
"backupSize": "Backup Size",
|
||||||
|
"lastDuration": "Last Duration",
|
||||||
|
"backupStatus": "Backup Status",
|
||||||
|
"last": "Last",
|
||||||
|
"files": "files",
|
||||||
|
"minutes": "{{count}}m",
|
||||||
|
"active": "Active",
|
||||||
|
"inactive": "Inactive",
|
||||||
|
"noBackupsYet": "No backups yet"
|
||||||
|
},
|
||||||
|
"recentActivity": {
|
||||||
|
"title": "Recent Backup Activity"
|
||||||
|
},
|
||||||
|
"notConfigured": {
|
||||||
|
"title": "Backup Not Configured",
|
||||||
|
"message": "Please configure backup settings in the Configuration tab before running backups."
|
||||||
|
},
|
||||||
|
"coverage": {
|
||||||
|
"title": "Backup Coverage",
|
||||||
|
"database": "Database",
|
||||||
|
"photos": "Photos",
|
||||||
|
"archives": "Archives",
|
||||||
|
"systemFiles": "System Files",
|
||||||
|
"included": "Included",
|
||||||
|
"excluded": "Excluded",
|
||||||
|
"optional": "Optional"
|
||||||
|
},
|
||||||
|
"storageDestination": "Storage Destination",
|
||||||
|
"nextScheduledBackup": "Next Scheduled Backup",
|
||||||
|
"backupType": "{{type}} backup",
|
||||||
|
"noDestinationSet": "No destination set"
|
||||||
|
},
|
||||||
|
"configuration": {
|
||||||
|
"enableBackup": "Enable Automated Backups",
|
||||||
|
"enableBackupHelp": "Automatically create backups according to the configured schedule",
|
||||||
|
"destinationType": "Backup Destination",
|
||||||
|
"destinationTypes": {
|
||||||
|
"local": {
|
||||||
|
"name": "Local Storage",
|
||||||
|
"description": "Store backups on the local server filesystem"
|
||||||
|
},
|
||||||
|
"rsync": {
|
||||||
|
"name": "Remote Server (Rsync)",
|
||||||
|
"description": "Sync backups to a remote server via SSH/Rsync"
|
||||||
|
},
|
||||||
|
"s3": {
|
||||||
|
"name": "S3 Compatible Storage",
|
||||||
|
"description": "Store backups in Amazon S3 or compatible object storage"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"destinationPath": "Destination Path",
|
||||||
|
"destinationPathHelp": "Local directory path for storing backups",
|
||||||
|
"destinationPathPlaceholder": "/path/to/backup/directory",
|
||||||
|
"rsyncHost": "Remote Host",
|
||||||
|
"rsyncHostHelp": "SSH hostname or IP address",
|
||||||
|
"rsyncHostPlaceholder": "backup.example.com",
|
||||||
|
"rsyncUser": "SSH User",
|
||||||
|
"rsyncUserHelp": "Username for SSH connection",
|
||||||
|
"rsyncUserPlaceholder": "backup-user",
|
||||||
|
"rsyncPath": "Remote Path",
|
||||||
|
"rsyncPathHelp": "Directory path on remote server",
|
||||||
|
"rsyncPathPlaceholder": "/home/backup/photo-sharing",
|
||||||
|
"rsyncSshKey": "SSH Private Key",
|
||||||
|
"rsyncSshKeyHelp": "SSH private key for authentication (optional)",
|
||||||
|
"rsyncSshKeyPlaceholder": "-----BEGIN RSA PRIVATE KEY-----",
|
||||||
|
"s3Endpoint": "S3 Endpoint",
|
||||||
|
"s3EndpointHelp": "S3 API endpoint (e.g., s3.amazonaws.com)",
|
||||||
|
"s3EndpointPlaceholder": "https://s3.amazonaws.com",
|
||||||
|
"s3Bucket": "Bucket Name",
|
||||||
|
"s3BucketHelp": "S3 bucket for storing backups",
|
||||||
|
"s3BucketPlaceholder": "my-backup-bucket",
|
||||||
|
"s3AccessKey": "Access Key ID",
|
||||||
|
"s3AccessKeyHelp": "AWS/S3 access key ID",
|
||||||
|
"s3AccessKeyPlaceholder": "AKIAIOSFODNN7EXAMPLE",
|
||||||
|
"s3SecretKey": "Secret Access Key",
|
||||||
|
"s3SecretKeyHelp": "AWS/S3 secret access key",
|
||||||
|
"s3SecretKeyPlaceholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||||
|
"s3Region": "Region",
|
||||||
|
"s3RegionHelp": "S3 region (e.g., us-east-1)",
|
||||||
|
"s3RegionPlaceholder": "us-east-1"
|
||||||
|
},
|
||||||
|
"schedule": {
|
||||||
|
"title": "Backup Schedule",
|
||||||
|
"scheduleType": "Schedule Type",
|
||||||
|
"scheduleOptions": {
|
||||||
|
"hourly": "Every hour",
|
||||||
|
"daily": "Daily",
|
||||||
|
"weekly": "Weekly",
|
||||||
|
"custom": "Custom cron expression"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"hourly": "Every hour",
|
||||||
|
"daily": "Daily",
|
||||||
|
"weekly": "Weekly",
|
||||||
|
"custom": "Custom cron expression"
|
||||||
|
},
|
||||||
|
"customCron": "Cron Expression",
|
||||||
|
"customCronHelp": "Enter a valid cron expression (e.g., 0 3 * * *)",
|
||||||
|
"retention": "Retention Period",
|
||||||
|
"retentionDays": "Keep backups for",
|
||||||
|
"retentionHelp": "days (older backups will be automatically deleted)"
|
||||||
|
},
|
||||||
|
"whatToBackup": {
|
||||||
|
"title": "What to Backup",
|
||||||
|
"database": "Database",
|
||||||
|
"databaseHelp": "All event data, settings, and configurations",
|
||||||
|
"photos": "Photos",
|
||||||
|
"photosHelp": "All uploaded photos in active galleries",
|
||||||
|
"archives": "Archives",
|
||||||
|
"archivesHelp": "Archived event ZIP files",
|
||||||
|
"thumbnails": "Thumbnails",
|
||||||
|
"thumbnailsHelp": "Generated thumbnail images (can be recreated)",
|
||||||
|
"tempFiles": "Temporary Files",
|
||||||
|
"tempFilesHelp": "Temporary upload and processing files"
|
||||||
|
},
|
||||||
|
"advancedOptions": {
|
||||||
|
"title": "Advanced Options",
|
||||||
|
"compression": "Enable Compression",
|
||||||
|
"compressionHelp": "Compress backup files to save storage space",
|
||||||
|
"encryption": "Enable Encryption",
|
||||||
|
"encryptionHelp": "Encrypt backups for additional security",
|
||||||
|
"encryptionPassphrase": "Encryption Passphrase",
|
||||||
|
"encryptionPassphraseHelp": "Strong passphrase for backup encryption",
|
||||||
|
"confirmPassphrase": "Confirm Passphrase",
|
||||||
|
"passphrasesDontMatch": "Passphrases don't match"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"requiredFields": "Please fill in all required fields",
|
||||||
|
"invalidCron": "Invalid cron expression",
|
||||||
|
"connectionTestFailed": "Connection test failed",
|
||||||
|
"connectionTestSuccess": "Connection test successful!"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"requiredFields": "Please fill in all required fields",
|
||||||
|
"connectionSuccess": "Connection test successful!",
|
||||||
|
"connectionFailed": "Connection test failed"
|
||||||
|
},
|
||||||
|
"testingConnection": "Testing connection...",
|
||||||
|
"saveSettings": "Save Configuration",
|
||||||
|
"savingSettings": "Saving..."
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"searchPlaceholder": "Search backups...",
|
||||||
|
"allStatus": "All Status",
|
||||||
|
"status": {
|
||||||
|
"completed": "Completed",
|
||||||
|
"failed": "Failed",
|
||||||
|
"running": "Running",
|
||||||
|
"partial": "Partial"
|
||||||
|
},
|
||||||
|
"deleteConfirm": "Are you sure you want to delete this backup from {{date}}?",
|
||||||
|
"noBackups": "No backups found",
|
||||||
|
"tableHeaders": {
|
||||||
|
"date": "Date",
|
||||||
|
"type": "Type",
|
||||||
|
"status": "Status",
|
||||||
|
"size": "Size",
|
||||||
|
"duration": "Duration",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"columns": {
|
||||||
|
"status": "Status",
|
||||||
|
"dateTime": "Date & Time",
|
||||||
|
"type": "Type",
|
||||||
|
"size": "Size",
|
||||||
|
"duration": "Duration",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"details": "Details",
|
||||||
|
"statistics": "Statistics",
|
||||||
|
"errors": "Errors",
|
||||||
|
"backupDetails": {
|
||||||
|
"backupId": "Backup ID",
|
||||||
|
"startTime": "Start Time",
|
||||||
|
"endTime": "End Time",
|
||||||
|
"destination": "Destination",
|
||||||
|
"filesProcessed": "Files Processed",
|
||||||
|
"totalSize": "Total Size",
|
||||||
|
"compressionRatio": "Compression Ratio",
|
||||||
|
"errorLog": "Error Log",
|
||||||
|
"noErrors": "No errors occurred"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"showing": "Showing {{from}}-{{to}} of {{total}} backups",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next"
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"allStatus": "All Status",
|
||||||
|
"completed": "Completed",
|
||||||
|
"failed": "Failed",
|
||||||
|
"running": "Running",
|
||||||
|
"partial": "Partial"
|
||||||
|
},
|
||||||
|
"noBackupsFound": "No backups found",
|
||||||
|
"backupsWillAppear": "Backups will appear here once created",
|
||||||
|
"messages": {
|
||||||
|
"deleteSuccess": "Backup deleted successfully"
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"backupDetails": "Backup Details",
|
||||||
|
"destination": "Destination",
|
||||||
|
"started": "Started",
|
||||||
|
"completed": "Completed",
|
||||||
|
"contentBackedUp": "Content Backed Up",
|
||||||
|
"errorDetails": "Error Details",
|
||||||
|
"manifest": "Manifest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"restore": {
|
||||||
|
"steps": {
|
||||||
|
"selectSource": "Select Source",
|
||||||
|
"chooseBackup": "Choose Backup",
|
||||||
|
"restoreOptions": "Restore Options",
|
||||||
|
"reviewConfirm": "Review & Confirm",
|
||||||
|
"progress": "Restore Progress"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"title": "Select Backup Source",
|
||||||
|
"subtitle": "Choose where to restore the backup from",
|
||||||
|
"local": {
|
||||||
|
"name": "Local Backup",
|
||||||
|
"description": "Restore from local filesystem"
|
||||||
|
},
|
||||||
|
"s3": {
|
||||||
|
"name": "S3 Storage",
|
||||||
|
"description": "Restore from S3 bucket"
|
||||||
|
},
|
||||||
|
"upload": {
|
||||||
|
"name": "Upload Backup",
|
||||||
|
"description": "Upload a backup file",
|
||||||
|
"comingSoon": "Upload functionality coming soon"
|
||||||
|
},
|
||||||
|
"configuration": {
|
||||||
|
"s3": "S3 Configuration",
|
||||||
|
"endpoint": "S3 Endpoint URL",
|
||||||
|
"bucket": "Bucket Name",
|
||||||
|
"accessKey": "Access Key ID",
|
||||||
|
"secretKey": "Secret Access Key"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backup": {
|
||||||
|
"title": "Choose Backup to Restore",
|
||||||
|
"subtitle": "Select from available backups",
|
||||||
|
"noBackupsFound": "No backups found in selected source",
|
||||||
|
"encrypted": "Encrypted Backup",
|
||||||
|
"encryptedMessage": "You'll need to provide the encryption passphrase to restore this backup.",
|
||||||
|
"enterPassphrase": "Enter encryption passphrase",
|
||||||
|
"at": "at"
|
||||||
|
},
|
||||||
|
"restoreTypes": {
|
||||||
|
"full": {
|
||||||
|
"name": "Full Restore",
|
||||||
|
"description": "Restore everything including database, photos, and archives",
|
||||||
|
"warning": "This will replace all current data"
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"name": "Database Only",
|
||||||
|
"description": "Restore only the database (settings, events, users)",
|
||||||
|
"warning": "Current database will be replaced"
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"name": "Files Only",
|
||||||
|
"description": "Restore only photos and archives",
|
||||||
|
"warning": "Existing files may be overwritten"
|
||||||
|
},
|
||||||
|
"selective": {
|
||||||
|
"name": "Selective Restore",
|
||||||
|
"description": "Choose specific items to restore",
|
||||||
|
"warning": "Only selected items will be restored"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"title": "Restore Options",
|
||||||
|
"subtitle": "Choose what to restore",
|
||||||
|
"additionalOptions": {
|
||||||
|
"title": "Additional Options",
|
||||||
|
"skipPreBackup": "Skip Pre-Restore Backup",
|
||||||
|
"skipPreBackupHelp": "By default, a backup is created before restore. Check this to skip it.",
|
||||||
|
"force": "Force Restore",
|
||||||
|
"forceHelp": "Override safety checks and warnings (use with caution)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"confirmation": {
|
||||||
|
"title": "Review & Confirm",
|
||||||
|
"subtitle": "Please review your restore configuration",
|
||||||
|
"validation": {
|
||||||
|
"passed": "Validation Passed",
|
||||||
|
"failed": "Validation Failed",
|
||||||
|
"checking": "Validating restore configuration..."
|
||||||
|
},
|
||||||
|
"spaceCheck": {
|
||||||
|
"title": "Storage Space",
|
||||||
|
"required": "Required",
|
||||||
|
"available": "Available",
|
||||||
|
"insufficient": "Insufficient storage space"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"title": "Restore Summary",
|
||||||
|
"source": "Source",
|
||||||
|
"backupDate": "Backup Date",
|
||||||
|
"restoreType": "Restore Type",
|
||||||
|
"preBackup": "Pre-backup",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"skipped": "Skipped"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"title": "Important Notice",
|
||||||
|
"message": "This restore operation will replace existing data. Make sure you have a current backup before proceeding. This action cannot be undone."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"title": "Restore Progress",
|
||||||
|
"inProgress": "Restore in progress...",
|
||||||
|
"completed": "Restore completed",
|
||||||
|
"overallProgress": "Overall Progress",
|
||||||
|
"current": "Current",
|
||||||
|
"statusDetails": "Status Details",
|
||||||
|
"restoreLogs": "Restore Logs",
|
||||||
|
"steps": {
|
||||||
|
"completed": "Completed",
|
||||||
|
"running": "Running",
|
||||||
|
"failed": "Failed",
|
||||||
|
"pending": "Pending"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"title": "Restore Completed Successfully",
|
||||||
|
"message": "Your data has been restored. Please verify everything is working correctly."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"startRestore": "Start Restore",
|
||||||
|
"starting": "Starting...",
|
||||||
|
"validating": "Validating...",
|
||||||
|
"startNewRestore": "Start New Restore"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"restoreStarted": "Restore started successfully"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"backupStarted": "Backup started successfully",
|
||||||
|
"backupFailed": "Failed to start backup",
|
||||||
|
"configUpdated": "Backup configuration updated",
|
||||||
|
"configUpdateFailed": "Failed to update configuration",
|
||||||
|
"backupDeleted": "Backup deleted successfully",
|
||||||
|
"deleteFailed": "Failed to delete backup",
|
||||||
|
"testEmailSent": "Test connection successful!",
|
||||||
|
"testEmailFailed": "Connection test failed"
|
||||||
|
}
|
||||||
|
},
|
||||||
"maintenance": {
|
"maintenance": {
|
||||||
"title": "System Maintenance",
|
"title": "System Maintenance",
|
||||||
"message": "We're currently performing scheduled maintenance to improve our service. We'll be back online shortly.",
|
"message": "We're currently performing scheduled maintenance to improve our service. We'll be back online shortly.",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Button, Card, Loading } from '../../components/common';
|
import { Button, Card, Loading } from '../../components/common';
|
||||||
import { BackupDashboard } from '../../components/admin/BackupDashboard';
|
import { BackupDashboard } from '../../components/admin/BackupDashboard';
|
||||||
@@ -33,17 +34,20 @@ import { BackupHistory } from '../../components/admin/BackupHistory';
|
|||||||
import { RestoreWizard } from '../../components/admin/RestoreWizard';
|
import { RestoreWizard } from '../../components/admin/RestoreWizard';
|
||||||
import { api } from '../../config/api';
|
import { api } from '../../config/api';
|
||||||
|
|
||||||
// Tab components
|
// Tab components will be defined inside the component to use translations
|
||||||
const tabs = [
|
|
||||||
{ id: 'dashboard', label: 'Dashboard', icon: HardDrive },
|
|
||||||
{ id: 'configuration', label: 'Configuration', icon: Settings },
|
|
||||||
{ id: 'history', label: 'Backup History', icon: History },
|
|
||||||
{ id: 'restore', label: 'Restore', icon: RefreshCw }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const BackupManagement = () => {
|
export const BackupManagement = () => {
|
||||||
const [activeTab, setActiveTab] = useState('dashboard');
|
const [activeTab, setActiveTab] = useState('dashboard');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Tab components with translations
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'dashboard', label: t('backup.tabs.dashboard'), icon: HardDrive },
|
||||||
|
{ id: 'configuration', label: t('backup.tabs.configuration'), icon: Settings },
|
||||||
|
{ id: 'history', label: t('backup.tabs.history'), icon: History },
|
||||||
|
{ id: 'restore', label: t('backup.tabs.restore'), icon: RefreshCw }
|
||||||
|
];
|
||||||
|
|
||||||
// Fetch backup status
|
// Fetch backup status
|
||||||
const { data: backupStatus, isLoading: statusLoading } = useQuery({
|
const { data: backupStatus, isLoading: statusLoading } = useQuery({
|
||||||
@@ -71,11 +75,11 @@ export const BackupManagement = () => {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Backup started successfully');
|
toast.success(t('backup.messages.backupStarted'));
|
||||||
queryClient.invalidateQueries({ queryKey: ['backup-status'] });
|
queryClient.invalidateQueries({ queryKey: ['backup-status'] });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const message = error.response?.data?.error || 'Failed to start backup';
|
const message = error.response?.data?.error || t('backup.messages.backupFailed');
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -87,11 +91,11 @@ export const BackupManagement = () => {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Backup configuration updated');
|
toast.success(t('backup.messages.configUpdated'));
|
||||||
queryClient.invalidateQueries({ queryKey: ['backup-config'] });
|
queryClient.invalidateQueries({ queryKey: ['backup-config'] });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const message = error.response?.data?.error || 'Failed to update configuration';
|
const message = error.response?.data?.error || t('backup.messages.configUpdateFailed');
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -108,9 +112,9 @@ export const BackupManagement = () => {
|
|||||||
<div className="p-8 max-w-7xl mx-auto">
|
<div className="p-8 max-w-7xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Backup Management</h1>
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">{t('backup.title')}</h1>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
Manage system backups, configure automated backups, and restore from previous backups.
|
{t('backup.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -122,19 +126,19 @@ export const BackupManagement = () => {
|
|||||||
{backupStatus?.isRunning ? (
|
{backupStatus?.isRunning ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-5 w-5 text-blue-500 animate-spin" />
|
<Loader2 className="h-5 w-5 text-blue-500 animate-spin" />
|
||||||
<span className="text-blue-600 font-medium">Backup in progress...</span>
|
<span className="text-blue-600 font-medium">{t('backup.status.inProgress')}</span>
|
||||||
</>
|
</>
|
||||||
) : backupStatus?.lastBackup ? (
|
) : backupStatus?.lastBackup ? (
|
||||||
<>
|
<>
|
||||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||||
<span className="text-gray-700">
|
<span className="text-gray-700">
|
||||||
Last backup: {format(new Date(backupStatus.lastBackup.created_at), 'PPp')}
|
{t('backup.status.lastBackup')}: {format(new Date(backupStatus.lastBackup.created_at), 'PPp')}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<AlertCircle className="h-5 w-5 text-amber-500" />
|
<AlertCircle className="h-5 w-5 text-amber-500" />
|
||||||
<span className="text-gray-700">No backups found</span>
|
<span className="text-gray-700">{t('backup.status.noBackups')}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -143,7 +147,7 @@ export const BackupManagement = () => {
|
|||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Clock className="h-5 w-5 text-gray-400" />
|
<Clock className="h-5 w-5 text-gray-400" />
|
||||||
<span className="text-sm text-gray-600">
|
<span className="text-sm text-gray-600">
|
||||||
Next backup: {backupStatus?.nextBackup || 'Not scheduled'}
|
{t('backup.status.nextBackup')}: {backupStatus?.nextBackup || t('backup.status.notScheduled')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -159,12 +163,12 @@ export const BackupManagement = () => {
|
|||||||
{manualBackupMutation.isLoading ? (
|
{manualBackupMutation.isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Starting...
|
{t('backup.actions.starting')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Play className="mr-2 h-4 w-4" />
|
<Play className="mr-2 h-4 w-4" />
|
||||||
Run Backup Now
|
{t('backup.actions.runBackupNow')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -175,7 +179,7 @@ export const BackupManagement = () => {
|
|||||||
: 'bg-gray-100 text-gray-700'
|
: 'bg-gray-100 text-gray-700'
|
||||||
}`}>
|
}`}>
|
||||||
<Shield className="h-4 w-4" />
|
<Shield className="h-4 w-4" />
|
||||||
<span>{backupConfig?.backup_enabled ? 'Enabled' : 'Disabled'}</span>
|
<span>{backupConfig?.backup_enabled ? t('backup.status.enabled') : t('backup.status.disabled')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { addDays } from 'date-fns';
|
|||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import { Button, Input, Card } from '../../components/common';
|
import { Button, Input, Card } from '../../components/common';
|
||||||
import { ThemeCustomizerEnhanced, GalleryPreview, WelcomeMessageEditor } from '../../components/admin';
|
import { ThemeCustomizerEnhanced, GalleryPreview, WelcomeMessageEditor, FeedbackSettings } from '../../components/admin';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { eventsService } from '../../services/events.service';
|
import { eventsService } from '../../services/events.service';
|
||||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||||
@@ -38,6 +38,19 @@ interface FormData {
|
|||||||
expires_in_days: number;
|
expires_in_days: number;
|
||||||
allow_user_uploads: boolean;
|
allow_user_uploads: boolean;
|
||||||
upload_category_id: number | null;
|
upload_category_id: number | null;
|
||||||
|
feedback_settings: {
|
||||||
|
feedback_enabled: boolean;
|
||||||
|
allow_ratings: boolean;
|
||||||
|
allow_likes: boolean;
|
||||||
|
allow_comments: boolean;
|
||||||
|
allow_favorites: boolean;
|
||||||
|
require_name_email: boolean;
|
||||||
|
moderate_comments: boolean;
|
||||||
|
show_feedback_to_guests: boolean;
|
||||||
|
enable_rate_limiting: boolean;
|
||||||
|
rate_limit_window_minutes?: number;
|
||||||
|
rate_limit_max_requests?: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const EVENT_TYPE_PRESETS: Record<string, string> = {
|
const EVENT_TYPE_PRESETS: Record<string, string> = {
|
||||||
@@ -71,7 +84,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
const [formData, setFormData] = useState<FormData>({
|
const [formData, setFormData] = useState<FormData>({
|
||||||
event_type: 'wedding',
|
event_type: 'wedding',
|
||||||
event_name: '',
|
event_name: '',
|
||||||
event_date: format(new Date(), 'yyyy-MM-dd'),
|
event_date: new Date().toISOString().split('T')[0], // Initialize with ISO date format
|
||||||
host_name: '',
|
host_name: '',
|
||||||
host_email: '',
|
host_email: '',
|
||||||
admin_email: '',
|
admin_email: '',
|
||||||
@@ -83,6 +96,19 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
expires_in_days: 30,
|
expires_in_days: 30,
|
||||||
allow_user_uploads: false,
|
allow_user_uploads: false,
|
||||||
upload_category_id: null,
|
upload_category_id: null,
|
||||||
|
feedback_settings: {
|
||||||
|
feedback_enabled: false,
|
||||||
|
allow_ratings: true,
|
||||||
|
allow_likes: true,
|
||||||
|
allow_comments: true,
|
||||||
|
allow_favorites: true,
|
||||||
|
require_name_email: false,
|
||||||
|
moderate_comments: true,
|
||||||
|
show_feedback_to_guests: true,
|
||||||
|
enable_rate_limiting: true,
|
||||||
|
rate_limit_window_minutes: 15,
|
||||||
|
rate_limit_max_requests: 10,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
|
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
|
||||||
@@ -218,6 +244,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
expiration_days: formData.expires_in_days,
|
expiration_days: formData.expires_in_days,
|
||||||
allow_user_uploads: formData.allow_user_uploads,
|
allow_user_uploads: formData.allow_user_uploads,
|
||||||
upload_category_id: formData.upload_category_id,
|
upload_category_id: formData.upload_category_id,
|
||||||
|
feedback_settings: formData.feedback_settings,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('Submitting payload:', payload);
|
console.log('Submitting payload:', payload);
|
||||||
@@ -553,6 +580,12 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Feedback Settings */}
|
||||||
|
<FeedbackSettings
|
||||||
|
settings={formData.feedback_settings}
|
||||||
|
onChange={(settings) => setFormData(prev => ({ ...prev, feedback_settings: settings }))}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Form Actions */}
|
{/* Form Actions */}
|
||||||
<div className="flex items-center justify-end gap-3">
|
<div className="flex items-center justify-end gap-3">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -16,18 +16,20 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
Image,
|
Image,
|
||||||
Key,
|
Key,
|
||||||
Mail
|
Mail,
|
||||||
|
MessageSquare
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { parseISO, differenceInDays } from 'date-fns';
|
import { parseISO, differenceInDays } from 'date-fns';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||||
|
|
||||||
import { Button, Input, Card, Loading } from '../../components/common';
|
import { Button, Input, Card, Loading } from '../../components/common';
|
||||||
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, PhotoUploadModal } from '../../components/admin';
|
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel } from '../../components/admin';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { eventsService } from '../../services/events.service';
|
import { eventsService } from '../../services/events.service';
|
||||||
import { archiveService } from '../../services/archive.service';
|
import { archiveService } from '../../services/archive.service';
|
||||||
import { photosService, AdminPhoto } from '../../services/photos.service';
|
import { photosService, AdminPhoto } from '../../services/photos.service';
|
||||||
|
import { feedbackService, FeedbackSettings as FeedbackSettingsType } from '../../services/feedback.service';
|
||||||
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||||
|
|
||||||
export const EventDetailsPage: React.FC = () => {
|
export const EventDetailsPage: React.FC = () => {
|
||||||
@@ -54,6 +56,15 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
hero_photo_id: null as number | null,
|
hero_photo_id: null as number | null,
|
||||||
host_name: '',
|
host_name: '',
|
||||||
});
|
});
|
||||||
|
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
|
||||||
|
feedback_enabled: false,
|
||||||
|
allow_ratings: true,
|
||||||
|
allow_likes: true,
|
||||||
|
allow_comments: true,
|
||||||
|
allow_favorites: true,
|
||||||
|
require_moderation: true,
|
||||||
|
show_public_stats: false
|
||||||
|
});
|
||||||
const [copiedLink, setCopiedLink] = useState(false);
|
const [copiedLink, setCopiedLink] = useState(false);
|
||||||
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
|
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories'>('overview');
|
const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories'>('overview');
|
||||||
@@ -77,6 +88,16 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch feedback settings
|
||||||
|
const { data: eventFeedbackSettings } = useQuery({
|
||||||
|
queryKey: ['admin-event-feedback-settings', id],
|
||||||
|
queryFn: () => feedbackService.getEventFeedbackSettings(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setFeedbackSettings(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Statistics are now fetched with the event details from the admin API
|
// Statistics are now fetched with the event details from the admin API
|
||||||
|
|
||||||
// Fetch photos (needed for both photos tab and hero photo selector)
|
// Fetch photos (needed for both photos tab and hero photo selector)
|
||||||
@@ -197,7 +218,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveEdit = () => {
|
const handleSaveEdit = async () => {
|
||||||
// Prepare color_theme - if we have a custom theme, serialize it
|
// Prepare color_theme - if we have a custom theme, serialize it
|
||||||
let themeToSave = editForm.color_theme;
|
let themeToSave = editForm.color_theme;
|
||||||
if (currentTheme && currentPresetName === 'custom') {
|
if (currentTheme && currentPresetName === 'custom') {
|
||||||
@@ -239,7 +260,16 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
|
|
||||||
console.log('Updating event with data:', updateData);
|
console.log('Updating event with data:', updateData);
|
||||||
console.log('Theme length:', updateData.color_theme ? updateData.color_theme.length : 0);
|
console.log('Theme length:', updateData.color_theme ? updateData.color_theme.length : 0);
|
||||||
|
|
||||||
|
// Update event details
|
||||||
updateMutation.mutate(updateData);
|
updateMutation.mutate(updateData);
|
||||||
|
|
||||||
|
// Update feedback settings separately
|
||||||
|
try {
|
||||||
|
await feedbackService.updateEventFeedbackSettings(id!, feedbackSettings);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update feedback settings:', error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyLink = async () => {
|
const handleCopyLink = async () => {
|
||||||
@@ -319,6 +349,16 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
{t('common.edit')}
|
{t('common.edit')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{feedbackSettings?.feedback_enabled && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<MessageSquare className="w-4 h-4" />}
|
||||||
|
onClick={() => navigate(`/admin/events/${id}/feedback`)}
|
||||||
|
>
|
||||||
|
{t('feedback.manage', 'Manage Feedback')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{event.share_link && (
|
{event.share_link && (
|
||||||
@@ -509,6 +549,15 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Feedback Settings */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-neutral-200">
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-900 mb-3">{t('feedback.settings', 'Feedback Settings')}</h3>
|
||||||
|
<FeedbackSettings
|
||||||
|
settings={feedbackSettings}
|
||||||
|
onChange={setFeedbackSettings}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<dl className="space-y-4">
|
<dl className="space-y-4">
|
||||||
@@ -779,6 +828,15 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Feedback Moderation Panel */}
|
||||||
|
{!event.is_archived && feedbackSettings?.feedback_enabled && (
|
||||||
|
<FeedbackModerationPanel
|
||||||
|
eventId={parseInt(id!)}
|
||||||
|
compact={true}
|
||||||
|
maxItems={3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Archive Status */}
|
{/* Archive Status */}
|
||||||
{event.is_archived ? (
|
{event.is_archived ? (
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
|
|||||||
@@ -0,0 +1,525 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
MessageSquare,
|
||||||
|
Star,
|
||||||
|
Heart,
|
||||||
|
TrendingUp,
|
||||||
|
Filter,
|
||||||
|
Download,
|
||||||
|
Shield,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Trash2
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
import { Button, Card, Loading } from '../../components/common';
|
||||||
|
import { FeedbackSettings } from '../../components/admin';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { eventsService } from '../../services/events.service';
|
||||||
|
import { feedbackService } from '../../services/feedback.service';
|
||||||
|
import type { PhotoFeedback, FeedbackAnalytics } from '../../services/feedback.service';
|
||||||
|
|
||||||
|
export const EventFeedbackPage: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<'settings' | 'feedback' | 'analytics' | 'moderation'>('settings');
|
||||||
|
const [feedbackFilter, setFeedbackFilter] = useState({
|
||||||
|
type: '',
|
||||||
|
status: '',
|
||||||
|
page: 1,
|
||||||
|
limit: 20
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch event details
|
||||||
|
const { data: event, isLoading: eventLoading } = useQuery({
|
||||||
|
queryKey: ['event', id],
|
||||||
|
queryFn: () => eventsService.getEvent(id!),
|
||||||
|
enabled: !!id
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch feedback settings
|
||||||
|
const { data: settings, isLoading: settingsLoading } = useQuery({
|
||||||
|
queryKey: ['feedback-settings', id],
|
||||||
|
queryFn: () => feedbackService.getEventFeedbackSettings(id!),
|
||||||
|
enabled: !!id
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch feedback list
|
||||||
|
const { data: feedbackData, isLoading: feedbackLoading } = useQuery({
|
||||||
|
queryKey: ['event-feedback', id, feedbackFilter],
|
||||||
|
queryFn: () => feedbackService.getEventFeedback(id!, feedbackFilter),
|
||||||
|
enabled: !!id && activeTab === 'feedback'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch analytics
|
||||||
|
const { data: analytics, isLoading: analyticsLoading } = useQuery({
|
||||||
|
queryKey: ['feedback-analytics', id],
|
||||||
|
queryFn: () => feedbackService.getEventFeedbackAnalytics(id!),
|
||||||
|
enabled: !!id && activeTab === 'analytics'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update settings mutation
|
||||||
|
const updateSettingsMutation = useMutation({
|
||||||
|
mutationFn: (newSettings: any) => feedbackService.updateEventFeedbackSettings(id!, newSettings),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['feedback-settings', id] });
|
||||||
|
toast.success(t('feedback.settingsUpdated', 'Feedback settings updated'));
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t('feedback.settingsUpdateError', 'Failed to update settings'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Moderate feedback mutation
|
||||||
|
const moderateMutation = useMutation({
|
||||||
|
mutationFn: ({ feedbackId, action }: { feedbackId: string; action: 'approve' | 'hide' | 'reject' }) =>
|
||||||
|
feedbackService.moderateFeedback(feedbackId, action),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['event-feedback', id] });
|
||||||
|
toast.success(t('feedback.moderated', 'Feedback moderated'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete feedback mutation
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (feedbackId: string) => feedbackService.deleteFeedback(feedbackId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['event-feedback', id] });
|
||||||
|
toast.success(t('feedback.deleted', 'Feedback deleted'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export feedback
|
||||||
|
const handleExport = async (format: 'json' | 'csv') => {
|
||||||
|
try {
|
||||||
|
const data = await feedbackService.exportEventFeedback(id!, format);
|
||||||
|
if (format === 'csv') {
|
||||||
|
const blob = new Blob([data], { type: 'text/csv' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `feedback-${event?.slug || id}.csv`;
|
||||||
|
a.click();
|
||||||
|
} else {
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `feedback-${event?.slug || id}.json`;
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
toast.success(t('feedback.exported', 'Feedback exported'));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t('feedback.exportError', 'Failed to export feedback'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (eventLoading || settingsLoading) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return <div>{t('events.notFound', 'Event not found')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<ArrowLeft className="w-4 h-4" />}
|
||||||
|
onClick={() => navigate(`/admin/events/${id}`)}
|
||||||
|
>
|
||||||
|
{t('common.back')}
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-900">
|
||||||
|
{t('feedback.title', 'Feedback Management')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-neutral-600 mt-1">
|
||||||
|
{event.event_name} • {event.slug}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<Download className="w-4 h-4" />}
|
||||||
|
onClick={() => handleExport('csv')}
|
||||||
|
>
|
||||||
|
{t('feedback.exportCSV', 'Export CSV')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<Download className="w-4 h-4" />}
|
||||||
|
onClick={() => handleExport('json')}
|
||||||
|
>
|
||||||
|
{t('feedback.exportJSON', 'Export JSON')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="mb-6 border-b border-neutral-200">
|
||||||
|
<nav className="-mb-px flex gap-6">
|
||||||
|
{[
|
||||||
|
{ id: 'settings', label: t('feedback.tabs.settings', 'Settings'), icon: Shield },
|
||||||
|
{ id: 'feedback', label: t('feedback.tabs.feedback', 'Feedback'), icon: MessageSquare },
|
||||||
|
{ id: 'analytics', label: t('feedback.tabs.analytics', 'Analytics'), icon: TrendingUp },
|
||||||
|
{ id: 'moderation', label: t('feedback.tabs.moderation', 'Moderation'), icon: Filter },
|
||||||
|
].map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id as any)}
|
||||||
|
className={`flex items-center gap-2 px-1 py-2 border-b-2 font-medium text-sm transition-colors ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'border-primary-600 text-primary-600'
|
||||||
|
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<tab.icon className="w-4 h-4" />
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{activeTab === 'settings' && settings && (
|
||||||
|
<FeedbackSettings
|
||||||
|
settings={settings}
|
||||||
|
onChange={(newSettings) => updateSettingsMutation.mutate(newSettings)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'feedback' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filters */}
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 flex gap-4">
|
||||||
|
<select
|
||||||
|
value={feedbackFilter.type}
|
||||||
|
onChange={(e) => setFeedbackFilter({ ...feedbackFilter, type: e.target.value, page: 1 })}
|
||||||
|
className="px-3 py-2 border border-neutral-300 rounded-lg"
|
||||||
|
>
|
||||||
|
<option value="">{t('feedback.allTypes', 'All Types')}</option>
|
||||||
|
<option value="rating">{t('feedback.types.rating', 'Ratings')}</option>
|
||||||
|
<option value="like">{t('feedback.types.like', 'Likes')}</option>
|
||||||
|
<option value="comment">{t('feedback.types.comment', 'Comments')}</option>
|
||||||
|
<option value="favorite">{t('feedback.types.favorite', 'Favorites')}</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={feedbackFilter.status}
|
||||||
|
onChange={(e) => setFeedbackFilter({ ...feedbackFilter, status: e.target.value, page: 1 })}
|
||||||
|
className="px-3 py-2 border border-neutral-300 rounded-lg"
|
||||||
|
>
|
||||||
|
<option value="">{t('feedback.allStatuses', 'All Statuses')}</option>
|
||||||
|
<option value="pending">{t('feedback.status.pending', 'Pending')}</option>
|
||||||
|
<option value="approved">{t('feedback.status.approved', 'Approved')}</option>
|
||||||
|
<option value="hidden">{t('feedback.status.hidden', 'Hidden')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Feedback List */}
|
||||||
|
{feedbackLoading ? (
|
||||||
|
<Loading />
|
||||||
|
) : feedbackData?.feedback?.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<div className="p-8 text-center text-neutral-500">
|
||||||
|
{t('feedback.noFeedback', 'No feedback found')}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{feedbackData?.feedback?.map((item: PhotoFeedback) => (
|
||||||
|
<Card key={item.id} className="overflow-hidden">
|
||||||
|
<div className="p-4 flex items-start gap-4">
|
||||||
|
<img
|
||||||
|
src={`/thumbnails/${item.path}`}
|
||||||
|
alt={item.filename}
|
||||||
|
className="w-16 h-16 object-cover rounded"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
{item.feedback_type === 'rating' && <Star className="w-4 h-4 text-yellow-500" />}
|
||||||
|
{item.feedback_type === 'like' && <Heart className="w-4 h-4 text-red-500" />}
|
||||||
|
{item.feedback_type === 'comment' && <MessageSquare className="w-4 h-4 text-blue-500" />}
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
{item.guest_name || t('feedback.anonymous', 'Anonymous')}
|
||||||
|
</span>
|
||||||
|
{item.guest_email && (
|
||||||
|
<span className="text-xs text-neutral-500">({item.guest_email})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.rating && (
|
||||||
|
<div className="flex gap-1 mb-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<Star
|
||||||
|
key={star}
|
||||||
|
className={`w-4 h-4 ${
|
||||||
|
star <= item.rating! ? 'fill-yellow-500 text-yellow-500' : 'text-neutral-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.comment_text && (
|
||||||
|
<p className="text-sm text-neutral-700">{item.comment_text}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
{format(new Date(item.created_at), 'PPpp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{item.feedback_type === 'comment' && !item.is_approved && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
leftIcon={<CheckCircle className="w-4 h-4" />}
|
||||||
|
onClick={() => moderateMutation.mutate({
|
||||||
|
feedbackId: item.id.toString(),
|
||||||
|
action: 'approve'
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('feedback.approve', 'Approve')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
leftIcon={<EyeOff className="w-4 h-4" />}
|
||||||
|
onClick={() => moderateMutation.mutate({
|
||||||
|
feedbackId: item.id.toString(),
|
||||||
|
action: 'hide'
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('feedback.hide', 'Hide')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.is_hidden && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
leftIcon={<Eye className="w-4 h-4" />}
|
||||||
|
onClick={() => moderateMutation.mutate({
|
||||||
|
feedbackId: item.id.toString(),
|
||||||
|
action: 'approve'
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('feedback.unhide', 'Unhide')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
leftIcon={<Trash2 className="w-4 h-4" />}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(t('feedback.confirmDelete', 'Are you sure you want to delete this feedback?'))) {
|
||||||
|
deleteMutation.mutate(item.id.toString());
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('common.delete', 'Delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{feedbackData?.pagination && feedbackData.pagination.pages > 1 && (
|
||||||
|
<div className="flex justify-center gap-2 mt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={feedbackFilter.page === 1}
|
||||||
|
onClick={() => setFeedbackFilter({ ...feedbackFilter, page: feedbackFilter.page - 1 })}
|
||||||
|
>
|
||||||
|
{t('common.previous', 'Previous')}
|
||||||
|
</Button>
|
||||||
|
<span className="flex items-center px-3 text-sm text-neutral-600">
|
||||||
|
{t('common.pageOf', 'Page {{current}} of {{total}}', {
|
||||||
|
current: feedbackFilter.page,
|
||||||
|
total: feedbackData.pagination.pages
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={feedbackFilter.page === feedbackData.pagination.pages}
|
||||||
|
onClick={() => setFeedbackFilter({ ...feedbackFilter, page: feedbackFilter.page + 1 })}
|
||||||
|
>
|
||||||
|
{t('common.next', 'Next')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'analytics' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{analyticsLoading ? (
|
||||||
|
<Loading />
|
||||||
|
) : analytics ? (
|
||||||
|
<>
|
||||||
|
{/* Summary Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<Star className="w-8 h-8 text-yellow-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{(analytics.summary.average_rating || 0).toFixed(1)}</p>
|
||||||
|
<p className="text-sm text-neutral-600">{t('feedback.avgRating', 'Average Rating')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
{t('feedback.totalRatings', '{{count}} ratings', { count: analytics.summary.total_ratings })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<Heart className="w-8 h-8 text-red-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{analytics.summary.total_likes}</p>
|
||||||
|
<p className="text-sm text-neutral-600">{t('feedback.totalLikes', 'Total Likes')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<MessageSquare className="w-8 h-8 text-blue-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{analytics.summary.total_comments}</p>
|
||||||
|
<p className="text-sm text-neutral-600">{t('feedback.totalComments', 'Total Comments')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{analytics.summary.pending_moderation > 0 && (
|
||||||
|
<p className="text-xs text-orange-600">
|
||||||
|
{t('feedback.pendingModeration', '{{count}} pending', {
|
||||||
|
count: analytics.summary.pending_moderation
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<TrendingUp className="w-8 h-8 text-green-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{analytics.summary.total_feedback}</p>
|
||||||
|
<p className="text-sm text-neutral-600">{t('feedback.totalInteractions', 'Total Interactions')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Rated Photos */}
|
||||||
|
{analytics.topRated.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">{t('feedback.topRated', 'Top Rated Photos')}</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{analytics.topRated.map((photo) => (
|
||||||
|
<div key={photo.id} className="flex items-center justify-between">
|
||||||
|
<span className="text-sm">{photo.filename}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<Star
|
||||||
|
key={star}
|
||||||
|
className={`w-3 h-3 ${
|
||||||
|
star <= Math.round(photo.average_rating)
|
||||||
|
? 'fill-yellow-500 text-yellow-500'
|
||||||
|
: 'text-neutral-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-neutral-600">
|
||||||
|
{photo.average_rating.toFixed(1)} ({photo.feedback_count})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent Comments */}
|
||||||
|
{analytics.recentComments.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">{t('feedback.recentComments', 'Recent Comments')}</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{analytics.recentComments.map((comment, idx) => (
|
||||||
|
<div key={idx} className="border-b border-neutral-100 pb-3 last:border-0">
|
||||||
|
<p className="text-sm text-neutral-700">{comment.comment_text}</p>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
{comment.guest_name} • {comment.filename} •
|
||||||
|
{format(new Date(comment.created_at), 'PP')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'moderation' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">{t('feedback.wordFilters', 'Word Filters')}</h3>
|
||||||
|
<p className="text-sm text-neutral-600">
|
||||||
|
{t('feedback.wordFiltersDesc', 'Manage blocked words for comment moderation')}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-4"
|
||||||
|
onClick={() => navigate('/admin/settings/moderation')}
|
||||||
|
>
|
||||||
|
{t('feedback.manageFilters', 'Manage Word Filters')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -57,7 +57,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
enable_registration: false,
|
enable_registration: false,
|
||||||
maintenance_mode: false,
|
maintenance_mode: false,
|
||||||
default_language: 'en',
|
default_language: 'en',
|
||||||
date_format: { format: 'DD/MM/YYYY', locale: 'en-GB' }
|
date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Security settings state
|
// Security settings state
|
||||||
@@ -99,7 +99,11 @@ export const SettingsPage: React.FC = () => {
|
|||||||
enable_registration: settings.general_enable_registration || false,
|
enable_registration: settings.general_enable_registration || false,
|
||||||
maintenance_mode: settings.general_maintenance_mode || false,
|
maintenance_mode: settings.general_maintenance_mode || false,
|
||||||
default_language: settings.general_default_language || 'en',
|
default_language: settings.general_default_language || 'en',
|
||||||
date_format: settings.general_date_format || { format: 'DD/MM/YYYY', locale: 'en-GB' }
|
date_format: settings.general_date_format
|
||||||
|
? (typeof settings.general_date_format === 'string'
|
||||||
|
? { format: settings.general_date_format, locale: settings.general_date_format.includes('MM/dd') ? 'en-US' : 'en-GB' }
|
||||||
|
: settings.general_date_format)
|
||||||
|
: { format: 'dd/MM/yyyy', locale: 'en-GB' }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extract security settings
|
// Extract security settings
|
||||||
@@ -130,7 +134,12 @@ export const SettingsPage: React.FC = () => {
|
|||||||
// Convert to the format expected by the API
|
// Convert to the format expected by the API
|
||||||
const settingsData: Record<string, any> = {};
|
const settingsData: Record<string, any> = {};
|
||||||
Object.entries(generalSettings).forEach(([key, value]) => {
|
Object.entries(generalSettings).forEach(([key, value]) => {
|
||||||
settingsData[`general_${key}`] = value;
|
// Special handling for date_format - only send the format string
|
||||||
|
if (key === 'date_format' && typeof value === 'object' && value.format) {
|
||||||
|
settingsData[`general_${key}`] = value.format;
|
||||||
|
} else {
|
||||||
|
settingsData[`general_${key}`] = value;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return settingsService.updateSettings(settingsData);
|
return settingsService.updateSettings(settingsData);
|
||||||
},
|
},
|
||||||
@@ -395,10 +404,10 @@ export const SettingsPage: React.FC = () => {
|
|||||||
{t('settings.general.dateFormat')}
|
{t('settings.general.dateFormat')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={generalSettings.date_format?.format || 'DD/MM/YYYY'}
|
value={generalSettings.date_format?.format || 'dd/MM/yyyy'}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const format = e.target.value;
|
const format = e.target.value;
|
||||||
const locale = format === 'MM/DD/YYYY' ? 'en-US' : 'en-GB';
|
const locale = format === 'MM/dd/yyyy' ? 'en-US' : 'en-GB';
|
||||||
setGeneralSettings(prev => ({
|
setGeneralSettings(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
date_format: { format, locale }
|
date_format: { format, locale }
|
||||||
@@ -406,10 +415,10 @@ export const SettingsPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
>
|
>
|
||||||
<option value="DD/MM/YYYY">DD/MM/YYYY (European)</option>
|
<option value="dd/MM/yyyy">DD/MM/YYYY (European)</option>
|
||||||
<option value="MM/DD/YYYY">MM/DD/YYYY (US)</option>
|
<option value="MM/dd/yyyy">MM/DD/YYYY (US)</option>
|
||||||
<option value="YYYY-MM-DD">YYYY-MM-DD (ISO)</option>
|
<option value="yyyy-MM-dd">YYYY-MM-DD (ISO)</option>
|
||||||
<option value="DD.MM.YYYY">DD.MM.YYYY (German)</option>
|
<option value="dd.MM.yyyy">DD.MM.YYYY (German)</option>
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-neutral-500 mt-1">
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
{t('settings.general.dateFormatHelp')}
|
{t('settings.general.dateFormatHelp')}
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ export { BrandingPage } from './BrandingPage';
|
|||||||
export { SettingsPage } from './SettingsPage';
|
export { SettingsPage } from './SettingsPage';
|
||||||
export { CMSPage } from './CMSPage';
|
export { CMSPage } from './CMSPage';
|
||||||
export { BackupManagement } from './BackupManagement';
|
export { BackupManagement } from './BackupManagement';
|
||||||
|
export { EventFeedbackPage } from './EventFeedbackPage';
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { api } from '../config/api';
|
||||||
|
|
||||||
|
export interface FeedbackSettings {
|
||||||
|
feedback_enabled: boolean;
|
||||||
|
allow_ratings: boolean;
|
||||||
|
allow_likes: boolean;
|
||||||
|
allow_comments: boolean;
|
||||||
|
allow_favorites: boolean;
|
||||||
|
require_name_email: boolean;
|
||||||
|
moderate_comments: boolean;
|
||||||
|
show_feedback_to_guests: boolean;
|
||||||
|
enable_rate_limiting: boolean;
|
||||||
|
rate_limit_window_minutes?: number;
|
||||||
|
rate_limit_max_requests?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhotoFeedback {
|
||||||
|
id: number;
|
||||||
|
photo_id: number;
|
||||||
|
event_id: number;
|
||||||
|
feedback_type: 'rating' | 'like' | 'comment' | 'favorite';
|
||||||
|
rating?: number;
|
||||||
|
comment_text?: string;
|
||||||
|
guest_name?: string;
|
||||||
|
guest_email?: string;
|
||||||
|
is_approved: boolean;
|
||||||
|
is_hidden: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
|
filename?: string;
|
||||||
|
path?: string;
|
||||||
|
is_mine?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackSummary {
|
||||||
|
average_rating: number;
|
||||||
|
total_ratings: number;
|
||||||
|
like_count: number;
|
||||||
|
favorite_count: number;
|
||||||
|
comment_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MyFeedback {
|
||||||
|
rating?: number;
|
||||||
|
liked: boolean;
|
||||||
|
favorited: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackResponse {
|
||||||
|
feedback: PhotoFeedback[];
|
||||||
|
summary: FeedbackSummary;
|
||||||
|
my_feedback: MyFeedback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackAnalytics {
|
||||||
|
summary: {
|
||||||
|
total_feedback: number;
|
||||||
|
total_ratings: number;
|
||||||
|
average_rating: number;
|
||||||
|
total_likes: number;
|
||||||
|
total_comments: number;
|
||||||
|
total_favorites: number;
|
||||||
|
pending_moderation: number;
|
||||||
|
};
|
||||||
|
topRated: Array<{
|
||||||
|
id: number;
|
||||||
|
filename: string;
|
||||||
|
average_rating: number;
|
||||||
|
feedback_count: number;
|
||||||
|
like_count: number;
|
||||||
|
}>;
|
||||||
|
mostLiked: Array<{
|
||||||
|
id: number;
|
||||||
|
filename: string;
|
||||||
|
like_count: number;
|
||||||
|
average_rating: number;
|
||||||
|
}>;
|
||||||
|
recentComments: Array<{
|
||||||
|
comment_text: string;
|
||||||
|
guest_name: string;
|
||||||
|
created_at: string;
|
||||||
|
filename: string;
|
||||||
|
}>;
|
||||||
|
timeline: Array<{
|
||||||
|
date: string;
|
||||||
|
count: number;
|
||||||
|
feedback_type: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FeedbackService {
|
||||||
|
// Admin endpoints
|
||||||
|
async getEventFeedbackSettings(eventId: string): Promise<FeedbackSettings> {
|
||||||
|
const response = await api.get(`/admin/feedback/events/${eventId}/feedback-settings`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateEventFeedbackSettings(eventId: string, settings: FeedbackSettings): Promise<FeedbackSettings> {
|
||||||
|
const response = await api.put(`/admin/feedback/events/${eventId}/feedback-settings`, settings);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEventFeedback(eventId: string, params?: {
|
||||||
|
type?: string;
|
||||||
|
status?: string;
|
||||||
|
photoId?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}) {
|
||||||
|
const response = await api.get(`/admin/feedback/events/${eventId}/feedback`, { params });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async moderateFeedback(feedbackId: string, action: 'approve' | 'hide' | 'reject') {
|
||||||
|
const response = await api.put(`/admin/feedback/feedback/${feedbackId}/${action}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFeedback(feedbackId: string) {
|
||||||
|
const response = await api.delete(`/admin/feedback/feedback/${feedbackId}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEventFeedbackAnalytics(eventId: string): Promise<FeedbackAnalytics> {
|
||||||
|
const response = await api.get(`/admin/feedback/events/${eventId}/feedback-analytics`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportEventFeedback(eventId: string, format: 'json' | 'csv' = 'json') {
|
||||||
|
const response = await api.get(`/admin/feedback/events/${eventId}/feedback/export`, {
|
||||||
|
params: { format },
|
||||||
|
responseType: format === 'csv' ? 'blob' : 'json'
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPendingModeration() {
|
||||||
|
const response = await api.get('/admin/feedback/feedback/pending-moderation');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word filter management
|
||||||
|
async getWordFilters() {
|
||||||
|
const response = await api.get('/admin/feedback/feedback/word-filters');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addWordFilter(word: string, severity: 'low' | 'moderate' | 'high' = 'moderate') {
|
||||||
|
const response = await api.post('/admin/feedback/feedback/word-filters', { word, severity });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateWordFilter(id: string, updates: { word?: string; severity?: string; is_active?: boolean }) {
|
||||||
|
const response = await api.put(`/admin/feedback/feedback/word-filters/${id}`, updates);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteWordFilter(id: string) {
|
||||||
|
const response = await api.delete(`/admin/feedback/feedback/word-filters/${id}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guest endpoints
|
||||||
|
async getGalleryFeedbackSettings(slug: string): Promise<Partial<FeedbackSettings>> {
|
||||||
|
const response = await api.get(`/gallery/${slug}/feedback-settings`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPhotoFeedback(slug: string, photoId: string): Promise<FeedbackResponse> {
|
||||||
|
const response = await api.get(`/gallery/${slug}/photos/${photoId}/feedback`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitFeedback(slug: string, photoId: string, feedback: {
|
||||||
|
feedback_type: 'rating' | 'like' | 'comment' | 'favorite';
|
||||||
|
rating?: number;
|
||||||
|
comment_text?: string;
|
||||||
|
guest_name?: string;
|
||||||
|
guest_email?: string;
|
||||||
|
}) {
|
||||||
|
const response = await api.post(`/gallery/${slug}/photos/${photoId}/feedback`, feedback);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGalleryFeedbackSummary(slug: string) {
|
||||||
|
const response = await api.get(`/gallery/${slug}/feedback-summary`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyFeedback(slug: string) {
|
||||||
|
const response = await api.get(`/gallery/${slug}/my-feedback`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const feedbackService = new FeedbackService();
|
||||||
@@ -8,3 +8,4 @@ export { emailService } from './email.service';
|
|||||||
export { settingsService } from './settings.service';
|
export { settingsService } from './settings.service';
|
||||||
export { cmsService } from './cms.service';
|
export { cmsService } from './cms.service';
|
||||||
export { notificationsService } from './notifications.service';
|
export { notificationsService } from './notifications.service';
|
||||||
|
export { feedbackService } from './feedback.service';
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { api } from '../config/api';
|
||||||
|
|
||||||
|
export interface PublicSettings {
|
||||||
|
branding_company_name: string;
|
||||||
|
branding_company_tagline: string;
|
||||||
|
branding_support_email: string;
|
||||||
|
branding_footer_text: string;
|
||||||
|
branding_watermark_enabled: boolean;
|
||||||
|
branding_watermark_logo_url: string;
|
||||||
|
branding_watermark_position: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'center';
|
||||||
|
branding_watermark_opacity: number;
|
||||||
|
branding_watermark_size: number;
|
||||||
|
branding_favicon_url: string;
|
||||||
|
branding_logo_url: string;
|
||||||
|
theme_config: any;
|
||||||
|
default_language: string;
|
||||||
|
enable_analytics: boolean;
|
||||||
|
general_date_format: string | { format: string; locale: string };
|
||||||
|
enable_recaptcha: boolean;
|
||||||
|
recaptcha_site_key: string | null;
|
||||||
|
maintenance_mode: boolean;
|
||||||
|
umami_enabled: boolean;
|
||||||
|
umami_url: string | null;
|
||||||
|
umami_website_id: string | null;
|
||||||
|
umami_share_url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const publicSettingsService = {
|
||||||
|
// Get public settings (no authentication required)
|
||||||
|
async getPublicSettings(): Promise<PublicSettings> {
|
||||||
|
const response = await api.get<PublicSettings>('/public/settings');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user