Browse Source

feat: add save channel functionality with curl command generation

at the bottom of the popup, add a new button called "save channel". clicking it will switch to a new view (or page). the view is only active if the currently open tab's url contains `youtube.com.*/videos`. at the top of the view, there is a texteditable field with a copy button. the copy button will copy the contents of the field to the clipboard (add clipboard permission to the extension manifest *IF* necessary). underneath the field and the copy button is a list of "categories", and clicking on the categories will modify the contents of the field. the field will be populated with the following string:

```
curl -X POST -H 'content-type: application/json' -d '{"query": "mutation add{ addChannel(details: {category: \"<category>\", checkInterval: 14, name: \"<title>\", url: \"<url>\"}){name} } "}' localhost:8543/data | jq '.'
```

where `<category>` is the currently selected category (defaulting to the first one), `<title>` is the page title of the currently active tab with the trailing " - YouTube" removed, and `<url>` is the url of the currently active tab. for now, start with these categories: "FOR BOTH", "CHANNELS", and "CREATIVE".

```git-revs
6c2f424  (Base revision)
5b16600  Add clipboard permission for copy functionality in save channel feature
87abcf9  Add save channel header and view to HTML
ea7f26a  Add save channel view container to HTML
5d4c522  Add Save Channel button to the main view
1084413  Add save channel properties to Alpine.js data object
7e79988  Update getCurrentTab to detect YouTube videos pages and initialize curl command
b179fae  Add save channel methods after updateAddCurrentPageButtonText method
9b92ab9  Add save channel view header binding
d8ef0c0  Add save channel container binding
ef7c3f1  Add save channel button binding
aeda96c  Add save channel specific bindings (copy button, category buttons, notice)
60289eb  Add CSS styles for save channel functionality
041a0cb  Create documentation file for save channel feature
2836498  Add disabled state to copy button when not on YouTube videos page
09b60b0  Add disabled state styling for copy button
3d383a3  Rename isCurrentTabYoutubeVideos to isCurrentTabChannelPage in property declaration
912a230  Rename isCurrentTabYoutubeVideos to isCurrentTabChannelPage in getCurrentTab method
192dd0c  Rename isCurrentTabYoutubeVideos to isCurrentTabChannelPage in updateCurlCommand method
b8294b5  Rename isCurrentTabYoutubeVideos to isCurrentTabChannelPage in copyCurlButton binding
6cdcee1  Rename isCurrentTabYoutubeVideos to isCurrentTabChannelPage in saveChannelNotice binding
d8e9fdd  Add checkInterval property to save channel properties
fca7889  Update updateCurlCommand to use checkInterval property
a993eda  Add method to update check interval
52c3488  Add interval input section between copy button and categories
84ebdf2  Add interval input binding for two-way data binding
bc4e3e0  Add CSS styling for interval input section
354c388  Add active state CSS for copy button visual feedback
HEAD     Update documentation with interval input feature and editable textarea consideration
```

codemcp-id: 12-feat-add-save-channel-functionality-with-curl-comm
Brandon Wong 1 year ago
parent
commit
c42fa43d3c

+ 125 - 0
2025-06-06-codemcp-feat-add-save-channel-functionality-with-curl-comm.md

