diff --git a/src/snek/static/app.js b/src/snek/static/app.js index 81c58b4..141a60c 100644 --- a/src/snek/static/app.js +++ b/src/snek/static/app.js @@ -10,7 +10,7 @@ import { Schedule } from "./schedule.js"; import { EventHandler } from "./event-handler.js"; import { Socket } from "./socket.js"; - +import { Njet } from "./njet.js"; export class RESTClient { debug = false; diff --git a/src/snek/static/chat-input.js b/src/snek/static/chat-input.js index 28e271b..a6719a6 100644 --- a/src/snek/static/chat-input.js +++ b/src/snek/static/chat-input.js @@ -1,6 +1,7 @@ -import { app } from "../app.js"; - -class ChatInputComponent extends HTMLElement { +import { app } from "./app.js"; +import { NjetComponent } from "./njet.js"; +import { FileUploadGrid } from "./file-upload-grid.js"; +class ChatInputComponent extends NjetComponent { autoCompletions = { "example 1": () => { }, @@ -161,6 +162,11 @@ class ChatInputComponent extends HTMLElement { this.classList.add("chat-input"); + this.fileUploadGrid = new FileUploadGrid(); + this.fileUploadGrid.setAttribute("channel", this.channelUid); + this.fileUploadGrid.style.display = "none"; + this.appendChild(this.fileUploadGrid); + this.textarea.setAttribute("placeholder", "Type a message..."); this.textarea.setAttribute("rows", "2"); @@ -174,7 +180,12 @@ class ChatInputComponent extends HTMLElement { this.uploadButton.addEventListener("uploaded", (e) => { this.dispatchEvent(new CustomEvent("uploaded", e)); }); + this.subscribe("file-uploading", (e) => { + this.fileUploadGrid.style.display = "block"; + this.uploadButton.style.display = "none"; + this.textarea.style.display = "none"; + }) this.appendChild(this.uploadButton); this.textarea.addEventListener("blur", () => { this.updateFromInput(""); diff --git a/src/snek/static/file-upload-grid.css b/src/snek/static/file-upload-grid.css new file mode 100644 index 0000000..1892c16 --- /dev/null +++ b/src/snek/static/file-upload-grid.css @@ -0,0 +1,59 @@ +.fug-root { + background: #181818; + color: white; + font-family: sans-serif; + /*min-height: 100vh;*/ +} +.fug-grid { + display: grid; + grid-template-columns: repeat(6, 150px); + gap: 20px; + margin: 30px; +} +.fug-tile { + background: #111; + border: 2px solid #ff6600; + border-radius: 12px; + display: flex; + flex-direction: column; + align-items: center; + padding: 16px 8px 8px 8px; + box-shadow: 0 0 4px #333; + position: relative; +} +.fug-icon { + width: 48px; height: 48px; + margin-bottom: 8px; + object-fit: cover; + background: #222; + border-radius: 5px; +} +.fug-filename { + word-break: break-all; + margin-bottom: 8px; + font-size: 0.95em; + text-align: center; +} +.fug-progressbar { + width: 100%; + height: 7px; + background: #333; + border-radius: 4px; + overflow: hidden; + margin-top: auto; +} +.fug-progress { + height: 100%; + background: #ffb200; + transition: width 0.15s; + width: 0%; +} +.fug-tile.fug-done { + border-color: #0f0; +} +.fug-fileinput { + margin: 24px; + font-size: 1.1em; + display: none; +} + diff --git a/src/snek/static/file-upload-grid.js b/src/snek/static/file-upload-grid.js new file mode 100644 index 0000000..e0d5838 --- /dev/null +++ b/src/snek/static/file-upload-grid.js @@ -0,0 +1,172 @@ +import { NjetComponent, NjetDialog } from './njet.js'; + +const FUG_ICONS = { + file: 'data:image/svg+xml;utf8,' +}; + +class FileUploadGrid extends NjetComponent { + constructor() { + super(); + this._grid = null; + this._fileInput = null; + } + + openFileDialog() { + if(this.isBusy){ + const dialog = new NjetDialog({ + title: 'Upload in progress', + content: 'Please wait for the current upload to complete.', + primaryButton: { + text: 'OK', + handler: function () { + this.closest('njet-dialog').remove(); + } + } + }) + return false + } + + if (this._fileInput) { + this._fileInput.value = ''; // Allow same file selection twice + this._fileInput.click(); + } + return true; + } + + connectedCallback() { + this.render(); + this._fileInput.addEventListener('change', e => this.handleFiles(e.target.files)); + } + + render() { + // Root wrapper for styling + this.classList.add('fug-root'); + // Clear previous (if rerendered) + this.innerHTML = ''; + // Input + this._fileInput = document.createElement('input'); + this._fileInput.type = 'file'; + this._fileInput.className = 'fug-fileinput'; + this._fileInput.multiple = true; + // Grid + this._grid = document.createElement('div'); + this._grid.className = 'fug-grid'; + this.appendChild(this._fileInput); + this.appendChild(this._grid); + this.uploadsDone = 0; + this.uploadsStarted = 0; + + + } + + reset(){ + this.uploadsDone = 0; + this.uploadsStarted = 0; + this._grid.innerHTML = ''; + } + + get isBusy(){ + return this.uploadsDone != this.uploadsStarted; + } + + handleFiles(files) { + this.reset() + this.uploadsDone = 0; + this.uploadsStarted = files.length; + [...files].forEach(file => this.createTile(file)); + } + + createTile(file) { + const tile = document.createElement('div'); + tile.className = 'fug-tile'; + // Icon/Thumbnail + let icon; + if (file.type.startsWith('image/')) { + icon = document.createElement('img'); + icon.className = 'fug-icon'; + icon.src = URL.createObjectURL(file); + icon.onload = () => URL.revokeObjectURL(icon.src); + } else if (file.type.startsWith('video/')) { + icon = document.createElement('video'); + icon.className = 'fug-icon'; + icon.src = URL.createObjectURL(file); + icon.muted = true; + icon.playsInline = true; + icon.controls = false; + icon.preload = 'metadata'; + icon.onloadeddata = () => { + icon.currentTime = 0.5; + URL.revokeObjectURL(icon.src); + }; + } else { + icon = document.createElement('img'); + icon.className = 'fug-icon'; + icon.src = FUG_ICONS.file; + } + // Filename + const name = document.createElement('div'); + name.className = 'fug-filename'; + name.textContent = file.name; + // Progressbar + const progressbar = document.createElement('div'); + progressbar.className = 'fug-progressbar'; + const progress = document.createElement('div'); + progress.className = 'fug-progress'; + progressbar.appendChild(progress); + + // Tile composition + tile.appendChild(icon); + tile.appendChild(name); + tile.appendChild(progressbar); + this._grid.appendChild(tile); + + // Start upload + this.startUpload(file, tile, progress); + + } + + startUpload(file, tile, progress) { + + this.publish('file-uploading', {file: file, tile: tile, progress: progress}); + + const ws = new WebSocket(`ws://${location.host}/ws`); + ws.binaryType = 'arraybuffer'; + let sent = 0; + + ws.onopen = async () => { + ws.send(JSON.stringify({type: 'start', filename: file.name})); + const chunkSize = 64*1024; + while (sent < file.size) { + const chunk = file.slice(sent, sent + chunkSize); + ws.send(await chunk.arrayBuffer()); + sent += chunkSize; + } + ws.send(JSON.stringify({type: 'end', filename: file.name})); + }; + + ws.onmessage = (event) => { + + + const data = JSON.parse(event.data); + if (data.type === 'progress') { + const pct = Math.min(100, Math.round(100 * data.bytes / file.size)); + progress.style.width = pct + '%'; + this.publish('file-uploading', {file: file, tile: tile, progress: progress}); + } else if (data.type === 'done') { + + this.uploadsDone += 1; + this.publish('file-uploaded', {file: file, tile: tile, progress: progress}); + progress.style.width = '100%'; + tile.classList.add('fug-done'); + + ws.close(); + + this.reset() + } + }; + } +} + +customElements.define('file-upload-grid', FileUploadGrid); + +export { FileUploadGrid }; diff --git a/src/snek/templates/app.html b/src/snek/templates/app.html index ee054d0..fdd0b60 100644 --- a/src/snek/templates/app.html +++ b/src/snek/templates/app.html @@ -6,7 +6,12 @@ Snek + + + + + diff --git a/src/snek/templates/channel.html b/src/snek/templates/channel.html new file mode 100644 index 0000000..597241b --- /dev/null +++ b/src/snek/templates/channel.html @@ -0,0 +1,18 @@ + + diff --git a/src/snek/templates/web.html b/src/snek/templates/web.html index 8c78ea2..74791a7 100644 --- a/src/snek/templates/web.html +++ b/src/snek/templates/web.html @@ -3,7 +3,7 @@ {% block header_text %}

{{ name }}

{% endblock %} {% block main %} - +{% include "channel.html" %}
@@ -30,6 +30,7 @@ {{ message.html }} {% endautoescape %} {% endfor %} +