Compare commits
1 Commits
main
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3285553419 |
5
.caddy/Caddyfile.local
Normal file
@@ -0,0 +1,5 @@
|
||||
timmypidashev.local {
|
||||
tls internal
|
||||
|
||||
reverse_proxy timmypidashev.dev:4321
|
||||
}
|
||||
5
.caddy/Caddyfile.release
Normal file
@@ -0,0 +1,5 @@
|
||||
timmypidashev.dev {
|
||||
tls pidashev.tim@gmail.com
|
||||
|
||||
reverse_proxy timmypidashev.dev:3000
|
||||
}
|
||||
27
.docker/Dockerfile.local
Normal file
@@ -0,0 +1,27 @@
|
||||
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"]
|
||||
47
.docker/Dockerfile.release
Normal file
@@ -0,0 +1,47 @@
|
||||
# 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"]
|
||||
83
.github/scripts/deploy_release.sh
vendored
Executable file
@@ -0,0 +1,83 @@
|
||||
#!/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
@@ -1,3 +1,3 @@
|
||||
[submodule "public/scripts"]
|
||||
path = public/scripts
|
||||
[submodule "src/public/scripts"]
|
||||
path = src/public/scripts
|
||||
url = https://github.com/timmypidashev/scripts
|
||||
|
||||
186
Makefile
Normal file
@@ -0,0 +1,186 @@
|
||||
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
|
||||
32
compose.release.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
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
|
||||
52
package.json
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"name": "timmypidashev-web",
|
||||
"version": "3.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev --host",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/react": "^5.0.2",
|
||||
"@astrojs/tailwind": "^6.0.2",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/react": "^18.3.28",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/three": "^0.175.0",
|
||||
"astro": "^6.1.2",
|
||||
"tailwindcss": "^3.4.19"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^5.0.3",
|
||||
"@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",
|
||||
"react-responsive": "^10.0.1",
|
||||
"reading-time": "^1.5.0",
|
||||
"rehype-pretty-code": "^0.14.3",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"schema-dts": "^1.1.5",
|
||||
"shiki": "^3.23.0",
|
||||
"three": "^0.175.0",
|
||||
"typewriter-effect": "^2.22.0",
|
||||
"unist-util-visit": "^5.1.0"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 578 KiB |
|
Before Width: | Height: | Size: 344 KiB |
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 558 KiB |
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 431 KiB |
|
Before Width: | Height: | Size: 684 KiB |
|
Before Width: | Height: | Size: 496 KiB |
|
Before Width: | Height: | Size: 436 KiB |
|
Before Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 867 KiB |
|
Before Width: | Height: | Size: 728 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 374 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 375 KiB |
|
Before Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 567 KiB |
|
Before Width: | Height: | Size: 543 KiB |
|
Before Width: | Height: | Size: 521 KiB |
|
Before Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 333 KiB |
|
Before Width: | Height: | Size: 460 KiB |
|
Before Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 543 KiB |
|
Before Width: | Height: | Size: 588 KiB |
|
Before Width: | Height: | Size: 582 KiB |
|
Before Width: | Height: | Size: 390 KiB |
11
src/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Astro with Tailwind
|
||||
|
||||
```
|
||||
npm init astro -- --template with-tailwindcss
|
||||
```
|
||||
|
||||
[](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).
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineConfig } from "astro/config";
|
||||
import vercel from "@astrojs/vercel";
|
||||
import node from "@astrojs/node";
|
||||
import tailwind from "@astrojs/tailwind";
|
||||
import react from "@astrojs/react";
|
||||
import mdx from "@astrojs/mdx";
|
||||
@@ -10,9 +10,14 @@ import sitemap from "@astrojs/sitemap";
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
output: "server",
|
||||
adapter: vercel(),
|
||||
server: {
|
||||
host: true,
|
||||
port: 3000,
|
||||
},
|
||||
adapter: node({
|
||||
mode: "standalone",
|
||||
}),
|
||||
site: "https://timmypidashev.dev",
|
||||
devToolbar: { enabled: false },
|
||||
build: {
|
||||
// Enable build-time optimizations
|
||||
inlineStylesheets: "auto",
|
||||
@@ -1,142 +0,0 @@
|
||||
import { Code2, BookOpen, RocketIcon, Compass } from "lucide-react";
|
||||
import { AnimateIn } from "@/components/animate-in";
|
||||
|
||||
export default function CurrentFocus() {
|
||||
const recentProjects = [
|
||||
{
|
||||
title: "Darkbox",
|
||||
description: "My gruvbox theme, with a pure black background",
|
||||
href: "/projects/darkbox",
|
||||
tech: ["Neovim", "Lua"],
|
||||
},
|
||||
{
|
||||
title: "Revive Auto Parts",
|
||||
description: "A car parts listing site built for a client",
|
||||
href: "/projects/reviveauto",
|
||||
tech: ["Tanstack", "React Query", "Fastapi"],
|
||||
},
|
||||
{
|
||||
title: "Fhccenter",
|
||||
description: "Website made for a private school",
|
||||
href: "/projects/fhccenter",
|
||||
tech: ["Nextjs", "Typescript", "Prisma"],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center w-full">
|
||||
<div className="w-full max-w-6xl p-4 sm:px-6 py-6 sm:py-8">
|
||||
<AnimateIn>
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-center text-yellow-bright mb-8 sm:mb-12">
|
||||
Current Focus
|
||||
</h2>
|
||||
</AnimateIn>
|
||||
|
||||
{/* Recent Projects Section */}
|
||||
<div className="mb-8 sm:mb-16">
|
||||
<AnimateIn delay={100}>
|
||||
<div className="flex items-center justify-center gap-2 mb-6">
|
||||
<Code2 className="text-yellow-bright" size={24} />
|
||||
<h3 className="text-xl font-bold text-foreground/90">Recent Projects</h3>
|
||||
</div>
|
||||
</AnimateIn>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6 max-w-5xl mx-auto">
|
||||
{recentProjects.map((project, i) => (
|
||||
<AnimateIn key={project.title} delay={200 + i * 100}>
|
||||
<a
|
||||
href={project.href}
|
||||
className="block p-4 sm:p-6 rounded-lg border border-foreground/10 hover:border-yellow-bright/50
|
||||
transition-colors duration-300 group bg-background/50 h-full"
|
||||
>
|
||||
<h4 className="font-bold text-lg group-hover:text-yellow-bright transition-colors">
|
||||
{project.title}
|
||||
</h4>
|
||||
<p className="text-foreground/70 mt-2 text-sm sm:text-base">{project.description}</p>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{project.tech.map((tech) => (
|
||||
<span key={tech} className="text-xs px-2 py-1 rounded-full bg-foreground/5 text-foreground/60">
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</a>
|
||||
</AnimateIn>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Learning & Interests */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8 max-w-5xl mx-auto">
|
||||
<AnimateIn delay={100}>
|
||||
<div className="space-y-4 p-4 sm:p-6 rounded-lg border border-foreground/10 bg-background/50 h-full">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<BookOpen className="text-green-bright" size={24} />
|
||||
<h3 className="text-lg sm:text-xl font-bold text-foreground/90">Currently Learning</h3>
|
||||
</div>
|
||||
<ul className="space-y-3 text-sm sm:text-base text-foreground/70">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-bright flex-shrink-0" />
|
||||
<span>Rust Programming</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-bright flex-shrink-0" />
|
||||
<span>WebAssembly with Rust</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-bright flex-shrink-0" />
|
||||
<span>HTTP/3 & WebTransport</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AnimateIn>
|
||||
|
||||
<AnimateIn delay={200}>
|
||||
<div className="space-y-4 p-4 sm:p-6 rounded-lg border border-foreground/10 bg-background/50 h-full">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<RocketIcon className="text-blue-bright" size={24} />
|
||||
<h3 className="text-lg sm:text-xl font-bold text-foreground/90">Project Interests</h3>
|
||||
</div>
|
||||
<ul className="space-y-3 text-sm sm:text-base text-foreground/70">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-bright flex-shrink-0" />
|
||||
<span>AI Model Integration</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-bright flex-shrink-0" />
|
||||
<span>Rust Systems Programming</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-bright flex-shrink-0" />
|
||||
<span>Cross-platform WASM Apps</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AnimateIn>
|
||||
|
||||
<AnimateIn delay={300}>
|
||||
<div className="space-y-4 p-4 sm:p-6 rounded-lg border border-foreground/10 bg-background/50 h-full">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Compass className="text-purple-bright" size={24} />
|
||||
<h3 className="text-lg sm:text-xl font-bold text-foreground/90">Want to Explore</h3>
|
||||
</div>
|
||||
<ul className="space-y-3 text-sm sm:text-base text-foreground/70">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-bright flex-shrink-0" />
|
||||
<span>LLM Fine-tuning</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-bright flex-shrink-0" />
|
||||
<span>Rust 2024 Edition</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-bright flex-shrink-0" />
|
||||
<span>Real-time Web Transport</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AnimateIn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
export default function Intro() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const inView = rect.top < window.innerHeight && rect.bottom > 0;
|
||||
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
|
||||
const isSpaNav = !!(window as any).__astroNavigation;
|
||||
if (inView && (isReload || isSpaNav)) {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
if (inView) {
|
||||
requestAnimationFrame(() => setVisible(true));
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.2 }
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const scrollToNext = () => {
|
||||
const nextSection = document.querySelector("section")?.nextElementSibling;
|
||||
if (nextSection) {
|
||||
const offset = (nextSection as HTMLElement).offsetTop - (window.innerHeight - (nextSection as HTMLElement).offsetHeight) / 2;
|
||||
window.scrollTo({ top: offset, behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
const anim = (delay: number) =>
|
||||
({
|
||||
opacity: visible ? 1 : 0,
|
||||
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 justify-center gap-8 sm:gap-16">
|
||||
<div
|
||||
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-colors duration-300"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center sm:text-left space-y-4 sm:space-y-6" style={anim(150)}>
|
||||
<h2 className="text-3xl sm:text-3xl lg:text-5xl font-bold text-yellow-bright">
|
||||
Timothy Pidashev
|
||||
</h2>
|
||||
<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 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 sm:justify-start font-bold gap-2" style={anim(600)}>
|
||||
<span className="text-yellow">Coffee Connoisseur</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-8" style={anim(750)}>
|
||||
<p className="text-foreground/80 text-center text-base sm:text-2xl italic max-w-3xl mx-auto font-medium">
|
||||
"Turning coffee into code" isn't just a clever phrase –
|
||||
<span className="text-aqua-bright"> it's how I approach each project:</span>
|
||||
<span className="text-purple-bright"> methodically,</span>
|
||||
<span className="text-blue-bright"> with attention to detail,</span>
|
||||
<span className="text-green-bright"> and a refined process.</span>
|
||||
</p>
|
||||
<div className="flex justify-center" style={anim(900)}>
|
||||
<button
|
||||
onClick={scrollToNext}
|
||||
className="text-foreground/50 hover:text-yellow-bright transition-colors duration-300"
|
||||
aria-label="Scroll to next section"
|
||||
>
|
||||
<ChevronDown size={40} className="animate-bounce" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Cross, Fish, Mountain, Book } from "lucide-react";
|
||||
import { AnimateIn } from "@/components/animate-in";
|
||||
|
||||
const interests = [
|
||||
{
|
||||
icon: <Cross className="text-red-bright" size={20} />,
|
||||
title: "Faith",
|
||||
description: "My walk with Jesus is the foundation of everything I do, guiding my purpose and perspective",
|
||||
},
|
||||
{
|
||||
icon: <Fish className="text-blue-bright" size={20} />,
|
||||
title: "Fishing",
|
||||
description: "Finding peace and adventure on the water, always looking for the next great fishing spot",
|
||||
},
|
||||
{
|
||||
icon: <Mountain className="text-green-bright" size={20} />,
|
||||
title: "Hiking",
|
||||
description: "Exploring trails with friends and seeking out scenic viewpoints in nature",
|
||||
},
|
||||
{
|
||||
icon: <Book className="text-purple-bright" size={20} />,
|
||||
title: "Reading",
|
||||
description: "Deep diving into novels & technical books that expand my horizons & captivate my mind",
|
||||
},
|
||||
];
|
||||
|
||||
export default function OutsideCoding() {
|
||||
return (
|
||||
<div className="flex justify-center items-center w-full">
|
||||
<div className="w-full max-w-4xl px-4 py-8">
|
||||
<AnimateIn>
|
||||
<h2 className="text-2xl md:text-4xl font-bold text-center text-yellow-bright mb-8">
|
||||
Outside of Programming
|
||||
</h2>
|
||||
</AnimateIn>
|
||||
|
||||
<div className="grid grid-cols-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-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>
|
||||
<p className="text-sm text-foreground/70">{interest.description}</p>
|
||||
</div>
|
||||
</AnimateIn>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AnimateIn delay={500}>
|
||||
<p className="text-center text-foreground/80 mt-8 max-w-2xl mx-auto text-sm md:text-base italic">
|
||||
When I'm not writing code, you'll find me
|
||||
<span className="text-red-bright"> walking with Christ,</span>
|
||||
<span className="text-blue-bright"> out on the water,</span>
|
||||
<span className="text-green-bright"> hiking trails,</span>
|
||||
<span className="text-purple-bright"> or reading books.</span>
|
||||
</p>
|
||||
</AnimateIn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
interface ActivityDay {
|
||||
grand_total: { total_seconds: number };
|
||||
date: string;
|
||||
}
|
||||
|
||||
interface ActivityGridProps {
|
||||
data: ActivityDay[];
|
||||
}
|
||||
|
||||
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"];
|
||||
|
||||
const getIntensity = (hours: number) => {
|
||||
if (hours === 0) return 0;
|
||||
if (hours < 2) return 1;
|
||||
if (hours < 4) return 2;
|
||||
if (hours < 6) return 3;
|
||||
return 4;
|
||||
};
|
||||
|
||||
const getColorClass = (intensity: number) => {
|
||||
if (intensity === 0) return "bg-foreground/5";
|
||||
if (intensity === 1) return "bg-green-DEFAULT/30";
|
||||
if (intensity === 2) return "bg-green-DEFAULT/60";
|
||||
if (intensity === 3) return "bg-green-DEFAULT/80";
|
||||
return "bg-green-bright";
|
||||
};
|
||||
|
||||
const weeks: ActivityDay[][] = [];
|
||||
let currentWeek: ActivityDay[] = [];
|
||||
|
||||
if (data && data.length > 0) {
|
||||
data.forEach((day, index) => {
|
||||
currentWeek.push(day);
|
||||
if (currentWeek.length === 7 || index === data.length - 1) {
|
||||
weeks.push(currentWeek);
|
||||
currentWeek = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* Days labels */}
|
||||
<div className="flex flex-col gap-2 pt-6 text-xs">
|
||||
{days.map((day, i) => (
|
||||
<div key={day} className="h-3 text-foreground/60">{i % 2 === 0 ? day : ""}</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Grid */}
|
||||
<div className="flex-grow overflow-x-auto">
|
||||
<div className="flex gap-2">
|
||||
{weeks.map((week, weekIndex) => (
|
||||
<div key={weekIndex} className="flex flex-col gap-2">
|
||||
{week.map((day, dayIndex) => {
|
||||
const hours = day.grand_total.total_seconds / 3600;
|
||||
const intensity = getIntensity(hours);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={dayIndex}
|
||||
className={`w-3 h-3 rounded-sm ${getColorClass(intensity)}
|
||||
hover:ring-1 hover:ring-foreground/30 transition-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 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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Months labels */}
|
||||
<div className="flex text-xs text-foreground/60 mt-2">
|
||||
{weeks.map((week, i) => {
|
||||
const date = new Date(week[0].date);
|
||||
const isFirstOfMonth = date.getDate() <= 7;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="w-3 mx-1"
|
||||
style={{ marginLeft: i === 0 ? "0" : undefined }}
|
||||
>
|
||||
{isFirstOfMonth && months[date.getMonth()]}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-2 mt-4 text-xs text-foreground/60">
|
||||
<span>Less</span>
|
||||
{[0, 1, 2, 3, 4].map((intensity) => (
|
||||
<div key={intensity} className={`w-3 h-3 rounded-sm ${getColorClass(intensity)}`} />
|
||||
))}
|
||||
<span>More</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityGrid;
|
||||
@@ -1,168 +0,0 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
const Stats = () => {
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [count, setCount] = useState(0);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [skipAnim, setSkipAnim] = useState(false);
|
||||
const hasAnimated = useRef(false);
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch data on mount
|
||||
useEffect(() => {
|
||||
fetch("/api/wakatime/alltime")
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("API error");
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => setStats(data.data))
|
||||
.catch(() => setError(true));
|
||||
}, []);
|
||||
|
||||
// Observe visibility — skip animation if already in view on mount
|
||||
useEffect(() => {
|
||||
const el = sectionRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const inView = rect.top < window.innerHeight && rect.bottom > 0;
|
||||
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
|
||||
const isSpaNav = !!(window as any).__astroNavigation;
|
||||
if (inView && (isReload || isSpaNav)) {
|
||||
setSkipAnim(true);
|
||||
setIsVisible(true);
|
||||
return;
|
||||
}
|
||||
if (inView) {
|
||||
requestAnimationFrame(() => setIsVisible(true));
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.3 }
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Start counter when both visible and data is ready
|
||||
useEffect(() => {
|
||||
if (!isVisible || !stats || hasAnimated.current) return;
|
||||
hasAnimated.current = true;
|
||||
|
||||
const totalSeconds = stats.total_seconds;
|
||||
const duration = 2000;
|
||||
let start: number | null = null;
|
||||
let rafId: number;
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
rafId = requestAnimationFrame(step);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [isVisible, stats]);
|
||||
|
||||
if (error) return null;
|
||||
if (!stats) return <div ref={sectionRef} className="min-h-[50vh]" />;
|
||||
|
||||
const hours = Math.floor(count / 3600);
|
||||
const formattedHours = hours.toLocaleString("en-US", {
|
||||
minimumIntegerDigits: 4,
|
||||
useGrouping: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={sectionRef} className="flex flex-col items-center justify-center min-h-[50vh] gap-6">
|
||||
<div className={skipAnim ? "text-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-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-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-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" : ""}`}>
|
||||
<span>since</span>
|
||||
<span className="text-green-bright font-bold">{stats.range.start_text}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.bg-gradient-text {
|
||||
background: linear-gradient(90deg,
|
||||
rgb(var(--color-yellow-bright)),
|
||||
rgb(var(--color-orange-bright)),
|
||||
rgb(var(--color-orange)),
|
||||
rgb(var(--color-yellow)),
|
||||
rgb(var(--color-orange-bright)),
|
||||
rgb(var(--color-yellow-bright))
|
||||
);
|
||||
background-size: 200% auto;
|
||||
color: transparent;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
@keyframes fadeInFirst {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 0.8; transform: translateY(0); }
|
||||
}
|
||||
@keyframes fadeInSecond {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes slideInHours {
|
||||
from { opacity: 0; transform: translateX(20px); margin-left: 0; }
|
||||
to { opacity: 0.6; transform: translateX(0); margin-left: 1rem; }
|
||||
}
|
||||
@keyframes fadeInThird {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 0.8; transform: translateY(0); }
|
||||
}
|
||||
@keyframes fadeInFourth {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 0.6; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate-fade-in-first { animation: fadeInFirst 0.7s ease-out forwards; }
|
||||
.animate-fade-in-second { animation: fadeInSecond 0.7s ease-out 0.4s forwards; }
|
||||
.animate-slide-in-hours { animation: slideInHours 0.7s ease-out 0.6s forwards; }
|
||||
.animate-fade-in-third { animation: fadeInThird 0.7s ease-out 0.8s forwards; }
|
||||
.animate-fade-in-fourth { animation: fadeInFourth 0.7s ease-out 1s forwards; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Stats;
|
||||
@@ -1,233 +0,0 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Clock, CalendarClock, CodeXml, Computer } from "lucide-react";
|
||||
import { ActivityGrid } from "@/components/about/stats-activity";
|
||||
|
||||
const DetailedStats = () => {
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [activity, setActivity] = useState<any>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [skipAnim, setSkipAnim] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/wakatime/detailed")
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error();
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => setStats(data.data))
|
||||
.catch(() => setError(true));
|
||||
|
||||
fetch("/api/wakatime/activity")
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error();
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => setActivity(data.data))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const inView = rect.top < window.innerHeight && rect.bottom > 0;
|
||||
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
|
||||
const isSpaNav = !!(window as any).__astroNavigation;
|
||||
if (inView && (isReload || isSpaNav)) {
|
||||
setSkipAnim(true);
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
if (inView) {
|
||||
requestAnimationFrame(() => setVisible(true));
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: "-15% 0px" }
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [stats]);
|
||||
|
||||
if (error) return null;
|
||||
|
||||
const progressColors = [
|
||||
"bg-red-bright",
|
||||
"bg-orange-bright",
|
||||
"bg-yellow-bright",
|
||||
"bg-green-bright",
|
||||
"bg-blue-bright",
|
||||
"bg-purple-bright",
|
||||
"bg-aqua-bright",
|
||||
];
|
||||
|
||||
const statCards = stats
|
||||
? [
|
||||
{
|
||||
title: "Total Time",
|
||||
value: `${Math.round((stats.total_seconds / 3600) * 10) / 10}`,
|
||||
unit: "hours",
|
||||
subtitle: "this week",
|
||||
color: "text-yellow-bright",
|
||||
borderHover: "hover:border-yellow-bright/50",
|
||||
icon: Clock,
|
||||
iconColor: "stroke-yellow-bright",
|
||||
},
|
||||
{
|
||||
title: "Daily Average",
|
||||
value: `${Math.round((stats.daily_average / 3600) * 10) / 10}`,
|
||||
unit: "hours",
|
||||
subtitle: "per day",
|
||||
color: "text-orange-bright",
|
||||
borderHover: "hover:border-orange-bright/50",
|
||||
icon: CalendarClock,
|
||||
iconColor: "stroke-orange-bright",
|
||||
},
|
||||
{
|
||||
title: "Primary Editor",
|
||||
value: stats.editors?.[0]?.name || "None",
|
||||
unit: `${Math.round(stats.editors?.[0]?.percent || 0)}%`,
|
||||
subtitle: "of the time",
|
||||
color: "text-blue-bright",
|
||||
borderHover: "hover:border-blue-bright/50",
|
||||
icon: CodeXml,
|
||||
iconColor: "stroke-blue-bright",
|
||||
},
|
||||
{
|
||||
title: "Operating System",
|
||||
value: stats.operating_systems?.[0]?.name || "None",
|
||||
unit: `${Math.round(stats.operating_systems?.[0]?.percent || 0)}%`,
|
||||
subtitle: "of the time",
|
||||
color: "text-green-bright",
|
||||
borderHover: "hover:border-green-bright/50",
|
||||
icon: Computer,
|
||||
iconColor: "stroke-green-bright",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const languages =
|
||||
stats?.languages?.slice(0, 7).map((lang: any, index: number) => ({
|
||||
name: lang.name,
|
||||
percent: Math.round(lang.percent),
|
||||
time: Math.round((lang.total_seconds / 3600) * 10) / 10 + " hrs",
|
||||
color: progressColors[index % progressColors.length],
|
||||
})) || [];
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col gap-10 md:py-32 w-full max-w-[1200px] mx-auto px-4 min-h-[50vh]">
|
||||
{!stats ? null : (
|
||||
<>
|
||||
{/* Header */}
|
||||
<h2
|
||||
className={`text-2xl md:text-4xl font-bold text-center text-yellow-bright ${skipAnim ? "" : "transition-[opacity,transform] duration-700 ease-out"}`}
|
||||
style={skipAnim ? {} : {
|
||||
opacity: visible ? 1 : 0,
|
||||
transform: visible ? "translateY(0)" : "translateY(20px)",
|
||||
}}
|
||||
>
|
||||
Weekly Statistics
|
||||
</h2>
|
||||
|
||||
{/* Stat Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{statCards.map((card, i) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<div
|
||||
key={card.title}
|
||||
className={`bg-background/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,
|
||||
transform: visible ? "translateY(0)" : "translateY(24px)",
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="p-3 rounded-lg bg-foreground/5">
|
||||
<Icon className={`w-6 h-6 ${card.iconColor}`} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className={`${card.color} text-sm mb-1`}>{card.title}</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div className="text-2xl font-bold">{card.value}</div>
|
||||
<div className="text-lg opacity-80">{card.unit}</div>
|
||||
</div>
|
||||
<div className="text-xs opacity-50 mt-0.5">{card.subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Languages */}
|
||||
<div
|
||||
className={`bg-background/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,
|
||||
transform: visible ? "translateY(0)" : "translateY(24px)",
|
||||
}}
|
||||
>
|
||||
<div className="text-purple-bright mb-6 text-lg">Languages</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-5">
|
||||
{languages.map((lang: any, i: number) => (
|
||||
<div key={lang.name} className="flex flex-col gap-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">{lang.name}</span>
|
||||
<span className="text-sm opacity-70 tabular-nums">{lang.time}</span>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex-grow h-2 bg-foreground/5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${lang.color} rounded-full`}
|
||||
style={{
|
||||
width: `${lang.percent}%`,
|
||||
opacity: 0.85,
|
||||
transform: visible ? "scaleX(1)" : "scaleX(0)",
|
||||
transformOrigin: "left",
|
||||
transition: skipAnim ? "none" : `transform 1s ease-out ${700 + i * 80}ms`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-foreground/50 min-w-[36px] text-right tabular-nums">
|
||||
{lang.percent}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activity Grid */}
|
||||
{activity && (
|
||||
<div
|
||||
className={skipAnim ? "" : "transition-[opacity,transform] duration-700 ease-out"}
|
||||
style={skipAnim ? {} : {
|
||||
transitionDelay: "750ms",
|
||||
opacity: visible ? 1 : 0,
|
||||
transform: visible ? "translateY(0)" : "translateY(24px)",
|
||||
}}
|
||||
>
|
||||
<ActivityGrid data={activity} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailedStats;
|
||||
@@ -1,185 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Check, Code, GitBranch, Star, Rocket } from "lucide-react";
|
||||
|
||||
const timelineItems = [
|
||||
{
|
||||
year: "2026",
|
||||
title: "Present",
|
||||
description: "Building domain-specific languages, diving deep into the Salesforce ecosystem, and writing production Java and Python daily. The craft keeps evolving.",
|
||||
technologies: ["Java", "Python", "Salesforce", "DSLs"],
|
||||
icon: <Rocket className="text-red-bright" size={20} />,
|
||||
},
|
||||
{
|
||||
year: "2024",
|
||||
title: "Shipping & Scaling",
|
||||
description: "The wisdom of past ventures now flows through my work, whether crafting elegant CRUD applications or embarking on bold projects that expand my limits.",
|
||||
technologies: ["Rust", "Typescript", "Go", "Postgres"],
|
||||
icon: <Code className="text-yellow-bright" size={20} />,
|
||||
},
|
||||
{
|
||||
year: "2022",
|
||||
title: "Diving Deeper",
|
||||
description: "The worlds of systems programming and scalable infrastructure collided as I explored low-level C++ graphics programming and containerization with Docker.",
|
||||
technologies: ["C++", "Cmake", "Docker", "Docker Compose"],
|
||||
icon: <GitBranch className="text-green-bright" size={20} />,
|
||||
},
|
||||
{
|
||||
year: "2020",
|
||||
title: "Exploring the Stack",
|
||||
description: "Starting with pure HTML and CSS, I explored the foundations of web development, gradually venturing into JavaScript and React to bring my static pages to life.",
|
||||
technologies: ["Javascript", "Tailwind", "React", "Express"],
|
||||
icon: <Star className="text-blue-bright" size={20} />,
|
||||
},
|
||||
{
|
||||
year: "2018",
|
||||
title: "Starting the Journey",
|
||||
description: "An elective Python class in 8th grade transformed my keen interest in programming into a relentless obsession, one that drove me to constantly explore new depths.",
|
||||
technologies: ["Python", "Discord.py", "Asyncio", "Sqlite"],
|
||||
icon: <Check className="text-purple-bright" size={20} />,
|
||||
},
|
||||
];
|
||||
|
||||
function TimelineCard({ item, index }: { item: (typeof timelineItems)[number]; index: number }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [skip, setSkip] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const inView = rect.top < window.innerHeight && rect.bottom > 0;
|
||||
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
|
||||
const isSpaNav = !!(window as any).__astroNavigation;
|
||||
|
||||
if (inView && (isReload || isSpaNav)) {
|
||||
setSkip(true);
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
if (inView) {
|
||||
requestAnimationFrame(() => setVisible(true));
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.2 }
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const isLeft = index % 2 === 0;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative mb-8 md:mb-12 last:mb-0">
|
||||
<div className={`flex flex-col sm:flex-row items-start ${isLeft ? "sm:flex-row-reverse" : ""}`}>
|
||||
{/* Node */}
|
||||
<div
|
||||
className={`
|
||||
absolute -left-8 sm:left-1/2 w-6 h-6 sm:w-8 sm:h-8 bg-background
|
||||
rounded-full border-2 border-yellow-bright sm:-translate-x-1/2
|
||||
flex items-center justify-center z-10
|
||||
${skip ? "" : "transition-[opacity,transform] duration-500"}
|
||||
${visible ? "scale-100 opacity-100" : "scale-0 opacity-0"}
|
||||
`}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div
|
||||
className={`
|
||||
w-full sm:w-[calc(50%-32px)]
|
||||
${isLeft ? "sm:pr-8 md:pr-12" : "sm:pl-8 md:pl-12"}
|
||||
${skip ? "" : "transition-[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`
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className="p-4 sm:p-6 bg-background/50 rounded-lg border border-foreground/10
|
||||
hover:border-yellow-bright/50 transition-colors duration-300"
|
||||
>
|
||||
<span className="text-xs sm:text-sm font-mono text-yellow-bright">{item.year}</span>
|
||||
<h3 className="text-lg sm:text-xl font-bold text-foreground/90 mt-2">{item.title}</h3>
|
||||
<p className="text-sm sm:text-base text-foreground/70 mt-2">{item.description}</p>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{item.technologies.map((tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
className="px-2 py-1 text-xs sm:text-sm rounded-full bg-foreground/5
|
||||
text-foreground/60 hover:text-yellow-bright transition-colors duration-300"
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Timeline() {
|
||||
const lineRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [lineHeight, setLineHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
// Animate line to full height over time
|
||||
const el = lineRef.current;
|
||||
if (el) {
|
||||
setLineHeight(100);
|
||||
}
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl px-4 py-8 relative z-0">
|
||||
<h2 className="text-2xl md:text-4xl font-bold text-center text-yellow-bright mb-8 md:mb-12">
|
||||
My Journey Through Code
|
||||
</h2>
|
||||
<div ref={containerRef} className="relative">
|
||||
{/* Animated vertical line */}
|
||||
<div className="absolute left-4 sm:left-1/2 h-full w-0.5 -translate-x-1/2">
|
||||
<div
|
||||
ref={lineRef}
|
||||
className="w-full h-full bg-foreground/10 transition-transform duration-[1500ms] ease-out origin-top"
|
||||
style={{ transform: `scaleY(${lineHeight / 100})` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ml-8 sm:ml-0">
|
||||
{timelineItems.map((item, index) => (
|
||||
<TimelineCard key={item.year} item={item} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
import { SpeedInsights } from "@vercel/speed-insights/react";
|
||||
|
||||
export default function VercelAnalytics() {
|
||||
return (
|
||||
<>
|
||||
<Analytics />
|
||||
<SpeedInsights />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { prefersReducedMotion } from "@/lib/reduced-motion";
|
||||
|
||||
interface AnimateInProps {
|
||||
children: React.ReactNode;
|
||||
delay?: number;
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
export function AnimateIn({ children, delay = 0, threshold = 0.15 }: AnimateInProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [skip, setSkip] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
if (prefersReducedMotion()) {
|
||||
setSkip(true);
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
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;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold }
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [threshold]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={skip ? "" : "transition-[opacity,transform] duration-700 ease-out"}
|
||||
style={skip ? {} : {
|
||||
transitionDelay: `${delay}ms`,
|
||||
willChange: "transform, opacity",
|
||||
opacity: visible ? 1 : 0,
|
||||
transform: visible ? "translate3d(0,0,0)" : "translate3d(0,24px,0)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
getStoredAnimationId,
|
||||
getNextAnimation,
|
||||
saveAnimation,
|
||||
} from "@/lib/animations/engine";
|
||||
import { ANIMATION_LABELS } from "@/lib/animations";
|
||||
|
||||
export default function AnimationSwitcher() {
|
||||
const [hovering, setHovering] = useState(false);
|
||||
const [currentLabel, setCurrentLabel] = useState("");
|
||||
const committedRef = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
committedRef.current = getStoredAnimationId();
|
||||
setCurrentLabel(ANIMATION_LABELS[committedRef.current]);
|
||||
|
||||
const handleSwap = () => {
|
||||
const id = getStoredAnimationId();
|
||||
committedRef.current = id;
|
||||
setCurrentLabel(ANIMATION_LABELS[id]);
|
||||
};
|
||||
|
||||
document.addEventListener("astro:after-swap", handleSwap);
|
||||
return () => {
|
||||
document.removeEventListener("astro:after-swap", handleSwap);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClick = () => {
|
||||
const nextId = getNextAnimation(
|
||||
committedRef.current as Parameters<typeof getNextAnimation>[0]
|
||||
);
|
||||
saveAnimation(nextId);
|
||||
committedRef.current = nextId;
|
||||
setCurrentLabel(ANIMATION_LABELS[nextId]);
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("animation-changed", { detail: { id: nextId } })
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed bottom-4 left-4 z-[101] pointer-events-auto hidden desk:block"
|
||||
onMouseEnter={() => setHovering(true)}
|
||||
onMouseLeave={() => setHovering(false)}
|
||||
onClick={handleClick}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<span
|
||||
className="text-foreground font-bold text-sm select-none transition-opacity duration-200"
|
||||
style={{ opacity: hovering ? 0.8 : 0.15 }}
|
||||
>
|
||||
{currentLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,574 +0,0 @@
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
import type { AnimationEngine } from "@/lib/animations/types";
|
||||
|
||||
interface ConfettiParticle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
r: number;
|
||||
color: [number, number, number];
|
||||
baseColor: [number, number, number];
|
||||
opacity: number;
|
||||
dop: number;
|
||||
elevation: number;
|
||||
targetElevation: number;
|
||||
staggerDelay: number;
|
||||
burst: boolean;
|
||||
}
|
||||
|
||||
const BASE_CONFETTI = 385;
|
||||
const BASE_AREA = 1920 * 1080;
|
||||
const PI_2 = 2 * Math.PI;
|
||||
const TARGET_FPS = 60;
|
||||
const SPEED_FACTOR = 0.15;
|
||||
const STAGGER_INTERVAL = 12;
|
||||
const COLOR_LERP_SPEED = 0.02;
|
||||
const MOUSE_INFLUENCE_RADIUS = 150;
|
||||
const ELEVATION_FACTOR = 6;
|
||||
const ELEVATION_LERP_SPEED = 0.05;
|
||||
const COLOR_SHIFT_AMOUNT = 30;
|
||||
const SHADOW_OFFSET_RATIO = 1.1;
|
||||
|
||||
function range(a: number, b: number): number {
|
||||
return (b - a) * Math.random() + a;
|
||||
}
|
||||
|
||||
export class ConfettiEngine implements AnimationEngine {
|
||||
id = "confetti";
|
||||
name = "Confetti";
|
||||
|
||||
private particles: ConfettiParticle[] = [];
|
||||
private palette: [number, number, number][] = [];
|
||||
private width = 0;
|
||||
private height = 0;
|
||||
private mouseX = -1000;
|
||||
private mouseY = -1000;
|
||||
private mouseXNorm = 0.5;
|
||||
private elapsed = 0;
|
||||
private exiting = false;
|
||||
|
||||
init(
|
||||
width: number,
|
||||
height: number,
|
||||
palette: [number, number, number][],
|
||||
_bgColor: string
|
||||
): void {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.palette = palette;
|
||||
this.elapsed = 0;
|
||||
this.mouseXNorm = 0.5;
|
||||
this.initParticles();
|
||||
}
|
||||
|
||||
beginExit(): void {
|
||||
if (this.exiting) return;
|
||||
this.exiting = true;
|
||||
|
||||
// Stagger fade-out over 3 seconds
|
||||
for (let i = 0; i < this.particles.length; i++) {
|
||||
const p = this.particles[i];
|
||||
p.staggerDelay = -1; // ensure visible
|
||||
// Random delay before fade starts, stored as negative dop
|
||||
const delay = Math.random() * 3000;
|
||||
setTimeout(() => {
|
||||
p.dop = -0.02;
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
isExitComplete(): boolean {
|
||||
if (!this.exiting) return false;
|
||||
for (let i = 0; i < this.particles.length; i++) {
|
||||
if (this.particles[i].opacity > 0.01) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.particles = [];
|
||||
}
|
||||
|
||||
private randomColor(): [number, number, number] {
|
||||
return this.palette[Math.floor(Math.random() * this.palette.length)];
|
||||
}
|
||||
|
||||
private getParticleCount(): number {
|
||||
const area = this.width * this.height;
|
||||
return Math.max(20, Math.round(BASE_CONFETTI * (area / BASE_AREA)));
|
||||
}
|
||||
|
||||
private initParticles(): void {
|
||||
this.particles = [];
|
||||
const count = this.getParticleCount();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const baseColor = this.randomColor();
|
||||
const r = ~~range(3, 8);
|
||||
this.particles.push({
|
||||
x: range(-r * 2, this.width - r * 2),
|
||||
y: range(-20, this.height - r * 2),
|
||||
vx: (range(0, 2) + 8 * 0.5 - 5) * SPEED_FACTOR,
|
||||
vy: (0.7 * r + range(-1, 1)) * SPEED_FACTOR,
|
||||
r,
|
||||
color: [...baseColor],
|
||||
baseColor,
|
||||
opacity: 0,
|
||||
dop: 0.03 * range(1, 4) * SPEED_FACTOR,
|
||||
elevation: 0,
|
||||
targetElevation: 0,
|
||||
staggerDelay: i * STAGGER_INTERVAL + range(0, STAGGER_INTERVAL),
|
||||
burst: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private replaceParticle(p: ConfettiParticle): void {
|
||||
p.opacity = 0;
|
||||
p.dop = 0.03 * range(1, 4) * SPEED_FACTOR;
|
||||
p.x = range(-p.r * 2, this.width - p.r * 2);
|
||||
p.y = range(-20, -p.r * 2);
|
||||
p.vx = (range(0, 2) + 8 * this.mouseXNorm - 5) * SPEED_FACTOR;
|
||||
p.vy = (0.7 * p.r + range(-1, 1)) * SPEED_FACTOR;
|
||||
p.elevation = 0;
|
||||
p.targetElevation = 0;
|
||||
p.baseColor = this.randomColor();
|
||||
p.color = [...p.baseColor];
|
||||
p.burst = false;
|
||||
}
|
||||
|
||||
update(deltaTime: number): void {
|
||||
const dt = deltaTime / (1000 / TARGET_FPS);
|
||||
this.elapsed += deltaTime;
|
||||
|
||||
const mouseX = this.mouseX;
|
||||
const mouseY = this.mouseY;
|
||||
|
||||
for (let i = 0; i < this.particles.length; i++) {
|
||||
const p = this.particles[i];
|
||||
|
||||
// Stagger gate
|
||||
if (p.staggerDelay >= 0) {
|
||||
if (this.elapsed >= p.staggerDelay) {
|
||||
p.staggerDelay = -1;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Gravity (capped so falling particles don't accelerate)
|
||||
const maxVy = (0.7 * p.r + 1) * SPEED_FACTOR;
|
||||
if (p.vy < maxVy) {
|
||||
p.vy = Math.min(p.vy + 0.02 * dt, maxVy);
|
||||
}
|
||||
|
||||
// Position update
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
|
||||
// Fade in, or fade out during exit
|
||||
if (this.exiting && p.dop < 0) {
|
||||
p.opacity += p.dop * dt;
|
||||
if (p.opacity < 0) p.opacity = 0;
|
||||
} else if (p.opacity < 1) {
|
||||
p.opacity += Math.abs(p.dop) * dt;
|
||||
if (p.opacity > 1) p.opacity = 1;
|
||||
}
|
||||
|
||||
// Past the bottom: burst particles removed, base particles recycle (or remove during exit)
|
||||
if (p.y > this.height + p.r) {
|
||||
if (p.burst || this.exiting) {
|
||||
this.particles.splice(i, 1);
|
||||
i--;
|
||||
} else {
|
||||
this.replaceParticle(p);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal wrap
|
||||
const xmax = this.width - p.r;
|
||||
if (p.x < 0 || p.x > xmax) {
|
||||
p.x = ((p.x % xmax) + xmax) % xmax;
|
||||
}
|
||||
|
||||
// Mouse proximity elevation
|
||||
const dx = p.x - mouseX;
|
||||
const dy = p.y - mouseY;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist < MOUSE_INFLUENCE_RADIUS && p.opacity > 0.1) {
|
||||
const influenceFactor = Math.cos(
|
||||
(dist / MOUSE_INFLUENCE_RADIUS) * (Math.PI / 2)
|
||||
);
|
||||
p.targetElevation =
|
||||
ELEVATION_FACTOR * influenceFactor * influenceFactor;
|
||||
|
||||
const shift = influenceFactor * COLOR_SHIFT_AMOUNT * 0.5;
|
||||
p.color = [
|
||||
Math.min(255, Math.max(0, p.baseColor[0] + shift)),
|
||||
Math.min(255, Math.max(0, p.baseColor[1] + shift)),
|
||||
Math.min(255, Math.max(0, p.baseColor[2] + shift)),
|
||||
];
|
||||
} else {
|
||||
p.targetElevation = 0;
|
||||
p.color[0] += (p.baseColor[0] - p.color[0]) * 0.1;
|
||||
p.color[1] += (p.baseColor[1] - p.color[1]) * 0.1;
|
||||
p.color[2] += (p.baseColor[2] - p.color[2]) * 0.1;
|
||||
}
|
||||
|
||||
// Elevation lerp
|
||||
p.elevation +=
|
||||
(p.targetElevation - p.elevation) * ELEVATION_LERP_SPEED * dt;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
render(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
_width: number,
|
||||
_height: number
|
||||
): void {
|
||||
for (let i = 0; i < this.particles.length; i++) {
|
||||
const p = this.particles[i];
|
||||
if (p.opacity <= 0.01 || p.staggerDelay >= 0) continue;
|
||||
|
||||
const drawX = ~~p.x;
|
||||
const drawY = ~~p.y - p.elevation;
|
||||
const [r, g, b] = p.color;
|
||||
|
||||
// Shadow
|
||||
if (p.elevation > 0.5) {
|
||||
const shadowAlpha =
|
||||
0.2 * (p.elevation / ELEVATION_FACTOR) * p.opacity;
|
||||
ctx.globalAlpha = shadowAlpha;
|
||||
ctx.fillStyle = "rgb(0,0,0)";
|
||||
ctx.shadowBlur = 2;
|
||||
ctx.shadowColor = "rgba(0,0,0,0.1)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(
|
||||
drawX,
|
||||
drawY + p.elevation * SHADOW_OFFSET_RATIO,
|
||||
p.r,
|
||||
0,
|
||||
PI_2
|
||||
);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.shadowColor = "transparent";
|
||||
}
|
||||
|
||||
// Main circle
|
||||
ctx.globalAlpha = p.opacity;
|
||||
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(drawX, drawY, p.r, 0, PI_2);
|
||||
ctx.fill();
|
||||
|
||||
// Highlight on elevated particles
|
||||
if (p.elevation > 0.5) {
|
||||
const highlightAlpha =
|
||||
0.1 * (p.elevation / ELEVATION_FACTOR) * p.opacity;
|
||||
ctx.globalAlpha = highlightAlpha;
|
||||
ctx.fillStyle = "rgb(255,255,255)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(drawX, drawY, p.r, Math.PI, 0);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
handleResize(width: number, height: number): void {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.elapsed = 0;
|
||||
this.initParticles();
|
||||
}
|
||||
|
||||
handleMouseMove(x: number, y: number, _isDown: boolean): void {
|
||||
this.mouseX = x;
|
||||
this.mouseY = y;
|
||||
if (this.width > 0) {
|
||||
this.mouseXNorm = Math.max(0, Math.min(1, x / this.width));
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseDown(x: number, y: number): void {
|
||||
const count = 12;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const baseColor = this.randomColor();
|
||||
const r = ~~range(3, 8);
|
||||
const angle = (i / count) * PI_2 + range(-0.3, 0.3);
|
||||
const speed = range(0.3, 1.2);
|
||||
this.particles.push({
|
||||
x,
|
||||
y,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
r,
|
||||
color: [...baseColor],
|
||||
baseColor,
|
||||
opacity: 1,
|
||||
dop: this.exiting ? -0.02 : 0,
|
||||
elevation: 0,
|
||||
targetElevation: 0,
|
||||
staggerDelay: -1,
|
||||
burst: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseUp(): void {}
|
||||
|
||||
handleMouseLeave(): void {
|
||||
this.mouseX = -1000;
|
||||
this.mouseY = -1000;
|
||||
this.mouseXNorm = 0.5;
|
||||
}
|
||||
|
||||
updatePalette(palette: [number, number, number][], _bgColor: string): void {
|
||||
this.palette = palette;
|
||||
for (let i = 0; i < this.particles.length; i++) {
|
||||
this.particles[i].baseColor = palette[i % palette.length];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,670 +0,0 @@
|
||||
import type { AnimationEngine } from "@/lib/animations/types";
|
||||
|
||||
interface Cell {
|
||||
alive: boolean;
|
||||
next: boolean;
|
||||
color: [number, number, number];
|
||||
baseColor: [number, number, number];
|
||||
currentX: number;
|
||||
currentY: number;
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
opacity: number;
|
||||
targetOpacity: number;
|
||||
scale: number;
|
||||
targetScale: number;
|
||||
elevation: number;
|
||||
targetElevation: number;
|
||||
transitioning: boolean;
|
||||
transitionComplete: boolean;
|
||||
rippleEffect: number;
|
||||
rippleStartTime: number;
|
||||
rippleDistance: number;
|
||||
}
|
||||
|
||||
interface Grid {
|
||||
cells: Cell[][];
|
||||
cols: number;
|
||||
rows: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
}
|
||||
|
||||
const CELL_SIZE_MOBILE = 15;
|
||||
const CELL_SIZE_DESKTOP = 25;
|
||||
const TARGET_FPS = 60;
|
||||
const CYCLE_TIME = 3000;
|
||||
const TRANSITION_SPEED = 0.05;
|
||||
const SCALE_SPEED = 0.05;
|
||||
const INITIAL_DENSITY = 0.15;
|
||||
const MOUSE_INFLUENCE_RADIUS = 150;
|
||||
const COLOR_SHIFT_AMOUNT = 30;
|
||||
const RIPPLE_ELEVATION_FACTOR = 4;
|
||||
const ELEVATION_FACTOR = 8;
|
||||
|
||||
export class GameOfLifeEngine implements AnimationEngine {
|
||||
id = "game-of-life";
|
||||
name = "Game of Life";
|
||||
|
||||
private grid: Grid | null = null;
|
||||
private palette: [number, number, number][] = [];
|
||||
private bgColor = "rgb(0, 0, 0)";
|
||||
private mouseX = -1000;
|
||||
private mouseY = -1000;
|
||||
private mouseIsDown = false;
|
||||
private mouseCellX = -1;
|
||||
private mouseCellY = -1;
|
||||
private lastCycleTime = 0;
|
||||
private timeAccumulator = 0;
|
||||
private pendingTimeouts: ReturnType<typeof setTimeout>[] = [];
|
||||
private canvasWidth = 0;
|
||||
private canvasHeight = 0;
|
||||
private exiting = false;
|
||||
|
||||
init(
|
||||
width: number,
|
||||
height: number,
|
||||
palette: [number, number, number][],
|
||||
bgColor: string
|
||||
): void {
|
||||
this.palette = palette;
|
||||
this.bgColor = bgColor;
|
||||
this.canvasWidth = width;
|
||||
this.canvasHeight = height;
|
||||
this.lastCycleTime = 0;
|
||||
this.timeAccumulator = 0;
|
||||
this.grid = this.initGrid(width, height);
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
for (const id of this.pendingTimeouts) {
|
||||
clearTimeout(id);
|
||||
}
|
||||
this.pendingTimeouts = [];
|
||||
this.grid = null;
|
||||
}
|
||||
|
||||
private getCellSize(): number {
|
||||
return this.canvasWidth <= 768 ? CELL_SIZE_MOBILE : CELL_SIZE_DESKTOP;
|
||||
}
|
||||
|
||||
private randomColor(): [number, number, number] {
|
||||
return this.palette[Math.floor(Math.random() * this.palette.length)];
|
||||
}
|
||||
|
||||
private initGrid(width: number, height: number): Grid {
|
||||
const cellSize = this.getCellSize();
|
||||
const cols = Math.floor(width / cellSize);
|
||||
const rows = Math.floor(height / cellSize);
|
||||
const offsetX = Math.floor((width - cols * cellSize) / 2);
|
||||
const offsetY = Math.floor((height - rows * cellSize) / 2);
|
||||
|
||||
const cells = Array(cols)
|
||||
.fill(0)
|
||||
.map((_, i) =>
|
||||
Array(rows)
|
||||
.fill(0)
|
||||
.map((_, j) => {
|
||||
const baseColor = this.randomColor();
|
||||
return {
|
||||
alive: Math.random() < INITIAL_DENSITY,
|
||||
next: false,
|
||||
color: [...baseColor] as [number, number, number],
|
||||
baseColor,
|
||||
currentX: i,
|
||||
currentY: j,
|
||||
targetX: i,
|
||||
targetY: j,
|
||||
opacity: 0,
|
||||
targetOpacity: 0,
|
||||
scale: 0,
|
||||
targetScale: 0,
|
||||
elevation: 0,
|
||||
targetElevation: 0,
|
||||
transitioning: false,
|
||||
transitionComplete: false,
|
||||
rippleEffect: 0,
|
||||
rippleStartTime: 0,
|
||||
rippleDistance: 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const grid = { cells, cols, rows, offsetX, offsetY };
|
||||
this.computeNextState(grid);
|
||||
|
||||
for (let i = 0; i < cols; i++) {
|
||||
for (let j = 0; j < rows; j++) {
|
||||
const cell = cells[i][j];
|
||||
if (cell.next) {
|
||||
cell.alive = true;
|
||||
const tid = setTimeout(() => {
|
||||
cell.targetOpacity = 1;
|
||||
cell.targetScale = 1;
|
||||
}, Math.random() * 1000);
|
||||
this.pendingTimeouts.push(tid);
|
||||
} else {
|
||||
cell.alive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return grid;
|
||||
}
|
||||
|
||||
private countNeighbors(
|
||||
grid: Grid,
|
||||
x: number,
|
||||
y: number
|
||||
): { count: number; colors: [number, number, number][] } {
|
||||
const neighbors = { count: 0, colors: [] as [number, number, number][] };
|
||||
|
||||
for (let i = -1; i <= 1; i++) {
|
||||
for (let j = -1; j <= 1; j++) {
|
||||
if (i === 0 && j === 0) continue;
|
||||
|
||||
const col = (x + i + grid.cols) % grid.cols;
|
||||
const row = (y + j + grid.rows) % grid.rows;
|
||||
|
||||
if (grid.cells[col][row].alive) {
|
||||
neighbors.count++;
|
||||
neighbors.colors.push(grid.cells[col][row].baseColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return neighbors;
|
||||
}
|
||||
|
||||
private averageColors(
|
||||
colors: [number, number, number][]
|
||||
): [number, number, number] {
|
||||
if (colors.length === 0) return [0, 0, 0];
|
||||
const sum = colors.reduce(
|
||||
(acc, color) => [acc[0] + color[0], acc[1] + color[1], acc[2] + color[2]],
|
||||
[0, 0, 0]
|
||||
);
|
||||
return [
|
||||
Math.round(sum[0] / colors.length),
|
||||
Math.round(sum[1] / colors.length),
|
||||
Math.round(sum[2] / colors.length),
|
||||
];
|
||||
}
|
||||
|
||||
private computeNextState(grid: Grid): void {
|
||||
for (let i = 0; i < grid.cols; i++) {
|
||||
for (let j = 0; j < grid.rows; j++) {
|
||||
const cell = grid.cells[i][j];
|
||||
const { count, colors } = this.countNeighbors(grid, i, j);
|
||||
|
||||
if (cell.alive) {
|
||||
cell.next = count === 2 || count === 3;
|
||||
} else {
|
||||
cell.next = count === 3;
|
||||
if (cell.next) {
|
||||
cell.baseColor = this.averageColors(colors);
|
||||
cell.color = [...cell.baseColor];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < grid.cols; i++) {
|
||||
for (let j = 0; j < grid.rows; j++) {
|
||||
const cell = grid.cells[i][j];
|
||||
|
||||
if (cell.alive !== cell.next && !cell.transitioning) {
|
||||
cell.transitioning = true;
|
||||
cell.transitionComplete = false;
|
||||
|
||||
const delay = Math.random() * 800;
|
||||
const tid = setTimeout(() => {
|
||||
if (!cell.next) {
|
||||
cell.targetScale = 0;
|
||||
cell.targetOpacity = 0;
|
||||
cell.targetElevation = 0;
|
||||
} else {
|
||||
cell.targetScale = 1;
|
||||
cell.targetOpacity = 1;
|
||||
cell.targetElevation = 0;
|
||||
}
|
||||
}, delay);
|
||||
this.pendingTimeouts.push(tid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createRippleEffect(
|
||||
grid: Grid,
|
||||
centerX: number,
|
||||
centerY: number
|
||||
): void {
|
||||
const currentTime = Date.now();
|
||||
|
||||
for (let i = 0; i < grid.cols; i++) {
|
||||
for (let j = 0; j < grid.rows; j++) {
|
||||
const cell = grid.cells[i][j];
|
||||
|
||||
const dx = i - centerX;
|
||||
const dy = j - centerY;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (cell.opacity > 0.1) {
|
||||
cell.rippleStartTime = currentTime + distance * 100;
|
||||
cell.rippleDistance = distance;
|
||||
cell.rippleEffect = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private spawnCellAtPosition(grid: Grid, x: number, y: number): void {
|
||||
if (x >= 0 && x < grid.cols && y >= 0 && y < grid.rows) {
|
||||
const cell = grid.cells[x][y];
|
||||
|
||||
if (!cell.alive && !cell.transitioning) {
|
||||
cell.alive = true;
|
||||
cell.next = true;
|
||||
cell.transitioning = true;
|
||||
cell.transitionComplete = false;
|
||||
cell.baseColor = this.randomColor();
|
||||
cell.color = [...cell.baseColor];
|
||||
cell.targetScale = 1;
|
||||
cell.targetOpacity = 1;
|
||||
cell.targetElevation = 0;
|
||||
|
||||
this.createRippleEffect(grid, x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beginExit(): void {
|
||||
if (this.exiting || !this.grid) return;
|
||||
this.exiting = true;
|
||||
|
||||
// Cancel all pending GOL transitions so they don't revive cells
|
||||
for (const id of this.pendingTimeouts) {
|
||||
clearTimeout(id);
|
||||
}
|
||||
this.pendingTimeouts = [];
|
||||
|
||||
const grid = this.grid;
|
||||
for (let i = 0; i < grid.cols; i++) {
|
||||
for (let j = 0; j < grid.rows; j++) {
|
||||
const cell = grid.cells[i][j];
|
||||
// Force cell into dying state, clear any pending transition
|
||||
cell.next = false;
|
||||
cell.transitioning = false;
|
||||
cell.transitionComplete = false;
|
||||
|
||||
if (cell.opacity > 0.01) {
|
||||
const delay = Math.random() * 3000;
|
||||
const tid = setTimeout(() => {
|
||||
cell.targetOpacity = 0;
|
||||
cell.targetScale = 0;
|
||||
cell.targetElevation = 0;
|
||||
}, delay);
|
||||
this.pendingTimeouts.push(tid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isExitComplete(): boolean {
|
||||
if (!this.exiting) return false;
|
||||
if (!this.grid) return true;
|
||||
|
||||
const grid = this.grid;
|
||||
for (let i = 0; i < grid.cols; i++) {
|
||||
for (let j = 0; j < grid.rows; j++) {
|
||||
if (grid.cells[i][j].opacity > 0.01) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
update(deltaTime: number): void {
|
||||
if (!this.grid) return;
|
||||
|
||||
if (!this.exiting) {
|
||||
this.timeAccumulator += deltaTime;
|
||||
if (this.timeAccumulator >= CYCLE_TIME) {
|
||||
this.computeNextState(this.grid);
|
||||
this.timeAccumulator -= CYCLE_TIME;
|
||||
}
|
||||
}
|
||||
|
||||
this.updateCellAnimations(this.grid, deltaTime);
|
||||
}
|
||||
|
||||
private updateCellAnimations(grid: Grid, deltaTime: number): void {
|
||||
const mouseX = this.mouseX;
|
||||
const mouseY = this.mouseY;
|
||||
const cellSize = this.getCellSize();
|
||||
|
||||
const transitionFactor =
|
||||
TRANSITION_SPEED * (deltaTime / (1000 / TARGET_FPS));
|
||||
const scaleFactor = SCALE_SPEED * (deltaTime / (1000 / TARGET_FPS));
|
||||
|
||||
for (let i = 0; i < grid.cols; i++) {
|
||||
for (let j = 0; j < grid.rows; j++) {
|
||||
const cell = grid.cells[i][j];
|
||||
|
||||
cell.opacity += (cell.targetOpacity - cell.opacity) * transitionFactor;
|
||||
cell.scale += (cell.targetScale - cell.scale) * scaleFactor;
|
||||
cell.elevation +=
|
||||
(cell.targetElevation - cell.elevation) * scaleFactor;
|
||||
|
||||
const cellCenterX = grid.offsetX + i * cellSize + cellSize / 2;
|
||||
const cellCenterY = grid.offsetY + j * cellSize + cellSize / 2;
|
||||
const dx = cellCenterX - mouseX;
|
||||
const dy = cellCenterY - mouseY;
|
||||
const distanceToMouse = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distanceToMouse < MOUSE_INFLUENCE_RADIUS && cell.opacity > 0.1) {
|
||||
const influenceFactor = Math.cos(
|
||||
(distanceToMouse / MOUSE_INFLUENCE_RADIUS) * (Math.PI / 2)
|
||||
);
|
||||
cell.targetElevation =
|
||||
ELEVATION_FACTOR * influenceFactor * influenceFactor;
|
||||
|
||||
const colorShift = influenceFactor * COLOR_SHIFT_AMOUNT * 0.5;
|
||||
cell.color = [
|
||||
Math.min(255, Math.max(0, cell.baseColor[0] + colorShift)),
|
||||
Math.min(255, Math.max(0, cell.baseColor[1] + colorShift)),
|
||||
Math.min(255, Math.max(0, cell.baseColor[2] + colorShift)),
|
||||
] as [number, number, number];
|
||||
} else {
|
||||
cell.color[0] += (cell.baseColor[0] - cell.color[0]) * 0.1;
|
||||
cell.color[1] += (cell.baseColor[1] - cell.color[1]) * 0.1;
|
||||
cell.color[2] += (cell.baseColor[2] - cell.color[2]) * 0.1;
|
||||
|
||||
cell.targetElevation = 0;
|
||||
}
|
||||
|
||||
// During exit: snap to zero once close enough
|
||||
if (this.exiting) {
|
||||
if (cell.opacity < 0.05) {
|
||||
cell.opacity = 0;
|
||||
cell.scale = 0;
|
||||
cell.elevation = 0;
|
||||
cell.alive = false;
|
||||
}
|
||||
} else if (cell.transitioning) {
|
||||
if (!cell.next && cell.opacity < 0.01 && cell.scale < 0.01) {
|
||||
cell.alive = false;
|
||||
cell.transitioning = false;
|
||||
cell.transitionComplete = true;
|
||||
cell.opacity = 0;
|
||||
cell.scale = 0;
|
||||
cell.elevation = 0;
|
||||
} else if (cell.next && !cell.alive && !cell.transitionComplete) {
|
||||
cell.alive = true;
|
||||
cell.transitioning = false;
|
||||
cell.transitionComplete = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (cell.rippleStartTime > 0) {
|
||||
const elapsedTime = Date.now() - cell.rippleStartTime;
|
||||
if (elapsedTime > 0) {
|
||||
const rippleProgress = elapsedTime / 1000;
|
||||
|
||||
if (rippleProgress < 1) {
|
||||
const wavePhase = rippleProgress * Math.PI * 2;
|
||||
const waveHeight =
|
||||
Math.sin(wavePhase) * Math.exp(-rippleProgress * 4);
|
||||
|
||||
if (distanceToMouse >= MOUSE_INFLUENCE_RADIUS) {
|
||||
cell.rippleEffect = waveHeight;
|
||||
cell.targetElevation = RIPPLE_ELEVATION_FACTOR * waveHeight;
|
||||
} else {
|
||||
cell.rippleEffect = waveHeight * 0.3;
|
||||
}
|
||||
} else {
|
||||
cell.rippleEffect = 0;
|
||||
cell.rippleStartTime = 0;
|
||||
if (distanceToMouse >= MOUSE_INFLUENCE_RADIUS) {
|
||||
cell.targetElevation = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number
|
||||
): void {
|
||||
if (!this.grid) return;
|
||||
|
||||
const grid = this.grid;
|
||||
const cellSize = this.getCellSize();
|
||||
const displayCellSize = cellSize * 0.8;
|
||||
const roundness = displayCellSize * 0.2;
|
||||
|
||||
for (let i = 0; i < grid.cols; i++) {
|
||||
for (let j = 0; j < grid.rows; j++) {
|
||||
const cell = grid.cells[i][j];
|
||||
if (
|
||||
(cell.alive || cell.targetOpacity > 0) &&
|
||||
cell.opacity > 0.01
|
||||
) {
|
||||
const [r, g, b] = cell.color;
|
||||
|
||||
ctx.globalAlpha = cell.opacity * 0.9;
|
||||
|
||||
const scaledSize = displayCellSize * cell.scale;
|
||||
const xOffset = (displayCellSize - scaledSize) / 2;
|
||||
const yOffset = (displayCellSize - scaledSize) / 2;
|
||||
|
||||
const elevationOffset = cell.elevation;
|
||||
|
||||
const x =
|
||||
grid.offsetX +
|
||||
i * cellSize +
|
||||
(cellSize - displayCellSize) / 2 +
|
||||
xOffset;
|
||||
const y =
|
||||
grid.offsetY +
|
||||
j * cellSize +
|
||||
(cellSize - displayCellSize) / 2 +
|
||||
yOffset -
|
||||
elevationOffset;
|
||||
const scaledRoundness = roundness * cell.scale;
|
||||
|
||||
// Shadow for 3D effect
|
||||
if (elevationOffset > 0.5) {
|
||||
ctx.fillStyle = `rgba(0, 0, 0, ${0.2 * (elevationOffset / ELEVATION_FACTOR)})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + scaledRoundness, y + elevationOffset * 1.1);
|
||||
ctx.lineTo(
|
||||
x + scaledSize - scaledRoundness,
|
||||
y + elevationOffset * 1.1
|
||||
);
|
||||
ctx.quadraticCurveTo(
|
||||
x + scaledSize,
|
||||
y + elevationOffset * 1.1,
|
||||
x + scaledSize,
|
||||
y + elevationOffset * 1.1 + scaledRoundness
|
||||
);
|
||||
ctx.lineTo(
|
||||
x + scaledSize,
|
||||
y + elevationOffset * 1.1 + scaledSize - scaledRoundness
|
||||
);
|
||||
ctx.quadraticCurveTo(
|
||||
x + scaledSize,
|
||||
y + elevationOffset * 1.1 + scaledSize,
|
||||
x + scaledSize - scaledRoundness,
|
||||
y + elevationOffset * 1.1 + scaledSize
|
||||
);
|
||||
ctx.lineTo(
|
||||
x + scaledRoundness,
|
||||
y + elevationOffset * 1.1 + scaledSize
|
||||
);
|
||||
ctx.quadraticCurveTo(
|
||||
x,
|
||||
y + elevationOffset * 1.1 + scaledSize,
|
||||
x,
|
||||
y + elevationOffset * 1.1 + scaledSize - scaledRoundness
|
||||
);
|
||||
ctx.lineTo(x, y + elevationOffset * 1.1 + scaledRoundness);
|
||||
ctx.quadraticCurveTo(
|
||||
x,
|
||||
y + elevationOffset * 1.1,
|
||||
x + scaledRoundness,
|
||||
y + elevationOffset * 1.1
|
||||
);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Main cell
|
||||
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + scaledRoundness, y);
|
||||
ctx.lineTo(x + scaledSize - scaledRoundness, y);
|
||||
ctx.quadraticCurveTo(x + scaledSize, y, x + scaledSize, y + scaledRoundness);
|
||||
ctx.lineTo(x + scaledSize, y + scaledSize - scaledRoundness);
|
||||
ctx.quadraticCurveTo(
|
||||
x + scaledSize,
|
||||
y + scaledSize,
|
||||
x + scaledSize - scaledRoundness,
|
||||
y + scaledSize
|
||||
);
|
||||
ctx.lineTo(x + scaledRoundness, y + scaledSize);
|
||||
ctx.quadraticCurveTo(x, y + scaledSize, x, y + scaledSize - scaledRoundness);
|
||||
ctx.lineTo(x, y + scaledRoundness);
|
||||
ctx.quadraticCurveTo(x, y, x + scaledRoundness, y);
|
||||
ctx.fill();
|
||||
|
||||
// Highlight on elevated cells
|
||||
if (elevationOffset > 0.5) {
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${0.1 * (elevationOffset / ELEVATION_FACTOR)})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + scaledRoundness, y);
|
||||
ctx.lineTo(x + scaledSize - scaledRoundness, y);
|
||||
ctx.quadraticCurveTo(
|
||||
x + scaledSize,
|
||||
y,
|
||||
x + scaledSize,
|
||||
y + scaledRoundness
|
||||
);
|
||||
ctx.lineTo(x + scaledSize, y + scaledSize / 3);
|
||||
ctx.lineTo(x, y + scaledSize / 3);
|
||||
ctx.lineTo(x, y + scaledRoundness);
|
||||
ctx.quadraticCurveTo(x, y, x + scaledRoundness, y);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
handleResize(width: number, height: number): void {
|
||||
this.canvasWidth = width;
|
||||
this.canvasHeight = height;
|
||||
const cellSize = this.getCellSize();
|
||||
if (
|
||||
!this.grid ||
|
||||
this.grid.cols !== Math.floor(width / cellSize) ||
|
||||
this.grid.rows !== Math.floor(height / cellSize)
|
||||
) {
|
||||
for (const id of this.pendingTimeouts) {
|
||||
clearTimeout(id);
|
||||
}
|
||||
this.pendingTimeouts = [];
|
||||
this.grid = this.initGrid(width, height);
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseMove(x: number, y: number, isDown: boolean): void {
|
||||
this.mouseX = x;
|
||||
this.mouseY = y;
|
||||
this.mouseIsDown = isDown;
|
||||
|
||||
if (isDown && this.grid && !this.exiting) {
|
||||
const grid = this.grid;
|
||||
const cellSize = this.getCellSize();
|
||||
const cellX = Math.floor((x - grid.offsetX) / cellSize);
|
||||
const cellY = Math.floor((y - grid.offsetY) / cellSize);
|
||||
|
||||
if (cellX !== this.mouseCellX || cellY !== this.mouseCellY) {
|
||||
this.mouseCellX = cellX;
|
||||
this.mouseCellY = cellY;
|
||||
|
||||
if (
|
||||
cellX >= 0 &&
|
||||
cellX < grid.cols &&
|
||||
cellY >= 0 &&
|
||||
cellY < grid.rows
|
||||
) {
|
||||
const cell = grid.cells[cellX][cellY];
|
||||
if (!cell.alive && !cell.transitioning) {
|
||||
this.spawnCellAtPosition(grid, cellX, cellY);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseDown(x: number, y: number): void {
|
||||
this.mouseIsDown = true;
|
||||
|
||||
if (!this.grid || this.exiting) return;
|
||||
const grid = this.grid;
|
||||
const cellSize = this.getCellSize();
|
||||
|
||||
const cellX = Math.floor((x - grid.offsetX) / cellSize);
|
||||
const cellY = Math.floor((y - grid.offsetY) / cellSize);
|
||||
|
||||
if (
|
||||
cellX >= 0 &&
|
||||
cellX < grid.cols &&
|
||||
cellY >= 0 &&
|
||||
cellY < grid.rows
|
||||
) {
|
||||
this.mouseCellX = cellX;
|
||||
this.mouseCellY = cellY;
|
||||
|
||||
const cell = grid.cells[cellX][cellY];
|
||||
if (cell.alive) {
|
||||
this.createRippleEffect(grid, cellX, cellY);
|
||||
} else {
|
||||
this.spawnCellAtPosition(grid, cellX, cellY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseUp(): void {
|
||||
this.mouseIsDown = false;
|
||||
}
|
||||
|
||||
handleMouseLeave(): void {
|
||||
this.mouseIsDown = false;
|
||||
this.mouseX = -1000;
|
||||
this.mouseY = -1000;
|
||||
}
|
||||
|
||||
updatePalette(palette: [number, number, number][], bgColor: string): void {
|
||||
this.palette = palette;
|
||||
this.bgColor = bgColor;
|
||||
|
||||
if (this.grid) {
|
||||
const grid = this.grid;
|
||||
for (let i = 0; i < grid.cols; i++) {
|
||||
for (let j = 0; j < grid.rows; j++) {
|
||||
const cell = grid.cells[i][j];
|
||||
if (cell.alive && cell.opacity > 0.01) {
|
||||
cell.baseColor = palette[(i * grid.rows + j) % palette.length];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,520 +0,0 @@
|
||||
import type { AnimationEngine } from "@/lib/animations/types";
|
||||
|
||||
interface Blob {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
baseRadius: number;
|
||||
radiusScale: number;
|
||||
targetRadiusScale: number;
|
||||
color: [number, number, number];
|
||||
targetColor: [number, number, number];
|
||||
phase: number;
|
||||
phaseSpeed: number;
|
||||
staggerDelay: number; // -1 means already revealed
|
||||
}
|
||||
|
||||
const BLOB_COUNT = 26;
|
||||
const BASE_MAX_BLOBS = 80; // at 1080p; scales with canvas area
|
||||
const MIN_SPEED = 0.1;
|
||||
const MAX_SPEED = 0.35;
|
||||
const RESOLUTION_SCALE = 5; // render at 1/5 resolution (was 1/4)
|
||||
const FIELD_THRESHOLD = 1.0;
|
||||
const SMOOTHSTEP_RANGE = 0.25;
|
||||
const MOUSE_REPEL_RADIUS = 150;
|
||||
const MOUSE_REPEL_FORCE = 0.2;
|
||||
const COLOR_LERP_SPEED = 0.02;
|
||||
const DRIFT_AMPLITUDE = 0.2;
|
||||
const RADIUS_LERP_SPEED = 0.06;
|
||||
const STAGGER_INTERVAL = 60;
|
||||
const CYCLE_MIN_MS = 2000; // min time between natural spawn/despawn
|
||||
const CYCLE_MAX_MS = 5000; // max time
|
||||
|
||||
function smoothstep(edge0: number, edge1: number, x: number): number {
|
||||
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
export class LavaLampEngine implements AnimationEngine {
|
||||
id = "lava-lamp";
|
||||
name = "Lava Lamp";
|
||||
|
||||
private blobs: Blob[] = [];
|
||||
private palette: [number, number, number][] = [];
|
||||
private bgRgb: [number, number, number] = [0, 0, 0];
|
||||
private width = 0;
|
||||
private height = 0;
|
||||
private mouseX = -1000;
|
||||
private mouseY = -1000;
|
||||
private offCanvas: HTMLCanvasElement | null = null;
|
||||
private offCtx: CanvasRenderingContext2D | null = null;
|
||||
private shadowCanvas: HTMLCanvasElement | null = null;
|
||||
private shadowCtx: CanvasRenderingContext2D | null = null;
|
||||
private elapsed = 0;
|
||||
private nextCycleTime = 0;
|
||||
private exiting = false;
|
||||
|
||||
// Pre-allocated typed arrays for the inner render loop (avoid per-frame GC)
|
||||
private blobX: Float64Array = new Float64Array(0);
|
||||
private blobY: Float64Array = new Float64Array(0);
|
||||
private blobR: Float64Array = new Float64Array(0);
|
||||
private blobCR: Float64Array = new Float64Array(0);
|
||||
private blobCG: Float64Array = new Float64Array(0);
|
||||
private blobCB: Float64Array = new Float64Array(0);
|
||||
private activeBlobCount = 0;
|
||||
|
||||
init(
|
||||
width: number,
|
||||
height: number,
|
||||
palette: [number, number, number][],
|
||||
bgColor: string
|
||||
): void {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.palette = palette;
|
||||
this.parseBgColor(bgColor);
|
||||
this.elapsed = 0;
|
||||
this.nextCycleTime = CYCLE_MIN_MS + Math.random() * (CYCLE_MAX_MS - CYCLE_MIN_MS);
|
||||
this.initBlobs();
|
||||
this.initOffscreenCanvas();
|
||||
}
|
||||
|
||||
private parseBgColor(bgColor: string): void {
|
||||
const match = bgColor.match(/(\d+)/g);
|
||||
if (match && match.length >= 3) {
|
||||
this.bgRgb = [parseInt(match[0]), parseInt(match[1]), parseInt(match[2])];
|
||||
}
|
||||
}
|
||||
|
||||
private getMaxBlobs(): number {
|
||||
const area = this.width * this.height;
|
||||
const scale = area / 2_073_600; // normalize to 1080p
|
||||
return Math.max(BASE_MAX_BLOBS, Math.round(BASE_MAX_BLOBS * scale));
|
||||
}
|
||||
|
||||
private getRadiusRange(): { min: number; max: number } {
|
||||
const area = this.width * this.height;
|
||||
const scale = Math.sqrt(area / 2_073_600);
|
||||
const min = Math.max(8, Math.round(25 * scale));
|
||||
const max = Math.max(15, Math.round(65 * scale));
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
private makeBlob(x: number, y: number, radiusOverride?: number): Blob {
|
||||
const { min, max } = this.getRadiusRange();
|
||||
const color = this.palette[
|
||||
Math.floor(Math.random() * this.palette.length)
|
||||
] || [128, 128, 128];
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
vx: (Math.random() - 0.5) * 2 * MAX_SPEED,
|
||||
vy: (Math.random() - 0.5) * 2 * MAX_SPEED,
|
||||
baseRadius: radiusOverride ?? (min + Math.random() * (max - min)),
|
||||
radiusScale: 0,
|
||||
targetRadiusScale: 1,
|
||||
color: [...color],
|
||||
targetColor: [...color],
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
phaseSpeed: 0.0005 + Math.random() * 0.001,
|
||||
staggerDelay: -1,
|
||||
};
|
||||
}
|
||||
|
||||
private initBlobs(): void {
|
||||
this.blobs = [];
|
||||
const { max } = this.getRadiusRange();
|
||||
const minDist = max * 2.5; // minimum distance between blob centers
|
||||
|
||||
for (let i = 0; i < BLOB_COUNT; i++) {
|
||||
let x: number, y: number;
|
||||
let attempts = 0;
|
||||
|
||||
// Try to find a position that doesn't overlap existing blobs
|
||||
do {
|
||||
x = Math.random() * this.width;
|
||||
y = Math.random() * this.height;
|
||||
attempts++;
|
||||
} while (attempts < 50 && this.tooCloseToExisting(x, y, minDist));
|
||||
|
||||
const blob = this.makeBlob(x, y);
|
||||
blob.targetRadiusScale = 0;
|
||||
blob.staggerDelay = i * STAGGER_INTERVAL + Math.random() * STAGGER_INTERVAL;
|
||||
this.blobs.push(blob);
|
||||
}
|
||||
}
|
||||
|
||||
private tooCloseToExisting(x: number, y: number, minDist: number): boolean {
|
||||
for (const blob of this.blobs) {
|
||||
const dx = blob.x - x;
|
||||
const dy = blob.y - y;
|
||||
if (dx * dx + dy * dy < minDist * minDist) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private initOffscreenCanvas(): void {
|
||||
const rw = Math.ceil(this.width / RESOLUTION_SCALE);
|
||||
const rh = Math.ceil(this.height / RESOLUTION_SCALE);
|
||||
|
||||
this.offCanvas = document.createElement("canvas");
|
||||
this.offCanvas.width = rw;
|
||||
this.offCanvas.height = rh;
|
||||
this.offCtx = this.offCanvas.getContext("2d", { willReadFrequently: true });
|
||||
|
||||
this.shadowCanvas = document.createElement("canvas");
|
||||
this.shadowCanvas.width = rw;
|
||||
this.shadowCanvas.height = rh;
|
||||
this.shadowCtx = this.shadowCanvas.getContext("2d", {
|
||||
willReadFrequently: true,
|
||||
});
|
||||
}
|
||||
|
||||
beginExit(): void {
|
||||
if (this.exiting) return;
|
||||
this.exiting = true;
|
||||
|
||||
for (let i = 0; i < this.blobs.length; i++) {
|
||||
const blob = this.blobs[i];
|
||||
if (blob.staggerDelay >= 0) {
|
||||
blob.staggerDelay = -1;
|
||||
}
|
||||
// Stagger the shrink over ~2 seconds
|
||||
setTimeout(() => {
|
||||
blob.targetRadiusScale = 0;
|
||||
}, Math.random() * 2000);
|
||||
}
|
||||
}
|
||||
|
||||
isExitComplete(): boolean {
|
||||
if (!this.exiting) return false;
|
||||
return this.blobs.length === 0;
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.blobs = [];
|
||||
this.offCanvas = null;
|
||||
this.offCtx = null;
|
||||
this.shadowCanvas = null;
|
||||
this.shadowCtx = null;
|
||||
}
|
||||
|
||||
/** Snapshot active blob data into flat typed arrays for fast inner-loop access */
|
||||
private syncBlobArrays(): void {
|
||||
const blobs = this.blobs;
|
||||
const n = blobs.length;
|
||||
|
||||
// Grow arrays if needed
|
||||
if (this.blobX.length < n) {
|
||||
const cap = n + 32;
|
||||
this.blobX = new Float64Array(cap);
|
||||
this.blobY = new Float64Array(cap);
|
||||
this.blobR = new Float64Array(cap);
|
||||
this.blobCR = new Float64Array(cap);
|
||||
this.blobCG = new Float64Array(cap);
|
||||
this.blobCB = new Float64Array(cap);
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const b = blobs[i];
|
||||
const r = b.baseRadius * b.radiusScale;
|
||||
if (r < 1) continue; // skip invisible blobs entirely
|
||||
this.blobX[count] = b.x;
|
||||
this.blobY[count] = b.y;
|
||||
this.blobR[count] = r;
|
||||
this.blobCR[count] = b.color[0];
|
||||
this.blobCG[count] = b.color[1];
|
||||
this.blobCB[count] = b.color[2];
|
||||
count++;
|
||||
}
|
||||
this.activeBlobCount = count;
|
||||
}
|
||||
|
||||
update(deltaTime: number): void {
|
||||
const dt = deltaTime / (1000 / 60);
|
||||
this.elapsed += deltaTime;
|
||||
|
||||
for (const blob of this.blobs) {
|
||||
// Staggered load-in
|
||||
if (blob.staggerDelay >= 0) {
|
||||
if (this.elapsed >= blob.staggerDelay) {
|
||||
blob.targetRadiusScale = 1;
|
||||
blob.staggerDelay = -1;
|
||||
}
|
||||
}
|
||||
|
||||
blob.radiusScale +=
|
||||
(blob.targetRadiusScale - blob.radiusScale) * RADIUS_LERP_SPEED * dt;
|
||||
|
||||
blob.phase += blob.phaseSpeed * deltaTime;
|
||||
const driftX = Math.sin(blob.phase) * DRIFT_AMPLITUDE;
|
||||
const driftY = Math.cos(blob.phase * 0.7) * DRIFT_AMPLITUDE;
|
||||
|
||||
blob.vx += driftX * dt * 0.01;
|
||||
blob.vy += driftY * dt * 0.01;
|
||||
blob.vx += (Math.random() - 0.5) * 0.008 * dt;
|
||||
blob.vy += (Math.random() - 0.5) * 0.008 * dt;
|
||||
|
||||
const speed = Math.sqrt(blob.vx * blob.vx + blob.vy * blob.vy);
|
||||
if (speed > MAX_SPEED) {
|
||||
blob.vx = (blob.vx / speed) * MAX_SPEED;
|
||||
blob.vy = (blob.vy / speed) * MAX_SPEED;
|
||||
}
|
||||
if (speed < MIN_SPEED) {
|
||||
const angle = Math.atan2(blob.vy, blob.vx);
|
||||
blob.vx = Math.cos(angle) * MIN_SPEED;
|
||||
blob.vy = Math.sin(angle) * MIN_SPEED;
|
||||
}
|
||||
|
||||
blob.x += blob.vx * dt;
|
||||
blob.y += blob.vy * dt;
|
||||
|
||||
const pad = blob.baseRadius * 0.3;
|
||||
if (blob.x < -pad) { blob.x = -pad; blob.vx = Math.abs(blob.vx) * 0.8; }
|
||||
if (blob.x > this.width + pad) { blob.x = this.width + pad; blob.vx = -Math.abs(blob.vx) * 0.8; }
|
||||
if (blob.y < -pad) { blob.y = -pad; blob.vy = Math.abs(blob.vy) * 0.8; }
|
||||
if (blob.y > this.height + pad) { blob.y = this.height + pad; blob.vy = -Math.abs(blob.vy) * 0.8; }
|
||||
|
||||
// Mouse repulsion
|
||||
const dx = blob.x - this.mouseX;
|
||||
const dy = blob.y - this.mouseY;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < MOUSE_REPEL_RADIUS && dist > 0) {
|
||||
const force = (1 - dist / MOUSE_REPEL_RADIUS) * MOUSE_REPEL_FORCE * dt;
|
||||
blob.vx += (dx / dist) * force;
|
||||
blob.vy += (dy / dist) * force;
|
||||
}
|
||||
|
||||
for (let c = 0; c < 3; c++) {
|
||||
blob.color[c] += (blob.targetColor[c] - blob.color[c]) * COLOR_LERP_SPEED * dt;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove blobs that have fully shrunk away (but not ones still waiting to stagger in)
|
||||
for (let i = this.blobs.length - 1; i >= 0; i--) {
|
||||
const b = this.blobs[i];
|
||||
if (b.targetRadiusScale === 0 && b.radiusScale < 0.01 && b.staggerDelay < 0) {
|
||||
this.blobs.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Natural spawn/despawn cycle — keeps the scene alive
|
||||
if (!this.exiting && this.elapsed >= this.nextCycleTime) {
|
||||
// Pick a random visible blob to fade out (skip ones still staggering in)
|
||||
const visible = [];
|
||||
for (let i = 0; i < this.blobs.length; i++) {
|
||||
if (this.blobs[i].radiusScale > 0.5 && this.blobs[i].staggerDelay < 0) {
|
||||
visible.push(i);
|
||||
}
|
||||
}
|
||||
if (visible.length > 0) {
|
||||
const killIdx = visible[Math.floor(Math.random() * visible.length)];
|
||||
this.blobs[killIdx].targetRadiusScale = 0;
|
||||
}
|
||||
|
||||
// Spawn a fresh one at a random position
|
||||
const blob = this.makeBlob(
|
||||
Math.random() * this.width,
|
||||
Math.random() * this.height
|
||||
);
|
||||
this.blobs.push(blob);
|
||||
|
||||
// Schedule next cycle
|
||||
this.nextCycleTime = this.elapsed + CYCLE_MIN_MS + Math.random() * (CYCLE_MAX_MS - CYCLE_MIN_MS);
|
||||
}
|
||||
|
||||
// Prune excess blobs (keep the initial set, drop oldest user-spawned ones)
|
||||
const maxBlobs = this.getMaxBlobs();
|
||||
if (this.blobs.length > maxBlobs) {
|
||||
this.blobs.splice(BLOB_COUNT, this.blobs.length - maxBlobs);
|
||||
}
|
||||
}
|
||||
|
||||
render(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number
|
||||
): void {
|
||||
if (!this.offCtx || !this.offCanvas || !this.shadowCtx || !this.shadowCanvas)
|
||||
return;
|
||||
|
||||
// Snapshot blob positions/radii into typed arrays for fast pixel loop
|
||||
this.syncBlobArrays();
|
||||
|
||||
const rw = this.offCanvas.width;
|
||||
const rh = this.offCanvas.height;
|
||||
|
||||
// Render shadow layer
|
||||
const shadowData = this.shadowCtx.createImageData(rw, rh);
|
||||
this.renderField(shadowData, rw, rh, true);
|
||||
this.shadowCtx.putImageData(shadowData, 0, 0);
|
||||
|
||||
// Render main layer
|
||||
const imageData = this.offCtx.createImageData(rw, rh);
|
||||
this.renderField(imageData, rw, rh, false);
|
||||
this.offCtx.putImageData(imageData, 0, 0);
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = "medium";
|
||||
|
||||
ctx.globalAlpha = 0.2;
|
||||
ctx.drawImage(this.shadowCanvas, 0, 4, width, height);
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.drawImage(this.offCanvas, 0, 0, width, height);
|
||||
}
|
||||
|
||||
private renderField(
|
||||
imageData: ImageData,
|
||||
rw: number,
|
||||
rh: number,
|
||||
isShadow: boolean
|
||||
): void {
|
||||
const data = imageData.data;
|
||||
const threshold = isShadow ? FIELD_THRESHOLD * 0.75 : FIELD_THRESHOLD;
|
||||
const bgR = this.bgRgb[0];
|
||||
const bgG = this.bgRgb[1];
|
||||
const bgB = this.bgRgb[2];
|
||||
const scale = RESOLUTION_SCALE;
|
||||
const n = this.activeBlobCount;
|
||||
const bx = this.blobX;
|
||||
const by = this.blobY;
|
||||
const br = this.blobR;
|
||||
const bcr = this.blobCR;
|
||||
const bcg = this.blobCG;
|
||||
const bcb = this.blobCB;
|
||||
const threshLow = threshold - SMOOTHSTEP_RANGE;
|
||||
|
||||
for (let py = 0; py < rh; py++) {
|
||||
const wy = py * scale;
|
||||
for (let px = 0; px < rw; px++) {
|
||||
const wx = px * scale;
|
||||
|
||||
let fieldSum = 0;
|
||||
let weightedR = 0;
|
||||
let weightedG = 0;
|
||||
let weightedB = 0;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const dx = wx - bx[i];
|
||||
const dy = wy - by[i];
|
||||
const distSq = dx * dx + dy * dy;
|
||||
const ri = br[i];
|
||||
const rSq = ri * ri;
|
||||
// Raw metaball field
|
||||
const raw = rSq / (distSq + rSq * 0.1);
|
||||
// Cap per-blob contribution so color stays flat inside the blob
|
||||
const contribution = raw > 2 ? 2 : raw;
|
||||
|
||||
fieldSum += contribution;
|
||||
|
||||
if (contribution > 0.01) {
|
||||
weightedR += bcr[i] * contribution;
|
||||
weightedG += bcg[i] * contribution;
|
||||
weightedB += bcb[i] * contribution;
|
||||
}
|
||||
}
|
||||
|
||||
const idx = (py * rw + px) << 2;
|
||||
|
||||
if (fieldSum > threshLow) {
|
||||
const alpha = smoothstep(threshLow, threshold, fieldSum);
|
||||
|
||||
if (isShadow) {
|
||||
data[idx] = 0;
|
||||
data[idx + 1] = 0;
|
||||
data[idx + 2] = 0;
|
||||
data[idx + 3] = (alpha * 150) | 0;
|
||||
} else {
|
||||
const invField = 1 / fieldSum;
|
||||
const r = Math.min(255, (weightedR * invField) | 0);
|
||||
const g = Math.min(255, (weightedG * invField) | 0);
|
||||
const b = Math.min(255, (weightedB * invField) | 0);
|
||||
|
||||
data[idx] = bgR + (r - bgR) * alpha;
|
||||
data[idx + 1] = bgG + (g - bgG) * alpha;
|
||||
data[idx + 2] = bgB + (b - bgB) * alpha;
|
||||
data[idx + 3] = 255;
|
||||
}
|
||||
} else {
|
||||
if (isShadow) {
|
||||
// data stays 0 (already zeroed by createImageData)
|
||||
} else {
|
||||
data[idx] = bgR;
|
||||
data[idx + 1] = bgG;
|
||||
data[idx + 2] = bgB;
|
||||
data[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleResize(width: number, height: number): void {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.elapsed = 0;
|
||||
this.exiting = false;
|
||||
this.nextCycleTime = CYCLE_MIN_MS + Math.random() * (CYCLE_MAX_MS - CYCLE_MIN_MS);
|
||||
this.initBlobs();
|
||||
this.initOffscreenCanvas();
|
||||
}
|
||||
|
||||
private sampleColorAt(x: number, y: number): [number, number, number] | null {
|
||||
let closest: Blob | null = null;
|
||||
let closestDist = Infinity;
|
||||
|
||||
for (const blob of this.blobs) {
|
||||
const dx = blob.x - x;
|
||||
const dy = blob.y - y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < blob.baseRadius * 1.5 && dist < closestDist) {
|
||||
closestDist = dist;
|
||||
closest = blob;
|
||||
}
|
||||
}
|
||||
|
||||
return closest ? ([...closest.color] as [number, number, number]) : null;
|
||||
}
|
||||
|
||||
private spawnAt(x: number, y: number): void {
|
||||
const { max } = this.getRadiusRange();
|
||||
const blob = this.makeBlob(x, y, max * (0.8 + Math.random() * 0.4));
|
||||
const nearby = this.sampleColorAt(x, y);
|
||||
if (nearby) {
|
||||
blob.color = nearby;
|
||||
blob.targetColor = [...nearby];
|
||||
}
|
||||
this.blobs.push(blob);
|
||||
}
|
||||
|
||||
handleMouseMove(x: number, y: number, _isDown: boolean): void {
|
||||
this.mouseX = x;
|
||||
this.mouseY = y;
|
||||
}
|
||||
|
||||
handleMouseDown(x: number, y: number): void {
|
||||
if (this.exiting) return;
|
||||
this.spawnAt(x, y);
|
||||
}
|
||||
|
||||
handleMouseUp(): void {}
|
||||
|
||||
handleMouseLeave(): void {
|
||||
this.mouseX = -1000;
|
||||
this.mouseY = -1000;
|
||||
}
|
||||
|
||||
updatePalette(palette: [number, number, number][], bgColor: string): void {
|
||||
this.palette = palette;
|
||||
this.parseBgColor(bgColor);
|
||||
|
||||
for (let i = 0; i < this.blobs.length; i++) {
|
||||
this.blobs[i].targetColor = [
|
||||
...palette[i % palette.length],
|
||||
] as [number, number, number];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,480 +0,0 @@
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,358 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { GameOfLifeEngine } from "@/components/background/engines/game-of-life";
|
||||
import { LavaLampEngine } from "@/components/background/engines/lava-lamp";
|
||||
import { ConfettiEngine } from "@/components/background/engines/confetti";
|
||||
import { AsciiquariumEngine } from "@/components/background/engines/asciiquarium";
|
||||
import { PipesEngine } from "@/components/background/engines/pipes";
|
||||
import { ShuffleEngine } from "@/components/background/engines/shuffle";
|
||||
import { getStoredAnimationId } from "@/lib/animations/engine";
|
||||
import type { AnimationEngine } from "@/lib/animations/types";
|
||||
import type { AnimationId } from "@/lib/animations";
|
||||
|
||||
const SIDEBAR_WIDTH = 240;
|
||||
|
||||
const FALLBACK_PALETTE: [number, number, number][] = [
|
||||
[204, 36, 29], [152, 151, 26], [215, 153, 33],
|
||||
[69, 133, 136], [177, 98, 134], [104, 157, 106],
|
||||
[251, 73, 52], [184, 187, 38], [250, 189, 47],
|
||||
[131, 165, 152], [211, 134, 155], [142, 192, 124],
|
||||
];
|
||||
|
||||
function createEngine(id: AnimationId): AnimationEngine {
|
||||
switch (id) {
|
||||
case "lava-lamp":
|
||||
return new LavaLampEngine();
|
||||
case "confetti":
|
||||
return new ConfettiEngine();
|
||||
case "asciiquarium":
|
||||
return new AsciiquariumEngine();
|
||||
case "pipes":
|
||||
return new PipesEngine();
|
||||
case "shuffle":
|
||||
return new ShuffleEngine();
|
||||
case "game-of-life":
|
||||
default:
|
||||
return new GameOfLifeEngine();
|
||||
}
|
||||
}
|
||||
|
||||
function readPaletteFromCSS(): [number, number, number][] {
|
||||
try {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const keys = [
|
||||
"--color-red", "--color-green", "--color-yellow",
|
||||
"--color-blue", "--color-purple", "--color-aqua",
|
||||
"--color-red-bright", "--color-green-bright", "--color-yellow-bright",
|
||||
"--color-blue-bright", "--color-purple-bright", "--color-aqua-bright",
|
||||
];
|
||||
const palette: [number, number, number][] = [];
|
||||
for (const key of keys) {
|
||||
const val = style.getPropertyValue(key).trim();
|
||||
if (val) {
|
||||
const parts = val.split(" ").map(Number);
|
||||
if (parts.length === 3 && parts.every((n) => !isNaN(n))) {
|
||||
palette.push([parts[0], parts[1], parts[2]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return palette.length > 0 ? palette : FALLBACK_PALETTE;
|
||||
} catch {
|
||||
return FALLBACK_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
function readBgFromCSS(): string {
|
||||
try {
|
||||
const val = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--color-background")
|
||||
.trim();
|
||||
if (val) {
|
||||
const [r, g, b] = val.split(" ");
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
} catch {}
|
||||
return "rgb(0, 0, 0)";
|
||||
}
|
||||
|
||||
interface BackgroundProps {
|
||||
layout?: "index" | "sidebar" | "content";
|
||||
position?: "left" | "right";
|
||||
mobileOnly?: boolean;
|
||||
}
|
||||
|
||||
const Background: React.FC<BackgroundProps> = ({
|
||||
layout = "index",
|
||||
position = "left",
|
||||
mobileOnly = false,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const engineRef = useRef<AnimationEngine | null>(null);
|
||||
const animationFrameRef = useRef<number>();
|
||||
const lastUpdateTimeRef = useRef<number>(0);
|
||||
const resizeTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const dimensionsRef = useRef({ width: 0, height: 0 });
|
||||
|
||||
const setupCanvas = (
|
||||
canvas: HTMLCanvasElement,
|
||||
width: number,
|
||||
height: number
|
||||
) => {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = width * dpr;
|
||||
canvas.height = height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
|
||||
return ctx;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
|
||||
const displayWidth =
|
||||
layout === "index" ? window.innerWidth : SIDEBAR_WIDTH;
|
||||
const displayHeight = window.innerHeight;
|
||||
dimensionsRef.current = { width: displayWidth, height: displayHeight };
|
||||
|
||||
const ctx = setupCanvas(canvas, displayWidth, displayHeight);
|
||||
if (!ctx) return;
|
||||
|
||||
const palette = readPaletteFromCSS();
|
||||
const bgColor = readBgFromCSS();
|
||||
|
||||
// Initialize engine
|
||||
if (!engineRef.current) {
|
||||
const animId = getStoredAnimationId();
|
||||
engineRef.current = createEngine(animId);
|
||||
engineRef.current.init(displayWidth, displayHeight, palette, bgColor);
|
||||
}
|
||||
|
||||
// Handle animation switching
|
||||
const handleAnimationChanged = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (!detail?.id) return;
|
||||
|
||||
if (engineRef.current) {
|
||||
engineRef.current.cleanup();
|
||||
}
|
||||
|
||||
const w = layout === "index" ? window.innerWidth : SIDEBAR_WIDTH;
|
||||
const h = window.innerHeight;
|
||||
engineRef.current = createEngine(detail.id);
|
||||
engineRef.current.init(w, h, readPaletteFromCSS(), readBgFromCSS());
|
||||
};
|
||||
|
||||
document.addEventListener("animation-changed", handleAnimationChanged, {
|
||||
signal,
|
||||
});
|
||||
|
||||
// Handle theme changes — only update if palette actually changed
|
||||
let currentPalette = palette;
|
||||
const handleThemeChanged = () => {
|
||||
const newPalette = readPaletteFromCSS();
|
||||
const newBg = readBgFromCSS();
|
||||
const same =
|
||||
newPalette.length === currentPalette.length &&
|
||||
newPalette.every(
|
||||
(c, i) =>
|
||||
c[0] === currentPalette[i][0] &&
|
||||
c[1] === currentPalette[i][1] &&
|
||||
c[2] === currentPalette[i][2]
|
||||
);
|
||||
if (!same && engineRef.current) {
|
||||
currentPalette = newPalette;
|
||||
engineRef.current.updatePalette(newPalette, newBg);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("theme-changed", handleThemeChanged, { signal });
|
||||
|
||||
// Handle resize
|
||||
const handleResize = () => {
|
||||
if (signal.aborted) return;
|
||||
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
|
||||
resizeTimeoutRef.current = setTimeout(() => {
|
||||
if (signal.aborted) return;
|
||||
|
||||
const w = layout === "index" ? window.innerWidth : SIDEBAR_WIDTH;
|
||||
const h = window.innerHeight;
|
||||
|
||||
const newCtx = setupCanvas(canvas, w, h);
|
||||
if (!newCtx) return;
|
||||
|
||||
lastUpdateTimeRef.current = 0;
|
||||
dimensionsRef.current = { width: w, height: h };
|
||||
|
||||
if (engineRef.current) {
|
||||
engineRef.current.handleResize(w, h);
|
||||
}
|
||||
}, 250);
|
||||
};
|
||||
|
||||
// Mouse events
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (!engineRef.current || !canvas) return;
|
||||
|
||||
// Don't spawn when clicking interactive elements
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("a, button, [role='button'], input, select, textarea, label, [onclick], [tabindex]")) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
if (
|
||||
mouseX < 0 ||
|
||||
mouseX > rect.width ||
|
||||
mouseY < 0 ||
|
||||
mouseY > rect.height
|
||||
)
|
||||
return;
|
||||
|
||||
e.preventDefault();
|
||||
engineRef.current.handleMouseDown(mouseX, mouseY);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!engineRef.current || !canvas) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
engineRef.current.handleMouseMove(mouseX, mouseY, e.buttons === 1);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (engineRef.current) {
|
||||
engineRef.current.handleMouseUp();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (engineRef.current) {
|
||||
engineRef.current.handleMouseLeave();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("mousedown", handleMouseDown, { signal });
|
||||
window.addEventListener("mousemove", handleMouseMove, { signal });
|
||||
window.addEventListener("mouseup", handleMouseUp, { signal });
|
||||
|
||||
// Visibility change
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = undefined;
|
||||
}
|
||||
} else {
|
||||
if (!animationFrameRef.current) {
|
||||
lastUpdateTimeRef.current = performance.now();
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Animation loop
|
||||
const animate = (currentTime: number) => {
|
||||
if (signal.aborted) return;
|
||||
|
||||
if (!lastUpdateTimeRef.current) {
|
||||
lastUpdateTimeRef.current = currentTime;
|
||||
}
|
||||
|
||||
const deltaTime = currentTime - lastUpdateTimeRef.current;
|
||||
const clampedDeltaTime = Math.min(deltaTime, 100);
|
||||
lastUpdateTimeRef.current = currentTime;
|
||||
|
||||
const engine = engineRef.current;
|
||||
if (engine) {
|
||||
engine.update(clampedDeltaTime);
|
||||
|
||||
// Clear canvas
|
||||
const bg = readBgFromCSS();
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const { width: rw, height: rh } = dimensionsRef.current;
|
||||
engine.render(ctx, rw, rh);
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange, {
|
||||
signal,
|
||||
});
|
||||
window.addEventListener("resize", handleResize, { signal });
|
||||
animate(performance.now());
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [layout]);
|
||||
|
||||
const isIndex = layout === "index";
|
||||
const isSidebar = !isIndex;
|
||||
|
||||
const getContainerStyle = (): React.CSSProperties => {
|
||||
if (isIndex) return {};
|
||||
// Fade the inner edge so blobs don't hard-cut at the content boundary
|
||||
return {
|
||||
maskImage:
|
||||
position === "left"
|
||||
? "linear-gradient(to right, black 60%, transparent 100%)"
|
||||
: "linear-gradient(to left, black 60%, transparent 100%)",
|
||||
WebkitMaskImage:
|
||||
position === "left"
|
||||
? "linear-gradient(to right, black 60%, transparent 100%)"
|
||||
: "linear-gradient(to left, black 60%, transparent 100%)",
|
||||
};
|
||||
};
|
||||
|
||||
const getContainerClasses = () => {
|
||||
if (isIndex) {
|
||||
return mobileOnly
|
||||
? "fixed inset-0 -z-10 desk:hidden"
|
||||
: "fixed inset-0 -z-10";
|
||||
}
|
||||
|
||||
const baseClasses = "fixed top-0 bottom-0 hidden desk:block -z-10";
|
||||
return position === "left"
|
||||
? `${baseClasses} left-0`
|
||||
: `${baseClasses} right-0`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={getContainerClasses()} style={getContainerStyle()}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full bg-background"
|
||||
style={{ cursor: "default" }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-background/30 backdrop-blur-sm pointer-events-none" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Background;
|
||||
@@ -1,59 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
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-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 & Writings
|
||||
</h1>
|
||||
</AnimateIn>
|
||||
<AnimateIn delay={100}>
|
||||
<div className="flex flex-wrap justify-center gap-4 mb-12 text-sm sm:text-base">
|
||||
<a
|
||||
href="/rss"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-background border border-foreground/20 text-orange hover:text-orange-bright hover:border-orange/50 transition-colors duration-200"
|
||||
>
|
||||
<RssIcon className="w-4 h-4" />
|
||||
<span>RSS Feed</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/blog/tags"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-background border border-foreground/20 text-aqua hover:text-aqua-bright hover:border-aqua/50 transition-colors duration-200"
|
||||
>
|
||||
<TagIcon className="w-4 h-4" />
|
||||
<span>Browse Tags</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/blog/popular"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-background border border-foreground/20 text-blue hover:text-blue-bright hover:border-blue/50 transition-colors duration-200"
|
||||
>
|
||||
<TrendingUpIcon className="w-4 h-4" />
|
||||
<span>Most Popular</span>
|
||||
</a>
|
||||
</div>
|
||||
</AnimateIn>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,101 +0,0 @@
|
||||
import { AnimateIn } from "@/components/animate-in";
|
||||
|
||||
type BlogPost = {
|
||||
id: string;
|
||||
data: {
|
||||
title: string;
|
||||
author: string;
|
||||
date: string;
|
||||
tags: string[];
|
||||
description: string;
|
||||
image?: string;
|
||||
imagePosition?: string;
|
||||
};
|
||||
};
|
||||
|
||||
interface BlogPostListProps {
|
||||
posts: BlogPost[];
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric"
|
||||
});
|
||||
};
|
||||
|
||||
export const BlogPostList = ({ posts }: BlogPostListProps) => {
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto">
|
||||
<ul className="space-y-6 md:space-y-10">
|
||||
{posts.map((post, i) => (
|
||||
<AnimateIn key={post.id} delay={i * 80}>
|
||||
<li className="group px-4 md:px-0">
|
||||
<a
|
||||
href={`/blog/${post.id}`}
|
||||
className="block"
|
||||
>
|
||||
<article className="flex flex-col md:flex-row md:items-center gap-4 md:gap-8 p-2 md:p-4 border-b border-foreground/20 last:border-b-0 rounded-lg group-hover:outline group-hover:outline-2 group-hover:outline-purple transition-[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
|
||||
src={post.data.image || "/blog/placeholder.png"}
|
||||
alt={post.data.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
style={{ objectPosition: post.data.imagePosition || "center center" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content container */}
|
||||
<div className="w-full md:w-2/3 flex flex-col gap-2 md:gap-3">
|
||||
{/* Title and meta info */}
|
||||
<div className="space-y-1.5 md:space-y-3">
|
||||
<h2 className="text-lg md:text-2xl font-semibold text-yellow group-hover:text-purple transition-colors duration-200 line-clamp-2">
|
||||
{post.data.title}
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 md:gap-3 text-sm md:text-base text-foreground/80">
|
||||
<span className="text-orange">{post.data.author}</span>
|
||||
<span className="text-foreground/50">•</span>
|
||||
<time dateTime={post.data.date} className="text-blue">
|
||||
{formatDate(post.data.date)}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-foreground/90 text-sm md:text-lg leading-relaxed line-clamp-2 mt-0.5 md:mt-0">
|
||||
{post.data.description}
|
||||
</p>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-1.5 md:gap-3 mt-1 md:mt-2">
|
||||
{post.data.tags.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-xs md:text-base text-aqua hover:text-aqua-bright transition-colors duration-200"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.location.href = `/blog/tags/${encodeURIComponent(tag)}`;
|
||||
}}
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
{post.data.tags.length > 3 && (
|
||||
<span className="text-xs md:text-base text-foreground/60">
|
||||
+{post.data.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
</li>
|
||||
</AnimateIn>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,92 +0,0 @@
|
||||
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;
|
||||
@@ -1,123 +0,0 @@
|
||||
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">•</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;
|
||||
@@ -1,665 +0,0 @@
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import { AnimateIn } from "@/components/animate-in";
|
||||
|
||||
interface ProjectListProps {
|
||||
projects: CollectionEntry<"projects">[];
|
||||
}
|
||||
|
||||
export function ProjectList({ projects }: ProjectListProps) {
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto pt-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 building lately
|
||||
</h1>
|
||||
</AnimateIn>
|
||||
|
||||
<ul className="space-y-6 md:space-y-10">
|
||||
{projects.map((project, i) => (
|
||||
<AnimateIn key={project.id} delay={i * 80}>
|
||||
<li className="group">
|
||||
<a href={`/projects/${project.id}`} className="block">
|
||||
<article className="flex flex-col md:flex-row md:items-center gap-4 md:gap-8 p-2 md:p-4 border-b border-foreground/20 last:border-b-0 rounded-lg group-hover:outline group-hover:outline-2 group-hover:outline-blue transition-[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 ? (
|
||||
<img
|
||||
src={project.data.image}
|
||||
alt={`${project.data.title} preview`}
|
||||
className="w-full h-full object-cover object-center group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-foreground/30">
|
||||
<span className="text-sm">No preview available</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="w-full md:w-2/3 flex flex-col gap-2 md:gap-3">
|
||||
<h2 className="text-lg md:text-2xl font-semibold text-yellow group-hover:text-blue transition-colors duration-200 line-clamp-2">
|
||||
{project.data.title}
|
||||
</h2>
|
||||
|
||||
<p className="text-foreground/90 text-sm md:text-lg leading-relaxed line-clamp-2">
|
||||
{project.data.description}
|
||||
</p>
|
||||
|
||||
{/* Tech stack */}
|
||||
<div className="flex flex-wrap gap-1.5 md:gap-2 mt-1">
|
||||
{project.data.techStack.map((tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
className="text-xs md:text-sm px-2 py-0.5 rounded-full bg-purple-bright/10 text-purple-bright"
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
{(project.data.githubUrl || project.data.demoUrl) && (
|
||||
<div className="flex gap-4 mt-1">
|
||||
{project.data.githubUrl && (
|
||||
<span
|
||||
className="text-sm text-foreground/50 hover:text-blue-bright transition-colors"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.open(project.data.githubUrl, "_blank");
|
||||
}}
|
||||
>
|
||||
Source
|
||||
</span>
|
||||
)}
|
||||
{project.data.demoUrl && (
|
||||
<span
|
||||
className="text-sm text-foreground/50 hover:text-green-bright transition-colors"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.open(project.data.demoUrl, "_blank");
|
||||
}}
|
||||
>
|
||||
Live
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
</li>
|
||||
</AnimateIn>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
FileDown,
|
||||
Github,
|
||||
Linkedin,
|
||||
Globe
|
||||
} from "lucide-react";
|
||||
import { useTypewriter, useScrollVisible } from "@/components/typed-text";
|
||||
|
||||
// --- Section fade-in ---
|
||||
|
||||
function Section({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
|
||||
const { ref, visible } = useScrollVisible();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="transition-[opacity,transform] duration-700 ease-out"
|
||||
style={{
|
||||
transitionDelay: `${delay}ms`,
|
||||
willChange: "transform, opacity",
|
||||
opacity: visible ? 1 : 0,
|
||||
transform: visible ? "translate3d(0,0,0)" : "translate3d(0,24px,0)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Typed heading + fade-in body ---
|
||||
|
||||
function TypedSection({
|
||||
heading,
|
||||
headingClass = "text-2xl md:text-3xl font-bold text-yellow-bright",
|
||||
children,
|
||||
}: {
|
||||
heading: string;
|
||||
headingClass?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { ref, visible } = useScrollVisible();
|
||||
const { displayed, done } = useTypewriter(heading, visible, 20);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="space-y-4">
|
||||
<h3 className={headingClass} style={{ minHeight: "1.2em" }}>
|
||||
{visible ? displayed : "\u00A0"}
|
||||
{visible && !done && <span className="animate-pulse text-foreground/40">|</span>}
|
||||
</h3>
|
||||
<div
|
||||
className="transition-[opacity,transform] duration-500 ease-out"
|
||||
style={{
|
||||
willChange: "transform, opacity",
|
||||
opacity: done ? 1 : 0,
|
||||
transform: done ? "translate3d(0,0,0)" : "translate3d(0,12px,0)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Staggered skill tags ---
|
||||
|
||||
function SkillTags({ skills, trigger }: { skills: string[]; trigger: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{skills.map((skill, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="border border-foreground/20 px-4 py-2 rounded-lg hover:border-foreground/40 transition-[opacity,transform] duration-500 ease-out"
|
||||
style={{
|
||||
transitionDelay: `${i * 60}ms`,
|
||||
willChange: "transform, opacity",
|
||||
opacity: trigger ? 1 : 0,
|
||||
transform: trigger ? "translate3d(0,0,0) scale(1)" : "translate3d(0,12px,0) scale(0.95)",
|
||||
}}
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Data ---
|
||||
|
||||
const resumeData = {
|
||||
name: "Timothy Pidashev",
|
||||
title: "Software Engineer",
|
||||
contact: {
|
||||
email: "contact@timmypidashev.dev",
|
||||
phone: "+1 (360) 409-0357",
|
||||
location: "Camas, WA",
|
||||
linkedin: "linkedin.com/in/timothy-pidashev-4353812b8/",
|
||||
github: "github.com/timmypidashev"
|
||||
},
|
||||
summary: "Experienced software engineer with a passion for building scalable web applications and solving complex problems. Specialized in React, TypeScript, and modern web technologies.",
|
||||
experience: [
|
||||
{
|
||||
title: "Office Manager & Tutor",
|
||||
company: "FHCC",
|
||||
location: "Ridgefield, WA",
|
||||
period: "2020 - Present",
|
||||
achievements: [
|
||||
"Tutored Python, JavaScript, and HTML to students in grades 4-10, successfully fostering early programming skills",
|
||||
"Designed and deployed a full-stack CRUD application to manage organizational operations",
|
||||
"Engineered and implemented building-wide networking infrastructure and managed multiple service deployments",
|
||||
"Maintained student records and administrative paperwork."
|
||||
]
|
||||
}
|
||||
],
|
||||
contractWork: [
|
||||
{
|
||||
title: "Revive Auto Parts",
|
||||
type: "Full-Stack Development & Maintenance",
|
||||
startDate: "2024",
|
||||
url: "https://reviveauto.parts",
|
||||
responsibilities: [
|
||||
"Maintain and optimize website performance and security",
|
||||
"Implement new features and functionality as needed",
|
||||
"Provide 24/7 monitoring and emergency support"
|
||||
],
|
||||
achievements: [
|
||||
"Designed and built the entire application from the ground up, including auth",
|
||||
"Engineered a tagging system to optimize search results by keywords and relativity",
|
||||
"Implemented a filter provider to further narrow down search results and enhance the user experience",
|
||||
"Created a smooth and responsive infinitely scrollable listings page",
|
||||
"Automated deployment & testing processes reducing downtime by 60%"
|
||||
]
|
||||
}
|
||||
],
|
||||
education: [
|
||||
{
|
||||
degree: "B.S. Computer Science",
|
||||
school: "Clark College",
|
||||
location: "Vancouver, WA",
|
||||
period: "Graduating 2026",
|
||||
achievements: [] as string[]
|
||||
}
|
||||
],
|
||||
skills: {
|
||||
technical: ["JavaScript", "TypeScript", "React", "Node.js", "Python", "SQL", "AWS", "Docker", "C", "Go", "Rust"],
|
||||
soft: ["Leadership", "Problem Solving", "Communication", "Agile Methodologies"]
|
||||
},
|
||||
};
|
||||
|
||||
// --- Component ---
|
||||
|
||||
const Resume = () => {
|
||||
const handleDownloadPDF = () => {
|
||||
window.open("/timothy-pidashev-resume.pdf", "_blank");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-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-3xl md:text-6xl font-bold text-aqua-bright">{resumeData.name}</h1>
|
||||
</Section>
|
||||
<Section delay={150}>
|
||||
<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-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>
|
||||
<a href={`tel:${resumeData.contact.phone}`} className="hover:text-foreground transition-colors duration-200">
|
||||
{resumeData.contact.phone}
|
||||
</a>
|
||||
<span className="hidden md:inline">•</span>
|
||||
<span>{resumeData.contact.location}</span>
|
||||
</div>
|
||||
</Section>
|
||||
<Section delay={450}>
|
||||
<div className="flex justify-center items-center gap-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"
|
||||
>
|
||||
<Github size={18} />
|
||||
GitHub
|
||||
</a>
|
||||
<a href={`https://${resumeData.contact.linkedin}`}
|
||||
target="_blank"
|
||||
className="text-blue-bright hover:text-blue transition-colors duration-200 flex items-center gap-2"
|
||||
>
|
||||
<Linkedin size={18} />
|
||||
LinkedIn
|
||||
</a>
|
||||
<button
|
||||
onClick={handleDownloadPDF}
|
||||
className="text-blue-bright hover:text-blue transition-colors duration-200 flex items-center gap-2"
|
||||
>
|
||||
<FileDown size={18} />
|
||||
Resume
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
</header>
|
||||
|
||||
{/* Summary */}
|
||||
<TypedSection heading="Professional Summary">
|
||||
<p className="text-base md:text-xl leading-relaxed">{resumeData.summary}</p>
|
||||
</TypedSection>
|
||||
|
||||
{/* Experience */}
|
||||
<TypedSection heading="Experience">
|
||||
<div className="space-y-8">
|
||||
{resumeData.experience.map((exp, index) => (
|
||||
<Section key={index} delay={index * 100}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-2">
|
||||
<div>
|
||||
<h4 className="text-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-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-base md:text-lg leading-relaxed">{a}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Section>
|
||||
))}
|
||||
</div>
|
||||
</TypedSection>
|
||||
|
||||
{/* Contract Work */}
|
||||
<TypedSection heading="Contract Work">
|
||||
<div className="space-y-8">
|
||||
{resumeData.contractWork.map((project, index) => (
|
||||
<Section key={index} delay={index * 100}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h4 className="text-xl md:text-2xl font-semibold text-green-bright">{project.title}</h4>
|
||||
{project.url && (
|
||||
<a
|
||||
href={project.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-bright hover:text-blue transition-colors duration-200 opacity-60 hover:opacity-100"
|
||||
>
|
||||
<Globe size={16} strokeWidth={1.5} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-foreground/60 text-base md:text-lg">{project.type}</div>
|
||||
</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-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-base md:text-lg leading-relaxed">{r}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{project.achievements && (
|
||||
<div>
|
||||
<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-base md:text-lg leading-relaxed">{a}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
))}
|
||||
</div>
|
||||
</TypedSection>
|
||||
|
||||
{/* Skills */}
|
||||
<SkillsSection />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Skills section ---
|
||||
|
||||
function SkillsSection() {
|
||||
const { ref, visible } = useScrollVisible();
|
||||
const { displayed, done } = useTypewriter("Skills", visible, 20);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="space-y-8">
|
||||
<h3 className="text-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-[opacity,transform] duration-500 ease-out"
|
||||
style={{
|
||||
willChange: "transform, opacity",
|
||||
opacity: done ? 1 : 0,
|
||||
transform: done ? "translate3d(0,0,0)" : "translate3d(0,12px,0)",
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<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-xl md:text-2xl font-semibold text-green-bright">Soft Skills</h4>
|
||||
<SkillTags skills={resumeData.skills.soft} trigger={done} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Resume;
|
||||
@@ -1,110 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
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 }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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,
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { Phase } from "../types";
|
||||
import { addVoidPhase, VOID_SEGMENT_COUNT } from "./void";
|
||||
|
||||
export { addVoidPhase };
|
||||
|
||||
export const PHASE_SEGMENT_COUNTS: Record<Phase, number> = {
|
||||
void: VOID_SEGMENT_COUNT,
|
||||
};
|
||||
@@ -1,132 +0,0 @@
|
||||
import type { TypewriterInstance, Segment } from "../types";
|
||||
import { buildSegments, T1 } from "../types";
|
||||
import { VOID } from "../palette";
|
||||
|
||||
export function createVoidSegments(visitCount: number): Segment[] {
|
||||
return [
|
||||
// 0
|
||||
{
|
||||
html: `<span>so this is it</span>`,
|
||||
pause: 3500,
|
||||
delay: T1,
|
||||
},
|
||||
// 1
|
||||
{
|
||||
html: `<span>the void</span>`,
|
||||
pause: 4000,
|
||||
delay: T1,
|
||||
},
|
||||
// 2
|
||||
{
|
||||
html: `<span>not much here</span>`,
|
||||
pause: 3000,
|
||||
},
|
||||
// 3
|
||||
{
|
||||
html: `<span>just dark water</span>`,
|
||||
pause: 4000,
|
||||
delay: T1,
|
||||
},
|
||||
// 4
|
||||
{
|
||||
html: `<span>you sat through the whole thing though</span>`,
|
||||
pause: 3500,
|
||||
prePause: 1500,
|
||||
},
|
||||
// 5
|
||||
{
|
||||
html: `<span>the countdown and everything</span>`,
|
||||
pause: 3000,
|
||||
},
|
||||
// 6
|
||||
{
|
||||
html: `<span>imagine if you took that energy</span>`,
|
||||
pause: 3000,
|
||||
prePause: 1500,
|
||||
},
|
||||
// 7
|
||||
{
|
||||
html: `<span>and pointed it at something that matters</span>`,
|
||||
pause: 3500,
|
||||
delay: T1,
|
||||
},
|
||||
// 8 — the line that lands
|
||||
{
|
||||
html: `<span>you'd be <span style="color:${VOID.red}">dangerous</span></span>`,
|
||||
pause: 4500,
|
||||
delay: T1,
|
||||
prePause: 1000,
|
||||
},
|
||||
// 9
|
||||
{
|
||||
html: `<span>seriously</span>`,
|
||||
pause: 2500,
|
||||
delay: T1,
|
||||
prePause: 1500,
|
||||
},
|
||||
// 10
|
||||
{
|
||||
html: `<span>don't waste that potential</span>`,
|
||||
pause: 3000,
|
||||
},
|
||||
// 11
|
||||
{
|
||||
html: `<span>go build something cool</span>`,
|
||||
pause: 4000,
|
||||
delay: T1,
|
||||
},
|
||||
// 12 — deflection
|
||||
{
|
||||
html: `<span>anyway</span>`,
|
||||
pause: 3000,
|
||||
delay: T1,
|
||||
prePause: 2000,
|
||||
},
|
||||
// 13 — visitor count (corruption picks up)
|
||||
{
|
||||
html: `<span>you're visitor <span style="color:${VOID.gold}">#${Math.max(visitCount, 1)}</span></span>`,
|
||||
pause: 4000,
|
||||
delay: T1,
|
||||
prePause: 1500,
|
||||
},
|
||||
// 14 — unstable
|
||||
{
|
||||
html: `<span>this void is pretty unstable though</span>`,
|
||||
pause: 3000,
|
||||
prePause: 1000,
|
||||
},
|
||||
// 15 — resigned
|
||||
{
|
||||
html: `<span>ah well</span>`,
|
||||
pause: 2500,
|
||||
delay: T1,
|
||||
prePause: 1000,
|
||||
},
|
||||
// 16 — goodbye
|
||||
{
|
||||
html: `<span>it's been nice knowing ya</span>`,
|
||||
pause: 2500,
|
||||
delay: T1,
|
||||
},
|
||||
// 17 — cut off, void wins
|
||||
{
|
||||
html: `<span>see you on the other si</span>`,
|
||||
pause: 500,
|
||||
delay: T1,
|
||||
deleteMode: "none",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const VOID_SEGMENT_COUNT = createVoidSegments(0).length;
|
||||
|
||||
export function addVoidPhase(
|
||||
tw: TypewriterInstance,
|
||||
onComplete: () => void,
|
||||
startSegment: number = 0,
|
||||
onSegmentChange?: (index: number) => void,
|
||||
visitCount: number = 0,
|
||||
) {
|
||||
const segments = createVoidSegments(visitCount);
|
||||
buildSegments(tw, segments, onComplete, startSegment, 4000, onSegmentChange);
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
import { useRef, useMemo } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
import { SIMPLEX_3D, PLANE_VERT } from "../shaders/noise";
|
||||
|
||||
interface VoidWaterProps {
|
||||
segment: number;
|
||||
corruption: number; // 0-1, drives RGB split + color noise
|
||||
}
|
||||
|
||||
const waterFrag = `
|
||||
${SIMPLEX_3D}
|
||||
|
||||
uniform float uTime;
|
||||
uniform float uOpacity;
|
||||
uniform float uCorruption;
|
||||
varying vec2 vUv;
|
||||
|
||||
// Sample the water height field — broad, slow waves
|
||||
float waterHeight(vec2 p) {
|
||||
float t = uTime;
|
||||
|
||||
// Large primary waves — slow, dominant
|
||||
float h = snoise(vec3(p * 0.4, t * 0.08)) * 0.6;
|
||||
// Medium secondary swell — different direction via offset
|
||||
h += snoise(vec3(p.yx * 0.7 + 2.0, t * 0.12)) * 0.3;
|
||||
// Small surface detail
|
||||
h += snoise(vec3(p * 1.5 + 5.0, t * 0.2)) * 0.1;
|
||||
|
||||
return h;
|
||||
}
|
||||
|
||||
// Compute lighting for a given UV position
|
||||
float computeLight(vec2 p) {
|
||||
float eps = 0.08;
|
||||
float h = waterHeight(p);
|
||||
float hx = waterHeight(p + vec2(eps, 0.0));
|
||||
float hy = waterHeight(p + vec2(0.0, eps));
|
||||
|
||||
vec3 normal = normalize(vec3(
|
||||
(h - hx) / eps * 2.0,
|
||||
(h - hy) / eps * 2.0,
|
||||
1.0
|
||||
));
|
||||
|
||||
vec3 viewDir = vec3(0.0, 0.0, 1.0);
|
||||
vec3 lightDir = normalize(vec3(0.4, 0.3, 1.0));
|
||||
vec3 halfDir = normalize(lightDir + viewDir);
|
||||
|
||||
float diffuse = max(dot(normal, lightDir), 0.0);
|
||||
float spec1 = pow(max(dot(normal, halfDir), 0.0), 12.0);
|
||||
float spec2 = pow(max(dot(normal, halfDir), 0.0), 40.0);
|
||||
float tilt = 1.0 - normal.z;
|
||||
|
||||
return tilt * 0.12 + diffuse * 0.2 + spec1 * 0.5 + spec2 * 0.6;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 p = (vUv - 0.5) * 4.0;
|
||||
|
||||
// Circular vignette
|
||||
float dist = length(vUv - 0.5) * 2.0;
|
||||
float vignette = 1.0 - smoothstep(0.5, 1.0, dist);
|
||||
|
||||
if (uCorruption < 0.01) {
|
||||
// Clean path — original water
|
||||
float light = computeLight(p);
|
||||
float intensity = light * vignette * uOpacity;
|
||||
vec3 color = vec3(0.3, 0.38, 0.5) * intensity;
|
||||
gl_FragColor = vec4(color, intensity);
|
||||
} else {
|
||||
// Corrupted path — RGB channel separation + color noise
|
||||
|
||||
// Chromatic offset increases with corruption
|
||||
float offset = uCorruption * 0.15;
|
||||
|
||||
// Sample lighting at offset positions for each channel
|
||||
float lightR = computeLight(p + vec2(offset, offset * 0.5));
|
||||
float lightG = computeLight(p);
|
||||
float lightB = computeLight(p - vec2(offset * 0.7, offset));
|
||||
|
||||
// Base water color per channel
|
||||
vec3 baseColor = vec3(0.3, 0.38, 0.5);
|
||||
float r = lightR * baseColor.r;
|
||||
float g = lightG * baseColor.g;
|
||||
float b = lightB * baseColor.b;
|
||||
|
||||
// Color static — high-frequency noise injecting random color
|
||||
float staticR = snoise(vec3(vUv * 80.0, uTime * 3.0)) * 0.5 + 0.5;
|
||||
float staticG = snoise(vec3(vUv * 80.0 + 50.0, uTime * 3.5)) * 0.5 + 0.5;
|
||||
float staticB = snoise(vec3(vUv * 80.0 + 100.0, uTime * 4.0)) * 0.5 + 0.5;
|
||||
|
||||
float staticMix = uCorruption * 0.3;
|
||||
r = mix(r, staticR * 0.4, staticMix);
|
||||
g = mix(g, staticG * 0.3, staticMix);
|
||||
b = mix(b, staticB * 0.5, staticMix);
|
||||
|
||||
// Scan line glitch — horizontal bands that flicker
|
||||
float scanline = step(0.92, snoise(vec3(0.0, vUv.y * 40.0, uTime * 5.0)));
|
||||
r += scanline * uCorruption * 0.15;
|
||||
|
||||
float avgLight = (lightR + lightG + lightB) / 3.0;
|
||||
float intensity = avgLight * vignette * uOpacity;
|
||||
|
||||
gl_FragColor = vec4(vec3(r, g, b) * vignette * uOpacity, intensity);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function getOpacityTarget(segment: number): number {
|
||||
if (segment < 2) return 0;
|
||||
if (segment === 2) return 0.5;
|
||||
if (segment === 3) return 0.7;
|
||||
if (segment === 4) return 0.85;
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
export default function VoidWater({ segment, corruption }: VoidWaterProps) {
|
||||
const meshRef = useRef<THREE.Mesh>(null!);
|
||||
const opacityRef = useRef(0);
|
||||
const corruptionRef = useRef(0);
|
||||
|
||||
const uniforms = useMemo(() => ({
|
||||
uTime: { value: 0 },
|
||||
uOpacity: { value: 0 },
|
||||
uCorruption: { value: 0 },
|
||||
}), []);
|
||||
|
||||
const material = useMemo(() => new THREE.ShaderMaterial({
|
||||
vertexShader: PLANE_VERT,
|
||||
fragmentShader: waterFrag,
|
||||
uniforms,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
}), [uniforms]);
|
||||
|
||||
useFrame((state, delta) => {
|
||||
const target = getOpacityTarget(segment);
|
||||
opacityRef.current = THREE.MathUtils.lerp(opacityRef.current, target, delta * 0.4);
|
||||
corruptionRef.current = THREE.MathUtils.lerp(corruptionRef.current, corruption, delta * 2.0);
|
||||
|
||||
const mesh = meshRef.current;
|
||||
if (!mesh) return;
|
||||
|
||||
if (opacityRef.current < 0.001) {
|
||||
mesh.visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
mesh.visible = true;
|
||||
const t = state.clock.elapsedTime;
|
||||
uniforms.uTime.value = t;
|
||||
|
||||
// Gentle pulse — slow breathing modulation on opacity
|
||||
const pulse = 1.0 + Math.sin(t * 0.4) * 0.08 + Math.sin(t * 0.7) * 0.04;
|
||||
uniforms.uOpacity.value = opacityRef.current * pulse;
|
||||
uniforms.uCorruption.value = corruptionRef.current;
|
||||
});
|
||||
|
||||
return (
|
||||
<mesh
|
||||
ref={meshRef}
|
||||
position={[0, 0, 0]}
|
||||
visible={false}
|
||||
material={material}
|
||||
>
|
||||
<planeGeometry args={[20, 20, 1, 1]} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
// Shared GLSL noise functions for void experience shaders
|
||||
// 3D Simplex noise (Ashima Arts / Stefan Gustavson, MIT)
|
||||
|
||||
export const SIMPLEX_3D = `
|
||||
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
|
||||
vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
|
||||
vec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); }
|
||||
vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
|
||||
|
||||
float snoise(vec3 v) {
|
||||
const vec2 C = vec2(1.0/6.0, 1.0/3.0);
|
||||
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
|
||||
|
||||
vec3 i = floor(v + dot(v, C.yyy));
|
||||
vec3 x0 = v - i + dot(i, C.xxx);
|
||||
|
||||
vec3 g = step(x0.yzx, x0.xyz);
|
||||
vec3 l = 1.0 - g;
|
||||
vec3 i1 = min(g.xyz, l.zxy);
|
||||
vec3 i2 = max(g.xyz, l.zxy);
|
||||
|
||||
vec3 x1 = x0 - i1 + C.xxx;
|
||||
vec3 x2 = x0 - i2 + C.yyy;
|
||||
vec3 x3 = x0 - D.yyy;
|
||||
|
||||
i = mod289(i);
|
||||
vec4 p = permute(permute(permute(
|
||||
i.z + vec4(0.0, i1.z, i2.z, 1.0))
|
||||
+ i.y + vec4(0.0, i1.y, i2.y, 1.0))
|
||||
+ i.x + vec4(0.0, i1.x, i2.x, 1.0));
|
||||
|
||||
float n_ = 0.142857142857;
|
||||
vec3 ns = n_ * D.wyz - D.xzx;
|
||||
|
||||
vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
|
||||
vec4 x_ = floor(j * ns.z);
|
||||
vec4 y_ = floor(j - 7.0 * x_);
|
||||
|
||||
vec4 x = x_ * ns.x + ns.yyyy;
|
||||
vec4 y = y_ * ns.x + ns.yyyy;
|
||||
vec4 h = 1.0 - abs(x) - abs(y);
|
||||
|
||||
vec4 b0 = vec4(x.xy, y.xy);
|
||||
vec4 b1 = vec4(x.zw, y.zw);
|
||||
|
||||
vec4 s0 = floor(b0) * 2.0 + 1.0;
|
||||
vec4 s1 = floor(b1) * 2.0 + 1.0;
|
||||
vec4 sh = -step(h, vec4(0.0));
|
||||
|
||||
vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
|
||||
vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
|
||||
|
||||
vec3 p0 = vec3(a0.xy, h.x);
|
||||
vec3 p1 = vec3(a0.zw, h.y);
|
||||
vec3 p2 = vec3(a1.xy, h.z);
|
||||
vec3 p3 = vec3(a1.zw, h.w);
|
||||
|
||||
vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3)));
|
||||
p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
|
||||
|
||||
vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
|
||||
m = m * m;
|
||||
return 42.0 * dot(m*m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3)));
|
||||
}
|
||||
`;
|
||||
|
||||
// Standard passthrough vertex shader used by all scene planes
|
||||
export const PLANE_VERT = `
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
@@ -1,83 +0,0 @@
|
||||
export interface TypewriterInstance {
|
||||
typeString: (str: string) => TypewriterInstance;
|
||||
pasteString: (str: string, node?: HTMLElement | null) => TypewriterInstance;
|
||||
pauseFor: (ms: number) => TypewriterInstance;
|
||||
deleteAll: (speed?: number | "natural") => TypewriterInstance;
|
||||
deleteChars: (amount: number) => TypewriterInstance;
|
||||
changeDelay: (delay: number | "natural") => TypewriterInstance;
|
||||
changeDeleteSpeed: (speed: number | "natural") => TypewriterInstance;
|
||||
callFunction: (cb: () => void) => TypewriterInstance;
|
||||
start: () => TypewriterInstance;
|
||||
}
|
||||
|
||||
export type Phase = "void";
|
||||
|
||||
export const PHASE_ORDER: Phase[] = ["void"];
|
||||
|
||||
export const T1 = 55;
|
||||
export const T2 = 35;
|
||||
export const DELETE_SPEED = 15;
|
||||
|
||||
export interface Segment {
|
||||
html: string;
|
||||
pause: number;
|
||||
method?: "type" | "paste";
|
||||
delay?: number;
|
||||
prePause?: number;
|
||||
deleteMode?: "all" | "none";
|
||||
deleteSpeed?: number;
|
||||
}
|
||||
|
||||
export type PhaseBuilder = (
|
||||
tw: TypewriterInstance,
|
||||
onComplete: () => void,
|
||||
startSegment?: number,
|
||||
onSegmentChange?: (index: number) => void,
|
||||
) => void;
|
||||
|
||||
export function buildSegments(
|
||||
tw: TypewriterInstance,
|
||||
segments: Segment[],
|
||||
onComplete: () => void,
|
||||
startSegment: number = 0,
|
||||
initialPause: number = 0,
|
||||
onSegmentChange?: (index: number) => void,
|
||||
) {
|
||||
if (startSegment === 0 && initialPause > 0) {
|
||||
tw.pauseFor(initialPause);
|
||||
}
|
||||
|
||||
for (let i = startSegment; i < segments.length; i++) {
|
||||
const seg = segments[i];
|
||||
const idx = i;
|
||||
|
||||
tw.callFunction(() => onSegmentChange?.(idx));
|
||||
|
||||
if (seg.prePause && seg.prePause > 0) {
|
||||
tw.pauseFor(seg.prePause);
|
||||
}
|
||||
|
||||
if (seg.delay !== undefined) {
|
||||
tw.changeDelay(seg.delay);
|
||||
}
|
||||
|
||||
if (seg.method === "paste") {
|
||||
tw.pasteString(seg.html, null);
|
||||
} else {
|
||||
tw.typeString(seg.html);
|
||||
}
|
||||
|
||||
tw.pauseFor(seg.pause);
|
||||
|
||||
if (seg.delay !== undefined) {
|
||||
tw.changeDelay(T2);
|
||||
}
|
||||
|
||||
const mode = seg.deleteMode ?? "all";
|
||||
if (mode === "all") {
|
||||
tw.deleteAll(seg.deleteSpeed ?? DELETE_SPEED);
|
||||
}
|
||||
}
|
||||
|
||||
tw.callFunction(onComplete);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { useRef, useEffect } from "react";
|
||||
import Typewriter from "typewriter-effect";
|
||||
import type { TypewriterInstance } from "./types";
|
||||
import { addVoidPhase } from "./phases";
|
||||
|
||||
const GLITCH_CHARS = "!<>-_\\/[]{}—=+*^?#________";
|
||||
|
||||
interface VoidTypewriterProps {
|
||||
startSegment: number;
|
||||
onPhaseComplete: () => void;
|
||||
onSegmentChange: (index: number) => void;
|
||||
visitCount: number;
|
||||
corruption: number;
|
||||
glitchClass: string;
|
||||
}
|
||||
|
||||
function getTextNodes(node: Node): Text[] {
|
||||
const nodes: Text[] = [];
|
||||
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
|
||||
let current: Node | null;
|
||||
while ((current = walker.nextNode())) {
|
||||
if (current.textContent && current.textContent.trim().length > 0) {
|
||||
nodes.push(current as Text);
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export default function VoidTypewriter({ startSegment, onPhaseComplete, onSegmentChange, visitCount, corruption, glitchClass }: VoidTypewriterProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const corruptionRef = useRef(corruption);
|
||||
corruptionRef.current = corruption;
|
||||
|
||||
const handleInit = (tw: TypewriterInstance): void => {
|
||||
addVoidPhase(tw, onPhaseComplete, startSegment, onSegmentChange, visitCount);
|
||||
tw.start();
|
||||
};
|
||||
|
||||
// 404-style character replacement glitch — intensity scales with corruption
|
||||
useEffect(() => {
|
||||
const pendingResets: ReturnType<typeof setTimeout>[] = [];
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const c = corruptionRef.current;
|
||||
if (c <= 0 || !containerRef.current) return;
|
||||
|
||||
const triggerChance = c * 0.4;
|
||||
if (Math.random() > triggerChance) return;
|
||||
|
||||
const textNodes = getTextNodes(containerRef.current);
|
||||
if (textNodes.length === 0) return;
|
||||
|
||||
const originals = textNodes.map(n => n.textContent || "");
|
||||
const charChance = c * 0.4;
|
||||
|
||||
textNodes.forEach((node, i) => {
|
||||
const text = originals[i];
|
||||
const glitched = text.split("").map(char => {
|
||||
if (char === " ") return char;
|
||||
if (Math.random() < charChance) {
|
||||
return GLITCH_CHARS[Math.floor(Math.random() * GLITCH_CHARS.length)];
|
||||
}
|
||||
return char;
|
||||
}).join("");
|
||||
node.textContent = glitched;
|
||||
});
|
||||
|
||||
const resetMs = Math.max(40, 120 - c * 80);
|
||||
const id = setTimeout(() => {
|
||||
textNodes.forEach((node, i) => {
|
||||
if (node.parentNode) {
|
||||
node.textContent = originals[i];
|
||||
}
|
||||
});
|
||||
}, resetMs);
|
||||
pendingResets.push(id);
|
||||
}, 60);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
pendingResets.forEach(clearTimeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[50] flex justify-center items-center pointer-events-none">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`text-xl md:text-3xl font-bold text-center max-w-[85vw] md:max-w-[70vw] break-words text-white leading-relaxed ${glitchClass}`}
|
||||
>
|
||||
<Typewriter
|
||||
key={`void-${startSegment}-${visitCount}`}
|
||||
options={{
|
||||
delay: 35,
|
||||
deleteSpeed: 15,
|
||||
cursor: "",
|
||||
autoStart: true,
|
||||
loop: false,
|
||||
}}
|
||||
onInit={handleInit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
---
|
||||
import "@/style/globals.css";
|
||||
|
||||
import { ClientRouter } from "astro:transitions";
|
||||
|
||||
import Header from "@/components/header";
|
||||
import Footer from "@/components/footer";
|
||||
import Background from "@/components/background";
|
||||
import ThemeSwitcher from "@/components/theme-switcher";
|
||||
import AnimationSwitcher from "@/components/animation-switcher";
|
||||
import VercelAnalytics from "@/components/analytics";
|
||||
import MobileNav from "@/components/mobile-nav";
|
||||
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
|
||||
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const { title, description } = Astro.props;
|
||||
const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{title}</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:image" content={ogImage} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="description" content={description} />
|
||||
<meta property="og:description" content={description} />
|
||||
<link rel="icon" type="image/jpeg" href="/me.jpeg" />
|
||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||
<ClientRouter />
|
||||
<script is:inline>
|
||||
if (window.innerWidth < 1024 || navigator.maxTouchPoints > 0) {
|
||||
document.addEventListener('click', function(e) {
|
||||
var a = e.target.closest('a[href]');
|
||||
if (a && a.href && !a.target && a.origin === location.origin) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.location.href = a.href;
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
</script>
|
||||
<script is:inline set:html={THEME_LOADER_SCRIPT} />
|
||||
<script is:inline set:html={ANIMATION_LOADER_SCRIPT} />
|
||||
</head>
|
||||
<body class="bg-background text-foreground overflow-hidden h-screen">
|
||||
<Header client:load transparent />
|
||||
<main>
|
||||
<Background layout="index" client:only="react" transition:persist />
|
||||
<slot />
|
||||
</main>
|
||||
<Footer client:load transition:persist fixed=true />
|
||||
<ThemeSwitcher client:only="react" transition:persist />
|
||||
<AnimationSwitcher client:only="react" transition:persist />
|
||||
<VercelAnalytics client:load />
|
||||
<MobileNav client:load transparent />
|
||||
<script is:inline set:html={THEME_NAV_SCRIPT} />
|
||||
<script is:inline set:html={ANIMATION_NAV_SCRIPT} />
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,20 +0,0 @@
|
||||
import { ANIMATION_IDS, DEFAULT_ANIMATION_ID } from "@/lib/animations";
|
||||
import type { AnimationId } from "@/lib/animations";
|
||||
|
||||
export function getStoredAnimationId(): AnimationId {
|
||||
if (typeof window === "undefined") return DEFAULT_ANIMATION_ID;
|
||||
const stored = localStorage.getItem("animation");
|
||||
if (stored && (ANIMATION_IDS as readonly string[]).includes(stored)) {
|
||||
return stored as AnimationId;
|
||||
}
|
||||
return DEFAULT_ANIMATION_ID;
|
||||
}
|
||||
|
||||
export function saveAnimation(id: AnimationId): void {
|
||||
localStorage.setItem("animation", id);
|
||||
}
|
||||
|
||||
export function getNextAnimation(currentId: AnimationId): AnimationId {
|
||||
const idx = ANIMATION_IDS.indexOf(currentId);
|
||||
return ANIMATION_IDS[(idx + 1) % ANIMATION_IDS.length];
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export const ANIMATION_IDS = ["shuffle", "game-of-life", "lava-lamp", "confetti", "asciiquarium", "pipes"] as const;
|
||||
export type AnimationId = (typeof ANIMATION_IDS)[number];
|
||||
export const DEFAULT_ANIMATION_ID: AnimationId = "shuffle";
|
||||
|
||||
export const ANIMATION_LABELS: Record<AnimationId, string> = {
|
||||
"shuffle": "shuffle",
|
||||
"game-of-life": "life",
|
||||
"lava-lamp": "lava",
|
||||
"confetti": "confetti",
|
||||
"asciiquarium": "aquarium",
|
||||
"pipes": "pipes",
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
import { ANIMATION_IDS, DEFAULT_ANIMATION_ID } from "@/lib/animations";
|
||||
|
||||
const VALID_IDS = JSON.stringify(ANIMATION_IDS);
|
||||
|
||||
export const ANIMATION_LOADER_SCRIPT = `(function(){var id=localStorage.getItem("animation");if(id&&${VALID_IDS}.indexOf(id)!==-1)document.documentElement.dataset.animation=id;else document.documentElement.dataset.animation="${DEFAULT_ANIMATION_ID}";})();`;
|
||||
|
||||
export const ANIMATION_NAV_SCRIPT = `document.addEventListener("astro:after-navigation",function(){var id=localStorage.getItem("animation");if(id&&${VALID_IDS}.indexOf(id)!==-1)document.documentElement.dataset.animation=id;else document.documentElement.dataset.animation="${DEFAULT_ANIMATION_ID}";});`;
|
||||
@@ -1,37 +0,0 @@
|
||||
export interface AnimationEngine {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
init(
|
||||
width: number,
|
||||
height: number,
|
||||
palette: [number, number, number][],
|
||||
bgColor: string
|
||||
): void;
|
||||
|
||||
update(deltaTime: number): void;
|
||||
|
||||
render(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number
|
||||
): void;
|
||||
|
||||
handleResize(width: number, height: number): void;
|
||||
|
||||
handleMouseMove(x: number, y: number, isDown: boolean): void;
|
||||
|
||||
handleMouseDown(x: number, y: number): void;
|
||||
|
||||
handleMouseUp(): void;
|
||||
|
||||
handleMouseLeave(): void;
|
||||
|
||||
updatePalette(palette: [number, number, number][], bgColor: string): void;
|
||||
|
||||
beginExit(): void;
|
||||
|
||||
isExitComplete(): boolean;
|
||||
|
||||
cleanup(): void;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export function prefersReducedMotion(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { THEMES, FAMILIES, DEFAULT_THEME_ID } from "@/lib/themes";
|
||||
import { CSS_PROPS } from "@/lib/themes/props";
|
||||
import type { Theme } from "@/lib/themes/types";
|
||||
|
||||
export function getStoredThemeId(): string {
|
||||
if (typeof window === "undefined") return DEFAULT_THEME_ID;
|
||||
return localStorage.getItem("theme") || DEFAULT_THEME_ID;
|
||||
}
|
||||
|
||||
export function saveTheme(id: string): void {
|
||||
localStorage.setItem("theme", id);
|
||||
}
|
||||
|
||||
/** Cycle to the next theme family, jumping to its default variant. */
|
||||
export function getNextFamily(currentId: string): Theme {
|
||||
const current = THEMES[currentId];
|
||||
const familyId = current?.family ?? FAMILIES[0].id;
|
||||
const idx = FAMILIES.findIndex((f) => f.id === familyId);
|
||||
const next = FAMILIES[(idx + 1) % FAMILIES.length];
|
||||
return THEMES[next.default];
|
||||
}
|
||||
|
||||
/** Cycle to the next variant within the current family. */
|
||||
export function getNextVariant(currentId: string): Theme {
|
||||
const current = THEMES[currentId];
|
||||
if (!current) return Object.values(THEMES)[0];
|
||||
const family = FAMILIES.find((f) => f.id === current.family);
|
||||
if (!family) return current;
|
||||
const idx = family.themes.findIndex((t) => t.id === currentId);
|
||||
return family.themes[(idx + 1) % family.themes.length];
|
||||
}
|
||||
|
||||
// Keep for backward compat (cycles all themes linearly)
|
||||
export function getNextTheme(currentId: string): Theme {
|
||||
const list = Object.values(THEMES);
|
||||
const idx = list.findIndex((t) => t.id === currentId);
|
||||
return list[(idx + 1) % list.length];
|
||||
}
|
||||
|
||||
/** Sets CSS vars and notifies canvas, but does NOT persist to localStorage. */
|
||||
export function previewTheme(id: string): void {
|
||||
const theme = THEMES[id];
|
||||
if (!theme) return;
|
||||
|
||||
const root = document.documentElement;
|
||||
for (const [key, prop] of CSS_PROPS) {
|
||||
root.style.setProperty(prop, theme.colors[key]);
|
||||
}
|
||||
|
||||
document.dispatchEvent(new CustomEvent("theme-changed", { detail: { id } }));
|
||||
}
|
||||
|
||||
export function applyTheme(id: string): void {
|
||||
const theme = THEMES[id];
|
||||
if (!theme) return;
|
||||
|
||||
// Set CSS vars on :root for immediate visual update
|
||||
const root = document.documentElement;
|
||||
for (const [key, prop] of CSS_PROPS) {
|
||||
root.style.setProperty(prop, theme.colors[key]);
|
||||
}
|
||||
|
||||
// Update <style id="theme-vars"> so Astro view transitions don't revert
|
||||
let el = document.getElementById("theme-vars") as HTMLStyleElement | null;
|
||||
if (!el) {
|
||||
el = document.createElement("style");
|
||||
el.id = "theme-vars";
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
let css = ":root{";
|
||||
for (const [key, prop] of CSS_PROPS) {
|
||||
css += `${prop}:${theme.colors[key]};`;
|
||||
}
|
||||
css += "}";
|
||||
el.textContent = css;
|
||||
|
||||
saveTheme(id);
|
||||
|
||||
document.dispatchEvent(new CustomEvent("theme-changed", { detail: { id } }));
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import type { Theme, ThemeFamily } from "../types";
|
||||
|
||||
// Catppuccin — warm pastel palette. Each flavor has its own accent colors.
|
||||
|
||||
const mocha: Theme = {
|
||||
id: "catppuccin-mocha",
|
||||
family: "catppuccin",
|
||||
label: "mocha",
|
||||
name: "Catppuccin Mocha",
|
||||
type: "dark",
|
||||
colors: {
|
||||
background: "30 30 46",
|
||||
foreground: "205 214 244",
|
||||
red: "243 139 168", redBright: "246 166 190",
|
||||
orange: "250 179 135", orangeBright: "252 200 170",
|
||||
green: "166 227 161", greenBright: "190 236 186",
|
||||
yellow: "249 226 175", yellowBright: "251 235 200",
|
||||
blue: "137 180 250", blueBright: "172 202 251",
|
||||
purple: "203 166 247", purpleBright: "220 192 249",
|
||||
aqua: "148 226 213", aquaBright: "180 236 228",
|
||||
surface: "49 50 68",
|
||||
},
|
||||
canvasPalette: [[243,139,168],[166,227,161],[249,226,175],[137,180,250],[203,166,247],[148,226,213]],
|
||||
};
|
||||
|
||||
const macchiato: Theme = {
|
||||
id: "catppuccin-macchiato",
|
||||
family: "catppuccin",
|
||||
label: "macchiato",
|
||||
name: "Catppuccin Macchiato",
|
||||
type: "dark",
|
||||
colors: {
|
||||
background: "36 39 58",
|
||||
foreground: "202 211 245",
|
||||
red: "237 135 150", redBright: "242 167 180",
|
||||
orange: "245 169 127", orangeBright: "248 192 165",
|
||||
green: "166 218 149", greenBright: "190 232 180",
|
||||
yellow: "238 212 159", yellowBright: "243 226 190",
|
||||
blue: "138 173 244", blueBright: "170 198 247",
|
||||
purple: "198 160 246", purpleBright: "218 190 249",
|
||||
aqua: "139 213 202", aquaBright: "175 228 220",
|
||||
surface: "54 58 79",
|
||||
},
|
||||
canvasPalette: [[237,135,150],[166,218,149],[238,212,159],[138,173,244],[198,160,246],[139,213,202]],
|
||||
};
|
||||
|
||||
const frappe: Theme = {
|
||||
id: "catppuccin-frappe",
|
||||
family: "catppuccin",
|
||||
label: "frappé",
|
||||
name: "Catppuccin Frappé",
|
||||
type: "dark",
|
||||
colors: {
|
||||
background: "48 52 70",
|
||||
foreground: "198 208 245",
|
||||
red: "231 130 132", redBright: "238 160 162",
|
||||
orange: "239 159 118", orangeBright: "244 185 158",
|
||||
green: "166 209 137", greenBright: "190 222 172",
|
||||
yellow: "229 200 144", yellowBright: "237 216 178",
|
||||
blue: "140 170 238", blueBright: "172 196 242",
|
||||
purple: "202 158 230", purpleBright: "218 186 238",
|
||||
aqua: "129 200 190", aquaBright: "168 216 208",
|
||||
surface: "65 69 89",
|
||||
},
|
||||
canvasPalette: [[231,130,132],[166,209,137],[229,200,144],[140,170,238],[202,158,230],[129,200,190]],
|
||||
};
|
||||
|
||||
const latte: Theme = {
|
||||
id: "catppuccin-latte",
|
||||
family: "catppuccin",
|
||||
label: "latte",
|
||||
name: "Catppuccin Latte",
|
||||
type: "light",
|
||||
colors: {
|
||||
background: "239 241 245",
|
||||
foreground: "76 79 105",
|
||||
red: "210 15 57", redBright: "228 50 82",
|
||||
orange: "254 100 11", orangeBright: "254 135 60",
|
||||
green: "64 160 43", greenBright: "85 180 65",
|
||||
yellow: "223 142 29", yellowBright: "236 170 60",
|
||||
blue: "30 102 245", blueBright: "70 130 248",
|
||||
purple: "136 57 239", purpleBright: "162 95 244",
|
||||
aqua: "23 146 153", aquaBright: "55 168 175",
|
||||
surface: "204 208 218",
|
||||
},
|
||||
canvasPalette: [[210,15,57],[64,160,43],[223,142,29],[30,102,245],[136,57,239],[23,146,153]],
|
||||
};
|
||||
|
||||
export const catppuccin: ThemeFamily = {
|
||||
id: "catppuccin",
|
||||
name: "Catppuccin",
|
||||
themes: [mocha, macchiato, frappe, latte],
|
||||
default: "catppuccin-mocha",
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { Theme, ThemeFamily } from "../types";
|
||||
|
||||
const classic: Theme = {
|
||||
id: "darkbox",
|
||||
family: "darkbox",
|
||||
label: "classic",
|
||||
name: "Darkbox Classic",
|
||||
type: "dark",
|
||||
colors: {
|
||||
background: "0 0 0",
|
||||
foreground: "235 219 178",
|
||||
red: "251 73 52", redBright: "255 110 85",
|
||||
orange: "254 128 25", orangeBright: "255 165 65",
|
||||
green: "184 187 38", greenBright: "210 215 70",
|
||||
yellow: "250 189 47", yellowBright: "255 215 85",
|
||||
blue: "131 165 152", blueBright: "165 195 180",
|
||||
purple: "211 134 155", purpleBright: "235 165 180",
|
||||
aqua: "142 192 124", aquaBright: "175 220 160",
|
||||
surface: "60 56 54",
|
||||
},
|
||||
canvasPalette: [[251,73,52],[184,187,38],[250,189,47],[131,165,152],[211,134,155],[142,192,124]],
|
||||
};
|
||||
|
||||
const retro: Theme = {
|
||||
id: "darkbox-retro",
|
||||
family: "darkbox",
|
||||
label: "retro",
|
||||
name: "Darkbox Retro",
|
||||
type: "dark",
|
||||
colors: {
|
||||
background: "0 0 0",
|
||||
foreground: "189 174 147",
|
||||
red: "204 36 29", redBright: "251 73 52",
|
||||
orange: "214 93 14", orangeBright: "254 128 25",
|
||||
green: "152 151 26", greenBright: "184 187 38",
|
||||
yellow: "215 153 33", yellowBright: "250 189 47",
|
||||
blue: "69 133 136", blueBright: "131 165 152",
|
||||
purple: "177 98 134", purpleBright: "211 134 155",
|
||||
aqua: "104 157 106", aquaBright: "142 192 124",
|
||||
surface: "60 56 54",
|
||||
},
|
||||
canvasPalette: [[204,36,29],[152,151,26],[215,153,33],[69,133,136],[177,98,134],[104,157,106]],
|
||||
};
|
||||
|
||||
const dim: Theme = {
|
||||
id: "darkbox-dim",
|
||||
family: "darkbox",
|
||||
label: "dim",
|
||||
name: "Darkbox Dim",
|
||||
type: "dark",
|
||||
colors: {
|
||||
background: "0 0 0",
|
||||
foreground: "168 153 132",
|
||||
red: "157 0 6", redBright: "204 36 29",
|
||||
orange: "175 58 3", orangeBright: "214 93 14",
|
||||
green: "121 116 14", greenBright: "152 151 26",
|
||||
yellow: "181 118 20", yellowBright: "215 153 33",
|
||||
blue: "7 102 120", blueBright: "69 133 136",
|
||||
purple: "143 63 113", purpleBright: "177 98 134",
|
||||
aqua: "66 123 88", aquaBright: "104 157 106",
|
||||
surface: "60 56 54",
|
||||
},
|
||||
canvasPalette: [[157,0,6],[121,116,14],[181,118,20],[7,102,120],[143,63,113],[66,123,88]],
|
||||
};
|
||||
|
||||
export const darkbox: ThemeFamily = {
|
||||
id: "darkbox",
|
||||
name: "Darkbox",
|
||||
themes: [classic, retro, dim],
|
||||
default: "darkbox-retro",
|
||||
};
|
||||
@@ -1,70 +0,0 @@
|
||||
import type { Theme, ThemeFamily } from "../types";
|
||||
|
||||
// Everforest — warm green-toned palette. Same accents across
|
||||
// hard/medium/soft, only backgrounds change.
|
||||
|
||||
const accents = {
|
||||
red: "230 126 128", redBright: "240 155 157",
|
||||
orange: "230 152 117", orangeBright: "240 177 150",
|
||||
green: "167 192 128", greenBright: "190 210 160",
|
||||
yellow: "219 188 127", yellowBright: "233 208 163",
|
||||
blue: "127 187 179", blueBright: "160 208 200",
|
||||
purple: "214 153 182", purpleBright: "228 180 202",
|
||||
aqua: "131 192 146", aquaBright: "162 212 176",
|
||||
} as const;
|
||||
|
||||
const palette: [number, number, number][] = [
|
||||
[230,126,128],[167,192,128],[219,188,127],[127,187,179],[214,153,182],[131,192,146],
|
||||
];
|
||||
|
||||
const hard: Theme = {
|
||||
id: "everforest-hard",
|
||||
family: "everforest",
|
||||
label: "hard",
|
||||
name: "Everforest Hard",
|
||||
type: "dark",
|
||||
colors: {
|
||||
background: "39 46 51",
|
||||
foreground: "211 198 170",
|
||||
...accents,
|
||||
surface: "55 65 69",
|
||||
},
|
||||
canvasPalette: palette,
|
||||
};
|
||||
|
||||
const medium: Theme = {
|
||||
id: "everforest-medium",
|
||||
family: "everforest",
|
||||
label: "medium",
|
||||
name: "Everforest Medium",
|
||||
type: "dark",
|
||||
colors: {
|
||||
background: "45 53 59",
|
||||
foreground: "211 198 170",
|
||||
...accents,
|
||||
surface: "61 72 77",
|
||||
},
|
||||
canvasPalette: palette,
|
||||
};
|
||||
|
||||
const soft: Theme = {
|
||||
id: "everforest-soft",
|
||||
family: "everforest",
|
||||
label: "soft",
|
||||
name: "Everforest Soft",
|
||||
type: "dark",
|
||||
colors: {
|
||||
background: "52 63 68",
|
||||
foreground: "211 198 170",
|
||||
...accents,
|
||||
surface: "68 80 85",
|
||||
},
|
||||
canvasPalette: palette,
|
||||
};
|
||||
|
||||
export const everforest: ThemeFamily = {
|
||||
id: "everforest",
|
||||
name: "Everforest",
|
||||
themes: [hard, medium, soft],
|
||||
default: "everforest-medium",
|
||||
};
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { Theme, ThemeFamily } from "../types";
|
||||
|
||||
// GitHub — the familiar look from github.com.
|
||||
// Dark (default dark), Dark Dimmed (softer), Light (classic white).
|
||||
|
||||
const dark: Theme = {
|
||||
id: "github-dark",
|
||||
family: "github",
|
||||
label: "dark",
|
||||
name: "GitHub Dark",
|
||||
type: "dark",
|
||||
colors: {
|
||||
background: "13 17 23",
|
||||
foreground: "230 237 243",
|
||||
red: "255 123 114", redBright: "255 166 158",
|
||||
orange: "217 156 90", orangeBright: "240 183 122",
|
||||
green: "126 231 135", greenBright: "168 242 175",
|
||||
yellow: "224 194 133", yellowBright: "240 215 168",
|
||||
blue: "121 192 255", blueBright: "165 214 255",
|
||||
purple: "210 153 255", purpleBright: "226 187 255",
|
||||
aqua: "118 214 198", aquaBright: "160 230 218",
|
||||
surface: "22 27 34",
|
||||
},
|
||||
canvasPalette: [[255,123,114],[126,231,135],[224,194,133],[121,192,255],[210,153,255],[118,214,198]],
|
||||
};
|
||||
|
||||
const dimmed: Theme = {
|
||||
id: "github-dimmed",
|
||||
family: "github",
|
||||
label: "dimmed",
|
||||
name: "GitHub Dark Dimmed",
|
||||
type: "dark",
|
||||
colors: {
|
||||
background: "34 39 46",
|
||||
foreground: "173 186 199",
|
||||
red: "255 123 114", redBright: "255 166 158",
|
||||
orange: "219 171 127", orangeBright: "236 195 158",
|
||||
green: "87 196 106", greenBright: "130 218 144",
|
||||
yellow: "224 194 133", yellowBright: "240 215 168",
|
||||
blue: "108 182 255", blueBright: "152 206 255",
|
||||
purple: "195 145 243", purpleBright: "218 180 248",
|
||||
aqua: "96 200 182", aquaBright: "140 220 208",
|
||||
surface: "45 51 59",
|
||||
},
|
||||
canvasPalette: [[255,123,114],[87,196,106],[224,194,133],[108,182,255],[195,145,243],[96,200,182]],
|
||||
};
|
||||
|
||||
const light: Theme = {
|
||||
id: "github-light",
|
||||
family: "github",
|
||||
label: "light",
|
||||
name: "GitHub Light",
|
||||
type: "light",
|
||||
colors: {
|
||||
background: "255 255 255",
|
||||
foreground: "31 35 40",
|
||||
red: "207 34 46", redBright: "227 70 80",
|
||||
orange: "191 135 0", orangeBright: "212 160 30",
|
||||
green: "26 127 55", greenBright: "45 155 78",
|
||||
yellow: "159 115 0", yellowBright: "182 140 22",
|
||||
blue: "9 105 218", blueBright: "48 132 238",
|
||||
purple: "130 80 223", purpleBright: "158 112 238",
|
||||
aqua: "18 130 140", aquaBright: "42 158 168",
|
||||
surface: "246 248 250",
|
||||
},
|
||||
canvasPalette: [[207,34,46],[26,127,55],[159,115,0],[9,105,218],[130,80,223],[18,130,140]],
|
||||
};
|
||||
|
||||
export const github: ThemeFamily = {
|
||||
id: "github",
|
||||
name: "GitHub",
|
||||
themes: [dark, dimmed, light],
|
||||
default: "github-dark",
|
||||
};
|
||||
@@ -1,70 +0,0 @@
|
||||
import type { Theme, ThemeFamily } from "../types";
|
||||
|
||||
// Original gruvbox palette — same accents across hard/medium/soft,
|
||||
// only background and surface change.
|
||||
|
||||
const accents = {
|
||||
red: "204 36 29", redBright: "251 73 52",
|
||||
orange: "214 93 14", orangeBright: "254 128 25",
|
||||
green: "152 151 26", greenBright: "184 187 38",
|
||||
yellow: "215 153 33", yellowBright: "250 189 47",
|
||||
blue: "69 133 136", blueBright: "131 165 152",
|
||||
purple: "177 98 134", purpleBright: "211 134 155",
|
||||
aqua: "104 157 106", aquaBright: "142 192 124",
|
||||
} as const;
|
||||
|
||||
const palette: [number, number, number][] = [
|
||||
[204,36,29],[152,151,26],[215,153,33],[69,133,136],[177,98,134],[104,157,106],
|
||||
];
|
||||
|
||||
const hard: Theme = {
|
||||
id: "gruvbox-hard",
|
||||
family: "gruvbox",
|
||||
label: "hard",
|
||||
name: "Gruvbox Hard",
|
||||
type: "dark",
|
||||
colors: {
|
||||
background: "29 32 33",
|
||||
foreground: "235 219 178",
|
||||
...accents,
|
||||
surface: "60 56 54",
|
||||
},
|
||||
canvasPalette: palette,
|
||||
};
|
||||
|
||||
const medium: Theme = {
|
||||
id: "gruvbox-medium",
|
||||
family: "gruvbox",
|
||||
label: "medium",
|
||||
name: "Gruvbox Medium",
|
||||
type: "dark",
|
||||
colors: {
|
||||
background: "40 40 40",
|
||||
foreground: "235 219 178",
|
||||
...accents,
|
||||
surface: "60 56 54",
|
||||
},
|
||||
canvasPalette: palette,
|
||||
};
|
||||
|
||||
const soft: Theme = {
|
||||
id: "gruvbox-soft",
|
||||
family: "gruvbox",
|
||||
label: "soft",
|
||||
name: "Gruvbox Soft",
|
||||
type: "dark",
|
||||
colors: {
|
||||
background: "50 48 47",
|
||||
foreground: "235 219 178",
|
||||
...accents,
|
||||
surface: "80 73 69",
|
||||
},
|
||||
canvasPalette: palette,
|
||||
};
|
||||
|
||||
export const gruvbox: ThemeFamily = {
|
||||
id: "gruvbox",
|
||||
name: "Gruvbox",
|
||||
themes: [hard, medium, soft],
|
||||
default: "gruvbox-medium",
|
||||
};
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { Theme, ThemeFamily } from "../types";
|
||||
|
||||
// Kanagawa — inspired by Katsushika Hokusai's paintings.
|
||||
// Each variant has its own distinct palette.
|
||||
|
||||
const wave: Theme = {
|
||||
id: "kanagawa-wave",
|
||||
family: "kanagawa",
|
||||
label: "wave",
|
||||
name: "Kanagawa Wave",
|
||||
type: "dark",
|
||||
colors: {
|
||||
background: "31 31 40",
|
||||
foreground: "220 215 186",
|
||||
red: "195 64 67", redBright: "255 93 98",
|
||||
orange: "255 160 102", orangeBright: "255 184 140",
|
||||
green: "118 148 106", greenBright: "152 187 108",
|
||||
yellow: "192 163 110", yellowBright: "230 195 132",
|
||||
blue: "126 156 216", blueBright: "127 180 202",
|
||||
purple: "149 127 184", purpleBright: "175 158 206",
|
||||
aqua: "106 149 137", aquaBright: "122 168 159",
|
||||
surface: "42 42 55",
|
||||
},
|
||||
canvasPalette: [[195,64,67],[118,148,106],[192,163,110],[126,156,216],[149,127,184],[106,149,137]],
|
||||
};
|
||||
|
||||
const dragon: Theme = {
|
||||
id: "kanagawa-dragon",
|
||||
family: "kanagawa",
|
||||
label: "dragon",
|
||||
name: "Kanagawa Dragon",
|
||||
type: "dark",
|
||||
colors: {
|
||||
background: "24 22 22",
|
||||
foreground: "197 201 197",
|
||||
red: "196 116 110", redBright: "195 64 67",
|
||||
orange: "182 146 123", orangeBright: "255 160 102",
|
||||
green: "135 169 135", greenBright: "152 187 108",
|
||||
yellow: "196 178 138", yellowBright: "230 195 132",
|
||||
blue: "139 164 176", blueBright: "126 156 216",
|
||||
purple: "162 146 163", purpleBright: "149 127 184",
|
||||
aqua: "142 164 162", aquaBright: "122 168 159",
|
||||
surface: "40 39 39",
|
||||
},
|
||||
canvasPalette: [[196,116,110],[135,169,135],[196,178,138],[139,164,176],[162,146,163],[142,164,162]],
|
||||
};
|
||||
|
||||
const lotus: Theme = {
|
||||
id: "kanagawa-lotus",
|
||||
family: "kanagawa",
|
||||
label: "lotus",
|
||||
name: "Kanagawa Lotus",
|
||||
type: "light",
|
||||
colors: {
|
||||
background: "242 236 188",
|
||||
foreground: "84 84 100",
|
||||
red: "200 64 83", redBright: "215 71 75",
|
||||
orange: "233 138 0", orangeBright: "245 160 30",
|
||||
green: "111 137 78", greenBright: "130 158 98",
|
||||
yellow: "222 152 0", yellowBright: "240 178 40",
|
||||
blue: "77 105 155", blueBright: "93 87 163",
|
||||
purple: "98 76 131", purpleBright: "118 100 155",
|
||||
aqua: "89 123 117", aquaBright: "110 145 138",
|
||||
surface: "228 215 148",
|
||||
},
|
||||
canvasPalette: [[200,64,83],[111,137,78],[222,152,0],[77,105,155],[98,76,131],[89,123,117]],
|
||||
};
|
||||
|
||||
export const kanagawa: ThemeFamily = {
|
||||
id: "kanagawa",
|
||||
name: "Kanagawa",
|
||||
themes: [wave, dragon, lotus],
|
||||
default: "kanagawa-wave",
|
||||
};
|
||||
@@ -1,136 +0,0 @@
|
||||
import type { Theme, ThemeFamily } from "../types";
|
||||
|
||||
// Monokai — the Sublime Text classic, plus Monokai Pro filter variants.
|
||||
|
||||
const classic: Theme = {
|
||||
id: "monokai-classic",
|
||||
family: "monokai",
|
||||
label: "classic",
|
||||
name: "Monokai Classic",
|
||||
type: "dark",
|
||||
colors: {
|
||||
background: "39 40 34",
|
||||
foreground: "248 248 242",
|
||||
red: "249 38 114", redBright: "252 85 145",
|
||||
orange: "253 151 31", orangeBright: "254 182 85",
|
||||
green: "166 226 46", greenBright: "195 240 95",
|
||||
yellow: "230 219 116", yellowBright: "240 232 160",
|
||||
blue: "102 217 239", blueBright: "145 230 245",
|
||||
purple: "174 129 255", purpleBright: "200 165 255",
|
||||
aqua: "161 239 228", aquaBright: "192 245 238",
|
||||
surface: "73 72 62",
|
||||
},
|
||||
canvasPalette: [[249,38,114],[166,226,46],[230,219,116],[102,217,239],[174,129,255],[161,239,228]],
|
||||
};
|
||||
|
||||
const pro: Theme = {
|
||||
id: "monokai-pro",
|
||||
family: "monokai",
|
||||
label: "pro",
|
||||
name: "Monokai Pro",
|
||||
type: "dark",
|
||||
colors: {
|
||||
background: "45 42 46",
|
||||
foreground: "252 252 250",
|
||||
red: "255 97 136", redBright: "255 140 170",
|
||||
orange: "252 152 103", orangeBright: "253 182 142",
|
||||
green: "169 220 118", greenBright: "195 234 155",
|
||||
yellow: "255 216 102", yellowBright: "255 230 155",
|
||||
blue: "120 220 232", blueBright: "160 234 242",
|
||||
purple: "171 157 242", purpleBright: "198 188 248",
|
||||
aqua: "140 228 200", aquaBright: "175 240 220",
|
||||
surface: "64 62 65",
|
||||
},
|
||||
canvasPalette: [[255,97,136],[169,220,118],[255,216,102],[120,220,232],[171,157,242],[140,228,200]],
|
||||
};
|
||||
|
||||
const octagon: Theme = {
|
||||
id: "monokai-octagon",
|
||||
family: "monokai",
|
||||
label: "octagon",
|
||||
name: "Monokai Octagon",
|
||||
type: "dark",
|
||||
colors: {
|
||||
background: "40 42 58",
|
||||
foreground: "234 242 241",
|
||||
red: "255 101 122", redBright: "255 142 158",
|
||||
orange: "255 155 94", orangeBright: "255 185 138",
|
||||
green: "186 215 97", greenBright: "210 230 140",
|
||||
yellow: "255 215 109", yellowBright: "255 230 160",
|
||||
blue: "156 209 187", blueBright: "185 225 208",
|
||||
purple: "195 154 201", purpleBright: "218 182 222",
|
||||
aqua: "130 212 200", aquaBright: "165 228 218",
|
||||
surface: "58 61 75",
|
||||
},
|
||||
canvasPalette: [[255,101,122],[186,215,97],[255,215,109],[156,209,187],[195,154,201],[130,212,200]],
|
||||
};
|
||||
|
||||
const ristretto: Theme = {
|
||||
id: "monokai-ristretto",
|
||||
family: "monokai",
|
||||
label: "ristretto",
|
||||
name: "Monokai Ristretto",
|
||||
type: "dark",
|
||||
colors: {
|
||||
background: "44 37 37",
|
||||
foreground: "255 241 243",
|
||||
red: "253 104 131", redBright: "254 145 165",
|
||||
orange: "243 141 112", orangeBright: "248 175 150",
|
||||
green: "173 218 120", greenBright: "198 232 158",
|
||||
yellow: "249 204 108", yellowBright: "252 222 155",
|
||||
blue: "133 218 204", blueBright: "168 232 222",
|
||||
purple: "168 169 235", purpleBright: "195 196 242",
|
||||
aqua: "150 222 195", aquaBright: "180 235 215",
|
||||
surface: "64 56 56",
|
||||
},
|
||||
canvasPalette: [[253,104,131],[173,218,120],[249,204,108],[133,218,204],[168,169,235],[150,222,195]],
|
||||
};
|
||||
|
||||
const machine: Theme = {
|
||||
id: "monokai-machine",
|
||||
family: "monokai",
|
||||
label: "machine",
|
||||
name: "Monokai Machine",
|
||||
type: "dark",
|
||||
colors: {
|
||||
background: "39 49 54",
|
||||
foreground: "242 255 252",
|
||||
red: "255 109 126", redBright: "255 148 162",
|
||||
orange: "255 178 112", orangeBright: "255 202 155",
|
||||
green: "162 229 123", greenBright: "192 240 160",
|
||||
yellow: "255 237 114", yellowBright: "255 244 168",
|
||||
blue: "124 213 241", blueBright: "162 228 246",
|
||||
purple: "186 160 248", purpleBright: "210 188 251",
|
||||
aqua: "142 225 200", aquaBright: "175 238 220",
|
||||
surface: "58 68 73",
|
||||
},
|
||||
canvasPalette: [[255,109,126],[162,229,123],[255,237,114],[124,213,241],[186,160,248],[142,225,200]],
|
||||
};
|
||||
|
||||
const spectrum: Theme = {
|
||||
id: "monokai-spectrum",
|
||||
family: "monokai",
|
||||
label: "spectrum",
|
||||
name: "Monokai Spectrum",
|
||||
type: "dark",
|
||||
colors: {
|
||||
background: "34 34 34",
|
||||
foreground: "247 241 255",
|
||||
red: "252 97 141", redBright: "253 140 172",
|
||||
orange: "253 147 83", orangeBright: "254 180 125",
|
||||
green: "123 216 143", greenBright: "162 232 175",
|
||||
yellow: "252 229 102", yellowBright: "253 238 155",
|
||||
blue: "90 212 230", blueBright: "135 226 240",
|
||||
purple: "148 138 227", purpleBright: "180 172 238",
|
||||
aqua: "108 218 190", aquaBright: "148 232 212",
|
||||
surface: "54 53 55",
|
||||
},
|
||||
canvasPalette: [[252,97,141],[123,216,143],[252,229,102],[90,212,230],[148,138,227],[108,218,190]],
|
||||
};
|
||||
|
||||
export const monokai: ThemeFamily = {
|
||||
id: "monokai",
|
||||
name: "Monokai",
|
||||
themes: [classic, pro, octagon, ristretto, machine, spectrum],
|
||||
default: "monokai-pro",
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { Theme, ThemeFamily } from "../types";
|
||||
|
||||
// Nord — arctic, bluish clean aesthetic.
|
||||
// Polar Night (dark bg), Snow Storm (light bg), Frost (blues), Aurora (accents).
|
||||
|
||||
const dark: Theme = {
|
||||
id: "nord-dark",
|
||||
family: "nord",
|
||||
label: "dark",
|
||||
name: "Nord Dark",
|
||||
type: "dark",
|
||||
colors: {
|
||||
background: "46 52 64",
|
||||
foreground: "216 222 233",
|
||||
red: "191 97 106", redBright: "210 130 138",
|
||||
orange: "208 135 112", orangeBright: "224 165 145",
|
||||
green: "163 190 140", greenBright: "185 210 168",
|
||||
yellow: "235 203 139", yellowBright: "242 220 175",
|
||||
blue: "94 129 172", blueBright: "129 161 193",
|
||||
purple: "180 142 173", purpleBright: "200 170 195",
|
||||
aqua: "143 188 187", aquaBright: "136 192 208",
|
||||
surface: "59 66 82",
|
||||
},
|
||||
canvasPalette: [[191,97,106],[163,190,140],[235,203,139],[94,129,172],[180,142,173],[143,188,187]],
|
||||
};
|
||||
|
||||
const light: Theme = {
|
||||
id: "nord-light",
|
||||
family: "nord",
|
||||
label: "light",
|
||||
name: "Nord Light",
|
||||
type: "light",
|
||||
colors: {
|
||||
background: "236 239 244",
|
||||
foreground: "46 52 64",
|
||||
red: "191 97 106", redBright: "170 75 85",
|
||||
orange: "208 135 112", orangeBright: "185 110 88",
|
||||
green: "163 190 140", greenBright: "135 162 110",
|
||||
yellow: "235 203 139", yellowBright: "200 170 100",
|
||||
blue: "94 129 172", blueBright: "75 108 150",
|
||||
purple: "180 142 173", purpleBright: "155 115 148",
|
||||
aqua: "143 188 187", aquaBright: "110 160 162",
|
||||
surface: "229 233 240",
|
||||
},
|
||||
canvasPalette: [[191,97,106],[163,190,140],[235,203,139],[94,129,172],[180,142,173],[143,188,187]],
|
||||
};
|
||||
|
||||
export const nord: ThemeFamily = {
|
||||
id: "nord",
|
||||
name: "Nord",
|
||||
themes: [dark, light],
|
||||
default: "nord-dark",
|
||||
};
|
||||