mohsin-devs commited on
Commit
2642f13
Β·
1 Parent(s): 3414777
index.html CHANGED
@@ -47,6 +47,9 @@
47
  <button class="new-dropdown-item" id="uploadFileBtn">
48
  <i class="ph-fill ph-upload-simple"></i> Upload File
49
  </button>
 
 
 
50
  </div>
51
  </div>
52
 
@@ -60,6 +63,9 @@
60
  <a href="#" class="nav-item" id="navStarred">
61
  <i class="ph-fill ph-star"></i> Starred
62
  </a>
 
 
 
63
  </nav>
64
 
65
  <div class="sidebar-bottom">
@@ -113,6 +119,14 @@
113
  <h2>Files</h2>
114
  </div>
115
  <div class="grid-container" id="filesContainer"></div>
 
 
 
 
 
 
 
 
116
  </div>
117
  </main>
118
  </div>
@@ -165,6 +179,30 @@
165
  </div>
166
  </div>
167
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
 
169
 
170
  <!-- Preview Modal -->
 
47
  <button class="new-dropdown-item" id="uploadFileBtn">
48
  <i class="ph-fill ph-upload-simple"></i> Upload File
49
  </button>
50
+ <button class="new-dropdown-item" id="addLinkBtn">
51
+ <i class="ph-fill ph-link-plus"></i> Add Link
52
+ </button>
53
  </div>
54
  </div>
55
 
 
63
  <a href="#" class="nav-item" id="navStarred">
64
  <i class="ph-fill ph-star"></i> Starred
65
  </a>
66
+ <a href="#" class="nav-item" id="navLinks">
67
+ <i class="ph-fill ph-link"></i> Links
68
+ </a>
69
  </nav>
70
 
71
  <div class="sidebar-bottom">
 
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>
132
  </div>
 
179
  </div>
180
  </div>
181
 
182
+ <!-- Add Link Modal -->
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">
190
+ </div>
191
+ <div class="input-group">
192
+ <label class="input-label">Title</label>
193
+ <input type="text" id="linkTitleInput" placeholder="Link title..." autocomplete="off">
194
+ </div>
195
+ <div class="input-group">
196
+ <label class="input-label">Description</label>
197
+ <textarea id="linkDescriptionInput" placeholder="Add notes about this link..." rows="3"></textarea>
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>
205
+
206
 
207
 
208
  <!-- Preview Modal -->
js/main.js CHANGED
@@ -89,6 +89,15 @@ class App {
89
  this.state.setBrowseMode('starred');
90
  this.render();
91
  };
 
 
 
 
 
 
 
 
 
92
 
93
  // View Toggles
94
  document.getElementById('viewGrid').onclick = () => this.state.setViewMode('grid');
@@ -123,6 +132,13 @@ class App {
123
  document.getElementById('folderNameInput').value = '';
124
  document.getElementById('folderNameInput').focus();
125
  };
 
 
 
 
 
 
 
126
 
127
  // File Input
128
  document.getElementById('fileInput').onchange = (e) => {
@@ -161,6 +177,15 @@ class App {
161
  if (e.key === 'Enter') this.renameItem();
162
  if (e.key === 'Escape') document.getElementById('renameModal').classList.remove('active');
163
  });
 
 
 
 
 
 
 
 
 
