|
|
@@ -7,7 +7,7 @@ document.addEventListener("alpine:init", () => {
|
|
|
// 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)
|
|
|
// X view playback history (?)
|
|
|
- // - include currently open tabs in export
|
|
|
+ // X 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?)
|
|
|
// - or raw json editing
|
|
|
@@ -16,20 +16,42 @@ document.addEventListener("alpine:init", () => {
|
|
|
// - 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?
|
|
|
// X add personal rating feature ("enjoyed", "this was important", etc)
|
|
|
+ // - option to move video (including status) to another playlist
|
|
|
Alpine.data("playlistManager", () => ({
|
|
|
playlists: {},
|
|
|
currentIndices: {},
|
|
|
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",
|
|
|
+ "SCIFI",
|
|
|
+ "POLI/REL",
|
|
|
+ "REAL ESTATE",
|
|
|
+ "HELICOPTER",
|
|
|
+ "SAILING",
|
|
|
+ "VAN LIFE",
|
|
|
+ "BLOCKCHAIN",
|
|
|
+ "MAKING",
|
|
|
+ "DESIGN",
|
|
|
+ "TECH CONF",
|
|
|
+ "CREATIVE",
|
|
|
+ ], // Available categories
|
|
|
+ curlCommand: "", // Generated curl command
|
|
|
+ checkInterval: 14, // Check interval in days
|
|
|
+
|
|
|
init() {
|
|
|
this.loadPlaylists();
|
|
|
this.loadHistory();
|
|
|
@@ -87,6 +109,12 @@ document.addEventListener("alpine:init", () => {
|
|
|
.map((video, index) => ({
|
|
|
...video,
|
|
|
originalIndex: currentIndex + index,
|
|
|
+ doneButtonText:
|
|
|
+ video.status === "done" ? "Remove Done Status" : "Mark as Done",
|
|
|
+ isNonContiguousDone: this.isNonContiguousDone(
|
|
|
+ playlistName,
|
|
|
+ currentIndex + index,
|
|
|
+ ),
|
|
|
}));
|
|
|
|
|
|
return {
|
|
|
@@ -107,7 +135,17 @@ document.addEventListener("alpine:init", () => {
|
|
|
) {
|
|
|
this.currentPlaylistVideos = [];
|
|
|
} else {
|
|
|
- this.currentPlaylistVideos = this.playlists[this.currentPlaylistName];
|
|
|
+ this.currentPlaylistVideos = this.playlists[
|
|
|
+ this.currentPlaylistName
|
|
|
+ ].map((video, index) => ({
|
|
|
+ ...video,
|
|
|
+ doneButtonText:
|
|
|
+ video.status === "done" ? "Remove Done Status" : "Mark as Done",
|
|
|
+ isNonContiguousDone: this.isNonContiguousDone(
|
|
|
+ this.currentPlaylistName,
|
|
|
+ index,
|
|
|
+ ),
|
|
|
+ }));
|
|
|
}
|
|
|
},
|
|
|
|
|
|
@@ -132,13 +170,18 @@ 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();
|
|
|
}
|
|
|
},
|
|
|
|
|
|
@@ -152,6 +195,61 @@ 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)
|
|
|
@@ -361,6 +459,40 @@ document.addEventListener("alpine:init", () => {
|
|
|
}
|
|
|
},
|
|
|
|
|
|
+ async toggleVideoDoneStatus(playlistName, index) {
|
|
|
+ const playlists = JSON.parse(JSON.stringify(this.playlists));
|
|
|
+ const playlist = [...playlists[playlistName]];
|
|
|
+ const video = { ...playlist[index] };
|
|
|
+
|
|
|
+ // Toggle the done status
|
|
|
+ if (video.status === "done") {
|
|
|
+ // Remove status property (undefined status means not done)
|
|
|
+ delete video.status;
|
|
|
+ } else {
|
|
|
+ // Set status to done
|
|
|
+ video.status = "done";
|
|
|
+ }
|
|
|
+
|
|
|
+ // Update the video in the playlist
|
|
|
+ playlist[index] = video;
|
|
|
+
|
|
|
+ // Create an updated playlists object
|
|
|
+ const updatedPlaylists = {
|
|
|
+ ...playlists,
|
|
|
+ [playlistName]: playlist,
|
|
|
+ };
|
|
|
+
|
|
|
+ // Update the playlists in storage
|
|
|
+ try {
|
|
|
+ await browser.storage.local.set({ playlists: updatedPlaylists });
|
|
|
+ this.playlists = updatedPlaylists;
|
|
|
+ this.updatePlaylistsForDisplay();
|
|
|
+ this.updateCurrentPlaylistVideos();
|
|
|
+ } catch (error) {
|
|
|
+ console.error("Error toggling video done status:", error);
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
async addCurrentPageToPlaylist() {
|
|
|
if (
|
|
|
!this.currentTab ||
|
|
|
@@ -401,10 +533,32 @@ document.addEventListener("alpine:init", () => {
|
|
|
const historyResult = await browser.storage.local.get("history");
|
|
|
const playbackHistory = historyResult.history || {};
|
|
|
|
|
|
+ // Get open tabs
|
|
|
+ let openTabs = [];
|
|
|
+ try {
|
|
|
+ const tabsResult = await browser.tabs.query({});
|
|
|
+ openTabs = tabsResult
|
|
|
+ .filter(
|
|
|
+ (tab) =>
|
|
|
+ tab.url &&
|
|
|
+ !tab.url.startsWith("moz-extension://") &&
|
|
|
+ !tab.url.startsWith("about:") &&
|
|
|
+ !tab.url.startsWith("chrome://"),
|
|
|
+ )
|
|
|
+ .map((tab) => ({
|
|
|
+ url: tab.url,
|
|
|
+ title: tab.title || "Untitled",
|
|
|
+ }));
|
|
|
+ } catch (error) {
|
|
|
+ console.warn("Could not retrieve open tabs:", error);
|
|
|
+ openTabs = [];
|
|
|
+ }
|
|
|
+
|
|
|
// Create export data object
|
|
|
const exportData = {
|
|
|
playlists,
|
|
|
playbackHistory,
|
|
|
+ openTabs,
|
|
|
exportDate: new Date().toISOString(),
|
|
|
};
|
|
|
|
|
|
@@ -444,6 +598,26 @@ document.addEventListener("alpine:init", () => {
|
|
|
},
|
|
|
},
|
|
|
|
|
|
+ isNonContiguousDone(playlistName, videoIndex) {
|
|
|
+ const playlist = this.playlists[playlistName];
|
|
|
+ if (
|
|
|
+ !playlist ||
|
|
|
+ !playlist[videoIndex] ||
|
|
|
+ playlist[videoIndex].status !== "done"
|
|
|
+ ) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Check if there's any non-done video before this done video
|
|
|
+ for (let i = 0; i < videoIndex; i++) {
|
|
|
+ if (playlist[i].status !== "done") {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return false;
|
|
|
+ },
|
|
|
+
|
|
|
isCurrentVideo(playlistName, index) {
|
|
|
const currentIndex = this.currentIndices[playlistName];
|
|
|
return currentIndex === index;
|
|
|
@@ -454,6 +628,12 @@ document.addEventListener("alpine:init", () => {
|
|
|
return index < currentIndex;
|
|
|
},
|
|
|
|
|
|
+ isVideoDone(playlistName, index) {
|
|
|
+ const video =
|
|
|
+ this.playlists[playlistName] && this.playlists[playlistName][index];
|
|
|
+ return video && video.status === "done";
|
|
|
+ },
|
|
|
+
|
|
|
videoItemClass: {
|
|
|
[":class"]() {
|
|
|
const playlistName = this.$el.dataset.playlistName;
|
|
|
@@ -464,6 +644,10 @@ document.addEventListener("alpine:init", () => {
|
|
|
"current-video": this.isCurrentVideo(playlistName, index),
|
|
|
"done-video":
|
|
|
this.isDoneVideo(playlistName, index) && video.status === "done",
|
|
|
+ "non-contiguous-done-video": this.isNonContiguousDone(
|
|
|
+ playlistName,
|
|
|
+ index,
|
|
|
+ ),
|
|
|
};
|
|
|
},
|
|
|
},
|
|
|
@@ -478,6 +662,16 @@ document.addEventListener("alpine:init", () => {
|
|
|
},
|
|
|
},
|
|
|
|
|
|
+ toggleVideoDoneButton: {
|
|
|
+ ["@click"]() {
|
|
|
+ this.toggleVideoDoneStatus(
|
|
|
+ this.$el.dataset.playlistName,
|
|
|
+ parseInt(this.$el.dataset.playlistIndex),
|
|
|
+ );
|
|
|
+ this.closeAllMenus();
|
|
|
+ },
|
|
|
+ },
|
|
|
+
|
|
|
moveUpButton: {
|
|
|
["@click"]() {
|
|
|
this.moveVideoUp(
|
|
|
@@ -719,6 +913,12 @@ document.addEventListener("alpine:init", () => {
|
|
|
},
|
|
|
},
|
|
|
|
|
|
+ saveChannelButton: {
|
|
|
+ ["@click"]() {
|
|
|
+ this.showSaveChannel();
|
|
|
+ },
|
|
|
+ },
|
|
|
+
|
|
|
backButton: {
|
|
|
["@click"]() {
|
|
|
this.showPlaylists();
|
|
|
@@ -780,6 +980,12 @@ document.addEventListener("alpine:init", () => {
|
|
|
},
|
|
|
},
|
|
|
|
|
|
+ saveChannelHeader: {
|
|
|
+ ["x-show"]() {
|
|
|
+ return this.currentView === "saveChannel";
|
|
|
+ },
|
|
|
+ },
|
|
|
+
|
|
|
playlistsContainer: {
|
|
|
["x-show"]() {
|
|
|
return this.currentView === "playlists";
|
|
|
@@ -798,6 +1004,12 @@ document.addEventListener("alpine:init", () => {
|
|
|
},
|
|
|
},
|
|
|
|
|
|
+ saveChannelContainer: {
|
|
|
+ ["x-show"]() {
|
|
|
+ return this.currentView === "saveChannel";
|
|
|
+ },
|
|
|
+ },
|
|
|
+
|
|
|
exportContainer: {
|
|
|
["x-show"]() {
|
|
|
return this.currentView === "playlists";
|
|
|
@@ -834,6 +1046,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"]() {
|