mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
updated build/instructions
This commit is contained in:
1 parent
d9aeeaaab8
commit
e8ed7bf7ae
12 files changed
+176
-61
No files matched your search
+24
-28
@@ -1,37 +1,33 @@
|
||||
# --- App Hosting ---
|
||||
# A comma-separated list of hostnames that the Vite dev server will accept.
|
||||
# Required if you are accessing the frontend via a custom domain.
|
||||
# Example: VITE_ALLOWED_HOSTS=holy.narl.io,localhost
|
||||
VITE_ALLOWED_HOSTS=localhost
|
||||
# --- Application Security ---
|
||||
# Set to a long, random string in production
|
||||
SECRET_KEY=django-insecure-replace-this-in-production
|
||||
|
||||
# A comma-separated list of domains that can access the backend API.
|
||||
# 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
|
||||
# Set to "false" in production for security and performance
|
||||
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.
|
||||
# This should match your frontend domain.
|
||||
# Example: CSRF_TRUSTED_ORIGINS=https://holy.narl.io,http://localhost:5173
|
||||
# --- Hosting & Networking ---
|
||||
# The primary domain where your app is hosted (e.g., holy.narl.io)
|
||||
# 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
|
||||
|
||||
# --- SMTP Configuration ---
|
||||
# Rename this file to .env and fill in your details to enable email sending.
|
||||
# CORS protection: Comma-separated list of allowed origins (must match your frontend domain)
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
|
||||
# --- Required for SMTP ---
|
||||
EMAIL_HOST=smtp.example.com
|
||||
# --- SMTP Configuration (Required for Email Verification) ---
|
||||
# For development, if EMAIL_HOST is empty, emails are printed to the console.
|
||||
EMAIL_HOST=smtp.gmail.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_HOST_USER=your-email@example.com
|
||||
# Do not wrap the password in quotes or backticks
|
||||
EMAIL_HOST_PASSWORD=your-app-password
|
||||
EMAIL_USE_TLS=true
|
||||
EMAIL_HOST_USER=your-email@gmail.com
|
||||
EMAIL_HOST_PASSWORD=your-app-specific-password
|
||||
|
||||
# --- Optional (defaults are usually fine) ---
|
||||
# EMAIL_USE_TLS=true
|
||||
# For providers like Gmail using port 465, you might need to set this to true
|
||||
# EMAIL_USE_SSL=true
|
||||
# --- Frontend Configuration ---
|
||||
# The base URL of the backend API (e.g., https://holy.narl.io/api/)
|
||||
VITE_API_URL=http://localhost:8000/api/
|
||||
VITE_ALLOWED_HOSTS=localhost
|
||||
@@ -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
|
||||
@@ -59,10 +59,10 @@ class Flavor(models.Model):
|
||||
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)
|
||||
is_available = models.BooleanField(default=True, db_index=True)
|
||||
is_legacy = models.BooleanField(default=False, db_index=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):
|
||||
return self.name
|
||||
@@ -83,9 +83,9 @@ class Flavor(models.Model):
|
||||
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)])
|
||||
score = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(10)], db_index=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:
|
||||
unique_together = ('user', 'flavor')
|
||||
|
||||
@@ -4,6 +4,8 @@ from rest_framework.response import Response
|
||||
from rest_framework.decorators import action
|
||||
from django.db.models import Avg
|
||||
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 .serializers import UserSerializer, FlavorSerializer, CategorySerializer, RatingSerializer, ReplySerializer
|
||||
|
||||
@@ -32,6 +34,7 @@ class RatingViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = RatingSerializer
|
||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||
|
||||
@method_decorator(ratelimit(key='user', rate='10/m', method='POST', block=True))
|
||||
def perform_create(self, serializer):
|
||||
# Ensure user can only have one rating per flavor
|
||||
flavor = serializer.validated_data['flavor']
|
||||
@@ -80,6 +83,7 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
return super().get_permissions()
|
||||
|
||||
@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')
|
||||
@@ -146,9 +150,7 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
except User.DoesNotExist:
|
||||
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()
|
||||
# Sort by score descending to highlight favorites
|
||||
ratings = sorted(ratings, key=lambda x: x.score, reverse=True)
|
||||
ratings = user.ratings.select_related('flavor', 'flavor__category').exclude(flavor__category__slug='packs-and-other').order_by('-score')
|
||||
|
||||
return Response({
|
||||
'username': user.username,
|
||||
@@ -247,6 +249,7 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
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:
|
||||
@@ -270,6 +273,7 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
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')
|
||||
|
||||
@@ -20,4 +20,4 @@ python manage.py sync_flavors
|
||||
|
||||
# Start the server using 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
|
||||
@@ -21,12 +21,12 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
|
||||
|
||||
# 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!
|
||||
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(',')]
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ INSTALLED_APPS = [
|
||||
'corsheaders',
|
||||
'django_filters',
|
||||
'drf_spectacular',
|
||||
'django_ratelimit',
|
||||
# Local
|
||||
'api',
|
||||
]
|
||||
@@ -53,6 +54,7 @@ INSTALLED_APPS = [
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'compression_middleware.middleware.CompressionMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
@@ -141,8 +143,19 @@ 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_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(',')]
|
||||
|
||||
# Custom User Model
|
||||
@@ -158,11 +171,13 @@ REST_FRAMEWORK = {
|
||||
'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_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(',')]
|
||||
|
||||
import os
|
||||
|
||||
@@ -7,9 +7,12 @@ from django.conf.urls.static import static
|
||||
|
||||
from django.views.static import serve
|
||||
import re
|
||||
import os
|
||||
|
||||
admin_path = os.environ.get('ADMIN_URL', 'admin/')
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path(admin_path, admin.site.urls),
|
||||
path('api/', include('api.urls')),
|
||||
path('api/token/', obtain_auth_token, name='api_token'),
|
||||
path('api-auth/', include('rest_framework.urls')),
|
||||
|
||||
@@ -7,3 +7,5 @@ Pillow>=10.2.0
|
||||
drf-spectacular>=0.27.0
|
||||
whitenoise>=6.6.0
|
||||
gunicorn>=21.2.0
|
||||
django-ratelimit>=4.1.0
|
||||
django-compression-middleware>=0.5.0
|
||||
+23
-18
@@ -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 {
|
||||
ThemeProvider,
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
Collapse,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Autocomplete
|
||||
Autocomplete,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
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 ExpandMore from '@mui/icons-material/ExpandMore';
|
||||
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';
|
||||
|
||||
// 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 {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -371,15 +374,17 @@ const App: React.FC = () => {
|
||||
</Drawer>
|
||||
|
||||
<Box sx={{ flexGrow: 1, width: '100%' }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<CategoryList />} />
|
||||
<Route path="/category/:slug" element={<CategoryFlavors />} />
|
||||
<Route path="/flavor/:id" element={<FlavorDetail />} />
|
||||
<Route path="/profile/:username" element={<PublicProfile />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/settings" element={<Settings themeName={themeName} onThemeChange={handleThemeChange} />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
</Routes>
|
||||
<Suspense fallback={<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>}>
|
||||
<Routes>
|
||||
<Route path="/" element={<CategoryList />} />
|
||||
<Route path="/category/:slug" element={<CategoryFlavors />} />
|
||||
<Route path="/flavor/:id" element={<FlavorDetail />} />
|
||||
<Route path="/profile/:username" element={<PublicProfile />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/settings" element={<Settings themeName={themeName} onThemeChange={handleThemeChange} />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Box>
|
||||
</Box>
|
||||
</Router>
|
||||
|
||||
@@ -159,6 +159,7 @@ const CategoryList: React.FC = () => {
|
||||
transition: 'transform 0.5s ease',
|
||||
'&:hover': { transform: 'scale(1.1)' }
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
@@ -260,6 +261,7 @@ const CategoryList: React.FC = () => {
|
||||
component="img"
|
||||
src={currentTop.image_url || ''}
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
loading="lazy"
|
||||
/>
|
||||
</Box>
|
||||
<CardContent sx={{ flex: 1, p: { xs: 3, sm: 4, md: 6 }, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
|
||||
@@ -137,6 +137,7 @@ const Dashboard: React.FC = () => {
|
||||
component="img"
|
||||
src={rating.flavor_image || undefined}
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
loading="lazy"
|
||||
/>
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
@@ -205,6 +206,7 @@ const Dashboard: React.FC = () => {
|
||||
component="img"
|
||||
src={flavor.image_url || undefined}
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
loading="lazy"
|
||||
/>
|
||||
</Box>
|
||||
<CardContent sx={{ p: 1, '&:last-child': { pb: 1 } }}>
|
||||
|
||||
@@ -166,6 +166,7 @@ const PublicProfile: React.FC = () => {
|
||||
component="img"
|
||||
src={rating.flavor_image || undefined}
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
loading="lazy"
|
||||
/>
|
||||
<Box sx={{
|
||||
position: 'absolute', bottom: 0, left: 0, right: 0,
|
||||
|
||||
Reference in new issue
Block a user