BREAKING CHANGE: start over for react + three project

This commit is contained in:
2025-05-21 17:39:12 +02:00
parent 1ee616ce7e
commit 94305aacbf
81 changed files with 1443 additions and 5728 deletions

View File

@@ -1,2 +0,0 @@
node_modules
dist

View File

@@ -1,46 +0,0 @@
// @ts-check
/** @type {import("eslint").Linter.Config} */
const config = {
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
},
env: { browser: true, es2020: true },
plugins: ["@typescript-eslint", "react-refresh"],
extends: [
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/recommended-type-checked",
"plugin:@typescript-eslint/stylistic-type-checked",
"plugin:react-hooks/recommended",
],
rules: {
// These opinionated rules are enabled in stylistic-type-checked above.
// Feel free to reconfigure them to your own preference.
"@typescript-eslint/array-type": "off",
"@typescript-eslint/consistent-type-definitions": "off",
"@typescript-eslint/consistent-type-imports": [
"warn",
{
prefer: "type-imports",
fixStyle: "inline-type-imports",
},
],
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
"@typescript-eslint/require-await": "off",
"@typescript-eslint/no-misused-promises": [
"error",
{
checksVoidReturn: { attributes: false },
},
],
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
};
module.exports = config;

View File

@@ -1,28 +0,0 @@
name: Build and Push Docker Image
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Log in to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push Docker image
run: |
docker build -t pihkaal/me:latest --build-arg GH_PAT=${{ secrets.GH_PAT }} .
docker push pihkaal/me:latest

44
.gitignore vendored
View File

@@ -1,33 +1,29 @@
# Dependencies
/node_modules
/.pnp
.pnp.js
# Production
/dist
# Misc
.DS_Store
*.pem
# Debug
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
pnpm-debug.log*
lerna-debug.log*
# Local env files
.env
.env*.local
node_modules
dist
dist-ssr
*.local
# Typescript
*.tsbuildinfo
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# ESLint
.eslintcache
# Project
.notes
# Generated
/src/assets.ts
_old

View File

@@ -1,4 +0,0 @@
.env.example
.idea
pnpm-lock.yaml
dist

View File

@@ -1,8 +0,0 @@
// @ts-check
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
const config = {
plugins: ["prettier-plugin-tailwindcss"],
};
module.exports = config;

View File

@@ -1,26 +0,0 @@
FROM node:20-slim AS base
ARG GH_PAT
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
ENV GH_PAT="$GH_PAT"
RUN corepack enable
COPY . /app
WORKDIR /app
FROM base AS prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
FROM base AS build
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,4 +1,4 @@
Copyright (c) 2024 Pihkaal <hello@pihkaal.me>
Copyright (c) 2025 Pihkaal <hello@pihkaal.me>
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without

View File

@@ -1,23 +0,0 @@
import { z } from "zod";
import { configDotenv } from "dotenv";
configDotenv();
// yeah ok might be overkill lol but I had more env variables before
const schema = z.object({
GH_PAT: z.string().min(1),
});
const result = schema.safeParse(process.env);
if (result.success === false) {
console.error("❌ Invalid environment variables");
console.error(
result.error.errors
.map((error) => `- ${error.path.join(".")}: ${error.message}`)
.join("\n"),
);
process.exit(1);
}
export const env = result.data;

View File

@@ -1,150 +0,0 @@
import { Plugin } from "vite";
import { mkdir, readFile, writeFile } from "fs/promises";
import { spawnSync } from "child_process";
import { Octokit, RestEndpointMethodTypes } from "@octokit/rest";
import { env } from "./env";
import { existsSync } from "fs";
import showdown from "showdown";
type Manifest = {
files: string[];
projects: string[];
links: {
name: string;
url: string;
icon: string;
};
};
type Project = {
name: string;
content: string;
language: string | null;
url: string;
private: boolean;
};
type File = {
name: string;
content: string;
};
export const manifest = (): Plugin => ({
name: "generate-pages-plugin",
buildStart: async () => {
const octokit = new Octokit({ auth: env.GH_PAT });
let manifestRepo: RestEndpointMethodTypes["repos"]["get"]["response"]["data"];
try {
const { data } = await octokit.repos.get({
owner: "pihkaal",
repo: "pihkaal",
});
manifestRepo = data;
} catch {
if (existsSync("./node_modules/.cache/assets")) {
console.warn("WARNING: Can't update assets, using cached ones");
return;
} else {
throw new Error("Can't update assets, nothing cached");
}
}
try {
const storedUpdatedAt = (
await readFile("./node_modules/.cache/assets")
).toString();
if (storedUpdatedAt === manifestRepo.updated_at) return;
} catch {}
await mkdir("./node_modules/.cache", { recursive: true });
await writeFile("./node_modules/.cache/assets", manifestRepo.updated_at);
const getRepoFileContent = async (repo: string, path: string) => {
const { data: file } = await octokit.repos.getContent({
owner: "pihkaal",
repo,
path,
});
if (Array.isArray(file) || file.type !== "file") throw new Error("");
return Buffer.from(file.content, "base64").toString("utf8");
};
const manifest = JSON.parse(
await getRepoFileContent("pihkaal", "manifest.json"),
) as Manifest;
showdown.setFlavor("github");
const converter = new showdown.Converter();
const projects: Array<Project> = [];
for (const project of manifest.projects) {
const { data: repo } = await octokit.repos.get({
owner: "pihkaal",
repo: project,
});
const content = await getRepoFileContent(project, "README.md");
let html = converter.makeHtml(content);
// that's honestly not really clean but it does exactly what i need
if (!repo.private) {
const repoLink = `https://github.com/pihkaal/${project}`;
if (html.includes('id="links')) {
html = html.replace(
'id="links">',
`><a href=\"${repoLink}\">Repo</a> •`,
);
} else {
html = html += `<br>\n<a href="${repoLink}">Github repo</>`;
}
}
html = html
.replace(new RegExp('href="https', "g"), 'target="_blank" href="https')
.replace(
new RegExp('target="_blank" href="https://pihkaal.me', "g"),
'href="#',
);
projects.push({
name: project,
content: html,
language: repo.language,
url: repo.url,
private: repo.private,
});
}
const files: Array<File> = [];
for (const file of manifest.files) {
const content = await getRepoFileContent("pihkaal", file);
const html = converter.makeHtml(content);
files.push({
name: file,
content: html,
});
}
const code = `
const projects = ${JSON.stringify(projects, null, 2)} as const;
const links = ${JSON.stringify(manifest.links, null, 2)} as const;
const files = ${JSON.stringify(files, null, 2)} as const;
export const assets = {
projects,
links,
files
};
`;
await writeFile("./src/assets.ts", code);
spawnSync("prettier", ["--write", "./src/assets.ts"]);
},
});

View File

@@ -1,17 +0,0 @@
services:
pihkaal-me:
image: pihkaal/me:latest
networks:
- web
labels:
- "traefik.enable=true"
- "traefik.http.routers.pihkaal-me.rule=Host(`pihkaal.me`)"
- "traefik.http.routers.pihkaal-me.service=pihkaal-me"
- "traefik.http.services.pihkaal-me.loadbalancer.server.port=80"
- "traefik.http.routers.pihkaal-me.tls=true"
- "traefik.http.routers.pihkaal-me.tls.certResolver=myresolver"
restart: always
networks:
web:
external: true

28
eslint.config.js Normal file
View File

@@ -0,0 +1,28 @@
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";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
);

View File

@@ -2,20 +2,8 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta property="og:title" content="pihkaal.me" />
<meta
property="og:description"
content="French passionate, and self-taught developer."
/>
<meta property="og:image" content="https://i.imgur.com/8xRqYCS.png" />
<meta property="og:url" content="https://pihkaal.me" />
<meta property="og:type" content="website" />
<meta name="theme-color" content="#918cbb" />
<title>pihkaal</title>
<title>pihkaal.me</title>
</head>
<body>
<div id="root"></div>

View File

@@ -1,51 +1,35 @@
{
"name": "pihkaal.me",
"version": "0.1.0",
"description": "My personal website",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"uncache": "rm -f node_modules/.cache/assets",
"build": "rm -f node_modules/.cache/assets && vite build && tsc",
"dev": "vite",
"lint": "eslint src --cache --fix --max-warnings 0",
"build": "tsc -b && vite build",
"lint": "eslint --cache .",
"preview": "vite preview",
"format": "prettier --cache --write ."
"format": "prettier --write --cache ."
},
"dependencies": {
"clsx": "2.1.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"tailwind-merge": "2.2.1"
"@react-three/drei": "^10.0.8",
"@react-three/fiber": "^9.1.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"three": "^0.176.0"
},
"devDependencies": {
"@octokit/rest": "21.0.0",
"@types/eslint": "8.56.1",
"@types/node": "18.19.6",
"@types/react": "18.2.47",
"@types/react-dom": "18.2.18",
"@types/showdown": "^2.0.6",
"@typescript-eslint/eslint-plugin": "6.18.1",
"@typescript-eslint/parser": "6.18.1",
"@vitejs/plugin-react-swc": "3.5.0",
"autoprefixer": "10.4.16",
"dotenv": "16.4.5",
"eslint": "8.56.0",
"eslint-config-next": "14.0.4",
"eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-react-refresh": "0.4.5",
"postcss": "8.4.33",
"prettier": "3.1.1",
"prettier-plugin-tailwindcss": "0.5.11",
"sass": "1.70.0",
"showdown": "^2.1.0",
"tailwindcss": "3.4.1",
"typescript": "5.3.3",
"vite": "5.0.12",
"vite-tsconfig-paths": "4.3.1",
"zod": "3.23.8"
},
"ct3aMetadata": {
"initVersion": "7.25.1"
"@eslint/js": "^9.25.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@types/three": "^0.176.0",
"@vitejs/plugin-react-swc": "^3.9.0",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"prettier": "^3.5.3",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5"
}
}

4222
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +0,0 @@
// @ts-check
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
module.exports = config;

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

10
src/App.css Normal file
View File

@@ -0,0 +1,10 @@
body {
margin: 0;
width: 100vw;
height: 100vh;
}
#root {
width: 100%;
height: 100%;
}

View File

@@ -1,60 +1,44 @@
import { Kitty } from "./components/Kitty";
import { AppProvider } from "./providers/AppProvider";
import { Music } from "./components/Music";
import { Nvim } from "./components/Nvim";
import { Waybar } from "./components/Waybar";
import { useApp } from "./hooks/useApp";
import { clamp } from "./utils/math";
import { Sddm } from "./components/Sddm";
import { Boot } from "./components/Boot";
import { Off } from "./components/Off";
import * as THREE from "three";
import { useRef, useState } from "react";
import { Canvas, type ThreeElements } from "@react-three/fiber";
import { OrbitControls } from '@react-three/drei'
const AppRoot = () => {
const { brightness, state } = useApp();
const opacity = clamp(0.5 - (0.5 * brightness) / 100, 0, 0.5);
if (state === "off" || state === "reboot" || state === "suspend") {
return <Off />;
}
if (state === "boot") {
return <Boot />;
}
import "./App.css";
function Box(props: ThreeElements["mesh"]) {
const ref = useRef<THREE.Mesh>(null!);
const [hovered, hover] = useState(false);
const [clicked, click] = useState(false);
return (
<>
<div
className="pointer-events-none fixed inset-0 z-20 bg-black"
style={{ opacity }}
/>
<main className="h-[100svh] w-screen overflow-hidden bg-[url(/wallpaper.webp)] bg-cover bg-center">
{state === "login" ? (
<Sddm />
) : (
<div className="h-full flex-col">
<div className="relative z-10 p-1 md:p-2">
<Waybar />
</div>
<div className="relative flex h-[calc(100svh-39px)] w-full flex-col md:h-[calc(100svh-50px)]">
<Kitty className="w-full flex-1 px-1 pt-1 md:px-2 md:pb-1">
<Nvim />
</Kitty>
<Music />
</div>
</div>
)}
</main>
</>
<mesh
{...props}
ref={ref}
scale={clicked ? 1.5 : 1}
onClick={() => click(!clicked)}
onPointerOver={() => hover(true)}
onPointerOut={() => hover(false)}
>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color={hovered ? "hotpink" : "orange"} />
</mesh>
);
};
}
export default function App() {
return (
<AppProvider>
<AppRoot />
</AppProvider>
<Canvas>
<ambientLight intensity={Math.PI / 2} />
<spotLight
position={[10, 10, 10]}
angle={0.15}
penumbra={1}
decay={0}
intensity={Math.PI}
/>
<pointLight position={[-10, -10, -10]} decay={0} intensity={Math.PI} />
<Box position={[-1.2, 0, 0]} />
<Box position={[1.2, 0, 0]} />
<OrbitControls />
</Canvas>
);
}

View File

@@ -1,41 +0,0 @@
import { useEffect, useState } from "react";
import { useApp } from "~/hooks/useApp";
const LINES = [
"Loading Linux...",
"Loading initial ramdisk...",
"Feeding the cat...",
"Cleaning my room...",
"Preparing tuna tomato couscous...",
"Ready",
];
export const Boot = () => {
const { setState } = useApp();
const [line, setLine] = useState(0);
useEffect(() => {
if (line >= LINES.length) {
setState("login");
return;
}
const timeout = setTimeout(
() => setLine(line + 1),
line === 0
? 3500
: line === LINES.length - 1
? 1200
: Math.random() * 750 + 200,
);
return () => clearTimeout(timeout);
}, [setState, line]);
return (
<main className="h-[100svh] w-screen bg-black text-white">
{LINES.filter((_, i) => i <= line).map((line, i) => (
<p key={i}>{line}</p>
))}
</main>
);
};

View File

@@ -1,102 +0,0 @@
import {
type ReactNode,
useEffect,
useRef,
useState,
useCallback,
useId,
type HTMLAttributes,
} from "react";
import { type KittyContextProps } from "~/context/KittyContext";
import { useApp } from "~/hooks/useApp";
import { KittyProvider } from "~/providers/KittyProvider";
export const CHAR_WIDTH = 12;
export const CHAR_HEIGHT = 26;
const PADDING_RIGHT = CHAR_WIDTH / 2;
export const Kitty = (props: {
children?: ReactNode;
rows?: number;
cols?: number;
className?: string;
}) => {
const container = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState<`${number}px` | "auto">(
props.cols ? `${props.cols * CHAR_WIDTH}px` : "auto",
);
const [height, setHeight] = useState<`${number}px` | "auto">(
props.rows ? `${props.rows * CHAR_HEIGHT}px` : "auto",
);
const [context, setContext] = useState<KittyContextProps | undefined>(
undefined,
);
const id = useId();
const { activeKitty, setActiveKitty } = useApp();
const handleMouseEnter = useCallback(() => {
setActiveKitty(id);
}, [id, setActiveKitty]);
const snapToCharacter = useCallback(() => {
if (!container.current) return;
const cols = Math.round(
(container.current.clientWidth - PADDING_RIGHT) / CHAR_WIDTH,
);
const rows = Math.round(container.current.clientHeight / CHAR_HEIGHT);
const width = cols * CHAR_WIDTH;
const height = rows * CHAR_HEIGHT;
setWidth(`${width}px`);
setHeight(`${height}px`);
setContext({ id, rows, cols });
}, [id]);
useEffect(() => {
if (!container.current) return;
snapToCharacter();
window.addEventListener("resize", snapToCharacter);
return () => {
window.removeEventListener("resize", snapToCharacter);
};
}, [snapToCharacter]);
const style: HTMLAttributes<HTMLDivElement>["style"] = props.rows
? { height }
: { maxHeight: height, height: "100%" };
return (
<div className={props.className} onMouseEnter={handleMouseEnter}>
<div
className={
"h-full w-full overflow-hidden rounded-lg border-2 border-borderInactive bg-[#262234] bg-background bg-opacity-90 px-[1px] text-lg text-[#cbc7d1] text-foreground shadow-window transition-colors duration-[500ms] ease-out"
}
style={{
lineHeight: `${CHAR_HEIGHT}px`,
...(activeKitty === id
? {
borderColor: "#cdd6f4",
animationDuration: "200ms",
}
: {}),
}}
ref={container}
>
<div
className="whitespace-pre-wrap"
style={{ backdropFilter: "blur(2.5px)", width, ...style }}
>
<KittyProvider value={context}>{props.children}</KittyProvider>
</div>
</div>
</div>
);
};

View File

@@ -1,159 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { type InnerKittyProps } from "~/utils/types";
import { CHAR_WIDTH } from "../Kitty";
import { useKitty } from "~/hooks/useKitty";
export const Cava = (props: { animate: boolean }) => {
const kitty = useKitty();
return (
<div
className="grid select-none text-[#b2b9d7]"
style={{
gap: `${CHAR_WIDTH}px`,
gridTemplateColumns: `repeat(auto-fill, ${CHAR_WIDTH * 2}px)`,
gridTemplateRows: `1fr`,
}}
>
{kitty && <InnerCava {...props} {...kitty} />}
</div>
);
};
const FrequencyBar = (props: {
value: number;
max: number;
height: number;
}) => {
const WIDTH = 2;
const GRADIENT = "▁▂▃▄▅▆▇█";
const FULL_BLOCK = "█";
const fraction = props.value / props.max;
const totalCharacters = props.height * GRADIENT.length;
const filledCharacters = fraction * totalCharacters;
const fullBlocksCount = Math.floor(filledCharacters / GRADIENT.length);
const remainderIndex = Math.floor(filledCharacters % GRADIENT.length);
let bar = "";
const emptyBlocksCount =
props.height - fullBlocksCount - (remainderIndex > 0 ? 1 : 0);
if (remainderIndex === 0 && fullBlocksCount === 0) {
bar += `${" ".repeat(WIDTH)}\n`.repeat(Math.max(emptyBlocksCount - 1, 0));
bar += GRADIENT[0].repeat(WIDTH);
} else {
bar += `${" ".repeat(WIDTH)}\n`.repeat(Math.max(emptyBlocksCount, 0));
if (remainderIndex > 0) {
bar += `${GRADIENT[remainderIndex].repeat(WIDTH)}\n`;
}
bar += `${FULL_BLOCK.repeat(WIDTH)}\n`.repeat(fullBlocksCount);
}
return <span>{bar}</span>;
};
const InnerCava = (props: InnerKittyProps<typeof Cava>) => {
const sourceRef = useRef<AudioBufferSourceNode | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const dataArray = useRef<Uint8Array | null>(null);
const [barHeights, setBarHeights] = useState(
new Array<number>(Math.floor(props.cols / 3)).fill(0),
);
const requestRef = useRef<number>();
const calculateBarHeights = useCallback(() => {
if (!dataArray.current || !analyserRef.current) return;
analyserRef.current.getByteFrequencyData(dataArray.current);
const barCount = Math.floor(props.cols / 2);
const newBarHeights = [];
for (let i = 0; i < barCount; i++) {
const startIndex = Math.floor((i / barCount) * dataArray.current.length);
const endIndex = Math.floor(
((i + 1) / barCount) * dataArray.current.length,
);
const slice = dataArray.current.slice(startIndex, endIndex);
const sum = slice.reduce((acc, val) => acc + val, 0);
const average = sum / slice.length;
newBarHeights.push(average * 0.9);
}
const stateBarHeights =
barHeights.length !== newBarHeights.length
? new Array<number>(newBarHeights.length).fill(0)
: barHeights;
const smoothedBarHeights = newBarHeights.map((height, i) => {
const smoothingFactor = 0.8;
return (
stateBarHeights[i] + (height - stateBarHeights[i]) * smoothingFactor
);
});
setBarHeights(smoothedBarHeights);
requestRef.current = requestAnimationFrame(calculateBarHeights);
}, [barHeights, props.cols]);
useEffect(() => {
const fetchAudio = async () => {
try {
const audioContext = new AudioContext();
audioContextRef.current = audioContext;
const response = await fetch("/audio/music.mp3");
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
const analyserNode = audioContext.createAnalyser();
analyserNode.fftSize = 256;
const gainNode = audioContext.createGain();
gainNode.gain.value = 0;
const source = audioContext.createBufferSource();
source.buffer = audioBuffer;
source.loop = true;
source.connect(analyserNode);
analyserNode.connect(gainNode);
gainNode.connect(audioContext.destination);
analyserRef.current = analyserNode;
sourceRef.current = source;
dataArray.current = new Uint8Array(analyserNode.frequencyBinCount);
requestRef.current = requestAnimationFrame(calculateBarHeights);
source.start();
} catch (error) {
console.error("Error fetching or decoding audio:", error);
}
};
if (audioContextRef.current) {
requestRef.current = requestAnimationFrame(calculateBarHeights);
} else {
void fetchAudio();
}
return () => {
if (requestRef.current) cancelAnimationFrame(requestRef.current);
};
}, [calculateBarHeights]);
return barHeights.map((height, i) => (
<FrequencyBar
key={i}
value={props.animate ? height : 0}
max={255}
height={props.rows}
/>
));
};

