You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

1114 lines
31 KiB

<script lang="ts">
import { onMount } from 'svelte';
import { fetchGifs, searchGifs, type GifMetadata } from '../../services/nostr/gif-service.js';
import { signAndPublish } from '../../services/nostr/auth-handler.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { sessionManager } from '../../services/auth/session-manager.js';
import { uploadFileToServer } from '../../services/nostr/file-upload.js';
import { KIND } from '../../types/kind-lookup.js';
import type { NostrEvent } from '../../types/nostr.js';
import { cacheEvent } from '../../services/cache/event-cache.js';
interface Props {
open: boolean;
onSelect: (gifUrl: string) => void;
onClose: () => void;
}
let { open, onSelect, onClose }: Props = $props();
let gifs = $state<GifMetadata[]>([]);
let loading = $state(false);
let searchQuery = $state('');
let searchInput: HTMLInputElement | null = $state(null);
let selectedGif: GifMetadata | null = $state(null);
let error: string | null = $state(null);
let uploading = $state(false);
let uploadError: string | null = $state(null);
let fileInput: HTMLInputElement | null = $state(null);
// Metadata form state
let showMetadataForm = $state(false);
let pendingUpload: { file: File; fileUrl: string } | null = $state(null);
let metadataForm = $state({
title: '',
summary: '',
alt: '',
dim: '',
blurhash: '',
thumb: '',
image: '',
content: ''
});
// Check if user is logged in
let isLoggedIn = $derived(sessionManager.isLoggedIn());
// Debounce search
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
async function loadGifs(query?: string, forceRefresh: boolean = false) {
loading = true;
error = null;
try {
console.log('[GifPicker] Loading GIFs, query:', query || 'none', forceRefresh ? '(force refresh)' : '');
let results: GifMetadata[];
if (query && query.trim()) {
results = await searchGifs(query.trim(), 50, forceRefresh);
} else {
results = await fetchGifs(undefined, 50, forceRefresh);
}
console.log('[GifPicker] Loaded', results.length, 'GIFs');
gifs = results;
if (results.length === 0 && !query) {
error = 'No GIFs found. Try searching for a specific term, or there may be no GIF events on the relays.';
}
} catch (error) {
console.error('[GifPicker] Error loading GIFs:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
gifs = [];
error = `Failed to load GIFs: ${errorMessage}`;
} finally {
loading = false;
}
}
function handleSearchInput(e: Event) {
const target = e.target as HTMLInputElement;
searchQuery = target.value;
// Debounce search
if (searchTimeout) {
clearTimeout(searchTimeout);
}
searchTimeout = setTimeout(() => {
loadGifs(searchQuery);
}, 300);
}
function handleGifSelect(gif: GifMetadata) {
selectedGif = gif;
// Use fallback URL if available, otherwise use main URL
const url = gif.fallbackUrl || gif.url;
onSelect(url);
onClose();
}
// Load GIFs when panel opens
$effect(() => {
if (open) {
loadGifs();
// Focus search input after a short delay
setTimeout(() => {
if (searchInput) {
searchInput.focus();
}
}, 100);
} else {
// Reset when closed
searchQuery = '';
selectedGif = null;
}
});
// Handle keyboard navigation
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
// Convert file to data URL (fallback method)
function fileToDataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// Upload file to media server using shared service
async function uploadGifFile(file: File): Promise<string> {
// For very large files, warn but still try
const maxRecommendedSize = 5 * 1024 * 1024; // 5MB
if (file.size > maxRecommendedSize) {
console.warn(`[GifPicker] File ${file.name} is large (${(file.size / 1024 / 1024).toFixed(2)}MB), upload may fail`);
}
// Use shared upload service
const result = await uploadFileToServer(file, 'GifPicker');
return result.url;
}
// Handle file upload
async function handleFileUpload(e: Event) {
const target = e.target as HTMLInputElement;
const files = target.files;
if (!files || files.length === 0) return;
const session = sessionManager.getSession();
if (!session) {
uploadError = 'Please log in to upload GIFs';
return;
}
uploading = true;
uploadError = null;
let successCount = 0;
let errorCount = 0;
try {
// For kind 1063, use file metadata publish relays (includes GIF relays)
const relays = relayManager.getFileMetadataPublishRelays();
// Process each selected file
for (const file of Array.from(files)) {
try {
// Verify it's a GIF
if (!file.type.includes('gif') && !file.name.toLowerCase().endsWith('.gif')) {
uploadError = `${file.name} is not a GIF file`;
errorCount++;
continue;
}
// Check file size (warn if too large, but try anyway)
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
console.warn(`[GifPicker] File ${file.name} is large (${(file.size / 1024 / 1024).toFixed(2)}MB), upload may take a while`);
}
// Upload file to media server to get URL
// We always try to upload to server first - data URLs are too large for relays
let fileUrl: string;
try {
fileUrl = await uploadGifFile(file);
console.log(`[GifPicker] Uploaded ${file.name} to ${fileUrl}`);
} catch (uploadError) {
console.error(`[GifPicker] Failed to upload ${file.name} to media server:`, uploadError);
const errorMessage = uploadError instanceof Error ? uploadError.message : String(uploadError);
// Only use data URL fallback for very small files (< 500KB) as last resort
// Data URLs are ~33% larger and can cause "Txn is too big" errors on relays
const maxDataUrlSize = 500 * 1024; // 500KB - very conservative limit
if (file.size <= maxDataUrlSize) {
console.warn(`[GifPicker] Using data URL fallback for ${file.name} (${(file.size / 1024).toFixed(1)}KB) - this is not recommended`);
fileUrl = await fileToDataURL(file);
console.log(`[GifPicker] Created data URL for ${file.name} (${(fileUrl.length / 1024).toFixed(1)}KB)`);
} else {
// File is too large for data URL fallback
const serverUrl = localStorage.getItem('aitherboard_mediaUploadServer') || 'https://nostr.build';
throw new Error(
`Failed to upload ${file.name} to media server: ${errorMessage}. ` +
`File is too large (${(file.size / 1024 / 1024).toFixed(2)}MB) to use data URL fallback. ` +
`If you're seeing CORS errors, try configuring a different media upload server in Preferences (current: ${serverUrl}). ` +
`Some servers that support CORS: https://void.cat, https://nostrimg.com, or your own NIP-96 server.`
);
}
}
// Create kind 1063 event (NIP-94 file metadata)
// Use proper NIP-94 format: ["file", "<URL>", "<mime-type>", "size <bytes>", ...]
const event: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.FILE_METADATA,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
['file', fileUrl, file.type || 'image/gif', `size ${file.size}`]
],
content: ''
};
// Store pending upload for metadata form
pendingUpload = { file, fileUrl };
showMetadataForm = true;
// Reset metadata form and prefill image URL with uploaded file URL
metadataForm = {
title: '',
summary: '',
alt: '',
dim: '',
blurhash: '',
thumb: '',
image: fileUrl, // Prefill with the uploaded GIF URL
content: ''
};
} catch (error) {
errorCount++;
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`[GifPicker] Error processing ${file.name}:`, error);
uploadError = uploadError
? `${uploadError}\n${file.name}: ${errorMessage}`
: `${file.name}: ${errorMessage}`;
}
}
// Show success message
if (successCount > 0) {
const message = `Successfully uploaded ${successCount} GIF${successCount > 1 ? 's' : ''}${errorCount > 0 ? ` (${errorCount} failed)` : ''}`;
console.log(`[GifPicker] ${message}`);
// Clear error if we had some successes
if (errorCount === 0) {
uploadError = null;
}
// Reload GIFs to show the newly uploaded ones
// The event is already cached, so we can reload from cache immediately
// No need to force refresh since the event is in cache
await loadGifs(searchQuery, false);
}
// Reset file input
if (fileInput) {
fileInput.value = '';
}
} catch (error) {
console.error('[GifPicker] Error uploading GIF:', error);
uploadError = error instanceof Error ? error.message : 'Failed to upload GIF';
} finally {
uploading = false;
}
}
function triggerFileUpload() {
fileInput?.click();
}
async function publishWithMetadata() {
if (!pendingUpload) return;
const { file, fileUrl } = pendingUpload;
const session = sessionManager.getSession();
if (!session) {
uploadError = 'Please log in to publish GIFs';
return;
}
uploading = true;
uploadError = null;
try {
// For kind 1063, use file metadata publish relays (includes GIF relays)
const relays = relayManager.getFileMetadataPublishRelays();
// Build tags array with metadata
const tags: string[][] = [
['file', fileUrl, file.type || 'image/gif', `size ${file.size}`]
];
// Add URL tag (alternative to file tag)
tags.push(['url', fileUrl]);
// Add mime type tag
tags.push(['m', file.type || 'image/gif']);
// Add dimensions if provided
if (metadataForm.dim) {
tags.push(['dim', metadataForm.dim]);
}
// Add blurhash if provided
if (metadataForm.blurhash) {
tags.push(['blurhash', metadataForm.blurhash]);
tags.push(['bh', metadataForm.blurhash]); // Alternative tag name
}
// Add thumbnail if provided
if (metadataForm.thumb) {
tags.push(['thumb', metadataForm.thumb]);
}
// Add image if provided
if (metadataForm.image) {
tags.push(['image', metadataForm.image]);
}
// Add title/tags if provided
if (metadataForm.title) {
tags.push(['t', metadataForm.title]);
}
// Add summary if provided
if (metadataForm.summary) {
tags.push(['summary', metadataForm.summary]);
}
// Add alt text if provided
if (metadataForm.alt) {
tags.push(['alt', metadataForm.alt]);
}
// Build content from summary and alt
const contentParts: string[] = [];
if (metadataForm.summary) contentParts.push(metadataForm.summary);
if (metadataForm.alt) contentParts.push(metadataForm.alt);
const content = contentParts.join(' ');
// Create kind 1063 event with metadata
const event: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.FILE_METADATA,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags,
content
};
// Sign the event
const signedEvent = await sessionManager.signEvent(event);
// Cache the event immediately
await cacheEvent(signedEvent);
// Publish the event
const result = await signAndPublish(event, relays);
if (result.success.length > 0) {
console.log(`[GifPicker] Published file metadata event for ${file.name} to ${result.success.length} relay(s)`);
showMetadataForm = false;
pendingUpload = null;
// Reload GIFs
await loadGifs(searchQuery, false);
} else {
const errorMsg = result.failed.length > 0
? result.failed.map(f => `${f.relay}: ${f.error}`).join(', ')
: 'No relays accepted the event';
throw new Error(`Failed to publish ${file.name}: ${errorMsg}`);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`[GifPicker] Error publishing ${file.name}:`, error);
uploadError = errorMessage;
} finally {
uploading = false;
}
}
function cancelMetadataForm() {
showMetadataForm = false;
pendingUpload = null;
uploading = false;
}
</script>
{#if open}
<div
class="drawer-backdrop"
onclick={onClose}
onkeydown={handleKeyDown}
role="button"
tabindex="0"
aria-label="Close GIF picker"
></div>
<div class="gif-picker drawer-left" onkeydown={handleKeyDown} role="dialog" aria-label="GIF picker" tabindex="-1">
<div class="drawer-header">
<h3 class="drawer-title">Choose GIF</h3>
<button
onclick={onClose}
class="drawer-close"
aria-label="Close GIF picker"
title="Close"
>
×
</button>
</div>
<div class="gif-search-container">
<input
bind:this={searchInput}
type="text"
placeholder="Search GIFs..."
value={searchQuery}
oninput={handleSearchInput}
class="gif-search-input"
aria-label="Search GIFs"
/>
</div>
<div class="gif-picker-content">
{#if loading}
<div class="gif-loading">Loading GIFs...</div>
{:else if error}
<div class="gif-error">
<p>{error}</p>
<button onclick={() => loadGifs(searchQuery)} class="retry-button">
Retry
</button>
</div>
{:else if gifs.length === 0}
<div class="gif-empty">
{#if searchQuery}
<p>No GIFs found for "{searchQuery}"</p>
<p class="gif-hint">Try a different search term. The relays were queried but returned no matching kind 1063 (NIP-94) GIF events.</p>
{:else}
<p>No GIFs available</p>
<p class="gif-hint">The relays were queried successfully, but no kind 1063 (NIP-94) GIF events were found. This means there are currently no GIFs published as NIP-94 file attachments on the connected relays.</p>
<p class="gif-hint">You can try searching for a specific term, or the relays may not have any GIF events available at this time.</p>
{/if}
</div>
{:else}
<div class="gif-grid">
{#each gifs as gif}
<button
onclick={() => handleGifSelect(gif)}
class="gif-item {selectedGif?.eventId === gif.eventId ? 'selected' : ''}"
title="Click to insert GIF"
>
<img
src={gif.url}
alt="GIF"
loading="lazy"
class="gif-thumbnail"
onerror={(e) => {
console.warn('[GifPicker] Failed to load image:', gif.url);
const target = e.target as HTMLImageElement;
target.style.display = 'none';
}}
/>
</button>
{/each}
</div>
{/if}
</div>
<!-- Bottom options -->
<div class="gif-picker-footer">
<a
href="https://www.gifbuddy.lol/"
target="_blank"
rel="noopener noreferrer"
class="gif-footer-link"
>
Search GifBuddy for more GIFs
</a>
{#if isLoggedIn}
<button
onclick={triggerFileUpload}
class="gif-footer-button"
disabled={uploading}
>
{uploading ? 'Uploading...' : 'Add your own GIFs'}
</button>
<input
bind:this={fileInput}
type="file"
accept=".gif,image/gif"
multiple
onchange={handleFileUpload}
style="display: none;"
/>
{#if uploadError}
<div class="gif-upload-error">{uploadError}</div>
{/if}
{/if}
</div>
</div>
{/if}
<!-- Metadata Form Modal -->
{#if showMetadataForm && pendingUpload}
<div
class="metadata-modal-backdrop"
onclick={cancelMetadataForm}
onkeydown={(e) => e.key === 'Escape' && cancelMetadataForm()}
role="button"
tabindex="0"
aria-label="Close metadata form"
>
<div
class="metadata-modal"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="metadata-modal-title"
tabindex="-1"
>
<div class="metadata-modal-header">
<h3 id="metadata-modal-title">Add Metadata for {pendingUpload.file.name}</h3>
<button class="metadata-modal-close" onclick={cancelMetadataForm}>×</button>
</div>
<div class="metadata-modal-content">
<div class="metadata-form-group">
<label for="metadata-title">Title/Tags (t tag)</label>
<input
id="metadata-title"
type="text"
bind:value={metadataForm.title}
placeholder="e.g., gifbuddy"
class="metadata-input"
/>
</div>
<div class="metadata-form-group">
<label for="metadata-summary">Summary</label>
<textarea
id="metadata-summary"
bind:value={metadataForm.summary}
placeholder="Brief description of the GIF"
class="metadata-textarea"
rows="2"
></textarea>
</div>
<div class="metadata-form-group">
<label for="metadata-alt">Alt Text</label>
<textarea
id="metadata-alt"
bind:value={metadataForm.alt}
placeholder="Accessibility description"
class="metadata-textarea"
rows="2"
></textarea>
</div>
<div class="metadata-form-group">
<label for="metadata-dim">Dimensions (dim tag, e.g., 498x280)</label>
<input
id="metadata-dim"
type="text"
bind:value={metadataForm.dim}
placeholder="widthxheight"
class="metadata-input"
/>
</div>
<div class="metadata-form-group">
<label for="metadata-blurhash">Blurhash</label>
<input
id="metadata-blurhash"
type="text"
bind:value={metadataForm.blurhash}
placeholder="Blurhash string"
class="metadata-input"
/>
</div>
<div class="metadata-form-group">
<label for="metadata-thumb">Thumbnail URL</label>
<input
id="metadata-thumb"
type="url"
bind:value={metadataForm.thumb}
placeholder="https://..."
class="metadata-input"
/>
</div>
<div class="metadata-form-group">
<label for="metadata-image">Image URL</label>
<input
id="metadata-image"
type="url"
bind:value={metadataForm.image}
placeholder="https://..."
class="metadata-input"
/>
</div>
<div class="metadata-form-actions">
<button class="metadata-button cancel" onclick={cancelMetadataForm} disabled={uploading}>
Cancel
</button>
<button class="metadata-button publish" onclick={publishWithMetadata} disabled={uploading}>
{uploading ? 'Publishing...' : 'Publish'}
</button>
</div>
{#if uploadError}
<div class="metadata-error">{uploadError}</div>
{/if}
</div>
</div>
</div>
{/if}
<style>
.drawer-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.gif-picker {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(500px, 85vw);
max-width: 500px;
background: var(--fog-post, #ffffff);
border-right: 2px solid var(--fog-border, #cbd5e1);
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2);
padding: 0;
z-index: 1000;
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideInLeft 0.3s ease-out;
transform: translateX(0);
}
:global(.dark) .gif-picker {
background: var(--fog-dark-post, #1f2937);
border-right-color: var(--fog-dark-border, #475569);
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.5);
}
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .drawer-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.drawer-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .drawer-title {
color: var(--fog-dark-text, #f9fafb);
}
.drawer-close {
background: transparent;
border: none;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--fog-text-light, #9ca3af);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: all 0.2s;
}
.drawer-close:hover {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .drawer-close {
color: var(--fog-dark-text-light, #6b7280);
}
:global(.dark) .drawer-close:hover {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
@keyframes slideInLeft {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
.gif-search-container {
padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .gif-search-container {
border-bottom-color: var(--fog-dark-border, #374151);
}
.gif-search-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-surface, #f8fafc);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
transition: all 0.2s;
}
.gif-search-input:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
background: var(--fog-post, #ffffff);
}
:global(.dark) .gif-search-input {
background: var(--fog-dark-surface, #1e293b);
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .gif-search-input:focus {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-accent, #64748b);
}
.gif-picker-content {
overflow-y: auto;
overflow-x: hidden;
flex: 1;
padding: 1rem;
}
.gif-loading,
.gif-empty,
.gif-error {
text-align: center;
padding: 2rem;
color: var(--fog-text-light, #9ca3af);
font-size: 0.875rem;
}
:global(.dark) .gif-loading,
:global(.dark) .gif-empty,
:global(.dark) .gif-error {
color: var(--fog-dark-text-light, #6b7280);
}
.gif-hint {
margin-top: 0.5rem;
font-size: 0.75rem;
opacity: 0.8;
}
.gif-error {
color: var(--fog-error, #dc2626);
}
:global(.dark) .gif-error {
color: var(--fog-dark-error, #ef4444);
}
.retry-button {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
transition: opacity 0.2s;
}
.retry-button:hover {
opacity: 0.9;
}
.gif-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.5rem;
}
.gif-item {
position: relative;
padding: 0;
border: 2px solid transparent;
border-radius: 0.375rem;
background: transparent;
cursor: pointer;
transition: all 0.2s;
overflow: hidden;
aspect-ratio: 1;
}
.gif-item:hover {
border-color: var(--fog-accent, #64748b);
transform: scale(1.05);
}
.gif-item.selected {
border-color: var(--fog-accent, #64748b);
box-shadow: 0 0 0 2px var(--fog-accent, #64748b);
}
:global(.dark) .gif-item:hover,
:global(.dark) .gif-item.selected {
border-color: var(--fog-dark-accent, #64748b);
}
.gif-thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.gif-picker-footer {
padding: 1rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
display: flex;
flex-direction: column;
gap: 0.75rem;
flex-shrink: 0;
}
:global(.dark) .gif-picker-footer {
border-top-color: var(--fog-dark-border, #374151);
}
.gif-footer-link {
color: var(--fog-accent, #64748b);
text-decoration: none;
font-size: 0.875rem;
padding: 0.5rem;
border-radius: 0.375rem;
transition: all 0.2s;
text-align: center;
}
.gif-footer-link:hover {
background: var(--fog-highlight, #f3f4f6);
text-decoration: underline;
}
:global(.dark) .gif-footer-link {
color: var(--fog-dark-accent, #94a3b8);
}
:global(.dark) .gif-footer-link:hover {
background: var(--fog-dark-highlight, #374151);
}
.gif-footer-button {
padding: 0.5rem 1rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
transition: opacity 0.2s;
}
.gif-footer-button:hover:not(:disabled) {
opacity: 0.9;
}
.gif-footer-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.gif-upload-error {
color: var(--fog-error, #dc2626);
font-size: 0.75rem;
text-align: center;
}
:global(.dark) .gif-upload-error {
color: var(--fog-dark-error, #ef4444);
}
/* Metadata Form Modal */
.metadata-modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.metadata-modal {
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
:global(.dark) .metadata-modal {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.metadata-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .metadata-modal-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.metadata-modal-header h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .metadata-modal-header h3 {
color: var(--fog-dark-text, #f9fafb);
}
.metadata-modal-close {
background: transparent;
border: none;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--fog-text-light, #9ca3af);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: all 0.2s;
}
.metadata-modal-close:hover {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .metadata-modal-close {
color: var(--fog-dark-text-light, #6b7280);
}
:global(.dark) .metadata-modal-close:hover {
background: var(--fog-dark-highlight, #475569);
color: var(--fog-dark-text, #f9fafb);
}
.metadata-modal-content {
padding: 1.5rem;
}
.metadata-form-group {
margin-bottom: 1rem;
}
.metadata-form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--fog-text, #1f2937);
}
:global(.dark) .metadata-form-group label {
color: var(--fog-dark-text, #f9fafb);
}
.metadata-input,
.metadata-textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
font-family: inherit;
}
:global(.dark) .metadata-input,
:global(.dark) .metadata-textarea {
background: var(--fog-dark-post, #334155);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #f9fafb);
}
.metadata-textarea {
resize: vertical;
min-height: 60px;
}
.metadata-form-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .metadata-form-actions {
border-top-color: var(--fog-dark-border, #374151);
}
.metadata-button {
padding: 0.5rem 1.5rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.metadata-button.cancel {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .metadata-button.cancel {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #f9fafb);
}
.metadata-button.cancel:hover {
background: var(--fog-accent, #64748b);
color: white;
border-color: var(--fog-accent, #64748b);
}
.metadata-button.publish {
background: var(--fog-accent, #64748b);
color: white;
border-color: var(--fog-accent, #64748b);
}
.metadata-button.publish:hover:not(:disabled) {
opacity: 0.9;
}
.metadata-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.metadata-error {
margin-top: 1rem;
padding: 0.75rem;
background: var(--fog-error-bg, #fee2e2);
border: 1px solid var(--fog-error, #dc2626);
border-radius: 0.375rem;
color: var(--fog-error, #dc2626);
font-size: 0.875rem;
}
:global(.dark) .metadata-error {
background: var(--fog-dark-error-bg, #7f1d1d);
border-color: var(--fog-dark-error, #ef4444);
color: var(--fog-dark-error, #ef4444);
}
</style>