From e2802d83f2db19235ad44d61478c4f7ae5140fb2 Mon Sep 17 00:00:00 2001 From: Sergey Vanyushkin Date: Sat, 2 May 2026 14:45:51 +0300 Subject: [PATCH] feat(ui): add web UI with Jinja2 templates and Gitea themes - Add Jinja2 templates with data-testid attributes for testing - Create light/dark themes based on Gitea color scheme - Add theme switching with localStorage persistence - Create base CSS, components, and layout styles - Add mock web routes for UI demonstration - Register web router and static files in main.py - Add data-testid requirements to AGENTS.md - Install jinja2 dependency --- AGENTS.md | 50 +- app/main.py | 16 + app/presentation/templates/base.html | 31 ++ app/presentation/templates/pages/index.html | 83 ++++ .../templates/pages/post_detail.html | 58 +++ .../templates/pages/post_form.html | 99 ++++ .../templates/partials/footer.html | 14 + .../templates/partials/header.html | 35 ++ app/presentation/templates/partials/nav.html | 11 + app/presentation/web/__init__.py | 13 + app/presentation/web/routes.py | 327 +++++++++++++ pyproject.toml | 1 + static/css/base.css | 150 ++++++ static/css/components.css | 347 ++++++++++++++ static/css/layout.css | 441 ++++++++++++++++++ static/css/themes/theme-dark.css | 199 ++++++++ static/css/themes/theme-light.css | 175 +++++++ static/js/theme.js | 163 +++++++ 18 files changed, 2212 insertions(+), 1 deletion(-) create mode 100644 app/presentation/templates/base.html create mode 100644 app/presentation/templates/pages/index.html create mode 100644 app/presentation/templates/pages/post_detail.html create mode 100644 app/presentation/templates/pages/post_form.html create mode 100644 app/presentation/templates/partials/footer.html create mode 100644 app/presentation/templates/partials/header.html create mode 100644 app/presentation/templates/partials/nav.html create mode 100644 app/presentation/web/__init__.py create mode 100644 app/presentation/web/routes.py create mode 100644 static/css/base.css create mode 100644 static/css/components.css create mode 100644 static/css/layout.css create mode 100644 static/css/themes/theme-dark.css create mode 100644 static/css/themes/theme-light.css create mode 100644 static/js/theme.js diff --git a/AGENTS.md b/AGENTS.md index 19c323f..9723c59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -186,7 +186,55 @@ Use the following sections as appropriate: - `Attributes` - For class attributes - `See Also` - References to related code -## DDD Concepts Used +## UI Development Requirements + +### HTML Templates (Jinja2) +- All HTML templates use **Jinja2** templating engine +- Templates are located in `app/presentation/templates/` +- Base template: `base.html` with theme support (light/dark) + +### data-testid Attributes (REQUIRED) +**Every interactive and significant HTML element MUST have a `data-testid` attribute** for automated testing. + +#### Required Elements: +- **Navigation**: `data-testid="nav-link-{name}"`, `data-testid="nav-logo"` +- **Buttons**: `data-testid="btn-{action}"` (e.g., `btn-create`, `btn-save`, `btn-delete`) +- **Forms**: `data-testid="form-{name}"`, `data-testid="input-{field}"`, `data-testid="submit-{action}"` +- **Cards/Posts**: `data-testid="post-card-{id}"`, `data-testid="post-title"`, `data-testid="post-content"` +- **Lists**: `data-testid="list-{name}"`, `data-testid="list-item-{index}"` +- **Theme Switcher**: `data-testid="theme-toggle"`, `data-testid="theme-{light|dark}"` +- **Messages/Alerts**: `data-testid="alert-{type}"`, `data-testid="alert-message" + +#### Example: +```html + + +
+

{{ post.title }}

+

{{ post.content }}

