diff --git a/episode-07/portfolio/README.md b/episode-07/portfolio/README.md new file mode 100644 index 0000000..a156afc --- /dev/null +++ b/episode-07/portfolio/README.md @@ -0,0 +1,20 @@ +# Episode 7 portfolio website + +This folder contains Jamie Rivera's portfolio website for Episode 7. + +## Folder contents + +| Path | Purpose | +| --- | --- | +| `index.html` | Main portfolio page, including the homepage blog preview. | +| `styles.css` | Shared styling for the portfolio, blog, and published posts. | +| `script.js` | Responsive navigation, theme switching, animations, and blog listing behavior. | +| `resume/` | Resume displayed and downloaded from the portfolio. | +| `blog/index.html` | Full list of published blog posts. | +| `blog/posts-src/` | Markdown source files used to write and edit posts. | +| `blog/posts/` | Static HTML pages generated for published posts. | +| `blog/posts.json` | Post titles, dates, summaries, and links shown in the website's blog lists. | +| `blog/generate_posts.py` | Converts approved Markdown sources into static post pages and updates `posts.json`. | + +The website starts with no published posts. To generate approved posts, run +`python blog/generate_posts.py` from this folder. diff --git a/episode-07/portfolio/blog/generate_posts.py b/episode-07/portfolio/blog/generate_posts.py new file mode 100644 index 0000000..81a7caf --- /dev/null +++ b/episode-07/portfolio/blog/generate_posts.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import html +import json +import re +from datetime import date +from pathlib import Path + + +BLOG_DIR = Path(__file__).parent +SOURCE_DIR = BLOG_DIR / "posts-src" +OUTPUT_DIR = BLOG_DIR / "posts" +POSTS_FILE = BLOG_DIR / "posts.json" +REQUIRED_FIELDS = ("title", "date", "summary") + + +def parse_post(source_path: Path) -> tuple[dict[str, str], str]: + text = source_path.read_text(encoding="utf-8") + if not text.startswith("---\n"): + raise ValueError(f"{source_path.name}: expected metadata between --- lines") + + try: + metadata_text, body = text[4:].split("\n---\n", 1) + except ValueError as error: + raise ValueError(f"{source_path.name}: metadata is missing its closing --- line") from error + + metadata: dict[str, str] = {} + for line in metadata_text.splitlines(): + key, separator, value = line.partition(":") + if not separator or not key.strip() or not value.strip(): + raise ValueError(f"{source_path.name}: invalid metadata line: {line}") + metadata[key.strip()] = value.strip() + + missing = [field for field in REQUIRED_FIELDS if not metadata.get(field)] + if missing: + raise ValueError(f"{source_path.name}: missing metadata: {', '.join(missing)}") + + date.fromisoformat(metadata["date"]) + if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", source_path.stem): + raise ValueError(f"{source_path.name}: filename must be a lowercase kebab-case slug") + + return metadata, body.strip() + + +def render_inline(text: str) -> str: + escaped = html.escape(text) + escaped = re.sub(r"\*\*(.+?)\*\*", r"\1", escaped) + escaped = re.sub( + r"\[([^\]]+)\]\((https?://[^)\s]+)\)", + r'\1', + escaped, + ) + return escaped + + +def render_markdown(markdown: str) -> str: + output: list[str] = [] + paragraph: list[str] = [] + list_type: str | None = None + + def flush_paragraph() -> None: + if paragraph: + output.append(f"

{render_inline(' '.join(paragraph))}

