@ -1,11 +1,12 @@
< script lang = "ts" >
< script lang = "ts" >
import { onMount } from 'svelte';
import { onMount } from 'svelte';
import { fetchGifs , searchGifs , type GifMetadata } from '../../services/nostr/gif-service.js';
import { fetchGifs , searchGifs , type GifMetadata } from '../../services/nostr/gif-service.js';
import { signAndPublish } from '../../services/nostr/auth-handler.js';
import { signAndPublish , signHttpAuth } from '../../services/nostr/auth-handler.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { sessionManager } from '../../services/auth/session-manager.js';
import { sessionManager } from '../../services/auth/session-manager.js';
import { KIND } from '../../types/kind-lookup.js';
import { KIND } from '../../types/kind-lookup.js';
import type { NostrEvent } from '../../types/nostr.js';
import type { NostrEvent } from '../../types/nostr.js';
import { cacheEvent } from '../../services/cache/event-cache.js';
interface Props {
interface Props {
open: boolean;
open: boolean;
@ -24,6 +25,20 @@
let uploading = $state(false);
let uploading = $state(false);
let uploadError: string | null = $state(null);
let uploadError: string | null = $state(null);
let fileInput: HTMLInputElement | 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
// Check if user is logged in
let isLoggedIn = $derived(sessionManager.isLoggedIn());
let isLoggedIn = $derived(sessionManager.isLoggedIn());
@ -31,16 +46,16 @@
// Debounce search
// Debounce search
let searchTimeout: ReturnType< typeof setTimeout > | null = null;
let searchTimeout: ReturnType< typeof setTimeout > | null = null;
async function loadGifs(query?: string) {
async function loadGifs(query?: string, forceRefresh: boolean = false ) {
loading = true;
loading = true;
error = null;
error = null;
try {
try {
console.log('[GifPicker] Loading GIFs, query:', query || 'none');
console.log('[GifPicker] Loading GIFs, query:', query || 'none', forceRefresh ? '(force refresh)' : '' );
let results: GifMetadata[];
let results: GifMetadata[];
if (query && query.trim()) {
if (query && query.trim()) {
results = await searchGifs(query.trim(), 50);
results = await searchGifs(query.trim(), 50, forceRefresh );
} else {
} else {
results = await fetchGifs(undefined, 50);
results = await fetchGifs(undefined, 50, forceRefresh );
}
}
console.log('[GifPicker] Loaded', results.length, 'GIFs');
console.log('[GifPicker] Loaded', results.length, 'GIFs');
gifs = results;
gifs = results;
@ -102,7 +117,7 @@
}
}
}
}
// Convert file to data URL
// Convert file to data URL (fallback method)
function fileToDataURL(file: File): Promise< string > {
function fileToDataURL(file: File): Promise< string > {
return new Promise((resolve, reject) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
const reader = new FileReader();
@ -112,6 +127,135 @@
});
});
}
}
// Upload file to media server using NIP-96 discovery (like jumble)
async function uploadFileToServer(file: File): Promise< string > {
// Get media upload server from preferences
// Note: nostr.build has CORS issues, so users may need to configure an alternative
const mediaServer = localStorage.getItem('aitherboard_mediaUploadServer') || 'https://nostr.build';
// 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`);
}
// Step 1: Try NIP-96 discovery (like jumble does)
let uploadUrl: string | null = null;
try {
const nip96Url = `${ mediaServer } /.well-known/nostr/nip96.json`;
console.log(`[GifPicker] Trying NIP-96 discovery: ${ nip96Url } `);
const discoveryResponse = await fetch(nip96Url);
if (discoveryResponse.ok) {
const discoveryData = await discoveryResponse.json();
uploadUrl = discoveryData?.api_url;
if (uploadUrl) {
console.log(`[GifPicker] Found NIP-96 upload URL: ${ uploadUrl } `);
}
}
} catch (error) {
console.log(`[GifPicker] NIP-96 discovery failed, will try direct endpoints:`, error);
}
// Step 2: If no NIP-96 URL, try common endpoints
const endpoints = uploadUrl
? [uploadUrl]
: [
`${ mediaServer } /api/upload`,
`${ mediaServer } /upload`,
`${ mediaServer } /api/v1/upload`,
`${ mediaServer } /api/v2/upload`,
mediaServer // Direct to root
];
// Step 3: Try uploading using XMLHttpRequest (like jumble) for better error handling
const formData = new FormData();
formData.append('file', file);
let lastError: Error | null = null;
for (const endpoint of endpoints) {
try {
console.log(`[GifPicker] Trying upload endpoint: ${ endpoint } `);
// Sign HTTP auth BEFORE creating the Promise (like jumble does)
let authHeader: string | null = null;
try {
const session = sessionManager.getSession();
if (session) {
authHeader = await signHttpAuth(endpoint, 'POST', 'Uploading media file');
}
} catch (authError) {
console.warn('[GifPicker] Failed to sign HTTP auth, trying without:', authError);
// Continue without auth - some servers might not require it
}
// Use XMLHttpRequest for better error handling (like jumble)
// Match jumble's exact implementation
const result = await new Promise< string > ((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', endpoint);
xhr.responseType = 'json';
// Add HTTP auth header if we have it (exactly like jumble)
if (authHeader) {
xhr.setRequestHeader('Authorization', authHeader);
}
// Don't set any other headers - let browser handle Content-Type for FormData
xhr.onerror = () => {
// Network errors - match jumble's exact error message
reject(new Error('Network error'));
};
xhr.onload = () => {
// Match jumble's exact error handling
if (xhr.status >= 200 && xhr.status < 300 ) {
const data = xhr.response;
try {
// Match jumble's exact parsing logic
const tags = data?.nip94_event?.tags ?? [];
const urlTag = tags.find((tag: string[]) => tag[0] === 'url');
if (urlTag?.[1]) {
console.log(`[GifPicker] Upload successful (NIP-96), got URL: ${ urlTag [ 1 ]} `);
resolve(urlTag[1]);
return;
} else {
reject(new Error('No url found'));
return;
}
} catch (e) {
reject(e instanceof Error ? e : new Error(String(e)));
}
} else {
// Match jumble's exact error format
reject(new Error(xhr.status.toString() + ' ' + xhr.statusText));
}
};
xhr.send(formData);
});
return result;
} catch (error) {
// NetworkError, CORS error, etc.
const errorMsg = error instanceof Error ? error.message : String(error);
lastError = error instanceof Error ? error : new Error(errorMsg);
console.warn(`[GifPicker] Endpoint ${ endpoint } failed:`, errorMsg);
// If it's a CORS or network error on the first endpoint, don't try others
if (endpoint === endpoints[0] && (errorMsg.includes('NetworkError') || errorMsg.includes('CORS') || errorMsg.includes('Failed to fetch'))) {
console.warn(`[GifPicker] Network/CORS error on primary endpoint, skipping remaining endpoints`);
break;
}
continue;
}
}
// All endpoints failed - throw error so caller can handle fallback
throw lastError || new Error('Failed to upload file: all endpoints failed');
}
// Handle file upload
// Handle file upload
async function handleFileUpload(e: Event) {
async function handleFileUpload(e: Event) {
const target = e.target as HTMLInputElement;
const target = e.target as HTMLInputElement;
@ -126,6 +270,8 @@
uploading = true;
uploading = true;
uploadError = null;
uploadError = null;
let successCount = 0;
let errorCount = 0;
try {
try {
const relays = relayManager.getPublishRelays(
const relays = relayManager.getPublishRelays(
@ -135,41 +281,109 @@
// Process each selected file
// Process each selected file
for (const file of Array.from(files)) {
for (const file of Array.from(files)) {
// Verify it's a GIF
try {
if (!file.type.includes('gif') && !file.name.toLowerCase().endsWith('.gif')) {
// Verify it's a GIF
uploadError = `${ file . name } is not a GIF file`;
if (!file.type.includes('gif') && !file.name.toLowerCase().endsWith('.gif')) {
continue;
uploadError = `${ file . name } is not a GIF file`;
}
errorCount++;
continue;
}
// Convert to data URL
// Check file size (warn if too large, but try anyway)
const dataUrl = await fileToDataURL(file);
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
// Create kind 1063 event (NIP-94 file metadata)
console.warn(`[GifPicker] File ${ file . name } is large (${( file . size / 1024 / 1024 ). toFixed ( 2 )} MB), upload may take a while`);
const event: Omit< NostrEvent , ' sig ' | ' id ' > = {
}
kind: KIND.FILE_METADATA,
pubkey: session.pubkey,
// Upload file to media server to get URL
created_at: Math.floor(Date.now() / 1000),
// We always try to upload to server first - data URLs are too large for relays
tags: [
let fileUrl: string;
['url', dataUrl],
['m', file.type || 'image/gif'],
try {
['size', file.size.toString()]
fileUrl = await uploadFileToServer(file);
],
console.log(`[GifPicker] Uploaded ${ file . name } to ${ fileUrl } `);
content: ''
} catch (uploadError) {
};
console.error(`[GifPicker] Failed to upload ${ file . name } to media server:`, uploadError);
// Publish the event
const errorMessage = uploadError instanceof Error ? uploadError.message : String(uploadError);
await signAndPublish(event, relays);
// 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
metadataForm = {
title: '',
summary: '',
alt: '',
dim: '',
blurhash: '',
thumb: '',
image: '',
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 } `;
}
}
}
// Reload GIFs to show the newly uploaded ones
// Show success message
await loadGifs(searchQuery);
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
// Reset file input
if (fileInput) {
if (fileInput) {
fileInput.value = '';
fileInput.value = '';
}
}
} catch (error) {
} catch (error) {
console.error('Error uploading GIF:', error);
console.error('[GifPicker] Error uploading GIF:', error);
uploadError = error instanceof Error ? error.message : 'Failed to upload GIF';
uploadError = error instanceof Error ? error.message : 'Failed to upload GIF';
} finally {
} finally {
uploading = false;
uploading = false;
@ -179,6 +393,124 @@
function triggerFileUpload() {
function triggerFileUpload() {
fileInput?.click();
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 {
const relays = relayManager.getPublishRelays(
[...relayManager.getThreadReadRelays(), ...relayManager.getFeedReadRelays()],
true
);
// 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 >
< / script >
{ #if open }
{ #if open }
@ -295,6 +627,116 @@
< / div >
< / div >
{ /if }
{ /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 >
< style >
.drawer-backdrop {
.drawer-backdrop {
position: fixed;
position: fixed;
@ -593,4 +1035,199 @@
:global(.dark) .gif-upload-error {
:global(.dark) .gif-upload-error {
color: var(--fog-dark-error, #ef4444);
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 >
< / style >