mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-04 00:47:02 +01:00
Merge branch 'main' into narl.io
This commit is contained in:
commit
e05e1b272c
20 files changed
+578
-179
No files matched your search
@@ -1,85 +1,147 @@
|
||||
# Holy Energy Archive & Community Hub
|
||||
# Holy Flavor Rater
|
||||
|
||||
A premium, responsive full-stack platform for "Holy Energy" fans to rate flavors, explore a categorized archive, and share personalized rating profiles.
|
||||
A high-density, community-driven platform for "Holy Energy" enthusiasts. Browse every flavor ever released—from current bestsellers to retired legacy editions—rate them, and build your own personal tiered leaderboard to share with the world.
|
||||
|
||||
## Features
|
||||
## 🚀 Key Features
|
||||
|
||||
- **Dynamic Hall of Fame:** An auto-rotating carousel featuring the top-rated flavors and community reviews.
|
||||
- **Flavor Gallery:** A space-efficient, grid-based view of all rated and missing flavors.
|
||||
- **Personalized Taste Profiles:** Share your ratings with the community using tiered (S-D) leaderboards.
|
||||
- **Responsive Design:** Optimized for all devices, from ultra-wide monitors to vertical smartphones.
|
||||
- **Secure Authentication:** SMTP-verified email signup, password resets, and session management.
|
||||
- **Catalog Automation:** Automatic flavor syncing from official Shopify sources with local image caching.
|
||||
- **Personalized Taste Profiles:** Interactive, tiered (S-D) leaderboards based on your personal ratings.
|
||||
- **Dynamic Hall of Fame:** Automatic carousel featuring community-favorite flavors and highlighted reviews.
|
||||
- **Advanced Search & Discovery:** Typo-friendly search with category-aware filtering and relevance sorting.
|
||||
- **Interactive Banners:** 10+ procedurally generated Canvas banners (Fireflies, Hextech, Nebula, etc.) that react to your mouse.
|
||||
- **Pro Themes:** Over 15 built-in themes including Catppuccin, Nord, and T0P (Twenty One Pilots) inspired aesthetics.
|
||||
- **Social Interaction:** Follow other collectors, receive follow notifications, and leave messages in profile guestbooks.
|
||||
- **Enterprise Verification:** Secure one-click email verification and password recovery.
|
||||
- **Automated Catalog:** Real-time syncing with Shopify APIs and historical legacy flavor data.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
## 🛠️ Setup & Installation
|
||||
|
||||
- [Docker](https://docs.docker.com/get-docker/)
|
||||
- [Docker Compose](https://docs.docker.com/compose/install/)
|
||||
- [SMTP Server](https://support.google.com/mail/answer/185833?hl=en) (e.g., Gmail App Password) for email verification.
|
||||
|
||||
---
|
||||
|
||||
## Quick Setup
|
||||
### Option 1: Docker (Recommended)
|
||||
The easiest way to get the full stack (Backend + Frontend + DB) running behind a production-ready proxy.
|
||||
|
||||
1. **Clone the repository:**
|
||||
```bash
|
||||
git clone https://github.com/your-repo/holy.git
|
||||
cd holy
|
||||
git clone https://github.com/nvrl/Holy-Flavor-Rater.git
|
||||
cd Holy-Flavor-Rater
|
||||
```
|
||||
|
||||
2. **Configure your environment:**
|
||||
Copy the example environment file and fill in your specific details (especially SMTP and your domain name):
|
||||
2. **Environment Configuration:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your SMTP settings and Domain
|
||||
```
|
||||
|
||||
3. **Start the containers:**
|
||||
3. **Start Services:**
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
*Note: On first startup, the backend will automatically migrate, sync the flavor catalog, and clean up any duplicates.*
|
||||
|
||||
4. **Wait for initial setup:**
|
||||
The first time you start the backend, it will automatically:
|
||||
- Run database migrations.
|
||||
- Collect static files.
|
||||
- **Sync flavors** from the Holy Energy Shopify API (this might take a minute).
|
||||
### Option 2: Local Manual Setup (Development)
|
||||
|
||||
5. **Access the application:**
|
||||
- **Frontend:** [http://localhost:5173](http://localhost:5173) (or your configured domain)
|
||||
- **API Docs:** [http://localhost:8000/api/schema/swagger-ui/](http://localhost:8000/api/schema/swagger-ui/)
|
||||
- **Admin Panel:** [http://localhost:8000/admin/](http://localhost:8000/admin/) (or your configured `ADMIN_URL`)
|
||||
#### Backend
|
||||
1. **Navigate and Install:**
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/Mac
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
2. **Initialize DB:**
|
||||
```bash
|
||||
python manage.py migrate
|
||||
python manage.py sync_flavors
|
||||
python manage.py seed_legacy_flavors
|
||||
```
|
||||
3. **Run Server:**
|
||||
```bash
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
#### Frontend
|
||||
1. **Navigate and Install:**
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
2. **Run Dev Server:**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Production Hosting
|
||||
## 🏗️ Architecture
|
||||
|
||||
### Security Best Practices
|
||||
The Holy Flavor Rater uses a modern **decoupled architecture**:
|
||||
|
||||
When hosting this application publicly, ensure you update your `.env` with:
|
||||
### Backend (Django REST Framework)
|
||||
- **Models:** Custom User model, Flavors (with category constraints), Ratings (with mention parsing), and Dynamic Banners.
|
||||
- **Automation:** Management commands for periodic Shopify API syncing and database deduplication.
|
||||
- **Security:** Token-based authentication, CSRF protection, and email-verified account activation.
|
||||
- **Monitoring:** Integrated `/health/` endpoint for Docker container status.
|
||||
|
||||
- `DEBUG=false`: Essential for disabling internal Django stack traces.
|
||||
- `SECRET_KEY`: Use a long, random string.
|
||||
- `ADMIN_URL`: Change this to something unique (e.g., `super-secret-gate/`) to hide your admin panel from scanners.
|
||||
- `ALLOWED_HOSTS`, `CSRF_TRUSTED_ORIGINS`, `CORS_ALLOWED_ORIGINS`: Set these to your actual production domain (e.g., `https://holy.narl.io`).
|
||||
|
||||
### Performance
|
||||
|
||||
- The application uses **WhiteNoise** for efficient static file serving.
|
||||
- **Gunicorn** is configured as the production WSGI server.
|
||||
- **Compression Middleware** (Gzip/Brotli) is active for API responses.
|
||||
- **Lazy Loading** is enabled for all flavor and carousel images.
|
||||
### Frontend (React 19 + TypeScript)
|
||||
- **State Management:** React Hooks and local state for high performance.
|
||||
- **UI Framework:** Material UI (MUI) with heavy customization for "Holy" branding.
|
||||
- **Visualization:** Procedural generative art system using HTML5 Canvas for profile backgrounds.
|
||||
- **Internationalization:** Full i18next integration (English & German).
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
## 💻 Development Workflow
|
||||
|
||||
- `backend/`: Django REST Framework API.
|
||||
- `frontend/`: React 19 application with Material UI and Catppuccin themes.
|
||||
- `media/`: Locally cached product images and user avatars.
|
||||
- `legacy/`: Static JSON data for historical flavors.
|
||||
### Integrity Protection
|
||||
This project includes a **Pre-Push Git Hook** to ensure code quality. It automatically:
|
||||
1. Attempts a full Frontend build (`tsc` + `vite build`).
|
||||
2. Runs Backend system checks and validates missing migrations.
|
||||
|
||||
## License
|
||||
**To install the hooks:**
|
||||
```bash
|
||||
bash scripts/install-hooks.sh
|
||||
```
|
||||
|
||||
### Management Commands
|
||||
- `python manage.py sync_flavors`: Syncs the latest products from the official Holy Energy API.
|
||||
- `python manage.py cleanup_duplicates`: Merges duplicate entries and maintains rating integrity.
|
||||
- `python manage.py seed_banners`: Updates procedurally generated banner configurations from JSON.
|
||||
- `python manage.py backup_db`: Creates a consistent SQLite snapshot.
|
||||
|
||||
---
|
||||
|
||||
## 💾 Backups & Restoration
|
||||
|
||||
### Create a Backup
|
||||
To create a full snapshot of your database and user-uploaded media:
|
||||
```bash
|
||||
bash scripts/backup.sh
|
||||
```
|
||||
This will create a `.tar.gz` file in `backend/backups/`.
|
||||
|
||||
**Automating with Cron:**
|
||||
Add this to your `crontab -e` to backup every night at 2:00 AM:
|
||||
```bash
|
||||
0 2 * * * /path/to/Holy-Flavor-Rater/scripts/backup.sh
|
||||
```
|
||||
|
||||
### Restore from Backup
|
||||
1. Extract the archive: `tar -xzf backup_filename.tar.gz`
|
||||
2. Stop the containers: `docker-compose down`
|
||||
3. Replace the current files:
|
||||
```bash
|
||||
cp extracted_folder/db.sqlite3 backend/db.sqlite3
|
||||
cp -r extracted_folder/media/* backend/media/
|
||||
```
|
||||
4. Restart: `docker-compose up -d`
|
||||
|
||||
---
|
||||
|
||||
## 🌍 Production Configuration
|
||||
|
||||
Ensure your `.env` contains:
|
||||
- `DEBUG=false`
|
||||
- `SECRET_KEY`: A long random string.
|
||||
- `ALLOWED_HOSTS`: Your domain (e.g., `holy.narl.io`).
|
||||
- `FRONTEND_URL`: For one-click email verification links.
|
||||
|
||||
## 📄 License
|
||||
MIT
|
||||
@@ -10,6 +10,7 @@ WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy the rest of the backend code
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import sqlite3
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime, timedelta
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Creates a safe, consistent backup of the SQLite database and manages rotation.'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--output', type=str, help='Specific output path for the backup file')
|
||||
parser.add_argument('--retention', type=int, default=7, help='Number of days to keep old backups')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
db_conf = settings.DATABASES['default']
|
||||
if db_conf['ENGINE'] != 'django.db.backends.sqlite3':
|
||||
self.stdout.write(self.style.ERROR("This command only supports SQLite."))
|
||||
return
|
||||
|
||||
db_path = str(db_conf['NAME'])
|
||||
backup_dir = os.path.join(settings.BASE_DIR, 'backups')
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
|
||||
# 1. Database Backup
|
||||
if options['output']:
|
||||
backup_path = options['output']
|
||||
else:
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_path = os.path.join(backup_dir, f'db_backup_{timestamp}.sqlite3')
|
||||
|
||||
self.stdout.write(f"Starting backup of {db_path}...")
|
||||
|
||||
try:
|
||||
# Use SQLite's online backup API for a consistent copy
|
||||
src = sqlite3.connect(db_path)
|
||||
dst = sqlite3.connect(backup_path)
|
||||
with dst:
|
||||
src.backup(dst)
|
||||
dst.close()
|
||||
src.close()
|
||||
self.stdout.write(self.style.SUCCESS(f"Successfully backed up database to {backup_path}"))
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f"Database backup failed: {e}"))
|
||||
return
|
||||
|
||||
# 2. Cleanup Old Backups
|
||||
retention_days = options['retention']
|
||||
cutoff = datetime.now() - timedelta(days=retention_days)
|
||||
|
||||
self.stdout.write(f"Cleaning up backups older than {retention_days} days...")
|
||||
for filename in os.listdir(backup_dir):
|
||||
file_path = os.path.join(backup_dir, filename)
|
||||
if os.path.isfile(file_path):
|
||||
file_time = datetime.fromtimestamp(os.path.getmtime(file_path))
|
||||
if file_time < cutoff:
|
||||
os.remove(file_path)
|
||||
self.stdout.write(f" -> Deleted: {filename}")
|
||||
@@ -0,0 +1,44 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from api.models import Flavor, Rating
|
||||
from django.db.models import Count
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Identifies and merges duplicate flavors (same name and category)'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Find duplicates based on name and category
|
||||
duplicates = Flavor.objects.values('name', 'category').annotate(
|
||||
name_count=Count('id')
|
||||
).filter(name_count__gt=1)
|
||||
|
||||
self.stdout.write(f"Found {duplicates.count()} sets of duplicates.")
|
||||
|
||||
for dup in duplicates:
|
||||
name = dup['name']
|
||||
category_id = dup['category']
|
||||
|
||||
# Get all flavors in this set, ordered by having an external_id first, then by date
|
||||
flavor_set = Flavor.objects.filter(
|
||||
name=name,
|
||||
category_id=category_id
|
||||
).order_by('-external_id', 'created_at')
|
||||
|
||||
# Keep the first one, merge others into it
|
||||
keep = flavor_set[0]
|
||||
others = flavor_set[1:]
|
||||
|
||||
self.stdout.write(f"Merging {len(others)} duplicates into '{keep.name}' (ID: {keep.id}, Ext: {keep.external_id})")
|
||||
|
||||
for other in others:
|
||||
# Re-assign ratings
|
||||
Rating.objects.filter(flavor=other).update(flavor=keep)
|
||||
# Note: This might trigger unique constraint errors on Rating (user, flavor)
|
||||
# if the user rated both duplicates. We'll handle that if needed.
|
||||
|
||||
# Re-assign other related models if any (e.g. notifications, etc.)
|
||||
# In our case, Rating is the main one.
|
||||
|
||||
# Delete the duplicate
|
||||
other.delete()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Finished cleaning up duplicates."))
|
||||
@@ -78,7 +78,7 @@ class Command(BaseCommand):
|
||||
synced_external_ids = []
|
||||
|
||||
for p in products:
|
||||
title = p.get('title')
|
||||
title = p.get('title', '').strip()
|
||||
tags = p.get('tags', [])
|
||||
if isinstance(tags, str):
|
||||
tags = [t.strip() for t in tags.split(',')]
|
||||
@@ -119,23 +119,26 @@ class Command(BaseCommand):
|
||||
handle = p.get('handle')
|
||||
shop_url = f"https://weareholy.com/products/{handle}" if handle else None
|
||||
|
||||
# Find or create
|
||||
# Find or create - be very specific to avoid duplicates
|
||||
# 1. Try external_id
|
||||
flavor = Flavor.objects.filter(external_id=p['id']).first()
|
||||
|
||||
if not flavor:
|
||||
# Try to find by name AND category to avoid Milkshake/Iced Tea name collisions
|
||||
# 2. Try exact name match in category
|
||||
flavor = Flavor.objects.filter(name=title, category=category, is_legacy=False).first()
|
||||
|
||||
if not flavor:
|
||||
# 3. Try case-insensitive name match in category (safety for whitespace/case)
|
||||
flavor = Flavor.objects.filter(name__iexact=title, category=category, is_legacy=False).first()
|
||||
|
||||
if not flavor:
|
||||
flavor = Flavor(external_id=p['id'], name=title, category=category, is_legacy=False)
|
||||
created_count += 1
|
||||
else:
|
||||
updated_count += 1
|
||||
|
||||
flavor.category = category
|
||||
flavor.description = description
|
||||
flavor.shop_url = shop_url
|
||||
flavor.is_available = is_available
|
||||
flavor.external_id = p['id'] # Ensure ID is set
|
||||
# Ensure external_id is updated if it was missing
|
||||
if not flavor.external_id:
|
||||
flavor.external_id = p['id']
|
||||
|
||||
if image_url:
|
||||
# Check if local file exists
|
||||
|
||||
+47
-9
@@ -106,24 +106,62 @@ class FlavorViewSet(viewsets.ModelViewSet):
|
||||
# Filter by remaining query words in name or description
|
||||
if remaining_query:
|
||||
words = remaining_query.split()
|
||||
# Construct a combined OR query for all words to be more permissive (fuzzy-ish)
|
||||
word_query = Q()
|
||||
for word in words:
|
||||
flavors = flavors.filter(
|
||||
Q(name__icontains=word) | Q(description__icontains=word)
|
||||
)
|
||||
if len(word) > 2: # Only filter by words longer than 2 chars to avoid noise
|
||||
word_query |= Q(name__icontains=word) | Q(description__icontains=word)
|
||||
|
||||
if word_query:
|
||||
flavors = flavors.filter(word_query)
|
||||
|
||||
# Limit results for performance
|
||||
results = []
|
||||
for f in flavors.order_by('name')[:15]:
|
||||
results.append({
|
||||
# Limit results and deduplicate in Python to be safe against DB inconsistencies
|
||||
results_list = []
|
||||
seen_keys = set()
|
||||
|
||||
# Priority sorting in Python
|
||||
search_lower = remaining_query.lower()
|
||||
|
||||
# Order by name, but we'll also try to prioritize exact matches in Python
|
||||
for f in flavors.order_by('name'):
|
||||
# Create a unique key for this flavor (name + category)
|
||||
key = f"{f.name.strip().lower()}|{f.category.slug}"
|
||||
if key in seen_keys:
|
||||
continue
|
||||
|
||||
seen_keys.add(key)
|
||||
|
||||
name_lower = f.name.lower()
|
||||
relevance = 0
|
||||
if name_lower == search_lower:
|
||||
relevance = 3
|
||||
elif name_lower.startswith(search_lower):
|
||||
relevance = 2
|
||||
elif search_lower in name_lower:
|
||||
relevance = 1
|
||||
|
||||
results_list.append({
|
||||
'id': f.id,
|
||||
'name': f.name,
|
||||
'type': 'flavor',
|
||||
'subtitle': f.category.name,
|
||||
'image_url': request.build_absolute_uri(f.image.url) if f.image else f.image_url,
|
||||
'slug': None
|
||||
'slug': None,
|
||||
'relevance': relevance
|
||||
})
|
||||
|
||||
return Response(results)
|
||||
# Sort by relevance desc, then name
|
||||
results_list.sort(key=lambda x: (-x['relevance'], x['name']))
|
||||
|
||||
# Remove relevance helper and limit to 15
|
||||
final_results = []
|
||||
for r in results_list:
|
||||
del r['relevance']
|
||||
final_results.append(r)
|
||||
if len(final_results) >= 15:
|
||||
break
|
||||
|
||||
return Response(final_results)
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny])
|
||||
def top(self, request):
|
||||
|
||||
@@ -25,6 +25,10 @@ chmod -R 777 django_cache
|
||||
echo "Syncing flavors..."
|
||||
python manage.py sync_flavors
|
||||
|
||||
# Merge any duplicates that might have been created
|
||||
echo "Cleaning up duplicates..."
|
||||
python manage.py cleanup_duplicates
|
||||
|
||||
# Seed legacy flavors if needed
|
||||
echo "Seeding legacy flavors..."
|
||||
python manage.py seed_legacy_flavors
|
||||
|
||||
@@ -6,12 +6,19 @@ from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.views.static import serve
|
||||
from django.views.generic.base import TemplateView
|
||||
from django.http import HttpResponse
|
||||
import os
|
||||
|
||||
def health_check(request):
|
||||
return HttpResponse("OK", content_type="text/plain")
|
||||
|
||||
# Clean the admin path
|
||||
admin_url = os.environ.get('ADMIN_URL', 'admin').strip('/')
|
||||
|
||||
urlpatterns = [
|
||||
# Health check
|
||||
path('health/', health_check, name='health_check'),
|
||||
|
||||
# Robots.txt
|
||||
path('robots.txt', TemplateView.as_view(template_name="robots.txt", content_type="text/plain")),
|
||||
|
||||
|
||||
+8
-1
@@ -25,6 +25,12 @@ services:
|
||||
- "traefik.http.routers.holy-energy-backend.entrypoints=websecure"
|
||||
- "traefik.http.routers.holy-energy-backend.tls.certresolver=https_resolver"
|
||||
- "traefik.http.services.holy-energy-backend.loadbalancer.server.port=8000"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health/"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
# --- Frontend Service ---
|
||||
frontend:
|
||||
@@ -46,7 +52,8 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- backend
|
||||
backend:
|
||||
condition: service_healthy
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=narl"
|
||||
|
||||
+5
-1
@@ -27,6 +27,10 @@ FROM nginx:stable-alpine
|
||||
# Copy built files
|
||||
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Custom Nginx config to handle React Router (SPA)
|
||||
RUN printf 'server {\n\
|
||||
listen 80;\n\
|
||||
@@ -38,4 +42,4 @@ RUN printf 'server {\n\
|
||||
}' > /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
|
||||
# The backend hostname is 'holy-backend' based on docker-compose.yml
|
||||
# We wait for port 8000 to be open
|
||||
echo "Waiting for backend (holy-backend:8000) to be available..."
|
||||
|
||||
MAX_RETRIES=30
|
||||
COUNT=0
|
||||
|
||||
while ! nc -z holy-backend 8000; do
|
||||
COUNT=$((COUNT + 1))
|
||||
if [ $COUNT -ge $MAX_RETRIES ]; then
|
||||
echo "Backend timed out after $MAX_RETRIES attempts - starting Nginx anyway"
|
||||
break
|
||||
fi
|
||||
echo "Backend is unavailable (attempt $COUNT/$MAX_RETRIES) - sleeping"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "Starting Nginx..."
|
||||
exec nginx -g "daemon off;"
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, alpha, useTheme } from '@mui/material';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import BlockIcon from '@mui/icons-material/Block';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
isLegacy?: boolean;
|
||||
isAvailable?: boolean;
|
||||
size?: 'small' | 'medium';
|
||||
sx?: any;
|
||||
}
|
||||
|
||||
const StatusBadge: React.FC<StatusBadgeProps> = ({ isLegacy, isAvailable, size = 'medium', sx }) => {
|
||||
const theme = useTheme();
|
||||
const isSmall = size === 'small';
|
||||
|
||||
if (isAvailable && !isLegacy) return null;
|
||||
|
||||
const label = isLegacy ? 'Limited' : 'Out of Stock';
|
||||
const color = isLegacy ? theme.palette.warning.main : theme.palette.error.main;
|
||||
const Icon = isLegacy ? HistoryIcon : BlockIcon;
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
bgcolor: alpha(color, 0.9),
|
||||
color: '#fff',
|
||||
px: isSmall ? 0.8 : 1.2,
|
||||
py: isSmall ? 0.2 : 0.4,
|
||||
borderRadius: 1.5,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.2)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
border: '1px solid',
|
||||
borderColor: alpha('#fff', 0.2),
|
||||
...sx
|
||||
}}>
|
||||
<Icon sx={{ fontSize: isSmall ? '0.7rem' : '0.9rem' }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: '900',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
fontSize: isSmall ? '0.55rem' : '0.65rem',
|
||||
lineHeight: 1
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusBadge;
|
||||
+10
-8
@@ -9,7 +9,7 @@ const resources = {
|
||||
"home": "Home",
|
||||
"community": "Community",
|
||||
"about": "About",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboard": "My Collection",
|
||||
"profile": "My Profile",
|
||||
"settings": "Settings",
|
||||
"logout": "Logout",
|
||||
@@ -25,6 +25,7 @@ const resources = {
|
||||
"packs-and-other": "Packs & Other"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "My Collection",
|
||||
"welcome": "Welcome back, {{username}}!",
|
||||
"shareProfile": "Share Profile",
|
||||
"copySuccess": "Link copied to clipboard!",
|
||||
@@ -32,14 +33,14 @@ const resources = {
|
||||
"missing": "Missing",
|
||||
"noRatings": "You haven't rated any flavors yet.",
|
||||
"noComment": "No comment provided.",
|
||||
"exploreFlavors": "Go explore flavors",
|
||||
"exploreFlavors": "Explore New Flavors",
|
||||
"allRated": "Amazing! You've rated everything! 🏆",
|
||||
"myReviews": "My Reviews",
|
||||
"myReviews": "My Rated Flavors",
|
||||
"exploreNew": "Explore New",
|
||||
"editingReview": "Editing review for {{flavor}}",
|
||||
"newScore": "New Score:",
|
||||
"searchMissing": "Search flavors you haven't rated...",
|
||||
"searchRated": "Search your reviews...",
|
||||
"searchRated": "Search your collection...",
|
||||
"circleAvg": "Average rating from your friends",
|
||||
"sortBy": "Sort by",
|
||||
"date": "Date",
|
||||
@@ -250,7 +251,7 @@ const resources = {
|
||||
"home": "Startseite",
|
||||
"community": "Community",
|
||||
"about": "Über uns",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboard": "Mein Archiv",
|
||||
"profile": "Mein Profil",
|
||||
"settings": "Einstellungen",
|
||||
"logout": "Abmelden",
|
||||
@@ -266,6 +267,7 @@ const resources = {
|
||||
"packs-and-other": "Bundles & Mehr"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Mein Archiv",
|
||||
"welcome": "Willkommen zurück, {{username}}!",
|
||||
"shareProfile": "Profil teilen",
|
||||
"copySuccess": "Link in die Zwischenablage kopiert!",
|
||||
@@ -273,14 +275,14 @@ const resources = {
|
||||
"missing": "Fehlend",
|
||||
"noRatings": "Du hast noch keine Sorten bewertet.",
|
||||
"noComment": "Kein Kommentar abgegeben.",
|
||||
"exploreFlavors": "Sorten entdecken",
|
||||
"exploreFlavors": "Neue Sorten entdecken",
|
||||
"allRated": "Wahnsinn! Du hast alles bewertet! 🏆",
|
||||
"myReviews": "Meine Rezensionen",
|
||||
"myReviews": "Meine Sammlungen",
|
||||
"exploreNew": "Neu entdecken",
|
||||
"editingReview": "Rezension bearbeiten für {{flavor}}",
|
||||
"newScore": "Neue Bewertung:",
|
||||
"searchMissing": "Suche nach Sorten, die du noch nicht bewertet hast...",
|
||||
"searchRated": "Suche in deinen Rezensionen...",
|
||||
"searchRated": "Suche in deinem Archiv...",
|
||||
"circleAvg": "Durchschnittsbewertung deiner Freunde",
|
||||
"sortBy": "Sortieren nach",
|
||||
"date": "Datum",
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Chip,
|
||||
Collapse,
|
||||
TextField,
|
||||
Rating as MuiRating,
|
||||
InputAdornment,
|
||||
Tooltip,
|
||||
useMediaQuery,
|
||||
@@ -36,9 +37,9 @@ import { useTranslation } from 'react-i18next';
|
||||
import api from '../api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import RatingBadge from '../components/RatingBadge';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import MentionTextField from '../components/MentionTextField';
|
||||
import RichText from '../components/RichText';
|
||||
import DynamicBanner from '../components/DynamicBanner';
|
||||
import { formatDate } from '../utils/date';
|
||||
|
||||
interface DashboardData {
|
||||
@@ -54,7 +55,7 @@ const Dashboard: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('lg'));
|
||||
useTitle(t('nav.dashboard'));
|
||||
useTitle(t('dashboard.title'));
|
||||
const [data, setData] = useState<DashboardData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
@@ -232,14 +233,17 @@ const Dashboard: React.FC = () => {
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
{/* Action Bar */}
|
||||
<Box sx={{ mb: 4, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleGoBack}
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold', color: 'text.secondary', borderColor: 'divider' }}
|
||||
>
|
||||
{window.history.length > 1 ? t('common.back') : t('common.backToHome')}
|
||||
</Button>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleGoBack}
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold', color: 'text.secondary', borderColor: 'divider' }}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Typography variant="h5" sx={{ fontWeight: '900', display: { xs: 'none', md: 'block' } }}>{t('dashboard.title')}</Typography>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
@@ -263,7 +267,7 @@ const Dashboard: React.FC = () => {
|
||||
position: 'relative'
|
||||
}}>
|
||||
<Box sx={{
|
||||
height: { xs: 100, sm: 140 },
|
||||
height: { xs: 80, sm: 100 },
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
@@ -279,21 +283,13 @@ const Dashboard: React.FC = () => {
|
||||
`,
|
||||
zIndex: 0
|
||||
}} />
|
||||
|
||||
{/* Layer 2: Interactive Generative Art */}
|
||||
<DynamicBanner
|
||||
username={data.user.username}
|
||||
palette={palette}
|
||||
ratingsCount={data.rated_count}
|
||||
followersCount={0}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<CardContent sx={{ pt: 0, px: { xs: 2, sm: 4 }, pb: 4, position: 'relative', zIndex: 2 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, alignItems: { xs: 'center', sm: 'flex-end' }, gap: { xs: 2, sm: 4 } }}>
|
||||
<Box sx={{
|
||||
position: 'relative',
|
||||
mt: { xs: -6, sm: -8 },
|
||||
mt: { xs: -5, sm: -6 },
|
||||
p: 0.5,
|
||||
borderRadius: '50%',
|
||||
background: `linear-gradient(135deg, ${palette[0] || theme.palette.primary.main}, ${theme.palette.secondary.main})`,
|
||||
@@ -303,8 +299,8 @@ const Dashboard: React.FC = () => {
|
||||
<Avatar
|
||||
src={data.user.avatar || undefined}
|
||||
sx={{
|
||||
width: { xs: 100, sm: 120 },
|
||||
height: { xs: 100, sm: 120 },
|
||||
width: { xs: 90, sm: 110 },
|
||||
height: { xs: 90, sm: 110 },
|
||||
border: '4px solid',
|
||||
borderColor: (theme) => theme.palette.background.paper,
|
||||
fontSize: '3rem',
|
||||
@@ -356,7 +352,7 @@ const Dashboard: React.FC = () => {
|
||||
|
||||
{/* Tabs Section */}
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 4 }}>
|
||||
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)} textColor="primary" indicatorColor="primary">
|
||||
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)} textColor="primary" indicatorColor="primary" variant="fullWidth">
|
||||
<Tab label={t('dashboard.myReviews')} sx={{ fontWeight: '900', textTransform: 'none', fontSize: '1.1rem' }} />
|
||||
<Tab label={t('dashboard.exploreNew')} sx={{ fontWeight: '900', textTransform: 'none', fontSize: '1.1rem' }} />
|
||||
</Tabs>
|
||||
@@ -422,7 +418,7 @@ const Dashboard: React.FC = () => {
|
||||
{filteredAndSortedRated.length === 0 ? (
|
||||
<Box sx={{ py: 10, textAlign: 'center', bgcolor: 'action.hover', borderRadius: 4, border: '1px dashed', borderColor: 'divider' }}>
|
||||
<Typography variant="h6" color="text.secondary">{t('dashboard.noRatings')}</Typography>
|
||||
<Button onClick={() => setActiveTab(1)} sx={{ mt: 2 }}>{t('dashboard.exploreFlavors')}</Button>
|
||||
<Button onClick={() => setActiveTab(1)} variant="contained" sx={{ mt: 2, borderRadius: 2 }}>{t('dashboard.exploreFlavors')}</Button>
|
||||
</Box>
|
||||
) : (
|
||||
filteredAndSortedRated.map(rating => (
|
||||
@@ -440,11 +436,9 @@ const Dashboard: React.FC = () => {
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>{t('dashboard.editingReview', { flavor: rating.flavor_name })}</Typography>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<Typography variant="body2">{t('dashboard.newScore')}</Typography>
|
||||
<TextField
|
||||
type="number" size="small"
|
||||
slotProps={{ input: { inputProps: { min: 1, max: 10 } } }}
|
||||
value={editScore} onChange={(e) => setEditScore(Number(e.target.value))}
|
||||
sx={{ width: 80 }}
|
||||
<MuiRating
|
||||
max={10}
|
||||
value={editScore} onChange={(_: any, val: number | null) => setEditScore(val || 0)}
|
||||
/>
|
||||
</Stack>
|
||||
<MentionTextField
|
||||
@@ -467,9 +461,12 @@ const Dashboard: React.FC = () => {
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', lineHeight: 1.2 }}>
|
||||
<Link to={`/flavor/${rating.flavor}`} style={{ color: 'inherit', textDecoration: 'none' }}>{rating.flavor_name}</Link>
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
{t(`categories.${rating.category_slug}`, { defaultValue: rating.category_name })} • {formatDate(rating.created_at)}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t(`categories.${rating.category_slug}`, { defaultValue: rating.category_name })} • {formatDate(rating.created_at)}
|
||||
</Typography>
|
||||
<StatusBadge isLegacy={rating.is_legacy} isAvailable={rating.is_available} size="small" />
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
<RatingBadge score={rating.score} size={isMobile ? "medium" : "large"} />
|
||||
@@ -630,29 +627,26 @@ const Dashboard: React.FC = () => {
|
||||
'&:hover': { transform: 'scale(1.1)' }
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ position: 'absolute', top: 10, left: 10, zIndex: 2 }}>
|
||||
<RatingBadge score={flavor.average_rating || 0} size="small" sx={{ height: 24 }} />
|
||||
<Box sx={{ position: 'absolute', top: 10, left: 10, zIndex: 2, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Tooltip title={exploreSort === 'community' ? t('dashboard.communityRating') : t('dashboard.circleRating')}>
|
||||
<Box>
|
||||
<RatingBadge
|
||||
score={exploreSort === 'community' ? (flavor.average_rating || 0) : (flavor.followed_average_rating || 0)}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 24,
|
||||
bgcolor: exploreSort === 'circle' ? 'secondary.main' : 'primary.main',
|
||||
'& .MuiTypography-root': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<StatusBadge isLegacy={flavor.is_legacy} isAvailable={flavor.is_available} size="small" />
|
||||
</Box>
|
||||
{flavor.followed_average_rating && (
|
||||
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 2 }}>
|
||||
<Tooltip title={t('dashboard.circleAvg')}>
|
||||
<Box sx={{
|
||||
bgcolor: alpha(theme.palette.secondary.main, 0.9),
|
||||
color: 'white',
|
||||
px: 1,
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
boxShadow: 2,
|
||||
height: 24
|
||||
}}>
|
||||
<VerifiedIcon sx={{ fontSize: '0.8rem' }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: '900', lineHeight: 1 }}>{flavor.followed_average_rating.toFixed(1)}</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', mb: 0.5, display: '-webkit-box', WebkitLineClamp: 1, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
|
||||
@@ -22,6 +22,7 @@ import api from '../api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import { formatDate } from '../utils/date';
|
||||
import RatingBadge from '../components/RatingBadge';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface Flavor {
|
||||
@@ -165,15 +166,9 @@ const MainPage: React.FC<MainPageProps> = ({ adminMode }) => {
|
||||
<Box key={flavor.id}>
|
||||
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column', position: 'relative', transition: 'transform 0.2s', '&:hover': { transform: 'scale(1.02)' } }}>
|
||||
<Box component={Link} to={`/flavor/${flavor.id}`} sx={{ textDecoration: 'none', color: 'inherit', flexGrow: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
{!flavor.is_available && (
|
||||
<Box sx={{
|
||||
position: 'absolute', top: 10, right: 10,
|
||||
bgcolor: flavor.is_legacy ? 'warning.main' : 'error.main',
|
||||
color: 'white', px: 1, borderRadius: 1, fontSize: '0.75rem', zIndex: 1
|
||||
}}>
|
||||
{flavor.is_legacy ? 'Unavailable' : 'Out of Stock'}
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 1 }}>
|
||||
<StatusBadge isLegacy={flavor.is_legacy} isAvailable={flavor.is_available} size="small" />
|
||||
</Box>
|
||||
{flavor.image_url && (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -407,6 +402,9 @@ const MainPage: React.FC<MainPageProps> = ({ adminMode }) => {
|
||||
textDecoration: 'none'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'absolute', top: 16, right: 16, zIndex: 1 }}>
|
||||
<StatusBadge isLegacy={currentTop.is_legacy} isAvailable={currentTop.is_available} />
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
component="img"
|
||||
@@ -553,8 +551,11 @@ const MainPage: React.FC<MainPageProps> = ({ adminMode }) => {
|
||||
gap: 2
|
||||
}}>
|
||||
{newestFlavors.map(flavor => (
|
||||
<Card key={flavor.id} sx={{ transition: 'transform 0.2s', '&:hover': { transform: 'translateY(-4px)' }, borderRadius: 3 }}>
|
||||
<Card key={flavor.id} sx={{ transition: 'transform 0.2s', '&:hover': { transform: 'translateY(-4px)' }, borderRadius: 3, position: 'relative' }}>
|
||||
<Link to={`/flavor/${flavor.id}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<Box sx={{ position: 'absolute', top: 8, right: 8, zIndex: 1 }}>
|
||||
<StatusBadge isLegacy={flavor.is_legacy} isAvailable={flavor.is_available} size="small" />
|
||||
</Box>
|
||||
<Box sx={{ aspectRatio: '1/1', overflow: 'hidden', bgcolor: 'action.hover' }}>
|
||||
<Box
|
||||
component="img"
|
||||
|
||||
@@ -13,6 +13,7 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import api from '../../api';
|
||||
import { useTitle } from '../../hooks/useTitle';
|
||||
import RatingBadge from '../../components/RatingBadge';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Rating {
|
||||
@@ -114,15 +115,9 @@ const CategoryFlavors: React.FC = () => {
|
||||
<Box key={flavor.id}>
|
||||
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column', position: 'relative', transition: 'transform 0.2s', '&:hover': { transform: 'scale(1.02)' } }}>
|
||||
<Box component={Link} to={`/flavor/${flavor.id}`} sx={{ textDecoration: 'none', color: 'inherit', flexGrow: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
{!flavor.is_available && (
|
||||
<Box sx={{
|
||||
position: 'absolute', top: 10, right: 10,
|
||||
bgcolor: flavor.is_legacy ? 'warning.main' : 'error.main',
|
||||
color: 'white', px: 1, borderRadius: 1, fontSize: '0.75rem', zIndex: 1
|
||||
}}>
|
||||
{flavor.is_legacy ? 'Unavailable' : 'Out of Stock'}
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 1 }}>
|
||||
<StatusBadge isLegacy={flavor.is_legacy} isAvailable={flavor.is_available} size="small" />
|
||||
</Box>
|
||||
{flavor.image_url && (
|
||||
<Box
|
||||
sx={{
|
||||
|
||||
@@ -28,6 +28,7 @@ import { formatDate } from '../../utils/date';
|
||||
import MentionTextField from '../../components/MentionTextField';
|
||||
import RichText from '../../components/RichText';
|
||||
import RatingBadge from '../../components/RatingBadge';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
|
||||
interface Reply {
|
||||
id: number;
|
||||
@@ -239,31 +240,19 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
backdropFilter: 'blur(12px)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<Box sx={{
|
||||
p: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'action.hover',
|
||||
position: 'relative',
|
||||
aspectRatio: '1/1'
|
||||
}}>
|
||||
{!flavor.is_available && (
|
||||
<Chip
|
||||
label={flavor.is_legacy ? "Legacy" : "Out of Stock"}
|
||||
color={flavor.is_legacy ? "warning" : "error"}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
fontWeight: '900',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{flavor.image_url ? (
|
||||
<Box
|
||||
<Box sx={{
|
||||
p: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'action.hover',
|
||||
position: 'relative',
|
||||
aspectRatio: '1/1'
|
||||
}}>
|
||||
<Box sx={{ position: 'absolute', top: 16, right: 16, zIndex: 1 }}>
|
||||
<StatusBadge isLegacy={flavor.is_legacy} isAvailable={flavor.is_available} />
|
||||
</Box>
|
||||
{flavor.image_url ? ( <Box
|
||||
component="img"
|
||||
src={flavor.image_url}
|
||||
sx={{
|
||||
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
|
||||
# This script creates a full snapshot of the database and user media.
|
||||
# Best run as a daily cron job.
|
||||
|
||||
ROOT_DIR=$(git rev-parse --show-toplevel)
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_NAME="full_snapshot_$TIMESTAMP"
|
||||
TEMP_DIR="backend/backups/$BACKUP_NAME"
|
||||
FINAL_DEST="backend/backups"
|
||||
|
||||
echo "========================================"
|
||||
echo " Starting Full Project Backup... "
|
||||
echo "========================================"
|
||||
|
||||
mkdir -p "$TEMP_DIR"
|
||||
|
||||
# 1. Run the database backup command
|
||||
echo "Backing up database..."
|
||||
docker exec holy-backend python manage.py backup_db --output "/app/backups/$BACKUP_NAME/db.sqlite3"
|
||||
|
||||
# 2. Copy the media folder
|
||||
echo "Backing up media..."
|
||||
cp -r backend/media "$TEMP_DIR/media"
|
||||
|
||||
# 3. Create compressed archive
|
||||
echo "Compressing..."
|
||||
tar -czf "$FINAL_DEST/$BACKUP_NAME.tar.gz" -C "backend/backups" "$BACKUP_NAME"
|
||||
|
||||
# 4. Cleanup temp folder
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
echo ""
|
||||
echo "✅ Backup complete: $FINAL_DEST/$BACKUP_NAME.tar.gz"
|
||||
echo "========================================"
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Get the root directory
|
||||
ROOT_DIR=$(git rev-parse --show-toplevel)
|
||||
|
||||
# Path to the hook script
|
||||
HOOK_SRC="$ROOT_DIR/scripts/pre-push.sh"
|
||||
HOOK_DEST="$ROOT_DIR/.git/hooks/pre-push"
|
||||
|
||||
# Make the source script executable
|
||||
chmod +x "$HOOK_SRC"
|
||||
|
||||
# Copy/Link to git hooks
|
||||
cp "$HOOK_SRC" "$HOOK_DEST"
|
||||
chmod +x "$HOOK_DEST"
|
||||
|
||||
echo "✅ Pre-push hook installed successfully!"
|
||||
echo "It will now run frontend builds and backend checks automatically before every 'git push'."
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Get the root directory of the project
|
||||
ROOT_DIR=$(git rev-parse --show-toplevel)
|
||||
|
||||
# Set environment variables for non-interactive backend checks
|
||||
export SECRET_KEY="pre-push-check-key"
|
||||
export DEBUG="false"
|
||||
export ALLOWED_HOSTS="localhost"
|
||||
|
||||
echo "========================================"
|
||||
echo " Running Pre-Push Integrity Checks "
|
||||
echo "========================================"
|
||||
|
||||
# 1. Frontend Build
|
||||
echo ""
|
||||
echo "Step 1: Checking Frontend Build..."
|
||||
cd "$ROOT_DIR/frontend"
|
||||
if ! npm run build; then
|
||||
echo ""
|
||||
echo "❌ ERROR: Frontend build failed."
|
||||
echo " Please fix build errors before pushing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Backend Check
|
||||
echo ""
|
||||
echo "Step 2: Checking Backend Integrity..."
|
||||
cd "$ROOT_DIR/backend"
|
||||
|
||||
# Detect python executable (prefer venv if exists)
|
||||
if [ -f "venv/bin/python" ]; then
|
||||
PYTHON_EXEC="venv/bin/python"
|
||||
elif command -v python3 &>/dev/null; then
|
||||
PYTHON_EXEC="python3"
|
||||
else
|
||||
PYTHON_EXEC="python"
|
||||
fi
|
||||
|
||||
# Check for missing migrations
|
||||
if ! $PYTHON_EXEC manage.py makemigrations --check --dry-run &>/dev/null; then
|
||||
echo ""
|
||||
echo "❌ ERROR: Missing migrations detected."
|
||||
echo " Please run 'python manage.py makemigrations' locally."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run Django system checks
|
||||
if ! $PYTHON_EXEC manage.py check; then
|
||||
echo ""
|
||||
echo "❌ ERROR: Django system check failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ All checks passed! Proceeding with push..."
|
||||
echo "========================================"
|
||||
exit 0
|
||||
Reference in new issue
Block a user