mohsin-devs commited on
Commit
4e3ead6
·
1 Parent(s): 2642f13

Add interactive link storage section

Browse files
Files changed (5) hide show
  1. index.html +47 -18
  2. js/api/hfService.js +48 -0
  3. js/main.js +339 -191
  4. js/state/stateManager.js +9 -2
  5. styles.css +134 -2
index.html CHANGED
@@ -106,26 +106,55 @@
106
  </div>
107
 
108
  <div class="content-area" id="contentArea">
109
- <div class="section-header">
110
- <h2>Folders</h2>
111
- <div class="view-toggles">
112
- <button class="icon-btn active" id="viewGrid" title="Grid"><i class="ph-fill ph-squares-four"></i></button>
113
- <button class="icon-btn" id="viewList" title="List"><i class="ph-fill ph-list-dashes"></i></button>
 
 
114
  </div>
115
- </div>
116
- <div class="grid-container" id="foldersContainer"></div>
117
-
118
- <div class="section-header mt-8">
119
- <h2>Files</h2>
120
- </div>
121
- <div class="grid-container" id="filesContainer"></div>
122
 
123
- <!-- Links Section -->
124
- <div id="linksSection" style="display:none;">
125
  <div class="section-header mt-8">
126
- <h2>Your Links</h2>
127
  </div>
128
- <div class="links-container" id="linksContainer"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  </div>
130
  </div>
131
  </main>
@@ -183,7 +212,7 @@
183
  <div class="modal-overlay" id="addLinkModal">
184
  <div class="modal glass-panel" style="max-width:500px">
185
  <button class="close-modal" id="closeAddLinkModal"><i class="ph-bold ph-x"></i></button>
186
- <h3><i class="ph-fill ph-link-plus" style="color:var(--primary-color);margin-right:10px"></i>Add Link</h3>
187
  <div class="input-group">
188
  <label class="input-label">URL *</label>
189
  <input type="url" id="linkUrlInput" placeholder="https://example.com" autocomplete="off">
@@ -198,7 +227,7 @@
198
  </div>
199
  <div class="modal-footer">
200
  <button class="btn-secondary" id="cancelLinkBtn">Cancel</button>
201
- <button class="btn-primary" id="confirmLinkBtn"><i class="ph-fill ph-check"></i> Add Link</button>
202
  </div>
203
  </div>
204
  </div>
 
106
  </div>
107
 
108
  <div class="content-area" id="contentArea">
109
+ <div id="filesView">
110
+ <div class="section-header">
111
+ <h2>Folders</h2>
112
+ <div class="view-toggles">
113
+ <button class="icon-btn active" id="viewGrid" title="Grid"><i class="ph-fill ph-squares-four"></i></button>
114
+ <button class="icon-btn" id="viewList" title="List"><i class="ph-fill ph-list-dashes"></i></button>
115
+ </div>
116
  </div>
117
+ <div class="grid-container" id="foldersContainer"></div>
 
 
 
 
 
 
118
 
 
 
119
  <div class="section-header mt-8">
120
+ <h2>Files</h2>
121
  </div>
122
+ <div class="grid-container" id="filesContainer"></div>
123
+ </div>
124
+
125
+ <div id="linksView" class="links-view" style="display:none;">
126
+ <section class="links-hero">
127
+ <div>
128
+ <span class="links-eyebrow">Interactive Link Storage</span>
129
+ <h2>Save important websites alongside your documents</h2>
130
+ <p>Collect references, dashboards, and research links in one searchable place.</p>
131
+ </div>
132
+ <button class="btn-primary" id="heroAddLinkBtn">
133
+ <i class="ph-fill ph-link-plus"></i> Add Link
134
+ </button>
135
+ </section>
136
+
137
+ <section class="links-toolbar">
138
+ <div class="links-stat-card">
139
+ <span class="links-stat-label">Stored Links</span>
140
+ <strong id="linksCount">0</strong>
141
+ </div>
142
+ <div class="links-stat-card">
143
+ <span class="links-stat-label">Latest Update</span>
144
+ <strong id="linksUpdatedAt">Just now</strong>
145
+ </div>
146
+ <div class="links-search">
147
+ <i class="ph-bold ph-magnifying-glass"></i>
148
+ <input type="text" id="linksSearchInput" placeholder="Search title, URL, or notes...">
149
+ </div>
150
+ </section>
151
+
152
+ <section id="linksSection">
153
+ <div class="section-header">
154
+ <h2>Your Links</h2>
155
+ </div>
156
+ <div class="links-container" id="linksContainer"></div>
157
+ </section>
158
  </div>
159
  </div>
160
  </main>
 
212
  <div class="modal-overlay" id="addLinkModal">
213
  <div class="modal glass-panel" style="max-width:500px">
214
  <button class="close-modal" id="closeAddLinkModal"><i class="ph-bold ph-x"></i></button>
215
+ <h3 id="addLinkModalTitle"><i class="ph-fill ph-link-plus" style="color:var(--primary-color);margin-right:10px"></i>Add Link</h3>
216
  <div class="input-group">
217
  <label class="input-label">URL *</label>
218
  <input type="url" id="linkUrlInput" placeholder="https://example.com" autocomplete="off">
 
227
  </div>
228
  <div class="modal-footer">
229
  <button class="btn-secondary" id="cancelLinkBtn">Cancel</button>
230
+ <button class="btn-primary" id="confirmLinkBtn"><i class="ph-fill ph-check"></i> Save Link</button>
231
  </div>
232
  </div>
233
  </div>
js/api/hfService.js CHANGED
@@ -238,6 +238,54 @@ class HFService {
238
  return await res.json();
239
  }
240
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  clearCache() {
242
  this.cache.clear();
243
  }
 
238
  return await res.json();
239
  }
240
 
241
+ async listLinks() {
242
+ const apiBase = await this.getApiBase();
243
+ const res = await this.fetchWithRetry(`${apiBase}/links/list`, {
244
+ headers: { 'X-User-ID': 'default_user' }
245
+ });
246
+ const data = await res.json();
247
+ return Array.isArray(data.links) ? data.links : [];
248
+ }
249
+
250
+ async addLink(payload) {
251
+ const apiBase = await this.getApiBase();
252
+ const res = await this.fetchWithRetry(`${apiBase}/links/add`, {
253
+ method: 'POST',
254
+ headers: {
255
+ 'Content-Type': 'application/json',
256
+ 'X-User-ID': 'default_user'
257
+ },
258
+ body: JSON.stringify(payload)
259
+ });
260
+ return await res.json();
261
+ }
262
+
263
+ async updateLink(payload) {
264
+ const apiBase = await this.getApiBase();
265
+ const res = await this.fetchWithRetry(`${apiBase}/links/update`, {
266
+ method: 'POST',
267
+ headers: {
268
+ 'Content-Type': 'application/json',
269
+ 'X-User-ID': 'default_user'
270
+ },
271
+ body: JSON.stringify(payload)
272
+ });
273
+ return await res.json();
274
+ }
275
+
276
+ async deleteLink(linkId) {
277
+ const apiBase = await this.getApiBase();
278
+ const res = await this.fetchWithRetry(`${apiBase}/links/delete`, {
279
+ method: 'POST',
280
+ headers: {
281
+ 'Content-Type': 'application/json',
282
+ 'X-User-ID': 'default_user'
283
+ },
284
+ body: JSON.stringify({ link_id: linkId })
285
+ });
286
+ return await res.json();
287
+ }
288
+
289
  clearCache() {
290
  this.cache.clear();
291
  }
js/main.js CHANGED
@@ -10,6 +10,7 @@ class App {
10
  this.hf = hfService;
11
  this.pendingDelete = null;
12
  this.pendingRename = null;
 
13
  this.cachedFolders = [];
14
  this.currentPreviewObjectUrl = null;
15
  this.init();
@@ -26,8 +27,13 @@ class App {
26
  setupNetworkHandling() {
27
  window.addEventListener('online', () => {
28
  this.ui.showToast('Back online! Syncing...', 'success');
29
- this.fetchAndRender();
 
 
 
 
30
  });
 
31
  window.addEventListener('offline', () => {
32
  this.ui.showToast('You are offline. Some features may be limited.', 'warning');
33
  });
@@ -37,15 +43,17 @@ class App {
37
  const area = document.getElementById('contentArea');
38
  if (!area) return;
39
 
40
- ['dragenter', 'dragover'].forEach(evt => {
41
  area.addEventListener(evt, (e) => {
42
  e.preventDefault();
43
  e.stopPropagation();
44
- area.classList.add('drag-over');
 
 
45
  });
46
  });
47
 
48
- ['dragleave', 'drop'].forEach(evt => {
49
  area.addEventListener(evt, (e) => {
50
  e.preventDefault();
51
  e.stopPropagation();
@@ -54,6 +62,7 @@ class App {
54
  });
55
 
56
  area.addEventListener('drop', (e) => {
 
57
  const files = e.dataTransfer.files;
58
  if (files.length > 0) {
59
  this.uploadFiles(files);
@@ -72,58 +81,67 @@ class App {
72
  }
73
  };
74
 
75
- // Nav
76
  document.getElementById('navMyFiles').onclick = (e) => {
77
  e.preventDefault();
78
  this.state.setBrowseMode('files');
79
  this.state.setPath([]);
80
  this.fetchAndRender();
81
  };
 
82
  document.getElementById('navRecent').onclick = (e) => {
83
  e.preventDefault();
84
  this.state.setBrowseMode('recent');
85
  this.render();
86
  };
 
87
  document.getElementById('navStarred').onclick = (e) => {
88
  e.preventDefault();
89
  this.state.setBrowseMode('starred');
90
  this.render();
91
  };
 
92
  document.getElementById('navLinks').onclick = (e) => {
93
  e.preventDefault();
94
  this.state.setBrowseMode('links');
95
  this.fetchAndRenderLinks();
96
- this.updateActiveNavItem();
97
- document.getElementById('contentArea').innerHTML = '';
98
- document.getElementById('linksSection').style.display = 'block';
99
- document.getElementById('breadcrumbs').innerHTML = '<span class="breadcrumb-item active">Links</span>';
100
  };
101
 
102
- // View Toggles
103
  document.getElementById('viewGrid').onclick = () => this.state.setViewMode('grid');
104
  document.getElementById('viewList').onclick = () => this.state.setViewMode('list');
105
 
106
- // Search
107
  let searchDebounce;
108
  document.getElementById('searchInput').oninput = (e) => {
 
109
  clearTimeout(searchDebounce);
110
  searchDebounce = setTimeout(() => {
111
- this.state.setSearchQuery(e.target.value.trim());
 
 
 
 
 
 
112
  this.fetchAndRender();
113
- }, 400);
 
 
 
 
 
114
  };
115
 
116
- // New actions
117
  document.getElementById('newBtn').onclick = (e) => {
118
  e.stopPropagation();
119
  document.getElementById('newDropdown').classList.toggle('active');
120
  };
 
121
  document.getElementById('uploadFileBtn').onclick = (e) => {
122
  e.preventDefault();
123
  e.stopPropagation();
124
  document.getElementById('newDropdown').classList.remove('active');
125
  document.getElementById('fileInput').click();
126
  };
 
127
  document.getElementById('createFolderBtn').onclick = (e) => {
128
  e.preventDefault();
129
  e.stopPropagation();
@@ -132,67 +150,66 @@ class App {
132
  document.getElementById('folderNameInput').value = '';
133
  document.getElementById('folderNameInput').focus();
134
  };
 
135
  document.getElementById('addLinkBtn').onclick = (e) => {
136
  e.preventDefault();
137
  e.stopPropagation();
138
  document.getElementById('newDropdown').classList.remove('active');
139
- document.getElementById('addLinkModal').classList.add('active');
140
- document.getElementById('linkUrlInput').focus();
141
  };
142
 
143
- // File Input
 
144
  document.getElementById('fileInput').onchange = (e) => {
145
  this.uploadFiles(e.target.files);
146
  e.target.value = '';
147
  };
148
 
149
  if (contentArea) {
150
- contentArea.addEventListener('click', () => {
151
- closeSidebarOnCompactView();
152
- });
153
- contentArea.addEventListener('touchstart', () => {
154
- closeSidebarOnCompactView();
155
- }, { passive: true });
156
  }
157
 
158
- // Create Folder Modal
159
  document.getElementById('confirmFolderBtn').onclick = () => this.createFolder();
160
  document.getElementById('cancelFolderBtn').onclick = () => document.getElementById('createFolderModal').classList.remove('active');
161
 
162
- // Enter on folder name input
163
  document.getElementById('folderNameInput').addEventListener('keydown', (e) => {
164
  if (e.key === 'Enter') this.createFolder();
165
  });
166
 
167
- // Delete Modal
168
  document.getElementById('confirmDeleteBtn').onclick = () => this.confirmDelete();
169
  document.getElementById('cancelDeleteBtn').onclick = () => document.getElementById('deleteModal').classList.remove('active');
170
 
171
- // Rename Modal
172
  document.getElementById('confirmRenameBtn').onclick = () => this.renameItem();
173
  document.getElementById('cancelRenameBtn').onclick = () => document.getElementById('renameModal').classList.remove('active');
174
 
175
- // Enter on rename input
176
  document.getElementById('renameInput').addEventListener('keydown', (e) => {
177
  if (e.key === 'Enter') this.renameItem();
178
  if (e.key === 'Escape') document.getElementById('renameModal').classList.remove('active');
179
  });
180
 
181
- // Add Link Modal
182
- document.getElementById('confirmLinkBtn').onclick = () => this.addLink();
183
  document.getElementById('cancelLinkBtn').onclick = () => document.getElementById('addLinkModal').classList.remove('active');
184
 
185
- // Enter on link URL input
186
  document.getElementById('linkUrlInput').addEventListener('keydown', (e) => {
187
  if (e.key === 'Enter') document.getElementById('linkTitleInput').focus();
188
  });
 
 
 
 
 
 
 
189
  document.addEventListener('click', () => {
190
  document.getElementById('newDropdown').classList.remove('active');
191
- // Close all dropdown menus
192
- document.querySelectorAll('.dropdown-menu.open').forEach(m => m.classList.remove('open'));
193
  });
194
 
195
- // Mobile Toggle
196
  if (menuToggle && sidebar) {
197
  menuToggle.onclick = (e) => {
198
  e.stopPropagation();
@@ -200,15 +217,11 @@ class App {
200
  };
201
  }
202
 
203
- // Close sidebar on navigation (mobile)
204
- document.querySelectorAll('.nav-item').forEach(item => {
205
- item.addEventListener('click', () => {
206
- closeSidebarOnCompactView();
207
- });
208
  });
209
 
210
- // Modals Close via X button
211
- document.querySelectorAll('.close-modal').forEach(btn => {
212
  btn.onclick = () => {
213
  const modal = btn.closest('.modal-overlay');
214
  if (modal && modal.id === 'previewModal') {
@@ -219,8 +232,7 @@ class App {
219
  };
220
  });
221
 
222
- // Modals close on overlay click
223
- document.querySelectorAll('.modal-overlay').forEach(overlay => {
224
  overlay.addEventListener('click', (e) => {
225
  if (e.target === overlay) {
226
  if (overlay.id === 'previewModal') {
@@ -234,9 +246,26 @@ class App {
234
  });
235
  }
236
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  async fetchAndRender() {
238
  if (this.state.isFetching) return;
239
  this.state.isFetching = true;
 
240
  this.ui.showSkeletons();
241
 
242
  try {
@@ -259,16 +288,40 @@ class App {
259
  }
260
  }
261
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  async updateStorageStats() {
263
  try {
264
- const { files } = await this.hf.listFiles('', true);
265
  const totalSize = files.reduce((sum, f) => sum + (f.size || 0), 0);
266
  const count = files.length;
267
 
268
  document.getElementById('storageUsageText').textContent = `${count} files • ${this.formatSize(totalSize)} used`;
269
- const MAX_STORAGE = 10 * 1024 * 1024 * 1024; // 10GB
270
  const pct = Math.min((totalSize / MAX_STORAGE) * 100, 100);
271
- document.getElementById('storageProgress').style.width = pct + '%';
272
  } catch (err) {
273
  console.error('Storage stats error:', err);
274
  }
@@ -279,12 +332,27 @@ class App {
279
  const k = 1024;
280
  const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
281
  const i = Math.floor(Math.log(bytes) / Math.log(k));
282
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  }
284
 
285
  buildStarredDisplayFiles() {
286
- const cachedByPath = new Map(this.state.cachedFiles.map(file => [file.path, file]));
287
- const recentByPath = new Map(this.state.recent.map(file => [file.path, file]));
288
 
289
  return this.state.starred.map((path) => {
290
  const cached = cachedByPath.get(path);
@@ -310,8 +378,31 @@ class App {
310
  });
311
  }
312
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  render() {
314
  const browseMode = this.state.currentBrowse;
 
 
 
 
 
 
 
 
 
315
  let displayFiles = [];
316
  let displayFolders = [];
317
 
@@ -330,17 +421,21 @@ class App {
330
  document.getElementById('breadcrumbs').innerHTML = '<span class="breadcrumb-item active">Starred</span>';
331
  }
332
 
333
- // Filter by search
334
  if (this.state.searchQuery) {
335
  const q = this.state.searchQuery.toLowerCase();
336
- displayFiles = displayFiles.filter(f => f.name.toLowerCase().includes(q));
337
- displayFolders = displayFolders.filter(f => f.name.toLowerCase().includes(q));
338
  }
339
 
340
- this.ui.renderFolders(displayFolders, (name) => {
341
- this.state.setPath([...this.state.currentPath, name]);
342
- this.fetchAndRender();
343
- }, (path, name) => this.openRenameModal(path, name), (path, name) => this.openDeleteModal(path, name));
 
 
 
 
 
344
 
345
  this.ui.renderFiles(displayFiles, {
346
  onPreview: (file) => this.openPreview(file),
@@ -359,6 +454,104 @@ class App {
359
  this.updateActiveNavItem();
360
  }
361
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
  updateActiveNavItem() {
363
  const items = {
364
  files: 'navMyFiles',
@@ -366,7 +559,8 @@ class App {
366
  starred: 'navStarred',
367
  links: 'navLinks'
368
  };
369
- Object.values(items).forEach(id => document.getElementById(id).classList.remove('active'));
 
370
  if (items[this.state.currentBrowse]) {
371
  document.getElementById(items[this.state.currentBrowse]).classList.add('active');
372
  }
@@ -374,10 +568,9 @@ class App {
374
 
375
  async uploadFiles(fileList) {
376
  const files = Array.from(fileList);
377
- const MAX_SIZE = 10 * 1024 * 1024; // 10MB limit for simple API
378
 
379
  for (const file of files) {
380
- // 1. Validation
381
  if (!this.isValidName(file.name)) {
382
  this.ui.showToast(`Invalid file name: ${file.name}`, 'error');
383
  continue;
@@ -391,8 +584,7 @@ class App {
391
  const path = this.state.getFolderPath();
392
  const destPath = path ? `${path}/${file.name}` : file.name;
393
 
394
- // 2. Duplicate Check
395
- if (this.state.cachedFiles.some(f => f.path === destPath)) {
396
  this.ui.showToast(`File already exists: ${file.name}`, 'warning');
397
  continue;
398
  }
@@ -405,6 +597,7 @@ class App {
405
  this.ui.showToast(err.message, 'error');
406
  }
407
  }
 
408
  this.ui.hideProgress();
409
  this.fetchAndRender();
410
  }
@@ -421,8 +614,7 @@ class App {
421
  const path = this.state.getFolderPath();
422
  const destPath = path ? `${path}/${name}` : name;
423
 
424
- // Check if folder name is already taken
425
- if (this.cachedFolders.some(f => f.name === name)) {
426
  this.ui.showToast(`Folder already exists: ${name}`, 'warning');
427
  return;
428
  }
@@ -445,6 +637,12 @@ class App {
445
  return name && name.length > 0 && !forbidden.test(name) && name.length < 255;
446
  }
447
 
 
 
 
 
 
 
448
  openDeleteModal(path, name) {
449
  this.pendingDelete = path;
450
  const strong = document.querySelector('#deleteModal p strong');
@@ -456,7 +654,7 @@ class App {
456
  const renameModal = document.getElementById('renameModal');
457
  const renameInput = document.getElementById('renameInput');
458
  const renameTitle = document.querySelector('#renameModal h3');
459
- const isFolder = this.cachedFolders.some(folder => folder.path === path);
460
 
461
  this.pendingRename = {
462
  path,
@@ -486,12 +684,12 @@ class App {
486
 
487
  if (btn) btn.classList.add('loading');
488
  try {
489
- const isFolder = this.cachedFolders.some(f => f.path === path);
490
  if (isFolder) {
491
  await this.hf.deleteFolder(path);
492
  this.state.starred
493
- .filter(starredPath => starredPath === path || starredPath.startsWith(`${path}/`))
494
- .forEach(starredPath => this.state.removeStar(starredPath));
495
  } else {
496
  await this.hf.deleteFile(path);
497
  this.state.removeStar(path);
@@ -510,37 +708,36 @@ class App {
510
 
511
  async renameItem() {
512
  if (!this.pendingRename) return;
513
-
514
  const newNameInput = document.getElementById('renameInput');
515
  const btn = document.getElementById('confirmRenameBtn');
516
  if (!newNameInput || !btn) return;
517
-
518
  const newName = newNameInput.value.trim();
519
  if (!newName) {
520
  this.ui.showToast('Please enter a new name', 'warning');
521
  return;
522
  }
523
-
524
  if (newName === this.pendingRename.originalName) {
525
  document.getElementById('renameModal').classList.remove('active');
526
  this.pendingRename = null;
527
  return;
528
  }
529
-
530
  if (!this.isValidName(newName)) {
531
  this.ui.showToast('Invalid name format (avoid < > : " / \\ | ? *)', 'error');
532
  return;
533
  }
534
 
535
- // Client-side Conflict Check
536
- const isConflict = this.state.cachedFiles.some(f => f.name.toLowerCase() === newName.toLowerCase()) ||
537
- this.cachedFolders.some(f => f.name.toLowerCase() === newName.toLowerCase());
538
-
539
  if (isConflict) {
540
  this.ui.showToast(`An item with name "${newName}" already exists in this folder`, 'warning');
541
  return;
542
  }
543
-
544
  const path = this.pendingRename.path;
545
  const oldName = this.pendingRename.originalName;
546
  const parentPath = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
@@ -727,144 +924,95 @@ class App {
727
  }
728
  }
729
 
730
- // =================== LINKS MANAGEMENT ===================
 
 
 
 
731
 
732
- async fetchAndRenderLinks() {
733
- try {
734
- const response = await fetch(`${this.hf.apiBase}/links/list`, {
735
- headers: { 'X-User-ID': 'default_user' }
736
- });
737
- const data = await response.json();
738
- if (data.success) {
739
- this.renderLinks(data.links || []);
740
- } else {
741
- this.ui.showToast('Failed to load links', 'error');
742
- }
743
- } catch (err) {
744
- console.error('Fetch links error:', err);
 
 
 
745
  }
 
 
 
746
  }
747
 
748
- renderLinks(links) {
749
- const linksSection = document.getElementById('linksSection');
750
- const linksContainer = document.getElementById('linksContainer');
751
-
752
- if (!linksContainer) return;
753
 
754
- linksSection.style.display = links.length > 0 ? 'block' : 'none';
 
 
755
 
756
- if (links.length === 0) {
757
- linksContainer.innerHTML = `
758
- <div class="links-empty">
759
- <i class="ph-fill ph-link"></i>
760
- <h3>No links yet</h3>
761
- <p>Add your first link to get started</p>
762
- </div>
763
- `;
764
  return;
765
  }
766
 
767
- linksContainer.innerHTML = links.map(link => `
768
- <div class="link-card">
769
- <div class="link-card-header">
770
- <div class="link-icon">
771
- <i class="ph-fill ph-link"></i>
772
- </div>
773
- <div style="flex: 1;">
774
- <div class="link-title">${this.escapeHtml(link.title || link.url)}</div>
775
- <a href="${this.escapeHtml(link.url)}" target="_blank" class="link-url" title="${this.escapeHtml(link.url)}">
776
- ${this.escapeHtml(link.url)}
777
- </a>
778
- </div>
779
- </div>
780
- ${link.description ? `<div class="link-description">${this.escapeHtml(link.description)}</div>` : ''}
781
- <div class="link-actions">
782
- <a href="${this.escapeHtml(link.url)}" target="_blank" class="link-action-btn">
783
- <i class="ph-fill ph-arrow-up-right"></i> Open
784
- </a>
785
- <button class="link-action-btn delete" data-link-id="${link.id}">
786
- <i class="ph-fill ph-trash"></i> Delete
787
- </button>
788
- </div>
789
- </div>
790
- `).join('');
791
-
792
- // Add delete event listeners
793
- linksContainer.querySelectorAll('.link-action-btn.delete').forEach(btn => {
794
- btn.addEventListener('click', (e) => {
795
- e.preventDefault();
796
- const linkId = btn.dataset.linkId;
797
- this.deleteLink(linkId);
798
- });
799
- });
800
- }
801
 
802
- async deleteLink(linkId) {
803
- if (!confirm('Are you sure you want to delete this link?')) return;
 
 
804
 
805
- try {
806
- const response = await fetch(`${this.hf.apiBase}/links/delete`, {
807
- method: 'POST',
808
- headers: {
809
- 'Content-Type': 'application/json',
810
- 'X-User-ID': 'default_user'
811
- },
812
- body: JSON.stringify({ link_id: linkId })
813
- });
814
- const data = await response.json();
815
- if (data.success) {
816
- this.ui.showToast('Link deleted', 'success');
817
- this.fetchAndRenderLinks();
818
- } else {
819
- this.ui.showToast(data.error || 'Failed to delete link', 'error');
820
  }
 
 
 
 
 
 
821
  } catch (err) {
822
- console.error('Delete link error:', err);
823
- this.ui.showToast('Error deleting link', 'error');
 
 
 
824
  }
825
  }
826
 
827
- async addLink() {
828
- const url = document.getElementById('linkUrlInput').value.trim();
829
- const title = document.getElementById('linkTitleInput').value.trim();
830
- const description = document.getElementById('linkDescriptionInput').value.trim();
831
-
832
- if (!url) {
833
- this.ui.showToast('URL is required', 'error');
834
- return;
835
- }
836
 
837
  try {
838
- this.ui.showProgress('Adding link...');
839
- const response = await fetch(`${this.hf.apiBase}/links/add`, {
840
- method: 'POST',
841
- headers: {
842
- 'Content-Type': 'application/json',
843
- 'X-User-ID': 'default_user'
844
- },
845
- body: JSON.stringify({
846
- url,
847
- title,
848
- description
849
- })
850
- });
851
- const data = await response.json();
852
- this.ui.hideProgress();
853
-
854
  if (data.success) {
855
- this.ui.showToast('Link added successfully', 'success');
856
- document.getElementById('addLinkModal').classList.remove('active');
857
- document.getElementById('linkUrlInput').value = '';
858
- document.getElementById('linkTitleInput').value = '';
859
- document.getElementById('linkDescriptionInput').value = '';
860
- this.fetchAndRenderLinks();
861
  } else {
862
- this.ui.showToast(data.error || 'Failed to add link', 'error');
863
  }
864
  } catch (err) {
865
- console.error('Add link error:', err);
 
 
866
  this.ui.hideProgress();
867
- this.ui.showToast('Error adding link', 'error');
868
  }
869
  }
870
 
 
10
  this.hf = hfService;
11
  this.pendingDelete = null;
12
  this.pendingRename = null;
13
+ this.pendingLinkEdit = null;
14
  this.cachedFolders = [];
15
  this.currentPreviewObjectUrl = null;
16
  this.init();
 
27
  setupNetworkHandling() {
28
  window.addEventListener('online', () => {
29
  this.ui.showToast('Back online! Syncing...', 'success');
30
+ if (this.state.currentBrowse === 'links') {
31
+ this.fetchAndRenderLinks();
32
+ } else {
33
+ this.fetchAndRender();
34
+ }
35
  });
36
+
37
  window.addEventListener('offline', () => {
38
  this.ui.showToast('You are offline. Some features may be limited.', 'warning');
39
  });
 
43
  const area = document.getElementById('contentArea');
44
  if (!area) return;
45
 
46
+ ['dragenter', 'dragover'].forEach((evt) => {
47
  area.addEventListener(evt, (e) => {
48
  e.preventDefault();
49
  e.stopPropagation();
50
+ if (this.state.currentBrowse !== 'links') {
51
+ area.classList.add('drag-over');
52
+ }
53
  });
54
  });
55
 
56
+ ['dragleave', 'drop'].forEach((evt) => {
57
  area.addEventListener(evt, (e) => {
58
  e.preventDefault();
59
  e.stopPropagation();
 
62
  });
63
 
64
  area.addEventListener('drop', (e) => {
65
+ if (this.state.currentBrowse === 'links') return;
66
  const files = e.dataTransfer.files;
67
  if (files.length > 0) {
68
  this.uploadFiles(files);
 
81
  }
82
  };
83
 
 
84
  document.getElementById('navMyFiles').onclick = (e) => {
85
  e.preventDefault();
86
  this.state.setBrowseMode('files');
87
  this.state.setPath([]);
88
  this.fetchAndRender();
89
  };
90
+
91
  document.getElementById('navRecent').onclick = (e) => {
92
  e.preventDefault();
93
  this.state.setBrowseMode('recent');
94
  this.render();
95
  };
96
+
97
  document.getElementById('navStarred').onclick = (e) => {
98
  e.preventDefault();
99
  this.state.setBrowseMode('starred');
100
  this.render();
101
  };
102
+
103
  document.getElementById('navLinks').onclick = (e) => {
104
  e.preventDefault();
105
  this.state.setBrowseMode('links');
106
  this.fetchAndRenderLinks();
 
 
 
 
107
  };
108
 
 
109
  document.getElementById('viewGrid').onclick = () => this.state.setViewMode('grid');
110
  document.getElementById('viewList').onclick = () => this.state.setViewMode('list');
111
 
 
112
  let searchDebounce;
113
  document.getElementById('searchInput').oninput = (e) => {
114
+ const value = e.target.value.trim();
115
  clearTimeout(searchDebounce);
116
  searchDebounce = setTimeout(() => {
117
+ if (this.state.currentBrowse === 'links') {
118
+ this.state.setLinksSearchQuery(value);
119
+ this.render();
120
+ return;
121
+ }
122
+
123
+ this.state.setSearchQuery(value);
124
  this.fetchAndRender();
125
+ }, 250);
126
+ };
127
+
128
+ document.getElementById('linksSearchInput').oninput = (e) => {
129
+ this.state.setLinksSearchQuery(e.target.value.trim());
130
+ this.render();
131
  };
132
 
 
133
  document.getElementById('newBtn').onclick = (e) => {
134
  e.stopPropagation();
135
  document.getElementById('newDropdown').classList.toggle('active');
136
  };
137
+
138
  document.getElementById('uploadFileBtn').onclick = (e) => {
139
  e.preventDefault();
140
  e.stopPropagation();
141
  document.getElementById('newDropdown').classList.remove('active');
142
  document.getElementById('fileInput').click();
143
  };
144
+
145
  document.getElementById('createFolderBtn').onclick = (e) => {
146
  e.preventDefault();
147
  e.stopPropagation();
 
150
  document.getElementById('folderNameInput').value = '';
151
  document.getElementById('folderNameInput').focus();
152
  };
153
+
154
  document.getElementById('addLinkBtn').onclick = (e) => {
155
  e.preventDefault();
156
  e.stopPropagation();
157
  document.getElementById('newDropdown').classList.remove('active');
158
+ this.openLinkModal();
 
159
  };
160
 
161
+ document.getElementById('heroAddLinkBtn').onclick = () => this.openLinkModal();
162
+
163
  document.getElementById('fileInput').onchange = (e) => {
164
  this.uploadFiles(e.target.files);
165
  e.target.value = '';
166
  };
167
 
168
  if (contentArea) {
169
+ contentArea.addEventListener('click', () => closeSidebarOnCompactView());
170
+ contentArea.addEventListener(
171
+ 'touchstart',
172
+ () => closeSidebarOnCompactView(),
173
+ { passive: true }
174
+ );
175
  }
176
 
 
177
  document.getElementById('confirmFolderBtn').onclick = () => this.createFolder();
178
  document.getElementById('cancelFolderBtn').onclick = () => document.getElementById('createFolderModal').classList.remove('active');
179
 
 
180
  document.getElementById('folderNameInput').addEventListener('keydown', (e) => {
181
  if (e.key === 'Enter') this.createFolder();
182
  });
183
 
 
184
  document.getElementById('confirmDeleteBtn').onclick = () => this.confirmDelete();
185
  document.getElementById('cancelDeleteBtn').onclick = () => document.getElementById('deleteModal').classList.remove('active');
186
 
 
187
  document.getElementById('confirmRenameBtn').onclick = () => this.renameItem();
188
  document.getElementById('cancelRenameBtn').onclick = () => document.getElementById('renameModal').classList.remove('active');
189
 
 
190
  document.getElementById('renameInput').addEventListener('keydown', (e) => {
191
  if (e.key === 'Enter') this.renameItem();
192
  if (e.key === 'Escape') document.getElementById('renameModal').classList.remove('active');
193
  });
194
 
195
+ document.getElementById('confirmLinkBtn').onclick = () => this.saveLink();
 
196
  document.getElementById('cancelLinkBtn').onclick = () => document.getElementById('addLinkModal').classList.remove('active');
197
 
 
198
  document.getElementById('linkUrlInput').addEventListener('keydown', (e) => {
199
  if (e.key === 'Enter') document.getElementById('linkTitleInput').focus();
200
  });
201
+
202
+ document.getElementById('linkDescriptionInput').addEventListener('keydown', (e) => {
203
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
204
+ this.saveLink();
205
+ }
206
+ });
207
+
208
  document.addEventListener('click', () => {
209
  document.getElementById('newDropdown').classList.remove('active');
210
+ document.querySelectorAll('.dropdown-menu.open').forEach((m) => m.classList.remove('open'));
 
211
  });
212
 
 
213
  if (menuToggle && sidebar) {
214
  menuToggle.onclick = (e) => {
215
  e.stopPropagation();
 
217
  };
218
  }
219
 
220
+ document.querySelectorAll('.nav-item').forEach((item) => {
221
+ item.addEventListener('click', () => closeSidebarOnCompactView());
 
 
 
222
  });
223
 
224
+ document.querySelectorAll('.close-modal').forEach((btn) => {
 
225
  btn.onclick = () => {
226
  const modal = btn.closest('.modal-overlay');
227
  if (modal && modal.id === 'previewModal') {
 
232
  };
233
  });
234
 
235
+ document.querySelectorAll('.modal-overlay').forEach((overlay) => {
 
236
  overlay.addEventListener('click', (e) => {
237
  if (e.target === overlay) {
238
  if (overlay.id === 'previewModal') {
 
246
  });
247
  }
248
 
249
+ setContentView(mode) {
250
+ const filesView = document.getElementById('filesView');
251
+ const linksView = document.getElementById('linksView');
252
+ const searchInput = document.getElementById('searchInput');
253
+ const isLinksMode = mode === 'links';
254
+
255
+ if (filesView) filesView.style.display = isLinksMode ? 'none' : 'block';
256
+ if (linksView) linksView.style.display = isLinksMode ? 'block' : 'none';
257
+ if (searchInput) {
258
+ searchInput.placeholder = isLinksMode
259
+ ? 'Search stored links...'
260
+ : 'Search files or folders...';
261
+ searchInput.value = isLinksMode ? this.state.linksSearchQuery : this.state.searchQuery;
262
+ }
263
+ }
264
+
265
  async fetchAndRender() {
266
  if (this.state.isFetching) return;
267
  this.state.isFetching = true;
268
+ this.setContentView('files');
269
  this.ui.showSkeletons();
270
 
271
  try {
 
288
  }
289
  }
290
 
291
+ async fetchAndRenderLinks() {
292
+ this.setContentView('links');
293
+ const linksContainer = document.getElementById('linksContainer');
294
+ if (linksContainer) {
295
+ linksContainer.innerHTML = `
296
+ <div class="links-empty">
297
+ <div class="spinner"></div>
298
+ <h3>Loading your links</h3>
299
+ <p>Pulling saved websites from your vault.</p>
300
+ </div>
301
+ `;
302
+ }
303
+
304
+ try {
305
+ this.state.cachedLinks = await this.hf.listLinks();
306
+ this.render();
307
+ } catch (err) {
308
+ console.error('Fetch links error:', err);
309
+ this.state.cachedLinks = [];
310
+ this.render();
311
+ this.ui.showToast(err.message || 'Failed to load links', 'error');
312
+ }
313
+ }
314
+
315
  async updateStorageStats() {
316
  try {
317
+ const { files } = await this.hf.listFiles('');
318
  const totalSize = files.reduce((sum, f) => sum + (f.size || 0), 0);
319
  const count = files.length;
320
 
321
  document.getElementById('storageUsageText').textContent = `${count} files • ${this.formatSize(totalSize)} used`;
322
+ const MAX_STORAGE = 10 * 1024 * 1024 * 1024;
323
  const pct = Math.min((totalSize / MAX_STORAGE) * 100, 100);
324
+ document.getElementById('storageProgress').style.width = `${pct}%`;
325
  } catch (err) {
326
  console.error('Storage stats error:', err);
327
  }
 
332
  const k = 1024;
333
  const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
334
  const i = Math.floor(Math.log(bytes) / Math.log(k));
335
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
336
+ }
337
+
338
+ formatRelativeDate(value) {
339
+ if (!value) return 'Just now';
340
+ const diffMs = Date.now() - new Date(value).getTime();
341
+ if (Number.isNaN(diffMs)) return 'Just now';
342
+
343
+ const minutes = Math.floor(diffMs / 60000);
344
+ if (minutes < 1) return 'Just now';
345
+ if (minutes < 60) return `${minutes}m ago`;
346
+ const hours = Math.floor(minutes / 60);
347
+ if (hours < 24) return `${hours}h ago`;
348
+ const days = Math.floor(hours / 24);
349
+ if (days < 7) return `${days}d ago`;
350
+ return new Date(value).toLocaleDateString();
351
  }
352
 
353
  buildStarredDisplayFiles() {
354
+ const cachedByPath = new Map(this.state.cachedFiles.map((file) => [file.path, file]));
355
+ const recentByPath = new Map(this.state.recent.map((file) => [file.path, file]));
356
 
357
  return this.state.starred.map((path) => {
358
  const cached = cachedByPath.get(path);
 
378
  });
379
  }
380
 
381
+ getFilteredLinks() {
382
+ const query = this.state.linksSearchQuery.toLowerCase();
383
+ const links = Array.isArray(this.state.cachedLinks) ? this.state.cachedLinks : [];
384
+ if (!query) return links;
385
+
386
+ return links.filter((link) => {
387
+ const haystack = [link.title, link.url, link.description]
388
+ .filter(Boolean)
389
+ .join(' ')
390
+ .toLowerCase();
391
+ return haystack.includes(query);
392
+ });
393
+ }
394
+
395
  render() {
396
  const browseMode = this.state.currentBrowse;
397
+ this.setContentView(browseMode);
398
+
399
+ if (browseMode === 'links') {
400
+ document.getElementById('breadcrumbs').innerHTML = '<span class="breadcrumb-item active">Links</span>';
401
+ this.renderLinks(this.getFilteredLinks());
402
+ this.updateActiveNavItem();
403
+ return;
404
+ }
405
+
406
  let displayFiles = [];
407
  let displayFolders = [];
408
 
 
421
  document.getElementById('breadcrumbs').innerHTML = '<span class="breadcrumb-item active">Starred</span>';
422
  }
423
 
 
424
  if (this.state.searchQuery) {
425
  const q = this.state.searchQuery.toLowerCase();
426
+ displayFiles = displayFiles.filter((f) => f.name.toLowerCase().includes(q));
427
+ displayFolders = displayFolders.filter((f) => f.name.toLowerCase().includes(q));
428
  }
429
 
430
+ this.ui.renderFolders(
431
+ displayFolders,
432
+ (name) => {
433
+ this.state.setPath([...this.state.currentPath, name]);
434
+ this.fetchAndRender();
435
+ },
436
+ (path, name) => this.openRenameModal(path, name),
437
+ (path, name) => this.openDeleteModal(path, name)
438
+ );
439
 
440
  this.ui.renderFiles(displayFiles, {
441
  onPreview: (file) => this.openPreview(file),
 
454
  this.updateActiveNavItem();
455
  }
456
 
457
+ renderLinks(links) {
458
+ const linksContainer = document.getElementById('linksContainer');
459
+ const linksCount = document.getElementById('linksCount');
460
+ const linksUpdatedAt = document.getElementById('linksUpdatedAt');
461
+
462
+ if (!linksContainer) return;
463
+
464
+ const totalLinks = Array.isArray(this.state.cachedLinks) ? this.state.cachedLinks.length : 0;
465
+ const latestUpdated = [...(this.state.cachedLinks || [])]
466
+ .map((link) => link.updated_at || link.created_at)
467
+ .filter(Boolean)
468
+ .sort((a, b) => new Date(b) - new Date(a))[0];
469
+
470
+ if (linksCount) linksCount.textContent = String(totalLinks);
471
+ if (linksUpdatedAt) linksUpdatedAt.textContent = this.formatRelativeDate(latestUpdated);
472
+
473
+ if (!links.length) {
474
+ const hasSearch = Boolean(this.state.linksSearchQuery);
475
+ linksContainer.innerHTML = `
476
+ <div class="links-empty">
477
+ <i class="ph-fill ph-link"></i>
478
+ <h3>${hasSearch ? 'No links match your search' : 'No links saved yet'}</h3>
479
+ <p>${hasSearch ? 'Try a different title, URL, or note.' : 'Add your first site to start building your link vault.'}</p>
480
+ ${hasSearch ? '' : '<button class="btn-primary" id="emptyStateAddLinkBtn"><i class="ph-fill ph-link-plus"></i> Add Your First Link</button>'}
481
+ </div>
482
+ `;
483
+
484
+ const emptyBtn = document.getElementById('emptyStateAddLinkBtn');
485
+ if (emptyBtn) emptyBtn.onclick = () => this.openLinkModal();
486
+ return;
487
+ }
488
+
489
+ linksContainer.innerHTML = links
490
+ .map((link) => `
491
+ <article class="link-card">
492
+ <div class="link-card-header">
493
+ <div class="link-icon">
494
+ <i class="ph-fill ph-link"></i>
495
+ </div>
496
+ <div class="link-card-content">
497
+ <div class="link-title">${this.escapeHtml(link.title || link.url)}</div>
498
+ <a href="${this.escapeHtml(link.url)}" target="_blank" rel="noopener noreferrer" class="link-url" title="${this.escapeHtml(link.url)}">
499
+ ${this.escapeHtml(link.url)}
500
+ </a>
501
+ </div>
502
+ </div>
503
+ ${link.description ? `<p class="link-description">${this.escapeHtml(link.description)}</p>` : '<p class="link-description muted">No notes added yet.</p>'}
504
+ <div class="link-meta">
505
+ <span><i class="ph-fill ph-clock"></i> ${this.formatRelativeDate(link.updated_at || link.created_at)}</span>
506
+ <span><i class="ph-fill ph-bookmark-simple"></i> Saved item</span>
507
+ </div>
508
+ <div class="link-actions">
509
+ <a href="${this.escapeHtml(link.url)}" target="_blank" rel="noopener noreferrer" class="link-action-btn">
510
+ <i class="ph-fill ph-arrow-up-right"></i> Open
511
+ </a>
512
+ <button class="link-action-btn" data-action="copy" data-link-url="${this.escapeHtml(link.url)}">
513
+ <i class="ph-fill ph-copy"></i> Copy
514
+ </button>
515
+ <button class="link-action-btn" data-action="edit" data-link-id="${link.id}">
516
+ <i class="ph-fill ph-pencil-simple"></i> Edit
517
+ </button>
518
+ <button class="link-action-btn delete" data-action="delete" data-link-id="${link.id}">
519
+ <i class="ph-fill ph-trash"></i> Delete
520
+ </button>
521
+ </div>
522
+ </article>
523
+ `)
524
+ .join('');
525
+
526
+ linksContainer.querySelectorAll('[data-action="copy"]').forEach((btn) => {
527
+ btn.addEventListener('click', async (e) => {
528
+ e.preventDefault();
529
+ const url = btn.dataset.linkUrl || '';
530
+ try {
531
+ await navigator.clipboard.writeText(url);
532
+ this.ui.showToast('Link copied to clipboard', 'success');
533
+ } catch (err) {
534
+ this.ui.showToast('Could not copy the link', 'error');
535
+ }
536
+ });
537
+ });
538
+
539
+ linksContainer.querySelectorAll('[data-action="edit"]').forEach((btn) => {
540
+ btn.addEventListener('click', (e) => {
541
+ e.preventDefault();
542
+ const link = (this.state.cachedLinks || []).find((item) => item.id === btn.dataset.linkId);
543
+ if (link) this.openLinkModal(link);
544
+ });
545
+ });
546
+
547
+ linksContainer.querySelectorAll('[data-action="delete"]').forEach((btn) => {
548
+ btn.addEventListener('click', (e) => {
549
+ e.preventDefault();
550
+ this.deleteLink(btn.dataset.linkId);
551
+ });
552
+ });
553
+ }
554
+
555
  updateActiveNavItem() {
556
  const items = {
557
  files: 'navMyFiles',
 
559
  starred: 'navStarred',
560
  links: 'navLinks'
561
  };
562
+
563
+ Object.values(items).forEach((id) => document.getElementById(id).classList.remove('active'));
564
  if (items[this.state.currentBrowse]) {
565
  document.getElementById(items[this.state.currentBrowse]).classList.add('active');
566
  }
 
568
 
569
  async uploadFiles(fileList) {
570
  const files = Array.from(fileList);
571
+ const MAX_SIZE = 10 * 1024 * 1024;
572
 
573
  for (const file of files) {
 
574
  if (!this.isValidName(file.name)) {
575
  this.ui.showToast(`Invalid file name: ${file.name}`, 'error');
576
  continue;
 
584
  const path = this.state.getFolderPath();
585
  const destPath = path ? `${path}/${file.name}` : file.name;
586
 
587
+ if (this.state.cachedFiles.some((f) => f.path === destPath)) {
 
588
  this.ui.showToast(`File already exists: ${file.name}`, 'warning');
589
  continue;
590
  }
 
597
  this.ui.showToast(err.message, 'error');
598
  }
599
  }
600
+
601
  this.ui.hideProgress();
602
  this.fetchAndRender();
603
  }
 
614
  const path = this.state.getFolderPath();
615
  const destPath = path ? `${path}/${name}` : name;
616
 
617
+ if (this.cachedFolders.some((f) => f.name === name)) {
 
618
  this.ui.showToast(`Folder already exists: ${name}`, 'warning');
619
  return;
620
  }
 
637
  return name && name.length > 0 && !forbidden.test(name) && name.length < 255;
638
  }
639
 
640
+ normalizeUrl(url) {
641
+ if (!url) return '';
642
+ if (/^https?:\/\//i.test(url)) return url;
643
+ return `https://${url}`;
644
+ }
645
+
646
  openDeleteModal(path, name) {
647
  this.pendingDelete = path;
648
  const strong = document.querySelector('#deleteModal p strong');
 
654
  const renameModal = document.getElementById('renameModal');
655
  const renameInput = document.getElementById('renameInput');
656
  const renameTitle = document.querySelector('#renameModal h3');
657
+ const isFolder = this.cachedFolders.some((folder) => folder.path === path);
658
 
659
  this.pendingRename = {
660
  path,
 
684
 
685
  if (btn) btn.classList.add('loading');
686
  try {
687
+ const isFolder = this.cachedFolders.some((f) => f.path === path);
688
  if (isFolder) {
689
  await this.hf.deleteFolder(path);
690
  this.state.starred
691
+ .filter((starredPath) => starredPath === path || starredPath.startsWith(`${path}/`))
692
+ .forEach((starredPath) => this.state.removeStar(starredPath));
693
  } else {
694
  await this.hf.deleteFile(path);
695
  this.state.removeStar(path);
 
708
 
709
  async renameItem() {
710
  if (!this.pendingRename) return;
711
+
712
  const newNameInput = document.getElementById('renameInput');
713
  const btn = document.getElementById('confirmRenameBtn');
714
  if (!newNameInput || !btn) return;
715
+
716
  const newName = newNameInput.value.trim();
717
  if (!newName) {
718
  this.ui.showToast('Please enter a new name', 'warning');
719
  return;
720
  }
721
+
722
  if (newName === this.pendingRename.originalName) {
723
  document.getElementById('renameModal').classList.remove('active');
724
  this.pendingRename = null;
725
  return;
726
  }
727
+
728
  if (!this.isValidName(newName)) {
729
  this.ui.showToast('Invalid name format (avoid < > : " / \\ | ? *)', 'error');
730
  return;
731
  }
732
 
733
+ const isConflict = this.state.cachedFiles.some((f) => f.name.toLowerCase() === newName.toLowerCase())
734
+ || this.cachedFolders.some((f) => f.name.toLowerCase() === newName.toLowerCase());
735
+
 
736
  if (isConflict) {
737
  this.ui.showToast(`An item with name "${newName}" already exists in this folder`, 'warning');
738
  return;
739
  }
740
+
741
  const path = this.pendingRename.path;
742
  const oldName = this.pendingRename.originalName;
743
  const parentPath = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
 
924
  }
925
  }
926
 
927
+ resetLinkForm() {
928
+ document.getElementById('linkUrlInput').value = '';
929
+ document.getElementById('linkTitleInput').value = '';
930
+ document.getElementById('linkDescriptionInput').value = '';
931
+ }
932
 
933
+ openLinkModal(link = null) {
934
+ const modal = document.getElementById('addLinkModal');
935
+ const title = document.getElementById('addLinkModalTitle');
936
+ const confirmBtn = document.getElementById('confirmLinkBtn');
937
+
938
+ this.pendingLinkEdit = link;
939
+ if (link) {
940
+ title.innerHTML = '<i class="ph-fill ph-pencil-simple" style="color:var(--primary-color);margin-right:10px"></i>Edit Link';
941
+ confirmBtn.innerHTML = '<i class="ph-fill ph-check"></i> Update Link';
942
+ document.getElementById('linkUrlInput').value = link.url || '';
943
+ document.getElementById('linkTitleInput').value = link.title || '';
944
+ document.getElementById('linkDescriptionInput').value = link.description || '';
945
+ } else {
946
+ title.innerHTML = '<i class="ph-fill ph-link-plus" style="color:var(--primary-color);margin-right:10px"></i>Add Link';
947
+ confirmBtn.innerHTML = '<i class="ph-fill ph-check"></i> Save Link';
948
+ this.resetLinkForm();
949
  }
950
+
951
+ modal.classList.add('active');
952
+ setTimeout(() => document.getElementById('linkUrlInput').focus(), 50);
953
  }
954
 
955
+ async saveLink() {
956
+ const urlInput = document.getElementById('linkUrlInput');
957
+ const titleInput = document.getElementById('linkTitleInput');
958
+ const descriptionInput = document.getElementById('linkDescriptionInput');
959
+ const confirmBtn = document.getElementById('confirmLinkBtn');
960
 
961
+ const url = this.normalizeUrl(urlInput.value.trim());
962
+ const title = titleInput.value.trim();
963
+ const description = descriptionInput.value.trim();
964
 
965
+ if (!url) {
966
+ this.ui.showToast('URL is required', 'error');
 
 
 
 
 
 
967
  return;
968
  }
969
 
970
+ try {
971
+ confirmBtn.classList.add('loading');
972
+ this.ui.showProgress(this.pendingLinkEdit ? 'Updating link...' : 'Adding link...');
973
+ const isEditing = Boolean(this.pendingLinkEdit);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
974
 
975
+ const payload = { url, title, description };
976
+ const result = isEditing
977
+ ? await this.hf.updateLink({ link_id: this.pendingLinkEdit.id, ...payload })
978
+ : await this.hf.addLink(payload);
979
 
980
+ if (!result.success) {
981
+ throw new Error(result.error || 'Failed to save link');
 
 
 
 
 
 
 
 
 
 
 
 
 
982
  }
983
+
984
+ document.getElementById('addLinkModal').classList.remove('active');
985
+ this.pendingLinkEdit = null;
986
+ this.resetLinkForm();
987
+ this.ui.showToast(isEditing ? 'Link updated successfully' : 'Link added successfully', 'success');
988
+ await this.fetchAndRenderLinks();
989
  } catch (err) {
990
+ console.error('Save link error:', err);
991
+ this.ui.showToast(err.message || 'Error saving link', 'error');
992
+ } finally {
993
+ this.ui.hideProgress();
994
+ confirmBtn.classList.remove('loading');
995
  }
996
  }
997
 
998
+ async deleteLink(linkId) {
999
+ if (!linkId) return;
1000
+ if (!window.confirm('Are you sure you want to delete this link?')) return;
 
 
 
 
 
 
1001
 
1002
  try {
1003
+ this.ui.showProgress('Deleting link...');
1004
+ const data = await this.hf.deleteLink(linkId);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1005
  if (data.success) {
1006
+ this.ui.showToast('Link deleted', 'success');
1007
+ await this.fetchAndRenderLinks();
 
 
 
 
1008
  } else {
1009
+ this.ui.showToast(data.error || 'Failed to delete link', 'error');
1010
  }
1011
  } catch (err) {
1012
+ console.error('Delete link error:', err);
1013
+ this.ui.showToast(err.message || 'Error deleting link', 'error');
1014
+ } finally {
1015
  this.ui.hideProgress();
 
1016
  }
1017
  }
1018
 
js/state/stateManager.js CHANGED
@@ -5,10 +5,12 @@ class StateManager {
5
  constructor() {
6
  this.currentPath = [];
7
  this.searchQuery = '';
 
8
  this.viewMode = localStorage.getItem('view_mode') || 'grid'; // 'grid' | 'list'
9
- this.currentBrowse = 'files'; // 'files' | 'starred' | 'recent'
10
  this.isFetching = false;
11
  this.cachedFiles = [];
 
12
  this.starred = this.load(STARRED_KEY);
13
  this.recent = this.load(RECENT_KEY);
14
  this.listeners = [];
@@ -56,8 +58,13 @@ class StateManager {
56
  this.notify('SET_SEARCH', query);
57
  }
58
 
 
 
 
 
 
59
  setBrowseMode(mode) {
60
- if (!['files', 'starred', 'recent'].includes(mode)) return;
61
  this.currentBrowse = mode;
62
  this.notify('SET_BROWSE', mode);
63
  }
 
5
  constructor() {
6
  this.currentPath = [];
7
  this.searchQuery = '';
8
+ this.linksSearchQuery = '';
9
  this.viewMode = localStorage.getItem('view_mode') || 'grid'; // 'grid' | 'list'
10
+ this.currentBrowse = 'files'; // 'files' | 'starred' | 'recent' | 'links'
11
  this.isFetching = false;
12
  this.cachedFiles = [];
13
+ this.cachedLinks = [];
14
  this.starred = this.load(STARRED_KEY);
15
  this.recent = this.load(RECENT_KEY);
16
  this.listeners = [];
 
58
  this.notify('SET_SEARCH', query);
59
  }
60
 
61
+ setLinksSearchQuery(query) {
62
+ this.linksSearchQuery = typeof query === 'string' ? query : '';
63
+ this.notify('SET_LINKS_SEARCH', query);
64
+ }
65
+
66
  setBrowseMode(mode) {
67
+ if (!['files', 'starred', 'recent', 'links'].includes(mode)) return;
68
  this.currentBrowse = mode;
69
  this.notify('SET_BROWSE', mode);
70
  }
styles.css CHANGED
@@ -448,6 +448,104 @@ body {
448
  color: var(--primary-color);
449
  }
450
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
  /* ── SECTION HEADERS ── */
452
  .section-header {
453
  display: flex;
@@ -1496,6 +1594,8 @@ body {
1496
  .sidebar { width: 224px; }
1497
  .search-bar { width: 100%; max-width: 420px; }
1498
  .grid-container { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
 
 
1499
  }
1500
 
1501
  @media (max-width: 768px) {
@@ -1533,6 +1633,14 @@ body {
1533
  .folder-card { padding: 12px; min-height: 136px; }
1534
  .file-card { border-radius: 16px; }
1535
  .file-preview { height: 92px; }
 
 
 
 
 
 
 
 
1536
 
1537
  /* Mobile Menu Toggle */
1538
  .menu-toggle {
@@ -1699,11 +1807,15 @@ body {
1699
 
1700
  .link-card-header {
1701
  display: flex;
1702
- justify-content: space-between;
1703
  align-items: flex-start;
1704
  gap: 12px;
1705
  }
1706
 
 
 
 
 
 
1707
  .link-icon {
1708
  width: 40px;
1709
  height: 40px;
@@ -1747,16 +1859,36 @@ body {
1747
  overflow: hidden;
1748
  }
1749
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1750
  .link-actions {
1751
  display: flex;
1752
  gap: 8px;
1753
  justify-content: space-between;
 
1754
  padding-top: 8px;
1755
  border-top: 1px solid var(--border-color);
1756
  }
1757
 
1758
  .link-action-btn {
1759
- flex: 1;
1760
  display: flex;
1761
  align-items: center;
1762
  justify-content: center;
 
448
  color: var(--primary-color);
449
  }
450
 
451
+ .links-view {
452
+ display: flex;
453
+ flex-direction: column;
454
+ gap: 24px;
455
+ padding-top: 12px;
456
+ }
457
+
458
+ .links-hero {
459
+ background:
460
+ radial-gradient(circle at top left, rgba(59, 130, 246, 0.18), transparent 32%),
461
+ linear-gradient(135deg, #0f172a, #1e293b 55%, #164e63);
462
+ color: #f8fafc;
463
+ border-radius: 24px;
464
+ padding: 28px;
465
+ display: flex;
466
+ align-items: center;
467
+ justify-content: space-between;
468
+ gap: 20px;
469
+ box-shadow: var(--shadow-lg);
470
+ }
471
+
472
+ .links-eyebrow {
473
+ display: inline-flex;
474
+ font-size: 11px;
475
+ letter-spacing: 1.4px;
476
+ text-transform: uppercase;
477
+ color: rgba(255, 255, 255, 0.7);
478
+ margin-bottom: 12px;
479
+ }
480
+
481
+ .links-hero h2 {
482
+ font-size: 30px;
483
+ line-height: 1.1;
484
+ letter-spacing: -1px;
485
+ margin-bottom: 10px;
486
+ }
487
+
488
+ .links-hero p {
489
+ max-width: 560px;
490
+ color: rgba(241, 245, 249, 0.82);
491
+ line-height: 1.6;
492
+ }
493
+
494
+ .links-toolbar {
495
+ display: grid;
496
+ grid-template-columns: repeat(2, minmax(0, 180px)) minmax(240px, 1fr);
497
+ gap: 16px;
498
+ }
499
+
500
+ .links-stat-card,
501
+ .links-search {
502
+ background: var(--card);
503
+ border: 1px solid var(--border-color);
504
+ border-radius: 18px;
505
+ padding: 18px 20px;
506
+ box-shadow: var(--shadow-sm);
507
+ }
508
+
509
+ .links-stat-card {
510
+ display: flex;
511
+ flex-direction: column;
512
+ gap: 8px;
513
+ }
514
+
515
+ .links-stat-label {
516
+ font-size: 11px;
517
+ text-transform: uppercase;
518
+ letter-spacing: 1px;
519
+ color: var(--text-muted);
520
+ font-weight: 700;
521
+ }
522
+
523
+ .links-stat-card strong {
524
+ font-size: 28px;
525
+ color: var(--text-main);
526
+ }
527
+
528
+ .links-search {
529
+ display: flex;
530
+ align-items: center;
531
+ gap: 12px;
532
+ }
533
+
534
+ .links-search i {
535
+ color: var(--text-muted);
536
+ font-size: 18px;
537
+ }
538
+
539
+ .links-search input {
540
+ width: 100%;
541
+ border: none;
542
+ background: transparent;
543
+ outline: none;
544
+ font-family: inherit;
545
+ font-size: 14px;
546
+ color: var(--text-main);
547
+ }
548
+
549
  /* ── SECTION HEADERS ── */
550
  .section-header {
551
  display: flex;
 
1594
  .sidebar { width: 224px; }
1595
  .search-bar { width: 100%; max-width: 420px; }
1596
  .grid-container { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
1597
+ .links-toolbar { grid-template-columns: 1fr 1fr; }
1598
+ .links-search { grid-column: 1 / -1; }
1599
  }
1600
 
1601
  @media (max-width: 768px) {
 
1633
  .folder-card { padding: 12px; min-height: 136px; }
1634
  .file-card { border-radius: 16px; }
1635
  .file-preview { height: 92px; }
1636
+ .links-hero {
1637
+ padding: 20px;
1638
+ flex-direction: column;
1639
+ align-items: flex-start;
1640
+ }
1641
+ .links-hero h2 { font-size: 24px; }
1642
+ .links-toolbar { grid-template-columns: 1fr; }
1643
+ .links-stat-card strong { font-size: 22px; }
1644
 
1645
  /* Mobile Menu Toggle */
1646
  .menu-toggle {
 
1807
 
1808
  .link-card-header {
1809
  display: flex;
 
1810
  align-items: flex-start;
1811
  gap: 12px;
1812
  }
1813
 
1814
+ .link-card-content {
1815
+ flex: 1;
1816
+ min-width: 0;
1817
+ }
1818
+
1819
  .link-icon {
1820
  width: 40px;
1821
  height: 40px;
 
1859
  overflow: hidden;
1860
  }
1861
 
1862
+ .link-description.muted {
1863
+ color: #94a3b8;
1864
+ font-style: italic;
1865
+ }
1866
+
1867
+ .link-meta {
1868
+ display: flex;
1869
+ flex-wrap: wrap;
1870
+ gap: 10px 16px;
1871
+ font-size: 12px;
1872
+ color: var(--text-muted);
1873
+ }
1874
+
1875
+ .link-meta span {
1876
+ display: inline-flex;
1877
+ align-items: center;
1878
+ gap: 6px;
1879
+ }
1880
+
1881
  .link-actions {
1882
  display: flex;
1883
  gap: 8px;
1884
  justify-content: space-between;
1885
+ flex-wrap: wrap;
1886
  padding-top: 8px;
1887
  border-top: 1px solid var(--border-color);
1888
  }
1889
 
1890
  .link-action-btn {
1891
+ flex: 1 1 140px;
1892
  display: flex;
1893
  align-items: center;
1894
  justify-content: center;