Share receiver for URLs - save, tag and route elsewhere!

icon fixes

+2 -1
.claude/settings.local.json
··· 64 64 "Bash(git checkout:*)", 65 65 "Bash(./scripts/check-db.sh:*)", 66 66 "Bash(cargo tauri build:*)", 67 - "Bash(git rm:*)" 67 + "Bash(git rm:*)", 68 + "Bash(diff:*)" 68 69 ], 69 70 "deny": [], 70 71 "ask": []
+20 -8
CLAUDE.md
··· 25 25 - Both should: include any text in the tag input field when Save is pressed 26 26 - If you change save logic in one place, update the other to match 27 27 - Share Extension: `savePressed()` adds `newTagTextField.text` to `selectedTags` before saving 28 - - Main App: `saveTagChanges()` adds `newTagInput` to `finalTags` before saving 28 + - Main App: `saveChanges()` adds `newTagInput` to `finalTags` before saving 29 29 30 30 **CRITICAL - Build Workflow (MUST FOLLOW THESE STEPS):** 31 31 ··· 95 95 - [ ] Save URL WITH tags from Safari share sheet 96 96 - [ ] Save URL WITHOUT tags from Safari share sheet 97 97 - [ ] Open main app - URLs should appear 98 - - [ ] Edit tags on existing URL 98 + - [ ] Edit URL and tags on existing entry 99 + - [ ] Delete URL from edit view 99 100 - [ ] Save duplicate URL - should update tags 100 101 101 102 **CRITICAL - Entitlements Files:** ··· 146 147 npm run tauri dev 147 148 ``` 148 149 149 - ### Building 150 + ### Build Scripts 151 + 152 + **Release build for device:** 153 + ```bash 154 + npm run build:release # Frontend-only changes (faster, uses incremental compilation) 155 + npm run build:release:full # Rust code changes (forces full recompile) 156 + ``` 150 157 151 158 **Build Rust library for iOS:** 152 159 ```bash ··· 201 208 urls (id TEXT PK, url TEXT UNIQUE, created_at, updated_at, deleted_at) 202 209 tags (id INTEGER PK, name TEXT UNIQUE, frequency, last_used, frecency_score, created_at, updated_at) 203 210 url_tags (url_id, tag_id, created_at) -- junction table 204 - settings (key TEXT PK, value TEXT) -- webhook_url, last_sync 211 + settings (key TEXT PK, value TEXT) -- webhook_url, webhook_api_key, last_sync 205 212 ``` 206 213 207 214 **Key Features:** 208 215 - WAL mode enabled for concurrent access from main app and share extension 209 - - Soft deletes via `deleted_at` timestamp on urls table 216 + - Hard deletes for URLs (removes from urls and url_tags tables) 210 217 - Normalized schema with junction table for URL-tag many-to-many relationships 211 218 212 219 **App Group Bridge:** ··· 325 332 The app supports syncing saved URLs to an external webhook endpoint. 326 333 327 334 **Features:** 328 - - Configure webhook URL in Settings 335 + - Configure webhook URL and optional API key in Settings 336 + - API key is sent as `Authorization: Bearer <key>` header 329 337 - Manual "Sync All" button sends all URLs to webhook 330 338 - Automatic sync on save (from both main app and share extension) 331 339 - Daily auto-sync: checks `last_sync` timestamp, syncs if >24 hours since last sync ··· 347 355 348 356 **Settings stored in database:** 349 357 - `webhook_url`: The configured endpoint URL 358 + - `webhook_api_key`: API key sent as `Authorization: Bearer <key>` header 350 359 - `last_sync`: RFC3339 timestamp of last successful sync 351 360 352 361 ### Tauri Commands ··· 355 364 - `get_tags_by_frecency()`: Returns all tags sorted by frecency score 356 365 - `get_tags_by_frecency_for_url(url: String)`: Returns tags with domain-affinity boost for the given URL 357 366 - `get_saved_urls()`: Returns all saved URLs sorted by most recent first 358 - - `update_url_tags(id: String, tags: Vec<String>)`: Updates tags for existing URL 359 - - `delete_url(id: String)`: Soft-deletes a URL 367 + - `update_url(id: String, url: String, tags: Vec<String>)`: Updates URL and tags for existing entry 368 + - `update_url_tags(id: String, tags: Vec<String>)`: Updates only tags for existing URL (legacy) 369 + - `delete_url(id: String)`: Hard-deletes a URL and its tag associations 360 370 - `get_webhook_url()`: Returns configured webhook URL 361 371 - `set_webhook_url(url: String)`: Saves webhook URL to settings 372 + - `get_webhook_api_key()`: Returns configured webhook API key 373 + - `set_webhook_api_key(key: String)`: Saves webhook API key to settings 362 374 - `sync_to_webhook()`: Manually syncs all URLs to webhook 363 375 - `auto_sync_if_needed()`: Checks last_sync timestamp, syncs if >24 hours 364 376 - `is_dark_mode()`: Returns system dark mode preference
Icons/Peek clouds src.png

