Fix linting and formatting issues

- Run Prettier to fix code style in 7 files
- Auto-fix ESLint errors with --fix flag
- Replace ++ with += 1 in commandTracking.ts
- Add eslint-disable comments for intentional console.error usage
- Fix destructuring, array types, and template literals
- Remove trivial type annotations
This commit is contained in:
2026-02-27 19:04:38 +01:00
parent 7274d0639a
commit b1f1fa8780
20 changed files with 438 additions and 255 deletions

View File

@@ -262,17 +262,22 @@ const getRequestStatus = async (
.catch(err => Promise.reject(err)); .catch(err => Promise.reject(err));
}; };
/* const watchLink = async (title: string, year: string) => {
const watchLink = async (title, year) => {
const url = new URL("/api/v1/plex/watch-link", API_HOSTNAME); const url = new URL("/api/v1/plex/watch-link", API_HOSTNAME);
url.searchParams.append("title", title); url.searchParams.append("title", title);
url.searchParams.append("year", year); url.searchParams.append("year", year);
return fetch(url.href) const options: RequestInit = {
headers: { "Content-Type": "application/json" },
credentials: "include"
};
return fetch(url.href, options)
.then(resp => resp.json()) .then(resp => resp.json())
.then(response => response.link); .then(response => response.link);
}; };
/*
const movieImages = id => { const movieImages = id => {
const url = new URL(`v2/movie/${id}/images`, API_HOSTNAME); const url = new URL(`v2/movie/${id}/images`, API_HOSTNAME);
@@ -560,6 +565,7 @@ export {
getSettings, getSettings,
updateSettings, updateSettings,
fetchGraphData, fetchGraphData,
watchLink,
getEmoji, getEmoji,
elasticSearchMoviesAndShows elasticSearchMoviesAndShows
}; };

View File

@@ -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,135 +47,212 @@
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); | Modern Color System
/* 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) { /*
|--------------------------------------------------------------------------
| Lifecycle
|--------------------------------------------------------------------------
*/
onMounted(() => generateGraph());
watch(() => props.data, generateGraph, { deep: true });
onBeforeUnmount(() => {
if (graphInstance) graphInstance.destroy();
});
/*
|--------------------------------------------------------------------------
| Helpers
|--------------------------------------------------------------------------
*/
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; | Chart Generator
} |--------------------------------------------------------------------------
*/
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,
elements: { responsive: true,
point: { layout: {
radius: 2, 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>

View File

@@ -36,19 +36,19 @@
sortDirection === "asc" ? "" : "" sortDirection === "asc" ? "" : ""
}}</span> }}</span>
</th> </th>
<th @click="sortBy('size')" class="sortable"> <th v-if="!isMobile" @click="sortBy('size')" class="sortable">
Size Size
<span v-if="sortColumn === 'size'">{{ <span v-if="sortColumn === 'size'">{{
sortDirection === "asc" ? "" : "" sortDirection === "asc" ? "" : ""
}}</span> }}</span>
</th> </th>
<th @click="sortBy('seeders')" class="sortable"> <th v-if="!isMobile" @click="sortBy('seeders')" class="sortable">
Seeders Seeders
<span v-if="sortColumn === 'seeders'">{{ <span v-if="sortColumn === 'seeders'">{{
sortDirection === "asc" ? "" : "" sortDirection === "asc" ? "" : ""
}}</span> }}</span>
</th> </th>
<th>Status</th> <th v-if="!isMobile">Status</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@@ -58,10 +58,23 @@
:key="torrent.id" :key="torrent.id"
:class="{ processing: torrent.processing }" :class="{ processing: torrent.processing }"
> >
<td class="torrent-name" :title="torrent.name">{{ torrent.name }}</td> <td class="torrent-name" :title="torrent.name">
<td>{{ torrent.size }}</td> <div class="torrent-name__title">{{ torrent.name }}</div>
<td>{{ torrent.seeders }}</td> <div v-if="isMobile" class="torrent-name__meta">
<td> <span class="meta-item">{{ torrent.size }}</span>
<span class="meta-separator"></span>
<span class="meta-item">{{ torrent.seeders }} seeders</span>
<span class="meta-separator"></span>
<span
:class="['status-badge', `status-badge--${torrent.status}`]"
>
{{ torrent.status }}
</span>
</div>
</td>
<td v-if="!isMobile">{{ torrent.size }}</td>
<td v-if="!isMobile">{{ torrent.seeders }}</td>
<td v-if="!isMobile">
<span :class="['status-badge', `status-badge--${torrent.status}`]"> <span :class="['status-badge', `status-badge--${torrent.status}`]">
{{ torrent.status }} {{ torrent.status }}
</span> </span>
@@ -116,7 +129,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from "vue"; import { ref, computed, onMounted, onUnmounted } from "vue";
import IconStop from "@/icons/IconStop.vue"; import IconStop from "@/icons/IconStop.vue";
import IconPlay from "@/icons/IconPlay.vue"; import IconPlay from "@/icons/IconPlay.vue";
import IconClose from "@/icons/IconClose.vue"; import IconClose from "@/icons/IconClose.vue";
@@ -144,6 +157,13 @@
const sortColumn = ref<keyof Torrent>("name"); const sortColumn = ref<keyof Torrent>("name");
const sortDirection = ref<"asc" | "desc">("asc"); const sortDirection = ref<"asc" | "desc">("asc");
const windowWidth = ref(window.innerWidth);
const isMobile = computed(() => windowWidth.value <= 768);
function handleResize() {
windowWidth.value = window.innerWidth;
}
const filteredTorrents = computed(() => { const filteredTorrents = computed(() => {
let result = [...torrents.value]; let result = [...torrents.value];
@@ -306,7 +326,14 @@
); );
} }
onMounted(fetchTorrents); onMounted(() => {
fetchTorrents();
window.addEventListener("resize", handleResize);
});
onUnmounted(() => {
window.removeEventListener("resize", handleResize);
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -441,15 +468,14 @@
&__table { &__table {
width: 100%; width: 100%;
max-width: 100%;
border-spacing: 0; border-spacing: 0;
border-radius: 0.5rem; border-radius: 0.5rem;
overflow: hidden; overflow: hidden;
table-layout: fixed;
@include mobile-only { @include mobile-only {
display: block; table-layout: auto;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
max-width: 100%;
} }
th, th,
@@ -525,8 +551,42 @@
white-space: nowrap; white-space: nowrap;
@include mobile-only { @include mobile-only {
max-width: 150px; max-width: none;
white-space: normal;
overflow: visible;
}
&__title {
word-break: break-word;
overflow-wrap: break-word;
@include mobile-only {
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 0.25rem;
}
}
&__meta {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
font-size: 0.7rem; font-size: 0.7rem;
color: var(--text-color-60);
margin-top: 0.25rem;
.meta-item {
white-space: nowrap;
}
.meta-separator {
color: var(--text-color-40);
}
.status-badge {
margin: 0;
}
} }
} }

View File

@@ -95,7 +95,8 @@
} }
function parseElasticResponse(elasticResponse: IAutocompleteSearchResults) { function parseElasticResponse(elasticResponse: IAutocompleteSearchResults) {
const data = elasticResponse.hits.hits; const { hits } = elasticResponse.hits;
const data = hits.length > 0 ? hits : (searchResults.value ?? []);
const results: Array<IAutocompleteResult> = []; const results: Array<IAutocompleteResult> = [];

View File

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

View File

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

View File

@@ -78,7 +78,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed, onBeforeUnmount } from "vue";
import IconClose from "@/icons/IconClose.vue"; import IconClose from "@/icons/IconClose.vue";
import IconMovie from "@/icons/IconMovie.vue"; import IconMovie from "@/icons/IconMovie.vue";
import IconShow from "@/icons/IconShow.vue"; import IconShow from "@/icons/IconShow.vue";
@@ -112,6 +112,17 @@
if (props.libraryType === "music") return IconMusic; if (props.libraryType === "music") return IconMusic;
return IconMovie; return IconMovie;
}); });
function checkEventForEscapeKey(event: KeyboardEvent) {
if (event.key !== "Escape") return;
emit("close");
}
window.addEventListener("keyup", checkEventForEscapeKey);
onBeforeUnmount(() => {
window.removeEventListener("keyup", checkEventForEscapeKey);
});
</script> </script>
<style scoped> <style scoped>
@@ -136,7 +147,6 @@
width: 100%; width: 100%;
max-width: 800px; max-width: 800px;
max-height: 90vh; max-height: 90vh;
margin-top: calc(var(--header-size) + 1rem);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
@@ -183,12 +193,16 @@
} }
.close-btn { .close-btn {
--size: 2.4rem;
background: none; background: none;
border: none; border: none;
color: #888; color: #888;
cursor: pointer; cursor: pointer;
padding: 8px; padding: 8px;
height: var(--size);
width: var(--size);
border-radius: 6px; border-radius: 6px;
fill: white;
transition: all 0.2s; transition: all 0.2s;
} }

