watercooler 0.0.3 ā 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/app.js +218 -20
- package/public/index.html +290 -9
- package/server.ts +73 -5
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -2,10 +2,11 @@ import * as THREE from 'three';
|
|
|
2
2
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
3
3
|
|
|
4
4
|
// State
|
|
5
|
-
let config = { user: '', mailbox: '' };
|
|
5
|
+
let config = { user: '', mailbox: '', avatar: null };
|
|
6
6
|
let messages = []; // Messages TO user (for main panel)
|
|
7
7
|
let allMessages = []; // All messages involving user (for house dialogs)
|
|
8
8
|
let recipients = [];
|
|
9
|
+
let avatarStates = {}; // Map of name -> {tool_name, timestamp}
|
|
9
10
|
let scene, camera, renderer, controls;
|
|
10
11
|
let agentMeshes = new Map();
|
|
11
12
|
let connectionLines = [];
|
|
@@ -47,6 +48,13 @@ function init() {
|
|
|
47
48
|
controls.maxPolarAngle = Math.PI / 2 - 0.1;
|
|
48
49
|
controls.minDistance = 20;
|
|
49
50
|
controls.maxDistance = 80;
|
|
51
|
+
controls.enableZoom = true;
|
|
52
|
+
controls.zoomSpeed = 0.8;
|
|
53
|
+
controls.enablePan = false; // Disable pan on touch for better mobile UX
|
|
54
|
+
controls.touches = {
|
|
55
|
+
ONE: THREE.TOUCH.ROTATE,
|
|
56
|
+
TWO: THREE.TOUCH.DOLLY_PAN
|
|
57
|
+
};
|
|
50
58
|
|
|
51
59
|
// Lighting
|
|
52
60
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
|
@@ -90,6 +98,18 @@ function init() {
|
|
|
90
98
|
mouse = new THREE.Vector2();
|
|
91
99
|
renderer.domElement.addEventListener('click', onHouseClick);
|
|
92
100
|
|
|
101
|
+
// Add touch support for mobile
|
|
102
|
+
renderer.domElement.addEventListener('touchstart', onHouseTouchStart, { passive: false });
|
|
103
|
+
renderer.domElement.addEventListener('touchend', onHouseTouchEnd, { passive: false });
|
|
104
|
+
|
|
105
|
+
// Disable context menu on mobile for better UX
|
|
106
|
+
renderer.domElement.addEventListener('contextmenu', (e) => e.preventDefault());
|
|
107
|
+
|
|
108
|
+
// Handle orientation change
|
|
109
|
+
window.addEventListener('orientationchange', () => {
|
|
110
|
+
setTimeout(onWindowResize, 100);
|
|
111
|
+
});
|
|
112
|
+
|
|
93
113
|
animate();
|
|
94
114
|
}
|
|
95
115
|
|
|
@@ -118,7 +138,7 @@ function createTrees() {
|
|
|
118
138
|
}
|
|
119
139
|
}
|
|
120
140
|
|
|
121
|
-
function createAgentHouse(name, position) {
|
|
141
|
+
function createAgentHouse(name, position, toolName = null) {
|
|
122
142
|
const color = getAgentColor(name);
|
|
123
143
|
const group = new THREE.Group();
|
|
124
144
|
group.position.copy(position);
|
|
@@ -164,30 +184,42 @@ function createAgentHouse(name, position) {
|
|
|
164
184
|
window2.position.set(1.8, 2.5, 3.1);
|
|
165
185
|
group.add(window2);
|
|
166
186
|
|
|
167
|
-
// Name label sprite
|
|
187
|
+
// Name label sprite (with optional tool name)
|
|
168
188
|
const canvas = document.createElement('canvas');
|
|
169
189
|
const context = canvas.getContext('2d');
|
|
170
190
|
// High DPI canvas for crisp text
|
|
171
191
|
const scale = 2;
|
|
172
192
|
canvas.width = 512;
|
|
173
|
-
canvas.height = 128;
|
|
193
|
+
canvas.height = toolName ? 160 : 128; // Taller if showing tool
|
|
174
194
|
context.scale(scale, scale);
|
|
195
|
+
|
|
196
|
+
// Background
|
|
175
197
|
context.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
|
176
|
-
context.roundRect(0, 0, 256, 64, 16);
|
|
198
|
+
context.roundRect(0, 0, 256, toolName ? 80 : 64, 16);
|
|
177
199
|
context.fill();
|
|
200
|
+
|
|
201
|
+
// Name text
|
|
178
202
|
context.font = 'bold 24px Arial';
|
|
179
203
|
context.fillStyle = 'white';
|
|
180
204
|
context.textAlign = 'center';
|
|
181
205
|
context.textBaseline = 'middle';
|
|
182
|
-
context.fillText(name, 128,
|
|
206
|
+
context.fillText(name, 128, 24);
|
|
207
|
+
|
|
208
|
+
// Tool name text (if available)
|
|
209
|
+
if (toolName) {
|
|
210
|
+
context.font = 'italic 16px Arial';
|
|
211
|
+
context.fillStyle = '#FFD700'; // Gold color for tool name
|
|
212
|
+
context.fillText(toolName, 128, 56);
|
|
213
|
+
}
|
|
183
214
|
|
|
184
215
|
const texture = new THREE.CanvasTexture(canvas);
|
|
185
216
|
texture.minFilter = THREE.LinearFilter;
|
|
186
217
|
texture.magFilter = THREE.LinearFilter;
|
|
187
218
|
const spriteMat = new THREE.SpriteMaterial({ map: texture });
|
|
188
219
|
const sprite = new THREE.Sprite(spriteMat);
|
|
189
|
-
sprite.position.set(0, 8, 0);
|
|
190
|
-
sprite.scale.set(8, 2, 1);
|
|
220
|
+
sprite.position.set(0, toolName ? 8.5 : 8, 0);
|
|
221
|
+
sprite.scale.set(8, toolName ? 2.5 : 2, 1);
|
|
222
|
+
sprite.name = 'label'; // Tag for easy updates
|
|
191
223
|
group.add(sprite);
|
|
192
224
|
|
|
193
225
|
// Path to house
|
|
@@ -204,6 +236,49 @@ function createAgentHouse(name, position) {
|
|
|
204
236
|
return group;
|
|
205
237
|
}
|
|
206
238
|
|
|
239
|
+
function updateHouseLabel(house, name, toolName = null) {
|
|
240
|
+
const sprite = house.getObjectByName('label');
|
|
241
|
+
if (!sprite) return;
|
|
242
|
+
|
|
243
|
+
// Create new canvas with updated text
|
|
244
|
+
const canvas = document.createElement('canvas');
|
|
245
|
+
const context = canvas.getContext('2d');
|
|
246
|
+
const scale = 2;
|
|
247
|
+
canvas.width = 512;
|
|
248
|
+
canvas.height = toolName ? 160 : 128;
|
|
249
|
+
context.scale(scale, scale);
|
|
250
|
+
|
|
251
|
+
// Background
|
|
252
|
+
context.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
|
253
|
+
context.roundRect(0, 0, 256, toolName ? 80 : 64, 16);
|
|
254
|
+
context.fill();
|
|
255
|
+
|
|
256
|
+
// Name text
|
|
257
|
+
context.font = 'bold 24px Arial';
|
|
258
|
+
context.fillStyle = 'white';
|
|
259
|
+
context.textAlign = 'center';
|
|
260
|
+
context.textBaseline = 'middle';
|
|
261
|
+
context.fillText(name, 128, 24);
|
|
262
|
+
|
|
263
|
+
// Tool name text (if available)
|
|
264
|
+
if (toolName) {
|
|
265
|
+
context.font = 'italic 16px Arial';
|
|
266
|
+
context.fillStyle = '#FFD700';
|
|
267
|
+
context.fillText(toolName, 128, 56);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Update texture
|
|
271
|
+
const texture = new THREE.CanvasTexture(canvas);
|
|
272
|
+
texture.minFilter = THREE.LinearFilter;
|
|
273
|
+
texture.magFilter = THREE.LinearFilter;
|
|
274
|
+
sprite.material.map = texture;
|
|
275
|
+
sprite.material.needsUpdate = true;
|
|
276
|
+
|
|
277
|
+
// Update position and scale
|
|
278
|
+
sprite.position.set(0, toolName ? 8.5 : 8, 0);
|
|
279
|
+
sprite.scale.set(8, toolName ? 2.5 : 2, 1);
|
|
280
|
+
}
|
|
281
|
+
|
|
207
282
|
function createMessageParticle(fromPos, toPos) {
|
|
208
283
|
const particleGeo = new THREE.SphereGeometry(0.3, 8, 8);
|
|
209
284
|
const particleMat = new THREE.MeshStandardMaterial({
|
|
@@ -321,12 +396,44 @@ function updateVillage() {
|
|
|
321
396
|
const z = Math.sin(angle) * radius;
|
|
322
397
|
const position = new THREE.Vector3(x, 0, z);
|
|
323
398
|
|
|
399
|
+
// Get tool name for this agent from avatar states
|
|
400
|
+
const avatarState = avatarStates[agent.toLowerCase()];
|
|
401
|
+
const toolName = avatarState?.tool_name || null;
|
|
402
|
+
|
|
324
403
|
if (!agentMeshes.has(agent)) {
|
|
325
|
-
createAgentHouse(agent, position);
|
|
404
|
+
createAgentHouse(agent, position, toolName);
|
|
326
405
|
} else {
|
|
327
406
|
// Update position if needed
|
|
328
407
|
const house = agentMeshes.get(agent);
|
|
329
408
|
house.position.copy(position);
|
|
409
|
+
|
|
410
|
+
// Update label with current tool name
|
|
411
|
+
updateHouseLabel(house, agent, toolName);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// Remove houses for coworkers that no longer exist
|
|
416
|
+
agentMeshes.forEach((house, name) => {
|
|
417
|
+
if (!allAgents.has(name)) {
|
|
418
|
+
// Remove from scene
|
|
419
|
+
scene.remove(house);
|
|
420
|
+
|
|
421
|
+
// Dispose of geometries and materials to prevent memory leaks
|
|
422
|
+
house.traverse((child) => {
|
|
423
|
+
if (child.isMesh) {
|
|
424
|
+
child.geometry.dispose();
|
|
425
|
+
if (child.material) {
|
|
426
|
+
if (Array.isArray(child.material)) {
|
|
427
|
+
child.material.forEach(m => m.dispose());
|
|
428
|
+
} else {
|
|
429
|
+
child.material.dispose();
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// Remove from Map
|
|
436
|
+
agentMeshes.delete(name);
|
|
330
437
|
}
|
|
331
438
|
});
|
|
332
439
|
|
|
@@ -354,9 +461,21 @@ function animate() {
|
|
|
354
461
|
}
|
|
355
462
|
|
|
356
463
|
function onWindowResize() {
|
|
357
|
-
|
|
464
|
+
const width = window.innerWidth;
|
|
465
|
+
const height = window.innerHeight;
|
|
466
|
+
camera.aspect = width / height;
|
|
358
467
|
camera.updateProjectionMatrix();
|
|
359
|
-
renderer.setSize(
|
|
468
|
+
renderer.setSize(width, height);
|
|
469
|
+
|
|
470
|
+
// Adjust camera position for better mobile view
|
|
471
|
+
if (width < 768) {
|
|
472
|
+
// On mobile, position camera slightly higher and further back
|
|
473
|
+
camera.position.y = Math.max(camera.position.y, 35);
|
|
474
|
+
camera.position.z = Math.max(camera.position.z, 45);
|
|
475
|
+
controls.minDistance = 30; // Prevent zooming too close on mobile
|
|
476
|
+
} else {
|
|
477
|
+
controls.minDistance = 20;
|
|
478
|
+
}
|
|
360
479
|
}
|
|
361
480
|
|
|
362
481
|
// API and UI Functions
|
|
@@ -379,6 +498,17 @@ async function loadData() {
|
|
|
379
498
|
recipients = Array.isArray(recipientsData) ? recipientsData : [];
|
|
380
499
|
allMessages = Array.isArray(allMessagesData) ? allMessagesData : [];
|
|
381
500
|
|
|
501
|
+
// Load avatar states if avatar DB is configured
|
|
502
|
+
if (config.avatar) {
|
|
503
|
+
try {
|
|
504
|
+
const avatarsRes = await fetch('/api/avatars');
|
|
505
|
+
avatarStates = await avatarsRes.json();
|
|
506
|
+
} catch (err) {
|
|
507
|
+
console.error('Error loading avatar states:', err);
|
|
508
|
+
avatarStates = {};
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
382
512
|
updateUI();
|
|
383
513
|
updateVillage();
|
|
384
514
|
} catch (err) {
|
|
@@ -396,7 +526,10 @@ window.toggleSendPanel = function() {
|
|
|
396
526
|
|
|
397
527
|
window.toggleMessagesPanel = function() {
|
|
398
528
|
const panel = document.getElementById('messages-panel');
|
|
529
|
+
const btn = document.getElementById('toggle-messages-btn');
|
|
399
530
|
panel.classList.toggle('open');
|
|
531
|
+
btn.style.opacity = panel.classList.contains('open') ? '0' : '1';
|
|
532
|
+
btn.style.pointerEvents = panel.classList.contains('open') ? 'none' : 'auto';
|
|
400
533
|
};
|
|
401
534
|
|
|
402
535
|
function updateUI() {
|
|
@@ -491,6 +624,18 @@ async function markAsRead(id) {
|
|
|
491
624
|
}
|
|
492
625
|
}
|
|
493
626
|
|
|
627
|
+
window.markAllAsRead = async function() {
|
|
628
|
+
const unreadMessages = messages.filter(m => !m.read && m.recipient.toLowerCase() === config.user.toLowerCase());
|
|
629
|
+
if (unreadMessages.length === 0) return;
|
|
630
|
+
|
|
631
|
+
try {
|
|
632
|
+
await Promise.all(unreadMessages.map(m => fetch(`/api/messages/${m.id}/read`, { method: 'POST' })));
|
|
633
|
+
loadData();
|
|
634
|
+
} catch (err) {
|
|
635
|
+
console.error('Error marking all as read:', err);
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
|
|
494
639
|
async function sendMessage() {
|
|
495
640
|
const to = document.getElementById('recipient-select').value;
|
|
496
641
|
const message = document.getElementById('message-input').value.trim();
|
|
@@ -529,9 +674,47 @@ async function sendMessage() {
|
|
|
529
674
|
|
|
530
675
|
// House click handler
|
|
531
676
|
function onHouseClick(event) {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
677
|
+
handleHouseInteraction(event.clientX, event.clientY);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Touch handlers for mobile
|
|
681
|
+
let touchStartX = 0;
|
|
682
|
+
let touchStartY = 0;
|
|
683
|
+
|
|
684
|
+
function onHouseTouchStart(event) {
|
|
685
|
+
if (event.touches.length === 1) {
|
|
686
|
+
touchStartX = event.touches[0].clientX;
|
|
687
|
+
touchStartY = event.touches[0].clientY;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function onHouseTouchEnd(event) {
|
|
692
|
+
if (event.changedTouches.length === 1) {
|
|
693
|
+
const touchEndX = event.changedTouches[0].clientX;
|
|
694
|
+
const touchEndY = event.changedTouches[0].clientY;
|
|
695
|
+
|
|
696
|
+
// Check if touch moved significantly (if so, it's a drag/pan, not a tap)
|
|
697
|
+
const moveDistance = Math.sqrt(
|
|
698
|
+
Math.pow(touchEndX - touchStartX, 2) +
|
|
699
|
+
Math.pow(touchEndY - touchStartY, 2)
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
// Only trigger if touch didn't move much (tap vs swipe)
|
|
703
|
+
if (moveDistance < 20) {
|
|
704
|
+
handleHouseInteraction(touchEndX, touchEndY);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Common house interaction handler
|
|
710
|
+
function handleHouseInteraction(clientX, clientY) {
|
|
711
|
+
// Calculate normalized device coordinates
|
|
712
|
+
const rect = renderer.domElement.getBoundingClientRect();
|
|
713
|
+
const x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
|
714
|
+
const y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
|
715
|
+
|
|
716
|
+
mouse.x = x;
|
|
717
|
+
mouse.y = y;
|
|
535
718
|
|
|
536
719
|
raycaster.setFromCamera(mouse, camera);
|
|
537
720
|
|
|
@@ -664,7 +847,7 @@ window.closeHouseDialog = function() {
|
|
|
664
847
|
document.getElementById('house-dialog').classList.remove('active');
|
|
665
848
|
};
|
|
666
849
|
|
|
667
|
-
// Update house labels to show unread indicators
|
|
850
|
+
// Update house labels to show unread indicators and tool names
|
|
668
851
|
function updateHouseLabels() {
|
|
669
852
|
agentMeshes.forEach((group, name) => {
|
|
670
853
|
// Check for unread messages SENT TO this agent (messages they haven't read)
|
|
@@ -681,6 +864,10 @@ function updateHouseLabels() {
|
|
|
681
864
|
!m.read
|
|
682
865
|
).length;
|
|
683
866
|
|
|
867
|
+
// Get tool name for this agent
|
|
868
|
+
const avatarState = avatarStates[name.toLowerCase()];
|
|
869
|
+
const toolName = avatarState?.tool_name || null;
|
|
870
|
+
|
|
684
871
|
// Find the sprite label
|
|
685
872
|
const sprite = group.children.find(c => c.isSprite);
|
|
686
873
|
if (sprite) {
|
|
@@ -689,7 +876,7 @@ function updateHouseLabels() {
|
|
|
689
876
|
const context = canvas.getContext('2d');
|
|
690
877
|
const scale = 2;
|
|
691
878
|
canvas.width = 700;
|
|
692
|
-
canvas.height = 128;
|
|
879
|
+
canvas.height = toolName ? 160 : 128;
|
|
693
880
|
context.scale(scale, scale);
|
|
694
881
|
|
|
695
882
|
// Background - change color if there are unread messages
|
|
@@ -703,7 +890,7 @@ function updateHouseLabels() {
|
|
|
703
890
|
// Default black background
|
|
704
891
|
context.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
|
705
892
|
}
|
|
706
|
-
context.roundRect(0, 0, 350, 64, 16);
|
|
893
|
+
context.roundRect(0, 0, 350, toolName ? 80 : 64, 16);
|
|
707
894
|
context.fill();
|
|
708
895
|
|
|
709
896
|
// Name
|
|
@@ -714,12 +901,19 @@ function updateHouseLabels() {
|
|
|
714
901
|
|
|
715
902
|
if (unreadFromAgent > 0) {
|
|
716
903
|
// Show name with unread indicator from agent
|
|
717
|
-
context.fillText(`${name} š“ ${unreadFromAgent}`, 175,
|
|
904
|
+
context.fillText(`${name} š“ ${unreadFromAgent}`, 175, 24);
|
|
718
905
|
} else if (unreadCount > 0) {
|
|
719
906
|
// Show name with sent-but-unread count
|
|
720
|
-
context.fillText(`${name} š¤ ${unreadCount}`, 175,
|
|
907
|
+
context.fillText(`${name} š¤ ${unreadCount}`, 175, 24);
|
|
721
908
|
} else {
|
|
722
|
-
context.fillText(name, 175,
|
|
909
|
+
context.fillText(name, 175, 24);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Tool name text (if available)
|
|
913
|
+
if (toolName) {
|
|
914
|
+
context.font = 'italic 16px Arial';
|
|
915
|
+
context.fillStyle = '#FFD700'; // Gold color for tool name
|
|
916
|
+
context.fillText(toolName, 175, 56);
|
|
723
917
|
}
|
|
724
918
|
|
|
725
919
|
const texture = new THREE.CanvasTexture(canvas);
|
|
@@ -727,6 +921,10 @@ function updateHouseLabels() {
|
|
|
727
921
|
texture.magFilter = THREE.LinearFilter;
|
|
728
922
|
sprite.material.map = texture;
|
|
729
923
|
sprite.material.needsUpdate = true;
|
|
924
|
+
|
|
925
|
+
// Update position and scale based on whether tool name is shown
|
|
926
|
+
sprite.position.set(0, toolName ? 8.5 : 8, 0);
|
|
927
|
+
sprite.scale.set(8, toolName ? 2.5 : 2, 1);
|
|
730
928
|
}
|
|
731
929
|
});
|
|
732
930
|
}
|
package/public/index.html
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
|
6
6
|
<title>Watercooler</title>
|
|
7
7
|
<style>
|
|
8
8
|
* {
|
|
@@ -199,6 +199,23 @@
|
|
|
199
199
|
opacity: 1;
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
.mark-all-read-btn {
|
|
203
|
+
background: rgba(255, 255, 255, 0.1);
|
|
204
|
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
205
|
+
color: rgba(255, 255, 255, 0.8);
|
|
206
|
+
font-size: 0.75rem;
|
|
207
|
+
padding: 4px 10px;
|
|
208
|
+
border-radius: 4px;
|
|
209
|
+
cursor: pointer;
|
|
210
|
+
transition: all 0.2s;
|
|
211
|
+
white-space: nowrap;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.mark-all-read-btn:hover {
|
|
215
|
+
background: rgba(255, 255, 255, 0.2);
|
|
216
|
+
color: white;
|
|
217
|
+
}
|
|
218
|
+
|
|
202
219
|
.messages-container {
|
|
203
220
|
flex: 1;
|
|
204
221
|
overflow-y: auto;
|
|
@@ -303,7 +320,7 @@
|
|
|
303
320
|
}
|
|
304
321
|
|
|
305
322
|
.message-text li {
|
|
306
|
-
margin:
|
|
323
|
+
margin: 2px 0;
|
|
307
324
|
}
|
|
308
325
|
|
|
309
326
|
/* Toggle Messages Button */
|
|
@@ -320,7 +337,7 @@
|
|
|
320
337
|
font-size: 0.9rem;
|
|
321
338
|
font-weight: 600;
|
|
322
339
|
cursor: pointer;
|
|
323
|
-
z-index:
|
|
340
|
+
z-index: 101;
|
|
324
341
|
transition: all 0.3s ease;
|
|
325
342
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
|
326
343
|
}
|
|
@@ -382,6 +399,239 @@
|
|
|
382
399
|
border-radius: 3px;
|
|
383
400
|
}
|
|
384
401
|
|
|
402
|
+
/* Mobile responsive styles */
|
|
403
|
+
@media (max-width: 768px) {
|
|
404
|
+
/* Send panel - full width at bottom */
|
|
405
|
+
.send-panel {
|
|
406
|
+
top: auto;
|
|
407
|
+
bottom: 0;
|
|
408
|
+
left: 0;
|
|
409
|
+
right: 0;
|
|
410
|
+
width: 100%;
|
|
411
|
+
max-width: 100%;
|
|
412
|
+
border-radius: 16px 16px 0 0;
|
|
413
|
+
border-bottom: none;
|
|
414
|
+
border-left: none;
|
|
415
|
+
border-right: none;
|
|
416
|
+
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
.send-panel.collapsed {
|
|
420
|
+
width: 100%;
|
|
421
|
+
min-width: 100%;
|
|
422
|
+
transform: translateY(calc(100% - 75px));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
.send-header {
|
|
426
|
+
padding: 14px 20px;
|
|
427
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
.send-header h2 {
|
|
431
|
+
font-size: 1rem;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
.collapse-btn {
|
|
435
|
+
font-size: 1.4rem;
|
|
436
|
+
padding: 8px 16px;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
.send-form {
|
|
440
|
+
padding: 20px;
|
|
441
|
+
max-height: 50vh;
|
|
442
|
+
overflow-y: auto;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
.recipient-select, .message-input, .send-btn {
|
|
446
|
+
font-size: 16px; /* Prevents zoom on iOS */
|
|
447
|
+
padding: 14px 16px;
|
|
448
|
+
margin-bottom: 12px;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
.message-input {
|
|
452
|
+
min-height: 80px;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/* Messages panel - nearly full screen on mobile */
|
|
456
|
+
.messages-panel {
|
|
457
|
+
top: auto;
|
|
458
|
+
bottom: 0;
|
|
459
|
+
left: 0;
|
|
460
|
+
right: 0;
|
|
461
|
+
width: 100%;
|
|
462
|
+
max-width: 100%;
|
|
463
|
+
max-height: 90vh;
|
|
464
|
+
height: 90vh;
|
|
465
|
+
border-radius: 16px 16px 0 0;
|
|
466
|
+
transform: translateY(100%);
|
|
467
|
+
transition: transform 0.3s ease;
|
|
468
|
+
display: flex;
|
|
469
|
+
flex-direction: column;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
.messages-panel.open {
|
|
473
|
+
right: 0;
|
|
474
|
+
transform: translateY(0);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
.messages-header {
|
|
478
|
+
padding: 20px 24px;
|
|
479
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
480
|
+
flex-shrink: 0;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
.messages-header h2 {
|
|
484
|
+
font-size: 1.2rem;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
.close-btn {
|
|
488
|
+
font-size: 1.8rem;
|
|
489
|
+
padding: 8px;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
.messages-container {
|
|
493
|
+
padding: 20px;
|
|
494
|
+
flex: 1;
|
|
495
|
+
max-height: calc(90vh - 80px);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
.message-card {
|
|
499
|
+
padding: 16px 16px 16px 24px;
|
|
500
|
+
margin-bottom: 12px;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
.message-sender {
|
|
504
|
+
font-size: 0.9rem;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
.message-time {
|
|
508
|
+
font-size: 0.75rem;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
.message-text {
|
|
512
|
+
font-size: 0.9rem;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/* Toggle messages button - repositioned */
|
|
516
|
+
.toggle-messages-btn {
|
|
517
|
+
top: 20px;
|
|
518
|
+
right: 20px;
|
|
519
|
+
padding: 14px 18px;
|
|
520
|
+
font-size: 1rem;
|
|
521
|
+
border-radius: 30px;
|
|
522
|
+
z-index: 101;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/* Toast notification - centered */
|
|
526
|
+
.toast {
|
|
527
|
+
left: 20px;
|
|
528
|
+
right: 20px;
|
|
529
|
+
bottom: auto;
|
|
530
|
+
top: 50%;
|
|
531
|
+
transform: translateY(-50%) translateY(20px);
|
|
532
|
+
text-align: center;
|
|
533
|
+
padding: 16px 24px;
|
|
534
|
+
font-size: 1rem;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
.toast.show {
|
|
538
|
+
transform: translateY(-50%) translateY(0);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/* House dialog - full screen on mobile */
|
|
542
|
+
.house-dialog-content {
|
|
543
|
+
width: 100%;
|
|
544
|
+
max-width: 100%;
|
|
545
|
+
max-height: 95vh;
|
|
546
|
+
height: 95vh;
|
|
547
|
+
border-radius: 16px 16px 0 0;
|
|
548
|
+
margin: 0;
|
|
549
|
+
position: fixed;
|
|
550
|
+
bottom: 0;
|
|
551
|
+
left: 0;
|
|
552
|
+
right: 0;
|
|
553
|
+
display: flex;
|
|
554
|
+
flex-direction: column;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
.house-dialog-header {
|
|
558
|
+
padding: 20px 24px;
|
|
559
|
+
flex-shrink: 0;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
.house-dialog-header h2 {
|
|
563
|
+
font-size: 1.2rem;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
.house-dialog-tabs {
|
|
567
|
+
padding: 0 12px;
|
|
568
|
+
flex-shrink: 0;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
.tab-btn {
|
|
572
|
+
padding: 16px 12px;
|
|
573
|
+
font-size: 0.9rem;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
.house-dialog-body {
|
|
577
|
+
padding: 20px;
|
|
578
|
+
max-height: calc(95vh - 140px);
|
|
579
|
+
flex: 1;
|
|
580
|
+
overflow-y: auto;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
.house-dialog-body .message-card {
|
|
584
|
+
padding: 16px 16px 16px 24px;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/* Empty state */
|
|
588
|
+
.empty-state {
|
|
589
|
+
padding: 60px 20px;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
.empty-state p {
|
|
593
|
+
font-size: 1rem;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/* Extra small screens */
|
|
598
|
+
@media (max-width: 480px) {
|
|
599
|
+
.send-panel.collapsed {
|
|
600
|
+
transform: translateY(calc(100% - 70px));
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
.send-header {
|
|
604
|
+
padding: 12px 16px;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
.send-header h2 {
|
|
608
|
+
font-size: 0.9rem;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
.send-form {
|
|
612
|
+
padding: 16px;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
.recipient-select, .message-input, .send-btn {
|
|
616
|
+
font-size: 16px;
|
|
617
|
+
padding: 12px 14px;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
.toggle-messages-btn {
|
|
621
|
+
padding: 12px 16px;
|
|
622
|
+
font-size: 0.9rem;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
.messages-panel {
|
|
626
|
+
max-height: 75vh;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
.tab-btn {
|
|
630
|
+
font-size: 0.8rem;
|
|
631
|
+
padding: 12px 8px;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
385
635
|
/* House Dialog */
|
|
386
636
|
.house-dialog {
|
|
387
637
|
display: none;
|
|
@@ -392,7 +642,7 @@
|
|
|
392
642
|
bottom: 0;
|
|
393
643
|
background: rgba(0, 0, 0, 0.7);
|
|
394
644
|
z-index: 300;
|
|
395
|
-
align-items:
|
|
645
|
+
align-items: flex-end;
|
|
396
646
|
justify-content: center;
|
|
397
647
|
}
|
|
398
648
|
|
|
@@ -400,15 +650,43 @@
|
|
|
400
650
|
display: flex;
|
|
401
651
|
}
|
|
402
652
|
|
|
653
|
+
/* Desktop override for house dialog */
|
|
654
|
+
@media (min-width: 769px) {
|
|
655
|
+
.house-dialog {
|
|
656
|
+
align-items: center;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
.house-dialog-content {
|
|
660
|
+
position: relative;
|
|
661
|
+
bottom: auto;
|
|
662
|
+
left: auto;
|
|
663
|
+
right: auto;
|
|
664
|
+
border-radius: 20px;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/* Desktop house dialog styles */
|
|
669
|
+
@media (min-width: 769px) {
|
|
670
|
+
.house-dialog-content {
|
|
671
|
+
background: rgba(255, 255, 255, 0.15);
|
|
672
|
+
backdrop-filter: blur(20px);
|
|
673
|
+
border-radius: 20px;
|
|
674
|
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
675
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
|
676
|
+
width: 500px;
|
|
677
|
+
max-width: 90%;
|
|
678
|
+
max-height: 80vh;
|
|
679
|
+
display: flex;
|
|
680
|
+
flex-direction: column;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/* Base house dialog styles (shared) */
|
|
403
685
|
.house-dialog-content {
|
|
404
686
|
background: rgba(255, 255, 255, 0.15);
|
|
405
687
|
backdrop-filter: blur(20px);
|
|
406
|
-
border-radius: 20px;
|
|
407
688
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
408
689
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
|
409
|
-
width: 500px;
|
|
410
|
-
max-width: 90%;
|
|
411
|
-
max-height: 80vh;
|
|
412
690
|
display: flex;
|
|
413
691
|
flex-direction: column;
|
|
414
692
|
}
|
|
@@ -529,7 +807,10 @@
|
|
|
529
807
|
<div class="messages-panel" id="messages-panel">
|
|
530
808
|
<div class="messages-header">
|
|
531
809
|
<h2>šØ Message History</h2>
|
|
532
|
-
<
|
|
810
|
+
<div style="display: flex; gap: 8px; align-items: center;">
|
|
811
|
+
<button class="mark-all-read-btn" onclick="markAllAsRead()">Mark all as read</button>
|
|
812
|
+
<button class="close-btn" onclick="toggleMessagesPanel()">Ć</button>
|
|
813
|
+
</div>
|
|
533
814
|
</div>
|
|
534
815
|
<div class="messages-container" id="messages-container">
|
|
535
816
|
<div class="empty-state">
|
package/server.ts
CHANGED
|
@@ -7,13 +7,15 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
7
7
|
const __dirname = path.dirname(__filename);
|
|
8
8
|
|
|
9
9
|
const app = express();
|
|
10
|
-
const PORT = process.env.PORT || 3000;
|
|
11
10
|
|
|
12
11
|
// Parse CLI args
|
|
13
12
|
const args = process.argv.slice(2);
|
|
14
13
|
let user: string | null = null;
|
|
15
14
|
let mailboxPath: string | null = null;
|
|
16
15
|
let coworkerPath: string | null = null;
|
|
16
|
+
let avatarPath: string | null = null;
|
|
17
|
+
let port: number = parseInt(process.env.PORT || '3000', 10);
|
|
18
|
+
let host: string = process.env.HOST || '0.0.0.0';
|
|
17
19
|
|
|
18
20
|
for (let i = 0; i < args.length; i++) {
|
|
19
21
|
if (args[i] === '--user' || args[i] === '-u') {
|
|
@@ -22,11 +24,18 @@ for (let i = 0; i < args.length; i++) {
|
|
|
22
24
|
mailboxPath = args[++i];
|
|
23
25
|
} else if (args[i] === '--coworkers' || args[i] === '-c') {
|
|
24
26
|
coworkerPath = args[++i];
|
|
27
|
+
} else if (args[i] === '--avatars' || args[i] === '-a') {
|
|
28
|
+
avatarPath = args[++i];
|
|
29
|
+
} else if (args[i] === '--port' || args[i] === '-p') {
|
|
30
|
+
const p = parseInt(args[++i], 10);
|
|
31
|
+
if (!isNaN(p)) port = p;
|
|
32
|
+
} else if (args[i] === '--host' || args[i] === '-h') {
|
|
33
|
+
host = args[++i];
|
|
25
34
|
}
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
if (!user || !mailboxPath) {
|
|
29
|
-
console.error('Usage: watercooler --user <name> --mailbox <path> [--coworkers <path>]');
|
|
38
|
+
console.error('Usage: watercooler --user <name> --mailbox <path> [--coworkers <path>] [--avatars <path>] [--port <number>] [--host <address>]');
|
|
30
39
|
process.exit(1);
|
|
31
40
|
}
|
|
32
41
|
|
|
@@ -35,11 +44,15 @@ console.log(` Mailbox: ${mailboxPath}`);
|
|
|
35
44
|
if (coworkerPath) {
|
|
36
45
|
console.log(` Coworker DB: ${coworkerPath}`);
|
|
37
46
|
}
|
|
38
|
-
|
|
47
|
+
if (avatarPath) {
|
|
48
|
+
console.log(` Avatar DB: ${avatarPath}`);
|
|
49
|
+
}
|
|
50
|
+
console.log(` URL: http://${host}:${port}`);
|
|
39
51
|
|
|
40
52
|
// Databases
|
|
41
53
|
let db: Database.Database | null = null;
|
|
42
54
|
let coworkerDb: Database.Database | null = null;
|
|
55
|
+
let avatarDb: Database.Database | null = null;
|
|
43
56
|
|
|
44
57
|
try {
|
|
45
58
|
db = new Database(mailboxPath);
|
|
@@ -58,6 +71,15 @@ if (coworkerPath) {
|
|
|
58
71
|
}
|
|
59
72
|
}
|
|
60
73
|
|
|
74
|
+
if (avatarPath) {
|
|
75
|
+
try {
|
|
76
|
+
avatarDb = new Database(avatarPath);
|
|
77
|
+
console.log(' Avatar DB: connected');
|
|
78
|
+
} catch (err: any) {
|
|
79
|
+
console.warn(' Avatar DB error:', err.message);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
61
83
|
// Helper: Check if table exists
|
|
62
84
|
function tableExists(database: Database.Database | null, tableName: string): boolean {
|
|
63
85
|
if (!database) return false;
|
|
@@ -225,11 +247,57 @@ app.post('/api/messages/:id/read', (req, res) => {
|
|
|
225
247
|
}
|
|
226
248
|
});
|
|
227
249
|
|
|
250
|
+
// API: Get avatar states (latest tool usage per coworker)
|
|
251
|
+
app.get('/api/avatars', (req, res) => {
|
|
252
|
+
try {
|
|
253
|
+
if (!avatarDb) {
|
|
254
|
+
res.json({});
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Check if latest_tool_usage table exists
|
|
259
|
+
const tableCheck = avatarDb.prepare(`
|
|
260
|
+
SELECT name FROM sqlite_master
|
|
261
|
+
WHERE type='table' AND name='latest_tool_usage'
|
|
262
|
+
`).get();
|
|
263
|
+
|
|
264
|
+
if (!tableCheck) {
|
|
265
|
+
res.json({});
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Get latest tool usage per name
|
|
270
|
+
const stmt = avatarDb.prepare(`
|
|
271
|
+
SELECT name, tool_name, timestamp
|
|
272
|
+
FROM latest_tool_usage
|
|
273
|
+
ORDER BY timestamp DESC
|
|
274
|
+
`);
|
|
275
|
+
|
|
276
|
+
const rows = stmt.all() as Array<{name: string; tool_name: string; timestamp: number}>;
|
|
277
|
+
|
|
278
|
+
// Build map of name -> latest tool (first occurrence is latest due to ORDER BY)
|
|
279
|
+
const avatarStates: Record<string, {tool_name: string; timestamp: number}> = {};
|
|
280
|
+
for (const row of rows) {
|
|
281
|
+
if (!avatarStates[row.name]) {
|
|
282
|
+
avatarStates[row.name] = {
|
|
283
|
+
tool_name: row.tool_name,
|
|
284
|
+
timestamp: row.timestamp
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
res.json(avatarStates);
|
|
290
|
+
} catch (err: any) {
|
|
291
|
+
console.error('Error in /api/avatars:', err.message);
|
|
292
|
+
res.status(500).json({ error: err.message });
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
228
296
|
// Config endpoint
|
|
229
297
|
app.get('/api/config', (req, res) => {
|
|
230
|
-
res.json({ user, mailbox: mailboxPath, coworker: coworkerPath });
|
|
298
|
+
res.json({ user, mailbox: mailboxPath, coworker: coworkerPath, avatar: avatarPath });
|
|
231
299
|
});
|
|
232
300
|
|
|
233
|
-
app.listen(
|
|
301
|
+
app.listen(port, host, () => {
|
|
234
302
|
console.log('\nā
Watercooler running!');
|
|
235
303
|
});
|