jedmund-svelte/src/routes/work/[slug]/+page.svelte

284 lines
6.7 KiB
Svelte

<script lang="ts">
import Page from '$components/Page.svelte'
import BackButton from '$components/BackButton.svelte'
import ProjectPasswordProtection from '$lib/components/ProjectPasswordProtection.svelte'
import ProjectHeaderContent from '$lib/components/ProjectHeaderContent.svelte'
import ProjectContent from '$lib/components/ProjectContent.svelte'
import { generateMetaTags, generateCreativeWorkJsonLd } from '$lib/utils/metadata'
import { page } from '$app/stores'
import type { PageData } from './$types'
import type { Project } from '$lib/types/project'
import { spring } from 'svelte/motion'
let { data } = $props<{ data: PageData }>()
const project = $derived(data.project as Project | null)
const error = $derived(data.error as string | undefined)
const pageUrl = $derived($page.url.href)
// Generate metadata
const metaTags = $derived(
project
? generateMetaTags({
title: project.title,
description:
project.description || `${project.title} — A professional project by Justin Edmund`,
url: pageUrl,
image: project.thumbnailUrl || project.logoUrl,
type: 'article',
titleFormat: { type: 'by' }
})
: generateMetaTags({
title: 'Project Not Found',
description: 'The project you are looking for could not be found.',
url: pageUrl,
noindex: true
})
)
// Generate creative work JSON-LD
const projectJsonLd = $derived(
project
? generateCreativeWorkJsonLd({
name: project.title,
description: project.description,
url: pageUrl,
image: project.thumbnailUrl || project.logoUrl,
creator: 'Justin Edmund',
dateCreated: project.year ? `${project.year}-01-01` : undefined,
keywords: project.tags || []
})
: null
)
let headerContainer = $state<HTMLElement | null>(null)
// Spring with aggressive bounce settings
const logoPosition = spring(
{ x: 0, y: 0 },
{
stiffness: 0.03, // Extremely low for maximum bounce
damping: 0.1, // Very low for many oscillations
precision: 0.001 // Keep animating for longer
}
)
// Derive transform from spring position
const logoTransform = $derived(`translate(${$logoPosition.x}px, ${$logoPosition.y}px)`)
function handleMouseMove(e: MouseEvent) {
if (!headerContainer) return
const rect = headerContainer.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const centerX = rect.width / 2
const centerY = rect.height / 2
// Calculate movement based on mouse position relative to center
const moveX = ((x - centerX) / centerX) * 30 // 30px max movement for more dramatic effect
const moveY = ((y - centerY) / centerY) * 30
logoPosition.set({ x: moveX, y: moveY })
}
function handleMouseLeave() {
logoPosition.set({ x: 0, y: 0 })
}
</script>
<svelte:head>
<title>{metaTags.title}</title>
<meta name="description" content={metaTags.description} />
<!-- OpenGraph -->
{#each Object.entries(metaTags.openGraph) as [property, content]}
<meta property="og:{property}" {content} />
{/each}
<!-- Twitter Card -->
{#each Object.entries(metaTags.twitter) as [property, content]}
<meta name="twitter:{property}" {content} />
{/each}
<!-- Other meta tags -->
{#if metaTags.other.canonical}
<link rel="canonical" href={metaTags.other.canonical} />
{/if}
{#if metaTags.other.robots}
<meta name="robots" content={metaTags.other.robots} />
{/if}
<!-- JSON-LD -->
{#if projectJsonLd}
{@html `<script type="application/ld+json">${JSON.stringify(projectJsonLd)}</script>`}
{/if}
</svelte:head>
{#if error}
<div class="error-container">
<Page>
<div slot="header" class="error-header">
<h1>Error</h1>
</div>
<div class="error-content">
<p>{error}</p>
<BackButton href="/" label="Back to projects" />
</div>
</Page>
</div>
{:else if !project}
<Page>
<div class="loading">Loading project...</div>
</Page>
{:else if project.status === 'list-only'}
<Page>
<div slot="header" class="error-header">
<h1>Project Not Available</h1>
</div>
<div class="error-content">
<p>This project is not yet available for viewing. Please check back later.</p>
<BackButton href="/" label="Back to projects" />
</div>
</Page>
{:else if project.status === 'password-protected' || project.status === 'published'}
{#snippet projectLayout()}
<div class="project-wrapper">
<div
bind:this={headerContainer}
class="project-header-container"
style="background-color: {project.backgroundColor || '#f5f5f5'}"
onmousemove={handleMouseMove}
onmouseleave={handleMouseLeave}
role="presentation"
aria-hidden="true"
>
{#if project.logoUrl}
<img
src={project.logoUrl}
alt="{project.title} logo"
class="project-logo"
style="transform: {logoTransform}"
/>
{/if}
</div>
<Page>
{#snippet header()}
<div class="project-header">
<ProjectHeaderContent {project} />
</div>
{/snippet}
{#if project.status === 'password-protected'}
<ProjectPasswordProtection
projectSlug={project.slug}
correctPassword={project.password || ''}
projectType="work"
>
{#snippet children()}
<ProjectContent {project} />
{/snippet}
</ProjectPasswordProtection>
{:else}
<ProjectContent {project} />
{/if}
</Page>
</div>
{/snippet}
{@render projectLayout()}
{/if}
<style lang="scss">
/* Error and Loading States */
.error-container {
width: 100%;
max-width: 700px;
margin: 0 auto;
box-sizing: border-box;
padding: 0 $unit-2x;
}
.error-header h1 {
color: $red-60;
font-size: 2rem;
margin: 0;
}
.error-content {
text-align: center;
p {
color: $grey-40;
margin-bottom: $unit-2x;
}
}
.loading {
text-align: center;
color: $grey-40;
padding: $unit-4x;
}
/* Project Wrapper */
.project-wrapper {
width: 100%;
max-width: 700px;
margin: 0 auto;
box-sizing: border-box;
@include breakpoint('phone') {
padding: 0 $unit-2x;
}
:global(.page) {
margin-top: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
}
/* Project Header Container */
.project-header-container {
width: 100%;
height: 300px;
display: flex;
align-items: center;
justify-content: center;
border-top-left-radius: $card-corner-radius;
border-top-right-radius: $card-corner-radius;
position: relative;
overflow: hidden;
@include breakpoint('phone') {
height: 250px;
}
@include breakpoint('small-phone') {
height: 200px;
}
}
/* Project Logo */
.project-logo {
width: 85px;
height: 85px;
object-fit: contain;
will-change: transform;
@include breakpoint('phone') {
width: 75px;
height: 75px;
}
@include breakpoint('small-phone') {
width: 65px;
height: 65px;
}
}
/* Project Header */
.project-header {
width: 100%;
}
</style>