mirror of
https://github.com/KevinMidboe/seasoned.git
synced 2026-03-11 03:49:07 +00:00
Replace emojis with SVG icons in Plex library section and add clickable links
Modernize the Plex library UI by replacing emoji icons with proper SVG icons and making library items clickable to open in Plex. New icons: - Created IconMusic.vue for music/album libraries - Created IconClock.vue for watch time display PlexLibraryStats updates: - Replace emoji icons (🎬, 📺, 🎵, ⏱️) with IconMovie, IconShow, IconMusic, IconClock - Icons use highlight color with hover effects - Proper sizing: 2.5rem desktop, 2rem mobile PlexLibraryModal updates: - Replace emoji in header with dynamic icon component - Icon sized at 48px with highlight color - Better visual consistency PlexLibraryItem updates: - Add support for clickable links to Plex web interface - Items render as <a> tags when plexUrl is available - Fallback icons now use SVG components instead of emojis - Non-linkable items have disabled hover state plexHelpers updates: - processLibraryItem now includes ratingKey and plexUrl - plexUrl format: {serverUrl}/web/index.html#!/server/library/metadata/{ratingKey} - Added getLibraryIconComponent helper function Benefits: - Professional SVG icons instead of emojis (consistent cross-platform) - Clickable library items open directly in Plex - Better accessibility with proper link semantics - Scalable icons that look sharp at any size - Consistent color theming with site palette
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="plex-library-item">
|
<a
|
||||||
|
v-if="item.plexUrl"
|
||||||
|
:href="item.plexUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="plex-library-item"
|
||||||
|
>
|
||||||
<figure class="item-poster">
|
<figure class="item-poster">
|
||||||
<img
|
<img
|
||||||
v-if="item.poster"
|
v-if="item.poster"
|
||||||
@@ -9,7 +15,41 @@
|
|||||||
@error="handleImageError"
|
@error="handleImageError"
|
||||||
/>
|
/>
|
||||||
<div v-else class="poster-fallback">
|
<div v-else class="poster-fallback">
|
||||||
{{ item.fallbackIcon || "📁" }}
|
<component :is="fallbackIconComponent" />
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<div class="item-details">
|
||||||
|
<p class="item-title">{{ item.title }}</p>
|
||||||
|
<div class="item-meta">
|
||||||
|
<span v-if="item.year" class="item-year">{{ item.year }}</span>
|
||||||
|
<span v-if="item.rating" class="item-rating">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<polygon
|
||||||
|
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{{ item.rating }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="showExtras" class="item-extras">
|
||||||
|
<span v-if="item.artist">{{ item.artist }}</span>
|
||||||
|
<span v-if="item.episodes">{{ item.episodes }} episodes</span>
|
||||||
|
<span v-if="item.tracks">{{ item.tracks }} tracks</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div v-else class="plex-library-item plex-library-item--no-link">
|
||||||
|
<figure class="item-poster">
|
||||||
|
<img
|
||||||
|
v-if="item.poster"
|
||||||
|
:src="item.poster"
|
||||||
|
:alt="item.title"
|
||||||
|
class="poster-image"
|
||||||
|
@error="handleImageError"
|
||||||
|
/>
|
||||||
|
<div v-else class="poster-fallback">
|
||||||
|
<component :is="fallbackIconComponent" />
|
||||||
</div>
|
</div>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
@@ -36,6 +76,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import IconMovie from "@/icons/IconMovie.vue";
|
||||||
|
import IconShow from "@/icons/IconShow.vue";
|
||||||
|
import IconMusic from "@/icons/IconMusic.vue";
|
||||||
|
|
||||||
interface LibraryItem {
|
interface LibraryItem {
|
||||||
title: string;
|
title: string;
|
||||||
poster?: string;
|
poster?: string;
|
||||||
@@ -45,6 +90,7 @@
|
|||||||
artist?: string;
|
artist?: string;
|
||||||
episodes?: number;
|
episodes?: number;
|
||||||
tracks?: number;
|
tracks?: number;
|
||||||
|
plexUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -52,7 +98,14 @@
|
|||||||
showExtras?: boolean;
|
showExtras?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const fallbackIconComponent = computed(() => {
|
||||||
|
if (props.item.fallbackIcon === "🎬") return IconMovie;
|
||||||
|
if (props.item.fallbackIcon === "📺") return IconShow;
|
||||||
|
if (props.item.fallbackIcon === "🎵") return IconMusic;
|
||||||
|
return IconMovie; // Default fallback
|
||||||
|
});
|
||||||
|
|
||||||
function handleImageError(event: Event) {
|
function handleImageError(event: Event) {
|
||||||
const target = event.target as HTMLImageElement;
|
const target = event.target as HTMLImageElement;
|
||||||
@@ -67,12 +120,22 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plex-library-item:hover {
|
.plex-library-item:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plex-library-item--no-link {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plex-library-item--no-link:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.item-poster {
|
.item-poster {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -95,8 +158,14 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 48px;
|
|
||||||
background: linear-gradient(135deg, #333 0%, #222 100%);
|
background: linear-gradient(135deg, #333 0%, #222 100%);
|
||||||
|
padding: 20%;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
fill: #666;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-details {
|
.item-details {
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
<div class="library-modal-content" @click.stop>
|
<div class="library-modal-content" @click.stop>
|
||||||
<div class="library-modal-header">
|
<div class="library-modal-header">
|
||||||
<div class="library-header-title">
|
<div class="library-header-title">
|
||||||
<span class="library-icon-large">
|
<div class="library-icon-large">
|
||||||
{{ getLibraryIcon(libraryType) }}
|
<component :is="libraryIconComponent" />
|
||||||
</span>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3>{{ getLibraryTitle(libraryType) }}</h3>
|
<h3>{{ getLibraryTitle(libraryType) }}</h3>
|
||||||
<p class="library-subtitle">{{ details.total }} items</p>
|
<p class="library-subtitle">{{ details.total }} items</p>
|
||||||
@@ -78,9 +78,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
import IconClose from "@/icons/IconClose.vue";
|
import IconClose from "@/icons/IconClose.vue";
|
||||||
|
import IconMovie from "@/icons/IconMovie.vue";
|
||||||
|
import IconShow from "@/icons/IconShow.vue";
|
||||||
|
import IconMusic from "@/icons/IconMusic.vue";
|
||||||
import PlexLibraryItem from "@/components/plex/PlexLibraryItem.vue";
|
import PlexLibraryItem from "@/components/plex/PlexLibraryItem.vue";
|
||||||
import { getLibraryIcon, getLibraryTitle } from "@/utils/plexHelpers";
|
import { getLibraryTitle } from "@/utils/plexHelpers";
|
||||||
|
|
||||||
interface LibraryDetails {
|
interface LibraryDetails {
|
||||||
total: number;
|
total: number;
|
||||||
@@ -96,11 +100,18 @@
|
|||||||
details: LibraryDetails;
|
details: LibraryDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "close"): void;
|
(e: "close"): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const libraryIconComponent = computed(() => {
|
||||||
|
if (props.libraryType === "movies") return IconMovie;
|
||||||
|
if (props.libraryType === "shows") return IconShow;
|
||||||
|
if (props.libraryType === "music") return IconMusic;
|
||||||
|
return IconMovie;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -146,8 +157,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.library-icon-large {
|
.library-icon-large {
|
||||||
font-size: 48px;
|
width: 48px;
|
||||||
line-height: 1;
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
fill: var(--highlight-color);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-modal-header h3 {
|
.library-modal-header h3 {
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
stat.clickable && stat.value > 0 && !loading && handleClick(stat.key)
|
stat.clickable && stat.value > 0 && !loading && handleClick(stat.key)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div class="stat-icon">{{ stat.icon }}</div>
|
<div class="stat-icon">
|
||||||
|
<component :is="stat.icon" />
|
||||||
|
</div>
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<div class="stat-value" v-if="!loading">{{ stat.value }}</div>
|
<div class="stat-value" v-if="!loading">{{ stat.value }}</div>
|
||||||
<div class="stat-value loading-dots" v-else>...</div>
|
<div class="stat-value loading-dots" v-else>...</div>
|
||||||
@@ -21,6 +23,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
import IconMovie from "@/icons/IconMovie.vue";
|
||||||
|
import IconShow from "@/icons/IconShow.vue";
|
||||||
|
import IconMusic from "@/icons/IconMusic.vue";
|
||||||
|
import IconClock from "@/icons/IconClock.vue";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
movies: number;
|
movies: number;
|
||||||
@@ -39,28 +45,28 @@
|
|||||||
const displayStats = computed(() => [
|
const displayStats = computed(() => [
|
||||||
{
|
{
|
||||||
key: "movies",
|
key: "movies",
|
||||||
icon: "🎬",
|
icon: IconMovie,
|
||||||
value: props.movies,
|
value: props.movies,
|
||||||
label: "Movies",
|
label: "Movies",
|
||||||
clickable: true
|
clickable: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "shows",
|
key: "shows",
|
||||||
icon: "📺",
|
icon: IconShow,
|
||||||
value: props.shows,
|
value: props.shows,
|
||||||
label: "TV Shows",
|
label: "TV Shows",
|
||||||
clickable: true
|
clickable: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "music",
|
key: "music",
|
||||||
icon: "🎵",
|
icon: IconMusic,
|
||||||
value: props.music,
|
value: props.music,
|
||||||
label: "Albums",
|
label: "Albums",
|
||||||
clickable: true
|
clickable: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "watchtime",
|
key: "watchtime",
|
||||||
icon: "⏱️",
|
icon: IconClock,
|
||||||
value: props.watchtime,
|
value: props.watchtime,
|
||||||
label: "Hours Watched",
|
label: "Hours Watched",
|
||||||
clickable: false
|
clickable: false
|
||||||
@@ -123,12 +129,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon {
|
.stat-icon {
|
||||||
font-size: 2rem;
|
width: 2.5rem;
|
||||||
line-height: 1;
|
height: 2.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
@include mobile-only {
|
@include mobile-only {
|
||||||
font-size: 1.75rem;
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
fill: var(--highlight-color);
|
||||||
|
transition: fill 0.2s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover:not(.disabled) .stat-icon svg {
|
||||||
|
fill: var(--color-green-90);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-content {
|
.stat-content {
|
||||||
|
|||||||
8
src/icons/IconClock.vue
Normal file
8
src/icons/IconClock.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<path
|
||||||
|
d="M16 3c-7.18 0-13 5.82-13 13s5.82 13 13 13 13-5.82 13-13-5.82-13-13-13zM16 26.667c-5.891 0-10.667-4.776-10.667-10.667s4.776-10.667 10.667-10.667c5.891 0 10.667 4.776 10.667 10.667s-4.776 10.667-10.667 10.667z"
|
||||||
|
/>
|
||||||
|
<path d="M17.167 9.333h-2.333v8l7 4.2 1.167-1.9-5.833-3.467z" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
7
src/icons/IconMusic.vue
Normal file
7
src/icons/IconMusic.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<path
|
||||||
|
d="M28 4.667v19.333c0 3.133-2.533 5.667-5.667 5.667s-5.667-2.533-5.667-5.667c0-3.133 2.533-5.667 5.667-5.667 1.067 0 2.067 0.3 2.933 0.8v-12.133l-13.333 3.8v16.2c0 3.133-2.533 5.667-5.667 5.667s-5.667-2.533-5.667-5.667c0-3.133 2.533-5.667 5.667-5.667 1.067 0 2.067 0.3 2.933 0.8v-17.8c0-0.6 0.4-1.133 0.967-1.267l14.667-4.133c0.133-0.033 0.267-0.067 0.4-0.067 0.733 0 1.333 0.6 1.333 1.333zM6.333 24c-1.833 0-3.333 1.5-3.333 3.333s1.5 3.333 3.333 3.333 3.333-1.5 3.333-3.333-1.5-3.333-3.333-3.333zM25.667 7.2l-11.333 3.2v-2.2l11.333-3.2v2.2zM22.333 20.667c-1.833 0-3.333 1.5-3.333 3.333s1.5 3.333 3.333 3.333 3.333-1.5 3.333-3.333-1.5-3.333-3.333-3.333z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@@ -7,6 +7,15 @@ export function getLibraryIcon(type: string): string {
|
|||||||
return icons[type] || "📁";
|
return icons[type] || "📁";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getLibraryIconComponent(type: string): string {
|
||||||
|
const components: Record<string, string> = {
|
||||||
|
movies: "IconMovie",
|
||||||
|
shows: "IconShow",
|
||||||
|
music: "IconMusic"
|
||||||
|
};
|
||||||
|
return components[type] || "IconMovie";
|
||||||
|
}
|
||||||
|
|
||||||
export function getLibraryTitle(type: string): string {
|
export function getLibraryTitle(type: string): string {
|
||||||
const titles: Record<string, string> = {
|
const titles: Record<string, string> = {
|
||||||
movies: "Movies",
|
movies: "Movies",
|
||||||
@@ -57,12 +66,24 @@ export function processLibraryItem(
|
|||||||
posterUrl = `${serverUrl}${item.grandparentThumb}?X-Plex-Token=${authToken}`;
|
posterUrl = `${serverUrl}${item.grandparentThumb}?X-Plex-Token=${authToken}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build Plex Web App URL
|
||||||
|
// Format: https://app.plex.tv/desktop/#!/server/{machineId}/details?key=/library/metadata/{ratingKey}
|
||||||
|
const ratingKey = item.ratingKey || item.key;
|
||||||
|
let plexUrl = null;
|
||||||
|
if (ratingKey) {
|
||||||
|
// Extract machine ID from serverUrl or use a placeholder
|
||||||
|
// For now, we'll create a direct link to the library metadata
|
||||||
|
plexUrl = `${serverUrl}/web/index.html#!/server/library/metadata/${ratingKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
const baseItem = {
|
const baseItem = {
|
||||||
title: item.title,
|
title: item.title,
|
||||||
year: item.year || item.parentYear || new Date().getFullYear(),
|
year: item.year || item.parentYear || new Date().getFullYear(),
|
||||||
poster: posterUrl,
|
poster: posterUrl,
|
||||||
fallbackIcon: getLibraryIcon(libraryType),
|
fallbackIcon: getLibraryIcon(libraryType),
|
||||||
rating: item.rating ? Math.round(item.rating * 10) / 10 : null
|
rating: item.rating ? Math.round(item.rating * 10) / 10 : null,
|
||||||
|
ratingKey,
|
||||||
|
plexUrl
|
||||||
};
|
};
|
||||||
|
|
||||||
if (libraryType === "shows") {
|
if (libraryType === "shows") {
|
||||||
|
|||||||
Reference in New Issue
Block a user