Compare commits

..

30 Commits

Author SHA1 Message Date
99e1cd5639 Update dockerfile; update compose 2025-04-21 12:02:33 -07:00
7446d8296a update compose 2025-04-21 11:52:32 -07:00
6b424ae8e4 Update compose 2025-04-21 11:51:22 -07:00
04489a53d1 Update compose 2025-04-21 11:47:09 -07:00
b40134833b Update compose; remove prisma schema 2025-04-21 11:28:32 -07:00
e2bf036919 Update package ver 2025-04-21 11:10:04 -07:00
7443947131 Add a comments feed; update background 2025-04-21 11:09:23 -07:00
0589ff9c7c Add interactivity to the background animation: 2025-04-17 12:39:48 -07:00
0c2e7f505d Update background animation to be more fluid and natural 2025-04-17 12:28:25 -07:00
cfbe43ab8b Remove prisma db; update comments.css 2025-04-17 12:02:36 -07:00
b5120b60df Style giscuss comment feed 2025-04-17 11:48:25 -07:00
b6b98023da Add interactive script message to blog 2025-04-10 11:09:12 -07:00
37c63db863 Update dependencies; add scripts submodule 2025-04-10 10:38:44 -07:00
61cca45350 Update astro 2025-03-10 14:19:55 -07:00
4b37d29a43 Update tag-list 2025-01-30 15:03:46 -08:00
d4f51b121e Update pages/about.astro 2025-01-30 09:24:25 -08:00
2e088c5c9f Add wakatime stats; begin work on blog tags 2025-01-30 09:18:18 -08:00
6ef97bb5f7 Update post index, rss feed, 404 page 2025-01-28 10:17:01 -08:00
bc4ddb7eae Update css 2025-01-28 09:43:30 -08:00
d69d3a0249 Update code style; add selection style 2025-01-28 09:37:46 -08:00
ee3918f428 Add title and description to blog posts 2025-01-28 08:52:05 -08:00
c9ab7a37b9 Update rss feed path 2025-01-27 11:29:24 -08:00
935d2a9077 Update dependencies; add rss feed 2025-01-23 14:53:26 -08:00
Timothy Pidashev
3c067b6c49 Update dependencies 2025-01-23 09:18:41 -08:00
Timothy Pidashev
8bb28cffa6 Fix coerce.date to PST time 2025-01-14 14:41:07 -08:00
Timothy Pidashev
a24fea8f3b Ensure date is UTC 2025-01-14 14:30:22 -08:00
Timothy Pidashev
8e32f21462 Update date format in blog slug 2025-01-14 14:23:29 -08:00
Timothy Pidashev
de871e775e Update config.ts 2025-01-14 12:21:40 -08:00
Timothy Pidashev
c89318ddd8 Update date parsing on blog; fix Makefile 2025-01-14 12:19:13 -08:00
Timothy Pidashev
b14fd5d7e7 Add favicon 2025-01-14 08:54:31 -08:00
53 changed files with 3584 additions and 1956 deletions

View File

@@ -1,5 +1,5 @@
timmypidashev.local {
tls internal
reverse_proxy web:4321
reverse_proxy timmypidashev.dev:4321
}

View File

@@ -1,5 +1,5 @@
timmypidashev.dev {
tls pidashev.tim@gmail.com
reverse_proxy 127.0.0.1:3000
reverse_proxy timmypidashev.dev:3000
}

View File

@@ -24,4 +24,4 @@ RUN echo "PUBLIC_VERSION=${CONTAINER_WEB_VERSION}" > /app/.env && \
EXPOSE 3000
CMD ["pnpm", "install", "&&", "pnpm", "run", "dev"]
CMD ["pnpm", "run", "dev"]

View File

@@ -36,10 +36,13 @@ FROM node:22-alpine
WORKDIR /app
# Install serve
RUN npm install -g serve
RUN npm install -g http-server
# Copy built files
COPY --from=builder /app/dist ./dist
# Expose port 3000
EXPOSE 3000
CMD ["serve", "-s", "dist", "-l", "3000"]
# Deployment command
CMD ["http-server", "dist", "-a", "127.0.0.1", "-p", "3000"]

3
.gitignore vendored
View File

@@ -1,5 +1,8 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Env
.env
# astro
.astro/

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "src/public/scripts"]
path = src/public/scripts
url = https://github.com/timmypidashev/scripts

152
Makefile
View File