164
  document.addEventListener('click', () => {
165
  document.getElementById('newDropdown').classList.remove('active');
166
  // Close all dropdown menus
@@ -338,10 +363,13 @@ class App {
338
  const items = {
339
  files: 'navMyFiles',
340
  recent: 'navRecent',
341
- starred: 'navStarred'
 
342
  };
343
  Object.values(items).forEach(id => document.getElementById(id).classList.remove('active'));
344
- document.getElementById(items[this.state.currentBrowse]).classList.add('active');
 
 
345
  }
346
 
347
  async uploadFiles(fileList) {
@@ -698,6 +726,153 @@ class App {
698
  this.ui.showToast(err.message || 'Failed to load history', 'error');
699
  }
700
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
701
  }
702
 
703
  new App();
 
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');
 
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) => {
 
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
 
363
  const items = {
364
  files: 'navMyFiles',
365
  recent: 'navRecent',
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
+ }
373
  }
374
 
375
  async uploadFiles(fileList) {
 
726
  this.ui.showToast(err.message || 'Failed to load history', 'error');
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
+
871
+ escapeHtml(text) {
872
+ const div = document.createElement('div');
873
+ div.textContent = text;
874
+ return div.innerHTML;
875
+ }
876
  }
877
 
878
  new App();
server/routes/api.py CHANGED
@@ -276,6 +276,80 @@ def restore_version():
276
  return jsonify({"success": False, "error": str(exc)}), 500
277
 
278
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  @api_bp.errorhandler(404)
280
  def not_found(error):
281
  return jsonify({"success": False, "error": "Endpoint not found"}), 404
 
276
  return jsonify({"success": False, "error": str(exc)}), 500
277
 
278
 
279
+ @api_bp.route("/links/add", methods=["POST"])
280
+ def add_link():
281
+ try:
282
+ user_id = get_user_id_from_request()
283
+ data = request.get_json(silent=True) or {}
284
+ url = (data.get("url") or "").strip()
285
+ title = (data.get("title") or "").strip()
286
+ description = (data.get("description") or "").strip()
287
+
288
+ if not url:
289
+ return jsonify({"success": False, "error": "url is required"}), 400
290
+
291
+ # Validate URL format
292
+ if not (url.startswith("http://") or url.startswith("https://")):
293
+ return jsonify({"success": False, "error": "Invalid URL format"}), 400
294
+
295
+ if not title:
296
+ title = url
297
+
298
+ result = _storage().add_link(user_id, url, title, description)
299
+ return jsonify(result), 201 if result.get("success") else 400
300
+ except Exception as exc:
301
+ logger.error("add_link failed: %s", exc, exc_info=True)
302
+ return jsonify({"success": False, "error": str(exc)}), 500
303
+
304
+
305
+ @api_bp.route("/links/list", methods=["GET"])
306
+ def list_links():
307
+ try:
308
+ user_id = get_user_id_from_request()
309
+ result = _storage().get_links(user_id)
310
+ return jsonify(result), 200 if result.get("success") else 400
311
+ except Exception as exc:
312
+ logger.error("list_links failed: %s", exc, exc_info=True)
313
+ return jsonify({"success": False, "error": str(exc)}), 500
314
+
315
+
316
+ @api_bp.route("/links/delete", methods=["POST"])
317
+ def delete_link():
318
+ try:
319
+ user_id = get_user_id_from_request()
320
+ data = request.get_json(silent=True) or {}
321
+ link_id = (data.get("link_id") or "").strip()
322
+
323
+ if not link_id:
324
+ return jsonify({"success": False, "error": "link_id is required"}), 400
325
+
326
+ result = _storage().delete_link(user_id, link_id)
327
+ return jsonify(result), 200 if result.get("success") else 400
328
+ except Exception as exc:
329
+ logger.error("delete_link failed: %s", exc, exc_info=True)
330
+ return jsonify({"success": False, "error": str(exc)}), 500
331
+
332
+
333
+ @api_bp.route("/links/update", methods=["POST"])
334
+ def update_link():
335
+ try:
336
+ user_id = get_user_id_from_request()
337
+ data = request.get_json(silent=True) or {}
338
+ link_id = (data.get("link_id") or "").strip()
339
+ url = (data.get("url") or "").strip()
340
+ title = (data.get("title") or "").strip()
341
+ description = (data.get("description") or "").strip()
342
+
343
+ if not link_id:
344
+ return jsonify({"success": False, "error": "link_id is required"}), 400
345
+
346
+ result = _storage().update_link(user_id, link_id, url, title, description)
347
+ return jsonify(result), 200 if result.get("success") else 400
348
+ except Exception as exc:
349
+ logger.error("update_link failed: %s", exc, exc_info=True)
350
+ return jsonify({"success": False, "error": str(exc)}), 500
351
+
352
+
353
  @api_bp.errorhandler(404)
354
  def not_found(error):
355
  return jsonify({"success": False, "error": "Endpoint not found"}), 404
server/storage/hf.py CHANGED
@@ -499,3 +499,243 @@ class HuggingFaceStorageManager(StorageInterface):
499
  "path": destination_relative_path,
500
  },