") + paragraph.clear() + + def close_list() -> None: + nonlocal list_type + if list_type: + output.append(f"") + list_type = None + + for raw_line in markdown.splitlines(): + line = raw_line.strip() + if not line: + flush_paragraph() + close_list() + continue + + heading = re.match(r"^(#{2,3})\s+(.+)$", line) + unordered = re.match(r"^[-*]\s+(.+)$", line) + ordered = re.match(r"^\d+\.\s+(.+)$", line) + + if heading: + flush_paragraph() + close_list() + level = len(heading.group(1)) + output.append(f"{render_inline(heading.group(2))}") + elif unordered or ordered: + flush_paragraph() + next_list_type = "ul" if unordered else "ol" + if list_type != next_list_type: + close_list() + output.append(f"<{next_list_type}>") + list_type = next_list_type + item = (unordered or ordered).group(1) + output.append(f"
  • {render_inline(item)}
  • ") + elif line.startswith("> "): + flush_paragraph() + close_list() + output.append(f"
    {render_inline(line[2:])}
    ") + else: + close_list() + paragraph.append(line) + + flush_paragraph() + close_list() + return "\n ".join(output) + + +def render_post_page(metadata: dict[str, str], body_html: str) -> str: + title = html.escape(metadata["title"]) + published = html.escape(metadata["date"]) + summary = html.escape(metadata["summary"]) + return f""" + + + + + {title} | Jamie Rivera + + + + + + +
    +
    +

    {published}

    +

    {title}

    +

    {summary}

    + {body_html} +

    Back to blog

    +
    +
    + + + + +""" + + +def main() -> None: + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + posts: list[dict[str, str]] = [] + + for output_path in OUTPUT_DIR.glob("*.html"): + output_path.unlink() + + for source_path in SOURCE_DIR.glob("*.md"): + metadata, body = parse_post(source_path) + output_name = f"{source_path.stem}.html" + output_path = OUTPUT_DIR / output_name + output_path.write_text( + render_post_page(metadata, render_markdown(body)), + encoding="utf-8", + ) + posts.append( + { + "title": metadata["title"], + "date": metadata["date"], + "summary": metadata["summary"], + "url": f"posts/{output_name}", + } + ) + + posts.sort(key=lambda post: post["date"], reverse=True) + POSTS_FILE.write_text(f"{json.dumps(posts, indent=2)}\n", encoding="utf-8") + print(f"Generated {len(posts)} post(s).") + + +if __name__ == "__main__": + main() diff --git a/episode-07/portfolio/blog/index.html b/episode-07/portfolio/blog/index.html new file mode 100644 index 0000000..b3ad737 --- /dev/null +++ b/episode-07/portfolio/blog/index.html @@ -0,0 +1,58 @@ + + + + + + Blog | Jamie Rivera + + + + + + + + +
    +
    +

    Writing and reflections

    +

    Blog

    +
    + +
    +
    +
    +
    + + + + + + diff --git a/episode-07/portfolio/blog/posts-src/.gitkeep b/episode-07/portfolio/blog/posts-src/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/episode-07/portfolio/blog/posts.json b/episode-07/portfolio/blog/posts.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/episode-07/portfolio/blog/posts.json @@ -0,0 +1 @@ +[] diff --git a/episode-07/portfolio/blog/posts/.gitkeep b/episode-07/portfolio/blog/posts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/episode-07/portfolio/index.html b/episode-07/portfolio/index.html new file mode 100644 index 0000000..fa25828 --- /dev/null +++ b/episode-07/portfolio/index.html @@ -0,0 +1,220 @@ + + + + + + Jamie Rivera | Portfolio + + + + + + + + +
    +
    +

    Seattle, WA

    +

    Jamie Rivera (they/them)

    +

    Communication & Media Studies Student | Storyteller and data-curious

    +

    + Junior Communication & Media Studies major with a Data Analytics minor who turns ideas into clear stories + across writing, audio, and social. Comfortable pairing creative work with simple data to show impact. +

    + +
    + +
    +

    About

    +

    + I am a junior at Cascade State University studying Communication & Media Studies with a minor in Data + Analytics. I enjoy creating clear, audience-first content and using simple data to show what is working. + I am seeking internship roles in content, communications, and community programs. +

    +
    + +
    +

    Resume Preview

    +

    + If the preview does not load in your browser, use the download button above. +

    +
    + +
    +
    + +
    +

    Experience

    +
    +
    +

    Marketing and Communications Intern

    +

    Greenline Community Trust | Seattle, WA | Jun 2025 - Present

    +
      +
    • Rebuilt the nonprofit social presence and grew Instagram following by 40% in one semester.
    • +
    • Write and schedule a weekly newsletter reaching more than 1,200 subscribers.
    • +
    • Produce short-form volunteer event recaps from filming through captions.
    • +
    +
    +
    +

    Peer Writing Tutor

    +

    Cascade State Writing Center | Seattle, WA | Sep 2024 - Present

    +
      +
    • Coach 60+ students each term on essays, resumes, and application materials.
    • +
    • Led two campus workshops on clear, structured writing.
    • +
    +
    +
    +

    Barista

    +

    Foghorn Coffee | 2023 - 2024

    +
      +
    • Trained four new hires and supported reliable service during high-volume shifts.
    • +
    +
    +
    +
    + +
    +

    Projects

    +
    +
    +

    First-Gen Voices Podcast (Founder & Producer)

    +

    2024 - Present

    +
      +
    • Launched a student podcast featuring first-generation college stories.
    • +
    • Published 15 episodes and reached 2,000+ downloads.
    • +
    • Own recording, editing, cover art, and release scheduling.
    • +
    + Podcast link (TODO: add URL) +
    +
    +

    Campus Recycling Dashboard

    +

    Spring 2025

    +
      +
    • Analyzed two years of campus waste data and built charts of recycling trends.
    • +
    • Recommended three changes that the sustainability office later piloted.
    • +
    +
    +
    +

    Greenline Content Refresh

    +

    Internship Project | 2025

    +
      +
    • Rewrote the Get Involved page and simplified volunteer signup flow, lifting completions.
    • +
    +
    +
    +

    Trail Journal

    +

    Personal Project | Ongoing

    +
      +
    • Growing photo-and-notes log of Pacific Northwest day hikes.
    • +
    + Project link (TODO: add URL) +
    +
    +
    + +
    +

    Blog

    +
    +

    View full blog

    +
    + +
    +

    Skills

    +
    +
    +

    Tools

    +

    Microsoft 365, Canva, Adobe Express, Audacity, basic HTML/CSS, Git/GitHub (learning)

    +
    +
    +

    Data

    +

    Spreadsheets, basic charting, survey design

    +
    +
    +

    Communication

    +

    Copywriting, public speaking, social media strategy

    +
    +
    +

    Languages

    +

    English (native), Spanish (conversational)

    +
    +
    +
    + +
    +

    Education

    +
    +

    Cascade State University

    +

    Seattle, WA | Expected May 2027

    +

    B.A. in Communication & Media Studies, Minor in Data Analytics | GPA 3.7 / 4.0

    +

    + Relevant coursework: Digital Media Production, Intro to Data Analytics, Public Relations Writing, Web + Content and Design +

    +
    +
    + +
    +

    Leadership & Involvement

    + +
    + +
    +

    Contact

    +

    + I am currently looking for internship opportunities in content, communications, and community-focused teams. + If you are hiring, I would love to connect. +

    + +
    +
    + + + + + + diff --git a/episode-07/portfolio/resume/Jamie Rivera - Resume.pdf b/episode-07/portfolio/resume/Jamie Rivera - Resume.pdf new file mode 100644 index 0000000..f2ff974 Binary files /dev/null and b/episode-07/portfolio/resume/Jamie Rivera - Resume.pdf differ diff --git a/episode-07/portfolio/script.js b/episode-07/portfolio/script.js new file mode 100644 index 0000000..da56423 --- /dev/null +++ b/episode-07/portfolio/script.js @@ -0,0 +1,107 @@ +const navToggle = document.querySelector(".nav-toggle"); +const navLinks = document.querySelector(".nav-links"); +const themeToggle = document.querySelector("#theme-toggle"); + +const themeStorageKey = "portfolio-theme"; +const savedTheme = localStorage.getItem(themeStorageKey); +const preferredTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +const initialTheme = savedTheme || preferredTheme; + +document.documentElement.setAttribute("data-theme", initialTheme); + +if (themeToggle) { + const syncThemeToggle = () => { + const isDark = document.documentElement.getAttribute("data-theme") === "dark"; + themeToggle.dataset.mode = isDark ? "dark" : "light"; + themeToggle.textContent = isDark ? "Light mode" : "Dark mode"; + themeToggle.setAttribute("aria-pressed", String(isDark)); + themeToggle.setAttribute("aria-label", isDark ? "Switch to light mode" : "Switch to dark mode"); + }; + + syncThemeToggle(); + + themeToggle.addEventListener("click", () => { + const isDark = document.documentElement.getAttribute("data-theme") === "dark"; + const nextTheme = isDark ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", nextTheme); + localStorage.setItem(themeStorageKey, nextTheme); + syncThemeToggle(); + }); +} + +if (navToggle && navLinks) { + navToggle.addEventListener("click", () => { + const isExpanded = navToggle.getAttribute("aria-expanded") === "true"; + navToggle.setAttribute("aria-expanded", String(!isExpanded)); + navLinks.classList.toggle("open", !isExpanded); + }); + + navLinks.querySelectorAll("a").forEach((link) => { + link.addEventListener("click", () => { + navToggle.setAttribute("aria-expanded", "false"); + navLinks.classList.remove("open"); + }); + }); +} + +const yearElement = document.querySelector("#year"); +if (yearElement) { + yearElement.textContent = String(new Date().getFullYear()); +} + +const revealElements = document.querySelectorAll(".reveal"); + +if ("IntersectionObserver" in window) { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + entry.target.classList.add("show"); + observer.unobserve(entry.target); + } + }); + }, + { threshold: 0.15 } + ); + + revealElements.forEach((element) => observer.observe(element)); +} else { + revealElements.forEach((element) => element.classList.add("show")); +} + +const renderPostList = async (list) => { + const response = await fetch(list.dataset.postSource); + if (!response.ok) { + throw new Error(`Unable to load blog posts: ${response.status}`); + } + + const posts = await response.json(); + const limit = Number.parseInt(list.dataset.postLimit, 10); + const visiblePosts = Number.isNaN(limit) ? posts : posts.slice(0, limit); + const postPrefix = list.dataset.postPrefix || ""; + + visiblePosts.forEach((post) => { + const article = document.createElement("article"); + article.className = "blog-card"; + + const heading = document.createElement(list.dataset.postLimit ? "h3" : "h2"); + const link = document.createElement("a"); + link.href = `${postPrefix}${post.url}`; + link.textContent = post.title; + heading.append(link); + + const date = document.createElement("p"); + date.className = "meta"; + date.textContent = post.date; + + const summary = document.createElement("p"); + summary.textContent = post.summary; + + article.append(heading, date, summary); + list.append(article); + }); +}; + +document.querySelectorAll("[data-post-list]").forEach((list) => { + renderPostList(list).catch((error) => console.error(error)); +}); diff --git a/episode-07/portfolio/styles.css b/episode-07/portfolio/styles.css new file mode 100644 index 0000000..b6a912a --- /dev/null +++ b/episode-07/portfolio/styles.css @@ -0,0 +1,407 @@ +:root { + --bg: #f7f8f6; + --surface: #ffffff; + --text: #111111; + --muted: #2f3b2f; + --line: #d8ddd2; + --primary: #1f6f3b; + --primary-hover: #16562d; + --accent: #e46f1b; + --accent-hover: #bf5a14; + --header-bg: rgba(247, 248, 246, 0.95); + --focus-ring: #f6b17b; + --skip-bg: #111111; + --btn-secondary-bg: #fff1e7; + --btn-secondary-border: #f6b17b; + --btn-secondary-hover-bg: #ffe5d4; + --radius: 14px; + --shadow: 0 8px 24px rgba(2, 8, 23, 0.08); + --space-1: 0.5rem; + --space-2: 0.75rem; + --space-3: 1rem; + --space-4: 1.5rem; + --space-5: 2rem; + --space-6: 3rem; + --max-width: 1100px; +} + +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #101211; + --surface: #181d1a; + --text: #f2f7f0; + --muted: #b8c8b7; + --line: #2d3931; + --primary: #37a15b; + --primary-hover: #2a8449; + --accent: #ff9b45; + --accent-hover: #ffb980; + --header-bg: rgba(16, 18, 17, 0.95); + --focus-ring: #ffb980; + --skip-bg: #000000; + --btn-secondary-bg: #2a2017; + --btn-secondary-border: #775134; + --btn-secondary-hover-bg: #35291d; + --shadow: 0 8px 24px rgba(0, 0, 0, 0.35); +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + font-family: "Segoe UI", system-ui, -apple-system, sans-serif; + color: var(--text); + background: var(--bg); + line-height: 1.6; +} + +a { + color: var(--primary); +} + +a:hover { + color: var(--primary-hover); +} + +.container { + width: min(100% - 2rem, var(--max-width)); + margin-inline: auto; +} + +.skip-link { + position: absolute; + top: -100px; + left: 1rem; + background: var(--skip-bg); + color: #fff; + padding: 0.5rem 0.75rem; + z-index: 9999; +} + +.skip-link:focus { + top: 1rem; +} + +.site-header { + position: sticky; + top: 0; + z-index: 10; + background: var(--header-bg); + border-bottom: 1px solid var(--line); + backdrop-filter: blur(8px); +} + +.nav { + position: relative; + min-height: 64px; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.nav-controls { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.brand { + text-decoration: none; + color: var(--text); + font-weight: 700; +} + +.nav-toggle { + display: none; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + padding: 0.4rem 0.7rem; + font: inherit; +} + +.theme-toggle { + display: inline-flex; + align-items: center; + gap: 0.4rem; + border: 1px solid var(--btn-secondary-border); + border-radius: 8px; + background: var(--btn-secondary-bg); + color: var(--accent-hover); + padding: 0.4rem 0.7rem; + font: inherit; + font-weight: 600; +} + +.theme-toggle::before { + content: "🌙"; + line-height: 1; +} + +.theme-toggle[data-mode="dark"]::before { + content: "☀️"; +} + +.theme-toggle:hover { + background: var(--btn-secondary-hover-bg); +} + +.nav-links { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 0.9rem; +} + +.nav-links a { + text-decoration: none; + color: var(--text); + font-weight: 500; +} + +.nav-links a:focus-visible, +.btn:focus-visible, +.nav-toggle:focus-visible, +.theme-toggle:focus-visible { + outline: 3px solid var(--focus-ring); + outline-offset: 2px; + border-radius: 6px; +} + +.hero { + padding-block: var(--space-6); +} + +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + font-size: 0.85rem; + margin-bottom: var(--space-2); +} + +h1 { + font-size: clamp(2rem, 5vw, 3rem); + line-height: 1.2; + margin: 0; +} + +.headline { + font-size: clamp(1.1rem, 2.8vw, 1.35rem); + color: var(--muted); + margin: var(--space-2) 0 var(--space-3); +} + +.summary { + max-width: 70ch; +} + +.section { + padding-block: var(--space-5); +} + +.resume-frame-wrap { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; +} + +.resume-frame { + display: block; + width: 100%; + min-height: 900px; + border: 0; +} + +h2 { + margin-top: 0; + margin-bottom: var(--space-4); + font-size: clamp(1.5rem, 4vw, 2rem); +} + +h3 { + margin: 0 0 var(--space-1); + font-size: 1.1rem; +} + +.cards, +.grid, +.blog-list { + display: grid; + gap: var(--space-3); +} + +.cards { + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} + +.grid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.card, +.panel, +.blog-card { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: var(--space-4); + box-shadow: var(--shadow); +} + +.blog-card h2, +.blog-card h3 { + margin-bottom: var(--space-1); +} + +.blog-card h2 { + font-size: 1.3rem; +} + +.blog-card p:last-child { + margin-bottom: 0; +} + +.blog-actions { + margin-top: var(--space-4); + font-weight: 600; +} + +.blog-post { + max-width: 760px; +} + +.blog-post > * + * { + margin-top: var(--space-3); +} + +.meta { + color: var(--muted); + font-size: 0.95rem; + margin-top: 0; +} + +.small { + font-size: 0.95rem; + color: var(--muted); +} + +.muted { + color: var(--muted); + font-weight: 500; +} + +.list-clean { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: var(--space-4) var(--space-5); + box-shadow: var(--shadow); +} + +.list-clean li + li { + margin-top: var(--space-2); +} + +.cta-row { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-top: var(--space-3); +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 10px; + padding: 0.62rem 1rem; + text-decoration: none; + font-weight: 600; + border: 1px solid transparent; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.btn:hover { + transform: translateY(-1px); +} + +.btn-primary { + background: var(--primary); + color: #fff; +} + +.btn-primary:hover { + color: #fff; + background: var(--primary-hover); +} + +.btn-secondary { + background: var(--btn-secondary-bg); + border-color: var(--btn-secondary-border); + color: var(--accent-hover); +} + +.btn-secondary:hover { + background: var(--btn-secondary-hover-bg); + color: var(--accent-hover); +} + +.site-footer { + border-top: 1px solid var(--line); + margin-top: var(--space-6); + padding: var(--space-4) 0; + color: var(--muted); +} + +.reveal { + opacity: 0; + transform: translateY(8px); + transition: opacity 0.45s ease, transform 0.45s ease; +} + +.reveal.show { + opacity: 1; + transform: translateY(0); +} + +@media (max-width: 900px) { + .nav-toggle { + display: inline-flex; + } + + .nav-links { + position: absolute; + right: 1rem; + top: 62px; + width: min(240px, 88vw); + padding: var(--space-3); + border: 1px solid var(--line); + border-radius: 12px; + box-shadow: var(--shadow); + background: var(--surface); + display: none; + flex-direction: column; + } + + .nav-links.open { + display: flex; + } + + .resume-frame { + min-height: 600px; + } +}