Files
Piped/src/components/SubscriptionsPage.vue
2026-03-27 20:06:35 +05:30

296 lines
12 KiB
Vue

<template>
<h1 v-t="'titles.subscriptions'" class="my-4 text-center font-bold" />
<!-- import / export section -->
<div class="flex flex-wrap justify-between">
<div class="flex gap-1">
<!-- import json/csv -->
<router-link
v-t="'actions.import_from_json_csv'"
to="/import"
class="inline-block w-auto cursor-pointer rounded-sm bg-gray-300 py-2 text-gray-600 hover:bg-gray-500 hover:text-white focus:shadow-red-400 focus:outline-2 focus:outline-red-500 max-md:px-2 md:px-4 dark:bg-dark-400 dark:text-gray-400 dark:hover:bg-dark-300"
/>
<!-- export to json -->
<button
v-t="'actions.export_to_json'"
class="inline-block w-auto cursor-pointer rounded-sm bg-gray-300 py-2 text-gray-600 hover:bg-gray-500 hover:text-white focus:shadow-red-400 focus:outline-2 focus:outline-red-500 max-md:px-2 md:px-4 dark:bg-dark-400 dark:text-gray-400 dark:hover:bg-dark-300"
@click="exportHandler"
/>
</div>
<div class="m-1 flex flex-wrap gap-1">
<!-- import channel groups to json-->
<div>
<label
for="fileSelector"
class="inline-block w-auto cursor-pointer rounded-sm bg-gray-300 py-2 text-gray-600 hover:bg-gray-500 hover:text-white focus:shadow-red-400 focus:outline-2 focus:outline-red-500 max-md:px-2 md:px-4 dark:bg-dark-400 dark:text-gray-400 dark:hover:bg-dark-300"
v-text="`${$t('actions.import_from_json')} (${$t('titles.channel_groups')})`"
/>
<input
id="fileSelector"
ref="fileSelector"
type="file"
class="hidden"
multiple="multiple"
@change="importGroupsHandler"
/>
</div>
<!-- export channel groups to json -->
<button
class="inline-block w-auto cursor-pointer rounded-sm bg-gray-300 py-2 text-gray-600 hover:bg-gray-500 hover:text-white focus:shadow-red-400 focus:outline-2 focus:outline-red-500 max-md:px-2 md:px-4 dark:bg-dark-400 dark:text-gray-400 dark:hover:bg-dark-300"
@click="exportGroupsHandler"
v-text="`${$t('actions.export_to_json')} (${$t('titles.channel_groups')})`"
/>
</div>
<!-- subscriptions count, only shown if there are any -->
<div v-if="subscriptions.length > 0" class="flex gap-1 self-center">
<i18n-t keypath="subscriptions.subscribed_channels_count">{{ subscriptions.length }}</i18n-t>
</div>
</div>
<br />
<hr />
<div class="flex w-full flex-wrap">
<button
v-for="group in channelGroups"
:key="group.groupName"
class="mx-1 inline-block w-max cursor-pointer rounded-sm bg-gray-300 py-2 text-gray-600 hover:bg-gray-500 hover:text-white focus:shadow-red-400 focus:outline-2 focus:outline-red-500 max-md:px-2 md:px-4 dark:bg-dark-400 dark:text-gray-400 dark:hover:bg-dark-300"
:class="{ 'border-2 border-red-500': selectedGroup === group }"
@click="selectGroup(group)"
>
<span v-text="group.groupName !== '' ? group.groupName : $t('video.all')" />
<div v-if="group.groupName != '' && selectedGroup == group">
<i-fa6-solid-pen class="mx-2" @click="showEditGroupModal = true" />
<i-fa6-solid-circle-minus class="mx-2" @click="groupToDelete = group.groupName" />
</div>
</button>
<ConfirmModal
v-if="groupToDelete != null"
:message="$t('actions.delete_group_confirm')"
@close="groupToDelete = null"
@confirm="deleteGroup(groupToDelete)"
/>
<button
class="mx-1 inline-block w-auto cursor-pointer rounded-sm bg-gray-300 py-2 text-gray-600 hover:bg-gray-500 hover:text-white focus:shadow-red-400 focus:outline-2 focus:outline-red-500 max-md:px-2 md:px-4 dark:bg-dark-400 dark:text-gray-400 dark:hover:bg-dark-300"
@click="showCreateGroupModal = true"
>
<i-fa6-solid-circle-plus />
</button>
</div>
<br />
<hr />
<!-- Subscriptions card list -->
<div class="max-md:flex-wrap xl:grid xl:grid-cols-5">
<!-- channel info card -->
<div
v-for="subscription in filteredSubscriptions"
:key="subscription.url"
class="m-2 rounded-lg border border-gray-500 p-1"
>
<router-link :to="subscription.url" class="flex p-2 text-4xl font-bold">
<img :src="subscription.avatar" class="h-fit rounded-full" width="48" height="48" />
<span class="mx-2 self-center" v-text="subscription.name" />
</router-link>
<!-- subscribe / unsubscribe btn -->
<button
v-t="`actions.${subscription.subscribed ? 'unsubscribe' : 'subscribe'}`"
class="mt-2 inline-block w-full cursor-pointer rounded-sm bg-gray-300 py-2 text-gray-600 hover:bg-gray-500 hover:text-white focus:shadow-red-400 focus:outline-2 focus:outline-red-500 max-md:px-2 md:px-4 dark:bg-dark-400 dark:text-gray-400 dark:hover:bg-dark-300"
@click="handleButton(subscription)"
/>
</div>
</div>
<br />
<CreateGroupModal
v-if="showCreateGroupModal"
:on-create-group="createGroup"
@close="showCreateGroupModal = false"
/>
<ModalComponent v-if="showEditGroupModal" @close="showEditGroupModal = false">
<div class="mt-3 mb-5 flex justify-between">
<input
v-model="editedGroupName"
type="text"
class="h-8 rounded-md bg-gray-300 px-2.5 text-gray-600 focus:shadow-red-400 focus:outline-2 focus:outline-red-500 dark:bg-dark-400 dark:text-gray-400"
/>
<button
v-t="'actions.okay'"
class="inline-block w-auto cursor-pointer rounded-sm bg-gray-300 py-2 text-gray-600 hover:bg-gray-500 hover:text-white focus:shadow-red-400 focus:outline-2 focus:outline-red-500 max-md:px-2 md:px-4 dark:bg-dark-400 dark:text-gray-400 dark:hover:bg-dark-300"
:placeholder="$t('actions.group_name')"
@click="editGroupName()"
/>
</div>
<div class="mt-3 mb-2 flex h-[80vh] flex-col overflow-y-scroll pr-2">
<div v-for="subscription in subscriptions" :key="subscription.name">
<div class="mr-3 flex items-center justify-between">
<a :href="subscription.url" target="_blank" class="flex items-center overflow-hidden">
<img :src="subscription.avatar" class="size-8 rounded-full" />
<span class="ml-2">{{ subscription.name }}</span>
</a>
<UiCheckbox
:model-value="selectedGroup.channels.includes(subscription.url.substr(-24))"
@update:model-value="checkedChange(subscription)"
/>
</div>
<hr />
</div>
</div>
</ModalComponent>
</template>
<script setup>
import { ref, computed, onMounted, onActivated } from "vue";
import ModalComponent from "./ModalComponent.vue";
import CreateGroupModal from "./CreateGroupModal.vue";
import ConfirmModal from "./ConfirmModal.vue";
import UiCheckbox from "./ui/Checkbox.vue";
import { fetchJson, authApiUrl, getAuthToken, isAuthenticated } from "@/composables/useApi.js";
import { fetchSubscriptions, handleLocalSubscriptions } from "@/composables/useSubscriptions.js";
import { getChannelGroups, createOrUpdateChannelGroup, deleteChannelGroup } from "@/composables/useChannelGroups.js";
import { download } from "@/composables/useMisc.js";
const fileSelector = ref(null);
const subscriptions = ref([]);
const selectedGroup = ref({
groupName: "",
channels: [],
});
const channelGroups = ref([]);
const showCreateGroupModal = ref(false);
const showEditGroupModal = ref(false);
const editedGroupName = ref("");
const groupToDelete = ref(null);
const filteredSubscriptions = computed(() => {
return selectedGroup.value.groupName == ""
? subscriptions.value
: subscriptions.value.filter(channel => selectedGroup.value.channels.includes(channel.url.substr(-24)));
});
function handleButton(subscription) {
const channelId = subscription.url.split("/")[2];
if (isAuthenticated()) {
fetchJson(authApiUrl() + (subscription.subscribed ? "/unsubscribe" : "/subscribe"), null, {
method: "POST",
body: JSON.stringify({
channelId: channelId,
}),
headers: {
Authorization: getAuthToken(),
"Content-Type": "application/json",
},
});
} else {
handleLocalSubscriptions(channelId);
}
subscription.subscribed = !subscription.subscribed;
}
function exportHandler() {
const subs = [];
subscriptions.value.forEach(subscription => {
subs.push({
url: "https://www.youtube.com" + subscription.url,
name: subscription.name,
service_id: 0,
});
});
const json = JSON.stringify({
app_version: "",
app_version_int: 0,
subscriptions: subs,
});
download(json, "subscriptions.json", "application/json");
}
function selectGroup(group) {
selectedGroup.value = group;
editedGroupName.value = group.groupName;
}
function createGroup(newGroupName) {
if (!newGroupName || channelGroups.value.some(group => group.groupName == newGroupName)) return;
const newGroup = {
groupName: newGroupName,
channels: [],
};
channelGroups.value.push(newGroup);
createOrUpdateChannelGroup(newGroup);
showCreateGroupModal.value = false;
}
function editGroupName() {
const oldGroupName = selectedGroup.value.groupName;
const newGroupName = editedGroupName.value;
if (!newGroupName || newGroupName == oldGroupName) return;
if (channelGroups.value.some(group => group.groupName == newGroupName)) return;
selectedGroup.value.groupName = newGroupName;
createOrUpdateChannelGroup(selectedGroup.value);
deleteChannelGroup(oldGroupName);
showEditGroupModal.value = false;
}
function deleteGroup(group) {
deleteChannelGroup(group);
channelGroups.value = channelGroups.value.filter(g => g.groupName != group);
selectedGroup.value = channelGroups.value[0] || {};
groupToDelete.value = null;
}
function checkedChange(subscription) {
const channelId = subscription.url.substr(-24);
selectedGroup.value.channels = selectedGroup.value.channels.includes(channelId)
? selectedGroup.value.channels.filter(channel => channel != channelId)
: selectedGroup.value.channels.concat(channelId);
createOrUpdateChannelGroup(selectedGroup.value);
}
async function importGroupsHandler() {
const files = fileSelector.value.files;
for (let file of files) {
const groups = JSON.parse(await file.text()).groups;
for (let group of groups) {
createOrUpdateChannelGroup(group);
channelGroups.value.push(group);
}
}
}
function exportGroupsHandler() {
const json = {
format: "Piped",
version: 1,
groups: channelGroups.value.slice(1),
};
download(JSON.stringify(json), "channel_groups.json", "application/json");
}
onMounted(() => {
fetchSubscriptions().then(json => {
if (json.error) {
alert(json.error);
return;
}
subscriptions.value = json;
subscriptions.value.forEach(subscription => (subscription.subscribed = true));
});
channelGroups.value.push(selectedGroup.value);
if (!window.db) return;
(async () => {
const groups = await getChannelGroups();
channelGroups.value.push(...groups);
})();
});
onActivated(() => {
document.title = "Subscriptions - Piped";
});
</script>