850 lines
19 KiB
Svelte
850 lines
19 KiB
Svelte
<script lang="ts">
|
|
import { createEventDispatcher } from 'svelte'
|
|
import { goto } from '$app/navigation'
|
|
import Modal from './Modal.svelte'
|
|
import CaseStudyEditor from './CaseStudyEditor.svelte'
|
|
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
|
|
import FormFieldWrapper from './FormFieldWrapper.svelte'
|
|
import Button from './Button.svelte'
|
|
import Input from './Input.svelte'
|
|
import MediaLibraryModal from './MediaLibraryModal.svelte'
|
|
import MediaDetailsModal from './MediaDetailsModal.svelte'
|
|
import SmartImage from '../SmartImage.svelte'
|
|
import type { JSONContent } from '@tiptap/core'
|
|
import type { Media } from '@prisma/client'
|
|
|
|
export let isOpen = false
|
|
export let initialMode: 'modal' | 'page' = 'modal'
|
|
export let initialPostType: 'post' | 'essay' = 'post'
|
|
export let initialContent: JSONContent | undefined = undefined
|
|
export let closeOnSave = true
|
|
|
|
type PostType = 'post' | 'essay'
|
|
type ComposerMode = 'modal' | 'page'
|
|
|
|
let postType: PostType = initialPostType
|
|
let mode: ComposerMode = initialMode
|
|
let content: JSONContent = initialContent || {
|
|
type: 'doc',
|
|
content: [{ type: 'paragraph' }]
|
|
}
|
|
let characterCount = 0
|
|
let editorInstance: Editor
|
|
|
|
// Essay metadata
|
|
let essayTitle = ''
|
|
let essaySlug = ''
|
|
let essayExcerpt = ''
|
|
let essayTags = ''
|
|
let essayTab = 0
|
|
|
|
// Photo attachment state
|
|
let attachedPhotos: Media[] = []
|
|
let isMediaLibraryOpen = false
|
|
let fileInput: HTMLInputElement
|
|
|
|
// Media details modal state
|
|
let selectedMedia: Media | null = null
|
|
let isMediaDetailsOpen = false
|
|
|
|
const CHARACTER_LIMIT = 280
|
|
const dispatch = createEventDispatcher()
|
|
|
|
function handleClose() {
|
|
if (hasContent() && !confirm('Are you sure you want to close? Your changes will be lost.')) {
|
|
return
|
|
}
|
|
resetComposer()
|
|
isOpen = false
|
|
dispatch('close')
|
|
}
|
|
|
|
function hasContent(): boolean {
|
|
return characterCount > 0 || attachedPhotos.length > 0
|
|
}
|
|
|
|
function resetComposer() {
|
|
postType = initialPostType
|
|
content = {
|
|
type: 'doc',
|
|
content: [{ type: 'paragraph' }]
|
|
}
|
|
characterCount = 0
|
|
attachedPhotos = []
|
|
if (editorInstance) {
|
|
editorInstance.clear()
|
|
}
|
|
}
|
|
|
|
function switchToEssay() {
|
|
// Store content in sessionStorage to avoid messy URLs
|
|
if (content && content.content && content.content.length > 0) {
|
|
sessionStorage.setItem('draft_content', JSON.stringify(content))
|
|
}
|
|
goto('/admin/posts/new?type=essay')
|
|
}
|
|
|
|
function generateSlug(title: string): string {
|
|
return title
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
}
|
|
|
|
$: if (essayTitle && !essaySlug) {
|
|
essaySlug = generateSlug(essayTitle)
|
|
}
|
|
|
|
function handlePhotoUpload() {
|
|
fileInput.click()
|
|
}
|
|
|
|
async function handleFileUpload(event: Event) {
|
|
const input = event.target as HTMLInputElement
|
|
const files = input.files
|
|
if (!files || files.length === 0) return
|
|
|
|
for (const file of files) {
|
|
if (!file.type.startsWith('image/')) continue
|
|
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
formData.append('type', 'image')
|
|
|
|
// Add auth header if needed
|
|
const auth = localStorage.getItem('admin_auth')
|
|
const headers: Record<string, string> = {}
|
|
if (auth) {
|
|
headers.Authorization = `Basic ${auth}`
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/media/upload', {
|
|
method: 'POST',
|
|
headers,
|
|
body: formData
|
|
})
|
|
|
|
if (response.ok) {
|
|
const media = await response.json()
|
|
attachedPhotos = [...attachedPhotos, media]
|
|
} else {
|
|
console.error('Failed to upload image:', response.status)
|
|
}
|
|
} catch (error) {
|
|
console.error('Error uploading image:', error)
|
|
}
|
|
}
|
|
|
|
// Clear the input
|
|
input.value = ''
|
|
}
|
|
|
|
function handleMediaSelect(media: Media | Media[]) {
|
|
const mediaArray = Array.isArray(media) ? media : [media]
|
|
const currentIds = attachedPhotos.map((p) => p.id)
|
|
const newMedia = mediaArray.filter((m) => !currentIds.includes(m.id))
|
|
attachedPhotos = [...attachedPhotos, ...newMedia]
|
|
}
|
|
|
|
function handleMediaLibraryClose() {
|
|
isMediaLibraryOpen = false
|
|
}
|
|
|
|
function removePhoto(photoId: number) {
|
|
attachedPhotos = attachedPhotos.filter((p) => p.id !== photoId)
|
|
}
|
|
|
|
function handlePhotoClick(photo: Media) {
|
|
selectedMedia = photo
|
|
isMediaDetailsOpen = true
|
|
}
|
|
|
|
function handleMediaDetailsClose() {
|
|
isMediaDetailsOpen = false
|
|
selectedMedia = null
|
|
}
|
|
|
|
function handleMediaUpdate(updatedMedia: Media) {
|
|
// Update the photo in the attachedPhotos array
|
|
attachedPhotos = attachedPhotos.map((photo) =>
|
|
photo.id === updatedMedia.id ? updatedMedia : photo
|
|
)
|
|
}
|
|
|
|
function getTextFromContent(json: JSONContent): number {
|
|
if (!json || !json.content) return 0
|
|
|
|
let text = ''
|
|
|
|
function extractText(node: any) {
|
|
if (node.text) {
|
|
text += node.text
|
|
}
|
|
if (node.content && Array.isArray(node.content)) {
|
|
node.content.forEach(extractText)
|
|
}
|
|
}
|
|
|
|
extractText(json)
|
|
return text.length
|
|
}
|
|
|
|
async function handleSave() {
|
|
if (!hasContent() && postType !== 'essay') return
|
|
if (postType === 'essay' && !essayTitle) return
|
|
|
|
let postData: any = {
|
|
content,
|
|
status: 'published',
|
|
attachedPhotos: attachedPhotos.map((photo) => photo.id)
|
|
}
|
|
|
|
if (postType === 'essay') {
|
|
postData = {
|
|
...postData,
|
|
type: 'essay', // No mapping needed anymore
|
|
title: essayTitle,
|
|
slug: essaySlug,
|
|
excerpt: essayExcerpt,
|
|
tags: essayTags ? essayTags.split(',').map((tag) => tag.trim()) : []
|
|
}
|
|
} else {
|
|
// All other content is just a "post" with attachments
|
|
postData = {
|
|
...postData,
|
|
type: 'post' // No mapping needed anymore
|
|
}
|
|
}
|
|
|
|
try {
|
|
const auth = localStorage.getItem('admin_auth')
|
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
if (auth) {
|
|
headers.Authorization = `Basic ${auth}`
|
|
}
|
|
|
|
const response = await fetch('/api/posts', {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify(postData)
|
|
})
|
|
|
|
if (response.ok) {
|
|
resetComposer()
|
|
if (closeOnSave) {
|
|
isOpen = false
|
|
}
|
|
dispatch('saved')
|
|
if (postType === 'essay') {
|
|
goto('/admin/posts')
|
|
}
|
|
} else {
|
|
console.error('Failed to save post')
|
|
}
|
|
} catch (error) {
|
|
console.error('Error saving post:', error)
|
|
}
|
|
}
|
|
|
|
$: isOverLimit = characterCount > CHARACTER_LIMIT
|
|
$: canSave =
|
|
(postType === 'post' && (characterCount > 0 || attachedPhotos.length > 0) && !isOverLimit) ||
|
|
(postType === 'essay' && essayTitle.length > 0 && content)
|
|
</script>
|
|
|
|
{#if mode === 'modal'}
|
|
<Modal bind:isOpen size="medium" on:close={handleClose} showCloseButton={false}>
|
|
<div class="composer">
|
|
<div class="composer-header">
|
|
<Button variant="ghost" onclick={handleClose}>Cancel</Button>
|
|
<div class="header-right">
|
|
<Button
|
|
variant="ghost"
|
|
iconOnly
|
|
onclick={switchToEssay}
|
|
title="Expand to essay"
|
|
class="expand-button"
|
|
>
|
|
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
<path
|
|
d="M10 6L14 2M14 2H10M14 2V6"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
/>
|
|
<path
|
|
d="M6 10L2 14M2 14H6M2 14V10"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
/>
|
|
</svg>
|
|
</Button>
|
|
<Button variant="primary" onclick={handleSave} disabled={!canSave}>Post</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="composer-body">
|
|
<CaseStudyEditor
|
|
bind:this={editorInstance}
|
|
bind:data={content}
|
|
onChange={(newContent) => {
|
|
content = newContent
|
|
characterCount = getTextFromContent(newContent)
|
|
}}
|
|
placeholder="What's on your mind?"
|
|
minHeight={80}
|
|
autofocus={true}
|
|
mode="inline"
|
|
showToolbar={false}
|
|
/>
|
|
|
|
{#if attachedPhotos.length > 0}
|
|
<div class="attached-photos">
|
|
{#each attachedPhotos as photo}
|
|
<div class="photo-item">
|
|
<button
|
|
class="photo-button"
|
|
onclick={() => handlePhotoClick(photo)}
|
|
title="View media details"
|
|
>
|
|
<img src={photo.url} alt={photo.altText || ''} class="photo-preview" />
|
|
</button>
|
|
<button
|
|
class="remove-photo"
|
|
onclick={() => removePhoto(photo.id)}
|
|
title="Remove photo"
|
|
>
|
|
<svg 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>
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="composer-footer">
|
|
<div class="footer-left">
|
|
<Button
|
|
variant="ghost"
|
|
iconOnly
|
|
buttonSize="icon"
|
|
onclick={handlePhotoUpload}
|
|
title="Add image"
|
|
class="tool-button"
|
|
>
|
|
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
|
<rect
|
|
x="2"
|
|
y="2"
|
|
width="14"
|
|
height="14"
|
|
rx="2"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
/>
|
|
<circle cx="5.5" cy="5.5" r="1.5" fill="currentColor" />
|
|
<path
|
|
d="M2 12l4-4 3 3 5-5 2 2"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
/>
|
|
</svg>
|
|
</Button>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
iconOnly
|
|
buttonSize="icon"
|
|
onclick={() => (isMediaLibraryOpen = true)}
|
|
title="Browse library"
|
|
class="tool-button"
|
|
>
|
|
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
|
<path
|
|
d="M2 5L9 12L16 5"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
/>
|
|
</svg>
|
|
</Button>
|
|
</div>
|
|
|
|
<div class="footer-right">
|
|
{#if postType === 'post'}
|
|
<span
|
|
class="character-count"
|
|
class:warning={characterCount > CHARACTER_LIMIT * 0.9}
|
|
class:error={isOverLimit}
|
|
>
|
|
{CHARACTER_LIMIT - characterCount}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
{:else if mode === 'page'}
|
|
{#if postType === 'essay'}
|
|
<div class="essay-composer">
|
|
<div class="essay-header">
|
|
<h1>New Essay</h1>
|
|
<div class="essay-actions">
|
|
<Button variant="secondary" onclick={() => goto('/admin/posts')}>Cancel</Button>
|
|
<Button variant="primary" onclick={handleSave} disabled={!canSave}>Publish</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<AdminSegmentedControl bind:selectedIndex={essayTab}>
|
|
<button slot="0">Metadata</button>
|
|
<button slot="1">Content</button>
|
|
</AdminSegmentedControl>
|
|
|
|
<div class="essay-content">
|
|
{#if essayTab === 0}
|
|
<div class="metadata-section">
|
|
<Input label="Title" bind:value={essayTitle} placeholder="Essay title" required />
|
|
|
|
<Input label="Slug" bind:value={essaySlug} placeholder="essay-slug" />
|
|
|
|
<Input
|
|
type="textarea"
|
|
label="Excerpt"
|
|
bind:value={essayExcerpt}
|
|
placeholder="Brief description of your essay"
|
|
rows={3}
|
|
/>
|
|
|
|
<Input
|
|
label="Tags"
|
|
bind:value={essayTags}
|
|
placeholder="design, development, thoughts"
|
|
helpText="Comma-separated list of tags"
|
|
/>
|
|
</div>
|
|
{:else}
|
|
<div class="content-section">
|
|
<CaseStudyEditor
|
|
bind:this={editorInstance}
|
|
bind:data={content}
|
|
onChange={(newContent) => {
|
|
content = newContent
|
|
characterCount = getTextFromContent(newContent)
|
|
}}
|
|
placeholder="Start writing your essay..."
|
|
minHeight={500}
|
|
autofocus={true}
|
|
mode="default"
|
|
/>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="inline-composer">
|
|
<Button
|
|
variant="ghost"
|
|
iconOnly
|
|
buttonSize="icon"
|
|
onclick={switchToEssay}
|
|
title="Switch to essay mode"
|
|
class="floating-expand-button"
|
|
>
|
|
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
<path
|
|
d="M10 6L14 2M14 2H10M14 2V6"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
/>
|
|
<path
|
|
d="M6 10L2 14M2 14H6M2 14V10"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
/>
|
|
</svg>
|
|
</Button>
|
|
<div class="composer-body">
|
|
<CaseStudyEditor
|
|
bind:this={editorInstance}
|
|
bind:data={content}
|
|
onChange={(newContent) => {
|
|
content = newContent
|
|
characterCount = getTextFromContent(newContent)
|
|
}}
|
|
placeholder="What's on your mind?"
|
|
minHeight={120}
|
|
autofocus={true}
|
|
mode="inline"
|
|
showToolbar={false}
|
|
/>
|
|
|
|
{#if attachedPhotos.length > 0}
|
|
<div class="attached-photos">
|
|
{#each attachedPhotos as photo}
|
|
<div class="photo-item">
|
|
<button
|
|
class="photo-button"
|
|
onclick={() => handlePhotoClick(photo)}
|
|
title="View media details"
|
|
>
|
|
<img src={photo.url} alt={photo.altText || ''} class="photo-preview" />
|
|
</button>
|
|
<button
|
|
class="remove-photo"
|
|
onclick={() => removePhoto(photo.id)}
|
|
title="Remove photo"
|
|
>
|
|
<svg 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>
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="composer-footer">
|
|
<div class="footer-left">
|
|
<Button
|
|
variant="ghost"
|
|
iconOnly
|
|
buttonSize="icon"
|
|
onclick={handlePhotoUpload}
|
|
title="Add image"
|
|
class="tool-button"
|
|
>
|
|
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
|
<rect
|
|
x="2"
|
|
y="2"
|
|
width="14"
|
|
height="14"
|
|
rx="2"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
/>
|
|
<circle cx="5.5" cy="5.5" r="1.5" fill="currentColor" />
|
|
<path
|
|
d="M2 12l4-4 3 3 5-5 2 2"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
/>
|
|
</svg>
|
|
</Button>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
iconOnly
|
|
buttonSize="icon"
|
|
onclick={() => (isMediaLibraryOpen = true)}
|
|
title="Browse library"
|
|
class="tool-button"
|
|
>
|
|
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
|
<path
|
|
d="M2 5L9 12L16 5"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
/>
|
|
</svg>
|
|
</Button>
|
|
</div>
|
|
|
|
<div class="footer-right">
|
|
<span
|
|
class="character-count"
|
|
class:warning={characterCount > CHARACTER_LIMIT * 0.9}
|
|
class:error={isOverLimit}
|
|
>
|
|
{CHARACTER_LIMIT - characterCount}
|
|
</span>
|
|
<Button variant="primary" onclick={handleSave} disabled={!canSave}>Post</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
<!-- Hidden file input for photo upload -->
|
|
<input
|
|
bind:this={fileInput}
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
onchange={handleFileUpload}
|
|
style="display: none;"
|
|
/>
|
|
|
|
<!-- Media Library Modal -->
|
|
<MediaLibraryModal
|
|
bind:isOpen={isMediaLibraryOpen}
|
|
mode="multiple"
|
|
fileType="image"
|
|
onSelect={handleMediaSelect}
|
|
onClose={handleMediaLibraryClose}
|
|
/>
|
|
|
|
<!-- Media Details Modal -->
|
|
{#if selectedMedia}
|
|
<MediaDetailsModal
|
|
bind:isOpen={isMediaDetailsOpen}
|
|
media={selectedMedia}
|
|
onClose={handleMediaDetailsClose}
|
|
onUpdate={handleMediaUpdate}
|
|
/>
|
|
{/if}
|
|
|
|
<style lang="scss">
|
|
@import '$styles/variables.scss';
|
|
|
|
.composer {
|
|
padding: 0;
|
|
max-width: 600px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.composer-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: $unit-2x;
|
|
}
|
|
|
|
.header-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: $unit;
|
|
}
|
|
|
|
.composer-body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.link-fields {
|
|
padding: 0 $unit-2x $unit-2x;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: $unit;
|
|
}
|
|
|
|
.composer-footer {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: calc($unit * 1.5) $unit-2x;
|
|
border-top: 1px solid $grey-80;
|
|
background-color: $grey-5;
|
|
}
|
|
|
|
.footer-left,
|
|
.footer-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: $unit-half;
|
|
}
|
|
|
|
.character-count {
|
|
font-size: 13px;
|
|
color: $grey-50;
|
|
font-weight: 400;
|
|
padding: 0 $unit;
|
|
min-width: 30px;
|
|
text-align: right;
|
|
font-variant-numeric: tabular-nums;
|
|
|
|
&.warning {
|
|
color: $universe-color;
|
|
}
|
|
|
|
&.error {
|
|
color: $red-50;
|
|
font-weight: 500;
|
|
}
|
|
}
|
|
|
|
// Essay composer styles
|
|
.essay-composer {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
padding: $unit-3x;
|
|
}
|
|
|
|
.essay-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: $unit-3x;
|
|
|
|
h1 {
|
|
font-size: 28px;
|
|
font-weight: 600;
|
|
margin: 0;
|
|
}
|
|
}
|
|
|
|
.essay-actions {
|
|
display: flex;
|
|
gap: $unit;
|
|
}
|
|
|
|
.essay-content {
|
|
margin-top: $unit-3x;
|
|
}
|
|
|
|
.metadata-section {
|
|
max-width: 600px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: $unit-3x;
|
|
}
|
|
|
|
.content-section {
|
|
:global(.editor) {
|
|
min-height: 500px;
|
|
}
|
|
}
|
|
|
|
// Inline composer styles
|
|
.inline-composer {
|
|
position: relative;
|
|
background: white;
|
|
border-radius: $unit-2x;
|
|
border: 1px solid $grey-80;
|
|
overflow: hidden;
|
|
width: 100%;
|
|
|
|
.composer-body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
}
|
|
|
|
:global(.floating-expand-button) {
|
|
position: absolute !important;
|
|
top: $unit-2x;
|
|
right: $unit-2x;
|
|
z-index: 10;
|
|
background-color: rgba(255, 255, 255, 0.9) !important;
|
|
backdrop-filter: blur(8px);
|
|
border: 1px solid $grey-80 !important;
|
|
|
|
&:hover {
|
|
background-color: rgba(255, 255, 255, 0.95) !important;
|
|
}
|
|
}
|
|
|
|
.inline-composer .link-fields {
|
|
padding: 0 $unit-3x;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: $unit-2x;
|
|
margin-top: $unit-2x;
|
|
}
|
|
|
|
.inline-composer .composer-footer {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: $unit-2x $unit-3x;
|
|
border-top: 1px solid $grey-80;
|
|
background-color: $grey-90;
|
|
}
|
|
|
|
.attached-photos {
|
|
padding: 0 $unit-3x $unit-2x;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: $unit;
|
|
}
|
|
|
|
.photo-item {
|
|
position: relative;
|
|
|
|
.photo-button {
|
|
border: none;
|
|
background: none;
|
|
padding: 0;
|
|
cursor: pointer;
|
|
display: block;
|
|
transition: transform 0.2s ease;
|
|
|
|
&:hover {
|
|
transform: scale(1.05);
|
|
}
|
|
}
|
|
|
|
:global(.photo-preview) {
|
|
width: 64px;
|
|
height: 64px;
|
|
object-fit: cover;
|
|
border-radius: 12px;
|
|
display: block;
|
|
}
|
|
|
|
.remove-photo {
|
|
position: absolute;
|
|
top: -6px;
|
|
right: -6px;
|
|
width: 20px;
|
|
height: 20px;
|
|
border: none;
|
|
border-radius: 50%;
|
|
background: rgba(0, 0, 0, 0.8);
|
|
color: white;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: all 0.2s ease;
|
|
opacity: 0;
|
|
|
|
&:hover {
|
|
background: rgba(0, 0, 0, 0.9);
|
|
}
|
|
|
|
svg {
|
|
width: 10px;
|
|
height: 10px;
|
|
}
|
|
}
|
|
|
|
&:hover .remove-photo {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
.inline-composer .attached-photos {
|
|
padding: 0 $unit-3x $unit-2x;
|
|
}
|
|
</style>
|