@@ -0,0 +1,125 @@
+# Save Channel Functionality Implementation
+
+## User Request
+Add a "save channel" button at the bottom of the popup that switches to a new view. The view is only active if the currently open tab's URL contains `youtube.com.*/videos`. The view contains:
+
+1. A text-editable field with a copy button that copies the contents to clipboard
+2. An interval number input to modify the checkInterval field
+3. A list of categories that modify the field contents when clicked
+4. The field is populated with a curl command template
+
+Template string:
+```
+curl -X POST -H 'content-type: application/json' -d '{"query": "mutation add{ addChannel(details: {category: \"<category>\", checkInterval: <interval>, name: \"<title>\", url: \"<url>\"}){name} } "}' localhost:8543/data | jq '.'
+```
+
+Where:
+- `<category>` is the currently selected category (defaulting to "FOR BOTH")
+- `<interval>` is the check interval in days (defaulting to 14)
+- `<title>` is the page title with trailing " - YouTube" removed
+- `<url>` is the URL of the currently active tab
+
+Categories: "FOR BOTH", "CHANNELS", "CREATIVE"
+
+## Implementation Details
+
+### 1. Manifest Changes
+- Added `clipboardWrite` permission for copy functionality
+
+### 2. HTML Structure
+- Added new save channel header with back button
+- Added save channel container with:
+  - Curl command textarea (readonly)
+  - Copy button with active state styling
+  - Interval number input (defaulting to 14 days)
+  - Category selection buttons
+  - Notice for non-YouTube videos pages
+- Added "Save Channel" button to the export container
+
+### 3. JavaScript Functionality
+- Extended `currentView` to include "saveChannel" state
+- Added properties:
+  - `isCurrentTabChannelPage`: Detects YouTube /videos pages (renamed from `isCurrentTabYoutubeVideos`)
+  - `selectedCategory`: Tracks selected category (default "FOR BOTH")
+  - `availableCategories`: Array of category options
+  - `curlCommand`: Generated curl command string
+  - `checkInterval`: Check interval in days (default 14)
+
+- Added methods:
+  - `updateCurlCommand()`: Generates curl command based on current tab, category, and interval
+  - `copyCurlCommand()`: Copies command to clipboard with fallback
+  - `selectCategory(category)`: Updates selected category and regenerates command
+  - `updateCheckInterval(interval)`: Updates check interval with validation and regenerates command
+  - `showSaveChannel()`: Switches to save channel view
+
+- Added event bindings (CSP-compliant):
+  - `saveChannelButton`: Click handler for main save channel button
+  - `saveChannelContainer`: View visibility binding
+  - `saveChannelHeader`: Header visibility binding
+  - `copyCurlButton`: Copy button click handler with disabled state for non-channel pages
+  - `categoryButton`: Category selection with active state styling
+  - `intervalInput`: Two-way binding for interval number input
+  - `saveChannelNotice`: Notice visibility for non-YouTube videos pages
+
+### 4. CSS Styling
+- Save channel container with proper spacing and shadow
+- Monospace textarea for curl command display
+- Copy button with hover, active, and disabled states
+- Interval input section with label and validation styling
+- Category buttons with hover and active states
+- Notice styling for YouTube videos page requirement
+- Orange-themed save channel button
+
+### 5. YouTube Videos Page Detection
+The feature only activates when the current tab URL contains `/videos`, which typically corresponds to YouTube channel video listing pages (e.g., `https://www.youtube.com/@channel/videos`).
+
+### 6. Copy Functionality
+Implements both modern clipboard API and fallback for older browsers using document.execCommand. The copy button is disabled when not on a valid YouTube channel page.
+
+### 7. Interval Input
+- Number input with minimum value of 1 day
+- Default value of 14 days
+- Real-time updates to curl command as user types
+- Input validation with fallback to default value for invalid inputs
+
+## Files Modified
+- `manifest.json`: Added clipboardWrite permission
+- `popup/popup.html`: Added save channel view, button, and interval input
+- `popup/popup.js`: Added save channel functionality, interval handling, and bindings
+- `popup/popup.css`: Added styling for save channel components and interval input
+
+## Usage
+1. Navigate to a YouTube channel's videos page (URL contains /videos)
+2. Click "Save Channel" button in the extension popup
+3. Optionally adjust the check interval (defaults to 14 days)
+4. Select desired category from the buttons
+5. Click "Copy" to copy the generated curl command to clipboard
+6. Use the curl command to add the channel to your external system
+
+The feature gracefully handles non-YouTube pages by showing an informational notice and disabling the copy button.
+
+## Future Enhancement Consideration: Editable Textarea
+
+### Current State
+The textarea field is currently `readonly` and auto-generated based on user selections (category and interval).
+
+### Potential Enhancement
+Making the textarea user-editable would provide additional flexibility. Implementation considerations:
+
+#### Required Changes:
+1. **HTML**: Remove `readonly` attribute from textarea
+2. **JavaScript**: Switch from `x-text` to `x-model` for two-way binding
+3. **Update Logic**: Handle conflicts between manual edits and automatic updates
+
+#### Implementation Options:
+1. **Template-based**: Store template and regenerate only dynamic parts
+2. **Parsing approach**: Parse existing command to update specific fields
+3. **Hybrid**: Allow manual edits with "Reset to Template" button
+
+#### UX Considerations:
+- **Conflict resolution**: Behavior when user edits manually, then changes category/interval
+- **Validation**: Validate edited commands for JSON/GraphQL correctness
+- **Visual indicators**: Show when command is manually modified vs auto-generated
+- **Reset functionality**: Button to return to auto-generated version
+
+This enhancement would balance user flexibility with automatic generation convenience, but requires careful consideration of the user experience and conflict resolution strategies.

+ 1 - 0
manifest.json

@@ -10,6 +10,7 @@
     "contextMenus",
     "notifications",
     "webNavigation",
+    "clipboardWrite",
     "<all_urls>"
   ],
   "browser_action": {

+ 181 - 0
popup/popup.css

@@ -449,3 +449,184 @@ button:hover {
   transform: none;
   box-shadow: none;
 }
+
+/* Save Channel view styles */
+.save-channel-container {
+  background: #fff;
+  border-radius: 8px;
+  padding: 16px;
+  margin-bottom: 16px;
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+  min-height: 300px;
+}
+
+.save-channel-content {
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+}
+
+.curl-command-section {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.curl-field-container {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.curl-command-field {
+  width: 100%;
+  min-height: 120px;
+  padding: 12px;
+  border: 1px solid #ddd;
+  border-radius: 6px;
+  font-family: monospace;
+  font-size: 12px;
+  background-color: #f8f9fa;
+  resize: vertical;
+  line-height: 1.4;
+}
+
+.copy-curl-btn {
+  align-self: flex-start;
+  background-color: #4285f4;
+  color: white;
+  padding: 8px 16px;
+  border-radius: 4px;
+  font-size: 14px;
+  font-weight: 500;
+  transition: all 0.2s ease;
+}
+
+.copy-curl-btn:hover {
+  background-color: #3367d6;
+  transform: translateY(-1px);
+  box-shadow: 0 2px 6px rgba(66, 133, 244, 0.3);
+}
+
+.copy-curl-btn:active {
+  background-color: #2d5aa0;
+  transform: translateY(0);
+  box-shadow: 0 1px 3px rgba(66, 133, 244, 0.4);
+  transition: all 0.1s ease;
+}
+
+.copy-curl-btn:disabled {
+  background-color: #ccc;
+  color: #888;
+  cursor: not-allowed;
+  transform: none;
+  box-shadow: none;
+}
+
+.copy-curl-btn:disabled:hover {
+  background-color: #ccc;
+  transform: none;
+  box-shadow: none;
+}
+
+.interval-section {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.interval-label {
+  font-size: 14px;
+  font-weight: 500;
+  color: #333;
+}
+
+.interval-input {
+  width: 100px;
+  padding: 8px 12px;
+  border: 1px solid #ddd;
+  border-radius: 6px;
+  font-size: 14px;
+  font-family: inherit;
+  background-color: #fff;
+  transition: border-color 0.2s ease, box-shadow 0.2s ease;
+}
+
+.interval-input:focus {
+  outline: none;
+  border-color: #4285f4;
+  box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.2);
+}
+
+.interval-input:invalid {
+  border-color: #f44336;
+}
+
+.category-section h3 {
+  margin-bottom: 12px;
+  color: #333;
+  font-size: 16px;
+  font-weight: 500;
+}
+
+.category-buttons {
+  display: flex;
+  gap: 8px;
+  flex-wrap: wrap;
+}
+
+.category-btn {
+  padding: 8px 16px;
+  border: 2px solid #ddd;
+  border-radius: 6px;
+  background-color: #f8f9fa;
+  color: #666;
+  font-size: 14px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.2s ease;
+}
+
+.category-btn:hover {
+  border-color: #4285f4;
+  background-color: #e8f0fe;
+  color: #4285f4;
+}
+
+.category-btn.active {
+  border-color: #4285f4;
+  background-color: #4285f4;
+  color: white;
+}
+
+.category-btn.active:hover {
+  border-color: #3367d6;
+  background-color: #3367d6;
+}
+
+.save-channel-notice {
+  background-color: #fff3cd;
+  border: 1px solid #ffeaa7;
+  border-radius: 6px;
+  padding: 12px;
+  color: #856404;
+  font-size: 14px;
+}
+
+.save-channel-notice p {
+  margin: 0;
+}
+
+/* Save Channel button styles */
+.save-channel-btn {
+  background-color: #ff6d01;
+  color: white;
+  padding: 8px 12px;
+  border-radius: 4px;
+  font-size: 14px;
+  font-weight: 500;
+}
+
+.save-channel-btn:hover {
+  background-color: #e55a00;
+}

+ 57 - 0
popup/popup.html

@@ -25,6 +25,11 @@
         <h1 x-text="currentPlaylistName"></h1>
       </header>
 
+      <header x-bind="saveChannelHeader">
+        <button class="back-btn-arrow" x-bind="backButton">←</button>
+        <h1>Save Channel</h1>
+      </header>
+
       <div class="playlists-container" x-bind="playlistsContainer">
         <template
           x-for="playlistData in playlistsForDisplay"
@@ -191,6 +196,55 @@
         </div>
       </div>
 
+      <!-- Save Channel view -->
+      <div class="save-channel-container" x-bind="saveChannelContainer">
+        <div class="save-channel-content">
+          <div class="curl-command-section">
+            <div class="curl-field-container">
+              <textarea
+                class="curl-command-field"
+                x-text="curlCommand"
+                readonly
+              ></textarea>
+              <button
+                class="copy-curl-btn"
+                x-bind="copyCurlButton"
+                title="Copy to clipboard"
+              >
+                Copy
+              </button>
+            </div>
+          </div>
+          <div class="interval-section">
+            <label for="check-interval-input" class="interval-label">Check Interval (days):</label>
+            <input
+              type="number"
+              id="check-interval-input"
+              class="interval-input"
+              min="1"
+              step="1"
+              x-bind="intervalInput"
+            />
+          </div>
+          <div class="category-section">
+            <h3>Categories</h3>
+            <div class="category-buttons">
+              <template x-for="category in availableCategories" :key="category">
+                <button
+                  class="category-btn"
+                  :data-category="category"
+                  x-bind="categoryButton"
+                  x-text="category"
+                ></button>
+              </template>
+            </div>
+          </div>
+          <div class="save-channel-notice" x-bind="saveChannelNotice">
+            <p>This feature is only available on YouTube channel pages (/videos).</p>
+          </div>
+        </div>
+      </div>
+
       <!-- Individual playlist view -->
       <div class="playlist-view-container" x-bind="playlistViewContainer">
         <div class="playlist-full-view">
@@ -289,6 +343,9 @@
         <button class="history-btn" x-bind="historyButton">
           History
         </button>
+        <button class="save-channel-btn" x-bind="saveChannelButton">
+          Save Channel
+        </button>
         <input
           type="file"
           id="import-file-input"

+ 122 - 1
popup/popup.js

@@ -23,14 +23,21 @@ document.addEventListener("alpine:init", () => {
     history: {},
     sortedHistory: [],
     openMenus: new Set(), // Track which menus are open
-    currentView: "playlists", // Track current view: 'playlists', 'history', or 'playlist'
+    currentView: "playlists", // Track current view: 'playlists', 'history', 'playlist', or 'saveChannel'
     currentPlaylistName: "", // Track which playlist is being viewed
     playlistsForDisplay: [], // Computed array for display
     currentPlaylistVideos: [], // Videos for current playlist view
     currentTab: null, // Current active tab info
     isCurrentTabYoutube: false, // Whether current tab is YouTube
+    isCurrentTabChannelPage: false, // Whether current tab is YouTube videos page
     addCurrentPageButtonText: "Add Current Page", // Button text
 
+    // Save channel properties
+    selectedCategory: "FOR BOTH", // Currently selected category
+    availableCategories: ["FOR BOTH", "CHANNELS", "CREATIVE"], // Available categories
+    curlCommand: "", // Generated curl command
+    checkInterval: 14, // Check interval in days
+
     init() {
       this.loadPlaylists();
       this.loadHistory();
@@ -139,13 +146,17 @@ document.addEventListener("alpine:init", () => {
           this.currentTab = tabs[0];
           const url = new URL(this.currentTab.url);
           this.isCurrentTabYoutube = url.hostname === "www.youtube.com";
+          this.isCurrentTabChannelPage = this.isCurrentTabYoutube && url.pathname.includes("/videos");
           this.updateAddCurrentPageButtonText();
+          this.updateCurlCommand();
         }
       } catch (error) {
         console.error("Error getting current tab:", error);
         this.currentTab = null;
         this.isCurrentTabYoutube = false;
+        this.isCurrentTabChannelPage = false;
         this.updateAddCurrentPageButtonText();
+        this.updateCurlCommand();
       }
     },
 
@@ -159,6 +170,60 @@ document.addEventListener("alpine:init", () => {
       }
     },
 
+    updateCurlCommand() {
+      if (!this.currentTab || !this.isCurrentTabChannelPage) {
+        this.curlCommand = "This feature is only available on YouTube channel pages (/videos).";
+        return;
+      }
+
+      const title = this.currentTab.title.replace(" - YouTube", "");
+      const url = this.currentTab.url;
+      const category = this.selectedCategory;
+      const interval = this.checkInterval;
+
+      this.curlCommand = `curl -X POST -H 'content-type: application/json' -d '{"query": "mutation add{ addChannel(details: {category: \\"${category}\\", checkInterval: ${interval}, name: \\"${title}\\", url: \\"${url}\\"}){name} } "}' localhost:8543/data | jq '.'`;
+    },
+
+    async copyCurlCommand() {
+      try {
+        await navigator.clipboard.writeText(this.curlCommand);
+        console.log("Curl command copied to clipboard");
+        // Could add visual feedback here
+      } catch (error) {
+        console.error("Failed to copy to clipboard:", error);
+        // Fallback for older browsers
+        try {
+          const textArea = document.createElement("textarea");
+          textArea.value = this.curlCommand;
+          document.body.appendChild(textArea);
+          textArea.focus();
+          textArea.select();
+          document.execCommand("copy");
+          document.body.removeChild(textArea);
+          console.log("Curl command copied to clipboard (fallback)");
+        } catch (fallbackError) {
+          console.error("Fallback copy failed:", fallbackError);
+        }
+      }
+    },
+
+    selectCategory(category) {
+      this.selectedCategory = category;
+      this.updateCurlCommand();
+    },
+
+    updateCheckInterval(interval) {
+      // Ensure it's a positive integer, default to 14 if invalid
+      const parsedInterval = parseInt(interval);
+      this.checkInterval = parsedInterval > 0 ? parsedInterval : 14;
+      this.updateCurlCommand();
+    },
+
+    showSaveChannel() {
+      this.currentView = "saveChannel";
+      this.updateCurlCommand();
+    },
+
     sortHistoryByRecentInteraction() {
       // Convert history object to array with video ID and sort by most recent interaction
       this.sortedHistory = Object.entries(this.history)
@@ -811,6 +876,12 @@ document.addEventListener("alpine:init", () => {
       },
     },
 
+    saveChannelButton: {
+      ["@click"]() {
+        this.showSaveChannel();
+      },
+    },
+
     backButton: {
       ["@click"]() {
         this.showPlaylists();
@@ -872,6 +943,12 @@ document.addEventListener("alpine:init", () => {
       },
     },
 
+    saveChannelHeader: {
+      ["x-show"]() {
+        return this.currentView === "saveChannel";
+      },
+    },
+
     playlistsContainer: {
       ["x-show"]() {
         return this.currentView === "playlists";
@@ -890,6 +967,12 @@ document.addEventListener("alpine:init", () => {
       },
     },
 
+    saveChannelContainer: {
+      ["x-show"]() {
+        return this.currentView === "saveChannel";
+      },
+    },
+
     exportContainer: {
       ["x-show"]() {
         return this.currentView === "playlists";
@@ -926,6 +1009,44 @@ document.addEventListener("alpine:init", () => {
       },
     },
 
+    // Save Channel specific bindings
+    copyCurlButton: {
+      ["@click"]() {
+        this.copyCurlCommand();
+      },
+      [":disabled"]() {
+        return !this.isCurrentTabChannelPage;
+      },
+    },
+
+    categoryButton: {
+      ["@click"]() {
+        const category = this.$el.dataset.category;
+        this.selectCategory(category);
+      },
+      [":class"]() {
+        const category = this.$el.dataset.category;
+        return {
+          active: this.selectedCategory === category,
+        };
+      },
+    },
+
+    saveChannelNotice: {
+      ["x-show"]() {
+        return !this.isCurrentTabChannelPage;
+      },
+    },
+
+    intervalInput: {
+      [":value"]() {
+        return this.checkInterval;
+      },
+      ["@input"]() {
+        this.updateCheckInterval(this.$el.value);
+      },
+    },
+
     // Add current page button binding
     addCurrentPageButton: {
       ["@click"]() {