@opencoop/opencode-plugin 1.0.0 → 1.1.1
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/dist/auth/auth-manager.d.ts +2 -0
- package/dist/auth/auth-manager.js +24 -1
- package/dist/auth/auth-manager.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +33 -6
- package/dist/index.js.map +1 -1
- package/dist/server/mcp-server.js +31 -15
- package/dist/server/mcp-server.js.map +1 -1
- package/dist/utils/config.js +2 -4
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/logger.js +9 -8
- package/dist/utils/logger.js.map +1 -1
- package/dist/web/public/assets/app.js +460 -296
- package/dist/web/public/assets/style.css +1175 -339
- package/dist/web/public/index.html +397 -144
- package/dist/web/public/invite.html +258 -87
- package/dist/web/server.js +1 -0
- package/dist/web/server.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,96 +1,216 @@
|
|
|
1
|
-
//
|
|
1
|
+
// ============================================
|
|
2
|
+
// OpenCOOP - Main Application
|
|
3
|
+
// ============================================
|
|
4
|
+
|
|
5
|
+
const API = '';
|
|
2
6
|
let currentPage = 'config';
|
|
3
|
-
let
|
|
4
|
-
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
24
|
+
// ============================================
|
|
25
|
+
// Navigation
|
|
26
|
+
// ============================================
|
|
17
27
|
|
|
18
|
-
|
|
19
|
-
document.
|
|
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
|
-
|
|
75
|
+
function loadPageData(page) {
|
|
26
76
|
switch (page) {
|
|
27
|
-
case 'dashboard':
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
case '
|
|
31
|
-
|
|
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
|
-
//
|
|
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(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
//
|
|
174
|
+
// ============================================
|
|
175
|
+
// Config Page
|
|
176
|
+
// ============================================
|
|
177
|
+
|
|
50
178
|
async function loadConfig() {
|
|
51
179
|
try {
|
|
52
|
-
const res = await fetch(
|
|
53
|
-
const
|
|
180
|
+
const res = await fetch(`${API}/api/config`);
|
|
181
|
+
const data = await res.json();
|
|
182
|
+
currentConfig = data;
|
|
54
183
|
|
|
55
|
-
if (
|
|
56
|
-
|
|
184
|
+
if (data.workspacePath) {
|
|
185
|
+
document.getElementById('workspace-path').value = data.workspacePath;
|
|
57
186
|
}
|
|
58
|
-
if (
|
|
59
|
-
|
|
187
|
+
if (data.mode) {
|
|
188
|
+
selectMode(data.mode);
|
|
60
189
|
}
|
|
61
|
-
if (
|
|
62
|
-
document.getElementById('host-url').value =
|
|
190
|
+
if (data.hostUrl) {
|
|
191
|
+
document.getElementById('host-url').value = data.hostUrl;
|
|
63
192
|
}
|
|
64
|
-
} catch
|
|
65
|
-
|
|
193
|
+
} catch {
|
|
194
|
+
// Use defaults
|
|
66
195
|
}
|
|
67
196
|
}
|
|
68
197
|
|
|
69
198
|
function selectMode(mode) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
document.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
109
|
-
|
|
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
|
-
|
|
238
|
+
showToast('warning', 'Missing URL', 'Please enter the host invite link');
|
|
124
239
|
return;
|
|
125
240
|
}
|
|
126
241
|
|
|
127
242
|
try {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
141
|
-
|
|
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(
|
|
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
|
-
|
|
154
|
-
} catch (e) {
|
|
155
|
-
alert('Error saving config');
|
|
156
|
-
}
|
|
157
|
-
}
|
|
280
|
+
const data = await res.json();
|
|
158
281
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
185
|
-
const
|
|
186
|
-
if (
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
container.innerHTML = '<div class="empty-state"><p>No users online</p></div>';
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
301
|
+
// ============================================
|
|
302
|
+
// Dashboard Page
|
|
303
|
+
// ============================================
|
|
207
304
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
400
|
+
// ============================================
|
|
401
|
+
// Changes Page
|
|
402
|
+
// ============================================
|
|
403
|
+
|
|
258
404
|
async function loadChanges() {
|
|
259
|
-
const filePath = document.getElementById('filter-file')
|
|
260
|
-
const userId = document.getElementById('filter-user')
|
|
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', '
|
|
412
|
+
params.set('limit', '50');
|
|
267
413
|
|
|
268
|
-
const res = await fetch(
|
|
414
|
+
const res = await fetch(`${API}/api/changes?${params}`);
|
|
269
415
|
const data = await res.json();
|
|
270
416
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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(
|
|
450
|
+
const res = await fetch(`${API}/api/team`);
|
|
298
451
|
const data = await res.json();
|
|
299
452
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
366
|
-
|
|
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.
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
//
|
|
536
|
+
// ============================================
|
|
537
|
+
// Locks Page
|
|
538
|
+
// ============================================
|
|
539
|
+
|
|
378
540
|
async function loadLocks() {
|
|
379
541
|
try {
|
|
380
|
-
const res = await fetch(
|
|
542
|
+
const res = await fetch(`${API}/api/locks`);
|
|
381
543
|
const data = await res.json();
|
|
382
544
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
<
|
|
401
|
-
<
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
<
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
573
|
+
`;
|
|
574
|
+
}
|
|
575
|
+
} catch {
|
|
576
|
+
showToast('error', 'Error', 'Failed to load locks');
|
|
577
|
+
}
|
|
420
578
|
}
|
|
421
579
|
|
|
422
|
-
//
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
const div = document.createElement('div');
|
|
426
|
-
div.textContent = str;
|
|
427
|
-
return div.innerHTML;
|
|
428
|
-
}
|
|
580
|
+
// ============================================
|
|
581
|
+
// Helpers
|
|
582
|
+
// ============================================
|
|
429
583
|
|
|
430
|
-
function formatTime(
|
|
431
|
-
if (!
|
|
432
|
-
const
|
|
584
|
+
function formatTime(timestamp) {
|
|
585
|
+
if (!timestamp) return '-';
|
|
586
|
+
const date = new Date(timestamp);
|
|
433
587
|
const now = new Date();
|
|
434
|
-
const diff = now -
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
+
}
|