add Twitch style emote autocomplete
This commit is contained in:
+28
-1
@@ -36,6 +36,8 @@
|
|||||||
let categorySearchTimer: number | undefined;
|
let categorySearchTimer: number | undefined;
|
||||||
let chatIdentity: 'streamer' | 'bot' = 'streamer';
|
let chatIdentity: 'streamer' | 'bot' = 'streamer';
|
||||||
let chatMessage = '';
|
let chatMessage = '';
|
||||||
|
let chatEmoteOptions: Array<{ name: string; url: string; provider: string }> = [];
|
||||||
|
let chatEmoteTimer: number | undefined;
|
||||||
let chatBusy = false;
|
let chatBusy = false;
|
||||||
let chatError = '';
|
let chatError = '';
|
||||||
let showModeration = false;
|
let showModeration = false;
|
||||||
@@ -313,6 +315,31 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function searchChatEmotes(value: string) {
|
||||||
|
const match = value.match(/(^|\s):([A-Za-z0-9_]{1,64})$/);
|
||||||
|
chatEmoteOptions = [];
|
||||||
|
if (chatEmoteTimer) window.clearTimeout(chatEmoteTimer);
|
||||||
|
if (!match) return;
|
||||||
|
const query = match[2];
|
||||||
|
chatEmoteTimer = window.setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${serverAddress}/api/v1/twitch/chat/emotes?query=${encodeURIComponent(query)}`, {
|
||||||
|
headers: { authorization: `Bearer ${sessionToken}` },
|
||||||
|
});
|
||||||
|
if (response.ok) chatEmoteOptions = await response.json();
|
||||||
|
} catch {
|
||||||
|
chatEmoteOptions = [];
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectChatEmote(name: string) {
|
||||||
|
const match = chatMessage.match(/(^|\s):[A-Za-z0-9_]{1,64}$/);
|
||||||
|
if (!match) return;
|
||||||
|
chatMessage = `${chatMessage.slice(0, chatMessage.length - match[0].length)}${match[1]}${name} `;
|
||||||
|
chatEmoteOptions = [];
|
||||||
|
}
|
||||||
|
|
||||||
function replyToChatMessage(message: { message_id: string; user_name: string }) {
|
function replyToChatMessage(message: { message_id: string; user_name: string }) {
|
||||||
chatReplyParent = { message_id: message.message_id, user_name: message.user_name };
|
chatReplyParent = { message_id: message.message_id, user_name: message.user_name };
|
||||||
}
|
}
|
||||||
@@ -464,7 +491,7 @@
|
|||||||
<div class="dashboard-grid">
|
<div class="dashboard-grid">
|
||||||
<section class="panel preview-panel"><div class="panel-header"><span>STREAM PREVIEW</span><span class="panel-menu">⋮</span></div>{#if playerSrc}<iframe class="twitch-player" title={`Twitch player for ${streamerName}`} src={playerSrc} allowfullscreen></iframe>{:else}<div class="preview-stage"><div class="offline-mark">STREAM OFFLINE</div><div class="offline-subtitle">CONNECT TWITCH TO BEGIN</div></div>{/if}<div class="panel-footer"><span class="micro">VIDEO PLAYER</span><span class="badge">{playerSrc ? streamerName : 'OFFLINE'}</span></div></section>
|
<section class="panel preview-panel"><div class="panel-header"><span>STREAM PREVIEW</span><span class="panel-menu">⋮</span></div>{#if playerSrc}<iframe class="twitch-player" title={`Twitch player for ${streamerName}`} src={playerSrc} allowfullscreen></iframe>{:else}<div class="preview-stage"><div class="offline-mark">STREAM OFFLINE</div><div class="offline-subtitle">CONNECT TWITCH TO BEGIN</div></div>{/if}<div class="panel-footer"><span class="micro">VIDEO PLAYER</span><span class="badge">{playerSrc ? streamerName : 'OFFLINE'}</span></div></section>
|
||||||
<section class="panel activity-panel"><div class="panel-header"><span>ACTIVITY FEED</span><span class="panel-menu">⋮</span></div><div class="panel-tabs"><button class="tab active">ALL ACTIVITY</button><button class="tab">MODERATION</button></div>{#if activityEvents.length}<div class="activity-list">{#each activityEvents as event}<div class="activity-row"><span class="activity-type">{event.event_type}</span><span class="activity-time">{new Date(event.occurred_at).toLocaleTimeString()}</span></div>{/each}</div>{:else}<div class="empty-state"><div class="empty-glyph">◌</div><strong>Nothing here yet</strong><span>Follows, subscriptions, raids, and server events will appear here.</span></div>{/if}</section>
|
<section class="panel activity-panel"><div class="panel-header"><span>ACTIVITY FEED</span><span class="panel-menu">⋮</span></div><div class="panel-tabs"><button class="tab active">ALL ACTIVITY</button><button class="tab">MODERATION</button></div>{#if activityEvents.length}<div class="activity-list">{#each activityEvents as event}<div class="activity-row"><span class="activity-type">{event.event_type}</span><span class="activity-time">{new Date(event.occurred_at).toLocaleTimeString()}</span></div>{/each}</div>{:else}<div class="empty-state"><div class="empty-glyph">◌</div><strong>Nothing here yet</strong><span>Follows, subscriptions, raids, and server events will appear here.</span></div>{/if}</section>
|
||||||
<section class="panel chat-panel"><div class="panel-header"><span>MY CHAT</span><span class="panel-menu">⋮</span></div>{#if chatMessages.length}<div class="chat-message-list">{#each chatMessages as message}<div class="chat-message"><span class="chat-user">{message.user_name}</span><span class="chat-text">{message.text}</span><button class="chat-reply" onclick={() => replyToChatMessage(message)}>REPLY</button></div>{/each}</div>{:else}<div class="chat-empty"><div class="empty-glyph">⌁</div><strong>Chat is waiting</strong><span>Connect a streamer or bot account to begin.</span></div>{/if}<div class="chat-composer">{#if chatReplyParent}<div class="chat-reply-context">REPLYING TO @{chatReplyParent.user_name}<button class="secondary" onclick={() => (chatReplyParent = undefined)}>×</button></div>{/if}<div class="chat-identity-toggle"><button class:active={chatIdentity === 'streamer'} class="chat-identity" onclick={() => (chatIdentity = 'streamer')}>STREAMER</button><button class:active={chatIdentity === 'bot'} class="chat-identity" onclick={() => (chatIdentity = 'bot')}>BOT</button></div><div class="chat-send-row"><input class="control" bind:value={chatMessage} maxlength="500" placeholder={`Send as ${chatIdentity}`} onkeydown={(event) => event.key === 'Enter' && sendChatMessage()} /><button class="primary" onclick={sendChatMessage} disabled={chatBusy || !chatMessage.trim()}>SEND</button></div>{#if chatError}<div class="auth-error">{chatError}</div>{/if}</div></section>
|
<section class="panel chat-panel"><div class="panel-header"><span>MY CHAT</span><span class="panel-menu">⋮</span></div>{#if chatMessages.length}<div class="chat-message-list">{#each chatMessages as message}<div class="chat-message"><span class="chat-user">{message.user_name}</span><span class="chat-text">{message.text}</span><button class="chat-reply" onclick={() => replyToChatMessage(message)}>REPLY</button></div>{/each}</div>{:else}<div class="chat-empty"><div class="empty-glyph">⌁</div><strong>Chat is waiting</strong><span>Connect a streamer or bot account to begin.</span></div>{/if}<div class="chat-composer">{#if chatReplyParent}<div class="chat-reply-context">REPLYING TO @{chatReplyParent.user_name}<button class="secondary" onclick={() => (chatReplyParent = undefined)}>×</button></div>{/if}<div class="chat-identity-toggle"><button class:active={chatIdentity === 'streamer'} class="chat-identity" onclick={() => (chatIdentity = 'streamer')}>STREAMER</button><button class:active={chatIdentity === 'bot'} class="chat-identity" onclick={() => (chatIdentity = 'bot')}>BOT</button></div>{#if chatEmoteOptions.length}<div class="chat-emote-picker">{#each chatEmoteOptions as emote}<button class="chat-emote-option" onclick={() => selectChatEmote(emote.name)}><img src={emote.url} alt={emote.name} /><span>{emote.name}</span><span class="micro">{emote.provider}</span></button>{/each}</div>{/if}<div class="chat-send-row"><input class="control" bind:value={chatMessage} maxlength="500" placeholder={`Send as ${chatIdentity}`} oninput={() => searchChatEmotes(chatMessage)} onkeydown={(event) => event.key === 'Enter' && sendChatMessage()} /><button class="primary" onclick={sendChatMessage} disabled={chatBusy || !chatMessage.trim()}>SEND</button></div>{#if chatError}<div class="auth-error">{chatError}</div>{/if}</div></section>
|
||||||
<section class="panel quick-panel"><div class="panel-header"><span>QUICK ACTIONS</span><span class="panel-menu">⋮</span></div><div class="quick-grid"><button class="quick-action accent" onclick={() => (showStreamEditor = !showStreamEditor)}>EDIT STREAM INFO</button><button class="quick-action" onclick={() => (showModeration = !showModeration)}>MODERATION</button><button class="quick-action">BOT COMMANDS</button><button class="quick-action">OVERLAYS</button></div>{#if showStreamEditor}<div class="stream-editor"><label class="micro" for="stream-title">TITLE</label><input id="stream-title" class="control" bind:value={streamTitle} maxlength="140" /><label class="micro" for="stream-category">CATEGORY</label><input id="stream-category" class="control" value={categorySearch} oninput={(event) => searchCategories(event.currentTarget.value)} autocomplete="off" placeholder="Search Twitch categories" />{#if categoryOptions.length}<div class="category-options">{#each categoryOptions as category}<button class="category-option" onclick={() => { streamGameId = category.id; categorySearch = category.name; categoryOptions = []; }}><span>{category.name}</span><span class="mono">{category.id}</span></button>{/each}</div>{/if}<label class="micro" for="stream-language">LANGUAGE</label><input id="stream-language" class="control" bind:value={streamLanguage} maxlength="10" /><label class="micro" for="stream-tags">TAGS</label><input id="stream-tags" class="control" bind:value={streamTags} placeholder="comma, separated" /><label class="micro" for="stream-labels">CLASSIFICATION LABELS</label><input id="stream-labels" class="control" bind:value={streamClassificationLabels} placeholder="comma, separated" /><button class="primary control-button" onclick={saveStreamInfo} disabled={streamBusy}>SAVE STREAM INFO</button>{#if streamError}<div class="auth-error">{streamError}</div>{/if}</div>{/if}{#if showModeration}<div class="stream-editor"><label class="micro" for="moderation-action">ACTION</label><select id="moderation-action" class="control" bind:value={moderationAction}><option value="timeout">TIMEOUT</option><option value="ban">BAN</option><option value="unban">UNBAN</option><option value="delete_message">DELETE MESSAGE</option></select><label class="micro" for="moderation-user">USER ID</label><input id="moderation-user" class="control" bind:value={moderationUserId} maxlength="64" /><label class="micro" for="moderation-message">MESSAGE ID (DELETE)</label><input id="moderation-message" class="control" bind:value={moderationMessageId} maxlength="128" /><label class="micro" for="moderation-duration">TIMEOUT SECONDS</label><input id="moderation-duration" class="control" type="number" min="1" max="1209600" bind:value={moderationDuration} /><label class="micro" for="moderation-reason">REASON</label><input id="moderation-reason" class="control" maxlength="500" bind:value={moderationReason} /><button class="primary control-button" onclick={applyModeration} disabled={moderationBusy || !moderationUserId}>APPLY MODERATION</button>{#if moderationError}<div class="auth-error">{moderationError}</div>{/if}</div>{/if}</section>
|
<section class="panel quick-panel"><div class="panel-header"><span>QUICK ACTIONS</span><span class="panel-menu">⋮</span></div><div class="quick-grid"><button class="quick-action accent" onclick={() => (showStreamEditor = !showStreamEditor)}>EDIT STREAM INFO</button><button class="quick-action" onclick={() => (showModeration = !showModeration)}>MODERATION</button><button class="quick-action">BOT COMMANDS</button><button class="quick-action">OVERLAYS</button></div>{#if showStreamEditor}<div class="stream-editor"><label class="micro" for="stream-title">TITLE</label><input id="stream-title" class="control" bind:value={streamTitle} maxlength="140" /><label class="micro" for="stream-category">CATEGORY</label><input id="stream-category" class="control" value={categorySearch} oninput={(event) => searchCategories(event.currentTarget.value)} autocomplete="off" placeholder="Search Twitch categories" />{#if categoryOptions.length}<div class="category-options">{#each categoryOptions as category}<button class="category-option" onclick={() => { streamGameId = category.id; categorySearch = category.name; categoryOptions = []; }}><span>{category.name}</span><span class="mono">{category.id}</span></button>{/each}</div>{/if}<label class="micro" for="stream-language">LANGUAGE</label><input id="stream-language" class="control" bind:value={streamLanguage} maxlength="10" /><label class="micro" for="stream-tags">TAGS</label><input id="stream-tags" class="control" bind:value={streamTags} placeholder="comma, separated" /><label class="micro" for="stream-labels">CLASSIFICATION LABELS</label><input id="stream-labels" class="control" bind:value={streamClassificationLabels} placeholder="comma, separated" /><button class="primary control-button" onclick={saveStreamInfo} disabled={streamBusy}>SAVE STREAM INFO</button>{#if streamError}<div class="auth-error">{streamError}</div>{/if}</div>{/if}{#if showModeration}<div class="stream-editor"><label class="micro" for="moderation-action">ACTION</label><select id="moderation-action" class="control" bind:value={moderationAction}><option value="timeout">TIMEOUT</option><option value="ban">BAN</option><option value="unban">UNBAN</option><option value="delete_message">DELETE MESSAGE</option></select><label class="micro" for="moderation-user">USER ID</label><input id="moderation-user" class="control" bind:value={moderationUserId} maxlength="64" /><label class="micro" for="moderation-message">MESSAGE ID (DELETE)</label><input id="moderation-message" class="control" bind:value={moderationMessageId} maxlength="128" /><label class="micro" for="moderation-duration">TIMEOUT SECONDS</label><input id="moderation-duration" class="control" type="number" min="1" max="1209600" bind:value={moderationDuration} /><label class="micro" for="moderation-reason">REASON</label><input id="moderation-reason" class="control" maxlength="500" bind:value={moderationReason} /><button class="primary control-button" onclick={applyModeration} disabled={moderationBusy || !moderationUserId}>APPLY MODERATION</button>{#if moderationError}<div class="auth-error">{moderationError}</div>{/if}</div>{/if}</section>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -278,6 +278,30 @@ button:hover:not(:disabled) {
|
|||||||
color: var(--ed-accent);
|
color: var(--ed-accent);
|
||||||
font: 10px var(--font-data);
|
font: 10px var(--font-data);
|
||||||
}
|
}
|
||||||
|
.chat-emote-picker {
|
||||||
|
display: flex;
|
||||||
|
max-height: 180px;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: auto;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
border: 1px solid var(--ui-line);
|
||||||
|
background: var(--ed-bg);
|
||||||
|
}
|
||||||
|
.chat-emote-option {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--ui-divider);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.chat-emote-option img {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
.workspace {
|
.workspace {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 214px 1fr;
|
grid-template-columns: 214px 1fr;
|
||||||
|
|||||||
Reference in New Issue
Block a user