Expose database projects on frontend

This commit is contained in:
Justin Edmund 2025-05-29 21:07:04 -07:00
parent 4fde0e6148
commit 867c23402f
4 changed files with 200 additions and 114 deletions

View file

@ -1,26 +1,89 @@
<script lang="ts"> <script lang="ts">
import SVGHoverEffect from '$components/SVGHoverEffect.svelte' import { onMount } from 'svelte'
import type { SvelteComponent } from 'svelte'
export let SVGComponent: typeof SvelteComponent interface Props {
export let backgroundColor: string logoUrl: string | null
export let name: string backgroundColor: string
export let description: string name: string
export let highlightColor: string description: string
export let index: number = 0 highlightColor: string
index?: number
}
$: isEven = index % 2 === 0 let { logoUrl, backgroundColor, name, description, highlightColor, index = 0 }: Props = $props()
const isEven = $derived(index % 2 === 0)
// Create highlighted description // Create highlighted description
$: highlightedDescription = description.replace( const highlightedDescription = $derived(
new RegExp(`(${name})`, 'gi'), description.replace(
`<span style="color: ${highlightColor};">$1</span>` new RegExp(`(${name})`, 'gi'),
`<span style="color: ${highlightColor};">$1</span>`
)
) )
// 3D tilt effect // 3D tilt effect
let cardElement: HTMLDivElement let cardElement: HTMLDivElement
let isHovering = false let logoElement: HTMLElement
let transform = '' let isHovering = $state(false)
let transform = $state('')
let svgContent = $state('')
// Logo bounce effect
let logoTransform = $state('')
let velocity = { x: 0, y: 0 }
let position = { x: 0, y: 0 }
let animationFrame: number
const maxMovement = 10
const bounceDamping = 0.2
const friction = 0.85
onMount(async () => {
// Load SVG content
if (logoUrl) {
try {
const response = await fetch(logoUrl)
if (response.ok) {
const text = await response.text()
const parser = new DOMParser()
const doc = parser.parseFromString(text, 'image/svg+xml')
const svgElement = doc.querySelector('svg')
if (svgElement) {
svgElement.removeAttribute('width')
svgElement.removeAttribute('height')
svgContent = svgElement.outerHTML
}
}
} catch (error) {
console.error('Failed to load SVG:', error)
}
}
return () => {
if (animationFrame) {
cancelAnimationFrame(animationFrame)
}
}
})
function updateLogoPosition() {
velocity.x *= friction
velocity.y *= friction
position.x += velocity.x
position.y += velocity.y
// Constrain position
position.x = Math.max(-maxMovement, Math.min(maxMovement, position.x))
position.y = Math.max(-maxMovement, Math.min(maxMovement, position.y))
logoTransform = `translate(${position.x}px, ${position.y}px)`
if (Math.abs(velocity.x) > 0.01 || Math.abs(velocity.y) > 0.01) {
animationFrame = requestAnimationFrame(updateLogoPosition)
}
}
function handleMouseMove(e: MouseEvent) { function handleMouseMove(e: MouseEvent) {
if (!cardElement || !isHovering) return if (!cardElement || !isHovering) return
@ -32,10 +95,31 @@
const centerX = rect.width / 2 const centerX = rect.width / 2
const centerY = rect.height / 2 const centerY = rect.height / 2
const rotateX = ((y - centerY) / centerY) * -4 // -4 to 4 degrees // 3D tilt for card
const rotateY = ((x - centerX) / centerX) * 4 // -4 to 4 degrees const rotateX = ((y - centerY) / centerY) * -4
const rotateY = ((x - centerX) / centerX) * 4
transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale3d(1.014, 1.014, 1.014)` transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale3d(1.014, 1.014, 1.014)`
// Logo movement
if (logoElement) {
const logoRect = logoElement.getBoundingClientRect()
const logoCenterX = logoRect.left + logoRect.width / 2 - rect.left
const logoCenterY = logoRect.top + logoRect.height / 2 - rect.top
const deltaX = x - logoCenterX
const deltaY = y - logoCenterY
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
if (distance < 100) {
const force = (100 - distance) / 100
velocity.x -= (deltaX / distance) * force * bounceDamping
velocity.y -= (deltaY / distance) * force * bounceDamping
if (!animationFrame) {
animationFrame = requestAnimationFrame(updateLogoPosition)
}
}
}
} }
function handleMouseEnter() { function handleMouseEnter() {
@ -45,6 +129,15 @@
function handleMouseLeave() { function handleMouseLeave() {
isHovering = false isHovering = false
transform = 'perspective(1000px) rotateX(0) rotateY(0) scale3d(1, 1, 1)' transform = 'perspective(1000px) rotateX(0) rotateY(0) scale3d(1, 1, 1)'
// Reset logo position
velocity = { x: 0, y: 0 }
position = { x: 0, y: 0 }
logoTransform = ''
if (animationFrame) {
cancelAnimationFrame(animationFrame)
animationFrame = 0
}
} }
</script> </script>
@ -56,14 +149,18 @@
on:mouseleave={handleMouseLeave} on:mouseleave={handleMouseLeave}
style="transform: {transform};" style="transform: {transform};"
> >
<div class="project-logo"> <div class="project-logo" style="background-color: {backgroundColor}">
<SVGHoverEffect {#if svgContent}
{SVGComponent} <div
{backgroundColor} bind:this={logoElement}
maxMovement={10} class="logo-svg"
containerHeight="80px" style="transform: {logoTransform}"
bounceDamping={0.2} >
/> {@html svgContent}
</div>
{:else if logoUrl}
<img src={logoUrl} alt="{name} logo" class="logo-image" />
{/if}
</div> </div>
<div class="project-content"> <div class="project-content">
<p class="project-description">{@html highlightedDescription}</p> <p class="project-description">{@html highlightedDescription}</p>
@ -101,19 +198,31 @@
flex-shrink: 0; flex-shrink: 0;
width: 80px; width: 80px;
height: 80px; height: 80px;
border-radius: $unit-2x;
display: flex;
align-items: center;
justify-content: center;
padding: $unit-2x;
box-sizing: border-box;
:global(.svg-container) { .logo-image {
width: 80px !important; max-width: 100%;
height: 80px !important; max-height: 100%;
border-radius: $unit-2x; object-fit: contain;
}
.logo-svg {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} width: 100%;
height: 100%;
transition: transform 0.15s ease-out;
:global(svg) { :global(svg) {
width: 48px !important; width: 48px;
height: 48px !important; height: 48px;
}
} }
} }
@ -139,16 +248,6 @@
.project-logo { .project-logo {
width: 60px; width: 60px;
height: 60px; height: 60px;
:global(.svg-container) {
width: 60px !important;
height: 60px !important;
}
:global(svg) {
width: 36px !important;
height: 36px !important;
}
} }
} }
</style> </style>