View File

@@ -1,159 +0,0 @@
import { formatMMSS } from "../../utils/time";
import { CharArray } from "../../utils/string";
import { CHAR_HEIGHT, CHAR_WIDTH } from "../Kitty";
import { type InnerKittyProps } from "~/utils/types";
import { useKitty } from "~/hooks/useKitty";
import { type CurrentlyPlaying } from ".";
export const SpotifyPlayer = (props: { playing: CurrentlyPlaying | null }) => {
const kitty = useKitty();
return (
<div
className="grid select-none"
style={{
gridTemplateColumns: `${CHAR_WIDTH}px ${
CHAR_WIDTH * 8
}px 1fr ${CHAR_WIDTH}px`,
gridTemplateRows: `${CHAR_HEIGHT}px ${
CHAR_HEIGHT * 3
}px ${CHAR_HEIGHT}px`,
}}
>
{kitty && <InnerSpotifyPlayer {...props} {...kitty} />}
</div>
);
};
const InnerSpotifyPlayer = (props: InnerKittyProps<typeof SpotifyPlayer>) => {
if (props.playing === null) {
return (
<>
{/* title */}
<span
className="font-extrabold text-color5"
style={{ gridArea: "1 / 2 / 2 / 3" }}
>
Playback
</span>
{/* border right */}
<span style={{ gridArea: "1 / 4 / 2 / 5" }}></span>
<span style={{ gridArea: "2 / 4 / 3 / 4" }}>
{"│\n".repeat(props.rows - 2)}
</span>
<span style={{ gridArea: "3 / 4 / 4 / 5" }}></span>
{/* borer left */}
<span style={{ gridArea: "1 / 1 / 2 / 2" }}></span>
<span style={{ gridArea: "2 / 1 / 3 / 1" }}>
{"│\n".repeat(props.rows - 2)}
</span>
<span style={{ gridArea: "3 / 1 / 4 / 2" }}></span>
{/* border top */}
<span className="overflow-hidden" style={{ gridArea: "1 / 3 / 2 / 4" }}>
{"─".repeat(props.cols - 2 - 8)}
</span>
{/* border bottom */}
<span className="overflow-hidden" style={{ gridArea: "3 / 2 / 4 / 4" }}>
{"─".repeat(props.cols - 2)}
</span>
{/* body */}
<div className="overflow-hidden" style={{ gridArea: "2 / 2 / 3 / 4" }}>
No playback found
<br />
I'm not listening to anything right now
</div>
</>
);
}
const fillSize = Math.round(
(props.playing.progress_ms / props.playing.item.duration_ms) *
(props.cols - 2),
);
const emptySize = props.cols - 2 - fillSize;
const timeString = `${formatMMSS(
props.playing.progress_ms / 1000,
)}/${formatMMSS(props.playing.item.duration_ms / 1000)}`;
const timeStringLeft = Math.round(
(props.cols - 2) / 2 - timeString.length / 2,
);
const fill = new CharArray(" ", fillSize)
.write(timeStringLeft, timeString)
.toString();
const empty = new CharArray(" ", emptySize)
.write(timeStringLeft - fillSize, timeString)
.toString();
let titleAndArtist = `${
props.playing.item.name
} · ${props.playing.item.artists.map((a) => a.name).join(", ")}`;
if (titleAndArtist.length > props.cols - 4) {
titleAndArtist = titleAndArtist.slice(0, props.cols - 7) + "...";
}
let album = props.playing.item.album.name;
if (album.length > props.cols - 2) {
album = album.slice(0, props.cols - 5) + "...";
}
return (
<>
{/* title */}
<span
className="font-extrabold text-color5"
style={{ gridArea: "1 / 2 / 2 / 3" }}
>
Playback
</span>
{/* border right */}
<span style={{ gridArea: "1 / 4 / 2 / 5" }}></span>
<span style={{ gridArea: "2 / 4 / 3 / 4" }}>
{"│\n".repeat(props.rows - 2)}
</span>
<span style={{ gridArea: "3 / 4 / 4 / 5" }}></span>
{/* borer left */}
<span style={{ gridArea: "1 / 1 / 2 / 2" }}></span>
<span style={{ gridArea: "2 / 1 / 3 / 1" }}>
{"│\n".repeat(props.rows - 2)}
</span>
<span style={{ gridArea: "3 / 1 / 4 / 2" }}></span>
{/* border top */}
<span className="overflow-hidden" style={{ gridArea: "1 / 3 / 2 / 4" }}>
{"─".repeat(props.cols - 2 - 8)}
</span>
{/* border bottom */}
<span className="overflow-hidden" style={{ gridArea: "3 / 2 / 4 / 4" }}>
{"─".repeat(props.cols - 2)}
</span>
{/* body */}
<div className="overflow-hidden" style={{ gridArea: "2 / 2 / 3 / 4" }}>
<span className="font-extrabold text-color6">
<span className="font-normal">
{false ? "\udb81\udc0a " : "\udb80\udfe4 "}
</span>
{titleAndArtist}
</span>
<br />
<span className="text-color3">{album}</span>
<br />
<div className="relative font-extrabold">
<span className="bg-color2 text-color8">{fill}</span>
<span className="bg-color8 text-color2">{empty}</span>
</div>
<br />
</div>
</>
);
};

View File

@@ -1,89 +0,0 @@
import { useEffect, useState } from "react";
import { Kitty } from "../Kitty";
import { SpotifyPlayer } from "./SpotifyPlayer";
import { useApp } from "~/hooks/useApp";
import { cn, hideIf } from "~/utils/react";
import { Cava } from "./Cava";
export type CurrentlyPlaying = {
is_playing: boolean;
item: {
album: {
name: string;
};
name: string;
artists: { name: string }[];
duration_ms: number;
};
progress_ms: number;
};
export const Music = () => {
const { screenWidth } = useApp();
const [playing, setPlaying] = useState<CurrentlyPlaying | null>(null);
useEffect(() => {
const fetchCurrentlyPlaying = () =>
fetch("https://api.pihkaal.me/currently-playing?format=json")
.then((r) => r.json())
.then((data: CurrentlyPlaying) => {
if (data.is_playing) {
data.progress_ms = Math.max(0, data.progress_ms - 1500);
setPlaying(data);
} else {
setPlaying(null);
}
});
const updatePlayingInterval = setInterval(() => {
void fetchCurrentlyPlaying();
}, 1000 * 10);
const updateTimeInterval = setInterval(() => {
setPlaying((prev) => {
if (prev === null) return null;
if (prev.progress_ms >= prev.item.duration_ms) {
void fetchCurrentlyPlaying();
return prev;
}
return {
...prev,
progress_ms: Math.min(prev.item.duration_ms, prev.progress_ms + 1000),
};
});
}, 1000);
void fetchCurrentlyPlaying();
return () => {
clearInterval(updateTimeInterval);
clearInterval(updatePlayingInterval);
};
}, []);
return (
<div className="flex flex-row pb-1">
<Kitty
className={cn(
"h-full pb-1.5 pl-1 pr-1 pt-1 md:px-2",
screenWidth < 900 ? "w-full" : "w-1/2",
)}
rows={5}
>
<SpotifyPlayer playing={playing} />
</Kitty>
<Kitty
className={cn(
"h-full w-1/2 pb-2 pl-1 pr-2 pt-1",
hideIf(screenWidth < 900),
)}
rows={5}
>
<Cava animate={playing !== null} />
</Kitty>
</div>
);
};

