Initial commit - realms platform
This commit is contained in:
parent
c590ab6d18
commit
c717c3751c
234 changed files with 74103 additions and 15231 deletions
711
frontend/src/lib/components/chat/ChatInput.svelte
Normal file
711
frontend/src/lib/components/chat/ChatInput.svelte
Normal file
|
|
@ -0,0 +1,711 @@
|
|||
<script>
|
||||
import { createEventDispatcher, onMount, tick } from 'svelte';
|
||||
import { stickerFavorites } from '$lib/chat/stickerFavorites';
|
||||
import { stickers, stickersMap as sharedStickersMap, ensureLoaded } from '$lib/stores/stickers';
|
||||
|
||||
export let disabled = false;
|
||||
export let username = '';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let message = '';
|
||||
let maxLength = 500;
|
||||
let selfDestructSeconds = 0; // 0 = permanent
|
||||
let showTimerMenu = false;
|
||||
let showFavoritesMenu = false;
|
||||
let inputElement;
|
||||
let favoriteContextMenu = null; // { stickerName, x, y }
|
||||
|
||||
// Autocomplete state
|
||||
let showAutocomplete = false;
|
||||
let autocompleteQuery = '';
|
||||
let autocompleteSuggestions = [];
|
||||
let selectedIndex = 0;
|
||||
let lastSentMessage = '';
|
||||
|
||||
onMount(async () => {
|
||||
// Ensure stickers are loaded (uses shared store - only fetches once across all components)
|
||||
await ensureLoaded();
|
||||
});
|
||||
|
||||
// Function to insert text at cursor or append to message
|
||||
export function insertText(text) {
|
||||
if (inputElement) {
|
||||
const start = inputElement.selectionStart;
|
||||
const end = inputElement.selectionEnd;
|
||||
const before = message.slice(0, start);
|
||||
const after = message.slice(end);
|
||||
message = before + text + after;
|
||||
// Focus and set cursor position after inserted text
|
||||
setTimeout(() => {
|
||||
inputElement.focus();
|
||||
const newPos = start + text.length;
|
||||
inputElement.setSelectionRange(newPos, newPos);
|
||||
}, 0);
|
||||
} else {
|
||||
message += text;
|
||||
}
|
||||
}
|
||||
|
||||
const timerOptions = [
|
||||
{ label: 'Off', value: 0 },
|
||||
{ label: '5s', value: 5 },
|
||||
{ label: '10s', value: 10 },
|
||||
{ label: '30s', value: 30 },
|
||||
{ label: '1m', value: 60 },
|
||||
{ label: '5m', value: 300 }
|
||||
];
|
||||
|
||||
// Autocomplete: detect :query pattern and filter stickers
|
||||
function handleInput() {
|
||||
if (!inputElement) return;
|
||||
|
||||
const cursorPos = inputElement.selectionStart;
|
||||
const textBeforeCursor = message.slice(0, cursorPos);
|
||||
|
||||
// Find the last unmatched : before cursor
|
||||
const lastColonIndex = textBeforeCursor.lastIndexOf(':');
|
||||
|
||||
if (lastColonIndex === -1) {
|
||||
showAutocomplete = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if there's a closing : between the last : and cursor (means it's already complete)
|
||||
const textAfterColon = textBeforeCursor.slice(lastColonIndex + 1);
|
||||
if (textAfterColon.includes(':')) {
|
||||
showAutocomplete = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract query (text after :)
|
||||
const query = textAfterColon.toLowerCase();
|
||||
|
||||
// Only show autocomplete if there's at least 1 character after :
|
||||
if (query.length < 1) {
|
||||
showAutocomplete = false;
|
||||
return;
|
||||
}
|
||||
|
||||
autocompleteQuery = query;
|
||||
updateAutocompleteSuggestions(query);
|
||||
}
|
||||
|
||||
function updateAutocompleteSuggestions(query) {
|
||||
if (!query || $stickers.length === 0) {
|
||||
autocompleteSuggestions = [];
|
||||
showAutocomplete = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter stickers that contain the query
|
||||
const matches = $stickers.filter((s) =>
|
||||
s.name.toLowerCase().includes(query)
|
||||
);
|
||||
|
||||
// Sort: stickers starting with query first, then others
|
||||
matches.sort((a, b) => {
|
||||
const aStartsWith = a.name.toLowerCase().startsWith(query);
|
||||
const bStartsWith = b.name.toLowerCase().startsWith(query);
|
||||
|
||||
if (aStartsWith && !bStartsWith) return -1;
|
||||
if (!aStartsWith && bStartsWith) return 1;
|
||||
|
||||
// Secondary sort: alphabetically
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Limit to 8 suggestions
|
||||
autocompleteSuggestions = matches.slice(0, 8);
|
||||
selectedIndex = 0;
|
||||
showAutocomplete = autocompleteSuggestions.length > 0;
|
||||
}
|
||||
|
||||
function selectAutocomplete(sticker) {
|
||||
if (!inputElement) return;
|
||||
|
||||
const cursorPos = inputElement.selectionStart;
|
||||
const textBeforeCursor = message.slice(0, cursorPos);
|
||||
const lastColonIndex = textBeforeCursor.lastIndexOf(':');
|
||||
|
||||
if (lastColonIndex === -1) return;
|
||||
|
||||
// Replace from : to cursor with :stickerName:
|
||||
const before = message.slice(0, lastColonIndex);
|
||||
const after = message.slice(cursorPos);
|
||||
const stickerText = `:${sticker.name}:`;
|
||||
|
||||
message = before + stickerText + after;
|
||||
showAutocomplete = false;
|
||||
|
||||
// Set cursor position after the inserted sticker
|
||||
tick().then(() => {
|
||||
const newPos = lastColonIndex + stickerText.length;
|
||||
inputElement.focus();
|
||||
inputElement.setSelectionRange(newPos, newPos);
|
||||
});
|
||||
}
|
||||
|
||||
$: activeTimerLabel = timerOptions.find(t => t.value === selfDestructSeconds)?.label || 'Off';
|
||||
|
||||
function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!message.trim() || disabled) return;
|
||||
|
||||
const trimmedMessage = message.trim();
|
||||
lastSentMessage = trimmedMessage;
|
||||
dispatch('send', { message: trimmedMessage, selfDestructSeconds });
|
||||
message = '';
|
||||
selfDestructSeconds = 0; // Reset timer after sending
|
||||
}
|
||||
|
||||
function handleKeyDown(event) {
|
||||
// Handle autocomplete keyboard navigation
|
||||
if (showAutocomplete && autocompleteSuggestions.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
selectedIndex = (selectedIndex + 1) % autocompleteSuggestions.length;
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
selectedIndex = (selectedIndex - 1 + autocompleteSuggestions.length) % autocompleteSuggestions.length;
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
selectAutocomplete(autocompleteSuggestions[selectedIndex]);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
showAutocomplete = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Recall last sent message with up arrow when input is empty
|
||||
if (event.key === 'ArrowUp' && message === '' && lastSentMessage) {
|
||||
event.preventDefault();
|
||||
message = lastSentMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSubmit(event);
|
||||
}
|
||||
}
|
||||
|
||||
function selectTimer(value) {
|
||||
selfDestructSeconds = value;
|
||||
showTimerMenu = false;
|
||||
}
|
||||
|
||||
function handleClickOutside(event) {
|
||||
if (showTimerMenu && !event.target.closest('.timer-container')) {
|
||||
showTimerMenu = false;
|
||||
}
|
||||
if (showFavoritesMenu && !event.target.closest('.favorites-container')) {
|
||||
showFavoritesMenu = false;
|
||||
}
|
||||
if (favoriteContextMenu && !event.target.closest('.favorite-context-menu')) {
|
||||
favoriteContextMenu = null;
|
||||
}
|
||||
if (showAutocomplete && !event.target.closest('.autocomplete-dropdown') && !event.target.closest('.input-wrapper')) {
|
||||
showAutocomplete = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectFavorite(stickerName) {
|
||||
insertText(`:${stickerName}:`);
|
||||
showFavoritesMenu = false;
|
||||
}
|
||||
|
||||
function handleFavoriteContextMenu(event, stickerName) {
|
||||
event.preventDefault();
|
||||
favoriteContextMenu = {
|
||||
stickerName,
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
};
|
||||
}
|
||||
|
||||
function removeFavorite() {
|
||||
if (favoriteContextMenu && confirm(`Remove "${favoriteContextMenu.stickerName}" from favorites?`)) {
|
||||
stickerFavorites.toggle(favoriteContextMenu.stickerName);
|
||||
}
|
||||
favoriteContextMenu = null;
|
||||
}
|
||||
|
||||
function copyImageLink() {
|
||||
if (favoriteContextMenu) {
|
||||
const url = $sharedStickersMap[favoriteContextMenu.stickerName.toLowerCase()];
|
||||
if (url) {
|
||||
const fullUrl = window.location.origin + url;
|
||||
navigator.clipboard.writeText(fullUrl);
|
||||
}
|
||||
}
|
||||
favoriteContextMenu = null;
|
||||
}
|
||||
|
||||
function openImageInNewTab() {
|
||||
if (favoriteContextMenu) {
|
||||
const url = $sharedStickersMap[favoriteContextMenu.stickerName.toLowerCase()];
|
||||
if (url) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
}
|
||||
favoriteContextMenu = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={handleClickOutside} />
|
||||
|
||||
<form class="chat-input" on:submit={handleSubmit}>
|
||||
<div class="input-wrapper">
|
||||
<div class="input-icons">
|
||||
<div class="timer-container">
|
||||
<button
|
||||
type="button"
|
||||
class="timer-btn"
|
||||
class:active={selfDestructSeconds > 0}
|
||||
on:click={() => showTimerMenu = !showTimerMenu}
|
||||
title="Self-destruct timer"
|
||||
{disabled}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z"/>
|
||||
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/>
|
||||
</svg>
|
||||
{#if selfDestructSeconds > 0}
|
||||
<span class="timer-value">{activeTimerLabel}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if showTimerMenu}
|
||||
<div class="timer-menu">
|
||||
{#each timerOptions as option}
|
||||
<button
|
||||
type="button"
|
||||
class="timer-option"
|
||||
class:selected={selfDestructSeconds === option.value}
|
||||
on:click={() => selectTimer(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if $stickerFavorites.length > 0}
|
||||
<div class="favorites-container">
|
||||
<button
|
||||
type="button"
|
||||
class="favorites-btn"
|
||||
on:click={() => showFavoritesMenu = !showFavoritesMenu}
|
||||
title="Favorite stickers"
|
||||
{disabled}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M3.612 15.443c-.386.198-.824-.149-.746-.592l.83-4.73L.173 6.765c-.329-.314-.158-.888.283-.95l4.898-.696L7.538.792c.197-.39.73-.39.927 0l2.184 4.327 4.898.696c.441.062.612.636.282.95l-3.522 3.356.83 4.73c.078.443-.36.79-.746.592L8 13.187l-4.389 2.256z"/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if showFavoritesMenu}
|
||||
<div class="favorites-menu">
|
||||
{#each $stickerFavorites as stickerName}
|
||||
<button
|
||||
type="button"
|
||||
class="favorite-item"
|
||||
on:click={() => selectFavorite(stickerName)}
|
||||
on:contextmenu={(e) => handleFavoriteContextMenu(e, stickerName)}
|
||||
title=":{stickerName}: (right-click for options)"
|
||||
>
|
||||
{#if $sharedStickersMap[stickerName.toLowerCase()]}
|
||||
<img src={$sharedStickersMap[stickerName.toLowerCase()]} alt={stickerName} />
|
||||
{:else}
|
||||
<span class="sticker-name">:{stickerName}:</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
bind:this={inputElement}
|
||||
bind:value={message}
|
||||
on:keydown={handleKeyDown}
|
||||
on:input={handleInput}
|
||||
placeholder={disabled
|
||||
? 'Connecting to chat...'
|
||||
: `Chat as ${username || 'Guest'}...`}
|
||||
{disabled}
|
||||
maxlength={maxLength}
|
||||
/>
|
||||
<span class="char-count" class:warning={message.length > maxLength * 0.9}>
|
||||
{message.length}/{maxLength}
|
||||
</span>
|
||||
{#if showAutocomplete && autocompleteSuggestions.length > 0}
|
||||
<div class="autocomplete-dropdown">
|
||||
{#each autocompleteSuggestions as sticker, index}
|
||||
<button
|
||||
type="button"
|
||||
class="autocomplete-item"
|
||||
class:selected={index === selectedIndex}
|
||||
on:click={() => selectAutocomplete(sticker)}
|
||||
on:mouseenter={() => selectedIndex = index}
|
||||
>
|
||||
<img
|
||||
src={sticker.filePath}
|
||||
alt={sticker.name}
|
||||
class="autocomplete-preview"
|
||||
/>
|
||||
<span class="autocomplete-name">:{sticker.name}:</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if favoriteContextMenu}
|
||||
<div
|
||||
class="favorite-context-menu"
|
||||
style="left: {favoriteContextMenu.x}px; top: {favoriteContextMenu.y}px;"
|
||||
>
|
||||
<button on:click={openImageInNewTab}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path fill-rule="evenodd" 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 fill-rule="evenodd" 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 on:click={copyImageLink}>
|
||||
<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 link
|
||||
</button>
|
||||
<div class="context-menu-divider"></div>
|
||||
<button class="danger" on:click={removeFavorite}>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M2.5 1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1H3v9a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V4h.5a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H10a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1H2.5zm3 4a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-1 0v-7a.5.5 0 0 1 .5-.5zM8 5a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-1 0v-7A.5.5 0 0 1 8 5zm3 .5v7a.5.5 0 0 1-1 0v-7a.5.5 0 0 1 1 0z"/>
|
||||
</svg>
|
||||
Remove from favorites
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.chat-input {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0;
|
||||
border-top: 1px solid #333;
|
||||
background: #0d0d0d;
|
||||
flex-shrink: 0; /* Prevent input from shrinking */
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #222;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.input-wrapper:focus-within {
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
|
||||
.input-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding-left: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
padding: 0.75rem 0.5rem;
|
||||
padding-right: 4rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.char-count {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.char-count.warning {
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
/* Sticker autocomplete dropdown */
|
||||
.autocomplete-dropdown {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.autocomplete-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ccc;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.autocomplete-item:hover,
|
||||
.autocomplete-item.selected {
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.autocomplete-preview {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.autocomplete-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timer-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timer-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.35rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.timer-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.timer-btn.active {
|
||||
background: rgba(255, 152, 0, 0.15);
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.timer-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.timer-value {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timer-menu {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.timer-option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ccc;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.timer-option:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.timer-option.selected {
|
||||
background: rgba(255, 152, 0, 0.2);
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.favorites-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.favorites-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.35rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.favorites-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #f5c518;
|
||||
}
|
||||
|
||||
.favorites-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.favorites-menu {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.favorite-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.favorite-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.favorite-item img {
|
||||
max-width: 32px;
|
||||
max-height: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.favorite-item .sticker-name {
|
||||
font-size: 0.7rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.favorite-context-menu {
|
||||
position: fixed;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.favorite-context-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ccc;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.favorite-context-menu button:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.favorite-context-menu button.danger {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.favorite-context-menu button.danger:hover {
|
||||
background: rgba(255, 107, 107, 0.15);
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.context-menu-divider {
|
||||
height: 1px;
|
||||
background: #333;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
</style>
|
||||
1026
frontend/src/lib/components/chat/ChatMessage.svelte
Normal file
1026
frontend/src/lib/components/chat/ChatMessage.svelte
Normal file
File diff suppressed because it is too large
Load diff
1392
frontend/src/lib/components/chat/ChatPanel.svelte
Normal file
1392
frontend/src/lib/components/chat/ChatPanel.svelte
Normal file
File diff suppressed because it is too large
Load diff
733
frontend/src/lib/components/chat/ChatTerminal.svelte
Normal file
733
frontend/src/lib/components/chat/ChatTerminal.svelte
Normal file
|
|
@ -0,0 +1,733 @@
|
|||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { fly, fade, slide } from 'svelte/transition';
|
||||
import { browser } from '$app/environment';
|
||||
import { isAuthenticated } from '$lib/stores/auth';
|
||||
import { connectionStatus } from '$lib/chat/chatStore';
|
||||
import TerminalTabBar from '$lib/components/terminal/TerminalTabBar.svelte';
|
||||
import TerminalCore from '$lib/components/terminal/TerminalCore.svelte';
|
||||
import StreamsBrowser from '$lib/components/terminal/StreamsBrowser.svelte';
|
||||
import WatchRoomsBrowser from '$lib/components/terminal/WatchRoomsBrowser.svelte';
|
||||
import AudioBrowser from '$lib/components/terminal/AudioBrowser.svelte';
|
||||
import EbookBrowser from '$lib/components/terminal/EbookBrowser.svelte';
|
||||
import TreasuryBrowser from '$lib/components/terminal/TreasuryBrowser.svelte';
|
||||
import GamesBrowser from '$lib/components/terminal/GamesBrowser.svelte';
|
||||
import StickerBrowser from './StickerBrowser.svelte';
|
||||
import ProfilePreview from './ProfilePreview.svelte';
|
||||
|
||||
export let isOpen = false;
|
||||
export let defaultRealmId = null;
|
||||
|
||||
// Date/time state
|
||||
let currentTime = new Date();
|
||||
let showCalendar = false;
|
||||
let calendarDate = new Date();
|
||||
let timeInterval;
|
||||
|
||||
// Tab navigation - includes audio, ebooks, games, and treasury
|
||||
let activeTab = 'terminal';
|
||||
const tabs = [
|
||||
{ id: 'terminal', label: 'Terminal' },
|
||||
{ id: 'stickers', label: 'Stickers' },
|
||||
{ id: 'streams', label: 'Streams' },
|
||||
{ id: 'watch', label: 'Watch', color: '#10b981' },
|
||||
{ id: 'audio', label: 'Audio', color: '#ec4899' },
|
||||
{ id: 'ebooks', label: 'eBooks', color: '#3b82f6' },
|
||||
{ id: 'games', label: 'Games', color: '#f59e0b' },
|
||||
{ id: 'treasury', label: 'Treasury', color: '#ffd700' }
|
||||
];
|
||||
|
||||
// State
|
||||
let selectedRealmId = defaultRealmId;
|
||||
let renderStickers = false;
|
||||
let isDocked = true;
|
||||
let terminalHeight = 333;
|
||||
let isResizing = false;
|
||||
let terminalPosition = { x: 100, y: 100 };
|
||||
let isDragging = false;
|
||||
let terminalHotkey = '`';
|
||||
let activeProfilePreview = null;
|
||||
let terminalCore;
|
||||
|
||||
$: isConnected = $connectionStatus === 'connected';
|
||||
|
||||
// Global hotkey handler - only for authenticated users
|
||||
function handleKeyDown(event) {
|
||||
// Terminal is only available for authenticated users
|
||||
if (!$isAuthenticated) return;
|
||||
|
||||
if (event.key === terminalHotkey && !event.ctrlKey && !event.altKey && !event.metaKey) {
|
||||
const target = event.target;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
if (!target.classList.contains('terminal-input')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
isOpen = !isOpen;
|
||||
|
||||
if (isOpen && terminalCore) {
|
||||
terminalCore.focusInput();
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'Escape' && isOpen) {
|
||||
isOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Resize handling (docked mode)
|
||||
function startResize(e) {
|
||||
if (!isDocked) return;
|
||||
isResizing = true;
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function handleMouseMove(e) {
|
||||
if (isResizing) {
|
||||
const navHeight = 60;
|
||||
const newHeight = e.clientY - navHeight;
|
||||
terminalHeight = Math.max(200, Math.min(window.innerHeight - navHeight - 100, newHeight));
|
||||
} else if (isDragging && !isDocked) {
|
||||
terminalPosition.x += e.movementX;
|
||||
terminalPosition.y += e.movementY;
|
||||
}
|
||||
}
|
||||
|
||||
function stopResize() {
|
||||
isResizing = false;
|
||||
isDragging = false;
|
||||
}
|
||||
|
||||
// Drag handling (undocked mode)
|
||||
function startDrag(e) {
|
||||
if (!isDocked && !e.target.closest('button')) {
|
||||
isDragging = true;
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDock() {
|
||||
isDocked = !isDocked;
|
||||
if (isDocked) {
|
||||
terminalHeight = 500;
|
||||
}
|
||||
}
|
||||
|
||||
function popoutTerminal() {
|
||||
const realmParam = selectedRealmId ? `?realm=${selectedRealmId}` : '';
|
||||
const popoutUrl = `/chat/terminal${realmParam}`;
|
||||
const popoutWindow = window.open(
|
||||
popoutUrl,
|
||||
'TerminalPopout',
|
||||
'width=600,height=500,menubar=no,toolbar=no,location=no,status=no'
|
||||
);
|
||||
|
||||
if (popoutWindow) {
|
||||
isOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Tab change handler
|
||||
function handleTabChange(event) {
|
||||
activeTab = event.detail.tab;
|
||||
}
|
||||
|
||||
// Sticker selection
|
||||
function handleStickerSelect(stickerText) {
|
||||
if (terminalCore) {
|
||||
// Insert sticker text into terminal - need to access input through exposed method
|
||||
// For now, switch to terminal tab and the user can paste
|
||||
}
|
||||
activeTab = 'terminal';
|
||||
if (terminalCore) {
|
||||
terminalCore.focusInput();
|
||||
}
|
||||
}
|
||||
|
||||
// Profile preview handlers
|
||||
function handleShowProfile(event) {
|
||||
const { username, userId, isGuest, messageId, position } = event.detail;
|
||||
activeProfilePreview = { username, userId, isGuest, messageId, position };
|
||||
}
|
||||
|
||||
function handleProfileClose() {
|
||||
activeProfilePreview = null;
|
||||
}
|
||||
|
||||
function handleRealmChange(event) {
|
||||
selectedRealmId = event.detail.realmId;
|
||||
}
|
||||
|
||||
function handleStickersToggled(event) {
|
||||
renderStickers = event.detail.renderStickers;
|
||||
}
|
||||
|
||||
// Date/time formatting
|
||||
function formatTime(date) {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function toggleCalendar() {
|
||||
showCalendar = !showCalendar;
|
||||
if (showCalendar) {
|
||||
calendarDate = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
// Calendar helpers
|
||||
function getCalendarDays(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const daysInMonth = lastDay.getDate();
|
||||
const startDayOfWeek = firstDay.getDay();
|
||||
|
||||
const days = [];
|
||||
// Add empty slots for days before the first of the month
|
||||
for (let i = 0; i < startDayOfWeek; i++) {
|
||||
days.push(null);
|
||||
}
|
||||
// Add the days of the month
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push(i);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
function prevMonth() {
|
||||
calendarDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1);
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
calendarDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1);
|
||||
}
|
||||
|
||||
function isToday(day) {
|
||||
if (!day) return false;
|
||||
const today = new Date();
|
||||
return day === today.getDate() &&
|
||||
calendarDate.getMonth() === today.getMonth() &&
|
||||
calendarDate.getFullYear() === today.getFullYear();
|
||||
}
|
||||
|
||||
$: calendarDays = getCalendarDays(calendarDate);
|
||||
$: calendarMonthYear = calendarDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||
|
||||
// Timezone definitions
|
||||
const timezones = [
|
||||
{ label: 'UTC', zone: 'UTC' },
|
||||
{ label: 'Germany', zone: 'Europe/Berlin' },
|
||||
{ label: 'India', zone: 'Asia/Kolkata' },
|
||||
{ label: 'Japan', zone: 'Asia/Tokyo' },
|
||||
{ label: 'Australia', zone: 'Australia/Sydney' },
|
||||
{ label: 'PST', zone: 'America/Los_Angeles' },
|
||||
{ label: 'MST', zone: 'America/Denver' },
|
||||
{ label: 'Central', zone: 'America/Chicago' },
|
||||
{ label: 'EST', zone: 'America/New_York' }
|
||||
];
|
||||
|
||||
function getTimezoneTime(zone) {
|
||||
return currentTime.toLocaleTimeString('en-US', {
|
||||
timeZone: zone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', stopResize);
|
||||
|
||||
const savedHotkey = localStorage.getItem('terminalHotkey');
|
||||
if (savedHotkey) {
|
||||
terminalHotkey = savedHotkey;
|
||||
}
|
||||
|
||||
// Update time every second
|
||||
timeInterval = setInterval(() => {
|
||||
currentTime = new Date();
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', stopResize);
|
||||
if (timeInterval) clearInterval(timeInterval);
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', stopResize);
|
||||
if (timeInterval) clearInterval(timeInterval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={() => showCalendar = false} />
|
||||
|
||||
{#if isOpen && $isAuthenticated}
|
||||
<div
|
||||
class="terminal-container"
|
||||
class:docked={isDocked}
|
||||
class:undocked={!isDocked}
|
||||
transition:slide={{ duration: isDocked ? 300 : 0 }}
|
||||
style={isDocked ? `height: ${terminalHeight}px;` : `left: ${terminalPosition.x}px; top: ${terminalPosition.y}px;`}
|
||||
>
|
||||
{#if isDocked}
|
||||
<div class="resize-handle" on:mousedown={startResize}></div>
|
||||
{/if}
|
||||
|
||||
<div class="terminal-header" on:mousedown={!isDocked ? startDrag : null}>
|
||||
<TerminalTabBar {tabs} {activeTab} on:tabChange={handleTabChange} />
|
||||
<div class="header-right">
|
||||
<div class="datetime-container">
|
||||
<button class="datetime-button" on:click|stopPropagation={toggleCalendar} title="Show calendar">
|
||||
<span class="datetime-date">{formatDate(currentTime)}</span>
|
||||
<span class="datetime-time">{formatTime(currentTime)}</span>
|
||||
</button>
|
||||
{#if showCalendar}
|
||||
<div class="calendar-dropdown" on:click|stopPropagation>
|
||||
<div class="calendar-panel">
|
||||
<div class="calendar-header">
|
||||
<button class="calendar-nav" on:click={prevMonth}>‹</button>
|
||||
<span class="calendar-month">{calendarMonthYear}</span>
|
||||
<button class="calendar-nav" on:click={nextMonth}>›</button>
|
||||
</div>
|
||||
<div class="calendar-weekdays">
|
||||
<span>Su</span><span>Mo</span><span>Tu</span><span>We</span><span>Th</span><span>Fr</span><span>Sa</span>
|
||||
</div>
|
||||
<div class="calendar-days">
|
||||
{#each calendarDays as day}
|
||||
<span class="calendar-day" class:today={isToday(day)} class:empty={!day}>
|
||||
{day || ''}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="timezone-panel">
|
||||
{#each timezones as tz}
|
||||
<div class="timezone-row">
|
||||
<span class="timezone-label">{tz.label}</span>
|
||||
<span class="timezone-time">{getTimezoneTime(tz.zone)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="status">
|
||||
<span class="status-dot" class:connected={isConnected}></span>
|
||||
</div>
|
||||
<div class="terminal-controls">
|
||||
<button class="control-button" on:click={popoutTerminal} title="Pop out terminal">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M6.5 1A.5.5 0 0 1 7 .5h7a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-1 0V1.5H7a.5.5 0 0 1-.5-.5z"/>
|
||||
<path d="M13.5 1l-6 6H4a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h5a1 1 0 0 0 1-1V9.5l6-6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="control-button" on:click={toggleDock} title={isDocked ? 'Undock' : 'Dock'}>
|
||||
{#if isDocked}
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M14 2H2v12h12V2zM3 13V3h10v10H3z"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M2 10h12v4H2v-4zm0-8h12v6H2V2z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
<button class="close-button" on:click={() => (isOpen = false)}>×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
{#if activeTab === 'terminal'}
|
||||
<TerminalCore
|
||||
bind:this={terminalCore}
|
||||
realmId={selectedRealmId}
|
||||
{renderStickers}
|
||||
showHotkeyHelp={true}
|
||||
{terminalHotkey}
|
||||
isActive={activeTab === 'terminal'}
|
||||
on:showProfile={handleShowProfile}
|
||||
on:realmChange={handleRealmChange}
|
||||
on:stickersToggled={handleStickersToggled}
|
||||
/>
|
||||
{:else if activeTab === 'stickers'}
|
||||
<StickerBrowser onSelect={handleStickerSelect} />
|
||||
{:else if activeTab === 'streams'}
|
||||
<StreamsBrowser isActive={activeTab === 'streams'} />
|
||||
{:else if activeTab === 'watch'}
|
||||
<WatchRoomsBrowser isActive={activeTab === 'watch'} />
|
||||
{:else if activeTab === 'audio'}
|
||||
<AudioBrowser isActive={activeTab === 'audio'} />
|
||||
{:else if activeTab === 'ebooks'}
|
||||
<EbookBrowser isActive={activeTab === 'ebooks'} />
|
||||
{:else if activeTab === 'games'}
|
||||
<GamesBrowser isActive={activeTab === 'games'} />
|
||||
{:else if activeTab === 'treasury'}
|
||||
<TreasuryBrowser isActive={activeTab === 'treasury'} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if activeProfilePreview}
|
||||
<ProfilePreview
|
||||
username={activeProfilePreview.username}
|
||||
userId={activeProfilePreview.userId}
|
||||
isGuest={activeProfilePreview.isGuest}
|
||||
messageId={activeProfilePreview.messageId}
|
||||
position={activeProfilePreview.position}
|
||||
realmId={selectedRealmId}
|
||||
on:close={handleProfileClose}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.terminal-container {
|
||||
position: fixed;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.terminal-container.docked {
|
||||
top: var(--nav-height, 60px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.terminal-container.undocked {
|
||||
width: 600px;
|
||||
height: 400px;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
resize: both;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: #333;
|
||||
cursor: ns-resize;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resize-handle:hover,
|
||||
.resize-handle:active {
|
||||
background: #4caf50;
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
background: #0d0d0d;
|
||||
border-bottom: 1px solid #333;
|
||||
gap: 0.375rem;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.docked .terminal-header {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.undocked .terminal-header {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 26px;
|
||||
min-width: 26px;
|
||||
padding: 0 0.375rem;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #f44336;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: #4caf50;
|
||||
}
|
||||
|
||||
.terminal-controls {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 26px;
|
||||
min-width: 26px;
|
||||
padding: 0 0.375rem;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
color: #aaa;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.control-button:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 26px;
|
||||
min-width: 26px;
|
||||
padding: 0 0.375rem;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
color: #aaa;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: rgb(13, 17, 23);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.datetime-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.datetime-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
height: 26px;
|
||||
padding: 0 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
color: #aaa;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.datetime-button:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.datetime-date {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.datetime-time {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.calendar-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 0.5rem;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
z-index: 100;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.calendar-panel {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.timezone-panel {
|
||||
border-left: 1px solid #30363d;
|
||||
padding-left: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.timezone-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.15rem 0;
|
||||
}
|
||||
|
||||
.timezone-label {
|
||||
color: #8b949e;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.timezone-time {
|
||||
color: #7ee787;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.calendar-month {
|
||||
color: #c9d1d9;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.calendar-nav {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #8b949e;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.calendar-nav:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.calendar-weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.calendar-weekdays span {
|
||||
text-align: center;
|
||||
color: #8b949e;
|
||||
font-size: 0.7rem;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.calendar-days {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
text-align: center;
|
||||
padding: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
color: #c9d1d9;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.calendar-day:not(.empty):not(.today):hover {
|
||||
background: rgba(139, 148, 158, 0.2);
|
||||
}
|
||||
|
||||
.calendar-day.empty {
|
||||
color: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.calendar-day.today {
|
||||
background: #7ee787;
|
||||
color: #0d1117;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.calendar-day.today:hover {
|
||||
background: #9eeea1;
|
||||
}
|
||||
|
||||
.timezone-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.15rem 0.35rem;
|
||||
margin: 0 -0.35rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.timezone-row:hover {
|
||||
background: rgba(126, 231, 135, 0.15);
|
||||
}
|
||||
|
||||
.timezone-row:hover .timezone-label {
|
||||
color: #c9d1d9;
|
||||
}
|
||||
</style>
|
||||
685
frontend/src/lib/components/chat/ProfilePreview.svelte
Normal file
685
frontend/src/lib/components/chat/ProfilePreview.svelte
Normal file
|
|
@ -0,0 +1,685 @@
|
|||
<script>
|
||||
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
|
||||
import { hiddenUsers, toggleHideUser, chatUserInfo } from '$lib/chat/chatStore';
|
||||
import { chatWebSocket as chatWs } from '$lib/chat/chatWebSocket';
|
||||
import { formatUbercoin } from '$lib/stores/ubercoin';
|
||||
import { auth, isAuthenticated } from '$lib/stores/auth';
|
||||
import UbercoinTipModal from '$lib/components/UbercoinTipModal.svelte';
|
||||
|
||||
export let username = '';
|
||||
export let userId = null;
|
||||
export let isGuest = false;
|
||||
export let position = { x: 0, y: 0 };
|
||||
export let targetFingerprint = ''; // For guest bans
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// Check current user's moderation permissions from chat connection
|
||||
$: currentUser = $chatUserInfo;
|
||||
$: canUberban = currentUser?.isAdmin || currentUser?.isSiteModerator;
|
||||
$: canModerate = currentUser?.isModerator || currentUser?.isAdmin || currentUser?.isSiteModerator;
|
||||
$: isSelf = currentUser?.userId === userId;
|
||||
|
||||
let profile = null;
|
||||
let loading = true;
|
||||
let error = null;
|
||||
let popupElement;
|
||||
let showTipModal = false;
|
||||
|
||||
// Check if this user is hidden
|
||||
$: isHidden = userId && $hiddenUsers.has(userId);
|
||||
|
||||
onMount(async () => {
|
||||
// Add click outside listener
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
|
||||
// Fetch profile for registered users
|
||||
if (!isGuest && username) {
|
||||
await fetchProfile();
|
||||
} else {
|
||||
loading = false;
|
||||
}
|
||||
|
||||
// Adjust position to stay within viewport
|
||||
requestAnimationFrame(adjustPosition);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
|
||||
function handleClickOutside(event) {
|
||||
if (popupElement && !popupElement.contains(event.target)) {
|
||||
dispatch('close');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProfile() {
|
||||
try {
|
||||
const response = await fetch(`/api/users/${encodeURIComponent(username)}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
profile = data.profile;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch profile:', e);
|
||||
error = 'Failed to load profile';
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function adjustPosition() {
|
||||
if (!popupElement) return;
|
||||
|
||||
const rect = popupElement.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Adjust if goes off right edge
|
||||
if (rect.right > viewportWidth - 10) {
|
||||
position.x = viewportWidth - rect.width - 10;
|
||||
}
|
||||
|
||||
// Adjust if goes off bottom edge
|
||||
if (rect.bottom > viewportHeight - 10) {
|
||||
position.y = position.y - rect.height - 20;
|
||||
}
|
||||
|
||||
// Ensure not off left edge
|
||||
if (position.x < 10) {
|
||||
position.x = 10;
|
||||
}
|
||||
|
||||
// Ensure not off top edge
|
||||
if (position.y < 10) {
|
||||
position.y = 10;
|
||||
}
|
||||
}
|
||||
|
||||
function handleViewProfile() {
|
||||
// Navigate to profile page
|
||||
window.location.href = `/profile/${encodeURIComponent(username)}`;
|
||||
}
|
||||
|
||||
function handleToggleHide() {
|
||||
if (userId) {
|
||||
toggleHideUser(userId);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short'
|
||||
});
|
||||
}
|
||||
|
||||
function handleOpenTipModal() {
|
||||
showTipModal = true;
|
||||
}
|
||||
|
||||
function handleTipModalClose() {
|
||||
showTipModal = false;
|
||||
}
|
||||
|
||||
function handleTipSent(event) {
|
||||
// Tip was sent successfully
|
||||
showTipModal = false;
|
||||
}
|
||||
|
||||
// Check if user can send tips (authenticated and not viewing self)
|
||||
$: canSendTip = $isAuthenticated && $auth.user && $auth.user.username !== username;
|
||||
|
||||
// Moderation action handlers
|
||||
function handleUberban() {
|
||||
if (!confirm(`Permanently ban ${username} from the entire site? This bans their browser fingerprint.`)) {
|
||||
return;
|
||||
}
|
||||
chatWs.uberbanUser(userId, targetFingerprint, '');
|
||||
dispatch('close');
|
||||
}
|
||||
|
||||
function handleBan() {
|
||||
if (!confirm(`Ban ${username} from this realm?`)) {
|
||||
return;
|
||||
}
|
||||
chatWs.banUser(userId, '');
|
||||
dispatch('close');
|
||||
}
|
||||
|
||||
function handleKick() {
|
||||
chatWs.kickUser(userId, 60, '');
|
||||
dispatch('close');
|
||||
}
|
||||
|
||||
function handleMute() {
|
||||
chatWs.muteUser(userId, 0, ''); // 0 = permanent
|
||||
dispatch('close');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.profile-preview {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
width: 280px;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.banner-container {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.banner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.banner-placeholder {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.profile-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
color: white;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.username-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: filter 0.15s ease;
|
||||
}
|
||||
|
||||
.username:hover {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.color-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.guest-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 6px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.member-since {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.bio {
|
||||
font-size: 0.85rem;
|
||||
color: #ccc;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 12px;
|
||||
max-height: 60px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.graffiti-container {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.graffiti-img {
|
||||
image-rendering: pixelated;
|
||||
width: 88px;
|
||||
height: 33px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.15s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.loading, .error-state {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.guest-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.guest-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.guest-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.hide-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.hide-btn:hover {
|
||||
color: #f44336;
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
}
|
||||
|
||||
.hide-btn.unhide {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.hide-btn.unhide:hover {
|
||||
color: #66bb6a;
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
}
|
||||
|
||||
.hide-btn svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ubercoin-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 215, 0, 0.08);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ubercoin-balance {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.9rem;
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
.coin-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: linear-gradient(135deg, #ffd700 0%, #ffb300 100%);
|
||||
border-radius: 50%;
|
||||
font-size: 0.7rem;
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.tip-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: linear-gradient(135deg, #ffd700 0%, #ffb300 100%);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #000;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tip-btn:hover {
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.tip-btn .coin-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
/* Moderation Actions */
|
||||
.mod-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border, #333);
|
||||
}
|
||||
|
||||
.mod-btn {
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mod-btn.kick {
|
||||
color: #ffc107;
|
||||
border-color: #ffc107;
|
||||
}
|
||||
|
||||
.mod-btn.kick:hover {
|
||||
background: rgba(255, 193, 7, 0.15);
|
||||
}
|
||||
|
||||
.mod-btn.mute {
|
||||
color: #9c27b0;
|
||||
border-color: #9c27b0;
|
||||
}
|
||||
|
||||
.mod-btn.mute:hover {
|
||||
background: rgba(156, 39, 176, 0.15);
|
||||
}
|
||||
|
||||
.mod-btn.ban {
|
||||
color: #f44336;
|
||||
border-color: #f44336;
|
||||
}
|
||||
|
||||
.mod-btn.ban:hover {
|
||||
background: rgba(244, 67, 54, 0.15);
|
||||
}
|
||||
|
||||
.mod-btn.uberban {
|
||||
color: #b71c1c;
|
||||
border-color: #b71c1c;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.mod-btn.uberban:hover {
|
||||
background: rgba(183, 28, 28, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div
|
||||
class="profile-preview"
|
||||
bind:this={popupElement}
|
||||
style="left: {position.x}px; top: {position.y}px;"
|
||||
on:click|stopPropagation
|
||||
>
|
||||
{#if isGuest}
|
||||
<!-- Guest User Card -->
|
||||
<div class="guest-card">
|
||||
<div class="guest-info">
|
||||
<div class="guest-avatar">G</div>
|
||||
<div class="user-details">
|
||||
<div class="username-row">
|
||||
<span class="username">{username}</span>
|
||||
<span class="guest-badge">Guest</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="action-btn hide-btn" class:unhide={isHidden} on:click={handleToggleHide}>
|
||||
{#if isHidden}
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"/>
|
||||
<path d="M2 8s3-5.5 6-5.5S14 8 14 8s-3 5.5-6 5.5S2 8 2 8zm-1 0a.5.5 0 0 0 0 1c0-.552.93-1.752 2.06-2.715.45-.383.937-.727 1.44-1.017C5.478 4.676 6.68 4 8 4c1.32 0 2.522.676 3.5 1.268.503.29.99.634 1.44 1.017C14.07 7.248 15 8.448 15 9a.5.5 0 0 0 0-1c0 .552-.93 1.752-2.06 2.715-.45.383-.937.727-1.44 1.017C10.522 12.324 9.32 13 8 13c-1.32 0-2.522-.676-3.5-1.268-.503-.29-.99-.634-1.44-1.017C1.93 9.752 1 8.552 1 8z"/>
|
||||
</svg>
|
||||
Show Messages
|
||||
{:else}
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M13.359 11.238C15.06 9.72 16 8 16 8s-3-5.5-8-5.5a7.028 7.028 0 0 0-2.79.588l.77.771A5.944 5.944 0 0 1 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.134 13.134 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755-.165.165-.337.328-.517.486l.708.709z"/>
|
||||
<path d="M11.297 9.176a3.5 3.5 0 0 0-4.474-4.474l.823.823a2.5 2.5 0 0 1 2.829 2.829l.822.822zm-2.943 1.299.822.822a3.5 3.5 0 0 1-4.474-4.474l.823.823a2.5 2.5 0 0 0 2.829 2.829z"/>
|
||||
<path d="M3.35 5.47c-.18.16-.353.322-.518.487A13.134 13.134 0 0 0 1.172 8l.195.288c.335.48.83 1.12 1.465 1.755C4.121 11.332 5.881 12.5 8 12.5c.716 0 1.39-.133 2.02-.36l.77.772A7.029 7.029 0 0 1 8 13.5C3 13.5 0 8 0 8s.939-1.721 2.641-3.238l.708.709zm10.296 8.884-12-12 .708-.708 12 12-.708.708z"/>
|
||||
</svg>
|
||||
Hide Messages
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Moderation Actions for Guests -->
|
||||
{#if canModerate}
|
||||
<div class="mod-actions">
|
||||
<button class="action-btn mod-btn kick" on:click={handleKick} title="Kick (1 min block)">
|
||||
Kick
|
||||
</button>
|
||||
<button class="action-btn mod-btn mute" on:click={handleMute} title="Mute">
|
||||
Mute
|
||||
</button>
|
||||
<button class="action-btn mod-btn ban" on:click={handleBan} title="Ban from realm">
|
||||
Ban
|
||||
</button>
|
||||
{#if canUberban}
|
||||
<button class="action-btn mod-btn uberban" on:click={handleUberban} title="Site-wide fingerprint ban">
|
||||
Uberban
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if loading}
|
||||
<div class="loading">Loading profile...</div>
|
||||
{:else if error}
|
||||
<div class="error-state">{error}</div>
|
||||
{:else if profile}
|
||||
<!-- Registered User Card -->
|
||||
{#if profile.bannerUrl}
|
||||
<div class="banner-container">
|
||||
<img
|
||||
src={profile.bannerUrl}
|
||||
alt="Banner"
|
||||
class="banner"
|
||||
style="object-position: {profile.bannerPositionX ?? 50}% {profile.bannerPosition ?? 50}%; transform: scale({(profile.bannerZoom ?? 100) / 100}); transform-origin: {profile.bannerPositionX ?? 50}% {profile.bannerPosition ?? 50}%;"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="banner-placeholder"
|
||||
style="background: linear-gradient(135deg, {profile.colorCode || '#561D5E'} 0%, {profile.colorCode || '#561D5E'}66 100%);"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<div class="profile-content">
|
||||
<div class="user-info">
|
||||
<div class="avatar">
|
||||
{#if profile.avatarUrl}
|
||||
<img src={profile.avatarUrl} alt={profile.username} />
|
||||
{:else}
|
||||
{profile.username.charAt(0).toUpperCase()}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="user-details">
|
||||
<div class="username-row">
|
||||
<span class="username" style="color: {profile.colorCode || '#561D5E'};">{profile.username}</span>
|
||||
<div
|
||||
class="color-dot"
|
||||
style="background: {profile.colorCode || '#561D5E'};"
|
||||
></div>
|
||||
</div>
|
||||
<div class="member-since">
|
||||
Member since {formatDate(profile.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if profile.bio}
|
||||
<div class="bio">{profile.bio}</div>
|
||||
{/if}
|
||||
|
||||
{#if profile.graffitiUrl}
|
||||
<div class="graffiti-container">
|
||||
<img
|
||||
src={profile.graffitiUrl}
|
||||
alt="{profile.username}'s graffiti"
|
||||
class="graffiti-img"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- übercoin Section -->
|
||||
<div class="ubercoin-section">
|
||||
<div class="ubercoin-balance">
|
||||
<span class="coin-icon">Ü</span>
|
||||
<span>{formatUbercoin(profile.ubercoinBalance)}</span>
|
||||
</div>
|
||||
{#if canSendTip}
|
||||
<button class="tip-btn" on:click|stopPropagation={handleOpenTipModal}>
|
||||
<span class="coin-icon">Ü</span>
|
||||
Send
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="action-btn" on:click={handleViewProfile}>
|
||||
View Full Profile
|
||||
</button>
|
||||
<button class="action-btn hide-btn" class:unhide={isHidden} on:click={handleToggleHide}>
|
||||
{#if isHidden}
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"/>
|
||||
<path d="M2 8s3-5.5 6-5.5S14 8 14 8s-3 5.5-6 5.5S2 8 2 8zm-1 0a.5.5 0 0 0 0 1c0-.552.93-1.752 2.06-2.715.45-.383.937-.727 1.44-1.017C5.478 4.676 6.68 4 8 4c1.32 0 2.522.676 3.5 1.268.503.29.99.634 1.44 1.017C14.07 7.248 15 8.448 15 9a.5.5 0 0 0 0-1c0 .552-.93 1.752-2.06 2.715-.45.383-.937.727-1.44 1.017C10.522 12.324 9.32 13 8 13c-1.32 0-2.522-.676-3.5-1.268-.503-.29-.99-.634-1.44-1.017C1.93 9.752 1 8.552 1 8z"/>
|
||||
</svg>
|
||||
Show Messages
|
||||
{:else}
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M13.359 11.238C15.06 9.72 16 8 16 8s-3-5.5-8-5.5a7.028 7.028 0 0 0-2.79.588l.77.771A5.944 5.944 0 0 1 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.134 13.134 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755-.165.165-.337.328-.517.486l.708.709z"/>
|
||||
<path d="M11.297 9.176a3.5 3.5 0 0 0-4.474-4.474l.823.823a2.5 2.5 0 0 1 2.829 2.829l.822.822zm-2.943 1.299.822.822a3.5 3.5 0 0 1-4.474-4.474l.823.823a2.5 2.5 0 0 0 2.829 2.829z"/>
|
||||
<path d="M3.35 5.47c-.18.16-.353.322-.518.487A13.134 13.134 0 0 0 1.172 8l.195.288c.335.48.83 1.12 1.465 1.755C4.121 11.332 5.881 12.5 8 12.5c.716 0 1.39-.133 2.02-.36l.77.772A7.029 7.029 0 0 1 8 13.5C3 13.5 0 8 0 8s.939-1.721 2.641-3.238l.708.709zm10.296 8.884-12-12 .708-.708 12 12-.708.708z"/>
|
||||
</svg>
|
||||
Hide Messages
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Moderation Actions for Registered Users -->
|
||||
{#if canModerate && !isSelf}
|
||||
<div class="mod-actions">
|
||||
<button class="action-btn mod-btn kick" on:click={handleKick} title="Kick (1 min block)">
|
||||
Kick
|
||||
</button>
|
||||
<button class="action-btn mod-btn mute" on:click={handleMute} title="Mute">
|
||||
Mute
|
||||
</button>
|
||||
<button class="action-btn mod-btn ban" on:click={handleBan} title="Ban from realm">
|
||||
Ban
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="error-state">Profile not found</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Ubercoin Tip Modal -->
|
||||
<UbercoinTipModal
|
||||
show={showTipModal}
|
||||
recipientUsername={username}
|
||||
on:close={handleTipModalClose}
|
||||
on:sent={handleTipSent}
|
||||
/>
|
||||
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