mirror of
https://github.com/KevinMidboe/seasoned.git
synced 2026-03-10 19:39:10 +00:00
Feat: Activity page enhancements (#106)
* Add activity page components and Tautulli stats integration - Add StatsOverview component for watch statistics display - Add WatchHistory component for recent watch activity - Add useTautulliStats composable for Tautulli API integration - Components display total plays, watch time, movies/episodes watched - Support for fetching home stats and last watched content * Enhance Graph component with improved styling and options - Add wrapper div for better layout control - Update color scheme with modern palette (Indigo, Amber, Emerald) - Add Filler plugin for filled area charts - Improve bar chart styling with rounded corners - Add proper lifecycle cleanup with onBeforeUnmount - Enhance tooltip formatting for time and number values - Add deep watch for reactive data updates - Better TypeScript type safety with Chart.js types * Refactor ActivityPage with enhanced stats and visualizations - Integrate StatsOverview component for at-a-glance metrics - Add WatchHistory component for recent watch activity - Add hourly viewing patterns chart - Modernize UI with card-based layout - Improve controls styling with better labels and input handling - Remove authentication dependency (now handled by route guards) - Use useTautulliStats composable for data fetching - Add comprehensive watch statistics (total plays, hours, by media type) - Support for both plays and duration view modes * Improve Plex authentication check with cookie fallback - Add usePlexAuth composable import to routes - Enhance hasPlexAccount() to check cookies when Vuex store is empty - Fixes authentication check after page refreshes - Ensures activity page remains accessible with valid Plex auth
This commit is contained in:
@@ -1,9 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<canvas ref="graphCanvas"></canvas>
|
<div class="graph-wrapper">
|
||||||
|
<canvas ref="graphCanvas"></canvas>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch } from "vue";
|
import { ref, onMounted, watch, onBeforeUnmount } from "vue";
|
||||||
import {
|
import {
|
||||||
Chart,
|
Chart,
|
||||||
LineElement,
|
LineElement,
|
||||||
@@ -16,12 +18,14 @@
|
|||||||
Legend,
|
Legend,
|
||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
Filler,
|
||||||
ChartType
|
ChartType
|
||||||
} from "chart.js";
|
} from "chart.js";
|
||||||
|
import type { BarOptions, ChartOptions } from "chart.js";
|
||||||
|
|
||||||
import type { Ref } from "vue";
|
import type { Ref } from "vue";
|
||||||
import { convertSecondsToHumanReadable } from "../utils";
|
import { convertSecondsToHumanReadable } from "../utils";
|
||||||
import { GraphValueTypes } from "../interfaces/IGraph";
|
import { GraphTypes, GraphValueTypes } from "../interfaces/IGraph";
|
||||||
import type { IGraphDataset, IGraphData } from "../interfaces/IGraph";
|
import type { IGraphDataset, IGraphData } from "../interfaces/IGraph";
|
||||||
|
|
||||||
Chart.register(
|
Chart.register(
|
||||||
@@ -34,7 +38,8 @@
|
|||||||
CategoryScale,
|
CategoryScale,
|
||||||
Legend,
|
Legend,
|
||||||
Title,
|
Title,
|
||||||
Tooltip
|
Tooltip,
|
||||||
|
Filler
|
||||||
);
|
);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -42,129 +47,188 @@
|
|||||||
data: IGraphData;
|
data: IGraphData;
|
||||||
type: ChartType;
|
type: ChartType;
|
||||||
stacked: boolean;
|
stacked: boolean;
|
||||||
|
|
||||||
datasetDescriptionSuffix: string;
|
datasetDescriptionSuffix: string;
|
||||||
tooltipDescriptionSuffix: string;
|
tooltipDescriptionSuffix: string;
|
||||||
graphValueType?: GraphValueTypes;
|
graphValueType?: GraphValueTypes;
|
||||||
}
|
}
|
||||||
|
|
||||||
Chart.defaults.elements.point.radius = 0;
|
|
||||||
Chart.defaults.elements.point.hitRadius = 10;
|
|
||||||
// Chart.defaults.elements.point.pointHoverRadius = 10;
|
|
||||||
Chart.defaults.elements.point.hoverBorderWidth = 4;
|
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
const graphCanvas: Ref<HTMLCanvasElement> = ref(null);
|
const graphCanvas: Ref<HTMLCanvasElement | null> = ref(null);
|
||||||
let graphInstance = null;
|
let graphInstance: Chart | null = null;
|
||||||
|
|
||||||
/* eslint-disable no-use-before-define */
|
|
||||||
onMounted(() => generateGraph());
|
|
||||||
watch(() => props.data, generateGraph);
|
|
||||||
/* eslint-enable no-use-before-define */
|
|
||||||
|
|
||||||
const graphTemplates = [
|
const graphTemplates = [
|
||||||
{
|
{
|
||||||
backgroundColor: "rgba(54, 162, 235, 0.2)",
|
borderColor: "#6366F1",
|
||||||
borderColor: "rgba(54, 162, 235, 1)",
|
backgroundColor: "rgba(99,102,241,0.12)"
|
||||||
borderWidth: 1,
|
|
||||||
tension: 0.4
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
backgroundColor: "rgba(255, 159, 64, 0.2)",
|
borderColor: "#F59E0B",
|
||||||
borderColor: "rgba(255, 159, 64, 1)",
|
backgroundColor: "rgba(245,158,11,0.12)"
|
||||||
borderWidth: 1,
|
|
||||||
tension: 0.4
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
backgroundColor: "rgba(255, 99, 132, 0.2)",
|
borderColor: "#10B981",
|
||||||
borderColor: "rgba(255, 99, 132, 1)",
|
backgroundColor: "rgba(16,185,129,0.12)"
|
||||||
borderWidth: 1,
|
|
||||||
tension: 0.4
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
// const gridColor = getComputedStyle(document.documentElement).getPropertyValue(
|
|
||||||
// "--text-color-5"
|
|
||||||
// );
|
|
||||||
|
|
||||||
function hydrateGraphLineOptions(dataset: IGraphDataset, index: number) {
|
onMounted(() => generateGraph());
|
||||||
|
watch(() => props.data, generateGraph, { deep: true });
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (graphInstance) graphInstance.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
function removeEmptyDataset(dataset: IGraphDataset) {
|
||||||
|
return dataset;
|
||||||
|
return !dataset.data.every(point => point === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateDataset(dataset: IGraphDataset, index: number) {
|
||||||
|
const base = graphTemplates[index % graphTemplates.length];
|
||||||
|
|
||||||
|
if (props.type === "bar") {
|
||||||
|
return {
|
||||||
|
label: `${dataset.name} ${props.datasetDescriptionSuffix}`,
|
||||||
|
data: dataset.data,
|
||||||
|
backgroundColor: base.borderColor,
|
||||||
|
inflateAmount: 0,
|
||||||
|
borderRadius: {
|
||||||
|
topLeft: 8,
|
||||||
|
topRight: 8,
|
||||||
|
bottomLeft: 8,
|
||||||
|
bottomRight: 8
|
||||||
|
},
|
||||||
|
|
||||||
|
borderSkipped: false,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: "transparent",
|
||||||
|
|
||||||
|
// Slight spacing between categories
|
||||||
|
barPercentage: 0.8,
|
||||||
|
categoryPercentage: 0.9
|
||||||
|
} as BarOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line chart — subtle, minimal points
|
||||||
return {
|
return {
|
||||||
label: `${dataset.name} ${props.datasetDescriptionSuffix}`,
|
label: `${dataset.name} ${props.datasetDescriptionSuffix}`,
|
||||||
data: dataset.data,
|
data: dataset.data,
|
||||||
...graphTemplates[index]
|
borderColor: base.borderColor,
|
||||||
|
backgroundColor: base.backgroundColor,
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.35,
|
||||||
|
fill: true,
|
||||||
|
|
||||||
|
pointRadius: 2,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
pointHitRadius: 12,
|
||||||
|
pointBackgroundColor: base.borderColor,
|
||||||
|
pointBorderColor: base.borderColor,
|
||||||
|
pointBorderWidth: 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeEmptyDataset(dataset: IGraphDataset) {
|
|
||||||
/* eslint-disable-next-line no-unneeded-ternary */
|
|
||||||
return dataset.data.every(point => point === 0) ? false : true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateGraph() {
|
function generateGraph() {
|
||||||
|
if (!graphCanvas.value) return;
|
||||||
|
|
||||||
const datasets = props.data.series
|
const datasets = props.data.series
|
||||||
.filter(removeEmptyDataset)
|
.filter(removeEmptyDataset)
|
||||||
.map(hydrateGraphLineOptions);
|
.map(hydrateDataset);
|
||||||
|
|
||||||
const graphOptions = {
|
const chartData = {
|
||||||
|
labels: props.data.labels,
|
||||||
|
datasets
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: ChartOptions = {
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
|
responsive: true,
|
||||||
|
layout: {
|
||||||
|
padding: { top: 8 }
|
||||||
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
tooltip: {
|
legend: {
|
||||||
callbacks: {
|
display: true
|
||||||
// title: (tooltipItem, data) => `Watch date: ${tooltipItem[0].label}`,
|
},
|
||||||
label: tooltipItem => {
|
|
||||||
const context = tooltipItem.dataset.label.split(" ")[0];
|
|
||||||
const text = `${context} ${props.tooltipDescriptionSuffix}`;
|
|
||||||
|
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: "#111827",
|
||||||
|
bodyColor: "#e5e7eb",
|
||||||
|
padding: 12,
|
||||||
|
cornerRadius: 8,
|
||||||
|
displayColors: true,
|
||||||
|
callbacks: {
|
||||||
|
label: (tooltipItem: any) => {
|
||||||
|
const context = tooltipItem.dataset.label.split(" ")[0];
|
||||||
|
|
||||||
|
let type = GraphTypes.Plays;
|
||||||
let value = tooltipItem.raw;
|
let value = tooltipItem.raw;
|
||||||
if (props.graphValueType === GraphValueTypes.Time) {
|
if (props.graphValueType === String(GraphTypes.Duration)) {
|
||||||
value = convertSecondsToHumanReadable(value);
|
value = convertSecondsToHumanReadable(value);
|
||||||
|
type = GraphTypes.Duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ` ${text}: ${value}`;
|
const text = `${context} ${type}`;
|
||||||
|
return `${text}: ${value}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
scales: {
|
scales: {
|
||||||
xAxes: {
|
x: {
|
||||||
stacked: props.stacked,
|
stacked: props.stacked,
|
||||||
gridLines: {
|
grid: {
|
||||||
display: false
|
display: false,
|
||||||
|
drawBorder: false
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: "#9CA3AF",
|
||||||
|
font: { size: 11 }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
yAxes: {
|
|
||||||
|
y: {
|
||||||
stacked: props.stacked,
|
stacked: props.stacked,
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: {
|
||||||
|
color: "rgba(0,0,0,0.04)",
|
||||||
|
drawBorder: false
|
||||||
|
},
|
||||||
ticks: {
|
ticks: {
|
||||||
callback: value => {
|
color: "#9CA3AF",
|
||||||
if (props.graphValueType === GraphValueTypes.Time) {
|
font: { size: 11 },
|
||||||
|
padding: 8,
|
||||||
|
callback: (value: number) => {
|
||||||
|
if (props.graphValueType === String(GraphTypes.Duration)) {
|
||||||
return convertSecondsToHumanReadable(value);
|
return convertSecondsToHumanReadable(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
},
|
}
|
||||||
beginAtZero: true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const chartData = {
|
|
||||||
labels: props.data.labels.toString().split(","),
|
|
||||||
datasets
|
|
||||||
};
|
|
||||||
|
|
||||||
if (graphInstance) {
|
if (graphInstance) {
|
||||||
graphInstance.clear();
|
|
||||||
graphInstance.data = chartData;
|
graphInstance.data = chartData;
|
||||||
graphInstance.update("none");
|
graphInstance.update();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
graphInstance = new Chart(graphCanvas.value, {
|
graphInstance = new Chart(graphCanvas.value, {
|
||||||
type: props.type,
|
type: props.type,
|
||||||
data: chartData,
|
data: chartData,
|
||||||
options: graphOptions
|
options
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped></style>
|
<style scoped lang="scss">
|
||||||
|
.graph-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 240px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
86
src/components/activity/StatsOverview.vue
Normal file
86
src/components/activity/StatsOverview.vue
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="watchStats" class="stats-overview">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ watchStats.totalPlays }}</div>
|
||||||
|
<div class="stat-label">Total Plays</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ watchStats.totalHours }}h</div>
|
||||||
|
<div class="stat-label">Watch Time</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ watchStats.moviePlays }}</div>
|
||||||
|
<div class="stat-label">Movies watched</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ watchStats.episodePlays }}</div>
|
||||||
|
<div class="stat-label">Episodes watched</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { WatchStats } from "../../composables/useTautulliStats";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
watchStats: WatchStats | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "scss/variables";
|
||||||
|
@import "scss/media-queries";
|
||||||
|
|
||||||
|
.stats-overview {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--background-ui);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--highlight-color);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-color-60);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 300;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
101
src/components/activity/WatchHistory.vue
Normal file
101
src/components/activity/WatchHistory.vue
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="topContent.length > 0" class="watch-history">
|
||||||
|
<h3 class="section-title">Last Watched</h3>
|
||||||
|
<div class="top-content-list">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in topContent"
|
||||||
|
:key="index"
|
||||||
|
class="top-content-item"
|
||||||
|
>
|
||||||
|
<div class="content-rank">{{ index + 1 }}</div>
|
||||||
|
<div class="content-details">
|
||||||
|
<div class="content-title">{{ item.title }}</div>
|
||||||
|
<div class="content-meta">
|
||||||
|
{{ item.type }} • {{ item.plays }} plays • {{ item.duration }}min
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface TopContentItem {
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
plays: number;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
topContent: TopContentItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "scss/variables";
|
||||||
|
@import "scss/media-queries";
|
||||||
|
|
||||||
|
.watch-history {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-content-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-content-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
background: var(--background-ui);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--text-color-50);
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--text-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-rank {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--highlight-color);
|
||||||
|
min-width: 2.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-details {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-meta {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-color-60);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
299
src/composables/useTautulliStats.ts
Normal file
299
src/composables/useTautulliStats.ts
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
import { API_HOSTNAME } from "../api";
|
||||||
|
|
||||||
|
export interface WatchStats {
|
||||||
|
totalHours: number;
|
||||||
|
totalPlays: number;
|
||||||
|
moviePlays: number;
|
||||||
|
episodePlays: number;
|
||||||
|
musicPlays: number;
|
||||||
|
lastWatched: WatchContent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DayStats {
|
||||||
|
date: string;
|
||||||
|
plays: number;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HomeStatItem {
|
||||||
|
rating_key: number;
|
||||||
|
title: string;
|
||||||
|
total_plays?: number;
|
||||||
|
total_duration?: number;
|
||||||
|
users_watched?: string;
|
||||||
|
last_play?: number;
|
||||||
|
grandparent_thumb?: string;
|
||||||
|
thumb?: string;
|
||||||
|
content_rating?: string;
|
||||||
|
labels?: string[];
|
||||||
|
media_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WatchContent {
|
||||||
|
title: string;
|
||||||
|
plays: number;
|
||||||
|
duration: number;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlaysGraphData {
|
||||||
|
categories: string[];
|
||||||
|
series: {
|
||||||
|
name: string;
|
||||||
|
data: number[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 resp = await fetch(url, options);
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`Tautulli API request failed: ${resp.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = await tautulliRequest("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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
}));
|
||||||
|
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,84 +1,110 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="plexUserId" class="wrapper">
|
<div class="activity">
|
||||||
<h1>Your watch activity</h1>
|
<h1 class="activity__title">Watch Activity</h1>
|
||||||
|
|
||||||
<div style="display: flex; flex-direction: row">
|
<!-- Stats Overview -->
|
||||||
<label class="filter" for="dayinput">
|
<stats-overview :watch-stats="watchStats" />
|
||||||
<span>Days:</span>
|
|
||||||
<input
|
|
||||||
id="dayinput"
|
|
||||||
v-model="days"
|
|
||||||
class="dayinput"
|
|
||||||
placeholder="days"
|
|
||||||
type="number"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
@change="fetchChartData"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="filter">
|
<div class="controls">
|
||||||
<span>Data sorted by:</span>
|
<div class="control-group">
|
||||||
|
<label class="control-label">Time Range</label>
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<input
|
||||||
|
v-model.number="days"
|
||||||
|
class="days-input"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="365"
|
||||||
|
@change="fetchChartData"
|
||||||
|
/>
|
||||||
|
<span class="input-suffix">days</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-group">
|
||||||
|
<label class="control-label">View Mode</label>
|
||||||
<toggle-button
|
<toggle-button
|
||||||
v-model:selected="graphViewMode"
|
v-model:selected="graphViewMode"
|
||||||
class="filter-item"
|
|
||||||
:options="[GraphTypes.Plays, GraphTypes.Duration]"
|
:options="[GraphTypes.Plays, GraphTypes.Duration]"
|
||||||
@change="fetchChartData"
|
@change="fetchChartData"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chart-section">
|
<div class="activity__charts">
|
||||||
<h3 class="chart-header">Activity per day:</h3>
|
<div class="chart-card">
|
||||||
<div class="graph">
|
<h3>Daily Activity</h3>
|
||||||
<Graph
|
<div class="chart-card__graph">
|
||||||
v-if="playsByDayData"
|
<Graph
|
||||||
:data="playsByDayData"
|
v-if="playsByDayData"
|
||||||
type="line"
|
:data="playsByDayData"
|
||||||
:stacked="false"
|
type="line"
|
||||||
:dataset-description-suffix="`watch last ${days} days`"
|
:stacked="false"
|
||||||
:tooltip-description-suffix="selectedGraphViewMode.tooltipLabel"
|
:dataset-description-suffix="`watch last ${days} days`"
|
||||||
:graph-value-type="selectedGraphViewMode.valueType"
|
:tooltip-description-suffix="selectedGraphViewMode.tooltipLabel"
|
||||||
/>
|
:graph-value-type="graphViewMode"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="chart-header">Activity per day of week:</h3>
|
<div class="chart-card">
|
||||||
<div class="graph">
|
<h3>Activity by Media Type</h3>
|
||||||
<Graph
|
<div class="chart-card__graph">
|
||||||
v-if="playsByDayofweekData"
|
<Graph
|
||||||
:data="playsByDayofweekData"
|
v-if="playsByDayofweekData"
|
||||||
type="bar"
|
:data="playsByDayofweekData"
|
||||||
:stacked="true"
|
:graphValueType="graphViewMode"
|
||||||
:dataset-description-suffix="`watch last ${days} days`"
|
type="bar"
|
||||||
:tooltip-description-suffix="selectedGraphViewMode.tooltipLabel"
|
:stacked="true"
|
||||||
:graph-value-type="selectedGraphViewMode.valueType"
|
:dataset-description-suffix="`watch last ${days} days`"
|
||||||
/>
|
tooltip-description-suffix="plays"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3>Viewing Patterns by Hour</h3>
|
||||||
|
<div class="chart-card__graph">
|
||||||
|
<Graph
|
||||||
|
v-if="hourlyData"
|
||||||
|
:data="hourlyData"
|
||||||
|
type="bar"
|
||||||
|
:stacked="false"
|
||||||
|
:dataset-description-suffix="`last ${days} days`"
|
||||||
|
tooltip-description-suffix="plays"
|
||||||
|
:graph-value-type="graphViewMode"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div v-else class="not-authenticated">
|
<!-- Top Content -->
|
||||||
<h1><IconStop /> Must be authenticated</h1>
|
<watch-history :top-content="topContent" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { useStore } from "vuex";
|
|
||||||
import Graph from "@/components/Graph.vue";
|
import Graph from "@/components/Graph.vue";
|
||||||
import ToggleButton from "@/components/ui/ToggleButton.vue";
|
import ToggleButton from "@/components/ui/ToggleButton.vue";
|
||||||
import IconStop from "@/icons/IconStop.vue";
|
import StatsOverview from "@/components/activity/StatsOverview.vue";
|
||||||
import type { Ref } from "vue";
|
import WatchHistory from "@/components/activity/WatchHistory.vue";
|
||||||
import { fetchGraphData } from "../api";
|
import {
|
||||||
|
fetchHomeStats,
|
||||||
|
fetchPlaysByDate,
|
||||||
|
fetchPlaysByDayOfWeek,
|
||||||
|
fetchPlaysByHourOfDay
|
||||||
|
} from "../composables/useTautulliStats";
|
||||||
import {
|
import {
|
||||||
GraphTypes,
|
GraphTypes,
|
||||||
GraphValueTypes,
|
GraphValueTypes,
|
||||||
IGraphData
|
IGraphData
|
||||||
} from "../interfaces/IGraph";
|
} from "../interfaces/IGraph";
|
||||||
|
import type { Ref } from "vue";
|
||||||
const store = useStore();
|
import type { WatchStats } from "../composables/useTautulliStats";
|
||||||
|
|
||||||
const days: Ref<number> = ref(30);
|
const days: Ref<number> = ref(30);
|
||||||
const graphViewMode: Ref<GraphTypes> = ref(GraphTypes.Plays);
|
const graphViewMode: Ref<GraphTypes> = ref(GraphTypes.Plays);
|
||||||
const plexUserId = computed(() => store.getters["user/plexUserId"]);
|
|
||||||
|
|
||||||
const graphValueViewMode = [
|
const graphValueViewMode = [
|
||||||
{
|
{
|
||||||
@@ -95,156 +121,226 @@
|
|||||||
|
|
||||||
const playsByDayData: Ref<IGraphData> = ref(null);
|
const playsByDayData: Ref<IGraphData> = ref(null);
|
||||||
const playsByDayofweekData: Ref<IGraphData> = ref(null);
|
const playsByDayofweekData: Ref<IGraphData> = ref(null);
|
||||||
|
const hourlyData: Ref<IGraphData> = ref(null);
|
||||||
|
const watchStats = ref(null);
|
||||||
|
const topContent = ref([]);
|
||||||
|
|
||||||
const selectedGraphViewMode = computed(() =>
|
const selectedGraphViewMode = computed(() =>
|
||||||
graphValueViewMode.find(viewMode => viewMode.type === graphViewMode.value)
|
graphValueViewMode.find(viewMode => viewMode.type === graphViewMode.value)
|
||||||
);
|
);
|
||||||
|
|
||||||
function convertDateStringToDayMonth(date: string): string {
|
function convertDateStringToDayMonth(date: string, short = true): string {
|
||||||
if (!date.match(/[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
|
if (!date.match(/[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
|
||||||
return date;
|
return date;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [, month, day] = date.split("-");
|
const [year, month, day] = date.split("-");
|
||||||
return `${day}.${month}`;
|
return short ? `${month}.${day}` : `${day}.${month}.${year}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertDateLabels(data) {
|
function activityPerDay(dataPromise: Promise<any>) {
|
||||||
return {
|
dataPromise.then(dayData => {
|
||||||
labels: data.categories.map(convertDateStringToDayMonth),
|
playsByDayData.value = {
|
||||||
series: data.series
|
labels: dayData.map(d =>
|
||||||
};
|
convertDateStringToDayMonth(d.date, dayData.length < 365)
|
||||||
|
),
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: "Activity",
|
||||||
|
data:
|
||||||
|
graphViewMode.value === GraphTypes.Plays
|
||||||
|
? dayData.map(d => d.plays)
|
||||||
|
: dayData.map(d => d.duration)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPlaysByDay() {
|
function playsByDayOfWeek(dataPromise: Promise<any>) {
|
||||||
playsByDayData.value = await fetchGraphData(
|
dataPromise.then(weekData => {
|
||||||
"plays_by_day",
|
playsByDayofweekData.value = {
|
||||||
days.value,
|
labels: weekData.labels,
|
||||||
graphViewMode.value
|
series: [
|
||||||
).then(data => convertDateLabels(data?.data));
|
{ name: "Movies", data: weekData.movies },
|
||||||
|
{ name: "Episodes", data: weekData.episodes },
|
||||||
|
{ name: "Music", data: weekData.music }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPlaysByDayOfWeek() {
|
function hourly(hourlyPromise: Promise<any>) {
|
||||||
playsByDayofweekData.value = await fetchGraphData(
|
hourlyPromise.then(hourData => {
|
||||||
"plays_by_dayofweek",
|
hourlyData.value = {
|
||||||
days.value,
|
labels: hourData.labels,
|
||||||
graphViewMode.value
|
series: [{ name: "Plays", data: hourData.data }]
|
||||||
).then(data => convertDateLabels(data?.data));
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchChartData() {
|
async function fetchChartData() {
|
||||||
fetchPlaysByDay();
|
try {
|
||||||
fetchPlaysByDayOfWeek();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchChartData();
|
onMounted(fetchChartData);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import "scss/variables";
|
@import "scss/variables";
|
||||||
|
@import "scss/media-queries";
|
||||||
|
|
||||||
.wrapper {
|
.activity {
|
||||||
padding: 2rem;
|
padding: 3rem;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
@include mobile-only {
|
@include mobile-only {
|
||||||
padding: 0 0.8rem;
|
padding: 0.75rem;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
|
|
||||||
&:not(:first-of-type) {
|
|
||||||
margin-left: 1.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
&__title {
|
||||||
width: 100%;
|
margin: 0 0 2rem 0;
|
||||||
font-size: inherit;
|
font-size: 2rem;
|
||||||
max-width: 6rem;
|
|
||||||
background-color: $background-ui;
|
|
||||||
color: $text-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
font-size: inherit;
|
|
||||||
line-height: 1;
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
|
color: $text-color;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__charts {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// .filter {
|
.chart-card {
|
||||||
// display: flex;
|
background: var(--background-ui);
|
||||||
// flex-direction: row;
|
border-radius: 12px;
|
||||||
// flex-wrap: wrap;
|
padding: 1.5rem;
|
||||||
// align-items: center;
|
border: 1px solid var(--text-color-50);
|
||||||
// margin-bottom: 2rem;
|
|
||||||
|
|
||||||
// h2 {
|
@include mobile-only {
|
||||||
// margin-bottom: 0.5rem;
|
padding: 1rem;
|
||||||
// width: 100%;
|
}
|
||||||
// font-weight: 400;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// &-item:not(:first-of-type) {
|
h3 {
|
||||||
// margin-left: 1rem;
|
margin: 0 0 1rem 0;
|
||||||
// }
|
font-size: 1.2rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text-color;
|
||||||
|
|
||||||
// .dayinput {
|
@include mobile-only {
|
||||||
// font-size: 1.2rem;
|
font-size: 1rem;
|
||||||
// max-width: 3rem;
|
}
|
||||||
// background-color: $background-ui;
|
}
|
||||||
// color: $text-color;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
.chart-section {
|
&__graph {
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
.graph {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 35vh;
|
height: 35vh;
|
||||||
width: 90vw;
|
min-height: 300px;
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-header {
|
@include mobile-only {
|
||||||
font-weight: 300;
|
height: 30vh;
|
||||||
|
min-height: 250px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.not-authenticated {
|
.controls {
|
||||||
padding: 2rem;
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
h1 {
|
@include mobile-only {
|
||||||
display: flex;
|
flex-direction: column;
|
||||||
align-items: center;
|
gap: 1rem;
|
||||||
font-size: 3rem;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
margin-right: 1rem;
|
|
||||||
height: 3rem;
|
|
||||||
width: 3rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@include mobile {
|
}
|
||||||
padding: 1rem;
|
|
||||||
padding-right: 0;
|
|
||||||
|
|
||||||
h1 {
|
.control-group {
|
||||||
font-size: 1.65rem;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 200px;
|
||||||
|
|
||||||
svg {
|
@include mobile-only {
|
||||||
margin-right: 1rem;
|
min-width: 0;
|
||||||
height: 2rem;
|
width: 100%;
|
||||||
width: 2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.control-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color-60);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--background-ui);
|
||||||
|
border: 1px solid var(--text-color-50);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-within {
|
||||||
|
border-color: var(--text-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.days-input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: $text-color;
|
||||||
|
outline: none;
|
||||||
|
width: 80px;
|
||||||
|
|
||||||
|
&::-webkit-inner-spin-button,
|
||||||
|
&::-webkit-outer-spin-button {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-suffix {
|
||||||
|
padding: 0 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-color-60);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { RouteRecordRaw, RouteLocationNormalized } from "vue-router";
|
|||||||
|
|
||||||
/* eslint-disable-next-line import-x/no-cycle */
|
/* eslint-disable-next-line import-x/no-cycle */
|
||||||
import store from "./store";
|
import store from "./store";
|
||||||
|
import { usePlexAuth } from "./composables/usePlexAuth";
|
||||||
|
const { getPlexAuthCookie } = usePlexAuth();
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -96,7 +98,14 @@ const router = createRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const loggedIn = () => store.getters["user/loggedIn"];
|
const loggedIn = () => store.getters["user/loggedIn"];
|
||||||
const hasPlexAccount = () => store.getters["user/plexUserId"] !== null;
|
const hasPlexAccount = () => {
|
||||||
|
// Check Vuex store first
|
||||||
|
if (store.getters["user/plexUserId"] !== null) return true;
|
||||||
|
|
||||||
|
// Fallback to localStorage/cookie for page refreshes
|
||||||
|
const authToken = getPlexAuthCookie();
|
||||||
|
return !!authToken;
|
||||||
|
};
|
||||||
const hamburgerIsOpen = () => store.getters["hamburger/isOpen"];
|
const hamburgerIsOpen = () => store.getters["hamburger/isOpen"];
|
||||||
|
|
||||||
router.beforeEach(
|
router.beforeEach(
|
||||||
|
|||||||
Reference in New Issue
Block a user