mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 21:16:41 +02:00
feat(mukke): display album cover art in library, playlists, and song lists
Add batch cover-url endpoint (POST /library/cover-urls) to efficiently resolve multiple cover art presigned URLs in a single request. Integrate cover art display across all UI surfaces: album grid, album detail header, song list thumbnails, playlist grid, and playlist detail song list. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
789ce0a435
commit
e848fa5342
81 changed files with 376 additions and 58 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Param, Body, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { LibraryService } from './library.service';
|
||||
|
||||
|
|
@ -7,6 +7,12 @@ import { LibraryService } from './library.service';
|
|||
export class LibraryController {
|
||||
constructor(private readonly libraryService: LibraryService) {}
|
||||
|
||||
@Post('cover-urls')
|
||||
async getCoverUrls(@Body() body: { paths: string[] }) {
|
||||
const urls = await this.libraryService.getCoverUrls(body.paths ?? []);
|
||||
return { urls };
|
||||
}
|
||||
|
||||
@Get('albums')
|
||||
async getAlbums(@CurrentUser() user: CurrentUserData) {
|
||||
const albums = await this.libraryService.getAlbums(user.userId);
|
||||
|
|
|
|||
|
|
@ -3,10 +3,15 @@ import { eq, and, asc, sql } from 'drizzle-orm';
|
|||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { Database } from '../db/connection';
|
||||
import { songs } from '../db/schema';
|
||||
import { createMukkeStorage, type StorageClient } from '@manacore/shared-storage';
|
||||
|
||||
@Injectable()
|
||||
export class LibraryService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
private storage: StorageClient;
|
||||
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {
|
||||
this.storage = createMukkeStorage();
|
||||
}
|
||||
|
||||
async getAlbums(userId: string) {
|
||||
const result = await this.db.execute<{
|
||||
|
|
@ -93,4 +98,26 @@ export class LibraryService {
|
|||
.where(and(eq(songs.userId, userId), eq(songs.genre, genreName)))
|
||||
.orderBy(asc(songs.title));
|
||||
}
|
||||
|
||||
async getCoverUrls(paths: string[]): Promise<Record<string, string>> {
|
||||
const uniquePaths = [...new Set(paths.filter(Boolean))];
|
||||
if (uniquePaths.length === 0) return {};
|
||||
|
||||
const entries = await Promise.all(
|
||||
uniquePaths.map(async (path) => {
|
||||
try {
|
||||
const url = await this.storage.getDownloadUrl(path, { expiresIn: 3600 });
|
||||
return [path, url] as const;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const entry of entries) {
|
||||
if (entry) result[entry[0]] = entry[1];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ interface LibraryState {
|
|||
artists: Artist[];
|
||||
genres: Genre[];
|
||||
stats: LibraryStats | null;
|
||||
coverUrls: Record<string, string>;
|
||||
activeTab: 'songs' | 'albums' | 'artists' | 'genres';
|
||||
sortField: SortField;
|
||||
sortDirection: SortDirection;
|
||||
|
|
@ -40,6 +41,7 @@ function createLibraryStore() {
|
|||
artists: [],
|
||||
genres: [],
|
||||
stats: null,
|
||||
coverUrls: {},
|
||||
activeTab: 'songs',
|
||||
sortField: 'addedAt' as SortField,
|
||||
sortDirection: 'desc' as SortDirection,
|
||||
|
|
@ -94,10 +96,27 @@ function createLibraryStore() {
|
|||
get isLoading() {
|
||||
return state.isLoading;
|
||||
},
|
||||
get coverUrls() {
|
||||
return state.coverUrls;
|
||||
},
|
||||
get error() {
|
||||
return state.error;
|
||||
},
|
||||
|
||||
async loadCoverUrls(paths: string[]) {
|
||||
const uncached = paths.filter((p) => p && !state.coverUrls[p]);
|
||||
if (uncached.length === 0) return;
|
||||
try {
|
||||
const data = await fetchApi<{ urls: Record<string, string> }>('/library/cover-urls', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ paths: uncached }),
|
||||
});
|
||||
state.coverUrls = { ...state.coverUrls, ...data.urls };
|
||||
} catch {
|
||||
// Cover URLs are non-critical, don't set error
|
||||
}
|
||||
},
|
||||
|
||||
async loadSongs() {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
|
|
@ -106,6 +125,8 @@ function createLibraryStore() {
|
|||
`/songs?sort=${state.sortField}&direction=${state.sortDirection}`
|
||||
);
|
||||
state.songs = data.songs;
|
||||
const coverPaths = data.songs.map((s) => s.coverArtPath).filter((p): p is string => !!p);
|
||||
if (coverPaths.length > 0) this.loadCoverUrls(coverPaths);
|
||||
} catch (e) {
|
||||
state.error = e instanceof Error ? e.message : 'Failed to load songs';
|
||||
}
|
||||
|
|
@ -118,6 +139,8 @@ function createLibraryStore() {
|
|||
try {
|
||||
const data = await fetchApi<{ albums: Album[] }>('/library/albums');
|
||||
state.albums = data.albums;
|
||||
const coverPaths = data.albums.map((a) => a.coverArtPath).filter((p): p is string => !!p);
|
||||
if (coverPaths.length > 0) this.loadCoverUrls(coverPaths);
|
||||
} catch (e) {
|
||||
state.error = e instanceof Error ? e.message : 'Failed to load albums';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { authStore } from './auth.svelte';
|
|||
interface PlaylistState {
|
||||
playlists: Playlist[];
|
||||
currentPlaylist: PlaylistWithSongs | null;
|
||||
coverUrls: Record<string, string>;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
|
@ -23,6 +24,7 @@ function createPlaylistStore() {
|
|||
let state = $state<PlaylistState>({
|
||||
playlists: [],
|
||||
currentPlaylist: null,
|
||||
coverUrls: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
|
@ -56,16 +58,37 @@ function createPlaylistStore() {
|
|||
get isLoading() {
|
||||
return state.isLoading;
|
||||
},
|
||||
get coverUrls() {
|
||||
return state.coverUrls;
|
||||
},
|
||||
get error() {
|
||||
return state.error;
|
||||
},
|
||||
|
||||
async loadCoverUrls(paths: string[]) {
|
||||
const uncached = paths.filter((p) => p && !state.coverUrls[p]);
|
||||
if (uncached.length === 0) return;
|
||||
try {
|
||||
const data = await fetchApi<{ urls: Record<string, string> }>('/library/cover-urls', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ paths: uncached }),
|
||||
});
|
||||
state.coverUrls = { ...state.coverUrls, ...data.urls };
|
||||
} catch {
|
||||
// Cover URLs are non-critical
|
||||
}
|
||||
},
|
||||
|
||||
async loadPlaylists() {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
try {
|
||||
const data = await fetchApi<{ playlists: Playlist[] }>('/playlists');
|
||||
state.playlists = data.playlists;
|
||||
const coverPaths = data.playlists
|
||||
.map((p) => p.coverArtPath)
|
||||
.filter((p): p is string => !!p);
|
||||
if (coverPaths.length > 0) this.loadCoverUrls(coverPaths);
|
||||
} catch (e) {
|
||||
state.error = e instanceof Error ? e.message : 'Failed to load playlists';
|
||||
}
|
||||
|
|
@ -78,6 +101,10 @@ function createPlaylistStore() {
|
|||
try {
|
||||
const data = await fetchApi<{ playlist: PlaylistWithSongs }>(`/playlists/${id}`);
|
||||
state.currentPlaylist = data.playlist;
|
||||
const coverPaths = data.playlist.songs
|
||||
.map((s) => s.coverArtPath)
|
||||
.filter((p): p is string => !!p);
|
||||
if (coverPaths.length > 0) this.loadCoverUrls(coverPaths);
|
||||
} catch (e) {
|
||||
state.error = e instanceof Error ? e.message : 'Failed to load playlist';
|
||||
}
|
||||
|
|
|
|||
1
apps/mukke/apps/web/src/lib/version.ts
Normal file
1
apps/mukke/apps/web/src/lib/version.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const APP_VERSION = '0.2.0';
|
||||
|
|
@ -136,8 +136,9 @@
|
|||
<div class="bg-surface rounded-lg overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="grid grid-cols-[1fr_1fr_1fr_80px_40px_40px_40px] gap-4 px-4 py-3 text-xs font-medium text-foreground-secondary uppercase tracking-wide border-b border-border"
|
||||
class="grid grid-cols-[40px_1fr_1fr_1fr_80px_40px_40px_40px] gap-4 px-4 py-3 text-xs font-medium text-foreground-secondary uppercase tracking-wide border-b border-border"
|
||||
>
|
||||
<span></span>
|
||||
<span>Title</span>
|
||||
<span>Artist</span>
|
||||
<span>Album</span>
|
||||
|
|
@ -149,8 +150,33 @@
|
|||
<!-- Song rows -->
|
||||
{#each libraryStore.songs as song}
|
||||
<div
|
||||
class="grid grid-cols-[1fr_1fr_1fr_80px_40px_40px_40px] gap-4 px-4 py-3 hover:bg-background transition-colors items-center"
|
||||
class="grid grid-cols-[40px_1fr_1fr_1fr_80px_40px_40px_40px] gap-4 px-4 py-3 hover:bg-background transition-colors items-center"
|
||||
>
|
||||
<div
|
||||
class="w-10 h-10 rounded bg-background flex items-center justify-center overflow-hidden flex-shrink-0"
|
||||
>
|
||||
{#if song.coverArtPath && libraryStore.coverUrls[song.coverArtPath]}
|
||||
<img
|
||||
src={libraryStore.coverUrls[song.coverArtPath]}
|
||||
alt=""
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<svg
|
||||
class="w-5 h-5 text-foreground-secondary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="truncate font-medium">{song.title}</span>
|
||||
<span class="truncate text-foreground-secondary">{song.artist ?? 'Unknown'}</span>
|
||||
<span class="truncate text-foreground-secondary">{song.album ?? 'Unknown'}</span>
|
||||
|
|
@ -226,21 +252,29 @@
|
|||
class="bg-surface rounded-lg p-4 hover:bg-surface-hover transition-colors group"
|
||||
>
|
||||
<div
|
||||
class="aspect-square bg-background rounded-lg mb-3 flex items-center justify-center"
|
||||
class="aspect-square bg-background rounded-lg mb-3 flex items-center justify-center overflow-hidden"
|
||||
>
|
||||
<svg
|
||||
class="w-12 h-12 text-foreground-secondary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
||||
{#if album.coverArtPath && libraryStore.coverUrls[album.coverArtPath]}
|
||||
<img
|
||||
src={libraryStore.coverUrls[album.coverArtPath]}
|
||||
alt={album.album}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
class="w-12 h-12 text-foreground-secondary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<h3 class="font-medium truncate group-hover:text-primary transition-colors">
|
||||
{album.album}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
let songs = $state<Song[]>([]);
|
||||
let isLoading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let coverUrl = $state<string | null>(null);
|
||||
|
||||
let albumName = $derived(decodeURIComponent($page.params.name ?? ''));
|
||||
let albumArtist = $derived(
|
||||
|
|
@ -42,10 +43,17 @@
|
|||
|
||||
isLoading = true;
|
||||
error = null;
|
||||
coverUrl = null;
|
||||
|
||||
fetchApi<{ songs: Song[] }>(`/library/albums/${encodeURIComponent(decodeURIComponent(name))}`)
|
||||
.then((data) => {
|
||||
songs = data.songs;
|
||||
const songWithCover = data.songs.find((s) => s.coverArtPath);
|
||||
if (songWithCover) {
|
||||
fetchApi<{ url: string | null }>(`/songs/${songWithCover.id}/cover-url`).then((res) => {
|
||||
coverUrl = res.url;
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
error = e instanceof Error ? e.message : 'Failed to load album';
|
||||
|
|
@ -100,20 +108,26 @@
|
|||
{:else}
|
||||
<!-- Album header -->
|
||||
<div class="flex items-end gap-6 mb-8">
|
||||
<div class="w-48 h-48 bg-surface rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<svg
|
||||
class="w-16 h-16 text-foreground-secondary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
class="w-48 h-48 bg-surface rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden"
|
||||
>
|
||||
{#if coverUrl}
|
||||
<img src={coverUrl} alt={albumName} class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
<svg
|
||||
class="w-16 h-16 text-foreground-secondary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold mb-1">{albumName}</h1>
|
||||
|
|
|
|||
|
|
@ -99,20 +99,30 @@
|
|||
href="/playlists/{playlist.id}"
|
||||
class="bg-surface rounded-lg p-4 hover:bg-surface-hover transition-colors group relative"
|
||||
>
|
||||
<div class="aspect-square bg-background rounded-lg mb-3 flex items-center justify-center">
|
||||
<svg
|
||||
class="w-12 h-12 text-foreground-secondary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
||||
<div
|
||||
class="aspect-square bg-background rounded-lg mb-3 flex items-center justify-center overflow-hidden"
|
||||
>
|
||||
{#if playlist.coverArtPath && playlistStore.coverUrls[playlist.coverArtPath]}
|
||||
<img
|
||||
src={playlistStore.coverUrls[playlist.coverArtPath]}
|
||||
alt={playlist.name}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
class="w-12 h-12 text-foreground-secondary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<h3 class="font-medium truncate group-hover:text-primary transition-colors">
|
||||
{playlist.name}
|
||||
|
|
|
|||
|
|
@ -205,8 +205,9 @@
|
|||
<div class="bg-surface rounded-lg overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="grid grid-cols-[1fr_1fr_1fr_80px_40px] gap-4 px-4 py-3 text-xs font-medium text-foreground-secondary uppercase tracking-wide border-b border-border"
|
||||
class="grid grid-cols-[40px_1fr_1fr_1fr_80px_40px] gap-4 px-4 py-3 text-xs font-medium text-foreground-secondary uppercase tracking-wide border-b border-border"
|
||||
>
|
||||
<span></span>
|
||||
<span>Title</span>
|
||||
<span>Artist</span>
|
||||
<span>Album</span>
|
||||
|
|
@ -216,7 +217,7 @@
|
|||
<!-- Song rows -->
|
||||
{#each playlistStore.currentPlaylist.songs as song, index}
|
||||
<div
|
||||
class="grid grid-cols-[1fr_1fr_1fr_80px_40px] gap-4 px-4 py-3 hover:bg-background transition-colors items-center cursor-pointer"
|
||||
class="grid grid-cols-[40px_1fr_1fr_1fr_80px_40px] gap-4 px-4 py-3 hover:bg-background transition-colors items-center cursor-pointer"
|
||||
onclick={() => handlePlaySong(song, index)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
|
|
@ -224,6 +225,31 @@
|
|||
if (e.key === 'Enter') handlePlaySong(song, index);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="w-10 h-10 rounded bg-background flex items-center justify-center overflow-hidden flex-shrink-0"
|
||||
>
|
||||
{#if song.coverArtPath && playlistStore.coverUrls[song.coverArtPath]}
|
||||
<img
|
||||
src={playlistStore.coverUrls[song.coverArtPath]}
|
||||
alt=""
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<svg
|
||||
class="w-5 h-5 text-foreground-secondary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="truncate font-medium">{song.title}</span>
|
||||
<span class="truncate text-foreground-secondary">{song.artist ?? 'Unknown'}</span>
|
||||
<span class="truncate text-foreground-secondary">{song.album ?? 'Unknown'}</span>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { APP_VERSION } from '$lib/version';
|
||||
import {
|
||||
SettingsPage,
|
||||
SettingsSection,
|
||||
|
|
@ -60,4 +61,6 @@
|
|||
onclick={handleLogout}
|
||||
/>
|
||||
</SettingsDangerZone>
|
||||
|
||||
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
|
||||
</SettingsPage>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import { getLoginTranslations } from '@manacore/shared-i18n';
|
||||
import { MukkeLogo } from '@manacore/shared-branding';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { APP_VERSION } from '$lib/version';
|
||||
|
||||
// Get redirect URL from query params or sessionStorage
|
||||
const redirectTo = $derived.by(() => {
|
||||
|
|
@ -60,4 +61,5 @@
|
|||
{translations}
|
||||
{verified}
|
||||
{initialEmail}
|
||||
version={APP_VERSION}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue