10 changed files with 1339 additions and 498 deletions
@ -0,0 +1,339 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
interface Props { |
||||||
|
open: boolean; |
||||||
|
title: string; |
||||||
|
items: string[]; |
||||||
|
onSelect: (item: string) => void; |
||||||
|
onClose: () => void; |
||||||
|
searchPlaceholder?: string; |
||||||
|
emptyMessage?: string; |
||||||
|
searchQuery?: string; |
||||||
|
onSearchChange?: (query: string) => void; |
||||||
|
children?: import('svelte').Snippet; |
||||||
|
} |
||||||
|
|
||||||
|
let { |
||||||
|
open, |
||||||
|
title, |
||||||
|
items, |
||||||
|
onSelect, |
||||||
|
onClose, |
||||||
|
searchPlaceholder = "Search emojis...", |
||||||
|
emptyMessage = "No emojis found", |
||||||
|
searchQuery: externalSearchQuery, |
||||||
|
onSearchChange, |
||||||
|
children |
||||||
|
}: Props = $props(); |
||||||
|
|
||||||
|
let searchInput: HTMLInputElement | null = $state(null); |
||||||
|
let internalSearchQuery = $state(''); |
||||||
|
|
||||||
|
// Use external search query if provided, otherwise use internal |
||||||
|
const currentSearchQuery = $derived(externalSearchQuery ?? internalSearchQuery); |
||||||
|
|
||||||
|
function handleSearchInput(e: Event) { |
||||||
|
const target = e.target as HTMLInputElement; |
||||||
|
const value = target.value; |
||||||
|
if (onSearchChange) { |
||||||
|
onSearchChange(value); |
||||||
|
} else if (externalSearchQuery !== undefined) { |
||||||
|
// If external search query is provided, parent should handle updates |
||||||
|
// For now, just use internal state |
||||||
|
internalSearchQuery = value; |
||||||
|
} else { |
||||||
|
internalSearchQuery = value; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function handleItemSelect(item: string) { |
||||||
|
onSelect(item); |
||||||
|
onClose(); |
||||||
|
} |
||||||
|
|
||||||
|
// Handle keyboard navigation |
||||||
|
function handleKeyDown(e: KeyboardEvent) { |
||||||
|
if (e.key === 'Escape') { |
||||||
|
onClose(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Focus search input when drawer opens |
||||||
|
$effect(() => { |
||||||
|
if (open) { |
||||||
|
setTimeout(() => { |
||||||
|
if (searchInput) { |
||||||
|
searchInput.focus(); |
||||||
|
} |
||||||
|
}, 100); |
||||||
|
} else { |
||||||
|
// Reset search when closed |
||||||
|
if (onSearchChange) { |
||||||
|
onSearchChange(''); |
||||||
|
} else { |
||||||
|
internalSearchQuery = ''; |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if open} |
||||||
|
<div |
||||||
|
class="drawer-backdrop" |
||||||
|
onclick={onClose} |
||||||
|
onkeydown={handleKeyDown} |
||||||
|
role="button" |
||||||
|
tabindex="0" |
||||||
|
aria-label="Close {title.toLowerCase()}" |
||||||
|
></div> |
||||||
|
<div |
||||||
|
class="emoji-drawer drawer-left" |
||||||
|
onkeydown={handleKeyDown} |
||||||
|
role="dialog" |
||||||
|
aria-modal="true" |
||||||
|
aria-label={title} |
||||||
|
tabindex="-1" |
||||||
|
> |
||||||
|
<div class="drawer-header"> |
||||||
|
<h3 class="drawer-title">{title}</h3> |
||||||
|
<button |
||||||
|
onclick={onClose} |
||||||
|
class="drawer-close" |
||||||
|
aria-label="Close {title.toLowerCase()}" |
||||||
|
title="Close" |
||||||
|
> |
||||||
|
× |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="emoji-search-container"> |
||||||
|
<input |
||||||
|
bind:this={searchInput} |
||||||
|
type="text" |
||||||
|
placeholder={searchPlaceholder} |
||||||
|
value={currentSearchQuery} |
||||||
|
oninput={handleSearchInput} |
||||||
|
class="emoji-search-input" |
||||||
|
aria-label="Search emojis" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="emoji-drawer-content"> |
||||||
|
{#if children} |
||||||
|
{@render children()} |
||||||
|
{:else if items.length === 0} |
||||||
|
<div class="emoji-empty">{emptyMessage}</div> |
||||||
|
{:else} |
||||||
|
<div class="emoji-grid"> |
||||||
|
{#each items as item} |
||||||
|
<button |
||||||
|
onclick={() => handleItemSelect(item)} |
||||||
|
class="emoji-item" |
||||||
|
title="Click to select" |
||||||
|
> |
||||||
|
{item} |
||||||
|
</button> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</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; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.emoji-drawer { |
||||||
|
position: fixed; |
||||||
|
top: 0; |
||||||
|
left: 0; |
||||||
|
bottom: 0; |
||||||
|
width: min(400px, 80vw); |
||||||
|
max-width: 400px; |
||||||
|
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); |
||||||
|
/* Override global grayscale filter for entire drawer */ |
||||||
|
filter: none !important; |
||||||
|
} |
||||||
|
|
||||||
|
/* Override grayscale filters for all children in the drawer */ |
||||||
|
.emoji-drawer * { |
||||||
|
filter: none !important; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .emoji-drawer { |
||||||
|
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); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.emoji-search-container { |
||||||
|
padding: 1rem; |
||||||
|
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .emoji-search-container { |
||||||
|
border-bottom-color: var(--fog-dark-border, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
.emoji-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; |
||||||
|
} |
||||||
|
|
||||||
|
.emoji-search-input:focus { |
||||||
|
outline: none; |
||||||
|
border-color: var(--fog-accent, #64748b); |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .emoji-search-input { |
||||||
|
background: var(--fog-dark-surface, #1e293b); |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
color: var(--fog-dark-text, #f9fafb); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .emoji-search-input:focus { |
||||||
|
background: var(--fog-dark-post, #1f2937); |
||||||
|
border-color: var(--fog-dark-accent, #64748b); |
||||||
|
} |
||||||
|
|
||||||
|
.emoji-drawer-content { |
||||||
|
overflow-y: auto; |
||||||
|
overflow-x: hidden; |
||||||
|
flex: 1; |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.emoji-empty { |
||||||
|
text-align: center; |
||||||
|
padding: 2rem; |
||||||
|
color: var(--fog-text-light, #9ca3af); |
||||||
|
font-size: 0.875rem; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .emoji-empty { |
||||||
|
color: var(--fog-dark-text-light, #6b7280); |
||||||
|
} |
||||||
|
|
||||||
|
.emoji-grid { |
||||||
|
display: grid; |
||||||
|
grid-template-columns: repeat(auto-fill, minmax(2.5rem, 1fr)); |
||||||
|
gap: 0.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
.emoji-item { |
||||||
|
padding: 0.5rem; |
||||||
|
border: 1px solid transparent; |
||||||
|
border-radius: 0.25rem; |
||||||
|
background: transparent; |
||||||
|
cursor: pointer; |
||||||
|
transition: all 0.2s; |
||||||
|
font-size: 1.5rem; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
min-height: 2.5rem; |
||||||
|
/* Override global grayscale filter for emojis in drawer */ |
||||||
|
filter: none !important; |
||||||
|
} |
||||||
|
|
||||||
|
.emoji-item:hover { |
||||||
|
background: var(--fog-highlight, #f3f4f6); |
||||||
|
border-color: var(--fog-border, #e5e7eb); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .emoji-item:hover { |
||||||
|
background: var(--fog-dark-highlight, #374151); |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,351 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import { fetchGifs, searchGifs, type GifMetadata } from '../../services/nostr/gif-service.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); |
||||||
|
|
||||||
|
// Debounce search |
||||||
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null; |
||||||
|
|
||||||
|
async function loadGifs(query?: string) { |
||||||
|
loading = true; |
||||||
|
try { |
||||||
|
if (query && query.trim()) { |
||||||
|
gifs = await searchGifs(query.trim(), 50); |
||||||
|
} else { |
||||||
|
gifs = await fetchGifs(undefined, 50); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('[GifPicker] Error loading GIFs:', error); |
||||||
|
gifs = []; |
||||||
|
} 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(); |
||||||
|
} |
||||||
|
} |
||||||
|
</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 gifs.length === 0} |
||||||
|
<div class="gif-empty"> |
||||||
|
{#if searchQuery} |
||||||
|
No GIFs found for "{searchQuery}" |
||||||
|
{:else} |
||||||
|
No GIFs available |
||||||
|
{/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" |
||||||
|
/> |
||||||
|
</button> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</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 { |
||||||
|
text-align: center; |
||||||
|
padding: 2rem; |
||||||
|
color: var(--fog-text-light, #9ca3af); |
||||||
|
font-size: 0.875rem; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .gif-loading, |
||||||
|
:global(.dark) .gif-empty { |
||||||
|
color: var(--fog-dark-text-light, #6b7280); |
||||||
|
} |
||||||
|
|
||||||
|
.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; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,159 @@ |
|||||||
|
/** |
||||||
|
* Service to fetch GIFs from Nostr NIP94 events |
||||||
|
* NIP94 events (kind 94) contain file attachment metadata |
||||||
|
*/ |
||||||
|
|
||||||
|
import { nostrClient } from './nostr-client.js'; |
||||||
|
import { relayManager } from './relay-manager.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
export interface GifMetadata { |
||||||
|
url: string; |
||||||
|
fallbackUrl?: string; |
||||||
|
sha256?: string; |
||||||
|
mimeType?: string; |
||||||
|
width?: number; |
||||||
|
height?: number; |
||||||
|
eventId: string; |
||||||
|
pubkey: string; |
||||||
|
createdAt: number; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse NIP94 event to extract GIF metadata |
||||||
|
*/ |
||||||
|
function parseNip94Event(event: NostrEvent): GifMetadata | null { |
||||||
|
// NIP94 events can have different tag structures
|
||||||
|
// Try to find URL in various tag formats: url, file, or in content
|
||||||
|
let url: string | undefined; |
||||||
|
|
||||||
|
// First try 'url' tag
|
||||||
|
const urlTag = event.tags.find(t => t[0] === 'url' && t[1]); |
||||||
|
if (urlTag && urlTag[1]) { |
||||||
|
url = urlTag[1]; |
||||||
|
} else { |
||||||
|
// Try 'file' tag (NIP-94 format)
|
||||||
|
const fileTag = event.tags.find(t => t[0] === 'file' && t[1]); |
||||||
|
if (fileTag && fileTag[1]) { |
||||||
|
url = fileTag[1]; |
||||||
|
} else { |
||||||
|
// Try to extract URL from content (might be in markdown or plain text)
|
||||||
|
const urlMatch = event.content.match(/https?:\/\/[^\s<>"']+\.gif(\?[^\s<>"']*)?/i); |
||||||
|
if (urlMatch) { |
||||||
|
url = urlMatch[0]; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (!url) { |
||||||
|
console.debug('[gif-service] No URL found in event:', event.id); |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// Check if it's a GIF by MIME type, file extension, or URL pattern
|
||||||
|
const mimeTag = event.tags.find(t => t[0] === 'm' && t[1]); |
||||||
|
const mimeType = mimeTag?.[1] || ''; |
||||||
|
const urlLower = url.toLowerCase(); |
||||||
|
|
||||||
|
// More flexible GIF detection
|
||||||
|
const isGif =
|
||||||
|
mimeType === 'image/gif' ||
|
||||||
|
urlLower.endsWith('.gif') || |
||||||
|
urlLower.includes('.gif?') || |
||||||
|
urlLower.includes('/gif') || |
||||||
|
(mimeType.startsWith('image/') && event.content.toLowerCase().includes('gif')); |
||||||
|
|
||||||
|
if (!isGif) { |
||||||
|
console.debug('[gif-service] Not a GIF:', { url, mimeType, eventId: event.id }); |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// Extract optional metadata
|
||||||
|
const sha256Tag = event.tags.find(t => t[0] === 'x' && t[1]); |
||||||
|
const dimTag = event.tags.find(t => t[0] === 'dim' && t[1]); |
||||||
|
const fallbackTag = event.tags.find(t => t[0] === 'fallback' && t[1]); |
||||||
|
|
||||||
|
let width: number | undefined; |
||||||
|
let height: number | undefined; |
||||||
|
if (dimTag && dimTag[1]) { |
||||||
|
// Format: "widthxheight" or "widthxheightxfps" for videos
|
||||||
|
const dims = dimTag[1].split('x'); |
||||||
|
if (dims.length >= 2) { |
||||||
|
width = parseInt(dims[0], 10); |
||||||
|
height = parseInt(dims[1], 10); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
url, |
||||||
|
fallbackUrl: fallbackTag?.[1], |
||||||
|
sha256: sha256Tag?.[1], |
||||||
|
mimeType: mimeType || 'image/gif', |
||||||
|
width, |
||||||
|
height, |
||||||
|
eventId: event.id, |
||||||
|
pubkey: event.pubkey, |
||||||
|
createdAt: event.created_at |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch GIFs from Nostr NIP94 events |
||||||
|
* @param searchQuery Optional search query to filter GIFs (searches in content/tags) |
||||||
|
* @param limit Maximum number of GIFs to return |
||||||
|
*/ |
||||||
|
export async function fetchGifs(searchQuery?: string, limit: number = 50): Promise<GifMetadata[]> { |
||||||
|
try { |
||||||
|
// Use profile read relays to get GIFs
|
||||||
|
const relays = relayManager.getProfileReadRelays(); |
||||||
|
|
||||||
|
// Fetch kind 94 events (NIP94 file attachments)
|
||||||
|
const filters = [{ |
||||||
|
kinds: [94], |
||||||
|
limit: limit * 2 // Fetch more to filter for GIFs
|
||||||
|
}]; |
||||||
|
|
||||||
|
const events = await nostrClient.fetchEvents(filters, relays, { |
||||||
|
useCache: true, |
||||||
|
cacheResults: true |
||||||
|
}); |
||||||
|
|
||||||
|
// Parse and filter for GIFs
|
||||||
|
const gifs: GifMetadata[] = []; |
||||||
|
console.log(`[gif-service] Processing ${events.length} kind 94 events`); |
||||||
|
|
||||||
|
for (const event of events) { |
||||||
|
const gif = parseNip94Event(event); |
||||||
|
if (gif) { |
||||||
|
// If search query provided, filter by content or tags
|
||||||
|
if (searchQuery) { |
||||||
|
const query = searchQuery.toLowerCase(); |
||||||
|
const content = event.content.toLowerCase(); |
||||||
|
const tags = event.tags.flat().join(' ').toLowerCase(); |
||||||
|
|
||||||
|
if (content.includes(query) || tags.includes(query)) { |
||||||
|
gifs.push(gif); |
||||||
|
} |
||||||
|
} else { |
||||||
|
gifs.push(gif); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
console.log(`[gif-service] Found ${gifs.length} GIFs from ${events.length} events`); |
||||||
|
|
||||||
|
// Sort by creation date (newest first) and limit
|
||||||
|
gifs.sort((a, b) => b.createdAt - a.createdAt); |
||||||
|
return gifs.slice(0, limit); |
||||||
|
} catch (error) { |
||||||
|
console.error('[gif-service] Error fetching GIFs:', error); |
||||||
|
return []; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Search GIFs by query |
||||||
|
*/ |
||||||
|
export async function searchGifs(query: string, limit: number = 50): Promise<GifMetadata[]> { |
||||||
|
return fetchGifs(query, limit); |
||||||
|
} |
||||||
@ -1,39 +1,81 @@ |
|||||||
/** |
/** |
||||||
* Text utility functions |
* Utility functions for text manipulation |
||||||
*/ |
*/ |
||||||
|
|
||||||
/** |
/** |
||||||
* Strip markdown formatting from text, returning plain text |
* Insert text at the current cursor position in a textarea |
||||||
|
* @param textarea The textarea element |
||||||
|
* @param text The text to insert |
||||||
*/ |
*/ |
||||||
export function stripMarkdown(text: string): string { |
export function insertTextAtCursor(textarea: HTMLTextAreaElement, text: string): void { |
||||||
// Remove code blocks (```code```)
|
const start = textarea.selectionStart; |
||||||
|
const end = textarea.selectionEnd; |
||||||
|
const currentValue = textarea.value; |
||||||
|
|
||||||
|
// Insert text at cursor position
|
||||||
|
const newValue = currentValue.substring(0, start) + text + currentValue.substring(end); |
||||||
|
|
||||||
|
// Update textarea value
|
||||||
|
textarea.value = newValue; |
||||||
|
|
||||||
|
// Set cursor position after inserted text
|
||||||
|
const newCursorPos = start + text.length; |
||||||
|
textarea.setSelectionRange(newCursorPos, newCursorPos); |
||||||
|
|
||||||
|
// Trigger input event so Svelte bindings update
|
||||||
|
textarea.dispatchEvent(new Event('input', { bubbles: true })); |
||||||
|
|
||||||
|
// Focus the textarea
|
||||||
|
textarea.focus(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Strip markdown formatting from text to get plain text |
||||||
|
* @param markdown The markdown text to strip |
||||||
|
* @returns Plain text without markdown formatting |
||||||
|
*/ |
||||||
|
export function stripMarkdown(markdown: string): string { |
||||||
|
if (!markdown) return ''; |
||||||
|
|
||||||
|
let text = markdown; |
||||||
|
|
||||||
|
// Remove code blocks
|
||||||
text = text.replace(/```[\s\S]*?```/g, ''); |
text = text.replace(/```[\s\S]*?```/g, ''); |
||||||
// Remove inline code (`code`)
|
|
||||||
text = text.replace(/`[^`]*`/g, ''); |
text = text.replace(/`[^`]*`/g, ''); |
||||||
// Remove images ()
|
|
||||||
|
// Remove images
|
||||||
text = text.replace(/!\[([^\]]*)\]\([^\)]*\)/g, ''); |
text = text.replace(/!\[([^\]]*)\]\([^\)]*\)/g, ''); |
||||||
// Remove links ([text](url)) - keep the link text
|
|
||||||
text = text.replace(/\[([^\]]*)\]\([^\)]*\)/g, '$1'); |
// Remove links but keep text
|
||||||
// Remove headers (# ## ###)
|
text = text.replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1'); |
||||||
text = text.replace(/^#{1,6}\s+/gm, ''); |
|
||||||
// Remove bold (**text** or __text__)
|
// Remove headers
|
||||||
text = text.replace(/\*\*([^*]+)\*\*/g, '$1'); |
text = text.replace(/^#{1,6}\s+(.+)$/gm, '$1'); |
||||||
|
|
||||||
|
// Remove bold/italic
|
||||||
|
text = text.replace(/\*\*([^\*]+)\*\*/g, '$1'); |
||||||
|
text = text.replace(/\*([^\*]+)\*/g, '$1'); |
||||||
text = text.replace(/__([^_]+)__/g, '$1'); |
text = text.replace(/__([^_]+)__/g, '$1'); |
||||||
// Remove italic (*text* or _text_)
|
|
||||||
text = text.replace(/\*([^*]+)\*/g, '$1'); |
|
||||||
text = text.replace(/_([^_]+)_/g, '$1'); |
text = text.replace(/_([^_]+)_/g, '$1'); |
||||||
// Remove strikethrough (~~text~~)
|
|
||||||
|
// Remove strikethrough
|
||||||
text = text.replace(/~~([^~]+)~~/g, '$1'); |
text = text.replace(/~~([^~]+)~~/g, '$1'); |
||||||
// Remove blockquotes (> text)
|
|
||||||
text = text.replace(/^>\s+/gm, ''); |
// Remove blockquotes
|
||||||
// Remove list markers (- * + or 1. 2. etc)
|
text = text.replace(/^>\s+(.+)$/gm, '$1'); |
||||||
text = text.replace(/^[\s]*[-*+]\s+/gm, ''); |
|
||||||
text = text.replace(/^\d+\.\s+/gm, ''); |
// Remove horizontal rules
|
||||||
// Remove horizontal rules (--- or ***)
|
text = text.replace(/^---$/gm, ''); |
||||||
text = text.replace(/^[-*]{3,}$/gm, ''); |
text = text.replace(/^___$/gm, ''); |
||||||
// Replace newlines with spaces
|
text = text.replace(/^\*\*\*$/gm, ''); |
||||||
text = text.replace(/\n/g, ' '); |
|
||||||
// Remove extra whitespace
|
// Remove list markers
|
||||||
text = text.replace(/\s+/g, ' ').trim(); |
text = text.replace(/^[\*\-\+]\s+(.+)$/gm, '$1'); |
||||||
|
text = text.replace(/^\d+\.\s+(.+)$/gm, '$1'); |
||||||
|
|
||||||
|
// Clean up extra whitespace
|
||||||
|
text = text.replace(/\n{3,}/g, '\n\n'); |
||||||
|
text = text.trim(); |
||||||
|
|
||||||
return text; |
return text; |
||||||
} |
} |
||||||
|
|||||||
Loading…
Reference in new issue