View File

@@ -1,26 +0,0 @@
export const NvimEditor = (props: { content: string | undefined }) => {
let rows = props.content?.split("\n") ?? [];
// trim end empty lines
for (let i = rows.length - 1; i >= 0; i--) {
if (rows[i].trim().length === 0) {
rows = rows.slice(0, rows.length - 1);
} else {
break;
}
}
// add spaces in empty lines
for (let i = 0; i < rows.length; i++) {
if (rows[i].trim().length === 0) {
rows[i] = " ";
}
}
return (
<div className="flex w-full justify-center">
<div
className="plain-html"
dangerouslySetInnerHTML={{ __html: props.content ?? "" }}
/>
</div>
);
};

View File

@@ -1 +0,0 @@
export const NvimInput = () => <div>~ hello@pihkaal.me</div>;

View File

@@ -1,23 +0,0 @@
import { DEFAULT_ICON } from "~/utils/icons";
import { type Icon } from "~/utils/tree";
export const NvimStatusBar = (props: {
label: string;
labelColor: string;
fileIcon?: Icon;
fileName?: string;
}) => (
<div className="select-none bg-[#29293c]">
<span className="text-[#272332]" style={{ background: props.labelColor }}>
{` ${props.label} `}
</span>
<span className="text-[#474353]" style={{ background: props.labelColor }}>
{"\ue0ba"}
</span>
<span className="bg-[#474353] text-[#373040]">{"\ue0ba"}</span>
<span className="bg-[#373040]">{` ${
props.fileIcon?.char ?? DEFAULT_ICON.char
}${props.fileName ?? "Empty"} `}</span>
<span className="bg-[#373040] text-[#29293c]">{"\ue0ba"}</span>
</div>
);

View File

@@ -1,45 +0,0 @@
import { useState } from "react";
import { DEFAULT_ICON } from "~/utils/icons";
import { type Child } from "~/utils/tree";
export const NvimTreeChild = (props: {
child: Child;
y: number;
selected: boolean;
inDirectory: boolean | "last";
onSelect: (y: number) => void;
onOpen: (file: Child) => void;
}) => {
const icon = props.child.icon ?? DEFAULT_ICON;
const [lastClick, setLastClick] = useState<number>();
const handleClick = () => {
props.onSelect(props.y);
if (lastClick && Date.now() - lastClick <= 500) {
props.onOpen(props.child);
}
setLastClick(Date.now());
};
return (
<li
style={{ background: props.selected ? "#504651" : "" }}
onClick={handleClick}
>
{" "}
{props.inDirectory && (
<span className="text-[#5b515b]">
{props.inDirectory === "last" ? "└ " : "│ "}
</span>
)}
<span style={{ color: icon.color }}>{`${icon.char}`}</span>
{props.child.name === "README.md" ? (
<span className="font-extrabold text-[#d8c5a1]">README.md</span>
) : (
<span>{props.child.name}</span>
)}
</li>
);
};

View File

@@ -1,39 +0,0 @@
import { useState } from "react";
import { type Folder } from "~/utils/tree";
export const NvimTreeDirectory = (props: {
directory: Folder;
y: number;
selected: boolean;
onSelect: (y: number) => void;
onOpen: (directory: Folder) => void;
}) => {
const [lastClick, setLastClick] = useState<number>();
const handleClick = () => {
props.onSelect(props.y);
if (lastClick && Date.now() - lastClick <= 500) {
props.onOpen(props.directory);
}
setLastClick(Date.now());
};
return (
<li
className="text-[#a0b6ee]"
style={{ background: props.selected ? "#504651" : "" }}
onMouseDown={handleClick}
>
{props.directory.opened ? (
<> </>
) : (
<>
<span className="text-[#716471]"> </span>{" "}
</>
)}
{props.directory.name}
</li>
);
};

View File

@@ -1,142 +0,0 @@
import { useApp } from "~/hooks/useApp";
import { CHAR_HEIGHT, CHAR_WIDTH } from "../../Kitty";
import { type ReactNode, useEffect, useState } from "react";
import { type InnerKittyProps } from "~/utils/types";
import { type Nvim } from "..";
import { NvimTreeDirectory } from "./NvimTreeDirectory";
import { NvimTreeChild } from "./NvimTreeChild";
import { assets } from "~/assets";
import {
file,
folder,
project,
sortFiles,
link,
type Child,
} from "~/utils/tree";
const buildTree = () =>
sortFiles([
folder(
"links",
assets.links.map((l) => link(l.name, l.url, l.icon)),
),
folder(
"projects",
assets.projects.map((p) =>
project(p.name, p.content, p.url, p.language, p.private),
),
),
...assets.files.map((f) => file(f.name, f.content)),
]);
export const NvimTree = (
props: InnerKittyProps<typeof Nvim> & {
onOpen: (file: Child) => void;
},
) => {
const { activeKitty } = useApp();
const [files, setFiles] = useState(buildTree());
const [selectedY, setSelectedY] = useState(0);
const tree: Array<ReactNode> = [];
let y = 0;
let selectedFile = files[0];
for (const file of files) {
if (selectedY === y) selectedFile = file;
if (file.type === "folder") {
tree.push(
<NvimTreeDirectory
key={y}
directory={file}
y={y}
selected={selectedY === y}
onSelect={setSelectedY}
onOpen={(directory) => {
directory.opened = !directory.opened;
setFiles([...files]);
}}
/>,
);
if (file.opened) {
file.children.forEach((child, i) => {
y++;
if (selectedY === y) selectedFile = child;
tree.push(
<NvimTreeChild
key={y}
child={child}
y={y}
inDirectory={i === file.children.length - 1 ? "last" : true}
selected={selectedY === y}
onSelect={setSelectedY}
onOpen={props.onOpen}
/>,
);
});
}
} else {
tree.push(
<NvimTreeChild
key={y}
child={file}
y={y}
inDirectory={false}
selected={selectedY === y}
onSelect={setSelectedY}
onOpen={props.onOpen}
/>,
);
}
y++;
}
useEffect(() => {
const readme = files.find((file) => file.name === "README.md") as Child;
props.onOpen(readme);
setSelectedY(files.indexOf(readme));
const onScroll = (event: KeyboardEvent) => {
if (activeKitty !== props.id) return;
switch (event.key) {
case "ArrowUp":
setSelectedY((x) => Math.max(0, x - 1));
break;
case "ArrowDown":
setSelectedY((x) => Math.min(y - 1, x + 1));
break;
case "Enter":
if (selectedFile.type === "folder") {
selectedFile.opened = !selectedFile.opened;
setFiles([...files]);
} else {
props.onOpen(selectedFile);
}
break;
}
};
window.addEventListener("keydown", onScroll);
return () => {
window.removeEventListener("keydown", onScroll);
};
}, []);
return (
<div className="h-full select-none bg-[#0000001a]">
<ul
style={{
padding: `${CHAR_HEIGHT}px ${CHAR_WIDTH}px 0 ${CHAR_WIDTH * 2}px`,
}}
>
{tree}
</ul>
</div>
);
};

View File

@@ -1,67 +0,0 @@
import { useKitty } from "~/hooks/useKitty";
import { CHAR_HEIGHT, CHAR_WIDTH } from "../Kitty";
import { NvimEditor } from "./NvimEditor";
import { NvimInput } from "./NvimInput";
import { NvimStatusBar } from "./NvimStatusBar";
import { NvimTree } from "./NvimTree";
import { useState } from "react";
import { type InnerKittyProps } from "~/utils/types";
import { type Child, type Icon } from "~/utils/tree";
export const Nvim = (_props: unknown) => {
const kitty = useKitty();
return kitty && <InnerNvimTree {...kitty} />;
};
const InnerNvimTree = (props: InnerKittyProps<typeof Nvim>) => {
const [activeChild, setActiveChild] = useState<{
name: string;
content: string;
icon: Icon;
}>();
const handleOpenChild = (child: Child) => {
if (child.type === "link") {
window.open(child.url, "_blank")?.focus();
} else {
setActiveChild(child);
}
};
return (
<div
className="grid h-full"
style={{
gridTemplateColumns: `minmax(${CHAR_WIDTH * 20}px, ${
Math.round(props.cols * 0.2) * CHAR_WIDTH
}px) 1fr`,
gridTemplateRows: `1fr ${CHAR_HEIGHT}px ${CHAR_HEIGHT}px`,
}}
>
<div style={{ gridArea: "1 / 1 / 1 / 2" }}>
<NvimTree {...props} onOpen={handleOpenChild} />
</div>
<div
className="scrollbar overflow-y-scroll"
style={{
gridArea: "1 / 2 / 1 / 3",
overflowY: "auto",
}}
>
<NvimEditor content={activeChild?.content} />
</div>
<div style={{ gridArea: "2 / 1 / 2 / 3" }}>
<NvimStatusBar
label=" NORMAL"
labelColor="#7ea7ca"
fileIcon={activeChild?.icon}
fileName={activeChild?.name}
/>
</div>
<div style={{ gridArea: "3 / 1 / 3 / 3" }}>
<NvimInput />
</div>
</div>
);
};

View File

