mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat(web): use timeline in geolocation manager (#21492)
This commit is contained in:
parent
5acd6b70d0
commit
7a1c45c364
20 changed files with 277 additions and 496 deletions
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
|
|
@ -12,10 +13,10 @@
|
|||
|
||||
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
||||
|
||||
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { fly, scale } from 'svelte/transition';
|
||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
|
||||
|
||||
let { isUploading } = uploadAssetsStore;
|
||||
|
||||
|
|
@ -27,11 +28,23 @@
|
|||
monthGroup: MonthGroup;
|
||||
timelineManager: TimelineManager;
|
||||
assetInteraction: AssetInteraction;
|
||||
customLayout?: Snippet<[TimelineAsset]>;
|
||||
|
||||
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
|
||||
onSelectAssets: (asset: TimelineAsset) => void;
|
||||
onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
|
||||
onScrollCompensation: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
|
||||
onThumbnailClick?: (
|
||||
asset: TimelineAsset,
|
||||
timelineManager: TimelineManager,
|
||||
dayGroup: DayGroup,
|
||||
onClick: (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
groupTitle: string,
|
||||
asset: TimelineAsset,
|
||||
) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -42,10 +55,12 @@
|
|||
monthGroup = $bindable(),
|
||||
assetInteraction,
|
||||
timelineManager,
|
||||
customLayout,
|
||||
onSelect,
|
||||
onSelectAssets,
|
||||
onSelectAssetCandidates,
|
||||
onScrollCompensation,
|
||||
onThumbnailClick,
|
||||
}: Props = $props();
|
||||
|
||||
let isMouseOverGroup = $state(false);
|
||||
|
|
@ -55,7 +70,7 @@
|
|||
monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150,
|
||||
);
|
||||
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||
const onClick = (
|
||||
const _onClick = (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
groupTitle: string,
|
||||
|
|
@ -202,7 +217,13 @@
|
|||
{showArchiveIcon}
|
||||
{asset}
|
||||
{groupIndex}
|
||||
onClick={(asset) => onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset)}
|
||||
onClick={(asset) => {
|
||||
if (typeof onThumbnailClick === 'function') {
|
||||
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
|
||||
} else {
|
||||
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
|
||||
}
|
||||
}}
|
||||
onSelect={(asset) => assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle)}
|
||||
onMouseEvent={() => assetMouseEventHandler(dayGroup.groupTitle, assetSnapshot(asset))}
|
||||
selected={assetInteraction.hasSelectedAsset(asset.id) ||
|
||||
|
|
@ -212,6 +233,9 @@
|
|||
thumbnailWidth={position.width}
|
||||
thumbnailHeight={position.height}
|
||||
/>
|
||||
{#if customLayout}
|
||||
{@render customLayout(asset)}
|
||||
{/if}
|
||||
</div>
|
||||
<!-- {/if} -->
|
||||
{/each}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
import Scrubber from '$lib/components/shared-components/scrubber/scrubber.svelte';
|
||||
import { AppRoute, AssetAction } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
|
|
@ -65,6 +66,18 @@
|
|||
onEscape?: () => void;
|
||||
children?: Snippet;
|
||||
empty?: Snippet;
|
||||
customLayout?: Snippet<[TimelineAsset]>;
|
||||
onThumbnailClick?: (
|
||||
asset: TimelineAsset,
|
||||
timelineManager: TimelineManager,
|
||||
dayGroup: DayGroup,
|
||||
onClick: (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
groupTitle: string,
|
||||
asset: TimelineAsset,
|
||||
) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -84,6 +97,8 @@
|
|||
onEscape = () => {},
|
||||
children,
|
||||
empty,
|
||||
customLayout,
|
||||
onThumbnailClick,
|
||||
}: Props = $props();
|
||||
|
||||
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget, mutex } = assetViewingStore;
|
||||
|
|
@ -940,6 +955,8 @@
|
|||
onSelectAssetCandidates={handleSelectAssetCandidates}
|
||||
onSelectAssets={handleSelectAssets}
|
||||
onScrollCompensation={handleScrollCompensation}
|
||||
{customLayout}
|
||||
{onThumbnailClick}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,113 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Button } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
onDateChange: (year?: number, month?: number, day?: number) => Promise<void>;
|
||||
onClearFilters?: () => void;
|
||||
defaultDate?: string;
|
||||
}
|
||||
|
||||
let { onDateChange, onClearFilters, defaultDate }: Props = $props();
|
||||
|
||||
let selectedYear = $state<number | undefined>(undefined);
|
||||
let selectedMonth = $state<number | undefined>(undefined);
|
||||
let selectedDay = $state<number | undefined>(undefined);
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearOptions = Array.from({ length: 30 }, (_, i) => currentYear - i);
|
||||
|
||||
const monthOptions = Array.from({ length: 12 }, (_, i) => ({
|
||||
value: i + 1,
|
||||
label: new Date(2000, i).toLocaleString('default', { month: 'long' }),
|
||||
}));
|
||||
|
||||
const dayOptions = $derived.by(() => {
|
||||
if (!selectedYear || !selectedMonth) {
|
||||
return [];
|
||||
}
|
||||
const daysInMonth = new Date(selectedYear, selectedMonth, 0).getDate();
|
||||
return Array.from({ length: daysInMonth }, (_, i) => i + 1);
|
||||
});
|
||||
|
||||
if (defaultDate) {
|
||||
const [year, month, day] = defaultDate.split('-');
|
||||
selectedYear = Number.parseInt(year);
|
||||
selectedMonth = Number.parseInt(month);
|
||||
selectedDay = Number.parseInt(day);
|
||||
}
|
||||
|
||||
const filterAssetsByDate = async () => {
|
||||
await onDateChange(selectedYear, selectedMonth, selectedDay);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
selectedYear = undefined;
|
||||
selectedMonth = undefined;
|
||||
selectedDay = undefined;
|
||||
if (onClearFilters) {
|
||||
onClearFilters();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="mt-2 mb-2 p-2 rounded-lg">
|
||||
<div class="flex flex-wrap gap-4 items-end w-136">
|
||||
<div class="flex-1 min-w-20">
|
||||
<label for="year-select" class="immich-form-label">
|
||||
{$t('year')}
|
||||
</label>
|
||||
<select
|
||||
id="year-select"
|
||||
bind:value={selectedYear}
|
||||
onchange={filterAssetsByDate}
|
||||
class="text-sm w-full mt-1 px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value={undefined}>{$t('year')}</option>
|
||||
{#each yearOptions as year (year)}
|
||||
<option value={year}>{year}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex-2 min-w-24">
|
||||
<label for="month-select" class="immich-form-label">
|
||||
{$t('month')}
|
||||
</label>
|
||||
<select
|
||||
id="month-select"
|
||||
bind:value={selectedMonth}
|
||||
onchange={filterAssetsByDate}
|
||||
disabled={!selectedYear}
|
||||
class="text-sm w-full mt-1 px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:opacity-50 disabled:bg-gray-400"
|
||||
>
|
||||
<option value={undefined}>{$t('month')}</option>
|
||||
{#each monthOptions as month (month.value)}
|
||||
<option value={month.value}>{month.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-16">
|
||||
<label for="day-select" class="immich-form-label">
|
||||
{$t('day')}
|
||||
</label>
|
||||
<select
|
||||
id="day-select"
|
||||
bind:value={selectedDay}
|
||||
onchange={filterAssetsByDate}
|
||||
disabled={!selectedYear || !selectedMonth}
|
||||
class="text-sm w-full mt-1 px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:opacity-50 disabled:bg-gray-400"
|
||||
>
|
||||
<option value={undefined}>{$t('day')}</option>
|
||||
{#each dayOptions as day (day)}
|
||||
<option value={day}>{day}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<Button size="small" color="secondary" variant="ghost" onclick={clearFilters}>{$t('reset')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
<script lang="ts">
|
||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { type AssetResponseDto } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
assetInteraction: AssetInteraction;
|
||||
onSelectAsset: (asset: AssetResponseDto) => void;
|
||||
onMouseEvent: (asset: AssetResponseDto) => void;
|
||||
onLocation: (location: { latitude: number; longitude: number }) => void;
|
||||
}
|
||||
|
||||
let { asset, assetInteraction, onSelectAsset, onMouseEvent, onLocation }: Props = $props();
|
||||
|
||||
let assetData = $derived(
|
||||
JSON.stringify(
|
||||
{
|
||||
originalFileName: asset.originalFileName,
|
||||
localDateTime: asset.localDateTime,
|
||||
make: asset.exifInfo?.make,
|
||||
model: asset.exifInfo?.model,
|
||||
gps: {
|
||||
latitude: asset.exifInfo?.latitude,
|
||||
longitude: asset.exifInfo?.longitude,
|
||||
},
|
||||
location: asset.exifInfo?.city ? `${asset.exifInfo?.country} - ${asset.exifInfo?.city}` : undefined,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
let boxWidth = $state(300);
|
||||
let timelineAsset = $derived(toTimelineAsset(asset));
|
||||
const hasGps = $derived(!!asset.exifInfo?.latitude && !!asset.exifInfo?.longitude);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="w-full aspect-square rounded-xl border-3 transition-colors font-semibold text-xs dark:bg-black bg-gray-200 border-gray-200 dark:border-gray-800"
|
||||
bind:clientWidth={boxWidth}
|
||||
title={assetData}
|
||||
>
|
||||
<div class="relative w-full h-full overflow-hidden rounded-lg">
|
||||
<Thumbnail
|
||||
asset={timelineAsset}
|
||||
onClick={() => {
|
||||
if (asset.exifInfo?.latitude && asset.exifInfo?.longitude) {
|
||||
onLocation({ latitude: asset.exifInfo?.latitude, longitude: asset.exifInfo?.longitude });
|
||||
} else {
|
||||
onSelectAsset(asset);
|
||||
}
|
||||
}}
|
||||
onSelect={() => onSelectAsset(asset)}
|
||||
onMouseEvent={() => onMouseEvent(asset)}
|
||||
selected={assetInteraction.hasSelectedAsset(asset.id)}
|
||||
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
|
||||
thumbnailSize={boxWidth}
|
||||
readonly={hasGps}
|
||||
/>
|
||||
|
||||
{#if hasGps}
|
||||
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-success text-black">
|
||||
{$t('gps')}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-danger text-light">
|
||||
{$t('gps_missing')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-center mt-4 px-4 text-sm font-semibold truncate" title={asset.originalFileName}>
|
||||
<a href={`${AppRoute.PHOTOS}/${asset.id}`} target="_blank" rel="noopener noreferrer">
|
||||
{asset.originalFileName}
|
||||
</a>
|
||||
</div>
|
||||
<div class="text-center my-3">
|
||||
<p class="px-4 text-xs font-normal truncate text-dark/75">
|
||||
{new Date(asset.localDateTime).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
<p class="px-4 text-xs font-normal truncate text-dark/75">
|
||||
{new Date(asset.localDateTime).toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZone: 'UTC',
|
||||
})}
|
||||
</p>
|
||||
{#if hasGps}
|
||||
<p class="text-primary mt-2 text-xs font-normal px-4 text-center truncate">
|
||||
{asset.exifInfo?.country}
|
||||
</p>
|
||||
<p class="text-primary text-xs font-normal px-4 text-center truncate">
|
||||
{asset.exifInfo?.city}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue