mirror of
https://github.com/KevinMidboe/seasoned.git
synced 2026-05-08 23:25:32 +00:00
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:
12
src/api.ts
12
src/api.ts
@@ -262,17 +262,22 @@ const getRequestStatus = async (
|
||||
.catch(err => Promise.reject(err));
|
||||
};
|
||||
|
||||
/*
|
||||
const watchLink = async (title, year) => {
|
||||
const watchLink = async (title: string, year: string) => {
|
||||
const url = new URL("/api/v1/plex/watch-link", API_HOSTNAME);
|
||||
url.searchParams.append("title", title);
|
||||
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(response => response.link);
|
||||
};
|
||||
|
||||
/*
|
||||
const movieImages = id => {
|
||||
const url = new URL(`v2/movie/${id}/images`, API_HOSTNAME);
|
||||
|
||||
@@ -560,6 +565,7 @@ export {
|
||||
getSettings,
|
||||
updateSettings,
|
||||
fetchGraphData,
|
||||
watchLink,
|
||||
getEmoji,
|
||||
elasticSearchMoviesAndShows
|
||||
};
|
||||
|
||||
@@ -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,135 +47,212 @@
|
||||
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;
|
||||
const graphCanvas: Ref<HTMLCanvasElement | null> = ref(null);
|
||||
let graphInstance: Chart | null = null;
|
||||
|
||||
/* eslint-disable no-use-before-define */
|
||||
onMounted(() => generateGraph());
|
||||
watch(() => props.data, generateGraph);
|
||||
/* eslint-enable no-use-before-define */
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Modern Color System
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
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) {
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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 {
|
||||
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;
|
||||
}
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Chart Generator
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
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,
|
||||
elements: {
|
||||
point: {
|
||||
radius: 2,
|
||||
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -36,19 +36,19 @@
|
||||
sortDirection === "asc" ? "↑" : "↓"
|
||||
}}</span>
|
||||
</th>
|
||||
<th @click="sortBy('size')" class="sortable">
|
||||
<th v-if="!isMobile" @click="sortBy('size')" class="sortable">
|
||||
Size
|
||||
<span v-if="sortColumn === 'size'">{{
|
||||
sortDirection === "asc" ? "↑" : "↓"
|
||||
}}</span>
|
||||
</th>
|
||||
<th @click="sortBy('seeders')" class="sortable">
|
||||
<th v-if="!isMobile" @click="sortBy('seeders')" class="sortable">
|
||||
Seeders
|
||||
<span v-if="sortColumn === 'seeders'">{{
|
||||
sortDirection === "asc" ? "↑" : "↓"
|
||||
}}</span>
|
||||
</th>
|
||||
<th>Status</th>
|
||||
<th v-if="!isMobile">Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -58,10 +58,23 @@
|
||||
:key="torrent.id"
|
||||
:class="{ processing: torrent.processing }"
|
||||
>
|
||||
<td class="torrent-name" :title="torrent.name">{{ torrent.name }}</td>
|
||||
<td>{{ torrent.size }}</td>
|
||||
<td>{{ torrent.seeders }}</td>
|
||||
<td>
|
||||
<td class="torrent-name" :title="torrent.name">
|
||||
<div class="torrent-name__title">{{ torrent.name }}</div>
|
||||
<div v-if="isMobile" class="torrent-name__meta">
|
||||
<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}`]">
|
||||
{{ torrent.status }}
|
||||
</span>
|
||||
@@ -116,7 +129,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import IconStop from "@/icons/IconStop.vue";
|
||||
import IconPlay from "@/icons/IconPlay.vue";
|
||||
import IconClose from "@/icons/IconClose.vue";
|
||||
@@ -144,6 +157,13 @@
|
||||
const sortColumn = ref<keyof Torrent>("name");
|
||||
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(() => {
|
||||
let result = [...torrents.value];
|
||||
|
||||
@@ -306,7 +326,14 @@
|
||||
);
|
||||
}
|
||||
|
||||
onMounted(fetchTorrents);
|
||||
onMounted(() => {
|
||||
fetchTorrents();
|
||||
window.addEventListener("resize", handleResize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -441,15 +468,14 @@
|
||||
|
||||
&__table {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-spacing: 0;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
table-layout: fixed;
|
||||
|
||||
@include mobile-only {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
max-width: 100%;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
th,
|
||||
@@ -525,8 +551,42 @@
|
||||
white-space: nowrap;
|
||||
|
||||
@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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,8 @@
|
||||
}
|
||||
|
||||
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> = [];
|
||||
|
||||
|
||||
@@ -42,7 +42,8 @@
|
||||
.navigation-link {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: var(--header-size);
|
||||
height: var(--header-size);
|
||||
width: var(--header-size);
|
||||
list-style: none;
|
||||
padding: 1rem 0.15rem;
|
||||
text-align: center;
|
||||
|
||||
@@ -90,10 +90,18 @@
|
||||
|
||||
@include desktop {
|
||||
grid-template-rows: var(--header-size);
|
||||
grid-auto-flow: row;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.navigation-icons > *:last-child) {
|
||||
margin-top: auto;
|
||||
justify-self: end;
|
||||
align-self: end;
|
||||
background-color: red;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { computed, onBeforeUnmount } from "vue";
|
||||
import IconClose from "@/icons/IconClose.vue";
|
||||
import IconMovie from "@/icons/IconMovie.vue";
|
||||
import IconShow from "@/icons/IconShow.vue";
|
||||
@@ -112,6 +112,17 @@
|
||||
if (props.libraryType === "music") return IconMusic;
|
||||
return IconMovie;
|
||||
});
|
||||
|
||||
function checkEventForEscapeKey(event: KeyboardEvent) {
|
||||
if (event.key !== "Escape") return;
|
||||
emit("close");
|
||||
}
|
||||
|
||||
window.addEventListener("keyup", checkEventForEscapeKey);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("keyup", checkEventForEscapeKey);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -136,7 +147,6 @@
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
max-height: 90vh;
|
||||
margin-top: calc(var(--header-size) + 1rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
@@ -183,12 +193,16 @@
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
--size: 2.4rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
height: var(--size);
|
||||
width: var(--size);
|
||||
border-radius: 6px;
|
||||
fill: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
v-for="stat in displayStats"
|
||||
:key="stat.key"
|
||||
class="stat-card"
|
||||
:class="{ disabled: stat.value === 0 || loading }"
|
||||
:class="{
|
||||
disabled: stat.value === 0 || loading,
|
||||
unclickable: !!!stat.clickable
|
||||
}"
|
||||
@click="
|
||||
stat.clickable && stat.value > 0 && !loading && handleClick(stat.key)
|
||||
"
|
||||
@@ -88,6 +91,10 @@
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.85rem;
|
||||
|
||||
@include tablet-only {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
@include mobile-only {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.65rem;
|
||||
@@ -102,7 +109,6 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
|
||||
@@ -110,15 +116,15 @@
|
||||
padding: 0.85rem 0.75rem;
|
||||
}
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
&:hover:not(.disabled, .unclickable) {
|
||||
background-color: var(--background-40);
|
||||
border-color: var(--highlight-color);
|
||||
cursor: pointer;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
</Detail>
|
||||
|
||||
<Detail
|
||||
v-if="creditedShows.length"
|
||||
v-if="creditedMovies.length"
|
||||
title="movies"
|
||||
:detail="`Credited in ${creditedMovies.length} movies`"
|
||||
>
|
||||
|
||||
@@ -293,10 +293,12 @@
|
||||
// ----- Library modal -----
|
||||
function showLibraryDetails(type: string) {
|
||||
selectedLibrary.value = type;
|
||||
document.getElementsByTagName("body")[0].classList.add("no-scroll");
|
||||
showLibraryModal.value = true;
|
||||
}
|
||||
function closeLibraryModal() {
|
||||
showLibraryModal.value = false;
|
||||
document.getElementsByTagName("body")[0].classList.remove("no-scroll");
|
||||
selectedLibrary.value = "";
|
||||
}
|
||||
|
||||
|
||||
@@ -192,16 +192,23 @@
|
||||
border-spacing: 0;
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
// border-collapse: collapse;
|
||||
max-width: 100%;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
table-layout: fixed;
|
||||
|
||||
@include mobile {
|
||||
table-layout: auto;
|
||||
}
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 0.5px solid var(--background-color-40);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@include mobile {
|
||||
white-space: nowrap;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
@@ -241,6 +248,8 @@
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
line-height: 1.3;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
@include mobile {
|
||||
font-size: 0.95rem;
|
||||
|
||||
@@ -204,7 +204,11 @@
|
||||
|
||||
const routes = computed(() => {
|
||||
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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
// Shared constants - generated once and reused
|
||||
export const CLIENT_IDENTIFIER =
|
||||
"seasoned-plex-app-" + Math.random().toString(36).substring(7);
|
||||
export const CLIENT_IDENTIFIER = `seasoned-plex-app-${Math.random().toString(36).substring(7)}`;
|
||||
export const APP_NAME = window.location.hostname;
|
||||
|
||||
export function usePlexApi() {
|
||||
|
||||
@@ -34,8 +34,8 @@ export function usePlexLibraries() {
|
||||
|
||||
try {
|
||||
for (const section of sections) {
|
||||
const type = section.type;
|
||||
const key = section.key;
|
||||
const { type } = section;
|
||||
const { key } = section;
|
||||
|
||||
if (type === "movie") {
|
||||
await processLibrarySection(
|
||||
|
||||
@@ -703,7 +703,7 @@ export function useRandomWords() {
|
||||
];
|
||||
|
||||
// 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 Random Word API first
|
||||
const response = await fetch(
|
||||
|
||||
@@ -31,10 +31,10 @@ interface HomeStatItem {
|
||||
|
||||
interface PlaysGraphData {
|
||||
categories: string[];
|
||||
series: Array<{
|
||||
series: {
|
||||
name: string;
|
||||
data: number[];
|
||||
}>;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function useTautulliStats() {
|
||||
@@ -72,7 +72,7 @@ export function useTautulliStats() {
|
||||
// Fetch home statistics (pre-aggregated by Tautulli!)
|
||||
async function fetchHomeStats(
|
||||
userId?: number,
|
||||
timeRange: number = 30,
|
||||
timeRange = 30,
|
||||
statsType: "plays" | "duration" = "plays"
|
||||
): Promise<WatchStats> {
|
||||
try {
|
||||
@@ -162,7 +162,7 @@ export function useTautulliStats() {
|
||||
|
||||
// Fetch plays by date (already aggregated by Tautulli!)
|
||||
async function fetchPlaysByDate(
|
||||
timeRange: number = 30,
|
||||
timeRange = 30,
|
||||
yAxis: "plays" | "duration" = "plays",
|
||||
userId?: number
|
||||
): Promise<DayStats[]> {
|
||||
@@ -184,10 +184,9 @@ export function useTautulliStats() {
|
||||
|
||||
// Sum all series data for each date
|
||||
return data.categories.map((date, index) => {
|
||||
const totalValue = data.series.reduce(
|
||||
(sum, series) => sum + (series.data[index] || 0),
|
||||
0
|
||||
);
|
||||
const totalValue = data.series
|
||||
.filter(s => s.name !== "Total")
|
||||
.reduce((sum, series) => sum + (series.data[index] || 0), 0);
|
||||
|
||||
return {
|
||||
date,
|
||||
@@ -203,7 +202,7 @@ export function useTautulliStats() {
|
||||
|
||||
// Fetch plays by day of week (already aggregated!)
|
||||
async function fetchPlaysByDayOfWeek(
|
||||
timeRange: number = 30,
|
||||
timeRange = 30,
|
||||
yAxis: "plays" | "duration" = "plays",
|
||||
userId?: number
|
||||
): Promise<{
|
||||
@@ -264,7 +263,7 @@ export function useTautulliStats() {
|
||||
|
||||
// Fetch plays by hour of day (already aggregated!)
|
||||
async function fetchPlaysByHourOfDay(
|
||||
timeRange: number = 30,
|
||||
timeRange = 30,
|
||||
yAxis: "plays" | "duration" = "plays",
|
||||
userId?: number
|
||||
): Promise<{ labels: string[]; data: number[] }> {
|
||||
@@ -285,12 +284,9 @@ export function useTautulliStats() {
|
||||
);
|
||||
|
||||
// Sum all series data for each hour
|
||||
const hourlyData = data.categories.map((hour, index) => {
|
||||
return data.series.reduce(
|
||||
(sum, series) => sum + (series.data[index] || 0),
|
||||
0
|
||||
);
|
||||
});
|
||||
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`),
|
||||
@@ -306,11 +302,7 @@ export function useTautulliStats() {
|
||||
}
|
||||
|
||||
// Fetch top watched content from home stats
|
||||
async function fetchTopContent(
|
||||
timeRange: number = 30,
|
||||
limit: number = 10,
|
||||
userId?: number
|
||||
) {
|
||||
async function fetchTopContent(timeRange = 30, limit = 10, userId?: number) {
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
time_range: timeRange,
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
:stacked="false"
|
||||
:dataset-description-suffix="`watch last ${days} days`"
|
||||
:tooltip-description-suffix="selectedGraphViewMode.tooltipLabel"
|
||||
:graph-value-type="selectedGraphViewMode.valueType"
|
||||
:graph-value-type="graphViewMode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,11 +70,11 @@
|
||||
<Graph
|
||||
v-if="playsByDayofweekData"
|
||||
:data="playsByDayofweekData"
|
||||
:graphValueType="graphViewMode"
|
||||
type="bar"
|
||||
:stacked="true"
|
||||
:dataset-description-suffix="`watch last ${days} days`"
|
||||
tooltip-description-suffix="plays"
|
||||
:graph-value-type="GraphValueTypes.Number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,7 +89,7 @@
|
||||
:stacked="false"
|
||||
:dataset-description-suffix="`last ${days} days`"
|
||||
tooltip-description-suffix="plays"
|
||||
:graph-value-type="GraphValueTypes.Number"
|
||||
:graph-value-type="graphViewMode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,13 +201,13 @@
|
||||
fetchTopContent
|
||||
} = 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}/)) {
|
||||
return date;
|
||||
}
|
||||
|
||||
const [, month, day] = date.split("-");
|
||||
return `${day}.${month}`;
|
||||
const [year, month, day] = date.split("-");
|
||||
return short ? `${month}.${day}` : `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
async function fetchChartData() {
|
||||
@@ -235,7 +235,9 @@
|
||||
|
||||
// Activity per day
|
||||
playsByDayData.value = {
|
||||
labels: dayData.map(d => convertDateStringToDayMonth(d.date)),
|
||||
labels: dayData.map(d =>
|
||||
convertDateStringToDayMonth(d.date, dayData.length < 365)
|
||||
),
|
||||
series: [
|
||||
{
|
||||
name: "Activity",
|
||||
|
||||
@@ -62,161 +62,161 @@
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ------------------------------
|
||||
/* ------------------------------
|
||||
Transition
|
||||
------------------------------ */
|
||||
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: all 0.35s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: all 0.35s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.slide-enter-from,
|
||||
.slide-leave-to {
|
||||
transform: translateY(40px);
|
||||
opacity: 0;
|
||||
}
|
||||
.slide-enter-from,
|
||||
.slide-leave-to {
|
||||
transform: translateY(40px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ------------------------------
|
||||
/* ------------------------------
|
||||
Toast
|
||||
------------------------------ */
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
right: 1.25rem;
|
||||
bottom: 1.25rem;
|
||||
z-index: 1000;
|
||||
cursor: pointer;
|
||||
.toast {
|
||||
position: fixed;
|
||||
right: 1.25rem;
|
||||
bottom: 1.25rem;
|
||||
z-index: 1000;
|
||||
cursor: pointer;
|
||||
|
||||
min-width: 340px;
|
||||
max-width: 460px;
|
||||
width: calc(100vw - 2rem);
|
||||
min-width: 340px;
|
||||
max-width: 460px;
|
||||
width: calc(100vw - 2rem);
|
||||
|
||||
padding: 1.1rem 1.25rem;
|
||||
padding: 1.1rem 1.25rem;
|
||||
|
||||
border-radius: 16px;
|
||||
border-radius: 16px;
|
||||
|
||||
/* System-based surface */
|
||||
background: var(--background-color-secondary);
|
||||
/* System-based surface */
|
||||
background: var(--background-color-secondary);
|
||||
|
||||
/* Subtle separation */
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
/* Subtle separation */
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
|
||||
/* Clear state indicator */
|
||||
border-left: 5px solid transparent;
|
||||
/* Clear state indicator */
|
||||
border-left: 5px solid transparent;
|
||||
|
||||
/* Base text tone */
|
||||
color: var(--text-color, #1f2937);
|
||||
/* Base text tone */
|
||||
color: var(--text-color, #1f2937);
|
||||
|
||||
line-height: 1.5;
|
||||
line-height: 1.5;
|
||||
|
||||
/* ------------------------------
|
||||
/* ------------------------------
|
||||
Content Layout
|
||||
------------------------------ */
|
||||
|
||||
&--content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
&--content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&--text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
&--text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ------------------------------
|
||||
/* ------------------------------
|
||||
Typography Hierarchy
|
||||
------------------------------ */
|
||||
|
||||
/* Context label */
|
||||
&--text__title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
/* Context label */
|
||||
&--text__title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
|
||||
/* Softer than body but not faded */
|
||||
color: color-mix(in srgb, currentColor 75%, transparent);
|
||||
}
|
||||
/* Softer than body but not faded */
|
||||
color: color-mix(in srgb, currentColor 75%, transparent);
|
||||
}
|
||||
|
||||
/* Primary message */
|
||||
&--text__description {
|
||||
margin-top: 0.3rem;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: currentColor;
|
||||
}
|
||||
/* Primary message */
|
||||
&--text__description {
|
||||
margin-top: 0.3rem;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
&--text__title-large {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
&--text__title-large {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ------------------------------
|
||||
/* ------------------------------
|
||||
Dismiss Button
|
||||
------------------------------ */
|
||||
|
||||
&--dismiss {
|
||||
flex-shrink: 0;
|
||||
&--dismiss {
|
||||
flex-shrink: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
|
||||
border-radius: 8px;
|
||||
border-radius: 8px;
|
||||
|
||||
transition: background 0.2s ease;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--background-color);
|
||||
&:hover {
|
||||
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
|
||||
------------------------------ */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.toast {
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
width: auto;
|
||||
min-width: unset;
|
||||
@media (max-width: 480px) {
|
||||
.toast {
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
width: auto;
|
||||
min-width: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,9 +6,7 @@ interface CommandData {
|
||||
}
|
||||
|
||||
interface CommandStats {
|
||||
commands: {
|
||||
[key: string]: CommandData;
|
||||
};
|
||||
commands: Record<string, CommandData>;
|
||||
version: number;
|
||||
}
|
||||
|
||||
@@ -24,6 +22,7 @@ function getStats(): CommandStats {
|
||||
const parsed = JSON.parse(stored) as CommandStats;
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to parse command stats:", error);
|
||||
return { commands: {}, version: CURRENT_VERSION };
|
||||
}
|
||||
@@ -33,6 +32,7 @@ function saveStats(stats: CommandStats): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stats));
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
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();
|
||||
|
||||
if (metadata?.routePath) {
|
||||
@@ -80,9 +80,7 @@ export function getCommandScore(commandId: string): number {
|
||||
return command.count * 0.7 + recencyBonus * 0.3;
|
||||
}
|
||||
|
||||
export function getTopCommands(
|
||||
limit = 10
|
||||
): Array<{ id: string; score: number }> {
|
||||
export function getTopCommands(limit = 10): { id: string; score: number }[] {
|
||||
const stats = getStats();
|
||||
|
||||
const scored = Object.keys(stats.commands).map(id => ({
|
||||
|
||||
@@ -79,10 +79,8 @@ export function processLibraryItem(
|
||||
}
|
||||
}
|
||||
// For movies and other types, use thumb
|
||||
else {
|
||||
if (item.thumb) {
|
||||
posterUrl = `${serverUrl}${item.thumb}?X-Plex-Token=${authToken}`;
|
||||
}
|
||||
else if (item.thumb) {
|
||||
posterUrl = `${serverUrl}${item.thumb}?X-Plex-Token=${authToken}`;
|
||||
}
|
||||
|
||||
// Build Plex Web App URL
|
||||
@@ -120,7 +118,8 @@ export function processLibraryItem(
|
||||
...baseItem,
|
||||
episodes: item.leafCount || 0
|
||||
};
|
||||
} else if (libraryType === "music") {
|
||||
}
|
||||
if (libraryType === "music") {
|
||||
return {
|
||||
...baseItem,
|
||||
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 formattedDuration = hours.toLocaleString() + " hours";
|
||||
const formattedDuration = `${hours.toLocaleString()} hours`;
|
||||
|
||||
return {
|
||||
totalDuration: formattedDuration,
|
||||
|
||||
Reference in New Issue
Block a user