@@ -1,43 +0,0 @@
import { useEffect, useState } from "react";
import { useApp } from "~/hooks/useApp";
export const Off = () => {
const { state, setState } = useApp();
const [booting, setBooting] = useState(state === "reboot");
useEffect(() => {
if (booting) {
const timout = setTimeout(() => {
if (state === "suspend") {
setState("login");
} else {
setState("boot");
}
}, 1000);
return () => clearTimeout(timout);
}
}, [state, setState, booting]);
return (
<div className="flex h-[100svh] w-screen flex-col items-center justify-center bg-black">
<button
className={`drop-shadow-white cursor-pointer transition-all ${
booting ? "animate-disappear" : "animate-breathing"
}`}
onClick={() => setBooting(true)}
>
<svg viewBox="0 0 34 34" width="128">
<path
fill="#ffffff"
d="M 14 1 L 14 13 L 15 13 L 15 1 L 14 1 z M 19 1 L 19 13 L 20 13 L 20 1 L 19 1 z M 9 3.1855469 C 4.1702837 5.9748853 1.0026451 11.162345 1 17 C 1 25.836556 8.163444 33 17 33 C 25.836556 33 33 25.836556 33 17 C 32.99593 11.163669 29.828666 5.9780498 25 3.1894531 L 25 4.3496094 C 29.280842 7.0494632 31.988612 11.788234 32 17 C 32 25.284271 25.284271 32 17 32 C 8.7157288 32 2 25.284271 2 17 C 2.0120649 11.788824 4.7195457 7.0510246 9 4.3515625 L 9 3.1855469 z "
/>
</svg>
</button>
<p className="absolute bottom-2 left-1/2 -translate-x-1/2 text-sm text-gray-400 text-white">
This website is primarly made for desktop
</p>
</div>
);
};

View File

