base ui #11

Merged
pi3c merged 10 commits from feature/base_ui into dev 2026-05-02 16:10:18 +00:00
13 changed files with 747 additions and 42 deletions
Showing only changes of commit b1878e470f - Show all commits

View File

@@ -26,6 +26,8 @@ from app.infrastructure.di.providers import (
from app.presentation import router
from app.presentation.web import auth_router
from app.presentation.web import router as web_router
from app.presentation.web.error_handlers import register_error_handlers
from app.presentation.web.flash import setup_flash_manager
@asynccontextmanager
@@ -71,6 +73,14 @@ def app_factory() -> FastAPI:
setup_dishka(container, app)
register_exception_handlers(app)
register_error_handlers(app)
@app.middleware("http")
async def flash_middleware(request, call_next):
"""Middleware to setup flash manager for each request."""
await setup_flash_manager(request)
response = await call_next(request)
return response
app.add_middleware(
CORSMiddleware,

View File

@@ -1,9 +1,35 @@
<!DOCTYPE html>
<html lang="ru" data-testid="html-root">
<html lang="en" 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 %}">
<meta name="description" content="{% block meta_description %}Blog - A modern blogging platform built with FastAPI{% endblock %}">
<meta name="keywords" content="{% block meta_keywords %}blog, articles, posts, writing{% endblock %}">
<meta name="author" content="{% block meta_author %}Blog Team{% endblock %}">
<meta name="robots" content="{% block meta_robots %}index, follow{% endblock %}">
<!-- Canonical URL -->
<link rel="canonical" href="{% block canonical_url %}{{ request.url }}{% endblock %}">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="{% block og_type %}website{% endblock %}">
<meta property="og:url" content="{% block og_url %}{{ request.url }}{% endblock %}">
<meta property="og:title" content="{% block og_title %}{{ self.title() }}{% endblock %}">
<meta property="og:description" content="{% block og_description %}{{ self.meta_description() }}{% endblock %}">
<meta property="og:image" content="{% block og_image %}{{ request.base_url }}static/images/og-default.png{% endblock %}">
<meta property="og:site_name" content="Blog">
<!-- Twitter -->
<meta property="twitter:card" content="{% block twitter_card %}summary_large_image{% endblock %}">
<meta property="twitter:url" content="{% block twitter_url %}{{ request.url }}{% endblock %}">
<meta property="twitter:title" content="{% block twitter_title %}{{ self.title() }}{% endblock %}">
<meta property="twitter:description" content="{% block twitter_description %}{{ self.meta_description() }}{% endblock %}">
<meta property="twitter:image" content="{% block twitter_image %}{{ self.og_image() }}{% endblock %}">
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/static/images/favicon.svg">
<link rel="alternate icon" href="/static/images/favicon.ico">
<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">
@@ -17,6 +43,18 @@
<body data-testid="body">
{% include "partials/header.html" %}
<!-- Flash Messages -->
{% if flash_messages %}
<div class="flash-container" data-testid="flash-container">
{% for msg in flash_messages %}
<div class="flash-message flash-{{ msg.category }}" data-testid="flash-message-{{ msg.category }}" role="alert">
<span class="flash-text" data-testid="flash-text">{{ msg.message }}</span>
<button type="button" class="flash-close" data-testid="flash-close" aria-label="Close message">&times;</button>
</div>
{% endfor %}
</div>
{% endif %}
<main class="main-wrapper" data-testid="main-content">
<div class="container" data-testid="container">
{% block content %}{% endblock %}
@@ -26,6 +64,119 @@
{% include "partials/footer.html" %}
<script src="/static/js/theme.js" data-testid="theme-script"></script>
<script src="/static/js/flash.js" data-testid="flash-script"></script>
{% block extra_js %}{% endblock %}
</body>
</html>
<style>
.flash-container {
position: fixed;
top: 5rem;
right: 1rem;
z-index: 1000;
max-width: 400px;
width: calc(100% - 2rem);
}
.flash-message {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
margin-bottom: 0.75rem;
border-radius: 8px;
border: 1px solid transparent;
box-shadow: 0 4px 12px var(--color-shadow);
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.flash-message.fade-out {
animation: slideOut 0.3s ease forwards;
}
@keyframes slideOut {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(100%);
}
}
.flash-success {
background-color: var(--color-success-bg);
border-color: var(--color-success-border);
color: var(--color-success-text);
}
.flash-error {
background-color: var(--color-error-bg);
border-color: var(--color-error-border);
color: var(--color-error-text);
}
.flash-warning {
background-color: var(--color-warning-bg);
border-color: var(--color-warning-border);
color: var(--color-warning-text);
}
.flash-info {
background-color: var(--color-info-bg);
border-color: var(--color-info-border);
color: var(--color-info-text);
}
.flash-text {
flex: 1;
font-size: 0.9375rem;
}
.flash-close {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
padding: 0;
background: transparent;
border: none;
color: inherit;
font-size: 1.25rem;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s ease;
}
.flash-close:hover {
opacity: 1;
}
@media (max-width: 768px) {
.flash-container {
top: 4rem;
left: 1rem;
right: 1rem;
max-width: none;
}
.flash-message {
padding: 0.875rem 1rem;
}
}
</style>

View File

@@ -0,0 +1,111 @@
{% extends "base.html" %}
{% block title %}{{ error_code }} - {{ error_title }}{% endblock %}
{% block meta_description %}{{ error_message }}{% endblock %}
{% block content %}
<div class="error-page" data-testid="error-page">
<div class="error-content" data-testid="error-content">
<div class="error-icon" data-testid="error-icon">
{% if error_code == 404 %}
🔍
{% elif error_code == 403 %}
🚫
{% elif error_code == 500 %}
⚠️
{% else %}
{% endif %}
</div>
<h1 class="error-code" data-testid="error-code">{{ error_code }}</h1>
<h2 class="error-title" data-testid="error-title">{{ error_title }}</h2>
<p class="error-message" data-testid="error-message">{{ error_message }}</p>
<div class="error-actions" data-testid="error-actions">
<a href="/" class="btn btn-primary" data-testid="btn-error-home">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M2 8L8 2L14 8M4 6V13H12V6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Go Home
</a>
{% if error_code == 403 %}
<a href="/auth/login" class="btn" data-testid="btn-error-login">
Sign In
</a>
{% endif %}
<button onclick="window.history.back()" class="btn btn-ghost" data-testid="btn-error-back">
Go Back
</button>
</div>
</div>
</div>
<style>
.error-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
padding: 2rem 1rem;
}
.error-content {
text-align: center;
max-width: 500px;
}
.error-icon {
font-size: 5rem;
margin-bottom: 1.5rem;
opacity: 0.8;
}
.error-code {
font-size: 6rem;
font-weight: 700;
color: var(--color-primary);
margin: 0;
line-height: 1;
}
.error-title {
font-size: 1.5rem;
color: var(--color-text-dark);
margin: 1rem 0 0.5rem;
}
.error-message {
color: var(--color-text-light);
font-size: 1.125rem;
margin-bottom: 2rem;
}
.error-actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
@media (max-width: 768px) {
.error-code {
font-size: 4rem;
}
.error-icon {
font-size: 3.5rem;
}
.error-actions {
flex-direction: column;
}
.error-actions .btn {
width: 100%;
}
}
</style>
{% endblock %}

View File

@@ -1,6 +1,15 @@
{% extends "base.html" %}
{% block title %}Blog - Home{% endblock %}
{% block meta_description %}Discover stories, thinking, and expertise from writers on any topic. A modern blog built with FastAPI.{% endblock %}
{% block meta_keywords %}blog, articles, posts, writing, fastapi, python{% endblock %}
{% block og_type %}website{% endblock %}
{% block og_title %}Blog - Home{% endblock %}
{% block og_description %}Discover stories, thinking, and expertise from writers on any topic.{% endblock %}
{% block twitter_title %}Blog - Home{% endblock %}
{% block twitter_description %}Discover stories, thinking, and expertise from writers on any topic.{% endblock %}
{% block content %}
<section class="page-header" data-testid="page-header-home">
@@ -26,7 +35,7 @@
<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>
<a href="/web/posts/{{ post.slug.value }}" 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>
@@ -55,7 +64,7 @@
<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 }}">
<a href="/web/posts/{{ post.slug.value }}" class="btn btn-sm" data-testid="btn-read-more-{{ post.id }}">
Read more
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-left: 0.25rem;">
<path d="M6 12L10 8L6 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>

View File

@@ -2,6 +2,18 @@
{% block title %}{{ post.title }} - Blog{% endblock %}
{% block meta_description %}{{ post.content.value[:160] }}{% endblock %}
{% block meta_keywords %}{{ post.tags|join(', ') }}{% endblock %}
{% block meta_author %}{{ post.author_id }}{% endblock %}
{% block canonical_url %}{{ request.base_url }}web/posts/{{ post.slug.value }}{% endblock %}
{% block og_type %}article{% endblock %}
{% block og_url %}{{ request.base_url }}web/posts/{{ post.slug.value }}{% endblock %}
{% block og_title %}{{ post.title }}{% endblock %}
{% block og_description %}{{ post.content.value[:160] }}{% endblock %}
{% block twitter_title %}{{ post.title }}{% endblock %}
{% block twitter_description %}{{ post.content.value[:160] }}{% endblock %}
{% block content %}
<article class="post-detail" data-testid="post-detail">
@@ -48,7 +60,7 @@
{% if can_edit or can_delete %}
<div class="flex gap-2" data-testid="post-detail-edit-actions">
{% if can_edit %}
<a href="/posts/{{ post.id }}/edit" class="btn" data-testid="btn-edit-post">
<a href="/web/posts/{{ post.slug.value }}/edit" class="btn" data-testid="btn-edit-post">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M11 2L14 5M2 14L3 10L12 1L15 4L6 13L2 14Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -56,7 +68,7 @@
</a>
{% endif %}
{% if can_delete %}
<form action="/posts/{{ post.id }}/delete" method="POST" style="display: inline;" data-testid="form-delete-post">
<form action="/web/posts/{{ post.slug.value }}/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?');">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M2 4h12M6 4V2a2 2 0 012-2h0a2 2 0 012 2v2m3 0v10a2 2 0 01-2 2H5a2 2 0 01-2-2V4h9z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>

