implements brevo form, webfonts sh

This commit is contained in:
saiminh 2026-01-16 17:43:23 +13:00
parent bad6faf555
commit 8e2a5ed8bf
14 changed files with 220 additions and 71 deletions

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,6 +1,10 @@
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;
@ -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,7 +1,11 @@
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);
@ -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

@ -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>
<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

@ -9,8 +9,10 @@
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
@ -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,23 +42,36 @@
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 '),
@ -70,7 +85,7 @@
[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',
@ -136,11 +151,17 @@
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> = [];
@ -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>
@ -205,3 +226,4 @@
pointer-events: none;
}
</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.