Initial commit - realms platform
This commit is contained in:
parent
c590ab6d18
commit
c717c3751c
234 changed files with 74103 additions and 15231 deletions
515
frontend/src/lib/components/chat/StickerBrowser.svelte
Normal file
515
frontend/src/lib/components/chat/StickerBrowser.svelte
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { stickerFavorites } from '$lib/chat/stickerFavorites';
|
||||
import { stickers as sharedStickers, ensureLoaded, isLoaded } from '$lib/stores/stickers';
|
||||
|
||||
export let onSelect = null; // callback when sticker is selected for insertion
|
||||
|
||||
let filteredStickers = [];
|
||||
let searchQuery = '';
|
||||
let loading = true;
|
||||
let error = null;
|
||||
|
||||
// Lazy loading state
|
||||
let visibleStickers = [];
|
||||
let batchSize = 50;
|
||||
let loadedCount = 0;
|
||||
let containerElement;
|
||||
let sentinelElement;
|
||||
let observer;
|
||||
|
||||
// Context menu state
|
||||
let contextMenu = null; // { sticker, x, y }
|
||||
|
||||
onMount(async () => {
|
||||
// Use shared sticker store - only fetches once across all components
|
||||
loading = true;
|
||||
try {
|
||||
await ensureLoaded();
|
||||
applyFilter();
|
||||
} catch (e) {
|
||||
error = 'Failed to load stickers';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
setupIntersectionObserver();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
function applyFilter() {
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
if (query) {
|
||||
filteredStickers = $sharedStickers.filter(s =>
|
||||
s.name.toLowerCase().includes(query)
|
||||
);
|
||||
} else {
|
||||
filteredStickers = [...$sharedStickers];
|
||||
}
|
||||
// Reset lazy loading
|
||||
loadedCount = 0;
|
||||
loadMoreStickers();
|
||||
}
|
||||
|
||||
function loadMoreStickers() {
|
||||
const nextBatch = filteredStickers.slice(loadedCount, loadedCount + batchSize);
|
||||
if (nextBatch.length > 0) {
|
||||
visibleStickers = [...visibleStickers.slice(0, loadedCount), ...nextBatch];
|
||||
loadedCount += nextBatch.length;
|
||||
}
|
||||
}
|
||||
|
||||
function setupIntersectionObserver() {
|
||||
if (!sentinelElement) return;
|
||||
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && loadedCount < filteredStickers.length) {
|
||||
loadMoreStickers();
|
||||
}
|
||||
});
|
||||
}, {
|
||||
root: containerElement,
|
||||
rootMargin: '100px',
|
||||
threshold: 0
|
||||
});
|
||||
|
||||
observer.observe(sentinelElement);
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
visibleStickers = [];
|
||||
applyFilter();
|
||||
}
|
||||
|
||||
function handleStickerClick(sticker) {
|
||||
if (onSelect) {
|
||||
onSelect(`:${sticker.name}:`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleContextMenu(event, sticker) {
|
||||
event.preventDefault();
|
||||
contextMenu = {
|
||||
sticker,
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
};
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenu = null;
|
||||
}
|
||||
|
||||
function handleClickOutside(event) {
|
||||
if (contextMenu && !event.target.closest('.sticker-context-menu')) {
|
||||
closeContextMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFavorite() {
|
||||
if (contextMenu) {
|
||||
stickerFavorites.toggle(contextMenu.sticker.name);
|
||||
closeContextMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function openInNewTab() {
|
||||
if (contextMenu) {
|
||||
window.open(contextMenu.sticker.filePath, '_blank');
|
||||
closeContextMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function copyImageUrl() {
|
||||
if (contextMenu) {
|
||||
const fullUrl = window.location.origin + contextMenu.sticker.filePath;
|
||||
navigator.clipboard.writeText(fullUrl).catch(() => {});
|
||||
closeContextMenu();
|
||||
}
|
||||
}
|
||||
|
||||
$: isFavorite = contextMenu ? $stickerFavorites.includes(contextMenu.sticker.name) : false;
|
||||
|
||||
// Get favorite stickers from the loaded stickers
|
||||
$: favoriteStickers = $sharedStickers.filter(s => $stickerFavorites.includes(s.name));
|
||||
|
||||
// Re-apply filter when search changes
|
||||
$: if (searchQuery !== undefined) {
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function handleFavoriteClick(sticker) {
|
||||
if (onSelect) {
|
||||
onSelect(`:${sticker.name}:`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFavoriteContextMenu(event, sticker) {
|
||||
event.preventDefault();
|
||||
contextMenu = {
|
||||
sticker,
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={handleClickOutside} />
|
||||
|
||||
<div class="sticker-browser">
|
||||
<!-- Favorites Section -->
|
||||
{#if favoriteStickers.length > 0}
|
||||
<div class="favorites-section">
|
||||
<div class="favorites-header">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 1.314C12.438-3.248 23.534 4.735 8 15-7.534 4.736 3.562-3.248 8 1.314z"/>
|
||||
</svg>
|
||||
<span>Favorites</span>
|
||||
<span class="favorites-count">{favoriteStickers.length}</span>
|
||||
</div>
|
||||
<div class="favorites-grid">
|
||||
{#each favoriteStickers as sticker (sticker.id)}
|
||||
<button
|
||||
class="favorite-sticker"
|
||||
on:click={() => handleFavoriteClick(sticker)}
|
||||
on:contextmenu={(e) => handleFavoriteContextMenu(e, sticker)}
|
||||
title={`:${sticker.name}:`}
|
||||
>
|
||||
<img
|
||||
src={sticker.filePath}
|
||||
alt={sticker.name}
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="search-bar">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search stickers..."
|
||||
bind:value={searchQuery}
|
||||
class="search-input"
|
||||
/>
|
||||
<span class="sticker-count">{filteredStickers.length} stickers</span>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Loading stickers...</div>
|
||||
{:else if error}
|
||||
<div class="error">{error}</div>
|
||||
{:else if filteredStickers.length === 0}
|
||||
<div class="empty">
|
||||
{#if searchQuery}
|
||||
No stickers found for "{searchQuery}"
|
||||
{:else}
|
||||
No stickers available
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="sticker-grid" bind:this={containerElement}>
|
||||
{#each visibleStickers as sticker (sticker.id)}
|
||||
<button
|
||||
class="sticker-item"
|
||||
class:favorite={$stickerFavorites.includes(sticker.name)}
|
||||
on:click={() => handleStickerClick(sticker)}
|
||||
on:contextmenu={(e) => handleContextMenu(e, sticker)}
|
||||
title={`:${sticker.name}:`}
|
||||
>
|
||||
<img
|
||||
src={sticker.filePath}
|
||||
alt={sticker.name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<span class="sticker-name">{sticker.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<!-- Sentinel for infinite scroll -->
|
||||
<div bind:this={sentinelElement} class="sentinel"></div>
|
||||
|
||||
{#if loadedCount < filteredStickers.length}
|
||||
<div class="loading-more">Loading more...</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Context Menu -->
|
||||
{#if contextMenu}
|
||||
<div
|
||||
class="sticker-context-menu"
|
||||
style="left: {contextMenu.x}px; top: {contextMenu.y}px;"
|
||||
>
|
||||
<button class="context-menu-item" on:click={toggleFavorite}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 1.314C12.438-3.248 23.534 4.735 8 15-7.534 4.736 3.562-3.248 8 1.314z"/>
|
||||
</svg>
|
||||
{isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}
|
||||
</button>
|
||||
<button class="context-menu-item" on:click={openInNewTab}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5z"/>
|
||||
<path d="M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0v-5z"/>
|
||||
</svg>
|
||||
Open in New Tab
|
||||
</button>
|
||||
<button class="context-menu-item" on:click={copyImageUrl}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
|
||||
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
|
||||
</svg>
|
||||
Copy Image URL
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.sticker-browser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #0d1117;
|
||||
}
|
||||
|
||||
/* Favorites Section */
|
||||
.favorites-section {
|
||||
background: #161b22;
|
||||
border-bottom: 1px solid #30363d;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.favorites-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #f85149;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.favorites-header svg {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.favorites-count {
|
||||
color: #8b949e;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.favorites-grid {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.favorites-grid::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.favorites-grid::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.favorites-grid::-webkit-scrollbar-thumb {
|
||||
background: #30363d;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.favorite-sticker {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0.25rem;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.favorite-sticker:hover {
|
||||
background: #21262d;
|
||||
border-color: #f85149;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.favorite-sticker img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: #161b22;
|
||||
border-bottom: 1px solid #30363d;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: #c9d1d9;
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #58a6ff;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #6e7681;
|
||||
}
|
||||
|
||||
.sticker-count {
|
||||
color: #8b949e;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sticker-grid {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
gap: 0.5rem;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.sticker-grid::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.sticker-grid::-webkit-scrollbar-track {
|
||||
background: #0d1117;
|
||||
}
|
||||
|
||||
.sticker-grid::-webkit-scrollbar-thumb {
|
||||
background: #30363d;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sticker-grid::-webkit-scrollbar-thumb:hover {
|
||||
background: #484f58;
|
||||
}
|
||||
|
||||
.sticker-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.sticker-item:hover {
|
||||
background: #21262d;
|
||||
border-color: #484f58;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.sticker-item.favorite {
|
||||
border-color: #f85149;
|
||||
background: rgba(248, 81, 73, 0.1);
|
||||
}
|
||||
|
||||
.sticker-item img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.sticker-name {
|
||||
font-size: 0.65rem;
|
||||
color: #8b949e;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.sentinel {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.empty,
|
||||
.loading-more {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #f85149;
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
grid-column: 1 / -1;
|
||||
padding: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Context Menu */
|
||||
.sticker-context-menu {
|
||||
position: fixed;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
padding: 0.25rem;
|
||||
min-width: 180px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #c9d1d9;
|
||||
font-size: 0.875rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
background: #30363d;
|
||||
}
|
||||
|
||||
.context-menu-item svg {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue