feat: add utility components and helpers
Add DropdownSelectField and StatusPicker components for form inputs. Add time utility functions.
This commit is contained in:
parent
4df84addfa
commit
314885b704
3 changed files with 375 additions and 0 deletions
159
src/lib/components/admin/DropdownSelectField.svelte
Normal file
159
src/lib/components/admin/DropdownSelectField.svelte
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { clickOutside } from '$lib/actions/clickOutside'
|
||||||
|
import DropdownItem from './DropdownItem.svelte'
|
||||||
|
import DropdownMenuContainer from './DropdownMenuContainer.svelte'
|
||||||
|
import FormField from './FormField.svelte'
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string
|
||||||
|
value?: string
|
||||||
|
options: Option[]
|
||||||
|
required?: boolean
|
||||||
|
helpText?: string
|
||||||
|
error?: string
|
||||||
|
disabled?: boolean
|
||||||
|
placeholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
label,
|
||||||
|
value = $bindable(),
|
||||||
|
options,
|
||||||
|
required = false,
|
||||||
|
helpText,
|
||||||
|
error,
|
||||||
|
disabled = false,
|
||||||
|
placeholder = 'Select an option'
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
let isOpen = $state(false)
|
||||||
|
|
||||||
|
const selectedOption = $derived(options.find((opt) => opt.value === value))
|
||||||
|
|
||||||
|
function handleSelect(optionValue: string) {
|
||||||
|
value = optionValue
|
||||||
|
isOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside() {
|
||||||
|
isOpen = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FormField {label} {required} {helpText} {error}>
|
||||||
|
{#snippet children()}
|
||||||
|
<div
|
||||||
|
class="dropdown-select"
|
||||||
|
use:clickOutside={{ enabled: isOpen }}
|
||||||
|
onclickoutside={handleClickOutside}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dropdown-select-trigger"
|
||||||
|
class:open={isOpen}
|
||||||
|
class:has-value={!!value}
|
||||||
|
class:disabled
|
||||||
|
{disabled}
|
||||||
|
onclick={() => !disabled && (isOpen = !isOpen)}
|
||||||
|
>
|
||||||
|
<span class="dropdown-select-value">
|
||||||
|
{selectedOption?.label || placeholder}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
class="chevron"
|
||||||
|
class:rotate={isOpen}
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3 4.5L6 7.5L9 4.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<DropdownMenuContainer>
|
||||||
|
{#each options as option}
|
||||||
|
<DropdownItem
|
||||||
|
label={option.label}
|
||||||
|
description={option.description}
|
||||||
|
onclick={() => handleSelect(option.value)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</DropdownMenuContainer>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.dropdown-select {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-select-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: $unit-2x $unit-3x;
|
||||||
|
background: $input-bg;
|
||||||
|
border: 1px solid $input-border;
|
||||||
|
border-radius: $corner-radius-full;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: $input-text;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover:not(.disabled) {
|
||||||
|
background: $input-bg-hover;
|
||||||
|
border-color: $gray-70;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.open,
|
||||||
|
&:focus {
|
||||||
|
background: $input-bg-focus;
|
||||||
|
border-color: $input-border-focus;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.has-value) {
|
||||||
|
.dropdown-select-value {
|
||||||
|
color: $gray-40;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-select-value {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: $gray-40;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
&.rotate {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
189
src/lib/components/admin/StatusPicker.svelte
Normal file
189
src/lib/components/admin/StatusPicker.svelte
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { clickOutside } from '$lib/actions/clickOutside'
|
||||||
|
import DropdownItem from './DropdownItem.svelte'
|
||||||
|
import DropdownMenuContainer from './DropdownMenuContainer.svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentStatus: 'draft' | 'published' | 'list-only' | 'password-protected'
|
||||||
|
onChange: (status: string) => void
|
||||||
|
disabled?: boolean
|
||||||
|
viewUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let { currentStatus, onChange, disabled = false, viewUrl }: Props = $props()
|
||||||
|
|
||||||
|
let isOpen = $state(false)
|
||||||
|
|
||||||
|
function handleStatusChange(status: string) {
|
||||||
|
onChange(status)
|
||||||
|
isOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside() {
|
||||||
|
isOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
draft: {
|
||||||
|
label: 'Draft',
|
||||||
|
color: 'var(--status-draft, #f59e0b)'
|
||||||
|
},
|
||||||
|
published: {
|
||||||
|
label: 'Published',
|
||||||
|
color: 'var(--status-published, #10b981)'
|
||||||
|
},
|
||||||
|
'list-only': {
|
||||||
|
label: 'List Only',
|
||||||
|
color: 'var(--status-list-only, #3b82f6)'
|
||||||
|
},
|
||||||
|
'password-protected': {
|
||||||
|
label: 'Password Protected',
|
||||||
|
color: 'var(--status-password, #f97316)'
|
||||||
|
}
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const currentConfig = $derived(statusConfig[currentStatus])
|
||||||
|
|
||||||
|
const availableStatuses = [
|
||||||
|
{ value: 'draft', label: 'Draft' },
|
||||||
|
{ value: 'published', label: 'Published' },
|
||||||
|
{ value: 'list-only', label: 'List Only' },
|
||||||
|
{ value: 'password-protected', label: 'Password Protected' }
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="status-picker"
|
||||||
|
use:clickOutside={{ enabled: isOpen }}
|
||||||
|
onclickoutside={handleClickOutside}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="status-badge"
|
||||||
|
class:disabled
|
||||||
|
style="--status-color: {currentConfig.color}"
|
||||||
|
onclick={() => !disabled && (isOpen = !isOpen)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
<span class="status-label">{currentConfig.label}</span>
|
||||||
|
<svg
|
||||||
|
class="chevron"
|
||||||
|
class:open={isOpen}
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3 4.5L6 7.5L9 4.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<DropdownMenuContainer>
|
||||||
|
{#each availableStatuses as status}
|
||||||
|
{#if status.value !== currentStatus}
|
||||||
|
<DropdownItem onclick={() => handleStatusChange(status.value)}>
|
||||||
|
{status.label}
|
||||||
|
</DropdownItem>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if viewUrl && currentStatus === 'published'}
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<a
|
||||||
|
href={viewUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="dropdown-item view-link"
|
||||||
|
>
|
||||||
|
View on site
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</DropdownMenuContainer>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.status-picker {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-half;
|
||||||
|
padding: $unit-half $unit-2x;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid $gray-70;
|
||||||
|
border-radius: $corner-radius-full;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--status-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover:not(.disabled) {
|
||||||
|
background: $gray-95;
|
||||||
|
border-color: $gray-60;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
color: $gray-40;
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-divider {
|
||||||
|
height: 1px;
|
||||||
|
background-color: $gray-80;
|
||||||
|
margin: $unit-half 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item.view-link {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: $unit-2x $unit-3x;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: $gray-20;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color $transition-normal ease;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $gray-95;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
27
src/lib/utils/time.ts
Normal file
27
src/lib/utils/time.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
/**
|
||||||
|
* Format a date as a relative time string (e.g., "2 minutes ago")
|
||||||
|
* @param date - The date to format
|
||||||
|
* @returns A human-readable relative time string
|
||||||
|
*/
|
||||||
|
export function formatTimeAgo(date: Date | string): string {
|
||||||
|
const now = new Date()
|
||||||
|
const past = new Date(date)
|
||||||
|
const seconds = Math.floor((now.getTime() - past.getTime()) / 1000)
|
||||||
|
|
||||||
|
if (seconds < 10) return 'just now'
|
||||||
|
if (seconds < 60) return `${seconds} seconds ago`
|
||||||
|
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
if (minutes < 60) return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago`
|
||||||
|
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return hours === 1 ? '1 hour ago' : `${hours} hours ago`
|
||||||
|
|
||||||
|
// For saves older than 24 hours, show formatted date
|
||||||
|
return past.toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue