Merge pull request #3 from jedmund/refine/polish

Refine Geo with mobile and admin fixes
This commit is contained in:
Justin Edmund 2025-06-10 22:24:35 -07:00 committed by GitHub
commit e270eddb84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 1704 additions and 692 deletions

View file

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<meta property="og:title" content="@jedmund" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://jedmund.com" />

View file

@ -9,6 +9,20 @@ body {
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
font-weight: 400;
line-height: 1.4;
overflow-x: hidden;
width: 100%;
}
// Prevent horizontal scroll
html {
overflow-x: hidden;
width: 100%;
font-size: 18px;
// Set rem to 16px on mobile devices
@include breakpoint('phone') {
font-size: 16px;
}
}
// Heading font weights

View file

@ -24,11 +24,22 @@ $unit-16x: $unit * 16;
$unit-18x: $unit * 18;
$unit-20x: $unit * 20;
/* Corner Radius
* -------------------------------------------------------------------------- */
$corner-radius-xs: 4px; // $unit-half
$corner-radius-sm: 6px; // $unit-three-fourth
$corner-radius-md: 8px; // $unit
$corner-radius-lg: 10px; // $unit + 2px
$corner-radius-xl: 12px; // $unit * 1.5
$corner-radius-2xl: 16px; // $unit-2x
$corner-radius-3xl: 24px; // $unit-3x
$corner-radius-full: 999px; // Full rounded
/* Page properties
* -------------------------------------------------------------------------- */
$page-corner-radius: $unit;
$image-corner-radius: $unit-2x;
$card-corner-radius: $unit-3x;
$page-corner-radius: $corner-radius-md;
$image-corner-radius: $corner-radius-2xl;
$card-corner-radius: $corner-radius-3xl;
$page-top-margin: $unit-6x;
@ -109,6 +120,7 @@ $salmon-pink: #ffd5cf; // Desaturated salmon pink for hover states
$bg-color: #e8e8e8;
$page-color: #ffffff;
$card-color: #f7f7f7;
$card-color-hover: #f0f0f0;
$text-color-light: #b2b2b2;
$text-color-body: #666666;
@ -149,6 +161,14 @@ $twitter-text-color: #0f5f9b;
$corner-radius: $unit-2x;
$mobile-corner-radius: $unit-2x;
/* Inputs
* -------------------------------------------------------------------------- */
$input-background-color: #f7f7f7;
$input-background-color-hover: #f0f0f0;
$input-text-color: #666666;
$input-text-color-hover: #4d4d4d;
/* Avatar header
* -------------------------------------------------------------------------- */
$avatar-radius: 2rem;

View file

@ -0,0 +1,75 @@
<script lang="ts">
import { goto } from '$app/navigation'
import ArrowLeft from '$icons/arrow-left.svg'
interface Props {
href?: string
label: string
onclick?: () => void
class?: string
}
let { href, label, onclick, class: className = '' }: Props = $props()
function handleClick() {
if (onclick) {
onclick()
} else if (href) {
goto(href)
} else {
history.back()
}
}
</script>
<button class="back-button {className}" onclick={handleClick} type="button">
<ArrowLeft class="arrow-icon" />
<span>{label}</span>
</button>
<style lang="scss">
@import '$styles/variables.scss';
@import '$styles/mixins.scss';
.back-button {
display: inline-flex;
align-items: center;
gap: $unit-half;
padding: $unit $unit-2x;
font-size: 0.875rem;
font-weight: 500;
color: $red-60;
background: none;
border: none;
border-radius: $corner-radius-md;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba($red-60, 0.08);
:global(.arrow-icon) {
transform: translateX(-3px);
}
}
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgba($red-60, 0.2);
}
:global(.arrow-icon) {
width: 16px;
height: 16px;
transition: transform 0.2s ease;
}
:global(svg) {
stroke: $red-60;
fill: none;
stroke-width: 2px;
stroke-linecap: round;
stroke-linejoin: round;
}
}
</style>

View file

@ -1,10 +1,9 @@
<script lang="ts">
import LinkCard from './LinkCard.svelte'
import Slideshow from './Slideshow.svelte'
import BackButton from './BackButton.svelte'
import { formatDate } from '$lib/utils/date'
import { renderEdraContent } from '$lib/utils/content'
import { goto } from '$app/navigation'
import ArrowLeft from '$icons/arrow-left.svg'
let { post }: { post: any } = $props()
@ -82,10 +81,7 @@
{/if}
<footer class="post-footer">
<button onclick={() => goto('/universe')} class="back-button">
<ArrowLeft class="back-arrow" />
Back to Universe
</button>
<BackButton href="/universe" label="Back to Universe" />
</footer>
</article>
@ -96,23 +92,23 @@
max-width: 784px;
gap: $unit-3x;
margin: 0 auto;
padding: 0 $unit-3x;
@include breakpoint('phone') {
padding: 0 $unit-2x;
gap: $unit-2x;
padding: $unit-half 0;
}
// Post type styles
&.post {
.post-body {
font-size: 1.125rem;
font-size: 1rem;
}
}
&.essay {
.post-body {
font-size: 1.125rem;
line-height: 1.7;
font-size: 1rem;
line-height: 1.4;
}
}
}
@ -169,7 +165,7 @@
margin-bottom: $unit-4x;
h3 {
font-size: 1.125rem;
font-size: 1rem;
font-weight: 600;
margin: 0 0 $unit-2x;
color: $grey-20;
@ -185,7 +181,7 @@
.album-description {
margin: 0;
font-size: 1.125rem;
font-size: 1rem;
color: $grey-10;
line-height: 1.5;
}
@ -218,7 +214,7 @@
:global(h4) {
margin: $unit-3x 0 $unit-2x;
font-size: 1.125rem;
font-size: 1rem;
font-weight: 600;
color: $grey-10;
}
@ -318,48 +314,4 @@
}
}
}
.post-footer {
padding-bottom: $unit-2x;
}
.back-button {
color: $red-60;
background-color: transparent;
border: 1px solid transparent;
font: inherit;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
gap: $unit;
border-radius: 24px;
outline: none;
&:hover:not(:disabled) :global(.back-arrow) {
transform: translateX(-3px);
}
&:focus-visible {
box-shadow: 0 0 0 3px rgba($red-60, 0.25);
}
:global(.back-arrow) {
width: 16px;
height: 16px;
flex-shrink: 0;
transition: transform 0.2s ease;
margin-left: -$unit-half;
:global(path) {
stroke: currentColor;
stroke-width: 2.25;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
}
}
}
</style>

View file

@ -1,6 +1,7 @@
<script lang="ts">
import Avatar from './Avatar.svelte'
import SegmentedController from './SegmentedController.svelte'
import NavDropdown from './NavDropdown.svelte'
let scrollY = $state(0)
// Smooth gradient opacity from 0 to 1 over the first 100px of scroll
@ -39,7 +40,12 @@
<a href="/about" class="header-link" aria-label="@jedmund">
<Avatar />
</a>
<SegmentedController />
<div class="nav-desktop">
<SegmentedController />
</div>
<div class="nav-mobile">
<NavDropdown />
</div>
</div>
</header>
@ -51,11 +57,15 @@
display: flex;
justify-content: center;
// Smooth padding transition based on scroll
padding: calc($unit-5x - ($unit-5x - $unit-2x) * var(--padding-progress)) 0;
padding: calc($unit-5x - ($unit-5x - $unit-2x) * var(--padding-progress)) $unit-2x;
pointer-events: none;
// Add a very subtle transition to smooth out any remaining jitter
transition: padding 0.1s ease-out;
@include breakpoint('phone') {
padding: calc($unit-3x - ($unit-3x - $unit-2x) * var(--padding-progress)) $unit-2x;
}
&::before {
content: '';
position: absolute;
@ -63,11 +73,7 @@
left: 0;
right: 0;
height: 120px;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.15),
transparent
);
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.15), transparent);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
mask-image: linear-gradient(to bottom, black 0%, black 15%, transparent 90%);
@ -83,8 +89,14 @@
.header-content {
display: flex;
align-items: center;
justify-content: center;
gap: $unit-3x;
pointer-events: auto;
width: 100%;
@include breakpoint('phone') {
gap: $unit-2x;
}
}
.header-link {
@ -110,4 +122,20 @@
}
}
}
.nav-desktop {
display: block;
@include breakpoint('phone') {
display: none;
}
}
.nav-mobile {
display: none;
@include breakpoint('phone') {
display: block;
}
}
</style>

View file

