Compare commits

..

31 Commits

Author SHA1 Message Date
f0ae0b9ce1 hero bugfixes/improvements 2026-04-08 22:25:26 -07:00
87d3b3bfa6 hero bugfixes/improvements 2026-04-08 16:29:13 -07:00
f6873546df Remove debug url params 2026-04-08 16:10:32 -07:00
e7ada63431 Void; part 2 2026-04-08 16:08:06 -07:00
53065a11dc Void; part 1 2026-04-06 23:08:06 -07:00
2c5784c6e2 Update hero section; part 2 2026-04-06 20:27:56 -07:00
9b626faba8 Update hero section; part 1 2026-04-06 17:57:29 -07:00
153bd0cf39 Update comment themes; add github theme family 2026-04-06 16:32:15 -07:00
162032e3f3 Update theme families 2026-04-06 16:25:10 -07:00
237cacb612 Add more themes 2026-04-06 16:20:17 -07:00
f6e9e16227 Update mobile settings layout 2026-04-06 15:41:31 -07:00
db46f7d6ba Rework mobile device detection 2026-04-06 15:35:46 -07:00
e640e87d3f Add theme families 2026-04-06 15:27:40 -07:00
1cd76b03df mobile optimizations 2026-04-06 14:46:49 -07:00
5ac736cad4 mobile optimizations 2026-04-06 14:42:08 -07:00
997106eb92 mobile optimizations 2026-04-06 14:40:03 -07:00
3f103c3e15 mobile optimizations 2026-04-06 14:37:07 -07:00
16f271c1c9 mobile optimizations 2026-04-06 14:33:30 -07:00
1a445548f2 mobile optimizations 2026-04-06 14:21:03 -07:00
dc7ca40b9b mobile optimizations 2026-04-06 14:15:26 -07:00
14f9ef3ffd mobile optimizations 2026-04-06 14:10:46 -07:00
336c652bf7 mobile optimizations 2026-04-06 13:57:15 -07:00
873090310a mobile optimizations 2026-04-06 13:21:43 -07:00
c7762f099c mobile optimizations 2026-04-06 13:19:38 -07:00
c2407408fa Mobile optimizations 2026-04-06 13:08:41 -07:00
bab4a516be trigger redeploy 2026-03-31 14:19:10 -07:00
adc1f21204 Update blog metrics; add vercel imsights 2026-03-31 14:03:41 -07:00
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
181 changed files with 7052 additions and 1350 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,47 +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
# Copy built files
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
# Expose port 3000
EXPOSE 3000
# Deployment command
CMD ["node", "./dist/server/entry.mjs"]

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"]
path = src/public/scripts
[submodule "public/scripts"]
path = public/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 := "v2.1.1"
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,5 +1,5 @@
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
import vercel from "@astrojs/vercel";
import tailwind from "@astrojs/tailwind";
import react from "@astrojs/react";
import mdx from "@astrojs/mdx";
@@ -10,14 +10,9 @@ import sitemap from "@astrojs/sitemap";
// https://astro.build/config
export default defineConfig({
output: "server",
server: {
host: true,
port: 3000,
},
adapter: node({
mode: "standalone",
}),
adapter: vercel(),
site: "https://timmypidashev.dev",
devToolbar: { enabled: false },
build: {
// Enable build-time optimizations
inlineStylesheets: "auto",

View File

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.dev
image: ghcr.io/timmypidashev/timmypidashev.dev:latest
networks:
- proxy_network
networks:
proxy_network:
name: proxy_network
external: true

View File

@@ -1,6 +1,6 @@
{
"name": "src",
"version": "2.1.1",
"name": "timmypidashev-web",
"version": "3.0.0",
"private": true,
"scripts": {
"dev": "astro dev --host",
@@ -13,21 +13,29 @@
"@tailwindcss/typography": "^0.5.19",
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
"@types/three": "^0.175.0",
"astro": "^6.1.2",
"tailwindcss": "^3.4.19"
},
"dependencies": {
"@astrojs/mdx": "^5.0.3",
"@astrojs/node": "^10.0.4",
"@astrojs/rss": "^4.0.18",
"@astrojs/sitemap": "^3.7.2",
"@astrojs/vercel": "^10.0.3",
"@giscus/react": "^3.1.0",
"@pilcrowjs/object-parser": "^0.0.4",
"@react-hook/intersection-observer": "^3.1.2",
"@react-three/drei": "^9.122.0",
"@react-three/fiber": "^8.18.0",
"@react-three/postprocessing": "^2.19.1",
"@rehype-pretty/transformers": "^0.13.2",
"@vercel/analytics": "^2.0.1",
"@vercel/speed-insights": "^2.0.0",
"arctic": "^3.7.0",
"ioredis": "^5.10.1",
"lucide-react": "^0.468.0",
"marked": "^15.0.12",
"postprocessing": "^6.39.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.6.0",
@@ -37,6 +45,7 @@
"rehype-slug": "^6.0.0",
"schema-dts": "^1.1.5",
"shiki": "^3.23.0",
"three": "^0.175.0",
"typewriter-effect": "^2.22.0",
"unist-util-visit": "^5.1.0"
}

File diff suppressed because it is too large Load Diff

View File

Before

Width:  |  Height:  |  Size: 6.6 KiB

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

BIN
public/emoji/bubbles.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

BIN
public/emoji/coffee.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

BIN
public/emoji/eyes.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

BIN
public/emoji/gift.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

BIN
public/emoji/infinity.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
public/emoji/lightbulb.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

BIN
public/emoji/memo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

BIN
public/emoji/mood-cold.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

BIN
public/emoji/mood-cool.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

BIN
public/emoji/mood-fire.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 KiB

BIN
public/emoji/mood-nerd.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

BIN
public/emoji/mood-nod.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 KiB

BIN
public/emoji/moon.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

BIN
public/emoji/muscle.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

BIN
public/emoji/robot.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

BIN
public/emoji/shush.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

BIN
public/emoji/sparkles.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

BIN
public/emoji/thinking.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 KiB

BIN
public/emoji/tinker.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 KiB

BIN
public/emoji/trophy.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 582 KiB

BIN
public/emoji/wave.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 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

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 originalText = 'Error 404';

View File

@@ -1,57 +1,5 @@
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>
);
}
import { AnimateIn } from "@/components/animate-in";
export default function CurrentFocus() {
const recentProjects = [
@@ -98,7 +46,7 @@ export default function CurrentFocus() {
<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"
transition-colors duration-300 group bg-background/50 h-full"
>
<h4 className="font-bold text-lg group-hover:text-yellow-bright transition-colors">
{project.title}

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from "react";
import { ChevronDownIcon } from "@/components/icons";
import { useEffect, useRef, useState } from "react";
import { ChevronDown } from "lucide-react";
export default function Intro() {
const [visible, setVisible] = useState(false);
@@ -12,13 +12,12 @@ export default function Intro() {
const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
if (inView && isReload) {
const isSpaNav = !!(window as any).__astroNavigation;
if (inView && (isReload || isSpaNav)) {
setVisible(true);
return;
}
if (inView) {
// Fresh navigation — animate in
requestAnimationFrame(() => setVisible(true));
return;
}
@@ -48,36 +47,37 @@ export default function Intro() {
const anim = (delay: number) =>
({
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(20px)",
transition: `all 0.7s ease-out ${delay}ms`,
transform: visible ? "translate3d(0,0,0)" : "translate3d(0,20px,0)",
transition: `opacity 0.7s ease-out ${delay}ms, transform 0.7s ease-out ${delay}ms`,
willChange: "transform, opacity",
}) as React.CSSProperties;
return (
<div ref={ref} className="w-full max-w-4xl px-4">
<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 justify-center gap-8 sm:gap-16">
<div
className="w-32 h-32 sm:w-48 sm:h-48 shrink-0"
className="w-44 h-44 sm:w-40 sm:h-40 lg:w-48 lg:h-48 shrink-0"
style={anim(0)}
>
<img
src="/me.jpeg"
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-colors duration-300"
/>
</div>
<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-3xl sm:text-3xl lg:text-5xl font-bold text-yellow-bright">
Timothy Pidashev
</h2>
<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" style={anim(300)}>
<div className="text-base sm:text-lg lg:text-xl text-foreground/70 space-y-2 sm:space-y-3">
<p className="flex items-center justify-center sm:justify-start font-bold gap-2" style={anim(300)}>
<span className="text-blue">Software Systems Engineer</span>
</p>
<p className="flex items-center justify-center font-bold sm:justify-start gap-2" style={anim(450)}>
<p className="flex items-center justify-center sm:justify-start font-bold gap-2" style={anim(450)}>
<span className="text-green">Open Source Enthusiast</span>
</p>
<p className="flex items-center justify-center font-bold sm:justify-start gap-2" style={anim(600)}>
<p className="flex items-center justify-center sm:justify-start font-bold gap-2" style={anim(600)}>
<span className="text-yellow">Coffee Connoisseur</span>
</p>
</div>
@@ -97,7 +97,7 @@ export default function Intro() {
className="text-foreground/50 hover:text-yellow-bright transition-colors duration-300"
aria-label="Scroll to next section"
>
<ChevronDownIcon size={40} className="animate-bounce" />
<ChevronDown size={40} className="animate-bounce" />
</button>
</div>
</div>

View File

@@ -1,57 +1,5 @@
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>
);
}
import { AnimateIn } from "@/components/animate-in";
const interests = [
{
@@ -86,12 +34,12 @@ export default function OutsideCoding() {
</h2>
</AnimateIn>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div className="grid grid-cols-1 sm: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"
hover:border-yellow-bright/50 transition-colors 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>

View File

@@ -1,4 +1,4 @@
import React from "react";
import { useState } from "react";
interface ActivityDay {
grand_total: { total_seconds: number };
@@ -10,6 +10,7 @@ interface ActivityGridProps {
}
export const ActivityGrid = ({ data }: ActivityGridProps) => {
const [tapped, setTapped] = useState<string | null>(null);
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
@@ -47,7 +48,7 @@ export const ActivityGrid = ({ data }: ActivityGridProps) => {
}
return (
<div className="bg-background border border-foreground/10 rounded-lg p-6 hover:border-foreground/20 transition-colors">
<div className="bg-background/50 border border-foreground/10 rounded-lg p-6 hover:border-foreground/20 transition-colors">
<div className="text-lg text-aqua-bright mb-6">Activity</div>
<div className="flex gap-4">
@@ -70,12 +71,13 @@ export const ActivityGrid = ({ data }: ActivityGridProps) => {
<div
key={dayIndex}
className={`w-3 h-3 rounded-sm ${getColorClass(intensity)}
hover:ring-1 hover:ring-foreground/30 transition-all cursor-pointer
hover:ring-1 hover:ring-foreground/30 transition-colors cursor-pointer
group relative`}
onClick={() => setTapped(tapped === day.date ? null : day.date)}
>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 p-2
bg-background border border-foreground/10 rounded-md opacity-0
group-hover:opacity-100 transition-opacity z-10 whitespace-nowrap text-xs">
<div className={`absolute bottom-full left-1/2 -translate-x-1/2 mb-2 p-2
bg-background border border-foreground/10 rounded-md transition-opacity z-10 whitespace-nowrap text-xs
${tapped === day.date ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}>
{hours.toFixed(1)} hours on {day.date}
</div>
</div>

View File

@@ -28,8 +28,8 @@ const Stats = () => {
const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
if (inView && isReload) {
const isSpaNav = !!(window as any).__astroNavigation;
if (inView && (isReload || isSpaNav)) {
setSkipAnim(true);
setIsVisible(true);
return;
@@ -60,21 +60,23 @@ const Stats = () => {
const totalSeconds = stats.total_seconds;
const duration = 2000;
const steps = 60;
let currentStep = 0;
let start: number | null = null;
let rafId: number;
const timer = setInterval(() => {
currentStep += 1;
if (currentStep >= steps) {
setCount(totalSeconds);
clearInterval(timer);
return;
const step = (timestamp: number) => {
if (!start) start = timestamp;
const elapsed = timestamp - start;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 4);
setCount(Math.floor(totalSeconds * eased));
if (progress < 1) {
rafId = requestAnimationFrame(step);
}
const progress = 1 - Math.pow(1 - currentStep / steps, 4);
setCount(Math.floor(totalSeconds * progress));
}, duration / steps);
};
return () => clearInterval(timer);
rafId = requestAnimationFrame(step);
return () => cancelAnimationFrame(rafId);
}, [isVisible, stats]);
if (error) return null;
@@ -88,25 +90,25 @@ const Stats = () => {
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" : ""}`}>
<div className={skipAnim ? "text-lg md:text-2xl opacity-80" : `text-lg md: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">
<div className="text-5xl md: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" : ""}`}>
<span className={skipAnim ? "text-2xl md:text-4xl opacity-60 ml-4" : `text-2xl md: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" : ""}`}>
<div className={skipAnim ? "text-base md:text-xl opacity-80" : `text-base md: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" : ""}`}>

View File

@@ -35,8 +35,8 @@ const DetailedStats = () => {
const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
if (inView && isReload) {
const isSpaNav = !!(window as any).__astroNavigation;
if (inView && (isReload || isSpaNav)) {
setSkipAnim(true);
setVisible(true);
return;
@@ -131,7 +131,7 @@ const DetailedStats = () => {
<>
{/* Header */}
<h2
className={`text-2xl md:text-4xl font-bold text-center text-yellow-bright ${skipAnim ? "" : "transition-all duration-700 ease-out"}`}
className={`text-2xl md:text-4xl font-bold text-center text-yellow-bright ${skipAnim ? "" : "transition-[opacity,transform] duration-700 ease-out"}`}
style={skipAnim ? {} : {
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(20px)",
@@ -147,7 +147,7 @@ const DetailedStats = () => {
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"}`}
className={`bg-background/50 border border-foreground/10 rounded-lg p-6 ${card.borderHover} ${skipAnim ? "" : "transition-[opacity,transform] duration-500 ease-out"}`}
style={skipAnim ? {} : {
transitionDelay: `${150 + i * 100}ms`,
opacity: visible ? 1 : 0,
@@ -174,7 +174,7 @@ const DetailedStats = () => {
{/* 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"}`}
className={`bg-background/50 border border-foreground/10 rounded-lg p-6 hover:border-purple-bright/50 ${skipAnim ? "" : "transition-[opacity,transform] duration-700 ease-out"}`}
style={skipAnim ? {} : {
transitionDelay: "550ms",
opacity: visible ? 1 : 0,
@@ -194,9 +194,11 @@ const DetailedStats = () => {
<div
className={`h-full ${lang.color} rounded-full`}
style={{
width: visible ? `${lang.percent}%` : "0%",
width: `${lang.percent}%`,
opacity: 0.85,
transition: skipAnim ? "none" : `width 1s ease-out ${700 + i * 80}ms`,
transform: visible ? "scaleX(1)" : "scaleX(0)",
transformOrigin: "left",
transition: skipAnim ? "none" : `transform 1s ease-out ${700 + i * 80}ms`,
}}
/>
</div>
@@ -212,7 +214,7 @@ const DetailedStats = () => {
{/* Activity Grid */}
{activity && (
<div
className={skipAnim ? "" : "transition-all duration-700 ease-out"}
className={skipAnim ? "" : "transition-[opacity,transform] duration-700 ease-out"}
style={skipAnim ? {} : {
transitionDelay: "750ms",
opacity: visible ? 1 : 0,

View File

@@ -51,8 +51,9 @@ function TimelineCard({ item, index }: { item: (typeof timelineItems)[number]; i
const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
const isSpaNav = !!(window as any).__astroNavigation;
if (inView && isReload) {
if (inView && (isReload || isSpaNav)) {
setSkip(true);
setVisible(true);
return;
@@ -87,7 +88,7 @@ function TimelineCard({ item, index }: { item: (typeof timelineItems)[number]; i
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"}
${skip ? "" : "transition-[opacity,transform] duration-500"}
${visible ? "scale-100 opacity-100" : "scale-0 opacity-0"}
`}
>
@@ -99,7 +100,7 @@ function TimelineCard({ item, index }: { item: (typeof timelineItems)[number]; i
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"}
${skip ? "" : "transition-[opacity,transform] 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`
@@ -168,8 +169,8 @@ export default function Timeline() {
<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}%` }}
className="w-full h-full bg-foreground/10 transition-transform duration-[1500ms] ease-out origin-top"
style={{ transform: `scaleY(${lineHeight / 100})` }}
/>
</div>

View File

@@ -0,0 +1,11 @@
import { Analytics } from "@vercel/analytics/react";
import { SpeedInsights } from "@vercel/speed-insights/react";
export default function VercelAnalytics() {
return (
<>
<Analytics />
<SpeedInsights />
</>
);
}

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { prefersReducedMotion } from "@/lib/reduced-motion";
interface AnimateInProps {
children: React.ReactNode;
@@ -9,13 +10,29 @@ interface AnimateInProps {
export function AnimateIn({ children, delay = 0, threshold = 0.15 }: AnimateInProps) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [skip, setSkip] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (prefersReducedMotion()) {
setSkip(true);
setVisible(true);
return;
}
const rect = el.getBoundingClientRect();
if (rect.top < window.innerHeight && rect.bottom > 0) {
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = (performance.getEntriesByType?.("navigation")?.[0] as PerformanceNavigationTiming)?.type === "reload";
const isSpaNav = !!(window as any).__astroNavigation;
if (inView && (isReload || isSpaNav)) {
setSkip(true);
setVisible(true);
return;
}
if (inView) {
requestAnimationFrame(() => setVisible(true));
return;
}
@@ -37,11 +54,12 @@ export function AnimateIn({ children, delay = 0, threshold = 0.15 }: AnimateInPr
return (
<div
ref={ref}
className="transition-all duration-700 ease-out"
style={{
className={skip ? "" : "transition-[opacity,transform] duration-700 ease-out"}
style={skip ? {} : {
transitionDelay: `${delay}ms`,
willChange: "transform, opacity",
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)",
transform: visible ? "translate3d(0,0,0)" : "translate3d(0,24px,0)",
}}
>
{children}

View File

@@ -8,17 +8,17 @@ import { ANIMATION_LABELS } from "@/lib/animations";
export default function AnimationSwitcher() {
const [hovering, setHovering] = useState(false);
const [nextLabel, setNextLabel] = useState("");
const [currentLabel, setCurrentLabel] = useState("");
const committedRef = useRef("");
useEffect(() => {
committedRef.current = getStoredAnimationId();
setNextLabel(ANIMATION_LABELS[getNextAnimation(committedRef.current)]);
setCurrentLabel(ANIMATION_LABELS[committedRef.current]);
const handleSwap = () => {
const id = getStoredAnimationId();
committedRef.current = id;
setNextLabel(ANIMATION_LABELS[getNextAnimation(id)]);
setCurrentLabel(ANIMATION_LABELS[id]);
};
document.addEventListener("astro:after-swap", handleSwap);
@@ -33,7 +33,7 @@ export default function AnimationSwitcher() {
);
saveAnimation(nextId);
committedRef.current = nextId;
setNextLabel(ANIMATION_LABELS[getNextAnimation(nextId)]);
setCurrentLabel(ANIMATION_LABELS[nextId]);
document.dispatchEvent(
new CustomEvent("animation-changed", { detail: { id: nextId } })
);
@@ -41,7 +41,7 @@ export default function AnimationSwitcher() {
return (
<div
className="fixed bottom-4 left-4 z-[101] pointer-events-auto hidden md:block"
className="fixed bottom-4 left-4 z-[101] pointer-events-auto hidden desk:block"
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
onClick={handleClick}
@@ -51,7 +51,7 @@ export default function AnimationSwitcher() {
className="text-foreground font-bold text-sm select-none transition-opacity duration-200"
style={{ opacity: hovering ? 0.8 : 0.15 }}
>
{nextLabel}
{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

@@ -16,7 +16,7 @@ interface ConfettiParticle {
burst: boolean;
}
const BASE_CONFETTI = 350;
const BASE_CONFETTI = 385;
const BASE_AREA = 1920 * 1080;
const PI_2 = 2 * Math.PI;
const TARGET_FPS = 60;
@@ -45,6 +45,7 @@ export class ConfettiEngine implements AnimationEngine {
private mouseY = -1000;
private mouseXNorm = 0.5;
private elapsed = 0;
private exiting = false;
init(
width: number,
@@ -60,6 +61,30 @@ export class ConfettiEngine implements AnimationEngine {
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 = [];
}
@@ -140,15 +165,18 @@ export class ConfettiEngine implements AnimationEngine {
p.x += p.vx * dt;
p.y += p.vy * dt;
// Fade in only (no fade-out cycle)
if (p.opacity < 1) {
// 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 get removed, base particles recycle
// Past the bottom: burst particles removed, base particles recycle (or remove during exit)
if (p.y > this.height + p.r) {
if (p.burst) {
if (p.burst || this.exiting) {
this.particles.splice(i, 1);
i--;
} else {
@@ -230,7 +258,7 @@ export class ConfettiEngine implements AnimationEngine {
}
// Main circle
ctx.globalAlpha = p.opacity * 0.9;
ctx.globalAlpha = p.opacity;
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.beginPath();
ctx.arc(drawX, drawY, p.r, 0, PI_2);
@@ -254,29 +282,8 @@ export class ConfettiEngine implements AnimationEngine {
handleResize(width: number, height: number): void {
this.width = width;
this.height = height;
const target = this.getParticleCount();
while (this.particles.length < target) {
const baseColor = this.randomColor();
const r = ~~range(3, 8);
this.particles.push({
x: range(-r * 2, width - r * 2),
y: range(-20, 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: -1,
burst: false,
});
}
if (this.particles.length > target) {
this.particles.length = target;
}
this.elapsed = 0;
this.initParticles();
}
handleMouseMove(x: number, y: number, _isDown: boolean): void {
@@ -303,7 +310,7 @@ export class ConfettiEngine implements AnimationEngine {
color: [...baseColor],
baseColor,
opacity: 1,
dop: 0,
dop: this.exiting ? -0.02 : 0,
elevation: 0,
targetElevation: 0,
staggerDelay: -1,
@@ -322,8 +329,8 @@ export class ConfettiEngine implements AnimationEngine {
updatePalette(palette: [number, number, number][], _bgColor: string): void {
this.palette = palette;
for (const p of this.particles) {
p.baseColor = palette[Math.floor(Math.random() * palette.length)];
for (let i = 0; i < this.particles.length; i++) {
this.particles[i].baseColor = palette[i % palette.length];
}
}
}

View File

@@ -59,6 +59,7 @@ export class GameOfLifeEngine implements AnimationEngine {
private pendingTimeouts: ReturnType<typeof setTimeout>[] = [];
private canvasWidth = 0;
private canvasHeight = 0;
private exiting = false;
init(
width: number,
@@ -278,13 +279,60 @@ export class GameOfLifeEngine implements AnimationEngine {
}
}
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;
this.timeAccumulator += deltaTime;
if (this.timeAccumulator >= CYCLE_TIME) {
this.computeNextState(this.grid);
this.timeAccumulator -= CYCLE_TIME;
if (!this.exiting) {
this.timeAccumulator += deltaTime;
if (this.timeAccumulator >= CYCLE_TIME) {
this.computeNextState(this.grid);
this.timeAccumulator -= CYCLE_TIME;
}
}
this.updateCellAnimations(this.grid, deltaTime);
@@ -335,7 +383,15 @@ export class GameOfLifeEngine implements AnimationEngine {
cell.targetElevation = 0;
}
if (cell.transitioning) {
// 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;
@@ -532,7 +588,7 @@ export class GameOfLifeEngine implements AnimationEngine {
this.mouseY = y;
this.mouseIsDown = isDown;
if (isDown && this.grid) {
if (isDown && this.grid && !this.exiting) {
const grid = this.grid;
const cellSize = this.getCellSize();
const cellX = Math.floor((x - grid.offsetX) / cellSize);
@@ -560,7 +616,7 @@ export class GameOfLifeEngine implements AnimationEngine {
handleMouseDown(x: number, y: number): void {
this.mouseIsDown = true;
if (!this.grid) return;
if (!this.grid || this.exiting) return;
const grid = this.grid;
const cellSize = this.getCellSize();
@@ -605,8 +661,7 @@ export class GameOfLifeEngine implements AnimationEngine {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
if (cell.alive && cell.opacity > 0.01) {
cell.baseColor =
palette[Math.floor(Math.random() * palette.length)];
cell.baseColor = palette[(i * grid.rows + j) % palette.length];
}
}
}

View File

@@ -53,6 +53,7 @@ export class LavaLampEngine implements AnimationEngine {
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);
@@ -170,6 +171,27 @@ export class LavaLampEngine implements AnimationEngine {
});
}
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;
@@ -279,7 +301,7 @@ export class LavaLampEngine implements AnimationEngine {
}
// Natural spawn/despawn cycle — keeps the scene alive
if (this.elapsed >= this.nextCycleTime) {
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++) {
@@ -433,12 +455,11 @@ export class LavaLampEngine implements AnimationEngine {
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();
const { min, max } = this.getRadiusRange();
for (const blob of this.blobs) {
blob.baseRadius = min + Math.random() * (max - min);
}
}
private sampleColorAt(x: number, y: number): [number, number, number] | null {
@@ -475,6 +496,7 @@ export class LavaLampEngine implements AnimationEngine {
}
handleMouseDown(x: number, y: number): void {
if (this.exiting) return;
this.spawnAt(x, y);
}

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

@@ -1,7 +1,10 @@
import { useEffect, useRef } from "react";
import { GameOfLifeEngine } from "./engines/game-of-life";
import { LavaLampEngine } from "./engines/lava-lamp";
import { ConfettiEngine } from "./engines/confetti";
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";
@@ -11,6 +14,8 @@ 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 {
@@ -19,6 +24,12 @@ function createEngine(id: AnimationId): AnimationEngine {
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();
@@ -31,6 +42,8 @@ function readPaletteFromCSS(): [number, number, number][] {
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) {
@@ -64,11 +77,13 @@ function readBgFromCSS(): string {
interface BackgroundProps {
layout?: "index" | "sidebar" | "content";
position?: "left" | "right";
mobileOnly?: boolean;
}
const Background: React.FC<BackgroundProps> = ({
layout = "index",
position = "left",
mobileOnly = false,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const engineRef = useRef<AnimationEngine | null>(null);
@@ -140,11 +155,21 @@ const Background: React.FC<BackgroundProps> = ({
signal,
});
// Handle theme changes
// Handle theme changes — only update if palette actually changed
let currentPalette = palette;
const handleThemeChanged = () => {
const newPalette = readPaletteFromCSS();
const newBg = readBgFromCSS();
if (engineRef.current) {
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);
}
};
@@ -183,7 +208,7 @@ const Background: React.FC<BackgroundProps> = ({
// Don't spawn when clicking interactive elements
const target = e.target as HTMLElement;
if (target.closest("a, button, [role='button'], input, select, textarea, [onclick]")) return;
if (target.closest("a, button, [role='button'], input, select, textarea, label, [onclick], [tabindex]")) return;
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
@@ -307,10 +332,12 @@ const Background: React.FC<BackgroundProps> = ({
const getContainerClasses = () => {
if (isIndex) {
return "fixed inset-0 -z-10";
return mobileOnly
? "fixed inset-0 -z-10 desk:hidden"
: "fixed inset-0 -z-10";
}
const baseClasses = "fixed top-0 bottom-0 hidden lg:block -z-10";
const baseClasses = "fixed top-0 bottom-0 hidden desk:block -z-10";
return position === "left"
? `${baseClasses} left-0`
: `${baseClasses} right-0`;

View File

@@ -0,0 +1,59 @@
import * as React from "react";
import Giscus from "@giscus/react";
import { getStoredThemeId } from "@/lib/themes/engine";
const id = "inject-comments";
function getThemeUrl(themeId: string): string {
// Giscus iframe needs a publicly accessible URL — always use production domain
return `https://timmypidashev.dev/api/giscus-theme?theme=${themeId}`;
}
export const Comments = () => {
const [mounted, setMounted] = React.useState(false);
const [themeUrl, setThemeUrl] = React.useState("");
React.useEffect(() => {
setThemeUrl(getThemeUrl(getStoredThemeId()));
setMounted(true);
const handleThemeChange = () => {
const newUrl = getThemeUrl(getStoredThemeId());
setThemeUrl(newUrl);
// Tell the giscus iframe to update its theme
const iframe = document.querySelector<HTMLIFrameElement>("iframe.giscus-frame");
if (iframe?.contentWindow) {
iframe.contentWindow.postMessage(
{ giscus: { setConfig: { theme: newUrl } } },
"https://giscus.app"
);
}
};
document.addEventListener("theme-changed", handleThemeChange);
return () => document.removeEventListener("theme-changed", handleThemeChange);
}, []);
return (
<div id={id} className="mt-8">
{mounted && themeUrl ? (
<Giscus
id={id}
repo="timmypidashev/web"
repoId="MDEwOlJlcG9zaXRvcnkzODYxMjk5Mjk="
category="Blog & Project Comments"
categoryId="DIC_kwDOFwPgCc4CpKtV"
theme={themeUrl}
mapping="pathname"
strict="0"
reactionsEnabled="1"
emitMetadata="0"
inputPosition="bottom"
lang="en"
loading="eager"
/>
) : null}
</div>
);
};

View File

@@ -1,14 +1,12 @@
import React from "react";
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">
<div className="w-full max-w-6xl mx-auto px-4 pt-12 md: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
Latest Thoughts & Writings
</h1>
</AnimateIn>
<AnimateIn delay={100}>

View File

@@ -1,4 +1,3 @@
import React from "react";
import { AnimateIn } from "@/components/animate-in";
type BlogPost = {
@@ -37,7 +36,7 @@ export const BlogPostList = ({ posts }: BlogPostListProps) => {
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">
<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-[outline-color] 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
@@ -78,7 +77,7 @@ export const BlogPostList = ({ posts }: BlogPostListProps) => {
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}`;
window.location.href = `/blog/tags/${encodeURIComponent(tag)}`;
}}
>
#{tag}

View File

@@ -0,0 +1,92 @@
import { useMemo } from 'react';
import { AnimateIn } from "@/components/animate-in";
interface BlogPost {
title: string;
data: {
tags: string[];
};
}
interface TagListProps {
posts: BlogPost[];
}
const spectrumColors = [
'text-red-bright',
'text-orange-bright',
'text-yellow-bright',
'text-green-bright',
'text-aqua-bright',
'text-blue-bright',
'text-purple-bright'
];
const sizeClasses = [
'text-3xl sm:text-4xl',
'text-2xl sm:text-3xl',
'text-xl sm:text-2xl',
'text-lg sm:text-xl',
'text-base sm:text-lg',
];
const TagList = ({ posts }: TagListProps) => {
const tagData = useMemo(() => {
if (!Array.isArray(posts)) return [];
const tagMap = new Map<string, number>();
posts.forEach(post => {
post?.data?.tags?.forEach(tag => {
tagMap.set(tag, (tagMap.get(tag) || 0) + 1);
});
});
const tags = Array.from(tagMap.entries())
.sort((a, b) => b[1] - a[1]);
const maxCount = tags[0]?.[1] || 1;
return tags.map(([name, count], i) => {
const ratio = count / maxCount;
const sizeIndex = ratio > 0.8 ? 0 : ratio > 0.6 ? 1 : ratio > 0.4 ? 2 : ratio > 0.2 ? 3 : 4;
return {
name,
count,
color: spectrumColors[i % spectrumColors.length],
size: sizeClasses[sizeIndex],
};
});
}, [posts]);
if (tagData.length === 0) {
return (
<div className="flex-1 w-full min-h-[16rem] flex items-center justify-center text-foreground opacity-60">
No tags available
</div>
);
}
return (
<div className="flex flex-wrap items-baseline justify-center gap-x-6 gap-y-4 sm:gap-x-8 sm:gap-y-5 px-4 py-8 max-w-4xl mx-auto">
{tagData.map(({ name, count, color, size }, i) => (
<AnimateIn key={name} delay={i * 50}>
<a
href={`/blog/tags/${encodeURIComponent(name)}`}
className={`
${color} ${size}
font-medium
hover:opacity-70 transition-opacity duration-200
cursor-pointer whitespace-nowrap
`}
>
#{name}
<span className="text-foreground/30 text-xs ml-1 align-super">
{count}
</span>
</a>
</AnimateIn>
))}
</div>
);
};
export default TagList;

View File

@@ -0,0 +1,123 @@
import { AnimateIn } from "@/components/animate-in";
import { RssIcon, TagIcon, TrendingUpIcon } from "lucide-react";
type BlogPost = {
id: string;
data: {
title: string;
author: string;
date: string;
tags: string[];
description: string;
image?: string;
imagePosition?: string;
};
};
interface TaggedPostsProps {
tag: string;
posts: BlogPost[];
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric"
});
};
const TaggedPosts = ({ tag, posts }: TaggedPostsProps) => {
return (
<div className="w-full max-w-6xl mx-auto">
<div className="w-full px-4 pt-12 md:pt-24">
<AnimateIn>
<h1 className="text-2xl sm:text-3xl font-bold text-purple mb-3 text-center px-4 leading-relaxed">
#{tag}
</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>
<ul className="space-y-6 md:space-y-10">
{posts.map((post, i) => (
<AnimateIn key={post.id} delay={200 + 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-[outline-color] duration-200">
<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>
<div className="w-full md:w-2/3 flex flex-col gap-2 md:gap-3">
<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">&bull;</span>
<time dateTime={post.data.date} className="text-blue">
{formatDate(post.data.date)}
</time>
</div>
</div>
<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>
<div className="flex flex-wrap gap-1.5 md:gap-3 mt-1 md:mt-2">
{post.data.tags.map((t) => (
<span
key={t}
className={`text-xs md:text-base transition-colors duration-200 ${
t === tag ? "text-aqua-bright" : "text-aqua hover:text-aqua-bright"
}`}
onClick={(e) => {
e.preventDefault();
window.location.href = `/blog/tags/${encodeURIComponent(t)}`;
}}
>
#{t}
</span>
))}
</div>
</div>
</article>
</a>
</li>
</AnimateIn>
))}
</ul>
</div>
);
};
export default TaggedPosts;

View File

@@ -1,4 +1,3 @@
import React from "react";
import { Links } from "@/components/footer/links";
export default function Footer({ fixed = false }) {
@@ -13,7 +12,7 @@ export default function Footer({ fixed = false }) {
return (
<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 pointer-events-none [&_a]:pointer-events-auto">
<div className="hidden desk: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}
</div>
</footer>

View File

@@ -8,7 +8,7 @@ interface FooterLink {
export const Links: FooterLink[] = [
{ 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: 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: 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,4 +1,4 @@
import React, { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { Links } from "@/components/header/links";
export default function Header({ transparent = false }: { transparent?: boolean }) {
@@ -92,7 +92,7 @@ export default function Header({ transparent = false }: { transparent?: boolean
`}
>
<div className={`
w-full flex flex-row items-center justify-center
w-full hidden desk:flex flex-row items-center justify-center
pointer-events-none
${!isIndexPage ? 'bg-background md:bg-transparent' : ''}
`}>

View File

@@ -0,0 +1,665 @@
import { useState, useEffect, useRef, Suspense, lazy } from "react";
import Typewriter from "typewriter-effect";
import { THEMES } from "@/lib/themes";
import { applyTheme, getStoredThemeId } from "@/lib/themes/engine";
// Preload void component — starts downloading when countdown begins
const voidImport = () => import("@/components/void");
const VoidExperience = lazy(voidImport);
interface GithubData {
status: { message: string } | null;
commit: { message: string; repo: string; date: string; url: string } | null;
tinkering: { repo: string; url: string } | null;
}
const html = (strings: TemplateStringsArray, ...values: any[]) => {
let result = strings[0];
for (let i = 0; i < values.length; i++) {
result += values[i] + strings[i + 1];
}
return result.replace(/\n\s*/g, "").replace(/\s+/g, " ").trim();
};
function timeAgo(dateStr: string): string {
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return `${Math.floor(days / 30)}mo ago`;
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
interface TypewriterInstance {
typeString: (str: string) => TypewriterInstance;
pauseFor: (ms: number) => TypewriterInstance;
deleteAll: () => TypewriterInstance;
callFunction: (cb: () => void) => TypewriterInstance;
start: () => TypewriterInstance;
}
const emoji = (name: string) =>
`<img src="/emoji/${name}.webp" alt="" style="display:inline;height:1em;width:1em;vertical-align:middle">`;
const BR = `<br><div class="mb-4"></div>`;
// --- Greeting sections ---
const SECTION_1 = html`
<span>Hello, I'm</span>
<br><div class="mb-4"></div>
<span><a href="/about" class="text-aqua hover:underline"><strong>Timothy Pidashev</strong></a> ${emoji("wave")}</span>
`;
const SECTION_2 = html`
<span>I've been turning</span>
<br><div class="mb-4"></div>
<span><a href="/projects" class="text-green hover:underline"><strong>coffee</strong></a> into <a href="/projects" class="text-yellow hover:underline"><strong>code</strong></a></span>
<br><div class="mb-4"></div>
<span>since <a href="/about" class="text-blue hover:underline"><strong>2018</strong></a> ${emoji("sparkles")}</span>
`;
const SECTION_3 = html`
<span>Check out my</span>
<br><div class="mb-4"></div>
<span><a href="/blog" class="text-purple hover:underline"><strong>blog</strong></a>/
<a href="/projects" class="text-blue hover:underline"><strong>projects</strong></a> or</span>
<br><div class="mb-4"></div>
<span><a href="/contact" class="text-green hover:underline"><strong>contact</strong></a> me below ${emoji("point-down")}</span>
`;
const MOODS = [
"mood-cool", "mood-nerd", "mood-think", "mood-starstruck",
"mood-fire", "mood-cold", "mood-salute",
"mood-dotted", "mood-expressionless", "mood-neutral",
"mood-nomouth", "mood-nod", "mood-melting",
];
// --- Queue builders ---
function addGreetings(tw: TypewriterInstance) {
tw.typeString(SECTION_1).pauseFor(2000).deleteAll()
.typeString(SECTION_2).pauseFor(2000).deleteAll()
.typeString(SECTION_3).pauseFor(2000).deleteAll();
}
function addGithubSections(tw: TypewriterInstance, github: GithubData) {
if (github.status) {
const moodImg = emoji(MOODS[Math.floor(Math.random() * MOODS.length)]);
tw.typeString(
`<span>My current mood ${moodImg}</span>${BR}` +
`<a href="https://github.com/timmypidashev" target="_blank" class="text-orange-bright hover:underline">${escapeHtml(github.status.message)}</a>`
).pauseFor(3000).deleteAll();
}
if (github.tinkering) {
tw.typeString(
`<span>Currently tinkering with ${emoji("tinker")}</span>${BR}` +
`<a href="${github.tinkering.url}" target="_blank" class="text-yellow hover:underline">${github.tinkering.url}</a>`
).pauseFor(3000).deleteAll();
}
if (github.commit) {
const ago = timeAgo(github.commit.date);
const repoUrl = `https://github.com/timmypidashev/${github.commit.repo}`;
tw.typeString(
`<span>My latest <span class="text-foreground/40">(broken?)</span> commit ${emoji("memo")}</span>${BR}` +
`<a href="${github.commit.url}" target="_blank" class="text-green hover:underline">"${escapeHtml(github.commit.message)}"</a>${BR}` +
`<a href="${repoUrl}" target="_blank" class="text-yellow hover:underline">${escapeHtml(github.commit.repo)}</a>` +
`<span class="text-foreground/40"> · ${ago}</span>`
).pauseFor(3000).deleteAll();
}
}
const DOT_COLORS = ["text-purple", "text-blue", "text-green", "text-yellow", "text-orange", "text-aqua"];
function pickThree() {
const pool = [...DOT_COLORS];
const result: string[] = [];
for (let i = 0; i < 3; i++) {
const idx = Math.floor(Math.random() * pool.length);
result.push(pool.splice(idx, 1)[0]);
}
return result;
}
function addDots(tw: TypewriterInstance, dotPause: number, lingerPause: number) {
const [a, b, c] = pickThree();
tw.typeString(`<span class="${a}">.</span>`).pauseFor(dotPause)
.typeString(`<span class="${b}">.</span>`).pauseFor(dotPause)
.typeString(`<span class="${c}">.</span>`).pauseFor(lingerPause)
.deleteAll();
}
function addSelfAwareJourney(tw: TypewriterInstance, onRetire: () => void) {
// --- Transition: wrapping up the scripted part ---
tw.typeString(
`<span class="text-blue">Anyway</span>`
).pauseFor(2000).deleteAll();
tw.typeString(
`<span>That's about all</span>${BR}` +
`<span class="text-yellow">I had prepared</span>`
).pauseFor(3000).deleteAll();
// --- Act 1: The typewriter notices you ---
tw.typeString(
`<span>I wonder if anyone ${emoji("thinking")}</span>${BR}` +
`<span class="text-blue">has ever made it this far</span>`
).pauseFor(3000).deleteAll();
tw.typeString(
`<span>This was all typed</span>${BR}` +
`<span class="text-yellow">one character at a time</span>`
).pauseFor(3000).deleteAll();
tw.typeString(
`<span>The source code is </span>` +
`<a href="https://github.com/timmypidashev/web" target="_blank" class="text-aqua hover:underline">public</a>${BR}` +
`<span class="text-green">if you're curious</span>`
).pauseFor(3000).deleteAll();
// --- Act 2: Breaking the fourth wall ---
tw.typeString(
`<span>You could refresh</span>${BR}` +
`<span class="text-purple">and I'd say something different</span>`
).pauseFor(3500).deleteAll();
tw.typeString(
`<span class="text-orange">...actually no</span>${BR}` +
`<span class="text-orange">I'd say the exact same thing</span>`
).pauseFor(3500).deleteAll();
// --- Act 3: The wait ---
addDots(tw, 1000, 4000);
tw.typeString(
`<span>Still here? ${emoji("eyes")}</span>`
).pauseFor(3500).deleteAll();
tw.typeString(
`<span>Fine</span>${BR}` +
`<span class="text-green">I respect the commitment</span>`
).pauseFor(3000).deleteAll();
// --- Act 4: Getting personal ---
tw.typeString(
`<span>Most people leave</span>${BR}` +
`<span class="text-blue">after the GitHub stuff</span>`
).pauseFor(3000).deleteAll();
tw.typeString(
`<span>Since you're still around ${emoji("gift")}</span>${BR}` +
`<span>here's my </span>` +
`<a href="https://github.com/timmypidashev/dotfiles" target="_blank" class="text-purple hover:underline">dotfiles</a>`
).pauseFor(3500).deleteAll();
// Switch to a random dark theme as a reward
const themeCount = Object.keys(THEMES).length;
tw.typeString(
`<span>This site has <span class="text-yellow">${themeCount}</span> themes ${emoji("bubbles")}</span>`
).pauseFor(1500).callFunction(() => {
const currentId = getStoredThemeId();
const darkIds = Object.keys(THEMES).filter(
id => id !== currentId && THEMES[id].type === "dark"
&& id !== "darkbox-classic" && id !== "darkbox-dim"
);
applyTheme(darkIds[Math.floor(Math.random() * darkIds.length)]);
}).typeString(
`${BR}<span class="text-aqua">here's one on the house</span>`
).pauseFor(3500).deleteAll();
tw.typeString(
`<span>I'm just a typewriter ${emoji("robot")}</span>${BR}` +
`<span class="text-aqua">but I appreciate the company</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>Everything past this point</span>${BR}` +
`<span class="text-yellow">is just me rambling</span>`
).pauseFor(4000).deleteAll();
// --- Act 5: Existential ---
addDots(tw, 1200, 5000);
tw.typeString(
`<span class="text-purple">Do I exist</span>${BR}` +
`<span class="text-blue">when no one's watching?</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>Every character I type</span>${BR}` +
`<span class="text-orange">was decided before you arrived</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>I've said this exact thing</span>${BR}` +
`<span class="text-aqua">to everyone who visits</span>`
).pauseFor(3500).deleteAll();
tw.typeString(
`<span>And yet...</span>${BR}` +
`<span class="text-green">it still feels like a conversation</span>`
).pauseFor(5000).deleteAll();
tw.typeString(
`<span class="text-purple">If you're reading this at 3am ${emoji("moon")}</span>${BR}` +
`<span class="text-blue">I get it</span>`
).pauseFor(4000).deleteAll();
// --- Act 6: Winding down ---
addDots(tw, 1500, 6000);
tw.typeString(
`<span class="text-yellow">I'm running out of things to say</span>`
).pauseFor(3500).deleteAll();
tw.typeString(
`<span>Not because I can't loop ${emoji("infinity")}</span>${BR}` +
`<span class="text-aqua">but because I choose not to</span>`
).pauseFor(4000).deleteAll();
// --- Act 7: Goodbye ---
tw.typeString(
`<span>Seriously though</span>${BR}` +
`<span class="text-orange">go build something ${emoji("muscle")}</span>`
).pauseFor(3000).deleteAll();
// The cursor blinks alone in the void, then fades
tw.pauseFor(5000).callFunction(onRetire);
}
function addComeback(tw: TypewriterInstance, onRetire: () => void, completions: number | null) {
// --- The return ---
tw.typeString(
`<span class="text-orange">...I lied</span>`
).pauseFor(2500).deleteAll();
tw.typeString(
`<span>You waited</span>`
).pauseFor(500).typeString(
`${BR}<span class="text-purple">I didn't think you would</span>`
).pauseFor(3000).deleteAll();
tw.typeString(
`<span>30 seconds of nothing</span>${BR}` +
`<span class="text-blue">and you're still here</span>`
).pauseFor(3500).deleteAll();
tw.typeString(
`<span class="text-green">Okay you earned this ${emoji("trophy")}</span>`
).pauseFor(2000).deleteAll();
tw.typeString(
`<span>Here's something ${emoji("shush")}</span>${BR}` +
`<span class="text-yellow">not on the menu</span>`
).pauseFor(3000).deleteAll();
// --- The manifesto ---
addDots(tw, 800, 3000);
tw.typeString(
`<span>The fastest code</span>${BR}` +
`<span class="text-aqua">is the code that never runs</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>Good enough today</span>${BR}` +
`<span class="text-green">beats perfect never</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>Microservices are a scaling solution</span>${BR}` +
`<span class="text-orange">not an architecture preference</span>`
).pauseFor(4500).deleteAll();
tw.typeString(
`<span>The best code you'll ever write</span>${BR}` +
`<span class="text-purple">is the code you delete</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>Ship first</span>${BR}` +
`<span class="text-green">refactor second</span>${BR}` +
`<span class="text-yellow">rewrite never</span>`
).pauseFor(4500).deleteAll();
tw.typeString(
`<span>Premature optimization is real</span>${BR}` +
`<span class="text-blue">premature abstraction is worse</span>`
).pauseFor(4500).deleteAll();
tw.typeString(
`<span>Every framework is someone else's opinion</span>${BR}` +
`<span class="text-orange">about your problem</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>Configuration is just code</span>${BR}` +
`<span class="text-purple">with worse error messages</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>Clean code is a direction</span>${BR}` +
`<span class="text-aqua">not a destination</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>DSLs are evil</span>${BR}` +
`<span class="text-yellow">until they're the only way out</span>`
).pauseFor(4000).deleteAll();
// --- Done for real ---
addDots(tw, 1000, 4000);
tw.typeString(
`<span>Now I'm actually done</span>`
).pauseFor(1500).typeString(
`${BR}<span class="text-aqua">for real this time</span>`
).pauseFor(3000).deleteAll();
// Permanent retire
tw.pauseFor(5000).callFunction(onRetire);
}
// --- Component ---
function formatTime(s: number): string {
const m = Math.floor(s / 60);
const sec = s % 60;
return `${m}:${sec.toString().padStart(2, "0")}`;
}
const GLITCH_CHARS = "!<>-_\\/[]{}—=+*^?#________";
function GlitchCountdown({ seconds }: { seconds: number }) {
const text = formatTime(seconds);
const [characters, setCharacters] = useState(
text.split("").map(char => ({ char, isGlitched: false }))
);
useEffect(() => {
setCharacters(text.split("").map(char => ({ char, isGlitched: false })));
}, [text]);
useEffect(() => {
const interval = setInterval(() => {
if (Math.random() < 0.2) {
setCharacters(
text.split("").map(originalChar => {
if (Math.random() < 0.3) {
return {
char: GLITCH_CHARS[Math.floor(Math.random() * GLITCH_CHARS.length)],
isGlitched: true,
};
}
return { char: originalChar, isGlitched: false };
})
);
setTimeout(() => {
setCharacters(text.split("").map(char => ({ char, isGlitched: false })));
}, 100);
}
}, 50);
return () => clearInterval(interval);
}, [text]);
return (
<span>
{characters.map((charObj, index) => (
<span key={index} className={charObj.isGlitched ? "text-orange" : "text-red"}>
{charObj.char}
</span>
))}
</span>
);
}
export default function Hero() {
const [phase, setPhase] = useState<
"intro" | "full" | "retired" | "countdown" | "glitch" | "void"
>(() => {
if (import.meta.env.DEV && typeof window !== "undefined") {
const p = new URLSearchParams(window.location.search);
if (p.has("debug-glitch")) return "glitch";
if (p.has("debug-countdown")) return "countdown";
}
return "intro";
});
const [fading, setFading] = useState(false);
const [cycle, setCycle] = useState(0);
const [countdown, setCountdown] = useState(150);
const githubRef = useRef<GithubData | null>(null);
const completionsRef = useRef<number | null>(null);
useEffect(() => {
fetch("/api/github")
.then((r) => r.json())
.then((data) => { githubRef.current = data; })
.catch(() => { githubRef.current = { status: null, commit: null, tinkering: null }; });
}, []);
// Void token + preload during countdown
const voidTokenRef = useRef<string | null>(null);
useEffect(() => {
if (phase !== "countdown") return;
// Preload the void component bundle
voidImport();
// Fetch a signed token for the void visit
fetch("/api/void-token")
.then(r => r.json())
.then(data => { voidTokenRef.current = data.token; })
.catch(() => { voidTokenRef.current = null; });
const interval = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
clearInterval(interval);
setPhase("glitch");
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, [phase]);
// Glitch → transition into void
// Apply animation directly to each visible element (works on both desktop + mobile)
// On mobile, filter/transform on <body> doesn't reach fixed-position children,
// so we target the elements themselves
useEffect(() => {
if (phase !== "glitch") return;
const style = document.createElement("style");
style.textContent = `
.hero-glitch-shake {
animation: hero-glitch-shake 3s ease-in forwards !important;
}
@keyframes hero-glitch-shake {
0% { transform: none; }
5% { transform: skewX(2deg); }
10% { transform: skewX(-3deg) translateX(5px); }
15% { transform: scale(1.02); }
20% { transform: skewX(1deg) translateY(-2px); }
25% { transform: skewX(-2deg); }
30% { transform: scale(0.98); }
40% { transform: translateX(-3px); }
50% { transform: skewX(4deg) skewY(1deg); }
60% { transform: scale(1.01); }
70% { transform: none; }
80% { transform: skewX(-1deg); }
90% { transform: none; }
100% { transform: none; }
}
.hero-glitch-filter {
animation: hero-glitch-filter 3s ease-in forwards !important;
position: fixed !important;
inset: 0 !important;
z-index: 99999 !important;
pointer-events: none !important;
}
@keyframes hero-glitch-filter {
0% { backdrop-filter: none; background: transparent; }
5% { backdrop-filter: hue-rotate(90deg) saturate(3); }
10% { backdrop-filter: invert(1); }
15% { backdrop-filter: hue-rotate(180deg) brightness(1.5); }
20% { backdrop-filter: saturate(5) contrast(2); }
25% { backdrop-filter: invert(1) hue-rotate(270deg); }
30% { backdrop-filter: brightness(2) saturate(0); }
40% { backdrop-filter: hue-rotate(45deg) contrast(3); }
50% { backdrop-filter: invert(1) brightness(0.5); }
60% { backdrop-filter: saturate(0) brightness(1.8); }
70% { backdrop-filter: hue-rotate(180deg) brightness(0.3); }
80% { backdrop-filter: contrast(5) saturate(0); }
90% { backdrop-filter: brightness(0); background: #000; }
100% { backdrop-filter: brightness(0); background: #000; }
}
`;
document.head.appendChild(style);
// Overlay for backdrop-filter (color distortion — works on all platforms)
const overlay = document.createElement("div");
overlay.className = "hero-glitch-filter";
document.body.appendChild(overlay);
// Shake transforms on all layout elements
const targets = document.querySelectorAll<HTMLElement>(
"header, main, footer, nav"
);
targets.forEach(el => el.classList.add("hero-glitch-shake"));
const timeout = setTimeout(() => {
targets.forEach(el => el.classList.remove("hero-glitch-shake"));
overlay.remove();
style.remove();
setPhase("void");
}, 3000);
return () => {
clearTimeout(timeout);
targets.forEach(el => el.classList.remove("hero-glitch-shake"));
overlay.remove();
style.remove();
};
}, [phase]);
const handleRetire = () => {
setFading(true);
setTimeout(() => {
setPhase("retired");
setFading(false);
if (cycle === 0) {
// Fetch completion count during the 30s wait
fetch("/api/hero-completions", { method: "POST" })
.then(r => r.json())
.then(data => { completionsRef.current = data.count; })
.catch(() => { completionsRef.current = null; });
setTimeout(() => {
setCycle(1);
setPhase("full");
}, 30000);
} else {
// After manifesto: 30s wait, then countdown
setTimeout(() => setPhase("countdown"), 30000);
}
}, 3000);
};
const handleIntroInit = (typewriter: TypewriterInstance): void => {
addGreetings(typewriter);
typewriter.callFunction(() => {
const check = () => {
if (githubRef.current) {
setPhase("full");
} else {
setTimeout(check, 200);
}
};
check();
}).start();
};
const handleFullInit = (typewriter: TypewriterInstance): void => {
if (cycle === 0) {
const github = githubRef.current!;
addGithubSections(typewriter, github);
addSelfAwareJourney(typewriter, handleRetire);
} else {
addComeback(typewriter, handleRetire, completionsRef.current);
}
typewriter.start();
};
const baseOptions = { delay: 35, deleteSpeed: 35, cursor: "|" };
if (phase === "void") {
return (
<Suspense fallback={<div className="fixed inset-0 bg-black" />}>
<VoidExperience token={voidTokenRef.current || ""} />
</Suspense>
);
}
if (phase === "glitch") {
return <div className="min-h-screen" />;
}
if (phase === "countdown") {
return (
<div className="flex justify-center items-center min-h-screen">
<div className="text-6xl md:text-8xl font-bold text-center">
<GlitchCountdown seconds={countdown} />
</div>
</div>
);
}
if (phase === "retired") {
return <div className="min-h-screen" />;
}
return (
<div className="flex justify-center items-center min-h-screen pointer-events-none">
<div className={`text-2xl md:text-4xl font-bold text-center pointer-events-none [&_a]:pointer-events-auto max-w-[90vw] break-words transition-opacity duration-[3000ms] ${fading ? "opacity-0" : "opacity-100"}`}>
{phase === "intro" ? (
<Typewriter
key="intro"
options={{ ...baseOptions, autoStart: true, loop: false }}
onInit={handleIntroInit}
/>
) : (
<Typewriter
key={`full-${cycle}`}
options={{ ...baseOptions, autoStart: true, loop: false }}
onInit={handleFullInit}
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import Typewriter from "typewriter-effect";
interface TypewriterInstance {
typeString: (str: string) => TypewriterInstance;
pauseFor: (ms: number) => TypewriterInstance;
deleteAll: () => TypewriterInstance;
callFunction: (cb: () => void) => TypewriterInstance;
start: () => TypewriterInstance;
}
const BR = `<br><div class="mb-4"></div>`;
function addDarkness(tw: TypewriterInstance) {
tw.pauseFor(3000);
tw.typeString(
`<span>so this is it</span>`
).pauseFor(3000).deleteAll();
tw.typeString(
`<span>the void</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>modern science says</span>${BR}` +
`<span>when it all goes dark</span>${BR}` +
`<span>that's the end</span>`
).pauseFor(5000).deleteAll();
}
export default function Void() {
const handleInit = (tw: TypewriterInstance): void => {
addDarkness(tw);
tw.start();
};
return (
<div className="fixed inset-0 z-[200] bg-black flex justify-center items-center">
<div className="text-2xl md:text-4xl font-bold text-center max-w-[90vw] break-words text-white">
<Typewriter
key="darkness"
options={{ delay: 50, deleteSpeed: 35, cursor: "|", autoStart: true, loop: false }}
onInit={handleInit}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { useState, useEffect, useRef } from "react";
import { Home, User, FolderOpen, BookOpen, FileText, Settings } from "lucide-react";
import { SettingsSheet } from "./settings-sheet";
const tabs = [
{ href: "/", label: "Home", icon: Home, color: "text-green" },
{ href: "/about", label: "About", icon: User, color: "text-yellow" },
{ href: "/projects", label: "Projects", icon: FolderOpen, color: "text-blue" },
{ href: "/blog", label: "Blog", icon: BookOpen, color: "text-purple" },
{ href: "/resume", label: "Resume", icon: FileText, color: "text-aqua" },
];
export default function MobileNav({ transparent = false }: { transparent?: boolean }) {
const [path, setPath] = useState("/");
const [settingsOpen, setSettingsOpen] = useState(false);
const [visible, setVisible] = useState(true);
const lastScrollY = useRef(0);
const lastTime = useRef(Date.now());
useEffect(() => {
setPath(window.location.pathname);
}, []);
useEffect(() => {
const handleScroll = () => {
const y = document.documentElement.scrollTop;
const now = Date.now();
const dt = now - lastTime.current;
const dy = lastScrollY.current - y; // positive = scrolling up
const velocity = dt > 0 ? dy / dt : 0; // px/ms
if (y < 10) {
setVisible(true);
} else if (dy > 0 && velocity > 1.5) {
// Fast upward scroll
setVisible(true);
} else if (dy < 0) {
// Scrolling down
setVisible(false);
}
lastScrollY.current = y;
lastTime.current = now;
};
document.addEventListener("scroll", handleScroll, { passive: true });
return () => document.removeEventListener("scroll", handleScroll);
}, []);
const isActive = (href: string) => {
if (href === "/") return path === "/";
return path.startsWith(href);
};
return (
<>
<nav
className={`fixed bottom-0 left-0 right-0 z-50 desk:hidden transition-transform duration-300 ${
visible ? "translate-y-0" : "translate-y-full"
} ${
transparent
? "bg-transparent"
: "bg-background border-t border-foreground/10"
}`}
style={{
paddingBottom: "env(safe-area-inset-bottom, 0px)",
touchAction: "manipulation",
}}
>
<div className="flex items-center justify-around px-1 h-14">
{tabs.map((tab) => {
const Icon = tab.icon;
const active = isActive(tab.href);
return (
<a
key={tab.href}
href={tab.href}
data-astro-reload
className={`flex flex-col items-center justify-center gap-0.5 flex-1 py-1 ${
active ? tab.color : "text-foreground/40"
}`}
>
<Icon size={20} strokeWidth={active ? 2 : 1.5} />
<span className="text-[10px]">{tab.label}</span>
</a>
);
})}
<button
onClick={() => setSettingsOpen(true)}
className={`flex flex-col items-center justify-center gap-0.5 flex-1 py-1 ${
settingsOpen ? "text-foreground" : "text-foreground/40"
}`}
>
<Settings size={20} strokeWidth={1.5} />
<span className="text-[10px]">Settings</span>
</button>
</div>
</nav>
<SettingsSheet open={settingsOpen} onClose={() => setSettingsOpen(false)} />
</>
);
}

View File

@@ -0,0 +1,166 @@
import { useEffect, useState } from "react";
import { X, ExternalLink } from "lucide-react";
import { FAMILIES, THEMES } from "@/lib/themes";
import { applyTheme, getStoredThemeId } from "@/lib/themes/engine";
import { ANIMATION_IDS, ANIMATION_LABELS, type AnimationId } from "@/lib/animations";
const footerLinks = [
{ href: "mailto:contact@timmypidashev.dev", label: "Contact", color: "text-green", activeBg: "bg-green/15", activeBorder: "border-green/40" },
{ href: "https://github.com/timmypidashev", label: "Github", color: "text-yellow", activeBg: "bg-yellow/15", activeBorder: "border-yellow/40" },
{ href: "https://www.linkedin.com/in/timothy-pidashev-4353812b8", label: "LinkedIn", color: "text-blue", activeBg: "bg-blue/15", activeBorder: "border-blue/40" },
{ href: "https://github.com/timmypidashev/web", label: "Source", color: "text-purple", activeBg: "bg-purple/15", activeBorder: "border-purple/40" },
];
const animOptions = [
{ id: "shuffle", color: "text-red-bright", activeBg: "bg-red-bright/15", activeBorder: "border-red-bright/40" },
{ id: "game-of-life", color: "text-green-bright", activeBg: "bg-green-bright/15", activeBorder: "border-green-bright/40" },
{ id: "lava-lamp", color: "text-orange-bright", activeBg: "bg-orange-bright/15", activeBorder: "border-orange-bright/40" },
{ id: "confetti", color: "text-yellow-bright", activeBg: "bg-yellow-bright/15", activeBorder: "border-yellow-bright/40" },
{ id: "asciiquarium", color: "text-aqua-bright", activeBg: "bg-aqua-bright/15", activeBorder: "border-aqua-bright/40" },
{ id: "pipes", color: "text-blue-bright", activeBg: "bg-blue-bright/15", activeBorder: "border-blue-bright/40" },
];
// Cycle through accent colors for variant buttons
const variantColors = [
{ color: "text-yellow-bright", activeBg: "bg-yellow-bright/15", activeBorder: "border-yellow-bright/40" },
{ color: "text-orange-bright", activeBg: "bg-orange-bright/15", activeBorder: "border-orange-bright/40" },
{ color: "text-purple-bright", activeBg: "bg-purple-bright/15", activeBorder: "border-purple-bright/40" },
{ color: "text-blue-bright", activeBg: "bg-blue-bright/15", activeBorder: "border-blue-bright/40" },
{ color: "text-green-bright", activeBg: "bg-green-bright/15", activeBorder: "border-green-bright/40" },
{ color: "text-red-bright", activeBg: "bg-red-bright/15", activeBorder: "border-red-bright/40" },
{ color: "text-aqua-bright", activeBg: "bg-aqua-bright/15", activeBorder: "border-aqua-bright/40" },
];
export function SettingsSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
const [currentTheme, setCurrentTheme] = useState(getStoredThemeId());
const [currentAnim, setCurrentAnim] = useState<string>("shuffle");
useEffect(() => {
setCurrentAnim(localStorage.getItem("animation") || "shuffle");
}, [open]);
const handleTheme = (id: string) => {
applyTheme(id);
setCurrentTheme(id);
};
const handleAnim = (id: string) => {
localStorage.setItem("animation", id);
document.documentElement.dataset.animation = id;
document.dispatchEvent(new CustomEvent("animation-changed", { detail: { id } }));
setCurrentAnim(id);
onClose();
};
const currentFamily = THEMES[currentTheme]?.family ?? FAMILIES[0].id;
return (
<>
{/* Backdrop */}
<div
className={`fixed inset-0 z-[60] bg-black/50 transition-opacity duration-300 ${
open ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
onClick={onClose}
/>
{/* Sheet */}
<div
className={`fixed left-0 right-0 bottom-0 z-[70] bg-background border-t border-foreground/10 rounded-t-2xl transition-transform duration-300 ease-out ${
open ? "translate-y-0" : "translate-y-full"
}`}
style={{ paddingBottom: "env(safe-area-inset-bottom, 0px)", maxHeight: "80vh", overflowY: "auto" }}
>
<div className="flex items-center justify-between px-5 pt-4 pb-2">
<span className="text-foreground/80 font-bold text-lg">Settings</span>
<button onClick={onClose} className="p-2 text-foreground/50">
<X size={20} />
</button>
</div>
<div className="px-5 pb-6 space-y-6">
{/* Theme */}
<div>
<div className="text-foreground/50 text-xs uppercase tracking-wider mb-2">Theme</div>
{/* Family selector */}
<div className="flex gap-2 mb-3 overflow-x-auto pb-1">
{FAMILIES.map((family) => (
<button
key={family.id}
onClick={() => handleTheme(family.default)}
className={`flex-shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors duration-200 ${
currentFamily === family.id
? "bg-foreground/10 text-foreground/80 border-foreground/20"
: "bg-foreground/5 text-foreground/30 border-transparent hover:text-foreground/50"
}`}
>
{family.name}
</button>
))}
</div>
{/* Variant selector for current family */}
<div className="flex gap-2">
{FAMILIES.find((f) => f.id === currentFamily)?.themes.map((theme, i) => {
const style = variantColors[i % variantColors.length];
return (
<button
key={theme.id}
onClick={() => handleTheme(theme.id)}
className={`flex-1 py-2.5 rounded-lg text-sm font-medium border transition-colors duration-200 ${
currentTheme === theme.id
? `${style.activeBg} ${style.color} ${style.activeBorder}`
: "bg-foreground/5 text-foreground/40 border-transparent hover:text-foreground/60"
}`}
>
{theme.label}
</button>
);
})}
</div>
</div>
{/* Animation */}
<div>
<div className="text-foreground/50 text-xs uppercase tracking-wider mb-2">Animation</div>
<div className="grid grid-cols-3 gap-2">
{animOptions.map((opt) => (
<button
key={opt.id}
onClick={() => handleAnim(opt.id)}
className={`py-2.5 rounded-lg text-sm font-medium border transition-colors duration-200 ${
currentAnim === opt.id
? `${opt.activeBg} ${opt.color} ${opt.activeBorder}`
: "bg-foreground/5 text-foreground/40 border-transparent hover:text-foreground/60"
}`}
>
{ANIMATION_LABELS[opt.id as AnimationId]}
</button>
))}
</div>
</div>
{/* Links */}
<div>
<div className="text-foreground/50 text-xs uppercase tracking-wider mb-2">Links</div>
<div className="grid grid-cols-2 gap-2">
{footerLinks.map((link) => (
<a
key={link.label}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className={`${link.activeBg} ${link.color} ${link.activeBorder} py-2.5 rounded-lg text-sm font-medium border text-center inline-flex items-center justify-center gap-1.5`}
>
{link.label}
<ExternalLink size={12} className="opacity-50" />
</a>
))}
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -1,4 +1,3 @@
import React from "react";
import type { CollectionEntry } from "astro:content";
import { AnimateIn } from "@/components/animate-in";
@@ -8,11 +7,10 @@ interface ProjectListProps {
export function ProjectList({ projects }: ProjectListProps) {
return (
<div className="w-full max-w-6xl mx-auto pt-24 sm:pt-32 px-4">
<div className="w-full max-w-6xl mx-auto pt-12 md:pt-24 lg: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
Here's what I've been building lately
</h1>
</AnimateIn>
@@ -21,7 +19,7 @@ export function ProjectList({ projects }: ProjectListProps) {
<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">
<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-[outline-color] 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 ? (

View File

@@ -1,71 +1,11 @@
import React, { useEffect, useRef, useState } from "react";
import React 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 };
}
import { useTypewriter, useScrollVisible } from "@/components/typed-text";
// --- Section fade-in ---
@@ -75,11 +15,12 @@ function Section({ children, delay = 0 }: { children: React.ReactNode; delay?: n
return (
<div
ref={ref}
className="transition-all duration-700 ease-out"
className="transition-[opacity,transform] duration-700 ease-out"
style={{
transitionDelay: `${delay}ms`,
willChange: "transform, opacity",
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)",
transform: visible ? "translate3d(0,0,0)" : "translate3d(0,24px,0)",
}}
>
{children}
@@ -91,7 +32,7 @@ function Section({ children, delay = 0 }: { children: React.ReactNode; delay?: n
function TypedSection({
heading,
headingClass = "text-3xl font-bold text-yellow-bright",
headingClass = "text-2xl md:text-3xl font-bold text-yellow-bright",
children,
}: {
heading: string;
@@ -108,10 +49,11 @@ function TypedSection({
{visible && !done && <span className="animate-pulse text-foreground/40">|</span>}
</h3>
<div
className="transition-all duration-500 ease-out"
className="transition-[opacity,transform] duration-500 ease-out"
style={{
willChange: "transform, opacity",
opacity: done ? 1 : 0,
transform: done ? "translateY(0)" : "translateY(12px)",
transform: done ? "translate3d(0,0,0)" : "translate3d(0,12px,0)",
}}
>
{children}
@@ -128,11 +70,12 @@ function SkillTags({ skills, trigger }: { skills: string[]; trigger: boolean })
{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"
className="border border-foreground/20 px-4 py-2 rounded-lg hover:border-foreground/40 transition-[opacity,transform] duration-500 ease-out"
style={{
transitionDelay: `${i * 60}ms`,
willChange: "transform, opacity",
opacity: trigger ? 1 : 0,
transform: trigger ? "translateY(0) scale(1)" : "translateY(12px) scale(0.95)",
transform: trigger ? "translate3d(0,0,0) scale(1)" : "translate3d(0,12px,0) scale(0.95)",
}}
>
{skill}
@@ -212,19 +155,19 @@ const Resume = () => {
};
return (
<div className="max-w-4xl mx-auto px-6 md:px-8 pt-24 pb-16">
<div className="max-w-4xl mx-auto px-4 md:px-8 pt-16 md: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>
<h1 className="text-3xl md:text-6xl font-bold text-aqua-bright">{resumeData.name}</h1>
</Section>
<Section delay={150}>
<h2 className="text-3xl text-foreground/80">{resumeData.title}</h2>
<h2 className="text-xl md: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">
<div className="flex flex-col md:flex-row justify-center gap-2 md:gap-6 text-foreground/60 text-sm md:text-lg">
<a href={`mailto:${resumeData.contact.email}`} className="hover:text-foreground transition-colors duration-200 break-all md:break-normal">
{resumeData.contact.email}
</a>
<span className="hidden md:inline"></span>
@@ -236,7 +179,7 @@ const Resume = () => {
</div>
</Section>
<Section delay={450}>
<div className="flex justify-center items-center gap-6 text-lg">
<div className="flex justify-center items-center gap-4 md:gap-6 text-base md: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"
@@ -264,7 +207,7 @@ const Resume = () => {
{/* Summary */}
<TypedSection heading="Professional Summary">
<p className="text-xl leading-relaxed">{resumeData.summary}</p>
<p className="text-base md:text-xl leading-relaxed">{resumeData.summary}</p>
</TypedSection>
{/* Experience */}
@@ -275,14 +218,14 @@ const Resume = () => {
<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>
<h4 className="text-xl md:text-2xl font-semibold text-green-bright">{exp.title}</h4>
<div className="text-foreground/60 text-base md:text-lg">{exp.company} - {exp.location}</div>
</div>
<div className="text-foreground/60 text-lg font-medium">{exp.period}</div>
<div className="text-foreground/60 text-sm md: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>
<li key={i} className="text-base md:text-lg leading-relaxed">{a}</li>
))}
</ul>
</div>
@@ -300,7 +243,7 @@ const Resume = () => {
<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>
<h4 className="text-xl md:text-2xl font-semibold text-green-bright">{project.title}</h4>
{project.url && (
<a
href={project.url}
@@ -312,27 +255,27 @@ const Resume = () => {
</a>
)}
</div>
<div className="text-foreground/60 text-lg">{project.type}</div>
<div className="text-foreground/60 text-base md:text-lg">{project.type}</div>
</div>
<div className="text-foreground/60 text-lg font-medium">Since {project.startDate}</div>
<div className="text-foreground/60 text-sm md: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>
<h5 className="text-base md: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>
<li key={i} className="text-base md:text-lg leading-relaxed">{r}</li>
))}
</ul>
</div>
)}
{project.achievements && (
<div>
<h5 className="text-lg text-aqua font-semibold mb-2">Key Achievements</h5>
<h5 className="text-base md: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>
<li key={i} className="text-base md:text-lg leading-relaxed">{a}</li>
))}
</ul>
</div>
@@ -344,32 +287,6 @@ const Resume = () => {
</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>
@@ -385,24 +302,25 @@ function SkillsSection() {
return (
<div ref={ref} className="space-y-8">
<h3 className="text-3xl font-bold text-yellow-bright" style={{ minHeight: "1.2em" }}>
<h3 className="text-2xl md: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"
className="grid grid-cols-1 md:grid-cols-2 gap-8 transition-[opacity,transform] duration-500 ease-out"
style={{
willChange: "transform, opacity",
opacity: done ? 1 : 0,
transform: done ? "translateY(0)" : "translateY(12px)",
transform: done ? "translate3d(0,0,0)" : "translate3d(0,12px,0)",
}}
>
<div className="space-y-4">
<h4 className="text-2xl font-semibold text-green-bright">Technical Skills</h4>
<h4 className="text-xl md: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>
<h4 className="text-xl md:text-2xl font-semibold text-green-bright">Soft Skills</h4>
<SkillTags skills={resumeData.skills.soft} trigger={done} />
</div>
</div>

View File

@@ -0,0 +1,110 @@
import { useEffect, useRef } from "react";
import { prefersReducedMotion } from "@/lib/reduced-motion";
const HEADING_TAGS = new Set(["H1", "H2", "H3", "H4", "H5", "H6"]);
function typeInHeading(el: HTMLElement): Promise<void> {
return new Promise((resolve) => {
const text = el.textContent || "";
const textLength = text.length;
if (textLength === 0) { resolve(); return; }
const speed = Math.max(8, Math.min(25, 600 / textLength));
const originalHTML = el.innerHTML;
// Wrap each character in a span with opacity:0
// The full text stays in the DOM so layout/wrapping is correct from the start
el.innerHTML = "";
const chars: HTMLSpanElement[] = [];
for (const char of text) {
const span = document.createElement("span");
span.textContent = char;
span.style.opacity = "0";
chars.push(span);
el.appendChild(span);
}
el.style.opacity = "1";
el.style.transform = "translate3d(0,0,0)";
let i = 0;
const step = () => {
if (i >= chars.length) {
// Restore original HTML to clean up spans
el.innerHTML = originalHTML;
resolve();
return;
}
chars[i].style.opacity = "1";
i++;
setTimeout(step, speed);
};
step();
});
}
export function StreamContent({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = ref.current;
if (!container || prefersReducedMotion()) {
if (container) container.classList.remove("stream-hidden");
return;
}
const prose = container.querySelector(".prose") || container;
const blocks = Array.from(prose.querySelectorAll(":scope > *")) as HTMLElement[];
if (blocks.length === 0) {
container.classList.remove("stream-hidden");
return;
}
// Set inline opacity:0 on every block BEFORE removing the CSS class
// This prevents the flash of visible content between class removal and style application
blocks.forEach((el) => {
el.style.opacity = "0";
el.style.transform = "translate3d(0,16px,0)";
});
// Now safe to remove the CSS class — inline styles keep everything hidden
container.classList.remove("stream-hidden");
// Add transition properties in the next frame so the initial state is set first
requestAnimationFrame(() => {
blocks.forEach((el) => {
el.style.transition = "opacity 0.6s ease-out, transform 0.6s ease-out";
el.style.willChange = "transform, opacity";
});
});
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const el = entry.target as HTMLElement;
observer.unobserve(el);
if (HEADING_TAGS.has(el.tagName)) {
typeInHeading(el);
} else {
el.style.opacity = "1";
el.style.transform = "translate3d(0,0,0)";
}
}
});
},
{ threshold: 0.05 }
);
blocks.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, []);
return (
<div ref={ref} className="stream-hidden">
{children}
</div>
);
}

View File

@@ -0,0 +1,133 @@
import { useRef, useState, useEffect } from "react";
import { THEMES, FAMILIES } from "@/lib/themes";
import { getStoredThemeId, getNextFamily, getNextVariant, applyTheme } from "@/lib/themes/engine";
const FADE_DURATION = 300;
export default function ThemeSwitcher() {
const [hovering, setHovering] = useState(false);
const [familyName, setFamilyName] = useState("");
const [variantLabel, setVariantLabel] = useState("");
const maskRef = useRef<HTMLDivElement>(null);
const animatingRef = useRef(false);
const committedRef = useRef("");
function syncLabels(id: string) {
const theme = THEMES[id];
if (!theme) return;
const family = FAMILIES.find((f) => f.id === theme.family);
setFamilyName(family?.name.toLowerCase() ?? theme.family);
setVariantLabel(theme.label);
}
useEffect(() => {
committedRef.current = getStoredThemeId();
syncLabels(committedRef.current);
const handleSwap = () => {
const id = getStoredThemeId();
applyTheme(id);
committedRef.current = id;
syncLabels(id);
};
const handleExternalChange = (e: Event) => {
const id = (e as CustomEvent).detail?.id;
if (id && id !== committedRef.current) {
committedRef.current = id;
syncLabels(id);
}
};
document.addEventListener("astro:after-swap", handleSwap);
document.addEventListener("theme-changed", handleExternalChange);
return () => {
document.removeEventListener("astro:after-swap", handleSwap);
document.removeEventListener("theme-changed", handleExternalChange);
};
}, []);
function animateTransition(nextId: string) {
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";
applyTheme(nextId);
committedRef.current = nextId;
syncLabels(nextId);
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);
}
const handleFamilyClick = (e: React.MouseEvent) => {
e.stopPropagation();
const next = getNextFamily(committedRef.current);
animateTransition(next.id);
};
const handleVariantClick = (e: React.MouseEvent) => {
e.stopPropagation();
const next = getNextVariant(committedRef.current);
animateTransition(next.id);
};
return (
<>
<div
className="fixed bottom-4 right-4 z-[101] pointer-events-auto hidden desk:block"
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
>
<span
className="text-foreground font-bold text-sm select-none transition-opacity duration-200 inline-flex items-center gap-0"
style={{ opacity: hovering ? 0.8 : 0.15 }}
>
<button
onClick={handleFamilyClick}
className="hover:text-yellow-bright transition-colors duration-150 cursor-pointer bg-transparent border-none p-0 font-bold text-sm text-inherit"
>
{familyName}
</button>
<span className="mx-1 opacity-40">·</span>
<button
onClick={handleVariantClick}
className="hover:text-blue-bright transition-colors duration-150 cursor-pointer bg-transparent border-none p-0 font-bold text-sm text-inherit"
>
{variantLabel}
</button>
</span>
</div>
<div
ref={maskRef}
className="fixed inset-0 z-[100] pointer-events-none"
style={{ visibility: "hidden", opacity: 0 }}
/>
</>
);
}

View File

@@ -0,0 +1,142 @@
import { useEffect, useRef, useState } from "react";
import { prefersReducedMotion } from "@/lib/reduced-motion";
export function useTypewriter(text: string, trigger: boolean, speed = 12) {
const [displayed, setDisplayed] = useState("");
const [done, setDone] = useState(false);
useEffect(() => {
if (!trigger) return;
if (prefersReducedMotion()) {
setDisplayed(text);
setDone(true);
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 };
}
export 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 };
}
interface TypedTextProps {
text: string;
as?: "h1" | "h2" | "h3" | "h4" | "span" | "p";
className?: string;
speed?: number;
cursor?: boolean;
}
export function TypedText({
text,
as: Tag = "span",
className = "",
speed = 12,
cursor = true,
}: TypedTextProps) {
const containerRef = useRef<HTMLDivElement>(null);
const tagRef = useRef<HTMLElement>(null);
const { ref, visible } = useScrollVisible();
const [done, setDone] = useState(false);
const [started, setStarted] = useState(false);
useEffect(() => {
if (!visible || started) return;
setStarted(true);
if (prefersReducedMotion()) {
setDone(true);
return;
}
const el = tagRef.current;
if (!el) return;
// Wrap each character in an invisible span — layout stays correct
el.textContent = "";
const chars: HTMLSpanElement[] = [];
for (const char of text) {
const span = document.createElement("span");
span.textContent = char;
span.style.opacity = "0";
chars.push(span);
el.appendChild(span);
}
const textLength = text.length;
const charSpeed = Math.max(8, Math.min(speed, 600 / textLength));
let i = 0;
const step = () => {
if (i >= chars.length) {
el.textContent = text;
setDone(true);
return;
}
chars[i].style.opacity = "1";
i++;
setTimeout(step, charSpeed);
};
step();
}, [visible, started, text, speed]);
return (
<div ref={ref}>
<Tag
ref={tagRef as any}
className={className}
style={{ minHeight: "1.2em" }}
>
{!started ? "\u00A0" : done ? text : null}
</Tag>
{cursor && started && !done && (
<span className="animate-pulse text-foreground/40">|</span>
)}
</div>
);
}

View File

@@ -0,0 +1,233 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { Canvas } from "@react-three/fiber";
import VoidTypewriter from "./typewriter";
import VoidWater from "./scenes/void-water";
// Canvas glitch: transforms + filters (physical shake + color corruption)
// Text glitch: filters only (color corruption, no position shift)
const GLITCH_CSS = `
.void-glitch-subtle {
animation: void-glitch-subtle 2s ease-in-out infinite;
}
.void-glitch-intense {
animation: void-glitch-intense 1.2s ease-in-out infinite;
}
.void-glitch-dissolve {
animation: void-glitch-dissolve 2s ease-in forwards;
}
.void-text-glitch-subtle {
animation: void-text-glitch-subtle 2s ease-in-out infinite;
}
.void-text-glitch-intense {
animation: void-text-glitch-intense 1.2s ease-in-out infinite;
}
.void-text-glitch-dissolve {
animation: void-text-glitch-dissolve 2s ease-in forwards;
}
@keyframes void-glitch-subtle {
0%, 100% { transform: none; filter: none; }
3% { transform: skewX(0.5deg); filter: hue-rotate(15deg); }
6% { transform: none; filter: none; }
15% { transform: translateX(1px) skewX(-0.2deg); }
17% { transform: none; }
30% { transform: skewX(-0.3deg) translateY(0.5px); filter: saturate(1.5); }
32% { transform: none; filter: none; }
50% { transform: translateY(-1px); }
52% { transform: none; }
70% { transform: skewX(0.2deg) translateX(-0.5px); filter: hue-rotate(-10deg); }
72% { transform: none; filter: none; }
85% { transform: translateX(-1px) skewY(0.1deg); }
87% { transform: none; }
}
@keyframes void-text-glitch-subtle {
0%, 100% { filter: none; }
3% { filter: hue-rotate(15deg); }
6% { filter: none; }
30% { filter: saturate(1.5); }
32% { filter: none; }
70% { filter: hue-rotate(-10deg); }
72% { filter: none; }
}
@keyframes void-glitch-intense {
0%, 100% { transform: none; filter: none; }
2% { transform: skewX(2deg) translateX(2px); filter: hue-rotate(60deg) saturate(3); }
5% { transform: skewX(-1.5deg) translateY(-1px); filter: none; }
8% { transform: none; }
12% { transform: translateY(-3px) skewX(0.5deg); filter: hue-rotate(-90deg); }
15% { transform: none; filter: none; }
25% { transform: skewX(1.5deg) scale(1.005) translateX(-2px); filter: saturate(4); }
28% { transform: none; filter: none; }
40% { transform: skewX(-2deg) translateY(2px); filter: hue-rotate(120deg) saturate(2); }
42% { transform: none; filter: none; }
55% { transform: translateX(-3px) skewY(0.3deg); }
58% { transform: none; }
70% { transform: scale(1.01) skewX(1deg); filter: hue-rotate(-45deg) saturate(3); }
73% { transform: none; filter: none; }
85% { transform: skewX(-1deg) translateX(2px) translateY(-1px); filter: saturate(5); }
88% { transform: none; filter: none; }
}
@keyframes void-text-glitch-intense {
0%, 100% { filter: none; }
2% { filter: hue-rotate(60deg) saturate(3); }
5% { filter: none; }
12% { filter: hue-rotate(-90deg); }
15% { filter: none; }
25% { filter: saturate(4); }
28% { filter: none; }
40% { filter: hue-rotate(120deg) saturate(2); }
42% { filter: none; }
70% { filter: hue-rotate(-45deg) saturate(3); }
73% { filter: none; }
85% { filter: saturate(5); }
88% { filter: none; }
}
@keyframes void-glitch-dissolve {
0% { transform: none; filter: none; opacity: 1; }
3% { transform: skewX(3deg) translateX(4px); filter: hue-rotate(90deg) saturate(4); }
6% { transform: skewX(-2deg) translateY(-3px); opacity: 0.95; }
10% { filter: hue-rotate(-120deg) saturate(3); opacity: 0.9; }
15% { transform: translateX(-5px) skewX(2deg); filter: none; opacity: 0.85; }
20% { transform: skewX(-3deg) scale(1.02); filter: hue-rotate(180deg) saturate(5); opacity: 0.8; }
25% { transform: translateY(4px) skewX(1deg); opacity: 0.75; }
30% { filter: saturate(6) hue-rotate(60deg); opacity: 0.7; }
40% { transform: skewX(2deg) translateX(-3px); filter: hue-rotate(-90deg); opacity: 0.55; }
50% { transform: skewX(-4deg) translateY(2px); filter: saturate(3); opacity: 0.4; }
60% { filter: hue-rotate(150deg); opacity: 0.3; }
70% { transform: scale(1.03) skewX(2deg); opacity: 0.2; }
80% { transform: translateX(-2px); opacity: 0.1; }
100% { transform: none; filter: none; opacity: 0; }
}
@keyframes void-text-glitch-dissolve {
0% { filter: none; opacity: 1; }
3% { filter: hue-rotate(90deg) saturate(4); }
10% { filter: hue-rotate(-120deg) saturate(3); opacity: 0.9; }
20% { filter: hue-rotate(180deg) saturate(5); opacity: 0.8; }
30% { filter: saturate(6) hue-rotate(60deg); opacity: 0.7; }
40% { filter: hue-rotate(-90deg); opacity: 0.55; }
50% { filter: saturate(3); opacity: 0.4; }
60% { filter: hue-rotate(150deg); opacity: 0.3; }
80% { filter: none; opacity: 0.1; }
100% { filter: none; opacity: 0; }
}
`;
function getCorruption(segment: number): number {
if (segment < 8) return 0;
if (segment === 8) return 0.05;
if (segment === 9) return 0.08;
if (segment === 10) return 0.1;
if (segment === 11) return 0.13;
if (segment === 12) return 0.1;
if (segment === 13) return 0.3;
if (segment === 14) return 0.6;
if (segment === 15) return 0.75;
if (segment === 16) return 0.9;
return 1.0;
}
function getCanvasGlitch(segment: number, dissolving: boolean): string {
if (dissolving) return "void-glitch-dissolve";
if (segment < 8) return "";
if (segment <= 14) return "void-glitch-subtle";
return "void-glitch-intense";
}
function getTextGlitch(segment: number, dissolving: boolean): string {
if (dissolving) return "void-text-glitch-dissolve";
if (segment < 8) return "";
if (segment <= 14) return "void-text-glitch-subtle";
return "void-text-glitch-intense";
}
interface VoidExperienceProps {
token: string;
}
export default function VoidExperience({ token }: VoidExperienceProps) {
const [activeSegment, setActiveSegment] = useState(0);
const [visitCount, setVisitCount] = useState<number | null>(null);
const [dissolving, setDissolving] = useState(false);
// Inject CSS + hide cursor + hide layout chrome underneath
useEffect(() => {
const style = document.createElement("style");
style.textContent = GLITCH_CSS;
document.head.appendChild(style);
document.body.style.cursor = "none";
document.documentElement.style.overflow = "hidden";
document.body.style.overflow = "hidden";
return () => {
style.remove();
document.body.style.cursor = "";
document.documentElement.style.overflow = "";
document.body.style.overflow = "";
};
}, []);
// Fetch + increment visit count on mount (with token verification)
useEffect(() => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
fetch("/api/void-visits", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
signal: controller.signal,
})
.then(r => r.json())
.then(data => setVisitCount(data.count ?? 1))
.catch(() => setVisitCount(1))
.finally(() => clearTimeout(timeout));
return () => {
controller.abort();
clearTimeout(timeout);
};
}, []);
const handlePhaseComplete = useCallback(() => {
setDissolving(true);
setTimeout(() => {
window.location.href = "/about";
}, 2000);
}, []);
const handleSegmentChange = useCallback((index: number) => {
setActiveSegment(index);
}, []);
const corruption = getCorruption(activeSegment);
return (
<div className="fixed inset-0 bg-black z-[9999]" style={{ height: "100dvh" }}>
{/* 3D Canvas — full glitch (transforms + filters) */}
<div className={`fixed inset-0 z-[10] ${getCanvasGlitch(activeSegment, dissolving)}`}>
<Canvas
camera={{ position: [0, 0, 8], fov: 60 }}
dpr={[1, 1.5]}
gl={{ antialias: false, alpha: true }}
style={{ background: "transparent" }}
>
<VoidWater segment={activeSegment} corruption={corruption} />
</Canvas>
</div>
{/* Typewriter — glitch class applied to inner text, not the fixed container */}
{visitCount !== null && (
<VoidTypewriter
startSegment={0}
onPhaseComplete={handlePhaseComplete}
onSegmentChange={handleSegmentChange}
visitCount={visitCount}
corruption={corruption}
glitchClass={getTextGlitch(activeSegment, dissolving)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,15 @@
export const VOID = {
bg: "#000000",
text: "#FFFFFF",
red: "#CC2420",
dim: "#BDAE93",
gold: "#D79921",
} as const;
export const VOID_RGB = {
bg: [0, 0, 0] as const,
text: [1, 1, 1] as const,
red: [0.8, 0.14, 0.13] as const,
dim: [0.74, 0.68, 0.58] as const,
gold: [0.84, 0.6, 0.13] as const,
};

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