diff --git a/i18n/en.json b/i18n/en.json index c34de74ae5..91bbb6cda2 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1,4 +1,6 @@ { + "user_usage_stats": "Account usage statistics", + "user_usage_stats_description": "View account usage statistics", "about": "Refresh", "account": "Account", "account_settings": "Account Settings", @@ -1315,5 +1317,7 @@ "years_ago": "{years, plural, one {# year} other {# years}} ago", "yes": "Yes", "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" } diff --git a/web/src/lib/components/shared-components/side-bar/more-information-albums.svelte b/web/src/lib/components/shared-components/side-bar/more-information-albums.svelte deleted file mode 100644 index 58ce0c8574..0000000000 --- a/web/src/lib/components/shared-components/side-bar/more-information-albums.svelte +++ /dev/null @@ -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} diff --git a/web/src/lib/components/shared-components/side-bar/more-information-assets.svelte b/web/src/lib/components/shared-components/side-bar/more-information-assets.svelte deleted file mode 100644 index 5e4589be18..0000000000 --- a/web/src/lib/components/shared-components/side-bar/more-information-assets.svelte +++ /dev/null @@ -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} diff --git a/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte b/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte index d3fd94ae08..13f08533c5 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte @@ -1,10 +1,7 @@ <script lang="ts"> - import { fade } from 'svelte/transition'; import Icon from '$lib/components/elements/icon.svelte'; - import { mdiInformationOutline } from '@mdi/js'; import { resolveRoute } from '$app/paths'; import { page } from '$app/stores'; - import type { Snippet } from 'svelte'; interface Props { title: string; @@ -13,7 +10,6 @@ flippedLogo?: boolean; isSelected?: boolean; preloadData?: boolean; - moreInformation?: Snippet; } let { @@ -23,10 +19,8 @@ flippedLogo = false, isSelected = $bindable(false), preloadData = true, - moreInformation, }: Props = $props(); - let showMoreInformation = $state(false); let routePath = $derived(resolveRoute(routeId, {})); $effect(() => { @@ -39,7 +33,7 @@ data-sveltekit-preload-data={preloadData ? 'hover' : 'off'} draggable="false" 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 ? '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 /> <span class="text-sm font-medium">{title}</span> </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> + <div></div> </a> diff --git a/web/src/lib/components/shared-components/side-bar/side-bar.svelte b/web/src/lib/components/shared-components/side-bar/side-bar.svelte index 54607e1779..000afa5d1a 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar.svelte @@ -24,8 +24,6 @@ } from '@mdi/js'; import SideBarSection from './side-bar-section.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 BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte'; import { preferences } from '$lib/stores/user.store'; @@ -47,11 +45,7 @@ routeId="/(user)/photos" bind:isSelected={isPhotosSelected} icon={isPhotosSelected ? mdiImageMultiple : mdiImageMultipleOutline} - > - {#snippet moreInformation()} - <MoreInformationAssets assetStats={{ isArchived: false }} /> - {/snippet} - </SideBarLink> + ></SideBarLink> {#if $featureFlags.search} <SideBarLink title={$t('explore')} routeId="/(user)/explore" icon={mdiMagnify} /> @@ -80,11 +74,7 @@ routeId="/(user)/sharing" icon={isSharingSelected ? mdiAccountMultiple : mdiAccountMultipleOutline} bind:isSelected={isSharingSelected} - > - {#snippet moreInformation()} - <MoreInformationAlbums albumType="shared" /> - {/snippet} - </SideBarLink> + ></SideBarLink> <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> @@ -96,17 +86,9 @@ routeId="/(user)/favorites" icon={isFavoritesSelected ? mdiHeart : mdiHeartOutline} bind:isSelected={isFavoritesSelected} - > - {#snippet moreInformation()} - <MoreInformationAssets assetStats={{ isFavorite: true }} /> - {/snippet} - </SideBarLink> + ></SideBarLink> - <SideBarLink title={$t('albums')} routeId="/(user)/albums" icon={mdiImageAlbum} flippedLogo> - {#snippet moreInformation()} - <MoreInformationAlbums albumType="owned" /> - {/snippet} - </SideBarLink> + <SideBarLink title={$t('albums')} routeId="/(user)/albums" icon={mdiImageAlbum} flippedLogo></SideBarLink> {#if $preferences.tags.enabled && $preferences.tags.sidebarWeb} <SideBarLink title={$t('tags')} routeId="/(user)/tags" icon={mdiTagMultipleOutline} flippedLogo /> @@ -128,11 +110,7 @@ routeId="/(user)/archive" bind:isSelected={isArchiveSelected} icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline} - > - {#snippet moreInformation()} - <MoreInformationAssets assetStats={{ isArchived: true }} /> - {/snippet} - </SideBarLink> + ></SideBarLink> {#if $featureFlags.trash} <SideBarLink @@ -140,11 +118,7 @@ routeId="/(user)/trash" bind:isSelected={isTrashSelected} icon={isTrashSelected ? mdiTrashCan : mdiTrashCanOutline} - > - {#snippet moreInformation()} - <MoreInformationAssets assetStats={{ isTrashed: true }} /> - {/snippet} - </SideBarLink> + ></SideBarLink> {/if} </nav> diff --git a/web/src/lib/components/user-settings-page/user-settings-list.svelte b/web/src/lib/components/user-settings-page/user-settings-list.svelte index 6f8a0ce4dc..5fdcc4a6a0 100644 --- a/web/src/lib/components/user-settings-page/user-settings-list.svelte +++ b/web/src/lib/components/user-settings-page/user-settings-list.svelte @@ -30,8 +30,10 @@ mdiFeatureSearchOutline, mdiKeyOutline, mdiOnepassword, + mdiServerOutline, mdiTwoFactorAuthentication, } from '@mdi/js'; + import UserUsageStatistic from '$lib/components/user-settings-page/user-usage-statistic.svelte'; interface Props { keys?: ApiKeyResponseDto[]; @@ -59,6 +61,15 @@ <UserProfileSettings /> </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')}> <UserAPIKeyList bind:keys /> </SettingAccordion> diff --git a/web/src/lib/components/user-settings-page/user-usage-statistic.svelte b/web/src/lib/components/user-settings-page/user-usage-statistic.svelte new file mode 100644 index 0000000000..8833d266ea --- /dev/null +++ b/web/src/lib/components/user-settings-page/user-usage-statistic.svelte @@ -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>