mirror of
https://github.com/KevinMidboe/seasoned.git
synced 2026-04-28 10:33:43 +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>
|
||||
<canvas ref="graphCanvas"></canvas>
|
||||
<div class="graph-wrapper">
|
||||
<canvas ref="graphCanvas"></canvas>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { ref, onMounted, watch, onBeforeUnmount } from "vue";
|
||||
import {
|
||||
Chart,
|
||||
LineElement,
|
||||
@@ -16,12 +18,14 @@
|
||||
Legend,
|
||||
Title,
|
||||
Tooltip,
|
||||
Filler,
|
||||
ChartType
|
||||
} from "chart.js";
|
||||
import type { BarOptions, ChartOptions } from "chart.js";
|
||||
|
||||
import type { Ref } from "vue";
|
||||
import { convertSecondsToHumanReadable } from "../utils";
|
||||
import { GraphValueTypes } from "../interfaces/IGraph";
|
||||
import { GraphTypes, GraphValueTypes } from "../interfaces/IGraph";
|
||||
import type { IGraphDataset, IGraphData } from "../interfaces/IGraph";
|
||||
|
||||
Chart.register(
|
||||
@@ -34,7 +38,8 @@
|
||||
CategoryScale,
|
||||
Legend,
|
||||
Title,
|
||||
Tooltip
|
||||
Tooltip,
|
||||
Filler
|
||||
);
|
||||
|
||||
interface Props {
|
||||
@@ -42,129 +47,188 @@
|
||||
data: IGraphData;
|
||||
type: ChartType;
|
||||
stacked: boolean;
|
||||
|
||||
datasetDescriptionSuffix: string;
|
||||
tooltipDescriptionSuffix: string;
|
||||
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 graphCanvas: Ref<HTMLCanvasElement> = ref(null);
|
||||
let graphInstance = null;
|
||||
|
||||
/* eslint-disable no-use-before-define */
|
||||
onMounted(() => generateGraph());
|
||||
watch(() => props.data, generateGraph);
|
||||
/* eslint-enable no-use-before-define */
|
||||
const graphCanvas: Ref<HTMLCanvasElement | null> = ref(null);
|
||||
let graphInstance: Chart | null = null;
|
||||
|
||||
const graphTemplates = [
|
||||
{
|
||||
backgroundColor: "rgba(54, 162, 235, 0.2)",
|
||||
borderColor: "rgba(54, 162, 235, 1)",
|
||||
borderWidth: 1,
|
||||
tension: 0.4
|
||||
borderColor: "#6366F1",
|
||||
backgroundColor: "rgba(99,102,241,0.12)"
|
||||
},
|
||||
{
|
||||
backgroundColor: "rgba(255, 159, 64, 0.2)",
|
||||
borderColor: "rgba(255, 159, 64, 1)",
|
||||
borderWidth: 1,
|
||||
tension: 0.4
|
||||
borderColor: "#F59E0B",
|
||||
backgroundColor: "rgba(245,158,11,0.12)"
|
||||
},
|
||||
{
|
||||
backgroundColor: "rgba(255, 99, 132, 0.2)",
|
||||
borderColor: "rgba(255, 99, 132, 1)",
|
||||
borderWidth: 1,
|
||||
tension: 0.4
|
||||
borderColor: "#10B981",
|
||||
backgroundColor: "rgba(16,185,129,0.12)"
|
||||
}
|
||||
];
|
||||
// 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 {
|
||||
label: `${dataset.name} ${props.datasetDescriptionSuffix}`,
|
||||
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() {
|
||||
if (!graphCanvas.value) return;
|
||||
|
||||
const datasets = props.data.series
|
||||
.filter(removeEmptyDataset)
|
||||
.map(hydrateGraphLineOptions);
|
||||
.map(hydrateDataset);
|
||||
|
||||
const graphOptions = {
|
||||
const chartData = {
|
||||
labels: props.data.labels,
|
||||
datasets
|
||||
};
|
||||
|
||||
const options: ChartOptions = {
|
||||
maintainAspectRatio: false,
|
||||
responsive: true,
|
||||
layout: {
|
||||
padding: { top: 8 }
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
// title: (tooltipItem, data) => `Watch date: ${tooltipItem[0].label}`,
|
||||
label: tooltipItem => {
|
||||
const context = tooltipItem.dataset.label.split(" ")[0];
|
||||
const text = `${context} ${props.tooltipDescriptionSuffix}`;
|
||||
legend: {
|
||||
display: true
|
||||
},
|
||||
|
||||
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;
|
||||
if (props.graphValueType === GraphValueTypes.Time) {
|
||||
if (props.graphValueType === String(GraphTypes.Duration)) {
|
||||
value = convertSecondsToHumanReadable(value);
|
||||
type = GraphTypes.Duration;
|
||||
}
|
||||
|
||||
return ` ${text}: ${value}`;
|
||||
const text = `${context} ${type}`;
|
||||
return `${text}: ${value}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
scales: {
|
||||
xAxes: {
|
||||
x: {
|
||||
stacked: props.stacked,
|
||||
gridLines: {
|
||||
display: false
|
||||
grid: {
|
||||
display: false,
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: "#9CA3AF",
|
||||
font: { size: 11 }
|
||||
}
|
||||
},
|
||||
yAxes: {
|
||||
|
||||
y: {
|
||||
stacked: props.stacked,
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: "rgba(0,0,0,0.04)",
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
callback: value => {
|
||||
if (props.graphValueType === GraphValueTypes.Time) {
|
||||
color: "#9CA3AF",
|
||||
font: { size: 11 },
|
||||
padding: 8,
|
||||
callback: (value: number) => {
|
||||
if (props.graphValueType === String(GraphTypes.Duration)) {
|
||||
return convertSecondsToHumanReadable(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const chartData = {
|
||||
labels: props.data.labels.toString().split(","),
|
||||
datasets
|
||||
};
|
||||
|
||||
if (graphInstance) {
|
||||
graphInstance.clear();
|
||||
graphInstance.data = chartData;
|
||||
graphInstance.update("none");
|
||||
graphInstance.update();
|
||||
return;
|
||||
}
|
||||
|
||||
graphInstance = new Chart(graphCanvas.value, {
|
||||
type: props.type,
|
||||
data: chartData,
|
||||
options: graphOptions
|
||||
options
|
||||
});
|
||||
}
|
||||
</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>
|
||||
Reference in New Issue
Block a user