From 453ce104948138414bd8060e434643fe0db50cfd Mon Sep 17 00:00:00 2001 From: matteoscrugli Date: Thu, 4 Dec 2025 22:24:47 +0100 Subject: [PATCH] Initial commit --- .env.example | 28 + .gitignore | 91 + Dockerfile | 65 + Makefile | 37 + README.md | 103 + backend/Dockerfile | 32 + backend/alembic.ini | 88 + backend/alembic/env.py | 88 + backend/alembic/script.py.mako | 26 + .../alembic/versions/001_initial_schema.py | 45 + .../versions/002_add_settings_table.py | 41 + .../versions/004_add_permissions_to_users.py | 28 + .../3382f3992222_add_last_login_to_users.py | 28 + backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/v1/__init__.py | 5 + backend/app/api/v1/auth.py | 155 ++ backend/app/api/v1/health.py | 34 + backend/app/api/v1/router.py | 15 + backend/app/api/v1/settings.py | 184 ++ backend/app/api/v1/users.py | 160 ++ backend/app/config.py | 48 + backend/app/core/__init__.py | 0 backend/app/core/exceptions.py | 21 + backend/app/core/security.py | 94 + backend/app/core/settings_registry.py | 601 +++++ backend/app/crud/__init__.py | 6 + backend/app/crud/base.py | 81 + backend/app/crud/settings.py | 48 + backend/app/crud/user.py | 78 + backend/app/db/__init__.py | 0 backend/app/db/base.py | 8 + backend/app/db/session.py | 26 + backend/app/dependencies.py | 82 + backend/app/main.py | 195 ++ backend/app/models/__init__.py | 6 + backend/app/models/settings.py | 25 + backend/app/models/user.py | 53 + backend/app/schemas/__init__.py | 18 + backend/app/schemas/auth.py | 41 + backend/app/schemas/common.py | 40 + backend/app/schemas/settings.py | 35 + backend/app/schemas/user.py | 61 + backend/requirements.txt | 41 + docker-compose.yml | 18 + frontend/README.md | 73 + frontend/eslint.config.js | 23 + frontend/index.html | 18 + frontend/package.json | 34 + frontend/public/logo_black.svg | 827 ++++++ frontend/public/logo_white.svg | 7 + frontend/src/App.css | 19 + frontend/src/App.tsx | 93 + frontend/src/api/client.ts | 95 + frontend/src/components/MainLayout.tsx | 11 + frontend/src/components/MobileHeader.tsx | 19 + frontend/src/components/Sidebar.tsx | 279 ++ frontend/src/components/UserMenu.tsx | 159 ++ frontend/src/components/admin/Feature1Tab.tsx | 15 + frontend/src/components/admin/GeneralTab.tsx | 358 +++ frontend/src/components/admin/SettingsTab.tsx | 98 + frontend/src/components/admin/UsersTab.tsx | 819 ++++++ frontend/src/contexts/AuthContext.tsx | 80 + frontend/src/contexts/LanguageContext.tsx | 56 + frontend/src/contexts/ModulesContext.tsx | 203 ++ frontend/src/contexts/SidebarContext.tsx | 127 + frontend/src/contexts/SiteConfigContext.tsx | 41 + frontend/src/contexts/ThemeContext.tsx | 768 ++++++ frontend/src/contexts/ViewModeContext.tsx | 119 + frontend/src/hooks/useDocumentTitle.ts | 35 + frontend/src/index.css | 71 + frontend/src/locales/en.json | 314 +++ frontend/src/locales/it.json | 314 +++ frontend/src/main.tsx | 18 + frontend/src/modules/index.ts | 110 + frontend/src/pages/AdminPanel.tsx | 56 + frontend/src/pages/Dashboard.tsx | 72 + frontend/src/pages/Feature1.tsx | 35 + frontend/src/pages/Login.tsx | 161 ++ frontend/src/pages/Settings.tsx | 53 + frontend/src/pages/Users.tsx | 404 +++ frontend/src/pages/admin/Features.tsx | 217 ++ frontend/src/pages/admin/Settings.tsx | 136 + frontend/src/pages/admin/Sources.tsx | 40 + frontend/src/pages/admin/ThemeSettings.tsx | 808 ++++++ frontend/src/styles/AdminPanel.css | 2260 +++++++++++++++++ frontend/src/styles/Dashboard.css | 84 + frontend/src/styles/Layout.css | 431 ++++ frontend/src/styles/Login.css | 189 ++ frontend/src/styles/MobileHeader.css | 51 + frontend/src/styles/Settings.css | 123 + frontend/src/styles/SettingsPage.css | 175 ++ frontend/src/styles/Sidebar.css | 1021 ++++++++ frontend/src/styles/ThemeSettings.css | 1375 ++++++++++ frontend/src/styles/Users.css | 686 +++++ frontend/src/styles/theme/README.md | 133 + frontend/src/styles/theme/colors.css | 67 + frontend/src/styles/theme/dimensions.css | 154 ++ frontend/src/styles/theme/effects.css | 46 + frontend/src/styles/theme/index.css | 105 + frontend/src/styles/theme/typography.css | 42 + frontend/src/types/index.ts | 56 + frontend/tsconfig.app.json | 28 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 26 + frontend/vite.config.ts | 22 + 106 files changed, 17145 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/001_initial_schema.py create mode 100644 backend/alembic/versions/002_add_settings_table.py create mode 100644 backend/alembic/versions/004_add_permissions_to_users.py create mode 100644 backend/alembic/versions/3382f3992222_add_last_login_to_users.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/v1/__init__.py create mode 100644 backend/app/api/v1/auth.py create mode 100644 backend/app/api/v1/health.py create mode 100644 backend/app/api/v1/router.py create mode 100644 backend/app/api/v1/settings.py create mode 100644 backend/app/api/v1/users.py create mode 100644 backend/app/config.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/exceptions.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/core/settings_registry.py create mode 100644 backend/app/crud/__init__.py create mode 100644 backend/app/crud/base.py create mode 100644 backend/app/crud/settings.py create mode 100644 backend/app/crud/user.py create mode 100644 backend/app/db/__init__.py create mode 100644 backend/app/db/base.py create mode 100644 backend/app/db/session.py create mode 100644 backend/app/dependencies.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/settings.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/app/schemas/common.py create mode 100644 backend/app/schemas/settings.py create mode 100644 backend/app/schemas/user.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml create mode 100644 frontend/README.md create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/public/logo_black.svg create mode 100644 frontend/public/logo_white.svg create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/components/MainLayout.tsx create mode 100644 frontend/src/components/MobileHeader.tsx create mode 100644 frontend/src/components/Sidebar.tsx create mode 100644 frontend/src/components/UserMenu.tsx create mode 100644 frontend/src/components/admin/Feature1Tab.tsx create mode 100644 frontend/src/components/admin/GeneralTab.tsx create mode 100644 frontend/src/components/admin/SettingsTab.tsx create mode 100644 frontend/src/components/admin/UsersTab.tsx create mode 100644 frontend/src/contexts/AuthContext.tsx create mode 100644 frontend/src/contexts/LanguageContext.tsx create mode 100644 frontend/src/contexts/ModulesContext.tsx create mode 100644 frontend/src/contexts/SidebarContext.tsx create mode 100644 frontend/src/contexts/SiteConfigContext.tsx create mode 100644 frontend/src/contexts/ThemeContext.tsx create mode 100644 frontend/src/contexts/ViewModeContext.tsx create mode 100644 frontend/src/hooks/useDocumentTitle.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/locales/en.json create mode 100644 frontend/src/locales/it.json create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/modules/index.ts create mode 100644 frontend/src/pages/AdminPanel.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/Feature1.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/Settings.tsx create mode 100644 frontend/src/pages/Users.tsx create mode 100644 frontend/src/pages/admin/Features.tsx create mode 100644 frontend/src/pages/admin/Settings.tsx create mode 100644 frontend/src/pages/admin/Sources.tsx create mode 100644 frontend/src/pages/admin/ThemeSettings.tsx create mode 100644 frontend/src/styles/AdminPanel.css create mode 100644 frontend/src/styles/Dashboard.css create mode 100644 frontend/src/styles/Layout.css create mode 100644 frontend/src/styles/Login.css create mode 100644 frontend/src/styles/MobileHeader.css create mode 100644 frontend/src/styles/Settings.css create mode 100644 frontend/src/styles/SettingsPage.css create mode 100644 frontend/src/styles/Sidebar.css create mode 100644 frontend/src/styles/ThemeSettings.css create mode 100644 frontend/src/styles/Users.css create mode 100644 frontend/src/styles/theme/README.md create mode 100644 frontend/src/styles/theme/colors.css create mode 100644 frontend/src/styles/theme/dimensions.css create mode 100644 frontend/src/styles/theme/effects.css create mode 100644 frontend/src/styles/theme/index.css create mode 100644 frontend/src/styles/theme/typography.css create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4f81b36 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c5d27b --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..740c970 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..78322ad --- /dev/null +++ b/Makefile @@ -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 + diff --git a/README.md b/README.md new file mode 100644 index 0000000..1d1b29e --- /dev/null +++ b/README.md @@ -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 + cd + ``` + + 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. + diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..b7f2c66 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..ba37d29 --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..8b07973 --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/001_initial_schema.py b/backend/alembic/versions/001_initial_schema.py new file mode 100644 index 0000000..ddbd306 --- /dev/null +++ b/backend/alembic/versions/001_initial_schema.py @@ -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') diff --git a/backend/alembic/versions/002_add_settings_table.py b/backend/alembic/versions/002_add_settings_table.py new file mode 100644 index 0000000..f1b5305 --- /dev/null +++ b/backend/alembic/versions/002_add_settings_table.py @@ -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') diff --git a/backend/alembic/versions/004_add_permissions_to_users.py b/backend/alembic/versions/004_add_permissions_to_users.py new file mode 100644 index 0000000..8c8678b --- /dev/null +++ b/backend/alembic/versions/004_add_permissions_to_users.py @@ -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') diff --git a/backend/alembic/versions/3382f3992222_add_last_login_to_users.py b/backend/alembic/versions/3382f3992222_add_last_login_to_users.py new file mode 100644 index 0000000..a6ac1e5 --- /dev/null +++ b/backend/alembic/versions/3382f3992222_add_last_login_to_users.py @@ -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') diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..d99fdb8 --- /dev/null +++ b/backend/app/api/v1/__init__.py @@ -0,0 +1,5 @@ +"""API v1 package - exports the main v1 router.""" + +from app.api.v1.router import router + +__all__ = ["router"] diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..b97e6ea --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -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} diff --git a/backend/app/api/v1/health.py b/backend/app/api/v1/health.py new file mode 100644 index 0000000..c201c50 --- /dev/null +++ b/backend/app/api/v1/health.py @@ -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 + } diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py new file mode 100644 index 0000000..ac75a9b --- /dev/null +++ b/backend/app/api/v1/router.py @@ -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"]) diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py new file mode 100644 index 0000000..8ae47e4 --- /dev/null +++ b/backend/app/api/v1/settings.py @@ -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) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py new file mode 100644 index 0000000..12a0658 --- /dev/null +++ b/backend/app/api/v1/users.py @@ -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 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..a29a8f4 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py new file mode 100644 index 0000000..4ed4c30 --- /dev/null +++ b/backend/app/core/exceptions.py @@ -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 diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..fa68259 --- /dev/null +++ b/backend/app/core/security.py @@ -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 diff --git a/backend/app/core/settings_registry.py b/backend/app/core/settings_registry.py new file mode 100644 index 0000000..58e7f09 --- /dev/null +++ b/backend/app/core/settings_registry.py @@ -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}") diff --git a/backend/app/crud/__init__.py b/backend/app/crud/__init__.py new file mode 100644 index 0000000..09af0e7 --- /dev/null +++ b/backend/app/crud/__init__.py @@ -0,0 +1,6 @@ +"""CRUD operations package.""" + +from app.crud.user import user +from app.crud import settings + +__all__ = ["user", "settings"] diff --git a/backend/app/crud/base.py b/backend/app/crud/base.py new file mode 100644 index 0000000..e88a1b8 --- /dev/null +++ b/backend/app/crud/base.py @@ -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) diff --git a/backend/app/crud/settings.py b/backend/app/crud/settings.py new file mode 100644 index 0000000..161e9d7 --- /dev/null +++ b/backend/app/crud/settings.py @@ -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() diff --git a/backend/app/crud/user.py b/backend/app/crud/user.py new file mode 100644 index 0000000..4b99ffd --- /dev/null +++ b/backend/app/crud/user.py @@ -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) diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..1fd3a5c --- /dev/null +++ b/backend/app/db/base.py @@ -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 diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..b38dfc3 --- /dev/null +++ b/backend/app/db/session.py @@ -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 +) diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py new file mode 100644 index 0000000..07a157f --- /dev/null +++ b/backend/app/dependencies.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..77e68c0 --- /dev/null +++ b/backend/app/main.py @@ -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() + ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..42502d0 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,6 @@ +"""Models package.""" + +from app.models.user import User +from app.models.settings import Settings + +__all__ = ["User", "Settings"] diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py new file mode 100644 index 0000000..7646771 --- /dev/null +++ b/backend/app/models/settings.py @@ -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 diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..a6535d8 --- /dev/null +++ b/backend/app/models/user.py @@ -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"" diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..62bd59d --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -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", +] diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..9fdceb9 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -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" + } + } diff --git a/backend/app/schemas/common.py b/backend/app/schemas/common.py new file mode 100644 index 0000000..89b1a85 --- /dev/null +++ b/backend/app/schemas/common.py @@ -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 diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py new file mode 100644 index 0000000..f68124f --- /dev/null +++ b/backend/app/schemas/settings.py @@ -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 + ) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..1a400cd --- /dev/null +++ b/backend/app/schemas/user.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..f4f8418 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..699e7fe --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d2e7761 --- /dev/null +++ b/frontend/README.md @@ -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... + }, + }, +]) +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/frontend/eslint.config.js @@ -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, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ff8afe2 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,18 @@ + + + + + + + + + + + + Loading... + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..fcd6706 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/logo_black.svg b/frontend/public/logo_black.svg new file mode 100644 index 0000000..3fcf3ec --- /dev/null +++ b/frontend/public/logo_black.svg @@ -0,0 +1,827 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/logo_white.svg b/frontend/public/logo_white.svg new file mode 100644 index 0000000..5d0784d --- /dev/null +++ b/frontend/public/logo_white.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..8e7f6b3 --- /dev/null +++ b/frontend/src/App.css @@ -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; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..ac12450 --- /dev/null +++ b/frontend/src/App.tsx @@ -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
Loading...
; + } + + return user ? children : ; +} + +function AdminRoute({ children }: { children: ReactElement }) { + const { user, isLoading } = useAuth(); + + if (isLoading) { + return
Loading...
; + } + + if (!user) { + return ; + } + + return user.is_superuser ? children : ; +} + +import MainLayout from './components/MainLayout'; + +// ... + +function AppRoutes() { + const { user } = useAuth(); + + return ( + + : } /> + + }> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + + + } /> + + ); +} + +function App() { + return ( + + + + + + + + + + + + + + + + + + ); +} + +export default App; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..7985bc4 --- /dev/null +++ b/frontend/src/api/client.ts @@ -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 => { + const response = await api.post('/auth/login', data); + return response.data; + }, + + register: async (data: RegisterRequest): Promise => { + const response = await api.post('/auth/register', data); + return response.data; + }, + + getCurrentUser: async (): Promise => { + const response = await api.get('/auth/me'); + return response.data; + }, +}; + +// Settings endpoints +export const settingsAPI = { + getTheme: async (): Promise> => { + const response = await api.get>('/settings/theme'); + return response.data; + }, + + updateTheme: async (data: Record): Promise> => { + const response = await api.put>('/settings/theme', data); + return response.data; + }, + + getModules: async (): Promise> => { + const response = await api.get>('/settings/modules'); + return response.data; + }, + + updateModules: async (data: Record): Promise> => { + const response = await api.put>('/settings/modules', data); + return response.data; + }, +}; + +// Users endpoints +export const usersAPI = { + list: async (): Promise => { + const response = await api.get('/users'); + return response.data; + }, + + get: async (id: string): Promise => { + const response = await api.get(`/users/${id}`); + return response.data; + }, + + create: async (data: UserCreate): Promise => { + const response = await api.post('/users', data); + return response.data; + }, + + update: async (id: string, data: UserUpdatePayload): Promise => { + const response = await api.put(`/users/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`/users/${id}`); + }, +}; + +export default api; diff --git a/frontend/src/components/MainLayout.tsx b/frontend/src/components/MainLayout.tsx new file mode 100644 index 0000000..423778e --- /dev/null +++ b/frontend/src/components/MainLayout.tsx @@ -0,0 +1,11 @@ +import { Outlet } from 'react-router-dom'; +import Sidebar from './Sidebar'; + +export default function MainLayout() { + return ( +
+ + +
+ ); +} diff --git a/frontend/src/components/MobileHeader.tsx b/frontend/src/components/MobileHeader.tsx new file mode 100644 index 0000000..3761e9c --- /dev/null +++ b/frontend/src/components/MobileHeader.tsx @@ -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 ( +
+ +

{config.name}

+
+ ); +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx new file mode 100644 index 0000000..1129be0 --- /dev/null +++ b/frontend/src/components/Sidebar.tsx @@ -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 | 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 */} +
+ + {/* Sidebar Tooltip */} + {tooltip.visible && ( +
+ {tooltip.text} +
+ )} + + + + ); +} diff --git a/frontend/src/components/UserMenu.tsx b/frontend/src/components/UserMenu.tsx new file mode 100644 index 0000000..f2c6399 --- /dev/null +++ b/frontend/src/components/UserMenu.tsx @@ -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(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 ( +
+ {isOpen && ( +
+ + + {user?.is_superuser && viewMode === 'admin' && ( + <> + +
+ + )} + +
+ handleNavClick(e, '/settings')} + > + settings + {t.sidebar.settings} + + {showDarkModeToggle && darkModeLocation === 'user_menu' && ( + + )} + {showLanguageToggle && languageLocation === 'user_menu' && ( + + )} +
+ +
+ + +
+ )} + + +
+ ); +} diff --git a/frontend/src/components/admin/Feature1Tab.tsx b/frontend/src/components/admin/Feature1Tab.tsx new file mode 100644 index 0000000..bbd7d56 --- /dev/null +++ b/frontend/src/components/admin/Feature1Tab.tsx @@ -0,0 +1,15 @@ +import { useTranslation } from '../../contexts/LanguageContext'; + +export default function Feature1Tab() { + const { t } = useTranslation(); + + return ( +
+
+ playlist_play +
+

{t.feature1.management}

+

{t.feature1.comingSoon}

+
+ ); +} diff --git a/frontend/src/components/admin/GeneralTab.tsx b/frontend/src/components/admin/GeneralTab.tsx new file mode 100644 index 0000000..259dfec --- /dev/null +++ b/frontend/src/components/admin/GeneralTab.tsx @@ -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 ( +
+ {/* Interface Section */} +
+
+

{t.admin.viewMode}

+
+
+
+
+
+ visibility +
+
+

{t.admin.userModeToggle}

+

{t.admin.userModeToggleDesc}

+
+
+ +
+
+
+
+ person_add +
+
+

{t.settings.allowRegistration}

+

{t.settings.allowRegistrationDesc}

+
+
+ +
+
+
+
+ branding_watermark +
+
+

{t.settings.showLogo}

+

{t.settings.showLogoDesc}

+
+
+ +
+
+
+ + {/* Dark Mode Settings Group */} +
+
+

{t.admin.darkModeSettings}

+
+
+
+
+
+ dark_mode +
+
+

{t.admin.enableDarkModeToggle}

+

{t.admin.enableDarkModeToggleDesc}

+
+
+ +
+ + {showDarkModeToggle && ( + <> +
+
+
+ login +
+
+

{t.admin.showOnLoginScreen}

+

{t.admin.showOnLoginScreenDesc}

+
+
+ +
+ +
+
+
+ splitscreen +
+
+

{t.admin.controlLocation}

+

{t.admin.controlLocationDesc}

+
+
+ +
+ + )} +
+
+ + {/* Language Settings Group */} +
+
+

{t.admin.languageSettings}

+
+
+
+
+
+ language +
+
+

{t.admin.enableLanguageSelector}

+

{t.admin.enableLanguageSelectorDesc}

+
+
+ +
+ + {showLanguageToggle && ( + <> +
+
+
+ login +
+
+

{t.admin.showOnLoginScreen}

+

{t.admin.showLanguageOnLoginDesc}

+
+
+ +
+ +
+
+
+ splitscreen +
+
+

{t.admin.controlLocation}

+

{t.admin.controlLocationDesc}

+
+
+ +
+ + )} +
+
+
+ ); +} diff --git a/frontend/src/components/admin/SettingsTab.tsx b/frontend/src/components/admin/SettingsTab.tsx new file mode 100644 index 0000000..6a26c78 --- /dev/null +++ b/frontend/src/components/admin/SettingsTab.tsx @@ -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({}); + 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
{t.common.loading}
; + } + + return ( +
+
+
+

{t.settings.authentication}

+
+ +
+
+

{t.settings.allowRegistration}

+

{t.settings.allowRegistrationDesc}

+
+ +
+
+
+ ); +} diff --git a/frontend/src/components/admin/UsersTab.tsx b/frontend/src/components/admin/UsersTab.tsx new file mode 100644 index 0000000..a970805 --- /dev/null +++ b/frontend/src/components/admin/UsersTab.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [isModalOpen, setModalOpen] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const [formData, setFormData] = useState({ ...emptyForm }); + const [searchTerm, setSearchTerm] = useState(''); + const [sortColumn, setSortColumn] = useState('username'); + const [sortDirection, setSortDirection] = useState('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) => { + 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 ( +
+
+
+ search + setSearchTerm(e.target.value)} + /> +
+
+ setShowActive(!showActive)} + > + {users.filter((u) => u.is_active).length} {t.usersPage.active} + + setShowInactive(!showInactive)} + > + {users.filter((u) => !u.is_active).length} {t.usersPage.inactive} + + +
+
+ + {error &&
{error}
} + + {loading ? ( +
{t.common.loading}
+ ) : filteredUsers.length === 0 ? ( +
{t.usersPage.noUsers}
+ ) : ( + <> + {/* Superusers Table */} + {superusers.length > 0 && ( +
+
+ + + + + + + + + + + + + {superusers.map((u) => ( + + + + + + + + + ))} + +
handleSort('username')}> + {t.usersPage.name} + {sortColumn === 'username' && ( + + {sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward'} + + )} + handleSort('created_at')}> + {t.usersPage.createdAt} + {sortColumn === 'created_at' && ( + + {sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward'} + + )} + handleSort('last_login')}> + {t.usersPage.lastLogin} + {sortColumn === 'last_login' && ( + + {sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward'} + + )} + handleSort('is_active')}> + {t.usersPage.status} + {sortColumn === 'is_active' && ( + + {sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward'} + + )} + + {t.usersPage.role} + {t.usersPage.actions}
+
+
+ {u.username.charAt(0).toUpperCase()} +
+
+ {u.username} + {u.email} +
+
+
+ {formatDate(u.created_at)} + + + {u.last_login ? formatDate(u.last_login) : '-'} + + + + {u.is_active ? t.usersPage.active : t.usersPage.inactive} + + + + {t.usersPage.superuser} + + +
+ + +
+
+
+
+ )} + + {/* Regular Users Table */} + {regularUsers.length > 0 && ( +
0 ? '1rem' : 0 }}> +
+ + + + + + + + + + + + + {regularUsers.map((u) => ( + + + + + + + + + ))} + +
handleSort('username')}> + {t.usersPage.name} + {sortColumn === 'username' && ( + + {sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward'} + + )} + handleSort('created_at')}> + {t.usersPage.createdAt} + {sortColumn === 'created_at' && ( + + {sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward'} + + )} + handleSort('last_login')}> + {t.usersPage.lastLogin} + {sortColumn === 'last_login' && ( + + {sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward'} + + )} + handleSort('is_active')}> + {t.usersPage.status} + {sortColumn === 'is_active' && ( + + {sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward'} + + )} + + {t.usersPage.role} + {t.usersPage.actions}
+
+
+ {u.username.charAt(0).toUpperCase()} +
+
+ {u.username} + {u.email} +
+
+
+ {formatDate(u.created_at)} + + + {u.last_login ? formatDate(u.last_login) : '-'} + + + + {u.is_active ? t.usersPage.active : t.usersPage.inactive} + + + + {t.usersPage.regular} + + +
+ + +
+
+
+
+ )} + + {/* Mobile Cards */} +
+ {superusers.map((u) => ( +
+
+
+ {u.username.charAt(0).toUpperCase()} +
+
+
{u.username}
+
{u.email}
+
+
+ + +
+
+ +
+
+ + {u.is_active ? t.usersPage.active : t.usersPage.inactive} + + + {t.usersPage.superuser} + +
+
+ + calendar_add_on + {formatDate(u.created_at)} + + + login + {u.last_login ? formatDate(u.last_login) : '-'} + +
+
+
+ ))} + + {/* Mobile Divider */} + {superusers.length > 0 && regularUsers.length > 0 && ( +
+ )} + + {/* Regular Users */} + {regularUsers.map((u) => ( +
+
+
+ {u.username.charAt(0).toUpperCase()} +
+
+
{u.username}
+
{u.email}
+
+
+ + +
+
+ +
+
+ + {u.is_active ? t.usersPage.active : t.usersPage.inactive} + + + {t.usersPage.regular} + +
+
+ + calendar_add_on + {formatDate(u.created_at)} + + + login + {u.last_login ? formatDate(u.last_login) : '-'} + +
+
+
+ ))} +
+ + )} + + {isModalOpen && createPortal( +
+
e.stopPropagation()}> +
+

+ {editingUser ? t.usersPage.editUser : t.usersPage.addUser} +

+ +
+ + {error &&
{error}
} + +
+
+ + + setFormData((prev) => ({ ...prev, username: e.target.value })) + } + required + minLength={3} + /> +
+ +
+ + + setFormData((prev) => ({ ...prev, email: e.target.value })) + } + required + /> +
+ +
+ + + setFormData((prev) => ({ ...prev, password: e.target.value })) + } + minLength={formData.password ? 8 : undefined} + /> +
+ + {/* Status Section */} +
+ +
+ + +
+
+ + {/* Permissions Section - only show for non-superuser */} + {!formData.is_superuser && ( +
+
+ + +
+ {formData.hasCustomPermissions ? ( +
+ {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 ( + + ); + })} +
+ ) : ( +
+ {t.usersPage.usingDefaultPermissions} +
+ )} +
+ )} + +
+ + +
+
+
+
, + document.body + )} +
+ ); +} diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..ad31108 --- /dev/null +++ b/frontend/src/contexts/AuthContext.tsx @@ -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(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [token, setToken] = useState(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 ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} diff --git a/frontend/src/contexts/LanguageContext.tsx b/frontend/src/contexts/LanguageContext.tsx new file mode 100644 index 0000000..1c4aca1 --- /dev/null +++ b/frontend/src/contexts/LanguageContext.tsx @@ -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 = { + it, + en, +}; + +const LanguageContext = createContext(undefined); + +export function LanguageProvider({ children }: { children: ReactNode }) { + const [language, setLanguageState] = useState(() => { + 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 {children}; +} + +export function useTranslation() { + const context = useContext(LanguageContext); + if (context === undefined) { + throw new Error('useTranslation must be used within a LanguageProvider'); + } + return context; +} diff --git a/frontend/src/contexts/ModulesContext.tsx b/frontend/src/contexts/ModulesContext.tsx new file mode 100644 index 0000000..9e931a7 --- /dev/null +++ b/frontend/src/contexts/ModulesContext.tsx @@ -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; + isModuleEnabled: (moduleId: string) => boolean; + isModuleEnabledForUser: (moduleId: string, userPermissions: UserPermissions | undefined, isSuperuser: boolean) => boolean; + setModuleEnabled: (moduleId: ModuleId, type: 'admin' | 'user', enabled: boolean) => void; + saveModulesToBackend: () => Promise; + isLoading: boolean; + hasInitialized: boolean; +} + +const ModulesContext = createContext(undefined); + +// Default states +const getDefaultStates = (): Record => { + const states: Record = {}; + TOGGLEABLE_MODULES.forEach(m => { + states[m.id] = { admin: m.defaultEnabled, user: m.defaultEnabled }; + }); + return states as Record; +}; + +export function ModulesProvider({ children }: { children: ReactNode }) { + const [moduleStates, setModuleStates] = useState>(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 = {}; + 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 ( + + {children} + + ); +} + +export function useModules() { + const context = useContext(ModulesContext); + if (context === undefined) { + throw new Error('useModules must be used within a ModulesProvider'); + } + return context; +} diff --git a/frontend/src/contexts/SidebarContext.tsx b/frontend/src/contexts/SidebarContext.tsx new file mode 100644 index 0000000..0f0725b --- /dev/null +++ b/frontend/src/contexts/SidebarContext.tsx @@ -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; + isHovered: boolean; + setIsHovered: (isHovered: boolean) => void; + showLogo: boolean; + setShowLogo: (show: boolean) => void; +} + +const SidebarContext = createContext(undefined); + +export function SidebarProvider({ children }: { children: ReactNode }) { + const [userCollapsed, setUserCollapsed] = useState(true); + const [isMobileOpen, setIsMobileOpen] = useState(false); + const [sidebarMode, setSidebarModeState] = useState('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 ( + setIsHovered(value), + showLogo, + setShowLogo, + }} + > + {children} + + ); +} + +export function useSidebar() { + const context = useContext(SidebarContext); + if (context === undefined) { + throw new Error('useSidebar must be used within a SidebarProvider'); + } + return context; +} diff --git a/frontend/src/contexts/SiteConfigContext.tsx b/frontend/src/contexts/SiteConfigContext.tsx new file mode 100644 index 0000000..97e055e --- /dev/null +++ b/frontend/src/contexts/SiteConfigContext.tsx @@ -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(null); + +export function SiteConfigProvider({ children }: { children: ReactNode }) { + const value: SiteConfigContextValue = { + config: siteConfig, + isFeatureEnabled, + }; + + return ( + + {children} + + ); +} + +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); +} diff --git a/frontend/src/contexts/ThemeContext.tsx b/frontend/src/contexts/ThemeContext.tsx new file mode 100644 index 0000000..c97656a --- /dev/null +++ b/frontend/src/contexts/ThemeContext.tsx @@ -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; + 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) => 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; + saveThemeToBackend: (overrides?: Partial<{ + accentColor: AccentColor; + borderRadius: BorderRadius; + sidebarStyle: SidebarStyle; + density: Density; + fontFamily: FontFamily; + colorPalette: ColorPalette; + customColors: Partial; + darkModeLocation: ControlLocation; + languageLocation: ControlLocation; + showDarkModeToggle: boolean; + showLanguageToggle: boolean; + showDarkModeLogin: boolean; + showLanguageLogin: boolean; + }>) => Promise; +} + +const ThemeContext = createContext(undefined); + +const ACCENT_COLORS: Record = { + 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 = { + small: { sm: '2px', md: '4px', lg: '6px' }, + medium: { sm: '4px', md: '6px', lg: '8px' }, + large: { sm: '8px', md: '12px', lg: '16px' }, +}; + +const DENSITIES: Record = { + 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 = { + 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 = { + 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(() => { + 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('auto'); + const [borderRadius, setBorderRadiusState] = useState('large'); + const [sidebarStyle, setSidebarStyleState] = useState('default'); + const [density, setDensityState] = useState('compact'); + const [fontFamily, setFontFamilyState] = useState('sans'); + const [colorPalette, setColorPaletteState] = useState('monochrome'); + const [customColors, setCustomColorsState] = useState>({}); + const [darkModeLocation, setDarkModeLocationState] = useState('sidebar'); + const [languageLocation, setLanguageLocationState] = useState('sidebar'); + const [showDarkModeToggle, setShowDarkModeToggleState] = useState(true); + const [showLanguageToggle, setShowLanguageToggleState] = useState(false); + const [showDarkModeLogin, setShowDarkModeLoginState] = useState(true); + const [showLanguageLogin, setShowLanguageLoginState] = useState(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); + } 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; + 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) => { + 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 ( + + {children} + + ); +} + +export function useTheme() { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +} diff --git a/frontend/src/contexts/ViewModeContext.tsx b/frontend/src/contexts/ViewModeContext.tsx new file mode 100644 index 0000000..8c37f74 --- /dev/null +++ b/frontend/src/contexts/ViewModeContext.tsx @@ -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(undefined); + +export function ViewModeProvider({ children }: { children: ReactNode }) { + // viewMode is user preference - stored in localStorage + const [viewMode, setViewModeState] = useState(() => { + const saved = localStorage.getItem('viewMode'); + return (saved as ViewMode) || 'admin'; + }); + + // isUserModeEnabled is a GLOBAL setting - comes from database, default false + const [isUserModeEnabled, setUserModeEnabledState] = useState(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 ( + + {children} + + ); +} + +export function useViewMode() { + const context = useContext(ViewModeContext); + if (context === undefined) { + throw new Error('useViewMode must be used within a ViewModeProvider'); + } + return context; +} diff --git a/frontend/src/hooks/useDocumentTitle.ts b/frontend/src/hooks/useDocumentTitle.ts new file mode 100644 index 0000000..025b9f5 --- /dev/null +++ b/frontend/src/hooks/useDocumentTitle.ts @@ -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; + } +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..7588a9e --- /dev/null +++ b/frontend/src/index.css @@ -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'; +} \ No newline at end of file diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json new file mode 100644 index 0000000..1edbe9b --- /dev/null +++ b/frontend/src/locales/en.json @@ -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" + } +} \ No newline at end of file diff --git a/frontend/src/locales/it.json b/frontend/src/locales/it.json new file mode 100644 index 0000000..5a68195 --- /dev/null +++ b/frontend/src/locales/it.json @@ -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" + } +} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..8c93ef7 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + , +) diff --git a/frontend/src/modules/index.ts b/frontend/src/modules/index.ts new file mode 100644 index 0000000..e55f53a --- /dev/null +++ b/frontend/src/modules/index.ts @@ -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; + }); +} diff --git a/frontend/src/pages/AdminPanel.tsx b/frontend/src/pages/AdminPanel.tsx new file mode 100644 index 0000000..c002dfa --- /dev/null +++ b/frontend/src/pages/AdminPanel.tsx @@ -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('general'); + + if (!currentUser?.is_superuser) { + return null; + } + + return ( +
+
+
+ +
+ admin_panel_settings + {t.admin.panel} +
+
+ + +
+
+ +
+ {activeTab === 'general' && } + {activeTab === 'users' && } +
+
+ ); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..d98c30e --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -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 ( +
+
+
+ +
+ dashboard + {t.dashboard.title} +
+
+
+ +
+
+
+

{t.dashboard.profile}

+

+ {t.auth.username}: {user?.username} +

+

+ {t.auth.email}: {user?.email} +

+

+ {t.dashboard.userId}: {user?.id} +

+

+ {t.dashboard.status}:{' '} + + {user?.is_active ? t.dashboard.active : t.dashboard.inactive} + +

+
+ +
+

{t.features.feature1}

+

{t.features.comingSoon}

+
+ +
+

{t.features.feature2}

+

{t.features.comingSoon}

+
+ +
+

{t.features.feature3}

+

{t.features.comingSoon}

+
+ + {user?.is_superuser && ( +
+

{t.admin.panel}

+

{t.admin.userManagement} - {t.features.comingSoon}

+

{t.admin.systemSettings} - {t.features.comingSoon}

+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/pages/Feature1.tsx b/frontend/src/pages/Feature1.tsx new file mode 100644 index 0000000..a35d71f --- /dev/null +++ b/frontend/src/pages/Feature1.tsx @@ -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 ( +
+
+
+ +
+ playlist_play + {t.feature1.title} +
+
+
+ +
+
+
+ playlist_play +
+ +

{t.feature1.title}

+

{t.feature1.comingSoon}

+
+
+
+ ); +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..76d3be9 --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -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 ( +
+
+
+

{config.name}

+

{config.tagline}

+
+ +
+
+ + setUsername(e.target.value)} + required + minLength={3} + /> +
+ + {isRegister && ( +
+ + setEmail(e.target.value)} + required + /> +
+ )} + +
+ + setPassword(e.target.value)} + required + minLength={8} + /> +
+ + {error &&
{error}
} + + +
+ +
+ {registrationEnabled && ( + + )} + +
+ {showDarkModeToggle && showDarkModeLogin && ( + + )} + {showLanguageToggle && showLanguageLogin && ( + + )} +
+
+
+
+ ); +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..9cd3f36 --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -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 ( +
+
+
+ +
+ settings + {t.settings.title} +
+
+
+ +
+
+ +
+
+
+
+ language +
+
+

{t.settings.language}

+

{t.settings.languageDesc}

+
+
+
+ +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/pages/Users.tsx b/frontend/src/pages/Users.tsx new file mode 100644 index 0000000..f559201 --- /dev/null +++ b/frontend/src/pages/Users.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [isModalOpen, setModalOpen] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const [formData, setFormData] = useState({ ...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) => { + 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 ( +
+ +
+
+
+ +
+ group + {t.admin.userManagement} +
+ +
+
+ +
+
+
+ search + setSearchTerm(e.target.value)} + /> +
+
+ + {t.usersPage.active}: {users.filter((u) => u.is_active).length} + + + {t.usersPage.inactive}: {users.filter((u) => !u.is_active).length} + +
+
+ + {error &&
{error}
} + + {loading ? ( +
{t.common.loading}
+ ) : filteredUsers.length === 0 ? ( +
{t.usersPage.noUsers}
+ ) : ( +
+ + + + + + + + + + + {filteredUsers.map((u) => ( + + + + + + + ))} + +
{t.usersPage.name}{t.usersPage.status}{t.usersPage.role}{t.usersPage.actions}
+
+
+ {u.username.charAt(0).toUpperCase()} +
+
+ {u.username} + {u.email} + + {t.dashboard.userId}: {u.id} + +
+
+
+ + {u.is_active ? t.usersPage.active : t.usersPage.inactive} + + + + {u.is_superuser ? t.usersPage.superuser : t.usersPage.regular} + + +
+ + +
+
+
+ )} +
+
+ + {isModalOpen && ( +
+
e.stopPropagation()}> +
+

+ {editingUser ? t.usersPage.editUser : t.usersPage.addUser} +

+ +
+ + {error &&
{error}
} + +
+
+ + + setFormData((prev) => ({ ...prev, username: e.target.value })) + } + required + minLength={3} + /> +
+ +
+ + + setFormData((prev) => ({ ...prev, email: e.target.value })) + } + required + /> +
+ +
+ + + setFormData((prev) => ({ ...prev, password: e.target.value })) + } + minLength={formData.password ? 8 : undefined} + /> +
+ +
+ + +
+ +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/admin/Features.tsx b/frontend/src/pages/admin/Features.tsx new file mode 100644 index 0000000..80b8cd3 --- /dev/null +++ b/frontend/src/pages/admin/Features.tsx @@ -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('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 ( +
+
+

{getModuleDescription(moduleId)}

+
+
+
+ {adminEnabled ? t.admin.active : t.admin.inactive} +
+
+ {t.admin.adminRole} + +
+
+ {t.admin.userRole} + +
+
+
+ ); + }; + + const renderTabContent = () => { + switch (activeTab) { + case 'feature1': + return ( + <> + {renderModuleToggle('feature1')} + + + ); + case 'feature2': + return ( + <> + {renderModuleToggle('feature2')} +
+
+ download +
+

{t.features.feature2}

+

{t.features.comingSoon}

+
+ + ); + case 'feature3': + return ( + <> + {renderModuleToggle('feature3')} +
+
+ cast +
+

{t.features.feature3}

+

{t.features.comingSoon}

+
+ + ); + default: + return null; + } + }; + + return ( +
+ {/* Tab Tooltip */} + {tooltip.visible && ( +
+ {tooltip.text} +
+ )} +
+
+ +
+ extension + {t.featuresPage.title} +
+
+ + + +
+
+ +
+ {renderTabContent()} +
+
+ ); +} diff --git a/frontend/src/pages/admin/Settings.tsx b/frontend/src/pages/admin/Settings.tsx new file mode 100644 index 0000000..a9067d9 --- /dev/null +++ b/frontend/src/pages/admin/Settings.tsx @@ -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({}); + 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
{t.common.loading}
; + } + + return ( +
+ +
+
+
+ +
+ settings + {t.settings.title} +
+
+
+ +
+
+
+

{t.settings.authentication}

+ +
+
+

{t.settings.allowRegistration}

+

{t.settings.allowRegistrationDesc}

+
+ +
+ +
+
+

{t.settings.showLogo}

+

{t.settings.showLogoDesc}

+
+ +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/pages/admin/Sources.tsx b/frontend/src/pages/admin/Sources.tsx new file mode 100644 index 0000000..b09269d --- /dev/null +++ b/frontend/src/pages/admin/Sources.tsx @@ -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 ( +
+
+
+ +
+ database + {t.sourcesPage.title} +
+
+
+ +
+
+
+ database +
+

{t.sourcesPage.title}

+

{t.sourcesPage.comingSoon}

+
+
+
+ ); +} diff --git a/frontend/src/pages/admin/ThemeSettings.tsx b/frontend/src/pages/admin/ThemeSettings.tsx new file mode 100644 index 0000000..15c0515 --- /dev/null +++ b/frontend/src/pages/admin/ThemeSettings.tsx @@ -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('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(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 ( +
+ +
+
+ + handleHexInput(theme, property, e.target.value)} + className="color-hex-input" + placeholder="#FFFFFF" + maxLength={7} + /> +
+
+
+ ); + }; + + // 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 ( +
+ {/* Tab Tooltip */} + {tooltip.visible && ( +
+ {tooltip.text} +
+ )} + {/* Modern Tab Navigation */} +
+
+ +
+ brush + {t.theme.title} +
+
+ + + + {isAdmin && ( + + )} +
+
+ +
+ {activeTab === 'colors' && ( +
+
+
+

{t.theme.accentColor}

+
+
+ {colors.map((color) => ( +
handleAccentColorChange(color.id)} + > +
+ {accentColor === color.id && ( + check + )} +
+
+ {color.label} + {color.description} +
+
+ ))} +
+
+ +
+
+

{t.theme.colorPalette}

+
+
+ {palettes.map((palette) => { + const paletteColors = COLOR_PALETTES[palette.id]; + return ( +
handleColorPaletteChange(palette.id)} + > +
+
+
+
+
+
+
+
+
+
+
+
+
+ {colorPalette === palette.id && ( +
+ check +
+ )} +
+
+ {palette.label} + {palette.description} +
+
+ ); + })} +
+
+
+ )} + + {activeTab === 'appearance' && ( +
+
+ {/* Border Radius Section */} +
+
+

{t.theme.borderRadius}

+
+
+ {radii.map((radius) => ( +
handleBorderRadiusChange(radius.id)} + > +
+
+
+
+ {radius.label} + {radius.description} +
+
+ ))} +
+
+ + {/* Sidebar Style Section */} +
+
+

{t.theme.sidebarStyle}

+
+
+ {sidebarStyles.map((style) => ( +
handleSidebarStyleChange(style.id)} + > +
+
+
+
+
+
+
+ {style.label} + {style.description} +
+
+ ))} +
+
+ + {/* Sidebar Mode Section */} +
+
+

