DevOps projects portfolio. Kubernetes deployments, AWS infrastructure, CI/CD pipelines, and GitOps workflows. Built from scratch with source code and runbooks.
Overview · Data Pipeline · Features · Adding a Project · Architecture · Structure · Development · CI/CD
Source for projects.ibtisam-iq.com: a filterable, searchable DevOps projects showcase.
Two properties define the design:
- Data-driven. All project content lives in
data/projects.yaml. Adding a project needs no source code change. - Prerendered for crawlers. The site is a client-rendered SPA, so every route would otherwise serve identical metadata. A post-build step writes one HTML shell per route.
One YAML file drives the build. Nothing downstream is edited by hand.
%%{init: {'flowchart': {'nodeSpacing': 14, 'rankSpacing': 38}, 'themeVariables': {'fontSize': '12px'}}}%%
flowchart TD
YAML["data/projects.yaml<br/><i>single source of truth</i>"]
GEN["generate-projects.js<br/><i>writes src/data/projects.ts</i>"]
VITE["vite build<br/><i>dist/index.html + hashed assets</i>"]
PRE["prerender-meta.js"]
ROUTES["dist/[route]/index.html<br/><i>one shell per route</i>"]
SEO["404.html<br/>sitemap.xml<br/>robots.txt"]
YAML --> GEN --> VITE --> PRE
PRE --> ROUTES
PRE --> SEO
style YAML fill:#16c8ec,stroke:#0a0f1d,color:#0a0f1d
style ROUTES fill:#10d492,stroke:#0a0f1d,color:#0a0f1d
style SEO fill:#10d492,stroke:#0a0f1d,color:#0a0f1d
| Stage | Runs | Produces |
|---|---|---|
generate-projects.js |
postinstall, dev, build |
src/data/projects.ts and the getAll* facet helpers |
vite build |
build |
Hashed JS and CSS bundles, one shared index.html |
prerender-meta.js |
after build |
Per-route shells, 404.html, sitemap.xml, robots.txt |
Note
src/data/projects.ts is generated and gitignored. It is recreated by postinstall, npm run dev, and npm run build. Run npm run generate to refresh it without starting a server.
Filtering
- Search across title, both descriptions, section content, tags, and technology names.
- Category: Platform or Tool, with live counts.
- Skills: multi-select capability tags such as
ci-cd,gitops,orchestration. Independent of the Technologies facet; narrows by discipline, not by tool. - Technologies: multi-select, grouped into six domains with in-popover search. Popover column count adapts to domain count (currently 3×2) rather than a fixed 4, so adding a domain doesn't leave an uneven last row.
- Status and Year: both derived from the data at build time.
Note
data/projects.yaml's tech and tags are validated at build time against
src/data/taxonomy.ts: every tech string carries a
domain and a showcase flag, and every tag comes from a closed vocabulary. An
unlisted string fails the build instead of compiling into a broken filter option.
showcase is read by ibtisam-iq.com to decide which tools
its skills page lists; this site shows every tech string regardless.
Note
Filter options come from getAllStatuses() and getAllYears(), never a hardcoded list. An option that matches zero projects cannot appear, and the Year control stays hidden while every project shares one year.
Pages
- Detail pages rendering dynamic sections, skills, the full tech stack, and link buttons. The card itself shows only the first 8 tech entries plus a "+N more" link, so the full list stays fully searchable and filterable without crowding the card. The 8 are curated per project, see
docs/authoring-guide.md#ordering-tech. - Methodology page at
/how-i-workoutlining the engineering pipeline.
Interface
- Dark theme locked to a deep space
#070b14gradient with frosted glass panels. - Count-up hero stats via
requestAnimationFramewith an easeOut curve. - Scroll reveals through
IntersectionObserverwith staggered delays. - Scroll progress indicator anchored to the top of the viewport.
- Below the
lgbreakpoint, filters collapse into a full-screen drawer with a focus trap and background scroll lock. - Every animation respects
prefers-reduced-motion.
Note
No source code changes are required to add a project.
- Append an entry to
data/projects.yaml. - Push to
main. - The pipeline compiles, prerenders, and deploys in roughly two minutes.
Required shortName, 28 characters or fewer, and homepage, a boolean:
shortNameis the name for chips and card headings, wheretitlewill not fit.homepagefeatures the project on the portfolio site. Seedocs/consumers.md.- The build rejects a missing or over-long
shortName, so neither can reach a consumer.
Optional metaTitle, roughly 45 to 55 characters:
- Used for the page
<title>andog:title. - Full
titlevalues run past what search results and link previews display. - When omitted, the build falls back to
titleup to the first:or,. - The meta description falls back to the first sentence of
shortDescription.
The same rule is mirrored client-side in src/utils/pageTitle.ts, so the prerendered shell and the hydrated app never disagree on a title.
References
- Authoring Guide: the full contract for a new entry (schema, taxonomy rules, tech ordering, and a checklist). Written to hand to an LLM as-is.
- Project Card Authoring Standards: prose and formatting style.
- Architecture & Schema Reference: data pipeline and schema specification.
- Consumers: the files another repository reads from here, and what changing them breaks.
%%{init: {'flowchart': {'nodeSpacing': 14, 'rankSpacing': 38}, 'themeVariables': {'fontSize': '12px'}}}%%
flowchart TD
App["App.tsx<br/><i>router, ScrollToTop</i>"]
Home["HomePage<br/><i>owns all filter state</i>"]
Detail["ProjectDetail.tsx<br/><i>/:slug</i>"]
How["HowIWork.tsx<br/><i>/how-i-work</i>"]
Chrome["Navbar · Hero · Footer"]
Bar["TopFilterBar.tsx<br/><i>owns dropdown state</i>"]
Cards["ProjectCard.tsx"]
Tools["ToolsMegaPopover"]
Chips["ActiveFilterChips"]
Drawer["MobileFilterDrawer<br/><i>portalled to body</i>"]
App --> Home
App --> Detail
App --> How
Home --> Chrome
Home --> Bar
Home --> Cards
Bar --> Tools
Bar --> Chips
Bar --> Drawer
style Home fill:#16c8ec,stroke:#0a0f1d,color:#0a0f1d
style Bar fill:#7c7cff,stroke:#0a0f1d,color:#ffffff
style Drawer fill:#ffc93c,stroke:#0a0f1d,color:#0a0f1d
Important
MobileFilterDrawer renders through createPortal into document.body. TopFilterBar is relative z-30, which creates a stacking context, so a fixed child inside it cannot rise above the z-50 sticky navbar no matter how high its own z-index goes.
State lives in one place. Every control reads and writes the same values, so desktop and mobile cannot drift.
%%{init: {'flowchart': {'nodeSpacing': 14, 'rankSpacing': 38}, 'themeVariables': {'fontSize': '12px'}}}%%
flowchart TD
STATE["HomePage useState<br/><i>search · category · tags<br/>tech · status · year</i>"]
BAR["TopFilterBar"]
POP["ToolsMegaPopover"]
CHIP["ActiveFilterChips"]
DRW["MobileFilterDrawer"]
MEMO["useMemo<br/><i>filteredProjects</i>"]
OUT["ProjectCard list"]
STATE -->|state| BAR
BAR --> POP
BAR --> CHIP
BAR --> DRW
POP -.->|setters| STATE
CHIP -.->|setters| STATE
DRW -.->|setters| STATE
STATE --> MEMO --> OUT
style STATE fill:#16c8ec,stroke:#0a0f1d,color:#0a0f1d
style MEMO fill:#10d492,stroke:#0a0f1d,color:#0a0f1d
Facet counts beside every option come from calculateProjectCounts() in src/utils/toolCategories.ts, computed once per project list.
| Layer | Technology |
|---|---|
| Framework | React 19 + TypeScript 5.9 |
| Styling | Tailwind CSS v4, @import plus an @config bridge to tailwind.config.js |
| Build tool | Vite 8 |
| Routing | React Router v7 |
| Icons | React Icons |
| Fonts | Plus Jakarta Sans, JetBrains Mono, Inter (Google Fonts) |
| Data format | YAML to TypeScript, generated at build time |
| Hosting | GitHub Pages |
| CI/CD | GitHub Actions |
| Custom domain | projects.ibtisam-iq.com |
Note
The theme is locked to dark: <html class="dark"> in index.html plus color-scheme: dark in index.css. There is no theme provider or toggle. darkMode: 'class' remains in the config so existing dark: variants keep resolving.
projects/
├── archive/
│ ├── README.md # Why files are archived, naming rule, restore notes.
│ └── workflows/ # Superseded workflows. Nothing here runs.
├── data/
│ └── projects.yaml # Single source of truth. Edited to add or update projects.
├── docs/
│ ├── architecture.md # Full architecture and pipeline documentation.
│ ├── authoring-guide.md # The contract for adding a project.
│ └── consumers.md # What another repo reads from here. Read before refactoring.
├── scripts/
│ ├── generate-projects.js # Converts projects.yaml to src/data/projects.ts.
│ └── prerender-meta.js # Per-route shells, 404.html, sitemap.xml, robots.txt.
├── src/
│ ├── components/
│ │ ├── Navbar.tsx # Sticky nav: scroll-progress bar, repository link, mobile menu.
│ │ ├── Hero.tsx # Landing section with animated stat counters.
│ │ ├── TopFilterBar.tsx # Owns dropdown state; desktop filter deck + mobile trigger.
│ │ ├── ToolsMegaPopover.tsx # Domain-grouped tech selector; column count adapts to domain count.
│ │ ├── ActiveFilterChips.tsx # Match count, removable chips, quick presets, reset-all.
│ │ ├── MobileFilterDrawer.tsx # Full-screen drawer below `lg`, portalled to <body>.
│ │ ├── ProjectCard.tsx # Card with scroll reveal and hover glow.
│ │ ├── ProjectDetail.tsx # Project page with dynamic sections and sidebar metadata.
│ │ ├── HowIWork.tsx # /how-i-work methodology page.
│ │ └── Footer.tsx
│ ├── hooks/
│ │ ├── useCanonical.ts # Keeps <link rel="canonical"> in sync on route change.
│ │ ├── useCountUp.ts # requestAnimationFrame counter with easeOut curve.
│ │ └── useInView.ts # IntersectionObserver hook for scroll-triggered animations.
│ ├── utils/
│ │ ├── pageTitle.ts # Client mirror of the prerender title rule.
│ │ └── toolCategories.ts # Groups tech into taxonomy domains; computes facet counts.
│ ├── data/
│ │ ├── taxonomy.ts # Single source of truth for tech domains and the tag vocabulary.
│ │ └── projects.ts # AUTO-GENERATED. Gitignored. Never edited by hand.
│ ├── types/
│ │ └── project.ts # Project TypeScript interface.
│ ├── App.tsx # Router setup, ScrollToTop wrapper.
│ ├── main.tsx
│ └── index.css # Gradient background, scrollbars, animations, skip-link.
├── .github/
│ └── workflows/
│ └── pages.yml # CI/CD pipeline.
├── public/ # Favicon, icons, web manifest.
├── CNAME
├── index.html
├── package.json
├── tailwind.config.js # Color tokens, fonts, animations.
├── vite.config.ts
└── tsconfig.app.json
git clone https://github.com/ibtisam-iq/projects.git
cd projects
npm install # postinstall generates src/data/projects.ts
npm run dev| Command | Purpose |
|---|---|
npm run dev |
Generate data, then start Vite |
npm run build |
Generate, typecheck, bundle, prerender |
npm run generate |
Refresh src/data/projects.ts only |
npm run lint |
ESLint across the repo |
act push -W .github/workflows/pages.ymlNote
Artifact upload and Pages deployment are skipped via if: ${{ env.ACT != 'true' }} guards. The act utility sets that variable automatically.
| Trigger | Behaviour |
|---|---|
push to main |
Build and deploy |
pull_request to main |
Build only. Deploy job skipped via if: github.event_name != 'pull_request' |
workflow_dispatch |
Manual re-run from the Actions UI |
%%{init: {'flowchart': {'nodeSpacing': 14, 'rankSpacing': 38}, 'themeVariables': {'fontSize': '12px'}}}%%
flowchart TD
SETUP["Checkout + Node.js 24"]
CI["npm ci<br/><i>postinstall generates projects.ts</i>"]
BUILD["npm run build<br/><i>vite + prerender-meta.js</i>"]
VERIFY{"Every shell carries<br/>its own og:url?"}
FAIL["Fail the build"]
DEPLOY["Add CNAME<br/>Deploy to GitHub Pages"]
SETUP --> CI --> BUILD --> VERIFY
VERIFY -->|no| FAIL
VERIFY -->|yes| DEPLOY
style VERIFY fill:#ffc93c,stroke:#0a0f1d,color:#0a0f1d
style FAIL fill:#ff8080,stroke:#0a0f1d,color:#0a0f1d
style DEPLOY fill:#10d492,stroke:#0a0f1d,color:#0a0f1d
sitemap.xml, robots.txt, and 404.html are written by prerender-meta.js rather than held in public/:
- The SPA fallback would answer
/robots.txtand/sitemap.xmlwith the app shell, so a crawler asking for the sitemap would receive HTML. - The sitemap is built from the same route list as the shells, so a project cannot appear in one without appearing in the other.
404.htmlcarries its own title, a self-referential canonical, andnoindex. A copy ofindex.htmlwould give every dead URL the home page metadata instead.
The og:url check is a build gate, not a warning. A shell left pointing at the site root means the metadata swap silently failed, which is exactly the case crawlers would expose in production.
For the data pipeline, design constraints, and extension points, see docs/architecture.md.