mirror of
https://github.com/KevinMidboe/seasoned.git
synced 2026-05-13 17:45:43 +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));
|
.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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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> = [];
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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`"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 => ({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user