This is a binary file and will not be displayed.

Icons/Peek.icon/Assets/Peek clouds src.png src-tauri/icons/ios/Peek.icon/Assets/Peek clouds src.png
Icons/Peek.icon/icon.json src-tauri/icons/ios/Peek.icon/icon.json
+38 -6
README.md
··· 5 5 ## Overview 6 6 7 7 Peek is a mobile bookmarking app that allows you to: 8 - - Save URLs directly from Safari's share sheet 8 + - Save URLs directly from the iOS share sheet 9 9 - Tag URLs with multiple tags 10 10 - Automatically merge tags when saving duplicate URLs 11 11 - Browse saved URLs sorted by recency ··· 194 194 195 195 ### Build Workflow 196 196 197 - The project supports two build workflows that can coexist: 197 + Frontend assets (CSS, JS, HTML) are **embedded in the Rust binary** at compile time. This means: 198 + - Changing CSS/JS requires rebuilding Rust with `cargo tauri build` (NOT just `cargo build`) 199 + - Simply rebuilding in Xcode won't pick up frontend changes 200 + - The library file to copy is in the `deps/` subdirectory 198 201 199 - 1. **Swift/UI-only changes**: Build directly from Xcode (fast, skips Rust build) 200 - 2. **Rust code changes**: Run `npm run tauri ios dev -- "iPhone 17 Pro"` from CLI first 201 - 3. **Full rebuild**: Use CLI 202 + **Debug Build (Simulator):** 203 + ```bash 204 + # 1. Start Vite dev server (for hot reload during development) 205 + npx vite --host 202 206 203 - The Xcode preBuildScript checks if `libapp.a` exists and skips the Rust build if so. To force a Rust rebuild from Xcode, delete `src-tauri/gen/apple/Externals/arm64/debug/libapp.a`. 207 + # 2. Build and run from Xcode with Debug scheme on simulator 208 + # OR use the full embedded build: 209 + npm run build 210 + cd src-tauri 211 + cargo tauri build --target aarch64-apple-ios-sim --debug 212 + cp target/aarch64-apple-ios-sim/debug/deps/libpeek_save_lib.a gen/apple/Externals/arm64/Debug/libapp.a 213 + # Then build in Xcode with Debug scheme, simulator target 214 + ``` 215 + 216 + **Release Build (Device):** 217 + ```bash 218 + # Use the build script: 219 + npm run build:release 220 + 221 + # Or manually: 222 + npm run build 223 + cd src-tauri 224 + cargo tauri build --target aarch64-apple-ios 225 + cp target/aarch64-apple-ios/release/deps/libpeek_save_lib.a gen/apple/Externals/arm64/Release/libapp.a 226 + # Then build in Xcode with Release scheme, device target 227 + ``` 228 + 229 + **Important Notes:** 230 + - Debug uses `Externals/arm64/Debug/libapp.a` and target `aarch64-apple-ios-sim` 231 + - Release uses `Externals/arm64/Release/libapp.a` and target `aarch64-apple-ios` 232 + - Always copy from the `deps/` subfolder (has embedded assets), not the root folder 233 + - Use `cargo tauri build`, NOT `cargo build` (the latter doesn't embed frontend assets) 234 + 235 + The Xcode preBuildScript checks if `libapp.a` exists and skips the Rust build if so. To force a Rust rebuild from Xcode, delete the corresponding `libapp.a` file. 204 236 205 237 ### App Icon 206 238
+12 -1
build-release.sh
··· 1 1 #!/bin/bash 2 2 set -e 3 3 4 + # Use --force flag to force full Rust recompile (for Rust code changes) 5 + FORCE_REBUILD=false 6 + if [ "$1" = "--force" ] || [ "$1" = "-f" ]; then 7 + FORCE_REBUILD=true 8 + fi 9 + 4 10 echo "Building frontend..." 5 11 npm run build 6 12 7 13 echo "Building Rust for iOS device (release)..." 8 14 cd src-tauri 9 - touch src/lib.rs 15 + 16 + if [ "$FORCE_REBUILD" = true ]; then 17 + echo "(Forcing Rust recompile...)" 18 + touch src/lib.rs 19 + fi 20 + 10 21 cargo tauri build --target aarch64-apple-ios 11 22 12 23 echo "Copying library to Xcode location..."
+2 -1
package.json
··· 11 11 "ios": "./run-ios.sh", 12 12 "xcode": "open src-tauri/gen/apple/tauri-app.xcodeproj", 13 13 "archive": "./build-archive.sh", 14 - "build:release": "./build-release.sh" 14 + "build:release": "./build-release.sh", 15 + "build:release:full": "./build-release.sh --force" 15 16 }, 16 17 "dependencies": { 17 18 "@tauri-apps/api": "^2",
+1 -1
src-tauri/gen/apple/peek-save.xcodeproj/xcshareddata/xcschemes/peek-save_iOS.xcscheme
··· 52 52 </Testables> 53 53 </TestAction> 54 54 <LaunchAction 55 - buildConfiguration = "debug" 55 + buildConfiguration = "release" 56 56 selectedDebuggerIdentifier = "" 57 57 selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn" 58 58 launchStyle = "0"
src-tauri/icons/128x128.png

This is a binary file and will not be displayed.

src-tauri/icons/128x128@2x.png

This is a binary file and will not be displayed.

src-tauri/icons/32x32.png

This is a binary file and will not be displayed.

src-tauri/icons/64x64.png

This is a binary file and will not be displayed.

src-tauri/icons/Square107x107Logo.png

This is a binary file and will not be displayed.

src-tauri/icons/Square142x142Logo.png

This is a binary file and will not be displayed.

src-tauri/icons/Square150x150Logo.png

This is a binary file and will not be displayed.

src-tauri/icons/Square284x284Logo.png

This is a binary file and will not be displayed.

src-tauri/icons/Square30x30Logo.png

This is a binary file and will not be displayed.

src-tauri/icons/Square310x310Logo.png

This is a binary file and will not be displayed.

src-tauri/icons/Square44x44Logo.png

This is a binary file and will not be displayed.

src-tauri/icons/Square71x71Logo.png

This is a binary file and will not be displayed.

src-tauri/icons/Square89x89Logo.png

This is a binary file and will not be displayed.

src-tauri/icons/StoreLogo.png

This is a binary file and will not be displayed.

src-tauri/icons/icon.icns

This is a binary file and will not be displayed.

src-tauri/icons/icon.ico

This is a binary file and will not be displayed.

src-tauri/icons/icon.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-20x20@1x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-20x20@2x-1.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-20x20@2x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-20x20@3x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-29x29@1x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-29x29@2x-1.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-29x29@2x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-29x29@3x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-40x40@1x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-40x40@2x-1.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-40x40@2x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-40x40@3x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-512@2x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-60x60@2x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-60x60@3x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-76x76@1x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-76x76@2x.png

This is a binary file and will not be displayed.

src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png

This is a binary file and will not be displayed.

+208 -14
src-tauri/src/lib.rs
··· 167 167 168 168 // Helper function to push a single URL to webhook (fire and forget) 169 169 async fn push_url_to_webhook(saved_url: SavedUrl) { 170 - // Get webhook URL from database 171 - let webhook_url = match get_connection() { 170 + // Get webhook URL and API key from database 171 + let (webhook_url, api_key) = match get_connection() { 172 172 Ok(conn) => { 173 - conn.query_row( 174 - "SELECT value FROM settings WHERE key = 'webhook_url'", 175 - [], 176 - |row| row.get::<_, String>(0), 177 - ) 178 - .ok() 173 + let url = conn 174 + .query_row( 175 + "SELECT value FROM settings WHERE key = 'webhook_url'", 176 + [], 177 + |row| row.get::<_, String>(0), 178 + ) 179 + .ok(); 180 + let key = conn 181 + .query_row( 182 + "SELECT value FROM settings WHERE key = 'webhook_api_key'", 183 + [], 184 + |row| row.get::<_, String>(0), 185 + ) 186 + .ok(); 187 + (url, key) 179 188 } 180 - Err(_) => None, 189 + Err(_) => (None, None), 181 190 }; 182 191 183 192 if let Some(url) = webhook_url { ··· 186 195 let client = reqwest::Client::new(); 187 196 let payload = WebhookPayload { urls: vec![saved_url] }; 188 197 189 - match client.post(&url).json(&payload).send().await { 198 + let mut request = client.post(&url).json(&payload); 199 + 200 + // Add Authorization header if API key is configured 201 + if let Some(key) = api_key { 202 + if !key.is_empty() { 203 + request = request.header("Authorization", format!("Bearer {}", key)); 204 + } 205 + } 206 + 207 + match request.send().await { 190 208 Ok(response) => { 191 209 if response.status().is_success() { 192 210 println!("[Rust] Webhook push successful"); ··· 491 509 } 492 510 493 511 #[tauri::command] 512 + async fn update_url(id: String, url: String, tags: Vec<String>) -> Result<(), String> { 513 + println!("[Rust] update_url called for id: {}, url: {}, tags: {:?}", id, url, tags); 514 + 515 + let conn = get_connection()?; 516 + let now = Utc::now().to_rfc3339(); 517 + 518 + // Verify URL exists 519 + let exists: bool = conn 520 + .query_row( 521 + "SELECT 1 FROM urls WHERE id = ? AND deleted_at IS NULL", 522 + params![&id], 523 + |_| Ok(true), 524 + ) 525 + .unwrap_or(false); 526 + 527 + if !exists { 528 + return Err("URL not found".to_string()); 529 + } 530 + 531 + // Update URL value and timestamp 532 + conn.execute( 533 + "UPDATE urls SET url = ?, updated_at = ? WHERE id = ?", 534 + params![&url, &now, &id], 535 + ) 536 + .map_err(|e| format!("Failed to update URL: {}", e))?; 537 + 538 + // Get existing tags for this URL 539 + let mut existing_tag_stmt = conn 540 + .prepare( 541 + "SELECT t.name FROM tags t 542 + JOIN url_tags ut ON t.id = ut.tag_id 543 + WHERE ut.url_id = ?", 544 + ) 545 + .map_err(|e| format!("Failed to prepare existing tags query: {}", e))?; 546 + 547 + let existing_tags: std::collections::HashSet<String> = existing_tag_stmt 548 + .query_map(params![&id], |row| row.get(0)) 549 + .map_err(|e| format!("Failed to query existing tags: {}", e))? 550 + .filter_map(|r| r.ok()) 551 + .collect(); 552 + 553 + let new_tags_set: std::collections::HashSet<String> = tags.iter().cloned().collect(); 554 + 555 + // Determine which tags are being added vs removed 556 + let tags_to_add: Vec<&String> = new_tags_set.difference(&existing_tags).collect(); 557 + let tags_to_remove: Vec<&String> = existing_tags.difference(&new_tags_set).collect(); 558 + 559 + // Remove only the tags that were actually removed 560 + for tag_name in &tags_to_remove { 561 + conn.execute( 562 + "DELETE FROM url_tags WHERE url_id = ? AND tag_id = (SELECT id FROM tags WHERE name = ?)", 563 + params![&id, tag_name], 564 + ) 565 + .map_err(|e| format!("Failed to remove tag association: {}", e))?; 566 + } 567 + 568 + // Add only the tags that are new to this URL 569 + for tag_name in &tags_to_add { 570 + // Get or create tag 571 + let tag_id: i64 = match conn.query_row( 572 + "SELECT id FROM tags WHERE name = ?", 573 + params![tag_name], 574 + |row| row.get(0), 575 + ) { 576 + Ok(existing_id) => { 577 + // Update existing tag stats 578 + let frequency: u32 = conn 579 + .query_row( 580 + "SELECT frequency FROM tags WHERE id = ?", 581 + params![existing_id], 582 + |row| row.get(0), 583 + ) 584 + .unwrap_or(0); 585 + 586 + let new_frequency = frequency + 1; 587 + let frecency = calculate_frecency(new_frequency, &now); 588 + 589 + conn.execute( 590 + "UPDATE tags SET frequency = ?, last_used = ?, frecency_score = ?, updated_at = ? WHERE id = ?", 591 + params![new_frequency, &now, frecency, &now, existing_id], 592 + ) 593 + .map_err(|e| format!("Failed to update tag: {}", e))?; 594 + 595 + existing_id 596 + } 597 + Err(_) => { 598 + // Create new tag 599 + let frecency = calculate_frecency(1, &now); 600 + conn.execute( 601 + "INSERT INTO tags (name, frequency, last_used, frecency_score, created_at, updated_at) VALUES (?, 1, ?, ?, ?, ?)", 602 + params![tag_name, &now, frecency, &now, &now], 603 + ) 604 + .map_err(|e| format!("Failed to insert tag: {}", e))?; 605 + 606 + conn.last_insert_rowid() 607 + } 608 + }; 609 + 610 + // Create URL-tag association 611 + conn.execute( 612 + "INSERT INTO url_tags (url_id, tag_id, created_at) VALUES (?, ?, ?)", 613 + params![&id, tag_id, &now], 614 + ) 615 + .map_err(|e| format!("Failed to link tag: {}", e))?; 616 + } 617 + 618 + println!("[Rust] URL updated successfully"); 619 + 620 + // Push to webhook (fire and forget) 621 + let saved_url = SavedUrl { 622 + id, 623 + url, 624 + tags, 625 + saved_at: now, 626 + }; 627 + tauri::async_runtime::spawn(async move { 628 + push_url_to_webhook(saved_url).await; 629 + }); 630 + 631 + Ok(()) 632 + } 633 + 634 + #[tauri::command] 494 635 async fn update_url_tags(id: String, tags: Vec<String>) -> Result<(), String> { 495 636 println!("[Rust] update_url_tags called for id: {}, tags: {:?}", id, tags); 496 637 ··· 639 780 } 640 781 641 782 #[tauri::command] 783 + async fn get_webhook_api_key() -> Result<Option<String>, String> { 784 + let conn = get_connection()?; 785 + 786 + let key: Option<String> = conn 787 + .query_row( 788 + "SELECT value FROM settings WHERE key = 'webhook_api_key'", 789 + [], 790 + |row| row.get(0), 791 + ) 792 + .ok(); 793 + 794 + Ok(key) 795 + } 796 + 797 + #[tauri::command] 798 + async fn set_webhook_api_key(key: String) -> Result<(), String> { 799 + println!("[Rust] set_webhook_api_key called"); 800 + let conn = get_connection()?; 801 + 802 + if key.is_empty() { 803 + conn.execute("DELETE FROM settings WHERE key = 'webhook_api_key'", []) 804 + .map_err(|e| format!("Failed to delete API key: {}", e))?; 805 + } else { 806 + conn.execute( 807 + "INSERT OR REPLACE INTO settings (key, value) VALUES ('webhook_api_key', ?)", 808 + params![&key], 809 + ) 810 + .map_err(|e| format!("Failed to save API key: {}", e))?; 811 + } 812 + 813 + println!("[Rust] API key saved successfully"); 814 + Ok(()) 815 + } 816 + 817 + #[tauri::command] 642 818 async fn set_webhook_url(url: String) -> Result<(), String> { 643 819 println!("[Rust] set_webhook_url called with url: {}", url); 644 820 let conn = get_connection()?; ··· 662 838 async fn sync_to_webhook() -> Result<SyncResult, String> { 663 839 println!("[Rust] sync_to_webhook called"); 664 840 665 - // Get webhook URL 841 + // Get webhook URL and API key 666 842 let conn = get_connection()?; 667 843 let webhook_url: String = conn 668 844 .query_row( ··· 676 852 return Err("No webhook URL configured".to_string()); 677 853 } 678 854 855 + let api_key: Option<String> = conn 856 + .query_row( 857 + "SELECT value FROM settings WHERE key = 'webhook_api_key'", 858 + [], 859 + |row| row.get(0), 860 + ) 861 + .ok(); 862 + 679 863 // Get all URLs (reuse existing logic) 680 864 drop(conn); // Close connection before async call 681 865 let urls = get_saved_urls().await?; ··· 694 878 695 879 // Send to webhook 696 880 let client = reqwest::Client::new(); 697 - let response = client 698 - .post(&webhook_url) 699 - .json(&payload) 881 + let mut request = client.post(&webhook_url).json(&payload); 882 + 883 + // Add Authorization header if API key is configured 884 + if let Some(key) = api_key { 885 + if !key.is_empty() { 886 + request = request.header("Authorization", format!("Bearer {}", key)); 887 + } 888 + } 889 + 890 + let response = request 700 891 .send() 701 892 .await 702 893 .map_err(|e| format!("Failed to send webhook request: {}", e))?; ··· 803 994 get_saved_urls, 804 995 get_shared_url, 805 996 delete_url, 997 + update_url, 806 998 update_url_tags, 807 999 is_dark_mode, 808 1000 get_webhook_url, 809 1001 set_webhook_url, 1002 + get_webhook_api_key, 1003 + set_webhook_api_key, 810 1004 sync_to_webhook, 811 1005 get_last_sync, 812 1006 auto_sync_if_needed
+79 -1
src/App.css
··· 352 352 overflow-wrap: break-word; 353 353 margin-bottom: 1rem; 354 354 padding-bottom: 0.75rem; 355 + } 356 + 357 + .edit-url-input { 358 + width: 100%; 359 + padding: 0.75rem; 360 + border: 1px solid #ddd; 361 + border-radius: 8px; 362 + font-size: 1rem; 363 + color: #007aff; 364 + box-sizing: border-box; 355 365 border-bottom: 1px solid #e0e0e0; 356 366 } 357 367 ··· 581 591 border-bottom-color: #444; 582 592 } 583 593 594 + body.dark .edit-url-input { 595 + background: #1c1c1e; 596 + border-color: #444; 597 + color: #0a84ff; 598 + } 599 + 584 600 body.dark .editing-tag { 585 601 background: #0a84ff; 586 602 } ··· 710 726 font-size: 16px; 711 727 } 712 728 729 + .api-key-field { 730 + position: relative; 731 + } 732 + 733 + .api-key-field input { 734 + padding-right: 3rem; 735 + } 736 + 737 + .toggle-visibility-btn { 738 + position: absolute; 739 + right: 0.75rem; 740 + top: 50%; 741 + transform: translateY(-50%); 742 + background: none; 743 + border: none; 744 + padding: 0.25rem; 745 + cursor: pointer; 746 + color: #666; 747 + display: flex; 748 + align-items: center; 749 + justify-content: center; 750 + } 751 + 752 + .toggle-visibility-btn:hover { 753 + color: #333; 754 + } 755 + 756 + body.dark .toggle-visibility-btn { 757 + color: #999; 758 + } 759 + 760 + body.dark .toggle-visibility-btn:hover { 761 + color: #ccc; 762 + } 763 + 713 764 .save-webhook-btn { 714 765 padding: 0.75rem 1rem; 715 766 background: #007aff; ··· 725 776 cursor: not-allowed; 726 777 } 727 778 779 + .save-settings-btn { 780 + width: 100%; 781 + padding: 1rem; 782 + margin-top: 1rem; 783 + background: #007aff; 784 + color: white; 785 + border: none; 786 + border-radius: 12px; 787 + font-weight: 600; 788 + font-size: 1rem; 789 + cursor: pointer; 790 + } 791 + 792 + .save-settings-btn:disabled { 793 + background: #ccc; 794 + cursor: not-allowed; 795 + } 796 + 728 797 .last-sync-info { 729 - margin: 0.5rem 0 1rem 0; 798 + margin: 1rem 0; 730 799 font-size: 0.85rem; 731 800 color: #666; 732 801 text-align: center; ··· 735 804 .sync-btn { 736 805 width: 100%; 737 806 padding: 1rem; 807 + margin-top: 1rem; 738 808 background: #34c759; 739 809 color: white; 740 810 border: none; ··· 791 861 } 792 862 793 863 body.dark .save-webhook-btn:disabled { 864 + background: #555; 865 + } 866 + 867 + body.dark .save-settings-btn { 868 + background: #0a84ff; 869 + } 870 + 871 + body.dark .save-settings-btn:disabled { 794 872 background: #555; 795 873 } 796 874
+78 -16
src/App.tsx
··· 26 26 const [savedUrls, setSavedUrls] = useState<SavedUrl[]>([]); 27 27 const [allTags, setAllTags] = useState<TagStats[]>([]); 28 28 const [editingUrlId, setEditingUrlId] = useState<string | null>(null); 29 + const [editingUrlValue, setEditingUrlValue] = useState(""); 29 30 const [editingTags, setEditingTags] = useState<Set<string>>(new Set()); 30 31 const [editingUrlTags, setEditingUrlTags] = useState<TagStats[]>([]); // Domain-boosted tags for editing 31 32 const [newTagInput, setNewTagInput] = useState(""); ··· 33 34 const [showSettings, setShowSettings] = useState(false); 34 35 const [webhookUrl, setWebhookUrl] = useState(""); 35 36 const [webhookUrlInput, setWebhookUrlInput] = useState(""); 37 + const [webhookApiKey, setWebhookApiKey] = useState(""); 38 + const [webhookApiKeyInput, setWebhookApiKeyInput] = useState(""); 39 + const [showApiKey, setShowApiKey] = useState(false); 36 40 const [isSyncing, setIsSyncing] = useState(false); 37 41 const [syncMessage, setSyncMessage] = useState<string | null>(null); 38 42 const [lastSync, setLastSync] = useState<string | null>(null); ··· 94 98 95 99 loadData(); 96 100 loadWebhookUrl(); 101 + loadWebhookApiKey(); 97 102 loadLastSync(); 98 103 tryAutoSync(); 99 104 ··· 123 128 } 124 129 }; 125 130 131 + const loadWebhookApiKey = async () => { 132 + try { 133 + const key = await invoke<string | null>("get_webhook_api_key"); 134 + if (key) { 135 + setWebhookApiKey(key); 136 + setWebhookApiKeyInput(key); 137 + } 138 + } catch (error) { 139 + console.error("Failed to load webhook API key:", error); 140 + } 141 + }; 142 + 126 143 const loadLastSync = async () => { 127 144 try { 128 145 const sync = await invoke<string | null>("get_last_sync"); ··· 132 149 } 133 150 }; 134 151 135 - const saveWebhookUrl = async () => { 152 + const saveWebhookSettings = async () => { 136 153 try { 137 154 await invoke("set_webhook_url", { url: webhookUrlInput }); 155 + await invoke("set_webhook_api_key", { key: webhookApiKeyInput }); 138 156 setWebhookUrl(webhookUrlInput); 139 - setSyncMessage("Webhook URL saved"); 157 + setWebhookApiKey(webhookApiKeyInput); 158 + setSyncMessage("Settings saved"); 140 159 setTimeout(() => setSyncMessage(null), 2000); 141 160 } catch (error) { 142 - console.error("Failed to save webhook URL:", error); 143 - setSyncMessage("Failed to save webhook URL"); 161 + console.error("Failed to save webhook settings:", error); 162 + setSyncMessage("Failed to save settings"); 144 163 setTimeout(() => setSyncMessage(null), 3000); 145 164 } 146 165 }; ··· 189 208 190 209 const startEditing = async (item: SavedUrl) => { 191 210 setEditingUrlId(item.id); 211 + setEditingUrlValue(item.url); 192 212 setEditingTags(new Set(item.tags)); 193 213 setNewTagInput(""); 194 214 ··· 204 224 205 225 const cancelEditing = () => { 206 226 setEditingUrlId(null); 227 + setEditingUrlValue(""); 207 228 setEditingTags(new Set()); 208 229 setEditingUrlTags([]); 209 230 setNewTagInput(""); ··· 257 278 } 258 279 }; 259 280 260 - const saveTagChanges = async () => { 281 + const saveChanges = async () => { 261 282 if (!editingUrlId) return; 262 283 263 284 // If there's text in the new tag field, add it first (matches share extension behavior) ··· 274 295 } 275 296 276 297 const tagsArray = Array.from(finalTags); 277 - console.log("[Frontend] saveTagChanges called"); 298 + console.log("[Frontend] saveChanges called"); 278 299 console.log("[Frontend] editingUrlId:", editingUrlId); 300 + console.log("[Frontend] url:", editingUrlValue); 279 301 console.log("[Frontend] tags to save:", tagsArray); 280 302 281 303 try { 282 - await invoke("update_url_tags", { 304 + await invoke("update_url", { 283 305 id: editingUrlId, 306 + url: editingUrlValue, 284 307 tags: tagsArray, 285 308 }); 286 - console.log("[Frontend] update_url_tags invoke succeeded"); 309 + console.log("[Frontend] update_url invoke succeeded"); 287 310 await loadSavedUrls(); 288 311 await loadAllTags(); 289 312 cancelEditing(); 290 313 } catch (error) { 291 - console.error("[Frontend] Failed to update tags:", error); 314 + console.error("[Frontend] Failed to update URL:", error); 292 315 } 293 316 }; 294 317 ··· 301 324 302 325 return ( 303 326 <div key={item.id} className="saved-url-item editing"> 304 - <div className="edit-url-display">{item.url}</div> 327 + <div className="edit-section"> 328 + <input 329 + type="url" 330 + className="edit-url-input" 331 + value={editingUrlValue} 332 + onChange={(e) => setEditingUrlValue(e.target.value)} 333 + placeholder="URL" 334 + autoCapitalize="none" 335 + autoCorrect="off" 336 + /> 337 + </div> 305 338 306 339 <div className="edit-section"> 307 340 <div className="editing-tags"> ··· 358 391 <button className="cancel-btn" onClick={cancelEditing}> 359 392 Cancel 360 393 </button> 361 - <button className="save-btn" onClick={saveTagChanges}> 394 + <button className="save-btn" onClick={saveChanges}> 362 395 Save 363 396 </button> 364 397 </div> ··· 412 445 <div className="settings-section"> 413 446 <h2>Webhook Sync</h2> 414 447 <p className="settings-description"> 415 - Enter a webhook URL to sync all your saved URLs. The webhook will receive a POST request with all URLs and their tags. 448 + Enter a webhook URL to sync your saved URLs. URLs are automatically sent when saved, and you can also sync all URLs manually. 416 449 </p> 417 450 418 451 <div className="webhook-input"> ··· 424 457 autoCapitalize="none" 425 458 autoCorrect="off" 426 459 /> 460 + </div> 461 + 462 + <div className="webhook-input api-key-field"> 463 + <input 464 + type={showApiKey ? "text" : "password"} 465 + value={webhookApiKeyInput} 466 + onChange={(e) => setWebhookApiKeyInput(e.target.value)} 467 + placeholder="API key (optional)" 468 + autoCapitalize="none" 469 + autoCorrect="off" 470 + /> 427 471 <button 428 - onClick={saveWebhookUrl} 429 - disabled={webhookUrlInput === webhookUrl} 430 - className="save-webhook-btn" 472 + type="button" 473 + className="toggle-visibility-btn" 474 + onClick={() => setShowApiKey(!showApiKey)} 431 475 > 432 - Save 476 + {showApiKey ? ( 477 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 478 + <path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path> 479 + <line x1="1" y1="1" x2="23" y2="23"></line> 480 + </svg> 481 + ) : ( 482 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 483 + <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path> 484 + <circle cx="12" cy="12" r="3"></circle> 485 + </svg> 486 + )} 433 487 </button> 434 488 </div> 489 + 490 + <button 491 + onClick={saveWebhookSettings} 492 + disabled={webhookUrlInput === webhookUrl && webhookApiKeyInput === webhookApiKey} 493 + className="save-settings-btn" 494 + > 495 + Save Settings 496 + </button> 435 497 436 498 {lastSync && ( 437 499 <p className="last-sync-info">
-1
src/assets/react.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>