First working POC
This commit is contained in:
@@ -0,0 +1,555 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { library, libraryItems, isLibraryLoading, currentLibrary, libraries } from "$lib/stores/library";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { isServerReachable } from "$lib/stores/connectivity";
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
|
||||
import TrackList from "$lib/components/library/TrackList.svelte";
|
||||
import AlbumDownloadButton from "$lib/components/library/AlbumDownloadButton.svelte";
|
||||
import SeasonSection from "$lib/components/library/SeasonSection.svelte";
|
||||
import EpisodeFocusView from "$lib/components/library/EpisodeFocusView.svelte";
|
||||
import SeriesDownloadButton from "$lib/components/library/SeriesDownloadButton.svelte";
|
||||
import VideoDownloadButton from "$lib/components/library/VideoDownloadButton.svelte";
|
||||
import CastSection from "$lib/components/library/CastSection.svelte";
|
||||
import PersonDetailView from "$lib/components/library/PersonDetailView.svelte";
|
||||
import RelatedItemsSection from "$lib/components/library/RelatedItemsSection.svelte";
|
||||
import ArtistDetailView from "$lib/components/library/ArtistDetailView.svelte";
|
||||
import CrewLinks from "$lib/components/library/CrewLinks.svelte";
|
||||
import GenreTags from "$lib/components/library/GenreTags.svelte";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
|
||||
interface SeasonData {
|
||||
season: MediaItem;
|
||||
episodes: MediaItem[];
|
||||
}
|
||||
|
||||
let item = $state<MediaItem | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let seasonData = $state<SeasonData[]>([]);
|
||||
let directFetchedEpisode = $state<MediaItem | null>(null);
|
||||
|
||||
// Track if we've done an initial load and previous server state
|
||||
let hasLoadedOnce = false;
|
||||
let previousServerReachable = false;
|
||||
|
||||
const itemId = $derived($page.params.id);
|
||||
const focusedEpisodeId = $derived($page.url.searchParams.get("episode"));
|
||||
|
||||
onMount(async () => {
|
||||
await loadItem();
|
||||
hasLoadedOnce = true;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (itemId) {
|
||||
loadItem();
|
||||
hasLoadedOnce = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Reload when server becomes reachable (handles cache-first timing issue)
|
||||
$effect(() => {
|
||||
const serverReachable = $isServerReachable;
|
||||
|
||||
// If server just became reachable and we've already loaded, reload to get fresh data
|
||||
if (serverReachable && !previousServerReachable && hasLoadedOnce && itemId) {
|
||||
loadItem();
|
||||
}
|
||||
|
||||
previousServerReachable = serverReachable;
|
||||
});
|
||||
|
||||
async function loadItem() {
|
||||
loading = true;
|
||||
error = null;
|
||||
seasonData = [];
|
||||
directFetchedEpisode = null;
|
||||
|
||||
try {
|
||||
item = await library.loadItem(itemId);
|
||||
console.log(`[LibraryDetail] ✓ Loaded item: ${item?.name} (${item?.type})`);
|
||||
console.log(`[LibraryDetail] - Has people? ${item?.people ? `YES (${item.people.length})` : 'NO'}`);
|
||||
if (item?.people) {
|
||||
item.people.forEach((p, i) => {
|
||||
console.log(`[LibraryDetail] [${i}] ${p.name} (${p.type})`);
|
||||
});
|
||||
}
|
||||
|
||||
// Set currentLibrary for music items if not already set
|
||||
// This ensures navigation to music library pages works correctly
|
||||
if ((item?.type === "MusicAlbum" || item?.type === "MusicArtist" || item?.type === "Audio") && !$currentLibrary) {
|
||||
// Find the music library
|
||||
if ($libraries.length === 0) {
|
||||
await library.loadLibraries();
|
||||
}
|
||||
const musicLibrary = $libraries.find(lib => lib.collectionType === "music");
|
||||
if (musicLibrary) {
|
||||
library.setCurrentLibrary(musicLibrary);
|
||||
console.log("[LibraryDetail] Set current library to music library for music item");
|
||||
}
|
||||
}
|
||||
|
||||
await library.loadItems(itemId, { limit: 100 });
|
||||
|
||||
// Ensure cast/crew data is loaded for Movies, Series, and Episodes
|
||||
// Some APIs/caches may not include people data on first load
|
||||
if ((item?.type === "Movie" || item?.type === "Series" || item?.type === "Episode") && (!item.people || item.people.length === 0)) {
|
||||
console.log(`[LibraryDetail] ⚠ People data missing, reloading ${item?.type}...`);
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const fullItem = await repo.getItem(itemId);
|
||||
console.log(`[LibraryDetail] - Reloaded has people? ${fullItem.people ? `YES (${fullItem.people.length})` : 'NO'}`);
|
||||
if (fullItem.people && fullItem.people.length > 0) {
|
||||
item = fullItem;
|
||||
console.log(`[LibraryDetail] ✓ Updated item with ${fullItem.people.length} people`);
|
||||
fullItem.people.forEach((p, i) => {
|
||||
console.log(`[LibraryDetail] [${i}] ${p.name} (${p.type})`);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Could not reload ${item?.type} with full cast data:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// For Series, load seasons and their episodes
|
||||
if (item?.type === "Series") {
|
||||
const seasons = $libraryItems.filter((i) => i.type === "Season");
|
||||
const repo = auth.getRepository();
|
||||
|
||||
// Load episodes for each season in parallel
|
||||
const seasonDataPromises = seasons.map(async (season) => {
|
||||
const result = await repo.getItems(season.id, { limit: 100 });
|
||||
const episodes = result.items
|
||||
.filter((i) => i.type === "Episode")
|
||||
.sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0));
|
||||
return { season, episodes };
|
||||
});
|
||||
|
||||
seasonData = await Promise.all(seasonDataPromises);
|
||||
// Sort seasons by index number
|
||||
seasonData.sort((a, b) => (a.season.indexNumber || 0) - (b.season.indexNumber || 0));
|
||||
|
||||
// If we have a focused episode ID but couldn't find it in the seasons,
|
||||
// fetch it directly (handles ID mismatch between APIs)
|
||||
const episodeIdParam = $page.url.searchParams.get("episode");
|
||||
if (episodeIdParam) {
|
||||
const allEps = seasonData.flatMap((s) => s.episodes);
|
||||
const foundInSeasons = allEps.some((e) => e.id === episodeIdParam);
|
||||
if (!foundInSeasons) {
|
||||
try {
|
||||
directFetchedEpisode = await repo.getItem(episodeIdParam);
|
||||
} catch {
|
||||
console.warn("Could not fetch focused episode directly:", episodeIdParam);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Failed to load item";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Images now handled by CachedImage component
|
||||
|
||||
function formatDuration(ticks?: number): string {
|
||||
if (!ticks) return "";
|
||||
const seconds = Math.floor(ticks / 10000000);
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
function handleItemClick(clickedItem: MediaItem) {
|
||||
switch (clickedItem.type) {
|
||||
case "Series":
|
||||
case "Season":
|
||||
case "MusicAlbum":
|
||||
case "MusicArtist":
|
||||
case "Folder":
|
||||
case "Playlist":
|
||||
case "Channel":
|
||||
case "ChannelFolderItem":
|
||||
case "Episode":
|
||||
case "Movie":
|
||||
goto(`/library/${clickedItem.id}`);
|
||||
break;
|
||||
default:
|
||||
goto(`/player/${clickedItem.id}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Removed custom handleTrackClick - let TrackList use its built-in playback logic
|
||||
// This fixes Android playback issues where navigation-based approach was hanging
|
||||
|
||||
function handleEpisodeClick(episode: MediaItem) {
|
||||
// Play the episode with the series queued for next episode
|
||||
goto(`/player/${episode.id}`);
|
||||
}
|
||||
|
||||
async function handlePlayAll() {
|
||||
// For single items (Episode, Movie), play the item directly
|
||||
if (item?.type === "Episode" || item?.type === "Movie") {
|
||||
goto(`/player/${itemId}`);
|
||||
} else if (item?.type === "MusicAlbum" && $libraryItems.length > 0) {
|
||||
// For albums, use the backend command (backend fetches and queues all tracks)
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const repositoryHandle = repo.getHandle();
|
||||
const firstTrack = $libraryItems[0];
|
||||
await invoke("player_play_album_track", {
|
||||
repositoryHandle,
|
||||
request: {
|
||||
albumId: item.id,
|
||||
albumName: item.name,
|
||||
trackId: firstTrack.id,
|
||||
shuffle: false,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to play album:", e);
|
||||
alert(`Failed to play album: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
||||
}
|
||||
} else if ($libraryItems.length > 0) {
|
||||
// For other collections, start playing first item
|
||||
goto(`/player/${$libraryItems[0].id}?queue=parent:${itemId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleShufflePlay() {
|
||||
if (item?.type === "MusicAlbum" && $libraryItems.length > 0) {
|
||||
// For albums, use the backend command with shuffle
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const repositoryHandle = repo.getHandle();
|
||||
// Pick a random track to start with
|
||||
const randomTrack = $libraryItems[Math.floor(Math.random() * $libraryItems.length)];
|
||||
await invoke("player_play_album_track", {
|
||||
repositoryHandle,
|
||||
request: {
|
||||
albumId: item.id,
|
||||
albumName: item.name,
|
||||
trackId: randomTrack.id,
|
||||
shuffle: true,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to shuffle play album:", e);
|
||||
alert(`Failed to shuffle play: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
||||
}
|
||||
} else if ($libraryItems.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * $libraryItems.length);
|
||||
goto(`/player/${$libraryItems[randomIndex].id}?queue=parent:${itemId}&shuffle=true`);
|
||||
}
|
||||
}
|
||||
|
||||
// For episode focus view: get all episodes across all seasons
|
||||
const allEpisodes = $derived(
|
||||
seasonData.flatMap((s) => s.episodes)
|
||||
);
|
||||
|
||||
// Find the focused episode (check allEpisodes first, then fall back to directly fetched)
|
||||
const focusedEpisode = $derived(
|
||||
focusedEpisodeId
|
||||
? allEpisodes.find((e) => e.id === focusedEpisodeId) ?? directFetchedEpisode
|
||||
: null
|
||||
);
|
||||
|
||||
function handleBackToSeries() {
|
||||
// Navigate to series page without the episode param
|
||||
goto(`/library/${itemId}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<!-- Backdrop -->
|
||||
{#if item?.backdropImageTags?.[0]}
|
||||
<div class="absolute inset-0 -z-10 h-96 overflow-hidden">
|
||||
<CachedImage
|
||||
itemId={item.id}
|
||||
imageType="Backdrop"
|
||||
tag={item.backdropImageTags[0]}
|
||||
maxWidth={1920}
|
||||
class="w-full h-full object-cover opacity-30"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-[var(--color-background)]"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-12">
|
||||
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="text-center py-12">
|
||||
<p class="text-red-400">{error}</p>
|
||||
<button
|
||||
onclick={() => goto("/library")}
|
||||
class="mt-4 text-[var(--color-jellyfin)] hover:underline"
|
||||
>
|
||||
Back to library
|
||||
</button>
|
||||
</div>
|
||||
{:else if item}
|
||||
<!-- Person Detail View - shown for Person items -->
|
||||
{#if item.type === "Person"}
|
||||
<PersonDetailView person={item} />
|
||||
<!-- Episode Focus View - shown when navigating with ?episode param -->
|
||||
{:else if item.type === "Series" && focusedEpisode}
|
||||
<EpisodeFocusView
|
||||
episode={focusedEpisode}
|
||||
series={item}
|
||||
{allEpisodes}
|
||||
onBack={handleBackToSeries}
|
||||
/>
|
||||
{:else}
|
||||
<div class="space-y-8">
|
||||
<!-- Header with item info -->
|
||||
<div class="flex gap-6 pt-4">
|
||||
<!-- Poster -->
|
||||
<div class="flex-shrink-0 w-48">
|
||||
{#if item.primaryImageTag}
|
||||
<CachedImage
|
||||
itemId={item.id}
|
||||
imageType="Primary"
|
||||
tag={item.primaryImageTag}
|
||||
maxWidth={400}
|
||||
alt={item.name}
|
||||
class="w-full rounded-lg shadow-lg"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full aspect-[2/3] bg-[var(--color-surface)] rounded-lg flex items-center justify-center">
|
||||
<svg class="w-16 h-16 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 space-y-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white">{item.name}</h1>
|
||||
{#if item.type === "Episode" && (item.parentIndexNumber || item.indexNumber)}
|
||||
<p class="text-lg text-gray-400 mt-1">
|
||||
{#if item.parentIndexNumber}Season {item.parentIndexNumber}{/if}
|
||||
{#if item.parentIndexNumber && item.indexNumber}, {/if}
|
||||
{#if item.indexNumber}Episode {item.indexNumber}{/if}
|
||||
</p>
|
||||
{:else if item.productionYear || item.artists?.length}
|
||||
<p class="text-lg text-gray-400 mt-1">
|
||||
{item.artists?.join(", ") || item.productionYear}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="flex items-center gap-4 text-sm text-gray-400">
|
||||
{#if item.type}
|
||||
<span class="px-2 py-1 bg-[var(--color-surface)] rounded">{item.type}</span>
|
||||
{/if}
|
||||
{#if item.runTimeTicks}
|
||||
<span>{formatDuration(item.runTimeTicks)}</span>
|
||||
{/if}
|
||||
{#if item.communityRating}
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||
</svg>
|
||||
{item.communityRating.toFixed(1)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3 flex-wrap">
|
||||
<button
|
||||
onclick={handlePlayAll}
|
||||
class="px-6 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] rounded-lg font-medium flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
Play
|
||||
</button>
|
||||
{#if item.type !== "Episode" && item.type !== "Movie"}
|
||||
<button
|
||||
onclick={handleShufflePlay}
|
||||
class="px-6 py-2 bg-[var(--color-surface)] hover:bg-[var(--color-surface-hover)] rounded-lg font-medium flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/>
|
||||
</svg>
|
||||
Shuffle
|
||||
</button>
|
||||
{/if}
|
||||
{#if item.type === "MusicAlbum"}
|
||||
<AlbumDownloadButton
|
||||
albumId={item.id}
|
||||
albumName={item.name}
|
||||
tracks={$libraryItems}
|
||||
/>
|
||||
{:else if item.type === "Series"}
|
||||
<SeriesDownloadButton
|
||||
seriesId={item.id}
|
||||
seriesName={item.name}
|
||||
episodeCount={allEpisodes.length || undefined}
|
||||
/>
|
||||
{:else if item.type === "Movie"}
|
||||
<VideoDownloadButton
|
||||
itemId={item.id}
|
||||
itemName={item.name}
|
||||
isMovie={true}
|
||||
size="lg"
|
||||
/>
|
||||
{:else if item.type === "Episode"}
|
||||
<VideoDownloadButton
|
||||
itemId={item.id}
|
||||
itemName={item.name}
|
||||
isMovie={false}
|
||||
size="lg"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Overview -->
|
||||
{#if item.overview}
|
||||
<p class="text-gray-300 leading-relaxed max-w-2xl">{item.overview}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Crew Links - for Movies and Series -->
|
||||
{#if item.people && (item.type === "Movie" || item.type === "Series")}
|
||||
<div class="space-y-2">
|
||||
{#if item.people.some(p => p.type === "Director")}
|
||||
<CrewLinks
|
||||
people={item.people}
|
||||
roleFilter={["Director"]}
|
||||
label="Directed by"
|
||||
maxShow={3}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if item.people.some(p => p.type === "Writer")}
|
||||
<CrewLinks
|
||||
people={item.people}
|
||||
roleFilter={["Writer"]}
|
||||
label="Written by"
|
||||
maxShow={3}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if item.people.some(p => p.type === "Composer")}
|
||||
<CrewLinks
|
||||
people={item.people}
|
||||
roleFilter={["Composer"]}
|
||||
label="Music by"
|
||||
maxShow={2}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Genre Tags -->
|
||||
{#if item.genres?.length}
|
||||
<div>
|
||||
<GenreTags genres={item.genres} maxShow={6} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Cast Section - for Movies, Series, and Episodes -->
|
||||
{#if (item.type === "Movie" || item.type === "Series" || item.type === "Episode") && item.people?.length}
|
||||
<CastSection people={item.people} />
|
||||
{/if}
|
||||
|
||||
<!-- Related Items Section - for Movies and Series -->
|
||||
{#if (item.type === "Movie" || item.type === "Series") && (item.genres?.length || item.people?.length)}
|
||||
<RelatedItemsSection
|
||||
currentItemId={item.id}
|
||||
itemType={item.type}
|
||||
genres={item.genres}
|
||||
people={item.people}
|
||||
limit={12}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Content items -->
|
||||
<div>
|
||||
{#if item.type === "MusicAlbum"}
|
||||
<!-- Tracks in list view -->
|
||||
<div class="space-y-8">
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-xl font-semibold text-white">Tracks</h2>
|
||||
<TrackList
|
||||
tracks={$libraryItems}
|
||||
loading={$isLibraryLoading}
|
||||
showArtist={false}
|
||||
showAlbum={false}
|
||||
showDownload={true}
|
||||
context={{ type: "album", albumId: item.id, albumName: item.name }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Related Albums -->
|
||||
{#if item.genres?.length || item.artistItems?.length}
|
||||
<RelatedItemsSection
|
||||
currentItemId={item.id}
|
||||
itemType="MusicAlbum"
|
||||
genres={item.genres}
|
||||
artistIds={item.artistItems?.map(a => a.id)}
|
||||
limit={12}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if item.type === "Series"}
|
||||
<!-- Series: Seasons with episodes -->
|
||||
<div class="space-y-8">
|
||||
{#if $isLibraryLoading}
|
||||
<div class="flex justify-center py-8">
|
||||
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
{:else if seasonData.length === 0}
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<p>No seasons found</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each seasonData as { season, episodes } (season.id)}
|
||||
<SeasonSection
|
||||
{season}
|
||||
{episodes}
|
||||
focusedEpisodeId={focusedEpisodeId ?? undefined}
|
||||
onEpisodeClick={handleEpisodeClick}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{:else if item.type === "MusicArtist"}
|
||||
<!-- Enhanced artist detail view with discography -->
|
||||
<ArtistDetailView artist={item} />
|
||||
{:else}
|
||||
<!-- Other content in grid view -->
|
||||
<LibraryGrid
|
||||
title="Contents"
|
||||
items={$libraryItems}
|
||||
loading={$isLibraryLoading}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user