@@ -1,188 +0,0 @@
import { type ReactNode, useEffect, useRef, useState } from "react";
import { useApp } from "~/hooks/useApp";
const SddmActionButton = (props: {
icon: ReactNode;
text: string;
onClick?: () => void;
}) => (
<button
className="flex select-none flex-col items-center text-white transition-colors hover:text-zinc-800"
onClick={props.onClick}
>
{props.icon}
<span>{props.text}</span>
</button>
);
const PASSWORD_LENGTH = 12;
export const Sddm = () => {
const { setState } = useApp();
const passwordInputRef = useRef<HTMLInputElement>(null);
const [password, setPassword] = useState(0);
const [showPassword, setShowPassword] = useState(false);
const [now, setNow] = useState(new Date());
useEffect(() => {
const interval = setInterval(() => {
setNow(new Date());
}, 1000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (password >= PASSWORD_LENGTH) {
passwordInputRef.current?.blur();
return;
}
const timeout = setTimeout(
() => {
passwordInputRef.current?.focus();
const canType =
password < 4 ||
password === PASSWORD_LENGTH - 1 ||
Math.random() > 0.15;
setPassword(Math.max(0, password + (canType ? 1 : -1)));
},
password === 0 ? 3000 : Math.random() * 250 + 100,
);
return () => clearTimeout(timeout);
}, [password]);
return (
<>
<div className="pointer-events-none absolute inset-0 z-10 animate-fadeOut bg-black" />
<div className="flex h-full cursor-default items-center justify-around text-white">
<div className="flex h-full w-full flex-col justify-center backdrop-blur-2xl sm:w-2/3 md:w-1/2 lg:w-2/5">
<div className="flex flex h-4/5 flex-col items-center justify-between">
<div className="text-center">
<p className="text-6xl leading-10">Welcome!</p>
<p className="text-5xl">
{now.toLocaleTimeString("en-us", {
hour: "2-digit",
minute: "2-digit",
})}
</p>
<p>{now.toLocaleDateString("en-us", { dateStyle: "long" })}</p>
</div>
<div className="flex min-w-[210px] flex-col gap-5 lg:w-1/2">
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 flex items-center ps-3.5">
</div>
<input
type="text"
className="block w-full rounded-full border border-white bg-transparent p-2 text-center text-white placeholder-zinc-200"
value={"Pihkaal"}
readOnly
spellCheck={false}
maxLength={15}
/>
</div>
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 flex items-center ps-3.5">
</div>
<input
type="text"
ref={passwordInputRef}
readOnly
className="block w-full rounded-full border border-white bg-transparent p-2 text-center text-white placeholder-zinc-200"
value={
showPassword
? password > 0
? "no."
: ""
: "•".repeat(password)
}
placeholder="Password"
spellCheck={false}
maxLength={PASSWORD_LENGTH}
/>
</div>
<label className="flex select-none items-center gap-2 transition-colors hover:border-black hover:text-black">
<div
className={`relative flex h-3.5 w-3.5 items-center justify-center border border-current`}
>
<input
type="checkbox"
className="peer h-2 w-2 appearance-none checked:bg-current"
checked={showPassword}
onChange={() => setShowPassword((show) => !show)}
/>
</div>
<p className="text-sm">Show Password</p>
</label>
</div>
<div className="flex min-w-[210px] flex-col gap-1 text-left transition-colors lg:w-1/2">
<button
disabled={password !== PASSWORD_LENGTH}
onClick={() => setState("desktop")}
className="w-full select-none rounded-full bg-neutral-800 p-2 hover:bg-zinc-800 disabled:cursor-default disabled:bg-white disabled:bg-opacity-30"
>
Login
</button>
<p className="text-sm">Session: Hyprland</p>
</div>
<div className="flex gap-8">
<SddmActionButton
key="suspend"
onClick={() => setState("suspend")}
icon={
<svg viewBox="0 0 34 34" width="38">
<path
fill="currentColor"
d="M 17,1 C 8.163444,1 1,8.163444 1,17 1,25.836556 8.163444,33 17,33 25.836556,33 33,25.836556 33,17 33,8.163444 25.836556,1 17,1 Z m 0,1 C 25.284271,2 32,8.7157288 32,17 32,25.284271 25.284271,32 17,32 8.7157288,32 2,25.284271 2,17 2,8.7157288 8.7157288,2 17,2 Z m -4,9 v 12 h 1 V 11 Z m 7,0 v 12 h 1 V 11 Z"
/>
</svg>
}
text="Suspend"
/>
<SddmActionButton
key="reboot"
onClick={() => setState("reboot")}
icon={
<svg width="38" viewBox="0 0 34 34">
<g transform="matrix(1.000593,0,0,1.0006688,0.99050505,-287.73702)">
<path
fill="currentColor"
d="M 19.001953,1.1308594 V 2 H 19 v 11 h 1 V 2.3359375 A 15,15 45 0 1 32,17 15,15 45 0 1 21.001953,31.455078 v 1.033203 A 16.009488,16.010701 45 0 0 33.009766,17 16.009488,16.010701 45 0 0 19.001953,1.1308594 Z M 12.998047,1.5117188 A 16.009488,16.010701 45 0 0 0.99023438,17 16.009488,16.010701 45 0 0 14.998047,32.869141 V 32 H 15 V 21 H 14 V 31.664062 A 15,15 45 0 1 2,17 15,15 45 0 1 12.998047,2.5449219 Z"
transform="matrix(0.70668771,-0.70663419,0.70668771,0.70663419,-8.0273788,304.53335)"
/>
</g>
</svg>
}
text="Reboot"
/>
<SddmActionButton
key="shutdown"
onClick={() => setState("off")}
icon={
<svg viewBox="0 0 34 34" width="38">
<path
fill="currentColor"
d="M 14 1 L 14 13 L 15 13 L 15 1 L 14 1 z M 19 1 L 19 13 L 20 13 L 20 1 L 19 1 z M 9 3.1855469 C 4.1702837 5.9748853 1.0026451 11.162345 1 17 C 1 25.836556 8.163444 33 17 33 C 25.836556 33 33 25.836556 33 17 C 32.99593 11.163669 29.828666 5.9780498 25 3.1894531 L 25 4.3496094 C 29.280842 7.0494632 31.988612 11.788234 32 17 C 32 25.284271 25.284271 32 17 32 C 8.7157288 32 2 25.284271 2 17 C 2.0120649 11.788824 4.7195457 7.0510246 9 4.3515625 L 9 3.1855469 z "
/>
</svg>
}
text="Shutdown"
/>
</div>
</div>
</div>
</div>
</>
);
};

View File

@@ -1,59 +0,0 @@
import { useState, type ReactNode, type MouseEvent, useRef } from "react";
import { cn } from "~/utils/react";
export const WaybarWidget = (props: {
className?: string;
tooltip?: ReactNode;
interactable?: boolean;
children: ReactNode;
onClick?: () => void;
}) => {
const [tooltipPosition, setTooltipPosition] = useState<{
x: number;
y: number;
}>();
const [tooltipVisible, setTooltipVisible] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout>();
const handleMouseEnter = () => {
timeoutRef.current = setTimeout(() => {
setTooltipVisible(true);
}, 500);
};
const handleMouveMove = (e: MouseEvent) => {
if (!tooltipVisible) {
setTooltipPosition({ x: e.clientX, y: e.clientY });
}
};
const handleMouseLeave = () => {
clearTimeout(timeoutRef.current);
timeoutRef.current = undefined;
setTooltipVisible(false);
};
return (
<div
className={cn(
"relative py-[6.5px] font-bold text-[#2b2b2c] opacity-90",
props.className,
props.interactable && "cursor-pointer",
)}
onMouseEnter={handleMouseEnter}
onMouseMove={handleMouveMove}
onMouseLeave={handleMouseLeave}
onClick={props.onClick}
>
{props.children}
{props.tooltip && tooltipPosition && tooltipVisible && (
<div
className="fixed z-20 -translate-x-1/2 whitespace-pre-line rounded-[10px] border-2 border-[#11111b] bg-[#e7e7ec] px-3 py-3 font-extrabold text-[#2b2b2c]"
style={{ top: tooltipPosition.y + 20, left: tooltipPosition.x }}
>
{props.tooltip}
</div>
)}
</div>
);
};

View File

@@ -1,16 +0,0 @@
import { type ReactNode } from "react";
import { cn } from "~/utils/react";
export const WaybarWidgetGroup = (props: {
className?: string;
children: ReactNode;
}) => (
<div
className={cn(
`flex flex-row justify-between rounded-[10px] bg-[#e6e7ec] bg-opacity-80`,
props.className,
)}
>
{props.children}
</div>
);

View File

@@ -1,46 +0,0 @@
import { useEffect, useState } from "react";
import { WaybarWidget } from "../WaybarWidget";
import { lerpIcon } from "~/utils/icons";
const ICONS = ["󰂎", "󰁺", "󰁻", "󰁼", "󰁽", "󰁾", "󰁿", "󰂀", "󰂁", "󰂂", "󰁹"];
export const WaybarBatteryWidget = (props: { frequency: number }) => {
const [battery] = useState(100);
useEffect(() => {
const interval = setInterval(() => {
// setBattery((x) => x - 1);
if (battery - 1 === 0) {
// TODO: do something
}
}, props.frequency);
return () => clearInterval(interval);
});
const tooltip =
battery === 100
? "Full"
: battery >= 70
? "Almost full"
: battery >= 50
? "Halfway down, but still doing great. I wonder what happens if the battery reaches 0"
: battery >= 25
? "Uh maybe you should consider charging me ?"
: battery >= 15
? "It's really reaching low level now"
: battery >= 5
? "Are you ignoring my messages ??"
: "I warned you";
return (
<WaybarWidget
className="pl-[0.625rem] pr-3 text-[#1d7715]"
tooltip={tooltip}
>
<span className="font-normal">{lerpIcon(ICONS, battery, 100)}</span>{" "}
{battery}%
</WaybarWidget>
);
};

View File

@@ -1,42 +0,0 @@
import { type WheelEvent } from "react";
import { clamp } from "~/utils/math";
import { WaybarWidget } from "../WaybarWidget";
import { useApp } from "~/hooks/useApp";
import { lerpIcon } from "~/utils/icons";
const ICONS = ["󰃞", "󰃟", "󰃠"];
export const WaybarBrightnessWidget = () => {
const { brightness, setBrightness } = useApp();
const handleScroll = (e: WheelEvent) => {
let newBrightness = brightness - Math.sign(e.deltaY);
newBrightness = clamp(newBrightness, 0, 100);
setBrightness(newBrightness);
};
const tooltip =
brightness === 100
? "Flashbang mode"
: brightness >= 70
? "Bright as a Brest day!"
: brightness >= 50
? "Halfway to becoming a night owl"
: brightness >= 25
? "I'm scared of the dark please stop"
: brightness >= 15
? "Just turn off you screen at this point"
: brightness >= 5
? "Can you even read now ?"
: "So dark";
return (
<WaybarWidget className="pl-3 pr-[0.625rem]" tooltip={tooltip}>
<span onWheel={handleScroll}>
<span className="font-normal">{lerpIcon(ICONS, brightness, 100)}</span>{" "}
{brightness}%
</span>
</WaybarWidget>
);
};

View File

@@ -1,51 +0,0 @@
import { useEffect, useState } from "react";
import { WaybarWidget } from "../WaybarWidget";
import { clamp, randomMinMax } from "~/utils/math";
export const WaybarCPUWidget = (props: {
cores: number;
min: number;
max: number;
variation: number;
frequency: number;
}) => {
const [usage, setUsage] = useState(
new Array<number>(props.cores)
.fill(0)
.map((_) => randomMinMax(props.min, props.max)),
);
useEffect(() => {
const interval = setInterval(() => {
const variation = randomMinMax(-props.variation, props.variation + 1);
const index = randomMinMax(0, usage.length);
usage[index] = clamp(usage[index] + variation, props.min, props.max);
setUsage([...usage]);
}, props.frequency);
return () => clearInterval(interval);
}, [usage, props.variation, props.min, props.max, props.frequency]);
const totalUsage = Math.round(
usage.reduce((acc, v) => acc + v, 0) / usage.length,
);
return (
<WaybarWidget
className="pl-3 pr-[0.625rem]"
tooltip={
<ul>
<li>Total: {totalUsage}%</li>
{usage.map((value, i) => (
<li key={i}>
Core{i}: {value}%
</li>
))}
</ul>
}
>
<span className="font-normal"></span> {totalUsage}%
</WaybarWidget>
);
};

View File

@@ -1,27 +0,0 @@
import { useState } from "react";
import { WaybarWidget } from "../WaybarWidget";
import { randomMinMax } from "~/utils/math";
// TODO: find a good idea to determine disk usage
export const WaybarDiskWidget = (props: {
current: number;
variation: number;
capacity: number;
}) => {
const [value] = useState(
props.current + randomMinMax(-props.variation, props.variation + 1),
);
const usage = Math.round((value / props.capacity) * 100);
return (
<WaybarWidget
className="pl-[0.625rem] pr-3"
tooltip={`SSD - ${value.toFixed(1)}GB used out of ${
props.capacity
}GiB on / (${usage}%)`}
>
<span className="font-normal">󰋊</span> {usage}%
</WaybarWidget>
);
};

View File

@@ -1,7 +0,0 @@
import { WaybarWidget } from "../WaybarWidget";
export const WaybarHomeWidget = () => (
<WaybarWidget className="text-[#407cdd]">
<span className="font-normal"></span>
</WaybarWidget>
);

View File

@@ -1,16 +0,0 @@
import { useApp } from "~/hooks/useApp";
import { WaybarWidget } from "../WaybarWidget";
export const WaybarLockWidget = () => {
const { setState } = useApp();
return (
<WaybarWidget
className="pl-3 pr-[0.625rem]"
onClick={() => setState("login")}
interactable
>
<span className="font-normal"></span>
</WaybarWidget>
);
};

View File

@@ -1,44 +0,0 @@
import { type WheelEvent, useState } from "react";
import { clamp } from "~/utils/math";
import { WaybarWidget } from "../WaybarWidget";
export const WaybarMicrophoneWidget = () => {
const [muted, setMuted] = useState(false);
const [volume, setVolume] = useState(100);
const handleWheel = (e: WheelEvent) => {
let newVolume = volume - Math.sign(e.deltaY) * 5;
newVolume = clamp(newVolume, 0, 100);
setVolume(newVolume);
};
const handleClick = () => {
setMuted((muted) => !muted);
};
const icon = muted ? "" : "";
const tooltip =
volume === 0 || muted
? "Don't worry I'm not listening to you"
: volume === 100
? "Broadcasting loud and clear!"
: volume >= 50
? "Your voice sounds really great!"
: volume >= 20
? "I can still hear you, just a bit quieter"
: "I can barely hear you anymore :(";
return (
<WaybarWidget className="pl-[0.625rem] pr-3" interactable tooltip={tooltip}>
<span
className="text-[#ad6bfd]"
onWheel={handleWheel}
onClick={handleClick}
>
<span className="font-normal">{icon}</span> {!muted && `${volume}%`}
</span>
</WaybarWidget>
);
};

View File

@@ -1,16 +0,0 @@
import { useApp } from "~/hooks/useApp";
import { WaybarWidget } from "../WaybarWidget";
export const WaybarPowerWidget = () => {
const { setState } = useApp();
return (
<WaybarWidget
className="pl-[0.625rem] pr-3"
onClick={() => setState("off")}
interactable
>
<span className="font-normal"></span>
</WaybarWidget>
);
};

View File

@@ -1,39 +0,0 @@
import { useEffect, useState } from "react";
import { WaybarWidget } from "../WaybarWidget";
import { clamp, randomMinMax } from "~/utils/math";
// start, min, max and variation are in %
// capacity is in Gb
export const WaybarRAMWidget = (props: {
start: number;
min: number;
max: number;
variation: number;
frequency: number;
capacity: number;
}) => {
const [usage, setUsage] = useState(props.start);
useEffect(() => {
const interval = setInterval(() => {
const offset = randomMinMax(-props.variation, props.variation + 1);
setUsage((x) => clamp(x + offset, props.min, props.max));
}, props.frequency);
return () => clearInterval(interval);
});
const used = (usage / 100) * props.capacity;
// TODO: tooltip
// Memory - (capacity * usage).1f GB used
return (
<WaybarWidget
className="px-[0.625rem]"
tooltip={`Memory - ${used.toFixed(1)}GB used`}
>
<span className="font-normal"></span> {usage}%
</WaybarWidget>
);
};

View File

@@ -1,32 +0,0 @@
import { useEffect, useState } from "react";
import { WaybarWidget } from "../WaybarWidget";
import { clamp, randomMinMax } from "~/utils/math";
export const WaybarTemperatureWidget = (props: {
min: number;
max: number;
variation: number;
frequency: number;
}) => {
const [temperature, setTemperature] = useState(
randomMinMax(props.min, props.max),
);
useEffect(() => {
const interval = setInterval(() => {
const offset = randomMinMax(-props.variation, props.variation + 1);
setTemperature((x) => clamp(x + offset, props.min, props.max));
}, props.frequency);
return () => clearInterval(interval);
});
return (
<WaybarWidget
className="pl-3 pr-[0.625rem]"
tooltip="All good until I start playing btd6"
>
<span className="font-normal"></span> {temperature}°C
</WaybarWidget>
);
};

View File

@@ -1,24 +0,0 @@
import { useEffect, useState } from "react";
import { WaybarWidget } from "../WaybarWidget";
export const WaybarTimeWidget = () => {
const [date, setDate] = useState(new Date());
useEffect(() => {
const interval = setInterval(() => {
setDate(new Date());
}, 1000);
return () => {
clearInterval(interval);
};
});
const time = date.toLocaleString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: true,
});
return <WaybarWidget className="px-[0.625rem]">{time}</WaybarWidget>;
};

View File

@@ -1,5 +0,0 @@
import { WaybarWidget } from "../WaybarWidget";
export const WaybarTitleWidget = () => (
<WaybarWidget className="px-3">pihkaal</WaybarWidget>
);

View File

@@ -1,7 +0,0 @@
import { WaybarWidget } from "../WaybarWidget";
export const WaybarToggleThemeWidget = () => (
<WaybarWidget className="pl-[0.625rem] pr-3" interactable>
<span className="font-normal">󰐾</span>
</WaybarWidget>
);

View File

@@ -1,46 +0,0 @@
import { type WheelEvent, useState } from "react";
import { clamp } from "~/utils/math";
import { WaybarWidget } from "../WaybarWidget";
import { useApp } from "~/hooks/useApp";
import { lerpIcon } from "~/utils/icons";
const ICONS = ["", "", ""];
export const WaybarVolumeWidget = () => {
const [muted, setMuted] = useState(false);
const { volume, setVolume } = useApp();
const handleWheel = (e: WheelEvent) => {
let newVolume = volume - Math.sign(e.deltaY) * 5;
newVolume = clamp(newVolume, 0, 100);
setVolume(newVolume);
};
const handleClick = () => {
setMuted(!muted);
};
const icon = muted ? "" : lerpIcon(ICONS, volume, 100);
const toolip =
volume === 0 || muted
? "You don't like the music? :("
: volume === 100
? "Always maximum volume when it's Hysta"
: volume >= 50
? "Turning up the vibes !"
: "Enjoying music at a moderate level";
return (
<WaybarWidget className="px-[0.625rem]" interactable tooltip={toolip}>
<span
className="text-[#407cdd]"
onWheel={handleWheel}
onClick={handleClick}
>
<span className="font-normal">{icon}</span> {!muted && `${volume}%`}
</span>
</WaybarWidget>
);
};

View File

@@ -1,22 +0,0 @@
import { useEffect, useState } from "react";
import { WaybarWidget } from "../WaybarWidget";
export const WaybarWeatherWidget = () => {
const [temperature, setTemperature] = useState<string>("??");
useEffect(() => {
void fetch("https://wttr.in/Brest?format=j1")
.then((response) => response.json())
.then((data: { current_condition: Array<{ temp_C: string }> }) =>
setTemperature(data.current_condition[0].temp_C),
);
}, []);
const hours = new Date().getHours();
return (
<WaybarWidget className="px-[0.625rem]">
{hours > 22 || hours < 7 ? "🌙" : "☀️"} {temperature}°
</WaybarWidget>
);
};

View File

@@ -1,86 +0,0 @@
import { WaybarWidgetGroup } from "./WaybarWidgetGroup";
import { WaybarCPUWidget } from "./Widgets/WaybarCPUWidget";
import { WaybarDiskWidget } from "./Widgets/WaybarDiskWidget";
import { WaybarRAMWidget } from "./Widgets/WaybarRAMWidget";
import { WaybarTitleWidget } from "./Widgets/WaybarTitleWidget";
import { WaybarHomeWidget } from "./Widgets/WaybarHomeWidget";
import { randomMinMax } from "~/utils/math";
import { WaybarTemperatureWidget } from "./Widgets/WaybarTemperatureWidget";
import { WaybarBatteryWidget } from "./Widgets/WaybarBatteryWidget";
import { WaybarBrightnessWidget } from "./Widgets/WaybarBrightnessWidget";
import { WaybarVolumeWidget } from "./Widgets/WaybarVolumeWidget";
import { WaybarMicrophoneWidget } from "./Widgets/WaybarMicrophoneWidget";
import { WaybarLockWidget } from "./Widgets/WaybarLockWidget";
import { WaybarTimeWidget } from "./Widgets/WaybarTimeWidget";
import { WaybarPowerWidget } from "./Widgets/WaybarPowerWidget";
import { WaybarToggleThemeWidget } from "./Widgets/WaybarToggleThemeWidget";
import { WaybarWeatherWidget } from "./Widgets/WaybarWeatherWidget";
import { cn, hideIf } from "~/utils/react";
import { useApp } from "~/hooks/useApp";
export const Waybar = () => {
const { screenWidth } = useApp();
return (
<div className="grid h-[37px] w-full select-none grid-cols-[1fr_max-content_1fr] grid-rows-1 gap-0">
<div className="flex items-center gap-3">
<WaybarWidgetGroup className="rounded-r-[5px] pl-[10px] pr-[14px]">
<WaybarHomeWidget />
</WaybarWidgetGroup>
<WaybarWidgetGroup className={cn(hideIf(screenWidth < 705))}>
<WaybarCPUWidget
variation={1}
frequency={3250 + randomMinMax(-100, 100)}
cores={12}
min={8}
max={16}
/>
<WaybarRAMWidget
variation={1}
frequency={5000 + randomMinMax(-100, 100)}
min={18}
max={40}
start={1 + randomMinMax(20, 30)}
capacity={16}
/>
<WaybarDiskWidget current={35.9} variation={4.1} capacity={160.3} />
</WaybarWidgetGroup>
<WaybarWidgetGroup className={cn(hideIf(screenWidth < 910))}>
<WaybarTitleWidget />
</WaybarWidgetGroup>
</div>
<div className="flex items-center">
<WaybarWidgetGroup>
<WaybarLockWidget />
<WaybarTimeWidget />
<WaybarPowerWidget />
</WaybarWidgetGroup>
</div>
<div className="flex items-center justify-end gap-2">
<WaybarWidgetGroup className={cn(hideIf(screenWidth < 1320))}>
<WaybarTemperatureWidget
min={50}
max={70}
variation={1}
frequency={7000 + randomMinMax(-100, 100)}
/>
<WaybarBatteryWidget frequency={7000 + randomMinMax(-100, 100)} />
</WaybarWidgetGroup>
<WaybarWidgetGroup className={cn(hideIf(screenWidth < 890))}>
<WaybarBrightnessWidget />
<WaybarVolumeWidget />
<WaybarMicrophoneWidget />
</WaybarWidgetGroup>
<WaybarWidgetGroup className={cn(hideIf(screenWidth < 490))}>
<WaybarWeatherWidget />
<WaybarToggleThemeWidget />
</WaybarWidgetGroup>
</div>
</div>
);
};

View File

@@ -1,11 +0,0 @@
import { createContext } from "react";
import { type Prettify, type State } from "~/utils/types";
export const AppContext = createContext<AppContextProps | undefined>(undefined);
export type AppContextProps = Prettify<
State<"state", "off" | "suspend" | "reboot" | "boot" | "login" | "desktop"> &
State<"activeKitty", string> &
State<"brightness", number> &
State<"volume", number> & { screenWidth: number }
>;

View File

@@ -1,11 +0,0 @@
import { createContext } from "react";
export const KittyContext = createContext<KittyContextProps | undefined>(
undefined,
);
export type KittyContextProps = {
rows: number;
cols: number;
id: string;
};

View File

@@ -1,9 +0,0 @@
import { useContext } from "react";
import { AppContext } from "~/context/AppContext";
export const useApp = () => {
const app = useContext(AppContext);
if (!app) throw new Error("`useApp` used outside AppContext");
return app;
};

View File

@@ -1,4 +0,0 @@
import { useContext } from "react";
import { KittyContext } from "~/context/KittyContext";
export const useKitty = () => useContext(KittyContext);

68
src/index.css Normal file
View File

