فهرست منبع

feat(web): Localize dates and numbers (#1056)

Kiel Hurley 2 سال پیش
والد
کامیت
5f2b75997f

+ 0 - 14
web/package-lock.json

@@ -15,7 +15,6 @@
 				"leaflet": "^1.8.0",
 				"lodash": "^4.17.21",
 				"lodash-es": "^4.17.21",
-				"moment": "^2.29.3",
 				"socket.io-client": "^4.5.1",
 				"svelte-keydown": "^0.5.0",
 				"svelte-material-icons": "^2.0.2"
@@ -8873,14 +8872,6 @@
 				"mkdirp": "bin/cmd.js"
 			}
 		},
-		"node_modules/moment": {
-			"version": "2.29.4",
-			"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
-			"integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==",
-			"engines": {
-				"node": "*"
-			}
-		},
 		"node_modules/mri": {
 			"version": "1.2.0",
 			"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -17603,11 +17594,6 @@
 				"minimist": "^1.2.6"
 			}
 		},
-		"moment": {
-			"version": "2.29.4",
-			"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
-			"integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w=="
-		},
 		"mri": {
 			"version": "1.2.0",
 			"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",

+ 0 - 1
web/package.json

@@ -65,7 +65,6 @@
 		"leaflet": "^1.8.0",
 		"lodash": "^4.17.21",
 		"lodash-es": "^4.17.21",
-		"moment": "^2.29.3",
 		"socket.io-client": "^4.5.1",
 		"svelte-keydown": "^0.5.0",
 		"svelte-material-icons": "^2.0.2"

+ 7 - 5
web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte

@@ -4,6 +4,7 @@
 	import PlayCircle from 'svelte-material-icons/PlayCircle.svelte';
 	import Memory from 'svelte-material-icons/Memory.svelte';
 	import StatsCard from './stats-card.svelte';
+	import { getBytesWithUnit, asByteUnitString } from '../../../utils/byte-units';
 	export let stats: ServerStatsResponseDto;
 	export let allUsers: Array<UserResponseDto>;
 
@@ -15,8 +16,9 @@
 		return name;
 	};
 
-	$: spaceUnit = stats.usage.split(' ')[1];
-	$: spaceUsage = stats.usage.split(' ')[0];
+	$: [spaceUsage, spaceUnit] = getBytesWithUnit(stats.usageRaw);
+
+	const locale = navigator.languages;
 </script>
 
 <div class="flex flex-col gap-5">
