diff --git a/src/constants.js b/src/constants.js index 0ae194d19a6cd..bac4c5b45de24 100644 --- a/src/constants.js +++ b/src/constants.js @@ -29,10 +29,12 @@ const IpcChannels = { DB_HISTORY: 'db-history', DB_PROFILES: 'db-profiles', DB_PLAYLISTS: 'db-playlists', + DB_SEARCH_HISTORY: 'db-search-history', DB_SUBSCRIPTION_CACHE: 'db-subscription-cache', SYNC_SETTINGS: 'sync-settings', SYNC_HISTORY: 'sync-history', + SYNC_SEARCH_HISTORY: 'sync-search-history', SYNC_PROFILES: 'sync-profiles', SYNC_PLAYLISTS: 'sync-playlists', SYNC_SUBSCRIPTION_CACHE: 'sync-subscription-cache', diff --git a/src/datastores/handlers/base.js b/src/datastores/handlers/base.js index 18411722951a1..1d98fd2c4c3cd 100644 --- a/src/datastores/handlers/base.js +++ b/src/datastores/handlers/base.js @@ -226,6 +226,32 @@ class Playlists { } } +class SearchHistory { + static create(pageBookmark) { + return db.searchHistory.insertAsync(pageBookmark) + } + + static find() { + return db.searchHistory.findAsync({}) + } + + static upsert(pageBookmark) { + return db.searchHistory.updateAsync({ _id: pageBookmark._id }, pageBookmark, { upsert: true }) + } + + static delete(_id) { + return db.searchHistory.removeAsync({ _id: _id }) + } + + static deleteMultiple(ids) { + return db.searchHistory.removeAsync({ _id: { $in: ids } }) + } + + static deleteAll() { + return db.searchHistory.removeAsync({}, { multi: true }) + } +} + class SubscriptionCache { static find() { return db.subscriptionCache.findAsync({}) @@ -311,6 +337,7 @@ function compactAllDatastores() { db.history.compactDatafileAsync(), db.profiles.compactDatafileAsync(), db.playlists.compactDatafileAsync(), + db.searchHistory.compactDatafileAsync(), db.subscriptionCache.compactDatafileAsync(), ]) } @@ -320,6 +347,7 @@ export { History as history, Profiles as profiles, Playlists as playlists, + SearchHistory as searchHistory, SubscriptionCache as subscriptionCache, compactAllDatastores, diff --git a/src/datastores/handlers/electron.js b/src/datastores/handlers/electron.js index 889c91d4f060c..68b95f1fd0b60 100644 --- a/src/datastores/handlers/electron.js +++ b/src/datastores/handlers/electron.js @@ -218,6 +218,50 @@ class Playlists { } } +class SearchHistory { + static create(pageBookmark) { + return ipcRenderer.invoke( + IpcChannels.DB_SEARCH_HISTORY, + { action: DBActions.GENERAL.CREATE, data: pageBookmark } + ) + } + + static find() { + return ipcRenderer.invoke( + IpcChannels.DB_SEARCH_HISTORY, + { action: DBActions.GENERAL.FIND } + ) + } + + static upsert(pageBookmark) { + return ipcRenderer.invoke( + IpcChannels.DB_SEARCH_HISTORY, + { action: DBActions.GENERAL.UPSERT, data: pageBookmark } + ) + } + + static delete(_id) { + return ipcRenderer.invoke( + IpcChannels.DB_SEARCH_HISTORY, + { action: DBActions.GENERAL.DELETE, data: _id } + ) + } + + static deleteMultiple(ids) { + return ipcRenderer.invoke( + IpcChannels.DB_SEARCH_HISTORY, + { action: DBActions.GENERAL.DELETE_MULTIPLE, data: ids } + ) + } + + static deleteAll() { + return ipcRenderer.invoke( + IpcChannels.DB_SEARCH_HISTORY, + { action: DBActions.GENERAL.DELETE_ALL } + ) + } +} + class SubscriptionCache { static find() { return ipcRenderer.invoke( @@ -296,5 +340,6 @@ export { History as history, Profiles as profiles, Playlists as playlists, + SearchHistory as searchHistory, SubscriptionCache as subscriptionCache, } diff --git a/src/datastores/handlers/index.js b/src/datastores/handlers/index.js index 6d9b8ab729d43..1409b1cd3a450 100644 --- a/src/datastores/handlers/index.js +++ b/src/datastores/handlers/index.js @@ -3,5 +3,6 @@ export { history as DBHistoryHandlers, profiles as DBProfileHandlers, playlists as DBPlaylistHandlers, + searchHistory as DBSearchHistoryHandlers, subscriptionCache as DBSubscriptionCacheHandlers, } from 'DB_HANDLERS_ELECTRON_RENDERER_OR_WEB' diff --git a/src/datastores/handlers/web.js b/src/datastores/handlers/web.js index d68e24f042615..b06b8c62ac75e 100644 --- a/src/datastores/handlers/web.js +++ b/src/datastores/handlers/web.js @@ -122,6 +122,32 @@ class Playlists { } } +class SearchHistory { + static create(pageBookmark) { + return baseHandlers.searchHistory.create(pageBookmark) + } + + static find() { + return baseHandlers.searchHistory.find() + } + + static upsert(pageBookmark) { + return baseHandlers.searchHistory.upsert(pageBookmark) + } + + static delete(_id) { + return baseHandlers.searchHistory.delete(_id) + } + + static deleteMultiple(ids) { + return baseHandlers.searchHistory.deleteMultiple(ids) + } + + static deleteAll() { + return baseHandlers.searchHistory.deleteAll() + } +} + class SubscriptionCache { static find() { return baseHandlers.subscriptionCache.find() @@ -180,5 +206,6 @@ export { History as history, Profiles as profiles, Playlists as playlists, + SearchHistory as searchHistory, SubscriptionCache as subscriptionCache, } diff --git a/src/datastores/index.js b/src/datastores/index.js index 7a3da53356f00..37a27b12cff89 100644 --- a/src/datastores/index.js +++ b/src/datastores/index.js @@ -26,4 +26,5 @@ export const settings = new Datastore({ filename: dbPath('settings'), autoload: export const profiles = new Datastore({ filename: dbPath('profiles'), autoload: true }) export const playlists = new Datastore({ filename: dbPath('playlists'), autoload: true }) export const history = new Datastore({ filename: dbPath('history'), autoload: true }) +export const searchHistory = new Datastore({ filename: dbPath('search-history'), autoload: true }) export const subscriptionCache = new Datastore({ filename: dbPath('subscription-cache'), autoload: true }) diff --git a/src/main/index.js b/src/main/index.js index eb3376c0776d2..2376bcaac96f8 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -1340,6 +1340,64 @@ function runApp() { } }) + // ************** // + // Search History + ipcMain.handle(IpcChannels.DB_SEARCH_HISTORY, async (event, { action, data }) => { + try { + switch (action) { + case DBActions.GENERAL.CREATE: { + const pageBookmark = await baseHandlers.searchHistory.create(data) + syncOtherWindows( + IpcChannels.SYNC_SEARCH_HISTORY, + event, + { event: SyncEvents.GENERAL.CREATE, data } + ) + return pageBookmark + } + case DBActions.GENERAL.FIND: + return await baseHandlers.searchHistory.find() + + case DBActions.GENERAL.UPSERT: + await baseHandlers.searchHistory.upsert(data) + syncOtherWindows( + IpcChannels.SYNC_SEARCH_HISTORY, + event, + { event: SyncEvents.GENERAL.UPSERT, data } + ) + return null + + case DBActions.GENERAL.DELETE: + await baseHandlers.searchHistory.delete(data) + syncOtherWindows( + IpcChannels.SYNC_SEARCH_HISTORY, + event, + { event: SyncEvents.GENERAL.DELETE, data } + ) + return null + + case DBActions.GENERAL.DELETE_MULTIPLE: + await baseHandlers.searchHistory.deleteMultiple(data) + syncOtherWindows( + IpcChannels.SYNC_SEARCH_HISTORY, + event, + { event: SyncEvents.GENERAL.DELETE_MULTIPLE, data } + ) + return null + + case DBActions.GENERAL.DELETE_ALL: + await baseHandlers.searchHistory.deleteAll() + return null + + default: + // eslint-disable-next-line no-throw-literal + throw 'invalid search history db action' + } + } catch (err) { + if (typeof err === 'string') throw err + else throw err.toString() + } + }) + // *********** // // *********** // diff --git a/src/renderer/App.js b/src/renderer/App.js index 372be1665bbeb..ed2164bfcc1f2 100644 --- a/src/renderer/App.js +++ b/src/renderer/App.js @@ -10,6 +10,7 @@ import FtToast from './components/ft-toast/ft-toast.vue' import FtProgressBar from './components/FtProgressBar/FtProgressBar.vue' import FtPlaylistAddVideoPrompt from './components/ft-playlist-add-video-prompt/ft-playlist-add-video-prompt.vue' import FtCreatePlaylistPrompt from './components/ft-create-playlist-prompt/ft-create-playlist-prompt.vue' +import PageBookmarkPrompt from './components/PageBookmarkPrompt/PageBookmarkPrompt.vue' import FtSearchFilters from './components/FtSearchFilters/FtSearchFilters.vue' import { marked } from 'marked' import { IpcChannels } from '../constants' @@ -32,6 +33,7 @@ export default defineComponent({ FtProgressBar, FtPlaylistAddVideoPrompt, FtCreatePlaylistPrompt, + PageBookmarkPrompt, FtSearchFilters }, data: function () { @@ -40,6 +42,7 @@ export default defineComponent({ showUpdatesBanner: false, showBlogBanner: false, showReleaseNotes: false, + pageBookmarksAvailable: false, updateBannerMessage: '', blogBannerMessage: '', latestBlogUrl: '', @@ -77,6 +80,9 @@ export default defineComponent({ showCreatePlaylistPrompt: function () { return this.$store.getters.getShowCreatePlaylistPrompt }, + showPageBookmarkPrompt: function () { + return this.$store.getters.getShowPageBookmarkPrompt + }, showSearchFilters: function () { return this.$store.getters.getShowSearchFilters }, @@ -188,6 +194,9 @@ export default defineComponent({ this.grabAllProfiles(this.$t('Profile.All Channels')).then(async () => { this.grabHistory() this.grabAllPlaylists() + this.grabPageBookmarks().then(() => { + this.pageBookmarksAvailable = true + }) this.grabAllSubscriptions() if (process.env.IS_ELECTRON) { @@ -568,6 +577,7 @@ export default defineComponent({ 'grabUserSettings', 'grabAllProfiles', 'grabHistory', + 'grabPageBookmarks', 'grabAllPlaylists', 'grabAllSubscriptions', 'getYoutubeUrlInfo', diff --git a/src/renderer/App.vue b/src/renderer/App.vue index a0235a5854898..21acc7e1abeef 100644 --- a/src/renderer/App.vue +++ b/src/renderer/App.vue @@ -56,6 +56,9 @@ + + + + {{ title }} + + + handleNameChange(e)" + @clear="e => handleNameChange('')" + @keydown.enter.native="save" + /> + + {{ $tc('Page Bookmark["There is {count} other bookmark with the same name."]', duplicateNameCount, { count: duplicateNameCount }) }} + + + + + + + + + + + + + + + diff --git a/src/renderer/components/ft-input/ft-input.css b/src/renderer/components/ft-input/ft-input.css index 89b7450e588f6..4db1aa301d293 100644 --- a/src/renderer/components/ft-input/ft-input.css +++ b/src/renderer/components/ft-input/ft-input.css @@ -202,8 +202,22 @@ body[dir='rtl'] .ft-input-component.search.showClearTextButton:focus-within .inp .list li { display: block; padding-block: 0; - padding-inline: 15px; line-height: 2rem; + padding-inline: 15px; + text-overflow: ellipsis; + overflow-x: hidden; + white-space: nowrap; +} + +.bookmarkStarIcon { + color: var(--favorite-icon-color); +} + +.searchResultIcon { + opacity: 0.6; + padding-inline-end: 10px; + inline-size: 16px; + block-size: 16px; } .hover { diff --git a/src/renderer/components/ft-input/ft-input.js b/src/renderer/components/ft-input/ft-input.js index 7a3aa07150fe7..b336041520f5b 100644 --- a/src/renderer/components/ft-input/ft-input.js +++ b/src/renderer/components/ft-input/ft-input.js @@ -2,7 +2,9 @@ import { defineComponent } from 'vue' import { mapActions } from 'vuex' import FtTooltip from '../ft-tooltip/ft-tooltip.vue' -import { isKeyboardEventKeyPrintableChar, isNullOrEmpty } from '../../helpers/strings' +import { getIconForRoute, isKeyboardEventKeyPrintableChar, isNullOrEmpty } from '../../helpers/strings' + +const MAX_VISIBLE_LIST_ITEMS = 15 export default defineComponent({ name: 'FtInput', @@ -83,7 +85,7 @@ export default defineComponent({ isPointerInList: false, keyboardSelectedOptionIndex: -1, }, - visibleDataList: this.dataList, + visibleDataList: this.dataList.slice(0, MAX_VISIBLE_LIST_ITEMS), // This button should be invisible on app start // As the text input box should be empty clearTextButtonExisting: false, @@ -116,8 +118,7 @@ export default defineComponent({ searchStateKeyboardSelectedOptionValue() { if (this.searchState.keyboardSelectedOptionIndex === -1) { return null } - - return this.visibleDataList[this.searchState.keyboardSelectedOptionIndex] + return this.getTextForArrayAtIndex(this.visibleDataList, this.searchState.keyboardSelectedOptionIndex) }, }, watch: { @@ -143,6 +144,9 @@ export default defineComponent({ this.updateVisibleDataList() }, methods: { + getTextForArrayAtIndex: function (array, index) { + return array[index].name ?? array[index] + }, handleClick: function (e) { // No action if no input text if (!this.inputDataPresent) { @@ -234,7 +238,11 @@ export default defineComponent({ handleOptionClick: function (index) { this.searchState.showOptions = false - this.inputData = this.visibleDataList[index] + if (this.visibleDataList[index].route) { + this.inputData = `ft:${this.visibleDataList[index].route}` + } else { + this.inputData = this.visibleDataList[index] + } this.$emit('input', this.inputData) this.handleClick() }, @@ -248,9 +256,11 @@ export default defineComponent({ if (this.searchState.selectedOption !== -1) { this.searchState.showOptions = false event.preventDefault() - this.inputData = this.visibleDataList[this.searchState.selectedOption] + this.inputData = this.getTextForArrayAtIndex(this.visibleDataList, this.searchState.selectedOption) + this.handleOptionClick(this.searchState.selectedOption) + } else { + this.handleClick(event) } - this.handleClick(event) // Early return return } @@ -296,18 +306,21 @@ export default defineComponent({ }, updateVisibleDataList: function () { - if (this.dataList.length === 0) { return } // Reset selected option before it's updated this.searchState.selectedOption = -1 this.searchState.keyboardSelectedOptionIndex = -1 if (this.inputData === '') { - this.visibleDataList = this.dataList + this.visibleDataList = this.dataList.slice(0, MAX_VISIBLE_LIST_ITEMS) return } // get list of items that match input const lowerCaseInputData = this.inputData.toLowerCase() - this.visibleDataList = this.dataList.filter(x => { + this.visibleDataList = this.dataList.slice(0, MAX_VISIBLE_LIST_ITEMS).filter(x => { + if (x.name) { + return x.name.toLowerCase().indexOf(lowerCaseInputData) !== -1 + } + return x.toLowerCase().indexOf(lowerCaseInputData) !== -1 }) }, @@ -316,6 +329,10 @@ export default defineComponent({ this.inputData = text }, + iconForBookmarkedPage: (pageBookmark) => { + return getIconForRoute(pageBookmark.route) ?? ['fas', 'magnifying-glass'] + }, + focus() { this.$refs.input.focus() }, diff --git a/src/renderer/components/ft-input/ft-input.vue b/src/renderer/components/ft-input/ft-input.vue index ea5f5c80d56a0..dcea8c54a5d2f 100644 --- a/src/renderer/components/ft-input/ft-input.vue +++ b/src/renderer/components/ft-input/ft-input.vue @@ -75,14 +75,25 @@ > - {{ list }} + + + {{ entry.name ?? entry }} diff --git a/src/renderer/components/privacy-settings/privacy-settings.js b/src/renderer/components/privacy-settings/privacy-settings.js index 98b9a266ced9d..ba208309d74b0 100644 --- a/src/renderer/components/privacy-settings/privacy-settings.js +++ b/src/renderer/components/privacy-settings/privacy-settings.js @@ -23,6 +23,7 @@ export default defineComponent({ showRemoveHistoryPrompt: false, showRemoveSubscriptionsPrompt: false, showRemovePlaylistsPrompt: false, + showRemovePageBookmarksPrompt: false, promptValues: [ 'delete', 'cancel' @@ -46,6 +47,9 @@ export default defineComponent({ removeSubscriptionsPromptMessage: function () { return this.$t('Settings.Privacy Settings["Are you sure you want to remove all subscriptions and profiles? This cannot be undone."]') }, + removePageBookmarksPromptMessage: function () { + return this.$t('Settings.Privacy Settings["Are you sure you want to remove all page bookmarks? This cannot be undone."]') + }, promptNames: function () { return [ this.$t('Yes, Delete'), @@ -115,6 +119,14 @@ export default defineComponent({ showToast(this.$t('Settings.Privacy Settings.All playlists have been removed')) }, + handleRemovePageBookmarks: function (option) { + this.showRemovePageBookmarksPrompt = false + if (option !== 'delete') { return } + + this.removeAllPageBookmarks() + showToast(this.$t('Settings.Privacy Settings.All page bookmarks have been removed')) + }, + ...mapActions([ 'updateRememberHistory', 'removeAllHistory', @@ -128,6 +140,7 @@ export default defineComponent({ 'updateAllSubscriptionsList', 'updateProfileSubscriptions', 'removeAllPlaylists', + 'removeAllPageBookmarks', 'updateQuickBookmarkTargetPlaylistId', ]) } diff --git a/src/renderer/components/privacy-settings/privacy-settings.vue b/src/renderer/components/privacy-settings/privacy-settings.vue index 3fe025d4e7df1..7706b8909451f 100644 --- a/src/renderer/components/privacy-settings/privacy-settings.vue +++ b/src/renderer/components/privacy-settings/privacy-settings.vue @@ -60,6 +60,13 @@ :icon="['fas', 'trash']" @click="showRemovePlaylistsPrompt = true" /> + + diff --git a/src/renderer/components/top-nav/top-nav.js b/src/renderer/components/top-nav/top-nav.js index ddd3b2609361a..6903994b86955 100644 --- a/src/renderer/components/top-nav/top-nav.js +++ b/src/renderer/components/top-nav/top-nav.js @@ -21,6 +21,12 @@ export default defineComponent({ FtInput, FtProfileSelector }, + props: { + pageBookmarksAvailable: { + type: Boolean, + default: false + } + }, data: () => { let isArrowBackwardDisabled = true let isArrowForwardDisabled = true @@ -33,12 +39,22 @@ export default defineComponent({ } return { + isRouteBookmarkable: false, showSearchContainer: true, isArrowBackwardDisabled, isArrowForwardDisabled, + currentRouteFullPath: '', navigationHistoryDropdownActiveEntry: null, navigationHistoryDropdownOptions: [], searchSuggestionsDataList: [], + allowedPageBookmarkRouteMetaTitles: [ + 'Search Results', + 'Playlist', + 'Channel', + 'Watch', + 'Hashtag', + 'Post', + ], lastSuggestionQuery: '' } }, @@ -120,16 +136,34 @@ export default defineComponent({ this.$t('Open New Window'), KeyboardShortcuts.APP.GENERAL.NEW_WINDOW ) + }, + + isPageBookmarked: function () { + return this.pageBookmarksAvailable && this.$store.getters.getPageBookmarkWithRoute(this.currentRouteFullPath) != null + }, + + matchingBookmarksDataList: function () { + return this.$store.getters.getPageBookmarksMatchingQuery(this.lastSuggestionQuery, this.currentRouteFullPath) + }, + + pageBookmarkIconTitle: function () { + return this.isPageBookmarked ? this.$t('Edit bookmark for this page') : this.$t('Bookmark this page') + }, + + pageBookmarkIconTheme: function () { + return this.isPageBookmarked ? 'favorite' : null } }, watch: { - $route: function () { + $route: function (to) { this.setNavigationHistoryDropdownOptions() if ('navigation' in window) { this.isArrowForwardDisabled = !window.navigation.canGoForward this.isArrowBackwardDisabled = !window.navigation.canGoBack } - }, + + this.setCurrentRoute(to) + } }, mounted: function () { let previousWidth = window.innerWidth @@ -137,6 +171,8 @@ export default defineComponent({ this.showSearchContainer = false } + this.$router.onReady(() => this.setCurrentRoute(this.$router.currentRoute)) + // Store is not up-to-date when the component mounts, so we use timeout. setTimeout(() => { if (this.expandSideBar) { @@ -156,6 +192,11 @@ export default defineComponent({ this.debounceSearchResults = debounce(this.getSearchSuggestions, 200) }, methods: { + setCurrentRoute: function (route) { + this.currentRouteFullPath = route.fullPath + // only allow page bookmarking on routes where it can be relevant to do so + this.isRouteBookmarkable = this.allowedPageBookmarkRouteMetaTitles.includes(route.meta.title) + }, goToSearch: async function (queryText, { event }) { const doCreateNewWindow = event && event.shiftKey @@ -168,6 +209,17 @@ export default defineComponent({ clearLocalSearchSuggestionsSession() + if (queryText.startsWith('ft:')) { + this.$refs.searchInput.handleClearTextClick() + const adjustedQuery = queryText.substring(3) + openInternalPath({ + path: adjustedQuery, + adjustedQuery, + doCreateNewWindow + }) + return + } + this.getYoutubeUrlInfo(queryText).then((result) => { switch (result.urlType) { case 'video': { @@ -289,12 +341,15 @@ export default defineComponent({ }, getSearchSuggestionsDebounce: function (query) { + const trimmedQuery = query.trim() + if (trimmedQuery === this.lastSuggestionQuery) { + return + } + + this.lastSuggestionQuery = trimmedQuery + if (this.enableSearchSuggestions) { - const trimmedQuery = query.trim() - if (trimmedQuery !== this.lastSuggestionQuery) { - this.lastSuggestionQuery = trimmedQuery - this.debounceSearchResults(trimmedQuery) - } + this.debounceSearchResults(trimmedQuery) } }, @@ -444,6 +499,7 @@ export default defineComponent({ ...mapActions([ 'getYoutubeUrlInfo', + 'showPageBookmarkPrompt', 'showSearchFilters' ]) } diff --git a/src/renderer/components/top-nav/top-nav.scss b/src/renderer/components/top-nav/top-nav.scss index cc346df5d73cf..8e23059ea1502 100644 --- a/src/renderer/components/top-nav/top-nav.scss +++ b/src/renderer/components/top-nav/top-nav.scss @@ -21,9 +21,9 @@ inline-size: 100%; z-index: 4; - @media only screen and (width >= 961px) { + @media only screen and (width >= 1162px) { display: grid; - grid-template-columns: 1fr 440px 1fr; + grid-template-columns: 1fr 440px 0.5fr 0.5fr; } @include top-nav-is-colored { @@ -45,7 +45,7 @@ } } -.arrowDisabled :deep(.iconButton) { +:deep(.iconButton.disabled) { filter: grayscale(1); background-color: inherit; opacity: 0.4; @@ -55,9 +55,8 @@ } .navIcon, -:deep(.ftIconButton:not(.arrowDisabled) .iconButton) { +:deep(.ftIconButton:not(.disabled) .iconButton) { border-radius: 50%; - color: var(--primary-text-color); cursor: pointer; font-size: 20px; block-size: 1em; @@ -67,22 +66,31 @@ &:hover { background-color: var(--side-nav-hover-color); + } + + &:active { + background-color: var(--tertiary-text-color); + } +} + +/* stylelint-disable-next-line no-descending-specificity */ +.navIcon, +:deep(:not(.active) .ftIconButton:not(.disabled) .iconButton) { + color: var(--primary-text-color); + + &:hover { color: var(--side-nav-hover-text-color); transition: background 0.2s ease-in; - } &:active { - background-color: var(--tertiary-text-color); color: var(--side-nav-active-text-color); transition: background 0.2s ease-in; } } .topNavBarColor .navIcon, -.topNavBarColor :deep(.ftIconButton:not(.arrowDisabled) .iconButton) { - color: var(--text-with-main-color); - +.topNavBarColor :deep(.ftIconButton:not(.disabled) .iconButton) { &:hover { background-color: var(--primary-color-hover); } @@ -93,6 +101,13 @@ } +/* stylelint-disable-next-line no-descending-specificity */ +.topNavBarColor .navIcon, +.topNavBarColor :deep(.ftIconButton:not(.disabled) .iconButton:not(.favorite)) { + color: var(--text-with-main-color); +} + + .navFilterIcon { $effect-distance: 20px; @@ -109,6 +124,17 @@ } } +.pageBookmarkIcon { + inline-size: fit-content; + padding: 10px; + + &.favorite { + @include top-nav-is-colored { + color: var(--accent-color); + } + } +} + .side { align-items: center; display: flex; diff --git a/src/renderer/components/top-nav/top-nav.vue b/src/renderer/components/top-nav/top-nav.vue index d156e02968541..139e8fc53d251 100644 --- a/src/renderer/components/top-nav/top-nav.vue +++ b/src/renderer/components/top-nav/top-nav.vue @@ -16,7 +16,7 @@ + diff --git a/src/renderer/helpers/strings.js b/src/renderer/helpers/strings.js index 29f9f9b41ac47..d6e7eff9b2e74 100644 --- a/src/renderer/helpers/strings.js +++ b/src/renderer/helpers/strings.js @@ -56,6 +56,46 @@ export function translateWindowTitle(title) { } } +/** + * @param {string} route + */ +export function getIconForRoute(route) { + const routeSlashIndex = route.indexOf('/', 2) + const truncatedRoute = (routeSlashIndex === -1) ? route : route.substring(0, routeSlashIndex) + switch (truncatedRoute) { + case '/subscriptions': + return ['fas', 'rss'] + case '/subscribedchannels': + case '/channel': + return ['fas', 'list'] + case '/trending': + return ['fas', 'fire'] + case '/popular': + return ['fas', 'users'] + case '/userplaylists': + return ['fas', 'bookmark'] + case '/history': + return ['fas', 'history'] + case '/settings': + return ['fas', 'sliders-h'] + case '/about': + return ['fas', 'info-circle'] + case '/search': + return ['fas', 'magnifying-glass'] + case '/hashtag': + return ['fas', 'hashtag'] + case '/post': + return ['fas', 'message'] + case '/playlist': { + const solidOrRegular = route.includes('?playlistType=user') ? 'fas' : 'far' + return [solidOrRegular, 'bookmark'] + } case '/watch': + return ['fas', 'play'] + default: + return null + } +} + /** * Returns the first user-perceived character, * respecting language specific rules and diff --git a/src/renderer/main.js b/src/renderer/main.js index 7d7d1708c87a4..e73ad06006c42 100644 --- a/src/renderer/main.js +++ b/src/renderer/main.js @@ -73,6 +73,7 @@ import { faList, faLocationDot, faLock, + faMessage, faMoneyCheckDollar, faMusic, faNetworkWired, @@ -97,6 +98,7 @@ import { faSortAlphaDown, faSortAlphaDownAlt, faSortDown, + faStar, faStepBackward, faStepForward, faSync, @@ -191,6 +193,7 @@ library.add( faList, faLocationDot, faLock, + faMessage, faMoneyCheckDollar, faMusic, faNetworkWired, @@ -215,6 +218,7 @@ library.add( faSortAlphaDown, faSortAlphaDownAlt, faSortDown, + faStar, faStepBackward, faStepForward, faSync, diff --git a/src/renderer/router/index.js b/src/renderer/router/index.js index 207f80b78904a..86d880706b2b8 100644 --- a/src/renderer/router/index.js +++ b/src/renderer/router/index.js @@ -18,13 +18,17 @@ import Post from '../views/Post.vue' Vue.use(Router) +/* + For new routes, add a corresponding + icon in src/renderer/helpers/strings.js > getIconForRoute() +*/ const router = new Router({ routes: [ { path: '/', name: 'default', meta: { - title: 'Subscriptions' + title: 'Subscriptions', }, component: Subscriptions }, diff --git a/src/renderer/store/modules/index.js b/src/renderer/store/modules/index.js index 144b0355c696f..8270551303c8f 100644 --- a/src/renderer/store/modules/index.js +++ b/src/renderer/store/modules/index.js @@ -8,6 +8,7 @@ import invidious from './invidious' import playlists from './playlists' import profiles from './profiles' import settings from './settings' +import searchHistory from './search-history' import subscriptionCache from './subscription-cache' import utils from './utils' import player from './player' @@ -18,6 +19,7 @@ export default { playlists, profiles, settings, + searchHistory, subscriptionCache, utils, player, diff --git a/src/renderer/store/modules/playlists.js b/src/renderer/store/modules/playlists.js index ac1a92a77eb5d..6c7e7b8fc4c3c 100644 --- a/src/renderer/store/modules/playlists.js +++ b/src/renderer/store/modules/playlists.js @@ -369,8 +369,10 @@ const actions = { } }, - async removeAllPlaylists({ commit }) { + async removeAllPlaylists({ commit, dispatch, state }) { try { + const playlistIds = state.playlists.map((playlist) => playlist._id) + dispatch('removeUserPlaylistPageBookmarks', playlistIds, { root: true }) await DBPlaylistHandlers.deleteAll() commit('removeAllPlaylists') } catch (errMessage) { @@ -387,8 +389,9 @@ const actions = { } }, - async removePlaylist({ commit }, playlistId) { + async removePlaylist({ commit, dispatch }, playlistId) { try { + dispatch('removeUserPlaylistPageBookmarks', [playlistId], { root: true }) await DBPlaylistHandlers.delete(playlistId) commit('removePlaylist', playlistId) } catch (errMessage) { @@ -396,8 +399,9 @@ const actions = { } }, - async removePlaylists({ commit }, playlistIds) { + async removePlaylists({ commit, dispatch }, playlistIds) { try { + dispatch('removeUserPlaylistPageBookmarks', playlistIds, { root: true }) await DBPlaylistHandlers.deleteMultiple(playlistIds) commit('removePlaylists', playlistIds) } catch (errMessage) { diff --git a/src/renderer/store/modules/search-history.js b/src/renderer/store/modules/search-history.js new file mode 100644 index 0000000000000..bc9e20aee289b --- /dev/null +++ b/src/renderer/store/modules/search-history.js @@ -0,0 +1,151 @@ +import { DBSearchHistoryHandlers } from '../../../datastores/handlers/index' + +const state = { + pageBookmarks: [] +} + +const getters = { + getPageBookmarks: (state) => { + return state.pageBookmarks + }, + + getPageBookmarkWithRoute: (state) => (route) => { + const pageBookmark = state.pageBookmarks.find(p => p.route === route) + return pageBookmark + }, + + getPageBookmarksMatchingQuery: (state) => (query, routeToExclude) => { + if (query === '') { + return [] + } + const queryToLower = query.toLowerCase() + return state.pageBookmarks.filter((pageBookmark) => + pageBookmark.name.toLowerCase().includes(queryToLower) && pageBookmark.route !== routeToExclude + ) + }, + + getPageBookmarkIdsForMatchingUserPlaylistIds: (state) => (playlistIds) => { + const pageBookmarkIds = [] + const allPageBookmarks = state.pageBookmarks + const pageBookmarkLimitedRoutesMap = new Map() + allPageBookmarks.forEach((pageBookmark) => { + pageBookmarkLimitedRoutesMap.set(pageBookmark.route, pageBookmark._id) + }) + + playlistIds.forEach((playlistId) => { + const route = `/playlist/${playlistId}?playlistType=user&searchQueryText=` + if (!pageBookmarkLimitedRoutesMap.has(route)) { + return + } + + pageBookmarkIds.push(pageBookmarkLimitedRoutesMap.get(route)) + }) + + return pageBookmarkIds + } +} +const actions = { + async grabPageBookmarks({ commit }) { + try { + const results = await DBSearchHistoryHandlers.find() + commit('setPageBookmarks', results) + } catch (errMessage) { + console.error(errMessage) + } + }, + + async createPageBookmark({ commit }, pageBookmark) { + try { + const newPageBookmark = await DBSearchHistoryHandlers.create(pageBookmark) + commit('addPageBookmarkToList', newPageBookmark) + } catch (errMessage) { + console.error(errMessage) + } + }, + + async updatePageBookmark({ commit }, pageBookmark) { + try { + await DBSearchHistoryHandlers.upsert(pageBookmark) + commit('upsertPageBookmarkToList', pageBookmark) + } catch (errMessage) { + console.error(errMessage) + } + }, + + async removePageBookmark({ commit }, _id) { + try { + await DBSearchHistoryHandlers.delete(_id) + commit('removePageBookmarkFromList', _id) + } catch (errMessage) { + console.error(errMessage) + } + }, + + async removePageBookmarks({ commit }, ids) { + try { + await DBSearchHistoryHandlers.deleteMultiple(ids) + commit('removePageBookmarksFromList', ids) + } catch (errMessage) { + console.error(errMessage) + } + }, + + async removeUserPlaylistPageBookmarks({ dispatch, getters }, userPlaylistIds) { + const pageBookmarkIds = getters.getPageBookmarkIdsForMatchingUserPlaylistIds(userPlaylistIds) + if (pageBookmarkIds.length === 0) { + return + } + + dispatch('removePageBookmarks', pageBookmarkIds) + }, + + async removeAllPageBookmarks({ commit }) { + try { + await DBSearchHistoryHandlers.deleteAll() + commit('setPageBookmarks', []) + } catch (errMessage) { + console.error(errMessage) + } + }, +} + +const mutations = { + addPageBookmarkToList(state, pageBookmark) { + state.pageBookmarks.push(pageBookmark) + }, + + setPageBookmarks(state, pageBookmarks) { + state.pageBookmarks = pageBookmarks + }, + + upsertPageBookmarkToList(state, updatedPageBookmark) { + const i = state.pageBookmarks.findIndex((p) => { + return p.route === updatedPageBookmark.route + }) + + if (i === -1) { + state.pageBookmarks.push(updatedPageBookmark) + } else { + state.pageBookmarks.splice(i, 1, updatedPageBookmark) + } + }, + + removePageBookmarkFromList(state, _id) { + const i = state.pageBookmarks.findIndex((pageBookmark) => { + return pageBookmark._id === _id + }) + + state.pageBookmarks.splice(i, 1) + }, + + removePageBookmarksFromList(state, ids) { + state.pageBookmarks = state.pageBookmarks.filter((pageBookmark) => !ids.includes(pageBookmark._id)) + } +} + +export default { + state, + getters, + actions, + mutations +} diff --git a/src/renderer/store/modules/settings.js b/src/renderer/store/modules/settings.js index 6704450a948a1..cc809054aff44 100644 --- a/src/renderer/store/modules/settings.js +++ b/src/renderer/store/modules/settings.js @@ -509,6 +509,29 @@ const customActions = { } }) + ipcRenderer.on(IpcChannels.SYNC_SEARCH_HISTORY, (_, { event, data }) => { + switch (event) { + case SyncEvents.GENERAL.CREATE: + commit('addPageBookmarkToList', data) + break + + case SyncEvents.GENERAL.UPSERT: + commit('upsertPageBookmarkToList', data) + break + + case SyncEvents.GENERAL.DELETE: + commit('removePageBookmarkFromList', data) + break + + case SyncEvents.GENERAL.DELETE_MULTIPLE: + commit('removePageBookmarksFromList', data) + break + + default: + console.error('search history: invalid sync event received') + } + }) + ipcRenderer.on(IpcChannels.SYNC_PROFILES, (_, { event, data }) => { switch (event) { case SyncEvents.GENERAL.CREATE: diff --git a/src/renderer/store/modules/utils.js b/src/renderer/store/modules/utils.js index f4dc864e9066c..2213fa09d4b0b 100644 --- a/src/renderer/store/modules/utils.js +++ b/src/renderer/store/modules/utils.js @@ -33,6 +33,7 @@ const state = { showProgressBar: false, showAddToPlaylistPrompt: false, showCreatePlaylistPrompt: false, + showPageBookmarkPrompt: false, showSearchFilters: false, searchFilterValueChanged: false, progressBarPercentage: 0, @@ -108,6 +109,10 @@ const getters = { return state.showCreatePlaylistPrompt }, + getShowPageBookmarkPrompt(state) { + return state.showPageBookmarkPrompt + }, + getShowSearchFilters(state) { return state.showSearchFilters }, @@ -388,6 +393,14 @@ const actions = { commit('setShowCreatePlaylistPrompt', false) }, + showPageBookmarkPrompt ({ commit }) { + commit('setShowPageBookmarkPrompt', true) + }, + + hidePageBookmarkPrompt ({ commit }) { + commit('setShowPageBookmarkPrompt', false) + }, + showSearchFilters ({ commit }) { commit('setShowSearchFilters', true) }, @@ -872,6 +885,10 @@ const mutations = { state.showCreatePlaylistPrompt = payload }, + setShowPageBookmarkPrompt (state, payload) { + state.showPageBookmarkPrompt = payload + }, + setShowSearchFilters (state, payload) { state.showSearchFilters = payload }, diff --git a/src/renderer/themes.css b/src/renderer/themes.css index a977cbc8510c5..203787bd2a80d 100644 --- a/src/renderer/themes.css +++ b/src/renderer/themes.css @@ -5,6 +5,7 @@ body { background-color: var(--bg-color); --primary-input-color: rgb(0 0 0 / 50%); + --side-nav-hover-text-color: var(--primary-text-color); --link-color: var(--accent-color); --link-visited-color: var(--accent-color-visited); --instance-menu-color: var(--search-bar-color); diff --git a/static/locales/en-US.yaml b/static/locales/en-US.yaml index de8959107d572..db4e34e581070 100644 --- a/static/locales/en-US.yaml +++ b/static/locales/en-US.yaml @@ -33,6 +33,10 @@ Right-click or hold to see history: Right-click or hold to see history Open New Window: Open New Window Go to page: Go to {page} Close Banner: Close Banner +Bookmark this page: Bookmark this page +Edit bookmark for this page: Edit bookmark for this page +Save: Save +Name: Name Version {versionNumber} is now available! Click for more details: Version {versionNumber} is now available! Click for more details @@ -483,6 +487,10 @@ Settings: Remove All Playlists: Remove All Playlists All playlists have been removed: All playlists have been removed Are you sure you want to remove all your playlists?: Are you sure you want to remove all your playlists? + Remove All Page Bookmarks: Remove All Page Bookmarks + Are you sure you want to remove all page bookmarks? This cannot be undone.: Are + you sure you want to remove all page bookmarks? This cannot be undone. + All page bookmarks have been removed: All page bookmarks have been removed Subscription Settings: Subscription Settings: Subscription Hide Videos on Watch: Hide Videos on Watch @@ -1103,6 +1111,18 @@ Hashtag: Hashtag: Hashtag This hashtag does not currently have any videos: This hashtag does not currently have any videos +Page Bookmark: + Bookmark this page: Bookmark this page + Create Bookmark: Create Bookmark + Edit Bookmark: Edit Bookmark + Remove Bookmark: Remove Bookmark + Created page bookmark: Created page bookmark "{name}" + Updated page bookmark: Updated page bookmark "{name}" + Removed page bookmark: Removed page bookmark "{name}" + There is {count} other bookmark with the same name.: There is {count} other page bookmark with the same name. | There are {count} other page bookmarks with the same name. + This page cannot be bookmarked.: This page cannot be bookmarked. +Role Descriptions: + bookmark: bookmark Moments Ago: moments ago Yes: Yes No: No
{{ $tc('Page Bookmark["There is {count} other bookmark with the same name."]', duplicateNameCount, { count: duplicateNameCount }) }}