Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 558931f683 | |||
| 0a5c192a48 | |||
| 591a119ab3 | |||
| c7cb96aaab | |||
| 94778dca55 | |||
| e9a2508750 | |||
| 27f551ef3a | |||
| 81c55261d6 | |||
| bf68344f0c | |||
| f7e1d067f4 | |||
| d8d7b500ad | |||
| 250fb93021 | |||
| c0bb2aa5d1 | |||
| 8b0c1655a1 | |||
| ff950244d5 | |||
| cd3d6fc704 | |||
| c536fe45ff | |||
| 42c8ef1c0e | |||
| d07d742528 | |||
| dbc9f0a605 | |||
| 3eb8550cbf | |||
| bebc045029 | |||
| 7325a9e5a2 | |||
| d8c229203e | |||
| 95a6ad505d | |||
| 9dc82c8490 | |||
| 554bf1d43c | |||
| f147bd6cfb | |||
| f564f40ed0 |
-49
@@ -108,55 +108,6 @@ steps:
|
||||
- VERSION=${DRONE_TAG}
|
||||
- VITE_API_URL=${VITE_API_URL:-/api}
|
||||
|
||||
# -------- NEW: Create GitHub Release --------
|
||||
- name: github-release
|
||||
image: plugins/github-release
|
||||
environment:
|
||||
PLUGIN_API_KEY:
|
||||
from_secret: GITHUB_TOKEN
|
||||
DRONE_REMOTE_URL: https://github.com/the-luap/picpeak.git
|
||||
settings:
|
||||
api_key:
|
||||
from_secret: GITHUB_TOKEN
|
||||
repo: the-luap/picpeak
|
||||
title: "PicPeak ${DRONE_TAG}"
|
||||
prerelease: false
|
||||
overwrite: true
|
||||
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:
|
||||
event:
|
||||
|
||||
@@ -10,120 +10,18 @@ jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository with full history
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for finding the commit
|
||||
fetch-depth: 0 # Full history for proper mirroring
|
||||
|
||||
- name: Setup Git
|
||||
run: |
|
||||
git config --global user.name "the-luap"
|
||||
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
|
||||
run: |
|
||||
# Switch to the github-mirror branch
|
||||
git checkout github-mirror
|
||||
|
||||
echo "Current files before cleanup:"
|
||||
ls -la | head -10 || true
|
||||
echo "..."
|
||||
@@ -164,18 +62,6 @@ jobs:
|
||||
echo "Final file structure (top level):"
|
||||
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
|
||||
env:
|
||||
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
||||
@@ -187,13 +73,10 @@ jobs:
|
||||
echo "GitHub token is available (length: ${#GITHUBTOKEN})"
|
||||
fi
|
||||
|
||||
- name: Force push completely new history to GitHub
|
||||
- name: Push to GitHub
|
||||
env:
|
||||
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
||||
run: |
|
||||
# Switch to github-mirror branch
|
||||
git checkout github-mirror
|
||||
|
||||
# Remove existing github remote if it exists
|
||||
git remote remove github || true
|
||||
|
||||
@@ -204,17 +87,13 @@ jobs:
|
||||
echo "GitHub remote added:"
|
||||
git remote -v
|
||||
|
||||
# Force push the completely new history to GitHub main
|
||||
echo "🔥 FORCE PUSHING completely new history to GitHub..."
|
||||
echo "⚠️ This will COMPLETELY REPLACE all history on GitHub!"
|
||||
git push github github-mirror:main --force
|
||||
echo "✅ Force push completed - GitHub now has completely new history!"
|
||||
# Push to GitHub main branch
|
||||
echo "Pushing to GitHub..."
|
||||
git push github main --force
|
||||
echo "✅ Push to GitHub completed!"
|
||||
|
||||
- name: Workflow completed
|
||||
run: |
|
||||
echo "✅ Mirror to GitHub workflow completed successfully!"
|
||||
echo "🔥 COMPLETE HISTORY REPLACEMENT: GitHub now has entirely new history"
|
||||
echo "📊 History starts from commit content: 7aca927937"
|
||||
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"
|
||||
echo "📊 Repository mirrored to: https://github.com/the-luap/picpeak"
|
||||
echo "🔒 Sensitive files have been removed from the mirror"
|
||||
@@ -48,6 +48,10 @@ coverage/
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Backup and test directories
|
||||
backups/
|
||||
test-archiver/
|
||||
|
||||
# Keep directory structure
|
||||
!storage/events/active/.gitkeep
|
||||
!storage/events/archived/.gitkeep
|
||||
|
||||
@@ -94,6 +94,20 @@ Perfect for:
|
||||
- **Email**: SMTP with customizable templates
|
||||
- **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
|
||||
|
||||
We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome.
|
||||
|
||||
@@ -125,9 +125,10 @@ exports.up = async function(knex) {
|
||||
// Add new email templates for restore notifications
|
||||
const emailTemplates = [
|
||||
{
|
||||
name: 'restore_completed',
|
||||
subject: '✅ Restore Completed Successfully',
|
||||
body: `<h2>Restore Operation Completed</h2>
|
||||
template_key: 'restore_completed',
|
||||
subject_en: '✅ Restore Completed Successfully',
|
||||
subject_de: '✅ Wiederherstellung erfolgreich abgeschlossen',
|
||||
body_html_en: `<h2>Restore Operation Completed</h2>
|
||||
<p>A restore operation has completed successfully.</p>
|
||||
|
||||
<h3>Details:</h3>
|
||||
@@ -140,32 +141,7 @@ exports.up = async function(knex) {
|
||||
</ul>
|
||||
|
||||
<p>Please verify that all systems are functioning correctly after the restore.</p>`,
|
||||
language: 'en',
|
||||
is_active: true
|
||||
},
|
||||
{
|
||||
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: true
|
||||
},
|
||||
{
|
||||
name: 'restore_completed',
|
||||
subject: '✅ Wiederherstellung erfolgreich abgeschlossen',
|
||||
body: `<h2>Wiederherstellungsvorgang abgeschlossen</h2>
|
||||
body_html_de: `<h2>Wiederherstellungsvorgang abgeschlossen</h2>
|
||||
<p>Ein Wiederherstellungsvorgang wurde erfolgreich abgeschlossen.</p>
|
||||
|
||||
<h3>Details:</h3>
|
||||
@@ -178,13 +154,50 @@ exports.up = async function(knex) {
|
||||
</ul>
|
||||
|
||||
<p>Bitte überprüfen Sie, ob alle Systeme nach der Wiederherstellung ordnungsgemäß funktionieren.</p>`,
|
||||
language: 'de',
|
||||
is_active: true
|
||||
body_text_en: `Restore Operation Completed
|
||||
|
||||
A restore operation has completed successfully.
|
||||
|
||||
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',
|
||||
subject: '❌ Wiederherstellungsvorgang fehlgeschlagen',
|
||||
body: `<h2>Wiederherstellungsvorgang fehlgeschlagen</h2>
|
||||
template_key: 'restore_failed',
|
||||
subject_en: '❌ Restore Operation Failed',
|
||||
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>
|
||||
|
||||
<h3>Details:</h3>
|
||||
@@ -197,8 +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><strong>Wichtig:</strong> Falls ein Backup vor der Wiederherstellung erstellt wurde, kann es zur Wiederherstellung verwendet werden.</p>`,
|
||||
language: 'de',
|
||||
is_active: true
|
||||
body_text_en: `Restore Operation Failed
|
||||
|
||||
A restore operation has failed and requires attention.
|
||||
|
||||
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'])
|
||||
}
|
||||
];
|
||||
|
||||
@@ -208,7 +244,7 @@ exports.up = async function(knex) {
|
||||
exports.down = async function(knex) {
|
||||
// Remove email templates
|
||||
await knex('email_templates')
|
||||
.whereIn('name', ['restore_completed', 'restore_failed'])
|
||||
.whereIn('template_key', ['restore_completed', 'restore_failed'])
|
||||
.delete();
|
||||
|
||||
// Remove settings
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.82",
|
||||
"version": "1.0.94",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.82",
|
||||
"version": "1.0.94",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.82",
|
||||
"version": "1.0.94",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -42,7 +42,7 @@ router.post('/', adminAuth, [
|
||||
} = req.body;
|
||||
|
||||
// Validate password strength for gallery
|
||||
const passwordValidation = validatePasswordInContext(password, 'gallery', {
|
||||
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||
eventName: event_name
|
||||
});
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ router.post('/', adminAuth, [
|
||||
} = req.body;
|
||||
|
||||
// Validate password strength
|
||||
const passwordValidation = validatePasswordInContext(password, 'gallery', {
|
||||
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||
eventName: event_name
|
||||
});
|
||||
|
||||
|
||||
@@ -123,8 +123,8 @@ router.get('/events/:eventId/feedback',
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total: totalCount.count || 0,
|
||||
pages: Math.ceil((totalCount.count || 0) / limit)
|
||||
total: totalCount?.count || 0,
|
||||
pages: Math.ceil((totalCount?.count || 0) / limit)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -182,7 +182,35 @@ router.get('/events/:eventId/feedback-analytics',
|
||||
const { eventId } = req.params;
|
||||
|
||||
// Get summary statistics
|
||||
const summary = await feedbackService.getEventFeedbackSummary(eventId);
|
||||
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')
|
||||
|
||||
@@ -447,9 +447,14 @@ router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
||||
if (photo.thumbnail_path) {
|
||||
const thumbPath = path.join(storagePath, 'events/active', photo.thumbnail_path);
|
||||
try {
|
||||
// Check if file exists before attempting to delete
|
||||
await fs.access(thumbPath);
|
||||
await fs.unlink(thumbPath);
|
||||
} 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) {
|
||||
const thumbPath = path.join(storagePath, photo.thumbnail_path);
|
||||
try {
|
||||
// Check if file exists before attempting to delete
|
||||
await fs.access(thumbPath);
|
||||
await fs.unlink(thumbPath);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ router.get('/', async (req, res) => {
|
||||
theme_config: settingsObject.theme_config || null,
|
||||
default_language: settingsObject.general_default_language || 'en',
|
||||
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',
|
||||
recaptcha_site_key: settingsObject.security_recaptcha_site_key || null,
|
||||
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true',
|
||||
|
||||
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",
|
||||
"version": "1.0.78",
|
||||
"version": "1.0.94",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-frontend",
|
||||
"version": "1.0.78",
|
||||
"version": "1.0.94",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tiptap/extension-character-count": "^2.26.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.78",
|
||||
"version": "1.0.94",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
EventsListPage,
|
||||
CreateEventPageEnhanced as CreateEventPage,
|
||||
EventDetailsPage,
|
||||
EventFeedbackPage,
|
||||
EmailConfigPage,
|
||||
ArchivesPage,
|
||||
AnalyticsPage,
|
||||
@@ -120,6 +121,7 @@ function App() {
|
||||
<Route path="events" element={<EventsListPage />} />
|
||||
<Route path="events/new" element={<CreateEventPage />} />
|
||||
<Route path="events/:id" element={<EventDetailsPage />} />
|
||||
<Route path="events/:id/feedback" element={<EventFeedbackPage />} />
|
||||
<Route path="archives" element={<ArchivesPage />} />
|
||||
<Route path="email" element={<EmailConfigPage />} />
|
||||
<Route path="analytics" element={<AnalyticsPage />} />
|
||||
|
||||
@@ -23,7 +23,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
||||
const [isSelectionMode, setIsSelectionMode] = 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) => {
|
||||
if (e) {
|
||||
@@ -54,15 +54,18 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingPhotoId(photo.id);
|
||||
setDeletingPhotos(prev => new Set(prev).add(photo.id));
|
||||
try {
|
||||
await photosService.deletePhoto(eventId, photo.id);
|
||||
toast.success('Photo deleted successfully');
|
||||
onPhotosDeleted();
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete photo');
|
||||
} finally {
|
||||
setDeletingPhotoId(null);
|
||||
setDeletingPhotos(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(photo.id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -75,14 +78,18 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
const selectedIds = Array.from(selectedPhotos);
|
||||
setDeletingPhotos(new Set(selectedIds));
|
||||
|
||||
try {
|
||||
await photosService.deletePhotos(eventId, Array.from(selectedPhotos));
|
||||
await photosService.deletePhotos(eventId, selectedIds);
|
||||
toast.success(`${count} photo${count > 1 ? 's' : ''} deleted successfully`);
|
||||
setSelectedPhotos(new Set());
|
||||
setIsSelectionMode(false);
|
||||
onPhotosDeleted();
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete photos');
|
||||
setDeletingPhotos(new Set());
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
@@ -155,13 +162,15 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
|
||||
{/* Photo Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{photos.map((photo, index) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className={`relative group cursor-pointer rounded-lg overflow-hidden bg-neutral-100 ${
|
||||
isSelectionMode ? 'ring-2 ring-offset-2 ' + (selectedPhotos.has(photo.id) ? 'ring-primary-500' : 'ring-transparent') : ''
|
||||
}`}
|
||||
onClick={() => isSelectionMode ? handlePhotoSelect(photo.id) : onPhotoClick(photo, index)}
|
||||
{photos.map((photo, index) => {
|
||||
const isDeleting = deletingPhotos.has(photo.id);
|
||||
return (
|
||||
<div
|
||||
key={photo.id}
|
||||
className={`relative group cursor-pointer rounded-lg overflow-hidden bg-neutral-100 transition-opacity ${
|
||||
isSelectionMode ? 'ring-2 ring-offset-2 ' + (selectedPhotos.has(photo.id) ? 'ring-primary-500' : 'ring-transparent') : ''
|
||||
} ${isDeleting ? 'opacity-50' : ''}`}
|
||||
onClick={() => !isDeleting && (isSelectionMode ? handlePhotoSelect(photo.id) : onPhotoClick(photo, index))}
|
||||
>
|
||||
{/* Selection Checkbox */}
|
||||
{isSelectionMode && (
|
||||
@@ -219,8 +228,8 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleDeleteSingle(photo, e)}
|
||||
className="p-1 text-white hover:bg-white/20 rounded"
|
||||
disabled={deletingPhotoId === photo.id}
|
||||
className="p-1 text-white hover:bg-white/20 rounded disabled:opacity-50"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
@@ -238,7 +247,8 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{photos.length === 0 && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Save,
|
||||
Server,
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
Info,
|
||||
Eye,
|
||||
EyeOff,
|
||||
TestTube,
|
||||
Wifi,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
@@ -23,38 +24,40 @@ import {
|
||||
import { toast } from 'react-toastify';
|
||||
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 }) => {
|
||||
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({
|
||||
backup_enabled: false,
|
||||
backup_destination_type: 'local',
|
||||
@@ -121,7 +124,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
}
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
toast.error('Please fill in all required fields');
|
||||
toast.error(t('backup.configuration.messages.requiredFields'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -133,9 +136,9 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
try {
|
||||
// TODO: Implement connection test endpoint
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
toast.success('Connection test successful!');
|
||||
toast.success(t('backup.configuration.messages.connectionSuccess'));
|
||||
} catch (error) {
|
||||
toast.error('Connection test failed: ' + error.message);
|
||||
toast.error(t('backup.configuration.messages.connectionFailed') + ': ' + error.message);
|
||||
} finally {
|
||||
setTestingConnection(false);
|
||||
}
|
||||
@@ -149,9 +152,9 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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">
|
||||
Enable automatic backups to protect your data
|
||||
{t('backup.configuration.enableBackupHelp')}
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
@@ -168,7 +171,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
|
||||
{/* Destination Configuration */}
|
||||
<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 */}
|
||||
<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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Backup Directory Path
|
||||
{t('backup.configuration.fields.destinationPath')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.backup_destination_path}
|
||||
onChange={(e) => handleChange('backup_destination_path', e.target.value)}
|
||||
placeholder="/path/to/backup/directory"
|
||||
placeholder={t('backup.configuration.fields.destinationPathPlaceholder')}
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Absolute path where backups will be stored
|
||||
{t('backup.configuration.fields.destinationPathHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
@@ -224,50 +227,50 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
SSH Host
|
||||
{t('backup.configuration.fields.rsyncHost')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.backup_rsync_host}
|
||||
onChange={(e) => handleChange('backup_rsync_host', e.target.value)}
|
||||
placeholder="backup.example.com"
|
||||
placeholder={t('backup.configuration.fields.rsyncHostPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
SSH User
|
||||
{t('backup.configuration.fields.rsyncUser')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.backup_rsync_user}
|
||||
onChange={(e) => handleChange('backup_rsync_user', e.target.value)}
|
||||
placeholder="backup-user"
|
||||
placeholder={t('backup.configuration.fields.rsyncUserPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Remote Path
|
||||
{t('backup.configuration.fields.rsyncPath')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.backup_rsync_path}
|
||||
onChange={(e) => handleChange('backup_rsync_path', e.target.value)}
|
||||
placeholder="/home/backup/photo-sharing"
|
||||
placeholder={t('backup.configuration.fields.rsyncPathPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
SSH Private Key (optional)
|
||||
{t('backup.configuration.fields.rsyncSshKey')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={formData.backup_rsync_ssh_key}
|
||||
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"
|
||||
rows={4}
|
||||
/>
|
||||
@@ -280,7 +283,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Leave empty to use system SSH keys
|
||||
{t('backup.configuration.fields.rsyncSshKeyHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
@@ -290,7 +293,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
S3 Endpoint URL
|
||||
{t('backup.configuration.fields.s3Endpoint')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
@@ -300,13 +303,13 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
required
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Bucket Name
|
||||
{t('backup.configuration.fields.s3Bucket')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
@@ -318,7 +321,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Region
|
||||
{t('backup.configuration.fields.s3Region')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
@@ -331,7 +334,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Access Key ID
|
||||
{t('backup.configuration.fields.s3AccessKey')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
@@ -343,7 +346,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Secret Access Key
|
||||
{t('backup.configuration.fields.s3SecretKey')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
@@ -379,12 +382,12 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
{testingConnection ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Testing...
|
||||
{t('backup.configuration.testingConnection')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TestTube className="mr-2 h-4 w-4" />
|
||||
Test Connection
|
||||
<Wifi className="mr-2 h-4 w-4" />
|
||||
{t('backup.actions.testConnection')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -395,12 +398,12 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
|
||||
{/* Schedule Configuration */}
|
||||
<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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Schedule
|
||||
{t('backup.configuration.schedule.scheduleType')}
|
||||
</label>
|
||||
<select
|
||||
value={formData.backup_schedule}
|
||||
@@ -418,7 +421,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
{formData.backup_schedule === 'custom' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Cron Expression
|
||||
{t('backup.configuration.schedule.customCron')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
@@ -427,14 +430,14 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
placeholder="0 3 * * *"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Use standard cron syntax (minute hour day month weekday)
|
||||
{t('backup.configuration.schedule.customCronHelp')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Retention Period (days)
|
||||
{t('backup.configuration.schedule.retention')}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -444,7 +447,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
max="365"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Backups older than this will be automatically deleted
|
||||
{t('backup.configuration.schedule.retentionHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -452,7 +455,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
|
||||
{/* Backup Content Selection */}
|
||||
<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">
|
||||
<label className="flex items-center">
|
||||
@@ -465,9 +468,9 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
<div className="ml-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<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>
|
||||
<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>
|
||||
</label>
|
||||
|
||||
@@ -481,9 +484,9 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
<div className="ml-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<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>
|
||||
<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>
|
||||
</label>
|
||||
|
||||
@@ -497,9 +500,9 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
<div className="ml-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<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>
|
||||
<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>
|
||||
</label>
|
||||
|
||||
@@ -513,9 +516,9 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
<div className="ml-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<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>
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
@@ -523,7 +526,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
|
||||
{/* Advanced Options */}
|
||||
<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">
|
||||
<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"
|
||||
/>
|
||||
<div className="ml-3">
|
||||
<span className="text-sm font-medium text-gray-700">Enable Compression</span>
|
||||
<p className="text-xs text-gray-500">Reduce backup size with gzip compression</p>
|
||||
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.advancedOptions.compression')}</span>
|
||||
<p className="text-xs text-gray-500">{t('backup.configuration.advancedOptions.compressionHelp')}</p>
|
||||
</div>
|
||||
</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"
|
||||
/>
|
||||
<div className="ml-3">
|
||||
<span className="text-sm font-medium text-gray-700">Enable Encryption</span>
|
||||
<p className="text-xs text-gray-500">Encrypt backups with AES-256</p>
|
||||
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.advancedOptions.encryption')}</span>
|
||||
<p className="text-xs text-gray-500">{t('backup.configuration.advancedOptions.encryptionHelp')}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{formData.backup_encryption && (
|
||||
<div className="ml-7">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Encryption Passphrase
|
||||
{t('backup.configuration.advancedOptions.encryptionPassphrase')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showSecrets.encryption_passphrase ? 'text' : 'password'}
|
||||
value={formData.backup_encryption_passphrase}
|
||||
onChange={(e) => handleChange('backup_encryption_passphrase', e.target.value)}
|
||||
placeholder="Enter a strong passphrase"
|
||||
placeholder={t('backup.configuration.advancedOptions.encryptionPassphraseHelp')}
|
||||
required={formData.backup_encryption}
|
||||
/>
|
||||
<button
|
||||
@@ -576,7 +579,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-red-600">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -593,12 +596,12 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
{t('backup.configuration.savingSettings')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save Configuration
|
||||
{t('backup.configuration.saveSettings')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
HardDrive,
|
||||
Database,
|
||||
@@ -46,6 +47,7 @@ const formatBytes = (bytes) => {
|
||||
};
|
||||
|
||||
export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }) => {
|
||||
const { t } = useTranslation();
|
||||
const lastBackup = status?.lastBackup;
|
||||
const statistics = lastBackup?.statistics || {};
|
||||
const isConfigured = config && config.backup_destination_type;
|
||||
@@ -53,22 +55,22 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
||||
|
||||
// Calculate backup health score
|
||||
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);
|
||||
|
||||
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) {
|
||||
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) {
|
||||
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
|
||||
return { score: 50, status: 'warning', message: 'Backup is getting old' };
|
||||
return { score: 50, status: 'warning', message: t('backup.dashboard.healthMessages.gettingOld') };
|
||||
} 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" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-amber-800">
|
||||
Backup Not Configured
|
||||
{t('backup.dashboard.notConfigured.title')}
|
||||
</h3>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,7 +104,7 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
||||
{/* Health Score Card */}
|
||||
<Card className="p-6">
|
||||
<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`}>
|
||||
{health.status.charAt(0).toUpperCase() + health.status.slice(1)}
|
||||
</span>
|
||||
@@ -153,12 +155,12 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
||||
{isBackupRunning ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Running...
|
||||
{t('backup.dashboard.actions.running')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Run Backup Now
|
||||
{t('backup.dashboard.actions.runBackupNow')}
|
||||
</>
|
||||
)}
|
||||
</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">
|
||||
<StatCard
|
||||
icon={FileArchive}
|
||||
label="Total Backups"
|
||||
label={t('backup.dashboard.stats.totalBackups')}
|
||||
value={status?.totalBackups || 0}
|
||||
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
|
||||
icon={HardDrive}
|
||||
label="Backup Size"
|
||||
label={t('backup.dashboard.stats.backupSize')}
|
||||
value={formatBytes(statistics.total_size || 0)}
|
||||
color="green"
|
||||
subtext={`${statistics.files_processed || 0} files`}
|
||||
subtext={`${statistics.files_processed || 0} ${t('backup.dashboard.stats.files')}`}
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
icon={Clock}
|
||||
label="Last Duration"
|
||||
label={t('backup.dashboard.stats.lastDuration')}
|
||||
value={lastBackup ? `${Math.round(lastBackup.duration_seconds / 60)}m` : 'N/A'}
|
||||
color="purple"
|
||||
subtext={lastBackup ? format(new Date(lastBackup.created_at), 'p') : ''}
|
||||
@@ -194,17 +196,17 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
||||
|
||||
<StatCard
|
||||
icon={Shield}
|
||||
label="Backup Status"
|
||||
value={isEnabled ? 'Active' : 'Inactive'}
|
||||
label={t('backup.dashboard.stats.backupStatus')}
|
||||
value={isEnabled ? t('backup.dashboard.stats.active') : t('backup.dashboard.stats.inactive')}
|
||||
color={isEnabled ? 'green' : 'gray'}
|
||||
subtext={config?.backup_destination_type || 'Not configured'}
|
||||
subtext={config?.backup_destination_type || t('backup.dashboard.notConfigured.title')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
{status?.recentBackups && status.recentBackups.length > 0 && (
|
||||
<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">
|
||||
{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">
|
||||
@@ -218,7 +220,7 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{backup.backup_type} backup
|
||||
{t('backup.dashboard.backupType', { type: backup.backup_type })}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{format(new Date(backup.created_at), 'PPp')}
|
||||
@@ -242,7 +244,7 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
||||
{/* Storage Status */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-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="flex items-center justify-between">
|
||||
<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 ${
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<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>
|
||||
<span className="text-sm text-gray-500">
|
||||
{statistics.archives_backed_up || 0} files
|
||||
{statistics.archives_backed_up || 0} {t('backup.dashboard.stats.files')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<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="flex items-center space-x-3">
|
||||
{config?.backup_destination_type === 's3' ? (
|
||||
@@ -292,8 +294,8 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{config?.backup_destination_type
|
||||
? config.backup_destination_type.toUpperCase()
|
||||
: 'Not Configured'}
|
||||
? t(`backup.configuration.destinationTypes.${config.backup_destination_type}.name`)
|
||||
: t('backup.dashboard.notConfigured.title')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{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}`
|
||||
: config?.backup_destination_type === 'rsync' && config?.backup_rsync_host
|
||||
? `Host: ${config.backup_rsync_host}`
|
||||
: 'No destination set'}
|
||||
: t('backup.dashboard.noDestinationSet')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,7 +314,7 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
||||
<div className="flex items-center space-x-2">
|
||||
<Info className="h-4 w-4 text-gray-400" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Download,
|
||||
Eye,
|
||||
@@ -41,6 +42,7 @@ const formatBytes = (bytes) => {
|
||||
};
|
||||
|
||||
export const BackupHistory = () => {
|
||||
const { t } = useTranslation();
|
||||
const [expandedRows, setExpandedRows] = useState(new Set());
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
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" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search backups..."
|
||||
placeholder={t('backup.history.searchPlaceholder')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
@@ -149,22 +151,22 @@ export const BackupHistory = () => {
|
||||
<thead>
|
||||
<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">
|
||||
Status
|
||||
{t('backup.history.columns.status')}
|
||||
</th>
|
||||
<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 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 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 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 className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
{t('backup.history.columns.actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -215,7 +217,7 @@ export const BackupHistory = () => {
|
||||
{formatBytes(stats.total_size || 0)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{stats.files_processed || 0} files
|
||||
{stats.files_processed || 0} {t('backup.dashboard.stats.files')}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
@@ -228,7 +230,7 @@ export const BackupHistory = () => {
|
||||
<button
|
||||
onClick={() => toggleRowExpansion(backup.id)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
title="View details"
|
||||
title={t('backup.actions.view')}
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
||||
</button>
|
||||
@@ -236,7 +238,7 @@ export const BackupHistory = () => {
|
||||
<button
|
||||
onClick={() => window.open(`/admin/backup/download/${backup.id}`, '_blank')}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
title="Download backup"
|
||||
title={t('backup.actions.download')}
|
||||
>
|
||||
<Download size={20} />
|
||||
</button>
|
||||
@@ -244,7 +246,7 @@ export const BackupHistory = () => {
|
||||
<button
|
||||
onClick={() => handleDelete(backup)}
|
||||
className="text-gray-400 hover:text-red-600"
|
||||
title="Delete backup"
|
||||
title={t('backup.actions.delete')}
|
||||
disabled={deleteMutation.isLoading}
|
||||
>
|
||||
<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">
|
||||
{/* Backup Details */}
|
||||
<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="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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
{backup.completed_at && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -281,11 +283,11 @@ export const BackupHistory = () => {
|
||||
|
||||
{/* Content Backed Up */}
|
||||
<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="flex items-center space-x-2">
|
||||
<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 className="flex items-center space-x-2">
|
||||
<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 */}
|
||||
{backup.error_message && (
|
||||
<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">
|
||||
{backup.error_message}
|
||||
</p>
|
||||
@@ -315,7 +317,7 @@ export const BackupHistory = () => {
|
||||
{/* Manifest Path */}
|
||||
{backup.manifest_path && (
|
||||
<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">
|
||||
{backup.manifest_path}
|
||||
</p>
|
||||
@@ -344,7 +346,7 @@ export const BackupHistory = () => {
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Previous
|
||||
{t('backup.history.pagination.previous')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setCurrentPage(p => Math.min(pagination.pages, p + 1))}
|
||||
@@ -352,17 +354,17 @@ export const BackupHistory = () => {
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Next
|
||||
{t('backup.history.pagination.next')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
Showing <span className="font-medium">{(currentPage - 1) * pagination.limit + 1}</span> to{' '}
|
||||
<span className="font-medium">
|
||||
{Math.min(currentPage * pagination.limit, pagination.total)}
|
||||
</span>{' '}
|
||||
of <span className="font-medium">{pagination.total}</span> results
|
||||
{t('backup.history.pagination.showing', {
|
||||
from: (currentPage - 1) * pagination.limit + 1,
|
||||
to: Math.min(currentPage * pagination.limit, pagination.total),
|
||||
total: pagination.total
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -372,7 +374,7 @@ export const BackupHistory = () => {
|
||||
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"
|
||||
>
|
||||
Previous
|
||||
{t('backup.history.pagination.previous')}
|
||||
</button>
|
||||
|
||||
{[...Array(Math.min(5, pagination.pages))].map((_, i) => {
|
||||
@@ -397,7 +399,7 @@ export const BackupHistory = () => {
|
||||
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"
|
||||
>
|
||||
Next
|
||||
{t('backup.history.pagination.next')}
|
||||
</button>
|
||||
</nav>
|
||||
</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';
|
||||
@@ -256,7 +256,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
<div className="flex justify-between text-sm text-neutral-600 mb-1">
|
||||
<span>
|
||||
{t('upload.uploading')}
|
||||
{totalChunks > 1 && ` (${t('common.chunk') || 'Chunk'} ${currentChunk}/${totalChunks})`}
|
||||
{totalChunks > 1 && ` (${t('common.chunk')} ${currentChunk}/${totalChunks})`}
|
||||
</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
@@ -268,7 +268,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
</div>
|
||||
{totalChunks > 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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
@@ -28,47 +29,49 @@ import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { Button, Card, Input, Loading } from '../common';
|
||||
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 = () => {
|
||||
const { t } = useTranslation();
|
||||
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({
|
||||
source: null,
|
||||
sourceConfig: {},
|
||||
@@ -184,8 +187,8 @@ export const RestoreWizard = () => {
|
||||
const renderSourceSelection = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Select Backup Source</h3>
|
||||
<p className="text-sm text-gray-600">Choose where to restore the backup from</p>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t('backup.restore.source.title')}</h3>
|
||||
<p className="text-sm text-gray-600">{t('backup.restore.source.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<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 ${
|
||||
restoreData.source === 'local' ? 'text-primary' : 'text-gray-400'
|
||||
}`} />
|
||||
<h4 className="font-medium text-gray-900">Local Backup</h4>
|
||||
<p className="text-xs text-gray-500 mt-1">Restore from local filesystem</p>
|
||||
<h4 className="font-medium text-gray-900">{t('backup.restore.source.local.name')}</h4>
|
||||
<p className="text-xs text-gray-500 mt-1">{t('backup.restore.source.local.description')}</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -215,8 +218,8 @@ export const RestoreWizard = () => {
|
||||
<Cloud className={`h-12 w-12 mb-3 mx-auto ${
|
||||
restoreData.source === 's3' ? 'text-primary' : 'text-gray-400'
|
||||
}`} />
|
||||
<h4 className="font-medium text-gray-900">S3 Storage</h4>
|
||||
<p className="text-xs text-gray-500 mt-1">Restore from S3 bucket</p>
|
||||
<h4 className="font-medium text-gray-900">{t('backup.restore.source.s3.name')}</h4>
|
||||
<p className="text-xs text-gray-500 mt-1">{t('backup.restore.source.s3.description')}</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -230,18 +233,18 @@ export const RestoreWizard = () => {
|
||||
<Upload className={`h-12 w-12 mb-3 mx-auto ${
|
||||
restoreData.source === 'upload' ? 'text-primary' : 'text-gray-400'
|
||||
}`} />
|
||||
<h4 className="font-medium text-gray-900">Upload Backup</h4>
|
||||
<p className="text-xs text-gray-500 mt-1">Upload a backup file</p>
|
||||
<h4 className="font-medium text-gray-900">{t('backup.restore.source.upload.name')}</h4>
|
||||
<p className="text-xs text-gray-500 mt-1">{t('backup.restore.source.upload.description')}</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Source-specific configuration */}
|
||||
{restoreData.source === 's3' && (
|
||||
<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">
|
||||
<Input
|
||||
placeholder="S3 Endpoint URL"
|
||||
placeholder={t('backup.restore.source.configuration.endpoint')}
|
||||
value={restoreData.sourceConfig.s3Endpoint || ''}
|
||||
onChange={(e) => setRestoreData(prev => ({
|
||||
...prev,
|
||||
@@ -249,7 +252,7 @@ export const RestoreWizard = () => {
|
||||
}))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Bucket Name"
|
||||
placeholder={t('backup.restore.source.configuration.bucket')}
|
||||
value={restoreData.sourceConfig.s3Bucket || ''}
|
||||
onChange={(e) => setRestoreData(prev => ({
|
||||
...prev,
|
||||
@@ -257,7 +260,7 @@ export const RestoreWizard = () => {
|
||||
}))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Access Key ID"
|
||||
placeholder={t('backup.restore.source.configuration.accessKey')}
|
||||
value={restoreData.sourceConfig.s3AccessKey || ''}
|
||||
onChange={(e) => setRestoreData(prev => ({
|
||||
...prev,
|
||||
@@ -266,7 +269,7 @@ export const RestoreWizard = () => {
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Secret Access Key"
|
||||
placeholder={t('backup.restore.source.configuration.secretKey')}
|
||||
value={restoreData.sourceConfig.s3SecretKey || ''}
|
||||
onChange={(e) => setRestoreData(prev => ({
|
||||
...prev,
|
||||
@@ -281,7 +284,7 @@ export const RestoreWizard = () => {
|
||||
<Card className="p-4">
|
||||
<div className="text-center py-8">
|
||||
<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>
|
||||
</Card>
|
||||
)}
|
||||
@@ -291,8 +294,8 @@ export const RestoreWizard = () => {
|
||||
const renderBackupSelection = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Choose Backup to Restore</h3>
|
||||
<p className="text-sm text-gray-600">Select from available backups</p>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t('backup.restore.backup.title')}</h3>
|
||||
<p className="text-sm text-gray-600">{t('backup.restore.backup.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{loadingBackups ? (
|
||||
@@ -300,7 +303,7 @@ export const RestoreWizard = () => {
|
||||
) : availableBackups?.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<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>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
@@ -327,10 +330,10 @@ export const RestoreWizard = () => {
|
||||
</div>
|
||||
<div>
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -348,13 +351,13 @@ export const RestoreWizard = () => {
|
||||
<div className="flex items-start space-x-3">
|
||||
<Shield className="h-5 w-5 text-amber-600 mt-0.5" />
|
||||
<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">
|
||||
You'll need to provide the encryption passphrase to restore this backup.
|
||||
{t('backup.restore.backup.encryptedMessage')}
|
||||
</p>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter encryption passphrase"
|
||||
placeholder={t('backup.restore.backup.enterPassphrase')}
|
||||
className="mt-3"
|
||||
value={restoreData.encryptionPassphrase}
|
||||
onChange={(e) => setRestoreData(prev => ({
|
||||
@@ -372,8 +375,8 @@ export const RestoreWizard = () => {
|
||||
const renderRestoreOptions = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Restore Options</h3>
|
||||
<p className="text-sm text-gray-600">Choose what to restore</p>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t('backup.restore.options.title')}</h3>
|
||||
<p className="text-sm text-gray-600">{t('backup.restore.options.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -409,7 +412,7 @@ export const RestoreWizard = () => {
|
||||
|
||||
{/* Additional Options */}
|
||||
<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">
|
||||
<input
|
||||
@@ -422,9 +425,9 @@ export const RestoreWizard = () => {
|
||||
className="mt-1 h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
/>
|
||||
<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">
|
||||
By default, a backup is created before restore. Check this to skip it.
|
||||
{t('backup.restore.options.additionalOptions.skipPreBackupHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
@@ -440,9 +443,9 @@ export const RestoreWizard = () => {
|
||||
className="mt-1 h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
/>
|
||||
<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">
|
||||
Override safety checks and warnings (use with caution)
|
||||
{t('backup.restore.options.additionalOptions.forceHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
@@ -453,8 +456,8 @@ export const RestoreWizard = () => {
|
||||
const renderConfirmation = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Review & Confirm</h3>
|
||||
<p className="text-sm text-gray-600">Please review your restore configuration</p>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t('backup.restore.confirmation.title')}</h3>
|
||||
<p className="text-sm text-gray-600">{t('backup.restore.confirmation.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{validationResult ? (
|
||||
@@ -476,8 +479,8 @@ export const RestoreWizard = () => {
|
||||
validationResult.validation?.isValid ? 'text-green-900' : 'text-red-900'
|
||||
}`}>
|
||||
{validationResult.validation?.isValid
|
||||
? 'Validation Passed'
|
||||
: 'Validation Failed'}
|
||||
? t('backup.restore.confirmation.validation.passed')
|
||||
: t('backup.restore.confirmation.validation.failed')}
|
||||
</p>
|
||||
{validationResult.validation?.errors?.length > 0 && (
|
||||
<ul className="mt-2 text-sm text-red-700 list-disc list-inside">
|
||||
@@ -493,20 +496,20 @@ export const RestoreWizard = () => {
|
||||
{/* Space Check */}
|
||||
{validationResult.spaceCheck && (
|
||||
<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="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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
{!validationResult.spaceCheck.sufficient && (
|
||||
<p className="text-red-600 text-xs mt-2">
|
||||
<AlertCircle className="inline h-3 w-3 mr-1" />
|
||||
Insufficient storage space
|
||||
{t('backup.restore.confirmation.spaceCheck.insufficient')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -515,25 +518,25 @@ export const RestoreWizard = () => {
|
||||
|
||||
{/* Summary */}
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<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">
|
||||
{format(new Date(restoreData.selectedBackup.created_at), 'PPp')}
|
||||
</dd>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-600">Pre-backup:</dt>
|
||||
<dd className="font-medium">{restoreData.skipPreBackup ? 'Skipped' : 'Enabled'}</dd>
|
||||
<dt className="text-gray-600">{t('backup.restore.confirmation.summary.preBackup')}:</dt>
|
||||
<dd className="font-medium">{restoreData.skipPreBackup ? t('backup.restore.confirmation.summary.skipped') : t('backup.restore.confirmation.summary.enabled')}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
@@ -544,11 +547,10 @@ export const RestoreWizard = () => {
|
||||
<AlertTriangle className="h-5 w-5 text-amber-400 mt-0.5" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-amber-800">
|
||||
Important Notice
|
||||
{t('backup.restore.confirmation.warning.title')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-amber-700">
|
||||
This restore operation will replace existing data. Make sure you have a current backup
|
||||
before proceeding. This action cannot be undone.
|
||||
{t('backup.restore.confirmation.warning.message')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -557,7 +559,7 @@ export const RestoreWizard = () => {
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<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>
|
||||
@@ -570,9 +572,9 @@ export const RestoreWizard = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
{isRunning ? 'Restore in progress...' : 'Restore completed'}
|
||||
{isRunning ? t('backup.restore.progress.inProgress') : t('backup.restore.progress.completed')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -580,7 +582,7 @@ export const RestoreWizard = () => {
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<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>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
@@ -591,7 +593,7 @@ export const RestoreWizard = () => {
|
||||
</div>
|
||||
{progress.currentFile && (
|
||||
<p className="text-sm text-gray-600">
|
||||
Current: {progress.currentFile}
|
||||
{t('backup.restore.progress.current')}: {progress.currentFile}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -599,7 +601,7 @@ export const RestoreWizard = () => {
|
||||
|
||||
{/* Status Details */}
|
||||
<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">
|
||||
{progress.steps?.map((step, idx) => (
|
||||
<div key={idx} className="flex items-center space-x-3">
|
||||
@@ -629,7 +631,7 @@ export const RestoreWizard = () => {
|
||||
{/* Logs */}
|
||||
{progress.logs && progress.logs.length > 0 && (
|
||||
<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">
|
||||
<pre className="text-xs text-gray-300 font-mono">
|
||||
{progress.logs.join('\n')}
|
||||
@@ -645,10 +647,10 @@ export const RestoreWizard = () => {
|
||||
<CheckCircle className="h-5 w-5 text-green-400 mt-0.5" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-green-800">
|
||||
Restore Completed Successfully
|
||||
{t('backup.restore.progress.success.title')}
|
||||
</h3>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -726,7 +728,7 @@ export const RestoreWizard = () => {
|
||||
disabled={currentStep === 0 || currentStep === 4}
|
||||
>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
{t('backup.restore.actions.back')}
|
||||
</Button>
|
||||
|
||||
{currentStep < 4 && (
|
||||
@@ -739,12 +741,12 @@ export const RestoreWizard = () => {
|
||||
{restoreMutation.isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Starting...
|
||||
{t('backup.restore.actions.starting')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Start Restore
|
||||
{t('backup.restore.actions.startRestore')}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -753,18 +755,18 @@ export const RestoreWizard = () => {
|
||||
{validateMutation.isLoading ? (
|
||||
<>
|
||||
<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" />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Next
|
||||
{t('backup.restore.actions.next')}
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
@@ -788,7 +790,7 @@ export const RestoreWizard = () => {
|
||||
setValidationResult(null);
|
||||
}}
|
||||
>
|
||||
Start New Restore
|
||||
{t('backup.restore.actions.startNewRestore')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -27,4 +27,5 @@ export { BackupDashboard } from './BackupDashboard';
|
||||
export { BackupConfiguration } from './BackupConfiguration';
|
||||
export { BackupHistory } from './BackupHistory';
|
||||
export { RestoreWizard } from './RestoreWizard';
|
||||
export { FeedbackSettings } from './FeedbackSettings';
|
||||
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 { Upload, Menu } from 'lucide-react';
|
||||
import { galleryService } from '../../services/gallery.service';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
||||
|
||||
interface GalleryViewProps {
|
||||
@@ -48,6 +49,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
||||
const [feedbackEnabled, setFeedbackEnabled] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
const { watermarkEnabled } = useWatermarkSettings();
|
||||
|
||||
@@ -76,6 +78,25 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
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
|
||||
useEffect(() => {
|
||||
if (settingsData) {
|
||||
@@ -429,6 +450,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
photos={filteredPhotos}
|
||||
slug={slug}
|
||||
categoryId={selectedCategoryId}
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectedPhotos={selectedPhotos}
|
||||
onSelectionChange={setSelectedPhotos}
|
||||
|
||||
@@ -15,9 +15,10 @@ interface PhotoGridProps {
|
||||
photos: Photo[];
|
||||
slug: string;
|
||||
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 [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
||||
@@ -204,6 +205,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug, categoryId }
|
||||
initialIndex={selectedPhotoIndex}
|
||||
onClose={() => setSelectedPhotoIndex(null)}
|
||||
slug={slug}
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -34,6 +34,7 @@ interface PhotoGridWithLayoutsProps {
|
||||
eventLogo?: string | null;
|
||||
eventDate?: string;
|
||||
expiresAt?: string;
|
||||
feedbackEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
@@ -42,6 +43,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
categoryId,
|
||||
isSelectionMode: parentSelectionMode,
|
||||
selectedPhotos: parentSelectedPhotos,
|
||||
feedbackEnabled,
|
||||
onSelectionChange,
|
||||
onToggleSelectionMode: parentToggleSelectionMode,
|
||||
showSelectionControls = true,
|
||||
@@ -259,6 +261,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
initialIndex={selectedPhotoIndex}
|
||||
onClose={() => setSelectedPhotoIndex(null)}
|
||||
slug={slug}
|
||||
feedbackEnabled={feedbackEnabled || false}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -10,6 +10,7 @@ interface PhotoLightboxProps {
|
||||
initialIndex: number;
|
||||
onClose: () => void;
|
||||
slug: string;
|
||||
feedbackEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
@@ -17,6 +18,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
initialIndex,
|
||||
onClose,
|
||||
slug,
|
||||
feedbackEnabled = false,
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
@@ -227,13 +229,15 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
<Download className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
|
||||
<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>
|
||||
{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>
|
||||
|
||||
@@ -83,8 +83,8 @@ api.interceptors.response.use(
|
||||
}
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
// Check if it's an admin route
|
||||
const isAdminRoute = error.config?.url?.includes('/admin');
|
||||
// Check if it's an admin route (but not public endpoints)
|
||||
const isAdminRoute = error.config?.url?.includes('/admin') && !error.config?.url?.includes('/public/');
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
if (isAdminRoute) {
|
||||
|
||||
@@ -2,16 +2,25 @@ import { useTranslation } from 'react-i18next';
|
||||
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns';
|
||||
import { de, enUS } from 'date-fns/locale';
|
||||
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 = () => {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
// Fetch admin settings to get the date format
|
||||
// Fetch public settings to get the date format
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['admin-settings-general'],
|
||||
queryFn: () => settingsService.getSettingsByType('general'),
|
||||
queryKey: ['public-settings'],
|
||||
queryFn: () => publicSettingsService.getPublicSettings(),
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
retry: 1, // Only retry once to avoid blocking the UI
|
||||
});
|
||||
|
||||
const getLocale = () => {
|
||||
@@ -21,7 +30,18 @@ export const useLocalizedDate = () => {
|
||||
const format = (date: Date | string, formatStr?: string) => {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
// 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() });
|
||||
};
|
||||
|
||||
@@ -34,6 +54,12 @@ export const useLocalizedDate = () => {
|
||||
format,
|
||||
formatDistanceToNow,
|
||||
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",
|
||||
"customize": "Anpassen",
|
||||
"hide": "Ausblenden",
|
||||
"unknown": "Unbekannt"
|
||||
"unknown": "Unbekannt",
|
||||
"chunk": "Teil"
|
||||
},
|
||||
"upload": {
|
||||
"photoCategory": "Fotokategorie",
|
||||
@@ -51,7 +52,8 @@
|
||||
"uploadPhotos": "Fotos hochladen",
|
||||
"maxFilesReached": "Maximal 500 Dateien erlaubt",
|
||||
"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": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -367,6 +369,11 @@
|
||||
"noEventsDescription": "Erstellen Sie Ihre erste Veranstaltung, um zu beginnen.",
|
||||
"eventsSelected": "{{count}} Veranstaltung 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",
|
||||
"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.",
|
||||
@@ -903,6 +910,404 @@
|
||||
"passwordSecurityRequirements": "Passwort erfüllt nicht die Sicherheitsanforderungen",
|
||||
"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": {
|
||||
"title": "Systemwartung",
|
||||
"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",
|
||||
"customize": "Customize",
|
||||
"hide": "Hide",
|
||||
"unknown": "Unknown"
|
||||
"unknown": "Unknown",
|
||||
"chunk": "Chunk"
|
||||
},
|
||||
"upload": {
|
||||
"photoCategory": "Photo Category",
|
||||
@@ -51,7 +52,8 @@
|
||||
"uploadPhotos": "Upload Photos",
|
||||
"maxFilesReached": "Maximum 500 files allowed",
|
||||
"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": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -953,6 +955,410 @@
|
||||
"datenschutz": "Privacy Policy",
|
||||
"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": {
|
||||
"title": "System Maintenance",
|
||||
"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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button, Card, Loading } from '../../components/common';
|
||||
import { BackupDashboard } from '../../components/admin/BackupDashboard';
|
||||
@@ -33,17 +34,20 @@ import { BackupHistory } from '../../components/admin/BackupHistory';
|
||||
import { RestoreWizard } from '../../components/admin/RestoreWizard';
|
||||
import { api } from '../../config/api';
|
||||
|
||||
// Tab components
|
||||
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 }
|
||||
];
|
||||
// Tab components will be defined inside the component to use translations
|
||||
|
||||
export const BackupManagement = () => {
|
||||
const [activeTab, setActiveTab] = useState('dashboard');
|
||||
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
|
||||
const { data: backupStatus, isLoading: statusLoading } = useQuery({
|
||||
@@ -71,11 +75,11 @@ export const BackupManagement = () => {
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Backup started successfully');
|
||||
toast.success(t('backup.messages.backupStarted'));
|
||||
queryClient.invalidateQueries({ queryKey: ['backup-status'] });
|
||||
},
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -87,11 +91,11 @@ export const BackupManagement = () => {
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Backup configuration updated');
|
||||
toast.success(t('backup.messages.configUpdated'));
|
||||
queryClient.invalidateQueries({ queryKey: ['backup-config'] });
|
||||
},
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -108,9 +112,9 @@ export const BackupManagement = () => {
|
||||
<div className="p-8 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<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">
|
||||
Manage system backups, configure automated backups, and restore from previous backups.
|
||||
{t('backup.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -122,19 +126,19 @@ export const BackupManagement = () => {
|
||||
{backupStatus?.isRunning ? (
|
||||
<>
|
||||
<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 ? (
|
||||
<>
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
@@ -143,7 +147,7 @@ export const BackupManagement = () => {
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-5 w-5 text-gray-400" />
|
||||
<span className="text-sm text-gray-600">
|
||||
Next backup: {backupStatus?.nextBackup || 'Not scheduled'}
|
||||
{t('backup.status.nextBackup')}: {backupStatus?.nextBackup || t('backup.status.notScheduled')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -159,12 +163,12 @@ export const BackupManagement = () => {
|
||||
{manualBackupMutation.isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Starting...
|
||||
{t('backup.actions.starting')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Run Backup Now
|
||||
{t('backup.actions.runBackupNow')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -175,7 +179,7 @@ export const BackupManagement = () => {
|
||||
: 'bg-gray-100 text-gray-700'
|
||||
}`}>
|
||||
<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>
|
||||
|
||||
@@ -84,7 +84,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
event_type: 'wedding',
|
||||
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_email: '',
|
||||
admin_email: '',
|
||||
|
||||
@@ -24,11 +24,12 @@ import { toast } from 'react-toastify';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
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 { eventsService } from '../../services/events.service';
|
||||
import { archiveService } from '../../services/archive.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';
|
||||
|
||||
export const EventDetailsPage: React.FC = () => {
|
||||
@@ -55,6 +56,15 @@ export const EventDetailsPage: React.FC = () => {
|
||||
hero_photo_id: null as number | null,
|
||||
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 [showPhotoUpload, setShowPhotoUpload] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories'>('overview');
|
||||
@@ -78,6 +88,16 @@ export const EventDetailsPage: React.FC = () => {
|
||||
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
|
||||
|
||||
// Fetch photos (needed for both photos tab and hero photo selector)
|
||||
@@ -198,7 +218,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
const handleSaveEdit = async () => {
|
||||
// Prepare color_theme - if we have a custom theme, serialize it
|
||||
let themeToSave = editForm.color_theme;
|
||||
if (currentTheme && currentPresetName === 'custom') {
|
||||
@@ -240,7 +260,16 @@ export const EventDetailsPage: React.FC = () => {
|
||||
|
||||
console.log('Updating event with data:', updateData);
|
||||
console.log('Theme length:', updateData.color_theme ? updateData.color_theme.length : 0);
|
||||
|
||||
// Update event details
|
||||
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 () => {
|
||||
@@ -320,14 +349,16 @@ export const EventDetailsPage: React.FC = () => {
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<MessageSquare className="w-4 h-4" />}
|
||||
onClick={() => navigate(`/admin/events/${id}/feedback`)}
|
||||
>
|
||||
{t('feedback.manage', 'Manage Feedback')}
|
||||
</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 && (
|
||||
@@ -518,6 +549,15 @@ export const EventDetailsPage: React.FC = () => {
|
||||
</p>
|
||||
</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>
|
||||
) : (
|
||||
<dl className="space-y-4">
|
||||
@@ -788,6 +828,15 @@ export const EventDetailsPage: React.FC = () => {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Feedback Moderation Panel */}
|
||||
{!event.is_archived && feedbackSettings?.feedback_enabled && (
|
||||
<FeedbackModerationPanel
|
||||
eventId={parseInt(id!)}
|
||||
compact={true}
|
||||
maxItems={3}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Archive Status */}
|
||||
{event.is_archived ? (
|
||||
<Card padding="md">
|
||||
|
||||
@@ -392,7 +392,7 @@ export const EventFeedbackPage: React.FC = () => {
|
||||
<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.toFixed(1)}</p>
|
||||
<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>
|
||||
|
||||
@@ -57,7 +57,7 @@ export const SettingsPage: React.FC = () => {
|
||||
enable_registration: false,
|
||||
maintenance_mode: false,
|
||||
default_language: 'en',
|
||||
date_format: { format: 'DD/MM/YYYY', locale: 'en-GB' }
|
||||
date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' }
|
||||
});
|
||||
|
||||
// Security settings state
|
||||
@@ -99,7 +99,11 @@ export const SettingsPage: React.FC = () => {
|
||||
enable_registration: settings.general_enable_registration || false,
|
||||
maintenance_mode: settings.general_maintenance_mode || false,
|
||||
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
|
||||
@@ -130,7 +134,12 @@ export const SettingsPage: React.FC = () => {
|
||||
// Convert to the format expected by the API
|
||||
const settingsData: Record<string, any> = {};
|
||||
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);
|
||||
},
|
||||
@@ -395,10 +404,10 @@ export const SettingsPage: React.FC = () => {
|
||||
{t('settings.general.dateFormat')}
|
||||
</label>
|
||||
<select
|
||||
value={generalSettings.date_format?.format || 'DD/MM/YYYY'}
|
||||
value={generalSettings.date_format?.format || 'dd/MM/yyyy'}
|
||||
onChange={(e) => {
|
||||
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 => ({
|
||||
...prev,
|
||||
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"
|
||||
>
|
||||
<option value="DD/MM/YYYY">DD/MM/YYYY (European)</option>
|
||||
<option value="MM/DD/YYYY">MM/DD/YYYY (US)</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 (European)</option>
|
||||
<option value="MM/dd/yyyy">MM/DD/YYYY (US)</option>
|
||||
<option value="yyyy-MM-dd">YYYY-MM-DD (ISO)</option>
|
||||
<option value="dd.MM.yyyy">DD.MM.YYYY (German)</option>
|
||||
</select>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.general.dateFormatHelp')}
|
||||
|
||||
@@ -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