View file

@ -1,53 +1,12 @@
<script lang="ts"> <script lang="ts">
import ProjectItem from '$components/ProjectItem.svelte' import ProjectItem from '$components/ProjectItem.svelte'
import type { Project } from '$lib/types/project'
import MaitsuLogo from '$illos/logo-maitsu.svg?component' interface Props {
import SlackLogo from '$illos/logo-slack.svg?component' projects: Project[]
import FigmaLogo from '$illos/logo-figma.svg?component'
import PinterestLogo from '$illos/logo-pinterest.svg?component'
import SVGHoverEffect from '$components/SVGHoverEffect.svelte'
interface Project {
SVGComponent: typeof SvelteComponent
backgroundColor: string
name: string
description: string
highlightColor: string
} }
const projects: Project[] = [ let { projects = [] }: Props = $props()
{
SVGComponent: MaitsuLogo,
backgroundColor: '#FFF7EA',
name: 'Maitsu',
description: 'Maitsu is a hobby journal that helps people make something new every week.',
highlightColor: '#F77754'
},
{
SVGComponent: SlackLogo,
backgroundColor: '#4a154b',
name: 'Slack',
description:
'At Slack, I helped redefine strategy for Workflows and other features in under the automation umbrella.',
highlightColor: '#611F69'
},
{
SVGComponent: FigmaLogo,
backgroundColor: '#2c2c2c',
name: 'Figma',
description:
'At Figma, I designed features and led R&D and strategy for the nascent prototyping team.',
highlightColor: '#0ACF83'
},
{
SVGComponent: PinterestLogo,
backgroundColor: '#f7f7f7',
name: 'Pinterest',
description:
'At Pinterest, I was the first product design hired, and touched almost every part of the product.',
highlightColor: '#CB1F27'
}
]
</script> </script>
<section class="projects"> <section class="projects">
@ -64,9 +23,21 @@
</p> </p>
</div> </div>
</li> </li>
{#if projects.length === 0}
<li>
<div class="no-projects">No projects found</div>
</li>
{/if}
{#each projects as project, index} {#each projects as project, index}
<li> <li>
<ProjectItem {...project} {index} /> <ProjectItem
logoUrl={project.logoUrl}
backgroundColor={project.backgroundColor || '#f7f7f7'}
name={project.title}
description={project.description || ''}
highlightColor={project.highlightColor || '#333'}
{index}
/>
</li> </li>
{/each} {/each}
</ul> </ul>
@ -114,4 +85,11 @@
color: #d0290d; color: #d0290d;
} }
} }
.no-projects {
padding: $unit-3x;
text-align: center;
color: $grey-40;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
</style> </style>

View file

@ -1,5 +1,8 @@
<script lang="ts"> <script lang="ts">
import ProjectList from '$components/ProjectList.svelte' import ProjectList from '$components/ProjectList.svelte'
import type { PageData } from './$types'
let { data } = $props<{ data: PageData }>()
</script> </script>
<ProjectList /> <ProjectList projects={data?.projects || []} />

View file

@ -1,36 +1,34 @@
import type { PageLoad } from './$types' import type { PageLoad } from './$types'
import type { Album } from '$lib/types/lastfm' import type { Album } from '$lib/types/lastfm'
import type { Project } from '$lib/types/project'
export const load: PageLoad = async ({ fetch }) => { export const load: PageLoad = async ({ fetch }) => {
try { try {
// const [albums, steamGames, psnGames] = await Promise.all([ // Fetch albums first
const [albums] = await Promise.all([ let albums: Album[] = []
fetchRecentAlbums(fetch) try {
// fetchRecentSteamGames(fetch), albums = await fetchRecentAlbums(fetch)
// fetchRecentPSNGames(fetch) } catch (albumError) {
]) console.error('Error fetching albums:', albumError)
}
// const response = await fetch('/api/giantbomb', { // Fetch projects
// method: 'POST', let projectsData = { projects: [] as Project[], pagination: null }
// body: JSON.stringify({ games: psnGames }), try {
// headers: { projectsData = await fetchProjects(fetch)
// 'Content-Type': 'application/json' } catch (projectError) {
// } console.error('Error fetching projects:', projectError)
// }) }
// const games = await response.json()
return { return {
albums albums,
// games: games, projects: projectsData.projects || []
// steamGames: steamGames,
// psnGames: psnGames
} }
} catch (err) { } catch (err) {
console.error('Error fetching data:', err) console.error('Error in load function:', err)
return { return {
albums: [], albums: [],
games: [], projects: []
error: err instanceof Error ? err.message : 'An unknown error occurred'
} }
} }
} }
@ -57,3 +55,11 @@ async function fetchRecentPSNGames(fetch: typeof window.fetch): Promise<Serializ
} }
return await response.json() return await response.json()
} }
async function fetchProjects(fetch: typeof window.fetch): Promise<{ projects: Project[]; pagination: any }> {
const response = await fetch('/api/projects?status=published')
if (!response.ok) {
throw new Error(`Failed to fetch projects: ${response.status}`)
}
return await response.json()
}