Frontend/Backend connection on projects
This commit is contained in:
parent
867c23402f
commit
baa030ac1c
4 changed files with 436 additions and 4 deletions
|
|
@ -1,16 +1,18 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
logoUrl: string | null
|
logoUrl: string | null
|
||||||
backgroundColor: string
|
backgroundColor: string
|
||||||
name: string
|
name: string
|
||||||
|
slug: string
|
||||||
description: string
|
description: string
|
||||||
highlightColor: string
|
highlightColor: string
|
||||||
index?: number
|
index?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
let { logoUrl, backgroundColor, name, description, highlightColor, index = 0 }: Props = $props()
|
let { logoUrl, backgroundColor, name, slug, description, highlightColor, index = 0 }: Props = $props()
|
||||||
|
|
||||||
const isEven = $derived(index % 2 === 0)
|
const isEven = $derived(index % 2 === 0)
|
||||||
|
|
||||||
|
|
@ -139,15 +141,23 @@
|
||||||
animationFrame = 0
|
animationFrame = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
goto(`/work/${slug}`)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="project-item {isEven ? 'even' : 'odd'}"
|
class="project-item {isEven ? 'even' : 'odd'}"
|
||||||
bind:this={cardElement}
|
bind:this={cardElement}
|
||||||
on:mousemove={handleMouseMove}
|
onclick={handleClick}
|
||||||
on:mouseenter={handleMouseEnter}
|
onkeydown={(e) => e.key === 'Enter' && handleClick()}
|
||||||
on:mouseleave={handleMouseLeave}
|
onmousemove={handleMouseMove}
|
||||||
|
onmouseenter={handleMouseEnter}
|
||||||
|
onmouseleave={handleMouseLeave}
|
||||||
style="transform: {transform};"
|
style="transform: {transform};"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<div class="project-logo" style="background-color: {backgroundColor}">
|
<div class="project-logo" style="background-color: {backgroundColor}">
|
||||||
{#if svgContent}
|
{#if svgContent}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@
|
||||||
logoUrl={project.logoUrl}
|
logoUrl={project.logoUrl}
|
||||||
backgroundColor={project.backgroundColor || '#f7f7f7'}
|
backgroundColor={project.backgroundColor || '#f7f7f7'}
|
||||||
name={project.title}
|
name={project.title}
|
||||||
|
slug={project.slug}
|
||||||
description={project.description || ''}
|
description={project.description || ''}
|
||||||
highlightColor={project.highlightColor || '#333'}
|
highlightColor={project.highlightColor || '#333'}
|
||||||
{index}
|
{index}
|
||||||
|
|
|
||||||
392
src/routes/work/[slug]/+page.svelte
Normal file
392
src/routes/work/[slug]/+page.svelte
Normal file
|
|
@ -0,0 +1,392 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Page from '$components/Page.svelte'
|
||||||
|
import type { PageData } from './$types'
|
||||||
|
import type { Project } from '$lib/types/project'
|
||||||
|
|
||||||
|
let { data } = $props<{ data: PageData }>()
|
||||||
|
|
||||||
|
const project = $derived(data.project as Project | null)
|
||||||
|
const error = $derived(data.error as string | undefined)
|
||||||
|
|
||||||
|
// Temporary function to render BlockNote content as HTML
|
||||||
|
// This is a basic implementation - you might want to use a proper BlockNote renderer
|
||||||
|
function renderBlockNoteContent(content: any): string {
|
||||||
|
if (!content || !content.content) return ''
|
||||||
|
|
||||||
|
return content.content.map((block: any) => {
|
||||||
|
switch (block.type) {
|
||||||
|
case 'heading':
|
||||||
|
const level = block.attrs?.level || 1
|
||||||
|
const text = block.content?.[0]?.text || ''
|
||||||
|
return `<h${level}>${text}</h${level}>`
|
||||||
|
|
||||||
|
case 'paragraph':
|
||||||
|
if (!block.content || block.content.length === 0) return '<p><br></p>'
|
||||||
|
const paragraphText = block.content.map((c: any) => c.text || '').join('')
|
||||||
|
return `<p>${paragraphText}</p>`
|
||||||
|
|
||||||
|
case 'image':
|
||||||
|
return `<figure><img src="${block.attrs?.src}" alt="${block.attrs?.alt || ''}" style="width: ${block.attrs?.width || '100%'}; height: ${block.attrs?.height || 'auto'};" /></figure>`
|
||||||
|
|
||||||
|
case 'bulletedList':
|
||||||
|
case 'numberedList':
|
||||||
|
const tag = block.type === 'bulletedList' ? 'ul' : 'ol'
|
||||||
|
const items = block.content?.map((item: any) => {
|
||||||
|
const itemText = item.content?.[0]?.content?.[0]?.text || ''
|
||||||
|
return `<li>${itemText}</li>`
|
||||||
|
}).join('') || ''
|
||||||
|
return `<${tag}>${items}</${tag}>`
|
||||||
|
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}).join('')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<Page>
|
||||||
|
<div slot="header" class="error-header">
|
||||||
|
<h1>Error</h1>
|
||||||
|
</div>
|
||||||
|
<div class="error-content">
|
||||||
|
<p>{error}</p>
|
||||||
|
<a href="/" class="back-link">← Back to home</a>
|
||||||
|
</div>
|
||||||
|
</Page>
|
||||||
|
{:else if !project}
|
||||||
|
<Page>
|
||||||
|
<div class="loading">Loading project...</div>
|
||||||
|
</Page>
|
||||||
|
{:else}
|
||||||
|
<Page>
|
||||||
|
<div slot="header" class="project-header">
|
||||||
|
{#if project.logoUrl}
|
||||||
|
<div class="project-logo" style="background-color: {project.backgroundColor || '#f5f5f5'}">
|
||||||
|
<img src={project.logoUrl} alt="{project.title} logo" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<h1 class="project-title">{project.title}</h1>
|
||||||
|
{#if project.subtitle}
|
||||||
|
<p class="project-subtitle">{project.subtitle}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article class="project-content">
|
||||||
|
<!-- Project Details -->
|
||||||
|
<div class="project-details">
|
||||||
|
<div class="meta-grid">
|
||||||
|
{#if project.client}
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-label">Client</span>
|
||||||
|
<span class="meta-value">{project.client}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if project.year}
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-label">Year</span>
|
||||||
|
<span class="meta-value">{project.year}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if project.role}
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-label">Role</span>
|
||||||
|
<span class="meta-value">{project.role}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if project.technologies && project.technologies.length > 0}
|
||||||
|
<div class="technologies">
|
||||||
|
{#each project.technologies as tech}
|
||||||
|
<span class="tech-tag">{tech}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if project.externalUrl}
|
||||||
|
<div class="external-link-wrapper">
|
||||||
|
<a href={project.externalUrl} target="_blank" rel="noopener noreferrer" class="external-link">
|
||||||
|
Visit Project →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Case Study Content -->
|
||||||
|
{#if project.caseStudyContent && project.caseStudyContent.content && project.caseStudyContent.content.length > 0}
|
||||||
|
<div class="case-study-section">
|
||||||
|
<div class="case-study-content">
|
||||||
|
{@html renderBlockNoteContent(project.caseStudyContent)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Gallery (if available) -->
|
||||||
|
{#if project.gallery && project.gallery.length > 0}
|
||||||
|
<div class="gallery-section">
|
||||||
|
<h2>Gallery</h2>
|
||||||
|
<div class="gallery-grid">
|
||||||
|
{#each project.gallery as image}
|
||||||
|
<img src={image} alt="Project gallery image" />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="project-nav">
|
||||||
|
<a href="/" class="back-link">← Back to projects</a>
|
||||||
|
</nav>
|
||||||
|
</article>
|
||||||
|
</Page>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
/* Error and Loading States */
|
||||||
|
.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 Header */
|
||||||
|
.project-header {
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-logo {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
margin: 0 auto $unit-2x;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: $unit-2x;
|
||||||
|
padding: $unit-2x;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 $unit;
|
||||||
|
color: $grey-10;
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-subtitle {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: $grey-40;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Project Content */
|
||||||
|
.project-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-4x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-3x;
|
||||||
|
padding-bottom: $unit-3x;
|
||||||
|
border-bottom: 1px solid $grey-90;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: $unit-2x;
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: $grey-60;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: $grey-20;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.technologies {
|
||||||
|
display: flex;
|
||||||
|
gap: $unit;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.tech-tag {
|
||||||
|
padding: $unit $unit-2x;
|
||||||
|
background: $grey-95;
|
||||||
|
border-radius: 50px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: $grey-30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-link-wrapper {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-link {
|
||||||
|
display: inline-block;
|
||||||
|
padding: $unit-2x $unit-3x;
|
||||||
|
background: $grey-10;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 50px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.925rem;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $grey-20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Case Study Section */
|
||||||
|
.case-study-section {
|
||||||
|
// No extra styling needed, content flows naturally
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-study-content {
|
||||||
|
:global(h1),
|
||||||
|
:global(h2),
|
||||||
|
:global(h3) {
|
||||||
|
margin: $unit-3x 0 $unit-2x;
|
||||||
|
color: $grey-10;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(h1) {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(h2) {
|
||||||
|
font-size: 1.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(h3) {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(p) {
|
||||||
|
margin: $unit-2x 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: $grey-20;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(figure) {
|
||||||
|
margin: $unit-3x 0;
|
||||||
|
|
||||||
|
:global(img) {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: $unit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(ul),
|
||||||
|
:global(ol) {
|
||||||
|
margin: $unit-2x 0;
|
||||||
|
padding-left: $unit-3x;
|
||||||
|
|
||||||
|
:global(li) {
|
||||||
|
margin: $unit 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: $grey-20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gallery Section */
|
||||||
|
.gallery-section {
|
||||||
|
padding-top: $unit-3x;
|
||||||
|
border-top: 1px solid $grey-90;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
margin: 0 0 $unit-3x;
|
||||||
|
color: $grey-10;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: $unit-2x;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: $unit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation */
|
||||||
|
.project-nav {
|
||||||
|
text-align: center;
|
||||||
|
padding-top: $unit-3x;
|
||||||
|
border-top: 1px solid $grey-90;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
color: $grey-40;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.925rem;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $grey-20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
29
src/routes/work/[slug]/+page.ts
Normal file
29
src/routes/work/[slug]/+page.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { PageLoad } from './$types'
|
||||||
|
import type { Project } from '$lib/types/project'
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ params, fetch }) => {
|
||||||
|
try {
|
||||||
|
// Find project by slug
|
||||||
|
const response = await fetch(`/api/projects?status=published`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch projects')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
const project = data.projects.find((p: Project) => p.slug === params.slug)
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new Error('Project not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
project
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading project:', error)
|
||||||
|
return {
|
||||||
|
project: null,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to load project'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue