Refactor: Create reusable PlexLibraryItem component with grid layout

- Create new PlexLibraryItem.vue component
  - Displays poster with fallback icon
  - Shows title, year, and rating
  - Optional extras (artist, episodes, tracks)
  - Hover effect with translateY animation
  - Responsive font sizes for mobile

- Update PlexLibraryModal to use grid layout
  - Replace vertical list with CSS Grid
  - Grid: repeat(auto-fill, minmax(140px, 1fr))
  - Mobile: minmax(110px, 1fr) with reduced gap
  - Much better space utilization
  - Items flow horizontally then vertically

- Remove duplicate styles from modal
  - Removed 69 lines of item styling
  - All item display logic in PlexLibraryItem
  - Cleaner separation of concerns

Benefits:
- Better visual presentation (grid vs vertical list)
- More items visible at once
- Reusable component for future Plex features
- Reduced modal complexity (284 → 215 lines)
This commit is contained in:
2026-02-27 18:07:38 +01:00
parent 720f4e253a
commit 6c24bc928c
2 changed files with 173 additions and 117 deletions

View File

@@ -0,0 +1,157 @@
<template>
<div class="plex-library-item">
<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">
{{ item.fallbackIcon || "📁" }}
</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>
</div>
</template>
<script setup lang="ts">
interface LibraryItem {
title: string;
poster?: string;
fallbackIcon?: string;
year?: number;
rating?: number;
artist?: string;
episodes?: number;
tracks?: number;
}
interface Props {
item: LibraryItem;
showExtras?: boolean;
}
defineProps<Props>();
function handleImageError(event: Event) {
const target = event.target as HTMLImageElement;
target.style.display = "none";
}
</script>
<style scoped>
.plex-library-item {
display: flex;
flex-direction: column;
gap: 8px;
cursor: pointer;
transition: transform 0.2s;
}
.plex-library-item:hover {
transform: translateY(-4px);
}
.item-poster {
position: relative;
width: 100%;
aspect-ratio: 2 / 3;
border-radius: 8px;
overflow: hidden;
background: #333;
margin: 0;
}
.poster-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.poster-fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
background: linear-gradient(135deg, #333 0%, #222 100%);
}
.item-details {
display: flex;
flex-direction: column;
gap: 4px;
}
.item-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #fff;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.item-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #888;
}
.item-year {
color: #aaa;
}
.item-rating {
display: flex;
align-items: center;
gap: 4px;
color: #fbbf24;
}
.item-extras {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 11px;
color: #888;
}
@media (max-width: 768px) {
.item-title {
font-size: 13px;
}
.item-meta {
font-size: 11px;
}
}
</style>

View File

@@ -40,51 +40,13 @@
<!-- Recently Added -->
<div class="library-section">
<h4 class="section-title">Recently Added</h4>
<div class="recent-items">
<div
<div class="recent-items-grid">
<PlexLibraryItem
v-for="(item, index) in details.recentlyAdded"
:key="index"
class="recent-item"
>
<div class="item-poster-container">
<img
v-if="item.poster"
:src="item.poster"
:alt="item.title"
class="item-poster-image"
@error="handleImageError"
:item="item"
:show-extras="libraryType === 'music' || libraryType === 'shows'"
/>
<span v-if="!item.poster" class="item-poster-fallback">
{{ item.fallbackIcon }}
</span>
</div>
<div class="item-info">
<div class="item-title">{{ item.title }}</div>
<div class="item-meta">
<span v-if="libraryType === 'music'">
{{ item.artist }}
</span>
<span>{{ item.year }}</span>
<span v-if="item.episodes">
{{ item.episodes }} episodes
</span>
<span v-if="item.tracks"> {{ item.tracks }} tracks </span>
</div>
</div>
<div class="item-rating" v-if="item.rating">
<svg
width="14"
height="14"
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 }}
</div>
</div>
</div>
</div>
@@ -117,6 +79,7 @@
<script setup lang="ts">
import IconClose from "@/icons/IconClose.vue";
import PlexLibraryItem from "@/components/plex/PlexLibraryItem.vue";
import { getLibraryIcon, getLibraryTitle } from "@/utils/plexHelpers";
interface LibraryDetails {
@@ -138,11 +101,6 @@
const emit = defineEmits<{
(e: "close"): void;
}>();
function handleImageError(event: Event) {
const target = event.target as HTMLElement;
target.style.display = "none";
}
</script>
<style scoped>
@@ -167,6 +125,7 @@
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);
@@ -264,75 +223,10 @@
font-weight: 600;
}
.recent-items {
display: flex;
flex-direction: column;
gap: 12px;
}
.recent-item {
display: flex;
align-items: center;
gap: 16px;
padding: 12px;
background: #252525;
border-radius: 8px;
transition: background 0.2s;
}
.recent-item:hover {
background: #2a2a2a;
}
.item-poster-container {
width: 60px;
height: 90px;
flex-shrink: 0;
border-radius: 6px;
overflow: hidden;
background: #333;
display: flex;
align-items: center;
justify-content: center;
}
.item-poster-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.item-poster-fallback {
font-size: 32px;
}
.item-info {
flex: 1;
min-width: 0;
}
.item-title {
font-size: 14px;
font-weight: 600;
color: #fff;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-meta {
font-size: 12px;
color: #888;
}
.item-rating {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
color: #fbbf24;
flex-shrink: 0;
.recent-items-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 20px;
}
.genre-list {
@@ -378,6 +272,11 @@
grid-template-columns: repeat(2, 1fr);
}
.recent-items-grid {
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 16px;
}
.genre-item {
grid-template-columns: 100px 1fr 50px;
}