updated build/instructions

This commit is contained in:
nvrl committed 2026-02-16 17:02:07 +01:00
1 parent d9aeeaaab8
commit e8ed7bf7ae
12 files changed
+176 -61

No files matched your search

+24 -28
View File
@@ -1,37 +1,33 @@
# --- App Hosting --- # --- Application Security ---
# A comma-separated list of hostnames that the Vite dev server will accept. # Set to a long, random string in production
# Required if you are accessing the frontend via a custom domain. SECRET_KEY=django-insecure-replace-this-in-production
# Example: VITE_ALLOWED_HOSTS=holy.narl.io,localhost
VITE_ALLOWED_HOSTS=localhost
# A comma-separated list of domains that can access the backend API. # Set to "false" in production for security and performance
# Example: CORS_ALLOWED_ORIGINS=http://localhost:5173,https://holy.narl.io
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# A comma-separated list of hostnames that the Django backend will serve.
# Example: ALLOWED_HOSTS=localhost,backend,api.holy.narl.io
ALLOWED_HOSTS=localhost,127.0.0.1,backend
# Set to "false" in production
DEBUG=true DEBUG=true
# Custom URL for the Django Admin interface (e.g., secret-admin/)
ADMIN_URL=admin/
# A comma-separated list of domains that Django will trust for CSRF purposes. # --- Hosting & Networking ---
# This should match your frontend domain. # The primary domain where your app is hosted (e.g., holy.narl.io)
# Example: CSRF_TRUSTED_ORIGINS=https://holy.narl.io,http://localhost:5173 # Comma-separated list for backend to allow requests
ALLOWED_HOSTS=localhost,127.0.0.1,backend
# CSRF protection: Comma-separated list of trusted origins (must start with http:// or https://)
CSRF_TRUSTED_ORIGINS=http://localhost:5173 CSRF_TRUSTED_ORIGINS=http://localhost:5173
# --- SMTP Configuration --- # CORS protection: Comma-separated list of allowed origins (must match your frontend domain)
# Rename this file to .env and fill in your details to enable email sending. CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# --- Required for SMTP --- # --- SMTP Configuration (Required for Email Verification) ---
EMAIL_HOST=smtp.example.com # For development, if EMAIL_HOST is empty, emails are printed to the console.
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587 EMAIL_PORT=587
EMAIL_HOST_USER=your-email@example.com EMAIL_USE_TLS=true
# Do not wrap the password in quotes or backticks EMAIL_HOST_USER=your-email@gmail.com
EMAIL_HOST_PASSWORD=your-app-password EMAIL_HOST_PASSWORD=your-app-specific-password
# --- Optional (defaults are usually fine) --- # --- Frontend Configuration ---
# EMAIL_USE_TLS=true # The base URL of the backend API (e.g., https://holy.narl.io/api/)
# For providers like Gmail using port 465, you might need to set this to true VITE_API_URL=http://localhost:8000/api/
# EMAIL_USE_SSL=true VITE_ALLOWED_HOSTS=localhost
+85
View File
@@ -0,0 +1,85 @@
# Holy Energy Archive & Community Hub
A premium, responsive full-stack platform for "Holy Energy" fans to rate flavors, explore a categorized archive, and share personalized rating profiles.
## Features
- **Dynamic Hall of Fame:** An auto-rotating carousel featuring the top-rated flavors and community reviews.
- **Flavor Gallery:** A space-efficient, grid-based view of all rated and missing flavors.
- **Personalized Taste Profiles:** Share your ratings with the community using tiered (S-D) leaderboards.
- **Responsive Design:** Optimized for all devices, from ultra-wide monitors to vertical smartphones.
- **Secure Authentication:** SMTP-verified email signup, password resets, and session management.
- **Catalog Automation:** Automatic flavor syncing from official Shopify sources with local image caching.
---
## Prerequisites
- [Docker](https://docs.docker.com/get-docker/)
- [Docker Compose](https://docs.docker.com/compose/install/)
- [SMTP Server](https://support.google.com/mail/answer/185833?hl=en) (e.g., Gmail App Password) for email verification.
---
## Quick Setup
1. **Clone the repository:**
```bash
git clone https://github.com/your-repo/holy.git
cd holy
```
2. **Configure your environment:**
Copy the example environment file and fill in your specific details (especially SMTP and your domain name):
```bash
cp .env.example .env
```
3. **Start the containers:**
```bash
docker-compose up -d --build
```
4. **Wait for initial setup:**
The first time you start the backend, it will automatically:
- Run database migrations.
- Collect static files.
- **Sync flavors** from the Holy Energy Shopify API (this might take a minute).
5. **Access the application:**
- **Frontend:** [http://localhost:5173](http://localhost:5173) (or your configured domain)
- **API Docs:** [http://localhost:8000/api/schema/swagger-ui/](http://localhost:8000/api/schema/swagger-ui/)
- **Admin Panel:** [http://localhost:8000/admin/](http://localhost:8000/admin/) (or your configured `ADMIN_URL`)
---
## Production Hosting
### Security Best Practices
When hosting this application publicly, ensure you update your `.env` with:
- `DEBUG=false`: Essential for disabling internal Django stack traces.
- `SECRET_KEY`: Use a long, random string.
- `ADMIN_URL`: Change this to something unique (e.g., `super-secret-gate/`) to hide your admin panel from scanners.
- `ALLOWED_HOSTS`, `CSRF_TRUSTED_ORIGINS`, `CORS_ALLOWED_ORIGINS`: Set these to your actual production domain (e.g., `https://holy.narl.io`).
### Performance
- The application uses **WhiteNoise** for efficient static file serving.
- **Gunicorn** is configured as the production WSGI server.
- **Compression Middleware** (Gzip/Brotli) is active for API responses.
- **Lazy Loading** is enabled for all flavor and carousel images.
---
## Project Structure
- `backend/`: Django REST Framework API.
- `frontend/`: React 19 application with Material UI and Catppuccin themes.
- `media/`: Locally cached product images and user avatars.
- `legacy/`: Static JSON data for historical flavors.
## License
MIT
+5 -5
View File
@@ -59,10 +59,10 @@ class Flavor(models.Model):
image_url = models.URLField(max_length=1000, blank=True, null=True) image_url = models.URLField(max_length=1000, blank=True, null=True)
image = models.ImageField(upload_to='flavors/', 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) shop_url = models.URLField(max_length=500, blank=True, null=True)
is_available = models.BooleanField(default=True) is_available = models.BooleanField(default=True, db_index=True)
is_legacy = models.BooleanField(default=False) is_legacy = models.BooleanField(default=False, db_index=True)
external_id = models.BigIntegerField(unique=True, null=True, blank=True) external_id = models.BigIntegerField(unique=True, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True, db_index=True)
def __str__(self): def __str__(self):
return self.name return self.name
@@ -83,9 +83,9 @@ class Flavor(models.Model):
class Rating(models.Model): class Rating(models.Model):
user = models.ForeignKey(User, related_name='ratings', on_delete=models.CASCADE) user = models.ForeignKey(User, related_name='ratings', on_delete=models.CASCADE)
flavor = models.ForeignKey(Flavor, 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)]) score = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(10)], db_index=True)
comment = models.TextField(blank=True, null=True) comment = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta: class Meta:
unique_together = ('user', 'flavor') unique_together = ('user', 'flavor')
+7 -3
View File
@@ -4,6 +4,8 @@ from rest_framework.response import Response
from rest_framework.decorators import action from rest_framework.decorators import action
from django.db.models import Avg from django.db.models import Avg
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from django_ratelimit.decorators import ratelimit
from django.utils.decorators import method_decorator
from .models import User, Flavor, Category, Rating, Reply from .models import User, Flavor, Category, Rating, Reply
from .serializers import UserSerializer, FlavorSerializer, CategorySerializer, RatingSerializer, ReplySerializer from .serializers import UserSerializer, FlavorSerializer, CategorySerializer, RatingSerializer, ReplySerializer
@@ -32,6 +34,7 @@ class RatingViewSet(viewsets.ModelViewSet):
serializer_class = RatingSerializer serializer_class = RatingSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly] permission_classes = [permissions.IsAuthenticatedOrReadOnly]
@method_decorator(ratelimit(key='user', rate='10/m', method='POST', block=True))
def perform_create(self, serializer): def perform_create(self, serializer):
# Ensure user can only have one rating per flavor # Ensure user can only have one rating per flavor
flavor = serializer.validated_data['flavor'] flavor = serializer.validated_data['flavor']
@@ -80,6 +83,7 @@ class UserViewSet(viewsets.ModelViewSet):
return super().get_permissions() return super().get_permissions()
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny]) @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): def signup(self, request):
username = request.data.get('username') username = request.data.get('username')
email = request.data.get('email') email = request.data.get('email')
@@ -146,9 +150,7 @@ class UserViewSet(viewsets.ModelViewSet):
except User.DoesNotExist: except User.DoesNotExist:
return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND) return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND)
ratings = user.ratings.select_related('flavor', 'flavor__category').exclude(flavor__category__slug='packs-and-other').all() ratings = user.ratings.select_related('flavor', 'flavor__category').exclude(flavor__category__slug='packs-and-other').order_by('-score')
# Sort by score descending to highlight favorites
ratings = sorted(ratings, key=lambda x: x.score, reverse=True)
return Response({ return Response({
'username': user.username, 'username': user.username,
@@ -247,6 +249,7 @@ class UserViewSet(viewsets.ModelViewSet):
return Response({'status': 'Email confirmed', 'email': user.email}) return Response({'status': 'Email confirmed', 'email': user.email})
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny]) @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): def request_password_reset(self, request):
email = request.data.get('email') email = request.data.get('email')
try: try:
@@ -270,6 +273,7 @@ class UserViewSet(viewsets.ModelViewSet):
return Response({'status': 'If an account exists with this email, a reset code has been sent.'}) 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]) @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): def complete_password_reset(self, request):
email = request.data.get('email') email = request.data.get('email')
code = request.data.get('code') code = request.data.get('code')
+1 -1
View File
@@ -20,4 +20,4 @@ python manage.py sync_flavors
# Start the server using Gunicorn # Start the server using Gunicorn
echo "Starting Gunicorn..." echo "Starting Gunicorn..."
exec gunicorn holy_backend.wsgi:application --bind 0.0.0.0:8000 --workers 3 exec gunicorn holy_backend.wsgi:application --bind 0.0.0.0:8000 --workers 3 --timeout 120
+20 -5
View File
@@ -21,12 +21,12 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-zos99ue2a89!4jrv4g@_b4nn4z!5o*@8k!5cr9!u$j%_@xb=l8' 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! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.environ.get('DEBUG', 'true').lower() == 'true' DEBUG = os.environ.get('DEBUG', 'false').lower() == 'true'
ALLOWED_HOSTS_STRING = os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1,backend') ALLOWED_HOSTS_STRING = os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1,backend,holy.narl.io')
ALLOWED_HOSTS = [host.strip() for host in ALLOWED_HOSTS_STRING.split(',')] ALLOWED_HOSTS = [host.strip() for host in ALLOWED_HOSTS_STRING.split(',')]
@@ -46,6 +46,7 @@ INSTALLED_APPS = [
'corsheaders', 'corsheaders',
'django_filters', 'django_filters',
'drf_spectacular', 'drf_spectacular',
'django_ratelimit',
# Local # Local
'api', 'api',
] ]
@@ -53,6 +54,7 @@ INSTALLED_APPS = [
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware', 'whitenoise.middleware.WhiteNoiseMiddleware',
'compression_middleware.middleware.CompressionMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware', 'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
@@ -141,8 +143,19 @@ USE_X_FORWARDED_HOST = True
USE_X_FORWARDED_PORT = True USE_X_FORWARDED_PORT = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') 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 # Read CSRF trusted origins from environment variable
CSRF_TRUSTED_ORIGINS_STRING = os.environ.get('CSRF_TRUSTED_ORIGINS', 'http://localhost:5173') CSRF_TRUSTED_ORIGINS_STRING = os.environ.get('CSRF_TRUSTED_ORIGINS', 'https://holy.narl.io,http://localhost:5173')
CSRF_TRUSTED_ORIGINS = [origin.strip() for origin in CSRF_TRUSTED_ORIGINS_STRING.split(',')] CSRF_TRUSTED_ORIGINS = [origin.strip() for origin in CSRF_TRUSTED_ORIGINS_STRING.split(',')]
# Custom User Model # Custom User Model
@@ -158,11 +171,13 @@ REST_FRAMEWORK = {
'rest_framework.permissions.IsAuthenticatedOrReadOnly', 'rest_framework.permissions.IsAuthenticatedOrReadOnly',
], ],
'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], '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', 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
} }
# CORS # CORS
CORS_ALLOWED_ORIGINS_STRING = os.environ.get('CORS_ALLOWED_ORIGINS', 'http://localhost:5173,http://127.0.0.1:5173') CORS_ALLOWED_ORIGINS_STRING = os.environ.get('CORS_ALLOWED_ORIGINS', 'https://holy.narl.io,http://localhost:5173,http://127.0.0.1:5173')
CORS_ALLOWED_ORIGINS = [origin.strip() for origin in CORS_ALLOWED_ORIGINS_STRING.split(',')] CORS_ALLOWED_ORIGINS = [origin.strip() for origin in CORS_ALLOWED_ORIGINS_STRING.split(',')]
import os import os
+4 -1
View File
@@ -7,9 +7,12 @@ from django.conf.urls.static import static
from django.views.static import serve from django.views.static import serve
import re import re
import os
admin_path = os.environ.get('ADMIN_URL', 'admin/')
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path(admin_path, admin.site.urls),
path('api/', include('api.urls')), path('api/', include('api.urls')),
path('api/token/', obtain_auth_token, name='api_token'), path('api/token/', obtain_auth_token, name='api_token'),
path('api-auth/', include('rest_framework.urls')), path('api-auth/', include('rest_framework.urls')),
+2
View File
@@ -7,3 +7,5 @@ Pillow>=10.2.0
drf-spectacular>=0.27.0 drf-spectacular>=0.27.0
whitenoise>=6.6.0 whitenoise>=6.6.0
gunicorn>=21.2.0 gunicorn>=21.2.0
django-ratelimit>=4.1.0
django-compression-middleware>=0.5.0
+23 -18
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo, Suspense, lazy } from 'react';
import { BrowserRouter as Router, Routes, Route, Link, useNavigate, useLocation } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route, Link, useNavigate, useLocation } from 'react-router-dom';
import { import {
ThemeProvider, ThemeProvider,
@@ -21,7 +21,8 @@ import {
Collapse, Collapse,
TextField, TextField,
InputAdornment, InputAdornment,
Autocomplete Autocomplete,
CircularProgress
} from '@mui/material'; } from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu'; import MenuIcon from '@mui/icons-material/Menu';
import SearchIcon from '@mui/icons-material/Search'; import SearchIcon from '@mui/icons-material/Search';
@@ -29,15 +30,17 @@ import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import ExpandLess from '@mui/icons-material/ExpandLess'; import ExpandLess from '@mui/icons-material/ExpandLess';
import ExpandMore from '@mui/icons-material/ExpandMore'; import ExpandMore from '@mui/icons-material/ExpandMore';
import { getTheme, type CatppuccinTheme } from './theme'; import { getTheme, type CatppuccinTheme } from './theme';
import CategoryList from './pages/CategoryList';
import CategoryFlavors from './pages/categories/CategoryFlavors';
import FlavorDetail from './pages/flavors/FlavorDetail';
import Dashboard from './pages/Dashboard';
import PublicProfile from './pages/PublicProfile';
import Login from './pages/Login';
import Settings from './pages/Settings';
import api from './api'; import api from './api';
// Code splitting for routes
const CategoryList = lazy(() => import('./pages/CategoryList'));
const CategoryFlavors = lazy(() => import('./pages/categories/CategoryFlavors'));
const FlavorDetail = lazy(() => import('./pages/flavors/FlavorDetail'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const PublicProfile = lazy(() => import('./pages/PublicProfile'));
const Login = lazy(() => import('./pages/Login'));
const Settings = lazy(() => import('./pages/Settings'));
interface SearchFlavor { interface SearchFlavor {
id: number; id: number;
name: string; name: string;
@@ -371,15 +374,17 @@ const App: React.FC = () => {
</Drawer> </Drawer>
<Box sx={{ flexGrow: 1, width: '100%' }}> <Box sx={{ flexGrow: 1, width: '100%' }}>
<Routes> <Suspense fallback={<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>}>
<Route path="/" element={<CategoryList />} /> <Routes>
<Route path="/category/:slug" element={<CategoryFlavors />} /> <Route path="/" element={<CategoryList />} />
<Route path="/flavor/:id" element={<FlavorDetail />} /> <Route path="/category/:slug" element={<CategoryFlavors />} />
<Route path="/profile/:username" element={<PublicProfile />} /> <Route path="/flavor/:id" element={<FlavorDetail />} />
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/profile/:username" element={<PublicProfile />} />
<Route path="/settings" element={<Settings themeName={themeName} onThemeChange={handleThemeChange} />} /> <Route path="/dashboard" element={<Dashboard />} />
<Route path="/login" element={<Login />} /> <Route path="/settings" element={<Settings themeName={themeName} onThemeChange={handleThemeChange} />} />
</Routes> <Route path="/login" element={<Login />} />
</Routes>
</Suspense>
</Box> </Box>
</Box> </Box>
</Router> </Router>
+2
View File
@@ -159,6 +159,7 @@ const CategoryList: React.FC = () => {
transition: 'transform 0.5s ease', transition: 'transform 0.5s ease',
'&:hover': { transform: 'scale(1.1)' } '&:hover': { transform: 'scale(1.1)' }
}} }}
loading="lazy"
/> />
</Box> </Box>
)} )}
@@ -260,6 +261,7 @@ const CategoryList: React.FC = () => {
component="img" component="img"
src={currentTop.image_url || ''} src={currentTop.image_url || ''}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }} sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
loading="lazy"
/> />
</Box> </Box>
<CardContent sx={{ flex: 1, p: { xs: 3, sm: 4, md: 6 }, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}> <CardContent sx={{ flex: 1, p: { xs: 3, sm: 4, md: 6 }, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
+2
View File
@@ -137,6 +137,7 @@ const Dashboard: React.FC = () => {
component="img" component="img"
src={rating.flavor_image || undefined} src={rating.flavor_image || undefined}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }} sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
loading="lazy"
/> />
<Box sx={{ <Box sx={{
position: 'absolute', position: 'absolute',
@@ -205,6 +206,7 @@ const Dashboard: React.FC = () => {
component="img" component="img"
src={flavor.image_url || undefined} src={flavor.image_url || undefined}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }} sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
loading="lazy"
/> />
</Box> </Box>
<CardContent sx={{ p: 1, '&:last-child': { pb: 1 } }}> <CardContent sx={{ p: 1, '&:last-child': { pb: 1 } }}>
+1
View File
@@ -166,6 +166,7 @@ const PublicProfile: React.FC = () => {
component="img" component="img"
src={rating.flavor_image || undefined} src={rating.flavor_image || undefined}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }} sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
loading="lazy"
/> />
<Box sx={{ <Box sx={{
position: 'absolute', bottom: 0, left: 0, right: 0, position: 'absolute', bottom: 0, left: 0, right: 0,