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
This commit is contained in:
2026-05-02 14:45:51 +03:00
parent ca4e8877a5
commit e2802d83f2
18 changed files with 2212 additions and 1 deletions

View File

@@ -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
<button data-testid="btn-create-post" class="btn btn-primary">
Create Post
</button>
<article data-testid="post-card-{{ post.id }}" class="card">
<h2 data-testid="post-title">{{ post.title }}</h2>
<p data-testid="post-content">{{ post.content }}</p>
</article>
```
### 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 `<html>` 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)

View File

@@ -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='<meta http-equiv="refresh" content="0;url=/web/">', status_code=200
)
@app.get("/health", tags=["health"])
async def health_check() -> dict[str, str]:

View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="ru" data-testid="html-root">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{% block meta_description %}Blog - A modern blogging platform{% endblock %}">
<title data-testid="page-title">{% block title %}Blog{% endblock %}</title>
<link rel="stylesheet" href="/static/css/themes/theme-light.css" data-testid="theme-light-stylesheet">
<link rel="stylesheet" href="/static/css/themes/theme-dark.css" data-testid="theme-dark-stylesheet">
<link rel="stylesheet" href="/static/css/base.css" data-testid="base-stylesheet">
<link rel="stylesheet" href="/static/css/components.css" data-testid="components-stylesheet">
<link rel="stylesheet" href="/static/css/layout.css" data-testid="layout-stylesheet">
{% block extra_css %}{% endblock %}
</head>
<body data-testid="body">
{% include "partials/header.html" %}
<main class="main-wrapper" data-testid="main-content">
<div class="container" data-testid="container">
{% block content %}{% endblock %}
</div>
</main>
{% include "partials/footer.html" %}
<script src="/static/js/theme.js" data-testid="theme-script"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}Blog - Home{% endblock %}
{% block content %}
<section class="page-header" data-testid="page-header-home">
<div class="page-header-flex">
<div data-testid="page-header-content">
<h1 class="page-title" data-testid="page-title-home">Latest Posts</h1>
<p class="page-subtitle" data-testid="page-subtitle-home">Discover stories, thinking, and expertise from writers on any topic.</p>
</div>
<a href="/posts/new" class="btn btn-primary" data-testid="btn-create-post-header">
Write a Post
</a>
</div>
</section>
{% if posts %}
<section class="post-list" data-testid="post-list">
{% for post in posts %}
<article class="card post-card" data-testid="post-card-{{ post.id }}">
<div class="post-card-header" data-testid="post-card-header-{{ post.id }}">
<h2 class="post-card-title" data-testid="post-title-{{ post.id }}">
<a href="/posts/{{ post.id }}" data-testid="post-title-link-{{ post.id }}">{{ post.title }}</a>
</h2>
{% if post.published %}
<span class="badge badge-success" data-testid="post-status-{{ post.id }}">Published</span>
{% else %}
<span class="badge" data-testid="post-status-{{ post.id }}">Draft</span>
{% endif %}
</div>
<div class="post-card-meta" data-testid="post-meta-{{ post.id }}">
<span class="post-card-meta-item" data-testid="post-author-{{ post.id }}">
<span class="avatar avatar-sm" data-testid="post-author-avatar-{{ post.id }}">{{ post.author_id[0]|upper }}</span>
<span data-testid="post-author-name-{{ post.id }}">{{ post.author_id }}</span>
</span>
<span class="post-card-meta-item" data-testid="post-date-{{ post.id }}">
{{ post.created_at.strftime('%B %d, %Y') }}
</span>
</div>
<div class="post-card-content" data-testid="post-content-preview-{{ post.id }}">
{{ post.content.value[:200] }}{% if post.content.value|length > 200 %}...{% endif %}
</div>
<div class="post-card-footer" data-testid="post-card-footer-{{ post.id }}">
<div class="post-card-tags" data-testid="post-tags-{{ post.id }}">
{% for tag in post.tags %}
<span class="tag" data-testid="post-tag-{{ post.id }}-{{ loop.index }}">{{ tag }}</span>
{% endfor %}
</div>
<a href="/posts/{{ post.id }}" class="btn btn-sm" data-testid="btn-read-more-{{ post.id }}">Read more</a>
</div>
</article>
{% endfor %}
</section>
<nav class="pagination" data-testid="pagination" aria-label="Pagination">
{% if has_prev %}
<a href="/?page={{ current_page - 1 }}" class="pagination-item" data-testid="pagination-prev">Previous</a>
{% else %}
<span class="pagination-item disabled" data-testid="pagination-prev">Previous</span>
{% endif %}
<span class="pagination-item active" data-testid="pagination-current">{{ current_page }}</span>
{% if has_next %}
<a href="/?page={{ current_page + 1 }}" class="pagination-item" data-testid="pagination-next">Next</a>
{% else %}
<span class="pagination-item disabled" data-testid="pagination-next">Next</span>
{% endif %}
</nav>
{% else %}
<div class="empty-state" data-testid="empty-state">
<div class="empty-state-icon" data-testid="empty-state-icon">📝</div>
<h3 data-testid="empty-state-title">No posts yet</h3>
<p data-testid="empty-state-description">Be the first to write a post!</p>
<a href="/posts/new" class="btn btn-primary" data-testid="btn-create-first-post">Create your first post</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}{{ post.title }} - Blog{% endblock %}
{% block meta_description %}{{ post.content.value[:160] }}{% endblock %}
{% block content %}
<article class="post-detail" data-testid="post-detail">
<header class="post-detail-header" data-testid="post-detail-header">
<h1 class="post-detail-title" data-testid="post-detail-title">{{ post.title }}</h1>
<div class="post-detail-meta" data-testid="post-detail-meta">
<span class="post-card-meta-item" data-testid="post-detail-author">
<span class="avatar avatar-sm" data-testid="post-detail-author-avatar">{{ post.author_id[0]|upper }}</span>
<span data-testid="post-detail-author-name">{{ post.author_id }}</span>
</span>
<span class="post-card-meta-item" data-testid="post-detail-date">
{{ post.created_at.strftime('%B %d, %Y') }}
</span>
{% if post.published %}
<span class="badge badge-success" data-testid="post-detail-status">Published</span>
{% else %}
<span class="badge" data-testid="post-detail-status">Draft</span>
{% endif %}
</div>
</header>
<div class="post-detail-content" data-testid="post-detail-content">
{{ post.content.value|nl2br }}
</div>
<footer class="post-detail-footer" data-testid="post-detail-footer">
<div class="post-detail-tags" data-testid="post-detail-tags">
{% for tag in post.tags %}
<span class="tag" data-testid="post-detail-tag-{{ loop.index }}">{{ tag }}</span>
{% endfor %}
</div>
<div class="divider" data-testid="post-detail-divider"></div>
<div class="flex justify-between items-center" data-testid="post-detail-actions">
<a href="/" class="btn" data-testid="btn-back-to-posts">
← Back to posts
</a>
<div class="flex gap-2" data-testid="post-detail-edit-actions">
<a href="/posts/{{ post.id }}/edit" class="btn" data-testid="btn-edit-post">
Edit
</a>
<form action="/posts/{{ post.id }}/delete" method="POST" style="display: inline;" data-testid="form-delete-post">
<button type="submit" class="btn btn-danger" data-testid="btn-delete-post" onclick="return confirm('Are you sure you want to delete this post?');">
Delete
</button>
</form>
</div>
</div>
</footer>
</article>
{% endblock %}

View File

@@ -0,0 +1,99 @@
{% extends "base.html" %}
{% block title %}{% if is_edit %}Edit Post{% else %}New Post{% endif %} - Blog{% endblock %}
{% block content %}
<section class="page-header" data-testid="page-header-form">
<h1 class="page-title" data-testid="page-title-form">
{% if is_edit %}Edit Post{% else %}Create New Post{% endif %}
</h1>
</section>
<form
method="POST"
action="{% if is_edit %}/posts/{{ post.id }}/edit{% else %}/posts/new{% endif %}"
class="card"
data-testid="form-post"
>
<div class="card-body" data-testid="form-post-body">
<div class="form-group" data-testid="form-group-title">
<label for="title" class="form-label form-label-required" data-testid="label-title">
Title
</label>
<input
type="text"
id="title"
name="title"
class="input input-lg"
value="{% if post %}{{ post.title.value }}{% endif %}"
placeholder="Enter post title"
required
data-testid="input-title"
>
<span class="form-hint" data-testid="hint-title">A catchy title for your post</span>
</div>
<div class="form-group" data-testid="form-group-content">
<label for="content" class="form-label form-label-required" data-testid="label-content">
Content
</label>
<textarea
id="content"
name="content"
class="textarea"
rows="12"
placeholder="Write your post content here..."
required
data-testid="textarea-content"
>{% if post %}{{ post.content.value }}{% endif %}</textarea>
<span class="form-hint" data-testid="hint-content">The main content of your post. Markdown is supported.</span>
</div>
<div class="form-group" data-testid="form-group-tags">
<label for="tags" class="form-label" data-testid="label-tags">
Tags
</label>
<input
type="text"
id="tags"
name="tags"
class="input"
value="{% if post %}{{ post.tags|join(', ') }}{% endif %}"
placeholder="python, fastapi, tutorial"
data-testid="input-tags"
>
<span class="form-hint" data-testid="hint-tags">Comma-separated list of tags</span>
</div>
<div class="form-group" data-testid="form-group-published">
<label class="form-label" data-testid="label-published">
<input
type="checkbox"
name="published"
value="true"
{% if post and post.published %}checked{% endif %}
data-testid="checkbox-published"
>
Publish immediately
</label>
</div>
</div>
<div class="card-footer" data-testid="form-post-footer">
<div class="flex justify-between items-center" data-testid="form-actions">
<a href="{% if is_edit %}/posts/{{ post.id }}{% else %}/{% endif %}" class="btn" data-testid="btn-cancel">
Cancel
</a>
<div class="flex gap-2" data-testid="form-submit-actions">
<button type="submit" name="action" value="draft" class="btn" data-testid="btn-save-draft">
Save as Draft
</button>
<button type="submit" name="action" value="publish" class="btn btn-primary" data-testid="btn-publish-post">
{% if is_edit %}Update Post{% else %}Publish Post{% endif %}
</button>
</div>
</div>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,14 @@
<footer class="site-footer" data-testid="site-footer">
<div class="container" data-testid="footer-container">
<div class="footer-copyright" data-testid="footer-copyright">
<span data-testid="copyright-text">© 2026 Blog. All rights reserved.</span>
</div>
<nav class="footer-links" data-testid="footer-nav" aria-label="Footer navigation">
<a href="/about" class="footer-link" data-testid="footer-link-about">About</a>
<a href="/privacy" class="footer-link" data-testid="footer-link-privacy">Privacy</a>
<a href="/terms" class="footer-link" data-testid="footer-link-terms">Terms</a>
<a href="/api/docs" class="footer-link" data-testid="footer-link-api">API</a>
</nav>
</div>
</footer>

View File

@@ -0,0 +1,35 @@
<header class="site-header" data-testid="site-header">
<div class="container" data-testid="header-container">
<a href="/" class="site-logo" data-testid="nav-logo">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" data-testid="logo-icon">
<rect width="32" height="32" rx="6" fill="var(--color-primary)"/>
<path d="M8 12h16M8 16h12M8 20h8" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>
<span data-testid="logo-text">Blog</span>
</a>
{% include "partials/nav.html" %}
<div class="header-actions" data-testid="header-actions">
<button
type="button"
class="theme-toggle"
data-testid="theme-toggle"
aria-label="Toggle theme"
title="Toggle theme"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" data-testid="theme-light-icon" style="display: none;">
<path d="M10 2v2M10 16v2M4.22 4.22l1.42 1.42M14.36 14.36l1.42 1.42M2 10h2M16 10h2M4.22 15.78l1.42-1.42M14.36 5.64l1.42-1.42" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<circle cx="10" cy="10" r="4" stroke="currentColor" stroke-width="2"/>
</svg>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" data-testid="theme-dark-icon" style="display: none;">
<path d="M18 10.79A9 9 0 1 1 9.21 2 7 7 0 0 0 18 10.79z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<a href="/posts/new" class="btn btn-primary btn-sm" data-testid="btn-create-post">
New Post
</a>
</div>
</div>
</header>

View File

@@ -0,0 +1,11 @@
<nav class="main-nav" data-testid="main-nav" aria-label="Main navigation">
<a href="/" class="nav-link {% if active_page == 'home' %}active{% endif %}" data-testid="nav-link-home">
Home
</a>
<a href="/posts" class="nav-link {% if active_page == 'posts' %}active{% endif %}" data-testid="nav-link-posts">
Posts
</a>
<a href="/about" class="nav-link {% if active_page == 'about' %}active{% endif %}" data-testid="nav-link-about">
About
</a>
</nav>

View File

@@ -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"]

View File

@@ -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="""
<!DOCTYPE html>
<html>
<head><title>About - Blog</title></head>
<body>
<h1>About</h1>
<p>A modern blog built with FastAPI and DDD architecture.</p>
<a href="/web/">Back to home</a>
</body>
</html>
"""
)

View File

@@ -14,6 +14,7 @@ dependencies = [
"asyncpg>=0.30.0",
"dishka>=1.5.0",
"httpx>=0.28.0",
"jinja2>=3.1.6",
]
[build-system]

150
static/css/base.css Normal file
View File

@@ -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;
}

347
static/css/components.css Normal file
View File

@@ -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;
}

441
static/css/layout.css Normal file
View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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;
}

163
static/js/theme.js Normal file
View File

@@ -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();
})();