Compare commits

...

5 commits

Author SHA1 Message Date
saiminh
8e2a5ed8bf implements brevo form, webfonts sh 2026-01-16 17:43:23 +13:00
saiminh
bad6faf555 fix deprecated syntax 2026-01-11 18:25:43 +13:00
saiminh
9ad74009cb deployment guide 2026-01-11 18:25:31 +13:00
saiminh
464121598d take deprecation into account 2026-01-11 18:24:43 +13:00
saiminh
b3c1433b67 remove unused 2026-01-11 18:24:11 +13:00
17 changed files with 278 additions and 105 deletions

View file

@ -148,5 +148,56 @@ If you see `ERR_CONNECTION_REFUSED` errors for `/_app/immutable/` assets:
## Environment Variables
If you need to set environment variables, edit the `env` section in `ecosystem.config.cjs` or create a `.env` file in `/floter-design`.
### Important: VITE_PUBLIC_* Variables
Variables prefixed with `VITE_PUBLIC_` (like `VITE_PUBLIC_CLOUDINARY_CLOUD_NAME`) must be available **at build time**, not just runtime. They are embedded in the client bundle during `npm run build`.
### Setting Up Environment Variables
**1. Create a `.env` file in your project directory:**
```bash
cd ~/floter-design
nano .env
```
Add your environment variables:
```bash
# Cloudinary Configuration (required)
VITE_PUBLIC_CLOUDINARY_CLOUD_NAME=your-actual-cloud-name
# Add any other VITE_PUBLIC_* variables you need
```
**2. Build with environment variables:**
The `.env` file will be automatically loaded when you run:
```bash
npm run build
```
**3. For runtime-only environment variables** (not prefixed with `VITE_PUBLIC_`):
Edit `ecosystem.config.cjs` and add them to the `env` section:
```javascript
env: {
NODE_ENV: 'production',
PORT: 3001,
HOST: '0.0.0.0',
// Add other runtime variables here
}
```
**4. After updating `.env`, rebuild and restart:**
```bash
npm run build
pm2 restart floter-design
```
**5. Security Note:**
- Never commit `.env` files to git (already in `.gitignore`)
- The `.env` file should contain your actual secrets
- `VITE_PUBLIC_*` variables are exposed to the client-side code - don't put secrets there

View file

@ -3,7 +3,10 @@
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="stylesheet" href="https://use.typekit.net/ltu2cxf.css">
<link rel="preload" as="font" type="font/woff2" href="/fonts/Stratos-Regular.woff2" crossorigin>
<link rel="preload" as="font" type="font/woff2" href="/fonts/Stratos-BoldItalic.woff2" crossorigin>
<link rel="preload" as="font" type="font/woff2" href="/fonts/PPFormula-Medium.woff2" crossorigin>
<link rel="preload" as="font" type="font/woff2" href="/fonts/PPFormula-Extrabold.woff2" crossorigin>
<meta name="viewport" content="width=device-width" />
%sveltekit.head%
</head>

View file

@ -1,13 +1,9 @@
:root {
--spacing-outer: 5vw;
--spacing-nav: 5vw;
// --color-bg: hsl(202, 58%, 39%);
--color-bg: hsl(0, 60%, 53%);
--color-text: #ffead9;
--color-highlight: #1d0c12;
// --color-bg: #000000;
// --color-text: #FFFFFF;
// --color-highlight: #FF4D00;
--color-bg: rgb(207, 63, 63);
--color-text: rgb(255, 234, 217);
--color-highlight: rgb(29, 12, 18);
--aspect-ratio-heroes: 1.5;
--font-size-p: clamp(20px, 1.6vw, 1.6vw);
@ -17,6 +13,20 @@
}
}
@font-face {
font-family: 'Stratos';
src: url('/fonts/Stratos-Regular.woff2') format('woff2');
font-weight: normal;
font-display: swap;
}
@font-face {
font-family: 'Stratos';
src: url('/fonts/Stratos-BoldItalic.woff2') format('woff2');
font-weight: bold;
font-display: swap;
}
a:hover, input:hover, button:hover {
cursor: url('/pointer.svg'), auto;
}
@ -32,18 +42,6 @@ body {
max-width: 100vw;
overflow-x: hidden;
}
// body:after {
// content: '';
// display: block;
// position: fixed;
// top: 0;
// left: 0;
// width: calc(100vw - 00px);
// height: calc(100svh - 30px);
// border: 15px solid var(--color-text);
// z-index: 1;
// pointer-events: none;
// }
body * {
box-sizing: border-box;
}

