base ui #11
10
app/main.py
10
app/main.py
@@ -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,
|
||||
|
||||
@@ -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">×</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>
|
||||
|
||||
111
app/presentation/templates/pages/error.html
Normal file
111
app/presentation/templates/pages/error.html
Normal 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 %}
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
188
app/presentation/web/error_handlers.py
Normal file
188
app/presentation/web/error_handlers.py
Normal 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)
|
||||
160
app/presentation/web/flash.py
Normal file
160
app/presentation/web/flash.py
Normal 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 []
|
||||
@@ -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")
|
||||
|
||||
@@ -15,6 +15,7 @@ dependencies = [
|
||||
"dishka>=1.5.0",
|
||||
"httpx>=0.28.0",
|
||||
"jinja2>=3.1.6",
|
||||
"itsdangerous>=2.2.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
4
static/images/favicon.svg
Normal file
4
static/images/favicon.svg
Normal 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
69
static/js/flash.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user