Kaynağa Gözat

add move-to-playlist option in video more menu

Adds a "Move to…" entry in the ⋯ more menu of the playlist detail view.
Clicking it expands an inline sub-list of the other playlists; selecting
one atomically moves the video there in a single storage write, preserving
the at-most-one-playlist invariant and the video's existing status.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Brandon Wong 2 hafta önce
ebeveyn
işleme
36580f6c70

+ 35 - 0
2026-06-08-claude-move-video-between-playlists.md

@@ -0,0 +1,35 @@
+# Move Video Between Playlists
+
+**Date:** 2026-06-08
+
+## User Request
+
+> I would like the option of moving a video in one playlist to a different playlist (while maintaining the guarantee that a video will be in at most one playlist). what would you recommend to support this action in the UI?
+
+## What Was Done
+
+Added a "Move to…" entry to the ⋯ more menu in the playlist detail view (single-playlist view). Clicking it expands an inline sub-list of the other five playlists; selecting one atomically removes the video from the current playlist and appends it to the target.
+
+### Files Changed
+
+**`popup/popup.js`**
+- Added `moveSubmenuOpenFor: null` state (tracks which video's move submenu is open by menuId key)
+- Added `otherPlaylists: []` state (array of `{name, display}` objects for the other playlists)
+- Updated `updateCurrentPlaylistVideos()` to recompute `otherPlaylists` using `formatPlaylistName()` for display labels
+- Added `async moveVideoToPlaylist(fromPlaylistName, videoIndex, toPlaylistName)` — splices video from source array, appends to target, single `storage.set` call (preserves video object including status)
+- Updated `closeAllMenus()` and `toggleMenu()` to reset `moveSubmenuOpenFor`
+- Added three new binding objects: `moveToSubmenuButton` (toggle), `moveToSubmenu` (x-show), `moveToPlaylistButton` (execute move)
+
+**`popup/popup.html`**
+- Added "Move to…" button and collapsible sub-list inside the playlist detail view's ⋯ more menu, after the Remove button
+- Sub-list uses `x-for="pl in otherPlaylists"` with `x-text="pl.display"` and `data-to-playlist-name="pl.name"`
+
+**`popup/popup.css`**
+- Added styles for `.move-to-playlist-item`, `.move-to-submenu` (separator border), and `.move-to-playlist-option` (indented, blue text with hover)
+
+### Design Notes
+
+- The one-playlist-per-video invariant is maintained because `moveVideoToPlaylist` splices from the source before appending to the target in a single atomic storage write — no window where the video exists in two playlists
+- Video status (done, timestamp) is preserved on move
+- All Alpine.js bindings are CSP-compliant (property accesses only, no inline expressions)
+- `otherPlaylists` is pre-computed with formatted display names so `x-text="pl.display"` needs no method call in the template

+ 17 - 0
popup/popup.css

@@ -900,3 +900,20 @@ button:hover {
   column-gap: 4px;
   margin-bottom: 12px;
 }
+
+.move-to-playlist-item {
+  color: #555;
+}
+
+.move-to-submenu {
+  border-top: 1px solid #eee;
+}
+
+.move-to-playlist-option {
+  padding-left: 20px;
+  color: #4285f4;
+}
+
+.move-to-playlist-option:hover {
+  background-color: #e8f0fe;
+}

+ 25 - 0
popup/popup.html

@@ -435,6 +435,31 @@
                     >
                       Remove
                     </button>
+                    <button
+                      :data-playlist-name="currentPlaylistName"
+                      :data-playlist-index="index"
+                      x-bind="moveToSubmenuButton"
+                      class="menu-item move-to-playlist-item"
+                    >
+                      Move to…
+                    </button>
+                    <div
+                      class="move-to-submenu"
+                      :data-playlist-name="currentPlaylistName"
+                      :data-playlist-index="index"
+                      x-bind="moveToSubmenu"
+                    >
+                      <template x-for="pl in otherPlaylists" :key="pl.name">
+                        <button
+                          class="menu-item move-to-playlist-option"
+                          :data-from-playlist-name="currentPlaylistName"
+                          :data-playlist-index="index"
+                          :data-to-playlist-name="pl.name"
+                          x-bind="moveToPlaylistButton"
+                          x-text="pl.display"
+                        ></button>
+                      </template>
+                    </div>
                   </div>
                 </div>
               </div>

+ 49 - 1
popup/popup.js

@@ -25,10 +25,12 @@ document.addEventListener("alpine:init", () => {
     history: {},
     sortedHistory: [],
     openMenus: new Set(), // Track which menus are open
+    moveSubmenuOpenFor: null, // Track which video has the move-to submenu open (menuId or null)
     currentView: "playlists", // Track current view: 'playlists', 'history', 'playlist', 'saveChannel', or 'wikiInsp'
     currentPlaylistName: "", // Track which playlist is being viewed
     playlistsForDisplay: [], // Computed array for display
     currentPlaylistVideos: [], // Videos for current playlist view
+    otherPlaylists: [], // Playlist names (with display labels) other than currentPlaylistName
     currentTab: null, // Current active tab info
     isCurrentTabYoutube: false, // Whether current tab is YouTube
     isCurrentTabChannelPage: false, // Whether current tab is YouTube videos page
@@ -146,6 +148,7 @@ document.addEventListener("alpine:init", () => {
         !this.playlists[this.currentPlaylistName]
       ) {
         this.currentPlaylistVideos = [];
+        this.otherPlaylists = [];
       } else {
         this.currentPlaylistVideos = this.playlists[
           this.currentPlaylistName
@@ -158,6 +161,9 @@ document.addEventListener("alpine:init", () => {
             index,
           ),
         }));
+        this.otherPlaylists = Object.keys(this.playlists)
+          .filter((name) => name !== this.currentPlaylistName)
+          .map((name) => ({ name, display: this.formatPlaylistName(name) }));
       }
     },
 
@@ -657,6 +663,20 @@ document.addEventListener("alpine:init", () => {
       }
     },
 
+    async moveVideoToPlaylist(fromPlaylistName, videoIndex, toPlaylistName) {
+      const playlists = JSON.parse(JSON.stringify(this.playlists));
+      const video = playlists[fromPlaylistName].splice(videoIndex, 1)[0];
+      playlists[toPlaylistName].push(video);
+      try {
+        await browser.storage.local.set({ playlists });
+        this.playlists = playlists;
+        this.updatePlaylistsForDisplay();
+        this.updateCurrentPlaylistVideos();
+      } catch (error) {
+        console.error("Error moving video to playlist:", error);
+      }
+    },
+
     findTopPosition(playlistName) {
       const playlist = this.playlists[playlistName];
       if (!playlist) return 0;
@@ -942,6 +962,33 @@ document.addEventListener("alpine:init", () => {
       },
     },
 
+    moveToSubmenuButton: {
+      ["@click.stop"]() {
+        const playlistName = this.$el.dataset.playlistName;
+        const index = parseInt(this.$el.dataset.playlistIndex);
+        const menuId = this.getMenuId(playlistName, index);
+        this.moveSubmenuOpenFor = this.moveSubmenuOpenFor === menuId ? null : menuId;
+      },
+    },
+
+    moveToSubmenu: {
+      ["x-show"]() {
+        const playlistName = this.$el.dataset.playlistName;
+        const index = parseInt(this.$el.dataset.playlistIndex);
+        return this.moveSubmenuOpenFor === this.getMenuId(playlistName, index);
+      },
+    },
+
+    moveToPlaylistButton: {
+      ["@click"]() {
+        const fromPlaylistName = this.$el.dataset.fromPlaylistName;
+        const videoIndex = parseInt(this.$el.dataset.playlistIndex);
+        const toPlaylistName = this.$el.dataset.toPlaylistName;
+        this.moveVideoToPlaylist(fromPlaylistName, videoIndex, toPlaylistName);
+        this.closeAllMenus();
+      },
+    },
+
     exportButton: {
       ["@click"]() {
         this.exportPlaylists();
@@ -1089,14 +1136,15 @@ document.addEventListener("alpine:init", () => {
       if (this.openMenus.has(menuId)) {
         this.openMenus.delete(menuId);
       } else {
-        // Close all other menus first
         this.openMenus.clear();
         this.openMenus.add(menuId);
       }
+      this.moveSubmenuOpenFor = null;
     },
 
     closeAllMenus() {
       this.openMenus.clear();
+      this.moveSubmenuOpenFor = null;
     },
 
     moreMenuButton: {