First implementation of sveltekit for webinterface

This commit is contained in:
Infi
2025-09-20 16:07:46 +02:00
parent 9b3e3f79e3
commit 94b345c6a3
40 changed files with 3074 additions and 97 deletions
+23
View File
@@ -0,0 +1,23 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
interface Error {
code: string;
id: string;
}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
interface Warning {
hasWarning: boolean;
content: string;
}
}
interface Element { scrollTopMax: number } // Firefox only property
}
export {};
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>%sveltekit.error.message%</title>
</head>
<body>
<p>Status: %sveltekit.status%</p>
<p>Message: %sveltekit.error.message%</p>
</body>
</html>
+426
View File
@@ -0,0 +1,426 @@
import {type Source, source} from "sveltekit-sse";
interface ChatElements {
channelHint: HTMLElement | null,
channelSelect: HTMLElement | null,
messagesContainer: Element | null,
messagesList: HTMLElement | null,
timestampWidthProbe: HTMLElement | null,
inputForm: Element | null,
chatInput: HTMLElement | null,
}
// ref `DataStructure.Messages`
interface Messages {
messages: MessageResponse[]
}
// ref `DataStructure.MessageResponse`
interface MessageResponse {
timestamp: string;
templates: Template[];
}
// ref `DataStructure.MessageTemplate`
interface Template {
id: number;
payload: string;
content: string;
color: number;
}
// ref `DataStructure.SwitchChannel`
interface SwitchChannel {
channelName: Template[];
channelLocked: boolean;
}
// ref `DataStructure.ChannelList`
interface ChannelList {
channels: {[key: string]: number};
}
// ref `DataStructure.ChatTab`
interface ChatTab {
name: string;
index: number;
}
// ref `DataStructure.ChatTabList`
interface ChatTabList {
tabs: ChatTab[];
}
export class ChatTwoWeb {
elements!: ChatElements;
maxTimestampWidth: number = 0;
scrolledToBottom: boolean = true;
channelLocked: boolean = false;
sse!: EventSource;
connection!: Source;
constructor() {
this.setupDOMElements();
this.setupSSEConnection();
}
setupDOMElements() {
this.elements = {
channelHint: document.getElementById('channel-hint'),
channelSelect: document.getElementById('channel-select'),
messagesContainer: document.querySelector('#messages > .scroll-container')!,
messagesList: document.getElementById('messages-list'),
timestampWidthProbe: document.getElementById('timestamp-width-probe'),
inputForm: document.querySelector('#input > form'),
chatInput: document.getElementById('chat-input')
};
// channel selector
this.elements.channelSelect?.addEventListener('change', async (event) => {
if (event.currentTarget === null)
return;
const rawResponse = await fetch('/channel', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ channel: (event.currentTarget as HTMLSelectElement).value })
});
// const content = await rawResponse.json();
// TODO: use the response
});
// add indicator signaling more messages below
this.elements.messagesContainer?.addEventListener('scroll', (event) => {
if (event.currentTarget === null)
return;
let parentElement = (event.currentTarget as HTMLDivElement).parentElement;
if (!this.messagesAreScrolledToBottom()) {
parentElement?.classList.add('more-messages');
} else {
parentElement?.classList.remove('more-messages');
}
});
// adjust scroll when the window size changes; mostly for mobile (opening/closing the keyboard)
window.addEventListener('resize', () => {
if (this.scrolledToBottom) {
this.scrollMessagesToBottom();
}
})
// handle message sending
this.elements.inputForm?.addEventListener('submit', async (event) => {
if (this.elements.chatInput === null)
return;
event.preventDefault();
// @ts-ignore
const message = this.elements.chatInput.value;
if (message.length > 500) {
return;
}
const rawResponse = await fetch('/send', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ message: message })
});
// const content = await rawResponse.json();
// TODO: use the response
// @ts-ignore
this.elements.chatInput.value = '';
});
}
messagesAreScrolledToBottom() {
if (this.elements.messagesContainer === null) {
return this.scrolledToBottom;
}
if (this.elements.messagesContainer.scrollTopMax) {
this.scrolledToBottom = this.elements.messagesContainer.scrollTop === this.elements.messagesContainer.scrollTopMax;
} else {
this.scrolledToBottom =
(
this.elements.messagesContainer.scrollHeight -
this.elements.messagesContainer.clientHeight -
this.elements.messagesContainer.scrollTop
) < 1;
}
return this.scrolledToBottom;
}
updateChannelHint(channel: SwitchChannel) {
if (this.elements.channelHint === null || this.elements.channelSelect === null)
return;
this.elements.channelHint.innerHTML = '';
const channelElement = this.processTemplate(channel.channelName);
// Makes the channel selector unclickable if the channel is fixed
this.channelLocked = channel.channelLocked;
if (this.channelLocked) {
if (channelElement.firstChild === null)
return;
// @ts-ignore
channelElement.firstChild.innerText = `(Locked) ${channelElement.firstChild.innerText}`;
this.elements.channelSelect.style.pointerEvents = 'none';
} else {
this.elements.channelSelect.style.removeProperty('pointer-events');
}
this.elements.channelHint.appendChild(channelElement);
}
updateChannels(channelList: ChannelList) {
if (this.elements.channelSelect === null)
return;
this.elements.channelSelect.innerHTML = '';
for (const [ label, channel ] of Object.entries(channelList.channels)) {
const option = document.createElement('option');
option.value = channel.toString();
option.innerText = label;
this.elements.channelSelect.appendChild(option);
}
}
// calculate timestamp width
// to ensure that all timestamps have the same width. some typefaces have the same width across
// all number glyphs, others do not. then there's AM/PM vs 24 hour, and so on
calculateTimestampWidth(timestamp: string) {
if (this.elements.timestampWidthProbe === null)
return;
this.elements.timestampWidthProbe.innerText = timestamp;
if (this.elements.timestampWidthProbe.clientWidth > this.maxTimestampWidth) {
this.maxTimestampWidth = this.elements.timestampWidthProbe.clientWidth;
document.body.style.setProperty('--timestamp-width', (Math.ceil(this.maxTimestampWidth) + 1) + 'px');
}
}
scrollMessagesToBottom() {
if (this.elements.messagesContainer === null || this.elements.messagesList === null)
return;
if (this.elements.messagesContainer.scrollTopMax) {
this.elements.messagesContainer.scrollTop = this.elements.messagesContainer.scrollTopMax;
} else {
if (this.elements.messagesList.lastElementChild === null)
return;
this.elements.messagesList.lastElementChild.scrollIntoView();
}
}
addMessage(messageData: MessageResponse) {
if (this.elements.messagesList === null)
return;
const scrolledToBottom = this.messagesAreScrolledToBottom();
this.calculateTimestampWidth(messageData.timestamp);
const liMessage = document.createElement('li');
const spanTimestamp = document.createElement('span');
spanTimestamp.classList.add('timestamp');
spanTimestamp.innerText = messageData.timestamp;
const spanMessage = document.createElement('span');
spanMessage.classList.add('message');
spanMessage.appendChild(this.processTemplate(messageData.templates))
liMessage.appendChild(spanTimestamp);
liMessage.appendChild(spanMessage);
this.elements.messagesList.appendChild(liMessage);
if (scrolledToBottom) {
this.scrollMessagesToBottom();
}
}
processTemplate(templates: Template[]) {
const frag = document.createDocumentFragment();
for( const template of templates ) {
const spanElement = document.createElement('span');
switch (template.payload) {
case 'text':
this.processTextTemplate(template, spanElement);
break;
case 'url':
this.processUrlTemplate(template, spanElement);
break;
case 'emote':
this.processEmote(template, spanElement);
break;
case 'icon':
this.processIcon(template, spanElement);
break;
case 'empty':
continue;
}
frag.appendChild(spanElement);
}
return frag;
}
processTextTemplate(template: Template, spanElement: HTMLSpanElement) {
spanElement.innerText = template.content;
if (template.color !== 0)
{
this.processColor(template, spanElement);
}
}
processUrlTemplate(template: Template, spanElement: HTMLSpanElement) {
const urlElement = document.createElement('a');
urlElement.innerText = template.content;
urlElement.href = encodeURI(template.content);
urlElement.target = '_blank'
if (template.color !== 0)
{
this.processColor(template, spanElement);
}
}
// converts a RGBA uint number to components
processColor(template: Template, spanElement: HTMLSpanElement) {
const r = (template.color & 0xFF000000) >>> 24;
const g = (template.color & 0xFF0000) >>> 16;
const b = (template.color & 0xFF00) >>> 8;
const a = (template.color & 0xFF) / 255.0;
spanElement.style.color = `rgba(${r}, ${g}, ${b}, ${a})`;
}
processEmote(template: Template, spanElement: HTMLSpanElement) {
const imgElement = document.createElement('img');
imgElement.src = `/emote/${template.content}`;
spanElement.classList.add('emote-icon');
spanElement.appendChild(imgElement);
}
processIcon(template: Template, spanElement: HTMLSpanElement) {
spanElement.classList.add('gfd-icon');
spanElement.classList.add(`gfd-icon-hq-${template.id}`);
}
clearAllMessages() {
if (this.elements.messagesList === null)
return;
this.elements.messagesList.innerHTML = '';
}
setupSSEConnection() {
this.connection = source('/sse')
this.connection.select('close').subscribe((data: string) => {
console.log(`Data received: ${data}`)
if (data) {
console.log('Closing SSE connection.');
this.connection.close();
}
});
// new messages to be appended to the message list
this.connection.select('new-message').subscribe((data: string) => {
console.log(`Data received: ${data}`)
if (data) {
try {
let message: MessageResponse = JSON.parse(data);
this.addMessage(message);
} catch (error) {
console.error(error);
}
}
});
// a bulk of new messages, with a clear of the message list beforehand
this.connection.select('bulk-messages').subscribe((data: string) => {
console.log(`Data received: ${data}`)
if (data) {
this.clearAllMessages();
try {
let messages: Messages = JSON.parse(data);
for (const message of messages.messages) {
this.addMessage(message);
}
} catch (error) {
console.error(error);
}
}
});
this.connection.select('channel-switched').subscribe((data: string) => {
console.log(`Data received: ${data}`)
if (data) {
try {
let channel: SwitchChannel = JSON.parse(data);
this.updateChannelHint(channel);
} catch (error) {
console.error(error);
}
}
});
// list of all channels
this.connection.select('channel-list').subscribe((data: string) => {
console.log(`Data received: ${data}`)
if (data) {
try {
let channelList: ChannelList = JSON.parse(data);
this.updateChannels(channelList);
} catch (error) {
console.error(error);
}
}
});
// tab switched
this.connection.select('tab-switched').subscribe((data: string) => {
console.log(`Data received: ${data}`)
if (data) {
try {
let chatTab: ChatTab = JSON.parse(data);
} catch (error) {
console.error(error);
}
}
});
// list of all tabs
this.connection.select('tab-list').subscribe((data: string) => {
console.log(`Data received: ${data}`)
if (data) {
try {
let chatTabLit: ChatTabList = JSON.parse(data);
} catch (error) {
console.error(error);
}
}
});
}
}
+134
View File
@@ -0,0 +1,134 @@
// from kizer, gfd icons
interface GdfEntry {
id: number,
left: number,
top: number,
width: number,
height: number,
unk0A: number,
redirect: number,
unk0E: number,
}
interface StylesheetEntry {
ids: number[],
style1: string,
style2: string,
width: number,
}
export async function addGfdStylesheet(gfdPath: string, texPath: string) {
const texPromise = loadTexAsBlob(texPath);
const gfdPromise = loadGfd(gfdPath);
const texUrl = URL.createObjectURL(await texPromise);
const gfd = await gfdPromise;
const stylesheets: {[id: number]: StylesheetEntry} = [];
for (const entry of gfd) {
if (entry.width * entry.height <= 0)
continue;
if (entry.redirect !== 0) {
stylesheets[entry.redirect].ids.push(entry.id);
continue;
}
stylesheets[entry.id] = {
ids: [entry.id],
style1: [
`background-position: -${entry.left}px -${entry.top}px`,
`background-image: url('${texUrl}')`,
`width: ${entry.width}px`,
`height: ${entry.height}px`
].join(';'),
style2: [
`background-position: -${entry.left * 2}px -${entry.top * 2 + 341}px`,
`background-image: url('${texUrl}')`,
`width: ${entry.width * 2}px`,
`height: ${entry.height * 2}px`
].join(';'),
width: entry.width
};
}
let stylesheet = '';
for (const entry of Object.values(stylesheets)) {
if (!entry)
continue;
stylesheet += `\n${entry.ids.map(x => `.gfd-icon.gfd-icon-${x}::before`).join(', ')}{${entry.style1};}`;
stylesheet += `\n${entry.ids.map(x => `.gfd-icon.gfd-icon-hq-${x}::before`).join(', ')}{${entry.style2};}`;
stylesheet += `\n${entry.ids.map(x => `.gfd-icon.gfd-icon-${x}`).join(', ')}{width:${entry.width}px;}`;
stylesheet += `\n${entry.ids.map(x => `.gfd-icon.gfd-icon-hq-${x}`).join(', ')}{width:${entry.width * 2}px;}`;
}
const styleNode = document.createElement('style');
styleNode.appendChild(document.createTextNode(stylesheet));
document.head.appendChild(styleNode);
}
async function loadTexAsBlob(path: string) {
const tex = parseTex(await (await fetch(path)).arrayBuffer());
if (tex.format !== 0x1450) // B8G8R8A8
throw 'Not supported';
const dataArray = new Uint8ClampedArray(tex.buffer, tex.offsetToSurface[0], tex.width * tex.height * 4);
for (let i = 0; i < dataArray.length; i += 4) {
const t = dataArray[i];
dataArray[i] = dataArray[i + 2];
dataArray[i + 2] = t;
}
const imageData = new ImageData(dataArray, tex.width, tex.height);
const bitmap = await createImageBitmap(imageData);
const canvas = new OffscreenCanvas(tex.width, tex.height);
canvas.getContext('bitmaprenderer')?.transferFromImageBitmap(bitmap);
return await canvas.convertToBlob();
}
async function loadGfd(path: string) {
const buffer = new DataView(await (await fetch(path)).arrayBuffer());
const count = buffer.getInt32(8, true);
const entries: GdfEntry[] = new Array(count);
for (let i = 0; i < count; i++) {
const offset = 0x10 + (i * 0x10);
entries[i] = {
id: buffer.getInt16(offset, true),
left: buffer.getInt16(offset + 2, true),
top: buffer.getInt16(offset + 4, true),
width: buffer.getInt16(offset + 6, true),
height: buffer.getInt16(offset + 8, true),
unk0A: buffer.getInt16(offset + 10, true),
redirect: buffer.getInt16(offset + 12, true),
unk0E: buffer.getInt16(offset + 14, true),
};
}
return entries;
}
function parseTex(arrayBuffer: ArrayBuffer) {
const buffer = new DataView(arrayBuffer);
const type = buffer.getInt32(0, true);
const format = buffer.getInt32(4, true);
const width = buffer.getInt16(8, true);
const height = buffer.getInt16(10, true);
const depth = buffer.getInt16(12, true);
const mipsAndFlag = buffer.getInt8(14);
const arraySize = buffer.getInt8(15);
const lodOffsets = [buffer.getInt32(16, true), buffer.getInt32(20, true), buffer.getInt32(24, true)];
const offsetToSurface = [buffer.getInt32(28, true), buffer.getInt32(32, true), buffer.getInt32(36, true), buffer.getInt32(40, true), buffer.getInt32(44, true), buffer.getInt32(48, true), buffer.getInt32(52, true), buffer.getInt32(56, true), buffer.getInt32(60, true), buffer.getInt32(64, true), buffer.getInt32(68, true), buffer.getInt32(72, true), buffer.getInt32(76, true)];
return {
buffer: arrayBuffer,
type,
format,
width,
height,
depth,
mipsAndFlag,
arraySize,
lodOffsets,
offsetToSurface,
};
}
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
@@ -0,0 +1,11 @@
<script lang="ts">
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/static/bootstrap.min.css">
<link rel="stylesheet" href="/static/start.css">
</svelte:head>
{@render children?.()}
@@ -0,0 +1 @@
export const prerender = true;
@@ -0,0 +1,33 @@
<script lang="ts">
import { page } from '$app/state'
import { Alert } from '@sveltestrap/sveltestrap';
let data: App.Warning | null = null;
$effect.pre(() => {
if (page.url.searchParams.has('message')) {
data = {
hasWarning: true,
content: page.url.searchParams.get('message') ?? '',
};
} else {
data = {
hasWarning: false,
content: '',
};
}
});
</script>
<main class="auth">
<h1>Authcode</h1>
{#if data?.hasWarning }
<Alert content={data.content} color="warning" dismissible={true}/>
{/if}
<form action="/auth" method="POST">
<label><input type="password" name="authcode"></label>
<button type="submit" class="submitButton">Submit</button>
</form>
<div data-sveltekit-preload-data="false">
<img src="/emote/Sure" alt=":Sure:" data-sveltekit-preload-data="off">
</div>
</main>
@@ -0,0 +1,68 @@
<script lang="ts">
import { page } from '$app/state'
import {Alert} from "@sveltestrap/sveltestrap";
import { onMount } from 'svelte';
import { ChatTwoWeb } from '$lib/chat'
import {addGfdStylesheet} from "$lib/gfd";
let data: App.Warning | null = null;
$effect.pre(() => {
if (page.url.searchParams.has('message')) {
data = {
hasWarning: true,
content: page.url.searchParams.get('message') ?? '',
};
} else {
data = {
hasWarning: false,
content: '',
};
}
});
onMount(() => {
console.log('the component has mounted');
// Populate the stylesheet with gfd data
addGfdStylesheet('/files/gfdata.gfd', '/files/fonticon_ps5.tex');
// Load all web functions in the background
const _ = new ChatTwoWeb();
});
</script>
<main class="chat">
<section id="messages">
<div class="scroll-container">
<ol id="messages-list"></ol>
</div>
<div id="more-messages-indicator">
<!-- "arrow-down" icon from https://github.com/feathericons/feather, under MIT license -->
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
</div>
</section>
{#if data?.hasWarning }
<section id="warnings">
<Alert content={data.content} color="warning" dismissible={true}/>
</section>
{/if}
<section id="input">
<form>
<div class="select-container">
<select id="channel-select"></select>
</div>
<div class="input-container">
<input type="text" id="chat-input" autocomplete="off" placeholder="Message" enterkeyhint="send" maxlength="500">
<div id="channel-hint"></div>
</div>
<button type="submit">Send</button>
</form>
</section>
</main>
<div id="timestamp-width-probe"></div>