mirror of
https://github.com/immich-app/immich.git
synced 2026-06-15 19:39:20 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b2d9083eb | |||
| cdb46728c1 | |||
| 90951521ca |
@@ -2,7 +2,6 @@ import { AssetVisibility, LoginResponseDto } from '@immich/sdk';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { basename, join } from 'node:path';
|
||||
import { Socket } from 'socket.io-client';
|
||||
import { createUserDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, testAssetDir, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
@@ -10,48 +9,28 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/map', () => {
|
||||
let websocket: Socket;
|
||||
let partnerWebsocket: Socket;
|
||||
let admin: LoginResponseDto;
|
||||
let partner: LoginResponseDto;
|
||||
let partnerArchivedAssetId: string;
|
||||
let adminArchivedAssetId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup({ onboarding: false });
|
||||
partner = await utils.userSetup(admin.accessToken, createUserDto.user1);
|
||||
|
||||
websocket = await utils.connectWebsocket(admin.accessToken);
|
||||
partnerWebsocket = await utils.connectWebsocket(partner.accessToken);
|
||||
|
||||
const adminFiles = ['formats/heic/IMG_2682.heic', 'metadata/gps-position/thompson-springs.jpg'];
|
||||
const adminArchivedFile = 'metadata/dates/datetimeoriginal-gps.jpg';
|
||||
const partnerFile = 'metadata/gps-position/thompson-springs.jpg';
|
||||
const files = ['formats/heic/IMG_2682.heic', 'metadata/gps-position/thompson-springs.jpg'];
|
||||
utils.resetEvents();
|
||||
const uploadFile = async (accessToken: string, input: string) => {
|
||||
const uploadFile = async (input: string) => {
|
||||
const filepath = join(testAssetDir, input);
|
||||
const { id } = await utils.createAsset(accessToken, {
|
||||
const { id } = await utils.createAsset(admin.accessToken, {
|
||||
assetData: { bytes: await readFile(filepath), filename: basename(filepath) },
|
||||
});
|
||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id });
|
||||
return id;
|
||||
};
|
||||
await Promise.all(adminFiles.map((f) => uploadFile(admin.accessToken, f)));
|
||||
[adminArchivedAssetId, partnerArchivedAssetId] = await Promise.all([
|
||||
uploadFile(admin.accessToken, adminArchivedFile),
|
||||
uploadFile(partner.accessToken, partnerFile),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
utils.archiveAssets(admin.accessToken, [adminArchivedAssetId]),
|
||||
utils.archiveAssets(partner.accessToken, [partnerArchivedAssetId]),
|
||||
utils.createPartner(partner.accessToken, admin.userId),
|
||||
]);
|
||||
await Promise.all(files.map((f) => uploadFile(f)));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
utils.disconnectWebsocket(websocket);
|
||||
utils.disconnectWebsocket(partnerWebsocket);
|
||||
});
|
||||
|
||||
describe('GET /map/markers', () => {
|
||||
@@ -61,6 +40,7 @@ describe('/map', () => {
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
// TODO archive one of these assets
|
||||
it('should get map markers for all non-archived assets', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/map/markers')
|
||||
@@ -89,28 +69,7 @@ describe('/map', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not expose partner archived asset locations', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/map/markers')
|
||||
.query({ withPartners: true, isArchived: true })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
const ids = body.map((m: { id: string }) => m.id);
|
||||
expect(ids).not.toContain(partnerArchivedAssetId);
|
||||
expect(ids).toContain(adminArchivedAssetId);
|
||||
});
|
||||
|
||||
it('should include own archived asset locations', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/map/markers')
|
||||
.query({ isArchived: true })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.map((m: { id: string }) => m.id)).toContain(adminArchivedAssetId);
|
||||
});
|
||||
|
||||
// TODO archive one of these assets
|
||||
it('should get all map markers', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/map/markers')
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -15,7 +15,6 @@ const Map<String, Locale> locales = {
|
||||
'Czech (cs)': Locale('cs'),
|
||||
'Danish (da)': Locale('da'),
|
||||
'Dutch (nl)': Locale('nl'),
|
||||
'English (United Kingdom) (en_GB)': Locale('en', 'GB'),
|
||||
'Estonian (et)': Locale('et'),
|
||||
'Filipino (tl)': Locale('tl'),
|
||||
'Finnish (fi)': Locale('fi'),
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
String? getVersionCompatibilityMessage(int _, int appMinor, int _, int serverMinor) {
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
|
||||
String? getVersionCompatibilityMessage(SemVer serverVersion, SemVer appVersion) {
|
||||
// Add latest compat info up top
|
||||
if (serverMinor < 106 && appMinor >= 106) {
|
||||
return 'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login';
|
||||
|
||||
// ensure mobile app major version is not behind server major version
|
||||
if (appVersion.major < serverVersion.major) {
|
||||
return 'Your mobile app version is not compatible with the server! Please update your mobile app to the latest version.';
|
||||
}
|
||||
|
||||
// ensure mobile app major version is not ahead of server major version by more than 1 major version
|
||||
if (appVersion.major > serverVersion.major + 1) {
|
||||
return 'Your server version is not compatible with the mobile app! Please update your server to the latest version.';
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -26,6 +26,7 @@ import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/provider_utils.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
import 'package:immich_mobile/utils/version_compatibility.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_logo.dart';
|
||||
@@ -88,18 +89,9 @@ class LoginForm extends HookConsumerWidget {
|
||||
checkVersionMismatch() async {
|
||||
try {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
final appVersion = packageInfo.version;
|
||||
final appMajorVersion = int.parse(appVersion.split('.')[0]);
|
||||
final appMinorVersion = int.parse(appVersion.split('.')[1]);
|
||||
final serverMajorVersion = serverInfo.serverVersion.major;
|
||||
final serverMinorVersion = serverInfo.serverVersion.minor;
|
||||
|
||||
warningMessage.value = getVersionCompatibilityMessage(
|
||||
appMajorVersion,
|
||||
appMinorVersion,
|
||||
serverMajorVersion,
|
||||
serverMinorVersion,
|
||||
);
|
||||
final appSemVer = SemVer.fromString(packageInfo.version);
|
||||
final serverSemVer = serverInfo.serverVersion;
|
||||
warningMessage.value = getVersionCompatibilityMessage(appSemVer, serverSemVer);
|
||||
} catch (error) {
|
||||
warningMessage.value = 'Error checking version compatibility';
|
||||
}
|
||||
|
||||
@@ -1,29 +1,47 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:immich_mobile/utils/version_compatibility.dart';
|
||||
|
||||
void main() {
|
||||
test('getVersionCompatibilityMessage', () {
|
||||
String? result;
|
||||
group('app major version behind server', () {
|
||||
const message =
|
||||
'Your mobile app version is not compatible with the server! Please update your mobile app to the latest version.';
|
||||
|
||||
result = getVersionCompatibilityMessage(1, 106, 1, 105);
|
||||
expect(
|
||||
result,
|
||||
'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login',
|
||||
);
|
||||
test('returns message when app major is behind server major', () {
|
||||
final result = getVersionCompatibilityMessage(
|
||||
const SemVer(major: 2, minor: 0, patch: 0),
|
||||
const SemVer(major: 1, minor: 200, patch: 0),
|
||||
);
|
||||
expect(result, message);
|
||||
});
|
||||
|
||||
result = getVersionCompatibilityMessage(1, 107, 1, 105);
|
||||
expect(
|
||||
result,
|
||||
'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login',
|
||||
);
|
||||
test('returns null when app major matches server major', () {
|
||||
final result = getVersionCompatibilityMessage(
|
||||
const SemVer(major: 2, minor: 0, patch: 0),
|
||||
const SemVer(major: 2, minor: 0, patch: 0),
|
||||
);
|
||||
expect(result, null);
|
||||
});
|
||||
});
|
||||
|
||||
result = getVersionCompatibilityMessage(1, 106, 1, 106);
|
||||
expect(result, null);
|
||||
group('app major version too far ahead of server', () {
|
||||
const message =
|
||||
'Your server version is not compatible with the mobile app! Please update your server to the latest version.';
|
||||
|
||||
result = getVersionCompatibilityMessage(1, 107, 1, 106);
|
||||
expect(result, null);
|
||||
test('returns message when app major is more than one ahead of server', () {
|
||||
final result = getVersionCompatibilityMessage(
|
||||
const SemVer(major: 1, minor: 200, patch: 0),
|
||||
const SemVer(major: 3, minor: 0, patch: 0),
|
||||
);
|
||||
expect(result, message);
|
||||
});
|
||||
|
||||
result = getVersionCompatibilityMessage(1, 107, 1, 108);
|
||||
expect(result, null);
|
||||
test('returns null when app major is exactly one ahead of server', () {
|
||||
final result = getVersionCompatibilityMessage(
|
||||
const SemVer(major: 1, minor: 200, patch: 0),
|
||||
const SemVer(major: 2, minor: 0, patch: 0),
|
||||
);
|
||||
expect(result, null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,9 +78,8 @@ export class MapRepository {
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID], [DummyValue.UUID]] })
|
||||
@GenerateSql({ params: [[DummyValue.UUID], [DummyValue.UUID]] })
|
||||
getMapMarkers(
|
||||
authUserId: string,
|
||||
ownerIds: string[],
|
||||
albumIds: string[],
|
||||
{ isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore }: MapMarkerSearchOptions = {},
|
||||
@@ -90,7 +89,7 @@ export class MapRepository {
|
||||
qb.where((eb) =>
|
||||
eb.or([
|
||||
eb('asset.visibility', '=', AssetVisibility.Timeline),
|
||||
eb.and([eb('asset.ownerId', '=', authUserId), eb('asset.visibility', '=', AssetVisibility.Archive)]),
|
||||
eb('asset.visibility', '=', AssetVisibility.Archive),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -59,7 +59,6 @@ describe(MapService.name, () => {
|
||||
const markers = await sut.getMapMarkers(auth, { withPartners: true });
|
||||
|
||||
expect(mocks.map.getMapMarkers).toHaveBeenCalledWith(
|
||||
auth.user.id,
|
||||
[auth.user.id, partner.sharedById],
|
||||
expect.arrayContaining([]),
|
||||
{ withPartners: true },
|
||||
|
||||
@@ -15,7 +15,7 @@ export class MapService extends BaseService {
|
||||
|
||||
const albumIds = options.withSharedAlbums ? await this.albumRepository.getAllIds(auth.user.id) : [];
|
||||
|
||||
return this.mapRepository.getMapMarkers(auth.user.id, userIds, albumIds, options);
|
||||
return this.mapRepository.getMapMarkers(userIds, albumIds, options);
|
||||
}
|
||||
|
||||
async reverseGeocode(dto: MapReverseGeocodeDto) {
|
||||
|
||||
@@ -261,6 +261,10 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if show.brokenAsset}
|
||||
<BrokenAsset class="absolute size-full text-xl" />
|
||||
{/if}
|
||||
|
||||
{#if show.preview}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
@@ -286,10 +290,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if show.brokenAsset}
|
||||
<BrokenAsset class="absolute inset-0 z-10 size-full text-xl" />
|
||||
{/if}
|
||||
|
||||
{#if overlays}
|
||||
<div class="pointer-events-none absolute inset-0">
|
||||
{@render overlays()}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto, getAssetInfo } from '@immich/sdk';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -82,7 +82,7 @@
|
||||
$effect(() => {
|
||||
const asset = assetViewerManager.asset;
|
||||
if (asset) {
|
||||
handlePromiseError(loadCloseAssets(asset));
|
||||
untrack(() => handlePromiseError(loadCloseAssets(asset)));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { SearchOptions } from '$lib/utils/dipatch';
|
||||
import { IconButton, LoadingSpinner } from '@immich/ui';
|
||||
import { mdiClose, mdiMagnify } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -8,7 +9,7 @@
|
||||
roundedBottom?: boolean;
|
||||
showLoadingSpinner: boolean;
|
||||
placeholder: string;
|
||||
onSearch?: (options: { force?: boolean }) => void;
|
||||
onSearch?: (options: SearchOptions) => void;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ class AssetViewerManager extends BaseEventManager<Events> {
|
||||
imageLoaderStatus = $state<ImageLoaderStatus | undefined>();
|
||||
#isImageLoading = $derived.by(() => {
|
||||
const quality = this.imageLoaderStatus?.quality;
|
||||
if (!quality || this.imageLoaderStatus?.hasError) {
|
||||
if (!quality) {
|
||||
return false;
|
||||
}
|
||||
const previewOrOriginalReady = quality.preview === 'success' || quality.original === 'success';
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface ResetOptions {
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
force?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { getExifCount } from '$lib/utils/exif-utils';
|
||||
|
||||
describe('getting the exif count', () => {
|
||||
it('returns 0 when exifInfo is undefined', () => {
|
||||
const asset = {};
|
||||
expect(getExifCount(asset as AssetResponseDto)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 when exifInfo is empty', () => {
|
||||
const asset = { exifInfo: {} };
|
||||
expect(getExifCount(asset as AssetResponseDto)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns the correct count of non-null exifInfo properties', () => {
|
||||
const asset = { exifInfo: { fileSizeInByte: 200, rating: 5, fNumber: null } };
|
||||
expect(getExifCount(asset as AssetResponseDto)).toBe(2);
|
||||
});
|
||||
|
||||
it('ignores null, undefined and empty properties in exifInfo', () => {
|
||||
const asset = { exifInfo: { fileSizeInByte: 200, rating: null, fNumber: undefined, description: '' } };
|
||||
expect(getExifCount(asset as AssetResponseDto)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns the correct count when all exifInfo properties are non-null', () => {
|
||||
const asset = { exifInfo: { fileSizeInByte: 200, rating: 5, fNumber: 1, description: 'test' } };
|
||||
expect(getExifCount(asset as AssetResponseDto)).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
export const getExifCount = (asset: AssetResponseDto) => {
|
||||
return Object.values(asset.exifInfo ?? {}).filter(Boolean).length;
|
||||
};
|
||||
@@ -13,6 +13,38 @@ export interface PlacesGroup {
|
||||
places: AssetResponseDto[];
|
||||
}
|
||||
|
||||
export interface PlacesGroupOptionMetadata {
|
||||
id: PlacesGroupBy;
|
||||
isDisabled: () => boolean;
|
||||
}
|
||||
|
||||
export const groupOptionsMetadata: PlacesGroupOptionMetadata[] = [
|
||||
{
|
||||
id: PlacesGroupBy.None,
|
||||
isDisabled: () => false,
|
||||
},
|
||||
{
|
||||
id: PlacesGroupBy.Country,
|
||||
isDisabled: () => false,
|
||||
},
|
||||
];
|
||||
|
||||
export const findGroupOptionMetadata = (groupBy: string) => {
|
||||
// Default is no grouping
|
||||
const defaultGroupOption = groupOptionsMetadata[0];
|
||||
return groupOptionsMetadata.find(({ id }) => groupBy === id) ?? defaultGroupOption;
|
||||
};
|
||||
|
||||
export const getSelectedPlacesGroupOption = (settings: PlacesViewSettings) => {
|
||||
const defaultGroupOption = PlacesGroupBy.None;
|
||||
const albumGroupOption = settings.groupBy ?? defaultGroupOption;
|
||||
|
||||
if (findGroupOptionMetadata(albumGroupOption).isDisabled()) {
|
||||
return defaultGroupOption;
|
||||
}
|
||||
return albumGroupOption;
|
||||
};
|
||||
|
||||
/**
|
||||
* ----------------------------
|
||||
* Places Groups Collapse/Expand
|
||||
|
||||
@@ -5,3 +5,13 @@ export const removeAccents = (str: string) => {
|
||||
export const normalizeSearchString = (str: string) => {
|
||||
return removeAccents(str.toLocaleLowerCase());
|
||||
};
|
||||
|
||||
export const buildDateString = (year: number, month?: number, day?: number) => {
|
||||
return [
|
||||
year.toString(),
|
||||
month && !Number.isNaN(month) ? month.toString() : undefined,
|
||||
day && !Number.isNaN(day) ? day.toString() : undefined,
|
||||
]
|
||||
.filter((date) => date !== undefined)
|
||||
.join('-');
|
||||
};
|
||||
|
||||
@@ -30,6 +30,9 @@ export const getTriggerDescription = ($t: MessageFormatter, type: WorkflowTrigge
|
||||
case WorkflowTrigger.AssetMetadataExtraction: {
|
||||
return $t('trigger_asset_metadata_extraction_description');
|
||||
}
|
||||
default: {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,6 +79,10 @@ export const getWorkflowDefaultConfig = (schema: JSONSchemaProperty) => {
|
||||
config[key] = property.properties ? getWorkflowDefaultConfig(property) : {};
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
console.log(`Unknown configuration type: ${property.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
<script lang="ts">
|
||||
import Dropdown from '$lib/elements/Dropdown.svelte';
|
||||
import SearchBar from '$lib/elements/SearchBar.svelte';
|
||||
import { PlacesGroupBy, placesViewSettings } from '$lib/stores/preferences.store';
|
||||
import { collapseAllPlacesGroups, expandAllPlacesGroups } from '$lib/utils/places-utils';
|
||||
import { IconButton, Select } from '@immich/ui';
|
||||
import { mdiUnfoldLessHorizontal, mdiUnfoldMoreHorizontal } from '@mdi/js';
|
||||
import {
|
||||
type PlacesGroupOptionMetadata,
|
||||
collapseAllPlacesGroups,
|
||||
expandAllPlacesGroups,
|
||||
findGroupOptionMetadata,
|
||||
getSelectedPlacesGroupOption,
|
||||
groupOptionsMetadata,
|
||||
} from '$lib/utils/places-utils';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import {
|
||||
mdiFolderArrowUpOutline,
|
||||
mdiFolderRemoveOutline,
|
||||
mdiUnfoldLessHorizontal,
|
||||
mdiUnfoldMoreHorizontal,
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
placesGroups: string[];
|
||||
@@ -14,26 +27,48 @@
|
||||
|
||||
let { placesGroups, searchQuery = $bindable() }: Props = $props();
|
||||
|
||||
let options = $derived([
|
||||
{ value: PlacesGroupBy.None, label: $t('group_no') },
|
||||
{ value: PlacesGroupBy.Country, label: $t('group_country') },
|
||||
]);
|
||||
const handleChangeGroupBy = ({ id }: PlacesGroupOptionMetadata) => {
|
||||
$placesViewSettings.groupBy = id;
|
||||
};
|
||||
|
||||
let groupIcon = $derived.by(() => {
|
||||
return selectedGroupOption.id === PlacesGroupBy.None ? mdiFolderRemoveOutline : mdiFolderArrowUpOutline; // OR mdiFolderArrowDownOutline
|
||||
});
|
||||
|
||||
let selectedGroupOption = $derived(findGroupOptionMetadata($placesViewSettings.groupBy));
|
||||
|
||||
let placesGroupByNames: Record<PlacesGroupBy, string> = $derived({
|
||||
[PlacesGroupBy.None]: $t('group_no'),
|
||||
[PlacesGroupBy.Country]: $t('group_country'),
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Search Places -->
|
||||
<div class="hidden h-10 md:block xl:w-60 2xl:w-80">
|
||||
<SearchBar placeholder={$t('search_places')} bind:name={searchQuery} showLoadingSpinner={false} />
|
||||
</div>
|
||||
|
||||
<div title={$t('group_places_by')}>
|
||||
<Select {options} bind:value={$placesViewSettings.groupBy} class="w-fit min-w-50" />
|
||||
</div>
|
||||
<!-- Group Places -->
|
||||
<Dropdown
|
||||
position="bottom-right"
|
||||
title={$t('group_places_by')}
|
||||
options={Object.values(groupOptionsMetadata)}
|
||||
selectedOption={selectedGroupOption}
|
||||
onSelect={handleChangeGroupBy}
|
||||
render={({ id, isDisabled }) => ({
|
||||
title: placesGroupByNames[id],
|
||||
icon: groupIcon,
|
||||
disabled: isDisabled(),
|
||||
})}
|
||||
/>
|
||||
|
||||
{#if $placesViewSettings.groupBy !== PlacesGroupBy.None}
|
||||
<span transition:slide={{ axis: 'x', duration: 250 }}>
|
||||
{#if getSelectedPlacesGroupOption($placesViewSettings) !== PlacesGroupBy.None}
|
||||
<span in:fly={{ x: -50, duration: 250 }}>
|
||||
<!-- Expand Countries Groups -->
|
||||
<div class="hidden gap-0 xl:flex">
|
||||
<div class="block">
|
||||
<IconButton
|
||||
title={$t('expand_all')}
|
||||
onclick={() => expandAllPlacesGroups()}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
@@ -46,6 +81,7 @@
|
||||
<!-- Collapse Countries Groups -->
|
||||
<div class="block">
|
||||
<IconButton
|
||||
title={$t('collapse_all')}
|
||||
onclick={() => collapseAllPlacesGroups(placesGroups)}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { groupBy } from 'lodash-es';
|
||||
import PlacesCardGroup from './PlacesCardGroup.svelte';
|
||||
|
||||
import { type PlacesGroup } from '$lib/utils/places-utils';
|
||||
import { type PlacesGroup, getSelectedPlacesGroupOption } from '$lib/utils/places-utils';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -78,8 +78,9 @@
|
||||
: places;
|
||||
});
|
||||
|
||||
const groupingFunction = $derived(groupOptions[userSettings.groupBy] ?? groupOptions[PlacesGroupBy.None]);
|
||||
const groupedPlaces = $derived(groupingFunction(filteredPlaces));
|
||||
const placesGroupOption: string = $derived(getSelectedPlacesGroupOption(userSettings));
|
||||
const groupingFunction = $derived(groupOptions[placesGroupOption] ?? groupOptions[PlacesGroupBy.None]);
|
||||
const groupedPlaces: PlacesGroup[] = $derived(groupingFunction(filteredPlaces));
|
||||
|
||||
$effect(() => {
|
||||
searchResultCount = filteredPlaces.length;
|
||||
@@ -92,7 +93,7 @@
|
||||
|
||||
{#if places.length > 0}
|
||||
<!-- Album Cards -->
|
||||
{#if userSettings.groupBy === PlacesGroupBy.None}
|
||||
{#if placesGroupOption === PlacesGroupBy.None}
|
||||
<PlacesCardGroup places={groupedPlaces[0].places} />
|
||||
{:else}
|
||||
{#each groupedPlaces as placeGroup (placeGroup.id)}
|
||||
|
||||
+3
-10
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { lang, locale } from '$lib/stores/preferences.store';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { getAllMetadataItems, type DifferingMetadataFields } from '$lib/utils/duplicate-utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
@@ -30,8 +30,7 @@
|
||||
initialVisibleCount = 5,
|
||||
}: Props = $props();
|
||||
|
||||
const listFormat = $derived(new Intl.ListFormat($lang));
|
||||
const isFromExternalLibrary = $derived(!!asset.libraryId);
|
||||
let isFromExternalLibrary = $derived(!!asset.libraryId);
|
||||
|
||||
const visibleMetadataItems = $derived(
|
||||
getAllMetadataItems(asset, $t, $locale)
|
||||
@@ -117,13 +116,7 @@
|
||||
{#await getAllAlbums({ assetId: asset.id })}
|
||||
{$t('scanning_for_album')}
|
||||
{:then albums}
|
||||
{#if albums.length === 1}
|
||||
{albums[0].albumName}
|
||||
{:else}
|
||||
<span title={listFormat.format(albums.map(({ albumName }) => albumName))}>
|
||||
{$t('in_albums', { values: { count: albums.length } })}
|
||||
</span>
|
||||
{/if}
|
||||
{$t('in_albums', { values: { count: albums.length } })}
|
||||
{/await}
|
||||
</InfoRow>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
}
|
||||
uploadAssetsStore.reset();
|
||||
}}
|
||||
class="fixed inset-e-16 bottom-6 z-60"
|
||||
class="fixed inset-e-16 bottom-6"
|
||||
>
|
||||
{#if showDetail}
|
||||
<div
|
||||
|
||||
@@ -141,11 +141,11 @@
|
||||
<Alert color="danger" title={errorMessage} closable />
|
||||
{/if}
|
||||
|
||||
<Field label={$t('email')} required="indicator">
|
||||
<Field label={$t('email')}>
|
||||
<Input id="email" name="email" type="email" autocomplete="email" bind:value={email} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('password')} required="indicator">
|
||||
<Field label={$t('password')}>
|
||||
<PasswordInput id="password" bind:value={password} autocomplete="current-password" />
|
||||
</Field>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user