Initial commit

This commit is contained in:
2025-12-04 22:24:47 +01:00
commit 453ce10494
106 changed files with 17145 additions and 0 deletions

28
.env.example Normal file
View File

@@ -0,0 +1,28 @@
# ==========================================
# REQUIRED SETTINGS
# ==========================================
# Security: Must be set to a secure random string in production
SECRET_KEY=generate_with_openssl_rand_base64_32
# ==========================================
# OPTIONAL SETTINGS (Defaults shown)
# ==========================================
# Database: Defaults to sqlite:////config/config.db if not set
# DATABASE_URL=sqlite:////config/config.db
# Security: Defaults to HS256 and 1440 (24h)
# ALGORITHM=HS256
# ACCESS_TOKEN_EXPIRE_MINUTES=1440
# CORS: Add external domains here. Defaults to empty list (localhost only).
# ALLOWED_HOSTS=["https://your-domain.com"]
# Frontend: Defaults to localhost:8000
# VITE_API_URL=http://localhost:8000
# VITE_WS_URL=ws://localhost:8000/ws
# Development: Defaults to false/info
# DEBUG=false
# LOG_LEVEL=info

91
.gitignore vendored Normal file
View File

@@ -0,0 +1,91 @@
# Environment variables
.env
.env.local
.env.*.local
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
venv/
ENV/
env/
.venv
# FastAPI / Alembic
alembic.ini.bak
*.db
*.sqlite
*.sqlite3
config/
# Node / npm / pnpm
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.pnpm-store/
package-lock.json
yarn.lock
pnpm-lock.yaml
# React / Vite
dist/
dist-ssr/
*.local
.vite/
# IDEs
.vscode/
.idea/
.claude/
*.swp
*.swo
*~
.DS_Store
# Logs
logs/
*.log
# Testing
.coverage
htmlcov/
.pytest_cache/
.vitest/
# Docker
docker-compose.override.yml
*.pid
# Database backups
*.sql
*.dump
# Temp files
temp/
tmp/
*.tmp
# OS
Thumbs.db
.DS_Store

65
Dockerfile Normal file
View File

@@ -0,0 +1,65 @@
# Multi-stage build for Generic App Service single container
# Stage 1: Build frontend
FROM node:20-alpine as frontend-builder
WORKDIR /frontend
# Copy package files
COPY frontend/package*.json ./
# Install dependencies
RUN npm ci
# Copy frontend source
COPY frontend/ .
# Build frontend
RUN npm run build
# Stage 2: Build backend dependencies
FROM python:3.11-slim as backend-builder
WORKDIR /app
# Install only essential build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copy and install Python dependencies
COPY backend/requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
# Stage 3: Final image
FROM python:3.11-slim
WORKDIR /app
# Copy Python packages from builder
COPY --from=backend-builder /root/.local /root/.local
# Make sure scripts in .local are usable
ENV PATH=/root/.local/bin:$PATH
# Create config directory
RUN mkdir -p /config
# Copy backend application
COPY backend/app ./app
COPY backend/alembic ./alembic
COPY backend/alembic.ini .
COPY .env .
# Copy built frontend from frontend-builder
COPY --from=frontend-builder /frontend/dist ./static
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
# Run database migrations and start the application
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"]

37
Makefile Normal file
View File

@@ -0,0 +1,37 @@
.PHONY: help up down restart logs clean build test
help:
@echo "Service App - Make Commands"
@echo ""
@echo "Usage:"
@echo " make up Start service with Docker Compose"
@echo " make down Stop service"
@echo " make restart Restart service"
@echo " make logs View logs"
@echo " make clean Stop and remove containers"
@echo " make build Rebuild container"
@echo " make shell Open container shell"
up:
docker-compose up -d
down:
docker-compose down
restart:
docker-compose restart
logs:
docker-compose logs -f
clean:
docker-compose down -v --remove-orphans
rm -rf backend/__pycache__ backend/**/__pycache__
rm -rf frontend/node_modules frontend/dist
build:
docker-compose build --no-cache
shell:
docker-compose exec app /bin/bash

103
README.md Normal file
View File

@@ -0,0 +1,103 @@
# Service App
Modern web application for service management. Built with React, FastAPI, and SQLite.
## Features
- 👥 User Management
- 🔐 Authentication & Authorization
- 🎨 Modern, responsive UI with dark mode
- 🐳 Fully containerized with Docker
## Technology Stack
### Frontend
- React 18 + TypeScript
- Vite (build tool)
- Tailwind CSS + shadcn/ui
- React Query (TanStack Query)
- Zustand (state management)
### Backend
- FastAPI (Python 3.11+)
- SQLAlchemy 2.0 (ORM)
- SQLite (Database)
- Alembic (migrations)
- JWT authentication
### Infrastructure
- Docker + Docker Compose
## Quick Start
### Prerequisites
- Docker and Docker Compose
- Git
### Installation
1. Clone the repository:
```bash
git clone <repository-url>
cd <repository-folder>
```
2. Copy environment variables:
```bash
cp .env.example .env
# Edit .env with your configuration
```
3. Generate secret key:
```bash
openssl rand -base64 32
# Add to .env as SECRET_KEY
```
4. Start all services:
```bash
docker-compose up -d
```
5. Access the application:
- Frontend: http://localhost:5174
- Backend API: http://localhost:5174/api/v1
- API Documentation: http://localhost:5174/docs
## Project Structure
```
.
├── docker-compose.yml
├── backend/ # FastAPI application
│ ├── app/
│ │ ├── api/ # API routes
│ │ ├── models/ # SQLAlchemy models
│ │ ├── schemas/ # Pydantic schemas
│ │ ├── services/ # Business logic
│ │ └── main.py # Entry point
│ └── alembic/ # Database migrations
├── frontend/ # React application
│ └── src/
│ ├── components/ # React components
│ ├── pages/ # Page components
│ ├── api/ # API client
│ └── hooks/ # Custom hooks
└── scripts/ # Utility scripts
```
## Configuration
### Environment Variables
See `.env.example` for all available configuration options.
Key variables:
- `SECRET_KEY`: JWT secret key
- `ALLOWED_HOSTS`: CORS configuration
## Contributing
Feel free to fork and customize for your needs.

32
backend/Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
FROM python:3.11-slim
# Install system dependencies
RUN apt-get update && apt-get install -y \
ffmpeg \
wget \
curl \
git \
postgresql-client \
docker.io \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Set working directory
WORKDIR /app
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create directories
RUN mkdir -p /downloads /plex
# Expose port
EXPOSE 8000
# Run migrations and start server
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"]

88
backend/alembic.ini Normal file
View File

@@ -0,0 +1,88 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

88
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,88 @@
"""Alembic environment configuration."""
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
import os
import sys
# Add parent directory to path
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
from app.db.base import Base
from app.config import settings
# Import all models so Alembic can detect them for auto-generating migrations
from app.models.user import User # noqa
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# Set database URL from settings
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,45 @@
"""Initial schema with users table
Revision ID: 001
Revises:
Create Date: 2025-01-15 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '001'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create users table (SQLite compatible)
op.create_table(
'users',
sa.Column('id', sa.String(length=36), primary_key=True),
sa.Column('username', sa.String(length=100), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('hashed_password', sa.String(length=255), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
sa.Column('is_superuser', sa.Boolean(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(), server_default=sa.text("(datetime('now'))"), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text("(datetime('now'))"), nullable=False),
)
# Create indexes
op.create_index('ix_users_username', 'users', ['username'], unique=True)
op.create_index('ix_users_email', 'users', ['email'], unique=True)
def downgrade() -> None:
# Drop indexes
op.drop_index('ix_users_email', table_name='users')
op.drop_index('ix_users_username', table_name='users')
# Drop table
op.drop_table('users')

View File

@@ -0,0 +1,41 @@
"""Add settings table
Revision ID: 002
Revises: 001
Create Date: 2025-01-27 10:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '002'
down_revision: Union[str, None] = '001'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create settings table (SQLite compatible)
op.create_table(
'settings',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('key', sa.String(length=100), nullable=False),
sa.Column('value_str', sa.String(length=500), nullable=True),
sa.Column('value_bool', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text("(datetime('now'))"), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text("(datetime('now'))"), nullable=False),
)
# Create unique index on key
op.create_index('ix_settings_key', 'settings', ['key'], unique=True)
def downgrade() -> None:
# Drop index
op.drop_index('ix_settings_key', table_name='settings')
# Drop table
op.drop_table('settings')

View File

@@ -0,0 +1,28 @@
"""add permissions to users
Revision ID: 004
Revises: 3382f3992222
Create Date: 2025-11-28 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '004'
down_revision: Union[str, None] = '3382f3992222'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add permissions column to users table (JSON stored as TEXT for SQLite)
op.add_column('users', sa.Column('permissions', sa.Text(), nullable=True))
def downgrade() -> None:
# Remove permissions column from users table
op.drop_column('users', 'permissions')

View File

@@ -0,0 +1,28 @@
"""add last_login to users
Revision ID: 3382f3992222
Revises: 002
Create Date: 2025-11-27 15:15:10.667875
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '3382f3992222'
down_revision: Union[str, None] = '002'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add last_login column to users table
op.add_column('users', sa.Column('last_login', sa.DateTime(), nullable=True))
def downgrade() -> None:
# Remove last_login column from users table
op.drop_column('users', 'last_login')

0
backend/app/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,5 @@
"""API v1 package - exports the main v1 router."""
from app.api.v1.router import router
__all__ = ["router"]

155
backend/app/api/v1/auth.py Normal file
View File

@@ -0,0 +1,155 @@
"""Authentication endpoints."""
from datetime import datetime, timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app import crud, schemas
from app.dependencies import get_db, get_current_user
from app.core.security import create_access_token
from app.config import settings
from app.models.user import User
router = APIRouter()
@router.post("/register", response_model=schemas.User, status_code=status.HTTP_201_CREATED)
def register(
*,
db: Session = Depends(get_db),
user_in: schemas.RegisterRequest
) -> Any:
"""
Register a new user.
Creates a new user account with the provided credentials.
Registration can be disabled by administrators via settings.
"""
# Check if this is the first user (always allow for initial setup)
user_count = db.query(User).count()
is_first_user = user_count == 0
# If not the first user, check if registration is enabled
if not is_first_user:
registration_enabled = crud.settings.get_setting_value(
db,
key="registration_enabled",
default=True # Default to enabled if setting doesn't exist
)
if not registration_enabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User registration is currently disabled"
)
# Check if username already exists
user = crud.user.get_by_username(db, username=user_in.username)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered"
)
# Check if email already exists
user = crud.user.get_by_email(db, email=user_in.email)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Create new user (first user becomes superuser)
user_create = schemas.UserCreate(
username=user_in.username,
email=user_in.email,
password=user_in.password,
is_active=True,
is_superuser=is_first_user # First user is superuser
)
user = crud.user.create(db, obj_in=user_create)
return user
@router.post("/login", response_model=schemas.Token)
def login(
*,
db: Session = Depends(get_db),
credentials: schemas.LoginRequest
) -> Any:
"""
Login and get access token.
Authenticates user and returns a JWT access token.
"""
# Authenticate user
user = crud.user.authenticate(
db,
username=credentials.username,
password=credentials.password
)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not crud.user.is_active(user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user"
)
# Update last_login timestamp
user.last_login = datetime.utcnow()
db.add(user)
db.commit()
# Create access token
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.id},
expires_delta=access_token_expires
)
return {
"access_token": access_token,
"token_type": "bearer"
}
@router.get("/me", response_model=schemas.User)
def read_users_me(
current_user: User = Depends(get_current_user)
) -> Any:
"""
Get current user.
Returns the currently authenticated user's information.
Requires a valid JWT token in the Authorization header.
"""
return current_user
@router.get("/registration-status")
def get_registration_status(
db: Session = Depends(get_db)
) -> Any:
"""
Check if user registration is enabled.
This is a public endpoint that doesn't require authentication.
Returns the registration_enabled setting value.
"""
registration_enabled = crud.settings.get_setting_value(
db,
key="registration_enabled",
default=True
)
return {"registration_enabled": registration_enabled}

View File

@@ -0,0 +1,34 @@
"""Health check endpoints."""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from sqlalchemy import text
from app.dependencies import get_db
from app.config import settings
router = APIRouter()
@router.get("")
async def health_check(db: Session = Depends(get_db)):
"""
Health check endpoint that verifies database connectivity.
Returns:
Dictionary with health status and database connectivity
"""
try:
# Test database connection
db.execute(text("SELECT 1"))
db_status = "connected"
except Exception as e:
db_status = f"error: {str(e)}"
return {
"status": "healthy" if db_status == "connected" else "unhealthy",
"app": settings.APP_NAME,
"version": settings.APP_VERSION,
"database": db_status
}

View File

@@ -0,0 +1,15 @@
"""Main API v1 router that aggregates all sub-routers."""
from fastapi import APIRouter
from app.api.v1 import health, auth, users, settings
# Create main API v1 router
router = APIRouter()
# Include all sub-routers
router.include_router(health.router, prefix="/health", tags=["Health"])
router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
router.include_router(users.router, prefix="/users", tags=["Users"])
router.include_router(settings.router, prefix="/settings", tags=["Settings"])

View File

@@ -0,0 +1,184 @@
"""Settings endpoints."""
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app import crud, schemas
from app.dependencies import get_db, get_current_superuser, get_current_user
from app.models.user import User
from app.core.settings_registry import (
THEME_KEYS,
MODULE_KEYS,
SETTINGS_REGISTRY,
get_default_value,
)
router = APIRouter()
@router.get("/theme", response_model=dict[str, Any])
def get_theme_settings(
*,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
) -> Any:
"""
Get theme settings (accessible to all authenticated users).
Returns theme-related settings that apply to all users.
"""
result = {}
for key in THEME_KEYS:
setting = crud.settings.get_setting(db, key=key)
if setting:
result[key] = setting.get_value()
else:
# Use default from registry
result[key] = get_default_value(key)
return result
@router.put("/theme", response_model=dict[str, Any])
def update_theme_settings(
*,
db: Session = Depends(get_db),
theme_data: dict[str, Any],
current_user: User = Depends(get_current_superuser)
) -> Any:
"""
Update theme settings (admin only).
Updates multiple theme settings at once.
"""
result = {}
for key, value in theme_data.items():
if key in THEME_KEYS or key.startswith("theme_"):
setting = crud.settings.update_setting(db, key=key, value=value)
result[key] = setting.get_value()
return result
@router.get("/modules", response_model=dict[str, Any])
def get_module_settings(
*,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
) -> Any:
"""
Get module settings (accessible to all authenticated users).
Returns module enabled/disabled states.
"""
result = {}
for key in MODULE_KEYS:
setting = crud.settings.get_setting(db, key=key)
if setting:
result[key] = setting.get_value()
else:
# Use default from registry
result[key] = get_default_value(key)
return result
@router.put("/modules", response_model=dict[str, Any])
def update_module_settings(
*,
db: Session = Depends(get_db),
module_data: dict[str, Any],
current_user: User = Depends(get_current_superuser)
) -> Any:
"""
Update module settings (admin only).
Enable or disable modules for all users.
"""
result = {}
for key, value in module_data.items():
if key in MODULE_KEYS or key.startswith("module_"):
setting = crud.settings.update_setting(db, key=key, value=value)
result[key] = setting.get_value()
return result
@router.get("/user_mode_enabled", response_model=dict[str, Any])
def get_user_mode_enabled(
*,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
) -> Any:
"""
Get user mode enabled setting (accessible to all authenticated users).
"""
setting = crud.settings.get_setting(db, key="user_mode_enabled")
if setting:
return {"key": "user_mode_enabled", "value": setting.get_value()}
# Use default from registry
return {"key": "user_mode_enabled", "value": get_default_value("user_mode_enabled")}
@router.put("/user_mode_enabled", response_model=dict[str, Any])
def update_user_mode_enabled(
*,
db: Session = Depends(get_db),
data: dict[str, Any],
current_user: User = Depends(get_current_superuser)
) -> Any:
"""
Update user mode enabled setting (admin only).
"""
value = data.get("value", True)
setting = crud.settings.update_setting(db, key="user_mode_enabled", value=value)
return {"key": "user_mode_enabled", "value": setting.get_value()}
@router.get("", response_model=dict[str, Any])
def get_all_settings(
*,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_superuser)
) -> Any:
"""
Get all settings (admin only).
Returns all application settings as a dictionary.
"""
settings_list = crud.settings.get_all_settings(db)
return {s.key: s.get_value() for s in settings_list}
@router.get("/{key}", response_model=schemas.Setting)
def get_setting(
*,
db: Session = Depends(get_db),
key: str,
current_user: User = Depends(get_current_superuser)
) -> Any:
"""
Get a specific setting by key (admin only).
"""
setting = crud.settings.get_setting(db, key=key)
if not setting:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Setting '{key}' not found"
)
return schemas.Setting.from_orm(setting)
@router.put("/{key}", response_model=schemas.Setting)
def update_setting(
*,
db: Session = Depends(get_db),
key: str,
setting_in: schemas.SettingUpdate,
current_user: User = Depends(get_current_superuser)
) -> Any:
"""
Update a setting (admin only).
Creates the setting if it doesn't exist.
"""
setting = crud.settings.update_setting(db, key=key, value=setting_in.value)
return schemas.Setting.from_orm(setting)

160
backend/app/api/v1/users.py Normal file
View File

@@ -0,0 +1,160 @@
"""User management endpoints (admin only)."""
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app import crud, schemas
from app.dependencies import get_db, get_current_user, get_current_active_superuser
from app.models.user import User
router = APIRouter()
@router.get("", response_model=List[schemas.User])
def list_users(
*,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_superuser),
skip: int = 0,
limit: int = 100
) -> Any:
"""
List all users (admin only).
Requires superuser privileges.
"""
users = crud.user.get_multi(db, skip=skip, limit=limit)
return users
@router.get("/{user_id}", response_model=schemas.User)
def get_user(
*,
db: Session = Depends(get_db),
user_id: str,
current_user: User = Depends(get_current_user)
) -> Any:
"""
Get user by ID.
Users can view their own profile, admins can view any profile.
"""
user = crud.user.get(db, id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Check if user is viewing their own profile or is superuser
if user.id != current_user.id and not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return user
@router.put("/{user_id}", response_model=schemas.User)
def update_user(
*,
db: Session = Depends(get_db),
user_id: str,
user_in: schemas.UserUpdate,
current_user: User = Depends(get_current_user)
) -> Any:
"""
Update user.
Users can update their own profile, admins can update any profile.
Only admins can change is_superuser and is_active flags.
"""
user = crud.user.get(db, id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Check permissions
if user.id != current_user.id and not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
# Only superusers can change is_superuser and is_active
if not current_user.is_superuser:
if user_in.is_superuser is not None or user_in.is_active is not None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can change user status"
)
user = crud.user.update(db, db_obj=user, obj_in=user_in)
return user
@router.delete("/{user_id}", response_model=schemas.User)
def delete_user(
*,
db: Session = Depends(get_db),
user_id: str,
current_user: User = Depends(get_current_active_superuser)
) -> Any:
"""
Delete user (admin only).
Requires superuser privileges.
Users cannot delete themselves.
"""
if user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Users cannot delete themselves"
)
user = crud.user.get(db, id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
user = crud.user.remove(db, id=user_id)
return user
@router.post("", response_model=schemas.User, status_code=status.HTTP_201_CREATED)
def create_user(
*,
db: Session = Depends(get_db),
user_in: schemas.UserCreate,
current_user: User = Depends(get_current_active_superuser)
) -> Any:
"""
Create new user (admin only).
Requires superuser privileges.
"""
# Check if username already exists
user = crud.user.get_by_username(db, username=user_in.username)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered"
)
# Check if email already exists
user = crud.user.get_by_email(db, email=user_in.email)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
user = crud.user.create(db, obj_in=user_in)
return user

48
backend/app/config.py Normal file
View File

@@ -0,0 +1,48 @@
"""Application configuration using Pydantic Settings."""
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# App Info
APP_NAME: str = "Service App"
APP_VERSION: str = "1.0.0"
API_V1_PREFIX: str = "/api/v1"
# Database
DATABASE_URL: str = "sqlite:////config/config.db"
# Security
SECRET_KEY: str
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 # 24 hours
# CORS
ALLOWED_HOSTS: list[str] = []
CORS_ORIGINS: list[str] = [
"http://localhost:3000",
"http://localhost:5173", # Vite dev server
"http://127.0.0.1:3000",
"http://127.0.0.1:5173",
]
@property
def all_cors_origins(self) -> list[str]:
"""Combine default CORS origins with allowed hosts."""
return self.CORS_ORIGINS + self.ALLOWED_HOSTS
# Logging
LOG_LEVEL: str = "info"
DEBUG: bool = False
class Config:
env_file = ".env"
case_sensitive = True
extra = "ignore" # Ignore extra fields from .env (like VITE_* for frontend)
# Global settings instance
settings = Settings()

View File

View File

@@ -0,0 +1,21 @@
"""Custom exceptions for the application."""
class AppException(Exception):
"""Base exception for the application."""
pass
class DatabaseException(AppException):
"""Exception for database errors."""
pass
class AuthenticationException(AppException):
"""Exception for authentication errors."""
pass
class AuthorizationException(AppException):
"""Exception for authorization errors."""
pass

View File

@@ -0,0 +1,94 @@
"""Security utilities for authentication and authorization."""
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.config import settings
# Password hashing context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verify a plain password against a hashed password.
Args:
plain_password: The plain text password
hashed_password: The hashed password to compare against
Returns:
True if password matches, False otherwise
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""
Hash a password using bcrypt.
Args:
password: The plain text password to hash
Returns:
The hashed password
"""
return pwd_context.hash(password)
def create_access_token(
data: Dict[str, Any],
expires_delta: Optional[timedelta] = None
) -> str:
"""
Create a JWT access token.
Args:
data: Dictionary containing claims to encode in the token
expires_delta: Optional expiration time delta
Returns:
Encoded JWT token string
"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(
to_encode,
settings.SECRET_KEY,
algorithm=settings.ALGORITHM
)
return encoded_jwt
def decode_access_token(token: str) -> Dict[str, Any]:
"""
Decode and validate a JWT access token.
Args:
token: The JWT token string to decode
Returns:
Dictionary containing the decoded token payload
Raises:
JWTError: If token is invalid or expired
"""
payload = jwt.decode(
token,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM]
)
return payload

View File

@@ -0,0 +1,601 @@
"""
Settings Registry - Central definition of all application settings.
This file defines ALL configurable settings in the application with:
- Storage location (database in /config, localStorage, env)
- Scope (global or user-specific)
- Default values
- Data types
- Descriptions
IMPORTANT: Any new setting MUST be added here first.
"""
from enum import Enum
from dataclasses import dataclass, field
from typing import Any, Optional
class SettingScope(Enum):
"""Defines who the setting applies to."""
GLOBAL = "global" # Same for all users, admin-controlled
USER_SPECIFIC = "user" # Each user can have different value
SYSTEM = "system" # Infrastructure/env settings, not in DB
class SettingStorage(Enum):
"""Defines where the setting is stored."""
DATABASE = "database" # Stored in /config/config.db (settings table)
LOCAL_STORAGE = "localStorage" # Browser localStorage (frontend only)
ENV = "env" # Environment variable (.env file)
MEMORY = "memory" # Runtime only, not persisted
class SettingType(Enum):
"""Data type of the setting value."""
BOOLEAN = "boolean"
STRING = "string"
INTEGER = "integer"
JSON = "json"
LIST = "list"
@dataclass
class SettingDefinition:
"""Definition of a single setting."""
key: str
type: SettingType
scope: SettingScope
storage: SettingStorage
default: Any
description: str
category: str
admin_only: bool = True # Only admins can modify (for DB settings)
sync_to_frontend: bool = True # Should be synced to frontend
choices: Optional[list] = None # Valid values if restricted
# =============================================================================
# SETTINGS REGISTRY
# =============================================================================
SETTINGS_REGISTRY: dict[str, SettingDefinition] = {}
def register_setting(setting: SettingDefinition) -> SettingDefinition:
"""Register a setting in the registry."""
SETTINGS_REGISTRY[setting.key] = setting
return setting
# =============================================================================
# THEME SETTINGS (Global, Database)
# =============================================================================
register_setting(SettingDefinition(
key="theme_accent_color",
type=SettingType.STRING,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default="auto",
description="Accent color for UI elements",
category="theme",
choices=["auto", "blue", "purple", "green", "orange", "pink", "red", "teal", "amber", "indigo", "cyan", "rose"]
))
register_setting(SettingDefinition(
key="theme_border_radius",
type=SettingType.STRING,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default="large",
description="Border radius style for UI components",
category="theme",
choices=["small", "medium", "large"]
))
register_setting(SettingDefinition(
key="theme_sidebar_style",
type=SettingType.STRING,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default="default",
description="Visual style of the sidebar",
category="theme",
choices=["default", "dark", "light"]
))
register_setting(SettingDefinition(
key="theme_density",
type=SettingType.STRING,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default="compact",
description="UI density/spacing",
category="theme",
choices=["compact", "comfortable", "spacious"]
))
register_setting(SettingDefinition(
key="theme_font_family",
type=SettingType.STRING,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default="sans",
description="Font family for the application",
category="theme",
choices=["sans", "inter", "roboto"]
))
register_setting(SettingDefinition(
key="theme_color_palette",
type=SettingType.STRING,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default="monochrome",
description="Color palette preset",
category="theme",
choices=["default", "monochrome", "monochromeBlue", "sepia", "nord", "dracula", "solarized", "github", "ocean", "forest", "midnight", "sunset"]
))
register_setting(SettingDefinition(
key="theme_custom_colors",
type=SettingType.JSON,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default="{}",
description="Custom color overrides as JSON",
category="theme"
))
register_setting(SettingDefinition(
key="theme_dark_mode_location",
type=SettingType.STRING,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default="sidebar",
description="Where to show dark mode toggle",
category="theme",
choices=["sidebar", "user_menu"]
))
register_setting(SettingDefinition(
key="theme_language_location",
type=SettingType.STRING,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default="sidebar",
description="Where to show language toggle",
category="theme",
choices=["sidebar", "user_menu"]
))
register_setting(SettingDefinition(
key="theme_show_dark_mode_toggle",
type=SettingType.BOOLEAN,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default=True,
description="Show/hide dark mode toggle globally",
category="theme"
))
register_setting(SettingDefinition(
key="theme_show_language_toggle",
type=SettingType.BOOLEAN,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default=False,
description="Show/hide language toggle globally",
category="theme"
))
register_setting(SettingDefinition(
key="theme_show_dark_mode_login",
type=SettingType.BOOLEAN,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default=True,
description="Show dark mode toggle on login page",
category="theme"
))
register_setting(SettingDefinition(
key="theme_show_language_login",
type=SettingType.BOOLEAN,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default=False,
description="Show language toggle on login page",
category="theme"
))
# =============================================================================
# MODULE/FEATURE SETTINGS (Global, Database)
# =============================================================================
register_setting(SettingDefinition(
key="module_feature1_admin_enabled",
type=SettingType.BOOLEAN,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default=True,
description="Enable Feature 1 module for admin users",
category="modules"
))
register_setting(SettingDefinition(
key="module_feature1_user_enabled",
type=SettingType.BOOLEAN,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default=True,
description="Enable Feature 1 module for regular users",
category="modules"
))
register_setting(SettingDefinition(
key="module_feature2_admin_enabled",
type=SettingType.BOOLEAN,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default=True,
description="Enable Feature 2 module for admin users",
category="modules"
))
register_setting(SettingDefinition(
key="module_feature2_user_enabled",
type=SettingType.BOOLEAN,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default=True,
description="Enable Feature 2 module for regular users",
category="modules"
))
register_setting(SettingDefinition(
key="module_feature3_admin_enabled",
type=SettingType.BOOLEAN,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default=True,
description="Enable Feature 3 module for admin users",
category="modules"
))
register_setting(SettingDefinition(
key="module_feature3_user_enabled",
type=SettingType.BOOLEAN,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default=True,
description="Enable Feature 3 module for regular users",
category="modules"
))
# =============================================================================
# AUTHENTICATION & SECURITY SETTINGS (Global, Database)
# =============================================================================
register_setting(SettingDefinition(
key="registration_enabled",
type=SettingType.BOOLEAN,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default=True,
description="Allow new user registration",
category="auth"
))
register_setting(SettingDefinition(
key="user_mode_enabled",
type=SettingType.BOOLEAN,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default=False,
description="Enable admin/user view mode switching",
category="auth"
))
# =============================================================================
# UI/LAYOUT SETTINGS (Global, Database)
# =============================================================================
register_setting(SettingDefinition(
key="sidebar_mode",
type=SettingType.STRING,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default="toggle",
description="Sidebar behavior mode",
category="ui",
choices=["collapsed", "expanded", "toggle", "dynamic"]
))
register_setting(SettingDefinition(
key="show_logo",
type=SettingType.BOOLEAN,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default=True,
description="Show logo in sidebar instead of text",
category="ui"
))
# =============================================================================
# USER-SPECIFIC SETTINGS (Per-user, Database)
# These settings can be different for each user
# =============================================================================
register_setting(SettingDefinition(
key="user_theme_mode",
type=SettingType.STRING,
scope=SettingScope.USER_SPECIFIC,
storage=SettingStorage.DATABASE,
default="system",
description="User's preferred theme mode (light/dark/system)",
category="user_preferences",
admin_only=False,
choices=["light", "dark", "system"]
))
register_setting(SettingDefinition(
key="user_language",
type=SettingType.STRING,
scope=SettingScope.USER_SPECIFIC,
storage=SettingStorage.DATABASE,
default="en",
description="User's preferred language",
category="user_preferences",
admin_only=False,
choices=["en", "it"]
))
register_setting(SettingDefinition(
key="user_sidebar_collapsed",
type=SettingType.BOOLEAN,
scope=SettingScope.USER_SPECIFIC,
storage=SettingStorage.DATABASE,
default=False,
description="User's sidebar collapsed state preference",
category="user_preferences",
admin_only=False
))
register_setting(SettingDefinition(
key="user_view_mode",
type=SettingType.STRING,
scope=SettingScope.USER_SPECIFIC,
storage=SettingStorage.DATABASE,
default="admin",
description="User's current view mode (admin/user)",
category="user_preferences",
admin_only=False,
choices=["admin", "user"]
))
# =============================================================================
# SYSTEM/ENVIRONMENT SETTINGS (Infrastructure, .env file)
# These are NOT stored in the database
# =============================================================================
register_setting(SettingDefinition(
key="SECRET_KEY",
type=SettingType.STRING,
scope=SettingScope.SYSTEM,
storage=SettingStorage.ENV,
default=None, # Required, no default
description="Secret key for JWT token signing",
category="security",
sync_to_frontend=False
))
register_setting(SettingDefinition(
key="DATABASE_URL",
type=SettingType.STRING,
scope=SettingScope.SYSTEM,
storage=SettingStorage.ENV,
default="sqlite:////config/config.db",
description="Database connection URL",
category="infrastructure",
sync_to_frontend=False
))
register_setting(SettingDefinition(
key="ALGORITHM",
type=SettingType.STRING,
scope=SettingScope.SYSTEM,
storage=SettingStorage.ENV,
default="HS256",
description="JWT signing algorithm",
category="security",
sync_to_frontend=False
))
register_setting(SettingDefinition(
key="ACCESS_TOKEN_EXPIRE_MINUTES",
type=SettingType.INTEGER,
scope=SettingScope.SYSTEM,
storage=SettingStorage.ENV,
default=1440,
description="JWT token expiration time in minutes",
category="security",
sync_to_frontend=False
))
register_setting(SettingDefinition(
key="ALLOWED_HOSTS",
type=SettingType.LIST,
scope=SettingScope.SYSTEM,
storage=SettingStorage.ENV,
default=[],
description="Additional allowed CORS origins",
category="security",
sync_to_frontend=False
))
register_setting(SettingDefinition(
key="LOG_LEVEL",
type=SettingType.STRING,
scope=SettingScope.SYSTEM,
storage=SettingStorage.ENV,
default="info",
description="Application logging level",
category="infrastructure",
choices=["debug", "info", "warning", "error", "critical"],
sync_to_frontend=False
))
register_setting(SettingDefinition(
key="DEBUG",
type=SettingType.BOOLEAN,
scope=SettingScope.SYSTEM,
storage=SettingStorage.ENV,
default=False,
description="Enable debug mode",
category="infrastructure",
sync_to_frontend=False
))
# =============================================================================
# FRONTEND-ONLY SETTINGS (localStorage, not in database)
# These are managed entirely by the frontend
# =============================================================================
register_setting(SettingDefinition(
key="token",
type=SettingType.STRING,
scope=SettingScope.USER_SPECIFIC,
storage=SettingStorage.LOCAL_STORAGE,
default=None,
description="JWT authentication token",
category="auth",
sync_to_frontend=False # Managed by frontend only
))
# =============================================================================
# HELPER FUNCTIONS
# =============================================================================
def get_settings_by_category(category: str) -> list[SettingDefinition]:
"""Get all settings in a specific category."""
return [s for s in SETTINGS_REGISTRY.values() if s.category == category]
def get_settings_by_scope(scope: SettingScope) -> list[SettingDefinition]:
"""Get all settings with a specific scope."""
return [s for s in SETTINGS_REGISTRY.values() if s.scope == scope]
def get_settings_by_storage(storage: SettingStorage) -> list[SettingDefinition]:
"""Get all settings stored in a specific location."""
return [s for s in SETTINGS_REGISTRY.values() if s.storage == storage]
def get_database_settings() -> list[SettingDefinition]:
"""Get all settings that should be stored in the database."""
return get_settings_by_storage(SettingStorage.DATABASE)
def get_global_settings() -> list[SettingDefinition]:
"""Get all global (non user-specific) settings."""
return get_settings_by_scope(SettingScope.GLOBAL)
def get_user_specific_settings() -> list[SettingDefinition]:
"""Get all user-specific settings."""
return get_settings_by_scope(SettingScope.USER_SPECIFIC)
def get_default_value(key: str) -> Any:
"""Get the default value for a setting."""
if key in SETTINGS_REGISTRY:
return SETTINGS_REGISTRY[key].default
return None
def get_all_defaults() -> dict[str, Any]:
"""Get all default values as a dictionary."""
return {key: setting.default for key, setting in SETTINGS_REGISTRY.items()}
def get_database_defaults() -> dict[str, Any]:
"""Get default values for all database-stored settings."""
return {
setting.key: setting.default
for setting in get_database_settings()
}
def validate_setting_value(key: str, value: Any) -> bool:
"""Validate a setting value against its definition."""
if key not in SETTINGS_REGISTRY:
return False
setting = SETTINGS_REGISTRY[key]
# Check type
if setting.type == SettingType.BOOLEAN:
if not isinstance(value, bool) and value not in ['true', 'false', 'True', 'False']:
return False
elif setting.type == SettingType.INTEGER:
if not isinstance(value, int):
try:
int(value)
except (ValueError, TypeError):
return False
elif setting.type == SettingType.STRING:
if not isinstance(value, str):
return False
# Check choices if defined
if setting.choices and value not in setting.choices:
# For booleans converted to strings
if setting.type == SettingType.BOOLEAN:
return True
return False
return True
# =============================================================================
# CATEGORY KEYS (for API endpoints)
# =============================================================================
THEME_KEYS = [s.key for s in get_settings_by_category("theme")]
MODULE_KEYS = [s.key for s in get_settings_by_category("modules")]
AUTH_KEYS = [s.key for s in get_settings_by_category("auth")]
UI_KEYS = [s.key for s in get_settings_by_category("ui")]
USER_PREFERENCE_KEYS = [s.key for s in get_settings_by_category("user_preferences")]
# =============================================================================
# PRINT SUMMARY (for debugging)
# =============================================================================
if __name__ == "__main__":
print("\n=== SETTINGS REGISTRY SUMMARY ===\n")
print(f"Total settings: {len(SETTINGS_REGISTRY)}")
print(f" - Database settings: {len(get_database_settings())}")
print(f" - Global settings: {len(get_global_settings())}")
print(f" - User-specific settings: {len(get_user_specific_settings())}")
print("\n--- By Category ---")
categories = set(s.category for s in SETTINGS_REGISTRY.values())
for cat in sorted(categories):
settings = get_settings_by_category(cat)
print(f" {cat}: {len(settings)} settings")
print("\n--- Database Settings (stored in /config/config.db) ---")
for setting in get_database_settings():
scope_label = "GLOBAL" if setting.scope == SettingScope.GLOBAL else "USER"
print(f" [{scope_label}] {setting.key}: {setting.type.value} = {setting.default}")

View File

@@ -0,0 +1,6 @@
"""CRUD operations package."""
from app.crud.user import user
from app.crud import settings
__all__ = ["user", "settings"]

81
backend/app/crud/base.py Normal file
View File

@@ -0,0 +1,81 @@
"""Base CRUD operations for database models."""
from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.db.base import Base
ModelType = TypeVar("ModelType", bound=Base)
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
"""Base class for CRUD operations."""
def __init__(self, model: Type[ModelType]):
"""
CRUD object with default methods to Create, Read, Update, Delete (CRUD).
**Parameters**
* `model`: A SQLAlchemy model class
* `schema`: A Pydantic model (schema) class
"""
self.model = model
def get(self, db: Session, id: Any) -> Optional[ModelType]:
"""Get a single record by ID."""
return db.query(self.model).filter(self.model.id == id).first()
def get_multi(
self, db: Session, *, skip: int = 0, limit: int = 100
) -> List[ModelType]:
"""Get multiple records with pagination."""
return db.query(self.model).offset(skip).limit(limit).all()
def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType:
"""Create a new record."""
obj_in_data = jsonable_encoder(obj_in)
db_obj = self.model(**obj_in_data)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
self,
db: Session,
*,
db_obj: ModelType,
obj_in: Union[UpdateSchemaType, Dict[str, Any]]
) -> ModelType:
"""Update a record."""
obj_data = jsonable_encoder(db_obj)
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.model_dump(exclude_unset=True)
for field in obj_data:
if field in update_data:
setattr(db_obj, field, update_data[field])
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def delete(self, db: Session, *, id: Any) -> ModelType:
"""Delete a record."""
obj = db.query(self.model).get(id)
db.delete(obj)
db.commit()
return obj
def remove(self, db: Session, *, id: Any) -> ModelType:
"""Remove a record (alias for delete)."""
return self.delete(db, id=id)

View File

@@ -0,0 +1,48 @@
"""CRUD operations for Settings."""
from typing import Optional
from sqlalchemy.orm import Session
from app.models.settings import Settings
def get_setting(db: Session, key: str) -> Optional[Settings]:
"""Get a setting by key."""
return db.query(Settings).filter(Settings.key == key).first()
def get_setting_value(db: Session, key: str, default: any = None) -> any:
"""Get a setting value by key, with optional default."""
setting = get_setting(db, key)
if setting:
return setting.get_value()
return default
def update_setting(db: Session, key: str, value: any) -> Settings:
"""Update or create a setting."""
setting = get_setting(db, key)
if setting is None:
setting = Settings(key=key)
db.add(setting)
# Determine if value is bool or string
if isinstance(value, bool):
setting.value_bool = value
setting.value_str = None
elif isinstance(value, str) and value.lower() in ('true', 'false'):
setting.value_bool = value.lower() == 'true'
setting.value_str = None
else:
setting.value_str = str(value)
setting.value_bool = None
db.commit()
db.refresh(setting)
return setting
def get_all_settings(db: Session) -> list[Settings]:
"""Get all settings."""
return db.query(Settings).all()

78
backend/app/crud/user.py Normal file
View File

@@ -0,0 +1,78 @@
"""CRUD operations for User model."""
from typing import Optional
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate
from app.core.security import get_password_hash, verify_password
class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]):
"""CRUD operations for User model."""
def get_by_email(self, db: Session, *, email: str) -> Optional[User]:
"""Get user by email."""
return db.query(User).filter(User.email == email).first()
def get_by_username(self, db: Session, *, username: str) -> Optional[User]:
"""Get user by username."""
return db.query(User).filter(User.username == username).first()
def create(self, db: Session, *, obj_in: UserCreate) -> User:
"""Create a new user with hashed password."""
db_obj = User(
username=obj_in.username,
email=obj_in.email,
hashed_password=get_password_hash(obj_in.password),
is_active=obj_in.is_active,
is_superuser=obj_in.is_superuser,
)
# Set permissions if provided
if obj_in.permissions is not None:
db_obj.permissions = obj_in.permissions
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
self, db: Session, *, db_obj: User, obj_in: UserUpdate
) -> User:
"""Update user, hashing password if provided."""
update_data = obj_in.model_dump(exclude_unset=True)
if "password" in update_data and update_data["password"]:
hashed_password = get_password_hash(update_data["password"])
del update_data["password"]
update_data["hashed_password"] = hashed_password
# Handle permissions separately since it uses a property setter
if "permissions" in update_data:
db_obj.permissions = update_data.pop("permissions")
return super().update(db, db_obj=db_obj, obj_in=update_data)
def authenticate(
self, db: Session, *, username: str, password: str
) -> Optional[User]:
"""Authenticate a user by username and password."""
user = self.get_by_username(db, username=username)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def is_active(self, user: User) -> bool:
"""Check if user is active."""
return user.is_active
def is_superuser(self, user: User) -> bool:
"""Check if user is superuser."""
return user.is_superuser
# Create instance
user = CRUDUser(User)

View File

8
backend/app/db/base.py Normal file
View File

@@ -0,0 +1,8 @@
"""Base model class for all database models."""
from sqlalchemy.ext.declarative import declarative_base
# Base class for all SQLAlchemy models
Base = declarative_base()
# Note: Model imports have been moved to alembic/env.py to avoid circular imports

26
backend/app/db/session.py Normal file
View File

@@ -0,0 +1,26 @@
"""Database session management."""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.config import settings
# SQLite specific connection arguments
connect_args = {}
if settings.DATABASE_URL.startswith("sqlite"):
connect_args = {"check_same_thread": False}
# Create database engine
engine = create_engine(
settings.DATABASE_URL,
connect_args=connect_args,
pool_pre_ping=True, # Verify connections before using
echo=settings.DEBUG, # Log SQL queries in debug mode
)
# Create session factory
SessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=engine
)

View File

@@ -0,0 +1,82 @@
"""Shared dependencies for FastAPI dependency injection."""
from typing import Generator, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from jose import JWTError, jwt
from app.db.session import SessionLocal
from app.config import settings
from app.core.security import decode_access_token
from app import models, crud
# Database dependency
def get_db() -> Generator[Session, None, None]:
"""
Dependency that provides a database session.
Automatically closes the session after the request.
"""
db = SessionLocal()
try:
yield db
finally:
db.close()
# Security
security = HTTPBearer()
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> models.User:
"""
Dependency that validates JWT token and returns current user.
Raises HTTPException if token is invalid or user not found.
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_access_token(credentials.credentials)
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = crud.user.get(db, id=user_id)
if user is None:
raise credentials_exception
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user"
)
return user
def get_current_active_superuser(
current_user: models.User = Depends(get_current_user)
) -> models.User:
"""
Dependency that requires the current user to be a superuser.
"""
if not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return current_user
# Alias for backward compatibility
get_current_superuser = get_current_active_superuser

195
backend/app/main.py Normal file
View File

@@ -0,0 +1,195 @@
"""
App Service - FastAPI Backend
Main application entry point.
"""
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, FileResponse
from fastapi.staticfiles import StaticFiles
import logging
import time
from app.config import settings
from app.api.v1 import router as api_v1_router
# from app.api.websocket import router as websocket_router # TODO: Create later
from app.db.session import engine
from app.db.base import Base
# Static files path
STATIC_DIR = Path(__file__).parent.parent / "static"
# Configure logging
logging.basicConfig(
level=getattr(logging, settings.LOG_LEVEL.upper()),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Create FastAPI application
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
description="Modern web application for service management",
docs_url="/docs",
redoc_url="/redoc",
openapi_url=f"{settings.API_V1_PREFIX}/openapi.json"
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.all_cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Request logging middleware
@app.middleware("http")
async def log_requests(request: Request, call_next):
"""Log all incoming requests with timing information."""
start_time = time.time()
# Process request
response = await call_next(request)
# Calculate duration
process_time = time.time() - start_time
# Log request
logger.info(
f"{request.method} {request.url.path} "
f"completed in {process_time:.3f}s "
f"with status {response.status_code}"
)
# Add timing header
response.headers["X-Process-Time"] = str(process_time)
return response
# Exception handlers
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
"""Handle uncaught exceptions."""
logger.error(f"Unhandled exception: {exc}", exc_info=True)
return JSONResponse(
status_code=500,
content={
"detail": "Internal server error" if not settings.DEBUG else str(exc)
}
)
# API routers
app.include_router(api_v1_router, prefix=settings.API_V1_PREFIX)
# app.include_router(websocket_router) # TODO: Add WebSocket router
# Health check endpoint
@app.get("/health", tags=["System"])
async def health_check():
"""Health check endpoint for monitoring."""
return {
"status": "healthy",
"app_name": settings.APP_NAME,
"version": settings.APP_VERSION
}
# Root endpoint
@app.get("/", tags=["System"])
async def root():
"""Root endpoint - serve frontend or API info."""
# If frontend exists, serve it
index_path = STATIC_DIR / "index.html"
if index_path.exists():
return FileResponse(index_path)
# Otherwise return API info
return {
"message": "App Service API",
"version": settings.APP_VERSION,
"docs": "/docs",
"api": settings.API_V1_PREFIX
}
# Startup event
@app.on_event("startup")
async def startup_event():
"""Run on application startup."""
logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION}")
logger.info(f"Debug mode: {settings.DEBUG}")
logger.info(f"Database: {settings.DATABASE_URL.split('@')[1] if '@' in settings.DATABASE_URL else 'configured'}")
# Create tables if they don't exist (for development)
# In production, use Alembic migrations
if settings.DEBUG:
logger.info("Creating database tables (debug mode)")
Base.metadata.create_all(bind=engine)
# Seed default settings from registry
from sqlalchemy.orm import Session
from app import crud
from app.core.settings_registry import get_database_defaults
with Session(engine) as db:
# Get all database settings with defaults from registry
default_settings = get_database_defaults()
for key, value in default_settings.items():
if crud.settings.get_setting(db, key) is None:
logger.info(f"Seeding default setting: {key}={value}")
crud.settings.update_setting(db, key, value)
# Shutdown event
@app.on_event("shutdown")
async def shutdown_event():
"""Run on application shutdown."""
logger.info(f"Shutting down {settings.APP_NAME}")
# Serve static files (frontend) - must be after API routes
if STATIC_DIR.exists():
# Mount static assets (JS, CSS, images)
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
# SPA catch-all: serve index.html for all non-API routes
@app.get("/{full_path:path}", include_in_schema=False)
async def serve_spa(request: Request, full_path: str):
"""Serve the SPA for all non-API routes."""
# Skip API routes
if full_path.startswith("api/") or full_path in ["docs", "redoc", "health", "openapi.json"]:
return JSONResponse(status_code=404, content={"detail": "Not found"})
# Check if file exists in static dir
file_path = STATIC_DIR / full_path
if file_path.exists() and file_path.is_file():
return FileResponse(file_path)
# Serve index.html for SPA routing
index_path = STATIC_DIR / "index.html"
if index_path.exists():
return FileResponse(index_path)
return JSONResponse(status_code=404, content={"detail": "Not found"})
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host="0.0.0.0",
port=8000,
reload=settings.DEBUG,
log_level=settings.LOG_LEVEL.lower()
)

View File

@@ -0,0 +1,6 @@
"""Models package."""
from app.models.user import User
from app.models.settings import Settings
__all__ = ["User", "Settings"]

View File

@@ -0,0 +1,25 @@
"""Settings model for application configuration."""
from sqlalchemy import Column, String, Boolean, Integer, DateTime
from sqlalchemy.sql import func
from app.db.base import Base
class Settings(Base):
"""Settings model for storing application-wide configuration."""
__tablename__ = "settings"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
key = Column(String, unique=True, index=True, nullable=False)
value_bool = Column(Boolean, nullable=True)
value_str = Column(String, nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
def get_value(self):
"""Get the actual value (bool or string)."""
if self.value_bool is not None:
return bool(self.value_bool)
return self.value_str

View File

@@ -0,0 +1,53 @@
"""User database model."""
import json
import uuid
from sqlalchemy import Column, String, Boolean, Text
from sqlalchemy.sql import func
from sqlalchemy.types import DateTime
from app.db.base import Base
class User(Base):
"""User model for authentication and authorization."""
__tablename__ = "users"
# Using String(36) for UUID to support SQLite
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
username = Column(String(100), unique=True, nullable=False, index=True)
email = Column(String(255), unique=True, nullable=False, index=True)
hashed_password = Column(String(255), nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
is_superuser = Column(Boolean, default=False, nullable=False)
# User permissions for modules (JSON stored as text for SQLite compatibility)
# Format: {"playlists": true, "downloads": false, "chromecast": true}
# null means inherit from global settings (all enabled by default)
_permissions = Column("permissions", Text, nullable=True)
created_at = Column(DateTime, server_default=func.now(), nullable=False)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
last_login = Column(DateTime, nullable=True)
@property
def permissions(self) -> dict:
"""Get permissions as a dictionary."""
if self._permissions:
try:
return json.loads(self._permissions)
except json.JSONDecodeError:
return {}
return {}
@permissions.setter
def permissions(self, value: dict):
"""Set permissions from a dictionary."""
if value is None:
self._permissions = None
else:
self._permissions = json.dumps(value)
def __repr__(self):
return f"<User(id={self.id}, username='{self.username}', email='{self.email}')>"

View File

@@ -0,0 +1,18 @@
"""Schemas package - exports all Pydantic schemas."""
from app.schemas.user import User, UserCreate, UserUpdate, UserInDB
from app.schemas.auth import Token, TokenData, LoginRequest, RegisterRequest
from app.schemas.settings import Setting, SettingUpdate
__all__ = [
"User",
"UserCreate",
"UserUpdate",
"UserInDB",
"Token",
"TokenData",
"LoginRequest",
"RegisterRequest",
"Setting",
"SettingUpdate",
]

View File

@@ -0,0 +1,41 @@
"""Pydantic schemas for authentication requests/responses."""
from typing import Optional
from pydantic import BaseModel, Field, EmailStr
class Token(BaseModel):
"""JWT token response schema."""
access_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
"""Token payload data schema."""
user_id: Optional[str] = None
class LoginRequest(BaseModel):
"""Login request schema."""
username: str = Field(..., min_length=3, max_length=100)
password: str = Field(..., min_length=1)
class RegisterRequest(BaseModel):
"""Registration request schema."""
username: str = Field(..., min_length=3, max_length=100)
email: EmailStr = Field(..., description="Valid email address")
password: str = Field(..., min_length=8, description="Password must be at least 8 characters")
class Config:
json_schema_extra = {
"example": {
"username": "johndoe",
"email": "john@example.com",
"password": "securepassword123"
}
}

View File

@@ -0,0 +1,40 @@
"""Common Pydantic schemas used across the application."""
from typing import Optional, Generic, TypeVar, List
from pydantic import BaseModel
DataT = TypeVar('DataT')
class PaginatedResponse(BaseModel, Generic[DataT]):
"""Generic paginated response schema."""
items: List[DataT]
total: int
page: int
limit: int
pages: int
class MessageResponse(BaseModel):
"""Simple message response schema."""
message: str
detail: Optional[str] = None
class ErrorResponse(BaseModel):
"""Error response schema."""
error: str
detail: Optional[str] = None
status_code: int
class TokenResponse(BaseModel):
"""JWT token response schema."""
access_token: str
token_type: str = "bearer"
expires_in: int # seconds

View File

@@ -0,0 +1,35 @@
"""Settings schemas."""
from pydantic import BaseModel
from typing import Optional, Any
from datetime import datetime
class SettingBase(BaseModel):
"""Base setting schema."""
key: str
value: Any
class SettingUpdate(BaseModel):
"""Schema for updating a setting."""
value: Any
class Setting(BaseModel):
"""Setting schema for responses."""
key: str
value: Any
updated_at: datetime
class Config:
from_attributes = True
@classmethod
def from_orm(cls, obj):
"""Custom from_orm to handle value extraction."""
return cls(
key=obj.key,
value=obj.get_value(),
updated_at=obj.updated_at
)

View File

@@ -0,0 +1,61 @@
"""Pydantic schemas for User API requests/responses."""
from datetime import datetime
from typing import Optional, Dict
from pydantic import BaseModel, EmailStr, Field
# Shared properties
class UserBase(BaseModel):
"""Base user schema with common fields."""
username: Optional[str] = Field(None, min_length=3, max_length=100)
email: Optional[EmailStr] = None
is_active: Optional[bool] = True
is_superuser: Optional[bool] = False
# Properties to receive via API on creation
class UserCreate(UserBase):
"""Schema for user creation."""
username: str = Field(..., min_length=3, max_length=100)
email: EmailStr
password: str = Field(..., min_length=8)
permissions: Optional[Dict[str, bool]] = None
# Properties to receive via API on update
class UserUpdate(UserBase):
"""Schema for user update."""
password: Optional[str] = Field(None, min_length=8)
permissions: Optional[Dict[str, bool]] = None
# Properties shared by models stored in DB
class UserInDBBase(UserBase):
"""Schema for user in database (base)."""
id: str # UUID stored as string for SQLite compatibility
permissions: Dict[str, bool] = Field(default_factory=dict)
created_at: datetime
updated_at: datetime
last_login: Optional[datetime] = None
class Config:
from_attributes = True # Pydantic v2 (was orm_mode in v1)
# Additional properties to return via API
class User(UserInDBBase):
"""Schema for user response."""
pass
# Additional properties stored in DB
class UserInDB(UserInDBBase):
"""Schema for user with hashed password."""
hashed_password: str

41
backend/requirements.txt Normal file
View File

@@ -0,0 +1,41 @@
# FastAPI and ASGI server
fastapi==0.109.0
uvicorn[standard]==0.27.0
python-multipart==0.0.6
# Database (SQLite is built-in with Python)
sqlalchemy==2.0.25
alembic==1.13.1
aiosqlite==0.19.0 # Async SQLite support
# Pydantic for validation
pydantic==2.5.3
pydantic-settings==2.1.0
email-validator==2.1.0 # For EmailStr validation
# Authentication
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.0.1 # Pin bcrypt version for passlib compatibility
python-multipart==0.0.6
# HTTP requests
requests==2.31.0
httpx==0.26.0
# WebSocket
python-socketio==5.11.0
python-engineio==4.9.0
# Utilities
python-dotenv==1.0.0
click==8.1.7
# CORS
fastapi-cors==0.0.6
# Testing
pytest==7.4.4
pytest-asyncio==0.23.3
pytest-cov==4.1.0
httpx==0.26.0 # For testing async clients

18
docker-compose.yml Normal file
View File

@@ -0,0 +1,18 @@
services:
app:
build: .
container_name: app-service
restart: unless-stopped
environment:
SECRET_KEY: ${SECRET_KEY}
ALLOWED_HOSTS: ${ALLOWED_HOSTS}
volumes:
- ./config:/config
ports:
- "5174:8000"
healthcheck:
test: [ "CMD-SHELL", "curl -f http://localhost:8000/health || exit 1" ]
interval: 30s
timeout: 10s
start_period: 40s
retries: 3

73
frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

18
frontend/index.html Normal file
View File

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo_white.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Web application" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
<title>Loading...</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

34
frontend/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "frontend-new",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@types/react-color": "^3.0.13",
"axios": "^1.13.2",
"react": "^19.2.0",
"react-color": "^2.19.3",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.6"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

View File

@@ -0,0 +1,827 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 335.522 335.522" xml:space="preserve">
<g>
<g>
<path d="M271.322,94.383c0.784,1.511-0.661,1.448,0.529,3.226c0.341-0.975,1.107,0.51,1.593,0.566
C273.394,97.521,272.473,94.859,271.322,94.383z M292.076,151.632c0.476,0.46,0.452,1.492,0.349,2.523
c-0.123,1.033-0.24,2.059,0.22,2.535c-0.686,0.83-0.862,0.076-0.833-1.137c0.005-0.606,0.099-1.332,0.144-2.033
C292.001,152.822,292.043,152.144,292.076,151.632z M282.062,112.107c0.687,0.043,1.367,0.92,1.984,2.113
c0.556,1.213,1.001,2.758,1.343,4.082c-0.538-0.971-1.07-2.002-1.597-3.057C283.279,114.185,282.629,113.156,282.062,112.107z
M55.527,78.972c0.536-0.879-0.267,0.119-0.261-0.267c1.979-2.498,3.697-4.29,4.726-4.89c-0.427,0.619-1.629,2.192-2.84,3.671
c-0.615,0.733-1.212,1.454-1.683,2.05c-0.484,0.588-0.883,1.014-1.105,1.145c-0.439,0.678-0.161,0.633-0.121,0.829
c-0.595,0.765-1.16,1.491-1.702,2.188c-0.538,0.703-0.994,1.418-1.473,2.101c-0.929,1.378-1.846,2.683-2.708,4.08
c0.021-1.049,1.434-3.063,2.786-5.017c0.662-0.988,1.403-1.901,1.96-2.675c0.555-0.776,0.94-1.405,0.986-1.766
C54.706,79.648,55.186,79.164,55.527,78.972z M48.456,89.969c0.27,0.071-1.125,2.169-1.554,3.087
c-0.476,0.109-0.414,0.301-1.173,0.815C46.947,91.675,47.513,91.383,48.456,89.969z M86.241,269.892
c0.873,0.096,3.525,2.402,5.153,3.299C89.977,272.927,88.279,271.347,86.241,269.892z M39.845,104.717
c-0.015,0.415-0.451,1.41-0.932,2.536c-0.442,1.144-0.958,2.406-1.252,3.304c-0.143-0.148-0.289-0.285-0.592,0.454
c-0.184-0.618,0.633-1.322,0.979-2.596c-0.082,0.939,0.136,0.337,0.474-0.666C38.831,106.732,39.423,105.376,39.845,104.717z
M125.96,289.738c1.907,0.057,2.918,0.899,5.277,1.236c0.506,0.492-2.171,0.047-0.255,0.693
C129.535,291.702,128.166,290.27,125.96,289.738z M43.513,95.634c0.005-0.801,1.383-2.253,2.31-4.279
c0.056,0.385-0.334,1.133-0.838,1.941c-0.252,0.403-0.534,0.822-0.803,1.221C43.925,94.921,43.698,95.312,43.513,95.634z
M47.138,88.001c0.363-0.575,0.403-0.355,0.563-0.431c0.669-1.046,0.653-1.338,1.374-2.447
C49.701,85.128,45.897,90.586,47.138,88.001z M36.735,110.046c-0.164,0.785-0.465,1.727-0.772,2.561
c-0.31,0.832-0.55,1.582-0.723,1.939c-0.162,0.361-0.213,0.342-0.031-0.326c0.099-0.332,0.229-0.838,0.48-1.521
C35.955,112.021,36.299,111.148,36.735,110.046z M31.969,125.284c0.084-1.317,1.236-4.095,1.316-5.419
C34.334,117.423,32.314,124.839,31.969,125.284z M30.612,131.376c0.137,0.143-0.022,0.892-0.199,1.658
c-0.155,0.772-0.364,1.559-0.436,1.754c-0.049-0.388-0.098-0.785-0.283-0.813C29.956,132.585,30.269,132.749,30.612,131.376z
M128.234,291.301c1.086,0.297,2.174,0.594,3.271,0.895c0.466,0.772-2.141-0.289-3.412-0.55L128.234,291.301z M92.013,41.607
l-0.138-0.306c0.603-0.428,1.001-0.672,1.347-0.864c0.359-0.18,0.663-0.315,1.078-0.542
C94.222,40.168,93.341,40.733,92.013,41.607z M98.382,279.613c-0.863-0.023-3.807-2.02-5.416-2.976
c0.395-0.015,1.469,0.548,2.596,1.183C96.706,278.427,97.877,279.156,98.382,279.613z M57.845,71.456
c-0.201,0.819-1.833,2.345-2.559,3.347c0.637-1.471,0.372-1.076-0.519-0.52c0.452-0.436,1.063-1.174,1.801-2.154
C56.085,73.806,56.031,73.265,57.845,71.456z M219.201,27.894l-2.026-0.719l0.189-0.274
C218.304,27.31,219.941,27.884,219.201,27.894z M109.08,31.505c-0.168-0.125,0.878-0.784,1.924-1.32
c1.071-0.482,2.132-0.847,1.937-0.5c-0.375,0.219-0.948,0.486-1.628,0.779C110.64,30.778,109.867,31.138,109.08,31.505z
M101.418,35.074c0.249-0.426,1.531-1.162,2.896-1.826c1.377-0.638,2.867-1.127,3.455-1.242c-1.315,0.729-2.141,1.091-3.02,1.475
C103.882,33.892,102.954,34.3,101.418,35.074z M122.276,291.417c-0.765-0.308-1.486-0.614-2.301-0.925
c-0.409-0.149-0.839-0.307-1.306-0.478c-0.463-0.183-0.965-0.371-1.525-0.566c0.442-0.075,1.563,0.289,2.687,0.641
C120.943,290.468,122.013,290.978,122.276,291.417z M137.192,21.792c-0.422-0.018-0.238-0.132,0.339-0.303
c0.582-0.143,1.562-0.329,2.723-0.54c1.16-0.21,2.5-0.451,3.81-0.688c1.311-0.217,2.6-0.364,3.634-0.55
c-0.686,0.194-1.583,0.37-2.568,0.532c-0.986,0.146-2.045,0.398-3.08,0.571C139.983,21.203,138.032,21.5,137.192,21.792z
M186.892,19.228c1.675,0.336,1.849,0.348,4.917,0.852C193.119,20.324,185.635,19.501,186.892,19.228z M118.152,27.359
c0.414-0.484,0.43-0.362-0.097-0.518c0.464-0.072,4.66-1.46,1.383-0.266C120.424,26.388,119.999,26.724,118.152,27.359z
M62.559,65.202c-0.655,0.677-1.251,1.399-1.863,2.128c0.488-1.016,0.823-1.322,0.071-1.014
C61.618,65.437,62.562,64.675,62.559,65.202z M81.552,47.16c-0.103,0.334-2.812,2.248-3.37,2.563
c-0.728,0.065,2.096-1.904,3.221-2.874C81.602,46.926,80.577,47.863,81.552,47.16z M124.055,24.312
c0.964-0.6,2.689-0.655,4.798-1.422C129.662,22.809,125.042,24.267,124.055,24.312z M123.361,24.428
c0.515-0.074-2.947,1.105-5.484,1.933c-1.277,0.393-2.273,0.832-2.468,0.865c-0.182,0.074,0.436-0.27,2.547-1.007
C118.066,26.182,123.307,24.446,123.361,24.428z M74.013,52.324c-1.45,1.412-1.465,1.084-2.728,2.259
c0.016-0.194,0.923-0.947,1.543-1.518c0.046-0.171-0.268,0.093-0.599,0.376C72.644,52.623,74.049,52.029,74.013,52.324z
M99.056,34.609c0.966-0.713,0.188-0.334-0.859,0.189c-1.033,0.549-2.3,1.299-2.402,1.164c0.521-0.367,1.198-0.766,1.979-1.197
c0.801-0.4,1.713-0.816,2.682-1.268c0.969-0.452,1.997-0.934,3.034-1.449c1.034-0.525,2.131-0.973,3.166-1.518
c-0.244,0.349-1.559,0.941-3.077,1.719C102.066,33.039,100.3,33.914,99.056,34.609z M70.085,55.345
c-0.466-0.088,0.74-1.217,0.09-1.176c1.348-1.099,2.827-2.357,2.94-1.977C69.236,55.232,73.202,52.471,70.085,55.345z
M114.968,25.584c2.308-1.28-0.521-0.013-0.146-0.728c1.622-0.708,1.51-0.396,3.435-1.152c-1.511,0.695-0.949,0.91,1.466,0.146
c0.726-0.123-0.151,0.182-1.368,0.572c-0.61,0.193-1.303,0.412-1.925,0.609C115.82,25.263,115.283,25.466,114.968,25.584z
M114.282,25.75c-0.523,0.473-3.465,1.389-4.058,1.519c-0.424-0.079,1.25-0.663,1.272-0.851
C113.609,25.514,111.859,26.834,114.282,25.75z M274.389,66.939c-0.262-0.524-0.541-1.056-1.186-1.799
c0.507-0.836,1.91,0.803,2.932,1.385c0.338,0.668-0.009,0.932,0.979,1.972C276.307,68.267,275.039,66.736,274.389,66.939z
M31.34,109.467c-0.082-0.558,1.502-4.2,1.829-4.71C33.374,105.613,32.101,107.447,31.34,109.467z M74.269,48.607
c-0.716,1.002-4.694,4.033-2.925,2.076C72.376,49.929,72.273,50.46,74.269,48.607z M36.213,97.381
c0.266-0.925-0.179-0.18-0.681,0.605c-0.465,0.804-0.989,1.647-0.999,0.854c0.177-0.387,0.359-0.781,0.541-1.178l0.624-1.155
c0.422-0.767,0.854-1.519,1.283-2.225c0.865-1.407,1.665-2.657,2.443-3.411c-0.494,0.986-0.933,1.863-1.435,2.865
C37.478,94.738,36.902,95.864,36.213,97.381z M116.832,23.865c-1.922,0.555-3.501,1.057-6.171,2.095
C112.081,25.176,116.576,23.541,116.832,23.865z M88.905,35.99c-0.558,0.775,0.044,0.271,0.254,0.625
C86.167,38.572,86.101,37.816,88.905,35.99z M73.739,46.251c0.738-1.026,2.112-1.408,2.482-1.52
c0.135-0.271,2.697-2.543,0.943-1.324c1.437-1.752,0.528,0.235,2.524-1.445l0.253,0.512c-1.359,0.885-2.267,1.571-2.946,2.044
c-0.679,0.474-1.116,0.751-1.445,0.937C74.888,45.822,74.652,45.814,73.739,46.251z M276.785,62.359
c-0.879-0.997-0.756-1.391-1.767-2.373c0.379,0.02,0.859,0.27,1.4,0.667c0.526,0.411,1.094,0.989,1.695,1.626
C276.843,61.617,278.055,63.019,276.785,62.359z M109.731,25.084c-0.642,0.529-2.854,1.246-4.032,1.791
C106.083,26.301,108.789,25.261,109.731,25.084z M56.751,62.561c-0.806,0.646-2.563,2.821-2.719,2.411
c1.994-2.377,2.798-2.992,4.576-4.767c-0.022,0.244-1.428,1.719-1.525,1.606C56.612,62.362,56.8,62.364,56.751,62.561z
M88.312,34.534c0.293-0.22,0.591-0.444,0.892-0.671c0.31-0.209,0.623-0.422,0.937-0.633c0.628-0.423,1.263-0.85,1.891-1.271
c1.143-0.331,3.2-1.473,5.507-2.566c2.284-1.141,4.683-2.49,6.433-3.367c0.278,0.139,1.223-0.236,2.484-0.789
c0.63-0.277,1.343-0.591,2.085-0.916c0.759-0.286,1.554-0.57,2.335-0.818C103.533,26.789,96.323,29.777,88.312,34.534z
M129.097,16.836c-1.329,0.659-3.188,0.673-2.91,0.292C128.846,16.306,127.461,17.214,129.097,16.836z M115.528,20.586
c-1.464,0.552-1.856,0.633-2.801,0.95c-0.264-0.154,1.981-0.901,2.024-1.128C115.721,20.117,115.382,20.47,115.528,20.586z
M109.922,22.526c-1.678,1.007-3.145,1.067-4.859,1.843c0.684-0.719,1.734-1.166,2.688-1.451
C108.716,22.664,109.593,22.588,109.922,22.526z M70.218,46.018c0.896-0.646,2.666-1.615,2.921-2.344
c1.413-0.935,0.438,0.039-0.767,1.046C71.144,45.7,69.789,46.848,70.218,46.018z M109.001,21.755
c-1.071,0.406-2.415,1.028-3.884,1.682c-1.474,0.643-3.066,1.333-4.58,1.992c-2.979,1.418-5.727,2.574-6.711,2.747
c2.975-1.115,7.465-3.842,10.318-4.444c0.734-0.355,0.41-0.414,1.328-0.826C107.615,21.897,107.344,22.361,109.001,21.755z
M276.591,47.648c1.526,0.747,3.022,1.811,4.699,3.125c0.835,0.664,1.703,1.401,2.61,2.227c0.918,0.815,1.778,1.809,2.729,2.859
c-0.754-0.585-1.532-1.232-2.328-1.922c-0.814-0.668-1.663-1.361-2.527-2.069c-0.849-0.712-1.714-1.438-2.575-2.159
c-0.429-0.358-0.859-0.717-1.285-1.07C277.47,48.304,277.027,47.973,276.591,47.648z M74.583,40.102
c0.952-0.912,2.735-2.14,4.366-3.261c0.821-0.553,1.617-1.059,2.287-1.441c0.671-0.376,1.238-0.589,1.552-0.635
c-1.081,0.626-2.454,1.567-3.924,2.51c-0.734,0.474-1.485,0.958-2.215,1.429c-0.365,0.234-0.725,0.469-1.078,0.693
C75.231,39.64,74.9,39.876,74.583,40.102z M87.101,31.517c-1.876,1.22-2.562,1.091-3.208,2.025
c-1.915,1.098,0.727-0.791-0.221-0.516C85.481,31.96,87.329,30.861,87.101,31.517z M82.321,31.777
c1.662-1.087,3.835-1.971,4.622-2.729c0.691-0.266,0.737-0.207,0.42,0.021c-0.322,0.221-0.999,0.625-1.735,1.088
C84.139,31.059,82.313,32.043,82.321,31.777z M71.531,39.158c-1.438,1.055-2.907,2.14-2.681,1.48
C69.839,40.05,71.647,38.619,71.531,39.158z M18.531,111.968l-0.778,2.201c-0.255,0.734-0.454,1.488-0.686,2.234
c-0.326,0.248-0.148-0.51,0.16-1.526c0.315-1.015,0.843-2.257,1.064-3.03C18.371,111.884,18.453,111.921,18.531,111.968z
M92.064,25.304c-2.649,1.785-3.874,2.098-6.764,3.697c1.862-1.451,3.766-2.043,6.018-3.529
C91.253,25.653,91.531,25.576,92.064,25.304z M46.515,62.027c-0.994,1.299-1.351,1.533-2.039,2.322
c0.306-0.549,0.34-0.857,0.163-0.969c1.152-1.491,1.186-1.17,2.586-2.879C47.079,60.935,46.664,61.593,46.515,62.027z
M62.717,45.283c-1.443,1.432-0.939,0.404-1.368,0.724c-2.706,2.179-4.451,3.928-5.651,5.137
c-1.184,1.229-1.926,1.831-2.782,1.632c-0.162-0.033-1.074,0.952-1.719,1.581c1.944-2.192,3.16-3.274,4.498-4.379
c1.382-1.065,2.865-2.176,5.333-4.466c0.452,0.069,1.272-0.201,3.879-2.531C65.321,43.046,62.749,44.94,62.717,45.283z
M22.127,97.974c-0.254,0.305-0.661,1.033-1.041,1.857c-0.368,0.828-0.754,1.733-1.069,2.34c-0.669,1.726,0.362,0.744-0.742,3.174
c0.296-2.047-0.507,0.666-1.328,1.937c0.02-0.919,0.834-2.712,1.769-4.859c0.483-1.068,0.975-2.235,1.519-3.388
c0.559-1.143,1.102-2.302,1.565-3.408c0.38-0.556,0.504-0.233,1.002-1.213c0.246,0.826-0.943,3.625,0.644,1.357
c-0.377,0.571-0.867,1.525-1.436,2.76c-0.352-0.66-1.361,1.034-2.948,4.561C19.951,102.642,21.444,99.46,22.127,97.974z
M15.065,118.892c-0.756,2.219-0.048-0.837-0.494-0.219c0.521-1.263,0.928-2.927,1.331-4.477c0.458-1.531,0.937-2.938,1.461-3.699
c-0.013,0.95-0.567,2.48-1.1,4.068C15.765,116.166,15.221,117.804,15.065,118.892z M29.142,84.861
c-0.667,0.923-1.2,1.971-1.815,3.206c-0.622,1.234-1.331,2.656-2.291,4.375l-0.528-0.37c0.95-1.755,1.832-3.301,2.605-4.539
c0.389-0.62,0.747-1.164,1.079-1.617C28.547,85.472,28.863,85.117,29.142,84.861z M92.697,304.984
c-3.583-2.477-7.605-3.73-9.709-5.801c1.745,0.935,4.016,2.006,5.913,3.045C90.825,303.223,92.409,304.142,92.697,304.984z
M8.368,146.324c0.01,2.117-0.144,4.059-0.361,6.186c-0.126,2.129-0.286,4.443-0.381,7.277c-0.105-1.484-0.02-3.798,0.103-6.265
C7.883,151.056,8.235,148.443,8.368,146.324z M66.124,286.767c2.387,1.511,2.336,2.66,2.997,2.666
c1.654,1.652-1.478-0.414-2.868-1.67C65.872,287.253,65.86,286.94,66.124,286.767z M53.533,275.474
c1.708,1.629,1.463,1.988,3.169,3.609c-1.38-0.98-1.981-0.768-2.313-0.151c1.419,1.424,1.964,1.626,3.169,2.61
c-0.69-1.342,2.293,1.02,0.438-1.432c0.835,0.773,1.668,1.539,1.918,1.499c1.95,2.116-3.916-1.063,0.542,2.778
c-2.271-1.348-4.372-3.289-6.41-4.127c1.492,0.633-0.262-2.308-1.439-4.045C54.973,277.857,53.144,276.045,53.533,275.474z
M26.832,240.349c-1.672-2.28-1.73-1.941-1.967-0.932c-1.049-2.947-0.044-1.99-0.862-4.815c0.604,0.798,1.113,1.899,1.613,2.946
C26.12,238.592,26.539,239.626,26.832,240.349z M7.023,160.503c-0.554-1.076,0.151-3.256,0.237-5.563
C7.835,156.216,6.95,157.005,7.023,160.503z M68.916,290.822c-1.562-1.475-2.603-1.947-3.743-2.531
c-1.125-0.6-2.419-1.231-4.243-3.246c0.395-0.221,1.55,0.676-0.104-1.01c0.366,0.004,1.606,1.277,2.56,2.043
c-0.902-0.43-1.041-0.322-0.506,0.473C63.835,286.722,69.199,289.683,68.916,290.822z M85.799,26.038
c-0.238,0.354-3.3,2.027-4.661,2.945c0.388-0.372,1.494-1.06,1.209-1.138c0.646-0.371,0.979-0.483,1.411-0.678
C84.192,26.974,84.712,26.676,85.799,26.038z M319.178,235.062c-1.528,2.031-2.038,3.285-2.58,4.754
c-0.276,0.729-0.53,1.527-0.974,2.467c-0.452,0.934-1.049,2.033-1.926,3.411c-0.04-0.472,0.183-1.196,0.947-2.44
c-1.052-0.006-2.077,3.406-3.005,4.09c0.426-1.707,1.987-4.561,3.507-7.15C316.644,237.595,318.273,235.339,319.178,235.062z
M41.835,62.595c0.435-0.828,1.231-1.979,1.684-2.826c0.856-0.803,0.888-0.591,0.461,0.052
C43.569,60.478,42.751,61.605,41.835,62.595z M24.034,238.892c2.131,4.115,7.44,11.995,8.743,15.549
c-1.083-1.834-1.849-2.497-3.249-4.713c-0.142,0.287,0.379,1.373,1.441,2.914c-0.458,0.133-1.742-2.188-2.875-4.025
c1.3,0.996,0.455-0.744-0.83-3.051C26.039,243.218,24.471,240.232,24.034,238.892z M98.374,311.255
c-1.094-0.376-2.296-0.842-3.586-1.377c-1.273-0.564-2.609-1.25-4.017-1.969c-1.393-0.744-2.893-1.468-4.348-2.374
c-1.467-0.891-2.968-1.823-4.484-2.78c-1.536-0.924-2.995-2.025-4.484-3.066c-0.74-0.529-1.48-1.06-2.217-1.586
c-0.741-0.521-1.465-1.059-2.159-1.627c-2.811-2.229-5.548-4.361-7.894-6.48c4.802,3.576,10.288,7.148,15.888,10.821
C86.778,304.322,92.597,307.96,98.374,311.255z M51.211,51.803c-1.067,0.779-3.318,3.253-3.142,2.367
C49.735,52.479,50.486,52.164,51.211,51.803z M13.323,214.332c0.296,1.158,0.369,2.01,0.726,3.152
c-0.274-0.385-0.54-0.74-0.736-0.668C13.01,215.439,12.737,214.064,13.323,214.332z M8.248,127.207
c0.378-1.367,0.67-1.707,0.904-1.368c-0.783,2.559-0.464,2.626-0.074,2.53c-0.404,1.056-0.998,1.879-0.941,0.506
C8.877,125.988,8.838,126.982,8.248,127.207z M39.236,64.435c-0.766,0.707-1.226,1.184-1.655,1.691
c-0.438,0.5-0.855,1.021-1.501,1.846c-1.062,1.121,0.459-1.488-1.4,0.853c-1.128,1.972-2.966,4.632-4.585,6.6
c0.42-0.958,1.875-3.122,3.327-5.101c1.486-1.957,2.867-3.805,2.91-4.338c0.305-0.234,0.749-0.727,1.176-1.189
c0.446-0.451,0.885-0.871,1.139-0.992c0.25-0.521,1.151-1.76,2.165-3.036c0.506-0.638,1.041-1.284,1.537-1.854
c0.512-0.557,0.998-1.025,1.364-1.348c-0.264,0.478-1.155,1.469-0.842,1.475c-1.661,1.095-3.013,4.023-5.142,6.098
C37.513,66.144,39.204,63.839,39.236,64.435z M158.128,327.572c-0.869,0.355-3.156,0.149-5.751-0.123
c-1.296-0.156-2.676-0.28-3.987-0.494c-1.311-0.213-2.565-0.402-3.628-0.52c0.856-0.085,1.793-0.071,2.798,0.005
c1.003,0.099,2.079,0.205,3.212,0.316C153.039,326.99,155.534,327.394,158.128,327.572z M251.519,307.384
c-1.019-0.135,14.608-8.942,4.329-2.707C254.149,305.718,253.863,306.291,251.519,307.384z M5.333,181.151
c0.242,1.692,0.125,2.136,0.196,3.243c-0.264-0.428-0.499-1.215-0.763-1.625C4.764,181.56,5.119,181.597,5.333,181.151z
M4.448,154.435c0.29,0.648-0.164,4.537-0.149,6.477C3.765,160.074,4.359,156.823,4.448,154.435z M87.158,22.912
c4.021-2.022,3.564-2.148,7.067-3.643c-0.537,0.326-2.199,1.259-3.779,2.094C88.9,22.261,87.422,23.043,87.158,22.912z
M20.081,93.828c-0.569,0.742-1.566,2.931-1.829,2.631c1.574-3.086,2.711-5.82,1.068-3.129c0.38-1.389,0.692-1.635,1.067-1.921
c0.367-0.29,0.818-0.609,1.537-2.122C21.568,90.771,20.334,92.3,20.081,93.828z M22.409,88.66
c-0.809,0.627,1.163-2.36,0.352-1.726c0.773-1.439,1.356-2.6,1.943-3.541c0.581-0.945,1.079-1.721,1.562-2.453
c0.489-0.729,0.956-1.42,1.479-2.197c0.264-0.385,0.542-0.791,0.847-1.236c0.32-0.43,0.667-0.896,1.052-1.412
C26.982,80.782,24.879,84.103,22.409,88.66z M8.931,122.293c0.389-2.416,1.008-4.015,1.69-5.384
c-0.151,0.833-0.46,2.204-0.798,3.35C9.524,121.416,9.209,122.355,8.931,122.293z M51.471,49.523
c-1.301,1.32-2.636,2.633-3.925,3.979c-0.761,0.079,2.565-2.813,3.795-4.182C51.382,49.39,51.432,49.452,51.471,49.523z
M75.527,29.798c-1.723,1.212-1.805,0.943-2.172,0.934c1.334-0.893,2.633-1.819,3.984-2.672c-0.065,0.172-1.183,0.879-1.954,1.427
C75.437,29.589,75.485,29.691,75.527,29.798z M62.63,39.173c-0.81,0.826-2.815,2.523-4.424,3.893
c-1.593,1.39-2.894,2.35-2.486,1.465c0.956-0.707,1.951-1.498,3.078-2.381C59.951,41.294,61.21,40.32,62.63,39.173z M80.75,25.97
c-0.201-0.14,2.378-1.559,3.381-2.149c-0.102,0.16-0.663,0.491-0.618,0.592c1.423-0.74,2.177-1.221,2.025-1.362
c1.883-1.12,1.489-0.565,2.867-1.319C86.03,23.56,83.611,24.253,80.75,25.97z M4.111,183.574c0.17,0.055,0.276,0.92,0.371,1.949
c0.13,1.027,0.275,2.215,0.427,2.926c-0.206-0.004-0.442-0.958-0.62-2.042C4.096,185.324,4.079,184.097,4.111,183.574z
M3.943,199.199c0.295,0.554,0.577,1.98,0.923,3.566c0.384,1.575,0.78,3.324,1.143,4.549c-0.331-0.757-0.681-1.802-1.037-2.938
c-0.173-0.569-0.353-1.159-0.532-1.748c-0.156-0.594-0.311-1.186-0.457-1.75C4.3,201.152,4.132,200.207,3.943,199.199z
M0.691,174.448c-0.182,1.462,0.261,2.438,0.11,4.151c-0.323-0.705-0.38-2.682-0.547-4.168
C0.398,174.425,0.547,174.443,0.691,174.448z M0.517,153.689c-0.091-1.355,0.021-2.726,0.15-4.107
c0.131-1.384,0.305-2.779,0.482-4.18c-0.007,1.164-0.071,2.615-0.19,4.084C0.844,150.957,0.655,152.443,0.517,153.689z
M0.301,157.832c0.144,0.471,0.073,1.773,0.014,3.242c-0.02,1.471-0.068,3.108-0.025,4.24
C-0.098,165.782-0.098,160.05,0.301,157.832z M3.748,129.88c-0.315-0.2,0.275-2.814,0.868-4.936
C4.92,125.166,4.144,127.723,3.748,129.88z M294.101,137.416c-0.23,1.588-0.096-1.568-0.629-1.881
c-0.862,0.125-1.102,1.245-1.184,2.594c-0.049,0.676-0.024,1.406-0.081,2.109c-0.065,0.703-0.147,1.373-0.302,1.908
c1.211,0.775,0.174-2.492,1.21-2.033c-0.102,2.338-0.439,2.764,0.501,5.109c-0.544,0.229-0.967-0.056-1.334-0.557
c-0.289,0.413-0.395,1.064-0.354,1.838c0.044,0.773,0.14,1.683,0.399,2.596c-0.182-0.674-0.338-1.322-0.471-1.951
c-0.145-0.627-0.318-1.227-0.442-1.816c-0.26-1.172-0.441-2.283-0.566-3.359c-0.063-0.535-0.11-1.063-0.147-1.587
c-0.057-0.52-0.114-1.031-0.168-1.538c-0.104-1.018-0.172-2.026-0.236-3.047c-0.085-1.019-0.075-2.072-0.247-3.125
c-0.141-1.058-0.307-2.152-0.518-3.302c-0.359-2.318-1.185-4.79-2.373-7.621c0.76-0.148,0.499-1.513-0.25-3.437
c-0.724-1.931-1.677-4.494-2.423-7.05c-0.377-1.432-0.536-2.55-0.622-3.51c-0.082-0.96-0.173-1.724-0.281-2.504
c-0.118-0.774-0.305-1.542-0.707-2.45c-0.413-0.902-0.984-1.973-2.078-3.238c-0.472-0.447-0.919-0.659-1.348-0.801
c0.905,2.005,3.288,6.183,3.02,9.157c-0.294-0.683-0.563-0.995-0.815-1.048c0.021-0.809-0.046-1.704-0.193-2.677
c-0.438-0.431-0.901-1.186-1.378-2.193c-0.739-1.531-1.026,1.102-1.889-1.25c-1.091,0.017,0.534,2.19,0.556,3.213
c-0.567-0.253-0.732-0.851-0.979-1.552c-0.243-0.701-0.504-1.543-1.143-2.318c0.296-0.795,1.095-1.042,2.276,0.336
c-0.128-2.319-1.225-4.673-3.562-7.515l0.689-0.378c-1.046-1.938-2.864-3.486-2.412-0.864c1.04-0.516,1.359,0.378,1.686,1.798
c0.164,0.711,0.328,1.551,0.588,2.418c0.263,0.863,0.533,1.803,1.043,2.65c-1.144-0.689-0.502,2.049-0.628,3.091
c-0.483-0.089-0.253-0.491-0.624-1.089c-0.586,0.803-0.435,2.085,0.883,3.689c-0.271,0.223-0.561-0.002-0.878-0.4
c-0.334-0.387-0.689-0.952-1.032-1.438c0.666-0.636-0.216-2.042-1.376-3.442c0.5-0.146,0.575-1.09,0.296-2.042
c-0.26-0.962-0.765-1.988-1.229-2.374c-0.569-1.087,1.205,0.508-0.553-1.834c1.147,0.898,2.055,0.365,3.537,3.1
c0.38-0.393-1.467-2.402-0.682-2.511c-0.99-1.755-1.453-0.853-2.539-2.958c1.273,0.846,1.02-0.274-0.44-1.961
c0.905,0.412,1.337-0.473,1.353-0.818c0.287-0.409,2.509,2.747,1.276,2.257c0.766,1.073,1.295,0.729,2.073,1.81
c-1.19-1.735-1.001-3.135,1.025-1.305c-1.418-1.994-2.637-2.265-3.916-3.021c-0.642-1.725-1.279-2.211-1.987-2.627
c-0.695-0.428-1.508-0.756-2.538-2.179c0.322-1.001,1.309,0.218,2.264,1.61c-0.36-1.648,1.466-0.162,2.317,0.087
c0.086-0.292-0.41-0.798-0.977-1.175c-0.569-0.378-1.259-0.587-1.467-0.382c0.377-1.301,0.547,0.083-1.81-2.717
c0.488-0.722,1.671,0.993,2.317,0.81c0.373,1.281-0.055,0.658,1.399,2.241c0.396-0.747-2.156-3.989-0.29-2.97
c-0.526-0.302-1.268-1.363-1.679-1.282c0.25-0.271,0.188-0.417-0.187-0.835c-0.188-0.209-0.443-0.494-0.797-0.885
c-0.339-0.398-0.769-0.908-1.319-1.553c0.611-0.102,3.464,2.311,0.864-0.622c1.976-0.438,3.272,2.095,5.502,5.226
c-0.006-0.547,1.724-0.754,2.016-2.131c-0.939-0.49-2.487-3.057-3.414-3.531c-0.732-1.688,2.438,2.396,3.21,2.836
c-0.409-1.01-0.971-1.914-1.627-2.752c-0.665-0.834-1.387-1.633-2.247-2.337c-1.693-1.435-3.494-2.851-5.184-4.561
c2.801,1.999,3.119,1.755,5.597,3.586c-0.94-1.695-0.114-1.743,1.11-0.976c1.258,0.734,2.751,2.418,3.277,3.918
c0.153-1.976-1.507-3.484-3.819-5.035c-1.138-0.793-2.396-1.634-3.612-2.633c-1.272-0.949-2.521-2.037-3.577-3.422
c1.708,0.392,4.369,2.846,6.501,4.738c-0.086-0.512-0.851-1.412-0.293-1.567c2.11,2.162,3.096,4,6.112,7.03
c1.759,0.199,2.075-1.181,1.248-3.238c-0.206-0.514-0.484-1.07-0.826-1.65c-0.357-0.567-0.801-1.142-1.297-1.733
c-0.993-1.175-2.229-2.38-3.644-3.474c0.438-0.229-0.144-1.027,0.351-1.213c0.125,0.811,0.777,1.963,2.441,3.678
c0.917-1.507,2-2.318,4.402-1.383c-3.976-4.504-7.292-6.582-11.338-10.11c1.839,0.495,3.149,1.328,4.902,2.756
c0.862,0.728,1.844,1.6,2.965,2.743c1.09,1.172,2.359,2.581,3.9,4.297c1.601,1.231,1.557,0.203,3.425,1.311
c-0.012-0.406-0.017-0.807-0.495-1.524c-0.875-0.831-0.242,0.112,0.25,0.765c-0.713-0.379-2.225-1.617-3.639-3.065
c-1.433-1.431-2.773-3.078-3.432-4.058c-1.175-0.225-2.949-1.852-4.719-3.477c-1.807-1.591-3.7-3.092-4.858-3.256
c-0.602-0.822-0.593-1.369-0.309-1.793c1.755,1.332,3.435,2.504,5.078,3.598c1.612,1.141,3.148,2.227,4.672,3.305
c0.769,0.541,1.549,1.07,2.313,1.63c0.741,0.583,1.485,1.173,2.244,1.771c1.49,1.229,3.088,2.473,4.609,3.985
c-0.004-1.045-1.019-2.031-2.314-3.244c-0.65-0.605-1.371-1.268-2.09-2.014c-0.724-0.744-1.413-1.601-2.109-2.506
c0.732,0.512,1.405,1.057,2.061,1.593c0.659,0.537,1.306,1.061,1.939,1.575c1.261,1.043,2.513,2.021,3.656,3.035
c2.238,2.069,4.419,3.98,6.406,5.98c0.982,1.018,1.951,2.023,2.92,3.029c0.955,1.02,1.972,2.006,2.869,3.098
c0.923,1.081,1.856,2.174,2.81,3.293c0.952,1.125,1.949,2.268,2.866,3.522c0.205-0.227-0.462-1.406-0.939-2.239
c1.312,0.802,3.159,5.277,4.547,6.829c-0.17-0.709-1.034-2.252-2.007-3.805c-0.965-1.558-2.05-3.117-2.723-3.838
c0.844,0.27,2.547,2.648,4.47,5.769c0.482,0.78,0.985,1.602,1.474,2.456c0.451,0.874,0.912,1.77,1.372,2.664
c0.898,1.8,1.805,3.594,2.532,5.254c0.91,1.479,0.909,1.512,0.062,0.84c-0.367-0.964-0.797-1.867-1.223-2.722
c-0.421-0.856-0.836-1.666-1.245-2.409c-0.818-1.481-1.593-2.7-2.332-3.488c0.218,0.889,1.099,3.027,1.967,3.59
c0.651,1.322,0.434,1.678,1.133,3.051c-0.951-0.612-2.017-1.852-3.147-3.462c-0.575-0.801-1.125-1.714-1.758-2.651
c-0.64-0.936-1.292-1.93-1.95-2.953c-0.651-1.029-1.316-2.08-1.982-3.133c-0.665-1.051-1.408-2.051-2.096-3.045
c-0.699-0.99-1.385-1.947-2.047-2.84c-0.338-0.445-0.664-0.874-0.979-1.291c-0.337-0.398-0.673-0.776-0.996-1.131
c-0.212,0.143,0.371,0.905,0.651,1.445c-0.702-1.048-1.602-2.105-2.622-3.314c-0.505-0.604-1.043-1.248-1.618-1.936
c-0.56-0.697-1.155-1.44-1.819-2.215c-0.365,0.046,0.217,0.74,0.672,1.381c0.44,0.653,0.781,1.23,0.037,0.965
c1.538,2.127,3.284,4.274,4.946,6.551c0.828,1.143,1.664,2.296,2.5,3.451c0.841,1.156,1.729,2.289,2.502,3.486
c0.809,1.179,1.616,2.352,2.411,3.511c0.806,1.157,1.619,2.292,2.332,3.456c1.451,2.31,2.964,4.484,4.245,6.566
c0.855-0.234,2.347,1.649,3.975,4.016c0.401,0.598,0.817,1.219,1.236,1.84c0.385,0.64,0.77,1.279,1.143,1.897
c0.755,1.237,1.485,2.382,2.141,3.216c-0.034-1.023-0.87-2.826-1.714-4.726c-0.85-1.9-1.864-3.839-1.98-5.322
c1.059,0.827,2.143,1.929,3.279,3.748c1.042,1.851,2.113,4.438,3.19,8.16c-0.415-0.206-0.854-1.288-1.276-1.4
c-0.203,0.268,0.366,1.618,0.043,1.704c0.874,1.272,1.738,2.775,2.577,5.362c0.056-1.245-0.361-3.241-1.102-5.729
c1.299,1.844,1.817,4.915,1.578,6.477c0.75,1.668,1.463,3.568,2.261,5.686c0.781,2.12,1.712,4.449,2.389,7.092
c-0.121-0.785-0.247-1.61-0.377-2.469c-0.168-0.85-0.368-1.73-0.568-2.644c-0.413-1.821-0.852-3.763-1.311-5.788
c-0.547-2.007-1.116-4.098-1.7-6.24c-0.302-1.068-0.553-2.17-0.891-3.254c-0.33-1.084-0.663-2.18-0.996-3.281
c3.829,10.161,6.671,21.923,8.25,32.725c0.189,1.354,0.376,2.687,0.563,4c0.093,0.656,0.185,1.308,0.277,1.953
c0.063,0.648,0.126,1.291,0.187,1.927c0.254,2.543,0.497,4.982,0.726,7.282c0.137,2.304,0.276,4.471,0.436,6.463
c0.086,0.996,0.17,1.949,0.249,2.854c0.046,0.906,0.098,1.765,0.159,2.569c-0.217,0.139-0.214-0.35-0.202-0.872
c-0.284-1.844-0.622,0.551-1.171-0.953c-0.099,2.127-0.296,4.617-0.527,7.289c-0.302,2.67-0.737,5.518-1.101,8.373
c-0.444,2.852-0.969,5.702-1.338,8.406c-0.453,2.691-0.888,5.225-1.085,7.467c-0.611,1.147-1.483,4.063-2.128,7.08
c-0.157,0.755-0.315,1.516-0.466,2.25c-0.17,0.734-0.331,1.441-0.482,2.102c-0.286,1.323-0.496,2.46-0.583,3.191
c-0.175-0.291,0.339-1.644,0.052-1.773c-0.417,1.08-0.958,2.602-1.571,4.371c-0.688,1.75-1.447,3.75-2.199,5.822
c-0.709,2.088-1.633,4.167-2.36,6.188c-0.73,2.021-1.417,3.922-1.994,5.521c-0.433,0.365-1.024,1.599-1.312,1.156
c-0.459,1.352-1.084,3.026-1.973,4.752c-0.888,1.73-1.846,3.604-2.792,5.447c-0.958,1.836-1.968,3.607-2.716,5.27
c-0.381,0.828-0.709,1.625-0.967,2.368c-0.263,0.741-0.492,1.411-0.603,2.03c0.104-2.234-2.793,2.934-4.094,5.258
c0.258,0.631,2.032-3.221,1.939-1.64c-0.298,0.452-0.715,1.231-1.152,2.042c-0.454,0.801-0.901,1.651-1.19,2.295
c-0.577,1.287-0.51,1.742,1.46-0.738c-0.706,1.954-3.561,4.46-2.69,2.117c-0.479,0.658-0.178,0.701-0.161,0.966
c-2.353,2.849-0.048-1.853-1.679-0.365c-1.34,2.28,0.091,0.652-0.165,2.085c-2.094,2.205-3.844,3.668-5.944,5.832
c0.454,0.268-0.818,1.73-2.469,3.551c-0.823,0.914-1.733,1.922-2.606,2.891c-0.89,0.951-1.708,1.895-2.282,2.738
c0.9-1.056,3.056-2.963,5.003-5.076c0.989-1.039,1.966-2.092,2.768-3.047c0.81-0.947,1.371-1.861,1.657-2.512
c0.904-1.205,0.654,0.56-0.589,1.757c0.32,0.083,2.513-2.395,0.322,0.312c1.072-1.457,2.126-2.488,3.272-3.539
c0.573-0.527,1.162-1.068,1.797-1.652c0.627-0.595,1.242-1.287,1.922-2.08c0.831-0.098-0.673,1.641-0.348,1.948
c1.685-2.067,1.752-2.868,2.716-4.377c-3.386,3.839,1.355-2.8,2.708-5.423c2.007-2.342-0.835,1.84,0.231,1.265
c0.841-0.462,1.432-3.168,1.66-4.072c0.973-1.315,0.906,0.586,2.351-2.464c-0.26,0.768-0.585,1.668-1.073,2.617
c-0.485,0.949-1.064,1.992-1.722,3.111c-0.66,1.117-1.391,2.318-2.197,3.569c-0.849,1.222-1.763,2.505-2.723,3.836
c-1.869,2.698-4.154,5.413-6.42,8.268c-1.126,1.433-2.39,2.786-3.582,4.208c-1.216,1.4-2.387,2.852-3.654,4.197
c1.641-3.289-3.132,2.52-3.118,1.576c2.688-3.012,3.179-3.113,5.714-6.262c-0.463-0.16-1.817,1.348-2.714,2.003
c-0.919,1.45-2.196,2.714-3.438,3.941c-0.621,0.614-1.237,1.218-1.8,1.832c-0.566,0.612-1.119,1.196-1.555,1.835
c0.203-0.033,1.422-0.919,2.143-1.48c0.721-0.563,1.044-0.703-0.48,0.844c1.084-0.535,2.431-1.615,4.043-3.08
c-0.952,1.057-1.86,2.064-2.737,3.039c-0.888,0.965-1.807,1.838-2.681,2.725c-1.771,1.746-3.449,3.468-5.339,5.076
c-0.933,0.816-1.884,1.646-2.865,2.505c-0.493,0.426-0.995,0.859-1.505,1.299c-0.531,0.42-1.074,0.845-1.627,1.28
c-2.231,1.728-4.612,3.68-7.521,5.643c-0.858,0.109,3.978-2.847,1.206-1.326c0.089-0.424,1.226-0.849,1.519-0.779
c0.963-0.832-0.415-0.029-0.333-0.551c4.695-3.469,5.546-3.783,6.679-5.288c-2.202,0.978-7.92,6.512-9.598,7.02
c0.874-0.841,2.078-1.73,3.327-2.813c1.267-1.062,2.677-2.187,4.11-3.306c1.456-1.091,2.828-2.303,4.148-3.38
c1.311-1.087,2.524-2.094,3.528-2.924c-0.97,1.421-3.138,3.213-5.108,4.504c0.086,0.12,0.168,0.232,0.245,0.357
c1.917-1.152,3.436-2.42,4.859-3.713c1.408-1.31,2.655-2.719,4.17-4.051c-0.54-0.558-4.061,3.369-3.938,1.816
c-1.974,1.915-3.089,2.402-4.647,3.627c-0.445,0.754-2.28,2.555-4.817,4.491c-1.261,0.98-2.645,2.053-4.095,3.067
c-1.478,0.98-2.986,1.946-4.412,2.816c0.328-0.436,3.901-3.303,0.378-1.024c1.202-1.112,1.57-1.808-0.998-0.094
c0.983-0.856,2.344-1.909,3.714-2.893c1.365-0.994,2.678-2,3.687-2.602c-0.141,0.664-1.552,2.026-0.099,1.412
c0.462-0.801,1.557-1.855,3.802-3.395c0.034,0.288-1.129,1.078-1.892,1.693c0.89-0.426,1.536-0.874,2.072-1.352
c0.526-0.487,0.926-1.021,1.363-1.581c0.873-1.126,1.845-2.393,4.033-3.928c0.046,0.458,0.876-0.149,0.762-0.369
c0.889-0.711,0.857-0.042,1.15,0.13c0.837-0.761,1.667-1.516,2.502-2.273c-3.729,1.916,2.171-2.276,2.407-3.708
c-1.323,1.233-3.589,3.139-5.992,5.287c-1.212,1.063-2.536,2.104-3.778,3.174c-1.26,1.051-2.428,2.149-3.542,3.077
c1.475-1.383,1.792-2.079,1.178-1.766c-0.603,0.326-2.266,1.497-4.872,3.739c-0.107-0.189-0.217-0.373-0.34-0.549
c-1.013,1.281-2.597,2.148-3.462,2.268c-0.726,1.446-2.979,3.441-4.672,5.228c-0.676,0.252-1.921,0.846-3.468,1.647
c-0.767,0.406-1.619,0.855-2.519,1.334c-0.449,0.241-0.907,0.489-1.375,0.744c-0.479,0.236-0.965,0.478-1.455,0.723
c-1.961,0.975-3.972,2.02-5.795,2.97c-1.861,0.882-3.521,1.702-4.717,2.345c-0.143,0.467,0.728,0.082,1.656-0.341
c-0.059,0.81-3.106,0.892-2.104,1.481c-1.236,0.399-2.602,0.898-4.031,1.469c-1.425,0.578-2.905,1.238-4.394,1.898
c-3.009,1.243-6.097,2.399-8.76,2.904c-0.962,1.18,3.504-1.214,2.556-0.05c-1.397,0.911-1.436,0.439-3.944,1.346
c0.506-1.29-4.813,0.925-5.073,0.106c-0.811,0.315-1.519,0.542-2.176,0.717c-0.654,0.182-1.247,0.347-1.838,0.511
c-1.178,0.332-2.333,0.689-3.908,1.321c0.079,0.35,3.72-1.299,4.849-1.058c-1.094,0.687-5.162,1.396-4.688,2.057
c-1.315,0.331-4.299,0.242-4.909,0.87c-1.199,0.156,0.063-0.195-0.049-0.398c-1.341,0.311-3.082,0.752-5.084,1.093
c-2.002,0.336-4.238,0.71-6.528,1.093c-4.599,0.636-9.406,1.37-13.018,2.039c-0.907-0.379-2.069-0.269-3.463-0.065
c-1.393,0.226-3.027,0.385-4.836,0.317c0.382-0.408-0.536-0.511-0.768-0.773c-4.52,0.28-4.996,0.51-9.149,0.346
c-1.371-0.891-10.709-1.755-13.128-1.394c0.398,0.123,1.014,0.249,1.753,0.342c0.741,0.063,1.601,0.138,2.483,0.213
c1.765,0.142,3.615,0.302,4.77,0.599c-2.696-0.014,0.796,0.982-3.514,0.809c2.124-0.813-1.68-0.873-3.901-1.536
c0.354,0.816-3.538-0.241-7.411-0.909c0.084-0.83,2.287,0.432,3.369,0.123c0.374-0.448-4.628-0.515-3.13-1.041
c-1.578-0.198-3.318-0.558-5.138-0.842c-1.814-0.324-3.714-0.568-5.526-1.024c-1.822-0.417-3.592-0.849-5.192-1.296
c-0.804-0.211-1.556-0.455-2.245-0.729c-0.692-0.262-1.327-0.524-1.891-0.788c-2.767-0.815-4.118-0.818-5.083-0.608
c-4.606-1.615-6.716-3.188-10.624-4.415c0.083-0.16,0.166-0.321,0.25-0.484c-1.498-0.532-2.979-1.098-4.473-1.744
c-1.474-0.695-2.975-1.439-4.558-2.232c-0.792-0.398-1.604-0.809-2.445-1.232c-0.822-0.455-1.673-0.926-2.555-1.417
c-0.886-0.482-1.806-0.982-2.763-1.505c-0.482-0.254-0.972-0.517-1.473-0.783c-0.492-0.285-0.994-0.576-1.506-0.873
c-0.844-0.472-0.785-0.098-1.02,0.006c-2-1.213-3.371-2.114-4.425-2.857c-1.03-0.787-1.748-1.414-2.473-2.04
c-1.429-1.284-2.958-2.456-6.704-5.388c1.248,0.419,1.766,0.996,3.387,2.273c0.329-0.438-3.03-2.6-1.916-2.695
c-1.484-0.832-2.44-1.742-3.616-2.887c-1.146-1.184-2.479-2.647-4.782-4.543c-0.049-0.542-0.383-1.086-0.883-1.709
c-0.496-0.629-1.188-1.305-2.012-2.044c-0.825-0.739-1.781-1.54-2.807-2.42c-1.029-0.874-2.046-1.915-3.105-2.995
c0,0.577-0.186,1.033,1.623,2.782c-1.59-1.313-3.167-2.75-4.708-4.229c-1.526-1.493-2.924-3.125-4.318-4.674
c-1.414-1.534-2.648-3.152-3.856-4.633c-1.192-1.495-2.381-2.838-3.375-4.114c-0.173-0.212-0.362-0.325-0.51-0.388
c-0.146-0.061-0.252-0.072-0.256-0.078c-0.599-0.781-1.557-2.423-2.461-3.742c-0.911-1.314-1.674-2.386-2.106-1.895
c0.903,1.773,2.905,4.158,4.767,6.471c0.918,1.166,1.917,2.223,2.752,3.166c0.837,0.941,1.534,1.746,1.969,2.319
c1.438,1.761-0.591,0.747,1.824,2.616c0.748,0.873-0.214,0.507,0.685,1.49c-0.384-0.338-1.191-1.006-2.166-1.892
c-0.943-0.918-2.063-2.047-3.14-3.234c-0.607-0.898-0.669-1.357-0.482-1.614c-1.987-2.192-0.399,0.348-1.489,0.019
c2.018,1.5,3.816,5.336,5.23,6.219c-0.634,0.084-1.374-0.843-2.305-2.183c-0.937-1.337-2.1-3.046-3.578-4.581
c-1.609-1.795-1.004-0.73-3.647-3.475c0.241-0.065,0.485-0.125-0.06-0.896c0.905,0.768,2.561,3.109,3.355,3.631
c-0.009-1.109-1.682-3.448-3.565-6.057c-0.892-1.34-1.839-2.746-2.673-4.083c-0.836-1.333-1.559-2.595-1.931-3.689
c0.741,0.26,0.57,0.336,0.722-0.364c-0.9-1.307-1.919-3.235-2.951-5.052c-0.988-1.845-1.934-3.615-2.875-4.537
c-0.358-0.949-0.822-1.961-1.276-3.079c-0.444-1.121-0.943-2.312-1.479-3.554c-0.522-1.25-1.149-2.523-1.682-3.889
c-0.549-1.355-1.112-2.752-1.688-4.172c-0.29-0.709-0.582-1.422-0.876-2.141c-0.262-0.728-0.525-1.459-0.788-2.192
c-0.524-1.465-1.046-2.933-1.564-4.386c-0.961-2.925-1.776-5.824-2.501-8.484c-1.089-0.391,0.546,2.484,0.195,3.167
c0.344,0.747,0.599,0.718,1.032,2.212c-0.275,0.33,0.063,1.971,0.548,3.553c0.47,1.586,0.939,3.168,0.638,3.486
c-0.715-1.916-1.284-3.305-1.813-4.857c-0.529-1.555-1.105-3.243-1.947-5.713c-0.008,0.861,0.259,2.243,0.66,3.873
c0.2,0.815,0.435,1.693,0.684,2.6c0.275,0.898,0.593,1.814,0.895,2.729c0.614,1.828,1.198,3.644,1.648,5.167
c0.498,1.503,0.812,2.731,0.769,3.433c-0.424-1.017-0.938-2.099-1.468-3.195c-0.494-1.111-0.979-2.25-1.426-3.344
c-0.875-2.194-1.601-4.205-1.422-5.637c0.371,1.152,0.473,1.828,0.609,2.457c0.132,0.631,0.297,1.215,0.868,2.154
c0.104-1.51-0.616-3.374-1.328-5.627c-0.375-1.118-0.794-2.316-1.187-3.582c-0.194-0.631-0.392-1.277-0.555-1.941
c-0.143-0.672-0.271-1.355-0.376-2.053c0.629,0.689,0.302,2.125,1.02,3.066c-0.575-2.407-0.523-5.146,0.134-4.049
c-0.316-1.555-0.92-3.902-1.219-5.784c-0.291-1.883-0.431-3.257-0.102-2.803c-0.193-0.905-0.385-1.81-0.578-2.71
c-0.201-0.899-0.383-1.805-0.519-2.721c0.194,0.174,0.352,1.545,0.573,1.529c-0.008-2.607-1.63-4.389-0.758-5.457
c-0.161-0.916-0.309-1.748-0.444-2.514c-0.108-0.768-0.208-1.473-0.301-2.131c-0.191-1.316-0.354-2.449-0.515-3.547
c-0.365-2.196-0.509-4.291-0.66-7.502c-0.692-0.68-0.272,3.237-0.641,3.894c-0.267-0.25,0.025-2.737-0.581-2.398
c0.063-1.153,0.091-2.354,0.091-3.636c-0.006-1.28,0.08-2.646,0.089-4.12c0.011-1.472,0.023-3.057,0.036-4.783
c0.085-1.727,0.174-3.592,0.274-5.632c0.475,0.759-0.064,1.948-0.057,4.767c0.158-0.566,0.273-1.4,0.381-2.33
c0.326-0.083-0.086,2.812,0.019,3.963c0.233-1.775,0.485-3.297,0.62-4.982c0.17-1.681,0.194-3.524,0.46-5.92
c-0.151,0.574-0.308,1.424-0.402,2.385c-0.066,0.965-0.14,2.037-0.208,3.055c-0.043-1.684-0.577-1.475-0.369-4.037
c0.074,0.434,0.156,0.871,0.357,0.871c0.099-2.218,0.186-4.124,0.286-6.348c-0.003,1.77,0.169,1.633,0.377,0.756
c0.221-0.879,0.418-2.505,0.659-3.717c0.104-1.096-0.231-0.12-0.383,0.719c0.106-0.008,0.322-0.997,0.607-2.55
c0.135-0.778,0.296-1.698,0.472-2.705c0.176-1.009,0.33-2.11,0.596-3.236c0.494-2.257,0.993-4.669,1.504-6.815
c0.588-2.131,1.131-4.006,1.54-5.226c0.295-1.468,0.177-2.02,0.771-3.875c-0.226,0.165-0.694,1.828-0.967,2.969
c-0.357,1.079-0.133-0.212,0.312-1.773c0.465-1.556,1.071-3.399,1.204-3.493c-0.264,1.33-0.102,1.809-0.051,2.513
c-0.37,0.433-0.587,1.124-0.773,1.915c-0.168,0.797-0.334,1.684-0.682,2.506c0.146,0.365,0.383-0.498,0.625-1.416
c-0.13,2.322-1.172,4.561-1.383,6.928c0.502-1.594,0.995-3.765,1.428-5.569c0.222-0.901,0.442-1.704,0.672-2.29
c0.238-0.583,0.508-0.938,0.765-0.959c1.719-6.221,3.326-10.454,5.799-15.923c-0.017,0.509-0.394,1.438-0.833,2.403
c-0.402,0.979-0.847,2.001-1.079,2.654c0.392-0.279,0.833-1.545,1.357-2.985c0.547-1.433,1.106-3.071,1.544-4.152
c0.017-1.404-1.298,3.064-1.511,2.663c-0.366-0.076,0.383-1.942,1.122-3.789c0.634-0.512-0.448,1.718-0.943,3.006
c0.519-0.708,0.888-1.641,1.264-2.629c0.371-0.99,0.75-2.037,1.355-2.936c0.049,0.217-0.349,1.007-0.572,1.669
c-0.231,0.657-0.359,1.153,0.092,0.722c0.278-0.689,0.084-0.63,0.063-0.847c3.626-6.305,6.712-13.153,10.156-18.954
c-0.379,0.1-1.22,1.324-2.047,2.729c-0.831,1.403-1.643,2.991-1.902,3.854c-0.628,0.336,0.239-1.326,0.218-1.748
c0.69-0.385,2.599-4.012,4.14-6.473c-0.525,0.679-0.756,0.939-0.836,0.828c-0.066-0.102,0.038-0.57,0.362-1.243
c-1.304,1.601-1.957,1.819-4.08,5.597c0.244-0.971,0.869-2.775-1.003,0.201c0.785-1.382,1.615-2.723,2.476-4.043
c0.435-0.66,0.864-1.32,1.322-1.967c0.48-0.629,0.963-1.262,1.448-1.896c0.982-1.254,1.975-2.521,2.985-3.811
c1.059-1.248,2.136-2.519,3.242-3.822c0.732-1.172,0.349-1.438,1.67-2.66c-0.296,0.791,0.601-0.17,1.881-1.717
c1.284-1.545,2.92-3.701,4.273-5.168c-0.584,0.279-2.421,2.022-1.498,0.602c0.776-0.701,2.756-2.64,4.58-4.588
c1.908-1.871,3.672-3.75,3.878-4.508c1.885-1.744,0.25,0.257,1.506-0.527c0.574-0.738,1.918-1.977,3.313-3.256
c-0.898,0.461,0.291-0.777,2.033-2.372c1.804-1.526,4.156-3.409,5.426-4.417c-1.385,1.441-1.148,1.6,0.953,0.121
c-1.566,1.211-3.422,2.646-5.402,4.177c-1.955,1.564-3.903,3.39-5.767,5.221c0.659-0.157,2.162-1.377,2.724-1.419
c1.137-1.04,2.228-2.131,3.398-3.119c0.717-0.649,0.463-0.697-0.123-0.201c-0.049-0.105,0.663-0.684,1.104-1.09
c0.391-0.33,0.778-0.66,1.178-0.999c-1.394,1.759-0.057,0.364,1.47-0.489c1.288-1.178-0.807,0.447,0.404-0.797
c0.746-0.578,1.285-0.885,1.356-0.68c-0.135-0.174,4.318-3.896,4.256-3.277c-1.401,1.121-2.345,1.782-3.094,2.238
c1.374,0.201,2.405-1.393,2.423-0.283c0.922-0.777,1.18-1.184,1.49-1.619c0.313-0.432,0.661-0.918,1.845-1.74
c0.648-0.096-0.226,0.685-1.046,1.461c-0.814,0.791-1.62,1.521-0.977,1.169c0.023,0.484-2.375,1.475-2.346,1.961
c-1.616,1.101-3.792,2.137-2.32,0.492c-0.494,0.253-1.151,0.745-1.875,1.278c0.576,0.133-1.546,1.727,1.177,0.51
c-1.359,1.221-1.361,0.885-2.947,2.363c2.009-1.033,0.345,0.177-1.994,2.059c-2.286,1.939-5.376,4.446-6.244,5.838
c-0.629,0.359-1.821,1.506-2.87,2.451c-0.09,0.457,0.893-0.596,1.733-1.446c0,0.196-0.904,1.075-1.51,1.729
c0.458-0.313,1.068-0.844,1.734-1.45c-0.162,0.402-1.405,1.467-2.358,2.197c-0.943,0.738-1.556,1.175-0.581,0.16
c-4.517,4.762-9.337,10.589-13.146,15.764c0.847-0.818,1.873-2.057,2.969-3.477c0.271-0.356,0.549-0.724,0.833-1.096
c0.298-0.363,0.601-0.73,0.904-1.102c0.606-0.746,1.219-1.504,1.825-2.249c0.605-0.744,1.201-1.474,1.765-2.167
c0.583-0.675,1.171-1.284,1.698-1.837c1.062-1.1,1.95-1.916,2.531-2.224c-1.205,1.24-1.749,1.799-2.313,2.377
c-0.276,0.295-0.558,0.596-0.929,0.99c-0.373,0.393-0.814,0.895-1.386,1.616c-0.372,0.503-0.389,0.684-0.862,1.276
c-0.008,0.722,1.339-0.934,1.52-0.547c0.331-0.554,1.102-2.266,0.061-1.047c0.397-0.707,1.852-1.971,3.193-3.301
c1.345-1.329,2.622-2.688,2.839-3.508c0.728-0.612,1.875-1.431,2.954-2.316c0.538-0.443,1.063-0.904,1.513-1.365
c0.462-0.451,0.866-0.885,1.126-1.322c0.448-0.202,0.717,0.154,2.333-1.875c-0.235,0.472-1.114,1.392-2.368,2.576
c-1.266,1.171-2.772,2.742-4.453,4.337c-1.704,1.574-3.319,3.413-4.87,5.059c-1.565,1.637-2.828,3.279-3.742,4.542
c-0.875,1.185,1.013-0.714-1.235,1.945c1.033-0.657,1.736-2.215,2.979-3.362c0.256,0.1-1.624,2.137-2.259,3.04
c0.156,0.36,1.946-1.996,0.89-0.141c1.891-1.95,1.56-2.499,2.872-3.43c0.509-0.637,0.175-0.569,0.822-1.313
c-0.102-0.318-1.036,0.56-1.779,1.352c0.826-1.769,2.124-2.458,3.519-3.89c1.395-1.755-1.265,1.173-0.808,0.191
c2.503-2.568,1.655-1.372,3.466-2.796c-2.659,2.573-0.315,0.377-1.279,1.936c0.692-0.753,1.151-1.26,1.472-1.644
c0.317-0.39,0.489-0.665,0.648-0.918c0.316-0.503,0.581-0.921,1.834-1.983c-0.293,0.152-0.586,0.305-1.182,0.887
c0.658-0.672,1.884-1.857,3.26-3.063c1.397-1.186,2.873-2.461,3.893-3.475c0.156-0.354,0.133-0.551,0.772-1.511
c1.396-1.104,2.738-2.149,3.098-2.054c-1.213,0.979-1.46,1.361-2.21,2.055c0.415,0.183,1.71-1.096,0.943,0.104
c2.477-2.076,2.405-2.361,3.268-3.119c0.089,0.006,0.733-0.477,1.222-0.77c0.489-0.295,0.781-0.452,0.091,0.111
c0.556-0.152,1.725-1.744,4.056-3.322c0.139,0.125,0.747-0.268,1.562-0.842c0.834-0.547,1.866-1.285,2.799-1.926
c-3.855,2.391-0.236,0.132-1.473,0.354c1.7-1.258,2.816-1.369,2.392-1.831c1.873-1.196,3.523-1.91,6.373-3.783
c-0.617,0.95-2.092,1.213-4.403,2.809c1.468-0.382,1.378-0.31,4.021-1.777c-0.122,0.257-1.515,1.079-2.445,1.697
c1.096-0.428,2.747-1.344,3.378-1.376c0.718-0.483,0.446-0.527,0.499-0.716c0.804-0.549,1.339-0.824,1.663-0.909
c0.321-0.094,0.421-0.015,0.381,0.173c3.652-1.967,5.215-3.126,7.828-3.996c1.827-1.101-1.829,0.633-1.48,0.154
c2.323-0.742,5.145-2.059,8.184-3.182c1.508-0.583,3.013-1.244,4.508-1.813c1.497-0.558,2.953-1.101,4.296-1.603
c1.196-0.502,1.521-0.798,1.291-0.962c0.841,0.059,4.033-0.998,7.598-2.237c1.809-0.535,3.717-1.1,5.458-1.616
c0.437-0.124,0.865-0.245,1.278-0.362c0.418-0.092,0.822-0.18,1.205-0.266c0.772-0.164,1.47-0.301,2.063-0.396
c-0.107,0.146-1.293,0.391-2.128,0.605c1.692-0.199,6.655-1.24,6.419-1.688c0.412,0.109,6.936-0.855,2.175,0.005
c2.307-0.239,4.783-0.749,7.276-1.017c2.484-0.318,4.945-0.674,7.179-0.613c-0.866,0.113-1.627,0.197-2.338,0.266
c-0.707,0.096-1.363,0.184-2.019,0.272c-1.312,0.178-2.628,0.356-4.36,0.687c1.844-0.392,0.968-0.332-0.204-0.17
c-1.177,0.134-2.617,0.541-1.95,0.514c-1.462,0.122-3.437,0.392-5.714,0.746c-2.262,0.426-4.809,1.027-7.46,1.611
c-2.607,0.747-5.33,1.443-7.9,2.245c-1.282,0.412-2.536,0.816-3.735,1.204c-1.204,0.378-2.362,0.719-3.413,1.111
c0.517-0.1,1.264-0.355,2.097-0.568c-1.878,1.074-2.71,0.756-4.158,1.277c-1.745,0.723,0.973-0.195,0.086,0.416
c-0.743,0.209-1.32,0.325-2.027,0.516c-0.711,0.188-1.557,0.439-2.789,1.029c-1.715,0.813,0.953-0.234,0.095,0.422
c-0.974,0.301-1.993,0.666-3.042,1.076c-1.044,0.416-2.137,0.833-3.187,1.385c-1.057,0.538-2.129,1.084-3.198,1.628
c-1.061,0.555-2.133,1.079-3.129,1.682c0.626-0.178,2.313-1.044,3.33-1.421c0.75-0.26-0.077,0.141-1.25,0.687
c-1.19,0.516-2.64,1.343-3.269,1.674c0.308-0.278,0.598-0.552,0.521-0.681c-1.824,1.063-4.142,2.432-5.103,2.865
c0.142-0.313,1.141-0.596,1.212-0.865c-1.157,0.298-4.926,3.053-5.175,2.629c0.866-0.387,2.332-1.33,3.093-1.989
c-0.325-0.044-2.844,1.819-2.035,0.782c-0.513,0.736-3.31,2.34-4.863,3.383c0.52-0.074,1.706-0.912,3.183-1.773
c-0.928,0.813-1.857,1.494-2.793,2.066c-0.937,0.57-1.874,1.043-2.749,1.525c0.19-0.342,0.792-0.508,1.809-1.289
c0.165-0.533-2.187,0.769-4.427,2.338c-2.213,1.608-4.387,3.363-4.092,3.395c-0.199,0.478-1.793,1.297-0.706,0.261
c-1.598,0.981-6.054,5.361-5.931,4.345c-1.723,1.514-0.438,0.969-2.571,2.764c-0.057,0.877,0.952,0.371,2.739-1.156
c0.04-0.217,0.178-0.438,0.504-0.749c0.339-0.3,0.877-0.678,1.703-1.227c0.045,0.112-0.635,0.67-1.054,1.07
c1.128-0.577,4.905-4.28,5.255-3.95c-1.313,1.199-1.315,0.863-2.842,2.313c5.889-4.801,2.316-1.257-0.984,1.163
c0.231-0.167,0.384-0.751-0.191-0.292c-1.358,1.117-1.109,1.209-2.656,2.568c-0.671-0.196-3.78,2.736-2.874,0.966
c-0.892,0.801-0.865,0.975-1.545,1.629c0.986-0.534,1.509-0.47,3.155-1.869c-1.015,1.165-2.182,2.22-3.354,3.396
c-1.174,1.174-2.451,2.373-3.836,3.67c-2.69,2.663-5.773,5.783-8.92,10.115c0.995-0.432,5.077-5.367,4.49-3.734
c2.531-3.029,1.56-2.734,3.356-3.887c0.587-0.863-0.334,0.084,0.731-1.395c1.733-1.469,3.68-3.187,5.683-5.047
c1.037-0.896,2.078-1.84,3.102-2.822c1.012-0.996,2.033-2.002,3.054-3.004c-0.708,0.84-0.224,0.682,1.282-0.656
c0.507-0.486,0.356-0.725-0.18-0.305c-0.008-0.236,4.086-3.431,2.391-1.626c1.657-1.278,3.078-2.294,4.432-3.334
c0.686-0.507,1.353-1.027,2.081-1.517c0.737-0.475,1.507-0.955,2.351-1.453c-0.127-0.261,1.89-1.476,3.62-2.437
c0.777-0.633,1.105-1.068,1.659-1.604c3.553-1.913,7.268-4.036,10.488-5.786c3.291-1.613,6.023-2.963,7.7-2.945
c1.3-0.543,3.296-1.832,1.898-1.226c-0.17-0.159,2.318-1.146,3.287-1.596c0.001,0.099-0.813,0.419-1.312,0.689
c-0.96,0.54-0.862,0.772-1.184,1.129c-1.564,0.502-3.217,1.083-4.858,1.823c-1.629,0.769-3.287,1.607-4.954,2.45
c-1.663,0.847-3.26,1.835-4.887,2.688c-0.813,0.43-1.617,0.855-2.407,1.271c-0.778,0.436-1.554,0.849-2.324,1.229
c-0.085,0.239-0.749,0.834-2.919,2.288c0.641-0.614,0.595-0.72-0.188-0.41c-1.584,1.096-3.519,2.438-3.455,2.735
c-2.607,1.291-4.573,2.793-8.79,6.409c0.06-0.299-0.62,0.278-0.729,0.179c-2.134,1.856-4.222,3.55-6.132,5.399
c-0.976,0.9-1.948,1.796-2.924,2.696c-0.953,0.923-1.879,1.883-2.843,2.836c-3.87,3.793-7.687,8.096-12.071,13.506
c-0.991,1.424,0.367,0.002,1.044-0.996c-0.443,1.389-2.632,3.469-1.991,2.119c-1.452,2.123-2.851,4.076-4.051,6.041
c-1.228,1.948-2.519,3.734-3.684,5.563c-0.372,0.708-0.049,0.592-0.531,1.432c0.229,0.082,1.278-2.053,1.393-1.67
c0.206-0.448,0.434-0.918,0.716-1.381c0.291-0.459,0.61-0.927,0.953-1.398c0.682-0.941,1.45-1.903,2.234-2.848
c0.766-0.958,1.615-1.857,2.426-2.704c0.806-0.856,1.56-1.667,2.206-2.409c0.853-1.957,1.472-1.813,3.956-5.213
c-0.612,0.201-1.486,1.233-2.483,2.512c-1.033,1.254-2.011,2.887-3.083,4.129c-0.163-0.304,1.396-2.037,1.677-2.375
c-0.268,0.187-0.525,0.359-1.014,1.012c0.108-0.426,0.698-1.302,1.302-2.061c0.636-0.738,1.268-1.371,1.388-1.371
c0.81-1.33-0.997,0.447-0.878,0.137c-0.448,0.076,2.82-3.236,0.537-0.49c0.6-0.438,2.004-2.348,2.867-3.273
c0.552-0.173-2.18,2.649-1.63,2.481c2.197-2.421,4.359-5.014,5.899-6.379c-0.658,0.229-0.674,0.218-0.473-0.554
c0.405-0.432,0.555-0.461,1.034-1.018c-0.024,0.188,0.245-0.127,0.568-0.447c-0.536,0.674-0.463,0.781-0.063,0.463
c0.424-0.297,1.22-0.979,2.171-1.848c0.955-0.865,2.053-1.931,3.118-2.963c1.102-0.992,2.148-1.979,2.92-2.77
c0.048,0.109-0.589,0.691-0.978,1.104c-0.734,0.848,0.24,0.01-0.25,0.893c-1.267,1.164-2.19,2.035-3.126,2.914
c-0.94,0.876-1.89,1.764-3.115,3.053c0.863-0.621,2.134-1.85,3.309-2.935c0.588-0.543,1.15-1.052,1.624-1.433
c0.487-0.364,0.895-0.594,1.134-0.615c-0.521,0.488-1.031,0.999-1.517,1.535c2.074-1.941,1.493-1.152,2.822-2.148
c-0.631,0.758-1.258,1.372-1.856,1.945c-0.583,0.588-1.163,1.105-1.748,1.609c-1.162,1.016-2.394,1.942-3.636,3.334
c-0.701,0.902-0.155,0.762-1.713,2.38c0.038-0.245,0.875-1.151,0.783-1.287c-0.645,0.206-0.523,1.041-1.793,2.285
c0.192-0.492-0.418,0.187-1.387,1.3c-0.918,1.154-2.162,2.764-3.344,4.059c0.062-0.297-0.168-0.178-0.578,0.199
c-0.412,0.374-1.002,1.008-1.606,1.776c-1.232,1.516-2.711,3.424-3.584,4.378c1.377-2.328-0.517,0.344,0.142-0.845
c-1.406,1.655-2.826,3.83-4.356,6.119c-0.734,1.163-1.413,2.405-2.126,3.632c-0.684,1.244-1.447,2.45-2.049,3.707
c-0.629,1.243-1.247,2.457-1.833,3.615c-0.3,0.575-0.592,1.136-0.875,1.679c-0.264,0.552-0.52,1.087-0.764,1.597
c-0.989,2.042-1.882,3.691-2.627,4.679c-1.407,3.612-2.728,6.646-3.648,9.499c-0.496,1.412-0.967,2.754-1.422,4.052
c-0.394,1.316-0.778,2.592-1.157,3.853c-0.361,1.268-0.799,2.503-1.099,3.793c-0.317,1.29-0.638,2.596-0.973,3.955
c-0.165,0.681-0.334,1.372-0.507,2.082c-0.156,0.713-0.279,1.451-0.424,2.207c-0.279,1.512-0.574,3.107-0.889,4.819
c-0.125,0.707,0.082,0.614,0.159,0.8c-0.611,4.898-1.031,6.348-1.171,10.902c0.11-1.199,0.26-2.506,0.417-3.84
c0.221-1.326,0.446-2.682,0.663-3.989c0.216-1.304,0.377-2.565,0.532-3.693c0.183-1.126,0.299-2.13,0.312-2.941
c0.253-2.065,0.514-1.799,0.768-3.557c0.085-1.707,0.145,1.155-0.507,2.876c0.332-0.814,0.604-2.368,1.048-4.155
c0.422-1.793,0.906-3.843,1.346-5.713c1.033-3.705,2.002-6.654,2.274-5.295c-1.197,4.494-2.382,8.957-3.512,13.208
c-0.5,2.142-1.042,4.227-1.492,6.249c-0.378,2.033-0.726,3.991-1.001,5.855c-0.064,0.907,0.003,1.523,0.252,1.539
c0.517-1.165,0.39-3.857,0.696-5.764c0.066,0.852,0.203,0.695,0.358-0.645c0.363-0.211,0.134,1.389,0.588,0.895
c-0.441,1.289-0.879,3.424-1.23,5.615c0.719-1.574,0.448-0.68,0.032,1.477c0.349-0.109,0.553-0.869,0.69-1.937
c0.188-1.06,0.317-2.429,0.435-3.768c0.119-1.336,0.212-2.643,0.365-3.565c0.185-0.919,0.385-1.458,0.626-1.275
c-0.172,0.622-0.338,1.238-0.516,1.353c0.004,1.915-0.08,3.235-0.284,4.944c-0.086,0.855-0.259,1.805-0.356,2.982
c-0.091,1.18-0.2,2.578-0.334,4.319c0.564-0.115,0.248-5.596,1.133-5.574c-0.162,1.661-0.23,3.153-0.396,4.521
c-0.164,1.373-0.316,2.631-0.463,3.845c-0.202,2.435-0.445,4.694-0.449,7.308c0.176,0.143,0.233-0.123,0.285-0.682
c0.397,2.74-0.499,1.395-0.28,5.713c0.289-0.403,0.423-1.473,0.659-2.086c0.057-3.363-0.059-7.314,0.054-10.756
c0.14-1.531,0.392-6.359,1.005-6.308c-0.249,1.891-0.842,4.301-0.205,5.009c-0.654,3.295-0.224,9.946-0.357,14.236
c0.562,0.59-0.017-3.782,0.633-2.779c-0.091,2.317,0.143,2.958,0.469,4.998c0.179-1.629-0.281-4.43,0.395-4.91
c0.033,0.945,0.065,1.888,0.097,2.826c0.562-0.612,0.188-3.395,0.599-3.449c-0.245,5.963,0.891,13.868,1.25,18.887
c0.481,1.334,0.368,0.545,0.652,2.771c0.097-0.013,0.197-0.02,0.297-0.02c-0.125-0.789-0.278-1.661-0.395-2.598
c-0.087-0.941-0.178-1.939-0.271-2.964c-0.18-2.05-0.377-4.204-0.365-6.236c0.672,4.177,1.359,10.028,2.41,16.388
c1.197,6.32,2.871,13.131,5.344,18.965c0.481,0.455,1.758,3.976,0.98,3.337c-0.484-0.749-0.908-1.585-1.286-2.466
c-0.336-0.894-0.631-1.832-0.915-2.767c-0.552-1.877-1.126-3.72-1.687-5.296c-0.145,0.746-0.503-0.098-0.994-1.625
c-0.236-0.766-0.524-1.696-0.828-2.684c-0.315-0.986-0.538-2.057-0.838-3.055c0.633,1.332,0.222-0.342,0.437-0.751
c-0.416-1.555-0.606-2.624-0.804-3.71c-0.143-1.098-0.291-2.207-0.658-3.848c-0.202,0.009-0.399,0.014-0.599,0.033
c0.607,2.071,0.525,3.482,0.184,4.197c-0.514-1.623-0.221-1.285-0.804-3.465c-0.158,1.549-1.338-1.125-0.68,2.879
c0.174,0.477,0.47,0.975,0.669,1.096c0.206,0.119,0.244-0.122,0.08-1.159c0.598,1.355,0.931,3.161,1.217,4.776
c0.297,1.609,0.601,3.008,1.374,3.434c0.8,2.765,1.507,5.666,2.549,8.526c0.499,1.437,0.95,2.906,1.496,4.352
c0.567,1.438,1.137,2.882,1.709,4.332c0.539,1.466,1.214,2.875,1.854,4.3c0.66,1.416,1.245,2.864,1.958,4.246
c1.425,2.762,2.758,5.543,4.308,8.129c-0.382-1.297-0.792-2.305-1.19-3.201c-0.374-0.908-0.774-1.68-1.209-2.457
c-0.851-1.557-1.918-3.096-2.899-5.826c0.96,1.246,0.639,0.309,0-1.215c-0.323-0.76-0.712-1.674-1.077-2.529
c-0.346-0.863-0.598-1.697-0.73-2.26c0.955,1.488,0.436-0.766,1.166,0.391c0.374,0.887,0.758,1.813,1.115,2.734
c0.338,0.928,0.672,1.844,0.978,2.685c0.314,0.838,0.602,1.606,0.938,2.198c0.35,0.586,0.707,1.016,1.094,1.209
c0.833,1.607,1.568,3.305,2.352,4.953c0.813,1.635,1.615,3.248,2.375,4.777c0.764,1.533,1.642,2.896,2.432,4.133
c0.816,1.219,1.572,2.295,2.432,3.034c0.612,0.712,1.324,1.817,2.108,2.962c0.394,0.57,0.798,1.155,1.227,1.693
c0.45,0.521,0.912,1.008,1.38,1.407c0.506,0.985,0.468,1.546,2.504,4.173c0.666,0.127-0.649-1.303-0.899-1.904
c0.239-0.1,0.922,0.879,1.68,1.793c0.761,0.915,1.522,1.83,1.751,1.732c1.1,1.169,0.118,0.857,0.975,1.854
c0.261-0.061,0.859,0.512,1.704,1.327c0.851,0.808,1.897,1.907,2.938,3.009c0.523,0.547,1.028,1.113,1.538,1.609
c0.518,0.49,1.006,0.947,1.438,1.332c0.866,0.773,1.509,1.264,1.713,1.195c1.002,0.998,0.636,1.25,1.659,2.232
c1.273,0.949-0.444-0.729,0.91,0.1c0.915,0.767,1.808,1.512,2.683,2.244c0.869,0.738,1.709,1.475,2.594,2.137
c0.87,0.68,1.727,1.352,2.579,2.021c0.854,0.668,1.689,1.352,2.589,1.954c0.888,0.618,1.778,1.237,2.677,1.864
c0.907,0.617,1.806,1.268,2.791,1.822c0.975,0.567,1.969,1.145,2.986,1.736c1.021,0.588,2.074,1.174,3.213,1.686
c-1.041-0.869-2.063-1.605-3.163-2.203c-1.099-0.595-2.27-1.063-3.531-1.502c-1.089-0.957-1.911-1.904-2.96-2.879
c-1.131-0.701-2.48-1.648-2.617-1.19c-0.626-1.371-3.426-2.586-5.896-4.784c0.101-0.38,0.45-0.005,1.055,0.542
c0.6,0.555,1.411,1.342,2.334,1.891c-0.458-0.74-3.156-2.469-1.581-2.297c-0.801-1.015-1.637-1.742-3.043-2.75
c0.602,0.016,1.696,0.791,2.827,1.766c1.176,0.917,2.394,2.033,3.173,2.818c-0.707,0.15,0.657,1.18-0.593,0.635
c0.54,0.406,1.101,0.828,1.68,1.264c0.584,0.428,1.215,0.828,1.846,1.262c1.268,0.855,2.598,1.754,3.972,2.68
c1.412,0.863,2.869,1.753,4.349,2.656l1.109,0.686l1.148,0.635c0.767,0.425,1.536,0.85,2.306,1.277
c6.166,3.4,12.557,6.426,17.64,8.65c-0.338,0.125-1.257-0.176-1.534-0.008c-1.986-1.132-4.768-2.621-7.76-3.881
c-1.46-0.703-2.945-1.411-4.381-2.053c-0.715-0.328-1.427-0.62-2.09-0.938c-0.66-0.324-1.291-0.636-1.882-0.926
c0.536,0.414,1.222,0.84,1.981,1.277c0.78,0.401,1.636,0.805,2.491,1.216c1.719,0.804,3.393,1.718,4.567,2.493
c0.152,0.031-0.85-0.313-1.866-0.724c-1.025-0.394-2.122-0.735-2.253-0.476c0.763,0.666,2.193,1.591,4.202,2.41
c-0.588,0.529-1.688-0.717-3.342-1.182c-0.089,0.336,2.047,1.158,2.333,1.52c2.372,0.277,2.612,1.125,5.765,2.531
c-0.525,0.509-1.986-0.371-1.169,0.694c1.035,0.479,1.216,0.296,2.057,0.616c-0.349-0.332-1.422-0.75-1.058-0.988
c1.749,0.617,2.029,1.055,1.628,1.402c1.461,0.096,3.854,1.969,4.177,1.043c-0.413-0.326-2.954-1.372-2.855-2.081
c1.6,0.537,1.271,0.852,3.171,1.419l0.161-0.33c-0.371-0.224-7.543-3.188-5.458-1.521c-0.79-0.556-2.012-1.295-3.388-2.108
c-1.369-0.827-2.932-1.647-4.267-2.64c1.021,0.695,3.985,1.791,6.95,3.12c1.519,0.577,3.05,1.192,4.367,1.816
c0.659,0.312,1.264,0.623,1.789,0.933c0.535,0.279,0.987,0.559,1.328,0.836c2.069,0.856,1.175-0.237,2.29-0.013
c1.019,0.415,1.915,0.821,2.638,1.219c0.731,0.382,1.3,0.718,1.638,1.077c1.314,0.278,1.841,0.095,3.499,0.584
c-0.291-0.587,0.531-0.547,1.799-0.281c1.288,0.199,3.035,0.599,4.575,0.84c0.327,0.663-2.401-0.303-1.644,0.586
c2.129,1.238,3.255,0.377,6.182,1.063c-0.833-0.281-1.596-0.567-2.331-0.854c-0.721-0.326-1.424-0.645-2.146-0.971
c3.318,0.4,10.808,1.504,12.927,2.107c0.246-0.184,0.826-0.222,1.571-0.182c0.744,0.049,1.658,0.167,2.58,0.237
c1.836,0.177,3.677,0.421,4.146,0.278c2.232,0.818,0.996,1.201,5.369,1.33c0.474-0.492-0.19-0.698-2.001-0.611
c0.743-0.386,3.108-0.476,2.735,0.241c0.819-0.017,0.631-0.343,0.754-0.565c1.3,0.349,4.208,0.066,3.366,0.953
c0.901-0.307,3.658-0.131,2.82-0.871c0.662,0.106,1.905,0.288,2.879,0.277c0.974-0.057,1.673-0.276,1.259-0.87
c2.946-0.438,2.635,0.327,4.801,0.189c1.521-0.151,0.277-0.859,0.672-1.237c2.516-0.315,1.716,0.053,1.375,0.608
c1.455-0.105,1.288-0.457,1.344-0.779c1.563,0.489,3.591,0.063,4.812,0.326c2.521-0.632-0.013-0.7,1.274-1.248
c1.185,0.119,3.513-0.013,5.29-0.379c1.767-0.428,2.984-0.999,2.017-1.529c2.873-0.201,2.451-1.291,4.458-2.138
c0.968-0.028,1.354,0.32,1.519,0.811c0.387-0.649,1.95-1.001,3.788-1.349c1.83-0.369,3.9-0.873,5.308-1.562
c-0.525-1.074-5.169,1.681-6.605,1.545c0.02-0.506,1.045-0.674,1.936-0.872c0.879-0.23,1.631-0.455,1.121-0.945
c1.435-0.469,2.829-0.941,4.2-1.423c1.369-0.485,2.674-1.095,3.999-1.647c2.666-1.072,5.203-2.463,7.923-3.818
c5.34-2.928,11.152-6.342,18.309-11.241c-0.069-0.401-1.197,0.19-0.322-0.556c1.92-1.371,0.145,0.377,1.947-0.629
c0.9-0.825,1.852-1.6,2.745-2.474c0.898-0.864,1.808-1.738,2.727-2.622c0.461-0.438,0.926-0.876,1.392-1.318
c0.446-0.463,0.896-0.93,1.348-1.398c0.904-0.926,1.818-1.864,2.742-2.813c0.923-0.942,1.781-1.972,2.693-2.958
c0.885-1.014,1.849-1.962,2.696-3.041c1.741-2.109,3.568-4.188,5.236-6.453c6.926-8.844,13.106-18.994,18.175-29.953
c2.466-5.514,4.648-11.223,6.508-17.074c0.446-1.469,0.832-2.957,1.264-4.434c0.205-0.741,0.432-1.478,0.626-2.222l0.52-2.252
c0.333-1.506,0.746-2.992,1.033-4.509l0.85-4.553c0.972-6.092,1.769-12.233,1.928-18.383l0.115-2.303
c0.027-0.766-0.002-1.531,0.003-2.297c-0.001-1.532-0.002-3.059-0.003-4.584c-0.015-1.521-0.151-3.035-0.211-4.547
c-0.096-1.508-0.098-3.025-0.298-4.514c-0.786-0.822-1.7-1.115-1.895-4.422c0.755-0.64,1.038-2.32,0.975-4.568
c-0.031-1.125-0.138-2.391-0.333-3.732c-0.268-1.321-0.617-2.718-1.032-4.116c-0.412,0.352,0.726,2.918-0.501,2.085
c0.18,0.843,0.343,1.526,0.488,2.12c0.131,0.595,0.209,1.111,0.295,1.609C294.002,135.07,294.096,135.996,294.101,137.416z
M13.201,194.357c0.314-0.1,0.153-1.145-0.08-2.713c-0.212-1.57-0.565-3.655-0.824-5.787c-0.474,1.404-0.418,3.451-1.263,2.375
c0.201,1.404,0.407,2.857,0.617,4.326c0.276,1.459,0.556,2.934,0.834,4.397c0.304,1.455,0.527,2.923,0.929,4.296
c0.372,1.381,0.755,2.714,1.153,3.962c-1.055-3.671-0.814-4.628,0.142-2.397c-0.729-2.303-0.6-2.779-0.984-4.594
c-0.609-1.875-0.151,1.438-0.766-0.708c-0.089-1.865,0.823,0.991,0.739-0.86c0.153,0.943,1.051,4.734,0.649,2.287
C13.945,196.585,13.598,196.711,13.201,194.357z M53.222,69.524c1.015-1.214,1.68-1.928,2.268-2.613
c0.596-0.679,1.155-1.3,2.096-2.206C57.476,64.162,53.059,68.972,53.222,69.524z M50.597,242.222
c0.112-0.069,0.86,1.125,1.655,2.297c0.769,1.188,1.726,2.246,2,2.072c-0.493-0.572-0.998-1.129-1.398-1.682
c-0.4-0.551-0.75-1.063-1.006-1.48c-0.512-0.835-0.656-1.306-0.124-1.034c-0.681-1.047-1.242-1.908-1.736-2.67
c-0.5-0.754-0.853-1.455-1.232-2.1c-0.725-1.311-1.391-2.508-2.358-4.25c-0.704-0.711-0.606-0.238-0.491,0.229
c0.132,0.456,0.283,0.906-0.387,0.177c-0.398-1.261-0.831-2.55-1.36-3.935c-0.548-1.373-1.146-2.867-1.827-4.564
c-0.772,1.334,1.749,6.332,3.903,10.486c0.651,0.071-0.265-1.561,0.877-0.422c0.786,1.359,1.888,3.279,2.75,4.707
C50.731,241.478,51.234,242.503,50.597,242.222z M291.349,126.71c-0.609,0.205-0.73,1.098-0.348,2.688
C291.848,129.549,291.968,128.658,291.349,126.71z M282.423,222.108c0.794-1.692,2.189-3.941,1.661-4.351
C283.64,218.915,281.588,223.189,282.423,222.108z M103.397,287.056c0.649,0.367,1.257,0.688,1.855,0.929
c0.599,0.235,1.169,0.434,1.704,0.629c1.074,0.392,2.016,0.767,2.837,1.392c-1.852-1.301-2.18-2.204-4.768-2.922
c1.545,0.76,1.102,0.62,0.274,0.391C104.489,287.208,103.32,286.789,103.397,287.056z M49.353,240.497
c0.418-0.037-1.872-3.917-2.438-4.273C48.138,238.037,47.523,238.169,49.353,240.497z M68.846,262.857
c-0.753-0.774-1.597-1.643-2.47-2.541c-0.828-0.94-1.687-1.906-2.532-2.813c-0.835-0.918-1.702-1.734-2.433-2.486
c-0.737-0.746-1.425-1.346-2.024-1.717c0.62,0.773,0.234,0.818,0.523,1.354c1.389,1.152,2.868,2.458,4.328,3.895
c0.729,0.721,1.472,1.457,2.222,2.199C67.244,261.457,68.037,262.168,68.846,262.857z M28.771,196.384
c-0.174,0.113-0.194,0.48-0.269,0.473c-0.073-0.01-0.219-0.389-0.517-1.806c-0.434,0.038-0.434,1.13-0.043,2.513
c0.364,1.388,0.975,3.096,1.513,4.426c-1.119-3.449,1,1.371,0.404-0.787C29.65,200.634,29.256,198.707,28.771,196.384z
M40.433,226.773c0.897,0.58-1.848-4.389-2.141-4.457C39.394,224.268,39.178,224.628,40.433,226.773z M284.817,80.605
c0.078-0.59-1.245-2.605-2.385-4.02c-0.556-0.715-1.122-1.239-1.42-1.38c-0.296-0.143-0.333,0.103,0.096,0.97
C282.621,77.025,283.888,80.037,284.817,80.605z M55.471,67.728c0.533-0.652,1.347-1.541,1.33-1.736
c-2.181,2.48-4.516,4.537-6.082,7.147c1.338-1.61-0.556,1.02-0.185,0.833c1.988-2.924,3.976-5.095,5.483-6.697
C55.729,67.599,55.451,67.908,55.471,67.728z M21.686,152.238c-0.153,0.98-0.181,1.76-0.198,2.426
c-0.017,0.668-0.013,1.221-0.009,1.758c0.007,1.074,0.013,2.074-0.167,3.766c0.583,0.486,0.205-7.393,0.804-4.244
c0.005-1.368,0.179-1.564,0.213-2.861C22.071,152.089,22.065,153.14,21.686,152.238z M20.176,161.494
c-0.166-2.566-0.023-4.311,0.041-5.911c0.041-0.798,0.078-1.564,0.097-2.38c0.057-0.814,0.093-1.682,0.09-2.688
c-0.106,1.16-0.289,2.299-0.338,3.393c-0.063,1.094-0.119,2.137-0.151,3.096C19.867,158.923,19.844,160.509,20.176,161.494z
M30.364,104.779c0.46-1.154,1.342-2.715,2.092-4.216c0.768-1.494,1.429-2.92,1.717-3.7c-1.169,2.473-0.452-0.242-1.055,1.039
c0.288,0.369-0.331,1.694-1.066,3.123C31.312,102.451,30.46,103.988,30.364,104.779z M30.139,104.641
c0.478-1.176,1.002-2.153,1.405-3.038c0.41-0.881,0.737-1.652,0.967-2.354C32.096,99.877,30.069,103.88,30.139,104.641z
M25.346,118.557c-0.62,2.412-1.362,4.852-1.514,6.493C24.436,123.113,25.41,119.806,25.346,118.557z M46.557,75.111
c-0.171-0.377,0.434-1.166,0.239-1.824c-0.504,0.85-1.177,1.828-1.909,2.847c-0.733,1.017-1.54,2.063-2.211,3.108
c-1.385,2.063-2.581,3.885-2.724,4.646c0.995-1.762,2.05-3.169,3.129-4.542c0.271-0.342,0.543-0.683,0.815-1.027
c0.29-0.332,0.581-0.668,0.877-1.009C45.362,76.624,45.956,75.905,46.557,75.111z M60.578,56.768
c-0.637,0.282-1.956,1.542-3.456,3.06c-0.766,0.745-1.524,1.604-2.258,2.443c-0.738,0.836-1.461,1.646-2.112,2.34
c0.995-0.826,2.141-1.854,3.513-3.187c1.414-1.296,3.113-2.844,5.107-4.807c-0.37-0.071-4.229,3.83-2.995,2.108
C58.594,58.816,60.162,56.962,60.578,56.768z M50.204,67.291c-0.174-0.611-2.876,2.658-2.86,3.423
c1.352-1.929,1.394-1.962,1.644-1.493c0.523-0.693,1.322-1.64,1.297-1.84C48.72,69.487,49.183,68.072,50.204,67.291z
M60.079,56.093c1.968-1.784,1.958-1.906,3.819-3.529C63.77,52.224,59.957,55.543,60.079,56.093z M52.516,64.332
c0.668-0.555,1.82-1.885,3.015-3.279C55.973,59.959,52.394,64.189,52.516,64.332z M43.815,74.5
c-0.591,0.863-1.344,1.705-2.015,2.515c-0.634,0.833-1.199,1.626-1.455,2.345c0.512-0.744,0.357-0.296,0.191,0.162
C42.951,76.248,41.903,77.238,43.815,74.5z M40.824,76.793c-0.748,1.186-1.31,2.168-1.46,2.704
c0.821-1.261,0.783-0.585,1.83-2.365L40.824,76.793z M32.876,89.611c0.169,0.408,0.798-0.684,1.787-2.4
C34.533,86.817,33.759,87.844,32.876,89.611z M33.954,86.666c0.822-1.73,1.878-2.48,1.912-3.272
c-1.419,2.231-0.787,0.478-0.487-0.411c-0.536,0.952-1.186,2.049-1.843,3.15c-0.655,1.104-1.334,2.203-1.831,3.215
c-1.035,2-1.702,3.445-1.206,3.176c-0.596,0.902-2.285,2.886-1.161,1.943c-0.641,1.086-1.92,3.834-2.961,6.146
c-1.011,2.327-1.609,4.29-1.167,3.702c0.306-0.938,0.709-1.866,1.266-3.046c0.576-1.174,1.25-2.623,1.996-4.666
c0.063,0.703,0.85-1.226,1.068-1.085c0.781-2.002,2.059-4.172,3.18-6.097c1.15-1.913,2.294-3.506,2.685-4.482
c-0.114,0.813,2.208-3.715,0.764-1.313C35.243,85.128,34.418,86.368,33.954,86.666z M42.116,72.852
c-0.673,1.134-0.69,1.618-0.879,2.273c0.807-1.074,2.132-3.169,1.949-2.283c-0.912,0.913-2.862,3.687-2.96,4.411
c0.572-1.006,1.589-2.277,2.45-3.467c0.871-1.184,1.629-2.256,1.834-2.78C42.907,73.217,43.128,71.816,42.116,72.852z
M23.555,107.721c-0.13-0.124,0.208-1.001-0.059-0.905c-0.28,0.714-0.595,1.414-0.829,2.143c0.166,0.278,0.54-0.673,0.722-0.47
c0.404-1.067,1.091-2.586,1.021-2.883C24.128,106.304,23.843,107.013,23.555,107.721z M314.102,99.843
c-1.472-2.822-1.342-5.434-2.688-5.841c0.725,1.407,0.292,1.581,0.454,2.384C312.537,96.502,313.482,100.457,314.102,99.843z
M64.298,46.375c-0.884,0.738-2.144,1.839-2.221,2.135c1.553-1.489,1.777-1.539,1.557-1.26c-0.22,0.281-0.786,0.996-0.736,1.129
C64.856,46.537,65.217,46.056,64.298,46.375z M60.449,51.476c2.077-1.725,2.004-1.928,3.963-3.527
c0.015-0.482,1.731-2.063-0.375-0.593C64.158,48.082,61.065,50.339,60.449,51.476z M54.167,56.93
c1.368-1.313,5.152-5.233,2.941-2.349c0.433-0.412,1.049-1.078,1.129-0.998c1.864-1.803,1.863-2.238,2.429-3.115
c-0.88,0.859-1.598,1.525-2.214,2.081c-0.615,0.559-1.092,1.043-1.554,1.472C55.987,54.892,55.224,55.638,54.167,56.93z
M54.473,56.102c0.941-0.894,2.296-2.181,3.639-3.458c1.39-1.225,2.733-2.47,3.537-3.418c-0.594,0.37-2.224,1.694-3.813,3.104
C56.313,53.804,54.847,55.376,54.473,56.102z M41.592,71.27c0.467-0.644,1.1-1.491,1.809-2.376
c0.741-0.854,1.53-1.766,2.242-2.585c1.423-1.635,2.527-2.909,2.25-2.724C45.24,66.492,43.095,68.493,41.592,71.27z
M39.698,73.668c-0.749,0.908-1.824,2.41-2.846,3.873c-1.016,1.465-1.986,2.887-2.361,3.729c0.85-1.375,2.02-2.799,3.014-4.129
c0.5-0.665,0.964-1.303,1.336-1.895C39.221,74.658,39.542,74.139,39.698,73.668z M13.624,192.769
c0.275,1.444,0.53,1.662,0.809,3.032c0.652,0.383-0.071-2.275-0.498-4.61c-0.323-0.455-0.247,0.168-0.172,0.793
C13.857,192.603,13.971,193.222,13.624,192.769z M53.357,55.892c0.087-0.379-0.854,0.361-1.808,1.313
c-0.937,0.969-1.925,2.107-2.064,2.384c0.607-0.58,1.208-0.994,1.832-1.524C51.93,57.525,52.598,56.898,53.357,55.892z
M10.798,144.5c0.286,0.877,0.233,1.221,0.392,1.689c-0.282,2.236-0.621,3.844-0.953,5.528c-0.347,1.683-0.518,3.451-0.676,5.997
c0.273-0.135,0.281,0.928,0.536,0.863c0.106-2.242,0.604-0.664,0.856-3.762c-0.468-1.25,0.935-7.684-0.038-3.941
c0.006-2.592,0.284-2.964,0.565-6.207C10.999,145.597,11.232,142.259,10.798,144.5z M9.952,167.939
c-0.225-3.535,0.042-4.811-0.225-7.057c-0.212,3.418-0.491,8.178,0.032,11.719c0.791-0.518,0.142-4.34,0.974-0.715
c-0.4-2.465,0.042-7.995-0.233-6.232C10.48,167.253,10.136,167.17,9.952,167.939z M10.042,179.64
c0.263-0.23,0.412,1.061,0.619,1.57c-0.39-2.535-0.378-3.691-0.271-4.605c0.133-0.917,0.328-1.594,0.296-3.175
C9.921,173.332,9.814,176.484,10.042,179.64z M24.94,232.57c1.729,3.555,3.328,6.475,4.674,9.188
c-0.049-0.904,2.189,2.742,1.123,0.366c-0.787-1.171-1.503-2.542-2.278-3.956c-0.385-0.707-0.787-1.424-1.217-2.134
c-0.397-0.73-0.826-1.452-1.301-2.151c0.75-1.596-4.718-10.957-6.161-16.943c0.376,2.879-0.498-0.357-1.335-0.475
c1.197,2.382,2.1,6.69,3.482,8.003c-0.549-1.247-0.689-1.894-0.639-2.261c0.93,2.066,1.554,3.681,1.581,4.432
c-0.4-0.877-0.792-1.704-0.93-1.356c1.549,3.074,1.692,3.597,1.454,4.497c1.135,2.462,1.5,1.987,2.353,3.35
C25.551,233.228,25.249,232.908,24.94,232.57z M12.505,132.886c0.805-3.764-0.083,0.818,0.115,0.852
c0.308-2.2,0.568-1.923,0.87-3.783C13.268,129.529,12.327,132.001,12.505,132.886z M11.898,136.765
c-0.21,1.076-0.424,0.793-0.631,1.428c-0.161,2.5-0.339,2.598-0.77,5.44C11.109,144.128,12.126,138.453,11.898,136.765z
M144.853,319.197c-0.382,0.633,3.023,0.952,2.918,1.566c2.788,0.449,2.66-0.345,4.186-0.464c-3.246-0.466-2.976-0.382-5.476-1.06
C146.27,319.355,145.768,319.359,144.853,319.197z M33.041,247.128c0.419,0.679,0.835,1.348,1.259,2.033
c0.164-0.697,0.636-0.223,0.308-1.035C33.96,247.451,33.151,246.334,33.041,247.128z M39.452,257.156
c1.29,2.115,2.165,2.685,3.656,4.27C42.738,259.894,40.258,256.816,39.452,257.156z M30.002,242.457
c1.326,2.063,0.889,1.259,2.058,3.396c0.142-0.078,0.293-0.14,0.441-0.204C31.262,243.488,30.597,242.982,30.002,242.457z
M75.972,292.597c-1.339-1.393-3.252-2.158-5.028-3.921C71.01,289.525,75.097,292.855,75.972,292.597z M12.941,201.472
c0.814,2.389,0.26,1.9,0.514,3.115C14.973,207.709,12.83,199.82,12.941,201.472z M78.155,294.947
c-0.337,0.527,2.614,2.364,3.473,3.371c-0.398,0.094-2.201-1.742-2.347-0.938c3.235,2.439,10.808,6.623,15.361,9.051
c0.129-0.231,0.102-0.552,0.509-0.613c0.403-0.058,1.241,0.145,3.15,0.835c-0.574-0.282-1.225-0.602-1.938-0.952
c-0.7-0.375-1.46-0.784-2.267-1.217c-1.608-0.877-3.437-1.783-5.271-2.842c-1.822-1.081-3.711-2.202-5.555-3.297
C81.459,297.199,79.737,296.012,78.155,294.947z M289.019,275.656c1.632-1.732,2.793-2.98,3.819-4.113
c1.024-1.133,1.884-2.173,2.742-3.615c-4.646,5.166,0.471-0.855-0.88,0.316c-2.008,2.075-3.068,3.262-4.064,4.446
c-0.991,1.186-2.007,2.283-3.828,4.253C287.211,278.104,290.4,272.879,289.019,275.656z M295.223,269.968
c1.313-1.551,4.173-4.496,4.228-5.318c-0.505,0.379-1.651,1.592-2.618,2.773C295.874,268.609,295.001,269.691,295.223,269.968z
M308.011,86.765c-1.232-1.025-2.336-3.281-4.33-5.916c0.223-0.142-0.281-0.956-0.554-1.504c1.131,0.328,0.959,0.996,1.169,2.095
C305.676,81.851,305.99,83.742,308.011,86.765z M105.681,37.179c-1.807,0.758-1.953,0.859-4.272,2.188
c-0.437-0.271,1.66-0.776,1.714-1.29C103.422,38.197,105.809,36.738,105.681,37.179z M73.066,59.757
c-0.83,0.865-1.735,1.635-2.69,2.422c-0.479,0.395-0.966,0.796-1.464,1.209c-0.479,0.433-0.955,0.896-1.438,1.386
c-0.627,0.749,0.089,0.212,0.609-0.317c-0.223,0.721-1.697,1.681-2.394,2.442c-0.131-0.038,1.861-2.386,0.238-0.788
c1.026-1.475,2.857-2.562,5.396-5.28C71.119,61.564,72.293,60.38,73.066,59.757z M64.139,68.238
c0.452-0.438,0.408-0.33,0.161-0.035c-0.234,0.305-0.648,0.818-0.958,1.185c0.29-0.2,0.581-0.397,0.667-0.243
c-0.871,1.047-0.772,0.42-1.914,1.929c0.174-0.386,1.102-1.607,0.281-0.711C62.282,69.992,63.241,69.58,64.139,68.238z
M46.596,90.218c0.155-0.338,0.461-1.033,0.928-1.846c0.473-0.809,1.05-1.764,1.634-2.682c0.582-0.917,1.171-1.795,1.665-2.446
c0.512-0.64,0.947-1.039,1.157-1.05c-0.003-0.6,1.462-2.404,3.348-4.648c1.988-2.172,4.285-4.86,6.128-7.207
c-3.082,3.688-6.616,8.564-10.093,13.703c-1.639,2.635-3.394,5.256-4.861,7.96c-0.383,0.667-0.764,1.329-1.14,1.985
c-0.188,0.328-0.376,0.655-0.564,0.98c-0.17,0.332-0.341,0.664-0.51,0.994c-0.676,1.32-1.338,2.607-1.976,3.85
c-0.529,0.98-0.9,1.816-1.168,2.403c-0.268,0.585-0.429,0.925-0.454,0.952c-0.023,0.026,0.091-0.258,0.37-0.916
c0.14-0.328,0.32-0.753,0.545-1.28c0.252-0.515,0.553-1.126,0.906-1.849c0.927-1.885,2.335-5.28,0.093-0.775
c3.338-7.488,6.001-10.438,7.505-13.24C49.064,85.906,47.503,88.916,46.596,90.218z M87.709,270.27
c0.962-0.082,2.348,1.806,2.559,1.134c0.929,0.564,1.158,0.826,1.273,1.06c0.115,0.236,0.119,0.438,0.649,0.806
c-0.14,0.048-0.847-0.397-1.703-1.04C89.636,271.58,88.6,270.787,87.709,270.27z M102.878,279.044
c0.059,0.352-0.675,0.072-1.397-0.33c0.529,0.979,3.376,1.541,2.718,1.973c-1.385-0.756-2.402-1.398-3.401-2.151
C101.525,278.731,101.333,278.13,102.878,279.044z M263.943,71.581c-0.236-0.398,0.183-0.6,0.842-0.484
c0.653,0.12,1.502,0.601,2.176,1.493C266.286,73.255,264.81,71.41,263.943,71.581z M104.318,281.738
c1.508,1.018-3.04-0.514-3.835-1.551C100.632,279.77,104.146,282.208,104.318,281.738z M55.972,72.507
c0.245-0.02-2.647,3.416-1.459,1.505c-1.702,2.065-1.095,1.956-2.261,3.673c0.47-0.37,1.187-1.28,2.256-2.668
c-0.633,1.205,0.171,0.279,0.431,0.439c-0.386,0.406-0.791,0.802-1.356,1.593c0.09,0.871,2.512-2.595,3.823-4.024
c-0.946,1.209-1.896,2.422-2.85,3.642c-0.942,1.228-1.8,2.524-2.709,3.788l-1.351,1.908c-0.448,0.639-0.845,1.313-1.271,1.969
l-2.519,3.96l-2.326,4.079c-0.382,0.68-0.785,1.351-1.152,2.039L42.17,96.5c-0.696,1.394-1.433,2.764-2.093,4.163
c-0.635,1.415-1.267,2.823-1.897,4.224c-0.662,1.387-1.193,2.822-1.752,4.236c-0.541,1.419-1.143,2.807-1.634,4.224
c-0.931,2.85-1.999,5.61-2.733,8.415c-0.404,1.391-0.806,2.767-1.2,4.127c-0.33,1.379-0.657,2.739-0.979,4.083
c-0.715,2.673-1.136,5.333-1.662,7.884c-0.114,1.04,0.216-0.222,0.397,0.122c-0.083,0.674-0.169,1.157-0.259,1.528
c-0.072,0.375-0.15,0.64-0.23,0.873c-0.163,0.461-0.335,0.795-0.469,1.633c0.153,1.53-0.073,2.99-0.302,4.305
c-0.175,1.321-0.333,2.5-0.138,3.416c-0.235,0.98-0.651,0.547-0.646,3.406c-0.424-0.232-0.007-3.528,0.087-5.652
c-0.325-0.09-0.485,1.117-0.875,0.515c0.094-0.583,0.163-1.13,0.213-1.651c0.077-0.518,0.134-1.008,0.178-1.477
c0.089-0.938,0.122-1.793,0.155-2.61c0.068-1.633,0.097-3.12,0.692-4.771c0.046-0.638,0.094-1.277-0.028-1.465
c-0.331-0.135-0.635,1.211-0.936,2.858c-0.243,1.656-0.416,3.618-0.679,4.696c-0.07-0.758,0.091-2.227,0.297-3.832
c0.225-1.605,0.613-3.324,0.777-4.633c-0.405,0.434-0.826,1.764-1.202,3.26c-0.365,1.496-0.559,3.174-0.738,4.26
c0.064-1.104,0.144-2.216,0.237-3.342c0.118-1.123,0.309-2.25,0.482-3.406c0.189-1.152,0.38-2.328,0.578-3.538
c0.221-1.204,0.537-2.421,0.822-3.687c0.302-1.262,0.592-2.568,0.938-3.912c0.392-1.33,0.797-2.709,1.219-4.143
c0.211-0.717,0.425-1.449,0.644-2.193c0.235-0.74,0.512-1.484,0.772-2.25c0.531-1.527,1.083-3.119,1.659-4.781
c-0.506,1.697-1.245,4.002-1.969,6.174c-0.677,2.184-1.203,4.279-1.497,5.488c0.282-0.279,0.598-0.889,0.878-1.159
c-0.023-0.595,0.068-1.282,0.228-2.043c0.164-0.763,0.381-1.604,0.706-2.479c0.62-1.76,1.344-3.752,1.889-5.861
c0.053,1.105,0.311,0.406,1.237-1.742c0.603-1.57-0.406,0.063-0.745,1.204c0.605-1.771,1.222-3.089,1.915-4.618
c0.332-0.769,0.739-1.57,1.19-2.502c0.446-0.936,0.926-2.003,1.457-3.293c-0.385,0.16-1.313,1.629-2.21,3.354
c-0.466,0.854-0.864,1.796-1.17,2.67c-0.314,0.871-0.549,1.668-0.64,2.254c-0.401,0.957-0.435,0.047-0.863,1.144
c2.719-7.667,5.997-14.27,11.2-23.173c-0.168,0.015-0.11-0.292,0.173-0.774c-1.199,1.603-0.953,1.345-1.474,1.546
c-0.397,0.809-0.601,1.385-0.411,1.49c-0.664,0.786-1.351,1.75-2.055,2.826c-0.672,1.095-1.312,2.33-1.986,3.607
c-1.397,2.536-2.574,5.413-3.861,8.021c0.47-1.842-0.643-0.047-1.256,0.908c0.2-1.265,1.407-2.203,2.292-4.496
c0.02,0.554,0.39,0.01,0.947-1.121c0.566-1.127,1.404-2.8,2.283-4.55c0.441-0.874,0.857-1.786,1.318-2.622
c0.47-0.832,0.923-1.621,1.335-2.309c0.822-1.373,1.476-2.332,1.75-2.385c0.593-1.318,1.217-2.43,1.772-3.598
c0.575-1.156,1.149-2.327,1.87-3.627c0.935-0.813-0.806,2.113,1.383-0.682c-0.108-0.918,1.609-1.839,3.499-4.535
c-0.02,0.783-1.443,2.063-1.895,3.111c0.539-0.463,1.193-1.201,1.886-2.074c0.692-0.873,1.507-1.819,2.242-2.798
c1.46-1.964,2.862-3.848,3.454-4.643c0.651-0.081,3.432-3.518,6.016-6.063c-0.795,0.989-1.688,1.951-2.585,2.968
c-0.875,1.036-1.802,2.085-2.759,3.158c-0.479,0.536-0.965,1.08-1.455,1.63c-0.463,0.573-0.931,1.155-1.404,1.743
c-0.937,1.184-1.88,2.402-2.806,3.668c1.502-1.734,2.429-2.182,0.026,1.021c1.609-1.331,4.85-5.857,7.846-9.101
c0.012,0.235-1.756,2.421,0.157,0.185c-0.146,0.381-0.697,0.972-1.396,1.713c-0.692,0.747-1.564,1.617-2.22,2.65
C55.817,72.13,55.124,73.675,55.972,72.507z M37.823,97.609c-0.201,0.394-0.4,0.784-0.588,1.148
c-0.166,0.376-0.313,0.729-0.435,1.041c-0.239,0.619-0.363,1.068-0.265,1.192c0.403-0.576,0.711-1.271,0.919-1.901
C37.674,98.464,37.818,97.917,37.823,97.609z M30.58,121.69c0.792-2.883,2.127-6.288,3.331-9.709
c0.686-1.681,1.356-3.362,1.967-4.968c0.293-0.809,0.614-1.582,0.935-2.319c0.312-0.739,0.602-1.455,0.867-2.132
C34.848,107.303,32.606,113.908,30.58,121.69z M27.721,131.839c0.342,0.231,0.75-1.556,1.149-3.308
C28.538,128.136,28.112,130.675,27.721,131.839z M47.077,83.386c1.254-2.07,3.153-4.855,4.059-5.82
C50.401,77.718,47.92,81.537,47.077,83.386z M27.079,127.857c0.22-0.055,0.417,0.426,0.665-0.511
c-0.179,0.112,0.104-0.802,0.39-1.814c0.301-1.008,0.675-2.094,0.516-2.383c-0.258,0.472-0.526,1.053-0.811,1.809
C27.585,125.722,27.343,126.671,27.079,127.857z M127.563,24.119c-0.463,0.225-1.029,0.6,0.027,0.31
c-1.115,0.71-1.405,0.075-2.711,0.811c0.64-0.004,2.399-0.521,3.427-0.707c-0.82,0.338-2.323,0.762-4.056,1.24
c-1.71,0.559-3.637,1.208-5.336,1.867c0.429-0.396,3.069-1.453,3.959-1.625c0.722-0.281,0.422-0.377,1.31-0.674
c-0.459-0.004-1.45,0.307-2.709,0.732c0.458-0.25,1.012-0.656-0.037-0.313c0.49-0.252,1.168-0.363,2.141-0.585
C124.555,24.971,125.848,24.73,127.563,24.119z M94.215,39.693c-1.271,0.673-2.704,1.682-4.292,2.679
c-0.791,0.506-1.607,1.03-2.431,1.558c-0.799,0.566-1.603,1.137-2.396,1.7c-3.222,2.187-6.062,4.494-7.67,5.583
c-0.254,0.111-3.765,3.355-0.609,0.345c2.466-2.153,2.994-2.243,5.633-4.21c0.977-0.688,2.084-1.818,2.746-2.287
c0.743-0.516,1.269-0.59,2.433-1.349c1.591-0.976,2.849-1.866,3.903-2.577c0.525-0.354,1.001-0.665,1.439-0.918
C93.414,39.976,93.83,39.804,94.215,39.693z M72.454,55.096c1.278-0.8,0.909-0.448,1.96-0.815
c-0.327,0.401-0.969,0.966-1.676,1.545c-0.359,0.285-0.728,0.579-1.08,0.86c-0.342,0.294-0.668,0.575-0.95,0.817
c0.122-0.227,0.359-0.536,0.684-0.906c0.332-0.359,0.768-0.76,1.25-1.203c-0.425,0.184-1.266,0.791-2.198,1.717
c0.001-0.214,0.271-0.627,0.701-1.127c0.444-0.484,1.04-1.063,1.651-1.653c-0.465,0.278-1.28,0.958-2.046,1.62
c-0.743,0.688-1.456,1.334-1.791,1.471c-0.016-0.367,2.211-2.271,2.116-1.878c-0.224-0.255-0.579,0.179,1.404-1.708
c1.113-0.943,0.403,0.326,1.844-1.018C74.114,53.317,71.659,55.225,72.454,55.096z M62.381,66.093
c0.484,0.01-2.488,2.262-2.35,2.584c-1.729,1.732,0.682-1.285,1.656-2.339C61.761,66.525,61.818,66.683,62.381,66.093z
M159.738,18.468c0.037-0.092,0.888-0.107,1.442-0.15c-0.098-0.104-1.288-0.027-2.126-0.011c0.45-0.102,0.884-0.202,0.028-0.19
c1.91-0.428,2.379,0.148,4.253,0.025C162.706,18.304,161.017,18.368,159.738,18.468z M116.79,27.652
c-2.854,1.252-2.989,0.899-5.33,1.842C112.112,29.084,116.861,27.259,116.79,27.652z M104.942,32.267
c1.416-0.641,1.456-0.383,1.433-0.078c-2.017,0.977-2.303,0.906-4.46,2.133c2.791-1.826-2.242,0.582,0.432-0.956
C102.677,33.486,104.576,32.835,104.942,32.267z M96.101,36.794c0.199,0.098-0.943,0.887,0.116,0.313
c0.147,0.019-0.205,0.239-0.568,0.461c-0.412,0.244-0.819,0.483-1.236,0.729c0.686-0.723-0.425-0.146,0.457-0.776
c-0.727,0.242-1.872,0.83-2.944,1.473c-1.056,0.666-2.069,1.336-2.617,1.615c0.826-0.67,2.116-1.533,3.438-2.217
C94.072,37.716,95.366,37.117,96.101,36.794z M84.795,44.242c-0.145-0.013,0.176-0.258,0.523-0.521
c0.62-0.697,0.288-0.847-1.979,0.748c-0.062-0.293,1.82-1.639,3.394-2.703c-0.14,0.318,0.557-0.112,1.289-0.579
c-0.619,0.566-1.618,1.302-0.956,1.294C85.99,43.179,85.897,43.484,84.795,44.242z M59.002,67.478
c0.096-0.744,1.5-2.027,2.319-2.695c0.428-0.426,0.427-0.523,0.414-0.615c0.753-0.609,0.273,0.007-0.51,0.866
C60.463,65.914,59.45,67.087,59.002,67.478z M76.095,48.909c0.018,0.089-0.516,0.507-1.335,1.122
c-0.799,0.639-1.867,1.492-2.971,2.377c-2.252,1.719-4.459,3.781-4.865,4.545c-1.156,1.099-0.512-0.195-0.925-0.082
c0.883-0.596,2.165-1.974,2.535-2.613c1.324-1.008,0.969-0.496,0.836,0.002c2.201-2.139,1.073-1.148,3.676-3.537
c-0.827,0.471-2.512,1.967-3.448,2.563c0.239-0.333,0.469-0.658,0.349-0.767c1.088-0.807,2.419-1.905,3.605-2.835
c1.185-0.934,2.303-1.601,2.813-1.72c2.044-1.999,3.383-2.337,3.341-2.811c1.042-0.577,2.091-1.142,4.197-2.759
c-0.146,0.676,1.23-0.27,2.817-1.195c1.58-0.931,3.287-1.977,3.604-1.787c-0.646,0.311-1.51,0.783-2.481,1.34
c-0.974,0.555-2.059,1.186-3.081,1.906c-2.061,1.414-4.15,2.783-5.239,3.656c-0.457-0.057-1.56,0.662-2.8,1.602
c-0.619,0.471-1.273,0.994-1.897,1.508c-0.621,0.516-1.175,1.065-1.662,1.501c0.107,0.103,0.635-0.3,1.23-0.788
C74.988,49.645,75.702,49.132,76.095,48.909z M89.937,38.48c-1.367,1.262-4.933,3.207-5.502,3.395
c0.331-0.27,0.695-0.529,1.099-0.785c0.416-0.232,0.859-0.479,1.328-0.741C87.8,39.832,88.841,39.251,89.937,38.48z
M134.295,19.425c-0.742,0.391-1.927,0.781-3.65,1.244c-1.711,0.504-3.939,1.16-6.79,2c0.611-0.281,0.642-0.454,0.233-0.472
c-0.411-0.024-1.259,0.114-2.381,0.528c0.888-0.801,3.219-0.881,4.133-1.593c0.317,0.116,1.631-0.21,3.257-0.645
C130.726,20.058,132.711,19.676,134.295,19.425z M33.475,105.767c0.222,0.459-1.818,3.766-1.698,4.756
c-0.395,0.956-0.458,0.035-0.853,1.156C31.005,110.894,32.781,107.398,33.475,105.767z M42.769,85.297
c-0.521,0.606-0.309,0.044-0.875,0.212c1.42-2.34,1.685-1.918,2.721-3.354C44.713,82.847,42.576,84.83,42.769,85.297z
M156.696,14.611c-1.208,0.234-3.038,0.356-4.661,0.424c-1.621,0.066-3.016,0.22-3.361,0.252c0.498-0.217,1.848-0.402,3.409-0.426
C153.644,14.82,155.398,14.787,156.696,14.611z M117.619,19.665c0.984-0.208,3.159-1.308,4.965-1.626
c-0.288,0.264-1.503,0.722-1.365,0.955c-1.655,0.563-2.095,0.521-4.249,1.324c-0.834-0.063-0.092-0.217-0.094-0.622
C117.691,19.46,118.701,19.273,117.619,19.665z M99.769,27.32c2.016-0.831-2.568,1.363-2.691,1.25
c-0.919,0.512-0.003,0.215,0.729-0.129c-1.16,0.967-2.945,1.283-4.633,2.361c0.063-0.183,1.144-0.763,1.909-1.216
c-0.375,0.108-0.739,0.218-0.759,0.057C96.662,28.439,98.05,28.035,99.769,27.32z M92.487,31.071
c-7.382,4.207-13.689,8.484-19.573,12.2c0.224-0.388,0.735-0.936,0.969-1.33c0.721-0.116,2.273-1.241,4.001-2.252
c1.705-1.044,3.462-2.141,4.395-2.456c0.946-0.713-0.63,0.185,0.521-0.627c-1.271,0.677-3.295,2.128-3.877,2.173
c1.377-1.207,2.576-2.051,3.683-2.764c1.119-0.699,2.101-1.338,3.004-2.248c2.197-1.054-0.425,1.188,2.829-0.881
c-1.252,1.201-3.334,1.938-3.327,1.59c-0.351,0.299-0.9,0.685-1.558,1.119c-0.66,0.432-1.426,0.908-2.175,1.449
c0.858-0.414,1.607-0.775,2.347-1.133c0.735-0.371,1.449-0.767,2.238-1.223c0.784-0.465,1.65-0.979,2.691-1.594
C89.722,32.531,90.971,31.872,92.487,31.071z M269.713,45.47c1.804,0.92,3.687,1.99,6.026,3.977
C276.801,49.939,271.813,47.554,269.713,45.47z M100.945,26.123c0.65-0.34,0.493-0.378,0.615-0.533
c1.799-0.842,2.288-0.864,2.164-0.504c-2.872,1.068-4.138,1.848-5.121,2.299c-0.975,0.472-1.623,0.712-3.137,1.034
c0.947-0.854,2.392-1.003,2.775-1.044C99.791,26.32,100.282,25.843,100.945,26.123z M69.804,45.314
c0.9-0.285-1.738,1.139-2.469,1.663c0.195-0.624,1.285-0.959,2.292-1.964C69.847,45.081,68.805,46.108,69.804,45.314z
M66.378,48.251c-0.449,0.404-0.921,0.741-1.453,1.097c-0.517,0.374-1.083,0.781-1.756,1.268
c-0.668,0.496-1.433,1.093-2.325,1.874c-0.447,0.391-0.924,0.826-1.438,1.32c-0.5,0.506-1.017,1.088-1.579,1.729
c-0.064-0.239-0.796,0.463-0.816,0.163c0.632-0.479,1.57-1.263,2.368-1.981c0.822-0.695,1.477-1.352,1.47-1.654
c0.572-0.426,1.446-1.079,2.34-1.746c0.877-0.688,1.742-1.431,2.301-2.068C66.564,47.396,65.434,48.956,66.378,48.251z
M63.126,50.084c-0.712,0.6-1.216,0.939-1.308,0.747c0.97-0.956,1.198-0.975,0.618-0.421c1.223-1.048,5.62-5.053,5.924-4.615
C67.296,46.501,63.554,49.378,63.126,50.084z M99.087,25.018c0.815-0.197-2.887,1.282-1.932,1.333
c-2.915,1.229-5.422,2.465-8.793,4.291c0.612-0.563,2.623-1.705,4.797-2.867C95.356,26.652,97.778,25.627,99.087,25.018z
M294.721,65.776c1.625,1.581,2.995,2.507,4.738,4.646c-0.203,0.148,0.375,0.922,0.678,1.472
C298.325,69.373,297.263,68.861,294.721,65.776z M83.034,33.427c-0.077,0.197-0.627,0.47-0.656,0.412
c-1.105,0.65-1.172,0.84-1.186,1.016c-1.868,1.183-1.959,1.248-3.741,2.617c-0.292-0.347,1.477-1.395,3.019-2.384
c0.185-0.665-3.486,2.275-2.787,1.125c1.546-0.747,1.71-0.845,2.039-1.132c0.332-0.281,0.804-0.789,3.08-2.164
c0.373,0.04-0.322,0.551-0.459,0.816C82.257,33.896,82.637,33.671,83.034,33.427z M88.877,29.847
c0.026,0.01-0.258,0.02-0.931,0.238c-0.684,0.201-1.713,0.689-3.197,1.606c0.074-0.192,0.62-0.461,0.645-0.403
c0.593-0.396,1.122-0.747,1.601-1.067c0.497-0.292,0.946-0.555,1.362-0.798c0.838-0.474,1.568-0.834,2.348-1.109
C90.093,28.828,88.99,29.529,88.877,29.847z M102.331,22.675c-2.096,1.276-3.92,1.572-6.886,3.209l-0.17-0.52
c2.067-1.122,2.057-0.822,4.135-1.948c-0.651,0.452-0.657,0.642-0.14,0.518C99.782,23.794,100.84,23.395,102.331,22.675z
M29.372,88.894c-0.091,0.546-0.576,1.607-1.229,2.885c-0.204-0.305-1.067,1.357-1.51,1.766
C26.881,92.465,28.471,90.639,29.372,88.894z M25.866,94.871c-0.086,0.43-0.167,0.85-0.006,0.924
c-0.355,0.574-0.716,1.163-0.652,1.398c-0.51,0.48-0.994-0.112-1.205,0.135C24.062,96.39,24.615,97.529,25.866,94.871z
M78.363,34.101c-0.092-0.209,1.671-1.332,1.076-1.291c-2.294,1.722-2.645,2.488-5.468,4.477c1.398-1.23,1.206-1.256,0.256-0.682
c-0.954,0.568-2.578,1.855-4.146,3.088c0.55-0.562,1.109-1.131,1.681-1.715c-0.572,0.135-3.499,2.566-4.952,3.697
c-1.185,0.851-0.416,0.225,0.466-0.524c0.902-0.725,1.945-1.54,1.18-1.221c1.724-1.13,4.114-2.934,4.59-2.792
c1.915-1.464,3.481-2.494,4.958-3.481c1.468-1.007,2.958-1.816,4.751-2.809c-0.09,0.163-0.613,0.514-0.572,0.621
c-0.43,0.268-0.87,0.543-1.307,0.814c-0.022-0.111,0.768-0.581,1.266-0.922C80.675,31.859,79.958,33.017,78.363,34.101z
M22.205,101.662c0.203,0.252-0.5,1.959-1.248,3.732c0.01-1.509-0.709,0.379-1.105,0.279
C20.031,104.756,21.453,103.207,22.205,101.662z M41.183,68.705c-0.538,0.693-0.339,0.255-0.085-0.093
c-0.875,0.753-1.058,1.596-2.388,3.054c0.046-0.335,0.671-1.242,0.659-1.521c1.357-1.986,1.102-0.983,2.497-2.947
C41.792,67.522,41.171,68.419,41.183,68.705z M27.4,89.353c0.18-0.022,0.752-1.189,1.2-1.991c0.032,1.001-1.238,2.378-2.449,4.896
c-0.447,0.089,1.261-2.637,1.142-2.976C27.728,88.514,27.598,88.979,27.4,89.353z M10.762,128.977
c0.259-1.433,0.678-3.153,1.12-4.907c0.23-0.875,0.462-1.76,0.687-2.623c0.246-0.856,0.535-1.678,0.783-2.449
c-0.913,2.384-1.563,4.938-2.29,7.428c-0.759,2.486-1.221,4.992-1.725,7.307c-0.229,1.162-0.491,2.271-0.645,3.337
c-0.145,1.066-0.283,2.071-0.409,2.997c-0.235,1.855-0.397,3.4-0.45,4.5c-0.121,2.6-0.551,1.016-0.775,2.26
c0.167,2.02-0.462,3.426-0.57,6.365c-0.009,1.008,0.245,0.859,0.281,0.061c-0.075,2.111-0.147,4.191-0.22,6.253
c-0.058,1.032-0.042,2.061-0.028,3.085c0.01,1.025,0.019,2.049,0.028,3.07l0.022,3.066c0.031,1.023,0.103,2.044,0.15,3.071
c0.124,2.05,0.163,4.118,0.344,6.204c-1.03-0.984-1.183-0.201-1.054,3.964c-0.436-4.166-0.459-9.039-0.381-13.809
c0.119-2.382,0.234-4.747,0.347-7.009c0.206-2.245,0.4-4.385,0.579-6.33c-0.777-0.579-0.062,4.264-0.881,3.877
c0.469-10.515,1.811-21.645,5.015-33.965c-0.273-0.375-0.732,1.486-1.159,2.958c-0.114-0.311,0.38-1.995,0.654-3.167
c-0.234,0.172-0.595,1.906-0.91,3.066c-0.259-0.204,0.772-3.32,1.047-4.725c-0.08,0.966,0.225,1.062,0.563,1.091
c0.331-1.232,0.611-2.437,0.977-3.581c0.375-1.141,0.739-2.246,1.092-3.315c0.366-1.062,0.72-2.087,1.062-3.075
c0.346-0.986,0.769-1.902,1.139-2.793c0.76-1.772,1.489-3.374,2.183-4.793c0.755-1.396,1.48-2.608,2.151-3.631
c-1.955,3.718-3.221,7.467-4.621,11.618c-0.624,2.104-1.279,4.31-1.991,6.703C12.257,123.503,11.567,126.107,10.762,128.977z
M53.02,54.195c0.131-0.111-1.042,1.111-2.188,2.317c-1.113,1.233-2.249,2.411-2.205,2.056c0.754-0.707,1.427-1.5,2.113-2.275
C51.443,55.531,52.191,54.812,53.02,54.195z M12.619,128.816c0.117,0.612-0.402,1.141-0.13-0.051
c-0.397,0.438-0.759,1.936-1.165,3.729c-0.261-1.739-0.838,0.561-1.438,3.271c-0.554,2.715-0.956,5.859-1.333,5.723
c0.323-1.369,0.603-2.84,0.861-4.382c0.305-1.536,0.621-3.141,0.948-4.791c0.574-3.313,1.483-6.747,2.494-10.116
c0.194,1.043-0.671,2.558-0.257,3.271c-0.331,1.094-0.091-0.123-0.254-0.1c0.061,0.538-0.713,2.964-0.5,3.16
c0.455-0.818,1.081-3.945,1.577-5.248c0.203,0.432-0.879,3.782-1.191,5.393C12.625,127.828,12.503,128.457,12.619,128.816z
M35.174,73.644c-1.499,1.6-1.054,1.922-1.995,3.452c0.553-0.847,1.125-1.724,1.709-2.62c0.583-0.895,1.193-1.797,1.866-2.664
c1.319-1.752,2.639-3.531,3.948-5.2c2.7-3.267,5.16-6.243,6.781-8.204c1.824-1.983,1.691-1.077,3.535-3.018
c-1.167,1.732-4.111,4.571-4.859,5.039c-0.934,1.266-2.438,3.275-4.108,5.303c-0.412,0.51-0.83,1.027-1.245,1.539
c-0.391,0.531-0.778,1.059-1.154,1.57c-0.75,1.025-1.449,2-2.024,2.847c-0.597,1.228,0.155,0.052,0.353,0.351
c-0.496,0.498-1.026,1.016-1.534,1.592c-0.498,0.582-1.008,1.201-1.525,1.858c-1.027,1.325-2.13,2.781-3.161,4.481
c0.798-1.55,2.002-4.563,0.073-1.978C31.846,77.835,35.236,72.21,35.174,73.644z M333.396,175.785
c0.313,0.645,0.498,1.822,0.392,3.309c-0.077,1.488-0.301,3.297-0.585,5.223c-0.544-0.75-0.37-2.258-0.108-3.894
c0.129-0.819,0.282-1.669,0.375-2.468C333.528,177.152,333.528,176.4,333.396,175.785z M24.664,91.23
c-0.043-0.023-0.396,0.484-0.892,1.365c-0.468,0.895-1.119,2.143-1.857,3.557c-1.526,2.809-3.059,6.436-4.319,9.141
c0.342-1.75,2.125-5.059,2.862-6.516c0.514-1.096,0.815-2.557-0.22-0.143c0.594-1.975,1.909-2.813,3.636-7.008
C24.925,90.013,23.52,93.109,24.664,91.23z M52.629,52.393c0.757-0.682-0.459,0.587-1.836,1.919
c-0.687,0.664-1.402,1.357-1.913,1.851c-0.518,0.482-0.862,0.736-0.813,0.512C49.364,55.402,51.244,53.564,52.629,52.393z
M322.288,227.066c-0.094,0.638-0.857,2.438-1.836,3.834c-0.401-0.115,0.035-1.191-0.433-1.221c0.58-1.33,1.29-1.792,1.097-2.294
C322.054,225.192,320.955,229.712,322.288,227.066z M64.861,39.863c-0.818,0.797-1.88,1.765-2.956,2.695
c-1.088,0.918-2.163,1.826-2.992,2.525c0.222-0.221,0.604-0.6,1.072-1.066c0.492-0.438,1.077-0.956,1.673-1.485
C62.863,41.481,64.135,40.404,64.861,39.863z M9.848,200.243c-0.563-2.849-1.083-3.781-1.629-6.298
c0.638-0.086-0.146-1.822-0.094-3.207c0.614,2.285,1.237,4.781,1.693,7.354c0.418,2.584,0.941,5.178,1.424,7.629
c-0.255-0.449-1.478-4.341-1.013-1.475C9.815,203.691,9.312,199.985,9.848,200.243z M120.254,10.527
c0.031,0.097,0.938-0.166,1.527-0.301c-0.418,0.24-1.682,0.537-2.847,0.916c-1.165,0.384-2.26,0.754-2.392,0.955
c-2.277,0.595,0.479-0.566,0.688-0.76c0.849-0.342,1.562-0.499,2.081-0.611c0.522-0.099,0.854-0.142,0.932-0.303
C121.108,10.215,120.676,10.416,120.254,10.527z M38.763,66.206c-0.601,1.465-4.82,7.132-5.232,6.993
c0.758-0.896,1.478-1.792,2.306-2.891C36.657,69.206,37.57,67.893,38.763,66.206z M31.955,76.007
c0.297,0.084-1.565,2.361-2.066,3.426c-0.14-0.438,1.988-3.224,1.341-3.014C32.05,74.472,31.208,77.373,31.955,76.007z
M18.817,99.619c0.173-0.047,0.657-1.288,1.093-2.117c-0.014,0.23-0.175,0.728-0.411,1.34c-0.212,0.621-0.495,1.358-0.79,2.053
c-0.588,1.391-1.226,2.615-1.427,2.41c0.382-1.113,1.721-3.738,1.422-3.754C19.077,98.723,18.99,99.207,18.817,99.619z
M72.284,33.921c0.918-0.748,2.068-1.609,3.381-2.547c0.66-0.465,1.355-0.953,2.081-1.462c0.742-0.479,1.531-0.955,2.336-1.45
C78.002,29.863,75.053,31.988,72.284,33.921z M109.064,315.751c0.532,0.047,1.071,0.066,0.158-0.328
c3.39,1.281,7.359,3.019,12.358,4.637c-1.617-0.3-3.306-0.623-4.97-0.972C117.016,317.994,111.111,316.969,109.064,315.751z
M6.47,144.222c-0.464,1.746-0.529,1.186-1.142,3.009c0.078-0.796,0.157-1.602,0.236-2.409c0.225,0.51,0.403-0.17,0.575-0.709
C6.317,143.577,6.451,143.175,6.47,144.222z M5.197,168.289c-0.343-0.29-0.163-3.535-0.455-4.082
c0.385,0.551,0.354-1.127,0.314-3.102c0.02-1.976,0.1-4.248,0.444-4.866c-0.097,0.887-0.155,1.834-0.196,2.819
c-0.049,0.986-0.067,2.009-0.025,3.046C5.324,164.175,5.358,166.298,5.197,168.289z M14.827,107.384
c-0.456,1.262-0.913,2.525-1.367,3.785c-0.146-0.285,0.196-1.436,0.854-3.146c-0.046-0.196-0.219,0.237-0.39,0.688
C13.745,108.235,14.819,106.621,14.827,107.384z M73.096,31.998c-3.077,2.941-1.203-0.012-6.01,3.812
c-0.14-0.406,2.187-2.158,2.185-2.639c1.107-0.875,1.477-0.262,2.102-1.091c2.342-1.58-1.235,1.295,0.809-0.142
c-0.387,0.521-1.21,0.813-1.96,1.371C70.706,33.284,71.032,33.42,73.096,31.998z M261.141,301.951
c-3.027,1.814-2.165,2.424-6.277,4.555c2.401-2.295-0.16,0.015-3.088,1.457c0.183-0.336,1.11-1.016,2.337-1.801
c1.202-0.824,2.686-1.791,4.047-2.61c1.356-0.824,2.59-1.503,3.254-1.806c0.652-0.32,0.777-0.205-0.08,0.581
C261.263,302.209,261.193,302.086,261.141,301.951z M12.83,111.745c-0.075,0.251-0.253,0.056-0.472,0.158
c-0.21,0.104-0.507,0.497-0.66,1.965c-0.684,1.7,0.322-2.394-0.224-1.892c0.604-1.56,0.597,0.076,1.309-2.051
c0.265-0.74,0.067-0.664,0.037-0.881c0.768-1.635,1.263-3.043,1.906-4.521c0.647-1.474,1.335-3.052,2.319-5.059
c0.51-1.376,0.877-2.002,0.928-1.968c0.043,0.028-0.226,0.716-0.818,2.039c-1.249,3.082-2.074,4.841-2.683,6.375
c-0.591,1.543-1.07,2.818-1.727,4.891c0.124,0.043,0.428-0.876,0.669-1.457C13.523,109.611,12.631,111.644,12.83,111.745z
M4.911,140.491c0.183,0.125,0.364,0.233,0.524-0.689c-0.224,2.167-0.55,4.721-0.717,7.287c-0.193,2.561-0.544,5.111-0.692,7.263
c-0.149,0.356-0.116-0.806-0.086-1.687c0.054-0.879,0.105-1.475-0.116,0.012C4.067,150.451,4.987,143.21,4.911,140.491z
M40.886,59.163c0.066,0.19,0.143,0.368,0.752-0.339c-0.71,1.444-1.934,1.863-2.809,2.836
C37.286,63.177,39.786,60.651,40.886,59.163z M106.41,12.97c0.459-0.154,1.294-0.47,2.369-0.889
c1.08-0.404,2.378-0.975,3.837-1.441c2.902-0.977,6.215-2.196,8.947-2.879c-0.844,0.301-2.107,0.646-3.511,1.15
c-1.409,0.494-2.99,1.047-4.534,1.587c-0.776,0.264-1.54,0.523-2.267,0.771c-0.721,0.272-1.405,0.529-2.028,0.763
C107.977,12.488,106.971,12.83,106.41,12.97z M27.968,122.087c-0.255,0.111-0.032-0.74-0.182-0.851
c-0.255,0.453-0.538,1.177-0.828,1.97c-0.411,1.936,0.746-1.742,0.816-0.424c-0.244,0.981-0.596,1.657-0.786,2.106
c-0.169,0.453-0.196,0.672,0.176,0.725c-0.482,2.457-0.865,2.314-1.297,3.304c0.26,0.32-0.083,0.983-0.215,2.191
c-0.116,0.354-0.37,0.929-0.505,1.116c-0.127,0.186-0.233-0.036,0.116-1.249c-0.237-0.366-0.8,3.157-0.276,2.165
c-0.295,1.004-0.587,2.182-0.896,1.957c1.624-8.768,2.521-13.685,5.795-21.547c0.107,0.078-0.186,0.887-0.336,1.428
c0.189,0.191,0.531-0.707,0.705-0.439c-0.445,0.824-0.858,2.281-1.233,3.745C28.701,119.763,28.38,121.239,27.968,122.087z
M30.468,245.718c0.5,1.077,0.533,1.639,0.111,1.72c-1.078-1.638-0.657-1.614-1.952-3.507c-0.078,0.596-0.076,1.775-1.177,0.52
c-0.926-1.667-0.897-2.193-2.196-4.32c0.785,0.543,1.584,1.137,2.113,0.863c0.536,0.902,1.098,1.854,1.604,2.711
C29.514,244.539,30.045,245.253,30.468,245.718z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 89 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 88 KiB

19
frontend/src/App.css Normal file
View File

@@ -0,0 +1,19 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
font-size: 1.5rem;
color: #667eea;
}

93
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,93 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import type { ReactElement } from 'react';
import { SiteConfigProvider } from './contexts/SiteConfigContext';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { LanguageProvider } from './contexts/LanguageContext';
import { ThemeProvider } from './contexts/ThemeContext';
import { SidebarProvider } from './contexts/SidebarContext';
import { ViewModeProvider } from './contexts/ViewModeContext';
import { ModulesProvider } from './contexts/ModulesContext';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Feature1 from './pages/Feature1';
import AdminPanel from './pages/AdminPanel';
import Sources from './pages/admin/Sources';
import Features from './pages/admin/Features';
import Settings from './pages/Settings';
import ThemeSettings from './pages/admin/ThemeSettings';
import './App.css';
function PrivateRoute({ children }: { children: ReactElement }) {
const { user, isLoading } = useAuth();
if (isLoading) {
return <div className="loading">Loading...</div>;
}
return user ? children : <Navigate to="/login" />;
}
function AdminRoute({ children }: { children: ReactElement }) {
const { user, isLoading } = useAuth();
if (isLoading) {
return <div className="loading">Loading...</div>;
}
if (!user) {
return <Navigate to="/login" />;
}
return user.is_superuser ? children : <Navigate to="/dashboard" />;
}
import MainLayout from './components/MainLayout';
// ...
function AppRoutes() {
const { user } = useAuth();
return (
<Routes>
<Route path="/login" element={user ? <Navigate to="/dashboard" /> : <Login />} />
<Route element={<PrivateRoute><MainLayout /></PrivateRoute>}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/feature1" element={<Feature1 />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<AdminRoute><AdminPanel /></AdminRoute>} />
<Route path="/admin/sources" element={<AdminRoute><Sources /></AdminRoute>} />
<Route path="/admin/features" element={<AdminRoute><Features /></AdminRoute>} />
<Route path="/admin/theme" element={<AdminRoute><ThemeSettings /></AdminRoute>} />
</Route>
<Route path="/" element={<Navigate to={user ? '/dashboard' : '/login'} />} />
</Routes>
);
}
function App() {
return (
<SiteConfigProvider>
<BrowserRouter>
<ThemeProvider>
<LanguageProvider>
<AuthProvider>
<ModulesProvider>
<ViewModeProvider>
<SidebarProvider>
<AppRoutes />
</SidebarProvider>
</ViewModeProvider>
</ModulesProvider>
</AuthProvider>
</LanguageProvider>
</ThemeProvider>
</BrowserRouter>
</SiteConfigProvider>
);
}
export default App;

View File

@@ -0,0 +1,95 @@
import axios from 'axios';
import type {
LoginRequest,
RegisterRequest,
Token,
User,
UserCreate,
UserUpdatePayload,
} from '../types';
const api = axios.create({
baseURL: '/api/v1',
headers: {
'Content-Type': 'application/json',
},
});
// Add auth token to requests
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Auth endpoints
export const authAPI = {
login: async (data: LoginRequest): Promise<Token> => {
const response = await api.post<Token>('/auth/login', data);
return response.data;
},
register: async (data: RegisterRequest): Promise<User> => {
const response = await api.post<User>('/auth/register', data);
return response.data;
},
getCurrentUser: async (): Promise<User> => {
const response = await api.get<User>('/auth/me');
return response.data;
},
};
// Settings endpoints
export const settingsAPI = {
getTheme: async (): Promise<Record<string, string>> => {
const response = await api.get<Record<string, string>>('/settings/theme');
return response.data;
},
updateTheme: async (data: Record<string, string>): Promise<Record<string, string>> => {
const response = await api.put<Record<string, string>>('/settings/theme', data);
return response.data;
},
getModules: async (): Promise<Record<string, boolean | string>> => {
const response = await api.get<Record<string, boolean | string>>('/settings/modules');
return response.data;
},
updateModules: async (data: Record<string, boolean>): Promise<Record<string, boolean>> => {
const response = await api.put<Record<string, boolean>>('/settings/modules', data);
return response.data;
},
};
// Users endpoints
export const usersAPI = {
list: async (): Promise<User[]> => {
const response = await api.get<User[]>('/users');
return response.data;
},
get: async (id: string): Promise<User> => {
const response = await api.get<User>(`/users/${id}`);
return response.data;
},
create: async (data: UserCreate): Promise<User> => {
const response = await api.post<User>('/users', data);
return response.data;
},
update: async (id: string, data: UserUpdatePayload): Promise<User> => {
const response = await api.put<User>(`/users/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/users/${id}`);
},
};
export default api;

View File

@@ -0,0 +1,11 @@
import { Outlet } from 'react-router-dom';
import Sidebar from './Sidebar';
export default function MainLayout() {
return (
<div className="app-layout">
<Sidebar />
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { useSidebar } from '../contexts/SidebarContext';
import { useTranslation } from '../contexts/LanguageContext';
import { useSiteConfig } from '../contexts/SiteConfigContext';
import '../styles/MobileHeader.css';
export default function MobileHeader() {
const { toggleMobileMenu } = useSidebar();
const { t } = useTranslation();
const { config } = useSiteConfig();
return (
<header className="mobile-header">
<button className="btn-hamburger" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<h1 className="mobile-title">{config.name}</h1>
</header>
);
}

View File

@@ -0,0 +1,279 @@
import { useState, useRef } from 'react';
import { NavLink, useNavigate } from 'react-router-dom';
import { useTranslation } from '../contexts/LanguageContext';
import { useSidebar } from '../contexts/SidebarContext';
import { useViewMode } from '../contexts/ViewModeContext';
import { useAuth } from '../contexts/AuthContext';
import { useModules } from '../contexts/ModulesContext';
import { useSiteConfig } from '../contexts/SiteConfigContext';
import { useTheme } from '../contexts/ThemeContext';
import { appModules } from '../modules';
import UserMenu from './UserMenu';
import '../styles/Sidebar.css';
export default function Sidebar() {
const { t, language, setLanguage } = useTranslation();
const { config } = useSiteConfig();
const { sidebarStyle, theme, toggleTheme, darkModeLocation, languageLocation, showDarkModeToggle, showLanguageToggle } = useTheme();
const {
isCollapsed,
isMobileOpen,
sidebarMode,
toggleCollapse,
closeMobileMenu,
isHovered,
setIsHovered,
showLogo: showLogoContext
} = useSidebar();
const { viewMode, toggleViewMode, isUserModeEnabled } = useViewMode();
const { user } = useAuth();
const { isModuleEnabled, isModuleEnabledForUser, hasInitialized: modulesInitialized } = useModules();
// When admin is in "user mode", show only user-permitted modules
// Otherwise, show all globally enabled modules (admin view)
const shouldUseUserPermissions = viewMode === 'user' || !user?.is_superuser;
// Don't show modules until initialization is complete to prevent flash
const mainModules = !modulesInitialized ? [] : (appModules
.find((cat) => cat.id === 'main')
?.modules.filter((m) => {
if (!m.enabled) return false;
if (shouldUseUserPermissions) {
return isModuleEnabledForUser(m.id, user?.permissions, user?.is_superuser || false);
}
return isModuleEnabled(m.id);
}) || []);
const handleCollapseClick = () => {
if (isMobileOpen) {
closeMobileMenu();
} else {
toggleCollapse();
}
};
const navigate = useNavigate();
const handleNavClick = (e: React.MouseEvent, path: string) => {
// Close mobile menu when clicking navigation items
if (isMobileOpen) {
e.preventDefault();
e.stopPropagation();
closeMobileMenu();
setTimeout(() => {
navigate(path);
}, 400);
}
};
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleMouseEnter = () => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
if (sidebarMode === 'dynamic' && isCollapsed && !isMobileOpen) {
setIsHovered(true);
}
};
const handleMouseLeave = () => {
hoverTimeoutRef.current = setTimeout(() => {
setIsHovered(false);
}, 100);
};
// derived state for expansion in dynamic mode
const isDynamicExpanded = sidebarMode === 'dynamic' && isCollapsed && (isHovered || isUserMenuOpen);
const handleSidebarClick = (e: React.MouseEvent) => {
// Only toggle if in toggle mode and not on mobile
if (sidebarMode === 'toggle' && !isMobileOpen) {
// Check if the click target is an interactive element or inside one
const target = e.target as HTMLElement;
const isInteractive = target.closest('a, button, [role="button"], input, select, textarea');
if (!isInteractive) {
toggleCollapse();
}
}
};
// Logo logic - use white logo on dark backgrounds, black logo on light backgrounds
// sidebarStyle 'default' and 'dark' both use dark sidebar background
// Only 'light' sidebarStyle uses a light background
const isLightSidebar = sidebarStyle === 'light';
const logoSrc = isLightSidebar ? '/logo_black.svg' : '/logo_white.svg';
// Show toggle button ONLY on mobile
const showToggle = isMobileOpen;
// Show logo only if enabled in config AND toggle button is not present
const showLogo = showLogoContext && !showToggle;
const [tooltip, setTooltip] = useState<{ text: string; top: number; visible: boolean }>({
text: '',
top: 0,
visible: false,
});
const handleItemMouseEnter = (text: string, e: React.MouseEvent) => {
if (isCollapsed && !isMobileOpen && sidebarMode !== 'dynamic') {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setTooltip({
text,
top: rect.top + rect.height / 2,
visible: true,
});
}
};
const handleItemMouseLeave = () => {
setTooltip((prev) => ({ ...prev, visible: false }));
};
const updateTooltipText = (text: string) => {
setTooltip((prev) => prev.visible ? { ...prev, text } : prev);
};
return (
<>
{/* Mobile overlay */}
<div className={`sidebar-overlay ${isMobileOpen ? 'visible' : ''}`} onClick={closeMobileMenu} />
{/* Sidebar Tooltip */}
{tooltip.visible && (
<div
className="sidebar-tooltip"
style={{
top: tooltip.top,
left: 'calc(80px + 1.5rem)', // Sidebar width + gap
}}
>
{tooltip.text}
</div>
)}
<aside
className={`sidebar ${isCollapsed && !isMobileOpen ? 'collapsed' : ''} ${isMobileOpen ? 'open' : ''} ${sidebarMode === 'dynamic' ? 'dynamic' : ''} ${isDynamicExpanded ? 'expanded-force' : ''} ${sidebarMode === 'toggle' ? 'clickable' : ''}`}
data-collapsed={isCollapsed}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleSidebarClick}
>
<div className="sidebar-header">
<div className="sidebar-header-content">
{showLogo ? (
<>
<img
src={logoSrc}
alt={config.name}
className="sidebar-logo"
onMouseEnter={(e) => handleItemMouseEnter(config.name, e)}
onMouseLeave={handleItemMouseLeave}
/>
{(!isCollapsed || isMobileOpen || sidebarMode === 'dynamic') && (
<div className="sidebar-title">
<h2>{config.name}</h2>
<p className="sidebar-tagline">{config.tagline}</p>
</div>
)}
</>
) : (
(!isCollapsed || isMobileOpen || sidebarMode === 'dynamic') && (
<div className="sidebar-title">
<h2>{config.name}</h2>
<p className="sidebar-tagline">{config.tagline}</p>
</div>
)
)}
{showToggle && (
<button onClick={handleCollapseClick} className="btn-collapse" title={isMobileOpen ? 'Close' : (isCollapsed ? 'Expand' : 'Collapse')}>
<span className="material-symbols-outlined">
{isMobileOpen ? 'close' : (isCollapsed ? 'chevron_right' : 'chevron_left')}
</span>
</button>
)}
</div>
</div>
<nav className="sidebar-nav">
<div className="nav-section">
{mainModules.map((module) => (
<NavLink
key={module.id}
to={module.path}
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
onClick={(e) => handleNavClick(e, module.path)}
onMouseEnter={(e) => handleItemMouseEnter(t.sidebar[module.id as keyof typeof t.sidebar], e)}
onMouseLeave={handleItemMouseLeave}
>
<span className="nav-icon material-symbols-outlined">{module.icon}</span>
<span className="nav-label">{t.sidebar[module.id as keyof typeof t.sidebar]}</span>
</NavLink>
))}
</div>
</nav>
<div className="sidebar-footer">
{user?.is_superuser && isUserModeEnabled && (
<button
className={`view-mode-toggle ${viewMode === 'user' ? 'user-mode' : 'admin-mode'}`}
onClick={() => { toggleViewMode(); updateTooltipText(viewMode === 'admin' ? t.admin.userView : t.admin.adminView); }}
title={viewMode === 'admin' ? t.admin.adminView : t.admin.userView}
onMouseEnter={(e) => handleItemMouseEnter(viewMode === 'admin' ? t.admin.adminView : t.admin.userView, e)}
onMouseLeave={handleItemMouseLeave}
>
<span className="material-symbols-outlined">
{viewMode === 'admin' ? 'admin_panel_settings' : 'person'}
</span>
{(!isCollapsed || isMobileOpen || sidebarMode === 'dynamic') && (
<span className="view-mode-label">
{viewMode === 'admin' ? t.admin.adminView : t.admin.userView}
</span>
)}
</button>
)}
{showDarkModeToggle && darkModeLocation === 'sidebar' && (
<button
className="view-mode-toggle"
onClick={() => { toggleTheme(); updateTooltipText(theme === 'dark' ? t.theme.lightMode : t.theme.darkMode); }}
title={theme === 'dark' ? t.theme.darkMode : t.theme.lightMode}
onMouseEnter={(e) => handleItemMouseEnter(theme === 'dark' ? t.theme.darkMode : t.theme.lightMode, e)}
onMouseLeave={handleItemMouseLeave}
>
<span className="material-symbols-outlined">
{theme === 'dark' ? 'dark_mode' : 'light_mode'}
</span>
{(!isCollapsed || isMobileOpen || sidebarMode === 'dynamic') && (
<span className="view-mode-label">
{theme === 'dark' ? t.theme.darkMode : t.theme.lightMode}
</span>
)}
</button>
)}
{showLanguageToggle && languageLocation === 'sidebar' && (
<button
className="view-mode-toggle"
onClick={() => { setLanguage(language === 'it' ? 'en' : 'it'); updateTooltipText(language === 'it' ? t.settings.english : t.settings.italian); }}
title={language === 'it' ? t.settings.italian : t.settings.english}
onMouseEnter={(e) => handleItemMouseEnter(language === 'it' ? t.settings.italian : t.settings.english, e)}
onMouseLeave={handleItemMouseLeave}
>
<span className="material-symbols-outlined">language</span>
{(!isCollapsed || isMobileOpen || sidebarMode === 'dynamic') && (
<span className="view-mode-label">
{language === 'it' ? t.settings.italian : t.settings.english}
</span>
)}
</button>
)}
<UserMenu onOpenChange={setIsUserMenuOpen} />
</div>
</aside>
</>
);
}

View File

@@ -0,0 +1,159 @@
import { useState, useRef, useEffect } from 'react';
import { NavLink, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useTranslation } from '../contexts/LanguageContext';
import { useTheme } from '../contexts/ThemeContext';
import { useSidebar } from '../contexts/SidebarContext';
import { useViewMode } from '../contexts/ViewModeContext';
export default function UserMenu({ onOpenChange }: { onOpenChange?: (isOpen: boolean) => void }) {
const { user, logout } = useAuth();
const { t, language, setLanguage } = useTranslation();
const { theme, toggleTheme, darkModeLocation, languageLocation, showDarkModeToggle, showLanguageToggle } = useTheme();
const { isCollapsed, isMobileOpen, closeMobileMenu, sidebarMode } = useSidebar();
const { viewMode } = useViewMode();
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
onOpenChange?.(isOpen);
}, [isOpen, onOpenChange]);
const toggleMenu = () => setIsOpen(!isOpen);
const toggleLanguage = () => {
setLanguage(language === 'it' ? 'en' : 'it');
};
const navigate = useNavigate();
const handleNavClick = (e: React.MouseEvent, path: string) => {
setIsOpen(false);
// Close mobile sidebar when clicking navigation items
if (isMobileOpen) {
e.preventDefault();
e.stopPropagation();
closeMobileMenu();
setTimeout(() => {
navigate(path);
}, 400);
}
};
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
return (
<div className="user-menu-container" ref={menuRef}>
{isOpen && (
<div className="user-menu-dropdown">
{user?.is_superuser && viewMode === 'admin' && (
<>
<nav className="user-menu-nav">
<NavLink
to="/admin"
className="user-menu-item"
onClick={(e) => handleNavClick(e, '/admin')}
>
<span className="material-symbols-outlined">admin_panel_settings</span>
<span>{t.admin.panel}</span>
</NavLink>
<NavLink
to="/admin/sources"
className="user-menu-item"
onClick={(e) => handleNavClick(e, '/admin/sources')}
>
<span className="material-symbols-outlined">database</span>
<span>{t.sourcesPage.title}</span>
</NavLink>
<NavLink
to="/admin/features"
className="user-menu-item"
onClick={(e) => handleNavClick(e, '/admin/features')}
>
<span className="material-symbols-outlined">extension</span>
<span>{t.featuresPage.title}</span>
</NavLink>
<NavLink
to="/admin/theme"
className="user-menu-item"
onClick={(e) => handleNavClick(e, '/admin/theme')}
>
<span className="material-symbols-outlined">brush</span>
<span>{t.theme.title}</span>
</NavLink>
</nav>
<div className="user-menu-divider" />
</>
)}
<div className="user-menu-actions">
<NavLink
to="/settings"
className="user-menu-item"
onClick={(e) => handleNavClick(e, '/settings')}
>
<span className="material-symbols-outlined">settings</span>
<span>{t.sidebar.settings}</span>
</NavLink>
{showDarkModeToggle && darkModeLocation === 'user_menu' && (
<button onClick={toggleTheme} className="user-menu-item">
<span className="material-symbols-outlined">
{theme === 'dark' ? 'dark_mode' : 'light_mode'}
</span>
<span>{theme === 'dark' ? 'Dark Mode' : 'Light Mode'}</span>
</button>
)}
{showLanguageToggle && languageLocation === 'user_menu' && (
<button onClick={toggleLanguage} className="user-menu-item">
<span className="material-symbols-outlined">language</span>
<span>{language === 'it' ? 'Italiano' : 'English'}</span>
</button>
)}
</div>
<div className="user-menu-divider" />
<button onClick={logout} className="user-menu-item danger">
<span className="material-symbols-outlined">logout</span>
<span>{t.auth.logout}</span>
</button>
</div>
)}
<button
className={`user-menu-trigger ${isOpen ? 'active' : ''}`}
onClick={toggleMenu}
title={t.dashboard.profile}
>
<div className="user-info-compact">
<span className="user-initial">{user?.username?.charAt(0).toUpperCase()}</span>
{(!isCollapsed || isMobileOpen || sidebarMode === 'dynamic') && (
<>
<span className="user-name">{user?.username}</span>
<span className="material-symbols-outlined chevron">
expand_more
</span>
</>
)}
</div>
</button>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { useTranslation } from '../../contexts/LanguageContext';
export default function Feature1Tab() {
const { t } = useTranslation();
return (
<div className="tab-content-placeholder">
<div className="placeholder-icon">
<span className="material-symbols-outlined">playlist_play</span>
</div>
<h3>{t.feature1.management}</h3>
<p>{t.feature1.comingSoon}</p>
</div>
);
}

View File

@@ -0,0 +1,358 @@
import { useEffect, useState } from 'react';
import { useTranslation } from '../../contexts/LanguageContext';
import { useViewMode } from '../../contexts/ViewModeContext';
import { useTheme } from '../../contexts/ThemeContext';
import { useSidebar } from '../../contexts/SidebarContext';
export default function GeneralTab() {
const { t } = useTranslation();
const { isUserModeEnabled, setUserModeEnabled } = useViewMode();
const { showLogo, setShowLogo } = useSidebar();
const {
darkModeLocation,
setDarkModeLocation,
languageLocation,
setLanguageLocation,
showDarkModeToggle,
setShowDarkModeToggle,
showLanguageToggle,
setShowLanguageToggle,
showDarkModeLogin,
setShowDarkModeLogin,
showLanguageLogin,
setShowLanguageLogin,
saveThemeToBackend
} = useTheme();
const [registrationEnabled, setRegistrationEnabled] = useState(true);
const [savingRegistration, setSavingRegistration] = useState(false);
const [savingShowLogo, setSavingShowLogo] = useState(false);
useEffect(() => {
const loadSettings = async () => {
try {
const response = await fetch('/api/v1/settings', {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (response.ok) {
const data = await response.json();
// Helper to check for true values (bool, string 'true'/'True', 1)
const isTrue = (val: any) => {
if (val === true) return true;
if (typeof val === 'string' && val.toLowerCase() === 'true') return true;
if (val === 1) return true;
return false;
};
// Default to true if undefined/null, otherwise check value
const regEnabled = data.registration_enabled;
setRegistrationEnabled(
regEnabled === undefined || regEnabled === null ? true : isTrue(regEnabled)
);
// Update sidebar context
if (data.show_logo !== undefined) {
setShowLogo(isTrue(data.show_logo));
}
}
} catch (error) {
console.error('Failed to load settings:', error);
}
};
loadSettings();
}, [setShowLogo]);
const handleRegistrationToggle = async (checked: boolean) => {
setRegistrationEnabled(checked); // Optimistic update
setSavingRegistration(true);
try {
const response = await fetch('/api/v1/settings/registration_enabled', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ value: checked }),
});
if (response.ok) {
const data = await response.json();
setRegistrationEnabled(data.value);
} else {
// Revert on failure
setRegistrationEnabled(!checked);
}
} catch (error) {
console.error('Failed to update registration setting:', error);
setRegistrationEnabled(!checked);
} finally {
setSavingRegistration(false);
}
};
const handleShowLogoToggle = async (checked: boolean) => {
setShowLogo(checked); // Optimistic update
setSavingShowLogo(true);
try {
const response = await fetch('/api/v1/settings/show_logo', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ value: checked }),
});
if (response.ok) {
const data = await response.json();
setShowLogo(data.value);
} else {
// Revert on failure
setShowLogo(!checked);
}
} catch (error) {
console.error('Failed to update show logo setting:', error);
setShowLogo(!checked);
} finally {
setSavingShowLogo(false);
}
};
return (
<div className="general-tab-content">
{/* Interface Section */}
<div className="general-section">
<div className="section-header">
<h3 className="section-title">{t.admin.viewMode}</h3>
</div>
<div className="modules-grid">
<div className="setting-card-compact">
<div className="setting-info">
<div className="setting-icon">
<span className="material-symbols-outlined">visibility</span>
</div>
<div className="setting-text">
<h4>{t.admin.userModeToggle}</h4>
<p>{t.admin.userModeToggleDesc}</p>
</div>
</div>
<label className="toggle-modern">
<input
type="checkbox"
checked={isUserModeEnabled}
onChange={(e) => setUserModeEnabled(e.target.checked)}
/>
<span className="toggle-slider-modern"></span>
</label>
</div>
<div className="setting-card-compact">
<div className="setting-info">
<div className="setting-icon">
<span className="material-symbols-outlined">person_add</span>
</div>
<div className="setting-text">
<h4>{t.settings.allowRegistration}</h4>
<p>{t.settings.allowRegistrationDesc}</p>
</div>
</div>
<label className="toggle-modern">
<input
type="checkbox"
checked={registrationEnabled}
onChange={(e) => handleRegistrationToggle(e.target.checked)}
disabled={savingRegistration}
/>
<span className="toggle-slider-modern"></span>
</label>
</div>
<div className="setting-card-compact">
<div className="setting-info">
<div className="setting-icon">
<span className="material-symbols-outlined">branding_watermark</span>
</div>
<div className="setting-text">
<h4>{t.settings.showLogo}</h4>
<p>{t.settings.showLogoDesc}</p>
</div>
</div>
<label className="toggle-modern">
<input
type="checkbox"
checked={showLogo}
onChange={(e) => handleShowLogoToggle(e.target.checked)}
disabled={savingShowLogo}
/>
<span className="toggle-slider-modern"></span>
</label>
</div>
</div>
</div>
{/* Dark Mode Settings Group */}
<div className="general-section">
<div className="section-header">
<h3 className="section-title">{t.admin.darkModeSettings}</h3>
</div>
<div className="modules-grid">
<div className="setting-card-compact">
<div className="setting-info">
<div className="setting-icon">
<span className="material-symbols-outlined">dark_mode</span>
</div>
<div className="setting-text">
<h4>{t.admin.enableDarkModeToggle}</h4>
<p>{t.admin.enableDarkModeToggleDesc}</p>
</div>
</div>
<label className="toggle-modern">
<input
type="checkbox"
checked={showDarkModeToggle}
onChange={(e) => {
setShowDarkModeToggle(e.target.checked);
saveThemeToBackend({ showDarkModeToggle: e.target.checked });
}}
/>
<span className="toggle-slider-modern"></span>
</label>
</div>
{showDarkModeToggle && (
<>
<div className="setting-card-compact">
<div className="setting-info">
<div className="setting-icon">
<span className="material-symbols-outlined">login</span>
</div>
<div className="setting-text">
<h4>{t.admin.showOnLoginScreen}</h4>
<p>{t.admin.showOnLoginScreenDesc}</p>
</div>
</div>
<label className="toggle-modern">
<input
type="checkbox"
checked={showDarkModeLogin}
onChange={(e) => {
setShowDarkModeLogin(e.target.checked);
saveThemeToBackend({ showDarkModeLogin: e.target.checked });
}}
/>
<span className="toggle-slider-modern"></span>
</label>
</div>
<div className="setting-card-compact">
<div className="setting-info">
<div className="setting-icon">
<span className="material-symbols-outlined">splitscreen</span>
</div>
<div className="setting-text">
<h4>{t.admin.controlLocation}</h4>
<p>{t.admin.controlLocationDesc}</p>
</div>
</div>
<label className="toggle-modern">
<input
type="checkbox"
checked={darkModeLocation === 'sidebar'}
onChange={(e) => {
const newLocation = e.target.checked ? 'sidebar' : 'user_menu';
setDarkModeLocation(newLocation);
saveThemeToBackend({ darkModeLocation: newLocation });
}}
/>
<span className="toggle-slider-modern"></span>
</label>
</div>
</>
)}
</div>
</div>
{/* Language Settings Group */}
<div className="general-section">
<div className="section-header">
<h3 className="section-title">{t.admin.languageSettings}</h3>
</div>
<div className="modules-grid">
<div className="setting-card-compact">
<div className="setting-info">
<div className="setting-icon">
<span className="material-symbols-outlined">language</span>
</div>
<div className="setting-text">
<h4>{t.admin.enableLanguageSelector}</h4>
<p>{t.admin.enableLanguageSelectorDesc}</p>
</div>
</div>
<label className="toggle-modern">
<input
type="checkbox"
checked={showLanguageToggle}
onChange={(e) => {
setShowLanguageToggle(e.target.checked);
saveThemeToBackend({ showLanguageToggle: e.target.checked });
}}
/>
<span className="toggle-slider-modern"></span>
</label>
</div>
{showLanguageToggle && (
<>
<div className="setting-card-compact">
<div className="setting-info">
<div className="setting-icon">
<span className="material-symbols-outlined">login</span>
</div>
<div className="setting-text">
<h4>{t.admin.showOnLoginScreen}</h4>
<p>{t.admin.showLanguageOnLoginDesc}</p>
</div>
</div>
<label className="toggle-modern">
<input
type="checkbox"
checked={showLanguageLogin}
onChange={(e) => {
setShowLanguageLogin(e.target.checked);
saveThemeToBackend({ showLanguageLogin: e.target.checked });
}}
/>
<span className="toggle-slider-modern"></span>
</label>
</div>
<div className="setting-card-compact">
<div className="setting-info">
<div className="setting-icon">
<span className="material-symbols-outlined">splitscreen</span>
</div>
<div className="setting-text">
<h4>{t.admin.controlLocation}</h4>
<p>{t.admin.controlLocationDesc}</p>
</div>
</div>
<label className="toggle-modern">
<input
type="checkbox"
checked={languageLocation === 'sidebar'}
onChange={(e) => {
const newLocation = e.target.checked ? 'sidebar' : 'user_menu';
setLanguageLocation(newLocation);
saveThemeToBackend({ languageLocation: newLocation });
}}
/>
<span className="toggle-slider-modern"></span>
</label>
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { useState, useEffect } from 'react';
import { useTranslation } from '../../contexts/LanguageContext';
interface Settings {
registration_enabled?: boolean;
}
export default function SettingsTab() {
const { t } = useTranslation();
const [settings, setSettings] = useState<Settings>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/v1/settings', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
const data = await response.json();
setSettings(data);
}
} catch (error) {
console.error('Failed to fetch settings:', error);
} finally {
setLoading(false);
}
};
const updateSetting = async (key: string, value: any) => {
setSaving(true);
try {
const token = localStorage.getItem('token');
const response = await fetch(`/api/v1/settings/${key}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ value }),
});
if (response.ok) {
const updatedSetting = await response.json();
setSettings(prev => ({
...prev,
[key]: updatedSetting.value
}));
}
} catch (error) {
console.error('Failed to update setting:', error);
} finally {
setSaving(false);
}
};
const handleRegistrationToggle = (checked: boolean) => {
updateSetting('registration_enabled', checked);
};
if (loading) {
return <div className="loading">{t.common.loading}</div>;
}
return (
<div className="settings-tab-content">
<div className="settings-section-modern">
<div className="section-header">
<h3 className="section-title">{t.settings.authentication}</h3>
</div>
<div className="setting-item-modern">
<div className="setting-info-modern">
<h4>{t.settings.allowRegistration}</h4>
<p>{t.settings.allowRegistrationDesc}</p>
</div>
<label className="toggle-modern">
<input
type="checkbox"
checked={settings.registration_enabled !== false}
onChange={(e) => handleRegistrationToggle(e.target.checked)}
disabled={saving}
/>
<span className="toggle-slider-modern"></span>
</label>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,819 @@
import { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import type { FormEvent } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { useTranslation } from '../../contexts/LanguageContext';
import { useModules, TOGGLEABLE_MODULES } from '../../contexts/ModulesContext';
import { usersAPI } from '../../api/client';
import type { User, UserCreate, UserUpdatePayload, UserPermissions } from '../../types';
type UserFormData = {
username: string;
email: string;
password: string;
is_active: boolean;
is_superuser: boolean;
hasCustomPermissions: boolean;
permissions: UserPermissions;
};
type SortColumn = 'username' | 'created_at' | 'last_login' | 'is_active' | 'is_superuser';
type SortDirection = 'asc' | 'desc';
// Get default permissions (all modules enabled)
const getDefaultPermissions = (): UserPermissions => {
const perms: UserPermissions = {};
TOGGLEABLE_MODULES.forEach(m => {
perms[m.id] = true;
});
return perms;
};
const emptyForm: UserFormData = {
username: '',
email: '',
password: '',
is_active: true,
is_superuser: false,
hasCustomPermissions: false,
permissions: getDefaultPermissions(),
};
// Helper function to format dates
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return new Intl.DateTimeFormat('it-IT', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(date);
};
export default function UsersTab() {
const { user: currentUser } = useAuth();
const { t } = useTranslation();
const { moduleStates } = useModules();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [isModalOpen, setModalOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [formData, setFormData] = useState<UserFormData>({ ...emptyForm });
const [searchTerm, setSearchTerm] = useState('');
const [sortColumn, setSortColumn] = useState<SortColumn>('username');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const [showActive, setShowActive] = useState(true);
const [showInactive, setShowInactive] = useState(true);
useEffect(() => {
const loadUsers = async () => {
setLoading(true);
setError('');
try {
const usersData = await usersAPI.list();
setUsers(usersData);
} catch (err: any) {
setError(err?.response?.data?.detail || t.usersPage.errorLoading);
} finally {
setLoading(false);
}
};
loadUsers();
}, [t.usersPage.errorLoading]);
const handleSort = (column: SortColumn) => {
if (sortColumn === column) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortColumn(column);
setSortDirection('asc');
}
};
const sortUsers = (userList: User[]): User[] => {
return [...userList].sort((a, b) => {
let comparison = 0;
switch (sortColumn) {
case 'username':
comparison = a.username.localeCompare(b.username);
break;
case 'created_at':
comparison = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
break;
case 'last_login':
const aLogin = a.last_login ? new Date(a.last_login).getTime() : 0;
const bLogin = b.last_login ? new Date(b.last_login).getTime() : 0;
comparison = aLogin - bLogin;
break;
case 'is_active':
comparison = (a.is_active === b.is_active) ? 0 : a.is_active ? -1 : 1;
break;
case 'is_superuser':
comparison = (a.is_superuser === b.is_superuser) ? 0 : a.is_superuser ? -1 : 1;
break;
}
return sortDirection === 'asc' ? comparison : -comparison;
});
};
const { superusers, regularUsers } = useMemo(() => {
// First filter by search term
const term = searchTerm.toLowerCase().trim();
let filtered = users;
if (term) {
filtered = users.filter(
(u) =>
u.username.toLowerCase().includes(term) ||
u.email.toLowerCase().includes(term)
);
}
// Then filter by status badges
filtered = filtered.filter((u) => {
if (u.is_active && !showActive) return false;
if (!u.is_active && !showInactive) return false;
return true;
});
// Separate and sort
const supers = sortUsers(filtered.filter((u) => u.is_superuser));
const regulars = sortUsers(filtered.filter((u) => !u.is_superuser));
return { superusers: supers, regularUsers: regulars };
}, [users, searchTerm, showActive, showInactive, sortColumn, sortDirection]);
const filteredUsers = useMemo(() => {
return [...superusers, ...regularUsers];
}, [superusers, regularUsers]);
const openCreateModal = () => {
setEditingUser(null);
setFormData({ ...emptyForm });
setModalOpen(true);
setError('');
};
const openEditModal = (user: User) => {
setEditingUser(user);
// Check if user has custom permissions (non-empty object)
const hasCustom = user.permissions && Object.keys(user.permissions).length > 0;
// Merge user's existing permissions with defaults (in case new modules were added)
const userPerms = { ...getDefaultPermissions(), ...(user.permissions || {}) };
setFormData({
username: user.username,
email: user.email,
password: '',
is_active: user.is_active,
is_superuser: user.is_superuser,
hasCustomPermissions: hasCustom,
permissions: userPerms,
});
setModalOpen(true);
setError('');
};
const closeModal = () => {
setModalOpen(false);
setError('');
setFormData({ ...emptyForm });
setEditingUser(null);
};
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSaving(true);
setError('');
try {
if (editingUser) {
if (formData.password && formData.password.trim().length > 0 && formData.password.trim().length < 8) {
throw new Error('password-too-short');
}
const payload: UserUpdatePayload = {
username: formData.username,
email: formData.email,
is_active: formData.is_active,
is_superuser: formData.is_superuser,
// Only send permissions if custom permissions are enabled, otherwise send empty object (inherit global)
permissions: formData.hasCustomPermissions ? formData.permissions : {},
};
if (formData.password.trim()) {
payload.password = formData.password;
}
const updated = await usersAPI.update(editingUser.id, payload);
setUsers((prev) => prev.map((u) => (u.id === updated.id ? updated : u)));
} else {
if (!formData.password.trim()) {
throw new Error('password-required');
}
if (formData.password.trim().length < 8) {
throw new Error('password-too-short');
}
const payload: UserCreate = {
username: formData.username,
email: formData.email,
password: formData.password,
is_active: formData.is_active,
is_superuser: formData.is_superuser,
// Only send permissions if custom permissions are enabled
permissions: formData.hasCustomPermissions ? formData.permissions : undefined,
};
const created = await usersAPI.create(payload);
setUsers((prev) => [created, ...prev]);
}
closeModal();
} catch (err: any) {
if (err?.message === 'password-required') {
setError(t.usersPage.passwordRequired);
} else if (err?.message === 'password-too-short') {
setError(t.usersPage.passwordTooShort);
} else {
setError(err?.response?.data?.detail || t.usersPage.saveError);
}
} finally {
setIsSaving(false);
}
};
const handleDelete = async (target: User) => {
if (currentUser?.id === target.id) {
setError(t.usersPage.selfDeleteWarning);
return;
}
const confirmed = window.confirm(t.usersPage.confirmDelete);
if (!confirmed) return;
setIsSaving(true);
setError('');
try {
await usersAPI.delete(target.id);
setUsers((prev) => prev.filter((u) => u.id !== target.id));
} catch (err: any) {
setError(err?.response?.data?.detail || t.usersPage.saveError);
} finally {
setIsSaving(false);
}
};
return (
<div className="users-root">
<div className="users-toolbar">
<div className="input-group">
<span className="material-symbols-outlined">search</span>
<input
type="text"
placeholder={t.usersPage.searchPlaceholder}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="toolbar-right">
<span
className="badge badge-success"
style={{ cursor: 'pointer', opacity: showActive ? 1 : 0.4 }}
onClick={() => setShowActive(!showActive)}
>
{users.filter((u) => u.is_active).length} {t.usersPage.active}
</span>
<span
className="badge badge-neutral"
style={{ cursor: 'pointer', opacity: showInactive ? 1 : 0.4 }}
onClick={() => setShowInactive(!showInactive)}
>
{users.filter((u) => !u.is_active).length} {t.usersPage.inactive}
</span>
<button className="btn-primary btn-sm" onClick={openCreateModal}>
<span className="material-symbols-outlined">add</span>
{t.usersPage.addUser}
</button>
</div>
</div>
{error && <div className="users-alert">{error}</div>}
{loading ? (
<div className="loading">{t.common.loading}</div>
) : filteredUsers.length === 0 ? (
<div className="users-empty">{t.usersPage.noUsers}</div>
) : (
<>
{/* Superusers Table */}
{superusers.length > 0 && (
<div className="users-card">
<div className="users-table-wrapper">
<table className="users-table">
<thead>
<tr>
<th className="sortable" onClick={() => handleSort('username')}>
{t.usersPage.name}
{sortColumn === 'username' && (
<span className="material-symbols-outlined sort-icon">
{sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward'}
</span>
)}
</th>
<th className="sortable" onClick={() => handleSort('created_at')}>
{t.usersPage.createdAt}
{sortColumn === 'created_at' && (
<span className="material-symbols-outlined sort-icon">
{sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward'}
</span>
)}
</th>
<th className="sortable" onClick={() => handleSort('last_login')}>
{t.usersPage.lastLogin}
{sortColumn === 'last_login' && (
<span className="material-symbols-outlined sort-icon">
{sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward'}
</span>
)}
</th>
<th className="sortable" onClick={() => handleSort('is_active')}>
{t.usersPage.status}
{sortColumn === 'is_active' && (
<span className="material-symbols-outlined sort-icon">
{sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward'}
</span>
)}
</th>
<th>
{t.usersPage.role}
</th>
<th>{t.usersPage.actions}</th>
</tr>
</thead>
<tbody>
{superusers.map((u) => (
<tr key={u.id} className="superuser-row">
<td>
<div className="user-cell">
<div className="user-avatar superuser">
{u.username.charAt(0).toUpperCase()}
</div>
<div className="user-meta">
<span className="user-name selectable">{u.username}</span>
<span className="user-email selectable">{u.email}</span>
</div>
</div>
</td>
<td>
<span className="user-date selectable">{formatDate(u.created_at)}</span>
</td>
<td>
<span className="user-date selectable">
{u.last_login ? formatDate(u.last_login) : '-'}
</span>
</td>
<td>
<span
className={`badge ${u.is_active ? 'badge-success' : 'badge-muted'}`}
>
{u.is_active ? t.usersPage.active : t.usersPage.inactive}
</span>
</td>
<td>
<span className="badge badge-accent">
{t.usersPage.superuser}
</span>
</td>
<td>
<div className="users-actions-icons">
<button
className="btn-icon-action btn-edit"
onClick={() => openEditModal(u)}
disabled={isSaving}
title={t.usersPage.edit}
>
<span className="material-symbols-outlined">edit</span>
</button>
<button
className="btn-icon-action btn-delete"
onClick={() => handleDelete(u)}
disabled={isSaving || currentUser?.id === u.id}
title={
currentUser?.id === u.id
? t.usersPage.selfDeleteWarning
: t.usersPage.delete
}
>
<span className="material-symbols-outlined">delete</span>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Regular Users Table */}
{regularUsers.length > 0 && (
<div className="users-card" style={{ marginTop: superusers.length > 0 ? '1rem' : 0 }}>
<div className="users-table-wrapper">
<table className="users-table">
<thead>
<tr>
<th className="sortable" onClick={() => handleSort('username')}>
{t.usersPage.name}
{sortColumn === 'username' && (
<span className="material-symbols-outlined sort-icon">
{sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward'}
</span>
)}
</th>
<th className="sortable" onClick={() => handleSort('created_at')}>
{t.usersPage.createdAt}
{sortColumn === 'created_at' && (
<span className="material-symbols-outlined sort-icon">
{sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward'}
</span>
)}
</th>
<th className="sortable" onClick={() => handleSort('last_login')}>
{t.usersPage.lastLogin}
{sortColumn === 'last_login' && (
<span className="material-symbols-outlined sort-icon">
{sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward'}
</span>
)}
</th>
<th className="sortable" onClick={() => handleSort('is_active')}>
{t.usersPage.status}
{sortColumn === 'is_active' && (
<span className="material-symbols-outlined sort-icon">
{sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward'}
</span>
)}
</th>
<th>
{t.usersPage.role}
</th>
<th>{t.usersPage.actions}</th>
</tr>
</thead>
<tbody>
{regularUsers.map((u) => (
<tr key={u.id}>
<td>
<div className="user-cell">
<div className="user-avatar">
{u.username.charAt(0).toUpperCase()}
</div>
<div className="user-meta">
<span className="user-name selectable">{u.username}</span>
<span className="user-email selectable">{u.email}</span>
</div>
</div>
</td>
<td>
<span className="user-date selectable">{formatDate(u.created_at)}</span>
</td>
<td>
<span className="user-date selectable">
{u.last_login ? formatDate(u.last_login) : '-'}
</span>
</td>
<td>
<span
className={`badge ${u.is_active ? 'badge-success' : 'badge-muted'}`}
>
{u.is_active ? t.usersPage.active : t.usersPage.inactive}
</span>
</td>
<td>
<span className="badge badge-neutral">
{t.usersPage.regular}
</span>
</td>
<td>
<div className="users-actions-icons">
<button
className="btn-icon-action btn-edit"
onClick={() => openEditModal(u)}
disabled={isSaving}
title={t.usersPage.edit}
>
<span className="material-symbols-outlined">edit</span>
</button>
<button
className="btn-icon-action btn-delete"
onClick={() => handleDelete(u)}
disabled={isSaving || currentUser?.id === u.id}
title={
currentUser?.id === u.id
? t.usersPage.selfDeleteWarning
: t.usersPage.delete
}
>
<span className="material-symbols-outlined">delete</span>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Mobile Cards */}
<div className="mobile-users-list">
{superusers.map((u) => (
<div key={u.id} className="mobile-user-card superuser-card">
<div className="mobile-user-header">
<div className="user-avatar superuser">
{u.username.charAt(0).toUpperCase()}
</div>
<div className="mobile-user-info">
<div className="mobile-user-name selectable">{u.username}</div>
<div className="mobile-user-email selectable">{u.email}</div>
</div>
<div className="mobile-user-actions">
<button
className="btn-icon-action btn-edit"
onClick={() => openEditModal(u)}
disabled={isSaving}
>
<span className="material-symbols-outlined">edit</span>
</button>
<button
className="btn-icon-action btn-delete"
onClick={() => handleDelete(u)}
disabled={isSaving || currentUser?.id === u.id}
>
<span className="material-symbols-outlined">delete</span>
</button>
</div>
</div>
<div className="mobile-user-footer">
<div className="mobile-badges-row">
<span className={`badge ${u.is_active ? 'badge-success' : 'badge-muted'}`}>
{u.is_active ? t.usersPage.active : t.usersPage.inactive}
</span>
<span className="badge badge-accent">
{t.usersPage.superuser}
</span>
</div>
<div className="mobile-dates">
<span className="mobile-date-item" title={t.usersPage.createdAt}>
<span className="material-symbols-outlined">calendar_add_on</span>
<span className="selectable">{formatDate(u.created_at)}</span>
</span>
<span className="mobile-date-item" title={t.usersPage.lastLogin}>
<span className="material-symbols-outlined">login</span>
<span className="selectable">{u.last_login ? formatDate(u.last_login) : '-'}</span>
</span>
</div>
</div>
</div>
))}
{/* Mobile Divider */}
{superusers.length > 0 && regularUsers.length > 0 && (
<div className="mobile-users-divider" />
)}
{/* Regular Users */}
{regularUsers.map((u) => (
<div key={u.id} className="mobile-user-card">
<div className="mobile-user-header">
<div className="user-avatar">
{u.username.charAt(0).toUpperCase()}
</div>
<div className="mobile-user-info">
<div className="mobile-user-name selectable">{u.username}</div>
<div className="mobile-user-email selectable">{u.email}</div>
</div>
<div className="mobile-user-actions">
<button
className="btn-icon-action btn-edit"
onClick={() => openEditModal(u)}
disabled={isSaving}
>
<span className="material-symbols-outlined">edit</span>
</button>
<button
className="btn-icon-action btn-delete"
onClick={() => handleDelete(u)}
disabled={isSaving || currentUser?.id === u.id}
>
<span className="material-symbols-outlined">delete</span>
</button>
</div>
</div>
<div className="mobile-user-footer">
<div className="mobile-badges-row">
<span className={`badge ${u.is_active ? 'badge-success' : 'badge-muted'}`}>
{u.is_active ? t.usersPage.active : t.usersPage.inactive}
</span>
<span className="badge badge-neutral">
{t.usersPage.regular}
</span>
</div>
<div className="mobile-dates">
<span className="mobile-date-item" title={t.usersPage.createdAt}>
<span className="material-symbols-outlined">calendar_add_on</span>
<span className="selectable">{formatDate(u.created_at)}</span>
</span>
<span className="mobile-date-item" title={t.usersPage.lastLogin}>
<span className="material-symbols-outlined">login</span>
<span className="selectable">{u.last_login ? formatDate(u.last_login) : '-'}</span>
</span>
</div>
</div>
</div>
))}
</div>
</>
)}
{isModalOpen && createPortal(
<div className="users-modal-backdrop" onClick={closeModal}>
<div className="users-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>
{editingUser ? t.usersPage.editUser : t.usersPage.addUser}
</h2>
<button className="btn-close-modal" onClick={closeModal} aria-label={t.common.close}>
<span className="material-symbols-outlined">close</span>
</button>
</div>
{error && <div className="users-alert in-modal">{error}</div>}
<form onSubmit={handleSubmit} className="users-form">
<div className="form-row">
<label htmlFor="username">{t.usersPage.name}</label>
<input
id="username"
type="text"
value={formData.username}
onChange={(e) =>
setFormData((prev) => ({ ...prev, username: e.target.value }))
}
required
minLength={3}
/>
</div>
<div className="form-row">
<label htmlFor="email">{t.usersPage.email}</label>
<input
id="email"
type="email"
value={formData.email}
onChange={(e) =>
setFormData((prev) => ({ ...prev, email: e.target.value }))
}
required
/>
</div>
<div className="form-row">
<label htmlFor="password">
{t.usersPage.password}{' '}
{editingUser ? (
<span className="helper-text">{t.usersPage.passwordHintEdit}</span>
) : (
<span className="helper-text">{t.usersPage.passwordHintCreate}</span>
)}
</label>
<input
id="password"
type="password"
value={formData.password}
onChange={(e) =>
setFormData((prev) => ({ ...prev, password: e.target.value }))
}
minLength={formData.password ? 8 : undefined}
/>
</div>
{/* Status Section */}
<div className="form-section">
<label className="form-section-title">{t.usersPage.status}</label>
<div className="form-grid">
<label className="checkbox-row">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) =>
setFormData((prev) => ({ ...prev, is_active: e.target.checked }))
}
/>
<span>{t.usersPage.isActive}</span>
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={formData.is_superuser}
onChange={(e) =>
setFormData((prev) => ({
...prev,
is_superuser: e.target.checked,
}))
}
/>
<span>{t.usersPage.isSuperuser}</span>
</label>
</div>
</div>
{/* Permissions Section - only show for non-superuser */}
{!formData.is_superuser && (
<div className="form-section">
<div className="form-section-header">
<label className="form-section-title">{t.usersPage.permissions}</label>
<label className="toggle-inline">
<input
type="checkbox"
checked={formData.hasCustomPermissions}
onChange={(e) =>
setFormData((prev) => ({
...prev,
hasCustomPermissions: e.target.checked,
}))
}
/>
<span className="toggle-slider-sm"></span>
<span className="toggle-label">{t.usersPage.customPermissions}</span>
</label>
</div>
{formData.hasCustomPermissions ? (
<div className="permissions-grid">
{TOGGLEABLE_MODULES.map((module) => {
const isGloballyDisabled = !moduleStates[module.id];
const isChecked = formData.permissions[module.id] ?? true;
const moduleName = t.sidebar[module.id as keyof typeof t.sidebar] || module.id;
return (
<label
key={module.id}
className={`checkbox-row ${isGloballyDisabled ? 'disabled' : ''}`}
title={isGloballyDisabled ? t.usersPage.moduleDisabledGlobally : ''}
>
<input
type="checkbox"
checked={isGloballyDisabled ? false : isChecked}
disabled={isGloballyDisabled}
onChange={(e) =>
setFormData((prev) => ({
...prev,
permissions: {
...prev.permissions,
[module.id]: e.target.checked,
},
}))
}
/>
<span className="material-symbols-outlined">{module.icon}</span>
<span>{moduleName}</span>
{isGloballyDisabled && (
<span className="badge badge-muted badge-sm">{t.usersPage.disabled}</span>
)}
</label>
);
})}
</div>
) : (
<div className="permissions-default-hint">
{t.usersPage.usingDefaultPermissions}
</div>
)}
</div>
)}
<div className="modal-actions">
<button type="button" className="btn-ghost" onClick={closeModal}>
{t.common.cancel}
</button>
<button type="submit" className="btn-primary" disabled={isSaving}>
{isSaving ? t.common.loading : t.usersPage.save}
</button>
</div>
</form>
</div>
</div>,
document.body
)}
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { createContext, useContext, useState, useEffect } from 'react';
import type { ReactNode } from 'react';
import { authAPI } from '../api/client';
import type { AuthContextType, User } from '../types';
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadUser = async () => {
if (token) {
try {
const userData = await authAPI.getCurrentUser();
setUser(userData);
} catch (error) {
console.error('Failed to load user:', error);
localStorage.removeItem('token');
setToken(null);
}
}
setIsLoading(false);
};
loadUser();
}, [token]);
const login = async (username: string, password: string) => {
setIsLoading(true);
try {
const response = await authAPI.login({ username, password });
localStorage.setItem('token', response.access_token);
setToken(response.access_token);
const userData = await authAPI.getCurrentUser();
setUser(userData);
} catch (error) {
console.error('Login failed:', error);
throw error;
} finally {
setIsLoading(false);
}
};
const register = async (username: string, email: string, password: string) => {
setIsLoading(true);
try {
await authAPI.register({ username, email, password });
await login(username, password);
} catch (error) {
console.error('Registration failed:', error);
throw error;
} finally {
setIsLoading(false);
}
};
const logout = () => {
localStorage.removeItem('token');
setToken(null);
setUser(null);
};
return (
<AuthContext.Provider value={{ user, token, login, register, logout, isLoading }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -0,0 +1,56 @@
import { createContext, useContext, useState, useEffect } from 'react';
import type { ReactNode } from 'react';
import it from '../locales/it.json';
import en from '../locales/en.json';
type Language = 'it' | 'en';
type Translations = typeof it;
interface LanguageContextType {
language: Language;
setLanguage: (lang: Language) => void;
t: Translations;
}
const translations: Record<Language, Translations> = {
it,
en,
};
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
export function LanguageProvider({ children }: { children: ReactNode }) {
const [language, setLanguageState] = useState<Language>(() => {
const saved = localStorage.getItem('language');
if (saved) return saved as Language;
// Try to detect from browser
const browserLang = navigator.language.split('-')[0];
return browserLang === 'it' ? 'it' : 'en';
});
const setLanguage = (lang: Language) => {
setLanguageState(lang);
localStorage.setItem('language', lang);
};
useEffect(() => {
document.documentElement.lang = language;
}, [language]);
const value = {
language,
setLanguage,
t: translations[language],
};
return <LanguageContext.Provider value={value}>{children}</LanguageContext.Provider>;
}
export function useTranslation() {
const context = useContext(LanguageContext);
if (context === undefined) {
throw new Error('useTranslation must be used within a LanguageProvider');
}
return context;
}

View File

@@ -0,0 +1,203 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import type { ReactNode } from 'react';
import { settingsAPI } from '../api/client';
import type { UserPermissions } from '../types';
// User-facing modules that can be toggled
export const TOGGLEABLE_MODULES = [
{ id: 'feature1', icon: 'playlist_play', defaultEnabled: true },
{ id: 'feature2', icon: 'download', defaultEnabled: true },
{ id: 'feature3', icon: 'cast', defaultEnabled: true },
] as const;
export type ModuleId = typeof TOGGLEABLE_MODULES[number]['id'];
export interface ModuleState {
admin: boolean;
user: boolean;
}
interface ModulesContextType {
moduleStates: Record<ModuleId, ModuleState>;
isModuleEnabled: (moduleId: string) => boolean;
isModuleEnabledForUser: (moduleId: string, userPermissions: UserPermissions | undefined, isSuperuser: boolean) => boolean;
setModuleEnabled: (moduleId: ModuleId, type: 'admin' | 'user', enabled: boolean) => void;
saveModulesToBackend: () => Promise<void>;
isLoading: boolean;
hasInitialized: boolean;
}
const ModulesContext = createContext<ModulesContextType | undefined>(undefined);
// Default states
const getDefaultStates = (): Record<ModuleId, ModuleState> => {
const states: Record<string, ModuleState> = {};
TOGGLEABLE_MODULES.forEach(m => {
states[m.id] = { admin: m.defaultEnabled, user: m.defaultEnabled };
});
return states as Record<ModuleId, ModuleState>;
};
export function ModulesProvider({ children }: { children: ReactNode }) {
const [moduleStates, setModuleStates] = useState<Record<ModuleId, ModuleState>>(getDefaultStates);
const [isLoading, setIsLoading] = useState(true);
const [hasInitialized, setHasInitialized] = useState(false);
// Load module settings from backend
const loadModulesFromBackend = useCallback(async () => {
try {
const settings = await settingsAPI.getModules();
const newStates = { ...getDefaultStates() };
TOGGLEABLE_MODULES.forEach(m => {
const adminKey = `module_${m.id}_admin_enabled`;
const userKey = `module_${m.id}_user_enabled`;
// Helper to safely parse boolean
const parseBool = (val: any, defaultVal: boolean): boolean => {
if (val === undefined || val === null) return defaultVal;
if (val === true || val === 'true' || val === 1 || val === '1') return true;
if (val === false || val === 'false' || val === 0 || val === '0') return false;
return defaultVal;
};
// Check for new keys
// If key exists in settings, use it (parsed). If not, use defaultEnabled.
// Crucially, if settings[key] is present, we MUST use it, even if it parses to false.
if (settings[adminKey] !== undefined) {
newStates[m.id].admin = parseBool(settings[adminKey], m.defaultEnabled);
} else {
newStates[m.id].admin = m.defaultEnabled;
}
if (settings[userKey] !== undefined) {
newStates[m.id].user = parseBool(settings[userKey], m.defaultEnabled);
} else {
newStates[m.id].user = m.defaultEnabled;
}
// Fallback for backward compatibility (if old key exists)
const oldKey = `module_${m.id}_enabled`;
if (settings[oldKey] !== undefined && settings[adminKey] === undefined) {
const val = parseBool(settings[oldKey], m.defaultEnabled);
newStates[m.id].admin = val;
newStates[m.id].user = val;
}
});
setModuleStates(newStates);
setHasInitialized(true);
} catch (error) {
console.error('Failed to load modules from backend:', error);
setHasInitialized(true); // Even on error, mark as initialized to prevent saving defaults
} finally {
setIsLoading(false);
}
}, []);
// Save module settings to backend
const saveModulesToBackend = useCallback(async () => {
try {
const data: Record<string, boolean> = {};
TOGGLEABLE_MODULES.forEach(m => {
data[`module_${m.id}_admin_enabled`] = moduleStates[m.id].admin;
data[`module_${m.id}_user_enabled`] = moduleStates[m.id].user;
});
await settingsAPI.updateModules(data);
} catch (error) {
console.error('Failed to save modules to backend:', error);
throw error;
}
}, [moduleStates]);
// Load modules on mount when token exists
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
loadModulesFromBackend();
} else {
setIsLoading(false);
setHasInitialized(true); // No token, mark as initialized
}
}, [loadModulesFromBackend]);
const isModuleEnabled = useCallback((moduleId: string): boolean => {
// Dashboard is always enabled
if (moduleId === 'dashboard') return true;
// Admin modules are always enabled for admins
if (moduleId === 'users' || moduleId === 'settings') return true;
const state = moduleStates[moduleId as ModuleId];
return state ? state.admin : true;
}, [moduleStates]);
// Check if module is enabled for a specific user (considering global state + user permissions)
const isModuleEnabledForUser = useCallback((
moduleId: string,
userPermissions: UserPermissions | undefined,
isSuperuser: boolean
): boolean => {
// Dashboard is always enabled
if (moduleId === 'dashboard') return true;
// Admin modules are always enabled for admins
if (moduleId === 'users' || moduleId === 'settings') return true;
const state = moduleStates[moduleId as ModuleId];
if (!state) return true;
// 1. If disabled for admin, it's disabled for everyone
if (!state.admin) return false;
// 2. If superuser, they have access (since admin is enabled)
if (isSuperuser) return true;
// 3. If disabled for users globally, regular users can't access
if (!state.user) return false;
// 4. Check user-specific permissions
if (userPermissions && userPermissions[moduleId] !== undefined) {
return userPermissions[moduleId];
}
// Default: enabled
return true;
}, [moduleStates]);
const setModuleEnabled = useCallback((moduleId: ModuleId, type: 'admin' | 'user', enabled: boolean) => {
setModuleStates(prev => {
const newState = { ...prev };
newState[moduleId] = { ...newState[moduleId], [type]: enabled };
// If admin is disabled, user must be disabled too
if (type === 'admin' && !enabled) {
newState[moduleId].user = false;
}
return newState;
});
}, []);
return (
<ModulesContext.Provider
value={{
moduleStates,
isModuleEnabled,
isModuleEnabledForUser,
setModuleEnabled,
saveModulesToBackend,
isLoading,
hasInitialized,
}}
>
{children}
</ModulesContext.Provider>
);
}
export function useModules() {
const context = useContext(ModulesContext);
if (context === undefined) {
throw new Error('useModules must be used within a ModulesProvider');
}
return context;
}

View File

@@ -0,0 +1,127 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import type { ReactNode } from 'react';
export type SidebarMode = 'collapsed' | 'expanded' | 'toggle' | 'dynamic';
interface SidebarContextType {
isCollapsed: boolean;
isMobileOpen: boolean;
sidebarMode: SidebarMode;
canToggle: boolean;
toggleCollapse: () => void;
toggleMobileMenu: () => void;
closeMobileMenu: () => void;
setSidebarMode: (mode: SidebarMode) => Promise<void>;
isHovered: boolean;
setIsHovered: (isHovered: boolean) => void;
showLogo: boolean;
setShowLogo: (show: boolean) => void;
}
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
export function SidebarProvider({ children }: { children: ReactNode }) {
const [userCollapsed, setUserCollapsed] = useState(true);
const [isMobileOpen, setIsMobileOpen] = useState(false);
const [sidebarMode, setSidebarModeState] = useState<SidebarMode>('toggle');
const [isHovered, setIsHovered] = useState(false);
const [showLogo, setShowLogo] = useState(false);
// Load sidebar mode from backend
useEffect(() => {
const loadSidebarMode = async () => {
try {
const token = localStorage.getItem('token');
if (!token) return;
const response = await fetch('/api/v1/settings', {
headers: { 'Authorization': `Bearer ${token} ` }
});
if (response.ok) {
const data = await response.json();
if (data.sidebar_mode && ['collapsed', 'expanded', 'toggle', 'dynamic'].includes(data.sidebar_mode)) {
setSidebarModeState(data.sidebar_mode as SidebarMode);
}
if (data.show_logo !== undefined) {
setShowLogo(data.show_logo === true);
}
}
} catch (error) {
console.error('Failed to load sidebar mode:', error);
}
};
loadSidebarMode();
}, []);
// Compute isCollapsed based on mode
const isCollapsed = sidebarMode === 'collapsed' ? true :
sidebarMode === 'dynamic' ? true :
sidebarMode === 'expanded' ? false :
userCollapsed;
// Can only toggle if mode is 'toggle'
const canToggle = sidebarMode === 'toggle';
const toggleCollapse = () => {
if (canToggle) {
setUserCollapsed((prev) => !prev);
}
};
const toggleMobileMenu = () => {
setIsMobileOpen((prev) => !prev);
};
const closeMobileMenu = () => {
setIsMobileOpen(false);
};
const setSidebarMode = useCallback(async (mode: SidebarMode) => {
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/v1/settings/sidebar_mode', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token} `,
'Content-Type': 'application/json',
},
body: JSON.stringify({ value: mode }),
});
if (response.ok) {
setSidebarModeState(mode);
}
} catch (error) {
console.error('Failed to save sidebar mode:', error);
}
}, []);
return (
<SidebarContext.Provider
value={{
isCollapsed,
isMobileOpen,
sidebarMode,
canToggle,
toggleCollapse,
toggleMobileMenu,
closeMobileMenu,
setSidebarMode,
isHovered,
setIsHovered: (value: boolean) => setIsHovered(value),
showLogo,
setShowLogo,
}}
>
{children}
</SidebarContext.Provider>
);
}
export function useSidebar() {
const context = useContext(SidebarContext);
if (context === undefined) {
throw new Error('useSidebar must be used within a SidebarProvider');
}
return context;
}

View File

@@ -0,0 +1,41 @@
import { createContext, useContext, type ReactNode } from 'react';
import siteConfig, { type SiteConfig, isFeatureEnabled } from '../config/site.config';
interface SiteConfigContextValue {
config: SiteConfig;
isFeatureEnabled: (feature: keyof SiteConfig['features']) => boolean;
}
const SiteConfigContext = createContext<SiteConfigContextValue | null>(null);
export function SiteConfigProvider({ children }: { children: ReactNode }) {
const value: SiteConfigContextValue = {
config: siteConfig,
isFeatureEnabled,
};
return (
<SiteConfigContext.Provider value={value}>
{children}
</SiteConfigContext.Provider>
);
}
export function useSiteConfig(): SiteConfigContextValue {
const context = useContext(SiteConfigContext);
if (!context) {
throw new Error('useSiteConfig must be used within a SiteConfigProvider');
}
return context;
}
// Shortcut hooks
export function useSiteName(): string {
const { config } = useSiteConfig();
return config.name;
}
export function useSiteFeature(feature: keyof SiteConfig['features']): boolean {
const { isFeatureEnabled } = useSiteConfig();
return isFeatureEnabled(feature);
}

View File

@@ -0,0 +1,768 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import type { ReactNode } from 'react';
import { settingsAPI } from '../api/client';
type Theme = 'light' | 'dark';
export type AccentColor = 'blue' | 'purple' | 'green' | 'orange' | 'pink' | 'red' | 'teal' | 'amber' | 'indigo' | 'cyan' | 'rose' | 'auto';
export type BorderRadius = 'small' | 'medium' | 'large';
export type SidebarStyle = 'default' | 'dark' | 'light';
export type Density = 'compact' | 'comfortable' | 'spacious';
export type FontFamily = 'sans' | 'inter' | 'roboto';
export type ColorPalette = 'default' | 'monochrome' | 'monochromeBlue' | 'sepia' | 'nord' | 'dracula' | 'solarized' | 'github' | 'ocean' | 'forest' | 'midnight' | 'sunset';
export type ControlLocation = 'sidebar' | 'user_menu';
interface ThemeContextType {
theme: Theme;
accentColor: AccentColor;
borderRadius: BorderRadius;
sidebarStyle: SidebarStyle;
density: Density;
fontFamily: FontFamily;
colorPalette: ColorPalette;
customColors: Partial<PaletteColors>;
darkModeLocation: ControlLocation;
languageLocation: ControlLocation;
showDarkModeToggle: boolean;
showLanguageToggle: boolean;
showDarkModeLogin: boolean;
showLanguageLogin: boolean;
toggleTheme: () => void;
setAccentColor: (color: AccentColor) => void;
setBorderRadius: (radius: BorderRadius) => void;
setSidebarStyle: (style: SidebarStyle) => void;
setDensity: (density: Density) => void;
setFontFamily: (font: FontFamily) => void;
setColorPalette: (palette: ColorPalette) => void;
setCustomColors: (colors: Partial<PaletteColors>) => void;
setDarkModeLocation: (location: ControlLocation) => void;
setLanguageLocation: (location: ControlLocation) => void;
setShowDarkModeToggle: (show: boolean) => void;
setShowLanguageToggle: (show: boolean) => void;
setShowDarkModeLogin: (show: boolean) => void;
setShowLanguageLogin: (show: boolean) => void;
loadThemeFromBackend: () => Promise<void>;
saveThemeToBackend: (overrides?: Partial<{
accentColor: AccentColor;
borderRadius: BorderRadius;
sidebarStyle: SidebarStyle;
density: Density;
fontFamily: FontFamily;
colorPalette: ColorPalette;
customColors: Partial<PaletteColors>;
darkModeLocation: ControlLocation;
languageLocation: ControlLocation;
showDarkModeToggle: boolean;
showLanguageToggle: boolean;
showDarkModeLogin: boolean;
showLanguageLogin: boolean;
}>) => Promise<void>;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
const ACCENT_COLORS: Record<AccentColor, { main: string; hover: string; darkMain?: string; darkHover?: string }> = {
blue: { main: '#3b82f6', hover: '#2563eb' },
purple: { main: '#8b5cf6', hover: '#7c3aed' },
green: { main: '#10b981', hover: '#059669' },
orange: { main: '#f97316', hover: '#ea580c' },
pink: { main: '#ec4899', hover: '#db2777' },
red: { main: '#ef4444', hover: '#dc2626' },
teal: { main: '#14b8a6', hover: '#0d9488' },
amber: { main: '#f59e0b', hover: '#d97706' },
indigo: { main: '#6366f1', hover: '#4f46e5' },
cyan: { main: '#06b6d4', hover: '#0891b2' },
rose: { main: '#f43f5e', hover: '#e11d48' },
auto: { main: '#374151', hover: '#1f2937', darkMain: '#838b99', darkHover: '#b5bcc8' },
};
const BORDER_RADII: Record<BorderRadius, { sm: string; md: string; lg: string }> = {
small: { sm: '2px', md: '4px', lg: '6px' },
medium: { sm: '4px', md: '6px', lg: '8px' },
large: { sm: '8px', md: '12px', lg: '16px' },
};
const DENSITIES: Record<Density, {
button: string;
input: string;
nav: string;
scale: number;
cardPadding: string;
cardPaddingSm: string;
cardGap: string;
sectionGap: string;
elementGap: string;
pagePaddingX: string;
pagePaddingY: string;
}> = {
compact: {
button: '32px',
input: '36px',
nav: '36px',
scale: 0.85,
cardPadding: '0.75rem',
cardPaddingSm: '0.5rem',
cardGap: '0.5rem',
sectionGap: '1rem',
elementGap: '0.375rem',
pagePaddingX: '1.5rem',
pagePaddingY: '1.5rem',
},
comfortable: {
button: '36px',
input: '40px',
nav: '40px',
scale: 1,
cardPadding: '1rem',
cardPaddingSm: '0.75rem',
cardGap: '0.75rem',
sectionGap: '1.5rem',
elementGap: '0.5rem',
pagePaddingX: '2rem',
pagePaddingY: '2rem',
},
spacious: {
button: '44px',
input: '48px',
nav: '48px',
scale: 1.15,
cardPadding: '1.5rem',
cardPaddingSm: '1rem',
cardGap: '1rem',
sectionGap: '2rem',
elementGap: '0.75rem',
pagePaddingX: '2.5rem',
pagePaddingY: '2.5rem',
},
};
const FONTS: Record<FontFamily, string> = {
sans: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
inter: '"Inter", sans-serif',
roboto: '"Roboto", sans-serif',
};
interface PaletteColors {
light: {
bgMain: string;
bgCard: string;
bgElevated: string;
textPrimary: string;
textSecondary: string;
border: string;
sidebarBg: string;
sidebarText: string;
};
dark: {
bgMain: string;
bgCard: string;
bgElevated: string;
textPrimary: string;
textSecondary: string;
border: string;
sidebarBg: string;
sidebarText: string;
};
}
export const COLOR_PALETTES: Record<ColorPalette, PaletteColors> = {
default: {
light: {
bgMain: '#ffffff',
bgCard: '#f9fafb',
bgElevated: '#ffffff',
textPrimary: '#111827',
textSecondary: '#6b7280',
border: '#e5e7eb',
sidebarBg: '#1f2937',
sidebarText: '#f9fafb',
},
dark: {
bgMain: '#0f172a',
bgCard: '#1e293b',
bgElevated: '#334155',
textPrimary: '#f1f5f9',
textSecondary: '#94a3b8',
border: '#334155',
sidebarBg: '#0c1222',
sidebarText: '#f9fafb',
},
},
monochrome: {
light: {
bgMain: '#fafafa',
bgCard: '#f5f5f5',
bgElevated: '#ffffff',
textPrimary: '#171717',
textSecondary: '#737373',
border: '#e5e5e5',
sidebarBg: '#262626',
sidebarText: '#fafafa',
},
dark: {
bgMain: '#0a0a0a',
bgCard: '#171717',
bgElevated: '#262626',
textPrimary: '#fafafa',
textSecondary: '#a3a3a3',
border: '#333333',
sidebarBg: '#0a0a0a',
sidebarText: '#fafafa',
},
},
monochromeBlue: {
light: {
bgMain: '#f8fafc',
bgCard: '#f1f5f9',
bgElevated: '#ffffff',
textPrimary: '#0f172a',
textSecondary: '#64748b',
border: '#e2e8f0',
sidebarBg: '#1e293b',
sidebarText: '#f8fafc',
},
dark: {
bgMain: '#020617',
bgCard: '#0f172a',
bgElevated: '#1e293b',
textPrimary: '#f8fafc',
textSecondary: '#94a3b8',
border: '#1e293b',
sidebarBg: '#020617',
sidebarText: '#f8fafc',
},
},
sepia: {
light: {
bgMain: '#faf8f5',
bgCard: '#f5f0e8',
bgElevated: '#ffffff',
textPrimary: '#3d3229',
textSecondary: '#7a6f5f',
border: '#e8e0d4',
sidebarBg: '#4a3f33',
sidebarText: '#faf8f5',
},
dark: {
bgMain: '#1a1612',
bgCard: '#2a241e',
bgElevated: '#3a332b',
textPrimary: '#f5efe6',
textSecondary: '#b8a990',
border: '#3a332b',
sidebarBg: '#12100d',
sidebarText: '#f5efe6',
},
},
nord: {
light: {
bgMain: '#eceff4',
bgCard: '#e5e9f0',
bgElevated: '#ffffff',
textPrimary: '#2e3440',
textSecondary: '#4c566a',
border: '#d8dee9',
sidebarBg: '#3b4252',
sidebarText: '#eceff4',
},
dark: {
bgMain: '#2e3440',
bgCard: '#3b4252',
bgElevated: '#434c5e',
textPrimary: '#eceff4',
textSecondary: '#d8dee9',
border: '#434c5e',
sidebarBg: '#242933',
sidebarText: '#eceff4',
},
},
dracula: {
light: {
bgMain: '#f8f8f2',
bgCard: '#f0f0e8',
bgElevated: '#ffffff',
textPrimary: '#282a36',
textSecondary: '#6272a4',
border: '#e0e0d8',
sidebarBg: '#44475a',
sidebarText: '#f8f8f2',
},
dark: {
bgMain: '#282a36',
bgCard: '#343746',
bgElevated: '#44475a',
textPrimary: '#f8f8f2',
textSecondary: '#6272a4',
border: '#44475a',
sidebarBg: '#21222c',
sidebarText: '#f8f8f2',
},
},
solarized: {
light: {
bgMain: '#fdf6e3',
bgCard: '#eee8d5',
bgElevated: '#ffffff',
textPrimary: '#073642',
textSecondary: '#586e75',
border: '#e0d8c0',
sidebarBg: '#073642',
sidebarText: '#fdf6e3',
},
dark: {
bgMain: '#002b36',
bgCard: '#073642',
bgElevated: '#094050',
textPrimary: '#fdf6e3',
textSecondary: '#93a1a1',
border: '#094050',
sidebarBg: '#001e26',
sidebarText: '#fdf6e3',
},
},
github: {
light: {
bgMain: '#ffffff',
bgCard: '#f6f8fa',
bgElevated: '#ffffff',
textPrimary: '#24292f',
textSecondary: '#57606a',
border: '#d0d7de',
sidebarBg: '#24292f',
sidebarText: '#f6f8fa',
},
dark: {
bgMain: '#0d1117',
bgCard: '#161b22',
bgElevated: '#21262d',
textPrimary: '#c9d1d9',
textSecondary: '#8b949e',
border: '#30363d',
sidebarBg: '#010409',
sidebarText: '#c9d1d9',
},
},
ocean: {
light: {
bgMain: '#f0f9ff',
bgCard: '#e0f2fe',
bgElevated: '#ffffff',
textPrimary: '#0c4a6e',
textSecondary: '#0369a1',
border: '#bae6fd',
sidebarBg: '#0c4a6e',
sidebarText: '#f0f9ff',
},
dark: {
bgMain: '#082f49',
bgCard: '#0c4a6e',
bgElevated: '#0369a1',
textPrimary: '#e0f2fe',
textSecondary: '#7dd3fc',
border: '#0369a1',
sidebarBg: '#042038',
sidebarText: '#e0f2fe',
},
},
forest: {
light: {
bgMain: '#f0fdf4',
bgCard: '#dcfce7',
bgElevated: '#ffffff',
textPrimary: '#14532d',
textSecondary: '#166534',
border: '#bbf7d0',
sidebarBg: '#14532d',
sidebarText: '#f0fdf4',
},
dark: {
bgMain: '#052e16',
bgCard: '#14532d',
bgElevated: '#166534',
textPrimary: '#dcfce7',
textSecondary: '#86efac',
border: '#166534',
sidebarBg: '#022c22',
sidebarText: '#dcfce7',
},
},
midnight: {
light: {
bgMain: '#f5f3ff',
bgCard: '#ede9fe',
bgElevated: '#ffffff',
textPrimary: '#1e1b4b',
textSecondary: '#4338ca',
border: '#c7d2fe',
sidebarBg: '#1e1b4b',
sidebarText: '#f5f3ff',
},
dark: {
bgMain: '#0c0a1d',
bgCard: '#1e1b4b',
bgElevated: '#312e81',
textPrimary: '#e0e7ff',
textSecondary: '#a5b4fc',
border: '#312e81',
sidebarBg: '#050311',
sidebarText: '#e0e7ff',
},
},
sunset: {
light: {
bgMain: '#fff7ed',
bgCard: '#ffedd5',
bgElevated: '#ffffff',
textPrimary: '#7c2d12',
textSecondary: '#c2410c',
border: '#fed7aa',
sidebarBg: '#7c2d12',
sidebarText: '#fff7ed',
},
dark: {
bgMain: '#1c0a04',
bgCard: '#431407',
bgElevated: '#7c2d12',
textPrimary: '#fed7aa',
textSecondary: '#fdba74',
border: '#7c2d12',
sidebarBg: '#0f0502',
sidebarText: '#fed7aa',
},
},
};
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
return (localStorage.getItem('theme') as Theme) || 'light';
});
// ============================================================================
// GLOBAL SETTINGS (from database, admin-controlled)
// These use registry defaults and are loaded from backend via loadThemeFromBackend()
// ============================================================================
const [accentColor, setAccentColorState] = useState<AccentColor>('auto');
const [borderRadius, setBorderRadiusState] = useState<BorderRadius>('large');
const [sidebarStyle, setSidebarStyleState] = useState<SidebarStyle>('default');
const [density, setDensityState] = useState<Density>('compact');
const [fontFamily, setFontFamilyState] = useState<FontFamily>('sans');
const [colorPalette, setColorPaletteState] = useState<ColorPalette>('monochrome');
const [customColors, setCustomColorsState] = useState<Partial<PaletteColors>>({});
const [darkModeLocation, setDarkModeLocationState] = useState<ControlLocation>('sidebar');
const [languageLocation, setLanguageLocationState] = useState<ControlLocation>('sidebar');
const [showDarkModeToggle, setShowDarkModeToggleState] = useState<boolean>(true);
const [showLanguageToggle, setShowLanguageToggleState] = useState<boolean>(false);
const [showDarkModeLogin, setShowDarkModeLoginState] = useState<boolean>(true);
const [showLanguageLogin, setShowLanguageLoginState] = useState<boolean>(false);
useEffect(() => {
const root = document.documentElement;
root.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}, [theme]);
useEffect(() => {
const root = document.documentElement;
const colors = ACCENT_COLORS[accentColor];
// For 'auto' accent, use dark colors in dark mode
const main = (theme === 'dark' && colors.darkMain) ? colors.darkMain : colors.main;
const hover = (theme === 'dark' && colors.darkHover) ? colors.darkHover : colors.hover;
root.style.setProperty('--color-accent', main);
root.style.setProperty('--color-accent-hover', hover);
}, [accentColor, theme]);
useEffect(() => {
const root = document.documentElement;
const radii = BORDER_RADII[borderRadius];
root.style.setProperty('--radius-sm', radii.sm);
root.style.setProperty('--radius-md', radii.md);
root.style.setProperty('--radius-lg', radii.lg);
}, [borderRadius]);
useEffect(() => {
const root = document.documentElement;
const d = DENSITIES[density];
// Component heights
root.style.setProperty('--height-button', d.button);
root.style.setProperty('--height-input', d.input);
root.style.setProperty('--height-nav-item', d.nav);
root.style.setProperty('--btn-height', d.button);
// Density scale factor (for calc() usage)
root.style.setProperty('--density-scale', d.scale.toString());
// Semantic spacing
root.style.setProperty('--card-padding', d.cardPadding);
root.style.setProperty('--card-padding-sm', d.cardPaddingSm);
root.style.setProperty('--card-gap', d.cardGap);
root.style.setProperty('--section-gap', d.sectionGap);
root.style.setProperty('--element-gap', d.elementGap);
// Page padding
root.style.setProperty('--page-padding-x', d.pagePaddingX);
root.style.setProperty('--page-padding-y', d.pagePaddingY);
}, [density]);
useEffect(() => {
const root = document.documentElement;
root.style.setProperty('--font-sans', FONTS[fontFamily]);
}, [fontFamily]);
// Apply color palette with overrides
useEffect(() => {
const root = document.documentElement;
const palette = COLOR_PALETTES[colorPalette];
const baseColors = theme === 'dark' ? palette.dark : palette.light;
// Apply overrides if they exist for the current theme mode
const overrides = theme === 'dark' ? customColors.dark : customColors.light;
const appColors = { ...baseColors, ...overrides };
root.style.setProperty('--color-bg-main', appColors.bgMain);
root.style.setProperty('--color-bg-card', appColors.bgCard);
root.style.setProperty('--color-bg-elevated', appColors.bgElevated);
root.style.setProperty('--color-text-primary', appColors.textPrimary);
root.style.setProperty('--color-text-secondary', appColors.textSecondary);
root.style.setProperty('--color-border', appColors.border);
// Determine sidebar colors based on sidebarStyle
let sidebarBg, sidebarText, sidebarBorder;
if (sidebarStyle === 'light') {
// Always Light: Use light palette colors (bgCard for slight contrast vs bgMain if main is white)
// Check for light mode overrides
const lightOverrides = customColors.light || {};
const lightBase = palette.light;
const lightColors = { ...lightBase, ...lightOverrides };
sidebarBg = lightColors.bgCard;
sidebarText = lightColors.textPrimary;
sidebarBorder = lightColors.border;
} else {
// Default or Always Dark: Use the theme's defined sidebar color (which is typically dark)
// This respects the user's preference for the default dark sidebar in light mode
sidebarBg = appColors.sidebarBg;
sidebarText = appColors.sidebarText;
// Increase visibility for monochrome dark
if (colorPalette === 'monochrome' && theme === 'dark') {
sidebarBorder = 'rgba(255, 255, 255, 0.12)';
} else {
sidebarBorder = 'rgba(255, 255, 255, 0.05)';
}
}
root.style.setProperty('--color-bg-sidebar', sidebarBg);
root.style.setProperty('--color-text-sidebar', sidebarText);
root.style.setProperty('--color-sidebar-border', sidebarBorder);
}, [colorPalette, theme, sidebarStyle, customColors]);
// Load theme from backend when user is authenticated
const loadThemeFromBackend = useCallback(async () => {
try {
const themeData = await settingsAPI.getTheme();
if (themeData.theme_accent_color) {
setAccentColorState(themeData.theme_accent_color as AccentColor);
}
if (themeData.theme_border_radius) {
setBorderRadiusState(themeData.theme_border_radius as BorderRadius);
}
if (themeData.theme_sidebar_style) {
setSidebarStyleState(themeData.theme_sidebar_style as SidebarStyle);
}
if (themeData.theme_density) {
setDensityState(themeData.theme_density as Density);
}
if (themeData.theme_font_family) {
setFontFamilyState(themeData.theme_font_family as FontFamily);
}
if (themeData.theme_color_palette) {
setColorPaletteState(themeData.theme_color_palette as ColorPalette);
}
if (themeData.theme_dark_mode_location) {
setDarkModeLocationState(themeData.theme_dark_mode_location as ControlLocation);
}
if (themeData.theme_language_location) {
setLanguageLocationState(themeData.theme_language_location as ControlLocation);
}
if (themeData.theme_show_dark_mode_toggle !== undefined) {
const val = themeData.theme_show_dark_mode_toggle as unknown;
setShowDarkModeToggleState(val === true || val === 'true' || val === 'True');
}
if (themeData.theme_show_language_toggle !== undefined) {
const val = themeData.theme_show_language_toggle as unknown;
setShowLanguageToggleState(val === true || val === 'true' || val === 'True');
}
if (themeData.theme_show_dark_mode_login !== undefined) {
const val = themeData.theme_show_dark_mode_login as unknown;
setShowDarkModeLoginState(val === true || val === 'true' || val === 'True');
}
if (themeData.theme_show_language_login !== undefined) {
const val = themeData.theme_show_language_login as unknown;
setShowLanguageLoginState(val === true || val === 'true' || val === 'True');
}
if (themeData.theme_custom_colors) {
try {
const parsed = typeof themeData.theme_custom_colors === 'string'
? JSON.parse(themeData.theme_custom_colors)
: themeData.theme_custom_colors;
setCustomColorsState(parsed as Partial<PaletteColors>);
} catch (e) {
console.error('Failed to parse custom colors:', e);
}
}
} catch (error) {
console.error('Failed to load theme from backend:', error);
}
}, []);
// Save theme to backend (admin only) - accepts optional overrides for immediate save
const saveThemeToBackend = useCallback(async (overrides?: Partial<{
accentColor: AccentColor;
borderRadius: BorderRadius;
sidebarStyle: SidebarStyle;
density: Density;
fontFamily: FontFamily;
colorPalette: ColorPalette;
customColors: Partial<PaletteColors>;
darkModeLocation: ControlLocation;
languageLocation: ControlLocation;
showDarkModeToggle: boolean;
showLanguageToggle: boolean;
showDarkModeLogin: boolean;
showLanguageLogin: boolean;
}>) => {
try {
await settingsAPI.updateTheme({
theme_accent_color: overrides?.accentColor ?? accentColor,
theme_border_radius: overrides?.borderRadius ?? borderRadius,
theme_sidebar_style: overrides?.sidebarStyle ?? sidebarStyle,
theme_density: overrides?.density ?? density,
theme_font_family: overrides?.fontFamily ?? fontFamily,
theme_color_palette: overrides?.colorPalette ?? colorPalette,
theme_custom_colors: JSON.stringify(overrides?.customColors ?? customColors),
theme_dark_mode_location: overrides?.darkModeLocation ?? darkModeLocation,
theme_language_location: overrides?.languageLocation ?? languageLocation,
theme_show_dark_mode_toggle: String(overrides?.showDarkModeToggle ?? showDarkModeToggle),
theme_show_language_toggle: String(overrides?.showLanguageToggle ?? showLanguageToggle),
theme_show_dark_mode_login: String(overrides?.showDarkModeLogin ?? showDarkModeLogin),
theme_show_language_login: String(overrides?.showLanguageLogin ?? showLanguageLogin),
});
} catch (error) {
console.error('Failed to save theme to backend:', error);
throw error;
}
}, [accentColor, borderRadius, sidebarStyle, density, fontFamily, colorPalette, customColors, darkModeLocation, languageLocation, showDarkModeToggle, showLanguageToggle, showDarkModeLogin, showLanguageLogin]);
// Auto-load theme from backend when token exists
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
loadThemeFromBackend();
}
}, [loadThemeFromBackend]);
const toggleTheme = () => {
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
};
const setAccentColor = (color: AccentColor) => {
setAccentColorState(color);
};
const setBorderRadius = (radius: BorderRadius) => {
setBorderRadiusState(radius);
};
const setSidebarStyle = (style: SidebarStyle) => {
setSidebarStyleState(style);
};
const setDensity = (d: Density) => {
setDensityState(d);
};
const setFontFamily = (font: FontFamily) => {
setFontFamilyState(font);
};
const setColorPalette = (palette: ColorPalette) => {
setColorPaletteState(palette);
};
const setCustomColors = (colors: Partial<PaletteColors>) => {
setCustomColorsState(colors);
};
const setDarkModeLocation = (location: ControlLocation) => {
setDarkModeLocationState(location);
};
const setLanguageLocation = (location: ControlLocation) => {
setLanguageLocationState(location);
};
const setShowDarkModeToggle = (show: boolean) => {
setShowDarkModeToggleState(show);
};
const setShowLanguageToggle = (show: boolean) => {
setShowLanguageToggleState(show);
};
const setShowDarkModeLogin = (show: boolean) => {
setShowDarkModeLoginState(show);
};
const setShowLanguageLogin = (show: boolean) => {
setShowLanguageLoginState(show);
};
return (
<ThemeContext.Provider
value={{
theme,
accentColor,
borderRadius,
sidebarStyle,
density,
fontFamily,
colorPalette,
customColors,
darkModeLocation,
languageLocation,
showDarkModeToggle,
showLanguageToggle,
showDarkModeLogin,
showLanguageLogin,
toggleTheme,
setAccentColor,
setBorderRadius,
setSidebarStyle,
setDensity,
setFontFamily,
setColorPalette,
setCustomColors,
setDarkModeLocation,
setLanguageLocation,
setShowDarkModeToggle,
setShowLanguageToggle,
setShowDarkModeLogin,
setShowLanguageLogin,
loadThemeFromBackend,
saveThemeToBackend,
}}
>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}

View File

@@ -0,0 +1,119 @@
import { createContext, useContext, useState, useEffect } from 'react';
import type { ReactNode } from 'react';
type ViewMode = 'admin' | 'user';
interface ViewModeContextType {
viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void;
toggleViewMode: () => void;
isUserModeEnabled: boolean;
setUserModeEnabled: (enabled: boolean) => void;
}
const ViewModeContext = createContext<ViewModeContextType | undefined>(undefined);
export function ViewModeProvider({ children }: { children: ReactNode }) {
// viewMode is user preference - stored in localStorage
const [viewMode, setViewModeState] = useState<ViewMode>(() => {
const saved = localStorage.getItem('viewMode');
return (saved as ViewMode) || 'admin';
});
// isUserModeEnabled is a GLOBAL setting - comes from database, default false
const [isUserModeEnabled, setUserModeEnabledState] = useState<boolean>(false);
const setViewMode = (mode: ViewMode) => {
setViewModeState(mode);
localStorage.setItem('viewMode', mode);
};
const toggleViewMode = () => {
const newMode = viewMode === 'admin' ? 'user' : 'admin';
setViewMode(newMode);
};
const setUserModeEnabled = async (enabled: boolean) => {
setUserModeEnabledState(enabled);
// If disabling, reset to admin view
if (!enabled && viewMode === 'user') {
setViewMode('admin');
}
// Save to backend (this is a global setting)
try {
const token = localStorage.getItem('token');
if (token) {
await fetch('/api/v1/settings/user_mode_enabled', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ value: enabled }),
});
}
} catch (error) {
console.error('Failed to save user mode setting:', error);
}
};
useEffect(() => {
// Sync viewMode (user preference) with localStorage on mount
const savedMode = localStorage.getItem('viewMode');
if (savedMode) setViewModeState(savedMode as ViewMode);
// Fetch user_mode_enabled (global setting) from backend
const fetchUserModeSetting = async () => {
try {
const token = localStorage.getItem('token');
if (!token) {
// No token = use default (false)
return;
}
const response = await fetch('/api/v1/settings/user_mode_enabled', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const data = await response.json();
if (data.value !== undefined) {
const val = data.value;
const isEnabled = val === true || val === 'true' || val === 'True';
setUserModeEnabledState(isEnabled);
}
}
// If 404 or error, keep default (false)
} catch (error) {
console.error('Failed to fetch user mode setting:', error);
// Keep default (false)
}
};
fetchUserModeSetting();
}, []);
const value = {
viewMode,
setViewMode,
toggleViewMode,
isUserModeEnabled,
setUserModeEnabled,
};
return (
<ViewModeContext.Provider value={value}>
{children}
</ViewModeContext.Provider>
);
}
export function useViewMode() {
const context = useContext(ViewModeContext);
if (context === undefined) {
throw new Error('useViewMode must be used within a ViewModeProvider');
}
return context;
}

View File

@@ -0,0 +1,35 @@
import { useEffect } from 'react';
import siteConfig from '../config/site.config';
/**
* Hook to set the document title
* @param title - Page-specific title (will be appended to site name)
* @param useFullTitle - If true, use title as-is without appending site name
*/
export function useDocumentTitle(title?: string, useFullTitle = false): void {
useEffect(() => {
if (useFullTitle && title) {
document.title = title;
} else if (title) {
document.title = `${title} | ${siteConfig.name}`;
} else {
document.title = siteConfig.meta.title;
}
// Cleanup: restore default title on unmount
return () => {
document.title = siteConfig.meta.title;
};
}, [title, useFullTitle]);
}
/**
* Set the document title imperatively (for use outside React components)
*/
export function setDocumentTitle(title?: string): void {
if (title) {
document.title = `${title} | ${siteConfig.name}`;
} else {
document.title = siteConfig.meta.title;
}
}

71
frontend/src/index.css Normal file
View File

@@ -0,0 +1,71 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Roboto:wght@400;500;700&display=swap');
/* ==========================================
MAIN STYLES
========================================== */
/* Import Theme System */
@import './styles/theme/index.css';
/* Global font rendering settings */
:root {
font-synthesis: none;
text-rendering: optimizeLegibility;
line-height: var(--leading-normal);
}
/* ========== SCROLLBAR STYLES ========== */
/* Custom scrollbar - semi-transparent overlay style */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.4);
border-radius: 4px;
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(128, 128, 128, 0.6);
border: 2px solid transparent;
background-clip: padding-box;
}
/* Firefox scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: rgba(128, 128, 128, 0.4) transparent;
}
/* Main content scrollbar - overlay style for symmetric margins */
.main-content {
overflow-y: auto;
overflow-x: hidden;
}
/* Material Icons */
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
font-size: var(--icon-lg);
line-height: var(--leading-none);
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-feature-settings: 'liga';
}

View File

@@ -0,0 +1,314 @@
{
"app": {
"name": "App Service",
"tagline": "Service Management"
},
"auth": {
"login": "Login",
"register": "Register",
"logout": "Logout",
"username": "Username",
"password": "Password",
"email": "Email",
"loginTitle": "Login",
"registerTitle": "Register",
"alreadyHaveAccount": "Already have an account? Login",
"dontHaveAccount": "Don't have an account? Register",
"authenticationFailed": "Authentication failed"
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome",
"profile": "Profile",
"userId": "User ID",
"status": "Status",
"active": "Active",
"inactive": "Inactive"
},
"sidebar": {
"dashboard": "Dashboard",
"feature1": "Feature 1",
"feature2": "Feature 2",
"feature3": "Feature 3",
"settings": "Settings",
"admin": "Administration",
"users": "Users"
},
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"close": "Close",
"search": "Search",
"loading": "Loading...",
"error": "Error",
"success": "Success",
"all": "All",
"reset": "Reset"
},
"features": {
"feature1": "Feature 1 Management",
"feature2": "Feature 2 Manager",
"feature3": "Feature 3 Integration",
"comingSoon": "Coming Soon"
},
"featuresPage": {
"title": "Features",
"subtitle": "Configure application features"
},
"sourcesPage": {
"title": "Sources",
"subtitle": "Manage data sources",
"comingSoon": "Data sources management coming soon..."
},
"admin": {
"panel": "Admin Panel",
"description": "Manage users and application settings",
"userManagement": "User Management",
"systemSettings": "System Settings",
"generalTab": "General",
"usersTab": "Users",
"playlistsTab": "Playlists",
"viewMode": "Interface",
"userModeToggle": "User Mode Button",
"userModeToggleDesc": "Show a button in the sidebar to quickly switch to user view",
"sidebarMode": "Sidebar Mode",
"sidebarModeDesc": "Choose how to display the sidebar",
"sidebarModeCollapsed": "Always Collapsed",
"sidebarModeCollapsedDesc": "Always small",
"sidebarModeDynamic": "Dynamic",
"sidebarModeDynamicDesc": "Expands on hover",
"sidebarModeToggle": "Toggleable",
"sidebarModeToggleDesc": "Collapsible",
"adminView": "Admin",
"userView": "User",
"modulesSection": "Features",
"playlistsDesc": "Manage playlists for streaming",
"downloadsDesc": "Download and manage offline content",
"chromecastDesc": "Cast content to devices",
"moduleDefaultDesc": "Enable or disable this feature for users",
"darkModeSettings": "Dark Mode Settings",
"enableDarkModeToggle": "Enable Dark Mode Toggle",
"enableDarkModeToggleDesc": "Allow users to switch themes",
"showOnLoginScreen": "Show on Login Screen",
"showOnLoginScreenDesc": "Display toggle on login page",
"controlLocation": "Control Location",
"controlLocationDesc": "Show in Sidebar (ON) or User Menu (OFF)",
"languageSettings": "Language Settings",
"enableLanguageSelector": "Enable Language Selector",
"enableLanguageSelectorDesc": "Allow users to change language",
"showLanguageOnLoginDesc": "Display selector on login page",
"adminRole": "Admin",
"userRole": "User",
"active": "Active",
"inactive": "Inactive"
},
"usersPage": {
"title": "Users",
"addUser": "Add user",
"editUser": "Edit user",
"name": "Username",
"email": "Email",
"password": "Password",
"passwordHintCreate": "(min 8 chars)",
"passwordHintEdit": "(leave blank to keep current password)",
"status": "Status",
"role": "Role",
"isActive": "Active",
"isSuperuser": "Superuser",
"active": "Active",
"inactive": "Inactive",
"superuser": "Superuser",
"regular": "User",
"actions": "Actions",
"edit": "Edit",
"delete": "Delete",
"save": "Save",
"confirmDelete": "Delete this user?",
"selfDeleteWarning": "You cannot delete your own account",
"noUsers": "No users yet",
"errorLoading": "Failed to load users",
"saveError": "Unable to save user",
"passwordRequired": "Password is required to create a new user",
"passwordTooShort": "Password must be at least 8 characters",
"searchPlaceholder": "Search by username or email",
"filterAll": "All",
"createdAt": "Created At",
"lastLogin": "Last Login",
"permissions": "Module Permissions",
"customPermissions": "Custom",
"usingDefaultPermissions": "Using global settings from General tab",
"moduleDisabledGlobally": "This module is disabled globally",
"disabled": "Disabled"
},
"user": {
"admin": "Admin",
"superuser": "Superuser"
},
"theme": {
"title": "Theme Editor",
"subtitle": "Customize the look and feel of the application",
"colorsTab": "Colors",
"appearanceTab": "Appearance",
"previewTab": "Preview",
"advancedTab": "Advanced",
"accentColor": "Accent Color",
"borderRadius": "Border Radius",
"sidebarStyle": "Sidebar Style",
"density": "Density",
"pageMargin": "Page Margin",
"fontFamily": "Font Family",
"preview": "Preview",
"previewTitle": "Theme Preview",
"previewText": "This is a live preview of your selected theme settings.",
"previewCard": "Preview Card",
"previewDescription": "This is how your content will look with the selected theme settings.",
"primaryButton": "Primary Button",
"ghostButton": "Ghost Button",
"inputPlaceholder": "Input field...",
"badge": "Badge",
"toggleMenu": "Toggle menu",
"advancedColors": "Custom Colors",
"customColors": "Custom Colors",
"customColorsDesc": "Fine-tune specific colors for light and dark modes.",
"advancedColorsDescription": "Customize every theme color in detail",
"lightThemeColors": "Light Theme Colors",
"lightMode": "Light Mode",
"darkThemeColors": "Dark Theme Colors",
"darkMode": "Dark Mode",
"background": "Main Background",
"backgroundCard": "Card Background",
"backgroundElevated": "Elevated Background",
"textPrimary": "Primary Text",
"textSecondary": "Secondary Text",
"border": "Border",
"pickColor": "Pick color",
"useEyedropper": "Use eyedropper",
"resetColors": "Reset to defaults",
"sidebar": "Sidebar",
"sidebarBackground": "Sidebar Background",
"sidebarText": "Sidebar Text",
"colors": {
"blue": "Blue",
"blueDesc": "Classic & Professional",
"purple": "Purple",
"purpleDesc": "Creative & Modern",
"green": "Green",
"greenDesc": "Fresh & Natural",
"orange": "Orange",
"orangeDesc": "Energetic & Bold",
"pink": "Pink",
"pinkDesc": "Playful & Vibrant",
"red": "Red",
"redDesc": "Dynamic & Powerful",
"teal": "Teal",
"tealDesc": "Calm & Refreshing",
"amber": "Amber",
"amberDesc": "Warm & Inviting",
"indigo": "Indigo",
"indigoDesc": "Deep & Mysterious",
"cyan": "Cyan",
"cyanDesc": "Cool & Electric",
"rose": "Rose",
"roseDesc": "Romantic & Soft",
"auto": "Auto",
"autoDesc": "Adapts to theme"
},
"radius": {
"small": "Small",
"smallDesc": "Sharp edges",
"medium": "Medium",
"mediumDesc": "Balanced",
"large": "Large",
"largeDesc": "Soft curves"
},
"sidebarOptions": {
"default": "Default",
"defaultDesc": "Adaptive theme",
"dark": "Dark",
"darkDesc": "Always dark",
"light": "Light",
"lightDesc": "Always light"
},
"densityOptions": {
"compact": "Compact",
"compactDesc": "More content",
"comfortable": "Comfortable",
"comfortableDesc": "Balanced",
"spacious": "Spacious",
"spaciousDesc": "More breathing room"
},
"fontOptions": {
"system": "System",
"systemDesc": "Default system font",
"inter": "Inter",
"interDesc": "Modern & clean",
"roboto": "Roboto",
"robotoDesc": "Google's classic"
},
"palettes": {
"default": "Default",
"defaultDesc": "Standard modern palette",
"monochrome": "Monochrome",
"monochromeDesc": "Pure black & white",
"monochromeBlue": "Slate",
"monochromeBlueDesc": "Elegant blue-gray tones",
"sepia": "Sepia",
"sepiaDesc": "Warm vintage tones",
"nord": "Nord",
"nordDesc": "Arctic inspired colors",
"dracula": "Dracula",
"draculaDesc": "Dark gothic theme",
"solarized": "Solarized",
"solarizedDesc": "Easy on the eyes",
"github": "GitHub",
"githubDesc": "Clean & minimal",
"ocean": "Ocean",
"oceanDesc": "Cool blue depths",
"forest": "Forest",
"forestDesc": "Natural green tones",
"midnight": "Midnight",
"midnightDesc": "Deep purple nights",
"sunset": "Sunset",
"sunsetDesc": "Warm orange glow"
},
"colorPalette": "Color Palette",
"sectionDesc": {
"accentColor": "Choose your primary accent color",
"colorPalette": "Choose a complete color scheme",
"borderRadius": "Adjust corner roundness",
"sidebarStyle": "Sidebar color scheme",
"density": "Interface spacing",
"fontFamily": "Typography style",
"preview": "See how your theme looks"
},
"sampleCard": "Sample Card",
"sampleCardDesc": "This is another example card to demonstrate how your theme will look across different components.",
"totalItems": "Total Items",
"successRate": "Success Rate",
"currentColor": "Current Color",
"apply": "Apply"
},
"settings": {
"title": "Settings",
"subtitle": "Manage application preferences",
"authentication": "Authentication",
"allowRegistration": "Allow User Registration",
"allowRegistrationDesc": "Allow new users to register accounts autonomously",
"showLogo": "Show Logo in Sidebar",
"showLogoDesc": "Display the application logo at the top of the sidebar when in dynamic or expanded mode",
"language": "Language",
"languageDesc": "Select your preferred language",
"english": "English",
"italian": "Italiano",
"comingSoon": "User settings will be available soon...",
"placeholderTitle": "Settings"
},
"feature1": {
"title": "Feature 1",
"subtitle": "Feature 1 management",
"comingSoon": "Feature coming soon...",
"management": "Feature 1 Management"
}
}

View File

@@ -0,0 +1,314 @@
{
"app": {
"name": "App Service",
"tagline": "Gestione Servizio"
},
"auth": {
"login": "Accedi",
"register": "Registrati",
"logout": "Esci",
"username": "Nome utente",
"password": "Password",
"email": "Email",
"loginTitle": "Accedi",
"registerTitle": "Registrati",
"alreadyHaveAccount": "Hai già un account? Accedi",
"dontHaveAccount": "Non hai un account? Registrati",
"authenticationFailed": "Autenticazione fallita"
},
"dashboard": {
"title": "Dashboard",
"welcome": "Benvenuto",
"profile": "Profilo",
"userId": "ID Utente",
"status": "Stato",
"active": "Attivo",
"inactive": "Inattivo"
},
"sidebar": {
"dashboard": "Dashboard",
"feature1": "Funzione 1",
"feature2": "Funzione 2",
"feature3": "Funzione 3",
"settings": "Impostazioni",
"admin": "Amministrazione",
"users": "Utenti"
},
"common": {
"save": "Salva",
"cancel": "Annulla",
"delete": "Elimina",
"edit": "Modifica",
"close": "Chiudi",
"search": "Cerca",
"loading": "Caricamento...",
"error": "Errore",
"success": "Successo",
"all": "Tutti",
"reset": "Reimposta"
},
"features": {
"feature1": "Gestione Funzione 1",
"feature2": "Gestore Funzione 2",
"feature3": "Integrazione Funzione 3",
"comingSoon": "Prossimamente"
},
"featuresPage": {
"title": "Funzionalità",
"subtitle": "Configura le funzionalità dell'applicazione"
},
"sourcesPage": {
"title": "Sorgenti",
"subtitle": "Gestisci le sorgenti dati",
"comingSoon": "Gestione sorgenti dati in arrivo..."
},
"admin": {
"panel": "Pannello Admin",
"description": "Gestisci utenti e impostazioni dell'applicazione",
"userManagement": "Gestione Utenti",
"systemSettings": "Impostazioni Sistema",
"generalTab": "Generali",
"usersTab": "Utenti",
"playlistsTab": "Playlist",
"viewMode": "Interfaccia",
"userModeToggle": "Pulsante Modalità Utente",
"userModeToggleDesc": "Mostra un pulsante nella sidebar per passare alla vista utente",
"sidebarMode": "Modalità Sidebar",
"sidebarModeDesc": "Scegli come visualizzare la barra laterale",
"sidebarModeCollapsed": "Sempre Ristretta",
"sidebarModeCollapsedDesc": "Sempre piccola",
"sidebarModeDynamic": "Dinamica",
"sidebarModeDynamicDesc": "Si espande al passaggio del mouse",
"sidebarModeToggle": "Commutabile",
"sidebarModeToggleDesc": "Richiudibile",
"adminView": "Admin",
"userView": "Utente",
"modulesSection": "Funzionalità",
"playlistsDesc": "Gestione delle playlist per lo streaming",
"downloadsDesc": "Scarica e gestisci contenuti offline",
"chromecastDesc": "Trasmetti contenuti su dispositivi",
"moduleDefaultDesc": "Abilita o disabilita questa funzionalità per gli utenti",
"darkModeSettings": "Impostazioni Modalità Scura",
"enableDarkModeToggle": "Abilita Toggle Modalità Scura",
"enableDarkModeToggleDesc": "Consenti agli utenti di cambiare tema",
"showOnLoginScreen": "Mostra nella schermata di login",
"showOnLoginScreenDesc": "Mostra il toggle nella pagina di login",
"controlLocation": "Posizione Controllo",
"controlLocationDesc": "Mostra nella Sidebar (ON) o nel Menu Utente (OFF)",
"languageSettings": "Impostazioni Lingua",
"enableLanguageSelector": "Abilita Selettore Lingua",
"enableLanguageSelectorDesc": "Consenti agli utenti di cambiare lingua",
"showLanguageOnLoginDesc": "Mostra il selettore nella pagina di login",
"adminRole": "Admin",
"userRole": "Utente",
"active": "Attivo",
"inactive": "Disattivo"
},
"usersPage": {
"title": "Utenti",
"addUser": "Aggiungi utente",
"editUser": "Modifica utente",
"name": "Nome utente",
"email": "Email",
"password": "Password",
"passwordHintCreate": "(min 8 caratteri)",
"passwordHintEdit": "(lascia vuoto per mantenere la password attuale)",
"status": "Stato",
"role": "Ruolo",
"isActive": "Attivo",
"isSuperuser": "Superutente",
"active": "Attivo",
"inactive": "Inattivo",
"superuser": "Superutente",
"regular": "Utente",
"actions": "Azioni",
"edit": "Modifica",
"delete": "Elimina",
"save": "Salva",
"confirmDelete": "Eliminare questo utente?",
"selfDeleteWarning": "Non puoi eliminare il tuo account",
"noUsers": "Nessun utente presente",
"errorLoading": "Caricamento utenti fallito",
"saveError": "Impossibile salvare l'utente",
"passwordRequired": "La password è obbligatoria per creare un nuovo utente",
"passwordTooShort": "La password deve essere di almeno 8 caratteri",
"searchPlaceholder": "Cerca per username o email",
"filterAll": "Tutti",
"createdAt": "Data Creazione",
"lastLogin": "Ultimo Accesso",
"permissions": "Permessi Moduli",
"customPermissions": "Personalizza",
"usingDefaultPermissions": "Usa impostazioni globali da Generali",
"moduleDisabledGlobally": "Questo modulo è disabilitato globalmente",
"disabled": "Disabilitato"
},
"user": {
"admin": "Admin",
"superuser": "Superutente"
},
"theme": {
"title": "Editor Temi",
"subtitle": "Personalizza l'aspetto dell'applicazione",
"colorsTab": "Colori",
"appearanceTab": "Aspetto",
"previewTab": "Anteprima",
"advancedTab": "Avanzato",
"accentColor": "Colore Accento",
"borderRadius": "Raggio Bordo",
"sidebarStyle": "Stile Sidebar",
"density": "Densità",
"pageMargin": "Margine Pagina",
"fontFamily": "Font",
"preview": "Anteprima",
"previewTitle": "Anteprima Tema",
"previewText": "Questa è un'anteprima dal vivo delle impostazioni del tema selezionate.",
"previewCard": "Card di Anteprima",
"previewDescription": "Ecco come apparirà il contenuto con le impostazioni del tema selezionate.",
"primaryButton": "Pulsante Primario",
"ghostButton": "Pulsante Ghost",
"inputPlaceholder": "Campo di input...",
"badge": "Badge",
"toggleMenu": "Apri/chiudi menu",
"advancedColors": "Colori Personalizzati",
"customColors": "Colori Personalizzati",
"customColorsDesc": "Regola i colori specifici per le modalità chiara e scura.",
"advancedColorsDescription": "Personalizza ogni colore del tema in dettaglio",
"lightThemeColors": "Colori Tema Chiaro",
"lightMode": "Modalità Chiara",
"darkThemeColors": "Colori Tema Scuro",
"darkMode": "Modalità Scura",
"background": "Sfondo Principale",
"backgroundCard": "Sfondo Card",
"backgroundElevated": "Sfondo Elevato",
"textPrimary": "Testo Primario",
"textSecondary": "Testo Secondario",
"border": "Bordo",
"pickColor": "Seleziona colore",
"useEyedropper": "Usa contagocce",
"resetColors": "Ripristina predefiniti",
"sidebar": "Sidebar",
"sidebarBackground": "Sfondo Sidebar",
"sidebarText": "Testo Sidebar",
"colors": {
"blue": "Blu",
"blueDesc": "Classico e Professionale",
"purple": "Viola",
"purpleDesc": "Creativo e Moderno",
"green": "Verde",
"greenDesc": "Fresco e Naturale",
"orange": "Arancione",
"orangeDesc": "Energico e Audace",
"pink": "Rosa",
"pinkDesc": "Giocoso e Vivace",
"red": "Rosso",
"redDesc": "Dinamico e Potente",
"teal": "Teal",
"tealDesc": "Calmo e Rinfrescante",
"amber": "Ambra",
"amberDesc": "Caldo e Accogliente",
"indigo": "Indaco",
"indigoDesc": "Profondo e Misterioso",
"cyan": "Ciano",
"cyanDesc": "Fresco e Elettrico",
"rose": "Rosa Antico",
"roseDesc": "Romantico e Delicato",
"auto": "Auto",
"autoDesc": "Si adatta al tema"
},
"radius": {
"small": "Piccolo",
"smallDesc": "Angoli netti",
"medium": "Medio",
"mediumDesc": "Bilanciato",
"large": "Grande",
"largeDesc": "Curve morbide"
},
"sidebarOptions": {
"default": "Predefinito",
"defaultDesc": "Tema adattivo",
"dark": "Scuro",
"darkDesc": "Sempre scuro",
"light": "Chiaro",
"lightDesc": "Sempre chiaro"
},
"densityOptions": {
"compact": "Compatto",
"compactDesc": "Più contenuto",
"comfortable": "Confortevole",
"comfortableDesc": "Bilanciato",
"spacious": "Spazioso",
"spaciousDesc": "Più respiro"
},
"fontOptions": {
"system": "Sistema",
"systemDesc": "Font di sistema",
"inter": "Inter",
"interDesc": "Moderno e pulito",
"roboto": "Roboto",
"robotoDesc": "Il classico di Google"
},
"palettes": {
"default": "Predefinito",
"defaultDesc": "Palette moderna standard",
"monochrome": "Monocromatico",
"monochromeDesc": "Bianco e nero puro",
"monochromeBlue": "Ardesia",
"monochromeBlueDesc": "Eleganti toni grigio-blu",
"sepia": "Seppia",
"sepiaDesc": "Toni vintage caldi",
"nord": "Nord",
"nordDesc": "Colori ispirati all'Artico",
"dracula": "Dracula",
"draculaDesc": "Tema gotico scuro",
"solarized": "Solarized",
"solarizedDesc": "Facile per gli occhi",
"github": "GitHub",
"githubDesc": "Pulito e minimale",
"ocean": "Oceano",
"oceanDesc": "Profondità blu fredde",
"forest": "Foresta",
"forestDesc": "Toni verdi naturali",
"midnight": "Mezzanotte",
"midnightDesc": "Notti viola profonde",
"sunset": "Tramonto",
"sunsetDesc": "Bagliore arancione caldo"
},
"colorPalette": "Palette Colori",
"sectionDesc": {
"accentColor": "Scegli il tuo colore principale",
"colorPalette": "Scegli uno schema colori completo",
"borderRadius": "Regola l'arrotondamento degli angoli",
"sidebarStyle": "Schema colori sidebar",
"density": "Spaziatura interfaccia",
"fontFamily": "Stile tipografico",
"preview": "Vedi come appare il tuo tema"
},
"sampleCard": "Card di Esempio",
"sampleCardDesc": "Questa è un'altra card di esempio per mostrare come apparirà il tema su diversi componenti.",
"totalItems": "Elementi Totali",
"successRate": "Tasso di Successo",
"currentColor": "Colore Attuale",
"apply": "Applica"
},
"settings": {
"title": "Impostazioni",
"subtitle": "Gestisci le preferenze dell'applicazione",
"authentication": "Autenticazione",
"allowRegistration": "Consenti Registrazione Utenti",
"allowRegistrationDesc": "Consenti ai nuovi utenti di registrarsi autonomamente",
"showLogo": "Mostra Logo nella Sidebar",
"showLogoDesc": "Mostra il logo dell'applicazione in alto nella sidebar quando in modalità dinamica o espansa",
"language": "Lingua",
"languageDesc": "Seleziona la tua lingua preferita",
"english": "Inglese",
"italian": "Italiano",
"comingSoon": "Le impostazioni utente saranno disponibili a breve...",
"placeholderTitle": "Impostazioni"
},
"feature1": {
"title": "Funzione 1",
"subtitle": "Gestione Funzione 1",
"comingSoon": "Funzionalità in arrivo...",
"management": "Gestione Funzione 1"
}
}

18
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import siteConfig from './config/site.config'
import './index.css'
import App from './App.tsx'
// Set initial document title and meta from config
document.title = siteConfig.meta.title
const metaDescription = document.querySelector('meta[name="description"]')
if (metaDescription) {
metaDescription.setAttribute('content', siteConfig.meta.description)
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,110 @@
import type { ReactNode } from 'react';
export interface Module {
id: string;
name: string;
icon: string;
path: string;
component: ReactNode;
enabled: boolean;
requiresAuth: boolean;
requiresAdmin?: boolean;
}
export interface ModuleCategory {
id: string;
name: string;
modules: Module[];
}
// Define available modules
export const appModules: ModuleCategory[] = [
{
id: 'main',
name: 'Main Features',
modules: [
{
id: 'dashboard',
name: 'sidebar.dashboard',
icon: 'dashboard',
path: '/dashboard',
component: null, // Will be lazy loaded
enabled: true,
requiresAuth: true,
},
{
id: 'feature1',
name: 'sidebar.feature1',
icon: 'playlist_play',
path: '/feature1',
component: null,
enabled: true,
requiresAuth: true,
},
{
id: 'feature2',
name: 'sidebar.feature2',
icon: 'download',
path: '/feature2',
component: null,
enabled: true,
requiresAuth: true,
},
{
id: 'feature3',
name: 'sidebar.feature3',
icon: 'cast',
path: '/feature3',
component: null,
enabled: true,
requiresAuth: true,
},
],
},
{
id: 'admin',
name: 'Administration',
modules: [
{
id: 'users',
name: 'sidebar.users',
icon: 'group',
path: '/admin/users',
component: null,
enabled: true,
requiresAuth: true,
requiresAdmin: true,
},
{
id: 'settings',
name: 'sidebar.settings',
icon: 'settings',
path: '/settings',
component: null,
enabled: true,
requiresAuth: true,
},
],
},
];
// Helper to get enabled modules
export function getEnabledModules(isAdmin: boolean = false): Module[] {
const allModules = appModules.flatMap((category) => category.modules);
return allModules.filter((module) => {
if (!module.enabled) return false;
if (module.requiresAdmin && !isAdmin) return false;
return true;
});
}
// Helper to get modules by category
export function getModulesByCategory(categoryId: string, isAdmin: boolean = false): Module[] {
const category = appModules.find((cat) => cat.id === categoryId);
if (!category) return [];
return category.modules.filter((module) => {
if (!module.enabled) return false;
if (module.requiresAdmin && !isAdmin) return false;
return true;
});
}

View File

@@ -0,0 +1,56 @@
import { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { useTranslation } from '../contexts/LanguageContext';
import { useSidebar } from '../contexts/SidebarContext';
import GeneralTab from '../components/admin/GeneralTab';
import UsersTab from '../components/admin/UsersTab';
import '../styles/AdminPanel.css';
type TabId = 'general' | 'users';
export default function AdminPanel() {
const { user: currentUser } = useAuth();
const { t } = useTranslation();
const { toggleMobileMenu } = useSidebar();
const [activeTab, setActiveTab] = useState<TabId>('general');
if (!currentUser?.is_superuser) {
return null;
}
return (
<main className="main-content admin-panel-root">
<div className="page-tabs-container">
<div className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="page-title-section">
<span className="material-symbols-outlined">admin_panel_settings</span>
<span className="page-title-text">{t.admin.panel}</span>
</div>
<div className="page-tabs-divider"></div>
<button
className={`page-tab-btn ${activeTab === 'general' ? 'active' : ''}`}
onClick={() => setActiveTab('general')}
>
<span className="material-symbols-outlined">tune</span>
<span>{t.admin.generalTab}</span>
</button>
<button
className={`page-tab-btn ${activeTab === 'users' ? 'active' : ''}`}
onClick={() => setActiveTab('users')}
>
<span className="material-symbols-outlined">group</span>
<span>{t.admin.usersTab}</span>
</button>
</div>
</div>
<div className="admin-tab-content">
{activeTab === 'general' && <GeneralTab />}
{activeTab === 'users' && <UsersTab />}
</div>
</main>
);
}

View File

@@ -0,0 +1,72 @@
import { useAuth } from '../contexts/AuthContext';
import { useTranslation } from '../contexts/LanguageContext';
import { useSidebar } from '../contexts/SidebarContext';
import '../styles/Dashboard.css';
export default function Dashboard() {
const { user } = useAuth();
const { t } = useTranslation();
const { toggleMobileMenu } = useSidebar();
return (
<main className="main-content">
<div className="page-tabs-container">
<div className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="page-title-section">
<span className="material-symbols-outlined">dashboard</span>
<span className="page-title-text">{t.dashboard.title}</span>
</div>
</div>
</div>
<div className="page-content">
<div className="stats-grid">
<div className="stat-card">
<h3>{t.dashboard.profile}</h3>
<p>
<strong>{t.auth.username}:</strong> <span className="selectable">{user?.username}</span>
</p>
<p>
<strong>{t.auth.email}:</strong> <span className="selectable">{user?.email}</span>
</p>
<p>
<strong>{t.dashboard.userId}:</strong> <span className="selectable">{user?.id}</span>
</p>
<p>
<strong>{t.dashboard.status}:</strong>{' '}
<span className={user?.is_active ? 'status-active' : 'status-inactive'}>
{user?.is_active ? t.dashboard.active : t.dashboard.inactive}
</span>
</p>
</div>
<div className="stat-card">
<h3>{t.features.feature1}</h3>
<p>{t.features.comingSoon}</p>
</div>
<div className="stat-card">
<h3>{t.features.feature2}</h3>
<p>{t.features.comingSoon}</p>
</div>
<div className="stat-card">
<h3>{t.features.feature3}</h3>
<p>{t.features.comingSoon}</p>
</div>
{user?.is_superuser && (
<div className="stat-card admin-card">
<h3>{t.admin.panel}</h3>
<p>{t.admin.userManagement} - {t.features.comingSoon}</p>
<p>{t.admin.systemSettings} - {t.features.comingSoon}</p>
</div>
)}
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,35 @@
import { useTranslation } from '../contexts/LanguageContext';
import { useSidebar } from '../contexts/SidebarContext';
import '../styles/AdminPanel.css';
export default function Feature1() {
const { t } = useTranslation();
const { toggleMobileMenu } = useSidebar();
return (
<main className="main-content">
<div className="page-tabs-container">
<div className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="page-title-section">
<span className="material-symbols-outlined">playlist_play</span>
<span className="page-title-text">{t.feature1.title}</span>
</div>
</div>
</div>
<div className="page-content">
<div className="tab-content-placeholder">
<div className="placeholder-icon">
<span className="material-symbols-outlined">playlist_play</span>
</div>
<h3>{t.feature1.title}</h3>
<p>{t.feature1.comingSoon}</p>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,161 @@
import { useState, useEffect } from 'react';
import type { FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useTranslation } from '../contexts/LanguageContext';
import { useTheme } from '../contexts/ThemeContext';
import { useSiteConfig } from '../contexts/SiteConfigContext';
import '../styles/Login.css';
export default function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [email, setEmail] = useState('');
const [isRegister, setIsRegister] = useState(false);
const [error, setError] = useState('');
const [registrationEnabled, setRegistrationEnabled] = useState(true);
const { login, register } = useAuth();
const { t, language, setLanguage } = useTranslation();
const { theme, toggleTheme, showDarkModeLogin, showLanguageLogin, showDarkModeToggle, showLanguageToggle } = useTheme();
const { config } = useSiteConfig();
const navigate = useNavigate();
// Check if registration is enabled
useEffect(() => {
const checkRegistrationStatus = async () => {
try {
const response = await fetch('/api/v1/auth/registration-status');
if (response.ok) {
const data = await response.json();
setRegistrationEnabled(data.registration_enabled !== false);
} else {
// Default to enabled if we can't fetch the setting
setRegistrationEnabled(true);
}
} catch (error) {
// Default to enabled if we can't fetch the setting
setRegistrationEnabled(true);
}
};
checkRegistrationStatus();
}, []);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
try {
if (isRegister) {
await register(username, email, password);
} else {
await login(username, password);
}
navigate('/dashboard');
} catch (err: any) {
const detail = err.response?.data?.detail;
if (Array.isArray(detail)) {
// Handle Pydantic validation errors
const messages = detail.map((e: any) => {
const field = e.loc[e.loc.length - 1];
return `${field}: ${e.msg}`;
}).join('\n');
setError(messages);
} else if (typeof detail === 'string') {
// Handle standard HTTP exceptions
setError(detail);
} else {
// Fallback
setError(t.auth.authenticationFailed);
}
}
};
const toggleLanguage = () => {
setLanguage(language === 'it' ? 'en' : 'it');
};
return (
<div className="login-container">
<div className="login-card">
<div className="login-header">
<h1>{config.name}</h1>
<p className="login-tagline">{config.tagline}</p>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="username">{t.auth.username}</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
minLength={3}
/>
</div>
{isRegister && (
<div className="form-group">
<label htmlFor="email">{t.auth.email}</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
)}
<div className="form-group">
<label htmlFor="password">{t.auth.password}</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
</div>
{error && <div className="error-message">{error}</div>}
<button type="submit" className="btn-primary">
{isRegister ? t.auth.register : t.auth.login}
</button>
</form>
<div className="login-footer">
{registrationEnabled && (
<button
onClick={() => {
setIsRegister(!isRegister);
setError('');
}}
className="btn-link"
>
{isRegister ? t.auth.alreadyHaveAccount : t.auth.dontHaveAccount}
</button>
)}
<div className="footer-actions">
{showDarkModeToggle && showDarkModeLogin && (
<button onClick={toggleTheme} className="btn-footer-action" title={theme === 'dark' ? 'Dark mode' : 'Light mode'}>
<span className="material-symbols-outlined">{theme === 'dark' ? 'dark_mode' : 'light_mode'}</span>
</button>
)}
{showLanguageToggle && showLanguageLogin && (
<button onClick={toggleLanguage} className="btn-footer-action" title="Change language">
<span className="material-symbols-outlined">language</span>
<span className="lang-text">{language.toUpperCase()}</span>
</button>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { useTranslation } from '../contexts/LanguageContext';
import { useSidebar } from '../contexts/SidebarContext';
import '../styles/SettingsPage.css';
export default function Settings() {
const { t, language, setLanguage } = useTranslation();
const { toggleMobileMenu } = useSidebar();
return (
<main className="main-content settings-page-root">
<div className="page-tabs-container">
<div className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="page-title-section">
<span className="material-symbols-outlined">settings</span>
<span className="page-title-text">{t.settings.title}</span>
</div>
</div>
</div>
<div className="page-content settings-tab-content">
<div className="settings-section-modern">
<div className="settings-grid">
<div className="setting-item-modern">
<div className="setting-info-modern">
<div className="setting-icon-modern">
<span className="material-symbols-outlined">language</span>
</div>
<div className="setting-text">
<h4>{t.settings.language}</h4>
<p>{t.settings.languageDesc}</p>
</div>
</div>
<div className="setting-control">
<select
className="select-modern"
value={language}
onChange={(e) => setLanguage(e.target.value as 'en' | 'it')}
>
<option value="en">{t.settings.english}</option>
<option value="it">{t.settings.italian}</option>
</select>
</div>
</div>
</div>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,404 @@
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import Sidebar from '../components/Sidebar';
import { useAuth } from '../contexts/AuthContext';
import { useTranslation } from '../contexts/LanguageContext';
import { useSidebar } from '../contexts/SidebarContext';
import { usersAPI } from '../api/client';
import type { User, UserCreate, UserUpdatePayload } from '../types';
import '../styles/Users.css';
type UserFormData = {
username: string;
email: string;
password: string;
is_active: boolean;
is_superuser: boolean;
};
const emptyForm: UserFormData = {
username: '',
email: '',
password: '',
is_active: true,
is_superuser: false,
};
export default function Users() {
const { user: currentUser } = useAuth();
const { t } = useTranslation();
const { toggleMobileMenu } = useSidebar();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [isModalOpen, setModalOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [formData, setFormData] = useState<UserFormData>({ ...emptyForm });
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
const loadUsers = async () => {
setLoading(true);
setError('');
try {
const data = await usersAPI.list();
setUsers(data);
} catch (err: any) {
setError(err?.response?.data?.detail || t.usersPage.errorLoading);
} finally {
setLoading(false);
}
};
loadUsers();
}, [t.usersPage.errorLoading]);
const filteredUsers = useMemo(() => {
const term = searchTerm.toLowerCase().trim();
if (!term) return users;
return users.filter(
(u) =>
u.username.toLowerCase().includes(term) ||
u.email.toLowerCase().includes(term)
);
}, [users, searchTerm]);
const openCreateModal = () => {
setEditingUser(null);
setFormData({ ...emptyForm });
setModalOpen(true);
setError('');
};
const openEditModal = (user: User) => {
setEditingUser(user);
setFormData({
username: user.username,
email: user.email,
password: '',
is_active: user.is_active,
is_superuser: user.is_superuser,
});
setModalOpen(true);
setError('');
};
const closeModal = () => {
setModalOpen(false);
setError('');
setFormData({ ...emptyForm });
setEditingUser(null);
};
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSaving(true);
setError('');
try {
if (editingUser) {
if (formData.password && formData.password.trim().length > 0 && formData.password.trim().length < 8) {
throw new Error('password-too-short');
}
const payload: UserUpdatePayload = {
username: formData.username,
email: formData.email,
is_active: formData.is_active,
is_superuser: formData.is_superuser,
};
if (formData.password.trim()) {
payload.password = formData.password;
}
const updated = await usersAPI.update(editingUser.id, payload);
setUsers((prev) => prev.map((u) => (u.id === updated.id ? updated : u)));
} else {
if (!formData.password.trim()) {
throw new Error('password-required');
}
if (formData.password.trim().length < 8) {
throw new Error('password-too-short');
}
const payload: UserCreate = {
username: formData.username,
email: formData.email,
password: formData.password,
is_active: formData.is_active,
is_superuser: formData.is_superuser,
};
const created = await usersAPI.create(payload);
setUsers((prev) => [created, ...prev]);
}
closeModal();
} catch (err: any) {
if (err?.message === 'password-required') {
setError(t.usersPage.passwordRequired);
} else if (err?.message === 'password-too-short') {
setError(t.usersPage.passwordTooShort);
} else {
setError(err?.response?.data?.detail || t.usersPage.saveError);
}
} finally {
setIsSaving(false);
}
};
const handleDelete = async (target: User) => {
if (currentUser?.id === target.id) {
setError(t.usersPage.selfDeleteWarning);
return;
}
const confirmed = window.confirm(t.usersPage.confirmDelete);
if (!confirmed) return;
setIsSaving(true);
setError('');
try {
await usersAPI.delete(target.id);
setUsers((prev) => prev.filter((u) => u.id !== target.id));
} catch (err: any) {
setError(err?.response?.data?.detail || t.usersPage.saveError);
} finally {
setIsSaving(false);
}
};
if (!currentUser?.is_superuser) {
return null;
}
return (
<div className="app-layout">
<Sidebar />
<main className="main-content users-root">
<div className="page-tabs-container">
<div className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="page-title-section">
<span className="material-symbols-outlined">group</span>
<span className="page-title-text">{t.admin.userManagement}</span>
</div>
<button className="btn-primary add-user-btn" onClick={openCreateModal}>
<span className="material-symbols-outlined">add</span>
<span>{t.usersPage.addUser}</span>
</button>
</div>
</div>
<div className="page-content users-page">
<div className="users-toolbar">
<div className="input-group">
<span className="material-symbols-outlined">search</span>
<input
type="text"
placeholder={t.usersPage.searchPlaceholder}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="users-badges">
<span className="badge badge-success">
{t.usersPage.active}: {users.filter((u) => u.is_active).length}
</span>
<span className="badge badge-neutral">
{t.usersPage.inactive}: {users.filter((u) => !u.is_active).length}
</span>
</div>
</div>
{error && <div className="users-alert">{error}</div>}
{loading ? (
<div className="loading">{t.common.loading}</div>
) : filteredUsers.length === 0 ? (
<div className="users-empty">{t.usersPage.noUsers}</div>
) : (
<div className="users-card">
<table className="users-table">
<thead>
<tr>
<th>{t.usersPage.name}</th>
<th>{t.usersPage.status}</th>
<th>{t.usersPage.role}</th>
<th>{t.usersPage.actions}</th>
</tr>
</thead>
<tbody>
{filteredUsers.map((u) => (
<tr key={u.id}>
<td>
<div className="user-cell">
<div className="user-avatar">
{u.username.charAt(0).toUpperCase()}
</div>
<div className="user-meta">
<span className="user-name">{u.username}</span>
<span className="user-email">{u.email}</span>
<span className="user-id">
{t.dashboard.userId}: {u.id}
</span>
</div>
</div>
</td>
<td>
<span
className={`badge ${u.is_active ? 'badge-success' : 'badge-muted'}`}
>
{u.is_active ? t.usersPage.active : t.usersPage.inactive}
</span>
</td>
<td>
<span
className={`badge ${u.is_superuser ? 'badge-accent' : 'badge-neutral'}`}
>
{u.is_superuser ? t.usersPage.superuser : t.usersPage.regular}
</span>
</td>
<td>
<div className="users-actions">
<button
className="btn-ghost"
onClick={() => openEditModal(u)}
disabled={isSaving}
>
<span className="material-symbols-outlined">edit</span>
{t.usersPage.edit}
</button>
<button
className="btn-ghost danger"
onClick={() => handleDelete(u)}
disabled={isSaving || currentUser?.id === u.id}
title={
currentUser?.id === u.id
? t.usersPage.selfDeleteWarning
: undefined
}
>
<span className="material-symbols-outlined">delete</span>
{t.usersPage.delete}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</main>
{isModalOpen && (
<div className="users-modal-backdrop" onClick={closeModal}>
<div className="users-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>
{editingUser ? t.usersPage.editUser : t.usersPage.addUser}
</h2>
<button className="btn-icon btn-close" onClick={closeModal} aria-label={t.common.close}>
<span className="material-symbols-outlined">close</span>
</button>
</div>
{error && <div className="users-alert in-modal">{error}</div>}
<form onSubmit={handleSubmit} className="users-form">
<div className="form-row">
<label htmlFor="username">{t.usersPage.name}</label>
<input
id="username"
type="text"
value={formData.username}
onChange={(e) =>
setFormData((prev) => ({ ...prev, username: e.target.value }))
}
required
minLength={3}
/>
</div>
<div className="form-row">
<label htmlFor="email">{t.usersPage.email}</label>
<input
id="email"
type="email"
value={formData.email}
onChange={(e) =>
setFormData((prev) => ({ ...prev, email: e.target.value }))
}
required
/>
</div>
<div className="form-row">
<label htmlFor="password">
{t.usersPage.password}{' '}
{editingUser ? (
<span className="helper-text">{t.usersPage.passwordHintEdit}</span>
) : (
<span className="helper-text">{t.usersPage.passwordHintCreate}</span>
)}
</label>
<input
id="password"
type="password"
value={formData.password}
onChange={(e) =>
setFormData((prev) => ({ ...prev, password: e.target.value }))
}
minLength={formData.password ? 8 : undefined}
/>
</div>
<div className="form-grid">
<label className="checkbox-row">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) =>
setFormData((prev) => ({ ...prev, is_active: e.target.checked }))
}
/>
<span>{t.usersPage.isActive}</span>
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={formData.is_superuser}
onChange={(e) =>
setFormData((prev) => ({
...prev,
is_superuser: e.target.checked,
}))
}
/>
<span>{t.usersPage.isSuperuser}</span>
</label>
</div>
<div className="modal-actions">
<button type="button" className="btn-ghost" onClick={closeModal}>
{t.common.cancel}
</button>
<button type="submit" className="btn-primary" disabled={isSaving}>
{isSaving ? t.common.loading : t.usersPage.save}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,217 @@
import { useState, useEffect, useRef } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { useTranslation } from '../../contexts/LanguageContext';
import { useSidebar } from '../../contexts/SidebarContext';
import { useModules } from '../../contexts/ModulesContext';
import type { ModuleId } from '../../contexts/ModulesContext';
import Feature1Tab from '../../components/admin/Feature1Tab';
import '../../styles/AdminPanel.css';
type TabId = 'feature1' | 'feature2' | 'feature3';
export default function Features() {
const { user: currentUser } = useAuth();
const { t } = useTranslation();
const { toggleMobileMenu } = useSidebar();
const { moduleStates, setModuleEnabled, saveModulesToBackend, hasInitialized } = useModules();
const [activeTab, setActiveTab] = useState<TabId>('feature1');
const hasUserMadeChanges = useRef(false);
const saveRef = useRef(saveModulesToBackend);
const [tooltip, setTooltip] = useState<{ text: string; left: number; visible: boolean }>({
text: '',
left: 0,
visible: false,
});
const handleTabMouseEnter = (text: string, e: React.MouseEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setTooltip({
text,
left: rect.left + rect.width / 2,
visible: true,
});
};
const handleTabMouseLeave = () => {
setTooltip((prev) => ({ ...prev, visible: false }));
};
// Keep saveRef updated with latest function
useEffect(() => {
saveRef.current = saveModulesToBackend;
}, [saveModulesToBackend]);
if (!currentUser?.is_superuser) {
return null;
}
const handleModuleToggle = async (moduleId: ModuleId, type: 'admin' | 'user', enabled: boolean) => {
hasUserMadeChanges.current = true;
setModuleEnabled(moduleId, type, enabled);
};
// Save changes when moduleStates change, but debounce to avoid too many requests
// Only save if: 1) Backend data has been loaded, and 2) User has made changes
useEffect(() => {
if (!hasInitialized || !hasUserMadeChanges.current) {
return;
}
const timeoutId = setTimeout(() => {
saveRef.current().catch(console.error);
}, 300);
return () => clearTimeout(timeoutId);
}, [moduleStates, hasInitialized]);
// Save on unmount if there are pending changes (empty deps = only on unmount)
useEffect(() => {
return () => {
if (hasUserMadeChanges.current) {
saveRef.current().catch(console.error);
}
};
}, []);
const getModuleDescription = (moduleId: string): string => {
const key = `${moduleId}Desc` as keyof typeof t.admin;
return t.admin[key] || t.admin.moduleDefaultDesc;
};
const renderModuleToggle = (moduleId: ModuleId) => {
const state = moduleStates[moduleId];
const adminEnabled = state?.admin ?? true;
const userEnabled = state?.user ?? true;
return (
<div className="feature-header">
<div className="feature-header-info">
<p>{getModuleDescription(moduleId)}</p>
</div>
<div className="feature-header-actions">
<div className={`feature-status-badge ${adminEnabled ? 'active' : ''}`}>
{adminEnabled ? t.admin.active : t.admin.inactive}
</div>
<div className="toggle-group">
<span className="toggle-label">{t.admin.adminRole}</span>
<label className="toggle-modern">
<input
type="checkbox"
checked={adminEnabled}
onChange={(e) => handleModuleToggle(moduleId, 'admin', e.target.checked)}
/>
<span className="toggle-slider-modern"></span>
</label>
</div>
<div className="toggle-group">
<span className="toggle-label">{t.admin.userRole}</span>
<label className={`toggle-modern ${!adminEnabled ? 'disabled' : ''}`}>
<input
type="checkbox"
checked={userEnabled}
disabled={!adminEnabled}
onChange={(e) => handleModuleToggle(moduleId, 'user', e.target.checked)}
/>
<span className="toggle-slider-modern"></span>
</label>
</div>
</div>
</div>
);
};
const renderTabContent = () => {
switch (activeTab) {
case 'feature1':
return (
<>
{renderModuleToggle('feature1')}
<Feature1Tab />
</>
);
case 'feature2':
return (
<>
{renderModuleToggle('feature2')}
<div className="tab-content-placeholder">
<div className="placeholder-icon">
<span className="material-symbols-outlined">download</span>
</div>
<h3>{t.features.feature2}</h3>
<p>{t.features.comingSoon}</p>
</div>
</>
);
case 'feature3':
return (
<>
{renderModuleToggle('feature3')}
<div className="tab-content-placeholder">
<div className="placeholder-icon">
<span className="material-symbols-outlined">cast</span>
</div>
<h3>{t.features.feature3}</h3>
<p>{t.features.comingSoon}</p>
</div>
</>
);
default:
return null;
}
};
return (
<main className="main-content admin-panel-root">
{/* Tab Tooltip */}
{tooltip.visible && (
<div
className="admin-tab-tooltip"
style={{ left: tooltip.left }}
>
{tooltip.text}
</div>
)}
<div className="admin-tabs-container">
<div className="admin-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="admin-title-section">
<span className="material-symbols-outlined">extension</span>
<span className="admin-title-text">{t.featuresPage.title}</span>
</div>
<div className="admin-tabs-divider"></div>
<button
className={`admin-tab-btn ${activeTab === 'feature1' ? 'active' : ''}`}
onClick={() => setActiveTab('feature1')}
onMouseEnter={(e) => handleTabMouseEnter(t.sidebar.feature1, e)}
onMouseLeave={handleTabMouseLeave}
>
<span className="material-symbols-outlined">playlist_play</span>
<span>{t.sidebar.feature1}</span>
</button>
<button
className={`admin-tab-btn ${activeTab === 'feature2' ? 'active' : ''}`}
onClick={() => setActiveTab('feature2')}
onMouseEnter={(e) => handleTabMouseEnter(t.sidebar.feature2, e)}
onMouseLeave={handleTabMouseLeave}
>
<span className="material-symbols-outlined">download</span>
<span>{t.sidebar.feature2}</span>
</button>
<button
className={`admin-tab-btn ${activeTab === 'feature3' ? 'active' : ''}`}
onClick={() => setActiveTab('feature3')}
onMouseEnter={(e) => handleTabMouseEnter(t.sidebar.feature3, e)}
onMouseLeave={handleTabMouseLeave}
>
<span className="material-symbols-outlined">cast</span>
<span>{t.sidebar.feature3}</span>
</button>
</div>
</div>
<div className="admin-tab-content">
{renderTabContent()}
</div>
</main>
);
}

View File

@@ -0,0 +1,136 @@
import { useState, useEffect } from 'react';
import { useTranslation } from '../../contexts/LanguageContext';
import { useSidebar } from '../../contexts/SidebarContext';
import Sidebar from '../../components/Sidebar';
import '../../styles/Settings.css';
interface Settings {
registration_enabled?: boolean;
show_logo?: boolean;
}
export default function Settings() {
const { t } = useTranslation();
const { toggleMobileMenu } = useSidebar();
const [settings, setSettings] = useState<Settings>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/v1/settings', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
const data = await response.json();
setSettings(data);
}
} catch (error) {
console.error('Failed to fetch settings:', error);
} finally {
setLoading(false);
}
};
const updateSetting = async (key: string, value: any) => {
setSaving(true);
try {
const token = localStorage.getItem('token');
const response = await fetch(`/api/v1/settings/${key}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ value }),
});
if (response.ok) {
const updatedSetting = await response.json();
setSettings(prev => ({
...prev,
[key]: updatedSetting.value
}));
}
} catch (error) {
console.error('Failed to update setting:', error);
} finally {
setSaving(false);
}
};
const handleRegistrationToggle = (checked: boolean) => {
updateSetting('registration_enabled', checked);
};
if (loading) {
return <div className="loading">{t.common.loading}</div>;
}
return (
<div className="app-layout">
<Sidebar />
<main className="main-content settings-root">
<div className="page-tabs-container">
<div className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="page-title-section">
<span className="material-symbols-outlined">settings</span>
<span className="page-title-text">{t.settings.title}</span>
</div>
</div>
</div>
<div className="page-content">
<div className="settings-card">
<div className="settings-section">
<h2>{t.settings.authentication}</h2>
<div className="setting-item">
<div className="setting-info">
<h3>{t.settings.allowRegistration}</h3>
<p>{t.settings.allowRegistrationDesc}</p>
</div>
<label className="toggle-switch">
<input
type="checkbox"
checked={settings.registration_enabled !== false}
onChange={(e) => handleRegistrationToggle(e.target.checked)}
disabled={saving}
/>
<span className="toggle-slider"></span>
</label>
</div>
<div className="setting-item">
<div className="setting-info">
<h3>{t.settings.showLogo}</h3>
<p>{t.settings.showLogoDesc}</p>
</div>
<label className="toggle-switch">
<input
type="checkbox"
checked={settings.show_logo === true}
onChange={(e) => updateSetting('show_logo', e.target.checked)}
disabled={saving}
/>
<span className="toggle-slider"></span>
</label>
</div>
</div>
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { useAuth } from '../../contexts/AuthContext';
import { useTranslation } from '../../contexts/LanguageContext';
import { useSidebar } from '../../contexts/SidebarContext';
import '../../styles/AdminPanel.css';
export default function Sources() {
const { user: currentUser } = useAuth();
const { t } = useTranslation();
const { toggleMobileMenu } = useSidebar();
if (!currentUser?.is_superuser) {
return null;
}
return (
<main className="main-content admin-panel-root">
<div className="admin-tabs-container">
<div className="admin-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="admin-title-section">
<span className="material-symbols-outlined">database</span>
<span className="admin-title-text">{t.sourcesPage.title}</span>
</div>
</div>
</div>
<div className="admin-tab-content">
<div className="tab-content-placeholder">
<div className="placeholder-icon">
<span className="material-symbols-outlined">database</span>
</div>
<h3>{t.sourcesPage.title}</h3>
<p>{t.sourcesPage.comingSoon}</p>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,808 @@
import { useState } from 'react';
import { useTheme, COLOR_PALETTES } from '../../contexts/ThemeContext';
import type { AccentColor, BorderRadius, SidebarStyle, Density, FontFamily, ColorPalette } from '../../contexts/ThemeContext';
import { useTranslation } from '../../contexts/LanguageContext';
import { useAuth } from '../../contexts/AuthContext';
import { useSidebar } from '../../contexts/SidebarContext';
import type { SidebarMode } from '../../contexts/SidebarContext';
import { ChromePicker, HuePicker } from 'react-color';
import '../../styles/ThemeSettings.css';
type ThemeTab = 'colors' | 'appearance' | 'preview' | 'advanced';
type ColorPickerState = {
isOpen: boolean;
theme: 'light' | 'dark';
property: string;
value: string;
} | null;
export default function ThemeSettings() {
const [activeTab, setActiveTab] = useState<ThemeTab>('colors');
const {
accentColor,
borderRadius,
sidebarStyle,
density,
fontFamily,
colorPalette,
setAccentColor,
setBorderRadius,
setSidebarStyle,
setDensity,
setFontFamily,
setColorPalette,
saveThemeToBackend
} = useTheme();
const { t } = useTranslation();
const { user } = useAuth();
const { toggleMobileMenu, sidebarMode, setSidebarMode } = useSidebar();
const isAdmin = user?.is_superuser || false;
const [tooltip, setTooltip] = useState<{ text: string; left: number; visible: boolean }>({
text: '',
left: 0,
visible: false,
});
const handleTabMouseEnter = (text: string, e: React.MouseEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setTooltip({
text,
left: rect.left + rect.width / 2,
visible: true,
});
};
const handleTabMouseLeave = () => {
setTooltip((prev) => ({ ...prev, visible: false }));
};
// Handlers that save to backend after setting
const handleAccentColorChange = async (color: AccentColor) => {
setAccentColor(color);
saveThemeToBackend({ accentColor: color }).catch(console.error);
};
const handleBorderRadiusChange = async (radius: BorderRadius) => {
setBorderRadius(radius);
saveThemeToBackend({ borderRadius: radius }).catch(console.error);
};
const handleSidebarStyleChange = async (style: SidebarStyle) => {
setSidebarStyle(style);
saveThemeToBackend({ sidebarStyle: style }).catch(console.error);
};
const handleDensityChange = async (d: Density) => {
setDensity(d);
saveThemeToBackend({ density: d }).catch(console.error);
};
const handleFontFamilyChange = async (font: FontFamily) => {
setFontFamily(font);
saveThemeToBackend({ fontFamily: font }).catch(console.error);
};
const handleColorPaletteChange = async (palette: ColorPalette) => {
setColorPalette(palette);
saveThemeToBackend({ colorPalette: palette }).catch(console.error);
};
// Advanced color states
const [customColors, setCustomColors] = useState({
light: {
bgMain: '#ffffff',
bgCard: '#f9fafb',
bgElevated: '#ffffff',
textPrimary: '#111827',
textSecondary: '#6b7280',
border: '#e5e7eb',
sidebarBg: '#1f2937',
sidebarText: '#f9fafb'
},
dark: {
bgMain: '#0f172a',
bgCard: '#1e293b',
bgElevated: '#334155',
textPrimary: '#f1f5f9',
textSecondary: '#94a3b8',
border: '#334155',
sidebarBg: '#0c1222',
sidebarText: '#f9fafb'
}
});
// Color picker popup state
const [colorPickerState, setColorPickerState] = useState<ColorPickerState>(null);
const colors: { id: AccentColor; label: string; value: string; description: string }[] = [
{ id: 'auto', label: t.theme.colors.auto, value: '#374151', description: t.theme.colors.autoDesc },
{ id: 'blue', label: t.theme.colors.blue, value: '#3b82f6', description: t.theme.colors.blueDesc },
{ id: 'purple', label: t.theme.colors.purple, value: '#8b5cf6', description: t.theme.colors.purpleDesc },
{ id: 'green', label: t.theme.colors.green, value: '#10b981', description: t.theme.colors.greenDesc },
{ id: 'orange', label: t.theme.colors.orange, value: '#f97316', description: t.theme.colors.orangeDesc },
{ id: 'pink', label: t.theme.colors.pink, value: '#ec4899', description: t.theme.colors.pinkDesc },
{ id: 'red', label: t.theme.colors.red, value: '#ef4444', description: t.theme.colors.redDesc },
{ id: 'teal', label: t.theme.colors.teal, value: '#14b8a6', description: t.theme.colors.tealDesc },
{ id: 'amber', label: t.theme.colors.amber, value: '#f59e0b', description: t.theme.colors.amberDesc },
{ id: 'indigo', label: t.theme.colors.indigo, value: '#6366f1', description: t.theme.colors.indigoDesc },
{ id: 'cyan', label: t.theme.colors.cyan, value: '#06b6d4', description: t.theme.colors.cyanDesc },
{ id: 'rose', label: t.theme.colors.rose, value: '#f43f5e', description: t.theme.colors.roseDesc },
];
const radii: { id: BorderRadius; label: string; description: string; preview: string }[] = [
{ id: 'large', label: t.theme.radius.large, description: t.theme.radius.largeDesc, preview: '16px' },
{ id: 'medium', label: t.theme.radius.medium, description: t.theme.radius.mediumDesc, preview: '8px' },
{ id: 'small', label: t.theme.radius.small, description: t.theme.radius.smallDesc, preview: '4px' },
];
const sidebarStyles: { id: SidebarStyle; label: string; description: string }[] = [
{ id: 'default', label: t.theme.sidebarOptions.default, description: t.theme.sidebarOptions.defaultDesc },
{ id: 'dark', label: t.theme.sidebarOptions.dark, description: t.theme.sidebarOptions.darkDesc },
{ id: 'light', label: t.theme.sidebarOptions.light, description: t.theme.sidebarOptions.lightDesc },
];
const sidebarModes: { id: SidebarMode; label: string; description: string }[] = [
{ id: 'toggle', label: t.admin.sidebarModeToggle, description: t.admin.sidebarModeToggleDesc },
{ id: 'dynamic', label: t.admin.sidebarModeDynamic, description: t.admin.sidebarModeDynamicDesc },
{ id: 'collapsed', label: t.admin.sidebarModeCollapsed, description: t.admin.sidebarModeCollapsedDesc },
];
const densities: { id: Density; label: string; description: string }[] = [
{ id: 'compact', label: t.theme.densityOptions.compact, description: t.theme.densityOptions.compactDesc },
{ id: 'comfortable', label: t.theme.densityOptions.comfortable, description: t.theme.densityOptions.comfortableDesc },
{ id: 'spacious', label: t.theme.densityOptions.spacious, description: t.theme.densityOptions.spaciousDesc },
];
const fonts: { id: FontFamily; label: string; description: string; fontStyle: string }[] = [
{ id: 'sans', label: t.theme.fontOptions.system, description: t.theme.fontOptions.systemDesc, fontStyle: 'system-ui' },
{ id: 'inter', label: t.theme.fontOptions.inter, description: t.theme.fontOptions.interDesc, fontStyle: 'Inter' },
{ id: 'roboto', label: t.theme.fontOptions.roboto, description: t.theme.fontOptions.robotoDesc, fontStyle: 'Roboto' },
];
const palettes: { id: ColorPalette; label: string; description: string }[] = [
{ id: 'monochrome', label: t.theme.palettes.monochrome, description: t.theme.palettes.monochromeDesc },
{ id: 'default', label: t.theme.palettes.default, description: t.theme.palettes.defaultDesc },
{ id: 'monochromeBlue', label: t.theme.palettes.monochromeBlue, description: t.theme.palettes.monochromeBlueDesc },
{ id: 'sepia', label: t.theme.palettes.sepia, description: t.theme.palettes.sepiaDesc },
{ id: 'nord', label: t.theme.palettes.nord, description: t.theme.palettes.nordDesc },
{ id: 'dracula', label: t.theme.palettes.dracula, description: t.theme.palettes.draculaDesc },
{ id: 'solarized', label: t.theme.palettes.solarized, description: t.theme.palettes.solarizedDesc },
{ id: 'github', label: t.theme.palettes.github, description: t.theme.palettes.githubDesc },
{ id: 'ocean', label: t.theme.palettes.ocean, description: t.theme.palettes.oceanDesc },
{ id: 'forest', label: t.theme.palettes.forest, description: t.theme.palettes.forestDesc },
{ id: 'midnight', label: t.theme.palettes.midnight, description: t.theme.palettes.midnightDesc },
{ id: 'sunset', label: t.theme.palettes.sunset, description: t.theme.palettes.sunsetDesc },
];
// Helper component for color control
const ColorControl = ({ theme, property, label, value }: {
theme: 'light' | 'dark';
property: string;
label: string;
value: string
}) => {
return (
<div className="color-control-item">
<label className="color-control-label">
<span>{label}</span>
<span className="color-value-display selectable">{value}</span>
</label>
<div className="color-control-actions">
<div className="color-input-group">
<button
className="btn-color-picker"
style={{ backgroundColor: value }}
title={t.theme.pickColor}
onClick={() => {
setColorPickerState({
isOpen: true,
theme,
property,
value
});
}}
>
{/* Color preview via background color */}
</button>
<input
type="text"
value={value.toUpperCase()}
onChange={(e) => handleHexInput(theme, property, e.target.value)}
className="color-hex-input"
placeholder="#FFFFFF"
maxLength={7}
/>
</div>
</div>
</div>
);
};
// Color picker handlers
const handleColorChange = (theme: 'light' | 'dark', property: string, value: string) => {
setCustomColors(prev => ({
...prev,
[theme]: {
...prev[theme],
[property]: value
}
}));
// Apply to CSS variables immediately
const root = document.documentElement;
const varName = `--color-${property.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
root.style.setProperty(varName, value);
};
const handleHexInput = (theme: 'light' | 'dark', property: string, value: string) => {
// Validate hex color format
const hexRegex = /^#?[0-9A-Fa-f]{6}$/;
const formattedValue = value.startsWith('#') ? value : `#${value}`;
if (hexRegex.test(formattedValue)) {
handleColorChange(theme, property, formattedValue);
}
};
const resetToDefaults = () => {
setCustomColors({
light: {
bgMain: '#ffffff',
bgCard: '#f9fafb',
bgElevated: '#ffffff',
textPrimary: '#111827',
textSecondary: '#6b7280',
border: '#e5e7eb',
sidebarBg: '#1f2937',
sidebarText: '#f9fafb'
},
dark: {
bgMain: '#0f172a',
bgCard: '#1e293b',
bgElevated: '#334155',
textPrimary: '#f1f5f9',
textSecondary: '#94a3b8',
border: '#334155',
sidebarBg: '#0c1222',
sidebarText: '#f9fafb'
}
});
// Reset CSS variables
const root = document.documentElement;
root.style.removeProperty('--color-bg-main');
root.style.removeProperty('--color-bg-card');
root.style.removeProperty('--color-bg-elevated');
root.style.removeProperty('--color-text-primary');
root.style.removeProperty('--color-text-secondary');
root.style.removeProperty('--color-border');
root.style.removeProperty('--color-sidebar-bg');
root.style.removeProperty('--color-sidebar-text');
};
return (
<main className="main-content theme-settings-root">
{/* Tab Tooltip */}
{tooltip.visible && (
<div
className="admin-tab-tooltip"
style={{ left: tooltip.left }}
>
{tooltip.text}
</div>
)}
{/* Modern Tab Navigation */}
<div className="page-tabs-container">
<div className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="page-title-section">
<span className="material-symbols-outlined">brush</span>
<span className="page-title-text">{t.theme.title}</span>
</div>
<div className="admin-tabs-divider"></div>
<button
className={`admin-tab-btn ${activeTab === 'colors' ? 'active' : ''}`}
onClick={() => setActiveTab('colors')}
onMouseEnter={(e) => handleTabMouseEnter(t.theme.colorsTab, e)}
onMouseLeave={handleTabMouseLeave}
>
<span className="material-symbols-outlined">color_lens</span>
<span>{t.theme.colorsTab}</span>
</button>
<button
className={`admin-tab-btn ${activeTab === 'appearance' ? 'active' : ''}`}
onClick={() => setActiveTab('appearance')}
onMouseEnter={(e) => handleTabMouseEnter(t.theme.appearanceTab, e)}
onMouseLeave={handleTabMouseLeave}
>
<span className="material-symbols-outlined">tune</span>
<span>{t.theme.appearanceTab}</span>
</button>
<button
className={`admin-tab-btn ${activeTab === 'preview' ? 'active' : ''}`}
onClick={() => setActiveTab('preview')}
onMouseEnter={(e) => handleTabMouseEnter(t.theme.previewTab, e)}
onMouseLeave={handleTabMouseLeave}
>
<span className="material-symbols-outlined">visibility</span>
<span>{t.theme.previewTab}</span>
</button>
{isAdmin && (
<button
className={`admin-tab-btn ${activeTab === 'advanced' ? 'active' : ''}`}
onClick={() => setActiveTab('advanced')}
onMouseEnter={(e) => handleTabMouseEnter(t.theme.advancedTab, e)}
onMouseLeave={handleTabMouseLeave}
>
<span className="material-symbols-outlined">code</span>
<span>{t.theme.advancedTab}</span>
</button>
)}
</div>
</div>
<div className="admin-tab-content">
{activeTab === 'colors' && (
<div className="theme-tab-content">
<div className="theme-section">
<div className="section-header">
<h3 className="section-title">{t.theme.accentColor}</h3>
</div>
<div className="color-grid-enhanced">
{colors.map((color) => (
<div
key={color.id}
className={`color-card ${accentColor === color.id ? 'active' : ''}`}
onClick={() => handleAccentColorChange(color.id)}
>
<div className="color-swatch-large" style={{ backgroundColor: color.value }}>
{accentColor === color.id && (
<span className="material-symbols-outlined">check</span>
)}
</div>
<div className="color-info">
<span className="color-name">{color.label}</span>
<span className="color-description">{color.description}</span>
</div>
</div>
))}
</div>
</div>
<div className="theme-section">
<div className="section-header">
<h3 className="section-title">{t.theme.colorPalette}</h3>
</div>
<div className="palette-grid">
{palettes.map((palette) => {
const paletteColors = COLOR_PALETTES[palette.id];
return (
<div
key={palette.id}
className={`palette-card ${colorPalette === palette.id ? 'active' : ''}`}
onClick={() => handleColorPaletteChange(palette.id)}
>
<div className="palette-preview">
<div className="palette-swatch-row">
<div className="palette-swatch" style={{ backgroundColor: paletteColors.light.bgMain }} title="Light BG" />
<div className="palette-swatch" style={{ backgroundColor: paletteColors.light.bgCard }} title="Light Card" />
<div className="palette-swatch" style={{ backgroundColor: paletteColors.light.textPrimary }} title="Light Text" />
<div className="palette-swatch" style={{ backgroundColor: paletteColors.light.sidebarBg }} title="Light Sidebar" />
</div>
<div className="palette-swatch-row">
<div className="palette-swatch" style={{ backgroundColor: paletteColors.dark.bgMain }} title="Dark BG" />
<div className="palette-swatch" style={{ backgroundColor: paletteColors.dark.bgCard }} title="Dark Card" />
<div className="palette-swatch" style={{ backgroundColor: paletteColors.dark.textPrimary }} title="Dark Text" />
<div className="palette-swatch" style={{ backgroundColor: paletteColors.dark.sidebarBg }} title="Dark Sidebar" />
</div>
{colorPalette === palette.id && (
<div className="palette-check">
<span className="material-symbols-outlined">check</span>
</div>
)}
</div>
<div className="palette-info">
<span className="palette-name">{palette.label}</span>
<span className="palette-description">{palette.description}</span>
</div>
</div>
);
})}
</div>
</div>
</div>
)}
{activeTab === 'appearance' && (
<div className="theme-tab-content">
<div className="appearance-grid">
{/* Border Radius Section */}
<div className="theme-section">
<div className="section-header">
<h3 className="section-title">{t.theme.borderRadius}</h3>
</div>
<div className="option-cards">
{radii.map((radius) => (
<div
key={radius.id}
className={`option-card ${borderRadius === radius.id ? 'active' : ''}`}
onClick={() => handleBorderRadiusChange(radius.id)}
>
<div className="option-preview">
<div
className="radius-preview-box"
style={{ borderRadius: radius.preview }}
></div>
</div>
<div className="option-info">
<span className="option-name">{radius.label}</span>
<span className="option-description">{radius.description}</span>
</div>
</div>
))}
</div>
</div>
{/* Sidebar Style Section */}
<div className="theme-section">
<div className="section-header">
<h3 className="section-title">{t.theme.sidebarStyle}</h3>
</div>
<div className="option-cards">
{sidebarStyles.map((style) => (
<div
key={style.id}
className={`option-card ${sidebarStyle === style.id ? 'active' : ''}`}
onClick={() => handleSidebarStyleChange(style.id)}
>
<div className="option-preview">
<div className={`sidebar-preview sidebar-preview-${style.id}`}>
<div className="sidebar-part"></div>
<div className="content-part"></div>
</div>
</div>
<div className="option-info">
<span className="option-name">{style.label}</span>
<span className="option-description">{style.description}</span>
</div>
</div>
))}
</div>
</div>
{/* Sidebar Mode Section */}
<div className="theme-section">
<div className="section-header">
<h3 className="section-title">{t.admin.sidebarMode}</h3>
</div>
<div className="option-cards">
{sidebarModes.map((mode) => (
<div
key={mode.id}
className={`option-card ${sidebarMode === mode.id ? 'active' : ''}`}
onClick={() => setSidebarMode(mode.id)}
>
<div className="option-preview">
<div className={`sidebar-mode-preview sidebar-mode-${mode.id}`}>
<div className="sidebar-line"></div>
<div className="sidebar-line"></div>
</div>
</div>
<div className="option-info">
<span className="option-name">{mode.label}</span>
<span className="option-description">{mode.description}</span>
</div>
</div>
))}
</div>
</div>
{/* Density Section */}
<div className="theme-section">
<div className="section-header">
<h3 className="section-title">{t.theme.density}</h3>
</div>
<div className="option-cards">
{densities.map((d) => (
<div
key={d.id}
className={`option-card ${density === d.id ? 'active' : ''}`}
onClick={() => handleDensityChange(d.id)}
>
<div className="option-preview">
<div className={`density-preview density-preview-${d.id}`}>
<div className="density-line"></div>
<div className="density-line"></div>
<div className="density-line"></div>
</div>
</div>
<div className="option-info">
<span className="option-name">{d.label}</span>
<span className="option-description">{d.description}</span>
</div>
</div>
))}
</div>
</div>
{/* Font Family Section */}
<div className="theme-section">
<div className="section-header">
<h3 className="section-title">{t.theme.fontFamily}</h3>
</div>
<div className="option-cards">
{fonts.map((f) => (
<div
key={f.id}
className={`option-card ${fontFamily === f.id ? 'active' : ''}`}
onClick={() => handleFontFamilyChange(f.id)}
>
<div className="option-preview">
<div className="font-preview" style={{ fontFamily: f.fontStyle }}>
Aa
</div>
</div>
<div className="option-info">
<span className="option-name">{f.label}</span>
<span className="option-description">{f.description}</span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
)}
{activeTab === 'preview' && (
<div className="theme-tab-content">
<div className="theme-section">
<div className="section-header">
<h3 className="section-title">{t.theme.preview}</h3>
</div>
<div className="preview-container">
<div className="preview-card">
<div className="preview-header">
<h3>{t.theme.previewCard}</h3>
<span className="badge badge-accent">{t.theme.badge}</span>
</div>
<p>{t.theme.previewDescription}</p>
<div className="preview-actions">
<button className="btn-primary">{t.theme.primaryButton}</button>
<button className="btn-ghost">{t.theme.ghostButton}</button>
</div>
<div className="preview-inputs">
<input type="text" placeholder={t.theme.inputPlaceholder} />
</div>
</div>
<div className="preview-card">
<div className="preview-header">
<h3>{t.theme.sampleCard}</h3>
<span className="badge badge-success">{t.dashboard.active}</span>
</div>
<p>{t.theme.sampleCardDesc}</p>
<div className="preview-stats">
<div className="stat-item">
<span className="stat-value">142</span>
<span className="stat-label">{t.theme.totalItems}</span>
</div>
<div className="stat-item">
<span className="stat-value">89%</span>
<span className="stat-label">{t.theme.successRate}</span>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{activeTab === 'advanced' && isAdmin && (
<div className="theme-tab-content">
<div className="theme-section">
<div className="section-header">
<h3 className="section-title">{t.theme.advancedColors}</h3>
<button className="btn-ghost" onClick={resetToDefaults} style={{ marginTop: '1rem' }}>
<span className="material-symbols-outlined">refresh</span>
{t.theme.resetColors}
</button>
</div>
<div className="advanced-colors-grid" style={{ marginTop: '2rem' }}>
{/* Light Theme Colors */}
<div className="color-theme-section">
<h3 className="color-theme-title">{t.theme.lightThemeColors}</h3>
<div className="color-controls-list">
<ColorControl
theme="light"
property="bgMain"
label={t.theme.background}
value={customColors.light.bgMain}
/>
<ColorControl
theme="light"
property="bgCard"
label={t.theme.backgroundCard}
value={customColors.light.bgCard}
/>
<ColorControl
theme="light"
property="bgElevated"
label={t.theme.backgroundElevated}
value={customColors.light.bgElevated}
/>
<ColorControl
theme="light"
property="textPrimary"
label={t.theme.textPrimary}
value={customColors.light.textPrimary}
/>
<ColorControl
theme="light"
property="textSecondary"
label={t.theme.textSecondary}
value={customColors.light.textSecondary}
/>
<ColorControl
theme="light"
property="border"
label={t.theme.border}
value={customColors.light.border}
/>
<ColorControl
theme="light"
property="sidebarBg"
label={t.theme.sidebarBackground}
value={customColors.light.sidebarBg}
/>
<ColorControl
theme="light"
property="sidebarText"
label={t.theme.sidebarText}
value={customColors.light.sidebarText}
/>
</div>
</div>
{/* Dark Theme Colors */}
<div className="color-theme-section">
<h3 className="color-theme-title">{t.theme.darkThemeColors}</h3>
<div className="color-controls-list">
<ColorControl
theme="dark"
property="bgMain"
label={t.theme.background}
value={customColors.dark.bgMain}
/>
<ColorControl
theme="dark"
property="bgCard"
label={t.theme.backgroundCard}
value={customColors.dark.bgCard}
/>
<ColorControl
theme="dark"
property="bgElevated"
label={t.theme.backgroundElevated}
value={customColors.dark.bgElevated}
/>
<ColorControl
theme="dark"
property="textPrimary"
label={t.theme.textPrimary}
value={customColors.dark.textPrimary}
/>
<ColorControl
theme="dark"
property="textSecondary"
label={t.theme.textSecondary}
value={customColors.dark.textSecondary}
/>
<ColorControl
theme="dark"
property="border"
label={t.theme.border}
value={customColors.dark.border}
/>
<ColorControl
theme="dark"
property="sidebarBg"
label={t.theme.sidebarBackground}
value={customColors.dark.sidebarBg}
/>
<ColorControl
theme="dark"
property="sidebarText"
label={t.theme.sidebarText}
value={customColors.dark.sidebarText}
/>
</div>
</div>
</div>
</div>
</div>
)}
</div>
{/* Color Picker Popup */}
{colorPickerState && (
<div
className="color-picker-overlay"
onClick={() => setColorPickerState(null)}
>
<div
className="color-picker-popup"
onClick={(e) => e.stopPropagation()}
>
<div className="color-picker-header">
<h3>{t.theme.pickColor}</h3>
<button
className="btn-close-picker"
onClick={() => setColorPickerState(null)}
>
<span className="material-symbols-outlined">close</span>
</button>
</div>
<div className="color-picker-content">
<div className="color-picker-preview-section">
<div
className="color-preview-box"
style={{ backgroundColor: colorPickerState.value }}
/>
<div className="color-preview-info">
<span className="color-preview-hex">{colorPickerState.value.toUpperCase()}</span>
<span className="color-preview-label">{t.theme.currentColor}</span>
</div>
</div>
<div className="chrome-picker-wrapper">
<ChromePicker
color={colorPickerState.value}
onChange={(color) => {
handleColorChange(
colorPickerState.theme,
colorPickerState.property,
color.hex
);
setColorPickerState({
...colorPickerState,
value: color.hex
});
}}
disableAlpha={true}
/>
<div className="hue-picker-wrapper">
<HuePicker
color={colorPickerState.value}
onChange={(color) => {
handleColorChange(
colorPickerState.theme,
colorPickerState.property,
color.hex
);
setColorPickerState({
...colorPickerState,
value: color.hex
});
}}
width="100%"
height="16px"
/>
</div>
</div>
<div className="color-picker-actions">
<button
className="btn-primary btn-full-width"
onClick={() => setColorPickerState(null)}
>
{t.theme.apply}
</button>
</div>
</div>
</div>
</div>
)}
</main>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,84 @@
/* ==========================================
DASHBOARD PAGE
Specific styles for dashboard page
Layout rules are in Layout.css
========================================== */
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
width: 100%;
}
/* Stat Card */
.stat-card {
background: var(--color-bg-card);
padding: 1.5rem;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition: transform 0.2s, box-shadow 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.stat-card h3 {
margin-top: 0;
color: var(--color-text-primary);
border-bottom: 2px solid var(--color-accent);
padding-bottom: 0.5rem;
font-size: 1.25rem;
}
.stat-card p {
margin: 0.5rem 0;
color: var(--color-text-secondary);
line-height: 1.6;
word-break: break-word;
}
/* Admin Card Variant */
.admin-card {
border: 2px solid var(--color-accent);
background: var(--color-bg-elevated);
}
/* Status Indicators */
.status-active {
color: var(--color-success);
font-weight: 600;
}
.status-inactive {
color: var(--color-error);
font-weight: 600;
}
/* ========== RESPONSIVE DESIGN ========== */
/* Tablet */
@media (max-width: 1024px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* Mobile */
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.stat-card {
padding: 1rem;
}
.stat-card h3 {
font-size: 1.1rem;
}
}

View File

@@ -0,0 +1,431 @@
/* ==========================================
UNIFIED PAGE LAYOUT SYSTEM
Standard layout classes for all pages
========================================== */
/* ========== BASE LAYOUT ========== */
/* App container - contains sidebar and main content */
.app-layout {
display: flex;
min-height: 100vh;
background: var(--color-bg-main);
}
/* Main content area - adjusts for sidebar */
.main-content {
flex: 1;
margin-left: var(--sidebar-width);
min-height: 100vh;
display: flex;
flex-direction: column;
transition: margin-left 0.3s ease;
}
/* Adjust main content when sidebar is collapsed */
.sidebar.collapsed~.main-content {
margin-left: var(--sidebar-width-collapsed);
}
/* ========== PAGE STRUCTURE ========== */
/* Page header container - centered with symmetric padding */
.page-tabs-container,
.admin-tabs-container {
display: flex;
justify-content: center;
padding: 0.75rem var(--page-padding-x);
background: var(--color-bg-main);
}
/* Page header slider - rounded pill style (like admin panel) */
.page-tabs-slider,
.admin-tabs-slider {
display: inline-flex;
align-items: center;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: 4px;
gap: 4px;
box-shadow: var(--shadow-sm);
max-width: 100%;
}
/* Page title section inside slider */
.page-title-section,
.admin-title-section {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1.25rem;
color: var(--color-text-primary);
font-size: 1rem;
font-weight: 600;
white-space: nowrap;
}
.page-title-section .material-symbols-outlined,
.admin-title-section .material-symbols-outlined {
font-size: 1.25rem;
color: var(--color-accent);
}
.page-title-text,
.admin-title-text {
color: var(--color-text-primary);
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Divider between title and tabs (for pages with tabs) */
.page-tabs-divider,
.admin-tabs-divider {
width: 1px;
height: 32px;
background: var(--color-border);
margin: 0 0.25rem;
}
/* Tab buttons (for pages with tabs) */
.page-tab-btn,
.admin-tab-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: var(--tab-padding);
background: transparent;
border: none;
border-radius: var(--radius-lg);
color: var(--color-text-secondary);
font-size: var(--tab-font-size);
font-weight: 500;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
outline: none;
-webkit-tap-highlight-color: transparent;
}
.page-tab-btn:focus,
.admin-tab-btn:focus,
.page-tab-btn:focus-visible,
.admin-tab-btn:focus-visible {
outline: none;
box-shadow: none;
}
.page-tab-btn.active:focus,
.admin-tab-btn.active:focus,
.page-tab-btn.active:focus-visible,
.admin-tab-btn.active:focus-visible {
box-shadow: 0 2px 8px rgba(var(--color-accent-rgb), 0.3);
}
.page-tab-btn .material-symbols-outlined,
.admin-tab-btn .material-symbols-outlined {
font-size: var(--icon-md);
transition: inherit;
}
.page-tab-btn:hover:not(.active),
.admin-tab-btn:hover:not(.active) {
color: var(--color-text-primary);
background: rgba(var(--color-accent-rgb), 0.1);
}
.page-tab-btn.active,
.admin-tab-btn.active {
background: var(--color-accent);
color: white;
box-shadow: 0 2px 8px rgba(var(--color-accent-rgb), 0.3);
}
.page-subtitle {
margin: 0.5rem 0 0;
color: var(--color-text-secondary);
font-size: 0.95rem;
}
/* Standard Section Title */
.section-title {
margin: 0;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-primary);
}
/* Standard Section Header */
.section-header {
margin-bottom: 1.5rem;
}
/* Standard Page Section */
.page-section {
margin-bottom: 3rem;
}
.page-section:last-child {
margin-bottom: 0;
}
/* ========== MOBILE MENU BUTTON ========== */
/* Hidden by default on desktop */
.mobile-menu-btn {
display: none;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: transparent;
border: none;
border-radius: var(--radius-lg);
color: var(--color-text-primary);
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.mobile-menu-btn:hover {
background: rgba(var(--color-accent-rgb), 0.1);
color: var(--color-accent);
}
.mobile-menu-btn .material-symbols-outlined {
font-size: var(--icon-lg);
}
/* ========== ACTION BUTTONS IN SLIDER ========== */
/* Action buttons that appear in the slider (like Add User) */
.page-tabs-slider .btn-primary,
.admin-tabs-slider .btn-primary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
margin-left: auto;
font-size: 0.9rem;
white-space: nowrap;
}
.page-tabs-slider .btn-primary .material-symbols-outlined,
.admin-tabs-slider .btn-primary .material-symbols-outlined {
font-size: 20px;
}
/* ========== PAGE CONTENT ========== */
/* Main content wrapper - with symmetric padding from sidebar edge to window edge */
.page-content {
flex: 1;
padding: var(--page-padding-y) var(--page-padding-x);
width: 100%;
max-width: 1600px;
margin: 0 auto;
}
/* Content wrapper for centered content with max-width (use inside page-content if needed) */
.content-wrapper {
max-width: var(--page-max-width);
width: 100%;
margin: 0 auto;
}
/* Admin tab content (for tabbed interfaces) */
.admin-tab-content {
padding: var(--page-padding-y) var(--page-padding-x);
max-width: 1600px;
margin: 0 auto;
width: 100%;
}
/* ========== RESPONSIVE DESIGN ========== */
/* Tablet - reduce padding */
@media (max-width: 1024px) {
.page-tabs-container,
.admin-tabs-container {
padding: var(--page-padding-y-tablet) var(--page-padding-x-tablet);
}
.page-content {
padding: var(--page-padding-y-tablet) var(--page-padding-x-tablet);
}
.admin-tab-content {
padding: var(--page-padding-y-tablet) var(--page-padding-x-tablet);
}
}
/* Mobile - remove sidebar margin */
@media (max-width: 768px) {
.main-content {
margin-left: 0;
}
/* Override collapsed state margin on mobile */
.sidebar.collapsed~.main-content {
margin-left: 0;
}
/* Show mobile menu button */
.mobile-menu-btn {
display: flex;
}
.page-tabs-container,
.admin-tabs-container {
padding: var(--page-padding-y-mobile) var(--page-padding-x-mobile);
}
.page-tabs-slider,
.admin-tabs-slider {
width: 100%;
flex-wrap: wrap;
justify-content: flex-start;
gap: 8px;
}
.page-title-section,
.admin-title-section {
flex: 1;
justify-content: flex-start;
padding: 0.5rem 0.75rem;
font-size: 1rem;
}
.page-title-section .material-symbols-outlined,
.admin-title-section .material-symbols-outlined {
font-size: 24px;
}
/* Hide divider on mobile */
.page-tabs-divider,
.admin-tabs-divider {
display: none;
}
/* Tabs on second row - full width */
.page-tab-btn,
.admin-tab-btn {
flex: 1;
justify-content: center;
padding: 0.75rem 1rem;
font-size: 0.9rem;
min-width: 0;
}
/* Hide text on mobile, show only icons */
.page-tab-btn span:not(.material-symbols-outlined),
.admin-tab-btn span:not(.material-symbols-outlined) {
display: none;
}
.page-tab-btn .material-symbols-outlined,
.admin-tab-btn .material-symbols-outlined {
font-size: 22px;
}
/* Action buttons in slider - icon only on mobile */
.page-tabs-slider .btn-primary,
.admin-tabs-slider .btn-primary {
padding: 0.5rem;
margin-left: 0;
}
.page-tabs-slider .btn-primary span:not(.material-symbols-outlined),
.admin-tabs-slider .btn-primary span:not(.material-symbols-outlined) {
display: none;
}
.page-content {
padding: var(--page-padding-y-mobile) var(--page-padding-x-mobile);
}
.admin-tab-content {
padding: var(--page-padding-y-mobile) var(--page-padding-x-mobile);
}
}
/* Small mobile - further reduce spacing */
@media (max-width: 480px) {
.page-tabs-container,
.admin-tabs-container {
padding: 0.75rem;
}
.page-content {
padding: 0.75rem;
}
.admin-tab-content {
padding: 0.75rem;
}
}
/* ========== TAB CONTENT ANIMATIONS ========== */
/* Prevent layout shift when switching tabs */
.admin-tab-content {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 0.25s ease;
}
.admin-tab-content>div {
opacity: 0;
animation: tabFadeIn 0.25s ease forwards;
min-height: 0;
}
@keyframes tabFadeIn {
0% {
opacity: 0;
transform: scale(0.98);
}
100% {
opacity: 1;
transform: none;
}
}
/* ========== UTILITY COMPONENTS ========== */
/* Tab Content Placeholder (for "coming soon" pages) */
.tab-content-placeholder {
text-align: center;
padding: 4rem 2rem;
color: var(--color-text-secondary);
}
.tab-content-placeholder .placeholder-icon {
margin-bottom: 1.5rem;
}
.tab-content-placeholder .placeholder-icon .material-symbols-outlined {
font-size: 80px;
color: var(--color-accent);
opacity: 0.5;
}
.tab-content-placeholder h3 {
margin: 0 0 0.5rem 0;
color: var(--color-text-primary);
font-size: 1.5rem;
}
.tab-content-placeholder p {
margin: 0;
font-size: 1rem;
}

View File

@@ -0,0 +1,189 @@
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-main);
padding: 1rem;
}
.login-card {
background: var(--color-bg-card);
padding: 2rem;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
width: 100%;
max-width: 450px;
border: 1px solid var(--color-border);
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.login-card {
padding: 1.5rem;
}
.login-card h1 {
font-size: 1.75rem;
}
.login-card h2 {
font-size: 1.25rem;
}
}
.login-header {
text-align: center;
margin-bottom: 1.5rem;
}
.login-card h1 {
color: var(--color-accent);
margin: 0 0 0.25rem 0;
font-size: 2rem;
}
.login-tagline {
margin: 0 0 0.75rem 0;
font-size: 0.9rem;
color: var(--color-text-secondary);
}
.btn-theme,
.btn-language {
padding: 0.5rem 0.75rem;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-accent);
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
transition: var(--transition);
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-theme .material-symbols-outlined,
.btn-language .material-symbols-outlined {
font-size: var(--icon-sm);
}
.btn-theme:hover,
.btn-language:hover {
background: var(--color-bg-card);
border-color: var(--color-accent);
transform: translateY(-1px);
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: var(--color-text-primary);
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 1rem;
background: var(--color-bg-card);
color: var(--color-text-primary);
transition: border-color 0.3s;
}
.form-group input:focus {
outline: none;
border-color: var(--color-accent);
}
.btn-primary {
width: 100%;
padding: 0.75rem;
background: var(--color-accent);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.3s;
display: flex;
align-items: center;
justify-content: center;
}
.btn-primary:hover {
background: var(--color-accent-hover);
}
.login-footer {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--color-border);
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.btn-link {
background: none;
border: none;
color: var(--color-accent);
cursor: pointer;
text-decoration: underline;
font-size: 0.95rem;
}
.btn-link:hover {
color: var(--color-accent-hover);
}
.footer-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.btn-footer-action {
padding: 0.5rem 0.75rem;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-accent);
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
transition: var(--transition);
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-footer-action .material-symbols-outlined {
font-size: var(--icon-sm);
}
.btn-footer-action:hover {
background: var(--color-bg-card);
border-color: var(--color-accent);
transform: translateY(-1px);
}
.error-message {
background: rgba(245, 101, 101, 0.1);
color: var(--color-error);
padding: 0.75rem;
border-radius: var(--radius-sm);
margin-bottom: 1rem;
text-align: center;
border: 1px solid var(--color-error);
}

View File

@@ -0,0 +1,51 @@
.mobile-header {
display: none;
}
@media (max-width: 768px) {
.mobile-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--color-bg-card);
border-bottom: 1px solid var(--color-border);
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 998;
height: 56px;
/* Slightly reduced height */
}
.btn-hamburger {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
color: var(--color-text-primary);
cursor: pointer;
padding: 0.5rem;
transition: var(--transition);
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
}
.btn-hamburger:hover {
background: var(--color-bg-hover);
}
.btn-hamburger .material-symbols-outlined {
font-size: var(--icon-lg);
}
.mobile-title {
font-size: 1.15rem;
font-weight: 700;
color: var(--color-text-primary);
margin: 0;
}
}

View File

@@ -0,0 +1,123 @@
.settings-root .settings-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 2rem;
box-shadow: var(--shadow-md);
}
.settings-root .settings-section {
margin-bottom: 2rem;
}
.settings-root .settings-section:last-child {
margin-bottom: 0;
}
.settings-root h2 {
font-size: 1.25rem;
margin-bottom: 1.5rem;
color: var(--color-text-primary);
border-bottom: 2px solid var(--color-border);
padding-bottom: 0.5rem;
}
.settings-root .setting-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 0;
border-bottom: 1px solid var(--color-border);
}
.settings-root .setting-item:last-child {
border-bottom: none;
}
.settings-root .setting-info {
flex: 1;
margin-right: 2rem;
}
.settings-root .setting-info h3 {
font-size: 1rem;
font-weight: 600;
margin: 0 0 0.5rem 0;
color: var(--color-text-primary);
}
.settings-root .setting-info p {
font-size: 0.875rem;
margin: 0;
color: var(--color-text-secondary);
line-height: 1.5;
}
/* Toggle Switch */
.toggle-switch {
position: relative;
display: inline-block;
width: 52px;
height: 28px;
flex-shrink: 0;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--color-border);
transition: 0.3s ease;
border-radius: 28px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 4px;
bottom: 4px;
background-color: white;
transition: 0.3s ease;
border-radius: 50%;
}
input:checked+.toggle-slider {
background-color: var(--color-accent);
}
input:focus+.toggle-slider {
box-shadow: 0 0 0 2px var(--color-accent-hover);
}
input:checked+.toggle-slider:before {
transform: translateX(24px);
}
input:disabled+.toggle-slider {
opacity: 0.5;
cursor: not-allowed;
}
/* Mobile responsive */
@media (max-width: 768px) {
.settings-root .setting-item {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.settings-root .setting-info {
margin-right: 0;
}
}

View File

@@ -0,0 +1,175 @@
/* Settings Page for all users */
.settings-page-root {
max-width: 100%;
}
.settings-page-root .page-header h1 {
margin: 0;
}
.settings-page-root .page-subtitle {
margin: 0.5rem 0 0;
color: var(--color-text-secondary);
font-size: 0.95rem;
}
.settings-page-root .page-content {
max-width: 800px;
}
/* Settings Sections */
.settings-section-modern {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 2rem;
box-shadow: var(--shadow-sm);
margin-bottom: 1.5rem;
}
.settings-section-modern:last-child {
margin-bottom: 0;
}
/* Section title uses standard .section-title from Layout.css */
.setting-item-modern {
display: flex;
justify-content: space-between;
align-items: center;
gap: 2rem;
padding: 1.5rem 0;
border-bottom: 1px solid var(--color-border);
}
.setting-item-modern:last-child {
border-bottom: none;
padding-bottom: 0;
}
.setting-item-modern:first-child {
padding-top: 0;
}
.setting-info-modern {
display: flex;
gap: 1rem;
align-items: center;
}
.setting-info-modern h4 {
margin: 0 0 0.25rem;
font-size: 1rem;
font-weight: 600;
color: var(--color-text-primary);
}
.setting-info-modern p {
margin: 0;
font-size: 0.9rem;
color: var(--color-text-secondary);
line-height: 1.5;
}
.setting-icon-modern {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
background: var(--color-bg-elevated);
border-radius: var(--radius-md);
color: var(--color-accent);
flex-shrink: 0;
}
.setting-icon-modern .material-symbols-outlined {
font-size: 24px;
}
.select-modern {
padding: 0.5rem 2rem 0.5rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-elevated);
color: var(--color-text-primary);
font-size: 0.9rem;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='24' viewBox='0 0 24 24' width='24' fill='%236b7280'%3E%3Cpath d='M0 0h24v24H0z' fill='none'/%3E%3Cpath d='M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
min-width: 150px;
}
.select-modern:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px rgba(var(--color-accent-rgb), 0.2);
}
/* Modern Toggle */
.toggle-modern {
position: relative;
display: inline-block;
width: 52px;
height: 28px;
flex-shrink: 0;
}
.toggle-modern input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider-modern {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--color-border);
transition: 0.3s ease;
border-radius: 28px;
}
.toggle-slider-modern:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 4px;
bottom: 4px;
background-color: white;
transition: 0.3s ease;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.toggle-modern input:checked+.toggle-slider-modern {
background-color: var(--color-accent);
}
.toggle-modern input:focus+.toggle-slider-modern {
box-shadow: 0 0 0 3px rgba(var(--color-accent-rgb), 0.2);
}
.toggle-modern input:checked+.toggle-slider-modern:before {
transform: translateX(24px);
}
.toggle-modern input:disabled+.toggle-slider-modern {
opacity: 0.5;
cursor: not-allowed;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.setting-item-modern {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,686 @@
/* Users Page Styles */
.users-root {
position: relative;
}
.users-root .page-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.users-root .page-subtitle {
margin: var(--space-1) 0 0;
color: var(--color-text-secondary);
}
.users-root .users-page {
width: 100%;
}
.users-root .page-header-row .btn-primary {
width: auto;
min-width: 0;
align-self: center;
height: 42px;
padding: 0.55rem 1rem;
font-size: 0.95rem;
}
/* Toolbar */
.users-root .users-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--toolbar-gap);
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.users-root .input-group {
display: flex;
align-items: center;
gap: var(--element-gap);
padding: var(--space-3) var(--space-4);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
min-width: 260px;
}
.users-root .input-group input {
border: none;
outline: none;
background: transparent;
color: var(--color-text-primary);
width: 100%;
}
.users-root .users-badges {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
gap: var(--element-gap) !important;
}
/* Table */
.users-root .users-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.users-root .users-table {
width: 100%;
border-collapse: collapse;
border-spacing: 0;
table-layout: auto;
}
.users-root .users-table th,
.users-root .users-table td {
padding: var(--table-cell-padding);
text-align: left;
vertical-align: middle;
}
.users-root .users-table tbody tr:not(:last-child) {
border-bottom: 1px solid var(--color-border);
}
.users-root .users-table th {
font-weight: 600;
font-size: 0.85rem;
color: var(--color-text-secondary);
background: var(--color-bg-elevated);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.users-root .users-table tbody tr:hover {
background: var(--color-bg-elevated);
}
/* User Cell */
.users-root .user-cell {
display: flex;
align-items: center;
gap: var(--element-gap-lg);
}
.users-root .user-avatar {
width: 38px;
height: 38px;
border-radius: 50%;
background: var(--color-accent);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
.users-root .user-meta {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.users-root .user-name {
font-weight: 600;
color: var(--color-text-primary);
}
.users-root .user-email,
.users-root .user-id {
color: var(--color-text-secondary);
font-size: 0.9rem;
}
/* Actions */
.users-root .users-actions {
display: flex;
gap: var(--element-gap);
align-items: center;
justify-content: flex-start;
}
/* Buttons */
.users-root .btn-primary {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.55rem 0.9rem;
background: var(--color-accent);
color: white;
border: none;
border-radius: var(--radius-md);
font-weight: 600;
cursor: pointer;
transition: var(--transition);
}
.users-root .btn-primary:hover {
background: var(--color-accent-hover);
}
.users-root .btn-ghost {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 0.85rem;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: var(--color-bg-main);
color: var(--color-text-primary);
cursor: pointer;
transition: var(--transition);
}
.users-root .btn-ghost:hover {
background: var(--color-bg-card);
}
.users-root .btn-ghost.danger {
border-color: var(--color-error);
color: var(--color-error);
}
.users-root .btn-ghost:disabled,
.users-root .btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Alerts */
.users-root .users-alert {
background: rgba(220, 38, 38, 0.12);
border: 1px solid var(--color-error);
color: var(--color-error);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
margin-bottom: var(--space-4);
}
.users-root .users-alert.in-modal {
margin-top: var(--space-2);
}
.users-root .users-empty {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-8);
text-align: center;
color: var(--color-text-secondary);
}
/* Badges */
.users-root .badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--badge-padding);
border-radius: var(--radius-md);
font-weight: 600;
font-size: var(--badge-font-size);
}
.users-root .badge-success {
background: rgba(16, 185, 129, 0.15);
color: #0f9c75;
}
.users-root .badge-muted {
background: rgba(220, 38, 38, 0.15);
color: var(--color-error);
}
.users-root .badge-accent {
background: rgba(129, 140, 248, 0.16);
color: var(--color-accent);
}
.users-root .badge-neutral {
background: rgba(74, 85, 104, 0.12);
color: var(--color-text-secondary);
}
/* Modal Backdrop */
.users-modal-backdrop {
position: fixed !important;
inset: 0 !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
background: rgba(0, 0, 0, 0.5);
display: flex !important;
align-items: center !important;
justify-content: center !important;
z-index: var(--z-modal-backdrop);
padding: var(--space-4);
backdrop-filter: blur(2px);
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Modal */
/* Modal */
.users-modal {
background: var(--color-bg-card);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-2xl);
width: calc(100% - var(--space-8));
max-width: 520px;
padding: var(--space-6);
position: relative;
z-index: var(--z-modal);
animation: modalSlideIn 0.2s ease-out;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.users-modal .modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
position: relative;
padding-right: var(--space-10);
}
.users-modal .modal-header h2 {
margin: 0;
}
.users-modal .btn-icon {
background: transparent;
border: none;
color: var(--color-text-primary);
cursor: pointer;
padding: 0.35rem;
}
.users-modal .btn-close {
position: absolute;
top: 0.35rem;
right: 0.35rem;
width: 38px;
height: 38px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--color-bg-main);
transition: var(--transition);
}
.users-modal .btn-close:hover {
background: var(--color-bg-card);
}
/* Form */
.users-modal .users-form {
margin-top: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--element-gap-lg);
}
.users-modal .form-row {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.users-modal .form-row input {
padding: 0.7rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-main);
color: var(--color-text-primary);
}
.users-modal .form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--element-gap-lg);
}
.users-modal .checkbox-row {
display: flex;
align-items: center;
gap: var(--element-gap-lg);
font-weight: 600;
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-main);
}
.users-modal .checkbox-row input {
width: 18px;
height: 18px;
}
.users-modal .helper-text {
color: var(--color-text-secondary);
font-weight: 400;
font-size: 0.9rem;
}
.users-modal .modal-actions {
display: flex;
justify-content: flex-end;
gap: var(--element-gap);
}
/* Mobile Card View */
.desktop-only {
display: block;
}
.mobile-only {
display: none;
}
.users-mobile-list {
display: none;
flex-direction: column;
}
.users-mobile-list .btn-ghost {
color: var(--color-text-primary);
}
.users-mobile-list .btn-ghost.danger {
color: var(--color-error);
border-color: var(--color-error);
}
.user-card-mobile {
padding: var(--card-padding);
border-bottom: 1px solid var(--color-border);
}
.user-card-mobile:last-child {
border-bottom: none;
}
.user-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-4);
}
.user-card-header .user-cell {
flex: 1;
min-width: 0;
}
.user-card-body {
display: flex;
flex-direction: column;
gap: var(--element-gap);
margin-bottom: var(--space-4);
padding: var(--space-3);
background: var(--color-bg-main);
border-radius: var(--radius-md);
}
.user-info-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.9rem;
}
.user-info-row .label {
color: var(--color-text-secondary);
}
.user-info-row .value {
color: var(--color-text-primary);
font-weight: 500;
}
.user-card-actions {
display: flex;
justify-content: flex-end;
gap: var(--element-gap);
}
/* Toolbar Right */
.users-root .toolbar-right {
display: flex;
align-items: center;
gap: var(--element-gap-lg);
flex-wrap: wrap;
}
/* Sortable Headers */
.users-root .users-table th.sortable {
cursor: pointer;
user-select: none;
transition: var(--transition);
position: relative;
white-space: nowrap;
}
.users-root .users-table th.sortable:hover {
background: var(--color-bg-card);
color: var(--color-accent);
}
.users-root .users-table th.sortable::after {
content: '↕';
font-size: 0.7rem;
margin-left: 0.35rem;
opacity: 0.3;
vertical-align: middle;
}
.users-root .users-table th.sortable:hover::after {
opacity: 0.6;
}
.users-root .sort-icon {
font-size: 14px !important;
margin-left: 0.3rem;
color: var(--color-accent);
vertical-align: -2px;
font-variation-settings: 'wght' 500;
}
/* Hide default arrow when sort icon is shown */
.users-root .users-table th.sortable:has(.sort-icon)::after {
display: none;
}
/* Superuser Styling */
.users-root .superuser-row {
background: rgba(129, 140, 248, 0.04);
}
.users-root .superuser-row:hover {
background: rgba(129, 140, 248, 0.08) !important;
}
.users-root .user-avatar.superuser {
background: linear-gradient(135deg, var(--color-accent), #a78bfa);
box-shadow: 0 2px 8px rgba(129, 140, 248, 0.3);
}
/* Users Divider */
.users-root .users-divider {
height: 1px;
}
.users-root .users-divider td {
padding: 0 !important;
border-bottom: none !important;
background: var(--color-border);
height: 1px;
}
.users-root .divider-line {
display: none;
}
/* Mobile User Cards */
.users-root .mobile-users-list {
display: none;
flex-direction: column;
gap: 0;
}
.users-root .mobile-user-card {
background: var(--color-bg-card);
border-radius: var(--radius-lg);
padding: var(--card-padding);
border: 1px solid var(--color-border);
}
.users-root .mobile-user-card.superuser-card {
background: rgba(129, 140, 248, 0.04);
}
.users-root .mobile-user-header {
display: flex;
align-items: center;
gap: var(--element-gap-lg);
margin-bottom: var(--element-gap-lg);
}
.users-root .mobile-user-info {
flex: 1;
min-width: 0;
}
.users-root .mobile-user-name {
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.users-root .mobile-user-email {
font-size: 0.85rem;
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.users-root .mobile-user-actions {
display: flex;
gap: var(--element-gap);
}
.users-root .mobile-user-footer {
display: flex;
flex-direction: column;
gap: var(--element-gap);
padding-top: var(--element-gap-lg);
border-top: 1px solid var(--color-border);
}
.users-root .mobile-badges-row {
display: flex;
gap: var(--element-gap);
}
.users-root .mobile-dates {
display: flex;
flex-wrap: wrap;
gap: var(--space-4);
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.users-root .mobile-date-item {
display: flex;
align-items: center;
gap: var(--space-1);
}
.users-root .mobile-date-item .material-symbols-outlined {
font-size: 14px;
}
.users-root .mobile-users-divider {
display: none;
}
@media (max-width: 768px) {
.desktop-only {
display: none !important;
}
.mobile-only {
display: block !important;
}
.users-root .users-card {
display: none;
}
.users-root .mobile-users-list {
display: flex;
}
.users-root .users-toolbar {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.users-root .input-group {
width: 100%;
min-width: 0;
}
.users-root .users-badges {
display: flex;
flex-direction: row;
justify-content: flex-start;
}
.users-root .toolbar-right {
width: 100%;
justify-content: space-between;
}
}

View File

@@ -0,0 +1,133 @@
# Theme System
Sistema modulare per la gestione del tema dell'applicazione Service Name.
## Struttura
```
theme/
├── colors.css # Palette colori (light/dark)
├── dimensions.css # Spacing, sizing, border radius, z-index
├── typography.css # Font sizes, weights, line heights
├── effects.css # Shadows, transitions, animations
├── index.css # Importa tutti i moduli
└── README.md # Questa documentazione
```
## Come Usare
### Modificare i Colori del Tema
Apri `colors.css` e modifica le variabili CSS:
```css
:root {
--color-primary: #2d3748; /* Colore primario light theme */
--color-accent: #4c51bf; /* Colore accent */
/* ... */
}
[data-theme='dark'] {
--color-primary: #d1d5db; /* Colore primario dark theme */
/* ... */
}
```
### Aggiungere un Nuovo Tema
1. Aggiungi un nuovo selettore in `colors.css`:
```css
[data-theme='ocean'] {
--color-primary: #006994;
--color-accent: #0ea5e9;
--color-bg-main: #f0f9ff;
/* ... definisci tutti i colori ... */
}
```
2. Modifica il context del tema per includere il nuovo tema nell'elenco
### Modificare le Dimensioni
Apri `dimensions.css`:
```css
:root {
--space-4: 1rem; /* Spacing base */
--sidebar-width: 260px; /* Larghezza sidebar */
--height-nav-item: 40px; /* Altezza nav items */
/* ... */
}
```
### Modificare la Tipografia
Apri `typography.css`:
```css
:root {
--text-base: 0.875rem; /* Dimensione testo base */
--weight-medium: 500; /* Peso font medio */
/* ... */
}
```
### Modificare Effetti e Transizioni
Apri `effects.css`:
```css
:root {
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
--transition-slow: 0.3s ease;
/* ... */
}
```
## Convenzioni
### Naming
- Usa prefissi descrittivi: `--color-`, `--space-`, `--text-`, `--shadow-`
- Usa scala numerica per dimensioni: `-sm`, `-md`, `-lg`, `-xl`
- Usa nomi semantici per colori: `primary`, `accent`, `success`, `error`
### Valori
- Spacing basato su multipli di 4px (0.25rem)
- Colori in formato esadecimale (#rrggbb)
- Dimensioni in rem/px dove appropriato
- Transizioni sempre con easing function
## Esempi d'Uso nei Componenti
```css
/* Sidebar.css */
.sidebar {
width: var(--sidebar-width);
background: var(--color-bg-sidebar);
box-shadow: var(--shadow-lg);
}
.nav-item {
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
transition: var(--transition);
}
.nav-label {
font-size: var(--text-md);
font-weight: var(--weight-medium);
color: var(--color-text-inverse);
}
```
## Vantaggi
**Centralizzato**: Tutti i valori in un posto
**Consistente**: Stessa palette ovunque
**Manutenibile**: Facile cambiare tema
**Scalabile**: Aggiungi nuovi temi facilmente
**Type-safe**: Le variabili CSS prevengono errori
**Performance**: Cambio tema istantaneo (solo CSS)

View File

@@ -0,0 +1,67 @@
/* ==========================================
THEME COLORS
========================================== */
:root {
/* Light Theme - Primary Colors */
--color-primary: #2d3748;
--color-primary-hover: #1a202c;
--color-secondary: #4a5568;
--color-accent: #4c51bf;
--color-accent-rgb: 76, 81, 191;
--color-accent-hover: #4338ca;
/* Light Theme - Background Colors */
--color-bg-main: #f7fafc;
--color-bg-card: #ffffff;
--color-bg-elevated: #f8fafc;
--color-bg-sidebar: #2d3748;
/* Light Theme - Text Colors */
--color-text-primary: #1a202c;
--color-text-secondary: #374151;
--color-text-muted: #6b7280;
--color-text-inverse: #ffffff;
/* Light Theme - Border Colors */
--color-border: #e5e7eb;
--color-border-hover: #d1d5db;
/* Semantic Colors (same for both themes) */
--color-success: #059669;
--color-error: #dc2626;
--color-warning: #d97706;
--color-info: #0284c7;
}
[data-theme='dark'] {
/* Dark Theme - Primary Colors */
--color-primary: #d1d5db;
--color-primary-hover: #f3f4f6;
--color-secondary: #9ca3af;
--color-accent: #3d3f9e;
--color-accent-rgb: 61, 63, 158;
--color-accent-hover: #4e50b2;
/* Dark Theme - Background Colors */
--color-bg-main: #0f172a;
--color-bg-card: #1e293b;
--color-bg-elevated: #334155;
--color-bg-sidebar: #0c1222;
/* Dark Theme - Text Colors */
--color-text-primary: #f1f5f9;
--color-text-secondary: #cbd5e1;
--color-text-muted: #94a3b8;
--color-text-inverse: #ffffff;
/* Dark Theme - Border Colors */
--color-border: #334155;
--color-border-hover: #475569;
/* Semantic Colors */
--color-success: #10b981;
--color-error: #ef4444;
--color-warning: #f59e0b;
--color-info: #06b6d4;
}

View File

@@ -0,0 +1,154 @@
/* ==========================================
DIMENSIONS & SPACING
========================================== */
:root {
/* Spacing Scale (based on 0.25rem = 4px) */
--space-0: 0;
--space-1: 0.25rem;
/* 4px */
--space-2: 0.5rem;
/* 8px */
--space-3: 0.75rem;
/* 12px */
--space-4: 1rem;
/* 16px */
--space-5: 1.25rem;
/* 20px */
--space-6: 1.5rem;
/* 24px */
--space-8: 2rem;
/* 32px */
--space-10: 2.5rem;
/* 40px */
--space-12: 3rem;
/* 48px */
--space-16: 4rem;
/* 64px */
--space-20: 5rem;
/* 80px */
/* Border Radius */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-full: 9999px;
/* Component Heights */
--height-button: 36px;
--height-input: 40px;
--height-nav-item: 40px;
--height-header: 70px;
/* Sidebar Dimensions */
/* Sidebar is positioned at left: 1rem (16px) with these widths */
/* margin-left = left offset (16px) + sidebar width */
--sidebar-width: 276px;
/* 16px offset + 260px width */
--sidebar-width-collapsed: 96px;
/* 16px offset + 80px width */
--sidebar-mobile-width: 280px;
/* Page Layout Spacing */
--page-padding-x: 2rem;
/* Horizontal padding for page content */
--page-padding-y: 2rem;
/* Vertical padding for page content */
--page-padding-x-tablet: 1.5rem;
--page-padding-y-tablet: 1.5rem;
--page-padding-x-mobile: 1rem;
--page-padding-y-mobile: 1rem;
--page-max-width: 1400px;
/* Maximum content width */
/* Container Widths */
--container-sm: 640px;
--container-md: 768px;
--container-lg: 1024px;
--container-xl: 1280px;
/* Z-index Scale */
--z-dropdown: 100;
--z-sticky: 200;
--z-fixed: 300;
--z-modal-backdrop: 1000;
--z-modal: 1001;
--z-popover: 1002;
--z-tooltip: 1003;
/* Icon Sizes */
--icon-xs: 16px;
--icon-sm: 18px;
--icon-md: 20px;
--icon-lg: 24px;
--icon-xl: 32px;
/* Button Sizes */
--btn-padding-sm: 0.5rem 1rem;
--btn-padding-md: 0.625rem 1.25rem;
--btn-font-size: 0.95rem;
--btn-font-size-sm: 0.875rem;
--btn-height: 36px;
--btn-height-sm: 32px;
/* Icon Button Sizes */
--btn-icon-sm: 32px;
--btn-icon-md: 36px;
--btn-icon-lg: 48px;
/* Badge Sizes */
--badge-padding: 0.25rem 0.625rem;
--badge-font-size: 0.8rem;
/* Semantic Spacing - Cards & Containers */
--card-padding: 0.875rem;
/* Reduced from 1rem */
--card-padding-sm: 0.625rem;
/* Reduced from 0.75rem */
--card-gap: 0.625rem;
/* Reduced from 0.75rem */
--card-gap-lg: 0.875rem;
/* Reduced from 1rem */
/* Semantic Spacing - Sections */
--section-gap: 1.25rem;
/* Reduced from 1.5rem */
--section-gap-sm: 0.875rem;
/* Reduced from 1rem */
/* Semantic Spacing - Elements */
--element-gap: 0.375rem;
/* Reduced from 0.5rem */
--element-gap-sm: 0.25rem;
/* Kept same */
--element-gap-lg: 0.625rem;
/* Reduced from 0.75rem */
/* Semantic Spacing - Toolbar & Header */
--toolbar-gap: 0.625rem;
/* Reduced from 0.75rem */
--toolbar-padding: 0.75rem;
/* Reduced from 1rem */
/* Semantic Spacing - Table */
--table-cell-padding: 0.625rem 0.875rem;
/* Reduced from 0.75rem 1rem */
--table-cell-padding-sm: 0.5rem 0.75rem;
/* Kept same */
/* Tab Sizes */
--tab-padding: 0.625rem 1rem;
/* Reduced from 0.75rem 1.25rem */
--tab-font-size: 0.95rem;
/* Input Sizes */
--input-padding: 0.625rem 0.875rem;
/* Reduced from 0.75rem 1rem */
--input-font-size: 0.95rem;
/* Breakpoints (for reference in media queries) */
--breakpoint-mobile: 768px;
--breakpoint-tablet: 1024px;
--breakpoint-desktop: 1280px;
}

View File

@@ -0,0 +1,46 @@
/* ==========================================
EFFECTS (Shadows, Transitions, Animations)
========================================== */
:root {
/* Box Shadows */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.15);
--shadow-2xl: 0 25px 50px rgba(0, 0, 0, 0.25);
--shadow-inner: inset 0 2px 4px rgba(0, 0, 0, 0.06);
/* Transitions */
--transition-fast: 0.15s ease;
--transition-base: 0.2s ease;
--transition-slow: 0.3s ease;
--transition-slower: 0.5s ease;
/* Transition Properties */
--transition: all 0.2s ease;
--transition-colors: color 0.2s ease, background-color 0.2s ease, border-color 0.2s ease;
--transition-transform: transform 0.2s ease;
--transition-opacity: opacity 0.2s ease;
/* Animation Durations */
--duration-fast: 150ms;
--duration-base: 200ms;
--duration-slow: 300ms;
--duration-slower: 500ms;
/* Easing Functions */
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
[data-theme='dark'] {
/* Dark Theme - Stronger Shadows */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.6);
--shadow-2xl: 0 25px 50px rgba(0, 0, 0, 0.7);
}

View File

@@ -0,0 +1,105 @@
/* ==========================================
THEME SYSTEM
Import all theme variables
========================================== */
/* Import theme modules */
@import './colors.css';
@import './dimensions.css';
@import './typography.css';
@import './effects.css';
/* Import layout system */
@import '../Layout.css';
/* Global Styles */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
font-family: var(--font-sans);
background: var(--color-bg-main);
color: var(--color-text-primary);
transition: background-color var(--transition-slow), color var(--transition-slow);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
user-select: none;
-webkit-user-select: none;
}
/* Utility for selectable text */
.selectable {
user-select: text;
-webkit-user-select: text;
cursor: text;
}
#root {
min-height: 100vh;
width: 100%;
}
/* Links */
a {
font-weight: var(--weight-medium);
color: var(--color-accent);
text-decoration: inherit;
transition: var(--transition-colors);
}
a:hover {
color: var(--color-accent-hover);
}
/* Buttons */
button {
border-radius: var(--radius-lg);
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: var(--weight-medium);
font-family: inherit;
cursor: pointer;
transition: var(--transition);
}
button:focus,
button:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
/* Loading state */
.loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
font-size: var(--text-3xl);
color: var(--color-accent);
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: var(--color-bg-main);
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: var(--radius-md);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-border-hover);
}

Some files were not shown because too many files have changed in this diff Show More