feat(ui): add error handling, flash messages and SEO optimization
- Add custom error pages (404, 403, 500) with user-friendly messages
- Add flash message system with signed cookies for security
- Add toast notifications with auto-dismiss and manual close
- Add comprehensive SEO meta tags (description, keywords, OG, Twitter)
- Add canonical URLs for SEO
- Update routes to use slug-based URLs (/posts/{slug} instead of /posts/{id})
- Add Open Graph and Twitter Card meta tags for social sharing
- Add favicon SVG
- Update all templates with proper meta tags and URLs
- Add error handlers registration in main.py
- Add flash middleware for request handling
- Install itsdangerous dependency
This commit is contained in:
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 import router
|
||||||
from app.presentation.web import auth_router
|
from app.presentation.web import auth_router
|
||||||
from app.presentation.web import router as web_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
|
@asynccontextmanager
|
||||||
@@ -71,6 +73,14 @@ def app_factory() -> FastAPI:
|
|||||||
setup_dishka(container, app)
|
setup_dishka(container, app)
|
||||||
|
|
||||||
register_exception_handlers(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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
@@ -1,9 +1,35 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ru" data-testid="html-root">
|
<html lang="en" data-testid="html-root">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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>
|
<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-light.css" data-testid="theme-light-stylesheet">
|
||||||
@@ -17,6 +43,18 @@
|
|||||||
<body data-testid="body">
|
<body data-testid="body">
|
||||||
{% include "partials/header.html" %}
|
{% 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">
|
<main class="main-wrapper" data-testid="main-content">
|
||||||
<div class="container" data-testid="container">
|
<div class="container" data-testid="container">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
@@ -26,6 +64,119 @@
|
|||||||
{% include "partials/footer.html" %}
|
{% include "partials/footer.html" %}
|
||||||
|
|
||||||
<script src="/static/js/theme.js" data-testid="theme-script"></script>
|
<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 %}
|
{% block extra_js %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Blog - Home{% endblock %}
|
{% 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 %}
|
{% block content %}
|
||||||
<section class="page-header" data-testid="page-header-home">
|
<section class="page-header" data-testid="page-header-home">
|
||||||
@@ -26,7 +35,7 @@
|
|||||||
<article class="card post-card" data-testid="post-card-{{ post.id }}">
|
<article class="card post-card" data-testid="post-card-{{ post.id }}">
|
||||||
<div class="post-card-header" data-testid="post-card-header-{{ 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 }}">
|
<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>
|
</h2>
|
||||||
{% if post.published %}
|
{% if post.published %}
|
||||||
<span class="badge badge-success" data-testid="post-status-{{ post.id }}">Published</span>
|
<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>
|
<span class="tag" data-testid="post-tag-{{ post.id }}-{{ loop.index }}">{{ tag }}</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</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
|
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;">
|
<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"/>
|
<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 title %}{{ post.title }} - Blog{% endblock %}
|
||||||
{% block meta_description %}{{ post.content.value[:160] }}{% 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 %}
|
{% block content %}
|
||||||
<article class="post-detail" data-testid="post-detail">
|
<article class="post-detail" data-testid="post-detail">
|
||||||
@@ -48,7 +60,7 @@
|
|||||||
{% if can_edit or can_delete %}
|
{% if can_edit or can_delete %}
|
||||||
<div class="flex gap-2" data-testid="post-detail-edit-actions">
|
<div class="flex gap-2" data-testid="post-detail-edit-actions">
|
||||||
{% if can_edit %}
|
{% 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;">
|
<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"/>
|
<path d="M11 2L14 5M2 14L3 10L12 1L15 4L6 13L2 14Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -56,7 +68,7 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if can_delete %}
|
{% 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?');">
|
<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;">
|
<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"/>
|
<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>
|
</h1>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="{% if is_edit %}/posts/{{ post.id }}/edit{% else %}/posts/new{% endif %}"
|
action="{% if is_edit %}/web/posts/{{ post.slug.value }}/edit{% else %}/web/posts/new{% endif %}"
|
||||||
class="card"
|
class="card"
|
||||||
data-testid="form-post"
|
data-testid="form-post"
|
||||||
>
|
>
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
|
|
||||||
<div class="card-footer" data-testid="form-post-footer">
|
<div class="card-footer" data-testid="form-post-footer">
|
||||||
<div class="flex justify-between items-center" data-testid="form-actions">
|
<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
|
Cancel
|
||||||
</a>
|
</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.auth import router as auth_router
|
||||||
|
from app.presentation.web.error_handlers import register_error_handlers
|
||||||
from app.presentation.web.routes import router
|
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 uuid import uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from app.infrastructure.auth import TokenInfo
|
from app.infrastructure.auth import TokenInfo
|
||||||
@@ -22,6 +22,7 @@ from app.presentation.web.deps import (
|
|||||||
can_see_draft,
|
can_see_draft,
|
||||||
get_user_role,
|
get_user_role,
|
||||||
)
|
)
|
||||||
|
from app.presentation.web.flash import flash
|
||||||
|
|
||||||
router = APIRouter(prefix="/web", tags=["web"])
|
router = APIRouter(prefix="/web", tags=["web"])
|
||||||
templates = Jinja2Templates(directory="app/presentation/templates")
|
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(
|
async def create_post(
|
||||||
request: Request,
|
request: Request,
|
||||||
user: RequireUserDep,
|
user: RequireUserDep,
|
||||||
) -> HTMLResponse:
|
) -> RedirectResponse:
|
||||||
"""Handle new post creation form submission.
|
"""Handle new post creation form submission.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -271,29 +272,17 @@ async def create_post(
|
|||||||
user: Current user (required).
|
user: Current user (required).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
HTMLResponse redirecting to the home page.
|
RedirectResponse to the new post or home page.
|
||||||
"""
|
"""
|
||||||
context = get_base_context(user)
|
flash(request, "Post created successfully!", "success")
|
||||||
visible_posts = filter_visible_posts(MOCK_POSTS, user)
|
response = RedirectResponse(url="/web/", status_code=303)
|
||||||
|
return response
|
||||||
return templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
"pages/index.html",
|
|
||||||
{
|
|
||||||
**context,
|
|
||||||
"posts": visible_posts,
|
|
||||||
"active_page": "home",
|
|
||||||
"current_page": 1,
|
|
||||||
"has_prev": False,
|
|
||||||
"has_next": False,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/posts/{post_id}", response_class=HTMLResponse)
|
@router.get("/posts/{post_slug}", response_class=HTMLResponse)
|
||||||
async def post_detail(
|
async def post_detail(
|
||||||
request: Request,
|
request: Request,
|
||||||
post_id: str,
|
post_slug: str,
|
||||||
user: OptionalUserDep,
|
user: OptionalUserDep,
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
"""Render a single post detail page.
|
"""Render a single post detail page.
|
||||||
@@ -309,7 +298,7 @@ async def post_detail(
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: If post not found or not visible to user.
|
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:
|
if not post:
|
||||||
raise HTTPException(status_code=404, detail="Post not found")
|
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(
|
async def edit_post_form(
|
||||||
request: Request,
|
request: Request,
|
||||||
post_id: str,
|
post_slug: str,
|
||||||
user: RequireUserDep,
|
user: RequireUserDep,
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
"""Render the post edit form.
|
"""Render the post edit form.
|
||||||
@@ -351,7 +340,7 @@ async def edit_post_form(
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: If post not found or user cannot edit it.
|
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:
|
if not post:
|
||||||
raise HTTPException(status_code=404, detail="Post not found")
|
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(
|
async def update_post(
|
||||||
request: Request,
|
request: Request,
|
||||||
post_id: str,
|
post_slug: str,
|
||||||
user: RequireUserDep,
|
user: RequireUserDep,
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
"""Handle post update form submission.
|
"""Handle post update form submission.
|
||||||
@@ -392,7 +381,7 @@ async def update_post(
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: If post not found or user cannot edit it.
|
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:
|
if not post:
|
||||||
raise HTTPException(status_code=404, detail="Post not found")
|
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(
|
async def delete_post(
|
||||||
request: Request,
|
request: Request,
|
||||||
post_id: str,
|
post_slug: str,
|
||||||
user: RequireUserDep,
|
user: RequireUserDep,
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
"""Handle post deletion.
|
"""Handle post deletion.
|
||||||
@@ -434,7 +423,7 @@ async def delete_post(
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: If post not found or user cannot delete it.
|
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:
|
if not post:
|
||||||
raise HTTPException(status_code=404, detail="Post not found")
|
raise HTTPException(status_code=404, detail="Post not found")
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ dependencies = [
|
|||||||
"dishka>=1.5.0",
|
"dishka>=1.5.0",
|
||||||
"httpx>=0.28.0",
|
"httpx>=0.28.0",
|
||||||
"jinja2>=3.1.6",
|
"jinja2>=3.1.6",
|
||||||
|
"itsdangerous>=2.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[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