Compare commits

...

41 Commits

Author SHA1 Message Date
99e4e65d92 migrate to vercel; bump version 2026-03-31 12:11:30 -07:00
11f05e0d6f bugfixes 2026-03-31 12:00:27 -07:00
367470b54e Retro is now default palette 2026-03-31 11:29:30 -07:00
78f1bc2ef6 Add shuffle, pipes engines; lots of polish 2026-03-31 11:12:24 -07:00
174ca69dcd Add confetti animation 2026-03-30 18:21:27 -07:00
f6f9c15e0c Add lavalamp animation 2026-03-30 17:57:32 -07:00
16902f00f4 Add darkbox palette themes 2026-03-30 17:17:52 -07:00
2c5f64a769 Polishing animations 2026-03-30 11:18:36 -07:00
b2cd74385f Astro upgrade to v6 2026-03-30 09:53:51 -07:00
95081b8b77 Omit drafts from build 2025-11-11 09:28:59 -08:00
40b6359d8f Add public pgp key; update astro 2025-11-11 09:19:03 -08:00
d61080722d push latest 2025-09-15 10:38:26 -07:00
5117218a1a Fix code block formatting on mobile devices 2025-09-15 10:04:35 -07:00
f355373ba1 For now sunset resources work 2025-09-11 08:55:05 -07:00
384cb82efb create 0101-welcome-to-the-terminal 2025-08-27 12:46:17 -07:00
7ff6f6542b create video player 2025-08-27 11:19:18 -07:00
9ad08dc85d background animations: 2025-08-27 08:53:56 -07:00
12631dbd42 Animations 2025-08-27 08:27:22 -07:00
1758dc3153 Fixed 2025-08-22 23:08:39 -07:00
9496030d41 Broken 2025-08-21 22:53:37 -07:00
30f264a6bb Thinking of ways to build out a presentation system 2025-08-21 22:18:04 -07:00
7992fcbd49 Add a resources layout 2025-08-18 13:28:55 -07:00
60a9fb0339 back at it 2025-08-16 13:57:37 -07:00
6711de5eb6 Update mdx command component to conform to mobile container size 2025-04-23 12:26:34 -07:00
f2b4660300 keep working on coreboot guide 2025-04-23 12:00:44 -07:00
97608e983c update readme 2025-04-22 13:29:29 -07:00
ce812e8466 Add commands mdx component; continue work on coreboot post 2025-04-22 12:20:19 -07:00
d44988b39c Set comment feed to load eagerly 2025-04-22 09:26:43 -07:00
d885ea4e6b Fix requestAnimationFrame blurring background after switching views 2025-04-22 09:07:47 -07:00
6cfa4c5b7d Remove cursor; update background 2025-04-22 09:04:21 -07:00
f2e85dc6d8 Add cursor trail; fix giscuss 2025-04-21 14:45:44 -07:00
fce17d397e hide custom cursor om mobile devices 2025-04-21 14:26:17 -07:00
7cc954ae07 Add custom cursor; improve pointer events 2025-04-21 14:15:08 -07:00
c6aa014d29 Fix content typography sizes 2025-04-21 13:25:48 -07:00
a9cbbb7e8e Update dockerfile 2025-04-21 12:31:57 -07:00
788eb84488 Update entrypoint to support ssr 2025-04-21 12:26:19 -07:00
4fc5a07249 Update Caddyfile.release 2025-04-21 12:21:15 -07:00
aca5d53bd1 fix proxy issue 2025-04-21 12:17:57 -07:00
f1af80afaf Update compose 2025-04-21 12:14:40 -07:00
257000e81d Update container name 2025-04-21 12:13:32 -07:00
8b30228c4a update compose 2025-04-21 12:10:48 -07:00
126 changed files with 8579 additions and 4646 deletions

View File

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

View File

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

View File

@@ -1,27 +0,0 @@
FROM node:22-alpine
ARG CONTAINER_WEB_VERSION
ARG ENVIRONMENT
ARG BUILD_DATE
ARG GIT_COMMIT
RUN set -eux \
& apk add \
--no-cache \
nodejs \
curl
RUN curl -L https://unpkg.com/@pnpm/self-installer | node
WORKDIR /app
COPY . .
RUN echo "PUBLIC_VERSION=${CONTAINER_WEB_VERSION}" > /app/.env && \
echo "PUBLIC_ENVIRONMENT=${ENVIRONMENT}" >> /app/.env && \
echo "PUBLIC_BUILD_DATE=${BUILD_DATE}" >> /app/.env && \
echo "PUBLIC_GIT_COMMIT=${GIT_COMMIT}" >> /app/.env
EXPOSE 3000
CMD ["pnpm", "run", "dev"]

View File

@@ -1,48 +0,0 @@
# Stage 1: Build and install dependencies
FROM node:22-alpine AS builder
WORKDIR /app
# Install necessary dependencies, including pnpm
RUN set -eux \
&& apk add --no-cache nodejs curl \
&& npm install -g pnpm
# Copy package files first (for better caching)
COPY package.json pnpm-lock.yaml ./
# Install dependencies
RUN pnpm install --frozen-lockfile
# Now copy the rest of your source code
COPY . .
# Set build arguments
ARG CONTAINER_WEB_VERSION
ARG ENVIRONMENT
ARG BUILD_DATE
ARG GIT_COMMIT
# Create .env file with build-time environment variables
RUN echo "PUBLIC_VERSION=${CONTAINER_WEB_VERSION}" > /app/.env && \
echo "PUBLIC_ENVIRONMENT=${ENVIRONMENT}" >> /app/.env && \
echo "PUBLIC_BUILD_DATE=${BUILD_DATE}" >> /app/.env && \
echo "PUBLIC_GIT_COMMIT=${GIT_COMMIT}" >> /app/.env
# Build the project
RUN pnpm run build
# Stage 2: Serve static files
FROM node:22-alpine
WORKDIR /app
# Install serve
RUN npm install -g http-server
# Copy built files
COPY --from=builder /app/dist ./dist
# Expose port 3000
EXPOSE 3000
# Deployment command
CMD ["http-server", "dist", "-a", "127.0.0.1", "-p", "3000"]

View File

@@ -1,83 +0,0 @@
#!/bin/sh
# Set variables
BRANCH_NAME="$1"
COMMIT_HASH="$2"
GHCR_USERNAME="$3"
GHCR_TOKEN="$4"
DEPLOY_TYPE="$5"
REPO_OWNER="$6"
COMPOSE_FILE="$7"
CADDYFILE="$8"
MAKEFILE="$9"
# Echo out variable names and their content on single lines
echo "BRANCH_NAME: $BRANCH_NAME"
echo "COMMIT_HASH: $COMMIT_HASH"
echo "GHCR_USERNAME: $GHCR_USERNAME"
echo "DEPLOY_TYPE: $DEPLOY_TYPE"
echo "REPO_OWNER: $REPO_OWNER"
echo "COMPOSE_FILE: $COMPOSE_FILE"
echo "CADDYFILE: $CADDYFILE"
echo "MAKEFILE: $MAKEFILE"
# Set the staging directory
STAGING_DIR="/root/deployments/.staging-${COMMIT_HASH}"
# Set the tmux session name for release
TMUX_SESSION="deployment-release"
# Function to cleanup existing release deployment
cleanup_release_deployment() {
echo "Cleaning up existing release deployment..."
# Stop and remove all release containers
docker-compose -f "/root/deployments/release/docker-compose.yml" down -v 2>/dev/null
# Remove release images
docker rmi $(docker images "ghcr.io/$REPO_OWNER/*:release" -q) 2>/dev/null
# Kill release tmux session if it exists
tmux kill-session -t "$TMUX_SESSION" 2>/dev/null
# Remove release deployment directory
rm -rf /root/deployments/release
}
# Function to create deployment directory
create_deployment_directory() {
echo "Creating deployment directory..."
mkdir -p /root/deployments/release
}
# Function to pull Docker images
pull_docker_images() {
echo "Pulling Docker images..."
docker pull ghcr.io/$REPO_OWNER/web:release
}
# Function to copy and prepare files
copy_and_prepare_files() {
echo "Copying and preparing files..."
# Copy files preserving names and locations
install -D "$STAGING_DIR/$COMPOSE_FILE" "/root/deployments/release/$COMPOSE_FILE"
install -D "$STAGING_DIR/$CADDYFILE" "/root/deployments/release/$CADDYFILE"
install -D "$STAGING_DIR/$MAKEFILE" "/root/deployments/release/$MAKEFILE"
# Replace {$COMMIT_HASH} with $COMMIT_HASH in $CADDYFILE
sed -i "s/{\$COMMIT_HASH}/$COMMIT_HASH/g" "/root/deployments/release/$CADDYFILE"
# Replace {commit_hash} with $COMMIT_HASH in $COMPOSE_FILE
sed -i "s/{commit_hash}/$COMMIT_HASH/g" "/root/deployments/release/$COMPOSE_FILE"
}
# Function to start the deployment
start_deployment() {
echo "Starting deployment..."
# Create new tmux session with specific name
tmux new-session -d -s "$TMUX_SESSION"
tmux send-keys -t "$TMUX_SESSION" "cd /root/deployments/release && make run release" Enter
}
# Main execution
cleanup_release_deployment
create_deployment_directory
copy_and_prepare_files
cd "/root/deployments/release"
pull_docker_images
start_deployment
echo "Release build $COMMIT_HASH deployed successfully!"

4
.gitmodules vendored
View File

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

186
Makefile
View File

@@ -1,186 +0,0 @@
PROJECT_NAME := "timmypidashev.dev"
PROJECT_AUTHORS := "Timothy Pidashev (timmypidashev) <pidashev.tim@gmail.com>"
PROJECT_VERSION := "v1.0.2"
PROJECT_LICENSE := "MIT"
PROJECT_SOURCES := "https://github.com/timmypidashev/web"
PROJECT_REGISTRY := "ghcr.io/timmypidashev"
PROJECT_ORGANIZATION := "org.opencontainers"
CONTAINER_WEB_NAME := "timmypidashev.dev"
CONTAINER_WEB_VERSION := "v2.1.1"
CONTAINER_WEB_LOCATION := "src/"
CONTAINER_WEB_DESCRIPTION := "My portfolio website!"
.DEFAULT_GOAL := help
.PHONY: watch run build push prune bump exec
.SILENT: watch run build push prune bump exec
help:
@echo "Available targets:"
@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 " prune - Removes all built and cached docker images and containers"
@echo " bump - Bumps the project and container versions"
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' or 'release'
#
# Explanation:
# * Builds the specified docker image with the appropriate environment.
# * Passes all generated arguments to docker build-kit.
# 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))))
# Call function container_build either through a for loop for each container
# if all is called, or singularly to build the container.
$(if $(filter $(strip $(INPUT_CONTAINER)),all), \
$(foreach container,$(containers),$(call container_build,$(container) $(INPUT_ENVIRONMENT))), \
$(call container_build,$(INPUT_CONTAINER) $(INPUT_ENVIRONMENT)))
push:
# Arguments
# [container]: Push context(which container to push to the registry)
# [version]: Container version to push.
#
# Explanation:
# * Pushes the specified container version to the registry defined in the user configuration.
# Extract container and version inputted.
$(eval INPUT_TARGET := $(word 2,$(MAKECMDGOALS)))
$(eval INPUT_CONTAINER := $(firstword $(subst :, ,$(INPUT_TARGET))))
$(eval INPUT_VERSION := $(lastword $(subst :, ,$(INPUT_TARGET))))
# Push the specified container version to the registry.
# NOTE: docker will complain if the container tag is invalid, no need to validate here.
@docker push $(PROJECT_REGISTRY)/$(INPUT_CONTAINER):$(INPUT_VERSION)
prune:
# Removes all built and cached docker images and containers.
bump:
# Arguments
# [container]: Bump context(which container version to bump)
# [semantic_type]: Semantic type context(major, minor, patch)
#
# Explanation:
# * Bumps the specified container version within the makefile config and the container's package.json.
# * Bumps the global project version in the makefile, and creates a new git tag with said version.
# Extract container and semantic_type inputted.
$(eval INPUT_TARGET := $(word 2,$(MAKECMDGOALS)))
$(eval INPUT_CONTAINER := $(firstword $(subst :, ,$(INPUT_TARGET))))
$(eval INPUT_SEMANTIC_TYPE := $(lastword $(subst :, ,$(INPUT_TARGET))))
# Extract old container and project versions.
$(eval OLD_CONTAINER_VERSION := $(subst v,,$(CONTAINER_$(shell echo $(INPUT_CONTAINER) | tr a-z A-Z)_VERSION)))
$(eval OLD_PROJECT_VERSION := $(subst v,,$(PROJECT_VERSION)))
# Pull docker semver becsause the normal command doesn't seem to work; also we don't need to worry about dependencies.
docker pull usvc/semver:latest
# Bump npm package.json file for selected container
cd $(call container_location,$(INPUT_CONTAINER)) && npm version $(shell docker run usvc/semver:latest bump $(INPUT_SEMANTIC_TYPE) $(OLD_CONTAINER_VERSION))
# Bump the git tag to match the new global version
git tag v$(shell docker run usvc/semver:latest bump $(INPUT_SEMANTIC_TYPE) $(OLD_PROJECT_VERSION))
# 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;
# 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.
define args
$(shell \
grep -E '^[[:alnum:]_]+[[:space:]]*[:?]?[[:space:]]*=' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":="} { \
gsub(/^[[:space:]]+|[[:space:]]+$$/, "", $$2); \
gsub(/^/, "\x27", $$2); \
gsub(/$$/, "\x27", $$2); \
gsub(/[[:space:]]+/, "", $$1); \
gsub(":", "", $$1); \
printf "--build-arg %s=%s ", $$1, $$2 \
}') \
--build-arg BUILD_DATE='"$(shell date)"' \
--build-arg GIT_COMMIT='"$(shell git rev-parse HEAD)"'
endef
# This function generates labels based on variables defined in the Makefile.
# It extracts only the selected container variables and is used to echo this information
# to the docker buildx engine in the command line.
define labels
--label $(PROJECT_ORGANIZATION).image.title=$(CONTAINER_$(1)_NAME) \
--label $(PROJECT_ORGANIZATION).image.description=$(CONTAINER_$(1)_DESCRIPTION) \
--label $(PROJECT_ORGANIZATION).image.authors=$(PROJECT_AUTHORS) \
--label $(PROJECT_ORGANIZATION).image.url=$(PROJECT_SOURCES) \
--label $(PROJECT_ORGANIZATION).image.source=$(PROJECT_SOURCES)/$(CONTAINER_$(1)_LOCATION)
endef
# This function returns a list of container names defined in the Makefile.
# In order for this function to return a container, it needs to have this format: CONTAINER_%_NAME!
define containers
$(strip $(filter-out $(_NL),$(foreach var,$(.VARIABLES),$(if $(filter CONTAINER_%_NAME,$(var)),$(strip $($(var)))))))
endef
define container_build
$(eval CONTAINER := $(word 1,$1))
$(eval ENVIRONMENT := $(word 2,$1))
$(eval ARGS := $(shell echo $(args)))
$(eval VERSION := $(strip $(call container_version,$(CONTAINER))))
$(eval TAG := $(PROJECT_NAME):$(ENVIRONMENT))
@echo "Building container: $(CONTAINER)"
@echo "Environment: $(ENVIRONMENT)"
@echo "Version: $(VERSION)"
@if [ "$(strip $(ENVIRONMENT))" != "local" ] && [ "$(strip $(ENVIRONMENT))" != "release" ]; then \
echo "Invalid environment. Please specify 'local' or 'release'"; \
exit 1; \
fi
$(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
$(strip $(eval CONTAINER_NAME := $(shell echo $(1) | tr '[:lower:]' '[:upper:]'))) \
$(CONTAINER_$(CONTAINER_NAME)_LOCATION)
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

View File

@@ -1 +1,3 @@
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Ftimmypidashev.dev&label=Visits&icon=eye-fill&color=%23198754)
<img src=".github/preview.jpeg" title="Preview"/> <img src=".github/preview.jpeg" title="Preview"/>

View File

