mirror of
https://github.com/KevinMidboe/seasoned.git
synced 2026-04-24 16:53:37 +00:00
Compare commits
6 Commits
b1f1fa8780
...
e69f0c52b8
| Author | SHA1 | Date | |
|---|---|---|---|
| e69f0c52b8 | |||
| 1b99399b4c | |||
| 990dde4d31 | |||
| 493ac02bab | |||
| e8a0598e8f | |||
| 9c6e6938e9 |
@@ -13,8 +13,6 @@
|
|||||||
<!-- Popup that will show above existing rendered content -->
|
<!-- Popup that will show above existing rendered content -->
|
||||||
<popup />
|
<popup />
|
||||||
|
|
||||||
<darkmode-toggle />
|
|
||||||
|
|
||||||
<!-- Command Palette -->
|
<!-- Command Palette -->
|
||||||
<command-palette />
|
<command-palette />
|
||||||
</div>
|
</div>
|
||||||
@@ -25,7 +23,6 @@
|
|||||||
import NavigationHeader from "@/components/header/NavigationHeader.vue";
|
import NavigationHeader from "@/components/header/NavigationHeader.vue";
|
||||||
import NavigationIcons from "@/components/header/NavigationIcons.vue";
|
import NavigationIcons from "@/components/header/NavigationIcons.vue";
|
||||||
import Popup from "@/components/Popup.vue";
|
import Popup from "@/components/Popup.vue";
|
||||||
import DarkmodeToggle from "@/components/ui/DarkmodeToggle.vue";
|
|
||||||
import CommandPalette from "@/components/ui/CommandPalette.vue";
|
import CommandPalette from "@/components/ui/CommandPalette.vue";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
16
src/api.ts
16
src/api.ts
@@ -413,6 +413,20 @@ const unlinkPlexAccount = async () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const plexRecentlyAddedInLibrary = async (id: number) => {
|
||||||
|
const url = new URL(`/api/v2/plex/recently_added/${id}`, API_HOSTNAME);
|
||||||
|
const options: RequestInit = {
|
||||||
|
credentials: "include"
|
||||||
|
};
|
||||||
|
|
||||||
|
return fetch(url.href, options)
|
||||||
|
.then(resp => resp.json())
|
||||||
|
.catch(error => {
|
||||||
|
console.error(`api error fetch plex recently added`); // eslint-disable-line no-console
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// - - - User graphs - - -
|
// - - - User graphs - - -
|
||||||
|
|
||||||
const fetchGraphData = async (
|
const fetchGraphData = async (
|
||||||
@@ -543,6 +557,7 @@ const elasticSearchMoviesAndShows = async (query: string, count = 22) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
API_HOSTNAME,
|
||||||
getMovie,
|
getMovie,
|
||||||
getShow,
|
getShow,
|
||||||
getPerson,
|
getPerson,
|
||||||
@@ -559,6 +574,7 @@ export {
|
|||||||
getRequestStatus,
|
getRequestStatus,
|
||||||
linkPlexAccount,
|
linkPlexAccount,
|
||||||
unlinkPlexAccount,
|
unlinkPlexAccount,
|
||||||
|
plexRecentlyAddedInLibrary,
|
||||||
register,
|
register,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
|
|||||||
@@ -56,12 +56,6 @@
|
|||||||
const graphCanvas: Ref<HTMLCanvasElement | null> = ref(null);
|
const graphCanvas: Ref<HTMLCanvasElement | null> = ref(null);
|
||||||
let graphInstance: Chart | null = null;
|
let graphInstance: Chart | null = null;
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Modern Color System
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
const graphTemplates = [
|
const graphTemplates = [
|
||||||
{
|
{
|
||||||
borderColor: "#6366F1",
|
borderColor: "#6366F1",
|
||||||
@@ -77,12 +71,6 @@
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Lifecycle
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
onMounted(() => generateGraph());
|
onMounted(() => generateGraph());
|
||||||
watch(() => props.data, generateGraph, { deep: true });
|
watch(() => props.data, generateGraph, { deep: true });
|
||||||
|
|
||||||
@@ -90,12 +78,6 @@
|
|||||||
if (graphInstance) graphInstance.destroy();
|
if (graphInstance) graphInstance.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Helpers
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
function removeEmptyDataset(dataset: IGraphDataset) {
|
function removeEmptyDataset(dataset: IGraphDataset) {
|
||||||
return dataset;
|
return dataset;
|
||||||
return !dataset.data.every(point => point === 0);
|
return !dataset.data.every(point => point === 0);
|
||||||
@@ -146,12 +128,6 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Chart Generator
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
function generateGraph() {
|
function generateGraph() {
|
||||||
if (!graphCanvas.value) return;
|
if (!graphCanvas.value) return;
|
||||||
|
|
||||||
|
|||||||
86
src/components/activity/StatsOverview.vue
Normal file
86
src/components/activity/StatsOverview.vue
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="watchStats" class="stats-overview">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ watchStats.totalPlays }}</div>
|
||||||
|
<div class="stat-label">Total Plays</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ watchStats.totalHours }}h</div>
|
||||||
|
<div class="stat-label">Watch Time</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ watchStats.moviePlays }}</div>
|
||||||
|
<div class="stat-label">Movies watched</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ watchStats.episodePlays }}</div>
|
||||||
|
<div class="stat-label">Episodes watched</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { WatchStats } from "../../composables/useTautulliStats";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
watchStats: WatchStats | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "scss/variables";
|
||||||
|
@import "scss/media-queries";
|
||||||
|
|
||||||
|
.stats-overview {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--background-ui);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--highlight-color);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-color-60);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 300;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
101
src/components/activity/WatchHistory.vue
Normal file
101
src/components/activity/WatchHistory.vue
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="topContent.length > 0" class="watch-history">
|
||||||
|
<h3 class="section-title">Last Watched</h3>
|
||||||
|
<div class="top-content-list">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in topContent"
|
||||||
|
:key="index"
|
||||||
|
class="top-content-item"
|
||||||
|
>
|
||||||
|
<div class="content-rank">{{ index + 1 }}</div>
|
||||||
|
<div class="content-details">
|
||||||
|
<div class="content-title">{{ item.title }}</div>
|
||||||
|
<div class="content-meta">
|
||||||
|
{{ item.type }} • {{ item.plays }} plays • {{ item.duration }}min
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface TopContentItem {
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
plays: number;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
topContent: TopContentItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "scss/variables";
|
||||||
|
@import "scss/media-queries";
|
||||||
|
|
||||||
|
.watch-history {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-content-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-content-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
background: var(--background-ui);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--text-color-50);
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--text-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-rank {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--highlight-color);
|
||||||
|
min-width: 2.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-details {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-meta {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-color-60);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -42,8 +42,7 @@
|
|||||||
.navigation-link {
|
.navigation-link {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
height: var(--header-size);
|
min-height: var(--header-size);
|
||||||
width: var(--header-size);
|
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 1rem 0.15rem;
|
padding: 1rem 0.15rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -90,18 +90,10 @@
|
|||||||
|
|
||||||
@include desktop {
|
@include desktop {
|
||||||
grid-template-rows: var(--header-size);
|
grid-template-rows: var(--header-size);
|
||||||
grid-auto-flow: row;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@include mobile {
|
@include mobile {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.navigation-icons > *:last-child) {
|
|
||||||
margin-top: auto;
|
|
||||||
justify-self: end;
|
|
||||||
align-self: end;
|
|
||||||
background-color: red;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -9,18 +9,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="signin-container">
|
<div class="signin-container">
|
||||||
<button @click="handleAuth" :disabled="loading" class="plex-signin-btn">
|
<button @click="handleAuth" :disabled="loading" class="plex-signin-btn">
|
||||||
<svg
|
|
||||||
v-if="!loading"
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 256 256"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M128 0C57.3 0 0 57.3 0 128s57.3 128 128 128 128-57.3 128-128S198.7 0 128 0zm57.7 128.7l-48 48c-.4.4-.9.7-1.4.9-.5.2-1.1.4-1.6.4s-1.1-.1-1.6-.4c-.5-.2-1-.5-1.4-.9l-48-48c-1.6-1.6-1.6-4.1 0-5.7 1.6-1.6 4.1-1.6 5.7 0l41.1 41.1V80c0-2.2 1.8-4 4-4s4 1.8 4 4v84.1l41.1-41.1c1.6-1.6 4.1-1.6 5.7 0 .8.8 1.2 1.8 1.2 2.8s-.4 2.1-1.2 2.9z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{{ loading ? "Connecting..." : "Sign in with Plex" }}
|
{{ loading ? "Connecting..." : "Sign in with Plex" }}
|
||||||
|
|
||||||
|
<IconPlex v-if="!loading" class="plex-icon" />
|
||||||
</button>
|
</button>
|
||||||
<p class="popup-note">A popup window will open for authentication</p>
|
<p class="popup-note">A popup window will open for authentication</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -30,6 +21,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { usePlexAuth } from "@/composables/usePlexAuth";
|
import { usePlexAuth } from "@/composables/usePlexAuth";
|
||||||
import IconInfo from "@/icons/IconInfo.vue";
|
import IconInfo from "@/icons/IconInfo.vue";
|
||||||
|
import IconPlex from "@/icons/IconPlex.vue";
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
authSuccess: [token: string];
|
authSuccess: [token: string];
|
||||||
@@ -134,10 +126,12 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
.plex-icon {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 22px;
|
--size: 24px;
|
||||||
height: 22px;
|
width: var(--size);
|
||||||
|
height: var(--size);
|
||||||
|
fill: currentColor;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="plex-library-item"
|
class="plex-library-item"
|
||||||
>
|
>
|
||||||
<figure class="item-poster">
|
<figure :class="`item-poster ${item.type}`">
|
||||||
<img
|
<img
|
||||||
v-if="item.poster"
|
v-if="item.poster"
|
||||||
:src="item.poster"
|
:src="item.poster"
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style style="scss" scoped>
|
||||||
.plex-library-item {
|
.plex-library-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -144,6 +144,10 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #333;
|
background: #333;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
|
&.music {
|
||||||
|
aspect-ratio: 1/1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.poster-image {
|
.poster-image {
|
||||||
|
|||||||
@@ -21,19 +21,33 @@
|
|||||||
<div class="library-stats-overview">
|
<div class="library-stats-overview">
|
||||||
<div class="overview-stat">
|
<div class="overview-stat">
|
||||||
<span class="overview-label">Total Items</span>
|
<span class="overview-label">Total Items</span>
|
||||||
<span class="overview-value">{{ details.total }}</span>
|
<span class="overview-value">{{
|
||||||
|
formatNumber(details.total)
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="overview-stat" v-if="libraryType === 'shows'">
|
|
||||||
|
<div class="overview-stat" v-if="libraryType === 'tv shows'">
|
||||||
|
<span class="overview-label">Seasons</span>
|
||||||
|
<span class="overview-value">{{
|
||||||
|
formatNumber(details?.childCount)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="overview-stat" v-if="libraryType === 'tv shows'">
|
||||||
<span class="overview-label">Episodes</span>
|
<span class="overview-label">Episodes</span>
|
||||||
<span class="overview-value">{{ details.totalEpisodes }}</span>
|
<span class="overview-value">{{
|
||||||
|
formatNumber(details?.leafCount)
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overview-stat" v-if="libraryType === 'music'">
|
<div class="overview-stat" v-if="libraryType === 'music'">
|
||||||
<span class="overview-label">Tracks</span>
|
<span class="overview-label">Tracks</span>
|
||||||
<span class="overview-value">{{ details.totalTracks }}</span>
|
<span class="overview-value">{{ details?.totalTracks }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="overview-stat">
|
<div class="overview-stat">
|
||||||
<span class="overview-label">Duration</span>
|
<span class="overview-label">Duration</span>
|
||||||
<span class="overview-value">{{ details.totalDuration }}</span>
|
<span class="overview-value">{{
|
||||||
|
convertSecondsToHumanReadable(details?.duration / 1000)
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -42,10 +56,12 @@
|
|||||||
<h4 class="section-title">Recently Added</h4>
|
<h4 class="section-title">Recently Added</h4>
|
||||||
<div class="recent-items-grid">
|
<div class="recent-items-grid">
|
||||||
<PlexLibraryItem
|
<PlexLibraryItem
|
||||||
v-for="(item, index) in details.recentlyAdded"
|
v-for="(item, index) in recentlyAdded"
|
||||||
:key="index"
|
:key="index"
|
||||||
:item="item"
|
:item="item"
|
||||||
:show-extras="libraryType === 'music' || libraryType === 'shows'"
|
:show-extras="
|
||||||
|
libraryType === 'music' || libraryType === 'tv shows'
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,41 +94,70 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount } from "vue";
|
import { computed, onMounted, onBeforeUnmount, ref } from "vue";
|
||||||
import IconClose from "@/icons/IconClose.vue";
|
import IconClose from "@/icons/IconClose.vue";
|
||||||
import IconMovie from "@/icons/IconMovie.vue";
|
import IconMovie from "@/icons/IconMovie.vue";
|
||||||
import IconShow from "@/icons/IconShow.vue";
|
import IconShow from "@/icons/IconShow.vue";
|
||||||
import IconMusic from "@/icons/IconMusic.vue";
|
import IconMusic from "@/icons/IconMusic.vue";
|
||||||
import PlexLibraryItem from "@/components/plex/PlexLibraryItem.vue";
|
import PlexLibraryItem from "@/components/plex/PlexLibraryItem.vue";
|
||||||
import { getLibraryTitle } from "@/utils/plexHelpers";
|
import { getLibraryTitle } from "@/utils/plexHelpers";
|
||||||
|
import { plexRecentlyAddedInLibrary } from "@/api";
|
||||||
|
import { processLibraryItem } from "@/utils/plexHelpers";
|
||||||
|
import { formatNumber, convertSecondsToHumanReadable } from "@/utils";
|
||||||
|
import { usePlexAuth } from "@/composables/usePlexAuth";
|
||||||
|
|
||||||
|
const { getPlexAuthCookie } = usePlexAuth();
|
||||||
|
const authToken = getPlexAuthCookie();
|
||||||
|
|
||||||
interface LibraryDetails {
|
interface LibraryDetails {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
total: number;
|
total: number;
|
||||||
recentlyAdded: any[];
|
childCount?: number;
|
||||||
genres: { name: string; count: number }[];
|
leafCount?: number;
|
||||||
totalDuration: string;
|
duration: number;
|
||||||
totalEpisodes?: number;
|
genres: Array<{
|
||||||
totalTracks?: number;
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
libraryType: string;
|
libraryType: string;
|
||||||
details: LibraryDetails;
|
details: LibraryDetails;
|
||||||
|
serverUrl: string;
|
||||||
|
serverMachineId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
let recentlyAdded = ref([]);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "close"): void;
|
(e: "close"): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const libraryIconComponent = computed(() => {
|
const libraryIconComponent = computed(() => {
|
||||||
if (props.libraryType === "movies") return IconMovie;
|
if (props.libraryType === "movies") return IconMovie;
|
||||||
if (props.libraryType === "shows") return IconShow;
|
if (props.libraryType === "tv shows") return IconShow;
|
||||||
if (props.libraryType === "music") return IconMusic;
|
if (props.libraryType === "music") return IconMusic;
|
||||||
return IconMovie;
|
return IconMovie;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function fetchRecentlyAdded() {
|
||||||
|
plexRecentlyAddedInLibrary(props.details.id).then(added => {
|
||||||
|
recentlyAdded.value = added?.MediaContainer?.Metadata.map(el =>
|
||||||
|
processLibraryItem(
|
||||||
|
el,
|
||||||
|
props.libraryType,
|
||||||
|
authToken,
|
||||||
|
props.serverUrl,
|
||||||
|
props.serverMachineId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function checkEventForEscapeKey(event: KeyboardEvent) {
|
function checkEventForEscapeKey(event: KeyboardEvent) {
|
||||||
if (event.key !== "Escape") return;
|
if (event.key !== "Escape") return;
|
||||||
emit("close");
|
emit("close");
|
||||||
@@ -120,12 +165,18 @@
|
|||||||
|
|
||||||
window.addEventListener("keyup", checkEventForEscapeKey);
|
window.addEventListener("keyup", checkEventForEscapeKey);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchRecentlyAdded();
|
||||||
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener("keyup", checkEventForEscapeKey);
|
window.removeEventListener("keyup", checkEventForEscapeKey);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style lang="scss" scoped>
|
||||||
|
@import "scss/media-queries.scss";
|
||||||
|
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -139,6 +190,10 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-modal-content {
|
.library-modal-content {
|
||||||
@@ -150,6 +205,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
max-height: 100vh;
|
||||||
|
border-radius: unset;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-modal-header {
|
.library-modal-header {
|
||||||
@@ -198,12 +258,16 @@
|
|||||||
border: none;
|
border: none;
|
||||||
color: #888;
|
color: #888;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 8px;
|
padding: 0.5rem;
|
||||||
height: var(--size);
|
height: var(--size);
|
||||||
width: var(--size);
|
width: var(--size);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
fill: white;
|
fill: white;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
margin: auto 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn:hover {
|
.close-btn:hover {
|
||||||
|
|||||||
@@ -5,18 +5,18 @@
|
|||||||
:key="stat.key"
|
:key="stat.key"
|
||||||
class="stat-card"
|
class="stat-card"
|
||||||
:class="{
|
:class="{
|
||||||
disabled: stat.value === 0 || loading,
|
disabled: stat.value === undefined || stat.value === 0 || loading,
|
||||||
unclickable: !!!stat.clickable
|
unclickable: !!!stat.clickable
|
||||||
}"
|
}"
|
||||||
@click="
|
@click="
|
||||||
stat.clickable && stat.value > 0 && !loading && handleClick(stat.key)
|
stat.clickable && stat.value?.total > 0 && !loading && handleClick(stat.key)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div class="stat-icon">
|
<div class="stat-icon">
|
||||||
<component :is="stat.icon" />
|
<component :is="stat.icon" />
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<div class="stat-value" v-if="!loading">{{ stat.value }}</div>
|
<div class="stat-value" v-if="!loading">{{ formatNumber(stat.value?.total) }}</div>
|
||||||
<div class="stat-value loading-dots" v-else>...</div>
|
<div class="stat-value loading-dots" v-else>...</div>
|
||||||
<div class="stat-label">{{ stat.label }}</div>
|
<div class="stat-label">{{ stat.label }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,15 +26,24 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
import { formatNumber } from '@/utils'
|
||||||
import IconMovie from "@/icons/IconMovie.vue";
|
import IconMovie from "@/icons/IconMovie.vue";
|
||||||
import IconShow from "@/icons/IconShow.vue";
|
import IconShow from "@/icons/IconShow.vue";
|
||||||
import IconMusic from "@/icons/IconMusic.vue";
|
import IconMusic from "@/icons/IconMusic.vue";
|
||||||
import IconClock from "@/icons/IconClock.vue";
|
import IconClock from "@/icons/IconClock.vue";
|
||||||
|
|
||||||
|
interface LibraryStat {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
total: number;
|
||||||
|
childCount?: number;
|
||||||
|
leafCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
movies: number;
|
movies: LibraryStat;
|
||||||
shows: number;
|
shows: LibraryStat;
|
||||||
music: number;
|
music: LibraryStat;
|
||||||
watchtime: number;
|
watchtime: number;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
@@ -54,7 +63,7 @@
|
|||||||
clickable: true
|
clickable: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "shows",
|
key: "tv shows",
|
||||||
icon: IconShow,
|
icon: IconShow,
|
||||||
value: props.shows,
|
value: props.shows,
|
||||||
label: "TV Shows",
|
label: "TV Shows",
|
||||||
|
|||||||
@@ -3,37 +3,14 @@
|
|||||||
<div class="plex-details">
|
<div class="plex-details">
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="detail-label">
|
<span class="detail-label">
|
||||||
<svg
|
<IconServer class="label-icon" />
|
||||||
width="16"
|
Plex server name
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect>
|
|
||||||
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path>
|
|
||||||
</svg>
|
|
||||||
Server
|
|
||||||
</span>
|
</span>
|
||||||
<span class="detail-value">{{ serverName || "Unknown" }}</span>
|
<span class="detail-value">{{ serverName || "Unknown" }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="detail-label">
|
<span class="detail-label">
|
||||||
<svg
|
<IconSync class="label-icon" />
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<polyline points="23 4 23 10 17 10"></polyline>
|
|
||||||
<polyline points="1 20 1 14 7 14"></polyline>
|
|
||||||
<path
|
|
||||||
d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Last Sync
|
Last Sync
|
||||||
</span>
|
</span>
|
||||||
<span class="detail-value">{{ lastSync || "Never" }}</span>
|
<span class="detail-value">{{ lastSync || "Never" }}</span>
|
||||||
@@ -42,21 +19,7 @@
|
|||||||
|
|
||||||
<div class="plex-actions">
|
<div class="plex-actions">
|
||||||
<seasoned-button @click="$emit('sync')" :disabled="syncing">
|
<seasoned-button @click="$emit('sync')" :disabled="syncing">
|
||||||
<svg
|
<IconSync v-if="!syncing" class="button-icon" />
|
||||||
v-if="!syncing"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<polyline points="23 4 23 10 17 10"></polyline>
|
|
||||||
<polyline points="1 20 1 14 7 14"></polyline>
|
|
||||||
<path
|
|
||||||
d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
{{ syncing ? "Syncing..." : "Sync Library" }}
|
{{ syncing ? "Syncing..." : "Sync Library" }}
|
||||||
</seasoned-button>
|
</seasoned-button>
|
||||||
<seasoned-button @click="$emit('unlink')">
|
<seasoned-button @click="$emit('unlink')">
|
||||||
@@ -68,6 +31,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
|
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
|
||||||
|
import IconServer from "@/icons/IconServer.vue";
|
||||||
|
import IconSync from "@/icons/IconSync.vue";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverName: string;
|
serverName: string;
|
||||||
@@ -120,6 +85,11 @@
|
|||||||
color: var(--text-color-60);
|
color: var(--text-color-60);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.label-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-value {
|
.detail-value {
|
||||||
@@ -147,6 +117,11 @@
|
|||||||
svg {
|
svg {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -215,7 +215,8 @@
|
|||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
const ASSET_URL = "https://image.tmdb.org/t/p/";
|
const ASSET_URL = "https://image.tmdb.org/t/p/";
|
||||||
const COLORS_URL = "https://colors.schleppe.cloud/colors";
|
// const COLORS_URL = "https://colors.schleppe.cloud/colors";
|
||||||
|
const COLORS_URL = "http://localhost:8080/colors";
|
||||||
const ASSET_SIZES = ["w500", "w780", "original"];
|
const ASSET_SIZES = ["w500", "w780", "original"];
|
||||||
|
|
||||||
const media: Ref<IMovie | IShow> = ref();
|
const media: Ref<IMovie | IShow> = ref();
|
||||||
@@ -435,7 +436,7 @@
|
|||||||
|
|
||||||
> img {
|
> img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: inherit;
|
border-radius: calc(1.6rem - 1px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="change-password">
|
<div class="change-password">
|
||||||
<div class="password-card">
|
<div class="password-card">
|
||||||
<p class="password-info">
|
<form class="password-form" @submit.prevent>
|
||||||
Update your password to keep your account secure. Use a strong password
|
|
||||||
with at least 8 characters.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form class="password-form" @submit.prevent="changePassword">
|
|
||||||
<seasoned-input
|
<seasoned-input
|
||||||
v-model="oldPassword"
|
v-model="oldPassword"
|
||||||
placeholder="Current password"
|
placeholder="Current password"
|
||||||
@@ -72,49 +67,8 @@
|
|||||||
newPasswordRepeat.value = password;
|
newPasswordRepeat.value = password;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addWarningMessage(message: string, title?: string) {
|
async function changePassword(event: CustomEvent) {
|
||||||
messages.value.push({
|
|
||||||
message,
|
|
||||||
title,
|
|
||||||
type: ErrorMessageTypes.Warning
|
|
||||||
} as IErrorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
function validate() {
|
|
||||||
return;
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (!oldPassword.value || oldPassword?.value?.length === 0) {
|
|
||||||
addWarningMessage("Missing old password!", "Validation error");
|
|
||||||
reject();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!newPassword.value || newPassword?.value?.length === 0) {
|
|
||||||
addWarningMessage("Missing new password!", "Validation error");
|
|
||||||
reject();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newPassword.value !== newPasswordRepeat.value) {
|
|
||||||
addWarningMessage(
|
|
||||||
"Password and password repeat do not match!",
|
|
||||||
"Validation error"
|
|
||||||
);
|
|
||||||
reject();
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function changePassword() {
|
|
||||||
try {
|
try {
|
||||||
await validate();
|
|
||||||
|
|
||||||
loading.value = true;
|
|
||||||
|
|
||||||
// API call disabled for now
|
|
||||||
// TODO: Implement actual password change API call
|
|
||||||
// await api.changePassword({ oldPassword, newPassword });
|
|
||||||
|
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
message: "Password change is currently disabled",
|
message: "Password change is currently disabled",
|
||||||
title: "Feature Disabled",
|
title: "Feature Disabled",
|
||||||
@@ -153,20 +107,6 @@
|
|||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.password-info {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.65rem;
|
|
||||||
background-color: var(--background-ui);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
border-left: 3px solid var(--highlight-color);
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
padding: 0.6rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.password-form {
|
.password-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,106 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="data-export">
|
<div class="data-export">
|
||||||
<!-- Info Header -->
|
|
||||||
<div class="data-export__header">
|
|
||||||
<div class="data-export__info">
|
|
||||||
<IconInfo class="info-icon" />
|
|
||||||
<span>
|
|
||||||
Full transparency and control over your data. Everything is stored
|
|
||||||
locally on your device—no servers, no tracking. You own your data.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="export-options">
|
<div class="export-options">
|
||||||
<!-- Export Data Card -->
|
|
||||||
<div class="export-card">
|
|
||||||
<div class="export-header">
|
|
||||||
<h4>Export Your Data</h4>
|
|
||||||
<p>
|
|
||||||
Download a copy of your account data including requests, watch
|
|
||||||
history, and preferences.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="export-actions">
|
|
||||||
<button
|
|
||||||
class="export-btn"
|
|
||||||
@click="exportData('json')"
|
|
||||||
:disabled="exporting"
|
|
||||||
>
|
|
||||||
<IconActivity v-if="exporting" class="spin" />
|
|
||||||
<span v-else>Export as JSON</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="export-btn"
|
|
||||||
@click="exportData('csv')"
|
|
||||||
:disabled="exporting"
|
|
||||||
>
|
|
||||||
<IconActivity v-if="exporting" class="spin" />
|
|
||||||
<span v-else>Export as CSV</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Request History Card -->
|
<!-- Request History Card -->
|
||||||
<div class="export-card">
|
<RequestHistory :data="requestStats" />
|
||||||
<div class="export-header">
|
|
||||||
<h4>Request History</h4>
|
|
||||||
<p>View and download your complete request history.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stats-grid">
|
<!-- Export Data Card -->
|
||||||
<div class="stat-mini">
|
<ExportSection :data="requestStats" />
|
||||||
<span class="stat-mini__value">{{ requestStats.total }}</span>
|
|
||||||
<span class="stat-mini__label">Total</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-mini">
|
|
||||||
<span class="stat-mini__value">{{ requestStats.approved }}</span>
|
|
||||||
<span class="stat-mini__label">Approved</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-mini">
|
|
||||||
<span class="stat-mini__value">{{ requestStats.pending }}</span>
|
|
||||||
<span class="stat-mini__label">Pending</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="view-btn" @click="viewHistory">View Full History</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Local Storage Items -->
|
<!-- Local Storage Items -->
|
||||||
<div class="storage-section">
|
<StorageManager />
|
||||||
<h4 class="storage-section__title">Browser Storage</h4>
|
|
||||||
<div class="storage-items">
|
|
||||||
<div
|
|
||||||
v-for="item in storageItems"
|
|
||||||
:key="item.key"
|
|
||||||
class="storage-item"
|
|
||||||
>
|
|
||||||
<div class="storage-item__info">
|
|
||||||
<h5 class="storage-item__title">{{ item.title }}</h5>
|
|
||||||
<p class="storage-item__description">
|
|
||||||
{{ item.description }} ·
|
|
||||||
<span class="storage-item__size">{{ item.size }}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="storage-item__delete"
|
|
||||||
@click="clearItem(item.key, item.title)"
|
|
||||||
:title="`Clear ${item.title}`"
|
|
||||||
>
|
|
||||||
<IconClose />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Clear All Local Data -->
|
|
||||||
<DangerZoneAction
|
|
||||||
title="Clear All Local Data"
|
|
||||||
description="Remove all locally stored data at once. This includes preferences, history, and cached information."
|
|
||||||
button-text="Clear All Data"
|
|
||||||
@action="clearAllData"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Delete Account -->
|
<!-- Delete Account -->
|
||||||
<DangerZoneAction
|
<DangerZoneAction
|
||||||
@@ -110,748 +18,36 @@
|
|||||||
@action="confirmDelete"
|
@action="confirmDelete"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete Confirmation Modal -->
|
|
||||||
<div v-if="showDeleteModal" class="modal-overlay" @click="cancelDelete">
|
|
||||||
<div class="modal-content" @click.stop>
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3>Delete Account</h3>
|
|
||||||
<button class="close-btn" @click="cancelDelete">
|
|
||||||
<IconClose />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="warning-box">
|
|
||||||
<span class="warning-icon">⚠️</span>
|
|
||||||
<p>
|
|
||||||
<strong>Warning:</strong> This action is permanent and cannot be
|
|
||||||
undone.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p>All of the following will be permanently deleted:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Your account and profile information</li>
|
|
||||||
<li>All request history</li>
|
|
||||||
<li>Watch history and preferences</li>
|
|
||||||
<li>Plex account connection</li>
|
|
||||||
</ul>
|
|
||||||
<p>Type <strong>DELETE</strong> to confirm:</p>
|
|
||||||
<input
|
|
||||||
v-model="deleteConfirmation"
|
|
||||||
type="text"
|
|
||||||
placeholder="Type DELETE"
|
|
||||||
class="confirm-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="cancel-btn" @click="cancelDelete">Cancel</button>
|
|
||||||
<button
|
|
||||||
class="confirm-delete-btn"
|
|
||||||
@click="deleteAccount"
|
|
||||||
:disabled="deleteConfirmation !== 'DELETE'"
|
|
||||||
>
|
|
||||||
Delete Account
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, inject } from "vue";
|
import { ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import StorageManager from "./StorageManager.vue";
|
||||||
import { clearCommandHistory } from "@/utils/commandTracking";
|
import ExportSection from "./ExportSection.vue"
|
||||||
import IconActivity from "@/icons/IconActivity.vue";
|
import RequestHistory from "./RequestHistory.vue"
|
||||||
import IconClose from "@/icons/IconClose.vue";
|
|
||||||
import IconInfo from "@/icons/IconInfo.vue";
|
|
||||||
import DangerZoneAction from "./DangerZoneAction.vue";
|
import DangerZoneAction from "./DangerZoneAction.vue";
|
||||||
|
|
||||||
interface StorageItem {
|
|
||||||
key: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
size: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const notifications: {
|
|
||||||
success: (options: {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
timeout?: number;
|
|
||||||
}) => void;
|
|
||||||
error: (options: {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
timeout?: number;
|
|
||||||
}) => void;
|
|
||||||
} = inject("notifications");
|
|
||||||
|
|
||||||
const exporting = ref(false);
|
|
||||||
const showDeleteModal = ref(false);
|
|
||||||
const deleteConfirmation = ref("");
|
|
||||||
|
|
||||||
const requestStats = ref({
|
const requestStats = ref({
|
||||||
total: 45,
|
total: 45,
|
||||||
approved: 38,
|
approved: 38,
|
||||||
pending: 7
|
pending: 7
|
||||||
});
|
});
|
||||||
|
|
||||||
const storageItems = computed<StorageItem[]>(() => {
|
|
||||||
const items: StorageItem[] = [];
|
|
||||||
|
|
||||||
// Command palette stats
|
|
||||||
const commandStats = localStorage.getItem("commandPalette_stats");
|
|
||||||
if (commandStats) {
|
|
||||||
items.push({
|
|
||||||
key: "commandPalette_stats",
|
|
||||||
title: "Command Palette History",
|
|
||||||
description: "Usage statistics for command palette navigation",
|
|
||||||
size: formatBytes(commandStats.length)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plex user data
|
|
||||||
const plexData = localStorage.getItem("plex_user_data");
|
|
||||||
if (plexData) {
|
|
||||||
items.push({
|
|
||||||
key: "plex_user_data",
|
|
||||||
title: "Plex User Data",
|
|
||||||
description: "Cached Plex account information",
|
|
||||||
size: formatBytes(plexData.length)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Theme preference
|
|
||||||
const theme = localStorage.getItem("theme");
|
|
||||||
if (theme) {
|
|
||||||
items.push({
|
|
||||||
key: "theme",
|
|
||||||
title: "Theme Preference",
|
|
||||||
description: "Your selected color theme",
|
|
||||||
size: formatBytes(theme.length)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Color scheme
|
|
||||||
const colorScheme = localStorage.getItem("color-scheme");
|
|
||||||
if (colorScheme) {
|
|
||||||
items.push({
|
|
||||||
key: "color-scheme",
|
|
||||||
title: "Color Scheme",
|
|
||||||
description: "Light or dark mode preference",
|
|
||||||
size: formatBytes(colorScheme.length)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
});
|
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
|
||||||
if (bytes === 0) return "0 Bytes";
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ["Bytes", "KB", "MB"];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function exportData(format: "json" | "csv") {
|
|
||||||
exporting.value = true;
|
|
||||||
|
|
||||||
// Mock export
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
username: "user123",
|
|
||||||
requests: requestStats.value,
|
|
||||||
exportDate: new Date().toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
const blob = new Blob(
|
|
||||||
[format === "json" ? JSON.stringify(data, null, 2) : convertToCSV(data)],
|
|
||||||
{ type: format === "json" ? "application/json" : "text/csv" }
|
|
||||||
);
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = url;
|
|
||||||
link.download = `seasoned-data-export.${format}`;
|
|
||||||
link.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
exporting.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function convertToCSV(data: any): string {
|
|
||||||
return `Username,Total Requests,Approved,Pending,Export Date\n${data.username},${data.requests.total},${data.requests.approved},${data.requests.pending},${data.exportDate}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function viewHistory() {
|
|
||||||
router.push({ name: "profile" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmDelete() {
|
function confirmDelete() {
|
||||||
showDeleteModal.value = true;
|
|
||||||
deleteConfirmation.value = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelDelete() {
|
|
||||||
showDeleteModal.value = false;
|
|
||||||
deleteConfirmation.value = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteAccount() {
|
|
||||||
if (deleteConfirmation.value === "DELETE") {
|
|
||||||
alert("Account deletion would be processed here");
|
|
||||||
showDeleteModal.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearItem(key: string, title: string) {
|
|
||||||
try {
|
|
||||||
// Special handling for command history
|
|
||||||
if (key === "commandPalette_stats") {
|
|
||||||
clearCommandHistory();
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
notifications.success({
|
|
||||||
title: "Data Cleared",
|
|
||||||
description: `${title} has been cleared`,
|
|
||||||
timeout: 3000
|
|
||||||
});
|
|
||||||
|
|
||||||
// Force re-render
|
|
||||||
storageItems.value;
|
|
||||||
} catch (error) {
|
|
||||||
notifications.error({
|
|
||||||
title: "Error",
|
|
||||||
description: `Failed to clear ${title}`,
|
|
||||||
timeout: 5000
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAllData() {
|
|
||||||
const confirmed = confirm(
|
const confirmed = confirm(
|
||||||
"Are you sure you want to clear all locally stored data? This action cannot be undone."
|
"Are you sure you want to *permanently delete* your account and all associated data? This action cannot be undone."
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
try {
|
|
||||||
localStorage.clear();
|
|
||||||
clearCommandHistory();
|
|
||||||
|
|
||||||
notifications.success({
|
|
||||||
title: "All Data Cleared",
|
|
||||||
description: "All locally stored data has been removed",
|
|
||||||
timeout: 3000
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
notifications.error({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to clear all data",
|
|
||||||
timeout: 5000
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import "scss/variables";
|
|
||||||
@import "scss/media-queries";
|
|
||||||
|
|
||||||
.data-export {
|
|
||||||
&__header {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
margin-bottom: 0.85rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__info {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: var(--background-ui);
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
border-left: 3px solid var(--highlight-color);
|
|
||||||
|
|
||||||
.info-icon {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
fill: var(--highlight-color);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-color-70);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-options {
|
.export-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
}
|
gap: 2rem;
|
||||||
|
|
||||||
.export-card {
|
|
||||||
padding: 0.85rem;
|
|
||||||
background-color: var(--background-ui);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
border-left: 3px solid var(--highlight-color);
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-header {
|
|
||||||
margin-bottom: 0.85rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
margin-bottom: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
margin: 0 0 0.25rem 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.55rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-btn {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0.55rem 0.85rem;
|
|
||||||
background-color: var(--highlight-color);
|
|
||||||
color: $white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background-color: var(--color-green-90);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
fill: $white;
|
|
||||||
|
|
||||||
&.spin {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 0.65rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-mini {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.5rem 0.4rem;
|
|
||||||
background-color: var(--background-color);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
padding: 0.45rem 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__value {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--highlight-color);
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__label {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin-top: 0.15rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 0.65rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-btn {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.55rem 0.85rem;
|
|
||||||
background-color: var(--background-color);
|
|
||||||
border: 1px solid var(--background-40);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--background-40);
|
|
||||||
border-color: var(--highlight-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.storage-section {
|
|
||||||
&__title {
|
|
||||||
margin: 0 0 0.65rem 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-color);
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
margin-bottom: 0.6rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.storage-items {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.storage-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0;
|
|
||||||
background: var(--background-color);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
.storage-item__delete {
|
|
||||||
background: var(--color-error-highlight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0.85rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
margin: 0 0 0.25rem 0;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-color);
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__description {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-color-70);
|
|
||||||
line-height: 1.4;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__size {
|
|
||||||
color: var(--text-color-50);
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__delete {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 70px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--color-error);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
width: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
fill: white;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-error-highlight);
|
|
||||||
|
|
||||||
svg {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
svg {
|
|
||||||
transform: scale(0.9);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: rgba(0, 0, 0, 0.7);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
padding: 1rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
padding: 0.5rem;
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
|
||||||
background-color: var(--background-color-secondary);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
max-width: 500px;
|
|
||||||
width: 100%;
|
|
||||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
border-bottom-left-radius: 0;
|
|
||||||
border-bottom-right-radius: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-bottom: 1px solid var(--background-40);
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-weight: 400;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.25rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--background-40);
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
padding: 1.5rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0 0 1rem 0;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
margin: 0 0 1.5rem 0;
|
|
||||||
padding-left: 1.5rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-box {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background-color: var(--color-warning);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-icon {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
color: $black;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem;
|
|
||||||
border: 2px solid var(--background-40);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
background-color: var(--background-color);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--color-error-highlight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
border-top: 1px solid var(--background-40);
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
justify-content: flex-end;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
padding: 1rem;
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: 0.65rem 1.25rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancel-btn {
|
|
||||||
background-color: transparent;
|
|
||||||
border: 1px solid var(--background-40) !important;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--background-ui);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-delete-btn {
|
|
||||||
background-color: var(--color-error-highlight);
|
|
||||||
color: $white;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background-color: var(--color-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
127
src/components/settings/ExportSection.vue
Normal file
127
src/components/settings/ExportSection.vue
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<template>
|
||||||
|
<div class="settings-section-card">
|
||||||
|
<div class="settings-section-header">
|
||||||
|
<h2>Export Your Data</h2>
|
||||||
|
<p>
|
||||||
|
Download a copy of your account data including requests, watch history,
|
||||||
|
and preferences.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Export to JSON & CSV section -->
|
||||||
|
<div class="export-actions">
|
||||||
|
<button
|
||||||
|
class="export-btn"
|
||||||
|
@click="() => exportData('json')"
|
||||||
|
:disabled="exporting"
|
||||||
|
>
|
||||||
|
<IconActivity v-if="exporting" class="spin" />
|
||||||
|
<span v-else>Export as JSON</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="export-btn"
|
||||||
|
@click="() => exportData('csv')"
|
||||||
|
:disabled="exporting"
|
||||||
|
>
|
||||||
|
<IconActivity v-if="exporting" class="spin" />
|
||||||
|
<span v-else>Export as CSV</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, defineProps } from "vue";
|
||||||
|
import IconActivity from "@/icons/IconActivity.vue";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const exporting = ref(false);
|
||||||
|
|
||||||
|
async function exportData(format: "json" | "csv") {
|
||||||
|
exporting.value = true;
|
||||||
|
|
||||||
|
// Mock export
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
username: "user123",
|
||||||
|
requests: props?.data,
|
||||||
|
exportDate: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
const blob = new Blob(
|
||||||
|
[format === "json" ? JSON.stringify(data, null, 2) : convertToCSV(data)],
|
||||||
|
{ type: format === "json" ? "application/json" : "text/csv" }
|
||||||
|
);
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `seasoned-data-export.${format}`;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
exporting.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertToCSV(data: any): string {
|
||||||
|
return `Username,Total Requests,Approved,Pending,Export Date\n${data.username},${data.requests.total},${data.requests.approved},${data.requests.pending},${data.exportDate}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "scss/media-queries";
|
||||||
|
@import "scss/shared-settings";
|
||||||
|
|
||||||
|
.export-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.55rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.55rem 0.85rem;
|
||||||
|
background-color: var(--highlight-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-green-90);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
fill: white;
|
||||||
|
|
||||||
|
&.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="storage-manager">
|
|
||||||
<div class="storage-manager__header">
|
|
||||||
<p class="storage-manager__description">
|
|
||||||
Full transparency and control over your data. Everything listed here is
|
|
||||||
stored locally on your device—no servers, no tracking. You own your
|
|
||||||
data.
|
|
||||||
</p>
|
|
||||||
<div class="storage-manager__info">
|
|
||||||
<IconInfo class="info-icon" />
|
|
||||||
<span
|
|
||||||
>Your browser stores this data to improve your experience. Clear
|
|
||||||
individual items or wipe everything—it's your choice.</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="storage-items">
|
|
||||||
<div v-for="item in storageItems" :key="item.key" class="storage-item">
|
|
||||||
<div class="storage-item__info">
|
|
||||||
<h4 class="storage-item__title">{{ item.title }}</h4>
|
|
||||||
<p class="storage-item__description">
|
|
||||||
{{ item.description }} ·
|
|
||||||
<span class="storage-item__size">{{ item.size }}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="storage-item__delete"
|
|
||||||
@click="clearItem(item.key, item.title)"
|
|
||||||
:title="`Clear ${item.title}`"
|
|
||||||
>
|
|
||||||
<IconClose />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DangerZoneAction
|
|
||||||
title="Clear Everything"
|
|
||||||
description="Remove all locally stored data at once. This includes preferences, history, and cached information."
|
|
||||||
button-text="Clear All Data"
|
|
||||||
@action="clearAllData"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, inject } from "vue";
|
|
||||||
import { clearCommandHistory } from "@/utils/commandTracking";
|
|
||||||
import IconInfo from "@/icons/IconInfo.vue";
|
|
||||||
import IconClose from "@/icons/IconClose.vue";
|
|
||||||
import DangerZoneAction from "./DangerZoneAction.vue";
|
|
||||||
|
|
||||||
interface StorageItem {
|
|
||||||
key: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
size: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifications: {
|
|
||||||
success: (options: {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
timeout?: number;
|
|
||||||
}) => void;
|
|
||||||
error: (options: {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
timeout?: number;
|
|
||||||
}) => void;
|
|
||||||
} = inject("notifications");
|
|
||||||
|
|
||||||
const storageItems = computed<StorageItem[]>(() => {
|
|
||||||
const items: StorageItem[] = [];
|
|
||||||
|
|
||||||
// Command palette stats
|
|
||||||
const commandStats = localStorage.getItem("commandPalette_stats");
|
|
||||||
if (commandStats) {
|
|
||||||
items.push({
|
|
||||||
key: "commandPalette_stats",
|
|
||||||
title: "Command Palette History",
|
|
||||||
description: "Usage statistics for command palette navigation",
|
|
||||||
size: formatBytes(commandStats.length)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plex user data
|
|
||||||
const plexData = localStorage.getItem("plex_user_data");
|
|
||||||
if (plexData) {
|
|
||||||
items.push({
|
|
||||||
key: "plex_user_data",
|
|
||||||
title: "Plex User Data",
|
|
||||||
description: "Cached Plex account information",
|
|
||||||
size: formatBytes(plexData.length)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Theme preference
|
|
||||||
const theme = localStorage.getItem("theme");
|
|
||||||
if (theme) {
|
|
||||||
items.push({
|
|
||||||
key: "theme",
|
|
||||||
title: "Theme Preference",
|
|
||||||
description: "Your selected color theme",
|
|
||||||
size: formatBytes(theme.length)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Color scheme
|
|
||||||
const colorScheme = localStorage.getItem("color-scheme");
|
|
||||||
if (colorScheme) {
|
|
||||||
items.push({
|
|
||||||
key: "color-scheme",
|
|
||||||
title: "Color Scheme",
|
|
||||||
description: "Light or dark mode preference",
|
|
||||||
size: formatBytes(colorScheme.length)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
});
|
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
|
||||||
if (bytes === 0) return "0 Bytes";
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ["Bytes", "KB", "MB"];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearItem(key: string, title: string) {
|
|
||||||
try {
|
|
||||||
// Special handling for command history
|
|
||||||
if (key === "commandPalette_stats") {
|
|
||||||
clearCommandHistory();
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
notifications.success({
|
|
||||||
title: "Data Cleared",
|
|
||||||
description: `${title} has been cleared`,
|
|
||||||
timeout: 3000
|
|
||||||
});
|
|
||||||
|
|
||||||
// Force re-render
|
|
||||||
storageItems.value;
|
|
||||||
} catch (error) {
|
|
||||||
notifications.error({
|
|
||||||
title: "Error",
|
|
||||||
description: `Failed to clear ${title}`,
|
|
||||||
timeout: 5000
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAllData() {
|
|
||||||
const confirmed = confirm(
|
|
||||||
"Are you sure you want to clear all locally stored data? This action cannot be undone."
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
localStorage.clear();
|
|
||||||
clearCommandHistory();
|
|
||||||
|
|
||||||
notifications.success({
|
|
||||||
title: "All Data Cleared",
|
|
||||||
description: "All locally stored data has been removed",
|
|
||||||
timeout: 3000
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
notifications.error({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to clear all data",
|
|
||||||
timeout: 5000
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
@import "scss/variables";
|
|
||||||
@import "scss/media-queries";
|
|
||||||
|
|
||||||
.storage-manager {
|
|
||||||
&__header {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__description {
|
|
||||||
margin: 0 0 0.75rem 0;
|
|
||||||
color: var(--text-color);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__info {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: var(--background-ui);
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
border-left: 3px solid var(--highlight-color);
|
|
||||||
|
|
||||||
.info-icon {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
fill: var(--highlight-color);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-color-70);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.storage-items {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.storage-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0;
|
|
||||||
background: var(--background-ui);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
.storage-item__delete {
|
|
||||||
background: var(--color-error-highlight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0.85rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
margin: 0 0 0.3rem 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-color);
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__description {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-color-70);
|
|
||||||
line-height: 1.4;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__size {
|
|
||||||
color: var(--text-color-50);
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__delete {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 70px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--color-error);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
width: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
fill: white;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-error-highlight);
|
|
||||||
|
|
||||||
svg {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
svg {
|
|
||||||
transform: scale(0.9);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -36,7 +36,10 @@
|
|||||||
|
|
||||||
<div class="generator-options">
|
<div class="generator-options">
|
||||||
<div class="option-row">
|
<div class="option-row">
|
||||||
<label>Number of words: {{ wordCount }}</label>
|
<div class="slider-header">
|
||||||
|
<label>Words</label>
|
||||||
|
<span class="slider-value">{{ wordCount }}</span>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
v-model.number="wordCount"
|
v-model.number="wordCount"
|
||||||
type="range"
|
type="range"
|
||||||
@@ -45,7 +48,10 @@
|
|||||||
class="slider"
|
class="slider"
|
||||||
@input="generateWordsPassword"
|
@input="generateWordsPassword"
|
||||||
/>
|
/>
|
||||||
<span class="option-value">{{ wordCount }}</span>
|
<div class="slider-labels">
|
||||||
|
<span>3</span>
|
||||||
|
<span>7</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,7 +76,10 @@
|
|||||||
|
|
||||||
<div class="generator-options">
|
<div class="generator-options">
|
||||||
<div class="option-row">
|
<div class="option-row">
|
||||||
<label>Length: {{ charLength }}</label>
|
<div class="slider-header">
|
||||||
|
<label>Length</label>
|
||||||
|
<span class="slider-value">{{ charLength }}</span>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
v-model.number="charLength"
|
v-model.number="charLength"
|
||||||
type="range"
|
type="range"
|
||||||
@@ -79,7 +88,10 @@
|
|||||||
class="slider"
|
class="slider"
|
||||||
@input="generateCharsPassword"
|
@input="generateCharsPassword"
|
||||||
/>
|
/>
|
||||||
<span class="option-value">{{ charLength }}</span>
|
<div class="slider-labels">
|
||||||
|
<span>12</span>
|
||||||
|
<span>46</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="option-row checkbox-row">
|
<div class="option-row checkbox-row">
|
||||||
@@ -133,7 +145,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted } from "vue";
|
import { ref, watch, onMounted } from "vue";
|
||||||
import IconActivity from "@/icons/IconActivity.vue";
|
import IconActivity from "@/icons/IconActivity.vue";
|
||||||
import { useRandomWords } from "@/composables/useRandomWords";
|
import { useRandomWords } from "@/composables/useRandomWords";
|
||||||
|
|
||||||
@@ -360,12 +372,12 @@
|
|||||||
.option-row {
|
.option-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 0.75rem;
|
||||||
|
|
||||||
label {
|
label {
|
||||||
font-size: 0.85rem;
|
font-size: 0.95rem;
|
||||||
color: $text-color;
|
color: $text-color;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,38 +408,121 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.slider-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-value {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--highlight-color);
|
||||||
|
min-width: 2.5rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-labels {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: $text-color-50;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.slider {
|
.slider {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 6px;
|
height: 10px;
|
||||||
border-radius: 3px;
|
border-radius: 5px;
|
||||||
background: var(--background-40);
|
background: var(--background-40);
|
||||||
outline: none;
|
outline: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--background-40);
|
||||||
|
}
|
||||||
|
|
||||||
&::-webkit-slider-thumb {
|
&::-webkit-slider-thumb {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
width: 18px;
|
width: 24px;
|
||||||
height: 18px;
|
height: 24px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--highlight-color);
|
background: var(--highlight-color);
|
||||||
cursor: pointer;
|
cursor: grab;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: all 0.2s;
|
||||||
|
margin-top: -7px;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
margin-top: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-moz-range-thumb {
|
&::-moz-range-thumb {
|
||||||
width: 18px;
|
width: 24px;
|
||||||
height: 18px;
|
height: 24px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--highlight-color);
|
background: var(--highlight-color);
|
||||||
cursor: pointer;
|
cursor: grab;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
}
|
transition: all 0.2s;
|
||||||
|
|
||||||
.option-value {
|
@include mobile-only {
|
||||||
font-size: 0.9rem;
|
width: 28px;
|
||||||
font-weight: 600;
|
height: 28px;
|
||||||
color: var(--highlight-color);
|
}
|
||||||
text-align: center;
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-slider-runnable-track {
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-range-track {
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--background-40);
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.separator-input {
|
.separator-input {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="plex-settings">
|
<div class="plex-settings">
|
||||||
<!-- Unconnected state -->
|
<!-- Unconnected state -->
|
||||||
<PlexAuthButton
|
<PlexAuthButton
|
||||||
v-if="!isPlexConnected"
|
v-if="!showPlexInformation"
|
||||||
@auth-success="handleAuthSuccess"
|
@auth-success="handleAuthSuccess"
|
||||||
@auth-error="handleAuthError"
|
@auth-error="handleAuthError"
|
||||||
/>
|
/>
|
||||||
@@ -16,20 +16,20 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<PlexLibraryStats
|
<PlexLibraryStats
|
||||||
:movies="libraryStats.movies"
|
:movies="libraryStats?.movies"
|
||||||
:shows="libraryStats.shows"
|
:shows="libraryStats?.['tv shows']"
|
||||||
:music="libraryStats.music"
|
:music="libraryStats?.music"
|
||||||
:watchtime="libraryStats.watchtime"
|
:watchtime="libraryStats?.watchtime || 0"
|
||||||
:loading="loadingLibraries"
|
:loading="syncingLibrary"
|
||||||
@open-library="showLibraryDetails"
|
@open-library="showLibraryDetails"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PlexServerInfo
|
<PlexServerInfo
|
||||||
:serverName="plexServer"
|
:serverName="plexServer"
|
||||||
:lastSync="lastSync"
|
:lastSync="lastSync"
|
||||||
:syncing="syncing"
|
:syncing="syncingServer"
|
||||||
@sync="syncLibrary"
|
@sync="syncLibrary"
|
||||||
@unlink="confirmUnlink"
|
@unlink="() => (showUnlinkModal = true)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -38,16 +38,18 @@
|
|||||||
|
|
||||||
<!-- Unlink Confirmation Modal -->
|
<!-- Unlink Confirmation Modal -->
|
||||||
<PlexUnlinkModal
|
<PlexUnlinkModal
|
||||||
v-if="showConfirmModal"
|
v-if="showUnlinkModal"
|
||||||
@confirm="unauthenticatePlex"
|
@confirm="unauthenticatePlex"
|
||||||
@cancel="cancelUnlink"
|
@cancel="() => (showUnlinkModal = false)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Library Details Modal -->
|
<!-- Library Details Modal -->
|
||||||
<PlexLibraryModal
|
<PlexLibraryModal
|
||||||
v-if="showLibraryModal && selectedLibrary"
|
v-if="showLibraryModal && selectedLibrary"
|
||||||
:libraryType="selectedLibrary"
|
:libraryType="selectedLibrary"
|
||||||
:details="libraryDetails[selectedLibrary]"
|
:details="libraryStats[selectedLibrary]"
|
||||||
|
:serverUrl="plexServerUrl"
|
||||||
|
:serverMachineId="plexMachineId"
|
||||||
@close="closeLibraryModal"
|
@close="closeLibraryModal"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,7 +57,6 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from "vue";
|
import { ref, onMounted, onUnmounted } from "vue";
|
||||||
import { useStore } from "vuex";
|
|
||||||
import SeasonedMessages from "@/components/ui/SeasonedMessages.vue";
|
import SeasonedMessages from "@/components/ui/SeasonedMessages.vue";
|
||||||
import PlexAuthButton from "@/components/plex/PlexAuthButton.vue";
|
import PlexAuthButton from "@/components/plex/PlexAuthButton.vue";
|
||||||
import PlexProfileCard from "@/components/plex/PlexProfileCard.vue";
|
import PlexProfileCard from "@/components/plex/PlexProfileCard.vue";
|
||||||
@@ -64,184 +65,167 @@
|
|||||||
import PlexUnlinkModal from "@/components/plex/PlexUnlinkModal.vue";
|
import PlexUnlinkModal from "@/components/plex/PlexUnlinkModal.vue";
|
||||||
import PlexLibraryModal from "@/components/plex/PlexLibraryModal.vue";
|
import PlexLibraryModal from "@/components/plex/PlexLibraryModal.vue";
|
||||||
import { usePlexAuth } from "@/composables/usePlexAuth";
|
import { usePlexAuth } from "@/composables/usePlexAuth";
|
||||||
import { usePlexApi } from "@/composables/usePlexApi";
|
import {
|
||||||
import { usePlexLibraries } from "@/composables/usePlexLibraries";
|
fetchPlexServers,
|
||||||
|
fetchPlexUserData,
|
||||||
|
fetchLibraryDetails
|
||||||
|
} from "@/composables/usePlexApi";
|
||||||
import type { Ref } from "vue";
|
import type { Ref } from "vue";
|
||||||
import { linkPlexAccount, unlinkPlexAccount } from "../../api";
|
|
||||||
import { ErrorMessageTypes } from "../../interfaces/IErrorMessage";
|
import { ErrorMessageTypes } from "../../interfaces/IErrorMessage";
|
||||||
import type { IErrorMessage } from "../../interfaces/IErrorMessage";
|
import type { IErrorMessage } from "../../interfaces/IErrorMessage";
|
||||||
|
|
||||||
const messages: Ref<IErrorMessage[]> = ref([]);
|
const messages: Ref<IErrorMessage[]> = ref([]);
|
||||||
const loading = ref(false);
|
const syncingServer = ref(false);
|
||||||
const syncing = ref(false);
|
const syncingLibrary = ref(false);
|
||||||
const showConfirmModal = ref(false);
|
const showUnlinkModal = ref(false);
|
||||||
const plexUsername = ref<string>("");
|
const plexUsername = ref<string>("");
|
||||||
const plexUserData = ref<any>(null);
|
const plexUserData = ref<any>(null);
|
||||||
const isPlexConnected = ref<boolean>(false);
|
const showPlexInformation = ref<boolean>(false);
|
||||||
const hasLocalStorageData = ref<boolean>(false);
|
const hasLocalStorageData = ref<boolean>(false);
|
||||||
const hasCookieData = ref<boolean>(false);
|
|
||||||
const showLibraryModal = ref<boolean>(false);
|
const showLibraryModal = ref<boolean>(false);
|
||||||
const selectedLibrary = ref<string>("");
|
const selectedLibrary = ref<string>("");
|
||||||
const loadingLibraries = ref<boolean>(false);
|
|
||||||
|
|
||||||
const plexServer = ref("");
|
const plexServer = ref("");
|
||||||
const plexServerUrl = ref("");
|
const plexServerUrl = ref("");
|
||||||
const plexMachineId = ref("");
|
const plexMachineId = ref("");
|
||||||
const lastSync = ref("");
|
const lastSync = ref(sessionStorage.getItem("plex_library_last_sync"));
|
||||||
const libraryStats = ref({
|
const libraryStats = ref({
|
||||||
movies: 0,
|
movies: 0,
|
||||||
shows: 0,
|
shows: 0,
|
||||||
music: 0,
|
music: 0,
|
||||||
watchtime: 0
|
watchtime: 0
|
||||||
});
|
});
|
||||||
const libraryDetails = ref<any>({
|
|
||||||
movies: {
|
|
||||||
total: 0,
|
|
||||||
recentlyAdded: [],
|
|
||||||
genres: [],
|
|
||||||
totalDuration: "0 hours"
|
|
||||||
},
|
|
||||||
shows: {
|
|
||||||
total: 0,
|
|
||||||
recentlyAdded: [],
|
|
||||||
genres: [],
|
|
||||||
totalEpisodes: 0,
|
|
||||||
totalDuration: "0 hours"
|
|
||||||
},
|
|
||||||
music: {
|
|
||||||
total: 0,
|
|
||||||
recentlyAdded: [],
|
|
||||||
genres: [],
|
|
||||||
totalTracks: 0
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const store = useStore();
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "reload"): void;
|
(e: "reload"): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Composables
|
// Composables
|
||||||
const { getCookie, setPlexAuthCookie, cleanup } = usePlexAuth();
|
const { getPlexAuthCookie, setPlexAuthCookie, cleanup } = usePlexAuth();
|
||||||
const {
|
|
||||||
fetchPlexUserData,
|
|
||||||
fetchPlexServers,
|
|
||||||
fetchLibrarySections,
|
|
||||||
fetchLibraryDetails
|
|
||||||
} = usePlexApi();
|
|
||||||
const { loadLibraries } = usePlexLibraries();
|
|
||||||
|
|
||||||
// ----- Connection check -----
|
// ----- Connection check -----
|
||||||
function checkPlexConnection() {
|
function checkPlexConnection() {
|
||||||
const cachedData = localStorage.getItem("plex_user_data");
|
const authToken = getPlexAuthCookie();
|
||||||
const authToken = getCookie("plex_auth_token");
|
showPlexInformation.value = !!authToken;
|
||||||
const storeHasPlexUserId = store.getters["user/plexUserId"];
|
return showPlexInformation.value;
|
||||||
hasLocalStorageData.value = !!cachedData;
|
|
||||||
hasCookieData.value = !!authToken;
|
|
||||||
isPlexConnected.value = !!(cachedData || authToken || storeHasPlexUserId);
|
|
||||||
return isPlexConnected.value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Library loading -----
|
// ----- Library loading -----
|
||||||
async function fetchPlexLibraries(authToken: string) {
|
async function loadPlexServer() {
|
||||||
try {
|
// return cached value from sessionStorage if exists
|
||||||
loadingLibraries.value = true;
|
const cacheKey = "plex_server_data";
|
||||||
const server = await fetchPlexServers(authToken);
|
const cachedData = sessionStorage.getItem(cacheKey);
|
||||||
if (!server) {
|
if (cachedData) {
|
||||||
console.error("No Plex server found");
|
const server = JSON.parse(cachedData);
|
||||||
return;
|
plexServer.value = server?.name;
|
||||||
}
|
plexServerUrl.value = server?.url;
|
||||||
|
plexMachineId.value = server?.machineIdentifier;
|
||||||
plexServer.value = server.name;
|
return;
|
||||||
plexServerUrl.value = server.url;
|
|
||||||
plexMachineId.value = server.machineIdentifier;
|
|
||||||
lastSync.value = new Date().toLocaleString();
|
|
||||||
|
|
||||||
const sections = await fetchLibrarySections(authToken, server.url);
|
|
||||||
if (!sections || sections.length === 0) {
|
|
||||||
console.error("No library sections found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await loadLibraries(
|
|
||||||
sections,
|
|
||||||
authToken,
|
|
||||||
server.url,
|
|
||||||
server.machineIdentifier,
|
|
||||||
plexUsername.value,
|
|
||||||
fetchLibraryDetails
|
|
||||||
);
|
|
||||||
|
|
||||||
libraryStats.value = result.stats;
|
|
||||||
libraryDetails.value = result.details;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[PlexSettings] Error fetching Plex libraries:", error);
|
|
||||||
} finally {
|
|
||||||
loadingLibraries.value = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// get token from cookie
|
||||||
|
const authToken = getPlexAuthCookie();
|
||||||
|
if (!authToken) return;
|
||||||
|
|
||||||
|
// make api call for data
|
||||||
|
syncingServer.value = true;
|
||||||
|
const server = await fetchPlexServers(authToken);
|
||||||
|
|
||||||
|
if (server) {
|
||||||
|
// set server name & id
|
||||||
|
plexServer.value = server?.name;
|
||||||
|
plexServerUrl.value = server?.url;
|
||||||
|
plexMachineId.value = server?.machineIdentifier;
|
||||||
|
// cache in sessionStorage
|
||||||
|
sessionStorage.setItem(cacheKey, JSON.stringify(server));
|
||||||
|
|
||||||
|
// set last-sync date
|
||||||
|
const now = new Date().toLocaleString();
|
||||||
|
lastSync.value = now;
|
||||||
|
sessionStorage.setItem("plex_library_last_sync", now);
|
||||||
|
} else {
|
||||||
|
console.log("unable to load plex server informmation");
|
||||||
|
}
|
||||||
|
|
||||||
|
syncingServer.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- User data loading -----
|
// ----- User data loading -----
|
||||||
async function loadPlexUserData() {
|
async function loadPlexUserData() {
|
||||||
checkPlexConnection();
|
// return cached value from sessionStorage if exists
|
||||||
const cachedData = localStorage.getItem("plex_user_data");
|
const cacheKey = "plex_user_data";
|
||||||
|
const cachedData = sessionStorage.getItem(cacheKey);
|
||||||
hasLocalStorageData.value = !!cachedData;
|
hasLocalStorageData.value = !!cachedData;
|
||||||
if (cachedData) {
|
if (cachedData) {
|
||||||
try {
|
plexUserData.value = JSON.parse(cachedData);
|
||||||
plexUserData.value = JSON.parse(cachedData);
|
plexUsername.value = plexUserData.value.username;
|
||||||
plexUsername.value = plexUserData.value.username;
|
return;
|
||||||
isPlexConnected.value = true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[PlexSettings] Error parsing cached Plex data:", error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const authToken = getCookie("plex_auth_token");
|
|
||||||
hasCookieData.value = !!authToken;
|
// get token from cookie
|
||||||
if (authToken) {
|
const authToken = getPlexAuthCookie();
|
||||||
const userData = await fetchPlexUserData(authToken);
|
if (!authToken) return;
|
||||||
if (userData) {
|
|
||||||
plexUserData.value = userData;
|
// make api call for data
|
||||||
plexUsername.value = userData.username;
|
const userData = await fetchPlexUserData(authToken);
|
||||||
isPlexConnected.value = true;
|
|
||||||
} else if (!cachedData) {
|
if (userData) {
|
||||||
isPlexConnected.value = false;
|
// set plex user data
|
||||||
}
|
plexUserData.value = userData;
|
||||||
if (isPlexConnected.value) {
|
plexUsername.value = userData?.username;
|
||||||
await fetchPlexLibraries(authToken);
|
|
||||||
}
|
// cache in sessionStorage
|
||||||
|
sessionStorage.setItem(cacheKey, JSON.stringify(userData));
|
||||||
} else {
|
} else {
|
||||||
isPlexConnected.value = false;
|
console.log("unable to load user data from plex");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- Load plex libary details -----
|
||||||
|
async function loadPlexLibraries() {
|
||||||
|
// return cached value from sessionStorage if exists
|
||||||
|
const cacheKey = "plex_library_data";
|
||||||
|
const cachedData = sessionStorage.getItem(cacheKey);
|
||||||
|
hasLocalStorageData.value = !!cachedData;
|
||||||
|
if (cachedData) {
|
||||||
|
libraryStats.value = JSON.parse(cachedData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get token from cookie
|
||||||
|
const authToken = getPlexAuthCookie();
|
||||||
|
if (!authToken) return;
|
||||||
|
|
||||||
|
// make api call for data
|
||||||
|
syncingLibrary.value = true;
|
||||||
|
const library = await fetchLibraryDetails();
|
||||||
|
|
||||||
|
if (library) {
|
||||||
|
libraryStats.value = library;
|
||||||
|
// cache in sessionStorage
|
||||||
|
sessionStorage.setItem(cacheKey, JSON.stringify(library));
|
||||||
|
} else {
|
||||||
|
console.log("unable to load plex library details");
|
||||||
|
}
|
||||||
|
|
||||||
|
syncingLibrary.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
// ----- OAuth flow (handlers for PlexAuthButton events) -----
|
// ----- OAuth flow (handlers for PlexAuthButton events) -----
|
||||||
async function handleAuthSuccess(authToken: string) {
|
async function handleAuthSuccess(authToken: string) {
|
||||||
try {
|
setPlexAuthCookie(authToken);
|
||||||
setPlexAuthCookie(authToken);
|
checkPlexConnection();
|
||||||
const userData = await fetchPlexUserData(authToken);
|
const success = await loadAll();
|
||||||
if (userData) {
|
|
||||||
plexUserData.value = userData;
|
if (success) {
|
||||||
plexUsername.value = userData.username;
|
messages.value.push({
|
||||||
isPlexConnected.value = true;
|
type: ErrorMessageTypes.Success,
|
||||||
}
|
title: "Authenticated with Plex",
|
||||||
const { success, message } = await linkPlexAccount(authToken);
|
message: "Successfully connected your Plex account"
|
||||||
if (success) {
|
} as IErrorMessage);
|
||||||
emit("reload");
|
} else {
|
||||||
await fetchPlexLibraries(authToken);
|
console.error("[PlexSettings] Error in handleAuthSuccess:");
|
||||||
messages.value.push({
|
|
||||||
type: ErrorMessageTypes.Success,
|
|
||||||
title: "Authenticated with Plex",
|
|
||||||
message: message || "Successfully connected your Plex account"
|
|
||||||
} as IErrorMessage);
|
|
||||||
} else {
|
|
||||||
messages.value.push({
|
|
||||||
type: ErrorMessageTypes.Error,
|
|
||||||
title: "Authentication failed",
|
|
||||||
message: message || "Could not connect to Plex"
|
|
||||||
} as IErrorMessage);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[PlexSettings] Error in handleAuthSuccess:", error);
|
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
type: ErrorMessageTypes.Error,
|
type: ErrorMessageTypes.Error,
|
||||||
title: "Authentication failed",
|
title: "Authentication failed",
|
||||||
@@ -259,35 +243,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ----- Unlink flow -----
|
// ----- Unlink flow -----
|
||||||
function confirmUnlink() {
|
|
||||||
showConfirmModal.value = true;
|
|
||||||
}
|
|
||||||
function cancelUnlink() {
|
|
||||||
showConfirmModal.value = false;
|
|
||||||
}
|
|
||||||
async function unauthenticatePlex() {
|
async function unauthenticatePlex() {
|
||||||
showConfirmModal.value = false;
|
showUnlinkModal.value = false;
|
||||||
loading.value = true;
|
sessionStorage.removeItem("plex_user_data");
|
||||||
const response = await unlinkPlexAccount();
|
sessionStorage.removeItem("plex_server_data");
|
||||||
if (response?.success) {
|
sessionStorage.removeItem("plex_library_data");
|
||||||
localStorage.removeItem("plex_user_data");
|
sessionStorage.removeItem("plex_library_last_sync");
|
||||||
document.cookie =
|
document.cookie =
|
||||||
"plex_auth_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Strict";
|
"plex_auth_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Strict";
|
||||||
plexUserData.value = null;
|
plexUserData.value = null;
|
||||||
plexUsername.value = "";
|
plexUsername.value = "";
|
||||||
isPlexConnected.value = false;
|
showPlexInformation.value = false;
|
||||||
emit("reload");
|
emit("reload");
|
||||||
}
|
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
type: response.success
|
type: ErrorMessageTypes.Success,
|
||||||
? ErrorMessageTypes.Success
|
title: "Unlinked Plex account",
|
||||||
: ErrorMessageTypes.Error,
|
message: "All browser storage has been clear of plex account"
|
||||||
title: response.success
|
|
||||||
? "Unlinked Plex account"
|
|
||||||
: "Something went wrong",
|
|
||||||
message: response.message
|
|
||||||
} as IErrorMessage);
|
} as IErrorMessage);
|
||||||
loading.value = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Library modal -----
|
// ----- Library modal -----
|
||||||
@@ -304,39 +277,60 @@
|
|||||||
|
|
||||||
// ----- Sync -----
|
// ----- Sync -----
|
||||||
async function syncLibrary() {
|
async function syncLibrary() {
|
||||||
syncing.value = true;
|
const authToken = getPlexAuthCookie();
|
||||||
const authToken = getCookie("plex_auth_token");
|
|
||||||
if (!authToken) {
|
if (!authToken) {
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
type: ErrorMessageTypes.Error,
|
type: ErrorMessageTypes.Error,
|
||||||
title: "Sync failed",
|
title: "Sync failed",
|
||||||
message: "No authentication token found"
|
message: "No authentication token found"
|
||||||
} as IErrorMessage);
|
} as IErrorMessage);
|
||||||
syncing.value = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
await fetchPlexLibraries(authToken);
|
sessionStorage.removeItem("plex_user_data");
|
||||||
|
sessionStorage.removeItem("plex_server_data");
|
||||||
|
sessionStorage.removeItem("plex_library_data");
|
||||||
|
|
||||||
|
const success = await loadAll();
|
||||||
|
|
||||||
|
if (success) {
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
type: ErrorMessageTypes.Success,
|
type: ErrorMessageTypes.Success,
|
||||||
title: "Library synced",
|
title: "Library synced",
|
||||||
message: "Your Plex library has been successfully synced"
|
message: "Your Plex library has been successfully synced"
|
||||||
} as IErrorMessage);
|
} as IErrorMessage);
|
||||||
} catch (error) {
|
} else {
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
type: ErrorMessageTypes.Error,
|
type: ErrorMessageTypes.Error,
|
||||||
title: "Sync failed",
|
title: "Sync failed",
|
||||||
message: "An error occurred while syncing your library"
|
message: "An error occurred while syncing your library"
|
||||||
} as IErrorMessage);
|
} as IErrorMessage);
|
||||||
} finally {
|
|
||||||
syncing.value = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
// ---- Helper load all ----
|
||||||
|
async function loadAll() {
|
||||||
|
let success = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
loadPlexServer(),
|
||||||
|
loadPlexUserData(),
|
||||||
|
loadPlexLibraries()
|
||||||
|
]);
|
||||||
|
|
||||||
|
success = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.log("loadall error, some info might be missing");
|
||||||
|
}
|
||||||
|
|
||||||
checkPlexConnection();
|
checkPlexConnection();
|
||||||
loadPlexUserData();
|
return success;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
// ---- Lifecycle functions ----
|
||||||
|
onMounted(loadAll);
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|||||||
233
src/components/settings/ProfileHero.vue
Normal file
233
src/components/settings/ProfileHero.vue
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
<template>
|
||||||
|
<div class="profile-hero">
|
||||||
|
<div class="profile-hero__main">
|
||||||
|
<div class="profile-hero__avatar">
|
||||||
|
<div class="avatar-large">{{ userInitials }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="profile-hero__info">
|
||||||
|
<h1 class="profile-hero__name">{{ username }}</h1>
|
||||||
|
<span :class="['profile-hero__badge', `badge--${userRole}`]">
|
||||||
|
<a v-if="userRole === 'admin'" href="/admin">{{ userRole }}</a>
|
||||||
|
<span v-else>{{ userRole }}</span>
|
||||||
|
</span>
|
||||||
|
<p class="profile-hero__member">Member since {{ memberSince }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-hero__stats">
|
||||||
|
<div class="stat-large">
|
||||||
|
<span class="stat-large__value">{{ stats.totalRequests }}</span>
|
||||||
|
<span class="stat-large__label">Requests</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-divider"></div>
|
||||||
|
<div class="stat-large">
|
||||||
|
<span class="stat-large__value">{{ stats.magnetsAdded }}</span>
|
||||||
|
<span class="stat-large__label">Magnets Added</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useStore } from "vuex";
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
const username = computed(() => store.getters["user/username"] || "User");
|
||||||
|
const userRole = computed(() =>
|
||||||
|
store.getters["user/admin"] ? "admin" : "user"
|
||||||
|
);
|
||||||
|
|
||||||
|
const userInitials = computed(() => {
|
||||||
|
return username.value.slice(0, 2).toUpperCase();
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberSince = computed(() => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setMonth(date.getMonth() - 6);
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
year: "numeric"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
totalRequests: 45,
|
||||||
|
magnetsAdded: 127
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "scss/media-queries";
|
||||||
|
|
||||||
|
.profile-hero {
|
||||||
|
background-color: var(--background-color-secondary);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px solid var(--background-40);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 2rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.5rem 1.25rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.1;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.7rem;
|
||||||
|
border-radius: 2rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
width: fit-content;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.badge--admin {
|
||||||
|
background-color: var(--color-warning);
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.badge--user {
|
||||||
|
background-color: var(--background-40);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__member {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-color-70);
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.75rem;
|
||||||
|
padding-left: 1.75rem;
|
||||||
|
border-left: 1px solid var(--background-40);
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem 0 0 0;
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid var(--background-40);
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-large {
|
||||||
|
width: 70px;
|
||||||
|
height: 70px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--highlight-color),
|
||||||
|
var(--color-green-70)
|
||||||
|
);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-large {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--highlight-color);
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-color-70);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 45px;
|
||||||
|
background-color: var(--background-40);
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
height: 45px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
103
src/components/settings/RequestHistory.vue
Normal file
103
src/components/settings/RequestHistory.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<template>
|
||||||
|
<div class="export-card">
|
||||||
|
<div class="settings-section-header">
|
||||||
|
<h2>Request History</h2>
|
||||||
|
<p>View and download your complete request history.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-mini">
|
||||||
|
<span class="stat-mini__value">{{ data.total }}</span>
|
||||||
|
<span class="stat-mini__label">Total</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-mini">
|
||||||
|
<span class="stat-mini__value">{{ data.approved }}</span>
|
||||||
|
<span class="stat-mini__label">Approved</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-mini">
|
||||||
|
<span class="stat-mini__value">{{ data.pending }}</span>
|
||||||
|
<span class="stat-mini__label">Pending</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="view-btn" @click="viewHistory">View Full History</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { defineProps } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function viewHistory() {
|
||||||
|
router.push({ name: "profile" });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "scss/media-queries";
|
||||||
|
@import "scss/shared-settings";
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-mini {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0.4rem;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
padding: 0.45rem 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--highlight-color);
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.55rem 0.85rem;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
border: 1px solid var(--background-40);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
color: var(--text-color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--background-40);
|
||||||
|
border-color: var(--highlight-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
46
src/components/settings/SecuritySettings.vue
Normal file
46
src/components/settings/SecuritySettings.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<template>
|
||||||
|
<div class="security-settings">
|
||||||
|
<div class="security-settings__intro">
|
||||||
|
<h2 class="security-settings__title">Security</h2>
|
||||||
|
<p class="security-settings__description">
|
||||||
|
Keep your account safe by using a strong, unique password. We recommend
|
||||||
|
using a passphrase or generated password that's hard to guess.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<change-password />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ChangePassword from "@/components/profile/ChangePassword.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "scss/variables";
|
||||||
|
@import "scss/media-queries";
|
||||||
|
|
||||||
|
.security-settings {
|
||||||
|
&__intro {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-color-70);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
215
src/components/settings/StorageManager.vue
Normal file
215
src/components/settings/StorageManager.vue
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
<template>
|
||||||
|
<div class="storage-manager">
|
||||||
|
<StorageSectionBrowser
|
||||||
|
:sections="storageSections"
|
||||||
|
@clear-item="clearItem"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DangerZoneAction
|
||||||
|
title="Clear All Browser Data"
|
||||||
|
description="Remove all locally stored data at once. This includes preferences, history, and cached information."
|
||||||
|
button-text="Clear All Data"
|
||||||
|
@action="clearAllData"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, inject } from "vue";
|
||||||
|
import IconCookie from "@/icons/IconCookie.vue";
|
||||||
|
import IconDatabase from "@/icons/IconDatabase.vue";
|
||||||
|
import IconTimer from "@/icons/IconTimer.vue";
|
||||||
|
import StorageSectionBrowser from "./StorageSectionBrowser.vue";
|
||||||
|
import DangerZoneAction from "./DangerZoneAction.vue";
|
||||||
|
import { formatBytes } from "../../utils";
|
||||||
|
|
||||||
|
interface StorageItem {
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
size: string;
|
||||||
|
type: "local" | "session" | "cookie";
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifications: {
|
||||||
|
success: (options: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
timeout?: number;
|
||||||
|
}) => void;
|
||||||
|
error: (options: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
timeout?: number;
|
||||||
|
}) => void;
|
||||||
|
} = inject("notifications");
|
||||||
|
|
||||||
|
const dict = {
|
||||||
|
commandPalette_stats: "Usage statistics for command palette navigation",
|
||||||
|
"theme-preference": "Your selected color theme",
|
||||||
|
plex_user_data: "Cached Plex account information",
|
||||||
|
plex_library_data: "Cached Plex library details per section",
|
||||||
|
plex_server_data: "Cached Plex server information",
|
||||||
|
plex_library_last_sync: "UTC time string for last synced Plex data",
|
||||||
|
plex_auth_token: "Authorized token from Plex.tv",
|
||||||
|
authorization: "This sites user login token"
|
||||||
|
};
|
||||||
|
|
||||||
|
const storageItems = computed<StorageItem[]>(() => {
|
||||||
|
const items: StorageItem[] = [];
|
||||||
|
|
||||||
|
// local storage
|
||||||
|
Object.keys(localStorage).map(key => {
|
||||||
|
items.push({
|
||||||
|
key,
|
||||||
|
description: dict[key] ?? "",
|
||||||
|
size: formatBytes(localStorage[key]?.length || 0),
|
||||||
|
type: "local"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// session storage
|
||||||
|
Object.keys(sessionStorage).map(key => {
|
||||||
|
items.push({
|
||||||
|
key,
|
||||||
|
description: dict[key] ?? "",
|
||||||
|
size: formatBytes(sessionStorage[key]?.length || 0),
|
||||||
|
type: "session"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// cookies
|
||||||
|
if (document.cookie) {
|
||||||
|
document.cookie.split(";").forEach(cookie => {
|
||||||
|
const [key, _] = cookie.trim().split("=");
|
||||||
|
if (key) {
|
||||||
|
items.push({
|
||||||
|
key,
|
||||||
|
description: dict[key] ?? "",
|
||||||
|
size: formatBytes(cookie.length || 0),
|
||||||
|
type: "cookie"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getTotalSize = (items: StorageItem[]) => {
|
||||||
|
const totalBytes = items.reduce((acc, item) => {
|
||||||
|
const match = item.size.match(/^([\d.]+)\s*(\w+)$/);
|
||||||
|
if (!match) return acc;
|
||||||
|
const value = parseFloat(match[1]);
|
||||||
|
const unit = match[2];
|
||||||
|
return (
|
||||||
|
acc +
|
||||||
|
(unit === "KB"
|
||||||
|
? value * 1024
|
||||||
|
: unit === "MB"
|
||||||
|
? value * 1024 * 1024
|
||||||
|
: value)
|
||||||
|
);
|
||||||
|
}, 0);
|
||||||
|
return formatBytes(totalBytes);
|
||||||
|
};
|
||||||
|
|
||||||
|
const storageSections = computed(() => [
|
||||||
|
{
|
||||||
|
type: "local" as const,
|
||||||
|
title: "LocalStorage",
|
||||||
|
iconComponent: IconDatabase,
|
||||||
|
description:
|
||||||
|
"LocalStorage keeps data permanently on your device, even after closing your browser. It's used to remember your preferences and settings between visits.",
|
||||||
|
items: storageItems.value.filter(item => item.type === "local"),
|
||||||
|
get totalSize() {
|
||||||
|
return getTotalSize(this.items);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "session" as const,
|
||||||
|
title: "SessionStorage",
|
||||||
|
iconComponent: IconTimer,
|
||||||
|
description:
|
||||||
|
"SessionStorage keeps data temporarily while you browse. It's automatically cleared when you close your browser tab or window.",
|
||||||
|
items: storageItems.value.filter(item => item.type === "session"),
|
||||||
|
get totalSize() {
|
||||||
|
return getTotalSize(this.items);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "cookie" as const,
|
||||||
|
title: "Cookies",
|
||||||
|
iconComponent: IconCookie,
|
||||||
|
description:
|
||||||
|
"Cookies are small text files stored by your browser. They can be temporary (session cookies) or persistent, and are often used for authentication and tracking your activity.",
|
||||||
|
items: storageItems.value.filter(item => item.type === "cookie"),
|
||||||
|
get totalSize() {
|
||||||
|
return getTotalSize(this.items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
function clearItem(key: string, type: "local" | "session" | "cookie") {
|
||||||
|
try {
|
||||||
|
if (type === "local") {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
} else if (type === "session") {
|
||||||
|
sessionStorage.removeItem(key);
|
||||||
|
} else if (type === "cookie") {
|
||||||
|
document.cookie = `${key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
|
||||||
|
}
|
||||||
|
|
||||||
|
notifications.success({
|
||||||
|
title: "Data Cleared",
|
||||||
|
description: `${key} has been cleared`,
|
||||||
|
timeout: 3000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force re-render
|
||||||
|
storageItems.value;
|
||||||
|
} catch (error) {
|
||||||
|
notifications.error({
|
||||||
|
title: "Error",
|
||||||
|
description: `Failed to clear ${key}`,
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAllData() {
|
||||||
|
const confirmed = confirm(
|
||||||
|
"Are you sure you want to clear all locally stored data? This action cannot be undone."
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
|
document.cookie.split(";").forEach(cookie => {
|
||||||
|
const eqPos = cookie.indexOf("=");
|
||||||
|
const name = eqPos > -1 ? cookie.substring(0, eqPos) : cookie;
|
||||||
|
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT";
|
||||||
|
});
|
||||||
|
|
||||||
|
notifications.success({
|
||||||
|
title: "All Data Cleared",
|
||||||
|
description: "All locally stored data has been removed",
|
||||||
|
timeout: 3000
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
notifications.error({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to clear all data",
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.storage-manager {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
366
src/components/settings/StorageSectionBrowser.vue
Normal file
366
src/components/settings/StorageSectionBrowser.vue
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
<template>
|
||||||
|
<div class="browser-storage">
|
||||||
|
<div class="settings-section-header">
|
||||||
|
<h2>Browser Storage</h2>
|
||||||
|
<p>
|
||||||
|
Your browser stores data locally to make this site faster and remember
|
||||||
|
your settings. View what's saved on this device and remove items
|
||||||
|
anytime.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="storage-sections">
|
||||||
|
<div
|
||||||
|
v-for="section in sections"
|
||||||
|
:key="section.type"
|
||||||
|
:class="`storage-section storage-section--${section.type}`"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="storage-section__header"
|
||||||
|
@click="
|
||||||
|
expandedSections[section.type] = !expandedSections[section.type]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="storage-section__header-content">
|
||||||
|
<component :is="section.iconComponent" class="section-icon" />
|
||||||
|
<h3 class="storage-section__title">{{ section.title }}</h3>
|
||||||
|
<span class="storage-section__count">{{
|
||||||
|
section.items.length
|
||||||
|
}}</span>
|
||||||
|
<span class="storage-section__size">{{ section.totalSize }}</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
class="storage-section__chevron"
|
||||||
|
:class="{
|
||||||
|
'storage-section__chevron--expanded':
|
||||||
|
expandedSections[section.type]
|
||||||
|
}"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="expandedSections[section.type]"
|
||||||
|
class="storage-section__content"
|
||||||
|
>
|
||||||
|
<p class="storage-section__description">{{ section.description }}</p>
|
||||||
|
<div class="storage-items">
|
||||||
|
<div
|
||||||
|
v-for="item in section.items"
|
||||||
|
:key="item.key"
|
||||||
|
:class="`storage-item storage-item--${section.type}`"
|
||||||
|
>
|
||||||
|
<component :is="section.iconComponent" class="type-icon" />
|
||||||
|
<div class="storage-item__info">
|
||||||
|
<h4 class="storage-item__title">{{ item.key }}</h4>
|
||||||
|
<p class="storage-item__description">
|
||||||
|
<span v-if="item.description">{{ item.description }} · </span>
|
||||||
|
<span class="storage-item__size">{{ item.size }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="storage-item__delete"
|
||||||
|
@click="$emit('clear-item', item.key, section.type)"
|
||||||
|
:title="`Clear ${item.key}`"
|
||||||
|
>
|
||||||
|
<IconClose />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import IconClose from "@/icons/IconClose.vue";
|
||||||
|
|
||||||
|
interface StorageItem {
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
size: string;
|
||||||
|
type: "local" | "session" | "cookie";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StorageSection {
|
||||||
|
type: "local" | "session" | "cookie";
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
iconComponent: any;
|
||||||
|
items: StorageItem[];
|
||||||
|
totalSize: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
sections: StorageSection[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
"clear-item": [key: string, type: "local" | "session" | "cookie"];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const expandedSections = ref<Record<string, boolean>>({
|
||||||
|
local: false,
|
||||||
|
session: false,
|
||||||
|
cookie: false
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "scss/variables";
|
||||||
|
@import "scss/media-queries";
|
||||||
|
@import "scss/shared-settings";
|
||||||
|
|
||||||
|
.browser-storage {
|
||||||
|
&__intro {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-sections {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-section {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--background-ui);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
|
||||||
|
&--local {
|
||||||
|
border-color: rgba(139, 92, 246, 0.2);
|
||||||
|
.section-icon,
|
||||||
|
.type-icon {
|
||||||
|
stroke: #8b5cf6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&--session {
|
||||||
|
border-color: rgba(245, 158, 11, 0.2);
|
||||||
|
.section-icon,
|
||||||
|
.type-icon {
|
||||||
|
stroke: #f59e0b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&--cookie {
|
||||||
|
border-color: rgba(236, 72, 153, 0.2);
|
||||||
|
.section-icon,
|
||||||
|
.type-icon {
|
||||||
|
fill: #ec4899;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
&:hover {
|
||||||
|
background: var(--background-40);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
.section-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
&__count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
background: var(--background-40);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color-70);
|
||||||
|
}
|
||||||
|
&__size {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color-50);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
&__chevron {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
stroke: var(--text-color-70);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
&--expanded {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&__content {
|
||||||
|
border-top: 1px solid var(--background-40);
|
||||||
|
}
|
||||||
|
&__description {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-color-70);
|
||||||
|
background: var(--background-color);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
background: var(--background-color);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border-left: 3px solid;
|
||||||
|
|
||||||
|
&--local {
|
||||||
|
border-color: #8b5cf6;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(139, 92, 246, 0.1),
|
||||||
|
var(--background-color)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
&--session {
|
||||||
|
border-color: #f59e0b;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(245, 158, 11, 0.1),
|
||||||
|
var(--background-color)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
&--cookie {
|
||||||
|
border-color: #ec4899;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(236, 72, 153, 0.1),
|
||||||
|
var(--background-color)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .storage-item__delete {
|
||||||
|
background: var(--color-error-highlight);
|
||||||
|
}
|
||||||
|
&:hover .type-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-icon {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
margin: auto 1.5rem;
|
||||||
|
@include mobile-only {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
margin: auto 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.85rem 0.85rem 0.85rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
@include mobile-only {
|
||||||
|
padding: 0.75rem 0.75rem 0.75rem 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0 0 0.3rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&__description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-color-70);
|
||||||
|
line-height: 1.4;
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&__size {
|
||||||
|
color: var(--text-color-50);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__delete {
|
||||||
|
width: 70px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--color-error);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
@include mobile-only {
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
fill: white;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
@include mobile-only {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-error-highlight);
|
||||||
|
svg {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:active svg {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
462
src/components/settings/StorageSectionServer.vue
Normal file
462
src/components/settings/StorageSectionServer.vue
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
<template>
|
||||||
|
<div class="server-storage">
|
||||||
|
<div class="server-storage__intro">
|
||||||
|
<h2 class="server-storage__title">Server Storage</h2>
|
||||||
|
<p class="server-storage__description">
|
||||||
|
Data stored on our servers to sync across your devices and provide
|
||||||
|
personalized features.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="server-sections">
|
||||||
|
<div
|
||||||
|
v-for="section in serverSections"
|
||||||
|
:key="section.type"
|
||||||
|
:class="`server-section server-section--${section.type}`"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="server-section__header"
|
||||||
|
@click="
|
||||||
|
expandedSections[section.type] = !expandedSections[section.type]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="server-section__header-content">
|
||||||
|
<component :is="section.iconComponent" class="section-icon" />
|
||||||
|
<h3 class="server-section__title">{{ section.title }}</h3>
|
||||||
|
<span class="server-section__count">{{
|
||||||
|
section.items.length
|
||||||
|
}}</span>
|
||||||
|
<span class="server-section__size">{{ section.totalSize }}</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
class="server-section__chevron"
|
||||||
|
:class="{
|
||||||
|
'server-section__chevron--expanded':
|
||||||
|
expandedSections[section.type]
|
||||||
|
}"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="expandedSections[section.type]"
|
||||||
|
class="server-section__content"
|
||||||
|
>
|
||||||
|
<p class="server-section__description">{{ section.description }}</p>
|
||||||
|
<div class="server-items">
|
||||||
|
<div
|
||||||
|
v-for="item in section.items"
|
||||||
|
:key="item.key"
|
||||||
|
:class="`server-item server-item--${section.type}`"
|
||||||
|
>
|
||||||
|
<component :is="section.iconComponent" class="type-icon" />
|
||||||
|
<div class="server-item__info">
|
||||||
|
<h4 class="server-item__title">{{ item.key }}</h4>
|
||||||
|
<p class="server-item__description">
|
||||||
|
<span v-if="item.description">{{ item.description }} · </span>
|
||||||
|
<span class="server-item__size">{{ item.size }}</span>
|
||||||
|
<span v-if="item.lastSynced" class="server-item__synced">
|
||||||
|
· Last synced: {{ item.lastSynced }}</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="server-item__delete"
|
||||||
|
@click="$emit('clear-item', item.key, section.type)"
|
||||||
|
:title="`Delete ${item.key}`"
|
||||||
|
>
|
||||||
|
<IconClose />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import IconClose from "@/icons/IconClose.vue";
|
||||||
|
import IconProfile from "@/icons/IconProfile.vue";
|
||||||
|
import IconSettings from "@/icons/IconSettings.vue";
|
||||||
|
import IconActivity from "@/icons/IconActivity.vue";
|
||||||
|
|
||||||
|
interface ServerItem {
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
size: string;
|
||||||
|
lastSynced?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
"clear-item": [key: string, type: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const expandedSections = ref<Record<string, boolean>>({
|
||||||
|
profile: false,
|
||||||
|
preferences: false,
|
||||||
|
activity: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock server data
|
||||||
|
const serverSections = computed(() => [
|
||||||
|
{
|
||||||
|
type: "profile",
|
||||||
|
title: "Profile Data",
|
||||||
|
iconComponent: IconProfile,
|
||||||
|
description:
|
||||||
|
"Your account information, settings, and preferences stored on our servers.",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: "user_profile",
|
||||||
|
description: "User account details",
|
||||||
|
size: "2.4 KB",
|
||||||
|
lastSynced: "2 hours ago"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "avatar_image",
|
||||||
|
description: "Profile picture",
|
||||||
|
size: "145 KB",
|
||||||
|
lastSynced: "1 day ago"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "email_preferences",
|
||||||
|
description: "Notification settings",
|
||||||
|
size: "512 Bytes",
|
||||||
|
lastSynced: "3 days ago"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalSize: "147.9 KB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "preferences",
|
||||||
|
title: "Synced Preferences",
|
||||||
|
iconComponent: IconSettings,
|
||||||
|
description:
|
||||||
|
"Settings that sync across all your devices when you sign in.",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: "theme_settings",
|
||||||
|
description: "Color theme and appearance",
|
||||||
|
size: "1.1 KB",
|
||||||
|
lastSynced: "5 hours ago"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "playback_settings",
|
||||||
|
description: "Video and audio preferences",
|
||||||
|
size: "856 Bytes",
|
||||||
|
lastSynced: "1 day ago"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "library_filters",
|
||||||
|
description: "Saved filters and sorting",
|
||||||
|
size: "2.3 KB",
|
||||||
|
lastSynced: "2 days ago"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalSize: "4.3 KB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "activity",
|
||||||
|
title: "Activity History",
|
||||||
|
iconComponent: IconActivity,
|
||||||
|
description:
|
||||||
|
"Your viewing history and watch progress stored on our servers.",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: "watch_history",
|
||||||
|
description: "Recently watched items",
|
||||||
|
size: "12.5 KB",
|
||||||
|
lastSynced: "1 hour ago"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "watch_progress",
|
||||||
|
description: "Playback positions",
|
||||||
|
size: "8.2 KB",
|
||||||
|
lastSynced: "30 minutes ago"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "favorites",
|
||||||
|
description: "Starred and favorited content",
|
||||||
|
size: "3.7 KB",
|
||||||
|
lastSynced: "6 hours ago"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalSize: "24.4 KB"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "scss/variables";
|
||||||
|
@import "scss/media-queries";
|
||||||
|
|
||||||
|
.server-storage {
|
||||||
|
&__intro {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__description {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-color-70);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-sections {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-section {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--background-ui);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
|
||||||
|
&--profile {
|
||||||
|
border-color: rgba(59, 130, 246, 0.2);
|
||||||
|
.section-icon,
|
||||||
|
.type-icon {
|
||||||
|
fill: #3b82f6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&--preferences {
|
||||||
|
border-color: rgba(16, 185, 129, 0.2);
|
||||||
|
.section-icon,
|
||||||
|
.type-icon {
|
||||||
|
fill: #10b981;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&--activity {
|
||||||
|
border-color: rgba(245, 158, 11, 0.2);
|
||||||
|
.section-icon,
|
||||||
|
.type-icon {
|
||||||
|
fill: #f59e0b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
&:hover {
|
||||||
|
background: var(--background-40);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
.section-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
&__count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
background: var(--background-40);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color-70);
|
||||||
|
}
|
||||||
|
&__size {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color-50);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
&__chevron {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
stroke: var(--text-color-70);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
&--expanded {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&__content {
|
||||||
|
border-top: 1px solid var(--background-40);
|
||||||
|
}
|
||||||
|
&__description {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-color-70);
|
||||||
|
background: var(--background-color);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
background: var(--background-color);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border-left: 3px solid;
|
||||||
|
|
||||||
|
&--profile {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(59, 130, 246, 0.1),
|
||||||
|
var(--background-color)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
&--preferences {
|
||||||
|
border-color: #10b981;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(16, 185, 129, 0.1),
|
||||||
|
var(--background-color)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
&--activity {
|
||||||
|
border-color: #f59e0b;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(245, 158, 11, 0.1),
|
||||||
|
var(--background-color)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .server-item__delete {
|
||||||
|
background: var(--color-error-highlight);
|
||||||
|
}
|
||||||
|
&:hover .type-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-icon {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
margin: auto 1.5rem;
|
||||||
|
@include mobile-only {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
margin: auto 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.85rem 0.85rem 0.85rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
@include mobile-only {
|
||||||
|
padding: 0.75rem 0.75rem 0.75rem 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0 0 0.3rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&__description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-color-70);
|
||||||
|
line-height: 1.4;
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&__size {
|
||||||
|
color: var(--text-color-50);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
&__synced {
|
||||||
|
color: var(--text-color-50);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__delete {
|
||||||
|
width: 70px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--color-error);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
@include mobile-only {
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
fill: white;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
@include mobile-only {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-error-highlight);
|
||||||
|
svg {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:active svg {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -16,10 +16,7 @@
|
|||||||
<button
|
<button
|
||||||
v-for="theme in themes"
|
v-for="theme in themes"
|
||||||
:key="theme.value"
|
:key="theme.value"
|
||||||
:class="[
|
:class="['theme-card', { active: selectedTheme === theme.value }]"
|
||||||
'theme-card',
|
|
||||||
{ 'theme-card--active': selectedTheme === theme.value }
|
|
||||||
]"
|
|
||||||
@click="selectTheme(theme.value)"
|
@click="selectTheme(theme.value)"
|
||||||
>
|
>
|
||||||
<div class="theme-card__preview" :data-theme="theme.value">
|
<div class="theme-card__preview" :data-theme="theme.value">
|
||||||
@@ -35,65 +32,31 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from "vue";
|
import { computed, onMounted } from "vue";
|
||||||
|
import { useTheme } from "@/composables/useTheme";
|
||||||
|
|
||||||
interface Theme {
|
const themes = [
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const themes: Theme[] = [
|
|
||||||
{ value: "auto", label: "Auto" },
|
{ value: "auto", label: "Auto" },
|
||||||
{ value: "light", label: "Light" },
|
{ value: "light", label: "Light" },
|
||||||
{ value: "dark", label: "Dark" },
|
{ value: "dark", label: "Dark" },
|
||||||
{ value: "ocean", label: "Ocean" },
|
{ value: "ocean", label: "Ocean" },
|
||||||
{ value: "nordic", label: "Nordic" },
|
{ value: "nordic", label: "Nordic" },
|
||||||
{ value: "halloween", label: "Halloween" }
|
{ value: "halloween", label: "Halloween" }
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
const selectedTheme = ref("auto");
|
const { currentTheme, savedTheme, setTheme } = useTheme();
|
||||||
|
const selectedTheme = currentTheme;
|
||||||
|
|
||||||
const currentThemeName = computed(() => {
|
const currentThemeName = computed(
|
||||||
const theme = themes.find(t => t.value === selectedTheme.value);
|
() => themes.find(t => t.value === selectedTheme.value)?.label ?? "Auto"
|
||||||
return theme ? theme.label : "Auto";
|
);
|
||||||
});
|
|
||||||
|
|
||||||
function systemDarkModeEnabled() {
|
|
||||||
const computedStyle = window.getComputedStyle(document.body);
|
|
||||||
if (computedStyle?.colorScheme != null) {
|
|
||||||
return computedStyle.colorScheme.includes("dark");
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectTheme(theme: string) {
|
function selectTheme(theme: string) {
|
||||||
selectedTheme.value = theme;
|
setTheme(theme as any);
|
||||||
|
|
||||||
if (theme === "auto") {
|
|
||||||
// Use system preference
|
|
||||||
const systemDark = systemDarkModeEnabled();
|
|
||||||
document.body.className = systemDark ? "dark" : "light";
|
|
||||||
|
|
||||||
// Listen for system theme changes
|
|
||||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
||||||
mediaQuery.addEventListener("change", e => {
|
|
||||||
if (selectedTheme.value === "auto") {
|
|
||||||
document.body.className = e.matches ? "dark" : "light";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Manual theme selection
|
|
||||||
document.body.className = theme;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save preference to localStorage
|
|
||||||
localStorage.setItem("theme-preference", theme);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Load saved preference or default to auto
|
selectedTheme.value = savedTheme.value;
|
||||||
const savedTheme = localStorage.getItem("theme-preference") || "auto";
|
|
||||||
selectTheme(savedTheme);
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -113,99 +76,91 @@
|
|||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.theme-display {
|
.theme-display {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
|
|
||||||
@include mobile-only {
|
@include mobile-only {
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.theme-icon {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
border-radius: 50%;
|
|
||||||
overflow: hidden;
|
|
||||||
flex-shrink: 0;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
width: 70px;
|
|
||||||
height: 70px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-inner {
|
.theme-icon {
|
||||||
width: 100%;
|
width: 80px;
|
||||||
height: 100%;
|
height: 80px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
width: 70px;
|
||||||
|
height: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-inner {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-theme="light"] .icon-inner {
|
||||||
|
background: linear-gradient(135deg, #f8f8f8, #e8e8e8);
|
||||||
|
border-color: #01d277;
|
||||||
|
}
|
||||||
|
&[data-theme="dark"] .icon-inner {
|
||||||
|
background: linear-gradient(135deg, #1a1a1a, #0a0a0a);
|
||||||
|
border-color: #01d277;
|
||||||
|
}
|
||||||
|
&[data-theme="ocean"] .icon-inner {
|
||||||
|
background: linear-gradient(135deg, #0f2027, #2c5364);
|
||||||
|
border-color: #00d4ff;
|
||||||
|
}
|
||||||
|
&[data-theme="nordic"] .icon-inner {
|
||||||
|
background: linear-gradient(135deg, #f5f0e8, #d8cdb9);
|
||||||
|
border-color: #3d6e4e;
|
||||||
|
}
|
||||||
|
&[data-theme="halloween"] .icon-inner {
|
||||||
|
background: linear-gradient(135deg, #1a0e2e, #2d1b3d);
|
||||||
|
border-color: #ff6600;
|
||||||
|
}
|
||||||
|
&[data-theme="auto"] .icon-inner {
|
||||||
|
background: conic-gradient(#f8f8f8 0deg 180deg, #1a1a1a 180deg);
|
||||||
|
border-color: #01d277;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-theme="light"] .icon-inner {
|
.theme-info {
|
||||||
background: linear-gradient(135deg, #f8f8f8 0%, #e8e8e8 100%);
|
flex: 1;
|
||||||
border: 3px solid #01d277;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-theme="dark"] .icon-inner {
|
span {
|
||||||
background: linear-gradient(135deg, #1a1a1a 0%, #0a0a0a 100%);
|
font-size: 0.85rem;
|
||||||
border: 3px solid #01d277;
|
color: $text-color-70;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-theme="ocean"] .icon-inner {
|
h3 {
|
||||||
background: linear-gradient(135deg, #0f2027 0%, #2c5364 100%);
|
margin: 0;
|
||||||
border: 3px solid #00d4ff;
|
font-size: 1.75rem;
|
||||||
}
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
&[data-theme="nordic"] .icon-inner {
|
@include mobile-only {
|
||||||
background: linear-gradient(135deg, #f5f0e8 0%, #d8cdb9 100%);
|
font-size: 1.4rem;
|
||||||
border: 3px solid #3d6e4e;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
&[data-theme="halloween"] .icon-inner {
|
|
||||||
background: linear-gradient(135deg, #1a0e2e 0%, #2d1b3d 100%);
|
|
||||||
border: 3px solid #ff6600;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-theme="auto"] .icon-inner {
|
|
||||||
background: conic-gradient(
|
|
||||||
from 0deg,
|
|
||||||
#f8f8f8 0deg 180deg,
|
|
||||||
#1a1a1a 180deg 360deg
|
|
||||||
);
|
|
||||||
border: 3px solid #01d277;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-info {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-label {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: $text-color-70;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
font-weight: 500;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-name {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 1.4rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,251 +173,182 @@
|
|||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.theme-card {
|
.theme-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background-color: var(--background-ui);
|
background-color: var(--background-ui);
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
@include mobile-only {
|
@include mobile-only {
|
||||||
padding: 0.85rem;
|
padding: 0.85rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
}
|
&:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--highlight-color);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
&--active {
|
|
||||||
border-color: var(--highlight-color);
|
|
||||||
background-color: var(--background-40);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: none;
|
border-color: var(--highlight-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&.active {
|
||||||
transform: scale(0.97);
|
border-color: var(--highlight-color);
|
||||||
}
|
background-color: var(--background-40);
|
||||||
}
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
&__preview {
|
|
||||||
width: 100%;
|
|
||||||
height: 120px;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
position: relative;
|
|
||||||
border: 1px solid var(--background-40);
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
height: 100px;
|
|
||||||
margin-bottom: 0.6rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-circle {
|
&__preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid var(--background-40);
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
height: 100px;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-circle {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
left: 8px;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
right: 8px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__preview[data-theme="light"] {
|
||||||
|
background: #f8f8f8;
|
||||||
|
.preview-circle {
|
||||||
|
background: #01d277;
|
||||||
|
}
|
||||||
|
&::before {
|
||||||
|
background: #fff;
|
||||||
|
border-color: rgba(8, 28, 36, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__preview[data-theme="dark"] {
|
||||||
|
background: #111;
|
||||||
|
.preview-circle {
|
||||||
|
background: #01d277;
|
||||||
|
}
|
||||||
|
&::before {
|
||||||
|
background: #060708;
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__preview[data-theme="ocean"] {
|
||||||
|
background: #0f2027;
|
||||||
|
.preview-circle {
|
||||||
|
background: #00d4ff;
|
||||||
|
}
|
||||||
|
&::before {
|
||||||
|
background: #203a43;
|
||||||
|
border-color: rgba(0, 212, 255, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__preview[data-theme="nordic"] {
|
||||||
|
background: #f5f0e8;
|
||||||
|
.preview-circle {
|
||||||
|
background: #3d6e4e;
|
||||||
|
}
|
||||||
|
&::before {
|
||||||
|
background: #fffef9;
|
||||||
|
border-color: rgba(61, 110, 78, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__preview[data-theme="halloween"] {
|
||||||
|
background: #1a0e2e;
|
||||||
|
.preview-circle {
|
||||||
|
background: #ff6600;
|
||||||
|
}
|
||||||
|
&::before {
|
||||||
|
background: #2d1b3d;
|
||||||
|
border-color: rgba(255, 102, 0, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__preview[data-theme="auto"] {
|
||||||
|
border-color: black;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
#f8f8f8 0%,
|
||||||
|
#f8f8f8 50%,
|
||||||
|
#111 50%,
|
||||||
|
#111 100%
|
||||||
|
);
|
||||||
|
.preview-circle {
|
||||||
|
left: auto;
|
||||||
|
right: 8px;
|
||||||
|
background: #01d277;
|
||||||
|
}
|
||||||
|
&::before {
|
||||||
|
right: auto;
|
||||||
|
width: calc(50% - 10px);
|
||||||
|
background: #fff;
|
||||||
|
border-color: rgba(8, 28, 36, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-color);
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 50%;
|
top: 0.5rem;
|
||||||
}
|
right: 0.5rem;
|
||||||
}
|
padding: 0.25rem 0.5rem;
|
||||||
|
background-color: var(--highlight-color);
|
||||||
|
color: white;
|
||||||
|
border-radius: 1rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
|
||||||
// Light Theme Preview
|
@include mobile-only {
|
||||||
&__preview[data-theme="light"] {
|
top: 0.4rem;
|
||||||
background: #f8f8f8;
|
right: 0.4rem;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
.preview-circle {
|
font-size: 0.6rem;
|
||||||
bottom: 8px;
|
}
|
||||||
left: 8px;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
background: #01d277;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 8px;
|
|
||||||
left: 8px;
|
|
||||||
right: 8px;
|
|
||||||
height: 20px;
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid rgba(8, 28, 36, 0.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dark Theme Preview
|
|
||||||
&__preview[data-theme="dark"] {
|
|
||||||
background: #111111;
|
|
||||||
|
|
||||||
.preview-circle {
|
|
||||||
bottom: 8px;
|
|
||||||
left: 8px;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
background: #01d277;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 8px;
|
|
||||||
left: 8px;
|
|
||||||
right: 8px;
|
|
||||||
height: 20px;
|
|
||||||
background: rgba(6, 7, 8, 1);
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ocean Theme Preview
|
|
||||||
&__preview[data-theme="ocean"] {
|
|
||||||
background: #0f2027;
|
|
||||||
|
|
||||||
.preview-circle {
|
|
||||||
bottom: 8px;
|
|
||||||
left: 8px;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
background: #00d4ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 8px;
|
|
||||||
left: 8px;
|
|
||||||
right: 8px;
|
|
||||||
height: 20px;
|
|
||||||
background: #203a43;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid rgba(0, 212, 255, 0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nordic Theme Preview
|
|
||||||
&__preview[data-theme="nordic"] {
|
|
||||||
background: #f5f0e8;
|
|
||||||
|
|
||||||
.preview-circle {
|
|
||||||
bottom: 8px;
|
|
||||||
left: 8px;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
background: #3d6e4e;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 8px;
|
|
||||||
left: 8px;
|
|
||||||
right: 8px;
|
|
||||||
height: 20px;
|
|
||||||
background: #fffef9;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid rgba(61, 110, 78, 0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Halloween Theme Preview
|
|
||||||
&__preview[data-theme="halloween"] {
|
|
||||||
background: #1a0e2e;
|
|
||||||
|
|
||||||
.preview-circle {
|
|
||||||
bottom: 8px;
|
|
||||||
left: 8px;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
background: #ff6600;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 8px;
|
|
||||||
left: 8px;
|
|
||||||
right: 8px;
|
|
||||||
height: 20px;
|
|
||||||
background: #2d1b3d;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid rgba(255, 102, 0, 0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto Theme Preview (split)
|
|
||||||
&__preview[data-theme="auto"] {
|
|
||||||
background: linear-gradient(
|
|
||||||
135deg,
|
|
||||||
#f8f8f8 0%,
|
|
||||||
#f8f8f8 50%,
|
|
||||||
#111111 50%,
|
|
||||||
#111111 100%
|
|
||||||
);
|
|
||||||
|
|
||||||
.preview-circle {
|
|
||||||
bottom: 8px;
|
|
||||||
right: 8px;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
background: #01d277;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 8px;
|
|
||||||
left: 8px;
|
|
||||||
width: calc(50% - 10px);
|
|
||||||
height: 20px;
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid rgba(8, 28, 36, 0.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__name {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1;
|
|
||||||
color: var(--text-color);
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__badge {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.5rem;
|
|
||||||
right: 0.5rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
background-color: var(--highlight-color);
|
|
||||||
color: $white;
|
|
||||||
border-radius: 1rem;
|
|
||||||
font-size: 0.65rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
top: 0.4rem;
|
|
||||||
right: 0.4rem;
|
|
||||||
padding: 0.2rem 0.4rem;
|
|
||||||
font-size: 0.6rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,7 +139,6 @@
|
|||||||
import IconMovie from "@/icons/IconMovie.vue";
|
import IconMovie from "@/icons/IconMovie.vue";
|
||||||
import IconActivity from "@/icons/IconActivity.vue";
|
import IconActivity from "@/icons/IconActivity.vue";
|
||||||
import IconProfile from "@/icons/IconProfile.vue";
|
import IconProfile from "@/icons/IconProfile.vue";
|
||||||
import IconRequest from "@/icons/IconRequest.vue";
|
|
||||||
import IconInbox from "@/icons/IconInbox.vue";
|
import IconInbox from "@/icons/IconInbox.vue";
|
||||||
import IconSearch from "@/icons/IconSearch.vue";
|
import IconSearch from "@/icons/IconSearch.vue";
|
||||||
import IconEdit from "@/icons/IconEdit.vue";
|
import IconEdit from "@/icons/IconEdit.vue";
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="darkToggle">
|
|
||||||
<span @click="toggleDarkmode" @keydown.enter="toggleDarkmode">{{
|
|
||||||
darkmodeToggleIcon
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed } from "vue";
|
|
||||||
|
|
||||||
function systemDarkModeEnabled() {
|
|
||||||
const computedStyle = window.getComputedStyle(document.body);
|
|
||||||
if (computedStyle?.colorScheme != null) {
|
|
||||||
return computedStyle.colorScheme.includes("dark");
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const darkmode = ref(systemDarkModeEnabled());
|
|
||||||
const darkmodeToggleIcon = computed(() => {
|
|
||||||
return darkmode.value ? "🌝" : "🌚";
|
|
||||||
});
|
|
||||||
|
|
||||||
function toggleDarkmode() {
|
|
||||||
darkmode.value = !darkmode.value;
|
|
||||||
document.body.className = darkmode.value ? "dark" : "light";
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.darkToggle {
|
|
||||||
height: 25px;
|
|
||||||
width: 25px;
|
|
||||||
cursor: pointer;
|
|
||||||
position: fixed;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
margin-right: 2px;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 10;
|
|
||||||
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-moz-user-select: none;
|
|
||||||
-ms-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:class="{ active: active, fullwidth: fullWidth }"
|
:class="{ active: active, fullwidth: fullWidth }"
|
||||||
@click="emit('click')"
|
@click="event => emit('click', event)"
|
||||||
>
|
>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</button>
|
</button>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Emit {
|
interface Emit {
|
||||||
(e: "click");
|
(e: "click", event?: MouseEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>();
|
defineProps<Props>();
|
||||||
|
|||||||
@@ -1,200 +1,125 @@
|
|||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
|
import { API_HOSTNAME } from "../api";
|
||||||
|
|
||||||
// Shared constants - generated once and reused
|
// Shared constants - generated once and reused
|
||||||
export const CLIENT_IDENTIFIER = `seasoned-plex-app-${Math.random().toString(36).substring(7)}`;
|
export const CLIENT_IDENTIFIER = `seasoned-plex-app-${Math.random().toString(36).substring(7)}`;
|
||||||
export const APP_NAME = window.location.hostname;
|
export const APP_NAME = window.location.hostname;
|
||||||
|
|
||||||
export function usePlexApi() {
|
async function fetchPlexServers(authToken: string) {
|
||||||
const plexServerUrl = ref("");
|
try {
|
||||||
|
const url =
|
||||||
// Fetch Plex user data
|
"https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1";
|
||||||
async function fetchPlexUserData(authToken: string) {
|
const options = {
|
||||||
try {
|
method: "GET",
|
||||||
const response = await fetch("https://plex.tv/api/v2/user", {
|
headers: {
|
||||||
method: "GET",
|
accept: "application/json",
|
||||||
headers: {
|
"X-Plex-Token": authToken,
|
||||||
accept: "application/json",
|
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER
|
||||||
"X-Plex-Product": APP_NAME,
|
|
||||||
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER,
|
|
||||||
"X-Plex-Token": authToken
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to fetch Plex user info");
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const data = await response.json();
|
const response = await fetch(url, options);
|
||||||
|
|
||||||
// Convert Unix timestamp to ISO date string if needed
|
if (!response.ok) {
|
||||||
let joinedDate = null;
|
throw new Error("Failed to fetch Plex servers");
|
||||||
if (data.joinedAt) {
|
|
||||||
if (typeof data.joinedAt === "number") {
|
|
||||||
joinedDate = new Date(data.joinedAt * 1000).toISOString();
|
|
||||||
} else {
|
|
||||||
joinedDate = data.joinedAt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const userData = {
|
|
||||||
id: data.id,
|
|
||||||
uuid: data.uuid,
|
|
||||||
username: data.username || data.title || "Plex User",
|
|
||||||
email: data.email,
|
|
||||||
thumb: data.thumb,
|
|
||||||
joined_at: joinedDate,
|
|
||||||
two_factor_enabled: data.twoFactorEnabled || false,
|
|
||||||
experimental_features: data.experimentalFeatures || false,
|
|
||||||
subscription: {
|
|
||||||
active: data.subscription?.active,
|
|
||||||
plan: data.subscription?.plan,
|
|
||||||
features: data.subscription?.features
|
|
||||||
},
|
|
||||||
profile: {
|
|
||||||
auto_select_audio: data.profile?.autoSelectAudio,
|
|
||||||
default_audio_language: data.profile?.defaultAudioLanguage,
|
|
||||||
default_subtitle_language: data.profile?.defaultSubtitleLanguage
|
|
||||||
},
|
|
||||||
entitlements: data.entitlements || [],
|
|
||||||
roles: data.roles || [],
|
|
||||||
created_at: new Date().toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
localStorage.setItem("plex_user_data", JSON.stringify(userData));
|
|
||||||
return userData;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[PlexAPI] Error fetching Plex user data:", error);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch Plex servers
|
const servers = await response.json();
|
||||||
async function fetchPlexServers(authToken: string) {
|
const ownedServer = servers.find(
|
||||||
try {
|
(s: any) => s.owned && s.provides === "server"
|
||||||
const response = await fetch(
|
);
|
||||||
"https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1",
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
accept: "application/json",
|
|
||||||
"X-Plex-Token": authToken,
|
|
||||||
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (ownedServer) {
|
||||||
throw new Error("Failed to fetch Plex servers");
|
const connection =
|
||||||
}
|
ownedServer.connections?.find((c: any) => c.local === false) ||
|
||||||
|
ownedServer.connections?.[0];
|
||||||
const servers = await response.json();
|
|
||||||
const ownedServer = servers.find(
|
|
||||||
(s: any) => s.owned && s.provides === "server"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (ownedServer) {
|
|
||||||
const connection =
|
|
||||||
ownedServer.connections?.find((c: any) => c.local === false) ||
|
|
||||||
ownedServer.connections?.[0];
|
|
||||||
if (connection) {
|
|
||||||
plexServerUrl.value = connection.uri;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
name: ownedServer.name,
|
|
||||||
url: plexServerUrl.value,
|
|
||||||
machineIdentifier: ownedServer.clientIdentifier
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[PlexAPI] Error fetching Plex servers:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch library sections
|
|
||||||
async function fetchLibrarySections(authToken: string, serverUrl: string) {
|
|
||||||
if (!serverUrl) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${serverUrl}/library/sections`, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
accept: "application/json",
|
|
||||||
"X-Plex-Token": authToken
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to fetch library sections");
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return data.MediaContainer?.Directory || [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[PlexAPI] Error fetching library sections:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch library details
|
|
||||||
async function fetchLibraryDetails(
|
|
||||||
authToken: string,
|
|
||||||
serverUrl: string,
|
|
||||||
sectionKey: string
|
|
||||||
) {
|
|
||||||
if (!serverUrl) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch all items
|
|
||||||
const allResponse = await fetch(
|
|
||||||
`${serverUrl}/library/sections/${sectionKey}/all`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
accept: "application/json",
|
|
||||||
"X-Plex-Token": authToken
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!allResponse.ok) throw new Error("Failed to fetch all items");
|
|
||||||
const allData = await allResponse.json();
|
|
||||||
|
|
||||||
// Fetch recently added
|
|
||||||
const size = 20;
|
|
||||||
const recentResponse = await fetch(
|
|
||||||
`${serverUrl}/library/sections/${sectionKey}/recentlyAdded?X-Plex-Container-Start=0&X-Plex-Container-Size=${size}`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
accept: "application/json",
|
|
||||||
"X-Plex-Token": authToken
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!recentResponse.ok) throw new Error("Failed to fetch recently added");
|
|
||||||
const recentData = await recentResponse.json();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
all: allData,
|
name: ownedServer.name,
|
||||||
recent: recentData,
|
url: connection?.uri,
|
||||||
metadata: allData.MediaContainer?.Metadata || [],
|
machineIdentifier: ownedServer.clientIdentifier
|
||||||
recentMetadata: recentData.MediaContainer?.Metadata || []
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
|
||||||
console.error("[PlexAPI] Error fetching library details:", error);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return null;
|
||||||
plexServerUrl,
|
} catch (error) {
|
||||||
fetchPlexUserData,
|
console.error("[PlexAPI] Error fetching Plex servers:", error);
|
||||||
fetchPlexServers,
|
return null;
|
||||||
fetchLibrarySections,
|
}
|
||||||
fetchLibraryDetails
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchPlexUserData(authToken: string) {
|
||||||
|
try {
|
||||||
|
const url = "https://plex.tv/api/v2/user";
|
||||||
|
const options = {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
"X-Plex-Product": APP_NAME,
|
||||||
|
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER,
|
||||||
|
"X-Plex-Token": authToken
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch Plex user info");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Convert Unix timestamp to ISO date string if needed
|
||||||
|
let joinedDate = null;
|
||||||
|
if (data.joinedAt) {
|
||||||
|
if (typeof data.joinedAt === "number") {
|
||||||
|
joinedDate = new Date(data.joinedAt * 1000).toISOString();
|
||||||
|
} else {
|
||||||
|
joinedDate = data.joinedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = {
|
||||||
|
id: data.id,
|
||||||
|
uuid: data.uuid,
|
||||||
|
username: data.username || data.title || "Plex User",
|
||||||
|
email: data.email,
|
||||||
|
thumb: data.thumb,
|
||||||
|
joined_at: joinedDate,
|
||||||
|
two_factor_enabled: data.twoFactorEnabled || false,
|
||||||
|
experimental_features: data.experimentalFeatures || false,
|
||||||
|
subscription: {
|
||||||
|
active: data.subscription?.active,
|
||||||
|
plan: data.subscription?.plan,
|
||||||
|
features: data.subscription?.features
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
auto_select_audio: data.profile?.autoSelectAudio,
|
||||||
|
default_audio_language: data.profile?.defaultAudioLanguage,
|
||||||
|
default_subtitle_language: data.profile?.defaultSubtitleLanguage
|
||||||
|
},
|
||||||
|
entitlements: data.entitlements || [],
|
||||||
|
roles: data.roles || [],
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
return userData;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[PlexAPI] Error fetching Plex user data:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch library details
|
||||||
|
async function fetchLibraryDetails() {
|
||||||
|
try {
|
||||||
|
const url = `${API_HOSTNAME}/api/v2/plex/library`;
|
||||||
|
const options: RequestInit = { credentials: "include" };
|
||||||
|
return await fetch(url, options).then(resp => resp.json());
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[PlexAPI] error fetching library:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { fetchPlexServers, fetchPlexUserData, fetchLibraryDetails };
|
||||||
|
|||||||
@@ -9,15 +9,17 @@ export function usePlexAuth() {
|
|||||||
// Generate a PIN for Plex OAuth
|
// Generate a PIN for Plex OAuth
|
||||||
async function generatePlexPin() {
|
async function generatePlexPin() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("https://plex.tv/api/v2/pins?strong=true", {
|
const url = "https://plex.tv/api/v2/pins?strong=true";
|
||||||
|
const options = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
accept: "application/json",
|
accept: "application/json",
|
||||||
"X-Plex-Product": APP_NAME,
|
"X-Plex-Product": APP_NAME,
|
||||||
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER
|
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const response = await fetch(url, options);
|
||||||
if (!response.ok) throw new Error("Failed to generate PIN");
|
if (!response.ok) throw new Error("Failed to generate PIN");
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return { id: data.id, code: data.code };
|
return { id: data.id, code: data.code };
|
||||||
@@ -30,15 +32,15 @@ export function usePlexAuth() {
|
|||||||
// Check PIN status
|
// Check PIN status
|
||||||
async function checkPin(pinId: number, pinCode: string) {
|
async function checkPin(pinId: number, pinCode: string) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const url = `https://plex.tv/api/v2/pins/${pinId}?code=${pinCode}`;
|
||||||
`https://plex.tv/api/v2/pins/${pinId}?code=${pinCode}`,
|
const options = {
|
||||||
{
|
headers: {
|
||||||
headers: {
|
accept: "application/json",
|
||||||
accept: "application/json",
|
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER
|
||||||
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
};
|
||||||
|
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -93,9 +95,10 @@ export function usePlexAuth() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get cookie
|
// Get cookie
|
||||||
function getCookie(name: string): string | null {
|
function getPlexAuthCookie(): string | null {
|
||||||
|
const key = "plex_auth_token";
|
||||||
const value = `; ${document.cookie}`;
|
const value = `; ${document.cookie}`;
|
||||||
const parts = value.split(`; ${name}=`);
|
const parts = value.split(`; ${key}=`);
|
||||||
if (parts.length === 2) {
|
if (parts.length === 2) {
|
||||||
return parts.pop()?.split(";").shift() || null;
|
return parts.pop()?.split(";").shift() || null;
|
||||||
}
|
}
|
||||||
@@ -171,9 +174,10 @@ export function usePlexAuth() {
|
|||||||
if (plexPopup.value && plexPopup.value.closed) {
|
if (plexPopup.value && plexPopup.value.closed) {
|
||||||
clearInterval(popupChecker);
|
clearInterval(popupChecker);
|
||||||
stopPolling();
|
stopPolling();
|
||||||
|
|
||||||
if (loading.value) {
|
if (loading.value) {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
onError("Plex authentication window was closed");
|
// onError("Plex authentication window was closed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
@@ -190,7 +194,7 @@ export function usePlexAuth() {
|
|||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
setPlexAuthCookie,
|
setPlexAuthCookie,
|
||||||
getCookie,
|
getPlexAuthCookie,
|
||||||
openAuthPopup,
|
openAuthPopup,
|
||||||
cleanup
|
cleanup
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
import {
|
|
||||||
processLibraryItem,
|
|
||||||
calculateGenreStats,
|
|
||||||
calculateDuration
|
|
||||||
} from "@/utils/plexHelpers";
|
|
||||||
|
|
||||||
export function usePlexLibraries() {
|
|
||||||
async function loadLibraries(
|
|
||||||
sections: any[],
|
|
||||||
authToken: string,
|
|
||||||
serverUrl: string,
|
|
||||||
machineIdentifier: string,
|
|
||||||
username: string,
|
|
||||||
fetchLibraryDetailsFn: any
|
|
||||||
) {
|
|
||||||
// Reset stats
|
|
||||||
const stats = { movies: 0, shows: 0, music: 0, watchtime: 0 };
|
|
||||||
const details: any = {
|
|
||||||
movies: {
|
|
||||||
total: 0,
|
|
||||||
recentlyAdded: [],
|
|
||||||
genres: [],
|
|
||||||
totalDuration: "0 hours"
|
|
||||||
},
|
|
||||||
shows: {
|
|
||||||
total: 0,
|
|
||||||
recentlyAdded: [],
|
|
||||||
genres: [],
|
|
||||||
totalEpisodes: 0,
|
|
||||||
totalDuration: "0 hours"
|
|
||||||
},
|
|
||||||
music: { total: 0, recentlyAdded: [], genres: [], totalTracks: 0 }
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const section of sections) {
|
|
||||||
const { type } = section;
|
|
||||||
const { key } = section;
|
|
||||||
|
|
||||||
if (type === "movie") {
|
|
||||||
await processLibrarySection(
|
|
||||||
authToken,
|
|
||||||
serverUrl,
|
|
||||||
machineIdentifier,
|
|
||||||
key,
|
|
||||||
"movies",
|
|
||||||
stats,
|
|
||||||
details,
|
|
||||||
fetchLibraryDetailsFn
|
|
||||||
);
|
|
||||||
} else if (type === "show") {
|
|
||||||
await processLibrarySection(
|
|
||||||
authToken,
|
|
||||||
serverUrl,
|
|
||||||
machineIdentifier,
|
|
||||||
key,
|
|
||||||
"shows",
|
|
||||||
stats,
|
|
||||||
details,
|
|
||||||
fetchLibraryDetailsFn
|
|
||||||
);
|
|
||||||
} else if (type === "artist") {
|
|
||||||
await processLibrarySection(
|
|
||||||
authToken,
|
|
||||||
serverUrl,
|
|
||||||
machineIdentifier,
|
|
||||||
key,
|
|
||||||
"music",
|
|
||||||
stats,
|
|
||||||
details,
|
|
||||||
fetchLibraryDetailsFn
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate watchtime from Tautulli if username provided
|
|
||||||
if (username) {
|
|
||||||
try {
|
|
||||||
const TAUTULLI_API_KEY = "28494032b47542278fe76c6ccd1f0619";
|
|
||||||
const TAUTULLI_BASE_URL = "http://plex.schleppe:8181/api/v2";
|
|
||||||
const url = `${TAUTULLI_BASE_URL}?apikey=${TAUTULLI_API_KEY}&cmd=get_history&user=${encodeURIComponent(
|
|
||||||
username
|
|
||||||
)}&length=8000`;
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
const history = data.response?.data?.data || [];
|
|
||||||
const totalMs = history.reduce(
|
|
||||||
(sum: number, item: any) => sum + (item.duration || 0) * 1000,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
stats.watchtime = Math.round(totalMs / (1000 * 60 * 60));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[PlexLibraries] Error fetching watchtime:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { stats, details };
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[PlexLibraries] Error loading libraries:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processLibrarySection(
|
|
||||||
authToken: string,
|
|
||||||
serverUrl: string,
|
|
||||||
machineIdentifier: string,
|
|
||||||
sectionKey: string,
|
|
||||||
libraryType: string,
|
|
||||||
stats: any,
|
|
||||||
details: any,
|
|
||||||
fetchLibraryDetailsFn: any
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const data = await fetchLibraryDetailsFn(
|
|
||||||
authToken,
|
|
||||||
serverUrl,
|
|
||||||
sectionKey
|
|
||||||
);
|
|
||||||
if (!data) return;
|
|
||||||
|
|
||||||
const totalCount = data.all.MediaContainer?.size || 0;
|
|
||||||
|
|
||||||
// Update stats
|
|
||||||
if (libraryType === "movies") {
|
|
||||||
stats.movies += totalCount;
|
|
||||||
} else if (libraryType === "shows") {
|
|
||||||
stats.shows += totalCount;
|
|
||||||
} else if (libraryType === "music") {
|
|
||||||
stats.music += totalCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process recently added items
|
|
||||||
const recentItems = data.recentMetadata.map((item: any) =>
|
|
||||||
processLibraryItem(
|
|
||||||
item,
|
|
||||||
libraryType,
|
|
||||||
authToken,
|
|
||||||
serverUrl,
|
|
||||||
machineIdentifier
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Calculate stats
|
|
||||||
const genres = calculateGenreStats(data.metadata);
|
|
||||||
const durations = calculateDuration(data.metadata, libraryType);
|
|
||||||
|
|
||||||
// Update library details
|
|
||||||
details[libraryType] = {
|
|
||||||
total: totalCount,
|
|
||||||
recentlyAdded: recentItems,
|
|
||||||
genres,
|
|
||||||
totalDuration: durations.totalDuration,
|
|
||||||
...(libraryType === "shows" && {
|
|
||||||
totalEpisodes: durations.totalEpisodes
|
|
||||||
}),
|
|
||||||
...(libraryType === "music" && { totalTracks: durations.totalTracks })
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[PlexLibraries] Error processing ${libraryType}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
loadLibraries
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
const TAUTULLI_API_KEY = "28494032b47542278fe76c6ccd1f0619";
|
import { API_HOSTNAME } from "../api";
|
||||||
const TAUTULLI_BASE_URL = "http://plex.schleppe:8181/api/v2";
|
|
||||||
|
|
||||||
interface WatchStats {
|
export interface WatchStats {
|
||||||
totalHours: number;
|
totalHours: number;
|
||||||
totalPlays: number;
|
totalPlays: number;
|
||||||
moviePlays: number;
|
moviePlays: number;
|
||||||
episodePlays: number;
|
episodePlays: number;
|
||||||
musicPlays: number;
|
musicPlays: number;
|
||||||
|
lastWatched: WatchContent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DayStats {
|
interface DayStats {
|
||||||
@@ -29,6 +29,13 @@ interface HomeStatItem {
|
|||||||
media_type?: string;
|
media_type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WatchContent {
|
||||||
|
title: string;
|
||||||
|
plays: number;
|
||||||
|
duration: number;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface PlaysGraphData {
|
interface PlaysGraphData {
|
||||||
categories: string[];
|
categories: string[];
|
||||||
series: {
|
series: {
|
||||||
@@ -37,310 +44,256 @@ interface PlaysGraphData {
|
|||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTautulliStats() {
|
export async function tautulliRequest(
|
||||||
// Helper function to make Tautulli API calls
|
resource: string,
|
||||||
async function tautulliRequest(
|
params: Record<string, any> = {}
|
||||||
cmd: string,
|
) {
|
||||||
params: Record<string, any> = {}
|
try {
|
||||||
) {
|
const queryParams = new URLSearchParams(params);
|
||||||
try {
|
const url = new URL(
|
||||||
const queryParams = new URLSearchParams({
|
`/api/v1/user/stats/${resource}?${queryParams}`,
|
||||||
apikey: TAUTULLI_API_KEY,
|
API_HOSTNAME
|
||||||
cmd,
|
);
|
||||||
...params
|
const options: RequestInit = {
|
||||||
});
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
credentials: "include"
|
||||||
|
};
|
||||||
|
|
||||||
const url = `${TAUTULLI_BASE_URL}?${queryParams}`;
|
const resp = await fetch(url, options);
|
||||||
const response = await fetch(url);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!resp.ok) {
|
||||||
throw new Error(`Tautulli API request failed: ${response.statusText}`);
|
throw new Error(`Tautulli API request failed: ${resp.statusText}`);
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.response?.result !== "success") {
|
|
||||||
throw new Error(data.response?.message || "Unknown API error");
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.response.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[Tautulli] Error with ${cmd}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch home statistics (pre-aggregated by Tautulli!)
|
const response = await resp.json();
|
||||||
async function fetchHomeStats(
|
if (response?.success !== true) {
|
||||||
userId?: number,
|
throw new Error(response?.message || "Unknown API error");
|
||||||
timeRange = 30,
|
|
||||||
statsType: "plays" | "duration" = "plays"
|
|
||||||
): Promise<WatchStats> {
|
|
||||||
try {
|
|
||||||
const params: Record<string, any> = {
|
|
||||||
time_range: timeRange,
|
|
||||||
stats_type: statsType,
|
|
||||||
grouping: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
if (userId) {
|
|
||||||
params.user_id = userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = await tautulliRequest("get_home_stats", params);
|
|
||||||
|
|
||||||
// Extract stats from the response
|
|
||||||
let totalPlays = 0;
|
|
||||||
let totalHours = 0;
|
|
||||||
let moviePlays = 0;
|
|
||||||
let episodePlays = 0;
|
|
||||||
let musicPlays = 0;
|
|
||||||
|
|
||||||
// Find the relevant stat sections
|
|
||||||
const topMovies = stats.find((s: any) => s.stat_id === "top_movies");
|
|
||||||
const topTV = stats.find((s: any) => s.stat_id === "top_tv");
|
|
||||||
const topMusic = stats.find((s: any) => s.stat_id === "top_music");
|
|
||||||
|
|
||||||
if (topMovies?.rows) {
|
|
||||||
moviePlays = topMovies.rows.reduce(
|
|
||||||
(sum: number, item: any) => sum + (item.total_plays || 0),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (topTV?.rows) {
|
|
||||||
episodePlays = topTV.rows.reduce(
|
|
||||||
(sum: number, item: any) => sum + (item.total_plays || 0),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (topMusic?.rows) {
|
|
||||||
musicPlays = topMusic.rows.reduce(
|
|
||||||
(sum: number, item: any) => sum + (item.total_plays || 0),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
totalPlays = moviePlays + episodePlays + musicPlays;
|
|
||||||
|
|
||||||
// Calculate total hours from duration
|
|
||||||
if (statsType === "duration") {
|
|
||||||
const totalDuration = [topMovies, topTV, topMusic].reduce(
|
|
||||||
(sum, stat) => {
|
|
||||||
if (!stat?.rows) return sum;
|
|
||||||
return (
|
|
||||||
sum +
|
|
||||||
stat.rows.reduce(
|
|
||||||
(s: number, item: any) => s + (item.total_duration || 0),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
0
|
|
||||||
);
|
|
||||||
totalHours = Math.round(totalDuration / 3600); // Convert seconds to hours
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalHours,
|
|
||||||
totalPlays,
|
|
||||||
moviePlays,
|
|
||||||
episodePlays,
|
|
||||||
musicPlays
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Tautulli] Error fetching home stats:", error);
|
|
||||||
return {
|
|
||||||
totalHours: 0,
|
|
||||||
totalPlays: 0,
|
|
||||||
moviePlays: 0,
|
|
||||||
episodePlays: 0,
|
|
||||||
musicPlays: 0
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Tautulli] Error with ${resource}:`, error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch plays by date (already aggregated by Tautulli!)
|
// Fetch home statistics (pre-aggregated by Tautulli!)
|
||||||
async function fetchPlaysByDate(
|
export async function fetchHomeStats(
|
||||||
timeRange = 30,
|
timeRange = 30,
|
||||||
yAxis: "plays" | "duration" = "plays",
|
statsType: "plays" | "duration" = "plays"
|
||||||
userId?: number
|
): Promise<WatchStats> {
|
||||||
): Promise<DayStats[]> {
|
try {
|
||||||
try {
|
const params: Record<string, any> = {
|
||||||
const params: Record<string, any> = {
|
days: timeRange,
|
||||||
time_range: timeRange,
|
type: statsType,
|
||||||
y_axis: yAxis,
|
grouping: 0
|
||||||
grouping: 0
|
};
|
||||||
};
|
|
||||||
|
|
||||||
if (userId) {
|
const stats = await tautulliRequest("home_stats", params);
|
||||||
params.user_id = userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: PlaysGraphData = await tautulliRequest(
|
// Extract stats from the response
|
||||||
"get_plays_by_date",
|
let totalPlays = 0;
|
||||||
params
|
let totalHours = 0;
|
||||||
|
let moviePlays = 0;
|
||||||
|
let episodePlays = 0;
|
||||||
|
let musicPlays = 0;
|
||||||
|
|
||||||
|
// Find the relevant stat sections
|
||||||
|
const topMovies = stats.find((s: any) => s.stat_id === "top_movies");
|
||||||
|
const topTV = stats.find((s: any) => s.stat_id === "top_tv");
|
||||||
|
const topMusic = stats.find((s: any) => s.stat_id === "top_music");
|
||||||
|
|
||||||
|
if (topMovies?.rows) {
|
||||||
|
moviePlays = topMovies.rows.reduce(
|
||||||
|
(sum: number, item: any) => sum + (item.total_plays || 0),
|
||||||
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sum all series data for each date
|
|
||||||
return data.categories.map((date, index) => {
|
|
||||||
const totalValue = data.series
|
|
||||||
.filter(s => s.name !== "Total")
|
|
||||||
.reduce((sum, series) => sum + (series.data[index] || 0), 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
date,
|
|
||||||
plays: yAxis === "plays" ? totalValue : 0,
|
|
||||||
duration: yAxis === "duration" ? totalValue : 0
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Tautulli] Error fetching plays by date:", error);
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch plays by day of week (already aggregated!)
|
if (topTV?.rows) {
|
||||||
async function fetchPlaysByDayOfWeek(
|
episodePlays = topTV.rows.reduce(
|
||||||
timeRange = 30,
|
(sum: number, item: any) => sum + (item.total_plays || 0),
|
||||||
yAxis: "plays" | "duration" = "plays",
|
0
|
||||||
userId?: number
|
|
||||||
): Promise<{
|
|
||||||
labels: string[];
|
|
||||||
movies: number[];
|
|
||||||
episodes: number[];
|
|
||||||
music: number[];
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const params: Record<string, any> = {
|
|
||||||
time_range: timeRange,
|
|
||||||
y_axis: yAxis,
|
|
||||||
grouping: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
if (userId) {
|
|
||||||
params.user_id = userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: PlaysGraphData = await tautulliRequest(
|
|
||||||
"get_plays_by_dayofweek",
|
|
||||||
params
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Map series names to our expected format
|
|
||||||
const movies =
|
|
||||||
data.series.find(s => s.name === "Movies")?.data ||
|
|
||||||
new Array(7).fill(0);
|
|
||||||
const episodes =
|
|
||||||
data.series.find(s => s.name === "TV")?.data || new Array(7).fill(0);
|
|
||||||
const music =
|
|
||||||
data.series.find(s => s.name === "Music")?.data || new Array(7).fill(0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
labels: data.categories,
|
|
||||||
movies,
|
|
||||||
episodes,
|
|
||||||
music
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Tautulli] Error fetching plays by day of week:", error);
|
|
||||||
return {
|
|
||||||
labels: [
|
|
||||||
"Sunday",
|
|
||||||
"Monday",
|
|
||||||
"Tuesday",
|
|
||||||
"Wednesday",
|
|
||||||
"Thursday",
|
|
||||||
"Friday",
|
|
||||||
"Saturday"
|
|
||||||
],
|
|
||||||
movies: new Array(7).fill(0),
|
|
||||||
episodes: new Array(7).fill(0),
|
|
||||||
music: new Array(7).fill(0)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch plays by hour of day (already aggregated!)
|
if (topMusic?.rows) {
|
||||||
async function fetchPlaysByHourOfDay(
|
musicPlays = topMusic.rows.reduce(
|
||||||
timeRange = 30,
|
(sum: number, item: any) => sum + (item.total_plays || 0),
|
||||||
yAxis: "plays" | "duration" = "plays",
|
0
|
||||||
userId?: number
|
|
||||||
): Promise<{ labels: string[]; data: number[] }> {
|
|
||||||
try {
|
|
||||||
const params: Record<string, any> = {
|
|
||||||
time_range: timeRange,
|
|
||||||
y_axis: yAxis,
|
|
||||||
grouping: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
if (userId) {
|
|
||||||
params.user_id = userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: PlaysGraphData = await tautulliRequest(
|
|
||||||
"get_plays_by_hourofday",
|
|
||||||
params
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sum all series data for each hour
|
|
||||||
const hourlyData = data.categories.map((hour, index) =>
|
|
||||||
data.series.reduce((sum, series) => sum + (series.data[index] || 0), 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
labels: data.categories.map(h => `${h}:00`),
|
|
||||||
data: hourlyData
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Tautulli] Error fetching plays by hour:", error);
|
|
||||||
return {
|
|
||||||
labels: Array.from({ length: 24 }, (_, i) => `${i}:00`),
|
|
||||||
data: new Array(24).fill(0)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch top watched content from home stats
|
totalPlays = moviePlays + episodePlays + musicPlays;
|
||||||
async function fetchTopContent(timeRange = 30, limit = 10, userId?: number) {
|
|
||||||
try {
|
|
||||||
const params: Record<string, any> = {
|
|
||||||
time_range: timeRange,
|
|
||||||
stats_type: "plays",
|
|
||||||
stats_count: limit,
|
|
||||||
grouping: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
if (userId) {
|
// Calculate total hours from duration
|
||||||
params.user_id = userId;
|
if (statsType === "duration") {
|
||||||
}
|
const totalDuration = [topMovies, topTV, topMusic].reduce((sum, stat) => {
|
||||||
|
if (!stat?.rows) return sum;
|
||||||
|
return (
|
||||||
|
sum +
|
||||||
|
stat.rows.reduce(
|
||||||
|
(s: number, item: any) => s + (item.total_duration || 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, 0);
|
||||||
|
totalHours = Math.round(totalDuration / 3600); // Convert seconds to hours
|
||||||
|
}
|
||||||
|
|
||||||
const stats = await tautulliRequest("get_home_stats", params);
|
// Get "last_watched" stat which contains recent items
|
||||||
|
const limit = 12;
|
||||||
// Get "last_watched" stat which contains recent items
|
const lastWatched = stats
|
||||||
const lastWatched = stats.find((s: any) => s.stat_id === "last_watched");
|
.find((s: any) => s.stat_id === "last_watched")
|
||||||
|
.rows.slice(0, limit)
|
||||||
if (!lastWatched?.rows) {
|
.map((item: any) => ({
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return lastWatched.rows.slice(0, limit).map((item: any) => ({
|
|
||||||
title: item.title || item.full_title || "Unknown",
|
title: item.title || item.full_title || "Unknown",
|
||||||
plays: item.total_plays || 0,
|
plays: item.total_plays || 0,
|
||||||
duration: Math.round((item.total_duration || 0) / 60), // Convert to minutes
|
duration: Math.round((item.total_duration || 0) / 60), // Convert to minutes
|
||||||
type: item.media_type || "unknown"
|
type: item.media_type || "unknown"
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
|
||||||
console.error("[Tautulli] Error fetching top content:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fetchHomeStats,
|
totalHours,
|
||||||
fetchPlaysByDate,
|
totalPlays,
|
||||||
fetchPlaysByDayOfWeek,
|
moviePlays,
|
||||||
fetchPlaysByHourOfDay,
|
episodePlays,
|
||||||
fetchTopContent
|
musicPlays,
|
||||||
};
|
lastWatched
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Tautulli] Error fetching home stats:", error);
|
||||||
|
return {
|
||||||
|
totalHours: 0,
|
||||||
|
totalPlays: 0,
|
||||||
|
moviePlays: 0,
|
||||||
|
episodePlays: 0,
|
||||||
|
musicPlays: 0,
|
||||||
|
lastWatched: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch plays by date (already aggregated by Tautulli!)
|
||||||
|
export async function fetchPlaysByDate(
|
||||||
|
timeRange = 30,
|
||||||
|
yAxis: "plays" | "duration" = "plays"
|
||||||
|
): Promise<DayStats[]> {
|
||||||
|
try {
|
||||||
|
const params: Record<string, any> = {
|
||||||
|
days: timeRange,
|
||||||
|
y_axis: yAxis,
|
||||||
|
grouping: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const data: PlaysGraphData = await tautulliRequest("plays_by_date", params);
|
||||||
|
|
||||||
|
// Sum all series data for each date
|
||||||
|
return data.categories.map((date, index) => {
|
||||||
|
const totalValue = data.series
|
||||||
|
.filter(s => s.name !== "Total")
|
||||||
|
.reduce((sum, series) => sum + (series.data[index] || 0), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
plays: yAxis === "plays" ? totalValue : 0,
|
||||||
|
duration: yAxis === "duration" ? totalValue : 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Tautulli] Error fetching plays by date:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch plays by day of week (already aggregated!)
|
||||||
|
export async function fetchPlaysByDayOfWeek(
|
||||||
|
timeRange = 30,
|
||||||
|
yAxis: "plays" | "duration" = "plays"
|
||||||
|
): Promise<{
|
||||||
|
labels: string[];
|
||||||
|
movies: number[];
|
||||||
|
episodes: number[];
|
||||||
|
music: number[];
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const params: Record<string, any> = {
|
||||||
|
days: timeRange,
|
||||||
|
y_axis: yAxis,
|
||||||
|
grouping: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const data: PlaysGraphData = await tautulliRequest(
|
||||||
|
"plays_by_dayofweek",
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map series names to our expected format
|
||||||
|
const movies =
|
||||||
|
data.series.find(s => s.name === "Movies")?.data || new Array(7).fill(0);
|
||||||
|
const episodes =
|
||||||
|
data.series.find(s => s.name === "TV")?.data || new Array(7).fill(0);
|
||||||
|
const music =
|
||||||
|
data.series.find(s => s.name === "Music")?.data || new Array(7).fill(0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: data.categories,
|
||||||
|
movies,
|
||||||
|
episodes,
|
||||||
|
music
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Tautulli] Error fetching plays by day of week:", error);
|
||||||
|
return {
|
||||||
|
labels: [
|
||||||
|
"Sunday",
|
||||||
|
"Monday",
|
||||||
|
"Tuesday",
|
||||||
|
"Wednesday",
|
||||||
|
"Thursday",
|
||||||
|
"Friday",
|
||||||
|
"Saturday"
|
||||||
|
],
|
||||||
|
movies: new Array(7).fill(0),
|
||||||
|
episodes: new Array(7).fill(0),
|
||||||
|
music: new Array(7).fill(0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch plays by hour of day (already aggregated!)
|
||||||
|
export async function fetchPlaysByHourOfDay(
|
||||||
|
timeRange = 30,
|
||||||
|
yAxis: "plays" | "duration" = "plays"
|
||||||
|
): Promise<{ labels: string[]; data: number[] }> {
|
||||||
|
try {
|
||||||
|
const params: Record<string, any> = {
|
||||||
|
days: timeRange,
|
||||||
|
y_axis: yAxis,
|
||||||
|
grouping: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const data: PlaysGraphData = await tautulliRequest(
|
||||||
|
"plays_by_hourofday",
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sum all series data for each hour
|
||||||
|
const hourlyData = data.categories.map((hour, index) =>
|
||||||
|
data.series.reduce((sum, series) => sum + (series.data[index] || 0), 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: data.categories.map(h => `${h}:00`),
|
||||||
|
data: hourlyData
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Tautulli] Error fetching plays by hour:", error);
|
||||||
|
return {
|
||||||
|
labels: Array.from({ length: 24 }, (_, i) => `${i}:00`),
|
||||||
|
data: new Array(24).fill(0)
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
56
src/composables/useTheme.ts
Normal file
56
src/composables/useTheme.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { ref, computed } from "vue";
|
||||||
|
|
||||||
|
type Theme = "light" | "dark" | "auto";
|
||||||
|
|
||||||
|
const currentTheme = ref<Theme>("auto");
|
||||||
|
|
||||||
|
function systemDarkModeEnabled(): boolean {
|
||||||
|
const computedStyle = window.getComputedStyle(document.body);
|
||||||
|
if (computedStyle?.colorScheme != null) {
|
||||||
|
return computedStyle.colorScheme.includes("dark");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme: Theme) {
|
||||||
|
if (theme === "auto") {
|
||||||
|
const systemDark = systemDarkModeEnabled();
|
||||||
|
document.body.className = systemDark ? "dark" : "light";
|
||||||
|
} else {
|
||||||
|
document.body.className = theme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const savedTheme = computed(() => {
|
||||||
|
return (localStorage.getItem("theme-preference") as Theme) || "auto";
|
||||||
|
});
|
||||||
|
|
||||||
|
function initTheme() {
|
||||||
|
const theme = savedTheme.value;
|
||||||
|
currentTheme.value = theme;
|
||||||
|
applyTheme(theme);
|
||||||
|
|
||||||
|
// Listen for system theme changes when in auto mode
|
||||||
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
mediaQuery.addEventListener("change", e => {
|
||||||
|
const currentSetting = localStorage.getItem("theme-preference") as Theme;
|
||||||
|
if (currentSetting === "auto") {
|
||||||
|
document.body.className = e.matches ? "dark" : "light";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTheme(theme: Theme) {
|
||||||
|
currentTheme.value = theme;
|
||||||
|
localStorage.setItem("theme-preference", theme);
|
||||||
|
applyTheme(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentTheme,
|
||||||
|
savedTheme,
|
||||||
|
initTheme,
|
||||||
|
setTheme
|
||||||
|
};
|
||||||
|
}
|
||||||
23
src/icons/IconCookie.vue
Normal file
23
src/icons/IconCookie.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
id="icon-cookie"
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style="transition-duration: 0s"
|
||||||
|
@click="$emit('click')"
|
||||||
|
@keydown="event => $emit('keydown', event)"
|
||||||
|
>
|
||||||
|
<circle cx="10" cy="21" r="2" fill="inherit" />
|
||||||
|
<circle cx="23" cy="20" r="2" fill="inherit" />
|
||||||
|
<circle cx="13" cy="10" r="2" fill="inherit" />
|
||||||
|
<circle cx="14" cy="15" r="1" fill="inherit" />
|
||||||
|
<circle cx="23" cy="5" r="2" fill="inherit" />
|
||||||
|
<circle cx="29" cy="3" r="1" fill="inherit" />
|
||||||
|
<circle cx="16" cy="23" r="1" fill="inherit" />
|
||||||
|
<path
|
||||||
|
fill="inherit"
|
||||||
|
d="M16 30C8.3 30 2 23.7 2 16S8.3 2 16 2c0.1 0 0.2 0 0.3 0l1.4 0.1-0.3 1.2c-0.1 0.4-0.2 0.9-0.2 1.3 0 2.8 2.2 5 5 5 1 0 2-0.3 2.9-0.9l1.3 1.5c-0.4 0.4-0.6 0.9-0.6 1.4 0 1.3 1.3 2.4 2.7 1.9l1.2-0.5 0.2 1.3C30 14.9 30 15.5 30 16c0 7.7-6.3 14-14 14zM15.3 4C9 4.4 4 9.6 4 16c0 6.6 5.4 12 12 12s12-5.4 12-12c0-0.1 0-0.3 0-0.4-2.3 0.1-4.2-1.7-4.2-4 0-0.1 0-0.1 0-0.2-0.5 0.1-1 0.2-1.6 0.2-3.9 0-7-3.1-7-7 0-0.2 0-0.4 0.1-0.6z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
18
src/icons/IconDatabase.vue
Normal file
18
src/icons/IconDatabase.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
id="icon-database"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style="transition-duration: 0s"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
@click="$emit('click')"
|
||||||
|
@keydown="event => $emit('keydown', event)"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
10
src/icons/IconPlex.vue
Normal file
10
src/icons/IconPlex.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<template>
|
||||||
|
<svg viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M16 2c-7.732 0-14 6.268-14 14s6.268 14 14 14 14-6.268 14-14-6.268-14-14-14zM16 28c-6.627 0-12-5.373-12-12s5.373-12 12-12c6.627 0 12 5.373 12 12s-5.373 12-12 12z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M13.333 10.667c-0.368 0-0.667 0.299-0.667 0.667v9.333c0 0.245 0.135 0.469 0.349 0.585 0.215 0.117 0.477 0.104 0.683-0.032l6.667-4.667c0.188-0.131 0.301-0.349 0.301-0.583s-0.113-0.452-0.301-0.583l-6.667-4.667c-0.109-0.076-0.239-0.115-0.365-0.115zM14.667 13.115l4.448 3.115-4.448 3.115v-6.229z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
17
src/icons/IconServer.vue
Normal file
17
src/icons/IconServer.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<svg viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M23 10V6c0-0.4719-0.1656-0.9094-0.4406-1.2531v0l-3.6688-4.5594C18.7969 0.0687 18.6531 0 18.5 0h-13C5.35 0 5.2062 0.0687 5.1094 0.1875L1.4406 4.75C1.1656 5.0906 1 5.5281 1 6v4c0 0.5969 0.2625 1.1344 0.6781 1.5C1.2625 11.8656 1 12.4031 1 13v2c0 0.5969 0.2625 1.1344 0.6781 1.5C1.2625 16.8656 1 17.4031 1 18v4c0 1.1031 0.8969 2 2 2h18c1.1031 0 2-0.8969 2-2v-4c0-0.5969-0.2625-1.1344-0.6781-1.5C22.7375 16.1344 23 15.5969 23 15v-2c0-0.5969-0.2625-1.1344-0.6781-1.5C22.7375 11.1344 23 10.5969 23 10zM5.7406 1h12.5219l2.4125 3H3.325zM21 22H3l-31e-4-4c0 0 0 0 31e-4 0v-1h18zM21.0031 15c0 0-31e-4 0 0 0L21 16H3v-1l-31e-4-2c0 0 0 0 31e-4 0v-1h18v1zM3 11V6h18v5z"
|
||||||
|
/>
|
||||||
|
<rect width="3" height="1.000008" x="16.999992" y="7.999992" />
|
||||||
|
<rect width="1.000008" height="1.000008" x="15" y="7.999992" />
|
||||||
|
<rect width="3" height="1.000008" x="16.999992" y="13.000008" />
|
||||||
|
<rect width="1.000008" height="1.000008" x="15" y="13.000008" />
|
||||||
|
<rect width="1.000008" height="1.000008" x="4.000008" y="18" />
|
||||||
|
<rect width="1.000008" height="1.000008" x="6" y="18" />
|
||||||
|
<rect width="1.000008" height="1.000008" x="7.999992" y="18" />
|
||||||
|
<rect width="1.000008" height="1.000008" x="10.000008" y="18" />
|
||||||
|
<rect width="3" height="1.000008" x="16.999992" y="19.999992" />
|
||||||
|
<rect width="1.000008" height="1.000008" x="15" y="19.999992" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
16
src/icons/IconSync.vue
Normal file
16
src/icons/IconSync.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline points="23 4 23 10 17 10"></polyline>
|
||||||
|
<polyline points="1 20 1 14 7 14"></polyline>
|
||||||
|
<path
|
||||||
|
d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
17
src/icons/IconTimer.vue
Normal file
17
src/icons/IconTimer.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
id="icon-timer"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style="transition-duration: 0s"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
@click="$emit('click')"
|
||||||
|
@keydown="event => $emit('keydown', event)"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="12 6 12 12 16 14" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
32
src/main.ts
32
src/main.ts
@@ -2,42 +2,14 @@ import { createApp } from "vue";
|
|||||||
import router from "./routes";
|
import router from "./routes";
|
||||||
import store from "./store";
|
import store from "./store";
|
||||||
import Toast from "./plugins/Toast";
|
import Toast from "./plugins/Toast";
|
||||||
|
import { useTheme } from "./composables/useTheme";
|
||||||
|
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
|
|
||||||
// Initialize theme from localStorage
|
|
||||||
function initTheme() {
|
|
||||||
const savedTheme = localStorage.getItem("theme-preference") || "auto";
|
|
||||||
|
|
||||||
function systemDarkModeEnabled() {
|
|
||||||
const computedStyle = window.getComputedStyle(document.body);
|
|
||||||
if (computedStyle?.colorScheme != null) {
|
|
||||||
return computedStyle.colorScheme.includes("dark");
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (savedTheme === "auto") {
|
|
||||||
const systemDark = systemDarkModeEnabled();
|
|
||||||
document.body.className = systemDark ? "dark" : "light";
|
|
||||||
|
|
||||||
// Listen for system theme changes
|
|
||||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
||||||
mediaQuery.addEventListener("change", e => {
|
|
||||||
const currentTheme = localStorage.getItem("theme-preference");
|
|
||||||
if (currentTheme === "auto") {
|
|
||||||
document.body.className = e.matches ? "dark" : "light";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
document.body.className = savedTheme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize theme before mounting
|
// Initialize theme before mounting
|
||||||
|
const { initTheme } = useTheme();
|
||||||
initTheme();
|
initTheme();
|
||||||
|
|
||||||
store.dispatch("darkmodeModule/findAndSetDarkmodeSupported");
|
|
||||||
store.dispatch("user/initUserFromCookie");
|
store.dispatch("user/initUserFromCookie");
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ export interface CookieOptions {
|
|||||||
/**
|
/**
|
||||||
* Read a cookie value.
|
* Read a cookie value.
|
||||||
*/
|
*/
|
||||||
export function getCookie(name: string): string | null {
|
export function getAuthorizationCookie(): string | null {
|
||||||
|
const key = 'authorization';
|
||||||
const array = document.cookie.split(";");
|
const array = document.cookie.split(";");
|
||||||
let match = null;
|
let match = null;
|
||||||
|
|
||||||
array.forEach((item: string) => {
|
array.forEach((item: string) => {
|
||||||
const query = `${name}=`;
|
const query = `${key}=`;
|
||||||
if (!item.trim().startsWith(query)) return;
|
if (!item.trim().startsWith(query)) return;
|
||||||
match = item.trim().substring(query.length);
|
match = item.trim().substring(query.length);
|
||||||
});
|
});
|
||||||
@@ -132,7 +133,7 @@ const userModule: Module<UserState, RootState> = {
|
|||||||
/* ── Actions ─────────────────────────────────────────────────── */
|
/* ── Actions ─────────────────────────────────────────────────── */
|
||||||
actions: {
|
actions: {
|
||||||
async initUserFromCookie({ dispatch }): Promise<boolean | null> {
|
async initUserFromCookie({ dispatch }): Promise<boolean | null> {
|
||||||
const jwtToken = getCookie("authorization");
|
const jwtToken = getAuthorizationCookie();
|
||||||
if (!jwtToken) return null;
|
if (!jwtToken) return null;
|
||||||
|
|
||||||
const token = parseJwt(jwtToken);
|
const token = parseJwt(jwtToken);
|
||||||
|
|||||||
@@ -1,26 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="plexUserId && plexUsername" class="activity">
|
<div class="activity">
|
||||||
<h1 class="activity__title">Watch Activity</h1>
|
<h1 class="activity__title">Watch Activity</h1>
|
||||||
|
|
||||||
<!-- Stats Overview -->
|
<!-- Stats Overview -->
|
||||||
<div v-if="watchStats" class="stats-overview">
|
<stats-overview :watch-stats="watchStats" />
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value">{{ watchStats.totalPlays }}</div>
|
|
||||||
<div class="stat-label">Total Plays</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value">{{ watchStats.totalHours }}h</div>
|
|
||||||
<div class="stat-label">Watch Time</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value">{{ watchStats.moviePlays }}</div>
|
|
||||||
<div class="stat-label">Movies</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value">{{ watchStats.episodePlays }}</div>
|
|
||||||
<div class="stat-label">Episodes</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
@@ -50,7 +33,7 @@
|
|||||||
|
|
||||||
<div class="activity__charts">
|
<div class="activity__charts">
|
||||||
<div class="chart-card">
|
<div class="chart-card">
|
||||||
<h3 class="chart-card__title">Daily Activity</h3>
|
<h3>Daily Activity</h3>
|
||||||
<div class="chart-card__graph">
|
<div class="chart-card__graph">
|
||||||
<Graph
|
<Graph
|
||||||
v-if="playsByDayData"
|
v-if="playsByDayData"
|
||||||
@@ -65,7 +48,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chart-card">
|
<div class="chart-card">
|
||||||
<h3 class="chart-card__title">Activity by Media Type</h3>
|
<h3>Activity by Media Type</h3>
|
||||||
<div class="chart-card__graph">
|
<div class="chart-card__graph">
|
||||||
<Graph
|
<Graph
|
||||||
v-if="playsByDayofweekData"
|
v-if="playsByDayofweekData"
|
||||||
@@ -80,7 +63,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chart-card">
|
<div class="chart-card">
|
||||||
<h3 class="chart-card__title">Viewing Patterns by Hour</h3>
|
<h3>Viewing Patterns by Hour</h3>
|
||||||
<div class="chart-card__graph">
|
<div class="chart-card__graph">
|
||||||
<Graph
|
<Graph
|
||||||
v-if="hourlyData"
|
v-if="hourlyData"
|
||||||
@@ -96,80 +79,33 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Top Content -->
|
<!-- Top Content -->
|
||||||
<div v-if="topContent.length > 0" class="activity__top-content">
|
<watch-history :top-content="topContent" />
|
||||||
<h3 class="section-title">Most Watched</h3>
|
|
||||||
<div class="top-content-list">
|
|
||||||
<div
|
|
||||||
v-for="(item, index) in topContent"
|
|
||||||
:key="index"
|
|
||||||
class="top-content-item"
|
|
||||||
>
|
|
||||||
<div class="content-rank">{{ index + 1 }}</div>
|
|
||||||
<div class="content-details">
|
|
||||||
<div class="content-title">{{ item.title }}</div>
|
|
||||||
<div class="content-meta">
|
|
||||||
{{ item.type }} • {{ item.plays }} plays • {{ item.duration }}min
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="not-authenticated">
|
|
||||||
<h1><IconStop /> Must be authenticated with Plex</h1>
|
|
||||||
<p>Go to Settings to link your Plex account</p>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { useStore } from "vuex";
|
|
||||||
import Graph from "@/components/Graph.vue";
|
import Graph from "@/components/Graph.vue";
|
||||||
import ToggleButton from "@/components/ui/ToggleButton.vue";
|
import ToggleButton from "@/components/ui/ToggleButton.vue";
|
||||||
import IconStop from "@/icons/IconStop.vue";
|
import StatsOverview from "@/components/activity/StatsOverview.vue";
|
||||||
import type { Ref } from "vue";
|
import WatchHistory from "@/components/activity/WatchHistory.vue";
|
||||||
import { useTautulliStats } from "@/composables/useTautulliStats";
|
import {
|
||||||
|
fetchHomeStats,
|
||||||
|
fetchPlaysByDate,
|
||||||
|
fetchPlaysByDayOfWeek,
|
||||||
|
fetchPlaysByHourOfDay
|
||||||
|
} from "../composables/useTautulliStats";
|
||||||
import {
|
import {
|
||||||
GraphTypes,
|
GraphTypes,
|
||||||
GraphValueTypes,
|
GraphValueTypes,
|
||||||
IGraphData
|
IGraphData
|
||||||
} from "../interfaces/IGraph";
|
} from "../interfaces/IGraph";
|
||||||
|
import type { Ref } from "vue";
|
||||||
const store = useStore();
|
import type { WatchStats } from "../composables/useTautulliStats";
|
||||||
|
|
||||||
const days: Ref<number> = ref(30);
|
const days: Ref<number> = ref(30);
|
||||||
const graphViewMode: Ref<GraphTypes> = ref(GraphTypes.Plays);
|
const graphViewMode: Ref<GraphTypes> = ref(GraphTypes.Plays);
|
||||||
|
|
||||||
// Check both Vuex store and localStorage for Plex user
|
|
||||||
const plexUserId = computed(() => {
|
|
||||||
// First try Vuex store
|
|
||||||
const storeId = store.getters["user/plexUserId"];
|
|
||||||
if (storeId) return storeId;
|
|
||||||
|
|
||||||
// Fallback to localStorage
|
|
||||||
const userData = localStorage.getItem("plex_user_data");
|
|
||||||
if (userData) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(userData).id;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const plexUsername = computed(() => {
|
|
||||||
const userData = localStorage.getItem("plex_user_data");
|
|
||||||
if (userData) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(userData).username;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const graphValueViewMode = [
|
const graphValueViewMode = [
|
||||||
{
|
{
|
||||||
type: GraphTypes.Plays,
|
type: GraphTypes.Plays,
|
||||||
@@ -193,14 +129,6 @@
|
|||||||
graphValueViewMode.find(viewMode => viewMode.type === graphViewMode.value)
|
graphValueViewMode.find(viewMode => viewMode.type === graphViewMode.value)
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
|
||||||
fetchHomeStats,
|
|
||||||
fetchPlaysByDate,
|
|
||||||
fetchPlaysByDayOfWeek,
|
|
||||||
fetchPlaysByHourOfDay,
|
|
||||||
fetchTopContent
|
|
||||||
} = useTautulliStats();
|
|
||||||
|
|
||||||
function convertDateStringToDayMonth(date: string, short = true): string {
|
function convertDateStringToDayMonth(date: string, short = true): string {
|
||||||
if (!date.match(/[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
|
if (!date.match(/[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
|
||||||
return date;
|
return date;
|
||||||
@@ -210,30 +138,8 @@
|
|||||||
return short ? `${month}.${day}` : `${day}.${month}.${year}`;
|
return short ? `${month}.${day}` : `${day}.${month}.${year}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchChartData() {
|
function activityPerDay(dataPromise: Promise<any>) {
|
||||||
if (!plexUserId.value) return;
|
dataPromise.then(dayData => {
|
||||||
|
|
||||||
try {
|
|
||||||
const yAxis =
|
|
||||||
graphViewMode.value === GraphTypes.Plays ? "plays" : "duration";
|
|
||||||
|
|
||||||
// Fetch all data in parallel using efficient Tautulli APIs
|
|
||||||
const [homeStats, dayData, weekData, hourData, topContentData] =
|
|
||||||
await Promise.all([
|
|
||||||
fetchHomeStats(plexUserId.value, days.value, "duration"), // Need duration for hours
|
|
||||||
fetchPlaysByDate(days.value, yAxis, plexUserId.value),
|
|
||||||
fetchPlaysByDayOfWeek(days.value, yAxis, plexUserId.value),
|
|
||||||
fetchPlaysByHourOfDay(days.value, yAxis, plexUserId.value),
|
|
||||||
fetchTopContent(days.value, 10, plexUserId.value)
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Set overall stats
|
|
||||||
watchStats.value = homeStats;
|
|
||||||
|
|
||||||
// Set top content
|
|
||||||
topContent.value = topContentData;
|
|
||||||
|
|
||||||
// Activity per day
|
|
||||||
playsByDayData.value = {
|
playsByDayData.value = {
|
||||||
labels: dayData.map(d =>
|
labels: dayData.map(d =>
|
||||||
convertDateStringToDayMonth(d.date, dayData.length < 365)
|
convertDateStringToDayMonth(d.date, dayData.length < 365)
|
||||||
@@ -248,8 +154,11 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Activity by day of week (stacked by media type)
|
function playsByDayOfWeek(dataPromise: Promise<any>) {
|
||||||
|
dataPromise.then(weekData => {
|
||||||
playsByDayofweekData.value = {
|
playsByDayofweekData.value = {
|
||||||
labels: weekData.labels,
|
labels: weekData.labels,
|
||||||
series: [
|
series: [
|
||||||
@@ -258,22 +167,42 @@
|
|||||||
{ name: "Music", data: weekData.music }
|
{ name: "Music", data: weekData.music }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Hourly distribution
|
function hourly(hourlyPromise: Promise<any>) {
|
||||||
|
hourlyPromise.then(hourData => {
|
||||||
hourlyData.value = {
|
hourlyData.value = {
|
||||||
labels: hourData.labels,
|
labels: hourData.labels,
|
||||||
series: [{ name: "Plays", data: hourData.data }]
|
series: [{ name: "Plays", data: hourData.data }]
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchChartData() {
|
||||||
|
try {
|
||||||
|
const yAxis =
|
||||||
|
graphViewMode.value === GraphTypes.Plays ? "plays" : "duration";
|
||||||
|
|
||||||
|
// Fetch all data in parallel using efficient Tautulli APIs
|
||||||
|
fetchHomeStats(days.value, "duration").then(
|
||||||
|
(homeStats: WatchStats) => (watchStats.value = homeStats)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Activity per day (line graph of last n days)
|
||||||
|
activityPerDay(fetchPlaysByDate(days.value, yAxis));
|
||||||
|
|
||||||
|
// Activity by day of week (stacked by media type)
|
||||||
|
playsByDayOfWeek(fetchPlaysByDayOfWeek(days.value, yAxis));
|
||||||
|
|
||||||
|
// Hourly distribution
|
||||||
|
hourly(fetchPlaysByHourOfDay(days.value, yAxis));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[ActivityPage] Error fetching chart data:", error);
|
console.error("[ActivityPage] Error fetching chart data:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(fetchChartData);
|
||||||
if (plexUsername.value) {
|
|
||||||
fetchChartData();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -297,7 +226,7 @@
|
|||||||
|
|
||||||
@include mobile-only {
|
@include mobile-only {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
margin: 0 0 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,37 +239,8 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__top-content {
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// .filter {
|
|
||||||
// display: flex;
|
|
||||||
// flex-direction: row;
|
|
||||||
// flex-wrap: wrap;
|
|
||||||
// align-items: center;
|
|
||||||
// margin-bottom: 2rem;
|
|
||||||
|
|
||||||
// h2 {
|
|
||||||
// margin-bottom: 0.5rem;
|
|
||||||
// width: 100%;
|
|
||||||
// font-weight: 400;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// &-item:not(:first-of-type) {
|
|
||||||
// margin-left: 1rem;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// .dayinput {
|
|
||||||
// font-size: 1.2rem;
|
|
||||||
// max-width: 3rem;
|
|
||||||
// background-color: $background-ui;
|
|
||||||
// color: $text-color;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
.chart-card {
|
.chart-card {
|
||||||
background: var(--background-ui);
|
background: var(--background-ui);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -351,7 +251,7 @@
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
h3 {
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -374,13 +274,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
|
||||||
margin: 0 0 1rem 0;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: $text-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
@@ -450,144 +343,4 @@
|
|||||||
color: var(--text-color-60);
|
color: var(--text-color-60);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-overview {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
||||||
gap: 1.5rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: var(--background-ui);
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
text-align: center;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--highlight-color);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--text-color-60);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
font-weight: 300;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-content-list {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-content-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
background: var(--background-ui);
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid var(--text-color-50);
|
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--text-color);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-rank {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--highlight-color);
|
|
||||||
min-width: 2.5rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-details {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-title {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-color);
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-meta {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-color-60);
|
|
||||||
}
|
|
||||||
|
|
||||||
.not-authenticated {
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 3rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
margin-right: 1rem;
|
|
||||||
height: 3rem;
|
|
||||||
width: 3rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
color: var(--text-color-60);
|
|
||||||
}
|
|
||||||
|
|
||||||
@include mobile {
|
|
||||||
padding: 1rem;
|
|
||||||
padding-right: 0;
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 1.65rem;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
margin-right: 1rem;
|
|
||||||
height: 2rem;
|
|
||||||
width: 2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
53
src/pages/MissingPlexAuthPage.vue
Normal file
53
src/pages/MissingPlexAuthPage.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<template>
|
||||||
|
<div class="not-authenticated">
|
||||||
|
<h1><IconStop /> Must be authenticated with Plex</h1>
|
||||||
|
<p>Go to Settings to link your Plex account</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import IconStop from "@/icons/IconStop.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "scss/media-queries";
|
||||||
|
|
||||||
|
.not-authenticated {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
margin-right: 1rem;
|
||||||
|
height: 3rem;
|
||||||
|
width: 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--text-color-60);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
padding: 1rem;
|
||||||
|
padding-right: 0;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.65rem;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
margin-right: 1rem;
|
||||||
|
height: 2rem;
|
||||||
|
width: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
<seasoned-button @click="submit">Register</seasoned-button>
|
<seasoned-button @click="submit">Register</seasoned-button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<router-link class="link" to="/signin"
|
<router-link class="link" to="/login"
|
||||||
>Have a user? Sign in here</router-link
|
>Have a user? Sign in here</router-link
|
||||||
>
|
>
|
||||||
|
|
||||||
|
|||||||
@@ -2,58 +2,30 @@
|
|||||||
<section class="settings">
|
<section class="settings">
|
||||||
<div class="settings__container">
|
<div class="settings__container">
|
||||||
<!-- Profile Hero Card -->
|
<!-- Profile Hero Card -->
|
||||||
<div class="profile-hero">
|
<ProfileHero />
|
||||||
<div class="profile-hero__main">
|
|
||||||
<div class="profile-hero__avatar">
|
|
||||||
<div class="avatar-large">{{ userInitials }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="profile-hero__info">
|
|
||||||
<h1 class="profile-hero__name">{{ username }}</h1>
|
|
||||||
<span :class="['profile-hero__badge', `badge--${userRole}`]">
|
|
||||||
<a v-if="userRole === 'admin'" href="/admin">{{ userRole }}</a>
|
|
||||||
<span v-else>{{ userRole }}</span>
|
|
||||||
</span>
|
|
||||||
<p class="profile-hero__member">Member since {{ memberSince }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="profile-hero__stats">
|
|
||||||
<div class="stat-large">
|
|
||||||
<span class="stat-large__value">{{ stats.totalRequests }}</span>
|
|
||||||
<span class="stat-large__label">Requests</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-divider"></div>
|
|
||||||
<div class="stat-large">
|
|
||||||
<span class="stat-large__value">{{ stats.magnetsAdded }}</span>
|
|
||||||
<span class="stat-large__label">Magnets Added</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Settings Grid -->
|
<!-- Settings Grid -->
|
||||||
<div class="settings__grid">
|
<div class="settings__grid">
|
||||||
<!-- Left Column: Quick Settings -->
|
<!-- Left Column -->
|
||||||
<div class="settings__column settings__column--left">
|
<div class="settings__column">
|
||||||
<section class="settings-section settings-section--compact">
|
<section class="settings-section settings-section--compact">
|
||||||
<h2 class="section-header">Appearance</h2>
|
<div class="settings-section-header"><h2>Appearance</h2></div>
|
||||||
<theme-preferences />
|
<theme-preferences />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="settings-section settings-section--compact">
|
<section class="settings-section settings-section--compact">
|
||||||
<h2 class="section-header">Security</h2>
|
<security-settings />
|
||||||
<change-password />
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Column: Data-Heavy Sections -->
|
<!-- Right Column -->
|
||||||
<div class="settings__column settings__column--right">
|
<div class="settings__column">
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h2 class="section-header">Integrations</h2>
|
<div class="settings-section-header"><h2>Integrations</h2></div>
|
||||||
<plex-settings @reload="reloadSettings" />
|
<plex-settings @reload="reloadSettings" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h2 class="section-header">Data & Privacy</h2>
|
|
||||||
<data-export />
|
<data-export />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,12 +35,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { inject, computed, onMounted } from "vue";
|
import { inject, onMounted } from "vue";
|
||||||
import { useStore } from "vuex";
|
import { useStore } from "vuex";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
|
import ProfileHero from "@/components/settings/ProfileHero.vue";
|
||||||
import ThemePreferences from "@/components/settings/ThemePreferences.vue";
|
import ThemePreferences from "@/components/settings/ThemePreferences.vue";
|
||||||
import PlexSettings from "@/components/settings/PlexSettings.vue";
|
import PlexSettings from "@/components/settings/PlexSettings.vue";
|
||||||
import ChangePassword from "@/components/profile/ChangePassword.vue";
|
import SecuritySettings from "@/components/settings/SecuritySettings.vue";
|
||||||
import DataExport from "@/components/settings/DataExport.vue";
|
import DataExport from "@/components/settings/DataExport.vue";
|
||||||
import { getSettings } from "../api";
|
import { getSettings } from "../api";
|
||||||
|
|
||||||
@@ -78,29 +51,6 @@
|
|||||||
error;
|
error;
|
||||||
} = inject("notifications");
|
} = inject("notifications");
|
||||||
|
|
||||||
const username = computed(() => store.getters["user/username"] || "User");
|
|
||||||
const userRole = computed(() =>
|
|
||||||
store.getters["user/admin"] ? "admin" : "user"
|
|
||||||
);
|
|
||||||
|
|
||||||
const userInitials = computed(() => {
|
|
||||||
return username.value.slice(0, 2).toUpperCase();
|
|
||||||
});
|
|
||||||
|
|
||||||
const memberSince = computed(() => {
|
|
||||||
const date = new Date();
|
|
||||||
date.setMonth(date.getMonth() - 6);
|
|
||||||
return date.toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
year: "numeric"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const stats = {
|
|
||||||
totalRequests: 45,
|
|
||||||
magnetsAdded: 127
|
|
||||||
};
|
|
||||||
|
|
||||||
function displayWarningIfMissingPlexAccount() {
|
function displayWarningIfMissingPlexAccount() {
|
||||||
if (route.query?.missingPlexAccount === "true") {
|
if (route.query?.missingPlexAccount === "true") {
|
||||||
notifications.error({
|
notifications.error({
|
||||||
@@ -127,13 +77,14 @@
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import "scss/variables";
|
@import "scss/variables";
|
||||||
@import "scss/media-queries";
|
@import "scss/media-queries";
|
||||||
|
@import "scss/shared-settings";
|
||||||
|
|
||||||
.settings {
|
.settings {
|
||||||
min-height: calc(100vh - var(--header-size));
|
min-height: calc(100vh - var(--header-size));
|
||||||
padding: 2rem 1.5rem;
|
padding: 2rem 1.5rem;
|
||||||
|
|
||||||
@include mobile-only {
|
@include mobile-only {
|
||||||
padding: 1rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__container {
|
&__container {
|
||||||
@@ -167,184 +118,6 @@
|
|||||||
@include mobile-only {
|
@include mobile-only {
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--left {
|
|
||||||
// Quick settings - lighter, more concise
|
|
||||||
}
|
|
||||||
|
|
||||||
&--right {
|
|
||||||
// Data-heavy sections
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-hero {
|
|
||||||
background-color: var(--background-color-secondary);
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
border: 1px solid var(--background-40);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 2rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 1.5rem 1.25rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
text-align: center;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__main {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1.5rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__avatar {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.35rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__name {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.1;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.25rem 0.7rem;
|
|
||||||
border-radius: 2rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-weight: 600;
|
|
||||||
width: fit-content;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
padding: 0.2rem 0.6rem;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.badge--admin {
|
|
||||||
background-color: var(--color-warning);
|
|
||||||
color: $black;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.badge--user {
|
|
||||||
background-color: var(--background-40);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__member {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: $text-color-70;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__stats {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1.75rem;
|
|
||||||
padding-left: 1.75rem;
|
|
||||||
border-left: 1px solid var(--background-40);
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
width: 100%;
|
|
||||||
padding: 1rem 0 0 0;
|
|
||||||
border-left: none;
|
|
||||||
border-top: 1px solid var(--background-40);
|
|
||||||
justify-content: center;
|
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-large {
|
|
||||||
width: 70px;
|
|
||||||
height: 70px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: linear-gradient(
|
|
||||||
135deg,
|
|
||||||
var(--highlight-color),
|
|
||||||
var(--color-green-70)
|
|
||||||
);
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 1.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: $white;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-large {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
|
|
||||||
&__value {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--highlight-color);
|
|
||||||
line-height: 1;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: $text-color-70;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-divider {
|
|
||||||
width: 1px;
|
|
||||||
height: 45px;
|
|
||||||
background-color: var(--background-40);
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
height: 45px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,36 +128,16 @@
|
|||||||
border: 1px solid var(--background-40);
|
border: 1px solid var(--background-40);
|
||||||
|
|
||||||
@include mobile-only {
|
@include mobile-only {
|
||||||
padding: 1rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--compact {
|
&--compact {
|
||||||
// Tighter spacing for quick settings
|
// Tighter padding for quick settings, but same header size
|
||||||
.section-header {
|
padding: 1rem;
|
||||||
font-size: 1.25rem;
|
|
||||||
margin-bottom: 0.85rem;
|
|
||||||
padding-bottom: 0.65rem;
|
|
||||||
|
|
||||||
@include mobile-only {
|
@include mobile-only {
|
||||||
font-size: 1.2rem;
|
padding: 0.5rem;
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
padding-bottom: 0.6rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header {
|
|
||||||
margin: 0 0 1rem 0;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
padding-bottom: 0.75rem;
|
|
||||||
border-bottom: 1px solid var(--background-40);
|
|
||||||
|
|
||||||
@include mobile-only {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
margin-bottom: 0.85rem;
|
|
||||||
padding-bottom: 0.65rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
|
|
||||||
@include mobile-only {
|
@include mobile-only {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
margin: 0 0 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { RouteRecordRaw, RouteLocationNormalized } from "vue-router";
|
|||||||
|
|
||||||
/* eslint-disable-next-line import-x/no-cycle */
|
/* eslint-disable-next-line import-x/no-cycle */
|
||||||
import store from "./store";
|
import store from "./store";
|
||||||
|
import { usePlexAuth } from "./composables/usePlexAuth";
|
||||||
|
const { getPlexAuthCookie } = usePlexAuth();
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -83,6 +85,11 @@ const routes: RouteRecordRaw[] = [
|
|||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
component: () => import("./pages/AdminPage.vue")
|
component: () => import("./pages/AdminPage.vue")
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "missing-plex-auth",
|
||||||
|
path: "/missing/plex",
|
||||||
|
component: () => import("./pages/MissingPlexAuthPage.vue")
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/:pathMatch(.*)*",
|
path: "/:pathMatch(.*)*",
|
||||||
name: "NotFound",
|
name: "NotFound",
|
||||||
@@ -111,16 +118,8 @@ const hasPlexAccount = () => {
|
|||||||
if (store.getters["user/plexUserId"] !== null) return true;
|
if (store.getters["user/plexUserId"] !== null) return true;
|
||||||
|
|
||||||
// Fallback to localStorage
|
// Fallback to localStorage
|
||||||
const userData = localStorage.getItem("plex_user_data");
|
const authToken = getPlexAuthCookie();
|
||||||
if (userData) {
|
return !!authToken;
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(userData);
|
|
||||||
return parsed.id !== null && parsed.id !== undefined;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
};
|
||||||
const hamburgerIsOpen = () => store.getters["hamburger/isOpen"];
|
const hamburgerIsOpen = () => store.getters["hamburger/isOpen"];
|
||||||
|
|
||||||
@@ -136,15 +135,14 @@ router.beforeEach(
|
|||||||
// send user to signin page.
|
// send user to signin page.
|
||||||
if (to.matched.some(record => record.meta.requiresAuth)) {
|
if (to.matched.some(record => record.meta.requiresAuth)) {
|
||||||
if (!loggedIn()) {
|
if (!loggedIn()) {
|
||||||
next({ path: "/signin" });
|
next({ path: "/login" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (to.matched.some(record => record.meta.requiresPlexAccount)) {
|
if (to.matched.some(record => record.meta.requiresPlexAccount)) {
|
||||||
if (!hasPlexAccount()) {
|
if (!hasPlexAccount()) {
|
||||||
next({
|
next({
|
||||||
path: "/settings",
|
path: "/missing/plex"
|
||||||
query: { missingPlexAccount: true }
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/scss/shared-settings.scss
Normal file
30
src/scss/shared-settings.scss
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
@import "./media-queries.scss";
|
||||||
|
|
||||||
|
.settings-section-card {
|
||||||
|
padding: 0.85rem;
|
||||||
|
background-color: var(--background-ui);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
border-left: 3px solid var(--highlight-color);
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-header {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-color-70);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/utils.ts
14
src/utils.ts
@@ -129,3 +129,17 @@ export function convertSecondsToHumanReadable(_value, values = null) {
|
|||||||
|
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatNumber(n: number) {
|
||||||
|
if (!n?.toString()) return n;
|
||||||
|
|
||||||
|
return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return "0 Bytes";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["Bytes", "KB", "MB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export function getLibraryIcon(type: string): string {
|
export function getLibraryIcon(type: string): string {
|
||||||
const icons: Record<string, string> = {
|
const icons: Record<string, string> = {
|
||||||
movies: "🎬",
|
movies: "🎬",
|
||||||
shows: "📺",
|
"tv shows": "📺",
|
||||||
music: "🎵"
|
music: "🎵"
|
||||||
};
|
};
|
||||||
return icons[type] || "📁";
|
return icons[type] || "📁";
|
||||||
@@ -10,7 +10,7 @@ export function getLibraryIcon(type: string): string {
|
|||||||
export function getLibraryIconComponent(type: string): string {
|
export function getLibraryIconComponent(type: string): string {
|
||||||
const components: Record<string, string> = {
|
const components: Record<string, string> = {
|
||||||
movies: "IconMovie",
|
movies: "IconMovie",
|
||||||
shows: "IconShow",
|
"tv shows": "IconShow",
|
||||||
music: "IconMusic"
|
music: "IconMusic"
|
||||||
};
|
};
|
||||||
return components[type] || "IconMovie";
|
return components[type] || "IconMovie";
|
||||||
@@ -19,7 +19,7 @@ export function getLibraryIconComponent(type: string): string {
|
|||||||
export function getLibraryTitle(type: string): string {
|
export function getLibraryTitle(type: string): string {
|
||||||
const titles: Record<string, string> = {
|
const titles: Record<string, string> = {
|
||||||
movies: "Movies",
|
movies: "Movies",
|
||||||
shows: "TV Shows",
|
"tv shows": "TV Shows",
|
||||||
music: "Music"
|
music: "Music"
|
||||||
};
|
};
|
||||||
return titles[type] || type;
|
return titles[type] || type;
|
||||||
@@ -62,8 +62,8 @@ export function processLibraryItem(
|
|||||||
// Get poster/thumbnail URL
|
// Get poster/thumbnail URL
|
||||||
let posterUrl = null;
|
let posterUrl = null;
|
||||||
|
|
||||||
// For TV shows, prefer grandparentThumb (show poster) over thumb (episode thumbnail)
|
// For TV tv shows, prefer grandparentThumb (show poster) over thumb (episode thumbnail)
|
||||||
if (libraryType === "shows") {
|
if (libraryType === "tv shows") {
|
||||||
if (item.grandparentThumb) {
|
if (item.grandparentThumb) {
|
||||||
posterUrl = `${serverUrl}${item.grandparentThumb}?X-Plex-Token=${authToken}`;
|
posterUrl = `${serverUrl}${item.grandparentThumb}?X-Plex-Token=${authToken}`;
|
||||||
} else if (item.thumb) {
|
} else if (item.thumb) {
|
||||||
@@ -92,14 +92,14 @@ export function processLibraryItem(
|
|||||||
plexUrl = `https://app.plex.tv/desktop/#!/server/${machineIdentifier}/details?key=${encodedKey}`;
|
plexUrl = `https://app.plex.tv/desktop/#!/server/${machineIdentifier}/details?key=${encodedKey}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For shows, use grandparent data (show info) instead of episode info
|
// For tv shows, use grandparent data (show info) instead of episode info
|
||||||
const title =
|
const title =
|
||||||
libraryType === "shows" && item.grandparentTitle
|
libraryType === "tv shows" && item.grandparentTitle
|
||||||
? item.grandparentTitle
|
? item.grandparentTitle
|
||||||
: item.title;
|
: item.title;
|
||||||
|
|
||||||
const year =
|
const year =
|
||||||
libraryType === "shows" && item.grandparentYear
|
libraryType === "tv shows" && item.grandparentYear
|
||||||
? item.grandparentYear
|
? item.grandparentYear
|
||||||
: item.year || item.parentYear || new Date().getFullYear();
|
: item.year || item.parentYear || new Date().getFullYear();
|
||||||
|
|
||||||
@@ -109,11 +109,12 @@ export function processLibraryItem(
|
|||||||
poster: posterUrl,
|
poster: posterUrl,
|
||||||
fallbackIcon: getLibraryIcon(libraryType),
|
fallbackIcon: getLibraryIcon(libraryType),
|
||||||
rating: item.rating ? Math.round(item.rating * 10) / 10 : null,
|
rating: item.rating ? Math.round(item.rating * 10) / 10 : null,
|
||||||
|
type: libraryType,
|
||||||
ratingKey,
|
ratingKey,
|
||||||
plexUrl
|
plexUrl
|
||||||
};
|
};
|
||||||
|
|
||||||
if (libraryType === "shows") {
|
if (libraryType === "tv shows") {
|
||||||
return {
|
return {
|
||||||
...baseItem,
|
...baseItem,
|
||||||
episodes: item.leafCount || 0
|
episodes: item.leafCount || 0
|
||||||
@@ -157,7 +158,7 @@ export function calculateDuration(metadata: any[], libraryType: string) {
|
|||||||
totalDuration += item.duration;
|
totalDuration += item.duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (libraryType === "shows" && item.leafCount) {
|
if (libraryType === "tv shows" && item.leafCount) {
|
||||||
totalEpisodes += item.leafCount;
|
totalEpisodes += item.leafCount;
|
||||||
} else if (libraryType === "music" && item.leafCount) {
|
} else if (libraryType === "music" && item.leafCount) {
|
||||||
totalTracks += item.leafCount;
|
totalTracks += item.leafCount;
|
||||||
|
|||||||
Reference in New Issue
Block a user