Compare commits
6 Commits
174ca69dcd
...
bab4a516be
| Author | SHA1 | Date | |
|---|---|---|---|
|
bab4a516be
|
|||
|
adc1f21204
|
|||
|
99e4e65d92
|
|||
|
11f05e0d6f
|
|||
|
367470b54e
|
|||
|
78f1bc2ef6
|
@@ -1,5 +0,0 @@
|
||||
timmypidashev.local {
|
||||
tls internal
|
||||
|
||||
reverse_proxy timmypidashev.dev:4321
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
timmypidashev.dev {
|
||||
tls pidashev.tim@gmail.com
|
||||
|
||||
reverse_proxy timmypidashev.dev:3000
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
ARG CONTAINER_WEB_VERSION
|
||||
ARG ENVIRONMENT
|
||||
ARG BUILD_DATE
|
||||
ARG GIT_COMMIT
|
||||
|
||||
RUN set -eux \
|
||||
& apk add \
|
||||
--no-cache \
|
||||
nodejs \
|
||||
curl
|
||||
|
||||
RUN curl -L https://unpkg.com/@pnpm/self-installer | node
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN echo "PUBLIC_VERSION=${CONTAINER_WEB_VERSION}" > /app/.env && \
|
||||
echo "PUBLIC_ENVIRONMENT=${ENVIRONMENT}" >> /app/.env && \
|
||||
echo "PUBLIC_BUILD_DATE=${BUILD_DATE}" >> /app/.env && \
|
||||
echo "PUBLIC_GIT_COMMIT=${GIT_COMMIT}" >> /app/.env
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["pnpm", "run", "dev"]
|
||||
@@ -1,47 +0,0 @@
|
||||
# Stage 1: Build and install dependencies
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install necessary dependencies, including pnpm
|
||||
RUN set -eux \
|
||||
&& apk add --no-cache nodejs curl \
|
||||
&& npm install -g pnpm
|
||||
|
||||
# Copy package files first (for better caching)
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Now copy the rest of your source code
|
||||
COPY . .
|
||||
|
||||
# Set build arguments
|
||||
ARG CONTAINER_WEB_VERSION
|
||||
ARG ENVIRONMENT
|
||||
ARG BUILD_DATE
|
||||
ARG GIT_COMMIT
|
||||
|
||||
# Create .env file with build-time environment variables
|
||||
RUN echo "PUBLIC_VERSION=${CONTAINER_WEB_VERSION}" > /app/.env && \
|
||||
echo "PUBLIC_ENVIRONMENT=${ENVIRONMENT}" >> /app/.env && \
|
||||
echo "PUBLIC_BUILD_DATE=${BUILD_DATE}" >> /app/.env && \
|
||||
echo "PUBLIC_GIT_COMMIT=${GIT_COMMIT}" >> /app/.env
|
||||
|
||||
# Build the project
|
||||
RUN pnpm run build
|
||||
|
||||
# Stage 2: Serve static files
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built files
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
|
||||
# Expose port 3000
|
||||
EXPOSE 3000
|
||||
|
||||
# Deployment command
|
||||
CMD ["node", "./dist/server/entry.mjs"]
|
||||
83
.github/scripts/deploy_release.sh
vendored
@@ -1,83 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Set variables
|
||||
BRANCH_NAME="$1"
|
||||
COMMIT_HASH="$2"
|
||||
GHCR_USERNAME="$3"
|
||||
GHCR_TOKEN="$4"
|
||||
DEPLOY_TYPE="$5"
|
||||
REPO_OWNER="$6"
|
||||
COMPOSE_FILE="$7"
|
||||
CADDYFILE="$8"
|
||||
MAKEFILE="$9"
|
||||
|
||||
# Echo out variable names and their content on single lines
|
||||
echo "BRANCH_NAME: $BRANCH_NAME"
|
||||
echo "COMMIT_HASH: $COMMIT_HASH"
|
||||
echo "GHCR_USERNAME: $GHCR_USERNAME"
|
||||
echo "DEPLOY_TYPE: $DEPLOY_TYPE"
|
||||
echo "REPO_OWNER: $REPO_OWNER"
|
||||
echo "COMPOSE_FILE: $COMPOSE_FILE"
|
||||
echo "CADDYFILE: $CADDYFILE"
|
||||
echo "MAKEFILE: $MAKEFILE"
|
||||
|
||||
# Set the staging directory
|
||||
STAGING_DIR="/root/deployments/.staging-${COMMIT_HASH}"
|
||||
|
||||
# Set the tmux session name for release
|
||||
TMUX_SESSION="deployment-release"
|
||||
|
||||
# Function to cleanup existing release deployment
|
||||
cleanup_release_deployment() {
|
||||
echo "Cleaning up existing release deployment..."
|
||||
# Stop and remove all release containers
|
||||
docker-compose -f "/root/deployments/release/docker-compose.yml" down -v 2>/dev/null
|
||||
# Remove release images
|
||||
docker rmi $(docker images "ghcr.io/$REPO_OWNER/*:release" -q) 2>/dev/null
|
||||
# Kill release tmux session if it exists
|
||||
tmux kill-session -t "$TMUX_SESSION" 2>/dev/null
|
||||
# Remove release deployment directory
|
||||
rm -rf /root/deployments/release
|
||||
}
|
||||
|
||||
# Function to create deployment directory
|
||||
create_deployment_directory() {
|
||||
echo "Creating deployment directory..."
|
||||
mkdir -p /root/deployments/release
|
||||
}
|
||||
|
||||
# Function to pull Docker images
|
||||
pull_docker_images() {
|
||||
echo "Pulling Docker images..."
|
||||
docker pull ghcr.io/$REPO_OWNER/web:release
|
||||
}
|
||||
|
||||
# Function to copy and prepare files
|
||||
copy_and_prepare_files() {
|
||||
echo "Copying and preparing files..."
|
||||
# Copy files preserving names and locations
|
||||
install -D "$STAGING_DIR/$COMPOSE_FILE" "/root/deployments/release/$COMPOSE_FILE"
|
||||
install -D "$STAGING_DIR/$CADDYFILE" "/root/deployments/release/$CADDYFILE"
|
||||
install -D "$STAGING_DIR/$MAKEFILE" "/root/deployments/release/$MAKEFILE"
|
||||
# Replace {$COMMIT_HASH} with $COMMIT_HASH in $CADDYFILE
|
||||
sed -i "s/{\$COMMIT_HASH}/$COMMIT_HASH/g" "/root/deployments/release/$CADDYFILE"
|
||||
# Replace {commit_hash} with $COMMIT_HASH in $COMPOSE_FILE
|
||||
sed -i "s/{commit_hash}/$COMMIT_HASH/g" "/root/deployments/release/$COMPOSE_FILE"
|
||||
}
|
||||
|
||||
# Function to start the deployment
|
||||
start_deployment() {
|
||||
echo "Starting deployment..."
|
||||
# Create new tmux session with specific name
|
||||
tmux new-session -d -s "$TMUX_SESSION"
|
||||
tmux send-keys -t "$TMUX_SESSION" "cd /root/deployments/release && make run release" Enter
|
||||
}
|
||||
|
||||
# Main execution
|
||||
cleanup_release_deployment
|
||||
create_deployment_directory
|
||||
copy_and_prepare_files
|
||||
cd "/root/deployments/release"
|
||||
pull_docker_images
|
||||
start_deployment
|
||||
echo "Release build $COMMIT_HASH deployed successfully!"
|
||||
4
.gitmodules
vendored
@@ -1,3 +1,3 @@
|
||||
[submodule "src/public/scripts"]
|
||||
path = src/public/scripts
|
||||
[submodule "public/scripts"]
|
||||
path = public/scripts
|
||||
url = https://github.com/timmypidashev/scripts
|
||||
|
||||
186
Makefile
@@ -1,186 +0,0 @@
|
||||
PROJECT_NAME := "timmypidashev.dev"
|
||||
PROJECT_AUTHORS := "Timothy Pidashev (timmypidashev) <pidashev.tim@gmail.com>"
|
||||
PROJECT_VERSION := "v2.1.1"
|
||||
PROJECT_LICENSE := "MIT"
|
||||
PROJECT_SOURCES := "https://github.com/timmypidashev/web"
|
||||
PROJECT_REGISTRY := "ghcr.io/timmypidashev"
|
||||
PROJECT_ORGANIZATION := "org.opencontainers"
|
||||
|
||||
CONTAINER_WEB_NAME := "timmypidashev.dev"
|
||||
CONTAINER_WEB_VERSION := "v2.1.1"
|
||||
CONTAINER_WEB_LOCATION := "src/"
|
||||
CONTAINER_WEB_DESCRIPTION := "My portfolio website!"
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
.PHONY: watch run build push prune bump exec
|
||||
.SILENT: watch run build push prune bump exec
|
||||
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " run - Runs the docker compose file with the specified environment (local or release)"
|
||||
@echo " build - Builds the specified docker image with the appropriate environment"
|
||||
@echo " push - Pushes the built docker image to the registry"
|
||||
@echo " prune - Removes all built and cached docker images and containers"
|
||||
@echo " bump - Bumps the project and container versions"
|
||||
|
||||
run:
|
||||
# Arguments:
|
||||
# [environment]: 'local' or 'release'
|
||||
#
|
||||
# Explanation:
|
||||
# * Runs the docker compose file with the specified environment(compose.local.yml, or compose.release.yml)
|
||||
# * Passes all generated arguments to the compose file.
|
||||
|
||||
# Make sure we have been given proper arguments.
|
||||
@if [ "$(word 2,$(MAKECMDGOALS))" = "local" ]; then \
|
||||
echo "Running in local environment"; \
|
||||
elif [ "$(word 2,$(MAKECMDGOALS))" = "release" ]; then \
|
||||
echo "Running in release environment"; \
|
||||
else \
|
||||
echo "Invalid usage. Please use 'make run local' or 'make run release'"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
docker compose -f compose.$(word 2,$(MAKECMDGOALS)).yml up --remove-orphans
|
||||
|
||||
|
||||
build:
|
||||
# Arguments
|
||||
# [container]: Build context(which container to build ['all' to build every container defined])
|
||||
# [environment]: 'local' or 'release'
|
||||
#
|
||||
# Explanation:
|
||||
# * Builds the specified docker image with the appropriate environment.
|
||||
# * Passes all generated arguments to docker build-kit.
|
||||
|
||||
# Extract container and environment inputted.
|
||||
$(eval INPUT_TARGET := $(word 2,$(MAKECMDGOALS)))
|
||||
$(eval INPUT_CONTAINER := $(firstword $(subst :, ,$(INPUT_TARGET))))
|
||||
$(eval INPUT_ENVIRONMENT := $(lastword $(subst :, ,$(INPUT_TARGET))))
|
||||
|
||||
# Call function container_build either through a for loop for each container
|
||||
# if all is called, or singularly to build the container.
|
||||
$(if $(filter $(strip $(INPUT_CONTAINER)),all), \
|
||||
$(foreach container,$(containers),$(call container_build,$(container) $(INPUT_ENVIRONMENT))), \
|
||||
$(call container_build,$(INPUT_CONTAINER) $(INPUT_ENVIRONMENT)))
|
||||
|
||||
push:
|
||||
# Arguments
|
||||
# [container]: Push context(which container to push to the registry)
|
||||
# [version]: Container version to push.
|
||||
#
|
||||
# Explanation:
|
||||
# * Pushes the specified container version to the registry defined in the user configuration.
|
||||
|
||||
# Extract container and version inputted.
|
||||
$(eval INPUT_TARGET := $(word 2,$(MAKECMDGOALS)))
|
||||
$(eval INPUT_CONTAINER := $(firstword $(subst :, ,$(INPUT_TARGET))))
|
||||
$(eval INPUT_VERSION := $(lastword $(subst :, ,$(INPUT_TARGET))))
|
||||
|
||||
# Push the specified container version to the registry.
|
||||
# NOTE: docker will complain if the container tag is invalid, no need to validate here.
|
||||
@docker push $(PROJECT_REGISTRY)/$(INPUT_CONTAINER):$(INPUT_VERSION)
|
||||
|
||||
prune:
|
||||
# Removes all built and cached docker images and containers.
|
||||
|
||||
bump:
|
||||
# Arguments
|
||||
# [container]: Bump context(which container version to bump)
|
||||
# [semantic_type]: Semantic type context(major, minor, patch)
|
||||
#
|
||||
# Explanation:
|
||||
# * Bumps the specified container version within the makefile config and the container's package.json.
|
||||
# * Bumps the global project version in the makefile, and creates a new git tag with said version.
|
||||
|
||||
# Extract container and semantic_type inputted.
|
||||
$(eval INPUT_TARGET := $(word 2,$(MAKECMDGOALS)))
|
||||
$(eval INPUT_CONTAINER := $(firstword $(subst :, ,$(INPUT_TARGET))))
|
||||
$(eval INPUT_SEMANTIC_TYPE := $(lastword $(subst :, ,$(INPUT_TARGET))))
|
||||
|
||||
# Extract old container and project versions.
|
||||
$(eval OLD_CONTAINER_VERSION := $(subst v,,$(CONTAINER_$(shell echo $(INPUT_CONTAINER) | tr a-z A-Z)_VERSION)))
|
||||
$(eval OLD_PROJECT_VERSION := $(subst v,,$(PROJECT_VERSION)))
|
||||
|
||||
# Pull docker semver becsause the normal command doesn't seem to work; also we don't need to worry about dependencies.
|
||||
docker pull usvc/semver:latest
|
||||
|
||||
# Bump npm package.json file for selected container
|
||||
cd $(call container_location,$(INPUT_CONTAINER)) && npm version $(shell docker run usvc/semver:latest bump $(INPUT_SEMANTIC_TYPE) $(OLD_CONTAINER_VERSION))
|
||||
|
||||
# Bump the git tag to match the new global version
|
||||
git tag v$(shell docker run usvc/semver:latest bump $(INPUT_SEMANTIC_TYPE) $(OLD_PROJECT_VERSION))
|
||||
|
||||
# Bump the container version and global version in the Makefile
|
||||
perl -pi -e 's/^PROJECT_VERSION\s*:=\s*\K.*/"v$(shell docker run usvc/semver:latest bump $(INPUT_SEMANTIC_TYPE) $(OLD_PROJECT_VERSION))"/ if /^PROJECT_VERSION\s*:=/' Makefile;
|
||||
perl -pi -e 's/^CONTAINER_$(shell echo $(INPUT_CONTAINER) | tr a-z A-Z)_VERSION\s*:=\s*\K.*/"v$(shell docker run usvc/semver:latest bump $(INPUT_SEMANTIC_TYPE) $(OLD_CONTAINER_VERSION))"/ if /^CONTAINER_$(shell echo $(INPUT_CONTAINER) | tr a-z A-Z)_VERSION\s*:=/' Makefile;
|
||||
|
||||
# This function generates Docker build arguments based on variables defined in the Makefile.
|
||||
# It extracts variable assignments, removes whitespace, and formats them as build arguments.
|
||||
# Additionally, it appends any custom shell generated arguments defined below.
|
||||
define args
|
||||
$(shell \
|
||||
grep -E '^[[:alnum:]_]+[[:space:]]*[:?]?[[:space:]]*=' $(MAKEFILE_LIST) | \
|
||||
awk 'BEGIN {FS = ":="} { \
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$$/, "", $$2); \
|
||||
gsub(/^/, "\x27", $$2); \
|
||||
gsub(/$$/, "\x27", $$2); \
|
||||
gsub(/[[:space:]]+/, "", $$1); \
|
||||
gsub(":", "", $$1); \
|
||||
printf "--build-arg %s=%s ", $$1, $$2 \
|
||||
}') \
|
||||
--build-arg BUILD_DATE='"$(shell date)"' \
|
||||
--build-arg GIT_COMMIT='"$(shell git rev-parse HEAD)"'
|
||||
endef
|
||||
|
||||
# This function generates labels based on variables defined in the Makefile.
|
||||
# It extracts only the selected container variables and is used to echo this information
|
||||
# to the docker buildx engine in the command line.
|
||||
define labels
|
||||
--label $(PROJECT_ORGANIZATION).image.title=$(CONTAINER_$(1)_NAME) \
|
||||
--label $(PROJECT_ORGANIZATION).image.description=$(CONTAINER_$(1)_DESCRIPTION) \
|
||||
--label $(PROJECT_ORGANIZATION).image.authors=$(PROJECT_AUTHORS) \
|
||||
--label $(PROJECT_ORGANIZATION).image.url=$(PROJECT_SOURCES) \
|
||||
--label $(PROJECT_ORGANIZATION).image.source=$(PROJECT_SOURCES)/$(CONTAINER_$(1)_LOCATION)
|
||||
endef
|
||||
|
||||
# This function returns a list of container names defined in the Makefile.
|
||||
# In order for this function to return a container, it needs to have this format: CONTAINER_%_NAME!
|
||||
define containers
|
||||
$(strip $(filter-out $(_NL),$(foreach var,$(.VARIABLES),$(if $(filter CONTAINER_%_NAME,$(var)),$(strip $($(var)))))))
|
||||
endef
|
||||
|
||||
define container_build
|
||||
$(eval CONTAINER := $(word 1,$1))
|
||||
$(eval ENVIRONMENT := $(word 2,$1))
|
||||
$(eval ARGS := $(shell echo $(args)))
|
||||
$(eval VERSION := $(strip $(call container_version,$(CONTAINER))))
|
||||
$(eval TAG := $(PROJECT_NAME):$(ENVIRONMENT))
|
||||
|
||||
@echo "Building container: $(CONTAINER)"
|
||||
@echo "Environment: $(ENVIRONMENT)"
|
||||
@echo "Version: $(VERSION)"
|
||||
|
||||
@if [ "$(strip $(ENVIRONMENT))" != "local" ] && [ "$(strip $(ENVIRONMENT))" != "release" ]; then \
|
||||
echo "Invalid environment. Please specify 'local' or 'release'"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
$(if $(filter $(strip $(ENVIRONMENT)),release), \
|
||||
$(eval TAG := $(PROJECT_REGISTRY)/$(PROJECT_NAME):$(VERSION)), \
|
||||
)
|
||||
|
||||
docker buildx build --load -t $(TAG) -f .docker/Dockerfile.$(ENVIRONMENT) ./$(strip $(subst $(SPACE),,$(call container_location,$(CONTAINER))))/. $(ARGS) $(call labels,$(shell echo $(CONTAINER_NAME) | tr '[:lower:]' '[:upper:]')) --no-cache
|
||||
endef
|
||||
|
||||
define container_location
|
||||
$(strip $(eval CONTAINER_NAME := $(shell echo $(1) | tr '[:lower:]' '[:upper:]'))) \
|
||||
$(CONTAINER_$(CONTAINER_NAME)_LOCATION)
|
||||
endef
|
||||
|
||||
define container_version
|
||||
$(strip $(eval CONTAINER_NAME := $(shell echo $(1) | tr '[:lower:]' '[:upper:]'))) \
|
||||
$(if $(CONTAINER_$(CONTAINER_NAME)_VERSION), \
|
||||
$(shell echo $(strip $(strip $(CONTAINER_$(CONTAINER_NAME)_VERSION))) | tr -d '[:space:]'), \
|
||||
$(error Version data for container $(1) not found))
|
||||
endef
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineConfig } from "astro/config";
|
||||
import node from "@astrojs/node";
|
||||
import vercel from "@astrojs/vercel";
|
||||
import tailwind from "@astrojs/tailwind";
|
||||
import react from "@astrojs/react";
|
||||
import mdx from "@astrojs/mdx";
|
||||
@@ -10,13 +10,7 @@ import sitemap from "@astrojs/sitemap";
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
output: "server",
|
||||
server: {
|
||||
host: true,
|
||||
port: 3000,
|
||||
},
|
||||
adapter: node({
|
||||
mode: "standalone",
|
||||
}),
|
||||
adapter: vercel(),
|
||||
site: "https://timmypidashev.dev",
|
||||
build: {
|
||||
// Enable build-time optimizations
|
||||
@@ -1,32 +0,0 @@
|
||||
services:
|
||||
caddy:
|
||||
container_name: caddy
|
||||
image: caddy:latest
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443
|
||||
volumes:
|
||||
- ./.caddy/Caddyfile.release:/etc/caddy/Caddyfile:rw
|
||||
networks:
|
||||
- proxy_network
|
||||
depends_on:
|
||||
- timmypidashev.dev
|
||||
|
||||
watchtower:
|
||||
container_name: updates
|
||||
image: containrrr/watchtower
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- $HOME/.docker/config.json:/config.json
|
||||
command: --interval 120 --cleanup --label-enable
|
||||
|
||||
timmypidashev.dev:
|
||||
container_name: timmypidashev.dev
|
||||
image: ghcr.io/timmypidashev/timmypidashev.dev:latest
|
||||
networks:
|
||||
- proxy_network
|
||||
|
||||
networks:
|
||||
proxy_network:
|
||||
name: proxy_network
|
||||
external: true
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "src",
|
||||
"version": "2.1.1",
|
||||
"name": "timmypidashev-web",
|
||||
"version": "3.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev --host",
|
||||
@@ -18,14 +18,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^5.0.3",
|
||||
"@astrojs/node": "^10.0.4",
|
||||
"@astrojs/rss": "^4.0.18",
|
||||
"@astrojs/sitemap": "^3.7.2",
|
||||
"@astrojs/vercel": "^10.0.3",
|
||||
"@giscus/react": "^3.1.0",
|
||||
"@pilcrowjs/object-parser": "^0.0.4",
|
||||
"@react-hook/intersection-observer": "^3.1.2",
|
||||
"@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",
|
||||
"react": "^18.3.1",
|
||||
679
src/pnpm-lock.yaml → pnpm-lock.yaml
generated
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 6.8 MiB After Width: | Height: | Size: 6.8 MiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 642 B After Width: | Height: | Size: 642 B |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
1
public/scripts
Submodule
@@ -1,11 +0,0 @@
|
||||
# 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,4 +1,4 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const GlitchText = () => {
|
||||
const originalText = 'Error 404';
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDownIcon } from "@/components/icons";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
export default function Intro() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
@@ -97,7 +97,7 @@ export default function Intro() {
|
||||
className="text-foreground/50 hover:text-yellow-bright transition-colors duration-300"
|
||||
aria-label="Scroll to next section"
|
||||
>
|
||||
<ChevronDownIcon size={40} className="animate-bounce" />
|
||||
<ChevronDown size={40} className="animate-bounce" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
|
||||
interface ActivityDay {
|
||||
grand_total: { total_seconds: number };
|
||||
11
src/components/analytics.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
import { SpeedInsights } from "@vercel/speed-insights/react";
|
||||
|
||||
export default function VercelAnalytics() {
|
||||
return (
|
||||
<>
|
||||
<Analytics />
|
||||
<SpeedInsights />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface AnimateInProps {
|
||||
children: React.ReactNode;
|
||||
@@ -8,17 +8,17 @@ import { ANIMATION_LABELS } from "@/lib/animations";
|
||||
|
||||
export default function AnimationSwitcher() {
|
||||
const [hovering, setHovering] = useState(false);
|
||||
const [nextLabel, setNextLabel] = useState("");
|
||||
const [currentLabel, setCurrentLabel] = useState("");
|
||||
const committedRef = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
committedRef.current = getStoredAnimationId();
|
||||
setNextLabel(ANIMATION_LABELS[getNextAnimation(committedRef.current)]);
|
||||
setCurrentLabel(ANIMATION_LABELS[committedRef.current]);
|
||||
|
||||
const handleSwap = () => {
|
||||
const id = getStoredAnimationId();
|
||||
committedRef.current = id;
|
||||
setNextLabel(ANIMATION_LABELS[getNextAnimation(id)]);
|
||||
setCurrentLabel(ANIMATION_LABELS[id]);
|
||||
};
|
||||
|
||||
document.addEventListener("astro:after-swap", handleSwap);
|
||||
@@ -33,7 +33,7 @@ export default function AnimationSwitcher() {
|
||||
);
|
||||
saveAnimation(nextId);
|
||||
committedRef.current = nextId;
|
||||
setNextLabel(ANIMATION_LABELS[getNextAnimation(nextId)]);
|
||||
setCurrentLabel(ANIMATION_LABELS[nextId]);
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("animation-changed", { detail: { id: nextId } })
|
||||
);
|
||||
@@ -51,7 +51,7 @@ export default function AnimationSwitcher() {
|
||||
className="text-foreground font-bold text-sm select-none transition-opacity duration-200"
|
||||
style={{ opacity: hovering ? 0.8 : 0.15 }}
|
||||
>
|
||||
{nextLabel}
|
||||
{currentLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
574
src/components/background/engines/asciiquarium.ts
Normal file
@@ -0,0 +1,574 @@
|
||||
import type { AnimationEngine } from "@/lib/animations/types";
|
||||
|
||||
// --- ASCII Art ---
|
||||
|
||||
interface AsciiPattern {
|
||||
lines: string[];
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function pat(lines: string[]): AsciiPattern {
|
||||
return {
|
||||
lines,
|
||||
width: Math.max(...lines.map((l) => l.length)),
|
||||
height: lines.length,
|
||||
};
|
||||
}
|
||||
|
||||
const FISH_DEFS: {
|
||||
size: "small" | "medium";
|
||||
weight: number;
|
||||
right: AsciiPattern;
|
||||
left: AsciiPattern;
|
||||
}[] = [
|
||||
{ size: "small", weight: 30, right: pat(["><>"]), left: pat(["<><"]) },
|
||||
{
|
||||
size: "small",
|
||||
weight: 30,
|
||||
right: pat(["><(('>"]),
|
||||
left: pat(["<'))><"]),
|
||||
},
|
||||
{
|
||||
size: "medium",
|
||||
weight: 20,
|
||||
right: pat(["><((o>"]),
|
||||
left: pat(["<o))><"]),
|
||||
},
|
||||
{
|
||||
size: "medium",
|
||||
weight: 10,
|
||||
right: pat(["><((('>"]),
|
||||
left: pat(["<')))><"]),
|
||||
},
|
||||
];
|
||||
|
||||
const TOTAL_FISH_WEIGHT = FISH_DEFS.reduce((s, d) => s + d.weight, 0);
|
||||
|
||||
const BUBBLE_CHARS = [".", "o", "O"];
|
||||
|
||||
// --- Entity Interfaces ---
|
||||
|
||||
interface FishEntity {
|
||||
kind: "fish";
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
pattern: AsciiPattern;
|
||||
size: "small" | "medium";
|
||||
color: [number, number, number];
|
||||
baseColor: [number, number, number];
|
||||
opacity: number;
|
||||
elevation: number;
|
||||
targetElevation: number;
|
||||
staggerDelay: number;
|
||||
}
|
||||
|
||||
interface BubbleEntity {
|
||||
kind: "bubble";
|
||||
x: number;
|
||||
y: number;
|
||||
vy: number;
|
||||
wobblePhase: number;
|
||||
wobbleAmplitude: number;
|
||||
char: string;
|
||||
color: [number, number, number];
|
||||
baseColor: [number, number, number];
|
||||
opacity: number;
|
||||
elevation: number;
|
||||
targetElevation: number;
|
||||
staggerDelay: number;
|
||||
burst: boolean;
|
||||
}
|
||||
|
||||
type AquariumEntity = FishEntity | BubbleEntity;
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
const BASE_AREA = 1920 * 1080;
|
||||
const BASE_FISH = 16;
|
||||
const BASE_BUBBLES = 12;
|
||||
|
||||
const TARGET_FPS = 60;
|
||||
const FONT_SIZE_MIN = 24;
|
||||
const FONT_SIZE_MAX = 36;
|
||||
const FONT_SIZE_REF_WIDTH = 1920;
|
||||
const LINE_HEIGHT_RATIO = 1.15;
|
||||
const STAGGER_INTERVAL = 15;
|
||||
const PI_2 = Math.PI * 2;
|
||||
|
||||
const MOUSE_INFLUENCE_RADIUS = 150;
|
||||
const ELEVATION_FACTOR = 6;
|
||||
const ELEVATION_LERP_SPEED = 0.05;
|
||||
const COLOR_SHIFT_AMOUNT = 30;
|
||||
const SHADOW_OFFSET_RATIO = 1.1;
|
||||
|
||||
const FISH_SPEED: Record<string, { min: number; max: number }> = {
|
||||
small: { min: 0.8, max: 1.4 },
|
||||
medium: { min: 0.5, max: 0.9 },
|
||||
};
|
||||
|
||||
const BUBBLE_SPEED_MIN = 0.3;
|
||||
const BUBBLE_SPEED_MAX = 0.7;
|
||||
const BUBBLE_WOBBLE_MIN = 0.3;
|
||||
const BUBBLE_WOBBLE_MAX = 1.0;
|
||||
|
||||
const BURST_BUBBLE_COUNT = 10;
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function range(a: number, b: number): number {
|
||||
return (b - a) * Math.random() + a;
|
||||
}
|
||||
|
||||
function pickFishDef() {
|
||||
let r = Math.random() * TOTAL_FISH_WEIGHT;
|
||||
for (const def of FISH_DEFS) {
|
||||
r -= def.weight;
|
||||
if (r <= 0) return def;
|
||||
}
|
||||
return FISH_DEFS[0];
|
||||
}
|
||||
|
||||
// --- Engine ---
|
||||
|
||||
export class AsciiquariumEngine implements AnimationEngine {
|
||||
id = "asciiquarium";
|
||||
name = "Asciiquarium";
|
||||
|
||||
private fish: FishEntity[] = [];
|
||||
private bubbles: BubbleEntity[] = [];
|
||||
private exiting = false;
|
||||
private palette: [number, number, number][] = [];
|
||||
private width = 0;
|
||||
private height = 0;
|
||||
private mouseX = -1000;
|
||||
private mouseY = -1000;
|
||||
private elapsed = 0;
|
||||
private charWidth = 0;
|
||||
private fontSize = FONT_SIZE_MAX;
|
||||
private lineHeight = FONT_SIZE_MAX * LINE_HEIGHT_RATIO;
|
||||
private font = `bold ${FONT_SIZE_MAX}px monospace`;
|
||||
|
||||
private computeFont(width: number): void {
|
||||
const t = Math.sqrt(Math.min(1, width / FONT_SIZE_REF_WIDTH));
|
||||
this.fontSize = Math.round(FONT_SIZE_MIN + (FONT_SIZE_MAX - FONT_SIZE_MIN) * t);
|
||||
this.lineHeight = Math.round(this.fontSize * LINE_HEIGHT_RATIO);
|
||||
this.font = `bold ${this.fontSize}px monospace`;
|
||||
this.charWidth = 0;
|
||||
}
|
||||
|
||||
init(
|
||||
width: number,
|
||||
height: number,
|
||||
palette: [number, number, number][],
|
||||
_bgColor: string
|
||||
): void {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.palette = palette;
|
||||
this.elapsed = 0;
|
||||
this.computeFont(width);
|
||||
this.initEntities();
|
||||
}
|
||||
|
||||
beginExit(): void {
|
||||
if (this.exiting) return;
|
||||
this.exiting = true;
|
||||
|
||||
// Stagger fade-out over 3 seconds
|
||||
for (const f of this.fish) {
|
||||
const delay = Math.random() * 3000;
|
||||
setTimeout(() => {
|
||||
f.staggerDelay = -2; // signal: fading out
|
||||
}, delay);
|
||||
}
|
||||
for (const b of this.bubbles) {
|
||||
const delay = Math.random() * 3000;
|
||||
setTimeout(() => {
|
||||
b.staggerDelay = -2;
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
isExitComplete(): boolean {
|
||||
if (!this.exiting) return false;
|
||||
for (const f of this.fish) {
|
||||
if (f.opacity > 0.01) return false;
|
||||
}
|
||||
for (const b of this.bubbles) {
|
||||
if (b.opacity > 0.01) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.fish = [];
|
||||
this.bubbles = [];
|
||||
}
|
||||
|
||||
private randomColor(): [number, number, number] {
|
||||
return this.palette[Math.floor(Math.random() * this.palette.length)];
|
||||
}
|
||||
|
||||
private getCounts(): { fish: number; bubbles: number } {
|
||||
const ratio = (this.width * this.height) / BASE_AREA;
|
||||
return {
|
||||
fish: Math.max(5, Math.round(BASE_FISH * ratio)),
|
||||
bubbles: Math.max(5, Math.round(BASE_BUBBLES * ratio)),
|
||||
};
|
||||
}
|
||||
|
||||
private initEntities(): void {
|
||||
this.fish = [];
|
||||
this.bubbles = [];
|
||||
|
||||
const counts = this.getCounts();
|
||||
let idx = 0;
|
||||
|
||||
for (let i = 0; i < counts.fish; i++) {
|
||||
this.fish.push(this.spawnFish(idx++));
|
||||
}
|
||||
|
||||
for (let i = 0; i < counts.bubbles; i++) {
|
||||
this.bubbles.push(this.spawnBubble(idx++, false));
|
||||
}
|
||||
}
|
||||
|
||||
private spawnFish(staggerIdx: number): FishEntity {
|
||||
const def = pickFishDef();
|
||||
const goRight = Math.random() > 0.5;
|
||||
const speed = range(FISH_SPEED[def.size].min, FISH_SPEED[def.size].max);
|
||||
const pattern = goRight ? def.right : def.left;
|
||||
const baseColor = this.randomColor();
|
||||
const cw = this.charWidth || 9.6;
|
||||
const pw = pattern.width * cw;
|
||||
|
||||
// Start off-screen on the side they swim from
|
||||
const startX = goRight
|
||||
? -pw - range(0, this.width * 0.5)
|
||||
: this.width + range(0, this.width * 0.5);
|
||||
|
||||
return {
|
||||
kind: "fish",
|
||||
x: startX,
|
||||
y: range(this.height * 0.05, this.height * 0.9),
|
||||
vx: goRight ? speed : -speed,
|
||||
pattern,
|
||||
size: def.size,
|
||||
color: [...baseColor],
|
||||
baseColor,
|
||||
opacity: 1,
|
||||
elevation: 0,
|
||||
targetElevation: 0,
|
||||
staggerDelay: -1,
|
||||
};
|
||||
}
|
||||
|
||||
private spawnBubble(staggerIdx: number, burst: boolean): BubbleEntity {
|
||||
const baseColor = this.randomColor();
|
||||
return {
|
||||
kind: "bubble",
|
||||
x: range(0, this.width),
|
||||
y: burst ? 0 : this.height + range(10, this.height * 0.5),
|
||||
vy: -range(BUBBLE_SPEED_MIN, BUBBLE_SPEED_MAX),
|
||||
wobblePhase: range(0, PI_2),
|
||||
wobbleAmplitude: range(BUBBLE_WOBBLE_MIN, BUBBLE_WOBBLE_MAX),
|
||||
char: BUBBLE_CHARS[Math.floor(Math.random() * BUBBLE_CHARS.length)],
|
||||
color: [...baseColor],
|
||||
baseColor,
|
||||
opacity: 1,
|
||||
elevation: 0,
|
||||
targetElevation: 0,
|
||||
staggerDelay: -1,
|
||||
burst,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Update ---
|
||||
|
||||
update(deltaTime: number): void {
|
||||
const dt = deltaTime / (1000 / TARGET_FPS);
|
||||
this.elapsed += deltaTime;
|
||||
|
||||
const mouseX = this.mouseX;
|
||||
const mouseY = this.mouseY;
|
||||
const cw = this.charWidth || 9.6;
|
||||
|
||||
// Fish
|
||||
for (let i = this.fish.length - 1; i >= 0; i--) {
|
||||
const f = this.fish[i];
|
||||
if (f.staggerDelay >= 0) {
|
||||
if (this.elapsed >= f.staggerDelay) f.staggerDelay = -1;
|
||||
else continue;
|
||||
}
|
||||
|
||||
// Fade out during exit
|
||||
if (f.staggerDelay === -2) {
|
||||
f.opacity -= 0.02 * dt;
|
||||
if (f.opacity <= 0) { f.opacity = 0; continue; }
|
||||
} else if (f.opacity < 1) {
|
||||
f.opacity = Math.min(1, f.opacity + 0.03 * dt);
|
||||
}
|
||||
|
||||
f.x += f.vx * dt;
|
||||
|
||||
const pw = f.pattern.width * cw;
|
||||
if (f.vx > 0 && f.x > this.width + pw) {
|
||||
f.x = -pw;
|
||||
} else if (f.vx < 0 && f.x < -pw) {
|
||||
f.x = this.width + pw;
|
||||
}
|
||||
|
||||
const cx = f.x + (f.pattern.width * cw) / 2;
|
||||
const cy = f.y + (f.pattern.height * this.lineHeight) / 2;
|
||||
this.applyMouseInfluence(f, cx, cy, mouseX, mouseY, dt);
|
||||
}
|
||||
|
||||
// Bubbles (reverse iteration for safe splice)
|
||||
for (let i = this.bubbles.length - 1; i >= 0; i--) {
|
||||
const b = this.bubbles[i];
|
||||
|
||||
if (b.staggerDelay >= 0) {
|
||||
if (this.elapsed >= b.staggerDelay) b.staggerDelay = -1;
|
||||
else continue;
|
||||
}
|
||||
|
||||
// Fade out during exit
|
||||
if (b.staggerDelay === -2) {
|
||||
b.opacity -= 0.02 * dt;
|
||||
if (b.opacity <= 0) { b.opacity = 0; continue; }
|
||||
} else if (b.opacity < 1) {
|
||||
b.opacity = Math.min(1, b.opacity + 0.03 * dt);
|
||||
}
|
||||
|
||||
b.y += b.vy * dt;
|
||||
b.x +=
|
||||
Math.sin(this.elapsed * 0.003 + b.wobblePhase) *
|
||||
b.wobbleAmplitude *
|
||||
dt;
|
||||
|
||||
if (b.y < -20) {
|
||||
if (b.burst) {
|
||||
this.bubbles.splice(i, 1);
|
||||
continue;
|
||||
} else {
|
||||
b.y = this.height + range(10, 40);
|
||||
b.x = range(0, this.width);
|
||||
}
|
||||
}
|
||||
|
||||
this.applyMouseInfluence(b, b.x, b.y, mouseX, mouseY, dt);
|
||||
}
|
||||
}
|
||||
|
||||
private applyMouseInfluence(
|
||||
entity: AquariumEntity,
|
||||
cx: number,
|
||||
cy: number,
|
||||
mouseX: number,
|
||||
mouseY: number,
|
||||
dt: number
|
||||
): void {
|
||||
const dx = cx - mouseX;
|
||||
const dy = cy - mouseY;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist < MOUSE_INFLUENCE_RADIUS && entity.opacity > 0.1) {
|
||||
const inf = Math.cos((dist / MOUSE_INFLUENCE_RADIUS) * (Math.PI / 2));
|
||||
entity.targetElevation = ELEVATION_FACTOR * inf * inf;
|
||||
|
||||
const shift = inf * COLOR_SHIFT_AMOUNT * 0.5;
|
||||
entity.color = [
|
||||
Math.min(255, Math.max(0, entity.baseColor[0] + shift)),
|
||||
Math.min(255, Math.max(0, entity.baseColor[1] + shift)),
|
||||
Math.min(255, Math.max(0, entity.baseColor[2] + shift)),
|
||||
];
|
||||
} else {
|
||||
entity.targetElevation = 0;
|
||||
entity.color[0] += (entity.baseColor[0] - entity.color[0]) * 0.1;
|
||||
entity.color[1] += (entity.baseColor[1] - entity.color[1]) * 0.1;
|
||||
entity.color[2] += (entity.baseColor[2] - entity.color[2]) * 0.1;
|
||||
}
|
||||
|
||||
entity.elevation +=
|
||||
(entity.targetElevation - entity.elevation) * ELEVATION_LERP_SPEED * dt;
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
|
||||
render(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
_width: number,
|
||||
_height: number
|
||||
): void {
|
||||
if (!this.charWidth) {
|
||||
ctx.font = this.font;
|
||||
this.charWidth = ctx.measureText("M").width;
|
||||
}
|
||||
|
||||
ctx.font = this.font;
|
||||
ctx.textBaseline = "top";
|
||||
|
||||
// Fish
|
||||
for (const f of this.fish) {
|
||||
if (f.opacity <= 0.01 || f.staggerDelay >= 0) continue;
|
||||
this.renderPattern(
|
||||
ctx,
|
||||
f.pattern,
|
||||
f.x,
|
||||
f.y,
|
||||
f.color,
|
||||
f.opacity,
|
||||
f.elevation
|
||||
);
|
||||
}
|
||||
|
||||
// Bubbles
|
||||
for (const b of this.bubbles) {
|
||||
if (b.opacity <= 0.01 || b.staggerDelay >= 0) continue;
|
||||
this.renderChar(ctx, b.char, b.x, b.y, b.color, b.opacity, b.elevation);
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
private renderPattern(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
pattern: AsciiPattern,
|
||||
x: number,
|
||||
y: number,
|
||||
color: [number, number, number],
|
||||
opacity: number,
|
||||
elevation: number
|
||||
): void {
|
||||
const drawY = y - elevation;
|
||||
const [r, g, b] = color;
|
||||
|
||||
// Shadow
|
||||
if (elevation > 0.5) {
|
||||
const shadowAlpha = 0.2 * (elevation / ELEVATION_FACTOR) * opacity;
|
||||
ctx.globalAlpha = shadowAlpha;
|
||||
ctx.fillStyle = "rgb(0,0,0)";
|
||||
for (let line = 0; line < pattern.height; line++) {
|
||||
ctx.fillText(
|
||||
pattern.lines[line],
|
||||
x,
|
||||
drawY + line * this.lineHeight + elevation * SHADOW_OFFSET_RATIO
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Main text
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||||
for (let line = 0; line < pattern.height; line++) {
|
||||
ctx.fillText(pattern.lines[line], x, drawY + line * this.lineHeight);
|
||||
}
|
||||
|
||||
// Highlight (top half of lines)
|
||||
if (elevation > 0.5) {
|
||||
const highlightAlpha = 0.1 * (elevation / ELEVATION_FACTOR) * opacity;
|
||||
ctx.globalAlpha = highlightAlpha;
|
||||
ctx.fillStyle = "rgb(255,255,255)";
|
||||
const topLines = Math.ceil(pattern.height / 2);
|
||||
for (let line = 0; line < topLines; line++) {
|
||||
ctx.fillText(pattern.lines[line], x, drawY + line * this.lineHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private renderChar(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
char: string,
|
||||
x: number,
|
||||
y: number,
|
||||
color: [number, number, number],
|
||||
opacity: number,
|
||||
elevation: number
|
||||
): void {
|
||||
const drawY = y - elevation;
|
||||
const [r, g, b] = color;
|
||||
|
||||
// Shadow
|
||||
if (elevation > 0.5) {
|
||||
const shadowAlpha = 0.2 * (elevation / ELEVATION_FACTOR) * opacity;
|
||||
ctx.globalAlpha = shadowAlpha;
|
||||
ctx.fillStyle = "rgb(0,0,0)";
|
||||
ctx.fillText(char, x, drawY + elevation * SHADOW_OFFSET_RATIO);
|
||||
}
|
||||
|
||||
// Main
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||||
ctx.fillText(char, x, drawY);
|
||||
|
||||
// Highlight
|
||||
if (elevation > 0.5) {
|
||||
const highlightAlpha = 0.1 * (elevation / ELEVATION_FACTOR) * opacity;
|
||||
ctx.globalAlpha = highlightAlpha;
|
||||
ctx.fillStyle = "rgb(255,255,255)";
|
||||
ctx.fillText(char, x, drawY);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Events ---
|
||||
|
||||
handleResize(width: number, height: number): void {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.elapsed = 0;
|
||||
this.exiting = false;
|
||||
this.computeFont(width);
|
||||
this.initEntities();
|
||||
}
|
||||
|
||||
handleMouseMove(x: number, y: number, _isDown: boolean): void {
|
||||
this.mouseX = x;
|
||||
this.mouseY = y;
|
||||
}
|
||||
|
||||
handleMouseDown(x: number, y: number): void {
|
||||
for (let i = 0; i < BURST_BUBBLE_COUNT; i++) {
|
||||
const baseColor = this.randomColor();
|
||||
const angle = (i / BURST_BUBBLE_COUNT) * PI_2 + range(-0.3, 0.3);
|
||||
const speed = range(0.3, 1.0);
|
||||
this.bubbles.push({
|
||||
kind: "bubble",
|
||||
x,
|
||||
y,
|
||||
vy: -Math.abs(Math.sin(angle) * speed) - 0.3,
|
||||
wobblePhase: range(0, PI_2),
|
||||
wobbleAmplitude: Math.cos(angle) * speed * 0.5,
|
||||
char: BUBBLE_CHARS[Math.floor(Math.random() * BUBBLE_CHARS.length)],
|
||||
color: [...baseColor],
|
||||
baseColor,
|
||||
opacity: 1,
|
||||
elevation: 0,
|
||||
targetElevation: 0,
|
||||
staggerDelay: this.exiting ? -2 : -1,
|
||||
burst: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseUp(): void {}
|
||||
|
||||
handleMouseLeave(): void {
|
||||
this.mouseX = -1000;
|
||||
this.mouseY = -1000;
|
||||
}
|
||||
|
||||
updatePalette(
|
||||
palette: [number, number, number][],
|
||||
_bgColor: string
|
||||
): void {
|
||||
this.palette = palette;
|
||||
for (let i = 0; i < this.fish.length; i++) {
|
||||
this.fish[i].baseColor = palette[i % palette.length];
|
||||
}
|
||||
for (let i = 0; i < this.bubbles.length; i++) {
|
||||
this.bubbles[i].baseColor = palette[i % palette.length];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ interface ConfettiParticle {
|
||||
burst: boolean;
|
||||
}
|
||||
|
||||
const BASE_CONFETTI = 350;
|
||||
const BASE_CONFETTI = 385;
|
||||
const BASE_AREA = 1920 * 1080;
|
||||
const PI_2 = 2 * Math.PI;
|
||||
const TARGET_FPS = 60;
|
||||
@@ -45,6 +45,7 @@ export class ConfettiEngine implements AnimationEngine {
|
||||
private mouseY = -1000;
|
||||
private mouseXNorm = 0.5;
|
||||
private elapsed = 0;
|
||||
private exiting = false;
|
||||
|
||||
init(
|
||||
width: number,
|
||||
@@ -60,6 +61,30 @@ export class ConfettiEngine implements AnimationEngine {
|
||||
this.initParticles();
|
||||
}
|
||||
|
||||
beginExit(): void {
|
||||
if (this.exiting) return;
|
||||
this.exiting = true;
|
||||
|
||||
// Stagger fade-out over 3 seconds
|
||||
for (let i = 0; i < this.particles.length; i++) {
|
||||
const p = this.particles[i];
|
||||
p.staggerDelay = -1; // ensure visible
|
||||
// Random delay before fade starts, stored as negative dop
|
||||
const delay = Math.random() * 3000;
|
||||
setTimeout(() => {
|
||||
p.dop = -0.02;
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
isExitComplete(): boolean {
|
||||
if (!this.exiting) return false;
|
||||
for (let i = 0; i < this.particles.length; i++) {
|
||||
if (this.particles[i].opacity > 0.01) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.particles = [];
|
||||
}
|
||||
@@ -140,15 +165,18 @@ export class ConfettiEngine implements AnimationEngine {
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
|
||||
// Fade in only (no fade-out cycle)
|
||||
if (p.opacity < 1) {
|
||||
// Fade in, or fade out during exit
|
||||
if (this.exiting && p.dop < 0) {
|
||||
p.opacity += p.dop * dt;
|
||||
if (p.opacity < 0) p.opacity = 0;
|
||||
} else if (p.opacity < 1) {
|
||||
p.opacity += Math.abs(p.dop) * dt;
|
||||
if (p.opacity > 1) p.opacity = 1;
|
||||
}
|
||||
|
||||
// Past the bottom: burst particles get removed, base particles recycle
|
||||
// Past the bottom: burst particles removed, base particles recycle (or remove during exit)
|
||||
if (p.y > this.height + p.r) {
|
||||
if (p.burst) {
|
||||
if (p.burst || this.exiting) {
|
||||
this.particles.splice(i, 1);
|
||||
i--;
|
||||
} else {
|
||||
@@ -230,7 +258,7 @@ export class ConfettiEngine implements AnimationEngine {
|
||||
}
|
||||
|
||||
// Main circle
|
||||
ctx.globalAlpha = p.opacity * 0.9;
|
||||
ctx.globalAlpha = p.opacity;
|
||||
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(drawX, drawY, p.r, 0, PI_2);
|
||||
@@ -254,29 +282,8 @@ export class ConfettiEngine implements AnimationEngine {
|
||||
handleResize(width: number, height: number): void {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
const target = this.getParticleCount();
|
||||
while (this.particles.length < target) {
|
||||
const baseColor = this.randomColor();
|
||||
const r = ~~range(3, 8);
|
||||
this.particles.push({
|
||||
x: range(-r * 2, width - r * 2),
|
||||
y: range(-20, height - r * 2),
|
||||
vx: (range(0, 2) + 8 * 0.5 - 5) * SPEED_FACTOR,
|
||||
vy: (0.7 * r + range(-1, 1)) * SPEED_FACTOR,
|
||||
r,
|
||||
color: [...baseColor],
|
||||
baseColor,
|
||||
opacity: 0,
|
||||
dop: 0.03 * range(1, 4) * SPEED_FACTOR,
|
||||
elevation: 0,
|
||||
targetElevation: 0,
|
||||
staggerDelay: -1,
|
||||
burst: false,
|
||||
});
|
||||
}
|
||||
if (this.particles.length > target) {
|
||||
this.particles.length = target;
|
||||
}
|
||||
this.elapsed = 0;
|
||||
this.initParticles();
|
||||
}
|
||||
|
||||
handleMouseMove(x: number, y: number, _isDown: boolean): void {
|
||||
@@ -303,7 +310,7 @@ export class ConfettiEngine implements AnimationEngine {
|
||||
color: [...baseColor],
|
||||
baseColor,
|
||||
opacity: 1,
|
||||
dop: 0,
|
||||
dop: this.exiting ? -0.02 : 0,
|
||||
elevation: 0,
|
||||
targetElevation: 0,
|
||||
staggerDelay: -1,
|
||||
@@ -322,8 +329,8 @@ export class ConfettiEngine implements AnimationEngine {
|
||||
|
||||
updatePalette(palette: [number, number, number][], _bgColor: string): void {
|
||||
this.palette = palette;
|
||||
for (const p of this.particles) {
|
||||
p.baseColor = palette[Math.floor(Math.random() * palette.length)];
|
||||
for (let i = 0; i < this.particles.length; i++) {
|
||||
this.particles[i].baseColor = palette[i % palette.length];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ export class GameOfLifeEngine implements AnimationEngine {
|
||||
private pendingTimeouts: ReturnType<typeof setTimeout>[] = [];
|
||||
private canvasWidth = 0;
|
||||
private canvasHeight = 0;
|
||||
private exiting = false;
|
||||
|
||||
init(
|
||||
width: number,
|
||||
@@ -278,13 +279,60 @@ export class GameOfLifeEngine implements AnimationEngine {
|
||||
}
|
||||
}
|
||||
|
||||
beginExit(): void {
|
||||
if (this.exiting || !this.grid) return;
|
||||
this.exiting = true;
|
||||
|
||||
// Cancel all pending GOL transitions so they don't revive cells
|
||||
for (const id of this.pendingTimeouts) {
|
||||
clearTimeout(id);
|
||||
}
|
||||
this.pendingTimeouts = [];
|
||||
|
||||
const grid = this.grid;
|
||||
for (let i = 0; i < grid.cols; i++) {
|
||||
for (let j = 0; j < grid.rows; j++) {
|
||||
const cell = grid.cells[i][j];
|
||||
// Force cell into dying state, clear any pending transition
|
||||
cell.next = false;
|
||||
cell.transitioning = false;
|
||||
cell.transitionComplete = false;
|
||||
|
||||
if (cell.opacity > 0.01) {
|
||||
const delay = Math.random() * 3000;
|
||||
const tid = setTimeout(() => {
|
||||
cell.targetOpacity = 0;
|
||||
cell.targetScale = 0;
|
||||
cell.targetElevation = 0;
|
||||
}, delay);
|
||||
this.pendingTimeouts.push(tid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isExitComplete(): boolean {
|
||||
if (!this.exiting) return false;
|
||||
if (!this.grid) return true;
|
||||
|
||||
const grid = this.grid;
|
||||
for (let i = 0; i < grid.cols; i++) {
|
||||
for (let j = 0; j < grid.rows; j++) {
|
||||
if (grid.cells[i][j].opacity > 0.01) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
update(deltaTime: number): void {
|
||||
if (!this.grid) return;
|
||||
|
||||
this.timeAccumulator += deltaTime;
|
||||
if (this.timeAccumulator >= CYCLE_TIME) {
|
||||
this.computeNextState(this.grid);
|
||||
this.timeAccumulator -= CYCLE_TIME;
|
||||
if (!this.exiting) {
|
||||
this.timeAccumulator += deltaTime;
|
||||
if (this.timeAccumulator >= CYCLE_TIME) {
|
||||
this.computeNextState(this.grid);
|
||||
this.timeAccumulator -= CYCLE_TIME;
|
||||
}
|
||||
}
|
||||
|
||||
this.updateCellAnimations(this.grid, deltaTime);
|
||||
@@ -335,7 +383,15 @@ export class GameOfLifeEngine implements AnimationEngine {
|
||||
cell.targetElevation = 0;
|
||||
}
|
||||
|
||||
if (cell.transitioning) {
|
||||
// During exit: snap to zero once close enough
|
||||
if (this.exiting) {
|
||||
if (cell.opacity < 0.05) {
|
||||
cell.opacity = 0;
|
||||
cell.scale = 0;
|
||||
cell.elevation = 0;
|
||||
cell.alive = false;
|
||||
}
|
||||
} else if (cell.transitioning) {
|
||||
if (!cell.next && cell.opacity < 0.01 && cell.scale < 0.01) {
|
||||
cell.alive = false;
|
||||
cell.transitioning = false;
|
||||
@@ -532,7 +588,7 @@ export class GameOfLifeEngine implements AnimationEngine {
|
||||
this.mouseY = y;
|
||||
this.mouseIsDown = isDown;
|
||||
|
||||
if (isDown && this.grid) {
|
||||
if (isDown && this.grid && !this.exiting) {
|
||||
const grid = this.grid;
|
||||
const cellSize = this.getCellSize();
|
||||
const cellX = Math.floor((x - grid.offsetX) / cellSize);
|
||||
@@ -560,7 +616,7 @@ export class GameOfLifeEngine implements AnimationEngine {
|
||||
handleMouseDown(x: number, y: number): void {
|
||||
this.mouseIsDown = true;
|
||||
|
||||
if (!this.grid) return;
|
||||
if (!this.grid || this.exiting) return;
|
||||
const grid = this.grid;
|
||||
const cellSize = this.getCellSize();
|
||||
|
||||
@@ -605,8 +661,7 @@ export class GameOfLifeEngine implements AnimationEngine {
|
||||
for (let j = 0; j < grid.rows; j++) {
|
||||
const cell = grid.cells[i][j];
|
||||
if (cell.alive && cell.opacity > 0.01) {
|
||||
cell.baseColor =
|
||||
palette[Math.floor(Math.random() * palette.length)];
|
||||
cell.baseColor = palette[(i * grid.rows + j) % palette.length];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ export class LavaLampEngine implements AnimationEngine {
|
||||
private shadowCtx: CanvasRenderingContext2D | null = null;
|
||||
private elapsed = 0;
|
||||
private nextCycleTime = 0;
|
||||
private exiting = false;
|
||||
|
||||
// Pre-allocated typed arrays for the inner render loop (avoid per-frame GC)
|
||||
private blobX: Float64Array = new Float64Array(0);
|
||||
@@ -170,6 +171,27 @@ export class LavaLampEngine implements AnimationEngine {
|
||||
});
|
||||
}
|
||||
|
||||
beginExit(): void {
|
||||
if (this.exiting) return;
|
||||
this.exiting = true;
|
||||
|
||||
for (let i = 0; i < this.blobs.length; i++) {
|
||||
const blob = this.blobs[i];
|
||||
if (blob.staggerDelay >= 0) {
|
||||
blob.staggerDelay = -1;
|
||||
}
|
||||
// Stagger the shrink over ~2 seconds
|
||||
setTimeout(() => {
|
||||
blob.targetRadiusScale = 0;
|
||||
}, Math.random() * 2000);
|
||||
}
|
||||
}
|
||||
|
||||
isExitComplete(): boolean {
|
||||
if (!this.exiting) return false;
|
||||
return this.blobs.length === 0;
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.blobs = [];
|
||||
this.offCanvas = null;
|
||||
@@ -279,7 +301,7 @@ export class LavaLampEngine implements AnimationEngine {
|
||||
}
|
||||
|
||||
// Natural spawn/despawn cycle — keeps the scene alive
|
||||
if (this.elapsed >= this.nextCycleTime) {
|
||||
if (!this.exiting && this.elapsed >= this.nextCycleTime) {
|
||||
// Pick a random visible blob to fade out (skip ones still staggering in)
|
||||
const visible = [];
|
||||
for (let i = 0; i < this.blobs.length; i++) {
|
||||
@@ -433,12 +455,11 @@ export class LavaLampEngine implements AnimationEngine {
|
||||
handleResize(width: number, height: number): void {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.elapsed = 0;
|
||||
this.exiting = false;
|
||||
this.nextCycleTime = CYCLE_MIN_MS + Math.random() * (CYCLE_MAX_MS - CYCLE_MIN_MS);
|
||||
this.initBlobs();
|
||||
this.initOffscreenCanvas();
|
||||
|
||||
const { min, max } = this.getRadiusRange();
|
||||
for (const blob of this.blobs) {
|
||||
blob.baseRadius = min + Math.random() * (max - min);
|
||||
}
|
||||
}
|
||||
|
||||
private sampleColorAt(x: number, y: number): [number, number, number] | null {
|
||||
@@ -475,6 +496,7 @@ export class LavaLampEngine implements AnimationEngine {
|
||||
}
|
||||
|
||||
handleMouseDown(x: number, y: number): void {
|
||||
if (this.exiting) return;
|
||||
this.spawnAt(x, y);
|
||||
}
|
||||
|
||||
480
src/components/background/engines/pipes.ts
Normal file
@@ -0,0 +1,480 @@
|
||||
import type { AnimationEngine } from "@/lib/animations/types";
|
||||
|
||||
// --- Directions ---
|
||||
|
||||
type Dir = 0 | 1 | 2 | 3; // up, right, down, left
|
||||
const DX = [0, 1, 0, -1];
|
||||
const DY = [-1, 0, 1, 0];
|
||||
|
||||
// Box-drawing characters
|
||||
const HORIZONTAL = "\u2501"; // ━
|
||||
const VERTICAL = "\u2503"; // ┃
|
||||
// Corner pieces: [oldDir]-[newDir]
|
||||
// oldDir determines entry side (opposite), newDir determines exit side
|
||||
// ┏ = RIGHT+BOTTOM, ┓ = LEFT+BOTTOM, ┗ = RIGHT+TOP, ┛ = LEFT+TOP
|
||||
const CORNER: Record<string, string> = {
|
||||
"0-1": "\u250F", // ┏ enter BOTTOM, exit RIGHT
|
||||
"0-3": "\u2513", // ┓ enter BOTTOM, exit LEFT
|
||||
"1-0": "\u251B", // ┛ enter LEFT, exit TOP
|
||||
"1-2": "\u2513", // ┓ enter LEFT, exit BOTTOM
|
||||
"2-1": "\u2517", // ┗ enter TOP, exit RIGHT
|
||||
"2-3": "\u251B", // ┛ enter TOP, exit LEFT
|
||||
"3-0": "\u2517", // ┗ enter RIGHT, exit TOP
|
||||
"3-2": "\u250F", // ┏ enter RIGHT, exit BOTTOM
|
||||
};
|
||||
|
||||
function getStraightChar(dir: Dir): string {
|
||||
return dir === 0 || dir === 2 ? VERTICAL : HORIZONTAL;
|
||||
}
|
||||
|
||||
function getCornerChar(fromDir: Dir, toDir: Dir): string {
|
||||
return CORNER[`${fromDir}-${toDir}`] || HORIZONTAL;
|
||||
}
|
||||
|
||||
// --- Grid Cell ---
|
||||
|
||||
interface PipeCell {
|
||||
char: string;
|
||||
pipeId: number;
|
||||
placedAt: number;
|
||||
color: [number, number, number];
|
||||
baseColor: [number, number, number];
|
||||
opacity: number;
|
||||
elevation: number;
|
||||
targetElevation: number;
|
||||
fadeOut: boolean;
|
||||
}
|
||||
|
||||
// --- Active Pipe ---
|
||||
|
||||
interface ActivePipe {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
dir: Dir;
|
||||
color: [number, number, number];
|
||||
spawnDelay: number;
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
const CELL_SIZE_DESKTOP = 20;
|
||||
const CELL_SIZE_MOBILE = 14;
|
||||
const MAX_ACTIVE_PIPES = 4;
|
||||
const GROW_INTERVAL = 80;
|
||||
const TURN_CHANCE = 0.3;
|
||||
const TARGET_FPS = 60;
|
||||
const PIPE_LIFETIME = 12_000; // ms before a pipe's segments start fading
|
||||
const FADE_IN_SPEED = 0.06;
|
||||
const FADE_OUT_SPEED = 0.02;
|
||||
|
||||
const MOUSE_INFLUENCE_RADIUS = 150;
|
||||
const ELEVATION_FACTOR = 6;
|
||||
const ELEVATION_LERP_SPEED = 0.05;
|
||||
const COLOR_SHIFT_AMOUNT = 30;
|
||||
const SHADOW_OFFSET_RATIO = 1.1;
|
||||
|
||||
const BURST_PIPE_COUNT = 4;
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function range(a: number, b: number): number {
|
||||
return (b - a) * Math.random() + a;
|
||||
}
|
||||
|
||||
// --- Engine ---
|
||||
|
||||
export class PipesEngine implements AnimationEngine {
|
||||
id = "pipes";
|
||||
name = "Pipes";
|
||||
|
||||
private grid: (PipeCell | null)[][] = [];
|
||||
private cols = 0;
|
||||
private rows = 0;
|
||||
private activePipes: ActivePipe[] = [];
|
||||
private palette: [number, number, number][] = [];
|
||||
private width = 0;
|
||||
private height = 0;
|
||||
private cellSize = CELL_SIZE_DESKTOP;
|
||||
private fontSize = CELL_SIZE_DESKTOP;
|
||||
private font = `bold ${CELL_SIZE_DESKTOP}px monospace`;
|
||||
private mouseX = -1000;
|
||||
private mouseY = -1000;
|
||||
private elapsed = 0;
|
||||
private growTimer = 0;
|
||||
private exiting = false;
|
||||
private nextPipeId = 0;
|
||||
private offsetX = 0;
|
||||
private offsetY = 0;
|
||||
|
||||
init(
|
||||
width: number,
|
||||
height: number,
|
||||
palette: [number, number, number][],
|
||||
_bgColor: string
|
||||
): void {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.palette = palette;
|
||||
this.elapsed = 0;
|
||||
this.growTimer = 0;
|
||||
this.exiting = false;
|
||||
this.computeGrid();
|
||||
this.spawnInitialPipes();
|
||||
}
|
||||
|
||||
private computeGrid(): void {
|
||||
this.cellSize = this.width <= 768 ? CELL_SIZE_MOBILE : CELL_SIZE_DESKTOP;
|
||||
this.fontSize = this.cellSize;
|
||||
this.font = `bold ${this.fontSize}px monospace`;
|
||||
this.cols = Math.floor(this.width / this.cellSize);
|
||||
this.rows = Math.floor(this.height / this.cellSize);
|
||||
this.offsetX = Math.floor((this.width - this.cols * this.cellSize) / 2);
|
||||
this.offsetY = Math.floor((this.height - this.rows * this.cellSize) / 2);
|
||||
this.grid = Array.from({ length: this.cols }, () =>
|
||||
Array.from({ length: this.rows }, () => null)
|
||||
);
|
||||
}
|
||||
|
||||
private randomColor(): [number, number, number] {
|
||||
// Prefer bright variants (second half of palette) if available
|
||||
const brightStart = Math.floor(this.palette.length / 2);
|
||||
if (brightStart > 0 && this.palette.length > brightStart) {
|
||||
return this.palette[brightStart + Math.floor(Math.random() * (this.palette.length - brightStart))];
|
||||
}
|
||||
return this.palette[Math.floor(Math.random() * this.palette.length)];
|
||||
}
|
||||
|
||||
private spawnInitialPipes(): void {
|
||||
this.activePipes = [];
|
||||
for (let i = 0; i < MAX_ACTIVE_PIPES; i++) {
|
||||
this.activePipes.push(this.makeEdgePipe(i * 400));
|
||||
}
|
||||
}
|
||||
|
||||
private makeEdgePipe(delay: number): ActivePipe {
|
||||
const color = this.randomColor();
|
||||
// Pick a random edge and inward-facing direction
|
||||
const edge = Math.floor(Math.random() * 4) as Dir;
|
||||
let x: number, y: number, dir: Dir;
|
||||
|
||||
switch (edge) {
|
||||
case 0: // top edge, face down
|
||||
x = Math.floor(Math.random() * this.cols);
|
||||
y = 0;
|
||||
dir = 2;
|
||||
break;
|
||||
case 1: // right edge, face left
|
||||
x = this.cols - 1;
|
||||
y = Math.floor(Math.random() * this.rows);
|
||||
dir = 3;
|
||||
break;
|
||||
case 2: // bottom edge, face up
|
||||
x = Math.floor(Math.random() * this.cols);
|
||||
y = this.rows - 1;
|
||||
dir = 0;
|
||||
break;
|
||||
default: // left edge, face right
|
||||
x = 0;
|
||||
y = Math.floor(Math.random() * this.rows);
|
||||
dir = 1;
|
||||
break;
|
||||
}
|
||||
|
||||
return { id: this.nextPipeId++, x, y, dir, color: [...color], spawnDelay: delay };
|
||||
}
|
||||
|
||||
private placeSegment(x: number, y: number, char: string, color: [number, number, number], pipeId: number): void {
|
||||
if (x < 0 || x >= this.cols || y < 0 || y >= this.rows) return;
|
||||
this.grid[x][y] = {
|
||||
char,
|
||||
pipeId,
|
||||
placedAt: this.elapsed,
|
||||
color: [...color],
|
||||
baseColor: [...color],
|
||||
opacity: 0,
|
||||
elevation: 0,
|
||||
targetElevation: 0,
|
||||
fadeOut: false,
|
||||
};
|
||||
}
|
||||
|
||||
private isOccupied(x: number, y: number): boolean {
|
||||
if (x < 0 || x >= this.cols || y < 0 || y >= this.rows) return true;
|
||||
return this.grid[x][y] !== null;
|
||||
}
|
||||
|
||||
private pickTurn(currentDir: Dir): Dir {
|
||||
// Turn left or right relative to current direction
|
||||
const leftDir = ((currentDir + 3) % 4) as Dir;
|
||||
const rightDir = ((currentDir + 1) % 4) as Dir;
|
||||
return Math.random() < 0.5 ? leftDir : rightDir;
|
||||
}
|
||||
|
||||
private growPipe(pipe: ActivePipe): boolean {
|
||||
// Decide direction
|
||||
let newDir = pipe.dir;
|
||||
let turned = false;
|
||||
if (Math.random() < TURN_CHANCE) {
|
||||
newDir = this.pickTurn(pipe.dir);
|
||||
turned = true;
|
||||
}
|
||||
|
||||
const nx = pipe.x + DX[newDir];
|
||||
const ny = pipe.y + DY[newDir];
|
||||
|
||||
// Check if destination is valid
|
||||
if (this.isOccupied(nx, ny)) {
|
||||
// If we tried to turn, try going straight instead
|
||||
if (turned) {
|
||||
const sx = pipe.x + DX[pipe.dir];
|
||||
const sy = pipe.y + DY[pipe.dir];
|
||||
if (!this.isOccupied(sx, sy)) {
|
||||
// Continue straight — place straight piece at destination
|
||||
this.placeSegment(sx, sy, getStraightChar(pipe.dir), pipe.color, pipe.id);
|
||||
pipe.x = sx;
|
||||
pipe.y = sy;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false; // dead end
|
||||
}
|
||||
|
||||
if (turned) {
|
||||
// Replace current head cell with corner piece (turn happens HERE)
|
||||
const cell = this.grid[pipe.x]?.[pipe.y];
|
||||
if (cell) {
|
||||
cell.char = getCornerChar(pipe.dir, newDir);
|
||||
}
|
||||
}
|
||||
|
||||
// Place straight piece at destination
|
||||
this.placeSegment(nx, ny, getStraightChar(newDir), pipe.color, pipe.id);
|
||||
pipe.dir = newDir;
|
||||
pipe.x = nx;
|
||||
pipe.y = ny;
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Interface Methods ---
|
||||
|
||||
beginExit(): void {
|
||||
if (this.exiting) return;
|
||||
this.exiting = true;
|
||||
|
||||
for (let i = 0; i < this.cols; i++) {
|
||||
for (let j = 0; j < this.rows; j++) {
|
||||
const cell = this.grid[i][j];
|
||||
if (cell) {
|
||||
setTimeout(() => {
|
||||
cell.fadeOut = true;
|
||||
}, Math.random() * 3000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isExitComplete(): boolean {
|
||||
if (!this.exiting) return false;
|
||||
for (let i = 0; i < this.cols; i++) {
|
||||
for (let j = 0; j < this.rows; j++) {
|
||||
const cell = this.grid[i][j];
|
||||
if (cell && cell.opacity > 0.01) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.grid = [];
|
||||
this.activePipes = [];
|
||||
}
|
||||
|
||||
update(deltaTime: number): void {
|
||||
const dt = deltaTime / (1000 / TARGET_FPS);
|
||||
this.elapsed += deltaTime;
|
||||
|
||||
// Grow pipes
|
||||
if (!this.exiting) {
|
||||
this.growTimer += deltaTime;
|
||||
while (this.growTimer >= GROW_INTERVAL) {
|
||||
this.growTimer -= GROW_INTERVAL;
|
||||
|
||||
for (let i = this.activePipes.length - 1; i >= 0; i--) {
|
||||
const pipe = this.activePipes[i];
|
||||
|
||||
if (pipe.spawnDelay > 0) {
|
||||
pipe.spawnDelay -= GROW_INTERVAL;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Place starting segment if this is the first step
|
||||
if (!this.isOccupied(pipe.x, pipe.y)) {
|
||||
this.placeSegment(pipe.x, pipe.y, getStraightChar(pipe.dir), pipe.color, pipe.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!this.growPipe(pipe)) {
|
||||
// Pipe is dead, replace it
|
||||
this.activePipes[i] = this.makeEdgePipe(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update cells: fade in/out, mouse influence
|
||||
const mouseX = this.mouseX;
|
||||
const mouseY = this.mouseY;
|
||||
|
||||
for (let i = 0; i < this.cols; i++) {
|
||||
for (let j = 0; j < this.rows; j++) {
|
||||
const cell = this.grid[i][j];
|
||||
if (!cell) continue;
|
||||
|
||||
// Age-based fade: old segments start dissolving
|
||||
if (!cell.fadeOut && !this.exiting && this.elapsed - cell.placedAt > PIPE_LIFETIME) {
|
||||
cell.fadeOut = true;
|
||||
}
|
||||
|
||||
// Fade in/out
|
||||
if (cell.fadeOut) {
|
||||
cell.opacity -= FADE_OUT_SPEED * dt;
|
||||
if (cell.opacity <= 0) {
|
||||
cell.opacity = 0;
|
||||
this.grid[i][j] = null; // free the cell for new pipes
|
||||
continue;
|
||||
}
|
||||
} else if (cell.opacity < 1) {
|
||||
cell.opacity = Math.min(1, cell.opacity + FADE_IN_SPEED * dt);
|
||||
}
|
||||
|
||||
// Mouse influence
|
||||
const cx = this.offsetX + i * this.cellSize + this.cellSize / 2;
|
||||
const cy = this.offsetY + j * this.cellSize + this.cellSize / 2;
|
||||
const dx = cx - mouseX;
|
||||
const dy = cy - mouseY;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist < MOUSE_INFLUENCE_RADIUS && cell.opacity > 0.1) {
|
||||
const inf = Math.cos((dist / MOUSE_INFLUENCE_RADIUS) * (Math.PI / 2));
|
||||
cell.targetElevation = ELEVATION_FACTOR * inf * inf;
|
||||
|
||||
const shift = inf * COLOR_SHIFT_AMOUNT * 0.5;
|
||||
cell.color = [
|
||||
Math.min(255, Math.max(0, cell.baseColor[0] + shift)),
|
||||
Math.min(255, Math.max(0, cell.baseColor[1] + shift)),
|
||||
Math.min(255, Math.max(0, cell.baseColor[2] + shift)),
|
||||
];
|
||||
} else {
|
||||
cell.targetElevation = 0;
|
||||
cell.color[0] += (cell.baseColor[0] - cell.color[0]) * 0.1;
|
||||
cell.color[1] += (cell.baseColor[1] - cell.color[1]) * 0.1;
|
||||
cell.color[2] += (cell.baseColor[2] - cell.color[2]) * 0.1;
|
||||
}
|
||||
|
||||
cell.elevation +=
|
||||
(cell.targetElevation - cell.elevation) * ELEVATION_LERP_SPEED * dt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
_width: number,
|
||||
_height: number
|
||||
): void {
|
||||
ctx.font = this.font;
|
||||
ctx.textBaseline = "top";
|
||||
|
||||
for (let i = 0; i < this.cols; i++) {
|
||||
for (let j = 0; j < this.rows; j++) {
|
||||
const cell = this.grid[i][j];
|
||||
if (!cell || cell.opacity <= 0.01) continue;
|
||||
|
||||
const x = this.offsetX + i * this.cellSize;
|
||||
const y = this.offsetY + j * this.cellSize - cell.elevation;
|
||||
const [r, g, b] = cell.color;
|
||||
|
||||
// Shadow
|
||||
if (cell.elevation > 0.5) {
|
||||
const shadowAlpha = 0.2 * (cell.elevation / ELEVATION_FACTOR) * cell.opacity;
|
||||
ctx.globalAlpha = shadowAlpha;
|
||||
ctx.fillStyle = "rgb(0,0,0)";
|
||||
ctx.fillText(cell.char, x, y + cell.elevation * SHADOW_OFFSET_RATIO);
|
||||
}
|
||||
|
||||
// Main
|
||||
ctx.globalAlpha = cell.opacity;
|
||||
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||||
ctx.fillText(cell.char, x, y);
|
||||
|
||||
// Highlight
|
||||
if (cell.elevation > 0.5) {
|
||||
const highlightAlpha = 0.1 * (cell.elevation / ELEVATION_FACTOR) * cell.opacity;
|
||||
ctx.globalAlpha = highlightAlpha;
|
||||
ctx.fillStyle = "rgb(255,255,255)";
|
||||
ctx.fillText(cell.char, x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
handleResize(width: number, height: number): void {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.elapsed = 0;
|
||||
this.growTimer = 0;
|
||||
this.exiting = false;
|
||||
this.computeGrid();
|
||||
this.spawnInitialPipes();
|
||||
}
|
||||
|
||||
handleMouseMove(x: number, y: number, _isDown: boolean): void {
|
||||
this.mouseX = x;
|
||||
this.mouseY = y;
|
||||
}
|
||||
|
||||
handleMouseDown(x: number, y: number): void {
|
||||
if (this.exiting) return;
|
||||
|
||||
// Convert to grid coords
|
||||
const gx = Math.floor((x - this.offsetX) / this.cellSize);
|
||||
const gy = Math.floor((y - this.offsetY) / this.cellSize);
|
||||
|
||||
// Spawn pipes in all 4 directions from click point
|
||||
for (let d = 0; d < BURST_PIPE_COUNT; d++) {
|
||||
const dir = d as Dir;
|
||||
const color = this.randomColor();
|
||||
this.activePipes.push({
|
||||
id: this.nextPipeId++,
|
||||
x: gx,
|
||||
y: gy,
|
||||
dir,
|
||||
color: [...color],
|
||||
spawnDelay: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseUp(): void {}
|
||||
|
||||
handleMouseLeave(): void {
|
||||
this.mouseX = -1000;
|
||||
this.mouseY = -1000;
|
||||
}
|
||||
|
||||
updatePalette(palette: [number, number, number][], _bgColor: string): void {
|
||||
this.palette = palette;
|
||||
// Assign by pipeId so all segments of the same pipe get the same color
|
||||
for (let i = 0; i < this.cols; i++) {
|
||||
for (let j = 0; j < this.rows; j++) {
|
||||
const cell = this.grid[i][j];
|
||||
if (cell) {
|
||||
cell.baseColor = palette[cell.pipeId % palette.length];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
207
src/components/background/engines/shuffle.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import type { AnimationEngine } from "@/lib/animations/types";
|
||||
import { GameOfLifeEngine } from "@/components/background/engines/game-of-life";
|
||||
import { LavaLampEngine } from "@/components/background/engines/lava-lamp";
|
||||
import { ConfettiEngine } from "@/components/background/engines/confetti";
|
||||
import { AsciiquariumEngine } from "@/components/background/engines/asciiquarium";
|
||||
import { PipesEngine } from "@/components/background/engines/pipes";
|
||||
|
||||
type ChildId = "game-of-life" | "lava-lamp" | "confetti" | "asciiquarium" | "pipes";
|
||||
|
||||
const CHILD_IDS: ChildId[] = [
|
||||
"game-of-life",
|
||||
"lava-lamp",
|
||||
"confetti",
|
||||
"asciiquarium",
|
||||
"pipes",
|
||||
];
|
||||
|
||||
const PLAY_DURATION = 30_000;
|
||||
const STATE_KEY = "shuffle-state";
|
||||
|
||||
interface StoredState {
|
||||
childId: ChildId;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
function createChild(id: ChildId): AnimationEngine {
|
||||
switch (id) {
|
||||
case "game-of-life":
|
||||
return new GameOfLifeEngine();
|
||||
case "lava-lamp":
|
||||
return new LavaLampEngine();
|
||||
case "confetti":
|
||||
return new ConfettiEngine();
|
||||
case "asciiquarium":
|
||||
return new AsciiquariumEngine();
|
||||
case "pipes":
|
||||
return new PipesEngine();
|
||||
}
|
||||
}
|
||||
|
||||
function pickDifferent(current: ChildId | null): ChildId {
|
||||
const others = current
|
||||
? CHILD_IDS.filter((id) => id !== current)
|
||||
: CHILD_IDS;
|
||||
return others[Math.floor(Math.random() * others.length)];
|
||||
}
|
||||
|
||||
function save(state: StoredState): void {
|
||||
try {
|
||||
localStorage.setItem(STATE_KEY, JSON.stringify(state));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function load(): StoredState | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STATE_KEY);
|
||||
if (!raw) return null;
|
||||
const state = JSON.parse(raw) as StoredState;
|
||||
if (CHILD_IDS.includes(state.childId)) return state;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class ShuffleEngine implements AnimationEngine {
|
||||
id = "shuffle";
|
||||
name = "Shuffle";
|
||||
|
||||
private child: AnimationEngine | null = null;
|
||||
private currentChildId: ChildId | null = null;
|
||||
private startedAt = 0;
|
||||
private phase: "playing" | "exiting" = "playing";
|
||||
|
||||
private width = 0;
|
||||
private height = 0;
|
||||
private palette: [number, number, number][] = [];
|
||||
private bgColor = "";
|
||||
|
||||
init(
|
||||
width: number,
|
||||
height: number,
|
||||
palette: [number, number, number][],
|
||||
bgColor: string
|
||||
): void {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.palette = palette;
|
||||
this.bgColor = bgColor;
|
||||
|
||||
const stored = load();
|
||||
|
||||
if (stored && Date.now() - stored.startedAt < PLAY_DURATION) {
|
||||
// Animation still within its play window — continue it
|
||||
// Covers: Astro nav, sidebar mount, layout switch, quick refresh
|
||||
this.currentChildId = stored.childId;
|
||||
} else {
|
||||
// No recent state (first visit, hard refresh after timer expired) — game-of-life
|
||||
this.currentChildId = "game-of-life";
|
||||
}
|
||||
|
||||
this.startedAt = Date.now();
|
||||
|
||||
this.phase = "playing";
|
||||
this.child = createChild(this.currentChildId);
|
||||
this.child.init(this.width, this.height, this.palette, this.bgColor);
|
||||
save({ childId: this.currentChildId, startedAt: this.startedAt });
|
||||
}
|
||||
|
||||
private switchTo(childId: ChildId, startedAt: number): void {
|
||||
if (this.child) this.child.cleanup();
|
||||
this.currentChildId = childId;
|
||||
this.startedAt = startedAt;
|
||||
this.phase = "playing";
|
||||
this.child = createChild(childId);
|
||||
this.child.init(this.width, this.height, this.palette, this.bgColor);
|
||||
}
|
||||
|
||||
private advance(): void {
|
||||
// Check if another instance already advanced
|
||||
const stored = load();
|
||||
if (stored && stored.childId !== this.currentChildId) {
|
||||
this.switchTo(stored.childId, stored.startedAt);
|
||||
} else {
|
||||
const next = pickDifferent(this.currentChildId);
|
||||
const now = Date.now();
|
||||
save({ childId: next, startedAt: now });
|
||||
this.switchTo(next, now);
|
||||
}
|
||||
}
|
||||
|
||||
update(deltaTime: number): void {
|
||||
if (!this.child) return;
|
||||
|
||||
// Sync: if another instance (sidebar, tab) switched, follow
|
||||
const stored = load();
|
||||
if (stored && stored.childId !== this.currentChildId) {
|
||||
this.switchTo(stored.childId, stored.startedAt);
|
||||
return;
|
||||
}
|
||||
|
||||
this.child.update(deltaTime);
|
||||
|
||||
const elapsed = Date.now() - this.startedAt;
|
||||
|
||||
if (this.phase === "playing" && elapsed >= PLAY_DURATION) {
|
||||
this.child.beginExit();
|
||||
this.phase = "exiting";
|
||||
}
|
||||
|
||||
if (this.phase === "exiting" && this.child.isExitComplete()) {
|
||||
this.child.cleanup();
|
||||
this.advance();
|
||||
}
|
||||
}
|
||||
|
||||
render(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number
|
||||
): void {
|
||||
if (this.child) this.child.render(ctx, width, height);
|
||||
}
|
||||
|
||||
handleResize(width: number, height: number): void {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
if (this.child) this.child.handleResize(width, height);
|
||||
}
|
||||
|
||||
handleMouseMove(x: number, y: number, isDown: boolean): void {
|
||||
if (this.child) this.child.handleMouseMove(x, y, isDown);
|
||||
}
|
||||
|
||||
handleMouseDown(x: number, y: number): void {
|
||||
if (this.child) this.child.handleMouseDown(x, y);
|
||||
}
|
||||
|
||||
handleMouseUp(): void {
|
||||
if (this.child) this.child.handleMouseUp();
|
||||
}
|
||||
|
||||
handleMouseLeave(): void {
|
||||
if (this.child) this.child.handleMouseLeave();
|
||||
}
|
||||
|
||||
updatePalette(palette: [number, number, number][], bgColor: string): void {
|
||||
this.palette = palette;
|
||||
this.bgColor = bgColor;
|
||||
if (this.child) this.child.updatePalette(palette, bgColor);
|
||||
}
|
||||
|
||||
beginExit(): void {
|
||||
if (this.child) this.child.beginExit();
|
||||
}
|
||||
|
||||
isExitComplete(): boolean {
|
||||
return this.child ? this.child.isExitComplete() : true;
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
if (this.child) {
|
||||
this.child.cleanup();
|
||||
this.child = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { GameOfLifeEngine } from "./engines/game-of-life";
|
||||
import { LavaLampEngine } from "./engines/lava-lamp";
|
||||
import { ConfettiEngine } from "./engines/confetti";
|
||||
import { GameOfLifeEngine } from "@/components/background/engines/game-of-life";
|
||||
import { LavaLampEngine } from "@/components/background/engines/lava-lamp";
|
||||
import { ConfettiEngine } from "@/components/background/engines/confetti";
|
||||
import { AsciiquariumEngine } from "@/components/background/engines/asciiquarium";
|
||||
import { PipesEngine } from "@/components/background/engines/pipes";
|
||||
import { ShuffleEngine } from "@/components/background/engines/shuffle";
|
||||
import { getStoredAnimationId } from "@/lib/animations/engine";
|
||||
import type { AnimationEngine } from "@/lib/animations/types";
|
||||
import type { AnimationId } from "@/lib/animations";
|
||||
@@ -11,6 +14,8 @@ const SIDEBAR_WIDTH = 240;
|
||||
const FALLBACK_PALETTE: [number, number, number][] = [
|
||||
[204, 36, 29], [152, 151, 26], [215, 153, 33],
|
||||
[69, 133, 136], [177, 98, 134], [104, 157, 106],
|
||||
[251, 73, 52], [184, 187, 38], [250, 189, 47],
|
||||
[131, 165, 152], [211, 134, 155], [142, 192, 124],
|
||||
];
|
||||
|
||||
function createEngine(id: AnimationId): AnimationEngine {
|
||||
@@ -19,6 +24,12 @@ function createEngine(id: AnimationId): AnimationEngine {
|
||||
return new LavaLampEngine();
|
||||
case "confetti":
|
||||
return new ConfettiEngine();
|
||||
case "asciiquarium":
|
||||
return new AsciiquariumEngine();
|
||||
case "pipes":
|
||||
return new PipesEngine();
|
||||
case "shuffle":
|
||||
return new ShuffleEngine();
|
||||
case "game-of-life":
|
||||
default:
|
||||
return new GameOfLifeEngine();
|
||||
@@ -31,6 +42,8 @@ function readPaletteFromCSS(): [number, number, number][] {
|
||||
const keys = [
|
||||
"--color-red", "--color-green", "--color-yellow",
|
||||
"--color-blue", "--color-purple", "--color-aqua",
|
||||
"--color-red-bright", "--color-green-bright", "--color-yellow-bright",
|
||||
"--color-blue-bright", "--color-purple-bright", "--color-aqua-bright",
|
||||
];
|
||||
const palette: [number, number, number][] = [];
|
||||
for (const key of keys) {
|
||||
@@ -140,11 +153,21 @@ const Background: React.FC<BackgroundProps> = ({
|
||||
signal,
|
||||
});
|
||||
|
||||
// Handle theme changes
|
||||
// Handle theme changes — only update if palette actually changed
|
||||
let currentPalette = palette;
|
||||
const handleThemeChanged = () => {
|
||||
const newPalette = readPaletteFromCSS();
|
||||
const newBg = readBgFromCSS();
|
||||
if (engineRef.current) {
|
||||
const same =
|
||||
newPalette.length === currentPalette.length &&
|
||||
newPalette.every(
|
||||
(c, i) =>
|
||||
c[0] === currentPalette[i][0] &&
|
||||
c[1] === currentPalette[i][1] &&
|
||||
c[2] === currentPalette[i][2]
|
||||
);
|
||||
if (!same && engineRef.current) {
|
||||
currentPalette = newPalette;
|
||||
engineRef.current.updatePalette(newPalette, newBg);
|
||||
}
|
||||
};
|
||||
@@ -183,7 +206,7 @@ const Background: React.FC<BackgroundProps> = ({
|
||||
|
||||
// Don't spawn when clicking interactive elements
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("a, button, [role='button'], input, select, textarea, [onclick]")) return;
|
||||
if (target.closest("a, button, [role='button'], input, select, textarea, label, [onclick], [tabindex]")) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
@@ -324,6 +347,8 @@ const Background: React.FC<BackgroundProps> = ({
|
||||
style={{ cursor: "default" }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-background/30 backdrop-blur-sm pointer-events-none" />
|
||||
<div className="crt-scanlines absolute inset-0 pointer-events-none" />
|
||||
<div className="crt-bloom absolute inset-0 pointer-events-none" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { RssIcon, TagIcon, TrendingUpIcon } from "lucide-react";
|
||||
import { AnimateIn } from "@/components/animate-in";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { AnimateIn } from "@/components/animate-in";
|
||||
|
||||
type BlogPost = {
|
||||
@@ -78,7 +77,7 @@ export const BlogPostList = ({ posts }: BlogPostListProps) => {
|
||||
className="text-xs md:text-base text-aqua hover:text-aqua-bright transition-colors duration-200"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.location.href = `/blog/tag/${tag}`;
|
||||
window.location.href = `/blog/tags/${encodeURIComponent(tag)}`;
|
||||
}}
|
||||
>
|
||||
#{tag}
|
||||
92
src/components/blog/tag-list.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useMemo } from 'react';
|
||||
import { AnimateIn } from "@/components/animate-in";
|
||||
|
||||
interface BlogPost {
|
||||
title: string;
|
||||
data: {
|
||||
tags: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface TagListProps {
|
||||
posts: BlogPost[];
|
||||
}
|
||||
|
||||
const spectrumColors = [
|
||||
'text-red-bright',
|
||||
'text-orange-bright',
|
||||
'text-yellow-bright',
|
||||
'text-green-bright',
|
||||
'text-aqua-bright',
|
||||
'text-blue-bright',
|
||||
'text-purple-bright'
|
||||
];
|
||||
|
||||
const sizeClasses = [
|
||||
'text-3xl sm:text-4xl',
|
||||
'text-2xl sm:text-3xl',
|
||||
'text-xl sm:text-2xl',
|
||||
'text-lg sm:text-xl',
|
||||
'text-base sm:text-lg',
|
||||
];
|
||||
|
||||
const TagList = ({ posts }: TagListProps) => {
|
||||
const tagData = useMemo(() => {
|
||||
if (!Array.isArray(posts)) return [];
|
||||
|
||||
const tagMap = new Map<string, number>();
|
||||
posts.forEach(post => {
|
||||
post?.data?.tags?.forEach(tag => {
|
||||
tagMap.set(tag, (tagMap.get(tag) || 0) + 1);
|
||||
});
|
||||
});
|
||||
|
||||
const tags = Array.from(tagMap.entries())
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
const maxCount = tags[0]?.[1] || 1;
|
||||
|
||||
return tags.map(([name, count], i) => {
|
||||
const ratio = count / maxCount;
|
||||
const sizeIndex = ratio > 0.8 ? 0 : ratio > 0.6 ? 1 : ratio > 0.4 ? 2 : ratio > 0.2 ? 3 : 4;
|
||||
return {
|
||||
name,
|
||||
count,
|
||||
color: spectrumColors[i % spectrumColors.length],
|
||||
size: sizeClasses[sizeIndex],
|
||||
};
|
||||
});
|
||||
}, [posts]);
|
||||
|
||||
if (tagData.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 w-full min-h-[16rem] flex items-center justify-center text-foreground opacity-60">
|
||||
No tags available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-baseline justify-center gap-x-6 gap-y-4 sm:gap-x-8 sm:gap-y-5 px-4 py-8 max-w-4xl mx-auto">
|
||||
{tagData.map(({ name, count, color, size }, i) => (
|
||||
<AnimateIn key={name} delay={i * 50}>
|
||||
<a
|
||||
href={`/blog/tags/${encodeURIComponent(name)}`}
|
||||
className={`
|
||||
${color} ${size}
|
||||
font-medium
|
||||
hover:opacity-70 transition-opacity duration-200
|
||||
cursor-pointer whitespace-nowrap
|
||||
`}
|
||||
>
|
||||
#{name}
|
||||
<span className="text-foreground/30 text-xs ml-1 align-super">
|
||||
{count}
|
||||
</span>
|
||||
</a>
|
||||
</AnimateIn>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagList;
|
||||
123
src/components/blog/tagged-posts.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { AnimateIn } from "@/components/animate-in";
|
||||
import { RssIcon, TagIcon, TrendingUpIcon } from "lucide-react";
|
||||
|
||||
type BlogPost = {
|
||||
id: string;
|
||||
data: {
|
||||
title: string;
|
||||
author: string;
|
||||
date: string;
|
||||
tags: string[];
|
||||
description: string;
|
||||
image?: string;
|
||||
imagePosition?: string;
|
||||
};
|
||||
};
|
||||
|
||||
interface TaggedPostsProps {
|
||||
tag: string;
|
||||
posts: BlogPost[];
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric"
|
||||
});
|
||||
};
|
||||
|
||||
const TaggedPosts = ({ tag, posts }: TaggedPostsProps) => {
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto">
|
||||
<div className="w-full px-4 pt-24 sm: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-all 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,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { Links } from "@/components/footer/links";
|
||||
|
||||
export default function Footer({ fixed = false }) {
|
||||
@@ -8,7 +8,7 @@ interface FooterLink {
|
||||
export const Links: FooterLink[] = [
|
||||
{ id: 0, href: "mailto:contact@timmypidashev.dev", label: "Contact", color: "text-green" },
|
||||
{ id: 1, href: "https://github.com/timmypidashev", label: "Github", color: "text-yellow" },
|
||||
{ id: 3, href: "https://www.linkedin.com/in/timothy-pidashev-4353812b8", label: "Linkedin", color: "text-blue" },
|
||||
{ id: 3, href: "https://www.linkedin.com/in/timothy-pidashev-4353812b8", label: "LinkedIn", color: "text-blue" },
|
||||
{ id: 4, href: "https://github.com/timmypidashev/web", label: "Source", color: "text-purple" },
|
||||
{ id: 5, href: "https://github.com/timmypidashev/web/releases", label: "v2", color: "text-aqua" }
|
||||
{ id: 5, href: "https://github.com/timmypidashev/web/releases", label: "v3", color: "text-aqua" }
|
||||
];
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Links } from "@/components/header/links";
|
||||
|
||||
export default function Header({ transparent = false }: { transparent?: boolean }) {
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import Typewriter from "typewriter-effect";
|
||||
|
||||
const html = (strings: TemplateStringsArray, ...values: any[]) => {
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import { AnimateIn } from "@/components/animate-in";
|
||||
|
||||
@@ -11,7 +11,7 @@ const LABELS: Record<string, string> = {
|
||||
|
||||
export default function ThemeSwitcher() {
|
||||
const [hovering, setHovering] = useState(false);
|
||||
const [nextLabel, setNextLabel] = useState("");
|
||||
const [currentLabel, setCurrentLabel] = useState("");
|
||||
|
||||
const maskRef = useRef<HTMLDivElement>(null);
|
||||
const animatingRef = useRef(false);
|
||||
@@ -19,13 +19,13 @@ export default function ThemeSwitcher() {
|
||||
|
||||
useEffect(() => {
|
||||
committedRef.current = getStoredThemeId();
|
||||
setNextLabel(LABELS[getNextTheme(committedRef.current).id] ?? "");
|
||||
setCurrentLabel(LABELS[committedRef.current] ?? "");
|
||||
|
||||
const handleSwap = () => {
|
||||
const id = getStoredThemeId();
|
||||
applyTheme(id);
|
||||
committedRef.current = id;
|
||||
setNextLabel(LABELS[getNextTheme(id).id] ?? "");
|
||||
setCurrentLabel(LABELS[id] ?? "");
|
||||
};
|
||||
|
||||
document.addEventListener("astro:after-swap", handleSwap);
|
||||
@@ -54,7 +54,7 @@ export default function ThemeSwitcher() {
|
||||
const next = getNextTheme(committedRef.current);
|
||||
applyTheme(next.id);
|
||||
committedRef.current = next.id;
|
||||
setNextLabel(LABELS[getNextTheme(next.id).id] ?? "");
|
||||
setCurrentLabel(LABELS[next.id] ?? "");
|
||||
|
||||
mask.offsetHeight;
|
||||
|
||||
@@ -84,7 +84,7 @@ export default function ThemeSwitcher() {
|
||||
className="text-foreground font-bold text-sm select-none transition-opacity duration-200"
|
||||
style={{ opacity: hovering ? 0.8 : 0.15 }}
|
||||
>
|
||||
{nextLabel}
|
||||
{currentLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
0
src/src/env.d.ts → src/env.d.ts
vendored
@@ -7,6 +7,7 @@ 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 { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
|
||||
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
|
||||
|
||||
@@ -68,6 +69,7 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
||||
</div>
|
||||
<ThemeSwitcher client:only="react" transition:persist />
|
||||
<AnimationSwitcher client:only="react" transition:persist />
|
||||
<VercelAnalytics client:load />
|
||||
<script is:inline set:html={`window.scrollTo(0,0);` + THEME_NAV_SCRIPT} />
|
||||
<script is:inline set:html={ANIMATION_NAV_SCRIPT} />
|
||||
</body>
|
||||
@@ -1,6 +1,4 @@
|
||||
---
|
||||
const { content } = Astro.props;
|
||||
|
||||
import "@/style/globals.css";
|
||||
|
||||
import { ClientRouter } from "astro:transitions";
|
||||
@@ -10,6 +8,7 @@ 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 { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
|
||||
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
|
||||
|
||||
@@ -50,6 +49,7 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
||||
<Footer client:load transition:persist fixed=true />
|
||||
<ThemeSwitcher client:only="react" transition:persist />
|
||||
<AnimationSwitcher client:only="react" transition:persist />
|
||||
<VercelAnalytics client:load />
|
||||
<script is:inline set:html={THEME_NAV_SCRIPT} />
|
||||
<script is:inline set:html={ANIMATION_NAV_SCRIPT} />
|
||||
</body>
|
||||
@@ -7,6 +7,7 @@ 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 { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
|
||||
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
|
||||
|
||||
@@ -64,6 +65,7 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
||||
</main>
|
||||
<ThemeSwitcher client:only="react" transition:persist />
|
||||
<AnimationSwitcher client:only="react" transition:persist />
|
||||
<VercelAnalytics client:load />
|
||||
<script is:inline set:html={`window.scrollTo(0,0);` + THEME_NAV_SCRIPT} />
|
||||
<script is:inline set:html={ANIMATION_NAV_SCRIPT} />
|
||||
</body>
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ANIMATION_IDS, DEFAULT_ANIMATION_ID } from "./index";
|
||||
import type { AnimationId } from "./index";
|
||||
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;
|
||||
12
src/lib/animations/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const ANIMATION_IDS = ["shuffle", "game-of-life", "lava-lamp", "confetti", "asciiquarium", "pipes"] as const;
|
||||
export type AnimationId = (typeof ANIMATION_IDS)[number];
|
||||
export const DEFAULT_ANIMATION_ID: AnimationId = "shuffle";
|
||||
|
||||
export const ANIMATION_LABELS: Record<AnimationId, string> = {
|
||||
"shuffle": "shuffle",
|
||||
"game-of-life": "life",
|
||||
"lava-lamp": "lava",
|
||||
"confetti": "confetti",
|
||||
"asciiquarium": "aquarium",
|
||||
"pipes": "pipes",
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ANIMATION_IDS, DEFAULT_ANIMATION_ID } from "./index";
|
||||
import { ANIMATION_IDS, DEFAULT_ANIMATION_ID } from "@/lib/animations";
|
||||
|
||||
const VALID_IDS = JSON.stringify(ANIMATION_IDS);
|
||||
|
||||
@@ -29,5 +29,9 @@ export interface AnimationEngine {
|
||||
|
||||
updatePalette(palette: [number, number, number][], bgColor: string): void;
|
||||
|
||||
beginExit(): void;
|
||||
|
||||
isExitComplete(): boolean;
|
||||
|
||||
cleanup(): void;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { THEMES, DEFAULT_THEME_ID } from "./index";
|
||||
import { CSS_PROPS } from "./props";
|
||||
import type { Theme } from "./types";
|
||||
import { THEMES, DEFAULT_THEME_ID } from "@/lib/themes";
|
||||
import { CSS_PROPS } from "@/lib/themes/props";
|
||||
import type { Theme } from "@/lib/themes/types";
|
||||
|
||||
export function getStoredThemeId(): string {
|
||||
if (typeof window === "undefined") return DEFAULT_THEME_ID;
|
||||
@@ -27,6 +27,7 @@ export function previewTheme(id: string): void {
|
||||
root.style.setProperty(prop, theme.colors[key]);
|
||||
}
|
||||
|
||||
|
||||
document.dispatchEvent(new CustomEvent("theme-changed", { detail: { id } }));
|
||||
}
|
||||
|
||||
@@ -55,5 +56,6 @@ export function applyTheme(id: string): void {
|
||||
el.textContent = css;
|
||||
|
||||
saveTheme(id);
|
||||
|
||||
document.dispatchEvent(new CustomEvent("theme-changed", { detail: { id } }));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Theme } from "./types";
|
||||
|
||||
export const DEFAULT_THEME_ID = "darkbox";
|
||||
export const DEFAULT_THEME_ID = "darkbox-retro";
|
||||
|
||||
function theme(
|
||||
id: string,
|
||||
@@ -3,8 +3,8 @@
|
||||
* Called at build time in Astro frontmatter.
|
||||
* The script reads "theme" from localStorage, looks up colors, injects a <style> tag.
|
||||
*/
|
||||
import { THEMES } from "./index";
|
||||
import { CSS_PROPS } from "./props";
|
||||
import { THEMES } from "@/lib/themes";
|
||||
import { CSS_PROPS } from "@/lib/themes/props";
|
||||
|
||||
// Pre-build a { prop: value } map for each theme at build time
|
||||
const themeVars: Record<string, Record<string, string>> = {};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ThemeColors } from "./types";
|
||||
import type { ThemeColors } from "@/lib/themes/types";
|
||||
|
||||
export const CSS_PROPS: [keyof ThemeColors, string][] = [
|
||||
["background", "--color-background"],
|
||||
55
src/lib/views.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import Redis from "ioredis";
|
||||
|
||||
let redis: Redis | null = null;
|
||||
|
||||
function getRedis(): Redis | null {
|
||||
if (redis) return redis;
|
||||
|
||||
const url = import.meta.env.REDIS_URL || process.env.REDIS_URL;
|
||||
if (!url) return null;
|
||||
|
||||
redis = new Redis(url);
|
||||
return redis;
|
||||
}
|
||||
|
||||
export async function incrementViews(slug: string): Promise<number> {
|
||||
const r = getRedis();
|
||||
if (!r) return 0;
|
||||
|
||||
try {
|
||||
return await r.incr(`views:${slug}`);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getViews(slug: string): Promise<number> {
|
||||
const r = getRedis();
|
||||
if (!r) return 0;
|
||||
|
||||
try {
|
||||
const val = await r.get(`views:${slug}`);
|
||||
return val ? parseInt(val, 10) : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllViews(slugs: string[]): Promise<Record<string, number>> {
|
||||
const r = getRedis();
|
||||
const result: Record<string, number> = {};
|
||||
|
||||
if (!r || slugs.length === 0) return result;
|
||||
|
||||
try {
|
||||
const keys = slugs.map(s => `views:${s}`);
|
||||
const values = await r.mget(...keys);
|
||||
for (let i = 0; i < slugs.length; i++) {
|
||||
result[slugs[i]] = values[i] ? parseInt(values[i], 10) : 0;
|
||||
}
|
||||
} catch {
|
||||
// Return empty counts if Redis unavailable
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import GlitchText from "@/components/404/glitched-text";
|
||||
const title = "404 Not Found";
|
||||
---
|
||||
|
||||
<IndexLayout content={{ title: "404 | Timothy Pidashev" }}>
|
||||
<IndexLayout title="404 | Timothy Pidashev" description="Page not found">
|
||||
<main class="min-h-screen flex flex-col items-center justify-center p-4 text-center">
|
||||
<GlitchText client:only />
|
||||
<p class="text-xl text-orange mb-8">Whoops! This page doesn't exist :(</p>
|
||||
@@ -5,6 +5,7 @@ import ContentLayout from "@/layouts/content.astro";
|
||||
import { getArticleSchema } from "@/lib/structuredData";
|
||||
import { blogWebsite } from "@/lib/structuredData";
|
||||
import { Comments } from "@/components/blog/comments";
|
||||
import { incrementViews, getViews } from "@/lib/views";
|
||||
|
||||
// This is a dynamic route in SSR mode
|
||||
const { slug } = Astro.params;
|
||||
@@ -20,6 +21,14 @@ if (!post || (!import.meta.env.DEV && post.data.isDraft === true)) {
|
||||
});
|
||||
}
|
||||
|
||||
// Track page view and get count
|
||||
let views = 0;
|
||||
if (!import.meta.env.DEV) {
|
||||
views = await incrementViews(post.id);
|
||||
} else {
|
||||
views = await getViews(post.id);
|
||||
}
|
||||
|
||||
// Dynamically render the content
|
||||
const { Content } = await render(post);
|
||||
|
||||
@@ -84,12 +93,18 @@ const jsonLd = {
|
||||
<time dateTime={post.data.date instanceof Date ? post.data.date.toISOString() : post.data.date} class="text-blue">
|
||||
{formattedDate}
|
||||
</time>
|
||||
{views > 0 && (
|
||||
<>
|
||||
<span class="text-foreground/50">•</span>
|
||||
<span class="text-green">{views.toLocaleString()} view{views !== 1 ? "s" : ""}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
{post.data.tags.map((tag) => (
|
||||
<span
|
||||
class="text-xs md:text-base text-aqua hover:text-aqua-bright transition-colors duration-200"
|
||||
onclick={`window.location.href='/blog/tag/${tag}'`}
|
||||
onclick={`window.location.href='/blog/tags/${encodeURIComponent(tag)}'`}
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
32
src/pages/blog/popular/index.astro
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
import { getCollection } from "astro:content";
|
||||
import ContentLayout from "@/layouts/content.astro";
|
||||
import { BlogHeader } from "@/components/blog/header";
|
||||
import { BlogPostList } from "@/components/blog/post-list";
|
||||
import { getAllViews } from "@/lib/views";
|
||||
|
||||
const posts = (await getCollection("blog", ({ data }) => {
|
||||
return import.meta.env.DEV || data.isDraft !== true;
|
||||
})).map(post => ({
|
||||
...post,
|
||||
data: {
|
||||
...post.data,
|
||||
date: post.data.date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric"
|
||||
})
|
||||
}
|
||||
}));
|
||||
|
||||
// Get view counts and sort by popularity
|
||||
const views = await getAllViews(posts.map(p => p.id));
|
||||
const sorted = [...posts].sort((a, b) => (views[b.id] || 0) - (views[a.id] || 0));
|
||||
---
|
||||
<ContentLayout
|
||||
title="Most Popular | Blog | Timothy Pidashev"
|
||||
description="Most popular blog posts by view count."
|
||||
>
|
||||
<BlogHeader client:load />
|
||||
<BlogPostList posts={sorted} client:load />
|
||||
</ContentLayout>
|
||||
38
src/pages/blog/tags/[...slug].astro
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
import { getCollection } from "astro:content";
|
||||
import ContentLayout from "@/layouts/content.astro";
|
||||
import TaggedPosts from "@/components/blog/tagged-posts";
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const tag = decodeURIComponent(slug || "");
|
||||
|
||||
if (!tag) {
|
||||
return Astro.redirect("/blog/tags");
|
||||
}
|
||||
|
||||
const filteredPosts = (await getCollection("blog", ({ data }) => {
|
||||
return (import.meta.env.DEV || data.isDraft !== true) && data.tags.includes(tag);
|
||||
})).sort((a, b) => {
|
||||
return b.data.date.valueOf() - a.data.date.valueOf();
|
||||
}).map(post => ({
|
||||
...post,
|
||||
data: {
|
||||
...post.data,
|
||||
date: post.data.date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric"
|
||||
})
|
||||
}
|
||||
}));
|
||||
|
||||
if (filteredPosts.length === 0) {
|
||||
return Astro.redirect("/blog/tags");
|
||||
}
|
||||
---
|
||||
<ContentLayout
|
||||
title={`#${tag} | Blog | Timothy Pidashev`}
|
||||
description={`Blog posts tagged with "${tag}".`}
|
||||
>
|
||||
<TaggedPosts tag={tag} posts={filteredPosts} client:load />
|
||||
</ContentLayout>
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
import { getCollection } from "astro:content";
|
||||
import ContentLayout from "@/layouts/content.astro";
|
||||
|
||||
import { BlogHeader } from "@/components/blog/header";
|
||||
import TagList from "@/components/blog/tag-list";
|
||||
|
||||
const posts = (await getCollection("blog", ({ data }) => {
|
||||
@@ -21,8 +21,9 @@ const posts = (await getCollection("blog", ({ data }) => {
|
||||
}));
|
||||
---
|
||||
<ContentLayout
|
||||
title="Blog | Timothy Pidashev"
|
||||
description="My experiences and technical insights into software development and the ever-evolving world of programming."
|
||||
title="Browse Tags | Blog | Timothy Pidashev"
|
||||
description="Browse blog posts by tag."
|
||||
>
|
||||
<TagList posts={posts} />
|
||||
<BlogHeader client:load />
|
||||
<TagList posts={posts} client:load />
|
||||
</ContentLayout>
|
||||