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 ## 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> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <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" /> <meta name="viewport" content="width=device-width" />
%sveltekit.head% %sveltekit.head%
</head> </head>

View file

@ -1,13 +1,9 @@
:root { :root {
--spacing-outer: 5vw; --spacing-outer: 5vw;
--spacing-nav: 5vw; --spacing-nav: 5vw;
// --color-bg: hsl(202, 58%, 39%); --color-bg: rgb(207, 63, 63);
--color-bg: hsl(0, 60%, 53%); --color-text: rgb(255, 234, 217);
--color-text: #ffead9; --color-highlight: rgb(29, 12, 18);
--color-highlight: #1d0c12;
// --color-bg: #000000;
// --color-text: #FFFFFF;
// --color-highlight: #FF4D00;
--aspect-ratio-heroes: 1.5; --aspect-ratio-heroes: 1.5;
--font-size-p: clamp(20px, 1.6vw, 1.6vw); --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 { a:hover, input:hover, button:hover {
cursor: url('/pointer.svg'), auto; cursor: url('/pointer.svg'), auto;
} }
@ -32,18 +42,6 @@ body {
max-width: 100vw; max-width: 100vw;
overflow-x: hidden; 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 * { body * {
box-sizing: border-box; box-sizing: border-box;
} }

View file

@ -1,6 +1,10 @@
import * as PIXI from 'pixi.js'; 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 elem = element;
const elemSrc = elem.currentSrc || elem.src; const elemSrc = elem.currentSrc || elem.src;
@ -11,9 +15,22 @@ export default function createCanvasText( element: HTMLImageElement, stage: PIX
if (elemSrc.includes('.svg')) { if (elemSrc.includes('.svg')) {
scalefactor = Number(elem.attributes.getNamedItem('data-svgscale')?.value); scalefactor = Number(elem.attributes.getNamedItem('data-svgscale')?.value);
const canvasImgTexture = PIXI.Texture.from(elemSrc, { resourceOptions: { scale: scalefactor } }); 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 { } 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); canvasImg.position.set(elemPosition.x, elemPosition.y);

View file

@ -1,7 +1,11 @@
import type * as PIXI from 'pixi.js'; import type * as PIXI from 'pixi.js';
import { Text } 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 elem = element;
const elemStyles = window.getComputedStyle(elem); const elemStyles = window.getComputedStyle(elem);
@ -19,7 +23,8 @@ export default function createCanvasText( element: HTMLElement, stage: PIXI.Con
elem.textContent = elem.textContent?.toUpperCase() as string; 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, fontFamily: elemFontFamily,
fontSize: elemFontSize, fontSize: elemFontSize,
fontWeight: elemFontWeight as PIXI.TextStyleFontWeight, fontWeight: elemFontWeight as PIXI.TextStyleFontWeight,
@ -30,6 +35,18 @@ export default function createCanvasText( element: HTMLElement, stage: PIXI.Con
padding: 20, 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', () => { canvasText.on('added', () => {
elem.classList.add('canvas-text-added'); elem.classList.add('canvas-text-added');
elem.style.visibility = 'hidden'; 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 // from filter packages that use the deprecated settings.FILTER_RESOLUTION
PIXI.Filter.defaultResolution = 2; 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 { SplitText } from 'gsap/SplitText';
import { ScrollTrigger } from 'gsap/ScrollTrigger'; import { ScrollTrigger } from 'gsap/ScrollTrigger';
export let form: { error?: string; fields?: { name?: string; email?: string; contact?: string } } | undefined;
let canvasTexts: Array<HTMLElement> = []; let canvasTexts: Array<HTMLElement> = [];
let contactFormVisible = false; let contactFormVisible = false;
let contactFormClickHandler = (e: Event) => { let contactFormClickHandler = (e: Event) => {
@ -88,25 +90,27 @@
</div> </div>
<div class="formwrapper"> <div class="formwrapper">
<span class="button button-back" on:click={contactFormClickHandler} on:keydown={contactFormClickHandler} role="button" tabindex="0">← Back</span> <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"> <form name="contact" method="POST">
<input type="hidden" name="form-name" value="contact">
<div class="inputs-flex-row"> <div class="inputs-flex-row">
<label for="name"> <label for="name">
<!-- <p>How would you like to be addressed?</p> --> <!-- <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>
<label for="email"> <label for="email">
<!-- <p>For receiving a reply, add your Email address:</p> --> <!-- <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> </label>
</div> </div>
<label for="contact"> <label for="contact">
<!-- <p>Please describe your plight in a few words</p> --> <!-- <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> </label>
<div class="disclaimer"> <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> <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> </div>
{#if form?.error}
<p class="form-error" role="alert" aria-live="polite">{form.error}</p>
{/if}
<div class="send"> <div class="send">
<button class="button button--xl button--primary" type="submit">Send it!</button> <button class="button button--xl button--primary" type="submit">Send it!</button>
</div> </div>
@ -266,6 +270,15 @@
margin: 1em auto; margin: 1em auto;
font-size: 1rem; 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 { .send {
max-width: var(--form-maxwidth); max-width: var(--form-maxwidth);
margin: auto; margin: auto;

View file

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

View file

@ -9,8 +9,10 @@
let canvas: HTMLCanvasElement; let canvas: HTMLCanvasElement;
let introDone = false; 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 highLightColor = window.getComputedStyle(document.body).getPropertyValue('--color-highlight');
let is_fine = window.matchMedia('(pointer:fine)').matches let is_fine = window.matchMedia('(pointer:fine)').matches
@ -31,7 +33,7 @@
let textgroup = new PIXI.Container(); let textgroup = new PIXI.Container();
textgroup.pivot.set(0, 0); textgroup.pivot.set(0, 0);
textgroup.x = 0; textgroup.x = 0;
textgroup.y = - window.innerHeight; textgroup.y = -window.innerHeight;
textgroup.height = window.innerHeight; textgroup.height = window.innerHeight;
textgroup.width = window.innerWidth; textgroup.width = window.innerWidth;
app.stage.addChild(textgroup); app.stage.addChild(textgroup);
@ -40,23 +42,36 @@
let fontSize = window.innerHeight / 3; let fontSize = window.innerHeight / 3;
function createText(string: string){ function createText(string: string): PIXI.Text {
let text = new PIXI.Text(string, let text = getTextFromPool();
{ fontFamily: 'Stratos', text.text = string;
fontSize: fontSize, text.style = {
fontWeight: '800', fontFamily: 'Stratos',
fontStyle: 'italic', fontSize: fontSize,
lineHeight: 0, fontWeight: '800',
letterSpacing: -10, fontStyle: 'italic',
fill: highLightColor, lineHeight: 0,
padding: 0 letterSpacing: -10,
} fill: highLightColor,
); padding: 0
};
text.anchor.set(0); text.anchor.set(0);
textgroup.addChild(text); textgroup.addChild(text);
return 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 = [ let allTexts = [
createText('SERVICES SERVICES '), createText('SERVICES SERVICES '), createText('SERVICES SERVICES '), createText('SERVICES SERVICES '),
createText('CONSULTATION DESIGN WEB DEVELOPMENT '), createText('CONSULTATION DESIGN WEB DEVELOPMENT '), createText('CONSULTATION DESIGN WEB DEVELOPMENT '), createText('CONSULTATION DESIGN WEB DEVELOPMENT '),
@ -70,7 +85,7 @@
[allTexts[4], allTexts[5]], [allTexts[4], allTexts[5]],
[allTexts[6], allTexts[7]] [allTexts[6], allTexts[7]]
] ]
textRows.forEach( (row, index) => { textRows.forEach((row, index) => {
if (index % 2 === 0) { if (index % 2 === 0) {
row[0].x = 0; row[0].x = 0;
row[1].x = row[0].width; row[1].x = row[0].width;
@ -82,7 +97,7 @@
row[1].y = (fontSize * 0.8 * index); 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, x: '+=' + -textRows[0][0].width,
duration: 10, duration: 10,
ease: 'none', ease: 'none',
@ -136,11 +151,17 @@
mouse.y = e.clientY / window.innerHeight; mouse.y = e.clientY / window.innerHeight;
}, { passive: true }) }, { passive: true })
// Optimize ticker by using requestAnimationFrame
let animationFrameId: number;
let elapsed = 0; 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); bulgefilter.center = new PIXI.Point(mouse.x, 0.5);
}) animationFrameId = requestAnimationFrame(animate);
}
animate();
let scrollTriggerTweens: Array<GSAPTween> = []; let scrollTriggerTweens: Array<GSAPTween> = [];
@ -173,23 +194,23 @@
} }
return () => { return () => {
cancelAnimationFrame(animationFrameId);
// IMPORTANT: Kill ScrollTrigger and GSAP animations FIRST, // IMPORTANT: Kill ScrollTrigger and GSAP animations FIRST,
// before destroying PixiJS objects they reference // before destroying PixiJS objects they reference
scrollTriggerTweens.forEach( tween => { scrollTriggerTweens.forEach(tween => {
if (tween.scrollTrigger) { if (tween.scrollTrigger) {
tween.scrollTrigger.kill(); tween.scrollTrigger.kill();
} }
tween.kill(); tween.kill();
}); });
// Kill all other tweens // 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 // Now safe to destroy PixiJS app
app.destroy(true, { children: true, texture: true, baseTexture: true }); app.destroy(true, { children: true, texture: true, baseTexture: true });
} }
}) })
</script> </script>
<canvas bind:this={canvas} id="service-canvas"></canvas> <canvas bind:this={canvas} id="service-canvas"></canvas>
@ -205,3 +226,4 @@
pointer-events: none; pointer-events: none;
} }
</style> </style>

View file

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.