From b62fa8a2929a6cbfe51bc0e12f0896aad707a7d6 Mon Sep 17 00:00:00 2001 From: AramJonghu Date: Sun, 21 Jun 2026 15:10:23 +0200 Subject: [PATCH] TS7 upgrade + write to TS over JS --- frontend/eslint.config.js | 40 ++- frontend/package.json | 4 + frontend/src/{App.jsx => App.tsx} | 5 +- .../src/components/{Footer.jsx => Footer.tsx} | 0 .../src/components/{Header.jsx => Header.tsx} | 18 +- .../src/components/{Layout.jsx => Layout.tsx} | 7 +- frontend/src/components/ProjectsReadme.jsx | 215 -------------- frontend/src/components/ProjectsReadme.tsx | 271 ++++++++++++++++++ .../src/hooks/{useTheme.js => useTheme.ts} | 9 +- frontend/src/main.jsx | 9 - frontend/src/main.tsx | 12 + frontend/src/pages/{Home.jsx => Home.tsx} | 24 +- frontend/src/pages/{Stream.jsx => Stream.tsx} | 15 +- frontend/src/utils/{theme.js => theme.ts} | 10 +- frontend/src/vite-env.d.ts | 1 + frontend/tsconfig.json | 16 ++ 16 files changed, 401 insertions(+), 255 deletions(-) rename frontend/src/{App.jsx => App.tsx} (73%) rename frontend/src/components/{Footer.jsx => Footer.tsx} (100%) rename frontend/src/components/{Header.jsx => Header.tsx} (84%) rename frontend/src/components/{Layout.jsx => Layout.tsx} (70%) delete mode 100644 frontend/src/components/ProjectsReadme.jsx create mode 100644 frontend/src/components/ProjectsReadme.tsx rename frontend/src/hooks/{useTheme.js => useTheme.ts} (63%) delete mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/main.tsx rename frontend/src/pages/{Home.jsx => Home.tsx} (90%) rename frontend/src/pages/{Stream.jsx => Stream.tsx} (83%) rename frontend/src/utils/{theme.js => theme.ts} (65%) create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json 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 }) => ( -
    - {children} -
- ), - - ol: ({ children }) => ( -
    - {children} -
- ), - - li: ({ children }) => ( -
  • {children}
  • - ), - - a: ({ children, href }) => ( - - {children} - - ), - - blockquote: ({ children }) => ( -
    - {children} -
    - ), - - img: ({ src, alt }) => { - const resolved = resolveImageSrc(src, base); - - return ( - {alt} - ); - }, - }} - > - {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 ( +
      + {children} +
    + ); + }, + + 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 ( + {alt} + ); + }, + }} + > + {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"] +}