mirror of
https://github.com/immich-app/immich.git
synced 2026-05-23 08:02:29 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54c1fbebde |
+1
-145
@@ -1,35 +1,17 @@
|
|||||||
import { defaultProvider, screencastManager, themeManager, ThemePreference, type ActionItem } from '@immich/ui';
|
import { defaultProvider, screencastManager, themeManager, ThemePreference, type ActionItem } from '@immich/ui';
|
||||||
import {
|
import {
|
||||||
mdiAccountMultipleOutline,
|
mdiAccountMultipleOutline,
|
||||||
mdiAccountOutline,
|
|
||||||
mdiArchiveArrowDownOutline,
|
|
||||||
mdiBookshelf,
|
mdiBookshelf,
|
||||||
mdiCog,
|
mdiCog,
|
||||||
mdiContentDuplicate,
|
|
||||||
mdiCrosshairsGps,
|
|
||||||
mdiFolderOutline,
|
|
||||||
mdiHeartOutline,
|
|
||||||
mdiImageAlbum,
|
|
||||||
mdiImageMultipleOutline,
|
|
||||||
mdiImageSizeSelectLarge,
|
|
||||||
mdiKeyboard,
|
mdiKeyboard,
|
||||||
mdiLink,
|
|
||||||
mdiLockOutline,
|
|
||||||
mdiMagnify,
|
|
||||||
mdiMapOutline,
|
|
||||||
mdiServer,
|
mdiServer,
|
||||||
mdiStateMachine,
|
|
||||||
mdiSync,
|
mdiSync,
|
||||||
mdiTagMultipleOutline,
|
|
||||||
mdiThemeLightDark,
|
mdiThemeLightDark,
|
||||||
mdiToolboxOutline,
|
|
||||||
mdiTrashCanOutline,
|
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import type { MessageFormatter } from 'svelte-i18n';
|
import type { MessageFormatter } from 'svelte-i18n';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
|
||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
import { copyToClipboard } from '$lib/utils';
|
import { copyToClipboard } from '$lib/utils';
|
||||||
|
|
||||||
@@ -67,133 +49,7 @@ export const getPagesProvider = ($t: MessageFormatter) => {
|
|||||||
},
|
},
|
||||||
].map((route) => ({ ...route, $if: () => authManager.authenticated && authManager.user.isAdmin }));
|
].map((route) => ({ ...route, $if: () => authManager.authenticated && authManager.user.isAdmin }));
|
||||||
|
|
||||||
const userPages: ActionItem[] = [
|
return defaultProvider({ name: $t('page'), actions: adminPages });
|
||||||
{
|
|
||||||
title: $t('photos'),
|
|
||||||
icon: mdiImageMultipleOutline,
|
|
||||||
onAction: () => goto(Route.photos()),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('explore'),
|
|
||||||
icon: mdiMagnify,
|
|
||||||
onAction: () => goto(Route.explore()),
|
|
||||||
$if: () => authManager.authenticated && featureFlagsManager.value.search,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
title: $t('map'),
|
|
||||||
icon: mdiMapOutline,
|
|
||||||
onAction: () => goto(Route.map()),
|
|
||||||
$if: () => authManager.authenticated && featureFlagsManager.value.map,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('people'),
|
|
||||||
description: $t('people_feature_description'),
|
|
||||||
icon: mdiAccountOutline,
|
|
||||||
onAction: () => goto(Route.people()),
|
|
||||||
$if: () => authManager.authenticated && authManager.preferences.people.enabled,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('shared_links'),
|
|
||||||
icon: mdiLink,
|
|
||||||
onAction: () => goto(Route.sharedLinks()),
|
|
||||||
$if: () => authManager.authenticated && authManager.preferences.sharedLinks.enabled,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('recently_added'),
|
|
||||||
icon: mdiMagnify,
|
|
||||||
onAction: () => goto(Route.recentlyAdded()),
|
|
||||||
$if: () => authManager.authenticated,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('sharing'),
|
|
||||||
icon: mdiAccountMultipleOutline,
|
|
||||||
onAction: () => goto(Route.sharing()),
|
|
||||||
$if: () => authManager.authenticated,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('favorites'),
|
|
||||||
icon: mdiHeartOutline,
|
|
||||||
onAction: () => goto(Route.favorites()),
|
|
||||||
$if: () => authManager.authenticated,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('albums'),
|
|
||||||
description: $t('albums_feature_description'),
|
|
||||||
icon: mdiImageAlbum,
|
|
||||||
onAction: () => goto(Route.albums()),
|
|
||||||
$if: () => authManager.authenticated,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('tags'),
|
|
||||||
description: $t('tag_feature_description'),
|
|
||||||
icon: mdiTagMultipleOutline,
|
|
||||||
onAction: () => goto(Route.tags()),
|
|
||||||
$if: () => authManager.authenticated && authManager.preferences.tags.enabled,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('folders'),
|
|
||||||
description: $t('folders_feature_description'),
|
|
||||||
icon: mdiFolderOutline,
|
|
||||||
onAction: () => goto(Route.folders()),
|
|
||||||
$if: () => authManager.authenticated && authManager.preferences.folders.enabled,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('utilities'),
|
|
||||||
icon: mdiToolboxOutline,
|
|
||||||
onAction: () => goto(Route.utilities()),
|
|
||||||
$if: () => authManager.authenticated,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('archive'),
|
|
||||||
icon: mdiArchiveArrowDownOutline,
|
|
||||||
onAction: () => goto(Route.archive()),
|
|
||||||
$if: () => authManager.authenticated,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('locked_folder'),
|
|
||||||
icon: mdiLockOutline,
|
|
||||||
onAction: () => goto(Route.locked()),
|
|
||||||
$if: () => authManager.authenticated,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('trash'),
|
|
||||||
icon: mdiTrashCanOutline,
|
|
||||||
onAction: () => goto(Route.trash()),
|
|
||||||
$if: () => authManager.authenticated && featureFlagsManager.value.trash,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('admin.user_settings'),
|
|
||||||
icon: mdiCog,
|
|
||||||
onAction: () => goto(Route.userSettings()),
|
|
||||||
$if: () => authManager.authenticated,
|
|
||||||
},
|
|
||||||
].map((route) => ({ $if: () => authManager.authenticated, ...route }));
|
|
||||||
|
|
||||||
const utilityPages: ActionItem[] = [
|
|
||||||
{
|
|
||||||
title: $t('review_duplicates'),
|
|
||||||
icon: mdiContentDuplicate,
|
|
||||||
onAction: () => goto(Route.duplicatesUtility()),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('review_large_files'),
|
|
||||||
icon: mdiImageSizeSelectLarge,
|
|
||||||
onAction: () => goto(Route.largeFileUtility()),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('manage_geolocation'),
|
|
||||||
icon: mdiCrosshairsGps,
|
|
||||||
onAction: () => goto(Route.geolocationUtility()),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: $t('workflows'),
|
|
||||||
icon: mdiStateMachine,
|
|
||||||
onAction: () => goto(Route.workflows()),
|
|
||||||
},
|
|
||||||
].map((route) => ({ ...route, $if: () => authManager.authenticated }));
|
|
||||||
|
|
||||||
return defaultProvider({ name: $t('page'), actions: [...userPages, ...utilityPages, ...adminPages] });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMyImmichLink = () => {
|
const getMyImmichLink = () => {
|
||||||
|
|||||||
@@ -3,15 +3,18 @@
|
|||||||
import type { AlbumResponseDto } from '@immich/sdk';
|
import type { AlbumResponseDto } from '@immich/sdk';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
album: AlbumResponseDto;
|
album: AlbumResponseDto;
|
||||||
}
|
};
|
||||||
|
|
||||||
let { album }: Props = $props();
|
const { album }: Props = $props();
|
||||||
|
const startDate = album.startDate;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span class="my-2 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details">
|
<span class="my-2 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details">
|
||||||
<span>{getAlbumDateRange(album)}</span>
|
{#if startDate}
|
||||||
<span>•</span>
|
<span>{getAlbumDateRange(startDate, album.endDate ?? startDate)}</span>
|
||||||
|
<span>•</span>
|
||||||
|
{/if}
|
||||||
<span>{$t('items_count', { values: { count: album.assetCount } })}</span>
|
<span>{$t('items_count', { values: { count: album.assetCount } })}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ export const dateFormats = {
|
|||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
} satisfies Intl.DateTimeFormatOptions,
|
||||||
|
albumShort: {
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
} satisfies Intl.DateTimeFormatOptions,
|
} satisfies Intl.DateTimeFormatOptions,
|
||||||
settings: {
|
settings: {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
|
|||||||
@@ -1,66 +1,93 @@
|
|||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { getAlbumDateRange, getShortDateRange } from './date-time';
|
import { getAlbumDateRange, getShortDateRange } from './date-time';
|
||||||
|
|
||||||
|
vitest.mock('$lib/stores/preferences.store', () => ({
|
||||||
|
locale: writable('en'),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('getShortDateRange', () => {
|
describe('getShortDateRange', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubEnv('TZ', 'UTC');
|
vi.stubEnv('TZ', 'UTC');
|
||||||
|
locale.set('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
locale.set('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly return long month if start and end date are within the same month', () => {
|
||||||
|
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-01-31T00:00:00.000Z')).toEqual('January 2022');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly return month range if start and end date are in separate months within the same year', () => {
|
||||||
|
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('Jan – Feb 2022');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly return range if start and end date are in separate months and years', () => {
|
||||||
|
expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021 – Jan 2022');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly return long month if start and end date are within the same month, ignoring local time zone', () => {
|
||||||
|
vi.stubEnv('TZ', 'UTC+6');
|
||||||
|
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-01-31T00:00:00.000Z')).toEqual('January 2022');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly return month range if start and end date are in separate months within the same year, ignoring local time zone', () => {
|
||||||
|
vi.stubEnv('TZ', 'UTC+6');
|
||||||
|
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('Jan – Feb 2022');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly return range if start and end date are in separate months and years, ignoring local time zone', () => {
|
||||||
|
vi.stubEnv('TZ', 'UTC+6');
|
||||||
|
expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021 – Jan 2022');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly return range if start and end date are in separate months and years, ignoring local time zone', () => {
|
||||||
|
vi.stubEnv('TZ', 'UTC-6');
|
||||||
|
expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021 – Jan 2022');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use the correct locale to return month range', () => {
|
||||||
|
locale.set('fr');
|
||||||
|
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('janv.–févr. 2022');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use the correct locale to return month-year range', () => {
|
||||||
|
locale.set('fr');
|
||||||
|
expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('déc. 2021 – janv. 2022');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAlbumDateRange', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
vi.stubEnv('TZ', 'UTC');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly return month if start and end date are within the same month', () => {
|
it('should work', () => {
|
||||||
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-01-31T00:00:00.000Z')).toEqual('Jan 2022');
|
expect(getAlbumDateRange('2021-01-01T00:00:00Z', '2021-01-05T00:00:00Z')).toEqual('Jan 1 – 5, 2021');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly return month range if start and end date are in separate months within the same year', () => {
|
it('should work with a single day range', () => {
|
||||||
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('Jan - Feb 2022');
|
expect(getAlbumDateRange('2021-01-01T09:00:00Z', '2021-01-01T10:00:00Z')).toEqual('Jan 1, 2021');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly return range if start and end date are in separate months and years', () => {
|
it('should work with positive time zone present', () => {
|
||||||
expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021 - Jan 2022');
|
expect(getAlbumDateRange('2021-01-01T00:00:00+05:00', '2021-01-01T00:00:00+05:00')).toEqual('Jan 1, 2021');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly return month if start and end date are within the same month, ignoring local time zone', () => {
|
it('should work with negative time zone present', () => {
|
||||||
vi.stubEnv('TZ', 'UTC+6');
|
expect(getAlbumDateRange('2021-01-01T00:00:00-05:00', '2021-01-01T00:00:00-05:00')).toEqual('Jan 1, 2021');
|
||||||
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-01-31T00:00:00.000Z')).toEqual('Jan 2022');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly return month range if start and end date are in separate months within the same year, ignoring local time zone', () => {
|
it('should use the proper locale', () => {
|
||||||
vi.stubEnv('TZ', 'UTC+6');
|
locale.set('fr');
|
||||||
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('Jan - Feb 2022');
|
expect(getAlbumDateRange('2020-03-26T12:00:00Z', '2021-12-01T00:00:00Z')).toEqual('26 mars 2020 – 1 déc. 2021');
|
||||||
});
|
locale.set('en');
|
||||||
|
|
||||||
it('should correctly return range if start and end date are in separate months and years, ignoring local time zone', () => {
|
|
||||||
vi.stubEnv('TZ', 'UTC+6');
|
|
||||||
expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021 - Jan 2022');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getAlbumDate', () => {
|
|
||||||
beforeAll(() => {
|
|
||||||
process.env.TZ = 'UTC';
|
|
||||||
|
|
||||||
vitest.mock('$lib/stores/preferences.store', () => ({
|
|
||||||
locale: writable('en'),
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should work with only a start date', () => {
|
|
||||||
expect(getAlbumDateRange({ startDate: '2021-01-01T00:00:00Z' })).toEqual('Jan 1, 2021');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should work with a start and end date', () => {
|
|
||||||
expect(
|
|
||||||
getAlbumDateRange({
|
|
||||||
startDate: '2021-01-01T00:00:00Z',
|
|
||||||
endDate: '2021-01-05T00:00:00Z',
|
|
||||||
}),
|
|
||||||
).toEqual('Jan 1, 2021 - Jan 5, 2021');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should work with the new date format', () => {
|
|
||||||
expect(getAlbumDateRange({ startDate: '2021-01-01T00:00:00+05:00' })).toEqual('Jan 1, 2021');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,69 +7,28 @@ export function parseUtcDate(date: string) {
|
|||||||
return DateTime.fromISO(date, { zone: 'UTC' }).toUTC();
|
return DateTime.fromISO(date, { zone: 'UTC' }).toUTC();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getShortDateRange = (startTimestamp: string, endTimestamp: string) => {
|
const getDateRange = (startTimestamp: string, endTimestamp: string, format: 'short' | 'long') => {
|
||||||
const userLocale = get(locale);
|
const userLocale = get(locale);
|
||||||
let startDate = DateTime.fromISO(startTimestamp).setZone('UTC');
|
const startDate = DateTime.fromISO(startTimestamp).setZone('UTC');
|
||||||
let endDate = DateTime.fromISO(endTimestamp).setZone('UTC');
|
const endDate = DateTime.fromISO(endTimestamp).setZone('UTC').setLocale(userLocale);
|
||||||
|
|
||||||
if (userLocale) {
|
if (startDate.year === endDate.year && startDate.month === endDate.month && format === 'short') {
|
||||||
startDate = startDate.setLocale(userLocale);
|
return endDate.toLocaleString({ month: 'long', year: 'numeric' });
|
||||||
endDate = endDate.setLocale(userLocale);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const endDateLocalized = endDate.toLocaleString({
|
const formatter = new Intl.DateTimeFormat(
|
||||||
month: 'short',
|
userLocale,
|
||||||
year: 'numeric',
|
format === 'short' ? dateFormats.albumShort : dateFormats.album,
|
||||||
});
|
);
|
||||||
|
return formatter.formatRange(startDate.toJSDate(), endDate.toJSDate());
|
||||||
if (startDate.year === endDate.year) {
|
|
||||||
if (startDate.month === endDate.month) {
|
|
||||||
// Same year and month.
|
|
||||||
// e.g.: aug. 2024
|
|
||||||
return endDateLocalized;
|
|
||||||
} else {
|
|
||||||
// Same year but different month.
|
|
||||||
// e.g.: jul. - sept. 2024
|
|
||||||
const startMonthLocalized = startDate.toLocaleString({
|
|
||||||
month: 'short',
|
|
||||||
});
|
|
||||||
return `${startMonthLocalized} - ${endDateLocalized}`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Different year.
|
|
||||||
// e.g.: feb. 2021 - sept. 2024
|
|
||||||
const startDateLocalized = startDate.toLocaleString({
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
});
|
|
||||||
return `${startDateLocalized} - ${endDateLocalized}`;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (date?: string) => {
|
/**
|
||||||
if (!date) {
|
* Get localized date range in short format like 'Oct – Nov 2026', with full month if start and end are the same: 'October 2026'
|
||||||
return;
|
*/
|
||||||
}
|
export const getShortDateRange = (start: string, end: string) => getDateRange(start, end, 'short');
|
||||||
|
|
||||||
// without timezone
|
export const getAlbumDateRange = (start: string, end: string) => getDateRange(start, end, 'long');
|
||||||
const localDate = date.replace(/Z$/, '').replace(/\+.+$/, '');
|
|
||||||
return localDate ? new Date(localDate).toLocaleDateString(get(locale), dateFormats.album) : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAlbumDateRange = (album: { startDate?: string; endDate?: string }) => {
|
|
||||||
const start = formatDate(album.startDate);
|
|
||||||
const end = formatDate(album.endDate);
|
|
||||||
|
|
||||||
if (start && end && start !== end) {
|
|
||||||
return `${start} - ${end}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (start) {
|
|
||||||
return start;
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use this to convert from "5pm EST" to "5pm UTC"
|
* Use this to convert from "5pm EST" to "5pm UTC"
|
||||||
|
|||||||
Reference in New Issue
Block a user