Initial commit - realms platform

This commit is contained in:
doomtube 2026-01-05 22:54:27 -05:00
parent c590ab6d18
commit c717c3751c
234 changed files with 74103 additions and 15231 deletions

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

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

View 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}
/>

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