@@ -0,0 +1,68 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -1,120 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: mono;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@import url(https://fonts.bunny.net/css?family=jetbrains-mono:700,800);
body {
margin: 0;
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro",
"IBM Plex Mono", "Menlo", "DejaVu Sans Mono", "Liberation Mono", monospace;
}
*::selection {
background-color: #58515c;
}
@font-face {
font-family: "JetBrains Mono";
src:
url("/fonts/JetBrainsMonoNF-Medium.woff2") format("woff2"),
url("/fonts/JetBrainsMonoNF-Medium.woff") format("woff");
font-weight: 400;
font-style: normal;
font-display: swap;
}
.drop-shadow-white:hover {
filter: drop-shadow(0px 0px 4px rgba(255, 255, 255, 1));
}
@layer utilities {
.scrollbar::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.scrollbar::-webkit-scrollbar-thumb {
background-color: #f7ddd9;
border-radius: 4px;
}
.scrollbar::-webkit-scrollbar-track {
background-color: transparent;
border-radius: 4px;
}
.scrollbar {
scrollbar-width: thin;
scrollbar-color: #f7ddd9 transparent;
}
.plain-html {
@apply w-full whitespace-normal px-10 py-1 xl:w-2/3 2xl:w-3/5;
& img {
@apply inline;
}
& p,
h1,
h2,
h3,
h4,
h5 {
@apply m-[revert];
font-size: revert;
font-weight: revert;
}
& p {
font-size: 0.8em;
}
& h1 {
@apply text-[2rem] font-bold;
& img {
@apply mb-4;
}
}
& h2 {
@apply text-2xl;
}
& a:not(:has(img)) {
@apply text-[#4493f8] underline underline-offset-2;
}
& h2::before {
content: "# ";
}
& h1,
h2,
h3,
h4,
h5 {
& a {
@apply no-underline;
}
}
& h1,
h2 {
@apply border-b border-[#5d646da3] pb-3;
}
}
}

View File

@@ -1,10 +1,9 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.scss";
import { createRoot } from "react-dom/client";
import { StrictMode } from "react";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
createRoot(document.getElementById("root") as HTMLElement).render(
<StrictMode>
<App />
</React.StrictMode>,
</StrictMode>,
);

View File

@@ -1,54 +0,0 @@
import { type ReactNode, useState, useEffect } from "react";
import { AppContext, type AppContextProps } from "~/context/AppContext";
export const AppProvider = (props: { children?: ReactNode }) => {
const [activeKitty, setActiveKitty] = useState(":r0:");
const [brightness, setBrightness] = useState(
parseInt(localStorage.getItem("brightness") ?? "100"),
);
const [volume, setVolume] = useState(
parseInt(localStorage.getItem("volume") ?? "100"),
);
const [state, setState] = useState<AppContextProps["state"]>(
(localStorage.getItem("state") as AppContextProps["state"]) ?? "off",
);
const [screenWidth, setScreenWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => {
setScreenWidth(window.innerWidth);
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
});
useEffect(() => {
localStorage.setItem(
"state",
state === "desktop" ? "login" : state === "boot" ? "off" : state,
);
localStorage.setItem("brightness", brightness.toString());
localStorage.setItem("volume", volume.toString());
}, [state, brightness, volume]);
return (
<AppContext.Provider
value={{
activeKitty,
setActiveKitty,
brightness,
setBrightness,
volume,
setVolume,
screenWidth,
state,
setState,
}}
>
{props.children}
</AppContext.Provider>
);
};

View File

@@ -1,3 +0,0 @@
import { KittyContext } from "~/context/KittyContext";
export const KittyProvider = KittyContext.Provider;

View File

@@ -1,61 +0,0 @@
import { type Child, type Icon } from "./tree";
export const lerpIcon = (icons: Array<string>, value: number, max: number) =>
icons[Math.floor((value / max) * icons.length)] ?? icons[icons.length - 1];
export const getIcon = (file: Child | string | undefined): Icon => {
if (!file) return DEFAULT_ICON;
if (typeof file === "string") {
if (ICONS[file]) return ICONS[file];
const parts = file.split(".");
const iconName = parts[parts.length - 1];
return ICONS[EXT_TO_LANGUAGE[iconName]] ?? DEFAULT_ICON;
}
return file.icon ?? DEFAULT_ICON;
};
export const EXT_TO_LANGUAGE: Record<string, string> = {
asc: "Key",
md: "Markdown",
};
export const ICONS: Record<string, Icon> = {
Markdown: {
char: " ",
color: "#89bafa",
},
Key: {
char: "󰷖 ",
color: "#f9e2af",
},
TypeScript: {
char: " ",
color: "#4d86a2",
},
Rust: {
char: " ",
color: "#be8f78",
},
Instagram: {
char: " ",
color: "#e1306c",
},
Github: {
char: "󰊤 ",
color: "#ffffff",
},
LinkedIn: {
char: " ",
color: "#0077b5",
},
CodinGame: {
char: " ",
color: "#f1c40f",
},
};
export const DEFAULT_ICON = { char: "󰈚 ", color: "#f599ae" };

View File

@@ -1,5 +0,0 @@
export const clamp = (v: number, min: number, max: number) =>
Math.min(Math.max(v, min), max);
export const randomMinMax = (min: number, max: number) =>
Math.floor(Math.random() * (max - min) + min);

View File

@@ -1,6 +0,0 @@
import { type ClassNameValue, twMerge } from "tailwind-merge";
import { clsx } from "clsx";
export const cn = (...classes: Array<ClassNameValue>) => twMerge(clsx(classes));
export const hideIf = (condition: boolean) => (condition ? "hidden" : "");

View File

@@ -1,26 +0,0 @@
export class CharArray {
private readonly chars: string[];
constructor(fill: string, size: number) {
this.chars = fill.repeat(size).split("");
}
set(i: number, char: string | undefined) {
if (char === undefined || i < 0 || i >= this.chars.length) return;
this.chars[i] = char;
return this;
}
write(i: number, str: string) {
for (let oi = 0; oi < str.length; oi++) {
this.set(i + oi, str[oi]);
}
return this;
}
toString() {
return this.chars.join("");
}
}

View File

@@ -1,9 +0,0 @@
export const formatMMSS = (time: number) => {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
const minutesString = minutes.toString().padStart(2, "0");
const secondsString = seconds.toString().padStart(2, "0");
return `${minutesString}:${secondsString}`;
};

View File

@@ -1,94 +0,0 @@
import { DEFAULT_ICON, ICONS, getIcon } from "./icons";
export type Icon = {
char: string;
color: string;
};
export type Project = {
type: "project";
name: string;
url: string;
private: boolean;
content: string;
icon: Icon;
};
export type File = {
type: "file";
name: string;
content: string;
icon: Icon;
};
export type Link = {
type: "link";
name: string;
url: string;
icon: Icon;
};
export type Folder = {
name: string;
type: "folder";
children: Array<Child>;
opened: boolean;
};
export type Child = Link | Project | File;
export const sortFiles = (files: Array<Folder | Child>) =>
files
.sort((a, b) => a.name.localeCompare(b.name))
.sort((a, b) =>
a.type === "folder" && b.type !== "folder"
? -1
: a.type !== "folder" && b.type === "folder"
? 1
: 0,
);
export const folder = (name: string, children: Array<Child>): Folder => ({
type: "folder",
name,
opened: false,
children,
});
export const link = (name: string, url: string, icon: string): Link => ({
type: "link",
name,
url,
icon: getIcon(icon),
});
export const file = (
name: string,
content: string,
icon: Icon = getIcon(name),
): File => ({
type: "file",
name,
content,
icon,
});
export const project = (
name: string,
content: string,
url: string,
language: string,
priv: boolean,
): Project => ({
type: "project",
name,
content,
url,
icon: ICONS[language] ?? DEFAULT_ICON,
private: priv,
});
export const icon = (char: string, color: string): Icon => ({
char,
color,
});

View File

@@ -1,17 +0,0 @@
import { type KittyContextProps } from "~/context/KittyContext";
export type Prettify<T> = NonNullable<{ [K in keyof T]: T[K] }>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type InnerKittyProps<T extends (...args: any[]) => any> = Prettify<
Parameters<T>[0] & KittyContextProps
>;
export type State<
Name extends string,
T,
> = Name extends `${infer First}${infer Rest}`
? {
[K in Name]: T;
} & { [K in `set${Capitalize<First>}${Rest}`]: (value: T) => void }
: never;

View File

@@ -1,77 +0,0 @@
import { type Config } from "tailwindcss";
import { fontFamily } from "tailwindcss/defaultTheme";
const config = {
content: ["index.html", "./src/**/*.tsx", "./src/**/*.ts"],
theme: {
extend: {
keyframes: {
fadeOut: {
"0%": { opacity: "1" },
"100%": { opacity: "0" },
},
breathing: {
"0%, 100%": { transform: "scale(1)", opacity: "0.9" },
"50%": {
transform: "scale(1.1)",
opacity: "1",
},
},
disappear: {
"0%": { transform: "scale(1)", opacity: "0.95" },
"20%": { transform: "scale(1.2)", opacity: "1" },
"100%": { transform: "scale(0)", opacity: "0" },
},
},
animation: {
fadeOut: "fadeOut 250ms ease-out forwards",
breathing: "breathing 4s ease-in-out infinite",
disappear: "disappear 750ms ease-in-out forwards",
},
fontSize: {
sm: "0.8rem",
base: "1rem",
lg: "1.25rem",
xl: "1.25rem",
"2xl": "1.563rem",
"3xl": "1.953rem",
"4xl": "2.441rem",
"5xl": "3.052rem",
},
fontFamily: {
sans: ["var(--font-sans)", ...fontFamily.sans],
body: ["JetBrainsMono", "mono"],
},
boxShadow: {
window: "0 0 1px 1px #1a1a1a",
},
colors: {
foreground: "#cdd6f4",
background: "#1e1e2e",
borderInactive: "#595959",
borderActive: "#cdd6f4",
selectionForeground: "#1e1e2e",
selectionBackground: "#f5e0dc",
color0: "#45475a",
color1: "#f38ba8",
color2: "#a6e3a1",
color3: "#f9e2af",
color4: "#89bafa",
color5: "#f5c2e7",
color6: "#94e2d5",
color7: "#bac2de",
color8: "#585B70",
color9: "#f38ba8",
color10: "#a6e3a1",
color11: "#f9e2af",
color12: "#89bafa",
color13: "#f5c2e7",
color14: "#94e2d5",
color15: "#a6adc8",
},
},
},
plugins: [],
} as const satisfies Config;
export default config;

View File

@@ -1,2 +0,0 @@
- change title based on focused kitty
- fetch currently playing song

27
tsconfig.app.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -1,30 +1,7 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -1,10 +1,25 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["build", "vite.config.ts", "tailwind.config.ts"]
"include": ["vite.config.ts"]
}

View File

@@ -1,10 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import tsconfigPaths from "vite-tsconfig-paths";
import { manifest } from "./build/manifestPlugin";
const config = defineConfig({
plugins: [react(), manifest(), tsconfigPaths()],
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
});
export default config;