View File

@@ -9,9 +9,9 @@
</h1>
</section>
<form
method="POST"
action="{% if is_edit %}/posts/{{ post.id }}/edit{% else %}/posts/new{% endif %}"
<form
method="POST"
action="{% if is_edit %}/web/posts/{{ post.slug.value }}/edit{% else %}/web/posts/new{% endif %}"
class="card"
data-testid="form-post"
>
@@ -81,7 +81,7 @@
<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">
<a href="{% if is_edit %}/web/posts/{{ post.slug.value }}{% else %}/web/{% endif %}" class="btn" data-testid="btn-cancel">
Cancel
</a>

View File

@@ -9,6 +9,7 @@ be integrated with use cases in future iterations.
"""
from app.presentation.web.auth import router as auth_router
from app.presentation.web.error_handlers import register_error_handlers
from app.presentation.web.routes import router
__all__ = ["router", "auth_router"]
__all__ = ["router", "auth_router", "register_error_handlers"]

View File

@@ -0,0 +1,188 @@
"""Error handlers and middleware for web UI.
This module provides custom error pages and flash message middleware
for the web interface.
"""
from typing import Any
from fastapi import HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from app.presentation.web.flash import FlashManager, get_flash_messages
templates = Jinja2Templates(directory="app/presentation/templates")
async def setup_flash_manager(request: Request) -> None:
"""Setup flash manager on request state.
Args:
request: FastAPI request object.
"""
request.state.flash_manager = FlashManager(request)
async def add_flash_to_response(request: Request, response: HTMLResponse) -> None:
"""Add flash cookie to response if needed.
Args:
request: FastAPI request object.
response: FastAPI response object.
"""
if hasattr(request.state, "flash_manager"):
request.state.flash_manager.set_cookie(response)
def get_template_context(request: Request) -> dict[str, Any]:
"""Get base template context with flash messages.
Args:
request: FastAPI request object.
Returns:
Template context dictionary.
"""
from app.presentation.web.deps import can_create_post, get_user_role
user = getattr(request.state, "user", None)
user_role = get_user_role(user)
return {
"request": request,
"user": user,
"user_role": user_role.value if user_role else None,
"can_create": can_create_post(user),
"flash_messages": get_flash_messages(request),
}
async def http_exception_handler(request: Request, exc: HTTPException) -> HTMLResponse:
"""Handle HTTP exceptions with custom error pages.
Args:
request: FastAPI request object.
exc: HTTPException instance.
Returns:
HTMLResponse with error page.
"""
# Handle redirects (307, 308)
if exc.status_code in (307, 308) and "Location" in exc.headers:
return RedirectResponse(url=exc.headers["Location"], status_code=exc.status_code)
error_pages = {
403: ("Access Denied", "You don't have permission to access this page."),
404: ("Page Not Found", "The page you're looking for doesn't exist."),
500: ("Server Error", "Something went wrong on our end. Please try again later."),
}
error_title, error_message = error_pages.get(
exc.status_code, ("Error", exc.detail or "An unexpected error occurred.")
)
context = get_template_context(request)
context.update(
{
"error_code": exc.status_code,
"error_title": error_title,
"error_message": error_message,
}
)
return templates.TemplateResponse(
"pages/error.html",
context,
status_code=exc.status_code,
)
async def not_found_handler(request: Request, exc: HTTPException) -> HTMLResponse:
"""Handle 404 Not Found errors.
Args:
request: FastAPI request object.
exc: HTTPException instance.
Returns:
HTMLResponse with 404 page.
"""
context = get_template_context(request)
context.update(
{
"error_code": 404,
"error_title": "Page Not Found",
"error_message": "The page you're looking for doesn't exist or has been moved.",
}
)
return templates.TemplateResponse(
"pages/error.html",
context,
status_code=404,
)
async def forbidden_handler(request: Request, exc: HTTPException) -> HTMLResponse:
"""Handle 403 Forbidden errors.
Args:
request: FastAPI request object.
exc: HTTPException instance.
Returns:
HTMLResponse with 403 page.
"""
context = get_template_context(request)
context.update(
{
"error_code": 403,
"error_title": "Access Denied",
"error_message": "You don't have permission to access this resource. Please sign in or contact an administrator.",
}
)
return templates.TemplateResponse(
"pages/error.html",
context,
status_code=403,
)
async def server_error_handler(request: Request, exc: Exception) -> HTMLResponse:
"""Handle 500 Internal Server Error.
Args:
request: FastAPI request object.
exc: Exception instance.
Returns:
HTMLResponse with 500 page.
"""
context = get_template_context(request)
context.update(
{
"error_code": 500,
"error_title": "Server Error",
"error_message": "Something went wrong on our end. Please try again later or contact support if the problem persists.",
}
)
return templates.TemplateResponse(
"pages/error.html",
context,
status_code=500,
)
def register_error_handlers(app) -> None:
"""Register error handlers with FastAPI app.
Args:
app: FastAPI application instance.
"""
app.add_exception_handler(404, not_found_handler)
app.add_exception_handler(403, forbidden_handler)
app.add_exception_handler(500, server_error_handler)
app.add_exception_handler(HTTPException, http_exception_handler)

View File

@@ -0,0 +1,160 @@
"""Flash messages middleware for web UI.
This module provides flash message functionality for the web interface,
allowing temporary messages to be passed between requests (e.g., after redirect).
Uses signed cookies for security.
"""
from fastapi import Request, Response
from itsdangerous import URLSafeSerializer
from app.infrastructure.config.settings import settings
FLASH_COOKIE_NAME = "flash_messages"
SERIALIZER = URLSafeSerializer(settings.security.secret_key.get_secret_value()) # type: ignore[union-attr]
class FlashMessage:
"""Flash message model.
Represents a single flash message with type and content.
Attributes:
message: The message text.
category: Message category (success, error, warning, info).
"""
def __init__(self, message: str, category: str = "info") -> None:
"""Initialize flash message.
Args:
message: The message text.
category: Message category (success, error, warning, info).
"""
self.message = message
self.category = category
def to_dict(self) -> dict[str, str]:
"""Convert to dictionary.
Returns:
Dictionary with message and category.
"""
return {"message": self.message, "category": self.category}
class FlashManager:
"""Manager for flash messages.
Handles storing and retrieving flash messages from cookies.
Messages are cleared after being read.
Attributes:
request: FastAPI request object.
messages: List of current messages.
"""
CATEGORIES = {
"success": "success",
"error": "error",
"warning": "warning",
"info": "info",
}
def __init__(self, request: Request) -> None:
"""Initialize flash manager.
Args:
request: FastAPI request object.
"""
self.request = request
self.messages: list[FlashMessage] = []
self._load_messages()
def _load_messages(self) -> None:
"""Load messages from cookie."""
cookie_value = self.request.cookies.get(FLASH_COOKIE_NAME)
if cookie_value:
try:
data = SERIALIZER.loads(cookie_value)
if isinstance(data, list):
self.messages = [FlashMessage(msg["message"], msg["category"]) for msg in data]
except Exception:
self.messages = []
def add(self, message: str, category: str = "info") -> None:
"""Add a flash message.
Args:
message: The message text.
category: Message category (success, error, warning, info).
"""
self.messages.append(FlashMessage(message, category))
def get_messages(self) -> list[dict[str, str]]:
"""Get all messages and clear them.
Returns:
List of message dictionaries.
"""
result = [msg.to_dict() for msg in self.messages]
self.messages = []
return result
def has_messages(self) -> bool:
"""Check if there are any messages.
Returns:
True if there are messages.
"""
return len(self.messages) > 0
def set_cookie(self, response: Response) -> None:
"""Set flash cookie on response.
Args:
response: FastAPI response object.
"""
if self.messages:
data = [msg.to_dict() for msg in self.messages]
cookie_value = SERIALIZER.dumps(data)
response.set_cookie(
key=FLASH_COOKIE_NAME,
value=cookie_value,
httponly=True,
secure=not settings.is_dev,
samesite="lax",
max_age=300, # 5 minutes
)
else:
response.delete_cookie(key=FLASH_COOKIE_NAME)
def flash(request: Request, message: str, category: str = "info") -> None:
"""Add flash message to request state.
Convenience function to add flash message.
Must be called before response is created.
Args:
request: FastAPI request object.
message: The message text.
category: Message category.
"""
if not hasattr(request.state, "flash_manager"):
request.state.flash_manager = FlashManager(request)
request.state.flash_manager.add(message, category)
def get_flash_messages(request: Request) -> list[dict[str, str]]:
"""Get flash messages from request.
Args:
request: FastAPI request object.
Returns:
List of flash message dictionaries.
"""
if hasattr(request.state, "flash_manager"):
return request.state.flash_manager.get_messages()
return []

View File

@@ -9,7 +9,7 @@ from typing import Any
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from app.infrastructure.auth import TokenInfo
@@ -22,6 +22,7 @@ from app.presentation.web.deps import (
can_see_draft,
get_user_role,
)
from app.presentation.web.flash import flash
router = APIRouter(prefix="/web", tags=["web"])
templates = Jinja2Templates(directory="app/presentation/templates")
@@ -259,11 +260,11 @@ async def new_post_form(
)
@router.post("/posts/new", response_class=HTMLResponse)
@router.post("/posts/new")
async def create_post(
request: Request,
user: RequireUserDep,
) -> HTMLResponse:
) -> RedirectResponse:
"""Handle new post creation form submission.
Args:
@@ -271,29 +272,17 @@ async def create_post(
user: Current user (required).
Returns:
HTMLResponse redirecting to the home page.
RedirectResponse to the new post or home page.
"""
context = get_base_context(user)
visible_posts = filter_visible_posts(MOCK_POSTS, user)
return templates.TemplateResponse(
request,
"pages/index.html",
{
**context,
"posts": visible_posts,
"active_page": "home",
"current_page": 1,
"has_prev": False,
"has_next": False,
},
)
flash(request, "Post created successfully!", "success")
response = RedirectResponse(url="/web/", status_code=303)
return response
@router.get("/posts/{post_id}", response_class=HTMLResponse)
@router.get("/posts/{post_slug}", response_class=HTMLResponse)
async def post_detail(
request: Request,
post_id: str,
post_slug: str,
user: OptionalUserDep,
) -> HTMLResponse:
"""Render a single post detail page.
@@ -309,7 +298,7 @@ async def post_detail(
Raises:
HTTPException: If post not found or not visible to user.
"""
post = next((p for p in MOCK_POSTS if str(p.id) == post_id), None)
post = next((p for p in MOCK_POSTS if p.slug.value == post_slug), None)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
@@ -332,10 +321,10 @@ async def post_detail(
)
@router.get("/posts/{post_id}/edit", response_class=HTMLResponse)
@router.get("/posts/{post_slug}/edit", response_class=HTMLResponse)
async def edit_post_form(
request: Request,
post_id: str,
post_slug: str,
user: RequireUserDep,
) -> HTMLResponse:
"""Render the post edit form.
@@ -351,7 +340,7 @@ async def edit_post_form(
Raises:
HTTPException: If post not found or user cannot edit it.
"""
post = next((p for p in MOCK_POSTS if str(p.id) == post_id), None)
post = next((p for p in MOCK_POSTS if p.slug.value == post_slug), None)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
@@ -373,10 +362,10 @@ async def edit_post_form(
)
@router.post("/posts/{post_id}/edit", response_class=HTMLResponse)
@router.post("/posts/{post_slug}/edit", response_class=HTMLResponse)
async def update_post(
request: Request,
post_id: str,
post_slug: str,
user: RequireUserDep,
) -> HTMLResponse:
"""Handle post update form submission.
@@ -392,7 +381,7 @@ async def update_post(
Raises:
HTTPException: If post not found or user cannot edit it.
"""
post = next((p for p in MOCK_POSTS if str(p.id) == post_id), None)
post = next((p for p in MOCK_POSTS if p.slug.value == post_slug), None)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
@@ -415,10 +404,10 @@ async def update_post(
)
@router.post("/posts/{post_id}/delete", response_class=HTMLResponse)
@router.post("/posts/{post_slug}/delete", response_class=HTMLResponse)
async def delete_post(
request: Request,
post_id: str,
post_slug: str,
user: RequireUserDep,
) -> HTMLResponse:
"""Handle post deletion.
@@ -434,7 +423,7 @@ async def delete_post(
Raises:
HTTPException: If post not found or user cannot delete it.
"""
post = next((p for p in MOCK_POSTS if str(p.id) == post_id), None)
post = next((p for p in MOCK_POSTS if p.slug.value == post_slug), None)
if not post:
raise HTTPException(status_code=404, detail="Post not found")

View File

@@ -15,6 +15,7 @@ dependencies = [
"dishka>=1.5.0",
"httpx>=0.28.0",
"jinja2>=3.1.6",
"itsdangerous>=2.2.0",
]
[build-system]

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="15" fill="#4183c4"/>
<text x="50" y="70" font-family="Arial, sans-serif" font-size="50" font-weight="bold" text-anchor="middle" fill="white">B</text>
</svg>

After

Width:  |  Height:  |  Size: 259 B

69
static/js/flash.js Normal file
View File

@@ -0,0 +1,69 @@
/**
* Flash messages functionality for blog application.
*
* Handles auto-dismissal and manual closing of flash messages.
*/
(function() {
'use strict';
const AUTO_DISMISS_DELAY = 5000; // 5 seconds
function initFlashMessages() {
const flashMessages = document.querySelectorAll('[data-testid^="flash-message-"]');
flashMessages.forEach(function(message) {
const closeBtn = message.querySelector('[data-testid="flash-close"]');
// Manual close
if (closeBtn) {
closeBtn.addEventListener('click', function() {
dismissMessage(message);
});
}
// Auto dismiss after delay
setTimeout(function() {
dismissMessage(message);
}, AUTO_DISMISS_DELAY);
// Pause auto-dismiss on hover
message.addEventListener('mouseenter', function() {
message.classList.add('paused');
});
message.addEventListener('mouseleave', function() {
message.classList.remove('paused');
});
});
}
function dismissMessage(message) {
if (message.classList.contains('paused')) {
// Retry after a short delay if paused
setTimeout(function() {
dismissMessage(message);
}, 1000);
return;
}
message.classList.add('fade-out');
setTimeout(function() {
message.remove();
// Remove container if empty
const container = document.querySelector('[data-testid="flash-container"]');
if (container && container.children.length === 0) {
container.remove();
}
}, 300);
}
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initFlashMessages);
} else {
initFlashMessages();
}
})();