Fix upload media button, new essay page
This commit is contained in:
parent
0d90981de0
commit
4407a85dec
6 changed files with 1149 additions and 76 deletions
|
|
@ -10,6 +10,7 @@
|
||||||
fullWidth?: boolean
|
fullWidth?: boolean
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
active?: boolean
|
active?: boolean
|
||||||
|
href?: string
|
||||||
class?: string
|
class?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -24,6 +25,7 @@
|
||||||
active = false,
|
active = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
type = 'button',
|
type = 'button',
|
||||||
|
href,
|
||||||
class: className = '',
|
class: className = '',
|
||||||
children,
|
children,
|
||||||
onclick,
|
onclick,
|
||||||
|
|
@ -63,58 +65,101 @@
|
||||||
const showSpinner = $derived(loading && !iconOnly)
|
const showSpinner = $derived(loading && !iconOnly)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
{#if href}
|
||||||
class={buttonClass()}
|
<a {href} class={buttonClass()} class:disabled={disabled || loading} {...restProps}>
|
||||||
{type}
|
{#if showSpinner}
|
||||||
disabled={disabled || loading}
|
<svg class="btn-spinner" width="16" height="16" viewBox="0 0 16 16">
|
||||||
{onclick}
|
<circle
|
||||||
{...restProps}
|
cx="8"
|
||||||
>
|
cy="8"
|
||||||
{#if showSpinner}
|
r="6"
|
||||||
<svg class="btn-spinner" width="16" height="16" viewBox="0 0 16 16">
|
stroke="currentColor"
|
||||||
<circle
|
stroke-width="2"
|
||||||
cx="8"
|
fill="none"
|
||||||
cy="8"
|
stroke-dasharray="25"
|
||||||
r="6"
|
stroke-dashoffset="25"
|
||||||
stroke="currentColor"
|
stroke-linecap="round"
|
||||||
stroke-width="2"
|
>
|
||||||
fill="none"
|
<animateTransform
|
||||||
stroke-dasharray="25"
|
attributeName="transform"
|
||||||
stroke-dashoffset="25"
|
type="rotate"
|
||||||
stroke-linecap="round"
|
from="0 8 8"
|
||||||
>
|
to="360 8 8"
|
||||||
<animateTransform
|
dur="1s"
|
||||||
attributeName="transform"
|
repeatCount="indefinite"
|
||||||
type="rotate"
|
/>
|
||||||
from="0 8 8"
|
</circle>
|
||||||
to="360 8 8"
|
</svg>
|
||||||
dur="1s"
|
{/if}
|
||||||
repeatCount="indefinite"
|
|
||||||
/>
|
|
||||||
</circle>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if hasIcon && iconPosition === 'left' && !iconOnly}
|
{#if hasIcon && iconPosition === 'left' && !iconOnly}
|
||||||
<span class="btn-icon-wrapper">
|
<span class="btn-icon-wrapper">
|
||||||
|
<slot name="icon" />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if hasDefaultSlot && !iconOnly}
|
||||||
|
<span class="btn-label">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
{:else if iconOnly && hasIcon}
|
||||||
<slot name="icon" />
|
<slot name="icon" />
|
||||||
</span>
|
{/if}
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if hasDefaultSlot && !iconOnly}
|
{#if hasIcon && iconPosition === 'right' && !iconOnly}
|
||||||
<span class="btn-label">
|
<span class="btn-icon-wrapper">
|
||||||
<slot />
|
<slot name="icon" />
|
||||||
</span>
|
</span>
|
||||||
{:else if iconOnly && hasIcon}
|
{/if}
|
||||||
<slot name="icon" />
|
</a>
|
||||||
{/if}
|
{:else}
|
||||||
|
<button class={buttonClass()} {type} disabled={disabled || loading} {onclick} {...restProps}>
|
||||||
|
{#if showSpinner}
|
||||||
|
<svg class="btn-spinner" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<circle
|
||||||
|
cx="8"
|
||||||
|
cy="8"
|
||||||
|
r="6"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
fill="none"
|
||||||
|
stroke-dasharray="25"
|
||||||
|
stroke-dashoffset="25"
|
||||||
|
stroke-linecap="round"
|
||||||
|
>
|
||||||
|
<animateTransform
|
||||||
|
attributeName="transform"
|
||||||
|
type="rotate"
|
||||||
|
from="0 8 8"
|
||||||
|
to="360 8 8"
|
||||||
|
dur="1s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</circle>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if hasIcon && iconPosition === 'right' && !iconOnly}
|
{#if hasIcon && iconPosition === 'left' && !iconOnly}
|
||||||
<span class="btn-icon-wrapper">
|
<span class="btn-icon-wrapper">
|
||||||
|
<slot name="icon" />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if hasDefaultSlot && !iconOnly}
|
||||||
|
<span class="btn-label">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
{:else if iconOnly && hasIcon}
|
||||||
<slot name="icon" />
|
<slot name="icon" />
|
||||||
</span>
|
{/if}
|
||||||
{/if}
|
|
||||||
</button>
|
{#if hasIcon && iconPosition === 'right' && !iconOnly}
|
||||||
|
<span class="btn-icon-wrapper">
|
||||||
|
<slot name="icon" />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@import '$styles/variables.scss';
|
@import '$styles/variables.scss';
|
||||||
|
|
@ -132,10 +177,14 @@
|
||||||
outline: none;
|
outline: none;
|
||||||
position: relative;
|
position: relative;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
text-decoration: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
&:disabled {
|
&:disabled,
|
||||||
|
&.disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.loading {
|
&.loading {
|
||||||
|
|
@ -145,6 +194,12 @@
|
||||||
&.full-width {
|
&.full-width {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure consistent styling for both button and anchor elements
|
||||||
|
&:focus {
|
||||||
|
outline: 2px solid rgba(59, 130, 246, 0.5);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Size variations
|
// Size variations
|
||||||
|
|
|
||||||
613
src/lib/components/admin/MediaUploadModal.svelte
Normal file
613
src/lib/components/admin/MediaUploadModal.svelte
Normal file
|
|
@ -0,0 +1,613 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Modal from './Modal.svelte'
|
||||||
|
import Button from './Button.svelte'
|
||||||
|
import LoadingSpinner from './LoadingSpinner.svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onUploadComplete: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let { isOpen = $bindable(), onClose, onUploadComplete }: Props = $props()
|
||||||
|
|
||||||
|
let files = $state<File[]>([])
|
||||||
|
let dragActive = $state(false)
|
||||||
|
let isUploading = $state(false)
|
||||||
|
let uploadProgress = $state<Record<string, number>>({})
|
||||||
|
let uploadErrors = $state<string[]>([])
|
||||||
|
let successCount = $state(0)
|
||||||
|
let fileInput: HTMLInputElement
|
||||||
|
|
||||||
|
// Reset state when modal opens/closes
|
||||||
|
$effect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
files = []
|
||||||
|
dragActive = false
|
||||||
|
isUploading = false
|
||||||
|
uploadProgress = {}
|
||||||
|
uploadErrors = []
|
||||||
|
successCount = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleDragOver(event: DragEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
dragActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave(event: DragEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
dragActive = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(event: DragEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
dragActive = false
|
||||||
|
|
||||||
|
const droppedFiles = Array.from(event.dataTransfer?.files || [])
|
||||||
|
addFiles(droppedFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileSelect(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
const selectedFiles = Array.from(target.files || [])
|
||||||
|
addFiles(selectedFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFiles(newFiles: File[]) {
|
||||||
|
// Filter for image files
|
||||||
|
const imageFiles = newFiles.filter((file) => file.type.startsWith('image/'))
|
||||||
|
|
||||||
|
if (imageFiles.length !== newFiles.length) {
|
||||||
|
uploadErrors = [
|
||||||
|
...uploadErrors,
|
||||||
|
`${newFiles.length - imageFiles.length} non-image files were skipped`
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
files = [...files, ...imageFiles]
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(index: number) {
|
||||||
|
files = files.filter((_, i) => i !== index)
|
||||||
|
// Clear any related upload progress
|
||||||
|
const fileName = files[index]?.name
|
||||||
|
if (fileName && uploadProgress[fileName]) {
|
||||||
|
const { [fileName]: removed, ...rest } = uploadProgress
|
||||||
|
uploadProgress = rest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 Bytes'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFiles() {
|
||||||
|
if (files.length === 0) return
|
||||||
|
|
||||||
|
isUploading = true
|
||||||
|
uploadErrors = []
|
||||||
|
successCount = 0
|
||||||
|
uploadProgress = {}
|
||||||
|
|
||||||
|
const auth = localStorage.getItem('admin_auth')
|
||||||
|
if (!auth) {
|
||||||
|
uploadErrors = ['Authentication required']
|
||||||
|
isUploading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload files individually to show progress
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
const response = await fetch('/api/media/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${auth}`
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
uploadErrors = [...uploadErrors, `${file.name}: ${error.message || 'Upload failed'}`]
|
||||||
|
} else {
|
||||||
|
successCount++
|
||||||
|
uploadProgress = { ...uploadProgress, [file.name]: 100 }
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
uploadErrors = [...uploadErrors, `${file.name}: Network error`]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isUploading = false
|
||||||
|
|
||||||
|
// If all uploads succeeded, close modal and refresh media list
|
||||||
|
if (successCount === files.length && uploadErrors.length === 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
onUploadComplete()
|
||||||
|
onClose()
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
files = []
|
||||||
|
uploadProgress = {}
|
||||||
|
uploadErrors = []
|
||||||
|
successCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
if (!isUploading) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal bind:isOpen on:close={handleClose} size="large">
|
||||||
|
<div class="upload-modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Upload Media</h2>
|
||||||
|
</div>
|
||||||
|
<!-- Drop Zone -->
|
||||||
|
<div class="modal-inner-content">
|
||||||
|
<div
|
||||||
|
class="drop-zone"
|
||||||
|
class:active={dragActive}
|
||||||
|
class:has-files={files.length > 0}
|
||||||
|
ondragover={handleDragOver}
|
||||||
|
ondragleave={handleDragLeave}
|
||||||
|
ondrop={handleDrop}
|
||||||
|
>
|
||||||
|
<div class="drop-zone-content">
|
||||||
|
{#if files.length === 0}
|
||||||
|
<div class="upload-icon">
|
||||||
|
<svg
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<polyline
|
||||||
|
points="14,2 14,8 20,8"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="16"
|
||||||
|
y1="13"
|
||||||
|
x2="8"
|
||||||
|
y2="13"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="16"
|
||||||
|
y1="17"
|
||||||
|
x2="8"
|
||||||
|
y2="17"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<polyline
|
||||||
|
points="10,9 9,9 8,9"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>Drop images here</h3>
|
||||||
|
<p>or click to browse and select files</p>
|
||||||
|
<p class="upload-hint">Supports JPG, PNG, GIF, WebP, and SVG files</p>
|
||||||
|
{:else}
|
||||||
|
<div class="file-count">
|
||||||
|
<strong>{files.length} file{files.length !== 1 ? 's' : ''} selected</strong>
|
||||||
|
<p>Drop more files to add them, or click to browse</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
bind:this={fileInput}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept="image/*"
|
||||||
|
onchange={handleFileSelect}
|
||||||
|
class="hidden-input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="drop-zone-button"
|
||||||
|
onclick={() => fileInput.click()}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
{dragActive ? 'Drop files' : 'Click to browse'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File List -->
|
||||||
|
{#if files.length > 0}
|
||||||
|
<div class="file-list">
|
||||||
|
<div class="file-list-header">
|
||||||
|
<h3>Files to Upload</h3>
|
||||||
|
<div class="file-actions">
|
||||||
|
<Button variant="secondary" size="small" onclick={clearAll} disabled={isUploading}>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="small"
|
||||||
|
onclick={uploadFiles}
|
||||||
|
disabled={isUploading || files.length === 0}
|
||||||
|
>
|
||||||
|
{#if isUploading}
|
||||||
|
<LoadingSpinner size="small" />
|
||||||
|
Uploading...
|
||||||
|
{:else}
|
||||||
|
Upload {files.length} File{files.length !== 1 ? 's' : ''}
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="files">
|
||||||
|
{#each files as file, index}
|
||||||
|
<div class="file-item">
|
||||||
|
<div class="file-preview">
|
||||||
|
{#if file.type.startsWith('image/')}
|
||||||
|
<img src={URL.createObjectURL(file)} alt={file.name} />
|
||||||
|
{:else}
|
||||||
|
<div class="file-icon">📄</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="file-info">
|
||||||
|
<div class="file-name">{file.name}</div>
|
||||||
|
<div class="file-size">{formatFileSize(file.size)}</div>
|
||||||
|
|
||||||
|
{#if uploadProgress[file.name]}
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" style="width: {uploadProgress[file.name]}%"></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !isUploading}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="remove-button"
|
||||||
|
onclick={() => removeFile(index)}
|
||||||
|
title="Remove file"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Upload Results -->
|
||||||
|
{#if successCount > 0 || uploadErrors.length > 0}
|
||||||
|
<div class="upload-results">
|
||||||
|
{#if successCount > 0}
|
||||||
|
<div class="success-message">
|
||||||
|
✅ Successfully uploaded {successCount} file{successCount !== 1 ? 's' : ''}
|
||||||
|
{#if successCount === files.length && uploadErrors.length === 0}
|
||||||
|
<br /><small>Closing modal...</small>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if uploadErrors.length > 0}
|
||||||
|
<div class="error-messages">
|
||||||
|
<h4>Upload Errors:</h4>
|
||||||
|
{#each uploadErrors as error}
|
||||||
|
<div class="error-item">❌ {error}</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.upload-modal-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
padding: $unit-2x $unit-3x $unit $unit-3x;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $grey-10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-inner-content {
|
||||||
|
padding: $unit $unit-3x $unit-3x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone {
|
||||||
|
border: 2px dashed $grey-80;
|
||||||
|
border-radius: $unit-2x;
|
||||||
|
padding: $unit-6x $unit-4x;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
background: $grey-95;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
background: rgba(59, 130, 246, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.has-files {
|
||||||
|
padding: $unit-4x;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $grey-60;
|
||||||
|
background: $grey-90;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-content {
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
color: $grey-50;
|
||||||
|
margin-bottom: $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: $grey-20;
|
||||||
|
margin-bottom: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: $grey-40;
|
||||||
|
margin-bottom: $unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hint {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: $grey-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-count {
|
||||||
|
strong {
|
||||||
|
color: $grey-20;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-button {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: transparent;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid $grey-85;
|
||||||
|
border-radius: $unit-2x;
|
||||||
|
padding: $unit-3x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: $unit-3x;
|
||||||
|
padding-bottom: $unit-2x;
|
||||||
|
border-bottom: 1px solid $grey-85;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: $grey-20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: $unit-2x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.files {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-3x;
|
||||||
|
padding: $unit-2x;
|
||||||
|
background: $grey-95;
|
||||||
|
border-radius: $unit;
|
||||||
|
border: 1px solid $grey-85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: $unit;
|
||||||
|
overflow: hidden;
|
||||||
|
background: $grey-90;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: $grey-20;
|
||||||
|
margin-bottom: $unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: $grey-50;
|
||||||
|
margin-bottom: $unit-half;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: $grey-85;
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: #3b82f6;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: $grey-50;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: $unit;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $red-60;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-results {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid $grey-85;
|
||||||
|
border-radius: $unit-2x;
|
||||||
|
padding: $unit-3x;
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
color: #16a34a;
|
||||||
|
margin-bottom: $unit-2x;
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: $grey-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-messages {
|
||||||
|
h4 {
|
||||||
|
color: $red-60;
|
||||||
|
margin-bottom: $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-item {
|
||||||
|
color: $red-60;
|
||||||
|
margin-bottom: $unit;
|
||||||
|
font-size: 0.925rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive adjustments
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.upload-modal-content {
|
||||||
|
max-height: 80vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone {
|
||||||
|
padding: $unit-4x $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: $unit-2x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -95,7 +95,7 @@
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
border-radius: 16px;
|
border-radius: $card-corner-radius;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
position: relative;
|
position: relative;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
|
|
@ -126,7 +126,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.close-button) {
|
:global(.close-button) {
|
||||||
position: absolute;
|
position: absolute !important;
|
||||||
top: $unit-2x;
|
top: $unit-2x;
|
||||||
right: $unit-2x;
|
right: $unit-2x;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
|
|
||||||
if (type === 'essay') {
|
if (type === 'essay') {
|
||||||
// Essays go straight to the full page
|
// Essays go straight to the full page
|
||||||
goto('/admin/universe/compose?type=essay')
|
goto('/admin/posts/new?type=essay')
|
||||||
} else if (type === 'post') {
|
} else if (type === 'post') {
|
||||||
// Posts open in modal
|
// Posts open in modal
|
||||||
selectedType = 'post'
|
selectedType = 'post'
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import Select from '$lib/components/admin/Select.svelte'
|
import Select from '$lib/components/admin/Select.svelte'
|
||||||
import Button from '$lib/components/admin/Button.svelte'
|
import Button from '$lib/components/admin/Button.svelte'
|
||||||
import MediaDetailsModal from '$lib/components/admin/MediaDetailsModal.svelte'
|
import MediaDetailsModal from '$lib/components/admin/MediaDetailsModal.svelte'
|
||||||
|
import MediaUploadModal from '$lib/components/admin/MediaUploadModal.svelte'
|
||||||
import type { Media } from '@prisma/client'
|
import type { Media } from '@prisma/client'
|
||||||
|
|
||||||
let media = $state<Media[]>([])
|
let media = $state<Media[]>([])
|
||||||
|
|
@ -41,6 +42,7 @@
|
||||||
// Modal states
|
// Modal states
|
||||||
let selectedMedia = $state<Media | null>(null)
|
let selectedMedia = $state<Media | null>(null)
|
||||||
let isDetailsModalOpen = $state(false)
|
let isDetailsModalOpen = $state(false)
|
||||||
|
let isUploadModalOpen = $state(false)
|
||||||
|
|
||||||
// Multiselect states
|
// Multiselect states
|
||||||
let selectedMediaIds = $state<Set<number>>(new Set())
|
let selectedMediaIds = $state<Set<number>>(new Set())
|
||||||
|
|
@ -145,6 +147,15 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleUploadComplete() {
|
||||||
|
// Reload media list after successful upload
|
||||||
|
loadMedia(currentPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUploadModal() {
|
||||||
|
isUploadModalOpen = true
|
||||||
|
}
|
||||||
|
|
||||||
// Multiselect functions
|
// Multiselect functions
|
||||||
function toggleMultiSelectMode() {
|
function toggleMultiSelectMode() {
|
||||||
isMultiSelectMode = !isMultiSelectMode
|
isMultiSelectMode = !isMultiSelectMode
|
||||||
|
|
@ -324,7 +335,7 @@
|
||||||
{viewMode === 'grid' ? '📋' : '🖼️'}
|
{viewMode === 'grid' ? '📋' : '🖼️'}
|
||||||
{viewMode === 'grid' ? 'List' : 'Grid'}
|
{viewMode === 'grid' ? 'List' : 'Grid'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" size="large" href="/admin/media/upload">Upload Media</Button>
|
<Button variant="primary" size="large" onclick={openUploadModal}>Upload Media</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</AdminHeader>
|
</AdminHeader>
|
||||||
|
|
||||||
|
|
@ -431,7 +442,7 @@
|
||||||
{:else if media.length === 0}
|
{:else if media.length === 0}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<p>No media files found.</p>
|
<p>No media files found.</p>
|
||||||
<a href="/admin/media/upload" class="btn btn-primary">Upload your first file</a>
|
<Button variant="primary" onclick={openUploadModal}>Upload your first file</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else if viewMode === 'grid'}
|
{:else if viewMode === 'grid'}
|
||||||
<div class="media-grid">
|
<div class="media-grid">
|
||||||
|
|
@ -636,6 +647,13 @@
|
||||||
onUpdate={handleMediaUpdate}
|
onUpdate={handleMediaUpdate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Media Upload Modal -->
|
||||||
|
<MediaUploadModal
|
||||||
|
bind:isOpen={isUploadModalOpen}
|
||||||
|
onClose={() => isUploadModalOpen = false}
|
||||||
|
onUploadComplete={handleUploadComplete}
|
||||||
|
/>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.btn {
|
.btn {
|
||||||
padding: $unit-2x $unit-3x;
|
padding: $unit-2x $unit-3x;
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,412 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores'
|
import { page } from '$app/stores'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import EssayForm from '$lib/components/admin/EssayForm.svelte'
|
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||||
import SimplePostForm from '$lib/components/admin/SimplePostForm.svelte'
|
import Editor from '$lib/components/admin/Editor.svelte'
|
||||||
|
import MetadataPopover from '$lib/components/admin/MetadataPopover.svelte'
|
||||||
|
import type { JSONContent } from '@tiptap/core'
|
||||||
|
|
||||||
let postType: 'post' | 'essay' = 'post'
|
let loading = $state(false)
|
||||||
let mounted = false
|
let saving = $state(false)
|
||||||
|
|
||||||
|
let title = $state('')
|
||||||
|
let postType = $state<'post' | 'essay'>('post')
|
||||||
|
let status = $state<'draft' | 'published'>('draft')
|
||||||
|
let slug = $state('')
|
||||||
|
let excerpt = $state('')
|
||||||
|
let content = $state<JSONContent>({ type: 'doc', content: [] })
|
||||||
|
let tags = $state<string[]>([])
|
||||||
|
let tagInput = $state('')
|
||||||
|
let showMetadata = $state(false)
|
||||||
|
let isPublishDropdownOpen = $state(false)
|
||||||
|
let publishButtonRef: HTMLButtonElement
|
||||||
|
let metadataButtonRef: HTMLButtonElement
|
||||||
|
|
||||||
|
const postTypeConfig = {
|
||||||
|
post: { icon: '💭', label: 'Post', showTitle: false, showContent: true },
|
||||||
|
essay: { icon: '📝', label: 'Essay', showTitle: true, showContent: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = $derived(postTypeConfig[postType])
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
// Get post type from URL params
|
||||||
const type = $page.url.searchParams.get('type')
|
const type = $page.url.searchParams.get('type')
|
||||||
if (type && ['post', 'essay'].includes(type)) {
|
if (type && ['post', 'essay'].includes(type)) {
|
||||||
postType = type as typeof postType
|
postType = type as typeof postType
|
||||||
}
|
}
|
||||||
mounted = true
|
|
||||||
|
// Generate initial slug based on title
|
||||||
|
generateSlug()
|
||||||
|
})
|
||||||
|
|
||||||
|
function generateSlug() {
|
||||||
|
if (title) {
|
||||||
|
slug = title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-generate slug when title changes (only if slug is empty)
|
||||||
|
$effect(() => {
|
||||||
|
if (title && (!slug || slug === '')) {
|
||||||
|
generateSlug()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function addTag() {
|
||||||
|
if (tagInput && !tags.includes(tagInput)) {
|
||||||
|
tags = [...tags, tagInput]
|
||||||
|
tagInput = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTag(tag: string) {
|
||||||
|
tags = tags.filter((t) => t !== tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave(publishStatus?: 'draft' | 'published') {
|
||||||
|
const auth = localStorage.getItem('admin_auth')
|
||||||
|
if (!auth) {
|
||||||
|
goto('/admin/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
generateSlug()
|
||||||
|
}
|
||||||
|
|
||||||
|
saving = true
|
||||||
|
const postData = {
|
||||||
|
title: config?.showTitle ? title : null,
|
||||||
|
slug: slug || `post-${Date.now()}`,
|
||||||
|
postType,
|
||||||
|
status: publishStatus || status,
|
||||||
|
content: config?.showContent ? content : null,
|
||||||
|
excerpt: postType === 'essay' ? excerpt : undefined,
|
||||||
|
tags
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/posts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Basic ${auth}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(postData)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const newPost = await response.json()
|
||||||
|
// Redirect to edit page after creation
|
||||||
|
goto(`/admin/posts/${newPost.id}/edit`)
|
||||||
|
} else {
|
||||||
|
console.error('Failed to create post:', response.statusText)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create post:', error)
|
||||||
|
} finally {
|
||||||
|
saving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePublishDropdown(event: MouseEvent) {
|
||||||
|
if (!publishButtonRef?.contains(event.target as Node)) {
|
||||||
|
isPublishDropdownOpen = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMetadataPopover(event: MouseEvent) {
|
||||||
|
const target = event.target as Node
|
||||||
|
// Don't close if clicking inside the metadata button or anywhere in a metadata popover
|
||||||
|
if (
|
||||||
|
metadataButtonRef?.contains(target) ||
|
||||||
|
document.querySelector('.metadata-popover')?.contains(target)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showMetadata = false
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (isPublishDropdownOpen) {
|
||||||
|
document.addEventListener('click', handlePublishDropdown)
|
||||||
|
return () => document.removeEventListener('click', handlePublishDropdown)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (showMetadata) {
|
||||||
|
document.addEventListener('click', handleMetadataPopover)
|
||||||
|
return () => document.removeEventListener('click', handleMetadataPopover)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock post object for metadata popover
|
||||||
|
const mockPost = $derived({
|
||||||
|
id: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
publishedAt: null
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if mounted}
|
<AdminPage>
|
||||||
{#if postType === 'essay'}
|
<header slot="header">
|
||||||
<EssayForm mode="create" />
|
<div class="header-left">
|
||||||
{:else}
|
<button class="btn-icon" onclick={() => goto('/admin/posts')}>
|
||||||
<SimplePostForm postType="post" mode="create" />
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
{/if}
|
<path
|
||||||
{/if}
|
d="M12.5 15L7.5 10L12.5 5"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<div class="metadata-popover-container">
|
||||||
|
<button
|
||||||
|
class="btn btn-text"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
showMetadata = !showMetadata
|
||||||
|
}}
|
||||||
|
bind:this={metadataButtonRef}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 56 56" fill="none">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M 36.4023 19.3164 C 38.8398 19.3164 40.9257 17.7461 41.6992 15.5898 L 49.8085 15.5898 C 50.7695 15.5898 51.6133 14.7461 51.6133 13.6914 C 51.6133 12.6367 50.7695 11.8164 49.8085 11.8164 L 41.7226 11.8164 C 40.9257 9.6367 38.8398 8.0430 36.4023 8.0430 C 33.9648 8.0430 31.8789 9.6367 31.1054 11.8164 L 6.2851 11.8164 C 5.2304 11.8164 4.3867 12.6367 4.3867 13.6914 C 4.3867 14.7461 5.2304 15.5898 6.2851 15.5898 L 31.1054 15.5898 C 31.8789 17.7461 33.9648 19.3164 36.4023 19.3164 Z M 6.1913 26.1133 C 5.2304 26.1133 4.3867 26.9570 4.3867 28.0117 C 4.3867 29.0664 5.2304 29.8867 6.1913 29.8867 L 14.5586 29.8867 C 15.3320 32.0898 17.4179 33.6601 19.8554 33.6601 C 22.3164 33.6601 24.4023 32.0898 25.1757 29.8867 L 49.7149 29.8867 C 50.7695 29.8867 51.6133 29.0664 51.6133 28.0117 C 51.6133 26.9570 50.7695 26.1133 49.7149 26.1133 L 25.1757 26.1133 C 24.3789 23.9570 22.2929 22.3867 19.8554 22.3867 C 17.4413 22.3867 15.3554 23.9570 14.5586 26.1133 Z M 36.4023 47.9570 C 38.8398 47.9570 40.9257 46.3867 41.6992 44.2070 L 49.8085 44.2070 C 50.7695 44.2070 51.6133 43.3867 51.6133 42.3320 C 51.6133 41.2773 50.7695 40.4336 49.8085 40.4336 L 41.6992 40.4336 C 40.9257 38.2539 38.8398 36.7070 36.4023 36.7070 C 33.9648 36.7070 31.8789 38.2539 31.1054 40.4336 L 6.2851 40.4336 C 5.2304 40.4336 4.3867 41.2773 4.3867 42.3320 C 4.3867 43.3867 5.2304 44.2070 6.2851 44.2070 L 31.1054 44.2070 C 31.8789 46.3867 33.9648 47.9570 36.4023 47.9570 Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Metadata
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if showMetadata && metadataButtonRef}
|
||||||
|
<MetadataPopover
|
||||||
|
post={mockPost}
|
||||||
|
{postType}
|
||||||
|
triggerElement={metadataButtonRef}
|
||||||
|
bind:slug
|
||||||
|
bind:excerpt
|
||||||
|
bind:tags
|
||||||
|
bind:tagInput
|
||||||
|
onAddTag={addTag}
|
||||||
|
onRemoveTag={removeTag}
|
||||||
|
onDelete={() => {}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="publish-dropdown">
|
||||||
|
<button
|
||||||
|
bind:this={publishButtonRef}
|
||||||
|
class="btn btn-primary"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
isPublishDropdownOpen = !isPublishDropdownOpen
|
||||||
|
}}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Publish'}
|
||||||
|
<svg 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 isPublishDropdownOpen}
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<button class="dropdown-item" onclick={() => handleSave('published')}>
|
||||||
|
<span>Publish now</span>
|
||||||
|
</button>
|
||||||
|
<button class="dropdown-item" onclick={() => handleSave('draft')}>
|
||||||
|
<span>Save as draft</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="post-composer">
|
||||||
|
<div class="main-content">
|
||||||
|
{#if config?.showTitle}
|
||||||
|
<input type="text" bind:value={title} placeholder="Title" class="title-input" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if config?.showContent}
|
||||||
|
<div class="editor-wrapper">
|
||||||
|
<Editor bind:data={content} placeholder="Start writing..." />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AdminPage>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@import '$styles/variables.scss';
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: $grey-40;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $grey-90;
|
||||||
|
color: $grey-10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text {
|
||||||
|
padding: $unit $unit-2x;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: $grey-40;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $grey-90;
|
||||||
|
color: $grey-10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: $unit-2x $unit-3x;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50px;
|
||||||
|
font-size: 0.925rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-primary {
|
||||||
|
background-color: $grey-10;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background-color: $grey-20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + $unit);
|
||||||
|
right: 0;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid $grey-80;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
min-width: 150px;
|
||||||
|
z-index: 100;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
width: 100%;
|
||||||
|
padding: $unit-2x;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: $grey-10;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $grey-95;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
border-bottom: 1px solid $grey-90;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-composer {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: $unit-4x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-3x;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 $unit-2x;
|
||||||
|
border: none;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $grey-10;
|
||||||
|
background: none;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $grey-60;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-popover-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue