Initial commit
This commit is contained in:
28
.env.example
Normal file
28
.env.example
Normal 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
91
.gitignore
vendored
Normal 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
65
Dockerfile
Normal 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
37
Makefile
Normal 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
103
README.md
Normal 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
32
backend/Dockerfile
Normal 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
88
backend/alembic.ini
Normal 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
88
backend/alembic/env.py
Normal 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()
|
||||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal 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"}
|
||||||
45
backend/alembic/versions/001_initial_schema.py
Normal file
45
backend/alembic/versions/001_initial_schema.py
Normal 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')
|
||||||
41
backend/alembic/versions/002_add_settings_table.py
Normal file
41
backend/alembic/versions/002_add_settings_table.py
Normal 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')
|
||||||
28
backend/alembic/versions/004_add_permissions_to_users.py
Normal file
28
backend/alembic/versions/004_add_permissions_to_users.py
Normal 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')
|
||||||
@@ -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
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
5
backend/app/api/v1/__init__.py
Normal file
5
backend/app/api/v1/__init__.py
Normal 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
155
backend/app/api/v1/auth.py
Normal 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}
|
||||||
34
backend/app/api/v1/health.py
Normal file
34
backend/app/api/v1/health.py
Normal 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
|
||||||
|
}
|
||||||
15
backend/app/api/v1/router.py
Normal file
15
backend/app/api/v1/router.py
Normal 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"])
|
||||||
184
backend/app/api/v1/settings.py
Normal file
184
backend/app/api/v1/settings.py
Normal 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
160
backend/app/api/v1/users.py
Normal 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
48
backend/app/config.py
Normal 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()
|
||||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
21
backend/app/core/exceptions.py
Normal file
21
backend/app/core/exceptions.py
Normal 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
|
||||||
94
backend/app/core/security.py
Normal file
94
backend/app/core/security.py
Normal 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
|
||||||
601
backend/app/core/settings_registry.py
Normal file
601
backend/app/core/settings_registry.py
Normal 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}")
|
||||||
6
backend/app/crud/__init__.py
Normal file
6
backend/app/crud/__init__.py
Normal 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
81
backend/app/crud/base.py
Normal 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)
|
||||||
48
backend/app/crud/settings.py
Normal file
48
backend/app/crud/settings.py
Normal 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
78
backend/app/crud/user.py
Normal 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)
|
||||||
0
backend/app/db/__init__.py
Normal file
0
backend/app/db/__init__.py
Normal file
8
backend/app/db/base.py
Normal file
8
backend/app/db/base.py
Normal 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
26
backend/app/db/session.py
Normal 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
|
||||||
|
)
|
||||||
82
backend/app/dependencies.py
Normal file
82
backend/app/dependencies.py
Normal 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
195
backend/app/main.py
Normal 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()
|
||||||
|
)
|
||||||
6
backend/app/models/__init__.py
Normal file
6
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Models package."""
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.settings import Settings
|
||||||
|
|
||||||
|
__all__ = ["User", "Settings"]
|
||||||
25
backend/app/models/settings.py
Normal file
25
backend/app/models/settings.py
Normal 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
|
||||||
53
backend/app/models/user.py
Normal file
53
backend/app/models/user.py
Normal 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}')>"
|
||||||
18
backend/app/schemas/__init__.py
Normal file
18
backend/app/schemas/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
41
backend/app/schemas/auth.py
Normal file
41
backend/app/schemas/auth.py
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
40
backend/app/schemas/common.py
Normal file
40
backend/app/schemas/common.py
Normal 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
|
||||||
35
backend/app/schemas/settings.py
Normal file
35
backend/app/schemas/settings.py
Normal 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
|
||||||
|
)
|
||||||
61
backend/app/schemas/user.py
Normal file
61
backend/app/schemas/user.py
Normal 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
41
backend/requirements.txt
Normal 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
18
docker-compose.yml
Normal 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
73
frontend/README.md
Normal 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
23
frontend/eslint.config.js
Normal 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
18
frontend/index.html
Normal 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
34
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
827
frontend/public/logo_black.svg
Normal file
827
frontend/public/logo_black.svg
Normal 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 |
7
frontend/public/logo_white.svg
Normal file
7
frontend/public/logo_white.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 88 KiB |
19
frontend/src/App.css
Normal file
19
frontend/src/App.css
Normal 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
93
frontend/src/App.tsx
Normal 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;
|
||||||
95
frontend/src/api/client.ts
Normal file
95
frontend/src/api/client.ts
Normal 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;
|
||||||
11
frontend/src/components/MainLayout.tsx
Normal file
11
frontend/src/components/MainLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
frontend/src/components/MobileHeader.tsx
Normal file
19
frontend/src/components/MobileHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
279
frontend/src/components/Sidebar.tsx
Normal file
279
frontend/src/components/Sidebar.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
frontend/src/components/UserMenu.tsx
Normal file
159
frontend/src/components/UserMenu.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
frontend/src/components/admin/Feature1Tab.tsx
Normal file
15
frontend/src/components/admin/Feature1Tab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
358
frontend/src/components/admin/GeneralTab.tsx
Normal file
358
frontend/src/components/admin/GeneralTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
frontend/src/components/admin/SettingsTab.tsx
Normal file
98
frontend/src/components/admin/SettingsTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
819
frontend/src/components/admin/UsersTab.tsx
Normal file
819
frontend/src/components/admin/UsersTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
frontend/src/contexts/AuthContext.tsx
Normal file
80
frontend/src/contexts/AuthContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
56
frontend/src/contexts/LanguageContext.tsx
Normal file
56
frontend/src/contexts/LanguageContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
203
frontend/src/contexts/ModulesContext.tsx
Normal file
203
frontend/src/contexts/ModulesContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
127
frontend/src/contexts/SidebarContext.tsx
Normal file
127
frontend/src/contexts/SidebarContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
41
frontend/src/contexts/SiteConfigContext.tsx
Normal file
41
frontend/src/contexts/SiteConfigContext.tsx
Normal 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);
|
||||||
|
}
|
||||||
768
frontend/src/contexts/ThemeContext.tsx
Normal file
768
frontend/src/contexts/ThemeContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
119
frontend/src/contexts/ViewModeContext.tsx
Normal file
119
frontend/src/contexts/ViewModeContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
35
frontend/src/hooks/useDocumentTitle.ts
Normal file
35
frontend/src/hooks/useDocumentTitle.ts
Normal 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
71
frontend/src/index.css
Normal 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';
|
||||||
|
}
|
||||||
314
frontend/src/locales/en.json
Normal file
314
frontend/src/locales/en.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
314
frontend/src/locales/it.json
Normal file
314
frontend/src/locales/it.json
Normal 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
18
frontend/src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
110
frontend/src/modules/index.ts
Normal file
110
frontend/src/modules/index.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
56
frontend/src/pages/AdminPanel.tsx
Normal file
56
frontend/src/pages/AdminPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
frontend/src/pages/Dashboard.tsx
Normal file
72
frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
frontend/src/pages/Feature1.tsx
Normal file
35
frontend/src/pages/Feature1.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
frontend/src/pages/Login.tsx
Normal file
161
frontend/src/pages/Login.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
frontend/src/pages/Settings.tsx
Normal file
53
frontend/src/pages/Settings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
404
frontend/src/pages/Users.tsx
Normal file
404
frontend/src/pages/Users.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
frontend/src/pages/admin/Features.tsx
Normal file
217
frontend/src/pages/admin/Features.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
frontend/src/pages/admin/Settings.tsx
Normal file
136
frontend/src/pages/admin/Settings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
frontend/src/pages/admin/Sources.tsx
Normal file
40
frontend/src/pages/admin/Sources.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
808
frontend/src/pages/admin/ThemeSettings.tsx
Normal file
808
frontend/src/pages/admin/ThemeSettings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
2260
frontend/src/styles/AdminPanel.css
Normal file
2260
frontend/src/styles/AdminPanel.css
Normal file
File diff suppressed because it is too large
Load Diff
84
frontend/src/styles/Dashboard.css
Normal file
84
frontend/src/styles/Dashboard.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
431
frontend/src/styles/Layout.css
Normal file
431
frontend/src/styles/Layout.css
Normal 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;
|
||||||
|
}
|
||||||
189
frontend/src/styles/Login.css
Normal file
189
frontend/src/styles/Login.css
Normal 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);
|
||||||
|
}
|
||||||
51
frontend/src/styles/MobileHeader.css
Normal file
51
frontend/src/styles/MobileHeader.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
123
frontend/src/styles/Settings.css
Normal file
123
frontend/src/styles/Settings.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
175
frontend/src/styles/SettingsPage.css
Normal file
175
frontend/src/styles/SettingsPage.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1021
frontend/src/styles/Sidebar.css
Normal file
1021
frontend/src/styles/Sidebar.css
Normal file
File diff suppressed because it is too large
Load Diff
1375
frontend/src/styles/ThemeSettings.css
Normal file
1375
frontend/src/styles/ThemeSettings.css
Normal file
File diff suppressed because it is too large
Load Diff
686
frontend/src/styles/Users.css
Normal file
686
frontend/src/styles/Users.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
133
frontend/src/styles/theme/README.md
Normal file
133
frontend/src/styles/theme/README.md
Normal 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)
|
||||||
67
frontend/src/styles/theme/colors.css
Normal file
67
frontend/src/styles/theme/colors.css
Normal 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;
|
||||||
|
}
|
||||||
154
frontend/src/styles/theme/dimensions.css
Normal file
154
frontend/src/styles/theme/dimensions.css
Normal 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;
|
||||||
|
}
|
||||||
46
frontend/src/styles/theme/effects.css
Normal file
46
frontend/src/styles/theme/effects.css
Normal 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);
|
||||||
|
}
|
||||||
105
frontend/src/styles/theme/index.css
Normal file
105
frontend/src/styles/theme/index.css
Normal 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
Reference in New Issue
Block a user