mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
refactor
This commit is contained in:
1 parent
5e3474dd59
commit
e4d59a13ba
119 files changed
+15490
-9975
No files matched your search
+12
-1
@@ -2,15 +2,26 @@
|
|||||||
# Your primary domain (e.g., example.com). Used to derive ALLOWED_HOSTS, CORS, and CSRF settings.
|
# Your primary domain (e.g., example.com). Used to derive ALLOWED_HOSTS, CORS, and CSRF settings.
|
||||||
DOMAIN="localhost"
|
DOMAIN="localhost"
|
||||||
|
|
||||||
# Set to a long, random string in production (e.g. 50+ chars)
|
# Set to a long, random string in production (e.g. 50+ chars). REQUIRED in prod.
|
||||||
SECRET_KEY="django-insecure-replace-this-in-production"
|
SECRET_KEY="django-insecure-replace-this-in-production"
|
||||||
|
|
||||||
|
# Which settings module to load. Use holy_backend.settings.dev locally,
|
||||||
|
# holy_backend.settings.prod in production (Docker compose sets this).
|
||||||
|
DJANGO_SETTINGS_MODULE=holy_backend.settings.dev
|
||||||
|
|
||||||
# Set to "false" in production for security and performance
|
# Set to "false" in production for security and performance
|
||||||
DEBUG=true
|
DEBUG=true
|
||||||
|
|
||||||
# Custom URL for the Django Admin interface (e.g., my-secret-admin/)
|
# Custom URL for the Django Admin interface (e.g., my-secret-admin/)
|
||||||
ADMIN_URL="admin"
|
ADMIN_URL="admin"
|
||||||
|
|
||||||
|
# --- Celery / Redis ---
|
||||||
|
# Broker and result backend for background jobs. Docker compose defaults to the bundled redis service.
|
||||||
|
CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
|
CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||||
|
# Run tasks synchronously (useful for local dev or CI). Unset in production.
|
||||||
|
# CELERY_TASK_ALWAYS_EAGER=true
|
||||||
|
|
||||||
# --- Advanced Networking (Optional, derived from DOMAIN if unset) ---
|
# --- Advanced Networking (Optional, derived from DOMAIN if unset) ---
|
||||||
# ALLOWED_HOSTS=example.com,another-domain.com
|
# ALLOWED_HOSTS=example.com,another-domain.com
|
||||||
# CSRF_TRUSTED_ORIGINS=https://example.com
|
# CSRF_TRUSTED_ORIGINS=https://example.com
|
||||||
|
|||||||
+42
-17
@@ -8,23 +8,40 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
frontend-build:
|
frontend-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '25'
|
node-version: '25'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
- name: Build Frontend
|
- name: Typecheck
|
||||||
|
run: npm run typecheck
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Format Check
|
||||||
|
run: npm run format:check
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: npm test -- --run
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
@@ -32,6 +49,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
env:
|
||||||
|
SECRET_KEY: "ci-test-key"
|
||||||
|
DEBUG: "false"
|
||||||
|
ALLOWED_HOSTS: "localhost"
|
||||||
|
DJANGO_SETTINGS_MODULE: holy_backend.settings.dev
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -40,24 +62,27 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: '3.14.3'
|
python-version: '3.14.3'
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
cache-dependency-path: backend/requirements.txt
|
cache-dependency-path: backend/requirements-dev.txt
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: pip install -r requirements.txt
|
run: pip install -r requirements-dev.txt
|
||||||
|
working-directory: backend
|
||||||
|
|
||||||
|
- name: Ruff Lint
|
||||||
|
run: ruff check .
|
||||||
|
working-directory: backend
|
||||||
|
|
||||||
|
- name: Ruff Format Check
|
||||||
|
run: ruff format --check .
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
|
|
||||||
- name: Django System Check
|
- name: Django System Check
|
||||||
run: python manage.py check
|
run: python manage.py check
|
||||||
working-directory: backend
|
|
||||||
env:
|
|
||||||
SECRET_KEY: "ci-test-key"
|
|
||||||
DEBUG: "false"
|
|
||||||
ALLOWED_HOSTS: "localhost"
|
|
||||||
|
|
||||||
- name: Check Migrations
|
- name: Check Migrations
|
||||||
run: python manage.py makemigrations --check --dry-run
|
run: python manage.py makemigrations --check --dry-run
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
env:
|
|
||||||
SECRET_KEY: "ci-test-key"
|
- name: Run Tests
|
||||||
DEBUG: "false"
|
run: pytest
|
||||||
ALLOWED_HOSTS: "localhost"
|
working-directory: backend
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth.admin import UserAdmin
|
from django.contrib.auth.admin import UserAdmin
|
||||||
from .models import User, Category, Flavor, Rating, Banner
|
|
||||||
|
from .models import Banner, Category, Flavor, Rating, User
|
||||||
|
|
||||||
|
|
||||||
class CustomUserAdmin(UserAdmin):
|
class CustomUserAdmin(UserAdmin):
|
||||||
fieldsets = UserAdmin.fieldsets + (
|
fieldsets = UserAdmin.fieldsets + ((None, {"fields": ("theme",)}),)
|
||||||
(None, {'fields': ('theme',)}),
|
add_fieldsets = UserAdmin.add_fieldsets + ((None, {"fields": ("theme",)}),)
|
||||||
)
|
|
||||||
add_fieldsets = UserAdmin.add_fieldsets + (
|
|
||||||
(None, {'fields': ('theme',)}),
|
|
||||||
)
|
|
||||||
|
|
||||||
admin.site.register(User, CustomUserAdmin)
|
admin.site.register(User, CustomUserAdmin)
|
||||||
admin.site.register(Category)
|
admin.site.register(Category)
|
||||||
|
|||||||
+1
-1
@@ -2,4 +2,4 @@ from django.apps import AppConfig
|
|||||||
|
|
||||||
|
|
||||||
class ApiConfig(AppConfig):
|
class ApiConfig(AppConfig):
|
||||||
name = 'api'
|
name = "api"
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import ssl
|
import ssl
|
||||||
from django.core.mail.backends.smtp import EmailBackend
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.mail.backends.smtp import EmailBackend
|
||||||
|
|
||||||
|
|
||||||
class InsecureEmailBackend(EmailBackend):
|
class InsecureEmailBackend(EmailBackend):
|
||||||
"""
|
"""
|
||||||
@@ -8,9 +10,10 @@ class InsecureEmailBackend(EmailBackend):
|
|||||||
Useful for local development or internal SMTP servers with self-signed certificates.
|
Useful for local development or internal SMTP servers with self-signed certificates.
|
||||||
Set EMAIL_SKIP_CERT_VERIFICATION = True in settings to use.
|
Set EMAIL_SKIP_CERT_VERIFICATION = True in settings to use.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ssl_context(self):
|
def ssl_context(self):
|
||||||
if getattr(settings, 'EMAIL_SKIP_CERT_VERIFICATION', False):
|
if getattr(settings, "EMAIL_SKIP_CERT_VERIFICATION", False):
|
||||||
# The most robust way to bypass verification for self-signed certs
|
# The most robust way to bypass verification for self-signed certs
|
||||||
context = ssl._create_unverified_context()
|
context = ssl._create_unverified_context()
|
||||||
return context
|
return context
|
||||||
|
|||||||
@@ -1,38 +1,43 @@
|
|||||||
import sqlite3
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import sqlite3
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'Creates a safe, consistent backup of the SQLite database and manages rotation.'
|
help = "Creates a safe, consistent backup of the SQLite database and manages rotation."
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument('--output', type=str, help='Specific output path for the backup file')
|
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')
|
parser.add_argument(
|
||||||
parser.add_argument('--full', action='store_true', help='Create a full archive including media')
|
"--retention", type=int, default=7, help="Number of days to keep old backups"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--full", action="store_true", help="Create a full archive including media"
|
||||||
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
db_conf = settings.DATABASES['default']
|
db_conf = settings.DATABASES["default"]
|
||||||
if db_conf['ENGINE'] != 'django.db.backends.sqlite3':
|
if db_conf["ENGINE"] != "django.db.backends.sqlite3":
|
||||||
self.stdout.write(self.style.ERROR("This command only supports SQLite."))
|
self.stdout.write(self.style.ERROR("This command only supports SQLite."))
|
||||||
return
|
return
|
||||||
|
|
||||||
db_path = str(db_conf['NAME'])
|
db_path = str(db_conf["NAME"])
|
||||||
backup_dir = os.path.join(settings.BASE_DIR, 'backups')
|
backup_dir = os.path.join(settings.BASE_DIR, "backups")
|
||||||
os.makedirs(backup_dir, exist_ok=True)
|
os.makedirs(backup_dir, exist_ok=True)
|
||||||
|
|
||||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
# 1. Database Backup
|
# 1. Database Backup
|
||||||
if options['output']:
|
if options["output"]:
|
||||||
db_backup_path = options['output']
|
db_backup_path = options["output"]
|
||||||
else:
|
else:
|
||||||
db_backup_path = os.path.join(backup_dir, f'db_backup_{timestamp}.sqlite3')
|
db_backup_path = os.path.join(backup_dir, f"db_backup_{timestamp}.sqlite3")
|
||||||
|
|
||||||
self.stdout.write(f"Cloning database {db_path}...")
|
self.stdout.write(f"Cloning database {db_path}...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
src = sqlite3.connect(db_path)
|
src = sqlite3.connect(db_path)
|
||||||
dst = sqlite3.connect(db_backup_path)
|
dst = sqlite3.connect(db_backup_path)
|
||||||
@@ -46,12 +51,13 @@ class Command(BaseCommand):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# 2. Full Archive (Optional)
|
# 2. Full Archive (Optional)
|
||||||
if options['full']:
|
if options["full"]:
|
||||||
import tarfile
|
import tarfile
|
||||||
|
|
||||||
archive_name = f"full_backup_{timestamp}.tar.gz"
|
archive_name = f"full_backup_{timestamp}.tar.gz"
|
||||||
archive_path = os.path.join(backup_dir, archive_name)
|
archive_path = os.path.join(backup_dir, archive_name)
|
||||||
self.stdout.write(f"Creating full archive at {archive_path}...")
|
self.stdout.write(f"Creating full archive at {archive_path}...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with tarfile.open(archive_path, "w:gz") as tar:
|
with tarfile.open(archive_path, "w:gz") as tar:
|
||||||
# Add database
|
# Add database
|
||||||
@@ -60,19 +66,19 @@ class Command(BaseCommand):
|
|||||||
media_dir = str(settings.MEDIA_ROOT)
|
media_dir = str(settings.MEDIA_ROOT)
|
||||||
if os.path.exists(media_dir):
|
if os.path.exists(media_dir):
|
||||||
tar.add(media_dir, arcname="media")
|
tar.add(media_dir, arcname="media")
|
||||||
|
|
||||||
# Remove the raw sqlite backup after bundling it into the archive
|
# Remove the raw sqlite backup after bundling it into the archive
|
||||||
if not options['output']:
|
if not options["output"]:
|
||||||
os.remove(db_backup_path)
|
os.remove(db_backup_path)
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Full archive created successfully."))
|
self.stdout.write(self.style.SUCCESS("Full archive created successfully."))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.stdout.write(self.style.ERROR(f"Archive creation failed: {e}"))
|
self.stdout.write(self.style.ERROR(f"Archive creation failed: {e}"))
|
||||||
|
|
||||||
# 3. Cleanup Old Backups
|
# 3. Cleanup Old Backups
|
||||||
retention_days = options['retention']
|
retention_days = options["retention"]
|
||||||
cutoff = datetime.now() - timedelta(days=retention_days)
|
cutoff = datetime.now() - timedelta(days=retention_days)
|
||||||
|
|
||||||
self.stdout.write(f"Cleaning up backups older than {retention_days} days...")
|
self.stdout.write(f"Cleaning up backups older than {retention_days} days...")
|
||||||
for filename in os.listdir(backup_dir):
|
for filename in os.listdir(backup_dir):
|
||||||
file_path = os.path.join(backup_dir, filename)
|
file_path = os.path.join(backup_dir, filename)
|
||||||
@@ -81,6 +87,10 @@ class Command(BaseCommand):
|
|||||||
if file_time < cutoff:
|
if file_time < cutoff:
|
||||||
try:
|
try:
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
self.stdout.write(self.style.SUCCESS(f" -> Deleted old backup: {filename}"))
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f" -> Deleted old backup: {filename}")
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.stdout.write(self.style.WARNING(f" -> Failed to delete {filename}: {e}"))
|
self.stdout.write(
|
||||||
|
self.style.WARNING(f" -> Failed to delete {filename}: {e}")
|
||||||
|
)
|
||||||
@@ -1,34 +1,39 @@
|
|||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from api.models import Flavor, Rating
|
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
|
|
||||||
|
from api.models import Flavor, Rating
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'Identifies and merges duplicate flavors (same name and category)'
|
help = "Identifies and merges duplicate flavors (same name and category)"
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
# Find duplicates based on name and category
|
# Find duplicates based on name and category
|
||||||
duplicates = Flavor.objects.values('name', 'category').annotate(
|
duplicates = (
|
||||||
name_count=Count('id')
|
Flavor.objects.values("name", "category")
|
||||||
).filter(name_count__gt=1)
|
.annotate(name_count=Count("id"))
|
||||||
|
.filter(name_count__gt=1)
|
||||||
|
)
|
||||||
|
|
||||||
self.stdout.write(f"Found {duplicates.count()} sets of duplicates.")
|
self.stdout.write(f"Found {duplicates.count()} sets of duplicates.")
|
||||||
|
|
||||||
for dup in duplicates:
|
for dup in duplicates:
|
||||||
name = dup['name']
|
name = dup["name"]
|
||||||
category_id = dup['category']
|
category_id = dup["category"]
|
||||||
|
|
||||||
# Get all flavors in this set, ordered by having an external_id first, then by date
|
# Get all flavors in this set, ordered by having an external_id first, then by date
|
||||||
flavor_set = Flavor.objects.filter(
|
flavor_set = Flavor.objects.filter(name=name, category_id=category_id).order_by(
|
||||||
name=name,
|
"-external_id", "created_at"
|
||||||
category_id=category_id
|
)
|
||||||
).order_by('-external_id', 'created_at')
|
|
||||||
|
|
||||||
# Keep the first one, merge others into it
|
# Keep the first one, merge others into it
|
||||||
keep = flavor_set[0]
|
keep = flavor_set[0]
|
||||||
others = flavor_set[1:]
|
others = flavor_set[1:]
|
||||||
|
|
||||||
self.stdout.write(f"Merging {len(others)} duplicates into '{keep.name}' (ID: {keep.id}, Ext: {keep.external_id})")
|
self.stdout.write(
|
||||||
|
f"Merging {len(others)} duplicates into '{keep.name}' (ID: {keep.id}, Ext: {keep.external_id})"
|
||||||
|
)
|
||||||
|
|
||||||
for other in others:
|
for other in others:
|
||||||
# Re-assign ratings
|
# Re-assign ratings
|
||||||
for rating in Rating.objects.filter(flavor=other):
|
for rating in Rating.objects.filter(flavor=other):
|
||||||
@@ -39,7 +44,7 @@ class Command(BaseCommand):
|
|||||||
# User already has a rating for the 'keep' flavor
|
# User already has a rating for the 'keep' flavor
|
||||||
# Delete the duplicate rating instead
|
# Delete the duplicate rating instead
|
||||||
rating.delete()
|
rating.delete()
|
||||||
|
|
||||||
# Delete the duplicate flavor
|
# Delete the duplicate flavor
|
||||||
other.delete()
|
other.delete()
|
||||||
|
|
||||||
|
|||||||
@@ -1,64 +1,75 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from api.models import Banner
|
|
||||||
import os
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from api.models import Banner
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'Seeds banner models and missing settings from JSON files in backend/banners/'
|
help = "Seeds banner models and missing settings from JSON files in backend/banners/"
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
# Determine the banners directory relative to the project root
|
# Determine the banners directory relative to the project root
|
||||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
||||||
banners_dir = os.path.join(project_root, 'banners')
|
banners_dir = os.path.join(project_root, "banners")
|
||||||
|
|
||||||
if not os.path.exists(banners_dir):
|
if not os.path.exists(banners_dir):
|
||||||
self.stdout.write(self.style.WARNING(f"Banners directory not found at {banners_dir}"))
|
self.stdout.write(self.style.WARNING(f"Banners directory not found at {banners_dir}"))
|
||||||
return
|
return
|
||||||
|
|
||||||
for filename in os.listdir(banners_dir):
|
for filename in os.listdir(banners_dir):
|
||||||
if filename.endswith('.json'):
|
if filename.endswith(".json"):
|
||||||
filepath = os.path.join(banners_dir, filename)
|
filepath = os.path.join(banners_dir, filename)
|
||||||
try:
|
try:
|
||||||
with open(filepath, 'r') as f:
|
with open(filepath) as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
|
|
||||||
slug = data.get('slug')
|
slug = data.get("slug")
|
||||||
if not slug:
|
if not slug:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
banner, created = Banner.objects.get_or_create(
|
banner, created = Banner.objects.get_or_create(
|
||||||
slug=slug,
|
slug=slug,
|
||||||
defaults={
|
defaults={
|
||||||
'name': data.get('name', slug),
|
"name": data.get("name", slug),
|
||||||
'description': data.get('description', ''),
|
"description": data.get("description", ""),
|
||||||
'settings': data.get('settings', {})
|
"settings": data.get("settings", {}),
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if not created:
|
if not created:
|
||||||
# Sync missing settings from JSON to DB without overwriting existing keys
|
# Sync missing settings from JSON to DB without overwriting existing keys
|
||||||
db_settings = banner.settings or {}
|
db_settings = banner.settings or {}
|
||||||
json_settings = data.get('settings', {})
|
json_settings = data.get("settings", {})
|
||||||
json_schema = data.get('schema', [])
|
json_schema = data.get("schema", [])
|
||||||
|
|
||||||
updated = False
|
updated = False
|
||||||
# Sync missing settings
|
# Sync missing settings
|
||||||
for key, value in json_settings.items():
|
for key, value in json_settings.items():
|
||||||
if key not in db_settings:
|
if key not in db_settings:
|
||||||
db_settings[key] = value
|
db_settings[key] = value
|
||||||
updated = True
|
updated = True
|
||||||
|
|
||||||
# Always sync schema as it represents the UI structure
|
# Always sync schema as it represents the UI structure
|
||||||
if banner.schema != json_schema:
|
if banner.schema != json_schema:
|
||||||
banner.schema = json_schema
|
banner.schema = json_schema
|
||||||
updated = True
|
updated = True
|
||||||
|
|
||||||
if updated:
|
if updated:
|
||||||
banner.settings = db_settings
|
banner.settings = db_settings
|
||||||
banner.save()
|
banner.save()
|
||||||
self.stdout.write(self.style.SUCCESS(f"Synced new settings for banner '{slug}' from {filename}"))
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"Synced new settings for banner '{slug}' from {filename}"
|
||||||
|
)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.stdout.write(self.style.SUCCESS(f"Created new banner '{slug}' from {filename}"))
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f"Created new banner '{slug}' from {filename}")
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.stdout.write(self.style.ERROR(f"Failed to sync banner from {filename}: {e}"))
|
self.stdout.write(
|
||||||
|
self.style.ERROR(f"Failed to sync banner from {filename}: {e}")
|
||||||
|
)
|
||||||
@@ -1,41 +1,45 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.core.files import File
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
|
||||||
from api.models import Category, Flavor
|
from api.models import Category, Flavor
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'Adds legacy flavors from JSON files in the legacy/ folder'
|
help = "Adds legacy flavors from JSON files in the legacy/ folder"
|
||||||
|
|
||||||
def download_image(self, url, flavor_name):
|
def download_image(self, url, flavor_name):
|
||||||
if not url:
|
if not url:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
safe_name = slugify(flavor_name)
|
safe_name = slugify(flavor_name)
|
||||||
ext = url.split('.')[-1].split('?')[0].lower()
|
ext = url.split(".")[-1].split("?")[0].lower()
|
||||||
if ext not in ['jpg', 'jpeg', 'png', 'webp', 'gif']:
|
if ext not in ["jpg", "jpeg", "png", "webp", "gif"]:
|
||||||
ext = 'png'
|
ext = "png"
|
||||||
|
|
||||||
filename = f"legacy_{safe_name}.{ext}"
|
filename = f"legacy_{safe_name}.{ext}"
|
||||||
filepath = os.path.join(settings.MEDIA_ROOT, 'flavors', filename)
|
filepath = os.path.join(settings.MEDIA_ROOT, "flavors", filename)
|
||||||
|
|
||||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||||
|
|
||||||
response = requests.get(url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=10)
|
response = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
if os.path.exists(filepath):
|
if os.path.exists(filepath):
|
||||||
os.remove(filepath)
|
os.remove(filepath)
|
||||||
|
|
||||||
with open(filepath, 'wb') as f:
|
with open(filepath, "wb") as f:
|
||||||
f.write(response.content)
|
f.write(response.content)
|
||||||
|
|
||||||
return f"flavors/{filename}"
|
return f"flavors/{filename}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.stdout.write(self.style.WARNING(f"Failed to download image for {flavor_name}: {e}"))
|
self.stdout.write(
|
||||||
|
self.style.WARNING(f"Failed to download image for {flavor_name}: {e}")
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
@@ -43,88 +47,103 @@ class Command(BaseCommand):
|
|||||||
# Instead, we update existing entries.
|
# Instead, we update existing entries.
|
||||||
|
|
||||||
cat_map = {
|
cat_map = {
|
||||||
'energy': Category.objects.get_or_create(name='Energy', defaults={'slug': 'energy'})[0],
|
"energy": Category.objects.get_or_create(name="Energy", defaults={"slug": "energy"})[0],
|
||||||
'hydration': Category.objects.get_or_create(name='Hydration', defaults={'slug': 'hydration'})[0],
|
"hydration": Category.objects.get_or_create(
|
||||||
'iced-tea': Category.objects.get_or_create(name='Iced Tea', defaults={'slug': 'iced-tea'})[0],
|
name="Hydration", defaults={"slug": "hydration"}
|
||||||
'milkshake': Category.objects.get_or_create(name='Milkshake', defaults={'slug': 'milkshake'})[0],
|
)[0],
|
||||||
'packs': Category.objects.get_or_create(name='Packs and other', defaults={'slug': 'packs-and-other'})[0],
|
"iced-tea": Category.objects.get_or_create(
|
||||||
|
name="Iced Tea", defaults={"slug": "iced-tea"}
|
||||||
|
)[0],
|
||||||
|
"milkshake": Category.objects.get_or_create(
|
||||||
|
name="Milkshake", defaults={"slug": "milkshake"}
|
||||||
|
)[0],
|
||||||
|
"packs": Category.objects.get_or_create(
|
||||||
|
name="Packs and other", defaults={"slug": "packs-and-other"}
|
||||||
|
)[0],
|
||||||
}
|
}
|
||||||
|
|
||||||
legacy_dir = os.path.join(settings.BASE_DIR, 'legacy')
|
legacy_dir = os.path.join(settings.BASE_DIR, "legacy")
|
||||||
if not os.path.exists(legacy_dir):
|
if not os.path.exists(legacy_dir):
|
||||||
legacy_dir = os.path.join(settings.BASE_DIR, '..', 'legacy')
|
legacy_dir = os.path.join(settings.BASE_DIR, "..", "legacy")
|
||||||
|
|
||||||
if not os.path.exists(legacy_dir):
|
if not os.path.exists(legacy_dir):
|
||||||
self.stdout.write(self.style.ERROR(f"Legacy directory not found."))
|
self.stdout.write(self.style.ERROR("Legacy directory not found."))
|
||||||
return
|
return
|
||||||
|
|
||||||
json_files = [f for f in os.listdir(legacy_dir) if f.endswith('.json')]
|
json_files = [f for f in os.listdir(legacy_dir) if f.endswith(".json")]
|
||||||
processed_count = 0
|
processed_count = 0
|
||||||
|
|
||||||
for json_file in json_files:
|
for json_file in json_files:
|
||||||
file_path = os.path.join(legacy_dir, json_file)
|
file_path = os.path.join(legacy_dir, json_file)
|
||||||
file_lower = json_file.lower()
|
file_lower = json_file.lower()
|
||||||
|
|
||||||
# Determine category from filename
|
# Determine category from filename
|
||||||
if 'energy' in file_lower:
|
if "energy" in file_lower:
|
||||||
file_cat = cat_map['energy']
|
file_cat = cat_map["energy"]
|
||||||
elif 'iced_tea' in file_lower or 'iced-tea' in file_lower:
|
elif "iced_tea" in file_lower or "iced-tea" in file_lower:
|
||||||
file_cat = cat_map['iced-tea']
|
file_cat = cat_map["iced-tea"]
|
||||||
elif 'hydration' in file_lower:
|
elif "hydration" in file_lower:
|
||||||
file_cat = cat_map['hydration']
|
file_cat = cat_map["hydration"]
|
||||||
elif 'milkshake' in file_lower:
|
elif "milkshake" in file_lower:
|
||||||
file_cat = cat_map['milkshake']
|
file_cat = cat_map["milkshake"]
|
||||||
else:
|
else:
|
||||||
file_cat = None
|
file_cat = None
|
||||||
|
|
||||||
with open(file_path, 'r', encoding='utf-8') as f:
|
with open(file_path, encoding="utf-8") as f:
|
||||||
data_list = json.load(f)
|
data_list = json.load(f)
|
||||||
|
|
||||||
for data in data_list:
|
for data in data_list:
|
||||||
name = data.get('Name')
|
name = data.get("Name")
|
||||||
desc = data.get('Beschreibung')
|
desc = data.get("Beschreibung")
|
||||||
taste = data.get('Geschmack')
|
taste = data.get("Geschmack")
|
||||||
status_text = data.get('Status')
|
status_text = data.get("Status")
|
||||||
image_url = data.get('Bild_URL')
|
image_url = data.get("Bild_URL")
|
||||||
|
|
||||||
full_desc = f"**Geschmack:** {taste}\n\n{desc}\n\n**Status:** {status_text}"
|
full_desc = f"**Geschmack:** {taste}\n\n{desc}\n\n**Status:** {status_text}"
|
||||||
|
|
||||||
# Determine final category
|
# Determine final category
|
||||||
cat = file_cat
|
cat = file_cat
|
||||||
if not cat:
|
if not cat:
|
||||||
# Fallback to name/desc detection if filename was ambiguous
|
# Fallback to name/desc detection if filename was ambiguous
|
||||||
if "hydration" in name.lower() or "hydration" in desc.lower():
|
if "hydration" in name.lower() or "hydration" in desc.lower():
|
||||||
cat = cat_map['hydration']
|
cat = cat_map["hydration"]
|
||||||
elif "iced tea" in name.lower() or "eistee" in name.lower():
|
elif "iced tea" in name.lower() or "eistee" in name.lower():
|
||||||
cat = cat_map['iced-tea']
|
cat = cat_map["iced-tea"]
|
||||||
elif "milkshake" in name.lower():
|
elif "milkshake" in name.lower():
|
||||||
cat = cat_map['milkshake']
|
cat = cat_map["milkshake"]
|
||||||
elif any(k in name.lower() for k in ['bundle', 'set', 'box', 'probe', 'sample', 'taster', 'shaker', 'starter']):
|
elif any(
|
||||||
cat = cat_map['packs']
|
k in name.lower()
|
||||||
|
for k in [
|
||||||
|
"bundle",
|
||||||
|
"set",
|
||||||
|
"box",
|
||||||
|
"probe",
|
||||||
|
"sample",
|
||||||
|
"taster",
|
||||||
|
"shaker",
|
||||||
|
"starter",
|
||||||
|
]
|
||||||
|
):
|
||||||
|
cat = cat_map["packs"]
|
||||||
else:
|
else:
|
||||||
cat = cat_map['energy'] # Ultimate fallback
|
cat = cat_map["energy"] # Ultimate fallback
|
||||||
|
|
||||||
# Try to find existing flavor by name and category regardless of legacy status
|
# Try to find existing flavor by name and category regardless of legacy status
|
||||||
flavor = Flavor.objects.filter(name__iexact=name, category=cat).first()
|
flavor = Flavor.objects.filter(name__iexact=name, category=cat).first()
|
||||||
|
|
||||||
# If it exists and is an active Shopify product, skip legacy processing
|
# If it exists and is an active Shopify product, skip legacy processing
|
||||||
if flavor and not flavor.is_legacy:
|
if flavor and not flavor.is_legacy:
|
||||||
self.stdout.write(f"Skipping active flavor: {name}")
|
self.stdout.write(f"Skipping active flavor: {name}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not flavor:
|
if not flavor:
|
||||||
flavor = Flavor(
|
flavor = Flavor(name=name, category=cat, is_legacy=True, external_id=None)
|
||||||
name=name,
|
|
||||||
category=cat,
|
|
||||||
is_legacy=True,
|
|
||||||
external_id=None
|
|
||||||
)
|
|
||||||
self.stdout.write(f"Adding new legacy flavor: {name}")
|
self.stdout.write(f"Adding new legacy flavor: {name}")
|
||||||
else:
|
else:
|
||||||
self.stdout.write(f"Updating legacy flavor: {name}")
|
self.stdout.write(f"Updating legacy flavor: {name}")
|
||||||
|
|
||||||
flavor.description = full_desc
|
flavor.description = full_desc
|
||||||
flavor.is_available = False # Legacy is always unavailable via API
|
flavor.is_available = False # Legacy is always unavailable via API
|
||||||
|
|
||||||
if image_url:
|
if image_url:
|
||||||
# Check if local file exists
|
# Check if local file exists
|
||||||
@@ -140,9 +159,11 @@ class Command(BaseCommand):
|
|||||||
if rel_path:
|
if rel_path:
|
||||||
flavor.image = rel_path
|
flavor.image = rel_path
|
||||||
self.stdout.write(f" -> Updated image for: {name}")
|
self.stdout.write(f" -> Updated image for: {name}")
|
||||||
|
|
||||||
flavor.image_url = image_url
|
flavor.image_url = image_url
|
||||||
flavor.save()
|
flavor.save()
|
||||||
processed_count += 1
|
processed_count += 1
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Finished! Processed {processed_count} legacy flavors."))
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f"Finished! Processed {processed_count} legacy flavors.")
|
||||||
|
)
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
import json
|
|
||||||
import requests
|
|
||||||
import re
|
|
||||||
import os
|
import os
|
||||||
from django.core.management.base import BaseCommand
|
import re
|
||||||
from django.core.files import File
|
|
||||||
|
import requests
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
|
||||||
from api.models import Category, Flavor
|
from api.models import Category, Flavor
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'Syncs flavors from Holy Energy Shopify API'
|
help = "Syncs flavors from Holy Energy Shopify API"
|
||||||
|
|
||||||
def clean_html(self, raw_html):
|
def clean_html(self, raw_html):
|
||||||
if not raw_html:
|
if not raw_html:
|
||||||
return ""
|
return ""
|
||||||
cleanr = re.compile('<.*?>')
|
cleanr = re.compile("<.*?>")
|
||||||
cleantext = re.sub(cleanr, '', raw_html)
|
cleantext = re.sub(cleanr, "", raw_html)
|
||||||
return cleantext.strip()
|
return cleantext.strip()
|
||||||
|
|
||||||
def download_image(self, url, flavor_name):
|
def download_image(self, url, flavor_name):
|
||||||
@@ -23,50 +24,60 @@ class Command(BaseCommand):
|
|||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
safe_name = slugify(flavor_name)
|
safe_name = slugify(flavor_name)
|
||||||
ext = url.split('.')[-1].split('?')[0].lower()
|
ext = url.split(".")[-1].split("?")[0].lower()
|
||||||
if ext not in ['jpg', 'jpeg', 'png', 'webp', 'gif']:
|
if ext not in ["jpg", "jpeg", "png", "webp", "gif"]:
|
||||||
ext = 'png'
|
ext = "png"
|
||||||
|
|
||||||
filename = f"{safe_name}.{ext}"
|
filename = f"{safe_name}.{ext}"
|
||||||
filepath = os.path.join(settings.MEDIA_ROOT, 'flavors', filename)
|
filepath = os.path.join(settings.MEDIA_ROOT, "flavors", filename)
|
||||||
|
|
||||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||||
|
|
||||||
response = requests.get(url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=10)
|
response = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
if os.path.exists(filepath):
|
if os.path.exists(filepath):
|
||||||
os.remove(filepath)
|
os.remove(filepath)
|
||||||
|
|
||||||
with open(filepath, 'wb') as f:
|
with open(filepath, "wb") as f:
|
||||||
f.write(response.content)
|
f.write(response.content)
|
||||||
|
|
||||||
return f"flavors/{filename}"
|
return f"flavors/{filename}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.stdout.write(self.style.WARNING(f"Failed to download image for {flavor_name}: {e}"))
|
self.stdout.write(
|
||||||
|
self.style.WARNING(f"Failed to download image for {flavor_name}: {e}")
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
url = "https://weareholy.com/products.json?limit=250"
|
url = "https://weareholy.com/products.json?limit=250"
|
||||||
self.stdout.write(f"Fetching from {url}...")
|
self.stdout.write(f"Fetching from {url}...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=15)
|
response = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=15)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.stdout.write(self.style.ERROR(f"Failed to fetch data: {e}"))
|
self.stdout.write(self.style.ERROR(f"Failed to fetch data: {e}"))
|
||||||
return
|
return
|
||||||
|
|
||||||
products = data.get('products', [])
|
products = data.get("products", [])
|
||||||
self.stdout.write(f"Found {len(products)} products.")
|
self.stdout.write(f"Found {len(products)} products.")
|
||||||
|
|
||||||
cat_map = {
|
cat_map = {
|
||||||
'energy': Category.objects.get_or_create(name='Energy', defaults={'slug': 'energy'})[0],
|
"energy": Category.objects.get_or_create(name="Energy", defaults={"slug": "energy"})[0],
|
||||||
'hydration': Category.objects.get_or_create(name='Hydration', defaults={'slug': 'hydration'})[0],
|
"hydration": Category.objects.get_or_create(
|
||||||
'iced-tea': Category.objects.get_or_create(name='Iced Tea', defaults={'slug': 'iced-tea'})[0],
|
name="Hydration", defaults={"slug": "hydration"}
|
||||||
'milkshake': Category.objects.get_or_create(name='Milkshake', defaults={'slug': 'milkshake'})[0],
|
)[0],
|
||||||
'packs': Category.objects.get_or_create(name='Packs and other', defaults={'slug': 'packs-and-other'})[0],
|
"iced-tea": Category.objects.get_or_create(
|
||||||
|
name="Iced Tea", defaults={"slug": "iced-tea"}
|
||||||
|
)[0],
|
||||||
|
"milkshake": Category.objects.get_or_create(
|
||||||
|
name="Milkshake", defaults={"slug": "milkshake"}
|
||||||
|
)[0],
|
||||||
|
"packs": Category.objects.get_or_create(
|
||||||
|
name="Packs and other", defaults={"slug": "packs-and-other"}
|
||||||
|
)[0],
|
||||||
}
|
}
|
||||||
|
|
||||||
created_count = 0
|
created_count = 0
|
||||||
@@ -74,82 +85,97 @@ class Command(BaseCommand):
|
|||||||
synced_external_ids = []
|
synced_external_ids = []
|
||||||
|
|
||||||
for p in products:
|
for p in products:
|
||||||
title = p.get('title', '').strip()
|
title = p.get("title", "").strip()
|
||||||
tags = p.get('tags', [])
|
tags = p.get("tags", [])
|
||||||
if isinstance(tags, str):
|
if isinstance(tags, str):
|
||||||
tags = [t.strip() for t in tags.split(',')]
|
tags = [t.strip() for t in tags.split(",")]
|
||||||
|
|
||||||
tags_lower = [t.lower() for t in tags]
|
tags_lower = [t.lower() for t in tags]
|
||||||
title_lower = title.lower()
|
title_lower = title.lower()
|
||||||
|
|
||||||
is_pack = False
|
is_pack = False
|
||||||
pack_keywords = ['bundle', 'set', 'box', 'probe', 'sample', 'taster', 'starter', 'collection', 'probier']
|
pack_keywords = [
|
||||||
|
"bundle",
|
||||||
|
"set",
|
||||||
|
"box",
|
||||||
|
"probe",
|
||||||
|
"sample",
|
||||||
|
"taster",
|
||||||
|
"starter",
|
||||||
|
"collection",
|
||||||
|
"probier",
|
||||||
|
]
|
||||||
if any(k in title_lower for k in pack_keywords):
|
if any(k in title_lower for k in pack_keywords):
|
||||||
is_pack = True
|
is_pack = True
|
||||||
if 'shaker' in title_lower or 'merch' in tags_lower:
|
if "shaker" in title_lower or "merch" in tags_lower:
|
||||||
is_pack = True
|
is_pack = True
|
||||||
|
|
||||||
category = None
|
category = None
|
||||||
if is_pack:
|
if is_pack:
|
||||||
category = cat_map['packs']
|
category = cat_map["packs"]
|
||||||
elif 'holy energy' in tags_lower or 'energy' in tags_lower:
|
elif "holy energy" in tags_lower or "energy" in tags_lower:
|
||||||
category = cat_map['energy']
|
category = cat_map["energy"]
|
||||||
elif 'holy hydration' in tags_lower or 'hydration' in tags_lower:
|
elif "holy hydration" in tags_lower or "hydration" in tags_lower:
|
||||||
category = cat_map['hydration']
|
category = cat_map["hydration"]
|
||||||
elif 'holy iced tea' in tags_lower or 'iced tea' in tags_lower:
|
elif "holy iced tea" in tags_lower or "iced tea" in tags_lower:
|
||||||
category = cat_map['iced-tea']
|
category = cat_map["iced-tea"]
|
||||||
elif 'milkshake' in tags_lower:
|
elif "milkshake" in tags_lower:
|
||||||
category = cat_map['milkshake']
|
category = cat_map["milkshake"]
|
||||||
|
|
||||||
if not category:
|
if not category:
|
||||||
if 'shaker' in tags_lower:
|
if "shaker" in tags_lower:
|
||||||
category = cat_map['packs']
|
category = cat_map["packs"]
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
variants = p.get('variants', [])
|
variants = p.get("variants", [])
|
||||||
is_available = any(v.get('available') for v in variants)
|
is_available = any(v.get("available") for v in variants)
|
||||||
images = p.get('images', [])
|
images = p.get("images", [])
|
||||||
image_url = images[0].get('src') if images else None
|
image_url = images[0].get("src") if images else None
|
||||||
description = self.clean_html(p.get('body_html', ''))
|
description = self.clean_html(p.get("body_html", ""))
|
||||||
handle = p.get('handle')
|
handle = p.get("handle")
|
||||||
shop_url = f"https://weareholy.com/products/{handle}" if handle else None
|
shop_url = f"https://weareholy.com/products/{handle}" if handle else None
|
||||||
|
|
||||||
# Find or create - be very specific to avoid duplicates
|
# Find or create - be very specific to avoid duplicates
|
||||||
# 1. Try external_id
|
# 1. Try external_id
|
||||||
flavor = Flavor.objects.filter(external_id=p['id']).first()
|
flavor = Flavor.objects.filter(external_id=p["id"]).first()
|
||||||
|
|
||||||
if not flavor:
|
if not flavor:
|
||||||
# 2. Try exact name match in category
|
# 2. Try exact name match in category
|
||||||
# REMOVED: is_legacy=False
|
# REMOVED: is_legacy=False
|
||||||
flavor = Flavor.objects.filter(name=title, category=category).first()
|
flavor = Flavor.objects.filter(name=title, category=category).first()
|
||||||
|
|
||||||
if not flavor:
|
if not flavor:
|
||||||
# 3. Try case-insensitive name match in category (safety for whitespace/case)
|
# 3. Try case-insensitive name match in category (safety for whitespace/case)
|
||||||
# REMOVED: is_legacy=False
|
# REMOVED: is_legacy=False
|
||||||
flavor = Flavor.objects.filter(name__iexact=title, category=category).first()
|
flavor = Flavor.objects.filter(name__iexact=title, category=category).first()
|
||||||
|
|
||||||
if not flavor:
|
if not flavor:
|
||||||
flavor = Flavor(external_id=p['id'], name=title, category=category, is_legacy=False)
|
flavor = Flavor(external_id=p["id"], name=title, category=category, is_legacy=False)
|
||||||
created_count += 1
|
created_count += 1
|
||||||
else:
|
else:
|
||||||
updated_count += 1
|
updated_count += 1
|
||||||
# Ensure external_id is updated if it was missing
|
# Ensure external_id is updated if it was missing
|
||||||
if not flavor.external_id:
|
if not flavor.external_id:
|
||||||
flavor.external_id = p['id']
|
flavor.external_id = p["id"]
|
||||||
|
|
||||||
# Reactivate the flavor if it was previously marked as legacy
|
# Reactivate the flavor if it was previously marked as legacy
|
||||||
if flavor.is_legacy:
|
if flavor.is_legacy:
|
||||||
flavor.is_legacy = False
|
flavor.is_legacy = False
|
||||||
|
|
||||||
# Ensure all synced fields are accurately reflected
|
# Ensure all synced fields are accurately reflected
|
||||||
if flavor.name != title and not Flavor.objects.filter(name=title, category=category).exclude(pk=flavor.pk).exists():
|
if (
|
||||||
|
flavor.name != title
|
||||||
|
and not Flavor.objects.filter(name=title, category=category)
|
||||||
|
.exclude(pk=flavor.pk)
|
||||||
|
.exists()
|
||||||
|
):
|
||||||
flavor.name = title
|
flavor.name = title
|
||||||
|
|
||||||
flavor.description = description
|
flavor.description = description
|
||||||
flavor.shop_url = shop_url
|
flavor.shop_url = shop_url
|
||||||
flavor.is_available = is_available
|
flavor.is_available = is_available
|
||||||
|
|
||||||
if image_url:
|
if image_url:
|
||||||
# Check if local file exists
|
# Check if local file exists
|
||||||
file_exists = False
|
file_exists = False
|
||||||
@@ -165,11 +191,21 @@ class Command(BaseCommand):
|
|||||||
flavor.image = rel_path
|
flavor.image = rel_path
|
||||||
self.stdout.write(f"Downloaded image for: {title}")
|
self.stdout.write(f"Downloaded image for: {title}")
|
||||||
else:
|
else:
|
||||||
self.stdout.write(self.style.WARNING(f"Failed to ensure image for: {title}"))
|
self.stdout.write(
|
||||||
|
self.style.WARNING(f"Failed to ensure image for: {title}")
|
||||||
|
)
|
||||||
|
|
||||||
flavor.image_url = image_url
|
flavor.image_url = image_url
|
||||||
flavor.save()
|
flavor.save()
|
||||||
synced_external_ids.append(p['id'])
|
synced_external_ids.append(p["id"])
|
||||||
|
|
||||||
discontinued_count = Flavor.objects.filter(external_id__isnull=False).exclude(external_id__in=synced_external_ids).update(is_available=False)
|
discontinued_count = (
|
||||||
self.stdout.write(self.style.SUCCESS(f"Finished! Created: {created_count}, Updated: {updated_count}, Marked Unavailable: {discontinued_count}"))
|
Flavor.objects.filter(external_id__isnull=False)
|
||||||
|
.exclude(external_id__in=synced_external_ids)
|
||||||
|
.update(is_available=False)
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"Finished! Created: {created_count}, Updated: {updated_count}, Marked Unavailable: {discontinued_count}"
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import time
|
|
||||||
import sys
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.core.management import call_command
|
|
||||||
from django.utils import timezone
|
|
||||||
from django.db import models
|
|
||||||
from api.models import Job
|
|
||||||
from io import StringIO
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = 'Background worker to process scheduled and on-demand jobs.'
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
self.stdout.write(self.style.SUCCESS("Starting Holy Archive Worker..."))
|
|
||||||
|
|
||||||
# Ensure all core jobs exist in the DB
|
|
||||||
for job_type, label in Job.JOB_TYPES:
|
|
||||||
Job.objects.get_or_create(name=job_type)
|
|
||||||
|
|
||||||
while True:
|
|
||||||
# 1. Find jobs that are either explicitly set to 'pending' (manual trigger)
|
|
||||||
# or those where next_run has passed.
|
|
||||||
now = timezone.now()
|
|
||||||
jobs_to_run = Job.objects.filter(
|
|
||||||
~models.Q(status='running')
|
|
||||||
).filter(
|
|
||||||
# Run if pending OR (next_run is set AND has passed)
|
|
||||||
models.Q(status='pending') |
|
|
||||||
models.Q(next_run__lte=now, interval_hours__gt=0)
|
|
||||||
)
|
|
||||||
|
|
||||||
for job in jobs_to_run:
|
|
||||||
self.run_job(job)
|
|
||||||
|
|
||||||
time.sleep(30) # Check every 30 seconds
|
|
||||||
|
|
||||||
def run_job(self, job):
|
|
||||||
self.stdout.write(f"Executing Job: {job.get_name_display()}...")
|
|
||||||
job.status = 'running'
|
|
||||||
job.last_run = timezone.now()
|
|
||||||
job.last_output = "" # Reset output for new run
|
|
||||||
job.save()
|
|
||||||
|
|
||||||
# Capture management command output
|
|
||||||
class LiveStream(StringIO):
|
|
||||||
def write(self, s):
|
|
||||||
super().write(s)
|
|
||||||
# Periodically update the DB with current output
|
|
||||||
job.last_output = self.getvalue()
|
|
||||||
job.save(update_fields=['last_output'])
|
|
||||||
|
|
||||||
out = LiveStream()
|
|
||||||
error = ""
|
|
||||||
|
|
||||||
try:
|
|
||||||
if job.name == 'sync_flavors':
|
|
||||||
call_command('sync_flavors', stdout=out)
|
|
||||||
elif job.name == 'cleanup_duplicates':
|
|
||||||
call_command('cleanup_duplicates', stdout=out)
|
|
||||||
elif job.name == 'backup_db':
|
|
||||||
call_command('backup_db', '--full', stdout=out)
|
|
||||||
elif job.name == 'seed_legacy':
|
|
||||||
call_command('seed_legacy_flavors', stdout=out)
|
|
||||||
|
|
||||||
job.status = 'completed'
|
|
||||||
except Exception as e:
|
|
||||||
job.status = 'failed'
|
|
||||||
error = str(e)
|
|
||||||
self.stdout.write(self.style.ERROR(f"Job failed: {error}"))
|
|
||||||
|
|
||||||
# Update scheduling for next run if interval is set
|
|
||||||
if job.interval_hours > 0:
|
|
||||||
job.next_run = timezone.now() + timedelta(hours=job.interval_hours)
|
|
||||||
else:
|
|
||||||
job.next_run = None
|
|
||||||
|
|
||||||
job.last_output = out.getvalue()
|
|
||||||
job.error_message = error
|
|
||||||
job.save()
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Job finished: {job.status}"))
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.4 on 2026-04-11 00:37
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('api', '0029_alter_job_name'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='flavor',
|
||||||
|
name='image_urls',
|
||||||
|
field=models.JSONField(blank=True, default=list),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
from django.db import models
|
|
||||||
from django.contrib.auth.models import AbstractUser
|
|
||||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
|
||||||
|
|
||||||
class User(AbstractUser):
|
|
||||||
THEME_CHOICES = [
|
|
||||||
('holy_light', 'Holy Light'),
|
|
||||||
('holy_dark', 'Holy Dark'),
|
|
||||||
('latte', 'Catppuccin Latte'),
|
|
||||||
('frappe', 'Catppuccin Frappé'),
|
|
||||||
('macchiato', 'Catppuccin Macchiato'),
|
|
||||||
('mocha', 'Catppuccin Mocha'),
|
|
||||||
('pink_pastel', 'Pink Pastel'),
|
|
||||||
('mint_pastel', 'Mint Pastel'),
|
|
||||||
('lavender_pastel', 'Lavender Pastel'),
|
|
||||||
('dracula', 'Dracula'),
|
|
||||||
('nord', 'Nordic Frost'),
|
|
||||||
('gruvbox', 'Gruvbox Retro'),
|
|
||||||
('oceanic', 'Oceanic Deep'),
|
|
||||||
('t0p_sai', 'SAI (T0P)'),
|
|
||||||
('t0p_trench', 'Trench (T0P)'),
|
|
||||||
('t0p_blurryface', 'Blurryface (T0P)'),
|
|
||||||
('t0p_clancy', 'Clancy (T0P)'),
|
|
||||||
]
|
|
||||||
LANGUAGE_CHOICES = [
|
|
||||||
('en', 'English'),
|
|
||||||
('de', 'Deutsch'),
|
|
||||||
]
|
|
||||||
theme = models.CharField(max_length=20, choices=THEME_CHOICES, default='holy_light')
|
|
||||||
language = models.CharField(max_length=2, choices=LANGUAGE_CHOICES, default='en')
|
|
||||||
avatar = models.ImageField(upload_to='avatars/', blank=True, null=True)
|
|
||||||
pending_email = models.EmailField(max_length=254, blank=True, null=True)
|
|
||||||
email_confirmation_code = models.CharField(max_length=6, blank=True, null=True)
|
|
||||||
following = models.ManyToManyField('self', symmetrical=False, related_name='followers', blank=True)
|
|
||||||
selected_banner = models.ForeignKey('Banner', on_delete=models.SET_NULL, null=True, blank=True, related_name='users')
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
if self.avatar:
|
|
||||||
try:
|
|
||||||
from PIL import Image
|
|
||||||
img = Image.open(self.avatar.path)
|
|
||||||
if img.height > 256 or img.width > 256 or img.format != 'JPEG':
|
|
||||||
output_size = (256, 256)
|
|
||||||
img.thumbnail(output_size)
|
|
||||||
if img.mode in ("RGBA", "P"):
|
|
||||||
img = img.convert("RGB")
|
|
||||||
img.save(self.avatar.path, 'JPEG', quality=90)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.username
|
|
||||||
|
|
||||||
class Category(models.Model):
|
|
||||||
name = models.CharField(max_length=50, unique=True)
|
|
||||||
slug = models.SlugField(unique=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name_plural = "Categories"
|
|
||||||
ordering = ['name']
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
class Flavor(models.Model):
|
|
||||||
category = models.ForeignKey(Category, related_name='flavors', on_delete=models.CASCADE)
|
|
||||||
name = models.CharField(max_length=100) # Removed unique=True here
|
|
||||||
description = models.TextField(blank=True)
|
|
||||||
image_url = models.URLField(max_length=1000, blank=True, null=True)
|
|
||||||
image = models.ImageField(upload_to='flavors/', blank=True, null=True)
|
|
||||||
shop_url = models.URLField(max_length=500, blank=True, null=True)
|
|
||||||
is_available = models.BooleanField(default=True)
|
|
||||||
is_legacy = models.BooleanField(default=False)
|
|
||||||
external_id = models.BigIntegerField(unique=True, null=True, blank=True)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
# This allows "Erdbeere" in Iced Tea and "Erdbeere" in Milkshake
|
|
||||||
constraints = [
|
|
||||||
models.UniqueConstraint(fields=['name', 'category'], name='unique_flavor_name_per_category')
|
|
||||||
]
|
|
||||||
ordering = ['name']
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.name} ({self.category.name})"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def get_average_rating(self):
|
|
||||||
ratings = self.ratings.all()
|
|
||||||
if ratings.exists():
|
|
||||||
return sum(r.score for r in ratings) / ratings.count()
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
@property
|
|
||||||
def cached_image_url(self):
|
|
||||||
if self.image:
|
|
||||||
return self.image.url
|
|
||||||
return self.image_url
|
|
||||||
|
|
||||||
class Rating(models.Model):
|
|
||||||
user = models.ForeignKey(User, related_name='ratings', on_delete=models.CASCADE)
|
|
||||||
flavor = models.ForeignKey(Flavor, related_name='ratings', on_delete=models.CASCADE)
|
|
||||||
score = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(10)])
|
|
||||||
comment = models.TextField(blank=True, null=True)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
unique_together = ('user', 'flavor')
|
|
||||||
ordering = ['-created_at']
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.user.username} - {self.flavor.name}: {self.score}"
|
|
||||||
|
|
||||||
class Reply(models.Model):
|
|
||||||
user = models.ForeignKey(User, related_name='replies', on_delete=models.CASCADE)
|
|
||||||
rating = models.ForeignKey(Rating, related_name='replies', on_delete=models.CASCADE)
|
|
||||||
text = models.TextField()
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ['created_at']
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"Reply by {self.user.username} on {self.rating.id}"
|
|
||||||
|
|
||||||
class Notification(models.Model):
|
|
||||||
TYPE_CHOICES = [
|
|
||||||
('reply', 'Reply'),
|
|
||||||
('mention', 'Mention'),
|
|
||||||
('follow', 'Follow'),
|
|
||||||
('ticket_new', 'New Ticket'),
|
|
||||||
('ticket_reply', 'Ticket Reply'),
|
|
||||||
('profile_comment', 'Profile Comment'),
|
|
||||||
]
|
|
||||||
recipient = models.ForeignKey(User, related_name='notifications', on_delete=models.CASCADE)
|
|
||||||
actor = models.ForeignKey(User, related_name='actions', on_delete=models.CASCADE)
|
|
||||||
notification_type = models.CharField(max_length=20, choices=TYPE_CHOICES)
|
|
||||||
rating = models.ForeignKey(Rating, on_delete=models.CASCADE, null=True, blank=True)
|
|
||||||
reply = models.ForeignKey(Reply, on_delete=models.CASCADE, null=True, blank=True)
|
|
||||||
ticket = models.ForeignKey('Ticket', on_delete=models.CASCADE, null=True, blank=True)
|
|
||||||
profile_comment = models.ForeignKey('ProfileComment', on_delete=models.CASCADE, null=True, blank=True)
|
|
||||||
is_read = models.BooleanField(default=False)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ['-created_at']
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.notification_type} for {self.recipient.username}"
|
|
||||||
|
|
||||||
class UserIP(models.Model):
|
|
||||||
user = models.ForeignKey(User, related_name='ips', on_delete=models.CASCADE)
|
|
||||||
ip_address = models.GenericIPAddressField()
|
|
||||||
last_login = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
unique_together = ('user', 'ip_address')
|
|
||||||
ordering = ['-last_login']
|
|
||||||
|
|
||||||
class Ticket(models.Model):
|
|
||||||
STATUS_CHOICES = [
|
|
||||||
('open', 'Open'),
|
|
||||||
('in_progress', 'In Progress'),
|
|
||||||
('resolved', 'Resolved'),
|
|
||||||
('closed', 'Closed'),
|
|
||||||
]
|
|
||||||
user = models.ForeignKey(User, related_name='tickets', on_delete=models.CASCADE)
|
|
||||||
subject = models.CharField(max_length=200)
|
|
||||||
description = models.TextField()
|
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='open')
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ['-updated_at']
|
|
||||||
|
|
||||||
class TicketMessage(models.Model):
|
|
||||||
ticket = models.ForeignKey(Ticket, related_name='messages', on_delete=models.CASCADE)
|
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
|
||||||
text = models.TextField()
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ['created_at']
|
|
||||||
|
|
||||||
class ProfileComment(models.Model):
|
|
||||||
profile_owner = models.ForeignKey(User, related_name='profile_comments', on_delete=models.CASCADE)
|
|
||||||
author = models.ForeignKey(User, related_name='authored_profile_comments', on_delete=models.CASCADE)
|
|
||||||
text = models.TextField()
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ['-created_at']
|
|
||||||
|
|
||||||
class Banner(models.Model):
|
|
||||||
name = models.CharField(max_length=100)
|
|
||||||
slug = models.SlugField(unique=True)
|
|
||||||
description = models.TextField(blank=True)
|
|
||||||
is_active = models.BooleanField(default=False, help_text="Designates this as the global default banner.")
|
|
||||||
is_enabled = models.BooleanField(default=True, help_text="Allows users to select this banner.")
|
|
||||||
settings = models.JSONField(default=dict, blank=True)
|
|
||||||
schema = models.JSONField(default=list, blank=True)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
if self.is_active:
|
|
||||||
# Set all others to inactive
|
|
||||||
Banner.objects.filter(is_active=True).exclude(pk=self.pk).update(is_active=False)
|
|
||||||
elif not Banner.objects.filter(is_active=True).exclude(pk=self.pk).exists():
|
|
||||||
# If this was the only active banner, don't allow deactivating it
|
|
||||||
# unless there's another one to take its place.
|
|
||||||
self.is_active = True
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
class SystemConfig(models.Model):
|
|
||||||
"""Singleton model for application-wide settings editable via UI."""
|
|
||||||
site_name = models.CharField(max_length=100, default="Holy Flavors Archive")
|
|
||||||
maintenance_mode = models.BooleanField(default=False)
|
|
||||||
allow_new_signups = models.BooleanField(default=True)
|
|
||||||
require_email_verification = models.BooleanField(default=True)
|
|
||||||
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
self.pk = 1 # Force singleton
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_solo(cls):
|
|
||||||
obj, _ = cls.objects.get_or_create(pk=1)
|
|
||||||
return obj
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "System Configuration"
|
|
||||||
|
|
||||||
class Job(models.Model):
|
|
||||||
"""Tracks background jobs, their status and scheduling."""
|
|
||||||
JOB_TYPES = [
|
|
||||||
('sync_flavors', 'Sync Holy Flavors'),
|
|
||||||
('cleanup_duplicates', 'Cleanup Duplicate Flavors'),
|
|
||||||
('backup_db', 'Database Backup'),
|
|
||||||
('seed_legacy', 'Seed Legacy Data'),
|
|
||||||
]
|
|
||||||
STATUS_CHOICES = [
|
|
||||||
('pending', 'Pending'),
|
|
||||||
('running', 'Running'),
|
|
||||||
('completed', 'Completed'),
|
|
||||||
('failed', 'Failed'),
|
|
||||||
]
|
|
||||||
|
|
||||||
name = models.CharField(max_length=100, choices=JOB_TYPES)
|
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
|
|
||||||
last_run = models.DateTimeField(null=True, blank=True)
|
|
||||||
next_run = models.DateTimeField(null=True, blank=True)
|
|
||||||
interval_hours = models.IntegerField(default=0, help_text="Set to 0 to disable periodic run.")
|
|
||||||
|
|
||||||
last_output = models.TextField(blank=True)
|
|
||||||
error_message = models.TextField(blank=True)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.get_name_display()} ({self.status})"
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from api.models.flavor import Category, Flavor
|
||||||
|
from api.models.rating import Rating, Reply
|
||||||
|
from api.models.social import Notification, ProfileComment
|
||||||
|
from api.models.support import Ticket, TicketMessage
|
||||||
|
from api.models.system import Banner, Job, SystemConfig
|
||||||
|
from api.models.user import User, UserIP
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Banner",
|
||||||
|
"Category",
|
||||||
|
"Flavor",
|
||||||
|
"Job",
|
||||||
|
"Notification",
|
||||||
|
"ProfileComment",
|
||||||
|
"Rating",
|
||||||
|
"Reply",
|
||||||
|
"SystemConfig",
|
||||||
|
"Ticket",
|
||||||
|
"TicketMessage",
|
||||||
|
"User",
|
||||||
|
"UserIP",
|
||||||
|
]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.db.models import Avg
|
||||||
|
|
||||||
|
|
||||||
|
class Category(models.Model):
|
||||||
|
name = models.CharField(max_length=50, unique=True)
|
||||||
|
slug = models.SlugField(unique=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name_plural = "Categories"
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Flavor(models.Model):
|
||||||
|
category = models.ForeignKey(Category, related_name="flavors", on_delete=models.CASCADE)
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
image_url = models.URLField(max_length=1000, blank=True, null=True)
|
||||||
|
image_urls = models.JSONField(blank=True, default=list)
|
||||||
|
image = models.ImageField(upload_to="flavors/", blank=True, null=True)
|
||||||
|
shop_url = models.URLField(max_length=500, blank=True, null=True)
|
||||||
|
is_available = models.BooleanField(default=True)
|
||||||
|
is_legacy = models.BooleanField(default=False)
|
||||||
|
external_id = models.BigIntegerField(unique=True, null=True, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["name", "category"],
|
||||||
|
name="unique_flavor_name_per_category",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.name} ({self.category.name})"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def get_average_rating(self) -> float:
|
||||||
|
result = self.ratings.aggregate(avg=Avg("score"))["avg"]
|
||||||
|
return result or 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cached_image_url(self) -> str | None:
|
||||||
|
if self.image:
|
||||||
|
return self.image.url
|
||||||
|
return self.image_url
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Rating(models.Model):
|
||||||
|
user = models.ForeignKey("api.User", related_name="ratings", on_delete=models.CASCADE)
|
||||||
|
flavor = models.ForeignKey("api.Flavor", related_name="ratings", on_delete=models.CASCADE)
|
||||||
|
score = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(10)])
|
||||||
|
comment = models.TextField(blank=True, null=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ("user", "flavor")
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.user.username} - {self.flavor.name}: {self.score}"
|
||||||
|
|
||||||
|
|
||||||
|
class Reply(models.Model):
|
||||||
|
user = models.ForeignKey("api.User", related_name="replies", on_delete=models.CASCADE)
|
||||||
|
rating = models.ForeignKey(Rating, related_name="replies", on_delete=models.CASCADE)
|
||||||
|
text = models.TextField()
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["created_at"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Reply by {self.user.username} on {self.rating.id}"
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(models.Model):
|
||||||
|
TYPE_CHOICES = [
|
||||||
|
("reply", "Reply"),
|
||||||
|
("mention", "Mention"),
|
||||||
|
("follow", "Follow"),
|
||||||
|
("ticket_new", "New Ticket"),
|
||||||
|
("ticket_reply", "Ticket Reply"),
|
||||||
|
("profile_comment", "Profile Comment"),
|
||||||
|
]
|
||||||
|
recipient = models.ForeignKey(
|
||||||
|
"api.User", related_name="notifications", on_delete=models.CASCADE
|
||||||
|
)
|
||||||
|
actor = models.ForeignKey("api.User", related_name="actions", on_delete=models.CASCADE)
|
||||||
|
notification_type = models.CharField(max_length=20, choices=TYPE_CHOICES)
|
||||||
|
rating = models.ForeignKey("api.Rating", on_delete=models.CASCADE, null=True, blank=True)
|
||||||
|
reply = models.ForeignKey("api.Reply", on_delete=models.CASCADE, null=True, blank=True)
|
||||||
|
ticket = models.ForeignKey("api.Ticket", on_delete=models.CASCADE, null=True, blank=True)
|
||||||
|
profile_comment = models.ForeignKey(
|
||||||
|
"api.ProfileComment", on_delete=models.CASCADE, null=True, blank=True
|
||||||
|
)
|
||||||
|
is_read = models.BooleanField(default=False)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.notification_type} for {self.recipient.username}"
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileComment(models.Model):
|
||||||
|
profile_owner = models.ForeignKey(
|
||||||
|
"api.User", related_name="profile_comments", on_delete=models.CASCADE
|
||||||
|
)
|
||||||
|
author = models.ForeignKey(
|
||||||
|
"api.User", related_name="authored_profile_comments", on_delete=models.CASCADE
|
||||||
|
)
|
||||||
|
text = models.TextField()
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Ticket(models.Model):
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
("open", "Open"),
|
||||||
|
("in_progress", "In Progress"),
|
||||||
|
("resolved", "Resolved"),
|
||||||
|
("closed", "Closed"),
|
||||||
|
]
|
||||||
|
user = models.ForeignKey("api.User", related_name="tickets", on_delete=models.CASCADE)
|
||||||
|
subject = models.CharField(max_length=200)
|
||||||
|
description = models.TextField()
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="open")
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-updated_at"]
|
||||||
|
|
||||||
|
|
||||||
|
class TicketMessage(models.Model):
|
||||||
|
ticket = models.ForeignKey(Ticket, related_name="messages", on_delete=models.CASCADE)
|
||||||
|
user = models.ForeignKey("api.User", on_delete=models.CASCADE)
|
||||||
|
text = models.TextField()
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["created_at"]
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Banner(models.Model):
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
slug = models.SlugField(unique=True)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
is_active = models.BooleanField(
|
||||||
|
default=False, help_text="Designates this as the global default banner."
|
||||||
|
)
|
||||||
|
is_enabled = models.BooleanField(default=True, help_text="Allows users to select this banner.")
|
||||||
|
settings = models.JSONField(default=dict, blank=True)
|
||||||
|
schema = models.JSONField(default=list, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if self.is_active:
|
||||||
|
Banner.objects.filter(is_active=True).exclude(pk=self.pk).update(is_active=False)
|
||||||
|
elif not Banner.objects.filter(is_active=True).exclude(pk=self.pk).exists():
|
||||||
|
self.is_active = True
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class SystemConfig(models.Model):
|
||||||
|
"""Singleton model for application-wide settings editable via UI."""
|
||||||
|
|
||||||
|
site_name = models.CharField(max_length=100, default="Holy Flavors Archive")
|
||||||
|
maintenance_mode = models.BooleanField(default=False)
|
||||||
|
allow_new_signups = models.BooleanField(default=True)
|
||||||
|
require_email_verification = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "System Configuration"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
self.pk = 1
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_solo(cls) -> "SystemConfig":
|
||||||
|
obj, _ = cls.objects.get_or_create(pk=1)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
class Job(models.Model):
|
||||||
|
"""Tracks background jobs, their status and scheduling."""
|
||||||
|
|
||||||
|
JOB_TYPES = [
|
||||||
|
("sync_flavors", "Sync Holy Flavors"),
|
||||||
|
("cleanup_duplicates", "Cleanup Duplicate Flavors"),
|
||||||
|
("backup_db", "Database Backup"),
|
||||||
|
("seed_legacy", "Seed Legacy Data"),
|
||||||
|
]
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
("pending", "Pending"),
|
||||||
|
("running", "Running"),
|
||||||
|
("completed", "Completed"),
|
||||||
|
("failed", "Failed"),
|
||||||
|
]
|
||||||
|
|
||||||
|
name = models.CharField(max_length=100, choices=JOB_TYPES)
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending")
|
||||||
|
last_run = models.DateTimeField(null=True, blank=True)
|
||||||
|
next_run = models.DateTimeField(null=True, blank=True)
|
||||||
|
interval_hours = models.IntegerField(default=0, help_text="Set to 0 to disable periodic run.")
|
||||||
|
|
||||||
|
last_output = models.TextField(blank=True)
|
||||||
|
error_message = models.TextField(blank=True)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.get_name_display()} ({self.status})"
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
from django.contrib.auth.models import AbstractUser
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class User(AbstractUser):
|
||||||
|
THEME_CHOICES = [
|
||||||
|
("holy_light", "Holy Light"),
|
||||||
|
("holy_dark", "Holy Dark"),
|
||||||
|
("latte", "Catppuccin Latte"),
|
||||||
|
("frappe", "Catppuccin Frappé"),
|
||||||
|
("macchiato", "Catppuccin Macchiato"),
|
||||||
|
("mocha", "Catppuccin Mocha"),
|
||||||
|
("pink_pastel", "Pink Pastel"),
|
||||||
|
("mint_pastel", "Mint Pastel"),
|
||||||
|
("lavender_pastel", "Lavender Pastel"),
|
||||||
|
("dracula", "Dracula"),
|
||||||
|
("nord", "Nordic Frost"),
|
||||||
|
("gruvbox", "Gruvbox Retro"),
|
||||||
|
("oceanic", "Oceanic Deep"),
|
||||||
|
("t0p_sai", "SAI (T0P)"),
|
||||||
|
("t0p_trench", "Trench (T0P)"),
|
||||||
|
("t0p_blurryface", "Blurryface (T0P)"),
|
||||||
|
("t0p_clancy", "Clancy (T0P)"),
|
||||||
|
]
|
||||||
|
LANGUAGE_CHOICES = [
|
||||||
|
("en", "English"),
|
||||||
|
("de", "Deutsch"),
|
||||||
|
]
|
||||||
|
theme = models.CharField(max_length=20, choices=THEME_CHOICES, default="holy_light")
|
||||||
|
language = models.CharField(max_length=2, choices=LANGUAGE_CHOICES, default="en")
|
||||||
|
avatar = models.ImageField(upload_to="avatars/", blank=True, null=True)
|
||||||
|
pending_email = models.EmailField(max_length=254, blank=True, null=True)
|
||||||
|
email_confirmation_code = models.CharField(max_length=6, blank=True, null=True)
|
||||||
|
following = models.ManyToManyField(
|
||||||
|
"self", symmetrical=False, related_name="followers", blank=True
|
||||||
|
)
|
||||||
|
selected_banner = models.ForeignKey(
|
||||||
|
"api.Banner",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="users",
|
||||||
|
)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
if self.avatar:
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
img = Image.open(self.avatar.path)
|
||||||
|
if img.height > 256 or img.width > 256 or img.format != "JPEG":
|
||||||
|
output_size = (256, 256)
|
||||||
|
img.thumbnail(output_size)
|
||||||
|
if img.mode in ("RGBA", "P"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
img.save(self.avatar.path, "JPEG", quality=90)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.username
|
||||||
|
|
||||||
|
|
||||||
|
class UserIP(models.Model):
|
||||||
|
user = models.ForeignKey(User, related_name="ips", on_delete=models.CASCADE)
|
||||||
|
ip_address = models.GenericIPAddressField()
|
||||||
|
last_login = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ("user", "ip_address")
|
||||||
|
ordering = ["-last_login"]
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
from rest_framework import serializers
|
|
||||||
from .models import User, Flavor, Category, Rating, Reply, Notification, Ticket, TicketMessage, UserIP, Banner, SystemConfig, Job
|
|
||||||
|
|
||||||
class CategorySerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = Category
|
|
||||||
fields = ['id', 'name', 'slug']
|
|
||||||
|
|
||||||
class SystemConfigSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = SystemConfig
|
|
||||||
fields = ['site_name', 'maintenance_mode', 'allow_new_signups', 'require_email_verification', 'updated_at']
|
|
||||||
|
|
||||||
class JobSerializer(serializers.ModelSerializer):
|
|
||||||
name_display = serializers.CharField(source='get_name_display', read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Job
|
|
||||||
fields = ['id', 'name', 'name_display', 'status', 'last_run', 'next_run', 'interval_hours', 'last_output', 'error_message']
|
|
||||||
read_only_fields = ['status', 'last_run', 'next_run', 'last_output', 'error_message']
|
|
||||||
|
|
||||||
class BannerSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = Banner
|
|
||||||
fields = ['id', 'name', 'slug', 'description', 'is_active', 'is_enabled', 'settings', 'schema', 'created_at', 'updated_at']
|
|
||||||
|
|
||||||
class ReplySerializer(serializers.ModelSerializer):
|
|
||||||
user = serializers.StringRelatedField(read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Reply
|
|
||||||
fields = ['id', 'user', 'rating', 'text', 'created_at']
|
|
||||||
read_only_fields = ['rating']
|
|
||||||
|
|
||||||
class RatingSerializer(serializers.ModelSerializer):
|
|
||||||
user = serializers.StringRelatedField(read_only=True)
|
|
||||||
user_id = serializers.IntegerField(source='user.id', read_only=True)
|
|
||||||
user_avatar = serializers.SerializerMethodField()
|
|
||||||
flavor_name = serializers.CharField(source='flavor.name', read_only=True)
|
|
||||||
flavor_image = serializers.SerializerMethodField()
|
|
||||||
category_name = serializers.CharField(source='flavor.category.name', read_only=True)
|
|
||||||
category_slug = serializers.CharField(source='flavor.category.slug', read_only=True)
|
|
||||||
is_available = serializers.BooleanField(source='flavor.is_available', read_only=True)
|
|
||||||
is_legacy = serializers.BooleanField(source='flavor.is_legacy', read_only=True)
|
|
||||||
replies = ReplySerializer(many=True, read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Rating
|
|
||||||
fields = ['id', 'user', 'user_id', 'user_avatar', 'flavor', 'flavor_name', 'flavor_image', 'category_name', 'category_slug', 'is_available', 'is_legacy', 'score', 'comment', 'created_at', 'replies']
|
|
||||||
|
|
||||||
def get_user_avatar(self, obj):
|
|
||||||
if obj.user.avatar:
|
|
||||||
request = self.context.get('request')
|
|
||||||
if request:
|
|
||||||
return request.build_absolute_uri(obj.user.avatar.url)
|
|
||||||
return obj.user.avatar.url
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_flavor_image(self, obj):
|
|
||||||
if obj.flavor.image:
|
|
||||||
request = self.context.get('request')
|
|
||||||
if request:
|
|
||||||
return request.build_absolute_uri(obj.flavor.image.url)
|
|
||||||
return obj.flavor.image.url
|
|
||||||
return obj.flavor.image_url
|
|
||||||
|
|
||||||
class FlavorSerializer(serializers.ModelSerializer):
|
|
||||||
category_name = serializers.CharField(source='category.name', read_only=True)
|
|
||||||
category_slug = serializers.CharField(source='category.slug', read_only=True)
|
|
||||||
average_rating = serializers.FloatField(read_only=True)
|
|
||||||
followed_average_rating = serializers.FloatField(read_only=True)
|
|
||||||
user_rating = serializers.SerializerMethodField()
|
|
||||||
ratings = RatingSerializer(many=True, read_only=True)
|
|
||||||
image_url = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Flavor
|
|
||||||
fields = ['id', 'name', 'category', 'category_name', 'category_slug', 'description', 'average_rating', 'followed_average_rating', 'user_rating', 'ratings', 'image_url', 'image', 'is_available', 'is_legacy', 'shop_url']
|
|
||||||
extra_kwargs = {
|
|
||||||
'image': {'write_only': True}
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_image_url(self, obj):
|
|
||||||
if obj.image:
|
|
||||||
request = self.context.get('request')
|
|
||||||
if request:
|
|
||||||
return request.build_absolute_uri(obj.image.url)
|
|
||||||
return obj.image.url
|
|
||||||
return obj.image_url
|
|
||||||
|
|
||||||
def get_user_rating(self, obj):
|
|
||||||
request = self.context.get('request')
|
|
||||||
if request and request.user.is_authenticated:
|
|
||||||
try:
|
|
||||||
rating = request.user.ratings.get(flavor=obj)
|
|
||||||
return rating.score
|
|
||||||
except Rating.DoesNotExist:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
|
||||||
avatar = serializers.SerializerMethodField()
|
|
||||||
following_count = serializers.IntegerField(source='following.count', read_only=True)
|
|
||||||
followers_count = serializers.IntegerField(source='followers.count', read_only=True)
|
|
||||||
is_following = serializers.SerializerMethodField()
|
|
||||||
unread_notifications_count = serializers.SerializerMethodField()
|
|
||||||
is_superuser = serializers.BooleanField(read_only=True)
|
|
||||||
selected_banner_slug = serializers.CharField(source='selected_banner.slug', read_only=True, allow_null=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = User
|
|
||||||
fields = ['id', 'username', 'email', 'theme', 'language', 'avatar', 'following_count', 'followers_count', 'is_following', 'unread_notifications_count', 'is_superuser', 'selected_banner', 'selected_banner_slug']
|
|
||||||
|
|
||||||
def get_avatar(self, obj):
|
|
||||||
if obj.avatar:
|
|
||||||
request = self.context.get('request')
|
|
||||||
if request:
|
|
||||||
return request.build_absolute_uri(obj.avatar.url)
|
|
||||||
return obj.avatar.url
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_is_following(self, obj):
|
|
||||||
request = self.context.get('request')
|
|
||||||
if request and request.user.is_authenticated:
|
|
||||||
return request.user.following.filter(pk=obj.pk).exists()
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_unread_notifications_count(self, obj):
|
|
||||||
return obj.notifications.filter(is_read=False).count()
|
|
||||||
|
|
||||||
class UserIPSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = UserIP
|
|
||||||
fields = ['id', 'ip_address', 'last_login']
|
|
||||||
|
|
||||||
class TicketMessageSerializer(serializers.ModelSerializer):
|
|
||||||
username = serializers.CharField(source='user.username', read_only=True)
|
|
||||||
is_admin = serializers.BooleanField(source='user.is_superuser', read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = TicketMessage
|
|
||||||
fields = ['id', 'username', 'text', 'created_at', 'is_admin']
|
|
||||||
|
|
||||||
class TicketSerializer(serializers.ModelSerializer):
|
|
||||||
username = serializers.CharField(source='user.username', read_only=True)
|
|
||||||
user_email = serializers.EmailField(source='user.email', read_only=True)
|
|
||||||
user_avatar = serializers.SerializerMethodField()
|
|
||||||
messages = TicketMessageSerializer(many=True, read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Ticket
|
|
||||||
fields = ['id', 'user', 'username', 'user_email', 'user_avatar', 'subject', 'description', 'status', 'created_at', 'updated_at', 'messages']
|
|
||||||
read_only_fields = ['status', 'user']
|
|
||||||
|
|
||||||
def get_user_avatar(self, obj):
|
|
||||||
if obj.user.avatar:
|
|
||||||
request = self.context.get('request')
|
|
||||||
if request:
|
|
||||||
return request.build_absolute_uri(obj.user.avatar.url)
|
|
||||||
return obj.user.avatar.url
|
|
||||||
return None
|
|
||||||
|
|
||||||
class ProfileCommentSerializer(serializers.ModelSerializer):
|
|
||||||
author_username = serializers.CharField(source='author.username', read_only=True)
|
|
||||||
author_avatar = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
from .models import ProfileComment
|
|
||||||
model = ProfileComment
|
|
||||||
fields = ['id', 'author_username', 'author_avatar', 'text', 'created_at']
|
|
||||||
|
|
||||||
def get_author_avatar(self, obj):
|
|
||||||
if obj.author.avatar:
|
|
||||||
request = self.context.get('request')
|
|
||||||
if request:
|
|
||||||
return request.build_absolute_uri(obj.author.avatar.url)
|
|
||||||
return obj.author.avatar.url
|
|
||||||
return None
|
|
||||||
|
|
||||||
class AdminUserListSerializer(serializers.ModelSerializer):
|
|
||||||
ips = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = User
|
|
||||||
fields = ['id', 'username', 'email', 'is_active', 'is_superuser', 'date_joined', 'last_login', 'ips']
|
|
||||||
|
|
||||||
def get_ips(self, obj):
|
|
||||||
return [ip.ip_address for ip in obj.ips.all()[:3]]
|
|
||||||
|
|
||||||
class AdminUserDetailSerializer(serializers.ModelSerializer):
|
|
||||||
ips = serializers.SerializerMethodField()
|
|
||||||
ratings = RatingSerializer(many=True, read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = User
|
|
||||||
fields = ['id', 'username', 'email', 'is_active', 'is_superuser', 'date_joined', 'last_login', 'ips', 'ratings']
|
|
||||||
|
|
||||||
def get_ips(self, obj):
|
|
||||||
return [ip.ip_address for ip in obj.ips.all()]
|
|
||||||
|
|
||||||
class NotificationSerializer(serializers.ModelSerializer):
|
|
||||||
actor_username = serializers.CharField(source='actor.username', read_only=True)
|
|
||||||
actor_avatar = serializers.SerializerMethodField()
|
|
||||||
flavor_name = serializers.SerializerMethodField()
|
|
||||||
flavor_id = serializers.SerializerMethodField()
|
|
||||||
profile_owner_username = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Notification
|
|
||||||
fields = ['id', 'actor_username', 'actor_avatar', 'notification_type', 'rating', 'reply', 'ticket', 'profile_comment', 'profile_owner_username', 'is_read', 'created_at', 'flavor_name', 'flavor_id']
|
|
||||||
|
|
||||||
def get_actor_avatar(self, obj):
|
|
||||||
if obj.actor.avatar:
|
|
||||||
request = self.context.get('request')
|
|
||||||
if request:
|
|
||||||
return request.build_absolute_uri(obj.actor.avatar.url)
|
|
||||||
return obj.actor.avatar.url
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_flavor_name(self, obj):
|
|
||||||
if obj.rating:
|
|
||||||
return obj.rating.flavor.name
|
|
||||||
if obj.reply:
|
|
||||||
return obj.reply.rating.flavor.name
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_flavor_id(self, obj):
|
|
||||||
if obj.rating:
|
|
||||||
return obj.rating.flavor.id
|
|
||||||
if obj.reply:
|
|
||||||
return obj.reply.rating.flavor.id
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_profile_owner_username(self, obj):
|
|
||||||
if obj.profile_comment:
|
|
||||||
return obj.profile_comment.profile_owner.username
|
|
||||||
return None
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from api.serializers.flavor import CategorySerializer, FlavorSerializer
|
||||||
|
from api.serializers.rating import RatingSerializer, ReplySerializer
|
||||||
|
from api.serializers.social import NotificationSerializer, ProfileCommentSerializer
|
||||||
|
from api.serializers.support import TicketMessageSerializer, TicketSerializer
|
||||||
|
from api.serializers.system import (
|
||||||
|
BannerSerializer,
|
||||||
|
JobSerializer,
|
||||||
|
SystemConfigSerializer,
|
||||||
|
)
|
||||||
|
from api.serializers.user import (
|
||||||
|
AdminUserDetailSerializer,
|
||||||
|
AdminUserListSerializer,
|
||||||
|
UserIPSerializer,
|
||||||
|
UserSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AdminUserDetailSerializer",
|
||||||
|
"AdminUserListSerializer",
|
||||||
|
"BannerSerializer",
|
||||||
|
"CategorySerializer",
|
||||||
|
"FlavorSerializer",
|
||||||
|
"JobSerializer",
|
||||||
|
"NotificationSerializer",
|
||||||
|
"ProfileCommentSerializer",
|
||||||
|
"RatingSerializer",
|
||||||
|
"ReplySerializer",
|
||||||
|
"SystemConfigSerializer",
|
||||||
|
"TicketMessageSerializer",
|
||||||
|
"TicketSerializer",
|
||||||
|
"UserIPSerializer",
|
||||||
|
"UserSerializer",
|
||||||
|
]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def absolute_image_url(serializer: Any, image_field: Any) -> str | None:
|
||||||
|
if not image_field:
|
||||||
|
return None
|
||||||
|
request = serializer.context.get("request")
|
||||||
|
if request:
|
||||||
|
return request.build_absolute_uri(image_field.url)
|
||||||
|
return image_field.url
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from api.models import Category, Flavor, Rating
|
||||||
|
from api.serializers._helpers import absolute_image_url
|
||||||
|
from api.serializers.rating import RatingSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class CategorySerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Category
|
||||||
|
fields = ["id", "name", "slug"]
|
||||||
|
|
||||||
|
|
||||||
|
class FlavorSerializer(serializers.ModelSerializer):
|
||||||
|
category_name = serializers.CharField(source="category.name", read_only=True)
|
||||||
|
category_slug = serializers.CharField(source="category.slug", read_only=True)
|
||||||
|
average_rating = serializers.FloatField(read_only=True)
|
||||||
|
followed_average_rating = serializers.FloatField(read_only=True)
|
||||||
|
user_rating = serializers.SerializerMethodField()
|
||||||
|
ratings = RatingSerializer(many=True, read_only=True)
|
||||||
|
image_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Flavor
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"category",
|
||||||
|
"category_name",
|
||||||
|
"category_slug",
|
||||||
|
"description",
|
||||||
|
"average_rating",
|
||||||
|
"followed_average_rating",
|
||||||
|
"user_rating",
|
||||||
|
"ratings",
|
||||||
|
"image_url",
|
||||||
|
"image",
|
||||||
|
"is_available",
|
||||||
|
"is_legacy",
|
||||||
|
"shop_url",
|
||||||
|
]
|
||||||
|
extra_kwargs = {"image": {"write_only": True}}
|
||||||
|
|
||||||
|
def get_image_url(self, obj: Flavor) -> str | None:
|
||||||
|
if obj.image:
|
||||||
|
return absolute_image_url(self, obj.image)
|
||||||
|
return obj.image_url
|
||||||
|
|
||||||
|
def get_user_rating(self, obj: Flavor) -> int | None:
|
||||||
|
request = self.context.get("request")
|
||||||
|
if request and request.user.is_authenticated:
|
||||||
|
try:
|
||||||
|
return request.user.ratings.get(flavor=obj).score
|
||||||
|
except Rating.DoesNotExist:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from api.models import Rating, Reply
|
||||||
|
from api.serializers._helpers import absolute_image_url
|
||||||
|
|
||||||
|
|
||||||
|
class ReplySerializer(serializers.ModelSerializer):
|
||||||
|
user = serializers.StringRelatedField(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Reply
|
||||||
|
fields = ["id", "user", "rating", "text", "created_at"]
|
||||||
|
read_only_fields = ["rating"]
|
||||||
|
|
||||||
|
|
||||||
|
class RatingSerializer(serializers.ModelSerializer):
|
||||||
|
user = serializers.StringRelatedField(read_only=True)
|
||||||
|
user_id = serializers.IntegerField(source="user.id", read_only=True)
|
||||||
|
user_avatar = serializers.SerializerMethodField()
|
||||||
|
flavor_name = serializers.CharField(source="flavor.name", read_only=True)
|
||||||
|
flavor_image = serializers.SerializerMethodField()
|
||||||
|
category_name = serializers.CharField(source="flavor.category.name", read_only=True)
|
||||||
|
category_slug = serializers.CharField(source="flavor.category.slug", read_only=True)
|
||||||
|
is_available = serializers.BooleanField(source="flavor.is_available", read_only=True)
|
||||||
|
is_legacy = serializers.BooleanField(source="flavor.is_legacy", read_only=True)
|
||||||
|
replies = ReplySerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Rating
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"user",
|
||||||
|
"user_id",
|
||||||
|
"user_avatar",
|
||||||
|
"flavor",
|
||||||
|
"flavor_name",
|
||||||
|
"flavor_image",
|
||||||
|
"category_name",
|
||||||
|
"category_slug",
|
||||||
|
"is_available",
|
||||||
|
"is_legacy",
|
||||||
|
"score",
|
||||||
|
"comment",
|
||||||
|
"created_at",
|
||||||
|
"replies",
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_user_avatar(self, obj: Rating) -> str | None:
|
||||||
|
return absolute_image_url(self, obj.user.avatar)
|
||||||
|
|
||||||
|
def get_flavor_image(self, obj: Rating) -> str | None:
|
||||||
|
if obj.flavor.image:
|
||||||
|
return absolute_image_url(self, obj.flavor.image)
|
||||||
|
return obj.flavor.image_url
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from api.models import Notification, ProfileComment
|
||||||
|
from api.serializers._helpers import absolute_image_url
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileCommentSerializer(serializers.ModelSerializer):
|
||||||
|
author_username = serializers.CharField(source="author.username", read_only=True)
|
||||||
|
author_avatar = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ProfileComment
|
||||||
|
fields = ["id", "author_username", "author_avatar", "text", "created_at"]
|
||||||
|
|
||||||
|
def get_author_avatar(self, obj: ProfileComment) -> str | None:
|
||||||
|
return absolute_image_url(self, obj.author.avatar)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationSerializer(serializers.ModelSerializer):
|
||||||
|
actor_username = serializers.CharField(source="actor.username", read_only=True)
|
||||||
|
actor_avatar = serializers.SerializerMethodField()
|
||||||
|
flavor_name = serializers.SerializerMethodField()
|
||||||
|
flavor_id = serializers.SerializerMethodField()
|
||||||
|
profile_owner_username = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Notification
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"actor_username",
|
||||||
|
"actor_avatar",
|
||||||
|
"notification_type",
|
||||||
|
"rating",
|
||||||
|
"reply",
|
||||||
|
"ticket",
|
||||||
|
"profile_comment",
|
||||||
|
"profile_owner_username",
|
||||||
|
"is_read",
|
||||||
|
"created_at",
|
||||||
|
"flavor_name",
|
||||||
|
"flavor_id",
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_actor_avatar(self, obj: Notification) -> str | None:
|
||||||
|
return absolute_image_url(self, obj.actor.avatar)
|
||||||
|
|
||||||
|
def get_flavor_name(self, obj: Notification) -> str | None:
|
||||||
|
if obj.rating:
|
||||||
|
return obj.rating.flavor.name
|
||||||
|
if obj.reply:
|
||||||
|
return obj.reply.rating.flavor.name
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_flavor_id(self, obj: Notification) -> int | None:
|
||||||
|
if obj.rating:
|
||||||
|
return obj.rating.flavor.id
|
||||||
|
if obj.reply:
|
||||||
|
return obj.reply.rating.flavor.id
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_profile_owner_username(self, obj: Notification) -> str | None:
|
||||||
|
if obj.profile_comment:
|
||||||
|
return obj.profile_comment.profile_owner.username
|
||||||
|
return None
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from api.models import Ticket, TicketMessage
|
||||||
|
from api.serializers._helpers import absolute_image_url
|
||||||
|
|
||||||
|
|
||||||
|
class TicketMessageSerializer(serializers.ModelSerializer):
|
||||||
|
username = serializers.CharField(source="user.username", read_only=True)
|
||||||
|
is_admin = serializers.BooleanField(source="user.is_superuser", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = TicketMessage
|
||||||
|
fields = ["id", "username", "text", "created_at", "is_admin"]
|
||||||
|
|
||||||
|
|
||||||
|
class TicketSerializer(serializers.ModelSerializer):
|
||||||
|
username = serializers.CharField(source="user.username", read_only=True)
|
||||||
|
user_email = serializers.EmailField(source="user.email", read_only=True)
|
||||||
|
user_avatar = serializers.SerializerMethodField()
|
||||||
|
messages = TicketMessageSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Ticket
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"user",
|
||||||
|
"username",
|
||||||
|
"user_email",
|
||||||
|
"user_avatar",
|
||||||
|
"subject",
|
||||||
|
"description",
|
||||||
|
"status",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"messages",
|
||||||
|
]
|
||||||
|
read_only_fields = ["status", "user"]
|
||||||
|
|
||||||
|
def get_user_avatar(self, obj: Ticket) -> str | None:
|
||||||
|
return absolute_image_url(self, obj.user.avatar)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from api.models import Banner, Job, SystemConfig
|
||||||
|
|
||||||
|
|
||||||
|
class SystemConfigSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = SystemConfig
|
||||||
|
fields = [
|
||||||
|
"site_name",
|
||||||
|
"maintenance_mode",
|
||||||
|
"allow_new_signups",
|
||||||
|
"require_email_verification",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class JobSerializer(serializers.ModelSerializer):
|
||||||
|
name_display = serializers.CharField(source="get_name_display", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Job
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"name_display",
|
||||||
|
"status",
|
||||||
|
"last_run",
|
||||||
|
"next_run",
|
||||||
|
"interval_hours",
|
||||||
|
"last_output",
|
||||||
|
"error_message",
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
"status",
|
||||||
|
"last_run",
|
||||||
|
"next_run",
|
||||||
|
"last_output",
|
||||||
|
"error_message",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BannerSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Banner
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"slug",
|
||||||
|
"description",
|
||||||
|
"is_active",
|
||||||
|
"is_enabled",
|
||||||
|
"settings",
|
||||||
|
"schema",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from api.models import User, UserIP
|
||||||
|
from api.serializers._helpers import absolute_image_url
|
||||||
|
from api.serializers.rating import RatingSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
avatar = serializers.SerializerMethodField()
|
||||||
|
following_count = serializers.IntegerField(source="following.count", read_only=True)
|
||||||
|
followers_count = serializers.IntegerField(source="followers.count", read_only=True)
|
||||||
|
is_following = serializers.SerializerMethodField()
|
||||||
|
unread_notifications_count = serializers.SerializerMethodField()
|
||||||
|
is_superuser = serializers.BooleanField(read_only=True)
|
||||||
|
selected_banner_slug = serializers.CharField(
|
||||||
|
source="selected_banner.slug", read_only=True, allow_null=True
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"username",
|
||||||
|
"email",
|
||||||
|
"theme",
|
||||||
|
"language",
|
||||||
|
"avatar",
|
||||||
|
"following_count",
|
||||||
|
"followers_count",
|
||||||
|
"is_following",
|
||||||
|
"unread_notifications_count",
|
||||||
|
"is_superuser",
|
||||||
|
"selected_banner",
|
||||||
|
"selected_banner_slug",
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_avatar(self, obj: User) -> str | None:
|
||||||
|
return absolute_image_url(self, obj.avatar)
|
||||||
|
|
||||||
|
def get_is_following(self, obj: User) -> bool:
|
||||||
|
request = self.context.get("request")
|
||||||
|
if request and request.user.is_authenticated:
|
||||||
|
return request.user.following.filter(pk=obj.pk).exists()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_unread_notifications_count(self, obj: User) -> int:
|
||||||
|
return obj.notifications.filter(is_read=False).count()
|
||||||
|
|
||||||
|
|
||||||
|
class UserIPSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = UserIP
|
||||||
|
fields = ["id", "ip_address", "last_login"]
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserListSerializer(serializers.ModelSerializer):
|
||||||
|
ips = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"username",
|
||||||
|
"email",
|
||||||
|
"is_active",
|
||||||
|
"is_superuser",
|
||||||
|
"date_joined",
|
||||||
|
"last_login",
|
||||||
|
"ips",
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_ips(self, obj: User) -> list[str]:
|
||||||
|
return [ip.ip_address for ip in obj.ips.all()[:3]]
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserDetailSerializer(serializers.ModelSerializer):
|
||||||
|
ips = serializers.SerializerMethodField()
|
||||||
|
ratings = RatingSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"username",
|
||||||
|
"email",
|
||||||
|
"is_active",
|
||||||
|
"is_superuser",
|
||||||
|
"date_joined",
|
||||||
|
"last_login",
|
||||||
|
"ips",
|
||||||
|
"ratings",
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_ips(self, obj: User) -> list[str]:
|
||||||
|
return [ip.ip_address for ip in obj.ips.all()]
|
||||||
Whitespace-only changes.
@@ -0,0 +1,14 @@
|
|||||||
|
from rest_framework.request import Request
|
||||||
|
|
||||||
|
from api.models import User, UserIP
|
||||||
|
|
||||||
|
|
||||||
|
def log_user_ip(user: User, request: Request) -> None:
|
||||||
|
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
|
||||||
|
if x_forwarded_for:
|
||||||
|
ip = x_forwarded_for.split(",")[0].strip()
|
||||||
|
else:
|
||||||
|
ip = request.META.get("REMOTE_ADDR")
|
||||||
|
|
||||||
|
if ip and user.is_authenticated:
|
||||||
|
UserIP.objects.update_or_create(user=user, ip_address=ip)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from api.models import Notification, Rating, Reply, User
|
||||||
|
|
||||||
|
_MENTION_RE = re.compile(r"@(\w+)")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_mentions(
|
||||||
|
text: str,
|
||||||
|
actor: User,
|
||||||
|
rating: Rating | None = None,
|
||||||
|
reply: Reply | None = None,
|
||||||
|
) -> None:
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
for username in set(_MENTION_RE.findall(text)):
|
||||||
|
try:
|
||||||
|
recipient = User.objects.get(username__iexact=username)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
continue
|
||||||
|
if recipient == actor:
|
||||||
|
continue
|
||||||
|
Notification.objects.get_or_create(
|
||||||
|
recipient=recipient,
|
||||||
|
actor=actor,
|
||||||
|
notification_type="mention",
|
||||||
|
rating=rating,
|
||||||
|
reply=reply,
|
||||||
|
)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from django.db.models import Q, QuerySet
|
||||||
|
|
||||||
|
from api.models import Flavor
|
||||||
|
|
||||||
|
CATEGORY_KEYWORDS: dict[str, str] = {
|
||||||
|
"iced tea": "iced-tea",
|
||||||
|
"eistee": "iced-tea",
|
||||||
|
"energy": "energy",
|
||||||
|
"hydration": "hydration",
|
||||||
|
"milkshake": "milkshake",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def extract_category_slug(query: str) -> tuple[str | None, str]:
|
||||||
|
lowered = query.lower().strip()
|
||||||
|
for keyword, slug in CATEGORY_KEYWORDS.items():
|
||||||
|
if keyword in lowered:
|
||||||
|
return slug, lowered.replace(keyword, "").strip()
|
||||||
|
return None, lowered
|
||||||
|
|
||||||
|
|
||||||
|
def filter_flavors_by_query(queryset: QuerySet[Flavor], query: str) -> QuerySet[Flavor]:
|
||||||
|
slug, remaining = extract_category_slug(query)
|
||||||
|
if slug:
|
||||||
|
queryset = queryset.filter(category__slug=slug)
|
||||||
|
if remaining:
|
||||||
|
word_query = Q()
|
||||||
|
for word in remaining.split():
|
||||||
|
if len(word) > 2:
|
||||||
|
word_query |= Q(name__icontains=word) | Q(description__icontains=word)
|
||||||
|
if word_query:
|
||||||
|
queryset = queryset.filter(word_query)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
def score_relevance(name: str, query: str) -> int:
|
||||||
|
name_lower = name.lower()
|
||||||
|
if name_lower == query:
|
||||||
|
return 3
|
||||||
|
if name_lower.startswith(query):
|
||||||
|
return 2
|
||||||
|
if query in name_lower:
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Celery tasks for Holy Flavors.
|
||||||
|
|
||||||
|
Each @shared_task wraps an existing management command so that the same
|
||||||
|
code path remains usable from the CLI for manual ops. The legacy ``Job``
|
||||||
|
rows are kept as an execution log: tasks update the matching row with
|
||||||
|
status/output/timestamps so the admin UI keeps working.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from io import StringIO
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
from celery.utils.log import get_task_logger
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
logger = get_task_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_command_job(job_name: str, command: str, *args: str) -> str:
|
||||||
|
"""Run a management command and mirror status/output into the Job row."""
|
||||||
|
from api.models import Job
|
||||||
|
|
||||||
|
job, _ = Job.objects.get_or_create(name=job_name)
|
||||||
|
job.status = "running"
|
||||||
|
job.last_run = timezone.now()
|
||||||
|
job.last_output = ""
|
||||||
|
job.error_message = ""
|
||||||
|
job.save(update_fields=["status", "last_run", "last_output", "error_message"])
|
||||||
|
|
||||||
|
out = StringIO()
|
||||||
|
try:
|
||||||
|
call_command(command, *args, stdout=out)
|
||||||
|
job.status = "completed"
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
job.status = "failed"
|
||||||
|
job.error_message = str(exc)
|
||||||
|
logger.exception("Job %s failed", job_name)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
job.last_output = out.getvalue()
|
||||||
|
if job.interval_hours > 0:
|
||||||
|
job.next_run = timezone.now() + timedelta(hours=job.interval_hours)
|
||||||
|
else:
|
||||||
|
job.next_run = None
|
||||||
|
job.save()
|
||||||
|
|
||||||
|
return job.last_output
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="api.sync_flavors")
|
||||||
|
def sync_flavors_task() -> str:
|
||||||
|
return _run_command_job("sync_flavors", "sync_flavors")
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="api.cleanup_duplicates")
|
||||||
|
def cleanup_duplicates_task() -> str:
|
||||||
|
return _run_command_job("cleanup_duplicates", "cleanup_duplicates")
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="api.backup_db")
|
||||||
|
def backup_db_task() -> str:
|
||||||
|
return _run_command_job("backup_db", "backup_db", "--full")
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="api.seed_legacy")
|
||||||
|
def seed_legacy_task() -> str:
|
||||||
|
return _run_command_job("seed_legacy", "seed_legacy_flavors")
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(
|
||||||
|
name="api.send_email",
|
||||||
|
autoretry_for=(Exception,),
|
||||||
|
retry_backoff=True,
|
||||||
|
retry_kwargs={"max_retries": 3},
|
||||||
|
)
|
||||||
|
def send_email_task(
|
||||||
|
subject: str,
|
||||||
|
message: str,
|
||||||
|
recipient_list: list[str],
|
||||||
|
from_email: str | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> int:
|
||||||
|
"""Async wrapper around django.core.mail.send_mail."""
|
||||||
|
return send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=message,
|
||||||
|
from_email=from_email,
|
||||||
|
recipient_list=recipient_list,
|
||||||
|
fail_silently=False,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
from django.urls import reverse
|
|
||||||
from rest_framework import status
|
|
||||||
from rest_framework.test import APITestCase
|
|
||||||
from .models import Category, Flavor, User, Rating
|
|
||||||
|
|
||||||
class FlavorTopTests(APITestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.category_energy = Category.objects.create(name='Energy', slug='energy')
|
|
||||||
self.category_iced_tea = Category.objects.create(name='Iced Tea', slug='iced-tea')
|
|
||||||
|
|
||||||
self.user = User.objects.create_user(username='testuser', password='password')
|
|
||||||
|
|
||||||
# Create flavors
|
|
||||||
self.flavor1 = Flavor.objects.create(name='Energy 1', category=self.category_energy)
|
|
||||||
self.flavor2 = Flavor.objects.create(name='Iced Tea 1', category=self.category_iced_tea)
|
|
||||||
|
|
||||||
# Add ratings to make them appear in top (since we filter by ratings__isnull=False)
|
|
||||||
Rating.objects.create(user=self.user, flavor=self.flavor1, score=5)
|
|
||||||
Rating.objects.create(user=self.user, flavor=self.flavor2, score=4)
|
|
||||||
|
|
||||||
def test_top_flavors_all(self):
|
|
||||||
url = reverse('flavor-top')
|
|
||||||
response = self.client.get(url)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(len(response.data), 2)
|
|
||||||
|
|
||||||
def test_top_flavors_filtered_energy(self):
|
|
||||||
url = reverse('flavor-top')
|
|
||||||
response = self.client.get(url, {'category': 'energy'})
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(len(response.data), 1)
|
|
||||||
self.assertEqual(response.data[0]['name'], 'Energy 1')
|
|
||||||
|
|
||||||
def test_top_flavors_filtered_iced_tea(self):
|
|
||||||
url = reverse('flavor-top')
|
|
||||||
response = self.client.get(url, {'category': 'iced-tea'})
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(len(response.data), 1)
|
|
||||||
self.assertEqual(response.data[0]['name'], 'Iced Tea 1')
|
|
||||||
|
|
||||||
def test_top_flavors_filtered_non_existent(self):
|
|
||||||
url = reverse('flavor-top')
|
|
||||||
response = self.client.get(url, {'category': 'non-existent'})
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(len(response.data), 0)
|
|
||||||
Whitespace-only changes.
@@ -0,0 +1,55 @@
|
|||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from api.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class JWTAuthTests(APITestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.username = "jwtuser"
|
||||||
|
self.password = "correct-horse-battery-staple"
|
||||||
|
self.user = User.objects.create_user(username=self.username, password=self.password)
|
||||||
|
|
||||||
|
def test_obtain_pair_returns_access_and_refresh(self) -> None:
|
||||||
|
url = reverse("token_obtain_pair")
|
||||||
|
response = self.client.post(
|
||||||
|
url,
|
||||||
|
{"username": self.username, "password": self.password},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertIn("access", response.data)
|
||||||
|
self.assertIn("refresh", response.data)
|
||||||
|
|
||||||
|
def test_access_token_authenticates_protected_endpoint(self) -> None:
|
||||||
|
pair = self.client.post(
|
||||||
|
reverse("token_obtain_pair"),
|
||||||
|
{"username": self.username, "password": self.password},
|
||||||
|
format="json",
|
||||||
|
).data
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {pair['access']}")
|
||||||
|
response = self.client.get("/api/notifications/", follow=True)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def test_refresh_issues_new_access_token(self) -> None:
|
||||||
|
pair = self.client.post(
|
||||||
|
reverse("token_obtain_pair"),
|
||||||
|
{"username": self.username, "password": self.password},
|
||||||
|
format="json",
|
||||||
|
).data
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("token_refresh"),
|
||||||
|
{"refresh": pair["refresh"]},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertIn("access", response.data)
|
||||||
|
|
||||||
|
def test_invalid_refresh_is_rejected(self) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("token_refresh"),
|
||||||
|
{"refresh": "not-a-real-token"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from api.models import Category, Flavor, Rating, User
|
||||||
|
|
||||||
|
|
||||||
|
class FlavorTopTests(APITestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.category_energy = Category.objects.create(name="Energy", slug="energy")
|
||||||
|
self.category_iced_tea = Category.objects.create(name="Iced Tea", slug="iced-tea")
|
||||||
|
|
||||||
|
self.user = User.objects.create_user(username="testuser", password="password")
|
||||||
|
|
||||||
|
self.flavor1 = Flavor.objects.create(name="Energy 1", category=self.category_energy)
|
||||||
|
self.flavor2 = Flavor.objects.create(name="Iced Tea 1", category=self.category_iced_tea)
|
||||||
|
|
||||||
|
Rating.objects.create(user=self.user, flavor=self.flavor1, score=5)
|
||||||
|
Rating.objects.create(user=self.user, flavor=self.flavor2, score=4)
|
||||||
|
|
||||||
|
def test_top_flavors_all(self) -> None:
|
||||||
|
url = reverse("flavor-top")
|
||||||
|
response = self.client.get(url, follow=True)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(len(response.data), 2)
|
||||||
|
|
||||||
|
def test_top_flavors_filtered_energy(self) -> None:
|
||||||
|
url = reverse("flavor-top")
|
||||||
|
response = self.client.get(url, {"category": "energy"}, follow=True)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(len(response.data), 1)
|
||||||
|
self.assertEqual(response.data[0]["name"], "Energy 1")
|
||||||
|
|
||||||
|
def test_top_flavors_filtered_iced_tea(self) -> None:
|
||||||
|
url = reverse("flavor-top")
|
||||||
|
response = self.client.get(url, {"category": "iced-tea"}, follow=True)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(len(response.data), 1)
|
||||||
|
self.assertEqual(response.data[0]["name"], "Iced Tea 1")
|
||||||
|
|
||||||
|
def test_top_flavors_filtered_non_existent(self) -> None:
|
||||||
|
url = reverse("flavor-top")
|
||||||
|
response = self.client.get(url, {"category": "non-existent"}, follow=True)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(len(response.data), 0)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from api.models import Category, Flavor, Rating, User
|
||||||
|
|
||||||
|
|
||||||
|
class FlavorAverageRatingTests(TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.category = Category.objects.create(name="Energy", slug="energy")
|
||||||
|
self.flavor = Flavor.objects.create(name="Test", category=self.category)
|
||||||
|
self.u1 = User.objects.create_user(username="u1", password="x")
|
||||||
|
self.u2 = User.objects.create_user(username="u2", password="x")
|
||||||
|
|
||||||
|
def test_average_zero_when_no_ratings(self) -> None:
|
||||||
|
self.assertEqual(self.flavor.get_average_rating, 0.0)
|
||||||
|
|
||||||
|
def test_average_uses_aggregate(self) -> None:
|
||||||
|
Rating.objects.create(user=self.u1, flavor=self.flavor, score=4)
|
||||||
|
Rating.objects.create(user=self.u2, flavor=self.flavor, score=8)
|
||||||
|
self.assertEqual(self.flavor.get_average_rating, 6.0)
|
||||||
+12
-11
@@ -1,18 +1,19 @@
|
|||||||
from django.urls import path, include
|
from django.urls import include, path
|
||||||
from rest_framework import routers
|
from rest_framework import routers
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
router = routers.DefaultRouter()
|
router = routers.DefaultRouter()
|
||||||
router.register('users', views.UserViewSet)
|
router.register("users", views.UserViewSet)
|
||||||
router.register('flavors', views.FlavorViewSet)
|
router.register("flavors", views.FlavorViewSet)
|
||||||
router.register('categories', views.CategoryViewSet)
|
router.register("categories", views.CategoryViewSet)
|
||||||
router.register('ratings', views.RatingViewSet)
|
router.register("ratings", views.RatingViewSet)
|
||||||
router.register('replies', views.ReplyViewSet)
|
router.register("replies", views.ReplyViewSet)
|
||||||
router.register('notifications', views.NotificationViewSet, basename='notification')
|
router.register("notifications", views.NotificationViewSet, basename="notification")
|
||||||
router.register('tickets', views.TicketViewSet, basename='ticket')
|
router.register("tickets", views.TicketViewSet, basename="ticket")
|
||||||
router.register('admin-custom', views.AdminViewSet, basename='admin-custom')
|
router.register("admin-custom", views.AdminViewSet, basename="admin-custom")
|
||||||
router.register('banners', views.BannerViewSet, basename='banner')
|
router.register("banners", views.BannerViewSet, basename="banner")
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', include(router.urls)),
|
path("", include(router.urls)),
|
||||||
]
|
]
|
||||||
@@ -1,979 +0,0 @@
|
|||||||
from django.core.mail import send_mail
|
|
||||||
from django.conf import settings
|
|
||||||
from rest_framework import viewsets, permissions, status, filters, serializers
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework.decorators import action
|
|
||||||
from django.db.models import Avg, Q
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
|
||||||
from django_ratelimit.decorators import ratelimit
|
|
||||||
from django.utils.decorators import method_decorator
|
|
||||||
from rest_framework.pagination import PageNumberPagination
|
|
||||||
from .models import User, Flavor, Category, Rating, Reply, Notification, Ticket, TicketMessage, UserIP, ProfileComment, Banner, SystemConfig, Job
|
|
||||||
from .serializers import (
|
|
||||||
UserSerializer, FlavorSerializer, CategorySerializer, RatingSerializer,
|
|
||||||
ReplySerializer, NotificationSerializer, TicketSerializer,
|
|
||||||
TicketMessageSerializer, AdminUserListSerializer, AdminUserDetailSerializer,
|
|
||||||
ProfileCommentSerializer, BannerSerializer, SystemConfigSerializer, JobSerializer
|
|
||||||
)
|
|
||||||
|
|
||||||
def log_user_ip(user, request):
|
|
||||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
|
||||||
if x_forwarded_for:
|
|
||||||
ip = x_forwarded_for.split(',')[0]
|
|
||||||
else:
|
|
||||||
ip = request.META.get('REMOTE_ADDR')
|
|
||||||
|
|
||||||
if ip and user.is_authenticated:
|
|
||||||
UserIP.objects.update_or_create(user=user, ip_address=ip)
|
|
||||||
|
|
||||||
def parse_mentions(text, actor, rating=None, reply=None):
|
|
||||||
import re
|
|
||||||
mentions = re.findall(r'@(\w+)', text)
|
|
||||||
for username in set(mentions):
|
|
||||||
try:
|
|
||||||
recipient = User.objects.get(username__iexact=username)
|
|
||||||
if recipient != actor:
|
|
||||||
Notification.objects.get_or_create(
|
|
||||||
recipient=recipient,
|
|
||||||
actor=actor,
|
|
||||||
notification_type='mention',
|
|
||||||
rating=rating,
|
|
||||||
reply=reply
|
|
||||||
)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
continue
|
|
||||||
|
|
||||||
class FeedPagination(PageNumberPagination):
|
|
||||||
page_size = 10
|
|
||||||
|
|
||||||
class FlavorViewSet(viewsets.ModelViewSet):
|
|
||||||
queryset = Flavor.objects.annotate(average_rating=Avg('ratings__score')).order_by('-average_rating')
|
|
||||||
serializer_class = FlavorSerializer
|
|
||||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter]
|
|
||||||
filterset_fields = ['category', 'category__slug']
|
|
||||||
search_fields = ['name', 'description']
|
|
||||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
qs = Flavor.objects.select_related('category').annotate(average_rating=Avg('ratings__score')).order_by('-average_rating').distinct()
|
|
||||||
|
|
||||||
# Handle category keywords in search query for main results too
|
|
||||||
search_query = self.request.query_params.get('search', '').lower().strip()
|
|
||||||
if search_query:
|
|
||||||
cat_keywords = {
|
|
||||||
'iced tea': 'iced-tea',
|
|
||||||
'eistee': 'iced-tea',
|
|
||||||
'energy': 'energy',
|
|
||||||
'hydration': 'hydration',
|
|
||||||
'milkshake': 'milkshake'
|
|
||||||
}
|
|
||||||
for keyword, slug in cat_keywords.items():
|
|
||||||
if keyword in search_query:
|
|
||||||
qs = qs.filter(category__slug=slug)
|
|
||||||
break # Only one category at a time
|
|
||||||
|
|
||||||
return qs
|
|
||||||
|
|
||||||
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny])
|
|
||||||
def search(self, request):
|
|
||||||
query = request.query_params.get('q', '').lower().strip()
|
|
||||||
if not query:
|
|
||||||
return Response([])
|
|
||||||
|
|
||||||
# Start with all flavors
|
|
||||||
flavors = Flavor.objects.select_related('category').distinct()
|
|
||||||
|
|
||||||
# Intelligent category filtering based on keywords
|
|
||||||
cat_keywords = {
|
|
||||||
'iced tea': 'iced-tea',
|
|
||||||
'eistee': 'iced-tea',
|
|
||||||
'energy': 'energy',
|
|
||||||
'hydration': 'hydration',
|
|
||||||
'milkshake': 'milkshake'
|
|
||||||
}
|
|
||||||
|
|
||||||
active_cat_slug = None
|
|
||||||
remaining_query = query
|
|
||||||
for keyword, slug in cat_keywords.items():
|
|
||||||
if keyword in query:
|
|
||||||
active_cat_slug = slug
|
|
||||||
remaining_query = remaining_query.replace(keyword, '').strip()
|
|
||||||
break
|
|
||||||
|
|
||||||
if active_cat_slug:
|
|
||||||
flavors = flavors.filter(category__slug=active_cat_slug)
|
|
||||||
|
|
||||||
# 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:
|
|
||||||
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 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,
|
|
||||||
'relevance': relevance
|
|
||||||
})
|
|
||||||
|
|
||||||
# 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):
|
|
||||||
queryset = self.get_queryset().filter(ratings__isnull=False)
|
|
||||||
category_slug = request.query_params.get('category')
|
|
||||||
if category_slug:
|
|
||||||
queryset = queryset.filter(category__slug=category_slug)
|
|
||||||
top_flavors = queryset.distinct()[:10]
|
|
||||||
serializer = self.get_serializer(top_flavors, many=True)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny])
|
|
||||||
def newest(self, request):
|
|
||||||
# Get the 10 newest flavors
|
|
||||||
newest_flavors = Flavor.objects.select_related('category').order_by('-created_at')[:10]
|
|
||||||
serializer = self.get_serializer(newest_flavors, many=True)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
@action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
|
|
||||||
def followed_top(self, request):
|
|
||||||
followed_users = request.user.following.all()
|
|
||||||
top_flavors = Flavor.objects.filter(ratings__user__in=followed_users) \
|
|
||||||
.annotate(average_rating=Avg('ratings__score')) \
|
|
||||||
.order_by('-average_rating') \
|
|
||||||
.distinct()[:10]
|
|
||||||
serializer = self.get_serializer(top_flavors, many=True)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
class CategoryViewSet(viewsets.ReadOnlyModelViewSet):
|
|
||||||
queryset = Category.objects.all()
|
|
||||||
serializer_class = CategorySerializer
|
|
||||||
|
|
||||||
class RatingViewSet(viewsets.ModelViewSet):
|
|
||||||
queryset = Rating.objects.all()
|
|
||||||
serializer_class = RatingSerializer
|
|
||||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
|
||||||
|
|
||||||
@method_decorator(ratelimit(key='user', rate='10/m', method='POST', block=True))
|
|
||||||
def create(self, request, *args, **kwargs):
|
|
||||||
return super().create(request, *args, **kwargs)
|
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
|
||||||
# Ensure user can only have one rating per flavor
|
|
||||||
flavor = serializer.validated_data['flavor']
|
|
||||||
if Rating.objects.filter(user=self.request.user, flavor=flavor).exists():
|
|
||||||
raise serializers.ValidationError('You have already rated this flavor.')
|
|
||||||
rating = serializer.save(user=self.request.user)
|
|
||||||
# Parse mentions in the rating comment
|
|
||||||
if rating.comment:
|
|
||||||
parse_mentions(rating.comment, self.request.user, rating=rating)
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
return Rating.objects.select_related('user', 'flavor', 'flavor__category').prefetch_related('replies', 'replies__user').order_by('-created_at')
|
|
||||||
|
|
||||||
@action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
|
|
||||||
def feed(self, request):
|
|
||||||
# Get ratings from people the user follows
|
|
||||||
followed_users = request.user.following.all()
|
|
||||||
feed_ratings = Rating.objects.filter(user__in=followed_users).select_related('user', 'flavor', 'flavor__category').prefetch_related('replies', 'replies__user').order_by('-created_at')
|
|
||||||
|
|
||||||
paginator = FeedPagination()
|
|
||||||
page = paginator.paginate_queryset(feed_ratings, request)
|
|
||||||
if page is not None:
|
|
||||||
serializer = self.get_serializer(page, many=True)
|
|
||||||
return paginator.get_paginated_response(serializer.data)
|
|
||||||
|
|
||||||
serializer = self.get_serializer(feed_ratings, many=True)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
def check_object_permissions(self, request, obj):
|
|
||||||
super().check_object_permissions(request, obj)
|
|
||||||
if request.method in ['PUT', 'PATCH', 'DELETE'] and obj.user != request.user and not request.user.is_superuser:
|
|
||||||
self.permission_denied(request, message='You cannot edit/delete this rating.')
|
|
||||||
|
|
||||||
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny])
|
|
||||||
def recent(self, request):
|
|
||||||
recent_ratings = Rating.objects.filter(comment__isnull=False).exclude(comment='').select_related('user', 'flavor').order_by('-created_at')[:10]
|
|
||||||
serializer = self.get_serializer(recent_ratings, many=True)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
|
||||||
def reply(self, request, pk=None):
|
|
||||||
rating = self.get_object()
|
|
||||||
text = request.data.get('text')
|
|
||||||
if not text:
|
|
||||||
return Response({'error': 'Text is required'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
reply = Reply.objects.create(user=request.user, rating=rating, text=text)
|
|
||||||
|
|
||||||
# Notify the rating author if it's not the same person
|
|
||||||
if rating.user != request.user:
|
|
||||||
Notification.objects.create(
|
|
||||||
recipient=rating.user,
|
|
||||||
actor=request.user,
|
|
||||||
notification_type='reply',
|
|
||||||
rating=rating,
|
|
||||||
reply=reply
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse mentions in the reply text
|
|
||||||
parse_mentions(text, request.user, rating=rating, reply=reply)
|
|
||||||
|
|
||||||
return Response(ReplySerializer(reply, context={'request': request}).data, status=status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
class ReplyViewSet(viewsets.ModelViewSet):
|
|
||||||
queryset = Reply.objects.all()
|
|
||||||
serializer_class = ReplySerializer
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
return Reply.objects.select_related('user', 'rating').order_by('created_at')
|
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
|
||||||
reply = self.get_object()
|
|
||||||
if reply.user != self.request.user and not self.request.user.is_superuser:
|
|
||||||
raise permissions.PermissionDenied("You cannot edit this reply.")
|
|
||||||
new_reply = serializer.save()
|
|
||||||
# Parse mentions in the updated text
|
|
||||||
parse_mentions(new_reply.text, self.request.user, rating=new_reply.rating, reply=new_reply)
|
|
||||||
|
|
||||||
def perform_destroy(self, instance):
|
|
||||||
if instance.user != self.request.user and not self.request.user.is_superuser:
|
|
||||||
raise permissions.PermissionDenied("You cannot delete this reply.")
|
|
||||||
instance.delete()
|
|
||||||
|
|
||||||
class NotificationViewSet(viewsets.ReadOnlyModelViewSet):
|
|
||||||
serializer_class = NotificationSerializer
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
pagination_class = None
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
return self.request.user.notifications.select_related('actor', 'rating__flavor', 'reply__rating__flavor').order_by('-created_at')
|
|
||||||
|
|
||||||
@action(detail=False, methods=['post'])
|
|
||||||
def mark_all_read(self, request):
|
|
||||||
request.user.notifications.filter(is_read=False).update(is_read=True)
|
|
||||||
return Response({'status': 'all read'})
|
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
|
||||||
def mark_read(self, request, pk=None):
|
|
||||||
notification = self.get_object()
|
|
||||||
notification.is_read = True
|
|
||||||
notification.save()
|
|
||||||
return Response({'status': 'marked read'})
|
|
||||||
|
|
||||||
class UserViewSet(viewsets.ModelViewSet):
|
|
||||||
queryset = User.objects.all()
|
|
||||||
serializer_class = UserSerializer
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
def get_permissions(self):
|
|
||||||
if self.action in ['signup', 'verify_signup', 'request_password_reset', 'complete_password_reset']:
|
|
||||||
return [permissions.AllowAny()]
|
|
||||||
return super().get_permissions()
|
|
||||||
|
|
||||||
@action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
|
|
||||||
def following_list(self, request):
|
|
||||||
following = request.user.following.all()
|
|
||||||
serializer = self.get_serializer(following, many=True)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
|
||||||
@method_decorator(ratelimit(key='ip', rate='5/h', method='POST', block=True))
|
|
||||||
def signup(self, request):
|
|
||||||
username = request.data.get('username')
|
|
||||||
email = request.data.get('email')
|
|
||||||
password = request.data.get('password')
|
|
||||||
if not username or not email or not password:
|
|
||||||
return Response({'error': 'Username, email and password required'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
if User.objects.filter(username=username).exists():
|
|
||||||
return Response({'error': 'Username already exists'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
if User.objects.filter(email=email).exists():
|
|
||||||
return Response({'error': 'Email already exists'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
import secrets
|
|
||||||
import string
|
|
||||||
code = ''.join(secrets.choice(string.digits) for _ in range(6))
|
|
||||||
|
|
||||||
user = User.objects.create_user(
|
|
||||||
username=username,
|
|
||||||
email=email,
|
|
||||||
password=password,
|
|
||||||
is_active=False,
|
|
||||||
email_confirmation_code=code
|
|
||||||
)
|
|
||||||
|
|
||||||
verification_link = f"{settings.FRONTEND_URL}/verify-email?username={username}&code={code}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
send_mail(
|
|
||||||
'Verify your Holy Flavors account',
|
|
||||||
f'Hi {username},\n\nYour verification code is: {code}\n\nAlternatively, you can complete your registration by clicking the link below:\n{verification_link}\n\nWelcome to the archive!',
|
|
||||||
settings.DEFAULT_FROM_EMAIL,
|
|
||||||
[email],
|
|
||||||
fail_silently=False,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
# If email fails for any reason, delete the user so they can try again
|
|
||||||
user.delete()
|
|
||||||
return Response(
|
|
||||||
{'error': f'Failed to send verification email. Please check your SMTP settings in the .env file. The server error was: {e}'},
|
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response({'status': 'User created, please verify your email', 'username': username, 'email': email}, status=status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
|
||||||
def resend_verification(self, request):
|
|
||||||
username = request.data.get('username')
|
|
||||||
if not username:
|
|
||||||
return Response({'error': 'Username required'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
try:
|
|
||||||
user = User.objects.get(username=username, is_active=False)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
return Response({'error': 'User not found or already active'}, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
import secrets
|
|
||||||
import string
|
|
||||||
code = ''.join(secrets.choice(string.digits) for _ in range(6))
|
|
||||||
user.email_confirmation_code = code
|
|
||||||
user.save()
|
|
||||||
|
|
||||||
verification_link = f"{settings.FRONTEND_URL}/verify-email?username={user.username}&code={code}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
send_mail(
|
|
||||||
'Verify your Holy Flavors account',
|
|
||||||
f'Hi {user.username},\n\nYour new verification code is: {code}\n\nAlternatively, you can complete your registration by clicking the link below:\n{verification_link}',
|
|
||||||
settings.DEFAULT_FROM_EMAIL,
|
|
||||||
[user.email],
|
|
||||||
fail_silently=False,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return Response(
|
|
||||||
{'error': f'Failed to send email: {e}'},
|
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response({'status': 'Verification code resent!'})
|
|
||||||
|
|
||||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
|
||||||
def verify_signup(self, request):
|
|
||||||
username = request.data.get('username')
|
|
||||||
code = request.data.get('code')
|
|
||||||
try:
|
|
||||||
user = User.objects.get(username=username, is_active=False)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
return Response({'error': 'User not found or already active'}, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
if user.email_confirmation_code == code:
|
|
||||||
user.is_active = True
|
|
||||||
user.email_confirmation_code = None
|
|
||||||
user.save()
|
|
||||||
return Response({'status': 'Account verified successfully!'})
|
|
||||||
else:
|
|
||||||
return Response({'error': 'Invalid verification code'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
@action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny], url_path='profile/(?P<username>[^/.]+)')
|
|
||||||
def public_profile(self, request, username=None):
|
|
||||||
try:
|
|
||||||
user = User.objects.get(username=username)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
ratings = user.ratings.select_related('flavor', 'flavor__category').order_by('-score')
|
|
||||||
comments = user.profile_comments.select_related('author').order_by('-created_at')
|
|
||||||
|
|
||||||
followers = user.followers.all()
|
|
||||||
following = user.following.all()
|
|
||||||
|
|
||||||
return Response({
|
|
||||||
'id': user.id,
|
|
||||||
'username': user.username,
|
|
||||||
'theme': user.theme,
|
|
||||||
'avatar': request.build_absolute_uri(user.avatar.url) if user.avatar else None,
|
|
||||||
'following_count': following.count(),
|
|
||||||
'followers_count': followers.count(),
|
|
||||||
'is_following': request.user.following.filter(pk=user.pk).exists() if request.user.is_authenticated else False,
|
|
||||||
'ratings': RatingSerializer(ratings, many=True, context={'request': request}).data,
|
|
||||||
'comments': ProfileCommentSerializer(comments, many=True, context={'request': request}).data,
|
|
||||||
'followers': UserSerializer(followers, many=True, context={'request': request}).data,
|
|
||||||
'following': UserSerializer(following, many=True, context={'request': request}).data
|
|
||||||
})
|
|
||||||
|
|
||||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
|
||||||
def add_comment(self, request, pk=None):
|
|
||||||
profile_owner = self.get_object()
|
|
||||||
text = request.data.get('text')
|
|
||||||
if not text:
|
|
||||||
return Response({'error': 'Text is required'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
comment = ProfileComment.objects.create(
|
|
||||||
profile_owner=profile_owner,
|
|
||||||
author=request.user,
|
|
||||||
text=text
|
|
||||||
)
|
|
||||||
|
|
||||||
# Notify profile owner
|
|
||||||
if profile_owner != request.user:
|
|
||||||
Notification.objects.create(
|
|
||||||
recipient=profile_owner,
|
|
||||||
actor=request.user,
|
|
||||||
notification_type='profile_comment',
|
|
||||||
profile_comment=comment
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response(ProfileCommentSerializer(comment, context={'request': request}).data, status=status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
@action(detail=True, methods=['delete'], url_path='delete_comment/(?P<comment_id>[^/.]+)', permission_classes=[permissions.IsAuthenticated])
|
|
||||||
def delete_comment(self, request, pk=None, comment_id=None):
|
|
||||||
profile_owner = self.get_object()
|
|
||||||
try:
|
|
||||||
comment = ProfileComment.objects.get(pk=comment_id, profile_owner=profile_owner)
|
|
||||||
except ProfileComment.DoesNotExist:
|
|
||||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
if comment.author != request.user and not request.user.is_superuser and profile_owner != request.user:
|
|
||||||
return Response(status=status.HTTP_403_FORBIDDEN)
|
|
||||||
|
|
||||||
comment.delete()
|
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
@action(detail=False, methods=['get'])
|
|
||||||
def me(self, request):
|
|
||||||
log_user_ip(request.user, request)
|
|
||||||
serializer = self.get_serializer(request.user)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
@action(detail=False, methods=['patch'])
|
|
||||||
def update_preferences(self, request):
|
|
||||||
user = request.user
|
|
||||||
theme = request.data.get('theme')
|
|
||||||
language = request.data.get('language')
|
|
||||||
banner_id = request.data.get('selected_banner')
|
|
||||||
|
|
||||||
updated = False
|
|
||||||
if theme in dict(User.THEME_CHOICES):
|
|
||||||
user.theme = theme
|
|
||||||
updated = True
|
|
||||||
if language in dict(User.LANGUAGE_CHOICES):
|
|
||||||
user.language = language
|
|
||||||
updated = True
|
|
||||||
|
|
||||||
if banner_id is not None:
|
|
||||||
if banner_id == "":
|
|
||||||
user.selected_banner = None
|
|
||||||
updated = True
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
banner = Banner.objects.get(pk=banner_id, is_enabled=True)
|
|
||||||
user.selected_banner = banner
|
|
||||||
updated = True
|
|
||||||
except Banner.DoesNotExist:
|
|
||||||
return Response({'error': 'Invalid or disabled banner'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
if updated:
|
|
||||||
user.save()
|
|
||||||
return Response({
|
|
||||||
'status': 'preferences updated',
|
|
||||||
'theme': user.theme,
|
|
||||||
'language': user.language,
|
|
||||||
'selected_banner': user.selected_banner.id if user.selected_banner else None
|
|
||||||
})
|
|
||||||
return Response({'error': 'No valid updates provided'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
@action(detail=False, methods=['post'])
|
|
||||||
def update_avatar(self, request):
|
|
||||||
user = request.user
|
|
||||||
avatar = request.FILES.get('avatar')
|
|
||||||
if not avatar:
|
|
||||||
return Response({'error': 'No avatar provided'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
# Check size (2MB = 2 * 1024 * 1024 bytes)
|
|
||||||
if avatar.size > 2 * 1024 * 1024:
|
|
||||||
return Response({'error': 'File size exceeds 2MB limit'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
user.avatar = avatar
|
|
||||||
user.save()
|
|
||||||
serializer = self.get_serializer(user)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
@action(detail=False, methods=['post'])
|
|
||||||
def change_password(self, request):
|
|
||||||
user = request.user
|
|
||||||
old_password = request.data.get('old_password')
|
|
||||||
new_password = request.data.get('new_password')
|
|
||||||
if not user.check_password(old_password):
|
|
||||||
return Response({'error': 'Wrong old password'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
user.set_password(new_password)
|
|
||||||
user.save()
|
|
||||||
return Response({'status': 'password changed'})
|
|
||||||
|
|
||||||
@action(detail=False, methods=['patch'])
|
|
||||||
def update_profile(self, request):
|
|
||||||
user = request.user
|
|
||||||
username = request.data.get('username')
|
|
||||||
email = request.data.get('email')
|
|
||||||
|
|
||||||
message = ""
|
|
||||||
if username and username != user.username:
|
|
||||||
if User.objects.filter(username=username).exists():
|
|
||||||
return Response({'error': 'Username already taken'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
user.username = username
|
|
||||||
|
|
||||||
if email and email != user.email:
|
|
||||||
import secrets
|
|
||||||
import string
|
|
||||||
code = ''.join(secrets.choice(string.digits) for _ in range(6))
|
|
||||||
user.pending_email = email
|
|
||||||
user.email_confirmation_code = code
|
|
||||||
send_mail(
|
|
||||||
'Confirm your new email',
|
|
||||||
f'Hi {user.username},\n\nYour confirmation code is: {code}',
|
|
||||||
settings.DEFAULT_FROM_EMAIL,
|
|
||||||
[email],
|
|
||||||
fail_silently=False,
|
|
||||||
)
|
|
||||||
message = " Confirmation code sent to new email."
|
|
||||||
|
|
||||||
user.save()
|
|
||||||
serializer = self.get_serializer(user)
|
|
||||||
data = serializer.data
|
|
||||||
if message:
|
|
||||||
data['message'] = message
|
|
||||||
return Response(data)
|
|
||||||
|
|
||||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
|
||||||
def follow(self, request, pk=None):
|
|
||||||
user_to_follow = self.get_object()
|
|
||||||
if user_to_follow == request.user:
|
|
||||||
return Response({'error': 'You cannot follow yourself'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
if not request.user.following.filter(pk=user_to_follow.pk).exists():
|
|
||||||
request.user.following.add(user_to_follow)
|
|
||||||
# Notify the user being followed
|
|
||||||
Notification.objects.create(
|
|
||||||
recipient=user_to_follow,
|
|
||||||
actor=request.user,
|
|
||||||
notification_type='follow'
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response({'status': 'followed'})
|
|
||||||
|
|
||||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
|
||||||
def unfollow(self, request, pk=None):
|
|
||||||
user_to_unfollow = self.get_object()
|
|
||||||
request.user.following.remove(user_to_unfollow)
|
|
||||||
return Response({'status': 'unfollowed'})
|
|
||||||
|
|
||||||
@action(detail=False, methods=['post'])
|
|
||||||
def confirm_email(self, request):
|
|
||||||
user = request.user
|
|
||||||
code = request.data.get('code')
|
|
||||||
if not user.email_confirmation_code or code != user.email_confirmation_code:
|
|
||||||
return Response({'error': 'Invalid or expired code'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
user.email = user.pending_email
|
|
||||||
user.pending_email = None
|
|
||||||
user.email_confirmation_code = None
|
|
||||||
user.save()
|
|
||||||
return Response({'status': 'Email confirmed', 'email': user.email})
|
|
||||||
|
|
||||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
|
||||||
@method_decorator(ratelimit(key='ip', rate='3/h', method='POST', block=True))
|
|
||||||
def request_password_reset(self, request):
|
|
||||||
email = request.data.get('email')
|
|
||||||
try:
|
|
||||||
user = User.objects.get(email=email, is_active=True)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
return Response({'status': 'If an account exists with this email, a reset code has been sent.'})
|
|
||||||
|
|
||||||
import secrets
|
|
||||||
import string
|
|
||||||
code = ''.join(secrets.choice(string.digits) for _ in range(6))
|
|
||||||
user.email_confirmation_code = code
|
|
||||||
user.save()
|
|
||||||
|
|
||||||
send_mail(
|
|
||||||
'Password Reset Request',
|
|
||||||
f'Hi {user.username},\n\nYour password reset code is: {code}',
|
|
||||||
settings.DEFAULT_FROM_EMAIL,
|
|
||||||
[email],
|
|
||||||
fail_silently=False,
|
|
||||||
)
|
|
||||||
return Response({'status': 'If an account exists with this email, a reset code has been sent.'})
|
|
||||||
|
|
||||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
|
||||||
@method_decorator(ratelimit(key='ip', rate='5/h', method='POST', block=True))
|
|
||||||
def complete_password_reset(self, request):
|
|
||||||
email = request.data.get('email')
|
|
||||||
code = request.data.get('code')
|
|
||||||
new_password = request.data.get('password')
|
|
||||||
|
|
||||||
try:
|
|
||||||
user = User.objects.get(email=email, email_confirmation_code=code)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
return Response({'error': 'Invalid email or code'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
user.set_password(new_password)
|
|
||||||
user.email_confirmation_code = None
|
|
||||||
user.save()
|
|
||||||
return Response({'status': 'Password reset successful!'})
|
|
||||||
|
|
||||||
@action(detail=False, methods=['post'])
|
|
||||||
@method_decorator(ratelimit(key='user', rate='3/h', method='POST', block=True))
|
|
||||||
def request_account_deletion(self, request):
|
|
||||||
import secrets
|
|
||||||
import string
|
|
||||||
code = ''.join(secrets.choice(string.digits) for _ in range(6))
|
|
||||||
|
|
||||||
user = request.user
|
|
||||||
user.email_confirmation_code = code
|
|
||||||
user.save()
|
|
||||||
|
|
||||||
send_mail(
|
|
||||||
'Confirm Account Deletion - Holy Flavors Archive',
|
|
||||||
f'Hi {user.username},\n\nYou requested to delete your account. This action is permanent.\n\nYour deletion code is: {code}',
|
|
||||||
settings.DEFAULT_FROM_EMAIL,
|
|
||||||
[user.email],
|
|
||||||
fail_silently=False,
|
|
||||||
)
|
|
||||||
return Response({'status': 'Code sent to your email'})
|
|
||||||
|
|
||||||
@action(detail=False, methods=['post'])
|
|
||||||
def confirm_account_deletion(self, request):
|
|
||||||
user = request.user
|
|
||||||
code = request.data.get('code')
|
|
||||||
if not user.email_confirmation_code or code != user.email_confirmation_code:
|
|
||||||
return Response({'error': 'Invalid or expired code'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
user.delete()
|
|
||||||
return Response({'status': 'Account deleted'})
|
|
||||||
|
|
||||||
@action(detail=False, methods=['get'])
|
|
||||||
def dashboard(self, request):
|
|
||||||
user = request.user
|
|
||||||
rated_ids = user.ratings.values_list('flavor_id', flat=True)
|
|
||||||
followed_users = user.following.all()
|
|
||||||
|
|
||||||
# Missing flavors with community avg and followed circle avg
|
|
||||||
missing_flavors = Flavor.objects.exclude(id__in=rated_ids) \
|
|
||||||
.exclude(category__name='Packs and other') \
|
|
||||||
.select_related('category') \
|
|
||||||
.annotate(
|
|
||||||
average_rating=Avg('ratings__score'),
|
|
||||||
followed_average_rating=Avg('ratings__score', filter=Q(ratings__user__in=followed_users))
|
|
||||||
).order_by('category__name', 'name')
|
|
||||||
|
|
||||||
rated_flavors = user.ratings.select_related('flavor', 'flavor__category') \
|
|
||||||
.prefetch_related('replies', 'replies__user') \
|
|
||||||
.order_by('-created_at')
|
|
||||||
|
|
||||||
return Response({
|
|
||||||
'user': UserSerializer(user).data,
|
|
||||||
'rated_count': rated_flavors.count(),
|
|
||||||
'missing_count': missing_flavors.count(),
|
|
||||||
'missing_flavors': FlavorSerializer(missing_flavors, many=True, context={'request': request}).data,
|
|
||||||
'my_ratings': RatingSerializer(rated_flavors, many=True, context={'request': request}).data
|
|
||||||
})
|
|
||||||
class TicketViewSet(viewsets.ModelViewSet):
|
|
||||||
serializer_class = TicketSerializer
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
if self.request.user.is_superuser:
|
|
||||||
return Ticket.objects.all().prefetch_related('messages', 'messages__user').order_by('-updated_at')
|
|
||||||
return Ticket.objects.filter(user=self.request.user).prefetch_related('messages', 'messages__user').order_by('-updated_at')
|
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
|
||||||
ticket = serializer.save(user=self.request.user)
|
|
||||||
# Notify all superusers about new ticket
|
|
||||||
admins = User.objects.filter(is_superuser=True).exclude(pk=self.request.user.pk)
|
|
||||||
for admin in admins:
|
|
||||||
Notification.objects.create(
|
|
||||||
recipient=admin,
|
|
||||||
actor=self.request.user,
|
|
||||||
notification_type='ticket_new',
|
|
||||||
ticket=ticket
|
|
||||||
)
|
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
|
||||||
def add_message(self, request, pk=None):
|
|
||||||
ticket = self.get_object()
|
|
||||||
text = request.data.get('text')
|
|
||||||
if not text:
|
|
||||||
return Response({'error': 'Text is required'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
msg = TicketMessage.objects.create(ticket=ticket, user=request.user, text=text)
|
|
||||||
|
|
||||||
# Notification logic
|
|
||||||
if request.user.is_superuser:
|
|
||||||
# Notify the user who opened the ticket
|
|
||||||
if ticket.user != request.user:
|
|
||||||
Notification.objects.create(
|
|
||||||
recipient=ticket.user,
|
|
||||||
actor=request.user,
|
|
||||||
notification_type='ticket_reply',
|
|
||||||
ticket=ticket
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Notify all superusers
|
|
||||||
admins = User.objects.filter(is_superuser=True).exclude(pk=request.user.pk)
|
|
||||||
for admin in admins:
|
|
||||||
Notification.objects.create(
|
|
||||||
recipient=admin,
|
|
||||||
actor=request.user,
|
|
||||||
notification_type='ticket_reply',
|
|
||||||
ticket=ticket
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response(TicketMessageSerializer(msg).data, status=status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAdminUser])
|
|
||||||
def update_status(self, request, pk=None):
|
|
||||||
ticket = self.get_object()
|
|
||||||
new_status = request.data.get('status')
|
|
||||||
if new_status in dict(Ticket.STATUS_CHOICES):
|
|
||||||
ticket.status = new_status
|
|
||||||
ticket.save()
|
|
||||||
return Response({'status': 'updated'})
|
|
||||||
return Response({'error': 'Invalid status'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
def destroy(self, request, *args, **kwargs):
|
|
||||||
if not request.user.is_superuser:
|
|
||||||
raise permissions.PermissionDenied("Only admins can delete tickets.")
|
|
||||||
return super().destroy(request, *args, **kwargs)
|
|
||||||
|
|
||||||
class BannerViewSet(viewsets.ModelViewSet):
|
|
||||||
queryset = Banner.objects.all()
|
|
||||||
serializer_class = BannerSerializer
|
|
||||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
|
||||||
pagination_class = None
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
if self.request.user.is_authenticated and self.request.user.is_superuser:
|
|
||||||
return Banner.objects.all()
|
|
||||||
return Banner.objects.filter(is_enabled=True)
|
|
||||||
|
|
||||||
def get_permissions(self):
|
|
||||||
if self.action in ['list', 'retrieve', 'active']:
|
|
||||||
return [permissions.AllowAny()]
|
|
||||||
return [permissions.IsAdminUser()]
|
|
||||||
|
|
||||||
@action(detail=False, methods=['get'])
|
|
||||||
def active(self, request):
|
|
||||||
# Determine which banner to show
|
|
||||||
# Priority:
|
|
||||||
# 1. Requested user's preference (if username provided in query)
|
|
||||||
# 2. Logged in user's preference
|
|
||||||
# 3. Global default
|
|
||||||
|
|
||||||
username = request.query_params.get('username')
|
|
||||||
user = None
|
|
||||||
if username:
|
|
||||||
try:
|
|
||||||
user = User.objects.get(username=username)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not user and request.user.is_authenticated:
|
|
||||||
user = request.user
|
|
||||||
|
|
||||||
if user and user.selected_banner and user.selected_banner.is_enabled:
|
|
||||||
serializer = self.get_serializer(user.selected_banner)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
active_banner = Banner.objects.filter(is_active=True).first()
|
|
||||||
if active_banner:
|
|
||||||
serializer = self.get_serializer(active_banner)
|
|
||||||
return Response(serializer.data)
|
|
||||||
return Response(None)
|
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
|
||||||
def activate(self, request, pk=None):
|
|
||||||
banner = self.get_object()
|
|
||||||
banner.is_active = True
|
|
||||||
banner.is_enabled = True # Active banner must be enabled
|
|
||||||
banner.save()
|
|
||||||
return Response({'status': 'banner set as global default'})
|
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
|
||||||
def toggle_enabled(self, request, pk=None):
|
|
||||||
banner = self.get_object()
|
|
||||||
if banner.is_active:
|
|
||||||
return Response({'error': 'Cannot disable the global default banner.'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
banner.is_enabled = not banner.is_enabled
|
|
||||||
banner.save()
|
|
||||||
return Response({'status': 'enabled' if banner.is_enabled else 'disabled'})
|
|
||||||
|
|
||||||
class AdminViewSet(viewsets.ViewSet):
|
|
||||||
permission_classes = [permissions.IsAdminUser]
|
|
||||||
|
|
||||||
@action(detail=False, methods=['get'])
|
|
||||||
def stats(self, request):
|
|
||||||
return Response({
|
|
||||||
'total_users': User.objects.count(),
|
|
||||||
'total_ratings': Rating.objects.count(),
|
|
||||||
'total_replies': Reply.objects.count(),
|
|
||||||
'open_tickets': Ticket.objects.filter(status='open').count(),
|
|
||||||
'email_config': {
|
|
||||||
'host': getattr(settings, 'EMAIL_HOST', 'None'),
|
|
||||||
'port': getattr(settings, 'EMAIL_PORT', 'None'),
|
|
||||||
'use_tls': getattr(settings, 'EMAIL_USE_TLS', False),
|
|
||||||
'use_ssl': getattr(settings, 'EMAIL_USE_SSL', False),
|
|
||||||
'skip_verify': getattr(settings, 'EMAIL_SKIP_CERT_VERIFICATION', False),
|
|
||||||
},
|
|
||||||
'server_info': {
|
|
||||||
'debug': settings.DEBUG,
|
|
||||||
'allowed_hosts': settings.ALLOWED_HOSTS,
|
|
||||||
'frontend_url': getattr(settings, 'FRONTEND_URL', 'Not set'),
|
|
||||||
'media_root': str(settings.MEDIA_ROOT),
|
|
||||||
'static_root': str(settings.STATIC_ROOT),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
@action(detail=False, methods=['get', 'patch'])
|
|
||||||
def config(self, request):
|
|
||||||
config = SystemConfig.get_solo()
|
|
||||||
if request.method == 'PATCH':
|
|
||||||
serializer = SystemConfigSerializer(config, data=request.data, partial=True)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
serializer.save()
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
serializer = SystemConfigSerializer(config)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
@action(detail=False, methods=['get'])
|
|
||||||
def jobs(self, request):
|
|
||||||
# Ensure all jobs exist
|
|
||||||
for job_type, label in Job.JOB_TYPES:
|
|
||||||
Job.objects.get_or_create(name=job_type)
|
|
||||||
|
|
||||||
jobs = Job.objects.all().order_by('name')
|
|
||||||
serializer = JobSerializer(jobs, many=True)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
|
||||||
def trigger_job(self, request, pk=None):
|
|
||||||
try:
|
|
||||||
job = Job.objects.get(pk=pk)
|
|
||||||
job.status = 'pending'
|
|
||||||
job.save()
|
|
||||||
return Response({'status': 'Job queued'})
|
|
||||||
except Job.DoesNotExist:
|
|
||||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
@action(detail=True, methods=['patch'])
|
|
||||||
def update_job_schedule(self, request, pk=None):
|
|
||||||
try:
|
|
||||||
job = Job.objects.get(pk=pk)
|
|
||||||
interval = request.data.get('interval_hours')
|
|
||||||
next_run = request.data.get('next_run')
|
|
||||||
|
|
||||||
if interval is not None:
|
|
||||||
job.interval_hours = int(interval)
|
|
||||||
|
|
||||||
if next_run is not None:
|
|
||||||
from django.utils.dateparse import parse_datetime
|
|
||||||
from django.utils import timezone
|
|
||||||
parsed_dt = parse_datetime(next_run)
|
|
||||||
if parsed_dt:
|
|
||||||
# If it's naive (has no timezone info), assume it's local time
|
|
||||||
if timezone.is_naive(parsed_dt):
|
|
||||||
job.next_run = timezone.make_aware(parsed_dt, timezone.get_current_timezone())
|
|
||||||
else:
|
|
||||||
job.next_run = parsed_dt
|
|
||||||
elif interval is not None and job.interval_hours > 0 and not job.next_run:
|
|
||||||
from django.utils import timezone
|
|
||||||
job.next_run = timezone.now()
|
|
||||||
|
|
||||||
job.save()
|
|
||||||
return Response(JobSerializer(job).data)
|
|
||||||
except Job.DoesNotExist:
|
|
||||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
@action(detail=False, methods=['post'])
|
|
||||||
def send_test_email(self, request):
|
|
||||||
try:
|
|
||||||
send_mail(
|
|
||||||
'Holy Flavors Admin Test Email',
|
|
||||||
f'This is a test email sent to {request.user.email} from the Holy Flavors Admin Interface.',
|
|
||||||
settings.DEFAULT_FROM_EMAIL,
|
|
||||||
[request.user.email],
|
|
||||||
fail_silently=False,
|
|
||||||
)
|
|
||||||
return Response({'status': 'Test email sent!'})
|
|
||||||
except Exception as e:
|
|
||||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
||||||
|
|
||||||
@action(detail=False, methods=['get'])
|
|
||||||
def users(self, request):
|
|
||||||
users = User.objects.all().prefetch_related('ips').order_by('-date_joined')
|
|
||||||
return Response(AdminUserListSerializer(users, many=True).data)
|
|
||||||
|
|
||||||
@action(detail=True, methods=['get', 'patch', 'delete'])
|
|
||||||
def user_detail(self, request, pk=None):
|
|
||||||
try:
|
|
||||||
user = User.objects.get(pk=pk)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
if request.method == 'GET':
|
|
||||||
return Response(AdminUserDetailSerializer(user, context={'request': request}).data)
|
|
||||||
elif request.method == 'PATCH':
|
|
||||||
is_active = request.data.get('is_active')
|
|
||||||
if is_active is not None:
|
|
||||||
user.is_active = is_active
|
|
||||||
user.save()
|
|
||||||
return Response(AdminUserDetailSerializer(user, context={'request': request}).data)
|
|
||||||
elif request.method == 'DELETE':
|
|
||||||
user.delete()
|
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from api.views._pagination import FeedPagination
|
||||||
|
from api.views.admin import AdminViewSet
|
||||||
|
from api.views.banner import BannerViewSet
|
||||||
|
from api.views.flavor import CategoryViewSet, FlavorViewSet
|
||||||
|
from api.views.rating import RatingViewSet, ReplyViewSet
|
||||||
|
from api.views.support import TicketViewSet
|
||||||
|
from api.views.user import NotificationViewSet, UserViewSet
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AdminViewSet",
|
||||||
|
"BannerViewSet",
|
||||||
|
"CategoryViewSet",
|
||||||
|
"FeedPagination",
|
||||||
|
"FlavorViewSet",
|
||||||
|
"NotificationViewSet",
|
||||||
|
"RatingViewSet",
|
||||||
|
"ReplyViewSet",
|
||||||
|
"TicketViewSet",
|
||||||
|
"UserViewSet",
|
||||||
|
]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from rest_framework.pagination import PageNumberPagination
|
||||||
|
|
||||||
|
|
||||||
|
class FeedPagination(PageNumberPagination):
|
||||||
|
page_size = 10
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from rest_framework import permissions, status, viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.request import Request
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from api.models import Job, Rating, Reply, SystemConfig, Ticket, User
|
||||||
|
from api.serializers import (
|
||||||
|
AdminUserDetailSerializer,
|
||||||
|
AdminUserListSerializer,
|
||||||
|
JobSerializer,
|
||||||
|
SystemConfigSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminViewSet(viewsets.ViewSet):
|
||||||
|
permission_classes = [permissions.IsAdminUser]
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"])
|
||||||
|
def stats(self, request: Request) -> Response:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"total_users": User.objects.count(),
|
||||||
|
"total_ratings": Rating.objects.count(),
|
||||||
|
"total_replies": Reply.objects.count(),
|
||||||
|
"open_tickets": Ticket.objects.filter(status="open").count(),
|
||||||
|
"email_config": {
|
||||||
|
"host": getattr(settings, "EMAIL_HOST", "None"),
|
||||||
|
"port": getattr(settings, "EMAIL_PORT", "None"),
|
||||||
|
"use_tls": getattr(settings, "EMAIL_USE_TLS", False),
|
||||||
|
"use_ssl": getattr(settings, "EMAIL_USE_SSL", False),
|
||||||
|
"skip_verify": getattr(settings, "EMAIL_SKIP_CERT_VERIFICATION", False),
|
||||||
|
},
|
||||||
|
"server_info": {
|
||||||
|
"debug": settings.DEBUG,
|
||||||
|
"allowed_hosts": settings.ALLOWED_HOSTS,
|
||||||
|
"frontend_url": getattr(settings, "FRONTEND_URL", "Not set"),
|
||||||
|
"media_root": str(settings.MEDIA_ROOT),
|
||||||
|
"static_root": str(settings.STATIC_ROOT),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get", "patch"])
|
||||||
|
def config(self, request: Request) -> Response:
|
||||||
|
config = SystemConfig.get_solo()
|
||||||
|
if request.method == "PATCH":
|
||||||
|
serializer = SystemConfigSerializer(config, data=request.data, partial=True)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
serializer.save()
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
return Response(SystemConfigSerializer(config).data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"])
|
||||||
|
def jobs(self, request: Request) -> Response:
|
||||||
|
for job_type, _ in Job.JOB_TYPES:
|
||||||
|
Job.objects.get_or_create(name=job_type)
|
||||||
|
|
||||||
|
jobs = Job.objects.all().order_by("name")
|
||||||
|
return Response(JobSerializer(jobs, many=True).data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"])
|
||||||
|
def trigger_job(self, request: Request, pk=None) -> Response:
|
||||||
|
try:
|
||||||
|
job = Job.objects.get(pk=pk)
|
||||||
|
job.status = "pending"
|
||||||
|
job.save()
|
||||||
|
return Response({"status": "Job queued"})
|
||||||
|
except Job.DoesNotExist:
|
||||||
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["patch"])
|
||||||
|
def update_job_schedule(self, request: Request, pk=None) -> Response:
|
||||||
|
try:
|
||||||
|
job = Job.objects.get(pk=pk)
|
||||||
|
interval = request.data.get("interval_hours")
|
||||||
|
next_run = request.data.get("next_run")
|
||||||
|
|
||||||
|
if interval is not None:
|
||||||
|
job.interval_hours = int(interval)
|
||||||
|
|
||||||
|
if next_run is not None:
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.dateparse import parse_datetime
|
||||||
|
|
||||||
|
parsed_dt = parse_datetime(next_run)
|
||||||
|
if parsed_dt:
|
||||||
|
if timezone.is_naive(parsed_dt):
|
||||||
|
job.next_run = timezone.make_aware(
|
||||||
|
parsed_dt, timezone.get_current_timezone()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
job.next_run = parsed_dt
|
||||||
|
elif interval is not None and job.interval_hours > 0 and not job.next_run:
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
job.next_run = timezone.now()
|
||||||
|
|
||||||
|
job.save()
|
||||||
|
return Response(JobSerializer(job).data)
|
||||||
|
except Job.DoesNotExist:
|
||||||
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"])
|
||||||
|
def send_test_email(self, request: Request) -> Response:
|
||||||
|
try:
|
||||||
|
send_mail(
|
||||||
|
"Holy Flavors Admin Test Email",
|
||||||
|
(
|
||||||
|
f"This is a test email sent to {request.user.email} from the "
|
||||||
|
f"Holy Flavors Admin Interface."
|
||||||
|
),
|
||||||
|
settings.DEFAULT_FROM_EMAIL,
|
||||||
|
[request.user.email],
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
return Response({"status": "Test email sent!"})
|
||||||
|
except Exception as e:
|
||||||
|
return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"])
|
||||||
|
def users(self, request: Request) -> Response:
|
||||||
|
users = User.objects.all().prefetch_related("ips").order_by("-date_joined")
|
||||||
|
return Response(AdminUserListSerializer(users, many=True).data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["get", "patch", "delete"])
|
||||||
|
def user_detail(self, request: Request, pk=None) -> Response:
|
||||||
|
try:
|
||||||
|
user = User.objects.get(pk=pk)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
return Response(AdminUserDetailSerializer(user, context={"request": request}).data)
|
||||||
|
elif request.method == "PATCH":
|
||||||
|
is_active = request.data.get("is_active")
|
||||||
|
if is_active is not None:
|
||||||
|
user.is_active = is_active
|
||||||
|
user.save()
|
||||||
|
return Response(AdminUserDetailSerializer(user, context={"request": request}).data)
|
||||||
|
elif request.method == "DELETE":
|
||||||
|
user.delete()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from rest_framework import permissions, status, viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.request import Request
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from api.models import Banner, User
|
||||||
|
from api.serializers import BannerSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class BannerViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Banner.objects.all()
|
||||||
|
serializer_class = BannerSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||||
|
pagination_class = None
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
if self.request.user.is_authenticated and self.request.user.is_superuser:
|
||||||
|
return Banner.objects.all()
|
||||||
|
return Banner.objects.filter(is_enabled=True)
|
||||||
|
|
||||||
|
def get_permissions(self):
|
||||||
|
if self.action in ["list", "retrieve", "active"]:
|
||||||
|
return [permissions.AllowAny()]
|
||||||
|
return [permissions.IsAdminUser()]
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"])
|
||||||
|
def active(self, request: Request) -> Response:
|
||||||
|
username = request.query_params.get("username")
|
||||||
|
user = None
|
||||||
|
if username:
|
||||||
|
try:
|
||||||
|
user = User.objects.get(username=username)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not user and request.user.is_authenticated:
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
if user and user.selected_banner and user.selected_banner.is_enabled:
|
||||||
|
return Response(self.get_serializer(user.selected_banner).data)
|
||||||
|
|
||||||
|
active_banner = Banner.objects.filter(is_active=True).first()
|
||||||
|
if active_banner:
|
||||||
|
return Response(self.get_serializer(active_banner).data)
|
||||||
|
return Response(None)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"])
|
||||||
|
def activate(self, request: Request, pk=None) -> Response:
|
||||||
|
banner = self.get_object()
|
||||||
|
banner.is_active = True
|
||||||
|
banner.is_enabled = True
|
||||||
|
banner.save()
|
||||||
|
return Response({"status": "banner set as global default"})
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"])
|
||||||
|
def toggle_enabled(self, request: Request, pk=None) -> Response:
|
||||||
|
banner = self.get_object()
|
||||||
|
if banner.is_active:
|
||||||
|
return Response(
|
||||||
|
{"error": "Cannot disable the global default banner."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
banner.is_enabled = not banner.is_enabled
|
||||||
|
banner.save()
|
||||||
|
return Response({"status": "enabled" if banner.is_enabled else "disabled"})
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
from django.db.models import Avg
|
||||||
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
from rest_framework import filters, permissions, viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.request import Request
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from api.models import Category, Flavor
|
||||||
|
from api.serializers import CategorySerializer, FlavorSerializer
|
||||||
|
from api.services.search import (
|
||||||
|
extract_category_slug,
|
||||||
|
filter_flavors_by_query,
|
||||||
|
score_relevance,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
queryset = Category.objects.all()
|
||||||
|
serializer_class = CategorySerializer
|
||||||
|
|
||||||
|
|
||||||
|
class FlavorViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Flavor.objects.annotate(average_rating=Avg("ratings__score")).order_by(
|
||||||
|
"-average_rating"
|
||||||
|
)
|
||||||
|
serializer_class = FlavorSerializer
|
||||||
|
filter_backends = [DjangoFilterBackend, filters.SearchFilter]
|
||||||
|
filterset_fields = ["category", "category__slug"]
|
||||||
|
search_fields = ["name", "description"]
|
||||||
|
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = (
|
||||||
|
Flavor.objects.select_related("category")
|
||||||
|
.annotate(average_rating=Avg("ratings__score"))
|
||||||
|
.order_by("-average_rating")
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
search_query = self.request.query_params.get("search", "")
|
||||||
|
slug, _ = extract_category_slug(search_query)
|
||||||
|
if slug:
|
||||||
|
qs = qs.filter(category__slug=slug)
|
||||||
|
return qs
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"], permission_classes=[permissions.AllowAny])
|
||||||
|
def search(self, request: Request) -> Response:
|
||||||
|
query = request.query_params.get("q", "").lower().strip()
|
||||||
|
if not query:
|
||||||
|
return Response([])
|
||||||
|
|
||||||
|
flavors = Flavor.objects.select_related("category").distinct()
|
||||||
|
flavors = filter_flavors_by_query(flavors, query)
|
||||||
|
|
||||||
|
_, remaining = extract_category_slug(query)
|
||||||
|
seen_keys: set[str] = set()
|
||||||
|
results: list[dict] = []
|
||||||
|
|
||||||
|
for f in flavors.order_by("name"):
|
||||||
|
key = f"{f.name.strip().lower()}|{f.category.slug}"
|
||||||
|
if key in seen_keys:
|
||||||
|
continue
|
||||||
|
seen_keys.add(key)
|
||||||
|
results.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,
|
||||||
|
"relevance": score_relevance(f.name, remaining),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
results.sort(key=lambda x: (-x["relevance"], x["name"]))
|
||||||
|
for r in results:
|
||||||
|
del r["relevance"]
|
||||||
|
return Response(results[:15])
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"], permission_classes=[permissions.AllowAny])
|
||||||
|
def top(self, request: Request) -> Response:
|
||||||
|
queryset = self.get_queryset().filter(ratings__isnull=False)
|
||||||
|
category_slug = request.query_params.get("category")
|
||||||
|
if category_slug:
|
||||||
|
queryset = queryset.filter(category__slug=category_slug)
|
||||||
|
top_flavors = queryset.distinct()[:10]
|
||||||
|
return Response(self.get_serializer(top_flavors, many=True).data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"], permission_classes=[permissions.AllowAny])
|
||||||
|
def newest(self, request: Request) -> Response:
|
||||||
|
newest_flavors = Flavor.objects.select_related("category").order_by("-created_at")[:10]
|
||||||
|
return Response(self.get_serializer(newest_flavors, many=True).data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"], permission_classes=[permissions.IsAuthenticated])
|
||||||
|
def followed_top(self, request: Request) -> Response:
|
||||||
|
followed_users = request.user.following.all()
|
||||||
|
top_flavors = (
|
||||||
|
Flavor.objects.filter(ratings__user__in=followed_users)
|
||||||
|
.annotate(average_rating=Avg("ratings__score"))
|
||||||
|
.order_by("-average_rating")
|
||||||
|
.distinct()[:10]
|
||||||
|
)
|
||||||
|
return Response(self.get_serializer(top_flavors, many=True).data)
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django_ratelimit.decorators import ratelimit
|
||||||
|
from rest_framework import permissions, serializers, status, viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
from rest_framework.request import Request
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from api.models import Notification, Rating, Reply
|
||||||
|
from api.serializers import RatingSerializer, ReplySerializer
|
||||||
|
from api.services.mentions import parse_mentions
|
||||||
|
from api.views._pagination import FeedPagination
|
||||||
|
|
||||||
|
|
||||||
|
class RatingViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Rating.objects.all()
|
||||||
|
serializer_class = RatingSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||||
|
|
||||||
|
@method_decorator(ratelimit(key="user", rate="10/m", method="POST", block=True))
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
return super().create(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
flavor = serializer.validated_data["flavor"]
|
||||||
|
if Rating.objects.filter(user=self.request.user, flavor=flavor).exists():
|
||||||
|
raise serializers.ValidationError("You have already rated this flavor.")
|
||||||
|
rating = serializer.save(user=self.request.user)
|
||||||
|
if rating.comment:
|
||||||
|
parse_mentions(rating.comment, self.request.user, rating=rating)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return (
|
||||||
|
Rating.objects.select_related("user", "flavor", "flavor__category")
|
||||||
|
.prefetch_related("replies", "replies__user")
|
||||||
|
.order_by("-created_at")
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"], permission_classes=[permissions.IsAuthenticated])
|
||||||
|
def feed(self, request: Request) -> Response:
|
||||||
|
followed_users = request.user.following.all()
|
||||||
|
feed_ratings = (
|
||||||
|
Rating.objects.filter(user__in=followed_users)
|
||||||
|
.select_related("user", "flavor", "flavor__category")
|
||||||
|
.prefetch_related("replies", "replies__user")
|
||||||
|
.order_by("-created_at")
|
||||||
|
)
|
||||||
|
|
||||||
|
paginator = FeedPagination()
|
||||||
|
page = paginator.paginate_queryset(feed_ratings, request)
|
||||||
|
if page is not None:
|
||||||
|
serializer = self.get_serializer(page, many=True)
|
||||||
|
return paginator.get_paginated_response(serializer.data)
|
||||||
|
|
||||||
|
return Response(self.get_serializer(feed_ratings, many=True).data)
|
||||||
|
|
||||||
|
def check_object_permissions(self, request, obj):
|
||||||
|
super().check_object_permissions(request, obj)
|
||||||
|
if (
|
||||||
|
request.method in ["PUT", "PATCH", "DELETE"]
|
||||||
|
and obj.user != request.user
|
||||||
|
and not request.user.is_superuser
|
||||||
|
):
|
||||||
|
self.permission_denied(request, message="You cannot edit/delete this rating.")
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"], permission_classes=[permissions.AllowAny])
|
||||||
|
def recent(self, request: Request) -> Response:
|
||||||
|
recent_ratings = (
|
||||||
|
Rating.objects.filter(comment__isnull=False)
|
||||||
|
.exclude(comment="")
|
||||||
|
.select_related("user", "flavor")
|
||||||
|
.order_by("-created_at")[:10]
|
||||||
|
)
|
||||||
|
return Response(self.get_serializer(recent_ratings, many=True).data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAuthenticated])
|
||||||
|
def reply(self, request: Request, pk=None) -> Response:
|
||||||
|
rating = self.get_object()
|
||||||
|
text = request.data.get("text")
|
||||||
|
if not text:
|
||||||
|
return Response({"error": "Text is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
reply = Reply.objects.create(user=request.user, rating=rating, text=text)
|
||||||
|
|
||||||
|
if rating.user != request.user:
|
||||||
|
Notification.objects.create(
|
||||||
|
recipient=rating.user,
|
||||||
|
actor=request.user,
|
||||||
|
notification_type="reply",
|
||||||
|
rating=rating,
|
||||||
|
reply=reply,
|
||||||
|
)
|
||||||
|
|
||||||
|
parse_mentions(text, request.user, rating=rating, reply=reply)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
ReplySerializer(reply, context={"request": request}).data,
|
||||||
|
status=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReplyViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Reply.objects.all()
|
||||||
|
serializer_class = ReplySerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Reply.objects.select_related("user", "rating").order_by("created_at")
|
||||||
|
|
||||||
|
def perform_update(self, serializer):
|
||||||
|
reply = self.get_object()
|
||||||
|
if reply.user != self.request.user and not self.request.user.is_superuser:
|
||||||
|
raise PermissionDenied("You cannot edit this reply.")
|
||||||
|
new_reply = serializer.save()
|
||||||
|
parse_mentions(new_reply.text, self.request.user, rating=new_reply.rating, reply=new_reply)
|
||||||
|
|
||||||
|
def perform_destroy(self, instance):
|
||||||
|
if instance.user != self.request.user and not self.request.user.is_superuser:
|
||||||
|
raise PermissionDenied("You cannot delete this reply.")
|
||||||
|
instance.delete()
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
from rest_framework import permissions, status, viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
from rest_framework.request import Request
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from api.models import Notification, Ticket, TicketMessage, User
|
||||||
|
from api.serializers import TicketMessageSerializer, TicketSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class TicketViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = TicketSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
if self.request.user.is_superuser:
|
||||||
|
return (
|
||||||
|
Ticket.objects.all()
|
||||||
|
.prefetch_related("messages", "messages__user")
|
||||||
|
.order_by("-updated_at")
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
Ticket.objects.filter(user=self.request.user)
|
||||||
|
.prefetch_related("messages", "messages__user")
|
||||||
|
.order_by("-updated_at")
|
||||||
|
)
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
ticket = serializer.save(user=self.request.user)
|
||||||
|
admins = User.objects.filter(is_superuser=True).exclude(pk=self.request.user.pk)
|
||||||
|
for admin in admins:
|
||||||
|
Notification.objects.create(
|
||||||
|
recipient=admin,
|
||||||
|
actor=self.request.user,
|
||||||
|
notification_type="ticket_new",
|
||||||
|
ticket=ticket,
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"])
|
||||||
|
def add_message(self, request: Request, pk=None) -> Response:
|
||||||
|
ticket = self.get_object()
|
||||||
|
text = request.data.get("text")
|
||||||
|
if not text:
|
||||||
|
return Response({"error": "Text is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
msg = TicketMessage.objects.create(ticket=ticket, user=request.user, text=text)
|
||||||
|
|
||||||
|
if request.user.is_superuser:
|
||||||
|
if ticket.user != request.user:
|
||||||
|
Notification.objects.create(
|
||||||
|
recipient=ticket.user,
|
||||||
|
actor=request.user,
|
||||||
|
notification_type="ticket_reply",
|
||||||
|
ticket=ticket,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
admins = User.objects.filter(is_superuser=True).exclude(pk=request.user.pk)
|
||||||
|
for admin in admins:
|
||||||
|
Notification.objects.create(
|
||||||
|
recipient=admin,
|
||||||
|
actor=request.user,
|
||||||
|
notification_type="ticket_reply",
|
||||||
|
ticket=ticket,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(TicketMessageSerializer(msg).data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAdminUser])
|
||||||
|
def update_status(self, request: Request, pk=None) -> Response:
|
||||||
|
ticket = self.get_object()
|
||||||
|
new_status = request.data.get("status")
|
||||||
|
if new_status in dict(Ticket.STATUS_CHOICES):
|
||||||
|
ticket.status = new_status
|
||||||
|
ticket.save()
|
||||||
|
return Response({"status": "updated"})
|
||||||
|
return Response({"error": "Invalid status"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
if not request.user.is_superuser:
|
||||||
|
raise PermissionDenied("Only admins can delete tickets.")
|
||||||
|
return super().destroy(request, *args, **kwargs)
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django_ratelimit.decorators import ratelimit
|
||||||
|
from rest_framework import permissions, status, viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.request import Request
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from api.models import Banner, Flavor, Notification, ProfileComment, User
|
||||||
|
from api.serializers import (
|
||||||
|
FlavorSerializer,
|
||||||
|
NotificationSerializer,
|
||||||
|
ProfileCommentSerializer,
|
||||||
|
RatingSerializer,
|
||||||
|
UserSerializer,
|
||||||
|
)
|
||||||
|
from api.services.ip_logging import log_user_ip
|
||||||
|
from api.tasks import send_email_task
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_code() -> str:
|
||||||
|
return "".join(secrets.choice(string.digits) for _ in range(6))
|
||||||
|
|
||||||
|
|
||||||
|
class UserViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = User.objects.all()
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_permissions(self):
|
||||||
|
if self.action in [
|
||||||
|
"signup",
|
||||||
|
"verify_signup",
|
||||||
|
"request_password_reset",
|
||||||
|
"complete_password_reset",
|
||||||
|
]:
|
||||||
|
return [permissions.AllowAny()]
|
||||||
|
return super().get_permissions()
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"], permission_classes=[permissions.IsAuthenticated])
|
||||||
|
def following_list(self, request: Request) -> Response:
|
||||||
|
following = request.user.following.all()
|
||||||
|
return Response(self.get_serializer(following, many=True).data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"], permission_classes=[permissions.AllowAny])
|
||||||
|
@method_decorator(ratelimit(key="ip", rate="5/h", method="POST", block=True))
|
||||||
|
def signup(self, request: Request) -> Response:
|
||||||
|
username = request.data.get("username")
|
||||||
|
email = request.data.get("email")
|
||||||
|
password = request.data.get("password")
|
||||||
|
if not username or not email or not password:
|
||||||
|
return Response(
|
||||||
|
{"error": "Username, email and password required"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
if User.objects.filter(username=username).exists():
|
||||||
|
return Response(
|
||||||
|
{"error": "Username already exists"}, status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
if User.objects.filter(email=email).exists():
|
||||||
|
return Response({"error": "Email already exists"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
code = _generate_code()
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username=username,
|
||||||
|
email=email,
|
||||||
|
password=password,
|
||||||
|
is_active=False,
|
||||||
|
email_confirmation_code=code,
|
||||||
|
)
|
||||||
|
|
||||||
|
verification_link = f"{settings.FRONTEND_URL}/verify-email?username={username}&code={code}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_email_task.delay(
|
||||||
|
subject="Verify your Holy Flavors account",
|
||||||
|
message=(
|
||||||
|
f"Hi {username},\n\nYour verification code is: {code}\n\n"
|
||||||
|
f"Alternatively, you can complete your registration by clicking the link below:\n"
|
||||||
|
f"{verification_link}\n\nWelcome to the archive!"
|
||||||
|
),
|
||||||
|
recipient_list=[email],
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
user.delete()
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"error": (
|
||||||
|
f"Failed to send verification email. Please check your SMTP settings "
|
||||||
|
f"in the .env file. The server error was: {e}"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"status": "User created, please verify your email",
|
||||||
|
"username": username,
|
||||||
|
"email": email,
|
||||||
|
},
|
||||||
|
status=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"], permission_classes=[permissions.AllowAny])
|
||||||
|
def resend_verification(self, request: Request) -> Response:
|
||||||
|
username = request.data.get("username")
|
||||||
|
if not username:
|
||||||
|
return Response({"error": "Username required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = User.objects.get(username=username, is_active=False)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "User not found or already active"},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
code = _generate_code()
|
||||||
|
user.email_confirmation_code = code
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
verification_link = (
|
||||||
|
f"{settings.FRONTEND_URL}/verify-email?username={user.username}&code={code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_email_task.delay(
|
||||||
|
subject="Verify your Holy Flavors account",
|
||||||
|
message=(
|
||||||
|
f"Hi {user.username},\n\nYour new verification code is: {code}\n\n"
|
||||||
|
f"Alternatively, you can complete your registration by clicking the link below:\n"
|
||||||
|
f"{verification_link}"
|
||||||
|
),
|
||||||
|
recipient_list=[user.email],
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return Response(
|
||||||
|
{"error": f"Failed to send email: {e}"},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({"status": "Verification code resent!"})
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"], permission_classes=[permissions.AllowAny])
|
||||||
|
def verify_signup(self, request: Request) -> Response:
|
||||||
|
username = request.data.get("username")
|
||||||
|
code = request.data.get("code")
|
||||||
|
try:
|
||||||
|
user = User.objects.get(username=username, is_active=False)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "User not found or already active"},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
if user.email_confirmation_code == code:
|
||||||
|
user.is_active = True
|
||||||
|
user.email_confirmation_code = None
|
||||||
|
user.save()
|
||||||
|
return Response({"status": "Account verified successfully!"})
|
||||||
|
return Response({"error": "Invalid verification code"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
@action(
|
||||||
|
detail=False,
|
||||||
|
methods=["get"],
|
||||||
|
permission_classes=[permissions.AllowAny],
|
||||||
|
url_path="profile/(?P<username>[^/.]+)",
|
||||||
|
)
|
||||||
|
def public_profile(self, request: Request, username=None) -> Response:
|
||||||
|
try:
|
||||||
|
user = User.objects.get(username=username)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return Response({"error": "User not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
ratings = user.ratings.select_related("flavor", "flavor__category").order_by("-score")
|
||||||
|
comments = user.profile_comments.select_related("author").order_by("-created_at")
|
||||||
|
|
||||||
|
followers = user.followers.all()
|
||||||
|
following = user.following.all()
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"theme": user.theme,
|
||||||
|
"avatar": (request.build_absolute_uri(user.avatar.url) if user.avatar else None),
|
||||||
|
"following_count": following.count(),
|
||||||
|
"followers_count": followers.count(),
|
||||||
|
"is_following": (
|
||||||
|
request.user.following.filter(pk=user.pk).exists()
|
||||||
|
if request.user.is_authenticated
|
||||||
|
else False
|
||||||
|
),
|
||||||
|
"ratings": RatingSerializer(ratings, many=True, context={"request": request}).data,
|
||||||
|
"comments": ProfileCommentSerializer(
|
||||||
|
comments, many=True, context={"request": request}
|
||||||
|
).data,
|
||||||
|
"followers": UserSerializer(
|
||||||
|
followers, many=True, context={"request": request}
|
||||||
|
).data,
|
||||||
|
"following": UserSerializer(
|
||||||
|
following, many=True, context={"request": request}
|
||||||
|
).data,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAuthenticated])
|
||||||
|
def add_comment(self, request: Request, pk=None) -> Response:
|
||||||
|
profile_owner = self.get_object()
|
||||||
|
text = request.data.get("text")
|
||||||
|
if not text:
|
||||||
|
return Response({"error": "Text is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
comment = ProfileComment.objects.create(
|
||||||
|
profile_owner=profile_owner,
|
||||||
|
author=request.user,
|
||||||
|
text=text,
|
||||||
|
)
|
||||||
|
|
||||||
|
if profile_owner != request.user:
|
||||||
|
Notification.objects.create(
|
||||||
|
recipient=profile_owner,
|
||||||
|
actor=request.user,
|
||||||
|
notification_type="profile_comment",
|
||||||
|
profile_comment=comment,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
ProfileCommentSerializer(comment, context={"request": request}).data,
|
||||||
|
status=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(
|
||||||
|
detail=True,
|
||||||
|
methods=["delete"],
|
||||||
|
url_path="delete_comment/(?P<comment_id>[^/.]+)",
|
||||||
|
permission_classes=[permissions.IsAuthenticated],
|
||||||
|
)
|
||||||
|
def delete_comment(self, request: Request, pk=None, comment_id=None) -> Response:
|
||||||
|
profile_owner = self.get_object()
|
||||||
|
try:
|
||||||
|
comment = ProfileComment.objects.get(pk=comment_id, profile_owner=profile_owner)
|
||||||
|
except ProfileComment.DoesNotExist:
|
||||||
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
if (
|
||||||
|
comment.author != request.user
|
||||||
|
and not request.user.is_superuser
|
||||||
|
and profile_owner != request.user
|
||||||
|
):
|
||||||
|
return Response(status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
comment.delete()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"])
|
||||||
|
def me(self, request: Request) -> Response:
|
||||||
|
log_user_ip(request.user, request)
|
||||||
|
return Response(self.get_serializer(request.user).data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["patch"])
|
||||||
|
def update_preferences(self, request: Request) -> Response:
|
||||||
|
user = request.user
|
||||||
|
theme = request.data.get("theme")
|
||||||
|
language = request.data.get("language")
|
||||||
|
banner_id = request.data.get("selected_banner")
|
||||||
|
|
||||||
|
updated = False
|
||||||
|
if theme in dict(User.THEME_CHOICES):
|
||||||
|
user.theme = theme
|
||||||
|
updated = True
|
||||||
|
if language in dict(User.LANGUAGE_CHOICES):
|
||||||
|
user.language = language
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if banner_id is not None:
|
||||||
|
if banner_id == "":
|
||||||
|
user.selected_banner = None
|
||||||
|
updated = True
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
banner = Banner.objects.get(pk=banner_id, is_enabled=True)
|
||||||
|
user.selected_banner = banner
|
||||||
|
updated = True
|
||||||
|
except Banner.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "Invalid or disabled banner"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
user.save()
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"status": "preferences updated",
|
||||||
|
"theme": user.theme,
|
||||||
|
"language": user.language,
|
||||||
|
"selected_banner": (user.selected_banner.id if user.selected_banner else None),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return Response({"error": "No valid updates provided"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"])
|
||||||
|
def update_avatar(self, request: Request) -> Response:
|
||||||
|
user = request.user
|
||||||
|
avatar = request.FILES.get("avatar")
|
||||||
|
if not avatar:
|
||||||
|
return Response({"error": "No avatar provided"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
if avatar.size > 2 * 1024 * 1024:
|
||||||
|
return Response(
|
||||||
|
{"error": "File size exceeds 2MB limit"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
user.avatar = avatar
|
||||||
|
user.save()
|
||||||
|
return Response(self.get_serializer(user).data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"])
|
||||||
|
def change_password(self, request: Request) -> Response:
|
||||||
|
user = request.user
|
||||||
|
old_password = request.data.get("old_password")
|
||||||
|
new_password = request.data.get("new_password")
|
||||||
|
if not user.check_password(old_password):
|
||||||
|
return Response({"error": "Wrong old password"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
user.set_password(new_password)
|
||||||
|
user.save()
|
||||||
|
return Response({"status": "password changed"})
|
||||||
|
|
||||||
|
@action(detail=False, methods=["patch"])
|
||||||
|
def update_profile(self, request: Request) -> Response:
|
||||||
|
user = request.user
|
||||||
|
username = request.data.get("username")
|
||||||
|
email = request.data.get("email")
|
||||||
|
|
||||||
|
message = ""
|
||||||
|
if username and username != user.username:
|
||||||
|
if User.objects.filter(username=username).exists():
|
||||||
|
return Response(
|
||||||
|
{"error": "Username already taken"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
user.username = username
|
||||||
|
|
||||||
|
if email and email != user.email:
|
||||||
|
code = _generate_code()
|
||||||
|
user.pending_email = email
|
||||||
|
user.email_confirmation_code = code
|
||||||
|
send_email_task.delay(
|
||||||
|
subject="Confirm your new email",
|
||||||
|
message=f"Hi {user.username},\n\nYour confirmation code is: {code}",
|
||||||
|
recipient_list=[email],
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
)
|
||||||
|
message = " Confirmation code sent to new email."
|
||||||
|
|
||||||
|
user.save()
|
||||||
|
data = self.get_serializer(user).data
|
||||||
|
if message:
|
||||||
|
data["message"] = message
|
||||||
|
return Response(data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAuthenticated])
|
||||||
|
def follow(self, request: Request, pk=None) -> Response:
|
||||||
|
user_to_follow = self.get_object()
|
||||||
|
if user_to_follow == request.user:
|
||||||
|
return Response(
|
||||||
|
{"error": "You cannot follow yourself"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not request.user.following.filter(pk=user_to_follow.pk).exists():
|
||||||
|
request.user.following.add(user_to_follow)
|
||||||
|
Notification.objects.create(
|
||||||
|
recipient=user_to_follow,
|
||||||
|
actor=request.user,
|
||||||
|
notification_type="follow",
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({"status": "followed"})
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAuthenticated])
|
||||||
|
def unfollow(self, request: Request, pk=None) -> Response:
|
||||||
|
user_to_unfollow = self.get_object()
|
||||||
|
request.user.following.remove(user_to_unfollow)
|
||||||
|
return Response({"status": "unfollowed"})
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"])
|
||||||
|
def confirm_email(self, request: Request) -> Response:
|
||||||
|
user = request.user
|
||||||
|
code = request.data.get("code")
|
||||||
|
if not user.email_confirmation_code or code != user.email_confirmation_code:
|
||||||
|
return Response(
|
||||||
|
{"error": "Invalid or expired code"}, status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
user.email = user.pending_email
|
||||||
|
user.pending_email = None
|
||||||
|
user.email_confirmation_code = None
|
||||||
|
user.save()
|
||||||
|
return Response({"status": "Email confirmed", "email": user.email})
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"], permission_classes=[permissions.AllowAny])
|
||||||
|
@method_decorator(ratelimit(key="ip", rate="3/h", method="POST", block=True))
|
||||||
|
def request_password_reset(self, request: Request) -> Response:
|
||||||
|
email = request.data.get("email")
|
||||||
|
try:
|
||||||
|
user = User.objects.get(email=email, is_active=True)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"status": "If an account exists with this email, a reset code has been sent."}
|
||||||
|
)
|
||||||
|
|
||||||
|
code = _generate_code()
|
||||||
|
user.email_confirmation_code = code
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
send_email_task.delay(
|
||||||
|
subject="Password Reset Request",
|
||||||
|
message=f"Hi {user.username},\n\nYour password reset code is: {code}",
|
||||||
|
recipient_list=[email],
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
{"status": "If an account exists with this email, a reset code has been sent."}
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"], permission_classes=[permissions.AllowAny])
|
||||||
|
@method_decorator(ratelimit(key="ip", rate="5/h", method="POST", block=True))
|
||||||
|
def complete_password_reset(self, request: Request) -> Response:
|
||||||
|
email = request.data.get("email")
|
||||||
|
code = request.data.get("code")
|
||||||
|
new_password = request.data.get("password")
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = User.objects.get(email=email, email_confirmation_code=code)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return Response({"error": "Invalid email or code"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
user.set_password(new_password)
|
||||||
|
user.email_confirmation_code = None
|
||||||
|
user.save()
|
||||||
|
return Response({"status": "Password reset successful!"})
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"])
|
||||||
|
@method_decorator(ratelimit(key="user", rate="3/h", method="POST", block=True))
|
||||||
|
def request_account_deletion(self, request: Request) -> Response:
|
||||||
|
code = _generate_code()
|
||||||
|
user = request.user
|
||||||
|
user.email_confirmation_code = code
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
send_email_task.delay(
|
||||||
|
subject="Confirm Account Deletion - Holy Flavors Archive",
|
||||||
|
message=(
|
||||||
|
f"Hi {user.username},\n\nYou requested to delete your account. "
|
||||||
|
f"This action is permanent.\n\nYour deletion code is: {code}"
|
||||||
|
),
|
||||||
|
recipient_list=[user.email],
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
)
|
||||||
|
return Response({"status": "Code sent to your email"})
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"])
|
||||||
|
def confirm_account_deletion(self, request: Request) -> Response:
|
||||||
|
user = request.user
|
||||||
|
code = request.data.get("code")
|
||||||
|
if not user.email_confirmation_code or code != user.email_confirmation_code:
|
||||||
|
return Response(
|
||||||
|
{"error": "Invalid or expired code"}, status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
user.delete()
|
||||||
|
return Response({"status": "Account deleted"})
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"])
|
||||||
|
def dashboard(self, request: Request) -> Response:
|
||||||
|
from django.db.models import Avg, Q
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
rated_ids = user.ratings.values_list("flavor_id", flat=True)
|
||||||
|
followed_users = user.following.all()
|
||||||
|
|
||||||
|
missing_flavors = (
|
||||||
|
Flavor.objects.exclude(id__in=rated_ids)
|
||||||
|
.exclude(category__name="Packs and other")
|
||||||
|
.select_related("category")
|
||||||
|
.annotate(
|
||||||
|
average_rating=Avg("ratings__score"),
|
||||||
|
followed_average_rating=Avg(
|
||||||
|
"ratings__score", filter=Q(ratings__user__in=followed_users)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.order_by("category__name", "name")
|
||||||
|
)
|
||||||
|
|
||||||
|
rated_flavors = (
|
||||||
|
user.ratings.select_related("flavor", "flavor__category")
|
||||||
|
.prefetch_related("replies", "replies__user")
|
||||||
|
.order_by("-created_at")
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"user": UserSerializer(user).data,
|
||||||
|
"rated_count": rated_flavors.count(),
|
||||||
|
"missing_count": missing_flavors.count(),
|
||||||
|
"missing_flavors": FlavorSerializer(
|
||||||
|
missing_flavors, many=True, context={"request": request}
|
||||||
|
).data,
|
||||||
|
"my_ratings": RatingSerializer(
|
||||||
|
rated_flavors, many=True, context={"request": request}
|
||||||
|
).data,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
serializer_class = NotificationSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
pagination_class = None
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return self.request.user.notifications.select_related(
|
||||||
|
"actor", "rating__flavor", "reply__rating__flavor"
|
||||||
|
).order_by("-created_at")
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"])
|
||||||
|
def mark_all_read(self, request: Request) -> Response:
|
||||||
|
request.user.notifications.filter(is_read=False).update(is_read=True)
|
||||||
|
return Response({"status": "all read"})
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"])
|
||||||
|
def mark_read(self, request: Request, pk=None) -> Response:
|
||||||
|
notification = self.get_object()
|
||||||
|
notification.is_read = True
|
||||||
|
notification.save()
|
||||||
|
return Response({"status": "marked read"})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from holy_backend.celery import app as celery_app
|
||||||
|
|
||||||
|
__all__ = ("celery_app",)
|
||||||
@@ -11,6 +11,6 @@ import os
|
|||||||
|
|
||||||
from django.core.asgi import get_asgi_application
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'holy_backend.settings')
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev")
|
||||||
|
|
||||||
application = get_asgi_application()
|
application = get_asgi_application()
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""Celery application for holy_backend.
|
||||||
|
|
||||||
|
Autodiscovers tasks from installed Django apps. Broker/backend configuration
|
||||||
|
is read from Django settings (CELERY_* namespace).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from celery import Celery
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev")
|
||||||
|
|
||||||
|
app = Celery("holy_backend")
|
||||||
|
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||||
|
app.autodiscover_tasks()
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
"""
|
|
||||||
Django settings for holy_backend project.
|
|
||||||
|
|
||||||
Generated by 'django-admin startproject' using Django 6.0.2.
|
|
||||||
|
|
||||||
For more information on this file, see
|
|
||||||
https://docs.djangoproject.com/en/6.0/topics/settings/
|
|
||||||
|
|
||||||
For the full list of settings and their values, see
|
|
||||||
https://docs.djangoproject.com/en/6.0/ref/settings/
|
|
||||||
"""
|
|
||||||
from pathlib import Path
|
|
||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
|
||||||
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
|
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
|
||||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'django-insecure-zos99ue2a89!4jrv4g@_b4nn4z!5o*@8k!5cr9!u$j%_@xb=l8')
|
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
|
||||||
DEBUG = os.environ.get('DEBUG', 'false').lower() == 'true'
|
|
||||||
|
|
||||||
# Simplification: Use a single DOMAIN variable to derive multiple settings
|
|
||||||
DOMAIN = os.environ.get('DOMAIN', 'localhost')
|
|
||||||
|
|
||||||
# Helper to build lists from env or defaults
|
|
||||||
def get_env_list(var_name, default_list):
|
|
||||||
val = os.environ.get(var_name)
|
|
||||||
if val:
|
|
||||||
return [item.strip() for item in val.split(',')]
|
|
||||||
return default_list
|
|
||||||
|
|
||||||
ALLOWED_HOSTS = get_env_list('ALLOWED_HOSTS', ['localhost', '127.0.0.1', 'backend', 'worker', DOMAIN])
|
|
||||||
|
|
||||||
|
|
||||||
# Application definition
|
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
|
||||||
'whitenoise.runserver_nostatic',
|
|
||||||
'django.contrib.admin',
|
|
||||||
'django.contrib.auth',
|
|
||||||
'django.contrib.contenttypes',
|
|
||||||
'django.contrib.sessions',
|
|
||||||
'django.contrib.messages',
|
|
||||||
'django.contrib.staticfiles',
|
|
||||||
# Third party
|
|
||||||
'rest_framework',
|
|
||||||
'rest_framework.authtoken',
|
|
||||||
'corsheaders',
|
|
||||||
'django_filters',
|
|
||||||
'drf_spectacular',
|
|
||||||
'django_ratelimit',
|
|
||||||
# Local
|
|
||||||
'api',
|
|
||||||
]
|
|
||||||
|
|
||||||
MIDDLEWARE = [
|
|
||||||
'django.middleware.security.SecurityMiddleware',
|
|
||||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
|
||||||
'compression_middleware.middleware.CompressionMiddleware',
|
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
|
||||||
'corsheaders.middleware.CorsMiddleware',
|
|
||||||
'django.middleware.common.CommonMiddleware',
|
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
|
||||||
]
|
|
||||||
|
|
||||||
ROOT_URLCONF = 'holy_backend.urls'
|
|
||||||
|
|
||||||
TEMPLATES = [
|
|
||||||
{
|
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
|
||||||
'DIRS': [],
|
|
||||||
'APP_DIRS': True,
|
|
||||||
'OPTIONS': {
|
|
||||||
'context_processors': [
|
|
||||||
'django.template.context_processors.request',
|
|
||||||
'django.contrib.auth.context_processors.auth',
|
|
||||||
'django.contrib.messages.context_processors.messages',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
WSGI_APPLICATION = 'holy_backend.wsgi.application'
|
|
||||||
|
|
||||||
|
|
||||||
# Database
|
|
||||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
|
||||||
|
|
||||||
DATABASES = {
|
|
||||||
'default': {
|
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
|
||||||
'NAME': BASE_DIR / 'db.sqlite3',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Cache configuration for Ratelimiting
|
|
||||||
CACHES = {
|
|
||||||
'default': {
|
|
||||||
'BACKEND': 'django.core.cache.backends.db.DatabaseCache',
|
|
||||||
'LOCATION': 'django_ratelimit_cache',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Silence ratelimit warnings for non-Redis/Memcached backends
|
|
||||||
SILENCED_SYSTEM_CHECKS = ['django_ratelimit.W001', 'django_ratelimit.E003']
|
|
||||||
|
|
||||||
|
|
||||||
# Password validation
|
|
||||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
|
||||||
{
|
|
||||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# Internationalization
|
|
||||||
# https://docs.djangoproject.com/en/6.0/topics/i18n/
|
|
||||||
|
|
||||||
LANGUAGE_CODE = 'en-us'
|
|
||||||
|
|
||||||
TIME_ZONE = 'UTC'
|
|
||||||
|
|
||||||
USE_I18N = True
|
|
||||||
|
|
||||||
USE_TZ = True
|
|
||||||
|
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
|
||||||
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
|
||||||
|
|
||||||
STATIC_URL = 'static/'
|
|
||||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
|
||||||
STATICFILES_STORAGE = 'whitenoise.storage.CompressedStaticFilesStorage'
|
|
||||||
WHITENOISE_INDEX_PAGE = True
|
|
||||||
|
|
||||||
MEDIA_URL = 'media/'
|
|
||||||
MEDIA_ROOT = BASE_DIR / 'media'
|
|
||||||
|
|
||||||
# --- Reverse Proxy Configuration ---
|
|
||||||
# These settings are crucial for running behind a proxy like Traefik
|
|
||||||
USE_X_FORWARDED_HOST = True
|
|
||||||
USE_X_FORWARDED_PORT = True
|
|
||||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
|
||||||
|
|
||||||
# Production Security
|
|
||||||
if not DEBUG:
|
|
||||||
SECURE_SSL_REDIRECT = True
|
|
||||||
SESSION_COOKIE_SECURE = True
|
|
||||||
CSRF_COOKIE_SECURE = True
|
|
||||||
SECURE_BROWSER_XSS_FILTER = True
|
|
||||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
|
||||||
SECURE_HSTS_SECONDS = 31536000 # 1 year
|
|
||||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
|
||||||
SECURE_HSTS_PRELOAD = True
|
|
||||||
|
|
||||||
# Read CSRF trusted origins from environment variable
|
|
||||||
CSRF_TRUSTED_ORIGINS = get_env_list('CSRF_TRUSTED_ORIGINS', [f'https://{DOMAIN}', 'http://localhost:5173'])
|
|
||||||
|
|
||||||
# Custom User Model
|
|
||||||
AUTH_USER_MODEL = 'api.User'
|
|
||||||
|
|
||||||
# REST Framework
|
|
||||||
REST_FRAMEWORK = {
|
|
||||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
|
||||||
'rest_framework.authentication.TokenAuthentication',
|
|
||||||
'rest_framework.authentication.SessionAuthentication',
|
|
||||||
],
|
|
||||||
'DEFAULT_PERMISSION_CLASSES': [
|
|
||||||
'rest_framework.permissions.IsAuthenticatedOrReadOnly',
|
|
||||||
],
|
|
||||||
'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'],
|
|
||||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
|
||||||
'PAGE_SIZE': 50,
|
|
||||||
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
|
||||||
}
|
|
||||||
|
|
||||||
# CORS
|
|
||||||
CORS_ALLOWED_ORIGINS = get_env_list('CORS_ALLOWED_ORIGINS', [f'https://{DOMAIN}', 'http://localhost:5173', 'http://127.0.0.1:5173'])
|
|
||||||
|
|
||||||
FRONTEND_URL = os.environ.get('FRONTEND_URL', f'https://{DOMAIN}').rstrip('/')
|
|
||||||
|
|
||||||
import os
|
|
||||||
# ... (near top of file)
|
|
||||||
|
|
||||||
# Email (SMTP)
|
|
||||||
# For production, set these via environment variables.
|
|
||||||
# For development, it will default to printing emails to the console.
|
|
||||||
EMAIL_HOST = os.environ.get('EMAIL_HOST')
|
|
||||||
EMAIL_PORT = int(os.environ.get('EMAIL_PORT', 587))
|
|
||||||
# Default to False unless explicitly set to 'true' in the .env file
|
|
||||||
EMAIL_USE_TLS = os.environ.get('EMAIL_USE_TLS', 'false').lower() == 'true'
|
|
||||||
EMAIL_USE_SSL = os.environ.get('EMAIL_USE_SSL', 'false').lower() == 'true'
|
|
||||||
EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER')
|
|
||||||
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD')
|
|
||||||
EMAIL_SKIP_CERT_VERIFICATION = os.environ.get('EMAIL_SKIP_CERT_VERIFICATION', 'false').lower() == 'true'
|
|
||||||
DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', EMAIL_HOST_USER)
|
|
||||||
SERVER_EMAIL = DEFAULT_FROM_EMAIL
|
|
||||||
|
|
||||||
if EMAIL_HOST:
|
|
||||||
EMAIL_BACKEND = 'api.email_backend.InsecureEmailBackend'
|
|
||||||
else:
|
|
||||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
|
||||||
|
|
||||||
# Swagger
|
|
||||||
SPECTACULAR_SETTINGS = {
|
|
||||||
'TITLE': 'Holy Flavors API',
|
|
||||||
'DESCRIPTION': 'API for rating flavors',
|
|
||||||
'VERSION': '1.0.0',
|
|
||||||
'SERVE_INCLUDE_SCHEMA': False,
|
|
||||||
}
|
|
||||||
Whitespace-only changes.
@@ -0,0 +1,191 @@
|
|||||||
|
"""
|
||||||
|
Base Django settings shared across all environments.
|
||||||
|
|
||||||
|
Environment-specific overrides live in `dev.py` and `prod.py`. Select one with
|
||||||
|
the DJANGO_SETTINGS_MODULE environment variable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def get_env_list(var_name: str, default_list: list[str]) -> list[str]:
|
||||||
|
val = os.environ.get(var_name)
|
||||||
|
if val:
|
||||||
|
return [item.strip() for item in val.split(",")]
|
||||||
|
return default_list
|
||||||
|
|
||||||
|
|
||||||
|
DOMAIN = os.environ.get("DOMAIN", "localhost")
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = get_env_list(
|
||||||
|
"ALLOWED_HOSTS",
|
||||||
|
["localhost", "127.0.0.1", "backend", "worker", DOMAIN],
|
||||||
|
)
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"whitenoise.runserver_nostatic",
|
||||||
|
"django.contrib.admin",
|
||||||
|
"django.contrib.auth",
|
||||||
|
"django.contrib.contenttypes",
|
||||||
|
"django.contrib.sessions",
|
||||||
|
"django.contrib.messages",
|
||||||
|
"django.contrib.staticfiles",
|
||||||
|
# Third party
|
||||||
|
"rest_framework",
|
||||||
|
"rest_framework.authtoken",
|
||||||
|
"rest_framework_simplejwt",
|
||||||
|
"rest_framework_simplejwt.token_blacklist",
|
||||||
|
"corsheaders",
|
||||||
|
"django_filters",
|
||||||
|
"drf_spectacular",
|
||||||
|
"django_ratelimit",
|
||||||
|
"django_celery_beat",
|
||||||
|
# Local
|
||||||
|
"api",
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
|
"compression_middleware.middleware.CompressionMiddleware",
|
||||||
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
|
"django.middleware.common.CommonMiddleware",
|
||||||
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = "holy_backend.urls"
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
"DIRS": [],
|
||||||
|
"APP_DIRS": True,
|
||||||
|
"OPTIONS": {
|
||||||
|
"context_processors": [
|
||||||
|
"django.template.context_processors.request",
|
||||||
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = "holy_backend.wsgi.application"
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
"default": {
|
||||||
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
|
"NAME": BASE_DIR / "db.sqlite3",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CACHES = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.core.cache.backends.db.DatabaseCache",
|
||||||
|
"LOCATION": "django_ratelimit_cache",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SILENCED_SYSTEM_CHECKS = ["django_ratelimit.W001", "django_ratelimit.E003"]
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||||
|
]
|
||||||
|
|
||||||
|
LANGUAGE_CODE = "en-us"
|
||||||
|
TIME_ZONE = "UTC"
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
STATIC_URL = "static/"
|
||||||
|
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||||
|
STATICFILES_STORAGE = "whitenoise.storage.CompressedStaticFilesStorage"
|
||||||
|
WHITENOISE_INDEX_PAGE = True
|
||||||
|
|
||||||
|
MEDIA_URL = "media/"
|
||||||
|
MEDIA_ROOT = BASE_DIR / "media"
|
||||||
|
|
||||||
|
# Reverse proxy
|
||||||
|
USE_X_FORWARDED_HOST = True
|
||||||
|
USE_X_FORWARDED_PORT = True
|
||||||
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
|
|
||||||
|
CSRF_TRUSTED_ORIGINS = get_env_list(
|
||||||
|
"CSRF_TRUSTED_ORIGINS",
|
||||||
|
[f"https://{DOMAIN}", "http://localhost:5173"],
|
||||||
|
)
|
||||||
|
|
||||||
|
AUTH_USER_MODEL = "api.User"
|
||||||
|
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||||
|
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||||
|
],
|
||||||
|
"DEFAULT_PERMISSION_CLASSES": [
|
||||||
|
"rest_framework.permissions.IsAuthenticatedOrReadOnly",
|
||||||
|
],
|
||||||
|
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
|
||||||
|
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||||
|
"PAGE_SIZE": 50,
|
||||||
|
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||||
|
}
|
||||||
|
|
||||||
|
SIMPLE_JWT = {
|
||||||
|
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=60),
|
||||||
|
"REFRESH_TOKEN_LIFETIME": timedelta(days=14),
|
||||||
|
"ROTATE_REFRESH_TOKENS": True,
|
||||||
|
"BLACKLIST_AFTER_ROTATION": True,
|
||||||
|
"AUTH_HEADER_TYPES": ("Bearer",),
|
||||||
|
}
|
||||||
|
|
||||||
|
CORS_ALLOWED_ORIGINS = get_env_list(
|
||||||
|
"CORS_ALLOWED_ORIGINS",
|
||||||
|
[f"https://{DOMAIN}", "http://localhost:5173", "http://127.0.0.1:5173"],
|
||||||
|
)
|
||||||
|
|
||||||
|
FRONTEND_URL = os.environ.get("FRONTEND_URL", f"https://{DOMAIN}").rstrip("/")
|
||||||
|
|
||||||
|
# Email (SMTP)
|
||||||
|
EMAIL_HOST = os.environ.get("EMAIL_HOST")
|
||||||
|
EMAIL_PORT = int(os.environ.get("EMAIL_PORT", 587))
|
||||||
|
EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "false").lower() == "true"
|
||||||
|
EMAIL_USE_SSL = os.environ.get("EMAIL_USE_SSL", "false").lower() == "true"
|
||||||
|
EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER")
|
||||||
|
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD")
|
||||||
|
EMAIL_SKIP_CERT_VERIFICATION = (
|
||||||
|
os.environ.get("EMAIL_SKIP_CERT_VERIFICATION", "false").lower() == "true"
|
||||||
|
)
|
||||||
|
DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", EMAIL_HOST_USER)
|
||||||
|
SERVER_EMAIL = DEFAULT_FROM_EMAIL
|
||||||
|
|
||||||
|
if EMAIL_HOST:
|
||||||
|
EMAIL_BACKEND = "api.email_backend.InsecureEmailBackend"
|
||||||
|
else:
|
||||||
|
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||||
|
|
||||||
|
SPECTACULAR_SETTINGS = {
|
||||||
|
"TITLE": "Holy Flavors API",
|
||||||
|
"DESCRIPTION": "API for rating flavors",
|
||||||
|
"VERSION": "1.0.0",
|
||||||
|
"SERVE_INCLUDE_SCHEMA": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Celery
|
||||||
|
CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "redis://redis:6379/0")
|
||||||
|
CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", "redis://redis:6379/0")
|
||||||
|
CELERY_TIMEZONE = TIME_ZONE
|
||||||
|
CELERY_TASK_TRACK_STARTED = True
|
||||||
|
CELERY_TASK_TIME_LIMIT = 30 * 60
|
||||||
|
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
|
||||||
|
CELERY_TASK_ALWAYS_EAGER = os.environ.get("CELERY_TASK_ALWAYS_EAGER", "false").lower() == "true"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Development settings. Selected by default via manage.py / wsgi.py / asgi.py."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from holy_backend.settings.base import * # noqa: F401,F403
|
||||||
|
|
||||||
|
DEBUG = True
|
||||||
|
|
||||||
|
# Dev-only fallback. Override via SECRET_KEY env var if you want consistency
|
||||||
|
# across restarts (sessions invalidate when this changes).
|
||||||
|
SECRET_KEY = os.environ.get(
|
||||||
|
"SECRET_KEY",
|
||||||
|
"django-insecure-dev-only-do-not-use-in-production",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run Celery tasks synchronously in dev/tests unless a broker is available.
|
||||||
|
CELERY_TASK_ALWAYS_EAGER = os.environ.get("CELERY_TASK_ALWAYS_EAGER", "true").lower() == "true"
|
||||||
|
CELERY_TASK_EAGER_PROPAGATES = True
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Production settings. Set DJANGO_SETTINGS_MODULE=holy_backend.settings.prod."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from holy_backend.settings.base import * # noqa: F401,F403
|
||||||
|
|
||||||
|
DEBUG = False
|
||||||
|
|
||||||
|
# Required in production. Fail loudly if not set.
|
||||||
|
try:
|
||||||
|
SECRET_KEY = os.environ["SECRET_KEY"]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise RuntimeError("SECRET_KEY environment variable must be set in production.") from exc
|
||||||
|
|
||||||
|
SECURE_SSL_REDIRECT = True
|
||||||
|
SESSION_COOKIE_SECURE = True
|
||||||
|
CSRF_COOKIE_SECURE = True
|
||||||
|
SECURE_BROWSER_XSS_FILTER = True
|
||||||
|
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||||
|
SECURE_HSTS_SECONDS = 31536000 # 1 year
|
||||||
|
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||||
|
SECURE_HSTS_PRELOAD = True
|
||||||
@@ -1,34 +1,43 @@
|
|||||||
from django.contrib import admin
|
|
||||||
from django.urls import path, include
|
|
||||||
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
|
|
||||||
from rest_framework.authtoken.views import obtain_auth_token
|
|
||||||
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
|
import os
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.urls import include, path
|
||||||
|
from django.views.generic.base import TemplateView
|
||||||
|
from django.views.static import serve
|
||||||
|
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView
|
||||||
|
|
||||||
|
|
||||||
def health_check(request):
|
def health_check(request):
|
||||||
return HttpResponse("OK", content_type="text/plain")
|
return HttpResponse("OK", content_type="text/plain")
|
||||||
|
|
||||||
|
|
||||||
# Clean the admin path
|
# Clean the admin path
|
||||||
admin_url = os.environ.get('ADMIN_URL', 'admin').strip('/')
|
admin_url = os.environ.get("ADMIN_URL", "admin").strip("/")
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Health check
|
# Health check
|
||||||
path('health/', health_check, name='health_check'),
|
path("health/", health_check, name="health_check"),
|
||||||
|
|
||||||
# Robots.txt
|
# Robots.txt
|
||||||
path('robots.txt', TemplateView.as_view(template_name="robots.txt", content_type="text/plain")),
|
path("robots.txt", TemplateView.as_view(template_name="robots.txt", content_type="text/plain")),
|
||||||
|
|
||||||
# Ensure admin ends with a slash for Django's redirection logic
|
# Ensure admin ends with a slash for Django's redirection logic
|
||||||
path(f'{admin_url}/', admin.site.urls),
|
path(f"{admin_url}/", admin.site.urls),
|
||||||
path('api/', include('api.urls')),
|
path("api/", include("api.urls")),
|
||||||
path('api/token/', obtain_auth_token, name='api_token'),
|
path("api/auth/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
|
||||||
path('api-auth/', include('rest_framework.urls')),
|
path("api/auth/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
|
||||||
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
path("api/auth/token/verify/", TokenVerifyView.as_view(), name="token_verify"),
|
||||||
path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
# Legacy alias: old DRF TokenAuth endpoint now returns a JWT pair so clients
|
||||||
path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
|
# relying on POST /api/token/ keep working for one release.
|
||||||
path('media/<path:path>', serve, {'document_root': settings.MEDIA_ROOT}),
|
path("api/token/", TokenObtainPairView.as_view(), name="api_token_legacy"),
|
||||||
|
path("api-auth/", include("rest_framework.urls")),
|
||||||
|
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
|
||||||
|
path(
|
||||||
|
"api/schema/swagger-ui/",
|
||||||
|
SpectacularSwaggerView.as_view(url_name="schema"),
|
||||||
|
name="swagger-ui",
|
||||||
|
),
|
||||||
|
path("api/schema/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
|
||||||
|
path("media/<path:path>", serve, {"document_root": settings.MEDIA_ROOT}),
|
||||||
]
|
]
|
||||||
@@ -11,6 +11,6 @@ import os
|
|||||||
|
|
||||||
from django.core.wsgi import get_wsgi_application
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'holy_backend.settings')
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev")
|
||||||
|
|
||||||
application = get_wsgi_application()
|
application = get_wsgi_application()
|
||||||
+3
-2
@@ -1,12 +1,13 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
"""Django's command-line utility for administrative tasks."""
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Run administrative tasks."""
|
"""Run administrative tasks."""
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'holy_backend.settings')
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "holy_backend.settings.dev")
|
||||||
try:
|
try:
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
@@ -18,5 +19,5 @@ def main():
|
|||||||
execute_from_command_line(sys.argv)
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py312"
|
||||||
|
extend-exclude = ["migrations", "venv", ".venv"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"E", # pycodestyle errors
|
||||||
|
"W", # pycodestyle warnings
|
||||||
|
"F", # pyflakes
|
||||||
|
"I", # isort
|
||||||
|
"B", # flake8-bugbear
|
||||||
|
"UP", # pyupgrade
|
||||||
|
"C4", # flake8-comprehensions
|
||||||
|
"DJ", # flake8-django
|
||||||
|
]
|
||||||
|
ignore = [
|
||||||
|
"E501", # line too long (formatter handles it)
|
||||||
|
"B008", # function call in default argument (common in DRF)
|
||||||
|
"DJ001", # null=True on string fields (pre-existing schema decisions)
|
||||||
|
"DJ008", # missing __str__ (not all models need one)
|
||||||
|
"DJ012", # model attribute ordering style
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"api/migrations/*.py" = ["ALL"]
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
quote-style = "double"
|
||||||
|
indent-style = "space"
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.12"
|
||||||
|
plugins = ["mypy_django_plugin.main", "mypy_drf_plugin.main"]
|
||||||
|
exclude = ["migrations/", "venv/", ".venv/"]
|
||||||
|
disallow_untyped_defs = false
|
||||||
|
check_untyped_defs = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
warn_redundant_casts = true
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[tool.django-stubs]
|
||||||
|
django_settings_module = "holy_backend.settings.dev"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
DJANGO_SETTINGS_MODULE = "holy_backend.settings.dev"
|
||||||
|
python_files = ["test_*.py", "tests.py"]
|
||||||
|
testpaths = ["api/tests"]
|
||||||
|
addopts = "-ra --strict-markers"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
|
||||||
|
# Linting / formatting
|
||||||
|
ruff>=0.6.0
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
mypy>=1.11.0
|
||||||
|
django-stubs[compatible-mypy]>=5.0.0
|
||||||
|
djangorestframework-stubs[compatible-mypy]>=3.15.0
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pytest>=8.0.0
|
||||||
|
pytest-django>=4.8.0
|
||||||
|
pytest-cov>=5.0.0
|
||||||
@@ -10,3 +10,7 @@ gunicorn>=21.2.0
|
|||||||
django-ratelimit>=4.1.0
|
django-ratelimit>=4.1.0
|
||||||
django-compression-middleware>=0.5.0
|
django-compression-middleware>=0.5.0
|
||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
|
celery[redis]>=5.4.0
|
||||||
|
django-celery-beat>=2.6.0
|
||||||
|
redis>=5.0.0
|
||||||
|
djangorestframework-simplejwt>=5.3.1
|
||||||
+54
-1
@@ -1,4 +1,19 @@
|
|||||||
services:
|
services:
|
||||||
|
# --- Redis (Celery broker + result backend) ---
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: holy-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- holy-net
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
# --- Backend Service ---
|
# --- Backend Service ---
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
@@ -17,6 +32,12 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- DOMAIN=${DOMAIN:-localhost}
|
- DOMAIN=${DOMAIN:-localhost}
|
||||||
|
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-holy_backend.settings.prod}
|
||||||
|
- CELERY_BROKER_URL=${CELERY_BROKER_URL:-redis://redis:6379/0}
|
||||||
|
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health/"]
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health/"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -28,18 +49,49 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
container_name: holy-worker
|
container_name: holy-worker
|
||||||
command: python manage.py worker
|
command: celery -A holy_backend worker -l info
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- holy-net
|
- holy-net
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
- DOMAIN=${DOMAIN:-localhost}
|
- DOMAIN=${DOMAIN:-localhost}
|
||||||
|
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-holy_backend.settings.prod}
|
||||||
|
- CELERY_BROKER_URL=${CELERY_BROKER_URL:-redis://redis:6379/0}
|
||||||
|
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- ./legacy:/app/legacy
|
- ./legacy:/app/legacy
|
||||||
depends_on:
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
beat:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
container_name: holy-beat
|
||||||
|
command: celery -A holy_backend beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- holy-net
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
- DOMAIN=${DOMAIN:-localhost}
|
||||||
|
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-holy_backend.settings.prod}
|
||||||
|
- CELERY_BROKER_URL=${CELERY_BROKER_URL:-redis://redis:6379/0}
|
||||||
|
- CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- ./legacy:/app/legacy
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
@@ -71,3 +123,4 @@ networks:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db_data:
|
db_data:
|
||||||
|
redis_data:
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
public
|
||||||
|
*.lock
|
||||||
|
package-lock.json
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"semi": true,
|
||||||
|
"arrowParens": "always"
|
||||||
|
}
|
||||||
@@ -19,5 +19,18 @@ export default defineConfig([
|
|||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
'@typescript-eslint/no-empty-object-type': 'warn',
|
||||||
|
'react-hooks/set-state-in-effect': 'warn',
|
||||||
|
'react-hooks/immutability': 'warn',
|
||||||
|
'react-hooks/purity': 'warn',
|
||||||
|
'react-refresh/only-export-components': 'warn',
|
||||||
|
'no-empty': 'warn',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
Generated
+1240
-4
File diff suppressed because it is too large.
Load diff
+14
-1
@@ -7,6 +7,11 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
"typecheck": "tsc -b --noEmit",
|
||||||
|
"format": "prettier --write \"src/**/*.{ts,tsx,js,json,css}\"",
|
||||||
|
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,json,css}\"",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -26,17 +31,25 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/colorthief": "^2.6.1",
|
"@types/colorthief": "^2.6.1",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"@vitest/ui": "^4.1.4",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"jsdom": "^29.0.2",
|
||||||
|
"prettier": "^3.8.2",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.48.0",
|
"typescript-eslint": "^8.48.0",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1",
|
||||||
|
"vitest": "^4.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+495
-621
File diff suppressed because it is too large.
Load diff
@@ -1,42 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
function getCookie(name: string): string | null {
|
|
||||||
let cookieValue = null;
|
|
||||||
if (document.cookie && document.cookie !== '') {
|
|
||||||
const cookies = document.cookie.split(';');
|
|
||||||
for (let i = 0; i < cookies.length; i++) {
|
|
||||||
const cookie = cookies[i].trim();
|
|
||||||
// Does this cookie string begin with the name we want?
|
|
||||||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
|
||||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cookieValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const api = axios.create({
|
|
||||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api/',
|
|
||||||
});
|
|
||||||
|
|
||||||
api.interceptors.request.use((config) => {
|
|
||||||
// Set DRF Auth Token if available from localStorage
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
if (token) {
|
|
||||||
config.headers.Authorization = `Token ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set CSRF token for state-changing methods
|
|
||||||
const csrfToken = getCookie('csrftoken');
|
|
||||||
const method = config.method?.toUpperCase();
|
|
||||||
if (csrfToken && method && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
||||||
if (config.headers) {
|
|
||||||
config.headers['X-CSRFToken'] = csrfToken;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
|
|
||||||
export default api;
|
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Autocomplete,
|
||||||
|
Box,
|
||||||
|
CircularProgress,
|
||||||
|
InputAdornment,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
useMediaQuery,
|
||||||
|
} from '@mui/material';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useGlobalSearch } from '../hooks/useGlobalSearch';
|
||||||
|
|
||||||
|
export const GlobalSearch = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isMobile = useMediaQuery('(max-width:1150px)');
|
||||||
|
const { query, setQuery, options, loading } = useGlobalSearch();
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (query.trim()) {
|
||||||
|
navigate(`/?q=${encodeURIComponent(query.trim())}`);
|
||||||
|
} else {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ flexGrow: 1, mx: isMobile ? 1 : 4 }}>
|
||||||
|
<Autocomplete
|
||||||
|
fullWidth
|
||||||
|
freeSolo
|
||||||
|
size="small"
|
||||||
|
loading={loading}
|
||||||
|
options={options}
|
||||||
|
filterOptions={(x) => x}
|
||||||
|
getOptionLabel={(option) => (typeof option === 'string' ? option : option.name || '')}
|
||||||
|
inputValue={query}
|
||||||
|
onInputChange={(_, newValue) => setQuery(typeof newValue === 'string' ? newValue : '')}
|
||||||
|
onChange={(_, newValue) => {
|
||||||
|
if (newValue && typeof newValue !== 'string' && newValue.type === 'flavor') {
|
||||||
|
navigate(`/flavor/${newValue.id}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSearch(e as unknown as React.FormEvent);
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
fullWidth
|
||||||
|
placeholder={t('common.search')}
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
borderRadius: 2,
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
color: 'inherit',
|
||||||
|
'& fieldset': { border: 'none' },
|
||||||
|
'&:hover fieldset': { border: 'none' },
|
||||||
|
'&.Mui-focused fieldset': { border: 'none' },
|
||||||
|
outline: 'none',
|
||||||
|
},
|
||||||
|
'& .MuiInputBase-input': { outline: 'none' },
|
||||||
|
}}
|
||||||
|
InputProps={{
|
||||||
|
...params.InputProps,
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon sx={{ color: 'inherit', opacity: 0.7 }} />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
endAdornment: (
|
||||||
|
<React.Fragment>
|
||||||
|
{loading ? <CircularProgress color="inherit" size={20} /> : null}
|
||||||
|
{params.InputProps.endAdornment}
|
||||||
|
</React.Fragment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderOption={(props, option) => {
|
||||||
|
const { key, ...optionProps } = props as React.HTMLAttributes<HTMLLIElement> & {
|
||||||
|
key: React.Key;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="li"
|
||||||
|
key={key}
|
||||||
|
{...optionProps}
|
||||||
|
sx={{ display: 'flex', alignItems: 'center', gap: 2 }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={option.image_url || undefined}
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 1,
|
||||||
|
objectFit: 'contain',
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
p: 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
||||||
|
{option.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{option.subtitle}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { ThemeProvider } from '../contexts/ThemeContext';
|
||||||
|
import { AuthProvider } from '../contexts/AuthContext';
|
||||||
|
import { NotificationProvider } from '../contexts/NotificationContext';
|
||||||
|
import '../i18n';
|
||||||
|
|
||||||
|
export const AppProviders = ({ children }: { children: ReactNode }) => (
|
||||||
|
<ThemeProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<NotificationProvider>{children}</NotificationProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
@@ -1,162 +1,175 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogTitle, DialogContent, DialogActions, Button,
|
Dialog,
|
||||||
TextField, Box, Typography, Alert, Slider, Stack,
|
DialogTitle,
|
||||||
Tooltip, IconButton
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Alert,
|
||||||
|
Slider,
|
||||||
|
Stack,
|
||||||
|
Tooltip,
|
||||||
|
IconButton,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
|
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import api from '../api';
|
import api from '../lib/api';
|
||||||
|
|
||||||
interface BannerSettingSchema {
|
interface BannerSettingSchema {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: 'number' | 'slider' | 'text' | 'boolean';
|
type: 'number' | 'slider' | 'text' | 'boolean';
|
||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
step?: number;
|
step?: number;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Banner {
|
interface Banner {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
description: string;
|
description: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
settings: any;
|
settings: any;
|
||||||
schema: BannerSettingSchema[];
|
schema: BannerSettingSchema[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BannerSettingsDialogProps {
|
interface BannerSettingsDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
banner: Banner | null;
|
banner: Banner | null;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({ open, onClose, banner, onSave }) => {
|
const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({
|
||||||
const { t } = useTranslation();
|
open,
|
||||||
const [localSettings, setLocalSettings] = useState<any>({});
|
onClose,
|
||||||
const [error, setError] = useState('');
|
banner,
|
||||||
const [saving, setSaving] = useState(false);
|
onSave,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [localSettings, setLocalSettings] = useState<any>({});
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (banner) {
|
if (banner) {
|
||||||
setLocalSettings(banner.settings || {});
|
setLocalSettings(banner.settings || {});
|
||||||
setError('');
|
setError('');
|
||||||
}
|
}
|
||||||
}, [banner]);
|
}, [banner]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!banner) return;
|
if (!banner) return;
|
||||||
|
|
||||||
try {
|
|
||||||
setSaving(true);
|
|
||||||
await api.patch(`banners/${banner.id}/`, { settings: localSettings });
|
|
||||||
onSave();
|
|
||||||
onClose();
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.response?.data?.error || 'Failed to save settings');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateSetting = (key: string, value: any) => {
|
try {
|
||||||
setLocalSettings((prev: any) => ({
|
setSaving(true);
|
||||||
...prev,
|
await api.patch(`banners/${banner.id}/`, { settings: localSettings });
|
||||||
[key]: value
|
onSave();
|
||||||
}));
|
onClose();
|
||||||
};
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to save settings');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const renderField = (schema: BannerSettingSchema) => {
|
const updateSetting = (key: string, value: any) => {
|
||||||
const value = localSettings[schema.key] ?? '';
|
setLocalSettings((prev: any) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
const renderField = (schema: BannerSettingSchema) => {
|
||||||
<Box key={schema.key} sx={{ mb: 3 }}>
|
const value = localSettings[schema.key] ?? '';
|
||||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
|
|
||||||
{schema.label}
|
|
||||||
</Typography>
|
|
||||||
{schema.description && (
|
|
||||||
<Tooltip title={schema.description} arrow>
|
|
||||||
<IconButton size="small" sx={{ p: 0.5 }}>
|
|
||||||
<HelpOutlineIcon sx={{ fontSize: '1rem', opacity: 0.6 }} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{schema.type === 'slider' ? (
|
|
||||||
<Box sx={{ px: 1 }}>
|
|
||||||
<Slider
|
|
||||||
value={Number(value)}
|
|
||||||
min={schema.min ?? 0}
|
|
||||||
max={schema.max ?? 100}
|
|
||||||
step={schema.step ?? 1}
|
|
||||||
onChange={(_, val) => updateSetting(schema.key, val)}
|
|
||||||
valueLabelDisplay="auto"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
) : schema.type === 'number' ? (
|
|
||||||
<TextField
|
|
||||||
type="number"
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
value={value}
|
|
||||||
inputProps={{ min: schema.min, max: schema.max, step: schema.step }}
|
|
||||||
onChange={(e) => updateSetting(schema.key, Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => updateSetting(schema.key, e.target.value)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{schema.description && (
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
|
||||||
{schema.description}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
<Box key={schema.key} sx={{ mb: 3 }}>
|
||||||
<DialogTitle sx={{ fontWeight: 'bold' }}>
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
||||||
Banner Settings: {banner?.name}
|
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
|
||||||
</DialogTitle>
|
{schema.label}
|
||||||
<DialogContent dividers>
|
</Typography>
|
||||||
{error && <Alert severity="error" sx={{ mb: 3 }}>{error}</Alert>}
|
{schema.description && (
|
||||||
|
<Tooltip title={schema.description} arrow>
|
||||||
{banner?.schema && banner.schema.length > 0 ? (
|
<IconButton size="small" sx={{ p: 0.5 }}>
|
||||||
<Box sx={{ mt: 1 }}>
|
<HelpOutlineIcon sx={{ fontSize: '1rem', opacity: 0.6 }} />
|
||||||
{banner.schema.map(field => renderField(field))}
|
</IconButton>
|
||||||
</Box>
|
</Tooltip>
|
||||||
) : (
|
)}
|
||||||
<Typography color="text.secondary" sx={{ fontStyle: 'italic', py: 2 }}>
|
</Stack>
|
||||||
No dynamic schema defined for this banner. Please use the JSON editor or check the banner configuration files.
|
|
||||||
</Typography>
|
{schema.type === 'slider' ? (
|
||||||
)}
|
<Box sx={{ px: 1 }}>
|
||||||
</DialogContent>
|
<Slider
|
||||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
value={Number(value)}
|
||||||
<Button onClick={onClose} color="inherit">{t('common.cancel')}</Button>
|
min={schema.min ?? 0}
|
||||||
<Button
|
max={schema.max ?? 100}
|
||||||
onClick={handleSave}
|
step={schema.step ?? 1}
|
||||||
variant="contained"
|
onChange={(_, val) => updateSetting(schema.key, val)}
|
||||||
disabled={saving}
|
valueLabelDisplay="auto"
|
||||||
sx={{ px: 4 }}
|
/>
|
||||||
>
|
</Box>
|
||||||
{saving ? t('common.loading') : t('common.save')}
|
) : schema.type === 'number' ? (
|
||||||
</Button>
|
<TextField
|
||||||
</DialogActions>
|
type="number"
|
||||||
</Dialog>
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
value={value}
|
||||||
|
inputProps={{ min: schema.min, max: schema.max, step: schema.step }}
|
||||||
|
onChange={(e) => updateSetting(schema.key, Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => updateSetting(schema.key, e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schema.description && (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||||
|
{schema.description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle sx={{ fontWeight: 'bold' }}>Banner Settings: {banner?.name}</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{banner?.schema && banner.schema.length > 0 ? (
|
||||||
|
<Box sx={{ mt: 1 }}>{banner.schema.map((field) => renderField(field))}</Box>
|
||||||
|
) : (
|
||||||
|
<Typography color="text.secondary" sx={{ fontStyle: 'italic', py: 2 }}>
|
||||||
|
No dynamic schema defined for this banner. Please use the JSON editor or check the
|
||||||
|
banner configuration files.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||||
|
<Button onClick={onClose} color="inherit">
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} variant="contained" disabled={saving} sx={{ px: 4 }}>
|
||||||
|
{saving ? t('common.loading') : t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default BannerSettingsDialog;
|
export default BannerSettingsDialog;
|
||||||
@@ -1,108 +1,123 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Typography,
|
Typography,
|
||||||
Button,
|
Button,
|
||||||
alpha,
|
alpha,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
Box
|
Box,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { CatppuccinTheme } from '../theme';
|
import type { CatppuccinTheme } from '../theme';
|
||||||
|
|
||||||
interface CookieBannerProps {
|
interface CookieBannerProps {
|
||||||
onThemeChange: (newTheme: CatppuccinTheme) => void;
|
onThemeChange: (newTheme: CatppuccinTheme) => void;
|
||||||
currentTheme: CatppuccinTheme;
|
currentTheme: CatppuccinTheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme }) => {
|
const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const consent = localStorage.getItem('cookie-consent');
|
const consent = localStorage.getItem('cookie-consent');
|
||||||
if (!consent) {
|
if (!consent) {
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleAccept = () => {
|
const handleAccept = () => {
|
||||||
localStorage.setItem('cookie-consent', 'true');
|
localStorage.setItem('cookie-consent', 'true');
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleTheme = () => {
|
const handleToggleTheme = () => {
|
||||||
const newTheme = currentTheme === 'holy_light' ? 'holy_dark' : 'holy_light';
|
const newTheme = currentTheme === 'holy_light' ? 'holy_dark' : 'holy_light';
|
||||||
onThemeChange(newTheme);
|
onThemeChange(newTheme);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
onClose={() => {}} // Disable closing by clicking backdrop
|
onClose={() => {}} // Disable closing by clicking backdrop
|
||||||
disableEscapeKeyDown
|
disableEscapeKeyDown
|
||||||
maxWidth="sm"
|
maxWidth="sm"
|
||||||
fullWidth
|
fullWidth
|
||||||
PaperProps={{
|
PaperProps={{
|
||||||
sx: {
|
sx: {
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: 'primary.main',
|
borderColor: 'primary.main',
|
||||||
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.98),
|
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.98),
|
||||||
backdropFilter: 'blur(10px)',
|
backdropFilter: 'blur(10px)',
|
||||||
boxShadow: (theme) => `0 20px 50px ${alpha(theme.palette.primary.main, 0.2)}`,
|
boxShadow: (theme) => `0 20px 50px ${alpha(theme.palette.primary.main, 0.2)}`,
|
||||||
}
|
},
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ fontWeight: 'bold', fontSize: '1.5rem', pt: 3 }}>
|
||||||
|
{t('cookieBanner.title')} 🍪
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography variant="body1" sx={{ mb: 2, lineHeight: 1.7 }}>
|
||||||
|
{t('cookieBanner.description')}
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
mt: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DialogTitle sx={{ fontWeight: 'bold', fontSize: '1.5rem', pt: 3 }}>
|
<Typography
|
||||||
{t('cookieBanner.title')} 🍪
|
variant="caption"
|
||||||
</DialogTitle>
|
color="text.secondary"
|
||||||
<DialogContent>
|
sx={{ fontWeight: 'bold', display: 'block', mb: 1, textTransform: 'uppercase' }}
|
||||||
<Typography variant="body1" sx={{ mb: 2, lineHeight: 1.7 }}>
|
>
|
||||||
{t('cookieBanner.description')}
|
Technical Details:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{
|
<Typography variant="caption" color="text.secondary">
|
||||||
p: 2,
|
• Session ID: Required for login & authentication.
|
||||||
borderRadius: 2,
|
<br />
|
||||||
bgcolor: 'action.hover',
|
• CSRF Token: Protects your account from malicious attacks.
|
||||||
border: '1px solid',
|
<br />• Preference Store: Remembers your theme and language choices.
|
||||||
borderColor: 'divider',
|
</Typography>
|
||||||
mt: 2
|
</Box>
|
||||||
}}>
|
</DialogContent>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 'bold', display: 'block', mb: 1, textTransform: 'uppercase' }}>
|
<DialogActions sx={{ p: 3, gap: 2, flexDirection: { xs: 'column', sm: 'row' } }}>
|
||||||
Technical Details:
|
<Button
|
||||||
</Typography>
|
fullWidth
|
||||||
<Typography variant="caption" color="text.secondary">
|
variant="outlined"
|
||||||
• Session ID: Required for login & authentication.<br />
|
color="primary"
|
||||||
• CSRF Token: Protects your account from malicious attacks.<br />
|
onClick={handleToggleTheme}
|
||||||
• Preference Store: Remembers your theme and language choices.
|
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold', m: '0 !important' }}
|
||||||
</Typography>
|
>
|
||||||
</Box>
|
{currentTheme === 'holy_light'
|
||||||
</DialogContent>
|
? t('cookieBanner.switchToDark')
|
||||||
<DialogActions sx={{ p: 3, gap: 2, flexDirection: { xs: 'column', sm: 'row' } }}>
|
: t('cookieBanner.switchToLight')}
|
||||||
<Button
|
</Button>
|
||||||
fullWidth
|
<Button
|
||||||
variant="outlined"
|
fullWidth
|
||||||
color="primary"
|
variant="contained"
|
||||||
onClick={handleToggleTheme}
|
color="primary"
|
||||||
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold', m: '0 !important' }}
|
onClick={handleAccept}
|
||||||
>
|
sx={{
|
||||||
{currentTheme === 'holy_light' ? t('cookieBanner.switchToDark') : t('cookieBanner.switchToLight')}
|
borderRadius: 2,
|
||||||
</Button>
|
textTransform: 'none',
|
||||||
<Button
|
fontWeight: 'bold',
|
||||||
fullWidth
|
px: 4,
|
||||||
variant="contained"
|
m: '0 !important',
|
||||||
color="primary"
|
}}
|
||||||
onClick={handleAccept}
|
>
|
||||||
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold', px: 4, m: '0 !important' }}
|
{t('cookieBanner.accept')}
|
||||||
>
|
</Button>
|
||||||
{t('cookieBanner.accept')}
|
</DialogActions>
|
||||||
</Button>
|
</Dialog>
|
||||||
</DialogActions>
|
);
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CookieBanner;
|
export default CookieBanner;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import api from '../api';
|
import api from '../lib/api';
|
||||||
import GenerativeBanner from './GenerativeBanner';
|
import GenerativeBanner from './GenerativeBanner';
|
||||||
import NebulaBanner from './NebulaBanner';
|
import NebulaBanner from './NebulaBanner';
|
||||||
import FirefliesBanner from './FirefliesBanner';
|
import FirefliesBanner from './FirefliesBanner';
|
||||||
@@ -8,106 +8,123 @@ import HextechCorruptionBanner from './HextechCorruptionBanner';
|
|||||||
import GenerativeFlowBanner from './GenerativeFlowBanner';
|
import GenerativeFlowBanner from './GenerativeFlowBanner';
|
||||||
|
|
||||||
interface Banner {
|
interface Banner {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
description: string;
|
description: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
settings: any;
|
settings: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DynamicBannerProps {
|
interface DynamicBannerProps {
|
||||||
username: string;
|
username: string;
|
||||||
palette: string[];
|
palette: string[];
|
||||||
ratingsCount: number;
|
ratingsCount: number;
|
||||||
followersCount: number;
|
followersCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Future expansion: Add more banner components here
|
// Future expansion: Add more banner components here
|
||||||
const MatrixBanner: React.FC<DynamicBannerProps & { settings: any }> = ({ username, settings }) => {
|
const MatrixBanner: React.FC<DynamicBannerProps & { settings: any }> = ({ username, settings }) => {
|
||||||
// Placeholder for a second model
|
// Placeholder for a second model
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div
|
||||||
position: 'absolute', top: 0, left: 0, width: '100%', height: '100%',
|
style={{
|
||||||
background: 'rgba(0,0,0,0.8)', color: '#0f0', display: 'flex',
|
position: 'absolute',
|
||||||
alignItems: 'center', justifyContent: 'center', fontFamily: 'monospace',
|
top: 0,
|
||||||
zIndex: 1, overflow: 'hidden', opacity: settings.opacity || 0.5
|
left: 0,
|
||||||
}}>
|
width: '100%',
|
||||||
<div>
|
height: '100%',
|
||||||
{Array.from({ length: 20 }).map((_, i) => (
|
background: 'rgba(0,0,0,0.8)',
|
||||||
<div key={i} style={{ animation: `matrixFall ${2 + Math.random() * 3}s linear infinite` }}>
|
color: '#0f0',
|
||||||
{username.split('').map((char, j) => (
|
display: 'flex',
|
||||||
<span key={j} style={{ display: 'block' }}>{char}</span>
|
alignItems: 'center',
|
||||||
))}
|
justifyContent: 'center',
|
||||||
</div>
|
fontFamily: 'monospace',
|
||||||
))}
|
zIndex: 1,
|
||||||
</div>
|
overflow: 'hidden',
|
||||||
<style>
|
opacity: settings.opacity || 0.5,
|
||||||
{`
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{Array.from({ length: 20 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{ animation: `matrixFall ${2 + Math.random() * 3}s linear infinite` }}
|
||||||
|
>
|
||||||
|
{username.split('').map((char, j) => (
|
||||||
|
<span key={j} style={{ display: 'block' }}>
|
||||||
|
{char}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
@keyframes matrixFall {
|
@keyframes matrixFall {
|
||||||
0% { transform: translateY(-100%); }
|
0% { transform: translateY(-100%); }
|
||||||
100% { transform: translateY(100%); }
|
100% { transform: translateY(100%); }
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
</style>
|
</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DynamicBanner: React.FC<DynamicBannerProps> = (props) => {
|
const DynamicBanner: React.FC<DynamicBannerProps> = (props) => {
|
||||||
const [banner, setBanner] = useState<Banner | null>(null);
|
const [banner, setBanner] = useState<Banner | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchActiveBanner = async () => {
|
const fetchActiveBanner = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('banners/active/', {
|
const res = await api.get('banners/active/', {
|
||||||
params: { username: props.username }
|
params: { username: props.username },
|
||||||
});
|
});
|
||||||
setBanner(res.data);
|
setBanner(res.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch active banner:', err);
|
console.error('Failed to fetch active banner:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchActiveBanner();
|
fetchActiveBanner();
|
||||||
}, [props.username]);
|
}, [props.username]);
|
||||||
|
|
||||||
if (loading) return null;
|
if (loading) return null;
|
||||||
|
|
||||||
// Default to GenerativeBanner if no active banner or unrecognized slug
|
// Default to GenerativeBanner if no active banner or unrecognized slug
|
||||||
if (!banner || banner.slug === 'generative-web') {
|
if (!banner || banner.slug === 'generative-web') {
|
||||||
return <GenerativeBanner {...props} settings={banner?.settings} />;
|
return <GenerativeBanner {...props} settings={banner?.settings} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (banner.slug === 'matrix') {
|
if (banner.slug === 'matrix') {
|
||||||
return <MatrixBanner {...props} settings={banner.settings} />;
|
return <MatrixBanner {...props} settings={banner.settings} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (banner.slug === 'nebula') {
|
if (banner.slug === 'nebula') {
|
||||||
return <NebulaBanner {...props} settings={banner.settings} />;
|
return <NebulaBanner {...props} settings={banner.settings} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (banner.slug === 'fireflies') {
|
if (banner.slug === 'fireflies') {
|
||||||
return <FirefliesBanner {...props} settings={banner.settings} />;
|
return <FirefliesBanner {...props} settings={banner.settings} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (banner.slug === 'hextech') {
|
if (banner.slug === 'hextech') {
|
||||||
return <HextechBanner {...props} settings={banner.settings} />;
|
return <HextechBanner {...props} settings={banner.settings} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (banner.slug === 'hextech-corruption') {
|
if (banner.slug === 'hextech-corruption') {
|
||||||
return <HextechCorruptionBanner {...props} settings={banner.settings} />;
|
return <HextechCorruptionBanner {...props} settings={banner.settings} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (banner.slug === 'generative-flow') {
|
if (banner.slug === 'generative-flow') {
|
||||||
return <GenerativeFlowBanner {...props} settings={banner.settings} />;
|
return <GenerativeFlowBanner {...props} settings={banner.settings} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback
|
// Fallback
|
||||||
return <GenerativeBanner {...props} />;
|
return <GenerativeBanner {...props} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DynamicBanner;
|
export default DynamicBanner;
|
||||||
@@ -2,256 +2,279 @@ import React, { useRef, useEffect, useMemo } from 'react';
|
|||||||
import { alpha, useTheme } from '@mui/material';
|
import { alpha, useTheme } from '@mui/material';
|
||||||
|
|
||||||
interface FirefliesBannerProps {
|
interface FirefliesBannerProps {
|
||||||
username: string;
|
username: string;
|
||||||
palette: string[];
|
palette: string[];
|
||||||
ratingsCount: number;
|
ratingsCount: number;
|
||||||
followersCount: number;
|
followersCount: number;
|
||||||
settings?: {
|
settings?: {
|
||||||
count?: number;
|
count?: number;
|
||||||
speed?: number;
|
speed?: number;
|
||||||
evasionRadius?: number;
|
evasionRadius?: number;
|
||||||
evasionStrength?: number;
|
evasionStrength?: number;
|
||||||
glowSize?: number;
|
glowSize?: number;
|
||||||
flickerSpeed?: number;
|
flickerSpeed?: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, settings }) => {
|
const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, settings }) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const count = settings?.count ?? 150;
|
|
||||||
const baseSpeed = settings?.speed ?? 0.8; // Slower default cruise
|
|
||||||
const evasionRadius = settings?.evasionRadius ?? 180;
|
|
||||||
const evasionStrength = settings?.evasionStrength ?? 0.6; // High evasion
|
|
||||||
const glowSizeMultiplier = settings?.glowSize ?? 4;
|
|
||||||
const flickerSpeed = settings?.flickerSpeed ?? 0.05;
|
|
||||||
|
|
||||||
const getSeed = (str: string) => {
|
const count = settings?.count ?? 150;
|
||||||
let hash = 0;
|
const baseSpeed = settings?.speed ?? 0.8; // Slower default cruise
|
||||||
for (let i = 0; i < str.length; i++) {
|
const evasionRadius = settings?.evasionRadius ?? 180;
|
||||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
const evasionStrength = settings?.evasionStrength ?? 0.6; // High evasion
|
||||||
}
|
const glowSizeMultiplier = settings?.glowSize ?? 4;
|
||||||
return hash;
|
const flickerSpeed = settings?.flickerSpeed ?? 0.05;
|
||||||
|
|
||||||
|
const getSeed = (str: string) => {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
};
|
||||||
|
|
||||||
|
const seed = useMemo(() => getSeed(username), [username]);
|
||||||
|
|
||||||
|
const brightColors = useMemo(() => {
|
||||||
|
const colors =
|
||||||
|
palette.length >= 2
|
||||||
|
? palette
|
||||||
|
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||||
|
const getBrightness = (hex: string) => {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16) || 0;
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16) || 0;
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16) || 0;
|
||||||
|
return (r * 299 + g * 587 + b * 114) / 1000;
|
||||||
|
};
|
||||||
|
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
|
||||||
|
return sorted.slice(0, 3);
|
||||||
|
}, [palette, theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d', { alpha: true });
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
let animationFrameId: number;
|
||||||
|
|
||||||
|
interface Firefly {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
phase: number;
|
||||||
|
noiseOffset: number;
|
||||||
|
isEvading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let swarm: Firefly[] = [];
|
||||||
|
|
||||||
|
const pseudoRandom = (offset: number) => {
|
||||||
|
const x = Math.sin(seed + offset) * 10000;
|
||||||
|
return x - Math.floor(x);
|
||||||
};
|
};
|
||||||
|
|
||||||
const seed = useMemo(() => getSeed(username), [username]);
|
const initSwarm = (width: number, height: number) => {
|
||||||
|
const newSwarm = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
newSwarm.push({
|
||||||
|
x: pseudoRandom(i) * width,
|
||||||
|
y: pseudoRandom(i + 500) * height,
|
||||||
|
vx: (pseudoRandom(i + 1000) - 0.5) * baseSpeed,
|
||||||
|
vy: (pseudoRandom(i + 1500) - 0.5) * baseSpeed,
|
||||||
|
size: 1 + pseudoRandom(i + 2000) * 2,
|
||||||
|
color: brightColors[i % brightColors.length],
|
||||||
|
phase: pseudoRandom(i + 2500) * Math.PI * 2,
|
||||||
|
noiseOffset: pseudoRandom(i + 3000) * 100,
|
||||||
|
isEvading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return newSwarm;
|
||||||
|
};
|
||||||
|
|
||||||
const brightColors = useMemo(() => {
|
const resize = () => {
|
||||||
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
const parent = canvas.parentElement;
|
||||||
const getBrightness = (hex: string) => {
|
if (parent) {
|
||||||
const r = parseInt(hex.slice(1, 3), 16) || 0;
|
const rect = parent.getBoundingClientRect();
|
||||||
const g = parseInt(hex.slice(3, 5), 16) || 0;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const b = parseInt(hex.slice(5, 7), 16) || 0;
|
canvas.width = rect.width * dpr;
|
||||||
return (r * 299 + g * 587 + b * 114) / 1000;
|
canvas.height = rect.height * dpr;
|
||||||
};
|
ctx.scale(dpr, dpr);
|
||||||
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
|
canvas.style.width = `${rect.width}px`;
|
||||||
return sorted.slice(0, 3);
|
canvas.style.height = `${rect.height}px`;
|
||||||
}, [palette, theme]);
|
swarm = initSwarm(rect.width, rect.height);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const resizeObserver = new ResizeObserver(() => resize());
|
||||||
const canvas = canvasRef.current;
|
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
||||||
if (!canvas) return;
|
resize();
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d', { alpha: true });
|
const mouse = { x: -1000, y: -1000 };
|
||||||
if (!ctx) return;
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
mouse.x = e.clientX - rect.left;
|
||||||
|
mouse.y = e.clientY - rect.top;
|
||||||
|
};
|
||||||
|
|
||||||
let animationFrameId: number;
|
const parent = canvas.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
parent.addEventListener('mousemove', handleMouseMove);
|
||||||
|
parent.addEventListener('mouseleave', () => {
|
||||||
|
mouse.x = -1000;
|
||||||
|
mouse.y = -1000;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
interface Firefly {
|
let time = 0;
|
||||||
x: number;
|
const draw = () => {
|
||||||
y: number;
|
const width = canvas.width / (window.devicePixelRatio || 1);
|
||||||
vx: number;
|
const height = canvas.height / (window.devicePixelRatio || 1);
|
||||||
vy: number;
|
if (!width || !height) {
|
||||||
size: number;
|
animationFrameId = requestAnimationFrame(draw);
|
||||||
color: string;
|
return;
|
||||||
phase: number;
|
}
|
||||||
noiseOffset: number;
|
|
||||||
isEvading: boolean;
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// Vibrant theme-aware night sky background
|
||||||
|
const isLight = theme.palette.mode === 'light';
|
||||||
|
const bgGrad = ctx.createRadialGradient(
|
||||||
|
width / 2,
|
||||||
|
height / 2,
|
||||||
|
0,
|
||||||
|
width / 2,
|
||||||
|
height / 2,
|
||||||
|
width,
|
||||||
|
);
|
||||||
|
if (isLight) {
|
||||||
|
// For light themes, we still need a "dusk/night" feel for fireflies to make sense.
|
||||||
|
// We use a deeper, more saturated version of the theme's colors.
|
||||||
|
bgGrad.addColorStop(0, alpha(brightColors[2] || '#1a1a2e', 0.85));
|
||||||
|
bgGrad.addColorStop(1, alpha(brightColors[2] || '#16213e', 1.0));
|
||||||
|
} else {
|
||||||
|
bgGrad.addColorStop(0, '#141a2a'); // Noticeable blue-grey tint
|
||||||
|
bgGrad.addColorStop(1, '#05080f');
|
||||||
|
}
|
||||||
|
ctx.fillStyle = bgGrad;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
time += flickerSpeed;
|
||||||
|
|
||||||
|
swarm.forEach((f, i) => {
|
||||||
|
// 1. Autonomous Chaotic Movement (Perlin + Jitter)
|
||||||
|
const driftX = Math.sin(time * 0.5 + f.noiseOffset) * 0.05;
|
||||||
|
const driftY = Math.cos(time * 0.4 + f.noiseOffset) * 0.05;
|
||||||
|
|
||||||
|
// High-frequency jitter for insect feel
|
||||||
|
const jitterX = (pseudoRandom(i + time * 10) - 0.5) * 0.2;
|
||||||
|
const jitterY = (pseudoRandom(i + 500 + time * 10) - 0.5) * 0.2;
|
||||||
|
|
||||||
|
f.vx += driftX + jitterX;
|
||||||
|
f.vy += driftY + jitterY;
|
||||||
|
|
||||||
|
// 2. Mouse Evasion (Fast Dash)
|
||||||
|
const dx = mouse.x - f.x;
|
||||||
|
const dy = mouse.y - f.y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
f.isEvading = dist < evasionRadius;
|
||||||
|
if (f.isEvading) {
|
||||||
|
const force = (evasionRadius - dist) / evasionRadius;
|
||||||
|
const angle = Math.atan2(dy, dx);
|
||||||
|
// Sharp acceleration away
|
||||||
|
f.vx -= Math.cos(angle) * force * evasionStrength * 1.5;
|
||||||
|
f.vy -= Math.sin(angle) * force * evasionStrength * 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
let swarm: Firefly[] = [];
|
// 3. Dynamic Speed Limits
|
||||||
|
const currentSpeed = Math.sqrt(f.vx * f.vx + f.vy * f.vy);
|
||||||
const pseudoRandom = (offset: number) => {
|
const maxSpeed = f.isEvading ? baseSpeed * 10 : baseSpeed * 1.5;
|
||||||
const x = Math.sin(seed + offset) * 10000;
|
|
||||||
return x - Math.floor(x);
|
|
||||||
};
|
|
||||||
|
|
||||||
const initSwarm = (width: number, height: number) => {
|
if (currentSpeed > maxSpeed) {
|
||||||
const newSwarm = [];
|
f.vx = (f.vx / currentSpeed) * maxSpeed;
|
||||||
for (let i = 0; i < count; i++) {
|
f.vy = (f.vy / currentSpeed) * maxSpeed;
|
||||||
newSwarm.push({
|
|
||||||
x: pseudoRandom(i) * width,
|
|
||||||
y: pseudoRandom(i + 500) * height,
|
|
||||||
vx: (pseudoRandom(i + 1000) - 0.5) * baseSpeed,
|
|
||||||
vy: (pseudoRandom(i + 1500) - 0.5) * baseSpeed,
|
|
||||||
size: 1 + pseudoRandom(i + 2000) * 2,
|
|
||||||
color: brightColors[i % brightColors.length],
|
|
||||||
phase: pseudoRandom(i + 2500) * Math.PI * 2,
|
|
||||||
noiseOffset: pseudoRandom(i + 3000) * 100,
|
|
||||||
isEvading: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return newSwarm;
|
|
||||||
};
|
|
||||||
|
|
||||||
const resize = () => {
|
|
||||||
const parent = canvas.parentElement;
|
|
||||||
if (parent) {
|
|
||||||
const rect = parent.getBoundingClientRect();
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
canvas.width = rect.width * dpr;
|
|
||||||
canvas.height = rect.height * dpr;
|
|
||||||
ctx.scale(dpr, dpr);
|
|
||||||
canvas.style.width = `${rect.width}px`;
|
|
||||||
canvas.style.height = `${rect.height}px`;
|
|
||||||
swarm = initSwarm(rect.width, rect.height);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => resize());
|
|
||||||
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
|
||||||
resize();
|
|
||||||
|
|
||||||
let mouse = { x: -1000, y: -1000 };
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
mouse.x = e.clientX - rect.left;
|
|
||||||
mouse.y = e.clientY - rect.top;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parent = canvas.parentElement;
|
|
||||||
if (parent) {
|
|
||||||
parent.addEventListener('mousemove', handleMouseMove);
|
|
||||||
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let time = 0;
|
// 4. Smooth Friction
|
||||||
const draw = () => {
|
f.vx *= 0.97;
|
||||||
const width = canvas.width / (window.devicePixelRatio || 1);
|
f.vy *= 0.97;
|
||||||
const height = canvas.height / (window.devicePixelRatio || 1);
|
|
||||||
if (!width || !height) {
|
|
||||||
animationFrameId = requestAnimationFrame(draw);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, width, height);
|
f.x += f.vx;
|
||||||
|
f.y += f.vy;
|
||||||
// Vibrant theme-aware night sky background
|
|
||||||
const isLight = theme.palette.mode === 'light';
|
|
||||||
const bgGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width);
|
|
||||||
if (isLight) {
|
|
||||||
// For light themes, we still need a "dusk/night" feel for fireflies to make sense.
|
|
||||||
// We use a deeper, more saturated version of the theme's colors.
|
|
||||||
bgGrad.addColorStop(0, alpha(brightColors[2] || '#1a1a2e', 0.85));
|
|
||||||
bgGrad.addColorStop(1, alpha(brightColors[2] || '#16213e', 1.0));
|
|
||||||
} else {
|
|
||||||
bgGrad.addColorStop(0, '#141a2a'); // Noticeable blue-grey tint
|
|
||||||
bgGrad.addColorStop(1, '#05080f');
|
|
||||||
}
|
|
||||||
ctx.fillStyle = bgGrad;
|
|
||||||
ctx.fillRect(0, 0, width, height);
|
|
||||||
|
|
||||||
time += flickerSpeed;
|
// Screen Wrap
|
||||||
|
const margin = 20;
|
||||||
|
if (f.x < -margin) f.x = width + margin;
|
||||||
|
if (f.x > width + margin) f.x = -margin;
|
||||||
|
if (f.y < -margin) f.y = height + margin;
|
||||||
|
if (f.y > height + margin) f.y = -margin;
|
||||||
|
|
||||||
swarm.forEach((f, i) => {
|
// Flicker
|
||||||
// 1. Autonomous Chaotic Movement (Perlin + Jitter)
|
const flicker = 0.4 + Math.sin(time + f.phase) * 0.6;
|
||||||
const driftX = Math.sin(time * 0.5 + f.noiseOffset) * 0.05;
|
const opacity = flicker > 0 ? flicker : 0;
|
||||||
const driftY = Math.cos(time * 0.4 + f.noiseOffset) * 0.05;
|
|
||||||
|
|
||||||
// High-frequency jitter for insect feel
|
|
||||||
const jitterX = (pseudoRandom(i + time * 10) - 0.5) * 0.2;
|
|
||||||
const jitterY = (pseudoRandom(i + 500 + time * 10) - 0.5) * 0.2;
|
|
||||||
|
|
||||||
f.vx += driftX + jitterX;
|
// Render
|
||||||
f.vy += driftY + jitterY;
|
const grad = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.size * glowSizeMultiplier);
|
||||||
|
grad.addColorStop(0, alpha(f.color, opacity));
|
||||||
|
grad.addColorStop(0.4, alpha(f.color, opacity * 0.3));
|
||||||
|
grad.addColorStop(1, alpha(f.color, 0));
|
||||||
|
|
||||||
// 2. Mouse Evasion (Fast Dash)
|
ctx.fillStyle = grad;
|
||||||
const dx = mouse.x - f.x;
|
ctx.beginPath();
|
||||||
const dy = mouse.y - f.y;
|
ctx.arc(f.x, f.y, f.size * glowSizeMultiplier, 0, Math.PI * 2);
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
ctx.fill();
|
||||||
|
|
||||||
f.isEvading = dist < evasionRadius;
|
|
||||||
if (f.isEvading) {
|
|
||||||
const force = (evasionRadius - dist) / evasionRadius;
|
|
||||||
const angle = Math.atan2(dy, dx);
|
|
||||||
// Sharp acceleration away
|
|
||||||
f.vx -= Math.cos(angle) * force * evasionStrength * 1.5;
|
|
||||||
f.vy -= Math.sin(angle) * force * evasionStrength * 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Dynamic Speed Limits
|
ctx.fillStyle = alpha('#fff', opacity * 0.8);
|
||||||
const currentSpeed = Math.sqrt(f.vx * f.vx + f.vy * f.vy);
|
ctx.beginPath();
|
||||||
const maxSpeed = f.isEvading ? baseSpeed * 10 : baseSpeed * 1.5;
|
ctx.arc(f.x, f.y, f.size * 0.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
if (currentSpeed > maxSpeed) {
|
});
|
||||||
f.vx = (f.vx / currentSpeed) * maxSpeed;
|
|
||||||
f.vy = (f.vy / currentSpeed) * maxSpeed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Smooth Friction
|
animationFrameId = requestAnimationFrame(draw);
|
||||||
f.vx *= 0.97;
|
};
|
||||||
f.vy *= 0.97;
|
|
||||||
|
|
||||||
f.x += f.vx;
|
draw();
|
||||||
f.y += f.vy;
|
|
||||||
|
|
||||||
// Screen Wrap
|
return () => {
|
||||||
const margin = 20;
|
resizeObserver.disconnect();
|
||||||
if (f.x < -margin) f.x = width + margin;
|
if (parent) {
|
||||||
if (f.x > width + margin) f.x = -margin;
|
parent.removeEventListener('mousemove', handleMouseMove);
|
||||||
if (f.y < -margin) f.y = height + margin;
|
}
|
||||||
if (f.y > height + margin) f.y = -margin;
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
seed,
|
||||||
|
brightColors,
|
||||||
|
count,
|
||||||
|
baseSpeed,
|
||||||
|
evasionRadius,
|
||||||
|
evasionStrength,
|
||||||
|
glowSizeMultiplier,
|
||||||
|
flickerSpeed,
|
||||||
|
theme,
|
||||||
|
]);
|
||||||
|
|
||||||
// Flicker
|
return (
|
||||||
const flicker = 0.4 + Math.sin(time + f.phase) * 0.6;
|
<canvas
|
||||||
const opacity = flicker > 0 ? flicker : 0;
|
ref={canvasRef}
|
||||||
|
style={{
|
||||||
// Render
|
position: 'absolute',
|
||||||
const grad = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.size * glowSizeMultiplier);
|
top: 0,
|
||||||
grad.addColorStop(0, alpha(f.color, opacity));
|
left: 0,
|
||||||
grad.addColorStop(0.4, alpha(f.color, opacity * 0.3));
|
width: '100%',
|
||||||
grad.addColorStop(1, alpha(f.color, 0));
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
ctx.fillStyle = grad;
|
zIndex: 1,
|
||||||
ctx.beginPath();
|
}}
|
||||||
ctx.arc(f.x, f.y, f.size * glowSizeMultiplier, 0, Math.PI * 2);
|
/>
|
||||||
ctx.fill();
|
);
|
||||||
|
|
||||||
ctx.fillStyle = alpha('#fff', opacity * 0.8);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(f.x, f.y, f.size * 0.5, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
});
|
|
||||||
|
|
||||||
animationFrameId = requestAnimationFrame(draw);
|
|
||||||
};
|
|
||||||
|
|
||||||
draw();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
if (parent) {
|
|
||||||
parent.removeEventListener('mousemove', handleMouseMove);
|
|
||||||
}
|
|
||||||
cancelAnimationFrame(animationFrameId);
|
|
||||||
};
|
|
||||||
}, [seed, brightColors, count, baseSpeed, evasionRadius, evasionStrength, glowSizeMultiplier, flickerSpeed, theme]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
zIndex: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FirefliesBanner;
|
export default FirefliesBanner;
|
||||||
@@ -8,25 +8,62 @@ const Footer: React.FC = () => {
|
|||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box component="footer" sx={{ bgcolor: 'background.paper', pt: 6, pb: 4, mt: 'auto', borderTop: '1px solid', borderColor: 'divider' }}>
|
<Box
|
||||||
|
component="footer"
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
pt: 6,
|
||||||
|
pb: 4,
|
||||||
|
mt: 'auto',
|
||||||
|
borderTop: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Container maxWidth="lg">
|
<Container maxWidth="lg">
|
||||||
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, justifyContent: 'space-between', alignItems: 'center', gap: 2, mb: 2 }}>
|
<Box
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 'bold' }}>
|
sx={{
|
||||||
© {year} Holy Flavors Archive
|
display: 'flex',
|
||||||
</Typography>
|
flexDirection: { xs: 'column', sm: 'row' },
|
||||||
|
justifyContent: 'space-between',
|
||||||
<Box sx={{ display: 'flex', gap: 3 }}>
|
alignItems: 'center',
|
||||||
<MuiLink component={Link} to="/about" color="inherit" sx={{ fontSize: '0.8rem', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}>
|
gap: 2,
|
||||||
Impressum
|
mb: 2,
|
||||||
</MuiLink>
|
}}
|
||||||
<MuiLink component={Link} to="/privacy" color="inherit" sx={{ fontSize: '0.8rem', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}>
|
>
|
||||||
Privacy Policy
|
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 'bold' }}>
|
||||||
</MuiLink>
|
© {year} Holy Flavors Archive
|
||||||
</Box>
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||||
|
<MuiLink
|
||||||
|
component={Link}
|
||||||
|
to="/about"
|
||||||
|
color="inherit"
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
textDecoration: 'none',
|
||||||
|
'&:hover': { textDecoration: 'underline' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Impressum
|
||||||
|
</MuiLink>
|
||||||
|
<MuiLink
|
||||||
|
component={Link}
|
||||||
|
to="/privacy"
|
||||||
|
color="inherit"
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
textDecoration: 'none',
|
||||||
|
'&:hover': { textDecoration: 'underline' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Privacy Policy
|
||||||
|
</MuiLink>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Divider sx={{ my: 2, opacity: 0.5 }} />
|
<Divider sx={{ my: 2, opacity: 0.5 }} />
|
||||||
<Typography variant="caption" color="text.secondary" align="center" display="block">
|
<Typography variant="caption" color="text.secondary" align="center" display="block">
|
||||||
{t('nav.about')} • Fan Project
|
{t('nav.about')} • Fan Project
|
||||||
</Typography>
|
</Typography>
|
||||||
</Container>
|
</Container>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -2,277 +2,315 @@ import React, { useRef, useEffect } from 'react';
|
|||||||
import { alpha, useTheme } from '@mui/material';
|
import { alpha, useTheme } from '@mui/material';
|
||||||
|
|
||||||
interface GenerativeBannerProps {
|
interface GenerativeBannerProps {
|
||||||
username: string;
|
username: string;
|
||||||
palette: string[];
|
palette: string[];
|
||||||
ratingsCount: number;
|
ratingsCount: number;
|
||||||
followersCount: number;
|
followersCount: number;
|
||||||
settings?: {
|
settings?: {
|
||||||
nodeCountBase?: number;
|
nodeCountBase?: number;
|
||||||
connectionDist?: number;
|
connectionDist?: number;
|
||||||
speed?: number;
|
speed?: number;
|
||||||
opacity?: number;
|
opacity?: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const GenerativeBanner: React.FC<GenerativeBannerProps> = ({ username, palette, ratingsCount, followersCount, settings }) => {
|
const GenerativeBanner: React.FC<GenerativeBannerProps> = ({
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
username,
|
||||||
const theme = useTheme();
|
palette,
|
||||||
|
ratingsCount,
|
||||||
const nodeCountBase = settings?.nodeCountBase ?? 60;
|
followersCount,
|
||||||
const connectionDist = settings?.connectionDist ?? 140;
|
settings,
|
||||||
const speed = settings?.speed ?? 0.005;
|
}) => {
|
||||||
const globalOpacity = settings?.opacity ?? 0.35;
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
const getSeed = (str: string) => {
|
const nodeCountBase = settings?.nodeCountBase ?? 60;
|
||||||
let hash = 0;
|
const connectionDist = settings?.connectionDist ?? 140;
|
||||||
for (let i = 0; i < str.length; i++) {
|
const speed = settings?.speed ?? 0.005;
|
||||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
const globalOpacity = settings?.opacity ?? 0.35;
|
||||||
}
|
|
||||||
return hash;
|
const getSeed = (str: string) => {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d', { alpha: true });
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
let animationFrameId: number;
|
||||||
|
const seed = getSeed(username);
|
||||||
|
|
||||||
|
// Configuration - High density for "complex web" feel
|
||||||
|
const nodeCount = Math.min(nodeCountBase + Math.floor(ratingsCount * 1.5), 120);
|
||||||
|
const colors =
|
||||||
|
palette.length >= 2
|
||||||
|
? palette
|
||||||
|
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||||
|
|
||||||
|
interface Node {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
baseX: number;
|
||||||
|
baseY: number;
|
||||||
|
color: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nodes: Node[] = [];
|
||||||
|
|
||||||
|
const pseudoRandom = (offset: number) => {
|
||||||
|
const x = Math.sin(seed + offset) * 10000;
|
||||||
|
return x - Math.floor(x);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const initNodes = (width: number, height: number) => {
|
||||||
const canvas = canvasRef.current;
|
const newNodes = [];
|
||||||
if (!canvas) return;
|
for (let i = 0; i < nodeCount; i++) {
|
||||||
|
const bx = pseudoRandom(i) * width;
|
||||||
|
const by = pseudoRandom(i + 100) * height;
|
||||||
|
newNodes.push({
|
||||||
|
x: bx,
|
||||||
|
y: by,
|
||||||
|
baseX: bx,
|
||||||
|
baseY: by,
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
color: colors[i % colors.length],
|
||||||
|
size: 1 + pseudoRandom(i + 200) * 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return newNodes;
|
||||||
|
};
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d', { alpha: true });
|
const resize = () => {
|
||||||
if (!ctx) return;
|
const parent = canvas.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
const rect = parent.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = rect.width * dpr;
|
||||||
|
canvas.height = rect.height * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
canvas.style.width = `${rect.width}px`;
|
||||||
|
canvas.style.height = `${rect.height}px`;
|
||||||
|
nodes = initNodes(rect.width, rect.height);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let animationFrameId: number;
|
const resizeObserver = new ResizeObserver(() => resize());
|
||||||
const seed = getSeed(username);
|
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
||||||
|
resize();
|
||||||
// Configuration - High density for "complex web" feel
|
|
||||||
const nodeCount = Math.min(nodeCountBase + Math.floor(ratingsCount * 1.5), 120);
|
|
||||||
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
|
||||||
|
|
||||||
interface Node {
|
const mouse = { x: -1000, y: -1000, down: false };
|
||||||
x: number;
|
|
||||||
y: number;
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
vx: number;
|
const rect = canvas.getBoundingClientRect();
|
||||||
vy: number;
|
mouse.x = e.clientX - rect.left;
|
||||||
baseX: number;
|
mouse.y = e.clientY - rect.top;
|
||||||
baseY: number;
|
};
|
||||||
color: string;
|
|
||||||
size: number;
|
const handleTouchMove = (e: TouchEvent) => {
|
||||||
|
if (e.touches.length > 0) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
mouse.x = e.touches[0].clientX - rect.left;
|
||||||
|
mouse.y = e.touches[0].clientY - rect.top;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseDown = () => {
|
||||||
|
mouse.down = true;
|
||||||
|
};
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
mouse.down = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parent = canvas.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
parent.addEventListener('mousemove', handleMouseMove);
|
||||||
|
parent.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||||
|
parent.addEventListener('mousedown', handleMouseDown);
|
||||||
|
parent.addEventListener('mouseup', handleMouseUp);
|
||||||
|
parent.addEventListener('touchstart', (e) => {
|
||||||
|
mouse.down = true;
|
||||||
|
handleTouchMove(e);
|
||||||
|
});
|
||||||
|
parent.addEventListener('touchend', handleMouseUp);
|
||||||
|
parent.addEventListener('mouseleave', () => {
|
||||||
|
mouse.x = -1000;
|
||||||
|
mouse.y = -1000;
|
||||||
|
mouse.down = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
const width = canvas.width / (window.devicePixelRatio || 1);
|
||||||
|
const height = canvas.height / (window.devicePixelRatio || 1);
|
||||||
|
|
||||||
|
if (!width || !height) {
|
||||||
|
animationFrameId = requestAnimationFrame(draw);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// Physics Update
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
// Return force (Elasticity) - slightly slower return
|
||||||
|
const dxBase = node.baseX - node.x;
|
||||||
|
const dyBase = node.baseY - node.y;
|
||||||
|
node.vx += dxBase * speed;
|
||||||
|
node.vy += dyBase * speed;
|
||||||
|
|
||||||
|
// Mouse interaction
|
||||||
|
const dxMouse = mouse.x - node.x;
|
||||||
|
const dyMouse = mouse.y - node.y;
|
||||||
|
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
|
||||||
|
|
||||||
|
if (mouse.down) {
|
||||||
|
// Grab and drag effect - less strong
|
||||||
|
const dragDist = 300;
|
||||||
|
if (distMouse < dragDist) {
|
||||||
|
const power = (dragDist - distMouse) / dragDist;
|
||||||
|
node.vx += dxMouse * power * 0.08;
|
||||||
|
node.vy += dyMouse * power * 0.08;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Hover disturbance - less strong pushing effect
|
||||||
|
const hoverDist = 150;
|
||||||
|
if (distMouse < hoverDist) {
|
||||||
|
const power = (hoverDist - distMouse) / hoverDist;
|
||||||
|
node.vx -= dxMouse * power * 0.02;
|
||||||
|
node.vy -= dyMouse * power * 0.02;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let nodes: Node[] = [];
|
// Damping (Friction) - stronger damping for less "fast" feel
|
||||||
|
node.vx *= 0.88;
|
||||||
const pseudoRandom = (offset: number) => {
|
node.vy *= 0.88;
|
||||||
const x = Math.sin(seed + offset) * 10000;
|
|
||||||
return x - Math.floor(x);
|
|
||||||
};
|
|
||||||
|
|
||||||
const initNodes = (width: number, height: number) => {
|
// Update Position
|
||||||
const newNodes = [];
|
node.x += node.vx;
|
||||||
for (let i = 0; i < nodeCount; i++) {
|
node.y += node.vy;
|
||||||
const bx = pseudoRandom(i) * width;
|
});
|
||||||
const by = pseudoRandom(i + 100) * height;
|
|
||||||
newNodes.push({
|
// Draw Mesh (Connections)
|
||||||
x: bx, y: by,
|
ctx.lineWidth = 1;
|
||||||
baseX: bx, baseY: by,
|
|
||||||
vx: 0, vy: 0,
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
color: colors[i % colors.length],
|
let currentConnections = 0;
|
||||||
size: 1 + pseudoRandom(i + 200) * 3
|
// We only connect a few to keep it a "web" and not a solid block
|
||||||
});
|
for (let j = i + 1; j < nodes.length && currentConnections < 6; j++) {
|
||||||
|
const dx = nodes[i].x - nodes[j].x;
|
||||||
|
const dy = nodes[i].y - nodes[j].y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (dist < connectionDist) {
|
||||||
|
currentConnections++;
|
||||||
|
const opacity = (1 - dist / connectionDist) * globalOpacity;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(nodes[i].x, nodes[i].y);
|
||||||
|
ctx.lineTo(nodes[j].x, nodes[j].y);
|
||||||
|
|
||||||
|
// Dynamic gradient for the line
|
||||||
|
const lineGrad = ctx.createLinearGradient(
|
||||||
|
nodes[i].x,
|
||||||
|
nodes[i].y,
|
||||||
|
nodes[j].x,
|
||||||
|
nodes[j].y,
|
||||||
|
);
|
||||||
|
lineGrad.addColorStop(0, alpha(nodes[i].color, opacity));
|
||||||
|
lineGrad.addColorStop(1, alpha(nodes[j].color, opacity));
|
||||||
|
|
||||||
|
ctx.strokeStyle = lineGrad;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Fill some triangles for complexity
|
||||||
|
if (dist < connectionDist * 0.6 && pseudoRandom(i + j) > 0.92) {
|
||||||
|
for (let k = j + 1; k < nodes.length; k++) {
|
||||||
|
const d3x = nodes[i].x - nodes[k].x;
|
||||||
|
const d3y = nodes[i].y - nodes[k].y;
|
||||||
|
if (Math.sqrt(d3x * d3x + d3y * d3y) < connectionDist * 0.6) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(nodes[i].x, nodes[i].y);
|
||||||
|
ctx.lineTo(nodes[j].x, nodes[j].y);
|
||||||
|
ctx.lineTo(nodes[k].x, nodes[k].y);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = alpha(nodes[i].color, 0.04);
|
||||||
|
ctx.fill();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return newNodes;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const resize = () => {
|
|
||||||
const parent = canvas.parentElement;
|
|
||||||
if (parent) {
|
|
||||||
const rect = parent.getBoundingClientRect();
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
canvas.width = rect.width * dpr;
|
|
||||||
canvas.height = rect.height * dpr;
|
|
||||||
ctx.scale(dpr, dpr);
|
|
||||||
canvas.style.width = `${rect.width}px`;
|
|
||||||
canvas.style.height = `${rect.height}px`;
|
|
||||||
nodes = initNodes(rect.width, rect.height);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => resize());
|
|
||||||
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
|
||||||
resize();
|
|
||||||
|
|
||||||
let mouse = { x: -1000, y: -1000, down: false };
|
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
mouse.x = e.clientX - rect.left;
|
|
||||||
mouse.y = e.clientY - rect.top;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchMove = (e: TouchEvent) => {
|
|
||||||
if (e.touches.length > 0) {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
mouse.x = e.touches[0].clientX - rect.left;
|
|
||||||
mouse.y = e.touches[0].clientY - rect.top;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseDown = () => { mouse.down = true; };
|
|
||||||
const handleMouseUp = () => { mouse.down = false; };
|
|
||||||
|
|
||||||
const parent = canvas.parentElement;
|
|
||||||
if (parent) {
|
|
||||||
parent.addEventListener('mousemove', handleMouseMove);
|
|
||||||
parent.addEventListener('touchmove', handleTouchMove, { passive: false });
|
|
||||||
parent.addEventListener('mousedown', handleMouseDown);
|
|
||||||
parent.addEventListener('mouseup', handleMouseUp);
|
|
||||||
parent.addEventListener('touchstart', (e) => { mouse.down = true; handleTouchMove(e); });
|
|
||||||
parent.addEventListener('touchend', handleMouseUp);
|
|
||||||
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; });
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const draw = () => {
|
// Draw Nodes
|
||||||
const width = canvas.width / (window.devicePixelRatio || 1);
|
nodes.forEach((node) => {
|
||||||
const height = canvas.height / (window.devicePixelRatio || 1);
|
ctx.beginPath();
|
||||||
|
ctx.arc(node.x, node.y, node.size, 0, Math.PI * 2);
|
||||||
if (!width || !height) {
|
ctx.fillStyle = node.color;
|
||||||
animationFrameId = requestAnimationFrame(draw);
|
ctx.fill();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, width, height);
|
// Core glow
|
||||||
|
ctx.beginPath();
|
||||||
// Physics Update
|
ctx.arc(node.x, node.y, node.size * 2.5, 0, Math.PI * 2);
|
||||||
nodes.forEach((node) => {
|
ctx.fillStyle = alpha(node.color, 0.15);
|
||||||
// Return force (Elasticity) - slightly slower return
|
ctx.fill();
|
||||||
const dxBase = node.baseX - node.x;
|
});
|
||||||
const dyBase = node.baseY - node.y;
|
|
||||||
node.vx += dxBase * speed;
|
|
||||||
node.vy += dyBase * speed;
|
|
||||||
|
|
||||||
// Mouse interaction
|
animationFrameId = requestAnimationFrame(draw);
|
||||||
const dxMouse = mouse.x - node.x;
|
};
|
||||||
const dyMouse = mouse.y - node.y;
|
|
||||||
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
|
|
||||||
|
|
||||||
if (mouse.down) {
|
|
||||||
// Grab and drag effect - less strong
|
|
||||||
const dragDist = 300;
|
|
||||||
if (distMouse < dragDist) {
|
|
||||||
const power = (dragDist - distMouse) / dragDist;
|
|
||||||
node.vx += dxMouse * power * 0.08;
|
|
||||||
node.vy += dyMouse * power * 0.08;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Hover disturbance - less strong pushing effect
|
|
||||||
const hoverDist = 150;
|
|
||||||
if (distMouse < hoverDist) {
|
|
||||||
const power = (hoverDist - distMouse) / hoverDist;
|
|
||||||
node.vx -= dxMouse * power * 0.02;
|
|
||||||
node.vy -= dyMouse * power * 0.02;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Damping (Friction) - stronger damping for less "fast" feel
|
draw();
|
||||||
node.vx *= 0.88;
|
|
||||||
node.vy *= 0.88;
|
|
||||||
|
|
||||||
// Update Position
|
return () => {
|
||||||
node.x += node.vx;
|
resizeObserver.disconnect();
|
||||||
node.y += node.vy;
|
if (parent) {
|
||||||
});
|
parent.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
parent.removeEventListener('touchmove', handleTouchMove);
|
||||||
|
parent.removeEventListener('mousedown', handleMouseDown);
|
||||||
|
parent.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
parent.removeEventListener('touchstart', handleMouseDown);
|
||||||
|
parent.removeEventListener('touchend', handleMouseUp);
|
||||||
|
}
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
username,
|
||||||
|
palette,
|
||||||
|
ratingsCount,
|
||||||
|
followersCount,
|
||||||
|
theme,
|
||||||
|
nodeCountBase,
|
||||||
|
connectionDist,
|
||||||
|
speed,
|
||||||
|
globalOpacity,
|
||||||
|
]);
|
||||||
|
|
||||||
// Draw Mesh (Connections)
|
return (
|
||||||
ctx.lineWidth = 1;
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
for (let i = 0; i < nodes.length; i++) {
|
style={{
|
||||||
let currentConnections = 0;
|
position: 'absolute',
|
||||||
// We only connect a few to keep it a "web" and not a solid block
|
top: 0,
|
||||||
for (let j = i + 1; j < nodes.length && currentConnections < 6; j++) {
|
left: 0,
|
||||||
const dx = nodes[i].x - nodes[j].x;
|
width: '100%',
|
||||||
const dy = nodes[i].y - nodes[j].y;
|
height: '100%',
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
pointerEvents: 'none',
|
||||||
|
zIndex: 1,
|
||||||
if (dist < connectionDist) {
|
mixBlendMode: theme.palette.mode === 'light' ? 'multiply' : 'screen',
|
||||||
currentConnections++;
|
}}
|
||||||
const opacity = (1 - dist / connectionDist) * globalOpacity;
|
/>
|
||||||
ctx.beginPath();
|
);
|
||||||
ctx.moveTo(nodes[i].x, nodes[i].y);
|
|
||||||
ctx.lineTo(nodes[j].x, nodes[j].y);
|
|
||||||
|
|
||||||
// Dynamic gradient for the line
|
|
||||||
const lineGrad = ctx.createLinearGradient(nodes[i].x, nodes[i].y, nodes[j].x, nodes[j].y);
|
|
||||||
lineGrad.addColorStop(0, alpha(nodes[i].color, opacity));
|
|
||||||
lineGrad.addColorStop(1, alpha(nodes[j].color, opacity));
|
|
||||||
|
|
||||||
ctx.strokeStyle = lineGrad;
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Fill some triangles for complexity
|
|
||||||
if (dist < connectionDist * 0.6 && pseudoRandom(i + j) > 0.92) {
|
|
||||||
for (let k = j + 1; k < nodes.length; k++) {
|
|
||||||
const d3x = nodes[i].x - nodes[k].x;
|
|
||||||
const d3y = nodes[i].y - nodes[k].y;
|
|
||||||
if (Math.sqrt(d3x*d3x + d3y*d3y) < connectionDist * 0.6) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(nodes[i].x, nodes[i].y);
|
|
||||||
ctx.lineTo(nodes[j].x, nodes[j].y);
|
|
||||||
ctx.lineTo(nodes[k].x, nodes[k].y);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fillStyle = alpha(nodes[i].color, 0.04);
|
|
||||||
ctx.fill();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw Nodes
|
|
||||||
nodes.forEach(node => {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(node.x, node.y, node.size, 0, Math.PI * 2);
|
|
||||||
ctx.fillStyle = node.color;
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
// Core glow
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(node.x, node.y, node.size * 2.5, 0, Math.PI * 2);
|
|
||||||
ctx.fillStyle = alpha(node.color, 0.15);
|
|
||||||
ctx.fill();
|
|
||||||
});
|
|
||||||
|
|
||||||
animationFrameId = requestAnimationFrame(draw);
|
|
||||||
};
|
|
||||||
|
|
||||||
draw();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
if (parent) {
|
|
||||||
parent.removeEventListener('mousemove', handleMouseMove);
|
|
||||||
parent.removeEventListener('touchmove', handleTouchMove);
|
|
||||||
parent.removeEventListener('mousedown', handleMouseDown);
|
|
||||||
parent.removeEventListener('mouseup', handleMouseUp);
|
|
||||||
parent.removeEventListener('touchstart', handleMouseDown);
|
|
||||||
parent.removeEventListener('touchend', handleMouseUp);
|
|
||||||
}
|
|
||||||
cancelAnimationFrame(animationFrameId);
|
|
||||||
};
|
|
||||||
}, [username, palette, ratingsCount, followersCount, theme, nodeCountBase, connectionDist, speed, globalOpacity]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
zIndex: 1,
|
|
||||||
mixBlendMode: theme.palette.mode === 'light' ? 'multiply' : 'screen'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default GenerativeBanner;
|
export default GenerativeBanner;
|
||||||
@@ -2,222 +2,244 @@ import React, { useRef, useEffect, useMemo } from 'react';
|
|||||||
import { alpha, useTheme } from '@mui/material';
|
import { alpha, useTheme } from '@mui/material';
|
||||||
|
|
||||||
interface GenerativeFlowProps {
|
interface GenerativeFlowProps {
|
||||||
username: string;
|
username: string;
|
||||||
palette: string[];
|
palette: string[];
|
||||||
ratingsCount: number;
|
ratingsCount: number;
|
||||||
followersCount: number;
|
followersCount: number;
|
||||||
settings?: {
|
settings?: {
|
||||||
blobCount?: number;
|
blobCount?: number;
|
||||||
flowSpeed?: number;
|
flowSpeed?: number;
|
||||||
vibrancy?: number;
|
vibrancy?: number;
|
||||||
blur?: number;
|
blur?: number;
|
||||||
distortion?: number;
|
distortion?: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette, settings }) => {
|
const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette, settings }) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const blobCount = settings?.blobCount ?? 8;
|
|
||||||
const flowSpeed = settings?.flowSpeed ?? 0.008;
|
|
||||||
const vibrancy = settings?.vibrancy ?? 0.8;
|
|
||||||
const blurAmount = settings?.blur ?? 60;
|
|
||||||
const distortionFactor = settings?.distortion ?? 1.2;
|
|
||||||
|
|
||||||
const getSeed = (str: string) => {
|
const blobCount = settings?.blobCount ?? 8;
|
||||||
let hash = 0;
|
const flowSpeed = settings?.flowSpeed ?? 0.008;
|
||||||
for (let i = 0; i < str.length; i++) {
|
const vibrancy = settings?.vibrancy ?? 0.8;
|
||||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
const blurAmount = settings?.blur ?? 60;
|
||||||
}
|
const distortionFactor = settings?.distortion ?? 1.2;
|
||||||
return hash;
|
|
||||||
|
const getSeed = (str: string) => {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
};
|
||||||
|
|
||||||
|
const seed = useMemo(() => getSeed(username), [username]);
|
||||||
|
|
||||||
|
const brightColors = useMemo(() => {
|
||||||
|
const colors =
|
||||||
|
palette.length >= 2
|
||||||
|
? palette
|
||||||
|
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||||
|
const getBrightness = (hex: string) => {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16) || 0;
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16) || 0;
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16) || 0;
|
||||||
|
return (r * 299 + g * 587 + b * 114) / 1000;
|
||||||
|
};
|
||||||
|
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
|
||||||
|
return sorted.slice(0, 4);
|
||||||
|
}, [palette, theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d', { alpha: true });
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
let animationFrameId: number;
|
||||||
|
let time = 0;
|
||||||
|
|
||||||
|
interface Blob {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
ox: number;
|
||||||
|
oy: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
phase: number;
|
||||||
|
speedFactor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let blobs: Blob[] = [];
|
||||||
|
|
||||||
|
const pseudoRandom = (offset: number) => {
|
||||||
|
const x = Math.sin(seed + offset) * 10000;
|
||||||
|
return x - Math.floor(x);
|
||||||
};
|
};
|
||||||
|
|
||||||
const seed = useMemo(() => getSeed(username), [username]);
|
const initBlobs = (width: number, height: number) => {
|
||||||
|
const newBlobs = [];
|
||||||
|
for (let i = 0; i < blobCount; i++) {
|
||||||
|
const rx = pseudoRandom(i) * width;
|
||||||
|
const ry = pseudoRandom(i + 500) * height;
|
||||||
|
newBlobs.push({
|
||||||
|
x: rx,
|
||||||
|
y: ry,
|
||||||
|
ox: rx,
|
||||||
|
oy: ry,
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
size: width / 4 + pseudoRandom(i + 1000) * (width / 3),
|
||||||
|
color: brightColors[i % brightColors.length],
|
||||||
|
phase: pseudoRandom(i + 1500) * Math.PI * 2,
|
||||||
|
speedFactor: 0.5 + pseudoRandom(i + 2000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return newBlobs;
|
||||||
|
};
|
||||||
|
|
||||||
const brightColors = useMemo(() => {
|
const resize = () => {
|
||||||
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
const parent = canvas.parentElement;
|
||||||
const getBrightness = (hex: string) => {
|
if (parent) {
|
||||||
const r = parseInt(hex.slice(1, 3), 16) || 0;
|
const rect = parent.getBoundingClientRect();
|
||||||
const g = parseInt(hex.slice(3, 5), 16) || 0;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const b = parseInt(hex.slice(5, 7), 16) || 0;
|
canvas.width = rect.width * dpr;
|
||||||
return (r * 299 + g * 587 + b * 114) / 1000;
|
canvas.height = rect.height * dpr;
|
||||||
};
|
ctx.scale(dpr, dpr);
|
||||||
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
|
canvas.style.width = `${rect.width}px`;
|
||||||
return sorted.slice(0, 4);
|
canvas.style.height = `${rect.height}px`;
|
||||||
}, [palette, theme]);
|
blobs = initBlobs(rect.width, rect.height);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const resizeObserver = new ResizeObserver(() => resize());
|
||||||
const canvas = canvasRef.current;
|
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
||||||
if (!canvas) return;
|
resize();
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d', { alpha: true });
|
const mouse = { x: -1000, y: -1000 };
|
||||||
if (!ctx) return;
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
mouse.x = e.clientX - rect.left;
|
||||||
|
mouse.y = e.clientY - rect.top;
|
||||||
|
};
|
||||||
|
|
||||||
let animationFrameId: number;
|
const parent = canvas.parentElement;
|
||||||
let time = 0;
|
if (parent) {
|
||||||
|
parent.addEventListener('mousemove', handleMouseMove);
|
||||||
|
parent.addEventListener('mouseleave', () => {
|
||||||
|
mouse.x = -1000;
|
||||||
|
mouse.y = -1000;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
interface Blob {
|
const draw = () => {
|
||||||
x: number;
|
const width = canvas.width / (window.devicePixelRatio || 1);
|
||||||
y: number;
|
const height = canvas.height / (window.devicePixelRatio || 1);
|
||||||
ox: number;
|
if (!width || !height) {
|
||||||
oy: number;
|
animationFrameId = requestAnimationFrame(draw);
|
||||||
vx: number;
|
return;
|
||||||
vy: number;
|
}
|
||||||
size: number;
|
|
||||||
color: string;
|
|
||||||
phase: number;
|
|
||||||
speedFactor: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let blobs: Blob[] = [];
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
const pseudoRandom = (offset: number) => {
|
|
||||||
const x = Math.sin(seed + offset) * 10000;
|
|
||||||
return x - Math.floor(x);
|
|
||||||
};
|
|
||||||
|
|
||||||
const initBlobs = (width: number, height: number) => {
|
// Background Base
|
||||||
const newBlobs = [];
|
const isLight = theme.palette.mode === 'light';
|
||||||
for (let i = 0; i < blobCount; i++) {
|
const bgGrad = ctx.createRadialGradient(
|
||||||
const rx = pseudoRandom(i) * width;
|
width / 2,
|
||||||
const ry = pseudoRandom(i + 500) * height;
|
height / 2,
|
||||||
newBlobs.push({
|
0,
|
||||||
x: rx, y: ry,
|
width / 2,
|
||||||
ox: rx, oy: ry,
|
height / 2,
|
||||||
vx: 0, vy: 0,
|
width,
|
||||||
size: (width / 4) + pseudoRandom(i + 1000) * (width / 3),
|
);
|
||||||
color: brightColors[i % brightColors.length],
|
if (isLight) {
|
||||||
phase: pseudoRandom(i + 1500) * Math.PI * 2,
|
// For light themes, use a deeper, more saturated version of the theme color
|
||||||
speedFactor: 0.5 + pseudoRandom(i + 2000)
|
bgGrad.addColorStop(
|
||||||
});
|
0,
|
||||||
}
|
alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.6),
|
||||||
return newBlobs;
|
);
|
||||||
};
|
bgGrad.addColorStop(
|
||||||
|
1,
|
||||||
|
alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.9),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
bgGrad.addColorStop(0, '#0a0a1a');
|
||||||
|
bgGrad.addColorStop(1, '#020205');
|
||||||
|
}
|
||||||
|
ctx.fillStyle = bgGrad;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
const resize = () => {
|
time += flowSpeed;
|
||||||
const parent = canvas.parentElement;
|
|
||||||
if (parent) {
|
|
||||||
const rect = parent.getBoundingClientRect();
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
canvas.width = rect.width * dpr;
|
|
||||||
canvas.height = rect.height * dpr;
|
|
||||||
ctx.scale(dpr, dpr);
|
|
||||||
canvas.style.width = `${rect.width}px`;
|
|
||||||
canvas.style.height = `${rect.height}px`;
|
|
||||||
blobs = initBlobs(rect.width, rect.height);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => resize());
|
// Apply filter for "Liquid" look
|
||||||
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
ctx.filter = `blur(${blurAmount}px)`;
|
||||||
resize();
|
ctx.globalCompositeOperation = 'screen';
|
||||||
|
|
||||||
let mouse = { x: -1000, y: -1000 };
|
blobs.forEach((b) => {
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
// Harmonic autonomous drift
|
||||||
const rect = canvas.getBoundingClientRect();
|
const driftX = Math.sin(time * b.speedFactor + b.phase) * (width / 5);
|
||||||
mouse.x = e.clientX - rect.left;
|
const driftY = Math.cos(time * 0.8 * b.speedFactor + b.phase) * (height / 5);
|
||||||
mouse.y = e.clientY - rect.top;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parent = canvas.parentElement;
|
// Mouse distortion
|
||||||
if (parent) {
|
const dxMouse = mouse.x - b.ox;
|
||||||
parent.addEventListener('mousemove', handleMouseMove);
|
const dyMouse = mouse.y - b.oy;
|
||||||
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; });
|
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
|
||||||
}
|
const mouseForce = Math.max(0, (500 - distMouse) / 500);
|
||||||
|
|
||||||
const draw = () => {
|
const mX = dxMouse * mouseForce * distortionFactor;
|
||||||
const width = canvas.width / (window.devicePixelRatio || 1);
|
const mY = dyMouse * mouseForce * distortionFactor;
|
||||||
const height = canvas.height / (window.devicePixelRatio || 1);
|
|
||||||
if (!width || !height) {
|
|
||||||
animationFrameId = requestAnimationFrame(draw);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, width, height);
|
b.x = b.ox + driftX + mX;
|
||||||
|
b.y = b.oy + driftY + mY;
|
||||||
// Background Base
|
|
||||||
const isLight = theme.palette.mode === 'light';
|
|
||||||
const bgGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width);
|
|
||||||
if (isLight) {
|
|
||||||
// For light themes, use a deeper, more saturated version of the theme color
|
|
||||||
bgGrad.addColorStop(0, alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.6));
|
|
||||||
bgGrad.addColorStop(1, alpha(brightColors[brightColors.length - 1] || brightColors[0], 0.9));
|
|
||||||
} else {
|
|
||||||
bgGrad.addColorStop(0, '#0a0a1a');
|
|
||||||
bgGrad.addColorStop(1, '#020205');
|
|
||||||
}
|
|
||||||
ctx.fillStyle = bgGrad;
|
|
||||||
ctx.fillRect(0, 0, width, height);
|
|
||||||
|
|
||||||
time += flowSpeed;
|
// Pulsing size
|
||||||
|
const pulse = 1 + Math.sin(time + b.phase) * 0.1;
|
||||||
|
const currentSize = b.size * pulse;
|
||||||
|
|
||||||
// Apply filter for "Liquid" look
|
const grad = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, currentSize);
|
||||||
ctx.filter = `blur(${blurAmount}px)`;
|
grad.addColorStop(0, alpha(b.color, vibrancy));
|
||||||
ctx.globalCompositeOperation = 'screen';
|
grad.addColorStop(1, alpha(b.color, 0));
|
||||||
|
|
||||||
blobs.forEach((b) => {
|
ctx.fillStyle = grad;
|
||||||
// Harmonic autonomous drift
|
ctx.beginPath();
|
||||||
const driftX = Math.sin(time * b.speedFactor + b.phase) * (width / 5);
|
ctx.arc(b.x, b.y, currentSize, 0, Math.PI * 2);
|
||||||
const driftY = Math.cos(time * 0.8 * b.speedFactor + b.phase) * (height / 5);
|
ctx.fill();
|
||||||
|
});
|
||||||
// Mouse distortion
|
|
||||||
const dxMouse = mouse.x - b.ox;
|
|
||||||
const dyMouse = mouse.y - b.oy;
|
|
||||||
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
|
|
||||||
const mouseForce = Math.max(0, (500 - distMouse) / 500);
|
|
||||||
|
|
||||||
const mX = dxMouse * mouseForce * distortionFactor;
|
|
||||||
const mY = dyMouse * mouseForce * distortionFactor;
|
|
||||||
|
|
||||||
b.x = b.ox + driftX + mX;
|
ctx.filter = 'none';
|
||||||
b.y = b.oy + driftY + mY;
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
|
|
||||||
// Pulsing size
|
animationFrameId = requestAnimationFrame(draw);
|
||||||
const pulse = 1 + Math.sin(time + b.phase) * 0.1;
|
};
|
||||||
const currentSize = b.size * pulse;
|
|
||||||
|
|
||||||
const grad = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, currentSize);
|
draw();
|
||||||
grad.addColorStop(0, alpha(b.color, vibrancy));
|
|
||||||
grad.addColorStop(1, alpha(b.color, 0));
|
|
||||||
|
|
||||||
ctx.fillStyle = grad;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(b.x, b.y, currentSize, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.filter = 'none';
|
return () => {
|
||||||
ctx.globalCompositeOperation = 'source-over';
|
resizeObserver.disconnect();
|
||||||
|
if (parent) {
|
||||||
|
parent.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
}
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
};
|
||||||
|
}, [seed, brightColors, blobCount, flowSpeed, vibrancy, blurAmount, distortionFactor, theme]);
|
||||||
|
|
||||||
animationFrameId = requestAnimationFrame(draw);
|
return (
|
||||||
};
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
draw();
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
return () => {
|
top: 0,
|
||||||
resizeObserver.disconnect();
|
left: 0,
|
||||||
if (parent) {
|
width: '100%',
|
||||||
parent.removeEventListener('mousemove', handleMouseMove);
|
height: '100%',
|
||||||
}
|
pointerEvents: 'none',
|
||||||
cancelAnimationFrame(animationFrameId);
|
zIndex: 1,
|
||||||
};
|
}}
|
||||||
}, [seed, brightColors, blobCount, flowSpeed, vibrancy, blurAmount, distortionFactor, theme]);
|
/>
|
||||||
|
);
|
||||||
return (
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
zIndex: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default GenerativeFlowBanner;
|
export default GenerativeFlowBanner;
|
||||||
@@ -2,268 +2,297 @@ import React, { useRef, useEffect, useMemo } from 'react';
|
|||||||
import { alpha, useTheme } from '@mui/material';
|
import { alpha, useTheme } from '@mui/material';
|
||||||
|
|
||||||
interface HextechBannerProps {
|
interface HextechBannerProps {
|
||||||
username: string;
|
username: string;
|
||||||
palette: string[];
|
palette: string[];
|
||||||
ratingsCount: number;
|
ratingsCount: number;
|
||||||
followersCount: number;
|
followersCount: number;
|
||||||
settings?: {
|
settings?: {
|
||||||
gridSize?: number;
|
gridSize?: number;
|
||||||
pulseSpeed?: number;
|
pulseSpeed?: number;
|
||||||
glowIntensity?: number;
|
glowIntensity?: number;
|
||||||
streamCount?: number;
|
streamCount?: number;
|
||||||
distortion?: number;
|
distortion?: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settings }) => {
|
const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settings }) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const gridSize = settings?.gridSize ?? 40;
|
|
||||||
const pulseSpeed = settings?.pulseSpeed ?? 0.02;
|
|
||||||
const glowIntensity = settings?.glowIntensity ?? 0.8;
|
|
||||||
const streamCount = settings?.streamCount ?? 12;
|
|
||||||
const distortionFactor = settings?.distortion ?? 0.5;
|
|
||||||
|
|
||||||
const getSeed = (str: string) => {
|
const gridSize = settings?.gridSize ?? 40;
|
||||||
let hash = 0;
|
const pulseSpeed = settings?.pulseSpeed ?? 0.02;
|
||||||
for (let i = 0; i < str.length; i++) {
|
const glowIntensity = settings?.glowIntensity ?? 0.8;
|
||||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
const streamCount = settings?.streamCount ?? 12;
|
||||||
}
|
const distortionFactor = settings?.distortion ?? 0.5;
|
||||||
return hash;
|
|
||||||
|
const getSeed = (str: string) => {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
};
|
||||||
|
|
||||||
|
const seed = useMemo(() => getSeed(username), [username]);
|
||||||
|
|
||||||
|
const brightColors = useMemo(() => {
|
||||||
|
const colors =
|
||||||
|
palette.length >= 2
|
||||||
|
? palette
|
||||||
|
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||||
|
const getBrightness = (hex: string) => {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16) || 0;
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16) || 0;
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16) || 0;
|
||||||
|
return (r * 299 + g * 587 + b * 114) / 1000;
|
||||||
|
};
|
||||||
|
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
|
||||||
|
return sorted.slice(0, 3);
|
||||||
|
}, [palette, theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d', { alpha: true });
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
let animationFrameId: number;
|
||||||
|
let time = 0;
|
||||||
|
|
||||||
|
const pseudoRandom = (offset: number) => {
|
||||||
|
const x = Math.sin(seed + offset) * 10000;
|
||||||
|
return x - Math.floor(x);
|
||||||
};
|
};
|
||||||
|
|
||||||
const seed = useMemo(() => getSeed(username), [username]);
|
// Mana Stream Logic
|
||||||
|
interface Stream {
|
||||||
|
segments: { x: number; y: number }[];
|
||||||
|
color: string;
|
||||||
|
life: number;
|
||||||
|
speed: number;
|
||||||
|
}
|
||||||
|
|
||||||
const brightColors = useMemo(() => {
|
let streams: Stream[] = [];
|
||||||
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
|
||||||
const getBrightness = (hex: string) => {
|
|
||||||
const r = parseInt(hex.slice(1, 3), 16) || 0;
|
|
||||||
const g = parseInt(hex.slice(3, 5), 16) || 0;
|
|
||||||
const b = parseInt(hex.slice(5, 7), 16) || 0;
|
|
||||||
return (r * 299 + g * 587 + b * 114) / 1000;
|
|
||||||
};
|
|
||||||
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
|
|
||||||
return sorted.slice(0, 3);
|
|
||||||
}, [palette, theme]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const createStream = (width: number, height: number) => {
|
||||||
const canvas = canvasRef.current;
|
const color = brightColors[Math.floor(pseudoRandom(time) * brightColors.length)];
|
||||||
if (!canvas) return;
|
const x = pseudoRandom(time + 1) * width;
|
||||||
|
const y = pseudoRandom(time + 2) * height;
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d', { alpha: true });
|
return {
|
||||||
if (!ctx) return;
|
segments: [{ x, y }],
|
||||||
|
color,
|
||||||
|
life: 1.0,
|
||||||
|
speed: 2 + pseudoRandom(time + 3) * 4,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
let animationFrameId: number;
|
const drawHexagon = (x: number, y: number, r: number, tilt: number) => {
|
||||||
let time = 0;
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const angle = (Math.PI / 3) * i + tilt;
|
||||||
|
const px = x + r * Math.cos(angle);
|
||||||
|
const py = y + r * Math.sin(angle);
|
||||||
|
if (i === 0) ctx.moveTo(px, py);
|
||||||
|
else ctx.lineTo(px, py);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
};
|
||||||
|
|
||||||
const pseudoRandom = (offset: number) => {
|
const resize = () => {
|
||||||
const x = Math.sin(seed + offset) * 10000;
|
const parent = canvas.parentElement;
|
||||||
return x - Math.floor(x);
|
if (parent) {
|
||||||
};
|
const rect = parent.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = rect.width * dpr;
|
||||||
|
canvas.height = rect.height * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
canvas.style.width = `${rect.width}px`;
|
||||||
|
canvas.style.height = `${rect.height}px`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Mana Stream Logic
|
const resizeObserver = new ResizeObserver(() => resize());
|
||||||
interface Stream {
|
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
||||||
segments: { x: number, y: number }[];
|
resize();
|
||||||
color: string;
|
|
||||||
life: number;
|
const mouse = { x: -1000, y: -1000, down: false };
|
||||||
speed: number;
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
mouse.x = e.clientX - rect.left;
|
||||||
|
mouse.y = e.clientY - rect.top;
|
||||||
|
};
|
||||||
|
const handleMouseDown = () => {
|
||||||
|
mouse.down = true;
|
||||||
|
};
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
mouse.down = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parent = canvas.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
parent.addEventListener('mousemove', handleMouseMove);
|
||||||
|
parent.addEventListener('mousedown', handleMouseDown);
|
||||||
|
parent.addEventListener('mouseup', handleMouseUp);
|
||||||
|
parent.addEventListener('mouseleave', () => {
|
||||||
|
mouse.x = -1000;
|
||||||
|
mouse.y = -1000;
|
||||||
|
mouse.down = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
const width = canvas.width / (window.devicePixelRatio || 1);
|
||||||
|
const height = canvas.height / (window.devicePixelRatio || 1);
|
||||||
|
if (!width || !height) {
|
||||||
|
animationFrameId = requestAnimationFrame(draw);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// Background
|
||||||
|
const isLight = theme.palette.mode === 'light';
|
||||||
|
const bgGrad = ctx.createRadialGradient(
|
||||||
|
width / 2,
|
||||||
|
height / 2,
|
||||||
|
0,
|
||||||
|
width / 2,
|
||||||
|
height / 2,
|
||||||
|
width,
|
||||||
|
);
|
||||||
|
if (isLight) {
|
||||||
|
// For light themes, use a deeper, more saturated version of the theme color
|
||||||
|
bgGrad.addColorStop(0, alpha(brightColors[2] || brightColors[0], 0.65));
|
||||||
|
bgGrad.addColorStop(1, alpha(brightColors[2] || brightColors[0], 0.95));
|
||||||
|
} else {
|
||||||
|
bgGrad.addColorStop(0, '#0a051a'); // Arcane Purple tint
|
||||||
|
bgGrad.addColorStop(1, '#020105');
|
||||||
|
}
|
||||||
|
ctx.fillStyle = bgGrad;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
time += pulseSpeed;
|
||||||
|
|
||||||
|
// 1. Draw Hex Grid
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
const h = gridSize * Math.sqrt(3);
|
||||||
|
const w = gridSize * 2;
|
||||||
|
|
||||||
|
for (let y = -gridSize; y < height + gridSize; y += h / 2) {
|
||||||
|
for (let x = -gridSize; x < width + gridSize; x += w * 0.75) {
|
||||||
|
const xOffset = Math.floor(y / (h / 2)) % 2 === 0 ? 0 : w * 0.375;
|
||||||
|
const finalX = x + xOffset;
|
||||||
|
const finalY = y;
|
||||||
|
|
||||||
|
// Apply Trippiness (Distortion)
|
||||||
|
const dist = Math.sqrt(Math.pow(finalX - mouse.x, 2) + Math.pow(finalY - mouse.y, 2));
|
||||||
|
const mouseImpact = Math.max(0, (200 - dist) / 200) * (mouse.down ? 2 : 1);
|
||||||
|
|
||||||
|
const dx =
|
||||||
|
Math.sin(time + finalX * 0.01) * gridSize * distortionFactor * (1 + mouseImpact);
|
||||||
|
const dy =
|
||||||
|
Math.cos(time + finalY * 0.01) * gridSize * distortionFactor * (1 + mouseImpact);
|
||||||
|
|
||||||
|
const drawX = finalX + dx;
|
||||||
|
const drawY = finalY + dy;
|
||||||
|
|
||||||
|
// Pulse glow
|
||||||
|
const pulseVal = 0.1 + Math.sin(time * 2 + (finalX + finalY) * 0.005) * 0.1;
|
||||||
|
const opacity = (0.1 + pulseVal + mouseImpact * 0.3) * glowIntensity;
|
||||||
|
|
||||||
|
ctx.strokeStyle = alpha(brightColors[0], opacity);
|
||||||
|
drawHexagon(drawX, drawY, gridSize * 0.9, time * 0.1);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Occasional Runes
|
||||||
|
if (pseudoRandom(Math.floor(finalX) + Math.floor(finalY)) > 0.92) {
|
||||||
|
ctx.fillStyle = alpha(brightColors[1], opacity * 0.5);
|
||||||
|
ctx.font = `${gridSize * 0.5}px serif`;
|
||||||
|
ctx.fillText('⚡', drawX - 5, drawY + 5);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let streams: Stream[] = [];
|
// 2. Process Mana Streams
|
||||||
|
if (streams.length < streamCount && Math.random() > 0.9) {
|
||||||
|
streams.push(createStream(width, height));
|
||||||
|
}
|
||||||
|
|
||||||
const createStream = (width: number, height: number) => {
|
ctx.globalCompositeOperation = 'screen';
|
||||||
const color = brightColors[Math.floor(pseudoRandom(time) * brightColors.length)];
|
streams.forEach((s, idx) => {
|
||||||
let x = pseudoRandom(time + 1) * width;
|
s.life -= 0.005;
|
||||||
let y = pseudoRandom(time + 2) * height;
|
const last = s.segments[s.segments.length - 1];
|
||||||
|
|
||||||
return {
|
|
||||||
segments: [{ x, y }],
|
|
||||||
color,
|
|
||||||
life: 1.0,
|
|
||||||
speed: 2 + pseudoRandom(time + 3) * 4
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const drawHexagon = (x: number, y: number, r: number, tilt: number) => {
|
const ang = pseudoRandom(time + idx) * Math.PI * 2;
|
||||||
ctx.beginPath();
|
const nextX = last.x + Math.cos(ang) * s.speed;
|
||||||
for (let i = 0; i < 6; i++) {
|
const nextY = last.y + Math.sin(ang) * s.speed;
|
||||||
const angle = (Math.PI / 3) * i + tilt;
|
|
||||||
const px = x + r * Math.cos(angle);
|
|
||||||
const py = y + r * Math.sin(angle);
|
|
||||||
if (i === 0) ctx.moveTo(px, py);
|
|
||||||
else ctx.lineTo(px, py);
|
|
||||||
}
|
|
||||||
ctx.closePath();
|
|
||||||
};
|
|
||||||
|
|
||||||
const resize = () => {
|
s.segments.push({ x: nextX, y: nextY });
|
||||||
const parent = canvas.parentElement;
|
if (s.segments.length > 20) s.segments.shift();
|
||||||
if (parent) {
|
|
||||||
const rect = parent.getBoundingClientRect();
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
canvas.width = rect.width * dpr;
|
|
||||||
canvas.height = rect.height * dpr;
|
|
||||||
ctx.scale(dpr, dpr);
|
|
||||||
canvas.style.width = `${rect.width}px`;
|
|
||||||
canvas.style.height = `${rect.height}px`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => resize());
|
ctx.beginPath();
|
||||||
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
ctx.strokeStyle = alpha(s.color, s.life * glowIntensity);
|
||||||
resize();
|
ctx.lineWidth = 2;
|
||||||
|
s.segments.forEach((seg, i) => {
|
||||||
|
if (i === 0) ctx.moveTo(seg.x, seg.y);
|
||||||
|
else ctx.lineTo(seg.x, seg.y);
|
||||||
|
});
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
let mouse = { x: -1000, y: -1000, down: false };
|
const head = s.segments[s.segments.length - 1];
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const g = ctx.createRadialGradient(head.x, head.y, 0, head.x, head.y, 15);
|
||||||
const rect = canvas.getBoundingClientRect();
|
g.addColorStop(0, alpha(s.color, s.life));
|
||||||
mouse.x = e.clientX - rect.left;
|
g.addColorStop(1, alpha(s.color, 0));
|
||||||
mouse.y = e.clientY - rect.top;
|
ctx.fillStyle = g;
|
||||||
};
|
ctx.beginPath();
|
||||||
const handleMouseDown = () => { mouse.down = true; };
|
ctx.arc(head.x, head.y, 15, 0, Math.PI * 2);
|
||||||
const handleMouseUp = () => { mouse.down = false; };
|
ctx.fill();
|
||||||
|
});
|
||||||
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
|
|
||||||
const parent = canvas.parentElement;
|
streams = streams.filter((s) => s.life > 0);
|
||||||
if (parent) {
|
|
||||||
parent.addEventListener('mousemove', handleMouseMove);
|
|
||||||
parent.addEventListener('mousedown', handleMouseDown);
|
|
||||||
parent.addEventListener('mouseup', handleMouseUp);
|
|
||||||
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
const draw = () => {
|
animationFrameId = requestAnimationFrame(draw);
|
||||||
const width = canvas.width / (window.devicePixelRatio || 1);
|
};
|
||||||
const height = canvas.height / (window.devicePixelRatio || 1);
|
|
||||||
if (!width || !height) {
|
|
||||||
animationFrameId = requestAnimationFrame(draw);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, width, height);
|
draw();
|
||||||
|
|
||||||
// Background
|
|
||||||
const isLight = theme.palette.mode === 'light';
|
|
||||||
const bgGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width);
|
|
||||||
if (isLight) {
|
|
||||||
// For light themes, use a deeper, more saturated version of the theme color
|
|
||||||
bgGrad.addColorStop(0, alpha(brightColors[2] || brightColors[0], 0.65));
|
|
||||||
bgGrad.addColorStop(1, alpha(brightColors[2] || brightColors[0], 0.95));
|
|
||||||
} else {
|
|
||||||
bgGrad.addColorStop(0, '#0a051a'); // Arcane Purple tint
|
|
||||||
bgGrad.addColorStop(1, '#020105');
|
|
||||||
}
|
|
||||||
ctx.fillStyle = bgGrad;
|
|
||||||
ctx.fillRect(0, 0, width, height);
|
|
||||||
|
|
||||||
time += pulseSpeed;
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
if (parent) {
|
||||||
|
parent.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
parent.removeEventListener('mousedown', handleMouseDown);
|
||||||
|
parent.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
}
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
seed,
|
||||||
|
brightColors,
|
||||||
|
gridSize,
|
||||||
|
pulseSpeed,
|
||||||
|
glowIntensity,
|
||||||
|
streamCount,
|
||||||
|
distortionFactor,
|
||||||
|
theme,
|
||||||
|
]);
|
||||||
|
|
||||||
// 1. Draw Hex Grid
|
return (
|
||||||
ctx.lineWidth = 1;
|
<canvas
|
||||||
const h = gridSize * Math.sqrt(3);
|
ref={canvasRef}
|
||||||
const w = gridSize * 2;
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
for (let y = -gridSize; y < height + gridSize; y += h/2) {
|
top: 0,
|
||||||
for (let x = -gridSize; x < width + gridSize; x += w * 0.75) {
|
left: 0,
|
||||||
const xOffset = (Math.floor(y / (h/2)) % 2 === 0) ? 0 : w * 0.375;
|
width: '100%',
|
||||||
const finalX = x + xOffset;
|
height: '100%',
|
||||||
const finalY = y;
|
pointerEvents: 'none',
|
||||||
|
zIndex: 1,
|
||||||
// Apply Trippiness (Distortion)
|
}}
|
||||||
const dist = Math.sqrt(Math.pow(finalX - mouse.x, 2) + Math.pow(finalY - mouse.y, 2));
|
/>
|
||||||
const mouseImpact = Math.max(0, (200 - dist) / 200) * (mouse.down ? 2 : 1);
|
);
|
||||||
|
|
||||||
const dx = Math.sin(time + (finalX * 0.01)) * gridSize * distortionFactor * (1 + mouseImpact);
|
|
||||||
const dy = Math.cos(time + (finalY * 0.01)) * gridSize * distortionFactor * (1 + mouseImpact);
|
|
||||||
|
|
||||||
const drawX = finalX + dx;
|
|
||||||
const drawY = finalY + dy;
|
|
||||||
|
|
||||||
// Pulse glow
|
|
||||||
const pulseVal = 0.1 + Math.sin(time * 2 + (finalX + finalY) * 0.005) * 0.1;
|
|
||||||
const opacity = (0.1 + pulseVal + mouseImpact * 0.3) * glowIntensity;
|
|
||||||
|
|
||||||
ctx.strokeStyle = alpha(brightColors[0], opacity);
|
|
||||||
drawHexagon(drawX, drawY, gridSize * 0.9, time * 0.1);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Occasional Runes
|
|
||||||
if (pseudoRandom(Math.floor(finalX) + Math.floor(finalY)) > 0.92) {
|
|
||||||
ctx.fillStyle = alpha(brightColors[1], opacity * 0.5);
|
|
||||||
ctx.font = `${gridSize * 0.5}px serif`;
|
|
||||||
ctx.fillText("⚡", drawX - 5, drawY + 5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Process Mana Streams
|
|
||||||
if (streams.length < streamCount && Math.random() > 0.9) {
|
|
||||||
streams.push(createStream(width, height));
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.globalCompositeOperation = 'screen';
|
|
||||||
streams.forEach((s, idx) => {
|
|
||||||
s.life -= 0.005;
|
|
||||||
const last = s.segments[s.segments.length - 1];
|
|
||||||
|
|
||||||
const ang = pseudoRandom(time + idx) * Math.PI * 2;
|
|
||||||
const nextX = last.x + Math.cos(ang) * s.speed;
|
|
||||||
const nextY = last.y + Math.sin(ang) * s.speed;
|
|
||||||
|
|
||||||
s.segments.push({ x: nextX, y: nextY });
|
|
||||||
if (s.segments.length > 20) s.segments.shift();
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.strokeStyle = alpha(s.color, s.life * glowIntensity);
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
s.segments.forEach((seg, i) => {
|
|
||||||
if (i === 0) ctx.moveTo(seg.x, seg.y);
|
|
||||||
else ctx.lineTo(seg.x, seg.y);
|
|
||||||
});
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
const head = s.segments[s.segments.length - 1];
|
|
||||||
const g = ctx.createRadialGradient(head.x, head.y, 0, head.x, head.y, 15);
|
|
||||||
g.addColorStop(0, alpha(s.color, s.life));
|
|
||||||
g.addColorStop(1, alpha(s.color, 0));
|
|
||||||
ctx.fillStyle = g;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(head.x, head.y, 15, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
});
|
|
||||||
ctx.globalCompositeOperation = 'source-over';
|
|
||||||
|
|
||||||
streams = streams.filter(s => s.life > 0);
|
|
||||||
|
|
||||||
animationFrameId = requestAnimationFrame(draw);
|
|
||||||
};
|
|
||||||
|
|
||||||
draw();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
if (parent) {
|
|
||||||
parent.removeEventListener('mousemove', handleMouseMove);
|
|
||||||
parent.removeEventListener('mousedown', handleMouseDown);
|
|
||||||
parent.removeEventListener('mouseup', handleMouseUp);
|
|
||||||
}
|
|
||||||
cancelAnimationFrame(animationFrameId);
|
|
||||||
};
|
|
||||||
}, [seed, brightColors, gridSize, pulseSpeed, glowIntensity, streamCount, distortionFactor, theme]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
zIndex: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default HextechBanner;
|
export default HextechBanner;
|
||||||
@@ -2,257 +2,287 @@ import React, { useRef, useEffect, useMemo } from 'react';
|
|||||||
import { alpha, useTheme } from '@mui/material';
|
import { alpha, useTheme } from '@mui/material';
|
||||||
|
|
||||||
interface HextechCorruptionBannerProps {
|
interface HextechCorruptionBannerProps {
|
||||||
username: string;
|
username: string;
|
||||||
palette: string[];
|
palette: string[];
|
||||||
ratingsCount: number;
|
ratingsCount: number;
|
||||||
followersCount: number;
|
followersCount: number;
|
||||||
settings?: {
|
settings?: {
|
||||||
cellCount?: number;
|
cellCount?: number;
|
||||||
shimmerSpeed?: number;
|
shimmerSpeed?: number;
|
||||||
glowIntensity?: number;
|
glowIntensity?: number;
|
||||||
instability?: number;
|
instability?: number;
|
||||||
shellDarkness?: number;
|
shellDarkness?: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({ username, palette, settings }) => {
|
const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
username,
|
||||||
const theme = useTheme();
|
palette,
|
||||||
|
settings,
|
||||||
const cellCount = settings?.cellCount ?? 40;
|
}) => {
|
||||||
const shimmerSpeed = settings?.shimmerSpeed ?? 0.015;
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const glowIntensity = settings?.glowIntensity ?? 0.9;
|
const theme = useTheme();
|
||||||
const instability = settings?.instability ?? 0.6;
|
|
||||||
const shellDarkness = settings?.shellDarkness ?? 0.85;
|
|
||||||
|
|
||||||
const getSeed = (str: string) => {
|
const cellCount = settings?.cellCount ?? 40;
|
||||||
let hash = 0;
|
const shimmerSpeed = settings?.shimmerSpeed ?? 0.015;
|
||||||
for (let i = 0; i < str.length; i++) {
|
const glowIntensity = settings?.glowIntensity ?? 0.9;
|
||||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
const instability = settings?.instability ?? 0.6;
|
||||||
}
|
const shellDarkness = settings?.shellDarkness ?? 0.85;
|
||||||
return hash;
|
|
||||||
|
const getSeed = (str: string) => {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
};
|
||||||
|
|
||||||
|
const seed = useMemo(() => getSeed(username), [username]);
|
||||||
|
|
||||||
|
const brightColors = useMemo(() => {
|
||||||
|
const colors =
|
||||||
|
palette.length >= 2
|
||||||
|
? palette
|
||||||
|
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||||
|
const getBrightness = (hex: string) => {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16) || 0;
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16) || 0;
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16) || 0;
|
||||||
|
return (r * 299 + g * 587 + b * 114) / 1000;
|
||||||
|
};
|
||||||
|
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
|
||||||
|
return sorted.slice(0, 3);
|
||||||
|
}, [palette, theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d', { alpha: true });
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
let animationFrameId: number;
|
||||||
|
let time = 0;
|
||||||
|
|
||||||
|
interface ControlPoint {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
ox: number;
|
||||||
|
oy: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
phase: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let points: ControlPoint[] = [];
|
||||||
|
|
||||||
|
const pseudoRandom = (offset: number) => {
|
||||||
|
const x = Math.sin(seed + offset) * 10000;
|
||||||
|
return x - Math.floor(x);
|
||||||
};
|
};
|
||||||
|
|
||||||
const seed = useMemo(() => getSeed(username), [username]);
|
const initPoints = (width: number, height: number) => {
|
||||||
|
const newPoints = [];
|
||||||
|
for (let i = 0; i < cellCount; i++) {
|
||||||
|
const rx = pseudoRandom(i) * width;
|
||||||
|
const ry = pseudoRandom(i + 500) * height;
|
||||||
|
newPoints.push({
|
||||||
|
x: rx,
|
||||||
|
y: ry,
|
||||||
|
ox: rx,
|
||||||
|
oy: ry,
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
phase: pseudoRandom(i + 1000) * Math.PI * 2,
|
||||||
|
color: brightColors[i % brightColors.length],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return newPoints;
|
||||||
|
};
|
||||||
|
|
||||||
const brightColors = useMemo(() => {
|
const resize = () => {
|
||||||
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
const parent = canvas.parentElement;
|
||||||
const getBrightness = (hex: string) => {
|
if (parent) {
|
||||||
const r = parseInt(hex.slice(1, 3), 16) || 0;
|
const rect = parent.getBoundingClientRect();
|
||||||
const g = parseInt(hex.slice(3, 5), 16) || 0;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const b = parseInt(hex.slice(5, 7), 16) || 0;
|
canvas.width = rect.width * dpr;
|
||||||
return (r * 299 + g * 587 + b * 114) / 1000;
|
canvas.height = rect.height * dpr;
|
||||||
};
|
ctx.scale(dpr, dpr);
|
||||||
const sorted = [...colors].sort((a, b) => getBrightness(b) - getBrightness(a));
|
canvas.style.width = `${rect.width}px`;
|
||||||
return sorted.slice(0, 3);
|
canvas.style.height = `${rect.height}px`;
|
||||||
}, [palette, theme]);
|
points = initPoints(rect.width, rect.height);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const resizeObserver = new ResizeObserver(() => resize());
|
||||||
const canvas = canvasRef.current;
|
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
||||||
if (!canvas) return;
|
resize();
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d', { alpha: true });
|
const mouse = { x: -1000, y: -1000, down: false };
|
||||||
if (!ctx) return;
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
mouse.x = e.clientX - rect.left;
|
||||||
|
mouse.y = e.clientY - rect.top;
|
||||||
|
};
|
||||||
|
const handleMouseDown = () => {
|
||||||
|
mouse.down = true;
|
||||||
|
};
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
mouse.down = false;
|
||||||
|
};
|
||||||
|
|
||||||
let animationFrameId: number;
|
const parent = canvas.parentElement;
|
||||||
let time = 0;
|
if (parent) {
|
||||||
|
parent.addEventListener('mousemove', handleMouseMove);
|
||||||
|
parent.addEventListener('mousedown', handleMouseDown);
|
||||||
|
parent.addEventListener('mouseup', handleMouseUp);
|
||||||
|
parent.addEventListener('mouseleave', () => {
|
||||||
|
mouse.x = -1000;
|
||||||
|
mouse.y = -1000;
|
||||||
|
mouse.down = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
interface ControlPoint {
|
const draw = () => {
|
||||||
x: number;
|
const width = canvas.width / (window.devicePixelRatio || 1);
|
||||||
y: number;
|
const height = canvas.height / (window.devicePixelRatio || 1);
|
||||||
ox: number;
|
if (!width || !height) {
|
||||||
oy: number;
|
animationFrameId = requestAnimationFrame(draw);
|
||||||
vx: number;
|
return;
|
||||||
vy: number;
|
}
|
||||||
phase: number;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let points: ControlPoint[] = [];
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
time += shimmerSpeed;
|
||||||
|
|
||||||
const pseudoRandom = (offset: number) => {
|
// 1. Draw Under-Glow (Inner Cores)
|
||||||
const x = Math.sin(seed + offset) * 10000;
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
return x - Math.floor(x);
|
points.forEach((p) => {
|
||||||
};
|
// Harmonic motion + mouse interaction
|
||||||
|
const dxMouse = mouse.x - p.x;
|
||||||
|
const dyMouse = mouse.y - p.y;
|
||||||
|
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
|
||||||
|
const mouseForce = Math.max(0, (300 - distMouse) / 300);
|
||||||
|
|
||||||
const initPoints = (width: number, height: number) => {
|
const driftX = Math.sin(time + p.phase) * 20 * instability;
|
||||||
const newPoints = [];
|
const driftY = Math.cos(time * 0.8 + p.phase) * 20 * instability;
|
||||||
for (let i = 0; i < cellCount; i++) {
|
|
||||||
const rx = pseudoRandom(i) * width;
|
|
||||||
const ry = pseudoRandom(i + 500) * height;
|
|
||||||
newPoints.push({
|
|
||||||
x: rx, y: ry,
|
|
||||||
ox: rx, oy: ry,
|
|
||||||
vx: 0, vy: 0,
|
|
||||||
phase: pseudoRandom(i + 1000) * Math.PI * 2,
|
|
||||||
color: brightColors[i % brightColors.length]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return newPoints;
|
|
||||||
};
|
|
||||||
|
|
||||||
const resize = () => {
|
p.x = p.ox + driftX + (mouse.down ? -dxMouse * mouseForce * 0.5 : 0);
|
||||||
const parent = canvas.parentElement;
|
p.y = p.oy + driftY + (mouse.down ? -dyMouse * mouseForce * 0.5 : 0);
|
||||||
if (parent) {
|
|
||||||
const rect = parent.getBoundingClientRect();
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
canvas.width = rect.width * dpr;
|
|
||||||
canvas.height = rect.height * dpr;
|
|
||||||
ctx.scale(dpr, dpr);
|
|
||||||
canvas.style.width = `${rect.width}px`;
|
|
||||||
canvas.style.height = `${rect.height}px`;
|
|
||||||
points = initPoints(rect.width, rect.height);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => resize());
|
const pulse = 0.7 + Math.sin(time * 3 + p.phase) * 0.3;
|
||||||
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
const glowSize = 60 + mouseForce * 40;
|
||||||
resize();
|
|
||||||
|
|
||||||
let mouse = { x: -1000, y: -1000, down: false };
|
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowSize);
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
grad.addColorStop(0, alpha(p.color, 0.8 * glowIntensity * pulse));
|
||||||
const rect = canvas.getBoundingClientRect();
|
grad.addColorStop(0.5, alpha(p.color, 0.3 * glowIntensity));
|
||||||
mouse.x = e.clientX - rect.left;
|
grad.addColorStop(1, alpha(p.color, 0));
|
||||||
mouse.y = e.clientY - rect.top;
|
|
||||||
};
|
|
||||||
const handleMouseDown = () => { mouse.down = true; };
|
|
||||||
const handleMouseUp = () => { mouse.down = false; };
|
|
||||||
|
|
||||||
const parent = canvas.parentElement;
|
ctx.fillStyle = grad;
|
||||||
if (parent) {
|
ctx.beginPath();
|
||||||
parent.addEventListener('mousemove', handleMouseMove);
|
ctx.arc(p.x, p.y, glowSize, 0, Math.PI * 2);
|
||||||
parent.addEventListener('mousedown', handleMouseDown);
|
ctx.fill();
|
||||||
parent.addEventListener('mouseup', handleMouseUp);
|
});
|
||||||
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
const draw = () => {
|
// 2. Draw Dark "Void" Shell (Voronoi-based cutout)
|
||||||
const width = canvas.width / (window.devicePixelRatio || 1);
|
// We use a temporary canvas to draw the shell then mask it, or just use a heavy overlay
|
||||||
const height = canvas.height / (window.devicePixelRatio || 1);
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
if (!width || !height) {
|
|
||||||
animationFrameId = requestAnimationFrame(draw);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, width, height);
|
// Create the "holes" effect by using a pattern of circles that are actually transparent
|
||||||
time += shimmerSpeed;
|
// Actually, to get the specific look from the image, we can use a simpler approach:
|
||||||
|
// Layer a dark color over everything, but with 'destination-out' circles at the point locations.
|
||||||
|
|
||||||
// 1. Draw Under-Glow (Inner Cores)
|
const offCanvas = document.createElement('canvas');
|
||||||
ctx.globalCompositeOperation = 'source-over';
|
offCanvas.width = canvas.width;
|
||||||
points.forEach((p) => {
|
offCanvas.height = canvas.height;
|
||||||
// Harmonic motion + mouse interaction
|
const offCtx = offCanvas.getContext('2d');
|
||||||
const dxMouse = mouse.x - p.x;
|
if (offCtx) {
|
||||||
const dyMouse = mouse.y - p.y;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const distMouse = Math.sqrt(dxMouse * dxMouse + dyMouse * dyMouse);
|
offCtx.scale(dpr, dpr);
|
||||||
const mouseForce = Math.max(0, (300 - distMouse) / 300);
|
|
||||||
|
|
||||||
const driftX = Math.sin(time + p.phase) * 20 * instability;
|
|
||||||
const driftY = Math.cos(time * 0.8 + p.phase) * 20 * instability;
|
|
||||||
|
|
||||||
p.x = p.ox + driftX + (mouse.down ? -dxMouse * mouseForce * 0.5 : 0);
|
|
||||||
p.y = p.oy + driftY + (mouse.down ? -dyMouse * mouseForce * 0.5 : 0);
|
|
||||||
|
|
||||||
const pulse = 0.7 + Math.sin(time * 3 + p.phase) * 0.3;
|
// Draw full dark shell
|
||||||
const glowSize = 60 + (mouseForce * 40);
|
const shellColor =
|
||||||
|
theme.palette.mode === 'light'
|
||||||
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowSize);
|
? alpha('#1a0a2a', shellDarkness)
|
||||||
grad.addColorStop(0, alpha(p.color, 0.8 * glowIntensity * pulse));
|
: alpha('#05020a', shellDarkness);
|
||||||
grad.addColorStop(0.5, alpha(p.color, 0.3 * glowIntensity));
|
offCtx.fillStyle = shellColor;
|
||||||
grad.addColorStop(1, alpha(p.color, 0));
|
offCtx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
ctx.fillStyle = grad;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(p.x, p.y, glowSize, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Draw Dark "Void" Shell (Voronoi-based cutout)
|
// Cut holes
|
||||||
// We use a temporary canvas to draw the shell then mask it, or just use a heavy overlay
|
offCtx.globalCompositeOperation = 'destination-out';
|
||||||
ctx.globalCompositeOperation = 'source-over';
|
points.forEach((p) => {
|
||||||
|
const holeSize = 15 + Math.sin(time + p.phase) * 5 + instability * 10;
|
||||||
// Create the "holes" effect by using a pattern of circles that are actually transparent
|
|
||||||
// Actually, to get the specific look from the image, we can use a simpler approach:
|
|
||||||
// Layer a dark color over everything, but with 'destination-out' circles at the point locations.
|
|
||||||
|
|
||||||
const offCanvas = document.createElement('canvas');
|
|
||||||
offCanvas.width = canvas.width;
|
|
||||||
offCanvas.height = canvas.height;
|
|
||||||
const offCtx = offCanvas.getContext('2d');
|
|
||||||
if (offCtx) {
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
offCtx.scale(dpr, dpr);
|
|
||||||
|
|
||||||
// Draw full dark shell
|
|
||||||
const shellColor = theme.palette.mode === 'light' ? alpha('#1a0a2a', shellDarkness) : alpha('#05020a', shellDarkness);
|
|
||||||
offCtx.fillStyle = shellColor;
|
|
||||||
offCtx.fillRect(0, 0, width, height);
|
|
||||||
|
|
||||||
// Cut holes
|
|
||||||
offCtx.globalCompositeOperation = 'destination-out';
|
|
||||||
points.forEach(p => {
|
|
||||||
const holeSize = 15 + Math.sin(time + p.phase) * 5 + (instability * 10);
|
|
||||||
|
|
||||||
// Main hole
|
|
||||||
offCtx.beginPath();
|
|
||||||
offCtx.arc(p.x, p.y, holeSize, 0, Math.PI * 2);
|
|
||||||
offCtx.fill();
|
|
||||||
|
|
||||||
// Add secondary smaller holes for that "punctured" look
|
|
||||||
for(let j=0; j<3; j++) {
|
|
||||||
const angle = p.phase + (j * Math.PI * 0.6);
|
|
||||||
const hx = p.x + Math.cos(angle) * (holeSize * 1.5);
|
|
||||||
const hy = p.y + Math.sin(angle) * (holeSize * 1.5);
|
|
||||||
offCtx.beginPath();
|
|
||||||
offCtx.arc(hx, hy, holeSize * 0.4, 0, Math.PI * 2);
|
|
||||||
offCtx.fill();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.drawImage(offCanvas, 0, 0, width, height);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Add Iridescent Edge Highlights
|
// Main hole
|
||||||
ctx.globalCompositeOperation = 'screen';
|
offCtx.beginPath();
|
||||||
ctx.lineWidth = 2;
|
offCtx.arc(p.x, p.y, holeSize, 0, Math.PI * 2);
|
||||||
points.forEach(p => {
|
offCtx.fill();
|
||||||
const pulse = 0.5 + Math.sin(time * 2 + p.phase) * 0.5;
|
|
||||||
ctx.strokeStyle = alpha(p.color, pulse * 0.4);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(p.x, p.y, 20 + pulse * 5, 0, Math.PI * 2);
|
|
||||||
ctx.stroke();
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.globalCompositeOperation = 'source-over';
|
// Add secondary smaller holes for that "punctured" look
|
||||||
animationFrameId = requestAnimationFrame(draw);
|
for (let j = 0; j < 3; j++) {
|
||||||
};
|
const angle = p.phase + j * Math.PI * 0.6;
|
||||||
|
const hx = p.x + Math.cos(angle) * (holeSize * 1.5);
|
||||||
|
const hy = p.y + Math.sin(angle) * (holeSize * 1.5);
|
||||||
|
offCtx.beginPath();
|
||||||
|
offCtx.arc(hx, hy, holeSize * 0.4, 0, Math.PI * 2);
|
||||||
|
offCtx.fill();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
draw();
|
ctx.drawImage(offCanvas, 0, 0, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
// 3. Add Iridescent Edge Highlights
|
||||||
resizeObserver.disconnect();
|
ctx.globalCompositeOperation = 'screen';
|
||||||
if (parent) {
|
ctx.lineWidth = 2;
|
||||||
parent.removeEventListener('mousemove', handleMouseMove);
|
points.forEach((p) => {
|
||||||
parent.removeEventListener('mousedown', handleMouseDown);
|
const pulse = 0.5 + Math.sin(time * 2 + p.phase) * 0.5;
|
||||||
parent.removeEventListener('mouseup', handleMouseUp);
|
ctx.strokeStyle = alpha(p.color, pulse * 0.4);
|
||||||
}
|
ctx.beginPath();
|
||||||
cancelAnimationFrame(animationFrameId);
|
ctx.arc(p.x, p.y, 20 + pulse * 5, 0, Math.PI * 2);
|
||||||
};
|
ctx.stroke();
|
||||||
}, [seed, brightColors, cellCount, shimmerSpeed, glowIntensity, instability, shellDarkness, theme]);
|
});
|
||||||
|
|
||||||
return (
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
<canvas
|
animationFrameId = requestAnimationFrame(draw);
|
||||||
ref={canvasRef}
|
};
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
draw();
|
||||||
top: 0,
|
|
||||||
left: 0,
|
return () => {
|
||||||
width: '100%',
|
resizeObserver.disconnect();
|
||||||
height: '100%',
|
if (parent) {
|
||||||
pointerEvents: 'none',
|
parent.removeEventListener('mousemove', handleMouseMove);
|
||||||
zIndex: 1
|
parent.removeEventListener('mousedown', handleMouseDown);
|
||||||
}}
|
parent.removeEventListener('mouseup', handleMouseUp);
|
||||||
/>
|
}
|
||||||
);
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
seed,
|
||||||
|
brightColors,
|
||||||
|
cellCount,
|
||||||
|
shimmerSpeed,
|
||||||
|
glowIntensity,
|
||||||
|
instability,
|
||||||
|
shellDarkness,
|
||||||
|
theme,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default HextechCorruptionBanner;
|
export default HextechCorruptionBanner;
|
||||||
@@ -1,199 +1,208 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
TextField,
|
TextField,
|
||||||
Popper,
|
Popper,
|
||||||
Paper,
|
Paper,
|
||||||
MenuList,
|
MenuList,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Avatar,
|
Avatar,
|
||||||
ListItemAvatar,
|
ListItemAvatar,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
Box,
|
Box,
|
||||||
alpha
|
alpha,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import api from '../api';
|
import api from '../lib/api';
|
||||||
|
|
||||||
interface UserSuggestion {
|
interface UserSuggestion {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MentionTextFieldProps {
|
interface MentionTextFieldProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
multiline?: boolean;
|
multiline?: boolean;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
size?: 'small' | 'medium';
|
size?: 'small' | 'medium';
|
||||||
onKeyDown?: (e: React.KeyboardEvent) => void;
|
onKeyDown?: (e: React.KeyboardEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MentionTextField: React.FC<MentionTextFieldProps> = ({
|
const MentionTextField: React.FC<MentionTextFieldProps> = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
fullWidth = true,
|
fullWidth = true,
|
||||||
multiline = false,
|
multiline = false,
|
||||||
rows,
|
rows,
|
||||||
size = 'small',
|
size = 'small',
|
||||||
onKeyDown
|
onKeyDown,
|
||||||
}) => {
|
}) => {
|
||||||
const [suggestions, setSuggestions] = useState<UserSuggestion[]>([]);
|
const [suggestions, setSuggestions] = useState<UserSuggestion[]>([]);
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
|
||||||
// Fetch users for suggestions (we could cache this or search on demand)
|
// Fetch users for suggestions (we could cache this or search on demand)
|
||||||
const [allUsers, setAllUsers] = useState<UserSuggestion[]>([]);
|
const [allUsers, setAllUsers] = useState<UserSuggestion[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('flavors/search/');
|
const res = await api.get('flavors/search/');
|
||||||
const users = (res.data || [])
|
const users = (res.data || [])
|
||||||
.filter((item: any) => item.type === 'user')
|
.filter((item: any) => item.type === 'user')
|
||||||
.map((item: any) => ({
|
.map((item: any) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
username: item.name,
|
username: item.name,
|
||||||
avatar: item.image_url
|
avatar: item.image_url,
|
||||||
}));
|
}));
|
||||||
setAllUsers(users);
|
setAllUsers(users);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch users for mentions');
|
console.error('Failed to fetch users for mentions');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const newValue = e.target.value;
|
const newValue = e.target.value;
|
||||||
onChange(newValue);
|
onChange(newValue);
|
||||||
|
|
||||||
const selectionStart = e.target.selectionStart || 0;
|
const selectionStart = e.target.selectionStart || 0;
|
||||||
const textBeforeCursor = newValue.substring(0, selectionStart);
|
const textBeforeCursor = newValue.substring(0, selectionStart);
|
||||||
const lastAtPos = textBeforeCursor.lastIndexOf('@');
|
const lastAtPos = textBeforeCursor.lastIndexOf('@');
|
||||||
|
|
||||||
if (lastAtPos !== -1) {
|
if (lastAtPos !== -1) {
|
||||||
const query = textBeforeCursor.substring(lastAtPos + 1);
|
const query = textBeforeCursor.substring(lastAtPos + 1);
|
||||||
// Ensure there's no space or newline between @ and the cursor
|
// Ensure there's no space or newline between @ and the cursor
|
||||||
if (!query.includes(' ') && !query.includes('\n')) {
|
if (!query.includes(' ') && !query.includes('\n')) {
|
||||||
const filtered = allUsers.filter(u =>
|
const filtered = allUsers.filter((u) =>
|
||||||
u.username.toLowerCase().startsWith(query.toLowerCase())
|
u.username.toLowerCase().startsWith(query.toLowerCase()),
|
||||||
);
|
);
|
||||||
setSuggestions(filtered);
|
setSuggestions(filtered);
|
||||||
setShowSuggestions(filtered.length > 0);
|
setShowSuggestions(filtered.length > 0);
|
||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
setAnchorEl(inputRef.current);
|
setAnchorEl(inputRef.current);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setShowSuggestions(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectSuggestion = (user: UserSuggestion) => {
|
||||||
|
const selectionStart = inputRef.current?.selectionStart || 0;
|
||||||
|
const textBeforeCursor = value.substring(0, selectionStart);
|
||||||
|
const textAfterCursor = value.substring(selectionStart);
|
||||||
|
const lastAtPos = textBeforeCursor.lastIndexOf('@');
|
||||||
|
|
||||||
|
const newValue = value.substring(0, lastAtPos) + '@' + user.username + ' ' + textAfterCursor;
|
||||||
|
|
||||||
|
onChange(newValue);
|
||||||
|
setShowSuggestions(false);
|
||||||
|
|
||||||
|
// Return focus to input and move cursor after the mention
|
||||||
|
setTimeout(() => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
const newPos = lastAtPos + user.username.length + 2; // +1 for @, +1 for space
|
||||||
|
inputRef.current.setSelectionRange(newPos, newPos);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (showSuggestions) {
|
||||||
|
if (e.key === 'Tab' || e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelectSuggestion(suggestions[selectedIndex]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => (prev + 1) % suggestions.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => (prev - 1 + suggestions.length) % suggestions.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
};
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (onKeyDown) onKeyDown(e);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSelectSuggestion = (user: UserSuggestion) => {
|
return (
|
||||||
const selectionStart = inputRef.current?.selectionStart || 0;
|
<Box sx={{ position: 'relative', width: fullWidth ? '100%' : 'auto' }}>
|
||||||
const textBeforeCursor = value.substring(0, selectionStart);
|
<TextField
|
||||||
const textAfterCursor = value.substring(selectionStart);
|
inputRef={inputRef}
|
||||||
const lastAtPos = textBeforeCursor.lastIndexOf('@');
|
fullWidth={fullWidth}
|
||||||
|
multiline={multiline}
|
||||||
const newValue =
|
rows={rows}
|
||||||
value.substring(0, lastAtPos) +
|
size={size}
|
||||||
'@' + user.username + ' ' +
|
placeholder={placeholder}
|
||||||
textAfterCursor;
|
value={value}
|
||||||
|
onChange={handleTextChange}
|
||||||
onChange(newValue);
|
onKeyDown={handleKeyDown}
|
||||||
setShowSuggestions(false);
|
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
|
||||||
|
autoComplete="off"
|
||||||
// Return focus to input and move cursor after the mention
|
/>
|
||||||
setTimeout(() => {
|
<Popper
|
||||||
if (inputRef.current) {
|
open={showSuggestions}
|
||||||
inputRef.current.focus();
|
anchorEl={anchorEl}
|
||||||
const newPos = lastAtPos + user.username.length + 2; // +1 for @, +1 for space
|
placement="bottom-start"
|
||||||
inputRef.current.setSelectionRange(newPos, newPos);
|
style={{ zIndex: 2000, width: 250 }}
|
||||||
}
|
>
|
||||||
}, 0);
|
<Paper
|
||||||
};
|
elevation={8}
|
||||||
|
sx={{
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
mt: 1,
|
||||||
if (showSuggestions) {
|
borderRadius: 2,
|
||||||
if (e.key === 'Tab' || e.key === 'Enter') {
|
overflow: 'hidden',
|
||||||
e.preventDefault();
|
border: '1px solid',
|
||||||
handleSelectSuggestion(suggestions[selectedIndex]);
|
borderColor: 'divider',
|
||||||
return;
|
}}
|
||||||
}
|
>
|
||||||
if (e.key === 'ArrowDown') {
|
<MenuList dense sx={{ py: 0 }}>
|
||||||
e.preventDefault();
|
{suggestions.map((user, index) => (
|
||||||
setSelectedIndex((prev) => (prev + 1) % suggestions.length);
|
<MenuItem
|
||||||
return;
|
key={user.id}
|
||||||
}
|
selected={index === selectedIndex}
|
||||||
if (e.key === 'ArrowUp') {
|
onClick={() => handleSelectSuggestion(user)}
|
||||||
e.preventDefault();
|
sx={{
|
||||||
setSelectedIndex((prev) => (prev - 1 + suggestions.length) % suggestions.length);
|
py: 1,
|
||||||
return;
|
'&.Mui-selected': {
|
||||||
}
|
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.15),
|
||||||
if (e.key === 'Escape') {
|
'&:hover': { bgcolor: (theme) => alpha(theme.palette.primary.main, 0.25) },
|
||||||
setShowSuggestions(false);
|
},
|
||||||
return;
|
}}
|
||||||
}
|
>
|
||||||
}
|
<ListItemAvatar sx={{ minWidth: 40 }}>
|
||||||
if (onKeyDown) onKeyDown(e);
|
<Avatar
|
||||||
};
|
src={user.avatar || undefined}
|
||||||
|
sx={{ width: 28, height: 28, fontSize: '0.8rem' }}
|
||||||
return (
|
>
|
||||||
<Box sx={{ position: 'relative', width: fullWidth ? '100%' : 'auto' }}>
|
{user.username.charAt(0).toUpperCase()}
|
||||||
<TextField
|
</Avatar>
|
||||||
inputRef={inputRef}
|
</ListItemAvatar>
|
||||||
fullWidth={fullWidth}
|
<ListItemText
|
||||||
multiline={multiline}
|
primary={user.username}
|
||||||
rows={rows}
|
primaryTypographyProps={{ variant: 'body2', fontWeight: 'bold' }}
|
||||||
size={size}
|
/>
|
||||||
placeholder={placeholder}
|
</MenuItem>
|
||||||
value={value}
|
))}
|
||||||
onChange={handleTextChange}
|
</MenuList>
|
||||||
onKeyDown={handleKeyDown}
|
</Paper>
|
||||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
|
</Popper>
|
||||||
autoComplete="off"
|
</Box>
|
||||||
/>
|
);
|
||||||
<Popper
|
|
||||||
open={showSuggestions}
|
|
||||||
anchorEl={anchorEl}
|
|
||||||
placement="bottom-start"
|
|
||||||
style={{ zIndex: 2000, width: 250 }}
|
|
||||||
>
|
|
||||||
<Paper elevation={8} sx={{ mt: 1, borderRadius: 2, overflow: 'hidden', border: '1px solid', borderColor: 'divider' }}>
|
|
||||||
<MenuList dense sx={{ py: 0 }}>
|
|
||||||
{suggestions.map((user, index) => (
|
|
||||||
<MenuItem
|
|
||||||
key={user.id}
|
|
||||||
selected={index === selectedIndex}
|
|
||||||
onClick={() => handleSelectSuggestion(user)}
|
|
||||||
sx={{
|
|
||||||
py: 1,
|
|
||||||
'&.Mui-selected': {
|
|
||||||
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.15),
|
|
||||||
'&:hover': { bgcolor: (theme) => alpha(theme.palette.primary.main, 0.25) }
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ListItemAvatar sx={{ minWidth: 40 }}>
|
|
||||||
<Avatar src={user.avatar || undefined} sx={{ width: 28, height: 28, fontSize: '0.8rem' }}>
|
|
||||||
{user.username.charAt(0).toUpperCase()}
|
|
||||||
</Avatar>
|
|
||||||
</ListItemAvatar>
|
|
||||||
<ListItemText
|
|
||||||
primary={user.username}
|
|
||||||
primaryTypographyProps={{ variant: 'body2', fontWeight: 'bold' }}
|
|
||||||
/>
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</MenuList>
|
|
||||||
</Paper>
|
|
||||||
</Popper>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MentionTextField;
|
export default MentionTextField;
|
||||||
@@ -2,293 +2,334 @@ import React, { useRef, useEffect, useMemo } from 'react';
|
|||||||
import { alpha, useTheme } from '@mui/material';
|
import { alpha, useTheme } from '@mui/material';
|
||||||
|
|
||||||
interface NebulaBannerProps {
|
interface NebulaBannerProps {
|
||||||
username: string;
|
username: string;
|
||||||
palette: string[];
|
palette: string[];
|
||||||
ratingsCount: number;
|
ratingsCount: number;
|
||||||
followersCount: number;
|
followersCount: number;
|
||||||
settings?: {
|
settings?: {
|
||||||
particleCount?: number;
|
particleCount?: number;
|
||||||
speed?: number;
|
speed?: number;
|
||||||
opacity?: number;
|
opacity?: number;
|
||||||
smearRadius?: number;
|
smearRadius?: number;
|
||||||
turbulence?: number;
|
turbulence?: number;
|
||||||
returnSpeed?: number;
|
returnSpeed?: number;
|
||||||
starDensity?: number;
|
starDensity?: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const NebulaBanner: React.FC<NebulaBannerProps> = ({ username, palette, ratingsCount, followersCount, settings }) => {
|
const NebulaBanner: React.FC<NebulaBannerProps> = ({
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
username,
|
||||||
const theme = useTheme();
|
palette,
|
||||||
|
ratingsCount,
|
||||||
const particleCount = settings?.particleCount ?? 400;
|
followersCount,
|
||||||
const speed = settings?.speed ?? 0.0015;
|
settings,
|
||||||
const globalOpacity = settings?.opacity ?? 0.6;
|
}) => {
|
||||||
const smearRadius = settings?.smearRadius ?? 120;
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const turbulenceStrength = settings?.turbulence ?? 0.4;
|
const theme = useTheme();
|
||||||
const returnSpeed = settings?.returnSpeed ?? 0.004; // Much lower for smoke-like feel
|
|
||||||
const starDensity = settings?.starDensity ?? 150;
|
|
||||||
|
|
||||||
const getSeed = (str: string) => {
|
const particleCount = settings?.particleCount ?? 400;
|
||||||
let hash = 0;
|
const speed = settings?.speed ?? 0.0015;
|
||||||
for (let i = 0; i < str.length; i++) {
|
const globalOpacity = settings?.opacity ?? 0.6;
|
||||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
const smearRadius = settings?.smearRadius ?? 120;
|
||||||
}
|
const turbulenceStrength = settings?.turbulence ?? 0.4;
|
||||||
return hash;
|
const returnSpeed = settings?.returnSpeed ?? 0.004; // Much lower for smoke-like feel
|
||||||
|
const starDensity = settings?.starDensity ?? 150;
|
||||||
|
|
||||||
|
const getSeed = (str: string) => {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
};
|
||||||
|
|
||||||
|
const seed = useMemo(() => getSeed(username), [username]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d', { alpha: true });
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
let animationFrameId: number;
|
||||||
|
const colors =
|
||||||
|
palette.length >= 2
|
||||||
|
? palette
|
||||||
|
: [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
||||||
|
|
||||||
|
interface Particle {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
baseX: number;
|
||||||
|
baseY: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
alpha: number;
|
||||||
|
targetAlpha: number;
|
||||||
|
phase: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Star {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
size: number;
|
||||||
|
opacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let particles: Particle[] = [];
|
||||||
|
let stars: Star[] = [];
|
||||||
|
|
||||||
|
const pseudoRandom = (offset: number) => {
|
||||||
|
const x = Math.sin(seed + offset) * 10000;
|
||||||
|
return x - Math.floor(x);
|
||||||
};
|
};
|
||||||
|
|
||||||
const seed = useMemo(() => getSeed(username), [username]);
|
const initScene = (width: number, height: number) => {
|
||||||
|
// Init Stars
|
||||||
|
const newStars = [];
|
||||||
|
for (let i = 0; i < starDensity; i++) {
|
||||||
|
newStars.push({
|
||||||
|
x: pseudoRandom(i + 5000) * width,
|
||||||
|
y: pseudoRandom(i + 6000) * height,
|
||||||
|
size: pseudoRandom(i + 7000) * 1.5,
|
||||||
|
opacity: 0.1 + pseudoRandom(i + 8000) * 0.5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
// Init Gas Clusters
|
||||||
const canvas = canvasRef.current;
|
const newParticles = [];
|
||||||
if (!canvas) return;
|
const clusterCount = colors.length * 3; // More clusters
|
||||||
|
const groupCenters = Array.from({ length: clusterCount }).map((_, i) => ({
|
||||||
|
x: (pseudoRandom(i * 15) * 0.8 + 0.1) * width,
|
||||||
|
y: (pseudoRandom(i * 25 + 7) * 0.8 + 0.1) * height,
|
||||||
|
}));
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d', { alpha: true });
|
for (let i = 0; i < particleCount; i++) {
|
||||||
if (!ctx) return;
|
const clusterIdx = i % clusterCount;
|
||||||
|
const colorIdx = clusterIdx % colors.length;
|
||||||
|
const center = groupCenters[clusterIdx];
|
||||||
|
const angle = pseudoRandom(i + 100) * Math.PI * 2;
|
||||||
|
const dist = pseudoRandom(i + 200) * (width * 0.12);
|
||||||
|
const bx = center.x + Math.cos(angle) * dist;
|
||||||
|
const by = center.y + Math.sin(angle) * dist;
|
||||||
|
|
||||||
let animationFrameId: number;
|
newParticles.push({
|
||||||
const colors = palette.length >= 2 ? palette : [theme.palette.primary.main, theme.palette.secondary.main, theme.palette.info.main];
|
x: bx,
|
||||||
|
y: by,
|
||||||
|
baseX: bx,
|
||||||
|
baseY: by,
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
size: 40 + pseudoRandom(i + 300) * 80, // Larger, softer blobs
|
||||||
|
color: colors[colorIdx],
|
||||||
|
alpha: 0,
|
||||||
|
targetAlpha: 0.05 + pseudoRandom(i + 400) * 0.15,
|
||||||
|
phase: pseudoRandom(i + 500) * Math.PI * 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { stars: newStars, particles: newParticles };
|
||||||
|
};
|
||||||
|
|
||||||
interface Particle {
|
const resize = () => {
|
||||||
x: number;
|
const parent = canvas.parentElement;
|
||||||
y: number;
|
if (parent) {
|
||||||
baseX: number;
|
const rect = parent.getBoundingClientRect();
|
||||||
baseY: number;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
vx: number;
|
canvas.width = rect.width * dpr;
|
||||||
vy: number;
|
canvas.height = rect.height * dpr;
|
||||||
size: number;
|
ctx.scale(dpr, dpr);
|
||||||
color: string;
|
canvas.style.width = `${rect.width}px`;
|
||||||
alpha: number;
|
canvas.style.height = `${rect.height}px`;
|
||||||
targetAlpha: number;
|
const scene = initScene(rect.width, rect.height);
|
||||||
phase: number;
|
stars = scene.stars;
|
||||||
|
particles = scene.particles;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => resize());
|
||||||
|
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
||||||
|
resize();
|
||||||
|
|
||||||
|
const mouse = { x: -1000, y: -1000, px: -1000, py: -1000, down: false };
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
mouse.px = mouse.x;
|
||||||
|
mouse.py = mouse.y;
|
||||||
|
mouse.x = e.clientX - rect.left;
|
||||||
|
mouse.y = e.clientY - rect.top;
|
||||||
|
};
|
||||||
|
const handleMouseDown = () => {
|
||||||
|
mouse.down = true;
|
||||||
|
};
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
mouse.down = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parent = canvas.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
parent.addEventListener('mousemove', handleMouseMove);
|
||||||
|
parent.addEventListener('mousedown', handleMouseDown);
|
||||||
|
parent.addEventListener('mouseup', handleMouseUp);
|
||||||
|
parent.addEventListener('mouseleave', () => {
|
||||||
|
mouse.x = -1000;
|
||||||
|
mouse.y = -1000;
|
||||||
|
mouse.down = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let time = 0;
|
||||||
|
const draw = () => {
|
||||||
|
const width = canvas.width / (window.devicePixelRatio || 1);
|
||||||
|
const height = canvas.height / (window.devicePixelRatio || 1);
|
||||||
|
if (!width || !height) {
|
||||||
|
animationFrameId = requestAnimationFrame(draw);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// 1. Dynamic Celestial Background
|
||||||
|
const isLight = theme.palette.mode === 'light';
|
||||||
|
const skyGrad = ctx.createRadialGradient(
|
||||||
|
width / 2,
|
||||||
|
height / 2,
|
||||||
|
0,
|
||||||
|
width / 2,
|
||||||
|
height / 2,
|
||||||
|
width,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLight) {
|
||||||
|
// Vibrant Deep Primary for light mode - but dark enough for glowing gas to pop
|
||||||
|
skyGrad.addColorStop(0, alpha(colors[colors.length - 1], 0.7));
|
||||||
|
skyGrad.addColorStop(0.5, alpha(colors[colors.length - 1], 0.9));
|
||||||
|
skyGrad.addColorStop(1, alpha(colors[colors.length - 1], 1.0));
|
||||||
|
} else {
|
||||||
|
// True Deep Space with visible tints for dark mode
|
||||||
|
skyGrad.addColorStop(0, '#1a1a3a'); // More blue/purple tint
|
||||||
|
skyGrad.addColorStop(0.5, '#0a0a1a');
|
||||||
|
skyGrad.addColorStop(1, '#050508');
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = skyGrad;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// 2. Draw Distant Stars
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
stars.forEach((s) => {
|
||||||
|
ctx.globalAlpha = s.opacity * (0.7 + Math.sin(time * 2 + s.x) * 0.3);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
});
|
||||||
|
ctx.globalAlpha = 1.0;
|
||||||
|
|
||||||
|
time += speed;
|
||||||
|
|
||||||
|
// 3. Draw Gas Clouds
|
||||||
|
particles.forEach((p) => {
|
||||||
|
// Restoration Force (Airy return)
|
||||||
|
const dxBase = p.baseX - p.x;
|
||||||
|
const dyBase = p.baseY - p.y;
|
||||||
|
p.vx += dxBase * returnSpeed;
|
||||||
|
p.vy += dyBase * returnSpeed;
|
||||||
|
|
||||||
|
// Mouse Interaction (Airy Smear)
|
||||||
|
const dx = mouse.x - p.x;
|
||||||
|
const dy = mouse.y - p.y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (dist < smearRadius) {
|
||||||
|
const force = (smearRadius - dist) / smearRadius;
|
||||||
|
if (mouse.down) {
|
||||||
|
// High turbulence on click
|
||||||
|
const angle = Math.atan2(dy, dx);
|
||||||
|
p.vx += Math.cos(angle + Math.PI / 2) * turbulenceStrength * 2;
|
||||||
|
p.vy += Math.sin(angle + Math.PI / 2) * turbulenceStrength * 2;
|
||||||
|
} else {
|
||||||
|
// Smoke-like smear
|
||||||
|
const mdx = mouse.x - mouse.px;
|
||||||
|
const mdy = mouse.y - mouse.py;
|
||||||
|
p.vx += mdx * force * 0.15;
|
||||||
|
p.vy += mdy * force * 0.15;
|
||||||
|
p.vx -= (dx / dist) * force * 0.8;
|
||||||
|
p.vy -= (dy / dist) * force * 0.8;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Star {
|
// Smooth drift
|
||||||
x: number;
|
p.vx += Math.sin(time + p.phase) * 0.03;
|
||||||
y: number;
|
p.vy += Math.cos(time * 0.8 + p.phase) * 0.03;
|
||||||
size: number;
|
|
||||||
opacity: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let particles: Particle[] = [];
|
// High friction for gas feel
|
||||||
let stars: Star[] = [];
|
p.vx *= 0.95;
|
||||||
|
p.vy *= 0.95;
|
||||||
const pseudoRandom = (offset: number) => {
|
p.x += p.vx;
|
||||||
const x = Math.sin(seed + offset) * 10000;
|
p.y += p.vy;
|
||||||
return x - Math.floor(x);
|
|
||||||
};
|
|
||||||
|
|
||||||
const initScene = (width: number, height: number) => {
|
if (p.alpha < p.targetAlpha) p.alpha += 0.001;
|
||||||
// Init Stars
|
|
||||||
const newStars = [];
|
|
||||||
for (let i = 0; i < starDensity; i++) {
|
|
||||||
newStars.push({
|
|
||||||
x: pseudoRandom(i + 5000) * width,
|
|
||||||
y: pseudoRandom(i + 6000) * height,
|
|
||||||
size: pseudoRandom(i + 7000) * 1.5,
|
|
||||||
opacity: 0.1 + pseudoRandom(i + 8000) * 0.5
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init Gas Clusters
|
// Render as very soft additive blob
|
||||||
const newParticles = [];
|
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size);
|
||||||
const clusterCount = colors.length * 3; // More clusters
|
const colorAlpha = p.alpha * globalOpacity;
|
||||||
const groupCenters = Array.from({ length: clusterCount }).map((_, i) => ({
|
grad.addColorStop(0, alpha(p.color, colorAlpha));
|
||||||
x: (pseudoRandom(i * 15) * 0.8 + 0.1) * width,
|
grad.addColorStop(0.6, alpha(p.color, colorAlpha * 0.2));
|
||||||
y: (pseudoRandom(i * 25 + 7) * 0.8 + 0.1) * height
|
grad.addColorStop(1, alpha(p.color, 0));
|
||||||
}));
|
|
||||||
|
|
||||||
for (let i = 0; i < particleCount; i++) {
|
ctx.fillStyle = grad;
|
||||||
const clusterIdx = i % clusterCount;
|
ctx.globalCompositeOperation = 'screen';
|
||||||
const colorIdx = clusterIdx % colors.length;
|
ctx.beginPath();
|
||||||
const center = groupCenters[clusterIdx];
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||||
const angle = pseudoRandom(i + 100) * Math.PI * 2;
|
ctx.fill();
|
||||||
const dist = pseudoRandom(i + 200) * (width * 0.12);
|
});
|
||||||
const bx = center.x + Math.cos(angle) * dist;
|
|
||||||
const by = center.y + Math.sin(angle) * dist;
|
|
||||||
|
|
||||||
newParticles.push({
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
x: bx, y: by,
|
animationFrameId = requestAnimationFrame(draw);
|
||||||
baseX: bx, baseY: by,
|
};
|
||||||
vx: 0, vy: 0,
|
|
||||||
size: 40 + pseudoRandom(i + 300) * 80, // Larger, softer blobs
|
|
||||||
color: colors[colorIdx],
|
|
||||||
alpha: 0,
|
|
||||||
targetAlpha: 0.05 + pseudoRandom(i + 400) * 0.15,
|
|
||||||
phase: pseudoRandom(i + 500) * Math.PI * 2
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { stars: newStars, particles: newParticles };
|
|
||||||
};
|
|
||||||
|
|
||||||
const resize = () => {
|
draw();
|
||||||
const parent = canvas.parentElement;
|
|
||||||
if (parent) {
|
|
||||||
const rect = parent.getBoundingClientRect();
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
canvas.width = rect.width * dpr;
|
|
||||||
canvas.height = rect.height * dpr;
|
|
||||||
ctx.scale(dpr, dpr);
|
|
||||||
canvas.style.width = `${rect.width}px`;
|
|
||||||
canvas.style.height = `${rect.height}px`;
|
|
||||||
const scene = initScene(rect.width, rect.height);
|
|
||||||
stars = scene.stars;
|
|
||||||
particles = scene.particles;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => resize());
|
return () => {
|
||||||
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
resizeObserver.disconnect();
|
||||||
resize();
|
if (parent) {
|
||||||
|
parent.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
parent.removeEventListener('mousedown', handleMouseDown);
|
||||||
|
parent.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
}
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
seed,
|
||||||
|
palette,
|
||||||
|
ratingsCount,
|
||||||
|
followersCount,
|
||||||
|
theme,
|
||||||
|
particleCount,
|
||||||
|
speed,
|
||||||
|
globalOpacity,
|
||||||
|
smearRadius,
|
||||||
|
turbulenceStrength,
|
||||||
|
returnSpeed,
|
||||||
|
starDensity,
|
||||||
|
]);
|
||||||
|
|
||||||
let mouse = { x: -1000, y: -1000, px: -1000, py: -1000, down: false };
|
return (
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
<canvas
|
||||||
const rect = canvas.getBoundingClientRect();
|
ref={canvasRef}
|
||||||
mouse.px = mouse.x; mouse.py = mouse.y;
|
style={{
|
||||||
mouse.x = e.clientX - rect.left;
|
position: 'absolute',
|
||||||
mouse.y = e.clientY - rect.top;
|
top: 0,
|
||||||
};
|
left: 0,
|
||||||
const handleMouseDown = () => { mouse.down = true; };
|
width: '100%',
|
||||||
const handleMouseUp = () => { mouse.down = false; };
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
const parent = canvas.parentElement;
|
zIndex: 1,
|
||||||
if (parent) {
|
}}
|
||||||
parent.addEventListener('mousemove', handleMouseMove);
|
/>
|
||||||
parent.addEventListener('mousedown', handleMouseDown);
|
);
|
||||||
parent.addEventListener('mouseup', handleMouseUp);
|
|
||||||
parent.addEventListener('mouseleave', () => { mouse.x = -1000; mouse.y = -1000; mouse.down = false; });
|
|
||||||
}
|
|
||||||
|
|
||||||
let time = 0;
|
|
||||||
const draw = () => {
|
|
||||||
const width = canvas.width / (window.devicePixelRatio || 1);
|
|
||||||
const height = canvas.height / (window.devicePixelRatio || 1);
|
|
||||||
if (!width || !height) {
|
|
||||||
animationFrameId = requestAnimationFrame(draw);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, width, height);
|
|
||||||
|
|
||||||
// 1. Dynamic Celestial Background
|
|
||||||
const isLight = theme.palette.mode === 'light';
|
|
||||||
const skyGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, width);
|
|
||||||
|
|
||||||
if (isLight) {
|
|
||||||
// Vibrant Deep Primary for light mode - but dark enough for glowing gas to pop
|
|
||||||
skyGrad.addColorStop(0, alpha(colors[colors.length - 1], 0.7));
|
|
||||||
skyGrad.addColorStop(0.5, alpha(colors[colors.length - 1], 0.9));
|
|
||||||
skyGrad.addColorStop(1, alpha(colors[colors.length - 1], 1.0));
|
|
||||||
} else {
|
|
||||||
// True Deep Space with visible tints for dark mode
|
|
||||||
skyGrad.addColorStop(0, '#1a1a3a'); // More blue/purple tint
|
|
||||||
skyGrad.addColorStop(0.5, '#0a0a1a');
|
|
||||||
skyGrad.addColorStop(1, '#050508');
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.fillStyle = skyGrad;
|
|
||||||
ctx.fillRect(0, 0, width, height);
|
|
||||||
|
|
||||||
// 2. Draw Distant Stars
|
|
||||||
ctx.fillStyle = '#fff';
|
|
||||||
stars.forEach(s => {
|
|
||||||
ctx.globalAlpha = s.opacity * (0.7 + Math.sin(time * 2 + s.x) * 0.3);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
});
|
|
||||||
ctx.globalAlpha = 1.0;
|
|
||||||
|
|
||||||
time += speed;
|
|
||||||
|
|
||||||
// 3. Draw Gas Clouds
|
|
||||||
particles.forEach((p) => {
|
|
||||||
// Restoration Force (Airy return)
|
|
||||||
const dxBase = p.baseX - p.x;
|
|
||||||
const dyBase = p.baseY - p.y;
|
|
||||||
p.vx += dxBase * returnSpeed;
|
|
||||||
p.vy += dyBase * returnSpeed;
|
|
||||||
|
|
||||||
// Mouse Interaction (Airy Smear)
|
|
||||||
const dx = mouse.x - p.x;
|
|
||||||
const dy = mouse.y - p.y;
|
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
||||||
|
|
||||||
if (dist < smearRadius) {
|
|
||||||
const force = (smearRadius - dist) / smearRadius;
|
|
||||||
if (mouse.down) {
|
|
||||||
// High turbulence on click
|
|
||||||
const angle = Math.atan2(dy, dx);
|
|
||||||
p.vx += Math.cos(angle + Math.PI/2) * turbulenceStrength * 2;
|
|
||||||
p.vy += Math.sin(angle + Math.PI/2) * turbulenceStrength * 2;
|
|
||||||
} else {
|
|
||||||
// Smoke-like smear
|
|
||||||
const mdx = mouse.x - mouse.px;
|
|
||||||
const mdy = mouse.y - mouse.py;
|
|
||||||
p.vx += mdx * force * 0.15;
|
|
||||||
p.vy += mdy * force * 0.15;
|
|
||||||
p.vx -= (dx / dist) * force * 0.8;
|
|
||||||
p.vy -= (dy / dist) * force * 0.8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Smooth drift
|
|
||||||
p.vx += Math.sin(time + p.phase) * 0.03;
|
|
||||||
p.vy += Math.cos(time * 0.8 + p.phase) * 0.03;
|
|
||||||
|
|
||||||
// High friction for gas feel
|
|
||||||
p.vx *= 0.95;
|
|
||||||
p.vy *= 0.95;
|
|
||||||
p.x += p.vx;
|
|
||||||
p.y += p.vy;
|
|
||||||
|
|
||||||
if (p.alpha < p.targetAlpha) p.alpha += 0.001;
|
|
||||||
|
|
||||||
// Render as very soft additive blob
|
|
||||||
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size);
|
|
||||||
const colorAlpha = p.alpha * globalOpacity;
|
|
||||||
grad.addColorStop(0, alpha(p.color, colorAlpha));
|
|
||||||
grad.addColorStop(0.6, alpha(p.color, colorAlpha * 0.2));
|
|
||||||
grad.addColorStop(1, alpha(p.color, 0));
|
|
||||||
|
|
||||||
ctx.fillStyle = grad;
|
|
||||||
ctx.globalCompositeOperation = 'screen';
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.globalCompositeOperation = 'source-over';
|
|
||||||
animationFrameId = requestAnimationFrame(draw);
|
|
||||||
};
|
|
||||||
|
|
||||||
draw();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
if (parent) {
|
|
||||||
parent.removeEventListener('mousemove', handleMouseMove);
|
|
||||||
parent.removeEventListener('mousedown', handleMouseDown);
|
|
||||||
parent.removeEventListener('mouseup', handleMouseUp);
|
|
||||||
}
|
|
||||||
cancelAnimationFrame(animationFrameId);
|
|
||||||
};
|
|
||||||
}, [seed, palette, ratingsCount, followersCount, theme, particleCount, speed, globalOpacity, smearRadius, turbulenceStrength, returnSpeed, starDensity]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
zIndex: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default NebulaBanner;
|
export default NebulaBanner;
|
||||||
@@ -2,54 +2,57 @@ import React from 'react';
|
|||||||
import { Box, Typography, useTheme } from '@mui/material';
|
import { Box, Typography, useTheme } from '@mui/material';
|
||||||
|
|
||||||
interface RatingBadgeProps {
|
interface RatingBadgeProps {
|
||||||
score: number;
|
score: number;
|
||||||
size?: 'small' | 'medium' | 'large';
|
size?: 'small' | 'medium' | 'large';
|
||||||
sx?: any;
|
sx?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RatingBadge: React.FC<RatingBadgeProps> = ({ score, size = 'medium', sx }) => {
|
const RatingBadge: React.FC<RatingBadgeProps> = ({ score, size = 'medium', sx }) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isLarge = size === 'large';
|
const isLarge = size === 'large';
|
||||||
const isSmall = size === 'small';
|
const isSmall = size === 'small';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{
|
<Box
|
||||||
display: 'inline-flex',
|
sx={{
|
||||||
alignItems: 'center',
|
display: 'inline-flex',
|
||||||
justifyContent: 'center',
|
alignItems: 'center',
|
||||||
gap: 0.5,
|
justifyContent: 'center',
|
||||||
bgcolor: 'primary.main',
|
gap: 0.5,
|
||||||
color: 'primary.contrastText',
|
bgcolor: 'primary.main',
|
||||||
px: isLarge ? 2 : (isSmall ? 1 : 1.5),
|
color: 'primary.contrastText',
|
||||||
py: isLarge ? 0.5 : (isSmall ? 0.2 : 0.4),
|
px: isLarge ? 2 : isSmall ? 1 : 1.5,
|
||||||
borderRadius: 2,
|
py: isLarge ? 0.5 : isSmall ? 0.2 : 0.4,
|
||||||
minWidth: 'fit-content',
|
borderRadius: 2,
|
||||||
height: 'fit-content',
|
minWidth: 'fit-content',
|
||||||
boxShadow: theme.palette.mode === 'dark'
|
height: 'fit-content',
|
||||||
? '0 4px 12px rgba(0,0,0,0.4)'
|
boxShadow:
|
||||||
: '0 2px 8px rgba(0,0,0,0.1)',
|
theme.palette.mode === 'dark'
|
||||||
...sx
|
? '0 4px 12px rgba(0,0,0,0.4)'
|
||||||
}}>
|
: '0 2px 8px rgba(0,0,0,0.1)',
|
||||||
<Typography
|
...sx,
|
||||||
variant={isLarge ? "h5" : (isSmall ? "caption" : "subtitle1")}
|
}}
|
||||||
sx={{ fontWeight: '900', lineHeight: 1, display: 'flex', alignItems: 'center' }}
|
>
|
||||||
>
|
<Typography
|
||||||
{typeof score === 'number' ? (isLarge ? score.toFixed(1) : score.toFixed(0)) : score}
|
variant={isLarge ? 'h5' : isSmall ? 'caption' : 'subtitle1'}
|
||||||
</Typography>
|
sx={{ fontWeight: '900', lineHeight: 1, display: 'flex', alignItems: 'center' }}
|
||||||
<Typography
|
>
|
||||||
variant="caption"
|
{typeof score === 'number' ? (isLarge ? score.toFixed(1) : score.toFixed(0)) : score}
|
||||||
sx={{
|
</Typography>
|
||||||
fontSize: isLarge ? '0.75rem' : (isSmall ? '0.55rem' : '0.65rem'),
|
<Typography
|
||||||
fontWeight: '900',
|
variant="caption"
|
||||||
opacity: 0.9,
|
sx={{
|
||||||
lineHeight: 1,
|
fontSize: isLarge ? '0.75rem' : isSmall ? '0.55rem' : '0.65rem',
|
||||||
mt: 0.2
|
fontWeight: '900',
|
||||||
}}
|
opacity: 0.9,
|
||||||
>
|
lineHeight: 1,
|
||||||
/ 10
|
mt: 0.2,
|
||||||
</Typography>
|
}}
|
||||||
</Box>
|
>
|
||||||
);
|
/ 10
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default RatingBadge;
|
export default RatingBadge;
|
||||||
@@ -3,41 +3,41 @@ import { Link } from 'react-router-dom';
|
|||||||
import { useTheme, alpha } from '@mui/material';
|
import { useTheme, alpha } from '@mui/material';
|
||||||
|
|
||||||
interface RichTextProps {
|
interface RichTextProps {
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RichText: React.FC<RichTextProps> = ({ text }) => {
|
const RichText: React.FC<RichTextProps> = ({ text }) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
|
|
||||||
const parts = text.split(/(@\w+)/g);
|
const parts = text.split(/(@\w+)/g);
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
{parts.map((part, i) => {
|
{parts.map((part, i) => {
|
||||||
if (part.startsWith('@')) {
|
if (part.startsWith('@')) {
|
||||||
const username = part.substring(1);
|
const username = part.substring(1);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={i}
|
key={i}
|
||||||
to={`/profile/${username}`}
|
to={`/profile/${username}`}
|
||||||
style={{
|
style={{
|
||||||
color: theme.palette.primary.main,
|
color: theme.palette.primary.main,
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
backgroundColor: alpha(theme.palette.primary.main, 0.1),
|
backgroundColor: alpha(theme.palette.primary.main, 0.1),
|
||||||
padding: '0 4px',
|
padding: '0 4px',
|
||||||
borderRadius: '4px'
|
borderRadius: '4px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{part}
|
{part}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return part;
|
return part;
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default RichText;
|
export default RichText;
|
||||||
@@ -4,53 +4,60 @@ import HistoryIcon from '@mui/icons-material/History';
|
|||||||
import BlockIcon from '@mui/icons-material/Block';
|
import BlockIcon from '@mui/icons-material/Block';
|
||||||
|
|
||||||
interface StatusBadgeProps {
|
interface StatusBadgeProps {
|
||||||
isLegacy?: boolean;
|
isLegacy?: boolean;
|
||||||
isAvailable?: boolean;
|
isAvailable?: boolean;
|
||||||
size?: 'small' | 'medium';
|
size?: 'small' | 'medium';
|
||||||
sx?: any;
|
sx?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatusBadge: React.FC<StatusBadgeProps> = ({ isLegacy, isAvailable, size = 'medium', sx }) => {
|
const StatusBadge: React.FC<StatusBadgeProps> = ({
|
||||||
const theme = useTheme();
|
isLegacy,
|
||||||
const isSmall = size === 'small';
|
isAvailable,
|
||||||
|
size = 'medium',
|
||||||
|
sx,
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isSmall = size === 'small';
|
||||||
|
|
||||||
if (isAvailable && !isLegacy) return null;
|
if (isAvailable && !isLegacy) return null;
|
||||||
|
|
||||||
const label = isLegacy ? 'Limited' : 'Out of Stock';
|
const label = isLegacy ? 'Limited' : 'Out of Stock';
|
||||||
const color = isLegacy ? theme.palette.warning.main : theme.palette.error.main;
|
const color = isLegacy ? theme.palette.warning.main : theme.palette.error.main;
|
||||||
const Icon = isLegacy ? HistoryIcon : BlockIcon;
|
const Icon = isLegacy ? HistoryIcon : BlockIcon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{
|
<Box
|
||||||
display: 'inline-flex',
|
sx={{
|
||||||
alignItems: 'center',
|
display: 'inline-flex',
|
||||||
gap: 0.5,
|
alignItems: 'center',
|
||||||
bgcolor: alpha(color, 0.9),
|
gap: 0.5,
|
||||||
color: '#fff',
|
bgcolor: alpha(color, 0.9),
|
||||||
px: isSmall ? 0.8 : 1.2,
|
color: '#fff',
|
||||||
py: isSmall ? 0.2 : 0.4,
|
px: isSmall ? 0.8 : 1.2,
|
||||||
borderRadius: 1.5,
|
py: isSmall ? 0.2 : 0.4,
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.2)',
|
borderRadius: 1.5,
|
||||||
backdropFilter: 'blur(4px)',
|
boxShadow: '0 4px 12px rgba(0,0,0,0.2)',
|
||||||
border: '1px solid',
|
backdropFilter: 'blur(4px)',
|
||||||
borderColor: alpha('#fff', 0.2),
|
border: '1px solid',
|
||||||
...sx
|
borderColor: alpha('#fff', 0.2),
|
||||||
}}>
|
...sx,
|
||||||
<Icon sx={{ fontSize: isSmall ? '0.7rem' : '0.9rem' }} />
|
}}
|
||||||
<Typography
|
>
|
||||||
variant="caption"
|
<Icon sx={{ fontSize: isSmall ? '0.7rem' : '0.9rem' }} />
|
||||||
sx={{
|
<Typography
|
||||||
fontWeight: '900',
|
variant="caption"
|
||||||
textTransform: 'uppercase',
|
sx={{
|
||||||
letterSpacing: 0.5,
|
fontWeight: '900',
|
||||||
fontSize: isSmall ? '0.55rem' : '0.65rem',
|
textTransform: 'uppercase',
|
||||||
lineHeight: 1
|
letterSpacing: 0.5,
|
||||||
}}
|
fontSize: isSmall ? '0.55rem' : '0.65rem',
|
||||||
>
|
lineHeight: 1,
|
||||||
{label}
|
}}
|
||||||
</Typography>
|
>
|
||||||
</Box>
|
{label}
|
||||||
);
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default StatusBadge;
|
export default StatusBadge;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { createContext, useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import api, { clearTokens } from '../lib/api';
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
username: string;
|
||||||
|
avatar: string | null;
|
||||||
|
unread_notifications_count: number;
|
||||||
|
is_superuser: boolean;
|
||||||
|
theme?: string;
|
||||||
|
language?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FollowingEntry {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
avatar: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthContextValue {
|
||||||
|
user: AuthUser | null;
|
||||||
|
setUser: (user: AuthUser | null) => void;
|
||||||
|
following: FollowingEntry[];
|
||||||
|
loadingUser: boolean;
|
||||||
|
refetchUser: () => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
|
const [following, setFollowing] = useState<FollowingEntry[]>([]);
|
||||||
|
const [loadingUser, setLoadingUser] = useState(true);
|
||||||
|
|
||||||
|
const refetchUser = useCallback(async () => {
|
||||||
|
const token = localStorage.getItem('access');
|
||||||
|
if (!token) {
|
||||||
|
setUser(null);
|
||||||
|
setLoadingUser(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await api.get('users/me/');
|
||||||
|
if (res.data?.username) {
|
||||||
|
setUser(res.data);
|
||||||
|
if (res.data.theme) localStorage.setItem('theme', res.data.theme);
|
||||||
|
if (res.data.language) i18n.changeLanguage(res.data.language);
|
||||||
|
try {
|
||||||
|
const followRes = await api.get('users/following_list/');
|
||||||
|
const data = Array.isArray(followRes.data)
|
||||||
|
? followRes.data
|
||||||
|
: followRes.data.results || [];
|
||||||
|
setFollowing(data);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setLoadingUser(false);
|
||||||
|
}
|
||||||
|
}, [i18n]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refetchUser();
|
||||||
|
}, [refetchUser]);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
clearTokens();
|
||||||
|
setUser(null);
|
||||||
|
setFollowing([]);
|
||||||
|
i18n.changeLanguage(navigator.language.split('-')[0] || 'en');
|
||||||
|
window.location.href = '/';
|
||||||
|
}, [i18n]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, setUser, following, loadingUser, refetchUser, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { createContext, useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||||
|
import api from '../lib/api';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: number;
|
||||||
|
actor_username: string;
|
||||||
|
actor_avatar: string | null;
|
||||||
|
notification_type:
|
||||||
|
| 'reply'
|
||||||
|
| 'mention'
|
||||||
|
| 'follow'
|
||||||
|
| 'ticket_new'
|
||||||
|
| 'ticket_reply'
|
||||||
|
| 'profile_comment';
|
||||||
|
rating: number | null;
|
||||||
|
reply: number | null;
|
||||||
|
is_read: boolean;
|
||||||
|
created_at: string;
|
||||||
|
flavor_name: string | null;
|
||||||
|
flavor_id: number | null;
|
||||||
|
profile_owner_username: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationContextValue {
|
||||||
|
notifications: Notification[];
|
||||||
|
fetchNotifications: () => Promise<void>;
|
||||||
|
markAllRead: () => Promise<void>;
|
||||||
|
markRead: (id: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NotificationContext = createContext<NotificationContextValue | null>(null);
|
||||||
|
|
||||||
|
export const NotificationProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const { user, setUser, refetchUser } = useAuth();
|
||||||
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
|
|
||||||
|
const fetchNotifications = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('notifications/');
|
||||||
|
const data = Array.isArray(res.data) ? res.data : res.data.results || [];
|
||||||
|
setNotifications(data);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
fetchNotifications();
|
||||||
|
}
|
||||||
|
}, [user, fetchNotifications]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (localStorage.getItem('access')) {
|
||||||
|
fetchNotifications();
|
||||||
|
refetchUser();
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchNotifications, refetchUser]);
|
||||||
|
|
||||||
|
const markAllRead = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await api.post('notifications/mark_all_read/');
|
||||||
|
setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })));
|
||||||
|
if (user) setUser({ ...user, unread_notifications_count: 0 });
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, [user, setUser]);
|
||||||
|
|
||||||
|
const markRead = useCallback(
|
||||||
|
async (id: number) => {
|
||||||
|
try {
|
||||||
|
await api.post(`notifications/${id}/mark_read/`);
|
||||||
|
setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)));
|
||||||
|
if (user) {
|
||||||
|
setUser({
|
||||||
|
...user,
|
||||||
|
unread_notifications_count: Math.max(0, user.unread_notifications_count - 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[user, setUser],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationContext.Provider
|
||||||
|
value={{ notifications, fetchNotifications, markAllRead, markRead }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</NotificationContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { createContext, useCallback, useMemo, useState, type ReactNode } from 'react';
|
||||||
|
import { ThemeProvider as MuiThemeProvider, CssBaseline } from '@mui/material';
|
||||||
|
import { getTheme, type CatppuccinTheme } from '../theme';
|
||||||
|
import api from '../lib/api';
|
||||||
|
|
||||||
|
export interface ThemeContextValue {
|
||||||
|
themeName: CatppuccinTheme;
|
||||||
|
setThemeName: (name: CatppuccinTheme) => void;
|
||||||
|
handleThemeChange: (name: CatppuccinTheme, isLoggedIn: boolean) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
|
|
||||||
|
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [themeName, setThemeName] = useState<CatppuccinTheme>(
|
||||||
|
(localStorage.getItem('theme') as CatppuccinTheme) || 'holy_light',
|
||||||
|
);
|
||||||
|
|
||||||
|
const theme = useMemo(() => getTheme(themeName), [themeName]);
|
||||||
|
|
||||||
|
const handleThemeChange = useCallback(async (name: CatppuccinTheme, isLoggedIn: boolean) => {
|
||||||
|
setThemeName(name);
|
||||||
|
localStorage.setItem('theme', name);
|
||||||
|
document.body.style.backgroundColor = getTheme(name).palette.background.default;
|
||||||
|
document.body.style.color = getTheme(name).palette.text.primary;
|
||||||
|
if (isLoggedIn) {
|
||||||
|
try {
|
||||||
|
await api.patch('users/update_preferences/', { theme: name });
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ themeName, setThemeName, handleThemeChange }}>
|
||||||
|
<MuiThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
{children}
|
||||||
|
</MuiThemeProvider>
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { AuthContext } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import api from '../lib/api';
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: 'flavor' | 'category' | 'user';
|
||||||
|
subtitle: string;
|
||||||
|
image_url: string | null;
|
||||||
|
slug: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGlobalSearch = () => {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [options, setOptions] = useState<SearchResult[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
setOptions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get(`flavors/search/?q=${encodeURIComponent(query)}`);
|
||||||
|
const results = Array.isArray(res.data) ? res.data : res.data.results || [];
|
||||||
|
setOptions(results);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const q = params.get('q');
|
||||||
|
if (q) setQuery(q);
|
||||||
|
}, [location.search]);
|
||||||
|
|
||||||
|
return { query, setQuery, options, loading };
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { NotificationContext } from '../contexts/NotificationContext';
|
||||||
|
|
||||||
|
export const useNotifications = () => {
|
||||||
|
const ctx = useContext(NotificationContext);
|
||||||
|
if (!ctx) throw new Error('useNotifications must be used within NotificationProvider');
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { ThemeContext } from '../contexts/ThemeContext';
|
||||||
|
|
||||||
|
export const useTheme = () => {
|
||||||
|
const ctx = useContext(ThemeContext);
|
||||||
|
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
+505
-456
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { clearTokens, getAccessToken, setTokens } from './api';
|
||||||
|
|
||||||
|
describe('token helpers', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearTokens();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no access token is stored', () => {
|
||||||
|
expect(getAccessToken()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores and retrieves the access token', () => {
|
||||||
|
setTokens('access-123', 'refresh-abc');
|
||||||
|
expect(getAccessToken()).toBe('access-123');
|
||||||
|
expect(localStorage.getItem('refresh')).toBe('refresh-abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates access without clobbering refresh when refresh is omitted', () => {
|
||||||
|
setTokens('first-access', 'first-refresh');
|
||||||
|
setTokens('second-access');
|
||||||
|
expect(getAccessToken()).toBe('second-access');
|
||||||
|
expect(localStorage.getItem('refresh')).toBe('first-refresh');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearTokens removes both access and refresh', () => {
|
||||||
|
setTokens('a', 'b');
|
||||||
|
clearTokens();
|
||||||
|
expect(getAccessToken()).toBeNull();
|
||||||
|
expect(localStorage.getItem('refresh')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import type { AxiosError, AxiosRequestConfig } from 'axios';
|
||||||
|
|
||||||
|
const ACCESS_KEY = 'access';
|
||||||
|
const REFRESH_KEY = 'refresh';
|
||||||
|
|
||||||
|
export function getAccessToken(): string | null {
|
||||||
|
return localStorage.getItem(ACCESS_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTokens(access: string, refresh?: string): void {
|
||||||
|
localStorage.setItem(ACCESS_KEY, access);
|
||||||
|
if (refresh) {
|
||||||
|
localStorage.setItem(REFRESH_KEY, refresh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTokens(): void {
|
||||||
|
localStorage.removeItem(ACCESS_KEY);
|
||||||
|
localStorage.removeItem(REFRESH_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-time migration: remove the legacy DRF TokenAuth key from localStorage. */
|
||||||
|
export function clearLegacyToken(): void {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api/',
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const access = getAccessToken();
|
||||||
|
if (access) {
|
||||||
|
config.headers.Authorization = `Bearer ${access}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
type RetriableConfig = AxiosRequestConfig & { _retry?: boolean };
|
||||||
|
|
||||||
|
let refreshPromise: Promise<string | null> | null = null;
|
||||||
|
|
||||||
|
async function refreshAccessToken(): Promise<string | null> {
|
||||||
|
const refresh = localStorage.getItem(REFRESH_KEY);
|
||||||
|
if (!refresh) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.post(`${api.defaults.baseURL}auth/token/refresh/`, { refresh });
|
||||||
|
const newAccess = res.data.access as string;
|
||||||
|
const newRefresh = res.data.refresh as string | undefined;
|
||||||
|
setTokens(newAccess, newRefresh);
|
||||||
|
return newAccess;
|
||||||
|
} catch {
|
||||||
|
clearTokens();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error: AxiosError) => {
|
||||||
|
const original = error.config as RetriableConfig | undefined;
|
||||||
|
if (!original || error.response?.status !== 401 || original._retry) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = original.url ?? '';
|
||||||
|
if (url.includes('auth/token/')) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
original._retry = true;
|
||||||
|
refreshPromise = refreshPromise ?? refreshAccessToken();
|
||||||
|
const newAccess = await refreshPromise;
|
||||||
|
refreshPromise = null;
|
||||||
|
|
||||||
|
if (!newAccess) {
|
||||||
|
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
original.headers = {
|
||||||
|
...(original.headers ?? {}),
|
||||||
|
Authorization: `Bearer ${newAccess}`,
|
||||||
|
};
|
||||||
|
return api(original);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
+13
-7
@@ -1,13 +1,19 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client';
|
||||||
import { BrowserRouter as Router } from 'react-router-dom'
|
import { BrowserRouter as Router } from 'react-router-dom';
|
||||||
import './index.css'
|
import './index.css';
|
||||||
import App from './App.tsx'
|
import App from './App.tsx';
|
||||||
|
import { AppProviders } from './app/providers';
|
||||||
|
import { clearLegacyToken } from './lib/api';
|
||||||
|
|
||||||
|
clearLegacyToken();
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<Router>
|
<Router>
|
||||||
<App />
|
<AppProviders>
|
||||||
|
<App />
|
||||||
|
</AppProviders>
|
||||||
</Router>
|
</Router>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
);
|
||||||
@@ -12,10 +12,10 @@ const About: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="md" sx={{ py: { xs: 4, md: 8 }, px: { xs: 2, sm: 3 } }}>
|
<Container maxWidth="md" sx={{ py: { xs: 4, md: 8 }, px: { xs: 2, sm: 3 } }}>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
component={Link}
|
component={Link}
|
||||||
to="/"
|
to="/"
|
||||||
startIcon={<ArrowBackIcon />}
|
startIcon={<ArrowBackIcon />}
|
||||||
sx={{ mb: 4, textTransform: 'none', borderRadius: 2 }}
|
sx={{ mb: 4, textTransform: 'none', borderRadius: 2 }}
|
||||||
>
|
>
|
||||||
@@ -23,67 +23,103 @@ const About: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Paper sx={{ p: { xs: 2.5, sm: 4, md: 6 }, borderRadius: 4, overflow: 'hidden' }}>
|
<Paper sx={{ p: { xs: 2.5, sm: 4, md: 6 }, borderRadius: 4, overflow: 'hidden' }}>
|
||||||
<Typography variant={isMobile ? "h4" : "h3"} gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.title')}</Typography>
|
<Typography variant={isMobile ? 'h4' : 'h3'} gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||||
<Typography variant="body1" paragraph sx={{ fontSize: { xs: '1rem', md: '1.1rem' }, lineHeight: 1.8 }}>
|
{t('about.title')}
|
||||||
{t('about.mission')}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
paragraph
|
||||||
|
sx={{ fontSize: { xs: '1rem', md: '1.1rem' }, lineHeight: 1.8 }}
|
||||||
|
>
|
||||||
|
{t('about.mission')}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ my: 4 }}>
|
<Box sx={{ my: 4 }}>
|
||||||
<Typography variant={isMobile ? "h6" : "h5"} gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.canDoTitle')}</Typography>
|
<Typography variant={isMobile ? 'h6' : 'h5'} gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||||
<Typography component="ul" sx={{ lineHeight: 2, pl: 2 }}>
|
{t('about.canDoTitle')}
|
||||||
<li>{t('about.featureExplore')}</li>
|
</Typography>
|
||||||
<li>{t('about.featureRate')}</li>
|
<Typography component="ul" sx={{ lineHeight: 2, pl: 2 }}>
|
||||||
<li>{t('about.featureCommunity')}</li>
|
<li>{t('about.featureExplore')}</li>
|
||||||
<li>{t('about.featureProfile')}</li>
|
<li>{t('about.featureRate')}</li>
|
||||||
<li>{t('about.featurePersonalize')}</li>
|
<li>{t('about.featureCommunity')}</li>
|
||||||
<li>{t('about.featureEngage')}</li>
|
<li>{t('about.featureProfile')}</li>
|
||||||
<li>{t('about.featureSupport')}</li>
|
<li>{t('about.featurePersonalize')}</li>
|
||||||
</Typography>
|
<li>{t('about.featureEngage')}</li>
|
||||||
|
<li>{t('about.featureSupport')}</li>
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography variant="body2" color="text.secondary" paragraph>
|
<Typography variant="body2" color="text.secondary" paragraph>
|
||||||
{t('about.disclaimer')}
|
{t('about.disclaimer')}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Divider sx={{ my: { xs: 4, md: 6 } }} />
|
<Divider sx={{ my: { xs: 4, md: 6 } }} />
|
||||||
|
|
||||||
<Typography variant={isMobile ? "h5" : "h4"} gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.impressumTitle')}</Typography>
|
<Typography variant={isMobile ? 'h5' : 'h4'} gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||||
|
{t('about.impressumTitle')}
|
||||||
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ lineHeight: 2 }}>
|
<Typography variant="body2" sx={{ lineHeight: 2 }}>
|
||||||
<strong>{t('about.legalInfo')}</strong><br />
|
<strong>{t('about.legalInfo')}</strong>
|
||||||
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name or Project Name]'}<br />
|
<br />
|
||||||
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}<br />
|
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name or Project Name]'}
|
||||||
{import.meta.env.VITE_IMPRESSUM_CITY || '[Postal Code and City]'}<br />
|
<br />
|
||||||
{t('about.region')}
|
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}
|
||||||
|
<br />
|
||||||
|
{import.meta.env.VITE_IMPRESSUM_CITY || '[Postal Code and City]'}
|
||||||
|
<br />
|
||||||
|
{t('about.region')}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ mt: 3 }}>
|
<Box sx={{ mt: 3 }}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{t('about.contact')}:</Typography>
|
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
|
||||||
<Typography variant="body2">
|
{t('about.contact')}:
|
||||||
E-Mail: {import.meta.env.VITE_IMPRESSUM_EMAIL || '[Your Support Email Address]'}<br />
|
</Typography>
|
||||||
{import.meta.env.VITE_IMPRESSUM_PHONE && (
|
<Typography variant="body2">
|
||||||
<>Telefon: {import.meta.env.VITE_IMPRESSUM_PHONE}<br /></>
|
E-Mail: {import.meta.env.VITE_IMPRESSUM_EMAIL || '[Your Support Email Address]'}
|
||||||
)}
|
<br />
|
||||||
Internet: holy.narl.io
|
{import.meta.env.VITE_IMPRESSUM_PHONE && (
|
||||||
</Typography>
|
<>
|
||||||
|
Telefon: {import.meta.env.VITE_IMPRESSUM_PHONE}
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
Internet: holy.narl.io
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ mt: 3 }}>
|
<Box sx={{ mt: 3 }}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{t('about.responsible')}</Typography>
|
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
|
||||||
<Typography variant="body2">
|
{t('about.responsible')}
|
||||||
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name]'}<br />
|
</Typography>
|
||||||
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}<br />
|
<Typography variant="body2">
|
||||||
{import.meta.env.VITE_IMPRESSUM_CITY || '[Postal Code and City]'}
|
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name]'}
|
||||||
</Typography>
|
<br />
|
||||||
|
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street Address]'}
|
||||||
|
<br />
|
||||||
|
{import.meta.env.VITE_IMPRESSUM_CITY || '[Postal Code and City]'}
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ mt: 4 }}>
|
<Box sx={{ mt: 4 }}>
|
||||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>{t('about.disclaimerTitle')}</Typography>
|
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||||
<Typography variant="caption" color="text.secondary" component="p" sx={{ mb: 2, lineHeight: 1.6 }}>
|
{t('about.disclaimerTitle')}
|
||||||
<strong>{t('about.liabilityContentTitle')}</strong> {t('about.liabilityContentDesc')}
|
</Typography>
|
||||||
</Typography>
|
<Typography
|
||||||
<Typography variant="caption" color="text.secondary" component="p" sx={{ lineHeight: 1.6 }}>
|
variant="caption"
|
||||||
<strong>{t('about.copyrightTitle')}</strong> {t('about.copyrightDesc')}
|
color="text.secondary"
|
||||||
</Typography>
|
component="p"
|
||||||
|
sx={{ mb: 2, lineHeight: 1.6 }}
|
||||||
|
>
|
||||||
|
<strong>{t('about.liabilityContentTitle')}</strong> {t('about.liabilityContentDesc')}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
component="p"
|
||||||
|
sx={{ lineHeight: 1.6 }}
|
||||||
|
>
|
||||||
|
<strong>{t('about.copyrightTitle')}</strong> {t('about.copyrightDesc')}
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user