jedmund-svelte/src/lib/components/admin/GenericMetadataPopover.svelte

465 lines
11 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { onMount } from 'svelte'
import Input from './Input.svelte'
import FormFieldWrapper from './FormFieldWrapper.svelte'
import Button from './Button.svelte'
export interface MetadataField {
type: 'input' | 'textarea' | 'date' | 'toggle' | 'tags' | 'metadata' | 'custom' | 'section'
key: string
label?: string
placeholder?: string
rows?: number
helpText?: string
component?: any // For custom components
props?: any // Additional props for custom components
}
export interface MetadataConfig {
title: string
fields: MetadataField[]
deleteButton?: {
label: string
action: () => void
}
}
type Props = {
config: MetadataConfig
data: any
triggerElement: HTMLElement
onUpdate?: (key: string, value: any) => void
onAddTag?: () => void
onRemoveTag?: (tag: string) => void
onClose?: () => void
}
let {
config,
data = $bindable(),
triggerElement,
onUpdate = () => {},
onAddTag = () => {},
onRemoveTag = () => {},
onClose = () => {}
}: Props = $props()
let popoverElement: HTMLDivElement
let portalTarget: HTMLElement
function updatePosition() {
if (!popoverElement || !triggerElement) return
const triggerRect = triggerElement.getBoundingClientRect()
const popoverRect = popoverElement.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
// Find the AdminPage container to align with its right edge
const adminPage =
document.querySelector('.admin-page') || document.querySelector('[data-admin-page]')
const adminPageRect = adminPage?.getBoundingClientRect()
// Position below the trigger button
let top = triggerRect.bottom + 8
// Align closer to the right edge of AdminPage, with some padding
let left: number
if (adminPageRect) {
// Position to align with AdminPage right edge minus padding
left = adminPageRect.right - popoverRect.width - 24
} else {
// Fallback to viewport-based positioning
left = triggerRect.right - popoverRect.width
}
// Ensure we don't go off-screen horizontally
if (left < 16) {
left = 16
} else if (left + popoverRect.width > viewportWidth - 16) {
left = viewportWidth - popoverRect.width - 16
}
// Check if popover would go off-screen vertically (both top and bottom)
if (top + popoverRect.height > viewportHeight - 16) {
// Try positioning above the trigger
const topAbove = triggerRect.top - popoverRect.height - 8
if (topAbove >= 16) {
top = topAbove
} else {
// If neither above nor below works, position with maximum available space
if (triggerRect.top > viewportHeight - triggerRect.bottom) {
// More space above - position at top of viewport with margin
top = 16
} else {
// More space below - position at bottom of viewport with margin
top = viewportHeight - popoverRect.height - 16
}
}
}
// Also check if positioning below would place us off the top (shouldn't happen but be safe)
if (top < 16) {
top = 16
}
popoverElement.style.position = 'fixed'
popoverElement.style.top = `${top}px`
popoverElement.style.left = `${left}px`
popoverElement.style.zIndex = '1200'
}
function handleFieldUpdate(key: string, value: any) {
data[key] = value
onUpdate(key, value)
}
onMount(() => {
// Create portal target
portalTarget = document.createElement('div')
portalTarget.style.position = 'absolute'
portalTarget.style.top = '0'
portalTarget.style.left = '0'
portalTarget.style.pointerEvents = 'none'
document.body.appendChild(portalTarget)
// Initial positioning
updatePosition()
// Update position on scroll/resize
const handleUpdate = () => updatePosition()
window.addEventListener('scroll', handleUpdate, true)
window.addEventListener('resize', handleUpdate)
// Click outside handler
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
// Don't close if clicking inside the trigger button or the popover itself
if (triggerElement?.contains(target) || popoverElement?.contains(target)) {
return
}
onClose()
}
// Add click outside listener
document.addEventListener('click', handleClickOutside)
return () => {
window.removeEventListener('scroll', handleUpdate, true)
window.removeEventListener('resize', handleUpdate)
document.removeEventListener('click', handleClickOutside)
if (portalTarget) {
document.body.removeChild(portalTarget)
}
}
})
$effect(() => {
if (popoverElement && portalTarget && triggerElement) {
portalTarget.appendChild(popoverElement)
portalTarget.style.pointerEvents = 'auto'
updatePosition()
}
})
</script>
<div class="metadata-popover" bind:this={popoverElement}>
<div class="popover-content">
<h3>{config.title}</h3>
{#each config.fields as field}
{#if field.type === 'input'}
<Input
label={field.label}
bind:value={data[field.key]}
placeholder={field.placeholder}
helpText={field.helpText}
onchange={() => handleFieldUpdate(field.key, data[field.key])}
/>
{:else if field.type === 'textarea'}
<Input
type="textarea"
label={field.label}
bind:value={data[field.key]}
rows={field.rows || 3}
placeholder={field.placeholder}
helpText={field.helpText}
onchange={() => handleFieldUpdate(field.key, data[field.key])}
/>
{:else if field.type === 'date'}
<Input
type="date"
label={field.label}
bind:value={data[field.key]}
helpText={field.helpText}
onchange={() => handleFieldUpdate(field.key, data[field.key])}
/>
{:else if field.type === 'toggle'}
<div class="toggle-wrapper">
<label class="toggle-label">
<input
type="checkbox"
bind:checked={data[field.key]}
class="toggle-input"
onchange={() => handleFieldUpdate(field.key, data[field.key])}
/>
<span class="toggle-slider"></span>
<div class="toggle-content">
<span class="toggle-title">{field.label}</span>
{#if field.helpText}
<span class="toggle-description">{field.helpText}</span>
{/if}
</div>
</label>
</div>
{:else if field.type === 'tags'}
<div class="tags-section">
<Input
label={field.label}
bind:value={data.tagInput}
onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), onAddTag())}
placeholder={field.placeholder || 'Add tags...'}
/>
<button type="button" onclick={onAddTag} class="add-tag-btn">Add</button>
{#if data[field.key] && data[field.key].length > 0}
<div class="tags">
{#each data[field.key] as tag}
<span class="tag">
{tag}
<button onclick={() => onRemoveTag(tag)}>×</button>
</span>
{/each}
</div>
{/if}
</div>
{:else if field.type === 'metadata'}
<div class="metadata">
<p>Created: {new Date(data.createdAt).toLocaleString()}</p>
<p>Updated: {new Date(data.updatedAt).toLocaleString()}</p>
{#if data.publishedAt}
<p>Published: {new Date(data.publishedAt).toLocaleString()}</p>
{/if}
</div>
{:else if field.type === 'section'}
<div class="section-header">
<h4>{field.label}</h4>
</div>
{:else if field.type === 'custom' && field.component}
<field.component {...field.props} bind:data />
{/if}
{/each}
</div>
{#if config.deleteButton}
<div class="popover-footer">
<Button variant="danger-text" pill={false} onclick={config.deleteButton.action}>
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M4 4L12 12M4 12L12 4"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{config.deleteButton.label}
</Button>
</div>
{/if}
</div>
<style lang="scss">
@import '$styles/variables.scss';
.metadata-popover {
background: white;
border: 1px solid $grey-80;
border-radius: $card-corner-radius;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
min-width: 420px;
max-width: 480px;
max-height: calc(100vh - #{$unit-2x * 2});
display: flex;
flex-direction: column;
pointer-events: auto;
overflow-y: auto;
}
.popover-content {
padding: $unit-3x;
display: flex;
flex-direction: column;
gap: $unit-3x;
h3 {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
color: $grey-10;
}
}
.popover-footer {
padding: $unit-3x;
border-top: 1px solid $grey-90;
display: flex;
justify-content: flex-start;
}
.section-header {
margin: $unit-3x 0 $unit 0;
&:first-child {
margin-top: 0;
}
h4 {
display: block;
margin-bottom: $unit;
font-weight: 500;
color: $grey-20;
font-size: 0.925rem;
}
}
.tags-section {
display: flex;
flex-direction: column;
gap: $unit;
}
.add-tag-btn {
align-self: flex-start;
margin-top: $unit-half;
padding: $unit $unit-2x;
background: $grey-10;
color: white;
border: none;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background: $grey-20;
}
}
.tags {
display: flex;
flex-wrap: wrap;
gap: $unit;
margin-top: $unit;
}
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px $unit-2x;
background: $grey-80;
border-radius: 20px;
font-size: 0.75rem;
button {
background: none;
border: none;
color: $grey-40;
cursor: pointer;
padding: 0;
font-size: 1rem;
line-height: 1;
&:hover {
color: $grey-10;
}
}
}
.metadata {
font-size: 0.75rem;
color: $grey-40;
p {
margin: $unit-half 0;
}
}
.toggle-wrapper {
.toggle-label {
display: flex;
align-items: center;
gap: $unit-3x;
cursor: pointer;
user-select: none;
}
.toggle-input {
position: absolute;
opacity: 0;
pointer-events: none;
&:checked + .toggle-slider {
background-color: $blue-60;
&::before {
transform: translateX(20px);
}
}
&:disabled + .toggle-slider {
opacity: 0.5;
cursor: not-allowed;
}
}
.toggle-slider {
position: relative;
width: 44px;
height: 24px;
background-color: $grey-80;
border-radius: 12px;
transition: background-color 0.2s ease;
flex-shrink: 0;
&::before {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background-color: white;
border-radius: 50%;
transition: transform 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
}
.toggle-content {
display: flex;
flex-direction: column;
gap: $unit-half;
.toggle-title {
font-weight: 500;
color: $grey-10;
font-size: 0.875rem;
}
.toggle-description {
font-size: 0.75rem;
color: $grey-50;
line-height: 1.4;
}
}
}
@include breakpoint('phone') {
.metadata-popover {
min-width: 280px;
max-width: calc(100vw - 2rem);
}
}
</style>