chore: remove dead code and unused files
Delete completely unused files: - album-stream.ts store (127 lines, never imported) - AdminSegmentedController + BaseSegmentedController (546 lines, superseded by AdminSegmentedControl) - AlbumMetadataPopover.svelte (never imported) - 5 test/demo pages in admin routes (buttons, inputs, *-test routes) Total cleanup: ~1,200+ lines of dead code removed
This commit is contained in:
parent
bc102fba0a
commit
d964bf05cd
9 changed files with 0 additions and 2010 deletions
|
|
@ -1,205 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores'
|
||||
import BaseSegmentedController from './BaseSegmentedController.svelte'
|
||||
import { clickOutside } from '$lib/actions/clickOutside'
|
||||
|
||||
const currentPath = $derived($page.url.pathname)
|
||||
|
||||
interface NavItem {
|
||||
value: string
|
||||
label: string
|
||||
href: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ value: 'dashboard', label: 'Dashboard', href: '/admin', icon: '📊' },
|
||||
{ value: 'projects', label: 'Projects', href: '/admin/projects', icon: '💼' },
|
||||
{ value: 'universe', label: 'Universe', href: '/admin/universe', icon: '🌟' },
|
||||
{ value: 'media', label: 'Media', href: '/admin/media', icon: '🖼️' }
|
||||
]
|
||||
|
||||
// Track dropdown state
|
||||
let showDropdown = $state(false)
|
||||
|
||||
// Calculate active value based on current path
|
||||
const activeValue = $derived(
|
||||
currentPath === '/admin'
|
||||
? 'dashboard'
|
||||
: currentPath.startsWith('/admin/projects')
|
||||
? 'projects'
|
||||
: currentPath.startsWith('/admin/posts') || currentPath.startsWith('/admin/universe')
|
||||
? 'universe'
|
||||
: currentPath.startsWith('/admin/media')
|
||||
? 'media'
|
||||
: ''
|
||||
)
|
||||
|
||||
function handleClickOutside() {
|
||||
showDropdown = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="admin-segmented-controller">
|
||||
<BaseSegmentedController
|
||||
items={navItems}
|
||||
value={activeValue}
|
||||
variant="navigation"
|
||||
pillColor="#e5e5e5"
|
||||
gap={4}
|
||||
containerPadding={0}
|
||||
class="admin-nav-pills"
|
||||
>
|
||||
{#snippet children({ item, isActive })}
|
||||
<span class="icon">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
{/snippet}
|
||||
</BaseSegmentedController>
|
||||
|
||||
<div
|
||||
class="dropdown-container"
|
||||
use:clickOutside={{ enabled: showDropdown }}
|
||||
onclickoutside={handleClickOutside}
|
||||
>
|
||||
<button
|
||||
class="dropdown-trigger"
|
||||
onclick={() => (showDropdown = !showDropdown)}
|
||||
aria-label="Menu"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" class:rotate={showDropdown}>
|
||||
<path
|
||||
d="M3 5L6 8L9 5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showDropdown}
|
||||
<div class="dropdown-menu">
|
||||
<form method="POST" action="/admin/logout">
|
||||
<button class="dropdown-item" type="submit">
|
||||
<span>Log out</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style lang="scss">
|
||||
.admin-segmented-controller {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
background: $gray-100;
|
||||
padding: $unit;
|
||||
border-radius: 100px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
:global(.admin-nav-pills) {
|
||||
flex: 1;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
:global(.segmented-pill) {
|
||||
background-color: $gray-85 !important;
|
||||
}
|
||||
|
||||
:global(.segmented-item) {
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
font-size: 1rem;
|
||||
color: $gray-20;
|
||||
|
||||
&:global(.active) {
|
||||
color: $gray-10;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dropdown-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: $gray-40;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
color: $gray-20;
|
||||
}
|
||||
|
||||
svg {
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&.rotate {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + $unit);
|
||||
right: 0;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
min-width: 150px;
|
||||
z-index: $z-index-modal;
|
||||
overflow: hidden;
|
||||
animation: slideDown 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: $unit-2x $unit-3x;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font-size: 0.925rem;
|
||||
color: $gray-20;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: $gray-95;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
<script lang="ts">
|
||||
import GenericMetadataPopover, { type MetadataConfig } from './GenericMetadataPopover.svelte'
|
||||
|
||||
type Props = {
|
||||
album: any
|
||||
triggerElement: HTMLElement
|
||||
onUpdate: (key: string, value: any) => void
|
||||
onDelete: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
album = $bindable(),
|
||||
triggerElement,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onClose = () => {}
|
||||
}: Props = $props()
|
||||
|
||||
// Convert album date to YYYY-MM-DD format for date input
|
||||
const albumDate = $derived(album.date ? new Date(album.date).toISOString().split('T')[0] : '')
|
||||
|
||||
// Handle date changes - convert back to ISO string
|
||||
function handleDateChange(key: string, value: string) {
|
||||
if (key === 'date') {
|
||||
const isoDate = value ? new Date(value).toISOString() : null
|
||||
onUpdate(key, isoDate)
|
||||
} else {
|
||||
onUpdate(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
const config: MetadataConfig = {
|
||||
title: 'Album Settings',
|
||||
fields: [
|
||||
{
|
||||
type: 'input',
|
||||
key: 'slug',
|
||||
label: 'Slug',
|
||||
placeholder: 'album-url-slug',
|
||||
helpText: 'Used in the album URL.'
|
||||
},
|
||||
{
|
||||
type: 'date',
|
||||
key: 'date',
|
||||
label: 'Date',
|
||||
helpText: 'When was this album created or photos taken?'
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
key: 'location',
|
||||
label: 'Location',
|
||||
placeholder: 'Location where photos were taken'
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
key: 'display-options',
|
||||
label: 'Display Options'
|
||||
},
|
||||
{
|
||||
type: 'toggle',
|
||||
key: 'isPhotography',
|
||||
label: 'Show in Photos',
|
||||
helpText: 'Show this album in the photography experience'
|
||||
},
|
||||
{
|
||||
type: 'toggle',
|
||||
key: 'showInUniverse',
|
||||
label: 'Show in Universe',
|
||||
helpText: 'Display this album in the Universe feed'
|
||||
},
|
||||
{
|
||||
type: 'metadata',
|
||||
key: 'metadata'
|
||||
}
|
||||
],
|
||||
deleteButton: {
|
||||
label: 'Delete Album',
|
||||
action: onDelete
|
||||
}
|
||||
}
|
||||
|
||||
// Create a reactive data object that includes the formatted date
|
||||
let popoverData = $state({
|
||||
...album,
|
||||
date: albumDate
|
||||
})
|
||||
|
||||
// Sync changes back to album
|
||||
$effect(() => {
|
||||
popoverData = {
|
||||
...album,
|
||||
date: albumDate
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<GenericMetadataPopover
|
||||
{config}
|
||||
bind:data={popoverData}
|
||||
{triggerElement}
|
||||
onUpdate={handleDateChange}
|
||||
{onClose}
|
||||
/>
|
||||
|
|
@ -1,340 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
interface BaseItem {
|
||||
value: string | number
|
||||
label: string
|
||||
href?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface Props<T extends BaseItem = BaseItem> {
|
||||
items: T[]
|
||||
value?: string | number
|
||||
defaultValue?: string | number
|
||||
onChange?: (value: string | number, item: T) => void
|
||||
variant?: 'navigation' | 'selection'
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
fullWidth?: boolean
|
||||
pillColor?: string | ((item: T) => string)
|
||||
showPill?: boolean
|
||||
gap?: number
|
||||
containerPadding?: number
|
||||
class?: string
|
||||
children?: Snippet<[{ item: T; index: number; isActive: boolean; isHovered: boolean }]>
|
||||
}
|
||||
|
||||
let {
|
||||
items = [],
|
||||
value = $bindable(),
|
||||
defaultValue,
|
||||
onChange,
|
||||
variant = 'selection',
|
||||
size = 'medium',
|
||||
fullWidth = false,
|
||||
pillColor = 'white',
|
||||
showPill = true,
|
||||
gap = 4,
|
||||
containerPadding = 4,
|
||||
class: className = '',
|
||||
children
|
||||
}: Props = $props()
|
||||
|
||||
// State
|
||||
let containerElement: HTMLElement
|
||||
let itemElements: HTMLElement[] = []
|
||||
let pillStyle = ''
|
||||
let hoveredIndex = $state(-1)
|
||||
let internalValue = $state(defaultValue ?? value ?? items[0]?.value ?? '')
|
||||
|
||||
// Derived state
|
||||
const currentValue = $derived(value ?? internalValue)
|
||||
const activeIndex = $derived(items.findIndex((item) => item.value === currentValue))
|
||||
|
||||
// Effects
|
||||
$effect(() => {
|
||||
if (value !== undefined) {
|
||||
internalValue = value
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
updatePillPosition()
|
||||
})
|
||||
|
||||
// Functions
|
||||
function updatePillPosition() {
|
||||
if (!showPill) return
|
||||
|
||||
if (activeIndex >= 0 && itemElements[activeIndex] && containerElement) {
|
||||
const activeElement = itemElements[activeIndex]
|
||||
const containerRect = containerElement.getBoundingClientRect()
|
||||
const activeRect = activeElement.getBoundingClientRect()
|
||||
|
||||
const left = activeRect.left - containerRect.left - containerPadding
|
||||
const width = activeRect.width
|
||||
|
||||
pillStyle = `transform: translateX(${left}px); width: ${width}px;`
|
||||
} else {
|
||||
pillStyle = 'opacity: 0;'
|
||||
}
|
||||
}
|
||||
|
||||
function handleItemClick(item: BaseItem, index: number) {
|
||||
if (variant === 'selection') {
|
||||
const newValue = item.value
|
||||
internalValue = newValue
|
||||
if (value === undefined) {
|
||||
// Uncontrolled mode
|
||||
value = newValue
|
||||
}
|
||||
onChange?.(newValue, item)
|
||||
}
|
||||
// Navigation variant handles clicks via href
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const currentIndex = activeIndex >= 0 ? activeIndex : 0
|
||||
let newIndex = currentIndex
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
newIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1
|
||||
break
|
||||
case 'ArrowRight':
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
newIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0
|
||||
break
|
||||
case 'Home':
|
||||
event.preventDefault()
|
||||
newIndex = 0
|
||||
break
|
||||
case 'End':
|
||||
event.preventDefault()
|
||||
newIndex = items.length - 1
|
||||
break
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
if (variant === 'navigation' && items[currentIndex]?.href) {
|
||||
// Let the link handle navigation
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
if (items[currentIndex]) {
|
||||
handleItemClick(items[currentIndex], currentIndex)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (newIndex !== currentIndex && items[newIndex]) {
|
||||
if (variant === 'navigation' && items[newIndex].href) {
|
||||
// Focus the link
|
||||
itemElements[newIndex]?.focus()
|
||||
} else {
|
||||
handleItemClick(items[newIndex], newIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPillColor(item: BaseItem): string {
|
||||
if (typeof pillColor === 'function') {
|
||||
return pillColor(item)
|
||||
}
|
||||
return pillColor
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMount(() => {
|
||||
const handleResize = () => updatePillPosition()
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
})
|
||||
|
||||
// Size classes
|
||||
const sizeClasses = {
|
||||
small: 'segmented-controller-small',
|
||||
medium: 'segmented-controller-medium',
|
||||
large: 'segmented-controller-large'
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={containerElement}
|
||||
class="base-segmented-controller {sizeClasses[size]} {className}"
|
||||
class:full-width={fullWidth}
|
||||
role="tablist"
|
||||
style="--gap: {gap}px; --container-padding: {containerPadding}px;"
|
||||
onkeydown={handleKeyDown}
|
||||
>
|
||||
{#if showPill && activeIndex >= 0}
|
||||
<div
|
||||
class="segmented-pill"
|
||||
style="{pillStyle}; background-color: {getPillColor(items[activeIndex])};"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#each items as item, index}
|
||||
{@const isActive = index === activeIndex}
|
||||
{@const isHovered = index === hoveredIndex}
|
||||
|
||||
{#if variant === 'navigation' && item.href}
|
||||
<a
|
||||
bind:this={itemElements[index]}
|
||||
href={item.href}
|
||||
class="segmented-item"
|
||||
class:active={isActive}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
tabindex={isActive ? 0 : -1}
|
||||
onmouseenter={() => (hoveredIndex = index)}
|
||||
onmouseleave={() => (hoveredIndex = -1)}
|
||||
>
|
||||
{#if children}
|
||||
{@render children({ item, index, isActive, isHovered })}
|
||||
{:else}
|
||||
<span class="item-label">{item.label}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
bind:this={itemElements[index]}
|
||||
type="button"
|
||||
class="segmented-item"
|
||||
class:active={isActive}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
tabindex={isActive ? 0 : -1}
|
||||
onclick={() => handleItemClick(item, index)}
|
||||
onmouseenter={() => (hoveredIndex = index)}
|
||||
onmouseleave={() => (hoveredIndex = -1)}
|
||||
>
|
||||
{#if children}
|
||||
{@render children({ item, index, isActive, isHovered })}
|
||||
{:else}
|
||||
<span class="item-label">{item.label}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.base-segmented-controller {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--gap);
|
||||
padding: var(--container-padding);
|
||||
background-color: $gray-90;
|
||||
border-radius: $corner-radius-xl;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.full-width {
|
||||
width: 100%;
|
||||
|
||||
.segmented-item {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.segmented-pill {
|
||||
position: absolute;
|
||||
top: var(--container-padding);
|
||||
bottom: var(--container-padding);
|
||||
background-color: white;
|
||||
border-radius: $corner-radius-lg;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: $shadow-sm;
|
||||
z-index: $z-index-base;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.segmented-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
border-radius: $corner-radius-lg;
|
||||
transition: all 0.2s ease;
|
||||
z-index: $z-index-above;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
|
||||
&:not(.active):hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
box-shadow: 0 0 0 2px $blue-50;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: $gray-10;
|
||||
|
||||
.item-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.active) {
|
||||
color: $gray-50;
|
||||
|
||||
&:hover {
|
||||
color: $gray-30;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.item-label {
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
// Size variants
|
||||
.segmented-controller-small {
|
||||
.segmented-item {
|
||||
padding: $unit $unit-2x;
|
||||
font-size: 0.875rem;
|
||||
min-height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.segmented-controller-medium {
|
||||
.segmented-item {
|
||||
padding: calc($unit + $unit-half) $unit-3x;
|
||||
font-size: 0.9375rem;
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.segmented-controller-large {
|
||||
.segmented-item {
|
||||
padding: $unit-2x $unit-4x;
|
||||
font-size: 1rem;
|
||||
min-height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
// Animation states
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.segmented-pill,
|
||||
.segmented-item {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
import { writable, derived, get, type Readable } from 'svelte/store'
|
||||
import { browser } from '$app/environment'
|
||||
import type { Album } from '$lib/types/lastfm'
|
||||
|
||||
interface AlbumStreamState {
|
||||
connected: boolean
|
||||
albums: Album[]
|
||||
lastUpdate: Date | null
|
||||
}
|
||||
|
||||
function createAlbumStream() {
|
||||
const { subscribe, set, update } = writable<AlbumStreamState>({
|
||||
connected: false,
|
||||
albums: [],
|
||||
lastUpdate: null
|
||||
})
|
||||
|
||||
let eventSource: EventSource | null = null
|
||||
let reconnectTimeout: NodeJS.Timeout | null = null
|
||||
let reconnectAttempts = 0
|
||||
|
||||
function connect() {
|
||||
if (!browser || eventSource?.readyState === EventSource.OPEN) return
|
||||
|
||||
// Don't connect in Storybook
|
||||
if (typeof window !== 'undefined' && window.parent !== window) {
|
||||
// We're in an iframe, likely Storybook
|
||||
console.log('Album stream disabled in Storybook')
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up existing connection
|
||||
disconnect()
|
||||
|
||||
eventSource = new EventSource('/api/lastfm/stream')
|
||||
|
||||
eventSource.addEventListener('connected', () => {
|
||||
console.log('Album stream connected')
|
||||
reconnectAttempts = 0
|
||||
update((state) => ({ ...state, connected: true }))
|
||||
})
|
||||
|
||||
eventSource.addEventListener('albums', (event) => {
|
||||
try {
|
||||
const albums: Album[] = JSON.parse(event.data)
|
||||
const nowPlayingAlbum = albums.find((a) => a.isNowPlaying)
|
||||
console.log('Album stream received albums:', {
|
||||
totalAlbums: albums.length,
|
||||
nowPlayingAlbum: nowPlayingAlbum
|
||||
? `${nowPlayingAlbum.artist.name} - ${nowPlayingAlbum.name}`
|
||||
: 'none'
|
||||
})
|
||||
update((state) => ({
|
||||
...state,
|
||||
albums,
|
||||
lastUpdate: new Date()
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Error parsing albums update:', error)
|
||||
}
|
||||
})
|
||||
|
||||
eventSource.addEventListener('heartbeat', () => {
|
||||
// Heartbeat received, connection is healthy
|
||||
})
|
||||
|
||||
eventSource.addEventListener('error', (error) => {
|
||||
console.error('Album stream error:', error)
|
||||
update((state) => ({ ...state, connected: false }))
|
||||
|
||||
// Attempt to reconnect with exponential backoff
|
||||
if (reconnectAttempts < 5) {
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000)
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
reconnectAttempts++
|
||||
connect()
|
||||
}, delay)
|
||||
}
|
||||
})
|
||||
|
||||
eventSource.addEventListener('open', () => {
|
||||
update((state) => ({ ...state, connected: true }))
|
||||
})
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
}
|
||||
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout)
|
||||
reconnectTimeout = null
|
||||
}
|
||||
|
||||
update((state) => ({ ...state, connected: false }))
|
||||
}
|
||||
|
||||
// Auto-connect in browser (but not in admin)
|
||||
if (browser && !window.location.pathname.startsWith('/admin')) {
|
||||
connect()
|
||||
|
||||
// Reconnect on visibility change
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
const currentState = get({ subscribe })
|
||||
if (
|
||||
document.visibilityState === 'visible' &&
|
||||
!currentState.connected &&
|
||||
!window.location.pathname.startsWith('/admin')
|
||||
) {
|
||||
connect()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
connect,
|
||||
disconnect,
|
||||
// Derived store for just the albums
|
||||
albums: derived({ subscribe }, ($state) => $state.albums) as Readable<Album[]>
|
||||
}
|
||||
}
|
||||
|
||||
export const albumStream = createAlbumStream()
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
<script lang="ts">
|
||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||
import Button from '$lib/components/admin/Button.svelte'
|
||||
</script>
|
||||
|
||||
<AdminPage title="Button Components Demo">
|
||||
<div class="button-demo">
|
||||
<section>
|
||||
<h2>Variants</h2>
|
||||
<div class="button-group">
|
||||
<Button>Primary</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="danger">Danger</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
<Button variant="text">Text</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Sizes</h2>
|
||||
<div class="button-group">
|
||||
<Button buttonSize="small">Small</Button>
|
||||
<Button buttonSize="medium">Medium</Button>
|
||||
<Button buttonSize="large">Large</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Icon Buttons</h2>
|
||||
<div class="button-group">
|
||||
<Button buttonSize="small" iconOnly>
|
||||
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M8 4v8m4-4H4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
<Button buttonSize="medium" iconOnly>
|
||||
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<path
|
||||
d="M9 5v8m4-4H5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
<Button buttonSize="large" iconOnly>
|
||||
<svg slot="icon" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path
|
||||
d="M10 6v8m4-4H6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
<Button buttonSize="icon" iconOnly variant="ghost">
|
||||
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<path
|
||||
d="M6 6l6 6m0-6l-6 6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Buttons with Icons</h2>
|
||||
<div class="button-group">
|
||||
<Button>
|
||||
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M8 4v8m4-4H4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
Add Item
|
||||
</Button>
|
||||
<Button iconPosition="right" variant="secondary">
|
||||
Next
|
||||
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M6 4l4 4-4 4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>States</h2>
|
||||
<div class="button-group">
|
||||
<Button disabled>Disabled</Button>
|
||||
<Button loading>Loading</Button>
|
||||
<Button active variant="ghost">Active</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Square Buttons</h2>
|
||||
<div class="button-group">
|
||||
<Button pill={false} buttonSize="small">Small Square</Button>
|
||||
<Button pill={false}>Medium Square</Button>
|
||||
<Button pill={false} buttonSize="large">Large Square</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Full Width</h2>
|
||||
<Button fullWidth>Full Width Button</Button>
|
||||
</section>
|
||||
</div>
|
||||
</AdminPage>
|
||||
|
||||
<style lang="scss">
|
||||
.button-demo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-4x;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: $gray-20;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,293 +0,0 @@
|
|||
<script lang="ts">
|
||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||
import MediaInput from '$lib/components/admin/MediaInput.svelte'
|
||||
import ImagePicker from '$lib/components/admin/ImagePicker.svelte'
|
||||
import GalleryManager from '$lib/components/admin/GalleryManager.svelte'
|
||||
import Button from '$lib/components/admin/Button.svelte'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
// State for different components
|
||||
let singleMedia = $state<Media | null>(null)
|
||||
let multipleMedia = $state<Media[]>([])
|
||||
let logoImage = $state<Media | null>(null)
|
||||
let featuredImage = $state<Media | null>(null)
|
||||
let galleryImages = $state<Media[]>([])
|
||||
let projectGallery = $state<Media[]>([])
|
||||
|
||||
function handleSingleMediaSelect(media: Media | null) {
|
||||
singleMedia = media
|
||||
console.log('Single media selected:', media)
|
||||
}
|
||||
|
||||
function handleMultipleMediaSelect(media: Media[]) {
|
||||
multipleMedia = media
|
||||
console.log('Multiple media selected:', media)
|
||||
}
|
||||
|
||||
function handleLogoSelect(media: Media | null) {
|
||||
logoImage = media
|
||||
console.log('Logo selected:', media)
|
||||
}
|
||||
|
||||
function handleFeaturedImageSelect(media: Media | null) {
|
||||
featuredImage = media
|
||||
console.log('Featured image selected:', media)
|
||||
}
|
||||
|
||||
function handleGallerySelect(media: Media[]) {
|
||||
galleryImages = media
|
||||
console.log('Gallery images selected:', media)
|
||||
}
|
||||
|
||||
function handleProjectGallerySelect(media: Media[]) {
|
||||
projectGallery = media
|
||||
console.log('Project gallery selected:', media)
|
||||
}
|
||||
|
||||
function logAllValues() {
|
||||
console.log('All form values:', {
|
||||
singleMedia,
|
||||
multipleMedia,
|
||||
logoImage,
|
||||
featuredImage,
|
||||
galleryImages,
|
||||
projectGallery
|
||||
})
|
||||
}
|
||||
|
||||
function clearAllValues() {
|
||||
singleMedia = null
|
||||
multipleMedia = []
|
||||
logoImage = null
|
||||
featuredImage = null
|
||||
galleryImages = []
|
||||
projectGallery = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<AdminPage title="Form Components Test" subtitle="Test all media form integration components">
|
||||
<div class="test-container">
|
||||
<!-- MediaInput Tests -->
|
||||
<section class="test-section">
|
||||
<h2>MediaInput Component</h2>
|
||||
<p>Generic input component for media selection with preview.</p>
|
||||
|
||||
<div class="form-grid">
|
||||
<MediaInput
|
||||
label="Single Media File"
|
||||
bind:value={singleMedia}
|
||||
mode="single"
|
||||
fileType="all"
|
||||
placeholder="Choose any media file"
|
||||
/>
|
||||
|
||||
<MediaInput
|
||||
label="Multiple Media Files"
|
||||
bind:value={multipleMedia}
|
||||
mode="multiple"
|
||||
fileType="all"
|
||||
placeholder="Choose multiple files"
|
||||
/>
|
||||
|
||||
<MediaInput
|
||||
label="Single Image Only"
|
||||
bind:value={logoImage}
|
||||
mode="single"
|
||||
fileType="image"
|
||||
placeholder="Choose an image"
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ImagePicker Tests -->
|
||||
<section class="test-section">
|
||||
<h2>ImagePicker Component</h2>
|
||||
<p>Specialized image picker with enhanced preview and aspect ratio support.</p>
|
||||
|
||||
<div class="form-grid">
|
||||
<ImagePicker
|
||||
label="Featured Image"
|
||||
bind:value={featuredImage}
|
||||
aspectRatio="16:9"
|
||||
placeholder="Select a featured image"
|
||||
showDimensions={true}
|
||||
/>
|
||||
|
||||
<ImagePicker
|
||||
label="Square Logo"
|
||||
bind:value={logoImage}
|
||||
aspectRatio="1:1"
|
||||
placeholder="Select a square logo"
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- GalleryManager Tests -->
|
||||
<section class="test-section">
|
||||
<h2>GalleryManager Component</h2>
|
||||
<p>Multiple image management with drag-and-drop reordering.</p>
|
||||
|
||||
<div class="form-column">
|
||||
<GalleryManager label="Image Gallery" bind:value={galleryImages} showFileInfo={false} />
|
||||
|
||||
<GalleryManager
|
||||
label="Project Gallery (Max 6 images)"
|
||||
bind:value={projectGallery}
|
||||
maxItems={6}
|
||||
showFileInfo={true}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<section class="test-section">
|
||||
<h2>Form Actions</h2>
|
||||
<div class="actions-grid">
|
||||
<Button variant="primary" onclick={logAllValues}>Log All Values</Button>
|
||||
<Button variant="ghost" onclick={clearAllValues}>Clear All</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Values Display -->
|
||||
<section class="test-section">
|
||||
<h2>Current Values</h2>
|
||||
<div class="values-display">
|
||||
<div class="value-item">
|
||||
<h4>Single Media:</h4>
|
||||
<pre>{JSON.stringify(singleMedia?.filename || null, null, 2)}</pre>
|
||||
</div>
|
||||
|
||||
<div class="value-item">
|
||||
<h4>Multiple Media ({multipleMedia.length}):</h4>
|
||||
<pre>{JSON.stringify(
|
||||
multipleMedia.map((m) => m.filename),
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
</div>
|
||||
|
||||
<div class="value-item">
|
||||
<h4>Featured Image:</h4>
|
||||
<pre>{JSON.stringify(featuredImage?.filename || null, null, 2)}</pre>
|
||||
</div>
|
||||
|
||||
<div class="value-item">
|
||||
<h4>Gallery Images ({galleryImages.length}):</h4>
|
||||
<pre>{JSON.stringify(
|
||||
galleryImages.map((m) => m.filename),
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
</div>
|
||||
|
||||
<div class="value-item">
|
||||
<h4>Project Gallery ({projectGallery.length}):</h4>
|
||||
<pre>{JSON.stringify(
|
||||
projectGallery.map((m) => m.filename),
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AdminPage>
|
||||
|
||||
<style lang="scss">
|
||||
.test-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: $unit-4x;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
margin-bottom: $unit-6x;
|
||||
padding: $unit-4x;
|
||||
background-color: white;
|
||||
border-radius: $card-corner-radius;
|
||||
border: 1px solid $gray-90;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 $unit 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: $gray-10;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 $unit-3x 0;
|
||||
color: $gray-30;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: $unit-4x;
|
||||
}
|
||||
|
||||
.form-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-4x;
|
||||
}
|
||||
|
||||
.actions-grid {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.values-display {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: $unit-3x;
|
||||
}
|
||||
|
||||
.value-item {
|
||||
h4 {
|
||||
margin: 0 0 $unit 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: $gray-20;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: $gray-95;
|
||||
padding: $unit-2x;
|
||||
border-radius: $card-corner-radius;
|
||||
font-size: 0.75rem;
|
||||
color: $gray-10;
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.test-container {
|
||||
padding: $unit-2x;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
padding: $unit-3x;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: $unit-3x;
|
||||
}
|
||||
|
||||
.actions-grid {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.values-display {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,272 +0,0 @@
|
|||
<script lang="ts">
|
||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||
import ImageUploader from '$lib/components/admin/ImageUploader.svelte'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
let singleImage = $state<Media | null>(null)
|
||||
let logoImage = $state<Media | null>(null)
|
||||
let bannerImage = $state<Media | null>(null)
|
||||
|
||||
function handleSingleImageUpload(media: Media) {
|
||||
singleImage = media
|
||||
console.log('Single image uploaded:', media)
|
||||
}
|
||||
|
||||
function handleLogoUpload(media: Media) {
|
||||
logoImage = media
|
||||
console.log('Logo uploaded:', media)
|
||||
}
|
||||
|
||||
function handleBannerUpload(media: Media) {
|
||||
bannerImage = media
|
||||
console.log('Banner uploaded:', media)
|
||||
}
|
||||
|
||||
function logAllValues() {
|
||||
console.log('All uploaded images:', {
|
||||
singleImage,
|
||||
logoImage,
|
||||
bannerImage
|
||||
})
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
singleImage = null
|
||||
logoImage = null
|
||||
bannerImage = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<AdminPage title="ImageUploader Test" subtitle="Test the new direct upload functionality">
|
||||
<div class="test-container">
|
||||
<!-- Basic Image Upload -->
|
||||
<section class="test-section">
|
||||
<h2>Basic Image Upload</h2>
|
||||
<p>Standard image upload with alt text support.</p>
|
||||
|
||||
<ImageUploader
|
||||
label="Featured Image"
|
||||
bind:value={singleImage}
|
||||
onUpload={handleSingleImageUpload}
|
||||
allowAltText={true}
|
||||
helpText="Upload any image to test the basic functionality."
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Square Logo Upload -->
|
||||
<section class="test-section">
|
||||
<h2>Square Logo Upload</h2>
|
||||
<p>Image upload with 1:1 aspect ratio constraint.</p>
|
||||
|
||||
<ImageUploader
|
||||
label="Company Logo"
|
||||
bind:value={logoImage}
|
||||
onUpload={handleLogoUpload}
|
||||
aspectRatio="1:1"
|
||||
allowAltText={true}
|
||||
required={true}
|
||||
maxFileSize={2}
|
||||
helpText="Upload a square logo (1:1 aspect ratio). Max 2MB."
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Banner Image Upload -->
|
||||
<section class="test-section">
|
||||
<h2>Banner Image Upload</h2>
|
||||
<p>Wide banner image with 16:9 aspect ratio.</p>
|
||||
|
||||
<ImageUploader
|
||||
label="Hero Banner"
|
||||
bind:value={bannerImage}
|
||||
onUpload={handleBannerUpload}
|
||||
aspectRatio="16:9"
|
||||
allowAltText={true}
|
||||
showBrowseLibrary={true}
|
||||
placeholder="Drag and drop a banner image here"
|
||||
helpText="Recommended size: 1920x1080 pixels for best quality."
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<section class="test-section">
|
||||
<h2>Actions</h2>
|
||||
<div class="actions-grid">
|
||||
<button type="button" class="btn btn-primary" onclick={logAllValues}>
|
||||
Log All Values
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" onclick={clearAll}> Clear All </button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Values Display -->
|
||||
<section class="test-section">
|
||||
<h2>Current Values</h2>
|
||||
<div class="values-display">
|
||||
<div class="value-item">
|
||||
<h4>Single Image:</h4>
|
||||
<pre>{JSON.stringify(
|
||||
singleImage
|
||||
? {
|
||||
id: singleImage.id,
|
||||
filename: singleImage.filename,
|
||||
altText: singleImage.altText,
|
||||
description: singleImage.description
|
||||
}
|
||||
: null,
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
</div>
|
||||
|
||||
<div class="value-item">
|
||||
<h4>Logo Image:</h4>
|
||||
<pre>{JSON.stringify(
|
||||
logoImage
|
||||
? {
|
||||
id: logoImage.id,
|
||||
filename: logoImage.filename,
|
||||
altText: logoImage.altText,
|
||||
description: logoImage.description
|
||||
}
|
||||
: null,
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
</div>
|
||||
|
||||
<div class="value-item">
|
||||
<h4>Banner Image:</h4>
|
||||
<pre>{JSON.stringify(
|
||||
bannerImage
|
||||
? {
|
||||
id: bannerImage.id,
|
||||
filename: bannerImage.filename,
|
||||
altText: bannerImage.altText,
|
||||
description: bannerImage.description
|
||||
}
|
||||
: null,
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AdminPage>
|
||||
|
||||
<style lang="scss">
|
||||
.test-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: $unit-4x;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
margin-bottom: $unit-6x;
|
||||
padding: $unit-4x;
|
||||
background-color: white;
|
||||
border-radius: $card-corner-radius;
|
||||
border: 1px solid $gray-90;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 $unit 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: $gray-10;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 $unit-3x 0;
|
||||
color: $gray-30;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.actions-grid {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.values-display {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: $unit-3x;
|
||||
}
|
||||
|
||||
.value-item {
|
||||
h4 {
|
||||
margin: 0 0 $unit 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: $gray-20;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: $gray-95;
|
||||
padding: $unit-2x;
|
||||
border-radius: $card-corner-radius;
|
||||
font-size: 0.75rem;
|
||||
color: $gray-10;
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $unit;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
outline: none;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
padding: $unit $unit-2x;
|
||||
font-size: 14px;
|
||||
border-radius: 24px;
|
||||
min-height: 36px;
|
||||
|
||||
&.btn-primary {
|
||||
background-color: $red-60;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background-color: $red-80;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-ghost {
|
||||
background-color: transparent;
|
||||
color: $gray-20;
|
||||
|
||||
&:hover {
|
||||
background-color: $gray-5;
|
||||
color: $gray-00;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.test-container {
|
||||
padding: $unit-2x;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
padding: $unit-3x;
|
||||
}
|
||||
|
||||
.actions-grid {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.values-display {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,257 +0,0 @@
|
|||
<script lang="ts">
|
||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||
import Input from '$lib/components/admin/Input.svelte'
|
||||
import Button from '$lib/components/admin/Button.svelte'
|
||||
|
||||
let textValue = $state('')
|
||||
let emailValue = $state('')
|
||||
let passwordValue = $state('')
|
||||
let urlValue = $state('https://')
|
||||
let searchValue = $state('')
|
||||
let numberValue = $state(0)
|
||||
let textareaValue = $state('')
|
||||
let colorValue = $state('#ff0000')
|
||||
let withErrorValue = $state('')
|
||||
let disabledValue = $state('Disabled input')
|
||||
let readonlyValue = $state('Readonly input')
|
||||
let charLimitValue = $state('')
|
||||
</script>
|
||||
|
||||
<AdminPage title="Input Components Demo">
|
||||
<div class="input-demo">
|
||||
<section>
|
||||
<h2>Basic Inputs</h2>
|
||||
<div class="input-group">
|
||||
<Input
|
||||
label="Text Input"
|
||||
placeholder="Enter some text"
|
||||
bind:value={textValue}
|
||||
helpText="This is a helpful hint"
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
label="Email Input"
|
||||
placeholder="email@example.com"
|
||||
bind:value={emailValue}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
label="Password Input"
|
||||
placeholder="Enter password"
|
||||
bind:value={passwordValue}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Specialized Inputs</h2>
|
||||
<div class="input-group">
|
||||
<Input
|
||||
type="url"
|
||||
label="URL Input"
|
||||
placeholder="https://example.com"
|
||||
bind:value={urlValue}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="search"
|
||||
label="Search Input"
|
||||
placeholder="Search..."
|
||||
bind:value={searchValue}
|
||||
prefixIcon
|
||||
>
|
||||
<svg slot="prefix" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="7" cy="7" r="5" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M11 11l3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</Input>
|
||||
|
||||
<Input
|
||||
type="number"
|
||||
label="Number Input"
|
||||
bind:value={numberValue}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
/>
|
||||
|
||||
<Input type="color" label="Color Input" bind:value={colorValue} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Textarea</h2>
|
||||
<Input
|
||||
type="textarea"
|
||||
label="Description"
|
||||
placeholder="Enter a detailed description..."
|
||||
bind:value={textareaValue}
|
||||
rows={4}
|
||||
helpText="Markdown is supported"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Input Sizes</h2>
|
||||
<div class="input-group">
|
||||
<Input buttonSize="small" label="Small Input" placeholder="Small size" />
|
||||
|
||||
<Input buttonSize="medium" label="Medium Input" placeholder="Medium size (default)" />
|
||||
|
||||
<Input buttonSize="large" label="Large Input" placeholder="Large size" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Input States</h2>
|
||||
<div class="input-group">
|
||||
<Input
|
||||
label="Input with Error"
|
||||
placeholder="Try typing something"
|
||||
bind:value={withErrorValue}
|
||||
error={withErrorValue.length > 0 && withErrorValue.length < 3
|
||||
? 'Too short! Minimum 3 characters'
|
||||
: ''}
|
||||
/>
|
||||
|
||||
<Input label="Disabled Input" bind:value={disabledValue} disabled />
|
||||
|
||||
<Input label="Readonly Input" bind:value={readonlyValue} readonly />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Input with Icons</h2>
|
||||
<div class="input-group">
|
||||
<Input label="With Prefix Icon" placeholder="Username" prefixIcon>
|
||||
<svg slot="prefix" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="3" stroke="currentColor" stroke-width="1.5" />
|
||||
<path
|
||||
d="M4 14c0-2.21 1.79-4 4-4s4 1.79 4 4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</Input>
|
||||
|
||||
<Input label="With Suffix Icon" placeholder="Email" type="email" suffixIcon>
|
||||
<svg slot="suffix" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<rect
|
||||
x="2"
|
||||
y="4"
|
||||
width="12"
|
||||
height="8"
|
||||
rx="1"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M2 5l6 3 6-3"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</Input>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Character Limit</h2>
|
||||
<Input
|
||||
label="Bio"
|
||||
placeholder="Tell us about yourself..."
|
||||
bind:value={charLimitValue}
|
||||
maxLength={100}
|
||||
showCharCount
|
||||
helpText="Keep it brief"
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="textarea"
|
||||
label="Tweet-style Input"
|
||||
placeholder="What's happening?"
|
||||
maxLength={280}
|
||||
showCharCount
|
||||
rows={3}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Form Example</h2>
|
||||
<form class="demo-form" on:submit|preventDefault>
|
||||
<Input label="Project Name" placeholder="My Awesome Project" required />
|
||||
|
||||
<Input
|
||||
type="url"
|
||||
label="Project URL"
|
||||
placeholder="https://example.com"
|
||||
helpText="Include the full URL with https://"
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="textarea"
|
||||
label="Project Description"
|
||||
placeholder="Describe your project..."
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
showCharCount
|
||||
/>
|
||||
|
||||
<div class="form-actions">
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
<Button variant="primary" type="submit">Save Project</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</AdminPage>
|
||||
|
||||
<style lang="scss">
|
||||
.input-demo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-5x;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-3x;
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: $gray-20;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: $unit-3x;
|
||||
}
|
||||
|
||||
.demo-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-3x;
|
||||
padding: $unit-3x;
|
||||
background-color: $gray-97;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
justify-content: flex-end;
|
||||
margin-top: $unit-2x;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
<script lang="ts">
|
||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||
import UnifiedMediaModal from '$lib/components/admin/UnifiedMediaModal.svelte'
|
||||
import Button from '$lib/components/admin/Button.svelte'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
let showSingleModal = $state(false)
|
||||
let showMultipleModal = $state(false)
|
||||
let selectedSingleMedia = $state<Media | null>(null)
|
||||
let selectedMultipleMedia = $state<Media[]>([])
|
||||
|
||||
function handleSingleSelect(media: Media) {
|
||||
selectedSingleMedia = media
|
||||
console.log('Single media selected:', media)
|
||||
}
|
||||
|
||||
function handleMultipleSelect(media: Media[]) {
|
||||
selectedMultipleMedia = media
|
||||
console.log('Multiple media selected:', media)
|
||||
}
|
||||
|
||||
function openSingleModal() {
|
||||
showSingleModal = true
|
||||
}
|
||||
|
||||
function openMultipleModal() {
|
||||
showMultipleModal = true
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
</script>
|
||||
|
||||
<AdminPage title="Media Library Test" subtitle="Test the UnifiedMediaModal component">
|
||||
<div class="test-container">
|
||||
<section class="test-section">
|
||||
<h2>Single Selection Mode</h2>
|
||||
<p>Test selecting a single media item.</p>
|
||||
|
||||
<Button variant="primary" onclick={openSingleModal}>Open Single Selection Modal</Button>
|
||||
|
||||
{#if selectedSingleMedia}
|
||||
<div class="selected-media">
|
||||
<h3>Selected Media:</h3>
|
||||
<div class="media-preview">
|
||||
{#if selectedSingleMedia.thumbnailUrl}
|
||||
<img src={selectedSingleMedia.thumbnailUrl} alt={selectedSingleMedia.filename} />
|
||||
{/if}
|
||||
<div class="media-details">
|
||||
<p><strong>Filename:</strong> {selectedSingleMedia.filename}</p>
|
||||
<p><strong>Size:</strong> {formatFileSize(selectedSingleMedia.size)}</p>
|
||||
<p><strong>Type:</strong> {selectedSingleMedia.mimeType}</p>
|
||||
{#if selectedSingleMedia.width && selectedSingleMedia.height}
|
||||
<p>
|
||||
<strong>Dimensions:</strong>
|
||||
{selectedSingleMedia.width}×{selectedSingleMedia.height}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="test-section">
|
||||
<h2>Multiple Selection Mode</h2>
|
||||
<p>Test selecting multiple media items.</p>
|
||||
|
||||
<Button variant="primary" onclick={openMultipleModal}>Open Multiple Selection Modal</Button>
|
||||
|
||||
{#if selectedMultipleMedia.length > 0}
|
||||
<div class="selected-media">
|
||||
<h3>Selected Media ({selectedMultipleMedia.length} items):</h3>
|
||||
<div class="media-grid">
|
||||
{#each selectedMultipleMedia as media}
|
||||
<div class="media-item">
|
||||
{#if media.thumbnailUrl}
|
||||
<img src={media.thumbnailUrl} alt={media.filename} />
|
||||
{/if}
|
||||
<div class="media-info">
|
||||
<p class="filename">{media.filename}</p>
|
||||
<p class="size">{formatFileSize(media.size)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="test-section">
|
||||
<h2>Image Only Selection</h2>
|
||||
<p>Test selecting only image files.</p>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onclick={() => {
|
||||
showSingleModal = true
|
||||
// This will be passed to the modal for image-only filtering
|
||||
}}
|
||||
>
|
||||
Open Image Selection Modal
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
<UnifiedMediaModal
|
||||
bind:isOpen={showSingleModal}
|
||||
mode="single"
|
||||
fileType="all"
|
||||
title="Select a Media File"
|
||||
confirmText="Select File"
|
||||
onSelect={handleSingleSelect}
|
||||
/>
|
||||
|
||||
<UnifiedMediaModal
|
||||
bind:isOpen={showMultipleModal}
|
||||
mode="multiple"
|
||||
fileType="all"
|
||||
title="Select Media Files"
|
||||
confirmText="Select Files"
|
||||
onSelect={handleMultipleSelect}
|
||||
/>
|
||||
</AdminPage>
|
||||
|
||||
<style lang="scss">
|
||||
.test-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: $unit-4x;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
margin-bottom: $unit-6x;
|
||||
padding: $unit-4x;
|
||||
background-color: white;
|
||||
border-radius: $card-corner-radius;
|
||||
border: 1px solid $gray-90;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 $unit 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: $gray-10;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 $unit-3x 0;
|
||||
color: $gray-30;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-media {
|
||||
margin-top: $unit-4x;
|
||||
padding: $unit-3x;
|
||||
background-color: $gray-95;
|
||||
border-radius: $card-corner-radius;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 $unit-2x 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: $gray-10;
|
||||
}
|
||||
}
|
||||
|
||||
.media-preview {
|
||||
display: flex;
|
||||
gap: $unit-3x;
|
||||
align-items: flex-start;
|
||||
|
||||
img {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
border-radius: $card-corner-radius;
|
||||
border: 1px solid $gray-80;
|
||||
}
|
||||
|
||||
.media-details {
|
||||
flex: 1;
|
||||
|
||||
p {
|
||||
margin: 0 0 $unit-half 0;
|
||||
font-size: 0.875rem;
|
||||
color: $gray-20;
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
color: $gray-10;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.media-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: $card-corner-radius;
|
||||
border: 1px solid $gray-80;
|
||||
}
|
||||
|
||||
.media-info {
|
||||
.filename {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: $gray-10;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.size {
|
||||
margin: 0;
|
||||
font-size: 0.625rem;
|
||||
color: $gray-40;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.test-container {
|
||||
padding: $unit-2x;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
padding: $unit-3x;
|
||||
}
|
||||
|
||||
.media-preview {
|
||||
flex-direction: column;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in a new issue