mirror of
https://git.aramjonghu.dev/AramJonghu/aramjonghu-site.git
synced 2026-09-11 17:13:29 +02:00
Compare commits
38
Commits
51075ac117
...
abd43cefdd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1,7 +1,7 @@
|
||||
name: Format, lint, typecheck & build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: ["master"]
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -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).
|
||||
|
||||
+10
-7
@@ -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,30 +19,32 @@
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^4.3.1",
|
||||
"tailwindcss": "^4.2.2"
|
||||
},
|
||||
"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": "npm:@typescript/typescript6@^6.0.2",
|
||||
"@typescript/native": "npm:typescript@^7.0.2",
|
||||
"typescript-eslint": "^8.61.1",
|
||||
"typescript-language-server": "^5.3.0",
|
||||
"vite": "^8.0.4",
|
||||
"vscode-langservers-extracted": "^4.10.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"core-js@3.49.0": true
|
||||
}
|
||||
},
|
||||
"_comment": "TS7 aliased side-by-side: typescript->TS6 API for typescript-eslint, @typescript/native->TS7 tsc. Revisit when TS 7.1 API lands (typescript-eslint#10940)."
|
||||
}
|
||||
|
||||
+63
-1
@@ -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
@@ -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.
@@ -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 |
Binary file not shown.
|
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 |
@@ -5,9 +5,6 @@ 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>
|
||||
<MdProcessor content={aboutMd} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -5,9 +5,6 @@ import currentActivityMd from "../content/current-activity.md?raw";
|
||||
export default function CurrentActivity(): ReactElement {
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-center m-6 text-3xl font-bold text-ctp-mauve">
|
||||
Current activity
|
||||
</h1>
|
||||
<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.dev/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.dev" },
|
||||
{ label: "Navidrome", url: "https://music.aramjonghu.dev" },
|
||||
{ label: "Forgejo Git", url: "https://git.aramjonghu.dev" },
|
||||
const pageItems: PageItem[] = [
|
||||
{ label: "Stream", url: "/stream" },
|
||||
{ label: "Omnisearch", url: "https://xng.aramjonghu.dev" },
|
||||
{ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,78 @@ import {
|
||||
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;
|
||||
@@ -110,6 +177,177 @@ export default function MdProcessor({
|
||||
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 ? (
|
||||
@@ -123,195 +361,7 @@ export default function MdProcessor({
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
||||
components={{
|
||||
code({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: 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
|
||||
}: 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,
|
||||
}: 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"
|
||||
/>
|
||||
);
|
||||
},
|
||||
}}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{displayContent}
|
||||
</ReactMarkdown>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useState, type ReactElement } from "react";
|
||||
import MdProcessor from "./MdProcessor";
|
||||
import projects from "../content/projects.json";
|
||||
|
||||
export default function ProjectInfo(): ReactElement {
|
||||
const [projIdx, setProjIdx] = useState<number>(0);
|
||||
const prevProject = () =>
|
||||
setProjIdx((i) => (i - 1 + projects.length) % projects.length);
|
||||
const nextProject = () => setProjIdx((i) => (i + 1) % projects.length);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between items-center gap-2 mx-4 md:mx-30 mb-6">
|
||||
<button
|
||||
onClick={prevProject}
|
||||
className="shrink-0 px-3 py-1 rounded ui-btn text-ctp-text"
|
||||
>
|
||||
◀︎ Prev
|
||||
</button>
|
||||
<span className="self-center min-w-0 truncate mx-2 md:mx-6 text-ctp-mauve lg:text-xl text-l font-bold">
|
||||
{projects[projIdx].name} ({projIdx + 1}/{projects.length})
|
||||
</span>
|
||||
<button
|
||||
onClick={nextProject}
|
||||
className="shrink-0 px-3 py-1 rounded ui-btn text-ctp-text"
|
||||
>
|
||||
Next ▶︎
|
||||
</button>
|
||||
</div>
|
||||
<MdProcessor repoUrl={projects[projIdx].url} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
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.
|
||||
@@ -10,7 +12,7 @@ I wish to educate myself in technology primarily to be able to combat anti-intel
|
||||
|
||||
## 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.
|
||||
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
|
||||
|
||||
@@ -25,7 +27,7 @@ A few notable projects I am interested in:
|
||||
|
||||
## Contact
|
||||
|
||||
You can contact me via **Email: aramjonghu@tutamail.com** and on **XMPP: aramjonghu@aramjonghu.dev**.
|
||||
You can contact me via **XMPP: aramjonghu@aramjonghu.dev** and on **Email: aramjonghu@tutamail.com**. More info at [contact](/contact) page.
|
||||
|
||||
## Notable Questions
|
||||
|
||||
@@ -45,23 +47,15 @@ You can contact me via **Email: aramjonghu@tutamail.com** and on **XMPP: aramjon
|
||||
>
|
||||
> The people behind SystemD seem passionate and oppose dystopian laws. I hope that when laws cross the line, real opposition will occur.
|
||||
|
||||
> **Q:** What languages do you actually use?
|
||||
>
|
||||
> **A:** Mainly use JavaScript/TypeScript (React) and Java as this is what I learned at school. Not a fan of either language.
|
||||
|
||||
> **Q:** What languages are you learning?
|
||||
>
|
||||
> **A:** Currently in the middle of learning Rust and Zig. I especially like the direction of Zig, but due to higher demand for Rust, I will be focusing on Rust first. I have not written any Rust yet. I have written a small Zig program, however.
|
||||
|
||||
> **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.
|
||||
> **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.
|
||||
> 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?
|
||||
>
|
||||
|
||||
@@ -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 **aramjongh@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.
|
||||
@@ -4,4 +4,4 @@ Currently learning Rust and Zig programming languages.
|
||||
|
||||
### Projects
|
||||
|
||||
Currently no projects planned.
|
||||
MarAn is a webpage I am working on for a freelancer. Check the [projects](/) tab on the home page.
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"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": "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,22 @@
|
||||
[
|
||||
{
|
||||
"id": "site",
|
||||
"name": "aramjonghu-site",
|
||||
"url": "https://git.aramjonghu.dev/AramJonghu/aramjonghu-site/raw/branch/master/README.md"
|
||||
},
|
||||
{
|
||||
"id": "maran freelance portfolio",
|
||||
"name": "maran-site",
|
||||
"url": "https://git.aramjonghu.dev/AramJonghu/maran-site/src/branch/main/README.md"
|
||||
},
|
||||
{
|
||||
"id": "file-organizer",
|
||||
"name": "file-organizer written in zig",
|
||||
"url": "https://git.aramjonghu.dev/AramJonghu/file-organizer/src/branch/main/README.md"
|
||||
},
|
||||
{
|
||||
"id": "nvim",
|
||||
"name": "Nvim Config",
|
||||
"url": "https://git.aramjonghu.dev/AramJonghu/nvim/src/branch/main/README.md"
|
||||
}
|
||||
]
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+10
-64
@@ -1,14 +1,8 @@
|
||||
import { useState, type ReactElement } from "react";
|
||||
import MdProcessor from "../components/MdProcessor";
|
||||
import ProjectInfo from "../components/ProjectInfo";
|
||||
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,62 +10,23 @@ interface Section {
|
||||
}
|
||||
|
||||
export default function Home(): ReactElement {
|
||||
const projectList: ProjectInfo[] = [
|
||||
{
|
||||
id: "site",
|
||||
name: "aramjonghu-site",
|
||||
url: "https://git.aramjonghu.dev/AramJonghu/aramjonghu-site/raw/branch/master/README.md",
|
||||
},
|
||||
{
|
||||
id: "file-organizer",
|
||||
name: "file-organizer written in zig",
|
||||
url: "https://git.aramjonghu.dev/AramJonghu/file-organizer/src/branch/main/README.md",
|
||||
},
|
||||
{
|
||||
id: "nvim",
|
||||
name: "Nvim Config",
|
||||
url: "https://git.aramjonghu.dev/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",
|
||||
id: "about",
|
||||
title: "About",
|
||||
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>
|
||||
<MdProcessor repoUrl={projectList[projIdx].url} />
|
||||
<About />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "projects",
|
||||
title: "Projects",
|
||||
content: <ProjectInfo />,
|
||||
},
|
||||
{
|
||||
id: "current-activity",
|
||||
title: "Current-activity",
|
||||
@@ -81,15 +36,6 @@ export default function Home(): ReactElement {
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "about",
|
||||
title: "About",
|
||||
content: (
|
||||
<>
|
||||
<About />
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user