@@ -1,5 +1,5 @@
import { defineConfig } from "astro/config"; import { defineConfig } from "astro/config";
import node from "@astrojs/node"; import vercel from "@astrojs/vercel";
import tailwind from "@astrojs/tailwind"; import tailwind from "@astrojs/tailwind";
import react from "@astrojs/react"; import react from "@astrojs/react";
import mdx from "@astrojs/mdx"; import mdx from "@astrojs/mdx";
@@ -10,9 +10,7 @@ import sitemap from "@astrojs/sitemap";
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
output: "server", output: "server",
adapter: node({ adapter: vercel(),
mode: "standalone",
}),
site: "https://timmypidashev.dev", site: "https://timmypidashev.dev",
build: { build: {
// Enable build-time optimizations // Enable build-time optimizations

View File

@@ -1,32 +0,0 @@
services:
caddy:
container_name: caddy
image: caddy:latest
ports:
- 80:80
- 443:443
volumes:
- ./.caddy/Caddyfile.release:/etc/caddy/Caddyfile:rw
networks:
- proxy_network
depends_on:
- 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
timmypidashev.dev:
container_name: timmypidashev
image: ghcr.io/timmypidashev/timmypidashev.dev:latest
networks:
- proxy_network
networks:
proxy_network:
name: proxy_network
external: true

43
package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "timmypidashev-web",
"version": "3.0.0",
"private": true,
"scripts": {
"dev": "astro dev --host",
"build": "astro build",
"preview": "astro preview"
},
"devDependencies": {
"@astrojs/react": "^5.0.2",
"@astrojs/tailwind": "^6.0.2",
"@tailwindcss/typography": "^0.5.19",
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
"astro": "^6.1.2",
"tailwindcss": "^3.4.19"
},
"dependencies": {
"@astrojs/mdx": "^5.0.3",
"@astrojs/vercel": "^10.0.3",
"@astrojs/rss": "^4.0.18",
"@astrojs/sitemap": "^3.7.2",
"@giscus/react": "^3.1.0",
"@pilcrowjs/object-parser": "^0.0.4",
"@react-hook/intersection-observer": "^3.1.2",
"@rehype-pretty/transformers": "^0.13.2",
"arctic": "^3.7.0",
"lucide-react": "^0.468.0",
"marked": "^15.0.12",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.6.0",
"react-responsive": "^10.0.1",
"reading-time": "^1.5.0",
"rehype-pretty-code": "^0.14.3",
"rehype-slug": "^6.0.0",
"schema-dts": "^1.1.5",
"shiki": "^3.23.0",
"typewriter-effect": "^2.22.0",
"unist-util-visit": "^5.1.0"
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

Before

Width:  |  Height:  |  Size: 6.8 MiB

After

Width:  |  Height:  |  Size: 6.8 MiB

View File

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 119 KiB

View File

Before

Width:  |  Height:  |  Size: 642 B

After

Width:  |  Height:  |  Size: 642 B

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

65
public/pgp.asc Normal file
View File

@@ -0,0 +1,65 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQINBGTwzLsBEADehIeiC1fV/GiRBclTmM9e6rmG29/YQbrRfJZ+sa1gWlAws/yR
sXbrCDh1S/JU85lirhc0A2N+OZSqCSkGUtvDhCttxLi5VUyVxgqshJWN/mU5eZEN
x4/5mpAApV6K2WGjAggJjoHecr/+sRpV3Vq/5ypdp6RLt7zeJolJSzKWpFrGeYTd
CFaZyZbQVw2NeFe9NBoQHDk0mMS+9bVyXQ2oi4fteKCe205cJkEc4Z8q4NzOlquL
BbowQwxKoAosCwz19Em5yeND34WUHJPlgoYTf9HRgsQcmI17hn9Q00gzwm7gotbF
bqWKEMduOzGBRyEEWeZ+l8CcWHAo2NbxZx01L4UsrZ6+MkXWv+2NQBFhPL14UcDg
uuBVvc5rsgeu7Rm8DE2EXTyA3Bszz1r75TbsQwUianma38kxSuiKArUDE+v5X0vv
F6e2z6hkDkKCe53EM9yF4lvVHNsQwxT8RBXPj+sc6Sqv3nIkLnan3++aMhW3up9s
YXKouadmvgB4uGqsBjWOme4ViW46C7rNVRtUKQTdx89cFG1c+GuaI203RbHjAZVJ
M9fBH5Ycdl0ieSCwmerbHfTRudHqPdNLQMvFYGPEj5pXalwtd3j4CEEC/HqOyklz
IN9ReIYIYF0pSC5OmWD3c70vj0INsbWp96eB8skjONjHYL3Y2CXIZ0AzRQARAQAB
tClUaW1vdGh5IFBpZGFzaGV2IDxwaWRhc2hldi50aW1AZ21haWwuY29tPokCTgQT
AQgAOBYhBGjE1l58MsXUX+ieufe9h7j9i0NOBQJk8My7AhsDBQsJCAcCBhUKCQgL
AgQWAgMBAh4BAheAAAoJEPe9h7j9i0NOsxwQALfdoZJAdkBpM2AmsVdx6JqvA08I
p/Xr1YgjwJvziq8fnWpu/AGz9VevFVgAt1h1Dsr4XAolEtQM6+aiNX7HGqyLKqT7
kum/dpnjw0/tiKvv/P2TRc+YZZLOfb+TYa1bZYGVDzGAHAm17yMJTV3rH8tIKNee
VaqWmxMuwmUQXutvF9P2bhaJLOTjGCIVxuAMfLIhRGKz8q8+I5g5aLm/JrpHC0OY
ACGSSj1vP0b5m5BLqqv67GueDHTX6w/7U1LAEspIcs+/GxoA5G9WzZFn4qNdq98h
RPixuY5y5FYKV6FAVGm5Yu3FSPvKpXAfWZIKM3WzKf7BNhUVaB6HNGbCFAMGDcHz
dZ9xXRohWlHidft55qBUpTXAjy3vb2k5eMXGPNMCwQvMyzDZzLkYbN3apuWbjzlQ
ARdlGKpRRzmeAHEmAybX1Fel8dT2DWjP05t2z2BRQAFK9sGE7WzYhvllMp31C7SA
uJZNzgjjs/aI8oiNc1qpeiQxsEGws3OakHRxd1rnM+d4icwTx13u8lMqHnvORgIe
rAa6qKIUnfZLo0ut0X5s8iPjoLZr/qjDGRbVmHH5K/D5Ci6VlsEkmcQ0REE1FWPA
hrlSNfKbeaHWAJ7MFKvNxViy8n7MeoR6Nn7EGoxCfekgGQLWuRNanChJ8dm7HZpq
o/vu4JH94Ui9xDwBtDlUaW1vdGh5IFBpZGFzaGV2ICh0aW1teXBpZGFzaGV2KSA8
bWFpbEB0aW1teXBpZGFzaGV2LmRldj6JAlQEEwEIAD4CGwMFCwkIBwICIgIGFQoJ
CAsCBBYCAwECHgcCF4AWIQRoxNZefDLF1F/onrn3vYe4/YtDTgUCZ5J4fQIZAQAK
CRD3vYe4/YtDTsGjD/4s/pBI8s+zoV+aBBKi9qmIqFAlZ8+JyY4TzAlIa1qZg/Xk
GVEN1+Lwa9m4eI1SFZUOprLPiqqFJ+DSHjrua5FGo56uhYGBEbPBlzIJx0XtXclS
1FmpoDOjY6FFsvrkv19jPYB2oXnPjok/nkRLdNWp1BVqisqFq8f//iynMu6GTndF
cNHf7iwZ+IHytFTiKFCgMg7jPeXofAkpnFXoOB33wn7ED2I26zhMx9wJE1cKApxv
ZmxGtkPk0rv6kiQqGE9zTg+AKSy1+jkXp8eFrnA4P9bIDXxcnDyVs/63X6X/qEK5
Yg1a3Xq1U4d6aoQUqAShpAQGbTmEvKusYXzdd2fFJEd0OExXkG2mWndhkF/9+8Rh
SroaqS+0G3nG38KTvK7OKnyHhuDVjcvJ5QiWVd1T7M3SBDAZwcOmpkw3SN26b8iS
i8iHAUQGjKftG+PDrXvRhMn7lpIshJBXopCGJvzPwLIoMvVzgq0gxAeCUHqI0wr2
EXEgboPW18zcAagI2r4B7p0xVtpJ9qPamYcCPqPMfxA8YYKhyzb8owkFUBboSF8j
ihBz1NN3ph8ZEa1YbxrdJVMkbOlE/O+DDaegxkXG9gSts/nYZXQx5GZ2LyVlHC4z
yVUpGwsRLfSejubhBnkRrXzOn1dFhAL4kIXFvFp4t4ZkYssdWzLVx71UVTyGsrkC
DQRk8My7ARAA1NRA04/vS9Cww0MMFQwaBztEb4INAT3dVxybyPZEIiNGttqGzEc9
EV+5NlcLwygDraXqw+k5GekIE7Mqf3YukeIqA+4TDVpFv26QbtBnLQ01YM7Z0tU4
R/X6IJmn/Uudc5hKLOLms3BH4x6O/XQJERJIALOfMWRfsmcUXw8a05HF5OuNVClT
w8FHVawN7frCJdBsh9g2bGJArwQFCxaLcDgpydUTMxNgxQMLgcAuIk3GiFwwWC4e
HzrAmp1yHn/iDh+UN8zQBjoi/5Ac4uXJlHKGAiakw/NqYlFccno1vUg5kuW+9QN4
ch2fa4zAosd7ObR5uZjNn6sggnq4ejA98vtg5DssCSQpTiFqNu3pBLroh4LsRh3r
THuWnXj4HWKDPZ3odlPVy2sIswtMXO3uygyWLJbPuT824iFwD9imshqsnoMxazb1
W/GuBFyI7ZM8tzCMVNtZExEBqnOwQdjlSgpla6L3UVWs4KL1UEVWm3doFCGgzQbK
JVVH3Uk0Z+w+jylZqXdmSSrB/wkg+j9QK2VxewEP0onS4FBhoJsaezLL5fTYpOy5
yAx2k3lqa3YF51ulPoGGg3u75R/37zt8VT3rfEXuCjtHd/H4fieluAOW2w4phXrC
u8iMq0eChaedVZsAAsy+DW9Ighf8zy8x/HQ0MKaFGI59B6BOsL6f/2MAEQEAAYkC
NgQYAQgAIBYhBGjE1l58MsXUX+ieufe9h7j9i0NOBQJk8My7AhsMAAoJEPe9h7j9
i0NOWvEQAJl5UqnzxM9+aYQcCw1qxGYTxdui7mE8QHDU9L7sz5vPeLVXrkxZfFsR
+2Y6S92ySk+pTZv8/+TztxYczr3tJ/TUpj6jM7jJROo2BUcAph4wSBD/vxqCN40g
XYcvbzbFvmbXJL+I2h/C3Ja7O0DqlDqRB2icaCYqVK0aDFfe5ldHbfs+w4Ox/zh6
7kkchA+WauRadwyaqWK3XGdusTw3ZcaFRSGfTRCQfM2U1761mRBppeDhOPoZ6Ymy
rWUpiOE7SJNe+gxyX0wVGv/CuUr5RaDEwvl7A5fUA9Sdvtxdr3Eixd6lsCRWoAtq
1woDoqpDZpS0wlb28r7/ZtvAUR1EovAOwy41GNri9AwyMeRSg3NLqb21c9yhEV5a
cWdGzioSjk89dd1c/pzuHPZ1cTRiizH8SRQjn/rLqJTnH1vVhLFmLv/Ywr9LOw2s
RcKxrByu0O+j96zR+6dqsiCo4i0CUS7P1shQlzhRuz6o3eZ1IlepAlif2LDW5waO
KdFJe2hD0oe4JKO9TbzJOeupFW1C/TBzdLif3K7VsKVdfPBL1YWinf7V7gryxJdc
pAE4764aIhfCkLa85tt7Tn/ii4ZLLMQG+Ww7LJ7BRarMlcyrw3nf0dICLxFgAnMS
vclOqTbTH082uTM/wa3dhxByz0rKZEz7xXAjPiZfK+2nncOdQhAm
=T4Wx
-----END PGP PUBLIC KEY BLOCK-----

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

View File

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

1
public/scripts Submodule

Submodule public/scripts added at cf15115731

View File

@@ -1,11 +0,0 @@
# Astro with Tailwind
```
npm init astro -- --template with-tailwindcss
```
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/with-tailwindcss)
Astro comes with [Tailwind](https://tailwindcss.com) support out of the box. This example showcases how to style your Astro project with Tailwind.
For complete setup instructions, please see our [Styling Guide](https://docs.astro.build/guides/styling#-tailwind).

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react"; import { useState, useEffect } from "react";
const GlitchText = () => { const GlitchText = () => {
const originalText = 'Error 404'; const originalText = 'Error 404';

View File

@@ -0,0 +1,194 @@
import React, { useEffect, useRef, useState } from "react";
import { Code2, BookOpen, RocketIcon, Compass } from "lucide-react";
function AnimateIn({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [skip, setSkip] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
if (inView && isReload) {
setSkip(true);
setVisible(true);
return;
}
if (inView) {
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold: 0.15 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={skip ? "" : "transition-all duration-700 ease-out"}
style={skip ? {} : {
transitionDelay: `${delay}ms`,
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)",
}}
>
{children}
</div>
);
}
export default function CurrentFocus() {
const recentProjects = [
{
title: "Darkbox",
description: "My gruvbox theme, with a pure black background",
href: "/projects/darkbox",
tech: ["Neovim", "Lua"],
},
{
title: "Revive Auto Parts",
description: "A car parts listing site built for a client",
href: "/projects/reviveauto",
tech: ["Tanstack", "React Query", "Fastapi"],
},
{
title: "Fhccenter",
description: "Website made for a private school",
href: "/projects/fhccenter",
tech: ["Nextjs", "Typescript", "Prisma"],
},
];
return (
<div className="flex justify-center items-center w-full">
<div className="w-full max-w-6xl p-4 sm:px-6 py-6 sm:py-8">
<AnimateIn>
<h2 className="text-3xl sm:text-4xl font-bold text-center text-yellow-bright mb-8 sm:mb-12">
Current Focus
</h2>
</AnimateIn>
{/* Recent Projects Section */}
<div className="mb-8 sm:mb-16">
<AnimateIn delay={100}>
<div className="flex items-center justify-center gap-2 mb-6">
<Code2 className="text-yellow-bright" size={24} />
<h3 className="text-xl font-bold text-foreground/90">Recent Projects</h3>
</div>
</AnimateIn>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6 max-w-5xl mx-auto">
{recentProjects.map((project, i) => (
<AnimateIn key={project.title} delay={200 + i * 100}>
<a
href={project.href}
className="block p-4 sm:p-6 rounded-lg border border-foreground/10 hover:border-yellow-bright/50
transition-all duration-300 group bg-background/50 h-full"
>
<h4 className="font-bold text-lg group-hover:text-yellow-bright transition-colors">
{project.title}
</h4>
<p className="text-foreground/70 mt-2 text-sm sm:text-base">{project.description}</p>
<div className="flex flex-wrap gap-2 mt-3">
{project.tech.map((tech) => (
<span key={tech} className="text-xs px-2 py-1 rounded-full bg-foreground/5 text-foreground/60">
{tech}
</span>
))}
</div>
</a>
</AnimateIn>
))}
</div>
</div>
{/* Current Learning & Interests */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8 max-w-5xl mx-auto">
<AnimateIn delay={100}>
<div className="space-y-4 p-4 sm:p-6 rounded-lg border border-foreground/10 bg-background/50 h-full">
<div className="flex items-center justify-center gap-2">
<BookOpen className="text-green-bright" size={24} />
<h3 className="text-lg sm:text-xl font-bold text-foreground/90">Currently Learning</h3>
</div>
<ul className="space-y-3 text-sm sm:text-base text-foreground/70">
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-green-bright flex-shrink-0" />
<span>Rust Programming</span>
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-green-bright flex-shrink-0" />
<span>WebAssembly with Rust</span>
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-green-bright flex-shrink-0" />
<span>HTTP/3 & WebTransport</span>
</li>
</ul>
</div>
</AnimateIn>
<AnimateIn delay={200}>
<div className="space-y-4 p-4 sm:p-6 rounded-lg border border-foreground/10 bg-background/50 h-full">
<div className="flex items-center justify-center gap-2">
<RocketIcon className="text-blue-bright" size={24} />
<h3 className="text-lg sm:text-xl font-bold text-foreground/90">Project Interests</h3>
</div>
<ul className="space-y-3 text-sm sm:text-base text-foreground/70">
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-blue-bright flex-shrink-0" />
<span>AI Model Integration</span>
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-blue-bright flex-shrink-0" />
<span>Rust Systems Programming</span>
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-blue-bright flex-shrink-0" />
<span>Cross-platform WASM Apps</span>
</li>
</ul>
</div>
</AnimateIn>
<AnimateIn delay={300}>
<div className="space-y-4 p-4 sm:p-6 rounded-lg border border-foreground/10 bg-background/50 h-full">
<div className="flex items-center justify-center gap-2">
<Compass className="text-purple-bright" size={24} />
<h3 className="text-lg sm:text-xl font-bold text-foreground/90">Want to Explore</h3>
</div>
<ul className="space-y-3 text-sm sm:text-base text-foreground/70">
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-purple-bright flex-shrink-0" />
<span>LLM Fine-tuning</span>
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-purple-bright flex-shrink-0" />
<span>Rust 2024 Edition</span>
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-purple-bright flex-shrink-0" />
<span>Real-time Web Transport</span>
</li>
</ul>
</div>
</AnimateIn>
</div>
</div>
</div>
);
}

View File

@@ -1,47 +1,89 @@
import React from "react"; import { useEffect, useRef, useState } from "react";
import { ChevronDownIcon } from "@/components/icons"; import { ChevronDown } from "lucide-react";
export default function Intro() { export default function Intro() {
const [visible, setVisible] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
if (inView && isReload) {
setVisible(true);
return;
}
if (inView) {
// Fresh navigation — animate in
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold: 0.2 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
const scrollToNext = () => { const scrollToNext = () => {
const nextSection = document.querySelector("section")?.nextElementSibling; const nextSection = document.querySelector("section")?.nextElementSibling;
if (nextSection) { if (nextSection) {
const offset = nextSection.offsetTop - (window.innerHeight - nextSection.offsetHeight) / 2; // Center the section const offset = (nextSection as HTMLElement).offsetTop - (window.innerHeight - (nextSection as HTMLElement).offsetHeight) / 2;
window.scrollTo({ window.scrollTo({ top: offset, behavior: "smooth" });
top: offset,
behavior: "smooth"
});
} }
}; };
const anim = (delay: number) =>
({
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(20px)",
transition: `all 0.7s ease-out ${delay}ms`,
}) as React.CSSProperties;
return ( return (
<div className="w-full max-w-4xl px-4"> <div ref={ref} className="w-full max-w-4xl px-4">
<div className="space-y-8 md:space-y-12"> <div className="space-y-8 md:space-y-12">
<div className="flex flex-col sm:flex-row items-center sm:items-center justify-center gap-8 sm:gap-16"> <div className="flex flex-col sm:flex-row items-center sm:items-center justify-center gap-8 sm:gap-16">
<div className="w-32 h-32 sm:w-48 sm:h-48 shrink-0"> <div
className="w-32 h-32 sm:w-48 sm:h-48 shrink-0"
style={anim(0)}
>
<img <img
src="/me.jpeg" src="/me.jpeg"
alt="Timothy Pidashev" alt="Timothy Pidashev"
className="rounded-lg object-cover w-full h-full ring-2 ring-yellow-bright hover:ring-orange-bright transition-all duration-300" className="rounded-lg object-cover w-full h-full ring-2 ring-yellow-bright hover:ring-orange-bright transition-all duration-300"
/> />
</div> </div>
<div className="text-center sm:text-left space-y-4 sm:space-y-6"> <div className="text-center sm:text-left space-y-4 sm:space-y-6" style={anim(150)}>
<h2 className="text-xl sm:text-5xl font-bold text-yellow-bright"> <h2 className="text-xl sm:text-5xl font-bold text-yellow-bright">
Timothy Pidashev Timothy Pidashev
</h2> </h2>
<div className="text-sm sm:text-xl text-foreground/70 space-y-2 sm:space-y-3"> <div className="text-sm sm:text-xl text-foreground/70 space-y-2 sm:space-y-3">
<p className="flex items-center justify-center font-bold sm:justify-start gap-2"> <p className="flex items-center justify-center font-bold sm:justify-start gap-2" style={anim(300)}>
<span className="text-blue">Software Systems Engineer</span> <span className="text-blue">Software Systems Engineer</span>
</p> </p>
<p className="flex items-center justify-center font-bold sm:justify-start gap-2"> <p className="flex items-center justify-center font-bold sm:justify-start gap-2" style={anim(450)}>
<span className="text-green">Open Source Enthusiast</span> <span className="text-green">Open Source Enthusiast</span>
</p> </p>
<p className="flex items-center justify-center font-bold sm:justify-start gap-2"> <p className="flex items-center justify-center font-bold sm:justify-start gap-2" style={anim(600)}>
<span className="text-yellow">Coffee Connoisseur</span> <span className="text-yellow">Coffee Connoisseur</span>
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div className="space-y-8"> <div className="space-y-8" style={anim(750)}>
<p className="text-foreground/80 text-center text-base sm:text-2xl italic max-w-3xl mx-auto font-medium"> <p className="text-foreground/80 text-center text-base sm:text-2xl italic max-w-3xl mx-auto font-medium">
"Turning coffee into code" isn't just a clever phrase "Turning coffee into code" isn't just a clever phrase
<span className="text-aqua-bright"> it's how I approach each project:</span> <span className="text-aqua-bright"> it's how I approach each project:</span>
@@ -49,13 +91,13 @@ export default function Intro() {
<span className="text-blue-bright"> with attention to detail,</span> <span className="text-blue-bright"> with attention to detail,</span>
<span className="text-green-bright"> and a refined process.</span> <span className="text-green-bright"> and a refined process.</span>
</p> </p>
<div className="flex justify-center"> <div className="flex justify-center" style={anim(900)}>
<button <button
onClick={scrollToNext} onClick={scrollToNext}
className="text-foreground/50 hover:text-yellow-bright transition-colors duration-300" className="text-foreground/50 hover:text-yellow-bright transition-colors duration-300"
aria-label="Scroll to next section" aria-label="Scroll to next section"
> >
<ChevronDownIcon size={40} className="animate-bounce" /> <ChevronDown size={40} className="animate-bounce" />
</button> </button>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,116 @@
import React, { useEffect, useRef, useState } from "react";
import { Cross, Fish, Mountain, Book } from "lucide-react";
function AnimateIn({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [skip, setSkip] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
if (inView && isReload) {
setSkip(true);
setVisible(true);
return;
}
if (inView) {
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold: 0.15 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={skip ? "" : "transition-all duration-700 ease-out"}
style={skip ? {} : {
transitionDelay: `${delay}ms`,
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0) scale(1)" : "translateY(20px) scale(0.97)",
}}
>
{children}
</div>
);
}
const interests = [
{
icon: <Cross className="text-red-bright" size={20} />,
title: "Faith",
description: "My walk with Jesus is the foundation of everything I do, guiding my purpose and perspective",
},
{
icon: <Fish className="text-blue-bright" size={20} />,
title: "Fishing",
description: "Finding peace and adventure on the water, always looking for the next great fishing spot",
},
{
icon: <Mountain className="text-green-bright" size={20} />,
title: "Hiking",
description: "Exploring trails with friends and seeking out scenic viewpoints in nature",
},
{
icon: <Book className="text-purple-bright" size={20} />,
title: "Reading",
description: "Deep diving into novels & technical books that expand my horizons & captivate my mind",
},
];
export default function OutsideCoding() {
return (
<div className="flex justify-center items-center w-full">
<div className="w-full max-w-4xl px-4 py-8">
<AnimateIn>
<h2 className="text-2xl md:text-4xl font-bold text-center text-yellow-bright mb-8">
Outside of Programming
</h2>
</AnimateIn>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
{interests.map((interest, i) => (
<AnimateIn key={interest.title} delay={100 + i * 100}>
<div
className="flex flex-col items-center text-center p-4 rounded-lg border border-foreground/10
hover:border-yellow-bright/50 transition-all duration-300 bg-background/50 h-full"
>
<div className="mb-3">{interest.icon}</div>
<h3 className="font-bold text-foreground/90 mb-2">{interest.title}</h3>
<p className="text-sm text-foreground/70">{interest.description}</p>
</div>
</AnimateIn>
))}
</div>
<AnimateIn delay={500}>
<p className="text-center text-foreground/80 mt-8 max-w-2xl mx-auto text-sm md:text-base italic">
When I'm not writing code, you'll find me
<span className="text-red-bright"> walking with Christ,</span>
<span className="text-blue-bright"> out on the water,</span>
<span className="text-green-bright"> hiking trails,</span>
<span className="text-purple-bright"> or reading books.</span>
</p>
</AnimateIn>
</div>
</div>
);
}

View File

@@ -1,34 +1,18 @@
import React, { useState, useEffect } from 'react';
export const ActivityGrid = () => { interface ActivityDay {
const [data, setData] = useState([]); grand_total: { total_seconds: number };
const [loading, setLoading] = useState(true); date: string;
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); interface ActivityGridProps {
} catch (err) { data: ActivityDay[];
setError(err.message);
} finally {
setLoading(false);
} }
};
fetchData(); export const ActivityGrid = ({ data }: ActivityGridProps) => {
}, []); const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
// Get intensity based on coding hours (0-4 for different shades) const getIntensity = (hours: number) => {
const getIntensity = (hours) => {
if (hours === 0) return 0; if (hours === 0) return 0;
if (hours < 2) return 1; if (hours < 2) return 1;
if (hours < 4) return 2; if (hours < 4) return 2;
@@ -36,20 +20,18 @@ export const ActivityGrid = () => {
return 4; return 4;
}; };
// Get color class based on intensity const getColorClass = (intensity: number) => {
const getColorClass = (intensity) => { if (intensity === 0) return "bg-foreground/5";
if (intensity === 0) return 'bg-foreground/5'; if (intensity === 1) return "bg-green-DEFAULT/30";
if (intensity === 1) return 'bg-green-DEFAULT/30'; if (intensity === 2) return "bg-green-DEFAULT/60";
if (intensity === 2) return 'bg-green-DEFAULT/60'; if (intensity === 3) return "bg-green-DEFAULT/80";
if (intensity === 3) return 'bg-green-DEFAULT/80'; return "bg-green-bright";
return 'bg-green-bright';
}; };
// Group data by week const weeks: ActivityDay[][] = [];
const weeks = []; let currentWeek: ActivityDay[] = [];
let currentWeek = [];
if (data.length > 0) { if (data && data.length > 0) {
data.forEach((day, index) => { data.forEach((day, index) => {
currentWeek.push(day); currentWeek.push(day);
if (currentWeek.length === 7 || index === data.length - 1) { if (currentWeek.length === 7 || index === data.length - 1) {
@@ -59,20 +41,8 @@ export const ActivityGrid = () => {
}); });
} }
if (loading) { if (!data || data.length === 0) {
return ( return null;
<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 ( return (
@@ -83,7 +53,7 @@ export const ActivityGrid = () => {
{/* Days labels */} {/* Days labels */}
<div className="flex flex-col gap-2 pt-6 text-xs"> <div className="flex flex-col gap-2 pt-6 text-xs">
{days.map((day, i) => ( {days.map((day, i) => (
<div key={day} className="h-3 text-foreground/60">{i % 2 === 0 ? day : ''}</div> <div key={day} className="h-3 text-foreground/60">{i % 2 === 0 ? day : ""}</div>
))} ))}
</div> </div>
{/* Grid */} {/* Grid */}
@@ -102,7 +72,6 @@ export const ActivityGrid = () => {
hover:ring-1 hover:ring-foreground/30 transition-all cursor-pointer hover:ring-1 hover:ring-foreground/30 transition-all cursor-pointer
group relative`} group relative`}
> >
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 p-2 <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 bg-background border border-foreground/10 rounded-md opacity-0
group-hover:opacity-100 transition-opacity z-10 whitespace-nowrap text-xs"> group-hover:opacity-100 transition-opacity z-10 whitespace-nowrap text-xs">
@@ -123,7 +92,7 @@ export const ActivityGrid = () => {
<div <div
key={i} key={i}
className="w-3 mx-1" className="w-3 mx-1"
style={{ marginLeft: i === 0 ? '0' : undefined }} style={{ marginLeft: i === 0 ? "0" : undefined }}
> >
{isFirstOfMonth && months[date.getMonth()]} {isFirstOfMonth && months[date.getMonth()]}
</div> </div>
@@ -136,10 +105,7 @@ export const ActivityGrid = () => {
<div className="flex items-center gap-2 mt-4 text-xs text-foreground/60"> <div className="flex items-center gap-2 mt-4 text-xs text-foreground/60">
<span>Less</span> <span>Less</span>
{[0, 1, 2, 3, 4].map((intensity) => ( {[0, 1, 2, 3, 4].map((intensity) => (
<div <div key={intensity} className={`w-3 h-3 rounded-sm ${getColorClass(intensity)}`} />
key={intensity}
className={`w-3 h-3 rounded-sm ${getColorClass(intensity)}`}
/>
))} ))}
<span>More</span> <span>More</span>
</div> </div>

View File

@@ -0,0 +1,166 @@
import { useState, useEffect, useRef } from "react";
const Stats = () => {
const [stats, setStats] = useState<any>(null);
const [error, setError] = useState(false);
const [count, setCount] = useState(0);
const [isVisible, setIsVisible] = useState(false);
const [skipAnim, setSkipAnim] = useState(false);
const hasAnimated = useRef(false);
const sectionRef = useRef<HTMLDivElement>(null);
// Fetch data on mount
useEffect(() => {
fetch("/api/wakatime/alltime")
.then((res) => {
if (!res.ok) throw new Error("API error");
return res.json();
})
.then((data) => setStats(data.data))
.catch(() => setError(true));
}, []);
// Observe visibility — skip animation if already in view on mount
useEffect(() => {
const el = sectionRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
if (inView && isReload) {
setSkipAnim(true);
setIsVisible(true);
return;
}
if (inView) {
requestAnimationFrame(() => setIsVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ threshold: 0.3 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
// Start counter when both visible and data is ready
useEffect(() => {
if (!isVisible || !stats || hasAnimated.current) return;
hasAnimated.current = true;
const totalSeconds = stats.total_seconds;
const duration = 2000;
const steps = 60;
let currentStep = 0;
const timer = setInterval(() => {
currentStep += 1;
if (currentStep >= steps) {
setCount(totalSeconds);
clearInterval(timer);
return;
}
const progress = 1 - Math.pow(1 - currentStep / steps, 4);
setCount(Math.floor(totalSeconds * progress));
}, duration / steps);
return () => clearInterval(timer);
}, [isVisible, stats]);
if (error) return null;
if (!stats) return <div ref={sectionRef} className="min-h-[50vh]" />;
const hours = Math.floor(count / 3600);
const formattedHours = hours.toLocaleString("en-US", {
minimumIntegerDigits: 4,
useGrouping: true,
});
return (
<div ref={sectionRef} className="flex flex-col items-center justify-center min-h-[50vh] gap-6">
<div className={skipAnim ? "text-2xl opacity-80" : `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={skipAnim ? "bg-gradient-text" : `bg-gradient-text opacity-0 ${isVisible ? "animate-fade-in-second" : ""}`}>
{formattedHours}
</span>
</span>
<span className={skipAnim ? "text-4xl opacity-60 ml-4" : `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={skipAnim ? "text-xl opacity-80" : `text-xl opacity-0 ${isVisible ? "animate-fade-in-third" : ""}`}>
writing code & building apps
</div>
<div className={skipAnim ? "flex items-center gap-3 text-lg opacity-60" : `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,
rgb(var(--color-yellow-bright)),
rgb(var(--color-orange-bright)),
rgb(var(--color-orange)),
rgb(var(--color-yellow)),
rgb(var(--color-orange-bright)),
rgb(var(--color-yellow-bright))
);
background-size: 200% auto;
color: transparent;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
@keyframes fadeInFirst {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 0.8; transform: translateY(0); }
}
@keyframes fadeInSecond {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideInHours {
from { opacity: 0; transform: translateX(20px); margin-left: 0; }
to { opacity: 0.6; transform: translateX(0); margin-left: 1rem; }
}
@keyframes fadeInThird {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 0.8; transform: translateY(0); }
}
@keyframes fadeInFourth {
from { opacity: 0; transform: translateY(20px); }
to { 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 0.4s forwards; }
.animate-slide-in-hours { animation: slideInHours 0.7s ease-out 0.6s forwards; }
.animate-fade-in-third { animation: fadeInThird 0.7s ease-out 0.8s forwards; }
.animate-fade-in-fourth { animation: fadeInFourth 0.7s ease-out 1s forwards; }
`}</style>
</div>
);
};
export default Stats;

View File

@@ -0,0 +1,231 @@
import { useState, useEffect, useRef } from "react";
import { Clock, CalendarClock, CodeXml, Computer } from "lucide-react";
import { ActivityGrid } from "@/components/about/stats-activity";
const DetailedStats = () => {
const [stats, setStats] = useState<any>(null);
const [activity, setActivity] = useState<any>(null);
const [error, setError] = useState(false);
const [visible, setVisible] = useState(false);
const [skipAnim, setSkipAnim] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
fetch("/api/wakatime/detailed")
.then((res) => {
if (!res.ok) throw new Error();
return res.json();
})
.then((data) => setStats(data.data))
.catch(() => setError(true));
fetch("/api/wakatime/activity")
.then((res) => {
if (!res.ok) throw new Error();
return res.json();
})
.then((data) => setActivity(data.data))
.catch(() => {});
}, []);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
if (inView && isReload) {
setSkipAnim(true);
setVisible(true);
return;
}
if (inView) {
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold: 0.1, rootMargin: "-15% 0px" }
);
observer.observe(el);
return () => observer.disconnect();
}, [stats]);
if (error) 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",
];
const statCards = stats
? [
{
title: "Total Time",
value: `${Math.round((stats.total_seconds / 3600) * 10) / 10}`,
unit: "hours",
subtitle: "this week",
color: "text-yellow-bright",
borderHover: "hover:border-yellow-bright/50",
icon: Clock,
iconColor: "stroke-yellow-bright",
},
{
title: "Daily Average",
value: `${Math.round((stats.daily_average / 3600) * 10) / 10}`,
unit: "hours",
subtitle: "per day",
color: "text-orange-bright",
borderHover: "hover:border-orange-bright/50",
icon: CalendarClock,
iconColor: "stroke-orange-bright",
},
{
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",
borderHover: "hover:border-blue-bright/50",
icon: CodeXml,
iconColor: "stroke-blue-bright",
},
{
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",
borderHover: "hover:border-green-bright/50",
icon: Computer,
iconColor: "stroke-green-bright",
},
]
: [];
const languages =
stats?.languages?.slice(0, 7).map((lang: any, index: number) => ({
name: lang.name,
percent: Math.round(lang.percent),
time: Math.round((lang.total_seconds / 3600) * 10) / 10 + " hrs",
color: progressColors[index % progressColors.length],
})) || [];
return (
<div ref={containerRef} className="flex flex-col gap-10 md:py-32 w-full max-w-[1200px] mx-auto px-4 min-h-[50vh]">
{!stats ? null : (
<>
{/* Header */}
<h2
className={`text-2xl md:text-4xl font-bold text-center text-yellow-bright ${skipAnim ? "" : "transition-all duration-700 ease-out"}`}
style={skipAnim ? {} : {
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(20px)",
}}
>
Weekly Statistics
</h2>
{/* Stat Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{statCards.map((card, i) => {
const Icon = card.icon;
return (
<div
key={card.title}
className={`bg-background border border-foreground/10 rounded-lg p-6 ${card.borderHover} ${skipAnim ? "" : "transition-all duration-500 ease-out"}`}
style={skipAnim ? {} : {
transitionDelay: `${150 + i * 100}ms`,
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)",
}}
>
<div className="flex gap-4 items-center">
<div className="p-3 rounded-lg bg-foreground/5">
<Icon className={`w-6 h-6 ${card.iconColor}`} strokeWidth={1.5} />
</div>
<div className="flex flex-col">
<div className={`${card.color} text-sm mb-1`}>{card.title}</div>
<div className="flex items-baseline gap-2">
<div className="text-2xl font-bold">{card.value}</div>
<div className="text-lg opacity-80">{card.unit}</div>
</div>
<div className="text-xs opacity-50 mt-0.5">{card.subtitle}</div>
</div>
</div>
</div>
);
})}
</div>
{/* Languages */}
<div
className={`bg-background border border-foreground/10 rounded-lg p-6 hover:border-purple-bright/50 ${skipAnim ? "" : "transition-all duration-700 ease-out"}`}
style={skipAnim ? {} : {
transitionDelay: "550ms",
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)",
}}
>
<div className="text-purple-bright mb-6 text-lg">Languages</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-5">
{languages.map((lang: any, i: number) => (
<div key={lang.name} className="flex flex-col gap-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">{lang.name}</span>
<span className="text-sm opacity-70 tabular-nums">{lang.time}</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 ${lang.color} rounded-full`}
style={{
width: visible ? `${lang.percent}%` : "0%",
opacity: 0.85,
transition: skipAnim ? "none" : `width 1s ease-out ${700 + i * 80}ms`,
}}
/>
</div>
<span className="text-xs text-foreground/50 min-w-[36px] text-right tabular-nums">
{lang.percent}%
</span>
</div>
</div>
))}
</div>
</div>
{/* Activity Grid */}
{activity && (
<div
className={skipAnim ? "" : "transition-all duration-700 ease-out"}
style={skipAnim ? {} : {
transitionDelay: "750ms",
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)",
}}
>
<ActivityGrid data={activity} />
</div>
)}
</>
)}
</div>
);
};
export default DetailedStats;

View File

@@ -0,0 +1,184 @@
import React, { useEffect, useRef, useState } from "react";
import { Check, Code, GitBranch, Star, Rocket } from "lucide-react";
const timelineItems = [
{
year: "2026",
title: "Present",
description: "Building domain-specific languages, diving deep into the Salesforce ecosystem, and writing production Java and Python daily. The craft keeps evolving.",
technologies: ["Java", "Python", "Salesforce", "DSLs"],
icon: <Rocket className="text-red-bright" size={20} />,
},
{
year: "2024",
title: "Shipping & Scaling",
description: "The wisdom of past ventures now flows through my work, whether crafting elegant CRUD applications or embarking on bold projects that expand my limits.",
technologies: ["Rust", "Typescript", "Go", "Postgres"],
icon: <Code className="text-yellow-bright" size={20} />,
},
{
year: "2022",
title: "Diving Deeper",
description: "The worlds of systems programming and scalable infrastructure collided as I explored low-level C++ graphics programming and containerization with Docker.",
technologies: ["C++", "Cmake", "Docker", "Docker Compose"],
icon: <GitBranch className="text-green-bright" size={20} />,
},
{
year: "2020",
title: "Exploring the Stack",
description: "Starting with pure HTML and CSS, I explored the foundations of web development, gradually venturing into JavaScript and React to bring my static pages to life.",
technologies: ["Javascript", "Tailwind", "React", "Express"],
icon: <Star className="text-blue-bright" size={20} />,
},
{
year: "2018",
title: "Starting the Journey",
description: "An elective Python class in 8th grade transformed my keen interest in programming into a relentless obsession, one that drove me to constantly explore new depths.",
technologies: ["Python", "Discord.py", "Asyncio", "Sqlite"],
icon: <Check className="text-purple-bright" size={20} />,
},
];
function TimelineCard({ item, index }: { item: (typeof timelineItems)[number]; index: number }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [skip, setSkip] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
if (inView && isReload) {
setSkip(true);
setVisible(true);
return;
}
if (inView) {
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold: 0.2 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
const isLeft = index % 2 === 0;
return (
<div ref={ref} className="relative mb-8 md:mb-12 last:mb-0">
<div className={`flex flex-col sm:flex-row items-start ${isLeft ? "sm:flex-row-reverse" : ""}`}>
{/* Node */}
<div
className={`
absolute -left-8 sm:left-1/2 w-6 h-6 sm:w-8 sm:h-8 bg-background
rounded-full border-2 border-yellow-bright sm:-translate-x-1/2
flex items-center justify-center z-10
${skip ? "" : "transition-all duration-500"}
${visible ? "scale-100 opacity-100" : "scale-0 opacity-0"}
`}
>
{item.icon}
</div>
{/* Card */}
<div
className={`
w-full sm:w-[calc(50%-32px)]
${isLeft ? "sm:pr-8 md:pr-12" : "sm:pl-8 md:pl-12"}
${skip ? "" : "transition-all duration-700 ease-out"}
${visible
? "opacity-100 translate-x-0"
: `opacity-0 ${isLeft ? "sm:translate-x-8" : "sm:-translate-x-8"} translate-y-4 sm:translate-y-0`
}
`}
>
<div
className="p-4 sm:p-6 bg-background/50 rounded-lg border border-foreground/10
hover:border-yellow-bright/50 transition-colors duration-300"
>
<span className="text-xs sm:text-sm font-mono text-yellow-bright">{item.year}</span>
<h3 className="text-lg sm:text-xl font-bold text-foreground/90 mt-2">{item.title}</h3>
<p className="text-sm sm:text-base text-foreground/70 mt-2">{item.description}</p>
<div className="flex flex-wrap gap-2 mt-3">
{item.technologies.map((tech) => (
<span
key={tech}
className="px-2 py-1 text-xs sm:text-sm rounded-full bg-foreground/5
text-foreground/60 hover:text-yellow-bright transition-colors duration-300"
>
{tech}
</span>
))}
</div>
</div>
</div>
</div>
</div>
);
}
export default function Timeline() {
const lineRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [lineHeight, setLineHeight] = useState(0);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
// Animate line to full height over time
const el = lineRef.current;
if (el) {
setLineHeight(100);
}
observer.disconnect();
}
},
{ threshold: 0.1 }
);
observer.observe(container);
return () => observer.disconnect();
}, []);
return (
<div className="w-full max-w-6xl px-4 py-8 relative z-0">
<h2 className="text-2xl md:text-4xl font-bold text-center text-yellow-bright mb-8 md:mb-12">
My Journey Through Code
</h2>
<div ref={containerRef} className="relative">
{/* Animated vertical line */}
<div className="absolute left-4 sm:left-1/2 h-full w-0.5 -translate-x-1/2">
<div
ref={lineRef}
className="w-full bg-foreground/10 transition-all duration-[1500ms] ease-out origin-top"
style={{ height: `${lineHeight}%` }}
/>
</div>
<div className="ml-8 sm:ml-0">
{timelineItems.map((item, index) => (
<TimelineCard key={item.year} item={item} index={index} />
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { useEffect, useRef, useState } from "react";
interface AnimateInProps {
children: React.ReactNode;
delay?: number;
threshold?: number;
}
export function AnimateIn({ children, delay = 0, threshold = 0.15 }: AnimateInProps) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
if (rect.top < window.innerHeight && rect.bottom > 0) {
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold }
);
observer.observe(el);
return () => observer.disconnect();
}, [threshold]);
return (
<div
ref={ref}
className="transition-all duration-700 ease-out"
style={{
transitionDelay: `${delay}ms`,
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)",
}}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { useState, useEffect, useRef } from "react";
import {
getStoredAnimationId,
getNextAnimation,
saveAnimation,
} from "@/lib/animations/engine";
import { ANIMATION_LABELS } from "@/lib/animations";
export default function AnimationSwitcher() {
const [hovering, setHovering] = useState(false);
const [currentLabel, setCurrentLabel] = useState("");
const committedRef = useRef("");
useEffect(() => {
committedRef.current = getStoredAnimationId();
setCurrentLabel(ANIMATION_LABELS[committedRef.current]);
const handleSwap = () => {
const id = getStoredAnimationId();
committedRef.current = id;
setCurrentLabel(ANIMATION_LABELS[id]);
};
document.addEventListener("astro:after-swap", handleSwap);
return () => {
document.removeEventListener("astro:after-swap", handleSwap);
};
}, []);
const handleClick = () => {
const nextId = getNextAnimation(
committedRef.current as Parameters<typeof getNextAnimation>[0]
);
saveAnimation(nextId);
committedRef.current = nextId;
setCurrentLabel(ANIMATION_LABELS[nextId]);
document.dispatchEvent(
new CustomEvent("animation-changed", { detail: { id: nextId } })
);
};
return (
<div
className="fixed bottom-4 left-4 z-[101] pointer-events-auto hidden md:block"
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
onClick={handleClick}
style={{ cursor: "pointer" }}
>
<span
className="text-foreground font-bold text-sm select-none transition-opacity duration-200"
style={{ opacity: hovering ? 0.8 : 0.15 }}
>
{currentLabel}
</span>
</div>
);
}

View File

@@ -0,0 +1,574 @@
import type { AnimationEngine } from "@/lib/animations/types";
// --- ASCII Art ---
interface AsciiPattern {
lines: string[];
width: number;
height: number;
}
function pat(lines: string[]): AsciiPattern {
return {
lines,
width: Math.max(...lines.map((l) => l.length)),
height: lines.length,
};
}
const FISH_DEFS: {
size: "small" | "medium";
weight: number;
right: AsciiPattern;
left: AsciiPattern;
}[] = [
{ size: "small", weight: 30, right: pat(["><>"]), left: pat(["<><"]) },
{
size: "small",
weight: 30,
right: pat(["><(('>"]),
left: pat(["<'))><"]),
},
{
size: "medium",
weight: 20,
right: pat(["><((o>"]),
left: pat(["<o))><"]),
},
{
size: "medium",
weight: 10,
right: pat(["><((('>"]),
left: pat(["<')))><"]),
},
];
const TOTAL_FISH_WEIGHT = FISH_DEFS.reduce((s, d) => s + d.weight, 0);
const BUBBLE_CHARS = [".", "o", "O"];
// --- Entity Interfaces ---
interface FishEntity {
kind: "fish";
x: number;
y: number;
vx: number;
pattern: AsciiPattern;
size: "small" | "medium";
color: [number, number, number];
baseColor: [number, number, number];
opacity: number;
elevation: number;
targetElevation: number;
staggerDelay: number;
}
interface BubbleEntity {
kind: "bubble";
x: number;
y: number;
vy: number;
wobblePhase: number;
wobbleAmplitude: number;
char: string;
color: [number, number, number];
baseColor: [number, number, number];
opacity: number;
elevation: number;
targetElevation: number;
staggerDelay: number;
burst: boolean;
}
type AquariumEntity = FishEntity | BubbleEntity;
// --- Constants ---
const BASE_AREA = 1920 * 1080;
const BASE_FISH = 16;
const BASE_BUBBLES = 12;
const TARGET_FPS = 60;
const FONT_SIZE_MIN = 24;
const FONT_SIZE_MAX = 36;
const FONT_SIZE_REF_WIDTH = 1920;
const LINE_HEIGHT_RATIO = 1.15;
const STAGGER_INTERVAL = 15;
const PI_2 = Math.PI * 2;
const MOUSE_INFLUENCE_RADIUS = 150;
const ELEVATION_FACTOR = 6;
const ELEVATION_LERP_SPEED = 0.05;
const COLOR_SHIFT_AMOUNT = 30;
const SHADOW_OFFSET_RATIO = 1.1;
const FISH_SPEED: Record<string, { min: number; max: number }> = {
small: { min: 0.8, max: 1.4 },
medium: { min: 0.5, max: 0.9 },
};
const BUBBLE_SPEED_MIN = 0.3;
const BUBBLE_SPEED_MAX = 0.7;
const BUBBLE_WOBBLE_MIN = 0.3;
const BUBBLE_WOBBLE_MAX = 1.0;
const BURST_BUBBLE_COUNT = 10;
// --- Helpers ---
function range(a: number, b: number): number {
return (b - a) * Math.random() + a;
}
function pickFishDef() {
let r = Math.random() * TOTAL_FISH_WEIGHT;
for (const def of FISH_DEFS) {
r -= def.weight;
if (r <= 0) return def;
}
return FISH_DEFS[0];
}
// --- Engine ---
export class AsciiquariumEngine implements AnimationEngine {
id = "asciiquarium";
name = "Asciiquarium";
private fish: FishEntity[] = [];
private bubbles: BubbleEntity[] = [];
private exiting = false;
private palette: [number, number, number][] = [];
private width = 0;
private height = 0;
private mouseX = -1000;
private mouseY = -1000;
private elapsed = 0;
private charWidth = 0;
private fontSize = FONT_SIZE_MAX;
private lineHeight = FONT_SIZE_MAX * LINE_HEIGHT_RATIO;
private font = `bold ${FONT_SIZE_MAX}px monospace`;
private computeFont(width: number): void {
const t = Math.sqrt(Math.min(1, width / FONT_SIZE_REF_WIDTH));
this.fontSize = Math.round(FONT_SIZE_MIN + (FONT_SIZE_MAX - FONT_SIZE_MIN) * t);
this.lineHeight = Math.round(this.fontSize * LINE_HEIGHT_RATIO);
this.font = `bold ${this.fontSize}px monospace`;
this.charWidth = 0;
}
init(
width: number,
height: number,
palette: [number, number, number][],
_bgColor: string
): void {
this.width = width;
this.height = height;
this.palette = palette;
this.elapsed = 0;
this.computeFont(width);
this.initEntities();
}
beginExit(): void {
if (this.exiting) return;
this.exiting = true;
// Stagger fade-out over 3 seconds
for (const f of this.fish) {
const delay = Math.random() * 3000;
setTimeout(() => {
f.staggerDelay = -2; // signal: fading out
}, delay);
}
for (const b of this.bubbles) {
const delay = Math.random() * 3000;
setTimeout(() => {
b.staggerDelay = -2;
}, delay);
}
}
isExitComplete(): boolean {
if (!this.exiting) return false;
for (const f of this.fish) {
if (f.opacity > 0.01) return false;
}
for (const b of this.bubbles) {
if (b.opacity > 0.01) return false;
}
return true;
}
cleanup(): void {
this.fish = [];
this.bubbles = [];
}
private randomColor(): [number, number, number] {
return this.palette[Math.floor(Math.random() * this.palette.length)];
}
private getCounts(): { fish: number; bubbles: number } {
const ratio = (this.width * this.height) / BASE_AREA;
return {
fish: Math.max(5, Math.round(BASE_FISH * ratio)),
bubbles: Math.max(5, Math.round(BASE_BUBBLES * ratio)),
};
}
private initEntities(): void {
this.fish = [];
this.bubbles = [];
const counts = this.getCounts();
let idx = 0;
for (let i = 0; i < counts.fish; i++) {
this.fish.push(this.spawnFish(idx++));
}
for (let i = 0; i < counts.bubbles; i++) {
this.bubbles.push(this.spawnBubble(idx++, false));
}
}
private spawnFish(staggerIdx: number): FishEntity {
const def = pickFishDef();
const goRight = Math.random() > 0.5;
const speed = range(FISH_SPEED[def.size].min, FISH_SPEED[def.size].max);
const pattern = goRight ? def.right : def.left;
const baseColor = this.randomColor();
const cw = this.charWidth || 9.6;
const pw = pattern.width * cw;
// Start off-screen on the side they swim from
const startX = goRight
? -pw - range(0, this.width * 0.5)
: this.width + range(0, this.width * 0.5);
return {
kind: "fish",
x: startX,
y: range(this.height * 0.05, this.height * 0.9),
vx: goRight ? speed : -speed,
pattern,
size: def.size,
color: [...baseColor],
baseColor,
opacity: 1,
elevation: 0,
targetElevation: 0,
staggerDelay: -1,
};
}
private spawnBubble(staggerIdx: number, burst: boolean): BubbleEntity {
const baseColor = this.randomColor();
return {
kind: "bubble",
x: range(0, this.width),
y: burst ? 0 : this.height + range(10, this.height * 0.5),
vy: -range(BUBBLE_SPEED_MIN, BUBBLE_SPEED_MAX),
wobblePhase: range(0, PI_2),
wobbleAmplitude: range(BUBBLE_WOBBLE_MIN, BUBBLE_WOBBLE_MAX),
char: BUBBLE_CHARS[Math.floor(Math.random() * BUBBLE_CHARS.length)],
color: [...baseColor],
baseColor,
opacity: 1,
elevation: 0,
targetElevation: 0,
staggerDelay: -1,
burst,
};
}
// --- Update ---
update(deltaTime: number): void {
const dt = deltaTime / (1000 / TARGET_FPS);
this.elapsed += deltaTime;
const mouseX = this.mouseX;
const mouseY = this.mouseY;
const cw = this.charWidth || 9.6;
// Fish
for (let i = this.fish.length - 1; i >= 0; i--) {
const f = this.fish[i];
if (f.staggerDelay >= 0) {
if (this.elapsed >= f.staggerDelay) f.staggerDelay = -1;
else continue;
}
// Fade out during exit
if (f.staggerDelay === -2) {
f.opacity -= 0.02 * dt;
if (f.opacity <= 0) { f.opacity = 0; continue; }
} else if (f.opacity < 1) {
f.opacity = Math.min(1, f.opacity + 0.03 * dt);
}
f.x += f.vx * dt;
const pw = f.pattern.width * cw;
if (f.vx > 0 && f.x > this.width + pw) {
f.x = -pw;
} else if (f.vx < 0 && f.x < -pw) {
f.x = this.width + pw;
}
const cx = f.x + (f.pattern.width * cw) / 2;
const cy = f.y + (f.pattern.height * this.lineHeight) / 2;
this.applyMouseInfluence(f, cx, cy, mouseX, mouseY, dt);
}
// Bubbles (reverse iteration for safe splice)
for (let i = this.bubbles.length - 1; i >= 0; i--) {
const b = this.bubbles[i];
if (b.staggerDelay >= 0) {
if (this.elapsed >= b.staggerDelay) b.staggerDelay = -1;
else continue;
}
// Fade out during exit
if (b.staggerDelay === -2) {
b.opacity -= 0.02 * dt;
if (b.opacity <= 0) { b.opacity = 0; continue; }
} else if (b.opacity < 1) {
b.opacity = Math.min(1, b.opacity + 0.03 * dt);
}
b.y += b.vy * dt;
b.x +=
Math.sin(this.elapsed * 0.003 + b.wobblePhase) *
b.wobbleAmplitude *
dt;
if (b.y < -20) {
if (b.burst) {
this.bubbles.splice(i, 1);
continue;
} else {
b.y = this.height + range(10, 40);
b.x = range(0, this.width);
}
}
this.applyMouseInfluence(b, b.x, b.y, mouseX, mouseY, dt);
}
}
private applyMouseInfluence(
entity: AquariumEntity,
cx: number,
cy: number,
mouseX: number,
mouseY: number,
dt: number
): void {
const dx = cx - mouseX;
const dy = cy - mouseY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < MOUSE_INFLUENCE_RADIUS && entity.opacity > 0.1) {
const inf = Math.cos((dist / MOUSE_INFLUENCE_RADIUS) * (Math.PI / 2));
entity.targetElevation = ELEVATION_FACTOR * inf * inf;
const shift = inf * COLOR_SHIFT_AMOUNT * 0.5;
entity.color = [
Math.min(255, Math.max(0, entity.baseColor[0] + shift)),
Math.min(255, Math.max(0, entity.baseColor[1] + shift)),
Math.min(255, Math.max(0, entity.baseColor[2] + shift)),
];
} else {
entity.targetElevation = 0;
entity.color[0] += (entity.baseColor[0] - entity.color[0]) * 0.1;
entity.color[1] += (entity.baseColor[1] - entity.color[1]) * 0.1;
entity.color[2] += (entity.baseColor[2] - entity.color[2]) * 0.1;
}
entity.elevation +=
(entity.targetElevation - entity.elevation) * ELEVATION_LERP_SPEED * dt;
}
// --- Render ---
render(
ctx: CanvasRenderingContext2D,
_width: number,
_height: number
): void {
if (!this.charWidth) {
ctx.font = this.font;
this.charWidth = ctx.measureText("M").width;
}
ctx.font = this.font;
ctx.textBaseline = "top";
// Fish
for (const f of this.fish) {
if (f.opacity <= 0.01 || f.staggerDelay >= 0) continue;
this.renderPattern(
ctx,
f.pattern,
f.x,
f.y,
f.color,
f.opacity,
f.elevation
);
}
// Bubbles
for (const b of this.bubbles) {
if (b.opacity <= 0.01 || b.staggerDelay >= 0) continue;
this.renderChar(ctx, b.char, b.x, b.y, b.color, b.opacity, b.elevation);
}
ctx.globalAlpha = 1;
}
private renderPattern(
ctx: CanvasRenderingContext2D,
pattern: AsciiPattern,
x: number,
y: number,
color: [number, number, number],
opacity: number,
elevation: number
): void {
const drawY = y - elevation;
const [r, g, b] = color;
// Shadow
if (elevation > 0.5) {
const shadowAlpha = 0.2 * (elevation / ELEVATION_FACTOR) * opacity;
ctx.globalAlpha = shadowAlpha;
ctx.fillStyle = "rgb(0,0,0)";
for (let line = 0; line < pattern.height; line++) {
ctx.fillText(
pattern.lines[line],
x,
drawY + line * this.lineHeight + elevation * SHADOW_OFFSET_RATIO
);
}
}
// Main text
ctx.globalAlpha = opacity;
ctx.fillStyle = `rgb(${r},${g},${b})`;
for (let line = 0; line < pattern.height; line++) {
ctx.fillText(pattern.lines[line], x, drawY + line * this.lineHeight);
}
// Highlight (top half of lines)
if (elevation > 0.5) {
const highlightAlpha = 0.1 * (elevation / ELEVATION_FACTOR) * opacity;
ctx.globalAlpha = highlightAlpha;
ctx.fillStyle = "rgb(255,255,255)";
const topLines = Math.ceil(pattern.height / 2);
for (let line = 0; line < topLines; line++) {
ctx.fillText(pattern.lines[line], x, drawY + line * this.lineHeight);
}
}
}
private renderChar(
ctx: CanvasRenderingContext2D,
char: string,
x: number,
y: number,
color: [number, number, number],
opacity: number,
elevation: number
): void {
const drawY = y - elevation;
const [r, g, b] = color;
// Shadow
if (elevation > 0.5) {
const shadowAlpha = 0.2 * (elevation / ELEVATION_FACTOR) * opacity;
ctx.globalAlpha = shadowAlpha;
ctx.fillStyle = "rgb(0,0,0)";
ctx.fillText(char, x, drawY + elevation * SHADOW_OFFSET_RATIO);
}
// Main
ctx.globalAlpha = opacity;
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.fillText(char, x, drawY);
// Highlight
if (elevation > 0.5) {
const highlightAlpha = 0.1 * (elevation / ELEVATION_FACTOR) * opacity;
ctx.globalAlpha = highlightAlpha;
ctx.fillStyle = "rgb(255,255,255)";
ctx.fillText(char, x, drawY);
}
}
// --- Events ---
handleResize(width: number, height: number): void {
this.width = width;
this.height = height;
this.elapsed = 0;
this.exiting = false;
this.computeFont(width);
this.initEntities();
}
handleMouseMove(x: number, y: number, _isDown: boolean): void {
this.mouseX = x;
this.mouseY = y;
}
handleMouseDown(x: number, y: number): void {
for (let i = 0; i < BURST_BUBBLE_COUNT; i++) {
const baseColor = this.randomColor();
const angle = (i / BURST_BUBBLE_COUNT) * PI_2 + range(-0.3, 0.3);
const speed = range(0.3, 1.0);
this.bubbles.push({
kind: "bubble",
x,
y,
vy: -Math.abs(Math.sin(angle) * speed) - 0.3,
wobblePhase: range(0, PI_2),
wobbleAmplitude: Math.cos(angle) * speed * 0.5,
char: BUBBLE_CHARS[Math.floor(Math.random() * BUBBLE_CHARS.length)],
color: [...baseColor],
baseColor,
opacity: 1,
elevation: 0,
targetElevation: 0,
staggerDelay: this.exiting ? -2 : -1,
burst: true,
});
}
}
handleMouseUp(): void {}
handleMouseLeave(): void {
this.mouseX = -1000;
this.mouseY = -1000;
}
updatePalette(
palette: [number, number, number][],
_bgColor: string
): void {
this.palette = palette;
for (let i = 0; i < this.fish.length; i++) {
this.fish[i].baseColor = palette[i % palette.length];
}
for (let i = 0; i < this.bubbles.length; i++) {
this.bubbles[i].baseColor = palette[i % palette.length];
}
}
}

View File

@@ -0,0 +1,336 @@
import type { AnimationEngine } from "@/lib/animations/types";
interface ConfettiParticle {
x: number;
y: number;
vx: number;
vy: number;
r: number;
color: [number, number, number];
baseColor: [number, number, number];
opacity: number;
dop: number;
elevation: number;
targetElevation: number;
staggerDelay: number;
burst: boolean;
}
const BASE_CONFETTI = 385;
const BASE_AREA = 1920 * 1080;
const PI_2 = 2 * Math.PI;
const TARGET_FPS = 60;
const SPEED_FACTOR = 0.15;
const STAGGER_INTERVAL = 12;
const COLOR_LERP_SPEED = 0.02;
const MOUSE_INFLUENCE_RADIUS = 150;
const ELEVATION_FACTOR = 6;
const ELEVATION_LERP_SPEED = 0.05;
const COLOR_SHIFT_AMOUNT = 30;
const SHADOW_OFFSET_RATIO = 1.1;
function range(a: number, b: number): number {
return (b - a) * Math.random() + a;
}
export class ConfettiEngine implements AnimationEngine {
id = "confetti";
name = "Confetti";
private particles: ConfettiParticle[] = [];
private palette: [number, number, number][] = [];
private width = 0;
private height = 0;
private mouseX = -1000;
private mouseY = -1000;
private mouseXNorm = 0.5;
private elapsed = 0;
private exiting = false;
init(
width: number,
height: number,
palette: [number, number, number][],
_bgColor: string
): void {
this.width = width;
this.height = height;
this.palette = palette;
this.elapsed = 0;
this.mouseXNorm = 0.5;
this.initParticles();
}
beginExit(): void {
if (this.exiting) return;
this.exiting = true;
// Stagger fade-out over 3 seconds
for (let i = 0; i < this.particles.length; i++) {
const p = this.particles[i];
p.staggerDelay = -1; // ensure visible
// Random delay before fade starts, stored as negative dop
const delay = Math.random() * 3000;
setTimeout(() => {
p.dop = -0.02;
}, delay);
}
}
isExitComplete(): boolean {
if (!this.exiting) return false;
for (let i = 0; i < this.particles.length; i++) {
if (this.particles[i].opacity > 0.01) return false;
}
return true;
}
cleanup(): void {
this.particles = [];
}
private randomColor(): [number, number, number] {
return this.palette[Math.floor(Math.random() * this.palette.length)];
}
private getParticleCount(): number {
const area = this.width * this.height;
return Math.max(20, Math.round(BASE_CONFETTI * (area / BASE_AREA)));
}
private initParticles(): void {
this.particles = [];
const count = this.getParticleCount();
for (let i = 0; i < count; i++) {
const baseColor = this.randomColor();
const r = ~~range(3, 8);
this.particles.push({
x: range(-r * 2, this.width - r * 2),
y: range(-20, this.height - r * 2),
vx: (range(0, 2) + 8 * 0.5 - 5) * SPEED_FACTOR,
vy: (0.7 * r + range(-1, 1)) * SPEED_FACTOR,
r,
color: [...baseColor],
baseColor,
opacity: 0,
dop: 0.03 * range(1, 4) * SPEED_FACTOR,
elevation: 0,
targetElevation: 0,
staggerDelay: i * STAGGER_INTERVAL + range(0, STAGGER_INTERVAL),
burst: false,
});
}
}
private replaceParticle(p: ConfettiParticle): void {
p.opacity = 0;
p.dop = 0.03 * range(1, 4) * SPEED_FACTOR;
p.x = range(-p.r * 2, this.width - p.r * 2);
p.y = range(-20, -p.r * 2);
p.vx = (range(0, 2) + 8 * this.mouseXNorm - 5) * SPEED_FACTOR;
p.vy = (0.7 * p.r + range(-1, 1)) * SPEED_FACTOR;
p.elevation = 0;
p.targetElevation = 0;
p.baseColor = this.randomColor();
p.color = [...p.baseColor];
p.burst = false;
}
update(deltaTime: number): void {
const dt = deltaTime / (1000 / TARGET_FPS);
this.elapsed += deltaTime;
const mouseX = this.mouseX;
const mouseY = this.mouseY;
for (let i = 0; i < this.particles.length; i++) {
const p = this.particles[i];
// Stagger gate
if (p.staggerDelay >= 0) {
if (this.elapsed >= p.staggerDelay) {
p.staggerDelay = -1;
} else {
continue;
}
}
// Gravity (capped so falling particles don't accelerate)
const maxVy = (0.7 * p.r + 1) * SPEED_FACTOR;
if (p.vy < maxVy) {
p.vy = Math.min(p.vy + 0.02 * dt, maxVy);
}
// Position update
p.x += p.vx * dt;
p.y += p.vy * dt;
// Fade in, or fade out during exit
if (this.exiting && p.dop < 0) {
p.opacity += p.dop * dt;
if (p.opacity < 0) p.opacity = 0;
} else if (p.opacity < 1) {
p.opacity += Math.abs(p.dop) * dt;
if (p.opacity > 1) p.opacity = 1;
}
// Past the bottom: burst particles removed, base particles recycle (or remove during exit)
if (p.y > this.height + p.r) {
if (p.burst || this.exiting) {
this.particles.splice(i, 1);
i--;
} else {
this.replaceParticle(p);
}
continue;
}
// Horizontal wrap
const xmax = this.width - p.r;
if (p.x < 0 || p.x > xmax) {
p.x = ((p.x % xmax) + xmax) % xmax;
}
// Mouse proximity elevation
const dx = p.x - mouseX;
const dy = p.y - mouseY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < MOUSE_INFLUENCE_RADIUS && p.opacity > 0.1) {
const influenceFactor = Math.cos(
(dist / MOUSE_INFLUENCE_RADIUS) * (Math.PI / 2)
);
p.targetElevation =
ELEVATION_FACTOR * influenceFactor * influenceFactor;
const shift = influenceFactor * COLOR_SHIFT_AMOUNT * 0.5;
p.color = [
Math.min(255, Math.max(0, p.baseColor[0] + shift)),
Math.min(255, Math.max(0, p.baseColor[1] + shift)),
Math.min(255, Math.max(0, p.baseColor[2] + shift)),
];
} else {
p.targetElevation = 0;
p.color[0] += (p.baseColor[0] - p.color[0]) * 0.1;
p.color[1] += (p.baseColor[1] - p.color[1]) * 0.1;
p.color[2] += (p.baseColor[2] - p.color[2]) * 0.1;
}
// Elevation lerp
p.elevation +=
(p.targetElevation - p.elevation) * ELEVATION_LERP_SPEED * dt;
}
}
render(
ctx: CanvasRenderingContext2D,
_width: number,
_height: number
): void {
for (let i = 0; i < this.particles.length; i++) {
const p = this.particles[i];
if (p.opacity <= 0.01 || p.staggerDelay >= 0) continue;
const drawX = ~~p.x;
const drawY = ~~p.y - p.elevation;
const [r, g, b] = p.color;
// Shadow
if (p.elevation > 0.5) {
const shadowAlpha =
0.2 * (p.elevation / ELEVATION_FACTOR) * p.opacity;
ctx.globalAlpha = shadowAlpha;
ctx.fillStyle = "rgb(0,0,0)";
ctx.shadowBlur = 2;
ctx.shadowColor = "rgba(0,0,0,0.1)";
ctx.beginPath();
ctx.arc(
drawX,
drawY + p.elevation * SHADOW_OFFSET_RATIO,
p.r,
0,
PI_2
);
ctx.fill();
ctx.shadowBlur = 0;
ctx.shadowColor = "transparent";
}
// Main circle
ctx.globalAlpha = p.opacity;
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.beginPath();
ctx.arc(drawX, drawY, p.r, 0, PI_2);
ctx.fill();
// Highlight on elevated particles
if (p.elevation > 0.5) {
const highlightAlpha =
0.1 * (p.elevation / ELEVATION_FACTOR) * p.opacity;
ctx.globalAlpha = highlightAlpha;
ctx.fillStyle = "rgb(255,255,255)";
ctx.beginPath();
ctx.arc(drawX, drawY, p.r, Math.PI, 0);
ctx.fill();
}
}
ctx.globalAlpha = 1;
}
handleResize(width: number, height: number): void {
this.width = width;
this.height = height;
this.elapsed = 0;
this.initParticles();
}
handleMouseMove(x: number, y: number, _isDown: boolean): void {
this.mouseX = x;
this.mouseY = y;
if (this.width > 0) {
this.mouseXNorm = Math.max(0, Math.min(1, x / this.width));
}
}
handleMouseDown(x: number, y: number): void {
const count = 12;
for (let i = 0; i < count; i++) {
const baseColor = this.randomColor();
const r = ~~range(3, 8);
const angle = (i / count) * PI_2 + range(-0.3, 0.3);
const speed = range(0.3, 1.2);
this.particles.push({
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
r,
color: [...baseColor],
baseColor,
opacity: 1,
dop: this.exiting ? -0.02 : 0,
elevation: 0,
targetElevation: 0,
staggerDelay: -1,
burst: true,
});
}
}
handleMouseUp(): void {}
handleMouseLeave(): void {
this.mouseX = -1000;
this.mouseY = -1000;
this.mouseXNorm = 0.5;
}
updatePalette(palette: [number, number, number][], _bgColor: string): void {
this.palette = palette;
for (let i = 0; i < this.particles.length; i++) {
this.particles[i].baseColor = palette[i % palette.length];
}
}
}

View File

@@ -0,0 +1,670 @@
import type { AnimationEngine } from "@/lib/animations/types";
interface Cell {
alive: boolean;
next: boolean;
color: [number, number, number];
baseColor: [number, number, number];
currentX: number;
currentY: number;
targetX: number;
targetY: number;
opacity: number;
targetOpacity: number;
scale: number;
targetScale: number;
elevation: number;
targetElevation: number;
transitioning: boolean;
transitionComplete: boolean;
rippleEffect: number;
rippleStartTime: number;
rippleDistance: number;
}
interface Grid {
cells: Cell[][];
cols: number;
rows: number;
offsetX: number;
offsetY: number;
}
const CELL_SIZE_MOBILE = 15;
const CELL_SIZE_DESKTOP = 25;
const TARGET_FPS = 60;
const CYCLE_TIME = 3000;
const TRANSITION_SPEED = 0.05;
const SCALE_SPEED = 0.05;
const INITIAL_DENSITY = 0.15;
const MOUSE_INFLUENCE_RADIUS = 150;
const COLOR_SHIFT_AMOUNT = 30;
const RIPPLE_ELEVATION_FACTOR = 4;
const ELEVATION_FACTOR = 8;
export class GameOfLifeEngine implements AnimationEngine {
id = "game-of-life";
name = "Game of Life";
private grid: Grid | null = null;
private palette: [number, number, number][] = [];
private bgColor = "rgb(0, 0, 0)";
private mouseX = -1000;
private mouseY = -1000;
private mouseIsDown = false;
private mouseCellX = -1;
private mouseCellY = -1;
private lastCycleTime = 0;
private timeAccumulator = 0;
private pendingTimeouts: ReturnType<typeof setTimeout>[] = [];
private canvasWidth = 0;
private canvasHeight = 0;
private exiting = false;
init(
width: number,
height: number,
palette: [number, number, number][],
bgColor: string
): void {
this.palette = palette;
this.bgColor = bgColor;
this.canvasWidth = width;
this.canvasHeight = height;
this.lastCycleTime = 0;
this.timeAccumulator = 0;
this.grid = this.initGrid(width, height);
}
cleanup(): void {
for (const id of this.pendingTimeouts) {
clearTimeout(id);
}
this.pendingTimeouts = [];
this.grid = null;
}
private getCellSize(): number {
return this.canvasWidth <= 768 ? CELL_SIZE_MOBILE : CELL_SIZE_DESKTOP;
}
private randomColor(): [number, number, number] {
return this.palette[Math.floor(Math.random() * this.palette.length)];
}
private initGrid(width: number, height: number): Grid {
const cellSize = this.getCellSize();
const cols = Math.floor(width / cellSize);
const rows = Math.floor(height / cellSize);
const offsetX = Math.floor((width - cols * cellSize) / 2);
const offsetY = Math.floor((height - rows * cellSize) / 2);
const cells = Array(cols)
.fill(0)
.map((_, i) =>
Array(rows)
.fill(0)
.map((_, j) => {
const baseColor = this.randomColor();
return {
alive: Math.random() < INITIAL_DENSITY,
next: false,
color: [...baseColor] as [number, number, number],
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,
rippleStartTime: 0,
rippleDistance: 0,
};
})
);
const grid = { cells, cols, rows, offsetX, offsetY };
this.computeNextState(grid);
for (let i = 0; i < cols; i++) {
for (let j = 0; j < rows; j++) {
const cell = cells[i][j];
if (cell.next) {
cell.alive = true;
const tid = setTimeout(() => {
cell.targetOpacity = 1;
cell.targetScale = 1;
}, Math.random() * 1000);
this.pendingTimeouts.push(tid);
} else {
cell.alive = false;
}
}
}
return grid;
}
private countNeighbors(
grid: Grid,
x: number,
y: number
): { count: number; colors: [number, number, number][] } {
const neighbors = { count: 0, colors: [] as [number, number, number][] };
for (let i = -1; i <= 1; i++) {
for (let j = -1; j <= 1; j++) {
if (i === 0 && j === 0) continue;
const col = (x + i + grid.cols) % grid.cols;
const row = (y + j + grid.rows) % grid.rows;
if (grid.cells[col][row].alive) {
neighbors.count++;
neighbors.colors.push(grid.cells[col][row].baseColor);
}
}
}
return neighbors;
}
private averageColors(
colors: [number, number, number][]
): [number, number, number] {
if (colors.length === 0) return [0, 0, 0];
const sum = colors.reduce(
(acc, color) => [acc[0] + color[0], acc[1] + color[1], acc[2] + color[2]],
[0, 0, 0]
);
return [
Math.round(sum[0] / colors.length),
Math.round(sum[1] / colors.length),
Math.round(sum[2] / colors.length),
];
}
private computeNextState(grid: Grid): void {
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 } = this.countNeighbors(grid, i, j);
if (cell.alive) {
cell.next = count === 2 || count === 3;
} else {
cell.next = count === 3;
if (cell.next) {
cell.baseColor = this.averageColors(colors);
cell.color = [...cell.baseColor];
}
}
}
}
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;
const delay = Math.random() * 800;
const tid = setTimeout(() => {
if (!cell.next) {
cell.targetScale = 0;
cell.targetOpacity = 0;
cell.targetElevation = 0;
} else {
cell.targetScale = 1;
cell.targetOpacity = 1;
cell.targetElevation = 0;
}
}, delay);
this.pendingTimeouts.push(tid);
}
}
}
}
private createRippleEffect(
grid: Grid,
centerX: number,
centerY: number
): void {
const currentTime = Date.now();
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
const dx = i - centerX;
const dy = j - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (cell.opacity > 0.1) {
cell.rippleStartTime = currentTime + distance * 100;
cell.rippleDistance = distance;
cell.rippleEffect = 0;
}
}
}
}
private spawnCellAtPosition(grid: Grid, x: number, y: number): void {
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 = this.randomColor();
cell.color = [...cell.baseColor];
cell.targetScale = 1;
cell.targetOpacity = 1;
cell.targetElevation = 0;
this.createRippleEffect(grid, x, y);
}
}
}
beginExit(): void {
if (this.exiting || !this.grid) return;
this.exiting = true;
// Cancel all pending GOL transitions so they don't revive cells
for (const id of this.pendingTimeouts) {
clearTimeout(id);
}
this.pendingTimeouts = [];
const grid = this.grid;
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
// Force cell into dying state, clear any pending transition
cell.next = false;
cell.transitioning = false;
cell.transitionComplete = false;
if (cell.opacity > 0.01) {
const delay = Math.random() * 3000;
const tid = setTimeout(() => {
cell.targetOpacity = 0;
cell.targetScale = 0;
cell.targetElevation = 0;
}, delay);
this.pendingTimeouts.push(tid);
}
}
}
}
isExitComplete(): boolean {
if (!this.exiting) return false;
if (!this.grid) return true;
const grid = this.grid;
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
if (grid.cells[i][j].opacity > 0.01) return false;
}
}
return true;
}
update(deltaTime: number): void {
if (!this.grid) return;
if (!this.exiting) {
this.timeAccumulator += deltaTime;
if (this.timeAccumulator >= CYCLE_TIME) {
this.computeNextState(this.grid);
this.timeAccumulator -= CYCLE_TIME;
}
}
this.updateCellAnimations(this.grid, deltaTime);
}
private updateCellAnimations(grid: Grid, deltaTime: number): void {
const mouseX = this.mouseX;
const mouseY = this.mouseY;
const cellSize = this.getCellSize();
const transitionFactor =
TRANSITION_SPEED * (deltaTime / (1000 / TARGET_FPS));
const scaleFactor = SCALE_SPEED * (deltaTime / (1000 / TARGET_FPS));
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
cell.opacity += (cell.targetOpacity - cell.opacity) * transitionFactor;
cell.scale += (cell.targetScale - cell.scale) * scaleFactor;
cell.elevation +=
(cell.targetElevation - cell.elevation) * scaleFactor;
const cellCenterX = grid.offsetX + i * cellSize + cellSize / 2;
const cellCenterY = grid.offsetY + j * cellSize + cellSize / 2;
const dx = cellCenterX - mouseX;
const dy = cellCenterY - mouseY;
const distanceToMouse = Math.sqrt(dx * dx + dy * dy);
if (distanceToMouse < MOUSE_INFLUENCE_RADIUS && cell.opacity > 0.1) {
const influenceFactor = Math.cos(
(distanceToMouse / MOUSE_INFLUENCE_RADIUS) * (Math.PI / 2)
);
cell.targetElevation =
ELEVATION_FACTOR * influenceFactor * influenceFactor;
const colorShift = influenceFactor * COLOR_SHIFT_AMOUNT * 0.5;
cell.color = [
Math.min(255, Math.max(0, cell.baseColor[0] + colorShift)),
Math.min(255, Math.max(0, cell.baseColor[1] + colorShift)),
Math.min(255, Math.max(0, cell.baseColor[2] + colorShift)),
] as [number, number, number];
} else {
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;
cell.targetElevation = 0;
}
// During exit: snap to zero once close enough
if (this.exiting) {
if (cell.opacity < 0.05) {
cell.opacity = 0;
cell.scale = 0;
cell.elevation = 0;
cell.alive = false;
}
} else if (cell.transitioning) {
if (!cell.next && cell.opacity < 0.01 && cell.scale < 0.01) {
cell.alive = false;
cell.transitioning = false;
cell.transitionComplete = true;
cell.opacity = 0;
cell.scale = 0;
cell.elevation = 0;
} else if (cell.next && !cell.alive && !cell.transitionComplete) {
cell.alive = true;
cell.transitioning = false;
cell.transitionComplete = true;
}
}
if (cell.rippleStartTime > 0) {
const elapsedTime = Date.now() - cell.rippleStartTime;
if (elapsedTime > 0) {
const rippleProgress = elapsedTime / 1000;
if (rippleProgress < 1) {
const wavePhase = rippleProgress * Math.PI * 2;
const waveHeight =
Math.sin(wavePhase) * Math.exp(-rippleProgress * 4);
if (distanceToMouse >= MOUSE_INFLUENCE_RADIUS) {
cell.rippleEffect = waveHeight;
cell.targetElevation = RIPPLE_ELEVATION_FACTOR * waveHeight;
} else {
cell.rippleEffect = waveHeight * 0.3;
}
} else {
cell.rippleEffect = 0;
cell.rippleStartTime = 0;
if (distanceToMouse >= MOUSE_INFLUENCE_RADIUS) {
cell.targetElevation = 0;
}
}
}
}
}
}
}
render(
ctx: CanvasRenderingContext2D,
width: number,
height: number
): void {
if (!this.grid) return;
const grid = this.grid;
const cellSize = this.getCellSize();
const displayCellSize = cellSize * 0.8;
const roundness = displayCellSize * 0.2;
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.targetOpacity > 0) &&
cell.opacity > 0.01
) {
const [r, g, b] = cell.color;
ctx.globalAlpha = cell.opacity * 0.9;
const scaledSize = displayCellSize * cell.scale;
const xOffset = (displayCellSize - scaledSize) / 2;
const yOffset = (displayCellSize - scaledSize) / 2;
const elevationOffset = cell.elevation;
const x =
grid.offsetX +
i * cellSize +
(cellSize - displayCellSize) / 2 +
xOffset;
const y =
grid.offsetY +
j * cellSize +
(cellSize - displayCellSize) / 2 +
yOffset -
elevationOffset;
const scaledRoundness = roundness * cell.scale;
// Shadow for 3D effect
if (elevationOffset > 0.5) {
ctx.fillStyle = `rgba(0, 0, 0, ${0.2 * (elevationOffset / ELEVATION_FACTOR)})`;
ctx.beginPath();
ctx.moveTo(x + scaledRoundness, y + elevationOffset * 1.1);
ctx.lineTo(
x + scaledSize - scaledRoundness,
y + elevationOffset * 1.1
);
ctx.quadraticCurveTo(
x + scaledSize,
y + elevationOffset * 1.1,
x + scaledSize,
y + elevationOffset * 1.1 + scaledRoundness
);
ctx.lineTo(
x + scaledSize,
y + elevationOffset * 1.1 + scaledSize - scaledRoundness
);
ctx.quadraticCurveTo(
x + scaledSize,
y + elevationOffset * 1.1 + scaledSize,
x + scaledSize - scaledRoundness,
y + elevationOffset * 1.1 + scaledSize
);
ctx.lineTo(
x + scaledRoundness,
y + elevationOffset * 1.1 + scaledSize
);
ctx.quadraticCurveTo(
x,
y + elevationOffset * 1.1 + scaledSize,
x,
y + elevationOffset * 1.1 + scaledSize - scaledRoundness
);
ctx.lineTo(x, y + elevationOffset * 1.1 + scaledRoundness);
ctx.quadraticCurveTo(
x,
y + elevationOffset * 1.1,
x + scaledRoundness,
y + elevationOffset * 1.1
);
ctx.fill();
}
// Main cell
ctx.fillStyle = `rgb(${r},${g},${b})`;
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 - scaledRoundness);
ctx.quadraticCurveTo(
x + scaledSize,
y + scaledSize,
x + scaledSize - scaledRoundness,
y + scaledSize
);
ctx.lineTo(x + scaledRoundness, y + scaledSize);
ctx.quadraticCurveTo(x, y + scaledSize, x, y + scaledSize - scaledRoundness);
ctx.lineTo(x, y + scaledRoundness);
ctx.quadraticCurveTo(x, y, x + scaledRoundness, y);
ctx.fill();
// Highlight on elevated cells
if (elevationOffset > 0.5) {
ctx.fillStyle = `rgba(255, 255, 255, ${0.1 * (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();
}
}
}
}
ctx.globalAlpha = 1;
}
handleResize(width: number, height: number): void {
this.canvasWidth = width;
this.canvasHeight = height;
const cellSize = this.getCellSize();
if (
!this.grid ||
this.grid.cols !== Math.floor(width / cellSize) ||
this.grid.rows !== Math.floor(height / cellSize)
) {
for (const id of this.pendingTimeouts) {
clearTimeout(id);
}
this.pendingTimeouts = [];
this.grid = this.initGrid(width, height);
}
}
handleMouseMove(x: number, y: number, isDown: boolean): void {
this.mouseX = x;
this.mouseY = y;
this.mouseIsDown = isDown;
if (isDown && this.grid && !this.exiting) {
const grid = this.grid;
const cellSize = this.getCellSize();
const cellX = Math.floor((x - grid.offsetX) / cellSize);
const cellY = Math.floor((y - grid.offsetY) / cellSize);
if (cellX !== this.mouseCellX || cellY !== this.mouseCellY) {
this.mouseCellX = cellX;
this.mouseCellY = cellY;
if (
cellX >= 0 &&
cellX < grid.cols &&
cellY >= 0 &&
cellY < grid.rows
) {
const cell = grid.cells[cellX][cellY];
if (!cell.alive && !cell.transitioning) {
this.spawnCellAtPosition(grid, cellX, cellY);
}
}
}
}
}
handleMouseDown(x: number, y: number): void {
this.mouseIsDown = true;
if (!this.grid || this.exiting) return;
const grid = this.grid;
const cellSize = this.getCellSize();
const cellX = Math.floor((x - grid.offsetX) / cellSize);
const cellY = Math.floor((y - grid.offsetY) / cellSize);
if (
cellX >= 0 &&
cellX < grid.cols &&
cellY >= 0 &&
cellY < grid.rows
) {
this.mouseCellX = cellX;
this.mouseCellY = cellY;
const cell = grid.cells[cellX][cellY];
if (cell.alive) {
this.createRippleEffect(grid, cellX, cellY);
} else {
this.spawnCellAtPosition(grid, cellX, cellY);
}
}
}
handleMouseUp(): void {
this.mouseIsDown = false;
}
handleMouseLeave(): void {
this.mouseIsDown = false;
this.mouseX = -1000;
this.mouseY = -1000;
}
updatePalette(palette: [number, number, number][], bgColor: string): void {
this.palette = palette;
this.bgColor = bgColor;
if (this.grid) {
const grid = this.grid;
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.opacity > 0.01) {
cell.baseColor = palette[(i * grid.rows + j) % palette.length];
}
}
}
}
}
}

View File

@@ -0,0 +1,520 @@
import type { AnimationEngine } from "@/lib/animations/types";
interface Blob {
x: number;
y: number;
vx: number;
vy: number;
baseRadius: number;
radiusScale: number;
targetRadiusScale: number;
color: [number, number, number];
targetColor: [number, number, number];
phase: number;
phaseSpeed: number;
staggerDelay: number; // -1 means already revealed
}
const BLOB_COUNT = 26;
const BASE_MAX_BLOBS = 80; // at 1080p; scales with canvas area
const MIN_SPEED = 0.1;
const MAX_SPEED = 0.35;
const RESOLUTION_SCALE = 5; // render at 1/5 resolution (was 1/4)
const FIELD_THRESHOLD = 1.0;
const SMOOTHSTEP_RANGE = 0.25;
const MOUSE_REPEL_RADIUS = 150;
const MOUSE_REPEL_FORCE = 0.2;
const COLOR_LERP_SPEED = 0.02;
const DRIFT_AMPLITUDE = 0.2;
const RADIUS_LERP_SPEED = 0.06;
const STAGGER_INTERVAL = 60;
const CYCLE_MIN_MS = 2000; // min time between natural spawn/despawn
const CYCLE_MAX_MS = 5000; // max time
function smoothstep(edge0: number, edge1: number, x: number): number {
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
}
export class LavaLampEngine implements AnimationEngine {
id = "lava-lamp";
name = "Lava Lamp";
private blobs: Blob[] = [];
private palette: [number, number, number][] = [];
private bgRgb: [number, number, number] = [0, 0, 0];
private width = 0;
private height = 0;
private mouseX = -1000;
private mouseY = -1000;
private offCanvas: HTMLCanvasElement | null = null;
private offCtx: CanvasRenderingContext2D | null = null;
private shadowCanvas: HTMLCanvasElement | null = null;
private shadowCtx: CanvasRenderingContext2D | null = null;
private elapsed = 0;
private nextCycleTime = 0;
private exiting = false;
// Pre-allocated typed arrays for the inner render loop (avoid per-frame GC)
private blobX: Float64Array = new Float64Array(0);
private blobY: Float64Array = new Float64Array(0);
private blobR: Float64Array = new Float64Array(0);
private blobCR: Float64Array = new Float64Array(0);
private blobCG: Float64Array = new Float64Array(0);
private blobCB: Float64Array = new Float64Array(0);
private activeBlobCount = 0;
init(
width: number,
height: number,
palette: [number, number, number][],
bgColor: string
): void {
this.width = width;
this.height = height;
this.palette = palette;
this.parseBgColor(bgColor);
this.elapsed = 0;
this.nextCycleTime = CYCLE_MIN_MS + Math.random() * (CYCLE_MAX_MS - CYCLE_MIN_MS);
this.initBlobs();
this.initOffscreenCanvas();
}
private parseBgColor(bgColor: string): void {
const match = bgColor.match(/(\d+)/g);
if (match && match.length >= 3) {
this.bgRgb = [parseInt(match[0]), parseInt(match[1]), parseInt(match[2])];
}
}
private getMaxBlobs(): number {
const area = this.width * this.height;
const scale = area / 2_073_600; // normalize to 1080p
return Math.max(BASE_MAX_BLOBS, Math.round(BASE_MAX_BLOBS * scale));
}
private getRadiusRange(): { min: number; max: number } {
const area = this.width * this.height;
const scale = Math.sqrt(area / 2_073_600);
const min = Math.max(8, Math.round(25 * scale));
const max = Math.max(15, Math.round(65 * scale));
return { min, max };
}
private makeBlob(x: number, y: number, radiusOverride?: number): Blob {
const { min, max } = this.getRadiusRange();
const color = this.palette[
Math.floor(Math.random() * this.palette.length)
] || [128, 128, 128];
return {
x,
y,
vx: (Math.random() - 0.5) * 2 * MAX_SPEED,
vy: (Math.random() - 0.5) * 2 * MAX_SPEED,
baseRadius: radiusOverride ?? (min + Math.random() * (max - min)),
radiusScale: 0,
targetRadiusScale: 1,
color: [...color],
targetColor: [...color],
phase: Math.random() * Math.PI * 2,
phaseSpeed: 0.0005 + Math.random() * 0.001,
staggerDelay: -1,
};
}
private initBlobs(): void {
this.blobs = [];
const { max } = this.getRadiusRange();
const minDist = max * 2.5; // minimum distance between blob centers
for (let i = 0; i < BLOB_COUNT; i++) {
let x: number, y: number;
let attempts = 0;
// Try to find a position that doesn't overlap existing blobs
do {
x = Math.random() * this.width;
y = Math.random() * this.height;
attempts++;
} while (attempts < 50 && this.tooCloseToExisting(x, y, minDist));
const blob = this.makeBlob(x, y);
blob.targetRadiusScale = 0;
blob.staggerDelay = i * STAGGER_INTERVAL + Math.random() * STAGGER_INTERVAL;
this.blobs.push(blob);
}
}
private tooCloseToExisting(x: number, y: number, minDist: number): boolean {
for (const blob of this.blobs) {
const dx = blob.x - x;
const dy = blob.y - y;
if (dx * dx + dy * dy < minDist * minDist) return true;
}
return false;
}
private initOffscreenCanvas(): void {
const rw = Math.ceil(this.width / RESOLUTION_SCALE);
const rh = Math.ceil(this.height / RESOLUTION_SCALE);
this.offCanvas = document.createElement("canvas");
this.offCanvas.width = rw;
this.offCanvas.height = rh;
this.offCtx = this.offCanvas.getContext("2d", { willReadFrequently: true });
this.shadowCanvas = document.createElement("canvas");
this.shadowCanvas.width = rw;
this.shadowCanvas.height = rh;
this.shadowCtx = this.shadowCanvas.getContext("2d", {
willReadFrequently: true,
});
}
beginExit(): void {
if (this.exiting) return;
this.exiting = true;
for (let i = 0; i < this.blobs.length; i++) {
const blob = this.blobs[i];
if (blob.staggerDelay >= 0) {
blob.staggerDelay = -1;
}
// Stagger the shrink over ~2 seconds
setTimeout(() => {
blob.targetRadiusScale = 0;
}, Math.random() * 2000);
}
}
isExitComplete(): boolean {
if (!this.exiting) return false;
return this.blobs.length === 0;
}
cleanup(): void {
this.blobs = [];
this.offCanvas = null;
this.offCtx = null;
this.shadowCanvas = null;
this.shadowCtx = null;
}
/** Snapshot active blob data into flat typed arrays for fast inner-loop access */
private syncBlobArrays(): void {
const blobs = this.blobs;
const n = blobs.length;
// Grow arrays if needed
if (this.blobX.length < n) {
const cap = n + 32;
this.blobX = new Float64Array(cap);
this.blobY = new Float64Array(cap);
this.blobR = new Float64Array(cap);
this.blobCR = new Float64Array(cap);
this.blobCG = new Float64Array(cap);
this.blobCB = new Float64Array(cap);
}
let count = 0;
for (let i = 0; i < n; i++) {
const b = blobs[i];
const r = b.baseRadius * b.radiusScale;
if (r < 1) continue; // skip invisible blobs entirely
this.blobX[count] = b.x;
this.blobY[count] = b.y;
this.blobR[count] = r;
this.blobCR[count] = b.color[0];
this.blobCG[count] = b.color[1];
this.blobCB[count] = b.color[2];
count++;
}
this.activeBlobCount = count;
}
update(deltaTime: number): void {
const dt = deltaTime / (1000 / 60);
this.elapsed += deltaTime;
for (const blob of this.blobs) {
// Staggered load-in
if (blob.staggerDelay >= 0) {
if (this.elapsed >= blob.staggerDelay) {
blob.targetRadiusScale = 1;
blob.staggerDelay = -1;
}
}
blob.radiusScale +=
(blob.targetRadiusScale - blob.radiusScale) * RADIUS_LERP_SPEED * dt;
blob.phase += blob.phaseSpeed * deltaTime;
const driftX = Math.sin(blob.phase) * DRIFT_AMPLITUDE;
const driftY = Math.cos(blob.phase * 0.7) * DRIFT_AMPLITUDE;
blob.vx += driftX * dt * 0.01;
blob.vy += driftY * dt * 0.01;
blob.vx += (Math.random() - 0.5) * 0.008 * dt;
blob.vy += (Math.random() - 0.5) * 0.008 * dt;
const speed = Math.sqrt(blob.vx * blob.vx + blob.vy * blob.vy);
if (speed > MAX_SPEED) {
blob.vx = (blob.vx / speed) * MAX_SPEED;
blob.vy = (blob.vy / speed) * MAX_SPEED;
}
if (speed < MIN_SPEED) {
const angle = Math.atan2(blob.vy, blob.vx);
blob.vx = Math.cos(angle) * MIN_SPEED;
blob.vy = Math.sin(angle) * MIN_SPEED;
}
blob.x += blob.vx * dt;
blob.y += blob.vy * dt;
const pad = blob.baseRadius * 0.3;
if (blob.x < -pad) { blob.x = -pad; blob.vx = Math.abs(blob.vx) * 0.8; }
if (blob.x > this.width + pad) { blob.x = this.width + pad; blob.vx = -Math.abs(blob.vx) * 0.8; }
if (blob.y < -pad) { blob.y = -pad; blob.vy = Math.abs(blob.vy) * 0.8; }
if (blob.y > this.height + pad) { blob.y = this.height + pad; blob.vy = -Math.abs(blob.vy) * 0.8; }
// Mouse repulsion
const dx = blob.x - this.mouseX;
const dy = blob.y - this.mouseY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < MOUSE_REPEL_RADIUS && dist > 0) {
const force = (1 - dist / MOUSE_REPEL_RADIUS) * MOUSE_REPEL_FORCE * dt;
blob.vx += (dx / dist) * force;
blob.vy += (dy / dist) * force;
}
for (let c = 0; c < 3; c++) {
blob.color[c] += (blob.targetColor[c] - blob.color[c]) * COLOR_LERP_SPEED * dt;
}
}
// Remove blobs that have fully shrunk away (but not ones still waiting to stagger in)
for (let i = this.blobs.length - 1; i >= 0; i--) {
const b = this.blobs[i];
if (b.targetRadiusScale === 0 && b.radiusScale < 0.01 && b.staggerDelay < 0) {
this.blobs.splice(i, 1);
}
}
// Natural spawn/despawn cycle — keeps the scene alive
if (!this.exiting && this.elapsed >= this.nextCycleTime) {
// Pick a random visible blob to fade out (skip ones still staggering in)
const visible = [];
for (let i = 0; i < this.blobs.length; i++) {
if (this.blobs[i].radiusScale > 0.5 && this.blobs[i].staggerDelay < 0) {
visible.push(i);
}
}
if (visible.length > 0) {
const killIdx = visible[Math.floor(Math.random() * visible.length)];
this.blobs[killIdx].targetRadiusScale = 0;
}
// Spawn a fresh one at a random position
const blob = this.makeBlob(
Math.random() * this.width,
Math.random() * this.height
);
this.blobs.push(blob);
// Schedule next cycle
this.nextCycleTime = this.elapsed + CYCLE_MIN_MS + Math.random() * (CYCLE_MAX_MS - CYCLE_MIN_MS);
}
// Prune excess blobs (keep the initial set, drop oldest user-spawned ones)
const maxBlobs = this.getMaxBlobs();
if (this.blobs.length > maxBlobs) {
this.blobs.splice(BLOB_COUNT, this.blobs.length - maxBlobs);
}
}
render(
ctx: CanvasRenderingContext2D,
width: number,
height: number
): void {
if (!this.offCtx || !this.offCanvas || !this.shadowCtx || !this.shadowCanvas)
return;
// Snapshot blob positions/radii into typed arrays for fast pixel loop
this.syncBlobArrays();
const rw = this.offCanvas.width;
const rh = this.offCanvas.height;
// Render shadow layer
const shadowData = this.shadowCtx.createImageData(rw, rh);
this.renderField(shadowData, rw, rh, true);
this.shadowCtx.putImageData(shadowData, 0, 0);
// Render main layer
const imageData = this.offCtx.createImageData(rw, rh);
this.renderField(imageData, rw, rh, false);
this.offCtx.putImageData(imageData, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "medium";
ctx.globalAlpha = 0.2;
ctx.drawImage(this.shadowCanvas, 0, 4, width, height);
ctx.globalAlpha = 1;
ctx.drawImage(this.offCanvas, 0, 0, width, height);
}
private renderField(
imageData: ImageData,
rw: number,
rh: number,
isShadow: boolean
): void {
const data = imageData.data;
const threshold = isShadow ? FIELD_THRESHOLD * 0.75 : FIELD_THRESHOLD;
const bgR = this.bgRgb[0];
const bgG = this.bgRgb[1];
const bgB = this.bgRgb[2];
const scale = RESOLUTION_SCALE;
const n = this.activeBlobCount;
const bx = this.blobX;
const by = this.blobY;
const br = this.blobR;
const bcr = this.blobCR;
const bcg = this.blobCG;
const bcb = this.blobCB;
const threshLow = threshold - SMOOTHSTEP_RANGE;
for (let py = 0; py < rh; py++) {
const wy = py * scale;
for (let px = 0; px < rw; px++) {
const wx = px * scale;
let fieldSum = 0;
let weightedR = 0;
let weightedG = 0;
let weightedB = 0;
for (let i = 0; i < n; i++) {
const dx = wx - bx[i];
const dy = wy - by[i];
const distSq = dx * dx + dy * dy;
const ri = br[i];
const rSq = ri * ri;
// Raw metaball field
const raw = rSq / (distSq + rSq * 0.1);
// Cap per-blob contribution so color stays flat inside the blob
const contribution = raw > 2 ? 2 : raw;
fieldSum += contribution;
if (contribution > 0.01) {
weightedR += bcr[i] * contribution;
weightedG += bcg[i] * contribution;
weightedB += bcb[i] * contribution;
}
}
const idx = (py * rw + px) << 2;
if (fieldSum > threshLow) {
const alpha = smoothstep(threshLow, threshold, fieldSum);
if (isShadow) {
data[idx] = 0;
data[idx + 1] = 0;
data[idx + 2] = 0;
data[idx + 3] = (alpha * 150) | 0;
} else {
const invField = 1 / fieldSum;
const r = Math.min(255, (weightedR * invField) | 0);
const g = Math.min(255, (weightedG * invField) | 0);
const b = Math.min(255, (weightedB * invField) | 0);
data[idx] = bgR + (r - bgR) * alpha;
data[idx + 1] = bgG + (g - bgG) * alpha;
data[idx + 2] = bgB + (b - bgB) * alpha;
data[idx + 3] = 255;
}
} else {
if (isShadow) {
// data stays 0 (already zeroed by createImageData)
} else {
data[idx] = bgR;
data[idx + 1] = bgG;
data[idx + 2] = bgB;
data[idx + 3] = 255;
}
}
}
}
}
handleResize(width: number, height: number): void {
this.width = width;
this.height = height;
this.elapsed = 0;
this.exiting = false;
this.nextCycleTime = CYCLE_MIN_MS + Math.random() * (CYCLE_MAX_MS - CYCLE_MIN_MS);
this.initBlobs();
this.initOffscreenCanvas();
}
private sampleColorAt(x: number, y: number): [number, number, number] | null {
let closest: Blob | null = null;
let closestDist = Infinity;
for (const blob of this.blobs) {
const dx = blob.x - x;
const dy = blob.y - y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < blob.baseRadius * 1.5 && dist < closestDist) {
closestDist = dist;
closest = blob;
}
}
return closest ? ([...closest.color] as [number, number, number]) : null;
}
private spawnAt(x: number, y: number): void {
const { max } = this.getRadiusRange();
const blob = this.makeBlob(x, y, max * (0.8 + Math.random() * 0.4));
const nearby = this.sampleColorAt(x, y);
if (nearby) {
blob.color = nearby;
blob.targetColor = [...nearby];
}
this.blobs.push(blob);
}
handleMouseMove(x: number, y: number, _isDown: boolean): void {
this.mouseX = x;
this.mouseY = y;
}
handleMouseDown(x: number, y: number): void {
if (this.exiting) return;
this.spawnAt(x, y);
}
handleMouseUp(): void {}
handleMouseLeave(): void {
this.mouseX = -1000;
this.mouseY = -1000;
}
updatePalette(palette: [number, number, number][], bgColor: string): void {
this.palette = palette;
this.parseBgColor(bgColor);
for (let i = 0; i < this.blobs.length; i++) {
this.blobs[i].targetColor = [
...palette[i % palette.length],
] as [number, number, number];
}
}
}

View File

@@ -0,0 +1,480 @@
import type { AnimationEngine } from "@/lib/animations/types";
// --- Directions ---
type Dir = 0 | 1 | 2 | 3; // up, right, down, left
const DX = [0, 1, 0, -1];
const DY = [-1, 0, 1, 0];
// Box-drawing characters
const HORIZONTAL = "\u2501"; // ━
const VERTICAL = "\u2503"; // ┃
// Corner pieces: [oldDir]-[newDir]
// oldDir determines entry side (opposite), newDir determines exit side
// ┏ = RIGHT+BOTTOM, ┓ = LEFT+BOTTOM, ┗ = RIGHT+TOP, ┛ = LEFT+TOP
const CORNER: Record<string, string> = {
"0-1": "\u250F", // ┏ enter BOTTOM, exit RIGHT
"0-3": "\u2513", // ┓ enter BOTTOM, exit LEFT
"1-0": "\u251B", // ┛ enter LEFT, exit TOP
"1-2": "\u2513", // ┓ enter LEFT, exit BOTTOM
"2-1": "\u2517", // ┗ enter TOP, exit RIGHT
"2-3": "\u251B", // ┛ enter TOP, exit LEFT
"3-0": "\u2517", // ┗ enter RIGHT, exit TOP
"3-2": "\u250F", // ┏ enter RIGHT, exit BOTTOM
};
function getStraightChar(dir: Dir): string {
return dir === 0 || dir === 2 ? VERTICAL : HORIZONTAL;
}
function getCornerChar(fromDir: Dir, toDir: Dir): string {
return CORNER[`${fromDir}-${toDir}`] || HORIZONTAL;
}
// --- Grid Cell ---
interface PipeCell {
char: string;
pipeId: number;
placedAt: number;
color: [number, number, number];
baseColor: [number, number, number];
opacity: number;
elevation: number;
targetElevation: number;
fadeOut: boolean;
}
// --- Active Pipe ---
interface ActivePipe {
id: number;
x: number;
y: number;
dir: Dir;
color: [number, number, number];
spawnDelay: number;
}
// --- Constants ---
const CELL_SIZE_DESKTOP = 20;
const CELL_SIZE_MOBILE = 14;
const MAX_ACTIVE_PIPES = 4;
const GROW_INTERVAL = 80;
const TURN_CHANCE = 0.3;
const TARGET_FPS = 60;
const PIPE_LIFETIME = 12_000; // ms before a pipe's segments start fading
const FADE_IN_SPEED = 0.06;
const FADE_OUT_SPEED = 0.02;
const MOUSE_INFLUENCE_RADIUS = 150;
const ELEVATION_FACTOR = 6;
const ELEVATION_LERP_SPEED = 0.05;
const COLOR_SHIFT_AMOUNT = 30;
const SHADOW_OFFSET_RATIO = 1.1;
const BURST_PIPE_COUNT = 4;
// --- Helpers ---
function range(a: number, b: number): number {
return (b - a) * Math.random() + a;
}
// --- Engine ---
export class PipesEngine implements AnimationEngine {
id = "pipes";
name = "Pipes";
private grid: (PipeCell | null)[][] = [];
private cols = 0;
private rows = 0;
private activePipes: ActivePipe[] = [];
private palette: [number, number, number][] = [];
private width = 0;
private height = 0;
private cellSize = CELL_SIZE_DESKTOP;
private fontSize = CELL_SIZE_DESKTOP;
private font = `bold ${CELL_SIZE_DESKTOP}px monospace`;
private mouseX = -1000;
private mouseY = -1000;
private elapsed = 0;
private growTimer = 0;
private exiting = false;
private nextPipeId = 0;
private offsetX = 0;
private offsetY = 0;
init(
width: number,
height: number,
palette: [number, number, number][],
_bgColor: string
): void {
this.width = width;
this.height = height;
this.palette = palette;
this.elapsed = 0;
this.growTimer = 0;
this.exiting = false;
this.computeGrid();
this.spawnInitialPipes();
}
private computeGrid(): void {
this.cellSize = this.width <= 768 ? CELL_SIZE_MOBILE : CELL_SIZE_DESKTOP;
this.fontSize = this.cellSize;
this.font = `bold ${this.fontSize}px monospace`;
this.cols = Math.floor(this.width / this.cellSize);
this.rows = Math.floor(this.height / this.cellSize);
this.offsetX = Math.floor((this.width - this.cols * this.cellSize) / 2);
this.offsetY = Math.floor((this.height - this.rows * this.cellSize) / 2);
this.grid = Array.from({ length: this.cols }, () =>
Array.from({ length: this.rows }, () => null)
);
}
private randomColor(): [number, number, number] {
// Prefer bright variants (second half of palette) if available
const brightStart = Math.floor(this.palette.length / 2);
if (brightStart > 0 && this.palette.length > brightStart) {
return this.palette[brightStart + Math.floor(Math.random() * (this.palette.length - brightStart))];
}
return this.palette[Math.floor(Math.random() * this.palette.length)];
}
private spawnInitialPipes(): void {
this.activePipes = [];
for (let i = 0; i < MAX_ACTIVE_PIPES; i++) {
this.activePipes.push(this.makeEdgePipe(i * 400));
}
}
private makeEdgePipe(delay: number): ActivePipe {
const color = this.randomColor();
// Pick a random edge and inward-facing direction
const edge = Math.floor(Math.random() * 4) as Dir;
let x: number, y: number, dir: Dir;
switch (edge) {
case 0: // top edge, face down
x = Math.floor(Math.random() * this.cols);
y = 0;
dir = 2;
break;
case 1: // right edge, face left
x = this.cols - 1;
y = Math.floor(Math.random() * this.rows);
dir = 3;
break;
case 2: // bottom edge, face up
x = Math.floor(Math.random() * this.cols);
y = this.rows - 1;
dir = 0;
break;
default: // left edge, face right
x = 0;
y = Math.floor(Math.random() * this.rows);
dir = 1;
break;
}
return { id: this.nextPipeId++, x, y, dir, color: [...color], spawnDelay: delay };
}
private placeSegment(x: number, y: number, char: string, color: [number, number, number], pipeId: number): void {
if (x < 0 || x >= this.cols || y < 0 || y >= this.rows) return;
this.grid[x][y] = {
char,
pipeId,
placedAt: this.elapsed,
color: [...color],
baseColor: [...color],
opacity: 0,
elevation: 0,
targetElevation: 0,
fadeOut: false,
};
}
private isOccupied(x: number, y: number): boolean {
if (x < 0 || x >= this.cols || y < 0 || y >= this.rows) return true;
return this.grid[x][y] !== null;
}
private pickTurn(currentDir: Dir): Dir {
// Turn left or right relative to current direction
const leftDir = ((currentDir + 3) % 4) as Dir;
const rightDir = ((currentDir + 1) % 4) as Dir;
return Math.random() < 0.5 ? leftDir : rightDir;
}
private growPipe(pipe: ActivePipe): boolean {
// Decide direction
let newDir = pipe.dir;
let turned = false;
if (Math.random() < TURN_CHANCE) {
newDir = this.pickTurn(pipe.dir);
turned = true;
}
const nx = pipe.x + DX[newDir];
const ny = pipe.y + DY[newDir];
// Check if destination is valid
if (this.isOccupied(nx, ny)) {
// If we tried to turn, try going straight instead
if (turned) {
const sx = pipe.x + DX[pipe.dir];
const sy = pipe.y + DY[pipe.dir];
if (!this.isOccupied(sx, sy)) {
// Continue straight — place straight piece at destination
this.placeSegment(sx, sy, getStraightChar(pipe.dir), pipe.color, pipe.id);
pipe.x = sx;
pipe.y = sy;
return true;
}
}
return false; // dead end
}
if (turned) {
// Replace current head cell with corner piece (turn happens HERE)
const cell = this.grid[pipe.x]?.[pipe.y];
if (cell) {
cell.char = getCornerChar(pipe.dir, newDir);
}
}
// Place straight piece at destination
this.placeSegment(nx, ny, getStraightChar(newDir), pipe.color, pipe.id);
pipe.dir = newDir;
pipe.x = nx;
pipe.y = ny;
return true;
}
// --- Interface Methods ---
beginExit(): void {
if (this.exiting) return;
this.exiting = true;
for (let i = 0; i < this.cols; i++) {
for (let j = 0; j < this.rows; j++) {
const cell = this.grid[i][j];
if (cell) {
setTimeout(() => {
cell.fadeOut = true;
}, Math.random() * 3000);
}
}
}
}
isExitComplete(): boolean {
if (!this.exiting) return false;
for (let i = 0; i < this.cols; i++) {
for (let j = 0; j < this.rows; j++) {
const cell = this.grid[i][j];
if (cell && cell.opacity > 0.01) return false;
}
}
return true;
}
cleanup(): void {
this.grid = [];
this.activePipes = [];
}
update(deltaTime: number): void {
const dt = deltaTime / (1000 / TARGET_FPS);
this.elapsed += deltaTime;
// Grow pipes
if (!this.exiting) {
this.growTimer += deltaTime;
while (this.growTimer >= GROW_INTERVAL) {
this.growTimer -= GROW_INTERVAL;
for (let i = this.activePipes.length - 1; i >= 0; i--) {
const pipe = this.activePipes[i];
if (pipe.spawnDelay > 0) {
pipe.spawnDelay -= GROW_INTERVAL;
continue;
}
// Place starting segment if this is the first step
if (!this.isOccupied(pipe.x, pipe.y)) {
this.placeSegment(pipe.x, pipe.y, getStraightChar(pipe.dir), pipe.color, pipe.id);
continue;
}
if (!this.growPipe(pipe)) {
// Pipe is dead, replace it
this.activePipes[i] = this.makeEdgePipe(0);
}
}
}
}
// Update cells: fade in/out, mouse influence
const mouseX = this.mouseX;
const mouseY = this.mouseY;
for (let i = 0; i < this.cols; i++) {
for (let j = 0; j < this.rows; j++) {
const cell = this.grid[i][j];
if (!cell) continue;
// Age-based fade: old segments start dissolving
if (!cell.fadeOut && !this.exiting && this.elapsed - cell.placedAt > PIPE_LIFETIME) {
cell.fadeOut = true;
}
// Fade in/out
if (cell.fadeOut) {
cell.opacity -= FADE_OUT_SPEED * dt;
if (cell.opacity <= 0) {
cell.opacity = 0;
this.grid[i][j] = null; // free the cell for new pipes
continue;
}
} else if (cell.opacity < 1) {
cell.opacity = Math.min(1, cell.opacity + FADE_IN_SPEED * dt);
}
// Mouse influence
const cx = this.offsetX + i * this.cellSize + this.cellSize / 2;
const cy = this.offsetY + j * this.cellSize + this.cellSize / 2;
const dx = cx - mouseX;
const dy = cy - mouseY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < MOUSE_INFLUENCE_RADIUS && cell.opacity > 0.1) {
const inf = Math.cos((dist / MOUSE_INFLUENCE_RADIUS) * (Math.PI / 2));
cell.targetElevation = ELEVATION_FACTOR * inf * inf;
const shift = inf * COLOR_SHIFT_AMOUNT * 0.5;
cell.color = [
Math.min(255, Math.max(0, cell.baseColor[0] + shift)),
Math.min(255, Math.max(0, cell.baseColor[1] + shift)),
Math.min(255, Math.max(0, cell.baseColor[2] + shift)),
];
} else {
cell.targetElevation = 0;
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;
}
cell.elevation +=
(cell.targetElevation - cell.elevation) * ELEVATION_LERP_SPEED * dt;
}
}
}
render(
ctx: CanvasRenderingContext2D,
_width: number,
_height: number
): void {
ctx.font = this.font;
ctx.textBaseline = "top";
for (let i = 0; i < this.cols; i++) {
for (let j = 0; j < this.rows; j++) {
const cell = this.grid[i][j];
if (!cell || cell.opacity <= 0.01) continue;
const x = this.offsetX + i * this.cellSize;
const y = this.offsetY + j * this.cellSize - cell.elevation;
const [r, g, b] = cell.color;
// Shadow
if (cell.elevation > 0.5) {
const shadowAlpha = 0.2 * (cell.elevation / ELEVATION_FACTOR) * cell.opacity;
ctx.globalAlpha = shadowAlpha;
ctx.fillStyle = "rgb(0,0,0)";
ctx.fillText(cell.char, x, y + cell.elevation * SHADOW_OFFSET_RATIO);
}
// Main
ctx.globalAlpha = cell.opacity;
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.fillText(cell.char, x, y);
// Highlight
if (cell.elevation > 0.5) {
const highlightAlpha = 0.1 * (cell.elevation / ELEVATION_FACTOR) * cell.opacity;
ctx.globalAlpha = highlightAlpha;
ctx.fillStyle = "rgb(255,255,255)";
ctx.fillText(cell.char, x, y);
}
}
}
ctx.globalAlpha = 1;
}
handleResize(width: number, height: number): void {
this.width = width;
this.height = height;
this.elapsed = 0;
this.growTimer = 0;
this.exiting = false;
this.computeGrid();
this.spawnInitialPipes();
}
handleMouseMove(x: number, y: number, _isDown: boolean): void {
this.mouseX = x;
this.mouseY = y;
}
handleMouseDown(x: number, y: number): void {
if (this.exiting) return;
// Convert to grid coords
const gx = Math.floor((x - this.offsetX) / this.cellSize);
const gy = Math.floor((y - this.offsetY) / this.cellSize);
// Spawn pipes in all 4 directions from click point
for (let d = 0; d < BURST_PIPE_COUNT; d++) {
const dir = d as Dir;
const color = this.randomColor();
this.activePipes.push({
id: this.nextPipeId++,
x: gx,
y: gy,
dir,
color: [...color],
spawnDelay: 0,
});
}
}
handleMouseUp(): void {}
handleMouseLeave(): void {
this.mouseX = -1000;
this.mouseY = -1000;
}
updatePalette(palette: [number, number, number][], _bgColor: string): void {
this.palette = palette;
// Assign by pipeId so all segments of the same pipe get the same color
for (let i = 0; i < this.cols; i++) {
for (let j = 0; j < this.rows; j++) {
const cell = this.grid[i][j];
if (cell) {
cell.baseColor = palette[cell.pipeId % palette.length];
}
}
}
}
}

View File

@@ -0,0 +1,207 @@
import type { AnimationEngine } from "@/lib/animations/types";
import { GameOfLifeEngine } from "@/components/background/engines/game-of-life";
import { LavaLampEngine } from "@/components/background/engines/lava-lamp";
import { ConfettiEngine } from "@/components/background/engines/confetti";
import { AsciiquariumEngine } from "@/components/background/engines/asciiquarium";
import { PipesEngine } from "@/components/background/engines/pipes";
type ChildId = "game-of-life" | "lava-lamp" | "confetti" | "asciiquarium" | "pipes";
const CHILD_IDS: ChildId[] = [
"game-of-life",
"lava-lamp",
"confetti",
"asciiquarium",
"pipes",
];
const PLAY_DURATION = 30_000;
const STATE_KEY = "shuffle-state";
interface StoredState {
childId: ChildId;
startedAt: number;
}
function createChild(id: ChildId): AnimationEngine {
switch (id) {
case "game-of-life":
return new GameOfLifeEngine();
case "lava-lamp":
return new LavaLampEngine();
case "confetti":
return new ConfettiEngine();
case "asciiquarium":
return new AsciiquariumEngine();
case "pipes":
return new PipesEngine();
}
}
function pickDifferent(current: ChildId | null): ChildId {
const others = current
? CHILD_IDS.filter((id) => id !== current)
: CHILD_IDS;
return others[Math.floor(Math.random() * others.length)];
}
function save(state: StoredState): void {
try {
localStorage.setItem(STATE_KEY, JSON.stringify(state));
} catch {}
}
function load(): StoredState | null {
try {
const raw = localStorage.getItem(STATE_KEY);
if (!raw) return null;
const state = JSON.parse(raw) as StoredState;
if (CHILD_IDS.includes(state.childId)) return state;
return null;
} catch {
return null;
}
}
export class ShuffleEngine implements AnimationEngine {
id = "shuffle";
name = "Shuffle";
private child: AnimationEngine | null = null;
private currentChildId: ChildId | null = null;
private startedAt = 0;
private phase: "playing" | "exiting" = "playing";
private width = 0;
private height = 0;
private palette: [number, number, number][] = [];
private bgColor = "";
init(
width: number,
height: number,
palette: [number, number, number][],
bgColor: string
): void {
this.width = width;
this.height = height;
this.palette = palette;
this.bgColor = bgColor;
const stored = load();
if (stored && Date.now() - stored.startedAt < PLAY_DURATION) {
// Animation still within its play window — continue it
// Covers: Astro nav, sidebar mount, layout switch, quick refresh
this.currentChildId = stored.childId;
} else {
// No recent state (first visit, hard refresh after timer expired) — game-of-life
this.currentChildId = "game-of-life";
}
this.startedAt = Date.now();
this.phase = "playing";
this.child = createChild(this.currentChildId);
this.child.init(this.width, this.height, this.palette, this.bgColor);
save({ childId: this.currentChildId, startedAt: this.startedAt });
}
private switchTo(childId: ChildId, startedAt: number): void {
if (this.child) this.child.cleanup();
this.currentChildId = childId;
this.startedAt = startedAt;
this.phase = "playing";
this.child = createChild(childId);
this.child.init(this.width, this.height, this.palette, this.bgColor);
}
private advance(): void {
// Check if another instance already advanced
const stored = load();
if (stored && stored.childId !== this.currentChildId) {
this.switchTo(stored.childId, stored.startedAt);
} else {
const next = pickDifferent(this.currentChildId);
const now = Date.now();
save({ childId: next, startedAt: now });
this.switchTo(next, now);
}
}
update(deltaTime: number): void {
if (!this.child) return;
// Sync: if another instance (sidebar, tab) switched, follow
const stored = load();
if (stored && stored.childId !== this.currentChildId) {
this.switchTo(stored.childId, stored.startedAt);
return;
}
this.child.update(deltaTime);
const elapsed = Date.now() - this.startedAt;
if (this.phase === "playing" && elapsed >= PLAY_DURATION) {
this.child.beginExit();
this.phase = "exiting";
}
if (this.phase === "exiting" && this.child.isExitComplete()) {
this.child.cleanup();
this.advance();
}
}
render(
ctx: CanvasRenderingContext2D,
width: number,
height: number
): void {
if (this.child) this.child.render(ctx, width, height);
}
handleResize(width: number, height: number): void {
this.width = width;
this.height = height;
if (this.child) this.child.handleResize(width, height);
}
handleMouseMove(x: number, y: number, isDown: boolean): void {
if (this.child) this.child.handleMouseMove(x, y, isDown);
}
handleMouseDown(x: number, y: number): void {
if (this.child) this.child.handleMouseDown(x, y);
}
handleMouseUp(): void {
if (this.child) this.child.handleMouseUp();
}
handleMouseLeave(): void {
if (this.child) this.child.handleMouseLeave();
}
updatePalette(palette: [number, number, number][], bgColor: string): void {
this.palette = palette;
this.bgColor = bgColor;
if (this.child) this.child.updatePalette(palette, bgColor);
}
beginExit(): void {
if (this.child) this.child.beginExit();
}
isExitComplete(): boolean {
return this.child ? this.child.isExitComplete() : true;
}
cleanup(): void {
if (this.child) {
this.child.cleanup();
this.child = null;
}
}
}

View File

@@ -0,0 +1,356 @@
import { useEffect, useRef } from "react";
import { GameOfLifeEngine } from "@/components/background/engines/game-of-life";
import { LavaLampEngine } from "@/components/background/engines/lava-lamp";
import { ConfettiEngine } from "@/components/background/engines/confetti";
import { AsciiquariumEngine } from "@/components/background/engines/asciiquarium";
import { PipesEngine } from "@/components/background/engines/pipes";
import { ShuffleEngine } from "@/components/background/engines/shuffle";
import { getStoredAnimationId } from "@/lib/animations/engine";
import type { AnimationEngine } from "@/lib/animations/types";
import type { AnimationId } from "@/lib/animations";
const SIDEBAR_WIDTH = 240;
const FALLBACK_PALETTE: [number, number, number][] = [
[204, 36, 29], [152, 151, 26], [215, 153, 33],
[69, 133, 136], [177, 98, 134], [104, 157, 106],
[251, 73, 52], [184, 187, 38], [250, 189, 47],
[131, 165, 152], [211, 134, 155], [142, 192, 124],
];
function createEngine(id: AnimationId): AnimationEngine {
switch (id) {
case "lava-lamp":
return new LavaLampEngine();
case "confetti":
return new ConfettiEngine();
case "asciiquarium":
return new AsciiquariumEngine();
case "pipes":
return new PipesEngine();
case "shuffle":
return new ShuffleEngine();
case "game-of-life":
default:
return new GameOfLifeEngine();
}
}
function readPaletteFromCSS(): [number, number, number][] {
try {
const style = getComputedStyle(document.documentElement);
const keys = [
"--color-red", "--color-green", "--color-yellow",
"--color-blue", "--color-purple", "--color-aqua",
"--color-red-bright", "--color-green-bright", "--color-yellow-bright",
"--color-blue-bright", "--color-purple-bright", "--color-aqua-bright",
];
const palette: [number, number, number][] = [];
for (const key of keys) {
const val = style.getPropertyValue(key).trim();
if (val) {
const parts = val.split(" ").map(Number);
if (parts.length === 3 && parts.every((n) => !isNaN(n))) {
palette.push([parts[0], parts[1], parts[2]]);
}
}
}
return palette.length > 0 ? palette : FALLBACK_PALETTE;
} catch {
return FALLBACK_PALETTE;
}
}
function readBgFromCSS(): string {
try {
const val = getComputedStyle(document.documentElement)
.getPropertyValue("--color-background")
.trim();
if (val) {
const [r, g, b] = val.split(" ");
return `rgb(${r}, ${g}, ${b})`;
}
} catch {}
return "rgb(0, 0, 0)";
}
interface BackgroundProps {
layout?: "index" | "sidebar" | "content";
position?: "left" | "right";
}
const Background: React.FC<BackgroundProps> = ({
layout = "index",
position = "left",
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const engineRef = useRef<AnimationEngine | null>(null);
const animationFrameRef = useRef<number>();
const lastUpdateTimeRef = useRef<number>(0);
const resizeTimeoutRef = useRef<NodeJS.Timeout>();
const dimensionsRef = useRef({ width: 0, height: 0 });
const setupCanvas = (
canvas: HTMLCanvasElement,
width: number,
height: number
) => {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.scale(dpr, dpr);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
return ctx;
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const controller = new AbortController();
const signal = controller.signal;
const displayWidth =
layout === "index" ? window.innerWidth : SIDEBAR_WIDTH;
const displayHeight = window.innerHeight;
dimensionsRef.current = { width: displayWidth, height: displayHeight };
const ctx = setupCanvas(canvas, displayWidth, displayHeight);
if (!ctx) return;
const palette = readPaletteFromCSS();
const bgColor = readBgFromCSS();
// Initialize engine
if (!engineRef.current) {
const animId = getStoredAnimationId();
engineRef.current = createEngine(animId);
engineRef.current.init(displayWidth, displayHeight, palette, bgColor);
}
// Handle animation switching
const handleAnimationChanged = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.id) return;
if (engineRef.current) {
engineRef.current.cleanup();
}
const w = layout === "index" ? window.innerWidth : SIDEBAR_WIDTH;
const h = window.innerHeight;
engineRef.current = createEngine(detail.id);
engineRef.current.init(w, h, readPaletteFromCSS(), readBgFromCSS());
};
document.addEventListener("animation-changed", handleAnimationChanged, {
signal,
});
// Handle theme changes — only update if palette actually changed
let currentPalette = palette;
const handleThemeChanged = () => {
const newPalette = readPaletteFromCSS();
const newBg = readBgFromCSS();
const same =
newPalette.length === currentPalette.length &&
newPalette.every(
(c, i) =>
c[0] === currentPalette[i][0] &&
c[1] === currentPalette[i][1] &&
c[2] === currentPalette[i][2]
);
if (!same && engineRef.current) {
currentPalette = newPalette;
engineRef.current.updatePalette(newPalette, newBg);
}
};
document.addEventListener("theme-changed", handleThemeChanged, { signal });
// Handle resize
const handleResize = () => {
if (signal.aborted) return;
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
resizeTimeoutRef.current = setTimeout(() => {
if (signal.aborted) return;
const w = layout === "index" ? window.innerWidth : SIDEBAR_WIDTH;
const h = window.innerHeight;
const newCtx = setupCanvas(canvas, w, h);
if (!newCtx) return;
lastUpdateTimeRef.current = 0;
dimensionsRef.current = { width: w, height: h };
if (engineRef.current) {
engineRef.current.handleResize(w, h);
}
}, 250);
};
// Mouse events
const handleMouseDown = (e: MouseEvent) => {
if (!engineRef.current || !canvas) return;
// Don't spawn when clicking interactive elements
const target = e.target as HTMLElement;
if (target.closest("a, button, [role='button'], input, select, textarea, label, [onclick], [tabindex]")) return;
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
if (
mouseX < 0 ||
mouseX > rect.width ||
mouseY < 0 ||
mouseY > rect.height
)
return;
e.preventDefault();
engineRef.current.handleMouseDown(mouseX, mouseY);
};
const handleMouseMove = (e: MouseEvent) => {
if (!engineRef.current || !canvas) return;
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
engineRef.current.handleMouseMove(mouseX, mouseY, e.buttons === 1);
};
const handleMouseUp = () => {
if (engineRef.current) {
engineRef.current.handleMouseUp();
}
};
const handleMouseLeave = () => {
if (engineRef.current) {
engineRef.current.handleMouseLeave();
}
};
window.addEventListener("mousedown", handleMouseDown, { signal });
window.addEventListener("mousemove", handleMouseMove, { signal });
window.addEventListener("mouseup", handleMouseUp, { signal });
// Visibility change
const handleVisibilityChange = () => {
if (document.hidden) {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = undefined;
}
} else {
if (!animationFrameRef.current) {
lastUpdateTimeRef.current = performance.now();
animationFrameRef.current = requestAnimationFrame(animate);
}
}
};
// Animation loop
const animate = (currentTime: number) => {
if (signal.aborted) return;
if (!lastUpdateTimeRef.current) {
lastUpdateTimeRef.current = currentTime;
}
const deltaTime = currentTime - lastUpdateTimeRef.current;
const clampedDeltaTime = Math.min(deltaTime, 100);
lastUpdateTimeRef.current = currentTime;
const engine = engineRef.current;
if (engine) {
engine.update(clampedDeltaTime);
// Clear canvas
const bg = readBgFromCSS();
ctx.fillStyle = bg;
ctx.fillRect(0, 0, canvas.width, canvas.height);
const { width: rw, height: rh } = dimensionsRef.current;
engine.render(ctx, rw, rh);
}
animationFrameRef.current = requestAnimationFrame(animate);
};
document.addEventListener("visibilitychange", handleVisibilityChange, {
signal,
});
window.addEventListener("resize", handleResize, { signal });
animate(performance.now());
return () => {
controller.abort();
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
};
}, [layout]);
const isIndex = layout === "index";
const isSidebar = !isIndex;
const getContainerStyle = (): React.CSSProperties => {
if (isIndex) return {};
// Fade the inner edge so blobs don't hard-cut at the content boundary
return {
maskImage:
position === "left"
? "linear-gradient(to right, black 60%, transparent 100%)"
: "linear-gradient(to left, black 60%, transparent 100%)",
WebkitMaskImage:
position === "left"
? "linear-gradient(to right, black 60%, transparent 100%)"
: "linear-gradient(to left, black 60%, transparent 100%)",
};
};
const getContainerClasses = () => {
if (isIndex) {
return "fixed inset-0 -z-10";
}
const baseClasses = "fixed top-0 bottom-0 hidden lg:block -z-10";
return position === "left"
? `${baseClasses} left-0`
: `${baseClasses} right-0`;
};
return (
<div className={getContainerClasses()} style={getContainerStyle()}>
<canvas
ref={canvasRef}
className="w-full h-full bg-background"
style={{ cursor: "default" }}
/>
<div className="absolute inset-0 bg-background/30 backdrop-blur-sm pointer-events-none" />
<div className="crt-scanlines absolute inset-0 pointer-events-none" />
<div className="crt-bloom absolute inset-0 pointer-events-none" />
</div>
);
};
export default Background;

View File

@@ -26,7 +26,7 @@ export const Comments = () => {
emitMetadata="0" emitMetadata="0"
inputPosition="bottom" inputPosition="bottom"
lang="en" lang="en"
loading="lazy" loading="eager"
/> />
) : null} ) : null}
</div> </div>

View File

@@ -0,0 +1,42 @@
import { RssIcon, TagIcon, TrendingUpIcon } from "lucide-react";
import { AnimateIn } from "@/components/animate-in";
export const BlogHeader = () => {
return (
<div className="w-full max-w-6xl mx-auto px-4 pt-24 sm:pt-24">
<AnimateIn>
<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>
</AnimateIn>
<AnimateIn delay={100}>
<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>
</AnimateIn>
</div>
);
};

View File

@@ -0,0 +1,101 @@
import { AnimateIn } from "@/components/animate-in";
type BlogPost = {
id: string;
data: {
title: string;
author: string;
date: string;
tags: string[];
description: string;
image?: string;
imagePosition?: string;
};
};
interface BlogPostListProps {
posts: BlogPost[];
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric"
});
};
export const BlogPostList = ({ posts }: BlogPostListProps) => {
return (
<div className="w-full max-w-6xl mx-auto">
<ul className="space-y-6 md:space-y-10">
{posts.map((post, i) => (
<AnimateIn key={post.id} delay={i * 80}>
<li className="group px-4 md:px-0">
<a
href={`/blog/${post.id}`}
className="block"
>
<article className="flex flex-col md:flex-row md:items-center gap-4 md:gap-8 p-2 md:p-4 border-b border-foreground/20 last:border-b-0 rounded-lg group-hover:outline group-hover:outline-2 group-hover:outline-purple transition-all duration-200">
{/* Image container with fixed aspect ratio */}
<div className="w-full md:w-1/3 aspect-[16/9] overflow-hidden rounded-lg bg-background flex-shrink-0">
<img
src={post.data.image || "/blog/placeholder.png"}
alt={post.data.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
style={{ objectPosition: post.data.imagePosition || "center center" }}
/>
</div>
{/* Content container */}
<div className="w-full md:w-2/3 flex flex-col gap-2 md:gap-3">
{/* Title and meta info */}
<div className="space-y-1.5 md:space-y-3">
<h2 className="text-lg md:text-2xl font-semibold text-yellow group-hover:text-purple transition-colors duration-200 line-clamp-2">
{post.data.title}
</h2>
<div className="flex flex-wrap items-center gap-2 md:gap-3 text-sm md:text-base text-foreground/80">
<span className="text-orange">{post.data.author}</span>
<span className="text-foreground/50"></span>
<time dateTime={post.data.date} className="text-blue">
{formatDate(post.data.date)}
</time>
</div>
</div>
{/* Description */}
<p className="text-foreground/90 text-sm md:text-lg leading-relaxed line-clamp-2 mt-0.5 md:mt-0">
{post.data.description}
</p>
{/* Tags */}
<div className="flex flex-wrap gap-1.5 md:gap-3 mt-1 md:mt-2">
{post.data.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="text-xs md:text-base text-aqua hover:text-aqua-bright transition-colors duration-200"
onClick={(e) => {
e.preventDefault();
window.location.href = `/blog/tag/${tag}`;
}}
>
#{tag}
</span>
))}
{post.data.tags.length > 3 && (
<span className="text-xs md:text-base text-foreground/60">
+{post.data.tags.length - 3}
</span>
)}
</div>
</div>
</article>
</a>
</li>
</AnimateIn>
))}
</ul>
</div>
);
};

View File

@@ -1,4 +1,3 @@
import React from "react";
import { Links } from "@/components/footer/links"; import { Links } from "@/components/footer/links";
export default function Footer({ fixed = false }) { export default function Footer({ fixed = false }) {
@@ -12,8 +11,8 @@ export default function Footer({ fixed = false }) {
)); ));
return ( return (
<footer className={`w-full font-bold ${fixed ? "fixed bottom-0 left-0 right-0" : ""}`}> <footer className={`w-full font-bold pointer-events-none ${fixed ? "fixed bottom-0 left-0 right-0" : ""}`}>
<div className="flex flex-row px-2 py-1 text-lg lg:px-6 lg:py-1.5 lg:text-3xl md:text-2xl justify-between md:justify-center space-x-2 md:space-x-10 lg:space-x-20"> <div className="flex flex-row px-2 py-1 text-lg lg:px-6 lg:py-1.5 lg:text-3xl md:text-2xl justify-between md:justify-center space-x-2 md:space-x-10 lg:space-x-20 pointer-events-none [&_a]:pointer-events-auto">
{footerLinks} {footerLinks}
</div> </div>
</footer> </footer>

View File

@@ -8,7 +8,7 @@ interface FooterLink {
export const Links: FooterLink[] = [ export const Links: FooterLink[] = [
{ id: 0, href: "mailto:contact@timmypidashev.dev", label: "Contact", color: "text-green" }, { id: 0, href: "mailto:contact@timmypidashev.dev", label: "Contact", color: "text-green" },
{ id: 1, href: "https://github.com/timmypidashev", label: "Github", color: "text-yellow" }, { id: 1, href: "https://github.com/timmypidashev", label: "Github", color: "text-yellow" },
{ id: 3, href: "https://www.linkedin.com/in/timothy-pidashev-4353812b8", label: "Linkedin", color: "text-blue" }, { id: 3, href: "https://www.linkedin.com/in/timothy-pidashev-4353812b8", label: "LinkedIn", color: "text-blue" },
{ id: 4, href: "https://github.com/timmypidashev/web", label: "Source", color: "text-purple" }, { id: 4, href: "https://github.com/timmypidashev/web", label: "Source", color: "text-purple" },
{ id: 5, href: "https://github.com/timmypidashev/web/releases", label: "v2", color: "text-aqua" } { id: 5, href: "https://github.com/timmypidashev/web/releases", label: "v3", color: "text-aqua" }
]; ];

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { Links } from "@/components/header/links"; import { Links } from "@/components/header/links";
export default function Header() { export default function Header({ transparent = false }: { transparent?: boolean }) {
const [isClient, setIsClient] = useState(false); const [isClient, setIsClient] = useState(false);
const [visible, setVisible] = useState(true); const [visible, setVisible] = useState(true);
const [lastScrollY, setLastScrollY] = useState(0); const [lastScrollY, setLastScrollY] = useState(0);
@@ -34,7 +34,7 @@ export default function Header() {
return linkHref !== "/" && path.startsWith(linkHref); return linkHref !== "/" && path.startsWith(linkHref);
}; };
const isIndexPage = checkIsActive("/"); const isIndexPage = transparent || checkIsActive("/");
const headerLinks = Links.map((link) => { const headerLinks = Links.map((link) => {
const isActive = checkIsActive(link.href); const isActive = checkIsActive(link.href);
@@ -44,7 +44,7 @@ export default function Header() {
className={` className={`
relative inline-block relative inline-block
${link.color} ${link.color}
${!isIndexPage ? 'bg-black' : ''} ${!isIndexPage ? 'bg-background' : ''}
`} `}
> >
<a <a
@@ -87,17 +87,20 @@ export default function Header() {
fixed z-50 top-0 left-0 right-0 fixed z-50 top-0 left-0 right-0
font-bold font-bold
transition-transform duration-300 transition-transform duration-300
pointer-events-none
${visible ? "translate-y-0" : "-translate-y-full"} ${visible ? "translate-y-0" : "-translate-y-full"}
`} `}
> >
<div className={` <div className={`
w-full flex flex-row items-center justify-center w-full flex flex-row items-center justify-center
${!isIndexPage ? 'bg-black md:bg-transparent' : ''} pointer-events-none
${!isIndexPage ? 'bg-background md:bg-transparent' : ''}
`}> `}>
<div className={` <div className={`
w-full md:w-auto flex flex-row pt-1 px-2 text-lg lg:text-3xl md:text-2xl w-full md:w-auto flex flex-row pt-1 px-2 text-lg lg:text-3xl md:text-2xl
items-center justify-between md:justify-center space-x-2 md:space-x-10 lg:space-x-20 md:py-2 items-center justify-between md:justify-center space-x-2 md:space-x-10 lg:space-x-20 md:py-2
${!isIndexPage ? 'bg-black md:px-20' : ''} pointer-events-none [&_a]:pointer-events-auto
${!isIndexPage ? 'bg-background md:px-20' : ''}
`}> `}>
{headerLinks} {headerLinks}
</div> </div>

View File

@@ -1,4 +1,3 @@
import React from "react";
import Typewriter from "typewriter-effect"; import Typewriter from "typewriter-effect";
const html = (strings: TemplateStringsArray, ...values: any[]) => { const html = (strings: TemplateStringsArray, ...values: any[]) => {
@@ -72,8 +71,8 @@ export default function Hero() {
}; };
return ( return (
<div className="flex justify-center items-center min-h-screen"> <div className="flex justify-center items-center min-h-screen pointer-events-none">
<div className="text-4xl font-bold text-center"> <div className="text-4xl font-bold text-center pointer-events-none [&_a]:pointer-events-auto">
<Typewriter <Typewriter
options={typewriterOptions} options={typewriterOptions}
onInit={handleInit} onInit={handleInit}

View File

@@ -0,0 +1,264 @@
import React, { useState } from 'react';
import { Terminal, Copy, Check } from 'lucide-react';
// Import all required icons from react-icons
import { FaDebian, FaFedora } from 'react-icons/fa6';
import { SiGentoo, SiNixos, SiArchlinux } from 'react-icons/si';
// Component for multi-line command sequences
const CommandSequence = ({
commands,
description,
shell = "bash"
}) => {
const [copied, setCopied] = useState(false);
// Join the commands with newlines for copying
const fullCommandText = Array.isArray(commands)
? commands.join('\n')
: commands;
const copyToClipboard = () => {
navigator.clipboard.writeText(fullCommandText)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => {
console.error('Failed to copy: ', err);
});
};
return (
<div className="w-full rounded-md overflow-hidden border border-foreground/20 bg-background my-4" style={{ maxWidth: '95vw' }}>
{/* Header with Terminal Icon and Copy Button */}
<div className="bg-background border-b border-foreground/20 text-foreground p-2 flex items-center justify-between">
<div className="flex items-center">
<Terminal size={20} className="mr-2 text-yellow-bright" />
<div className="text-sm font-comic-code">
{description || "Terminal Commands"}
</div>
</div>
<button
onClick={copyToClipboard}
className="bg-background hover:bg-foreground/10 text-foreground text-xs px-2 py-1 rounded flex items-center"
>
{copied ? (
<>
<Check size={14} className="mr-1 text-green-bright" />
</>
) : (
<>
<Copy size={14} className="mr-1 text-foreground/70" />
</>
)}
</button>
</div>
{/* Command Display */}
<div className="text-foreground p-3 overflow-x-auto">
<div className="font-comic-code text-sm">
{Array.isArray(commands)
? commands.map((cmd, index) => (
<div key={index} className="flex items-start mb-2 last:mb-0">
<span className="text-orange-bright mr-2 flex-shrink-0">$</span>
<span className="text-purple-bright overflow-x-auto whitespace-nowrap">
{cmd}
</span>
</div>
))
: (
<div className="flex items-start">
<span className="text-orange-bright mr-2 flex-shrink-0">$</span>
<span className="text-purple-bright overflow-x-auto whitespace-nowrap">
{commands}
</span>
</div>
)
}
</div>
</div>
</div>
);
};
// Original Commands component with tabs for different distros
const Commands = ({
commandId,
description,
archCommand,
debianCommand,
fedoraCommand,
gentooCommand,
nixCommand
}) => {
const [activeTab, setActiveTab] = useState('arch');
const [copied, setCopied] = useState(false);
const distros = [
{
id: 'arch',
name: 'Arch',
icon: SiArchlinux,
command: archCommand || 'echo "No command specified for Arch"'
},
{
id: 'debian',
name: 'Debian/Ubuntu',
icon: FaDebian,
command: debianCommand || 'echo "No command specified for Debian/Ubuntu"'
},
{
id: 'fedora',
name: 'Fedora',
icon: FaFedora,
command: fedoraCommand || 'echo "No command specified for Fedora"'
},
{
id: 'gentoo',
name: 'Gentoo',
icon: SiGentoo,
command: gentooCommand || 'echo "No command specified for Gentoo"'
},
{
id: 'nix',
name: 'NixOS',
icon: SiNixos,
command: nixCommand || 'echo "No command specified for NixOS"'
}
];
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => {
console.error('Failed to copy: ', err);
});
};
return (
<div className="w-full rounded-md overflow-hidden border border-foreground/20 bg-background my-4" style={{ maxWidth: '95vw' }}>
{/* Header with Terminal Icon and Copy Button */}
<div className="bg-background border-b border-foreground/20 text-foreground p-2 flex items-center justify-between">
<div className="flex items-center">
<Terminal size={20} className="mr-2 text-yellow-bright" />
<div className="text-sm font-comic-code">
{description || "Terminal Command"}
</div>
</div>
<button
onClick={() => copyToClipboard(distros.find(d => d.id === activeTab).command)}
className="bg-background hover:bg-foreground/10 text-foreground text-xs px-2 py-1 rounded flex items-center"
>
{copied ? (
<>
<Check size={14} className="mr-1 text-green-bright" />
<span>Copied</span>
</>
) : (
<>
<Copy size={14} className="mr-1 text-foreground/70" />
<span>Copy</span>
</>
)}
</button>
</div>
{/* Tabs */}
<div className="flex flex-wrap border-b border-foreground/20 bg-background">
{distros.map((distro) => {
const IconComponent = distro.icon;
return (
<button
key={distro.id}
className={`px-3 py-2 text-sm font-medium flex items-center ${
activeTab === distro.id
? 'bg-background border-b-2 border-blue-bright text-blue-bright'
: 'text-foreground/80 hover:text-foreground hover:bg-foreground/5'
}`}
onClick={() => setActiveTab(distro.id)}
>
<span className="mr-1 inline-flex items-center">
<IconComponent size={16} />
</span>
{distro.name}
</button>
);
})}
</div>
{/* Command Display with Horizontal Scrolling */}
<div className="text-foreground p-3 overflow-x-auto">
<div className="flex items-center font-comic-code text-sm whitespace-nowrap">
<span className="text-orange-bright mr-2">$</span>
<span className="text-purple-bright">
{distros.find(d => d.id === activeTab).command}
</span>
</div>
</div>
</div>
);
};
// Single command component
const Command = ({
command,
description,
shell = "bash"
}) => {
const [copied, setCopied] = useState(false);
const copyToClipboard = () => {
navigator.clipboard.writeText(command)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => {
console.error('Failed to copy: ', err);
});
};
return (
<div className="w-full rounded-md overflow-hidden border border-foreground/20 bg-background my-4" style={{ maxWidth: '95vw' }}>
{/* Header with Terminal Icon and Copy Button */}
<div className="bg-background border-b border-foreground/20 text-foreground p-2 flex items-center justify-between">
<div className="flex items-center">
<Terminal size={20} className="mr-2 text-yellow-bright" />
<div className="text-sm font-comic-code">
{description || "Terminal Command"}
</div>
</div>
<button
onClick={copyToClipboard}
className="bg-background hover:bg-foreground/10 text-foreground text-xs px-2 py-1 rounded flex items-center"
>
{copied ? (
<>
<Check size={14} className="mr-1 text-green-bright" />
</>
) : (
<>
<Copy size={14} className="mr-1 text-foreground/70" />
</>
)}
</button>
</div>
{/* Command Display with Horizontal Scrolling */}
<div className="text-foreground p-3 overflow-x-auto">
<div className="flex items-center font-comic-code text-sm whitespace-nowrap">
<span className="text-orange-bright mr-2">$</span>
<span className="text-purple-bright">
{command}
</span>
</div>
</div>
</div>
);
};
export { Commands, Command, CommandSequence };

View File

@@ -0,0 +1,68 @@
// src/components/mdx/Video.tsx
import React, { useRef } from "react";
import { Play } from "lucide-react";
type VideoProps = {
url: string;
title: string;
attribution?: string;
className?: string;
};
export function Video({ url, title, attribution, className }: VideoProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const overlayRef = useRef<HTMLButtonElement>(null);
const handlePlay = () => {
if (!videoRef.current || !overlayRef.current) return;
// Show browser native controls on play
videoRef.current.controls = true;
videoRef.current.play();
// Hide the overlay
overlayRef.current.style.display = "none";
};
return (
<figure className={`w-full ${className ?? ""}`}>
<div className="relative w-full bg-background rounded-lg overflow-hidden">
<video
ref={videoRef}
className="w-full h-auto bg-black cursor-pointer rounded-lg block"
preload="metadata"
playsInline
title={title}
>
<source src={url} type="video/mp4" />
Your browser does not support the video tag.
</video>
{/* Big overlay play button */}
<button
ref={overlayRef}
onClick={handlePlay}
className="absolute inset-0 flex items-center justify-center bg-background/90 text-foreground hover:text-yellow-bright transition"
aria-label={`Play ${title}`}
>
<Play size={64} strokeWidth={1.5} />
</button>
</div>
{/* Title + attribution */}
<figcaption className="mt-2 text-xs text-foreground flex justify-between items-center">
<span>{title}</span>
{attribution && (
<a
href={attribution}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-blue-bright"
>
{attribution}
</a>
)}
</figcaption>
</figure>
);
}

View File

@@ -0,0 +1,99 @@
import type { CollectionEntry } from "astro:content";
import { AnimateIn } from "@/components/animate-in";
interface ProjectListProps {
projects: CollectionEntry<"projects">[];
}
export function ProjectList({ projects }: ProjectListProps) {
return (
<div className="w-full max-w-6xl mx-auto pt-24 sm:pt-32 px-4">
<AnimateIn>
<h1 className="text-2xl sm:text-3xl font-bold text-blue mb-12 text-center leading-relaxed">
Here's what I've been <br className="sm:hidden" />
building lately
</h1>
</AnimateIn>
<ul className="space-y-6 md:space-y-10">
{projects.map((project, i) => (
<AnimateIn key={project.id} delay={i * 80}>
<li className="group">
<a href={`/projects/${project.id}`} className="block">
<article className="flex flex-col md:flex-row md:items-center gap-4 md:gap-8 p-2 md:p-4 border-b border-foreground/20 last:border-b-0 rounded-lg group-hover:outline group-hover:outline-2 group-hover:outline-blue transition-all duration-200">
{/* Image */}
<div className="w-full md:w-1/3 aspect-[16/9] overflow-hidden rounded-lg bg-foreground/5 flex-shrink-0">
{project.data.image ? (
<img
src={project.data.image}
alt={`${project.data.title} preview`}
className="w-full h-full object-cover object-center group-hover:scale-105 transition-transform duration-300"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-foreground/30">
<span className="text-sm">No preview available</span>
</div>
)}
</div>
{/* Content */}
<div className="w-full md:w-2/3 flex flex-col gap-2 md:gap-3">
<h2 className="text-lg md:text-2xl font-semibold text-yellow group-hover:text-blue transition-colors duration-200 line-clamp-2">
{project.data.title}
</h2>
<p className="text-foreground/90 text-sm md:text-lg leading-relaxed line-clamp-2">
{project.data.description}
</p>
{/* Tech stack */}
<div className="flex flex-wrap gap-1.5 md:gap-2 mt-1">
{project.data.techStack.map((tech) => (
<span
key={tech}
className="text-xs md:text-sm px-2 py-0.5 rounded-full bg-purple-bright/10 text-purple-bright"
>
{tech}
</span>
))}
</div>
{/* Links */}
{(project.data.githubUrl || project.data.demoUrl) && (
<div className="flex gap-4 mt-1">
{project.data.githubUrl && (
<span
className="text-sm text-foreground/50 hover:text-blue-bright transition-colors"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.open(project.data.githubUrl, "_blank");
}}
>
Source
</span>
)}
{project.data.demoUrl && (
<span
className="text-sm text-foreground/50 hover:text-green-bright transition-colors"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.open(project.data.demoUrl, "_blank");
}}
>
Live
</span>
)}
</div>
)}
</div>
</article>
</a>
</li>
</AnimateIn>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,413 @@
import React, { useEffect, useRef, useState } from "react";
import {
FileDown,
Github,
Linkedin,
Globe
} from "lucide-react";
// --- Typewriter hook ---
function useTypewriter(text: string, trigger: boolean, speed = 12) {
const [displayed, setDisplayed] = useState("");
const [done, setDone] = useState(false);
useEffect(() => {
if (!trigger) return;
let i = 0;
setDisplayed("");
setDone(false);
const interval = setInterval(() => {
i++;
setDisplayed(text.slice(0, i));
if (i >= text.length) {
setDone(true);
clearInterval(interval);
}
}, speed);
return () => clearInterval(interval);
}, [trigger, text, speed]);
return { displayed, done };
}
// --- Visibility hook ---
function useScrollVisible(threshold = 0.1) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
if (rect.top < window.innerHeight && rect.bottom > 0) {
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold }
);
observer.observe(el);
return () => observer.disconnect();
}, [threshold]);
return { ref, visible };
}
// --- Section fade-in ---
function Section({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
const { ref, visible } = useScrollVisible();
return (
<div
ref={ref}
className="transition-all duration-700 ease-out"
style={{
transitionDelay: `${delay}ms`,
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)",
}}
>
{children}
</div>
);
}
// --- Typed heading + fade-in body ---
function TypedSection({
heading,
headingClass = "text-3xl font-bold text-yellow-bright",
children,
}: {
heading: string;
headingClass?: string;
children: React.ReactNode;
}) {
const { ref, visible } = useScrollVisible();
const { displayed, done } = useTypewriter(heading, visible, 20);
return (
<div ref={ref} className="space-y-4">
<h3 className={headingClass} style={{ minHeight: "1.2em" }}>
{visible ? displayed : "\u00A0"}
{visible && !done && <span className="animate-pulse text-foreground/40">|</span>}
</h3>
<div
className="transition-all duration-500 ease-out"
style={{
opacity: done ? 1 : 0,
transform: done ? "translateY(0)" : "translateY(12px)",
}}
>
{children}
</div>
</div>
);
}
// --- Staggered skill tags ---
function SkillTags({ skills, trigger }: { skills: string[]; trigger: boolean }) {
return (
<div className="flex flex-wrap gap-3">
{skills.map((skill, i) => (
<span
key={i}
className="border border-foreground/20 px-4 py-2 rounded-lg hover:border-foreground/40 transition-all duration-500 ease-out"
style={{
transitionDelay: `${i * 60}ms`,
opacity: trigger ? 1 : 0,
transform: trigger ? "translateY(0) scale(1)" : "translateY(12px) scale(0.95)",
}}
>
{skill}
</span>
))}
</div>
);
}
// --- Data ---
const resumeData = {
name: "Timothy Pidashev",
title: "Software Engineer",
contact: {
email: "contact@timmypidashev.dev",
phone: "+1 (360) 409-0357",
location: "Camas, WA",
linkedin: "linkedin.com/in/timothy-pidashev-4353812b8/",
github: "github.com/timmypidashev"
},
summary: "Experienced software engineer with a passion for building scalable web applications and solving complex problems. Specialized in React, TypeScript, and modern web technologies.",
experience: [
{
title: "Office Manager & Tutor",
company: "FHCC",
location: "Ridgefield, WA",
period: "2020 - Present",
achievements: [
"Tutored Python, JavaScript, and HTML to students in grades 4-10, successfully fostering early programming skills",
"Designed and deployed a full-stack CRUD application to manage organizational operations",
"Engineered and implemented building-wide networking infrastructure and managed multiple service deployments",
"Maintained student records and administrative paperwork."
]
}
],
contractWork: [
{
title: "Revive Auto Parts",
type: "Full-Stack Development & Maintenance",
startDate: "2024",
url: "https://reviveauto.parts",
responsibilities: [
"Maintain and optimize website performance and security",
"Implement new features and functionality as needed",
"Provide 24/7 monitoring and emergency support"
],
achievements: [
"Designed and built the entire application from the ground up, including auth",
"Engineered a tagging system to optimize search results by keywords and relativity",
"Implemented a filter provider to further narrow down search results and enhance the user experience",
"Created a smooth and responsive infinitely scrollable listings page",
"Automated deployment & testing processes reducing downtime by 60%"
]
}
],
education: [
{
degree: "B.S. Computer Science",
school: "Clark College",
location: "Vancouver, WA",
period: "Graduating 2026",
achievements: [] as string[]
}
],
skills: {
technical: ["JavaScript", "TypeScript", "React", "Node.js", "Python", "SQL", "AWS", "Docker", "C", "Go", "Rust"],
soft: ["Leadership", "Problem Solving", "Communication", "Agile Methodologies"]
},
};
// --- Component ---
const Resume = () => {
const handleDownloadPDF = () => {
window.open("/timothy-pidashev-resume.pdf", "_blank");
};
return (
<div className="max-w-4xl mx-auto px-6 md:px-8 pt-24 pb-16">
<div className="space-y-16">
{/* Header */}
<header className="text-center space-y-6">
<Section>
<h1 className="text-6xl font-bold text-aqua-bright">{resumeData.name}</h1>
</Section>
<Section delay={150}>
<h2 className="text-3xl text-foreground/80">{resumeData.title}</h2>
</Section>
<Section delay={300}>
<div className="flex flex-col md:flex-row justify-center gap-4 md:gap-6 text-foreground/60 text-lg">
<a href={`mailto:${resumeData.contact.email}`} className="hover:text-foreground transition-colors duration-200">
{resumeData.contact.email}
</a>
<span className="hidden md:inline"></span>
<a href={`tel:${resumeData.contact.phone}`} className="hover:text-foreground transition-colors duration-200">
{resumeData.contact.phone}
</a>
<span className="hidden md:inline"></span>
<span>{resumeData.contact.location}</span>
</div>
</Section>
<Section delay={450}>
<div className="flex justify-center items-center gap-6 text-lg">
<a href={`https://${resumeData.contact.github}`}
target="_blank"
className="text-blue-bright hover:text-blue transition-colors duration-200 flex items-center gap-2"
>
<Github size={18} />
GitHub
</a>
<a href={`https://${resumeData.contact.linkedin}`}
target="_blank"
className="text-blue-bright hover:text-blue transition-colors duration-200 flex items-center gap-2"
>
<Linkedin size={18} />
LinkedIn
</a>
<button
onClick={handleDownloadPDF}
className="text-blue-bright hover:text-blue transition-colors duration-200 flex items-center gap-2"
>
<FileDown size={18} />
Resume
</button>
</div>
</Section>
</header>
{/* Summary */}
<TypedSection heading="Professional Summary">
<p className="text-xl leading-relaxed">{resumeData.summary}</p>
</TypedSection>
{/* Experience */}
<TypedSection heading="Experience">
<div className="space-y-8">
{resumeData.experience.map((exp, index) => (
<Section key={index} delay={index * 100}>
<div className="space-y-4">
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-2">
<div>
<h4 className="text-2xl font-semibold text-green-bright">{exp.title}</h4>
<div className="text-foreground/60 text-lg">{exp.company} - {exp.location}</div>
</div>
<div className="text-foreground/60 text-lg font-medium">{exp.period}</div>
</div>
<ul className="list-disc pl-6 space-y-3">
{exp.achievements.map((a, i) => (
<li key={i} className="text-lg leading-relaxed">{a}</li>
))}
</ul>
</div>
</Section>
))}
</div>
</TypedSection>
{/* Contract Work */}
<TypedSection heading="Contract Work">
<div className="space-y-8">
{resumeData.contractWork.map((project, index) => (
<Section key={index} delay={index * 100}>
<div className="space-y-4">
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-2">
<div>
<div className="flex items-center gap-3">
<h4 className="text-2xl font-semibold text-green-bright">{project.title}</h4>
{project.url && (
<a
href={project.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-bright hover:text-blue transition-colors duration-200 opacity-60 hover:opacity-100"
>
<Globe size={16} strokeWidth={1.5} />
</a>
)}
</div>
<div className="text-foreground/60 text-lg">{project.type}</div>
</div>
<div className="text-foreground/60 text-lg font-medium">Since {project.startDate}</div>
</div>
<div className="space-y-4">
{project.responsibilities && (
<div>
<h5 className="text-lg text-aqua font-semibold mb-2">Responsibilities</h5>
<ul className="list-disc pl-6 space-y-3">
{project.responsibilities.map((r, i) => (
<li key={i} className="text-lg leading-relaxed">{r}</li>
))}
</ul>
</div>
)}
{project.achievements && (
<div>
<h5 className="text-lg text-aqua font-semibold mb-2">Key Achievements</h5>
<ul className="list-disc pl-6 space-y-3">
{project.achievements.map((a, i) => (
<li key={i} className="text-lg leading-relaxed">{a}</li>
))}
</ul>
</div>
)}
</div>
</div>
</Section>
))}
</div>
</TypedSection>
{/* Education */}
<TypedSection heading="Education">
<div className="space-y-8">
{resumeData.education.map((edu, index) => (
<Section key={index}>
<div className="space-y-4">
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-2">
<div>
<h4 className="text-2xl font-semibold text-green-bright">{edu.degree}</h4>
<div className="text-foreground/60 text-lg">{edu.school} - {edu.location}</div>
</div>
<div className="text-foreground/60 text-lg font-medium">{edu.period}</div>
</div>
{edu.achievements.length > 0 && (
<ul className="list-disc pl-6 space-y-3">
{edu.achievements.map((a, i) => (
<li key={i} className="text-lg leading-relaxed">{a}</li>
))}
</ul>
)}
</div>
</Section>
))}
</div>
</TypedSection>
{/* Skills */}
<SkillsSection />
</div>
</div>
);
};
// --- Skills section ---
function SkillsSection() {
const { ref, visible } = useScrollVisible();
const { displayed, done } = useTypewriter("Skills", visible, 20);
return (
<div ref={ref} className="space-y-8">
<h3 className="text-3xl font-bold text-yellow-bright" style={{ minHeight: "1.2em" }}>
{visible ? displayed : "\u00A0"}
{visible && !done && <span className="animate-pulse text-foreground/40">|</span>}
</h3>
<div
className="grid grid-cols-1 md:grid-cols-2 gap-8 transition-all duration-500 ease-out"
style={{
opacity: done ? 1 : 0,
transform: done ? "translateY(0)" : "translateY(12px)",
}}
>
<div className="space-y-4">
<h4 className="text-2xl font-semibold text-green-bright">Technical Skills</h4>
<SkillTags skills={resumeData.skills.technical} trigger={done} />
</div>
<div className="space-y-4">
<h4 className="text-2xl font-semibold text-green-bright">Soft Skills</h4>
<SkillTags skills={resumeData.skills.soft} trigger={done} />
</div>
</div>
</div>
);
}
export default Resume;

View File

@@ -0,0 +1,98 @@
import { useRef, useState, useEffect } from "react";
import { getStoredThemeId, getNextTheme, applyTheme } from "@/lib/themes/engine";
const FADE_DURATION = 300;
const LABELS: Record<string, string> = {
darkbox: "classic",
"darkbox-retro": "retro",
"darkbox-dim": "dim",
};
export default function ThemeSwitcher() {
const [hovering, setHovering] = useState(false);
const [currentLabel, setCurrentLabel] = useState("");
const maskRef = useRef<HTMLDivElement>(null);
const animatingRef = useRef(false);
const committedRef = useRef("");
useEffect(() => {
committedRef.current = getStoredThemeId();
setCurrentLabel(LABELS[committedRef.current] ?? "");
const handleSwap = () => {
const id = getStoredThemeId();
applyTheme(id);
committedRef.current = id;
setCurrentLabel(LABELS[id] ?? "");
};
document.addEventListener("astro:after-swap", handleSwap);
return () => {
document.removeEventListener("astro:after-swap", handleSwap);
};
}, []);
const handleClick = () => {
if (animatingRef.current) return;
animatingRef.current = true;
const mask = maskRef.current;
if (!mask) return;
const v = getComputedStyle(document.documentElement)
.getPropertyValue("--color-background")
.trim();
const [r, g, b] = v.split(" ").map(Number);
mask.style.backgroundColor = `rgb(${r},${g},${b})`;
mask.style.opacity = "1";
mask.style.visibility = "visible";
mask.style.transition = "none";
const next = getNextTheme(committedRef.current);
applyTheme(next.id);
committedRef.current = next.id;
setCurrentLabel(LABELS[next.id] ?? "");
mask.offsetHeight;
mask.style.transition = `opacity ${FADE_DURATION}ms ease-out`;
mask.style.opacity = "0";
const onEnd = () => {
mask.removeEventListener("transitionend", onEnd);
mask.style.visibility = "hidden";
mask.style.transition = "none";
animatingRef.current = false;
};
mask.addEventListener("transitionend", onEnd);
};
return (
<>
<div
className="fixed bottom-4 right-4 z-[101] pointer-events-auto hidden md:block"
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
onClick={handleClick}
style={{ cursor: "pointer" }}
>
<span
className="text-foreground font-bold text-sm select-none transition-opacity duration-200"
style={{ opacity: hovering ? 0.8 : 0.15 }}
>
{currentLabel}
</span>
</div>
<div
ref={maskRef}
className="fixed inset-0 z-[100] pointer-events-none"
style={{ visibility: "hidden", opacity: 0 }}
/>
</>
);
}

View File

@@ -1,7 +1,9 @@
import { defineCollection, z } from "astro:content"; import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
export const collections = { export const collections = {
blog: defineCollection({ blog: defineCollection({
loader: glob({ pattern: "**/*.mdx", base: "./src/content/blog" }),
schema: z.object({ schema: z.object({
title: z.string(), title: z.string(),
description: z.string(), description: z.string(),
@@ -12,9 +14,11 @@ export const collections = {
}), }),
image: z.string().optional(), image: z.string().optional(),
imagePosition: z.string().optional(), imagePosition: z.string().optional(),
isDraft: z.boolean().optional()
}), }),
}), }),
projects: defineCollection({ projects: defineCollection({
loader: glob({ pattern: "**/*.mdx", base: "./src/content/projects" }),
schema: z.object({ schema: z.object({
title: z.string(), title: z.string(),
description: z.string(), description: z.string(),
@@ -22,7 +26,7 @@ export const collections = {
demoUrl: z.string().url().optional(), demoUrl: z.string().url().optional(),
techStack: z.array(z.string()), techStack: z.array(z.string()),
date: z.string(), date: z.string(),
image: z.string().optional(), image: z.string().optional()
}),
}), }),
})
}; };

View File

@@ -0,0 +1,9 @@
---
title: Breaking the Chromebook Cage
description: From breaking Chromebooks as a student to breaking Chromebooks to stop students from breaking Chromebooks
author: Timothy Pidashev
tags: ["uefi", "coreboot", "firmware", "chromebooks"]
date: 2025-09-15
image: "/blog/breaking-the-chromebook-cage/thumbnail.png"
isDraft: true
---

View File

@@ -0,0 +1,104 @@
import React from 'react';
interface T440pAdProps {
className?: string;
}
const Advertisement: React.FC<T440pAdProps> = ({ className = '' }) => {
return (
<div className={`bg-gradient-to-br from-blue-50 to-indigo-100 border-2 border-blue-200 rounded-lg p-6 my-8 shadow-lg ${className}`}>
<div className="flex items-start gap-4">
{/* Icon/Logo placeholder */}
<div className="flex-shrink-0 w-12 h-12 bg-blue-600 rounded-lg flex items-center justify-center">
<svg
className="w-8 h-8 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<h3 className="text-xl font-bold text-gray-900">
Custom Corebooted ThinkPad T440p
</h3>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Available Now
</span>
</div>
<p className="text-gray-700 mb-4">
Skip the technical complexity and get a professionally corebooted ThinkPad T440p
built to your specifications. Each laptop is carefully modified and tested to ensure
optimal performance and reliability.
</p>
<div className="grid md:grid-cols-2 gap-4 mb-4">
<div>
<h4 className="font-semibold text-gray-900 mb-2"> What's Included:</h4>
<ul className="text-sm text-gray-700 space-y-1">
<li> Coreboot firmware pre-installed</li>
<li> IPS 1080p display upgrade</li>
<li> RAM options: 4GB, 8GB, or 16GB</li>
<li> CPU choice available</li>
<li> Battery upgrade option</li>
<li> Thorough testing & quality assurance</li>
</ul>
</div>
<div>
<h4 className="font-semibold text-gray-900 mb-2">🔧 Benefits:</h4>
<ul className="text-sm text-gray-700 space-y-1">
<li> Faster boot times</li>
<li> Open-source BIOS</li>
<li> Enhanced security</li>
<li> No proprietary firmware</li>
<li> Full hardware control</li>
<li> Professional installation</li>
</ul>
</div>
</div>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 pt-4 border-t border-blue-200">
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-blue-600">$500</span>
<span className="text-sm text-gray-600">USD (base configuration)</span>
</div>
<div className="flex gap-3">
<a
href="https://ebay.com"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
<svg className="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M8.5 2A1.5 1.5 0 007 3.5v1A1.5 1.5 0 008.5 6h7A1.5 1.5 0 0017 4.5v-1A1.5 1.5 0 0015.5 2h-7zM10 3h4v1h-4V3z"/>
<path d="M6 7.5A1.5 1.5 0 017.5 6h9A1.5 1.5 0 0118 7.5v9a1.5 1.5 0 01-1.5 1.5h-9A1.5 1.5 0 016 16.5v-9z"/>
</svg>
Order on eBay
</a>
<button className="inline-flex items-center px-4 py-2 border border-blue-600 text-blue-600 font-medium rounded-lg hover:bg-blue-50 transition-colors">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Ask Questions
</button>
</div>
</div>
</div>
</div>
</div>
);
};
export default Advertisement;

View File

@@ -0,0 +1,351 @@
---
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"
isDraft: true
---
import { Commands, Command, CommandSequence } from "@/components/mdx/command";
import Advertisement from '@/content/blog/components/thinkpad-t440p-coreboot-guide/advertisement';
> **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.
> This script supports Arch, Debian, Fedora, Gentoo, and Nix!
<Command
description="Interactive script"
command="curl -fsSL https://timmypidashev.dev/scripts/run.sh | sh -s -- -t coreboot-t440p"
client:load
/>
Don't pipe anyone's scripts to **sh** blindly, including mine - <a href="https://github.com/timmypidashev/scripts" target="_blank" rel="noopener noreferrer">audit the source</a>.
## 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.
## Installing Dependencies
Install the following programs. These will be needed to compile coreboot and flash the BIOS.
<Commands
description="Install prerequisite packages"
archCommand="sudo pacman -S base-devel curl git gcc-ada ncurses zlib nasm sharutils unzip flashrom"
debianCommand="sudo apt install build-essential curl git gnat libncurses-dev zlib1g-dev nasm sharutils unzip flashrom"
fedoraCommand="sudo dnf install @development-tools curl git gcc-gnat ncurses-devel zlib-devel nasm sharutils unzip flashrom"
gentooCommand="sudo emerge --ask sys-devel/base-devel net-misc/curl dev-vcs/git sys-devel/gcc ncurses dev-libs/zlib dev-lang/nasm app-arch/sharutils app-arch/unzip sys-apps/flashrom"
nixCommand="nix-env -i stdenv curl git gcc gnat ncurses zlib nasm sharutils unzip flashrom"
client:load
/>
## 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
In order to flash the laptop, you will need to have access to two EEPROM chips located next to the sodimm RAM.
![EEPROM Chips Location](/blog/thinkpad-t440p-coreboot-guide/eeprom_chips_location.png)
## Assembling the SPI Flasher
Place the SPI flasher ribbon cable into the correct slot and make sure its the 3.3v variant
![SPI Flasher Assembly](/blog/thinkpad-t440p-coreboot-guide/spi_flasher_assembly.png)
After the flasher is ready, connect it to your machine and ensure its ready to use:
<Command
description="Ensure the CH341A flasher is being detected"
command="flashrom --programmer ch341a_spi"
/>
Flashrom should report that programmer initialization was a success.
## Extracting Original BIOS
To begin, first create a clean directory where all work to coreboot
the T440p will be done.
<Command
description="Create a directory where all work will be done"
command="mkdir ~/t440p-coreboot"
client:load
/>
Next, extract the original rom from both EEPROM chips. This is
done by attaching the programmer to the correct chip and running
the subsequent commands. It may take longer than expected, and
ensuring the bios was properly extracted is important before proceeding
further.
<CommandSequence
commands={[
"sudo flashrom --programmer ch341a_spi -r 4mb_backup1.bin",
"sudo flashrom --programmer ch341a_spi -r 4mb_backup2.bin",
"diff 4mb_backup1.bin 4mb_backup2.bin"
]}
description="Backup and verify 4MB chip"
client:load
/>
<CommandSequence
commands={[
"sudo flashrom --programmer ch341a_spi -r 8mb_backup1.bin",
"sudo flashrom --programmer ch341a_spi -r 8mb_backup2.bin",
"diff 8mb_backup1.bin 8mb_backup2.bin"
]}
description="Backup and verify 8MB chip"
client:load
/>
If the diff checks pass, combine both files into one ROM.
<Command
description="Combine 4MB & 8MB into one ROM"
command="cat 8mb_backup_1.bin 4mb_backup1.bin > t440p-original.rom"
client:load
/>
## Building Required Tools
Now that the original bios has been successfuly extracted, it is time
to clone the coreboot repository and build every tool needed to build
a new bios image.
<CommandSequence
commands={[
"git clone https://review.coreboot.org/coreboot ~/t440p-coreboot/coreboot",
"cd ~/t440p-coreboot/coreboot",
"git checkout e1e762716cf925c621d58163133ed1c3e006a903",
"git submodule update --init --checkout"
]}
description="Clone coreboot and checkout to the correct commit"
client:load
/>
We will need to build `idftool`, which will be used to export all necessary blobs
from our original bios, and `cbfstool`, which will be used to extract __mrc.bin__(a blob
from a haswell chromebook peppy image).
<Command
description="Build util/ifdtool"
command="cd ~/t440p-coreboot/coreboot/util/ifdtool && make"
client:load
/>
<Command
description="Build util/cbfstool"
command="cd ~/t440p-coreboot/coreboot/ && make -C util/cbfstool"
client:load
/>
## Exporting Firmware Blobs
Once the necessary tools have been built, we can export the
3 flash regions from our original bios image.
<CommandSequence
commands={[
"cd ~/t440p-coreboot/coreboot/util/ifdtool",
"./ifdtool -x ~/t440p-coreboot/t440p-original.rom",
"mv flashregion_0_flashdescriptor.bin ~/t440p-coreboot/ifd.bin",
"mv flashregion_2_intel_me.bin ~/t440p-coreboot/me.bin",
"mv flashregion_3_gbe.bin ~/t440p-coreboot/gbe.bin"
]}
description="Export firmware blobs"
client:load
/>
## Obtaining mrc.bin
In order to obtain __mrc.bin__, we need the chromeos peppy image.
This can be pulled by running the `crosfirmware.sh` script found in util/chromeos.
<Command
description="Download peppy chromeos image"
command="cd ~/t440p-coreboot/coreboot/util/chromeos && ./crosfirmware.sh peppy"
client:load
/>
We can now obtain __mrc.bin__ using cbfstool to extract the blob from the image.
<CommandSequence
commands={[
"cd ~/t440p-coreboot/coreboot/util/chromeos",
"../cbfstool/cbfstool coreboot-*.bin extract -f mrc.bin -n mrc.bin -r RO_SECTION",
"mv mrc.bin ~/t440p-coreboot/mrc.bin"
]}
description="Extract mrc.bin using cbfstool"
client:load
/>
## Configuring Coreboot
Configuring coreboot is really where most of your time will be spent. To help out,
I've created several handy configs that should suit most use cases, and can be easily
tweaked to your liking. Here is a list of whats available:
### 1. GRUB2 (Recommended)
GRUB2 is the recommended payload for most users. It boots Linux directly without needing a
separate bootloader installation on disk. This configuration includes three secondary payloads:
- **memtest86+** - Memory testing utility
- **nvramcui** - CMOS/NVRAM settings editor
- **coreinfo** - System information viewer
If your T440p has the optional GT730M dGPU, the GRUB2 config also includes the
necessary VGA option ROM for it.
### 2. SeaBIOS
SeaBIOS provides a traditional BIOS interface, making it the most compatible option.
Choose this if you need to boot operating systems that expect a legacy BIOS, such
as Windows or BSD.
### 3. edk2 (UEFI)
edk2 provides a UEFI firmware interface. Choose this if you prefer UEFI boot or
need UEFI-specific features.
---
If using the interactive script, it will prompt you to choose a payload and apply
a preset configuration automatically. You can also choose to open the full
configuration menu (`make nconfig`) to customize further.
For manual configuration, first copy the extracted blobs into place:
<CommandSequence
commands={[
"mkdir -p ~/t440p-coreboot/coreboot/3rdparty/blobs/mainboard/lenovo/haswell",
"mkdir -p ~/t440p-coreboot/coreboot/3rdparty/blobs/cpu/intel/haswell",
"cp ~/t440p-coreboot/ifd.bin ~/t440p-coreboot/coreboot/3rdparty/blobs/mainboard/lenovo/haswell/descriptor.bin",
"cp ~/t440p-coreboot/me.bin ~/t440p-coreboot/coreboot/3rdparty/blobs/mainboard/lenovo/haswell/me.bin",
"cp ~/t440p-coreboot/gbe.bin ~/t440p-coreboot/coreboot/3rdparty/blobs/mainboard/lenovo/haswell/gbe.bin",
"cp ~/t440p-coreboot/mrc.bin ~/t440p-coreboot/coreboot/3rdparty/blobs/cpu/intel/haswell/mrc.bin"
]}
description="Copy firmware blobs into the coreboot source tree"
client:load
/>
Then open the configuration menu:
<Command
description="Open coreboot configuration"
command="cd ~/t440p-coreboot/coreboot && make nconfig"
client:load
/>
Key settings to configure:
- **Mainboard** &rarr; Mainboard vendor: **Lenovo** &rarr; Mainboard model: **ThinkPad T440p**
- **Chipset** &rarr; Add Intel descriptor.bin, ME firmware, and GbE configuration (set paths to your blobs)
- **Chipset** &rarr; Add haswell MRC file (set path to mrc.bin)
- **Payload** &rarr; Choose your preferred payload (GRUB2, SeaBIOS, or edk2)
## Building and Flashing
After configuring coreboot, it is time to build and flash it onto your unsuspecting T440p :D
<CommandSequence
commands={[
"cd ~/t440p-coreboot/coreboot",
"make crossgcc-i386 CPUS=$(nproc)",
"make"
]}
description="Build coreboot"
client:load
/>
Once the coreboot build has completed, split the built ROM for the 8MB(bottom) chip & 4MB(top) chip.
<CommandSequence
commands={[
"cd ~/t440p-coreboot/coreboot/build",
"dd if=coreboot.rom of=bottom.rom bs=1M count=8",
"dd if=coreboot.rom of=top.rom bs=1M skip=8"
]}
description="Split the built ROM for both EEPROM chips"
client:load
/>
Now flash the new bios onto your thinkpad!
<Command
description="Flash the 4MB chip"
command="sudo flashrom --programmer ch341a_spi -w top.rom"
/>
<Command
description="Flash the 8MB chip"
command="sudo flashrom --programmer ch341a_spi -w bottom.rom"
/>
Thats it! If done properly, your thinkpad should now boot!
## Reverting to Original
If for some reason you feel the need to revert back, or your T440p can't boot,
here are the steps needed to flash the original image back.
### Can't Boot
<CommandSequence
commands={[
"cd ~/t440p-coreboot/",
"dd if=t440p-original.rom of=bottom.rom bs=1M count=8",
"dd if=t440p-original.rom of=top.rom bs=1M skip=8"
]}
description="Split original bios image for both EEPROM chips"
client:load
/>
<Command
description="Flash the 4MB chip"
command="sudo flashrom --programmer ch341a_spi -w top.rom"
/>
<Command
description="Flash the 8MB chip"
command="sudo flashrom --programmer ch341a_spi -w bottom.rom"
/>
### Can Boot
<CommandSequence
commands={[
"sudo sed -i '/GRUB_CMDLINE_LINUX_DEFAULT/ s/\"/ iomem=relaxed\"/2' /etc/default/grub",
"sudo grub-mkconfig -o /boot/grub/grub.cfg",
]}
description="Set kernel flag iomem=relaxed and update grub config"
client:load
/>
Reboot to apply `iomem=relaxed`
<Command
description="Flash the original bios"
command="sudo flashrom -p internal:laptop=force_I_want_a_brick -r ~/t440p-coreboot/t440p-original.rom"
/>
And that about wraps it up! If you liked the guide, leave a reaction or comment any changes or fixes
I should make below. Your feedback is greatly appreciated!

View File

View File

@@ -1,32 +1,36 @@
--- ---
import "@/style/globals.css"; import "@/style/globals.css";
import { ClientRouter } from "astro:transitions"; import { ClientRouter } from "astro:transitions";
import Header from "@/components/header"; import Header from "@/components/header";
import Footer from "@/components/footer"; import Footer from "@/components/footer";
import Background from "@/components/background"; import Background from "@/components/background";
import ThemeSwitcher from "@/components/theme-switcher";
import AnimationSwitcher from "@/components/animation-switcher";
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
export interface Props { export interface Props {
title: string; title: string;
description: string; description: string;
} }
const { title, description } = Astro.props; const { title, description } = Astro.props;
const ogImage = "https://timmypidashev.dev/og-image.jpg"; const ogImage = "https://timmypidashev.dev/og-image.jpg";
--- ---
<html lang="en"> <html lang="en">
<head> <head>
<title>{title}</title> <title>{title}</title>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<!-- OpenGraph -->
<meta property="og:image" content={ogImage} /> <meta property="og:image" content={ogImage} />
<meta property="og:image:width" content="1200" /> <meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" /> <meta property="og:image:height" content="630" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={ogImage} /> <meta name="twitter:image" content={ogImage} />
<meta name="twitter:description" content={description} /> <meta name="twitter:description" content={description} />
<!-- Basic meta description for search engines -->
<meta name="description" content={description} /> <meta name="description" content={description} />
<!-- Also used in OpenGraph for social media sharing -->
<meta property="og:description" content={description} /> <meta property="og:description" content={description} />
<link rel="icon" type="image/jpeg" href="/me.jpeg" /> <link rel="icon" type="image/jpeg" href="/me.jpeg" />
<ClientRouter <ClientRouter
@@ -37,7 +41,6 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
::view-transition-new(:root) { ::view-transition-new(:root) {
animation: none; animation: none;
} }
::view-transition-old(:root) { ::view-transition-old(:root) {
animation: 90ms ease-out both fade-out; animation: 90ms ease-out both fade-out;
} }
@@ -46,23 +49,26 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
to { opacity: 0; } to { opacity: 0; }
} }
</style> </style>
<script is:inline set:html={THEME_LOADER_SCRIPT} />
<script is:inline set:html={ANIMATION_LOADER_SCRIPT} />
</head> </head>
<body class="bg-background text-foreground min-h-screen flex flex-col"> <body class="bg-background text-foreground min-h-screen flex flex-col">
<Header client:load /> <Header client:load />
<main class="flex-1 flex flex-col"> <main class="flex-1 flex flex-col">
<div class="max-w-5xl mx-auto pt-12 px-4 py-8 flex-1"> <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 /> <Background layout="content" position="right" client:only="react" transition:persist />
<div>
<slot /> <slot />
</div>
<Background layout="content" position="left" client:only="react" transition:persist /> <Background layout="content" position="left" client:only="react" transition:persist />
</div> </div>
</main> </main>
<div class="mt-auto"> <div class="mt-auto">
<Footer client:load transition:persist /> <Footer client:load transition:persist />
</div> </div>
<script> <ThemeSwitcher client:only="react" transition:persist />
document.addEventListener("astro:after-navigation", () => { <AnimationSwitcher client:only="react" transition:persist />
window.scrollTo(0, 0); <script is:inline set:html={`window.scrollTo(0,0);` + THEME_NAV_SCRIPT} />
}); <script is:inline set:html={ANIMATION_NAV_SCRIPT} />
</script>
</body> </body>
</html> </html>

View File

@@ -1,6 +1,4 @@
--- ---
const { content } = Astro.props;
import "@/style/globals.css"; import "@/style/globals.css";
import { ClientRouter } from "astro:transitions"; import { ClientRouter } from "astro:transitions";
@@ -8,6 +6,10 @@ import { ClientRouter } from "astro:transitions";
import Header from "@/components/header"; import Header from "@/components/header";
import Footer from "@/components/footer"; import Footer from "@/components/footer";
import Background from "@/components/background"; import Background from "@/components/background";
import ThemeSwitcher from "@/components/theme-switcher";
import AnimationSwitcher from "@/components/animation-switcher";
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
export interface Props { export interface Props {
title: string; title: string;
@@ -23,28 +25,30 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
<title>{title}</title> <title>{title}</title>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<!-- OpenGraph -->
<meta property="og:image" content={ogImage} /> <meta property="og:image" content={ogImage} />
<meta property="og:image:width" content="1200" /> <meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" /> <meta property="og:image:height" content="630" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={ogImage} /> <meta name="twitter:image" content={ogImage} />
<meta name="twitter:description" content={description} /> <meta name="twitter:description" content={description} />
<!-- Basic meta description for search engines -->
<meta name="description" content={description} /> <meta name="description" content={description} />
<!-- Also used in OpenGraph for social media sharing -->
<meta property="og:description" content={description} /> <meta property="og:description" content={description} />
<link rel="icon" type="image/jpeg" href="/me.jpeg" /> <link rel="icon" type="image/jpeg" href="/me.jpeg" />
<link rel="sitemap" href="/sitemap-index.xml" /> <link rel="sitemap" href="/sitemap-index.xml" />
<ClientRouter /> <ClientRouter />
<script is:inline set:html={THEME_LOADER_SCRIPT} />
<script is:inline set:html={ANIMATION_LOADER_SCRIPT} />
</head> </head>
<body class="bg-background text-foreground"> <body class="bg-background text-foreground">
<Header client:load /> <Header client:load transparent />
<main transition:animate="fade"> <main transition:animate="fade">
<Background layout="index" client:only="react" transition:persist /> <Background layout="index" client:only="react" transition:persist />
<slot /> <slot />
</main> </main>
<Footer client:load transition:persist fixed=true /> <Footer client:load transition:persist fixed=true />
<ThemeSwitcher client:only="react" transition:persist />
<AnimationSwitcher client:only="react" transition:persist />
<script is:inline set:html={THEME_NAV_SCRIPT} />
<script is:inline set:html={ANIMATION_NAV_SCRIPT} />
</body> </body>
</html> </html>

View File

@@ -0,0 +1,70 @@
---
import "@/style/globals.css";
import { ClientRouter } from "astro:transitions";
import Header from "@/components/header";
import Footer from "@/components/footer";
import Background from "@/components/background";
import ThemeSwitcher from "@/components/theme-switcher";
import AnimationSwitcher from "@/components/animation-switcher";
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
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>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<meta property="og:image" content={ogImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={ogImage} />
<meta name="twitter:description" content={description} />
<meta name="description" content={description} />
<meta property="og:description" content={description} />
<link rel="icon" type="image/jpeg" href="/me.jpeg" />
<ClientRouter
defaultTransition={false}
handleFocus={false}
/>
<style>
::view-transition-new(:root) {
animation: none;
}
::view-transition-old(:root) {
animation: 90ms ease-out both fade-out;
}
@keyframes fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
</style>
<script is:inline set:html={THEME_LOADER_SCRIPT} />
<script is:inline set:html={ANIMATION_LOADER_SCRIPT} />
</head>
<body class="bg-background text-foreground min-h-screen flex flex-col">
<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 />
<div>
<slot />
</div>
<Background layout="content" position="left" client:only="react" transition:persist />
</div>
</main>
<ThemeSwitcher client:only="react" transition:persist />
<AnimationSwitcher client:only="react" transition:persist />
<script is:inline set:html={`window.scrollTo(0,0);` + THEME_NAV_SCRIPT} />
<script is:inline set:html={ANIMATION_NAV_SCRIPT} />
</body>
</html>

View File

@@ -0,0 +1,20 @@
import { ANIMATION_IDS, DEFAULT_ANIMATION_ID } from "@/lib/animations";
import type { AnimationId } from "@/lib/animations";
export function getStoredAnimationId(): AnimationId {
if (typeof window === "undefined") return DEFAULT_ANIMATION_ID;
const stored = localStorage.getItem("animation");
if (stored && (ANIMATION_IDS as readonly string[]).includes(stored)) {
return stored as AnimationId;
}
return DEFAULT_ANIMATION_ID;
}
export function saveAnimation(id: AnimationId): void {
localStorage.setItem("animation", id);
}
export function getNextAnimation(currentId: AnimationId): AnimationId {
const idx = ANIMATION_IDS.indexOf(currentId);
return ANIMATION_IDS[(idx + 1) % ANIMATION_IDS.length];
}

View File

@@ -0,0 +1,12 @@
export const ANIMATION_IDS = ["shuffle", "game-of-life", "lava-lamp", "confetti", "asciiquarium", "pipes"] as const;
export type AnimationId = (typeof ANIMATION_IDS)[number];
export const DEFAULT_ANIMATION_ID: AnimationId = "shuffle";
export const ANIMATION_LABELS: Record<AnimationId, string> = {
"shuffle": "shuffle",
"game-of-life": "life",
"lava-lamp": "lava",
"confetti": "confetti",
"asciiquarium": "aquarium",
"pipes": "pipes",
};

View File

@@ -0,0 +1,7 @@
import { ANIMATION_IDS, DEFAULT_ANIMATION_ID } from "@/lib/animations";
const VALID_IDS = JSON.stringify(ANIMATION_IDS);
export const ANIMATION_LOADER_SCRIPT = `(function(){var id=localStorage.getItem("animation");if(id&&${VALID_IDS}.indexOf(id)!==-1)document.documentElement.dataset.animation=id;else document.documentElement.dataset.animation="${DEFAULT_ANIMATION_ID}";})();`;
export const ANIMATION_NAV_SCRIPT = `document.addEventListener("astro:after-navigation",function(){var id=localStorage.getItem("animation");if(id&&${VALID_IDS}.indexOf(id)!==-1)document.documentElement.dataset.animation=id;else document.documentElement.dataset.animation="${DEFAULT_ANIMATION_ID}";});`;

View File

@@ -0,0 +1,37 @@
export interface AnimationEngine {
id: string;
name: string;
init(
width: number,
height: number,
palette: [number, number, number][],
bgColor: string
): void;
update(deltaTime: number): void;
render(
ctx: CanvasRenderingContext2D,
width: number,
height: number
): void;
handleResize(width: number, height: number): void;
handleMouseMove(x: number, y: number, isDown: boolean): void;
handleMouseDown(x: number, y: number): void;
handleMouseUp(): void;
handleMouseLeave(): void;
updatePalette(palette: [number, number, number][], bgColor: string): void;
beginExit(): void;
isExitComplete(): boolean;
cleanup(): void;
}

View File

@@ -41,7 +41,7 @@ export function getArticleSchema(post: CollectionEntry<"blog">) {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Article", "@type": "Article",
headline: post.data.title, headline: post.data.title,
url: `${import.meta.env.SITE}/blog/${post.slug}/`, url: `${import.meta.env.SITE}/blog/${post.id}/`,
description: post.data.excerpt, description: post.data.excerpt,
datePublished: post.data.date.toString(), datePublished: post.data.date.toString(),
publisher: { publisher: {

61
src/lib/themes/engine.ts Normal file
View File

@@ -0,0 +1,61 @@
import { THEMES, DEFAULT_THEME_ID } from "@/lib/themes";
import { CSS_PROPS } from "@/lib/themes/props";
import type { Theme } from "@/lib/themes/types";
export function getStoredThemeId(): string {
if (typeof window === "undefined") return DEFAULT_THEME_ID;
return localStorage.getItem("theme") || DEFAULT_THEME_ID;
}
export function saveTheme(id: string): void {
localStorage.setItem("theme", id);
}
export function getNextTheme(currentId: string): Theme {
const list = Object.values(THEMES);
const idx = list.findIndex((t) => t.id === currentId);
return list[(idx + 1) % list.length];
}
/** Sets CSS vars and notifies canvas, but does NOT persist to localStorage. */
export function previewTheme(id: string): void {
const theme = THEMES[id];
if (!theme) return;
const root = document.documentElement;
for (const [key, prop] of CSS_PROPS) {
root.style.setProperty(prop, theme.colors[key]);
}
document.dispatchEvent(new CustomEvent("theme-changed", { detail: { id } }));
}
export function applyTheme(id: string): void {
const theme = THEMES[id];
if (!theme) return;
// Set CSS vars on :root for immediate visual update
const root = document.documentElement;
for (const [key, prop] of CSS_PROPS) {
root.style.setProperty(prop, theme.colors[key]);
}
// Update <style id="theme-vars"> so Astro view transitions don't revert
let el = document.getElementById("theme-vars") as HTMLStyleElement | null;
if (!el) {
el = document.createElement("style");
el.id = "theme-vars";
document.head.appendChild(el);
}
let css = ":root{";
for (const [key, prop] of CSS_PROPS) {
css += `${prop}:${theme.colors[key]};`;
}
css += "}";
el.textContent = css;
saveTheme(id);
document.dispatchEvent(new CustomEvent("theme-changed", { detail: { id } }));
}

58
src/lib/themes/index.ts Normal file
View File

@@ -0,0 +1,58 @@
import type { Theme } from "./types";
export const DEFAULT_THEME_ID = "darkbox-retro";
function theme(
id: string,
name: string,
type: "dark" | "light",
colors: Theme["colors"],
palette: [number, number, number][]
): Theme {
return { id, name, type, colors, canvasPalette: palette };
}
// Three darkbox variants from darkbox.nvim
// Classic (vivid) → Retro (muted) → Dim (deep)
// Each variant's "bright" is the next level up's base.
export const THEMES: Record<string, Theme> = {
darkbox: theme("darkbox", "Darkbox Classic", "dark", {
background: "0 0 0",
foreground: "235 219 178",
red: "251 73 52", redBright: "255 110 85",
orange: "254 128 25", orangeBright: "255 165 65",
green: "184 187 38", greenBright: "210 215 70",
yellow: "250 189 47", yellowBright: "255 215 85",
blue: "131 165 152", blueBright: "165 195 180",
purple: "211 134 155", purpleBright: "235 165 180",
aqua: "142 192 124", aquaBright: "175 220 160",
surface: "60 56 54",
}, [[251,73,52],[184,187,38],[250,189,47],[131,165,152],[211,134,155],[142,192,124]]),
"darkbox-retro": theme("darkbox-retro", "Darkbox Retro", "dark", {
background: "0 0 0",
foreground: "189 174 147",
red: "204 36 29", redBright: "251 73 52",
orange: "214 93 14", orangeBright: "254 128 25",
green: "152 151 26", greenBright: "184 187 38",
yellow: "215 153 33", yellowBright: "250 189 47",
blue: "69 133 136", blueBright: "131 165 152",
purple: "177 98 134", purpleBright: "211 134 155",
aqua: "104 157 106", aquaBright: "142 192 124",
surface: "60 56 54",
}, [[204,36,29],[152,151,26],[215,153,33],[69,133,136],[177,98,134],[104,157,106]]),
"darkbox-dim": theme("darkbox-dim", "Darkbox Dim", "dark", {
background: "0 0 0",
foreground: "168 153 132",
red: "157 0 6", redBright: "204 36 29",
orange: "175 58 3", orangeBright: "214 93 14",
green: "121 116 14", greenBright: "152 151 26",
yellow: "181 118 20", yellowBright: "215 153 33",
blue: "7 102 120", blueBright: "69 133 136",
purple: "143 63 113", purpleBright: "177 98 134",
aqua: "66 123 88", aquaBright: "104 157 106",
surface: "60 56 54",
}, [[157,0,6],[121,116,14],[181,118,20],[7,102,120],[143,63,113],[66,123,88]]),
};

25
src/lib/themes/loader.ts Normal file
View File

@@ -0,0 +1,25 @@
/**
* Generates the inline <script> content for theme loading.
* Called at build time in Astro frontmatter.
* The script reads "theme" from localStorage, looks up colors, injects a <style> tag.
*/
import { THEMES } from "@/lib/themes";
import { CSS_PROPS } from "@/lib/themes/props";
// Pre-build a { prop: value } map for each theme at build time
const themeVars: Record<string, Record<string, string>> = {};
for (const [id, theme] of Object.entries(THEMES)) {
const vars: Record<string, string> = {};
for (const [key, prop] of CSS_PROPS) {
vars[prop] = theme.colors[key];
}
themeVars[id] = vars;
}
// Sets inline styles on <html> — highest specificity, beats any stylesheet
const APPLY = `var v=t[id];if(!v)return;var s=document.documentElement.style;for(var k in v)s.setProperty(k,v[k])`;
const LOOKUP = `var id=localStorage.getItem("theme");if(!id)return;var t=${JSON.stringify(themeVars)};`;
export const THEME_LOADER_SCRIPT = `(function(){${LOOKUP}${APPLY}})();`;
export const THEME_NAV_SCRIPT = `document.addEventListener("astro:after-navigation",function(){${LOOKUP}${APPLY}});`;

21
src/lib/themes/props.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { ThemeColors } from "@/lib/themes/types";
export const CSS_PROPS: [keyof ThemeColors, string][] = [
["background", "--color-background"],
["foreground", "--color-foreground"],
["red", "--color-red"],
["redBright", "--color-red-bright"],
["orange", "--color-orange"],
["orangeBright", "--color-orange-bright"],
["green", "--color-green"],
["greenBright", "--color-green-bright"],
["yellow", "--color-yellow"],
["yellowBright", "--color-yellow-bright"],
["blue", "--color-blue"],
["blueBright", "--color-blue-bright"],
["purple", "--color-purple"],
["purpleBright", "--color-purple-bright"],
["aqua", "--color-aqua"],
["aquaBright", "--color-aqua-bright"],
["surface", "--color-surface"],
];

27
src/lib/themes/types.ts Normal file
View File

@@ -0,0 +1,27 @@
export interface ThemeColors {
background: string;
foreground: string;
red: string;
redBright: string;
orange: string;
orangeBright: string;
green: string;
greenBright: string;
yellow: string;
yellowBright: string;
blue: string;
blueBright: string;
purple: string;
purpleBright: string;
aqua: string;
aquaBright: string;
surface: string;
}
export interface Theme {
id: string;
name: string;
type: "dark" | "light";
colors: ThemeColors;
canvasPalette: [number, number, number][];
}

View File

@@ -1,39 +0,0 @@
{
"name": "src",
"version": "2.1.1",
"private": true,
"scripts": {
"dev": "astro dev --host",
"build": "astro build",
"preview": "astro preview"
},
"devDependencies": {
"@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.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.1",
"reading-time": "^1.5.0",
"rehype-pretty-code": "^0.14.1",
"rehype-slug": "^6.0.0",
"schema-dts": "^1.1.5",
"typewriter-effect": "^2.21.0"
}
}

View File

@@ -4,7 +4,7 @@ import GlitchText from "@/components/404/glitched-text";
const title = "404 Not Found"; const title = "404 Not Found";
--- ---
<IndexLayout content={{ title: "404 | Timothy Pidashev" }}> <IndexLayout title="404 | Timothy Pidashev" description="Page not found">
<main class="min-h-screen flex flex-col items-center justify-center p-4 text-center"> <main class="min-h-screen flex flex-col items-center justify-center p-4 text-center">
<GlitchText client:only /> <GlitchText client:only />
<p class="text-xl text-orange mb-8">Whoops! This page doesn't exist :(</p> <p class="text-xl text-orange mb-8">Whoops! This page doesn't exist :(</p>

View File

@@ -17,23 +17,23 @@ import OutsideCoding from "@/components/about/outside-coding";
<Intro client:load /> <Intro client:load />
</section> </section>
<section class="flex items-center justify-center py-16"> <section class="min-h-[60vh] flex items-center justify-center py-16">
<AllTimeStats client:only="react" /> <AllTimeStats client:load />
</section> </section>
<section class="flex items-center justify-center py-16"> <section class="min-h-screen flex items-center justify-center py-16">
<DetailedStats client:only="react" /> <DetailedStats client:load />
</section> </section>
<section class="flex items-center justify-center py-16"> <section class="min-h-[80vh] flex items-center justify-center py-16">
<Timeline client:load /> <Timeline client:load />
</section> </section>
<section class="flex items-center justify-center py-16"> <section class="min-h-[80vh] flex items-center justify-center py-16">
<CurrentFocus client:load /> <CurrentFocus client:load />
</section> </section>
<section class="flex items-center justify-center py-16"> <section class="min-h-[50vh] flex items-center justify-center py-16">
<OutsideCoding client:load /> <OutsideCoding client:load />
</section> </section>
</div> </div>

View File

@@ -3,6 +3,13 @@ import type { APIRoute } from 'astro';
export const GET: APIRoute = async () => { export const GET: APIRoute = async () => {
const WAKATIME_API_KEY = import.meta.env.WAKATIME_API_KEY; const WAKATIME_API_KEY = import.meta.env.WAKATIME_API_KEY;
if (!WAKATIME_API_KEY) {
return new Response(
JSON.stringify({ error: "WAKATIME_API_KEY not configured" }),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
try { try {
const response = await fetch( const response = await fetch(
'https://wakatime.com/api/v1/users/current/summaries?range=last_6_months', { 'https://wakatime.com/api/v1/users/current/summaries?range=last_6_months', {

View File

@@ -0,0 +1,35 @@
import type { APIRoute } from "astro";
export const GET: APIRoute = async () => {
const WAKATIME_API_KEY = import.meta.env.WAKATIME_API_KEY;
if (!WAKATIME_API_KEY) {
return new Response(
JSON.stringify({ error: "WAKATIME_API_KEY not configured" }),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
try {
const response = await fetch(
"https://wakatime.com/api/v1/users/current/all_time_since_today",
{
headers: {
Authorization: `Basic ${Buffer.from(WAKATIME_API_KEY).toString("base64")}`,
},
}
);
const data = await response.json();
return new Response(JSON.stringify(data), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("WakaTime alltime API error:", error);
return new Response(
JSON.stringify({ error: "Failed to fetch stats" }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
};

View File

@@ -4,6 +4,13 @@ import type { APIRoute } from 'astro';
export const GET: APIRoute = async () => { export const GET: APIRoute = async () => {
const WAKATIME_API_KEY = import.meta.env.WAKATIME_API_KEY; const WAKATIME_API_KEY = import.meta.env.WAKATIME_API_KEY;
if (!WAKATIME_API_KEY) {
return new Response(
JSON.stringify({ error: "WAKATIME_API_KEY not configured" }),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
try { try {
const response = await fetch( const response = await fetch(
'https://wakatime.com/api/v1/users/current/stats/last_7_days?timeout=15', { 'https://wakatime.com/api/v1/users/current/stats/last_7_days?timeout=15', {

View File

@@ -1,5 +1,5 @@
--- ---
import { getCollection } from "astro:content"; import { getCollection, render } from "astro:content";
import { Image } from "astro:assets"; import { Image } from "astro:assets";
import ContentLayout from "@/layouts/content.astro"; import ContentLayout from "@/layouts/content.astro";
import { getArticleSchema } from "@/lib/structuredData"; import { getArticleSchema } from "@/lib/structuredData";
@@ -11,17 +11,17 @@ const { slug } = Astro.params;
// Fetch blog posts // Fetch blog posts
const posts = await getCollection("blog"); const posts = await getCollection("blog");
const post = posts.find(post => post.slug === slug); const post = posts.find(post => post.id === slug);
if (!post) { if (!post || (!import.meta.env.DEV && post.data.isDraft === true)) {
return new Response(null, { return new Response(null, {
status: 404, status: 404,
statusText: 'Not found' statusText: "Not found"
}); });
} }
// Dynamically render the content // Dynamically render the content
const { Content } = await post.render(); const { Content } = await render(post);
// Format the date // Format the date
const formattedDate = new Date(post.data.date).toLocaleDateString("en-US", { const formattedDate = new Date(post.data.date).toLocaleDateString("en-US", {
@@ -46,7 +46,7 @@ const breadcrumbsStructuredData = {
"@type": "ListItem", "@type": "ListItem",
position: 2, position: 2,
name: post.data.title, name: post.data.title,
item: `${import.meta.env.SITE}/blog/${post.slug}/`, item: `${import.meta.env.SITE}/blog/${post.id}/`,
}, },
], ],
}; };
@@ -63,9 +63,9 @@ const jsonLd = {
> >
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} /> <script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
<div class="relative max-w-8xl mx-auto"> <div class="relative max-w-8xl mx-auto">
<article class="prose prose-lg mx-auto max-w-4xl"> <article class="prose prose-invert prose-lg mx-auto max-w-4xl">
{post.data.image && ( {post.data.image && (
<div class="-mx-4 sm:mx-0 mb-8"> <div class="-mx-4 sm:mx-0 mb-4">
<Image <Image
src={post.data.image} src={post.data.image}
alt={post.data.title} alt={post.data.title}
@@ -76,9 +76,8 @@ const jsonLd = {
/> />
</div> </div>
)} )}
<h1 class="text-3xl pt-4">{post.data.title}</h1> <h1 class="text-3xl !mt-2 !mb-2">{post.data.title}</h1>
<p class="lg:text-2xl sm:text-lg">{post.data.description}</p> <p class="lg:text-2xl sm:text-lg !mt-0 !mb-3">{post.data.description}</p>
<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"> <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-orange">{post.data.author}</span>
<span class="text-foreground/50">•</span> <span class="text-foreground/50">•</span>
@@ -86,8 +85,7 @@ const jsonLd = {
{formattedDate} {formattedDate}
</time> </time>
</div> </div>
</div> <div class="flex flex-wrap gap-2 mt-2">
<div class="flex flex-wrap gap-2 mt-4 md:mt-6">
{post.data.tags.map((tag) => ( {post.data.tags.map((tag) => (
<span <span
class="text-xs md:text-base text-aqua hover:text-aqua-bright transition-colors duration-200" class="text-xs md:text-base text-aqua hover:text-aqua-bright transition-colors duration-200"
@@ -97,7 +95,7 @@ const jsonLd = {
</span> </span>
))} ))}
</div> </div>
<div class="prose prose-invert max-w-none"> <div class="prose prose-invert prose-lg max-w-none">
<Content /> <Content />
</div> </div>
</article> </article>

View File

@@ -5,7 +5,7 @@ import { BlogHeader } from "@/components/blog/header";
import { BlogPostList } from "@/components/blog/post-list"; import { BlogPostList } from "@/components/blog/post-list";
const posts = (await getCollection("blog", ({ data }) => { const posts = (await getCollection("blog", ({ data }) => {
return data.isDraft !== true; return import.meta.env.DEV || data.isDraft !== true;
})).sort((a, b) => { })).sort((a, b) => {
return b.data.date.valueOf() - a.data.date.valueOf() return b.data.date.valueOf() - a.data.date.valueOf()
}).map(post => ({ }).map(post => ({

View File

@@ -5,7 +5,7 @@ import ContentLayout from "@/layouts/content.astro";
import TagList from "@/components/blog/tag-list"; import TagList from "@/components/blog/tag-list";
const posts = (await getCollection("blog", ({ data }) => { const posts = (await getCollection("blog", ({ data }) => {
return data.isDraft !== true; return import.meta.env.DEV || data.isDraft !== true;
})).sort((a, b) => { })).sort((a, b) => {
return b.data.date.valueOf() - a.data.date.valueOf() return b.data.date.valueOf() - a.data.date.valueOf()
}).map(post => ({ }).map(post => ({

View File

@@ -1,20 +1,21 @@
--- ---
export const prerender = true; export const prerender = true;
import { getCollection } from "astro:content"; import { getCollection, render } from "astro:content";
import ContentLayout from "@/layouts/content.astro"; import ContentLayout from "@/layouts/content.astro";
import { Comments } from "@/components/blog/comments"; import { Comments } from "@/components/blog/comments";
export async function getStaticPaths() { export async function getStaticPaths() {
const projects = await getCollection("projects"); const projects = await getCollection("projects");
return projects.map(project => ({ return projects.map(project => ({
params: { slug: project.slug }, params: { slug: project.id },
props: { project }, props: { project },
})); }));
} }
const { project } = Astro.props; const { project } = Astro.props;
const { Content } = await project.render(); const { Content } = await render(project);
--- ---
<ContentLayout title={`${project.data.title} | Timothy Pidashev`}> <ContentLayout title={`${project.data.title} | Timothy Pidashev`}>
@@ -59,7 +60,7 @@ const { Content } = await project.render();
</div> </div>
</header> </header>
<div class="prose prose-invert max-w-none"> <div class="prose prose-invert prose-lg max-w-none">
<Content /> <Content />
</div> </div>
</article> </article>

Some files were not shown because too many files have changed in this diff Show More