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
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>
|
|
|