Subscription groups

This commit is contained in:
Bnyro 2023-05-07 19:56:56 +02:00
parent 5e955b2286
commit c217d5e4e3
6 changed files with 200 additions and 8 deletions

View file

@ -55,7 +55,7 @@ export default {
}); });
if ("indexedDB" in window) { if ("indexedDB" in window) {
const request = indexedDB.open("piped-db", 3); const request = indexedDB.open("piped-db", 4);
request.onupgradeneeded = ev => { request.onupgradeneeded = ev => {
const db = request.result; const db = request.result;
console.log("Upgrading object store."); console.log("Upgrading object store.");
@ -73,6 +73,10 @@ export default {
store.createIndex("playlist_id_idx", "playlistId", { unique: true }); store.createIndex("playlist_id_idx", "playlistId", { unique: true });
store.createIndex("id_idx", "id", { unique: true, autoIncrement: true }); store.createIndex("id_idx", "id", { unique: true, autoIncrement: true });
} }
if (!db.objectStoreNames.contains("channel_groups")) {
const store = db.createObjectStore("channel_groups", { keyPath: "groupName" });
store.createIndex("groupName", "groupName", { unique: true });
}
}; };
request.onsuccess = e => { request.onsuccess = e => {
window.db = e.target.result; window.db = e.target.result;

View file

@ -0,0 +1,22 @@
// Wrapper around v-model to allow default values without requiring to use a v-model inside the calling component
<script>
export default {
props: {
defaultValue: Boolean,
callback: Function,
},
data() {
return {
value: false,
};
},
mounted() {
this.value = this.defaultValue;
},
};
</script>
<template>
<input type="checkbox" class="checkbox" v-model="value" @change="callback()" />
</template>

View file

@ -18,6 +18,19 @@
<option v-for="filter in availableFilters" :key="filter" :value="filter" v-t="`video.${filter}`" /> <option v-for="filter in availableFilters" :key="filter" :value="filter" v-t="`video.${filter}`" />
</select> </select>
<label for="group-selector" class="ml-10 mr-2">
<strong v-text="`${$t('titles.channel_groups')}:`" />
</label>
<select id="group-selector" v-model="selectedGroupName" default="" class="select w-auto">
<option value="" v-t="`video.all`" />
<option
v-for="group in channelGroups"
:key="group.groupName"
:value="group.groupName"
v-text="group.groupName"
/>
</select>
<span class="md:float-right"> <span class="md:float-right">
<SortingSelector by-key="uploaded" @apply="order => videos.sort(order)" /> <SortingSelector by-key="uploaded" @apply="order => videos.sort(order)" />
</span> </span>
@ -25,7 +38,7 @@
<hr /> <hr />
<LoadingIndicatorPage :show-content="videosStore != null" class="video-grid"> <LoadingIndicatorPage :show-content="videosStore != null" class="video-grid">
<template v-for="video in videos" :key="video.url"> <template v-for="video in filteredVideos" :key="video.url">
<VideoItem v-if="shouldShowVideo(video)" :is-feed="true" :item="video" /> <VideoItem v-if="shouldShowVideo(video)" :is-feed="true" :item="video" />
</template> </template>
</LoadingIndicatorPage> </LoadingIndicatorPage>
@ -50,6 +63,8 @@ export default {
videos: [], videos: [],
availableFilters: ["all", "shorts", "videos"], availableFilters: ["all", "shorts", "videos"],
selectedFilter: "all", selectedFilter: "all",
selectedGroupName: "",
channelGroups: [],
}; };
}, },
computed: { computed: {
@ -57,6 +72,12 @@ export default {
if (_this.authenticated) return _this.authApiUrl() + "/feed/rss?authToken=" + _this.getAuthToken(); if (_this.authenticated) return _this.authApiUrl() + "/feed/rss?authToken=" + _this.getAuthToken();
else return _this.authApiUrl() + "/feed/unauthenticated/rss?channels=" + _this.getUnauthenticatedChannels(); else return _this.authApiUrl() + "/feed/unauthenticated/rss?channels=" + _this.getUnauthenticatedChannels();
}, },
filteredVideos(_this) {
const selectedGroup = _this.channelGroups.filter(group => group.groupName == _this.selectedGroupName);
return _this.selectedGroupName == ""
? _this.videos
: _this.videos.filter(video => selectedGroup[0].channels.includes(video.uploaderUrl.substr(-11)));
},
}, },
mounted() { mounted() {
this.fetchFeed().then(videos => { this.fetchFeed().then(videos => {
@ -66,6 +87,20 @@ export default {
}); });
this.selectedFilter = this.getPreferenceString("feedFilter") ?? "all"; this.selectedFilter = this.getPreferenceString("feedFilter") ?? "all";
if (!window.db) return;
const cursor = this.getChannelGroupsCursor();
cursor.onsuccess = e => {
const cursor = e.target.result;
if (cursor) {
const group = cursor.value;
this.channelGroups = this.channelGroups.concat({
groupName: group.groupName,
channels: JSON.parse(group.channels),
});
}
};
}, },
activated() { activated() {
document.title = this.$t("titles.feed") + " - Piped"; document.title = this.$t("titles.feed") + " - Piped";

View file

@ -15,12 +15,32 @@
</div> </div>
<br /> <br />
<hr /> <hr />
<div class="w-full flex flex-wrap">
<button
v-for="group in channelGroups"
class="btn mx-1 w-max"
:class="{ selected: selectedGroup === group }"
:key="group.groupName"
@click="selectedGroup = group"
>
<span v-text="group.groupName !== '' ? group.groupName : $t('video.all')" />
<div v-if="group.groupName != '' && selectedGroup == group">
<font-awesome-icon class="mx-2" icon="edit" @click="showEditGroupModal = true" />
<font-awesome-icon class="mx-2" icon="circle-minus" @click="deleteGroup(group)" />
</div>
</button>
<button class="btn mx-1">
<font-awesome-icon icon="circle-plus" @click="showCreateGroupModal = true" />
</button>
</div>
<br />
<hr />
<!-- Subscriptions card list --> <!-- Subscriptions card list -->
<div class="xl:grid xl:grid-cols-5 <md:flex-wrap"> <div class="xl:grid xl:grid-cols-5 <md:flex-wrap">
<!-- channel info card --> <!-- channel info card -->
<div <div
class="col m-2 p-1 border rounded-lg border-gray-500" class="col m-2 p-1 border rounded-lg border-gray-500"
v-for="subscription in subscriptions" v-for="subscription in filteredSubscriptions"
:key="subscription.url" :key="subscription.url"
> >
<router-link :to="subscription.url" class="flex p-2 font-bold text-4x4"> <router-link :to="subscription.url" class="flex p-2 font-bold text-4x4">
@ -36,13 +56,48 @@
</div> </div>
</div> </div>
<br /> <br />
<ModalComponent v-if="showCreateGroupModal" @close="showCreateGroupModal = !showCreateGroupModal">
<h2 v-t="'actions.create_group'" />
<div class="flex flex-col">
<input class="input my-4" type="text" v-model="newGroupName" :placeholder="$t('actions.group_name')" />
<button class="ml-auto btn w-max" v-t="'actions.create_group'" @click="createGroup()" />
</div>
</ModalComponent>
<ModalComponent v-if="showEditGroupModal" @close="showEditGroupModal = false">
<h2>{{ selectedGroup.groupName }}</h2>
<div class="flex flex-col mt-3 mb-2">
<div v-for="subscription in subscriptions" :key="subscription.name">
<div class="flex justify-between">
<span>{{ subscription.name }}</span>
<DefaultValueCheckbox
:default-value="selectedGroup.channels.includes(subscription.url.substr(-11))"
:callback="() => checkedChange(subscription)"
/>
</div>
<hr />
</div>
</div>
</ModalComponent>
</template> </template>
<script> <script>
import DefaultValueCheckbox from "./DefaultValueCheckbox.vue";
import ModalComponent from "./ModalComponent.vue";
export default { export default {
data() { data() {
return { return {
subscriptions: [], subscriptions: [],
selectedGroup: {
groupName: "",
channels: [],
},
channelGroups: [],
showCreateGroupModal: false,
showEditGroupModal: false,
newGroupName: "",
}; };
}, },
mounted() { mounted() {
@ -50,6 +105,21 @@ export default {
this.subscriptions = json; this.subscriptions = json;
this.subscriptions.forEach(subscription => (subscription.subscribed = true)); this.subscriptions.forEach(subscription => (subscription.subscribed = true));
}); });
this.channelGroups = this.channelGroups.concat(this.selectedGroup);
if (!window.db) return;
const cursor = this.getChannelGroupsCursor();
cursor.onsuccess = e => {
const cursor = e.target.result;
if (cursor) {
const group = cursor.value;
this.channelGroups = this.channelGroups.concat({
groupName: group.groupName,
channels: JSON.parse(group.channels),
});
}
};
}, },
activated() { activated() {
document.title = "Subscriptions - Piped"; document.title = "Subscriptions - Piped";
@ -88,7 +158,6 @@ export default {
}, },
exportHandler() { exportHandler() {
const subscriptions = []; const subscriptions = [];
this.subscriptions.forEach(subscription => { this.subscriptions.forEach(subscription => {
subscriptions.push({ subscriptions.push({
url: "https://www.youtube.com" + subscription.url, url: "https://www.youtube.com" + subscription.url,
@ -96,15 +165,53 @@ export default {
service_id: 0, service_id: 0,
}); });
}); });
const json = JSON.stringify({ const json = JSON.stringify({
app_version: "", app_version: "",
app_version_int: 0, app_version_int: 0,
subscriptions: subscriptions, subscriptions: subscriptions,
}); });
this.download(json, "subscriptions.json", "application/json"); this.download(json, "subscriptions.json", "application/json");
}, },
createGroup() {
if (!this.newGroupName || this.channelGroups.some(group => group.groupName == this.newGroupName)) return;
const newGroup = {
groupName: this.newGroupName,
channels: [],
};
this.channelGroups = this.channelGroups.concat(newGroup);
this.createOrUpdateChannelGroup(newGroup);
this.newGroupName = "";
this.showCreateGroupModal = false;
},
deleteGroup(group) {
this.deleteChannelGroup(group.groupName);
this.channelGroups = this.channelGroups.filter(g => g != group);
this.selectedGroup = this.channelGroups[0];
},
checkedChange(subscription) {
const channelId = subscription.url.substr(-11);
this.selectedGroup.channels = this.selectedGroup.channels.includes(channelId)
? this.selectedGroup.channels.filter(channel => channel != channelId)
: this.selectedGroup.channels.concat(channelId);
this.createOrUpdateChannelGroup(this.selectedGroup);
},
}, },
computed: {
filteredSubscriptions(_this) {
return _this.selectedGroup.groupName == ""
? _this.subscriptions
: _this.subscriptions.filter(channel => _this.selectedGroup.channels.includes(channel.url.substr(-11)));
},
},
components: { ModalComponent, DefaultValueCheckbox },
}; };
</script> </script>
<style>
.selected {
border: 0.1rem outset red;
}
</style>

