Compare commits

...

6 Commits

Author SHA1 Message Date
e69f0c52b8 Minor improvements and code cleanup across components
- Refactor PlexSettings component for better organization
- Add utility functions to utils.ts for shared logic
- Update API methods for improved error handling
- Clean up navigation icon components
- Remove unused code from CommandPalette and SeasonedButton
- Fix minor issues in Movie popup component
- Update page component imports (RegisterPage, TorrentsPage)
2026-03-08 20:57:37 +01:00
1b99399b4c Refactor ActivityPage with extracted stats and history components
- Extract StatsOverview component for watch statistics display
- Extract WatchHistory component for recently watched content
- Reduce ActivityPage from 490 to 346 lines (~29% reduction)
- Move stats card styles to StatsOverview component
- Move watch history list styles to WatchHistory component
- Clean up useTautulliStats composable
- Improve Graph component for better chart rendering
- Maintain three clear sections: stats, controls+graphs, history
- Follow component extraction pattern from settings refactor
2026-03-08 20:57:20 +01:00
990dde4d31 Add Plex integration icons and improve authentication flow
- Create IconPlex with play button symbol for Plex login button
- Create IconServer for server information display
- Create IconSync for library sync operations
- Replace inline SVGs with icon components in PlexAuthButton
- Replace inline SVGs with icon components in PlexServerInfo
- Add MissingPlexAuthPage for better auth error handling
- Update routes to redirect missing Plex auth to dedicated page
- Refactor usePlexApi and usePlexAuth for better composable patterns
- Remove deprecated usePlexLibraries composable
- Improve PlexLibraryModal and PlexLibraryStats components
- Clean up Plex-related helper utilities
2026-03-08 20:57:08 +01:00
493ac02bab Create centralized theme management with useTheme composable
- Extract theme initialization logic from main.ts into useTheme composable
- Add setTheme() and initTheme() functions for consistent theme handling
- Update ThemePreferences to use useTheme instead of duplicate logic
- Remove unused DarkmodeToggle component
- Clean up main.ts from ~49 to 28 lines (theme logic now in composable)
- Establish single source of truth for theme management
- Follow Vue 3 composables pattern for better organization and testability
2026-03-08 20:56:54 +01:00
e8a0598e8f Refactor data management with browser and server storage sections
- Split LocalStorageManager into modular StorageManager component
- Create StorageSectionBrowser for localStorage/sessionStorage/cookies UI
- Create StorageSectionServer for server-side data management (mock)
- Extract ExportSection component from DataExport
- Add storage type icons (IconCookie, IconDatabase, IconTimer)
- Implement collapsible storage sections with visual indicators
- Add colored borders and gradients per storage type
- Display item counts and total size in section headers
- Improve delete button layout using CSS Grid
- Reduce DataExport from ~824 lines to focused component
2026-03-08 20:56:46 +01:00
9c6e6938e9 Refactor settings page with improved component structure
- Split SettingsPage into two-column layout with ProfileHero component
- Extract SecuritySettings component with user-friendly messaging
- Create RequestHistory component for Plex request tracking
- Optimize ThemePreferences component (reduced from ~368 to cleaner structure)
- Improve PasswordGenerator slider UX with better visual feedback
- Standardize typography across all settings sections (h2: 1.5rem, 700 weight)
- Add shared-settings.scss for consistent styling patterns
- Remove redundant ChangePassword description (now in SecuritySettings)
2026-03-08 20:56:34 +01:00
51 changed files with 3156 additions and 3210 deletions

View File

@@ -13,8 +13,6 @@
<!-- Popup that will show above existing rendered content -->
<popup />
<darkmode-toggle />
<!-- Command Palette -->
<command-palette />
</div>
@@ -25,7 +23,6 @@
import NavigationHeader from "@/components/header/NavigationHeader.vue";
import NavigationIcons from "@/components/header/NavigationIcons.vue";
import Popup from "@/components/Popup.vue";
import DarkmodeToggle from "@/components/ui/DarkmodeToggle.vue";
import CommandPalette from "@/components/ui/CommandPalette.vue";
const router = useRouter();

View File

