Migrate to tailwind + reka ui

This commit is contained in:
Kavin
2026-03-27 11:43:13 +05:30
parent 9d0da61e34
commit d1ef96e7d4
54 changed files with 2206 additions and 1926 deletions

View File

@@ -1,9 +1,10 @@
import js from "@eslint/js";
import unoConfig from "@unocss/eslint-config/flat";
import { defineConfig } from "eslint/config";
import betterTailwindcss from "eslint-plugin-better-tailwindcss";
import prettierRecommended from "eslint-plugin-prettier/recommended";
import vue from "eslint-plugin-vue";
import globals from "globals";
import vueParser from "vue-eslint-parser";
export default defineConfig([
{
@@ -25,16 +26,28 @@ export default defineConfig([
files: ["**/*.{js,mjs,cjs,vue}"],
},
...vue.configs["flat/recommended"],
{
...unoConfig,
files: ["**/*.{js,mjs,cjs,vue}"],
},
{
files: ["**/*.vue"],
rules: {
"no-useless-assignment": "off",
},
},
{
files: ["**/*.vue"],
extends: [betterTailwindcss.configs.recommended],
settings: {
"better-tailwindcss": {
entryPoint: "src/app.css",
},
},
languageOptions: {
parser: vueParser,
},
rules: {
"better-tailwindcss/enforce-consistent-line-wrapping": "off",
},
},
{
...prettierRecommended,
files: ["**/*.{js,mjs,cjs,vue}"],

View File

@@ -11,6 +11,7 @@
"lint": "eslint --fix --color ."
},
"dependencies": {
"@vueuse/core": "^14.2.1",
"dompurify": "3.3.3",
"fast-xml-parser": "5.5.9",
"hotkeys-js": "4.0.2",
@@ -18,6 +19,7 @@
"linkify-html": "4.3.2",
"linkifyjs": "4.3.2",
"qrcode": "^1.5.4",
"reka-ui": "^2.9.2",
"shaka-player": "5.0.8",
"vue": "3.5.31",
"vue-i18n": "11.3.0",
@@ -28,24 +30,21 @@
"@iconify-json/fa6-brands": "1.2.6",
"@iconify-json/fa6-solid": "1.2.4",
"@intlify/unplugin-vue-i18n": "11.1.1",
"@unocss/eslint-config": "66.6.7",
"@unocss/preset-icons": "66.6.7",
"@unocss/preset-uno": "66.6.7",
"@unocss/preset-web-fonts": "66.6.7",
"@unocss/reset": "66.6.7",
"@unocss/transformer-directives": "66.6.7",
"@unocss/transformer-variant-group": "66.6.7",
"@tailwindcss/vite": "^4.2.2",
"@vitejs/plugin-legacy": "8.0.1",
"@vitejs/plugin-vue": "6.0.5",
"@vue/compiler-sfc": "3.5.31",
"eslint": "10.1.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-better-tailwindcss": "^4.3.2",
"eslint-plugin-prettier": "5.5.5",
"eslint-plugin-vue": "10.8.0",
"globals": "17.4.0",
"lightningcss": "1.32.0",
"prettier": "3.8.1",
"unocss": "66.6.7",
"tailwindcss": "^4.2.2",
"unplugin-icons": "^23.0.1",
"unplugin-vue-components": "^32.0.0",
"vite": "8.0.3",
"vite-plugin-eslint": "1.8.1",
"vite-plugin-pwa": "1.2.0",

1205
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,8 @@
<template>
<div class="reset min-h-screen w-full flex flex-col px-1vw py-5 antialiased" :class="[theme]">
<div
class="flex min-h-screen w-full flex-col bg-white px-[1vw] py-5 text-black antialiased dark:bg-dark-900 dark:text-white"
:class="[theme]"
>
<div class="flex-1">
<NavBar />
<router-view v-slot="{ Component }">
@@ -14,27 +17,28 @@
</template>
<script setup>
import { ref, onMounted } from "vue";
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import NavBar from "./components/NavBar.vue";
import FooterComponent from "./components/FooterComponent.vue";
import { getPreferenceString } from "@/composables/usePreferences.js";
import { testLocalStorage, usePreferenceString } from "@/composables/usePreferences.js";
import { getDefaultLanguage, TimeAgo, TimeAgoConfig } from "@/composables/useFormatting.js";
import { fetchSubscriptions } from "@/composables/useSubscriptions.js";
import { getChannelGroups, createOrUpdateChannelGroup } from "@/composables/useChannelGroups.js";
const darkModePreference = window.matchMedia("(prefers-color-scheme: dark)");
const themePreference = usePreferenceString("theme", "dark");
const localePreference = usePreferenceString("hl", "en");
const theme = ref("dark");
function setTheme() {
let themePref = getPreferenceString("theme", "dark");
const themes = {
dark: "dark",
light: "light",
auto: darkModePreference.matches ? "dark" : "light",
};
theme.value = themes[themePref];
theme.value = themes[themePreference.value] ?? themes.dark;
changeTitleBarColor();
@@ -48,11 +52,42 @@ function changeTitleBarColor() {
themeColor.setAttribute("content", currentColor[theme.value]);
}
onMounted(() => {
setTheme();
darkModePreference.addEventListener("change", () => {
async function applyLocale(locale = localePreference.value) {
const resolvedLocale = locale || (await getDefaultLanguage());
if (resolvedLocale !== TimeAgoConfig.locale) {
const localeTime = await import(`../node_modules/javascript-time-ago/locale/${resolvedLocale}.json`)
.catch(() => null)
.then(module => module?.default);
if (localeTime) {
TimeAgo.addLocale(localeTime);
TimeAgoConfig.locale = resolvedLocale;
}
}
if (window.i18n.global.locale.value !== resolvedLocale) {
if (!window.i18n.global.availableLocales.includes(resolvedLocale)) {
const messages = await import(`./locales/${resolvedLocale}.json`).then(module => module.default);
window.i18n.global.setLocaleMessage(resolvedLocale, messages);
}
window.i18n.global.locale.value = resolvedLocale;
}
}
function handlePreferredColorSchemeChange() {
if (themePreference.value === "auto") setTheme();
}
watch(
themePreference,
() => {
setTheme();
});
},
{ immediate: true },
);
onMounted(() => {
darkModePreference.addEventListener("change", handlePreferredColorSchemeChange);
if ("indexedDB" in window) {
const request = indexedDB.open("piped-db", 6);
@@ -107,202 +142,115 @@ onMounted(() => {
} else console.log("This browser doesn't support IndexedDB");
(async function () {
const defaultLang = await getDefaultLanguage();
const locale = getPreferenceString("hl", defaultLang);
if (locale !== TimeAgoConfig.locale) {
const localeTime = await import(`../node_modules/javascript-time-ago/locale/${locale}.json`)
.catch(() => null)
.then(module => module?.default);
if (localeTime) {
TimeAgo.addLocale(localeTime);
TimeAgoConfig.locale = locale;
}
}
if (window.i18n.global.locale.value !== locale) {
if (!window.i18n.global.availableLocales.includes(locale)) {
const messages = await import(`./locales/${locale}.json`).then(module => module.default);
window.i18n.global.setLocaleMessage(locale, messages);
}
window.i18n.global.locale.value = locale;
}
const initialLocale =
testLocalStorage() && localStorage.getItem("hl") === null
? await getDefaultLanguage()
: localePreference.value;
await applyLocale(initialLocale);
watch(localePreference, locale => {
applyLocale(locale);
});
})();
});
onBeforeUnmount(() => {
darkModePreference.removeEventListener("change", handlePreferredColorSchemeChange);
});
</script>
<style>
h1,
p,
a,
b {
unicode-bidi: plaintext;
text-align: start;
}
@reference "./app.css";
:root {
color-scheme: only light;
}
@layer base {
h1,
p,
a,
b {
unicode-bidi: plaintext;
text-align: start;
}
::-webkit-scrollbar {
background-color: #d1d5db;
}
:root {
color-scheme: only light;
}
::-webkit-scrollbar-thumb {
background-color: #4b4f52;
}
::-webkit-scrollbar {
background-color: #d1d5db;
}
::-webkit-scrollbar-thumb:hover {
background-color: #5b6469;
}
::-webkit-scrollbar-thumb {
background-color: #4b4f52;
}
::-webkit-scrollbar-thumb:active {
background-color: #485053;
}
::-webkit-scrollbar-thumb:hover {
background-color: #5b6469;
}
::-webkit-scrollbar-corner {
background-color: #0b0e0f;
}
::-webkit-scrollbar-thumb:active {
background-color: #485053;
}
:root {
scrollbar-color: #4b4f52 #d1d5db;
}
::-webkit-scrollbar-corner {
background-color: #0b0e0f;
}
.dark ::-webkit-scrollbar {
background-color: #15191a;
}
:root {
scrollbar-color: #4b4f52 #d1d5db;
}
.dark ::-webkit-scrollbar-thumb {
background-color: #4b4f52;
}
.dark ::-webkit-scrollbar {
background-color: #15191a;
}
.dark ::-webkit-scrollbar-thumb:hover {
background-color: #5b6469;
}
.dark ::-webkit-scrollbar-thumb {
background-color: #4b4f52;
}
.dark ::-webkit-scrollbar-thumb:active {
background-color: #485053;
}
.dark ::-webkit-scrollbar-thumb:hover {
background-color: #5b6469;
}
.dark ::-webkit-scrollbar-corner {
background-color: #0b0e0f;
}
.dark ::-webkit-scrollbar-thumb:active {
background-color: #485053;
}
:root.dark {
scrollbar-color: #4b4f52 #15191a;
}
.dark ::-webkit-scrollbar-corner {
background-color: #0b0e0f;
}
* {
@apply font-sans;
}
:root.dark {
scrollbar-color: #4b4f52 #15191a;
}
.video-grid {
@apply grid grid-cols-1 mx-2 sm:mx-0 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 col-auto lt-md:gap-x-3 md:gap-x-6 gap-y-5;
}
* {
font-family: var(--font-sans);
}
.btn {
@apply py-2 lt-md:px-2 md:px-4 rounded cursor-pointer inline-block hover:bg-gray-500 hover:text-white;
}
hr {
margin-top: 0.5rem !important;
margin-bottom: 0.75rem !important;
border-color: #d1d5db;
}
.reset {
@apply text-black bg-white;
}
.dark hr {
border-color: var(--color-dark-100);
}
.dark {
@apply text-white bg-dark-900;
}
h1,
h2 {
margin: 0;
font-weight: 700;
}
.input,
.select,
.btn {
@apply w-auto text-gray-600 bg-gray-300;
}
h1 {
font-size: 3rem !important;
line-height: 1;
}
.input,
.select {
@apply h-8;
}
.checkbox {
@apply h-4 w-4;
}
.dark .input,
.dark .select,
.dark .btn {
@apply text-gray-400 bg-dark-400;
}
.dark .btn {
@apply hover:bg-dark-300;
}
.input {
@apply px-2.5 rounded-md;
}
.input:focus {
@apply outline-red-500;
outline-style: solid;
outline-width: 2px;
box-shadow: 0 0 15px rgba(239, 68, 68, 1);
}
hr {
@apply !mt-2 !mb-3 border-gray-300;
}
.dark hr {
@apply border-dark-100;
}
h1,
h2 {
@apply m-0 font-bold;
}
h1 {
@apply !text-5xl;
}
h2 {
@apply !text-3xl;
}
.table {
@apply w-full text-lg text-left font-light border;
}
.link {
@apply focus:text-red-500 hover:text-red-500;
}
.link-secondary {
@apply hover:text-dark-400 focus:text-dark-400 underline underline-dark-400;
}
.dark .link {
@apply focus:text-red-400 hover:text-red-400;
}
.dark .link-secondary {
@apply text-gray-300 hover:(text-gray-400 underline underline-gray-400);
}
.line {
@apply px-2.5 py-0.25 my-0.45 rounded-xl bg-dark-900;
}
.dark .line {
@apply bg-white;
}
.thumbnail-overlay {
@apply rounded-md absolute bg-black text-white bg-opacity-75 px-5px;
}
.thumbnail-right {
@apply bottom-5px right-5px;
}
.thumbnail-left {
@apply bottom-5px left-5px text-xs font-bold bg-red-600 uppercase;
h2 {
font-size: 1.875rem !important;
line-height: 2.25rem;
}
}
</style>

39
src/app.css Normal file
View File

@@ -0,0 +1,39 @@
@import "tailwindcss";
@theme {
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--color-dark-100: #3f3f46;
--color-dark-300: #323232;
--color-dark-400: #222222;
--color-dark-700: #1a1a1a;
--color-dark-800: #141414;
--color-dark-900: #0f0f0f;
}
@utility skip-segment-button {
z-index: 1000;
position: absolute;
transform: translate(0, -50%);
top: 50%;
right: 0;
background-color: rgb(0 0 0 / 0.5);
border: 2px rgba(255, 255, 255, 0.75) solid;
border-right: 0;
border-radius: 0.75em;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
padding: 0.5em;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
line-height: 1.5em;
}
@utility skip-segment-icon {
font-size: 1.6em !important;
line-height: inherit !important;
margin-left: 0.4em;
}

View File

@@ -1,6 +1,6 @@
<template>
<ModalComponent @close="$emit('close')">
<div class="min-w-[50vw] flex flex-col">
<div class="flex min-w-[50vw] flex-col">
<div class="h-[70vh] overflow-y-scroll pr-4">
<span v-t="'actions.add_to_group'" class="mb-3 inline-block w-max text-2xl" />
<div v-for="(group, index) in channelGroups" :key="group.groupName" class="px-1">
@@ -15,7 +15,11 @@
<hr class="h-1 w-full" />
</div>
</div>
<button v-t="'actions.create_group'" class="btn ml-auto w-max" @click="showCreateGroupModal = true" />
<button
v-t="'actions.create_group'"
class="ml-auto 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"
@click="showCreateGroupModal = true"
/>
</div>
</ModalComponent>

View File

@@ -1,38 +1,53 @@
<template>
<div class="flex flex-col flex-justify-between">
<router-link :to="item.url" class="link font-bold">
<div class="flex flex-col justify-between">
<router-link
:to="item.url"
class="font-bold hover:text-red-500 focus:text-red-500 dark:hover:text-red-400 dark:focus:text-red-400"
>
<div class="my-4 flex justify-center">
<img loading="lazy" class="aspect-square w-[50%] rounded-full" :src="item.thumbnail" />
</div>
<p>
<p class="line-clamp-2 leading-tight">
<span v-text="item.name" />
<i v-if="item.verified" class="i-fa6-solid:check ml-1.5" />
<i-fa6-solid-check v-if="item.verified" class="ml-1.5" />
</p>
</router-link>
<p v-if="item.description" class="pt-1 text-sm" v-text="item.description" />
<router-link v-if="item.uploaderUrl" class="link" :to="item.uploaderUrl">
<p>
<p
v-if="item.description"
class="line-clamp-2 pt-1 text-sm text-gray-600 dark:text-gray-400"
v-text="item.description"
/>
<router-link
v-if="item.uploaderUrl"
class="mt-1 line-clamp-1 block text-sm underline decoration-dark-400 hover:text-dark-400 focus:text-dark-400 dark:text-gray-300 dark:decoration-dark-100 dark:hover:text-gray-400"
:to="item.uploaderUrl"
>
<p class="leading-tight">
<span v-text="item.uploader" />
<i v-if="item.uploaderVerified" class="i-fa6-solid:check ml-1.5" />
<i-fa6-solid-check v-if="item.uploaderVerified" class="ml-1.5" />
</p>
</router-link>
<a v-if="item.uploaderName" class="link" v-text="item.uploaderName" />
<a
v-if="item.uploaderName"
class="mt-1 line-clamp-1 block text-sm hover:text-red-500 focus:text-red-500 dark:text-gray-300 dark:hover:text-red-400 dark:focus:text-red-400"
v-text="item.uploaderName"
/>
<template v-if="item.videos >= 0">
<br v-if="item.uploaderName" />
<strong v-text="`${item.videos} ${$t('video.videos')}`" />
<strong
class="mt-1 text-sm text-gray-800 dark:text-gray-200"
v-text="`${item.videos} ${$t('video.videos')}`"
/>
</template>
<button
v-if="subscribed != null"
class="btn mt-2 w-max"
class="mt-2 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"
@click="subscribeHandler"
v-text="
$t('actions.' + (subscribed ? 'unsubscribe' : 'subscribe')) + ' - ' + numberFormat(item.subscribers)
"
/>
<br />
</div>
</template>

View File

@@ -12,14 +12,14 @@
<div class="flex place-items-center">
<img height="48" width="48" class="m-1 rounded-full" :src="channel.avatarUrl" />
<div class="flex items-center gap-1">
<h1 class="!text-xl" v-text="channel.name" />
<i v-if="channel.verified" class="i-fa6-solid:check !text-xl" />
<h1 class="text-xl!" v-text="channel.name" />
<i-fa6-solid-check v-if="channel.verified" class="text-xl!" />
</div>
</div>
<div class="flex gap-2">
<button
class="btn"
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="subscribeHandler"
v-text="
$t('actions.' + (subscribed ? 'unsubscribe' : 'subscribe')) +
@@ -31,7 +31,7 @@
<button
v-if="subscribed"
v-t="'actions.add_to_group'"
class="btn"
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="showGroupModal = true"
></button>
@@ -43,9 +43,9 @@
role="button"
:href="`${apiUrl()}/feed/unauthenticated/rss?channels=${channel.id}`"
target="_blank"
class="btn flex-col"
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"
>
<i class="i-fa6-solid:rss" />
<i-fa6-solid-rss />
</a>
</div>
</div>
@@ -58,20 +58,28 @@
<button
v-for="(tab, index) in tabs"
:key="tab.name"
class="btn mr-2"
:class="{ active: selectedTab == index }"
class="mr-2 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"
:class="{
'bg-gray-500 text-white dark:bg-dark-300': selectedTab == index,
}"
@click="loadTab(index)"
>
<span v-text="tab.translatedName"></span>
</button>
<router-link :to="`/playlist?list=UU${channel.id.substring(2)}`">
<button class="btn h-full">Play all videos</button>
<button
class="inline-block h-full 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"
>
Play all videos
</button>
</router-link>
</div>
<hr />
<div class="video-grid">
<div
class="mx-2 grid grid-cols-1 gap-y-5 max-md:gap-x-3 sm:mx-0 sm:grid-cols-2 md:grid-cols-3 md:gap-x-6 lg:grid-cols-4 xl:grid-cols-5"
>
<ContentItem
v-for="item in contentItems"
:key="item.url"

View File

@@ -1,22 +1,22 @@
<template>
<!-- desktop view -->
<div v-if="!mobileLayout" class="max-h-75vh max-w-35vw min-h-64 flex-col overflow-y-auto lt-lg:hidden">
<div v-if="!mobileLayout" class="max-h-[75vh] min-h-64 max-w-[35vw] flex-col overflow-y-auto max-lg:hidden">
<h2 class="mb-2 bg-gray-500/50 p-2" aria-label="chapters" title="chapters">
{{ $t("video.chapters") }} ({{ chapters.length }})
</h2>
<div
v-for="(chapter, index) in chapters"
:key="chapter.start"
class="chapter-vertical"
class="cursor-pointer self-center p-2.5 hover:bg-gray-500"
:class="{ 'bg-red-500/50': isCurrentChapter(index) }"
@click="$emit('seek', chapter.start)"
>
<div class="flex">
<span class="mr-2 mt-5 text-current" v-text="index + 1" />
<img class="shrink-0" :src="chapter.image" :alt="chapter.title" />
<span class="mt-5 mr-2 text-current" v-text="index + 1" />
<img class="h-[30%] w-[30%] shrink-0" :src="chapter.image" :alt="chapter.title" />
<div class="m-2 flex flex-col">
<span class="text-sm" :title="chapter.title" v-text="chapter.title" />
<span class="text-sm text-blue-500 font-bold" v-text="timeFormat(chapter.start)" />
<span class="text-sm font-bold text-blue-500" v-text="timeFormat(chapter.start)" />
</div>
</div>
</div>
@@ -25,7 +25,7 @@
<!-- mobile vertical view -->
<div
v-if="mobileLayout && getPreferenceString('mobileChapterLayout') == 'Vertical'"
class="max-h-64 flex flex-col overflow-y-auto"
class="flex max-h-64 flex-col overflow-y-auto"
>
<h2 class="mb-2 bg-gray-500/50 p-2" aria-label="chapters" title="chapters">
{{ $t("video.chapters") }} ({{ chapters.length }})
@@ -33,16 +33,16 @@
<div
v-for="(chapter, index) in chapters"
:key="chapter.start"
class="chapter-vertical"
class="cursor-pointer self-center p-2.5 hover:bg-gray-500"
:class="{ 'bg-red-500/50': isCurrentChapter(index) }"
@click="$emit('seek', chapter.start)"
>
<div class="flex">
<span class="mr-2 mt-5 text-current" v-text="index + 1" />
<img class="shrink-0" :src="chapter.image" :alt="chapter.title" />
<span class="mt-5 mr-2 text-current" v-text="index + 1" />
<img class="h-[30%] w-[30%] shrink-0" :src="chapter.image" :alt="chapter.title" />
<div class="m-2 flex flex-col">
<span class="text-sm" :title="chapter.title" v-text="chapter.title" />
<span class="text-sm text-blue-500 font-bold" v-text="timeFormat(chapter.start)" />
<span class="text-sm font-bold text-blue-500" v-text="timeFormat(chapter.start)" />
</div>
</div>
</div>
@@ -52,14 +52,18 @@
<div
v-for="(chapter, index) in chapters"
:key="chapter.start"
class="chapter"
class="cursor-pointer self-center p-2.5"
:class="{ 'bg-red-500/50': isCurrentChapter(index) }"
@click="$emit('seek', chapter.start)"
>
<img :src="chapter.image" :alt="chapter.title" />
<img class="size-full" :src="chapter.image" :alt="chapter.title" />
<div class="m-1 flex">
<span class="text-truncate text-sm" :title="chapter.title" v-text="chapter.title" />
<span class="px-1 text-sm text-blue-500 font-bold" v-text="timeFormat(chapter.start)" />
<span
class="inline-block w-[10em] truncate overflow-hidden text-sm"
:title="chapter.title"
v-text="chapter.title"
/>
<span class="px-1 text-sm font-bold text-blue-500" v-text="timeFormat(chapter.start)" />
</div>
</div>
</div>
@@ -92,26 +96,11 @@ defineEmits(["seek"]);
</script>
<style>
::-webkit-scrollbar {
height: 5px;
}
.chapter {
@apply cursor-pointer self-center p-2.5;
}
.chapter img {
@apply w-full h-full;
}
.chapter-vertical {
@apply cursor-pointer self-center p-2.5;
}
.chapter-vertical img {
@apply w-3/10 h-3/10;
}
@reference "../app.css";
.chapter-vertical:hover {
@apply bg-gray-500;
}
.text-truncate {
@apply truncate overflow-hidden inline-block w-10em;
@layer base {
::-webkit-scrollbar {
height: 5px;
}
}
</style>

View File

@@ -1,13 +1,13 @@
<template>
<div v-if="text" class="mx-1 whitespace-pre-wrap py-2">
<div v-if="text" class="mx-1 py-2 whitespace-pre-wrap">
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-if="showFullText" class="contentText" v-html="fullText()" />
<span v-if="showFullText" class="wrap-anywhere" v-html="fullText()" />
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-else v-html="collapsedText()" />
<span v-if="text.length > visibleLimit && !showFullText">...</span>
<button
v-if="text.length > visibleLimit"
class="block whitespace-normal text-neutral-500 font-semibold hover:underline"
class="block font-semibold whitespace-normal text-neutral-500 hover:underline"
@click="showFullText = !showFullText"
>
[{{ showFullText ? $t("actions.show_less") : $t("actions.show_more") }}]
@@ -40,9 +40,3 @@ function collapsedText() {
return purifyHTML(rewriteDescription(props.text.slice(0, props.visibleLimit)));
}
</script>
<style>
.contentText {
word-wrap: anywhere;
}
</style>

View File

@@ -1,18 +1,11 @@
<template>
<div class="comment mt-1.5 flex">
<img
loading="lazy"
:src="comment.thumbnail"
class="comment-avatar h-12 w-12 rounded-full"
height="48"
width="48"
alt="Avatar"
/>
<div class="mt-1.5 flex">
<img loading="lazy" :src="comment.thumbnail" class="size-12 rounded-full" height="48" width="48" alt="Avatar" />
<div class="comment-content pl-2">
<div class="comment-header">
<div v-if="comment.pinned" class="comment-pinned">
<i class="i-fa6-solid:thumbtack" />
<div class="pl-2">
<div>
<div v-if="comment.pinned">
<i-fa6-solid-thumbtack />
<span
v-t="{
path: 'comment.pinned_by',
@@ -22,45 +15,49 @@
/>
</div>
<div class="comment-author">
<router-link class="link font-bold" :to="comment.commentorUrl">{{ comment.author }}</router-link>
<i v-if="comment.verified" class="i-fa6-solid:check ml-1.5" />
<div>
<router-link
class="font-bold hover:text-red-500 focus:text-red-500 dark:hover:text-red-400 dark:focus:text-red-400"
:to="comment.commentorUrl"
>{{ comment.author }}</router-link
>
<i-fa6-solid-check v-if="comment.verified" class="ml-1.5" />
</div>
<div class="comment-meta mb-1.5 text-sm" v-text="comment.commentedTime" />
<div class="mb-1.5 text-sm" v-text="comment.commentedTime" />
</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<CollapsableText :text="comment.commentText" :visible-limit="500" />
<div class="comment-footer my-1 flex items-center gap-3">
<div class="i-fa6-solid:thumbs-up" />
<div class="my-1 flex items-center gap-3">
<i-fa6-solid-thumbs-up />
<span v-text="numberFormat(comment.likeCount)" />
<i v-if="comment.hearted" class="i-fa6-solid:heart" :title="$t('actions.creator_liked')" />
<i-fa6-solid-heart v-if="comment.hearted" :title="$t('actions.creator_liked')" />
<img
v-if="comment.creatorReplied"
:src="uploaderAvatarUrl"
class="h-5 w-5 rounded-full"
class="size-5 rounded-full"
:title="$t('actions.creator_replied')"
/>
</div>
<template v-if="comment.repliesPage && (!loadingReplies || !showingReplies)">
<div class="cursor-pointer" @click="loadReplies">
<a v-text="`${$t('actions.reply_count', comment.replyCount)}`" />
<i class="i-fa6-solid:level-down-alt ml-1.5" />
<i-fa6-solid-turn-down class="ml-1.5" />
</div>
</template>
<template v-if="showingReplies">
<div class="cursor-pointer" @click="hideReplies">
<a v-t="'actions.hide_replies'" />
<i class="i-fa6-solid:level-up-alt ml-1.5" />
<i-fa6-solid-turn-up class="ml-1.5" />
</div>
</template>
<div v-show="showingReplies" v-if="replies" class="replies">
<div v-show="showingReplies" v-if="replies">
<div v-for="reply in replies" :key="reply.commentId" class="w-full">
<!-- eslint-disable-next-line vue/no-undef-components -->
<CommentItem :comment="reply" :uploader="uploader" :video-id="videoId" />
</div>
<div v-if="nextpage" class="cursor-pointer" @click="loadReplies">
<a v-t="'actions.load_more_replies'" />
<i class="i-fa6-solid:level-down-alt ml-1.5" />
<i-fa6-solid-turn-down class="ml-1.5" />
</div>
</div>
</div>

View File

@@ -2,9 +2,17 @@
<ModalComponent @close="$emit('close')">
<div>
<h3 class="text-xl" v-text="message" />
<div class="ml-auto mt-8 w-min flex gap-2">
<button v-t="'actions.cancel'" class="btn" @click="$emit('close')" />
<button v-t="'actions.okay'" class="btn" @click="$emit('confirm')" />
<div class="mt-8 ml-auto flex w-min gap-2">
<button
v-t="'actions.cancel'"
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="$emit('close')"
/>
<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"
@click="$emit('confirm')"
/>
</div>
</div>
</ModalComponent>

View File

@@ -2,8 +2,17 @@
<ModalComponent @close="$emit('close')">
<h2 v-t="'actions.create_group'" />
<div class="flex flex-col">
<input v-model="groupName" class="input my-4" type="text" :placeholder="$t('actions.group_name')" />
<button v-t="'actions.create_group'" class="btn ml-auto w-max" @click="createGroup()" />
<input
v-model="groupName"
class="my-4 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"
type="text"
:placeholder="$t('actions.group_name')"
/>
<button
v-t="'actions.create_group'"
class="ml-auto 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"
@click="createGroup()"
/>
</div>
</ModalComponent>
</template>

View File

@@ -2,10 +2,23 @@
<ModalComponent @close="$emit('close')">
<div class="flex flex-col">
<h2 v-t="'actions.create_playlist'" />
<input ref="input" v-model="playlistName" type="text" class="input mt-2" />
<div class="ml-auto mt-3 w-min flex">
<button v-t="'actions.cancel'" class="btn" @click="$emit('close')" />
<button v-t="'actions.okay'" class="btn ml-2" @click="onCreatePlaylist" />
<input
ref="input"
v-model="playlistName"
type="text"
class="mt-2 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"
/>
<div class="mt-3 ml-auto flex w-min">
<button
v-t="'actions.cancel'"
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="$emit('close')"
/>
<button
v-t="'actions.okay'"
class="ml-2 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="onCreatePlaylist"
/>
</div>
</div>
</ModalComponent>

View File

@@ -7,8 +7,8 @@
<div v-for="(customInstance, index) in customInstances" :key="customInstance.name">
<div class="flex items-center justify-between">
<span>{{ customInstance.name }} - {{ customInstance.api_url }}</span>
<span
class="i-fa6-solid:circle-minus cursor-pointer"
<i-fa6-solid-circle-minus
class="cursor-pointer"
@click="removeInstance(customInstance, index)"
/>
</div>
@@ -16,15 +16,24 @@
</div>
</div>
<form class="flex flex-col items-end gap-2">
<input v-model="name" class="input w-full" type="text" :placeholder="$t('preferences.instance_name')" />
<input
v-model="name"
class="h-8 w-full 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"
type="text"
:placeholder="$t('preferences.instance_name')"
/>
<input
v-model="url"
class="input w-full"
class="h-8 w-full 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"
type="text"
:placeholder="$t('preferences.api_url')"
@keyup.enter="addInstance"
/>
<button v-t="'actions.add'" class="btn w-min" @click.prevent="addInstance" />
<button
v-t="'actions.add'"
class="inline-block w-min 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.prevent="addInstance"
/>
</form>
</div>
</ModalComponent>

View File

@@ -1,6 +1,10 @@
<template>
<p v-text="message" />
<button v-t="'actions.show_more'" class="btn" @click="toggleTrace" />
<button
v-t="'actions.show_more'"
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="toggleTrace"
/>
<p ref="stacktrace" class="whitespace-pre-wrap" hidden v-text="error" />
</template>

View File

@@ -1,11 +1,15 @@
<template>
<ModalComponent>
<div class="min-w-max flex flex-col">
<div class="flex min-w-max flex-col">
<h2 v-t="'actions.export_history'" class="mb-4 text-center text-xl font-bold" />
<form>
<div>
<label v-t="'actions.file_format'" class="mr-2" for="export-format" />
<select id="export-format" v-model="exportAs" class="select">
<select
id="export-format"
v-model="exportAs"
class="h-8 rounded-md bg-gray-300 px-2.5 text-gray-600 dark:bg-dark-400 dark:text-gray-400"
>
<option
v-for="option in exportOptions"
:key="option"
@@ -18,7 +22,7 @@
<label v-for="field in fields" :key="field" class="flex items-center gap-2">
<input
v-model="selectedFields"
class="checkbox"
class="size-4"
type="checkbox"
:value="field"
:disabled="field === 'videoId'"
@@ -27,7 +31,11 @@
</label>
</div>
</form>
<button class="btn mt-4" @click="handleExport" v-text="$t('actions.export')" />
<button
class="mt-4 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="handleExport"
v-text="$t('actions.export')"
/>
</div>
</ModalComponent>
</template>

View File

@@ -1,7 +1,7 @@
<template>
<h1 v-t="'titles.feed'" class="my-4 text-center font-bold" />
<div class="flex flex-col flex-wrap gap-2 children:(flex items-center gap-1) md:flex-row md:items-center">
<div class="flex flex-col flex-wrap gap-2 *:flex *:items-center *:gap-1 md:flex-row md:items-center">
<span>
<label for="filters">
<strong v-text="`${$t('actions.filter')}:`" />
@@ -10,7 +10,7 @@
id="filters"
v-model="selectedFilter"
default="all"
class="select flex-grow"
class="h-8 grow bg-gray-300 text-gray-600 dark:bg-dark-400 dark:text-gray-400"
@change="onFilterChange()"
>
<option v-for="filter in availableFilters" :key="filter" v-t="`video.${filter}`" :value="filter" />
@@ -21,7 +21,12 @@
<label for="group-selector">
<strong v-text="`${$t('titles.channel_groups')}:`" />
</label>
<select id="group-selector" v-model="selectedGroupName" default="" class="select flex-grow">
<select
id="group-selector"
v-model="selectedGroupName"
default=""
class="h-8 grow bg-gray-300 text-gray-600 dark:bg-dark-400 dark:text-gray-400"
>
<option v-t="`video.all`" value="" />
<option
v-for="group in channelGroups"
@@ -39,14 +44,24 @@
<hr />
<span class="flex gap-2">
<router-link v-t="'titles.subscriptions'" class="btn" to="/subscriptions" />
<a :href="getRssUrl" class="btn">
<i class="i-fa6-solid:rss" />
<router-link
v-t="'titles.subscriptions'"
class="inline-block w-auto cursor-pointer rounded-sm bg-gray-300 py-2 text-gray-600 hover:bg-gray-500 hover:text-white max-md:px-2 md:px-4 dark:bg-dark-400 dark:text-gray-400 dark:hover:bg-dark-300"
to="/subscriptions"
/>
<a
:href="getRssUrl"
class="inline-block w-auto cursor-pointer rounded-sm bg-gray-300 py-2 text-gray-600 hover:bg-gray-500 hover:text-white max-md:px-2 md:px-4 dark:bg-dark-400 dark:text-gray-400 dark:hover:bg-dark-300"
>
<i-fa6-solid-rss />
</a>
</span>
<hr />
<LoadingIndicatorPage :show-content="videosStore != null" class="video-grid">
<LoadingIndicatorPage
:show-content="videosStore != null"
class="mx-2 grid grid-cols-1 gap-y-5 max-md:gap-x-3 sm:mx-0 sm:grid-cols-2 md:grid-cols-3 md:gap-x-6 lg:grid-cols-4 xl:grid-cols-5"
>
<template v-for="video in filteredVideos" :key="video.url">
<VideoItem v-if="shouldShowVideo(video)" :is-feed="true" :item="video" @update:watched="onUpdateWatched" />
</template>

View File

@@ -1,27 +1,41 @@
<template>
<footer class="mt-10 w-full rounded-xl py-4 text-center children:(mx-3)">
<a aria-label="GitHub" href="https://github.com/TeamPiped/Piped" target="_blank">
<i class="i-fa6-brands:github" />
<footer class="mt-10 flex w-full flex-wrap items-center justify-center gap-x-6 gap-y-4 rounded-xl py-4 text-center">
<a
aria-label="GitHub"
href="https://github.com/TeamPiped/Piped"
target="_blank"
class="inline-flex items-center justify-center"
>
<i-fa6-brands-github />
<span v-t="'actions.source_code'" class="ml-2 hover:underline" />
</a>
<a href="https://docs.piped.video/" target="_blank">
<i class="i-fa6-solid:book" />
<a href="https://docs.piped.video/" target="_blank" class="inline-flex items-center justify-center">
<i-fa6-solid-book />
<span v-t="'actions.documentation'" class="ml-2 hover:underline" />
</a>
<a href="https://github.com/TeamPiped/Piped#donations" target="_blank">
<i class="i-fa6-brands:bitcoin" />
<a
href="https://github.com/TeamPiped/Piped#donations"
target="_blank"
class="inline-flex items-center justify-center"
>
<i-fa6-brands-bitcoin />
<span v-t="'actions.donations'" class="ml-2 hover:underline" />
</a>
<a v-if="statusPageHref" :href="statusPageHref">
<i class="i-fa6-solid:server" />
<a v-if="statusPageHref" :href="statusPageHref" class="inline-flex items-center justify-center">
<i-fa6-solid-server />
<span v-t="'actions.status_page'" class="ml-2 hover:underline" />
</a>
<a v-if="donationHref" :href="donationHref">
<i class="i-fa6-solid:money-check" />
<a v-if="donationHref" :href="donationHref" class="inline-flex items-center justify-center">
<i-fa6-solid-money-check />
<span v-t="'actions.instance_donations'" class="ml-2 hover:underline" />
</a>
<a v-if="privacyPolicyHref" :href="privacyPolicyHref" target="_blank">
<i class="i-fa6-solid:eye" />
<a
v-if="privacyPolicyHref"
:href="privacyPolicyHref"
target="_blank"
class="inline-flex items-center justify-center"
>
<i-fa6-solid-eye />
<span v-t="'actions.instance_privacy_policy'" class="ml-2 hover:underline" />
</a>
</footer>
@@ -45,10 +59,14 @@ onMounted(() => {
</script>
<style>
footer {
@apply bg-light-900;
}
.dark footer {
@apply bg-dark-800;
@reference "../app.css";
@layer components {
footer {
background-color: #cacaca;
}
.dark footer {
background-color: var(--color-dark-800);
}
}
</style>

View File

@@ -3,10 +3,22 @@
<div class="flex justify-between">
<div class="flex flex-col gap-2 md:flex-row md:items-center">
<button v-t="'actions.clear_history'" class="btn" @click="clearHistory" />
<button
v-t="'actions.clear_history'"
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="clearHistory"
/>
<button v-t="'actions.export_history'" class="btn" @click="showExportModal = !showExportModal" />
<button v-t="'actions.import_history'" class="btn" @click="showImportModal = !showImportModal" />
<button
v-t="'actions.export_history'"
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="showExportModal = !showExportModal"
/>
<button
v-t="'actions.import_history'"
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="showImportModal = !showImportModal"
/>
</div>
<div class="flex items-center gap-1">
@@ -16,7 +28,11 @@
<div class="ml-4 flex items-center">
<input id="autoDelete" v-model="autoDeleteHistory" type="checkbox" @change="onChange" />
<label v-t="'actions.delete_automatically'" class="ml-2" for="autoDelete" />
<select v-model="autoDeleteDelayHours" class="select ml-3 pl-3" @change="onChange">
<select
v-model="autoDeleteDelayHours"
class="ml-3 h-8 rounded-md bg-gray-300 pl-3 text-gray-600 dark:bg-dark-400 dark:text-gray-400"
@change="onChange"
>
<option v-t="{ path: 'info.hours', args: { amount: '1' } }" value="1" />
<option v-t="{ path: 'info.hours', args: { amount: '3' } }" value="3" />
<option v-t="{ path: 'info.hours', args: { amount: '6' } }" value="6" />
@@ -33,7 +49,9 @@
<hr />
<div class="video-grid">
<div
class="mx-2 grid grid-cols-1 gap-y-5 max-md:gap-x-3 sm:mx-0 sm:grid-cols-2 md:grid-cols-3 md:gap-x-6 lg:grid-cols-4 xl:grid-cols-5"
>
<VideoItem v-for="video in videos" :key="video.url" :item="video" />
</div>

View File

@@ -5,7 +5,12 @@
<form>
<br />
<div>
<input ref="fileSelector" class="btn mb-2 ml-2" type="file" @change="fileChange" />
<input
ref="fileSelector"
class="mb-2 ml-2 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"
type="file"
@change="fileChange"
/>
</div>
<div>
<strong
@@ -14,7 +19,7 @@
</div>
<div>
<strong class="flex items-center justify-center gap-2">
<span v-t="'actions.override'" />: <input v-model="override" class="checkbox" type="checkbox" />
<span v-t="'actions.override'" />: <input v-model="override" class="size-4" type="checkbox" />
</strong>
</div>
<br />
@@ -22,13 +27,19 @@
<progress :value="index" :max="itemsLength" />
<div
v-text="
`${$t('info.success')}: ${success} ${$t('info.error')}: ${error} ${$t('info.skipped')}: ${skipped}`
`${$t('info.success')}: ${success} ${$t('info.error')}: ${error} ${$t(
'info.skipped',
)}: ${skipped}`
"
/>
</div>
<br />
<div>
<a class="btn w-auto" @click="handleImport" v-text="$t('actions.import')" />
<a
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="handleImport"
v-text="$t('actions.import')"
/>
</div>
</form>
</div>

View File

@@ -9,11 +9,15 @@
</div>
<div>
<strong
><span v-t="'actions.override'" />: <input v-model="override" class="checkbox" type="checkbox"
><span v-t="'actions.override'" />: <input v-model="override" class="size-4" type="checkbox"
/></strong>
</div>
<div>
<a v-t="'actions.import'" class="btn w-auto" @click="handleImport" />
<a
v-t="'actions.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"
@click="handleImport"
/>
</div>
</form>
<br />

View File

@@ -1,13 +1,17 @@
<template>
<div v-if="!showContent" class="min-h-[75vh] w-full flex items-center justify-center">
<div v-if="!showContent" class="grid min-h-[75vh] w-full place-items-center">
<span id="spinner" />
</div>
<div v-else>
<div v-else v-bind="$attrs">
<slot />
</div>
</template>
<script setup>
defineOptions({
inheritAttrs: false,
});
defineProps({
showContent: {
type: Boolean,
@@ -29,6 +33,7 @@ defineProps({
display: inline-block;
width: 70px;
height: 70px;
margin-inline: auto;
}
#spinner:after {
content: " ";

View File

@@ -1,15 +1,15 @@
<template>
<div class="flex justify-center">
<h1 v-t="'titles.login'" class="my-4 text-center font-bold" />
<i class="i-fa6-solid:circle-info ml-2 mt-6 cursor-pointer" :title="$t('info.login_note')" />
<i-fa6-solid-circle-info class="mt-6 ml-2 cursor-pointer" :title="$t('info.login_note')" />
</div>
<hr />
<div class="w-full flex items-center justify-center text-center">
<form class="w-min children:pb-3">
<div class="flex w-full items-center justify-center text-center">
<form class="w-min *:pb-3">
<div>
<input
v-model="username"
class="input"
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"
type="text"
autocomplete="username"
:placeholder="$t('login.username')"
@@ -20,7 +20,7 @@
<div>
<input
v-model="password"
class="input"
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"
type="password"
autocomplete="password"
:placeholder="$t('login.password')"
@@ -29,7 +29,11 @@
/>
</div>
<div>
<a v-t="'titles.login'" class="btn w-auto" @click="login" />
<a
v-t="'titles.login'"
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="login"
/>
</div>
</form>
</div>

View File

@@ -1,8 +1,8 @@
<template>
<div class="modal">
<div @click="handleClick">
<div class="modal-container">
<button @click="$emit('close')"><i class="i-fa6-solid:xmark" /></button>
<div class="fixed top-0 left-0 z-50 table size-full bg-gray-500/80 transition-opacity dark:bg-dark-900/80">
<div class="table-cell align-middle" @click="handleClick">
<div class="relative m-auto w-min min-w-[20vw] rounded-xl bg-white p-5 dark:bg-dark-700">
<button class="absolute top-1 right-2.5" @click="$emit('close')"><i-fa6-solid-xmark /></button>
<slot></slot>
</div>
</div>
@@ -34,27 +34,3 @@ onUnmounted(() => {
window.removeEventListener("keydown", handleKeyDown);
});
</script>
<style scoped>
.modal {
@apply fixed z-50 top-0 left-0 w-full h-full bg-gray bg-opacity-80 transition-opacity table;
}
.dark .modal {
@apply bg-dark-900 bg-opacity-80;
}
.modal > div {
@apply table-cell align-middle;
}
.modal-container {
@apply w-min m-auto bg-white p-5 rounded-xl min-w-[20vw] relative;
}
.dark .modal-container {
@apply bg-dark-700;
}
.modal-container > button {
@apply absolute right-2.5 top-1;
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<nav class="relative w-full flex flex-wrap items-center justify-center px-2 pb-2.5 sm:px-4">
<nav class="relative flex w-full flex-wrap items-center justify-center px-2 pb-2.5 sm:px-4">
<div class="flex flex-1 justify-start">
<router-link class="flex items-center text-3xl font-bold font-sans" :to="homePagePath"
<router-link class="flex items-center font-sans text-3xl font-bold" :to="homePagePath"
><img
alt="logo"
src="/img/icons/logo.svg"
@@ -11,11 +11,11 @@
/>iped</router-link
>
</div>
<div class="search-container lt-md:hidden">
<div class="relative inline-flex items-center max-md:hidden">
<input
ref="videoSearch"
v-model="searchText"
class="input h-10 w-72 pr-20"
class="h-10 w-72 rounded-md bg-gray-300 px-2.5 pr-20 text-gray-600 focus:shadow-red-400 focus:outline-2 focus:outline-red-500 dark:bg-dark-400 dark:text-gray-400"
type="text"
role="search"
:title="$t('actions.search')"
@@ -25,81 +25,106 @@
@focus="onInputFocus"
@blur="onInputBlur"
/>
<span v-if="searchText" class="delete-search" @click="searchText = ''"></span>
<span
v-if="searchText"
class="absolute right-3 size-4 cursor-pointer rounded-full bg-[#ccc] text-center text-[10px] text-black opacity-50 hover:opacity-70"
@click="searchText = ''"
></span
>
</div>
<button id="search-btn" class="input btn mx-1 h-10" @click="onSearchClick">
<div class="i-fa6-solid:magnifying-glass"></div>
<button
id="search-btn"
class="mx-1 inline-block h-10 w-auto cursor-pointer rounded-sm bg-gray-300 px-2.5 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="onSearchClick"
>
<i-fa6-solid-magnifying-glass />
</button>
<!-- three vertical lines for toggling the hamburger menu on mobile -->
<button class="mr-3 flex flex-col justify-end md:hidden" @click="showTopNav = !showTopNav">
<span class="line"></span>
<span class="line"></span>
<span class="line"></span>
<span class="my-[0.1125rem] rounded-xl bg-dark-900 px-2.5 py-px dark:bg-white"></span>
<span class="my-[0.1125rem] rounded-xl bg-dark-900 px-2.5 py-px dark:bg-white"></span>
<span class="my-[0.1125rem] rounded-xl bg-dark-900 px-2.5 py-px dark:bg-white"></span>
</button>
<!-- navigation bar for large screen devices -->
<ul class="md:text-1xl hidden list-none md:(flex flex flex-1 justify-end children:pl-3)">
<ul class="hidden list-none *:pl-3 md:flex md:flex-1 md:justify-end">
<li v-if="shouldShowTrending">
<router-link v-t="'titles.trending'" to="/trending" class="nav-link" />
<router-link
v-t="'titles.trending'"
to="/trending"
class="hover:text-red-500 dark:hover:text-red-400"
/>
</li>
<li>
<router-link v-t="'titles.preferences'" to="/preferences" class="nav-link" />
<router-link
v-t="'titles.preferences'"
to="/preferences"
class="hover:text-red-500 dark:hover:text-red-400"
/>
</li>
<li v-if="shouldShowLogin">
<router-link v-t="'titles.login'" to="/login" class="nav-link" />
<router-link v-t="'titles.login'" to="/login" class="hover:text-red-500 dark:hover:text-red-400" />
</li>
<li v-if="shouldShowRegister">
<router-link v-t="'titles.register'" to="/register" class="nav-link" />
<router-link
v-t="'titles.register'"
to="/register"
class="hover:text-red-500 dark:hover:text-red-400"
/>
</li>
<li v-if="shouldShowHistory">
<router-link v-t="'titles.history'" to="/history" class="nav-link" />
<router-link v-t="'titles.history'" to="/history" class="hover:text-red-500 dark:hover:text-red-400" />
</li>
<li>
<router-link v-t="'titles.playlists'" to="/playlists" class="nav-link" />
<router-link
v-t="'titles.playlists'"
to="/playlists"
class="hover:text-red-500 dark:hover:text-red-400"
/>
</li>
<li v-if="!shouldShowTrending">
<router-link v-t="'titles.feed'" to="/feed" class="nav-link" />
<router-link v-t="'titles.feed'" to="/feed" class="hover:text-red-500 dark:hover:text-red-400" />
</li>
</ul>
</nav>
<!-- navigation bar for mobile devices -->
<div
v-if="showTopNav"
class="mobile-nav mb-4 flex flex-col children:(w-full flex items-center gap-1 border-b border-dark-100 p-1)"
class="mb-4 flex flex-col *:flex *:w-full *:items-center *:gap-1 *:border-b *:border-dark-100 *:p-1"
>
<router-link v-if="shouldShowTrending" to="/trending">
<div class="i-fa6-solid:fire"></div>
<i-fa6-solid-fire />
<i18n-t keypath="titles.trending"></i18n-t>
</router-link>
<router-link to="/preferences">
<div class="i-fa6-solid:gear"></div>
<i-fa6-solid-gear />
<i18n-t keypath="titles.preferences"></i18n-t>
</router-link>
<router-link v-if="shouldShowLogin" to="/login">
<div class="i-fa6-solid:user"></div>
<i-fa6-solid-user />
<i18n-t keypath="titles.login"></i18n-t>
</router-link>
<router-link v-if="shouldShowLogin" to="/register">
<div class="i-fa6-solid:user-plus"></div>
<i-fa6-solid-user-plus />
<i18n-t keypath="titles.register"></i18n-t>
</router-link>
<router-link v-if="shouldShowHistory" to="/history">
<div class="i-fa6-solid:clock-rotate-left"></div>
<i-fa6-solid-clock-rotate-left />
<i18n-t keypath="titles.history"></i18n-t>
</router-link>
<router-link to="/playlists">
<div class="i-fa6-solid:list"></div>
<i-fa6-solid-list />
<i18n-t keypath="titles.playlists"></i18n-t>
</router-link>
<router-link v-if="!shouldShowTrending" to="/feed">
<div class="i-fa6-solid:play"></div>
<i-fa6-solid-play />
<i18n-t keypath="titles.feed"></i18n-t>
</router-link>
</div>
<!-- search suggestions for mobile devices -->
<div class="search-container mb-2 w-full md:hidden">
<div class="relative mb-2 inline-flex w-full items-center md:hidden">
<input
v-model="searchText"
class="input h-10 w-full"
class="h-10 w-full 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"
type="text"
role="search"
:title="$t('actions.search')"
@@ -109,7 +134,12 @@
@focus="onInputFocus"
@blur="onInputBlur"
/>
<span v-if="searchText" class="delete-search" @click="searchText = ''"></span>
<span
v-if="searchText"
class="absolute right-3 size-4 cursor-pointer rounded-full bg-[#ccc] text-center text-[10px] text-black opacity-50 hover:opacity-70"
@click="searchText = ''"
></span
>
</div>
<SearchSuggestions
v-show="(searchText || showSearchHistory) && suggestionsVisible"
@@ -237,32 +267,6 @@ onMounted(() => {
</script>
<style>
.search-container {
@apply relative inline-flex items-center;
}
.delete-search {
@apply absolute right-3 cursor-pointer rounded-full bg-[#ccc] w-4 h-4 text-center text-black opacity-50 hover:(opacity-70) text-size-[10px];
}
.mobile-nav div {
@apply mx-1;
}
#search-btn:hover {
@apply bg-red-400;
}
.dark #search-btn:hover {
@apply bg-dark-100;
}
.nav-link {
@apply hover:text-red-500;
}
.dark .nav-link {
@apply hover:text-red-400;
}
@media screen and (max-width: 848px) {
#search-btn {
display: none;

View File

@@ -3,9 +3,13 @@ const homeUrl = import.meta.env.BASE_URL;
</script>
<template>
<div class="min-h-[88vh] flex flex-col items-center justify-center">
<h1 class="font-bold !text-9xl">404</h1>
<h2 v-t="'info.page_not_found'" class="!text-2xl" />
<a v-t="'actions.back_to_home'" class="btn mt-16" :href="homeUrl" />
<div class="flex min-h-[88vh] flex-col items-center justify-center">
<h1 class="text-9xl! font-bold">404</h1>
<h2 v-t="'info.page_not_found'" class="text-2xl!" />
<a
v-t="'actions.back_to_home'"
class="mt-16 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"
:href="homeUrl"
/>
</div>
</template>

View File

@@ -1,20 +1,23 @@
<template>
<ModalComponent @close="$emit('close')">
<span v-t="'actions.select_playlist'" class="inline-block w-max text-2xl" />
<select v-model="selectedPlaylist" class="select mt-3 w-full">
<select
v-model="selectedPlaylist"
class="mt-3 h-8 w-full rounded-md bg-gray-300 px-2.5 text-gray-600 dark:bg-dark-400 dark:text-gray-400"
>
<option v-for="playlist in playlists" :key="playlist.id" :value="playlist.id" v-text="playlist.name" />
</select>
<div class="mt-3 w-full flex justify-between">
<div class="mt-3 flex w-full justify-between">
<button
ref="addButton"
v-t="'actions.create_playlist'"
class="btn"
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="showCreatePlaylistModal = true"
/>
<button
ref="addButton"
v-t="'actions.add_to_playlist'"
class="btn"
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="handleClick(selectedPlaylist)"
/>
</div>

View File

@@ -1,27 +1,46 @@
<template>
<div class="flex flex-col flex-justify-between">
<router-link :to="props.item.url" class="link inline-block">
<div class="flex flex-col justify-between">
<router-link
:to="props.item.url"
class="inline-block hover:text-red-500 focus:text-red-500 dark:hover:text-red-400 dark:focus:text-red-400"
>
<div class="relative">
<img loading="lazy" class="w-full" :src="props.item.thumbnail" />
</div>
<p class="link pt-2 font-bold" :title="props.item.name" v-text="props.item.name" />
<p
class="line-clamp-2 pt-2 leading-tight font-bold hover:text-red-500 focus:text-red-500 dark:hover:text-red-400 dark:focus:text-red-400"
:title="props.item.name"
v-text="props.item.name"
/>
</router-link>
<p v-if="props.item.description" v-text="props.item.description" />
<p
v-if="props.item.description"
class="mt-1 line-clamp-2 text-sm text-gray-600 dark:text-gray-400"
v-text="props.item.description"
/>
<router-link v-if="props.item.uploaderUrl" class="link-secondary text-sm" :to="props.item.uploaderUrl">
<p>
<router-link
v-if="props.item.uploaderUrl"
class="mt-1 line-clamp-1 block text-sm underline decoration-dark-400 hover:text-dark-400 focus:text-dark-400 dark:text-gray-300 dark:decoration-dark-100 dark:hover:text-gray-400"
:to="props.item.uploaderUrl"
>
<p class="leading-tight">
<span v-text="props.item.uploaderName" />
<i v-if="props.item.uploaderVerified" class="i-fa6-solid:check ml-1.5" />
<i-fa6-solid-check v-if="props.item.uploaderVerified" class="ml-1.5" />
</p>
</router-link>
<a v-else-if="props.item.uploaderName" class="link" v-text="props.item.uploaderName" />
<a
v-else-if="props.item.uploaderName"
class="mt-1 line-clamp-1 block text-sm hover:text-red-500 focus:text-red-500 dark:text-gray-300 dark:hover:text-red-400 dark:focus:text-red-400"
v-text="props.item.uploaderName"
/>
<template v-if="props.item.videos >= 0">
<br v-if="props.item.uploaderName" />
<span class="text-sm" v-text="`${props.item.videos} ${$t('video.videos')}`" />
<span
class="mt-1 text-sm font-medium text-gray-700 dark:text-gray-300"
v-text="`${props.item.videos} ${$t('video.videos')}`"
/>
</template>
<br />
</div>
</template>

View File

@@ -2,13 +2,16 @@
<ErrorHandler v-if="playlist && playlist.error" :message="playlist.message" :error="playlist.error" />
<LoadingIndicatorPage v-show="!playlist?.error" :show-content="playlist != null">
<h1 class="mb-1 ml-1 mt-4 text-3xl!" v-text="playlist.name" />
<h1 class="mt-4 mb-1 ml-1 text-3xl!" v-text="playlist.name" />
<CollapsableText v-if="playlist?.description" :text="playlist.description" />
<div class="mt-1 flex justify-between <md:flex-col md:items-center">
<div class="mt-1 flex justify-between max-md:flex-col md:items-center">
<div>
<router-link class="link flex items-center gap-3" :to="playlist.uploaderUrl || '/'">
<router-link
class="flex items-center gap-3 hover:text-red-500 focus:text-red-500 dark:hover:text-red-400 dark:focus:text-red-400"
:to="playlist.uploaderUrl || '/'"
>
<img loading="lazy" :src="playlist.uploaderAvatar" class="h-12 rounded-full" />
<strong v-text="playlist.uploader" />
</router-link>
@@ -21,18 +24,32 @@
}`
"
/>
<button v-if="!isPipedPlaylist" class="btn mx-1" @click="bookmarkPlaylist">
<button
v-if="!isPipedPlaylist"
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="bookmarkPlaylist"
>
{{ $t(`actions.${isBookmarked ? "playlist_bookmarked" : "bookmark_playlist"}`)
}}<i class="i-fa6-solid:bookmark ml-3" />
}}<i-fa6-solid-bookmark class="ml-3" />
</button>
<button v-if="authenticated && !isPipedPlaylist" class="btn mr-1" @click="clonePlaylist">
{{ $t("actions.clone_playlist") }}<i class="i-fa6-solid:clone ml-3" />
<button
v-if="authenticated && !isPipedPlaylist"
class="mr-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="clonePlaylist"
>
{{ $t("actions.clone_playlist") }}<i-fa6-solid-clone class="ml-3" />
</button>
<button class="btn mr-1" @click="downloadPlaylistAsTxt">
<button
class="mr-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="downloadPlaylistAsTxt"
>
{{ $t("actions.download_as_txt") }}
</button>
<a class="btn mr-1" :href="getRssUrl">
<i class="i-fa6-solid:rss" />
<a
class="mr-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"
:href="getRssUrl"
>
<i-fa6-solid-rss />
</a>
<WatchOnButton :link="`https://www.youtube.com/playlist?list=${$route.query.list}`" />
</div>
@@ -40,7 +57,9 @@
<hr />
<div class="video-grid">
<div
class="mx-2 grid grid-cols-1 gap-y-5 max-md:gap-x-3 sm:mx-0 sm:grid-cols-2 md:grid-cols-3 md:gap-x-6 lg:grid-cols-4 xl:grid-cols-5"
>
<VideoItem
v-for="(video, index) in playlist.relatedStreams"
:key="video.url"

View File

@@ -1,11 +1,15 @@
<template>
<div>
<router-link :to="{ path: '/playlist', query: { list: playlistId } }"
><h1 class="font-bold !text-lg hover:underline" v-text="playlist.name"
><h1 class="text-lg! font-bold hover:underline" v-text="playlist.name"
/></router-link>
<span class="text-sm">
<template v-if="playlist.uploader">
<router-link class="link-secondary" :to="playlist.uploaderUrl" :title="playlist.uploader">
<router-link
class="underline decoration-dark-400 hover:text-dark-400 focus:text-dark-400 dark:text-gray-300 dark:decoration-dark-100 dark:hover:text-gray-400"
:to="playlist.uploaderUrl"
:title="playlist.uploader"
>
{{ playlist.uploader }}
</router-link>
-
@@ -13,7 +17,7 @@
{{ selectedIndex }} / {{ playlist.videos }}
</span>
</div>
<div ref="scrollable" class="mt-4 max-h-screen-sm overflow-y-auto">
<div ref="scrollable" class="mt-4 max-h-160 overflow-y-auto">
<div
v-for="(related, index) in playlist.relatedStreams"
:key="related.url"
@@ -22,8 +26,8 @@
:prefer-listen="preferListen"
>
<router-link
class="flex rounded px-2 py-1 hover:bg-gray-50 .dark:hover:bg-neutral-800"
:class="{ 'bg-gray-200 .dark:bg-neutral-700': index === selectedIndex - 1 }"
class="flex rounded-sm px-2 py-1 hover:bg-gray-50 dark:hover:bg-neutral-800"
:class="{ 'bg-gray-200 dark:bg-neutral-700': index === selectedIndex - 1 }"
:to="{
path: '/watch',
query: {
@@ -40,16 +44,20 @@
</div>
<div class="ml-2 flex flex-col">
<span class="link" :title="related.title" v-text="related.title" />
<span
class="hover:text-red-500 focus:text-red-500 dark:hover:text-red-400 dark:focus:text-red-400"
:title="related.title"
v-text="related.title"
/>
<div class="flex-1">
<router-link
v-if="related.uploaderUrl && related.uploaderName && !hideChannel"
class="link-secondary block overflow-hidden text-xs"
class="block overflow-hidden text-xs underline decoration-dark-400 hover:text-dark-400 focus:text-dark-400 dark:text-gray-300 dark:decoration-dark-100 dark:hover:text-gray-400"
:to="related.uploaderUrl"
:title="related.uploaderName"
>
<span v-text="related.uploaderName" />
<i v-if="related.uploaderVerified" class="i-fa6-solid:check ml-1.5" />
<i-fa6-solid-check v-if="related.uploaderVerified" class="ml-1.5" />
</router-link>
</div>
</div>

View File

@@ -2,9 +2,18 @@
<h2 v-t="'titles.playlists'" class="my-4 font-bold" />
<div class="mb-3 flex justify-between">
<button v-t="'actions.create_playlist'" class="btn" @click="showCreatePlaylistModal = true" />
<button
v-t="'actions.create_playlist'"
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="showCreatePlaylistModal = true"
/>
<div class="flex">
<button v-if="playlists.length > 0" v-t="'actions.export_to_json'" class="btn" @click="exportPlaylists" />
<button
v-if="playlists.length > 0"
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="exportPlaylists"
/>
<input
id="fileSelector"
ref="fileSelector"
@@ -13,45 +22,63 @@
multiple="multiple"
@change="importPlaylists"
/>
<label v-t="'actions.import_from_json_csv'" for="fileSelector" class="btn ml-2" />
<label
v-t="'actions.import_from_json_csv'"
for="fileSelector"
class="ml-2 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"
/>
</div>
</div>
<div class="video-grid">
<div
class="mx-2 grid grid-cols-1 gap-y-5 max-md:gap-x-3 sm:mx-0 sm:grid-cols-2 md:grid-cols-3 md:gap-x-6 lg:grid-cols-4 xl:grid-cols-5"
>
<div v-for="playlist in playlists" :key="playlist.id">
<router-link :to="`/playlist?list=${playlist.id}`">
<img class="w-full" :src="playlist.thumbnail" alt="thumbnail" />
<div class="relative text-sm">
<span
class="thumbnail-overlay thumbnail-right"
class="absolute right-1.25 bottom-1.25 rounded-md bg-black/75 px-1.25 text-white"
v-text="`${playlist.videos} ${$t('video.videos')}`"
/>
</div>
<p
style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical"
class="link my-2 flex overflow-hidden"
class="my-2 flex overflow-hidden hover:text-red-500 focus:text-red-500 dark:hover:text-red-400 dark:focus:text-red-400"
:title="playlist.name"
v-text="playlist.name"
/>
</router-link>
<button v-t="'actions.edit_playlist'" class="btn h-auto" @click="showPlaylistEditModal(playlist)" />
<button v-t="'actions.delete_playlist'" class="btn ml-2 h-auto" @click="playlistToDelete = playlist.id" />
<button
v-t="'actions.edit_playlist'"
class="inline-block size-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="showPlaylistEditModal(playlist)"
/>
<button
v-t="'actions.delete_playlist'"
class="ml-2 inline-block size-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="playlistToDelete = playlist.id"
/>
<ModalComponent v-if="playlist.id == playlistToEdit" @close="playlistToEdit = null">
<div class="flex flex-col gap-2">
<h2 v-t="'actions.edit_playlist'" />
<input
v-model="newPlaylistName"
class="input"
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"
type="text"
:placeholder="$t('actions.playlist_name')"
/>
<input
v-model="newPlaylistDescription"
class="input"
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"
type="text"
:placeholder="$t('actions.playlist_description')"
/>
<button v-t="'actions.okay'" class="btn ml-auto" @click="editPlaylist(playlist)" />
<button
v-t="'actions.okay'"
class="ml-auto 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="editPlaylist(playlist)"
/>
</div>
</ModalComponent>
<ConfirmModal
@@ -66,7 +93,10 @@
<h2 v-t="'titles.bookmarks'" class="my-4 font-bold" />
<div v-if="bookmarks" class="video-grid">
<div
v-if="bookmarks"
class="mx-2 grid grid-cols-1 gap-y-5 max-md:gap-x-3 sm:mx-0 sm:grid-cols-2 md:grid-cols-3 md:gap-x-6 lg:grid-cols-4 xl:grid-cols-5"
>
<router-link
v-for="(playlist, index) in bookmarks"
:key="playlist.playlistId"
@@ -74,19 +104,22 @@
>
<img class="w-full" :src="playlist.thumbnail" alt="thumbnail" />
<div class="relative text-sm">
<span class="thumbnail-overlay thumbnail-right" v-text="`${playlist.videos} ${$t('video.videos')}`" />
<div class="absolute bottom-100px right-5px z-100 px-5px" @click.prevent="removeBookmark(index)">
<i class="i-fa6-solid:bookmark ml-3" />
<span
class="absolute right-1.25 bottom-1.25 rounded-md bg-black/75 px-1.25 text-white"
v-text="`${playlist.videos} ${$t('video.videos')}`"
/>
<div class="absolute right-1.25 bottom-25 z-100 px-1.25" @click.prevent="removeBookmark(index)">
<i-fa6-solid-bookmark class="ml-3" />
</div>
</div>
<p
style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical"
class="link my-2 flex overflow-hidden"
class="my-2 flex overflow-hidden hover:text-red-500 focus:text-red-500 dark:hover:text-red-400 dark:focus:text-red-400"
:title="playlist.name"
v-text="playlist.name"
/>
<a :href="playlist.uploaderUrl" class="flex items-center">
<img class="h-32px w-32px rounded-full" :src="playlist.uploaderAvatar" />
<img class="size-8 rounded-full" :src="playlist.uploaderAvatar" />
<span class="ml-3 hover:underline" v-text="playlist.uploader" />
</a>
</router-link>
@@ -95,7 +128,7 @@
<CreatePlaylistModal
v-if="showCreatePlaylistModal"
@close="showCreatePlaylistModal = false"
@created="fetchPlaylists"
@created="fetchPlaylistsList"
/>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,15 @@
<template>
<div class="flex justify-center">
<h1 v-t="'titles.register'" class="my-4 text-center font-bold" />
<i class="i-fa6-solid:circle-info ml-2 mt-6 cursor-pointer" :title="$t('info.register_note')" />
<i-fa6-solid-circle-info class="mt-6 ml-2 cursor-pointer" :title="$t('info.register_note')" />
</div>
<hr />
<div class="flex flex-col items-center justify-center text-center">
<form class="w-max items-center px-3 children:pb-3">
<form class="w-max items-center px-3 *:pb-3">
<div>
<input
v-model="username"
class="input w-full"
class="h-8 w-full 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"
type="text"
autocomplete="username"
:placeholder="$t('login.username')"
@@ -20,33 +20,45 @@
<div class="flex justify-center">
<input
v-model="password"
class="input h-auto w-full"
class="h-auto w-full 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"
:type="showPassword ? 'text' : 'password'"
autocomplete="password"
:placeholder="$t('login.password')"
:aria-label="$t('login.password')"
@keyup.enter="register"
/>
<button type="button" class="btn ml-2" @click="showPassword = !showPassword">
<div class="i-fa6-solid:eye" />
<button
type="button"
class="ml-2 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="showPassword = !showPassword"
>
<i-fa6-solid-eye />
</button>
</div>
<div class="flex justify-center">
<input
v-model="passwordConfirm"
class="input h-auto w-full"
class="h-auto w-full 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"
:type="showConfirmPassword ? 'text' : 'password'"
autocomplete="password"
:placeholder="$t('login.password_confirm')"
:aria-label="$t('login.password_confirm')"
@keyup.enter="register"
/>
<button type="button" class="btn ml-2" @click="showConfirmPassword = !showConfirmPassword">
<div class="i-fa6-solid:eye" />
<button
type="button"
class="ml-2 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="showConfirmPassword = !showConfirmPassword"
>
<i-fa6-solid-eye />
</button>
</div>
<div>
<a v-t="'titles.register'" class="btn w-auto" @click="register" />
<a
v-t="'titles.register'"
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="register"
/>
</div>
</form>
</div>

View File

@@ -4,7 +4,13 @@
<label for="ddlSearchFilters">
<strong v-text="`${$t('actions.filter')}:`" />
</label>
<select id="ddlSearchFilters" v-model="selectedFilter" default="all" class="select w-auto" @change="updateFilter()">
<select
id="ddlSearchFilters"
v-model="selectedFilter"
default="all"
class="h-8 w-auto rounded-md bg-gray-300 px-2.5 text-gray-600 dark:bg-dark-400 dark:text-gray-400"
@change="updateFilter()"
>
<option v-for="filter in availableFilters" :key="filter" v-t="`search.${filter}`" :value="filter" />
</select>
@@ -18,7 +24,10 @@
</i18n-t>
</div>
<LoadingIndicatorPage :show-content="Boolean(results != null && results.items?.length)" class="video-grid">
<LoadingIndicatorPage
:show-content="Boolean(results != null && results.items?.length)"
class="mx-2 grid grid-cols-1 gap-y-5 max-md:gap-x-3 sm:mx-0 sm:grid-cols-2 md:grid-cols-3 md:gap-x-6 lg:grid-cols-4 xl:grid-cols-5"
>
<template v-for="result in results.items" :key="result.url">
<ContentItem :item="result" height="94" width="168" />
</template>

View File

@@ -1,5 +1,7 @@
<template>
<div class="suggestions-container absolute">
<div
class="absolute left-1/2 z-10 box-border w-full max-w-3xl -translate-x-1/2 transform-gpu bg-gray-300 max-md:max-w-[calc(100%-0.5rem)] dark:bg-dark-400"
>
<ul>
<li
v-for="(suggestion, i) in searchSuggestions"
@@ -8,8 +10,8 @@
@click="setSelected(i)"
>
<router-link
class="suggestion"
:class="{ 'suggestion-selected': selected === i }"
class="block w-full p-1"
:class="{ 'bg-gray-200 dark:bg-dark-100': selected === i }"
:to="`/results?search_query=${encodeURIComponent(suggestion)}`"
>{{ suggestion }}</router-link
>
@@ -84,25 +86,3 @@ function setSelected(val) {
defineExpose({ onKeyUp, refreshSuggestions });
</script>
<style>
.suggestions-container {
@apply left-1/2 translate-x-[-50%] transform-gpu max-w-3xl w-full box-border z-10 lt-md:max-w-[calc(100%-0.5rem)] bg-gray-300;
}
.dark .suggestions-container {
@apply bg-dark-400;
}
.suggestion-selected {
@apply bg-gray-200;
}
.dark .suggestion-selected {
@apply bg-dark-100;
}
.suggestion {
@apply block w-full p-1;
}
</style>

View File

@@ -16,7 +16,12 @@
</div>
<div v-if="withTimeCode" class="mt-2 flex items-center justify-between">
<label v-t="'actions.time_code'" />
<input v-model="timeStamp" class="input w-12" type="text" @change="onChange" />
<input
v-model="timeStamp"
class="h-8 w-12 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"
type="text"
@change="onChange"
/>
</div>
<hr />
<a :href="generatedLink" target="_blank">
@@ -24,9 +29,21 @@
</a>
<QrCode v-if="showQrCode" :text="generatedLink" />
<div class="mt-4 flex justify-end">
<button v-t="'actions.generate_qrcode'" class="btn" @click="showQrCode = !showQrCode" />
<button v-t="'actions.follow_link'" class="btn ml-3" @click="followLink()" />
<button v-t="'actions.copy_link'" class="btn ml-3" @click="copyLink()" />
<button
v-t="'actions.generate_qrcode'"
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="showQrCode = !showQrCode"
/>
<button
v-t="'actions.follow_link'"
class="ml-3 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="followLink()"
/>
<button
v-t="'actions.copy_link'"
class="ml-3 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="copyLink()"
/>
</div>
</ModalComponent>
</template>

View File

@@ -1,6 +1,10 @@
<template>
<label v-t="'actions.sort_by'" for="ddlSortBy" />
<select id="ddlSortBy" v-model="selectedSort" class="select flex-grow">
<select
id="ddlSortBy"
v-model="selectedSort"
class="h-8 grow rounded-md bg-gray-300 px-2.5 text-gray-600 dark:bg-dark-400 dark:text-gray-400"
>
<option v-for="(value, key) in options" :key="key" v-t="`actions.${key}`" :value="value" />
</select>
</template>

View File

@@ -4,18 +4,24 @@
<div class="flex flex-wrap justify-between">
<div class="flex gap-1">
<!-- import json/csv -->
<button class="btn">
<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"
>
<router-link v-t="'actions.import_from_json_csv'" to="/import" />
</button>
<!-- export to json -->
<button v-t="'actions.export_to_json'" class="btn" @click="exportHandler" />
<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="btn"
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
@@ -30,30 +36,30 @@
<!-- export channel groups to json -->
<button
class="btn"
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 self-center gap-1">
<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="w-full flex flex-wrap">
<div class="flex w-full flex-wrap">
<button
v-for="group in channelGroups"
:key="group.groupName"
class="btn mx-1 w-max"
:class="{ selected: selectedGroup === group }"
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 class="i-fa6-solid:pen mx-2" @click="showEditGroupModal = true" />
<i class="i-fa6-solid:circle-minus mx-2" @click="groupToDelete = group.groupName" />
<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
@@ -62,28 +68,31 @@
@close="groupToDelete = null"
@confirm="deleteGroup(groupToDelete)"
/>
<button class="btn mx-1" @click="showCreateGroupModal = true">
<i class="i-fa6-solid:circle-plus" />
<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="xl:grid xl:grid-cols-5 <md:flex-wrap">
<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="col m-2 border border-gray-500 rounded-lg p-1"
class="m-2 rounded-lg border border-gray-500 p-1"
>
<router-link :to="subscription.url" class="text-4x4 flex p-2 font-bold">
<img :src="subscription.avatar" class="h-[fit-content] rounded-full" width="48" height="48" />
<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="btn mt-2 w-full"
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>
@@ -97,20 +106,29 @@
/>
<ModalComponent v-if="showEditGroupModal" @close="showEditGroupModal = false">
<div class="mb-5 mt-3 flex justify-between">
<input v-model="editedGroupName" type="text" class="input" />
<button v-t="'actions.okay'" class="btn" :placeholder="$t('actions.group_name')" @click="editGroupName()" />
<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="mb-2 mt-3 h-[80vh] flex flex-col overflow-y-scroll pr-2">
<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="h-8 w-8 rounded-full" />
<img :src="subscription.avatar" class="size-8 rounded-full" />
<span class="ml-2">{{ subscription.name }}</span>
</a>
<input
type="checkbox"
class="checkbox"
class="size-4"
:checked="selectedGroup.channels.includes(subscription.url.substr(-24))"
@change="checkedChange(subscription)"
/>
@@ -276,9 +294,3 @@ onActivated(() => {
document.title = "Subscriptions - Piped";
});
</script>
<style>
.selected {
border: 0.1rem outset red;
}
</style>

View File

@@ -1,7 +1,9 @@
<template>
<div class="toast">
<div
class="fixed top-12 right-12 z-9999 flex min-w-max flex-col justify-center rounded-sm bg-white/80 p-4 text-black shadow-sm duration-200 dark:bg-dark-900/80 dark:text-white"
>
<slot />
<button v-t="'actions.dismiss'" @click="dismiss" />
<button v-t="'actions.dismiss'" class="underline" @click="dismiss" />
</div>
</template>
@@ -12,15 +14,3 @@ function dismiss() {
emit("dismissed");
}
</script>
<style>
.toast {
@apply bg-white/80 text-black flex flex-col justify-center fixed top-12 right-12 p-4 min-w-max shadow rounded duration-200 z-9999;
}
.dark .toast {
@apply bg-dark-900/80 text-white;
}
.toast button {
@apply underline;
}
</style>

View File

@@ -3,7 +3,10 @@
<hr />
<LoadingIndicatorPage :show-content="videos.length != 0" class="video-grid">
<LoadingIndicatorPage
:show-content="videos.length != 0"
class="mx-2 grid grid-cols-1 gap-y-5 max-md:gap-x-3 sm:mx-0 sm:grid-cols-2 md:grid-cols-3 md:gap-x-6 lg:grid-cols-4 xl:grid-cols-5"
>
<VideoItem v-for="video in videos" :key="video.url" :item="video" height="118" width="210" />
</LoadingIndicatorPage>
</template>

View File

@@ -1,7 +1,7 @@
<template>
<div v-if="showVideo" class="flex flex-col flex-justify-between">
<div v-if="showVideo" class="flex flex-col justify-between">
<router-link
class="link inline-block w-full"
class="inline-block w-full hover:text-red-500 focus:text-red-500 dark:hover:text-red-400 dark:focus:text-red-400"
:to="{
path: '/watch',
query: {
@@ -17,52 +17,54 @@
<div>
<p
style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical"
class="link flex overflow-hidden pt-2 font-bold"
class="flex overflow-hidden pt-2 leading-tight font-bold hover:text-red-500 focus:text-red-500 dark:hover:text-red-400 dark:focus:text-red-400"
:title="title"
v-text="title"
/>
</div>
</router-link>
<div class="flex">
<div class="flex items-start pt-1">
<router-link :to="item.uploaderUrl">
<img
v-if="item.uploaderAvatar"
loading="lazy"
:src="item.uploaderAvatar"
class="mr-0.5 mt-0.5 h-32px w-32px rounded-full"
class="mt-0.5 mr-0.5 size-8 shrink-0 rounded-full"
width="68"
height="68"
/>
</router-link>
<div class="flex-1 px-2">
<div class="min-w-0 flex-1 px-2">
<router-link
v-if="item.uploaderUrl && item.uploaderName && !hideChannel"
class="link-secondary block overflow-hidden text-sm"
class="inline-flex max-w-full items-center gap-1 text-sm/tight underline decoration-dark-400 hover:text-dark-400 focus:text-dark-400 dark:text-gray-300 dark:decoration-dark-100 dark:hover:text-gray-400 dark:hover:underline dark:hover:decoration-gray-400"
:to="item.uploaderUrl"
:title="item.uploaderName"
>
<span v-text="item.uploaderName" />
<i v-if="item.uploaderVerified" class="i-fa6-solid:check ml-1.5" />
<span class="truncate" v-text="item.uploaderName" />
<i-fa6-solid-check v-if="item.uploaderVerified" class="shrink-0" />
</router-link>
<div v-if="item.views >= 0 || item.uploadedDate" class="video-info">
<span v-if="item.views >= 0">
<i class="i-fa6-solid:eye" />
<span class="pl-1" v-text="`${numberFormat(item.views)} •`" />
<div
v-if="item.views >= 0 || item.uploadedDate"
class="mt-1 flex flex-wrap items-center gap-x-1 text-xs font-normal text-gray-600 dark:text-gray-400"
>
<span v-if="item.views >= 0" class="inline-flex items-center gap-1">
<i-fa6-solid-eye class="shrink-0" />
<span v-text="`${numberFormat(item.views)} •`" />
</span>
<span
v-if="item.uploaded > 0"
class="pl-0.5"
:title="new Date(item.uploaded).toLocaleString()"
v-text="timeAgo(item.uploaded)"
/>
<span v-else-if="item.uploadedDate" class="pl-0.5" v-text="item.uploadedDate" />
<span v-else-if="item.uploadedDate" v-text="item.uploadedDate" />
</div>
</div>
<div class="flex items-center gap-2.5">
<div class="ml-1 flex shrink-0 items-center gap-2.5 pt-0.5">
<router-link
:to="{
path: '/watch',
@@ -76,13 +78,14 @@
:aria-label="preferListen ? title : 'Listen to ' + title"
:title="preferListen ? title : 'Listen to ' + title"
>
<i :class="preferListen ? 'i-fa6-solid:tv' : 'i-fa6-solid:headphones'" />
<i-fa6-solid-tv v-if="preferListen" />
<i-fa6-solid-headphones v-else />
</router-link>
<button :title="$t('actions.add_to_playlist')" @click="showPlaylistModal = !showPlaylistModal">
<i class="i-fa6-solid:circle-plus" />
<i-fa6-solid-circle-plus />
</button>
<button :title="$t('actions.share')" @click="showShareModal = !showShareModal">
<i class="i-fa6-solid:share" />
<i-fa6-solid-share />
</button>
<button
v-if="admin"
@@ -90,19 +93,18 @@
:title="$t('actions.remove_from_playlist')"
@click="showConfirmRemove = true"
>
<i class="i-fa6-solid:circle-minus" />
<i-fa6-solid-circle-minus />
</button>
<button
v-if="showMarkOnWatched && isFeed"
ref="watchButton"
@click="toggleWatched(item.url.substr(-11))"
>
<i
<i-fa6-solid-eye-slash
v-if="item.watched && item.currentTime > item.duration * 0.9"
:title="$t('actions.mark_as_unwatched')"
class="i-fa6-solid:eye-slash"
/>
<i v-else :title="$t('actions.mark_as_watched')" class="i-fa6-solid:eye" />
<i-fa6-solid-eye v-else :title="$t('actions.mark_as_watched')" />
</button>
<ConfirmModal
v-if="showConfirmRemove"
@@ -229,13 +231,3 @@ onMounted(() => {
shouldShowMarkOnWatched();
});
</script>
<style>
.video-info {
@apply mt-1 text-xs text-gray-600 font-normal;
}
.dark .video-info {
@apply text-gray-400;
}
</style>

View File

@@ -2,8 +2,8 @@
<div
ref="container"
data-shaka-player-container
class="relative max-h-screen w-full flex justify-center"
:class="{ 'player-container': !isEmbed }"
class="relative flex max-h-screen w-full justify-center"
:class="{ 'max-h-[75vh] min-h-64 bg-black': !isEmbed }"
>
<video
ref="videoEl"
@@ -22,26 +22,26 @@
@click="onClickSkipSegment"
>
<span v-t="'actions.skip_segment'" />
<i class="skip-segment-icon i-fa6-solid:forward-step" aria-hidden="true" />
<i-fa6-solid-forward-step class="skip-segment-icon" aria-hidden="true" />
</button>
<span
v-if="error > 0"
v-t="{ path: 'player.failed', args: [error] }"
class="absolute top-8 rounded bg-black/80 p-2 text-lg backdrop-blur-sm"
class="absolute top-8 rounded-sm bg-black/80 p-2 text-lg backdrop-blur-sm"
/>
<div
v-if="showCurrentSpeed"
class="text-l absolute left-1/2 top-1/2 flex flex-col transform items-center gap-6 rounded-8 bg-white/80 px-8 py-4 -translate-x-1/2 -translate-y-1/2 .dark:bg-dark-700/80"
class="absolute top-1/2 left-1/2 flex -translate-1/2 transform flex-col items-center gap-6 rounded-4xl bg-white/80 px-8 py-4 text-lg dark:bg-dark-700/80"
>
<i class="i-fa6-solid:gauge-high h-25 w-25 p-5" />
<i-fa6-solid-gauge-high class="size-25 p-5" />
<span v-text="videoEl.playbackRate" />
</div>
<div
v-if="showCurrentVolume"
class="text-l absolute left-1/2 top-1/2 flex flex-col transform items-center gap-6 rounded-8 bg-white/80 px-8 py-4 -translate-x-1/2 -translate-y-1/2 .dark:bg-dark-700/80"
class="absolute top-1/2 left-1/2 flex -translate-1/2 transform flex-col items-center gap-6 rounded-4xl bg-white/80 px-8 py-4 text-lg dark:bg-dark-700/80"
>
<i v-if="videoEl.volume > 0" class="i-fa6-solid:volume-high h-25 w-25 p-5" />
<i v-else class="i-fa6-solid:volume-xmark h-25 w-25 p-5" />
<i-fa6-solid-volume-high v-if="videoEl.volume > 0" class="size-25 p-5" />
<i-fa6-solid-volume-xmark v-else class="size-25 p-5" />
<span v-text="Math.round(videoEl.volume * 100) / 100" />
</div>
</div>
@@ -51,12 +51,16 @@
<div class="flex flex-col">
<input
v-model="playbackSpeedInput"
class="input my-3"
class="my-3 h-8 w-auto 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"
type="text"
:placeholder="$t('actions.playback_speed')"
@keyup.enter="setSpeedFromInput()"
/>
<button v-t="'actions.okay'" class="btn ml-auto w-min" @click="setSpeedFromInput()" />
<button
v-t="'actions.okay'"
class="ml-auto inline-block w-min cursor-pointer rounded-sm bg-gray-300 py-2 text-gray-600 hover:bg-gray-500 hover:text-white max-md:px-2 md:px-4 dark:bg-dark-400 dark:text-gray-400 dark:hover:bg-dark-300"
@click="setSpeedFromInput()"
/>
</div>
</ModalComponent>
</template>
@@ -917,86 +921,55 @@ defineExpose({
</script>
<style>
:root {
--player-base: rgba(255, 255, 255, 0.3);
--player-buffered: rgba(255, 255, 255, 0.54);
--player-played: rgba(255, 0, 0);
@layer base {
:root {
--player-base: rgba(255, 255, 255, 0.3);
--player-buffered: rgba(255, 255, 255, 0.54);
--player-played: rgba(255, 0, 0);
--spon-seg-sponsor: #00d400;
--spon-seg-selfpromo: #ffff00;
--spon-seg-interaction: #cc00ff;
--spon-seg-poi_highlight: #ff1684;
--spon-seg-intro: #00ffff;
--spon-seg-outro: #0202ed;
--spon-seg-preview: #008fd6;
--spon-seg-filler: #7300ff;
--spon-seg-music_offtopic: #ff9900;
--spon-seg-default: white;
--spon-seg-sponsor: #00d400;
--spon-seg-selfpromo: #ffff00;
--spon-seg-interaction: #cc00ff;
--spon-seg-poi_highlight: #ff1684;
--spon-seg-intro: #00ffff;
--spon-seg-outro: #0202ed;
--spon-seg-preview: #008fd6;
--spon-seg-filler: #7300ff;
--spon-seg-music_offtopic: #ff9900;
--spon-seg-default: white;
}
}
.player-container {
@apply max-h-75vh min-h-64 bg-black;
}
@layer components {
.shaka-video-container .material-icons-round {
font-size: 1.25rem !important;
line-height: 1.75rem;
}
.shaka-video-container .material-icons-round {
@apply !text-xl;
}
.shaka-video-container:-webkit-full-screen {
max-height: none !important;
}
.shaka-video-container:-webkit-full-screen {
max-height: none !important;
}
/* captions style */
.shaka-text-wrapper * {
text-align: left !important;
}
/* captions style */
.shaka-text-wrapper * {
text-align: left !important;
}
.shaka-text-wrapper > span > span {
background-color: transparent !important;
}
.shaka-text-wrapper > span > span {
background-color: transparent !important;
}
/* apply to all spans that don't include multiple other spans to avoid the style being applied to the text container too when the subtitles are two lines */
.shaka-text-wrapper > span > span *:first-child:last-child {
background-color: rgba(0, 0, 0, 0.6) !important;
padding: 0.09em 0;
}
/* apply to all spans that don't include multiple other spans to avoid the style being applied to the text container too when the subtitles are two lines */
.shaka-text-wrapper > span > span *:first-child:last-child {
background-color: rgba(0, 0, 0, 0.6) !important;
padding: 0.09em 0;
}
.skip-segment-button {
/* position button above player overlay */
z-index: 1000;
position: absolute;
transform: translate(0, -50%);
top: 50%;
right: 0;
background-color: rgb(0 0 0 / 0.5);
border: 2px rgba(255, 255, 255, 0.75) solid;
border-right: 0;
border-radius: 0.75em;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
padding: 0.5em;
/* center text vertically */
display: flex;
align-items: center;
justify-content: center;
color: #fff;
line-height: 1.5em;
}
.skip-segment-button .skip-segment-icon {
font-size: 1.6em !important;
line-height: inherit !important;
margin-left: 0.4em;
}
/* Override Tailwind preflight's `img { max-width: 100% }` which clamps
the sprite-sheet image to the container width and breaks Shaka's
transform-based thumbnail cropping. */
.shaka-player-ui-thumbnail-image {
max-width: none !important;
/* Override Tailwind preflight's `img { max-width: 100% }` which clamps
the sprite-sheet image to the container width and breaks Shaka's
transform-based thumbnail cropping. */
.shaka-player-ui-thumbnail-image {
max-width: none !important;
}
}
</style>

View File

@@ -1,11 +1,11 @@
<template>
<div class="w-full">
<div class="relative w-full">
<img
loading="lazy"
class="aspect-video w-full rounded-md object-contain"
:src="item.thumbnail"
:alt="item.title"
:class="{ 'shorts-img': item.isShort, 'opacity-75': item.watched }"
:class="{ 'w-full object-contain': item.isShort, 'opacity-75': item.watched }"
/>
<!-- progress bar -->
<div class="relative h-1 w-full">
@@ -15,20 +15,33 @@
:style="{ width: `clamp(0%, ${(item.currentTime / item.duration) * 100}%, 100%` }"
/>
</div>
</div>
<div class="relative" :class="small ? 'text-xs' : 'text-sm'">
<!-- shorts thumbnail -->
<span v-if="item.isShort" v-t="'video.shorts'" class="thumbnail-overlay thumbnail-left" />
<span
v-if="item.isShort"
v-t="'video.shorts'"
class="absolute bottom-1.25 left-1.25 rounded-md bg-red-600 px-1.25 text-xs font-bold text-white uppercase"
/>
<span
v-if="item.duration > 0 || (item.duration == 0 && item.isShort)"
class="thumbnail-overlay thumbnail-right px-0.5"
class="absolute right-1.25 bottom-1.25 rounded-md bg-black/75 px-0.5 text-white"
:class="small ? 'text-xs' : 'text-sm'"
v-text="timeFormat(item.duration)"
/>
<i18n-t v-else keypath="video.live" class="thumbnail-overlay thumbnail-right !bg-red-600" tag="div">
<i class="i-fa6-solid:tower-broadcast w-3" />
<i18n-t
v-else
keypath="video.live"
class="absolute right-1.25 bottom-1.25 inline-flex items-center gap-1 rounded-md bg-red-600 px-1.25 text-white"
:class="small ? 'text-xs' : 'text-sm'"
tag="div"
>
<i-fa6-solid-tower-broadcast class="w-3" />
</i18n-t>
<span v-if="item.watched" v-t="'video.watched'" class="thumbnail-overlay bottom-5px left-5px" />
<span
v-if="item.watched"
v-t="'video.watched'"
class="absolute bottom-1.25 left-1.25 rounded-md bg-black/75 px-1.25 text-white"
:class="small ? 'text-xs' : 'text-sm'"
/>
</div>
</template>
<script setup>
@@ -49,9 +62,3 @@ defineProps({
},
});
</script>
<style>
.shorts-img {
@apply w-full object-contain;
}
</style>

View File

@@ -17,15 +17,21 @@ defineProps({
<template>
<template v-if="getPreferenceBoolean('showWatchOnYouTube', false)">
<!-- For large screens -->
<a :href="link" class="btn flex items-center lt-lg:hidden">
<a
:href="link"
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-lg:hidden max-md:px-2 md:px-4 dark:bg-dark-400 dark:text-gray-400 dark:hover:bg-dark-300"
>
<i18n-t keypath="player.watch_on" tag="strong">{{ platform }}</i18n-t>
<i v-if="platform == 'YouTube'" class="i-fa6-brands:youtube mx-1.5" />
<i v-else-if="platform == 'Odysee'" class="i-fa6-brands:odysee mx-1.5" />
<i-fa6-brands-youtube v-if="platform == 'YouTube'" class="mx-1.5" />
<i-fa6-brands-odysee v-else-if="platform == 'Odysee'" class="mx-1.5" />
</a>
<!-- For small screens -->
<a :href="link" class="btn flex items-center lg:hidden">
<i v-if="platform == 'YouTube'" class="i-fa6-brands:youtube mx-1.5" />
<i v-else-if="platform == 'Odysee'" class="i-fa6-brands:odysee mx-1.5" />
<a
:href="link"
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 lg:hidden dark:bg-dark-400 dark:text-gray-400 dark:hover:bg-dark-300"
>
<i-fa6-brands-youtube v-if="platform == 'YouTube'" class="mx-1.5" />
<i-fa6-brands-odysee v-else-if="platform == 'Odysee'" class="mx-1.5" />
</a>
</template>
</template>

View File

@@ -1,5 +1,5 @@
<template>
<div v-if="video && isEmbed" class="absolute left-0 top-0 z-50 h-full w-full bg-black">
<div v-if="video && isEmbed" class="absolute top-0 left-0 z-50 size-full bg-black">
<VideoPlayer
ref="videoPlayer"
:video="video"
@@ -9,7 +9,7 @@
:is-embed="isEmbed"
/>
</div>
<div id="theaterModeSpot" class="-mx-1vw"></div>
<div id="theaterModeSpot" class="-mx-[1vw]"></div>
<LoadingIndicatorPage :show-content="video != null && !isEmbed" class="w-full">
<ErrorHandler v-if="video && video.error" :message="video.message" :error="video.error" />
<Transition>
@@ -43,9 +43,10 @@
setPreference('theaterMode', theaterMode);
"
>
<div
:class="theaterMode ? 'i-fa6-solid:chevron-left' : 'i-fa6-solid:chevron-right'"
></div>
<div>
<i-fa6-solid-chevron-left v-if="theaterMode" />
<i-fa6-solid-chevron-right v-else />
</div>
</button>
</div>
</Teleport>
@@ -66,8 +67,8 @@
/>
</div>
<!-- video title -->
<div class="mt-2 break-words text-2xl font-bold" v-text="video.title" />
<div class="mb-3 mt-3 flex flex-wrap">
<div class="mt-2 text-2xl font-bold wrap-break-word" v-text="video.title" />
<div class="my-3 flex flex-wrap">
<!-- views / date -->
<div class="flex flex-auto gap-2">
<span v-t="{ path: 'video.views', args: { views: addCommas(video.views) } }" />
@@ -78,11 +79,11 @@
<div class="flex gap-2">
<template v-if="video.likes >= 0">
<div class="flex items-center">
<div class="i-fa6-solid:thumbs-up" />
<i-fa6-solid-thumbs-up />
<strong class="ml-1" v-text="addCommas(video.likes)" />
</div>
<div class="flex items-center">
<div class="i-fa6-solid:thumbs-down" />
<i-fa6-solid-thumbs-down />
<strong
class="ml-1"
v-text="video.dislikes >= 0 ? addCommas(video.dislikes) : '?'"
@@ -108,11 +109,14 @@
alt=""
class="rounded-full"
/>
<router-link v-if="video.uploaderUrl" class="link ml-1.5" :to="video.uploaderUrl">{{
video.uploader
}}</router-link>
<router-link
v-if="video.uploaderUrl"
class="ml-1.5 hover:text-red-500 focus:text-red-500 dark:hover:text-red-400 dark:focus:text-red-400"
:to="video.uploaderUrl"
>{{ video.uploader }}</router-link
>
<!-- Verified Badge -->
<i v-if="video.uploaderVerified" class="i-fa6-solid:check ml-1" />
<i-fa6-solid-check v-if="video.uploaderVerified" class="ml-1" />
</div>
<PlaylistAddModal
v-if="showModal"
@@ -130,14 +134,20 @@
/>
<div class="ml-auto flex flex-wrap gap-1">
<!-- Subscribe Button -->
<button class="btn flex items-center gap-1 <md:hidden" @click="downloadCurrentFrame">
{{ $t("actions.download_frame") }}<i class="i-fa6-solid:download" />
</button>
<button class="btn flex items-center" @click="showModal = !showModal">
{{ $t("actions.add_to_playlist") }}<i class="i-fa6-solid:circle-plus ml-1" />
<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:hidden max-md:px-2 md:px-4 dark:bg-dark-400 dark:text-gray-400 dark:hover:bg-dark-300"
@click="downloadCurrentFrame"
>
{{ $t("actions.download_frame") }}<i-fa6-solid-download />
</button>
<button
class="btn"
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="showModal = !showModal"
>
{{ $t("actions.add_to_playlist") }}<i-fa6-solid-circle-plus class="ml-1" />
</button>
<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="subscribeHandler"
v-text="
$t('actions.' + (subscribed ? 'unsubscribe' : 'subscribe')) +
@@ -156,14 +166,17 @@
video.uploaderUrl.split('/')[2]
}`"
target="_blank"
class="btn flex items-center"
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"
>
<i class="i-fa6-solid:rss mx-1.5" />
<i-fa6-solid-rss class="mx-1.5" />
</a>
<!-- Share Dialog -->
<button class="btn flex items-center" @click="showShareModal = !showShareModal">
<i18n-t class="lt-lg:hidden" keypath="actions.share" tag="strong"></i18n-t>
<i class="i-fa6-solid:share mx-1.5" />
<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="showShareModal = !showShareModal"
>
<i18n-t class="max-lg:hidden" keypath="actions.share" tag="strong"></i18n-t>
<i-fa6-solid-share class="mx-1.5" />
</button>
<!-- YouTube -->
<WatchOnButton :link="youtubeVideoHref" />
@@ -178,12 +191,12 @@
:to="toggleListenUrl"
:aria-label="(isListening ? 'Watch ' : 'Listen to ') + video.title"
:title="(isListening ? 'Watch ' : 'Listen to ') + video.title"
class="btn flex items-center"
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"
>
<i
:class="isListening ? 'i-fa6-solid:tv' : 'i-fa6-solid:headphones'"
class="mx-1.5"
/>
<div>
<i-fa6-solid-tv v-if="isListening" class="mx-1.5" />
<i-fa6-solid-headphones v-else class="mx-1.5" />
</div>
</router-link>
</div>
</div>
@@ -194,7 +207,7 @@
<div
v-for="metaInfo in video?.metaInfo ?? []"
:key="metaInfo.title"
class="btn my-3 flex flex-wrap cursor-default gap-2 px-4 py-2"
class="my-3 inline-block w-auto cursor-default rounded-sm bg-gray-300 px-4 py-2 text-gray-600 dark:bg-dark-400 dark:text-gray-400"
>
<span>{{ metaInfo.description ?? metaInfo.title }}</span>
<a v-for="(link, linkIndex) in metaInfo.urls" :key="linkIndex" :href="link" class="underline">{{
@@ -205,18 +218,21 @@
<button
v-t="`actions.${showDesc ? 'minimize_description' : 'show_description'}`"
class="btn mb-2"
class="mb-2 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="showDesc = !showDesc"
/>
<span v-show="video?.chapters?.length > 0" class="btn ml-2">
<span
v-show="video?.chapters?.length > 0"
class="ml-2 inline-block w-auto cursor-default rounded-sm bg-gray-300 py-2 text-gray-600 max-md:px-2 md:px-4 dark:bg-dark-400 dark:text-gray-400"
>
<input id="showChapters" v-model="showChapters" type="checkbox" />
<label v-t="'actions.show_chapters'" class="ml-2" for="showChapters" />
</span>
<template v-if="showDesc">
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="description break-words" v-html="purifiedDescription" />
<div class="wrap-break-word [&_a]:underline [&_a]:brightness-75" v-html="purifiedDescription" />
<br />
<div
@@ -231,7 +247,7 @@
<router-link
v-for="tag in video.tags"
:key="tag"
class="btn line-clamp-1 rounded-s px-2 py-1"
class="line-clamp-1 inline-block w-auto cursor-pointer rounded-sm rounded-s bg-gray-300 px-2 py-1 text-gray-600 hover:bg-gray-500 hover:text-white focus:shadow-red-400 focus:outline-2 focus:outline-red-500 dark:bg-dark-400 dark:text-gray-400 dark:hover:bg-dark-300"
:to="`/results?search_query=${encodeURIComponent(tag)}`"
>{{ tag }}</router-link
>
@@ -254,7 +270,7 @@
<select
id="chkAutoPlay"
v-model.number="selectedAutoPlay"
class="select ml-1.5"
class="ml-1.5 h-8 rounded-md bg-gray-300 px-2.5 text-gray-600 dark:bg-dark-400 dark:text-gray-400"
@change="onChange($event)"
>
<option v-t="'actions.never'" value="0" />
@@ -266,7 +282,7 @@
<div v-if="isMobile">
<a
v-t="`actions.${showRecs ? 'minimize_recommendations' : 'show_recommendations'}`"
class="btn mb-2"
class="mb-2 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="showRecs = !showRecs"
/>
<hr v-show="showRecs" />
@@ -288,7 +304,7 @@
<div class="">
<button
v-if="!comments?.disabled"
class="btn mb-2"
class="mb-2 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="toggleCommentsVisibility"
v-text="
`${$t(
@@ -333,7 +349,7 @@
/>
<a
v-t="`actions.${showRecs ? 'minimize_recommendations' : 'show_recommendations'}`"
class="btn mb-2"
class="mb-2 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="showRecs = !showRecs"
/>
<hr v-show="showRecs" />
@@ -803,9 +819,4 @@ onUnmounted(() => {
opacity: 0;
transform: translateX(100%) scale(0.5);
}
.description a {
text-decoration: underline;
filter: brightness(0.75);
}
</style>

View File

@@ -0,0 +1,47 @@
<template>
<ListboxRoot
:id="id"
:model-value="modelValue"
multiple
class="w-52 rounded-md bg-gray-300 text-gray-700 dark:bg-dark-400 dark:text-gray-300"
@update:model-value="handleUpdate"
>
<ListboxContent class="max-h-28 overflow-y-auto rounded-md p-1">
<ListboxItem
v-for="option in options"
:key="option.value"
:value="option.value"
class="flex cursor-pointer items-center justify-between rounded-sm px-2 py-1 text-sm transition-colors outline-none data-highlighted:bg-gray-500 data-highlighted:text-white data-[state=checked]:bg-red-100 data-[state=checked]:text-red-700 dark:data-highlighted:bg-dark-300 dark:data-[state=checked]:bg-red-950/40 dark:data-[state=checked]:text-red-200"
>
<span v-text="option.label" />
<ListboxItemIndicator class="ml-2 text-red-500 dark:text-red-400"></ListboxItemIndicator>
</ListboxItem>
</ListboxContent>
</ListboxRoot>
</template>
<script setup>
import { ListboxContent, ListboxItem, ListboxItemIndicator, ListboxRoot } from "reka-ui";
defineProps({
id: {
type: String,
required: true,
},
modelValue: {
type: Array,
required: true,
},
options: {
type: Array,
required: true,
},
});
const emit = defineEmits(["update:modelValue", "change"]);
function handleUpdate(value) {
emit("update:modelValue", value);
emit("change", value);
}
</script>

View File

@@ -0,0 +1,61 @@
<template>
<NumberFieldRoot
:id="id"
:model-value="modelValue"
:min="min"
:max="max"
:step="step"
class="inline-flex h-8 items-center rounded-md bg-gray-300 text-gray-700 dark:bg-dark-400 dark:text-gray-300"
@update:model-value="handleUpdate"
>
<NumberFieldDecrement
class="inline-flex h-full w-8 items-center justify-center rounded-l-md border-r border-gray-400 text-base transition-colors hover:bg-gray-500 hover:text-white focus-visible:outline-2 focus-visible:outline-red-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-300 dark:hover:bg-dark-300"
aria-label="Decrease value"
>
</NumberFieldDecrement>
<NumberFieldInput
class="h-full w-16 bg-transparent px-2 text-center text-gray-700 outline-none focus-visible:outline-2 focus-visible:outline-red-500 dark:text-gray-300"
/>
<NumberFieldIncrement
class="inline-flex h-full w-8 items-center justify-center rounded-r-md border-l border-gray-400 text-base transition-colors hover:bg-gray-500 hover:text-white focus-visible:outline-2 focus-visible:outline-red-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-300 dark:hover:bg-dark-300"
aria-label="Increase value"
>
+
</NumberFieldIncrement>
</NumberFieldRoot>
</template>
<script setup>
import { NumberFieldDecrement, NumberFieldIncrement, NumberFieldInput, NumberFieldRoot } from "reka-ui";
defineProps({
id: {
type: String,
required: true,
},
modelValue: {
type: Number,
required: true,
},
min: {
type: Number,
default: undefined,
},
max: {
type: Number,
default: undefined,
},
step: {
type: Number,
default: 1,
},
});
const emit = defineEmits(["update:modelValue", "change"]);
function handleUpdate(value) {
emit("update:modelValue", value ?? 0);
emit("change", value ?? 0);
}
</script>

View File

@@ -0,0 +1,18 @@
<template>
<label
class="mx-[15vw] my-2 flex items-center justify-between odd:bg-gray-200 max-md:mx-[2vw] dark:odd:bg-dark-800"
:for="forId"
>
<slot name="label" />
<slot />
</label>
</template>
<script setup>
defineProps({
forId: {
type: String,
required: true,
},
});
</script>

View File

@@ -0,0 +1,34 @@
<template>
<SwitchRoot
:id="id"
:model-value="modelValue"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-gray-300 transition-colors focus-visible:outline-2 focus-visible:outline-red-500 data-[state=checked]:bg-red-500 dark:bg-dark-400 dark:data-[state=checked]:bg-red-400"
@update:model-value="handleUpdate"
>
<SwitchThumb
class="pointer-events-none block size-5 translate-x-0 rounded-full bg-white shadow-sm transition-transform data-[state=checked]:translate-x-5"
/>
</SwitchRoot>
</template>
<script setup>
import { SwitchRoot, SwitchThumb } from "reka-ui";
defineProps({
id: {
type: String,
required: true,
},
modelValue: {
type: Boolean,
required: true,
},
});
const emit = defineEmits(["update:modelValue", "change"]);
function handleUpdate(value) {
emit("update:modelValue", value);
emit("change", value);
}
</script>

View File

@@ -1,3 +1,7 @@
import { StorageSerializers, useLocalStorage } from "@vueuse/core";
const preferenceRefs = new Map();
export function testLocalStorage() {
try {
if (window.localStorage !== undefined) localStorage;
@@ -7,20 +11,83 @@ export function testLocalStorage() {
}
}
function getQueryPreference(key) {
return new URLSearchParams(window.location.search).get(key);
}
function getOrCreatePreferenceRef(key, createRef) {
if (preferenceRefs.has(key)) return preferenceRefs.get(key);
const preferenceRef = createRef();
preferenceRefs.set(key, preferenceRef);
return preferenceRef;
}
function createPreferenceRefForValue(key, value) {
if (typeof value === "string" || value === undefined) return usePreferenceString(key);
if (typeof value === "boolean") return usePreferenceBoolean(key, value);
if (typeof value === "number") return usePreferenceNumber(key, value);
return usePreferenceJSON(key, value);
}
export function usePreferenceString(key, defaultVal) {
return getOrCreatePreferenceRef(key, () =>
useLocalStorage(key, defaultVal ?? null, {
serializer: StorageSerializers.any,
writeDefaults: false,
}),
);
}
export function usePreferenceBoolean(key, defaultVal = false) {
return getOrCreatePreferenceRef(key, () =>
useLocalStorage(key, defaultVal, {
writeDefaults: false,
}),
);
}
export function usePreferenceNumber(key, defaultVal = 0) {
return getOrCreatePreferenceRef(key, () =>
useLocalStorage(key, defaultVal, {
writeDefaults: false,
}),
);
}
export function usePreferenceJSON(key, defaultVal) {
return getOrCreatePreferenceRef(key, () =>
useLocalStorage(key, defaultVal ?? null, {
serializer: StorageSerializers.object,
writeDefaults: false,
}),
);
}
export function setPreference(key, value, disableAlert = false) {
try {
localStorage.setItem(key, value);
createPreferenceRefForValue(key, value).value = value;
} catch {
if (!disableAlert) alert("Could not save preference to local storage.");
}
}
export function getPreferenceBoolean(key, defaultVal) {
var value;
if (
(value = new URLSearchParams(window.location.search).get(key)) !== null ||
(testLocalStorage() && (value = localStorage.getItem(key)) !== null)
) {
const queryValue = getQueryPreference(key);
if (queryValue !== null) {
switch (String(queryValue).toLowerCase()) {
case "true":
case "1":
case "on":
case "yes":
return true;
default:
return false;
}
}
if (testLocalStorage()) {
const value = usePreferenceBoolean(key, defaultVal).value;
switch (String(value).toLowerCase()) {
case "true":
case "1":
@@ -30,36 +97,47 @@ export function getPreferenceBoolean(key, defaultVal) {
default:
return false;
}
} else return defaultVal;
}
return defaultVal;
}
export function getPreferenceString(key, defaultVal) {
var value;
if (
(value = new URLSearchParams(window.location.search).get(key)) !== null ||
(testLocalStorage() && (value = localStorage.getItem(key)) !== null)
) {
return value;
} else return defaultVal;
const queryValue = getQueryPreference(key);
if (queryValue !== null) return queryValue;
if (testLocalStorage()) {
const value = usePreferenceString(key, defaultVal).value;
return value ?? defaultVal;
}
return defaultVal;
}
export function getPreferenceNumber(key, defaultVal) {
var value;
if (
(value = new URLSearchParams(window.location.search).get(key)) !== null ||
(testLocalStorage() && (value = localStorage.getItem(key)) !== null)
) {
const queryValue = getQueryPreference(key);
if (queryValue !== null) {
const num = Number(queryValue);
return isNaN(num) ? defaultVal : num;
}
if (testLocalStorage()) {
const value = usePreferenceNumber(key, defaultVal).value;
const num = Number(value);
return isNaN(num) ? defaultVal : num;
} else return defaultVal;
}
return defaultVal;
}
export function getPreferenceJSON(key, defaultVal) {
var value;
if (
(value = new URLSearchParams(window.location.search).get(key)) !== null ||
(testLocalStorage() && (value = localStorage.getItem(key)) !== null)
) {
return JSON.parse(value);
} else return defaultVal;
const queryValue = getQueryPreference(key);
if (queryValue !== null) return JSON.parse(queryValue);
if (testLocalStorage()) {
const value = usePreferenceJSON(key, defaultVal).value;
return value ?? defaultVal;
}
return defaultVal;
}

View File

@@ -4,8 +4,7 @@ import App from "./App.vue";
import { createI18n } from "vue-i18n";
import enLocale from "@/locales/en.json";
import "@unocss/reset/tailwind.css";
import "uno.css";
import "./app.css";
import("./registerServiceWorker");

View File

@@ -1,41 +0,0 @@
import { defineConfig } from "unocss";
import transformerDirective from "@unocss/transformer-directives";
import transformerVariantGroup from "@unocss/transformer-variant-group";
import presetUno from "@unocss/preset-uno";
import presetIcons from "@unocss/preset-icons";
import presetWebFonts from "@unocss/preset-web-fonts";
export default defineConfig({
transformers: [transformerDirective(), transformerVariantGroup()],
presets: [
presetUno({
dark: "media",
}),
presetIcons({
extraProperties: {
display: "inline-block",
"vertical-align": "middle",
},
}),
presetWebFonts({
provider: "none",
fonts: {
sans: [
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
"Roboto",
"Helvetica Neue",
"Arial",
"Noto Sans",
"sans-serif",
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol",
"Noto Color Emoji",
],
},
}),
],
});

View File

@@ -1,6 +1,9 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import Unocss from "unocss/vite";
import tailwindcss from "@tailwindcss/vite";
import Icons from "unplugin-icons/vite";
import Components from "unplugin-vue-components/vite";
import IconsResolver from "unplugin-icons/resolver";
import legacy from "@vitejs/plugin-legacy";
import VueI18nPlugin from "@intlify/unplugin-vue-i18n/vite";
import { VitePWA } from "vite-plugin-pwa";
@@ -11,7 +14,12 @@ import eslintPlugin from "vite-plugin-eslint";
export default defineConfig({
plugins: [
vue(),
Unocss(),
tailwindcss(),
Icons({ compiler: "vue3", scale: 1 }),
Components({
resolvers: [IconsResolver()],
dts: false,
}),
VueI18nPlugin({
include: path.resolve(__dirname, "./src/locales/**"),
}),