View file

@ -13,7 +13,8 @@
"player": "Player", "player": "Player",
"livestreams": "Livestreams", "livestreams": "Livestreams",
"channels": "Channels", "channels": "Channels",
"bookmarks": "Bookmarks" "bookmarks": "Bookmarks",
"channel_groups": "Channel groups"
}, },
"player": { "player": {
"watch_on": "Watch on {0}" "watch_on": "Watch on {0}"
@ -131,7 +132,9 @@
"playlist_bookmarked": "Bookmarked", "playlist_bookmarked": "Bookmarked",
"dismiss": "Dismiss", "dismiss": "Dismiss",
"show_more": "Show more", "show_more": "Show more",
"show_less": "Show less" "show_less": "Show less",
"create_group": "Create group",
"group_name": "Group name"
}, },
"comment": { "comment": {
"pinned_by": "Pinned by {author}", "pinned_by": "Pinned by {author}",

View file

@ -21,6 +21,7 @@ import {
faServer, faServer,
faDonate, faDonate,
faBookmark, faBookmark,
faEdit,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { faGithub, faBitcoin, faYoutube } from "@fortawesome/free-brands-svg-icons"; import { faGithub, faBitcoin, faYoutube } from "@fortawesome/free-brands-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
@ -48,6 +49,7 @@ library.add(
faServer, faServer,
faDonate, faDonate,
faBookmark, faBookmark,
faEdit,
); );
import router from "@/router/router.js"; import router from "@/router/router.js";
@ -271,6 +273,25 @@ const mixin = {
) )
.replaceAll("\n", "<br>"); .replaceAll("\n", "<br>");
}, },
getChannelGroupsCursor() {
if (!window.db) return;
var tx = window.db.transaction("channel_groups", "readonly");
var store = tx.objectStore("channel_groups");
return store.index("groupName").openCursor();
},
createOrUpdateChannelGroup(group) {
var tx = window.db.transaction("channel_groups", "readwrite");
var store = tx.objectStore("channel_groups");
store.put({
groupName: group.groupName,
channels: JSON.stringify(group.channels),
});
},
deleteChannelGroup(groupName) {
var tx = window.db.transaction("channel_groups", "readwrite");
var store = tx.objectStore("channel_groups");
store.delete(groupName);
},
}, },
computed: { computed: {
authenticated(_this) { authenticated(_this) {