@opencoop/opencode-plugin 1.0.0 → 1.1.0

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.
@@ -1,96 +1,216 @@
1
- // ==================== STATE ====================
1
+ // ============================================
2
+ // OpenCOOP - Main Application
3
+ // ============================================
4
+
5
+ const API = '';
2
6
  let currentPage = 'config';
3
- let currentMode = null;
4
-
5
- // ==================== NAVIGATION ====================
6
- document.querySelectorAll('.nav-links a').forEach(link => {
7
- link.addEventListener('click', (e) => {
8
- e.preventDefault();
9
- const page = link.dataset.page;
10
- navigateTo(page);
11
- });
7
+ let currentConfig = { mode: null, workspacePath: '' };
8
+
9
+ // ============================================
10
+ // Initialization
11
+ // ============================================
12
+
13
+ document.addEventListener('DOMContentLoaded', () => {
14
+ initNavigation();
15
+ initThemeToggle();
16
+ initMenuToggle();
17
+ checkStatus();
18
+ loadConfig();
19
+
20
+ // Auto-refresh status
21
+ setInterval(checkStatus, 10000);
12
22
  });
13
23
 
14
- function navigateTo(page) {
15
- document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
16
- document.querySelectorAll('.nav-links a').forEach(a => a.classList.remove('active'));
24
+ // ============================================
25
+ // Navigation
26
+ // ============================================
17
27
 
18
- document.getElementById(`page-${page}`)?.classList.add('active');
19
- document.querySelector(`[data-page="${page}"]`)?.classList.add('active');
28
+ function initNavigation() {
29
+ document.querySelectorAll('.nav-item').forEach(item => {
30
+ item.addEventListener('click', (e) => {
31
+ e.preventDefault();
32
+ const page = item.dataset.page;
33
+ navigateTo(page);
34
+ });
35
+ });
36
+ }
20
37
 
38
+ function navigateTo(page) {
21
39
  currentPage = page;
40
+
41
+ // Update nav items
42
+ document.querySelectorAll('.nav-item').forEach(item => {
43
+ item.classList.toggle('active', item.dataset.page === page);
44
+ });
45
+
46
+ // Update pages with animation
47
+ document.querySelectorAll('.page').forEach(p => {
48
+ if (p.id === `page-${page}`) {
49
+ p.classList.add('active');
50
+ p.style.animation = 'none';
51
+ p.offsetHeight; // Trigger reflow
52
+ p.style.animation = 'slideUp 0.4s ease forwards';
53
+ } else {
54
+ p.classList.remove('active');
55
+ }
56
+ });
57
+
58
+ // Update page title
59
+ const titles = {
60
+ config: 'Configuration',
61
+ dashboard: 'Dashboard',
62
+ changes: 'Change History',
63
+ team: 'Team Management',
64
+ locks: 'File Locks'
65
+ };
66
+ document.getElementById('pageTitle').textContent = titles[page] || page;
67
+
68
+ // Load page data
22
69
  loadPageData(page);
70
+
71
+ // Close mobile menu
72
+ document.getElementById('sidebar').classList.remove('open');
23
73
  }
24
74
 
25
- async function loadPageData(page) {
75
+ function loadPageData(page) {
26
76
  switch (page) {
27
- case 'dashboard': await loadDashboard(); break;
28
- case 'changes': await loadChanges(); break;
29
- case 'team': await loadTeam(); break;
30
- case 'locks': await loadLocks(); break;
31
- case 'config': await loadConfig(); break;
77
+ case 'dashboard':
78
+ loadDashboard();
79
+ break;
80
+ case 'changes':
81
+ loadChanges();
82
+ break;
83
+ case 'team':
84
+ loadTeam();
85
+ break;
86
+ case 'locks':
87
+ loadLocks();
88
+ break;
32
89
  }
33
90
  }
34
91
 
35
- // ==================== STATUS CHECK ====================
92
+ // ============================================
93
+ // Theme Toggle
94
+ // ============================================
95
+
96
+ function initThemeToggle() {
97
+ const theme = localStorage.getItem('opencoop-theme') || 'dark';
98
+ document.documentElement.dataset.theme = theme;
99
+
100
+ document.getElementById('themeToggle').addEventListener('click', () => {
101
+ const current = document.documentElement.dataset.theme;
102
+ const next = current === 'dark' ? 'light' : 'dark';
103
+ document.documentElement.dataset.theme = next;
104
+ localStorage.setItem('opencoop-theme', next);
105
+ });
106
+ }
107
+
108
+ // ============================================
109
+ // Mobile Menu
110
+ // ============================================
111
+
112
+ function initMenuToggle() {
113
+ document.getElementById('menuToggle').addEventListener('click', () => {
114
+ document.getElementById('sidebar').classList.toggle('open');
115
+ });
116
+ }
117
+
118
+ // ============================================
119
+ // Toast Notifications
120
+ // ============================================
121
+
122
+ function showToast(type, title, message, duration = 4000) {
123
+ const container = document.getElementById('toast-container');
124
+ const toast = document.createElement('div');
125
+ toast.className = `toast ${type}`;
126
+
127
+ const icons = {
128
+ success: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>',
129
+ error: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>',
130
+ warning: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
131
+ info: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>'
132
+ };
133
+
134
+ toast.innerHTML = `
135
+ <div class="toast-icon">${icons[type]}</div>
136
+ <div class="toast-content">
137
+ <div class="toast-title">${title}</div>
138
+ ${message ? `<div class="toast-message">${message}</div>` : ''}
139
+ </div>
140
+ <button class="toast-close" onclick="this.parentElement.remove()">
141
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
142
+ </button>
143
+ `;
144
+
145
+ container.appendChild(toast);
146
+
147
+ setTimeout(() => {
148
+ toast.classList.add('removing');
149
+ setTimeout(() => toast.remove(), 300);
150
+ }, duration);
151
+ }
152
+
153
+ // ============================================
154
+ // Status Check
155
+ // ============================================
156
+
36
157
  async function checkStatus() {
158
+ const el = document.getElementById('connectionStatus');
37
159
  try {
38
- const res = await fetch('/api/status');
39
- const data = await res.json();
40
-
41
- document.getElementById('statusDot')?.classList.add('online');
42
- document.getElementById('statusText').textContent = `Running on port ${data.port}`;
43
- } catch (e) {
44
- document.getElementById('statusDot')?.classList.remove('online');
45
- document.getElementById('statusText').textContent = 'Offline';
160
+ const res = await fetch(`${API}/api/status`);
161
+ if (res.ok) {
162
+ el.className = 'connection-status connected';
163
+ el.querySelector('.status-text').textContent = 'Connected';
164
+ } else {
165
+ el.className = 'connection-status error';
166
+ el.querySelector('.status-text').textContent = 'Error';
167
+ }
168
+ } catch {
169
+ el.className = 'connection-status error';
170
+ el.querySelector('.status-text').textContent = 'Offline';
46
171
  }
47
172
  }
48
173
 
49
- // ==================== CONFIG PAGE ====================
174
+ // ============================================
175
+ // Config Page
176
+ // ============================================
177
+
50
178
  async function loadConfig() {
51
179
  try {
52
- const res = await fetch('/api/config');
53
- const config = await res.json();
180
+ const res = await fetch(`${API}/api/config`);
181
+ const data = await res.json();
182
+ currentConfig = data;
54
183
 
55
- if (config.mode) {
56
- selectMode(config.mode);
184
+ if (data.workspacePath) {
185
+ document.getElementById('workspace-path').value = data.workspacePath;
57
186
  }
58
- if (config.workspacePath) {
59
- document.getElementById('workspace-path').value = config.workspacePath;
187
+ if (data.mode) {
188
+ selectMode(data.mode);
60
189
  }
61
- if (config.hostUrl) {
62
- document.getElementById('host-url').value = config.hostUrl;
190
+ if (data.hostUrl) {
191
+ document.getElementById('host-url').value = data.hostUrl;
63
192
  }
64
- } catch (e) {
65
- console.log('Failed to load config');
193
+ } catch {
194
+ // Use defaults
66
195
  }
67
196
  }
68
197
 
69
198
  function selectMode(mode) {
70
- currentMode = mode;
71
- document.querySelectorAll('.mode-card').forEach(c => c.classList.remove('selected'));
72
- document.querySelector(`[data-mode="${mode}"]`)?.classList.add('selected');
199
+ currentConfig.mode = mode;
200
+
201
+ document.querySelectorAll('.mode-card').forEach(card => {
202
+ card.classList.toggle('selected', card.dataset.mode === mode);
203
+ });
73
204
 
74
205
  document.getElementById('host-config').classList.toggle('hidden', mode !== 'host');
75
206
  document.getElementById('remote-config').classList.toggle('hidden', mode !== 'remote');
76
- }
77
207
 
78
- function selectFolder() {
79
- const path = prompt('Enter project folder path:');
80
- if (path) {
81
- document.getElementById('workspace-path').value = path;
82
- }
208
+ document.getElementById('saveBar').classList.remove('hidden');
83
209
  }
84
210
 
85
211
  async function generateInvite() {
86
- const workspacePath = document.getElementById('workspace-path').value;
87
- if (!workspacePath) {
88
- alert('Please enter a project folder path first');
89
- return;
90
- }
91
-
92
212
  try {
93
- const res = await fetch('/api/invite', {
213
+ const res = await fetch(`${API}/api/invite`, {
94
214
  method: 'POST',
95
215
  headers: { 'Content-Type': 'application/json' },
96
216
  body: JSON.stringify({
@@ -99,241 +219,276 @@ async function generateInvite() {
99
219
  expires_in_days: 7
100
220
  })
101
221
  });
102
-
103
222
  const data = await res.json();
223
+
104
224
  if (data.success) {
105
225
  document.getElementById('invite-link').value = data.invite.link;
106
- alert('Invite link generated!');
226
+ showToast('success', 'Invite Generated', 'Link is ready to share');
227
+ } else {
228
+ showToast('error', 'Error', 'Failed to generate invite link');
107
229
  }
108
- } catch (e) {
109
- alert('Error generating invite');
230
+ } catch {
231
+ showToast('error', 'Error', 'Failed to generate invite link');
110
232
  }
111
233
  }
112
234
 
113
- function copyInviteLink() {
114
- const input = document.getElementById('invite-link');
115
- input.select();
116
- navigator.clipboard.writeText(input.value);
117
- alert('Copied!');
118
- }
119
-
120
235
  async function connectToHost() {
121
236
  const hostUrl = document.getElementById('host-url').value;
122
237
  if (!hostUrl) {
123
- alert('Please enter the host invite link');
238
+ showToast('warning', 'Missing URL', 'Please enter the host invite link');
124
239
  return;
125
240
  }
126
241
 
127
242
  try {
128
- await fetch('/api/config', {
129
- method: 'POST',
130
- headers: { 'Content-Type': 'application/json' },
131
- body: JSON.stringify({ mode: 'remote', hostUrl })
132
- });
133
- alert('Connected! Restart OpenCode to apply.');
134
- } catch (e) {
135
- alert('Error connecting');
243
+ const token = hostUrl.split('/invite/')[1];
244
+ if (token) {
245
+ const res = await fetch(`${API}/api/invite/validate/${token}`);
246
+ const result = await res.json();
247
+
248
+ if (result.valid) {
249
+ showToast('success', 'Connected', 'Successfully connected to host');
250
+ } else {
251
+ showToast('error', 'Invalid Link', result.reason || 'This invite link is not valid');
252
+ }
253
+ }
254
+ } catch {
255
+ showToast('error', 'Connection Failed', 'Could not connect to host');
136
256
  }
137
257
  }
138
258
 
139
259
  async function saveConfig() {
140
- const workspacePath = document.getElementById('workspace-path').value;
141
- const hostUrl = document.getElementById('host-url').value;
260
+ const config = {
261
+ mode: currentConfig.mode,
262
+ workspacePath: document.getElementById('workspace-path').value
263
+ };
264
+
265
+ if (config.mode === 'remote') {
266
+ config.hostUrl = document.getElementById('host-url').value;
267
+ }
268
+
269
+ if (!config.workspacePath && config.mode === 'host') {
270
+ showToast('warning', 'Missing Path', 'Please enter the project folder path');
271
+ return;
272
+ }
142
273
 
143
274
  try {
144
- await fetch('/api/config', {
275
+ const res = await fetch(`${API}/api/config`, {
145
276
  method: 'POST',
146
277
  headers: { 'Content-Type': 'application/json' },
147
- body: JSON.stringify({
148
- mode: currentMode,
149
- workspacePath,
150
- hostUrl
151
- })
278
+ body: JSON.stringify(config)
152
279
  });
153
- alert('Configuration saved!');
154
- } catch (e) {
155
- alert('Error saving config');
156
- }
157
- }
280
+ const data = await res.json();
158
281
 
159
- // ==================== DASHBOARD PAGE ====================
160
- async function loadDashboard() {
161
- try {
162
- const [statsRes, teamRes] = await Promise.all([
163
- fetch('/api/stats'),
164
- fetch('/api/team')
165
- ]);
166
-
167
- const stats = await statsRes.json();
168
- const team = await teamRes.json();
169
-
170
- document.getElementById('stat-total-changes').textContent = stats.totalChanges || 0;
171
- document.getElementById('stat-online-users').textContent = stats.onlineUsers || 0;
172
- document.getElementById('stat-active-locks').textContent = stats.activeLocks || 0;
173
- document.getElementById('stat-team-members').textContent = team.members?.length || 0;
174
-
175
- renderActivity(stats.recentActivity || []);
176
- renderOnlineUsers(stats.online || []);
177
- renderChangesByUser(stats.changesByUser || {});
178
- renderActiveLocks(stats.locks || []);
179
- } catch (e) {
180
- console.error('Failed to load dashboard', e);
282
+ if (data.success) {
283
+ showToast('success', 'Saved', 'Configuration saved successfully');
284
+ document.getElementById('saveBar').classList.add('hidden');
285
+ } else {
286
+ showToast('error', 'Error', 'Failed to save configuration');
287
+ }
288
+ } catch {
289
+ showToast('error', 'Error', 'Failed to save configuration');
181
290
  }
182
291
  }
183
292
 
184
- function renderActivity(changes) {
185
- const container = document.getElementById('recent-activity');
186
- if (!changes.length) {
187
- container.innerHTML = '<div class="empty-state"><p>No recent activity</p></div>';
188
- return;
293
+ function copyInviteLink() {
294
+ const input = document.getElementById('invite-link');
295
+ if (input.value) {
296
+ navigator.clipboard.writeText(input.value);
297
+ showToast('success', 'Copied', 'Invite link copied to clipboard');
189
298
  }
190
-
191
- container.innerHTML = changes.map(c => `
192
- <div class="activity-item">
193
- <span class="activity-file">${escapeHtml(c.filePath)}</span>
194
- <span class="activity-action ${c.action}">${c.action}</span>
195
- <span class="activity-user">${escapeHtml(c.userId)}</span>
196
- <span class="activity-time">${formatTime(c.timestamp)}</span>
197
- </div>
198
- `).join('');
199
299
  }
200
300
 
201
- function renderOnlineUsers(users) {
202
- const container = document.getElementById('online-users');
203
- if (!users.length) {
204
- container.innerHTML = '<div class="empty-state"><p>No users online</p></div>';
205
- return;
206
- }
301
+ // ============================================
302
+ // Dashboard Page
303
+ // ============================================
207
304
 
208
- container.innerHTML = users.map(u => `
209
- <div class="user-item">
210
- <div class="user-avatar">${(u.user_id || 'U')[0].toUpperCase()}</div>
211
- <span class="user-name">${escapeHtml(u.user_id)}</span>
212
- <span class="user-status online">Online</span>
213
- </div>
214
- `).join('');
215
- }
305
+ async function loadDashboard() {
306
+ try {
307
+ const res = await fetch(`${API}/api/stats`);
308
+ const data = await res.json();
216
309
 
217
- function renderChangesByUser(data) {
218
- const container = document.getElementById('changes-by-user');
219
- const entries = Object.entries(data);
310
+ // Animate stat values
311
+ animateValue('stat-total-changes', data.totalChanges || 0);
312
+ animateValue('stat-online-users', data.onlineUsers || 0);
313
+ animateValue('stat-active-locks', data.activeLocks || 0);
314
+
315
+ // Load team count
316
+ const teamRes = await fetch(`${API}/api/team`);
317
+ const teamData = await teamRes.json();
318
+ animateValue('stat-team-members', (teamData.members || []).length);
319
+
320
+ // Recent activity
321
+ const activityContainer = document.getElementById('recent-activity');
322
+ const recent = data.recentActivity || [];
323
+
324
+ if (recent.length === 0) {
325
+ activityContainer.innerHTML = '<div class="empty-state"><p>No activity yet</p></div>';
326
+ } else {
327
+ activityContainer.innerHTML = recent.map((item, i) => `
328
+ <div class="activity-item" style="animation-delay: ${i * 0.05}s">
329
+ <div class="activity-icon ${item.action}">${item.action.charAt(0).toUpperCase()}</div>
330
+ <div class="activity-info">
331
+ <div class="activity-file">${item.filePath}</div>
332
+ <div class="activity-meta">${item.userId} - ${formatTime(item.timestamp)}</div>
333
+ </div>
334
+ </div>
335
+ `).join('');
336
+ }
220
337
 
221
- if (!entries.length) {
222
- container.innerHTML = '<div class="empty-state"><p>No data</p></div>';
223
- return;
224
- }
338
+ // Online users
339
+ const onlineContainer = document.getElementById('online-users');
340
+ const online = data.online || [];
341
+
342
+ if (online.length === 0) {
343
+ onlineContainer.innerHTML = '<div class="empty-state"><p>No users online</p></div>';
344
+ } else {
345
+ onlineContainer.innerHTML = online.map((user, i) => `
346
+ <div class="user-item" style="animation-delay: ${i * 0.05}s">
347
+ <div class="user-avatar" style="background: ${getAvatarColor(user.userId)}">${user.userId.charAt(0).toUpperCase()}</div>
348
+ <div class="user-info">
349
+ <div class="user-name">${user.userId}</div>
350
+ <div class="user-status online">Online</div>
351
+ </div>
352
+ </div>
353
+ `).join('');
354
+ }
225
355
 
226
- const max = Math.max(...entries.map(([, v]) => v));
227
-
228
- container.innerHTML = entries.map(([user, count]) => `
229
- <div style="margin-bottom: 0.5rem;">
230
- <div style="display: flex; justify-content: space-between; margin-bottom: 0.25rem;">
231
- <span>${escapeHtml(user)}</span>
232
- <span>${count}</span>
233
- </div>
234
- <div style="background: var(--bg-primary); border-radius: 4px; height: 8px;">
235
- <div style="background: var(--accent); height: 100%; width: ${(count / max) * 100}%; border-radius: 4px;"></div>
236
- </div>
237
- </div>
238
- `).join('');
356
+ // Active locks
357
+ const locksContainer = document.getElementById('active-locks');
358
+ const locks = data.locks || [];
359
+
360
+ if (locks.length === 0) {
361
+ locksContainer.innerHTML = '<div class="empty-state"><p>No active locks</p></div>';
362
+ } else {
363
+ locksContainer.innerHTML = locks.map((lock, i) => `
364
+ <div class="lock-item" style="animation-delay: ${i * 0.05}s">
365
+ <div class="lock-icon">
366
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
367
+ </div>
368
+ <div class="lock-info">
369
+ <div class="lock-file">${lock.filePath}</div>
370
+ <div class="lock-meta">${lock.userId} - expires ${formatTime(lock.expiresAt)}</div>
371
+ </div>
372
+ </div>
373
+ `).join('');
374
+ }
375
+ } catch {
376
+ showToast('error', 'Error', 'Failed to load dashboard data');
377
+ }
239
378
  }
240
379
 
241
- function renderActiveLocks(locks) {
242
- const container = document.getElementById('active-locks');
243
- if (!locks.length) {
244
- container.innerHTML = '<div class="empty-state"><p>No active locks</p></div>';
245
- return;
380
+ function animateValue(elementId, end) {
381
+ const el = document.getElementById(elementId);
382
+ const start = parseInt(el.textContent) || 0;
383
+ const duration = 600;
384
+ const startTime = performance.now();
385
+
386
+ function update(currentTime) {
387
+ const elapsed = currentTime - startTime;
388
+ const progress = Math.min(elapsed / duration, 1);
389
+ const eased = 1 - Math.pow(1 - progress, 3);
390
+ el.textContent = Math.round(start + (end - start) * eased);
391
+
392
+ if (progress < 1) {
393
+ requestAnimationFrame(update);
394
+ }
246
395
  }
247
396
 
248
- container.innerHTML = locks.map(l => `
249
- <div class="activity-item">
250
- <span class="activity-file">${escapeHtml(l.filePath)}</span>
251
- <span class="activity-user">${escapeHtml(l.userId)}</span>
252
- <span class="activity-time">${l.reason || 'No reason'}</span>
253
- </div>
254
- `).join('');
397
+ requestAnimationFrame(update);
255
398
  }
256
399
 
257
- // ==================== CHANGES PAGE ====================
400
+ // ============================================
401
+ // Changes Page
402
+ // ============================================
403
+
258
404
  async function loadChanges() {
259
- const filePath = document.getElementById('filter-file')?.value;
260
- const userId = document.getElementById('filter-user')?.value;
405
+ const filePath = document.getElementById('filter-file').value;
406
+ const userId = document.getElementById('filter-user').value;
261
407
 
262
408
  try {
263
409
  const params = new URLSearchParams();
264
410
  if (filePath) params.set('file_path', filePath);
265
411
  if (userId) params.set('user_id', userId);
266
- params.set('limit', '100');
412
+ params.set('limit', '50');
267
413
 
268
- const res = await fetch(`/api/changes?${params}`);
414
+ const res = await fetch(`${API}/api/changes?${params}`);
269
415
  const data = await res.json();
270
416
 
271
- renderChanges(data.changes || []);
272
- } catch (e) {
273
- console.error('Failed to load changes', e);
417
+ const container = document.getElementById('changes-list');
418
+ const changes = data.changes || [];
419
+
420
+ if (changes.length === 0) {
421
+ container.innerHTML = `
422
+ <div class="empty-state">
423
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity: 0.3">
424
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
425
+ </svg>
426
+ <p>No changes recorded yet</p>
427
+ </div>
428
+ `;
429
+ } else {
430
+ container.innerHTML = changes.map((item, i) => `
431
+ <div class="change-item" style="animation-delay: ${i * 0.03}s">
432
+ <span class="change-action ${item.action}">${item.action}</span>
433
+ <span class="change-file">${item.filePath}</span>
434
+ <span class="change-user">${item.userId}</span>
435
+ <span class="change-time">${formatTime(item.timestamp)}</span>
436
+ </div>
437
+ `).join('');
438
+ }
439
+ } catch {
440
+ showToast('error', 'Error', 'Failed to load changes');
274
441
  }
275
442
  }
276
443
 
277
- function renderChanges(changes) {
278
- const container = document.getElementById('changes-list');
279
- if (!changes.length) {
280
- container.innerHTML = '<div class="empty-state"><p>No changes recorded yet</p></div>';
281
- return;
282
- }
444
+ // ============================================
445
+ // Team Page
446
+ // ============================================
283
447
 
284
- container.innerHTML = changes.map(c => `
285
- <div class="change-item">
286
- <span class="change-badge ${c.action}">${c.action}</span>
287
- <span class="change-path">${escapeHtml(c.filePath)}</span>
288
- <span class="change-user">${escapeHtml(c.userId)}</span>
289
- <span class="change-time">${formatTime(c.timestamp)}</span>
290
- </div>
291
- `).join('');
292
- }
293
-
294
- // ==================== TEAM PAGE ====================
295
448
  async function loadTeam() {
296
449
  try {
297
- const res = await fetch('/api/team');
450
+ const res = await fetch(`${API}/api/team`);
298
451
  const data = await res.json();
299
452
 
300
- renderTeamMembers(data.members || []);
301
- renderTeamOnline(data.online || []);
302
- } catch (e) {
303
- console.error('Failed to load team', e);
304
- }
305
- }
306
-
307
- function renderTeamMembers(members) {
308
- const container = document.getElementById('team-members');
309
- if (!members.length) {
310
- container.innerHTML = '<div class="empty-state"><p>No team members yet</p></div>';
311
- return;
312
- }
313
-
314
- container.innerHTML = members.map(m => `
315
- <div class="user-item">
316
- <div class="user-avatar">${(m.user_id || 'U')[0].toUpperCase()}</div>
317
- <span class="user-name">${escapeHtml(m.user_id)}</span>
318
- <span class="user-status">${m.permissions || 'read,write'}</span>
319
- </div>
320
- `).join('');
321
- }
453
+ // Members
454
+ const membersContainer = document.getElementById('team-members');
455
+ const members = data.members || [];
456
+
457
+ if (members.length === 0) {
458
+ membersContainer.innerHTML = '<div class="empty-state"><p>No team members yet</p></div>';
459
+ } else {
460
+ membersContainer.innerHTML = members.map((member, i) => `
461
+ <div class="member-item" style="animation-delay: ${i * 0.05}s">
462
+ <div class="user-avatar" style="background: ${getAvatarColor(member.userId || 'U')}">${(member.userId || 'U').charAt(0).toUpperCase()}</div>
463
+ <div class="user-info">
464
+ <div class="user-name">${member.email || member.userId || 'Unknown'}</div>
465
+ <div class="user-status">${member.role || 'member'}</div>
466
+ </div>
467
+ <span class="member-role">${(member.permissions || 'read').split(',').join(' + ')}</span>
468
+ </div>
469
+ `).join('');
470
+ }
322
471
 
323
- function renderTeamOnline(users) {
324
- const container = document.getElementById('team-online');
325
- if (!users.length) {
326
- container.innerHTML = '<div class="empty-state"><p>No users online</p></div>';
327
- return;
472
+ // Online
473
+ const onlineContainer = document.getElementById('team-online');
474
+ const online = data.online || [];
475
+
476
+ if (online.length === 0) {
477
+ onlineContainer.innerHTML = '<div class="empty-state"><p>No users online</p></div>';
478
+ } else {
479
+ onlineContainer.innerHTML = online.map((user, i) => `
480
+ <div class="user-item" style="animation-delay: ${i * 0.05}s">
481
+ <div class="user-avatar" style="background: ${getAvatarColor(user.userId)}">${user.userId.charAt(0).toUpperCase()}</div>
482
+ <div class="user-info">
483
+ <div class="user-name">${user.userId}</div>
484
+ <div class="user-status online">Online</div>
485
+ </div>
486
+ </div>
487
+ `).join('');
488
+ }
489
+ } catch {
490
+ showToast('error', 'Error', 'Failed to load team data');
328
491
  }
329
-
330
- container.innerHTML = users.map(u => `
331
- <div class="user-item">
332
- <div class="user-avatar">${(u.user_id || 'U')[0].toUpperCase()}</div>
333
- <span class="user-name">${escapeHtml(u.user_id)}</span>
334
- <span class="user-status online">Online</span>
335
- </div>
336
- `).join('');
337
492
  }
338
493
 
339
494
  async function createInvite() {
@@ -342,12 +497,12 @@ async function createInvite() {
342
497
  const days = parseInt(document.getElementById('invite-days').value) || 7;
343
498
 
344
499
  if (!email) {
345
- alert('Please enter an email');
500
+ showToast('warning', 'Missing Email', 'Please enter an email address');
346
501
  return;
347
502
  }
348
503
 
349
504
  try {
350
- const res = await fetch('/api/invite', {
505
+ const res = await fetch(`${API}/api/invite`, {
351
506
  method: 'POST',
352
507
  headers: { 'Content-Type': 'application/json' },
353
508
  body: JSON.stringify({
@@ -356,91 +511,100 @@ async function createInvite() {
356
511
  expires_in_days: days
357
512
  })
358
513
  });
359
-
360
514
  const data = await res.json();
515
+
361
516
  if (data.success) {
362
517
  document.getElementById('generated-invite').value = data.invite.link;
363
518
  document.getElementById('invite-result').classList.remove('hidden');
519
+ showToast('success', 'Invite Created', 'Share the link with your team member');
520
+ } else {
521
+ showToast('error', 'Error', 'Failed to create invite');
364
522
  }
365
- } catch (e) {
366
- alert('Error creating invite');
523
+ } catch {
524
+ showToast('error', 'Error', 'Failed to create invite');
367
525
  }
368
526
  }
369
527
 
370
528
  function copyGeneratedInvite() {
371
529
  const input = document.getElementById('generated-invite');
372
- input.select();
373
- navigator.clipboard.writeText(input.value);
374
- alert('Copied!');
530
+ if (input.value) {
531
+ navigator.clipboard.writeText(input.value);
532
+ showToast('success', 'Copied', 'Invite link copied to clipboard');
533
+ }
375
534
  }
376
535
 
377
- // ==================== LOCKS PAGE ====================
536
+ // ============================================
537
+ // Locks Page
538
+ // ============================================
539
+
378
540
  async function loadLocks() {
379
541
  try {
380
- const res = await fetch('/api/locks');
542
+ const res = await fetch(`${API}/api/locks`);
381
543
  const data = await res.json();
382
544
 
383
- renderLocks(data.locks || []);
384
- } catch (e) {
385
- console.error('Failed to load locks', e);
386
- }
387
- }
388
-
389
- function renderLocks(locks) {
390
- const container = document.getElementById('locks-list');
391
- if (!locks.length) {
392
- container.innerHTML = '<div class="empty-state"><p>No active locks</p></div>';
393
- return;
394
- }
395
-
396
- container.innerHTML = `
397
- <table>
398
- <thead>
399
- <tr>
400
- <th>File</th>
401
- <th>Locked By</th>
402
- <th>Reason</th>
403
- <th>Acquired</th>
404
- <th>Expires</th>
405
- </tr>
406
- </thead>
407
- <tbody>
408
- ${locks.map(l => `
409
- <tr>
410
- <td>${escapeHtml(l.filePath)}</td>
411
- <td>${escapeHtml(l.userId)}</td>
412
- <td>${escapeHtml(l.reason || '-')}</td>
413
- <td>${formatTime(l.acquiredAt)}</td>
414
- <td>${formatTime(l.expiresAt)}</td>
415
- </tr>
545
+ const container = document.getElementById('locks-list');
546
+ const locks = data.locks || [];
547
+
548
+ if (locks.length === 0) {
549
+ container.innerHTML = `
550
+ <div class="empty-state">
551
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity: 0.3">
552
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
553
+ </svg>
554
+ <p>No active locks</p>
555
+ </div>
556
+ `;
557
+ } else {
558
+ container.innerHTML = `
559
+ <div class="locks-table-header">
560
+ <span>File</span>
561
+ <span>Locked By</span>
562
+ <span>Reason</span>
563
+ <span>Expires</span>
564
+ </div>
565
+ ${locks.map((lock, i) => `
566
+ <div class="locks-table-row" style="animation-delay: ${i * 0.05}s">
567
+ <span class="lock-file">${lock.filePath}</span>
568
+ <span>${lock.userId}</span>
569
+ <span>${lock.reason || '-'}</span>
570
+ <span>${formatTime(lock.expiresAt)}</span>
571
+ </div>
416
572
  `).join('')}
417
- </tbody>
418
- </table>
419
- `;
573
+ `;
574
+ }
575
+ } catch {
576
+ showToast('error', 'Error', 'Failed to load locks');
577
+ }
420
578
  }
421
579
 
422
- // ==================== UTILITIES ====================
423
- function escapeHtml(str) {
424
- if (!str) return '';
425
- const div = document.createElement('div');
426
- div.textContent = str;
427
- return div.innerHTML;
428
- }
580
+ // ============================================
581
+ // Helpers
582
+ // ============================================
429
583
 
430
- function formatTime(ts) {
431
- if (!ts) return '-';
432
- const d = new Date(ts);
584
+ function formatTime(timestamp) {
585
+ if (!timestamp) return '-';
586
+ const date = new Date(timestamp);
433
587
  const now = new Date();
434
- const diff = now - d;
588
+ const diff = now - date;
435
589
 
436
590
  if (diff < 60000) return 'Just now';
437
591
  if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
438
592
  if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
439
-
440
- return d.toLocaleDateString();
593
+ return date.toLocaleDateString();
441
594
  }
442
595
 
443
- // ==================== INIT ====================
444
- checkStatus();
445
- loadConfig();
446
- setInterval(checkStatus, 30000);
596
+ function getAvatarColor(str) {
597
+ const colors = [
598
+ 'linear-gradient(135deg, #8b5cf6, #06b6d4)',
599
+ 'linear-gradient(135deg, #10b981, #06b6d4)',
600
+ 'linear-gradient(135deg, #f59e0b, #ef4444)',
601
+ 'linear-gradient(135deg, #ec4899, #8b5cf6)',
602
+ 'linear-gradient(135deg, #06b6d4, #3b82f6)',
603
+ 'linear-gradient(135deg, #8b5cf6, #ec4899)',
604
+ ];
605
+ let hash = 0;
606
+ for (let i = 0; i < str.length; i++) {
607
+ hash = str.charCodeAt(i) + ((hash << 5) - hash);
608
+ }
609
+ return colors[Math.abs(hash) % colors.length];
610
+ }