@@ -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 - - -
const fetchGraphData = async (
@@ -543,6 +557,7 @@ const elasticSearchMoviesAndShows = async (query: string, count = 22) => {
};
export {
API_HOSTNAME,
getMovie,
getShow,
getPerson,
@@ -559,6 +574,7 @@ export {
getRequestStatus,
linkPlexAccount,
unlinkPlexAccount,
plexRecentlyAddedInLibrary,
register,
login,
logout,

View File

@@ -56,12 +56,6 @@
const graphCanvas: Ref<HTMLCanvasElement | null> = ref(null);
let graphInstance: Chart | null = null;
/*
|--------------------------------------------------------------------------
| Modern Color System
|--------------------------------------------------------------------------
*/
const graphTemplates = [
{
borderColor: "#6366F1",
@@ -77,12 +71,6 @@
}
];
/*
|--------------------------------------------------------------------------
| Lifecycle
|--------------------------------------------------------------------------
*/
onMounted(() => generateGraph());
watch(() => props.data, generateGraph, { deep: true });
@@ -90,12 +78,6 @@
if (graphInstance) graphInstance.destroy();
});
/*
|--------------------------------------------------------------------------
| Helpers
|--------------------------------------------------------------------------
*/
function removeEmptyDataset(dataset: IGraphDataset) {
return dataset;
return !dataset.data.every(point => point === 0);
@@ -146,12 +128,6 @@
};
}
/*
|--------------------------------------------------------------------------
| Chart Generator
|--------------------------------------------------------------------------
*/
function generateGraph() {
if (!graphCanvas.value) return;

View 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>

View 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>

View File

@@ -42,8 +42,7 @@
.navigation-link {
display: grid;
place-items: center;
height: var(--header-size);
width: var(--header-size);
min-height: var(--header-size);
list-style: none;
padding: 1rem 0.15rem;
text-align: center;

View File

@@ -90,18 +90,10 @@
@include desktop {
grid-template-rows: var(--header-size);
grid-auto-flow: row;
}
@include mobile {
grid-template-columns: 1fr 1fr;
}
}
:global(.navigation-icons > *:last-child) {
margin-top: auto;
justify-self: end;
align-self: end;
background-color: red;
}
</style>

View File

@@ -9,18 +9,9 @@
</div>
<div class="signin-container">
<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" }}
<IconPlex v-if="!loading" class="plex-icon" />
</button>
<p class="popup-note">A popup window will open for authentication</p>
</div>
@@ -30,6 +21,7 @@
<script setup lang="ts">
import { usePlexAuth } from "@/composables/usePlexAuth";
import IconInfo from "@/icons/IconInfo.vue";
import IconPlex from "@/icons/IconPlex.vue";
const emit = defineEmits<{
authSuccess: [token: string];
@@ -134,10 +126,12 @@
cursor: not-allowed;
}
svg {
.plex-icon {
flex-shrink: 0;
width: 22px;
height: 22px;
--size: 24px;
width: var(--size);
height: var(--size);
fill: currentColor;
}
}

View File

@@ -6,7 +6,7 @@
rel="noopener noreferrer"
class="plex-library-item"
>
<figure class="item-poster">
<figure :class="`item-poster ${item.type}`">
<img
v-if="item.poster"
:src="item.poster"
@@ -113,7 +113,7 @@
}
</script>
<style scoped>
<style style="scss" scoped>
.plex-library-item {
display: flex;
flex-direction: column;
@@ -144,6 +144,10 @@
overflow: hidden;
background: #333;
margin: 0;
&.music {
aspect-ratio: 1/1;
}
}
.poster-image {

View File

@@ -21,19 +21,33 @@
<div class="library-stats-overview">
<div class="overview-stat">
<span class="overview-label">Total Items</span>
<span class="overview-value">{{ details.total }}</span>
<span class="overview-value">{{
formatNumber(details.total)
}}</span>
</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-value">{{ details.totalEpisodes }}</span>
<span class="overview-value">{{
formatNumber(details?.leafCount)
}}</span>
</div>
<div class="overview-stat" v-if="libraryType === 'music'">
<span class="overview-label">Tracks</span>
<span class="overview-value">{{ details.totalTracks }}</span>
<span class="overview-value">{{ details?.totalTracks }}</span>
</div>
<div class="overview-stat">
<span class="overview-label">Duration</span>
<span class="overview-value">{{ details.totalDuration }}</span>
<span class="overview-value">{{
convertSecondsToHumanReadable(details?.duration / 1000)
}}</span>
</div>
</div>
@@ -42,10 +56,12 @@
<h4 class="section-title">Recently Added</h4>
<div class="recent-items-grid">
<PlexLibraryItem
v-for="(item, index) in details.recentlyAdded"
v-for="(item, index) in recentlyAdded"
:key="index"
:item="item"
:show-extras="libraryType === 'music' || libraryType === 'shows'"
:show-extras="
libraryType === 'music' || libraryType === 'tv shows'
"
/>
</div>
</div>
@@ -78,41 +94,70 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount } from "vue";
import { computed, onMounted, onBeforeUnmount, ref } from "vue";
import IconClose from "@/icons/IconClose.vue";
import IconMovie from "@/icons/IconMovie.vue";
import IconShow from "@/icons/IconShow.vue";
import IconMusic from "@/icons/IconMusic.vue";
import PlexLibraryItem from "@/components/plex/PlexLibraryItem.vue";
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 {
id: number;
title: string;
total: number;
recentlyAdded: any[];
genres: { name: string; count: number }[];
totalDuration: string;
totalEpisodes?: number;
totalTracks?: number;
childCount?: number;
leafCount?: number;
duration: number;
genres: Array<{
name: string;
count: number;
}>;
}
interface Props {
libraryType: string;
details: LibraryDetails;
serverUrl: string;
serverMachineId: string;
}
const props = defineProps<Props>();
let recentlyAdded = ref([]);
const emit = defineEmits<{
(e: "close"): void;
}>();
const libraryIconComponent = computed(() => {
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;
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) {
if (event.key !== "Escape") return;
emit("close");
@@ -120,12 +165,18 @@
window.addEventListener("keyup", checkEventForEscapeKey);
onMounted(() => {
fetchRecentlyAdded();
});
onBeforeUnmount(() => {
window.removeEventListener("keyup", checkEventForEscapeKey);
});
</script>
<style scoped>
<style lang="scss" scoped>
@import "scss/media-queries.scss";
.modal-overlay {
position: fixed;
top: 0;
@@ -139,6 +190,10 @@
justify-content: center;
z-index: 1000;
padding: 20px;
@include mobile {
padding: 0;
}
}
.library-modal-content {
@@ -150,6 +205,11 @@
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
@include mobile {
max-height: 100vh;
border-radius: unset;
}
}
.library-modal-header {
@@ -198,12 +258,16 @@
border: none;
color: #888;
cursor: pointer;
padding: 8px;
padding: 0.5rem;
height: var(--size);
width: var(--size);
border-radius: 6px;
fill: white;
transition: all 0.2s;
@include mobile {
margin: auto 0;
}
}
.close-btn:hover {

View File

@@ -5,18 +5,18 @@
:key="stat.key"
class="stat-card"
:class="{
disabled: stat.value === 0 || loading,
disabled: stat.value === undefined || stat.value === 0 || loading,
unclickable: !!!stat.clickable
}"
@click="
stat.clickable && stat.value > 0 && !loading && handleClick(stat.key)
stat.clickable && stat.value?.total > 0 && !loading && handleClick(stat.key)
"
>
<div class="stat-icon">
<component :is="stat.icon" />
</div>
<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-label">{{ stat.label }}</div>
</div>
@@ -26,15 +26,24 @@
<script setup lang="ts">
import { computed } from "vue";
import { formatNumber } from '@/utils'
import IconMovie from "@/icons/IconMovie.vue";
import IconShow from "@/icons/IconShow.vue";
import IconMusic from "@/icons/IconMusic.vue";
import IconClock from "@/icons/IconClock.vue";
interface LibraryStat {
id: number;
title: string;
total: number;
childCount?: number;
leafCount?: number;
}
interface Props {
movies: number;
shows: number;
music: number;
movies: LibraryStat;
shows: LibraryStat;
music: LibraryStat;
watchtime: number;
loading?: boolean;
}
@@ -54,7 +63,7 @@
clickable: true
},
{
key: "shows",
key: "tv shows",
icon: IconShow,
value: props.shows,
label: "TV Shows",

View File

@@ -3,37 +3,14 @@
<div class="plex-details">
<div class="detail-row">
<span class="detail-label">
<svg
width="16"
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
<IconServer class="label-icon" />
Plex server name
</span>
<span class="detail-value">{{ serverName || "Unknown" }}</span>
</div>
<div class="detail-row">
<span class="detail-label">
<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>
<IconSync class="label-icon" />
Last Sync
</span>
<span class="detail-value">{{ lastSync || "Never" }}</span>
@@ -42,21 +19,7 @@
<div class="plex-actions">
<seasoned-button @click="$emit('sync')" :disabled="syncing">
<svg
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>
<IconSync v-if="!syncing" class="button-icon" />
{{ syncing ? "Syncing..." : "Sync Library" }}
</seasoned-button>
<seasoned-button @click="$emit('unlink')">
@@ -68,6 +31,8 @@
<script setup lang="ts">
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import IconServer from "@/icons/IconServer.vue";
import IconSync from "@/icons/IconSync.vue";
interface Props {
serverName: string;
@@ -120,6 +85,11 @@
color: var(--text-color-60);
flex-shrink: 0;
}
.label-icon {
width: 16px;
height: 16px;
}
}
.detail-value {
@@ -147,6 +117,11 @@
svg {
flex-shrink: 0;
}
.button-icon {
width: 16px;
height: 16px;
}
}
}
</style>

