mirror of
https://github.com/immich-app/immich.git
synced 2025-01-16 00:36:47 +01:00
feat(web): dedicated view for user's usage stats (#14348)
* feat(web): dedicated view for user's usage stats * cell heights * Translation * pr feedback * clean up * clean up * pr feedback
This commit is contained in:
parent
d277096d58
commit
361d83c729
7 changed files with 148 additions and 116 deletions
|
@ -1,4 +1,6 @@
|
||||||
{
|
{
|
||||||
|
"user_usage_stats": "Account usage statistics",
|
||||||
|
"user_usage_stats_description": "View account usage statistics",
|
||||||
"about": "Refresh",
|
"about": "Refresh",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"account_settings": "Account Settings",
|
"account_settings": "Account Settings",
|
||||||
|
@ -1315,5 +1317,7 @@
|
||||||
"years_ago": "{years, plural, one {# year} other {# years}} ago",
|
"years_ago": "{years, plural, one {# year} other {# years}} ago",
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"you_dont_have_any_shared_links": "You don't have any shared links",
|
"you_dont_have_any_shared_links": "You don't have any shared links",
|
||||||
"zoom_image": "Zoom Image"
|
"zoom_image": "Zoom Image",
|
||||||
|
"timeline": "Timeline",
|
||||||
|
"total": "Total"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,27 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { type AlbumStatisticsResponseDto, getAlbumStatistics } from '@immich/sdk';
|
|
||||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
albumType: keyof AlbumStatisticsResponseDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { albumType }: Props = $props();
|
|
||||||
|
|
||||||
const handleAlbumCount = async () => {
|
|
||||||
try {
|
|
||||||
return await getAlbumStatistics();
|
|
||||||
} catch {
|
|
||||||
return { owned: 0, shared: 0, notShared: 0 };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#await handleAlbumCount()}
|
|
||||||
<LoadingSpinner />
|
|
||||||
{:then data}
|
|
||||||
<div>
|
|
||||||
<p>{$t('albums_count', { values: { count: data[albumType] } })}</p>
|
|
||||||
</div>
|
|
||||||
{/await}
|
|
|
@ -1,20 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { getAssetStatistics } from '@immich/sdk';
|
|
||||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
assetStats: NonNullable<Parameters<typeof getAssetStatistics>[0]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { assetStats }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#await getAssetStatistics(assetStats)}
|
|
||||||
<LoadingSpinner />
|
|
||||||
{:then data}
|
|
||||||
<div>
|
|
||||||
<p>{$t('videos_count', { values: { count: data.videos } })}</p>
|
|
||||||
<p>{$t('photos_count', { values: { count: data.images } })}</p>
|
|
||||||
</div>
|
|
||||||
{/await}
|
|
|
@ -1,10 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { mdiInformationOutline } from '@mdi/js';
|
|
||||||
import { resolveRoute } from '$app/paths';
|
import { resolveRoute } from '$app/paths';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
|
@ -13,7 +10,6 @@
|
||||||
flippedLogo?: boolean;
|
flippedLogo?: boolean;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
preloadData?: boolean;
|
preloadData?: boolean;
|
||||||
moreInformation?: Snippet;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
@ -23,10 +19,8 @@
|
||||||
flippedLogo = false,
|
flippedLogo = false,
|
||||||
isSelected = $bindable(false),
|
isSelected = $bindable(false),
|
||||||
preloadData = true,
|
preloadData = true,
|
||||||
moreInformation,
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let showMoreInformation = $state(false);
|
|
||||||
let routePath = $derived(resolveRoute(routeId, {}));
|
let routePath = $derived(resolveRoute(routeId, {}));
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
@ -39,7 +33,7 @@
|
||||||
data-sveltekit-preload-data={preloadData ? 'hover' : 'off'}
|
data-sveltekit-preload-data={preloadData ? 'hover' : 'off'}
|
||||||
draggable="false"
|
draggable="false"
|
||||||
aria-current={isSelected ? 'page' : undefined}
|
aria-current={isSelected ? 'page' : undefined}
|
||||||
class="flex w-full place-items-center justify-between gap-4 rounded-r-full py-3 transition-[padding] delay-100 duration-100 hover:cursor-pointer hover:bg-immich-gray hover:text-immich-primary dark:text-immich-dark-fg dark:hover:bg-immich-dark-gray dark:hover:text-immich-dark-primary
|
class="flex w-full place-items-center gap-4 rounded-r-full py-3 transition-[padding] delay-100 duration-100 hover:cursor-pointer hover:bg-immich-gray hover:text-immich-primary dark:text-immich-dark-fg dark:hover:bg-immich-dark-gray dark:hover:text-immich-dark-primary
|
||||||
{isSelected
|
{isSelected
|
||||||
? 'bg-immich-primary/10 text-immich-primary hover:bg-immich-primary/10 dark:bg-immich-dark-primary/10 dark:text-immich-dark-primary'
|
? 'bg-immich-primary/10 text-immich-primary hover:bg-immich-primary/10 dark:bg-immich-dark-primary/10 dark:text-immich-dark-primary'
|
||||||
: ''}
|
: ''}
|
||||||
|
@ -50,33 +44,5 @@
|
||||||
<Icon path={icon} size="1.5em" class="shrink-0" flipped={flippedLogo} ariaHidden />
|
<Icon path={icon} size="1.5em" class="shrink-0" flipped={flippedLogo} ariaHidden />
|
||||||
<span class="text-sm font-medium">{title}</span>
|
<span class="text-sm font-medium">{title}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div></div>
|
||||||
<div
|
|
||||||
class="h-0 overflow-hidden transition-[height] delay-1000 duration-100 sm:group-hover:h-auto group-hover:sm:overflow-visible md:h-auto md:overflow-visible"
|
|
||||||
>
|
|
||||||
{#if moreInformation}
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div
|
|
||||||
class="relative flex cursor-default select-none justify-center"
|
|
||||||
onmouseenter={() => (showMoreInformation = true)}
|
|
||||||
onmouseleave={() => (showMoreInformation = false)}
|
|
||||||
>
|
|
||||||
<div class="p-1 text-gray-600 hover:cursor-help dark:text-gray-400">
|
|
||||||
<Icon path={mdiInformationOutline} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if showMoreInformation}
|
|
||||||
<div class="absolute right-6 top-0">
|
|
||||||
<div
|
|
||||||
class="flex place-content-center place-items-center whitespace-nowrap rounded-3xl border bg-immich-bg px-6 py-3 text-xs text-immich-fg shadow-lg dark:border-immich-dark-gray dark:bg-gray-600 dark:text-immich-dark-fg"
|
|
||||||
class:hidden={!showMoreInformation}
|
|
||||||
transition:fade={{ duration: 200 }}
|
|
||||||
>
|
|
||||||
{@render moreInformation?.()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -24,8 +24,6 @@
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import SideBarSection from './side-bar-section.svelte';
|
import SideBarSection from './side-bar-section.svelte';
|
||||||
import SideBarLink from './side-bar-link.svelte';
|
import SideBarLink from './side-bar-link.svelte';
|
||||||
import MoreInformationAssets from '$lib/components/shared-components/side-bar/more-information-assets.svelte';
|
|
||||||
import MoreInformationAlbums from '$lib/components/shared-components/side-bar/more-information-albums.svelte';
|
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
|
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
|
||||||
import { preferences } from '$lib/stores/user.store';
|
import { preferences } from '$lib/stores/user.store';
|
||||||
|
@ -47,11 +45,7 @@
|
||||||
routeId="/(user)/photos"
|
routeId="/(user)/photos"
|
||||||
bind:isSelected={isPhotosSelected}
|
bind:isSelected={isPhotosSelected}
|
||||||
icon={isPhotosSelected ? mdiImageMultiple : mdiImageMultipleOutline}
|
icon={isPhotosSelected ? mdiImageMultiple : mdiImageMultipleOutline}
|
||||||
>
|
></SideBarLink>
|
||||||
{#snippet moreInformation()}
|
|
||||||
<MoreInformationAssets assetStats={{ isArchived: false }} />
|
|
||||||
{/snippet}
|
|
||||||
</SideBarLink>
|
|
||||||
|
|
||||||
{#if $featureFlags.search}
|
{#if $featureFlags.search}
|
||||||
<SideBarLink title={$t('explore')} routeId="/(user)/explore" icon={mdiMagnify} />
|
<SideBarLink title={$t('explore')} routeId="/(user)/explore" icon={mdiMagnify} />
|
||||||
|
@ -80,11 +74,7 @@
|
||||||
routeId="/(user)/sharing"
|
routeId="/(user)/sharing"
|
||||||
icon={isSharingSelected ? mdiAccountMultiple : mdiAccountMultipleOutline}
|
icon={isSharingSelected ? mdiAccountMultiple : mdiAccountMultipleOutline}
|
||||||
bind:isSelected={isSharingSelected}
|
bind:isSelected={isSharingSelected}
|
||||||
>
|
></SideBarLink>
|
||||||
{#snippet moreInformation()}
|
|
||||||
<MoreInformationAlbums albumType="shared" />
|
|
||||||
{/snippet}
|
|
||||||
</SideBarLink>
|
|
||||||
|
|
||||||
<div class="text-xs transition-all duration-200 dark:text-immich-dark-fg">
|
<div class="text-xs transition-all duration-200 dark:text-immich-dark-fg">
|
||||||
<p class="hidden p-6 group-hover:sm:block md:block">{$t('library').toUpperCase()}</p>
|
<p class="hidden p-6 group-hover:sm:block md:block">{$t('library').toUpperCase()}</p>
|
||||||
|
@ -96,17 +86,9 @@
|
||||||
routeId="/(user)/favorites"
|
routeId="/(user)/favorites"
|
||||||
icon={isFavoritesSelected ? mdiHeart : mdiHeartOutline}
|
icon={isFavoritesSelected ? mdiHeart : mdiHeartOutline}
|
||||||
bind:isSelected={isFavoritesSelected}
|
bind:isSelected={isFavoritesSelected}
|
||||||
>
|
></SideBarLink>
|
||||||
{#snippet moreInformation()}
|
|
||||||
<MoreInformationAssets assetStats={{ isFavorite: true }} />
|
|
||||||
{/snippet}
|
|
||||||
</SideBarLink>
|
|
||||||
|
|
||||||
<SideBarLink title={$t('albums')} routeId="/(user)/albums" icon={mdiImageAlbum} flippedLogo>
|
<SideBarLink title={$t('albums')} routeId="/(user)/albums" icon={mdiImageAlbum} flippedLogo></SideBarLink>
|
||||||
{#snippet moreInformation()}
|
|
||||||
<MoreInformationAlbums albumType="owned" />
|
|
||||||
{/snippet}
|
|
||||||
</SideBarLink>
|
|
||||||
|
|
||||||
{#if $preferences.tags.enabled && $preferences.tags.sidebarWeb}
|
{#if $preferences.tags.enabled && $preferences.tags.sidebarWeb}
|
||||||
<SideBarLink title={$t('tags')} routeId="/(user)/tags" icon={mdiTagMultipleOutline} flippedLogo />
|
<SideBarLink title={$t('tags')} routeId="/(user)/tags" icon={mdiTagMultipleOutline} flippedLogo />
|
||||||
|
@ -128,11 +110,7 @@
|
||||||
routeId="/(user)/archive"
|
routeId="/(user)/archive"
|
||||||
bind:isSelected={isArchiveSelected}
|
bind:isSelected={isArchiveSelected}
|
||||||
icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline}
|
icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline}
|
||||||
>
|
></SideBarLink>
|
||||||
{#snippet moreInformation()}
|
|
||||||
<MoreInformationAssets assetStats={{ isArchived: true }} />
|
|
||||||
{/snippet}
|
|
||||||
</SideBarLink>
|
|
||||||
|
|
||||||
{#if $featureFlags.trash}
|
{#if $featureFlags.trash}
|
||||||
<SideBarLink
|
<SideBarLink
|
||||||
|
@ -140,11 +118,7 @@
|
||||||
routeId="/(user)/trash"
|
routeId="/(user)/trash"
|
||||||
bind:isSelected={isTrashSelected}
|
bind:isSelected={isTrashSelected}
|
||||||
icon={isTrashSelected ? mdiTrashCan : mdiTrashCanOutline}
|
icon={isTrashSelected ? mdiTrashCan : mdiTrashCanOutline}
|
||||||
>
|
></SideBarLink>
|
||||||
{#snippet moreInformation()}
|
|
||||||
<MoreInformationAssets assetStats={{ isTrashed: true }} />
|
|
||||||
{/snippet}
|
|
||||||
</SideBarLink>
|
|
||||||
{/if}
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
|
@ -30,8 +30,10 @@
|
||||||
mdiFeatureSearchOutline,
|
mdiFeatureSearchOutline,
|
||||||
mdiKeyOutline,
|
mdiKeyOutline,
|
||||||
mdiOnepassword,
|
mdiOnepassword,
|
||||||
|
mdiServerOutline,
|
||||||
mdiTwoFactorAuthentication,
|
mdiTwoFactorAuthentication,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
|
import UserUsageStatistic from '$lib/components/user-settings-page/user-usage-statistic.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
keys?: ApiKeyResponseDto[];
|
keys?: ApiKeyResponseDto[];
|
||||||
|
@ -59,6 +61,15 @@
|
||||||
<UserProfileSettings />
|
<UserProfileSettings />
|
||||||
</SettingAccordion>
|
</SettingAccordion>
|
||||||
|
|
||||||
|
<SettingAccordion
|
||||||
|
icon={mdiServerOutline}
|
||||||
|
key="user-usage-info"
|
||||||
|
title={$t('user_usage_stats')}
|
||||||
|
subtitle={$t('user_usage_stats_description')}
|
||||||
|
>
|
||||||
|
<UserUsageStatistic />
|
||||||
|
</SettingAccordion>
|
||||||
|
|
||||||
<SettingAccordion icon={mdiApi} key="api-keys" title={$t('api_keys')} subtitle={$t('manage_your_api_keys')}>
|
<SettingAccordion icon={mdiApi} key="api-keys" title={$t('api_keys')} subtitle={$t('manage_your_api_keys')}>
|
||||||
<UserAPIKeyList bind:keys />
|
<UserAPIKeyList bind:keys />
|
||||||
</SettingAccordion>
|
</SettingAccordion>
|
||||||
|
|
|
@ -0,0 +1,124 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
getAlbumStatistics,
|
||||||
|
getAssetStatistics,
|
||||||
|
type AlbumStatisticsResponseDto,
|
||||||
|
type AssetStatsResponseDto,
|
||||||
|
} from '@immich/sdk';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
|
let timelineStats: AssetStatsResponseDto = $state({
|
||||||
|
videos: 0,
|
||||||
|
images: 0,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let favoriteStats: AssetStatsResponseDto = $state({
|
||||||
|
videos: 0,
|
||||||
|
images: 0,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let archiveStats: AssetStatsResponseDto = $state({
|
||||||
|
videos: 0,
|
||||||
|
images: 0,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let trashStats: AssetStatsResponseDto = $state({
|
||||||
|
videos: 0,
|
||||||
|
images: 0,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let albumStats: AlbumStatisticsResponseDto = $state({
|
||||||
|
owned: 0,
|
||||||
|
shared: 0,
|
||||||
|
notShared: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getUsage = async () => {
|
||||||
|
[timelineStats, favoriteStats, archiveStats, trashStats, albumStats] = await Promise.all([
|
||||||
|
getAssetStatistics({ isArchived: false }),
|
||||||
|
getAssetStatistics({ isFavorite: true }),
|
||||||
|
getAssetStatistics({ isArchived: true }),
|
||||||
|
getAssetStatistics({ isTrashed: true }),
|
||||||
|
getAlbumStatistics(),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await getUsage();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet row(viewName: string, imageCount: number, videoCount: number, totalCount: number)}
|
||||||
|
<td class="w-1/4 text-ellipsis px-4 text-sm">{viewName}</td>
|
||||||
|
<td class="w-1/4 text-ellipsis px-4 text-sm">{imageCount}</td>
|
||||||
|
<td class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-1/4"> {videoCount}</td>
|
||||||
|
<td class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-1/4"> {totalCount}</td>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<section class="my-6">
|
||||||
|
<p class="text-xs dark:text-white uppercase">{$t('photos_and_videos')}</p>
|
||||||
|
<table class="w-full text-left mt-4">
|
||||||
|
<thead
|
||||||
|
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
|
||||||
|
>
|
||||||
|
<tr class="flex w-full place-items-center">
|
||||||
|
<th class="w-1/4 text-center text-sm font-medium">{$t('view').toLocaleString()}</th>
|
||||||
|
<th class="w-1/4 text-center text-sm font-medium">{$t('photos').toLocaleString()}</th>
|
||||||
|
<th class="w-1/4 text-center text-sm font-medium">{$t('videos').toLocaleString()}</th>
|
||||||
|
<th class="w-1/4 text-center text-sm font-medium">{$t('total').toLocaleString()}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
|
||||||
|
<tr
|
||||||
|
class="flex h-[60px] w-full place-items-center text-center dark:text-immich-dark-fg bg-immich-bg dark:bg-immich-dark-gray/50"
|
||||||
|
>
|
||||||
|
{@render row($t('timeline'), timelineStats.images, timelineStats.videos, timelineStats.total)}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr
|
||||||
|
class="flex h-[60px] w-full place-items-center text-center dark:text-immich-dark-fg bg-immich-gray dark:bg-immich-dark-gray/75"
|
||||||
|
>
|
||||||
|
{@render row($t('favorites'), favoriteStats.images, favoriteStats.videos, favoriteStats.total)}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr
|
||||||
|
class="flex h-[60px] w-full place-items-center text-center dark:text-immich-dark-fg bg-immich-bg dark:bg-immich-dark-gray/50"
|
||||||
|
>
|
||||||
|
{@render row($t('archive'), archiveStats.images, archiveStats.videos, archiveStats.total)}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr
|
||||||
|
class="flex h-[60px] w-full place-items-center text-center dark:text-immich-dark-fg bg-immich-gray dark:bg-immich-dark-gray/75"
|
||||||
|
>
|
||||||
|
{@render row($t('trash'), trashStats.images, trashStats.videos, trashStats.total)}
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<p class="text-xs dark:text-white uppercase">{$t('albums')}</p>
|
||||||
|
</div>
|
||||||
|
<table class="w-full text-left mt-4">
|
||||||
|
<thead
|
||||||
|
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
|
||||||
|
>
|
||||||
|
<tr class="flex w-full place-items-center">
|
||||||
|
<th class="w-1/2 text-center text-sm font-medium">{$t('owned')}</th>
|
||||||
|
<th class="w-1/2 text-center text-sm font-medium">{$t('shared')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
|
||||||
|
<tr
|
||||||
|
class="flex h-[60px] w-full place-items-center text-center dark:text-immich-dark-fg bg-immich-bg dark:bg-immich-dark-gray/50"
|
||||||
|
>
|
||||||
|
<td class="w-1/2 text-ellipsis px-4 text-sm"> {albumStats.owned.toLocaleString()}</td>
|
||||||
|
<td class="w-1/2 text-ellipsis px-4 text-sm">{albumStats.shared.toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
Loading…
Reference in a new issue