diff --git a/src/app.html b/src/app.html index 34e21ce..02f852f 100644 --- a/src/app.html +++ b/src/app.html @@ -3,7 +3,10 @@ - + + + + %sveltekit.head% diff --git a/src/lib/styles/global.scss b/src/lib/styles/global.scss index 099871a..d6033fa 100644 --- a/src/lib/styles/global.scss +++ b/src/lib/styles/global.scss @@ -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; } diff --git a/src/lib/utils/createCanvasImg.ts b/src/lib/utils/createCanvasImg.ts index 8971dc0..9622005 100644 --- a/src/lib/utils/createCanvasImg.ts +++ b/src/lib/utils/createCanvasImg.ts @@ -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); diff --git a/src/lib/utils/createCanvasText.ts b/src/lib/utils/createCanvasText.ts index 81494e2..e297b82 100644 --- a/src/lib/utils/createCanvasText.ts +++ b/src/lib/utils/createCanvasText.ts @@ -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'; diff --git a/src/routes/contact/+page.server.ts b/src/routes/contact/+page.server.ts new file mode 100644 index 0000000..b0632c3 --- /dev/null +++ b/src/routes/contact/+page.server.ts @@ -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 = `

Name: ${name || 'N/A'}

Email: ${email}

Message:

${message.replace(/\n/g, '
')}

`; + + 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 }); + } + } +}; diff --git a/src/routes/contact/+page.svelte b/src/routes/contact/+page.svelte index a2afa6c..8f2dadf 100644 --- a/src/routes/contact/+page.svelte +++ b/src/routes/contact/+page.svelte @@ -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 = []; let contactFormVisible = false; let contactFormClickHandler = (e: Event) => { @@ -88,25 +90,27 @@
← Back -
- +

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.

+ {#if form?.error} + + {/if}
@@ -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; diff --git a/src/routes/contact/+page.ts b/src/routes/contact/+page.ts index ac85e0b..da456bc 100644 --- a/src/routes/contact/+page.ts +++ b/src/routes/contact/+page.ts @@ -1,4 +1,4 @@ -export const prerender = true +export const prerender = false export function load() { return { title: 'Contact me!', diff --git a/src/routes/service/ServiceCanvas.svelte b/src/routes/service/ServiceCanvas.svelte index c1ef0d6..57f3bf6 100644 --- a/src/routes/service/ServiceCanvas.svelte +++ b/src/routes/service/ServiceCanvas.svelte @@ -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 = []; - + 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 }); } }) - - - @@ -204,4 +225,5 @@ z-index: 2; pointer-events: none; } - \ No newline at end of file + + diff --git a/src/routes/work/+page.svelte b/src/routes/work/+page.svelte index 428dbde..0f28bfb 100644 --- a/src/routes/work/+page.svelte +++ b/src/routes/work/+page.svelte @@ -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', }) }) diff --git a/static/fonts/PPFormula-Extrabold.woff2 b/static/fonts/PPFormula-Extrabold.woff2 new file mode 100644 index 0000000..24f9c95 Binary files /dev/null and b/static/fonts/PPFormula-Extrabold.woff2 differ diff --git a/static/fonts/PPFormula-Medium.woff2 b/static/fonts/PPFormula-Medium.woff2 new file mode 100644 index 0000000..578022d Binary files /dev/null and b/static/fonts/PPFormula-Medium.woff2 differ diff --git a/static/fonts/PPFormula-MediumItalic.woff2 b/static/fonts/PPFormula-MediumItalic.woff2 new file mode 100644 index 0000000..014cbca Binary files /dev/null and b/static/fonts/PPFormula-MediumItalic.woff2 differ diff --git a/static/fonts/Stratos-BoldItalic.woff2 b/static/fonts/Stratos-BoldItalic.woff2 new file mode 100644 index 0000000..db022aa Binary files /dev/null and b/static/fonts/Stratos-BoldItalic.woff2 differ diff --git a/static/fonts/Stratos-Regular.woff2 b/static/fonts/Stratos-Regular.woff2 new file mode 100644 index 0000000..298b8f8 Binary files /dev/null and b/static/fonts/Stratos-Regular.woff2 differ