staging #96
@@ -1,5 +1,5 @@
|
||||
"""Application-level callbacks for the portfolio app."""
|
||||
|
||||
from . import theme
|
||||
from . import sidebar, theme
|
||||
|
||||
__all__ = ["theme"]
|
||||
__all__ = ["sidebar", "theme"]
|
||||
|
||||
25
portfolio_app/callbacks/sidebar.py
Normal file
25
portfolio_app/callbacks/sidebar.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Sidebar navigation callbacks for active state updates."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from dash import Input, Output, callback
|
||||
|
||||
from portfolio_app.components.sidebar import create_sidebar_content
|
||||
|
||||
|
||||
@callback( # type: ignore[misc]
|
||||
Output("floating-sidebar", "children"),
|
||||
Input("url", "pathname"),
|
||||
prevent_initial_call=False,
|
||||
)
|
||||
def update_sidebar_active_state(pathname: str) -> list[Any]:
|
||||
"""Update sidebar to highlight the current page.
|
||||
|
||||
Args:
|
||||
pathname: Current URL pathname from dcc.Location.
|
||||
|
||||
Returns:
|
||||
Updated sidebar content with correct active state.
|
||||
"""
|
||||
current_path = pathname or "/"
|
||||
return create_sidebar_content(current_path=current_path)
|
||||
@@ -4,9 +4,18 @@ import dash_mantine_components as dmc
|
||||
from dash import dcc, html
|
||||
from dash_iconify import DashIconify
|
||||
|
||||
# Navigation items configuration
|
||||
NAV_ITEMS = [
|
||||
# Navigation items configuration - main pages
|
||||
NAV_ITEMS_MAIN = [
|
||||
{"path": "/", "icon": "tabler:home", "label": "Home"},
|
||||
{"path": "/about", "icon": "tabler:user", "label": "About"},
|
||||
{"path": "/blog", "icon": "tabler:article", "label": "Blog"},
|
||||
{"path": "/resume", "icon": "tabler:file-text", "label": "Resume"},
|
||||
{"path": "/contact", "icon": "tabler:mail", "label": "Contact"},
|
||||
]
|
||||
|
||||
# Navigation items configuration - projects/dashboards (separated)
|
||||
NAV_ITEMS_PROJECTS = [
|
||||
{"path": "/projects", "icon": "tabler:folder", "label": "Projects"},
|
||||
{"path": "/toronto", "icon": "tabler:map-2", "label": "Toronto Housing"},
|
||||
]
|
||||
|
||||
@@ -135,6 +144,59 @@ def create_sidebar_divider() -> html.Div:
|
||||
return html.Div(className="sidebar-divider")
|
||||
|
||||
|
||||
def create_sidebar_content(
|
||||
current_path: str = "/", current_theme: str = "dark"
|
||||
) -> list[dmc.Tooltip | html.Div]:
|
||||
"""Create the sidebar content list.
|
||||
|
||||
Args:
|
||||
current_path: Current page path for active state highlighting.
|
||||
current_theme: Current theme for toggle icon state.
|
||||
|
||||
Returns:
|
||||
List of sidebar components.
|
||||
"""
|
||||
return [
|
||||
# Brand logo
|
||||
create_brand_logo(),
|
||||
create_sidebar_divider(),
|
||||
# Main navigation icons
|
||||
*[
|
||||
create_nav_icon(
|
||||
icon=item["icon"],
|
||||
label=item["label"],
|
||||
path=item["path"],
|
||||
current_path=current_path,
|
||||
)
|
||||
for item in NAV_ITEMS_MAIN
|
||||
],
|
||||
create_sidebar_divider(),
|
||||
# Dashboard/Project links
|
||||
*[
|
||||
create_nav_icon(
|
||||
icon=item["icon"],
|
||||
label=item["label"],
|
||||
path=item["path"],
|
||||
current_path=current_path,
|
||||
)
|
||||
for item in NAV_ITEMS_PROJECTS
|
||||
],
|
||||
create_sidebar_divider(),
|
||||
# Theme toggle
|
||||
create_theme_toggle(current_theme),
|
||||
create_sidebar_divider(),
|
||||
# External links
|
||||
*[
|
||||
create_external_link(
|
||||
url=link["url"],
|
||||
icon=link["icon"],
|
||||
label=link["label"],
|
||||
)
|
||||
for link in EXTERNAL_LINKS
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
def create_sidebar(current_path: str = "/", current_theme: str = "dark") -> html.Div:
|
||||
"""Create the floating sidebar navigation.
|
||||
|
||||
@@ -146,34 +208,7 @@ def create_sidebar(current_path: str = "/", current_theme: str = "dark") -> html
|
||||
Complete sidebar component.
|
||||
"""
|
||||
return html.Div(
|
||||
[
|
||||
# Brand logo
|
||||
create_brand_logo(),
|
||||
create_sidebar_divider(),
|
||||
# Navigation icons
|
||||
*[
|
||||
create_nav_icon(
|
||||
icon=item["icon"],
|
||||
label=item["label"],
|
||||
path=item["path"],
|
||||
current_path=current_path,
|
||||
)
|
||||
for item in NAV_ITEMS
|
||||
],
|
||||
create_sidebar_divider(),
|
||||
# Theme toggle
|
||||
create_theme_toggle(current_theme),
|
||||
create_sidebar_divider(),
|
||||
# External links
|
||||
*[
|
||||
create_external_link(
|
||||
url=link["url"],
|
||||
icon=link["icon"],
|
||||
label=link["label"],
|
||||
)
|
||||
for link in EXTERNAL_LINKS
|
||||
],
|
||||
],
|
||||
className="floating-sidebar",
|
||||
id="floating-sidebar",
|
||||
className="floating-sidebar",
|
||||
children=create_sidebar_content(current_path, current_theme),
|
||||
)
|
||||
|
||||
111
portfolio_app/content/blog/building-data-platform-team-of-one.md
Normal file
111
portfolio_app/content/blog/building-data-platform-team-of-one.md
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
title: "Building a Data Platform as a Team of One"
|
||||
date: "2025-01-15"
|
||||
description: "What I learned from 5 years as the sole data professional at a mid-size company"
|
||||
tags:
|
||||
- data-engineering
|
||||
- career
|
||||
- lessons-learned
|
||||
status: published
|
||||
---
|
||||
|
||||
When I joined Summitt Energy in 2019, there was no data infrastructure. No warehouse. No pipelines. No documentation. Just a collection of spreadsheets and a Genesys Cloud instance spitting out CSVs.
|
||||
|
||||
Five years later, I'd built DataFlow: an enterprise platform processing 1B+ rows across 21 tables, feeding dashboards that executives actually opened. Here's what I learned doing it alone.
|
||||
|
||||
## The Reality of "Full Stack Data"
|
||||
|
||||
When you're the only data person, "full stack" isn't a buzzword—it's survival. In a single week, I might:
|
||||
|
||||
- Debug a Python ETL script at 7am because overnight loads failed
|
||||
- Present quarterly metrics to leadership at 10am
|
||||
- Design a new dimensional model over lunch
|
||||
- Write SQL transformations in the afternoon
|
||||
- Handle ad-hoc "can you pull this data?" requests between meetings
|
||||
|
||||
There's no handoff. No "that's not my job." Everything is your job.
|
||||
|
||||
## Prioritization Frameworks
|
||||
|
||||
The hardest part isn't the technical work—it's deciding what to build first when everything feels urgent.
|
||||
|
||||
### The 80/20 Rule, Applied Ruthlessly
|
||||
|
||||
I asked myself: **What 20% of the data drives 80% of decisions?**
|
||||
|
||||
For a contact center, that turned out to be:
|
||||
- Call volume by interval
|
||||
- Abandon rate
|
||||
- Average handle time
|
||||
- Service level
|
||||
|
||||
Everything else was nice-to-have. I built those four metrics first, got them bulletproof, then expanded.
|
||||
|
||||
### The "Who's Screaming?" Test
|
||||
|
||||
When multiple stakeholders want different things:
|
||||
1. Who has executive backing?
|
||||
2. What's blocking revenue?
|
||||
3. What's causing visible pain?
|
||||
|
||||
If nobody's screaming, it can probably wait.
|
||||
|
||||
## Technical Debt vs. Shipping
|
||||
|
||||
I rewrote DataFlow three times:
|
||||
|
||||
- **v1 (2020)**: Hacky Python scripts. Worked, barely.
|
||||
- **v2 (2021)**: Proper dimensional model. Still messy code.
|
||||
- **v3 (2022)**: SQLAlchemy ORM, proper error handling, logging.
|
||||
- **v4 (2023)**: dbt-style transformations, FastAPI layer.
|
||||
|
||||
Was v1 embarrassing? Yes. Did it work? Also yes.
|
||||
|
||||
**The lesson**: Ship something that works, then iterate. Perfect is the enemy of done, especially when you're alone.
|
||||
|
||||
## Building Stakeholder Trust
|
||||
|
||||
The technical work is maybe 40% of the job. The rest is politics.
|
||||
|
||||
### Quick Wins First
|
||||
|
||||
Before asking for resources or patience, I delivered:
|
||||
- Automated a weekly report that took someone 4 hours
|
||||
- Fixed a dashboard that had been wrong for months
|
||||
- Built a simple tool that answered a frequent question
|
||||
|
||||
Trust is earned in small deposits.
|
||||
|
||||
### Speak Their Language
|
||||
|
||||
Executives don't care about your star schema. They care about:
|
||||
- "This will save 10 hours/week"
|
||||
- "This will catch errors before they hit customers"
|
||||
- "This will let you see X in real-time"
|
||||
|
||||
Translate technical work into business outcomes.
|
||||
|
||||
## What I'd Do Differently
|
||||
|
||||
1. **Document earlier**. I waited too long. When I finally wrote things down, onboarding became possible.
|
||||
|
||||
2. **Say no more**. Every "yes" to an ad-hoc request is a "no" to infrastructure work. Guard your time.
|
||||
|
||||
3. **Build monitoring first**. I spent too many mornings discovering failures manually. Alerting should be table stakes.
|
||||
|
||||
4. **Version control everything**. Even SQL. Even documentation. If it's not in Git, it doesn't exist.
|
||||
|
||||
## The Upside
|
||||
|
||||
Being a team of one forced me to learn things I'd have specialized away from on a bigger team:
|
||||
- Data modeling
|
||||
- Pipeline architecture
|
||||
- Dashboard design
|
||||
- Stakeholder management
|
||||
- System administration
|
||||
|
||||
It's brutal, but it makes you dangerous. You understand the whole stack.
|
||||
|
||||
---
|
||||
|
||||
*This is part of a series on building data infrastructure at small companies. More posts coming on dimensional modeling, dbt patterns, and surviving legacy systems.*
|
||||
248
portfolio_app/pages/about.py
Normal file
248
portfolio_app/pages/about.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""About page - Professional narrative and background."""
|
||||
|
||||
import dash
|
||||
import dash_mantine_components as dmc
|
||||
from dash import dcc
|
||||
from dash_iconify import DashIconify
|
||||
|
||||
dash.register_page(__name__, path="/about", name="About")
|
||||
|
||||
# Opening section
|
||||
OPENING = """I didn't start in data. I started in project management—CAPM certified, ITIL trained, \
|
||||
the whole corporate playbook. Then I realized I liked building systems more than managing timelines, \
|
||||
and I was better at automating reports than attending meetings about them.
|
||||
|
||||
That pivot led me to where I am now: 8 years deep in data engineering, analytics, and the messy \
|
||||
reality of turning raw information into something people can actually use."""
|
||||
|
||||
# What I Actually Do section
|
||||
WHAT_I_DO_SHORT = "The short version: I build data infrastructure. Pipelines, warehouses, \
|
||||
dashboards, automation—the invisible machinery that makes businesses run on data instead of gut feelings."
|
||||
|
||||
WHAT_I_DO_LONG = """The longer version: At Summitt Energy, I've been the sole data professional \
|
||||
supporting 150+ employees across 9 markets (Canada and US). I inherited nothing—no data warehouse, \
|
||||
no reporting infrastructure, no documentation. Over 5 years, I built DataFlow: an enterprise \
|
||||
platform processing 1B+ rows, integrating contact center data, CRM systems, and legacy tools \
|
||||
that definitely weren't designed to talk to each other.
|
||||
|
||||
That meant learning to be a generalist. I've done ETL pipeline development (Python, SQLAlchemy), \
|
||||
dimensional modeling, dashboard design (Power BI, Plotly-Dash), API integration, and more \
|
||||
stakeholder management than I'd like to admit. When you're the only data person, you learn to wear every hat."""
|
||||
|
||||
# How I Think About Data
|
||||
DATA_PHILOSOPHY_INTRO = "I'm not interested in data for data's sake. The question I always \
|
||||
start with: What decision does this help someone make?"
|
||||
|
||||
DATA_PHILOSOPHY_DETAIL = """Most of my work has been in operations-heavy environments—contact \
|
||||
centers, energy retail, logistics. These aren't glamorous domains, but they're where data can \
|
||||
have massive impact. A 30% improvement in abandon rate isn't just a metric; it's thousands of \
|
||||
customers who didn't hang up frustrated. A 40% reduction in reporting time means managers can \
|
||||
actually manage instead of wrestling with spreadsheets."""
|
||||
|
||||
DATA_PHILOSOPHY_CLOSE = "I care about outcomes, not technology stacks."
|
||||
|
||||
# Technical skills
|
||||
TECH_SKILLS = {
|
||||
"Languages": "Python (Pandas, SQLAlchemy, FastAPI), SQL (MSSQL, PostgreSQL), R, VBA",
|
||||
"Data Engineering": "ETL/ELT pipelines, dimensional modeling (star schema), dbt patterns, batch processing, API integration, web scraping (Selenium)",
|
||||
"Visualization": "Plotly/Dash, Power BI, Tableau",
|
||||
"Platforms": "Genesys Cloud, Five9, Zoho, Azure DevOps",
|
||||
"Currently Learning": "Cloud certification (Azure DP-203), Airflow, Snowflake",
|
||||
}
|
||||
|
||||
# Outside Work
|
||||
OUTSIDE_WORK_INTRO = "I'm a Brazilian-Canadian based in Toronto. I speak Portuguese (native), \
|
||||
English (fluent), and enough Spanish to survive."
|
||||
|
||||
OUTSIDE_WORK_ACTIVITIES = [
|
||||
"Building automation tools for small businesses through Bandit Labs (my side project)",
|
||||
"Contributing to open source (MCP servers, Claude Code plugins)",
|
||||
'Trying to explain to my kid why Daddy\'s job involves "making computers talk to each other"',
|
||||
]
|
||||
|
||||
# What I'm Looking For
|
||||
LOOKING_FOR_INTRO = "I'm currently exploring Senior Data Analyst and Data Engineer roles in \
|
||||
the Toronto area (or remote). I'm most interested in:"
|
||||
|
||||
LOOKING_FOR_ITEMS = [
|
||||
"Companies that treat data as infrastructure, not an afterthought",
|
||||
"Teams where I can contribute to architecture decisions, not just execute tickets",
|
||||
"Operations-focused industries (energy, logistics, financial services, contact center tech)",
|
||||
]
|
||||
|
||||
LOOKING_FOR_CLOSE = "If that sounds like your team, let's talk."
|
||||
|
||||
|
||||
def create_section_title(title: str) -> dmc.Title:
|
||||
"""Create a consistent section title."""
|
||||
return dmc.Title(title, order=2, size="h3", mb="sm")
|
||||
|
||||
|
||||
def create_opening_section() -> dmc.Paper:
|
||||
"""Create the opening/intro section."""
|
||||
paragraphs = OPENING.split("\n\n")
|
||||
return dmc.Paper(
|
||||
dmc.Stack(
|
||||
[dmc.Text(p, size="md") for p in paragraphs],
|
||||
gap="md",
|
||||
),
|
||||
p="xl",
|
||||
radius="md",
|
||||
withBorder=True,
|
||||
)
|
||||
|
||||
|
||||
def create_what_i_do_section() -> dmc.Paper:
|
||||
"""Create the What I Actually Do section."""
|
||||
return dmc.Paper(
|
||||
dmc.Stack(
|
||||
[
|
||||
create_section_title("What I Actually Do"),
|
||||
dmc.Text(WHAT_I_DO_SHORT, size="md", fw=500),
|
||||
dmc.Text(WHAT_I_DO_LONG, size="md"),
|
||||
],
|
||||
gap="md",
|
||||
),
|
||||
p="xl",
|
||||
radius="md",
|
||||
withBorder=True,
|
||||
)
|
||||
|
||||
|
||||
def create_philosophy_section() -> dmc.Paper:
|
||||
"""Create the How I Think About Data section."""
|
||||
return dmc.Paper(
|
||||
dmc.Stack(
|
||||
[
|
||||
create_section_title("How I Think About Data"),
|
||||
dmc.Text(DATA_PHILOSOPHY_INTRO, size="md", fw=500),
|
||||
dmc.Text(DATA_PHILOSOPHY_DETAIL, size="md"),
|
||||
dmc.Text(DATA_PHILOSOPHY_CLOSE, size="md", fw=500, fs="italic"),
|
||||
],
|
||||
gap="md",
|
||||
),
|
||||
p="xl",
|
||||
radius="md",
|
||||
withBorder=True,
|
||||
)
|
||||
|
||||
|
||||
def create_tech_section() -> dmc.Paper:
|
||||
"""Create the Technical Stuff section."""
|
||||
return dmc.Paper(
|
||||
dmc.Stack(
|
||||
[
|
||||
create_section_title("The Technical Stuff"),
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.Text(category + ":", fw=600, size="sm", w=150),
|
||||
dmc.Text(skills, size="sm", c="dimmed"),
|
||||
],
|
||||
gap="sm",
|
||||
align="flex-start",
|
||||
wrap="nowrap",
|
||||
)
|
||||
for category, skills in TECH_SKILLS.items()
|
||||
],
|
||||
gap="xs",
|
||||
),
|
||||
],
|
||||
gap="md",
|
||||
),
|
||||
p="xl",
|
||||
radius="md",
|
||||
withBorder=True,
|
||||
)
|
||||
|
||||
|
||||
def create_outside_work_section() -> dmc.Paper:
|
||||
"""Create the Outside Work section."""
|
||||
return dmc.Paper(
|
||||
dmc.Stack(
|
||||
[
|
||||
create_section_title("Outside Work"),
|
||||
dmc.Text(OUTSIDE_WORK_INTRO, size="md"),
|
||||
dmc.Text("When I'm not staring at SQL, I'm usually:", size="md"),
|
||||
dmc.List(
|
||||
[
|
||||
dmc.ListItem(dmc.Text(item, size="md"))
|
||||
for item in OUTSIDE_WORK_ACTIVITIES
|
||||
],
|
||||
spacing="xs",
|
||||
),
|
||||
],
|
||||
gap="md",
|
||||
),
|
||||
p="xl",
|
||||
radius="md",
|
||||
withBorder=True,
|
||||
)
|
||||
|
||||
|
||||
def create_looking_for_section() -> dmc.Paper:
|
||||
"""Create the What I'm Looking For section."""
|
||||
return dmc.Paper(
|
||||
dmc.Stack(
|
||||
[
|
||||
create_section_title("What I'm Looking For"),
|
||||
dmc.Text(LOOKING_FOR_INTRO, size="md"),
|
||||
dmc.List(
|
||||
[
|
||||
dmc.ListItem(dmc.Text(item, size="md"))
|
||||
for item in LOOKING_FOR_ITEMS
|
||||
],
|
||||
spacing="xs",
|
||||
),
|
||||
dmc.Text(LOOKING_FOR_CLOSE, size="md", fw=500),
|
||||
dmc.Group(
|
||||
[
|
||||
dcc.Link(
|
||||
dmc.Button(
|
||||
"Download Resume",
|
||||
variant="filled",
|
||||
leftSection=DashIconify(
|
||||
icon="tabler:download", width=18
|
||||
),
|
||||
),
|
||||
href="/resume",
|
||||
),
|
||||
dcc.Link(
|
||||
dmc.Button(
|
||||
"Contact Me",
|
||||
variant="outline",
|
||||
leftSection=DashIconify(icon="tabler:mail", width=18),
|
||||
),
|
||||
href="/contact",
|
||||
),
|
||||
],
|
||||
gap="sm",
|
||||
mt="md",
|
||||
),
|
||||
],
|
||||
gap="md",
|
||||
),
|
||||
p="xl",
|
||||
radius="md",
|
||||
withBorder=True,
|
||||
)
|
||||
|
||||
|
||||
layout = dmc.Container(
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Title("About", order=1, ta="center", mb="lg"),
|
||||
create_opening_section(),
|
||||
create_what_i_do_section(),
|
||||
create_philosophy_section(),
|
||||
create_tech_section(),
|
||||
create_outside_work_section(),
|
||||
create_looking_for_section(),
|
||||
dmc.Space(h=40),
|
||||
],
|
||||
gap="xl",
|
||||
),
|
||||
size="md",
|
||||
py="xl",
|
||||
)
|
||||
1
portfolio_app/pages/blog/__init__.py
Normal file
1
portfolio_app/pages/blog/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Blog pages package."""
|
||||
147
portfolio_app/pages/blog/article.py
Normal file
147
portfolio_app/pages/blog/article.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""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",
|
||||
)
|
||||
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",
|
||||
)
|
||||
287
portfolio_app/pages/contact.py
Normal file
287
portfolio_app/pages/contact.py
Normal file
@@ -0,0 +1,287 @@
|
||||
"""Contact page - Form UI and direct contact information."""
|
||||
|
||||
import dash
|
||||
import dash_mantine_components as dmc
|
||||
from dash_iconify import DashIconify
|
||||
|
||||
dash.register_page(__name__, path="/contact", name="Contact")
|
||||
|
||||
# Contact information
|
||||
CONTACT_INFO = {
|
||||
"email": "leobrmi@hotmail.com",
|
||||
"phone": "(416) 859-7936",
|
||||
"linkedin": "https://linkedin.com/in/leobmiranda",
|
||||
"github": "https://github.com/leomiranda",
|
||||
"location": "Toronto, ON, Canada",
|
||||
}
|
||||
|
||||
# Page intro text
|
||||
INTRO_TEXT = (
|
||||
"I'm currently open to Senior Data Analyst and Data Engineer roles in Toronto "
|
||||
"(or remote). If you're working on something interesting and need someone who can "
|
||||
"build data infrastructure from scratch, I'd like to hear about it."
|
||||
)
|
||||
|
||||
CONSULTING_TEXT = (
|
||||
"For consulting inquiries (automation, dashboards, small business data work), "
|
||||
"reach out about Bandit Labs."
|
||||
)
|
||||
|
||||
# Form subject options
|
||||
SUBJECT_OPTIONS = [
|
||||
{"value": "job", "label": "Job Opportunity"},
|
||||
{"value": "consulting", "label": "Consulting Inquiry"},
|
||||
{"value": "other", "label": "Other"},
|
||||
]
|
||||
|
||||
|
||||
def create_intro_section() -> dmc.Stack:
|
||||
"""Create the intro text section."""
|
||||
return dmc.Stack(
|
||||
[
|
||||
dmc.Title("Get In Touch", order=1, ta="center"),
|
||||
dmc.Text(INTRO_TEXT, size="md", ta="center", maw=600, mx="auto"),
|
||||
dmc.Text(
|
||||
CONSULTING_TEXT, size="md", ta="center", maw=600, mx="auto", c="dimmed"
|
||||
),
|
||||
],
|
||||
gap="md",
|
||||
mb="xl",
|
||||
)
|
||||
|
||||
|
||||
def create_contact_form() -> dmc.Paper:
|
||||
"""Create the contact form (disabled in Phase 1)."""
|
||||
return dmc.Paper(
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Title("Send a Message", order=2, size="h4"),
|
||||
dmc.Alert(
|
||||
"Contact form submission is coming soon. Please use the direct contact "
|
||||
"methods below for now.",
|
||||
title="Form Coming Soon",
|
||||
color="blue",
|
||||
variant="light",
|
||||
),
|
||||
dmc.TextInput(
|
||||
label="Name",
|
||||
placeholder="Your name",
|
||||
leftSection=DashIconify(icon="tabler:user", width=18),
|
||||
disabled=True,
|
||||
),
|
||||
dmc.TextInput(
|
||||
label="Email",
|
||||
placeholder="your.email@example.com",
|
||||
leftSection=DashIconify(icon="tabler:mail", width=18),
|
||||
disabled=True,
|
||||
),
|
||||
dmc.Select(
|
||||
label="Subject",
|
||||
placeholder="Select a subject",
|
||||
data=SUBJECT_OPTIONS,
|
||||
leftSection=DashIconify(icon="tabler:tag", width=18),
|
||||
disabled=True,
|
||||
),
|
||||
dmc.Textarea(
|
||||
label="Message",
|
||||
placeholder="Your message...",
|
||||
minRows=4,
|
||||
disabled=True,
|
||||
),
|
||||
dmc.Button(
|
||||
"Send Message",
|
||||
fullWidth=True,
|
||||
leftSection=DashIconify(icon="tabler:send", width=18),
|
||||
disabled=True,
|
||||
),
|
||||
],
|
||||
gap="md",
|
||||
),
|
||||
p="xl",
|
||||
radius="md",
|
||||
withBorder=True,
|
||||
)
|
||||
|
||||
|
||||
def create_direct_contact() -> dmc.Paper:
|
||||
"""Create the direct contact information section."""
|
||||
return dmc.Paper(
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Title("Direct Contact", order=2, size="h4"),
|
||||
dmc.Stack(
|
||||
[
|
||||
# Email
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.ThemeIcon(
|
||||
DashIconify(icon="tabler:mail", width=20),
|
||||
size="lg",
|
||||
radius="md",
|
||||
variant="light",
|
||||
),
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Text("Email", size="sm", c="dimmed"),
|
||||
dmc.Anchor(
|
||||
CONTACT_INFO["email"],
|
||||
href=f"mailto:{CONTACT_INFO['email']}",
|
||||
size="md",
|
||||
fw=500,
|
||||
),
|
||||
],
|
||||
gap=0,
|
||||
),
|
||||
],
|
||||
gap="md",
|
||||
),
|
||||
# Phone
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.ThemeIcon(
|
||||
DashIconify(icon="tabler:phone", width=20),
|
||||
size="lg",
|
||||
radius="md",
|
||||
variant="light",
|
||||
),
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Text("Phone", size="sm", c="dimmed"),
|
||||
dmc.Anchor(
|
||||
CONTACT_INFO["phone"],
|
||||
href=f"tel:{CONTACT_INFO['phone'].replace('(', '').replace(')', '').replace(' ', '').replace('-', '')}",
|
||||
size="md",
|
||||
fw=500,
|
||||
),
|
||||
],
|
||||
gap=0,
|
||||
),
|
||||
],
|
||||
gap="md",
|
||||
),
|
||||
# LinkedIn
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.ThemeIcon(
|
||||
DashIconify(icon="tabler:brand-linkedin", width=20),
|
||||
size="lg",
|
||||
radius="md",
|
||||
variant="light",
|
||||
color="blue",
|
||||
),
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Text("LinkedIn", size="sm", c="dimmed"),
|
||||
dmc.Anchor(
|
||||
"linkedin.com/in/leobmiranda",
|
||||
href=CONTACT_INFO["linkedin"],
|
||||
target="_blank",
|
||||
size="md",
|
||||
fw=500,
|
||||
),
|
||||
],
|
||||
gap=0,
|
||||
),
|
||||
],
|
||||
gap="md",
|
||||
),
|
||||
# GitHub
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.ThemeIcon(
|
||||
DashIconify(icon="tabler:brand-github", width=20),
|
||||
size="lg",
|
||||
radius="md",
|
||||
variant="light",
|
||||
),
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Text("GitHub", size="sm", c="dimmed"),
|
||||
dmc.Anchor(
|
||||
"github.com/leomiranda",
|
||||
href=CONTACT_INFO["github"],
|
||||
target="_blank",
|
||||
size="md",
|
||||
fw=500,
|
||||
),
|
||||
],
|
||||
gap=0,
|
||||
),
|
||||
],
|
||||
gap="md",
|
||||
),
|
||||
],
|
||||
gap="lg",
|
||||
),
|
||||
],
|
||||
gap="lg",
|
||||
),
|
||||
p="xl",
|
||||
radius="md",
|
||||
withBorder=True,
|
||||
)
|
||||
|
||||
|
||||
def create_location_section() -> dmc.Paper:
|
||||
"""Create the location and work eligibility section."""
|
||||
return dmc.Paper(
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Title("Location", order=2, size="h4"),
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.ThemeIcon(
|
||||
DashIconify(icon="tabler:map-pin", width=20),
|
||||
size="lg",
|
||||
radius="md",
|
||||
variant="light",
|
||||
color="red",
|
||||
),
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Text(CONTACT_INFO["location"], size="md", fw=500),
|
||||
dmc.Text(
|
||||
"Canadian Citizen | Eligible to work in Canada and US",
|
||||
size="sm",
|
||||
c="dimmed",
|
||||
),
|
||||
],
|
||||
gap=0,
|
||||
),
|
||||
],
|
||||
gap="md",
|
||||
),
|
||||
],
|
||||
gap="md",
|
||||
),
|
||||
p="xl",
|
||||
radius="md",
|
||||
withBorder=True,
|
||||
)
|
||||
|
||||
|
||||
layout = dmc.Container(
|
||||
dmc.Stack(
|
||||
[
|
||||
create_intro_section(),
|
||||
dmc.SimpleGrid(
|
||||
[
|
||||
create_contact_form(),
|
||||
dmc.Stack(
|
||||
[
|
||||
create_direct_contact(),
|
||||
create_location_section(),
|
||||
],
|
||||
gap="lg",
|
||||
),
|
||||
],
|
||||
cols={"base": 1, "md": 2},
|
||||
spacing="xl",
|
||||
),
|
||||
dmc.Space(h=40),
|
||||
],
|
||||
gap="lg",
|
||||
),
|
||||
size="lg",
|
||||
py="xl",
|
||||
)
|
||||
@@ -1,81 +1,118 @@
|
||||
"""Bio landing page."""
|
||||
"""Home landing page - Portfolio entry point."""
|
||||
|
||||
import dash
|
||||
import dash_mantine_components as dmc
|
||||
from dash import dcc
|
||||
from dash_iconify import DashIconify
|
||||
|
||||
dash.register_page(__name__, path="/", name="Home")
|
||||
|
||||
# Content from bio_content_v2.md
|
||||
HEADLINE = "Leo | Data Engineer & Analytics Developer"
|
||||
TAGLINE = "I build data infrastructure that actually gets used."
|
||||
# Hero content from blueprint
|
||||
HEADLINE = "I turn messy data into systems that actually work."
|
||||
SUBHEAD = (
|
||||
"Data Engineer & Analytics Specialist. 8 years building pipelines, dashboards, "
|
||||
"and the infrastructure nobody sees but everyone depends on. Based in Toronto."
|
||||
)
|
||||
|
||||
SUMMARY = """Over the past 5 years, I've designed and evolved an enterprise analytics platform
|
||||
from scratch—now processing 1B+ rows across 21 tables with Python-based ETL pipelines and
|
||||
dbt-style SQL transformations. The result: 40% efficiency gains, 30% reduction in call
|
||||
abandon rates, and dashboards that executives actually open.
|
||||
|
||||
My approach: dimensional modeling (star schema), layered transformations
|
||||
(staging → intermediate → marts), and automation that eliminates manual work.
|
||||
I've built everything from self-service analytics portals to OCR-powered receipt processing systems.
|
||||
|
||||
Currently at Summitt Energy supporting multi-market operations across Canada and 8 US states.
|
||||
Previously cut my teeth on IT infrastructure projects at Petrobras (Fortune 500) and the
|
||||
Project Management Institute."""
|
||||
|
||||
TECH_STACK = [
|
||||
"Python",
|
||||
"Pandas",
|
||||
"SQLAlchemy",
|
||||
"FastAPI",
|
||||
"SQL",
|
||||
"PostgreSQL",
|
||||
"MSSQL",
|
||||
"Power BI",
|
||||
"Plotly/Dash",
|
||||
"dbt patterns",
|
||||
"Genesys Cloud",
|
||||
# Impact metrics
|
||||
IMPACT_STATS = [
|
||||
{"value": "1B+", "label": "Rows processed daily across enterprise platform"},
|
||||
{"value": "40%", "label": "Efficiency gain through automation"},
|
||||
{"value": "5 Years", "label": "Building DataFlow from zero"},
|
||||
]
|
||||
|
||||
PROJECTS = [
|
||||
{
|
||||
"title": "Toronto Housing Dashboard",
|
||||
"description": "Choropleth visualization of GTA real estate trends with TRREB and CMHC data.",
|
||||
"status": "In Development",
|
||||
"link": "/toronto",
|
||||
},
|
||||
{
|
||||
"title": "Energy Pricing Analysis",
|
||||
"description": "Time series analysis and ML prediction for utility market pricing.",
|
||||
"status": "Planned",
|
||||
"link": "/energy",
|
||||
},
|
||||
]
|
||||
# Featured project
|
||||
FEATURED_PROJECT = {
|
||||
"title": "Toronto Housing Market Dashboard",
|
||||
"description": (
|
||||
"Real-time analytics on Toronto's housing trends. "
|
||||
"dbt-powered ETL, Python scraping, Plotly visualization."
|
||||
),
|
||||
"status": "Live",
|
||||
"dashboard_link": "/toronto",
|
||||
"repo_link": "https://github.com/leomiranda/personal-portfolio",
|
||||
}
|
||||
|
||||
AVAILABILITY = "Open to Senior Data Analyst, Analytics Engineer, and BI Developer opportunities in Toronto or remote."
|
||||
# Brief intro
|
||||
INTRO_TEXT = (
|
||||
"I'm a data engineer who's spent the last 8 years in the trenches—building the "
|
||||
"infrastructure that feeds dashboards, automates the boring stuff, and makes data "
|
||||
"actually usable. Most of my work has been in contact center operations and energy, "
|
||||
"where I've had to be scrappy: one-person data teams, legacy systems, stakeholders "
|
||||
"who need answers yesterday."
|
||||
)
|
||||
|
||||
INTRO_CLOSING = "I like solving real problems, not theoretical ones."
|
||||
|
||||
|
||||
def create_hero_section() -> dmc.Stack:
|
||||
"""Create the hero section with name and tagline."""
|
||||
"""Create the hero section with headline, subhead, and CTAs."""
|
||||
return dmc.Stack(
|
||||
[
|
||||
dmc.Title(HEADLINE, order=1, ta="center"),
|
||||
dmc.Text(TAGLINE, size="xl", c="dimmed", ta="center"),
|
||||
dmc.Title(
|
||||
HEADLINE,
|
||||
order=1,
|
||||
ta="center",
|
||||
size="2.5rem",
|
||||
),
|
||||
dmc.Text(
|
||||
SUBHEAD,
|
||||
size="lg",
|
||||
c="dimmed",
|
||||
ta="center",
|
||||
maw=700,
|
||||
mx="auto",
|
||||
),
|
||||
dmc.Group(
|
||||
[
|
||||
dcc.Link(
|
||||
dmc.Button(
|
||||
"View Projects",
|
||||
size="lg",
|
||||
variant="filled",
|
||||
leftSection=DashIconify(icon="tabler:folder", width=20),
|
||||
),
|
||||
href="/projects",
|
||||
),
|
||||
dcc.Link(
|
||||
dmc.Button(
|
||||
"Get In Touch",
|
||||
size="lg",
|
||||
variant="outline",
|
||||
leftSection=DashIconify(icon="tabler:mail", width=20),
|
||||
),
|
||||
href="/contact",
|
||||
),
|
||||
],
|
||||
justify="center",
|
||||
gap="md",
|
||||
mt="md",
|
||||
),
|
||||
],
|
||||
gap="xs",
|
||||
gap="md",
|
||||
py="xl",
|
||||
)
|
||||
|
||||
|
||||
def create_summary_section() -> dmc.Paper:
|
||||
"""Create the professional summary section."""
|
||||
paragraphs = SUMMARY.strip().split("\n\n")
|
||||
def create_impact_stat(stat: dict[str, str]) -> dmc.Stack:
|
||||
"""Create a single impact stat."""
|
||||
return dmc.Stack(
|
||||
[
|
||||
dmc.Text(stat["value"], fw=700, size="2rem", ta="center"),
|
||||
dmc.Text(stat["label"], size="sm", c="dimmed", ta="center"),
|
||||
],
|
||||
gap="xs",
|
||||
align="center",
|
||||
)
|
||||
|
||||
|
||||
def create_impact_strip() -> dmc.Paper:
|
||||
"""Create the impact statistics strip."""
|
||||
return dmc.Paper(
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Title("About", order=2, size="h3"),
|
||||
*[dmc.Text(p.replace("\n", " "), size="md") for p in paragraphs],
|
||||
],
|
||||
gap="md",
|
||||
dmc.SimpleGrid(
|
||||
[create_impact_stat(stat) for stat in IMPACT_STATS],
|
||||
cols={"base": 1, "sm": 3},
|
||||
spacing="xl",
|
||||
),
|
||||
p="xl",
|
||||
radius="md",
|
||||
@@ -83,16 +120,56 @@ def create_summary_section() -> dmc.Paper:
|
||||
)
|
||||
|
||||
|
||||
def create_tech_stack_section() -> dmc.Paper:
|
||||
"""Create the tech stack section with badges."""
|
||||
def create_featured_project() -> dmc.Paper:
|
||||
"""Create the featured project card."""
|
||||
return dmc.Paper(
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Title("Tech Stack", order=2, size="h3"),
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.Badge(tech, size="lg", variant="light", radius="sm")
|
||||
for tech in TECH_STACK
|
||||
dmc.Title("Featured Project", order=2, size="h3"),
|
||||
dmc.Badge(
|
||||
FEATURED_PROJECT["status"],
|
||||
color="green",
|
||||
variant="light",
|
||||
size="lg",
|
||||
),
|
||||
],
|
||||
justify="space-between",
|
||||
),
|
||||
dmc.Title(
|
||||
FEATURED_PROJECT["title"],
|
||||
order=3,
|
||||
size="h4",
|
||||
),
|
||||
dmc.Text(
|
||||
FEATURED_PROJECT["description"],
|
||||
size="md",
|
||||
c="dimmed",
|
||||
),
|
||||
dmc.Group(
|
||||
[
|
||||
dcc.Link(
|
||||
dmc.Button(
|
||||
"View Dashboard",
|
||||
variant="light",
|
||||
leftSection=DashIconify(
|
||||
icon="tabler:chart-bar", width=18
|
||||
),
|
||||
),
|
||||
href=FEATURED_PROJECT["dashboard_link"],
|
||||
),
|
||||
dmc.Anchor(
|
||||
dmc.Button(
|
||||
"View Repository",
|
||||
variant="subtle",
|
||||
leftSection=DashIconify(
|
||||
icon="tabler:brand-github", width=18
|
||||
),
|
||||
),
|
||||
href=FEATURED_PROJECT["repo_link"],
|
||||
target="_blank",
|
||||
),
|
||||
],
|
||||
gap="sm",
|
||||
),
|
||||
@@ -105,38 +182,13 @@ def create_tech_stack_section() -> dmc.Paper:
|
||||
)
|
||||
|
||||
|
||||
def create_project_card(project: dict[str, str]) -> dmc.Card:
|
||||
"""Create a project card."""
|
||||
status_color = "blue" if project["status"] == "In Development" else "gray"
|
||||
return dmc.Card(
|
||||
[
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.Text(project["title"], fw=500, size="lg"),
|
||||
dmc.Badge(project["status"], color=status_color, variant="light"),
|
||||
],
|
||||
justify="space-between",
|
||||
align="center",
|
||||
),
|
||||
dmc.Text(project["description"], size="sm", c="dimmed", mt="sm"),
|
||||
],
|
||||
withBorder=True,
|
||||
radius="md",
|
||||
p="lg",
|
||||
)
|
||||
|
||||
|
||||
def create_projects_section() -> dmc.Paper:
|
||||
"""Create the portfolio projects section."""
|
||||
def create_intro_section() -> dmc.Paper:
|
||||
"""Create the brief intro section."""
|
||||
return dmc.Paper(
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Title("Portfolio Projects", order=2, size="h3"),
|
||||
dmc.SimpleGrid(
|
||||
[create_project_card(p) for p in PROJECTS],
|
||||
cols={"base": 1, "sm": 2},
|
||||
spacing="lg",
|
||||
),
|
||||
dmc.Text(INTRO_TEXT, size="md"),
|
||||
dmc.Text(INTRO_CLOSING, size="md", fw=500, fs="italic"),
|
||||
],
|
||||
gap="md",
|
||||
),
|
||||
@@ -146,20 +198,13 @@ def create_projects_section() -> dmc.Paper:
|
||||
)
|
||||
|
||||
|
||||
def create_availability_section() -> dmc.Text:
|
||||
"""Create the availability statement."""
|
||||
return dmc.Text(AVAILABILITY, size="sm", c="dimmed", ta="center", fs="italic")
|
||||
|
||||
|
||||
layout = dmc.Container(
|
||||
dmc.Stack(
|
||||
[
|
||||
create_hero_section(),
|
||||
create_summary_section(),
|
||||
create_tech_stack_section(),
|
||||
create_projects_section(),
|
||||
dmc.Divider(my="lg"),
|
||||
create_availability_section(),
|
||||
create_impact_strip(),
|
||||
create_featured_project(),
|
||||
create_intro_section(),
|
||||
dmc.Space(h=40),
|
||||
],
|
||||
gap="xl",
|
||||
|
||||
304
portfolio_app/pages/projects.py
Normal file
304
portfolio_app/pages/projects.py
Normal file
@@ -0,0 +1,304 @@
|
||||
"""Projects overview page - Hub for all portfolio projects."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import dash
|
||||
import dash_mantine_components as dmc
|
||||
from dash import dcc
|
||||
from dash_iconify import DashIconify
|
||||
|
||||
dash.register_page(__name__, path="/projects", name="Projects")
|
||||
|
||||
# Page intro
|
||||
INTRO_TEXT = (
|
||||
"These are projects I've built—some professional (anonymized where needed), "
|
||||
"some personal. Each one taught me something. Use the sidebar to jump directly "
|
||||
"to live dashboards or explore the overviews below."
|
||||
)
|
||||
|
||||
# Project definitions
|
||||
PROJECTS: list[dict[str, Any]] = [
|
||||
{
|
||||
"title": "Toronto Housing Market Dashboard",
|
||||
"type": "Personal Project",
|
||||
"status": "Live",
|
||||
"status_color": "green",
|
||||
"problem": (
|
||||
"Toronto's housing market moves fast, and most publicly available data "
|
||||
"is either outdated, behind paywalls, or scattered across dozens of sources. "
|
||||
"I wanted a single dashboard that tracked trends in real-time."
|
||||
),
|
||||
"built": [
|
||||
"Data Pipeline: Python scraper pulling listings data, automated on schedule",
|
||||
"Transformation Layer: dbt-based SQL architecture (staging -> intermediate -> marts)",
|
||||
"Visualization: Interactive Plotly-Dash dashboard with filters by neighborhood, price range, property type",
|
||||
"Infrastructure: PostgreSQL backend, version-controlled in Git",
|
||||
],
|
||||
"tech_stack": "Python, dbt, PostgreSQL, Plotly-Dash, GitHub Actions",
|
||||
"learned": (
|
||||
"Real estate data is messy as hell. Listings get pulled, prices change, "
|
||||
"duplicates are everywhere. Building a reliable pipeline meant implementing "
|
||||
'serious data quality checks and learning to embrace "good enough" over "perfect."'
|
||||
),
|
||||
"dashboard_link": "/toronto",
|
||||
"repo_link": "https://github.com/leomiranda/personal-portfolio",
|
||||
},
|
||||
{
|
||||
"title": "US Retail Energy Price Predictor",
|
||||
"type": "Personal Project",
|
||||
"status": "Coming Soon",
|
||||
"status_color": "yellow",
|
||||
"problem": (
|
||||
"Retail energy pricing in deregulated US markets is volatile and opaque. "
|
||||
"Consumers and analysts lack accessible tools to understand pricing trends "
|
||||
"and forecast where rates are headed."
|
||||
),
|
||||
"built": [
|
||||
"Data Pipeline: Automated ingestion of public pricing data across multiple US markets",
|
||||
"ML Model: Price prediction using time series forecasting (ARIMA, Prophet, or similar)",
|
||||
"Transformation Layer: dbt-based SQL architecture for feature engineering",
|
||||
"Visualization: Interactive dashboard showing historical trends + predictions by state/market",
|
||||
],
|
||||
"tech_stack": "Python, Scikit-learn, dbt, PostgreSQL, Plotly-Dash",
|
||||
"learned": (
|
||||
"This showcases the ML side of my skillset—something the Toronto Housing "
|
||||
"dashboard doesn't cover. It also leverages my domain expertise from 5+ years "
|
||||
"in retail energy operations."
|
||||
),
|
||||
"dashboard_link": None,
|
||||
"repo_link": None,
|
||||
},
|
||||
{
|
||||
"title": "DataFlow Platform",
|
||||
"type": "Professional",
|
||||
"status": "Case Study Pending",
|
||||
"status_color": "gray",
|
||||
"problem": (
|
||||
"When I joined Summitt Energy, there was no data infrastructure. "
|
||||
"Reports were manual. Insights were guesswork. I was hired to fix that."
|
||||
),
|
||||
"built": [
|
||||
"v1 (2020): Basic ETL scripts pulling Genesys Cloud data into MSSQL",
|
||||
"v2 (2021): Dimensional model (star schema) with fact/dimension tables",
|
||||
"v3 (2022): Python refactor with SQLAlchemy ORM, batch processing, error handling",
|
||||
"v4 (2023-24): dbt-pattern SQL views (staging -> intermediate -> marts), FastAPI layer, CLI tools",
|
||||
],
|
||||
"tech_stack": "Python, SQLAlchemy, FastAPI, MSSQL, Power BI, Genesys Cloud API",
|
||||
"impact": [
|
||||
"21 tables, 1B+ rows",
|
||||
"5,000+ daily transactions processed",
|
||||
"40% improvement in reporting efficiency",
|
||||
"30% reduction in call abandon rate",
|
||||
"50% faster Average Speed to Answer",
|
||||
],
|
||||
"learned": (
|
||||
"Building data infrastructure as a team of one forces brutal prioritization. "
|
||||
"I learned to ship imperfect solutions fast, iterate based on feedback, "
|
||||
"and never underestimate how long stakeholder buy-in takes."
|
||||
),
|
||||
"note": "This is proprietary work. A sanitized case study with architecture patterns (no proprietary data) will be published in Phase 3.",
|
||||
"dashboard_link": None,
|
||||
"repo_link": None,
|
||||
},
|
||||
{
|
||||
"title": "AI-Assisted Automation (Bandit Labs)",
|
||||
"type": "Consulting/Side Business",
|
||||
"status": "Active",
|
||||
"status_color": "blue",
|
||||
"problem": (
|
||||
"Small businesses don't need enterprise data platforms—they need someone "
|
||||
"to eliminate the 4 hours/week they spend manually entering receipts."
|
||||
),
|
||||
"built": [
|
||||
"Receipt Processing Automation: OCR pipeline (Tesseract, Google Vision) extracting purchase data from photos",
|
||||
"Product Margin Tracker: Plotly-Dash dashboard with real-time profitability insights",
|
||||
"Claude Code Plugins: MCP servers for Gitea, Wiki.js, NetBox integration",
|
||||
],
|
||||
"tech_stack": "Python, Tesseract, Google Vision API, Plotly-Dash, QuickBooks API",
|
||||
"learned": (
|
||||
"Small businesses are underserved by the data/automation industry. "
|
||||
"Everyone wants to sell them enterprise software they don't need. "
|
||||
"I like solving problems at a scale where the impact is immediately visible."
|
||||
),
|
||||
"dashboard_link": None,
|
||||
"repo_link": None,
|
||||
"external_link": "/lab",
|
||||
"external_label": "Learn More About Bandit Labs",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def create_project_card(project: dict[str, Any]) -> dmc.Paper:
|
||||
"""Create a detailed project card."""
|
||||
# Build the "What I Built" list
|
||||
built_items = project.get("built", [])
|
||||
built_section = (
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Text("What I Built:", fw=600, size="sm"),
|
||||
dmc.List(
|
||||
[dmc.ListItem(dmc.Text(item, size="sm")) for item in built_items],
|
||||
spacing="xs",
|
||||
size="sm",
|
||||
),
|
||||
],
|
||||
gap="xs",
|
||||
)
|
||||
if built_items
|
||||
else None
|
||||
)
|
||||
|
||||
# Build impact section for DataFlow
|
||||
impact_items = project.get("impact", [])
|
||||
impact_section = (
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Text("Impact:", fw=600, size="sm"),
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.Badge(item, variant="light", size="sm")
|
||||
for item in impact_items
|
||||
],
|
||||
gap="xs",
|
||||
),
|
||||
],
|
||||
gap="xs",
|
||||
)
|
||||
if impact_items
|
||||
else None
|
||||
)
|
||||
|
||||
# Build action buttons
|
||||
buttons = []
|
||||
if project.get("dashboard_link"):
|
||||
buttons.append(
|
||||
dcc.Link(
|
||||
dmc.Button(
|
||||
"View Dashboard",
|
||||
variant="light",
|
||||
size="sm",
|
||||
leftSection=DashIconify(icon="tabler:chart-bar", width=16),
|
||||
),
|
||||
href=project["dashboard_link"],
|
||||
)
|
||||
)
|
||||
if project.get("repo_link"):
|
||||
buttons.append(
|
||||
dmc.Anchor(
|
||||
dmc.Button(
|
||||
"View Repository",
|
||||
variant="subtle",
|
||||
size="sm",
|
||||
leftSection=DashIconify(icon="tabler:brand-github", width=16),
|
||||
),
|
||||
href=project["repo_link"],
|
||||
target="_blank",
|
||||
)
|
||||
)
|
||||
if project.get("external_link"):
|
||||
buttons.append(
|
||||
dcc.Link(
|
||||
dmc.Button(
|
||||
project.get("external_label", "Learn More"),
|
||||
variant="outline",
|
||||
size="sm",
|
||||
leftSection=DashIconify(icon="tabler:arrow-right", width=16),
|
||||
),
|
||||
href=project["external_link"],
|
||||
)
|
||||
)
|
||||
|
||||
# Handle "Coming Soon" state
|
||||
if project["status"] == "Coming Soon" and not buttons:
|
||||
buttons.append(
|
||||
dmc.Badge("Coming Soon", variant="light", color="yellow", size="lg")
|
||||
)
|
||||
|
||||
return dmc.Paper(
|
||||
dmc.Stack(
|
||||
[
|
||||
# Header
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Text(project["title"], fw=600, size="lg"),
|
||||
dmc.Text(project["type"], size="sm", c="dimmed"),
|
||||
],
|
||||
gap=0,
|
||||
),
|
||||
dmc.Badge(
|
||||
project["status"],
|
||||
color=project["status_color"],
|
||||
variant="light",
|
||||
size="lg",
|
||||
),
|
||||
],
|
||||
justify="space-between",
|
||||
align="flex-start",
|
||||
),
|
||||
# Problem
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Text("The Problem:", fw=600, size="sm"),
|
||||
dmc.Text(project["problem"], size="sm", c="dimmed"),
|
||||
],
|
||||
gap="xs",
|
||||
),
|
||||
# What I Built
|
||||
built_section,
|
||||
# Impact (if exists)
|
||||
impact_section,
|
||||
# Tech Stack
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.Text("Tech Stack:", fw=600, size="sm"),
|
||||
dmc.Text(project["tech_stack"], size="sm", c="dimmed"),
|
||||
],
|
||||
gap="xs",
|
||||
),
|
||||
# What I Learned
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Text("What I Learned:", fw=600, size="sm"),
|
||||
dmc.Text(project["learned"], size="sm", fs="italic"),
|
||||
],
|
||||
gap="xs",
|
||||
),
|
||||
# Note (if exists)
|
||||
(
|
||||
dmc.Alert(
|
||||
project["note"],
|
||||
color="gray",
|
||||
variant="light",
|
||||
)
|
||||
if project.get("note")
|
||||
else None
|
||||
),
|
||||
# Action buttons
|
||||
dmc.Group(buttons, gap="sm") if buttons else None,
|
||||
],
|
||||
gap="md",
|
||||
),
|
||||
p="xl",
|
||||
radius="md",
|
||||
withBorder=True,
|
||||
)
|
||||
|
||||
|
||||
layout = dmc.Container(
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Title("Projects", order=1, ta="center"),
|
||||
dmc.Text(
|
||||
INTRO_TEXT, size="md", c="dimmed", ta="center", maw=700, mx="auto"
|
||||
),
|
||||
dmc.Divider(my="lg"),
|
||||
*[create_project_card(project) for project in PROJECTS],
|
||||
dmc.Space(h=40),
|
||||
],
|
||||
gap="xl",
|
||||
),
|
||||
size="md",
|
||||
py="xl",
|
||||
)
|
||||
362
portfolio_app/pages/resume.py
Normal file
362
portfolio_app/pages/resume.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""Resume page - Inline display with download options."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import dash
|
||||
import dash_mantine_components as dmc
|
||||
from dash_iconify import DashIconify
|
||||
|
||||
dash.register_page(__name__, path="/resume", name="Resume")
|
||||
|
||||
# =============================================================================
|
||||
# HUMAN TASK: Upload resume content via Gitea
|
||||
# Replace the placeholder content below with actual resume data.
|
||||
# You can upload PDF/DOCX files to portfolio_app/assets/resume/
|
||||
# =============================================================================
|
||||
|
||||
# Resume sections - replace with actual content
|
||||
RESUME_HEADER = {
|
||||
"name": "Leo Miranda",
|
||||
"title": "Data Engineer & Analytics Specialist",
|
||||
"location": "Toronto, ON, Canada",
|
||||
"email": "leobrmi@hotmail.com",
|
||||
"phone": "(416) 859-7936",
|
||||
"linkedin": "linkedin.com/in/leobmiranda",
|
||||
"github": "github.com/leomiranda",
|
||||
}
|
||||
|
||||
RESUME_SUMMARY = (
|
||||
"Data Engineer with 8 years of experience building enterprise analytics platforms, "
|
||||
"ETL pipelines, and business intelligence solutions. Proven track record of delivering "
|
||||
"40% efficiency gains through automation and data infrastructure modernization. "
|
||||
"Expert in Python, SQL, and dimensional modeling with deep domain expertise in "
|
||||
"contact center operations and energy retail."
|
||||
)
|
||||
|
||||
# Experience - placeholder structure
|
||||
EXPERIENCE = [
|
||||
{
|
||||
"title": "Senior Data Analyst / Data Engineer",
|
||||
"company": "Summitt Energy",
|
||||
"location": "Toronto, ON",
|
||||
"period": "2019 - Present",
|
||||
"highlights": [
|
||||
"Built DataFlow platform from scratch: 21 tables, 1B+ rows, processing 5,000+ daily transactions",
|
||||
"Achieved 40% improvement in reporting efficiency through automated ETL pipelines",
|
||||
"Reduced call abandon rate by 30% via KPI framework and real-time dashboards",
|
||||
"Sole data professional supporting 150+ employees across 9 markets (Canada + US)",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "IT Project Coordinator",
|
||||
"company": "Petrobras",
|
||||
"location": "Rio de Janeiro, Brazil",
|
||||
"period": "2015 - 2018",
|
||||
"highlights": [
|
||||
"Coordinated IT infrastructure projects for Fortune 500 energy company",
|
||||
"Managed vendor relationships and project timelines",
|
||||
"Developed reporting automation reducing manual effort by 60%",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "Project Management Associate",
|
||||
"company": "Project Management Institute",
|
||||
"location": "Remote",
|
||||
"period": "2014 - 2015",
|
||||
"highlights": [
|
||||
"Supported global project management standards development",
|
||||
"CAPM and ITIL certified during this period",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
# Skills - organized by category
|
||||
SKILLS = {
|
||||
"Languages": ["Python", "SQL", "R", "VBA"],
|
||||
"Data Engineering": [
|
||||
"ETL/ELT Pipelines",
|
||||
"Dimensional Modeling",
|
||||
"dbt",
|
||||
"SQLAlchemy",
|
||||
"FastAPI",
|
||||
],
|
||||
"Databases": ["PostgreSQL", "MSSQL", "Redis"],
|
||||
"Visualization": ["Plotly/Dash", "Power BI", "Tableau"],
|
||||
"Platforms": ["Genesys Cloud", "Five9", "Zoho CRM", "Azure DevOps"],
|
||||
"Currently Learning": ["Azure DP-203", "Airflow", "Snowflake"],
|
||||
}
|
||||
|
||||
# Education
|
||||
EDUCATION = [
|
||||
{
|
||||
"degree": "Bachelor of Business Administration",
|
||||
"school": "Universidade Federal do Rio de Janeiro",
|
||||
"year": "2014",
|
||||
},
|
||||
]
|
||||
|
||||
# Certifications
|
||||
CERTIFICATIONS = [
|
||||
"CAPM (Certified Associate in Project Management)",
|
||||
"ITIL Foundation",
|
||||
"Azure DP-203 (In Progress)",
|
||||
]
|
||||
|
||||
|
||||
def create_header_section() -> dmc.Paper:
|
||||
"""Create the resume header with contact info."""
|
||||
return dmc.Paper(
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Title(RESUME_HEADER["name"], order=1, ta="center"),
|
||||
dmc.Text(RESUME_HEADER["title"], size="xl", c="dimmed", ta="center"),
|
||||
dmc.Divider(my="sm"),
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.Group(
|
||||
[
|
||||
DashIconify(icon="tabler:map-pin", width=16),
|
||||
dmc.Text(RESUME_HEADER["location"], size="sm"),
|
||||
],
|
||||
gap="xs",
|
||||
),
|
||||
dmc.Group(
|
||||
[
|
||||
DashIconify(icon="tabler:mail", width=16),
|
||||
dmc.Text(RESUME_HEADER["email"], size="sm"),
|
||||
],
|
||||
gap="xs",
|
||||
),
|
||||
dmc.Group(
|
||||
[
|
||||
DashIconify(icon="tabler:phone", width=16),
|
||||
dmc.Text(RESUME_HEADER["phone"], size="sm"),
|
||||
],
|
||||
gap="xs",
|
||||
),
|
||||
],
|
||||
justify="center",
|
||||
gap="lg",
|
||||
wrap="wrap",
|
||||
),
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.Anchor(
|
||||
dmc.Group(
|
||||
[
|
||||
DashIconify(icon="tabler:brand-linkedin", width=16),
|
||||
dmc.Text("LinkedIn", size="sm"),
|
||||
],
|
||||
gap="xs",
|
||||
),
|
||||
href=f"https://{RESUME_HEADER['linkedin']}",
|
||||
target="_blank",
|
||||
),
|
||||
dmc.Anchor(
|
||||
dmc.Group(
|
||||
[
|
||||
DashIconify(icon="tabler:brand-github", width=16),
|
||||
dmc.Text("GitHub", size="sm"),
|
||||
],
|
||||
gap="xs",
|
||||
),
|
||||
href=f"https://{RESUME_HEADER['github']}",
|
||||
target="_blank",
|
||||
),
|
||||
],
|
||||
justify="center",
|
||||
gap="lg",
|
||||
),
|
||||
],
|
||||
gap="sm",
|
||||
),
|
||||
p="xl",
|
||||
radius="md",
|
||||
withBorder=True,
|
||||
)
|
||||
|
||||
|
||||
def create_download_section() -> dmc.Group:
|
||||
"""Create download buttons for resume files."""
|
||||
# Note: Buttons disabled until files are uploaded
|
||||
return dmc.Group(
|
||||
[
|
||||
dmc.Button(
|
||||
"Download PDF",
|
||||
variant="filled",
|
||||
leftSection=DashIconify(icon="tabler:file-type-pdf", width=18),
|
||||
disabled=True, # Enable after uploading resume.pdf to assets
|
||||
),
|
||||
dmc.Button(
|
||||
"Download DOCX",
|
||||
variant="outline",
|
||||
leftSection=DashIconify(icon="tabler:file-type-docx", width=18),
|
||||
disabled=True, # Enable after uploading resume.docx to assets
|
||||
),
|
||||
dmc.Anchor(
|
||||
dmc.Button(
|
||||
"View on LinkedIn",
|
||||
variant="subtle",
|
||||
leftSection=DashIconify(icon="tabler:brand-linkedin", width=18),
|
||||
),
|
||||
href=f"https://{RESUME_HEADER['linkedin']}",
|
||||
target="_blank",
|
||||
),
|
||||
],
|
||||
justify="center",
|
||||
gap="md",
|
||||
)
|
||||
|
||||
|
||||
def create_summary_section() -> dmc.Paper:
|
||||
"""Create the professional summary section."""
|
||||
return dmc.Paper(
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Title("Professional Summary", order=2, size="h4"),
|
||||
dmc.Text(RESUME_SUMMARY, size="md"),
|
||||
],
|
||||
gap="sm",
|
||||
),
|
||||
p="lg",
|
||||
radius="md",
|
||||
withBorder=True,
|
||||
)
|
||||
|
||||
|
||||
def create_experience_item(exp: dict[str, Any]) -> dmc.Stack:
|
||||
"""Create a single experience entry."""
|
||||
return dmc.Stack(
|
||||
[
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.Text(exp["title"], fw=600),
|
||||
dmc.Text(exp["period"], size="sm", c="dimmed"),
|
||||
],
|
||||
justify="space-between",
|
||||
),
|
||||
dmc.Text(f"{exp['company']} | {exp['location']}", size="sm", c="dimmed"),
|
||||
dmc.List(
|
||||
[dmc.ListItem(dmc.Text(h, size="sm")) for h in exp["highlights"]],
|
||||
spacing="xs",
|
||||
size="sm",
|
||||
),
|
||||
],
|
||||
gap="xs",
|
||||
)
|
||||
|
||||
|
||||
def create_experience_section() -> dmc.Paper:
|
||||
"""Create the experience section."""
|
||||
return dmc.Paper(
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Title("Experience", order=2, size="h4"),
|
||||
*[create_experience_item(exp) for exp in EXPERIENCE],
|
||||
],
|
||||
gap="lg",
|
||||
),
|
||||
p="lg",
|
||||
radius="md",
|
||||
withBorder=True,
|
||||
)
|
||||
|
||||
|
||||
def create_skills_section() -> dmc.Paper:
|
||||
"""Create the skills section with badges."""
|
||||
return dmc.Paper(
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Title("Skills", order=2, size="h4"),
|
||||
dmc.SimpleGrid(
|
||||
[
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Text(category, fw=600, size="sm"),
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.Badge(skill, variant="light", size="sm")
|
||||
for skill in skills
|
||||
],
|
||||
gap="xs",
|
||||
),
|
||||
],
|
||||
gap="xs",
|
||||
)
|
||||
for category, skills in SKILLS.items()
|
||||
],
|
||||
cols={"base": 1, "sm": 2},
|
||||
spacing="md",
|
||||
),
|
||||
],
|
||||
gap="md",
|
||||
),
|
||||
p="lg",
|
||||
radius="md",
|
||||
withBorder=True,
|
||||
)
|
||||
|
||||
|
||||
def create_education_section() -> dmc.Paper:
|
||||
"""Create education and certifications section."""
|
||||
return dmc.Paper(
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Title("Education & Certifications", order=2, size="h4"),
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Stack(
|
||||
[
|
||||
dmc.Text(edu["degree"], fw=600),
|
||||
dmc.Text(
|
||||
f"{edu['school']} | {edu['year']}",
|
||||
size="sm",
|
||||
c="dimmed",
|
||||
),
|
||||
],
|
||||
gap=0,
|
||||
)
|
||||
for edu in EDUCATION
|
||||
],
|
||||
gap="sm",
|
||||
),
|
||||
dmc.Divider(my="sm"),
|
||||
dmc.Group(
|
||||
[
|
||||
dmc.Badge(cert, variant="outline", size="md")
|
||||
for cert in CERTIFICATIONS
|
||||
],
|
||||
gap="xs",
|
||||
),
|
||||
],
|
||||
gap="md",
|
||||
),
|
||||
p="lg",
|
||||
radius="md",
|
||||
withBorder=True,
|
||||
)
|
||||
|
||||
|
||||
layout = dmc.Container(
|
||||
dmc.Stack(
|
||||
[
|
||||
create_header_section(),
|
||||
create_download_section(),
|
||||
dmc.Alert(
|
||||
"Resume files (PDF/DOCX) will be available for download once uploaded. "
|
||||
"The inline content below is a preview.",
|
||||
title="Downloads Coming Soon",
|
||||
color="blue",
|
||||
variant="light",
|
||||
),
|
||||
create_summary_section(),
|
||||
create_experience_section(),
|
||||
create_skills_section(),
|
||||
create_education_section(),
|
||||
dmc.Space(h=40),
|
||||
],
|
||||
gap="lg",
|
||||
),
|
||||
size="md",
|
||||
py="xl",
|
||||
)
|
||||
9
portfolio_app/utils/__init__.py
Normal file
9
portfolio_app/utils/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Utility modules for the portfolio app."""
|
||||
|
||||
from portfolio_app.utils.markdown_loader import (
|
||||
get_all_articles,
|
||||
get_article,
|
||||
render_markdown,
|
||||
)
|
||||
|
||||
__all__ = ["get_all_articles", "get_article", "render_markdown"]
|
||||
109
portfolio_app/utils/markdown_loader.py
Normal file
109
portfolio_app/utils/markdown_loader.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Markdown article loader with frontmatter support."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
import frontmatter
|
||||
import markdown
|
||||
from markdown.extensions.codehilite import CodeHiliteExtension
|
||||
from markdown.extensions.fenced_code import FencedCodeExtension
|
||||
from markdown.extensions.tables import TableExtension
|
||||
from markdown.extensions.toc import TocExtension
|
||||
|
||||
# Content directory (relative to this file's package)
|
||||
CONTENT_DIR = Path(__file__).parent.parent / "content" / "blog"
|
||||
|
||||
|
||||
class ArticleMeta(TypedDict):
|
||||
"""Article metadata from frontmatter."""
|
||||
|
||||
slug: str
|
||||
title: str
|
||||
date: str
|
||||
description: str
|
||||
tags: list[str]
|
||||
status: str # "published" or "draft"
|
||||
|
||||
|
||||
class Article(TypedDict):
|
||||
"""Full article with metadata and content."""
|
||||
|
||||
meta: ArticleMeta
|
||||
content: str
|
||||
html: str
|
||||
|
||||
|
||||
def render_markdown(content: str) -> str:
|
||||
"""Convert markdown to HTML with syntax highlighting.
|
||||
|
||||
Args:
|
||||
content: Raw markdown string.
|
||||
|
||||
Returns:
|
||||
HTML string with syntax-highlighted code blocks.
|
||||
"""
|
||||
md = markdown.Markdown(
|
||||
extensions=[
|
||||
FencedCodeExtension(),
|
||||
CodeHiliteExtension(css_class="highlight", guess_lang=False),
|
||||
TableExtension(),
|
||||
TocExtension(permalink=True),
|
||||
"nl2br",
|
||||
]
|
||||
)
|
||||
return str(md.convert(content))
|
||||
|
||||
|
||||
def get_article(slug: str) -> Article | None:
|
||||
"""Load a single article by slug.
|
||||
|
||||
Args:
|
||||
slug: Article slug (filename without .md extension).
|
||||
|
||||
Returns:
|
||||
Article dict or None if not found.
|
||||
"""
|
||||
filepath = CONTENT_DIR / f"{slug}.md"
|
||||
if not filepath.exists():
|
||||
return None
|
||||
|
||||
post = frontmatter.load(filepath)
|
||||
|
||||
meta: ArticleMeta = {
|
||||
"slug": slug,
|
||||
"title": post.get("title", slug.replace("-", " ").title()),
|
||||
"date": str(post.get("date", "")),
|
||||
"description": post.get("description", ""),
|
||||
"tags": post.get("tags", []),
|
||||
"status": post.get("status", "published"),
|
||||
}
|
||||
|
||||
return {
|
||||
"meta": meta,
|
||||
"content": post.content,
|
||||
"html": render_markdown(post.content),
|
||||
}
|
||||
|
||||
|
||||
def get_all_articles(include_drafts: bool = False) -> list[Article]:
|
||||
"""Load all articles from the content directory.
|
||||
|
||||
Args:
|
||||
include_drafts: If True, include articles with status="draft".
|
||||
|
||||
Returns:
|
||||
List of articles sorted by date (newest first).
|
||||
"""
|
||||
if not CONTENT_DIR.exists():
|
||||
return []
|
||||
|
||||
articles: list[Article] = []
|
||||
for filepath in CONTENT_DIR.glob("*.md"):
|
||||
slug = filepath.stem
|
||||
article = get_article(slug)
|
||||
if article and (include_drafts or article["meta"]["status"] == "published"):
|
||||
articles.append(article)
|
||||
|
||||
# Sort by date descending
|
||||
articles.sort(key=lambda a: a["meta"]["date"], reverse=True)
|
||||
return articles
|
||||
@@ -48,6 +48,11 @@ dependencies = [
|
||||
# Utilities
|
||||
"python-dotenv>=1.0",
|
||||
"httpx>=0.28",
|
||||
|
||||
# Blog/Markdown
|
||||
"python-frontmatter>=1.1",
|
||||
"markdown>=3.5",
|
||||
"pygments>=2.17",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -148,5 +153,7 @@ module = [
|
||||
"pdfplumber.*",
|
||||
"tabula.*",
|
||||
"pydantic_settings.*",
|
||||
"frontmatter.*",
|
||||
"markdown.*",
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
|
||||
Reference in New Issue
Block a user