View File

@@ -4,7 +4,10 @@
v-for="stat in displayStats" v-for="stat in displayStats"
:key="stat.key" :key="stat.key"
class="stat-card" class="stat-card"
:class="{ disabled: stat.value === 0 || loading }" :class="{
disabled: stat.value === 0 || loading,
unclickable: !!!stat.clickable
}"
@click=" @click="
stat.clickable && stat.value > 0 && !loading && handleClick(stat.key) stat.clickable && stat.value > 0 && !loading && handleClick(stat.key)
" "
@@ -88,6 +91,10 @@
gap: 0.75rem; gap: 0.75rem;
margin-bottom: 0.85rem; margin-bottom: 0.85rem;
@include tablet-only {
grid-template-columns: repeat(3, 1fr);
}
@include mobile-only { @include mobile-only {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 0.65rem; gap: 0.65rem;
@@ -102,7 +109,6 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
border: 1px solid transparent; border: 1px solid transparent;
@@ -110,15 +116,15 @@
padding: 0.85rem 0.75rem; padding: 0.85rem 0.75rem;
} }
&:hover:not(.disabled) { &:hover:not(.disabled, .unclickable) {
background-color: var(--background-40); background-color: var(--background-40);
border-color: var(--highlight-color); border-color: var(--highlight-color);
cursor: pointer;
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
} }
&.disabled { &.disabled {
cursor: default;
opacity: 0.6; opacity: 0.6;
&:hover { &:hover {

View File

@@ -51,7 +51,7 @@
</Detail> </Detail>
<Detail <Detail
v-if="creditedShows.length" v-if="creditedMovies.length"
title="movies" title="movies"
:detail="`Credited in ${creditedMovies.length} movies`" :detail="`Credited in ${creditedMovies.length} movies`"
> >

View File

@@ -293,10 +293,12 @@
// ----- Library modal ----- // ----- Library modal -----
function showLibraryDetails(type: string) { function showLibraryDetails(type: string) {
selectedLibrary.value = type; selectedLibrary.value = type;
document.getElementsByTagName("body")[0].classList.add("no-scroll");
showLibraryModal.value = true; showLibraryModal.value = true;
} }
function closeLibraryModal() { function closeLibraryModal() {
showLibraryModal.value = false; showLibraryModal.value = false;
document.getElementsByTagName("body")[0].classList.remove("no-scroll");
selectedLibrary.value = ""; selectedLibrary.value = "";
} }

View File

@@ -192,16 +192,23 @@
border-spacing: 0; border-spacing: 0;
margin-top: 0.5rem; margin-top: 0.5rem;
width: 100%; width: 100%;
// border-collapse: collapse; max-width: 100%;
border-radius: 0.5rem; border-radius: 0.5rem;
overflow: hidden; overflow: hidden;
table-layout: fixed;
@include mobile {
table-layout: auto;
}
} }
th, th,
td { td {
border: 0.5px solid var(--background-color-40); border: 0.5px solid var(--background-color-40);
overflow: hidden;
text-overflow: ellipsis;
@include mobile { @include mobile {
white-space: nowrap;
padding: 0; padding: 0;
} }
} }
@@ -241,6 +248,8 @@
font-weight: 500; font-weight: 500;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
line-height: 1.3; line-height: 1.3;
word-break: break-word;
overflow-wrap: break-word;
@include mobile { @include mobile {
font-size: 0.95rem; font-size: 0.95rem;

View File

@@ -204,7 +204,11 @@
const routes = computed(() => { const routes = computed(() => {
return router.getRoutes().filter(route => { return router.getRoutes().filter(route => {
return routeMetadata[route?.name?.toString() ?? ""] && route.name && route.name !== "NotFound"; return (
routeMetadata[route?.name?.toString() ?? ""] &&
route.name &&
route.name !== "NotFound"
);
}); });
}); });

View File

@@ -1,8 +1,7 @@
import { ref } from "vue"; import { ref } from "vue";
// Shared constants - generated once and reused // Shared constants - generated once and reused
export const CLIENT_IDENTIFIER = export const CLIENT_IDENTIFIER = `seasoned-plex-app-${Math.random().toString(36).substring(7)}`;
"seasoned-plex-app-" + Math.random().toString(36).substring(7);
export const APP_NAME = window.location.hostname; export const APP_NAME = window.location.hostname;
export function usePlexApi() { export function usePlexApi() {

View File

@@ -34,8 +34,8 @@ export function usePlexLibraries() {
try { try {
for (const section of sections) { for (const section of sections) {
const type = section.type; const { type } = section;
const key = section.key; const { key } = section;
if (type === "movie") { if (type === "movie") {
await processLibrarySection( await processLibrarySection(

View File

@@ -703,7 +703,7 @@ export function useRandomWords() {
]; ];
// Try to fetch random words from API, fallback to local list // Try to fetch random words from API, fallback to local list
async function getRandomWords(count: number = 4): Promise<string[]> { async function getRandomWords(count = 4): Promise<string[]> {
try { try {
// Try Random Word API first // Try Random Word API first
const response = await fetch( const response = await fetch(

View File

@@ -31,10 +31,10 @@ interface HomeStatItem {
interface PlaysGraphData { interface PlaysGraphData {
categories: string[]; categories: string[];
series: Array<{ series: {
name: string; name: string;
data: number[]; data: number[];
}>; }[];
} }
export function useTautulliStats() { export function useTautulliStats() {
@@ -72,7 +72,7 @@ export function useTautulliStats() {
// Fetch home statistics (pre-aggregated by Tautulli!) // Fetch home statistics (pre-aggregated by Tautulli!)
async function fetchHomeStats( async function fetchHomeStats(
userId?: number, userId?: number,
timeRange: number = 30, timeRange = 30,
statsType: "plays" | "duration" = "plays" statsType: "plays" | "duration" = "plays"
): Promise<WatchStats> { ): Promise<WatchStats> {
try { try {
@@ -162,7 +162,7 @@ export function useTautulliStats() {
// Fetch plays by date (already aggregated by Tautulli!) // Fetch plays by date (already aggregated by Tautulli!)
async function fetchPlaysByDate( async function fetchPlaysByDate(
timeRange: number = 30, timeRange = 30,
yAxis: "plays" | "duration" = "plays", yAxis: "plays" | "duration" = "plays",
userId?: number userId?: number
): Promise<DayStats[]> { ): Promise<DayStats[]> {
@@ -184,10 +184,9 @@ export function useTautulliStats() {
// Sum all series data for each date // Sum all series data for each date
return data.categories.map((date, index) => { return data.categories.map((date, index) => {
const totalValue = data.series.reduce( const totalValue = data.series
(sum, series) => sum + (series.data[index] || 0), .filter(s => s.name !== "Total")
0 .reduce((sum, series) => sum + (series.data[index] || 0), 0);
);
return { return {
date, date,
@@ -203,7 +202,7 @@ export function useTautulliStats() {
// Fetch plays by day of week (already aggregated!) // Fetch plays by day of week (already aggregated!)
async function fetchPlaysByDayOfWeek( async function fetchPlaysByDayOfWeek(
timeRange: number = 30, timeRange = 30,
yAxis: "plays" | "duration" = "plays", yAxis: "plays" | "duration" = "plays",
userId?: number userId?: number
): Promise<{ ): Promise<{
@@ -264,7 +263,7 @@ export function useTautulliStats() {
// Fetch plays by hour of day (already aggregated!) // Fetch plays by hour of day (already aggregated!)
async function fetchPlaysByHourOfDay( async function fetchPlaysByHourOfDay(
timeRange: number = 30, timeRange = 30,
yAxis: "plays" | "duration" = "plays", yAxis: "plays" | "duration" = "plays",
userId?: number userId?: number
): Promise<{ labels: string[]; data: number[] }> { ): Promise<{ labels: string[]; data: number[] }> {
@@ -285,12 +284,9 @@ export function useTautulliStats() {
); );
// Sum all series data for each hour // Sum all series data for each hour
const hourlyData = data.categories.map((hour, index) => { const hourlyData = data.categories.map((hour, index) =>
return data.series.reduce( data.series.reduce((sum, series) => sum + (series.data[index] || 0), 0)
(sum, series) => sum + (series.data[index] || 0), );
0
);
});
return { return {
labels: data.categories.map(h => `${h}:00`), labels: data.categories.map(h => `${h}:00`),
@@ -306,11 +302,7 @@ export function useTautulliStats() {
} }
// Fetch top watched content from home stats // Fetch top watched content from home stats
async function fetchTopContent( async function fetchTopContent(timeRange = 30, limit = 10, userId?: number) {
timeRange: number = 30,
limit: number = 10,
userId?: number
) {
try { try {
const params: Record<string, any> = { const params: Record<string, any> = {
time_range: timeRange, time_range: timeRange,

View File

@@ -59,7 +59,7 @@
:stacked="false" :stacked="false"
:dataset-description-suffix="`watch last ${days} days`" :dataset-description-suffix="`watch last ${days} days`"
:tooltip-description-suffix="selectedGraphViewMode.tooltipLabel" :tooltip-description-suffix="selectedGraphViewMode.tooltipLabel"
:graph-value-type="selectedGraphViewMode.valueType" :graph-value-type="graphViewMode"
/> />
</div> </div>
</div> </div>
@@ -70,11 +70,11 @@
<Graph <Graph
v-if="playsByDayofweekData" v-if="playsByDayofweekData"
:data="playsByDayofweekData" :data="playsByDayofweekData"
:graphValueType="graphViewMode"
type="bar" type="bar"
:stacked="true" :stacked="true"
:dataset-description-suffix="`watch last ${days} days`" :dataset-description-suffix="`watch last ${days} days`"
tooltip-description-suffix="plays" tooltip-description-suffix="plays"
:graph-value-type="GraphValueTypes.Number"
/> />
</div> </div>
</div> </div>
@@ -89,7 +89,7 @@
:stacked="false" :stacked="false"
:dataset-description-suffix="`last ${days} days`" :dataset-description-suffix="`last ${days} days`"
tooltip-description-suffix="plays" tooltip-description-suffix="plays"
:graph-value-type="GraphValueTypes.Number" :graph-value-type="graphViewMode"
/> />
</div> </div>
</div> </div>
@@ -201,13 +201,13 @@
fetchTopContent fetchTopContent
} = useTautulliStats(); } = useTautulliStats();
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}`;
} }
async function fetchChartData() { async function fetchChartData() {
@@ -235,7 +235,9 @@
// Activity per day // Activity per day
playsByDayData.value = { playsByDayData.value = {
labels: dayData.map(d => convertDateStringToDayMonth(d.date)), labels: dayData.map(d =>
convertDateStringToDayMonth(d.date, dayData.length < 365)
),
series: [ series: [
{ {
name: "Activity", name: "Activity",

View File

@@ -62,161 +62,161 @@
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
/* ------------------------------ /* ------------------------------
Transition Transition
------------------------------ */ ------------------------------ */
.slide-enter-active, .slide-enter-active,
.slide-leave-active { .slide-leave-active {
transition: all 0.35s cubic-bezier(0.22, 1, 0.36, 1); transition: all 0.35s cubic-bezier(0.22, 1, 0.36, 1);
} }
.slide-enter-from, .slide-enter-from,
.slide-leave-to { .slide-leave-to {
transform: translateY(40px); transform: translateY(40px);
opacity: 0; opacity: 0;
} }
/* ------------------------------ /* ------------------------------
Toast Toast
------------------------------ */ ------------------------------ */
.toast { .toast {
position: fixed; position: fixed;
right: 1.25rem; right: 1.25rem;
bottom: 1.25rem; bottom: 1.25rem;
z-index: 1000; z-index: 1000;
cursor: pointer; cursor: pointer;
min-width: 340px; min-width: 340px;
max-width: 460px; max-width: 460px;
width: calc(100vw - 2rem); width: calc(100vw - 2rem);
padding: 1.1rem 1.25rem; padding: 1.1rem 1.25rem;
border-radius: 16px; border-radius: 16px;
/* System-based surface */ /* System-based surface */
background: var(--background-color-secondary); background: var(--background-color-secondary);
/* Subtle separation */ /* Subtle separation */
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
/* Clear state indicator */ /* Clear state indicator */
border-left: 5px solid transparent; border-left: 5px solid transparent;
/* Base text tone */ /* Base text tone */
color: var(--text-color, #1f2937); color: var(--text-color, #1f2937);
line-height: 1.5; line-height: 1.5;
/* ------------------------------ /* ------------------------------
Content Layout Content Layout
------------------------------ */ ------------------------------ */
&--content { &--content {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 1rem; gap: 1rem;
} }
&--text { &--text {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
/* ------------------------------ /* ------------------------------
Typography Hierarchy Typography Hierarchy
------------------------------ */ ------------------------------ */
/* Context label */ /* Context label */
&--text__title { &--text__title {
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 500; font-weight: 500;
letter-spacing: 0.3px; letter-spacing: 0.3px;
text-transform: uppercase; text-transform: uppercase;
/* Softer than body but not faded */ /* Softer than body but not faded */
color: color-mix(in srgb, currentColor 75%, transparent); color: color-mix(in srgb, currentColor 75%, transparent);
} }
/* Primary message */ /* Primary message */
&--text__description { &--text__description {
margin-top: 0.3rem; margin-top: 0.3rem;
font-size: 0.98rem; font-size: 0.98rem;
font-weight: 400; font-weight: 400;
line-height: 1.5; line-height: 1.5;
color: currentColor; color: currentColor;
} }
&--text__title-large { &--text__title-large {
font-size: 1.15rem; font-size: 1.15rem;
font-weight: 500; font-weight: 500;
} }
/* ------------------------------ /* ------------------------------
Dismiss Button Dismiss Button
------------------------------ */ ------------------------------ */
&--dismiss { &--dismiss {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 30px; width: 30px;
height: 30px; height: 30px;
border-radius: 8px; border-radius: 8px;
transition: background 0.2s ease; transition: background 0.2s ease;
&:hover { &:hover {
background: var(--background-color); background: var(--background-color);
}
i {
font-size: 0.75rem;
}
} }
i { /* ------------------------------
font-size: 0.75rem; State Colors
------------------------------ */
&.success {
border-left-color: #22c55e;
}
&.info {
border-left-color: #facc15;
}
&.warning {
border-left-color: #f97316;
}
&.error {
border-left-color: #ef4444;
}
&.simple {
border-left-color: transparent;
} }
} }
/* ------------------------------ /* ------------------------------
State Colors
------------------------------ */
&.success {
border-left-color: #22c55e;
}
&.info {
border-left-color: #facc15;
}
&.warning {
border-left-color: #f97316;
}
&.error {
border-left-color: #ef4444;
}
&.simple {
border-left-color: transparent;
}
}
/* ------------------------------
Mobile Mobile
------------------------------ */ ------------------------------ */
@media (max-width: 480px) { @media (max-width: 480px) {
.toast { .toast {
right: 1rem; right: 1rem;
left: 1rem; left: 1rem;
width: auto; width: auto;
min-width: unset; min-width: unset;
}
} }
}
</style> </style>

View File

@@ -6,9 +6,7 @@ interface CommandData {
} }
interface CommandStats { interface CommandStats {
commands: { commands: Record<string, CommandData>;
[key: string]: CommandData;
};
version: number; version: number;
} }
@@ -24,6 +22,7 @@ function getStats(): CommandStats {
const parsed = JSON.parse(stored) as CommandStats; const parsed = JSON.parse(stored) as CommandStats;
return parsed; return parsed;
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console
console.error("Failed to parse command stats:", error); console.error("Failed to parse command stats:", error);
return { commands: {}, version: CURRENT_VERSION }; return { commands: {}, version: CURRENT_VERSION };
} }
@@ -33,6 +32,7 @@ function saveStats(stats: CommandStats): void {
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(stats)); localStorage.setItem(STORAGE_KEY, JSON.stringify(stats));
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console
console.error("Failed to save command stats:", error); console.error("Failed to save command stats:", error);
} }
} }
@@ -53,7 +53,7 @@ export function trackCommand(
}; };
} }
stats.commands[id].count++; stats.commands[id].count += 1;
stats.commands[id].lastUsed = new Date().toISOString(); stats.commands[id].lastUsed = new Date().toISOString();
if (metadata?.routePath) { if (metadata?.routePath) {
@@ -80,9 +80,7 @@ export function getCommandScore(commandId: string): number {
return command.count * 0.7 + recencyBonus * 0.3; return command.count * 0.7 + recencyBonus * 0.3;
} }
export function getTopCommands( export function getTopCommands(limit = 10): { id: string; score: number }[] {
limit = 10
): Array<{ id: string; score: number }> {
const stats = getStats(); const stats = getStats();
const scored = Object.keys(stats.commands).map(id => ({ const scored = Object.keys(stats.commands).map(id => ({

View File

@@ -79,10 +79,8 @@ export function processLibraryItem(
} }
} }
// For movies and other types, use thumb // For movies and other types, use thumb
else { else if (item.thumb) {
if (item.thumb) { posterUrl = `${serverUrl}${item.thumb}?X-Plex-Token=${authToken}`;
posterUrl = `${serverUrl}${item.thumb}?X-Plex-Token=${authToken}`;
}
} }
// Build Plex Web App URL // Build Plex Web App URL
@@ -120,7 +118,8 @@ export function processLibraryItem(
...baseItem, ...baseItem,
episodes: item.leafCount || 0 episodes: item.leafCount || 0
}; };
} else if (libraryType === "music") { }
if (libraryType === "music") {
return { return {
...baseItem, ...baseItem,
artist: item.parentTitle || "Unknown Artist", artist: item.parentTitle || "Unknown Artist",
@@ -166,7 +165,7 @@ export function calculateDuration(metadata: any[], libraryType: string) {
}); });
const hours = Math.round(totalDuration / (1000 * 60 * 60)); const hours = Math.round(totalDuration / (1000 * 60 * 60));
const formattedDuration = hours.toLocaleString() + " hours"; const formattedDuration = `${hours.toLocaleString()} hours`;
return { return {
totalDuration: formattedDuration, totalDuration: formattedDuration,