mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
merge: remote-tracking branch 'origin/main' into feat/integrity-checks-izzy
This commit is contained in:
@@ -131,7 +131,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1
|
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
|
||||||
|
|
||||||
- name: Load parameters
|
- name: Load parameters
|
||||||
id: parameters
|
id: parameters
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1
|
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
|
||||||
|
|
||||||
- name: Destroy Docs Subdomain
|
- name: Destroy Docs Subdomain
|
||||||
env:
|
env:
|
||||||
|
|||||||
Generated
+6
@@ -82,6 +82,8 @@ class Permission {
|
|||||||
static const timelinePeriodRead = Permission._(r'timeline.read');
|
static const timelinePeriodRead = Permission._(r'timeline.read');
|
||||||
static const timelinePeriodDownload = Permission._(r'timeline.download');
|
static const timelinePeriodDownload = Permission._(r'timeline.download');
|
||||||
static const maintenance = Permission._(r'maintenance');
|
static const maintenance = Permission._(r'maintenance');
|
||||||
|
static const mapPeriodRead = Permission._(r'map.read');
|
||||||
|
static const mapPeriodSearch = Permission._(r'map.search');
|
||||||
static const memoryPeriodCreate = Permission._(r'memory.create');
|
static const memoryPeriodCreate = Permission._(r'memory.create');
|
||||||
static const memoryPeriodRead = Permission._(r'memory.read');
|
static const memoryPeriodRead = Permission._(r'memory.read');
|
||||||
static const memoryPeriodUpdate = Permission._(r'memory.update');
|
static const memoryPeriodUpdate = Permission._(r'memory.update');
|
||||||
@@ -238,6 +240,8 @@ class Permission {
|
|||||||
timelinePeriodRead,
|
timelinePeriodRead,
|
||||||
timelinePeriodDownload,
|
timelinePeriodDownload,
|
||||||
maintenance,
|
maintenance,
|
||||||
|
mapPeriodRead,
|
||||||
|
mapPeriodSearch,
|
||||||
memoryPeriodCreate,
|
memoryPeriodCreate,
|
||||||
memoryPeriodRead,
|
memoryPeriodRead,
|
||||||
memoryPeriodUpdate,
|
memoryPeriodUpdate,
|
||||||
@@ -429,6 +433,8 @@ class PermissionTypeTransformer {
|
|||||||
case r'timeline.read': return Permission.timelinePeriodRead;
|
case r'timeline.read': return Permission.timelinePeriodRead;
|
||||||
case r'timeline.download': return Permission.timelinePeriodDownload;
|
case r'timeline.download': return Permission.timelinePeriodDownload;
|
||||||
case r'maintenance': return Permission.maintenance;
|
case r'maintenance': return Permission.maintenance;
|
||||||
|
case r'map.read': return Permission.mapPeriodRead;
|
||||||
|
case r'map.search': return Permission.mapPeriodSearch;
|
||||||
case r'memory.create': return Permission.memoryPeriodCreate;
|
case r'memory.create': return Permission.memoryPeriodCreate;
|
||||||
case r'memory.read': return Permission.memoryPeriodRead;
|
case r'memory.read': return Permission.memoryPeriodRead;
|
||||||
case r'memory.update': return Permission.memoryPeriodUpdate;
|
case r'memory.update': return Permission.memoryPeriodUpdate;
|
||||||
|
|||||||
@@ -6574,6 +6574,7 @@
|
|||||||
"state": "Stable"
|
"state": "Stable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"x-immich-permission": "map.read",
|
||||||
"x-immich-state": "Stable"
|
"x-immich-state": "Stable"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -6645,6 +6646,7 @@
|
|||||||
"state": "Stable"
|
"state": "Stable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"x-immich-permission": "map.search",
|
||||||
"x-immich-state": "Stable"
|
"x-immich-state": "Stable"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -19348,6 +19350,8 @@
|
|||||||
"timeline.read",
|
"timeline.read",
|
||||||
"timeline.download",
|
"timeline.download",
|
||||||
"maintenance",
|
"maintenance",
|
||||||
|
"map.read",
|
||||||
|
"map.search",
|
||||||
"memory.create",
|
"memory.create",
|
||||||
"memory.read",
|
"memory.read",
|
||||||
"memory.update",
|
"memory.update",
|
||||||
|
|||||||
@@ -5639,6 +5639,8 @@ export enum Permission {
|
|||||||
TimelineRead = "timeline.read",
|
TimelineRead = "timeline.read",
|
||||||
TimelineDownload = "timeline.download",
|
TimelineDownload = "timeline.download",
|
||||||
Maintenance = "maintenance",
|
Maintenance = "maintenance",
|
||||||
|
MapRead = "map.read",
|
||||||
|
MapSearch = "map.search",
|
||||||
MemoryCreate = "memory.create",
|
MemoryCreate = "memory.create",
|
||||||
MemoryRead = "memory.read",
|
MemoryRead = "memory.read",
|
||||||
MemoryUpdate = "memory.update",
|
MemoryUpdate = "memory.update",
|
||||||
|
|||||||
@@ -38,11 +38,6 @@
|
|||||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Warnung
|
|
||||||
|
|
||||||
- ⚠️ Das Projekt befindet sich in **sehr aktiver** Entwicklung.
|
|
||||||
- ⚠️ Gehe von möglichen Fehlern und von Änderungen mit Breaking-Changes aus.
|
|
||||||
- ⚠️ **Nutze die App auf keinen Fall als einziges Speichermedium für deine Fotos und Videos.**
|
|
||||||
- ⚠️ Befolge immer die [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) Backup-Regel für deine wertvollen Fotos und Videos!
|
- ⚠️ Befolge immer die [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) Backup-Regel für deine wertvollen Fotos und Videos!
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
@@ -62,7 +57,7 @@
|
|||||||
|
|
||||||
## Demo
|
## Demo
|
||||||
|
|
||||||
Die Web-Demo kannst Du unter https://demo.immich.app finden. Für die Handy-App kannst Du `https://demo.immich.app` als `Server Endpoint URL` angeben.
|
Die Web-Demo kannst Du unter https://demo.immich.app finden. Für die Smartphone-App kannst Du `https://demo.immich.app` als `Server Endpoint URL` angeben.
|
||||||
|
|
||||||
### Login Daten
|
### Login Daten
|
||||||
|
|
||||||
@@ -93,7 +88,7 @@ Die Web-Demo kannst Du unter https://demo.immich.app finden. Für die Handy-App
|
|||||||
| LivePhoto/MotionPhoto Sicherung und Wiedergabe | Ja | Ja |
|
| LivePhoto/MotionPhoto Sicherung und Wiedergabe | Ja | Ja |
|
||||||
| Unterstützung für 360-Grad-Bilder | Nein | Ja |
|
| Unterstützung für 360-Grad-Bilder | Nein | Ja |
|
||||||
| Benutzerdefinierte Speicherstruktur | Ja | Ja |
|
| Benutzerdefinierte Speicherstruktur | Ja | Ja |
|
||||||
| Öffentliches Teilen | Nein | Ja |
|
| Öffentliches Teilen | Ja | Ja |
|
||||||
| Archiv und Favoriten | Ja | Ja |
|
| Archiv und Favoriten | Ja | Ja |
|
||||||
| Globale Karte | Ja | Ja |
|
| Globale Karte | Ja | Ja |
|
||||||
| Partnerfreigabe (Teilen) | Ja | Ja |
|
| Partnerfreigabe (Teilen) | Ja | Ja |
|
||||||
@@ -103,7 +98,7 @@ Die Web-Demo kannst Du unter https://demo.immich.app finden. Für die Handy-App
|
|||||||
| Schreibgeschützte Gallerie | Ja | Ja |
|
| Schreibgeschützte Gallerie | Ja | Ja |
|
||||||
| Gestapelte Bilder | Ja | Ja |
|
| Gestapelte Bilder | Ja | Ja |
|
||||||
| Tags | Nein | Ja |
|
| Tags | Nein | Ja |
|
||||||
| Ordner-Ansicht | Nein | Ja |
|
| Ordner-Ansicht | Ja | Ja |
|
||||||
|
|
||||||
|
|
||||||
## Übersetzungen
|
## Übersetzungen
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
MapReverseGeocodeDto,
|
MapReverseGeocodeDto,
|
||||||
MapReverseGeocodeResponseDto,
|
MapReverseGeocodeResponseDto,
|
||||||
} from 'src/dtos/map.dto';
|
} from 'src/dtos/map.dto';
|
||||||
import { ApiTag } from 'src/enum';
|
import { ApiTag, Permission } from 'src/enum';
|
||||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||||
import { MapService } from 'src/services/map.service';
|
import { MapService } from 'src/services/map.service';
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ export class MapController {
|
|||||||
constructor(private service: MapService) {}
|
constructor(private service: MapService) {}
|
||||||
|
|
||||||
@Get('markers')
|
@Get('markers')
|
||||||
@Authenticated()
|
@Authenticated({ permission: Permission.MapRead })
|
||||||
@Endpoint({
|
@Endpoint({
|
||||||
summary: 'Retrieve map markers',
|
summary: 'Retrieve map markers',
|
||||||
description: 'Retrieve a list of latitude and longitude coordinates for every asset with location data.',
|
description: 'Retrieve a list of latitude and longitude coordinates for every asset with location data.',
|
||||||
@@ -28,8 +28,8 @@ export class MapController {
|
|||||||
return this.service.getMapMarkers(auth, options);
|
return this.service.getMapMarkers(auth, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Authenticated()
|
|
||||||
@Get('reverse-geocode')
|
@Get('reverse-geocode')
|
||||||
|
@Authenticated({ permission: Permission.MapSearch })
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Endpoint({
|
@Endpoint({
|
||||||
summary: 'Reverse geocode coordinates',
|
summary: 'Reverse geocode coordinates',
|
||||||
|
|||||||
@@ -160,6 +160,9 @@ export enum Permission {
|
|||||||
|
|
||||||
Maintenance = 'maintenance',
|
Maintenance = 'maintenance',
|
||||||
|
|
||||||
|
MapRead = 'map.read',
|
||||||
|
MapSearch = 'map.search',
|
||||||
|
|
||||||
MemoryCreate = 'memory.create',
|
MemoryCreate = 'memory.create',
|
||||||
MemoryRead = 'memory.read',
|
MemoryRead = 'memory.read',
|
||||||
MemoryUpdate = 'memory.update',
|
MemoryUpdate = 'memory.update',
|
||||||
|
|||||||
@@ -206,15 +206,15 @@ describe(TagService.name, () => {
|
|||||||
count: 6,
|
count: 6,
|
||||||
});
|
});
|
||||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||||
{ assetId: 'asset-1', tags: ['tag-1', 'tag-2'] },
|
{ assetId: 'asset-1', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] },
|
||||||
{ lockedPropertiesBehavior: 'append' },
|
{ lockedPropertiesBehavior: 'append' },
|
||||||
);
|
);
|
||||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||||
{ assetId: 'asset-2', tags: ['tag-1', 'tag-2'] },
|
{ assetId: 'asset-2', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] },
|
||||||
{ lockedPropertiesBehavior: 'append' },
|
{ lockedPropertiesBehavior: 'append' },
|
||||||
);
|
);
|
||||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||||
{ assetId: 'asset-3', tags: ['tag-1', 'tag-2'] },
|
{ assetId: 'asset-3', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] },
|
||||||
{ lockedPropertiesBehavior: 'append' },
|
{ lockedPropertiesBehavior: 'append' },
|
||||||
);
|
);
|
||||||
expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([
|
expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([
|
||||||
@@ -255,11 +255,11 @@ describe(TagService.name, () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
expect(mocks.asset.upsertExif).not.toHaveBeenCalledWith(
|
expect(mocks.asset.upsertExif).not.toHaveBeenCalledWith(
|
||||||
{ assetId: 'asset-1', tags: ['tag-1'] },
|
{ assetId: 'asset-1', lockedProperties: ['tags'], tags: ['tag-1'] },
|
||||||
{ lockedPropertiesBehavior: 'append' },
|
{ lockedPropertiesBehavior: 'append' },
|
||||||
);
|
);
|
||||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||||
{ assetId: 'asset-2', tags: ['tag-1'] },
|
{ assetId: 'asset-2', lockedProperties: ['tags'], tags: ['tag-1'] },
|
||||||
{ lockedPropertiesBehavior: 'append' },
|
{ lockedPropertiesBehavior: 'append' },
|
||||||
);
|
);
|
||||||
expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
|
expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { JobName, JobStatus, Permission, QueueName } from 'src/enum';
|
|||||||
import { TagAssetTable } from 'src/schema/tables/tag-asset.table';
|
import { TagAssetTable } from 'src/schema/tables/tag-asset.table';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { addAssets, removeAssets } from 'src/utils/asset.util';
|
import { addAssets, removeAssets } from 'src/utils/asset.util';
|
||||||
|
import { updateLockedColumns } from 'src/utils/database';
|
||||||
import { upsertTags } from 'src/utils/tag';
|
import { upsertTags } from 'src/utils/tag';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -152,7 +153,7 @@ export class TagService extends BaseService {
|
|||||||
private async updateTags(assetId: string) {
|
private async updateTags(assetId: string) {
|
||||||
const asset = await this.assetRepository.getById(assetId, { tags: true });
|
const asset = await this.assetRepository.getById(assetId, { tags: true });
|
||||||
await this.assetRepository.upsertExif(
|
await this.assetRepository.upsertExif(
|
||||||
{ assetId, tags: asset?.tags?.map(({ value }) => value) ?? [] },
|
updateLockedColumns({ assetId, tags: asset?.tags?.map(({ value }) => value) ?? [] }),
|
||||||
{ lockedPropertiesBehavior: 'append' },
|
{ lockedPropertiesBehavior: 'append' },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { Kysely } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
import { JobStatus } from 'src/enum';
|
import { JobStatus } from 'src/enum';
|
||||||
import { AccessRepository } from 'src/repositories/access.repository';
|
import { AccessRepository } from 'src/repositories/access.repository';
|
||||||
|
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||||
|
import { EventRepository } from 'src/repositories/event.repository';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { TagRepository } from 'src/repositories/tag.repository';
|
import { TagRepository } from 'src/repositories/tag.repository';
|
||||||
import { DB } from 'src/schema';
|
import { DB } from 'src/schema';
|
||||||
import { TagService } from 'src/services/tag.service';
|
import { TagService } from 'src/services/tag.service';
|
||||||
import { upsertTags } from 'src/utils/tag';
|
import { upsertTags } from 'src/utils/tag';
|
||||||
import { newMediumService } from 'test/medium.factory';
|
import { newMediumService } from 'test/medium.factory';
|
||||||
|
import { factory } from 'test/small.factory';
|
||||||
import { getKyselyDB } from 'test/utils';
|
import { getKyselyDB } from 'test/utils';
|
||||||
|
|
||||||
let defaultDatabase: Kysely<DB>;
|
let defaultDatabase: Kysely<DB>;
|
||||||
@@ -14,8 +17,8 @@ let defaultDatabase: Kysely<DB>;
|
|||||||
const setup = (db?: Kysely<DB>) => {
|
const setup = (db?: Kysely<DB>) => {
|
||||||
return newMediumService(TagService, {
|
return newMediumService(TagService, {
|
||||||
database: db || defaultDatabase,
|
database: db || defaultDatabase,
|
||||||
real: [TagRepository, AccessRepository],
|
real: [AssetRepository, TagRepository, AccessRepository],
|
||||||
mock: [LoggingRepository],
|
mock: [EventRepository, LoggingRepository],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -24,6 +27,32 @@ beforeAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe(TagService.name, () => {
|
describe(TagService.name, () => {
|
||||||
|
describe('addAssets', () => {
|
||||||
|
it('should lock exif column', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||||
|
const { user } = await ctx.newUser();
|
||||||
|
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||||
|
const [tag] = await upsertTags(ctx.get(TagRepository), { userId: user.id, tags: ['tag-1'] });
|
||||||
|
const authDto = factory.auth({ user });
|
||||||
|
|
||||||
|
await sut.addAssets(authDto, tag.id, { ids: [asset.id] });
|
||||||
|
await expect(
|
||||||
|
ctx.database
|
||||||
|
.selectFrom('asset_exif')
|
||||||
|
.select(['lockedProperties', 'tags'])
|
||||||
|
.where('assetId', '=', asset.id)
|
||||||
|
.executeTakeFirstOrThrow(),
|
||||||
|
).resolves.toEqual({
|
||||||
|
lockedProperties: ['tags'],
|
||||||
|
tags: ['tag-1'],
|
||||||
|
});
|
||||||
|
await expect(ctx.get(TagRepository).getByValue(user.id, 'tag-1')).resolves.toEqual(
|
||||||
|
expect.objectContaining({ id: tag.id }),
|
||||||
|
);
|
||||||
|
await expect(ctx.get(TagRepository).getAssetIds(tag.id, [asset.id])).resolves.toContain(asset.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
describe('deleteEmptyTags', () => {
|
describe('deleteEmptyTags', () => {
|
||||||
it('single tag exists, not connected to any assets, and is deleted', async () => {
|
it('single tag exists, not connected to any assets, and is deleted', async () => {
|
||||||
const { sut, ctx } = setup();
|
const { sut, ctx } = setup();
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ describe('AssetViewerNavBar component', () => {
|
|||||||
preAction: () => {},
|
preAction: () => {},
|
||||||
onZoomImage: () => {},
|
onZoomImage: () => {},
|
||||||
onAction: () => {},
|
onAction: () => {},
|
||||||
onRunJob: () => {},
|
onEdit: () => {},
|
||||||
onPlaySlideshow: () => {},
|
onPlaySlideshow: () => {},
|
||||||
onClose: () => {},
|
onClose: () => {},
|
||||||
playOriginalVideo: false,
|
playOriginalVideo: false,
|
||||||
|
|||||||
@@ -28,12 +28,11 @@
|
|||||||
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
|
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
|
||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||||
import { getAssetJobName, getSharedLink, withoutIcons } from '$lib/utils';
|
import { getSharedLink, withoutIcons } from '$lib/utils';
|
||||||
import type { OnUndoDelete } from '$lib/utils/actions';
|
import type { OnUndoDelete } from '$lib/utils/actions';
|
||||||
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
|
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
|
||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import {
|
import {
|
||||||
AssetJobName,
|
|
||||||
AssetTypeEnum,
|
AssetTypeEnum,
|
||||||
AssetVisibility,
|
AssetVisibility,
|
||||||
type AlbumResponseDto,
|
type AlbumResponseDto,
|
||||||
@@ -44,13 +43,9 @@
|
|||||||
import { CommandPaletteDefaultProvider, IconButton, type ActionItem } from '@immich/ui';
|
import { CommandPaletteDefaultProvider, IconButton, type ActionItem } from '@immich/ui';
|
||||||
import {
|
import {
|
||||||
mdiArrowLeft,
|
mdiArrowLeft,
|
||||||
mdiCogRefreshOutline,
|
|
||||||
mdiCompare,
|
mdiCompare,
|
||||||
mdiContentCopy,
|
mdiContentCopy,
|
||||||
mdiDatabaseRefreshOutline,
|
|
||||||
mdiDotsVertical,
|
mdiDotsVertical,
|
||||||
mdiHeadSyncOutline,
|
|
||||||
mdiImageRefreshOutline,
|
|
||||||
mdiImageSearch,
|
mdiImageSearch,
|
||||||
mdiMagnifyMinusOutline,
|
mdiMagnifyMinusOutline,
|
||||||
mdiMagnifyPlusOutline,
|
mdiMagnifyPlusOutline,
|
||||||
@@ -71,7 +66,6 @@
|
|||||||
preAction: PreAction;
|
preAction: PreAction;
|
||||||
onAction: OnAction;
|
onAction: OnAction;
|
||||||
onUndoDelete?: OnUndoDelete;
|
onUndoDelete?: OnUndoDelete;
|
||||||
onRunJob: (name: AssetJobName) => void;
|
|
||||||
onPlaySlideshow: () => void;
|
onPlaySlideshow: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
@@ -90,7 +84,6 @@
|
|||||||
preAction,
|
preAction,
|
||||||
onAction,
|
onAction,
|
||||||
onUndoDelete = undefined,
|
onUndoDelete = undefined,
|
||||||
onRunJob,
|
|
||||||
onPlaySlideshow,
|
onPlaySlideshow,
|
||||||
onClose,
|
onClose,
|
||||||
onEdit,
|
onEdit,
|
||||||
@@ -124,6 +117,10 @@
|
|||||||
PlayMotionPhoto,
|
PlayMotionPhoto,
|
||||||
StopMotionPhoto,
|
StopMotionPhoto,
|
||||||
Info,
|
Info,
|
||||||
|
RefreshFacesJob,
|
||||||
|
RefreshMetadataJob,
|
||||||
|
RegenerateThumbnailJob,
|
||||||
|
TranscodeVideoJob,
|
||||||
} = $derived(getAssetActions($t, asset));
|
} = $derived(getAssetActions($t, asset));
|
||||||
const sharedLink = getSharedLink();
|
const sharedLink = getSharedLink();
|
||||||
|
|
||||||
@@ -140,7 +137,24 @@
|
|||||||
|
|
||||||
<CommandPaletteDefaultProvider
|
<CommandPaletteDefaultProvider
|
||||||
name={$t('assets')}
|
name={$t('assets')}
|
||||||
actions={withoutIcons([Close, Share, Offline, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto, Info])}
|
actions={withoutIcons([
|
||||||
|
Close,
|
||||||
|
Cast,
|
||||||
|
Share,
|
||||||
|
Download,
|
||||||
|
DownloadOriginal,
|
||||||
|
SharedLinkDownload,
|
||||||
|
Offline,
|
||||||
|
Favorite,
|
||||||
|
Unfavorite,
|
||||||
|
PlayMotionPhoto,
|
||||||
|
StopMotionPhoto,
|
||||||
|
Info,
|
||||||
|
RefreshFacesJob,
|
||||||
|
RefreshMetadataJob,
|
||||||
|
RegenerateThumbnailJob,
|
||||||
|
TranscodeVideoJob,
|
||||||
|
])}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -275,28 +289,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if isOwner}
|
{#if isOwner}
|
||||||
<hr />
|
<hr />
|
||||||
<MenuOption
|
<ActionMenuItem action={RefreshFacesJob} />
|
||||||
icon={mdiHeadSyncOutline}
|
<ActionMenuItem action={RefreshMetadataJob} />
|
||||||
onClick={() => onRunJob(AssetJobName.RefreshFaces)}
|
<ActionMenuItem action={RegenerateThumbnailJob} />
|
||||||
text={$getAssetJobName(AssetJobName.RefreshFaces)}
|
<ActionMenuItem action={TranscodeVideoJob} />
|
||||||
/>
|
|
||||||
<MenuOption
|
|
||||||
icon={mdiDatabaseRefreshOutline}
|
|
||||||
onClick={() => onRunJob(AssetJobName.RefreshMetadata)}
|
|
||||||
text={$getAssetJobName(AssetJobName.RefreshMetadata)}
|
|
||||||
/>
|
|
||||||
<MenuOption
|
|
||||||
icon={mdiImageRefreshOutline}
|
|
||||||
onClick={() => onRunJob(AssetJobName.RegenerateThumbnail)}
|
|
||||||
text={$getAssetJobName(AssetJobName.RegenerateThumbnail)}
|
|
||||||
/>
|
|
||||||
{#if asset.type === AssetTypeEnum.Video}
|
|
||||||
<MenuOption
|
|
||||||
icon={mdiCogRefreshOutline}
|
|
||||||
onClick={() => onRunJob(AssetJobName.TranscodeVideo)}
|
|
||||||
text={$getAssetJobName(AssetJobName.TranscodeVideo)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
|
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
|
||||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
import { getAssetJobMessage, getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils';
|
import { getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils';
|
||||||
import type { OnUndoDelete } from '$lib/utils/actions';
|
import type { OnUndoDelete } from '$lib/utils/actions';
|
||||||
import { navigateToAsset } from '$lib/utils/asset-utils';
|
import { navigateToAsset } from '$lib/utils/asset-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
@@ -28,18 +28,15 @@
|
|||||||
import { preloadImageUrl } from '$lib/utils/sw-messaging';
|
import { preloadImageUrl } from '$lib/utils/sw-messaging';
|
||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import {
|
import {
|
||||||
AssetJobName,
|
|
||||||
AssetTypeEnum,
|
AssetTypeEnum,
|
||||||
getAllAlbums,
|
getAllAlbums,
|
||||||
getAssetInfo,
|
getAssetInfo,
|
||||||
getStack,
|
getStack,
|
||||||
runAssetJobs,
|
|
||||||
type AlbumResponseDto,
|
type AlbumResponseDto,
|
||||||
type AssetResponseDto,
|
type AssetResponseDto,
|
||||||
type PersonResponseDto,
|
type PersonResponseDto,
|
||||||
type StackResponseDto,
|
type StackResponseDto,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { toastManager } from '@immich/ui';
|
|
||||||
import { onDestroy, onMount, untrack } from 'svelte';
|
import { onDestroy, onMount, untrack } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
@@ -262,15 +259,6 @@
|
|||||||
isShowEditor = !isShowEditor;
|
isShowEditor = !isShowEditor;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRunJob = async (name: AssetJobName) => {
|
|
||||||
try {
|
|
||||||
await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } });
|
|
||||||
toastManager.success($getAssetJobMessage(name));
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, $t('errors.unable_to_submit_job'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Slide show mode
|
* Slide show mode
|
||||||
*/
|
*/
|
||||||
@@ -443,6 +431,7 @@
|
|||||||
const showOcrButton = $derived(
|
const showOcrButton = $derived(
|
||||||
$slideshowState === SlideshowState.None &&
|
$slideshowState === SlideshowState.None &&
|
||||||
asset.type === AssetTypeEnum.Image &&
|
asset.type === AssetTypeEnum.Image &&
|
||||||
|
!(asset.exifInfo?.projectionType === 'EQUIRECTANGULAR') &&
|
||||||
!isShowEditor &&
|
!isShowEditor &&
|
||||||
ocrManager.hasOcrData,
|
ocrManager.hasOcrData,
|
||||||
);
|
);
|
||||||
@@ -473,7 +462,6 @@
|
|||||||
onAction={handleAction}
|
onAction={handleAction}
|
||||||
{onUndoDelete}
|
{onUndoDelete}
|
||||||
onEdit={showEditor}
|
onEdit={showEditor}
|
||||||
onRunJob={handleRunJob}
|
|
||||||
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
|
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
|
||||||
onClose={onClose ? () => onClose(asset) : undefined}
|
onClose={onClose ? () => onClose(asset) : undefined}
|
||||||
{playOriginalVideo}
|
{playOriginalVideo}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||||
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
|
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
|
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
|
||||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||||
import { websocketEvents } from '$lib/stores/websocket';
|
|
||||||
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
|
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { zoomImageToBase64 } from '$lib/utils/people-utils';
|
import { zoomImageToBase64 } from '$lib/utils/people-utils';
|
||||||
@@ -70,8 +70,8 @@
|
|||||||
isShowLoadingPeople = false;
|
isShowLoadingPeople = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const onPersonThumbnail = (personId: string) => {
|
const onPersonThumbnailReady = ({ id }: { id: string }) => {
|
||||||
assetFaceGenerated.push(personId);
|
assetFaceGenerated.push(id);
|
||||||
if (
|
if (
|
||||||
isEqual(assetFaceGenerated, peopleToCreate) &&
|
isEqual(assetFaceGenerated, peopleToCreate) &&
|
||||||
loaderLoadingDoneTimeout &&
|
loaderLoadingDoneTimeout &&
|
||||||
@@ -86,7 +86,6 @@
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
handlePromiseError(loadPeople());
|
handlePromiseError(loadPeople());
|
||||||
return websocketEvents.on('on_person_thumbnail', onPersonThumbnail);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const isEqual = (a: string[], b: string[]): boolean => {
|
const isEqual = (a: string[], b: string[]): boolean => {
|
||||||
@@ -184,6 +183,8 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<OnEvents {onPersonThumbnailReady} />
|
||||||
|
|
||||||
<section
|
<section
|
||||||
transition:fly={{ x: 360, duration: 100, easing: linear }}
|
transition:fly={{ x: 360, duration: 100, easing: linear }}
|
||||||
class="absolute top-0 h-full w-90 overflow-x-hidden p-2 dark:text-immich-dark-fg bg-light"
|
class="absolute top-0 h-full w-90 overflow-x-hidden p-2 dark:text-immich-dark-fg bg-light"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||||
import { getAssetJobIcon, getAssetJobMessage, getAssetJobName } from '$lib/utils';
|
import { getAssetJobIcon, getAssetJobName } from '$lib/utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { AssetJobName, runAssetJobs } from '@immich/sdk';
|
import { AssetJobName, runAssetJobs } from '@immich/sdk';
|
||||||
import { toastManager } from '@immich/ui';
|
import { toastManager } from '@immich/ui';
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
try {
|
try {
|
||||||
const ids = [...getOwnedAssets()].map(({ id }) => id);
|
const ids = [...getOwnedAssets()].map(({ id }) => id);
|
||||||
await runAssetJobs({ assetJobsDto: { assetIds: ids, name } });
|
await runAssetJobs({ assetJobsDto: { assetIds: ids, name } });
|
||||||
toastManager.success($getAssetJobMessage(name));
|
toastManager.success(getAssetJobName($t, name));
|
||||||
clearSelect();
|
clearSelect();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.unable_to_submit_job'));
|
handleError(error, $t('errors.unable_to_submit_job'));
|
||||||
@@ -32,6 +32,6 @@
|
|||||||
|
|
||||||
{#each jobs as job (job)}
|
{#each jobs as job (job)}
|
||||||
{#if isAllVideos || job !== AssetJobName.TranscodeVideo}
|
{#if isAllVideos || job !== AssetJobName.TranscodeVideo}
|
||||||
<MenuOption text={$getAssetJobName(job)} icon={getAssetJobIcon(job)} onClick={() => handleRunJob(job)} />
|
<MenuOption text={getAssetJobName($t, job)} icon={getAssetJobIcon(job)} onClick={() => handleRunJob(job)} />
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export type Events = {
|
|||||||
AlbumUserDelete: [{ albumId: string; userId: string }];
|
AlbumUserDelete: [{ albumId: string; userId: string }];
|
||||||
|
|
||||||
PersonUpdate: [PersonResponseDto];
|
PersonUpdate: [PersonResponseDto];
|
||||||
|
PersonThumbnailReady: [{ id: string }];
|
||||||
|
|
||||||
BackupDeleteStatus: [{ filename: string; isDeleting: boolean }];
|
BackupDeleteStatus: [{ filename: string; isDeleting: boolean }];
|
||||||
BackupDeleted: [{ filename: string }];
|
BackupDeleted: [{ filename: string }];
|
||||||
|
|||||||
@@ -3,28 +3,36 @@ import { authManager } from '$lib/managers/auth-manager.svelte';
|
|||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||||
import { user as authUser, preferences } from '$lib/stores/user.store';
|
import { user as authUser, preferences } from '$lib/stores/user.store';
|
||||||
import { getSharedLink, sleep } from '$lib/utils';
|
import { getAssetJobName, getSharedLink, sleep } from '$lib/utils';
|
||||||
import { downloadUrl } from '$lib/utils/asset-utils';
|
import { downloadUrl } from '$lib/utils/asset-utils';
|
||||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { getFormatter } from '$lib/utils/i18n';
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
import { asQueryString } from '$lib/utils/shared-links';
|
import { asQueryString } from '$lib/utils/shared-links';
|
||||||
import {
|
import {
|
||||||
|
AssetJobName,
|
||||||
|
AssetTypeEnum,
|
||||||
AssetVisibility,
|
AssetVisibility,
|
||||||
copyAsset,
|
copyAsset,
|
||||||
deleteAssets,
|
deleteAssets,
|
||||||
getAssetInfo,
|
getAssetInfo,
|
||||||
getBaseUrl,
|
getBaseUrl,
|
||||||
|
runAssetJobs,
|
||||||
updateAsset,
|
updateAsset,
|
||||||
|
type AssetJobsDto,
|
||||||
type AssetResponseDto,
|
type AssetResponseDto,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||||
import {
|
import {
|
||||||
mdiAlertOutline,
|
mdiAlertOutline,
|
||||||
|
mdiCogRefreshOutline,
|
||||||
|
mdiDatabaseRefreshOutline,
|
||||||
mdiDownload,
|
mdiDownload,
|
||||||
mdiDownloadBox,
|
mdiDownloadBox,
|
||||||
|
mdiHeadSyncOutline,
|
||||||
mdiHeart,
|
mdiHeart,
|
||||||
mdiHeartOutline,
|
mdiHeartOutline,
|
||||||
|
mdiImageRefreshOutline,
|
||||||
mdiInformationOutline,
|
mdiInformationOutline,
|
||||||
mdiMotionPauseOutline,
|
mdiMotionPauseOutline,
|
||||||
mdiMotionPlayOutline,
|
mdiMotionPlayOutline,
|
||||||
@@ -124,6 +132,31 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
|
|||||||
shortcuts: [{ key: 'i' }],
|
shortcuts: [{ key: 'i' }],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RefreshFacesJob: ActionItem = {
|
||||||
|
title: getAssetJobName($t, AssetJobName.RefreshFaces),
|
||||||
|
icon: mdiHeadSyncOutline,
|
||||||
|
onAction: () => handleRunAssetJob({ name: AssetJobName.RefreshFaces, assetIds: [asset.id] }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const RefreshMetadataJob: ActionItem = {
|
||||||
|
title: getAssetJobName($t, AssetJobName.RefreshMetadata),
|
||||||
|
icon: mdiDatabaseRefreshOutline,
|
||||||
|
onAction: () => handleRunAssetJob({ name: AssetJobName.RefreshMetadata, assetIds: [asset.id] }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const RegenerateThumbnailJob: ActionItem = {
|
||||||
|
title: getAssetJobName($t, AssetJobName.RegenerateThumbnail),
|
||||||
|
icon: mdiImageRefreshOutline,
|
||||||
|
onAction: () => handleRunAssetJob({ name: AssetJobName.RegenerateThumbnail, assetIds: [asset.id] }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const TranscodeVideoJob: ActionItem = {
|
||||||
|
title: getAssetJobName($t, AssetJobName.TranscodeVideo),
|
||||||
|
icon: mdiCogRefreshOutline,
|
||||||
|
onAction: () => handleRunAssetJob({ name: AssetJobName.TranscodeVideo, assetIds: [asset.id] }),
|
||||||
|
$if: () => asset.type === AssetTypeEnum.Video,
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
Share,
|
Share,
|
||||||
Download,
|
Download,
|
||||||
@@ -135,6 +168,10 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
|
|||||||
Unfavorite,
|
Unfavorite,
|
||||||
PlayMotionPhoto,
|
PlayMotionPhoto,
|
||||||
StopMotionPhoto,
|
StopMotionPhoto,
|
||||||
|
RefreshFacesJob,
|
||||||
|
RefreshMetadataJob,
|
||||||
|
RegenerateThumbnailJob,
|
||||||
|
TranscodeVideoJob,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -217,3 +254,14 @@ export const handleReplaceAsset = async (oldAssetId: string) => {
|
|||||||
|
|
||||||
eventManager.emit('AssetReplace', { oldAssetId, newAssetId });
|
eventManager.emit('AssetReplace', { oldAssetId, newAssetId });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRunAssetJob = async (dto: AssetJobsDto) => {
|
||||||
|
const $t = await getFormatter();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runAssetJobs({ assetJobsDto: dto });
|
||||||
|
toastManager.success(getAssetJobName($t, dto.name));
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('errors.unable_to_submit_job'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
||||||
|
import { modalManager, type ActionItem } from '@immich/ui';
|
||||||
|
import { mdiKeyboard } from '@mdi/js';
|
||||||
|
import type { MessageFormatter } from 'svelte-i18n';
|
||||||
|
|
||||||
|
export const getKeyboardActions = ($t: MessageFormatter) => {
|
||||||
|
const KeyboardShortcuts: ActionItem = {
|
||||||
|
title: $t('show_keyboard_shortcuts'),
|
||||||
|
icon: mdiKeyboard,
|
||||||
|
onAction: () => modalManager.show(ShortcutsModal, {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { KeyboardShortcuts };
|
||||||
|
};
|
||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
type UpdateLibraryDto,
|
type UpdateLibraryDto,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||||
import { mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js';
|
import { mdiInformationOutline, mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js';
|
||||||
import type { MessageFormatter } from 'svelte-i18n';
|
import type { MessageFormatter } from 'svelte-i18n';
|
||||||
|
|
||||||
export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResponseDto[]) => {
|
export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResponseDto[]) => {
|
||||||
@@ -45,6 +45,13 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponseDto) => {
|
export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponseDto) => {
|
||||||
|
const Detail: ActionItem = {
|
||||||
|
icon: mdiInformationOutline,
|
||||||
|
type: $t('command'),
|
||||||
|
title: $t('details'),
|
||||||
|
onAction: () => goto(Route.viewLibrary(library)),
|
||||||
|
};
|
||||||
|
|
||||||
const Edit: ActionItem = {
|
const Edit: ActionItem = {
|
||||||
icon: mdiPencilOutline,
|
icon: mdiPencilOutline,
|
||||||
type: $t('command'),
|
type: $t('command'),
|
||||||
@@ -84,7 +91,7 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
|
|||||||
shortcuts: { shift: true, key: 'r' },
|
shortcuts: { shift: true, key: 'r' },
|
||||||
};
|
};
|
||||||
|
|
||||||
return { Edit, Delete, AddFolder, AddExclusionPattern, Scan };
|
return { Detail, Edit, Delete, AddFolder, AddExclusionPattern, Scan };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryResponseDto, folder: string) => {
|
export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryResponseDto, folder: string) => {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||||
import {
|
import {
|
||||||
mdiDeleteRestore,
|
mdiDeleteRestore,
|
||||||
|
mdiInformationOutline,
|
||||||
mdiLockReset,
|
mdiLockReset,
|
||||||
mdiLockSmart,
|
mdiLockSmart,
|
||||||
mdiPencilOutline,
|
mdiPencilOutline,
|
||||||
@@ -46,6 +47,12 @@ export const getUserAdminsActions = ($t: MessageFormatter) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminResponseDto) => {
|
export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminResponseDto) => {
|
||||||
|
const Detail: ActionItem = {
|
||||||
|
icon: mdiInformationOutline,
|
||||||
|
title: $t('details'),
|
||||||
|
onAction: () => goto(Route.viewUser(user)),
|
||||||
|
};
|
||||||
|
|
||||||
const Update: ActionItem = {
|
const Update: ActionItem = {
|
||||||
icon: mdiPencilOutline,
|
icon: mdiPencilOutline,
|
||||||
title: $t('edit'),
|
title: $t('edit'),
|
||||||
@@ -92,7 +99,7 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
|
|||||||
onAction: () => handleResetPinCodeUserAdmin(user),
|
onAction: () => handleResetPinCodeUserAdmin(user),
|
||||||
};
|
};
|
||||||
|
|
||||||
return { Update, Delete, Restore, ResetPassword, ResetPinCode };
|
return { Detail, Update, Delete, Restore, ResetPassword, ResetPinCode };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleCreateUserAdmin = async (dto: UserAdminCreateDto) => {
|
export const handleCreateUserAdmin = async (dto: UserAdminCreateDto) => {
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ websocket
|
|||||||
.on('on_new_release', (event) => eventManager.emit('ReleaseEvent', event))
|
.on('on_new_release', (event) => eventManager.emit('ReleaseEvent', event))
|
||||||
.on('on_session_delete', () => authManager.logout())
|
.on('on_session_delete', () => authManager.logout())
|
||||||
.on('on_user_delete', (id) => eventManager.emit('UserAdminDeleted', { id }))
|
.on('on_user_delete', (id) => eventManager.emit('UserAdminDeleted', { id }))
|
||||||
|
.on('on_person_thumbnail', (id) => eventManager.emit('PersonThumbnailReady', { id }))
|
||||||
.on('on_notification', () => notificationManager.refresh())
|
.on('on_notification', () => notificationManager.refresh())
|
||||||
.on('connect_error', (e) => console.log('Websocket Connect Error', e));
|
.on('connect_error', (e) => console.log('Websocket Connect Error', e));
|
||||||
|
|
||||||
|
|||||||
+2
-17
@@ -28,7 +28,7 @@ import {
|
|||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { toastManager, type ActionItem, type IfLike } from '@immich/ui';
|
import { toastManager, type ActionItem, type IfLike } from '@immich/ui';
|
||||||
import { mdiCogRefreshOutline, mdiDatabaseRefreshOutline, mdiHeadSyncOutline, mdiImageRefreshOutline } from '@mdi/js';
|
import { mdiCogRefreshOutline, mdiDatabaseRefreshOutline, mdiHeadSyncOutline, mdiImageRefreshOutline } from '@mdi/js';
|
||||||
import { init, register, t } from 'svelte-i18n';
|
import { init, register, t, type MessageFormatter } from 'svelte-i18n';
|
||||||
import { derived, get } from 'svelte/store';
|
import { derived, get } from 'svelte/store';
|
||||||
|
|
||||||
interface DownloadRequestOptions<T = unknown> {
|
interface DownloadRequestOptions<T = unknown> {
|
||||||
@@ -260,21 +260,7 @@ export const getProfileImageUrl = (user: UserResponseDto) =>
|
|||||||
export const getPeopleThumbnailUrl = (person: PersonResponseDto, updatedAt?: string) =>
|
export const getPeopleThumbnailUrl = (person: PersonResponseDto, updatedAt?: string) =>
|
||||||
createUrl(getPeopleThumbnailPath(person.id), { updatedAt: updatedAt ?? person.updatedAt });
|
createUrl(getPeopleThumbnailPath(person.id), { updatedAt: updatedAt ?? person.updatedAt });
|
||||||
|
|
||||||
export const getAssetJobName = derived(t, ($t) => {
|
export const getAssetJobName = ($t: MessageFormatter, job: AssetJobName) => {
|
||||||
return (job: AssetJobName) => {
|
|
||||||
const names: Record<AssetJobName, string> = {
|
|
||||||
[AssetJobName.RefreshFaces]: $t('refresh_faces'),
|
|
||||||
[AssetJobName.RefreshMetadata]: $t('refresh_metadata'),
|
|
||||||
[AssetJobName.RegenerateThumbnail]: $t('refresh_thumbnails'),
|
|
||||||
[AssetJobName.TranscodeVideo]: $t('refresh_encoded_videos'),
|
|
||||||
};
|
|
||||||
|
|
||||||
return names[job];
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getAssetJobMessage = derived(t, ($t) => {
|
|
||||||
return (job: AssetJobName) => {
|
|
||||||
const messages: Record<AssetJobName, string> = {
|
const messages: Record<AssetJobName, string> = {
|
||||||
[AssetJobName.RefreshFaces]: $t('refreshing_faces'),
|
[AssetJobName.RefreshFaces]: $t('refreshing_faces'),
|
||||||
[AssetJobName.RefreshMetadata]: $t('refreshing_metadata'),
|
[AssetJobName.RefreshMetadata]: $t('refreshing_metadata'),
|
||||||
@@ -284,7 +270,6 @@ export const getAssetJobMessage = derived(t, ($t) => {
|
|||||||
|
|
||||||
return messages[job];
|
return messages[job];
|
||||||
};
|
};
|
||||||
});
|
|
||||||
|
|
||||||
export const getAssetJobIcon = (job: AssetJobName) => {
|
export const getAssetJobIcon = (job: AssetJobName) => {
|
||||||
const names: Record<AssetJobName, string> = {
|
const names: Record<AssetJobName, string> = {
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||||
|
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||||
import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte';
|
import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte';
|
||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
import { websocketEvents } from '$lib/stores/websocket';
|
|
||||||
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||||
import { AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
|
import { AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
|
||||||
import { Icon } from '@immich/ui';
|
import { Icon } from '@immich/ui';
|
||||||
import { mdiHeart } from '@mdi/js';
|
import { mdiHeart } from '@mdi/js';
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
@@ -29,17 +28,17 @@
|
|||||||
|
|
||||||
let hasPeople = $derived(data.response.total > 0);
|
let hasPeople = $derived(data.response.total > 0);
|
||||||
|
|
||||||
onMount(() => {
|
const onPersonThumbnailReady = ({ id }: { id: string }) => {
|
||||||
return websocketEvents.on('on_person_thumbnail', (personId: string) => {
|
for (const person of people) {
|
||||||
people.map((person) => {
|
if (person.id === id) {
|
||||||
if (person.id === personId) {
|
person.updatedAt = new Date().toISOString();
|
||||||
person.updatedAt = Date.now().toString();
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
});
|
};
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<OnEvents {onPersonThumbnailReady} />
|
||||||
|
|
||||||
<UserPageLayout title={data.meta.title}>
|
<UserPageLayout title={data.meta.title}>
|
||||||
{#if hasPeople}
|
{#if hasPeople}
|
||||||
<div class="mb-6 mt-2">
|
<div class="mb-6 mt-2">
|
||||||
|
|||||||
@@ -1,30 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||||
import UserSettingsList from '$lib/components/user-settings-page/user-settings-list.svelte';
|
import UserSettingsList from '$lib/components/user-settings-page/user-settings-list.svelte';
|
||||||
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
import { getKeyboardActions } from '$lib/services/keyboard.service';
|
||||||
import { Container, IconButton, modalManager } from '@immich/ui';
|
import { Container } from '@immich/ui';
|
||||||
import { mdiKeyboard } from '@mdi/js';
|
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
data: PageData;
|
data: PageData;
|
||||||
}
|
};
|
||||||
|
|
||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
const { KeyboardShortcuts } = $derived(getKeyboardActions($t));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<UserPageLayout title={data.meta.title}>
|
<UserPageLayout title={data.meta.title} actions={[KeyboardShortcuts]}>
|
||||||
{#snippet buttons()}
|
|
||||||
<IconButton
|
|
||||||
shape="round"
|
|
||||||
color="secondary"
|
|
||||||
variant="ghost"
|
|
||||||
icon={mdiKeyboard}
|
|
||||||
aria-label={$t('show_keyboard_shortcuts')}
|
|
||||||
onclick={() => modalManager.show(ShortcutsModal, {})}
|
|
||||||
/>
|
|
||||||
{/snippet}
|
|
||||||
<Container size="medium" center>
|
<Container size="medium" center>
|
||||||
<UserSettingsList keys={data.keys} sessions={data.sessions} />
|
<UserSettingsList keys={data.keys} sessions={data.sessions} />
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -4,14 +4,16 @@
|
|||||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
import { getLibrariesActions } from '$lib/services/library.service';
|
import { getLibrariesActions, getLibraryActions } from '$lib/services/library.service';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||||
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
|
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
|
||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
CommandPaletteDefaultProvider,
|
CommandPaletteDefaultProvider,
|
||||||
Container,
|
Container,
|
||||||
|
ContextMenuButton,
|
||||||
|
Link,
|
||||||
|
MenuItemType,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
@@ -58,13 +60,18 @@
|
|||||||
|
|
||||||
const { Create, ScanAll } = $derived(getLibrariesActions($t, libraries));
|
const { Create, ScanAll } = $derived(getLibrariesActions($t, libraries));
|
||||||
|
|
||||||
|
const getActionsForLibrary = (library: LibraryResponseDto) => {
|
||||||
|
const { Detail, Scan, Edit, Delete } = getLibraryActions($t, library);
|
||||||
|
return [Detail, Scan, Edit, MenuItemType.Divider, Delete];
|
||||||
|
};
|
||||||
|
|
||||||
const classes = {
|
const classes = {
|
||||||
column1: 'w-4/12',
|
column1: 'w-4/12',
|
||||||
column2: 'w-4/12',
|
column2: 'w-4/12',
|
||||||
column3: 'w-2/12',
|
column3: 'w-1/12',
|
||||||
column4: 'w-2/12',
|
column4: 'w-1/12',
|
||||||
column5: 'w-2/12',
|
column5: 'w-1/12',
|
||||||
column6: 'w-2/12',
|
column6: 'w-1/12 flex justify-end',
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -89,14 +96,19 @@
|
|||||||
{#each libraries as library (library.id + library.name)}
|
{#each libraries as library (library.id + library.name)}
|
||||||
{@const { photos, usage, videos } = statistics[library.id]}
|
{@const { photos, usage, videos } = statistics[library.id]}
|
||||||
{@const [diskUsage, diskUsageUnit] = getBytesWithUnit(usage, 0)}
|
{@const [diskUsage, diskUsageUnit] = getBytesWithUnit(usage, 0)}
|
||||||
|
{@const owner = owners[library.id]}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell class={classes.column1}>{library.name}</TableCell>
|
<TableCell class={classes.column1}>
|
||||||
<TableCell class={classes.column2}>{owners[library.id].name}</TableCell>
|
<Link href={Route.viewLibrary(library)}>{library.name}</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class={classes.column2}>
|
||||||
|
<Link href={Route.viewUser(owner)}>{owner.name}</Link>
|
||||||
|
</TableCell>
|
||||||
<TableCell class={classes.column3}>{photos.toLocaleString($locale)}</TableCell>
|
<TableCell class={classes.column3}>{photos.toLocaleString($locale)}</TableCell>
|
||||||
<TableCell class={classes.column4}>{videos.toLocaleString($locale)}</TableCell>
|
<TableCell class={classes.column4}>{videos.toLocaleString($locale)}</TableCell>
|
||||||
<TableCell class={classes.column5}>{diskUsage} {diskUsageUnit}</TableCell>
|
<TableCell class={classes.column5}>{diskUsage} {diskUsageUnit}</TableCell>
|
||||||
<TableCell class={classes.column6}>
|
<TableCell class={classes.column6}>
|
||||||
<Button size="small" href={Route.viewLibrary(library)}>{$t('view')}</Button>
|
<ContextMenuButton color="primary" aria-label={$t('open')} items={getActionsForLibrary(library)} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||||
import { getUserAdminsActions, handleNavigateUserAdmin } from '$lib/services/user-admin.service';
|
import { Route } from '$lib/route';
|
||||||
|
import { getUserAdminActions, getUserAdminsActions } from '$lib/services/user-admin.service';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||||
import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
|
import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
|
||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
CommandPaletteDefaultProvider,
|
CommandPaletteDefaultProvider,
|
||||||
Container,
|
Container,
|
||||||
|
ContextMenuButton,
|
||||||
Icon,
|
Icon,
|
||||||
|
Link,
|
||||||
|
MenuItemType,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
@@ -46,11 +49,16 @@
|
|||||||
|
|
||||||
const { Create } = $derived(getUserAdminsActions($t));
|
const { Create } = $derived(getUserAdminsActions($t));
|
||||||
|
|
||||||
|
const getActionsForUser = (user: UserAdminResponseDto) => {
|
||||||
|
const { Detail, Update, Delete, ResetPassword, ResetPinCode } = getUserAdminActions($t, user);
|
||||||
|
return [Detail, Update, ResetPassword, ResetPinCode, MenuItemType.Divider, Delete];
|
||||||
|
};
|
||||||
|
|
||||||
const classes = {
|
const classes = {
|
||||||
column1: 'w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12',
|
column1: 'w-8/12 md:w-5/12 lg:w-4/12',
|
||||||
column2: 'hidden sm:block w-3/12',
|
column2: 'hidden md:block md:w-5/12 lg:w-4/12',
|
||||||
column3: 'hidden xl:block w-3/12 2xl:w-2/12',
|
column3: 'hidden lg:block lg:w-2/12',
|
||||||
column4: 'w-4/12 lg:w-3/12 xl:w-2/12',
|
column4: 'w-4/12 md:w-2/12 flex justify-end',
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -68,16 +76,18 @@
|
|||||||
<Container center size="large">
|
<Container center size="large">
|
||||||
<Table class="mt-4" striped spacing="small" size="small">
|
<Table class="mt-4" striped spacing="small" size="small">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableHeading class={classes.column1}>{$t('email')}</TableHeading>
|
<TableHeading class={classes.column1}>{$t('name')}</TableHeading>
|
||||||
<TableHeading class={classes.column2}>{$t('name')}</TableHeading>
|
<TableHeading class={classes.column2}>{$t('email')}</TableHeading>
|
||||||
<TableHeading class={classes.column3}>{$t('has_quota')}</TableHeading>
|
<TableHeading class={classes.column3}>{$t('has_quota')}</TableHeading>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{#each users as user (user.id)}
|
{#each users as user (user.id)}
|
||||||
<TableRow color={user.deletedAt ? 'danger' : undefined}>
|
<TableRow color={user.deletedAt ? 'danger' : undefined}>
|
||||||
<TableCell class={classes.column1}>{user.email}</TableCell>
|
<TableCell class={classes.column1}>
|
||||||
<TableCell class={classes.column2}>{user.name}</TableCell>
|
<Link href={Route.viewUser(user)}>{user.name}</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class={classes.column2}>{user.email}</TableCell>
|
||||||
<TableCell class={classes.column3}>
|
<TableCell class={classes.column3}>
|
||||||
<div class="container mx-auto flex flex-wrap justify-center">
|
<div class="container mx-auto flex flex-wrap justify-center">
|
||||||
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
||||||
@@ -88,7 +98,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class={classes.column4}>
|
<TableCell class={classes.column4}>
|
||||||
<Button onclick={() => handleNavigateUserAdmin(user)}>{$t('view')}</Button>
|
<ContextMenuButton color="primary" aria-label={$t('open')} items={getActionsForUser(user)} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -198,8 +198,8 @@
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<p class="font-medium text-immich-dark-gray dark:text-white mb-2">{$t('storage')}</p>
|
<p class="font-medium text-immich-dark-gray dark:text-white mb-2">{$t('storage')}</p>
|
||||||
<div class="mt-4 h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
<div class="mt-4 h-1.75 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||||
<div class="h-[7px] rounded-full {getUsageClass()}" style="width: {usedPercentage}%"></div>
|
<div class="h-1.75 rounded-full {getUsageClass()}" style="width: {usedPercentage}%"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user