Add pre-testing version of the webinterface
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,243 @@
|
||||
/* fonts */
|
||||
@font-face {
|
||||
font-family: Lodestone;
|
||||
src: url('files/FFXIV_Lodestone_SSF.ttf') format('truetype');
|
||||
unicode-range: U+E020-E0DB;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter var';
|
||||
font-weight: 100 900;
|
||||
font-style: oblique 0deg 10deg;
|
||||
src: url('static/Inter.var.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* variables */
|
||||
:root {
|
||||
--fg: white;
|
||||
--fg-faint: #a0a0a0;
|
||||
--fg-scrollbar: #404040;
|
||||
--bg: #101010;
|
||||
--bg-input: #202020;
|
||||
--bg-input-hover: #282828;
|
||||
--focus-color: #4060a0;
|
||||
|
||||
--gradient-clickable: linear-gradient(to bottom, #404040, var(--bg-input) 65%, var(--bg-input));
|
||||
--gradient-clickable-hover: linear-gradient(to bottom, #505050, var(--bg-input-hover) 65%, var(--bg-input-hover));
|
||||
|
||||
--timestamp-width: 70px;
|
||||
}
|
||||
|
||||
/* reset */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
color: var(--fg);
|
||||
font-family: Lodestone, 'Inter var', sans-serif;
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* layout and global styles */
|
||||
body {
|
||||
padding: 50px;
|
||||
height: 100dvh;
|
||||
background-color: var(--bg-input-hover);
|
||||
}
|
||||
|
||||
body > main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--bg);
|
||||
border-radius: 20px;
|
||||
|
||||
box-shadow: 0 0 50px 0 rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* message list */
|
||||
section#messages {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 20px;
|
||||
line-height: 1.5;
|
||||
|
||||
.scroll-container {
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
scrollbar-color: var(--fg-scrollbar) var(--bg);
|
||||
|
||||
&.more-messages::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -20px;
|
||||
left: 100px;
|
||||
width: calc(100% - 200px);
|
||||
height: 200px;
|
||||
background-image: radial-gradient(50% 20% at 50% 100%, #60a0ff40, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
|
||||
.timestamp {
|
||||
flex: 0 0 var(--timestamp-width);
|
||||
color: var(--fg-faint);
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#timestamp-width-probe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* input bar, channel selector, ... */
|
||||
section#input {
|
||||
flex-grow: 0;
|
||||
padding: 20px;
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
input, button, select {
|
||||
font-size: 1rem;
|
||||
border: 3px solid transparent;
|
||||
border-radius: 20px;
|
||||
background-color: var(--bg-input);
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid var(--focus-color);
|
||||
}
|
||||
}
|
||||
|
||||
button, select {
|
||||
padding: 5px 15px;
|
||||
border: 3px solid var(--bg-input);
|
||||
background-image: var(--gradient-clickable);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-input-hover);
|
||||
background-color: var(--bg-input-hover);
|
||||
background-image: var(--gradient-clickable-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.select-container, button {
|
||||
position: relative;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.select-container {
|
||||
flex-basis: 0;
|
||||
|
||||
&::before {
|
||||
/* "message-circle" icon from https://github.com/feathericons/feather, under MIT license */
|
||||
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>');
|
||||
}
|
||||
|
||||
select {
|
||||
width: 100%;
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
appearance: none;
|
||||
color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
padding-left: calc(20px + 1.5rem);
|
||||
|
||||
&::before {
|
||||
/* "send" icon from https://github.com/feathericons/feather, under MIT license */
|
||||
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>');
|
||||
}
|
||||
}
|
||||
|
||||
.select-container::before, button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 15px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
background-color: var(--fg);
|
||||
mask-size: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.select-container::before {
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-50%);
|
||||
}
|
||||
|
||||
.input-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
|
||||
#chat-input {
|
||||
width: 100%;
|
||||
padding: 5px 20px;
|
||||
}
|
||||
|
||||
#channel-hint {
|
||||
position: absolute;
|
||||
top: -1.2em;
|
||||
left: 23px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 550;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*** mobile ***/
|
||||
@media ((max-width: 600px) and (orientation: portrait)) or (max-width: 400px) {
|
||||
body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body > main {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
section#input {
|
||||
button {
|
||||
max-width: 0;
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
button::before {
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// websocket connection
|
||||
class WSConnection {
|
||||
constructor() {
|
||||
this.socket = new WebSocket('ws://192.168.2.106:9001/ws');
|
||||
this.socket.addEventListener('open', this.onWSOpen.bind(this));
|
||||
this.socket.addEventListener('close', this.onWSClose.bind(this));
|
||||
this.socket.addEventListener('message', this.onWSMessage.bind(this));
|
||||
}
|
||||
|
||||
onWSOpen() {
|
||||
// send request for initial data (channels, currently selected channel)
|
||||
}
|
||||
|
||||
onWSClose() {
|
||||
// open new websocket here? for mobile
|
||||
}
|
||||
|
||||
onWSMessage(event) {
|
||||
try {
|
||||
let eventData = JSON.parse(event.data);
|
||||
for (let message of eventData.messages)
|
||||
{
|
||||
addMessage(message);
|
||||
}
|
||||
} catch (error) {
|
||||
// TODO: error handling?
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
send(message) {
|
||||
this.socket.send(message);
|
||||
}
|
||||
}
|
||||
|
||||
const ws = new WSConnection();
|
||||
|
||||
|
||||
// channel switcher
|
||||
function updateChannelHint(label) {
|
||||
document.getElementById('channel-hint').innerText = label;
|
||||
}
|
||||
document.getElementById('channel-select').addEventListener('change', (event) => {
|
||||
updateChannelHint(event.target.value);
|
||||
// TODO: send new channel to "backend"
|
||||
// ws.send(...);
|
||||
});
|
||||
|
||||
|
||||
// functions for handling the message list
|
||||
function scrollMessagesToBottom() {
|
||||
const messagesContainer = document.querySelector('#messages > .scroll-container');
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight - messagesContainer.offsetHeight;
|
||||
}
|
||||
|
||||
function messagesContainerIsScrolledToBottom() {
|
||||
const messagesContainer = document.querySelector('#messages > .scroll-container');
|
||||
return messagesContainer.scrollTop == messagesContainer.scrollHeight - messagesContainer.offsetHeight;
|
||||
}
|
||||
|
||||
function addMessage(messageData) {
|
||||
const scrolledToBottom = messagesContainerIsScrolledToBottom();
|
||||
|
||||
const liMessage = document.createElement('li');
|
||||
const spanTimestamp = document.createElement('span');
|
||||
spanTimestamp.classList.add('timestamp');
|
||||
const spanMessage = document.createElement('span');
|
||||
spanMessage.classList.add('message');
|
||||
|
||||
// suggestions, update with actual data model
|
||||
spanTimestamp.innerText = messageData.timestamp;
|
||||
spanMessage.innerHTML = messageData.messageHTML; // or build HTML in here
|
||||
|
||||
liMessage.appendChild(spanTimestamp);
|
||||
liMessage.appendChild(spanMessage);
|
||||
document.getElementById('messages-list').appendChild(liMessage);
|
||||
|
||||
if (scrolledToBottom) {
|
||||
scrollMessagesToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
document.getElementById('messages-list').innerHTML = '';
|
||||
}
|
||||
|
||||
//
|
||||
document.querySelector('#messages > .scroll-container').addEventListener('scroll', () => {
|
||||
const messagesContainer = document.querySelector('#messages > .scroll-container');
|
||||
if (!messagesContainerIsScrolledToBottom()) {
|
||||
messagesContainer.classList.add('more-messages');
|
||||
} else {
|
||||
messagesContainer.classList.remove('more-messages');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// handle message sending
|
||||
document.querySelector('#input > form').addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
const message = chatInput.value;
|
||||
|
||||
fetch('/send', {
|
||||
method: 'POST',
|
||||
body: message,
|
||||
headers: {
|
||||
'Content-type': 'application/txt; charset=UTF-8'
|
||||
}
|
||||
});
|
||||
|
||||
chatInput.value = '';
|
||||
});
|
||||
|
||||
|
||||
// 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. the solution below is very rudimentary; at the very least,
|
||||
// delaying it to account for font loading might make sense. perhaps there's an even better way?
|
||||
window.setTimeout(() => {
|
||||
const widthProbe = document.getElementById('timestamp-width-probe');
|
||||
widthProbe.innerText = '88:88'; // assume 8 to be widest glyph
|
||||
document.body.style.setProperty('--timestamp-width', Math.ceil(widthProbe.clientWidth) + 'px');
|
||||
}, 100);
|
||||
|
||||
// From kizer
|
||||
async function AddGfdStylesheet(gfdPath, texPath) {
|
||||
const texPromise = LoadTexAsBlob(texPath);
|
||||
const gfdPromise = LoadGfd(gfdPath);
|
||||
const texUrl = URL.createObjectURL(await texPromise);
|
||||
const gfd = await gfdPromise;
|
||||
|
||||
let stylesheets = [];
|
||||
for (const entry of gfd) {
|
||||
if (entry.width * entry.height <= 0)
|
||||
continue;
|
||||
|
||||
if (entry.redirect !== 0) {
|
||||
stylesheets[entry.redirect][0].push(entry.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
stylesheets[entry.id] = [
|
||||
[entry.id],
|
||||
[
|
||||
`background-position: -${entry.left}px -${entry.top}px`,
|
||||
`background-image: url('${texUrl}')`,
|
||||
`width: ${entry.width}px`,
|
||||
`height: ${entry.height}px`,
|
||||
`margin-bottom: -20px`
|
||||
].join(";"),
|
||||
[
|
||||
`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`,
|
||||
`margin-bottom: -40px`
|
||||
].join(";")
|
||||
];
|
||||
}
|
||||
|
||||
let stylesheet = ".gfd-icon::before { content: ''; display: inline-block; overflow: hidden; vertical-align: top; height:0; }";
|
||||
for (const entry of stylesheets) {
|
||||
if (!entry)
|
||||
continue;
|
||||
|
||||
stylesheet += `\n${entry[0].map(x => `.gfd-icon.gfd-icon-${x}::before`).join(', ')}{${entry[1]};}`;
|
||||
stylesheet += `\n${entry[0].map(x => `.gfd-icon.gfd-icon-hq-${x}::before`).join(', ')}{${entry[2]};}`;
|
||||
}
|
||||
|
||||
const styleNode = document.createElement("style");
|
||||
styleNode.type = "text/css";
|
||||
styleNode.appendChild(document.createTextNode(stylesheet));
|
||||
document.head.appendChild(styleNode);
|
||||
}
|
||||
|
||||
async function LoadTexAsBlob(path) {
|
||||
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) {
|
||||
const buffer = new DataView(await (await fetch(path)).arrayBuffer());
|
||||
const count = buffer.getInt32(8, true);
|
||||
const entries = 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) {
|
||||
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, true);
|
||||
const arraySize = buffer.getInt8(15, true);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
AddGfdStylesheet("/files/gfdata.gfd", "/files/fonticon_ps5.tex");
|
||||
Reference in New Issue
Block a user