chore: prepare homepage repository for publishing
|
|
@ -13,4 +13,3 @@ npm-debug.log*
|
|||
.agents
|
||||
.codex
|
||||
*.md
|
||||
DEPLOY.md
|
||||
|
|
|
|||
5
.gitignore
vendored
|
|
@ -1,7 +1,10 @@
|
|||
node_modules
|
||||
dist
|
||||
.cache
|
||||
.npm
|
||||
.idea/
|
||||
*.iml
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env*
|
||||
!.env.example
|
||||
|
|
|
|||
52
DEPLOY.md
|
|
@ -1,52 +0,0 @@
|
|||
# Server deployment
|
||||
|
||||
The production image builds the Webpack site with Node and serves the generated `dist/` directory with an unprivileged Nginx process on port `8080`. The container does not publish a host port; Traefik reaches it over an external Docker network.
|
||||
|
||||
The runtime is read-only, drops every Linux capability, cannot gain new privileges, uses a bounded in-memory `/tmp`, and has CPU, memory, process, and log limits. Browser security headers are set by Nginx, and the site makes no third-party font, icon, or script requests.
|
||||
|
||||
## Defaults
|
||||
|
||||
| Setting | Default |
|
||||
| --- | --- |
|
||||
| Domain | `fiwlabs.dev` |
|
||||
| Traefik network | `proxy` |
|
||||
| HTTPS entrypoint | `websecure` |
|
||||
| Certificate resolver | `porkbun` |
|
||||
|
||||
Compose reads overrides from `.env`. The available keys and defaults are documented in `.env.example`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Point the `A` and optional `AAAA` records for `fiwlabs.dev` to the server.
|
||||
2. Ensure Traefik has its Docker provider enabled.
|
||||
3. Attach Traefik to the same external network configured by `TRAEFIK_NETWORK`.
|
||||
4. Ensure the configured certificate resolver exists in Traefik.
|
||||
|
||||
Create the default shared network once if the Traefik stack has not already created it:
|
||||
|
||||
```sh
|
||||
docker network create proxy
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
Validate the resolved Compose configuration:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml config
|
||||
```
|
||||
|
||||
Build and start the site:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml up --build -d
|
||||
```
|
||||
|
||||
Check container health and logs:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml ps
|
||||
docker compose -f docker-compose.yml logs -f fiws-page
|
||||
```
|
||||
|
||||
The container exposes `/healthz` internally for Docker health checks. Traefik serves the site at `https://fiwlabs.dev`; the server's `web` entrypoint redirects HTTP requests to HTTPS globally.
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
Copyright (c) HTML5 Boilerplate
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Alex (Fi3w0)
|
||||
Portions copyright (c) HTML5 Boilerplate
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
|
|
|
|||
93
README.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# FIW Labs Homepage
|
||||
|
||||
The source for [fiwlabs.dev](https://fiwlabs.dev), the personal portfolio of Alex (`Fi3w0`). It presents systems and desktop projects including TideWM, Scorium, Flux, Moonlit Shell, and the FIW Minecraft tooling collection.
|
||||
|
||||
The site is intentionally framework-light: semantic HTML, hand-written CSS, and vanilla JavaScript are compiled into a static production bundle. There is no analytics, telemetry, backend, database, or runtime dependency on third-party fonts, icons, or scripts.
|
||||
|
||||
## Highlights
|
||||
|
||||
- Responsive editorial interface with accessible navigation and reduced-motion support.
|
||||
- Interactive liquid ASCII artwork with graceful text fallbacks.
|
||||
- Self-contained assets and strict browser security headers.
|
||||
- Reproducible Webpack production build.
|
||||
- Unprivileged, read-only Nginx container designed to sit behind Traefik.
|
||||
|
||||
## Technology
|
||||
|
||||
| Area | Choice |
|
||||
| --- | --- |
|
||||
| Interface | HTML, CSS, vanilla JavaScript |
|
||||
| Build | Node.js 24, Webpack 5 |
|
||||
| Runtime | Unprivileged Nginx on port `8080` |
|
||||
| Deployment | Docker Compose and Traefik |
|
||||
| Privacy | No analytics or third-party runtime requests |
|
||||
|
||||
## Local development
|
||||
|
||||
Requirements: Node.js 24 and npm.
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm start
|
||||
```
|
||||
|
||||
Create the production bundle with:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
Webpack writes the static site to `dist/`.
|
||||
|
||||
## Production deployment
|
||||
|
||||
The Compose stack expects an existing external Docker network shared with Traefik. Copy the example environment file and adjust it for the target host:
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
docker network create proxy
|
||||
docker compose config
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
The network only needs to be created once. If Traefik already owns it, skip that command.
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `DOMAIN` | `fiwlabs.dev` | Traefik host rule |
|
||||
| `TRAEFIK_NETWORK` | `proxy` | Shared external network |
|
||||
| `TRAEFIK_HTTPS_ENTRYPOINT` | `websecure` | HTTPS entrypoint |
|
||||
| `TRAEFIK_CERT_RESOLVER` | `porkbun` | Configured certificate resolver |
|
||||
| `IMAGE_NAME` | `fiws-page` | Local/container registry image name |
|
||||
| `IMAGE_TAG` | `latest` | Image tag to deploy |
|
||||
|
||||
Check the deployment with:
|
||||
|
||||
```sh
|
||||
docker compose ps
|
||||
docker compose logs --tail=100 fiws-page
|
||||
```
|
||||
|
||||
Docker monitors `/healthz` inside the container. The service is exposed only to the shared Docker network; it does not publish a host port.
|
||||
|
||||
## Security posture
|
||||
|
||||
The production container runs as an unprivileged user with a read-only filesystem, all Linux capabilities dropped, privilege escalation disabled, and bounded CPU, memory, process, temporary-storage, and log usage. Nginx serves only static files and applies CSP, HSTS, referrer, permissions, framing, and content-type protections.
|
||||
|
||||
Keep secrets out of the frontend and out of Docker build arguments. Everything delivered to a browser is public by definition.
|
||||
|
||||
## Repository layout
|
||||
|
||||
```text
|
||||
css/ Site styles
|
||||
deploy/nginx.conf Static server and security headers
|
||||
img/ Optimized project artwork
|
||||
js/app.js Interaction and visual effects
|
||||
Dockerfile Multi-stage production image
|
||||
docker-compose.yml Traefik-connected runtime
|
||||
webpack.*.js Development and production builds
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
The website source is available under the [MIT License](LICENSE.txt). Product names, screenshots, logos, and third-party marks retain their respective rights.
|
||||
|
|
@ -732,101 +732,6 @@ code {
|
|||
transform: translateX(1.2rem);
|
||||
}
|
||||
|
||||
.current-project.has-tide-ascii .current-watermark {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ASCIIText, ported from React Bits to vanilla JS/three.js. Lazily mounted
|
||||
(see app.js) to replace the flat "TIDE" watermark with a rippling,
|
||||
wave-distorted ASCII render — a nod to the compositor's water theme.
|
||||
Falls back to the plain watermark above if WebGL/three.js is unavailable. */
|
||||
.tide-ascii {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
bottom: -9%;
|
||||
left: -6%;
|
||||
width: min(82rem, 126%);
|
||||
height: 55%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 900ms ease;
|
||||
}
|
||||
|
||||
.tide-ascii.is-ready {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tide-ascii-inner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.tide-ascii-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.7;
|
||||
filter: saturate(1.4);
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.tide-ascii-pre {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
color: rgba(17, 23, 26, 0.86);
|
||||
line-height: 1em;
|
||||
mix-blend-mode: multiply;
|
||||
white-space: pre;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media (max-width: 68rem) {
|
||||
.tide-ascii {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.current-project.has-tide-ascii .current-watermark {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.project-visual-flux.has-flux-ascii .flux-watermark {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.flux-ascii {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
bottom: 34%;
|
||||
left: -14%;
|
||||
width: min(58rem, 118%);
|
||||
height: 88%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 900ms ease;
|
||||
}
|
||||
|
||||
.flux-ascii.is-ready {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@media (max-width: 64rem) {
|
||||
.flux-ascii {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.project-visual-flux.has-flux-ascii .flux-watermark {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.project-feature {
|
||||
min-height: 38rem;
|
||||
margin-bottom: 4rem;
|
||||
|
|
|
|||
BIN
icon.png
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 4.8 KiB |
BIN
img/avatar.png
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 32 KiB |
368
js/app.js
|
|
@ -241,374 +241,6 @@ if (gooeyNav && gooeyLinks.length && !reducedMotion) {
|
|||
});
|
||||
}
|
||||
|
||||
// --- ASCIIText, ported from React Bits (vanilla JS, three.js bundled and
|
||||
// loaded on demand). Replaces the flat "TIDE" watermark on the TideWM card with a
|
||||
// rippling, wave-distorted ASCII render once the card scrolls into view.
|
||||
// The plain-text watermark stays in the DOM as the fallback if WebGL or the
|
||||
// three.js module load ever fails. ---
|
||||
(() => {
|
||||
const ASCII_FONT_STACK = '"SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace';
|
||||
const targets = [
|
||||
{
|
||||
host: document.querySelector("#tide-ascii"),
|
||||
ancestorSelector: ".current-project",
|
||||
readyClass: "has-tide-ascii",
|
||||
text: "TIDE",
|
||||
enableWaves: true,
|
||||
asciiFontSize: 11,
|
||||
textFontSize: 220,
|
||||
},
|
||||
{
|
||||
// Flux is a media-editing tool, not a water compositor — keep its
|
||||
// watermark shape still and crisp (no mesh ripple), but let the
|
||||
// glyphs themselves flicker/decode for a "processing feed" feel.
|
||||
host: document.querySelector("#flux-ascii"),
|
||||
ancestorSelector: ".project-visual-flux",
|
||||
readyClass: "has-flux-ascii",
|
||||
text: "FLUX",
|
||||
enableWaves: false,
|
||||
charJitter: true,
|
||||
asciiFontSize: 8,
|
||||
textFontSize: 240,
|
||||
},
|
||||
]
|
||||
.map((t) => ({ ...t, ancestor: t.host?.closest(t.ancestorSelector) }))
|
||||
.filter((t) => t.host && t.ancestor);
|
||||
|
||||
if (!targets.length || reducedMotion) return;
|
||||
|
||||
let hasWebGL = false;
|
||||
try {
|
||||
const testCanvas = document.createElement("canvas");
|
||||
hasWebGL = Boolean(testCanvas.getContext("webgl") || testCanvas.getContext("experimental-webgl"));
|
||||
} catch {
|
||||
hasWebGL = false;
|
||||
}
|
||||
if (!hasWebGL) return;
|
||||
|
||||
let threeLoadPromise = null;
|
||||
|
||||
const loadThree = () => {
|
||||
if (window.THREE) return Promise.resolve();
|
||||
if (!threeLoadPromise) {
|
||||
threeLoadPromise = import("three").then((three) => {
|
||||
window.THREE = three;
|
||||
});
|
||||
}
|
||||
return threeLoadPromise;
|
||||
};
|
||||
|
||||
const mapRange = (n, start, stop, start2, stop2) => ((n - start) / (stop - start)) * (stop2 - start2) + start2;
|
||||
|
||||
const vertexShader = `
|
||||
varying vec2 vUv;
|
||||
uniform float uTime;
|
||||
uniform float uEnableWaves;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
float time = uTime * 5.0;
|
||||
float waveFactor = uEnableWaves;
|
||||
vec3 transformed = position;
|
||||
transformed.x += sin(time + position.y) * 0.5 * waveFactor;
|
||||
transformed.y += cos(time + position.z) * 0.15 * waveFactor;
|
||||
transformed.z += sin(time + position.x) * waveFactor;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = `
|
||||
varying vec2 vUv;
|
||||
uniform float uTime;
|
||||
uniform float uEnableWaves;
|
||||
uniform sampler2D uTexture;
|
||||
void main() {
|
||||
float time = uTime;
|
||||
vec2 pos = vUv;
|
||||
float amount = mix(0.006, 0.01, uEnableWaves);
|
||||
float r = texture2D(uTexture, pos + cos(time * 2.0 - time + pos.x) * amount).r;
|
||||
float g = texture2D(uTexture, pos + tan(time * 0.5 + pos.x - time) * amount).g;
|
||||
float b = texture2D(uTexture, pos - cos(time * 2.0 + time + pos.y) * amount).b;
|
||||
float a = texture2D(uTexture, pos).a;
|
||||
gl_FragColor = vec4(r, g, b, a);
|
||||
}
|
||||
`;
|
||||
|
||||
class AsciiFilter {
|
||||
constructor(renderer, { fontSize, fontFamily, charset, charJitter } = {}) {
|
||||
this.renderer = renderer;
|
||||
this.domElement = document.createElement("div");
|
||||
this.domElement.className = "tide-ascii-inner";
|
||||
this.pre = document.createElement("pre");
|
||||
this.pre.className = "tide-ascii-pre";
|
||||
this.domElement.append(this.pre);
|
||||
this.canvas = document.createElement("canvas");
|
||||
this.canvas.className = "tide-ascii-canvas";
|
||||
this.context = this.canvas.getContext("2d");
|
||||
this.domElement.append(this.canvas);
|
||||
this.fontSize = fontSize ?? 8;
|
||||
this.fontFamily = fontFamily ?? ASCII_FONT_STACK;
|
||||
this.charset = charset ?? " .'`^\",:;Il!i~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$";
|
||||
// Optional per-cell character flicker — a subtle "decoding" glitch
|
||||
// where a handful of glyphs jump to a denser/lighter char and settle
|
||||
// back, independent of any mesh/wave motion.
|
||||
this.charJitter = charJitter ?? false;
|
||||
this.context.imageSmoothingEnabled = false;
|
||||
}
|
||||
|
||||
setSize(width, height) {
|
||||
if (!width || !height) return;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.renderer.setSize(width, height);
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.context.font = `${this.fontSize}px ${this.fontFamily}`;
|
||||
const charWidth = this.context.measureText("A").width;
|
||||
this.cols = Math.max(1, Math.floor(this.width / (this.fontSize * (charWidth / this.fontSize))));
|
||||
this.rows = Math.max(1, Math.floor(this.height / this.fontSize));
|
||||
this.canvas.width = this.cols;
|
||||
this.canvas.height = this.rows;
|
||||
this.pre.style.fontFamily = this.fontFamily;
|
||||
this.pre.style.fontSize = `${this.fontSize}px`;
|
||||
}
|
||||
|
||||
render(scene, camera, time) {
|
||||
this.renderer.render(scene, camera);
|
||||
const w = this.canvas.width;
|
||||
const h = this.canvas.height;
|
||||
this.context.clearRect(0, 0, w, h);
|
||||
if (this.context && w && h) this.context.drawImage(this.renderer.domElement, 0, 0, w, h);
|
||||
this.asciify(this.context, w, h, time ?? 0);
|
||||
}
|
||||
|
||||
asciify(ctx, w, h, time = 0) {
|
||||
if (!w || !h) return;
|
||||
const imgData = ctx.getImageData(0, 0, w, h).data;
|
||||
const jitter = this.charJitter;
|
||||
const frameBucket = Math.floor(time * 7);
|
||||
const last = this.charset.length - 1;
|
||||
let str = "";
|
||||
for (let y = 0; y < h; y += 1) {
|
||||
for (let x = 0; x < w; x += 1) {
|
||||
const i = x * 4 + y * 4 * w;
|
||||
const [r, g, b, a] = [imgData[i], imgData[i + 1], imgData[i + 2], imgData[i + 3]];
|
||||
if (a === 0) {
|
||||
str += " ";
|
||||
continue;
|
||||
}
|
||||
const gray = (0.3 * r + 0.6 * g + 0.1 * b) / 255;
|
||||
let idx = this.charset.length - Math.floor((1 - gray) * last) - 1;
|
||||
if (jitter) {
|
||||
const seed = Math.sin(x * 12.9898 + y * 78.233 + frameBucket * 37.719) * 43758.5453;
|
||||
const rnd = seed - Math.floor(seed);
|
||||
if (rnd > 0.85) {
|
||||
const swing = Math.floor(((rnd - 0.85) / 0.15) * 8) - 4;
|
||||
idx = Math.min(last, Math.max(0, idx + swing));
|
||||
}
|
||||
}
|
||||
str += this.charset[idx];
|
||||
}
|
||||
str += "\n";
|
||||
}
|
||||
this.pre.textContent = str;
|
||||
}
|
||||
|
||||
dispose() {}
|
||||
}
|
||||
|
||||
class CanvasTxt {
|
||||
constructor(txt, { fontSize = 200, fontFamily = "Arial", color = "#fdf9f3" } = {}) {
|
||||
this.canvas = document.createElement("canvas");
|
||||
this.context = this.canvas.getContext("2d");
|
||||
this.txt = txt;
|
||||
this.font = `600 ${fontSize}px ${fontFamily}`;
|
||||
this.color = color;
|
||||
}
|
||||
|
||||
resize() {
|
||||
this.context.font = this.font;
|
||||
const metrics = this.context.measureText(this.txt);
|
||||
this.canvas.width = Math.ceil(metrics.width) + 20;
|
||||
this.canvas.height = Math.ceil((metrics.actualBoundingBoxAscent || 0) + (metrics.actualBoundingBoxDescent || 0)) + 20;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.context.fillStyle = this.color;
|
||||
this.context.font = this.font;
|
||||
const metrics = this.context.measureText(this.txt);
|
||||
this.context.fillText(this.txt, 10, 10 + metrics.actualBoundingBoxAscent);
|
||||
}
|
||||
|
||||
get width() {
|
||||
return this.canvas.width;
|
||||
}
|
||||
|
||||
get height() {
|
||||
return this.canvas.height;
|
||||
}
|
||||
|
||||
get texture() {
|
||||
return this.canvas;
|
||||
}
|
||||
}
|
||||
|
||||
class CanvAscii {
|
||||
constructor({ text, asciiFontSize, textFontSize, textColor, planeBaseHeight, enableWaves, charJitter }, container, width, height) {
|
||||
this.textString = text;
|
||||
this.asciiFontSize = asciiFontSize;
|
||||
this.textFontSize = textFontSize;
|
||||
this.textColor = textColor;
|
||||
this.planeBaseHeight = planeBaseHeight;
|
||||
this.container = container;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.enableWaves = enableWaves;
|
||||
this.charJitter = charJitter;
|
||||
this.camera = new window.THREE.PerspectiveCamera(45, this.width / this.height, 1, 1000);
|
||||
this.camera.position.z = 30;
|
||||
this.scene = new window.THREE.Scene();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setMesh();
|
||||
this.setRenderer();
|
||||
}
|
||||
|
||||
setMesh() {
|
||||
const THREE = window.THREE;
|
||||
this.textCanvas = new CanvasTxt(this.textString, {
|
||||
fontSize: this.textFontSize,
|
||||
fontFamily: ASCII_FONT_STACK,
|
||||
color: this.textColor,
|
||||
});
|
||||
this.textCanvas.resize();
|
||||
this.textCanvas.render();
|
||||
this.texture = new THREE.CanvasTexture(this.textCanvas.texture);
|
||||
this.texture.minFilter = THREE.NearestFilter;
|
||||
const textAspect = this.textCanvas.width / this.textCanvas.height;
|
||||
const planeW = this.planeBaseHeight * textAspect;
|
||||
this.geometry = new THREE.PlaneGeometry(planeW, this.planeBaseHeight, 36, 36);
|
||||
this.material = new THREE.ShaderMaterial({
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
transparent: true,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uTexture: { value: this.texture },
|
||||
uEnableWaves: { value: this.enableWaves ? 1 : 0 },
|
||||
},
|
||||
});
|
||||
this.mesh = new THREE.Mesh(this.geometry, this.material);
|
||||
this.scene.add(this.mesh);
|
||||
}
|
||||
|
||||
setRenderer() {
|
||||
const THREE = window.THREE;
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
|
||||
this.renderer.setPixelRatio(1);
|
||||
this.renderer.setClearColor(0x000000, 0);
|
||||
this.filter = new AsciiFilter(this.renderer, { fontSize: this.asciiFontSize, charJitter: this.charJitter });
|
||||
this.container.append(this.filter.domElement);
|
||||
this.setSize(this.width, this.height);
|
||||
}
|
||||
|
||||
setSize(w, h) {
|
||||
if (!w || !h) return;
|
||||
this.width = w;
|
||||
this.height = h;
|
||||
this.camera.aspect = w / h;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.filter.setSize(w, h);
|
||||
}
|
||||
|
||||
animate() {
|
||||
const frame = () => {
|
||||
this.animationFrameId = requestAnimationFrame(frame);
|
||||
this.renderFrame();
|
||||
};
|
||||
frame();
|
||||
}
|
||||
|
||||
renderFrame() {
|
||||
const time = Date.now() * 0.001;
|
||||
this.mesh.material.uniforms.uTime.value = Math.sin(time);
|
||||
this.filter.render(this.scene, this.camera, time);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.animationFrameId) cancelAnimationFrame(this.animationFrameId);
|
||||
this.filter?.dispose();
|
||||
this.filter?.domElement?.remove();
|
||||
this.renderer?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
const mount = async ({ host, ancestor, readyClass, text, enableWaves, charJitter, asciiFontSize, textFontSize }) => {
|
||||
try {
|
||||
await loadThree();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!window.THREE) return;
|
||||
|
||||
const rect = host.getBoundingClientRect();
|
||||
if (rect.width < 20 || rect.height < 20) return;
|
||||
|
||||
let instance;
|
||||
try {
|
||||
instance = new CanvAscii(
|
||||
{
|
||||
text,
|
||||
asciiFontSize: asciiFontSize ?? 11,
|
||||
textFontSize: textFontSize ?? 220,
|
||||
textColor: "#eaf9f7",
|
||||
planeBaseHeight: 8,
|
||||
enableWaves: enableWaves ?? true,
|
||||
charJitter: charJitter ?? false,
|
||||
},
|
||||
host,
|
||||
rect.width,
|
||||
rect.height,
|
||||
);
|
||||
await instance.init();
|
||||
instance.animate();
|
||||
} catch {
|
||||
instance?.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
host.classList.add("is-ready");
|
||||
ancestor.classList.add(readyClass);
|
||||
|
||||
if ("ResizeObserver" in window) {
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
const { width, height } = entry.contentRect;
|
||||
if (width > 20 && height > 20) instance.setSize(width, height);
|
||||
});
|
||||
resizeObserver.observe(host);
|
||||
}
|
||||
};
|
||||
|
||||
targets.forEach((target) => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries, obs) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) return;
|
||||
obs.disconnect();
|
||||
mount(target);
|
||||
});
|
||||
},
|
||||
{ rootMargin: "200px" },
|
||||
);
|
||||
observer.observe(target.host);
|
||||
});
|
||||
})();
|
||||
|
||||
// --- Liquid ASCII: a real FLIP/PIC fluid simulation (grid + particles,
|
||||
// incompressible pressure solve, particle-particle separation) rendered as
|
||||
// ASCII. No React, no registry, no CDN. Ported by hand from
|
||||
|
|
|
|||
16
package-lock.json
generated
|
|
@ -1,21 +1,24 @@
|
|||
{
|
||||
"name": "fi3w0-portfolio",
|
||||
"name": "fiwlabs-homepage",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "fi3w0-portfolio",
|
||||
"name": "fiwlabs-homepage",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"three": "0.128.0",
|
||||
"webpack": "^5.91.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "^6.0.0",
|
||||
"webpack-merge": "^5.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24",
|
||||
"npm": ">=11"
|
||||
}
|
||||
},
|
||||
"node_modules/@discoveryjs/json-ext": {
|
||||
|
|
@ -4229,13 +4232,6 @@
|
|||
"tslib": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.128.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.128.0.tgz",
|
||||
"integrity": "sha512-i0ap/E+OaSfzw7bD1TtYnPo3VEplkl70WX5fZqZnfZsE3k3aSFudqrrC9ldFZfYFkn1zwDmBcdGfiIm/hnbyZA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/thunky": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
|
||||
|
|
|
|||
10
package.json
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "fi3w0-portfolio",
|
||||
"name": "fiwlabs-homepage",
|
||||
"version": "1.0.0",
|
||||
"description": "Personal systems and development portfolio for Alex (Fi3w0).",
|
||||
"private": true,
|
||||
|
|
@ -10,7 +10,12 @@
|
|||
"homelab"
|
||||
],
|
||||
"license": "MIT",
|
||||
"author": "Alex (Fi3w0)",
|
||||
"author": "Alex (Fi3w0) <alex@fiwlabs.dev>",
|
||||
"homepage": "https://fiwlabs.dev",
|
||||
"engines": {
|
||||
"node": ">=24",
|
||||
"npm": ">=11"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "webpack serve --open --config webpack.config.dev.js",
|
||||
"build": "webpack --config webpack.config.prod.js"
|
||||
|
|
@ -18,7 +23,6 @@
|
|||
"devDependencies": {
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"three": "0.128.0",
|
||||
"webpack": "^5.91.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "^6.0.0",
|
||||
|
|
|
|||