@@ -55,9 +57,9 @@
 						}`}
 					>
 						<td class="text-sm px-2 w-1/4 text-ellipsis">{getFullName(user.userId)}</td>
-						<td class="text-sm px-2 w-1/4 text-ellipsis">{user.photos}</td>
-						<td class="text-sm px-2 w-1/4 text-ellipsis">{user.videos}</td>
-						<td class="text-sm px-2 w-1/4 text-ellipsis">{user.usage}</td>
+						<td class="text-sm px-2 w-1/4 text-ellipsis">{user.photos.toLocaleString(locale)}</td>
+						<td class="text-sm px-2 w-1/4 text-ellipsis">{user.videos.toLocaleString(locale)}</td>
+						<td class="text-sm px-2 w-1/4 text-ellipsis">{asByteUnitString(user.usageRaw)}</td>
 					</tr>
 				{/each}
 			</tbody>

+ 8 - 2
web/src/lib/components/admin-page/user-management.svelte

@@ -5,7 +5,6 @@
 	import PencilOutline from 'svelte-material-icons/PencilOutline.svelte';
 	import TrashCanOutline from 'svelte-material-icons/TrashCanOutline.svelte';
 	import DeleteRestore from 'svelte-material-icons/DeleteRestore.svelte';
-	import moment from 'moment';
 
 	export let allUsers: Array<UserResponseDto>;
 
@@ -15,8 +14,15 @@
 		return user.deletedAt != null;
 	};
 
+	const locale = navigator.languages;
+	const deleteDateFormat: Intl.DateTimeFormatOptions = {
+		month: 'long', day: 'numeric', year: 'numeric'
+	};
+
 	const getDeleteDate = (user: UserResponseDto): string => {
-		return moment(user.deletedAt).add(7, 'days').format('LL');
+		let deletedAt = new Date(user.deletedAt ? user.deletedAt : Date.now());
+		deletedAt.setDate(deletedAt.getDate() + 7);
+		return deletedAt.toLocaleString(locale, deleteDateFormat);
 	};
 </script>
 

+ 3 - 1
web/src/lib/components/album-page/album-card.svelte

@@ -53,6 +53,8 @@
 	onMount(async () => {
 		imageData = (await loadHighQualityThumbnail(album.albumThumbnailAssetId)) || NO_THUMBNAIL;
 	});
+
+	const locale = navigator.languages;
 </script>
 
 <div
@@ -87,7 +89,7 @@
 		</p>
 
 		<span class="text-xs flex gap-2 dark:text-immich-dark-fg" data-testid="album-details">
-			<p>{album.assetCount} items</p>
+			<p>{album.assetCount.toLocaleString(locale)} {album.assetCount == 1 ? `item` : `items`}</p>
 
 			{#if album.shared}
 				<p>·</p>

+ 11 - 8
web/src/lib/components/album-page/album-viewer.svelte

@@ -86,19 +86,22 @@
 		}
 	}
 
-	const getDateRange = () => {
-		const startDate = new Date(album.assets[0].createdAt);
-		const endDate = new Date(album.assets[album.assetCount - 1].createdAt);
-
-		const timeFormatOption: Intl.DateTimeFormatOptions = {
+	const locale = navigator.languages;
+	const albumDateFormat: Intl.DateTimeFormatOptions = {
 			month: 'short',
 			day: 'numeric',
 			year: 'numeric'
 		};
 
-		const startDateString = startDate.toLocaleDateString('us-EN', timeFormatOption);
-		const endDateString = endDate.toLocaleDateString('us-EN', timeFormatOption);
-		return `${startDateString} - ${endDateString}`;
+	const getDateRange = () => {
+		const startDate = new Date(album.assets[0].createdAt);
+		const endDate = new Date(album.assets[album.assetCount - 1].createdAt);
+
+		const startDateString = startDate.toLocaleDateString(locale, albumDateFormat);
+		const endDateString = endDate.toLocaleDateString(locale, albumDateFormat);
+
+		// If the start and end date are the same, only show one date
+		return startDateString === endDateString ? startDateString : `${startDateString} - ${endDateString}`;
 	};
 
 	onMount(async () => {

+ 12 - 9
web/src/lib/components/asset-viewer/detail-panel.svelte

@@ -4,11 +4,10 @@
 	import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
 	import CameraIris from 'svelte-material-icons/CameraIris.svelte';
 	import MapMarkerOutline from 'svelte-material-icons/MapMarkerOutline.svelte';
-	import moment from 'moment';
 	import { createEventDispatcher, onMount } from 'svelte';
 	import { browser } from '$app/environment';
 	import { AssetResponseDto, AlbumResponseDto } from '@api';
-	import { getHumanReadableBytes } from '../../utils/byte-units';
+	import { asByteUnitString } from '../../utils/byte-units';
 
 	type Leaflet = typeof import('leaflet');
 	type LeafletMap = import('leaflet').Map;
@@ -70,6 +69,8 @@
 
 		return undefined;
 	};
+
+	const locale = navigator.languages;
 </script>
 
 <section class="p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
@@ -92,18 +93,18 @@
 		{/if}
 
 		{#if asset.exifInfo?.dateTimeOriginal}
+			{@const assetDateTimeOriginal = new Date(asset.exifInfo.dateTimeOriginal)}
 			<div class="flex gap-4 py-4">
 				<div>
 					<Calendar size="24" />
 				</div>
 
 				<div>
-					<p>{moment(asset.exifInfo.dateTimeOriginal).format('MMM DD, YYYY')}</p>
+					<p>{assetDateTimeOriginal.toLocaleDateString(locale, {month:'short', day:'numeric', year: 'numeric'})}</p>
 					<div class="flex gap-2 text-sm">
 						<p>
-							{moment(asset.exifInfo.dateTimeOriginal).format('ddd, hh:mm A')}
+							{assetDateTimeOriginal.toLocaleString(locale, {weekday:'short', hour: 'numeric', minute: '2-digit', timeZoneName:'longOffset'})}
 						</p>
-						<p>GMT{moment(asset.exifInfo.dateTimeOriginal).format('Z')}</p>
 					</div>
 				</div>
 			</div>{/if}
@@ -124,7 +125,7 @@
 
 							<p>{asset.exifInfo.exifImageHeight} x {asset.exifInfo.exifImageWidth}</p>
 						{/if}
-						<p>{getHumanReadableBytes(asset.exifInfo.fileSizeInByte)}</p>
+						<p>{asByteUnitString(asset.exifInfo.fileSizeInByte)}</p>
 					</div>
 				</div>
 			</div>
@@ -137,14 +138,14 @@
 				<div>
 					<p>{asset.exifInfo.make || ''} {asset.exifInfo.model || ''}</p>
 					<div class="flex text-sm gap-2">
-						<p>{`ƒ/${asset.exifInfo.fNumber}` || ''}</p>
+						<p>{`ƒ/${asset.exifInfo.fNumber.toLocaleString(locale)}` || ''}</p>
 
 						{#if asset.exifInfo.exposureTime}
 							<p>{`1/${Math.floor(1 / asset.exifInfo.exposureTime)}`}</p>
 						{/if}
 
 						{#if asset.exifInfo.focalLength}
-							<p>{`${asset.exifInfo.focalLength} mm`}</p>
+							<p>{`${asset.exifInfo.focalLength.toLocaleString(locale)} mm`}</p>
 						{/if}
 
 						{#if asset.exifInfo.iso}
@@ -164,7 +165,9 @@
 				<div>
 					<p>{asset.exifInfo.city}</p>
 					<div class="flex text-sm gap-2">
-						<p>{asset.exifInfo.state},</p>
+						<p>{asset.exifInfo.state}</p>
+					</div>
+					<div class="flex text-sm gap-2">
 						<p>{asset.exifInfo.country}</p>
 					</div>
 				</div>

+ 10 - 3
web/src/lib/components/photos-page/asset-date-group.svelte

@@ -5,7 +5,6 @@
 	import { fly } from 'svelte/transition';
 	import { AssetResponseDto } from '@api';
 	import lodash from 'lodash-es';
-	import moment from 'moment';
 	import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
 	import {
 		assetInteractionStore,
@@ -19,12 +18,20 @@
 	export let bucketHeight: number;
 	export let isAlbumSelectionMode = false;
 
+	const locale = navigator.languages;
+	const groupDateFormat: Intl.DateTimeFormatOptions = {
+		weekday: 'short',
+		month: 'short',
+		day: 'numeric',
+		year: 'numeric'
+	};
+
 	let isMouseOverGroup = false;
 	let actualBucketHeight: number;
 	let hoveredDateGroup = '';
 	$: assetsGroupByDate = lodash
 		.chain(assets)
-		.groupBy((a) => moment(a.createdAt).format('ddd, MMM DD YYYY'))
+		.groupBy((a) => new Date(a.createdAt).toLocaleDateString(locale, groupDateFormat))
 		.sortBy((group) => assets.indexOf(group[0]))
 		.value();
 
@@ -107,7 +114,7 @@
 	bind:clientHeight={actualBucketHeight}
 >
 	{#each assetsGroupByDate as assetsInDateGroup, groupIndex (assetsInDateGroup[0].id)}
-		{@const dateGroupTitle = moment(assetsInDateGroup[0].createdAt).format('ddd, MMM DD YYYY')}
+		{@const dateGroupTitle = new Date(assetsInDateGroup[0].createdAt).toLocaleDateString(locale, groupDateFormat)}
 		<!-- Asset Group By Date -->
 
 		<div

+ 2 - 1
web/src/lib/components/shared-components/status-box.svelte

@@ -4,6 +4,7 @@
 	import Dns from 'svelte-material-icons/Dns.svelte';
 	import LoadingSpinner from './loading-spinner.svelte';
 	import { api, ServerInfoResponseDto } from '@api';
+	import { asByteUnitString } from '../../utils/byte-units';
 
 	let isServerOk = true;
 	let serverVersion = '';
@@ -61,7 +62,7 @@
 						style={`width: ${getStorageUsagePercentage()}%`}
 					/>
 				</div>
-				<p class="text-xs">{serverInfo?.diskUse} of {serverInfo?.diskSize} used</p>
+				<p class="text-xs">{asByteUnitString(serverInfo?.diskUseRaw)} of {asByteUnitString(serverInfo?.diskSizeRaw)} used</p>
 			{:else}
 				<div class="mt-2">
 					<LoadingSpinner />

+ 2 - 2
web/src/lib/components/shared-components/upload-panel.svelte

@@ -6,7 +6,7 @@
 	import WindowMinimize from 'svelte-material-icons/WindowMinimize.svelte';
 	import type { UploadAsset } from '$lib/models/upload-asset';
 	import { notificationController, NotificationType } from './notification/notification';
-	import { getHumanReadableBytes } from '../../utils/byte-units';
+	import { getBytesWithUnit } from '../../utils/byte-units';
 
 	let showDetail = true;
 
@@ -115,7 +115,7 @@
 									<input
 										disabled
 										class="bg-gray-100 border w-full p-1 rounded-md text-[10px] px-2"
-										value={`[${getHumanReadableBytes(uploadAsset.file.size)}] ${
+										value={`[${getBytesWithUnit(uploadAsset.file.size)}] ${
 											uploadAsset.file.name
 										}`}
 									/>

+ 31 - 2
web/src/lib/utils/byte-units.ts

@@ -1,4 +1,14 @@
-export function getHumanReadableBytes(bytes: number): string {
+/**
+ * Convert bytes to best human readable unit and number of that unit.
+ *
+ * * For `1024` bytes, returns `1` and `KiB`.
+ * * For `1536` bytes, returns `1.5` and `KiB`.
+ *
+ * @param bytes number of bytes
+ * @param maxPrecision maximum number of decimal places, default is `1`
+ * @returns size (number) and unit (string)
+ */
+export function getBytesWithUnit(bytes: number, maxPrecision = 1): [number, string] {
 	const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'];
 
 	let magnitude = 0;
@@ -12,5 +22,24 @@ export function getHumanReadableBytes(bytes: number): string {
 		}
 	}
 
-	return `${remainder.toFixed(magnitude == 0 ? 0 : 1)} ${units[magnitude]}`;
+	remainder = parseFloat(remainder.toFixed(maxPrecision));
+
+	return [remainder, units[magnitude]];
+}
+
+/**
+ * Localized number of bytes with a unit.
+ *
+ * For `1536` bytes:
+ * * en: `1.5 KiB`
+ * * de: `1,5 KiB`
+ *
+ * @param bytes number of bytes
+ * @param maxPrecision maximum number of decimal places, default is `1`
+ * @returns localized bytes with unit as string
+ */
+export function asByteUnitString(bytes: number, maxPrecision = 1): string {
+	const locale = Array.from(navigator.languages);
+	const [size, unit] = getBytesWithUnit(bytes, maxPrecision);
+	return `${size.toLocaleString(locale)} ${unit}`;
 }