332 lines
8.2 KiB
Svelte
332 lines
8.2 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte'
|
|
import { goto } from '$app/navigation'
|
|
|
|
interface Props {
|
|
logoUrl: string | null
|
|
backgroundColor: string
|
|
name: string
|
|
slug: string
|
|
description: string
|
|
highlightColor: string
|
|
status?: 'draft' | 'published' | 'list-only' | 'password-protected'
|
|
index?: number
|
|
}
|
|
|
|
let {
|
|
logoUrl,
|
|
backgroundColor,
|
|
name,
|
|
slug,
|
|
description,
|
|
highlightColor,
|
|
status = 'published',
|
|
index = 0
|
|
}: Props = $props()
|
|
|
|
const isEven = $derived(index % 2 === 0)
|
|
const isClickable = $derived(status === 'published' || status === 'password-protected')
|
|
const isListOnly = $derived(status === 'list-only')
|
|
const isPasswordProtected = $derived(status === 'password-protected')
|
|
|
|
// Create highlighted description
|
|
const highlightedDescription = $derived(
|
|
description.replace(
|
|
new RegExp(`(${name})`, 'gi'),
|
|
`<span style="color: ${highlightColor};">$1</span>`
|
|
)
|
|
)
|
|
|
|
// 3D tilt effect
|
|
let cardElement: HTMLDivElement
|
|
let logoElement: HTMLElement
|
|
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) {
|
|
if (!cardElement || !isHovering) return
|
|
|
|
const rect = cardElement.getBoundingClientRect()
|
|
const x = e.clientX - rect.left
|
|
const y = e.clientY - rect.top
|
|
|
|
const centerX = rect.width / 2
|
|
const centerY = rect.height / 2
|
|
|
|
// 3D tilt for card
|
|
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)`
|
|
|
|
// 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() {
|
|
isHovering = true
|
|
}
|
|
|
|
function handleMouseLeave() {
|
|
isHovering = false
|
|
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
|
|
}
|
|
}
|
|
|
|
function handleClick() {
|
|
if (isClickable) {
|
|
goto(`/work/${slug}`)
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div
|
|
class="project-item {isEven ? 'even' : 'odd'}"
|
|
class:clickable={isClickable}
|
|
class:list-only={isListOnly}
|
|
class:password-protected={isPasswordProtected}
|
|
bind:this={cardElement}
|
|
onclick={handleClick}
|
|
onkeydown={(e) => e.key === 'Enter' && handleClick()}
|
|
onmousemove={isClickable ? handleMouseMove : undefined}
|
|
onmouseenter={isClickable ? handleMouseEnter : undefined}
|
|
onmouseleave={isClickable ? handleMouseLeave : undefined}
|
|
style="transform: {transform};"
|
|
role={isClickable ? 'button' : 'article'}
|
|
tabindex={isClickable ? 0 : -1}
|
|
>
|
|
<div class="project-logo" style="background-color: {backgroundColor}">
|
|
{#if svgContent}
|
|
<div bind:this={logoElement} class="logo-svg" style="transform: {logoTransform}">
|
|
{@html svgContent}
|
|
</div>
|
|
{:else if logoUrl}
|
|
<img src={logoUrl} alt="{name} logo" class="logo-image" />
|
|
{/if}
|
|
</div>
|
|
<div class="project-content">
|
|
<p class="project-description">{@html highlightedDescription}</p>
|
|
|
|
{#if isListOnly}
|
|
<div class="status-indicator list-only">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2"/>
|
|
<path d="M20.188 10.934c.388.472.612 1.057.612 1.686 0 .63-.224 1.214-.612 1.686a11.79 11.79 0 01-1.897 1.853c-1.481 1.163-3.346 2.24-5.291 2.24-1.945 0-3.81-1.077-5.291-2.24A11.79 11.79 0 016.812 14.32C6.224 13.648 6 13.264 6 12.62c0-.63.224-1.214.612-1.686A11.79 11.79 0 018.709 9.08c1.481-1.163 3.346-2.24 5.291-2.24 1.945 0 3.81 1.077 5.291 2.24a11.79 11.79 0 011.897 1.853z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
<path d="M2 2l20 20" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
|
</svg>
|
|
<span>Coming Soon</span>
|
|
</div>
|
|
{:else if isPasswordProtected}
|
|
<div class="status-indicator password-protected">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" stroke="currentColor" stroke-width="2"/>
|
|
<circle cx="12" cy="16" r="1" fill="currentColor"/>
|
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
</svg>
|
|
<span>Password Required</span>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<style lang="scss">
|
|
.project-item {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: $unit-3x;
|
|
padding: $unit-3x;
|
|
background: $grey-100;
|
|
border-radius: $card-corner-radius;
|
|
transition:
|
|
transform 0.15s ease-out,
|
|
box-shadow 0.15s ease-out,
|
|
opacity 0.15s ease-out;
|
|
transform-style: preserve-3d;
|
|
will-change: transform;
|
|
cursor: default;
|
|
|
|
&.clickable {
|
|
cursor: pointer;
|
|
|
|
&:hover {
|
|
box-shadow:
|
|
0 10px 30px rgba(0, 0, 0, 0.1),
|
|
0 1px 8px rgba(0, 0, 0, 0.06);
|
|
}
|
|
}
|
|
|
|
&.list-only {
|
|
opacity: 0.7;
|
|
background: $grey-97;
|
|
}
|
|
|
|
&.password-protected {
|
|
// Keep full interactivity for password-protected items
|
|
}
|
|
|
|
&.odd {
|
|
flex-direction: row-reverse;
|
|
}
|
|
}
|
|
|
|
.project-logo {
|
|
flex-shrink: 0;
|
|
width: 80px;
|
|
height: 80px;
|
|
border-radius: $unit-2x;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: $unit-2x;
|
|
box-sizing: border-box;
|
|
|
|
.logo-image {
|
|
max-width: 100%;
|
|
max-height: 100%;
|
|
object-fit: contain;
|
|
}
|
|
|
|
.logo-svg {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 100%;
|
|
height: 100%;
|
|
transition: transform 0.15s ease-out;
|
|
|
|
:global(svg) {
|
|
width: 48px;
|
|
height: 48px;
|
|
}
|
|
}
|
|
}
|
|
|
|
.project-content {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.project-description {
|
|
margin: 0;
|
|
font-size: 1.125rem; // 18px
|
|
line-height: 1.3;
|
|
color: $grey-00;
|
|
}
|
|
|
|
.status-indicator {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: $unit-half;
|
|
margin-top: $unit;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
|
|
&.list-only {
|
|
color: $grey-60;
|
|
}
|
|
|
|
&.password-protected {
|
|
color: $blue-50;
|
|
}
|
|
|
|
svg {
|
|
flex-shrink: 0;
|
|
}
|
|
}
|
|
|
|
@include breakpoint('phone') {
|
|
.project-item {
|
|
flex-direction: column !important;
|
|
gap: $unit-2x;
|
|
padding: $unit-2x;
|
|
}
|
|
|
|
.project-logo {
|
|
width: 60px;
|
|
height: 60px;
|
|
}
|
|
}
|
|
</style>
|