diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..c2d2ff8
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,39 @@
+name: Build Vite
+
+on:
+ push:
+ branches:
+ - master
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ strategy:
+ matrix:
+ node-version: [24.x]
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Use Node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node-version }}
+
+ - name: Install dependencies & build
+ run: |
+ npm ci
+ npm run build
+
+ - name: Deploy
+ uses: peaceiris/actions-gh-pages@v4
+ with:
+ publish_dir: ./dist
+ github_token: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 819f592..ffe0fcc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,29 +1,3 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements. See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership. The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License. You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied. See the License for the
-# specific language governing permissions and limitations
-# under the License.
-
-.DS_Store
-
-# Generated by package manager
+dist/
node_modules/
-
-# Generated by Cordova
-/plugins/
-/platforms/
-
-pandora.apks
-/android/app/build/
-test-upload-key.jks
+.idea
\ No newline at end of file
diff --git a/README.md b/README.md
index f310aab..b7b3008 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-
+
Pandora is an open-source web client for the WAHA API.
@@ -13,7 +13,7 @@ Pandora is an open-source web client for the WAHA API.
- [ ] Sending audio messages
- [x] Sending attachments (sends image/video as such)4
- [x] Notification sound5
-- [ ] Browser notification
+- [x] Browser notification
- [x] Download attachments
- [x] Automatically download images6
- [X] Local message storing
diff --git a/index.html b/index.html
deleted file mode 100644
index 1f455f7..0000000
--- a/index.html
+++ /dev/null
@@ -1,173 +0,0 @@
-
-
-
-
-
-
- Pandora
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/js/ui.js b/js/ui.js
deleted file mode 100644
index d2e1261..0000000
--- a/js/ui.js
+++ /dev/null
@@ -1,396 +0,0 @@
-import { formatTime, normalizeId } from "./utils.js";
-import { getChatPicture, getMessage, getMedia, getMoreChatMessages } from "./storage.js";
-import { getCurrentChat } from "./app.js";
-
-export const elements = {
- chatList: document.getElementById('chat-list'),
- chatsLoader: document.getElementById('chats-loader'),
- chatSearch: document.getElementById('chat-search'),
- backendStatusText: document.getElementById('backend-status-text'),
- apiStatusIndicator: document.querySelector('.pulse-dot'),
- noChatState: document.getElementById('no-chat-state'),
- activeChatContainer: document.getElementById('active-chat-container'),
- activeChatName: document.getElementById('active-chat-name'),
- activeChatAvatar: document.getElementById('active-chat-avatar'),
- messagesContainer: document.getElementById('messages-container'),
- messageForm: document.getElementById('message-form'),
- messageInput: document.getElementById('message-input'),
- backToSidebarBtn: document.querySelector('.chat-header'),
- sidebar: document.querySelector('.sidebar'),
- appContainer: document.querySelector('.app-container'),
- settingsModal: document.getElementById('settings-page'),
- settingsIconBtn: document.getElementById('settings-sidebar-btn'),
- saveSettingsBtn: document.getElementById('save-settings'),
- inputWahaUrl: document.getElementById('settings-waha-url'),
- inputSession: document.getElementById('settings-session'),
- inputApiKey: document.getElementById('settings-api-key'),
- inputBackgroundImage: document.getElementById('settings-background-image'),
- inputBackgroundOpacity: document.getElementById('settings-background-opacity'),
- loggedUserName: document.getElementById('pandora-username'),
- chatBottomBar: document.getElementById('chat-bottom-bar'),
- chatBottomBarBtn: document.getElementById('chat-bottom-bar-btn'),
- chatInputPanel: document.getElementById('chat-input-panel'),
- attachmentInput: document.getElementById('attachment-input'),
- attachmentBtn: document.getElementById('attachment-btn'),
- extraPages: document.querySelectorAll('.extra-page'),
- desktopSidebarButtons: document.querySelectorAll("#desktop-aside button"),
- contentUserName: document.querySelectorAll('[data-content="app-user"]'),
- contentUserNumber: document.querySelectorAll('[data-content="app-user-number"]'),
- resourceUserPic: document.querySelectorAll('[data-resource="app-user-image"]'),
- valueUserStatus: document.querySelectorAll('[data-value="app-user-status"]'),
- inputUserStatus: document.getElementById('profile-page-status-input'),
- selectable: document.querySelectorAll('.selectable'),
-};
-
-export const ui = {
- showExtraPage(pageId) {
- elements.extraPages.forEach(page => {
- if (page.id != pageId) {
- page.style.display = "none";
- } else {
- page.style.display = "flex";
- }
- })
- },
-
- /**
- * Switch view state when a contact chat is opened or closed
- */
- toggleChatState(hasActive) {
- if (hasActive) {
- elements.noChatState.classList.add('hidden');
- elements.activeChatContainer.classList.remove('hidden');
- } else {
- elements.noChatState.classList.remove('hidden');
- elements.activeChatContainer.classList.add('hidden');
- }
- },
-
- /**
- * Scroll message list automatically to bottom
- */
- scrollToBottom() {
- elements.messagesContainer.scrollTop = elements.messagesContainer.scrollHeight;
- },
-
- /**
- * Update connection status badge in sidebar footer
- */
- updateConnectionStatus(isConnected, text) {
- elements.backendStatusText.textContent = text;
- if (isConnected) {
- elements.apiStatusIndicator.style.backgroundColor = 'var(--online-color)';
- elements.apiStatusIndicator.style.animation = 'pulse 1.8s infinite';
- } else {
- elements.apiStatusIndicator.style.backgroundColor = '#ef4444';
- elements.apiStatusIndicator.style.animation = 'none';
- }
- },
-
- async renderChatList(chats, activeChat, onChatSelect) {
- elements.chatList.innerHTML = '';
- chats.sort((a, b) => b.timestamp - a.timestamp);
-
- if (chats.length === 0) {
- elements.chatList.innerHTML = `No chats found `;
- return;
- }
-
- for (const chat of chats) {
- const li = document.createElement('li');
- li.className = `chat-item selectable ${activeChat && activeChat.id === chat.id ? 'active' : ''}`;
- li.dataset.id = chat.id;
-
- const initials = chat.name ? chat.name.substring(0, 1).toUpperCase() : '?';
- const hasUnread = chat.unreadCount && chat.unreadCount > 0;
- const timeStr = formatTime(chat.timestamp || new Date());
-
- li.innerHTML = `
-
-
-
-
-
- ${chat.name}
- ${timeStr}
-
-
-
- ${chat.lastMessage || 'No messages yet'}
-
- ${hasUnread ? `${chat.unreadCount} ` : ''}
-
-
- `;
-
- li.addEventListener('click', () => onChatSelect(chat));
- elements.chatList.appendChild(li);
-
- (async () => {
- try {
- const picture = await getChatPicture(chat.id);
- const img = li.querySelector(`img[data-chat-avatar="${chat.id}"]`);
- if (img) img.src = picture.url ? picture.url : '';
- } catch (e) {
- }
- })();
- }
- },
-
- async updateChatInChatList(msg) {
- const chat = document.querySelector(`.chat-item[data-id="${msg.fromMe ? msg.to : msg.from}"]`);
-
- if (chat) {
- const messageItem = chat.querySelector('.chat-item-msg');
- const time = chat.querySelector('.chat-item-time')
- messageItem.innerText = msg.body || msg.text || 'Media message';
- time.innerText = msg.timestamp ? formatTime(msg.timestamp) : Date.now();
-
- const activeChatState = getCurrentChat();
-
- if (!msg.fromMe && (!activeChatState || activeChatState.id !== (msg.fromMe ? msg.to : msg.from))) {
- const unreadBadge = chat.querySelector('.unread-badge');
- unreadBadge.innerText = (Number(unreadBadge.innerHTML) || 0) + 1;
- }
- }
- },
-
- /**
- * Render chat message log inside chat view container
- */
- async renderMessages(messages, activeChatName, userID, chatId) {
- elements.messagesContainer.innerHTML = '';
-
- if (messages.length === 0) {
- elements.messagesContainer.innerHTML = 'No messages. Say hello!
';
- return;
- }
-
- const loadMore = document.createElement("button");
- loadMore.classList.add("load-more-btn");
- loadMore.innerText = "Load more";
- loadMore.addEventListener('click', () => {
- this.loadMoreMessages(chatId, userID);
- });
- elements.messagesContainer.appendChild(loadMore);
-
- for (const msg of messages) {
- this.appendSingleMessage(msg, userID, chatId);
- }
-
- this.scrollToBottom();
- },
-
- async loadMoreMessages(chatId, userId) {
- const oldest = document.querySelector('.message-group:first-of-type');
- const oldestTimestamp = oldest.dataset.timestamp;
- const oldestId = oldest.id;
- console.log(oldest)
- console.log(oldestId)
- console.log(oldestTimestamp)
-
- const loadMoreButton = document.querySelector('.load-more-btn');
- loadMoreButton.removeEventListener('click', this.loadMoreMessages);
-
- const msgs = await getMoreChatMessages(chatId, oldestTimestamp, oldestId);
- msgs.shift();
-
- msgs.forEach(async msg => {
- loadMoreButton.after(this.generateMessage(msg, userId, chatId));
- });
-
- loadMoreButton.addEventListener('click', this.loadMoreMessages);
- },
-
- /**
- * Append a single message (used for optimistic updates immediately upon sending)
- */
- appendSingleMessage(msg, userID, chatId, isLocal = false) {
- elements.messagesContainer.appendChild(this.generateMessage(msg, userID, chatId, isLocal))
- },
-
- generateTempMessageLink(msg) {
- const a = document.createElement('a');
- a.target = "_blank";
- a.href = msg.media.url;
-
- if (msg._data?.mimetype?.startsWith('image/')) {
- const img = document.createElement('img');
- img.classList.add('message-image-attachement');
- img.src = msg.media.url;
- a.appendChild(img);
- } else {
- a.textContent = msg.media.filename || "Download file";
- a.download = msg.media.filename;
- }
-
- return a;
- },
-
- generateMessage(msg, userID, chatId, isLocal = false) {
- const isOutgoing = msg.fromMe || msg.sender === 'me';
-
- function getPrevMessageElem() {
- return elements.messagesContainer.lastElementChild;
- }
-
- const prevMsgEl = getPrevMessageElem();
-
- const groupDiv = document.createElement('div');
- groupDiv.className = `message-group selectable ${isOutgoing ? 'outgoing' : 'incoming'}`;
- groupDiv.id = normalizeId(msg._serialized ? msg : msg.id);
- groupDiv.dataset.timestamp = msg.timestamp;
- groupDiv.dataset.from = msg.participant || msg.from;
-
- const senderName = isOutgoing ? userID : (msg._data.notifyName || msg.from);
- const timeStr = formatTime(msg.timestamp || new Date());
-
- let statusCheck = '';
- if (isOutgoing) {
- if (msg.status === 'read') {
- statusCheck = ' ';
- } else if (msg.status === 'delivered') {
- statusCheck = ' ';
- } else if (msg.status === 'sending') {
- statusCheck = ' ';
- } else {
- statusCheck = ' ';
- }
- }
-
- const bubble = document.createElement('div');
- bubble.className = 'message-bubble';
-
- let prevUid;
-
- if (msg.participant) {
- prevUid = msg.participant;
- } else {
- prevUid = msg.from;
- }
-
- if (!isOutgoing && (!prevMsgEl || prevUid !== prevMsgEl.dataset.from)) {
- const senderEl = document.createElement('span');
- senderEl.className = 'message-sender';
- senderEl.textContent = senderName;
- bubble.appendChild(senderEl);
- }
-
- const contentEl = document.createElement('div');
- contentEl.classList.add('message-content');
- const textEl = document.createElement('div');
- textEl.innerHTML = msg.body || msg.text || "";
- contentEl.appendChild(textEl);
- bubble.appendChild(contentEl);
-
- if (msg.hasMedia) {
- let a;
-
- if (isLocal) {
- a = this.generateTempMessageLink(msg);
- } else {
- a = document.createElement('a');
- a.innerText = `[Request media]`;
- a.target = "_blank";
-
- const clickListener = async (e) => {
- a.removeEventListener('click', clickListener);
- a.innerText = `[Downloading]`;
- const mediaMsg = msg.media ? msg : await getMessage(chatId, normalizeId(msg._serialized ? msg : msg.id), true);
- if (!mediaMsg || !mediaMsg?.media.url) {
- a.addEventListener('click', clickListener);
- a.innerText = `[Error, click to try again]`
- return;
- }
- const url = new URL(mediaMsg.media.url);
- const reqID = url.pathname.split('/').filter(Boolean).pop();
-
- const { blob, filename } = await getMedia(reqID);
-
- const objectUrl = URL.createObjectURL(blob);
- e.target.href = objectUrl;
-
- if (blob.type.startsWith('image/')) {
- a.textContent = "";
- const img = document.createElement('img');
- img.classList.add('message-image-attachement');
- img.src = objectUrl;
- bubble.after(img);
- bubble.querySelector('.message-content').remove();
- } else {
- e.target.textContent = filename || `Download ${mediaMsg.media.filename}`;
- }
- }
-
- a.addEventListener('click', clickListener);
- }
-
- contentEl.appendChild(a);
-
- if (!isLocal && msg._data?.mimetype?.startsWith('image/')) {
- a.click();
- }
- }
-
-
- const meta = document.createElement('div');
- meta.className = 'message-meta';
- meta.innerHTML = `${timeStr} ${statusCheck}`;
-
- bubble.appendChild(meta);
-
- if (isOutgoing) {
- if (prevMsgEl && prevMsgEl.classList.contains('outgoing')) {
- groupDiv.classList.add('same-sender');
- } else {
- groupDiv.appendChild(document.createElement('div')).className = 'message-indicator';
- }
- } else if (msg.participant) {
- if (!prevMsgEl || prevUid !== prevMsgEl.dataset.from) {
- groupDiv.appendChild(document.createElement('div')).className = 'message-indicator';
- } else groupDiv.classList.add('same-sender');
- } else {
- if (!prevMsgEl || prevUid !== prevMsgEl.dataset.from) {
- groupDiv.appendChild(document.createElement('div')).className = 'message-indicator';
- } else groupDiv.classList.add('same-sender');
- }
-
- groupDiv.appendChild(bubble);
- return groupDiv;
- },
-
- updateMessage(originalMsgId, generatedMsg) {
- const originalMsg = document.querySelector(`#${originalMsgId}`);
- if (originalMsg) {
- originalMsg.replaceWith(generatedMsg)
- }
- },
-
- updateMessageTick(id, status) {
- let statusCheck;
- if (status === 'read') {
- statusCheck = ' ';
- } else if (status === 'delivered') {
- statusCheck = ' ';
- } else if (status === 'sending') {
- statusCheck = ' ';
- } else {
- statusCheck = ' ';
- }
-
- document.getElementById(id).querySelector('.message-meta').outerHTML = statusCheck;
- },
-
- toggleChatBottomBar() {
- elements.chatBottomBar.classList.toggle("collapsed");
- },
-
- removeChatMessage(msgId) {
- const message = document.getElementById(msgId);
- if (message) message.remove();
- }
-};
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..7ebca84
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1249 @@
+{
+ "name": "pandora",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "pandora",
+ "version": "1.0.0",
+ "license": "ISC",
+ "devDependencies": {
+ "typescript": "^7.0.2",
+ "vite": "^8.1.5"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
+ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
+ "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.139.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
+ "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
+ "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
+ "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
+ "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
+ "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
+ "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
+ "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
+ "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
+ "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
+ "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
+ "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
+ "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
+ "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
+ "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.11.1",
+ "@emnapi/runtime": "1.11.1",
+ "@napi-rs/wasm-runtime": "^1.1.6"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
+ "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
+ "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
+ "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@typescript/typescript-aix-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-loong64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-mips64el": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-riscv64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-s390x": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-sunos-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.20",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz",
+ "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.16",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
+ "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.139.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.1.5",
+ "@rolldown/binding-darwin-arm64": "1.1.5",
+ "@rolldown/binding-darwin-x64": "1.1.5",
+ "@rolldown/binding-freebsd-x64": "1.1.5",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
+ "@rolldown/binding-linux-arm64-gnu": "1.1.5",
+ "@rolldown/binding-linux-arm64-musl": "1.1.5",
+ "@rolldown/binding-linux-ppc64-gnu": "1.1.5",
+ "@rolldown/binding-linux-s390x-gnu": "1.1.5",
+ "@rolldown/binding-linux-x64-gnu": "1.1.5",
+ "@rolldown/binding-linux-x64-musl": "1.1.5",
+ "@rolldown/binding-openharmony-arm64": "1.1.5",
+ "@rolldown/binding-wasm32-wasi": "1.1.5",
+ "@rolldown/binding-win32-arm64-msvc": "1.1.5",
+ "@rolldown/binding-win32-x64-msvc": "1.1.5"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/typescript": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc"
+ },
+ "engines": {
+ "node": ">=16.20.0"
+ },
+ "optionalDependencies": {
+ "@typescript/typescript-aix-ppc64": "7.0.2",
+ "@typescript/typescript-darwin-arm64": "7.0.2",
+ "@typescript/typescript-darwin-x64": "7.0.2",
+ "@typescript/typescript-freebsd-arm64": "7.0.2",
+ "@typescript/typescript-freebsd-x64": "7.0.2",
+ "@typescript/typescript-linux-arm": "7.0.2",
+ "@typescript/typescript-linux-arm64": "7.0.2",
+ "@typescript/typescript-linux-loong64": "7.0.2",
+ "@typescript/typescript-linux-mips64el": "7.0.2",
+ "@typescript/typescript-linux-ppc64": "7.0.2",
+ "@typescript/typescript-linux-riscv64": "7.0.2",
+ "@typescript/typescript-linux-s390x": "7.0.2",
+ "@typescript/typescript-linux-x64": "7.0.2",
+ "@typescript/typescript-netbsd-arm64": "7.0.2",
+ "@typescript/typescript-netbsd-x64": "7.0.2",
+ "@typescript/typescript-openbsd-arm64": "7.0.2",
+ "@typescript/typescript-openbsd-x64": "7.0.2",
+ "@typescript/typescript-sunos-x64": "7.0.2",
+ "@typescript/typescript-win32-arm64": "7.0.2",
+ "@typescript/typescript-win32-x64": "7.0.2"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.1.5",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
+ "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.17",
+ "rolldown": "~1.1.5",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.3.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..f8b9b76
--- /dev/null
+++ b/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "pandora",
+ "version": "1.0.0",
+ "description": " ",
+ "main": "index.js",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "tsc && vite build && vite preview"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "devDependencies": {
+ "typescript": "^7.0.2",
+ "vite": "^8.1.5"
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..3af771c
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "isolatedModules": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "outDir": "./dist",
+ "rootDir": "./web/src",
+ "sourceMap": true
+ },
+ "include": ["web/src"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..bdf4746
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ root: 'web',
+ base: '/pandora/',
+ build: {
+ outDir: '../dist',
+ emptyOutDir: true,
+ },
+});
diff --git a/web/app.html b/web/app.html
new file mode 100644
index 0000000..2f7be0d
--- /dev/null
+++ b/web/app.html
@@ -0,0 +1,175 @@
+
+
+
+
+
+
+ Pandora
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fonts/selawk.ttf b/web/fonts/selawk.ttf
similarity index 100%
rename from fonts/selawk.ttf
rename to web/fonts/selawk.ttf
diff --git a/fonts/selawkb.ttf b/web/fonts/selawkb.ttf
similarity index 100%
rename from fonts/selawkb.ttf
rename to web/fonts/selawkb.ttf
diff --git a/fonts/selawkl.ttf b/web/fonts/selawkl.ttf
similarity index 100%
rename from fonts/selawkl.ttf
rename to web/fonts/selawkl.ttf
diff --git a/fonts/selawksb.ttf b/web/fonts/selawksb.ttf
similarity index 100%
rename from fonts/selawksb.ttf
rename to web/fonts/selawksb.ttf
diff --git a/fonts/selawksl.ttf b/web/fonts/selawksl.ttf
similarity index 100%
rename from fonts/selawksl.ttf
rename to web/fonts/selawksl.ttf
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..ab8d213
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+ Pandora
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/loading.css b/web/loading.css
similarity index 100%
rename from loading.css
rename to web/loading.css
diff --git a/metroicons.css b/web/metroicons.css
similarity index 100%
rename from metroicons.css
rename to web/metroicons.css
diff --git a/icon-192.png b/web/public/icon-192.png
similarity index 100%
rename from icon-192.png
rename to web/public/icon-192.png
diff --git a/icon-512.png b/web/public/icon-512.png
similarity index 100%
rename from icon-512.png
rename to web/public/icon-512.png
diff --git a/manifest.json b/web/public/manifest.json
similarity index 100%
rename from manifest.json
rename to web/public/manifest.json
diff --git a/message-long.ogg b/web/public/message-long.ogg
similarity index 100%
rename from message-long.ogg
rename to web/public/message-long.ogg
diff --git a/message.ogg b/web/public/message.ogg
similarity index 100%
rename from message.ogg
rename to web/public/message.ogg
diff --git a/js/app.js b/web/src/app.ts
similarity index 63%
rename from js/app.js
rename to web/src/app.ts
index 1611b35..cdf0242 100644
--- a/js/app.js
+++ b/web/src/app.ts
@@ -1,28 +1,38 @@
-import { config } from "./config.js";
-import { waha } from "./waha.js";
-import { ui, elements } from "./ui.js";
-import { websocket } from "./websocket.js";
-import { compensateMessageOrdering, debounce, formatTime, normalizeId } from "./utils.js";
-import { fetchChats, getAppUser, getChatMessages, getChatPicture, getChats, getUser, getUserAbout, sendStatus, updateOnlineStatus } from "./storage.js";
-import { upsertMessages } from "./db.js";
+import { config } from "./config";
+import { waha } from "./waha";
+import { ui, elements, views } from "./ui";
+import { websocket } from "./websocket";
+import { compensateMessageOrdering, debounce, formatTime, normalizeId } from "./utils";
+import { fetchChats, getAppUser, getChatMessages, getChatPicture, getChats, getUser, getUserAbout, markRead, sendStatus, updateOnlineStatus } from "./storage";
+import { deleteDatabase, upsertMessages } from "./db";
+import { showNotification } from "./notification";
+import type { Chat, Message, WebSocketEvent } from "./types";
+import { activeChatState, setActiveChatState } from "./states";
-let activeChatState = null;
const messageTone = new Audio("./message.ogg");
const longPressEvent = new CustomEvent("longpress");
export let isLoadingChat = false;
+export let notificationAuthorization: NotificationPermission = "default";
+const mainViewEl = document.getElementById("main-view");
+if (!mainViewEl) throw console.error();
+const mainView = views.get(mainViewEl);
document.addEventListener('DOMContentLoaded', async () => {
- elements.inputApiKey.value = config.apiKey;
- elements.inputWahaUrl.value = config.wahaUrl;
- elements.inputSession.value = config.session;
- elements.inputBackgroundImage.value = config.bgImg;
- elements.inputBackgroundOpacity.value = config.bgOpacity;
- elements.activeChatContainer.style.setProperty('--background-image', `URL("${config.bgImg}")`);
- elements.activeChatContainer.style.setProperty('--background-opacity', `${config.bgOpacity}`);
+ updateSidebarPosition();
+ askForNotificationPermission();
+ if (elements.inputApiKey) elements.inputApiKey.value = config.apiKey;
+ if (elements.inputWahaUrl) elements.inputWahaUrl.value = config.wahaUrl;
+ if (elements.inputSession) elements.inputSession.value = config.session;
+ if (elements.inputBackgroundImage) elements.inputBackgroundImage.value = config.bgImg;
+ if (elements.inputBackgroundOpacity) elements.inputBackgroundOpacity.value = config.bgOpacity;
+ if (elements.activeChatContainer) {
+ elements.activeChatContainer.style.setProperty('--background-image', `URL("${config.bgImg}")`);
+ elements.activeChatContainer.style.setProperty('--background-opacity', `${config.bgOpacity}`);
+ }
await updateOnlineStatus();
setupEventListeners();
try {
- setupElementsData();
+ setupElementsData();
loadChats();
checkWahaStatus();
initWebSocket();
@@ -31,29 +41,46 @@ document.addEventListener('DOMContentLoaded', async () => {
}
});
+async function askForNotificationPermission() {
+ notificationAuthorization = await Notification.requestPermission();
+}
+
async function setupElementsData() {
- const usr = await getAppUser();
- const usrPic = (await getChatPicture(usr.id))?.url;
- const usrInfo = await getUser(usr.id);
- const usrAbout = (await getUserAbout(usr.id)).about;
- elements.contentUserName.forEach(e => {
- e.innerHTML = usr.pushName;
- })
- elements.contentUserNumber.forEach(async e => {
- e.innerHTML = usrInfo.number;
- })
- elements.resourceUserPic.forEach(async e => {
- e.src = usrPic;
- })
- elements.valueUserStatus.forEach(async e => {
- e.value = usrAbout.trim();
- })
+ try {
+ const usr = await getAppUser();
+ const usrPic = (await getChatPicture(usr.id))?.url;
+ const usrInfo = await getUser(usr.id);
+ const usrAbout = (await getUserAbout(usr.id))?.about;
+ elements.contentUserName.forEach(e => {
+ e.innerHTML = usr.pushName || usr.name || '';
+ })
+ elements.contentUserNumber.forEach(async e => {
+ if (usrInfo) e.innerHTML = usrInfo.number;
+ })
+ elements.resourceUserPic.forEach(async e => {
+ if (usrPic) e.src = usrPic;
+ })
+ elements.valueUserStatus.forEach(async e => {
+ if (usrAbout) e.value = usrAbout.trim();
+ })
+ } catch (error: any) {
+ console.error(error.message);
+ }
+}
+
+function purgeDatabase(ask: boolean = true) {
+ if (ask) {
+ if (!(confirm("Are you sure you want to delete all cached messages?") && confirm("This cannot be undone. Proceed?"))) return;
+ }
+ localStorage.clear();
+ deleteDatabase();
+ location.reload();
}
function loadChats() {
elements.chatsLoader.classList.remove('hidden');
try {
- fetchChats().then(async () => {
+ fetchChats().then(async () => {
ui.renderChatList(getChats(), activeChatState, selectChat);
const hash = window.location.hash;
@@ -65,7 +92,7 @@ function loadChats() {
}
}
});
- } catch (error) {
+ } catch (error: any) {
console.error('Failed to load chats:', error);
elements.chatList.innerHTML = `
@@ -82,7 +109,7 @@ function loadChats() {
}
let isScrollingProgrammatically = false;
-let scrollTimeout = null;
+let scrollTimeout: ReturnType | null = null;
function scrollToChat(smooth = true) {
isScrollingProgrammatically = true;
@@ -103,6 +130,8 @@ function scrollToList(smooth = true) {
}
function setupEventListeners() {
+ window.addEventListener('resize', updateSidebarPosition);
+
if (!window.location.hash) {
window.location.hash = '';
}
@@ -119,12 +148,12 @@ function setupEventListeners() {
closeActiveChat(true);
}
});
-
+
elements.appContainer.addEventListener('scroll', () => {
if (window.innerWidth > 768) return;
if (isScrollingProgrammatically) return;
- clearTimeout(scrollTimeout);
+ if (scrollTimeout) clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
const scrollLeft = elements.appContainer.scrollLeft;
const width = elements.appContainer.clientWidth;
@@ -137,7 +166,15 @@ function setupEventListeners() {
}
}, 100);
});
-
+
+ elements.appContainer.addEventListener('resize', () => {
+ if (window.innerWidth > 768) {
+ elements.desktopAside.after(elements.sidebar);
+ } else {
+ mainViewEl?.insertBefore(elements.sidebar, mainViewEl.firstChild);
+ }
+ })
+
document.addEventListener('keydown', (e) => {
if (e.code == "Escape") {
e.preventDefault();
@@ -145,9 +182,9 @@ function setupEventListeners() {
}
});
- elements.chatSearch.addEventListener('input', (e) => {
- const query = e.target.value.toLowerCase();
- const filtered = getChats().filter(chat =>
+ elements.chatSearch.addEventListener('input', (e: Event) => {
+ const query = (e.target as HTMLInputElement).value.toLowerCase();
+ const filtered = getChats().filter(chat =>
chat.name.toLowerCase().includes(query)
);
ui.renderChatList(filtered, activeChatState, selectChat);
@@ -170,59 +207,89 @@ function setupEventListeners() {
if (e.target == e.currentTarget) ui.toggleChatBottomBar();
});
+ elements.markreadBtn.addEventListener('click', async () => {
+ if (activeChatState) {
+ const result = await markRead(activeChatState.id);
+ if (result) ui.updateChatInChatList2(result);
+ }
+ })
elements.attachmentBtn.addEventListener('click', () => {
elements.attachmentInput.click();
})
- elements.attachmentInput.addEventListener('change', function () {
- const firstFile = this.files[0];
- sendFileMessage(firstFile);
+ elements.attachmentInput.addEventListener('change', function (this: HTMLInputElement) {
+ const firstFile = this.files?.[0];
+ if (firstFile) sendFileMessage(firstFile);
})
elements.backToSidebarBtn.addEventListener('click', () => {
closeActiveChat(false);
- });
-
+ });
+
elements.desktopSidebarButtons.forEach(sidebarBtn => {
sidebarBtn.addEventListener('click', () => {
- ui.showExtraPage(sidebarBtn.dataset.page);
+ const page = sidebarBtn.dataset.page;
+ if (!page) return;
+ const pageEl = document.getElementById(page);
+ if (!pageEl) return;
+ if (page) mainView?.scrollTo(pageEl);
})
})
-
+
elements.saveSettingsBtn.addEventListener('click', saveSettings);
-
+
+ elements.purgeDatabaseButton.addEventListener('click', () => purgeDatabase(true));
+
elements.inputUserStatus.addEventListener('input', debounce(async function() {
const result = await sendStatus(elements.inputUserStatus.value);
+ if (result?.success) {
+ showNotification("Status updated successfully!", "", 2000);
+ } else {
+ showNotification("Failed to update status...", "", 2000);
+ }
console.log(result);
}, 2000))
-
+
elements.selectable.forEach(e => {
- let timerId, longPressed;
-
+ let timerId: ReturnType, longPressed: boolean;
+
e.addEventListener('mousedown', () => {
longPressed = false;
-
+
timerId = setTimeout(() => {
longPressed = true;
e.dispatchEvent(longPressEvent);
- })
+ }, 500); // 500ms for long press
})
-
- e.addEventListener('click', () => {
+
+ e.addEventListener('click', (event) => {
if (longPressed) {
- e.preventDefault();
+ event.preventDefault();
clearTimeout(timerId);
}
})
-
+
e.addEventListener('mouseleave', () => {
clearTimeout(timerId);
})
})
}
+function updateSidebarPosition() {
+ if (!mainViewEl || !elements.sidebar || !elements.desktopAside) return;
+
+ if (window.innerWidth > 768) {
+ if (elements.sidebar.parentElement !== elements.appContainer) {
+ elements.desktopAside.after(elements.sidebar);
+ }
+ } else {
+ if (elements.sidebar.parentElement !== mainViewEl) {
+ mainViewEl.insertBefore(elements.sidebar, mainViewEl.firstChild);
+ }
+ }
+}
+
function initWebSocket() {
- websocket.connect((data) => {
- // console.log('[WS] Received event:', data.event, data);
+ websocket.connect((data: WebSocketEvent) => {
const ev = data.event;
if (ev === 'message' || ev === 'message.any' || ev === 'message.ack') {
handleIncomingMessage(data.payload);
@@ -232,46 +299,47 @@ function initWebSocket() {
}
-async function handleIncomingMessage(msg) {
+async function handleIncomingMessage(msg: Message) {
if (!msg) return;
ui.updateChatInChatList(msg);
- const rawChatId = msg.chatId || msg.from || (msg.chat && msg.chat.id);
+ const rawChatId = msg.chatId || (typeof msg.from === 'string' ? msg.from : (msg.from as any)?._serialized) || (msg.chat && msg.chat.id);
const msgChatId = normalizeId(rawChatId);
if (!msgChatId) {
console.warn('[WS] Could not resolve chatId from payload:', msg);
return;
}
- // console.log('[WS] Resolved msgChatId:', msgChatId, '| activeChatState:', activeChatState?.id);
if (!msg.fromMe) {
messageTone.play();
}
+ if (notificationAuthorization === "granted") {
+ new Notification("New message", { body: msg.body || msg.text });
+ }
if (activeChatState && activeChatState.id === msgChatId) {
- const msgId = normalizeId(msg.id) || msg.id;
+ const msgId = normalizeId(msg.id as any) || (msg.id as string);
const exists = document.getElementById(msgId);
if (!exists) {
- const scrolled = elements.messagesContainer.scrollTop == elements.messagesContainer.scrollTopMax;
+ const container = elements.messagesContainer;
+ const scrolled = container.scrollTop === (container.scrollHeight - container.clientHeight);
ui.appendSingleMessage({ ...msg, chatId: msgChatId }, activeChatState.name, (await getAppUser()).id);
if (scrolled) {
ui.scrollToBottom();
- }
+ }
}
}
}
-async function selectChat(chat, isPopState = false, smoothScroll = true) {
+async function selectChat(chat: Chat, isPopState = false, smoothScroll = true) {
if (isLoadingChat) return;
isLoadingChat = true;
- activeChatState = chat;
+ setActiveChatState(chat);
chat.unreadCount = 0;
- // ui.renderChatList(chatsState, activeChatState, selectChat);
-
ui.toggleChatState(true);
elements.activeChatName.textContent = chat.name.toUpperCase();
elements.activeChatAvatar.textContent = chat.name ? chat.name.substring(0, 1).toUpperCase() : '?';
@@ -307,19 +375,19 @@ async function selectChat(chat, isPopState = false, smoothScroll = true) {
console.error('Failed to load messages:', error);
elements.messagesContainer.innerHTML = 'Error loading messages
';
}
-
+
isLoadingChat = false;
}
async function closeActiveChat(isPopState = false) {
- activeChatState = null;
+ setActiveChatState(null);
if (window.innerWidth <= 768) {
scrollToList();
} else {
ui.toggleChatState(false);
}
-
+
if (!isPopState) {
if (window.location.hash.startsWith('#chat-')) {
@@ -341,7 +409,7 @@ async function sendMessage() {
sender: 'me',
timestamp: new Date().toISOString(),
status: 'sending'
- };
+ } as any;
ui.appendSingleMessage(tempMsg, activeChatState.name, (await getAppUser()).id);
ui.scrollToBottom();
@@ -365,7 +433,7 @@ async function sendMessage() {
if (!activeChatState.id.endsWith('@lid')) {
await waha.readChat(activeChatState.id);
}
- } catch (e) {
+ } catch (e: any) {
console.warn('readChat failed (non-fatal):', e.message);
}
@@ -374,25 +442,26 @@ async function sendMessage() {
const tempBubble = document.getElementById(tempMsg.id);
if (tempBubble) {
if (responseData && responseData.id) {
- tempBubble.id = normalizeId(responseData.id);
+ tempBubble.id = normalizeId(responseData.id as any) || tempBubble.id;
}
const meta = tempBubble.querySelector('.message-meta');
- meta.innerHTML = `${formatTime(new Date())} `;
+ if (meta) meta.innerHTML = `${formatTime(new Date())} `;
}
activeChatState.lastMessage = text;
- activeChatState.timestamp = new Date();
+ activeChatState.timestamp = new Date().toISOString();
} catch (error) {
console.error('Failed to send message:', error);
const tempBubble = document.getElementById(tempMsg.id);
if (tempBubble) {
const meta = tempBubble.querySelector('.message-meta');
- meta.innerHTML = `Failed to send `;
+ if (meta) meta.innerHTML = `Failed to send `;
}
}
}
-async function sendFileMessage(file) {
+async function sendFileMessage(file: File) {
+ if (!activeChatState) return;
try {
const tempId = 'temp-' + Date.now();
const tempMsg = {
@@ -410,15 +479,15 @@ async function sendFileMessage(file) {
url: URL.createObjectURL(file),
filename: file.name
}
- };
+ } as any;
- ui.appendSingleMessage(tempMsg, activeChatState.name, (await getAppUser()).id, activeChatState.id, true);
+ ui.appendSingleMessage(tempMsg, activeChatState.name, (await getAppUser()).id, true);
ui.scrollToBottom();
const result = await waha.sendFileMessage(activeChatState.id, file);
ui.removeChatMessage(tempId);
- ui.appendSingleMessage(result.id, activeChatState.name, (await getAppUser()).id, activeChatState.id);
- } catch (error) {
+ ui.appendSingleMessage(result, activeChatState.name, (await getAppUser()).id);
+ } catch (error: any) {
console.error(error.message);
}
}
@@ -448,4 +517,4 @@ async function checkWahaStatus() {
export function getCurrentChat() {
return activeChatState;
-}
\ No newline at end of file
+}
diff --git a/js/config.js b/web/src/config.ts
similarity index 60%
rename from js/config.js
rename to web/src/config.ts
index edb15f6..cb53254 100644
--- a/js/config.js
+++ b/web/src/config.ts
@@ -1,13 +1,20 @@
-// Client Configuration State & Storage Manager
+export interface Config {
+ wahaUrl: string;
+ session: string;
+ apiKey: string;
+ bgImg: string;
+ bgOpacity: string;
+ save(url: string, session: string, apiKey: string, bgImg: string, bgOpacity: string): void;
+}
-export const config = {
- wahaUrl: localStorage.getItem('waha_url') || 'http://inspiran.beetal-castor.ts.net:3100',
- session: localStorage.getItem('waha_session') || 'session_01kxc62bk5fs8vh4v127k88a7j',
+export const config: Config = {
+ wahaUrl: localStorage.getItem('waha_url') || '',
+ session: localStorage.getItem('waha_session') || '',
apiKey: localStorage.getItem('waha_api_key') || '',
bgImg: localStorage.getItem('background_image') || '',
bgOpacity: localStorage.getItem('background_opacity') || '0.4',
- save(url, session, apiKey, bgImg, bgOpacity) {
+ save(url: string, session: string, apiKey: string, bgImg: string, bgOpacity: string): void {
this.wahaUrl = url.trim().replace(/\/$/, "");
this.session = session.trim();
this.apiKey = apiKey.trim();
diff --git a/js/db.js b/web/src/db.ts
similarity index 68%
rename from js/db.js
rename to web/src/db.ts
index f1eb2a2..1c1f9fb 100644
--- a/js/db.js
+++ b/web/src/db.ts
@@ -1,25 +1,26 @@
-import { normalizeId } from "./utils.js";
+import { normalizeId } from "./utils";
+import type { Chat, Message, StoredMedia } from "./types";
const DB_NAME = "pandora";
const DB_VERSION = 6;
-let dbPromise = null;
+let dbPromise: Promise | null = null;
-function openDb() {
+function openDb(): Promise {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
- req.onupgradeneeded = (e) => {
+ req.onupgradeneeded = () => {
const db = req.result;
- const tx = req.transaction;
+ const tx = req.transaction!;
if (!db.objectStoreNames.contains("chats")) {
const store = db.createObjectStore("chats", { keyPath: "id" });
store.createIndex("timestamp", "timestamp", { unique: false });
}
- let msgStore;
+ let msgStore: IDBObjectStore;
if (!db.objectStoreNames.contains("messages")) {
msgStore = db.createObjectStore("messages", { keyPath: "id" });
msgStore.createIndex("from", "from", { unique: false });
@@ -33,22 +34,21 @@ function openDb() {
msgStore.createIndex("chatId_timestamp", ["chatId", "timestamp"], { unique: false });
}
- if (db.objectStoreNames.contains("messages")) {
- msgStore.openCursor().onsuccess = (event) => {
- const cursor = event.target.result;
- if (cursor) {
- const m = cursor.value;
- const from = normalizeId(m.from);
- const to = normalizeId(m.to);
- const chatId = normalizeId(m.chatId) || (m.fromMe ? to : from);
- if (!m.chatId && chatId) {
- m.chatId = chatId;
- cursor.update(m);
- }
- cursor.continue();
+ // Migration logic
+ msgStore.openCursor().onsuccess = (event) => {
+ const cursor = (event.target as IDBRequest).result;
+ if (cursor) {
+ const m = cursor.value as Message;
+ const from = normalizeId(m.from);
+ const to = normalizeId(m.to);
+ const chatId = normalizeId(m.chatId) || (m.fromMe ? to : from);
+ if (!m.chatId && chatId) {
+ m.chatId = chatId;
+ cursor.update(m);
}
- };
- }
+ cursor.continue();
+ }
+ };
if (!db.objectStoreNames.contains("media")) {
db.createObjectStore("media", { keyPath: "reqId" });
@@ -62,7 +62,7 @@ function openDb() {
return dbPromise;
}
-export async function upsertChats(chats) {
+export async function upsertChats(chats: Chat[]): Promise {
const db = await openDb();
return new Promise((resolve, reject) => {
@@ -84,7 +84,7 @@ export async function upsertChats(chats) {
});
}
-export async function loadChatsSorted() {
+export async function loadChatsSorted(): Promise {
const db = await openDb();
return new Promise((resolve, reject) => {
@@ -92,9 +92,9 @@ export async function loadChatsSorted() {
const store = tx.objectStore("chats");
const idx = store.index("timestamp");
- const result = [];
+ const result: Chat[] = [];
idx.openCursor(null, "prev").onsuccess = (e) => {
- const cursor = e.target.result;
+ const cursor = (e.target as IDBRequest).result;
if (cursor) {
result.push(cursor.value);
cursor.continue();
@@ -107,7 +107,20 @@ export async function loadChatsSorted() {
});
}
-function mapMessage(m) {
+export async function loadChat(chatId: string): Promise {
+ const db = await openDb();
+
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction("chats", "readonly");
+ const store = tx.objectStore("chats");
+ const req = store.get(chatId);
+
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+}
+
+function mapMessage(m: Message): any {
const from = normalizeId(m.from);
const to = normalizeId(m.to);
const chatId = normalizeId(m.chatId) || (m.fromMe ? to : from);
@@ -126,7 +139,7 @@ function mapMessage(m) {
}
}
-export async function upsertMessages(messages) {
+export async function upsertMessages(messages: Message[]): Promise {
const db = await openDb();
return new Promise((resolve, reject) => {
@@ -142,7 +155,7 @@ export async function upsertMessages(messages) {
});
}
-export async function loadLatestMessages(chatId, limit = 50) {
+export async function loadLatestMessages(chatId: string, limit: number = 50): Promise {
const db = await openDb();
return new Promise((resolve, reject) => {
@@ -150,12 +163,12 @@ export async function loadLatestMessages(chatId, limit = 50) {
const store = tx.objectStore("messages");
const idx = store.index("chatId_timestamp");
- const out = [];
+ const out: Message[] = [];
const range = IDBKeyRange.bound([chatId, -Infinity], [chatId, Infinity]);
idx.openCursor(range, "prev").onsuccess = (e) => {
- const cursor = e.target.result;
+ const cursor = (e.target as IDBRequest).result;
if (!cursor) return resolve(out);
out.push(cursor.value);
@@ -167,7 +180,7 @@ export async function loadLatestMessages(chatId, limit = 50) {
});
}
-export async function loadOlderMessages(chatId, oldestTimestamp, oldestId, limit = 50) {
+export async function loadOlderMessages(chatId: string, oldestTimestamp: any, oldestId: string, limit: number = 50): Promise {
const db = await openDb();
return new Promise((resolve, reject) => {
@@ -175,7 +188,7 @@ export async function loadOlderMessages(chatId, oldestTimestamp, oldestId, limit
const store = tx.objectStore("messages");
const idx = store.index("cidTimestampId");
- const out = [];
+ const out: Message[] = [];
const parsedTimestamp = isNaN(oldestTimestamp) ? oldestTimestamp : Number(oldestTimestamp);
@@ -187,7 +200,7 @@ export async function loadOlderMessages(chatId, oldestTimestamp, oldestId, limit
);
idx.openCursor(range, "prev").onsuccess = (e) => {
- const cursor = e.target.result;
+ const cursor = (e.target as IDBRequest).result;
if (!cursor) return resolve(out);
out.push(cursor.value);
@@ -200,7 +213,7 @@ export async function loadOlderMessages(chatId, oldestTimestamp, oldestId, limit
});
}
-export async function upsertMedia(reqId, blob, filename) {
+export async function upsertMedia(reqId: string, blob: Blob, filename: string): Promise {
const db = await openDb();
return new Promise((resolve, reject) => {
@@ -218,7 +231,7 @@ export async function upsertMedia(reqId, blob, filename) {
});
}
-export async function loadMedia(reqId) {
+export async function loadMedia(reqId: string): Promise {
const db = await openDb();
return new Promise((resolve, reject) => {
@@ -229,4 +242,8 @@ export async function loadMedia(reqId) {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
+}
+
+export function deleteDatabase() {
+ indexedDB.deleteDatabase(DB_NAME);
}
\ No newline at end of file
diff --git a/web/src/notification.ts b/web/src/notification.ts
new file mode 100644
index 0000000..3b25164
--- /dev/null
+++ b/web/src/notification.ts
@@ -0,0 +1,37 @@
+export async function showNotification(title: string, subtitle: string, time: number = 5000, _hint?: string): Promise {
+ const notificationBox = document.createElement('div');
+ notificationBox.classList.add('notification-box');
+
+ const notificationTitle = document.createElement('h1');
+ notificationTitle.innerHTML = title;
+
+ const notificationSubtitle = document.createElement('p');
+ notificationSubtitle.innerHTML = subtitle;
+
+ notificationBox.appendChild(notificationTitle);
+ if (subtitle) {
+ notificationBox.appendChild(notificationSubtitle);
+ }
+ document.querySelector('body')!.appendChild(notificationBox);
+
+ let clicked = false;
+
+ notificationBox.addEventListener('click', () => {
+ clicked = true;
+ hideNotification(notificationBox);
+ })
+
+ await new Promise(requestAnimationFrame);
+ notificationBox.classList.add("shown");
+
+ await new Promise(r => setTimeout(r, time));
+ if (!clicked) {
+ hideNotification(notificationBox);
+ }
+}
+
+async function hideNotification(notificationBox: HTMLDivElement): Promise {
+ notificationBox.classList.remove('shown');
+ await new Promise(r => setTimeout(r, 1000));
+ notificationBox.remove();
+}
diff --git a/web/src/setup.ts b/web/src/setup.ts
new file mode 100644
index 0000000..5be0007
--- /dev/null
+++ b/web/src/setup.ts
@@ -0,0 +1,57 @@
+import { config } from "./config";
+import { elements, ScrollableView, ui, views } from "./ui";
+import { waha } from "./waha";
+
+const page = views.get(elements.appContainer);
+page?.scrollToIndex(0);
+const statusConnection = document.getElementById("connecting-status");
+const connectingPage = document.getElementById("connecting-page");
+const loading = document.getElementById("connecting-animation");
+const connectingNextBtn = document.getElementById("connecting-next-btn");
+const settingsApiKey = document.getElementById("settings-api-key") as HTMLInputElement;
+const settingsSession = document.getElementById("settings-session") as HTMLInputElement;
+const settingsWahaURL = document.getElementById("settings-waha-url") as HTMLInputElement;
+
+if ('scrollRestoration' in history) {
+ history.scrollRestoration = 'manual';
+}
+
+window.addEventListener('load', () => {
+ window.scrollTo(0, 0);
+ if (localStorage.getItem('setupComplete') == 'true') {
+ window.location.href = 'app.html';
+ }
+});
+
+if (connectingPage) {
+ connectingPage.addEventListener('intoView', () => {
+ testConnection();
+ })
+}
+
+connectingNextBtn?.addEventListener('click', () => {
+ localStorage.setItem('setupComplete', 'true');
+ window.location.href = "app.html";
+})
+
+async function testConnection() {
+ console.log(settingsWahaURL?.value)
+ config.save(settingsWahaURL?.value, settingsSession?.value, settingsApiKey?.value, "", "");
+ if (!statusConnection || !connectingNextBtn || !loading) return;
+ statusConnection.textContent = "Asking for server version...";
+
+ try {
+ const resp = await waha.getMyInfo();
+
+ if (resp.pushName != null) {
+ statusConnection.textContent = `Logged in as ${resp.pushName}`
+ connectingNextBtn.style.display = 'flex';
+ loading.style.display = 'none';
+ } else {
+ page?.previows();
+ }
+ } catch (e) {
+ console.log(e);
+ page?.previows();
+ }
+}
\ No newline at end of file
diff --git a/web/src/states.ts b/web/src/states.ts
new file mode 100644
index 0000000..f707e88
--- /dev/null
+++ b/web/src/states.ts
@@ -0,0 +1,7 @@
+import { Chat } from "./types";
+
+export let activeChatState: Chat | null = null;
+
+export function setActiveChatState(value: Chat | null) {
+ activeChatState = value;
+}
\ No newline at end of file
diff --git a/js/storage.js b/web/src/storage.ts
similarity index 51%
rename from js/storage.js
rename to web/src/storage.ts
index 238b4b9..19977b1 100644
--- a/js/storage.js
+++ b/web/src/storage.ts
@@ -1,10 +1,11 @@
-import { loadChatsSorted, loadLatestMessages, loadMedia, loadOlderMessages, upsertChats, upsertMedia, upsertMessages } from "./db.js";
-import { waha } from "./waha.js";
+import { loadChat, loadChatsSorted, loadLatestMessages, loadMedia, loadOlderMessages, upsertChats, upsertMedia, upsertMessages } from "./db";
+import { waha } from "./waha";
+import type { Chat, Message, AppUser, ContactInfo, UserAboutResponse, ChatPictureResponse, StatusResponse, DownloadedMedia } from "./types";
let online = false;
-let chats = [];
+let chats: Chat[] = [];
-export async function updateOnlineStatus() {
+export async function updateOnlineStatus(): Promise {
try {
await waha.getVersion();
online = true;
@@ -14,17 +15,17 @@ export async function updateOnlineStatus() {
}
}
-export async function fetchChats() {
+export async function fetchChats(): Promise {
if (online) {
await getRemoteChats()
}
chats = await loadChatsSorted();
}
-export async function getRemoteChats() {
+export async function getRemoteChats(): Promise {
const u = await waha.getChats();
- const mapped = u.map(chat => ({
+ const mapped: Chat[] = u.map(chat => ({
id: chat.id,
name: chat.name,
lastMessage: chat.lastMessage,
@@ -35,15 +36,15 @@ export async function getRemoteChats() {
await upsertChats(mapped);
}
-export function getUsers() {
+export function getUsers(): Chat[] {
return chats.filter(c => c.id.endsWith("@c.us"));
}
-export function getGroups() {
+export function getGroups(): Chat[] {
return chats.filter(c => c.id.endsWith("@g.us"));
}
-export async function getUser(number) {
+export async function getUser(number: string): Promise {
if (online) {
return await waha.getUser(number);
} else {
@@ -51,7 +52,7 @@ export async function getUser(number) {
}
}
-export async function getUserAbout(userId) {
+export async function getUserAbout(userId: string): Promise {
if (online) {
return await waha.getUserAbout(userId);
} else {
@@ -59,25 +60,26 @@ export async function getUserAbout(userId) {
}
}
-export function getChats() {
+export function getChats(): Chat[] {
return chats;
}
-export async function getAppUser() {
+export async function getAppUser(): Promise {
if (online) {
const info = await waha.getMyInfo();
- localStorage.setItem('pandora-last-username', info.name);
+ localStorage.setItem('pandora-last-username', info.pushName || info.name || '');
localStorage.setItem('pandora-last-userid', info.id);
return info;
} else {
return {
pushName: localStorage.getItem('pandora-last-username') || 'Unknown',
+ name: localStorage.getItem('pandora-last-username') || 'Unknown',
id: localStorage.getItem('pandora-last-userid') || 'Unknown'
- }
+ } as AppUser;
}
}
-export async function getMessage(chatId, msgId, downloadMedia) {
+export async function getMessage(chatId: string, msgId: string, downloadMedia: boolean): Promise {
if (online) {
const newMessage = await waha.getSingleChatMessage(chatId, msgId, downloadMedia);
upsertMessages([newMessage]);
@@ -88,14 +90,14 @@ export async function getMessage(chatId, msgId, downloadMedia) {
body: "You're offline",
from: "system",
timestamp: new Date().toISOString()
- };
+ } as Message;
}
}
-export async function getMedia(reqId) {
+export async function getMedia(reqId: string): Promise {
const cached = await loadMedia(reqId);
if (cached) {
- return cached;
+ return { blob: cached.blob, filename: cached.filename };
}
try {
@@ -109,7 +111,7 @@ export async function getMedia(reqId) {
}
}
-export async function getChatMessages(chatId) {
+export async function getChatMessages(chatId: string): Promise {
if (online) {
const newMessages = await waha.getChatMessages(chatId);
upsertMessages(newMessages);
@@ -119,7 +121,7 @@ export async function getChatMessages(chatId) {
}
}
-export async function getMoreChatMessages(chatId, oldestTimestamp, oldestId, limit = 50) {
+export async function getMoreChatMessages(chatId: string, oldestTimestamp: any, oldestId: string): Promise {
if (online) {
return waha.getChatMessages(chatId, oldestTimestamp);
} else {
@@ -127,7 +129,7 @@ export async function getMoreChatMessages(chatId, oldestTimestamp, oldestId, lim
}
}
-export async function getChatPicture(chatId) {
+export async function getChatPicture(chatId: string): Promise {
if (online) {
return await waha.getChatPicture(chatId);
} else {
@@ -135,11 +137,11 @@ export async function getChatPicture(chatId) {
}
}
-export function isOnline() {
+export function isOnline(): boolean {
return online;
}
-export async function sendStatus(text) {
+export async function sendStatus(text: string): Promise {
if (online) {
return await waha.setStatus(text);
} else {
@@ -147,4 +149,19 @@ export async function sendStatus(text) {
success: false
}
}
+}
+
+export async function markRead(chatId: string): Promise {
+ if (online) {
+ await waha.readChat(chatId);
+ }
+
+ const chat = await loadChat(chatId);
+ if (chat) {
+ chat.unreadCount = 0;
+ // Note: upsertMessages was called with [chat] in JS, but chat is a Chat object, not Message.
+ // Keeping JS behavior but chat is Chat type here.
+ await upsertChats([chat]);
+ }
+ return chat;
}
\ No newline at end of file
diff --git a/web/src/types.ts b/web/src/types.ts
new file mode 100644
index 0000000..d65614c
--- /dev/null
+++ b/web/src/types.ts
@@ -0,0 +1,124 @@
+/** Represents a chat entry in the sidebar */
+export interface Chat {
+ id: string;
+ name: string;
+ lastMessage: string;
+ timestamp: number | string | Date;
+ unreadCount: number;
+}
+
+/** Raw chat object returned from the WAHA API */
+export interface WahaChat {
+ id: string | { _serialized?: string; user?: string };
+ chatId?: string;
+ name?: string;
+ unreadCount?: number;
+ lastMessage?: {
+ body?: string;
+ timestamp?: number | string;
+ };
+ lastMessageText?: string;
+}
+
+/** Message data sub-object (_data field) */
+export interface MessageData {
+ notifyName?: string;
+ mimetype?: string;
+ [key: string]: unknown;
+}
+
+/** Media attachment info */
+export interface MediaInfo {
+ url: string;
+ filename?: string;
+}
+
+/** A WhatsApp message */
+export interface Message {
+ _data?: MessageData;
+ _serialized?: string | { _serialized?: string; user?: string };
+ id: string | { _serialized?: string; user?: string };
+ body?: string;
+ text?: string;
+ from?: string;
+ to?: string;
+ fromMe?: boolean;
+ sender?: string;
+ timestamp: number | string;
+ status?: string;
+ ack?: number;
+ hasMedia?: boolean;
+ media?: MediaInfo;
+ chatId?: string;
+ chat?: { id: string };
+ participant?: string;
+}
+
+/** Temporary message used for optimistic UI updates */
+export interface TempMessage extends Message {
+ status: string;
+}
+
+/** File-sending temporary message */
+export interface TempFileMessage extends TempMessage {
+ _data: MessageData;
+ hasMedia: true;
+ media: MediaInfo;
+}
+
+/** WAHA version response */
+export interface VersionResponse {
+ version?: string;
+}
+
+/** WAHA user info (getMyInfo) */
+export interface AppUser {
+ id: string;
+ pushName?: string;
+ name?: string;
+}
+
+/** WAHA contact info */
+export interface ContactInfo {
+ number: string;
+ [key: string]: unknown;
+}
+
+/** WAHA user about response */
+export interface UserAboutResponse {
+ about?: string;
+}
+
+/** Chat picture response */
+export interface ChatPictureResponse {
+ url?: string;
+}
+
+/** Status update response */
+export interface StatusResponse {
+ success?: boolean;
+}
+
+/** WebSocket event payload envelope */
+export interface WebSocketEvent {
+ event: string;
+ payload: Message;
+}
+
+/** IndexedDB stored media record */
+export interface StoredMedia {
+ reqId: string;
+ blob: Blob;
+ filename: string;
+}
+
+/** Downloaded media result */
+export interface DownloadedMedia {
+ blob: Blob;
+ filename: string;
+}
+
+/** Message with _time field used during ordering compensation */
+export interface MessageWithTime extends Message {
+ _time: number;
+}
diff --git a/web/src/ui.ts b/web/src/ui.ts
new file mode 100644
index 0000000..13f14bf
--- /dev/null
+++ b/web/src/ui.ts
@@ -0,0 +1,553 @@
+import { formatTime, normalizeId } from "./utils";
+import { getChatPicture, getMessage, getMedia, getMoreChatMessages } from "./storage";
+import type { Chat, Message } from "./types";
+import { activeChatState } from "./states";
+
+export const elements = {
+ chatList: document.getElementById('chat-list') as HTMLUListElement,
+ chatsLoader: document.getElementById('chats-loader') as HTMLDivElement,
+ chatSearch: document.getElementById('chat-search') as HTMLInputElement,
+ backendStatusText: document.getElementById('backend-status-text') as HTMLSpanElement,
+ apiStatusIndicator: document.querySelector('.pulse-dot') as HTMLSpanElement,
+ noChatState: document.getElementById('no-chat-state') as HTMLDivElement,
+ activeChatContainer: document.getElementById('active-chat-container') as HTMLDivElement,
+ activeChatName: document.getElementById('active-chat-name') as HTMLHeadingElement,
+ activeChatAvatar: document.getElementById('active-chat-avatar') as HTMLDivElement,
+ messagesContainer: document.getElementById('messages-container') as HTMLDivElement,
+ messageForm: document.getElementById('message-form') as HTMLFormElement,
+ messageInput: document.getElementById('message-input') as HTMLInputElement,
+ backToSidebarBtn: document.querySelector('.chat-header') as HTMLElement,
+ sidebar: document.querySelector('.sidebar') as HTMLElement,
+ appContainer: document.querySelector('.app-container') as HTMLElement,
+ settingsModal: document.getElementById('settings-page') as HTMLElement,
+ settingsIconBtn: document.getElementById('settings-sidebar-btn') as HTMLButtonElement,
+ saveSettingsBtn: document.getElementById('save-settings') as HTMLButtonElement,
+ purgeDatabaseButton: document.getElementById('purge-database') as HTMLButtonElement,
+ inputWahaUrl: document.getElementById('settings-waha-url') as HTMLInputElement,
+ inputSession: document.getElementById('settings-session') as HTMLInputElement,
+ inputApiKey: document.getElementById('settings-api-key') as HTMLInputElement,
+ inputBackgroundImage: document.getElementById('settings-background-image') as HTMLInputElement,
+ inputBackgroundOpacity: document.getElementById('settings-background-opacity') as HTMLInputElement,
+ loggedUserName: document.getElementById('pandora-username') as HTMLHeadingElement,
+ chatBottomBar: document.getElementById('chat-bottom-bar') as HTMLElement,
+ chatBottomBarBtn: document.getElementById('chat-bottom-bar-btn') as HTMLButtonElement,
+ chatInputPanel: document.getElementById('chat-input-panel') as HTMLElement,
+ attachmentInput: document.getElementById('attachment-input') as HTMLInputElement,
+ attachmentBtn: document.getElementById('attachment-btn') as HTMLButtonElement,
+ markreadBtn: document.getElementById('markread-btn') as HTMLButtonElement,
+ extraPages: document.querySelectorAll('.extra-page') as NodeListOf,
+ desktopSidebarButtons: document.querySelectorAll("#desktop-aside button") as NodeListOf,
+ desktopAside: document.getElementById('desktop-aside') as HTMLElement,
+ contentUserName: document.querySelectorAll('[data-content="app-user"]') as NodeListOf,
+ contentUserNumber: document.querySelectorAll('[data-content="app-user-number"]') as NodeListOf,
+ resourceUserPic: document.querySelectorAll('[data-resource="app-user-image"]') as NodeListOf,
+ valueUserStatus: document.querySelectorAll('[data-value="app-user-status"]') as NodeListOf,
+ inputUserStatus: document.getElementById('profile-page-status-input') as HTMLInputElement,
+ selectable: document.querySelectorAll('.selectable') as NodeListOf,
+ nextPageButtons: document.querySelectorAll('.next-page-btn') as NodeListOf,
+ scrollableViews: document.querySelectorAll('._scrollableView') as NodeListOf
+};
+
+export const views = new Map;
+
+export const ui = {
+ /**
+ * Switch view state when a contact chat is opened or closed
+ */
+ toggleChatState(hasActive: boolean) {
+ if (hasActive) {
+ elements.noChatState.classList.add('hidden');
+ elements.activeChatContainer.classList.remove('hidden');
+ } else {
+ elements.noChatState.classList.remove('hidden');
+ elements.activeChatContainer.classList.add('hidden');
+ }
+ },
+
+ /**
+ * Scroll message list automatically to bottom
+ */
+ scrollToBottom() {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ elements.messagesContainer.scrollTop = elements.messagesContainer.scrollHeight;
+ });
+ });
+ },
+
+ ensureScroll(container: HTMLElement, after: Function) {
+ const scrolled = container.scrollTop === (container.scrollHeight - container.clientHeight);
+ after();
+ if (scrolled) {
+ container.scrollTop = container.scrollHeight;
+ }
+ },
+
+ /**
+ * Update connection status badge in sidebar footer
+ */
+ updateConnectionStatus(isConnected: boolean, text: string) {
+ elements.backendStatusText.textContent = text;
+ if (isConnected) {
+ elements.apiStatusIndicator.style.backgroundColor = 'var(--online-color)';
+ elements.apiStatusIndicator.style.animation = 'pulse 1.8s infinite';
+ } else {
+ elements.apiStatusIndicator.style.backgroundColor = '#ef4444';
+ elements.apiStatusIndicator.style.animation = 'none';
+ }
+ },
+
+ async renderChatList(chats: Chat[], activeChat: Chat | null, onChatSelect: (chat: Chat) => void) {
+ elements.chatList.innerHTML = '';
+ chats.sort((a, b) => {
+ const timeA = new Date(a.timestamp).getTime();
+ const timeB = new Date(b.timestamp).getTime();
+ return timeB - timeA;
+ });
+
+ if (chats.length === 0) {
+ elements.chatList.innerHTML = `No chats found `;
+ return;
+ }
+
+ for (const chat of chats) {
+ if (chat.timestamp == null) return;
+ const li = document.createElement('li');
+ li.className = `chat-item selectable ${activeChat && activeChat.id === chat.id ? 'active' : ''}`;
+ li.dataset.id = chat.id;
+
+ const initials = chat.name ? chat.name.substring(0, 1).toUpperCase() : '?';
+ const hasUnread = chat.unreadCount && chat.unreadCount > 0;
+ const timeStr = formatTime(chat.timestamp || new Date());
+
+ li.innerHTML = `
+
+
+
+
+
+ ${chat.name}
+ ${timeStr}
+
+
+
+ ${chat.lastMessage || '...'}
+
+ ${hasUnread ? `${chat.unreadCount} ` : ''}
+
+
+ `;
+
+ li.addEventListener('click', () => onChatSelect(chat));
+ elements.chatList.appendChild(li);
+
+ (async () => {
+ try {
+ const picture = await getChatPicture(chat.id);
+ const img = li.querySelector(`img[data-chat-avatar="${chat.id}"]`) as HTMLImageElement;
+ if (img) img.src = picture.url ? picture.url : '';
+ } catch (e) {
+ }
+ })();
+ }
+ },
+
+ async updateChatInChatList(msg: Message) {
+ const chatNode = document.querySelector(`.chat-item[data-id="${msg.fromMe ? msg.to : msg.from}"]`) as HTMLElement;
+
+ if (chatNode) {
+ const messageItem = chatNode.querySelector('.chat-item-msg') as HTMLElement;
+ const time = chatNode.querySelector('.chat-item-time') as HTMLElement;
+ messageItem.innerText = msg.body || msg.text || 'Media message';
+ time.innerText = msg.timestamp ? formatTime(msg.timestamp) : formatTime(Date.now());
+
+ if (!msg.fromMe && (!activeChatState || activeChatState.id !== (msg.fromMe ? msg.to : msg.from))) {
+ const unreadBadge = chatNode.querySelector('.unread-badge') as HTMLElement;
+ if (unreadBadge) {
+ unreadBadge.innerText = (Number(unreadBadge.innerHTML) || 0) + 1 + "";
+ } else {
+ const preview = chatNode.querySelector('.chat-item-preview') as HTMLElement;
+ const badge = document.createElement('span');
+ badge.className = 'unread-badge';
+ badge.innerText = '1';
+ preview.appendChild(badge);
+ }
+ }
+ }
+ },
+
+ async updateChatInChatList2(chat: Chat) {
+ const chatNode = document.querySelector(`.chat-item[data-id="${chat.id}"]`) as HTMLElement;
+
+ if (chatNode) {
+ const messageItem = chatNode.querySelector('.chat-item-msg') as HTMLElement;
+ const time = chatNode.querySelector('.chat-item-time') as HTMLElement;
+ messageItem.innerText = chat.lastMessage || 'Media message';
+ time.innerText = chat.timestamp ? formatTime(chat.timestamp) : formatTime(Date.now());
+ const unreadBadge = chatNode.querySelector('.unread-badge') as HTMLElement;
+ const newCount = (Number(unreadBadge?.innerHTML) || 0);
+ if (unreadBadge && newCount != 0) {
+ unreadBadge.innerText = newCount + "";
+ } else {
+ if (unreadBadge) unreadBadge.remove();
+ }
+ }
+ },
+
+ /**
+ * Render chat message log inside chat view container
+ */
+ async renderMessages(messages: Message[], _activeChatName: string, userID: string, chatId: string) {
+ elements.messagesContainer.innerHTML = '';
+
+ if (messages.length === 0) {
+ elements.messagesContainer.innerHTML = 'No messages. Say hello!
';
+ return;
+ }
+
+ const loadMore = document.createElement("button");
+ loadMore.classList.add("load-more-btn");
+ loadMore.innerText = "Load more";
+ loadMore.onclick = () => {
+ this.loadMoreMessages(chatId, userID);
+ };
+ elements.messagesContainer.appendChild(loadMore);
+
+ for (const msg of messages) {
+ this.appendSingleMessage(msg, userID, chatId);
+ }
+
+ this.scrollToBottom();
+ },
+
+ async loadMoreMessages(chatId: string, userId: string) {
+ const oldest = document.querySelector('.message-group:first-of-type') as HTMLElement;
+ if (!oldest) return;
+ const oldestTimestamp = oldest.dataset.timestamp;
+ const oldestId = oldest.id;
+
+ const loadMoreButton = document.querySelector('.load-more-btn') as HTMLButtonElement;
+
+ const msgs = await getMoreChatMessages(chatId, oldestTimestamp, oldestId);
+ // JS version had msgs.shift(), probably to avoid duplication of the oldest message
+ msgs.shift();
+
+ msgs.forEach(async msg => {
+ loadMoreButton.after(this.generateMessage(msg, userId, chatId));
+ });
+ },
+
+ /**
+ * Append a single message (used for optimistic updates immediately upon sending)
+ */
+ appendSingleMessage(msg: Message, userID: string, chatId: string, isLocal: boolean = false) {
+ elements.messagesContainer.appendChild(this.generateMessage(msg, userID, chatId, isLocal))
+ },
+
+ generateTempMessageLink(msg: Message) {
+ const a = document.createElement('a');
+ a.target = "_blank";
+ if (msg.media) {
+ a.href = msg.media.url;
+
+ if (msg._data?.mimetype?.startsWith('image/')) {
+ const img = document.createElement('img');
+ img.classList.add('message-image-attachement');
+ img.src = msg.media.url;
+ a.appendChild(img);
+ } else {
+ a.textContent = msg.media.filename || "Download file";
+ a.download = msg.media.filename || "file";
+ }
+ }
+
+ return a;
+ },
+
+ generateMessage(msg: Message, userID: string, chatId: string, isLocal: boolean = false) {
+ const isOutgoing = msg.fromMe || msg.sender === 'me';
+
+ function getPrevMessageElem() {
+ return elements.messagesContainer.lastElementChild as HTMLElement | null;
+ }
+
+ const prevMsgEl = getPrevMessageElem();
+
+ const groupDiv = document.createElement('div');
+ groupDiv.className = `message-group selectable ${isOutgoing ? 'outgoing' : 'incoming'}`;
+ groupDiv.id = normalizeId(msg._serialized ? (msg._serialized as any) : msg.id) || "msg-id";
+ groupDiv.dataset.timestamp = msg.timestamp?.toString();
+ groupDiv.dataset.from = msg.participant || (msg.from as string);
+
+ const senderName = isOutgoing ? userID : (msg._data?.notifyName || (msg.from as string));
+ const timeStr = formatTime(msg.timestamp || new Date());
+
+ let statusCheck = '';
+ if (isOutgoing) {
+ if (msg.status === 'read') {
+ statusCheck = ' ';
+ } else if (msg.status === 'delivered') {
+ statusCheck = ' ';
+ } else if (msg.status === 'sending') {
+ statusCheck = ' ';
+ } else {
+ statusCheck = ' ';
+ }
+ }
+
+ const bubble = document.createElement('div');
+ bubble.className = 'message-bubble';
+
+ let prevUid: string | undefined;
+
+ if (msg.participant) {
+ prevUid = msg.participant;
+ } else {
+ prevUid = msg.from as string;
+ }
+
+ if (!isOutgoing && (!prevMsgEl || prevUid !== prevMsgEl.dataset.from)) {
+ const senderEl = document.createElement('span');
+ senderEl.className = 'message-sender';
+ senderEl.textContent = senderName;
+ bubble.appendChild(senderEl);
+ }
+
+ const contentEl = document.createElement('div');
+ contentEl.classList.add('message-content');
+ const textEl = document.createElement('div');
+ textEl.innerHTML = msg.body || msg.text || "";
+ contentEl.appendChild(textEl);
+ bubble.appendChild(contentEl);
+
+ if (msg.hasMedia) {
+ let a: HTMLAnchorElement;
+
+ if (isLocal) {
+ a = this.generateTempMessageLink(msg);
+ } else {
+ a = document.createElement('a');
+ a.innerText = `[Request media]`;
+ a.target = "_blank";
+
+ const clickListener = async (e: MouseEvent) => {
+ a.removeEventListener('click', clickListener);
+ a.innerText = `[Downloading]`;
+ const mediaMsg = msg.media ? msg : await getMessage(chatId, normalizeId(msg._serialized ? (msg._serialized as any) : msg.id) || "", true);
+ if (!mediaMsg || !mediaMsg?.media?.url) {
+ a.addEventListener('click', clickListener);
+ a.innerText = `[Error, click to try again]`
+ return;
+ }
+ const url = new URL(mediaMsg.media.url);
+ const reqID = url.pathname.split('/').filter(Boolean).pop();
+
+ if (!reqID) return;
+ const media = await getMedia(reqID);
+ if (!media) return;
+
+ const objectUrl = URL.createObjectURL(media.blob);
+ (e.target as HTMLAnchorElement).href = objectUrl;
+
+ if (media.blob.type.startsWith('image/')) {
+ a.textContent = "";
+ const img = document.createElement('img');
+ img.classList.add('message-image-attachement');
+ img.src = objectUrl;
+ this.ensureScroll(elements.messagesContainer, () => {
+ bubble.before(img);
+ })
+ // const content = bubble.querySelector('.message-content');
+ // if (content) content.remove(); // TODO make images attachment with text look cooler
+ } else {
+ (e.target as HTMLAnchorElement).textContent = media.filename || `Download ${mediaMsg.media.filename}`;
+ }
+ }
+
+ a.addEventListener('click', clickListener);
+ }
+
+ this.ensureScroll(elements.messagesContainer, () => {
+ contentEl.appendChild(a);
+ });
+
+ if (!isLocal && msg._data?.mimetype?.startsWith('image/')) {
+ a.click();
+ }
+ }
+
+
+ const meta = document.createElement('div');
+ meta.className = 'message-meta';
+ meta.innerHTML = `${timeStr} ${statusCheck}`;
+
+ bubble.appendChild(meta);
+
+ if (isOutgoing) {
+ if (prevMsgEl && prevMsgEl.classList.contains('outgoing')) {
+ groupDiv.classList.add('same-sender');
+ } else {
+ const indicator = document.createElement('div');
+ indicator.className = 'message-indicator';
+ groupDiv.appendChild(indicator);
+ }
+ } else if (msg.participant) {
+ if (!prevMsgEl || prevUid !== prevMsgEl.dataset.from) {
+ const indicator = document.createElement('div');
+ indicator.className = 'message-indicator';
+ groupDiv.appendChild(indicator);
+ } else groupDiv.classList.add('same-sender');
+ } else {
+ if (!prevMsgEl || prevUid !== prevMsgEl.dataset.from) {
+ const indicator = document.createElement('div');
+ indicator.className = 'message-indicator';
+ groupDiv.appendChild(indicator);
+ } else groupDiv.classList.add('same-sender');
+ }
+
+ groupDiv.appendChild(bubble);
+ return groupDiv;
+ },
+
+ updateMessage(originalMsgId: string, generatedMsg: HTMLElement) {
+ const originalMsg = document.querySelector(`#${originalMsgId}`);
+ if (originalMsg) {
+ originalMsg.replaceWith(generatedMsg)
+ }
+ },
+
+ updateMessageTick(id: string, status: string) {
+ let statusCheck;
+ if (status === 'read') {
+ statusCheck = ' ';
+ } else if (status === 'delivered') {
+ statusCheck = ' ';
+ } else if (status === 'sending') {
+ statusCheck = ' ';
+ } else {
+ statusCheck = ' ';
+ }
+
+ const msgNode = document.getElementById(id);
+ if (msgNode) {
+ const meta = msgNode.querySelector('.message-meta');
+ if (meta) meta.outerHTML = statusCheck;
+ }
+ },
+
+ toggleChatBottomBar() {
+ elements.chatBottomBar.classList.toggle("collapsed");
+ },
+
+ removeChatMessage(msgId: string) {
+ const message = document.getElementById(msgId);
+ if (message) message.remove();
+ },
+};
+
+export class ScrollableView {
+ private observer: IntersectionObserver | null = null;
+ private activePage: HTMLElement | null = null;
+ container: HTMLElement;
+ isScrollingProgrammatically = false;
+ scrollTimeout: ReturnType | null = null;
+
+ constructor(container: HTMLElement) {
+ this.container = container;
+ this.setupIntersectionObserver();
+
+ window.addEventListener('scroll', (e) => {
+ if (this.isScrollingProgrammatically) e.preventDefault();
+ });
+
+ container.querySelectorAll('.next-page-btn').forEach(nextBtn => {
+ nextBtn.addEventListener('click', () => {
+ this.next();
+ })
+ })
+ }
+
+ private setupIntersectionObserver() {
+ this.observer = new IntersectionObserver((entries) => {
+ entries.forEach(entry => {
+ if (entry.isIntersecting && entry.target !== this.activePage) {
+ this.activePage = entry.target as HTMLElement;
+
+ entry.target.dispatchEvent(new CustomEvent('intoView', {
+ bubbles: true,
+ detail: {
+ page: entry.target,
+ index: this.getCurrentIndex()
+ }
+ }));
+ }
+ });
+ }, {
+ root: this.container,
+ threshold: 0.6
+ });
+
+ this.observePages();
+ }
+
+ observePages() {
+ if (!this.observer) return;
+ Array.from(this.container.children).forEach(child => {
+ this.observer?.observe(child);
+ });
+ }
+
+ get pages(): HTMLElement[] {
+ return Array.from(this.container.children) as HTMLElement[];
+ }
+
+ getCurrentIndex(): number {
+ const width = this.container.clientWidth;
+ if (width === 0) return 0;
+ return Math.round(this.container.scrollLeft / width);
+ }
+
+ getCurrentScreen(): HTMLElement | null {
+ const pages = this.pages;
+ return pages[this.getCurrentIndex()] || null;
+ }
+
+ scrollToIndex(index: number, smooth = true) {
+ const pages = this.pages;
+ if (index >= 0 && index < pages.length) {
+ this.scrollTo(pages[index], smooth);
+ }
+ }
+
+ next(smooth = true) {
+ this.scrollToIndex(this.getCurrentIndex() + 1, smooth);
+ }
+
+ previows(smooth = true) {
+ this.scrollToIndex(this.getCurrentIndex() - 1, smooth);
+ }
+
+ scrollTo(element: HTMLElement, smooth = true) {
+ this.isScrollingProgrammatically = true;
+ element.scrollIntoView({
+ behavior: smooth ? 'smooth' : 'auto',
+ inline: 'start',
+ block: 'nearest'
+ });
+ setTimeout(() => { this.isScrollingProgrammatically = false; }, smooth ? 400 : 50);
+ }
+
+ getCurrentExtraPage(): number {
+ const pages = Array.from(elements.extraPages);
+ const activeIndex = pages.findIndex(page => page.classList.contains("shown"));
+ return activeIndex !== -1 ? activeIndex : 0;
+ }
+}
+
+elements.scrollableViews.forEach(view => {
+ views.set(view, new ScrollableView(view));
+})
\ No newline at end of file
diff --git a/js/utils.js b/web/src/utils.ts
similarity index 69%
rename from js/utils.js
rename to web/src/utils.ts
index 81f05f0..261f1bf 100644
--- a/js/utils.js
+++ b/web/src/utils.ts
@@ -1,13 +1,13 @@
-// Helper utilities and algorithms
+import type { Message, MessageWithTime } from "./types";
/**
* Format timestamps (supports Unix epoch seconds/ms, strings and ISO dates)
* @param {string|number|Date} dateVal
* @returns {string} Formatted HH:MM AM/PM string
*/
-export function formatTime(dateVal) {
+export function formatTime(dateVal: string | number | Date): string {
if (!dateVal) return '';
- let date;
+ let date: Date;
if (typeof dateVal === 'number') {
date = new Date(dateVal < 10000000000 ? dateVal * 1000 : dateVal);
} else if (typeof dateVal === 'string' && /^\d+$/.test(dateVal)) {
@@ -18,12 +18,12 @@ export function formatTime(dateVal) {
}
let hours = date.getHours();
- let minutes = date.getMinutes();
+ const minutes = date.getMinutes();
const ampm = hours >= 12 ? 'PM' : 'AM';
hours = hours % 12;
hours = hours ? hours : 12;
- minutes = minutes < 10 ? '0' + minutes : minutes;
- return `${hours}:${minutes} ${ampm}`;
+ const minutesStr = minutes < 10 ? '0' + minutes : String(minutes);
+ return `${hours}:${minutesStr} ${ampm}`;
}
/**
@@ -32,24 +32,22 @@ export function formatTime(dateVal) {
* @param {Array} messages List of raw messages from WAHA
* @returns {Array} Compensated chronological message array
*/
-export function compensateMessageOrdering(messages) {
+export function compensateMessageOrdering(messages: Message[]): Message[] {
if (!Array.isArray(messages)) return [];
- // Convert timestamps to numeric milliseconds for stable comparison
- const msgs = messages.map(m => {
- let t = m.timestamp;
- if (typeof t === 'number') {
- if (t < 10000000000) t = t * 1000;
+ // convert to ms
+ const msgs: MessageWithTime[] = messages.map(m => {
+ let t: number;
+ if (typeof m.timestamp === 'number') {
+ t = m.timestamp < 10000000000 ? m.timestamp * 1000 : m.timestamp;
} else {
- t = new Date(t).getTime();
+ t = new Date(m.timestamp).getTime();
}
return { ...m, _time: t };
});
- // Initial chronological sort
msgs.sort((a, b) => a._time - b._time);
- // Apply drift bubble adjustments
let changed = true;
while (changed) {
changed = false;
@@ -58,12 +56,12 @@ export function compensateMessageOrdering(messages) {
const next = msgs[i + 1];
const timeDiff = next._time - current._time;
- // Swap if an outgoing message is sorted before an incoming message within a 30-sec window
+ // swap if outgoing message is sorted before incoming message within 30-sec window
if (current.fromMe && !next.fromMe && timeDiff >= 0 && timeDiff <= 30000) {
msgs[i] = next;
msgs[i + 1] = current;
- // Advance the outgoing message's timestamp to be exactly 1 second after the incoming one
+ // advance the outgoing message timestamp to exactly 1 sec after the incoming
current._time = next._time + 1000;
if (typeof current.timestamp === 'number') {
current.timestamp = Math.floor(current._time / 1000);
@@ -76,16 +74,16 @@ export function compensateMessageOrdering(messages) {
}
}
- // Clean up temporary property
+ // clean temp property
return msgs.map(({ _time, ...m }) => m);
}
-export function getBase64(file) {
+export function getBase64(file: File): Promise {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
- resolve(reader.result.split(",")[1]);
+ resolve((reader.result as string).split(",")[1]);
};
reader.onerror = (e) => {
@@ -106,7 +104,7 @@ export function getBase64(file) {
* @param {string|object} raw
* @returns {string|null}
*/
-export function normalizeId(raw) {
+export function normalizeId(raw: string | { _serialized?: string; user?: string } | null | undefined): string | null {
if (!raw) return null;
if (typeof raw === 'object') {
return raw._serialized || raw.user || JSON.stringify(raw);
@@ -114,10 +112,10 @@ export function normalizeId(raw) {
return raw;
}
-export function debounce(func, delay) {
- let timeoutId;
+export function debounce(func: () => void, delay: number): () => void {
+ let timeoutId: ReturnType;
return function() {
clearTimeout(timeoutId);
timeoutId = setTimeout(func, delay);
};
-}
\ No newline at end of file
+}
diff --git a/js/waha.js b/web/src/waha.ts
similarity index 53%
rename from js/waha.js
rename to web/src/waha.ts
index 2c5c077..353c973 100644
--- a/js/waha.js
+++ b/web/src/waha.ts
@@ -1,19 +1,19 @@
-import { config } from "./config.js";
-import { getBase64 } from "./utils.js";
+import { config } from "./config";
+import { showNotification } from "./notification";
+import { getBase64 } from "./utils";
+import type { Message, VersionResponse, AppUser, ContactInfo, UserAboutResponse, ChatPictureResponse, StatusResponse } from "./types";
-async function request(path, options = {}) {
+async function request(path: string, options: RequestInit = {}): Promise {
const url = `${config.wahaUrl}${path}`;
- const headers = {
+ const headers: Record = {
'Content-Type': 'application/json',
'accept': '*/*',
- ...options.headers
+ ...(options.headers as Record)
};
if (config.apiKey) {
headers['X-Api-Key'] = config.apiKey;
}
- // console.log(`[WAHA] ${options.method || 'GET'} ${url}`, options.body ? JSON.parse(options.body) : '');
-
const response = await fetch(url, { ...options, headers });
if (!response.ok) {
let errorDetail = '';
@@ -24,24 +24,24 @@ async function request(path, options = {}) {
} catch (_) {
errorDetail = await response.text().catch(() => '');
}
+
+ showNotification("API Error", `WAHA API returned ${response.status}: ${response.statusText} — ${errorDetail}`, 4000);
throw new Error(`WAHA API returned ${response.status}: ${response.statusText} — ${errorDetail}`);
}
return response.json();
}
-async function downloadFile(path, options = {}) {
+async function downloadFile(path: string, options: RequestInit = {}): Promise<{ blob: Blob, filename: string }> {
const url = `${config.wahaUrl}${path}`;
- const headers = {
- 'Content-Type': options.headers?.['Content-Type'] ?? 'application/json',
+ const headers: Record = {
+ 'Content-Type': (options.headers as Record | undefined)?.['Content-Type'] ?? 'application/json',
'accept': '*/*',
- ...options.headers
+ ...(options.headers as Record)
};
if (config.apiKey) headers['X-Api-Key'] = config.apiKey;
-// console.log(`[WAHA] ${options.method || 'GET'} ${url}`);
-
const response = await fetch(url, { ...options, headers });
if (!response.ok) {
@@ -66,82 +66,80 @@ async function downloadFile(path, options = {}) {
return { blob, filename };
}
-
export const waha = {
- async getVersion() {
- return await request('/api/version');
+ async getVersion(): Promise {
+ return await request('/api/version');
},
- async getChats() {
- const data = await request(`/api/${config.session}/chats`);
+ async getChats(): Promise {
+ const data = await request(`/api/${config.session}/chats`);
return data.map(chat => {
let chatId = chat.id;
if (chatId && typeof chatId === "object") {
chatId = chatId._serialized || chatId.user || JSON.stringify(chatId);
}
return {
- id: chatId || chat.chatId || chat.name,
- name: chat.name || "Unknown Contact",
+ id: chatId || chat.chatId || null,
+ name: chat.name || null,
unreadCount: chat.unreadCount || 0,
- lastMessage: chat.lastMessage?.body || chat.lastMessageText || "Click to open chat",
- timestamp: chat.lastMessage?.timestamp || new Date()
+ lastMessage: chat.lastMessage?.body || chat.lastMessageText || null,
+ timestamp: chat.lastMessage?.timestamp || null
};
});
},
- async getChatMessages(chatId, beforeTimestamp) {
- console.log(`/api/${config.session}/chats/${chatId}/messages?downloadMedia=false&limit=40&sortBy=timestamp${beforeTimestamp ? `&filter.timestamp.gte=${beforeTimestamp}` : "" }`);
- return request(`/api/${config.session}/chats/${chatId}/messages?downloadMedia=false&limit=40${beforeTimestamp ? `&filter.timestamp.lte=${beforeTimestamp}` : "" }`);
+ async getChatMessages(chatId: string, beforeTimestamp?: any): Promise {
+ return request(`/api/${config.session}/chats/${chatId}/messages?downloadMedia=false&limit=40${beforeTimestamp ? `&filter.timestamp.lte=${beforeTimestamp}` : "" }`);
},
- async getSingleChatMessage(chatId, messageId, downladMedia) {
- return request(`/api/${config.session}/chats/${chatId}/messages/${messageId}?downloadMedia=${downladMedia}`);
+ async getSingleChatMessage(chatId: string, messageId: string, downloadMedia: boolean): Promise {
+ return request(`/api/${config.session}/chats/${chatId}/messages/${messageId}?downloadMedia=${downloadMedia}`);
},
- async getChatPicture(chatId) {
- return request(`/api/${config.session}/chats/${chatId}/picture`);
+ async getChatPicture(chatId: string): Promise {
+ return request(`/api/${config.session}/chats/${chatId}/picture`);
},
- async getUser(chatId) {
- return request(`/api/${config.session}/contacts/${chatId}`);
+ async getUser(chatId: string): Promise {
+ return request(`/api/${config.session}/contacts/${chatId}`);
},
- async getUserAbout(chatId) {
- return request(`/api/contacts/about?contactId=${chatId}&session=${config.session}`);
+ async getUserAbout(chatId: string): Promise {
+ return request(`/api/contacts/about?contactId=${chatId}&session=${config.session}`);
},
- async readChat(chatId) {
+ async readChat(chatId: string): Promise {
return request('/api/sendSeen', {
method: 'POST',
body: JSON.stringify({ chatId, session: config.session })
});
},
- async downloadMedia(file) {
+ async downloadMedia(file: string): Promise<{ blob: Blob, filename: string }> {
const { blob, filename } = await downloadFile(`/api/files/${config.session}/${file}`);
return { blob, filename };
},
- async getMyInfo() {
- return request(`/api/sessions/${config.session}/me`);
+ async getMyInfo(): Promise {
+ return request(`/api/sessions/${config.session}/me`);
},
- async startTyping(chatId) {
+ async startTyping(chatId: string): Promise {
return request('/api/startTyping', {
method: 'POST',
body: JSON.stringify({ chatId, session: config.session })
});
},
- async stopTyping(chatId) {
+ async stopTyping(chatId: string): Promise {
return request('/api/stopTyping', {
method: 'POST',
body: JSON.stringify({ chatId, session: config.session })
});
},
- async sendTextMessage(chatId, text) {
- return request('/api/sendText', {
+ async sendTextMessage(chatId: string, text: string): Promise {
+ return request('/api/sendText', {
method: 'POST',
body: JSON.stringify({
chatId,
@@ -151,8 +149,8 @@ export const waha = {
});
},
- async setStatus(text) {
- return request(`/api/${config.session}/profile/status`, {
+ async setStatus(text: string): Promise {
+ return request(`/api/${config.session}/profile/status`, {
method: 'PUT',
body: JSON.stringify({
status: text
@@ -160,9 +158,9 @@ export const waha = {
});
},
- async sendFileMessage(chatId, file) {
+ async sendFileMessage(chatId: string, file: File): Promise {
const fileBase64 = await getBase64(file);
- const body = {
+ const body: RequestInit = {
method: 'POST',
body: JSON.stringify({
chatId,
@@ -180,7 +178,7 @@ export const waha = {
if (file.type.startsWith('image/')) endpoint = '/api/sendImage';
if (file.type.startsWith('video/')) endpoint = '/api/sendVideo';
- const result = await request(endpoint, body);
+ const result = await request(endpoint, body);
return result;
}
};
diff --git a/js/websocket.js b/web/src/websocket.ts
similarity index 78%
rename from js/websocket.js
rename to web/src/websocket.ts
index c1c0aa2..dd6e86f 100644
--- a/js/websocket.js
+++ b/web/src/websocket.ts
@@ -1,13 +1,14 @@
-import { config } from "./config.js";
-import { isOnline, updateOnlineStatus } from "./storage.js";
+import { config } from "./config";
+import { isOnline, updateOnlineStatus } from "./storage";
+import type { WebSocketEvent } from "./types";
-let socket = null;
-let reconnectTimer = null;
-let currentOnMessageCallback = null;
+let socket: WebSocket | null = null;
+let reconnectTimer: ReturnType | null = null;
+let currentOnMessageCallback: ((data: WebSocketEvent) => void) | null = null;
export const websocket = {
- connect(onMessageCallback) {
- if (!isOnline) return;
+ connect(onMessageCallback: (data: WebSocketEvent) => void) {
+ if (!isOnline()) return;
currentOnMessageCallback = onMessageCallback;
this.disconnect(false);
@@ -48,7 +49,7 @@ export const websocket = {
socket.onmessage = (event) => {
try {
- const data = JSON.parse(event.data);
+ const data = JSON.parse(event.data) as WebSocketEvent;
if (currentOnMessageCallback) {
currentOnMessageCallback(data);
}
@@ -64,10 +65,13 @@ export const websocket = {
socket.onclose = (event) => {
console.log(`[WS] Connection closed (code: ${event.code}). Reconnecting in 5 seconds...`);
socket = null;
-
+
reconnectTimer = setTimeout(() => {
- updateOnlineStatus();
- this.connect(currentOnMessageCallback);
+ updateOnlineStatus().then(() => {
+ if (currentOnMessageCallback) {
+ this.connect(currentOnMessageCallback);
+ }
+ });
}, 5000);
};
} catch (e) {
diff --git a/style.css b/web/style.css
similarity index 92%
rename from style.css
rename to web/style.css
index ddc4e0b..387b52a 100644
--- a/style.css
+++ b/web/style.css
@@ -93,9 +93,6 @@ html, body {
body {
background-color: var(--bg-main);
color: var(--text-primary);
- background-image:
- radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.12) 0px, transparent 50%),
- radial-gradient(at 100% 100%, rgba(168, 85, 247, 0.12) 0px, transparent 50%);
}
a {
@@ -129,16 +126,44 @@ input:focus {
.app-container {
display: flex;
+ flex-direction: row;
+ width: 100%;
+ height: 100dvh;
+ overflow-x: auto;
+ overflow-y: hidden;
+ scroll-snap-type: x mandatory;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+
+.main-holder {
+ display: flex;
+}
+
+._scrollableView, ._scrollableContainer {
+ display: flex;
+ flex-direction: row;
width: 100%;
height: 100%;
- overflow: hidden;
- position: relative;
+ overflow-x: auto;
+ overflow-y: hidden;
+ scroll-snap-type: x mandatory;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+
+.app-container::-webkit-scrollbar, ._scrollableContainer::-webkit-scrollbar, ._scrollableView::-webkit-scrollbar {
+ display: none;
}
.app-name {
font-weight: bold;
}
+.dim {
+ opacity: .6;
+}
+
/* Sidebar Styling */
.sidebar {
width: 380px;
@@ -440,6 +465,7 @@ input:focus {
.api-status-badge {
display: flex;
align-items: center;
+ justify-content: center;
gap: 8px;
font-size: 0.75rem;
color: var(--text-secondary);
@@ -476,6 +502,7 @@ input:focus {
display: flex;
flex-direction: column;
position: relative;
+ height: 100%;
}
/* Empty State */
@@ -843,8 +870,14 @@ input:focus {
/* Extra Pages */
.extra-page {
- display: none;
+ display: flex;
+ flex-direction: column;
width: 100%;
+ height: 100%;
+ scroll-snap-align: start;
+ scroll-snap-stop: always;
+ flex: 0 0 100%;
+
}
.extra-page-content {
@@ -871,7 +904,7 @@ input:focus {
flex: 1;
}
-#profile-page h2 {
+.big-title {
padding: 1.4rem;
font-size: 5rem;
text-wrap-mode: nowrap;
@@ -899,6 +932,27 @@ input:focus {
border-bottom-color: var(--text-primary);
}
+
+.notification-box {
+ transition: transform 1s cubic-bezier(0.19, 1, 0.22, 1);
+ width: 100%;
+ position: fixed;
+ top: 0;
+ left: 0;
+ background-color: var(--bg-secondary);
+ transform: translateY(-100%);
+ padding: .4em;
+ text-transform: uppercase;
+}
+
+.notification-box.shown {
+ transform: none;
+}
+
+.notification-box h1 {
+ font-size: 1rem;
+}
+
@media (max-width: 768px) {
.app-container {
width: 100%;
@@ -910,13 +964,12 @@ input:focus {
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
- /* Hide scrollbars */
- scrollbar-width: none; /* Firefox */
- -ms-overflow-style: none; /* IE and Edge */
+ scrollbar-width: none;
+ -ms-overflow-style: none;
}
.app-container::-webkit-scrollbar {
- display: none; /* Chrome, Safari, Opera */
+ display: none;
}
.sidebar {
@@ -944,7 +997,7 @@ input:focus {
}
.extra-page {
- display: block;
+ display: flex;
width: 100%;
flex: 0 0 100%;
position: relative;
@@ -993,7 +1046,6 @@ input:focus {
.modal-content {
width: 100%;
background: var(--bg-main);
- padding: 24px;
box-shadow: var(--shadow-lg);
backdrop-filter: var(--glass-blur);
display: flex;
@@ -1010,7 +1062,7 @@ input:focus {
display: flex;
justify-content: space-between;
align-items: center;
- margin-bottom: 20px;
+ padding: 1em 2em 0em 2em;
}
.modal-header h2 {
@@ -1022,6 +1074,11 @@ input:focus {
display: flex;
flex-direction: column;
gap: 16px;
+ min-height: 0;
+ overflow-y: auto;
+ flex: 1;
+ overflow-x: hidden;
+ padding: 0 2rem 0 2rem;
}
.form-group {
@@ -1046,7 +1103,7 @@ input:focus {
display: flex;
justify-content: flex-end;
gap: 12px;
- margin-top: auto;
+ padding: 1rem 2rem;
}
.btn {
@@ -1059,8 +1116,15 @@ input:focus {
}
.primary-btn {
- background: var(--accent-gradient);
- color: white;
+ background: transparent;
+ color: var(--text-primary);
+ border: medium solid var(--bg-secondary);
+}
+
+.primary-btn:hover {
+ background: var(--text-primary);
+ color: var(--bg-main);
+ border-color: var(--text-primary);
}
.secondary-btn {