@ -40,13 +40,16 @@
</script>
{#if isClickable}
<a
href={projectUrl}
<div
class="lab-card clickable"
bind:this={cardElement}
onmousemove={handleMouseMove}
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
on:mousemove={handleMouseMove}
on:mouseenter={handleMouseEnter}
on:mouseleave={handleMouseLeave}
on:click={() => (window.location.href = projectUrl)}
on:keydown={(e) => e.key === 'Enter' && (window.location.href = projectUrl)}
role="button"
tabindex="0"
style:transform
>
<div class="card-header">
@ -62,9 +65,9 @@
target="_blank"
rel="noopener noreferrer"
iconPosition="right"
onclick={(e) => e.stopPropagation()}
on:click={(e) => e.stopPropagation()}
>
Visit site
Visit
<svg
slot="icon"
width="16"
@ -105,14 +108,14 @@
<span>Password Protected</span>
</div>
{/if}
</a>
</div>
{:else}
<article
class="lab-card"
bind:this={cardElement}
onmousemove={handleMouseMove}
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
on:mousemove={handleMouseMove}
on:mouseenter={handleMouseEnter}
on:mouseleave={handleMouseLeave}
style:transform
>
<div class="card-header">
@ -187,14 +190,12 @@
transform-style: preserve-3d;
will-change: transform;
// Prevent overflow issues with 3D transforms
-webkit-mask-image: -webkit-radial-gradient(white, black);
mask-image: radial-gradient(white, black);
// Remove mask-image to allow shadows to render properly
&:hover {
box-shadow:
0 10px 30px rgba(0, 0, 0, 0.12),
0 2px 10px rgba(0, 0, 0, 0.08);
0 10px 30px rgba(0, 0, 0, 0.1),
0 1px 8px rgba(0, 0, 0, 0.06);
.project-title {
color: $red-60;
@ -203,6 +204,15 @@
&.clickable {
cursor: pointer;
&:focus {
outline: 2px solid $red-60;
outline-offset: 2px;
}
&:focus:not(:focus-visible) {
outline: none;
}
}
@include breakpoint('phone') {
@ -224,6 +234,8 @@
:global(.btn) {
flex-shrink: 0;
margin-top: 2px; // Align with title baseline
font-size: 1rem !important; // Match detail page Visit button
min-height: auto !important; // Remove min-height to match detail page
}
}
@ -235,13 +247,13 @@
.project-title {
margin: 0;
font-size: 1.125rem;
font-size: 1rem;
font-weight: 400;
color: $grey-00;
line-height: 1.3;
@include breakpoint('phone') {
font-size: 1.125rem;
font-size: 1rem;
}
}
@ -254,7 +266,7 @@
.project-description {
margin: 0 0 $unit-3x 0;
font-size: 1.125rem;
font-size: 1rem;
line-height: 1.5;
color: $grey-20;

View file

@ -1,9 +1,19 @@
<script>
export let href = ''
export let title = ''
export let sourceType = ''
export let date = ''
export let source = ''
<script lang="ts">
interface Props {
href?: string
title?: string
sourceType?: string
date?: string
source?: string
}
let {
href = '',
title = '',
sourceType = '',
date = '',
source = ''
}: Props = $props()
</script>
<li class="mention">

View file

@ -0,0 +1,237 @@
<script lang="ts">
import WorkIcon from '$icons/work.svg'
import LabsIcon from '$icons/labs.svg'
import UniverseIcon from '$icons/universe.svg'
import PhotosIcon from '$icons/photos.svg'
import ChevronDownIcon from '$icons/chevron-down.svg'
import { page } from '$app/stores'
const currentPath = $derived($page.url.pathname)
let isOpen = $state(false)
let dropdownElement: HTMLDivElement
interface NavItem {
icon: typeof WorkIcon
text: string
href: string
variant: 'work' | 'universe' | 'labs' | 'photos'
}
const navItems: NavItem[] = [
{ icon: WorkIcon, text: 'Work', href: '/', variant: 'work' },
{ icon: UniverseIcon, text: 'Universe', href: '/universe', variant: 'universe' },
{ icon: PhotosIcon, text: 'Photos', href: '/photos', variant: 'photos' },
{ icon: LabsIcon, text: 'Labs', href: '/labs', variant: 'labs' }
]
// Get current active item
const activeItem = $derived(
currentPath === '/'
? navItems[0]
: navItems.find((item) => currentPath.startsWith(item.href === '/' ? '/work' : item.href)) ||
navItems[0]
)
// Get background color based on variant
function getBgColor(variant: string): string {
switch (variant) {
case 'work':
return '#ffcdc5'
case 'photos':
return '#e8c5ff'
case 'universe':
return '#ffebc5'
case 'labs':
return '#c5eaff'
default:
return '#c5eaff'
}
}
// Get text color based on variant
function getTextColor(variant: string): string {
switch (variant) {
case 'work':
return '#d0290d'
case 'photos':
return '#7c3aed'
case 'universe':
return '#b97d14'
case 'labs':
return '#1482c1'
default:
return '#1482c1'
}
}
function toggleDropdown(e: MouseEvent) {
e.stopPropagation()
isOpen = !isOpen
console.log('Dropdown toggled:', isOpen)
}
function handleClickOutside(event: MouseEvent) {
if (dropdownElement && !dropdownElement.contains(event.target as Node)) {
isOpen = false
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
isOpen = false
}
}
$effect(() => {
if (typeof document !== 'undefined') {
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleKeydown)
return () => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleKeydown)
}
}
})
</script>
<div class="nav-dropdown" bind:this={dropdownElement}>
<button
class="dropdown-trigger"
onclick={toggleDropdown}
aria-expanded={isOpen}
aria-haspopup="true"
style="color: {getTextColor(activeItem.variant)};"
>
<svelte:component this={activeItem.icon} class="nav-icon" />
<span>{activeItem.text}</span>
<ChevronDownIcon class="chevron {isOpen ? 'open' : ''}" />
</button>
{#if isOpen}
<div class="dropdown-menu">
{#each navItems as item}
<a
href={item.href}
class="dropdown-item"
class:active={item === activeItem}
onclick={() => (isOpen = false)}
>
<svelte:component this={item.icon} class="nav-icon" />
<span>{item.text}</span>
</a>
{/each}
</div>
{/if}
</div>
<style lang="scss">
.nav-dropdown {
position: relative;
height: 52px; // Match avatar height
min-width: 180px; // Wider to better match dropdown menu
}
.dropdown-trigger {
display: flex;
align-items: center;
gap: $unit;
width: 100%;
height: 100%;
padding: 0 $unit-2x;
border: none;
border-radius: 100px;
background: white;
font-size: 1rem;
font-weight: 400;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
&:hover {
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
&:active {
transform: translateY(0);
}
:global(svg.nav-icon) {
width: 20px;
height: 20px;
flex-shrink: 0;
fill: currentColor;
}
:global(svg.chevron) {
width: 16px;
height: 16px;
margin-left: auto;
flex-shrink: 0;
transition: transform 0.2s ease;
fill: none;
stroke: currentColor;
stroke-width: 2px;
stroke-linecap: round;
stroke-linejoin: round;
&.open {
transform: rotate(180deg);
}
}
}
.dropdown-menu {
position: absolute;
top: calc(100% + $unit);
left: 50%;
transform: translateX(-50%);
min-width: 180px;
background: white;
border-radius: $unit-2x;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: $unit;
z-index: 1000;
animation: dropdownOpen 0.2s ease;
}
.dropdown-item {
display: flex;
align-items: center;
gap: $unit-2x;
padding: $unit-2x $unit-2x;
border-radius: $unit;
text-decoration: none;
color: $grey-20;
font-size: 1rem;
transition: background-color 0.2s ease;
&:hover {
background-color: $grey-97;
}
&.active {
background-color: $grey-95;
font-weight: 500;
}
:global(svg.nav-icon) {
width: 20px;
height: 20px;
flex-shrink: 0;
fill: currentColor;
}
}
@keyframes dropdownOpen {
from {
opacity: 0;
transform: translateX(-50%) translateY(-8px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
</style>

View file

@ -1,13 +1,26 @@
<script lang="ts">
export let noHorizontalPadding = false
import type { Snippet } from 'svelte'
interface Props {
noHorizontalPadding?: boolean
class?: string
header?: Snippet
children?: Snippet
}
let { noHorizontalPadding = false, class: className = '', header, children }: Props = $props()
</script>
<section class="page" class:no-horizontal-padding={noHorizontalPadding}>
<header>
<slot name="header" />
</header>
<section class="page {className}" class:no-horizontal-padding={noHorizontalPadding}>
{#if header}
<header>
{@render header()}
</header>
{/if}
<slot />
{#if children}
{@render children()}
{/if}
</section>
<style lang="scss">

View file

@ -2,6 +2,7 @@
import type { Post } from '$lib/posts'
import ImagePost from './ImagePost.svelte'
import LinkCard from './LinkCard.svelte'
import BackButton from './BackButton.svelte'
let { post }: { post: Post } = $props()
@ -42,7 +43,7 @@
</div>
<footer class="post-footer">
<a href="/universe" class="back-link">← Back to all posts</a>
<BackButton href="/universe" label="Back to all posts" />
</footer>
</article>
@ -54,13 +55,13 @@
// Post type styles for simplified post types
&.post {
.post-body {
font-size: 1.125rem;
font-size: 1rem;
}
}
&.essay {
.post-body {
font-size: 1.125rem;
font-size: 1rem;
line-height: 1.5;
}
}
@ -69,13 +70,13 @@
&.note,
&.microblog {
.post-body {
font-size: 1.125rem;
font-size: 1rem;
}
}
&.blog {
.post-body {
font-size: 1.125rem;
font-size: 1rem;
line-height: 1.5;
}
}
@ -201,17 +202,4 @@
padding-top: $unit-4x;
border-top: 1px solid $grey-80;
}
.back-link {
color: $red-60;
text-decoration: none;
font-weight: 500;
transition: all 0.2s ease;
&:hover {
text-decoration: underline;
text-decoration-style: wavy;
text-underline-offset: 0.15em;
}
}
</style>

View file

@ -57,7 +57,7 @@
&.note {
.post-excerpt {
font-size: 1.125rem;
font-size: 1rem;
}
}
@ -107,7 +107,7 @@
.post-excerpt {
margin: 0;
color: $grey-00;
font-size: 1.125rem;
font-size: 1rem;
line-height: 1.5;
display: -webkit-box;
-webkit-box-orient: vertical;

View file

@ -1,7 +1,6 @@
<script lang="ts">
import type { Project } from '$lib/types/project'
import ArrowLeft from '$icons/arrow-left.svg'
import { goto } from '$app/navigation'
import BackButton from './BackButton.svelte'
interface Props {
project: Project
@ -74,15 +73,9 @@
<!-- Navigation -->
<footer class="project-footer">
{#if project.projectType === 'labs'}
<button onclick={() => goto('/labs')} class="back-button">
<ArrowLeft class="back-arrow" />
Back to labs
</button>
<BackButton href="/labs" label="Back to Labs" />
{:else}
<button onclick={() => goto('/')} class="back-button">
<ArrowLeft class="back-arrow" />
Back to projects
</button>
<BackButton href="/" label="Back to projects" />
{/if}
</footer>
</article>
@ -118,12 +111,12 @@
}
:global(h3) {
font-size: 1.125rem;
font-size: 1rem;
}
:global(p) {
margin: $unit-2x 0;
font-size: 1.125rem;
font-size: 1rem;
line-height: 1.5;
color: $grey-20;
}
@ -178,44 +171,4 @@
.project-footer {
padding-bottom: $unit-2x;
}
.back-button {
color: $red-60;
background-color: transparent;
border: 1px solid transparent;
font: inherit;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
gap: $unit;
border-radius: 24px;
outline: none;
&:hover:not(:disabled) :global(.back-arrow) {
transform: translateX(-3px);
}
&:focus-visible {
box-shadow: 0 0 0 3px rgba($red-60, 0.25);
}
:global(.back-arrow) {
width: 16px;
height: 16px;
flex-shrink: 0;
transition: transform 0.2s ease;
margin-left: -$unit-half;
:global(path) {
stroke: currentColor;
stroke-width: 2.25;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
}
}
}
</style>

View file

@ -19,7 +19,7 @@
target="_blank"
rel="noopener noreferrer"
class="visit-button"
style="--button-bg: {project.highlightColor || '#4d4d4d'}; --button-color: white"
style="--button-bg: {project.highlightColor || '#e33d3d'}; --button-color: white"
>
Visit <ArrowRight />
</a>
@ -66,7 +66,7 @@
margin: 0;
@include breakpoint('phone') {
font-size: 1.125rem;
font-size: 1rem;
}
}
@ -75,7 +75,7 @@
align-items: center;
gap: $unit;
padding: ($unit * 1.5) $unit-2x;
background: var(--button-bg, $grey-10);
background: var(--button-bg, $red-60);
color: var(--button-color, white);
text-decoration: none;
border-radius: 50px;
@ -107,7 +107,13 @@
}
@include breakpoint('phone') {
align-self: flex-start;
width: 100%;
justify-content: center;
box-sizing: border-box;
:global(svg) {
display: none;
}
}
}
</style>

View file

@ -285,7 +285,7 @@
.project-description {
margin: 0;
font-size: 1.125rem; // 18px
font-size: 1rem; // 18px
line-height: 1.3;
color: $grey-00;
}

View file

@ -56,6 +56,11 @@
justify-content: center;
width: 100%;
@include breakpoint('phone') {
box-sizing: border-box;
padding: 0 $unit-2x;
}
ul {
display: flex;
flex-direction: column;
@ -89,7 +94,7 @@
.intro-text {
margin: 0;
font-size: 1.125rem; // 18px
font-size: 1rem; // 18px
line-height: 1.3;
color: $grey-00;

View file

@ -1,5 +1,6 @@
<script lang="ts">
import Button from '$lib/components/admin/Button.svelte'
import BackButton from './BackButton.svelte'
import { onMount } from 'svelte'
interface Props {
@ -121,9 +122,9 @@
<div class="back-link-wrapper">
{#if projectType === 'labs'}
<a href="/labs" class="back-link">← Back to labs</a>
<BackButton href="/labs" label="Back to Labs" />
{:else}
<a href="/" class="back-link">← Back to projects</a>
<BackButton href="/" label="Back to projects" />
{/if}
</div>
</div>
@ -163,7 +164,7 @@
color: $grey-40;
margin: 0;
line-height: 1.5;
font-size: 1.125rem;
font-size: 1rem;
}
}
@ -236,16 +237,5 @@
text-align: center;
width: 100%;
}
.back-link {
color: $grey-40;
text-decoration: none;
font-size: 0.925rem;
transition: color 0.2s ease;
&:hover {
color: $grey-20;
}
}
}
</style>

View file

@ -84,7 +84,7 @@
.album-description {
margin: 0;
color: $grey-10;
font-size: 1.125rem;
font-size: 1rem;
line-height: 1.5;
display: -webkit-box;
-webkit-box-orient: vertical;

View file

@ -16,8 +16,11 @@
{/if}
{/each}
{:else}
<div class="empty-state">
<p>No content found in the universe yet.</p>
<div class="empty-container">
<div class="empty-message">
<h2>No posts yet</h2>
<p>Posts will be added to Universe soon</p>
</div>
</div>
{/if}
</div>
@ -26,17 +29,32 @@
.universe-feed {
display: flex;
flex-direction: column;
gap: $unit-3x;
gap: $unit-2x;
padding: 0 $unit-2x;
}
.empty-state {
.empty-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
}
.empty-message {
text-align: center;
padding: $unit-6x $unit-3x;
color: $grey-40;
max-width: 500px;
h2 {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 $unit-2x;
color: $grey-10;
}
p {
margin: 0;
font-size: 1.125rem;
color: $grey-40;
line-height: 1.5;
}
}
</style>

View file

@ -101,7 +101,7 @@
p {
margin: 0;
color: $grey-10;
font-size: 1.125rem;
font-size: 1rem;
line-height: 1.5;
display: -webkit-box;
-webkit-box-orient: vertical;

View file

@ -236,6 +236,7 @@
<div class="form-section">
<Input
label="Album Title"
size="jumbo"
bind:value={title}
placeholder="Enter album title"
required={true}

View file

@ -170,7 +170,7 @@
align-items: center;
justify-content: center;
gap: $unit;
font-weight: 500;
font-weight: 400;
border: none;
cursor: pointer;
transition: all 0.15s ease;
@ -211,7 +211,7 @@
}
.btn-medium {
padding: $unit $unit-2x;
padding: ($unit * 1.5) $unit-2x;
font-size: 14px;
border-radius: 24px;
min-height: 36px;

View file

@ -261,7 +261,7 @@
}}
>
<div class="form-section">
<Input label="Title" bind:value={title} required placeholder="Essay title" />
<Input label="Title" size="jumbo" bind:value={title} required placeholder="Essay title" />
<Input label="Slug" bind:value={slug} placeholder="essay-url-slug" />

View file

@ -18,12 +18,12 @@
{/if}
</label>
{@render children?.()}
{#if helpText}
<p class="help-text">{helpText}</p>
{/if}
{@render children?.()}
{#if error}
<p class="error-text">{error}</p>
{/if}
@ -39,7 +39,8 @@
&.has-error {
:global(input),
:global(textarea) {
:global(textarea),
:global(select) {
border-color: #c33;
}
}

View file

@ -11,6 +11,7 @@
label: string
value?: Media | null
onUpload: (media: Media) => void
onRemove?: () => void
aspectRatio?: string // e.g., "16:9", "1:1"
required?: boolean
error?: string
@ -26,6 +27,7 @@
label,
value = $bindable(),
onUpload,
onRemove,
aspectRatio,
required = false,
error,
@ -182,6 +184,7 @@
altTextValue = ''
descriptionValue = ''
uploadError = null
onRemove?.()
}
// Update alt text on server

View file

@ -1,8 +1,7 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLTextareaAttributes } from 'svelte/elements'
import type { HTMLInputAttributes } from 'svelte/elements'
// Type helpers for different input elements
type InputProps = HTMLInputAttributes & {
type Props = HTMLInputAttributes & {
type?:
| 'text'
| 'email'
@ -14,19 +13,10 @@
| 'date'
| 'time'
| 'color'
}
type TextareaProps = HTMLTextareaAttributes & {
type: 'textarea'
rows?: number
autoResize?: boolean
}
type Props = (InputProps | TextareaProps) & {
label?: string
error?: string
helpText?: string
size?: 'small' | 'medium' | 'large'
size?: 'small' | 'medium' | 'large' | 'jumbo'
pill?: boolean
fullWidth?: boolean
required?: boolean
@ -64,8 +54,6 @@
...restProps
}: Props = $props()
// For textarea auto-resize
let textareaElement: HTMLTextAreaElement | undefined = $state()
let charCount = $derived(String(value).length)
let charsRemaining = $derived(maxLength ? maxLength - charCount : 0)
@ -92,16 +80,6 @@
}
}
// Auto-resize textarea
$effect(() => {
if (type === 'textarea' && textareaElement && isTextarea(restProps) && restProps.autoResize) {
// Reset height to auto to get the correct scrollHeight
textareaElement.style.height = 'auto'
// Set the height to match content
textareaElement.style.height = textareaElement.scrollHeight + 'px'
}
})
// Compute classes
const wrapperClasses = $derived(() => {
const classes = ['input-wrapper']
@ -112,8 +90,6 @@
if (prefixIcon) classes.push('has-prefix-icon')
if (suffixIcon) classes.push('has-suffix-icon')
if (colorSwatch) classes.push('has-color-swatch')
if (type === 'textarea' && isTextarea(restProps) && restProps.autoResize)
classes.push('has-auto-resize')
if (wrapperClass) classes.push(wrapperClass)
if (className) classes.push(className)
return classes.join(' ')
@ -126,11 +102,6 @@
if (inputClass) classes.push(inputClass)
return classes.join(' ')
})
// Type guard for textarea props
function isTextarea(props: Props): props is TextareaProps {
return props.type === 'textarea'
}
</script>
<div class={wrapperClasses()}>
@ -161,32 +132,17 @@
></span>
{/if}
{#if type === 'textarea' && isTextarea(restProps)}
<textarea
bind:this={textareaElement}
bind:value
{id}
{disabled}
{readonly}
{required}
{maxLength}
class={inputClasses()}
rows={restProps.rows || 3}
{...restProps}
/>
{:else}
<input
bind:value
{id}
{type}
{disabled}
{readonly}
{required}
{maxLength}
class={inputClasses()}
{...restProps}
/>
{/if}
<input
bind:value
{id}
{type}
{disabled}
{readonly}
{required}
{maxLength}
class={inputClasses()}
{...restProps}
/>
{#if suffixIcon}
<span class="input-icon suffix-icon">
@ -303,23 +259,27 @@
}
}
// Input and textarea styles
// Input styles
.input {
width: 100%;
font-size: 14px;
border: 1px solid $grey-80;
border-radius: 6px;
background-color: white;
border: 1px solid transparent;
color: $input-text-color;
background-color: $input-background-color;
transition: all 0.15s ease;
&:hover {
background-color: $input-background-color-hover;
color: $input-text-color-hover;
}
&::placeholder {
color: $grey-50;
}
&:focus {
outline: none;
border-color: $primary-color;
background-color: white;
background-color: $input-background-color-hover;
color: $input-text-color-hover;
}
&:disabled {
@ -337,17 +297,23 @@
// Size variations
.input-small {
padding: $unit calc($unit * 1.5);
font-size: 13px;
font-size: 0.75rem;
}
.input-medium {
padding: calc($unit * 1.5) $unit-2x;
font-size: 14px;
font-size: 1rem;
}
.input-large {
padding: $unit-2x $unit-3x;
font-size: 16px;
font-size: 1.25rem;
box-sizing: border-box;
}
.input-jumbo {
padding: $unit-2x $unit-2x;
font-size: 1.33rem;
box-sizing: border-box;
}
@ -362,17 +328,23 @@
&.input-large {
border-radius: 28px;
}
&.input-jumbo {
border-radius: 32px;
}
}
.input:not(.input-pill) {
&.input-small {
border-radius: 6px;
border-radius: $corner-radius-lg;
}
&.input-medium {
border-radius: 8px;
border-radius: $corner-radius-2xl;
}
&.input-large {
border-radius: 10px;
border-radius: $corner-radius-2xl;
}
&.input-jumbo {
border-radius: $corner-radius-2xl;
}
}
@ -409,31 +381,6 @@
}
}
// Textarea specific
textarea.input {
resize: vertical;
min-height: 80px;
padding-top: calc($unit * 1.5);
padding-bottom: calc($unit * 1.5);
line-height: 1.5;
overflow-y: hidden; // Important for auto-resize
&.input-small {
min-height: 60px;
padding-top: $unit;
padding-bottom: $unit;
}
&.input-large {
min-height: 100px;
}
}
// Auto-resizing textarea
.has-auto-resize textarea.input {
resize: none; // Disable manual resize when auto-resize is enabled
}
// Footer styles
.input-footer {
display: flex;

View file

@ -1,21 +1,27 @@
<script lang="ts">
import Input from './Input.svelte'
import ImageUploader from './ImageUploader.svelte'
import Button from './Button.svelte'
import type { ProjectFormData } from '$lib/types/project'
import type { Media } from '@prisma/client'
interface Props {
formData: ProjectFormData
validationErrors: Record<string, string>
onSave?: () => Promise<void>
}
let { formData = $bindable() }: Props = $props()
let { formData = $bindable(), validationErrors, onSave }: Props = $props()
// State for collapsible logo section
let showLogoSection = $state(!!formData.logoUrl && formData.logoUrl.trim() !== '')
// Convert logoUrl string to Media object for ImageUploader
let logoMedia = $state<Media | null>(null)
// Update logoMedia when logoUrl changes
$effect(() => {
if (formData.logoUrl && !logoMedia) {
if (formData.logoUrl && formData.logoUrl.trim() !== '' && !logoMedia) {
// Create a minimal Media object from the URL for display
logoMedia = {
id: -1, // Temporary ID for existing URLs
@ -33,8 +39,13 @@
createdAt: new Date(),
updatedAt: new Date()
}
} else if (!formData.logoUrl) {
logoMedia = null
}
})
// Sync logoMedia changes back to formData
$effect(() => {
if (!logoMedia && formData.logoUrl) {
formData.logoUrl = ''
}
})
@ -43,27 +54,87 @@
logoMedia = media
}
function handleLogoRemove() {
async function handleLogoRemove() {
formData.logoUrl = ''
logoMedia = null
showLogoSection = false
// Auto-save the removal
if (onSave) {
await onSave()
}
}
</script>
<div class="form-section">
<h2>Branding</h2>
<ImageUploader
label="Project Logo"
value={logoMedia}
onUpload={handleLogoUpload}
aspectRatio="1:1"
allowAltText={true}
maxFileSize={0.5}
placeholder="Drag and drop an SVG logo here, or click to browse"
helpText="Upload an SVG logo for project thumbnail (max 500KB). Square logos work best."
showBrowseLibrary={true}
compact={true}
/>
{#if !showLogoSection && (!formData.logoUrl || formData.logoUrl.trim() === '')}
<Button
variant="secondary"
buttonSize="medium"
onclick={() => (showLogoSection = true)}
iconPosition="left"
>
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="12" y1="3" x2="12" y2="21"></line>
<line x1="3" y1="12" x2="21" y2="12"></line>
</svg>
Add Project Logo
</Button>
{:else}
<div class="collapsible-section">
<div class="section-header">
<h3>Project Logo</h3>
</div>
<ImageUploader
label=""
bind:value={logoMedia}
onUpload={handleLogoUpload}
onRemove={handleLogoRemove}
aspectRatio="1:1"
allowAltText={true}
maxFileSize={0.5}
placeholder="Drag and drop an SVG logo here, or click to browse"
helpText="Upload an SVG logo for project thumbnail (max 500KB). Square logos work best."
showBrowseLibrary={true}
compact={true}
/>
</div>
{/if}
<div class="form-row">
<Input
type="text"
bind:value={formData.backgroundColor}
label="Background Color"
helpText="Hex color for project card"
error={validationErrors.backgroundColor}
placeholder="#FFFFFF"
pattern="^#[0-9A-Fa-f]{6}$"
colorSwatch={true}
/>
<Input
type="text"
bind:value={formData.highlightColor}
label="Highlight Color"
helpText="Accent color for the project"
error={validationErrors.highlightColor}
placeholder="#000000"
pattern="^#[0-9A-Fa-f]{6}$"
colorSwatch={true}
/>
</div>
</div>
<style lang="scss">
@ -81,4 +152,36 @@
color: $grey-10;
}
}
.collapsible-section {
// No border or background needed
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
h3 {
font-size: 0.875rem;
font-weight: 600;
margin: 0;
color: $grey-20;
}
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $unit-2x;
margin-top: $unit-3x;
@include breakpoint('phone') {
grid-template-columns: 1fr;
}
:global(.input-wrapper) {
margin-bottom: 0;
}
}
</style>

View file

@ -7,8 +7,7 @@
import Editor from './Editor.svelte'
import ProjectMetadataForm from './ProjectMetadataForm.svelte'
import ProjectBrandingForm from './ProjectBrandingForm.svelte'
import ProjectGalleryForm from './ProjectGalleryForm.svelte'
import ProjectStylingForm from './ProjectStylingForm.svelte'
import ProjectImagesForm from './ProjectImagesForm.svelte'
import Button from './Button.svelte'
import StatusDropdown from './StatusDropdown.svelte'
import { projectSchema } from '$lib/schemas/project'
@ -60,11 +59,10 @@
role: data.role || '',
projectType: data.projectType || 'work',
externalUrl: data.externalUrl || '',
featuredImage: data.featuredImage || null,
featuredImage: data.featuredImage && data.featuredImage.trim() !== '' ? data.featuredImage : null,
backgroundColor: data.backgroundColor || '',
highlightColor: data.highlightColor || '',
logoUrl: data.logoUrl || '',
gallery: data.gallery || null,
logoUrl: data.logoUrl && data.logoUrl.trim() !== '' ? data.logoUrl : '',
status: data.status || 'draft',
password: data.password || '',
caseStudyContent: data.caseStudyContent || {
@ -142,9 +140,8 @@
role: formData.role,
projectType: formData.projectType,
externalUrl: formData.externalUrl,
featuredImage: formData.featuredImage,
logoUrl: formData.logoUrl,
gallery: formData.gallery && formData.gallery.length > 0 ? formData.gallery : null,
featuredImage: formData.featuredImage && formData.featuredImage !== '' ? formData.featuredImage : null,
logoUrl: formData.logoUrl && formData.logoUrl !== '' ? formData.logoUrl : null,
backgroundColor: formData.backgroundColor,
highlightColor: formData.highlightColor,
status: formData.status,
@ -266,10 +263,9 @@
handleSave()
}}
>
<ProjectMetadataForm bind:formData {validationErrors} />
<ProjectBrandingForm bind:formData />
<ProjectGalleryForm bind:formData />
<ProjectStylingForm bind:formData {validationErrors} />
<ProjectMetadataForm bind:formData {validationErrors} onSave={handleSave} />
<ProjectBrandingForm bind:formData {validationErrors} onSave={handleSave} />
<ProjectImagesForm bind:formData {validationErrors} onSave={handleSave} />
</form>
</div>
</div>

View file

@ -0,0 +1,138 @@
<script lang="ts">
import Input from './Input.svelte'
import ImageUploader from './ImageUploader.svelte'
import Button from './Button.svelte'
import type { ProjectFormData } from '$lib/types/project'
import type { Media } from '@prisma/client'
interface Props {
formData: ProjectFormData
validationErrors: Record<string, string>
onSave?: () => Promise<void>
}
let { formData = $bindable(), validationErrors, onSave }: Props = $props()
// State for collapsible featured image section
let showFeaturedImage = $state(
!!formData.featuredImage && formData.featuredImage !== '' && formData.featuredImage !== null
)
// Convert featuredImage string to Media object for ImageUploader
let featuredImageMedia = $state<Media | null>(null)
// Initialize media object from existing featuredImage URL
$effect(() => {
if (
formData.featuredImage &&
formData.featuredImage !== '' &&
formData.featuredImage !== null &&
!featuredImageMedia
) {
// Only create a minimal Media object if we don't already have one
featuredImageMedia = {
id: -1, // Temporary ID for existing URLs
filename: 'featured-image',
originalName: 'featured-image',
mimeType: 'image/jpeg',
size: 0,
url: formData.featuredImage,
thumbnailUrl: formData.featuredImage,
width: null,
height: null,
altText: null,
description: null,
usedIn: [],
createdAt: new Date(),
updatedAt: new Date()
}
}
})
// Sync featuredImageMedia changes back to formData
$effect(() => {
if (!featuredImageMedia && formData.featuredImage) {
formData.featuredImage = ''
}
})
function handleFeaturedImageUpload(media: Media) {
formData.featuredImage = media.url
featuredImageMedia = media
}
async function handleFeaturedImageRemove() {
formData.featuredImage = ''
featuredImageMedia = null
showFeaturedImage = false
// Auto-save the removal
if (onSave) {
await onSave()
}
}
</script>
<div class="form-section">
<div class="section-header-with-action">
<h2>Images</h2>
{#if !showFeaturedImage}
<Button
variant="secondary"
buttonSize="small"
onclick={() => (showFeaturedImage = true)}
iconPosition="left"
>
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
Add Featured Image
</Button>
{/if}
</div>
{#if showFeaturedImage}
<ImageUploader
label=""
bind:value={featuredImageMedia}
onUpload={handleFeaturedImageUpload}
onRemove={handleFeaturedImageRemove}
placeholder="Upload a featured image for this project"
showBrowseLibrary={true}
/>
{/if}
</div>
<style lang="scss">
.form-section {
margin-bottom: $unit-6x;
&:last-child {
margin-bottom: 0;
}
h2 {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: $grey-10;
}
}
.section-header-with-action {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $unit-3x;
}
</style>

View file

@ -1,51 +1,58 @@
<script lang="ts">
import Input from './Input.svelte'
import Select from './Select.svelte'
import ImageUploader from './ImageUploader.svelte'
import Textarea from './Textarea.svelte'
import SelectField from './SelectField.svelte'
import SegmentedControlField from './SegmentedControlField.svelte'
import type { ProjectFormData } from '$lib/types/project'
interface Props {
formData: ProjectFormData
validationErrors: Record<string, string>
onSave?: () => Promise<void>
}
let { formData = $bindable(), validationErrors }: Props = $props()
let { formData = $bindable(), validationErrors, onSave }: Props = $props()
function handleFeaturedImageUpload(media: Media) {
formData.featuredImage = media.url
}
</script>
<div class="form-section">
<Input
label="Title"
required
size="jumbo"
error={validationErrors.title}
bind:value={formData.title}
placeholder="Project title"
/>
<Input
type="textarea"
<Textarea
label="Description"
size="jumbo"
error={validationErrors.description}
bind:value={formData.description}
rows={3}
placeholder="Short description for project cards"
/>
<Select
label="Project Type"
bind:value={formData.projectType}
error={validationErrors.projectType}
options={[
{ value: 'work', label: 'Work' },
{ value: 'labs', label: 'Labs' }
]}
helpText="Choose whether this project appears in the Work tab or Labs tab"
<Input
type="url"
label="External URL"
error={validationErrors.externalUrl}
bind:value={formData.externalUrl}
placeholder="https://example.com"
/>
<div class="form-row">
<div class="form-row three-column">
<SegmentedControlField
label="Project Type"
bind:value={formData.projectType}
error={validationErrors.projectType}
options={[
{ value: 'work', label: 'Work' },
{ value: 'labs', label: 'Labs' }
]}
/>
<Input
type="number"
label="Year"
@ -64,35 +71,6 @@
/>
</div>
<Input
type="url"
label="External URL"
error={validationErrors.externalUrl}
bind:value={formData.externalUrl}
placeholder="https://example.com"
/>
<ImageUploader
label="Featured Image"
value={null}
onUpload={handleFeaturedImageUpload}
placeholder="Upload a featured image for this project"
showBrowseLibrary={true}
/>
<Select
label="Project Status"
bind:value={formData.status}
error={validationErrors.status}
options={[
{ value: 'draft', label: 'Draft (Hidden)' },
{ value: 'published', label: 'Published' },
{ value: 'list-only', label: 'List Only (No Access)' },
{ value: 'password-protected', label: 'Password Protected' }
]}
helpText="Control how this project appears on the public site"
/>
{#if formData.status === 'password-protected'}
<Input
type="password"
@ -110,19 +88,30 @@
.form-section {
display: flex;
flex-direction: column;
gap: $unit-2x;
gap: $unit-4x;
&:last-child {
margin-bottom: 0;
}
h2 {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: $grey-10;
}
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $unit-2x;
gap: $unit-4x;
padding-bottom: $unit-3x;
&.three-column {
grid-template-columns: 1fr 1fr 1fr;
}
@include breakpoint('phone') {
grid-template-columns: 1fr;
}

View file

@ -0,0 +1,108 @@
<script lang="ts">
import FormFieldWrapper from './FormFieldWrapper.svelte'
interface Option {
value: string
label: string
}
interface Props {
label: string
options: Option[]
value?: string
required?: boolean
helpText?: string
error?: string
fullWidth?: boolean
}
let {
label,
options,
value = $bindable(),
required = false,
helpText,
error,
fullWidth = true
}: Props = $props()
function handleChange(newValue: string) {
value = newValue
}
</script>
<FormFieldWrapper {label} {required} {helpText} {error}>
{#snippet children()}
<div class="segmented-control-wrapper" class:full-width={fullWidth}>
<div class="segmented-control">
{#each options as option}
<button
type="button"
class="segment"
class:active={value === option.value}
onclick={() => handleChange(option.value)}
>
{option.label}
</button>
{/each}
</div>
</div>
{/snippet}
</FormFieldWrapper>
<style lang="scss">
.segmented-control-wrapper {
&.full-width {
width: 100%;
}
}
.segmented-control {
display: inline-flex;
background-color: $input-background-color;
border-radius: $corner-radius-full;
padding: 3px;
gap: 2px;
width: 100%;
// Match medium input height: padding (12px * 2) + font line-height (~20px) + padding for container (3px * 2)
height: 50px;
box-sizing: border-box;
}
.segment {
flex: 1;
padding: 0 $unit-2x;
background: transparent;
border: none;
border-radius: $corner-radius-full;
font-size: 1rem;
font-weight: 500;
color: $input-text-color;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
&:hover:not(.active) {
color: $input-text-color-hover;
background-color: rgba(0, 0, 0, 0.03);
}
&.active {
background-color: white;
color: $grey-10;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04);
}
&:focus {
outline: none;
}
&:focus-visible {
box-shadow: 0 0 0 2px $primary-color;
}
}
</style>

View file

@ -49,6 +49,7 @@
.select-wrapper {
position: relative;
display: inline-block;
width: 100%;
}
.select {
@ -59,6 +60,7 @@
transition: all 0.2s ease;
appearance: none;
padding-right: 36px;
width: 100%;
&:focus {
outline: none;

View file

@ -0,0 +1,60 @@
<script lang="ts">
import Select from './Select.svelte'
import FormFieldWrapper from './FormFieldWrapper.svelte'
import type { HTMLSelectAttributes } from 'svelte/elements'
interface Option {
value: string
label: string
}
interface Props extends Omit<HTMLSelectAttributes, 'size'> {
label: string
options: Option[]
value?: string
size?: 'small' | 'medium' | 'large'
variant?: 'default' | 'minimal'
fullWidth?: boolean
pill?: boolean
required?: boolean
helpText?: string
error?: string
}
let {
label,
options,
value = $bindable(),
size = 'medium',
variant = 'default',
fullWidth = true,
pill = true,
required = false,
helpText,
error,
...restProps
}: Props = $props()
</script>
<FormFieldWrapper {label} {required} {helpText} {error}>
{#snippet children()}
<Select
bind:value
{options}
{size}
{variant}
{fullWidth}
{pill}
{...restProps}
/>
{/snippet}
</FormFieldWrapper>
<style lang="scss">
// Ensure proper spacing for select fields
:global(.form-field) {
:global(.select-wrapper) {
margin-top: 0;
}
}
</style>

View file

@ -0,0 +1,266 @@
<script lang="ts">
import type { HTMLTextareaAttributes } from 'svelte/elements'
type Props = HTMLTextareaAttributes & {
label?: string
error?: string
helpText?: string
size?: 'small' | 'medium' | 'large' | 'jumbo'
fullWidth?: boolean
required?: boolean
wrapperClass?: string
textareaClass?: string
showCharCount?: boolean
maxLength?: number
autoResize?: boolean
}
let {
label,
error,
helpText,
size = 'medium',
fullWidth = true,
required = false,
wrapperClass = '',
textareaClass = '',
showCharCount = false,
maxLength,
autoResize = false,
rows = 3,
value = $bindable(''),
disabled = false,
readonly = false,
id = `textarea-${Math.random().toString(36).substr(2, 9)}`,
...restProps
}: Props = $props()
// Element reference for auto-resize
let textareaElement: HTMLTextAreaElement | undefined = $state()
// Character counting
let charCount = $derived(String(value).length)
let charsRemaining = $derived(maxLength ? maxLength - charCount : 0)
// Auto-resize textarea
$effect(() => {
if (autoResize && textareaElement) {
// Reset height to auto to get the correct scrollHeight
textareaElement.style.height = 'auto'
// Set the height to match content
textareaElement.style.height = textareaElement.scrollHeight + 'px'
}
})
// Compute wrapper classes
function getWrapperClasses() {
const classes = ['textarea-wrapper']
if (fullWidth) classes.push('full-width')
if (error) classes.push('has-error')
if (disabled) classes.push('disabled')
if (wrapperClass) classes.push(wrapperClass)
return classes.join(' ')
}
// Compute textarea classes
function getTextareaClasses() {
const sizeClass = `textarea-${size}`
const classes = ['textarea', sizeClass]
if (textareaClass) classes.push(textareaClass)
return classes.join(' ')
}
</script>
<div class={getWrapperClasses()}>
{#if label}
<label for={id} class="textarea-label">
{label}
{#if required}
<span class="required">*</span>
{/if}
</label>
{/if}
<div class="textarea-container">
<textarea
bind:this={textareaElement}
bind:value
{id}
{disabled}
{readonly}
{required}
{maxLength}
{rows}
class={getTextareaClasses()}
{...restProps}
/>
</div>
{#if (error || helpText || showCharCount) && !disabled}
<div class="textarea-footer">
{#if error}
<span class="textarea-error">{error}</span>
{:else if helpText}
<span class="textarea-help">{helpText}</span>
{/if}
{#if showCharCount && maxLength}
<span
class="char-count"
class:warning={charsRemaining < maxLength * 0.1}
class:error={charsRemaining < 0}
>
{charsRemaining}
</span>
{/if}
</div>
{/if}
</div>
<style lang="scss">
@import '$styles/variables.scss';
// Wrapper styles
.textarea-wrapper {
display: inline-block;
position: relative;
&.full-width {
display: block;
width: 100%;
}
&.has-error {
.textarea {
border-color: $red-50;
&:focus {
border-color: $red-50;
}
}
}
&.disabled {
opacity: 0.6;
}
}
// Label styles
.textarea-label {
display: block;
margin-bottom: $unit;
font-size: 14px;
font-weight: 500;
color: $grey-20;
.required {
color: $red-50;
margin-left: 2px;
}
}
.textarea-container {
position: relative;
width: 100%;
}
// Textarea styles
.textarea {
color: $input-text-color;
width: 100%;
font-family: inherit;
border: 1px solid transparent;
border-radius: $corner-radius-sm;
background-color: $input-background-color;
transition: all 0.15s ease;
resize: vertical;
&:hover {
background-color: $input-background-color-hover;
color: $input-text-color-hover;
}
&::placeholder {
color: $grey-50;
}
&:focus {
outline: none;
background-color: $input-background-color-hover;
color: $input-text-color-hover;
}
&:disabled {
background-color: $grey-95;
cursor: not-allowed;
color: $grey-40;
resize: none;
}
&:read-only {
background-color: $grey-97;
cursor: default;
resize: none;
}
}
// Size variations
.textarea-small {
padding: $unit calc($unit * 1.5);
border-radius: $corner-radius-sm;
font-size: 0.75rem;
}
.textarea-medium {
padding: calc($unit * 1.5) $unit-2x;
border-radius: $corner-radius-md;
font-size: 1rem;
}
.textarea-large {
padding: $unit-2x $unit-3x;
border-radius: $corner-radius-lg;
font-size: 1.25rem;
}
.textarea-jumbo {
padding: $unit-2x $unit-2x;
border-radius: $corner-radius-2xl;
font-size: 1.33rem;
}
// Footer styles
.textarea-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: $unit-half;
min-height: 20px;
}
.textarea-error {
font-size: 13px;
color: $red-50;
flex: 1;
}
.textarea-help {
font-size: 13px;
color: $grey-40;
flex: 1;
}
.char-count {
font-size: 12px;
color: $grey-40;
margin-left: $unit;
&.warning {
color: $yellow-50;
}
&.error {
color: $red-50;
}
}
</style>

View file

@ -131,8 +131,8 @@ input[type='checkbox'] {
display: grid;
place-content: center;
cursor: pointer;
width: 1.125rem;
height: 1.125rem;
width: 1rem;
height: 1rem;
border-radius: 0.25rem;
}

View file

@ -12,7 +12,6 @@ export interface Project {
role: string | null
featuredImage: string | null
logoUrl: string | null
gallery: any[] | null
externalUrl: string | null
caseStudyContent: any | null
backgroundColor: string | null
@ -39,7 +38,6 @@ export interface ProjectFormData {
backgroundColor: string
highlightColor: string
logoUrl: string
gallery: any[] | null
status: ProjectStatus
password: string
caseStudyContent: any
@ -58,7 +56,6 @@ export const defaultProjectFormData: ProjectFormData = {
backgroundColor: '',
highlightColor: '',
logoUrl: '',
gallery: null,
status: 'draft',
password: '',
caseStudyContent: {

View file

@ -7,52 +7,55 @@
import type { PageData } from './$types'
export let data: PageData
let { data } = $props<{ data: PageData }>()
$: ({ albums, games, error } = data)
let albums = $derived(data.albums)
let games = $derived(data.games)
let error = $derived(data.error)
</script>
<Page>
<svelte:fragment slot="header">
<h2>A little about me</h2>
</svelte:fragment>
<section class="about-container">
<Page>
{#snippet header()}
<h2>A little about me</h2>
{/snippet}
<section class="bio">
<p>
Hello! My name is <em>Justin Edmund</em>. I'm a software designer and developer living in San
Francisco.
</p>
<p>
Right now, I'm spending my free time building a hobby journaling app called <a
href="https://maitsu.co"
target="_blank">Maitsu</a
>. I've spent time at several companies over the last 11 years, but you might know me from
<a href="https://www.pinterest.com/" target="_blank">Pinterest</a>, where I was the first
design hire.
</p>
<p>
I was born and raised in New York City and spend a lot of time in Tokyo. I graduated from <a
href="http://design.cmu.edu/"
target="_blank">Carnegie Mellon University</a
> in 2011 with a Bachelors of Arts in Communication Design.
</p>
</section>
</Page>
<Page>
<svelte:fragment slot="header">
<h2>Notable mentions</h2>
</svelte:fragment>
<section class="bio">
<p>
Hello! My name is <em>Justin Edmund</em>. I'm a software designer and developer living in
San Francisco.
</p>
<p>
Right now, I'm spending my free time building a hobby journaling app called <a
href="https://maitsu.co"
target="_blank">Maitsu</a
>. I've spent time at several companies over the last 11 years, but you might know me from
<a href="https://www.pinterest.com/" target="_blank">Pinterest</a>, where I was the first
design hire.
</p>
<p>
I was born and raised in New York City and spend a lot of time in Tokyo. I graduated from <a
href="http://design.cmu.edu/"
target="_blank">Carnegie Mellon University</a
> in 2011 with a Bachelors of Arts in Communication Design.
</p>
</section>
</Page>
<Page>
{#snippet header()}
<h2>Notable mentions</h2>
{/snippet}
<MentionList />
</Page>
<Page noHorizontalPadding={true}>
<svelte:fragment slot="header">
<h2>Now playing</h2>
</svelte:fragment>
<MentionList />
</Page>
<Page noHorizontalPadding={true}>
{#snippet header()}
<h2>Now playing</h2>
{/snippet}
<RecentAlbums {albums} />
<RecentAlbums {albums} />
<!-- <section class="latest-games">
<!-- <section class="latest-games">
{#if games && games.length > 0}
<ul>
{#each games.slice(0, 3) as game}
@ -63,9 +66,27 @@
<p>Loading games...</p>
{/if}
</section> -->
</Page>
</Page>
</section>
<style lang="scss">
.about-container {
display: flex;
flex-direction: column;
gap: $unit-2x;
justify-content: center;
max-width: 700px;
margin: 0 auto;
@include breakpoint('phone') {
padding: 0 $unit-2x;
}
:global(.page) {
margin: 0;
}
}
a,
em {
color: $red-60;
@ -88,7 +109,7 @@
}
.bio {
font-size: 1.125rem;
font-size: 1rem;
line-height: 1.5;
color: #333;
background: $grey-100;

View file

@ -134,15 +134,15 @@ export const PUT: RequestHandler = async (event) => {
const album = await prisma.album.update({
where: { id },
data: {
slug: body.slug ?? existing.slug,
title: body.title ?? existing.title,
slug: body.slug !== undefined ? body.slug : existing.slug,
title: body.title !== undefined ? body.title : existing.title,
description: body.description !== undefined ? body.description : existing.description,
date: body.date !== undefined ? (body.date ? new Date(body.date) : null) : existing.date,
location: body.location !== undefined ? body.location : existing.location,
coverPhotoId: body.coverPhotoId !== undefined ? body.coverPhotoId : existing.coverPhotoId,
isPhotography: body.isPhotography ?? existing.isPhotography,
status: body.status ?? existing.status,
showInUniverse: body.showInUniverse ?? existing.showInUniverse
isPhotography: body.isPhotography !== undefined ? body.isPhotography : existing.isPhotography,
status: body.status !== undefined ? body.status : existing.status,
showInUniverse: body.showInUniverse !== undefined ? body.showInUniverse : existing.showInUniverse
}
})

View file

@ -21,7 +21,7 @@ export const GET: RequestHandler = async ({ url }) => {
// const albums = await getWeeklyAlbumChart(client, USERNAME)
const albums = await getRecentAlbums(client, USERNAME, ALBUM_LIMIT)
console.log(albums)
// console.log(albums)
const enrichedAlbums = await Promise.all(
albums.slice(0, ALBUM_LIMIT).map(async (album) => {
try {

View file

@ -68,9 +68,9 @@ export const PUT: RequestHandler = async (event) => {
const media = await prisma.media.update({
where: { id },
data: {
altText: body.altText ?? existing.altText,
description: body.description ?? existing.description,
isPhotography: body.isPhotography ?? existing.isPhotography
altText: body.altText !== undefined ? body.altText : existing.altText,
description: body.description !== undefined ? body.description : existing.description,
isPhotography: body.isPhotography !== undefined ? body.isPhotography : existing.isPhotography
}
})

View file

@ -8,6 +8,8 @@ import {
} from '$lib/server/api-utils'
import { logger } from '$lib/server/logger'
import { removeMediaUsage, extractMediaIds } from '$lib/server/media-usage.js'
import { deleteFile, extractPublicId, isCloudinaryConfigured } from '$lib/server/cloudinary'
import { deleteFileLocally } from '$lib/server/local-storage'
// DELETE /api/media/bulk-delete - Delete multiple media files and clean up references
export const DELETE: RequestHandler = async (event) => {
@ -37,6 +39,65 @@ export const DELETE: RequestHandler = async (event) => {
return errorResponse('No media files found with the provided IDs', 404)
}
// Delete files from storage (Cloudinary or local)
const storageDeleteResults: Array<{
id: number
filename: string
deleted: boolean
error?: string
}> = []
for (const media of mediaRecords) {
try {
let deleted = false
// Check if it's a Cloudinary URL
if (media.url.includes('cloudinary.com')) {
const publicId = extractPublicId(media.url)
if (publicId) {
deleted = await deleteFile(publicId)
if (!deleted) {
logger.warn('Failed to delete from Cloudinary', { publicId, mediaId: media.id })
}
}
} else if (media.url.includes('/local-uploads/')) {
// Local storage deletion
deleted = await deleteFileLocally(media.url)
if (!deleted) {
logger.warn('Failed to delete from local storage', { url: media.url, mediaId: media.id })
}
}
// Also try to delete thumbnail if it exists
if (media.thumbnailUrl) {
if (media.thumbnailUrl.includes('cloudinary.com')) {
const thumbPublicId = extractPublicId(media.thumbnailUrl)
if (thumbPublicId && thumbPublicId !== extractPublicId(media.url)) {
await deleteFile(thumbPublicId)
}
}
}
storageDeleteResults.push({
id: media.id,
filename: media.filename,
deleted
})
} catch (error) {
logger.error('Error deleting file from storage', {
mediaId: media.id,
url: media.url,
error: error instanceof Error ? error.message : 'Unknown error'
})
storageDeleteResults.push({
id: media.id,
filename: media.filename,
deleted: false,
error: error instanceof Error ? error.message : 'Unknown error'
})
}
}
// Remove media usage tracking for all affected media
for (const mediaId of mediaIds) {
await prisma.mediaUsage.deleteMany({
@ -52,16 +113,24 @@ export const DELETE: RequestHandler = async (event) => {
where: { id: { in: mediaIds } }
})
// Count successful storage deletions
const successfulStorageDeletions = storageDeleteResults.filter(r => r.deleted).length
const failedStorageDeletions = storageDeleteResults.filter(r => !r.deleted)
logger.info('Bulk media deletion completed', {
deletedCount: deleteResult.count,
storageDeletedCount: successfulStorageDeletions,
storageFailedCount: failedStorageDeletions.length,
mediaIds,
filenames: mediaRecords.map((m) => m.filename)
})
return jsonResponse({
success: true,
message: `Successfully deleted ${deleteResult.count} media file${deleteResult.count > 1 ? 's' : ''}`,
message: `Successfully deleted ${deleteResult.count} media file${deleteResult.count > 1 ? 's' : ''} from database`,
deletedCount: deleteResult.count,
storageDeletedCount: successfulStorageDeletions,
storageFailures: failedStorageDeletions.length > 0 ? failedStorageDeletions : undefined,
deletedFiles: mediaRecords.map((m) => ({ id: m.id, filename: m.filename }))
})
} catch (error) {

View file

@ -22,6 +22,9 @@ export const GET: RequestHandler = async (event) => {
const { page, limit } = getPaginationParams(event.url)
const skip = (page - 1) * limit
// Check if admin is authenticated
const isAdmin = checkAdminAuth(event)
// Get filter parameters
const status = event.url.searchParams.get('status')
const projectType = event.url.searchParams.get('projectType')
@ -34,8 +37,8 @@ export const GET: RequestHandler = async (event) => {
if (status) {
where.status = status
} else {
// Default behavior: determine which statuses to include
} else if (!isAdmin) {
// For non-admin users: only show published projects by default
const allowedStatuses = ['published']
if (includeListOnly) {
@ -48,6 +51,7 @@ export const GET: RequestHandler = async (event) => {
where.status = { in: allowedStatuses }
}
// For admin users: show all projects (no status filter applied)
if (projectType) {
where.projectType = projectType

View file

@ -76,23 +76,23 @@ export const PUT: RequestHandler = async (event) => {
where: { id },
data: {
slug,
title: body.title ?? existing.title,
subtitle: body.subtitle ?? existing.subtitle,
description: body.description ?? existing.description,
year: body.year ?? existing.year,
client: body.client ?? existing.client,
role: body.role ?? existing.role,
featuredImage: body.featuredImage ?? existing.featuredImage,
logoUrl: body.logoUrl ?? existing.logoUrl,
gallery: body.gallery ?? existing.gallery,
externalUrl: body.externalUrl ?? existing.externalUrl,
caseStudyContent: body.caseStudyContent ?? existing.caseStudyContent,
backgroundColor: body.backgroundColor ?? existing.backgroundColor,
highlightColor: body.highlightColor ?? existing.highlightColor,
projectType: body.projectType ?? existing.projectType,
displayOrder: body.displayOrder ?? existing.displayOrder,
status: body.status ?? existing.status,
password: body.password ?? existing.password,
title: body.title !== undefined ? body.title : existing.title,
subtitle: body.subtitle !== undefined ? body.subtitle : existing.subtitle,
description: body.description !== undefined ? body.description : existing.description,
year: body.year !== undefined ? body.year : existing.year,
client: body.client !== undefined ? body.client : existing.client,
role: body.role !== undefined ? body.role : existing.role,
featuredImage: body.featuredImage !== undefined ? body.featuredImage : existing.featuredImage,
logoUrl: body.logoUrl !== undefined ? body.logoUrl : existing.logoUrl,
gallery: body.gallery !== undefined ? body.gallery : existing.gallery,
externalUrl: body.externalUrl !== undefined ? body.externalUrl : existing.externalUrl,
caseStudyContent: body.caseStudyContent !== undefined ? body.caseStudyContent : existing.caseStudyContent,
backgroundColor: body.backgroundColor !== undefined ? body.backgroundColor : existing.backgroundColor,
highlightColor: body.highlightColor !== undefined ? body.highlightColor : existing.highlightColor,
projectType: body.projectType !== undefined ? body.projectType : existing.projectType,
displayOrder: body.displayOrder !== undefined ? body.displayOrder : existing.displayOrder,
status: body.status !== undefined ? body.status : existing.status,
password: body.password !== undefined ? body.password : existing.password,
publishedAt:
body.status === 'published' && !existing.publishedAt ? new Date() : existing.publishedAt
}
@ -151,7 +151,7 @@ export const PUT: RequestHandler = async (event) => {
})
if (usageReferences.length > 0) {
await trackMediaUsage(usageReferences)
await updateMediaUsage(usageReferences)
}
} catch (error) {
logger.warn('Failed to update media usage tracking for project', { projectId: id, error })
@ -166,6 +166,87 @@ export const PUT: RequestHandler = async (event) => {
}
}
// PATCH /api/projects/[id] - Partially update a project
export const PATCH: RequestHandler = async (event) => {
// Check authentication
if (!checkAdminAuth(event)) {
return errorResponse('Unauthorized', 401)
}
const id = parseInt(event.params.id)
if (isNaN(id)) {
return errorResponse('Invalid project ID', 400)
}
try {
const body = await parseRequestBody<any>(event.request)
if (!body) {
return errorResponse('Invalid request body', 400)
}
// Check if project exists
const existing = await prisma.project.findUnique({
where: { id }
})
if (!existing) {
return errorResponse('Project not found', 404)
}
// Build update data object with only provided fields
const updateData: any = {}
// Handle status update specially
if (body.status !== undefined) {
updateData.status = body.status
// Set publishedAt if changing to published for the first time
if (body.status === 'published' && !existing.publishedAt) {
updateData.publishedAt = new Date()
}
// Clear publishedAt if changing to draft
else if (body.status === 'draft') {
updateData.publishedAt = null
}
}
// Add other fields if provided
if (body.title !== undefined) updateData.title = body.title
if (body.subtitle !== undefined) updateData.subtitle = body.subtitle
if (body.description !== undefined) updateData.description = body.description
if (body.year !== undefined) updateData.year = body.year
if (body.client !== undefined) updateData.client = body.client
if (body.role !== undefined) updateData.role = body.role
if (body.featuredImage !== undefined) updateData.featuredImage = body.featuredImage
if (body.logoUrl !== undefined) updateData.logoUrl = body.logoUrl
if (body.gallery !== undefined) updateData.gallery = body.gallery
if (body.externalUrl !== undefined) updateData.externalUrl = body.externalUrl
if (body.caseStudyContent !== undefined) updateData.caseStudyContent = body.caseStudyContent
if (body.backgroundColor !== undefined) updateData.backgroundColor = body.backgroundColor
if (body.highlightColor !== undefined) updateData.highlightColor = body.highlightColor
if (body.projectType !== undefined) updateData.projectType = body.projectType
if (body.displayOrder !== undefined) updateData.displayOrder = body.displayOrder
if (body.password !== undefined) updateData.password = body.password
// Handle slug update if provided
if (body.slug && body.slug !== existing.slug) {
updateData.slug = await ensureUniqueSlug(body.slug, 'project', id)
}
// Update project
const project = await prisma.project.update({
where: { id },
data: updateData
})
logger.info('Project partially updated', { id: project.id, fields: Object.keys(updateData) })
return jsonResponse(project)
} catch (error) {
logger.error('Failed to update project', error as Error)
return errorResponse('Failed to update project', 500)
}
}
// DELETE /api/projects/[id] - Delete a project
export const DELETE: RequestHandler = async (event) => {
// Check authentication

View file

@ -10,14 +10,18 @@
<div class="labs-container">
{#if error}
<div class="error-message">
<h2>Unable to load projects</h2>
<p>{error}</p>
<div class="error-container">
<div class="error-message">
<h2>Unable to load projects</h2>
<p>{error}</p>
</div>
</div>
{:else if projects.length === 0}
<div class="empty-message">
<h2>No projects yet</h2>
<p>Labs projects will appear here once published.</p>
<div class="empty-container">
<div class="empty-message">
<h2>No projects yet</h2>
<p>Projects will be added to Labs soon</p>
</div>
</div>
{:else}
<div class="projects-grid">
@ -35,7 +39,8 @@
padding: 0 $unit-2x;
@include breakpoint('phone') {
padding: $unit-3x $unit;
padding: 0 $unit-2x;
box-sizing: border-box;
}
}
@ -49,10 +54,18 @@
}
}
.error-container,
.empty-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
}
.error-message,
.empty-message {
text-align: center;
padding: $unit-6x $unit-3x;
max-width: 500px;
h2 {
font-size: 1.5rem;
@ -68,7 +81,9 @@
}
}
.error-message h2 {
color: $red-60;
.error-message {
h2 {
color: $red-60;
}
}
</style>

View file

@ -1,6 +1,8 @@
<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 type { PageData } from './$types'
import type { Project } from '$lib/types/project'
@ -12,74 +14,65 @@
</script>
{#if error}
<Page>
<div slot="header" class="error-header">
<h1>Error</h1>
</div>
<div class="error-content">
<p>{error}</p>
<a href="/labs" class="back-link">← Back to labs</a>
</div>
</Page>
<div class="error-wrapper">
<Page>
<div class="error-content">
<p>{error}</p>
<BackButton href="/labs" label="Back to Labs" />
</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>
{#snippet header()}
<div class="error-header">
<h1>Project Not Available</h1>
</div>
{/snippet}
<div class="error-content">
<p>This project is not yet available for viewing. Please check back later.</p>
<a href="/labs" class="back-link">← Back to labs</a>
<BackButton href="/labs" label="Back to Labs" />
</div>
</Page>
{:else if project.status === 'password-protected'}
<Page>
<ProjectPasswordProtection
projectSlug={project.slug}
correctPassword={project.password || ''}
projectType="labs"
>
{#snippet children()}
<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}
{:else if project.status === 'password-protected' || project.status === 'published'}
<div class="project-wrapper">
<Page>
{#snippet header()}
<div class="project-header">
<ProjectHeaderContent {project} />
</div>
<ProjectContent {project} />
{/snippet}
</ProjectPasswordProtection>
</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 project.status === 'password-protected'}
<ProjectPasswordProtection
projectSlug={project.slug}
correctPassword={project.password || ''}
projectType="labs"
>
{#snippet children()}
<ProjectContent {project} />
{/snippet}
</ProjectPasswordProtection>
{:else}
<ProjectContent {project} />
{/if}
<h1 class="project-title">{project.title}</h1>
{#if project.subtitle}
<p class="project-subtitle">{project.subtitle}</p>
{/if}
</div>
<ProjectContent {project} />
</Page>
</Page>
</div>
{/if}
<style lang="scss">
/* Error and Loading States */
.error-wrapper {
width: 100%;
max-width: 700px;
margin: 0 auto;
padding: 0 $unit-2x;
box-sizing: border-box;
}
.error-header h1 {
color: $red-60;
font-size: 2rem;
@ -101,59 +94,20 @@
padding: $unit-4x;
}
.back-link {
color: $grey-40;
text-decoration: none;
font-size: 0.925rem;
transition: color 0.2s ease;
/* Project Wrapper */
.project-wrapper {
width: 100%;
max-width: 700px;
margin: 0 auto;
box-sizing: border-box;
&:hover {
color: $grey-20;
@include breakpoint('phone') {
padding: 0 $unit-2x;
}
}
/* 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;
}
}
</style>

View file

@ -8,7 +8,7 @@
const error = $derived(data.error)
</script>
<div class="photos-page">
<div class="photos-container">
{#if error}
<div class="error-container">
<div class="error-message">
@ -20,7 +20,7 @@
<div class="empty-container">
<div class="empty-message">
<h2>No photos yet</h2>
<p>Photography albums will appear here once published.</p>
<p>Photos and albums will be added soon</p>
</div>
</div>
{:else}
@ -29,14 +29,15 @@
</div>
<style lang="scss">
.photos-page {
.photos-container {
width: 100%;
max-width: 900px;
max-width: 700px;
margin: 0 auto;
padding: 0 $unit-3x;
@include breakpoint('phone') {
padding: $unit-3x $unit-2x;
padding: 0 $unit-2x;
box-sizing: border-box;
}
}

View file

@ -1,4 +1,5 @@
<script lang="ts">
import BackButton from '$components/BackButton.svelte'
import type { PageData } from './$types'
let { data }: { data: PageData } = $props()
@ -84,7 +85,7 @@
<div class="error-content">
<h1>Photo Not Found</h1>
<p>{error || "The photo you're looking for doesn't exist."}</p>
<a href="/photos" class="back-link">← Back to Photos</a>
<BackButton href="/photos" label="Back to Photos" />
</div>
</div>
{:else}
@ -265,17 +266,6 @@
}
}
.back-link {
color: $grey-40;
text-decoration: none;
font-size: 0.925rem;
transition: color 0.2s ease;
&:hover {
color: $grey-20;
}
}
.photo-page {
min-height: 100vh;
display: grid;
@ -445,7 +435,7 @@
}
.photo-caption {
font-size: 1.125rem;
font-size: 1rem;
color: $grey-20;
margin: 0 0 $unit-3x;
line-height: 1.5;

View file

@ -1,5 +1,6 @@
<script lang="ts">
import PhotoGrid from '$components/PhotoGrid.svelte'
import BackButton from '$components/BackButton.svelte'
import type { PageData } from './$types'
let { data }: { data: PageData } = $props()
@ -43,7 +44,7 @@
<div class="error-message">
<h1>Album Not Found</h1>
<p>{error}</p>
<a href="/photos" class="back-link">← Back to Photos</a>
<BackButton href="/photos" label="Back to Photos" />
</div>
</div>
{:else if album}
@ -107,17 +108,6 @@
}
}
.back-link {
color: $grey-40;
text-decoration: none;
font-size: 0.925rem;
transition: color 0.2s ease;
&:hover {
color: $grey-20;
}
}
.album-page {
width: 100%;
max-width: 900px;
@ -158,7 +148,7 @@
}
.album-description {
font-size: 1.125rem;
font-size: 1rem;
color: $grey-30;
margin: 0 0 $unit-4x;
line-height: 1.5;

View file

@ -24,17 +24,7 @@
.universe-container {
max-width: 784px;
margin: 0 auto;
padding: 0 $unit-5x;
@include breakpoint('phone') {
margin-top: $unit-3x;
margin-bottom: $unit-3x;
padding: 0 $unit-3x;
}
@include breakpoint('small-phone') {
padding: 0 $unit-2x;
}
padding: 0;
}
.error-message {
@ -47,7 +37,7 @@
p {
margin: 0;
font-size: 1.125rem;
font-size: 1rem;
}
}
</style>

View file

@ -1,9 +1,8 @@
<script lang="ts">
import Page from '$components/Page.svelte'
import BackButton from '$components/BackButton.svelte'
import DynamicPostContent from '$components/DynamicPostContent.svelte'
import { getContentExcerpt } from '$lib/utils/content'
import { goto } from '$app/navigation'
import ArrowLeft from '$icons/arrow-left.svg'
import type { PageData } from './$types'
let { data }: { data: PageData } = $props()
@ -39,26 +38,29 @@
{/if}
</svelte:head>
{#if error || !post}
<Page>
<div class="error-container">
<div class="error-content">
<h1>Post Not Found</h1>
<p>{error || "The post you're looking for doesn't exist."}</p>
<button onclick={() => goto('/universe')} class="back-button">
<ArrowLeft class="back-arrow" />
Back to Universe
</button>
<div class="universe-page-container">
{#if error || !post}
<Page>
<div class="error-container">
<div class="error-content">
<p>{error || "The post you're looking for doesn't exist."}</p>
<BackButton href="/universe" label="Back to Universe" />
</div>
</div>
</div>
</Page>
{:else}
<Page>
<DynamicPostContent {post} />
</Page>
{/if}
</Page>
{:else}
<Page>
<DynamicPostContent {post} />
</Page>
{/if}
</div>
<style lang="scss">
.universe-page-container {
padding: 0 $unit-2x;
box-sizing: border-box;
}
.error-container {
display: flex;
justify-content: center;
@ -83,45 +85,5 @@
color: $grey-40;
line-height: 1.5;
}
.back-button {
color: $red-60;
background-color: transparent;
border: 1px solid transparent;
font: inherit;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
gap: $unit;
border-radius: 24px;
outline: none;
&:hover:not(:disabled) :global(.back-arrow) {
transform: translateX(-3px);
}
&:focus-visible {
box-shadow: 0 0 0 3px rgba($red-60, 0.25);
}
:global(.back-arrow) {
width: 16px;
height: 16px;
flex-shrink: 0;
transition: transform 0.2s ease;
margin-left: -$unit-half;
:global(path) {
stroke: currentColor;
stroke-width: 2.25;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
}
}
}
}
</style>

View file

@ -1,5 +1,6 @@
<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'
@ -50,15 +51,17 @@
</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>
<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>
@ -70,7 +73,7 @@
</div>
<div class="error-content">
<p>This project is not yet available for viewing. Please check back later.</p>
<a href="/" class="back-link">← Back to projects</a>
<BackButton href="/" label="Back to projects" />
</div>
</Page>
{:else if project.status === 'password-protected' || project.status === 'published'}
@ -95,9 +98,11 @@
{/if}
</div>
<Page>
<div slot="header" class="project-header">
<ProjectHeaderContent {project} />
</div>
{#snippet header()}
<div class="project-header">
<ProjectHeaderContent {project} />
</div>
{/snippet}
{#if project.status === 'password-protected'}
<ProjectPasswordProtection
projectSlug={project.slug}
@ -120,6 +125,14 @@
<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;
@ -141,25 +154,15 @@
padding: $unit-4x;
}
.back-link {
color: $grey-40;
text-decoration: none;
font-size: 0.925rem;
transition: color 0.2s ease;
&:hover {
color: $grey-20;
}
}
/* Project Wrapper */
.project-wrapper {
width: 100%;
max-width: 700px;
margin: 0 auto;
box-sizing: border-box;
@include breakpoint('phone') {
margin-top: $unit-3x;
padding: 0 $unit-2x;
}
:global(.page) {