New pages: - Home: Redesigned with hero, impact stats, featured project - About: 6-section professional narrative - Projects: Hub with 4 project cards and status badges - Resume: Inline display with download placeholders - Contact: Form UI (disabled) with contact info - Blog: Markdown-based system with frontmatter support Infrastructure: - Blog system with markdown loader (python-frontmatter, markdown, pygments) - Sidebar callback for active state highlighting on navigation - Separated navigation into main pages and projects/dashboards groups Closes #36, #37, #38, #39, #40, #41, #42, #43 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
148 lines
4.8 KiB
Python
148 lines
4.8 KiB
Python
"""Blog article page - Dynamic routing for individual articles."""
|
|
|
|
import dash
|
|
import dash_mantine_components as dmc
|
|
from dash import dcc, html
|
|
from dash_iconify import DashIconify
|
|
|
|
from portfolio_app.utils.markdown_loader import get_article
|
|
|
|
dash.register_page(
|
|
__name__,
|
|
path_template="/blog/<slug>",
|
|
name="Article",
|
|
)
|
|
|
|
|
|
def create_not_found() -> dmc.Container:
|
|
"""Create 404 state for missing articles."""
|
|
return dmc.Container(
|
|
dmc.Stack(
|
|
[
|
|
dmc.ThemeIcon(
|
|
DashIconify(icon="tabler:file-unknown", width=48),
|
|
size=80,
|
|
radius="xl",
|
|
variant="light",
|
|
color="red",
|
|
),
|
|
dmc.Title("Article Not Found", order=2),
|
|
dmc.Text(
|
|
"The article you're looking for doesn't exist or has been moved.",
|
|
size="md",
|
|
c="dimmed",
|
|
ta="center",
|
|
),
|
|
dcc.Link(
|
|
dmc.Button(
|
|
"Back to Blog",
|
|
variant="light",
|
|
leftSection=DashIconify(icon="tabler:arrow-left", width=18),
|
|
),
|
|
href="/blog",
|
|
),
|
|
],
|
|
align="center",
|
|
gap="md",
|
|
py="xl",
|
|
),
|
|
size="md",
|
|
py="xl",
|
|
)
|
|
|
|
|
|
def layout(slug: str = "") -> dmc.Container:
|
|
"""Generate the article layout dynamically.
|
|
|
|
Args:
|
|
slug: Article slug from URL path.
|
|
"""
|
|
if not slug:
|
|
return create_not_found()
|
|
|
|
article = get_article(slug)
|
|
if not article:
|
|
return create_not_found()
|
|
|
|
meta = article["meta"]
|
|
|
|
return dmc.Container(
|
|
dmc.Stack(
|
|
[
|
|
# Back link
|
|
dcc.Link(
|
|
dmc.Group(
|
|
[
|
|
DashIconify(icon="tabler:arrow-left", width=16),
|
|
dmc.Text("Back to Blog", size="sm"),
|
|
],
|
|
gap="xs",
|
|
),
|
|
href="/blog",
|
|
style={"textDecoration": "none"},
|
|
),
|
|
# Article header
|
|
dmc.Paper(
|
|
dmc.Stack(
|
|
[
|
|
dmc.Title(meta["title"], order=1),
|
|
dmc.Group(
|
|
[
|
|
dmc.Group(
|
|
[
|
|
DashIconify(
|
|
icon="tabler:calendar", width=16
|
|
),
|
|
dmc.Text(
|
|
meta["date"], size="sm", c="dimmed"
|
|
),
|
|
],
|
|
gap="xs",
|
|
),
|
|
dmc.Group(
|
|
[
|
|
dmc.Badge(tag, variant="light", size="sm")
|
|
for tag in meta.get("tags", [])
|
|
],
|
|
gap="xs",
|
|
),
|
|
],
|
|
justify="space-between",
|
|
wrap="wrap",
|
|
),
|
|
(
|
|
dmc.Text(meta["description"], size="lg", c="dimmed")
|
|
if meta.get("description")
|
|
else None
|
|
),
|
|
],
|
|
gap="sm",
|
|
),
|
|
p="xl",
|
|
radius="md",
|
|
withBorder=True,
|
|
),
|
|
# Article content
|
|
dmc.Paper(
|
|
html.Div(
|
|
# Render HTML content from markdown
|
|
# Using dangerously_allow_html via dcc.Markdown or html.Div
|
|
dcc.Markdown(
|
|
article["content"],
|
|
className="article-content",
|
|
dangerously_allow_html=True,
|
|
),
|
|
),
|
|
p="xl",
|
|
radius="md",
|
|
withBorder=True,
|
|
className="article-body",
|
|
),
|
|
dmc.Space(h=40),
|
|
],
|
|
gap="lg",
|
|
),
|
|
size="md",
|
|
py="xl",
|
|
)
|