501
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
499
  "path": destination_relative_path,
500
  },
501
  }
502
+
503
+ def add_link(
504
+ self, user_id: str, url: str, title: str, description: str = ""
505
+ ) -> Dict[str, Any]:
506
+ import json
507
+ import uuid
508
+
509
+ self._ensure_token()
510
+
511
+ try:
512
+ # Get existing links
513
+ links_repo_path = self._user_repo_path(user_id, ".links/data.json")
514
+ repo_files = self._list_repo_files()
515
+
516
+ links = []
517
+ if links_repo_path in repo_files:
518
+ try:
519
+ content = self.api.hf_hub_download(
520
+ repo_id=config.HF_REPO_ID,
521
+ filename=links_repo_path,
522
+ repo_type=config.HF_REPO_TYPE,
523
+ )
524
+ with open(content, 'r') as f:
525
+ links = json.load(f)
526
+ except Exception as e:
527
+ logger.warning(f"Failed to load existing links: {e}")
528
+ links = []
529
+
530
+ # Add new link
531
+ link_id = str(uuid.uuid4())
532
+ new_link = {
533
+ "id": link_id,
534
+ "url": url,
535
+ "title": title,
536
+ "description": description,
537
+ "created_at": self._timestamp(),
538
+ "updated_at": self._timestamp(),
539
+ }
540
+ links.append(new_link)
541
+
542
+ # Save links
543
+ operations = [
544
+ CommitOperationAdd(
545
+ path_in_repo=links_repo_path,
546
+ path_or_fileobj=json.dumps(links, indent=2),
547
+ )
548
+ ]
549
+ self.api.create_commit(
550
+ repo_id=config.HF_REPO_ID,
551
+ repo_type=config.HF_REPO_TYPE,
552
+ operations=operations,
553
+ commit_message=f"Add link: {title}",
554
+ )
555
+
556
+ return {
557
+ "success": True,
558
+ "message": "Link added successfully",
559
+ "link": new_link,
560
+ }
561
+ except Exception as exc:
562
+ logger.error(f"add_link failed: {exc}", exc_info=True)
563
+ return {
564
+ "success": False,
565
+ "error": str(exc),
566
+ }
567
+
568
+ def get_links(self, user_id: str) -> Dict[str, Any]:
569
+ import json
570
+
571
+ try:
572
+ links_repo_path = self._user_repo_path(user_id, ".links/data.json")
573
+ repo_files = self._list_repo_files()
574
+
575
+ if links_repo_path not in repo_files:
576
+ return {
577
+ "success": True,
578
+ "links": [],
579
+ }
580
+
581
+ try:
582
+ content = self.api.hf_hub_download(
583
+ repo_id=config.HF_REPO_ID,
584
+ filename=links_repo_path,
585
+ repo_type=config.HF_REPO_TYPE,
586
+ )
587
+ with open(content, 'r') as f:
588
+ links = json.load(f)
589
+ return {
590
+ "success": True,
591
+ "links": links,
592
+ }
593
+ except Exception as e:
594
+ logger.warning(f"Failed to load links: {e}")
595
+ return {
596
+ "success": True,
597
+ "links": [],
598
+ }
599
+ except Exception as exc:
600
+ logger.error(f"get_links failed: {exc}", exc_info=True)
601
+ return {
602
+ "success": False,
603
+ "error": str(exc),
604
+ "links": [],
605
+ }
606
+
607
+ def delete_link(self, user_id: str, link_id: str) -> Dict[str, Any]:
608
+ import json
609
+
610
+ self._ensure_token()
611
+
612
+ try:
613
+ links_repo_path = self._user_repo_path(user_id, ".links/data.json")
614
+ repo_files = self._list_repo_files()
615
+
616
+ if links_repo_path not in repo_files:
617
+ return {
618
+ "success": False,
619
+ "error": "No links found",
620
+ }
621
+
622
+ try:
623
+ content = self.api.hf_hub_download(
624
+ repo_id=config.HF_REPO_ID,
625
+ filename=links_repo_path,
626
+ repo_type=config.HF_REPO_TYPE,
627
+ )
628
+ with open(content, 'r') as f:
629
+ links = json.load(f)
630
+ except Exception as e:
631
+ logger.warning(f"Failed to load links: {e}")
632
+ return {
633
+ "success": False,
634
+ "error": "Failed to load links",
635
+ }
636
+
637
+ # Remove link by ID
638
+ links = [link for link in links if link.get("id") != link_id]
639
+
640
+ # Save updated links
641
+ operations = [
642
+ CommitOperationAdd(
643
+ path_in_repo=links_repo_path,
644
+ path_or_fileobj=json.dumps(links, indent=2),
645
+ )
646
+ ]
647
+ self.api.create_commit(
648
+ repo_id=config.HF_REPO_ID,
649
+ repo_type=config.HF_REPO_TYPE,
650
+ operations=operations,
651
+ commit_message=f"Delete link: {link_id}",
652
+ )
653
+
654
+ return {
655
+ "success": True,
656
+ "message": "Link deleted successfully",
657
+ }
658
+ except Exception as exc:
659
+ logger.error(f"delete_link failed: {exc}", exc_info=True)
660
+ return {
661
+ "success": False,
662
+ "error": str(exc),
663
+ }
664
+
665
+ def update_link(
666
+ self, user_id: str, link_id: str, url: str = "", title: str = "", description: str = ""
667
+ ) -> Dict[str, Any]:
668
+ import json
669
+
670
+ self._ensure_token()
671
+
672
+ try:
673
+ links_repo_path = self._user_repo_path(user_id, ".links/data.json")
674
+ repo_files = self._list_repo_files()
675
+
676
+ if links_repo_path not in repo_files:
677
+ return {
678
+ "success": False,
679
+ "error": "No links found",
680
+ }
681
+
682
+ try:
683
+ content = self.api.hf_hub_download(
684
+ repo_id=config.HF_REPO_ID,
685
+ filename=links_repo_path,
686
+ repo_type=config.HF_REPO_TYPE,
687
+ )
688
+ with open(content, 'r') as f:
689
+ links = json.load(f)
690
+ except Exception as e:
691
+ logger.warning(f"Failed to load links: {e}")
692
+ return {
693
+ "success": False,
694
+ "error": "Failed to load links",
695
+ }
696
+
697
+ # Update link by ID
698
+ updated = False
699
+ for link in links:
700
+ if link.get("id") == link_id:
701
+ if url:
702
+ link["url"] = url
703
+ if title:
704
+ link["title"] = title
705
+ if description or description == "":
706
+ link["description"] = description
707
+ link["updated_at"] = self._timestamp()
708
+ updated = True
709
+ break
710
+
711
+ if not updated:
712
+ return {
713
+ "success": False,
714
+ "error": "Link not found",
715
+ }
716
+
717
+ # Save updated links
718
+ operations = [
719
+ CommitOperationAdd(
720
+ path_in_repo=links_repo_path,
721
+ path_or_fileobj=json.dumps(links, indent=2),
722
+ )
723
+ ]
724
+ self.api.create_commit(
725
+ repo_id=config.HF_REPO_ID,
726
+ repo_type=config.HF_REPO_TYPE,
727
+ operations=operations,
728
+ commit_message=f"Update link: {link_id}",
729
+ )
730
+
731
+ return {
732
+ "success": True,
733
+ "message": "Link updated successfully",
734
+ "link": next(link for link in links if link.get("id") == link_id),
735
+ }
736
+ except Exception as exc:
737
+ logger.error(f"update_link failed: {exc}", exc_info=True)
738
+ return {
739
+ "success": False,
740
+ "error": str(exc),
741
+ }
server/storage/interface.py CHANGED
@@ -63,6 +63,26 @@ class StorageInterface(ABC):
63
  ) -> Dict[str, Any]:
