Merge branch 'main' into narl.io

This commit is contained in:
nvrl committed 2026-02-19 17:00:27 +01:00
commit e05e1b272c
20 files changed
+578 -179

No files matched your search

+115 -53
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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):
+4
View File
@@ -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
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+21
View File
@@ -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;"
+56
View File
@@ -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
View File
@@ -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",
+48 -54
View File
@@ -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' }}>
+11 -10
View File
@@ -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={{
+14 -25
View File
@@ -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={{
+37
View File
@@ -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 "========================================"
+18
View File
@@ -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'."
+58
View File
@@ -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