{t.admin.sidebarMode}

+
+
+ {sidebarModes.map((mode) => ( +
setSidebarMode(mode.id)} + > +
+
+
+
+
+
+
+ {mode.label} + {mode.description} +
+
+ ))} +
+
+ + {/* Density Section */} +
+
+

{t.theme.density}

+
+
+ {densities.map((d) => ( +
handleDensityChange(d.id)} + > +
+
+
+
+
+
+
+
+ {d.label} + {d.description} +
+
+ ))} +
+
+ + {/* Font Family Section */} +
+
+

{t.theme.fontFamily}

+
+
+ {fonts.map((f) => ( +
handleFontFamilyChange(f.id)} + > +
+
+ Aa +
+
+
+ {f.label} + {f.description} +
+
+ ))} +
+
+
+
+ )} + + {activeTab === 'preview' && ( +
+
+
+

{t.theme.preview}

+
+
+
+
+

{t.theme.previewCard}

+ {t.theme.badge} +
+

{t.theme.previewDescription}

+
+ + +
+
+ +
+
+ +
+
+

{t.theme.sampleCard}

+ {t.dashboard.active} +
+

{t.theme.sampleCardDesc}

+
+
+ 142 + {t.theme.totalItems} +
+
+ 89% + {t.theme.successRate} +
+
+
+
+
+
+ )} + + {activeTab === 'advanced' && isAdmin && ( +
+
+
+

{t.theme.advancedColors}

+ +
+ +
+ {/* Light Theme Colors */} +
+

{t.theme.lightThemeColors}

+
+ + + + + + + + +
+
+ + {/* Dark Theme Colors */} +
+

{t.theme.darkThemeColors}

+
+ + + + + + + + +
+
+
+
+
+ )} +
+ + {/* Color Picker Popup */} + {colorPickerState && ( +
setColorPickerState(null)} + > +
e.stopPropagation()} + > +
+