@@ -1,13 +1,13 @@
PROJECT_NAME := "timmypidashev.dev"
PROJECT_AUTHORS := "Timothy Pidashev (timmypidashev) <pidashev.tim@gmail.com>"
PROJECT_VERSION := "v1.0.1"
PROJECT_VERSION := "v1.0.2"
PROJECT_LICENSE := "MIT"
PROJECT_SOURCES := "https://github.com/timmypidashev/web"
PROJECT_REGISTRY := "ghcr.io/timmypidashev/web"
PROJECT_REGISTRY := "ghcr.io/timmypidashev"
PROJECT_ORGANIZATION := "org.opencontainers"
CONTAINER_WEB_NAME := "web"
CONTAINER_WEB_VERSION := "v1.0.1"
CONTAINER_WEB_NAME := "timmypidashev.dev"
CONTAINER_WEB_VERSION := "v2.1.1"
CONTAINER_WEB_LOCATION := "src/"
CONTAINER_WEB_DESCRIPTION := "My portfolio website!"
@@ -17,26 +17,41 @@ CONTAINER_WEB_DESCRIPTION := "My portfolio website!"
help:
@echo "Available targets:"
@echo " run - Runs the docker compose file with the specified environment (local, dev, preview, or release)"
@echo " run - Runs the docker compose file with the specified environment (local or release)"
@echo " build - Builds the specified docker image with the appropriate environment"
@echo " push - Pushes the built docker image to the registry"
@echo " pull - Pulls the latest specified docker image from the registry"
@echo " prune - Removes all built and cached docker images and containers"
@echo " bump - Bumps the project and container versions"
@echo " exec - Spawns a shell to execute commands from within a running container"
run:
# Arguments:
# [environment]: 'local' or 'release'
#
# Explanation:
# * Runs the docker compose file with the specified environment(compose.local.yml, or compose.release.yml)
# * Passes all generated arguments to the compose file.
# Make sure we have been given proper arguments.
@if [ "$(word 2,$(MAKECMDGOALS))" = "local" ]; then \
echo "Running in local environment"; \
elif [ "$(word 2,$(MAKECMDGOALS))" = "release" ]; then \
echo "Running in release environment"; \
else \
echo "Invalid usage. Please use 'make run local' or 'make run release'"; \
exit 1; \
fi
docker compose -f compose.$(word 2,$(MAKECMDGOALS)).yml up --remove-orphans
build:
# Arguments
# [container]: Build context(which container to build ['all' to build every container defined])
# [environment]: 'local', 'dev', 'preview', or 'release'
# [environment]: 'local' or 'release'
#
# Explanation:
# * Builds the specified docker image with the appropriate environment.
# * Passes all generated arguments to docker build-kit.
# * Installs pre-commit hooks if in a git repository.
# Install pre-commit hooks if in a git repository.
$(call install_precommit)
# Extract container and environment inputted.
$(eval INPUT_TARGET := $(word 2,$(MAKECMDGOALS)))
@@ -49,33 +64,6 @@ build:
$(foreach container,$(containers),$(call container_build,$(container) $(INPUT_ENVIRONMENT))), \
$(call container_build,$(INPUT_CONTAINER) $(INPUT_ENVIRONMENT)))
run:
# Arguments:
# [environment]: 'local', 'dev', 'preview', or 'release'
#
# Explanation:
# * Runs the docker compose file with the specified environment(compose.local.yml, compose.dev.yml, compose.preview.yml, or compose.release.yml)
# * Passes all generated arguments to the compose file.
# * Installs pre-commit hooks if in a git repository.
# Install pre-commit hooks if in a git repository.
$(call install_precommit)
# Make sure we have been given proper arguments.
@if [ "$(word 2,$(MAKECMDGOALS))" = "local" ]; then \
echo "Running in local environment"; \
docker compose -f compose.$(word 2,$(MAKECMDGOALS)).yml up --watch --remove-orphans; \
elif [ "$(word 2,$(MAKECMDGOALS))" = "preview" ]; then \
echo "Running in preview environment"; \
docker compose -f compose.$(word 2,$(MAKECMDGOALS)).yml up --remove-orphans; \
elif [ "$(word 2,$(MAKECMDGOALS))" = "release" ]; then \
echo "Running in release environment"; \
docker compose -f compose.$(word 2,$(MAKECMDGOALS)).yml up --remove-orphans; \
else \
echo "Invalid usage. Please use 'make run <'local', 'dev', 'preview', or 'release'>"; \
exit 1; \
fi
push:
# Arguments
# [container]: Push context(which container to push to the registry)
@@ -93,45 +81,7 @@ push:
# NOTE: docker will complain if the container tag is invalid, no need to validate here.
@docker push $(PROJECT_REGISTRY)/$(INPUT_CONTAINER):$(INPUT_VERSION)
pull:
# TODO: FIX COMMAND PULL
# Arguments
# [container]: Pull context (which container to pull from the registry)
# [environment]: 'local', 'dev', 'preview', or 'release'
#
# Explanation:
# * Pulls the specified container version from the registry defined in the user configuration.
# * Uses sed and basename to extract the repository name.
# Extract container and environment inputted.
$(eval INPUT_TARGET := $(word 2,$(MAKECMDGOALS)))
$(eval INPUT_CONTAINER := $(firstword $(subst :, ,$(INPUT_TARGET))))
$(eval INPUT_ENVIRONMENT := $(lastword $(subst :, ,$(INPUT_TARGET))))
# Validate environment
@if [ "$(strip $(INPUT_ENVIRONMENT))" != "local" ] [ "$(strip $(INPUT_ENVIRONMENT))" != "dev" ] && [ "$(strip $(INPUT_ENVIRONMENT))" != "preview" ] && [ "$(strip $(INPUT_ENVIRONMENT))" != "release" ]; then \
echo "Invalid environment. Please specify 'local', 'dev', 'preview', or 'release'"; \
exit 1; \
fi
# Extract repository name from PROJECT_SOURCES using sed and basename
$(eval REPO_NAME := $(shell echo "$(PROJECT_SOURCES)" | sed 's|https://github.com/[^/]*/||' | sed 's/\.git$$//' | xargs basename))
# Determine the correct tag based on the environment and container
$(eval TAG := $(if $(filter $(INPUT_ENVIRONMENT),local),\
$(INPUT_CONTAINER):$(INPUT_ENVIRONMENT),\
$(if $(filter $(INPUT_CONTAINER),$(REPO_NAME)),\
$(PROJECT_REGISTRY):$(if $(filter $(INPUT_ENVIRONMENT),prev),prev,$(call container_version,$(INPUT_CONTAINER))),\
$(PROJECT_REGISTRY)/$(INPUT_CONTAINER):$(if $(filter $(INPUT_ENVIRONMENT),prev),prev,$(call container_version,$(INPUT_CONTAINER))))))
# Pull the specified container from the registry
@echo "Pulling container: $(INPUT_CONTAINER)"
@echo "Environment: $(INPUT_ENVIRONMENT)"
@echo "Tag: $(TAG)"
@docker pull $(TAG)
prune:
# TODO: IMPLEMENT COMMAND PRUNE
# Removes all built and cached docker images and containers.
bump:
@@ -164,22 +114,7 @@ bump:
# Bump the container version and global version in the Makefile
perl -pi -e 's/^PROJECT_VERSION\s*:=\s*\K.*/"v$(shell docker run usvc/semver:latest bump $(INPUT_SEMANTIC_TYPE) $(OLD_PROJECT_VERSION))"/ if /^PROJECT_VERSION\s*:=/' Makefile;
perl -pi -e 's/^CONTAINER_$(shell echo $(INPUT_CONTAINER) | tr a-z A-Z)_VERSION\s*:=\s*\K.*/"v$(shell docker run usvc/semver:latest bump $(INPUT_SEMANTIC_TYPE) $(OLD_CONTAINER_VERSION))"/ if /^CONTAINER_$(shell echo $(INPUT_CONTAINER) | tr a-z A-Z)_VERSION\s*:=/' Makefile;
# Commit and push to git origin
git add .
git commit -a -S -m "Bump $(INPUT_CONTAINER) to v$(shell docker run usvc/semver:latest bump $(INPUT_SEMANTIC_TYPE) $(OLD_PROJECT_VERSION))"
git push
git push origin tag v$(shell docker run usvc/semver:latest bump $(INPUT_SEMANTIC_TYPE) $(OLD_PROJECT_VERSION))
exec:
# Extract container and environment inputted.
$(eval INPUT_TARGET := $(word 2,$(MAKECMDGOALS)))
$(eval INPUT_CONTAINER := $(firstword $(subst :, ,$(INPUT_TARGET))))
$(eval INPUT_ENVIRONMENT := $(lastword $(subst :, ,$(INPUT_TARGET))))
$(eval COMPOSE_FILE := compose.$(INPUT_ENVIRONMENT).yml)
docker compose -f $(COMPOSE_FILE) run --service-ports $(INPUT_CONTAINER) sh
# This function generates Docker build arguments based on variables defined in the Makefile.
# It extracts variable assignments, removes whitespace, and formats them as build arguments.
# Additionally, it appends any custom shell generated arguments defined below.
@@ -194,7 +129,6 @@ define args
gsub(":", "", $$1); \
printf "--build-arg %s=%s ", $$1, $$2 \
}') \
--build-arg ENVIRONMENT='"$(shell echo $(INPUT_ENVIRONMENT))"' \
--build-arg BUILD_DATE='"$(shell date)"' \
--build-arg GIT_COMMIT='"$(shell git rev-parse HEAD)"'
endef
@@ -221,19 +155,22 @@ define container_build
$(eval ENVIRONMENT := $(word 2,$1))
$(eval ARGS := $(shell echo $(args)))
$(eval VERSION := $(strip $(call container_version,$(CONTAINER))))
$(eval PROJECT := $(strip $(subst ",,$(PROJECT_NAME))))
$(eval TAG := $(PROJECT).$(CONTAINER):$(ENVIRONMENT))
$(eval TAG := $(PROJECT_NAME):$(ENVIRONMENT))
@echo "Building container: $(CONTAINER)"
@echo "Environment: $(ENVIRONMENT)"
@echo "Version: $(VERSION)"
@if [ "$(strip $(ENVIRONMENT))" != "local" ] && [ "$(strip $(ENVIRONMENT))" != "dev" ] && [ "$(strip $(ENVIRONMENT))" != "preview" ] && [ "$(strip $(ENVIRONMENT))" != "release" ]; then \
echo "Invalid environment. Please specify 'local', 'dev', 'preview', or 'release'"; \
@if [ "$(strip $(ENVIRONMENT))" != "local" ] && [ "$(strip $(ENVIRONMENT))" != "release" ]; then \
echo "Invalid environment. Please specify 'local' or 'release'"; \
exit 1; \
fi
docker buildx build --no-cache --load -t $(TAG) -f .docker/Dockerfile.$(ENVIRONMENT) ./$(strip $(subst $(SPACE),,$(call container_location,$(CONTAINER))))/. $(ARGS) $(call labels,$(shell echo $(CONTAINER_NAME) | tr '[:lower:]' '[:upper:]')) --debug
$(if $(filter $(strip $(ENVIRONMENT)),release), \
$(eval TAG := $(PROJECT_REGISTRY)/$(PROJECT_NAME):$(VERSION)), \
)
docker buildx build --load -t $(TAG) -f .docker/Dockerfile.$(ENVIRONMENT) ./$(strip $(subst $(SPACE),,$(call container_location,$(CONTAINER))))/. $(ARGS) $(call labels,$(shell echo $(CONTAINER_NAME) | tr '[:lower:]' '[:upper:]')) --no-cache
endef
define container_location
@@ -241,26 +178,9 @@ define container_location
$(CONTAINER_$(CONTAINER_NAME)_LOCATION)
endef
define container_name
$(strip $(shell echo '$(1)' | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]'))
endef
define container_version
$(strip $(eval CONTAINER_NAME := $(shell echo $(1) | tr '[:lower:]' '[:upper:]'))) \
$(if $(CONTAINER_$(CONTAINER_NAME)_VERSION), \
$(shell echo $(strip $(strip $(CONTAINER_$(CONTAINER_NAME)_VERSION))) | tr -d '[:space:]'), \
$(error Version data for container $(1) not found))
endef
define install_precommit
$(strip \
$(shell \
if git rev-parse --is-inside-work-tree > /dev/null 2>&1; then \
pre-commit install > /dev/null 2>&1; \
fi \
) \
)
endef
%:
@:

View File

@@ -1,35 +0,0 @@
services:
caddy:
container_name: proxy
image: caddy:latest
ports:
- 80:80
- 443:443
volumes:
- ./.caddy/Caddyfile.dev:/etc/caddy/Caddyfile:rw
networks:
- web_proxy
depends_on:
- web
web:
container_name: web
image: web:dev
volumes:
- ./src/node_modules:/app/node_modules
- ./src/sandbox.config.json:/app/sandbox.config.json
- ./src/.stackblitzrc:/app/.stackblitzrc
- ./src/astro.config.mjs:/app/astro.config.mjs
- ./src/tailwind.config.cjs:/app/tailwind.config.cjs
- ./src/tsconfig.json:/app/tsconfig.json
- ./src/pnpm-lock.yaml:/app/pnpm-lock.yaml
- ./src/package.json:/app/package.json
- ./src/public:/app/public
- ./src/src:/app/src
networks:
- web_proxy
networks:
web_proxy:
name: web_proxy
external: true

0
compose.local.yml Normal file
View File

View File

@@ -1,20 +1,28 @@
services:
caddy:
container_name: proxy
container_name: caddy
image: caddy:latest
ports:
- 80:80
- 443:443
volumes:
- ./.caddy/Caddyfile.dev:/etc/caddy/Caddyfile:rw
- ./.caddy/Caddyfile.release:/etc/caddy/Caddyfile:rw
networks:
- proxy_network
depends_on:
- release.timmypidashev.dev
- timmypidashev.dev
watchtower:
container_name: updates
image: containrrr/watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- $HOME/.docker/config.json:/config.json
command: --interval 120 --cleanup --label-enable
release.timmypidashev.dev:
timmypidashev.dev:
container_name: timmypidashev
image: ghcr.io/timmypidashev/timmypidashev.dev:release
image: ghcr.io/timmypidashev/timmypidashev.dev:latest
networks:
- proxy_network

View File

@@ -1,8 +0,0 @@
{
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1"
}
}

90
pnpm-lock.yaml generated
View File

@@ -1,90 +0,0 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@types/react':
specifier: ^18.3.12
version: 18.3.12
'@types/react-dom':
specifier: ^18.3.1
version: 18.3.1
react:
specifier: ^18.3.1
version: 18.3.1
react-dom:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
packages:
'@types/prop-types@15.7.13':
resolution: {integrity: sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==}
'@types/react-dom@18.3.1':
resolution: {integrity: sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==}
'@types/react@18.3.12':
resolution: {integrity: sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==}
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
react-dom@18.3.1:
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies:
react: ^18.3.1
react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
snapshots:
'@types/prop-types@15.7.13': {}
'@types/react-dom@18.3.1':
dependencies:
'@types/react': 18.3.12
'@types/react@18.3.12':
dependencies:
'@types/prop-types': 15.7.13
csstype: 3.1.3
csstype@3.1.3: {}
js-tokens@4.0.0: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
react-dom@18.3.1(react@18.3.1):
dependencies:
loose-envify: 1.4.0
react: 18.3.1
scheduler: 0.23.2
react@18.3.1:
dependencies:
loose-envify: 1.4.0
scheduler@0.23.2:
dependencies:
loose-envify: 1.4.0

View File

@@ -1,14 +1,18 @@
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
import tailwind from "@astrojs/tailwind";
import react from "@astrojs/react";
import mdx from "@astrojs/mdx";
import rehypePrettyCode from "rehype-pretty-code";
import rehypeSlug from "rehype-slug";
import sitemap from "@astrojs/sitemap";
// https://astro.build/config
export default defineConfig({
output: "server",
adapter: node({
mode: "standalone",
}),
site: "https://timmypidashev.dev",
build: {
// Enable build-time optimizations
@@ -35,7 +39,7 @@ export default defineConfig({
rehypePrettyCode,
{
theme: {
"name": "Custom Gruvbox Dark",
"name": "Darkbox",
"type": "dark",
"colors": {
"editor.background": "#000000",
@@ -163,6 +167,7 @@ export default defineConfig({
}
],
},
keepBackground: true,
},
],
],

View File

@@ -1,6 +1,6 @@
{
"name": "src",
"version": "v1.0.1",
"version": "2.1.1",
"private": true,
"scripts": {
"dev": "astro dev --host",
@@ -8,25 +8,32 @@
"preview": "astro preview"
},
"devDependencies": {
"@astrojs/react": "^4.1.2",
"@astrojs/tailwind": "^5.1.4",
"@tailwindcss/typography": "^0.5.15",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"astro": "^5.1.2",
"tailwindcss": "^3.4.15"
"@astrojs/react": "^4.2.4",
"@astrojs/tailwind": "^6.0.2",
"@tailwindcss/typography": "^0.5.16",
"@types/react": "^18.3.20",
"@types/react-dom": "^18.3.6",
"astro": "^5.7.4",
"tailwindcss": "^3.4.17"
},
"dependencies": {
"@astrojs/mdx": "^4.0.3",
"@astrojs/sitemap": "^3.2.1",
"@astrojs/mdx": "^4.2.4",
"@astrojs/node": "^9.2.0",
"@astrojs/rss": "^4.0.11",
"@astrojs/sitemap": "^3.3.0",
"@giscus/react": "^3.1.0",
"@pilcrowjs/object-parser": "^0.0.4",
"@react-hook/intersection-observer": "^3.1.2",
"arctic": "^3.6.0",
"lucide-react": "^0.468.0",
"marked": "^15.0.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-responsive": "^10.0.0",
"react-responsive": "^10.0.1",
"reading-time": "^1.5.0",
"rehype-pretty-code": "^0.14.0",
"rehype-pretty-code": "^0.14.1",
"rehype-slug": "^6.0.0",
"schema-dts": "^1.1.2",
"schema-dts": "^1.1.5",
"typewriter-effect": "^2.21.0"
}
}

3033
src/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

3
src/pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
onlyBuiltDependencies:
- esbuild
- sharp

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

14
src/public/favicon.svg Normal file
View File

@@ -0,0 +1,14 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="16.000000pt" height="16.000000pt" viewBox="0 0 16.000000 16.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,16.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M0 80 l0 -80 30 32 c17 17 30 35 30 39 0 5 12 9 26 9 14 0 22 -4 19
-10 -6 -10 33 -70 47 -70 4 0 8 36 8 80 l0 80 -80 0 -80 0 0 -80z m105 20 c-3
-5 -16 -10 -28 -10 -18 0 -19 2 -7 10 20 13 43 13 35 0z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 642 B

1
src/public/scripts Submodule

Submodule src/public/scripts added at b555dc1e10

View File

@@ -0,0 +1,56 @@
import React, { useState, useEffect } from "react";
const GlitchText = () => {
const originalText = 'Error 404';
const [characters, setCharacters] = useState(
originalText.split("").map(char => ({ char, isGlitched: false }))
);
const glitchChars = "!<>-_\\/[]{}—=+*^?#________";
useEffect(() => {
const glitchInterval = setInterval(() => {
if (Math.random() < 0.2) { // 20% chance to trigger glitch
setCharacters(prev => {
return originalText.split('').map((originalChar, index) => {
if (Math.random() < 0.3) { // 30% chance to glitch each character
return {
char: glitchChars[Math.floor(Math.random() * glitchChars.length)],
isGlitched: true
};
}
return { char: originalChar, isGlitched: false };
});
});
// Reset after short delay
setTimeout(() => {
setCharacters(originalText.split('').map(char => ({
char,
isGlitched: false
})));
}, 100);
}
}, 50);
return () => clearInterval(glitchInterval);
}, []);
return (
<div className="relative">
<h1 className="text-6xl font-bold mb-4 relative">
<span className="relative inline-block">
{characters.map((charObj, index) => (
<span
key={index}
className={charObj.isGlitched ? "text-red" : "text-purple"}
>
{charObj.char}
</span>
))}
</span>
</h1>
</div>
);
};
export default GlitchText;

View File

@@ -3,12 +3,16 @@ import { ChevronDownIcon } from "@/components/icons";
export default function Intro() {
const scrollToNext = () => {
window.scrollTo({
top: window.innerHeight,
behavior: "smooth"
});
const nextSection = document.querySelector("section")?.nextElementSibling;
if (nextSection) {
const offset = nextSection.offsetTop - (window.innerHeight - nextSection.offsetHeight) / 2; // Center the section
window.scrollTo({
top: offset,
behavior: "smooth"
});
}
};
return (
<div className="w-full max-w-4xl px-4">
<div className="space-y-8 md:space-y-12">

View File

@@ -0,0 +1,150 @@
import React, { useState, useEffect } from 'react';
export const ActivityGrid = () => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch('/api/wakatime');
if (!response.ok) {
throw new Error('Failed to fetch data');
}
const result = await response.json();
setData(result.data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
// Get intensity based on coding hours (0-4 for different shades)
const getIntensity = (hours) => {
if (hours === 0) return 0;
if (hours < 2) return 1;
if (hours < 4) return 2;
if (hours < 6) return 3;
return 4;
};
// Get color class based on intensity
const getColorClass = (intensity) => {
if (intensity === 0) return 'bg-foreground/5';
if (intensity === 1) return 'bg-green-DEFAULT/30';
if (intensity === 2) return 'bg-green-DEFAULT/60';
if (intensity === 3) return 'bg-green-DEFAULT/80';
return 'bg-green-bright';
};
// Group data by week
const weeks = [];
let currentWeek = [];
if (data.length > 0) {
data.forEach((day, index) => {
currentWeek.push(day);
if (currentWeek.length === 7 || index === data.length - 1) {
weeks.push(currentWeek);
currentWeek = [];
}
});
}
if (loading) {
return (
<div className="bg-background border border-foreground/10 rounded-lg p-6">
<div className="text-lg text-aqua-bright mb-6">Loading activity data...</div>
</div>
);
}
if (error) {
return (
<div className="bg-background border border-foreground/10 rounded-lg p-6">
<div className="text-lg text-red-bright mb-6">Error loading activity: {error}</div>
</div>
);
}
return (
<div className="bg-background border border-foreground/10 rounded-lg p-6 hover:border-foreground/20 transition-colors">
<div className="text-lg text-aqua-bright mb-6">Activity</div>
<div className="flex gap-4">
{/* Days labels */}
<div className="flex flex-col gap-2 pt-6 text-xs">
{days.map((day, i) => (
<div key={day} className="h-3 text-foreground/60">{i % 2 === 0 ? day : ''}</div>
))}
</div>
{/* Grid */}
<div className="flex-grow overflow-x-auto">
<div className="flex gap-2">
{weeks.map((week, weekIndex) => (
<div key={weekIndex} className="flex flex-col gap-2">
{week.map((day, dayIndex) => {
const hours = day.grand_total.total_seconds / 3600;
const intensity = getIntensity(hours);
return (
<div
key={dayIndex}
className={`w-3 h-3 rounded-sm ${getColorClass(intensity)}
hover:ring-1 hover:ring-foreground/30 transition-all cursor-pointer
group relative`}
>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 p-2
bg-background border border-foreground/10 rounded-md opacity-0
group-hover:opacity-100 transition-opacity z-10 whitespace-nowrap text-xs">
{hours.toFixed(1)} hours on {day.date}
</div>
</div>
);
})}
</div>
))}
</div>
{/* Months labels */}
<div className="flex text-xs text-foreground/60 mt-2">
{weeks.map((week, i) => {
const date = new Date(week[0].date);
const isFirstOfMonth = date.getDate() <= 7;
return (
<div
key={i}
className="w-3 mx-1"
style={{ marginLeft: i === 0 ? '0' : undefined }}
>
{isFirstOfMonth && months[date.getMonth()]}
</div>
);
})}
</div>
</div>
</div>
{/* Legend */}
<div className="flex items-center gap-2 mt-4 text-xs text-foreground/60">
<span>Less</span>
{[0, 1, 2, 3, 4].map((intensity) => (
<div
key={intensity}
className={`w-3 h-3 rounded-sm ${getColorClass(intensity)}`}
/>
))}
<span>More</span>
</div>
</div>
);
};
export default ActivityGrid;

View File

@@ -0,0 +1,213 @@
import { useState, useEffect } from "react";
const Stats = () => {
const [stats, setStats] = useState<any>(null);
const [count, setCount] = useState(0);
const [isFinished, setIsFinished] = useState(false);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
setIsVisible(true);
const fetchStats = async () => {
try {
const res = await fetch("/api/wakatime/alltime");
const data = await res.json();
setStats(data.data);
startCounting(data.data.total_seconds);
} catch (error) {
console.error("Error fetching stats:", error);
}
};
fetchStats();
}, []);
const startCounting = (totalSeconds: number) => {
const duration = 2000;
const steps = 60;
let currentStep = 0;
const timer = setInterval(() => {
currentStep += 1;
if (currentStep >= steps) {
setCount(totalSeconds);
setIsFinished(true);
clearInterval(timer);
return;
}
const progress = 1 - Math.pow(1 - currentStep / steps, 4);
setCount(Math.floor(totalSeconds * progress));
}, duration / steps);
return () => clearInterval(timer);
};
if (!stats) return null;
const hours = Math.floor(count / 3600);
const formattedHours = hours.toLocaleString("en-US", {
minimumIntegerDigits: 4,
useGrouping: true
});
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-6">
<div className={`
text-2xl opacity-0
${isVisible ? "animate-fade-in-first" : ""}
`}>
I've spent
</div>
<div className="relative">
<div className="text-8xl text-center relative z-10">
<span className="font-bold relative">
<span className={`
bg-gradient-text opacity-0
${isVisible ? "animate-fade-in-second" : ""}
`}>
{formattedHours}
</span>
</span>
<span className={`
text-4xl opacity-0
${isVisible ? "animate-slide-in-hours" : ""}
`}>
hours
</span>
</div>
</div>
<div className="flex flex-col items-center gap-3 text-center">
<div className={`
text-xl opacity-0
${isVisible ? "animate-fade-in-third" : ""}
`}>
writing code & building apps
</div>
<div className={`
flex items-center gap-3 text-lg opacity-0
${isVisible ? "animate-fade-in-fourth" : ""}
`}>
<span>since</span>
<span className="text-green-bright font-bold">{stats.range.start_text}</span>
</div>
</div>
<style jsx>{`
.bg-gradient-text {
background: linear-gradient(
90deg,
#fbbf24,
#f59e0b,
#d97706,
#b45309,
#f59e0b,
#fbbf24
);
background-size: 200% auto;
color: transparent;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.animate-gradient {
animation: gradient 4s linear infinite;
}
@keyframes gradient {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
@keyframes fadeInFirst {
0% {
opacity: 0;
transform: translateY(20px);
}
100% {
opacity: 0.8;
transform: translateY(0);
}
}
@keyframes fadeInSecond {
0% {
opacity: 0;
transform: translateY(20px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInHours {
0% {
opacity: 0;
transform: translateX(20px);
margin-left: 0;
}
100% {
opacity: 0.6;
transform: translateX(0);
margin-left: 1rem;
}
}
@keyframes fadeInThird {
0% {
opacity: 0;
transform: translateY(20px);
}
100% {
opacity: 0.8;
transform: translateY(0);
}
}
@keyframes fadeInFourth {
0% {
opacity: 0;
transform: translateY(20px);
}
100% {
opacity: 0.6;
transform: translateY(0);
}
}
.animate-fade-in-first {
animation: fadeInFirst 0.7s ease-out forwards;
}
.animate-fade-in-second {
animation: fadeInSecond 0.7s ease-out forwards;
animation-delay: 0.4s;
}
.animate-slide-in-hours {
animation: slideInHours 0.7s ease-out forwards;
animation-delay: 0.6s;
}
.animate-fade-in-third {
animation: fadeInThird 0.7s ease-out forwards;
animation-delay: 0.8s;
}
.animate-fade-in-fourth {
animation: fadeInFourth 0.7s ease-out forwards;
animation-delay: 1s;
}
`}</style>
</div>
);
};
export default Stats;

View File

@@ -0,0 +1,175 @@
import { useState, useEffect } from "react";
import { Clock, CalendarClock, CodeXml, Computer } from "lucide-react";
import { ActivityGrid } from "@/components/about/stats-activity";
const DetailedStats = () => {
const [stats, setStats] = useState(null);
const [activity, setActivity] = useState(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
fetch("/api/wakatime/detailed")
.then(res => res.json())
.then(data => {
setStats(data.data);
setIsVisible(true);
})
.catch(error => {
console.error("Error fetching stats:", error);
});
fetch("/api/wakatime/activity")
.then(res => res.json())
.then(data => {
setActivity(data.data);
})
.catch(error => {
console.error("Error fetching activity:", error);
});
}, []);
if (!stats) return null;
const progressColors = [
"bg-red-bright",
"bg-orange-bright",
"bg-yellow-bright",
"bg-green-bright",
"bg-blue-bright",
"bg-purple-bright",
"bg-aqua-bright"
];
return (
<div className="flex flex-col gap-10 md:py-32 w-full max-w-[1200px] mx-auto px-4">
<h2 className="text-2xl md:text-4xl font-bold text-center text-yellow-bright">
Weekly Statistics
</h2>
{/* Top Stats Grid */}
<div className={`
grid grid-cols-1 md:grid-cols-2 gap-8
transition-all duration-700 transform
${isVisible ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0'}
`}>
{/* Total Time */}
<StatsCard
title="Total Time"
value={`${Math.round(stats.total_seconds / 3600 * 10) / 10}`}
unit="hours"
subtitle="this week"
color="text-yellow-bright"
icon={Clock}
iconColor="stroke-yellow-bright"
/>
{/* Daily Average */}
<StatsCard
title="Daily Average"
value={`${Math.round(stats.daily_average / 3600 * 10) / 10}`}
unit="hours"
subtitle="per day"
color="text-orange-bright"
icon={CalendarClock}
iconColor="stroke-orange-bright"
/>
{/* Editors */}
<StatsCard
title="Primary Editor"
value={stats.editors?.[0]?.name || "None"}
unit={`${Math.round(stats.editors?.[0]?.percent || 0)}%`}
subtitle="of the time"
color="text-blue-bright"
icon={CodeXml}
iconColor="stroke-blue-bright"
/>
{/* OS */}
<StatsCard
title="Operating System"
value={stats.operating_systems?.[0]?.name || "None"}
unit={`${Math.round(stats.operating_systems?.[0]?.percent || 0)}%`}
subtitle="of the time"
color="text-green-bright"
icon={Computer}
iconColor="stroke-green-bright"
/>
</div>
{/* Languages */}
<div className={`
transition-all duration-700 delay-200 transform
${isVisible ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0'}
`}>
<DetailCard
title="Languages"
items={stats.languages?.slice(0, 7).map((lang, index) => ({
name: lang.name,
value: Math.round(lang.percent) + '%',
time: Math.round(lang.total_seconds / 3600 * 10) / 10 + ' hrs',
color: progressColors[index % progressColors.length]
})) || []}
titleColor="text-purple-bright"
/>
{/* Activity Grid */}
{activity && (
<div className={`
transition-all duration-700 delay-300 transform
${isVisible ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0'}
`}>
<ActivityGrid data={activity} />
</div>
)}
</div>
</div>
);
};
const StatsCard = ({ title, value, unit, subtitle, color, icon: Icon, iconColor }) => (
<div className="bg-background border border-foreground/10 rounded-lg p-6 hover:border-yellow transition-colors flex items-center justify-center">
<div className="flex gap-3 items-center">
<Icon className={`w-6 h-6 ${iconColor}`} strokeWidth={1.5} />
<div className="flex flex-col items-center">
<div className={`${color} text-lg mb-1`}>{title}</div>
<div className="flex items-baseline gap-2">
<div className="text-2xl font-bold">{value}</div>
<div className="text-lg opacity-80">{unit}</div>
</div>
<div className="text-sm opacity-60 mt-1">{subtitle}</div>
</div>
</div>
</div>
);
const DetailCard = ({ title, items, titleColor }) => (
<div className="bg-background border border-foreground/10 rounded-lg p-6 hover:border-yellow transition-colors">
<div className={`${titleColor} mb-6 text-lg`}>{title}</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-6">
{items.map((item) => (
<div key={item.name} className="flex flex-col gap-2">
<div className="flex justify-between items-center">
<span className="text-base font-medium">{item.name}</span>
<span className="text-base opacity-80">{item.value}</span>
</div>
<div className="flex gap-3 items-center">
<div className="flex-grow h-2 bg-foreground/5 rounded-full overflow-hidden">
<div
className={`h-full ${item.color} rounded-full transition-all duration-1000`}
style={{
width: item.value,
opacity: '0.8'
}}
/>
</div>
<span className="text-sm text-foreground/60 min-w-[70px] text-right">{item.time}</span>
</div>
</div>
))}
</div>
</div>
);
export default DetailedStats;

View File

@@ -4,6 +4,7 @@ interface Cell {
alive: boolean;
next: boolean;
color: [number, number, number];
baseColor: [number, number, number]; // Original color
currentX: number;
currentY: number;
targetX: number;
@@ -12,8 +13,11 @@ interface Cell {
targetOpacity: number;
scale: number;
targetScale: number;
elevation: number; // For 3D effect
targetElevation: number;
transitioning: boolean;
transitionComplete: boolean;
rippleEffect: number; // For ripple animation
}
interface Grid {
@@ -24,17 +28,30 @@ interface Grid {
offsetY: number;
}
interface MousePosition {
x: number;
y: number;
isDown: boolean;
lastClickTime: number;
cellX: number;
cellY: number;
}
interface BackgroundProps {
layout?: 'index' | 'sidebar';
position?: 'left' | 'right';
}
const CELL_SIZE = 25;
const TRANSITION_SPEED = 0.1;
const SCALE_SPEED = 0.15;
const CYCLE_FRAMES = 120;
const TRANSITION_SPEED = 0.05;
const SCALE_SPEED = 0.05;
const CYCLE_FRAMES = 180;
const INITIAL_DENSITY = 0.15;
const SIDEBAR_WIDTH = 240;
const MOUSE_INFLUENCE_RADIUS = 150; // Radius of mouse influence in pixels
const COLOR_SHIFT_AMOUNT = 30; // Maximum color shift amount
const RIPPLE_SPEED = 0.2; // Speed of ripple propagation
const ELEVATION_FACTOR = 15; // Max height for 3D effect
const Background: React.FC<BackgroundProps> = ({
layout = 'index',
@@ -45,6 +62,14 @@ const Background: React.FC<BackgroundProps> = ({
const animationFrameRef = useRef<number>();
const frameCount = useRef(0);
const resizeTimeoutRef = useRef<NodeJS.Timeout>();
const mouseRef = useRef<MousePosition>({
x: -1000,
y: -1000,
isDown: false,
lastClickTime: 0,
cellX: -1,
cellY: -1
});
const randomColor = (): [number, number, number] => {
const colors = [
@@ -70,21 +95,28 @@ const Background: React.FC<BackgroundProps> = ({
const { cols, rows, offsetX, offsetY } = calculateGridDimensions(width, height);
const cells = Array(cols).fill(0).map((_, i) =>
Array(rows).fill(0).map((_, j) => ({
alive: Math.random() < INITIAL_DENSITY,
next: false,
color: randomColor(),
currentX: i,
currentY: j,
targetX: i,
targetY: j,
opacity: 0,
targetOpacity: 0,
scale: 0,
targetScale: 0,
transitioning: false,
transitionComplete: false
}))
Array(rows).fill(0).map((_, j) => {
const baseColor = randomColor();
return {
alive: Math.random() < INITIAL_DENSITY,
next: false,
color: [...baseColor] as [number, number, number],
baseColor: baseColor,
currentX: i,
currentY: j,
targetX: i,
targetY: j,
opacity: 0,
targetOpacity: 0,
scale: 0,
targetScale: 0,
elevation: 0,
targetElevation: 0,
transitioning: false,
transitionComplete: false,
rippleEffect: 0
};
})
);
const grid = { cells, cols, rows, offsetX, offsetY };
@@ -121,7 +153,7 @@ const Background: React.FC<BackgroundProps> = ({
if (grid.cells[col][row].alive) {
neighbors.count++;
neighbors.colors.push(grid.cells[col][row].color);
neighbors.colors.push(grid.cells[col][row].baseColor);
}
}
}
@@ -144,65 +176,230 @@ const Background: React.FC<BackgroundProps> = ({
};
const computeNextState = (grid: Grid) => {
// First, calculate the next state for all cells based on standard rules
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
const { count, colors } = countNeighbors(grid, i, j);
// Standard Conway's Game of Life rules
if (cell.alive) {
cell.next = count === 2 || count === 3;
} else {
cell.next = count === 3;
if (cell.next) {
cell.color = averageColors(colors);
cell.baseColor = averageColors(colors);
cell.color = [...cell.baseColor];
}
}
}
}
// Then, set up animations for cells that need to change state
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
if (cell.alive !== cell.next && !cell.transitioning) {
cell.transitioning = true;
cell.transitionComplete = false;
if (!cell.next) {
cell.targetScale = 0;
cell.targetOpacity = 0;
}
// Random delay for staggered animation effect
const delay = Math.random() * 800;
setTimeout(() => {
if (!cell.next) {
cell.targetScale = 0;
cell.targetOpacity = 0;
cell.targetElevation = 0;
} else {
cell.targetScale = 1;
cell.targetOpacity = 1;
cell.targetElevation = 0;
}
}, delay);
}
}
}
};
const updateCellAnimations = (grid: Grid) => {
const createRippleEffect = (grid: Grid, centerX: number, centerY: number) => {
const maxDistance = Math.max(grid.cols, grid.rows) / 2;
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
// Calculate distance from cell to ripple center
const dx = i - centerX;
const dy = j - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
// Only apply ripple to visible cells
if (cell.opacity > 0.1) {
// Delayed animation based on distance from center
setTimeout(() => {
cell.rippleEffect = 1; // Start ripple
// After a short time, reset ripple
setTimeout(() => {
cell.rippleEffect = 0;
}, 300 + distance * 50);
}, distance * 100);
}
}
}
};
const spawnCellAtPosition = (grid: Grid, x: number, y: number) => {
if (x >= 0 && x < grid.cols && y >= 0 && y < grid.rows) {
const cell = grid.cells[x][y];
if (!cell.alive && !cell.transitioning) {
cell.alive = true;
cell.next = true;
cell.transitioning = true;
cell.transitionComplete = false;
cell.baseColor = randomColor();
cell.color = [...cell.baseColor];
cell.targetScale = 1;
cell.targetOpacity = 1;
cell.targetElevation = 0;
// Create a small ripple from the new cell
createRippleEffect(grid, x, y);
}
}
};
const updateCellAnimations = (grid: Grid) => {
const mouseX = mouseRef.current.x;
const mouseY = mouseRef.current.y;
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
// Smooth transitions
cell.opacity += (cell.targetOpacity - cell.opacity) * TRANSITION_SPEED;
cell.scale += (cell.targetScale - cell.scale) * SCALE_SPEED;
cell.elevation += (cell.targetElevation - cell.elevation) * SCALE_SPEED;
// Apply mouse interaction
const cellCenterX = grid.offsetX + i * CELL_SIZE + CELL_SIZE / 2;
const cellCenterY = grid.offsetY + j * CELL_SIZE + CELL_SIZE / 2;
const dx = cellCenterX - mouseX;
const dy = cellCenterY - mouseY;
const distanceToMouse = Math.sqrt(dx * dx + dy * dy);
// Color wave effect based on mouse position
if (distanceToMouse < MOUSE_INFLUENCE_RADIUS && cell.opacity > 0.1) {
// Calculate color adjustment based on distance
const influenceFactor = 1 - (distanceToMouse / MOUSE_INFLUENCE_RADIUS);
// Wave effect with sine function
const waveOffset = (frameCount.current * 0.05 + distanceToMouse * 0.05) % (Math.PI * 2);
const waveFactor = (Math.sin(waveOffset) * 0.5 + 0.5) * influenceFactor;
// Adjust color based on wave
cell.color = [
Math.min(255, Math.max(0, cell.baseColor[0] + COLOR_SHIFT_AMOUNT * waveFactor)),
Math.min(255, Math.max(0, cell.baseColor[1] - COLOR_SHIFT_AMOUNT * waveFactor)),
Math.min(255, Math.max(0, cell.baseColor[2] + COLOR_SHIFT_AMOUNT * waveFactor))
] as [number, number, number];
// 3D elevation effect when mouse is close
cell.targetElevation = ELEVATION_FACTOR * influenceFactor;
} else {
// Gradually return to base color when mouse is away
cell.color[0] += (cell.baseColor[0] - cell.color[0]) * 0.1;
cell.color[1] += (cell.baseColor[1] - cell.color[1]) * 0.1;
cell.color[2] += (cell.baseColor[2] - cell.color[2]) * 0.1;
// Reset elevation when mouse moves away
cell.targetElevation = 0;
}
// Handle cell state transitions
if (cell.transitioning) {
if (!cell.next && cell.scale < 0.05) {
// When a cell is completely faded out, update its alive state
if (!cell.next && cell.opacity < 0.01 && cell.scale < 0.01) {
cell.alive = false;
cell.transitioning = false;
cell.transitionComplete = true;
cell.scale = 0;
cell.opacity = 0;
cell.scale = 0;
cell.elevation = 0;
}
// When a new cell is born
else if (cell.next && !cell.alive && !cell.transitionComplete) {
cell.alive = true;
cell.transitioning = false;
cell.transitionComplete = true;
cell.targetScale = 1;
cell.targetOpacity = 1;
}
else if (cell.next && !cell.alive && cell.transitionComplete) {
cell.transitioning = true;
cell.targetScale = 1;
cell.targetOpacity = 1;
}
}
// Gradually decrease ripple effect
if (cell.rippleEffect > 0) {
cell.rippleEffect = Math.max(0, cell.rippleEffect - RIPPLE_SPEED);
}
}
}
};
const handleMouseDown = (e: MouseEvent) => {
if (!gridRef.current || !canvasRef.current) return;
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
mouseRef.current.isDown = true;
mouseRef.current.lastClickTime = Date.now();
const grid = gridRef.current;
// Calculate which cell was clicked
const cellX = Math.floor((mouseX - grid.offsetX) / CELL_SIZE);
const cellY = Math.floor((mouseY - grid.offsetY) / CELL_SIZE);
if (cellX >= 0 && cellX < grid.cols && cellY >= 0 && cellY < grid.rows) {
mouseRef.current.cellX = cellX;
mouseRef.current.cellY = cellY;
const cell = grid.cells[cellX][cellY];
if (cell.alive) {
// Create ripple effect from existing cell
createRippleEffect(grid, cellX, cellY);
} else {
// Spawn new cell at empty position
spawnCellAtPosition(grid, cellX, cellY);
}
}
};
const handleMouseMove = (e: MouseEvent) => {
if (!canvasRef.current) return;
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
mouseRef.current.x = e.clientX - rect.left;
mouseRef.current.y = e.clientY - rect.top;
};
const handleMouseUp = () => {
mouseRef.current.isDown = false;
};
const handleMouseLeave = () => {
mouseRef.current.isDown = false;
mouseRef.current.x = -1000;
mouseRef.current.y = -1000;
};
const setupCanvas = (canvas: HTMLCanvasElement, width: number, height: number) => {
const ctx = canvas.getContext('2d');
if (!ctx) return;
@@ -264,12 +461,19 @@ const Background: React.FC<BackgroundProps> = ({
gridRef.current = initGrid(displayWidth, displayHeight);
}
// Add mouse event listeners
canvas.addEventListener('mousedown', handleMouseDown, { signal });
canvas.addEventListener('mousemove', handleMouseMove, { signal });
canvas.addEventListener('mouseup', handleMouseUp, { signal });
canvas.addEventListener('mouseleave', handleMouseLeave, { signal });
const animate = () => {
if (signal.aborted) return;
frameCount.current++;
if (gridRef.current) {
// Every CYCLE_FRAMES, compute the next state
if (frameCount.current % CYCLE_FRAMES === 0) {
computeNextState(gridRef.current);
}
@@ -289,19 +493,75 @@ const Background: React.FC<BackgroundProps> = ({
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
if (cell.opacity > 0.01) {
// Draw all transitioning cells, even if they're fading out
if ((cell.alive || cell.targetOpacity > 0) && cell.opacity > 0.01) {
const [r, g, b] = cell.color;
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.globalAlpha = cell.opacity * 0.8;
// Apply ripple and elevation effects to opacity
const rippleBoost = cell.rippleEffect * 0.4; // Boost opacity during ripple
ctx.globalAlpha = Math.min(1, cell.opacity * 0.8 + rippleBoost);
const scaledSize = cellSize * cell.scale;
const xOffset = (cellSize - scaledSize) / 2;
const yOffset = (cellSize - scaledSize) / 2;
// Apply 3D elevation effect
const elevationOffset = cell.elevation;
const x = grid.offsetX + i * CELL_SIZE + (CELL_SIZE - cellSize) / 2 + xOffset;
const y = grid.offsetY + j * CELL_SIZE + (CELL_SIZE - cellSize) / 2 + yOffset;
const y = grid.offsetY + j * CELL_SIZE + (CELL_SIZE - cellSize) / 2 + yOffset - elevationOffset;
const scaledRoundness = roundness * cell.scale;
// Draw shadow for 3D effect if cell has elevation
if (elevationOffset > 1) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.beginPath();
ctx.moveTo(x + scaledRoundness, y + elevationOffset + 5);
ctx.lineTo(x + scaledSize - scaledRoundness, y + elevationOffset + 5);
ctx.quadraticCurveTo(x + scaledSize, y + elevationOffset + 5, x + scaledSize, y + elevationOffset + 5 + scaledRoundness);
ctx.lineTo(x + scaledSize, y + elevationOffset + 5 + scaledSize - scaledRoundness);
ctx.quadraticCurveTo(x + scaledSize, y + elevationOffset + 5 + scaledSize, x + scaledSize - scaledRoundness, y + elevationOffset + 5 + scaledSize);
ctx.lineTo(x + scaledRoundness, y + elevationOffset + 5 + scaledSize);
ctx.quadraticCurveTo(x, y + elevationOffset + 5 + scaledSize, x, y + elevationOffset + 5 + scaledSize - scaledRoundness);
ctx.lineTo(x, y + elevationOffset + 5 + scaledRoundness);
ctx.quadraticCurveTo(x, y + elevationOffset + 5, x + scaledRoundness, y + elevationOffset + 5);
ctx.fill();
// Draw side of elevated cell
const sideHeight = elevationOffset;
ctx.fillStyle = `rgba(${r*0.7}, ${g*0.7}, ${b*0.7}, ${ctx.globalAlpha})`;
// Left side
ctx.beginPath();
ctx.moveTo(x, y + scaledRoundness);
ctx.lineTo(x, y + scaledSize - scaledRoundness + sideHeight);
ctx.lineTo(x + scaledRoundness, y + scaledSize + sideHeight);
ctx.lineTo(x + scaledRoundness, y + scaledSize);
ctx.lineTo(x, y + scaledSize - scaledRoundness);
ctx.fill();
// Right side
ctx.beginPath();
ctx.moveTo(x + scaledSize, y + scaledRoundness);
ctx.lineTo(x + scaledSize, y + scaledSize - scaledRoundness + sideHeight);
ctx.lineTo(x + scaledSize - scaledRoundness, y + scaledSize + sideHeight);
ctx.lineTo(x + scaledSize - scaledRoundness, y + scaledSize);
ctx.lineTo(x + scaledSize, y + scaledSize - scaledRoundness);
ctx.fill();
// Bottom side
ctx.fillStyle = `rgba(${r*0.5}, ${g*0.5}, ${b*0.5}, ${ctx.globalAlpha})`;
ctx.beginPath();
ctx.moveTo(x + scaledRoundness, y + scaledSize);
ctx.lineTo(x + scaledSize - scaledRoundness, y + scaledSize);
ctx.lineTo(x + scaledSize - scaledRoundness, y + scaledSize + sideHeight);
ctx.lineTo(x + scaledRoundness, y + scaledSize + sideHeight);
ctx.fill();
}
// Draw main cell with original color
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.beginPath();
ctx.moveTo(x + scaledRoundness, y);
ctx.lineTo(x + scaledSize - scaledRoundness, y);
@@ -313,6 +573,38 @@ const Background: React.FC<BackgroundProps> = ({
ctx.lineTo(x, y + scaledRoundness);
ctx.quadraticCurveTo(x, y, x + scaledRoundness, y);
ctx.fill();
// Draw highlight on top for 3D effect
if (elevationOffset > 1) {
ctx.fillStyle = `rgba(255, 255, 255, ${0.2 * elevationOffset / ELEVATION_FACTOR})`;
ctx.beginPath();
ctx.moveTo(x + scaledRoundness, y);
ctx.lineTo(x + scaledSize - scaledRoundness, y);
ctx.quadraticCurveTo(x + scaledSize, y, x + scaledSize, y + scaledRoundness);
ctx.lineTo(x + scaledSize, y + scaledSize/3);
ctx.lineTo(x, y + scaledSize/3);
ctx.lineTo(x, y + scaledRoundness);
ctx.quadraticCurveTo(x, y, x + scaledRoundness, y);
ctx.fill();
}
// Draw ripple effect
if (cell.rippleEffect > 0) {
const rippleRadius = cell.rippleEffect * cellSize * 2;
const rippleAlpha = (1 - cell.rippleEffect) * 0.5;
ctx.strokeStyle = `rgba(255, 255, 255, ${rippleAlpha})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(
x + scaledSize / 2,
y + scaledSize / 2,
rippleRadius,
0,
Math.PI * 2
);
ctx.stroke();
}
}
}
}
@@ -336,14 +628,14 @@ const Background: React.FC<BackgroundProps> = ({
clearTimeout(resizeTimeoutRef.current);
}
};
}, []); // Empty dependency array since we're managing state internally
}, [layout]); // Added layout as a dependency since it's used in the effect
const getContainerClasses = () => {
if (layout === 'index') {
return 'fixed inset-0 -z-10';
}
const baseClasses = 'fixed top-0 bottom-0 hidden lg:block -z-10 pointer-events-none';
const baseClasses = 'fixed top-0 bottom-0 hidden lg:block -z-10';
return position === 'left'
? `${baseClasses} left-0`
: `${baseClasses} right-0`;
@@ -353,9 +645,9 @@ const Background: React.FC<BackgroundProps> = ({
<div className={getContainerClasses()}>
<canvas
ref={canvasRef}
className="w-full h-full bg-black"
className="w-full h-full bg-black cursor-pointer"
/>
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" />
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm pointer-events-none" />
</div>
);
};

View File

@@ -0,0 +1,34 @@
import * as React from "react";
import Giscus from "@giscus/react";
const id = "inject-comments";
export const Comments = () => {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
return (
<div id={id}>
{mounted ? (
<Giscus
id={id}
repo="timmypidashev/web"
repoId="MDEwOlJlcG9zaXRvcnkzODYxMjk5Mjk="
category="Blog & Project Comments"
categoryId="DIC_kwDOFwPgCc4CpKtV"
theme="https://timmypidashev.us-sea-1.linodeobjects.com/comments.css"
mapping="pathname"
strict="0"
reactionsEnabled="1"
emitMetadata="0"
inputPosition="bottom"
lang="en"
loading="lazy"
/>
) : null}
</div>
);
};

View File

@@ -0,0 +1,38 @@
import React from "react";
import { RssIcon, TagIcon, TrendingUpIcon } from "lucide-react";
export const BlogHeader = () => {
return (
<div className="w-full max-w-6xl mx-auto px-4 pt-24 sm:pt-24">
<h1 className="text-2xl sm:text-3xl font-bold text-purple mb-3 text-center px-4 leading-relaxed">
Latest Thoughts <br className="sm:hidden" />
& Writings
</h1>
<div className="flex flex-wrap justify-center gap-4 mb-12 text-sm sm:text-base">
<a
href="/rss"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-background border border-foreground/20 text-orange hover:text-orange-bright hover:border-orange/50 transition-colors duration-200"
>
<RssIcon className="w-4 h-4" />
<span>RSS Feed</span>
</a>
<a
href="/blog/tags"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-background border border-foreground/20 text-aqua hover:text-aqua-bright hover:border-aqua/50 transition-colors duration-200"
>
<TagIcon className="w-4 h-4" />
<span>Browse Tags</span>
</a>
<a
href="/blog/popular"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-background border border-foreground/20 text-blue hover:text-blue-bright hover:border-blue/50 transition-colors duration-200"
>
<TrendingUpIcon className="w-4 h-4" />
<span>Most Popular</span>
</a>
</div>
</div>
);
};

View File

@@ -27,12 +27,7 @@ const formatDate = (dateString: string) => {
export const BlogPostList = ({ posts }: BlogPostListProps) => {
return (
<div className="w-full max-w-6xl mx-auto pt-24 sm:pt-24">
<h1 className="text-2xl sm:text-3xl font-bold text-purple mb-12 text-center px-4 leading-relaxed">
Latest Thoughts <br className="sm:hidden" />
& Writings
</h1>
<div className="w-full max-w-6xl mx-auto">
<ul className="space-y-6 md:space-y-10">
{posts.map((post) => (
<li key={post.slug} className="group px-4 md:px-0">

View File

@@ -0,0 +1,114 @@
import React, { useMemo } from 'react';
interface BlogPost {
title: string;
data: {
tags: string[];
};
}
interface TagListProps {
posts: BlogPost[];
}
const TagList: React.FC<TagListProps> = ({ posts }) => {
const spectrumColors = [
'text-red-bright',
'text-orange-bright',
'text-yellow-bright',
'text-green-bright',
'text-aqua-bright',
'text-blue-bright',
'text-purple-bright'
];
const tagData = useMemo(() => {
if (!Array.isArray(posts)) return [];
const tagMap = new Map();
posts.forEach(post => {
if (post?.data?.tags && Array.isArray(post.data.tags)) {
post.data.tags.forEach(tag => {
if (!tagMap.has(tag)) {
tagMap.set(tag, {
name: tag,
count: 1
});
} else {
const data = tagMap.get(tag);
data.count++;
}
});
}
});
const tagArray = Array.from(tagMap.values());
const maxCount = Math.max(...tagArray.map(t => t.count));
return tagArray
.sort((a, b) => b.count - a.count)
.map((tag, index) => ({
...tag,
color: spectrumColors[index % spectrumColors.length],
frequency: tag.count / maxCount
}));
}, [posts]);
if (tagData.length === 0) {
return (
<div className="flex-1 w-full min-h-[16rem] flex items-center justify-center text-foreground opacity-60">
No tags available
</div>
);
}
return (
<div className="flex-1 w-full bg-background p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{tagData.map(({ name, count, color, frequency }) => (
<a
key={name}
href={`/blog/tags/${encodeURIComponent(name)}`}
className={`
group relative
flex flex-col items-center justify-center
min-h-[5rem]
px-6 py-4 rounded-lg
text-xl
transition-all duration-300 ease-in-out
hover:scale-105
hover:bg-foreground/5
${color}
`}
>
{/* Main tag display */}
<div className="font-medium text-center">
#{name}
</div>
{/* Post count */}
<div className="mt-2 text-base opacity-60">
{count} post{count !== 1 ? 's' : ''}
</div>
{/* Background gradient */}
<div
className="absolute inset-0 -z-10 rounded-lg opacity-10"
style={{
background: `
linear-gradient(
45deg,
currentColor ${frequency * 100}%,
transparent
)
`
}}
/>
</a>
))}
</div>
</div>
);
};
export default TagList;

View File

@@ -3,7 +3,7 @@ title: My First Post!
description: A quick introduction
author: Timothy Pidashev
tags: [greeting]
date: January 9, 2025
date: 2025-01-09
image: "/blog/my-first-post/thumbnail.png"
imagePosition: "center 30%"
---

View File

@@ -7,7 +7,9 @@ export const collections = {
description: z.string(),
author: z.string(),
tags: z.array(z.string()),
date: z.string(),
date: z.coerce.date().transform((date) => {
return new Date(date.setUTCHours(12, 0, 0, 0));
}),
image: z.string().optional(),
imagePosition: z.string().optional(),
}),

View File

@@ -0,0 +1,38 @@
---
title: Thinkpad T440p Coreboot Guide
description: The definitive guide on corebooting a Thinkpad T440p
author: Timothy Pidashev
tags: [t440p, coreboot, thinkpad]
date: 2025-01-15
image: "/blog/thinkpad-t440p-coreboot-guide/thumbnail.png"
---
> **Interactive Script Available!**
> Want to skip the manual steps in this guide?
> I've created an interactive script that can automate the entire process step by step as you follow along.
> Simply run the following command in your terminal to get started:
>
> ```
> curl -fsSL https://timmypidashev.dev/scripts/run.sh | sh -s -- -t coreboot-t440p
> ```
> NOTE: This script supports Arch, Debian, Fedora, Gentoo, and Nix linux distributions!
## Getting Started
The Thinkpad T440p is a powerful and versatile laptop that can be further enhanced by installing coreboot,
an open-source BIOS replacement. This guide will walk you through the process of corebooting your T440p,
including flashing the BIOS chip and installing the necessary software.
## What You'll Need
Before getting started corebooting your T440p, make sure you have the following:
- **Thinkpad T440p**: This guide is specifically for the T440p model.
- **CH341A Programmer**: This is a USB device used to flash the BIOS chip.
- **Screwdriver**: A torx screwdriver is needed to open the laptop.
## Disassembling the Laptop
1. **Power off your laptop**: Make sure your T440p is completely powered off and unplugged from any power source.
2. **Remove the battery**: Flip the laptop over and remove the battery by sliding the latch to the unlock position and lifting it out.
3. **Unscrew the back panel**: Use a torx screwdriver to remove the screws securing the back panel.
## Locating the EEPROM Chips

View File

@@ -0,0 +1,86 @@
---
title: Thinkpad T440p Modification Guide
description: You purchased a T440p, now what?
author: Timothy Pidashev
tags: [t440p, mods, coreboot, thinkpad]
date: 2025-01-15
image: "/blog/thinkpad-t440p-modification-guide/thumbnail.png"
---
## The T440p
Whether for privacy related reasons, coreboot, or someones advice on the internet,
you are now the proud owner of a T440p. Now what? Well, I have been daily driving
this laptop for over two years now, and would like to share my knowledge on this
lovely machine. If followed properly, this guide should help any privacy seeking
individual or programmer how to setup the "reasonably" perfect T440p.
## Buying the Right Model
Although the T440p comes in various configurations and specs, when searching for
one online there are two things to consider.
1. Online Marketplace
* Purchasing from the right marketplace is important to consider, and while trusted
vendors like Amazon might be preferred, consider Ebay or AliExpress.
* I personally have only purchased my thinkpad's on Ebay, as there are generally more listings
available from companies reselling retired units, usually at a steep discount.
2. Dedicated GPU
* The T440p motherboard comes in two different varieties, one with
a dGPU and the other without. There is only one dGPU model, which is the NVIDIA GT 730M.
Featuring 2GB of VRAM, it will work, however if your looking for longer battery life and
an easier coreboot config should you choose to coreboot, I would recommend sticking to
a non dGPU variant.
* Finding a dGPU variant is quite difficult, as many online
sellers don't always list the motherboard spec, making things quite the guessing game.
When I was shopping for one, my strategy was to purchase the dGPU motherboard on its own,
and then a T440p laptop listed with a dead motherboard, as I was going to swap it out anyways.
3. Quality
* Finding the perfect T440p is hard, and you will likely end up purchasing one that looks ok
in pictures, but comes with a cracked palmrest or front panel. Consider purchasing one which
looks good, and then replacing any cracked or aged parts should you choose to do so in the future.
* T440p plastics are aged. Although this machine is an absolute brick, which can probably be thrown
at the ground without any major damage, it will definitely chip and crack. I myself have replaced my
palm rest/keyboard cover thrice, as every half a year or so I will open the laptop in the morning to
find that my careless "throw it in the backpack" has finally cracked the palmrest yet again.
## Screen
When it comes to the screen, you really don't want to get one of poor quality, especially since the
lousy 1366x768 panel is not great nowadays. Generally, I would recommend going for an ips 1080p panel,
as this is generally most the most supported. I purchased this panel from amazon for ~$60USD and have
never looked back.
## Keyboard
## Trackpad
## Battery
## CPU
The T440p has a trick up its sleeve. The processor can be swapped out and replaced, allowing for an upgrade!
There are many models out there, however some aren't recommended due to thermal constraints, so finding the
right balance can be tough.
## RAM
## Storage
## WLAN
## WAN
## MISC
1. Fingerprint Reader
2. Disc Reader
3. Webcam & Microphone

View File

@@ -1,104 +0,0 @@
---
title: "I corebooted my T440p, here's how I did it."
author: "Timothy Pidashev"
date: "2024/06/05"
description: "This is a sample MDX file."
tags: ["coreboot", "t440p", "dgpu"]
---
```python
# discord api
import discord
from discord.ext import commands
# custom utilities
from Utilities import log
log = log.Logger("errors")
class Errors(commands.Cog):
def __init__(self, client):
self.client = client
@commands.Cog.listener()
async def on_ready(self):
await log.info("Errors cog loaded.")
@commands.Cog.listener()
async def on_command_error(self, context, error):
if isinstance(error, commands.CheckFailure):
await context.reply(
"You are not priveleged enough to use this command.",
mention_author=False
)
else:
await context.reply(
f"**Error**\n```diff\n- {error}```",
mention_author=False
)
def setup(client):
client.add_cog(Errors(client))
```
# Heading 1
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
*Italic Text*
_Italic Text_
**Bold Text**
__Bold Text__
* Bullet List
* Item 1
* Item 2
* Subitem 1
* Subitem 2
1. Numbered List
1. Item 1
2. Item 2
- Subitem 1
- Subitem 2
[Link Text](https://example.com)
![Image Alt Text](https://example.com/image.jpg)
> Blockquote
>
> Lorem ipsum dolor sit amet, consectetur adipiscing elit.
`Inline Code`
| Table Header 1 | Table Header 2 |
|----------------|----------------|
| Table Row 1 | Table Row 1 |
| Table Row 2 | Table Row 2 |
<sup>Superscript Text</sup>
<sub>Subscript Text</sub>
<mark>Highlighted Text</mark>
<ins>Underlined Text</ins>
<del>Strikethrough Text</del>
<abbr title="Abbreviation">Abbreviation</abbr>

7
src/src/env.d.ts vendored
View File

@@ -1,2 +1,9 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />
declare namespace App {
interface Locals {
user: import("./lib/user").User | null;
session: import("./lib/session").Session | null;
}
}

View File

@@ -4,16 +4,13 @@ import { ClientRouter } from "astro:transitions";
import Header from "@/components/header";
import Footer from "@/components/footer";
import Background from "@/components/background";
export interface Props {
title: string;
description: string;
}
const { title, description } = Astro.props;
const ogImage = "https://timmypidashev.dev/og-image.jpg";
---
<html lang="en">
<head>
<title>{title}</title>
@@ -31,12 +28,11 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
<meta name="description" content={description} />
<!-- Also used in OpenGraph for social media sharing -->
<meta property="og:description" content={description} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/jpeg" href="/me.jpeg" />
<ClientRouter
defaultTransition={false}
handleFocus={false}
/>
<style>
::view-transition-new(:root) {
animation: none;
@@ -45,24 +41,24 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
::view-transition-old(:root) {
animation: 90ms ease-out both fade-out;
}
@keyframes fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
</style>
</head>
<body class="bg-background text-foreground">
<body class="bg-background text-foreground min-h-screen flex flex-col">
<Header client:load />
<main>
<div class="max-w-5xl mx-auto pt-12 px-4 py-8">
<main class="flex-1 flex flex-col">
<div class="max-w-5xl mx-auto pt-12 px-4 py-8 flex-1">
<Background layout="content" position="right" client:only="react" transition:persist />
<slot />
<Background layout="content" position="left" client:only="react" transition:persist />
</div>
</main>
<Footer client:load transition:persist />
<div class="mt-auto">
<Footer client:load transition:persist />
</div>
<script>
document.addEventListener("astro:after-navigation", () => {
window.scrollTo(0, 0);

View File

@@ -35,7 +35,7 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
<meta name="description" content={description} />
<!-- Also used in OpenGraph for social media sharing -->
<meta property="og:description" content={description} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/jpeg" href="/me.jpeg" />
<link rel="sitemap" href="/sitemap-index.xml" />
<ClientRouter />
</head>

View File

@@ -2,12 +2,12 @@ import { type Article, type Person, type WebSite, type WithContext } from "schem
import type { CollectionEntry } from 'astro:content';
export const blogWebsite: WithContext<WebSite> = {
'@context': 'https://schema.org',
'@type': 'WebSite',
"@context": "https://schema.org",
"@type": "WebSite",
url: `${import.meta.env.SITE}/blog/`,
name: 'Dzmitry Kozhukh blog',
description: 'Frontend insights',
inLanguage: 'en_US',
name: "Timothy Pidsashev - Blog",
description: "Timothy Pidsashev's blog",
inLanguage: "en_US",
};
export const mainWebsite: WithContext<WebSite> = {

View File

@@ -1,16 +1,16 @@
---
import IndexLayout from "@/layouts/index.astro";
import GlitchText from "@/components/404/glitched-text";
const title = "404 Not Found";
---
<IndexLayout content={{ title: "404 | Timothy Pidashev" }}>
<main class="min-h-screen flex flex-col items-center justify-center p-4 text-center">
<h1 class="text-6xl font-bold mb-4">404</h1>
<p class="text-xl mb-8">Whoops! This page doesn't exist.</p>
<GlitchText client:only />
<p class="text-xl text-orange mb-8">Whoops! This page doesn't exist :(</p>
<button
onclick="window.history.back()"
class="underline hover:opacity-70 transition-opacity"
class="underline text-green hover:opacity-70 transition-opacity"
>
go back
</button>

View File

@@ -2,6 +2,8 @@
import "@/style/globals.css"
import ContentLayout from "@/layouts/content.astro";
import Intro from "@/components/about/intro";
import AllTimeStats from "@/components/about/stats-alltime";
import DetailedStats from "@/components/about/stats-detailed";
import Timeline from "@/components/about/timeline";
import CurrentFocus from "@/components/about/current-focus";
import OutsideCoding from "@/components/about/outside-coding";
@@ -14,6 +16,14 @@ import OutsideCoding from "@/components/about/outside-coding";
<section class="h-screen flex items-center justify-center">
<Intro client:load />
</section>
<section class="flex items-center justify-center py-16">
<AllTimeStats client:only="react" />
</section>
<section class="flex items-center justify-center py-16">
<DetailedStats client:only="react" />
</section>
<section class="flex items-center justify-center py-16">
<Timeline client:load />
@@ -27,4 +37,4 @@ import OutsideCoding from "@/components/about/outside-coding";
<OutsideCoding client:load />
</section>
</div>
</MainLayout>
</ContentLayout>

View File

@@ -0,0 +1,32 @@
import type { APIRoute } from 'astro';
export const GET: APIRoute = async () => {
const WAKATIME_API_KEY = import.meta.env.WAKATIME_API_KEY;
try {
const response = await fetch(
'https://wakatime.com/api/v1/users/current/summaries?range=last_6_months', {
headers: {
'Authorization': `Basic ${Buffer.from(WAKATIME_API_KEY).toString('base64')}`
}
}
);
const data = await response.json();
return new Response(
JSON.stringify({ data: data.data }),
{
status: 200,
headers: {
'Content-Type': 'application/json'
}
}
);
} catch (error) {
console.error('API Error:', error);
return new Response(
JSON.stringify({ error: 'Failed to fetch WakaTime data' }),
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,22 @@
import type { APIRoute } from "astro";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
export const GET: APIRoute = async () => {
try {
const { stdout } = await execAsync(`curl -H "Authorization: Basic ${import.meta.env.WAKATIME_API_KEY}" https://wakatime.com/api/v1/users/current/all_time_since_today`);
return new Response(stdout, {
status: 200,
headers: {
"Content-Type": "application/json"
}
});
} catch (error) {
return new Response(JSON.stringify({ error: "Failed to fetch stats" }), {
status: 500
});
}
}

View File

@@ -0,0 +1,33 @@
// src/pages/api/wakatime/detailed.ts
import type { APIRoute } from 'astro';
export const GET: APIRoute = async () => {
const WAKATIME_API_KEY = import.meta.env.WAKATIME_API_KEY;
try {
const response = await fetch(
'https://wakatime.com/api/v1/users/current/stats/last_7_days?timeout=15', {
headers: {
'Authorization': `Basic ${Buffer.from(WAKATIME_API_KEY).toString('base64')}`
}
}
);
const data = await response.json();
return new Response(
JSON.stringify({ data: data.data }),
{
status: 200,
headers: {
'Content-Type': 'application/json'
}
}
);
} catch (error) {
console.error('API Error:', error);
return new Response(
JSON.stringify({ error: 'Failed to fetch WakaTime data' }),
{ status: 500 }
);
}
}

View File

@@ -1,24 +1,37 @@
---
import { CollectionEntry, getCollection } from "astro:content";
import { getCollection } from "astro:content";
import { Image } from "astro:assets";
import ContentLayout from "@/layouts/content.astro";
import { getArticleSchema } from "@/lib/structuredData";
import { blogWebsite } from "@/lib/structuredData";
import { Comments } from "@/components/blog/comments";
interface Props {
post: CollectionEntry<"blog">;
// This is a dynamic route in SSR mode
const { slug } = Astro.params;
// Fetch blog posts
const posts = await getCollection("blog");
const post = posts.find(post => post.slug === slug);
if (!post) {
return new Response(null, {
status: 404,
statusText: 'Not found'
});
}
export async function getStaticPaths() {
const posts = await getCollection("blog");
return posts.map((post) => ({
params: { slug: post.slug },
props: post,
}));
}
const post = Astro.props;
// Dynamically render the content
const { Content } = await post.render();
// Format the date
const formattedDate = new Date(post.data.date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric"
});
const articleStructuredData = getArticleSchema(post);
const breadcrumbsStructuredData = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
@@ -37,13 +50,17 @@ const breadcrumbsStructuredData = {
},
],
};
const jsonLd = {
"@context": "https://schema.org",
"@graph": [articleStructuredData, breadcrumbsStructuredData, blogWebsite],
};
---
<ContentLayout>
<ContentLayout
title={`${post.data.title} | Timothy Pidashev`}
description={post.data.description}
>
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
<div class="relative max-w-8xl mx-auto">
<article class="prose prose-lg mx-auto max-w-4xl">
@@ -61,16 +78,29 @@ const jsonLd = {
)}
<h1 class="text-3xl pt-4">{post.data.title}</h1>
<p class="lg:text-2xl sm:text-lg">{post.data.description}</p>
<p class="text-lg">{post.data.author} | {post.data.date}</p>
<div class="flex flex-wrap gap-2 pb-4">
<div class="mt-4 md:mt-6">
<div class="flex flex-wrap items-center gap-2 md:gap-3 text-sm md:text-base text-foreground/80">
<span class="text-orange">{post.data.author}</span>
<span class="text-foreground/50">•</span>
<time dateTime={post.data.date instanceof Date ? post.data.date.toISOString() : post.data.date} class="text-blue">
{formattedDate}
</time>
</div>
</div>
<div class="flex flex-wrap gap-2 mt-4 md:mt-6">
{post.data.tags.map((tag) => (
<span class="text-sm px-2 py-1 bg-muted rounded-full">
<span
class="text-xs md:text-base text-aqua hover:text-aqua-bright transition-colors duration-200"
onclick={`window.location.href='/blog/tag/${tag}'`}
>
#{tag}
</span>
))}
</div>
<hr class="bg-orange" />
<Content />
<div class="prose prose-invert max-w-none">
<Content />
</div>
</article>
<Comments client:idle />
</div>
</ContentLayout>

View File

@@ -1,19 +1,29 @@
---
import { getCollection } from "astro:content";
import ContentLayout from "@/layouts/content.astro";
import { BlogHeader } from "@/components/blog/header";
import { BlogPostList } from "@/components/blog/post-list";
const posts = (await getCollection("blog", ({ data }) => {
return data.isDraft !== true;
})).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
})).sort((a, b) => {
return b.data.date.valueOf() - a.data.date.valueOf()
}).map(post => ({
...post,
data: {
...post.data,
date: post.data.date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric"
})
}
}));
---
<ContentLayout
title="Blog | Timothy Pidashev"
description="My experiences and technical insights into software development and the ever-evolving world of programming."
>
<BlogHeader client:load />
<BlogPostList posts={posts} client:load />
</ContentLayout>

View File

@@ -0,0 +1,28 @@
---
import { getCollection } from "astro:content";
import ContentLayout from "@/layouts/content.astro";
import TagList from "@/components/blog/tag-list";
const posts = (await getCollection("blog", ({ data }) => {
return data.isDraft !== true;
})).sort((a, b) => {
return b.data.date.valueOf() - a.data.date.valueOf()
}).map(post => ({
...post,
data: {
...post.data,
date: post.data.date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric"
})
}
}));
---
<ContentLayout
title="Blog | Timothy Pidashev"
description="My experiences and technical insights into software development and the ever-evolving world of programming."
>
<TagList posts={posts} />
</ContentLayout>

View File

@@ -1,4 +1,6 @@
---
export const prerender = false;
import "@/style/globals.css"
import IndexLayout from "@/layouts/index.astro";
@@ -7,7 +9,7 @@ import Hero from "@/components/hero";
<IndexLayout
title="Timothy Pidashev"
description="Turning coffee into code since 2018."
description="Software engineer passionate about systems programming, artificial intelligence, and building innovative solutions. Personal site featuring my projects, technical blog, and research."
>
<Hero client:load />
</IndexLayout>

View File

@@ -1,6 +1,9 @@
---
export const prerender = true;
import { getCollection } from "astro:content";
import ContentLayout from "@/layouts/content.astro";
import { Comments } from "@/components/blog/comments";
export async function getStaticPaths() {
const projects = await getCollection("projects");
@@ -60,4 +63,5 @@ const { Content } = await project.render();
<Content />
</div>
</article>
<Comments client:idle />
</ContentLayout>

29
src/src/pages/rss.ts Normal file
View File

@@ -0,0 +1,29 @@
import rss from "@astrojs/rss";
import { getCollection } from "astro:content";
import type { APIContext } from "astro";
export async function GET(context: APIContext) {
const blog = await getCollection("blog");
const sortedPosts = blog
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
return rss({
title: "Timothy Pidashev",
description: "My experiences and technical insights into software development and the ever-evolving world of programming.",
site: context.site!,
items: sortedPosts.map((post) => ({
title: post.data.title,
pubDate: post.data.date,
description: post.data.description,
link: `/blog/${post.slug}/`,
author: post.data.author,
categories: post.data.tags,
enclosure: post.data.image ? {
url: new URL(`blog/${post.slug}/thumbnail.png`, context.site).toString(),
type: 'image/jpeg',
length: 0
} : undefined
})),
});
}

348
src/src/style/comments.css Normal file
View File

@@ -0,0 +1,348 @@
/*!
* Gruvbox Dark Theme for Giscus - Minimalist Edition
* Pure black background with simplified design
* Hosted on a linode bucket
*/
main {
/* Syntax highlighting colors */
--color-prettylights-syntax-comment: #928374;
--color-prettylights-syntax-constant: #83a598;
--color-prettylights-syntax-entity: #d3869b;
--color-prettylights-syntax-storage-modifier-import: #ebdbb2;
--color-prettylights-syntax-entity-tag: #b8bb26;
--color-prettylights-syntax-keyword: #fb4934;
--color-prettylights-syntax-string: #83a598;
--color-prettylights-syntax-variable: #fe8019;
--color-prettylights-syntax-brackethighlighter-unmatched: #fb4934;
--color-prettylights-syntax-invalid-illegal-text: #ebdbb2;
--color-prettylights-syntax-invalid-illegal-bg: #cc241d;
--color-prettylights-syntax-carriage-return-text: #ebdbb2;
--color-prettylights-syntax-carriage-return-bg: #cc241d;
--color-prettylights-syntax-string-regexp: #b8bb26;
--color-prettylights-syntax-markup-list: #fabd2f;
--color-prettylights-syntax-markup-heading: #83a598;
--color-prettylights-syntax-markup-italic: #ebdbb2;
--color-prettylights-syntax-markup-bold: #fe8019;
--color-prettylights-syntax-markup-deleted-text: #fb4934;
--color-prettylights-syntax-markup-deleted-bg: #000000;
--color-prettylights-syntax-markup-inserted-text: #b8bb26;
--color-prettylights-syntax-markup-inserted-bg: #000000;
--color-prettylights-syntax-markup-changed-text: #fabd2f;
--color-prettylights-syntax-markup-changed-bg: #000000;
--color-prettylights-syntax-markup-ignored-text: #ebdbb2;
--color-prettylights-syntax-markup-ignored-bg: #000000;
--color-prettylights-syntax-meta-diff-range: #d3869b;
--color-prettylights-syntax-brackethighlighter-angle: #928374;
--color-prettylights-syntax-sublimelinter-gutter-mark: #928374;
--color-prettylights-syntax-constant-other-reference-link: #83a598;
/* Button colors */
--color-btn-text: #ebdbb2;
--color-btn-bg: #000000;
--color-btn-border: #3c3836;
--color-btn-shadow: 0 0 #0000;
--color-btn-inset-shadow: 0 0 #0000;
--color-btn-hover-bg: #282828;
--color-btn-hover-border: #504945;
--color-btn-active-bg: #1d2021;
--color-btn-active-border: #504945;
--color-btn-selected-bg: #000000;
/* Primary button colors */
--color-btn-primary-text: #ebdbb2;
--color-btn-primary-bg: #458588;
--color-btn-primary-border: #000000;
--color-btn-primary-shadow: 0 0 #0000;
--color-btn-primary-inset-shadow: 0 0 #0000;
--color-btn-primary-hover-bg: #83a598;
--color-btn-primary-hover-border: #000000;
--color-btn-primary-selected-bg: #458588;
--color-btn-primary-selected-shadow: 0 0 #0000;
--color-btn-primary-disabled-text: #ebdbb280;
--color-btn-primary-disabled-bg: #45858899;
--color-btn-primary-disabled-border: #000000;
/* Control colors */
--color-action-list-item-default-hover-bg: #28282850;
--color-segmented-control-bg: #28282850;
--color-segmented-control-button-bg: #000000;
--color-segmented-control-button-selected-border: #504945;
/* Foreground colors */
--color-fg-default: #ebdbb2;
--color-fg-muted: #a89984;
--color-fg-subtle: #7c6f64;
/* Background colors - pure black */
--color-canvas-default: #000000;
--color-canvas-overlay: #000000;
--color-canvas-inset: #000000;
--color-canvas-subtle: #0a0a0a;
/* Border colors - minimal */
--color-border-default: #28282880;
--color-border-muted: #28282850;
--color-neutral-muted: #28282840;
/* Accent colors */
--color-accent-fg: #83a598;
--color-accent-emphasis: #458588;
--color-accent-muted: #83a59866;
--color-accent-subtle: #83a5981a;
/* Status colors */
--color-success-fg: #b8bb26;
--color-attention-fg: #fabd2f;
--color-attention-muted: #fabd2f66;
--color-attention-subtle: #fabd2f26;
--color-danger-fg: #fb4934;
--color-danger-muted: #fb493466;
--color-danger-subtle: #fb49341a;
/* Shadow color */
--color-primer-shadow-inset: 0 0 #0000;
/* Scale colors */
--color-scale-gray-7: #282828;
--color-scale-blue-8: #458588;
/* Social reaction colors */
--color-social-reaction-bg-hover: #28282850;
--color-social-reaction-bg-reacted-hover: #45858850;
}
/* Loader SVG */
main .pagination-loader-container {
background-image: url(https://github.com/images/modules/pulls/progressive-disclosure-line-dark.svg);
}
/* Custom Giscus styles - Minimalist */
.gsc-reactions-count {
display: none;
}
.gsc-timeline {
flex-direction: column-reverse;
}
.gsc-header {
padding-bottom: 1rem;
font-family: "Comic Code", monospace;
border-bottom: none;
}
.gsc-comments > .gsc-header {
order: 1;
}
.gsc-comments > .gsc-comment-box {
margin-bottom: 1rem;
order: 2;
font-family: "Comic Code", monospace;
background-color: #000000;
border-radius: 0.25rem;
border: 1px solid #28282850;
}
.gsc-comments > .gsc-timeline {
order: 3;
}
.gsc-homepage-bg {
background-color: #000000;
}
/* Loading image */
main .gsc-loading-image {
background-image: url(https://github.githubassets.com/images/mona-loading-dimmed.gif);
}
/* Additional custom styles - Minimalist */
.gsc-comment {
border: 1px solid #28282850;
border-radius: 0.25rem;
margin-bottom: 1rem;
background-color: #000000;
transition: border-color 0.2s ease;
}
.gsc-comment-header {
background-color: #0a0a0a;
padding: 0.75rem;
border-bottom: 1px solid #28282830;
font-family: "Comic Code", monospace;
}
.gsc-comment-content {
padding: 1rem;
font-family: "Comic Code", monospace;
}
.gsc-comment-author {
color: var(--color-fg-default);
font-weight: 600;
}
.gsc-comment-author-avatar img {
border-radius: 50%;
}
.gsc-comment-reactions {
border-top: none;
padding-top: 0.5rem;
}
.gsc-reply-box {
background-color: #000000;
border-radius: 0.25rem;
margin-top: 0.5rem;
margin-left: 1rem;
font-family: "Comic Code", monospace;
border: 1px solid #28282840;
}
/* Text input areas */
.gsc-comment-box-textarea {
background-color: #0a0a0a;
border: 1px solid #28282850;
border-radius: 0.25rem;
color: var(--color-fg-default);
font-family: "Comic Code", monospace;
padding: 0.75rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.gsc-comment-box-textarea:focus {
border-color: var(--color-accent-fg);
box-shadow: 0 0 0 2px #45858830;
outline: none;
}
/* Buttons */
.gsc-comment-box-buttons button {
font-family: "Comic Code", monospace;
border-radius: 0.25rem;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
/* Code block styling */
.gsc-comment pre {
background-color: #0a0a0a;
border-radius: 0.25rem;
padding: 1rem;
overflow-x: auto;
border: 1px solid #28282840;
}
.gsc-comment code {
font-family: "Comic Code", monospace;
background-color: #0a0a0a;
color: var(--color-prettylights-syntax-entity);
padding: 0.2em 0.4em;
border-radius: 0.25rem;
}
/* Add hover effects - subtle */
.gsc-comment:hover {
border-color: #504945;
}
.gsc-social-reaction-summary-item:hover {
background-color: #28282850;
}
/* Dark scrollbar - minimal */
.gsc-timeline::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.gsc-timeline::-webkit-scrollbar-track {
background: #000000;
border-radius: 4px;
}
.gsc-timeline::-webkit-scrollbar-thumb {
background: #28282880;
border-radius: 4px;
}
.gsc-timeline::-webkit-scrollbar-thumb:hover {
background: #3c3836;
}
/* Remove unnecessary borders and separators */
.gsc-comment-footer,
.gsc-comment-footer-separator,
.gsc-reactions-button,
.gsc-social-reaction-summary-item:not(:hover) {
border: none;
}
.gsc-upvote svg {
fill: #83a598;
}
.gsc-downvote svg {
fill: #fb4934;
}
/* Add subtle hover transitions */
.gsc-comment-box,
.gsc-comment,
.gsc-comment-reactions,
button,
.gsc-reply-box {
transition: all 0.2s ease-in-out;
}
.gsc-main {
border: none !important;
}
.gsc-left-header {
background-color: #000000 !important;
}
.gsc-right-header {
background-color: #000000 !important;
}
.gsc-header-status {
background-color: #000000 !important;
}
/* Remove any additional borders */
.gsc-comment-box,
.gsc-comment-box-md-toolbar,
.gsc-comment-box-buttons {
border: none !important;
}
.gsc-comment-box-md-toolbar-item {
color: #83a598 !important;
}
/* Simplify the editor toolbar */
.gsc-comment-box-md-toolbar {
background-color: #0a0a0a !important;
padding: 0.5rem !important;
}
/* Hide the "powered by giscus" text */
.gsc-comments .gsc-powered-by {
display: none !important;
}
/* Alternative approach if the above doesn't work */
.gsc-comments footer {
display: none !important;
}
/* Another approach targeting specifically the text */
.gsc-comments .gsc-powered-by a {
color: #000000 !important;
opacity: 0 !important;
visibility: hidden !important;
}

File diff suppressed because one or more lines are too long

View File

@@ -2,6 +2,9 @@ module.exports = {
content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"],
theme: {
extend: {
fontFamily: {
"comic-code": ["Comic Code", "monospace"],
},
colors: {
background: "#000000",
foreground: "#ebdbb2",
@@ -116,6 +119,43 @@ module.exports = {
},
// Code
'code:not([data-language])': {
color: theme('colors.purple.bright'),
backgroundColor: '#282828',
padding: '0.2em 0.4em',
borderRadius: '0.25rem',
fontFamily: 'Comic Code, monospace',
fontWeight: '400',
'&::before': { content: 'none' },
'&::after': { content: 'none' },
},
'pre code': {
display: 'grid', // This ensures line backgrounds stretch full width
minWidth: '100%',
fontFamily: 'Comic Code, monospace',
fontSize: '0.875rem', // text-sm
lineHeight: '1.7142857', // leading-6
padding: '1rem', // p-4
'&::before': { content: 'none' },
'&::after': { content: 'none' },
},
'.highlighted': {
backgroundColor: theme('colors.foreground/5'),
paddingLeft: '1rem',
paddingRight: '1rem',
marginLeft: '-1rem',
marginRight: '-1rem',
},
'.word': {
backgroundColor: theme('colors.foreground/20'),
padding: '0.2em',
borderRadius: '0.25rem',
},
code: {
color: theme("colors.purple.bright"),
backgroundColor: "#282828", // A dark gray that works with black