SS# Saugat Dhakal — Portfolio (Python / Flask)
A premium, editorial, dark-first personal portfolio built entirely in Python (Flask + Jinja2), with vanilla CSS/JS handling the motion and interaction design — no Node.js, React, or build step required.
portfolio/
├── app.py # Flask app: all routes + SEO (sitemap/robots)
├── requirements.txt
├── data/
│ └── content.py # CMS-style content: projects, skills, blog, etc.
├── templates/
│ ├── base.html # Shared layout: nav, footer, theme, cursor
│ ├── home.html
│ ├── about.html
│ ├── work.html # Project archive (filters + search)
│ ├── project_detail.html # Case-study template
│ ├── skills.html
│ ├── experience.html
│ ├── services.html
│ ├── process.html
│ ├── blog.html
│ ├── blog_article.html
│ ├── contact.html
│ ├── resume.html
│ └── 404.html
└── static/
├── css/style.css # Full design system (colors, type, motion)
├── js/main.js # Theme toggle, cursor, reveal, filters, nav
└── images/favicon.svg # SD monogram favicon
cd portfolio
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
flask --app app run --debug
Open http://127.0.0.1:5000. The dev server auto-reloads on file changes.
Flask apps don’t have a static build step — for production, run behind a WSGI server:
pip install gunicorn
gunicorn app:app -w 4 -b 0.0.0.0:8000
None are required to run the site as-is. If you wire up real email
sending on the contact form (see app.py → contact()), you’ll likely
add something like:
SMTP_HOST=...
SMTP_USER=...
SMTP_PASSWORD=...
CONTACT_RECEIVER_EMAIL=...
Load them with python-dotenv or your platform’s env-var settings —
never hardcode secrets into app.py or data/content.py.
data/content.py — the entire site’s content lives here as plain
Python dicts/lists (profile, nav, skills, experience, services,
process steps, projects, blog posts, social links). Templates only
loop over this data — nothing content-related is hardcoded in HTML.app.py — one route per page, each building a context dict and
rendering a template. Also serves /sitemap.xml and /robots.txt
dynamically from the same project/post data.templates/base.html — the shared shell: navigation (desktop +
fullscreen mobile menu), theme toggle, custom cursor markup, scroll
progress bar, and footer. Every page extends this.static/css/style.css — the full design system: CSS custom
properties for the dark/light color systems, typography scale,
spacing, and every component (project rows, skill pills, timeline,
service grid, blog cards, forms, resume sheet, 404).static/js/main.js — vanilla JS for: theme persistence
(localStorage), nav scroll state + progress bar, mobile menu,
custom cursor (desktop only, disabled on touch), IntersectionObserver
scroll-reveal, magnetic buttons, and client-side filter/search on the
Work archive. Respects prefers-reduced-motion.Open data/content.py and add a new dict to the PROJECTS list:
{
"slug": "your-project-slug", # used in the URL /work/<slug>
"number": "05",
"title": "Project Name",
"category": "Full-stack Development",
"categories": ["development"], # used by the Work page filters
"year": "2026",
"technology": ["Python", "React"],
"description": "One or two sentence summary.",
"role": "...", "tools": "...", "team": "...", "status": "...",
"problem": "...", "research": "...", "strategy": "...",
"design_notes": "...", "development_notes": "...",
"challenges": "...", "solution": "...",
"results": "...", "lessons": "...",
},
It will automatically appear on the homepage (first 4), the Work
archive, and get its own case-study page at /work/your-project-slug.
Add a new dict to BLOG_POSTS in data/content.py:
{
"slug": "your-post-slug",
"title": "Post Title",
"category": "Development", # must match a BLOG_CATEGORIES entry
"tags": ["Development", "Python"],
"excerpt": "One-sentence summary shown on cards.",
"date": "August 2026",
"reading_time": "5 min read",
"author": "Saugat Dhakal",
"body": ["Paragraph one.", "Paragraph two."],
},
It appears on /blog and at /blog/your-post-slug automatically.
Everything — name, role, tagline, email, social links, skills,
experience, services, process steps — lives in data/content.py. Edit
the PROFILE, SOCIAL_LINKS, SKILLS, EXPERIENCE, EDUCATION, and
SERVICES structures at the top of the file. Anything marked
# PLACEHOLDER is intentionally fake/unset — replace it with real
information before publishing (no fabricated stats, clients, or
testimonials were added).
To change the accent color or theme values, edit the CSS custom
properties at the top of static/css/style.css (:root for dark,
html[data-theme="light"] for light).
Any host that runs a Python WSGI app works:
gunicorn app:app, add gunicorn to requirements.txt.app.app.Docker — a minimal Dockerfile:
FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt gunicorn
CMD ["gunicorn", "app:app", "-b", "0.0.0.0:8000"]
Before going live: replace every # PLACEHOLDER value in
data/content.py, set a real contact-form email backend, and point
SOCIAL_LINKS URLs at real profiles.
Notes on scope: the contact form currently validates and
acknowledges submissions but does not send email — wire up an SMTP or
transactional-email provider in app.py → contact() before relying
on it. No employment history, clients, awards, statistics, or
testimonials were invented; anything unknown is a labeled placeholder
in data/content.py, ready for you to fill in.
# P o r t f o l i o - w e b s i t e - m
# automatic-fortnight