{t.theme.pickColor}

+ +
+
+
+
+
+ {colorPickerState.value.toUpperCase()} + {t.theme.currentColor} +
+
+
+ { + handleColorChange( + colorPickerState.theme, + colorPickerState.property, + color.hex + ); + setColorPickerState({ + ...colorPickerState, + value: color.hex + }); + }} + disableAlpha={true} + /> +
+ { + handleColorChange( + colorPickerState.theme, + colorPickerState.property, + color.hex + ); + setColorPickerState({ + ...colorPickerState, + value: color.hex + }); + }} + width="100%" + height="16px" + /> +
+
+
+ +
+
+
+
+ )} +
+ ); +} diff --git a/frontend/src/styles/AdminPanel.css b/frontend/src/styles/AdminPanel.css new file mode 100644 index 0000000..6c5e1e8 --- /dev/null +++ b/frontend/src/styles/AdminPanel.css @@ -0,0 +1,2260 @@ +/* Admin Panel - Shared styles with Users page */ + +/* Admin Panel Root */ +.admin-panel-root { + max-width: 100%; +} + +.admin-panel-root .page-header h1 { + margin: 0; +} + +.admin-panel-root .page-subtitle { + margin: var(--space-2) 0 0; + color: var(--color-text-secondary); + font-size: 0.95rem; +} + +/* admin-tab-content padding is now managed by Layout.css */ + +/* Badge styles - ensure they're defined */ +.badge { + display: inline-flex; + align-items: center; + padding: var(--badge-padding); + border-radius: var(--radius-md); + font-size: var(--badge-font-size); + font-weight: 500; + white-space: nowrap; +} + +.badge-success { + background: rgba(34, 197, 94, 0.1); + color: #16a34a; + border: 1px solid rgba(34, 197, 94, 0.2); +} + +.badge-neutral { + background: var(--color-bg-elevated); + color: var(--color-text-secondary); + border: 1px solid var(--color-border); +} + +.badge-muted { + background: rgba(156, 163, 175, 0.1); + color: #6b7280; + border: 1px solid rgba(156, 163, 175, 0.2); +} + +.badge-accent { + background: rgba(var(--color-accent-rgb), 0.1); + color: var(--color-accent); + border: 1px solid rgba(var(--color-accent-rgb), 0.2); +} + +/* Toolbar - single row with search left, badges+button right */ +.admin-panel-root .users-toolbar, +.users-root .users-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--toolbar-gap); + margin-bottom: var(--section-gap-sm); + flex-wrap: wrap; +} + +.toolbar-right { + display: flex; + align-items: center; + gap: var(--element-gap-lg); +} + +/* Primary Button */ +.btn-primary { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: var(--btn-padding-md); + background: var(--color-accent); + color: white; + border: none; + border-radius: var(--radius-md); + font-weight: 600; + font-size: var(--btn-font-size); + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-primary:hover { + background: var(--color-accent-hover); + opacity: 0.95; +} + +.btn-primary .material-symbols-outlined { + font-size: var(--icon-md); +} + +/* Small button variant */ +.btn-sm { + padding: var(--btn-padding-sm) !important; + font-size: var(--btn-font-size-sm) !important; + height: auto !important; +} + +.btn-sm .material-symbols-outlined { + font-size: var(--icon-sm); +} + +.admin-panel-root .input-group, +.users-root .input-group { + display: flex; + align-items: center; + gap: var(--element-gap); + padding: var(--input-padding); + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + min-width: 260px; +} + +.admin-panel-root .input-group input, +.users-root .input-group input { + border: none; + outline: none; + background: transparent; + color: var(--color-text-primary); + width: 100%; +} + +.admin-panel-root .users-badges, +.users-root .users-badges { + display: flex; + gap: var(--element-gap); + flex-wrap: wrap; +} + +.admin-panel-root .users-card, +.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; +} + +/* Table wrapper for horizontal scroll */ +.admin-panel-root .users-table-wrapper, +.users-root .users-table-wrapper { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + position: relative; +} + +/* Scroll hint shadow on narrow screens */ +@media (max-width: 1200px) { + + .admin-panel-root .users-table-wrapper, + .users-root .users-table-wrapper { + background: + linear-gradient(to right, var(--color-bg-card) 30%, rgba(255, 255, 255, 0)), + linear-gradient(to left, var(--color-bg-card) 30%, rgba(255, 255, 255, 0)), + linear-gradient(to right, rgba(0, 0, 0, .15), rgba(255, 255, 255, 0)), + linear-gradient(to left, rgba(0, 0, 0, .15), rgba(255, 255, 255, 0)); + background-position: left center, right center, left center, right center; + background-repeat: no-repeat; + background-color: var(--color-bg-card); + background-size: 40px 100%, 40px 100%, 14px 100%, 14px 100%; + background-attachment: local, local, scroll, scroll; + } +} + +/* Users Settings Card */ +.users-setting-card { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + padding: var(--space-6); + margin-top: var(--section-gap); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--section-gap); +} + +.users-setting-card .setting-info { + display: flex; + align-items: flex-start; + gap: var(--space-4); + flex: 1; +} + +.users-setting-card .setting-icon { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + background: var(--color-bg-elevated); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + flex-shrink: 0; +} + +.users-setting-card .setting-icon .material-symbols-outlined { + font-size: 24px; + color: var(--color-accent); +} + +.users-setting-card .setting-text { + flex: 1; +} + +.users-setting-card .setting-text h4 { + margin: 0 0 0.25rem 0; + font-size: 1rem; + font-weight: 600; + color: var(--color-text-primary); +} + +.users-setting-card .setting-text p { + margin: 0; + font-size: 0.875rem; + color: var(--color-text-secondary); + line-height: 1.5; +} + +/* General Tab Content */ +.general-tab-content { + display: flex; + flex-direction: column; + gap: 3rem; + padding: 0 1rem; + /* Added side margins */ +} + +.general-section { + display: flex; + flex-direction: column; +} + + + + + +.modules-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; + /* Increased gap from var(--card-gap) which is usually small */ +} + +/* Compact setting card for general tab */ +.general-tab-content .setting-card-compact { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--card-padding); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + min-width: 0; + overflow: hidden; + min-height: 80px; + /* Increased height */ +} + +.general-tab-content .setting-card-compact .setting-info { + display: flex; + align-items: center; + gap: var(--element-gap-lg); + flex: 1; + min-width: 0; +} + +.general-tab-content .setting-card-compact .setting-icon { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: var(--color-bg-elevated); + border-radius: var(--radius-sm); + flex-shrink: 0; +} + +.general-tab-content .setting-card-compact .setting-icon .material-symbols-outlined { + font-size: 20px; + color: var(--color-accent); +} + +.general-tab-content .setting-card-compact .setting-text { + flex: 1; + min-width: 0; +} + +.general-tab-content .setting-card-compact .setting-text h4 { + margin: 0; + font-size: 0.9rem; + font-weight: 500; + color: var(--color-text-primary); +} + +.general-tab-content .setting-card-compact .setting-text p { + margin: 0; + font-size: 0.8rem; + color: var(--color-text-secondary); + line-height: 1.4; +} + +.admin-panel-root .users-table, +.users-root .users-table { + width: 100%; + border-collapse: collapse; + border-spacing: 0; + table-layout: fixed; +} + +.admin-panel-root .users-table th:nth-child(1), +.admin-panel-root .users-table td:nth-child(1), +.users-root .users-table th:nth-child(1), +.users-root .users-table td:nth-child(1) { + width: 34%; +} + +.admin-panel-root .users-table th:nth-child(2), +.admin-panel-root .users-table td:nth-child(2), +.users-root .users-table th:nth-child(2), +.users-root .users-table td:nth-child(2) { + width: 15%; + text-align: center; +} + +.admin-panel-root .users-table th:nth-child(3), +.admin-panel-root .users-table td:nth-child(3), +.users-root .users-table th:nth-child(3), +.users-root .users-table td:nth-child(3) { + width: 15%; + text-align: center; +} + +.admin-panel-root .users-table th:nth-child(4), +.admin-panel-root .users-table td:nth-child(4), +.users-root .users-table th:nth-child(4), +.users-root .users-table td:nth-child(4) { + width: 12%; + text-align: center; +} + +.admin-panel-root .users-table th:nth-child(5), +.admin-panel-root .users-table td:nth-child(5), +.users-root .users-table th:nth-child(5), +.users-root .users-table td:nth-child(5) { + width: 12%; + text-align: center; +} + +.admin-panel-root .users-table th:nth-child(6), +.admin-panel-root .users-table td:nth-child(6), +.users-root .users-table th:nth-child(6), +.users-root .users-table td:nth-child(6) { + width: 12%; + text-align: center; +} + +.admin-panel-root .users-table th, +.admin-panel-root .users-table td, +.users-root .users-table th, +.users-root .users-table td { + padding: var(--table-cell-padding); + text-align: left; + vertical-align: middle; +} + +.admin-panel-root .users-table tbody tr:not(:last-child), +.users-root .users-table tbody tr:not(:last-child) { + border-bottom: 1px solid var(--color-border); +} + +.admin-panel-root .users-table th, +.users-root .users-table th { + font-weight: 700; + color: var(--color-text-secondary); + background: var(--color-bg-elevated); + white-space: nowrap; +} + + + +/* Flex layout for sortable headers to align icon */ +.admin-panel-root .users-table th.sortable, +.users-root .users-table th.sortable { + cursor: pointer; + user-select: none; + transition: all 0.2s ease; + vertical-align: middle; +} + +.admin-panel-root .users-table th.sortable:hover, +.users-root .users-table th.sortable:hover { + background: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.sort-icon { + font-size: 16px !important; + /* Force smaller size */ + opacity: 0.6; + margin-left: 0.5rem; + vertical-align: middle; + display: inline-block; +} + + +.admin-panel-root .users-table tbody tr:hover, +.users-root .users-table tbody tr:hover { + background: var(--color-bg-elevated); +} + +.admin-panel-root .user-cell, +.users-root .user-cell { + display: flex; + align-items: center; + gap: var(--element-gap-lg); +} + +.admin-panel-root .user-avatar, +.users-root .user-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--color-accent); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 1rem; + flex-shrink: 0; +} + +.admin-panel-root .user-meta, +.users-root .user-meta { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.admin-panel-root .user-name, +.users-root .user-name { + font-weight: 600; + color: var(--color-text-primary); +} + +.admin-panel-root .user-email, +.users-root .user-email { + font-size: 0.85rem; + color: var(--color-text-secondary); +} + +.admin-panel-root .user-date, +.users-root .user-date { + font-size: 0.9rem; + color: var(--color-text-secondary); + font-variant-numeric: tabular-nums; +} + +.admin-panel-root .users-alert, +.users-root .users-alert { + padding: var(--space-4); + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: var(--radius-md); + color: #dc2626; + margin-bottom: var(--space-4); +} + +.admin-panel-root .users-empty, +.users-root .users-empty { + padding: 3rem; + text-align: center; + color: var(--color-text-secondary); +} + + +/* Tab Navigation */ +.admin-tabs { + display: flex; + gap: var(--element-gap); + border-bottom: 2px solid var(--color-border); + margin: var(--space-4) 0 0; +} + +.admin-tab { + display: flex; + align-items: center; + gap: var(--element-gap); + padding: 0.5rem 0.75rem; + background: transparent; + border: none; + outline: none; + border-bottom: 3px solid transparent; + color: var(--color-text-secondary); + cursor: pointer; + transition: all 0.2s ease; + font-size: var(--tab-font-size); + font-weight: 500; + margin-bottom: -2px; +} + +.admin-tab .material-symbols-outlined { + font-size: 20px; +} + +.admin-tab:hover { + color: var(--color-text-primary); + background: var(--color-bg-elevated); +} + +.admin-tab.active { + color: var(--color-accent); + border-bottom-color: var(--color-accent); +} + +/* admin-tab-content padding managed by Layout.css */ + +/* Modern Icon-Only Action Buttons */ +.users-actions-icons { + display: flex; + gap: var(--element-gap); + justify-content: center; +} + +.btn-icon-action { + display: flex; + align-items: center; + justify-content: center; + width: var(--btn-icon-md); + height: var(--btn-icon-md); + background: var(--color-bg-elevated); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s ease; + padding: 0; +} + +.btn-icon-action .material-symbols-outlined { + font-size: 18px; + color: var(--color-text-primary); +} + +.btn-icon-action:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.btn-icon-action.btn-edit { + color: var(--color-text-primary); +} + +.btn-icon-action.btn-edit:hover { + background: var(--color-accent); + border-color: var(--color-accent); +} + +.btn-icon-action.btn-edit:hover .material-symbols-outlined { + color: white; +} + +.btn-icon-action.btn-delete { + color: var(--color-text-primary); +} + +.btn-icon-action.btn-delete:hover { + background: #ef4444; + border-color: #ef4444; +} + +.btn-icon-action.btn-delete:hover .material-symbols-outlined { + color: white; +} + +.btn-icon-action:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; +} + +.btn-icon-action:disabled:hover { + background: var(--color-bg-elevated); + border-color: var(--color-border); + box-shadow: none; +} + +.btn-icon-action:disabled:hover .material-symbols-outlined { + color: var(--color-text-secondary); +} + +/* Hide mobile cards on desktop by default */ +.mobile-user-card { + display: none; +} + +/* Admin Tab Tooltip - Hidden by default on desktop */ +.admin-tab-tooltip { + display: none; +} + +/* Tablet Responsive */ +@media (max-width: 1024px) { + .modules-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +/* Mobile Responsive */ +@media (max-width: 768px) { + .admin-tabs { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .admin-tab { + white-space: nowrap; + } + + .admin-panel-root .users-toolbar, + .users-root .users-toolbar { + flex-direction: column; + align-items: stretch; + } + + .toolbar-right { + flex-direction: row; + justify-content: space-between; + } + + /* Hide desktop table on mobile */ + .admin-panel-root .users-card, + .users-root .users-card { + display: none; + } + + .mobile-users-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .mobile-users-divider { + height: 1px; + background: var(--color-border); + margin: 0.125rem 0; + opacity: 0.5; + } + + .mobile-user-card { + display: flex; + flex-direction: column; + gap: 0.5rem; + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 0.75rem; + box-shadow: var(--shadow-sm); + } + + .mobile-user-header { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .mobile-user-info { + flex: 1; + min-width: 0; + } + + .mobile-user-name { + font-weight: 600; + color: var(--color-text-primary); + font-size: 0.95rem; + } + + .mobile-user-email { + font-size: 0.8rem; + color: var(--color-text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .mobile-user-actions { + display: flex; + gap: 0.375rem; + flex-shrink: 0; + } + + .mobile-user-actions .btn-icon-action { + width: var(--btn-icon-sm); + height: var(--btn-icon-sm); + } + + .mobile-user-actions .btn-icon-action .material-symbols-outlined { + font-size: var(--icon-sm); + } + + .mobile-user-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid var(--color-border); + } + + .mobile-badges-row { + display: flex; + gap: 0.5rem; + } + + .mobile-dates { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.8rem; + color: var(--color-text-secondary); + } + + .mobile-date-item { + display: flex; + align-items: center; + gap: 0.375rem; + } + + .mobile-date-item .material-symbols-outlined { + font-size: 14px; + opacity: 0.7; + } +} + +/* Tablets and smaller screens - enable horizontal scroll */ +@media (max-width: 1200px) { + + .admin-panel-root .users-table, + .users-root .users-table { + min-width: 900px; + } + + .admin-panel-root .users-table th, + .admin-panel-root .users-table td, + .users-root .users-table th, + .users-root .users-table td { + padding: 0.75rem 0.75rem; + font-size: 0.9rem; + } + + .admin-panel-root .user-avatar, + .users-root .user-avatar { + width: 36px; + height: 36px; + font-size: 0.9rem; + } + + .admin-panel-root .user-name, + .users-root .user-name { + font-size: 0.95rem; + } + + .admin-panel-root .user-email, + .users-root .user-email { + font-size: 0.8rem; + } + + .admin-panel-root .user-date, + .users-root .user-date { + font-size: 0.85rem; + } + + .btn-icon-action { + width: var(--btn-icon-sm); + height: var(--btn-icon-sm); + } + + .btn-icon-action .material-symbols-outlined { + font-size: var(--icon-xs); + } +} + + +/* Admin Panel Root */ +.admin-panel-root { + max-width: 100%; +} + +.admin-panel-root .page-header h1 { + margin: 0; +} + +.admin-panel-root .page-subtitle { + margin: var(--space-2) 0 0; + color: var(--color-text-secondary); + font-size: 0.95rem; +} + +/* Tab Navigation */ +.admin-tabs { + display: flex; + gap: var(--element-gap); + border-bottom: 2px solid var(--color-border); + margin: var(--space-4) 0 0; +} + +.admin-tab { + display: flex; + align-items: center; + gap: var(--element-gap); + padding: 0.5rem 0.75rem; + background: transparent; + border: none; + border-bottom: 3px solid transparent; + color: var(--color-text-secondary); + cursor: pointer; + transition: all 0.2s ease; + font-size: var(--tab-font-size); + font-weight: 500; + margin-bottom: -2px; +} + +.admin-tab .material-symbols-outlined { + font-size: 20px; +} + +.admin-tab:hover { + color: var(--color-text-primary); + background: var(--color-bg-elevated); +} + +.admin-tab.active { + color: var(--color-accent); + border-bottom-color: var(--color-accent); +} + +/* admin-tab-content padding managed by Layout.css */ + +/* Modern Icon-Only Action Buttons */ +.users-actions-icons { + display: flex; + gap: var(--element-gap); + justify-content: center; +} + +.btn-icon-action { + display: flex; + align-items: center; + justify-content: center; + width: var(--btn-icon-md); + height: var(--btn-icon-md); + background: var(--color-bg-elevated); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s ease; + padding: 0; +} + +.btn-icon-action .material-symbols-outlined { + font-size: 18px; + color: var(--color-text-primary); +} + +.btn-icon-action:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.btn-icon-action.btn-edit { + color: var(--color-text-primary); +} + +.btn-icon-action.btn-edit:hover { + background: var(--color-accent); + border-color: var(--color-accent); +} + +.btn-icon-action.btn-edit:hover .material-symbols-outlined { + color: white; +} + +.btn-icon-action.btn-delete { + color: var(--color-text-primary); +} + +.btn-icon-action.btn-delete:hover { + background: #ef4444; + border-color: #ef4444; +} + +.btn-icon-action.btn-delete:hover .material-symbols-outlined { + color: white; +} + +.btn-icon-action:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; +} + +.btn-icon-action:disabled:hover { + background: var(--color-bg-elevated); + border-color: var(--color-border); + box-shadow: none; +} + +.btn-icon-action:disabled:hover .material-symbols-outlined { + color: var(--color-text-secondary); +} + +/* Mobile Responsive */ +@media (max-width: 768px) { + .admin-tabs { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .admin-tab { + white-space: nowrap; + } +} + +/* Modern Toolbar */ +.tab-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.toolbar-actions { + display: flex; + gap: 1rem; + align-items: center; +} + +.input-group-modern { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1.25rem; + background: var(--color-bg-card); + border: 2px solid var(--color-border); + border-radius: var(--radius-lg); + min-width: 300px; + transition: all 0.2s ease; +} + +.input-group-modern:focus-within { + border-color: var(--color-accent); + box-shadow: 0 0 0 3px rgba(var(--color-accent-rgb), 0.1); +} + +.input-group-modern .material-symbols-outlined { + color: var(--color-text-secondary); + font-size: 20px; +} + +.input-group-modern input { + border: none; + outline: none; + background: transparent; + color: var(--color-text-primary); + width: 100%; + font-size: 0.95rem; +} + +/* Modern Buttons */ +.btn-primary-modern { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + background: var(--color-accent); + color: white; + border: none; + border-radius: var(--radius-md); + font-weight: 600; + font-size: 0.95rem; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.btn-primary-modern:hover { + background: var(--color-accent-hover); + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} + +.btn-primary-modern .material-symbols-outlined { + font-size: 18px; +} + +.btn-ghost-modern { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + background: var(--color-bg-elevated); + color: var(--color-text-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-weight: 500; + font-size: 0.95rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-ghost-modern:hover { + background: var(--color-bg-card); + border-color: var(--color-accent); +} + +/* Modern Icon Buttons */ +.action-buttons { + display: flex; + gap: 0.5rem; +} + +.btn-icon-modern { + display: flex; + align-items: center; + justify-content: center; + width: var(--btn-icon-md); + height: var(--btn-icon-md); + background: var(--color-bg-elevated); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-icon-modern .material-symbols-outlined { + font-size: var(--icon-sm); + color: var(--color-text-secondary); +} + +.btn-icon-modern:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.btn-icon-modern.btn-edit:hover { + background: var(--color-accent); + border-color: var(--color-accent); +} + +.btn-icon-modern.btn-edit:hover .material-symbols-outlined { + color: white; +} + +.btn-icon-modern.btn-delete:hover { + background: #ef4444; + border-color: #ef4444; +} + +.btn-icon-modern.btn-delete:hover .material-symbols-outlined { + color: white; +} + +.btn-icon-modern:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; +} + +/* Modern Table */ +.users-table-container { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow-sm); +} + +.modern-table { + width: 100%; + border-collapse: collapse; +} + +.modern-table th { + padding: 1rem 1.5rem; + text-align: left; + font-weight: 600; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-text-secondary); + background: var(--color-bg-elevated); + border-bottom: 2px solid var(--color-border); +} + +.modern-table th.actions-col { + width: 120px; + text-align: center; +} + +.modern-table td { + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--color-border); + vertical-align: middle; +} + +.modern-table tbody tr:last-child td { + border-bottom: none; +} + +.modern-table tbody tr:hover { + background: var(--color-bg-elevated); +} + +.modern-table .user-info { + display: flex; + align-items: center; + gap: 1rem; +} + +.modern-table .user-avatar-modern { + width: 42px; + height: 42px; + border-radius: 50%; + background: linear-gradient(135deg, var(--color-accent), var(--color-accent-hover)); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 1rem; + flex-shrink: 0; +} + +.modern-table .user-details { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.modern-table .user-name-modern { + font-weight: 600; + color: var(--color-text-primary); + font-size: 0.95rem; +} + +.modern-table .user-email-modern { + font-size: 0.85rem; + color: var(--color-text-secondary); +} + +/* Modern Alerts */ +.alert-modern { + padding: 1rem 1.5rem; + border-radius: var(--radius-md); + margin-bottom: 1.5rem; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.alert-modern.alert-error { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + color: #dc2626; +} + +/* Modern Modal */ +.modal-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.modal-modern { + background: var(--color-bg-card); + border-radius: var(--radius-lg); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + max-width: 500px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + animation: slideUp 0.3s ease; +} + +@keyframes slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } +} + +.modal-header-modern { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem 2rem; + border-bottom: 1px solid var(--color-border); +} + +.modal-header-modern h2 { + margin: 0; + font-size: 1.25rem; +} + +.btn-close-modern { + width: var(--btn-icon-sm); + height: var(--btn-icon-sm); + display: flex; + align-items: center; + justify-content: center; + background: var(--color-bg-elevated); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-close-modern:hover { + background: #ef4444; + border-color: #ef4444; + color: white; +} + +.modal-form { + padding: 2rem; +} + +.form-field { + margin-bottom: 1.5rem; +} + +.form-field label { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; + color: var(--color-text-primary); + font-size: 0.9rem; +} + +.form-field .helper-text { + font-weight: 400; + color: var(--color-text-secondary); + font-size: 0.85rem; +} + +.form-field input { + width: 100%; + padding: 0.75rem 1rem; + background: var(--color-bg-elevated); + border: 2px solid var(--color-border); + border-radius: var(--radius-md); + color: var(--color-text-primary); + font-size: 0.95rem; + transition: all 0.2s ease; +} + +.form-field input:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px rgba(var(--color-accent-rgb), 0.1); +} + +.form-grid-checks { + display: flex; + gap: 1.5rem; + margin-bottom: 1.5rem; +} + +.checkbox-modern { + display: flex; + align-items: center; + gap: 0.75rem; + cursor: pointer; +} + +.checkbox-modern input { + width: 18px; + height: 18px; + cursor: pointer; +} + +.checkbox-modern span { + font-size: 0.95rem; + color: var(--color-text-primary); +} + +.modal-actions-modern { + display: flex; + justify-content: flex-end; + gap: 1rem; + padding-top: 1.5rem; + border-top: 1px solid var(--color-border); +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 4rem 2rem; + color: var(--color-text-secondary); +} + +.empty-state .material-symbols-outlined { + font-size: 64px; + opacity: 0.3; + margin-bottom: 1rem; +} + +.empty-state p { + font-size: 1.1rem; + margin: 0; +} + +/* Settings Tab */ +.settings-tab-content { + max-width: 800px; +} + +.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); +} + +.settings-section- .mobile-user-card { + display: block; + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 1rem; + box-shadow: var(--shadow-sm); +} + +/* 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; +} + +.setting-info-modern h4 { + margin: 0 0 0.5rem; + 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; +} + +/* 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; +} + +/* Dark mode override for toggle knob to be less bright when off */ +[data-theme='dark'] .toggle-slider-modern:before { + background-color: var(--color-text-secondary); +} + +[data-theme='dark'] .toggle-modern input:checked+.toggle-slider-modern:before { + background-color: white; +} + +/* Modal Backdrop */ +/* Modal Backdrop */ +.users-modal-backdrop { + position: fixed !important; + inset: 0 !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + background: rgba(0, 0, 0, 0.5); + display: flex !important; + align-items: center !important; + justify-content: center !important; + z-index: 2000 !important; + padding: 1rem; + 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: 100%; + max-width: 580px; + max-height: 90vh; + overflow-y: auto; + transform: translateY(0); + position: relative; + z-index: 2010; + animation: slideUp 0.2s ease-out; + display: flex; + flex-direction: column; +} + +@keyframes slideUp { + 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: 1rem; + padding: 1.75rem 2rem 1rem; + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +.users-modal .modal-header h2 { + margin: 0; + font-size: 1.35rem; + font-weight: 600; + color: var(--color-text-primary); +} + +.users-modal .btn-close-modal { + background: var(--color-bg-elevated); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + color: var(--color-text-primary); + cursor: pointer; + padding: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + width: var(--btn-icon-md); + height: var(--btn-icon-md); +} + +.users-modal .btn-close-modal:hover { + background: var(--color-bg-hover); + border-color: var(--color-text-secondary); +} + +.users-modal .btn-close-modal .material-symbols-outlined { + font-size: var(--icon-md); +} + +/* Form Container */ +.users-modal .users-form { + display: flex; + flex-direction: column; + gap: 1.25rem; + padding: 1.5rem 2rem; + flex: 1; + overflow-y: auto; +} + +.users-modal .form-row { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.users-modal .form-row label { + font-weight: 500; + color: var(--color-text-primary); + font-size: 0.95rem; +} + +.users-modal .form-row input { + padding: 0.75rem 0.875rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-main); + color: var(--color-text-primary); + font-size: 0.95rem; + transition: all 0.2s ease; +} + +.users-modal .form-row input:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +/* Form Section with Title */ +.users-modal .form-section { + margin-top: 0.5rem; + padding-top: 0.75rem; +} + +.users-modal .form-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.5rem; +} + +.users-modal .form-section-title { + display: block; + font-weight: 600; + color: var(--color-text-primary); + font-size: 0.9rem; + margin: 0; +} + +/* Inline Toggle for Custom Permissions */ +.users-modal .toggle-inline { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; +} + +.users-modal .toggle-inline input { + display: none; +} + +.users-modal .toggle-slider-sm { + position: relative; + width: 36px; + height: 20px; + background: var(--color-border); + border-radius: 20px; + transition: all 0.2s ease; +} + +.users-modal .toggle-slider-sm::before { + content: ''; + position: absolute; + width: 14px; + height: 14px; + left: 3px; + top: 3px; + background: white; + border-radius: 50%; + transition: all 0.2s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); +} + +.users-modal .toggle-inline input:checked+.toggle-slider-sm { + background: var(--color-accent); +} + +.users-modal .toggle-inline input:checked+.toggle-slider-sm::before { + transform: translateX(16px); +} + +.users-modal .toggle-label { + font-size: 0.8rem; + font-weight: 500; + color: var(--color-text-secondary); +} + +/* Permissions Default Hint */ +.users-modal .permissions-default-hint { + padding: 0.75rem 1rem; + background: var(--color-bg-elevated); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: 0.85rem; + color: var(--color-text-secondary); + text-align: center; +} + +.users-modal .form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: var(--color-bg-elevated); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); +} + +.users-modal .checkbox-row { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + padding: 0.5rem 0.75rem; + border-radius: var(--radius-md); + transition: all 0.2s ease; + background: var(--color-bg-card); + border: 1px solid transparent; +} + +.users-modal .checkbox-row:hover:not(.disabled) { + border-color: var(--color-accent); + background: rgba(var(--color-accent-rgb), 0.05); +} + +/* Custom Checkbox with Accent Color */ +.users-modal .checkbox-row input[type="checkbox"] { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + min-width: 18px; + border: 2px solid var(--color-border); + border-radius: 4px; + background: var(--color-bg-card); + cursor: pointer; + margin: 0; + position: relative; + transition: all 0.2s ease; +} + +.users-modal .checkbox-row input[type="checkbox"]:checked { + background: var(--color-accent); + border-color: var(--color-accent); +} + +.users-modal .checkbox-row input[type="checkbox"]:checked::after { + content: ''; + position: absolute; + left: 5px; + top: 2px; + width: 4px; + height: 8px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +.users-modal .checkbox-row input[type="checkbox"]:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.users-modal .checkbox-row input[type="checkbox"]:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(var(--color-accent-rgb), 0.2); +} + +.users-modal .checkbox-row span:not(.material-symbols-outlined):not(.badge) { + font-weight: 500; + color: var(--color-text-primary); + font-size: 0.9rem; +} + +.users-modal .helper-text { + color: var(--color-text-secondary); + font-weight: 400; + font-size: 0.875rem; + margin-top: 0.25rem; +} + +/* Permissions Grid */ +.users-modal .permissions-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.5rem; + padding: 0.75rem; + background: var(--color-bg-elevated); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); +} + +.users-modal .permissions-grid .checkbox-row { + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 0.75rem 0.5rem; + gap: 0.375rem; + min-height: 80px; +} + +.users-modal .permissions-grid .checkbox-row .material-symbols-outlined { + font-size: 24px; + color: var(--color-accent); +} + +.users-modal .permissions-grid .checkbox-row.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.users-modal .permissions-grid .checkbox-row.disabled .material-symbols-outlined { + color: var(--color-text-secondary); +} + +.users-modal .permissions-grid .checkbox-row span:not(.material-symbols-outlined):not(.badge) { + font-size: 0.8rem; +} + +.users-modal .permissions-grid .badge-sm { + font-size: 0.6rem; + padding: 0.1rem 0.35rem; + position: absolute; + top: 4px; + right: 4px; +} + +.users-modal .permissions-grid .checkbox-row { + position: relative; +} + +/* Modal Actions */ +.users-modal .modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding: 1.25rem 2rem; + border-top: 1px solid var(--color-border); + background: var(--color-bg-card); + flex-shrink: 0; +} + +.users-modal .modal-actions .btn-secondary, +.users-modal .modal-actions .btn-primary { + min-width: 100px; + font-weight: 500; +} + +.users-modal .btn-secondary { + background: var(--color-bg-elevated); + color: var(--color-text-primary); + border: 1px solid var(--color-border); + padding: 0.625rem 1.25rem; + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s ease; +} + +.users-modal .btn-secondary:hover { + background: var(--color-bg-hover); + border-color: var(--color-text-secondary); +} + +.users-modal .btn-primary { + background: var(--color-accent); + color: white; + border: none; + padding: 0.625rem 1.25rem; + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s ease; +} + +.users-modal .btn-primary:hover { + opacity: 0.9; +} + +/* Mobile Responsive */ +@media (max-width: 768px) { + .tab-toolbar { + flex-direction: column; + align-items: stretch; + } + + .toolbar-actions { + flex-direction: column; + } + + .input-group-modern { + min-width: 100%; + } + + .admin-tabs { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .admin-tab { + white-space: nowrap; + } + + .modern-table { + display: none; + } + + .setting-item-modern { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .modal-modern { + margin: 0; + max-height: 100vh; + border-radius: 0; + } + + .form-grid-checks { + flex-direction: column; + gap: 1rem; + } + + /* Users Settings Card Mobile */ + .users-setting-card { + padding: 1rem; + gap: 1rem; + } + + .users-setting-card .setting-info { + gap: 0.75rem; + } + + .users-setting-card .setting-icon { + width: 40px; + height: 40px; + } + + .users-setting-card .setting-icon .material-symbols-outlined { + font-size: 20px; + } + + .users-setting-card .setting-text h4 { + font-size: 0.9rem; + } + + .users-setting-card .setting-text p { + font-size: 0.8rem; + } + + /* General Tab Mobile */ + .modules-grid { + grid-template-columns: 1fr; + } + + .general-tab-content .setting-card-compact { + padding: 0.875rem 1rem; + } + + .general-tab-content .setting-card-compact .setting-icon { + width: 32px; + height: 32px; + } + + .general-tab-content .setting-card-compact .setting-icon .material-symbols-outlined { + font-size: 18px; + } + + /* Users Modal Mobile */ + .users-modal { + max-width: 100%; + width: calc(100% - 1.5rem); + max-height: 95vh; + } + + .users-modal .modal-header { + padding: 1.25rem 1.25rem 1rem; + } + + .users-modal .modal-header h2 { + font-size: 1.2rem; + } + + .users-modal .users-form { + padding: 1.25rem; + } + + .users-modal .form-grid { + grid-template-columns: 1fr; + gap: 0.75rem; + } + + .users-modal .permissions-grid { + grid-template-columns: repeat(2, 1fr); + gap: 0.5rem; + } + + .users-modal .modal-actions { + padding: 1rem 1.25rem; + flex-direction: column-reverse; + gap: 0.5rem; + } + + .users-modal .modal-actions .btn-secondary, + .users-modal .modal-actions .btn-primary { + width: 100%; + min-width: auto; + } + + /* Ensure button text colors are consistent in mobile */ + .users-modal .btn-secondary { + color: var(--color-text-primary); + } + + .users-modal .btn-primary { + color: white; + } +} + +/* Admin tabs styles are now in Layout.css */ + +/* Mobile Sidebar Toggle Button */ +.mobile-sidebar-toggle { + display: none; + align-items: center; + justify-content: center; + width: var(--btn-icon-md); + height: var(--btn-icon-md); + border: none; + background: transparent; + color: var(--color-text-primary); + border-radius: var(--radius-md); + cursor: pointer; + margin-right: 4px; +} + +@media (max-width: 768px) { + .mobile-sidebar-toggle { + display: flex; + } +} + +/* Admin title, divider, and tab button styles are now in Layout.css */ + +/* Tab content placeholder and animations are now in Layout.css */ + +/* Sidebar Mode Setting */ +.sidebar-mode-setting { + flex-direction: column; + align-items: flex-start !important; + gap: 0.75rem !important; +} + +.sidebar-mode-setting .setting-info { + width: 100%; +} + +.sidebar-mode-options { + display: flex; + gap: 0.5rem; + width: 100%; +} + +.sidebar-mode-option { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.25rem; + flex: 1; + padding: 0.75rem 0.5rem; + background: var(--color-bg-elevated); + border: 2px solid var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s ease; +} + +.sidebar-mode-option input { + display: none; +} + +.sidebar-mode-option .material-symbols-outlined { + font-size: 20px; + color: var(--color-text-secondary); +} + +.sidebar-mode-option span:last-child { + font-size: 0.75rem; + color: var(--color-text-secondary); + font-weight: 500; + text-align: center; +} + +.sidebar-mode-option:hover { + border-color: var(--color-accent); + background: rgba(var(--color-accent-rgb), 0.05); +} + +.sidebar-mode-option.active { + border-color: var(--color-accent); + background: rgba(var(--color-accent-rgb), 0.1); +} + +.sidebar-mode-option.active .material-symbols-outlined { + color: var(--color-accent); +} + +.sidebar-mode-option.active span:last-child { + color: var(--color-accent); +} + +@media (max-width: 768px) { + .sidebar-mode-options { + flex-direction: column; + } + + .sidebar-mode-option { + flex-direction: row; + gap: 0.5rem; + padding: 0.75rem 1rem; + } + + .sidebar-mode-option span:last-child { + font-size: 0.85rem; + } +} + +/* Feature Header - Clean style for feature toggles */ +.feature-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 2rem; + margin-bottom: 2rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--color-border); +} + +.feature-header-info { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.feature-header-info h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--color-text-primary); +} + +.feature-header-info p { + margin: 0; + color: var(--color-text-secondary); + font-size: 0.95rem; + line-height: 1.5; +} + +.feature-header-actions { + display: flex; + align-items: center; + gap: 1rem; + padding-top: 0.25rem; + /* Align with text top */ +} + +/* Status badge in header */ +.feature-status-badge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.75rem; + border-radius: var(--radius-full); + font-size: 0.85rem; + font-weight: 500; + background: var(--color-bg-elevated); + color: var(--color-text-secondary); + border: 1px solid var(--color-border); +} + +.feature-status-badge.active { + background: rgba(var(--color-accent-rgb), 0.1); + color: var(--color-accent); + border-color: rgba(var(--color-accent-rgb), 0.2); +} + +.feature-status-badge::before { + content: ''; + display: block; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +/* Feature Toggles - Dual Role */ +.toggle-group { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.toggle-label { + font-size: 0.85rem; + color: var(--color-text-secondary); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.toggle-modern.disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +.toggle-modern.disabled .toggle-slider-modern { + background: var(--color-bg-elevated); +} + +/* Feature Header Mobile - Stack description above toggles */ +@media (max-width: 768px) { + .feature-header { + flex-direction: column; + align-items: stretch; + gap: 1.25rem; + padding-bottom: 1.25rem; + margin-bottom: 1.5rem; + } + + .feature-header-info { + width: 100%; + text-align: center; + } + + .feature-header-info h2 { + font-size: 1.1rem; + } + + .feature-header-info p { + font-size: 0.9rem; + } + + .feature-header-actions { + width: 100%; + justify-content: center; + border-top: 1px solid var(--color-border); + padding-top: 1rem; + } + + .feature-header-actions .toggle-group { + justify-content: center; + } + + /* Admin Tab Tooltip - Mobile Only */ + .admin-tab-tooltip { + display: block; + position: fixed; + top: calc(var(--tabs-height, 56px) + 8px); + transform: translateX(-50%); + background: var(--color-bg-elevated); + color: var(--color-text-primary); + padding: 6px 12px; + border-radius: var(--radius-sm); + font-size: 0.8rem; + white-space: nowrap; + z-index: 1000; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + border: 1px solid var(--color-border); + pointer-events: none; + } +} \ No newline at end of file diff --git a/frontend/src/styles/Dashboard.css b/frontend/src/styles/Dashboard.css new file mode 100644 index 0000000..f6c8f8c --- /dev/null +++ b/frontend/src/styles/Dashboard.css @@ -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; + } +} diff --git a/frontend/src/styles/Layout.css b/frontend/src/styles/Layout.css new file mode 100644 index 0000000..3edf9d3 --- /dev/null +++ b/frontend/src/styles/Layout.css @@ -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; +} \ No newline at end of file diff --git a/frontend/src/styles/Login.css b/frontend/src/styles/Login.css new file mode 100644 index 0000000..a7299ae --- /dev/null +++ b/frontend/src/styles/Login.css @@ -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); +} diff --git a/frontend/src/styles/MobileHeader.css b/frontend/src/styles/MobileHeader.css new file mode 100644 index 0000000..d57c08a --- /dev/null +++ b/frontend/src/styles/MobileHeader.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/styles/Settings.css b/frontend/src/styles/Settings.css new file mode 100644 index 0000000..ed54e6f --- /dev/null +++ b/frontend/src/styles/Settings.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/styles/SettingsPage.css b/frontend/src/styles/SettingsPage.css new file mode 100644 index 0000000..0ebfeac --- /dev/null +++ b/frontend/src/styles/SettingsPage.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/styles/Sidebar.css b/frontend/src/styles/Sidebar.css new file mode 100644 index 0000000..79be01b --- /dev/null +++ b/frontend/src/styles/Sidebar.css @@ -0,0 +1,1021 @@ +.sidebar { + width: 260px; + height: calc(100vh - 2rem); + /* Floating height */ + max-height: calc(100vh - 2rem); + background: var(--color-bg-sidebar); + color: var(--color-text-sidebar); + display: flex; + flex-direction: column; + position: fixed; + left: 1rem; + /* Floating position */ + top: 1rem; + bottom: 1rem; + box-shadow: var(--shadow-xl); + /* Enhanced shadow for floating effect */ + z-index: 1000; + transition: width 0.3s ease, transform 0.3s ease; + overflow: hidden; + padding: 0; + margin: 0; + border-radius: var(--radius-lg); + /* Matched to page-tabs-slider */ + border: 1px solid var(--color-sidebar-border); + /* Subtle border */ +} + +.sidebar.clickable { + cursor: pointer; +} + +/* Dark mode overrides for softer shadow */ +[data-theme='dark'] .sidebar { + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.1); + /* Softer, more diffuse shadow */ +} + +.sidebar.collapsed { + width: 80px; + /* Slightly wider for better floating look */ +} + +/* Dynamic Sidebar Mode */ +.sidebar.dynamic.collapsed { + transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 1001; + /* Ensure it floats above content */ +} + +.sidebar.dynamic.collapsed.expanded-force { + width: 260px; +} + +/* Base state for content in dynamic collapsed mode (hidden) */ +/* Base state for content in dynamic collapsed mode (hidden) */ +/* Base state for content in dynamic collapsed mode (hidden) */ +.sidebar.dynamic.collapsed .sidebar-tagline, +.sidebar.dynamic.collapsed .nav-label, +.sidebar.dynamic.collapsed .user-name, +.sidebar.dynamic.collapsed .badge-compact, +.sidebar.dynamic.collapsed .nav-section-title, +.sidebar.dynamic.collapsed .sidebar-header h2, +.sidebar.dynamic.collapsed .view-mode-label, +.sidebar.dynamic.collapsed .chevron, +.sidebar.dynamic.collapsed .sidebar-title, +.sidebar.dynamic.collapsed .btn-collapse { + display: block; + /* Keep in layout but hidden */ + opacity: 0; + max-width: 0; + max-height: 0; + /* Collapse height to prevent vertical shifts */ + margin: 0; + /* Reset margins to prevent shifts */ + white-space: nowrap; + overflow: hidden; + transition: opacity 0.2s ease, max-width 0.3s cubic-bezier(0.4, 0, 0.2, 1), max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1), margin 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Hover state for content in dynamic collapsed mode (visible) */ +/* Hover state for content in dynamic collapsed mode (visible) */ +.sidebar.dynamic.collapsed.expanded-force .sidebar-tagline, +.sidebar.dynamic.collapsed.expanded-force .nav-label, +.sidebar.dynamic.collapsed.expanded-force .user-name, +.sidebar.dynamic.collapsed.expanded-force .badge-compact, +.sidebar.dynamic.collapsed.expanded-force .nav-section-title, +.sidebar.dynamic.collapsed.expanded-force .sidebar-header h2, +.sidebar.dynamic.collapsed.expanded-force .view-mode-label, +.sidebar.dynamic.collapsed.expanded-force .sidebar-title { + opacity: 1; + max-width: 200px; + max-height: 50px; + /* Restore height */ + /* Enough to show content */ + transition-delay: 0.05s; + /* Slight delay to match width expansion */ +} + +.sidebar.dynamic.collapsed.expanded-force .btn-collapse { + opacity: 1; + max-width: 40px; + /* Restore button width */ + max-height: 40px; + margin: 0; + transition-delay: 0.05s; +} + +.sidebar.dynamic.collapsed.expanded-force .chevron { + opacity: 1; + max-width: 24px; + margin-left: auto; + /* Restore margin for chevron */ + transition-delay: 0.05s; +} + +.sidebar.dynamic.collapsed.expanded-force .sidebar-header-content { + justify-content: space-between; + gap: 1rem; +} + +.sidebar.dynamic.collapsed.expanded-force .nav-item { + justify-content: flex-start; + padding: 0.75rem 1rem; + gap: 0.75rem; +} + +.sidebar.dynamic.collapsed.expanded-force .view-mode-toggle { + justify-content: flex-start; + padding: 0.75rem; + gap: 0.75rem; +} + +.sidebar.dynamic.collapsed.expanded-force .user-info-compact { + justify-content: flex-start; + padding: 0.5rem; + gap: 0.75rem; +} + +.sidebar.dynamic.collapsed.expanded-force .user-initial { + width: 28px; + height: 28px; + font-size: 0.85rem; +} + +.sidebar.dynamic.collapsed.expanded-force .sidebar-actions { + flex-direction: row; +} + +.sidebar.dynamic.collapsed.expanded-force .btn-icon { + flex: 1; + width: auto; +} + +/* Reset gaps in collapsed dynamic mode to prevent off-center icons */ +/* Reset gaps in collapsed dynamic mode to prevent off-center icons */ +.sidebar.dynamic.collapsed .nav-item, +.sidebar.dynamic.collapsed .view-mode-toggle, +.sidebar.dynamic.collapsed .user-info-compact { + gap: 0; + justify-content: center; + /* Disable transition for gap/padding when NOT hovering to prevent glitch on mode switch */ + transition: none; +} + +.sidebar.dynamic.collapsed .sidebar-header-content { + gap: 0; + justify-content: center; + transition: none; +} + +/* Match standard collapsed padding exactly */ +.sidebar.dynamic.collapsed .nav-item { + padding: 0.75rem 0.5rem; +} + +.sidebar.dynamic.collapsed .user-info-compact { + padding: 0.65rem 0.5rem; +} + +.sidebar.dynamic.collapsed .view-mode-toggle { + padding: 0.75rem; +} + +/* Re-enable transitions ONLY on hover or when forced expanded */ +.sidebar.dynamic.collapsed.expanded-force .nav-item, +.sidebar.dynamic.collapsed.expanded-force .view-mode-toggle, +.sidebar.dynamic.collapsed.expanded-force .user-info-compact, +.sidebar.dynamic.collapsed.expanded-force .sidebar-header-content { + transition: gap 0.3s cubic-bezier(0.4, 0, 0.2, 1), padding 0.3s cubic-bezier(0.4, 0, 0.2, 1), justify-content 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Hide toggle button in dynamic mode */ +.sidebar.dynamic .sidebar-toggle { + display: none !important; +} + +.sidebar.collapsed .sidebar-tagline, +.sidebar.collapsed .nav-label, +.sidebar.collapsed .user-name, +.sidebar.collapsed .badge-compact, +.sidebar.collapsed .nav-section-title { + display: none; +} + +.sidebar-header { + padding: 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + height: 70px; + display: flex; + align-items: center; + flex-shrink: 0; + position: relative; +} + +.sidebar-header-content { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + width: 100%; +} + +.sidebar.collapsed .sidebar-header-content { + justify-content: center; + gap: 0; + /* Ensure no gap affects centering */ +} + +.sidebar-title { + flex: 1; + min-width: 0; +} + +.sidebar-header h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--color-text-sidebar); +} + +.sidebar-tagline { + margin: 0.125rem 0 0 0; + font-size: 0.75rem; + opacity: 0.9; + transition: opacity 0.3s ease, width 0.3s ease; + white-space: nowrap; + color: var(--color-text-sidebar); +} + +/* Sidebar Logo */ +.sidebar-logo { + height: 32px; + width: auto; + flex-shrink: 0; + transition: height 0.3s ease; +} + +/* Logo centered when sidebar is collapsed */ +.sidebar.collapsed .sidebar-logo { + height: 28px; +} + +/* Dynamic mode logo handling */ +.sidebar.dynamic.collapsed .sidebar-logo { + height: 28px; +} + +.sidebar.dynamic.collapsed.expanded-force .sidebar-logo { + height: 32px; +} + +.btn-collapse { + background: rgba(255, 255, 255, 0.1); + border: none; + border-radius: var(--radius-md); + color: white; + cursor: pointer; + padding: 0.5rem; + transition: var(--transition); + min-width: var(--btn-icon-md); + height: var(--btn-icon-md); + display: flex; + align-items: center; + justify-content: center; +} + +.btn-collapse .material-symbols-outlined { + font-size: var(--icon-md); +} + +.btn-collapse:hover { + background: rgba(255, 255, 255, 0.2); +} + +.sidebar-nav { + flex: 1 1 auto; + overflow-y: auto; + overflow-x: hidden; + padding: 0.5rem 0; + display: flex; + flex-direction: column; + min-height: 0; +} + +.nav-section { + padding: 0 0.75rem; + /* Increased padding */ +} + +.nav-section-bottom { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.nav-section-title { + padding: 0.75rem 1rem; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 1px; + opacity: 0.9; + font-weight: 600; + color: var(--color-text-sidebar); +} + +.nav-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + /* Slightly larger padding */ + margin: 0.25rem 0; + color: var(--color-text-sidebar); + text-decoration: none; + border-radius: var(--radius-md); + transition: all 0.2s ease; + position: relative; + white-space: nowrap; + min-height: 44px; + /* Taller touch target */ +} + +.sidebar.collapsed .nav-item { + justify-content: center; + padding: 0.75rem 0.5rem; + gap: 0; + /* Ensure no gap affects centering */ +} + +.nav-item:hover { + background: rgba(255, 255, 255, 0.1); + color: var(--color-text-sidebar) !important; +} + +.nav-item:hover .nav-icon, +.nav-item:hover .nav-label { + color: var(--color-text-sidebar) !important; +} + +.sidebar:not(.collapsed) .nav-item:hover { + transform: translateX(4px); +} + +.nav-item.active { + background: var(--color-accent); + /* Use accent color for active state */ + font-weight: 600; + color: white !important; + box-shadow: var(--shadow-md); +} + +.nav-item.active .nav-icon, +.nav-item.active .nav-label { + color: white !important; +} + +.nav-item.active::before { + display: none; + /* Remove the side bar indicator */ +} + +.nav-icon { + font-size: 22px; + /* Slightly larger icons */ + line-height: 1; + min-width: 24px; + text-align: center; + display: flex; + align-items: center; + justify-content: center; +} + +.nav-label { + font-size: 0.95rem; + transition: opacity 0.3s ease, width 0.3s ease; +} + +.sidebar-footer { + padding: 0.75rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 0; + margin-top: auto; + /* Push to bottom */ + margin-left: 0; + margin-right: 0; + margin-bottom: 0; +} + +.sidebar-footer>*:not(:last-child) { + margin-bottom: 0.15rem; +} + +/* View Mode Toggle Button */ +.view-mode-toggle { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + background: transparent; + border: none; + border-radius: var(--radius-md); + color: var(--color-text-sidebar); + cursor: pointer; + transition: all 0.2s ease; + width: 100%; + font-size: 0.9rem; + font-weight: 500; +} + +.view-mode-toggle:hover { + background: rgba(255, 255, 255, 0.1); + color: var(--color-text-sidebar) !important; +} + +.view-mode-toggle .material-symbols-outlined { + font-size: var(--icon-md); + flex-shrink: 0; +} + +.view-mode-toggle .view-mode-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.view-mode-toggle.user-mode { + background: rgba(var(--color-accent-rgb), 0.2); + border-color: rgba(var(--color-accent-rgb), 0.3); +} + +.view-mode-toggle.user-mode:hover { + background: rgba(var(--color-accent-rgb), 0.3); + border-color: rgba(var(--color-accent-rgb), 0.4); +} + +.sidebar.collapsed .view-mode-toggle { + justify-content: center; + padding: 0.75rem; + gap: 0; + /* Ensure no gap affects centering */ +} + +.user-info-compact { + padding: 0.5rem; + background: rgba(255, 255, 255, 0.1); + border-radius: var(--radius-md); + margin: 0; + transition: padding 0.3s ease; + min-height: 48px; + display: flex; + flex-direction: row; + align-items: center; + gap: 0.75rem; +} + +.sidebar.dynamic.collapsed .user-info-compact { + transition: none; + /* Prevent padding animation on mode switch */ +} + +.sidebar.collapsed .user-info-compact { + padding: 0.65rem 0.5rem; + align-items: center; + justify-content: center; + height: 48px; + min-height: 48px; + gap: 0; + /* Ensure no gap affects centering */ +} + +.user-name { + display: block; + font-weight: 600; + font-size: 0.9rem; + transition: opacity 0.3s ease; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--color-text-sidebar); + flex: 1; + min-width: 0; +} + +.user-initial { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + margin: 0; + font-size: 0.85rem; + font-weight: 700; + background: rgba(255, 255, 255, 0.3); + border-radius: 50%; + flex-shrink: 0; + color: var(--color-text-sidebar); +} + +.sidebar.collapsed .user-initial { + width: 24px; + height: 24px; + font-size: 0.75rem; +} + +.badge-compact { + display: inline-block; + background: rgba(255, 255, 255, 0.35); + padding: 0.2rem 0.5rem; + border-radius: var(--radius-sm); + font-size: 0.7rem; + font-weight: 600; + transition: opacity 0.3s ease, width 0.3s ease; + color: var(--color-text-sidebar); +} + +.sidebar-actions { + display: flex; + gap: 0.15rem; + flex-wrap: nowrap; + margin: 0; +} + +.sidebar.collapsed .sidebar-actions { + flex-direction: column; + justify-content: center; + height: auto; + gap: 0.15rem; +} + +.btn-icon { + flex: 1; + padding: 0; + margin: 0; + background: rgba(255, 255, 255, 0.1); + border: none; + border-radius: var(--radius-md); + color: white; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + height: 40px; + min-width: 40px; +} + +.sidebar.collapsed .btn-icon { + flex: none; + width: 100%; + height: 40px; +} + +.btn-icon .material-symbols-outlined { + font-size: var(--icon-sm); +} + +.btn-icon:hover { + background: rgba(255, 255, 255, 0.2); + transform: translateY(-2px); +} + +/* Mobile sidebar - Reset to full screen */ +@media (max-width: 768px) { + .sidebar { + transform: translateX(-110%); + /* Ensure fully hidden */ + width: 280px; + z-index: 1001; + height: calc(100dvh - 2rem); + /* Floating height on mobile too */ + max-height: calc(100dvh - 2rem); + left: 1rem; + top: 1rem; + bottom: 1rem; + border-radius: var(--radius-lg); + /* Rounded on mobile */ + margin: 0; + background: var(--color-bg-sidebar); + /* Use theme color */ + backdrop-filter: blur(12px); + /* Blur effect */ + border: 1px solid var(--color-sidebar-border); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + } + + .sidebar-nav { + max-height: calc(100dvh - 180px); + } + + .sidebar.open { + transform: translateX(0); + } + + /* Reset ALL collapsed styles on mobile - sidebar should always be fully expanded when open */ + .sidebar.collapsed { + width: 280px; + } + + .sidebar.collapsed .sidebar-tagline, + .sidebar.collapsed .nav-label, + .sidebar.collapsed .user-name, + .sidebar.collapsed .badge-compact, + .sidebar.collapsed .nav-section-title { + display: block; + } + + .sidebar.collapsed .sidebar-header-content { + justify-content: space-between; + } + + .sidebar.collapsed .nav-item { + justify-content: flex-start; + padding: 0.75rem 1rem; + } + + .sidebar.collapsed .view-mode-toggle { + justify-content: flex-start; + } + + .sidebar.collapsed .user-info-compact { + justify-content: flex-start; + gap: 0.75rem; + } + + .sidebar.collapsed .user-initial { + width: 28px; + height: 28px; + font-size: 0.85rem; + } + + .sidebar.collapsed .sidebar-actions { + flex-direction: row; + } + + .sidebar.collapsed .btn-icon { + flex: 1; + width: auto; + } + + .sidebar-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.3); + /* Lighter overlay */ + backdrop-filter: blur(2px); + /* Blur the content behind */ + z-index: 1000; + visibility: hidden; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease, visibility 0.3s ease; + } + + .sidebar-overlay.visible { + visibility: visible; + opacity: 1; + pointer-events: auto; + } +} + +/* Scrollbar styling */ +.sidebar-nav::-webkit-scrollbar { + width: 6px; +} + +.sidebar-nav::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); +} + +.sidebar-nav::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; +} + +.sidebar-nav::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); +} + +/* User Menu Styles */ +.user-menu-container { + position: relative; + width: 100%; +} + +.user-menu-trigger { + width: 100%; + background: transparent; + border: none; + padding: 0; + cursor: pointer; + text-align: left; + border-radius: 8px; + transition: background 0.2s ease; +} + +.sidebar.dynamic.collapsed .user-menu-trigger { + padding: 0; + background: transparent; +} + +.user-menu-trigger:hover, +.user-menu-trigger.active { + background: rgba(255, 255, 255, 0.1); +} + +.sidebar.dynamic.collapsed .user-menu-trigger:hover, +.sidebar.dynamic.collapsed .user-menu-trigger.active { + background: transparent; + /* Let the inner user-info-compact handle hover */ +} + +.user-menu-trigger .user-info-compact { + background: transparent; + /* Override default */ +} + +.sidebar.dynamic.collapsed .user-menu-trigger .user-info-compact { + background: transparent; + /* Remove background as requested */ + padding: 0.65rem 0.5rem !important; + /* Keep padding for alignment */ +} + +.sidebar.dynamic.collapsed .user-menu-trigger .user-info-compact:hover { + background: rgba(255, 255, 255, 0.1); +} + +.user-menu-trigger .chevron { + margin-left: auto; + font-size: 1.2rem; + color: var(--color-text-sidebar); + /* Match sidebar icon color */ + transition: transform 0.2s ease; +} + +.user-menu-trigger.active .chevron { + transform: rotate(180deg); +} + +.user-menu-dropdown { + position: absolute; + left: 50%; + bottom: calc(100% + 8px); + transform: translateX(-50%); + transform-origin: bottom center; + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-xl); + z-index: 1100; + overflow: hidden; + animation: slideUpFade 0.25s cubic-bezier(0.4, 0, 0.2, 1); + width: 244px; + border-color: var(--color-border); +} + +.sidebar.collapsed .user-menu-dropdown { + position: fixed; + left: calc(80px + 2rem); + /* Sidebar width (80px) + Sidebar Left (1rem) + Gap (1rem) */ + bottom: 1rem; + /* Align with bottom of sidebar */ + top: auto; + transform: none; + transform-origin: left bottom; + animation: slideFromLeftFade 0.25s cubic-bezier(0.4, 0, 0.2, 1); + width: 244px; +} + +/* Reset dropdown to standard position when dynamic sidebar is expanded (hover or forced) */ +.sidebar.dynamic.collapsed.expanded-force .user-menu-dropdown { + position: absolute; + left: 50%; + bottom: calc(100% + 8px); + top: auto; + transform: translateX(-50%); + transform-origin: bottom center; + animation: slideUpFade 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes slideUpFade { + from { + opacity: 0; + transform: translateX(-50%) translateY(10px) scale(0.95); + } + + to { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); + } +} + +@keyframes slideFromLeftFade { + from { + opacity: 0; + transform: translateX(-10px) scale(0.95); + } + + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +/* Sidebar JS Tooltip */ +.sidebar-tooltip { + position: fixed; + transform: translateY(-50%); + background: var(--color-bg-card); + color: var(--color-text-primary); + padding: 0.5rem 0.75rem; + border-radius: var(--radius-md); + font-size: 0.85rem; + font-weight: 500; + white-space: nowrap; + box-shadow: var(--shadow-lg); + border: 1px solid var(--color-border); + z-index: 1100; + pointer-events: none; + animation: tooltipFadeIn 0.2s ease forwards; +} + +.sidebar-tooltip::before { + content: ''; + position: absolute; + left: -5px; + top: 50%; + transform: translateY(-50%); + border-width: 5px 5px 5px 0; + border-style: solid; + border-color: transparent var(--color-border) transparent transparent; +} + +.sidebar-tooltip::after { + content: ''; + position: absolute; + left: -4px; + top: 50%; + transform: translateY(-50%); + border-width: 5px 5px 5px 0; + border-style: solid; + border-color: transparent var(--color-bg-card) transparent transparent; +} + +@keyframes tooltipFadeIn { + from { + opacity: 0; + transform: translateY(-50%) translateX(-5px); + } + + to { + opacity: 1; + transform: translateY(-50%) translateX(0); + } +} + +.user-menu-header { + padding: 1rem; + display: flex; + align-items: center; + gap: 1rem; + background: var(--color-bg-elevated); +} + +.user-initial-large { + width: 48px; + height: 48px; + border-radius: 50%; + background: var(--color-accent); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + font-weight: 700; +} + +.user-details { + display: flex; + flex-direction: column; +} + +.user-name-large { + font-weight: 600; + font-size: 1rem; + color: var(--color-text-primary); +} + +.user-role { + font-size: 0.85rem; + color: var(--color-text-secondary); +} + +.user-menu-divider { + height: 1px; + background: var(--color-border); + margin: 0.25rem 0; +} + +.user-menu-nav { + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.user-menu-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + color: var(--color-text-primary); + text-decoration: none; + border-radius: var(--radius-md); + transition: all 0.2s ease; + background: transparent; + border: none; + width: 100%; + cursor: pointer; + font-size: 0.95rem; +} + +.user-menu-item .material-symbols-outlined { + font-size: 1.25rem; + color: var(--color-text-primary); + transition: color 0.2s ease; +} + +.user-menu-item:hover { + background: var(--color-bg-elevated); + color: var(--color-accent); +} + +.user-menu-item:hover .material-symbols-outlined { + color: var(--color-accent); +} + +/* Danger item (logout) */ +.user-menu-item.danger { + color: var(--color-error); +} + +.user-menu-item.danger .material-symbols-outlined { + color: var(--color-error); +} + +.user-menu-item.danger:hover { + background: rgba(220, 38, 38, 0.1); + color: var(--color-error); +} + +.user-menu-item.danger:hover .material-symbols-outlined { + color: var(--color-error); +} + +.user-menu-actions { + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +/* Mobile menu dropdown adjustments */ +@media (max-width: 768px) { + .user-menu-dropdown { + position: absolute; + left: 50%; + bottom: calc(100% + 8px); + transform: translateX(-50%); + transform-origin: bottom center; + animation: slideUpFade 0.25s cubic-bezier(0.4, 0, 0.2, 1); + width: 244px; + } + + .sidebar.open .user-menu-dropdown { + position: absolute; + left: 50%; + bottom: calc(100% + 8px); + transform: translateX(-50%); + transform-origin: bottom center; + animation: slideUpFade 0.25s cubic-bezier(0.4, 0, 0.2, 1); + } + + .sidebar.collapsed .user-menu-dropdown { + position: absolute; + left: 50%; + bottom: calc(100% + 8px); + transform: translateX(-50%); + transform-origin: bottom center; + animation: slideUpFade 0.25s cubic-bezier(0.4, 0, 0.2, 1); + } +} \ No newline at end of file diff --git a/frontend/src/styles/ThemeSettings.css b/frontend/src/styles/ThemeSettings.css new file mode 100644 index 0000000..d2b5f88 --- /dev/null +++ b/frontend/src/styles/ThemeSettings.css @@ -0,0 +1,1375 @@ +/* Theme Settings - Modern Layout */ + +/* Header & Tabs Restoration */ +.theme-header { + margin-bottom: 2rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--color-border); +} + +.theme-title { + font-size: 1.875rem; + font-weight: 700; + color: var(--color-text-primary); + margin: 0 0 0.5rem 0; +} + +.theme-subtitle { + font-size: 1rem; + color: var(--color-text-secondary); + margin: 0; +} + +.theme-tabs { + display: flex; + gap: 1rem; + margin-bottom: 2rem; + border-bottom: 1px solid var(--color-border); + padding-bottom: 1px; +} + +.theme-tab-btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--color-text-secondary); + font-weight: 500; + font-size: 0.95rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.theme-tab-btn:hover { + color: var(--color-text-primary); + background: var(--color-bg-elevated); + border-radius: var(--radius-md) var(--radius-md) 0 0; +} + +.theme-tab-btn.active { + color: var(--color-accent); + border-bottom-color: var(--color-accent); +} + +.theme-tab-btn i { + font-size: 1.1rem; +} + +/* Tab Content Wrapper */ +.theme-tab-content { + padding: var(--page-padding-y) var(--page-padding-x); + max-width: 1600px; + margin: 0 auto; + display: grid; + grid-template-rows: 1fr; + transition: grid-template-rows 0.25s ease; +} + +/* Smooth transition between tabs */ +.theme-tab-content>div { + opacity: 0; + animation: tabContentFadeIn 0.25s ease forwards; + min-height: 0; +} + +@keyframes tabContentFadeIn { + 0% { + opacity: 0; + transform: scale(0.98); + } + + 100% { + opacity: 1; + transform: none; + } +} + +/* Theme Section - Consistent spacing */ +.theme-section { + margin-bottom: 3rem; +} + +.theme-section:last-child { + margin-bottom: 0; +} + +/* Section Header - uses standard section-title from Layout.css */ + +/* ========== COLORS TAB ========== */ + +/* Enhanced Color Grid - 4 columns default */ +.color-grid-enhanced { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1.5rem; +} + +.color-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + padding: 1.5rem; + background: var(--color-bg-card); + border: 2px solid var(--color-border); + border-radius: var(--radius-lg); + cursor: pointer; + transition: all 0.25s ease; +} + +.color-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-lg); + border-color: var(--color-text-secondary); +} + +.color-card.active { + border-color: var(--color-accent); + box-shadow: 0 0 0 3px rgba(var(--color-accent-rgb), 0.2); +} + +.color-swatch-large { + width: 80px; + height: 80px; + border-radius: var(--radius-lg); + display: flex; + align-items: center; + justify-content: center; + box-shadow: var(--shadow-md); +} + +.color-swatch-large .material-symbols-outlined { + color: white; + font-size: 2rem; + font-weight: 700; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + -webkit-text-stroke: 1px rgba(0, 0, 0, 0.5); + paint-order: stroke fill; +} + +.color-info { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + text-align: center; +} + +.color-name { + font-weight: 600; + font-size: 1rem; + color: var(--color-text-primary); +} + +.color-description { + font-size: 0.875rem; + color: var(--color-text-secondary); +} + +/* ========== PALETTE GRID ========== */ + +.palette-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1.5rem; +} + +.palette-card { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.25rem; + background: var(--color-bg-card); + border: 2px solid var(--color-border); + border-radius: var(--radius-lg); + cursor: pointer; + transition: all 0.25s ease; +} + +.palette-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-lg); + border-color: var(--color-text-secondary); +} + +.palette-card.active { + border-color: var(--color-accent); + box-shadow: 0 0 0 3px rgba(var(--color-accent-rgb), 0.2); +} + +.palette-preview { + position: relative; + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px; + background: var(--color-bg-elevated); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); +} + +.palette-swatch-row { + display: flex; + gap: 4px; +} + +.palette-swatch { + flex: 1; + height: 24px; + border-radius: var(--radius-sm); + border: 1px solid rgba(0, 0, 0, 0.1); +} + +.palette-check { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border-radius: var(--radius-md); +} + +.palette-check .material-symbols-outlined { + color: white; + font-size: 1.5rem; + font-weight: 700; + background: var(--color-accent); + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.palette-info { + display: flex; + flex-direction: column; + gap: 0.25rem; + text-align: center; +} + +.palette-name { + font-weight: 600; + font-size: 1rem; + color: var(--color-text-primary); +} + +.palette-description { + font-size: 0.875rem; + color: var(--color-text-secondary); +} + +/* ========== APPEARANCE TAB ========== */ + +/* Appearance Grid - Stack sections vertically */ +.appearance-grid { + display: flex; + flex-direction: column; + gap: 3rem; +} + +/* Remove margin from sections inside appearance grid */ +.appearance-grid .theme-section { + margin-bottom: 0; + display: flex; + flex-direction: column; +} + +.appearance-grid .option-cards { + width: 100%; +} + +/* section-header uses standard margin from Layout.css */ + +/* Option Cards - Horizontal Row Layout */ +.option-cards { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 1rem; +} + +.option-card { + flex: 1; + display: flex; + align-items: center; + gap: 1rem; + padding: var(--card-padding); + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.25s ease; + min-width: 340px; + /* Prevent flex overflow */ +} + +.option-card:hover { + transform: translateX(4px); + border-color: var(--color-text-secondary); + box-shadow: var(--shadow-md); +} + +.option-card.active { + border-color: var(--color-accent); + background: rgba(var(--color-accent-rgb), 0.05); + box-shadow: 0 0 0 3px rgba(var(--color-accent-rgb), 0.15); +} + +.option-preview { + flex-shrink: 0; + width: 56px; + height: 56px; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-bg-elevated); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); +} + +.option-info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.option-name { + font-weight: 600; + font-size: 1.05rem; + color: var(--color-text-primary); +} + +.option-description { + font-size: 0.9rem; + color: var(--color-text-secondary); +} + +/* Border Radius Preview */ +.radius-preview-box { + width: 36px; + height: 36px; + background: var(--color-accent); + box-shadow: 0 2px 8px rgba(var(--color-accent-rgb), 0.3); +} + +/* Sidebar Preview */ +.sidebar-preview { + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + position: relative; + display: flex; + overflow: hidden; + border: 1px solid var(--color-border); +} + +.sidebar-part { + width: 30%; + height: 100%; + border-right: 1px solid var(--color-border); +} + +.content-part { + flex: 1; + height: 100%; +} + +.sidebar-preview-default .sidebar-part { + background: linear-gradient(160deg, var(--color-accent) 50%, #ffffff 50%); +} + +.sidebar-preview-default .content-part { + background: var(--color-bg-elevated); +} + +.sidebar-preview-dark .sidebar-part { + background: var(--color-accent); + border-right-color: var(--color-border); +} + +.sidebar-preview-dark .content-part { + background: var(--color-bg-elevated); +} + +.sidebar-preview-light .sidebar-part { + background: #ffffff; + border-right: 1px solid var(--color-border); +} + +.sidebar-preview-light .content-part { + background: var(--color-bg-elevated); +} + +/* Density Preview */ +.density-preview { + width: 36px; + height: 36px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; +} + +.density-line { + height: 3px; + background: var(--color-accent); + border-radius: 2px; +} + +.density-preview-compact .density-line { + height: 2px; +} + +.density-preview-compact { + gap: 3px; +} + +.density-preview-comfortable { + gap: 4px; +} + +.density-preview-spacious { + gap: 6px; +} + +.density-preview-spacious .density-line { + height: 4px; +} + +/* Font Preview */ +.font-preview { + font-size: 2.25rem; + font-weight: 600; + color: var(--color-accent); +} + +/* ========== PREVIEW TAB ========== */ + +/* Preview Container - Grid layout for preview cards */ +.preview-container { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 2rem; +} + +.preview-card { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1.25rem; + box-shadow: var(--shadow-md); +} + +.preview-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.preview-header h3 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: var(--color-text-primary); +} + +.preview-card p { + margin: 0; + color: var(--color-text-secondary); + line-height: 1.6; +} + +.preview-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.preview-inputs input { + width: 100%; + padding: 0.75rem 1rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-main); + color: var(--color-text-primary); + font-size: 0.95rem; + transition: border-color 0.2s ease; +} + +.preview-inputs input:focus { + outline: none; + border-color: var(--color-accent); +} + +/* Preview Stats */ +.preview-stats { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + padding-top: 0.5rem; +} + +.stat-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + padding: 1rem; + background: var(--color-bg-elevated); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); +} + +.stat-value { + font-size: 1.75rem; + font-weight: 700; + color: var(--color-accent); + line-height: 1; +} + +.stat-label { + font-size: 0.8rem; + color: var(--color-text-secondary); + text-align: center; +} + +/* Badge Styles */ +.badge { + padding: 0.35rem 0.75rem; + border-radius: var(--radius-md); + font-size: 0.8rem; + font-weight: 600; + white-space: nowrap; +} + +.badge-accent { + background: var(--color-accent); + color: white; +} + +.badge-success { + background: rgba(34, 197, 94, 0.15); + color: #16a34a; + border: 1px solid rgba(34, 197, 94, 0.3); +} + +/* Button Styles for Preview */ +.btn-primary { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.65rem 1.25rem; + background: var(--color-accent); + color: white; + border: none; + border-radius: var(--radius-md); + font-weight: 600; + font-size: 0.95rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-primary:hover { + opacity: 0.9; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(var(--color-accent-rgb), 0.3); +} + +.btn-ghost { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.65rem 1.25rem; + background: transparent; + color: var(--color-text-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-weight: 500; + font-size: 0.95rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-ghost:hover { + background: var(--color-bg-elevated); + border-color: var(--color-accent); +} + +/* ========== ADVANCED TAB ========== */ + +/* Advanced Colors Grid - 2 columns for Light/Dark themes */ +.advanced-colors-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 3rem; +} + +.color-theme-section { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.color-theme-title { + margin: 0; + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-primary); +} + +.color-controls-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.color-control-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.25rem; + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + transition: all 0.2s ease; +} + +.color-control-item:hover { + border-color: var(--color-text-secondary); + box-shadow: var(--shadow-sm); +} + +.color-control-label { + display: flex; + flex-direction: column; + gap: 0.25rem; + flex: 1; + cursor: pointer; +} + +.color-control-label>span:first-child { + font-weight: 500; + font-size: 0.95rem; + color: var(--color-text-primary); +} + +.color-value-display { + font-family: 'Courier New', monospace; + font-size: 0.85rem; + color: var(--color-text-secondary); +} + +.color-control-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +/* Color input group */ +.color-input-group { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.color-picker-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.color-picker-input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.btn-color-picker { + display: flex; + align-items: center; + justify-content: center; + width: 50px; + height: 40px; + border: 2px solid var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s ease; + position: relative; + overflow: hidden; + box-shadow: var(--shadow-sm); + /* Background color is set inline via style prop */ +} + +.btn-color-picker:hover { + border-color: var(--color-accent); + transform: scale(1.05); + box-shadow: var(--shadow-md); +} + +.color-hex-input { + width: 90px; + padding: 0.5rem 0.65rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-elevated); + color: var(--color-text-primary); + font-family: 'Courier New', monospace; + font-size: 0.875rem; + text-transform: uppercase; + transition: all 0.2s ease; +} + +.color-hex-input:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 2px rgba(var(--color-accent-rgb), 0.1); +} + +.btn-eyedropper { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background: var(--color-bg-elevated); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.btn-eyedropper:hover { + background: var(--color-accent); + border-color: var(--color-accent); + color: white; + transform: scale(1.1); +} + +.btn-eyedropper .material-symbols-outlined { + font-size: var(--icon-md); +} + +/* Eyedropper indicator */ +.eyedropper-indicator { + display: flex; + align-items: center; + justify-content: center; + color: var(--color-accent); + animation: pulse 1.5s ease-in-out infinite; +} + +.eyedropper-indicator .material-symbols-outlined { + font-size: var(--icon-md); +} + +@keyframes pulse { + + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + + 50% { + opacity: 0.6; + transform: scale(1.1); + } +} + +.color-control-item.eyedropper-active { + border-color: var(--color-accent); + background: rgba(var(--color-accent-rgb), 0.05); + box-shadow: 0 0 0 2px rgba(var(--color-accent-rgb), 0.2); +} + +/* Color Picker Popup */ +.color-picker-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +/* Color Picker Popup */ +.color-picker-popup { + background: var(--color-bg-card); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-xl); + padding: 1.75rem; + width: 380px; + /* Wider popup */ + max-width: 90vw; + animation: slideUp 0.2s ease; + border: 1px solid var(--color-border); +} + +/* ... (skip to chrome picker overrides) ... */ + +/* Saturation/brightness picker */ +.chrome-picker>div:first-child { + border-radius: var(--radius-md) !important; + overflow: hidden; + margin-bottom: 1.5rem !important; + padding-bottom: 60% !important; + /* Aspect ratio based height */ + height: 0 !important; + /* Reset fixed height */ +} + +/* Controls container (Hue, Alpha, Fields) */ +.chrome-picker>div:nth-child(2) { + display: none !important; + /* Hide ALL controls from ChromePicker since we use custom HuePicker */ +} + +/* Explicit Hue Picker Wrapper */ +.hue-picker-wrapper { + margin-top: 0; + position: relative; + height: 20px; + border-radius: var(--radius-md); + overflow: visible; + padding: 4px 0; +} + +/* Custom styling for the HuePicker component */ +.hue-picker-wrapper .hue-horizontal { + border-radius: var(--radius-md) !important; + height: 20px !important; +} + +.hue-picker-wrapper .hue-horizontal>div { + border-radius: var(--radius-md) !important; +} + +/* Make the pointer larger and more visible */ +.hue-picker-wrapper .hue-horizontal>div>div { + width: 24px !important; + height: 24px !important; + border-radius: 50% !important; + background: white !important; + border: 3px solid white !important; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.2) !important; + transform: translateX(-12px) translateY(-2px) !important; + cursor: pointer !important; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.color-picker-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--color-border); +} + +.color-picker-header h3 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--color-text-primary); +} + +.color-picker-header-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.btn-eyedropper-popup { + display: flex; + align-items: center; + justify-content: center; + width: var(--btn-icon-md); + height: var(--btn-icon-md); + background: var(--color-bg-elevated); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; + color: var(--color-text-secondary); + transition: all 0.2s ease; +} + +.btn-eyedropper-popup:hover { + background: var(--color-accent); + border-color: var(--color-accent); + color: white; + transform: scale(1.05); +} + +.btn-eyedropper-popup .material-symbols-outlined { + font-size: var(--icon-md); +} + +/* Visual feedback for unsupported eyedropper (optional) */ +@supports not ((-webkit-appearance: none) and (appearance: none)) { + .btn-eyedropper-popup { + opacity: 0.5; + cursor: not-allowed; + } +} + +.btn-close-picker { + display: flex; + align-items: center; + justify-content: center; + width: var(--btn-icon-md); + height: var(--btn-icon-md); + background: transparent; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; + color: var(--color-text-secondary); + transition: all 0.2s ease; +} + +.btn-close-picker:hover { + background: var(--color-bg-elevated); + border-color: var(--color-text-secondary); + color: var(--color-text-primary); +} + +.btn-close-picker .material-symbols-outlined { + font-size: var(--icon-md); +} + +/* Override react-color styles to match theme */ +.chrome-picker { + box-shadow: none !important; + font-family: inherit !important; + background: transparent !important; + border: none !important; + padding: 0 !important; + width: 100% !important; +} + +.chrome-picker-wrapper { + margin-top: 0.75rem; +} + +/* Saturation/brightness picker */ +.chrome-picker>div:first-child { + border-radius: var(--radius-md) !important; + overflow: hidden; + margin-bottom: 0 !important; + height: 200px !important; + /* Fixed large height */ + padding-bottom: 0 !important; +} + +/* Controls container (Hue, Alpha, Fields) */ +.chrome-picker>div:nth-child(2) { + display: flex !important; + flex-direction: column !important; + gap: 1rem !important; +} + +/* Hide the default fields (Hex input) */ +.chrome-picker>div:nth-child(2)>div:last-child { + display: none !important; +} + +/* Sliders container */ +.chrome-picker>div:nth-child(2)>div:first-child { + display: flex !important; + flex-direction: column !important; + gap: 1rem !important; + padding-right: 0 !important; + /* Remove default padding */ +} + +/* Hide the small preview swatch */ +.chrome-picker>div:nth-child(2)>div:first-child>div:first-child { + display: none !important; +} + +/* Hue Slider Container - Ensure it takes full width */ +.chrome-picker>div:nth-child(2)>div:first-child>div:nth-child(2) { + width: 100% !important; + margin: 0 !important; +} + +.chrome-picker .hue-horizontal { + border-radius: var(--radius-full) !important; + height: 16px !important; + margin-bottom: 0 !important; + display: block !important; + /* Ensure visibility */ +} + +/* Hue Slider Pointer */ +.chrome-picker .hue-horizontal>div>div { + width: 22px !important; + height: 22px !important; + border-radius: 50% !important; + background: white !important; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3) !important; + transform: translate(-11px, -3px) !important; +} + +/* Apply Button */ +.color-picker-actions { + margin-top: 1rem; + padding-top: 0.75rem; + border-top: 1px solid var(--color-border); +} + +.btn-full-width { + width: 100%; + justify-content: center; +} + +/* Custom Preview Section */ +.color-picker-content { + display: flex; + flex-direction: column; +} + +.color-picker-preview-section { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1rem; + background: var(--color-bg-elevated); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); +} + +.color-preview-box { + width: 48px; + height: 48px; + border-radius: var(--radius-md); + border: 1px solid rgba(0, 0, 0, 0.1); + box-shadow: var(--shadow-sm); +} + +.color-preview-info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.color-preview-hex { + font-family: 'Courier New', monospace; + font-size: 1.1rem; + font-weight: 700; + color: var(--color-text-primary); + letter-spacing: 0.5px; +} + +.color-preview-label { + font-size: 0.8rem; + color: var(--color-text-secondary); +} + +/* Remove the inner circle/pointer from color swatch */ +.chrome-picker>div:nth-child(2)>div:first-child>div>div { + display: none !important; +} + +/* Hide all labels including HEX */ +.chrome-picker label { + display: none !important; +} + +/* Style HEX input field - taller */ +.chrome-picker input { + background: var(--color-bg-elevated) !important; + color: var(--color-text-primary) !important; + border: 1px solid var(--color-border) !important; + border-radius: var(--radius-md) !important; + font-family: 'Courier New', monospace !important; + box-shadow: none !important; + padding: 0.85rem 1rem !important; + font-size: 1rem !important; + text-align: center !important; + transition: border-color 0.2s ease !important; + text-transform: uppercase !important; +} + +.chrome-picker input:hover { + border-color: var(--color-text-secondary) !important; +} + +.chrome-picker input:focus { + border-color: var(--color-accent) !important; + outline: none !important; + box-shadow: 0 0 0 3px rgba(var(--color-accent-rgb), 0.15) !important; +} + +/* Hide RGB and HSL - keep only HEX */ +.chrome-picker>div:nth-child(2)>div:nth-child(2)>div:nth-child(2), +.chrome-picker>div:nth-child(2)>div:nth-child(2)>div:nth-child(3) { + display: none !important; +} + +/* ========== RESPONSIVE DESIGN ========== */ + +/* Tablet - 2 columns for colors, 1 column for appearance */ +@media (max-width: 1024px) { + .theme-tab-content { + padding: var(--page-padding-y-tablet) var(--page-padding-x-tablet); + } + + .color-grid-enhanced { + grid-template-columns: repeat(3, 1fr); + gap: 1.25rem; + } + + .palette-grid { + grid-template-columns: repeat(3, 1fr); + gap: 1.25rem; + } + + .appearance-grid { + gap: 2rem; + } + + .advanced-colors-grid { + grid-template-columns: 1fr; + gap: 2rem; + } + +} + +/* Mobile - Single column for everything */ +@media (max-width: 768px) { + .theme-tab-content { + padding: var(--page-padding-y-mobile) var(--page-padding-x-mobile); + } + + .theme-section { + margin-bottom: 2rem; + } + + /* Appearance Grid Mobile Overrides */ + .appearance-grid { + gap: 2rem; + } + + .appearance-grid .theme-section { + margin-bottom: 0; + } + + .color-grid-enhanced { + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + } + + .palette-grid { + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + } + + .palette-card { + padding: 1rem; + } + + .palette-swatch { + height: 18px; + } + + .color-card { + padding: 1.25rem; + } + + .color-swatch-large { + width: 52px; + height: 52px; + } + + .color-swatch-large .material-symbols-outlined { + font-size: 1.5rem; + } + + .option-card { + padding: 1rem 1.25rem; + gap: 1rem; + min-width: 0; + width: 100%; + } + + .option-cards { + flex-direction: column; + width: 100%; + } + + .option-preview { + width: 40px; + height: 40px; + } + + .sidebar-preview, + .sidebar-mode-preview, + .density-preview { + width: 28px; + height: 28px; + } + + .radius-preview-box { + width: 28px; + height: 28px; + } + + .sidebar-mode-preview { + padding: 4px; + gap: 3px; + } + + .sidebar-mode-preview .sidebar-line { + height: 3px; + } + + .density-preview { + gap: 2px; + } + + .density-line { + height: 2px; + } + + .font-preview { + font-size: 1.75rem; + } + + .preview-container { + grid-template-columns: 1fr; + gap: 1.5rem; + } + + .preview-actions { + flex-direction: column; + } + + .btn-primary, + .btn-ghost { + width: 100%; + justify-content: center; + } + + .color-control-item { + flex-direction: column; + align-items: stretch; + gap: 0.75rem; + padding: 1rem; + } + + .color-control-actions { + justify-content: space-between; + } + + .color-input-group { + flex: 1; + } + + .btn-color-picker { + width: 44px; + height: 36px; + } + + .color-hex-input { + width: 100%; + max-width: 120px; + } + + .btn-eyedropper { + width: var(--btn-icon-md); + height: var(--btn-icon-md); + } +} + +/* Very small screens */ +@media (max-width: 480px) { + .theme-tab-content { + padding: 0.75rem; + } +} + +/* Sidebar Mode Preview */ +.sidebar-mode-preview { + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + position: relative; + background: var(--color-bg-elevated); + border: 1px solid var(--color-border); + display: flex; + flex-direction: column; + justify-content: center; + gap: 4px; + padding: 6px; +} + +.sidebar-mode-preview .sidebar-line { + height: 4px; + background: var(--color-accent); + border-radius: 2px; + transition: all 0.3s ease; +} + +.sidebar-mode-collapsed .sidebar-line { + width: 8px; +} + +.sidebar-mode-expanded .sidebar-line { + width: 100%; +} + +.sidebar-mode-toggle .sidebar-line:first-child { + width: 100%; +} + +.sidebar-mode-toggle .sidebar-line:last-child { + width: 12px; +} + +.sidebar-mode-dynamic .sidebar-line { + width: 8px; + transition: width 0.3s ease; +} + +.option-card:hover .sidebar-mode-dynamic .sidebar-line { + width: 100%; +} \ No newline at end of file diff --git a/frontend/src/styles/Users.css b/frontend/src/styles/Users.css new file mode 100644 index 0000000..1f88af9 --- /dev/null +++ b/frontend/src/styles/Users.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/styles/theme/README.md b/frontend/src/styles/theme/README.md new file mode 100644 index 0000000..2cc2d28 --- /dev/null +++ b/frontend/src/styles/theme/README.md @@ -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) diff --git a/frontend/src/styles/theme/colors.css b/frontend/src/styles/theme/colors.css new file mode 100644 index 0000000..0e0fcff --- /dev/null +++ b/frontend/src/styles/theme/colors.css @@ -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; +} \ No newline at end of file diff --git a/frontend/src/styles/theme/dimensions.css b/frontend/src/styles/theme/dimensions.css new file mode 100644 index 0000000..673526c --- /dev/null +++ b/frontend/src/styles/theme/dimensions.css @@ -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; +} \ No newline at end of file diff --git a/frontend/src/styles/theme/effects.css b/frontend/src/styles/theme/effects.css new file mode 100644 index 0000000..cb96955 --- /dev/null +++ b/frontend/src/styles/theme/effects.css @@ -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); +} diff --git a/frontend/src/styles/theme/index.css b/frontend/src/styles/theme/index.css new file mode 100644 index 0000000..d01ee40 --- /dev/null +++ b/frontend/src/styles/theme/index.css @@ -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); +} \ No newline at end of file diff --git a/frontend/src/styles/theme/typography.css b/frontend/src/styles/theme/typography.css new file mode 100644 index 0000000..a925ca7 --- /dev/null +++ b/frontend/src/styles/theme/typography.css @@ -0,0 +1,42 @@ +/* ========================================== + TYPOGRAPHY + ========================================== */ + +:root { + /* Font Families */ + --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + --font-mono: 'Fira Code', 'Courier New', monospace; + + /* Font Sizes */ + --text-xs: 0.7rem; /* 11.2px */ + --text-sm: 0.75rem; /* 12px */ + --text-base: 0.875rem; /* 14px */ + --text-md: 0.95rem; /* 15.2px */ + --text-lg: 1rem; /* 16px */ + --text-xl: 1.125rem; /* 18px */ + --text-2xl: 1.25rem; /* 20px */ + --text-3xl: 1.5rem; /* 24px */ + --text-4xl: 2rem; /* 32px */ + --text-5xl: 2.5rem; /* 40px */ + + /* Font Weights */ + --weight-normal: 400; + --weight-medium: 500; + --weight-semibold: 600; + --weight-bold: 700; + + /* Line Heights */ + --leading-none: 1; + --leading-tight: 1.25; + --leading-normal: 1.5; + --leading-relaxed: 1.75; + --leading-loose: 2; + + /* Letter Spacing */ + --tracking-tight: -0.025em; + --tracking-normal: 0; + --tracking-wide: 0.025em; + --tracking-wider: 0.05em; + --tracking-widest: 0.1em; +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..1049549 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,56 @@ +export type UserPermissions = Record; + +export interface User { + id: string; + username: string; + email: string; + is_active: boolean; + is_superuser: boolean; + permissions: UserPermissions; + created_at: string; + updated_at: string; + last_login: string | null; +} + +export interface LoginRequest { + username: string; + password: string; +} + +export interface RegisterRequest { + username: string; + email: string; + password: string; +} + +export interface UserCreate { + username: string; + email: string; + password: string; + is_active?: boolean; + is_superuser?: boolean; + permissions?: UserPermissions; +} + +export interface UserUpdatePayload { + username?: string; + email?: string; + password?: string; + is_active?: boolean; + is_superuser?: boolean; + permissions?: UserPermissions; +} + +export interface Token { + access_token: string; + token_type: string; +} + +export interface AuthContextType { + user: User | null; + token: string | null; + login: (username: string, password: string) => Promise; + register: (username: string, email: string, password: string) => Promise; + logout: () => void; + isLoading: boolean; +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..a9b5a59 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..d9b0b63 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + host: '0.0.0.0', + port: 5173, + allowedHosts: [ + 'perro.montecalvo.me', + 'localhost', + '127.0.0.1', + ], + proxy: { + '/api': { + target: 'http://localhost:5174', + changeOrigin: true, + }, + }, + }, +})