feat: Implement Sprint 8 - Portfolio website expansion (MVP)
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>
This commit is contained in:
113
portfolio_app/pages/blog/index.py
Normal file
113
portfolio_app/pages/blog/index.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Blog index page - Article listing."""
|
||||
|
||||
import dash
|
||||
import dash_mantine_components as dmc
|
||||
from dash import dcc
|
||||
from dash_iconify import DashIconify
|
||||
|
||||
from portfolio_app.utils.markdown_loader import Article, get_all_articles
|
||||
|
||||
dash.register_page(__name__, path="/blog", name="Blog")
|
||||
|
||||
# Page intro
|
||||
INTRO_TEXT = (
|
||||
"I write occasionally about data engineering, automation, and the reality of being "
|
||||
"a one-person data team. No hot takes, no growth hacking—just things I've learned "
|
||||
"the hard way."
|
||||
)
|
||||
|
||||
|
||||
def create_article_card(article: Article) -> dmc.Paper:
|
||||
"""Create an article preview card."""
|
||||
meta = article["meta"]
|
||||
return dmc.Paper(
|
||||
dcc.Link(
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.Text(meta["title"], fw=600, size="lg"),
|
||||
dmc.Text(meta["date"], size="sm", c="dimmed"),
|
||||
],
|
||||
justify="space-between",
|
||||
align="flex-start",
|
||||
wrap="wrap",
|
||||
),
|
||||
dmc.Text(meta["description"], size="md", c="dimmed", lineClamp=2),
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.Badge(tag, variant="light", size="sm")
|
||||
for tag in meta.get("tags", [])[:3]
|
||||
],
|
||||
gap="xs",
|
||||
),
|
||||
],
|
||||
gap="sm",
|
||||
),
|
||||
href=f"/blog/{meta['slug']}",
|
||||
style={"textDecoration": "none", "color": "inherit"},
|
||||
),
|
||||
p="lg",
|
||||
radius="md",
|
||||
withBorder=True,
|
||||
className="article-card",
|
||||
)
|
||||
|
||||
|
||||
def create_empty_state() -> dmc.Paper:
|
||||
"""Create empty state when no articles exist."""
|
||||
return dmc.Paper(
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.ThemeIcon(
|
||||
DashIconify(icon="tabler:article-off", width=48),
|
||||
size=80,
|
||||
radius="xl",
|
||||
variant="light",
|
||||
color="gray",
|
||||
),
|
||||
dmc.Title("No Articles Yet", order=3),
|
||||
dmc.Text(
|
||||
"Articles are coming soon. Check back later!",
|
||||
size="md",
|
||||
c="dimmed",
|
||||
ta="center",
|
||||
),
|
||||
],
|
||||
align="center",
|
||||
gap="md",
|
||||
py="xl",
|
||||
),
|
||||
p="xl",
|
||||
radius="md",
|
||||
withBorder=True,
|
||||
)
|
||||
|
||||
|
||||
def layout() -> dmc.Container:
|
||||
"""Generate the blog index layout dynamically."""
|
||||
articles = get_all_articles(include_drafts=False)
|
||||
|
||||
return dmc.Container(
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Title("Blog", order=1, ta="center"),
|
||||
dmc.Text(
|
||||
INTRO_TEXT, size="md", c="dimmed", ta="center", maw=600, mx="auto"
|
||||
),
|
||||
dmc.Divider(my="lg"),
|
||||
(
|
||||
dmc.Stack(
|
||||
[create_article_card(article) for article in articles],
|
||||
gap="lg",
|
||||
)
|
||||
if articles
|
||||
else create_empty_state()
|
||||
),
|
||||
dmc.Space(h=40),
|
||||
],
|
||||
gap="lg",
|
||||
),
|
||||
size="md",
|
||||
py="xl",
|
||||
)
|
||||
Reference in New Issue
Block a user