Merge pull request #3 from jedmund/refine/polish
Refine Geo with mobile and admin fixes
This commit is contained in:
commit
e270eddb84
51 changed files with 1704 additions and 692 deletions
|
|
@ -3,7 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
<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:title" content="@jedmund" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:url" content="https://jedmund.com" />
|
<meta property="og:url" content="https://jedmund.com" />
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,20 @@ body {
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 1.4;
|
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
|
// Heading font weights
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,22 @@ $unit-16x: $unit * 16;
|
||||||
$unit-18x: $unit * 18;
|
$unit-18x: $unit * 18;
|
||||||
$unit-20x: $unit * 20;
|
$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 properties
|
||||||
* -------------------------------------------------------------------------- */
|
* -------------------------------------------------------------------------- */
|
||||||
$page-corner-radius: $unit;
|
$page-corner-radius: $corner-radius-md;
|
||||||
$image-corner-radius: $unit-2x;
|
$image-corner-radius: $corner-radius-2xl;
|
||||||
$card-corner-radius: $unit-3x;
|
$card-corner-radius: $corner-radius-3xl;
|
||||||
|
|
||||||
$page-top-margin: $unit-6x;
|
$page-top-margin: $unit-6x;
|
||||||
|
|
||||||
|
|
@ -109,6 +120,7 @@ $salmon-pink: #ffd5cf; // Desaturated salmon pink for hover states
|
||||||
$bg-color: #e8e8e8;
|
$bg-color: #e8e8e8;
|
||||||
$page-color: #ffffff;
|
$page-color: #ffffff;
|
||||||
$card-color: #f7f7f7;
|
$card-color: #f7f7f7;
|
||||||
|
$card-color-hover: #f0f0f0;
|
||||||
|
|
||||||
$text-color-light: #b2b2b2;
|
$text-color-light: #b2b2b2;
|
||||||
$text-color-body: #666666;
|
$text-color-body: #666666;
|
||||||
|
|
@ -149,6 +161,14 @@ $twitter-text-color: #0f5f9b;
|
||||||
$corner-radius: $unit-2x;
|
$corner-radius: $unit-2x;
|
||||||
$mobile-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 header
|
||||||
* -------------------------------------------------------------------------- */
|
* -------------------------------------------------------------------------- */
|
||||||
$avatar-radius: 2rem;
|
$avatar-radius: 2rem;
|
||||||
|
|
|
||||||
75
src/lib/components/BackButton.svelte
Normal file
75
src/lib/components/BackButton.svelte
Normal 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>
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import LinkCard from './LinkCard.svelte'
|
import LinkCard from './LinkCard.svelte'
|
||||||
import Slideshow from './Slideshow.svelte'
|
import Slideshow from './Slideshow.svelte'
|
||||||
|
import BackButton from './BackButton.svelte'
|
||||||
import { formatDate } from '$lib/utils/date'
|
import { formatDate } from '$lib/utils/date'
|
||||||
import { renderEdraContent } from '$lib/utils/content'
|
import { renderEdraContent } from '$lib/utils/content'
|
||||||
import { goto } from '$app/navigation'
|
|
||||||
import ArrowLeft from '$icons/arrow-left.svg'
|
|
||||||
|
|
||||||
let { post }: { post: any } = $props()
|
let { post }: { post: any } = $props()
|
||||||
|
|
||||||
|
|
@ -82,10 +81,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<footer class="post-footer">
|
<footer class="post-footer">
|
||||||
<button onclick={() => goto('/universe')} class="back-button">
|
<BackButton href="/universe" label="Back to Universe" />
|
||||||
<ArrowLeft class="back-arrow" />
|
|
||||||
Back to Universe
|
|
||||||
</button>
|
|
||||||
</footer>
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|
@ -96,23 +92,23 @@
|
||||||
max-width: 784px;
|
max-width: 784px;
|
||||||
gap: $unit-3x;
|
gap: $unit-3x;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 $unit-3x;
|
|
||||||
|
|
||||||
@include breakpoint('phone') {
|
@include breakpoint('phone') {
|
||||||
padding: 0 $unit-2x;
|
gap: $unit-2x;
|
||||||
|
padding: $unit-half 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post type styles
|
// Post type styles
|
||||||
&.post {
|
&.post {
|
||||||
.post-body {
|
.post-body {
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.essay {
|
&.essay {
|
||||||
.post-body {
|
.post-body {
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
line-height: 1.7;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -169,7 +165,7 @@
|
||||||
margin-bottom: $unit-4x;
|
margin-bottom: $unit-4x;
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0 0 $unit-2x;
|
margin: 0 0 $unit-2x;
|
||||||
color: $grey-20;
|
color: $grey-20;
|
||||||
|
|
@ -185,7 +181,7 @@
|
||||||
|
|
||||||
.album-description {
|
.album-description {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
color: $grey-10;
|
color: $grey-10;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
@ -218,7 +214,7 @@
|
||||||
|
|
||||||
:global(h4) {
|
:global(h4) {
|
||||||
margin: $unit-3x 0 $unit-2x;
|
margin: $unit-3x 0 $unit-2x;
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: $grey-10;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Avatar from './Avatar.svelte'
|
import Avatar from './Avatar.svelte'
|
||||||
import SegmentedController from './SegmentedController.svelte'
|
import SegmentedController from './SegmentedController.svelte'
|
||||||
|
import NavDropdown from './NavDropdown.svelte'
|
||||||
|
|
||||||
let scrollY = $state(0)
|
let scrollY = $state(0)
|
||||||
// Smooth gradient opacity from 0 to 1 over the first 100px of scroll
|
// Smooth gradient opacity from 0 to 1 over the first 100px of scroll
|
||||||
|
|
@ -39,8 +40,13 @@
|
||||||
<a href="/about" class="header-link" aria-label="@jedmund">
|
<a href="/about" class="header-link" aria-label="@jedmund">
|
||||||
<Avatar />
|
<Avatar />
|
||||||
</a>
|
</a>
|
||||||
|
<div class="nav-desktop">
|
||||||
<SegmentedController />
|
<SegmentedController />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav-mobile">
|
||||||
|
<NavDropdown />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|
@ -51,11 +57,15 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
// Smooth padding transition based on scroll
|
// 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;
|
pointer-events: none;
|
||||||
// Add a very subtle transition to smooth out any remaining jitter
|
// Add a very subtle transition to smooth out any remaining jitter
|
||||||
transition: padding 0.1s ease-out;
|
transition: padding 0.1s ease-out;
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
padding: calc($unit-3x - ($unit-3x - $unit-2x) * var(--padding-progress)) $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
@ -63,11 +73,7 @@
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 120px;
|
height: 120px;
|
||||||
background: linear-gradient(
|
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.15), transparent);
|
||||||
to bottom,
|
|
||||||
rgba(0, 0, 0, 0.15),
|
|
||||||
transparent
|
|
||||||
);
|
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
-webkit-backdrop-filter: blur(6px);
|
-webkit-backdrop-filter: blur(6px);
|
||||||
mask-image: linear-gradient(to bottom, black 0%, black 15%, transparent 90%);
|
mask-image: linear-gradient(to bottom, black 0%, black 15%, transparent 90%);
|
||||||
|
|
@ -83,8 +89,14 @@
|
||||||
.header-content {
|
.header-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: $unit-3x;
|
gap: $unit-3x;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
gap: $unit-2x;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-link {
|
.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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -40,13 +40,16 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isClickable}
|
{#if isClickable}
|
||||||
<a
|
<div
|
||||||
href={projectUrl}
|
|
||||||
class="lab-card clickable"
|
class="lab-card clickable"
|
||||||
bind:this={cardElement}
|
bind:this={cardElement}
|
||||||
onmousemove={handleMouseMove}
|
on:mousemove={handleMouseMove}
|
||||||
onmouseenter={handleMouseEnter}
|
on:mouseenter={handleMouseEnter}
|
||||||
onmouseleave={handleMouseLeave}
|
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
|
style:transform
|
||||||
>
|
>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
|
|
@ -62,9 +65,9 @@
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
iconPosition="right"
|
iconPosition="right"
|
||||||
onclick={(e) => e.stopPropagation()}
|
on:click={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
Visit site
|
Visit
|
||||||
<svg
|
<svg
|
||||||
slot="icon"
|
slot="icon"
|
||||||
width="16"
|
width="16"
|
||||||
|
|
@ -105,14 +108,14 @@
|
||||||
<span>Password Protected</span>
|
<span>Password Protected</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</a>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<article
|
<article
|
||||||
class="lab-card"
|
class="lab-card"
|
||||||
bind:this={cardElement}
|
bind:this={cardElement}
|
||||||
onmousemove={handleMouseMove}
|
on:mousemove={handleMouseMove}
|
||||||
onmouseenter={handleMouseEnter}
|
on:mouseenter={handleMouseEnter}
|
||||||
onmouseleave={handleMouseLeave}
|
on:mouseleave={handleMouseLeave}
|
||||||
style:transform
|
style:transform
|
||||||
>
|
>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
|
|
@ -187,14 +190,12 @@
|
||||||
transform-style: preserve-3d;
|
transform-style: preserve-3d;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
|
|
||||||
// Prevent overflow issues with 3D transforms
|
// Remove mask-image to allow shadows to render properly
|
||||||
-webkit-mask-image: -webkit-radial-gradient(white, black);
|
|
||||||
mask-image: radial-gradient(white, black);
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 10px 30px rgba(0, 0, 0, 0.12),
|
0 10px 30px rgba(0, 0, 0, 0.1),
|
||||||
0 2px 10px rgba(0, 0, 0, 0.08);
|
0 1px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
.project-title {
|
.project-title {
|
||||||
color: $red-60;
|
color: $red-60;
|
||||||
|
|
@ -203,6 +204,15 @@
|
||||||
|
|
||||||
&.clickable {
|
&.clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: 2px solid $red-60;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus:not(:focus-visible) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@include breakpoint('phone') {
|
@include breakpoint('phone') {
|
||||||
|
|
@ -224,6 +234,8 @@
|
||||||
:global(.btn) {
|
:global(.btn) {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-top: 2px; // Align with title baseline
|
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 {
|
.project-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: $grey-00;
|
color: $grey-00;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
|
|
||||||
@include breakpoint('phone') {
|
@include breakpoint('phone') {
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -254,7 +266,7 @@
|
||||||
|
|
||||||
.project-description {
|
.project-description {
|
||||||
margin: 0 0 $unit-3x 0;
|
margin: 0 0 $unit-3x 0;
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: $grey-20;
|
color: $grey-20;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,19 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
export let href = ''
|
interface Props {
|
||||||
export let title = ''
|
href?: string
|
||||||
export let sourceType = ''
|
title?: string
|
||||||
export let date = ''
|
sourceType?: string
|
||||||
export let source = ''
|
date?: string
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
href = '',
|
||||||
|
title = '',
|
||||||
|
sourceType = '',
|
||||||
|
date = '',
|
||||||
|
source = ''
|
||||||
|
}: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<li class="mention">
|
<li class="mention">
|
||||||
|
|
|
||||||
237
src/lib/components/NavDropdown.svelte
Normal file
237
src/lib/components/NavDropdown.svelte
Normal 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>
|
||||||
|
|
@ -1,13 +1,26 @@
|
||||||
<script lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
<section class="page" class:no-horizontal-padding={noHorizontalPadding}>
|
<section class="page {className}" class:no-horizontal-padding={noHorizontalPadding}>
|
||||||
|
{#if header}
|
||||||
<header>
|
<header>
|
||||||
<slot name="header" />
|
{@render header()}
|
||||||
</header>
|
</header>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<slot />
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import type { Post } from '$lib/posts'
|
import type { Post } from '$lib/posts'
|
||||||
import ImagePost from './ImagePost.svelte'
|
import ImagePost from './ImagePost.svelte'
|
||||||
import LinkCard from './LinkCard.svelte'
|
import LinkCard from './LinkCard.svelte'
|
||||||
|
import BackButton from './BackButton.svelte'
|
||||||
|
|
||||||
let { post }: { post: Post } = $props()
|
let { post }: { post: Post } = $props()
|
||||||
|
|
||||||
|
|
@ -42,7 +43,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="post-footer">
|
<footer class="post-footer">
|
||||||
<a href="/universe" class="back-link">← Back to all posts</a>
|
<BackButton href="/universe" label="Back to all posts" />
|
||||||
</footer>
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|
@ -54,13 +55,13 @@
|
||||||
// Post type styles for simplified post types
|
// Post type styles for simplified post types
|
||||||
&.post {
|
&.post {
|
||||||
.post-body {
|
.post-body {
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.essay {
|
&.essay {
|
||||||
.post-body {
|
.post-body {
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -69,13 +70,13 @@
|
||||||
&.note,
|
&.note,
|
||||||
&.microblog {
|
&.microblog {
|
||||||
.post-body {
|
.post-body {
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.blog {
|
&.blog {
|
||||||
.post-body {
|
.post-body {
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -201,17 +202,4 @@
|
||||||
padding-top: $unit-4x;
|
padding-top: $unit-4x;
|
||||||
border-top: 1px solid $grey-80;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@
|
||||||
|
|
||||||
&.note {
|
&.note {
|
||||||
.post-excerpt {
|
.post-excerpt {
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,7 +107,7 @@
|
||||||
.post-excerpt {
|
.post-excerpt {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: $grey-00;
|
color: $grey-00;
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Project } from '$lib/types/project'
|
import type { Project } from '$lib/types/project'
|
||||||
import ArrowLeft from '$icons/arrow-left.svg'
|
import BackButton from './BackButton.svelte'
|
||||||
import { goto } from '$app/navigation'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project
|
project: Project
|
||||||
|
|
@ -74,15 +73,9 @@
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<footer class="project-footer">
|
<footer class="project-footer">
|
||||||
{#if project.projectType === 'labs'}
|
{#if project.projectType === 'labs'}
|
||||||
<button onclick={() => goto('/labs')} class="back-button">
|
<BackButton href="/labs" label="Back to Labs" />
|
||||||
<ArrowLeft class="back-arrow" />
|
|
||||||
Back to labs
|
|
||||||
</button>
|
|
||||||
{:else}
|
{:else}
|
||||||
<button onclick={() => goto('/')} class="back-button">
|
<BackButton href="/" label="Back to projects" />
|
||||||
<ArrowLeft class="back-arrow" />
|
|
||||||
Back to projects
|
|
||||||
</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
</footer>
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
|
|
@ -118,12 +111,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(h3) {
|
:global(h3) {
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(p) {
|
:global(p) {
|
||||||
margin: $unit-2x 0;
|
margin: $unit-2x 0;
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: $grey-20;
|
color: $grey-20;
|
||||||
}
|
}
|
||||||
|
|
@ -178,44 +171,4 @@
|
||||||
.project-footer {
|
.project-footer {
|
||||||
padding-bottom: $unit-2x;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="visit-button"
|
class="visit-button"
|
||||||
style="--button-bg: {project.highlightColor || '#4d4d4d'}; --button-color: white"
|
style="--button-bg: {project.highlightColor || '#e33d3d'}; --button-color: white"
|
||||||
>
|
>
|
||||||
Visit <ArrowRight />
|
Visit <ArrowRight />
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -66,7 +66,7 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
@include breakpoint('phone') {
|
@include breakpoint('phone') {
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,7 +75,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $unit;
|
gap: $unit;
|
||||||
padding: ($unit * 1.5) $unit-2x;
|
padding: ($unit * 1.5) $unit-2x;
|
||||||
background: var(--button-bg, $grey-10);
|
background: var(--button-bg, $red-60);
|
||||||
color: var(--button-color, white);
|
color: var(--button-color, white);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 50px;
|
border-radius: 50px;
|
||||||
|
|
@ -107,7 +107,13 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@include breakpoint('phone') {
|
@include breakpoint('phone') {
|
||||||
align-self: flex-start;
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
:global(svg) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -285,7 +285,7 @@
|
||||||
|
|
||||||
.project-description {
|
.project-description {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.125rem; // 18px
|
font-size: 1rem; // 18px
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
color: $grey-00;
|
color: $grey-00;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,11 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -89,7 +94,7 @@
|
||||||
|
|
||||||
.intro-text {
|
.intro-text {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.125rem; // 18px
|
font-size: 1rem; // 18px
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
color: $grey-00;
|
color: $grey-00;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Button from '$lib/components/admin/Button.svelte'
|
import Button from '$lib/components/admin/Button.svelte'
|
||||||
|
import BackButton from './BackButton.svelte'
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -121,9 +122,9 @@
|
||||||
|
|
||||||
<div class="back-link-wrapper">
|
<div class="back-link-wrapper">
|
||||||
{#if projectType === 'labs'}
|
{#if projectType === 'labs'}
|
||||||
<a href="/labs" class="back-link">← Back to labs</a>
|
<BackButton href="/labs" label="Back to Labs" />
|
||||||
{:else}
|
{:else}
|
||||||
<a href="/" class="back-link">← Back to projects</a>
|
<BackButton href="/" label="Back to projects" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -163,7 +164,7 @@
|
||||||
color: $grey-40;
|
color: $grey-40;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,16 +237,5 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-link {
|
|
||||||
color: $grey-40;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.925rem;
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: $grey-20;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@
|
||||||
.album-description {
|
.album-description {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: $grey-10;
|
color: $grey-10;
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,11 @@
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="empty-state">
|
<div class="empty-container">
|
||||||
<p>No content found in the universe yet.</p>
|
<div class="empty-message">
|
||||||
|
<h2>No posts yet</h2>
|
||||||
|
<p>Posts will be added to Universe soon</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -26,17 +29,32 @@
|
||||||
.universe-feed {
|
.universe-feed {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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;
|
text-align: center;
|
||||||
padding: $unit-6x $unit-3x;
|
max-width: 500px;
|
||||||
color: $grey-40;
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 $unit-2x;
|
||||||
|
color: $grey-10;
|
||||||
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.125rem;
|
color: $grey-40;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: $grey-10;
|
color: $grey-10;
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
|
|
|
||||||
|
|
@ -236,6 +236,7 @@
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<Input
|
<Input
|
||||||
label="Album Title"
|
label="Album Title"
|
||||||
|
size="jumbo"
|
||||||
bind:value={title}
|
bind:value={title}
|
||||||
placeholder="Enter album title"
|
placeholder="Enter album title"
|
||||||
required={true}
|
required={true}
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: $unit;
|
gap: $unit;
|
||||||
font-weight: 500;
|
font-weight: 400;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
|
|
@ -211,7 +211,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-medium {
|
.btn-medium {
|
||||||
padding: $unit $unit-2x;
|
padding: ($unit * 1.5) $unit-2x;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
|
|
|
||||||
|
|
@ -261,7 +261,7 @@
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="form-section">
|
<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" />
|
<Input label="Slug" bind:value={slug} placeholder="essay-url-slug" />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,12 @@
|
||||||
{/if}
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{@render children?.()}
|
||||||
|
|
||||||
{#if helpText}
|
{#if helpText}
|
||||||
<p class="help-text">{helpText}</p>
|
<p class="help-text">{helpText}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{@render children?.()}
|
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="error-text">{error}</p>
|
<p class="error-text">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -39,7 +39,8 @@
|
||||||
|
|
||||||
&.has-error {
|
&.has-error {
|
||||||
:global(input),
|
:global(input),
|
||||||
:global(textarea) {
|
:global(textarea),
|
||||||
|
:global(select) {
|
||||||
border-color: #c33;
|
border-color: #c33;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
label: string
|
label: string
|
||||||
value?: Media | null
|
value?: Media | null
|
||||||
onUpload: (media: Media) => void
|
onUpload: (media: Media) => void
|
||||||
|
onRemove?: () => void
|
||||||
aspectRatio?: string // e.g., "16:9", "1:1"
|
aspectRatio?: string // e.g., "16:9", "1:1"
|
||||||
required?: boolean
|
required?: boolean
|
||||||
error?: string
|
error?: string
|
||||||
|
|
@ -26,6 +27,7 @@
|
||||||
label,
|
label,
|
||||||
value = $bindable(),
|
value = $bindable(),
|
||||||
onUpload,
|
onUpload,
|
||||||
|
onRemove,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
required = false,
|
required = false,
|
||||||
error,
|
error,
|
||||||
|
|
@ -182,6 +184,7 @@
|
||||||
altTextValue = ''
|
altTextValue = ''
|
||||||
descriptionValue = ''
|
descriptionValue = ''
|
||||||
uploadError = null
|
uploadError = null
|
||||||
|
onRemove?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update alt text on server
|
// Update alt text on server
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLInputAttributes, HTMLTextareaAttributes } from 'svelte/elements'
|
import type { HTMLInputAttributes } from 'svelte/elements'
|
||||||
|
|
||||||
// Type helpers for different input elements
|
type Props = HTMLInputAttributes & {
|
||||||
type InputProps = HTMLInputAttributes & {
|
|
||||||
type?:
|
type?:
|
||||||
| 'text'
|
| 'text'
|
||||||
| 'email'
|
| 'email'
|
||||||
|
|
@ -14,19 +13,10 @@
|
||||||
| 'date'
|
| 'date'
|
||||||
| 'time'
|
| 'time'
|
||||||
| 'color'
|
| 'color'
|
||||||
}
|
|
||||||
|
|
||||||
type TextareaProps = HTMLTextareaAttributes & {
|
|
||||||
type: 'textarea'
|
|
||||||
rows?: number
|
|
||||||
autoResize?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = (InputProps | TextareaProps) & {
|
|
||||||
label?: string
|
label?: string
|
||||||
error?: string
|
error?: string
|
||||||
helpText?: string
|
helpText?: string
|
||||||
size?: 'small' | 'medium' | 'large'
|
size?: 'small' | 'medium' | 'large' | 'jumbo'
|
||||||
pill?: boolean
|
pill?: boolean
|
||||||
fullWidth?: boolean
|
fullWidth?: boolean
|
||||||
required?: boolean
|
required?: boolean
|
||||||
|
|
@ -64,8 +54,6 @@
|
||||||
...restProps
|
...restProps
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
|
|
||||||
// For textarea auto-resize
|
|
||||||
let textareaElement: HTMLTextAreaElement | undefined = $state()
|
|
||||||
let charCount = $derived(String(value).length)
|
let charCount = $derived(String(value).length)
|
||||||
let charsRemaining = $derived(maxLength ? maxLength - charCount : 0)
|
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
|
// Compute classes
|
||||||
const wrapperClasses = $derived(() => {
|
const wrapperClasses = $derived(() => {
|
||||||
const classes = ['input-wrapper']
|
const classes = ['input-wrapper']
|
||||||
|
|
@ -112,8 +90,6 @@
|
||||||
if (prefixIcon) classes.push('has-prefix-icon')
|
if (prefixIcon) classes.push('has-prefix-icon')
|
||||||
if (suffixIcon) classes.push('has-suffix-icon')
|
if (suffixIcon) classes.push('has-suffix-icon')
|
||||||
if (colorSwatch) classes.push('has-color-swatch')
|
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 (wrapperClass) classes.push(wrapperClass)
|
||||||
if (className) classes.push(className)
|
if (className) classes.push(className)
|
||||||
return classes.join(' ')
|
return classes.join(' ')
|
||||||
|
|
@ -126,11 +102,6 @@
|
||||||
if (inputClass) classes.push(inputClass)
|
if (inputClass) classes.push(inputClass)
|
||||||
return classes.join(' ')
|
return classes.join(' ')
|
||||||
})
|
})
|
||||||
|
|
||||||
// Type guard for textarea props
|
|
||||||
function isTextarea(props: Props): props is TextareaProps {
|
|
||||||
return props.type === 'textarea'
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={wrapperClasses()}>
|
<div class={wrapperClasses()}>
|
||||||
|
|
@ -161,20 +132,6 @@
|
||||||
></span>
|
></span>
|
||||||
{/if}
|
{/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
|
<input
|
||||||
bind:value
|
bind:value
|
||||||
{id}
|
{id}
|
||||||
|
|
@ -186,7 +143,6 @@
|
||||||
class={inputClasses()}
|
class={inputClasses()}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if suffixIcon}
|
{#if suffixIcon}
|
||||||
<span class="input-icon suffix-icon">
|
<span class="input-icon suffix-icon">
|
||||||
|
|
@ -303,23 +259,27 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Input and textarea styles
|
// Input styles
|
||||||
.input {
|
.input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 14px;
|
border: 1px solid transparent;
|
||||||
border: 1px solid $grey-80;
|
color: $input-text-color;
|
||||||
border-radius: 6px;
|
background-color: $input-background-color;
|
||||||
background-color: white;
|
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $input-background-color-hover;
|
||||||
|
color: $input-text-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: $grey-50;
|
color: $grey-50;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: $primary-color;
|
background-color: $input-background-color-hover;
|
||||||
background-color: white;
|
color: $input-text-color-hover;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
|
|
@ -337,17 +297,23 @@
|
||||||
// Size variations
|
// Size variations
|
||||||
.input-small {
|
.input-small {
|
||||||
padding: $unit calc($unit * 1.5);
|
padding: $unit calc($unit * 1.5);
|
||||||
font-size: 13px;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-medium {
|
.input-medium {
|
||||||
padding: calc($unit * 1.5) $unit-2x;
|
padding: calc($unit * 1.5) $unit-2x;
|
||||||
font-size: 14px;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-large {
|
.input-large {
|
||||||
padding: $unit-2x $unit-3x;
|
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;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -362,17 +328,23 @@
|
||||||
&.input-large {
|
&.input-large {
|
||||||
border-radius: 28px;
|
border-radius: 28px;
|
||||||
}
|
}
|
||||||
|
&.input-jumbo {
|
||||||
|
border-radius: 32px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.input:not(.input-pill) {
|
.input:not(.input-pill) {
|
||||||
&.input-small {
|
&.input-small {
|
||||||
border-radius: 6px;
|
border-radius: $corner-radius-lg;
|
||||||
}
|
}
|
||||||
&.input-medium {
|
&.input-medium {
|
||||||
border-radius: 8px;
|
border-radius: $corner-radius-2xl;
|
||||||
}
|
}
|
||||||
&.input-large {
|
&.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
|
// Footer styles
|
||||||
.input-footer {
|
.input-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,27 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Input from './Input.svelte'
|
import Input from './Input.svelte'
|
||||||
import ImageUploader from './ImageUploader.svelte'
|
import ImageUploader from './ImageUploader.svelte'
|
||||||
|
import Button from './Button.svelte'
|
||||||
import type { ProjectFormData } from '$lib/types/project'
|
import type { ProjectFormData } from '$lib/types/project'
|
||||||
import type { Media } from '@prisma/client'
|
import type { Media } from '@prisma/client'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
formData: ProjectFormData
|
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
|
// Convert logoUrl string to Media object for ImageUploader
|
||||||
let logoMedia = $state<Media | null>(null)
|
let logoMedia = $state<Media | null>(null)
|
||||||
|
|
||||||
// Update logoMedia when logoUrl changes
|
// Update logoMedia when logoUrl changes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (formData.logoUrl && !logoMedia) {
|
if (formData.logoUrl && formData.logoUrl.trim() !== '' && !logoMedia) {
|
||||||
// Create a minimal Media object from the URL for display
|
// Create a minimal Media object from the URL for display
|
||||||
logoMedia = {
|
logoMedia = {
|
||||||
id: -1, // Temporary ID for existing URLs
|
id: -1, // Temporary ID for existing URLs
|
||||||
|
|
@ -33,8 +39,13 @@
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: 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,19 +54,53 @@
|
||||||
logoMedia = media
|
logoMedia = media
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogoRemove() {
|
async function handleLogoRemove() {
|
||||||
formData.logoUrl = ''
|
formData.logoUrl = ''
|
||||||
logoMedia = null
|
logoMedia = null
|
||||||
|
showLogoSection = false
|
||||||
|
|
||||||
|
// Auto-save the removal
|
||||||
|
if (onSave) {
|
||||||
|
await onSave()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h2>Branding</h2>
|
<h2>Branding</h2>
|
||||||
|
|
||||||
|
{#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
|
<ImageUploader
|
||||||
label="Project Logo"
|
label=""
|
||||||
value={logoMedia}
|
bind:value={logoMedia}
|
||||||
onUpload={handleLogoUpload}
|
onUpload={handleLogoUpload}
|
||||||
|
onRemove={handleLogoRemove}
|
||||||
aspectRatio="1:1"
|
aspectRatio="1:1"
|
||||||
allowAltText={true}
|
allowAltText={true}
|
||||||
maxFileSize={0.5}
|
maxFileSize={0.5}
|
||||||
|
|
@ -65,6 +110,32 @@
|
||||||
compact={true}
|
compact={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<style lang="scss">
|
||||||
.form-section {
|
.form-section {
|
||||||
|
|
@ -81,4 +152,36 @@
|
||||||
color: $grey-10;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,7 @@
|
||||||
import Editor from './Editor.svelte'
|
import Editor from './Editor.svelte'
|
||||||
import ProjectMetadataForm from './ProjectMetadataForm.svelte'
|
import ProjectMetadataForm from './ProjectMetadataForm.svelte'
|
||||||
import ProjectBrandingForm from './ProjectBrandingForm.svelte'
|
import ProjectBrandingForm from './ProjectBrandingForm.svelte'
|
||||||
import ProjectGalleryForm from './ProjectGalleryForm.svelte'
|
import ProjectImagesForm from './ProjectImagesForm.svelte'
|
||||||
import ProjectStylingForm from './ProjectStylingForm.svelte'
|
|
||||||
import Button from './Button.svelte'
|
import Button from './Button.svelte'
|
||||||
import StatusDropdown from './StatusDropdown.svelte'
|
import StatusDropdown from './StatusDropdown.svelte'
|
||||||
import { projectSchema } from '$lib/schemas/project'
|
import { projectSchema } from '$lib/schemas/project'
|
||||||
|
|
@ -60,11 +59,10 @@
|
||||||
role: data.role || '',
|
role: data.role || '',
|
||||||
projectType: data.projectType || 'work',
|
projectType: data.projectType || 'work',
|
||||||
externalUrl: data.externalUrl || '',
|
externalUrl: data.externalUrl || '',
|
||||||
featuredImage: data.featuredImage || null,
|
featuredImage: data.featuredImage && data.featuredImage.trim() !== '' ? data.featuredImage : null,
|
||||||
backgroundColor: data.backgroundColor || '',
|
backgroundColor: data.backgroundColor || '',
|
||||||
highlightColor: data.highlightColor || '',
|
highlightColor: data.highlightColor || '',
|
||||||
logoUrl: data.logoUrl || '',
|
logoUrl: data.logoUrl && data.logoUrl.trim() !== '' ? data.logoUrl : '',
|
||||||
gallery: data.gallery || null,
|
|
||||||
status: data.status || 'draft',
|
status: data.status || 'draft',
|
||||||
password: data.password || '',
|
password: data.password || '',
|
||||||
caseStudyContent: data.caseStudyContent || {
|
caseStudyContent: data.caseStudyContent || {
|
||||||
|
|
@ -142,9 +140,8 @@
|
||||||
role: formData.role,
|
role: formData.role,
|
||||||
projectType: formData.projectType,
|
projectType: formData.projectType,
|
||||||
externalUrl: formData.externalUrl,
|
externalUrl: formData.externalUrl,
|
||||||
featuredImage: formData.featuredImage,
|
featuredImage: formData.featuredImage && formData.featuredImage !== '' ? formData.featuredImage : null,
|
||||||
logoUrl: formData.logoUrl,
|
logoUrl: formData.logoUrl && formData.logoUrl !== '' ? formData.logoUrl : null,
|
||||||
gallery: formData.gallery && formData.gallery.length > 0 ? formData.gallery : null,
|
|
||||||
backgroundColor: formData.backgroundColor,
|
backgroundColor: formData.backgroundColor,
|
||||||
highlightColor: formData.highlightColor,
|
highlightColor: formData.highlightColor,
|
||||||
status: formData.status,
|
status: formData.status,
|
||||||
|
|
@ -266,10 +263,9 @@
|
||||||
handleSave()
|
handleSave()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ProjectMetadataForm bind:formData {validationErrors} />
|
<ProjectMetadataForm bind:formData {validationErrors} onSave={handleSave} />
|
||||||
<ProjectBrandingForm bind:formData />
|
<ProjectBrandingForm bind:formData {validationErrors} onSave={handleSave} />
|
||||||
<ProjectGalleryForm bind:formData />
|
<ProjectImagesForm bind:formData {validationErrors} onSave={handleSave} />
|
||||||
<ProjectStylingForm bind:formData {validationErrors} />
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
138
src/lib/components/admin/ProjectImagesForm.svelte
Normal file
138
src/lib/components/admin/ProjectImagesForm.svelte
Normal 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>
|
||||||
|
|
@ -1,40 +1,49 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Input from './Input.svelte'
|
import Input from './Input.svelte'
|
||||||
import Select from './Select.svelte'
|
import Textarea from './Textarea.svelte'
|
||||||
import ImageUploader from './ImageUploader.svelte'
|
import SelectField from './SelectField.svelte'
|
||||||
|
import SegmentedControlField from './SegmentedControlField.svelte'
|
||||||
import type { ProjectFormData } from '$lib/types/project'
|
import type { ProjectFormData } from '$lib/types/project'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
formData: ProjectFormData
|
formData: ProjectFormData
|
||||||
validationErrors: Record<string, string>
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<Input
|
<Input
|
||||||
label="Title"
|
label="Title"
|
||||||
required
|
required
|
||||||
|
size="jumbo"
|
||||||
error={validationErrors.title}
|
error={validationErrors.title}
|
||||||
bind:value={formData.title}
|
bind:value={formData.title}
|
||||||
placeholder="Project title"
|
placeholder="Project title"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Textarea
|
||||||
type="textarea"
|
|
||||||
label="Description"
|
label="Description"
|
||||||
|
size="jumbo"
|
||||||
error={validationErrors.description}
|
error={validationErrors.description}
|
||||||
bind:value={formData.description}
|
bind:value={formData.description}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Short description for project cards"
|
placeholder="Short description for project cards"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Select
|
<Input
|
||||||
|
type="url"
|
||||||
|
label="External URL"
|
||||||
|
error={validationErrors.externalUrl}
|
||||||
|
bind:value={formData.externalUrl}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="form-row three-column">
|
||||||
|
<SegmentedControlField
|
||||||
label="Project Type"
|
label="Project Type"
|
||||||
bind:value={formData.projectType}
|
bind:value={formData.projectType}
|
||||||
error={validationErrors.projectType}
|
error={validationErrors.projectType}
|
||||||
|
|
@ -42,10 +51,8 @@
|
||||||
{ value: 'work', label: 'Work' },
|
{ value: 'work', label: 'Work' },
|
||||||
{ value: 'labs', label: 'Labs' }
|
{ value: 'labs', label: 'Labs' }
|
||||||
]}
|
]}
|
||||||
helpText="Choose whether this project appears in the Work tab or Labs tab"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
label="Year"
|
label="Year"
|
||||||
|
|
@ -64,35 +71,6 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</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'}
|
{#if formData.status === 'password-protected'}
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
|
|
@ -110,19 +88,30 @@
|
||||||
.form-section {
|
.form-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $unit-2x;
|
gap: $unit-4x;
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: $grey-10;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-row {
|
.form-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: $unit-2x;
|
gap: $unit-4x;
|
||||||
padding-bottom: $unit-3x;
|
padding-bottom: $unit-3x;
|
||||||
|
|
||||||
|
&.three-column {
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
@include breakpoint('phone') {
|
@include breakpoint('phone') {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
108
src/lib/components/admin/SegmentedControlField.svelte
Normal file
108
src/lib/components/admin/SegmentedControlField.svelte
Normal 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>
|
||||||
|
|
@ -49,6 +49,7 @@
|
||||||
.select-wrapper {
|
.select-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select {
|
.select {
|
||||||
|
|
@ -59,6 +60,7 @@
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
padding-right: 36px;
|
padding-right: 36px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
|
||||||
60
src/lib/components/admin/SelectField.svelte
Normal file
60
src/lib/components/admin/SelectField.svelte
Normal 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>
|
||||||
266
src/lib/components/admin/Textarea.svelte
Normal file
266
src/lib/components/admin/Textarea.svelte
Normal 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>
|
||||||
|
|
@ -131,8 +131,8 @@ input[type='checkbox'] {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-content: center;
|
place-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 1.125rem;
|
width: 1rem;
|
||||||
height: 1.125rem;
|
height: 1rem;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ export interface Project {
|
||||||
role: string | null
|
role: string | null
|
||||||
featuredImage: string | null
|
featuredImage: string | null
|
||||||
logoUrl: string | null
|
logoUrl: string | null
|
||||||
gallery: any[] | null
|
|
||||||
externalUrl: string | null
|
externalUrl: string | null
|
||||||
caseStudyContent: any | null
|
caseStudyContent: any | null
|
||||||
backgroundColor: string | null
|
backgroundColor: string | null
|
||||||
|
|
@ -39,7 +38,6 @@ export interface ProjectFormData {
|
||||||
backgroundColor: string
|
backgroundColor: string
|
||||||
highlightColor: string
|
highlightColor: string
|
||||||
logoUrl: string
|
logoUrl: string
|
||||||
gallery: any[] | null
|
|
||||||
status: ProjectStatus
|
status: ProjectStatus
|
||||||
password: string
|
password: string
|
||||||
caseStudyContent: any
|
caseStudyContent: any
|
||||||
|
|
@ -58,7 +56,6 @@ export const defaultProjectFormData: ProjectFormData = {
|
||||||
backgroundColor: '',
|
backgroundColor: '',
|
||||||
highlightColor: '',
|
highlightColor: '',
|
||||||
logoUrl: '',
|
logoUrl: '',
|
||||||
gallery: null,
|
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
password: '',
|
password: '',
|
||||||
caseStudyContent: {
|
caseStudyContent: {
|
||||||
|
|
|
||||||
|
|
@ -7,20 +7,23 @@
|
||||||
|
|
||||||
import type { PageData } from './$types'
|
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>
|
</script>
|
||||||
|
|
||||||
|
<section class="about-container">
|
||||||
<Page>
|
<Page>
|
||||||
<svelte:fragment slot="header">
|
{#snippet header()}
|
||||||
<h2>A little about me</h2>
|
<h2>A little about me</h2>
|
||||||
</svelte:fragment>
|
{/snippet}
|
||||||
|
|
||||||
<section class="bio">
|
<section class="bio">
|
||||||
<p>
|
<p>
|
||||||
Hello! My name is <em>Justin Edmund</em>. I'm a software designer and developer living in San
|
Hello! My name is <em>Justin Edmund</em>. I'm a software designer and developer living in
|
||||||
Francisco.
|
San Francisco.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Right now, I'm spending my free time building a hobby journaling app called <a
|
Right now, I'm spending my free time building a hobby journaling app called <a
|
||||||
|
|
@ -39,16 +42,16 @@
|
||||||
</section>
|
</section>
|
||||||
</Page>
|
</Page>
|
||||||
<Page>
|
<Page>
|
||||||
<svelte:fragment slot="header">
|
{#snippet header()}
|
||||||
<h2>Notable mentions</h2>
|
<h2>Notable mentions</h2>
|
||||||
</svelte:fragment>
|
{/snippet}
|
||||||
|
|
||||||
<MentionList />
|
<MentionList />
|
||||||
</Page>
|
</Page>
|
||||||
<Page noHorizontalPadding={true}>
|
<Page noHorizontalPadding={true}>
|
||||||
<svelte:fragment slot="header">
|
{#snippet header()}
|
||||||
<h2>Now playing</h2>
|
<h2>Now playing</h2>
|
||||||
</svelte:fragment>
|
{/snippet}
|
||||||
|
|
||||||
<RecentAlbums {albums} />
|
<RecentAlbums {albums} />
|
||||||
|
|
||||||
|
|
@ -64,8 +67,26 @@
|
||||||
{/if}
|
{/if}
|
||||||
</section> -->
|
</section> -->
|
||||||
</Page>
|
</Page>
|
||||||
|
</section>
|
||||||
|
|
||||||
<style lang="scss">
|
<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,
|
a,
|
||||||
em {
|
em {
|
||||||
color: $red-60;
|
color: $red-60;
|
||||||
|
|
@ -88,7 +109,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.bio {
|
.bio {
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: #333;
|
color: #333;
|
||||||
background: $grey-100;
|
background: $grey-100;
|
||||||
|
|
|
||||||
|
|
@ -134,15 +134,15 @@ export const PUT: RequestHandler = async (event) => {
|
||||||
const album = await prisma.album.update({
|
const album = await prisma.album.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
slug: body.slug ?? existing.slug,
|
slug: body.slug !== undefined ? body.slug : existing.slug,
|
||||||
title: body.title ?? existing.title,
|
title: body.title !== undefined ? body.title : existing.title,
|
||||||
description: body.description !== undefined ? body.description : existing.description,
|
description: body.description !== undefined ? body.description : existing.description,
|
||||||
date: body.date !== undefined ? (body.date ? new Date(body.date) : null) : existing.date,
|
date: body.date !== undefined ? (body.date ? new Date(body.date) : null) : existing.date,
|
||||||
location: body.location !== undefined ? body.location : existing.location,
|
location: body.location !== undefined ? body.location : existing.location,
|
||||||
coverPhotoId: body.coverPhotoId !== undefined ? body.coverPhotoId : existing.coverPhotoId,
|
coverPhotoId: body.coverPhotoId !== undefined ? body.coverPhotoId : existing.coverPhotoId,
|
||||||
isPhotography: body.isPhotography ?? existing.isPhotography,
|
isPhotography: body.isPhotography !== undefined ? body.isPhotography : existing.isPhotography,
|
||||||
status: body.status ?? existing.status,
|
status: body.status !== undefined ? body.status : existing.status,
|
||||||
showInUniverse: body.showInUniverse ?? existing.showInUniverse
|
showInUniverse: body.showInUniverse !== undefined ? body.showInUniverse : existing.showInUniverse
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||||
// const albums = await getWeeklyAlbumChart(client, USERNAME)
|
// const albums = await getWeeklyAlbumChart(client, USERNAME)
|
||||||
|
|
||||||
const albums = await getRecentAlbums(client, USERNAME, ALBUM_LIMIT)
|
const albums = await getRecentAlbums(client, USERNAME, ALBUM_LIMIT)
|
||||||
console.log(albums)
|
// console.log(albums)
|
||||||
const enrichedAlbums = await Promise.all(
|
const enrichedAlbums = await Promise.all(
|
||||||
albums.slice(0, ALBUM_LIMIT).map(async (album) => {
|
albums.slice(0, ALBUM_LIMIT).map(async (album) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -68,9 +68,9 @@ export const PUT: RequestHandler = async (event) => {
|
||||||
const media = await prisma.media.update({
|
const media = await prisma.media.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
altText: body.altText ?? existing.altText,
|
altText: body.altText !== undefined ? body.altText : existing.altText,
|
||||||
description: body.description ?? existing.description,
|
description: body.description !== undefined ? body.description : existing.description,
|
||||||
isPhotography: body.isPhotography ?? existing.isPhotography
|
isPhotography: body.isPhotography !== undefined ? body.isPhotography : existing.isPhotography
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import {
|
||||||
} from '$lib/server/api-utils'
|
} from '$lib/server/api-utils'
|
||||||
import { logger } from '$lib/server/logger'
|
import { logger } from '$lib/server/logger'
|
||||||
import { removeMediaUsage, extractMediaIds } from '$lib/server/media-usage.js'
|
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
|
// DELETE /api/media/bulk-delete - Delete multiple media files and clean up references
|
||||||
export const DELETE: RequestHandler = async (event) => {
|
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)
|
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
|
// Remove media usage tracking for all affected media
|
||||||
for (const mediaId of mediaIds) {
|
for (const mediaId of mediaIds) {
|
||||||
await prisma.mediaUsage.deleteMany({
|
await prisma.mediaUsage.deleteMany({
|
||||||
|
|
@ -52,16 +113,24 @@ export const DELETE: RequestHandler = async (event) => {
|
||||||
where: { id: { in: mediaIds } }
|
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', {
|
logger.info('Bulk media deletion completed', {
|
||||||
deletedCount: deleteResult.count,
|
deletedCount: deleteResult.count,
|
||||||
|
storageDeletedCount: successfulStorageDeletions,
|
||||||
|
storageFailedCount: failedStorageDeletions.length,
|
||||||
mediaIds,
|
mediaIds,
|
||||||
filenames: mediaRecords.map((m) => m.filename)
|
filenames: mediaRecords.map((m) => m.filename)
|
||||||
})
|
})
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
success: true,
|
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,
|
deletedCount: deleteResult.count,
|
||||||
|
storageDeletedCount: successfulStorageDeletions,
|
||||||
|
storageFailures: failedStorageDeletions.length > 0 ? failedStorageDeletions : undefined,
|
||||||
deletedFiles: mediaRecords.map((m) => ({ id: m.id, filename: m.filename }))
|
deletedFiles: mediaRecords.map((m) => ({ id: m.id, filename: m.filename }))
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ export const GET: RequestHandler = async (event) => {
|
||||||
const { page, limit } = getPaginationParams(event.url)
|
const { page, limit } = getPaginationParams(event.url)
|
||||||
const skip = (page - 1) * limit
|
const skip = (page - 1) * limit
|
||||||
|
|
||||||
|
// Check if admin is authenticated
|
||||||
|
const isAdmin = checkAdminAuth(event)
|
||||||
|
|
||||||
// Get filter parameters
|
// Get filter parameters
|
||||||
const status = event.url.searchParams.get('status')
|
const status = event.url.searchParams.get('status')
|
||||||
const projectType = event.url.searchParams.get('projectType')
|
const projectType = event.url.searchParams.get('projectType')
|
||||||
|
|
@ -34,8 +37,8 @@ export const GET: RequestHandler = async (event) => {
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
where.status = status
|
where.status = status
|
||||||
} else {
|
} else if (!isAdmin) {
|
||||||
// Default behavior: determine which statuses to include
|
// For non-admin users: only show published projects by default
|
||||||
const allowedStatuses = ['published']
|
const allowedStatuses = ['published']
|
||||||
|
|
||||||
if (includeListOnly) {
|
if (includeListOnly) {
|
||||||
|
|
@ -48,6 +51,7 @@ export const GET: RequestHandler = async (event) => {
|
||||||
|
|
||||||
where.status = { in: allowedStatuses }
|
where.status = { in: allowedStatuses }
|
||||||
}
|
}
|
||||||
|
// For admin users: show all projects (no status filter applied)
|
||||||
|
|
||||||
if (projectType) {
|
if (projectType) {
|
||||||
where.projectType = projectType
|
where.projectType = projectType
|
||||||
|
|
|
||||||
|
|
@ -76,23 +76,23 @@ export const PUT: RequestHandler = async (event) => {
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
slug,
|
slug,
|
||||||
title: body.title ?? existing.title,
|
title: body.title !== undefined ? body.title : existing.title,
|
||||||
subtitle: body.subtitle ?? existing.subtitle,
|
subtitle: body.subtitle !== undefined ? body.subtitle : existing.subtitle,
|
||||||
description: body.description ?? existing.description,
|
description: body.description !== undefined ? body.description : existing.description,
|
||||||
year: body.year ?? existing.year,
|
year: body.year !== undefined ? body.year : existing.year,
|
||||||
client: body.client ?? existing.client,
|
client: body.client !== undefined ? body.client : existing.client,
|
||||||
role: body.role ?? existing.role,
|
role: body.role !== undefined ? body.role : existing.role,
|
||||||
featuredImage: body.featuredImage ?? existing.featuredImage,
|
featuredImage: body.featuredImage !== undefined ? body.featuredImage : existing.featuredImage,
|
||||||
logoUrl: body.logoUrl ?? existing.logoUrl,
|
logoUrl: body.logoUrl !== undefined ? body.logoUrl : existing.logoUrl,
|
||||||
gallery: body.gallery ?? existing.gallery,
|
gallery: body.gallery !== undefined ? body.gallery : existing.gallery,
|
||||||
externalUrl: body.externalUrl ?? existing.externalUrl,
|
externalUrl: body.externalUrl !== undefined ? body.externalUrl : existing.externalUrl,
|
||||||
caseStudyContent: body.caseStudyContent ?? existing.caseStudyContent,
|
caseStudyContent: body.caseStudyContent !== undefined ? body.caseStudyContent : existing.caseStudyContent,
|
||||||
backgroundColor: body.backgroundColor ?? existing.backgroundColor,
|
backgroundColor: body.backgroundColor !== undefined ? body.backgroundColor : existing.backgroundColor,
|
||||||
highlightColor: body.highlightColor ?? existing.highlightColor,
|
highlightColor: body.highlightColor !== undefined ? body.highlightColor : existing.highlightColor,
|
||||||
projectType: body.projectType ?? existing.projectType,
|
projectType: body.projectType !== undefined ? body.projectType : existing.projectType,
|
||||||
displayOrder: body.displayOrder ?? existing.displayOrder,
|
displayOrder: body.displayOrder !== undefined ? body.displayOrder : existing.displayOrder,
|
||||||
status: body.status ?? existing.status,
|
status: body.status !== undefined ? body.status : existing.status,
|
||||||
password: body.password ?? existing.password,
|
password: body.password !== undefined ? body.password : existing.password,
|
||||||
publishedAt:
|
publishedAt:
|
||||||
body.status === 'published' && !existing.publishedAt ? new Date() : existing.publishedAt
|
body.status === 'published' && !existing.publishedAt ? new Date() : existing.publishedAt
|
||||||
}
|
}
|
||||||
|
|
@ -151,7 +151,7 @@ export const PUT: RequestHandler = async (event) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
if (usageReferences.length > 0) {
|
if (usageReferences.length > 0) {
|
||||||
await trackMediaUsage(usageReferences)
|
await updateMediaUsage(usageReferences)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Failed to update media usage tracking for project', { projectId: id, 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
|
// DELETE /api/projects/[id] - Delete a project
|
||||||
export const DELETE: RequestHandler = async (event) => {
|
export const DELETE: RequestHandler = async (event) => {
|
||||||
// Check authentication
|
// Check authentication
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,18 @@
|
||||||
|
|
||||||
<div class="labs-container">
|
<div class="labs-container">
|
||||||
{#if error}
|
{#if error}
|
||||||
|
<div class="error-container">
|
||||||
<div class="error-message">
|
<div class="error-message">
|
||||||
<h2>Unable to load projects</h2>
|
<h2>Unable to load projects</h2>
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{:else if projects.length === 0}
|
{:else if projects.length === 0}
|
||||||
|
<div class="empty-container">
|
||||||
<div class="empty-message">
|
<div class="empty-message">
|
||||||
<h2>No projects yet</h2>
|
<h2>No projects yet</h2>
|
||||||
<p>Labs projects will appear here once published.</p>
|
<p>Projects will be added to Labs soon</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="projects-grid">
|
<div class="projects-grid">
|
||||||
|
|
@ -35,7 +39,8 @@
|
||||||
padding: 0 $unit-2x;
|
padding: 0 $unit-2x;
|
||||||
|
|
||||||
@include breakpoint('phone') {
|
@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,
|
.error-message,
|
||||||
.empty-message {
|
.empty-message {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: $unit-6x $unit-3x;
|
max-width: 500px;
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
|
|
@ -68,7 +81,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message h2 {
|
.error-message {
|
||||||
|
h2 {
|
||||||
color: $red-60;
|
color: $red-60;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Page from '$components/Page.svelte'
|
import Page from '$components/Page.svelte'
|
||||||
|
import BackButton from '$components/BackButton.svelte'
|
||||||
import ProjectPasswordProtection from '$lib/components/ProjectPasswordProtection.svelte'
|
import ProjectPasswordProtection from '$lib/components/ProjectPasswordProtection.svelte'
|
||||||
|
import ProjectHeaderContent from '$lib/components/ProjectHeaderContent.svelte'
|
||||||
import ProjectContent from '$lib/components/ProjectContent.svelte'
|
import ProjectContent from '$lib/components/ProjectContent.svelte'
|
||||||
import type { PageData } from './$types'
|
import type { PageData } from './$types'
|
||||||
import type { Project } from '$lib/types/project'
|
import type { Project } from '$lib/types/project'
|
||||||
|
|
@ -12,74 +14,65 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
|
<div class="error-wrapper">
|
||||||
<Page>
|
<Page>
|
||||||
<div slot="header" class="error-header">
|
|
||||||
<h1>Error</h1>
|
|
||||||
</div>
|
|
||||||
<div class="error-content">
|
<div class="error-content">
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
<a href="/labs" class="back-link">← Back to labs</a>
|
<BackButton href="/labs" label="Back to Labs" />
|
||||||
</div>
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
|
</div>
|
||||||
{:else if !project}
|
{:else if !project}
|
||||||
<Page>
|
<Page>
|
||||||
<div class="loading">Loading project...</div>
|
<div class="loading">Loading project...</div>
|
||||||
</Page>
|
</Page>
|
||||||
{:else if project.status === 'list-only'}
|
{:else if project.status === 'list-only'}
|
||||||
<Page>
|
<Page>
|
||||||
<div slot="header" class="error-header">
|
{#snippet header()}
|
||||||
|
<div class="error-header">
|
||||||
<h1>Project Not Available</h1>
|
<h1>Project Not Available</h1>
|
||||||
</div>
|
</div>
|
||||||
|
{/snippet}
|
||||||
<div class="error-content">
|
<div class="error-content">
|
||||||
<p>This project is not yet available for viewing. Please check back later.</p>
|
<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>
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
{:else if project.status === 'password-protected'}
|
{:else if project.status === 'password-protected' || project.status === 'published'}
|
||||||
|
<div class="project-wrapper">
|
||||||
<Page>
|
<Page>
|
||||||
|
{#snippet header()}
|
||||||
|
<div class="project-header">
|
||||||
|
<ProjectHeaderContent {project} />
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
{#if project.status === 'password-protected'}
|
||||||
<ProjectPasswordProtection
|
<ProjectPasswordProtection
|
||||||
projectSlug={project.slug}
|
projectSlug={project.slug}
|
||||||
correctPassword={project.password || ''}
|
correctPassword={project.password || ''}
|
||||||
projectType="labs"
|
projectType="labs"
|
||||||
>
|
>
|
||||||
{#snippet children()}
|
{#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}
|
|
||||||
</div>
|
|
||||||
<ProjectContent {project} />
|
<ProjectContent {project} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ProjectPasswordProtection>
|
</ProjectPasswordProtection>
|
||||||
</Page>
|
|
||||||
{:else}
|
{:else}
|
||||||
<Page>
|
|
||||||
<div slot="header" class="project-header">
|
|
||||||
{#if project.logoUrl}
|
|
||||||
<div class="project-logo" style="background-color: {project.backgroundColor || '#f5f5f5'}">
|
|
||||||
<img src={project.logoUrl} alt="{project.title} logo" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<h1 class="project-title">{project.title}</h1>
|
|
||||||
{#if project.subtitle}
|
|
||||||
<p class="project-subtitle">{project.subtitle}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<ProjectContent {project} />
|
<ProjectContent {project} />
|
||||||
|
{/if}
|
||||||
</Page>
|
</Page>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
/* Error and Loading States */
|
/* 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 {
|
.error-header h1 {
|
||||||
color: $red-60;
|
color: $red-60;
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
|
|
@ -101,59 +94,20 @@
|
||||||
padding: $unit-4x;
|
padding: $unit-4x;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-link {
|
/* Project Wrapper */
|
||||||
color: $grey-40;
|
.project-wrapper {
|
||||||
text-decoration: none;
|
width: 100%;
|
||||||
font-size: 0.925rem;
|
max-width: 700px;
|
||||||
transition: color 0.2s ease;
|
margin: 0 auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
&:hover {
|
@include breakpoint('phone') {
|
||||||
color: $grey-20;
|
padding: 0 $unit-2x;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Project Header */
|
/* Project Header */
|
||||||
.project-header {
|
.project-header {
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
const error = $derived(data.error)
|
const error = $derived(data.error)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="photos-page">
|
<div class="photos-container">
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="error-container">
|
<div class="error-container">
|
||||||
<div class="error-message">
|
<div class="error-message">
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
<div class="empty-container">
|
<div class="empty-container">
|
||||||
<div class="empty-message">
|
<div class="empty-message">
|
||||||
<h2>No photos yet</h2>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -29,14 +29,15 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.photos-page {
|
.photos-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 900px;
|
max-width: 700px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 $unit-3x;
|
padding: 0 $unit-3x;
|
||||||
|
|
||||||
@include breakpoint('phone') {
|
@include breakpoint('phone') {
|
||||||
padding: $unit-3x $unit-2x;
|
padding: 0 $unit-2x;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import BackButton from '$components/BackButton.svelte'
|
||||||
import type { PageData } from './$types'
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props()
|
let { data }: { data: PageData } = $props()
|
||||||
|
|
@ -84,7 +85,7 @@
|
||||||
<div class="error-content">
|
<div class="error-content">
|
||||||
<h1>Photo Not Found</h1>
|
<h1>Photo Not Found</h1>
|
||||||
<p>{error || "The photo you're looking for doesn't exist."}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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 {
|
.photo-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -445,7 +435,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.photo-caption {
|
.photo-caption {
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
color: $grey-20;
|
color: $grey-20;
|
||||||
margin: 0 0 $unit-3x;
|
margin: 0 0 $unit-3x;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PhotoGrid from '$components/PhotoGrid.svelte'
|
import PhotoGrid from '$components/PhotoGrid.svelte'
|
||||||
|
import BackButton from '$components/BackButton.svelte'
|
||||||
import type { PageData } from './$types'
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props()
|
let { data }: { data: PageData } = $props()
|
||||||
|
|
@ -43,7 +44,7 @@
|
||||||
<div class="error-message">
|
<div class="error-message">
|
||||||
<h1>Album Not Found</h1>
|
<h1>Album Not Found</h1>
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
<a href="/photos" class="back-link">← Back to Photos</a>
|
<BackButton href="/photos" label="Back to Photos" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if album}
|
{: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 {
|
.album-page {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
|
|
@ -158,7 +148,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.album-description {
|
.album-description {
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
color: $grey-30;
|
color: $grey-30;
|
||||||
margin: 0 0 $unit-4x;
|
margin: 0 0 $unit-4x;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
|
|
||||||
|
|
@ -24,17 +24,7 @@
|
||||||
.universe-container {
|
.universe-container {
|
||||||
max-width: 784px;
|
max-width: 784px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 $unit-5x;
|
padding: 0;
|
||||||
|
|
||||||
@include breakpoint('phone') {
|
|
||||||
margin-top: $unit-3x;
|
|
||||||
margin-bottom: $unit-3x;
|
|
||||||
padding: 0 $unit-3x;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include breakpoint('small-phone') {
|
|
||||||
padding: 0 $unit-2x;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
|
|
@ -47,7 +37,7 @@
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Page from '$components/Page.svelte'
|
import Page from '$components/Page.svelte'
|
||||||
|
import BackButton from '$components/BackButton.svelte'
|
||||||
import DynamicPostContent from '$components/DynamicPostContent.svelte'
|
import DynamicPostContent from '$components/DynamicPostContent.svelte'
|
||||||
import { getContentExcerpt } from '$lib/utils/content'
|
import { getContentExcerpt } from '$lib/utils/content'
|
||||||
import { goto } from '$app/navigation'
|
|
||||||
import ArrowLeft from '$icons/arrow-left.svg'
|
|
||||||
import type { PageData } from './$types'
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props()
|
let { data }: { data: PageData } = $props()
|
||||||
|
|
@ -39,16 +38,13 @@
|
||||||
{/if}
|
{/if}
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="universe-page-container">
|
||||||
{#if error || !post}
|
{#if error || !post}
|
||||||
<Page>
|
<Page>
|
||||||
<div class="error-container">
|
<div class="error-container">
|
||||||
<div class="error-content">
|
<div class="error-content">
|
||||||
<h1>Post Not Found</h1>
|
|
||||||
<p>{error || "The post you're looking for doesn't exist."}</p>
|
<p>{error || "The post you're looking for doesn't exist."}</p>
|
||||||
<button onclick={() => goto('/universe')} class="back-button">
|
<BackButton href="/universe" label="Back to Universe" />
|
||||||
<ArrowLeft class="back-arrow" />
|
|
||||||
Back to Universe
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
|
|
@ -57,8 +53,14 @@
|
||||||
<DynamicPostContent {post} />
|
<DynamicPostContent {post} />
|
||||||
</Page>
|
</Page>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
.universe-page-container {
|
||||||
|
padding: 0 $unit-2x;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
.error-container {
|
.error-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -83,45 +85,5 @@
|
||||||
color: $grey-40;
|
color: $grey-40;
|
||||||
line-height: 1.5;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Page from '$components/Page.svelte'
|
import Page from '$components/Page.svelte'
|
||||||
|
import BackButton from '$components/BackButton.svelte'
|
||||||
import ProjectPasswordProtection from '$lib/components/ProjectPasswordProtection.svelte'
|
import ProjectPasswordProtection from '$lib/components/ProjectPasswordProtection.svelte'
|
||||||
import ProjectHeaderContent from '$lib/components/ProjectHeaderContent.svelte'
|
import ProjectHeaderContent from '$lib/components/ProjectHeaderContent.svelte'
|
||||||
import ProjectContent from '$lib/components/ProjectContent.svelte'
|
import ProjectContent from '$lib/components/ProjectContent.svelte'
|
||||||
|
|
@ -50,15 +51,17 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
|
<div class="error-container">
|
||||||
<Page>
|
<Page>
|
||||||
<div slot="header" class="error-header">
|
<div slot="header" class="error-header">
|
||||||
<h1>Error</h1>
|
<h1>Error</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="error-content">
|
<div class="error-content">
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
<a href="/" class="back-link">← Back to home</a>
|
<BackButton href="/" label="Back to projects" />
|
||||||
</div>
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
|
</div>
|
||||||
{:else if !project}
|
{:else if !project}
|
||||||
<Page>
|
<Page>
|
||||||
<div class="loading">Loading project...</div>
|
<div class="loading">Loading project...</div>
|
||||||
|
|
@ -70,7 +73,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="error-content">
|
<div class="error-content">
|
||||||
<p>This project is not yet available for viewing. Please check back later.</p>
|
<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>
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
{:else if project.status === 'password-protected' || project.status === 'published'}
|
{:else if project.status === 'password-protected' || project.status === 'published'}
|
||||||
|
|
@ -95,9 +98,11 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<Page>
|
<Page>
|
||||||
<div slot="header" class="project-header">
|
{#snippet header()}
|
||||||
|
<div class="project-header">
|
||||||
<ProjectHeaderContent {project} />
|
<ProjectHeaderContent {project} />
|
||||||
</div>
|
</div>
|
||||||
|
{/snippet}
|
||||||
{#if project.status === 'password-protected'}
|
{#if project.status === 'password-protected'}
|
||||||
<ProjectPasswordProtection
|
<ProjectPasswordProtection
|
||||||
projectSlug={project.slug}
|
projectSlug={project.slug}
|
||||||
|
|
@ -120,6 +125,14 @@
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
/* Error and Loading States */
|
/* 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 {
|
.error-header h1 {
|
||||||
color: $red-60;
|
color: $red-60;
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
|
|
@ -141,25 +154,15 @@
|
||||||
padding: $unit-4x;
|
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 */
|
||||||
.project-wrapper {
|
.project-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 700px;
|
max-width: 700px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
@include breakpoint('phone') {
|
@include breakpoint('phone') {
|
||||||
margin-top: $unit-3x;
|
padding: 0 $unit-2x;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.page) {
|
:global(.page) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue