11 Commits 350b001d02 ... 8283bf214f

Auteur SHA1 Message Date
  Brandon Wong 8283bf214f manually removed "new" status; formatted code il y a 1 an
  Brandon Wong 911e9789c1 feat: add current page to playlist button in popup il y a 1 an
  Brandon Wong 0632289c39 feat: make video ID clickable and add toggleable tags system il y a 1 an
  Brandon Wong 0812197fac updated to-do list il y a 1 an
  Brandon Wong e45d0e471d fix: resolve import button functionality issues il y a 1 an
  Brandon Wong 03948d414d updated codemcp.toml with stronger language about alpine.js csp-compliance il y a 1 an
  Brandon Wong 7f17f1ca0b feat: add full playlist view with truncated display il y a 1 an
  Brandon Wong 04923d5b2c committing codemcp.toml file used (slightly adapted) for the last three commits il y a 1 an
  Brandon Wong b2c92be0ba feat: populate history page with video interaction data il y a 1 an
  Brandon Wong d7c2d0e638 feat: add history view with navigation il y a 1 an
  Brandon Wong 8dd3a17b71 feat: replace remove button with more menu for playlist items il y a 1 an

+ 80 - 0
2025-05-27-codemcp-replace-remove-button-with-more-menu.md

@@ -0,0 +1,80 @@
+# Replace Remove Button with More Menu - CodeMCP Session
+
+**Date:** 2025-05-27
+**Feature:** Replace remove button with more menu for playlist items
+**Project:** ~/personal/projects/experiments/playlist/
+
+## Summary
+
+Replaced the "remove" button in the playlist popup with a "more" button (three dots) that opens a dropdown menu containing the remove option. This provides a more space-efficient solution and allows for future expansion of additional actions per playlist item.
+
+## Chat Session Context
+
+### Initial Request
+User requested to replace the "remove" button with a "more" button represented by three dots, which opens a menu with the "remove" button as the currently only option. This was needed because there wasn't much space to add more buttons in the popup menu.
+
+### Project Context
+- Firefox browser extension managing playlists of videos (URLs)
+- Tracks video playback history
+- Uses browser storage API for data persistence
+- Popup implemented in Alpine.js with CSP mode (no JavaScript expressions in HTML attributes)
+- Background.js handles context menus and navigation
+- Content script adds video playback event listeners
+
+## Implementation Details
+
+### Files Modified
+1. `popup/popup.html` - Replaced remove button with more menu structure
+2. `popup/popup.js` - Added menu state management and Alpine.js bindings
+3. `popup/popup.css` - Added menu styling and removed old button styles
+
+### Key Changes
+
+#### HTML Structure
+```html
+<div class="more-menu-container">
+  <button x-bind="moreMenuButton" class="more-btn" title="More actions">
+    ⋯
+  </button>
+  <div class="more-menu" x-bind="moreMenu">
+    <button x-bind="removeVideoButton" class="menu-item remove-item">
+      Remove
+    </button>
+  </div>
+</div>
+```
+
+#### JavaScript Functionality
+- Added `openMenus` Set to track menu states
+- Menu management methods: `getMenuId()`, `isMenuOpen()`, `toggleMenu()`, `closeAllMenus()`
+- Alpine.js bindings for menu button and dropdown
+- Document click handler for closing menus when clicking outside
+- Updated remove handler to close menu after action
+
+#### CSS Styling
+- Positioned dropdown menu with proper z-index
+- Added hover effects and visual feedback
+- Removed old remove button styles
+- Red styling for remove action to indicate destructive action
+
+### Technical Considerations
+- Follows Alpine.js CSP mode requirements
+- Maintains existing code patterns and conventions
+- Ensures only one menu open at a time
+- Proper event handling to prevent conflicts
+- Expandable design for future menu items
+
+## Git Commits Made
+- Initial: `feat: replace remove button with more menu for playlist items`
+- Multiple amendments during development process
+- Final commit hash: `1da117d`
+
+## Outcome
+Successfully created a space-efficient, expandable menu system that:
+- Reduces visual clutter in the popup
+- Provides familiar UX pattern (three dots = more actions)
+- Allows easy addition of future actions
+- Maintains all existing functionality
+- Follows project coding standards
+
+The implementation is ready for use and future expansion with additional menu items.

+ 46 - 0
2025-05-28-codemcp-add-history-view-with-navigation.md

@@ -0,0 +1,46 @@
+# Add History View with Navigation - 2025-05-28
+
+## User Request
+Initialize codemcp with ~/personal/projects/experiments/playlist/
+
+the popup menu currently only has one "page" or view, which is to show the playlists. add a button at the bottom of the menu (next to the "export" and "import" buttons) that will switch to a new "page" (or tab, or view) that displays the history. at the top of the history page, there should be a back button to return to the main view (which is the playlists). leave the rest of the page blank for now.
+
+## Implementation Summary
+
+### Files Modified
+
+1. **popup/popup.html**
+   - Added conditional headers for playlists and history views using `x-show` directive
+   - Added history view container with placeholder content
+   - Updated export container to include history button and only show on playlists view
+   - Added back button in history header
+
+2. **popup/popup.js**
+   - Added `currentView` state property to track current view ('playlists' or 'history')
+   - Added navigation methods: `showHistory()` and `showPlaylists()`
+   - Added button bindings: `historyButton` and `backButton` using Alpine.js CSP-compliant syntax
+
+3. **popup/popup.css**
+   - Added styles for back button with blue color scheme
+   - Added styles for history container with basic white background and padding
+   - Added styles for history button with green color scheme
+
+### Key Features Added
+
+- **View State Management**: Uses Alpine.js reactive `currentView` property to control which view is shown
+- **Navigation**: History button switches to history view, back button returns to playlists view
+- **Conditional Rendering**: Uses `x-show` directives to show/hide content based on current view
+- **CSP Compliance**: All Alpine.js bindings use the object syntax to avoid inline JavaScript expressions
+- **Consistent Styling**: New buttons follow the existing design patterns and color scheme
+
+### Project Structure Context
+
+This is a Firefox browser extension that:
+- Manages playlists of videos (each video has a URL)
+- Tracks video playback history
+- Uses browser storage API for data persistence
+- Uses Alpine.js for popup UI in CSP mode
+- Has background.js for context menus and navigation
+- Has content scripts for video playback event listeners
+
+The history view is now ready for future implementation of actual history display functionality.

Fichier diff supprimé car celui-ci est trop grand
+ 111 - 0
2025-05-28-codemcp-feat-add-full-playlist-view-with-truncated-display.md


+ 77 - 0
2025-05-28-codemcp-feat-populate-history-page-with-video-interaction.md

@@ -0,0 +1,77 @@
+# Populate History Page with Video Interaction Data
+
+## Context
+Date: 2025-05-28
+Feature: Populate the empty history page/view with video interaction data
+
+## User Request
+The newly-created history "page" (or view or tab) is currently empty. Populate it with history (see background.js for details), ordered, with the most recently interacted video at the top. For each video, put the title, the "v" param (id) in parentheses, and the list of events. Put human-readable timestamps.
+
+## Analysis
+From examining the codebase:
+
+1. **History Data Structure** (from background.js):
+   - History is stored in browser.storage.local with key "history"
+   - Each video is keyed by video ID (the "v" parameter from YouTube URL)
+   - Each video entry contains:
+     - `url`: Original video URL
+     - `title`: Video title
+     - `duration`: Video duration
+     - `history`: Array of interaction events
+   - Each event contains:
+     - `action`: Type of event ("play", "playing", "pause", "ended")
+     - `position`: Timestamp in video where event occurred
+     - `timestamp`: Unix timestamp when event was recorded
+
+2. **Existing UI Structure**:
+   - History view framework already exists in popup.html
+   - Navigation between playlists and history views implemented
+   - CSS container for history already present but empty
+
+## Implementation
+
+### 1. Added History Data Management (popup.js)
+- Added `history` and `sortedHistory` properties to Alpine.js data
+- Created `loadHistory()` method to fetch data from browser storage
+- Created `sortHistoryByRecentInteraction()` to sort videos by most recent interaction
+- Added utility methods:
+  - `formatTimestamp()`: Human-readable relative timestamps ("2 hours ago", etc.)
+  - `formatVideoPosition()`: Convert seconds to MM:SS format
+  - `formatActionName()`: Convert action types to human-readable names
+
+### 2. Updated HTML Structure (popup.html)
+- Populated empty history container with Alpine.js template
+- Added structured display for each video:
+  - Video title (clickable link)
+  - Video ID in parentheses
+  - List of events with action, position, and timestamp
+- Added empty state message
+
+### 3. Added CSS Styles (popup.css)
+- Comprehensive styling for history items
+- Event display with proper spacing and typography
+- Hover effects and visual hierarchy
+- Responsive layout considerations
+
+### 4. Enhanced Navigation
+- Updated `showHistory()` method to refresh data when switching views
+- Maintained existing navigation between playlists and history
+
+## Key Features Implemented
+1. **Chronological Ordering**: Videos sorted by most recent interaction
+2. **Comprehensive Event Display**: Each event shows action type, video position, and human-readable timestamp
+3. **Interactive Elements**: Video titles are clickable links that open in new tabs
+4. **Clean UI**: Well-structured layout with proper visual hierarchy
+5. **Empty State Handling**: Friendly message when no history exists
+
+## Files Modified
+- `popup/popup.js`: Added history data management and formatting methods
+- `popup/popup.html`: Populated history container with structured content
+- `popup/popup.css`: Added comprehensive styling for history display
+
+## Testing Considerations
+- Verify history loads correctly from browser storage
+- Test sorting by most recent interaction
+- Confirm human-readable timestamps display properly
+- Validate video links open correctly
+- Check empty state display when no history exists

+ 35 - 0
2025-05-28-codemcp-fix-import-button-functionality.md

@@ -0,0 +1,35 @@
+# 2025-05-28 - codemcp - fix import button functionality
+
+## User Request
+the "import" button (at the bottom of the popup menu) doesn't seem to work. it might be due to a javascript expression in the html code (not CSP-compliant), and/or it might be that the import code is wrong (the former is more likely).
+
+## Problem Identified
+The import button issue was caused by a CSP-violating JavaScript expression in the HTML file. On line 238 of `popup.html`, there was:
+
+```html
+@change="importFile($event)"
+```
+
+This violates the Content Security Policy (CSP) that requires all Alpine.js code to be CSP-compliant, meaning no JavaScript expressions in HTML attributes.
+
+## Solution Applied
+1. **Added CSP-compliant binding in popup.js**: Created a new `importFileInput` binding object that handles the change event:
+   ```javascript
+   importFileInput: {
+     ["@change"]() {
+       this.importFile(this.$event);
+     },
+   },
+   ```
+
+2. **Updated HTML to use binding**: Replaced the CSP-violating inline JavaScript with the Alpine.js x-bind directive:
+   ```html
+   x-bind="importFileInput"
+   ```
+
+## Files Modified
+- `popup/popup.js`: Added `importFileInput` binding object with change event handler
+- `popup/popup.html`: Replaced `@change="importFile($event)"` with `x-bind="importFileInput"`
+
+## Result
+The import button should now work correctly without violating CSP policies. The functionality remains the same - clicking the "Import Playlists" button will open a file dialog, and selecting a JSON file will trigger the import process.

+ 114 - 0
2025-05-29-codemcp-feat-add-current-page-to-playlist-button-in-popup.md

@@ -0,0 +1,114 @@
+# 2025-05-29 - codemcp - Add Current Page to Playlist Button in Popup
+
+## User Request
+in the individual playlist view of the popup menu (not the main view), add a button at the bottom that will add the current page (in the active tab) to the playlist. use the website's title (html tag) as the video title and the url as the url. disable the button (and grey it out) if the url's host is not youtube.com.
+
+## Implementation
+
+### Files Modified
+
+#### popup/popup.html
+- Added a new button container at the bottom of the individual playlist view
+- Added `add-current-page-container` div with `add-current-page-btn` button
+- Button uses Alpine.js bindings for click handler, disabled state, and text content
+
+#### popup/popup.js
+- Added new data properties:
+  - `currentTab`: stores current active tab info
+  - `isCurrentTabYoutube`: boolean for YouTube detection
+  - `addCurrentPageButtonText`: dynamic button text
+- Added `getCurrentTab()` method to fetch current tab info and check if it's YouTube
+- Added `updateAddCurrentPageButtonText()` method to set appropriate button text
+- Added `addCurrentPageToPlaylist()` method to add current page to the selected playlist
+- Added Alpine.js binding `addCurrentPageButton` with click handler, disabled state, and CSS classes
+- Called `getCurrentTab()` in the `init()` method
+
+#### popup/popup.css
+- Added comprehensive styling for the new button:
+  - `.add-current-page-container`: container styling with border separator
+  - `.add-current-page-btn`: main button styling with hover effects
+  - Disabled state styling with grey colors and no-cursor
+
+### Key Features
+1. **YouTube Detection**: Button is only enabled when current tab is on youtube.com
+2. **Dynamic Text**: Button text changes based on current tab state:
+   - "Add Current Page to Playlist" (YouTube tab)
+   - "Add Current Page (YouTube only)" (non-YouTube tab)
+   - "Unable to get current page" (no tab access)
+3. **CSP Compliance**: All Alpine.js code follows CSP requirements with no JavaScript expressions in HTML
+4. **Visual Feedback**: Button is visually disabled (greyed out) when not usable
+5. **Integration**: Uses existing playlist management system and storage
+
+### Technical Details
+- Uses browser.tabs.query() to get current active tab
+- Checks URL hostname for YouTube detection
+- Creates video object with current tab's URL and title
+- Adds video to end of current playlist
+- Updates display after adding video
+- Respects existing Alpine.js patterns and CSP compliance
+
+# 2025-05-29 - codemcp - Add Current Page to Playlist Button in Popup + Shared Utils
+
+## User Request
+background.js has a function called addLinkToPlaylist, which adds a video to a playlist so long as it is not already present in a playlist. move that function to a shared file (like "playlist utils" or something) along with any of its dependent functions (like findPlaylist and findVideoInPlaylist). then have the new popup menu button call this function instead.
+
+## Implementation
+
+### New Files Created
+
+#### shared/playlist-utils.js
+- **PlaylistUtils** global object containing shared functions
+- **extractVideoId()**: Extract YouTube video ID from URL
+- **findVideoInPlaylists()**: Find video in all playlists, returns [playlistName, index, isLast] or false
+- **findPlaylist()**: Find which playlist contains a video (wrapper for findVideoInPlaylists)
+- **addVideoToPlaylist()**: Add video to playlist with duplicate checking
+- **addLinkToPlaylist()**: Legacy function for context menu compatibility
+
+### Files Modified
+
+#### manifest.json
+- Added `shared/playlist-utils.js` to background scripts array
+- Scripts load in order: shared utils first, then background.js
+
+#### popup/popup.html
+- Added `<script src="../shared/playlist-utils.js"></script>` before popup.js
+- Ensures shared utilities are available to popup code
+
+#### background.js
+- **Removed duplicate functions**: findVideoInPlaylists, findPlaylist, addLinkToPlaylist
+- **Updated function calls** to use PlaylistUtils:
+  - `updateTracking()` now uses `PlaylistUtils.findVideoInPlaylists()`
+  - `findNext()` now uses `PlaylistUtils.findVideoInPlaylists()`
+  - Context menu handler uses `PlaylistUtils.addLinkToPlaylist()`
+
+#### popup/popup.js
+- **Replaced custom addCurrentPageToPlaylist()** with call to shared utils
+- **Benefits of shared function**:
+  - Automatic duplicate checking (won't add if video exists in any playlist)
+  - Consistent video object structure
+  - Better error handling and logging
+  - Returns boolean indicating if video was actually added
+- **Improved user experience**:
+  - Only refreshes display if video was actually added
+  - Logs when video already exists (could show user notification later)
+
+### Key Improvements
+
+1. **Code Deduplication**: Removed ~65 lines of duplicate code from background.js
+2. **Consistent Logic**: Both popup and context menu use same duplicate-checking logic
+3. **Better UX**: Popup button now prevents adding duplicates (context menu already did this)
+4. **Maintainable**: Single source of truth for playlist operations
+5. **No Build Tools**: Pure vanilla JavaScript with global objects pattern
+
+### Technical Details
+- **Loading Order**: shared/playlist-utils.js loads before both popup.js and background.js
+- **Global Scope**: PlaylistUtils object available in both contexts
+- **Browser API Usage**: Uses browser.storage.local consistently
+- **Error Handling**: Graceful handling of malformed URLs and missing data
+- **Backward Compatibility**: Context menu functionality unchanged
+
+### Architecture Benefits
+- **Separation of Concerns**: UI logic vs business logic clearly separated
+- **Reusability**: Playlist functions can be used by any future components
+- **Testing**: Shared functions could be unit tested independently
+- **Consistency**: Same validation and storage patterns everywhere

+ 48 - 0
2025-05-29-codemcp-feat-make-video-id-clickable-and-add-toggleable-tags.md

@@ -0,0 +1,48 @@
+# 2025-05-29 - CodeMCP - Make Video ID Clickable and Add Toggleable Tags
+
+## User Request
+in the history view of the popup menu, make the id in parentheses the clickable link, instead of the video title (make the video title not clickable). under the video id, add tags or chips that can be activated or deactivated (default inactive). for now, only put a single one called "notable". when activated, modify the history in storage to include an array of the active tags (as string), like so: `{video-id: {url:..., history: [], tags: ["notable"]}}`.
+
+## Implementation Summary
+
+### Changes Made
+
+1. **HTML Changes (popup.html)**:
+   - Changed video title from clickable link to plain div
+   - Made video ID parentheses clickable with the video URL
+   - Added a tags section below the video ID with a "notable" tag chip
+
+2. **CSS Changes (popup.css)**:
+   - Updated `.history-video-id` to be clickable with hover effects
+   - Added styles for `.history-tags` container and `.tag-chip` elements
+   - Added active state styling for activated tags
+
+3. **JavaScript Changes (popup.js)**:
+   - Added `toggleTag(videoId, tag)` method to handle tag activation/deactivation
+   - Added `isTagActive(videoId, tag)` helper method to check tag state
+   - Modified `sortHistoryByRecentInteraction()` to ensure tags array exists
+   - Added CSP-compliant Alpine.js bindings for tag chips with click handlers and active state
+
+### Key Features
+
+- **Video ID Clickability**: The video ID in parentheses is now the clickable element that opens the video
+- **Tag System**: Each video history item has a "notable" tag chip that can be toggled
+- **Visual Feedback**: Active tags have different styling (blue background, white text)
+- **Data Persistence**: Tag state is saved to browser storage in the format: `{video-id: {url:..., history: [], tags: ["notable"]}}`
+- **CSP Compliance**: All Alpine.js code follows CSP requirements with no JavaScript expressions in HTML
+
+### Technical Implementation
+
+The tag system integrates with the existing history data structure by adding a `tags` array to each video's data. The implementation is fully CSP-compliant and maintains the existing functionality while adding the new tag features.
+
+The storage format now supports:
+```javascript
+{
+  "video-id": {
+    "url": "https://example.com/video",
+    "title": "Video Title",
+    "history": [...],
+    "tags": ["notable"] // New tags array
+  }
+}
+```

+ 4 - 70
background.js

@@ -91,75 +91,9 @@ browser.runtime.onInstalled.addListener(() => {
   });
 });
 
-function findVideoInPlaylists(playlists, url) {
-  // Extract the video id from URL query params
-  let v;
-  try {
-    const urlObj = new URL(url);
-    v = urlObj.searchParams.get("v");
-  } catch (e) {
-    return false; // Invalid URL
-  }
-
-  // If no video id found, return false
-  if (!v) {
-    return false;
-  }
-
-  // Check each playlist in the current set
-  for (const playlistName in playlists) {
-    const videos = playlists[playlistName];
-
-    // Check each video in the array
-    for (let i = 0; i < videos.length; i++) {
-      try {
-        const itemUrl = new URL(videos[i].url);
-        const itemVParam = itemUrl.searchParams.get("v");
-
-        // If the "v" parameters match, return playlist info
-        if (itemVParam === v) {
-          const isLastVideo = i === videos.length - 1;
-          return [playlistName, i, isLastVideo];
-        }
-      } catch (e) {
-        // Skip malformed URLs
-        continue;
-      }
-    }
-  }
-
-  // No match found
-  return false;
-}
-
-function findPlaylist(current, url) {
-  const result = findVideoInPlaylists(current, url);
-  // If found, return just the playlist name
-  return result ? result[0] : false;
-}
-
-async function addLinkToPlaylist(plName, item) {
-  const { playlists: currentPlaylists } =
-    await browser.storage.local.get("playlists");
-
-  const alreadyHave = findPlaylist(currentPlaylists, item.linkUrl);
-
-  if (alreadyHave) {
-    console.log("already have that link in", alreadyHave);
-  } else {
-    const { [plName]: playlist, ...others } = currentPlaylists;
-    await browser.storage.local.set({
-      playlists: {
-        [plName]: [...playlist, { url: item.linkUrl, title: item.linkText }],
-        ...others,
-      },
-    });
-  }
-}
-
 // Context menu click handler
 browser.contextMenus.onClicked.addListener(async (item, _tab) => {
-  addLinkToPlaylist(item.menuItemId, item);
+  PlaylistUtils.addLinkToPlaylist(item.menuItemId, item);
 });
 
 // Function to navigate a tab to a new URL
@@ -171,8 +105,8 @@ async function updateTracking(message) {
   // Get current playlists from storage
   const { playlists } = await browser.storage.local.get("playlists");
 
-  // Find the video in playlists
-  const result = findVideoInPlaylists(playlists, message.url);
+  // Find the video in playlists using shared utils
+  const result = PlaylistUtils.findVideoInPlaylists(playlists, message.url);
 
   // If the video is not present in any playlist, do nothing
   if (!result) {
@@ -267,7 +201,7 @@ async function updateHistory(message) {
 }
 
 function findNext(playlists, url) {
-  const result = findVideoInPlaylists(playlists, url);
+  const result = PlaylistUtils.findVideoInPlaylists(playlists, url);
 
   if (!result) {
     return false; // Not found in any playlist

+ 21 - 0
codemcp.toml

@@ -0,0 +1,21 @@
+project_prompt = '''
+- this is a firefox browser extension which manages a set of playlists, where each video is located at a url
+- it also tracks video playback history
+- playlist and history data is stored using the browser's storage API
+- the extension popup code is in the popup/ folder, and is implemented in Alpine.js
+  - all the Alpine code must be in "CSP" mode, which means there can be no javascript expression strings in the html attributes (no `===` equality checks, no function calls) (favor binding in Alpine.data instead)
+- background.js adds context menus (for adding new videos to the playlist), and manages automatic browser navigation and history tracking
+- content_scripts/content.js is injected to add video playback event listeners
+- under no circumstances will there be any need to read or write files outside the code directory
+  - do not execute any command that reads, writes, or searches for files outside the code directory
+- at the end of each request, before committing in git, save a copy of the contents of the context window (including the user's original prompt) to a file in the code folder with the filename of "<date>-codemcp-<feature name>.md"
+  - if you have to update the file (due to a subsequent user request), append to it, do not replace it
+
+ALPINE.JS CSP COMPLIANCE: All Alpine.js code must be CSP-compliant, meaning:
+- NO JavaScript expressions in HTML attributes (x-show="variable === 0", x-text="method()", etc.)
+- Use x-bind with Alpine.data binding objects instead
+- Pre-compute all display data in Alpine.data properties
+- Never use function calls, method calls, or equality checks in HTML templates
+'''
+
+[commands]

+ 1 - 1
manifest.json

@@ -17,7 +17,7 @@
     "default_popup": "popup/popup.html"
   },
   "background": {
-    "scripts": ["background.js"]
+    "scripts": ["shared/playlist-utils.js", "background.js"]
   },
   "content_scripts": [
     {

+ 335 - 5
popup/popup.css

@@ -20,6 +20,10 @@ body {
 header {
   margin-bottom: 16px;
   text-align: center;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  position: relative;
 }
 
 h1 {
@@ -34,6 +38,17 @@ h2 {
   border-bottom: 1px solid #ddd;
 }
 
+.playlist-name-clickable {
+  cursor: pointer;
+  color: #4285f4;
+  transition: color 0.2s ease;
+}
+
+.playlist-name-clickable:hover {
+  color: #3367d6;
+  text-decoration: underline;
+}
+
 .playlist {
   background: #fff;
   border-radius: 8px;
@@ -79,11 +94,6 @@ button {
   font-size: 12px;
 }
 
-.remove-btn {
-  background-color: #f44336;
-  color: white;
-}
-
 button:hover {
   opacity: 0.9;
 }
@@ -110,3 +120,323 @@ button:hover {
 .done-video .video-title {
   text-decoration: line-through;
 }
+
+/* Truncated videos indicator */
+.truncated-videos {
+  padding: 8px 0 12px 8px;
+  margin-bottom: 12px;
+  border-bottom: 1px solid #eee;
+  cursor: pointer;
+  color: #888;
+  font-style: italic;
+  font-size: 14px;
+  transition: color 0.2s ease, background-color 0.2s ease;
+}
+
+.truncated-videos:hover {
+  color: #4285f4;
+  background-color: #f8f9fa;
+}
+
+/* More menu styles */
+.more-menu-container {
+  position: relative;
+}
+
+.more-btn {
+  background-color: #666;
+  color: white;
+  font-size: 14px;
+  padding: 4px 8px;
+  border-radius: 4px;
+  font-weight: bold;
+}
+
+.more-menu {
+  position: absolute;
+  right: 0;
+  top: 100%;
+  background: white;
+  border: 1px solid #ddd;
+  border-radius: 4px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+  z-index: 1000;
+  min-width: 100px;
+  margin-top: 4px;
+}
+
+.menu-item {
+  display: block;
+  width: 100%;
+  padding: 8px 12px;
+  border: none;
+  background: none;
+  text-align: left;
+  cursor: pointer;
+  font-size: 12px;
+  border-radius: 0;
+}
+
+.menu-item:hover {
+  background-color: #f5f5f5;
+}
+
+.remove-item {
+  color: #f44336;
+}
+
+.remove-item:hover {
+  background-color: #ffeaea;
+}
+
+/* Back button styles */
+.back-btn {
+  background-color: #4285f4;
+  color: white;
+  padding: 8px 12px;
+  border-radius: 4px;
+  font-size: 14px;
+  margin-bottom: 8px;
+}
+
+.back-btn:hover {
+  background-color: #3367d6;
+}
+
+/* Arrow back button styles */
+.back-btn-arrow {
+  position: absolute;
+  left: 0;
+  background: none;
+  border: none;
+  font-size: 20px;
+  color: #4285f4;
+  cursor: pointer;
+  padding: 8px 12px;
+  border-radius: 50%;
+  transition: background-color 0.2s ease, color 0.2s ease;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 36px;
+  height: 36px;
+}
+
+.back-btn-arrow:hover {
+  background-color: #e8f0fe;
+  color: #3367d6;
+}
+
+/* History view styles */
+.history-container {
+  background: #fff;
+  border-radius: 8px;
+  padding: 12px;
+  margin-bottom: 16px;
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+  min-height: 200px;
+}
+
+.history-list {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.history-item {
+  border-bottom: 1px solid #eee;
+  padding-bottom: 12px;
+}
+
+.history-item:last-child {
+  border-bottom: none;
+  padding-bottom: 0;
+}
+
+.history-video-info {
+  margin-bottom: 8px;
+}
+
+.history-video-title {
+  display: block;
+  font-weight: bold;
+  color: #333;
+  text-decoration: none;
+  margin-bottom: 4px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.history-video-title:hover {
+  color: #4285f4;
+}
+
+.history-video-id {
+  font-size: 12px;
+  color: #4285f4;
+  font-family: monospace;
+  text-decoration: none;
+  cursor: pointer;
+  transition: color 0.2s ease;
+}
+
+.history-video-id:hover {
+  color: #3367d6;
+  text-decoration: underline;
+}
+
+.history-tags {
+  margin-top: 6px;
+  display: flex;
+  gap: 6px;
+  flex-wrap: wrap;
+}
+
+.tag-chip {
+  font-size: 10px;
+  padding: 2px 8px;
+  border: 1px solid #ddd;
+  border-radius: 12px;
+  background-color: #f8f9fa;
+  color: #666;
+  cursor: pointer;
+  transition: all 0.2s ease;
+}
+
+.tag-chip:hover {
+  background-color: #e8f0fe;
+  border-color: #4285f4;
+  color: #4285f4;
+}
+
+.tag-chip.active {
+  background-color: #4285f4;
+  border-color: #4285f4;
+  color: white;
+}
+
+.tag-chip.active:hover {
+  background-color: #3367d6;
+  border-color: #3367d6;
+}
+
+.history-events {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  margin-left: 12px;
+}
+
+.history-event {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 12px;
+  color: #555;
+}
+
+.event-action {
+  font-weight: bold;
+  min-width: 60px;
+}
+
+.event-position {
+  color: #666;
+  font-family: monospace;
+  min-width: 70px;
+}
+
+.event-timestamp {
+  color: #888;
+  font-size: 11px;
+  margin-left: auto;
+}
+
+.empty-history {
+  text-align: center;
+  padding: 40px 0;
+  color: #888;
+  font-style: italic;
+}
+
+/* History button styles */
+.history-btn {
+  background-color: #34a853;
+  color: white;
+  padding: 8px 12px;
+  border-radius: 4px;
+  font-size: 14px;
+}
+
+.history-btn:hover {
+  background-color: #2d7d3a;
+}
+
+/* Playlist view styles */
+.playlist-view-container {
+  background: #fff;
+  border-radius: 8px;
+  padding: 12px;
+  margin-bottom: 16px;
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+  min-height: 200px;
+}
+
+.playlist-full-view {
+  display: flex;
+  flex-direction: column;
+  gap: 0;
+}
+
+.playlist-full-view .video-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 8px 0;
+  border-bottom: 1px solid #eee;
+}
+
+.playlist-full-view .video-item:last-child {
+  border-bottom: none;
+}
+
+/* Add current page button styles */
+.add-current-page-container {
+  margin-top: 16px;
+  padding-top: 12px;
+  border-top: 1px solid #eee;
+}
+
+.add-current-page-btn {
+  width: 100%;
+  background-color: #4285f4;
+  color: white;
+  padding: 10px 16px;
+  border-radius: 6px;
+  font-size: 14px;
+  font-weight: 500;
+  transition: all 0.2s ease;
+}
+
+.add-current-page-btn:hover:not(:disabled) {
+  background-color: #3367d6;
+  transform: translateY(-1px);
+  box-shadow: 0 2px 8px rgba(66, 133, 244, 0.3);
+}
+
+.add-current-page-btn:disabled,
+.add-current-page-btn.disabled {
+  background-color: #ccc;
+  color: #888;
+  cursor: not-allowed;
+  transform: none;
+  box-shadow: none;
+}
+
+.add-current-page-btn:disabled:hover,
+.add-current-page-btn.disabled:hover {
+  background-color: #ccc;
+  transform: none;
+  box-shadow: none;
+}

+ 188 - 23
popup/popup.html

@@ -6,28 +6,52 @@
     <title>My Playlists</title>
     <link rel="stylesheet" href="popup.css" />
     <script src="alpine.min.js" defer></script>
+    <script src="../shared/playlist-utils.js"></script>
     <script src="popup.js"></script>
   </head>
   <body>
     <div id="app" x-data="playlistManager">
-      <header>
+      <header x-bind="playlistsHeader">
         <h1>My Playlists</h1>
       </header>
 
-      <div class="playlists-container">
+      <header x-bind="historyHeader">
+        <button class="back-btn-arrow" x-bind="backButton">←</button>
+        <h1>History</h1>
+      </header>
+
+      <header x-bind="playlistViewHeader">
+        <button class="back-btn-arrow" x-bind="backButton">←</button>
+        <h1 x-text="currentPlaylistName"></h1>
+      </header>
+
+      <div class="playlists-container" x-bind="playlistsContainer">
         <template
-          x-for="(videos, playlistName) in playlists"
-          :key="playlistName"
+          x-for="playlistData in playlistsForDisplay"
+          :key="playlistData.name"
         >
           <div class="playlist">
-            <h2 x-text="playlistName"></h2>
+            <h2
+              class="playlist-name-clickable"
+              x-text="playlistData.name"
+              :data-playlist-name="playlistData.name"
+              x-bind="playlistNameClick"
+            ></h2>
             <div class="video-list">
-              <template x-for="(video, index) in videos" :key="index">
+              <!-- Truncated previous videos indicator -->
+              <div
+                class="truncated-videos"
+                :data-playlist-name="playlistData.name"
+                x-bind="truncatedVideosDisplay"
+              >
+                <span x-text="playlistData.truncationText"></span>
+              </div>
+              <template x-for="(video, index) in playlistData.visibleVideos" :key="index">
                 <div
                   class="video-item"
                   :title="video.title"
-                  :data-playlist-name="playlistName"
-                  :data-playlist-index="index"
+                  :data-playlist-name="playlistData.name"
+                  :data-playlist-index="video.originalIndex"
                   x-bind="videoItemClass"
                 >
                   <a
@@ -38,8 +62,8 @@
                   ></a>
                   <div class="video-actions">
                     <button
-                      :data-playlist-name="playlistName"
-                      :data-playlist-index="index"
+                      :data-playlist-name="playlistData.name"
+                      :data-playlist-index="video.originalIndex"
                       x-bind="moveUpButton"
                       class="move-up-btn"
                       title="Move up"
@@ -47,22 +71,40 @@
                     </button>
                     <button
-                      :data-playlist-name="playlistName"
-                      :data-playlist-index="index"
+                      :data-playlist-name="playlistData.name"
+                      :data-playlist-index="video.originalIndex"
                       x-bind="moveDownButton"
                       class="move-down-btn"
                       title="Move down"
                     >
                     </button>
-                    <button
-                      :data-playlist-name="playlistName"
-                      :data-playlist-index="index"
-                      x-bind="removeVideoButton"
-                      class="remove-btn"
-                    >
-                      X
-                    </button>
+                    <div class="more-menu-container">
+                      <button
+                        :data-playlist-name="playlistData.name"
+                        :data-playlist-index="video.originalIndex"
+                        x-bind="moreMenuButton"
+                        class="more-btn"
+                        title="More actions"
+                      >
+                        ⋯
+                      </button>
+                      <div
+                        class="more-menu"
+                        :data-playlist-name="playlistData.name"
+                        :data-playlist-index="video.originalIndex"
+                        x-bind="moreMenu"
+                      >
+                        <button
+                          :data-playlist-name="playlistData.name"
+                          :data-playlist-index="video.originalIndex"
+                          x-bind="removeVideoButton"
+                          class="menu-item remove-item"
+                        >
+                          Remove
+                        </button>
+                      </div>
+                    </div>
                   </div>
                 </div>
               </template>
@@ -76,20 +118,143 @@
         </template>
       </div>
 
-      <!-- Export/Import buttons -->
-      <div class="export-container">
+      <!-- History view -->
+      <div class="history-container" x-bind="historyContainer">
+        <div class="history-list">
+          <template x-for="videoHistory in sortedHistory" :key="videoHistory.videoId">
+            <div class="history-item" :data-video-id="videoHistory.videoId">
+              <div class="history-video-info">
+                <div class="history-video-title" x-text="videoHistory.title"></div>
+                <a
+                  class="history-video-id"
+                  :href="videoHistory.url"
+                  x-text="videoHistory.formattedVideoId"
+                  x-bind="videoPlayLink"
+                ></a>
+                <div class="history-tags">
+                  <button
+                    class="tag-chip"
+                    :data-video-id="videoHistory.videoId"
+                    data-tag="notable"
+                    x-bind="tagChip"
+                  >
+                    notable
+                  </button>
+                </div>
+              </div>
+              <div class="history-events">
+                <template x-for="event in videoHistory.processedEvents" :key="event.uniqueKey">
+                  <div class="history-event">
+                    <span class="event-action" x-text="event.formattedAction"></span>
+                    <span class="event-position" x-text="event.formattedPosition"></span>
+                    <span class="event-timestamp" x-text="event.formattedTimestamp"></span>
+                  </div>
+                </template>
+              </div>
+            </div>
+          </template>
+          <div x-bind="historyEmptyState" class="empty-history">
+            No video history yet
+          </div>
+        </div>
+      </div>
+
+      <!-- Individual playlist view -->
+      <div class="playlist-view-container" x-bind="playlistViewContainer">
+        <div class="playlist-full-view">
+          <template x-for="(video, index) in currentPlaylistVideos" :key="index">
+            <div
+              class="video-item"
+              :title="video.title"
+              :data-playlist-name="currentPlaylistName"
+              :data-playlist-index="index"
+              x-bind="videoItemClass"
+            >
+              <a
+                class="video-title"
+                :href="video.url"
+                x-text="video.title"
+                x-bind="videoPlayLink"
+              ></a>
+              <div class="video-actions">
+                <button
+                  :data-playlist-name="currentPlaylistName"
+                  :data-playlist-index="index"
+                  x-bind="moveUpButton"
+                  class="move-up-btn"
+                  title="Move up"
+                >
+                  ↑
+                </button>
+                <button
+                  :data-playlist-name="currentPlaylistName"
+                  :data-playlist-index="index"
+                  x-bind="moveDownButton"
+                  class="move-down-btn"
+                  title="Move down"
+                >
+                  ↓
+                </button>
+                <div class="more-menu-container">
+                  <button
+                    :data-playlist-name="currentPlaylistName"
+                    :data-playlist-index="index"
+                    x-bind="moreMenuButton"
+                    class="more-btn"
+                    title="More actions"
+                  >
+                    ⋯
+                  </button>
+                  <div
+                    class="more-menu"
+                    :data-playlist-name="currentPlaylistName"
+                    :data-playlist-index="index"
+                    x-bind="moreMenu"
+                  >
+                    <button
+                      :data-playlist-name="currentPlaylistName"
+                      :data-playlist-index="index"
+                      x-bind="removeVideoButton"
+                      class="menu-item remove-item"
+                    >
+                      Remove
+                    </button>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </template>
+          <div x-bind="playlistViewEmptyState" class="empty-playlist">
+            No videos in this playlist
+          </div>
+        </div>
+        <!-- Add current page button -->
+        <div class="add-current-page-container">
+          <button
+            class="add-current-page-btn"
+            x-bind="addCurrentPageButton"
+            x-text="addCurrentPageButtonText"
+          ></button>
+        </div>
+      </div>
+
+      <!-- Export/Import/History buttons -->
+      <div class="export-container" x-bind="exportContainer">
         <button class="export-btn" x-bind="exportButton">
           Export Playlists
         </button>
         <button class="import-btn" x-bind="importButton">
           Import Playlists
         </button>
+        <button class="history-btn" x-bind="historyButton">
+          History
+        </button>
         <input
           type="file"
           id="import-file-input"
           accept=".json"
           style="display: none"
-          @change="importFile($event)"
+          x-bind="importFileInput"
         />
       </div>
     </div>

+ 493 - 5
popup/popup.js

@@ -3,23 +3,44 @@ document.addEventListener("alpine:init", () => {
   // - preserve playlist order (sort)
   // - "play/resume playlist" button (pick up where you left off)
   //   X track timestamp to truly resume where you left off
-  // - periodically remove watched videos
-  //   - or move to an "old" category, and add a screen to see the list
+  // X periodically remove watched videos
+  //   X or move to an "old" category, and add a screen to see the list
   // - consider separating context menu items (rather than having a sub-menu)
-  // - view playback history (?)
+  // X view playback history (?)
   // - include currently open tabs in export
   // - support playlist management (add, remove, rename playlists)
   // - support input text box for adding to playlist (how to handle title?)
-  // - option (probably a button) to add current page (url, title) to playlist
+  //   - or raw json editing
+  // X option (probably a button) to add current page (url, title) to playlist
+  //   X align with addLinkToPlaylist in background.js (no repeated videos)
   // - button to add channel to youtube page (copy gql mutation to clipboard) (like the automa version)
   // - long-term: replace youtube page? rss feeds? need server?
-  // - add personal rating feature ("enjoyed", "this was important", etc)
+  // X add personal rating feature ("enjoyed", "this was important", etc)
   Alpine.data("playlistManager", () => ({
     playlists: {},
     currentIndices: {},
+    history: {},
+    sortedHistory: [],
+    openMenus: new Set(), // Track which menus are open
+    currentView: "playlists", // Track current view: 'playlists', 'history', or 'playlist'
+    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
+    addCurrentPageButtonText: "Add Current Page", // Button text
 
     init() {
       this.loadPlaylists();
+      this.loadHistory();
+      this.getCurrentTab();
+      // Add document click handler to close menus
+      document.addEventListener("click", (e) => {
+        // If click is not on a more button or menu, close all menus
+        if (!e.target.closest(".more-menu-container")) {
+          this.closeAllMenus();
+        }
+      });
     },
 
     async loadPlaylists() {
@@ -48,6 +69,196 @@ document.addEventListener("alpine:init", () => {
       } catch (error) {
         console.error("Error loading playlists:", error);
       }
+      this.updatePlaylistsForDisplay();
+    },
+
+    updatePlaylistsForDisplay() {
+      this.playlistsForDisplay = Object.entries(this.playlists).map(
+        ([playlistName, videos]) => {
+          const currentIndex = this.currentIndices[playlistName] || 0;
+          const shouldShowTruncation = currentIndex > 0;
+          const truncationText = shouldShowTruncation
+            ? `(${currentIndex} previous video${currentIndex === 1 ? "" : "s"})`
+            : "";
+
+          // Get visible videos from current index onwards with original indices
+          const visibleVideos = videos
+            .slice(currentIndex)
+            .map((video, index) => ({
+              ...video,
+              originalIndex: currentIndex + index,
+            }));
+
+          return {
+            name: playlistName,
+            videos: videos,
+            visibleVideos: visibleVideos,
+            shouldShowTruncation: shouldShowTruncation,
+            truncationText: truncationText,
+          };
+        },
+      );
+    },
+
+    updateCurrentPlaylistVideos() {
+      if (
+        !this.currentPlaylistName ||
+        !this.playlists[this.currentPlaylistName]
+      ) {
+        this.currentPlaylistVideos = [];
+      } else {
+        this.currentPlaylistVideos = this.playlists[this.currentPlaylistName];
+      }
+    },
+
+    async loadHistory() {
+      try {
+        const result = await browser.storage.local.get("history");
+        console.log("LOAD HISTORY RESULT", result.history);
+        this.history = result.history || {};
+        this.sortHistoryByRecentInteraction();
+      } catch (error) {
+        console.error("Error loading history:", error);
+      }
+    },
+
+    async getCurrentTab() {
+      try {
+        const tabs = await browser.tabs.query({
+          active: true,
+          currentWindow: true,
+        });
+        if (tabs.length > 0) {
+          this.currentTab = tabs[0];
+          const url = new URL(this.currentTab.url);
+          this.isCurrentTabYoutube = url.hostname === "www.youtube.com";
+          this.updateAddCurrentPageButtonText();
+        }
+      } catch (error) {
+        console.error("Error getting current tab:", error);
+        this.currentTab = null;
+        this.isCurrentTabYoutube = false;
+        this.updateAddCurrentPageButtonText();
+      }
+    },
+
+    updateAddCurrentPageButtonText() {
+      if (!this.currentTab) {
+        this.addCurrentPageButtonText = "Unable to get current page";
+      } else if (!this.isCurrentTabYoutube) {
+        this.addCurrentPageButtonText = "Add Current Page (YouTube only)";
+      } else {
+        this.addCurrentPageButtonText = "Add Current Page to Playlist";
+      }
+    },
+
+    sortHistoryByRecentInteraction() {
+      // Convert history object to array with video ID and sort by most recent interaction
+      this.sortedHistory = Object.entries(this.history)
+        .map(([videoId, videoData]) => {
+          const lastInteraction =
+            videoData.history.length > 0
+              ? Math.max(...videoData.history.map((event) => event.timestamp))
+              : 0;
+
+          // Pre-process events with formatted data for CSP compliance
+          const processedEvents = videoData.history
+            .slice()
+            .reverse()
+            .map((event, index) => ({
+              ...event,
+              formattedAction: this.formatActionName(event.action),
+              formattedPosition: `at ${this.formatVideoPosition(event.position)}`,
+              formattedTimestamp: this.formatTimestamp(event.timestamp),
+              uniqueKey: `${videoId}-${event.timestamp}-${index}`,
+            }));
+
+          return {
+            videoId,
+            formattedVideoId: `(${videoId})`,
+            ...videoData,
+            lastInteraction,
+            processedEvents,
+            tags: videoData.tags || [], // Ensure tags array exists
+          };
+        })
+        .sort((a, b) => b.lastInteraction - a.lastInteraction);
+    },
+
+    formatTimestamp(timestamp) {
+      const date = new Date(timestamp);
+      const now = new Date();
+      const diffMs = now - date;
+      const diffMins = Math.floor(diffMs / (1000 * 60));
+      const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
+      const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
+
+      if (diffMins < 1) return "Just now";
+      if (diffMins < 60)
+        return `${diffMins} minute${diffMins > 1 ? "s" : ""} ago`;
+      if (diffHours < 24)
+        return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`;
+      if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`;
+
+      return (
+        date.toLocaleDateString() +
+        " " +
+        date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
+      );
+    },
+
+    formatVideoPosition(seconds) {
+      const minutes = Math.floor(seconds / 60);
+      const remainingSeconds = Math.floor(seconds % 60);
+      return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
+    },
+
+    async toggleTag(videoId, tag) {
+      // Create a deep copy of history to avoid proxy issues
+      const history = JSON.parse(JSON.stringify(this.history));
+
+      if (!history[videoId]) {
+        console.error(`Video ${videoId} not found in history`);
+        return;
+      }
+
+      const videoData = history[videoId];
+      if (!videoData.tags) {
+        videoData.tags = [];
+      }
+
+      const tagIndex = videoData.tags.indexOf(tag);
+      if (tagIndex === -1) {
+        // Add tag
+        videoData.tags.push(tag);
+      } else {
+        // Remove tag
+        videoData.tags.splice(tagIndex, 1);
+      }
+
+      // Save to storage and update local state
+      try {
+        await browser.storage.local.set({ history: history });
+        this.history = history;
+        this.sortHistoryByRecentInteraction(); // Refresh display
+      } catch (error) {
+        console.error("Error saving tag changes:", error);
+      }
+    },
+
+    isTagActive(videoId, tag) {
+      const videoData = this.history[videoId];
+      return videoData && videoData.tags && videoData.tags.includes(tag);
+    },
+
+    formatActionName(action) {
+      const actionMap = {
+        play: "Started",
+        playing: "Playing",
+        pause: "Paused",
+        ended: "Finished",
+      };
+      return actionMap[action] || action;
     },
 
     formatPlaylistName(name) {
@@ -81,6 +292,8 @@ document.addEventListener("alpine:init", () => {
       try {
         await browser.storage.local.set({ playlists: updatedPlaylists });
         this.playlists = updatedPlaylists;
+        this.updatePlaylistsForDisplay();
+        this.updateCurrentPlaylistVideos();
       } catch (error) {
         console.error("Error removing video:", error);
       }
@@ -111,6 +324,8 @@ document.addEventListener("alpine:init", () => {
       try {
         await browser.storage.local.set({ playlists: updatedPlaylists });
         this.playlists = updatedPlaylists;
+        this.updatePlaylistsForDisplay();
+        this.updateCurrentPlaylistVideos();
       } catch (error) {
         console.error("Error moving video up:", error);
       }
@@ -139,11 +354,43 @@ document.addEventListener("alpine:init", () => {
       try {
         await browser.storage.local.set({ playlists: updatedPlaylists });
         this.playlists = updatedPlaylists;
+        this.updatePlaylistsForDisplay();
+        this.updateCurrentPlaylistVideos();
       } catch (error) {
         console.error("Error moving video down:", error);
       }
     },
 
+    async addCurrentPageToPlaylist() {
+      if (
+        !this.currentTab ||
+        !this.isCurrentTabYoutube ||
+        !this.currentPlaylistName
+      ) {
+        return;
+      }
+
+      // Create video object using current tab info
+      const video = {
+        url: this.currentTab.url,
+        title: this.currentTab.title,
+      };
+
+      // Use shared utility function to add video (handles duplicate checking)
+      const wasAdded = await PlaylistUtils.addVideoToPlaylist(
+        this.currentPlaylistName,
+        video,
+      );
+
+      if (wasAdded) {
+        // Refresh displays only if video was actually added
+        this.loadPlaylists(); // This will update both display arrays
+      } else {
+        // Could show user feedback that video already exists
+        console.log("Video already exists in a playlist");
+      }
+    },
+
     async exportPlaylists() {
       try {
         // Get current playlists
@@ -227,6 +474,7 @@ document.addEventListener("alpine:init", () => {
           this.$el.dataset.playlistName,
           this.$el.dataset.playlistIndex,
         );
+        this.closeAllMenus();
       },
     },
 
@@ -269,6 +517,12 @@ document.addEventListener("alpine:init", () => {
       },
     },
 
+    importFileInput: {
+      ["@change"]() {
+        this.importFile(this.$event);
+      },
+    },
+
     importFile(event) {
       const file = event.target.files[0];
       if (!file) return;
@@ -361,11 +615,245 @@ document.addEventListener("alpine:init", () => {
       try {
         await browser.storage.local.set({ playlists });
         this.playlists = playlists;
+        this.updatePlaylistsForDisplay();
+        this.updateCurrentPlaylistVideos();
         alert("Playlists imported successfully!");
       } catch (error) {
         console.error("Error updating playlists:", error);
         alert("Error importing playlists: " + error.message);
       }
     },
+
+    getMenuId(playlistName, index) {
+      return `${playlistName}-${index}`;
+    },
+
+    isMenuOpen(playlistName, index) {
+      return this.openMenus.has(this.getMenuId(playlistName, index));
+    },
+
+    toggleMenu(playlistName, index) {
+      const menuId = this.getMenuId(playlistName, index);
+      if (this.openMenus.has(menuId)) {
+        this.openMenus.delete(menuId);
+      } else {
+        // Close all other menus first
+        this.openMenus.clear();
+        this.openMenus.add(menuId);
+      }
+    },
+
+    closeAllMenus() {
+      this.openMenus.clear();
+    },
+
+    moreMenuButton: {
+      ["@click.stop"]() {
+        this.toggleMenu(
+          this.$el.dataset.playlistName,
+          parseInt(this.$el.dataset.playlistIndex),
+        );
+      },
+    },
+
+    moreMenu: {
+      ["x-show"]() {
+        return this.isMenuOpen(
+          this.$el.dataset.playlistName,
+          parseInt(this.$el.dataset.playlistIndex),
+        );
+      },
+    },
+
+    // View navigation methods
+    showHistory() {
+      this.currentView = "history";
+      this.loadHistory(); // Refresh history data when switching to history view
+    },
+
+    showPlaylists() {
+      this.currentView = "playlists";
+    },
+
+    showPlaylist(playlistName) {
+      this.currentView = "playlist";
+      this.currentPlaylistName = playlistName;
+      this.updateCurrentPlaylistVideos();
+    },
+
+    // Methods for truncated display
+    shouldShowTruncation(playlistName) {
+      const currentIndex = this.currentIndices[playlistName] || 0;
+      return currentIndex > 0;
+    },
+
+    getTruncationText(playlistName) {
+      const currentIndex = this.currentIndices[playlistName] || 0;
+      const count = currentIndex;
+      return `(${count} previous video${count === 1 ? "" : "s"})`;
+    },
+
+    getVisibleVideos(playlistName, videos) {
+      const currentIndex = this.currentIndices[playlistName] || 0;
+      // Show from current video onwards, but keep original indices
+      return videos.slice(currentIndex).map((video, index) => ({
+        ...video,
+        originalIndex: currentIndex + index,
+      }));
+    },
+
+    getCurrentPlaylistVideos() {
+      if (
+        !this.currentPlaylistName ||
+        !this.playlists[this.currentPlaylistName]
+      ) {
+        return [];
+      }
+      return this.playlists[this.currentPlaylistName];
+    },
+
+    // Button bindings for navigation
+    historyButton: {
+      ["@click"]() {
+        this.showHistory();
+      },
+    },
+
+    backButton: {
+      ["@click"]() {
+        this.showPlaylists();
+      },
+    },
+
+    // Event handlers for playlist navigation
+    playlistNameClick: {
+      ["@click"]() {
+        const playlistName = this.$el.dataset.playlistName;
+        this.showPlaylist(playlistName);
+      },
+    },
+
+    truncatedVideosClick: {
+      ["@click"]() {
+        const playlistName = this.$el.dataset.playlistName;
+        this.showPlaylist(playlistName);
+      },
+    },
+
+    truncatedVideosClick: {
+      ["@click"]() {
+        const playlistName = this.$el.dataset.playlistName;
+        this.showPlaylist(playlistName);
+      },
+    },
+
+    truncatedVideosDisplay: {
+      ["x-show"]() {
+        const playlistName = this.$el.dataset.playlistName;
+        const playlistData = this.playlistsForDisplay.find(
+          (p) => p.name === playlistName,
+        );
+        return playlistData ? playlistData.shouldShowTruncation : false;
+      },
+      ["@click"]() {
+        const playlistName = this.$el.dataset.playlistName;
+        this.showPlaylist(playlistName);
+      },
+    },
+
+    // CSP-compliant view bindings
+    playlistsHeader: {
+      ["x-show"]() {
+        return this.currentView === "playlists";
+      },
+    },
+
+    historyHeader: {
+      ["x-show"]() {
+        return this.currentView === "history";
+      },
+    },
+
+    playlistViewHeader: {
+      ["x-show"]() {
+        return this.currentView === "playlist";
+      },
+    },
+
+    playlistsContainer: {
+      ["x-show"]() {
+        return this.currentView === "playlists";
+      },
+    },
+
+    historyContainer: {
+      ["x-show"]() {
+        return this.currentView === "history";
+      },
+    },
+
+    playlistViewContainer: {
+      ["x-show"]() {
+        return this.currentView === "playlist";
+      },
+    },
+
+    exportContainer: {
+      ["x-show"]() {
+        return this.currentView === "playlists";
+      },
+    },
+
+    // History-specific bindings for CSP compliance
+    historyEmptyState: {
+      ["x-show"]() {
+        return this.sortedHistory.length === 0;
+      },
+    },
+
+    // Playlist view-specific bindings for CSP compliance
+    playlistViewEmptyState: {
+      ["x-show"]() {
+        return this.currentPlaylistVideos.length === 0;
+      },
+    },
+
+    // Tag chip bindings for CSP compliance
+    tagChip: {
+      ["@click"]() {
+        const videoId = this.$el.dataset.videoId;
+        const tag = this.$el.dataset.tag;
+        this.toggleTag(videoId, tag);
+      },
+      [":class"]() {
+        const videoId = this.$el.dataset.videoId;
+        const tag = this.$el.dataset.tag;
+        return {
+          active: this.isTagActive(videoId, tag),
+        };
+      },
+    },
+
+    // Add current page button binding
+    addCurrentPageButton: {
+      ["@click"]() {
+        this.addCurrentPageToPlaylist();
+      },
+      [":disabled"]() {
+        return (
+          !this.currentTab ||
+          !this.isCurrentTabYoutube ||
+          !this.currentPlaylistName
+        );
+      },
+      [":class"]() {
+        return {
+          disabled:
+            !this.currentTab ||
+            !this.isCurrentTabYoutube ||
+            !this.currentPlaylistName,
+        };
+      },
+    },
   }));
 });

+ 105 - 0
shared/playlist-utils.js

@@ -0,0 +1,105 @@
+// Shared playlist utility functions
+// Used by both popup.js and background.js
+
+const PlaylistUtils = {
+  /**
+   * Extract video ID from YouTube URL
+   * @param {string} url - YouTube URL
+   * @returns {string|null} - Video ID or null if not found
+   */
+  extractVideoId(url) {
+    try {
+      const urlObj = new URL(url);
+      return urlObj.searchParams.get("v");
+    } catch (e) {
+      return null;
+    }
+  },
+
+  /**
+   * Find a video in all playlists by URL
+   * @param {Object} playlists - All playlists object
+   * @param {string} url - Video URL to search for
+   * @returns {Array|false} - [playlistName, videoIndex, isLastVideo] or false if not found
+   */
+  findVideoInPlaylists(playlists, url) {
+    const videoId = this.extractVideoId(url);
+
+    if (!videoId) {
+      return false;
+    }
+
+    // Check each playlist
+    for (const playlistName in playlists) {
+      const videos = playlists[playlistName];
+
+      // Check each video in the playlist
+      for (let i = 0; i < videos.length; i++) {
+        const itemVideoId = this.extractVideoId(videos[i].url);
+
+        // If the video IDs match, return playlist info
+        if (itemVideoId === videoId) {
+          const isLastVideo = i === videos.length - 1;
+          return [playlistName, i, isLastVideo];
+        }
+      }
+    }
+
+    return false;
+  },
+
+  /**
+   * Find which playlist contains a video by URL
+   * @param {Object} playlists - All playlists object
+   * @param {string} url - Video URL to search for
+   * @returns {string|false} - Playlist name or false if not found
+   */
+  findPlaylist(playlists, url) {
+    const result = this.findVideoInPlaylists(playlists, url);
+    return result ? result[0] : false;
+  },
+
+  /**
+   * Add a video to a playlist if it's not already present in any playlist
+   * @param {string} playlistName - Name of playlist to add to
+   * @param {Object} video - Video object with url and title properties
+   * @returns {Promise<boolean>} - True if added, false if already exists
+   */
+  async addVideoToPlaylist(playlistName, video) {
+    const { playlists: currentPlaylists } =
+      await browser.storage.local.get("playlists");
+
+    const alreadyExists = this.findPlaylist(currentPlaylists, video.url);
+
+    if (alreadyExists) {
+      console.log("Video already exists in playlist:", alreadyExists);
+      return false;
+    }
+
+    // Add video to the specified playlist
+    const updatedPlaylist = [...currentPlaylists[playlistName], video];
+    const updatedPlaylists = {
+      ...currentPlaylists,
+      [playlistName]: updatedPlaylist,
+    };
+
+    await browser.storage.local.set({ playlists: updatedPlaylists });
+    console.log("Added video to playlist:", playlistName);
+    return true;
+  },
+
+  /**
+   * Legacy function for backward compatibility with context menu
+   * @param {string} playlistName - Name of playlist to add to
+   * @param {Object} item - Context menu item with linkUrl and linkText
+   * @returns {Promise<boolean>} - True if added, false if already exists
+   */
+  async addLinkToPlaylist(playlistName, item) {
+    const video = {
+      url: item.linkUrl,
+      title: item.linkText,
+    };
+
+    return await this.addVideoToPlaylist(playlistName, video);
+  },
+};