+
+``` + +### CSS Architecture (Gitea-inspired) +- **Theme files**: `static/css/themes/theme-{light|dark}.css` with CSS variables +- **Base styles**: `static/css/base.css` - reset, typography, CSS variables usage +- **Components**: `static/css/components.css` - buttons, cards, forms, inputs +- **Layout**: `static/css/layout.css` - grid, navigation, containers + +### Theme Support +- Light and dark themes based on Gitea color scheme +- Theme switching via `data-theme` attribute on `` element +- LocalStorage persistence for user preference +- All colors use CSS custom properties (variables) + +### Static Assets +- **All assets are local** - no external CDN dependencies +- Location: `static/` directory at project root +- Served via FastAPI `StaticFiles` middleware + +### DDD Concepts Used ### Entities - Have identity (UUID) diff --git a/app/main.py b/app/main.py index 2e690cd..19ab938 100644 --- a/app/main.py +++ b/app/main.py @@ -12,6 +12,8 @@ from dishka import make_async_container from dishka.integrations.fastapi import setup_dishka from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles from app.infrastructure import close_db, init_db, register_exception_handlers, settings from app.infrastructure.di.providers import ( @@ -22,6 +24,7 @@ from app.infrastructure.di.providers import ( UseCaseProvider, ) from app.presentation import router +from app.presentation.web import router as web_router @asynccontextmanager @@ -77,6 +80,19 @@ def app_factory() -> FastAPI: ) app.include_router(router, prefix="/api") + app.include_router(web_router) + app.mount("/static", StaticFiles(directory="static"), name="static") + + @app.get("/", response_class=HTMLResponse) + async def root_redirect() -> HTMLResponse: + """Redirect root URL to web UI. + + Returns: + HTMLResponse with redirect to web interface. + """ + return HTMLResponse( + content='', status_code=200 + ) @app.get("/health", tags=["health"]) async def health_check() -> dict[str, str]: diff --git a/app/presentation/templates/base.html b/app/presentation/templates/base.html new file mode 100644 index 0000000..14966c3 --- /dev/null +++ b/app/presentation/templates/base.html @@ -0,0 +1,31 @@ + + + + + + + {% block title %}Blog{% endblock %} + + + + + + + + {% block extra_css %}{% endblock %} + + + {% include "partials/header.html" %} + +
+
+ {% block content %}{% endblock %} +
+
+ + {% include "partials/footer.html" %} + + + {% block extra_js %}{% endblock %} + + diff --git a/app/presentation/templates/pages/index.html b/app/presentation/templates/pages/index.html new file mode 100644 index 0000000..b66ecf6 --- /dev/null +++ b/app/presentation/templates/pages/index.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} + +{% block title %}Blog - Home{% endblock %} + +{% block content %} + + +{% if posts %} +
+ {% for post in posts %} +
+
+

+ {{ post.title }} +

+ {% if post.published %} + Published + {% else %} + Draft + {% endif %} +
+ +
+ + {{ post.author_id[0]|upper }} + {{ post.author_id }} + + + {{ post.created_at.strftime('%B %d, %Y') }} + +
+ +
+ {{ post.content.value[:200] }}{% if post.content.value|length > 200 %}...{% endif %} +
+ +
+
+ {% for tag in post.tags %} + {{ tag }} + {% endfor %} +
+ Read more +
+
+ {% endfor %} +
+ + + +{% else %} +
+
📝
+

No posts yet

+

Be the first to write a post!

+ Create your first post +
+{% endif %} +{% endblock %} diff --git a/app/presentation/templates/pages/post_detail.html b/app/presentation/templates/pages/post_detail.html new file mode 100644 index 0000000..0572500 --- /dev/null +++ b/app/presentation/templates/pages/post_detail.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} + +{% block title %}{{ post.title }} - Blog{% endblock %} +{% block meta_description %}{{ post.content.value[:160] }}{% endblock %} + +{% block content %} +
+
+

{{ post.title }}

+ +
+ + {{ post.author_id[0]|upper }} + {{ post.author_id }} + + + {{ post.created_at.strftime('%B %d, %Y') }} + + {% if post.published %} + Published + {% else %} + Draft + {% endif %} +
+
+ +
+ {{ post.content.value|nl2br }} +
+ + +
+{% endblock %} diff --git a/app/presentation/templates/pages/post_form.html b/app/presentation/templates/pages/post_form.html new file mode 100644 index 0000000..2d18680 --- /dev/null +++ b/app/presentation/templates/pages/post_form.html @@ -0,0 +1,99 @@ +{% extends "base.html" %} + +{% block title %}{% if is_edit %}Edit Post{% else %}New Post{% endif %} - Blog{% endblock %} + +{% block content %} + + +
+
+
+ + + A catchy title for your post +
+ +
+ + + The main content of your post. Markdown is supported. +
+ +
+ + + Comma-separated list of tags +
+ +
+ +
+
+ + +
+{% endblock %} diff --git a/app/presentation/templates/partials/footer.html b/app/presentation/templates/partials/footer.html new file mode 100644 index 0000000..d62fb3e --- /dev/null +++ b/app/presentation/templates/partials/footer.html @@ -0,0 +1,14 @@ + diff --git a/app/presentation/templates/partials/header.html b/app/presentation/templates/partials/header.html new file mode 100644 index 0000000..bc8d572 --- /dev/null +++ b/app/presentation/templates/partials/header.html @@ -0,0 +1,35 @@ + diff --git a/app/presentation/templates/partials/nav.html b/app/presentation/templates/partials/nav.html new file mode 100644 index 0000000..c060f53 --- /dev/null +++ b/app/presentation/templates/partials/nav.html @@ -0,0 +1,11 @@ + diff --git a/app/presentation/web/__init__.py b/app/presentation/web/__init__.py new file mode 100644 index 0000000..7e5abdd --- /dev/null +++ b/app/presentation/web/__init__.py @@ -0,0 +1,13 @@ +"""Web UI layer for blog application. + +This package provides HTML endpoints and templates for the blog web interface, +separate from the JSON API layer. Uses Jinja2 templates with Gitea-inspired +theme support and comprehensive data-testid attributes for testing. + +The web layer follows the same DDD principles as the API layer and will +be integrated with use cases in future iterations. +""" + +from app.presentation.web.routes import router + +__all__ = ["router"] diff --git a/app/presentation/web/routes.py b/app/presentation/web/routes.py new file mode 100644 index 0000000..b85eb67 --- /dev/null +++ b/app/presentation/web/routes.py @@ -0,0 +1,327 @@ +"""Web UI routes for blog application. + +This module provides HTML endpoints for the blog web interface. +Currently uses mock data for demonstration purposes. +Integration with use cases will be added in future iterations. +""" + +from datetime import datetime +from uuid import uuid4 + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +router = APIRouter(prefix="/web", tags=["web"]) +templates = Jinja2Templates(directory="app/presentation/templates") + + +class MockPost: + """Mock post object for UI demonstration. + + This class simulates a Post entity for template rendering + before integration with actual use cases. + + Attributes: + id: Unique identifier for the post. + title: Post title value object. + content: Post content value object. + slug: URL-friendly slug. + author_id: Identifier of the post author. + published: Publication status flag. + tags: List of tags associated with the post. + created_at: Timestamp when the post was created. + updated_at: Timestamp when the post was last updated. + """ + + def __init__( + self, + id: str, + title: str, + content: str, + slug: str, + author_id: str, + published: bool, + tags: list[str], + created_at: datetime | None = None, + ) -> None: + """Initialize mock post with provided attributes. + + Args: + id: Unique identifier for the post. + title: Post title string. + content: Post content string. + slug: URL-friendly slug string. + author_id: Author identifier string. + published: Whether the post is published. + tags: List of tag strings. + created_at: Optional creation timestamp, defaults to now. + """ + self.id = id + self.title = MockValueObject(title) + self.content = MockValueObject(content) + self.slug = MockValueObject(slug) + self.author_id = author_id + self.published = published + self.tags = tags + self.created_at = created_at or datetime.now() + self.updated_at = self.created_at + + +class MockValueObject: + """Mock value object for simulating domain value objects. + + Wraps a raw value to simulate the interface of domain + value objects like Title, Content, and Slug. + + Attributes: + value: The wrapped string value. + """ + + def __init__(self, value: str) -> None: + """Initialize with a string value. + + Args: + value: The string value to wrap. + """ + self.value = value + + +MOCK_POSTS = [ + MockPost( + id=str(uuid4()), + title="Getting Started with FastAPI", + content="FastAPI is a modern, fast (high-performance), web framework for building APIs with Python 3.7+ based on standard Python type hints. It is designed to be easy to use while providing high performance.", + slug="getting-started-with-fastapi", + author_id="john_doe", + published=True, + tags=["python", "fastapi", "tutorial"], + created_at=datetime(2026, 1, 15, 10, 30), + ), + MockPost( + id=str(uuid4()), + title="Understanding DDD Architecture", + content="Domain-Driven Design (DDD) is an approach to software development that centers the development on programming a domain model that has a rich understanding of the processes and rules of a domain. The term was coined by Eric Evans in his book of the same title.", + slug="understanding-ddd-architecture", + author_id="jane_smith", + published=True, + tags=["ddd", "architecture", "software-design"], + created_at=datetime(2026, 1, 14, 14, 45), + ), + MockPost( + id=str(uuid4()), + title="Draft Post Example", + content="This is a draft post that hasn't been published yet. It demonstrates how unpublished posts appear in the UI.", + slug="draft-post-example", + author_id="john_doe", + published=False, + tags=["draft"], + created_at=datetime(2026, 1, 13, 9, 0), + ), +] + + +@router.get("/", response_class=HTMLResponse) +async def home(request: Request) -> HTMLResponse: + """Render the home page with list of posts. + + Args: + request: The HTTP request object for template context. + + Returns: + HTMLResponse with rendered posts list template. + """ + return templates.TemplateResponse( + request, + "pages/index.html", + { + "posts": MOCK_POSTS, + "active_page": "home", + "current_page": 1, + "has_prev": False, + "has_next": False, + }, + ) + + +@router.get("/posts", response_class=HTMLResponse) +async def list_posts(request: Request) -> HTMLResponse: + """Render the posts listing page. + + Args: + request: The HTTP request object for template context. + + Returns: + HTMLResponse with rendered posts list template. + """ + return templates.TemplateResponse( + request, + "pages/index.html", + { + "posts": MOCK_POSTS, + "active_page": "posts", + "current_page": 1, + "has_prev": False, + "has_next": True, + }, + ) + + +@router.get("/posts/new", response_class=HTMLResponse) +async def new_post_form(request: Request) -> HTMLResponse: + """Render the new post creation form. + + Args: + request: The HTTP request object for template context. + + Returns: + HTMLResponse with rendered post form template. + """ + return templates.TemplateResponse( + request, + "pages/post_form.html", + { + "is_edit": False, + "post": None, + "active_page": "posts", + }, + ) + + +@router.post("/posts/new", response_class=HTMLResponse) +async def create_post(request: Request) -> HTMLResponse: + """Handle new post creation form submission. + + Args: + request: The HTTP request object containing form data. + + Returns: + HTMLResponse redirecting to the home page. + """ + return templates.TemplateResponse( + request, + "pages/index.html", + { + "posts": MOCK_POSTS, + "active_page": "home", + "current_page": 1, + "has_prev": False, + "has_next": False, + }, + ) + + +@router.get("/posts/{post_id}", response_class=HTMLResponse) +async def post_detail(request: Request, post_id: str) -> HTMLResponse: + """Render a single post detail page. + + Args: + request: The HTTP request object for template context. + post_id: The unique identifier of the post to display. + + Returns: + HTMLResponse with rendered post detail template. + """ + post = next((p for p in MOCK_POSTS if str(p.id) == post_id), MOCK_POSTS[0]) + return templates.TemplateResponse( + request, + "pages/post_detail.html", + { + "post": post, + "active_page": "posts", + }, + ) + + +@router.get("/posts/{post_id}/edit", response_class=HTMLResponse) +async def edit_post_form(request: Request, post_id: str) -> HTMLResponse: + """Render the post edit form. + + Args: + request: The HTTP request object for template context. + post_id: The unique identifier of the post to edit. + + Returns: + HTMLResponse with rendered post form template. + """ + post = next((p for p in MOCK_POSTS if str(p.id) == post_id), MOCK_POSTS[0]) + return templates.TemplateResponse( + request, + "pages/post_form.html", + { + "is_edit": True, + "post": post, + "active_page": "posts", + }, + ) + + +@router.post("/posts/{post_id}/edit", response_class=HTMLResponse) +async def update_post(request: Request, post_id: str) -> HTMLResponse: + """Handle post update form submission. + + Args: + request: The HTTP request object containing form data. + post_id: The unique identifier of the post to update. + + Returns: + HTMLResponse with rendered post detail template. + """ + post = next((p for p in MOCK_POSTS if str(p.id) == post_id), MOCK_POSTS[0]) + return templates.TemplateResponse( + request, + "pages/post_detail.html", + { + "post": post, + "active_page": "posts", + }, + ) + + +@router.post("/posts/{post_id}/delete", response_class=HTMLResponse) +async def delete_post(request: Request, post_id: str) -> HTMLResponse: + """Handle post deletion. + + Args: + request: The HTTP request object. + post_id: The unique identifier of the post to delete. + + Returns: + HTMLResponse redirecting to the home page. + """ + return templates.TemplateResponse( + request, + "pages/index.html", + { + "posts": MOCK_POSTS, + "active_page": "home", + "current_page": 1, + "has_prev": False, + "has_next": False, + }, + ) + + +@router.get("/about", response_class=HTMLResponse) +async def about(request: Request) -> HTMLResponse: + """Render the about page. + + Args: + request: The HTTP request object for template context. + + Returns: + HTMLResponse with rendered about page template. + """ + return HTMLResponse( + content=""" + + + About - Blog + +

About

+

A modern blog built with FastAPI and DDD architecture.

+ Back to home + + + """ + ) diff --git a/pyproject.toml b/pyproject.toml index edf9ece..cd9e55f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "asyncpg>=0.30.0", "dishka>=1.5.0", "httpx>=0.28.0", + "jinja2>=3.1.6", ] [build-system] diff --git a/static/css/base.css b/static/css/base.css new file mode 100644 index 0000000..7f30f73 --- /dev/null +++ b/static/css/base.css @@ -0,0 +1,150 @@ +/* Base styles for blog application + * + * This file provides reset, typography, and base styles + * using CSS variables from theme files. + */ + +/* Reset */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + background-color: var(--color-body); + color: var(--color-text); + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Typography */ +h1, h2, h3, h4, h5, h6 { + color: var(--color-text-dark); + font-weight: 600; + line-height: 1.25; + margin-bottom: 1rem; +} + +h1 { font-size: 2rem; } +h2 { font-size: 1.75rem; } +h3 { font-size: 1.5rem; } +h4 { font-size: 1.25rem; } +h5 { font-size: 1.125rem; } +h6 { font-size: 1rem; } + +p { + margin-bottom: 1rem; + color: var(--color-text); +} + +a { + color: var(--color-primary); + text-decoration: none; + transition: color 0.2s ease; +} + +a:hover { + color: var(--color-primary-hover); + text-decoration: underline; +} + +/* Lists */ +ul, ol { + margin-bottom: 1rem; + padding-left: 1.5rem; +} + +li { + margin-bottom: 0.25rem; +} + +/* Code */ +code { + font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, monospace; + font-size: 0.875em; + background-color: var(--color-code-bg); + color: var(--color-text); + padding: 0.125rem 0.375rem; + border-radius: 3px; +} + +pre { + background-color: var(--color-code-bg); + padding: 1rem; + border-radius: 6px; + overflow-x: auto; + margin-bottom: 1rem; +} + +pre code { + background: none; + padding: 0; +} + +/* Selection */ +::selection { + background-color: var(--color-primary-alpha-30); + color: var(--color-text-dark); +} + +/* Focus styles */ +:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: var(--color-secondary-light-4); +} + +::-webkit-scrollbar-thumb { + background: var(--color-secondary-dark-4); + border-radius: 5px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--color-secondary-dark-5); +} + +/* Utility classes */ +.text-light { + color: var(--color-text-light); +} + +.text-muted { + color: var(--color-text-light-3); +} + +.text-center { + text-align: center; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/static/css/components.css b/static/css/components.css new file mode 100644 index 0000000..01f3adc --- /dev/null +++ b/static/css/components.css @@ -0,0 +1,347 @@ +/* Component styles for blog application + * + * This file provides reusable UI components like buttons, + * cards, forms, inputs, and other interactive elements. + * All components use CSS variables from theme files. + */ + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 500; + line-height: 1.5; + border: 1px solid var(--color-secondary-dark-1); + border-radius: 4px; + background-color: var(--color-button); + color: var(--color-text); + cursor: pointer; + text-decoration: none; + transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease; + white-space: nowrap; +} + +.btn:hover { + background-color: var(--color-hover); + border-color: var(--color-secondary-dark-2); + text-decoration: none; +} + +.btn:active { + background-color: var(--color-active); +} + +.btn:focus { + outline: none; + box-shadow: 0 0 0 3px var(--color-primary-alpha-30); +} + +.btn:disabled, +.btn.disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-primary { + background-color: var(--color-primary); + border-color: var(--color-primary-dark-1); + color: var(--color-primary-contrast); +} + +.btn-primary:hover { + background-color: var(--color-primary-hover); + border-color: var(--color-primary-dark-2); +} + +.btn-primary:active { + background-color: var(--color-primary-active); +} + +.btn-danger { + background-color: var(--color-red); + border-color: var(--color-red-dark-1); + color: #ffffff; +} + +.btn-danger:hover { + background-color: var(--color-red-dark-1); +} + +.btn-success { + background-color: var(--color-green); + border-color: var(--color-green-dark-1); + color: #ffffff; +} + +.btn-success:hover { + background-color: var(--color-green-dark-1); +} + +.btn-ghost { + background-color: transparent; + border-color: transparent; +} + +.btn-ghost:hover { + background-color: var(--color-hover); +} + +.btn-sm { + padding: 0.25rem 0.75rem; + font-size: 0.8125rem; +} + +.btn-lg { + padding: 0.75rem 1.5rem; + font-size: 1rem; +} + +/* Cards */ +.card { + background-color: var(--color-box-body); + border: 1px solid var(--color-border); + border-radius: 6px; + overflow: hidden; + transition: box-shadow 0.2s ease; +} + +.card:hover { + box-shadow: 0 2px 8px var(--color-shadow); +} + +.card-header { + background-color: var(--color-box-header); + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--color-border); + font-weight: 600; +} + +.card-body { + padding: 1.25rem; +} + +.card-footer { + background-color: var(--color-box-header); + padding: 0.75rem 1.25rem; + border-top: 1px solid var(--color-border); +} + +/* Forms */ +.form-group { + margin-bottom: 1.25rem; +} + +.form-label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: var(--color-text); +} + +.form-label-required::after { + content: " *"; + color: var(--color-red); +} + +.form-hint { + display: block; + margin-top: 0.25rem; + font-size: 0.8125rem; + color: var(--color-text-light-3); +} + +/* Inputs */ +.input, +.textarea, +.select { + display: block; + width: 100%; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + line-height: 1.5; + color: var(--color-input-text); + background-color: var(--color-input-background); + border: 1px solid var(--color-input-border); + border-radius: 4px; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.input:focus, +.textarea:focus, +.select:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-alpha-20); +} + +.input:disabled, +.textarea:disabled, +.select:disabled { + background-color: var(--color-secondary-light-2); + cursor: not-allowed; +} + +.input::placeholder, +.textarea::placeholder { + color: var(--color-placeholder-text); +} + +.textarea { + min-height: 100px; + resize: vertical; +} + +.select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + padding-right: 2.5rem; +} + +/* Input sizes */ +.input-sm, +.textarea-sm, +.select-sm { + padding: 0.25rem 0.5rem; + font-size: 0.8125rem; +} + +.input-lg, +.textarea-lg, +.select-lg { + padding: 0.75rem 1rem; + font-size: 1rem; +} + +/* Alerts */ +.alert { + padding: 1rem 1.25rem; + margin-bottom: 1rem; + border: 1px solid transparent; + border-radius: 4px; +} + +.alert-error { + background-color: var(--color-error-bg); + border-color: var(--color-error-border); + color: var(--color-error-text); +} + +.alert-success { + background-color: var(--color-success-bg); + border-color: var(--color-success-border); + color: var(--color-success-text); +} + +.alert-warning { + background-color: var(--color-warning-bg); + border-color: var(--color-warning-border); + color: var(--color-warning-text); +} + +.alert-info { + background-color: var(--color-info-bg); + border-color: var(--color-info-border); + color: var(--color-info-text); +} + +/* Badges */ +.badge { + display: inline-flex; + align-items: center; + padding: 0.125rem 0.5rem; + font-size: 0.75rem; + font-weight: 500; + line-height: 1.5; + border-radius: 9999px; + background-color: var(--color-label-bg); + color: var(--color-label-text); +} + +.badge-primary { + background-color: var(--color-primary-alpha-20); + color: var(--color-primary); +} + +.badge-success { + background-color: var(--color-green-badge-bg); + color: var(--color-green-badge); +} + +.badge-danger { + background-color: var(--color-red-badge-bg); + color: var(--color-red-badge); +} + +.badge-warning { + background-color: var(--color-yellow-badge-bg); + color: var(--color-yellow-badge); +} + +/* Tags */ +.tag { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.125rem 0.5rem; + font-size: 0.75rem; + background-color: var(--color-secondary-light-3); + border: 1px solid var(--color-border); + border-radius: 4px; + color: var(--color-text-light); +} + +.tag:hover { + background-color: var(--color-hover); +} + +/* Avatar */ +.avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 50%; + background-color: var(--color-primary); + color: var(--color-primary-contrast); + font-weight: 500; + font-size: 0.875rem; +} + +.avatar-sm { + width: 1.5rem; + height: 1.5rem; + font-size: 0.75rem; +} + +.avatar-lg { + width: 2.5rem; + height: 2.5rem; + font-size: 1rem; +} + +/* Dividers */ +.divider { + height: 1px; + background-color: var(--color-border); + margin: 1.5rem 0; +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 3rem 1rem; + color: var(--color-text-light-3); +} + +.empty-state-icon { + font-size: 3rem; + margin-bottom: 1rem; + opacity: 0.5; +} diff --git a/static/css/layout.css b/static/css/layout.css new file mode 100644 index 0000000..96ae99c --- /dev/null +++ b/static/css/layout.css @@ -0,0 +1,441 @@ +/* Layout styles for blog application + * + * This file provides layout-related styles including + * grid system, navigation, containers, and page structure. + */ + +/* Container */ +.container { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 0 1rem; +} + +.container-narrow { + max-width: 800px; +} + +.container-wide { + max-width: 1400px; +} + +/* Main layout */ +.main-wrapper { + flex: 1; + padding: 2rem 0; +} + +/* Grid system */ +.grid { + display: grid; + gap: 1.5rem; +} + +.grid-2 { + grid-template-columns: repeat(2, 1fr); +} + +.grid-3 { + grid-template-columns: repeat(3, 1fr); +} + +.grid-4 { + grid-template-columns: repeat(4, 1fr); +} + +@media (max-width: 1024px) { + .grid-4 { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + .grid-2, + .grid-3, + .grid-4 { + grid-template-columns: 1fr; + } +} + +/* Flex utilities */ +.flex { + display: flex; +} + +.flex-col { + flex-direction: column; +} + +.items-center { + align-items: center; +} + +.justify-between { + justify-content: space-between; +} + +.justify-center { + justify-content: center; +} + +.gap-1 { gap: 0.25rem; } +.gap-2 { gap: 0.5rem; } +.gap-3 { gap: 0.75rem; } +.gap-4 { gap: 1rem; } + +/* Header */ +.site-header { + background-color: var(--color-nav-bg); + border-bottom: 1px solid var(--color-border); + position: sticky; + top: 0; + z-index: 100; +} + +.site-header .container { + display: flex; + align-items: center; + justify-content: space-between; + height: 4rem; +} + +.site-logo { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1.25rem; + font-weight: 600; + color: var(--color-text-dark); + text-decoration: none; +} + +.site-logo:hover { + color: var(--color-primary); + text-decoration: none; +} + +/* Navigation */ +.main-nav { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.nav-link { + color: var(--color-nav-text); + font-weight: 500; + padding: 0.5rem 0; + border-bottom: 2px solid transparent; + transition: color 0.2s ease, border-color 0.2s ease; +} + +.nav-link:hover { + color: var(--color-primary); + text-decoration: none; + border-bottom-color: var(--color-primary-alpha-50); +} + +.nav-link.active { + color: var(--color-primary); + border-bottom-color: var(--color-primary); +} + +/* Header actions */ +.header-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +/* Theme toggle button */ +.theme-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + padding: 0; + border-radius: 4px; + background: transparent; + border: 1px solid transparent; + color: var(--color-nav-text); + cursor: pointer; + transition: background-color 0.2s ease, color 0.2s ease; +} + +.theme-toggle:hover { + background-color: var(--color-nav-hover-bg); + color: var(--color-primary); +} + +/* Footer */ +.site-footer { + background-color: var(--color-footer); + border-top: 1px solid var(--color-border); + padding: 2rem 0; + margin-top: auto; +} + +.site-footer .container { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; +} + +.footer-links { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.footer-link { + color: var(--color-text-light); + font-size: 0.875rem; +} + +.footer-link:hover { + color: var(--color-primary); +} + +/* Page header */ +.page-header { + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--color-border); +} + +.page-header-flex { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.page-title { + margin-bottom: 0; +} + +.page-subtitle { + color: var(--color-text-light); + margin-top: 0.25rem; +} + +/* Post list */ +.post-list { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +/* Post card specific */ +.post-card { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.post-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.post-card-title { + margin-bottom: 0; + font-size: 1.25rem; +} + +.post-card-title a { + color: var(--color-text-dark); +} + +.post-card-title a:hover { + color: var(--color-primary); +} + +.post-card-meta { + display: flex; + align-items: center; + gap: 1rem; + font-size: 0.875rem; + color: var(--color-text-light-2); +} + +.post-card-meta-item { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.post-card-content { + color: var(--color-text-light); + line-height: 1.6; +} + +.post-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border); +} + +.post-card-tags { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +/* Post detail */ +.post-detail { + max-width: 800px; + margin: 0 auto; +} + +.post-detail-header { + margin-bottom: 2rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--color-border); +} + +.post-detail-title { + font-size: 2rem; + margin-bottom: 1rem; +} + +.post-detail-meta { + display: flex; + align-items: center; + gap: 1.5rem; + flex-wrap: wrap; + color: var(--color-text-light-2); +} + +.post-detail-content { + font-size: 1.125rem; + line-height: 1.8; + color: var(--color-text); +} + +.post-detail-content p { + margin-bottom: 1.5rem; +} + +.post-detail-footer { + margin-top: 3rem; + padding-top: 1.5rem; + border-top: 1px solid var(--color-border); +} + +.post-detail-tags { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +/* Sidebar */ +.sidebar { + position: sticky; + top: 6rem; +} + +.sidebar-section { + background-color: var(--color-box-body); + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 1.25rem; + margin-bottom: 1.5rem; +} + +.sidebar-title { + font-size: 0.875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.025em; + color: var(--color-text-light); + margin-bottom: 1rem; +} + +/* Two column layout */ +.two-column { + display: grid; + grid-template-columns: 1fr 300px; + gap: 2rem; +} + +@media (max-width: 1024px) { + .two-column { + grid-template-columns: 1fr; + } + + .sidebar { + position: static; + } +} + +/* Pagination */ +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + margin-top: 2rem; +} + +.pagination-item { + display: flex; + align-items: center; + justify-content: center; + min-width: 2rem; + height: 2rem; + padding: 0 0.5rem; + border-radius: 4px; + font-size: 0.875rem; + color: var(--color-text); + text-decoration: none; + transition: background-color 0.2s ease; +} + +.pagination-item:hover { + background-color: var(--color-hover); + text-decoration: none; +} + +.pagination-item.active { + background-color: var(--color-primary); + color: var(--color-primary-contrast); +} + +.pagination-item.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Mobile menu */ +@media (max-width: 768px) { + .site-header .container { + height: 3.5rem; + } + + .main-nav { + display: none; + } + + .mobile-menu-btn { + display: flex; + } +} + +@media (min-width: 769px) { + .mobile-menu-btn { + display: none; + } +} diff --git a/static/css/themes/theme-dark.css b/static/css/themes/theme-dark.css new file mode 100644 index 0000000..9907c60 --- /dev/null +++ b/static/css/themes/theme-dark.css @@ -0,0 +1,199 @@ +gitea-theme-meta-info { + --theme-display-name: "Dark"; + --theme-color-scheme: "dark"; +} + +[data-theme="dark"] { + --is-dark-theme: true; + + /* Primary colors */ + --color-primary: #4183c4; + --color-primary-contrast: #ffffff; + --color-primary-dark-1: #548fca; + --color-primary-dark-2: #679cd0; + --color-primary-dark-3: #7aa8d6; + --color-primary-dark-4: #8db5dc; + --color-primary-dark-5: #b3cde7; + --color-primary-dark-6: #d9e6f3; + --color-primary-dark-7: #f4f8fb; + --color-primary-light-1: #3876b3; + --color-primary-light-2: #31699f; + --color-primary-light-3: #2b5c8b; + --color-primary-light-4: #254f77; + --color-primary-light-5: #193450; + --color-primary-light-6: #0c1a28; + --color-primary-light-7: #04080c; + --color-primary-alpha-10: #4183c419; + --color-primary-alpha-20: #4183c433; + --color-primary-alpha-30: #4183c44b; + --color-primary-alpha-40: #4183c466; + --color-primary-alpha-50: #4183c480; + --color-primary-alpha-60: #4183c499; + --color-primary-alpha-70: #4183c4b3; + --color-primary-alpha-80: #4183c4cc; + --color-primary-alpha-90: #4183c4e1; + --color-primary-hover: var(--color-primary-light-1); + --color-primary-active: var(--color-primary-light-2); + + /* Secondary colors */ + --color-secondary: #3f4248; + --color-secondary-dark-1: #46494f; + --color-secondary-dark-2: #4f5259; + --color-secondary-dark-3: #5e626a; + --color-secondary-dark-4: #6f747d; + --color-secondary-dark-5: #7d828c; + --color-secondary-dark-6: #8b8f98; + --color-secondary-dark-7: #999da4; + --color-secondary-dark-8: #a8abb1; + --color-secondary-dark-9: #aeb1b8; + --color-secondary-dark-10: #bbbec3; + --color-secondary-dark-11: #c8cacf; + --color-secondary-dark-12: #d2d4d7; + --color-secondary-dark-13: #d5d6d9; + --color-secondary-light-1: #35373c; + --color-secondary-light-2: #2c2e32; + --color-secondary-light-3: #1f2124; + --color-secondary-light-4: #191a1c; + --color-secondary-alpha-10: #3f424819; + --color-secondary-alpha-20: #3f424833; + --color-secondary-alpha-30: #3f42484b; + --color-secondary-alpha-40: #3f424866; + --color-secondary-alpha-50: #3f424880; + --color-secondary-alpha-60: #3f424899; + --color-secondary-alpha-70: #3f4248b3; + --color-secondary-alpha-80: #3f4248cc; + --color-secondary-alpha-90: #3f4248e1; + --color-secondary-button: var(--color-secondary-dark-4); + --color-secondary-hover: var(--color-secondary-dark-3); + --color-secondary-active: var(--color-secondary-dark-2); + + /* Semantic colors */ + --color-red: #cc4848; + --color-orange: #cc580c; + --color-yellow: #cc9903; + --color-olive: #91a313; + --color-green: #87ab63; + --color-teal: #00918a; + --color-blue: #3a8ac6; + --color-violet: #906ae1; + --color-purple: #b259d0; + --color-pink: #d22e8b; + --color-brown: #a47252; + --color-black: #202225; + + /* Light variants */ + --color-red-light: #d15a5a; + --color-orange-light: #f6a066; + --color-yellow-light: #eaaf03; + --color-olive-light: #abc016; + --color-green-light: #93b373; + --color-teal-light: #00b6ad; + --color-blue-light: #4e96cc; + --color-violet-light: #9b79e4; + --color-purple-light: #ba6ad5; + --color-pink-light: #d74397; + --color-brown-light: #b08061; + --color-black-light: #45484e; + + /* Dark variants */ + --color-red-dark-1: #c23636; + --color-orange-dark-1: #f38236; + --color-yellow-dark-1: #b88a03; + --color-olive-dark-1: #839311; + --color-green-dark-1: #7a9e55; + --color-teal-dark-1: #00837c; + --color-blue-dark-1: #347cb3; + --color-violet-dark-1: #7b4edb; + --color-purple-dark-1: #a742c9; + --color-pink-dark-1: #be297d; + --color-brown-dark-1: #94674a; + --color-black-dark-1: #2e3033; + + /* Status colors */ + --color-error-border: #763232; + --color-error-bg: #322226; + --color-error-bg-active: #49262a; + --color-error-bg-hover: #3c2427; + --color-error-text: #f85149; + --color-success-border: #225633; + --color-success-bg: #1c3329; + --color-success-text: #3fb950; + --color-warning-border: #5f481a; + --color-warning-bg: #342e1f; + --color-warning-text: #d29922; + --color-info-border: #254a7e; + --color-info-bg: #1b283a; + --color-info-text: #2f81f7; + + /* Target-based colors */ + --color-body: #1e1f20; + --color-box-header: #1b1c1e; + --color-box-body: #161718; + --color-box-body-highlight: #202124; + --color-text-dark: #f8f8f8; + --color-text: #d2d4d8; + --color-text-light: #c0c2c7; + --color-text-light-1: #aaadb4; + --color-text-light-2: #969aa1; + --color-text-light-3: #80858f; + --color-footer: var(--color-nav-bg); + --color-timeline: #383b40; + --color-input-text: var(--color-text-dark); + --color-input-background: #191a1c; + --color-input-toggle-background: #323438; + --color-input-border: var(--color-secondary-dark-1); + --color-light: #0b0b0c28; + --color-light-border: #f3f3f428; + --color-hover: #f3f3f419; + --color-hover-opaque: #232528; + --color-active: #f3f3f424; + --color-menu: #191a1c; + --color-card: #191a1c; + --color-button: #191a1c; + --color-code-bg: #161718; + --color-shadow: #0b0b0c58; + --color-shadow-opaque: #0b0b0c; + --color-secondary-bg: #2e3033; + --color-expand-button: #333539; + --color-placeholder-text: var(--color-text-light-3); + --color-tooltip-text: #fafafa; + --color-tooltip-bg: #0b0b0cf0; + --color-nav-bg: #18191b; + --color-nav-hover-bg: var(--color-secondary-light-1); + --color-nav-text: var(--color-text); + --color-secondary-nav-bg: #1a1b1e; + --color-label-text: var(--color-text); + --color-label-bg: #7a7f8a4b; + --color-label-hover-bg: #7a7f8aa0; + --color-label-active-bg: #7a7f8aff; + --color-accent: var(--color-primary-light-1); + --color-small-accent: var(--color-primary-light-5); + --color-border: #3f4248; + + accent-color: var(--color-accent); + color-scheme: dark; +} + +/* invert emojis that are hard to read otherwise */ +.emoji[aria-label="check mark"], +.emoji[aria-label="currency exchange"], +.emoji[aria-label="TOP arrow"], +.emoji[aria-label="END arrow"], +.emoji[aria-label="ON! arrow"], +.emoji[aria-label="SOON arrow"], +.emoji[aria-label="heavy dollar sign"], +.emoji[aria-label="copyright"], +.emoji[aria-label="registered"], +.emoji[aria-label="trade mark"], +.emoji[aria-label="multiply"], +.emoji[aria-label="plus"], +.emoji[aria-label="minus"], +.emoji[aria-label="divide"], +.emoji[aria-label="curly loop"], +.emoji[aria-label="double curly loop"], +.emoji[aria-label="wavy dash"], +.emoji[aria-label="paw prints"], +.emoji[aria-label="musical note"], +.emoji[aria-label="musical notes"] { + filter: invert(100%) hue-rotate(180deg); +} diff --git a/static/css/themes/theme-light.css b/static/css/themes/theme-light.css new file mode 100644 index 0000000..c9eb875 --- /dev/null +++ b/static/css/themes/theme-light.css @@ -0,0 +1,175 @@ +gitea-theme-meta-info { + --theme-display-name: "Light"; + --theme-color-scheme: "light"; +} + +:root { + --is-dark-theme: false; + + /* Primary colors */ + --color-primary: #4183c4; + --color-primary-contrast: #ffffff; + --color-primary-dark-1: #3876b3; + --color-primary-dark-2: #31699f; + --color-primary-dark-3: #2b5c8b; + --color-primary-dark-4: #254f77; + --color-primary-dark-5: #193450; + --color-primary-dark-6: #0c1a28; + --color-primary-dark-7: #04080c; + --color-primary-light-1: #548fca; + --color-primary-light-2: #679cd0; + --color-primary-light-3: #7aa8d6; + --color-primary-light-4: #8db5dc; + --color-primary-light-5: #b3cde7; + --color-primary-light-6: #d9e6f3; + --color-primary-light-7: #f4f8fb; + --color-primary-alpha-10: #4183c419; + --color-primary-alpha-20: #4183c433; + --color-primary-alpha-30: #4183c44b; + --color-primary-alpha-40: #4183c466; + --color-primary-alpha-50: #4183c480; + --color-primary-alpha-60: #4183c499; + --color-primary-alpha-70: #4183c4b3; + --color-primary-alpha-80: #4183c4cc; + --color-primary-alpha-90: #4183c4e1; + --color-primary-hover: var(--color-primary-dark-1); + --color-primary-active: var(--color-primary-dark-2); + + /* Secondary colors */ + --color-secondary: #d0d7de; + --color-secondary-dark-1: #c7ced5; + --color-secondary-dark-2: #b9c0c7; + --color-secondary-dark-3: #99a0a7; + --color-secondary-dark-4: #899097; + --color-secondary-dark-5: #7a8188; + --color-secondary-dark-6: #6a7178; + --color-secondary-dark-7: #5b6269; + --color-secondary-dark-8: #4b5259; + --color-secondary-dark-9: #3c434a; + --color-secondary-dark-10: #2c333a; + --color-secondary-dark-11: #1d242b; + --color-secondary-dark-12: #0d141b; + --color-secondary-dark-13: #00040b; + --color-secondary-light-1: #dee5ec; + --color-secondary-light-2: #e4ebf2; + --color-secondary-light-3: #ebf2f9; + --color-secondary-light-4: #f1f8ff; + --color-secondary-alpha-10: #d0d7de19; + --color-secondary-alpha-20: #d0d7de33; + --color-secondary-alpha-30: #d0d7de4b; + --color-secondary-alpha-40: #d0d7de66; + --color-secondary-alpha-50: #d0d7de80; + --color-secondary-alpha-60: #d0d7de99; + --color-secondary-alpha-70: #d0d7deb3; + --color-secondary-alpha-80: #d0d7decc; + --color-secondary-alpha-90: #d0d7dee1; + --color-secondary-button: var(--color-secondary-dark-4); + --color-secondary-hover: var(--color-secondary-dark-5); + --color-secondary-active: var(--color-secondary-dark-6); + + /* Semantic colors */ + --color-red: #db2828; + --color-orange: #f2711c; + --color-yellow: #fbbd08; + --color-olive: #b5cc18; + --color-green: #21ba45; + --color-teal: #00b5ad; + --color-blue: #2185d0; + --color-violet: #6435c9; + --color-purple: #a333c8; + --color-pink: #e03997; + --color-brown: #a5673f; + --color-black: #1d2328; + + /* Light variants */ + --color-red-light: #e45e5e; + --color-orange-light: #f59555; + --color-yellow-light: #fcce46; + --color-olive-light: #d3e942; + --color-green-light: #46de6a; + --color-teal-light: #08fff4; + --color-blue-light: #51a5e3; + --color-violet-light: #8b67d7; + --color-purple-light: #bb64d8; + --color-pink-light: #e86bb1; + --color-brown-light: #c58b66; + --color-black-light: #4b5b68; + + /* Dark variants */ + --color-red-dark-1: #c82121; + --color-orange-dark-1: #e6630d; + --color-yellow-dark-1: #e5ac04; + --color-olive-dark-1: #a3b816; + --color-green-dark-1: #1ea73e; + --color-teal-dark-1: #00a39c; + --color-blue-dark-1: #1e78bb; + --color-violet-dark-1: #5a30b5; + --color-purple-dark-1: #932eb4; + --color-pink-dark-1: #db228a; + --color-brown-dark-1: #955d39; + --color-black-dark-1: #2c3339; + + /* Status colors */ + --color-error-border: #ff818266; + --color-error-bg: #ffebe9; + --color-error-bg-active: #ffcecb; + --color-error-bg-hover: #ffdcd7; + --color-error-text: #d1242f; + --color-success-border: #4ac26b66; + --color-success-bg: #dafbe1; + --color-success-text: #1a7f37; + --color-warning-border: #d4a72c66; + --color-warning-bg: #fff8c5; + --color-warning-text: #9a6700; + --color-info-border: #54aeff66; + --color-info-bg: #ddf4ff; + --color-info-text: #0969da; + + /* Target-based colors */ + --color-body: #ffffff; + --color-box-header: #f1f3f5; + --color-box-body: #ffffff; + --color-box-body-highlight: #ecf5fd; + --color-text-dark: #01050a; + --color-text: #181c21; + --color-text-light: #30363b; + --color-text-light-1: #40474d; + --color-text-light-2: #5b6167; + --color-text-light-3: #747c84; + --color-footer: var(--color-nav-bg); + --color-timeline: #d0d7de; + --color-input-text: var(--color-text-dark); + --color-input-background: #fff; + --color-input-toggle-background: #d0d7de; + --color-input-border: var(--color-secondary-dark-1); + --color-light: #00001706; + --color-light-border: #0000171d; + --color-hover: #00001708; + --color-hover-opaque: #f1f3f5; + --color-active: #00001714; + --color-menu: #f8f9fb; + --color-card: #f8f9fb; + --color-button: #f8f9fb; + --color-code-bg: #fafdff; + --color-shadow: #00001726; + --color-shadow-opaque: #c7ced5; + --color-secondary-bg: #f2f5f8; + --color-expand-button: #cfe8fa; + --color-placeholder-text: var(--color-text-light-3); + --color-tooltip-text: #fbfdff; + --color-tooltip-bg: #000017f0; + --color-nav-bg: #f6f7fa; + --color-nav-hover-bg: var(--color-secondary-light-1); + --color-nav-text: var(--color-text); + --color-secondary-nav-bg: #f9fafb; + --color-label-text: var(--color-text); + --color-label-bg: #949da64b; + --color-label-hover-bg: #949da6a0; + --color-label-active-bg: #949da6ff; + --color-accent: var(--color-primary-light-1); + --color-small-accent: var(--color-primary-light-6); + --color-border: #d0d7de; + + accent-color: var(--color-accent); + color-scheme: light; +} diff --git a/static/js/theme.js b/static/js/theme.js new file mode 100644 index 0000000..bd7cd8b --- /dev/null +++ b/static/js/theme.js @@ -0,0 +1,163 @@ +/** + * Theme switching functionality for blog application. + * + * Handles theme persistence in localStorage and applies + * the selected theme to the document root element. + * Supports system preference detection and manual theme switching. + */ + +(function() { + 'use strict'; + + const STORAGE_KEY = 'blog-theme'; + const THEME_ATTRIBUTE = 'data-theme'; + const THEME_LIGHT = 'light'; + const THEME_DARK = 'dark'; + + /** + * Get the currently stored theme preference. + * Falls back to system preference if no stored value. + * + * @returns {string} The theme name ('light' or 'dark') + */ + function getStoredTheme() { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === THEME_LIGHT || stored === THEME_DARK) { + return stored; + } + } catch (e) { + console.warn('Failed to access localStorage:', e); + } + + return getSystemPreference(); + } + + /** + * Detect system color scheme preference. + * + * @returns {string} 'dark' if system prefers dark mode, 'light' otherwise + */ + function getSystemPreference() { + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + return THEME_DARK; + } + return THEME_LIGHT; + } + + /** + * Apply the specified theme to the document. + * Updates the data-theme attribute on the html element. + * + * @param {string} theme - The theme to apply ('light' or 'dark') + */ + function applyTheme(theme) { + const html = document.documentElement; + if (html) { + html.setAttribute(THEME_ATTRIBUTE, theme); + } + } + + /** + * Save the theme preference to localStorage. + * + * @param {string} theme - The theme to save ('light' or 'dark') + */ + function saveTheme(theme) { + try { + localStorage.setItem(STORAGE_KEY, theme); + } catch (e) { + console.warn('Failed to save theme to localStorage:', e); + } + } + + /** + * Set and apply the specified theme. + * Updates both the DOM and localStorage. + * + * @param {string} theme - The theme to set ('light' or 'dark') + */ + function setTheme(theme) { + if (theme !== THEME_LIGHT && theme !== THEME_DARK) { + console.warn('Invalid theme:', theme); + return; + } + applyTheme(theme); + saveTheme(theme); + updateThemeIcons(theme); + } + + /** + * Toggle between light and dark themes. + */ + function toggleTheme() { + const currentTheme = document.documentElement.getAttribute(THEME_ATTRIBUTE); + const newTheme = currentTheme === THEME_DARK ? THEME_LIGHT : THEME_DARK; + setTheme(newTheme); + } + + /** + * Update theme toggle icons based on current theme. + * Shows/hides sun/moon icons appropriately. + * + * @param {string} theme - The current theme + */ + function updateThemeIcons(theme) { + const lightIcons = document.querySelectorAll('[data-testid="theme-light-icon"]'); + const darkIcons = document.querySelectorAll('[data-testid="theme-dark-icon"]'); + + lightIcons.forEach(icon => { + icon.style.display = theme === THEME_LIGHT ? 'none' : 'block'; + }); + + darkIcons.forEach(icon => { + icon.style.display = theme === THEME_DARK ? 'none' : 'block'; + }); + } + + /** + * Initialize theme on page load. + * Applies stored theme and sets up event listeners. + */ + function init() { + const theme = getStoredTheme(); + applyTheme(theme); + + document.addEventListener('DOMContentLoaded', function() { + updateThemeIcons(theme); + + const toggleBtn = document.querySelector('[data-testid="theme-toggle"]'); + if (toggleBtn) { + toggleBtn.addEventListener('click', toggleTheme); + } + }); + + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) { + try { + const hasUserPreference = localStorage.getItem(STORAGE_KEY); + if (!hasUserPreference) { + const newTheme = e.matches ? THEME_DARK : THEME_LIGHT; + applyTheme(newTheme); + updateThemeIcons(newTheme); + } + } catch (err) { + console.warn('Failed to handle system theme change:', err); + } + }); + } + + const BlogTheme = { + setTheme: setTheme, + toggleTheme: toggleTheme, + getStoredTheme: getStoredTheme, + getSystemPreference: getSystemPreference, + THEME_LIGHT: THEME_LIGHT, + THEME_DARK: THEME_DARK + }; + + if (typeof window !== 'undefined') { + window.BlogTheme = BlogTheme; + } + + init(); +})();