diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
index 1ced299..88b7e1a 100644
--- a/frontend/eslint.config.js
+++ b/frontend/eslint.config.js
@@ -2,22 +2,22 @@ import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
+import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from "eslint/config";
export default defineConfig([
globalIgnores(["dist"]),
+
{
- files: ["**/*.{js,jsx}"],
- extends: [
- js.configs.recommended,
- reactHooks.configs.flat.recommended,
- reactRefresh.configs.vite,
- ],
+ files: ["**/*.{js,jsx,mjs}"],
+ extends: [js.configs.recommended, reactHooks.configs.flat.recommended],
languageOptions: {
ecmaVersion: 2020,
- globals: globals.browser,
+ globals: {
+ ...globals.browser,
+ ...globals.node,
+ },
parserOptions: {
- ecmaVersion: "latest",
ecmaFeatures: { jsx: true },
sourceType: "module",
},
@@ -26,4 +26,28 @@ export default defineConfig([
"no-unused-vars": ["error", { varsIgnorePattern: "^[A-Z_]" }],
},
},
+
+ {
+ files: ["**/*.{ts,tsx}"],
+ extends: [
+ js.configs.recommended,
+ ...tseslint.configs.recommended,
+ reactHooks.configs.flat.recommended,
+ reactRefresh.configs.vite,
+ ],
+ languageOptions: {
+ globals: globals.browser,
+ parserOptions: {
+ ecmaFeatures: { jsx: true },
+ sourceType: "module",
+ },
+ },
+ rules: {
+ "no-unused-vars": "off",
+ "@typescript-eslint/no-unused-vars": [
+ "error",
+ { varsIgnorePattern: "^[A-Z_]" },
+ ],
+ },
+ },
]);
diff --git a/frontend/package.json b/frontend/package.json
index 36c84e2..60dce16 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -7,6 +7,7 @@
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
+ "typecheck": "node_modules/typescript-7/bin/tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
@@ -34,6 +35,9 @@
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"prettier": "^3.8.3",
+ "typescript": "^6.0.3",
+ "typescript-7": "npm:typescript@^7.0.1-rc",
+ "typescript-eslint": "^8.61.1",
"typescript-language-server": "^5.3.0",
"vite": "^8.0.4",
"vscode-langservers-extracted": "^4.10.0"
diff --git a/frontend/src/App.jsx b/frontend/src/App.tsx
similarity index 73%
rename from frontend/src/App.jsx
rename to frontend/src/App.tsx
index d51c43b..ac94928 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.tsx
@@ -1,13 +1,14 @@
+import type { ReactElement } from "react";
import "./App.css";
import Layout from "./components/Layout";
import Home from "./pages/Home";
import Stream from "./pages/Stream";
-function App() {
+function App(): ReactElement {
const path = window.location.pathname.toLowerCase();
- let content = ;
+ let content: ReactElement = ;
if (path === "/stream") {
content = ;
}
diff --git a/frontend/src/components/Footer.jsx b/frontend/src/components/Footer.tsx
similarity index 100%
rename from frontend/src/components/Footer.jsx
rename to frontend/src/components/Footer.tsx
diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.tsx
similarity index 84%
rename from frontend/src/components/Header.jsx
rename to frontend/src/components/Header.tsx
index a8fcc12..6a5a681 100644
--- a/frontend/src/components/Header.jsx
+++ b/frontend/src/components/Header.tsx
@@ -1,9 +1,23 @@
import Aku from "../assets/images/aku.png";
-export default function Header({ theme }) {
+interface NavItem {
+ label: string;
+ url: string;
+}
+
+interface HeaderTheme {
+ theme: string;
+ toggleTheme: () => void;
+}
+
+interface HeaderProps {
+ theme: HeaderTheme;
+}
+
+export default function Header({ theme }: HeaderProps) {
const { theme: currentTheme, toggleTheme } = theme;
- const navItems = [
+ 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" },
diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.tsx
similarity index 70%
rename from frontend/src/components/Layout.jsx
rename to frontend/src/components/Layout.tsx
index 3a12144..9672772 100644
--- a/frontend/src/components/Layout.jsx
+++ b/frontend/src/components/Layout.tsx
@@ -1,8 +1,13 @@
+import type { ReactNode } from "react";
import Footer from "./Footer";
import Header from "./Header";
import { useTheme } from "../hooks/useTheme";
-export default function Layout({ children }) {
+interface LayoutProps {
+ children: ReactNode;
+}
+
+export default function Layout({ children }: LayoutProps) {
const theme = useTheme();
return (
diff --git a/frontend/src/components/ProjectsReadme.jsx b/frontend/src/components/ProjectsReadme.jsx
deleted file mode 100644
index 8c696a4..0000000
--- a/frontend/src/components/ProjectsReadme.jsx
+++ /dev/null
@@ -1,215 +0,0 @@
-import { useEffect, useState, useMemo } 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) => {
- 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) => {
- 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, base) => {
- 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) => {
- 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.";
- }
-};
-
-export default function ProjectsReadme({ repoUrl }) {
- const [readmeContent, setReadmeContent] = useState("");
- const [isLoading, setIsLoading] = useState(true);
-
- const base = useMemo(() => getRepoRawBase(repoUrl), [repoUrl]);
-
- useEffect(() => {
- fetchREADME(repoUrl).then((content) => {
- setReadmeContent(content);
- setIsLoading(false);
- });
- }, [repoUrl]);
-
- return (
-
- {isLoading ? (
-
- ) : (
-
-
- {children}
-
- );
- }
-
- return (
-
- {children}
-
- );
- },
-
- pre({ children, ...props }) {
- return (
-
- {children}
-
- );
- },
-
- h1: ({ children }) => (
-
- {children}
-
- ),
-
- h2: ({ children }) => (
-
- {children}
-
- ),
-
- h3: ({ children }) => (
-
- {children}
-
- ),
-
- p: ({ children }) => (
- {children}
- ),
-
- ul: ({ children }) => (
-
- ),
-
- ol: ({ children }) => (
-
- {children}
-
- ),
-
- li: ({ children }) => (
- {children}
- ),
-
- a: ({ children, href }) => (
-
- {children}
-
- ),
-
- blockquote: ({ children }) => (
-
- {children}
-
- ),
-
- img: ({ src, alt }) => {
- const resolved = resolveImageSrc(src, base);
-
- return (
-
- );
- },
- }}
- >
- {readmeContent}
-
-
- )}
-
- );
-}
diff --git a/frontend/src/components/ProjectsReadme.tsx b/frontend/src/components/ProjectsReadme.tsx
new file mode 100644
index 0000000..a927566
--- /dev/null
+++ b/frontend/src/components/ProjectsReadme.tsx
@@ -0,0 +1,271 @@
+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 => {
+ 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("");
+ const [isLoading, setIsLoading] = useState(true);
+
+ const base = useMemo(() => getRepoRawBase(repoUrl), [repoUrl]);
+
+ useEffect(() => {
+ fetchREADME(repoUrl).then((content) => {
+ setReadmeContent(content);
+ setIsLoading(false);
+ });
+ }, [repoUrl]);
+
+ return (
+
+ {isLoading ? (
+
+ ) : (
+
+
): ReactElement {
+ const isBlock =
+ className ||
+ (typeof children === "string" &&
+ children.includes("\n"));
+
+ if (isBlock) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+ },
+
+ pre({
+ children,
+ ...props
+ }: React.ComponentPropsWithoutRef<"pre">): ReactElement {
+ return (
+
+ {children}
+
+ );
+ },
+
+ h1({
+ children,
+ }: React.ComponentPropsWithoutRef<"h1">): ReactElement {
+ return (
+
+ {children}
+
+ );
+ },
+
+ h2({
+ children,
+ }: React.ComponentPropsWithoutRef<"h2">): ReactElement {
+ return (
+
+ {children}
+
+ );
+ },
+
+ h3({
+ children,
+ }: React.ComponentPropsWithoutRef<"h3">): ReactElement {
+ return (
+
+ {children}
+
+ );
+ },
+
+ p({
+ children,
+ }: React.ComponentPropsWithoutRef<"p">): ReactElement {
+ return (
+
+ {children}
+
+ );
+ },
+
+ ul({
+ children,
+ }: React.ComponentPropsWithoutRef<"ul">): ReactElement {
+ return (
+
+ );
+ },
+
+ ol({
+ children,
+ }: React.ComponentPropsWithoutRef<"ol">): ReactElement {
+ return (
+
+ {children}
+
+ );
+ },
+
+ li({
+ children,
+ }: React.ComponentPropsWithoutRef<"li">): ReactElement {
+ return {children};
+ },
+
+ a({
+ children,
+ href,
+ }: React.ComponentPropsWithoutRef<"a">): ReactElement {
+ return (
+
+ {children}
+
+ );
+ },
+
+ blockquote({
+ children,
+ }: React.ComponentPropsWithoutRef<"blockquote">): ReactElement {
+ return (
+
+ {children}
+
+ );
+ },
+
+ img({
+ src,
+ alt,
+ }: React.ComponentPropsWithoutRef<"img">): ReactElement {
+ const resolved = resolveImageSrc(
+ src || "",
+ base,
+ );
+
+ return (
+
+ );
+ },
+ }}
+ >
+ {readmeContent}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/hooks/useTheme.js b/frontend/src/hooks/useTheme.ts
similarity index 63%
rename from frontend/src/hooks/useTheme.js
rename to frontend/src/hooks/useTheme.ts
index e0e766e..c4d79e5 100644
--- a/frontend/src/hooks/useTheme.js
+++ b/frontend/src/hooks/useTheme.ts
@@ -1,8 +1,13 @@
import { useEffect, useState } from "react";
import { getInitialTheme, applyTheme, toggleTheme } from "../utils/theme";
-export function useTheme() {
- const [theme, setTheme] = useState(getInitialTheme);
+interface UseThemeReturn {
+ theme: string;
+ toggleTheme: () => void;
+}
+
+export function useTheme(): UseThemeReturn {
+ const [theme, setTheme] = useState(getInitialTheme);
useEffect(() => {
applyTheme(theme);
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
deleted file mode 100644
index 35cd9a4..0000000
--- a/frontend/src/main.jsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import { StrictMode } from "react";
-import { createRoot } from "react-dom/client";
-import App from "./App.jsx";
-
-createRoot(document.getElementById("root")).render(
-
-
- ,
-);
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..fe09ccc
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,12 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import App from "./App";
+
+const root = document.getElementById("root");
+if (!root) throw new Error("Root element not found");
+
+createRoot(root).render(
+
+
+ ,
+);
diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.tsx
similarity index 90%
rename from frontend/src/pages/Home.jsx
rename to frontend/src/pages/Home.tsx
index 1116720..5798fae 100644
--- a/frontend/src/pages/Home.jsx
+++ b/frontend/src/pages/Home.tsx
@@ -1,8 +1,20 @@
-import { useState } from "react";
+import { useState, type ReactElement } from "react";
import ProjectsReadme from "../components/ProjectsReadme";
-export default function Home() {
- const projectList = [
+interface ProjectInfo {
+ id: string;
+ name: string;
+ url: string;
+}
+
+interface Section {
+ id: string;
+ title: string;
+ content: ReactElement;
+}
+
+export default function Home(): ReactElement {
+ const projectList: ProjectInfo[] = [
{
id: "site",
name: "aramjonghu-site",
@@ -20,14 +32,14 @@ export default function Home() {
},
];
- const [projIdx, setProjIdx] = useState(0);
+ const [projIdx, setProjIdx] = useState(0);
const prevProject = () =>
setProjIdx((i) => (i - 1 + projectList.length) % projectList.length);
const nextProject = () => setProjIdx((i) => (i + 1) % projectList.length);
- const [activeTab, setActiveTab] = useState("projects");
+ const [activeTab, setActiveTab] = useState("projects");
- const sections = [
+ const sections: Section[] = [
{
id: "projects",
title: "Projects",
diff --git a/frontend/src/pages/Stream.jsx b/frontend/src/pages/Stream.tsx
similarity index 83%
rename from frontend/src/pages/Stream.jsx
rename to frontend/src/pages/Stream.tsx
index 223ec3a..0cd9657 100644
--- a/frontend/src/pages/Stream.jsx
+++ b/frontend/src/pages/Stream.tsx
@@ -1,14 +1,19 @@
-import { useState, useRef, useEffect } from "react";
+import { useState, useRef, useEffect, type ReactElement } from "react";
-const streams = [
+interface StreamInfo {
+ id: string;
+ label: string;
+}
+
+const streams: StreamInfo[] = [
{ id: "aramstream", label: "Aram" },
{ id: "generalstream", label: "General" },
{ id: "gueststream", label: "Guest" },
];
-export default function Stream() {
- const [activeStream, setActiveStream] = useState("aramstream");
- const headerRef = useRef(null);
+export default function Stream(): ReactElement {
+ const [activeStream, setActiveStream] = useState("aramstream");
+ const headerRef = useRef(null);
useEffect(() => {
headerRef.current?.scrollIntoView({
diff --git a/frontend/src/utils/theme.js b/frontend/src/utils/theme.ts
similarity index 65%
rename from frontend/src/utils/theme.js
rename to frontend/src/utils/theme.ts
index 82fefe6..f588292 100644
--- a/frontend/src/utils/theme.js
+++ b/frontend/src/utils/theme.ts
@@ -1,21 +1,21 @@
const LIGHT = "latte";
const DARK = "macchiato";
-export function getSystemTheme() {
+export function getSystemTheme(): string {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? DARK
: LIGHT;
}
-export function getStoredTheme() {
+export function getStoredTheme(): string | null {
return localStorage.getItem("theme");
}
-export function getInitialTheme() {
+export function getInitialTheme(): string {
return getStoredTheme() || getSystemTheme();
}
-export function applyTheme(theme) {
+export function applyTheme(theme: string): void {
const root = document.documentElement;
root.classList.remove(LIGHT, DARK);
@@ -25,6 +25,6 @@ export function applyTheme(theme) {
localStorage.setItem("theme", theme);
}
-export function toggleTheme(current) {
+export function toggleTheme(current: string): string {
return current === DARK ? LIGHT : DARK;
}
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/frontend/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..2b1ca1a
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noEmit": true,
+ "isolatedModules": true,
+ "skipLibCheck": true,
+ "noUncheckedSideEffectImports": false,
+ "types": ["react", "react-dom"],
+ "rootDir": "./src"
+ },
+ "include": ["src"]
+}