View file

@ -1,7 +1,11 @@
import * as PIXI from 'pixi.js';
export default function createCanvasText( element: HTMLImageElement, stage: PIXI.Container ){
export default function createCanvasText(
element: HTMLImageElement,
stage: PIXI.Container,
spriteObject: PIXI.Sprite | null = null
){
const elem = element;
const elemSrc = elem.currentSrc || elem.src;
const elemPosition = elem.getBoundingClientRect();
@ -11,9 +15,22 @@ export default function createCanvasText( element: HTMLImageElement, stage: PIX
if (elemSrc.includes('.svg')) {
scalefactor = Number(elem.attributes.getNamedItem('data-svgscale')?.value);
const canvasImgTexture = PIXI.Texture.from(elemSrc, { resourceOptions: { scale: scalefactor } });
canvasImg = new PIXI.Sprite(canvasImgTexture);
// Use provided sprite object or create a new one
if (spriteObject) {
spriteObject.texture = canvasImgTexture;
canvasImg = spriteObject;
} else {
canvasImg = new PIXI.Sprite(canvasImgTexture);
}
} else {
canvasImg = PIXI.Sprite.from(elemSrc);
// Use provided sprite object or create a new one
if (spriteObject) {
spriteObject.texture = PIXI.Texture.from(elemSrc);
canvasImg = spriteObject;
} else {
canvasImg = PIXI.Sprite.from(elemSrc);
}
}
canvasImg.position.set(elemPosition.x, elemPosition.y);

View file

@ -1,8 +1,12 @@
import type * as PIXI from 'pixi.js';
import { Text } from 'pixi.js';
export default function createCanvasText( element: HTMLElement, stage: PIXI.Container ){
export default function createCanvasText(
element: HTMLElement,
stage: PIXI.Container,
textObject: PIXI.Text | null = null
){
const elem = element;
const elemStyles = window.getComputedStyle(elem);
const elemFontSize = elemStyles.getPropertyValue('font-size') || '16px';
@ -19,7 +23,8 @@ export default function createCanvasText( element: HTMLElement, stage: PIXI.Con
elem.textContent = elem.textContent?.toUpperCase() as string;
}
const canvasText = new Text(elem.textContent as string, {
// Use provided text object or create a new one
const canvasText = textObject || new Text(elem.textContent as string, {
fontFamily: elemFontFamily,
fontSize: elemFontSize,
fontWeight: elemFontWeight as PIXI.TextStyleFontWeight,
@ -30,6 +35,18 @@ export default function createCanvasText( element: HTMLElement, stage: PIXI.Con
padding: 20,
});
// Update existing text object properties
if (textObject) {
canvasText.text = elem.textContent as string;
canvasText.style.fontFamily = elemFontFamily;
canvasText.style.fontSize = parseInt(elemFontSize);
canvasText.style.fontWeight = elemFontWeight as PIXI.TextStyleFontWeight;
canvasText.style.fontStyle = elemFontStyle as PIXI.TextStyleFontStyle;
canvasText.style.letterSpacing = elemLetterSpacing;
canvasText.style.fill = elemColor;
canvasText.style.align = elemAlignment as PIXI.TextStyleAlign;
}
canvasText.on('added', () => {
elem.classList.add('canvas-text-added');
elem.style.visibility = 'hidden';

View file

@ -9,3 +9,9 @@ import * as PIXI from 'pixi.js';
// from filter packages that use the deprecated settings.FILTER_RESOLUTION
PIXI.Filter.defaultResolution = 2;
// Also set the deprecated setting for backward compatibility with older packages
// This prevents warnings from packages like @pixi/filter-advanced-bloom that
// still check the deprecated settings.FILTER_RESOLUTION during module initialization
if (PIXI.settings && 'FILTER_RESOLUTION' in PIXI.settings) {
(PIXI.settings as any).FILTER_RESOLUTION = 2;
}

View file

@ -1,33 +0,0 @@
import { dominantColourPlaceholder, IMAGE_DIR, lowResolutionPlaceholder } from '$lib/utils/image'
import path from 'path';
const __dirname = path.resolve();
export async function POST({request}: {request: Request}) {
try {
const { images } = await request.json();
const dominantColourPromises = images.map((element: string)=>{
const source = path.join(__dirname, IMAGE_DIR, element);
return dominantColourPlaceholder({ source });
});
const placeholderPromises = images.map((element: string) => {
const source = path.join(__dirname, IMAGE_DIR, element);
return lowResolutionPlaceholder({ source });
});
const dominantColours =await Promise.all(dominantColourPromises);
const placeholders = await Promise.all(placeholderPromises);
return {
body:JSON.stringify({ placeholders, dominantColours }),
};
} catch (err) {
console.log('Error: ', err);
return {
status: 500,
error: 'Error retrieving data',
};
}
};

View file

@ -0,0 +1,79 @@
import type { Actions } from './$types';
import { BREVO_API_KEY } from '$env/static/private';
import { fail, redirect } from '@sveltejs/kit';
const BREVO_ENDPOINT = 'https://api.brevo.com/v3/smtp/email';
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const prerender = false;
export const actions: Actions = {
default: async ({ request, fetch }) => {
const formData = await request.formData();
const name = (formData.get('name') ?? '').toString().trim();
const email = (formData.get('email') ?? '').toString().trim();
const message = (formData.get('contact') ?? '').toString().trim();
const fields = { name, email, contact: message };
if (!email || !message) {
return fail(400, { error: 'Please provide your email and message.', fields });
}
if (!emailRegex.test(email)) {
return fail(400, { error: 'Please provide a valid email address.', fields });
}
if (!BREVO_API_KEY) {
console.error('Missing BREVO_API_KEY in environment');
return fail(500, { error: 'Email service is not configured. Please try again later.', fields });
}
const subject = `New contact form submission from ${name || 'Website visitor'}`;
const textContent = `Name: ${name || 'N/A'}\nEmail: ${email}\n\nMessage:\n${message}`;
const htmlContent = `<p><strong>Name:</strong> ${name || 'N/A'}</p><p><strong>Email:</strong> ${email}</p><p><strong>Message:</strong></p><p>${message.replace(/\n/g, '<br>')}</p>`;
const payload = {
sender: {
name: 'Website Contact',
email: 'simon@floter.design'
},
to: [
{
email: 'simon@floter.design',
name: 'Simon Flöter'
}
],
replyTo: {
email,
name: name || 'Website visitor'
},
subject,
textContent,
htmlContent,
tags: ['contact-form']
};
try {
const response = await fetch(BREVO_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'api-key': BREVO_API_KEY
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorText = await response.text();
console.error(`Brevo API error ${response.status}: ${errorText}`);
return fail(502, { error: 'Sending failed. Please try again later.', fields });
}
throw redirect(303, '/success');
} catch (err) {
console.error('Brevo API request failed', err);
return fail(500, { error: 'Unexpected error. Please try again later.', fields });
}
}
};

View file

@ -5,6 +5,8 @@
import { SplitText } from 'gsap/SplitText';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
export let form: { error?: string; fields?: { name?: string; email?: string; contact?: string } } | undefined;
let canvasTexts: Array<HTMLElement> = [];
let contactFormVisible = false;
let contactFormClickHandler = (e: Event) => {
@ -88,25 +90,27 @@
</div>
<div class="formwrapper">
<span class="button button-back" on:click={contactFormClickHandler} on:keydown={contactFormClickHandler} role="button" tabindex="0">← Back</span>
<form name="contact" action="/success" method="POST" data-netlify="true">
<input type="hidden" name="form-name" value="contact">
<form name="contact" method="POST">
<div class="inputs-flex-row">
<label for="name">
<!-- <p>How would you like to be addressed?</p> -->
<input type="text" name="name" id="name" placeholder="Your name">
<input type="text" name="name" id="name" placeholder="Your name" value={form?.fields?.name ?? ''}>
</label>
<label for="email">
<!-- <p>For receiving a reply, add your Email address:</p> -->
<input type="email" name="email" id="email" placeholder="Your Email?*" required>
<input type="email" name="email" id="email" placeholder="Your Email?*" required value={form?.fields?.email ?? ''}>
</label>
</div>
<label for="contact">
<!-- <p>Please describe your plight in a few words</p> -->
<textarea rows="8" name="contact" id="contact" placeholder="Your business propositions, praise, complaints and/or threats" required />
<textarea rows="8" name="contact" id="contact" placeholder="Your business propositions, praise, complaints and/or threats" required>{form?.fields?.contact ?? ''}</textarea>
</label>
<div class="disclaimer">
<p>Disclaimer: I will only use the data you submit here (name, email, message) to respond. I will not pass it on to any third party. If I don't hear from you I will delete the data and keep no records of it.</p>
</div>
{#if form?.error}
<p class="form-error" role="alert" aria-live="polite">{form.error}</p>
{/if}
<div class="send">
<button class="button button--xl button--primary" type="submit">Send it!</button>
</div>
@ -266,6 +270,15 @@
margin: 1em auto;
font-size: 1rem;
}
.form-error {
max-width: var(--form-maxwidth);
margin: 0 auto 1em auto;
padding: .75em;
border: 1px solid var(--color-text);
border-radius: 4px;
font-size: .95em;
background: rgba(255, 255, 255, 0.08);
}
.send {
max-width: var(--form-maxwidth);
margin: auto;

View file

@ -1,4 +1,4 @@
export const prerender = true
export const prerender = false
export function load() {
return {
title: 'Contact me!',

View file

@ -5,23 +5,25 @@
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { onMount } from 'svelte';
let canvas: HTMLCanvasElement;
let introDone = false;
onMount( () => {
// Object pool for PIXI.Text objects
const textPool: PIXI.Text[] = [];
onMount(() => {
let highLightColor = window.getComputedStyle(document.body).getPropertyValue('--color-highlight');
let is_fine = window.matchMedia('(pointer:fine)').matches
let is_landscape = window.matchMedia('(orientation:landscape)').matches
gsap.registerPlugin(ScrollTrigger);
let app = new PIXI.Application({
resizeTo: window,
antialias: true,
autoDensity: true,
autoDensity: true,
resolution: 2,
backgroundColor: 'rgb(255, 255, 255)',
backgroundAlpha: 0,
@ -31,7 +33,7 @@
let textgroup = new PIXI.Container();
textgroup.pivot.set(0, 0);
textgroup.x = 0;
textgroup.y = - window.innerHeight;
textgroup.y = -window.innerHeight;
textgroup.height = window.innerHeight;
textgroup.width = window.innerWidth;
app.stage.addChild(textgroup);
@ -40,37 +42,50 @@
let fontSize = window.innerHeight / 3;
function createText(string: string){
let text = new PIXI.Text(string,
{ fontFamily: 'Stratos',
fontSize: fontSize,
fontWeight: '800',
fontStyle: 'italic',
lineHeight: 0,
letterSpacing: -10,
fill: highLightColor,
padding: 0
}
);
function createText(string: string): PIXI.Text {
let text = getTextFromPool();
text.text = string;
text.style = {
fontFamily: 'Stratos',
fontSize: fontSize,
fontWeight: '800',
fontStyle: 'italic',
lineHeight: 0,
letterSpacing: -10,
fill: highLightColor,
padding: 0
};
text.anchor.set(0);
textgroup.addChild(text);
return text
}
// Text object pool management
function getTextFromPool(): PIXI.Text {
if (textPool.length > 0) {
return textPool.pop() as PIXI.Text;
}
return new PIXI.Text();
}
function returnTextToPool(text: PIXI.Text) {
textPool.push(text);
}
let allTexts = [
createText('SERVICES SERVICES '), createText('SERVICES SERVICES '),
createText('CONSULTATION DESIGN WEB DEVELOPMENT '), createText('CONSULTATION DESIGN WEB DEVELOPMENT '),
createText('SERVICES SERVICES '), createText('SERVICES SERVICES '),
createText('CONSULTATION DESIGN WEB DEVELOPMENT '), createText('CONSULTATION DESIGN WEB DEVELOPMENT '),
createText('SERVICES SERVICES '), createText('SERVICES SERVICES '),
createText('CONSULTATION DESIGN WEB DEVELOPMENT '), createText('CONSULTATION DESIGN WEB DEVELOPMENT ')
]
let textRows = [
[allTexts[0], allTexts[1]],
[allTexts[2], allTexts[3]],
[allTexts[4], allTexts[5]],
[allTexts[6], allTexts[7]]
]
textRows.forEach( (row, index) => {
textRows.forEach((row, index) => {
if (index % 2 === 0) {
row[0].x = 0;
row[1].x = row[0].width;
@ -82,7 +97,7 @@
row[1].y = (fontSize * 0.8 * index);
})
let textRowsright = gsap.to([textRows[0], textRows[2]], {
let textRowsright = gsap.to([textRows[0], textRows[2]], {
x: '+=' + -textRows[0][0].width,
duration: 10,
ease: 'none',
@ -90,7 +105,7 @@
overwrite: true
})
tweens.push(textRowsright);
let textRowsLeft = gsap.to([textRows[1], textRows[3]], {
x: '+=' + textRows[1][0].width,
duration: 10,
@ -136,14 +151,20 @@
mouse.y = e.clientY / window.innerHeight;
}, { passive: true })
// Optimize ticker by using requestAnimationFrame
let animationFrameId: number;
let elapsed = 0;
app.ticker.add((delta) => {
elapsed += delta;
function animate() {
elapsed += app.ticker.elapsedMS / 16.6667; // Convert to delta time
bulgefilter.center = new PIXI.Point(mouse.x, 0.5);
})
animationFrameId = requestAnimationFrame(animate);
}
animate();
let scrollTriggerTweens: Array<GSAPTween> = [];
function createScrollTrigger() {
const tween1 = gsap.to([textRows[0], textRows[1]], {
y: '+=' + -textRows[0][0].height * 0.9,
@ -173,23 +194,23 @@
}
return () => {
cancelAnimationFrame(animationFrameId);
// IMPORTANT: Kill ScrollTrigger and GSAP animations FIRST,
// before destroying PixiJS objects they reference
scrollTriggerTweens.forEach( tween => {
scrollTriggerTweens.forEach(tween => {
if (tween.scrollTrigger) {
tween.scrollTrigger.kill();
}
tween.kill();
});
// Kill all other tweens
tweens.forEach( tween => tween.kill() );
tweens.forEach(tween => tween.kill());
// Return objects to pools
allTexts.forEach(text => returnTextToPool(text));
// Now safe to destroy PixiJS app
app.destroy(true, { children: true, texture: true, baseTexture: true });
}
})
</script>
<canvas bind:this={canvas} id="service-canvas"></canvas>
@ -204,4 +225,5 @@
z-index: 2;
pointer-events: none;
}
</style>
</style>

View file

@ -129,18 +129,18 @@
overwrite: true,
})
}
// Hover states or .work
// Hover states of .work
works.forEach((work, index) => {
work.addEventListener('mouseenter', (e) => {
if (isZoomed) return;
gsap.to(work, {
backgroundColor: 'var(--color-bg)',
duration: .5,
duration: .125,
ease: 'power1.inOut',
})
gsap.to(work.querySelector('.work-logo'), {
color: 'var(--color-highlight)',
duration: .5,
duration: .125,
ease: 'power1.inOut',
})
})
@ -148,12 +148,12 @@
if (isZoomed) return;
gsap.to(work, {
backgroundColor: 'var(--color-highlight)',
duration: .5,
duration: .125,
ease: 'power1.inOut',
})
gsap.to(work.querySelector('.work-logo'), {
color: 'var(--color-bg)',
duration: .5,
duration: .125,
ease: 'power1.inOut',
})
})

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.