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