View File

@@ -215,7 +215,8 @@
const props = defineProps<Props>();
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 media: Ref<IMovie | IShow> = ref();
@@ -435,7 +436,7 @@
> img {
width: 100%;
border-radius: inherit;
border-radius: calc(1.6rem - 1px);
}
}
}

View File

@@ -1,12 +1,7 @@
<template>
<div class="change-password">
<div class="password-card">
<p class="password-info">
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">
<form class="password-form" @submit.prevent>
<seasoned-input
v-model="oldPassword"
placeholder="Current password"
@@ -72,49 +67,8 @@
newPasswordRepeat.value = password;
}
function addWarningMessage(message: string, title?: string) {
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() {
async function changePassword(event: CustomEvent) {
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({
message: "Password change is currently disabled",
title: "Feature Disabled",
@@ -153,20 +107,6 @@
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 {
display: flex;
flex-direction: column;

View File

@@ -1,106 +1,14 @@
<template>
<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 deviceno servers, no tracking. You own your data.
</span>
</div>
</div>
<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 -->
<div class="export-card">
<div class="export-header">
<h4>Request History</h4>
<p>View and download your complete request history.</p>
</div>
<RequestHistory :data="requestStats" />
<div class="stats-grid">
<div class="stat-mini">
<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>
<!-- Export Data Card -->
<ExportSection :data="requestStats" />
<!-- Local Storage Items -->
<div class="storage-section">
<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"
/>
<StorageManager />
<!-- Delete Account -->
<DangerZoneAction
@@ -110,748 +18,36 @@
@action="confirmDelete"
/>
</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>
</template>
<script setup lang="ts">
import { ref, computed, inject } from "vue";
import { useRouter } from "vue-router";
import { clearCommandHistory } from "@/utils/commandTracking";
import IconActivity from "@/icons/IconActivity.vue";
import IconClose from "@/icons/IconClose.vue";
import IconInfo from "@/icons/IconInfo.vue";
import { ref } from "vue";
import StorageManager from "./StorageManager.vue";
import ExportSection from "./ExportSection.vue"
import RequestHistory from "./RequestHistory.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({
total: 45,
approved: 38,
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() {
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(
"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;
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";
.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 {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.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);
}
gap: 2rem;
}
</style>

View 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>

View File

@@ -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 deviceno 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 everythingit'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>

View File

@@ -36,7 +36,10 @@
<div class="generator-options">
<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
v-model.number="wordCount"
type="range"
@@ -45,7 +48,10 @@
class="slider"
@input="generateWordsPassword"
/>
<span class="option-value">{{ wordCount }}</span>
<div class="slider-labels">
<span>3</span>
<span>7</span>
</div>
</div>
</div>
</div>
@@ -70,7 +76,10 @@
<div class="generator-options">
<div class="option-row">
<label>Length: {{ charLength }}</label>
<div class="slider-header">
<label>Length</label>
<span class="slider-value">{{ charLength }}</span>
</div>
<input
v-model.number="charLength"
type="range"
@@ -79,7 +88,10 @@
class="slider"
@input="generateCharsPassword"
/>
<span class="option-value">{{ charLength }}</span>
<div class="slider-labels">
<span>12</span>
<span>46</span>
</div>
</div>
<div class="option-row checkbox-row">
@@ -133,7 +145,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from "vue";
import { ref, watch, onMounted } from "vue";
import IconActivity from "@/icons/IconActivity.vue";
import { useRandomWords } from "@/composables/useRandomWords";
@@ -360,12 +372,12 @@
.option-row {
display: flex;
flex-direction: column;
gap: 0.4rem;
gap: 0.75rem;
label {
font-size: 0.85rem;
font-size: 0.95rem;
color: $text-color;
font-weight: 500;
font-weight: 600;
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 {
width: 100%;
height: 6px;
border-radius: 3px;
height: 10px;
border-radius: 5px;
background: var(--background-40);
outline: 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 {
appearance: none;
width: 18px;
height: 18px;
width: 24px;
height: 24px;
border-radius: 50%;
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 {
width: 18px;
height: 18px;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--highlight-color);
cursor: pointer;
cursor: grab;
border: none;
}
}
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
.option-value {
font-size: 0.9rem;
font-weight: 600;
color: var(--highlight-color);
text-align: center;
@include mobile-only {
width: 28px;
height: 28px;
}
&: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 {

View File

@@ -2,7 +2,7 @@
<div class="plex-settings">
<!-- Unconnected state -->
<PlexAuthButton
v-if="!isPlexConnected"
v-if="!showPlexInformation"
@auth-success="handleAuthSuccess"
@auth-error="handleAuthError"
/>
@@ -16,20 +16,20 @@
/>
<PlexLibraryStats
:movies="libraryStats.movies"
:shows="libraryStats.shows"
:music="libraryStats.music"
:watchtime="libraryStats.watchtime"
:loading="loadingLibraries"
:movies="libraryStats?.movies"
:shows="libraryStats?.['tv shows']"
:music="libraryStats?.music"
:watchtime="libraryStats?.watchtime || 0"
:loading="syncingLibrary"
@open-library="showLibraryDetails"
/>
<PlexServerInfo
:serverName="plexServer"
:lastSync="lastSync"
:syncing="syncing"
:syncing="syncingServer"
@sync="syncLibrary"
@unlink="confirmUnlink"
@unlink="() => (showUnlinkModal = true)"
/>
</div>
@@ -38,16 +38,18 @@
<!-- Unlink Confirmation Modal -->
<PlexUnlinkModal
v-if="showConfirmModal"
v-if="showUnlinkModal"
@confirm="unauthenticatePlex"
@cancel="cancelUnlink"
@cancel="() => (showUnlinkModal = false)"
/>
<!-- Library Details Modal -->
<PlexLibraryModal
v-if="showLibraryModal && selectedLibrary"
:libraryType="selectedLibrary"
:details="libraryDetails[selectedLibrary]"
:details="libraryStats[selectedLibrary]"
:serverUrl="plexServerUrl"
:serverMachineId="plexMachineId"
@close="closeLibraryModal"
/>
</div>
@@ -55,7 +57,6 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { useStore } from "vuex";
import SeasonedMessages from "@/components/ui/SeasonedMessages.vue";
import PlexAuthButton from "@/components/plex/PlexAuthButton.vue";
import PlexProfileCard from "@/components/plex/PlexProfileCard.vue";
@@ -64,184 +65,167 @@
import PlexUnlinkModal from "@/components/plex/PlexUnlinkModal.vue";
import PlexLibraryModal from "@/components/plex/PlexLibraryModal.vue";
import { usePlexAuth } from "@/composables/usePlexAuth";
import { usePlexApi } from "@/composables/usePlexApi";
import { usePlexLibraries } from "@/composables/usePlexLibraries";
import {
fetchPlexServers,
fetchPlexUserData,
fetchLibraryDetails
} from "@/composables/usePlexApi";
import type { Ref } from "vue";
import { linkPlexAccount, unlinkPlexAccount } from "../../api";
import { ErrorMessageTypes } from "../../interfaces/IErrorMessage";
import type { IErrorMessage } from "../../interfaces/IErrorMessage";
const messages: Ref<IErrorMessage[]> = ref([]);
const loading = ref(false);
const syncing = ref(false);
const showConfirmModal = ref(false);
const syncingServer = ref(false);
const syncingLibrary = ref(false);
const showUnlinkModal = ref(false);
const plexUsername = ref<string>("");
const plexUserData = ref<any>(null);
const isPlexConnected = ref<boolean>(false);
const showPlexInformation = ref<boolean>(false);
const hasLocalStorageData = ref<boolean>(false);
const hasCookieData = ref<boolean>(false);
const showLibraryModal = ref<boolean>(false);
const selectedLibrary = ref<string>("");
const loadingLibraries = ref<boolean>(false);
const plexServer = ref("");
const plexServerUrl = ref("");
const plexMachineId = ref("");
const lastSync = ref("");
const lastSync = ref(sessionStorage.getItem("plex_library_last_sync"));
const libraryStats = ref({
movies: 0,
shows: 0,
music: 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<{
(e: "reload"): void;
}>();
// Composables
const { getCookie, setPlexAuthCookie, cleanup } = usePlexAuth();
const {
fetchPlexUserData,
fetchPlexServers,
fetchLibrarySections,
fetchLibraryDetails
} = usePlexApi();
const { loadLibraries } = usePlexLibraries();
const { getPlexAuthCookie, setPlexAuthCookie, cleanup } = usePlexAuth();
// ----- Connection check -----
function checkPlexConnection() {
const cachedData = localStorage.getItem("plex_user_data");
const authToken = getCookie("plex_auth_token");
const storeHasPlexUserId = store.getters["user/plexUserId"];
hasLocalStorageData.value = !!cachedData;
hasCookieData.value = !!authToken;
isPlexConnected.value = !!(cachedData || authToken || storeHasPlexUserId);
return isPlexConnected.value;
const authToken = getPlexAuthCookie();
showPlexInformation.value = !!authToken;
return showPlexInformation.value;
}
// ----- Library loading -----
async function fetchPlexLibraries(authToken: string) {
try {
loadingLibraries.value = true;
const server = await fetchPlexServers(authToken);
if (!server) {
console.error("No Plex server found");
return;
}
plexServer.value = server.name;
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;
async function loadPlexServer() {
// return cached value from sessionStorage if exists
const cacheKey = "plex_server_data";
const cachedData = sessionStorage.getItem(cacheKey);
if (cachedData) {
const server = JSON.parse(cachedData);
plexServer.value = server?.name;
plexServerUrl.value = server?.url;
plexMachineId.value = server?.machineIdentifier;
return;
}
// 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 -----
async function loadPlexUserData() {
checkPlexConnection();
const cachedData = localStorage.getItem("plex_user_data");
// return cached value from sessionStorage if exists
const cacheKey = "plex_user_data";
const cachedData = sessionStorage.getItem(cacheKey);
hasLocalStorageData.value = !!cachedData;
if (cachedData) {
try {
plexUserData.value = JSON.parse(cachedData);
plexUsername.value = plexUserData.value.username;
isPlexConnected.value = true;
} catch (error) {
console.error("[PlexSettings] Error parsing cached Plex data:", error);
}
plexUserData.value = JSON.parse(cachedData);
plexUsername.value = plexUserData.value.username;
return;
}
const authToken = getCookie("plex_auth_token");
hasCookieData.value = !!authToken;
if (authToken) {
const userData = await fetchPlexUserData(authToken);
if (userData) {
plexUserData.value = userData;
plexUsername.value = userData.username;
isPlexConnected.value = true;
} else if (!cachedData) {
isPlexConnected.value = false;
}
if (isPlexConnected.value) {
await fetchPlexLibraries(authToken);
}
// get token from cookie
const authToken = getPlexAuthCookie();
if (!authToken) return;
// make api call for data
const userData = await fetchPlexUserData(authToken);
if (userData) {
// set plex user data
plexUserData.value = userData;
plexUsername.value = userData?.username;
// cache in sessionStorage
sessionStorage.setItem(cacheKey, JSON.stringify(userData));
} 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) -----
async function handleAuthSuccess(authToken: string) {
try {
setPlexAuthCookie(authToken);
const userData = await fetchPlexUserData(authToken);
if (userData) {
plexUserData.value = userData;
plexUsername.value = userData.username;
isPlexConnected.value = true;
}
const { success, message } = await linkPlexAccount(authToken);
if (success) {
emit("reload");
await fetchPlexLibraries(authToken);
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);
setPlexAuthCookie(authToken);
checkPlexConnection();
const success = await loadAll();
if (success) {
messages.value.push({
type: ErrorMessageTypes.Success,
title: "Authenticated with Plex",
message: "Successfully connected your Plex account"
} as IErrorMessage);
} else {
console.error("[PlexSettings] Error in handleAuthSuccess:");
messages.value.push({
type: ErrorMessageTypes.Error,
title: "Authentication failed",
@@ -259,35 +243,24 @@
}
// ----- Unlink flow -----
function confirmUnlink() {
showConfirmModal.value = true;
}
function cancelUnlink() {
showConfirmModal.value = false;
}
async function unauthenticatePlex() {
showConfirmModal.value = false;
loading.value = true;
const response = await unlinkPlexAccount();
if (response?.success) {
localStorage.removeItem("plex_user_data");
document.cookie =
"plex_auth_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Strict";
plexUserData.value = null;
plexUsername.value = "";
isPlexConnected.value = false;
emit("reload");
}
showUnlinkModal.value = false;
sessionStorage.removeItem("plex_user_data");
sessionStorage.removeItem("plex_server_data");
sessionStorage.removeItem("plex_library_data");
sessionStorage.removeItem("plex_library_last_sync");
document.cookie =
"plex_auth_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Strict";
plexUserData.value = null;
plexUsername.value = "";
showPlexInformation.value = false;
emit("reload");
messages.value.push({
type: response.success
? ErrorMessageTypes.Success
: ErrorMessageTypes.Error,
title: response.success
? "Unlinked Plex account"
: "Something went wrong",
message: response.message
type: ErrorMessageTypes.Success,
title: "Unlinked Plex account",
message: "All browser storage has been clear of plex account"
} as IErrorMessage);
loading.value = false;
}
// ----- Library modal -----
@@ -304,39 +277,60 @@
// ----- Sync -----
async function syncLibrary() {
syncing.value = true;
const authToken = getCookie("plex_auth_token");
const authToken = getPlexAuthCookie();
if (!authToken) {
messages.value.push({
type: ErrorMessageTypes.Error,
title: "Sync failed",
message: "No authentication token found"
} as IErrorMessage);
syncing.value = false;
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({
type: ErrorMessageTypes.Success,
title: "Library synced",
message: "Your Plex library has been successfully synced"
} as IErrorMessage);
} catch (error) {
} else {
messages.value.push({
type: ErrorMessageTypes.Error,
title: "Sync failed",
message: "An error occurred while syncing your library"
} 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();
loadPlexUserData();
});
return success;
}
// ---- Lifecycle functions ----
onMounted(loadAll);
onUnmounted(() => {
cleanup();
});

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -16,10 +16,7 @@
<button
v-for="theme in themes"
:key="theme.value"
:class="[
'theme-card',
{ 'theme-card--active': selectedTheme === theme.value }
]"
:class="['theme-card', { active: selectedTheme === theme.value }]"
@click="selectTheme(theme.value)"
>
<div class="theme-card__preview" :data-theme="theme.value">
@@ -35,65 +32,31 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { computed, onMounted } from "vue";
import { useTheme } from "@/composables/useTheme";
interface Theme {
value: string;
label: string;
}
const themes: Theme[] = [
const themes = [
{ value: "auto", label: "Auto" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "ocean", label: "Ocean" },
{ value: "nordic", label: "Nordic" },
{ value: "halloween", label: "Halloween" }
];
] as const;
const selectedTheme = ref("auto");
const { currentTheme, savedTheme, setTheme } = useTheme();
const selectedTheme = currentTheme;
const currentThemeName = computed(() => {
const theme = themes.find(t => t.value === selectedTheme.value);
return theme ? theme.label : "Auto";
});
function systemDarkModeEnabled() {
const computedStyle = window.getComputedStyle(document.body);
if (computedStyle?.colorScheme != null) {
return computedStyle.colorScheme.includes("dark");
}
return false;
}
const currentThemeName = computed(
() => themes.find(t => t.value === selectedTheme.value)?.label ?? "Auto"
);
function selectTheme(theme: string) {
selectedTheme.value = theme;
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);
setTheme(theme as any);
}
onMounted(() => {
// Load saved preference or default to auto
const savedTheme = localStorage.getItem("theme-preference") || "auto";
selectTheme(savedTheme);
selectedTheme.value = savedTheme.value;
});
</script>
@@ -113,99 +76,91 @@
padding: 1.5rem;
border-radius: 0.75rem;
}
}
.theme-display {
display: flex;
align-items: center;
gap: 1.5rem;
.theme-display {
display: flex;
align-items: center;
gap: 1.5rem;
@include mobile-only {
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;
@include mobile-only {
gap: 1.25rem;
}
}
.icon-inner {
width: 100%;
height: 100%;
.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 {
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 {
background: linear-gradient(135deg, #f8f8f8 0%, #e8e8e8 100%);
border: 3px solid #01d277;
.theme-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
&[data-theme="dark"] .icon-inner {
background: linear-gradient(135deg, #1a1a1a 0%, #0a0a0a 100%);
border: 3px solid #01d277;
span {
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;
}
}
&[data-theme="ocean"] .icon-inner {
background: linear-gradient(135deg, #0f2027 0%, #2c5364 100%);
border: 3px solid #00d4ff;
}
h3 {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
line-height: 1;
&[data-theme="nordic"] .icon-inner {
background: linear-gradient(135deg, #f5f0e8 0%, #d8cdb9 100%);
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;
@include mobile-only {
font-size: 1.4rem;
}
}
}
@@ -218,251 +173,182 @@
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
}
.theme-card {
display: flex;
flex-direction: column;
align-items: stretch;
padding: 1rem;
background-color: var(--background-ui);
border-radius: 0.75rem;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
text-align: center;
.theme-card {
display: flex;
flex-direction: column;
align-items: stretch;
padding: 1rem;
background-color: var(--background-ui);
border-radius: 0.75rem;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
text-align: center;
@include mobile-only {
padding: 0.85rem;
border-radius: 0.5rem;
}
@include mobile-only {
padding: 0.85rem;
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 {
transform: none;
border-color: var(--highlight-color);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
&:active {
transform: scale(0.97);
}
}
&__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;
&.active {
border-color: var(--highlight-color);
background-color: var(--background-40);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.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;
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
&__preview[data-theme="light"] {
background: #f8f8f8;
.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: #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;
@include mobile-only {
top: 0.4rem;
right: 0.4rem;
padding: 0.2rem 0.4rem;
font-size: 0.6rem;
}
}
}
}

View File

@@ -139,7 +139,6 @@
import IconMovie from "@/icons/IconMovie.vue";
import IconActivity from "@/icons/IconActivity.vue";
import IconProfile from "@/icons/IconProfile.vue";
import IconRequest from "@/icons/IconRequest.vue";
import IconInbox from "@/icons/IconInbox.vue";
import IconSearch from "@/icons/IconSearch.vue";
import IconEdit from "@/icons/IconEdit.vue";

View File

@@ -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>

View File

@@ -2,7 +2,7 @@
<button
type="button"
:class="{ active: active, fullwidth: fullWidth }"
@click="emit('click')"
@click="event => emit('click', event)"
>
<slot></slot>
</button>
@@ -15,7 +15,7 @@
}
interface Emit {
(e: "click");
(e: "click", event?: MouseEvent);
}
defineProps<Props>();

View File

@@ -1,200 +1,125 @@
import { ref } from "vue";
import { API_HOSTNAME } from "../api";
// Shared constants - generated once and reused
export const CLIENT_IDENTIFIER = `seasoned-plex-app-${Math.random().toString(36).substring(7)}`;
export const APP_NAME = window.location.hostname;
export function usePlexApi() {
const plexServerUrl = ref("");
// Fetch Plex user data
async function fetchPlexUserData(authToken: string) {
try {
const response = await fetch("https://plex.tv/api/v2/user", {
method: "GET",
headers: {
accept: "application/json",
"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");
async function fetchPlexServers(authToken: string) {
try {
const url =
"https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1";
const options = {
method: "GET",
headers: {
accept: "application/json",
"X-Plex-Token": authToken,
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER
}
};
const data = await response.json();
const response = await fetch(url, options);
// 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()
};
localStorage.setItem("plex_user_data", JSON.stringify(userData));
return userData;
} catch (error) {
console.error("[PlexAPI] Error fetching Plex user data:", error);
return null;
if (!response.ok) {
throw new Error("Failed to fetch Plex servers");
}
}
// Fetch Plex servers
async function fetchPlexServers(authToken: string) {
try {
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
}
}
);
const servers = await response.json();
const ownedServer = servers.find(
(s: any) => s.owned && s.provides === "server"
);
if (!response.ok) {
throw new Error("Failed to fetch Plex servers");
}
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();
if (ownedServer) {
const connection =
ownedServer.connections?.find((c: any) => c.local === false) ||
ownedServer.connections?.[0];
return {
all: allData,
recent: recentData,
metadata: allData.MediaContainer?.Metadata || [],
recentMetadata: recentData.MediaContainer?.Metadata || []
name: ownedServer.name,
url: connection?.uri,
machineIdentifier: ownedServer.clientIdentifier
};
} catch (error) {
console.error("[PlexAPI] Error fetching library details:", error);
return null;
}
}
return {
plexServerUrl,
fetchPlexUserData,
fetchPlexServers,
fetchLibrarySections,
fetchLibraryDetails
};
return null;
} catch (error) {
console.error("[PlexAPI] Error fetching Plex servers:", error);
return null;
}
}
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 };

View File

@@ -9,15 +9,17 @@ export function usePlexAuth() {
// Generate a PIN for Plex OAuth
async function generatePlexPin() {
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",
headers: {
accept: "application/json",
"X-Plex-Product": APP_NAME,
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER
}
});
};
const response = await fetch(url, options);
if (!response.ok) throw new Error("Failed to generate PIN");
const data = await response.json();
return { id: data.id, code: data.code };
@@ -30,15 +32,15 @@ export function usePlexAuth() {
// Check PIN status
async function checkPin(pinId: number, pinCode: string) {
try {
const response = await fetch(
`https://plex.tv/api/v2/pins/${pinId}?code=${pinCode}`,
{
headers: {
accept: "application/json",
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER
}
const url = `https://plex.tv/api/v2/pins/${pinId}?code=${pinCode}`;
const options = {
headers: {
accept: "application/json",
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER
}
);
};
const response = await fetch(url, options);
if (!response.ok) return null;
const data = await response.json();
@@ -93,9 +95,10 @@ export function usePlexAuth() {
}
// Get cookie
function getCookie(name: string): string | null {
function getPlexAuthCookie(): string | null {
const key = "plex_auth_token";
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
const parts = value.split(`; ${key}=`);
if (parts.length === 2) {
return parts.pop()?.split(";").shift() || null;
}
@@ -171,9 +174,10 @@ export function usePlexAuth() {
if (plexPopup.value && plexPopup.value.closed) {
clearInterval(popupChecker);
stopPolling();
if (loading.value) {
loading.value = false;
onError("Plex authentication window was closed");
// onError("Plex authentication window was closed");
}
}
}, 500);
@@ -190,7 +194,7 @@ export function usePlexAuth() {
return {
loading,
setPlexAuthCookie,
getCookie,
getPlexAuthCookie,
openAuthPopup,
cleanup
};

View File

@@ -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
};
}

View File

@@ -1,12 +1,12 @@
const TAUTULLI_API_KEY = "28494032b47542278fe76c6ccd1f0619";
const TAUTULLI_BASE_URL = "http://plex.schleppe:8181/api/v2";
import { API_HOSTNAME } from "../api";
interface WatchStats {
export interface WatchStats {
totalHours: number;
totalPlays: number;
moviePlays: number;
episodePlays: number;
musicPlays: number;
lastWatched: WatchContent[];
}
interface DayStats {
@@ -29,6 +29,13 @@ interface HomeStatItem {
media_type?: string;
}
export interface WatchContent {
title: string;
plays: number;
duration: number;
type: string;
}
interface PlaysGraphData {
categories: string[];
series: {
@@ -37,310 +44,256 @@ interface PlaysGraphData {
}[];
}
export function useTautulliStats() {
// Helper function to make Tautulli API calls
async function tautulliRequest(
cmd: string,
params: Record<string, any> = {}
) {
try {
const queryParams = new URLSearchParams({
apikey: TAUTULLI_API_KEY,
cmd,
...params
});
export async function tautulliRequest(
resource: string,
params: Record<string, any> = {}
) {
try {
const queryParams = new URLSearchParams(params);
const url = new URL(
`/api/v1/user/stats/${resource}?${queryParams}`,
API_HOSTNAME
);
const options: RequestInit = {
headers: {
"Content-Type": "application/json"
},
credentials: "include"
};
const url = `${TAUTULLI_BASE_URL}?${queryParams}`;
const response = await fetch(url);
const resp = await fetch(url, options);
if (!response.ok) {
throw new Error(`Tautulli API request failed: ${response.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;
if (!resp.ok) {
throw new Error(`Tautulli API request failed: ${resp.statusText}`);
}
}
// Fetch home statistics (pre-aggregated by Tautulli!)
async function fetchHomeStats(
userId?: number,
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
};
const response = await resp.json();
if (response?.success !== true) {
throw new Error(response?.message || "Unknown API error");
}
return response.data;
} catch (error) {
console.error(`[Tautulli] Error with ${resource}:`, error);
throw error;
}
}
// Fetch plays by date (already aggregated by Tautulli!)
async function fetchPlaysByDate(
timeRange = 30,
yAxis: "plays" | "duration" = "plays",
userId?: number
): Promise<DayStats[]> {
try {
const params: Record<string, any> = {
time_range: timeRange,
y_axis: yAxis,
grouping: 0
};
// Fetch home statistics (pre-aggregated by Tautulli!)
export async function fetchHomeStats(
timeRange = 30,
statsType: "plays" | "duration" = "plays"
): Promise<WatchStats> {
try {
const params: Record<string, any> = {
days: timeRange,
type: statsType,
grouping: 0
};
if (userId) {
params.user_id = userId;
}
const stats = await tautulliRequest("home_stats", params);
const data: PlaysGraphData = await tautulliRequest(
"get_plays_by_date",
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
);
// 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!)
async function fetchPlaysByDayOfWeek(
timeRange = 30,
yAxis: "plays" | "duration" = "plays",
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
if (topTV?.rows) {
episodePlays = topTV.rows.reduce(
(sum: number, item: any) => sum + (item.total_plays || 0),
0
);
// 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!)
async function fetchPlaysByHourOfDay(
timeRange = 30,
yAxis: "plays" | "duration" = "plays",
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
if (topMusic?.rows) {
musicPlays = topMusic.rows.reduce(
(sum: number, item: any) => sum + (item.total_plays || 0),
0
);
// 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
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
};
totalPlays = moviePlays + episodePlays + musicPlays;
if (userId) {
params.user_id = userId;
}
// 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
}
const stats = await tautulliRequest("get_home_stats", params);
// Get "last_watched" stat which contains recent items
const lastWatched = stats.find((s: any) => s.stat_id === "last_watched");
if (!lastWatched?.rows) {
return [];
}
return lastWatched.rows.slice(0, limit).map((item: any) => ({
// Get "last_watched" stat which contains recent items
const limit = 12;
const lastWatched = stats
.find((s: any) => s.stat_id === "last_watched")
.rows.slice(0, limit)
.map((item: any) => ({
title: item.title || item.full_title || "Unknown",
plays: item.total_plays || 0,
duration: Math.round((item.total_duration || 0) / 60), // Convert to minutes
type: item.media_type || "unknown"
}));
} catch (error) {
console.error("[Tautulli] Error fetching top content:", error);
return [];
}
}
return {
fetchHomeStats,
fetchPlaysByDate,
fetchPlaysByDayOfWeek,
fetchPlaysByHourOfDay,
fetchTopContent
};
return {
totalHours,
totalPlays,
moviePlays,
episodePlays,
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)
};
}
}

View 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
View 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>

View 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
View 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
View 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
View 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
View 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>

View File

@@ -2,42 +2,14 @@ import { createApp } from "vue";
import router from "./routes";
import store from "./store";
import Toast from "./plugins/Toast";
import { useTheme } from "./composables/useTheme";
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
const { initTheme } = useTheme();
initTheme();
store.dispatch("darkmodeModule/findAndSetDarkmodeSupported");
store.dispatch("user/initUserFromCookie");
const app = createApp(App);

View File

@@ -16,12 +16,13 @@ export interface CookieOptions {
/**
* Read a cookie value.
*/
export function getCookie(name: string): string | null {
export function getAuthorizationCookie(): string | null {
const key = 'authorization';
const array = document.cookie.split(";");
let match = null;
array.forEach((item: string) => {
const query = `${name}=`;
const query = `${key}=`;
if (!item.trim().startsWith(query)) return;
match = item.trim().substring(query.length);
});
@@ -132,7 +133,7 @@ const userModule: Module<UserState, RootState> = {
/* ── Actions ─────────────────────────────────────────────────── */
actions: {
async initUserFromCookie({ dispatch }): Promise<boolean | null> {
const jwtToken = getCookie("authorization");
const jwtToken = getAuthorizationCookie();
if (!jwtToken) return null;
const token = parseJwt(jwtToken);

View File

@@ -1,26 +1,9 @@
<template>
<div v-if="plexUserId && plexUsername" class="activity">
<div class="activity">
<h1 class="activity__title">Watch Activity</h1>
<!-- Stats Overview -->
<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</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ watchStats.episodePlays }}</div>
<div class="stat-label">Episodes</div>
</div>
</div>
<stats-overview :watch-stats="watchStats" />
<div class="controls">
<div class="control-group">
@@ -50,7 +33,7 @@
<div class="activity__charts">
<div class="chart-card">
<h3 class="chart-card__title">Daily Activity</h3>
<h3>Daily Activity</h3>
<div class="chart-card__graph">
<Graph
v-if="playsByDayData"
@@ -65,7 +48,7 @@
</div>
<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">
<Graph
v-if="playsByDayofweekData"
@@ -80,7 +63,7 @@
</div>
<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">
<Graph
v-if="hourlyData"
@@ -96,80 +79,33 @@
</div>
<!-- Top Content -->
<div v-if="topContent.length > 0" class="activity__top-content">
<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>
<watch-history :top-content="topContent" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useStore } from "vuex";
import Graph from "@/components/Graph.vue";
import ToggleButton from "@/components/ui/ToggleButton.vue";
import IconStop from "@/icons/IconStop.vue";
import type { Ref } from "vue";
import { useTautulliStats } from "@/composables/useTautulliStats";
import StatsOverview from "@/components/activity/StatsOverview.vue";
import WatchHistory from "@/components/activity/WatchHistory.vue";
import {
fetchHomeStats,
fetchPlaysByDate,
fetchPlaysByDayOfWeek,
fetchPlaysByHourOfDay
} from "../composables/useTautulliStats";
import {
GraphTypes,
GraphValueTypes,
IGraphData
} from "../interfaces/IGraph";
const store = useStore();
import type { Ref } from "vue";
import type { WatchStats } from "../composables/useTautulliStats";
const days: Ref<number> = ref(30);
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 = [
{
type: GraphTypes.Plays,
@@ -193,14 +129,6 @@
graphValueViewMode.find(viewMode => viewMode.type === graphViewMode.value)
);
const {
fetchHomeStats,
fetchPlaysByDate,
fetchPlaysByDayOfWeek,
fetchPlaysByHourOfDay,
fetchTopContent
} = useTautulliStats();
function convertDateStringToDayMonth(date: string, short = true): string {
if (!date.match(/[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
return date;
@@ -210,30 +138,8 @@
return short ? `${month}.${day}` : `${day}.${month}.${year}`;
}
async function fetchChartData() {
if (!plexUserId.value) return;
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
function activityPerDay(dataPromise: Promise<any>) {
dataPromise.then(dayData => {
playsByDayData.value = {
labels: dayData.map(d =>
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 = {
labels: weekData.labels,
series: [
@@ -258,22 +167,42 @@
{ name: "Music", data: weekData.music }
]
};
});
}
// Hourly distribution
function hourly(hourlyPromise: Promise<any>) {
hourlyPromise.then(hourData => {
hourlyData.value = {
labels: hourData.labels,
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) {
console.error("[ActivityPage] Error fetching chart data:", error);
}
}
onMounted(() => {
if (plexUsername.value) {
fetchChartData();
}
});
onMounted(fetchChartData);
</script>
<style lang="scss" scoped>
@@ -297,7 +226,7 @@
@include mobile-only {
font-size: 1.5rem;
margin: 0 0 1rem 0;
margin: 1rem 0;
}
}
@@ -310,37 +239,8 @@
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 {
background: var(--background-ui);
border-radius: 12px;
@@ -351,7 +251,7 @@
padding: 1rem;
}
&__title {
h3 {
margin: 0 0 1rem 0;
font-size: 1.2rem;
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 {
display: flex;
gap: 2rem;
@@ -450,144 +343,4 @@
color: var(--text-color-60);
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>

View 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>

View File

@@ -29,7 +29,7 @@
<seasoned-button @click="submit">Register</seasoned-button>
</form>
<router-link class="link" to="/signin"
<router-link class="link" to="/login"
>Have a user? Sign in here</router-link
>

View File

@@ -2,58 +2,30 @@
<section class="settings">
<div class="settings__container">
<!-- Profile Hero Card -->
<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>
<ProfileHero />
<!-- Settings Grid -->
<div class="settings__grid">
<!-- Left Column: Quick Settings -->
<div class="settings__column settings__column--left">
<!-- Left Column -->
<div class="settings__column">
<section class="settings-section settings-section--compact">
<h2 class="section-header">Appearance</h2>
<div class="settings-section-header"><h2>Appearance</h2></div>
<theme-preferences />
</section>
<section class="settings-section settings-section--compact">
<h2 class="section-header">Security</h2>
<change-password />
<security-settings />
</section>
</div>
<!-- Right Column: Data-Heavy Sections -->
<div class="settings__column settings__column--right">
<!-- Right Column -->
<div class="settings__column">
<section class="settings-section">
<h2 class="section-header">Integrations</h2>
<div class="settings-section-header"><h2>Integrations</h2></div>
<plex-settings @reload="reloadSettings" />
</section>
<section class="settings-section">
<h2 class="section-header">Data & Privacy</h2>
<data-export />
</section>
</div>
@@ -63,12 +35,13 @@
</template>
<script setup lang="ts">
import { inject, computed, onMounted } from "vue";
import { inject, onMounted } from "vue";
import { useStore } from "vuex";
import { useRoute } from "vue-router";
import ProfileHero from "@/components/settings/ProfileHero.vue";
import ThemePreferences from "@/components/settings/ThemePreferences.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 { getSettings } from "../api";
@@ -78,29 +51,6 @@
error;
} = 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() {
if (route.query?.missingPlexAccount === "true") {
notifications.error({
@@ -127,13 +77,14 @@
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
@import "scss/shared-settings";
.settings {
min-height: calc(100vh - var(--header-size));
padding: 2rem 1.5rem;
@include mobile-only {
padding: 1rem;
padding: 0.5rem;
}
&__container {
@@ -167,184 +118,6 @@
@include mobile-only {
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);
@include mobile-only {
padding: 1rem;
padding: 0.5rem;
}
&--compact {
// Tighter spacing for quick settings
.section-header {
font-size: 1.25rem;
margin-bottom: 0.85rem;
padding-bottom: 0.65rem;
// Tighter padding for quick settings, but same header size
padding: 1rem;
@include mobile-only {
font-size: 1.2rem;
margin-bottom: 0.75rem;
padding-bottom: 0.6rem;
}
@include mobile-only {
padding: 0.5rem;
}
}
}
.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>

View File

@@ -59,7 +59,7 @@
@include mobile-only {
font-size: 1.5rem;
margin: 0 0 1rem 0;
margin: 1rem 0;
}
}

View File

@@ -3,6 +3,8 @@ import type { RouteRecordRaw, RouteLocationNormalized } from "vue-router";
/* eslint-disable-next-line import-x/no-cycle */
import store from "./store";
import { usePlexAuth } from "./composables/usePlexAuth";
const { getPlexAuthCookie } = usePlexAuth();
declare global {
interface Window {
@@ -83,6 +85,11 @@ const routes: RouteRecordRaw[] = [
meta: { requiresAuth: true },
component: () => import("./pages/AdminPage.vue")
},
{
name: "missing-plex-auth",
path: "/missing/plex",
component: () => import("./pages/MissingPlexAuthPage.vue")
},
{
path: "/:pathMatch(.*)*",
name: "NotFound",
@@ -111,16 +118,8 @@ const hasPlexAccount = () => {
if (store.getters["user/plexUserId"] !== null) return true;
// Fallback to localStorage
const userData = localStorage.getItem("plex_user_data");
if (userData) {
try {
const parsed = JSON.parse(userData);
return parsed.id !== null && parsed.id !== undefined;
} catch {
return false;
}
}
return false;
const authToken = getPlexAuthCookie();
return !!authToken;
};
const hamburgerIsOpen = () => store.getters["hamburger/isOpen"];
@@ -136,15 +135,14 @@ router.beforeEach(
// send user to signin page.
if (to.matched.some(record => record.meta.requiresAuth)) {
if (!loggedIn()) {
next({ path: "/signin" });
next({ path: "/login" });
}
}
if (to.matched.some(record => record.meta.requiresPlexAccount)) {
if (!hasPlexAccount()) {
next({
path: "/settings",
query: { missingPlexAccount: true }
path: "/missing/plex"
});
}
}

View 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;
}
}

View File

@@ -129,3 +129,17 @@ export function convertSecondsToHumanReadable(_value, values = null) {
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];
}

View File

@@ -1,7 +1,7 @@
export function getLibraryIcon(type: string): string {
const icons: Record<string, string> = {
movies: "🎬",
shows: "📺",
"tv shows": "📺",
music: "🎵"
};
return icons[type] || "📁";
@@ -10,7 +10,7 @@ export function getLibraryIcon(type: string): string {
export function getLibraryIconComponent(type: string): string {
const components: Record<string, string> = {
movies: "IconMovie",
shows: "IconShow",
"tv shows": "IconShow",
music: "IconMusic"
};
return components[type] || "IconMovie";
@@ -19,7 +19,7 @@ export function getLibraryIconComponent(type: string): string {
export function getLibraryTitle(type: string): string {
const titles: Record<string, string> = {
movies: "Movies",
shows: "TV Shows",
"tv shows": "TV Shows",
music: "Music"
};
return titles[type] || type;
@@ -62,8 +62,8 @@ export function processLibraryItem(
// Get poster/thumbnail URL
let posterUrl = null;
// For TV shows, prefer grandparentThumb (show poster) over thumb (episode thumbnail)
if (libraryType === "shows") {
// For TV tv shows, prefer grandparentThumb (show poster) over thumb (episode thumbnail)
if (libraryType === "tv shows") {
if (item.grandparentThumb) {
posterUrl = `${serverUrl}${item.grandparentThumb}?X-Plex-Token=${authToken}`;
} else if (item.thumb) {
@@ -92,14 +92,14 @@ export function processLibraryItem(
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 =
libraryType === "shows" && item.grandparentTitle
libraryType === "tv shows" && item.grandparentTitle
? item.grandparentTitle
: item.title;
const year =
libraryType === "shows" && item.grandparentYear
libraryType === "tv shows" && item.grandparentYear
? item.grandparentYear
: item.year || item.parentYear || new Date().getFullYear();
@@ -109,11 +109,12 @@ export function processLibraryItem(
poster: posterUrl,
fallbackIcon: getLibraryIcon(libraryType),
rating: item.rating ? Math.round(item.rating * 10) / 10 : null,
type: libraryType,
ratingKey,
plexUrl
};
if (libraryType === "shows") {
if (libraryType === "tv shows") {
return {
...baseItem,
episodes: item.leafCount || 0
@@ -157,7 +158,7 @@ export function calculateDuration(metadata: any[], libraryType: string) {
totalDuration += item.duration;
}
if (libraryType === "shows" && item.leafCount) {
if (libraryType === "tv shows" && item.leafCount) {
totalEpisodes += item.leafCount;
} else if (libraryType === "music" && item.leafCount) {
totalTracks += item.leafCount;