Compare commits

..
59 Commits
Author SHA1 Message Date
AramJonghu 208299b3d8 fix(contact): xmpp address typo 2026-08-11 14:03:43 +02:00
AramJonghu 0d78d32144 chore: update package.json 2026-08-11 12:37:03 +02:00
AramJonghu ad7e151618 chore(projects): removal of projects section on home page 2026-08-11 12:36:10 +02:00
AramJonghu 28b2fff3d6 chore: audit fix and temporary removal ts7 from package 2026-08-11 12:10:34 +02:00
AramJonghu 6d44d8d1e9 chore(npm): audit fix and resolve dependency hell 2026-08-10 01:27:42 +02:00
AramJonghu abd43cefdd chore(contact): Updated contact page with some context flavour text 2026-08-08 18:42:05 +02:00
AramJonghu d2cc733386 chore(cleanup): Cleaning up some double or irrelevant information 2026-08-08 18:40:43 +02:00
AramJonghu 0611e56399 chore(mobile): mobile view improved /w min-w and component order 2026-08-07 06:11:09 +02:00
AramJonghu 7777afae64 feat(avatar): added avatar to portfolio 2026-08-06 22:53:58 +02:00
AramJonghu 4388242241 chore(styling): header and footer flexbox styling fix 2026-08-06 22:43:18 +02:00
AramJonghu fec4ed568c feat(Portfolio): implementation of Portfolio page 2026-08-06 22:19:04 +02:00
AramJonghu 030ca4009c feat(Timeline): Timeline component showing current entry. Might change. 2026-08-06 22:19:04 +02:00
AramJonghu 4a190627cb feat(portfolio sectioning): sectioning component of Portfolio.tsx 2026-08-06 22:19:04 +02:00
AramJonghu 7668100fb6 feat(skills): lists skills component of portfolio 2026-08-06 22:19:04 +02:00
AramJonghu 8e4f01b213 feat(content): portfolio content 2026-08-06 22:19:04 +02:00
AramJonghu c08ffdcb5e chore(footer): reordering and removal of github icon 2026-08-06 22:19:04 +02:00
AramJonghu a38f1b64b6 chore(redirects): added more icons and moved redirects to footer 2026-08-06 22:19:04 +02:00
AramJonghu 30476dbc27 chore(shiki): removed languages currently improbable to be part needed 2026-08-04 03:45:38 +02:00
AramJonghu 9993c9dcfa chore(link): LISCENSE link direct 2026-08-01 17:25:12 +02:00
AramJonghu b764d1c86b chore(npm): alias so eslint does not break 2026-08-01 17:21:37 +02:00
AramJonghu f8953a5d7b update(deps): npm deps updated 2026-08-01 17:11:23 +02:00
AramJonghu dc94a50354 chore(activity): updated 2026-07-31 22:03:07 +02:00
AramJonghu 3a42489556 feat(hamburger_menu): added mobile friendly hamburger menu 2026-07-31 21:53:38 +02:00
AramJonghu 4e91ecafec chore(prettier): prettier format 2026-07-31 21:27:39 +02:00
AramJonghu 34e1387aa8 feat(contacts+css): minor css changes + contacts page 2026-07-31 21:26:51 +02:00
AramJonghu 5d07d33eaa chore(mobile view): more mobile view improvements e.g. item sizes 2026-07-31 20:23:38 +02:00
AramJonghu 9edde535bb feat(mobile): improve mobile view by introducing css for small devices 2026-07-31 18:30:57 +02:00
AramJonghu 6b94a75e30 feat(default): default page changed to about page 2026-07-29 06:41:46 +02:00
AramJonghu 046c2f395a chore(npm): audit fix 2026-07-29 06:39:37 +02:00
AramJonghu 605685abb3 feat(about): new language & tools section (incomplete) 2026-07-29 06:38:25 +02:00
AramJonghu b629e042b2 chore(ci): on push ci vs on pr 2026-07-29 05:04:34 +02:00
AramJonghu f91587a767 chore(cleanup): removed double title card text; less wasted space 2026-07-23 03:29:05 +02:00
AramJonghu d2b2eb2343 perf(highlighter): lazy-init Shiki at module level without blocking 2026-07-22 16:14:28 +02:00
AramJonghu e613d96ddf chore(nesting): reduced nesting 2026-07-22 00:01:39 +02:00
AramJonghu 21c61968fe chore(background): project layout background is now fitting 2026-07-21 23:51:03 +02:00
AramJonghu f583696295 chore(icons): nerd-fonts icons included 2026-07-21 23:47:55 +02:00
AramJonghu bfc7dd9018 chore(README): updated README to fit changes 2026-07-21 23:26:27 +02:00
AramJonghu 9041c683f8 feat(codeblocks): codeblocks now have proper theming with highlighting 2026-07-21 23:24:16 +02:00
AramJonghu 1311c79479 feat(font): inter default font, monospace untouched 2026-07-20 18:59:33 +02:00
AramJonghu b1cc3a837a chore(format): prettier format 2026-07-20 17:46:08 +02:00
AramJonghu d489adee3c Chore(refactor): refactor project info into separate component 2026-07-20 16:54:45 +02:00
AramJonghu e16833ecc3 chore(readme): adds LICENSE link in README 2026-07-19 22:14:53 +02:00
AramJonghu d66133082c chore(project): new project added 2026-07-19 20:45:46 +02:00
AramJonghu 51075ac117 Added LICENSE and hotfix bookmark log 2026-07-11 15:46:58 +02:00
AramJonghu 80896f2287 hotfix(contact): updated xmpp contact to self hosted domain 2026-07-07 15:06:01 +02:00
AramJonghu d4adc0e31b hotfix(about): Let through incorrect wording, hotfixed. 2026-07-06 01:17:01 +02:00
AramJonghu 702c9cda69 hotfix(image): incorrect image timing, not complete 2026-07-05 20:23:52 +02:00
AramJonghu eb9e457287 hotfix(readme): Readme update to fit changes in screenshot 2026-07-05 20:19:03 +02:00
AramJonghu 417ab28fcc hotfix(readme): Readme update to fit changes in screenshot 2026-07-05 20:12:02 +02:00
AramJonghu c4c50276d3 Merge branch 'about-systems-components' 2026-07-05 20:05:07 +02:00
AramJonghu 7482999fb4 Adjust(refactor): Refactor systems to current-activity 2026-07-05 20:01:21 +02:00
AramJonghu 890741335e update(CI): workflow updated for PR's 2026-07-05 19:36:53 +02:00
AramJonghu e3b9014c02 feat(about): add Notable Questions, fix grammar, blockquote formatting 2026-07-05 19:09:57 +02:00
AramJonghuandForgejo Server d84c8b62e0 Removal of jj commit contents, test commits 2026-07-05 15:11:28 +02:00
AramJonghuandForgejo Server dcb428114c Removing test commit changes with jj 2026-07-05 15:11:28 +02:00
AramJonghuandForgejo Server 089a0f3e32 a commit after jj new to test if I do correctly 2026-07-05 15:11:28 +02:00
AramJonghuandForgejo Server 49520a8ce3 another addition test 2026-07-05 15:11:28 +02:00
AramJonghuandForgejo Server 2074a2806a testing addition 2026-07-05 15:11:28 +02:00
AramJonghu bd23d31ed3 update tld nl -> dev 2026-07-05 01:20:10 +02:00
37 changed files with 1427 additions and 492 deletions
+37
View File
@@ -0,0 +1,37 @@
name: Format, lint, typecheck & build
on:
push:
branches: ["master"]
jobs:
check:
runs-on: alpine
container: node:lts-alpine
steps:
- name: Install dependencies
run: apk add --no-cache git
- name: Checkout repo
uses: actions/checkout@v4
- name: Install frontend deps
working-directory: frontend
run: npm install
- name: Run eslint check
working-directory: frontend
run: npm run lint
- name: Run typecheck
working-directory: frontend
run: npm run typecheck
- name: Run Prettier check
working-directory: frontend
run: npx prettier --check .
- name: Run build check
working-directory: frontend
run: npm run build
-25
View File
@@ -1,25 +0,0 @@
name: Format frontend with Prettier
on:
push:
branches: ["master"]
jobs:
format:
runs-on: alpine
container: node:26-alpine
steps:
- name: Install dependencies
run: apk add --no-cache git
- name: Checkout repo
uses: actions/checkout@v4
- name: Install frontend deps
working-directory: frontend
run: npm install
- name: Run Prettier check
working-directory: frontend
run: npx prettier --check .
+29
View File
@@ -0,0 +1,29 @@
BSD 3-Clause License
Copyright (c) 2025, AramJonghu
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+38 -29
View File
@@ -8,35 +8,38 @@ This site is not just a portfolio — it acts as an entry point into a larger ec
## Overview
The site is divided into three conceptual layers:
The site is divided into two conceptual layers:
### 1. Home
### 1. Tabs (Home)
The homepage provides a high-level overview of who I am and what I work on.
It highlights selected projects and core skills, without listing everything.
The homepage provides tabs for three sections:
- **Projects** — fetches and renders READMEs from self-hosted Forgejo repositories via raw URL
- **Current-activity** — language learning progress and active work
- **About** — personal background, philosophy, and Q&A
### 2. Header
The header serves as a navigation layer into my self-hosted infrastructure.
Each entry links to a running instance.
The header serves as a navigation layer into my self-hosted infrastructure. Each entry links to a running instance.
- Nextcloud — file storage and synchronization
- Navidrome — personal music streaming
- Forgejo — Git hosting and development platform
- OmniSearch — privacy-focused search engine
- Stream service — live media platform powered by MediaMTX
- Friends Gitea instance — external development collaboration
- Forgejo Git — Git hosting and development platform
- Stream — live media platform powered by MediaMTX (internal route)
- Omnisearch — privacy-focused search engine
- Gitea zach-dev — external development collaboration
These services are part of my personal infrastructure and exist outside the portfolio itself.
## Stack
### 3. Pages
Dedicated pages for deeper content:
- Projects — selected work and builds
- Systems — architecture and self-hosted infrastructure overview
- About — personal background and context
- CV — formal resume (if applicable)
| Layer | Choice | Notes |
| --------- | ---------------------------- | ----------------------------------------- |
| Language | TypeScript / React 19 | Vite 8 + rolldown |
| CSS | TailwindCSS v4 | Catppuccin macchiato/latte |
| Markdown | react-markdown v10 | remark-gfm, rehype-raw, rehype-sanitize |
| Syntax HL | Shiki v4 | Catppuccin themes, CSS variable switching |
| Font | Inter (self-hosted, 4 WOFF2) | no Google Fonts |
| Theme | Catppuccin macchiato/latte | JS toggle, persisted to localStorage |
| Git host | Forgejo (self-hosted) | git.aramjonghu.dev |
## Philosophy
@@ -51,16 +54,22 @@ Instead of a traditional portfolio, it focuses on:
It evolves alongside my personal development and technical interests.
## Stack
## Project layout
- React
- Vite
- Tailwind CSS
- Catppuccin theme
- Self-hosted backend services (Forgejo, Nextcloud, etc.)
```
├── public/ favicon
├── src/
│ ├── assets/ images, fonts, logo
│ ├── components/ MdProcessor, Header, Layout, ProjectInfo, ...
│ ├── content/ about.md, current-activity.md, projects.json
│ ├── hooks/ useTheme
│ ├── pages/ Home
│ └── utils/ theme, highlighter
├── index.html
├── package.json
└── LICENSE
```
## Status
## License
This site is continuously evolving and serves as both a portfolio and experimental environment.
Expect structural changes as systems grow.
BSD 3-Clause. See [LICENSE](https://git.aramjonghu.dev/AramJonghu/aramjonghu-site/src/branch/master/LICENSE).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 71 KiB

+9 -8
View File
@@ -11,6 +11,7 @@
"preview": "vite preview"
},
"dependencies": {
"@shikijs/rehype": "^4.3.1",
"@tailwindcss/vite": "^4.2.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@@ -18,26 +19,26 @@
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"tailwindcss": "^4.2.2"
"shiki": "^4.3.1",
"tailwindcss": "^4.2.2",
"typescript-eslint": "^8.67.0"
},
"devDependencies": {
"@babel/core": "^7.29.0",
"@babel/core": "^8.0.1",
"@catppuccin/tailwindcss": "^1.0.0",
"@eslint/js": "^9.39.4",
"@eslint/js": "^10.0.1",
"@rolldown/plugin-babel": "^0.2.2",
"@tailwindcss/language-server": "^0.14.29",
"@tailwindcss/language-server": "^0.16.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.39.4",
"eslint": "^10.8.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"prettier": "^3.8.3",
"typescript": "^5.7.3",
"typescript-eslint": "^8.61.1",
"typescript-language-server": "^5.3.0",
"typescript": "^7.0.2",
"vite": "^8.0.4",
"vscode-langservers-extracted": "^4.10.0"
},
+63 -1
View File
@@ -1,8 +1,54 @@
@import "tailwindcss";
@import "@catppuccin/tailwindcss/macchiato.css";
@theme {
--font-family-sans: Inter, ui-sans-serif, system-ui, sans-serif;
}
@font-face {
font-family: "Inter";
src: url("./assets/fonts/Inter-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Inter";
src: url("./assets/fonts/Inter-Medium.woff2") format("woff2");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Inter";
src: url("./assets/fonts/Inter-SemiBold.woff2") format("woff2");
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Inter";
src: url("./assets/fonts/Inter-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "NerdFontSymbols";
src: url("./assets/fonts/nerd-fonts-symbols.woff2") format("woff2");
font-display: block;
}
.ui-btn {
@apply text-ctp-text border border-ctp-text rounded px-3 py-1 cursor-pointer transition-colors;
@apply text-ctp-text text-sm border border-ctp-text rounded px-3 py-1 min-w-8 lg:min-w-26 text-center cursor-pointer transition-colors;
}
.theme-ui-btn {
@apply text-ctp-text text-3xl border border-ctp-text rounded px-3 py-1 min-w-10 text-center cursor-pointer transition-colors;
}
.ui-btn:hover {
@@ -22,3 +68,19 @@
.stream-frame iframe {
@apply w-full h-full border-0;
}
.shiki,
.shiki .line span {
color: var(--shiki-light);
background-color: var(--shiki-light-bg);
}
.macchiato .shiki,
.macchiato .shiki .line span {
color: var(--shiki-dark);
background-color: var(--shiki-dark-bg);
}
.macchiato .icon-invert {
filter: invert(1);
}
+11 -3
View File
@@ -4,8 +4,10 @@ import "./App.css";
import Layout from "./components/Layout";
import Home from "./pages/Home";
import Stream from "./pages/Stream";
import Contact from "./pages/Contact";
import Portfolio from "./pages/Portfolio";
function App(): ReactElement {
export default function App(): ReactElement {
const path = window.location.pathname.toLowerCase();
let content: ReactElement = <Home />;
@@ -13,7 +15,13 @@ function App(): ReactElement {
content = <Stream />;
}
if (path === "/contact") {
content = <Contact />;
}
if (path === "/portfolio") {
content = <Portfolio />;
}
return <Layout>{content}</Layout>;
}
export default App;
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>github [#142]</title>
<desc>Created with Sketch.</desc>
<defs>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Dribbble-Light-Preview" transform="translate(-140.000000, -7559.000000)" fill="#000000">
<g id="icons" transform="translate(56.000000, 160.000000)">
<path d="M94,7399 C99.523,7399 104,7403.59 104,7409.253 C104,7413.782 101.138,7417.624 97.167,7418.981 C96.66,7419.082 96.48,7418.762 96.48,7418.489 C96.48,7418.151 96.492,7417.047 96.492,7415.675 C96.492,7414.719 96.172,7414.095 95.813,7413.777 C98.04,7413.523 100.38,7412.656 100.38,7408.718 C100.38,7407.598 99.992,7406.684 99.35,7405.966 C99.454,7405.707 99.797,7404.664 99.252,7403.252 C99.252,7403.252 98.414,7402.977 96.505,7404.303 C95.706,7404.076 94.85,7403.962 94,7403.958 C93.15,7403.962 92.295,7404.076 91.497,7404.303 C89.586,7402.977 88.746,7403.252 88.746,7403.252 C88.203,7404.664 88.546,7405.707 88.649,7405.966 C88.01,7406.684 87.619,7407.598 87.619,7408.718 C87.619,7412.646 89.954,7413.526 92.175,7413.785 C91.889,7414.041 91.63,7414.493 91.54,7415.156 C90.97,7415.418 89.522,7415.871 88.63,7414.304 C88.63,7414.304 88.101,7413.319 87.097,7413.247 C87.097,7413.247 86.122,7413.234 87.029,7413.87 C87.029,7413.87 87.684,7414.185 88.139,7415.37 C88.139,7415.37 88.726,7417.2 91.508,7416.58 C91.513,7417.437 91.522,7418.245 91.522,7418.489 C91.522,7418.76 91.338,7419.077 90.839,7418.982 C86.865,7417.627 84,7413.783 84,7409.253 C84,7403.59 88.478,7399 94,7399" id="github-[#142]">
</path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+38
View File
@@ -0,0 +1,38 @@
<svg xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
viewBox="0 0 256 256" width="256" height="256">
<style>
.st1,.st2{fill:#000;stroke:#fff;stroke-width:9.9999;stroke-miterlimit:15.118}
.st2{fill:none;stroke-linecap:round}
</style>
<g id="spinGroup">
<circle cx="128" cy="128" r="122.8"
style="fill:#0084ff;stroke:#fff;stroke-width:10.3641;stroke-miterlimit:15.118"/>
<circle cx="128" cy="128" r="43.7" fill="#fff" class="st1"/>
<path fill="#fff"
d="M201.8 128c0 40.7-33 73.8-73.8 73.8M54.2 128c0-40.7 33-73.8 73.8-73.8"
class="st2"/>
<path fill="#fff"
d="M224.9 128c0 1.2 0 2.5-.1 3.7m-2.2 17.6c-9.7 43.3-48.4 75.6-94.6 75.6
M31.1 128c0-1.1 0-2.2.1-3.3m1.8-16c9-44.2 48.1-77.6 95-77.6"
class="st2"/>
<circle cx="128" cy="128" r="9" fill="#fff" class="st1"/>
<!-- SPIN ANIMATION INSIDE THE GROUP -->
<animateTransform
attributeName="transform"
type="rotate"
from="0 128 128"
to="360 128 128"
dur="3s"
repeatCount="indefinite"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<circle cx="512" cy="512" r="512" style="fill:#0082c9"/>
<path d="M512.5 370.9c-64.6 0-118.8 44.2-135.4 103.7-14.5-31.8-46.3-54.4-83.3-54.4-50.6.2-91.5 41.2-91.8 91.8.2 50.6 41.2 91.5 91.8 91.8 37 0 68.8-22.6 83.3-54.4 16.6 59.5 70.8 103.7 135.4 103.7 64.2 0 118.2-43.7 135.1-102.7 14.7 31.3 46.1 53.4 82.6 53.4 50.6-.2 91.6-41.2 91.8-91.8-.2-50.6-41.2-91.6-91.8-91.8-36.6 0-67.9 22.2-82.6 53.4-16.9-59-70.9-102.8-135.1-102.7zm0 53.8c48.5 0 87.3 38.8 87.3 87.3s-38.8 87.3-87.3 87.3c-48 .2-87-38.5-87.2-86.5v-.8c-.1-48.5 38.7-87.3 87.2-87.3zm-218.7 49.4c21.2 0 37.9 16.7 37.9 37.9 0 21.3-16.6 37.9-37.9 37.9-20.7.2-37.7-16.4-37.9-37.1v-.8c0-21.3 16.6-37.9 37.9-37.9zm436.4 0c21.3 0 37.9 16.7 37.9 37.9 0 21.3-16.7 37.9-37.9 37.9-20.7.2-37.7-16.4-37.9-37.1v-.8c0-21.3 16.7-37.9 37.9-37.9z" style="fill:#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-0.5 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 11.8201C22 9.84228 21.4135 7.90885 20.3147 6.26436C19.2159 4.61987 17.6542 3.33813 15.8269 2.58126C13.9996 1.82438 11.9889 1.62637 10.0491 2.01223C8.10927 2.39808 6.32748 3.35052 4.92896 4.74904C3.53043 6.14757 2.578 7.92935 2.19214 9.86916C1.80629 11.809 2.00436 13.8197 2.76123 15.6469C3.51811 17.4742 4.79985 19.036 6.44434 20.1348C8.08883 21.2336 10.0222 21.8201 12 21.8201" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 11.8201H22" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 21.8201C10.07 21.8201 8.5 17.3401 8.5 11.8201C8.5 6.30007 10.07 1.82007 12 1.82007C13.93 1.82007 15.5 6.30007 15.5 11.8201" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.3691 21.6901C20.3021 21.6901 21.8691 20.1231 21.8691 18.1901C21.8691 16.2571 20.3021 14.6901 18.3691 14.6901C16.4361 14.6901 14.8691 16.2571 14.8691 18.1901C14.8691 20.1231 16.4361 21.6901 18.3691 21.6901Z" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M22.9998 22.8202L20.8398 20.6702" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+6 -10
View File
@@ -1,15 +1,11 @@
export default function About() {
import { type ReactElement } from "react";
import MdProcessor from "./MdProcessor";
import aboutMd from "../content/about.md?raw";
export default function About(): ReactElement {
return (
<>
<h1 className="text-center m-6 text-3xl font-bold text-ctp-mauve">
About
</h1>
<div className="flex justify-center mb-6">
<p className="text-ctp-text">
</p>
<p className="text-ctp-text"></p>
</div>
<MdProcessor content={aboutMd} />
</>
);
}
@@ -0,0 +1,11 @@
import { type ReactElement } from "react";
import MdProcessor from "./MdProcessor";
import currentActivityMd from "../content/current-activity.md?raw";
export default function CurrentActivity(): ReactElement {
return (
<>
<MdProcessor content={currentActivityMd} />
</>
);
}
+60 -31
View File
@@ -1,42 +1,71 @@
import type { ReactElement } from "react";
import nextcloud from "../assets/images/nextcloud.svg";
import navidrome from "../assets/images/navidrome.svg";
import forgejo from "../assets/images/forgejo.svg";
import gitea from "../assets/images/gitea.svg";
import search from "../assets/images/search.svg";
export default function Footer() {
interface IconLink {
label: string;
url: string;
src: string;
invert?: boolean;
}
const iconLinks: IconLink[] = [
{
label: "Nextcloud",
url: "https://nextcloud.aramjonghu.dev",
src: nextcloud,
},
{ label: "Navidrome", url: "https://music.aramjonghu.dev", src: navidrome },
{
label: "Forgejo Git",
url: "https://git.aramjonghu.dev/AramJonghu",
src: forgejo,
},
{
label: "Omnisearch",
url: "https://xng.aramjonghu.dev",
src: search,
invert: true,
},
{
label: "Gitea zach-dev",
url: "https://git.zach-dev.cc/AramJonghu",
src: gitea,
},
];
export default function Footer(): ReactElement {
const currentYear = new Date().getFullYear();
const goToForgejo = () => {
window.location.href =
"https://git.aramjonghu.nl/AramJonghu/aramjonghu-site";
};
const goToGitea = () => {
window.location.href =
"https://git.zach-dev.cc/AramJonghu/aramjonghu-site";
};
const startYear = 2025;
return (
<footer className="bg-ctp-mantle flex items-center justify-between px-24 py-6 border-t border-ctp-lavender-800 mt-10">
<footer className="bg-ctp-mantle flex flex-col md:flex-row items-center justify-around gap-3 px-4 md:px-24 py-6 border-t border-ctp-lavender-800 mt-10">
<p className="text-sm text-ctp-text">
&copy; {currentYear} AramJonghu. All rights reserved.
&copy; {startYear} - {currentYear} AramJonghu. All rights
reserved.
</p>
<div className="flex">
<p className="text-sm text-ctp-text">
Check out the website repository here:
</p>
<img
src={forgejo}
alt="Forgejo repository"
onClick={goToForgejo}
className="ml-2 w-6 h-6 cursor-pointer hover:opacity-80 transition-opacity"
/>
<img
src={gitea}
alt="Gitea mirror"
onClick={goToGitea}
className="ml-2 w-6 h-6 cursor-pointer hover:opacity-80 transition-opacity"
/>
<div className="flex items-center gap-2">
{iconLinks.map((icon) => (
<a
key={icon.label}
href={icon.url}
target="_blank"
rel="noreferrer"
aria-label={icon.label}
title={icon.label}
className="p-2 cursor-pointer hover:opacity-80 transition-opacity"
>
<img
src={icon.src}
alt={icon.label}
className={`w-7 h-7 ${icon.invert ? "icon-invert" : ""}`}
/>
</a>
))}
</div>
</footer>
);
+78 -24
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import Aku from "../assets/images/aku.png";
interface NavItem {
interface PageItem {
label: string;
url: string;
}
@@ -16,46 +17,99 @@ interface HeaderProps {
export default function Header({ theme }: HeaderProps) {
const { theme: currentTheme, toggleTheme } = theme;
const [menuOpen, setMenuOpen] = useState(false);
const navItems: NavItem[] = [
{ label: "Nextcloud", url: "https://nextcloud.aramjonghu.nl" },
{ label: "Navidrome", url: "https://music.aramjonghu.nl" },
{ label: "Forgejo Git", url: "https://git.aramjonghu.nl" },
const pageItems: PageItem[] = [
{ label: "Stream", url: "/stream" },
{ label: "Omnisearch", url: "https://xng.aramjonghu.nl" },
{ label: "Gitea zach-dev", url: "https://git.zach-dev.cc" },
{ label: "Portfolio", url: "/portfolio" },
{ label: "Contact", url: "/contact" },
];
const goHome = () => {
window.location.href = "/";
};
return (
<header className="bg-ctp-mantle flex justify-between items-center px-12 py-6 gap-6 border-b border-ctp-lavender-800">
<img
className="size-16 cursor-pointer"
src={Aku}
alt="Home"
onClick={goHome}
/>
const closeMenu = () => {
setMenuOpen(false);
};
<nav className="flex flex-wrap gap-3 items-center">
{navItems.map((item) => (
return (
<header className="bg-ctp-mantle flex flex-col md:flex-row justify-evenly items-start md:items-center px-4 md:px-12 py-4 md:py-6 gap-4 md:gap-6 border-b border-ctp-lavender-800">
<div className="grid grid-cols-3 items-center w-full md:hidden gap-4">
<img
className="size-16 cursor-pointer justify-self-start"
src={Aku}
alt="Home"
onClick={goHome}
/>
<button
className="theme-ui-btn ui-btn:hover font-[NerdFontSymbols] justify-self-center"
aria-label="Toggle menu"
aria-expanded={menuOpen}
onClick={() => setMenuOpen(!menuOpen)}
>
{menuOpen ? "\uf00d" : "\uf0c9"}
</button>
<button
className="theme-ui-btn ui-btn:hover text-lg font-[NerdFontSymbols] justify-self-end"
onClick={toggleTheme}
>
{currentTheme === "macchiato" ? "" : ""}
</button>
</div>
<div className="hidden md:block">
<img
className="size-16 cursor-pointer"
src={Aku}
alt="Home"
onClick={goHome}
/>
</div>
<nav className="hidden md:flex flex-wrap gap-2 md:gap-3 items-end">
{pageItems.map((page) => (
<a
key={item.label}
href={item.url}
className="ui-btn ui-btn:hover"
key={page.label}
href={page.url}
className="ui-btn ui-btn:hover px-2 py-1 md:px-3 md:py-1"
>
{item.label}
{page.label}
</a>
))}
</nav>
<div>
<button className="ui-btn ui-btn:hover" onClick={toggleTheme}>
{currentTheme === "macchiato" ? "🌙 Dark" : "☀️ Light"}
<div className="hidden md:block">
<button
className="theme-ui-btn ui-btn:hover text-lg font-[NerdFontSymbols]"
onClick={toggleTheme}
>
{currentTheme === "macchiato" ? "" : ""}
</button>
</div>
<div
className={`md:hidden w-full overflow-hidden transition-all duration-150 ease-in-out ${
menuOpen
? "max-h-80 opacity-100 visible"
: "max-h-0 opacity-0 invisible"
}`}
>
<nav className="flex flex-col gap-2 w-full max-w-sm mx-auto border-t border-ctp-lavender-800 pt-4">
{pageItems.map((item) => (
<a
key={item.label}
href={item.url}
className="ui-btn ui-btn:hover px-2 py-1 w-full"
onClick={closeMenu}
>
{item.label}
</a>
))}
</nav>
</div>
</header>
);
}
+372
View File
@@ -0,0 +1,372 @@
import {
useEffect,
useState,
useMemo,
type ComponentPropsWithoutRef,
type ReactElement,
type ReactNode,
} from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize";
import { getHighlighter } from "../utils/highlighter";
function CopyButton({ code }: { code: string }): ReactElement {
return (
<button
onClick={(e) => {
const btn = e.currentTarget;
navigator.clipboard.writeText(code).then(() => {
btn.textContent = "Copied!";
setTimeout(() => {
btn.textContent = "\uf0c5";
}, 1500);
});
}}
className="absolute top-1 right-1 px-2 py-1 text-base leading-none text-ctp-subtext0 hover:text-ctp-green transition-colors cursor-pointer select-none z-10 font-[NerdFontSymbols]"
>
</button>
);
}
function HighlightedBlock({
code,
lang,
children,
}: {
code: string;
lang: string;
children: ReactNode;
}): ReactElement {
const [html, setHtml] = useState<string | null>(null);
useEffect(() => {
getHighlighter().then((hl) => {
setHtml(
hl.codeToHtml(code, {
lang,
themes: {
dark: "catppuccin-macchiato",
light: "catppuccin-latte",
},
defaultColor: false,
}),
);
});
}, [code, lang]);
return (
<div className="relative border-2 border-ctp-text/50 rounded-md overflow-hidden my-6 bg-ctp-base">
<span className="absolute top-0 left-0 px-2 py-1 text-xs font-mono text-ctp-mauve">
{lang}
</span>
<CopyButton code={code} />
{html ? (
<div
className="overflow-x-auto text-sm p-4 pt-7"
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<pre className="overflow-x-auto text-sm p-4 pt-7 m-0">
{children}
</pre>
)}
</div>
);
}
const toRawUrl = (repoUrl: string): string => {
if (!repoUrl) return repoUrl;
if (
repoUrl.includes("github.com") &&
!repoUrl.includes("raw.githubusercontent.com")
) {
return repoUrl
.replace("github.com", "raw.githubusercontent.com")
.replace("/blob/", "/");
}
if (repoUrl.includes("/src/branch/")) {
return repoUrl.replace("/src/branch/", "/raw/branch/");
}
return repoUrl;
};
const getRepoRawBase = (repoUrl: string): string | null => {
const rawUrl = toRawUrl(repoUrl);
if (!rawUrl) return null;
try {
const url = new URL(rawUrl);
const parts = url.pathname.split("/").filter(Boolean);
if (url.hostname.includes("raw.githubusercontent.com")) {
return `${url.origin}/${parts.slice(0, 3).join("/")}`;
}
const rawIndex = parts.indexOf("raw");
if (rawIndex !== -1) {
return `${url.origin}/${parts.slice(0, rawIndex + 3).join("/")}`;
}
return null;
} catch {
return null;
}
};
const resolveImageSrc = (src: string, base: string | null): string => {
if (!src) return src;
if (src.startsWith("http")) return src;
if (!base) return src;
const clean = src.replace(/^.\//, "");
if (src.startsWith("/")) return base + src;
return `${base}/${clean}`;
};
interface MdProcessorProps {
content?: string;
repoUrl?: string;
}
export default function MdProcessor({
content: directContent,
repoUrl,
}: MdProcessorProps): ReactElement {
const useRemote = !directContent && !!repoUrl;
const [fetchState, setFetchState] = useState<{
url: string;
content: string;
}>({ url: "", content: "" });
useEffect(() => {
if (!useRemote || !repoUrl) return;
const fetchREADME = async (url: string): Promise<string> => {
try {
const rawUrl = toRawUrl(url);
const response = await fetch(rawUrl);
if (!response.ok) {
throw new Error("Failed to fetch README");
}
return await response.text();
} catch (error) {
console.error(error);
return "Error fetching README. Make sure the URL points to a raw markdown file.";
}
};
fetchREADME(repoUrl).then((content) => {
setFetchState({ url: repoUrl, content });
});
}, [useRemote, repoUrl]);
const base = useMemo(() => {
if (directContent) return null;
return repoUrl ? getRepoRawBase(repoUrl) : null;
}, [directContent, repoUrl]);
const loading = useRemote && fetchState.url !== repoUrl;
const displayContent = directContent ?? fetchState.content;
const markdownComponents = {
code({
className,
children,
...props
}: ComponentPropsWithoutRef<"code">): ReactElement {
const isBlock =
className ||
(typeof children === "string" && children.includes("\n"));
if (isBlock) {
return (
<code className="text-sm" {...props}>
{children}
</code>
);
}
return (
<code
className="bg-ctp-mantle px-1.5 py-0.5 rounded text-sm font-mono"
{...props}
>
{children}
</code>
);
},
pre({ children }: ComponentPropsWithoutRef<"pre">): ReactElement {
const codeChild = Array.isArray(children) ? children[0] : children;
let lang = "";
let code = "";
if (
codeChild &&
typeof codeChild === "object" &&
"props" in codeChild
) {
const el = codeChild as {
props: { className?: string; children?: string };
};
const cls = el.props?.className;
const match = cls?.match(/language-(\w+)/);
if (match) lang = match[1];
if (typeof el.props?.children === "string") {
code = el.props.children;
}
}
if (lang && code) {
return (
<HighlightedBlock code={code} lang={lang}>
{children}
</HighlightedBlock>
);
}
return (
<div className="relative border-2 border-ctp-text/50 rounded-md overflow-hidden my-6 bg-ctp-base">
<CopyButton code={code} />
<pre className="overflow-x-auto text-sm p-4 m-0">
{children}
</pre>
</div>
);
},
h1({ children }: ComponentPropsWithoutRef<"h1">): ReactElement {
return (
<h1 className="text-3xl font-bold text-ctp-mauve mt-8 mb-4 border-b pb-2 border-ctp-green">
{children}
</h1>
);
},
h2({ children }: ComponentPropsWithoutRef<"h2">): ReactElement {
return (
<h2 className="text-2xl font-bold text-ctp-lavender mt-8 mb-4 border-b pb-2 border-ctp-green">
{children}
</h2>
);
},
h3({ children }: ComponentPropsWithoutRef<"h3">): ReactElement {
return (
<h3 className="text-xl font-bold text-ctp-lavender mt-5 mb-3">
{children}
</h3>
);
},
p({ children }: ComponentPropsWithoutRef<"p">): ReactElement {
return <p className="mb-4 text-ctp-text">{children}</p>;
},
ul({ children }: ComponentPropsWithoutRef<"ul">): ReactElement {
return <ul className="list-disc pl-6 mb-4">{children}</ul>;
},
ol({ children }: ComponentPropsWithoutRef<"ol">): ReactElement {
return <ol className="list-decimal pl-6 mb-4">{children}</ol>;
},
li({ children }: ComponentPropsWithoutRef<"li">): ReactElement {
return <li className="mb-1">{children}</li>;
},
a({ children, href }: ComponentPropsWithoutRef<"a">): ReactElement {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="hover:underline text-ctp-green"
>
{children}
</a>
);
},
blockquote({
children,
}: ComponentPropsWithoutRef<"blockquote">): ReactElement {
return (
<blockquote className="border-l-4 border-ctp-text pl-4 py-1 mb-4 bg-ctp-mantle rounded-r-lg">
{children}
</blockquote>
);
},
table({ children }: ComponentPropsWithoutRef<"table">): ReactElement {
return (
<div className="overflow-x-auto my-4">
<table className="w-full border-collapse border border-ctp-overlay0">
{children}
</table>
</div>
);
},
th({ children }: ComponentPropsWithoutRef<"th">): ReactElement {
return (
<th className="border border-ctp-overlay0 bg-ctp-surface0 px-4 py-2 text-left font-bold text-ctp-lavender">
{children}
</th>
);
},
td({ children }: ComponentPropsWithoutRef<"td">): ReactElement {
return (
<td className="border border-ctp-overlay0 px-4 py-2 text-ctp-text">
{children}
</td>
);
},
img({ src, alt }: ComponentPropsWithoutRef<"img">): ReactElement {
const resolved = resolveImageSrc(src || "", base);
return (
<img
src={resolved}
alt={alt}
className="max-w-full h-auto rounded-md my-4 shadow-sm border-2 border-ctp-text"
loading="lazy"
/>
);
},
};
return (
<div className="w-full max-w-4xl mx-auto p-4 md:p-8 bg-ctp-mantle rounded-xl shadow-sm border border-ctp-text">
{loading ? (
<div className="flex flex-col gap-3 animate-pulse w-full">
<div className="h-4 bg-ctp-overlay0 rounded-full w-3/4"></div>
<div className="h-4 bg-ctp-overlay0 rounded-full w-3/4"></div>
<div className="h-4 bg-ctp-overlay0 rounded-full w-3/4"></div>
</div>
) : (
<div className="text-ctp-text leading-relaxed prose prose-base md:prose-lg dark:prose-invert max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
components={markdownComponents}
>
{displayContent}
</ReactMarkdown>
</div>
)}
</div>
);
}
-271
View File
@@ -1,271 +0,0 @@
import { useEffect, useState, useMemo, type ReactElement } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize";
const toRawUrl = (repoUrl: string): string => {
if (!repoUrl) return repoUrl;
if (
repoUrl.includes("github.com") &&
!repoUrl.includes("raw.githubusercontent.com")
) {
return repoUrl
.replace("github.com", "raw.githubusercontent.com")
.replace("/blob/", "/");
}
if (repoUrl.includes("/src/branch/")) {
return repoUrl.replace("/src/branch/", "/raw/branch/");
}
return repoUrl;
};
const getRepoRawBase = (repoUrl: string): string | null => {
const rawUrl = toRawUrl(repoUrl);
if (!rawUrl) return null;
try {
const url = new URL(rawUrl);
const parts = url.pathname.split("/").filter(Boolean);
if (url.hostname.includes("raw.githubusercontent.com")) {
return `${url.origin}/${parts.slice(0, 3).join("/")}`;
}
const rawIndex = parts.indexOf("raw");
if (rawIndex !== -1) {
return `${url.origin}/${parts.slice(0, rawIndex + 3).join("/")}`;
}
return null;
} catch {
return null;
}
};
const resolveImageSrc = (src: string, base: string | null): string => {
if (!src) return src;
if (src.startsWith("http")) return src;
if (!base) return src;
const clean = src.replace(/^.\//, "");
if (src.startsWith("/")) return base + src;
return `${base}/${clean}`;
};
const fetchREADME = async (repoUrl: string): Promise<string> => {
try {
const rawUrl = toRawUrl(repoUrl);
const response = await fetch(rawUrl);
if (!response.ok) {
throw new Error("Failed to fetch README");
}
return await response.text();
} catch (error) {
console.error(error);
return "Error fetching README. Make sure the URL points to a raw markdown file.";
}
};
interface ProjectsReadmeProps {
repoUrl: string;
}
export default function ProjectsReadme({
repoUrl,
}: ProjectsReadmeProps): ReactElement {
const [readmeContent, setReadmeContent] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(true);
const base = useMemo(() => getRepoRawBase(repoUrl), [repoUrl]);
useEffect(() => {
fetchREADME(repoUrl).then((content) => {
setReadmeContent(content);
setIsLoading(false);
});
}, [repoUrl]);
return (
<div className="w-full max-w-4xl mx-auto p-4 md:p-8 bg-ctp-mantle rounded-xl shadow-sm border border-ctp-text">
{isLoading ? (
<div className="flex flex-col gap-3 animate-pulse w-full">
<div className="h-4 bg-ctp-overlay0 rounded-full w-3/4"></div>
<div className="h-4 bg-ctp-overlay0 rounded-full w-3/4"></div>
<div className="h-4 bg-ctp-overlay0 rounded-full w-3/4"></div>
</div>
) : (
<div className="text-ctp-text leading-relaxed prose prose-base md:prose-lg dark:prose-invert max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
components={{
code({
className,
children,
...props
}: React.ComponentPropsWithoutRef<"code">): ReactElement {
const isBlock =
className ||
(typeof children === "string" &&
children.includes("\n"));
if (isBlock) {
return (
<code
className={`${className || ""} text-sm block overflow-x-auto`}
{...props}
>
{children}
</code>
);
}
return (
<code
className="bg-ctp-mantle px-1.5 py-0.5 rounded text-sm font-mono"
{...props}
>
{children}
</code>
);
},
pre({
children,
...props
}: React.ComponentPropsWithoutRef<"pre">): ReactElement {
return (
<pre
className="bg-ctp-mantle p-4 rounded-lg overflow-x-auto text-sm my-4"
{...props}
>
{children}
</pre>
);
},
h1({
children,
}: React.ComponentPropsWithoutRef<"h1">): ReactElement {
return (
<h1 className="text-3xl font-bold text-ctp-mauve mt-8 mb-4 border-b pb-2 border-ctp-green">
{children}
</h1>
);
},
h2({
children,
}: React.ComponentPropsWithoutRef<"h2">): ReactElement {
return (
<h2 className="text-2xl font-bold text-ctp-lavender mt-8 mb-4 border-b pb-2 border-ctp-green">
{children}
</h2>
);
},
h3({
children,
}: React.ComponentPropsWithoutRef<"h3">): ReactElement {
return (
<h3 className="text-xl font-bold text-ctp-lavender mt-5 mb-3">
{children}
</h3>
);
},
p({
children,
}: React.ComponentPropsWithoutRef<"p">): ReactElement {
return (
<p className="mb-4 text-ctp-text">
{children}
</p>
);
},
ul({
children,
}: React.ComponentPropsWithoutRef<"ul">): ReactElement {
return (
<ul className="list-disc pl-6 mb-4">
{children}
</ul>
);
},
ol({
children,
}: React.ComponentPropsWithoutRef<"ol">): ReactElement {
return (
<ol className="list-decimal pl-6 mb-4">
{children}
</ol>
);
},
li({
children,
}: React.ComponentPropsWithoutRef<"li">): ReactElement {
return <li className="mb-1">{children}</li>;
},
a({
children,
href,
}: React.ComponentPropsWithoutRef<"a">): ReactElement {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="hover:underline text-ctp-green"
>
{children}
</a>
);
},
blockquote({
children,
}: React.ComponentPropsWithoutRef<"blockquote">): ReactElement {
return (
<blockquote className="border-l-4 border-ctp-text pl-4 py-1 mb-4 bg-ctp-mantle rounded-r-lg">
{children}
</blockquote>
);
},
img({
src,
alt,
}: React.ComponentPropsWithoutRef<"img">): ReactElement {
const resolved = resolveImageSrc(
src || "",
base,
);
return (
<img
src={resolved}
alt={alt}
className="max-w-full h-auto rounded-md my-4 shadow-sm border-2 border-ctp-text"
loading="lazy"
/>
);
},
}}
>
{readmeContent}
</ReactMarkdown>
</div>
)}
</div>
);
}
-10
View File
@@ -1,10 +0,0 @@
export default function Systems() {
return (
<>
<h1 className="text-center m-6 text-3xl font-bold text-ctp-mauve">
Systems
</h1>
<div></div>
</>
);
}
@@ -0,0 +1,20 @@
import type { ReactElement, ReactNode } from "react";
interface PortfolioSectionProps {
title: string;
children: ReactNode;
}
export default function PortfolioSection({
title,
children,
}: PortfolioSectionProps): ReactElement {
return (
<section className="mb-10">
<h2 className="text-2xl font-bold text-ctp-lavender border-b border-ctp-green pb-2 mb-4">
{title}
</h2>
{children}
</section>
);
}
@@ -0,0 +1,13 @@
import type { ReactElement } from "react";
interface SkillsProps {
label: string;
}
export default function Skills({ label }: SkillsProps): ReactElement {
return (
<span className="border border-ctp-text rounded-full px-3 py-1 text-sm text-ctp-text transition-colors duration-300 hover:border-ctp-green hover:text-ctp-green">
{label}
</span>
);
}
@@ -0,0 +1,59 @@
import type { ReactElement } from "react";
interface TimelineEntryProps {
title: string;
subtitle: string;
subtitleUrl?: string;
date: string;
current?: boolean;
highlights: string[];
}
export default function TimelineEntry({
title,
subtitle,
subtitleUrl,
date,
current,
highlights,
}: TimelineEntryProps): ReactElement {
return (
<div className="relative pl-6 pb-8 border-l-2 border-ctp-overlay0">
<span className="absolute -left-1.25 top-1 size-2 rounded-full bg-ctp-green" />
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<h3 className="text-lg font-semibold text-ctp-text">
{title}
{current && (
<span className="ml-2 text-xs font-normal text-ctp-green border border-ctp-green rounded-full px-2 py-0.5 align-middle">
Current
</span>
)}
</h3>
<span className="text-sm text-ctp-subtext0">{date}</span>
</div>
<p className="text-ctp-mauve">
{subtitleUrl ? (
<a
href={subtitleUrl}
target="_blank"
rel="noreferrer"
className="hover:underline"
>
{subtitle}
</a>
) : (
subtitle
)}
</p>
{highlights.length > 0 && (
<ul className="list-disc pl-6 mt-2 space-y-1 text-ctp-text">
{highlights.map((h) => (
<li key={h} className="text-sm">
{h}
</li>
))}
</ul>
)}
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
## Background
Currently a software engineering student. Have primarily done full-stack web development, but my interest has grown toward backend services using low-level programming languages. I am still learning as I go.
> I request that none of my projects gets hosted on platforms that are not open-source. This includes GitHub and GitLab, which has a proprietary software "Enterprise" edition. Otherwise, my projects are generally licensed as BSD 3-Clause License.
## Motivation
I value security, privacy, human health, and combating climate change. Technology can achieve all of this today to some extent. Unfortunately, we are in times of misinformation, Big Tech taking over, and science denial. In a world becoming seemingly more complex, people are overwhelmed and feel like they have no energy left to assess decisions. People do not hesitate to purchase the newest iPhone, without thinking about whether they truly need the newest device. Or they will install whatever application without thinking about possible consequences: what information is shared and sold about them, or they walk right into getting hacked and suffer financial losses and stress. Large corporations take advantage of people who do not understand such concepts; it has become all about consuming the next new thing to the detriment of people.
I wish to educate myself in technology primarily to be able to combat anti-intellectualism and offer free software to improve people's lives respecting human rights.
## Interests
My interests are primarily projects aiming to solve emerging problems, or ones that significantly improve development experience. I believe that software should be free and open-source (FOSS).
### Projects
A few notable projects I am interested in:
- GrapheneOS
- Zig
- Hyprland
- Linux
- Osu!
- Jujutsu
## Contact
You can contact me via **XMPP: aramjonghu@aramjonghu.dev** and on **Email: aramjonghu@tutamail.com**. More info at [contact](/contact) page.
## Notable Questions
**Note that I am fully open to being wrong on some of the claims below.**
> **Q:** Why "AramJonghu"?
>
> **A:** I played on a Minecraft server with friends and friends of friends back in the 1.5 days. One of them would often say or type "Aram Jonguh" — Jonguh being akin to "bro" in Dutch, so it says "Aram Bro". Liking the name, my IGN became AramJonghu. It is spelled incorrectly, which I did not correct at the time. It has persisted ever since.
> **Q:** What is my setup?
>
> **A:** I currently use Artix with OpenRC on Hyprland. I use neovim in the Ghostty terminal (with tmux). My server runs Alpine with OpenRC.
> **Q:** Are you specifically avoiding SystemD?
>
> **A:** Yes, but it is not due to the infamous pull request. The concern is not about systemd applying age verification (they have not). Rather, I want to explore other options in case SystemD becomes compromised, so to speak. My main reason for switching is to have an init system be an init system first and foremost. A smaller codebase makes it easier to manage.
>
> The people behind SystemD seem passionate and oppose dystopian laws. I hope that when laws cross the line, real opposition will occur.
> **Q:** Are you on LinkedIn?
>
> **A:** No. I quit in 2025, understanding that LinkedIn has been a platform to curate a fake image in a race for relevance. It is also owned by Microsoft, which is another deterrent. In my eyes, it is slop. Furthermore, most of these social platforms are made to spy and track people to make money rather than connect people.
> **Q:** Why do you have no personal projects on GitHub?
>
> **A:** I dropped GitHub because of concerns that my projects would be trained on AI. On top of that, the platform's uptime is becoming increasingly spotty. There have been a few incidents such as projects losing critical progress due to data corruption in pull requests. Worst of all, banning developers who offer critical contributions from GitHub (and sometimes also on GitLab).
>
> Although my projects are not anything special today, in preparation, I host my own instance of Forgejo. I also wish that none of my projects be used or shared on proprietary platforms. They do not respect developers, so we should not respect their service either. For work, I will have to use GitHub, which is the only use case GitHub will have for me. You can find me if you are interested.
> **Q:** Do you support AI?
>
> **A:** Generally, no. I believe AI (LLMs) has done more harm than good. From data collection, climate harm, theft of data and art, and the damage it has done to people who misuse it to skip proper learning, and the billion-dollar circlejerk investments that are actively killing personal computing. The technology itself is interesting and I see real value for specific use cases such as in the health sector to better diagnose patients, improve translation accuracy, or improve development experience. This should not, however, outweigh the many downsides.
>
> I try to use AI as sparingly as possible, but I also catch myself being reliant more often than not on some days. In the end, AI should be used as a tool to assist us.
> **Q:** Are billionaires evil?
>
> **A:** Yes.
+50
View File
@@ -0,0 +1,50 @@
Anyone is free to contact me for any reason. Below are ways to contact me.
## XMPP
You can reach me at **aramjonghu@aramjonghu.dev**.
My preferred way of contacting is via a [XMPP](https://xmpp.org/) client. It is a privacy focused standard and it is decentralized. No one owns XMPP. Look [here](https://xmpp.org/software/?category=clients) to determine which client options there are for your operating system (or browser). But generally:
| Platform | Client | Offers server for login |
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| Linux | Dino | No |
| Windows | Converse.js (browser based), but I would recommend Movim in a browser. It seems that there is no solid option that supports calls on Windows. | Yes for both |
| Android/AOSP | Conversations (free in fdroid store) or Cheogram | Conversations has, Cheogram is a fork of Conversations |
| iOS | Monal IM or Siskin IM | Siskin does via tigase.im |
| MacOS | Monal IM or Converse.js for browser based | Conversejs does |
| Browser | Movim or Converse.js | Yes for both |
Note that you can use any client even if they do not offer their own server for account creation. You can create an account on any provider such as conversejs.org. Then log in to your preferred client.
## Email
Not preferred, but you can contact me via email at **aramjonghu@tutamail.com**.
## Other platforms
For various reasons, I am not on a handful of the larger platforms. Check on the [home](/) page in the **About** section under the **Notable Questions** header if you wish to know why.
> Below I drop a sort of rant on secure messaging, you can skip reading. I will move this elsewhere later.
### Not recommended
I do not use various chat apps, or I do not recommend them for private messaging. Some examples:
- SMS
- WhatsApp (anything from Meta)
- Telegram
- Line (both JP and World versions)
- Viber
These options lack privacy and sometimes basic security features. Metadata is often enough not end-to-end encrypted, let alone the messages themselves. Metadata is generally enough information to infer information about you that should have stayed between you and the recipient. Data from these apps are used to track you, share your information for various reasons such as advertising or AI training among other reasons detrimental to the user.
### Platforms I'd recommend
There are a few platforms I generally would recommend, though if I am on them, my contact details are not shared publicly.
- Signal
- SimpleX
- Briar
Each is solid in their own right. Signal is probably the easiest for users to switch from if they are used to apps like Line or WhatsApp. One downside is that Signal still requires a phone number, which can be personally identifiable.
+7
View File
@@ -0,0 +1,7 @@
### Language learning
Currently learning Rust and Zig programming languages.
### Projects
MarAn is a webpage I am working on for a freelancer.
+100
View File
@@ -0,0 +1,100 @@
{
"profile": {
"name": "AramJonghu",
"headline": "Software Engineer",
"region": "",
"location": "The Netherlands",
"summary": "I am third year software engineering student. I have primarily done full-stack web development, but my interest has grown towards backend services. Languages like Zig interest me with their vision on how code can be written.\n\nRead the about section on the home page. I write about my motivation, interest, how to contact me and I answer some notable questions.\n",
"links": [
{
"label": "Forgejo",
"svg": "forgejo",
"url": "https://git.aramjonghu.dev/AramJonghu"
},
{
"label": "Gitea",
"svg": "gitea",
"url": "https://git.zach-dev.cc/AramJonghu"
}
]
},
"experience": [
{
"role": "Backend Developer",
"company": "HANflex",
"companyUrl": "https://www.han.nl/over-de-han/werken-bij-de-han/andere-inzetvormen-bij-de-han/",
"start": "2026-03",
"end": "present",
"current": true,
"highlights": [
"Learnt basic python and typedb",
"Learning Rust as potential replacement of some python codebases"
]
}
],
"education": [
{
"degree": "IT - Software Engineer (Bachelor)",
"school": "HAN University of Applied Sciences",
"schoolUrl": "https://han.nl",
"start": "2023",
"end": "expected 2028",
"current": true,
"highlights": [
"Java OOP",
"PHP Security",
"JavaScript/React Frontend Webdev",
"Java Backend Webdev"
]
}
],
"skills": [
{
"category": "Languages",
"items": ["JavaScript", "TypeScript", "Java"]
},
{
"category": "Tools",
"items": [
"Linux",
"Git",
"React",
"Neovim",
"Docker",
"CI/CD",
"Nginx"
]
},
{ "category": "Learning", "items": ["Zig", "Rust", "Jujutsu"] }
],
"languages": [
{ "name": "English", "proficiency": "Fluent" },
{ "name": "Dutch", "proficiency": "Native" },
{ "name": "Armenian", "proficiency": "Basic conversational" }
],
"projects": [
{ "name": "HANflex kennisplatform",
"url": "https://github.com/AIM-kennisplatformen",
"description": "Repositories I contributed to. Mainly studio and database-builder-scepa.",
"tags": ["Python", "TypeDB"]
},
{
"name": "aramjonghu-site",
"url": "https://git.aramjonghu.dev/AramJonghu/aramjonghu-site",
"description": "Personal website and central hub for my projects, systems, and self-hosted services.",
"tags": ["React", "TypeScript"]
},
{
"name": "MarAn-site",
"url": "https://git.aramjonghu.dev/AramJonghu/maran-site",
"description": "Portfolio site with Zig backend serving static frontend + contact API.",
"tags": ["Zig using zap", "JavaScript", "plain HTML"]
},
{
"name": "z-bar-qt (also known as ZShell)",
"url": "https://git.zach-dev.cc/zach/z-bar-qt",
"description": "ZShell is a Hyprland-focused desktop shell built with Quickshell + Qt6/QML.\nI mostly contributed by adding CI/CD and Python",
"tags": ["QML", "Python", "C++", "Rust"]
}
]
}
+17
View File
@@ -0,0 +1,17 @@
import { type ReactElement } from "react";
import MdProcessor from "../components/MdProcessor";
import contact from "../content/contact.md?raw";
export default function CurrentActivity(): ReactElement {
return (
<div className="text-ctp-text flex flex-col min-h-screen px-4 py-6">
<div className="w-full flex flex-col flex-1">
<h1 className="text-4xl text-center font-bold text-ctp-mauve mb-6">
Contact
</h1>
<MdProcessor content={contact} />
</div>
</div>
);
}
+11 -79
View File
@@ -1,14 +1,7 @@
import { useState, type ReactElement } from "react";
import ProjectsReadme from "../components/ProjectsReadme";
import Systems from "../components/Systems";
import CurrentActivity from "../components/CurrentActivity";
import About from "../components/About";
interface ProjectInfo {
id: string;
name: string;
url: string;
}
interface Section {
id: string;
title: string;
@@ -16,71 +9,9 @@ interface Section {
}
export default function Home(): ReactElement {
const projectList: ProjectInfo[] = [
{
id: "site",
name: "aramjonghu-site",
url: "https://git.aramjonghu.nl/AramJonghu/aramjonghu-site/raw/branch/master/README.md",
},
{
id: "file-organizer",
name: "file-organizer written in zig",
url: "https://git.aramjonghu.nl/AramJonghu/file-organizer/src/branch/main/README.md",
},
{
id: "nvim",
name: "Nvim Config",
url: "https://git.aramjonghu.nl/AramJonghu/nvim/src/branch/main/README.md",
},
];
const [projIdx, setProjIdx] = useState<number>(0);
const prevProject = () =>
setProjIdx((i) => (i - 1 + projectList.length) % projectList.length);
const nextProject = () => setProjIdx((i) => (i + 1) % projectList.length);
const [activeTab, setActiveTab] = useState<string>("projects");
const [activeTab, setActiveTab] = useState<string>("about");
const sections: Section[] = [
{
id: "projects",
title: "Projects",
content: (
<>
<h1 className="text-center m-6 text-3xl font-bold text-ctp-mauve">
Projects
</h1>
<div className="flex justify-center mb-6">
<button
onClick={prevProject}
className="px-3 py-1 rounded ui-btn text-ctp-text"
>
Prev
</button>
<span className="self-center mx-6 font-medium">
{projectList[projIdx].name} ({projIdx + 1}/
{projectList.length})
</span>
<button
onClick={nextProject}
className="px-3 py-1 rounded ui-btn text-ctp-text"
>
Next
</button>
</div>
<ProjectsReadme repoUrl={projectList[projIdx].url} />
</>
),
},
{
id: "systems",
title: "Systems",
content: (
<>
<Systems />
</>
),
},
{
id: "about",
title: "About",
@@ -90,6 +21,15 @@ export default function Home(): ReactElement {
</>
),
},
{
id: "current-activity",
title: "Current-activity",
content: (
<>
<CurrentActivity />
</>
),
},
];
return (
@@ -98,14 +38,6 @@ export default function Home(): ReactElement {
<h1 className="text-4xl font-bold text-ctp-mauve">
AramJonghu
</h1>
<p className="mt-4">
Software engineer building systems, services, and
experiments across a self-hosted environment.
</p>
<p className="mt-3">
This site acts as a hub for projects, infrastructure, and
personal tooling.
</p>
<div className="mt-10 border rounded p-4">
<div className="flex flex-wrap justify-center gap-12 mb-6">
{sections.map((section) => (
+247
View File
@@ -0,0 +1,247 @@
import type { ReactElement } from "react";
import PortfolioSection from "../components/portfolio/PortfolioSection";
import TimelineEntry from "../components/portfolio/TimelineEntry";
import Skills from "../components/portfolio/Skills";
import portfolio from "../content/portfolio.json";
import forgejo from "../assets/images/forgejo.svg";
import gitea from "../assets/images/gitea.svg";
import avatar from "../assets/images/portfolio_avatar.png";
const LOGOS: Record<string, string> = { forgejo, gitea };
interface Profile {
name: string;
headline: string;
region: string;
location: string;
links: { label: string; svg: string; url: string }[];
summary: string;
}
interface TimelineItem {
role?: string;
company?: string;
companyUrl?: string;
degree?: string;
school?: string;
schoolUrl?: string;
start: string;
end: string;
current: boolean;
highlights: string[];
}
interface Experience extends TimelineItem {
role: string;
company: string;
companyUrl?: string;
}
interface Education extends TimelineItem {
degree: string;
school: string;
schoolUrl?: string;
}
interface SkillGroup {
category: string;
items: string[];
}
interface Language {
name: string;
proficiency: string;
}
interface Project {
name: string;
url: string;
description: string;
tags: string[];
}
interface PortfolioData {
profile: Profile;
experience: Experience[];
education: Education[];
skills: SkillGroup[];
languages: Language[];
projects: Project[];
}
const data = portfolio as PortfolioData;
const MONTHS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const formatDate = (value: string): string => {
if (value.toLowerCase() === "present") return "Present";
const [year, month] = value.split("-");
return month ? `${MONTHS[Number(month) - 1]} ${year}` : year;
};
const formatRange = (start: string, end: string): string =>
`${formatDate(start)} ${formatDate(end)}`;
export default function Portfolio(): ReactElement {
const { profile, experience, education, skills, languages, projects } =
data;
return (
<div className="text-ctp-text flex flex-col min-h-screen px-4 py-6">
<div className="max-w-340 min-w-96 mx-auto flex flex-col flex-1">
<h1 className="text-4xl text-center font-bold text-ctp-mauve mb-6">
Portfolio
</h1>
<div className="grid md:grid-cols-[10fr_6fr] gap-8 items-start">
<div>
<PortfolioSection title="Projects & Contributions">
{projects.map((proj) => (
<a
key={proj.name}
href={proj.url}
target="_blank"
rel="noreferrer"
className="block bg-ctp-mantle border border-ctp-lavender-800 rounded-xl p-4 mb-4 hover:border-ctp-green transition-colors"
>
<h3 className="font-semibold text-ctp-mauve hover:underline">
{proj.name}
</h3>
<p className="text-sm text-ctp-text mt-1 whitespace-pre-line">
{proj.description}
</p>
<div className="flex flex-wrap gap-2 mt-3">
{proj.tags.map((tag) => (
<Skills key={tag} label={tag} />
))}
</div>
</a>
))}
</PortfolioSection>
<PortfolioSection title="Skills">
{skills.map((group) => (
<div key={group.category} className="mb-4">
<h3 className="text-ctp-lavender font-semibold mb-2">
{group.category}
</h3>
<div className="flex flex-wrap gap-2">
{group.items.map((skill) => (
<Skills key={skill} label={skill} />
))}
</div>
</div>
))}
</PortfolioSection>
<PortfolioSection title="Education">
{education.map((edu) => (
<TimelineEntry
key={edu.degree}
title={edu.degree}
subtitle={edu.school}
subtitleUrl={edu.schoolUrl}
date={formatRange(edu.start, edu.end)}
current={edu.current}
highlights={edu.highlights}
/>
))}
</PortfolioSection>
<PortfolioSection title="Experience">
{experience.map((exp) => (
<TimelineEntry
key={exp.role}
title={exp.role}
subtitle={exp.company}
subtitleUrl={exp.companyUrl}
date={formatRange(exp.start, exp.end)}
current={exp.current}
highlights={exp.highlights}
/>
))}
</PortfolioSection>
</div>
<div className="space-y-6 order-first md:order-0">
<div className="bg-ctp-mantle border border-ctp-lavender-800 rounded-xl p-6">
<img
src={avatar}
alt={`${profile.name} avatar`}
className="size-28 border border-ctp-lavender-800 rounded-xl object-cover mb-4"
/>
<h2 className="text-3xl font-bold text-ctp-mauve">
{profile.name}
</h2>
<p className="text-ctp-green mt-1">
{profile.headline}
</p>
<p className="text-sm text-ctp-subtext0 mt-3">
{profile.location}
</p>
<p className="text-sm text-ctp-subtext0">
{profile.region}
</p>
<div className="flex flex-wrap gap-2 mt-4">
{profile.links.map((link) => (
<a
key={link.label}
href={link.url}
target="_blank"
rel="noreferrer"
aria-label={link.label}
title={link.label}
className="p-2 cursor-pointer hover:opacity-80 transition-opacity"
>
<img
src={LOGOS[link.svg]}
alt={link.label}
className={`w-7 h-7 ${
link.svg === "github"
? "icon-invert"
: ""
}`}
/>
</a>
))}
</div>
</div>
<PortfolioSection title="Summary">
<p className="text-sm leading-relaxed whitespace-pre-line">
{profile.summary}
</p>
</PortfolioSection>
<PortfolioSection title="Languages">
<ul className="space-y-2">
{languages.map((lang) => (
<li
key={lang.name}
className="flex justify-between text-sm border-b border-ctp-surface0 pb-2"
>
<span>{lang.name}</span>
<span className="text-ctp-subtext0">
{lang.proficiency}
</span>
</li>
))}
</ul>
</PortfolioSection>
</div>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -50,7 +50,7 @@ export default function Stream(): ReactElement {
<div className="stream-frame w-full mx-auto flex-1 max-h-[85vh]">
<iframe
src={`https://stream.aramjonghu.nl/${activeStream}`}
src={`https://stream.aramjonghu.dev/${activeStream}`}
title={activeStream}
allow="autoplay; fullscreen; picture-in-picture"
className="w-full h-full border-0 rounded-lg"
+50
View File
@@ -0,0 +1,50 @@
import { createHighlighter, type Highlighter } from "shiki";
const highlighterPromise = createHighlighter({
themes: ["catppuccin-macchiato", "catppuccin-latte"],
langs: [
"javascript",
"typescript",
"jsx",
"tsx",
"css",
"html",
"json",
"bash",
"shellscript",
"python",
"markdown",
"yaml",
"toml",
"rust",
"sql",
"diff",
"docker",
"go",
"java",
"kotlin",
"swift",
"c",
"cpp",
"odin",
"csharp",
"php",
"ruby",
"lua",
"scss",
"vue",
"svelte",
"nix",
"zig",
"makefile",
"cmake",
"powershell",
"nginx",
"latex",
"tex",
],
});
export function getHighlighter(): Promise<Highlighter> {
return highlighterPromise;
}