Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
208299b3d8
|
||
|
|
0d78d32144
|
||
|
|
ad7e151618
|
||
|
|
28b2fff3d6
|
||
|
|
6d44d8d1e9
|
||
|
|
abd43cefdd
|
||
|
|
d2cc733386
|
||
|
|
0611e56399 | ||
|
|
7777afae64 | ||
|
|
4388242241 | ||
|
|
fec4ed568c | ||
|
|
030ca4009c | ||
|
|
4a190627cb | ||
|
|
7668100fb6 | ||
|
|
8e4f01b213 | ||
|
|
c08ffdcb5e | ||
|
|
a38f1b64b6 | ||
|
|
30476dbc27 | ||
|
|
9993c9dcfa | ||
|
|
b764d1c86b | ||
|
|
f8953a5d7b | ||
|
|
dc94a50354 | ||
|
|
3a42489556 | ||
|
|
4e91ecafec | ||
|
|
34e1387aa8 | ||
|
|
5d07d33eaa | ||
|
|
9edde535bb | ||
|
|
6b94a75e30 | ||
|
|
046c2f395a | ||
|
|
605685abb3 | ||
|
|
b629e042b2 | ||
|
|
f91587a767 | ||
|
|
d2b2eb2343 | ||
|
|
e613d96ddf | ||
|
|
21c61968fe | ||
|
|
f583696295 | ||
|
|
bfc7dd9018 | ||
|
|
9041c683f8 | ||
|
|
1311c79479 | ||
|
|
b1cc3a837a | ||
|
|
d489adee3c | ||
|
|
e16833ecc3 | ||
|
|
d66133082c | ||
|
|
51075ac117 | ||
|
|
80896f2287 | ||
|
|
d4adc0e31b | ||
|
|
702c9cda69 | ||
|
|
eb9e457287 | ||
|
|
417ab28fcc | ||
|
|
c4c50276d3 | ||
|
|
7482999fb4 | ||
|
|
890741335e | ||
|
|
e3b9014c02 | ||
|
|
d84c8b62e0 | ||
|
|
dcb428114c | ||
|
|
089a0f3e32 | ||
|
|
49520a8ce3 | ||
|
|
2074a2806a | ||
|
|
bd23d31ed3 |
@@ -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
|
||||
@@ -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 .
|
||||
@@ -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.
|
||||
@@ -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
|
||||
- Friend’s 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).
|
||||
|
||||
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 71 KiB |
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 367 KiB |
@@ -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 |
@@ -1,12 +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>
|
||||
</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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
© {currentYear} AramJonghu. All rights reserved.
|
||||
© {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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||