64
  pass
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  def standardize_file(
67
  self,
68
  name: str,
 
63
  ) -> Dict[str, Any]:
64
  pass
65
 
66
+ @abstractmethod
67
+ def add_link(
68
+ self, user_id: str, url: str, title: str, description: str = ""
69
+ ) -> Dict[str, Any]:
70
+ pass
71
+
72
+ @abstractmethod
73
+ def get_links(self, user_id: str) -> Dict[str, Any]:
74
+ pass
75
+
76
+ @abstractmethod
77
+ def delete_link(self, user_id: str, link_id: str) -> Dict[str, Any]:
78
+ pass
79
+
80
+ @abstractmethod
81
+ def update_link(
82
+ self, user_id: str, link_id: str, url: str = "", title: str = "", description: str = ""
83
+ ) -> Dict[str, Any]:
84
+ pass
85
+
86
  def standardize_file(
87
  self,
88
  name: str,
server/storage/local.py CHANGED
@@ -49,3 +49,15 @@ class LocalStorageManager(StorageInterface):
49
 
50
  def restore(self, user_id, path, revision, as_copy=False):
51
  raise RuntimeError("Local storage is disabled.")
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
  def restore(self, user_id, path, revision, as_copy=False):
51
  raise RuntimeError("Local storage is disabled.")
52
+
53
+ def add_link(self, user_id, url, title, description=""):
54
+ raise RuntimeError("Local storage is disabled.")
55
+
56
+ def get_links(self, user_id):
57
+ raise RuntimeError("Local storage is disabled.")
58
+
59
+ def delete_link(self, user_id, link_id):
60
+ raise RuntimeError("Local storage is disabled.")
61
+
62
+ def update_link(self, user_id, link_id, url="", title="", description=""):
63
+ raise RuntimeError("Local storage is disabled.")
styles.css CHANGED
@@ -174,6 +174,10 @@ body {
174
  color: var(--file-color);
175
  }
176
 
 
 
 
 
177
  /* ── SIDEBAR NAV ── */
178
  .sidebar-nav {
179
  display: flex;
@@ -1661,3 +1665,172 @@ body {
1661
  color: var(--text-muted);
1662
  font-size: 13px;
1663
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  color: var(--file-color);
175
  }
176
 
177
+ .new-dropdown-item .ph-link-plus {
178
+ color: #3b82f6;
179
+ }
180
+
181
  /* ── SIDEBAR NAV ── */
182
  .sidebar-nav {
183
  display: flex;
 
1665
  color: var(--text-muted);
1666
  font-size: 13px;
1667
  }
1668
+
1669
+ /* ── LINKS SECTION ── */
1670
+ .links-container {
1671
+ display: grid;
1672
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
1673
+ gap: 16px;
1674
+ margin-top: 16px;
1675
+ }
1676
+
1677
+ .link-card {
1678
+ background: var(--card);
1679
+ border: 1px solid var(--border-color);
1680
+ border-radius: 14px;
1681
+ padding: 18px;
1682
+ position: relative;
1683
+ cursor: pointer;
1684
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
1685
+ box-shadow: var(--shadow-sm);
1686
+ display: flex;
1687
+ flex-direction: column;
1688
+ gap: 12px;
1689
+ animation: cardFadeIn 0.4s ease-out forwards;
1690
+ opacity: 0;
1691
+ transform: translateY(20px);
1692
+ }
1693
+
1694
+ .link-card:hover {
1695
+ transform: translateY(-4px);
1696
+ box-shadow: var(--shadow-lg);
1697
+ border-color: var(--primary-color);
1698
+ }
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;
1710
+ background: var(--primary-light);
1711
+ border-radius: 10px;
1712
+ display: flex;
1713
+ align-items: center;
1714
+ justify-content: center;
1715
+ color: var(--primary-color);
1716
+ font-size: 20px;
1717
+ flex-shrink: 0;
1718
+ }
1719
+
1720
+ .link-title {
1721
+ font-size: 15px;
1722
+ font-weight: 600;
1723
+ color: var(--text-main);
1724
+ word-break: break-word;
1725
+ line-height: 1.3;
1726
+ }
1727
+
1728
+ .link-url {
1729
+ font-size: 12px;
1730
+ color: var(--primary-color);
1731
+ word-break: break-all;
1732
+ text-decoration: none;
1733
+ line-height: 1.4;
1734
+ overflow: hidden;
1735
+ display: -webkit-box;
1736
+ -webkit-line-clamp: 1;
1737
+ -webkit-box-orient: vertical;
1738
+ }
1739
+
1740
+ .link-description {
1741
+ font-size: 13px;
1742
+ color: var(--text-muted);
1743
+ line-height: 1.5;
1744
+ display: -webkit-box;
1745
+ -webkit-line-clamp: 2;
1746
+ -webkit-box-orient: vertical;
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;
1763
+ gap: 6px;
1764
+ padding: 8px 12px;
1765
+ background: var(--hover-bg);
1766
+ border: 1px solid var(--border-color);
1767
+ border-radius: 8px;
1768
+ color: var(--text-muted);
1769
+ font-size: 13px;
1770
+ font-weight: 600;
1771
+ cursor: pointer;
1772
+ transition: all 0.15s ease;
1773
+ text-decoration: none;
1774
+ }
1775
+
1776
+ .link-action-btn:hover {
1777
+ background: var(--primary-light);
1778
+ color: var(--primary-color);
1779
+ border-color: var(--primary-color);
1780
+ }
1781
+
1782
+ .link-action-btn.delete:hover {
1783
+ background: rgba(239, 68, 68, 0.1);
1784
+ color: var(--danger-color);
1785
+ border-color: var(--danger-color);
1786
+ }
1787
+
1788
+ .links-empty {
1789
+ grid-column: 1 / -1;
1790
+ display: flex;
1791
+ flex-direction: column;
1792
+ align-items: center;
1793
+ justify-content: center;
1794
+ padding: 60px 20px;
1795
+ text-align: center;
1796
+ color: var(--text-muted);
1797
+ animation: fadeInUp 0.6s ease-out;
1798
+ }
1799
+
1800
+ .links-empty i {
1801
+ font-size: 48px;
1802
+ margin-bottom: 16px;
1803
+ opacity: 0.6;
1804
+ animation: bounceIn 0.8s ease-out;
1805
+ }
1806
+
1807
+ .links-empty h3 {
1808
+ font-size: 18px;
1809
+ font-weight: 600;
1810
+ margin-bottom: 8px;
1811
+ color: var(--text-main);
1812
+ }
1813
+
1814
+ .links-empty p {
1815
+ font-size: 14px;
1816
+ margin-bottom: 20px;
1817
+ }
1818
+
1819
+ .input-group textarea {
1820
+ width: 100%;
1821
+ padding: 12px 16px;
1822
+ border: 2px solid var(--border-color);
1823
+ border-radius: 12px;
1824
+ font-family: inherit;
1825
+ font-size: 14px;
1826
+ color: var(--text-main);
1827
+ background: var(--bg-color);
1828
+ outline: none;
1829
+ resize: vertical;
1830
+ transition: all 0.2s ease;
1831
+ }
1832
+
1833
+ .input-group textarea:focus {
1834
+ border-color: var(--primary-color);
1835
+ box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
1836
+ }