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));
};
/*
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
};

View File

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

View File

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

View File

@@ -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> = [];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 => ({

View File

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