Compare commits

...

16 Commits

Author SHA1 Message Date
Jason Rasmussen
ed8310af14 fix: shared-link-mapper 2025-12-22 14:57:10 -05:00
Jason Rasmussen
01e3b8e5df refactor: form modals (#24790) 2025-12-22 14:15:23 -05:00
Jason Rasmussen
5a7c9a252c feat: disable admin setup (#24628) 2025-12-22 14:15:08 -05:00
Jason Rasmussen
f99f5f4f91 refactor: map setting modal (#24789) 2025-12-22 13:54:14 -05:00
Jason Rasmussen
8ad27c7cea refactor: slideshow modal (#24788) 2025-12-22 18:44:53 +00:00
Jason Rasmussen
edc21ed746 fix(web): stale album info (#24787) 2025-12-22 19:38:57 +01:00
Jason Rasmussen
dd744f8ee3 refactor: album edit modal (#24786) 2025-12-22 13:33:49 -05:00
Min Idzelis
f6f9a3abb4 fix: task never rejected on cancel, add tests (#24418) 2025-12-22 13:12:43 -05:00
Jason Rasmussen
1c156a179b feat: shared link edit (#24783) 2025-12-22 11:47:06 -05:00
Jason Rasmussen
952f189d8b feat: prefer admin settings page over users page (#24780) 2025-12-22 11:31:22 -05:00
Jason Rasmussen
40e750e8be refactor: api key service (#24779) 2025-12-22 11:09:11 -05:00
Jason Rasmussen
c7510d572a chore: move models (#24778) 2025-12-22 15:23:57 +00:00
Jason Rasmussen
165f9e15ee feat: modal routes (#24726)
feat: new user route
2025-12-22 15:04:08 +00:00
Mert
dfdbb773ce fix(web): display jxl original (#24766)
display jxl original
2025-12-21 20:10:22 -06:00
bo0tzz
f053ce548d fix: product keys wording in commercial guidelines faq (#24765) 2025-12-21 19:35:21 +00:00
bo0tzz
d7c28470ee feat: focus jumped-to item in timeline (#24738) 2025-12-21 08:52:52 -06:00
91 changed files with 2189 additions and 1465 deletions

View File

@@ -22,7 +22,7 @@ For organizations seeking to resell Immich, we have established the following gu
- Do not misrepresent your reseller site or services as being officially affiliated with or endorsed by Immich or our development team.
- For small resellers who wish to contribute financially to Immich's development, we recommend directing your customers to purchase licenses directly from us rather than attempting to broker revenue-sharing arrangements. We ask that you refrain from misrepresenting reseller activities as directly supporting our development work.
- For small resellers who wish to contribute financially to Immich's development, we recommend directing your customers to purchase product keys directly from us rather than attempting to broker revenue-sharing arrangements. We ask that you refrain from misrepresenting reseller activities as directly supporting our development work.
When in doubt or if you have an edge case scenario, we encourage you to contact us directly via email to discuss the use of our trademark. We can provide clear guidance on what is acceptable and what is not. You can reach out at: questions@immich.app

View File

@@ -43,6 +43,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | server | api |
\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`.
`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution.

View File

@@ -160,7 +160,9 @@ class SharedLinksApi {
/// Parameters:
///
/// * [String] albumId:
Future<Response> getAllSharedLinksWithHttpInfo({ String? albumId, }) async {
///
/// * [String] id:
Future<Response> getAllSharedLinksWithHttpInfo({ String? albumId, String? id, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/shared-links';
@@ -174,6 +176,9 @@ class SharedLinksApi {
if (albumId != null) {
queryParams.addAll(_queryParams('', 'albumId', albumId));
}
if (id != null) {
queryParams.addAll(_queryParams('', 'id', id));
}
const contentTypes = <String>[];
@@ -196,8 +201,10 @@ class SharedLinksApi {
/// Parameters:
///
/// * [String] albumId:
Future<List<SharedLinkResponseDto>?> getAllSharedLinks({ String? albumId, }) async {
final response = await getAllSharedLinksWithHttpInfo( albumId: albumId, );
///
/// * [String] id:
Future<List<SharedLinkResponseDto>?> getAllSharedLinks({ String? albumId, String? id, }) async {
final response = await getAllSharedLinksWithHttpInfo( albumId: albumId, id: id, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View File

@@ -10381,6 +10381,21 @@
"format": "uuid",
"type": "string"
}
},
{
"name": "id",
"required": false,
"in": "query",
"x-immich-history": [
{
"version": "v2.5.0",
"state": "Added"
}
],
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {

View File

@@ -4218,14 +4218,16 @@ export function lockSession({ id }: {
/**
* Retrieve all shared links
*/
export function getAllSharedLinks({ albumId }: {
export function getAllSharedLinks({ albumId, id }: {
albumId?: string;
id?: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: SharedLinkResponseDto[];
}>(`/shared-links${QS.query(QS.explode({
albumId
albumId,
id
}))}`, {
...opts
}));

View File

@@ -58,7 +58,7 @@ export class EnvDto {
IMMICH_MICROSERVICES_METRICS_PORT?: number;
@ValidateBoolean({ optional: true })
IMMICH_PLUGINS_ENABLED?: boolean;
IMMICH_ALLOW_EXTERNAL_PLUGINS?: boolean;
@Optional()
@Matches(/^\//, { message: 'IMMICH_PLUGINS_INSTALL_FOLDER must be an absolute path' })
@@ -113,6 +113,9 @@ export class EnvDto {
@Optional()
IMMICH_THIRD_PARTY_SUPPORT_URL?: string;
@ValidateBoolean({ optional: true })
IMMICH_ALLOW_SETUP?: boolean;
@IsIPRange({ requireCIDR: false }, { each: true })
@Transform(({ value }) =>
value && typeof value === 'string'

View File

@@ -1,7 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
import _ from 'lodash';
import { SharedLink } from 'src/database';
import { HistoryBuilder, Property } from 'src/decorators';
import { AlbumResponseDto, mapAlbumWithoutAssets } from 'src/dtos/album.dto';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { SharedLinkType } from 'src/enum';
@@ -10,6 +10,10 @@ import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } f
export class SharedLinkSearchDto {
@ValidateUUID({ optional: true })
albumId?: string;
@ValidateUUID({ optional: true })
@Property({ history: new HistoryBuilder().added('v2.5.0') })
id?: string;
}
export class SharedLinkCreateDto {
@@ -113,10 +117,10 @@ export class SharedLinkResponseDto {
slug!: string | null;
}
export function mapSharedLink(sharedLink: SharedLink): SharedLinkResponseDto {
const linkAssets = sharedLink.assets || [];
export function mapSharedLink(sharedLink: SharedLink, options: { stripAssetMetadata: boolean }): SharedLinkResponseDto {
const assets = sharedLink.assets || [];
return {
const response = {
id: sharedLink.id,
description: sharedLink.description,
password: sharedLink.password,
@@ -125,35 +129,19 @@ export function mapSharedLink(sharedLink: SharedLink): SharedLinkResponseDto {
type: sharedLink.type,
createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt,
assets: linkAssets.map((asset) => mapAsset(asset)),
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
allowUpload: sharedLink.allowUpload,
allowDownload: sharedLink.allowDownload,
showMetadata: sharedLink.showExif,
slug: sharedLink.slug,
};
}
export function mapSharedLinkWithoutMetadata(sharedLink: SharedLink): SharedLinkResponseDto {
const linkAssets = sharedLink.assets || [];
const albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset);
const assets = _.uniqBy([...linkAssets, ...albumAssets], (asset) => asset.id);
return {
id: sharedLink.id,
description: sharedLink.description,
password: sharedLink.password,
userId: sharedLink.userId,
key: sharedLink.key.toString('base64url'),
type: sharedLink.type,
createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt,
assets: assets.map((asset) => mapAsset(asset, { stripMetadata: true })),
assets: assets.map((asset) => mapAsset(asset, { stripMetadata: options.stripAssetMetadata })),
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
allowUpload: sharedLink.allowUpload,
allowDownload: sharedLink.allowDownload,
showMetadata: sharedLink.showExif,
slug: sharedLink.slug,
};
// unless we select sharedLink.album.sharedLinks this will be wrong
if (response.album) {
response.album.hasSharedLink = true;
response.album.shared = true;
}
return response;
}

View File

@@ -8,6 +8,8 @@ const getEnv = () => {
const resetEnv = () => {
for (const env of [
'IMMICH_ALLOW_EXTERNAL_PLUGINS',
'IMMICH_ALLOW_SETUP',
'IMMICH_ENV',
'IMMICH_WORKERS_INCLUDE',
'IMMICH_WORKERS_EXCLUDE',
@@ -75,6 +77,9 @@ describe('getEnv', () => {
configFile: undefined,
logLevel: undefined,
});
expect(config.plugins.external).toEqual({ allow: false });
expect(config.setup).toEqual({ allow: true });
});
describe('IMMICH_MEDIA_LOCATION', () => {
@@ -84,6 +89,32 @@ describe('getEnv', () => {
});
});
describe('IMMICH_ALLOW_EXTERNAL_PLUGINS', () => {
it('should disable plugins', () => {
process.env.IMMICH_ALLOW_EXTERNAL_PLUGINS = 'false';
const config = getEnv();
expect(config.plugins.external).toEqual({ allow: false });
});
it('should throw an error for invalid value', () => {
process.env.IMMICH_ALLOW_EXTERNAL_PLUGINS = 'invalid';
expect(() => getEnv()).toThrowError('IMMICH_ALLOW_EXTERNAL_PLUGINS must be a boolean value');
});
});
describe('IMMICH_ALLOW_SETUP', () => {
it('should disable setup', () => {
process.env.IMMICH_ALLOW_SETUP = 'false';
const { setup } = getEnv();
expect(setup).toEqual({ allow: false });
});
it('should throw an error for invalid value', () => {
process.env.IMMICH_ALLOW_SETUP = 'invalid';
expect(() => getEnv()).toThrowError('IMMICH_ALLOW_SETUP must be a boolean value');
});
});
describe('database', () => {
it('should use defaults', () => {
const { database } = getEnv();

View File

@@ -90,6 +90,10 @@ export interface EnvData {
redis: RedisOptions;
setup: {
allow: boolean;
};
telemetry: {
apiPort: number;
microservicesPort: number;
@@ -104,8 +108,10 @@ export interface EnvData {
workers: ImmichWorker[];
plugins: {
enabled: boolean;
installFolder?: string;
external: {
allow: boolean;
installFolder?: string;
};
};
noColor: boolean;
@@ -313,6 +319,10 @@ const getEnv = (): EnvData => {
corePlugin: join(buildFolder, 'corePlugin'),
},
setup: {
allow: dto.IMMICH_ALLOW_SETUP ?? true,
},
storage: {
ignoreMountCheckErrors: !!dto.IMMICH_IGNORE_MOUNT_CHECK_ERRORS,
mediaLocation: dto.IMMICH_MEDIA_LOCATION,
@@ -327,8 +337,10 @@ const getEnv = (): EnvData => {
workers,
plugins: {
enabled: !!dto.IMMICH_PLUGINS_ENABLED,
installFolder: dto.IMMICH_PLUGINS_INSTALL_FOLDER,
external: {
allow: dto.IMMICH_ALLOW_EXTERNAL_PLUGINS ?? false,
installFolder: dto.IMMICH_PLUGINS_INSTALL_FOLDER,
},
},
noColor: !!dto.NO_COLOR,

View File

@@ -12,6 +12,7 @@ import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
export type SharedLinkSearchOptions = {
userId: string;
id?: string;
albumId?: string;
};
@@ -118,7 +119,7 @@ export class SharedLinkRepository {
}
@GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }] })
getAll({ userId, albumId }: SharedLinkSearchOptions) {
getAll({ userId, id, albumId }: SharedLinkSearchOptions) {
return this.db
.selectFrom('shared_link')
.selectAll('shared_link')
@@ -176,6 +177,7 @@ export class SharedLinkRepository {
.select((eb) => eb.fn.toJson('album').$castTo<Album | null>().as('album'))
.where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.Individual), eb('album.id', 'is not', null)]))
.$if(!!albumId, (eb) => eb.where('shared_link.albumId', '=', albumId!))
.$if(!!id, (eb) => eb.where('shared_link.id', '=', id!))
.orderBy('shared_link.createdAt', 'desc')
.distinctOn(['shared_link.createdAt'])
.execute();

View File

@@ -165,6 +165,11 @@ export class AuthService extends BaseService {
}
async adminSignUp(dto: SignUpDto): Promise<UserAdminResponseDto> {
const { setup } = this.configRepository.getEnv();
if (!setup.allow) {
throw new BadRequestException('Admin setup is disabled');
}
const adminUser = await this.userRepository.getAdmin();
if (adminUser) {
throw new BadRequestException('The server already has an admin');

View File

@@ -85,8 +85,8 @@ export class PluginService extends BaseService {
this.logger.log(`Successfully processed core plugin: ${coreManifest.name} (version ${coreManifest.version})`);
// Load external plugins
if (plugins.enabled && plugins.installFolder) {
await this.loadExternalPlugins(plugins.installFolder);
if (plugins.external.allow && plugins.external.installFolder) {
await this.loadExternalPlugins(plugins.external.installFolder);
}
}

View File

@@ -115,8 +115,9 @@ export class ServerService extends BaseService {
}
async getSystemConfig(): Promise<ServerConfigDto> {
const { setup } = this.configRepository.getEnv();
const config = await this.getConfig({ withCache: false });
const isInitialized = await this.userRepository.hasAdmin();
const isInitialized = !setup.allow || (await this.userRepository.hasAdmin());
const onboarding = await this.systemMetadataRepository.get(SystemMetadataKey.AdminOnboarding);
return {

View File

@@ -6,7 +6,6 @@ import { AssetIdsDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
mapSharedLink,
mapSharedLinkWithoutMetadata,
SharedLinkCreateDto,
SharedLinkEditDto,
SharedLinkPasswordDto,
@@ -19,10 +18,10 @@ import { getExternalDomain, OpenGraphTags } from 'src/utils/misc';
@Injectable()
export class SharedLinkService extends BaseService {
async getAll(auth: AuthDto, { albumId }: SharedLinkSearchDto): Promise<SharedLinkResponseDto[]> {
async getAll(auth: AuthDto, { id, albumId }: SharedLinkSearchDto): Promise<SharedLinkResponseDto[]> {
return this.sharedLinkRepository
.getAll({ userId: auth.user.id, albumId })
.then((links) => links.map((link) => mapSharedLink(link)));
.getAll({ userId: auth.user.id, id, albumId })
.then((links) => links.map((link) => mapSharedLink(link, { stripAssetMetadata: false })));
}
async getMine(auth: AuthDto, dto: SharedLinkPasswordDto): Promise<SharedLinkResponseDto> {
@@ -31,7 +30,7 @@ export class SharedLinkService extends BaseService {
}
const sharedLink = await this.findOrFail(auth.user.id, auth.sharedLink.id);
const response = this.mapToSharedLink(sharedLink, { withExif: sharedLink.showExif });
const response = mapSharedLink(sharedLink, { stripAssetMetadata: !sharedLink.showExif });
if (sharedLink.password) {
response.token = this.validateAndRefreshToken(sharedLink, dto);
}
@@ -41,7 +40,7 @@ export class SharedLinkService extends BaseService {
async get(auth: AuthDto, id: string): Promise<SharedLinkResponseDto> {
const sharedLink = await this.findOrFail(auth.user.id, id);
return this.mapToSharedLink(sharedLink, { withExif: true });
return mapSharedLink(sharedLink, { stripAssetMetadata: false });
}
async create(auth: AuthDto, dto: SharedLinkCreateDto): Promise<SharedLinkResponseDto> {
@@ -81,7 +80,7 @@ export class SharedLinkService extends BaseService {
slug: dto.slug || null,
});
return this.mapToSharedLink(sharedLink, { withExif: true });
return mapSharedLink(sharedLink, { stripAssetMetadata: false });
} catch (error) {
this.handleError(error);
}
@@ -108,7 +107,7 @@ export class SharedLinkService extends BaseService {
showExif: dto.showMetadata,
slug: dto.slug || null,
});
return this.mapToSharedLink(sharedLink, { withExif: true });
return mapSharedLink(sharedLink, { stripAssetMetadata: false });
} catch (error) {
this.handleError(error);
}
@@ -214,10 +213,6 @@ export class SharedLinkService extends BaseService {
};
}
private mapToSharedLink(sharedLink: SharedLink, { withExif }: { withExif: boolean }) {
return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithoutMetadata(sharedLink);
}
private validateAndRefreshToken(sharedLink: SharedLink, dto: SharedLinkPasswordDto): string {
const token = this.cryptoRepository.hashSha256(`${sharedLink.id}-${sharedLink.password}`);
const sharedLinkTokens = dto.token?.split(',') || [];

View File

@@ -75,6 +75,10 @@ const envData: EnvData = {
corePlugin: '/build/corePlugin',
},
setup: {
allow: true,
},
storage: {
ignoreMountCheckErrors: false,
},
@@ -88,8 +92,10 @@ const envData: EnvData = {
workers: [ImmichWorker.Api, ImmichWorker.Microservices],
plugins: {
enabled: true,
installFolder: '/app/data/plugins',
external: {
allow: true,
installFolder: '/app/data/plugins',
},
},
noColor: false,

View File

@@ -9,7 +9,7 @@
<div class="h-full flex flex-col justify-between gap-2">
<div class="flex flex-col pt-8 pe-4 gap-1">
<NavbarItem title={$t('users')} href={AppRoute.ADMIN_USERS} icon={mdiAccountMultipleOutline} />
<NavbarItem title={$t('external_libraries')} href={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} />
<NavbarItem title={$t('external_libraries')} href={AppRoute.ADMIN_LIBRARIES} icon={mdiBookshelf} />
<NavbarItem title={$t('admin.queues')} href={AppRoute.ADMIN_QUEUES} icon={mdiTrayFull} />
<NavbarItem title={$t('settings')} href={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
<NavbarItem title={$t('server_stats')} href={AppRoute.ADMIN_STATS} icon={mdiServer} />

View File

@@ -1,14 +1,15 @@
<script lang="ts">
import { IconButton, type ActionItem } from '@immich/ui';
import { IconButton, type ActionItem, type Size } from '@immich/ui';
type Props = {
action: ActionItem;
size?: Size;
};
const { action }: Props = $props();
const { action, size }: Props = $props();
const { title, icon, onAction } = $derived(action);
</script>
{#if action.$if?.() ?? true}
<IconButton shape="round" color="primary" {icon} aria-label={title} onclick={() => onAction(action)} />
<IconButton {size} shape="round" color="primary" {icon} aria-label={title} onclick={() => onAction(action)} />
{/if}

View File

@@ -65,7 +65,6 @@
<div class="grid grid-auto-fill-56 gap-y-4" transition:slide={{ duration: 300 }}>
{#each albums as album, index (album.id)}
<a
data-sveltekit-preload-data="hover"
href={resolve(`${AppRoute.ALBUMS}/${album.id}`)}
animate:flip={{ duration: 400 }}
oncontextmenu={(event) => oncontextmenu(event, album)}

View File

@@ -1,13 +1,9 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
import AlbumsTable from '$lib/components/album-page/albums-table.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import RightClickContextMenu from '$lib/components/shared-components/context-menu/right-click-context-menu.svelte';
import ToastAction from '$lib/components/ToastAction.svelte';
import { AppRoute } from '$lib/constants';
import AlbumEditModal from '$lib/modals/AlbumEditModal.svelte';
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
@@ -17,8 +13,8 @@
AlbumGroupBy,
AlbumSortBy,
AlbumViewMode,
SortOrder,
locale,
SortOrder,
type AlbumViewSettings,
} from '$lib/stores/preferences.store';
import { user } from '$lib/stores/user.store';
@@ -27,8 +23,13 @@
import type { ContextMenuPosition } from '$lib/utils/context-menu';
import { handleError } from '$lib/utils/handle-error';
import { normalizeSearchString } from '$lib/utils/string-utils';
import { addUsersToAlbum, type AlbumResponseDto, type AlbumUserAddDto } from '@immich/sdk';
import { modalManager, toastManager } from '@immich/ui';
import {
addUsersToAlbum,
type AlbumResponseDto,
type AlbumUserAddDto,
type SharedLinkResponseDto,
} from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { mdiDeleteOutline, mdiDownload, mdiRenameOutline, mdiShareVariantOutline } from '@mdi/js';
import { groupBy } from 'lodash-es';
import { onMount, type Snippet } from 'svelte';
@@ -199,10 +200,7 @@
switch (action) {
case 'edit': {
const editedAlbum = await modalManager.show(AlbumEditModal, { album: selectedAlbum });
if (editedAlbum) {
successEditAlbumInfo(editedAlbum);
}
await modalManager.show(AlbumEditModal, { album: selectedAlbum });
break;
}
@@ -215,12 +213,7 @@
}
case 'sharedLink': {
const success = await modalManager.show(SharedLinkCreateModal, { albumId: selectedAlbum.id });
if (success) {
selectedAlbum.shared = true;
selectedAlbum.hasSharedLink = true;
updateAlbumInfo(selectedAlbum);
}
await modalManager.show(SharedLinkCreateModal, { albumId: selectedAlbum.id });
break;
}
}
@@ -244,39 +237,18 @@
await Promise.allSettled(albumsToRemove.map((album) => handleDeleteAlbum(album, { prompt: false, notify: false })));
};
const updateAlbumInfo = (album: AlbumResponseDto) => {
ownedAlbums[ownedAlbums.findIndex(({ id }) => id === album.id)] = album;
sharedAlbums[sharedAlbums.findIndex(({ id }) => id === album.id)] = album;
};
const updateRecentAlbumInfo = (album: AlbumResponseDto) => {
for (const cachedAlbum of userInteraction.recentAlbums || []) {
if (cachedAlbum.id === album.id) {
Object.assign(cachedAlbum, { ...cachedAlbum, ...album });
break;
}
const findAndUpdate = (albums: AlbumResponseDto[], album: AlbumResponseDto) => {
const target = albums.find(({ id }) => id === album.id);
if (target) {
Object.assign(target, album);
}
return albums;
};
const successEditAlbumInfo = (album: AlbumResponseDto) => {
toastManager.custom({
component: ToastAction,
props: {
color: 'primary',
title: $t('success'),
description: $t('album_info_updated'),
button: {
text: $t('view_album'),
color: 'primary',
onClick() {
return goto(resolve(`${AppRoute.ALBUMS}/${album.id}`));
},
},
},
});
updateAlbumInfo(album);
updateRecentAlbumInfo(album);
const onUpdate = (album: AlbumResponseDto) => {
ownedAlbums = findAndUpdate(ownedAlbums, album);
sharedAlbums = findAndUpdate(sharedAlbums, album);
};
const handleAddUsers = async (album: AlbumResponseDto, albumUsers: AlbumUserAddDto[]) => {
@@ -287,19 +259,30 @@
albumUsers,
},
});
updateAlbumInfo(updatedAlbum);
onUpdate(updatedAlbum);
} catch (error) {
handleError(error, $t('errors.unable_to_add_album_users'));
}
};
const onAlbumUpdate = (album: AlbumResponseDto) => {
onUpdate(album);
userInteraction.recentAlbums = findAndUpdate(userInteraction.recentAlbums || [], album);
};
const onAlbumDelete = (album: AlbumResponseDto) => {
ownedAlbums = ownedAlbums.filter(({ id }) => id !== album.id);
sharedAlbums = sharedAlbums.filter(({ id }) => id !== album.id);
};
const onSharedLinkCreate = (sharedLink: SharedLinkResponseDto) => {
if (sharedLink.album) {
onUpdate(sharedLink.album);
}
};
</script>
<OnEvents {onAlbumDelete} />
<OnEvents {onAlbumUpdate} {onAlbumDelete} {onSharedLinkCreate} />
{#if albums.length > 0}
{#if userSettings.view === AlbumViewMode.Cover}

View File

@@ -1,26 +0,0 @@
<script lang="ts">
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import type { AssetResponseDto } from '@immich/sdk';
import { IconButton, modalManager } from '@immich/ui';
import { mdiShareVariantOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
asset: AssetResponseDto;
}
let { asset }: Props = $props();
const handleClick = async () => {
await modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] });
};
</script>
<IconButton
color="secondary"
shape="round"
variant="ghost"
icon={mdiShareVariantOutline}
onclick={handleClick}
aria-label={$t('share')}
/>

View File

@@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import CastButton from '$lib/cast/cast-button.svelte';
import ActionButton from '$lib/components/ActionButton.svelte';
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte';
import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte';
@@ -18,14 +19,13 @@
import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte';
import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/set-stack-primary-asset.svelte';
import SetVisibilityAction from '$lib/components/asset-viewer/actions/set-visibility-action.svelte';
import ShareAction from '$lib/components/asset-viewer/actions/share-action.svelte';
import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte';
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AppRoute } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { handleReplaceAsset } from '$lib/services/asset.service';
import { getAssetActions, handleReplaceAsset } from '$lib/services/asset.service';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { user } from '$lib/stores/user.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
@@ -110,6 +110,8 @@
let isLocked = $derived(asset.visibility === AssetVisibility.Locked);
let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
const { Share } = $derived(getAssetActions($t, asset));
// $: showEditorButton =
// isOwner &&
// asset.type === AssetTypeEnum.Image &&
@@ -132,9 +134,7 @@
<div class="flex gap-2 overflow-x-auto dark" data-testid="asset-viewer-navbar-actions">
<CastButton />
{#if !asset.isTrashed && $user && !isLocked}
<ShareAction {asset} />
{/if}
<ActionButton action={Share} />
{#if asset.isOffline}
<IconButton
shape="round"

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import PageContent from '$lib/components/layouts/PageContent.svelte';
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
import AdminSidebar from '$lib/sidebars/AdminSidebar.svelte';
import AdminSidebar from '$lib/components/AdminSidebar.svelte';
import { sidebarStore } from '$lib/stores/sidebar.svelte';
import type { HeaderButtonActionItem } from '$lib/types';
import {

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { OnboardingRole } from '$lib/models/onboarding-role';
import { user } from '$lib/stores/user.store';
import { OnboardingRole } from '$lib/types';
import { Logo } from '@immich/ui';
import { t } from 'svelte-i18n';

View File

@@ -78,7 +78,7 @@
</Button>
{#if $user.isAdmin}
<Button
href={AppRoute.ADMIN_USERS}
href={AppRoute.ADMIN_SETTINGS}
onclick={onClose}
shape="round"
variant="ghost"

View File

@@ -1,9 +1,9 @@
<script lang="ts">
import { AppRoute } from '$lib/constants';
import type { UploadAsset } from '$lib/models/upload-asset';
import { UploadState } from '$lib/models/upload-asset';
import { locale } from '$lib/stores/preferences.store';
import { uploadAssetsStore } from '$lib/stores/upload';
import type { UploadAsset } from '$lib/types';
import { UploadState } from '$lib/types';
import { getByteUnitString } from '$lib/utils/byte-units';
import { fileUploadHandler } from '$lib/utils/file-uploader';
import { Icon } from '@immich/ui';

View File

@@ -13,6 +13,7 @@
import Skeleton from '$lib/elements/Skeleton.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
@@ -25,7 +26,7 @@
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
import { type AlbumResponseDto, type PersonResponseDto, type UserResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { onDestroy, onMount, type Snippet } from 'svelte';
import { onDestroy, onMount, tick, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
interface Props {
@@ -226,6 +227,9 @@
if (!scrolled) {
// if the asset is not found, scroll to the top
timelineManager.scrollTo(0);
} else if (scrollTarget) {
await tick();
focusAsset(scrollTarget);
}
invisible = false;
};

View File

@@ -21,11 +21,15 @@ export const focusPreviousAsset = () =>
const queryHTMLElement = (query: string) => document.querySelector(query) as HTMLElement;
export const focusAsset = (assetId: string) => {
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${assetId}"]`);
element?.focus();
};
export const setFocusToAsset = (scrollToAsset: (asset: TimelineAsset) => boolean, asset: TimelineAsset) => {
const scrolled = scrollToAsset(asset);
if (scrolled) {
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
element?.focus();
focusAsset(asset.id);
}
};
@@ -71,8 +75,7 @@ export const setFocusTo = async (
if (!invocation.isStillValid()) {
return;
}
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
element?.focus();
focusAsset(asset.id);
}
invocation.endInvocation();

View File

@@ -1,68 +1,47 @@
<script lang="ts">
import OnEvents from '$lib/components/OnEvents.svelte';
import TableButton from '$lib/components/TableButton.svelte';
import { dateFormats } from '$lib/constants';
import ApiKeyCreateModal from '$lib/modals/ApiKeyCreateModal.svelte';
import ApiKeySecretModal from '$lib/modals/ApiKeySecretModal.svelte';
import ApiKeyUpdateModal from '$lib/modals/ApiKeyUpdateModal.svelte';
import { getApiKeyActions, getApiKeysActions } from '$lib/services/api-key.service';
import { locale } from '$lib/stores/preferences.store';
import { handleError } from '$lib/utils/handle-error';
import { deleteApiKey, getApiKeys, type ApiKeyResponseDto } from '@immich/sdk';
import { Button, IconButton, modalManager, toastManager } from '@immich/ui';
import { mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js';
import { getApiKeys, type ApiKeyResponseDto } from '@immich/sdk';
import { Button } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
type Props = {
keys: ApiKeyResponseDto[];
}
};
let { keys = $bindable() }: Props = $props();
async function refreshKeys() {
const onApiKeyCreate = async () => {
keys = await getApiKeys();
}
const handleCreate = async () => {
const secret = await modalManager.show(ApiKeyCreateModal);
if (!secret) {
return;
}
await modalManager.show(ApiKeySecretModal, { secret });
await refreshKeys();
};
const handleUpdate = async (key: ApiKeyResponseDto) => {
const success = await modalManager.show(ApiKeyUpdateModal, {
apiKey: key,
});
if (success) {
await refreshKeys();
const onApiKeyUpdate = (update: ApiKeyResponseDto) => {
for (const key of keys) {
if (key.id === update.id) {
Object.assign(key, update);
}
}
};
const handleDelete = async (key: ApiKeyResponseDto) => {
const isConfirmed = await modalManager.showDialog({ prompt: $t('delete_api_key_prompt') });
if (!isConfirmed) {
return;
}
try {
await deleteApiKey({ id: key.id });
toastManager.success($t('removed_api_key', { values: { name: key.name } }));
} catch (error) {
handleError(error, $t('errors.unable_to_remove_api_key'));
} finally {
await refreshKeys();
}
const onApiKeyDelete = ({ id }: ApiKeyResponseDto) => {
keys = keys.filter((apiKey) => apiKey.id !== id);
};
const { Create } = $derived(getApiKeysActions($t));
</script>
<OnEvents {onApiKeyCreate} {onApiKeyUpdate} {onApiKeyDelete} />
<section class="my-4">
<div class="flex flex-col gap-2" in:fade={{ duration: 500 }}>
<div class="mb-2 flex justify-end">
<Button shape="round" size="small" onclick={() => handleCreate()}>{$t('new_api_key')}</Button>
<Button leadingIcon={Create.icon} shape="round" size="small" onclick={() => Create.onAction(Create)}
>{Create.title}</Button
>
</div>
{#if keys.length > 0}
@@ -79,6 +58,7 @@
</thead>
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each keys as key (key.id)}
{@const { Update, Delete } = getApiKeyActions($t, key)}
<tr
class="flex h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
@@ -91,22 +71,8 @@
>{new Date(key.createdAt).toLocaleDateString($locale, dateFormats.settings)}
</td>
<td class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-1/4">
<IconButton
shape="round"
color="primary"
icon={mdiPencilOutline}
aria-label={$t('edit_key')}
size="small"
onclick={() => handleUpdate(key)}
/>
<IconButton
shape="round"
color="primary"
icon={mdiTrashCanOutline}
aria-label={$t('delete_key')}
size="small"
onclick={() => handleDelete(key)}
/>
<TableButton action={Update} size="small" />
<TableButton action={Delete} size="small" />
</td>
</tr>
{/each}

View File

@@ -1,3 +1,5 @@
export const UUID_REGEX = /^[\dA-Fa-f]{8}(?:\b-[\dA-Fa-f]{4}){3}\b-[\dA-Fa-f]{12}$/;
export enum AssetAction {
ARCHIVE = 'archive',
UNARCHIVE = 'unarchive',
@@ -20,7 +22,9 @@ export enum AssetAction {
export enum AppRoute {
ADMIN_USERS = '/admin/users',
ADMIN_LIBRARY_MANAGEMENT = '/admin/library-management',
ADMIN_USERS_NEW = '/admin/users/new',
ADMIN_LIBRARIES = '/admin/library-management',
ADMIN_LIBRARIES_NEW = '/admin/library-management/new',
ADMIN_SETTINGS = '/admin/system-settings',
ADMIN_STATS = '/admin/server-status',
ADMIN_QUEUES = '/admin/queues',

View File

@@ -2,6 +2,7 @@ import type { ThemeSetting } from '$lib/managers/theme-manager.svelte';
import type { ReleaseEvent } from '$lib/types';
import type {
AlbumResponseDto,
ApiKeyResponseDto,
LibraryResponseDto,
LoginResponseDto,
QueueResponseDto,
@@ -19,8 +20,13 @@ export type Events = {
LanguageChange: [{ name: string; code: string; rtl?: boolean }];
ThemeChange: [ThemeSetting];
ApiKeyCreate: [ApiKeyResponseDto];
ApiKeyUpdate: [ApiKeyResponseDto];
ApiKeyDelete: [ApiKeyResponseDto];
AssetReplace: [{ oldAssetId: string; newAssetId: string }];
AlbumUpdate: [AlbumResponseDto];
AlbumDelete: [AlbumResponseDto];
QueueUpdate: [QueueResponseDto];

View File

@@ -1,63 +1,41 @@
<script lang="ts">
import AlbumCover from '$lib/components/album-page/album-cover.svelte';
import { handleError } from '$lib/utils/handle-error';
import { updateAlbumInfo, type AlbumResponseDto } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Textarea } from '@immich/ui';
import { handleUpdateAlbum } from '$lib/services/album.service';
import { type AlbumResponseDto } from '@immich/sdk';
import { Field, FormModal, Input, Textarea } from '@immich/ui';
import { mdiRenameOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
album: AlbumResponseDto;
onClose: (album?: AlbumResponseDto) => void;
onClose: () => void;
};
let { album = $bindable(), onClose }: Props = $props();
let { album, onClose }: Props = $props();
let albumName = $state(album.albumName);
let description = $state(album.description);
let isSubmitting = $state(false);
const handleSubmit = async (event: Event) => {
event.preventDefault();
isSubmitting = true;
try {
await updateAlbumInfo({ id: album.id, updateAlbumDto: { albumName, description } });
album.albumName = albumName;
album.description = description;
onClose(album);
} catch (error) {
handleError(error, $t('errors.unable_to_update_album_info'));
} finally {
isSubmitting = false;
const onSubmit = async () => {
const success = await handleUpdateAlbum(album, { albumName, description });
if (success) {
onClose();
}
};
</script>
<Modal icon={mdiRenameOutline} title={$t('edit_album')} size="medium" {onClose}>
<ModalBody>
<form onsubmit={handleSubmit} autocomplete="off" id="edit-album-form">
<div class="flex items-center gap-8 m-4">
<AlbumCover {album} class="h-50 w-50 shadow-lg hidden sm:flex" />
<FormModal icon={mdiRenameOutline} title={$t('edit_album')} size="medium" {onClose} {onSubmit}>
<div class="flex items-center gap-8 m-4">
<AlbumCover {album} class="h-50 w-50 shadow-lg hidden sm:flex" />
<div class="grow flex flex-col gap-4">
<Field label={$t('name')}>
<Input bind:value={albumName} />
</Field>
<div class="grow flex flex-col gap-4">
<Field label={$t('name')}>
<Input bind:value={albumName} />
</Field>
<Field label={$t('description')}>
<Textarea bind:value={description} />
</Field>
</div>
</div>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth disabled={isSubmitting} form="edit-album-form">{$t('save')}</Button>
</HStack>
</ModalFooter>
</Modal>
<Field label={$t('description')}>
<Textarea bind:value={description} />
</Field>
</div>
</div>
</FormModal>

View File

@@ -1,12 +1,12 @@
<script lang="ts">
import ApiKeyPermissionsPicker from '$lib/components/ApiKeyPermissionsPicker.svelte';
import { handleError } from '$lib/utils/handle-error';
import { createApiKey, Permission } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, toastManager } from '@immich/ui';
import { handleCreateApiKey } from '$lib/services/api-key.service';
import { Permission } from '@immich/sdk';
import { Field, FormModal, Input } from '@immich/ui';
import { mdiKeyVariant } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = { onClose: (secret?: string) => void };
type Props = { onClose: () => void };
const { onClose }: Props = $props();
@@ -14,47 +14,22 @@
let selectedPermissions = $state<Permission[]>([]);
const isAllPermissions = $derived(selectedPermissions.length === Object.keys(Permission).length - 1);
const onsubmit = async () => {
if (!name) {
toastManager.warning($t('api_key_empty'));
return;
}
if (selectedPermissions.length === 0) {
toastManager.warning($t('permission_empty'));
return;
}
try {
const { secret } = await createApiKey({
apiKeyCreateDto: {
name,
permissions: isAllPermissions ? [Permission.All] : selectedPermissions,
},
});
onClose(secret);
} catch (error) {
handleError(error, $t('errors.unable_to_create_api_key'));
const onSubmit = async () => {
const success = await handleCreateApiKey({
name,
permissions: isAllPermissions ? [Permission.All] : selectedPermissions,
});
if (success) {
onClose();
}
};
</script>
<Modal title={$t('new_api_key')} icon={mdiKeyVariant} {onClose} size="giant">
<ModalBody>
<form {onsubmit} autocomplete="off" id="api-key-form">
<div class="mb-4 flex flex-col gap-2">
<Field label={$t('name')}>
<Input bind:value={name} />
</Field>
</div>
<ApiKeyPermissionsPicker bind:selectedPermissions />
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth form="api-key-form">{$t('create')}</Button>
</HStack>
</ModalFooter>
</Modal>
<FormModal title={$t('new_api_key')} icon={mdiKeyVariant} {onClose} {onSubmit} submitText={$t('create')} size="giant">
<div class="mb-4 flex flex-col gap-2">
<Field label={$t('name')}>
<Input bind:value={name} />
</Field>
</div>
<ApiKeyPermissionsPicker bind:selectedPermissions />
</FormModal>

View File

@@ -1,69 +1,44 @@
<script lang="ts">
import ApiKeyPermissionsPicker from '$lib/components/ApiKeyPermissionsPicker.svelte';
import { handleError } from '$lib/utils/handle-error';
import { Permission, updateApiKey } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, toastManager } from '@immich/ui';
import { handleUpdateApiKey } from '$lib/services/api-key.service';
import { Permission } from '@immich/sdk';
import { Field, FormModal, Input } from '@immich/ui';
import { mdiKeyVariant } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
type Props = {
apiKey: { id: string; name: string; permissions: Permission[] };
onClose: (success?: true) => void;
}
onClose: () => void;
};
let { apiKey, onClose }: Props = $props();
const isAllPermissions = (permissions: Permission[]) => permissions.length === Object.keys(Permission).length - 1;
const mapPermissions = (permissions: Permission[]) =>
permissions.includes(Permission.All)
? Object.values(Permission).filter((permission) => permission !== Permission.All)
: permissions;
const isAllPermissions = (permissions: Permission[]) => permissions.length === Object.keys(Permission).length - 1;
let name = $state(apiKey.name);
let selectedPermissions = $state<Permission[]>(mapPermissions(apiKey.permissions));
const onsubmit = async () => {
if (!name) {
toastManager.warning($t('api_key_empty'));
return;
}
if (selectedPermissions.length === 0) {
toastManager.warning($t('permission_empty'));
return;
}
try {
await updateApiKey({
id: apiKey.id,
apiKeyUpdateDto: {
name,
permissions: isAllPermissions(selectedPermissions) ? [Permission.All] : selectedPermissions,
},
});
toastManager.success($t('saved_api_key'));
onClose(true);
} catch (error) {
handleError(error, $t('errors.unable_to_save_api_key'));
const onSubmit = async () => {
const success = await handleUpdateApiKey(apiKey, {
name,
permissions: isAllPermissions(selectedPermissions) ? [Permission.All] : selectedPermissions,
});
if (success) {
onClose();
}
};
</script>
<Modal title={$t('api_key')} icon={mdiKeyVariant} {onClose} size="giant">
<ModalBody>
<form {onsubmit} autocomplete="off" id="api-key-form">
<div class="mb-4 flex flex-col gap-2">
<Field label={$t('name')}>
<Input bind:value={name} />
</Field>
</div>
<ApiKeyPermissionsPicker bind:selectedPermissions />
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth form="api-key-form">{$t('save')}</Button>
</HStack>
</ModalFooter>
</Modal>
<FormModal title={$t('api_key')} icon={mdiKeyVariant} {onClose} {onSubmit} size="giant">
<div class="mb-4 flex flex-col gap-2">
<Field label={$t('name')}>
<Input bind:value={name} />
</Field>
</div>
<ApiKeyPermissionsPicker bind:selectedPermissions />
</FormModal>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { tagAssets } from '$lib/utils/asset-utils';
import { getAllTags, upsertTags, type TagResponseDto } from '@immich/sdk';
import { Button, HStack, Icon, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { FormModal, Icon } from '@immich/ui';
import { mdiClose, mdiTag } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -25,7 +25,11 @@
allTags = await getAllTags();
});
const handleSubmit = async () => {
const onSubmit = async () => {
if (selectedIds.size === 0) {
return;
}
await tagAssets({ tagIds: [...selectedIds], assetIds, showNotification: false });
onClose(true);
};
@@ -47,60 +51,52 @@
const handleRemove = (tag: string) => {
selectedIds.delete(tag);
};
const onsubmit = async (event: Event) => {
event.preventDefault();
await handleSubmit();
};
</script>
<Modal size="small" title={$t('tag_assets')} icon={mdiTag} {onClose}>
<ModalBody>
<form {onsubmit} autocomplete="off" id="create-tag-form">
<div class="my-4 flex flex-col gap-2">
<Combobox
onSelect={handleSelect}
label={$t('tag')}
{allowCreate}
defaultFirstOption
options={allTags.map((tag) => ({ id: tag.id, label: tag.value, value: tag.id }))}
placeholder={$t('search_tags')}
forceFocus
/>
</div>
</form>
<FormModal
size="small"
title={$t('tag_assets')}
icon={mdiTag}
{onClose}
{onSubmit}
submitText={$t('tag_assets')}
{disabled}
>
<div class="my-4 flex flex-col gap-2">
<Combobox
onSelect={handleSelect}
label={$t('tag')}
{allowCreate}
defaultFirstOption
options={allTags.map((tag) => ({ id: tag.id, label: tag.value, value: tag.id }))}
placeholder={$t('search_tags')}
forceFocus
/>
</div>
<section class="flex flex-wrap pt-2 gap-1">
{#each selectedIds as tagId (tagId)}
{@const tag = tagMap[tagId]}
{#if tag}
<div class="flex group transition-all">
<span
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary roudned-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
>
<p class="text-sm">
{tag.value}
</p>
</span>
<section class="flex flex-wrap pt-2 gap-1">
{#each selectedIds as tagId (tagId)}
{@const tag = tagMap[tagId]}
{#if tag}
<div class="flex group transition-all">
<span
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary roudned-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
>
<p class="text-sm">
{tag.value}
</p>
</span>
<button
type="button"
class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-e-full place-items-center place-content-center pe-2 ps-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
title={$t('remove_tag')}
onclick={() => handleRemove(tagId)}
>
<Icon icon={mdiClose} />
</button>
</div>
{/if}
{/each}
</section>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" fullWidth color="secondary" onclick={() => onClose()}>{$t('cancel')}</Button>
<Button type="submit" shape="round" fullWidth form="create-tag-form" {disabled}>{$t('tag_assets')}</Button>
</HStack>
</ModalFooter>
</Modal>
<button
type="button"
class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-e-full place-items-center place-content-center pe-2 ps-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
title={$t('remove_tag')}
onclick={() => handleRemove(tagId)}
>
<Icon icon={mdiClose} />
</button>
</div>
{/if}
{/each}
</section>
</FormModal>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { handleAddLibraryExclusionPattern } from '$lib/services/library.service';
import type { LibraryResponseDto } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { Field, FormModal, Input, Text } from '@immich/ui';
import { mdiFolderSync } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -11,33 +11,26 @@
};
const { library, onClose }: Props = $props();
let exclusionPattern = $state('');
let value = $state('');
const onsubmit = async () => {
const success = await handleAddLibraryExclusionPattern(library, exclusionPattern);
const onSubmit = async () => {
const success = await handleAddLibraryExclusionPattern(library, value);
if (success) {
onClose();
}
};
</script>
<Modal title={$t('add_exclusion_pattern')} icon={mdiFolderSync} {onClose} size="small">
<ModalBody>
<form {onsubmit} autocomplete="off" id="library-exclusion-pattern-form">
<Text size="small" class="mb-4">{$t('admin.exclusion_pattern_description')}</Text>
<Field label={$t('pattern')}>
<Input bind:value={exclusionPattern} />
</Field>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth form="library-exclusion-pattern-form">
{$t('add')}
</Button>
</HStack>
</ModalFooter>
</Modal>
<FormModal
title={$t('add_exclusion_pattern')}
icon={mdiFolderSync}
{onClose}
{onSubmit}
submitText={$t('add')}
size="small"
>
<Text size="small" class="mb-4">{$t('admin.exclusion_pattern_description')}</Text>
<Field label={$t('pattern')}>
<Input bind:value />
</Field>
</FormModal>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { handleEditExclusionPattern } from '$lib/services/library.service';
import type { LibraryResponseDto } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { Field, FormModal, Input, Text } from '@immich/ui';
import { mdiFolderSync } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -11,35 +11,20 @@
onClose: () => void;
};
const { library, exclusionPattern, onClose }: Props = $props();
const { library, exclusionPattern: oldValue, onClose }: Props = $props();
let newValue = $state(oldValue);
let newExclusionPattern = $state(exclusionPattern);
const onsubmit = async () => {
const success = await handleEditExclusionPattern(library, exclusionPattern, newExclusionPattern);
const onSubmit = async () => {
const success = await handleEditExclusionPattern(library, oldValue, newValue);
if (success) {
onClose();
}
};
</script>
<Modal title={$t('edit_exclusion_pattern')} icon={mdiFolderSync} {onClose} size="small">
<ModalBody>
<form {onsubmit} autocomplete="off" id="library-exclusion-pattern-form">
<Text size="small" class="mb-4">{$t('admin.exclusion_pattern_description')}</Text>
<Field label={$t('pattern')}>
<Input bind:value={newExclusionPattern} />
</Field>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth form="library-exclusion-pattern-form">
{$t('save')}
</Button>
</HStack>
</ModalFooter>
</Modal>
<FormModal title={$t('edit_exclusion_pattern')} icon={mdiFolderSync} {onClose} {onSubmit} size="small">
<Text size="small" class="mb-4">{$t('admin.exclusion_pattern_description')}</Text>
<Field label={$t('pattern')}>
<Input bind:value={newValue} />
</Field>
</FormModal>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { handleAddLibraryFolder } from '$lib/services/library.service';
import type { LibraryResponseDto } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { Field, FormModal, Input, Text } from '@immich/ui';
import { mdiFolderSync } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -11,34 +11,26 @@
};
const { library, onClose }: Props = $props();
let folder = $state('');
const onsubmit = async () => {
const success = await handleAddLibraryFolder(library, folder);
let value = $state('');
const onSubmit = async () => {
const success = await handleAddLibraryFolder(library, value);
if (success) {
onClose();
}
};
</script>
<Modal title={$t('library_add_folder')} icon={mdiFolderSync} {onClose} size="small">
<ModalBody>
<form {onsubmit} autocomplete="off" id="library-import-path-form">
<Text size="small" class="mb-4">{$t('admin.library_folder_description')}</Text>
<Field label={$t('path')}>
<Input bind:value={folder} />
</Field>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth form="library-import-path-form">
{$t('add')}
</Button>
</HStack>
</ModalFooter>
</Modal>
<FormModal
title={$t('library_add_folder')}
icon={mdiFolderSync}
{onClose}
{onSubmit}
size="small"
submitText={$t('add')}
>
<Text size="small" class="mb-4">{$t('admin.library_folder_description')}</Text>
<Field label={$t('path')}>
<Input bind:value />
</Field>
</FormModal>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { handleEditLibraryFolder } from '$lib/services/library.service';
import type { LibraryResponseDto } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { Field, FormModal, Input, Text } from '@immich/ui';
import { mdiFolderSync } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -11,35 +11,21 @@
onClose: () => void;
};
const { library, folder, onClose }: Props = $props();
const { library, folder: oldValue, onClose }: Props = $props();
let newFolder = $state(folder);
let newValue = $state(oldValue);
const onsubmit = async () => {
const success = await handleEditLibraryFolder(library, folder, newFolder);
const onSubmit = async () => {
const success = await handleEditLibraryFolder(library, oldValue, newValue);
if (success) {
onClose();
}
};
</script>
<Modal title={$t('library_edit_folder')} icon={mdiFolderSync} {onClose} size="small">
<ModalBody>
<form {onsubmit} autocomplete="off" id="library-import-path-form">
<Text size="small" class="mb-4">{$t('admin.library_folder_description')}</Text>
<Field label={$t('path')}>
<Input bind:value={newFolder} />
</Field>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth form="library-import-path-form">
{$t('save')}
</Button>
</HStack>
</ModalFooter>
</Modal>
<FormModal title={$t('library_edit_folder')} icon={mdiFolderSync} {onClose} {onSubmit} size="small">
<Text size="small" class="mb-4">{$t('admin.library_folder_description')}</Text>
<Field label={$t('path')}>
<Input bind:value={newValue} />
</Field>
</FormModal>

View File

@@ -1,41 +0,0 @@
<script lang="ts">
import { handleRenameLibrary } from '$lib/services/library.service';
import type { LibraryResponseDto } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { mdiRenameOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
library: LibraryResponseDto;
onClose: () => void;
};
let { library, onClose }: Props = $props();
let newName = $state(library.name);
const onsubmit = async () => {
const success = await handleRenameLibrary(library, newName);
if (success) {
onClose();
}
};
</script>
<Modal icon={mdiRenameOutline} title={$t('rename')} {onClose} size="small">
<ModalBody>
<form {onsubmit} autocomplete="off" id="rename-library-form">
<Field label={$t('name')}>
<Input bind:value={newName} />
</Field>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" fullWidth color="secondary" onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" fullWidth type="submit" form="rename-library-form">{$t('save')}</Button>
</HStack>
</ModalFooter>
</Modal>

View File

@@ -2,7 +2,7 @@
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import DateInput from '$lib/elements/DateInput.svelte';
import type { MapSettings } from '$lib/stores/preferences.store';
import { Button, Field, HStack, Modal, ModalBody, ModalFooter, Stack, Switch } from '@immich/ui';
import { Button, Field, FormModal, Stack, Switch } from '@immich/ui';
import { Duration } from 'luxon';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
@@ -17,119 +17,107 @@
let customDateRange = $state(!!settings.dateAfter || !!settings.dateBefore);
const onsubmit = (event: Event) => {
event.preventDefault();
const onSubmit = () => {
onClose(settings);
};
</script>
<Modal title={$t('map_settings')} {onClose} size="small">
<ModalBody>
<form {onsubmit} id="map-settings-form">
<Stack gap={4}>
<Field label={$t('allow_dark_mode')}>
<Switch bind:checked={settings.allowDarkMode} />
</Field>
<Field label={$t('only_favorites')}>
<Switch bind:checked={settings.onlyFavorites} />
</Field>
<Field label={$t('include_archived')}>
<Switch bind:checked={settings.includeArchived} />
</Field>
<Field label={$t('include_shared_partner_assets')}>
<Switch bind:checked={settings.withPartners} />
</Field>
<Field label={$t('include_shared_albums')}>
<Switch bind:checked={settings.withSharedAlbums} />
</Field>
<FormModal title={$t('map_settings')} {onClose} {onSubmit} size="small">
<Stack gap={4}>
<Field label={$t('allow_dark_mode')}>
<Switch bind:checked={settings.allowDarkMode} />
</Field>
<Field label={$t('only_favorites')}>
<Switch bind:checked={settings.onlyFavorites} />
</Field>
<Field label={$t('include_archived')}>
<Switch bind:checked={settings.includeArchived} />
</Field>
<Field label={$t('include_shared_partner_assets')}>
<Switch bind:checked={settings.withPartners} />
</Field>
<Field label={$t('include_shared_albums')}>
<Switch bind:checked={settings.withSharedAlbums} />
</Field>
{#if customDateRange}
<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-8">
<label class="immich-form-label shrink-0 text-sm" for="date-after">{$t('date_after')}</label>
<DateInput
class="immich-form-input w-40"
type="date"
id="date-after"
max={settings.dateBefore}
bind:value={settings.dateAfter}
/>
</div>
<div class="flex items-center justify-between gap-8">
<label class="immich-form-label shrink-0 text-sm" for="date-before">{$t('date_before')}</label>
<DateInput class="immich-form-input w-40" type="date" id="date-before" bind:value={settings.dateBefore} />
</div>
<div class="flex justify-center text-xs">
<Button
color="primary"
size="small"
variant="ghost"
onclick={() => {
customDateRange = false;
settings.dateAfter = '';
settings.dateBefore = '';
}}
>
{$t('remove_custom_date_range')}
</Button>
</div>
</div>
{:else}
<div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-1">
<SettingSelect
label={$t('date_range')}
name="date-range"
bind:value={settings.relativeDate}
options={[
{
value: '',
text: $t('all'),
},
{
value: Duration.fromObject({ hours: 24 }).toISO() || '',
text: $t('past_durations.hours', { values: { hours: 24 } }),
},
{
value: Duration.fromObject({ days: 7 }).toISO() || '',
text: $t('past_durations.days', { values: { days: 7 } }),
},
{
value: Duration.fromObject({ days: 30 }).toISO() || '',
text: $t('past_durations.days', { values: { days: 30 } }),
},
{
value: Duration.fromObject({ years: 1 }).toISO() || '',
text: $t('past_durations.years', { values: { years: 1 } }),
},
{
value: Duration.fromObject({ years: 3 }).toISO() || '',
text: $t('past_durations.years', { values: { years: 3 } }),
},
]}
/>
<div class="text-xs">
<Button
color="primary"
size="small"
variant="ghost"
onclick={() => {
customDateRange = true;
settings.relativeDate = '';
}}
>
{$t('use_custom_date_range')}
</Button>
</div>
</div>
{/if}
</Stack>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button color="secondary" shape="round" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button type="submit" shape="round" fullWidth form="map-settings-form">{$t('save')}</Button>
</HStack>
</ModalFooter>
</Modal>
{#if customDateRange}
<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-8">
<label class="immich-form-label shrink-0 text-sm" for="date-after">{$t('date_after')}</label>
<DateInput
class="immich-form-input w-40"
type="date"
id="date-after"
max={settings.dateBefore}
bind:value={settings.dateAfter}
/>
</div>
<div class="flex items-center justify-between gap-8">
<label class="immich-form-label shrink-0 text-sm" for="date-before">{$t('date_before')}</label>
<DateInput class="immich-form-input w-40" type="date" id="date-before" bind:value={settings.dateBefore} />
</div>
<div class="flex justify-center text-xs">
<Button
color="primary"
size="small"
variant="ghost"
onclick={() => {
customDateRange = false;
settings.dateAfter = '';
settings.dateBefore = '';
}}
>
{$t('remove_custom_date_range')}
</Button>
</div>
</div>
{:else}
<div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-1">
<SettingSelect
label={$t('date_range')}
name="date-range"
bind:value={settings.relativeDate}
options={[
{
value: '',
text: $t('all'),
},
{
value: Duration.fromObject({ hours: 24 }).toISO() || '',
text: $t('past_durations.hours', { values: { hours: 24 } }),
},
{
value: Duration.fromObject({ days: 7 }).toISO() || '',
text: $t('past_durations.days', { values: { days: 7 } }),
},
{
value: Duration.fromObject({ days: 30 }).toISO() || '',
text: $t('past_durations.days', { values: { days: 30 } }),
},
{
value: Duration.fromObject({ years: 1 }).toISO() || '',
text: $t('past_durations.years', { values: { years: 1 } }),
},
{
value: Duration.fromObject({ years: 3 }).toISO() || '',
text: $t('past_durations.years', { values: { years: 3 } }),
},
]}
/>
<div class="text-xs">
<Button
color="primary"
size="small"
variant="ghost"
onclick={() => {
customDateRange = true;
settings.relativeDate = '';
}}
>
{$t('use_custom_date_range')}
</Button>
</div>
</div>
{/if}
</Stack>
</FormModal>

View File

@@ -2,17 +2,17 @@
import SharedLinkExpiration from '$lib/components/SharedLinkExpiration.svelte';
import { handleCreateSharedLink } from '$lib/services/shared-link.service';
import { SharedLinkType } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, PasswordInput, Switch, Text } from '@immich/ui';
import { Field, FormModal, Input, PasswordInput, Switch, Text } from '@immich/ui';
import { mdiLink } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
onClose: (success?: boolean) => void;
onClose: () => void;
albumId?: string;
assetIds?: string[];
}
let { onClose, albumId = $bindable(), assetIds = $bindable([]) }: Props = $props();
let { onClose, albumId, assetIds }: Props = $props();
let description = $state('');
let allowDownload = $state(true);
@@ -22,7 +22,7 @@
let slug = $state('');
let expiresAt = $state<string | null>(null);
let shareType = $derived(albumId ? SharedLinkType.Album : SharedLinkType.Individual);
let type = $derived(albumId ? SharedLinkType.Album : SharedLinkType.Individual);
$effect(() => {
if (!showMetadata) {
@@ -30,9 +30,9 @@
}
});
const onCreate = async () => {
const onSubmit = async () => {
const success = await handleCreateSharedLink({
type: shareType,
type,
albumId,
assetIds,
expiresAt,
@@ -43,61 +43,58 @@
showMetadata,
slug,
});
if (success) {
onClose(true);
onClose();
}
};
</script>
<Modal title={$t('create_link_to_share')} icon={mdiLink} size="small" {onClose}>
<ModalBody>
{#if shareType === SharedLinkType.Album}
<div>{$t('album_with_link_access')}</div>
{/if}
<FormModal
title={$t('create_link_to_share')}
icon={mdiLink}
size="small"
{onClose}
{onSubmit}
submitText={$t('create_link')}
>
{#if type === SharedLinkType.Album}
<div>{$t('album_with_link_access')}</div>
{/if}
{#if shareType === SharedLinkType.Individual}
<div>{$t('create_link_to_share_description')}</div>
{/if}
{#if type === SharedLinkType.Individual}
<div>{$t('create_link_to_share_description')}</div>
{/if}
<div class="flex flex-col gap-4 mt-4">
<div>
<Field label={$t('custom_url')} description={$t('shared_link_custom_url_description')}>
<Input bind:value={slug} autocomplete="off" />
</Field>
{#if slug}
<Text size="tiny" color="muted" class="pt-2 break-all">/s/{encodeURIComponent(slug)}</Text>
{/if}
</div>
<Field label={$t('password')} description={$t('shared_link_password_description')}>
<PasswordInput bind:value={password} autocomplete="new-password" />
</Field>
<Field label={$t('description')}>
<Input bind:value={description} autocomplete="off" />
</Field>
<SharedLinkExpiration bind:expiresAt />
<Field label={$t('show_metadata')}>
<Switch bind:checked={showMetadata} />
</Field>
<Field label={$t('allow_public_user_to_download')} disabled={!showMetadata}>
<Switch bind:checked={allowDownload} />
</Field>
<Field label={$t('allow_public_user_to_upload')}>
<Switch bind:checked={allowUpload} />
<div class="flex flex-col gap-4 mt-4">
<div>
<Field label={$t('custom_url')} description={$t('shared_link_custom_url_description')}>
<Input bind:value={slug} autocomplete="off" />
</Field>
{#if slug}
<Text size="tiny" color="muted" class="pt-2 break-all">/s/{encodeURIComponent(slug)}</Text>
{/if}
</div>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button color="secondary" shape="round" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button fullWidth shape="round" onclick={onCreate}>{$t('create_link')}</Button>
</HStack>
</ModalFooter>
</Modal>
<Field label={$t('password')} description={$t('shared_link_password_description')}>
<PasswordInput bind:value={password} autocomplete="new-password" />
</Field>
<Field label={$t('description')}>
<Input bind:value={description} autocomplete="off" />
</Field>
<SharedLinkExpiration bind:expiresAt />
<Field label={$t('show_metadata')}>
<Switch bind:checked={showMetadata} />
</Field>
<Field label={$t('allow_public_user_to_download')} disabled={!showMetadata}>
<Switch bind:checked={allowDownload} />
</Field>
<Field label={$t('allow_public_user_to_upload')}>
<Switch bind:checked={allowUpload} />
</Field>
</div>
</FormModal>

View File

@@ -1,98 +0,0 @@
<script lang="ts">
import SharedLinkExpiration from '$lib/components/SharedLinkExpiration.svelte';
import { handleUpdateSharedLink } from '$lib/services/shared-link.service';
import { SharedLinkType, type SharedLinkResponseDto } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, PasswordInput, Switch, Text } from '@immich/ui';
import { mdiLink } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
onClose: (success?: boolean) => void;
sharedLink: SharedLinkResponseDto;
}
let { onClose, sharedLink }: Props = $props();
let description = $state(sharedLink.description ?? '');
let allowDownload = $state(sharedLink.allowDownload);
let allowUpload = $state(sharedLink.allowUpload);
let showMetadata = $state(sharedLink.showMetadata);
let password = $state(sharedLink.password ?? '');
let slug = $state(sharedLink.slug ?? '');
let shareType = sharedLink.album ? SharedLinkType.Album : SharedLinkType.Individual;
let expiresAt = $state(sharedLink.expiresAt);
const onUpdate = async () => {
const success = await handleUpdateSharedLink(sharedLink, {
description,
password: password ?? null,
expiresAt,
allowUpload,
allowDownload,
showMetadata,
slug: slug.trim() ?? null,
});
if (success) {
onClose(true);
}
};
</script>
<Modal title={$t('edit_link')} icon={mdiLink} size="small" {onClose}>
<ModalBody>
{#if shareType === SharedLinkType.Album}
<div class="text-sm">
{$t('public_album')} |
<span class="text-primary">{sharedLink.album?.albumName}</span>
</div>
{/if}
{#if shareType === SharedLinkType.Individual}
<div class="text-sm">
{$t('individual_share')} |
<span class="text-primary">{sharedLink.description || ''}</span>
</div>
{/if}
<div class="flex flex-col gap-4 mt-4">
<div>
<Field label={$t('custom_url')} description={$t('shared_link_custom_url_description')}>
<Input bind:value={slug} autocomplete="off" />
</Field>
{#if slug}
<Text size="tiny" color="muted" class="pt-2">/s/{encodeURIComponent(slug)}</Text>
{/if}
</div>
<Field label={$t('password')} description={$t('shared_link_password_description')}>
<PasswordInput bind:value={password} autocomplete="new-password" />
</Field>
<Field label={$t('description')}>
<Input bind:value={description} autocomplete="off" />
</Field>
<SharedLinkExpiration createdAt={sharedLink.createdAt} bind:expiresAt />
<Field label={$t('show_metadata')}>
<Switch bind:checked={showMetadata} />
</Field>
<Field label={$t('allow_public_user_to_download')} disabled={!showMetadata}>
<Switch bind:checked={allowDownload} />
</Field>
<Field label={$t('allow_public_user_to_upload')}>
<Switch bind:checked={allowUpload} />
</Field>
</div>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button color="secondary" shape="round" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button fullWidth shape="round" onclick={onUpdate}>{$t('confirm')}</Button>
</HStack>
</ModalFooter>
</Modal>

View File

@@ -1,9 +1,6 @@
<script lang="ts">
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import type { RenderedOption } from '$lib/elements/Dropdown.svelte';
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { Field, FormModal, HelperText, NumberInput, Switch } from '@immich/ui';
import {
mdiArrowDownThin,
mdiArrowUpThin,
@@ -26,9 +23,9 @@
slideshowState,
} = slideshowStore;
interface Props {
type Props = {
onClose: () => void;
}
};
let { onClose }: Props = $props();
@@ -63,7 +60,7 @@
}
};
const applyChanges = () => {
const onSubmit = () => {
$slideshowDelay = tempSlideshowDelay;
$showProgressBar = tempShowProgressBar;
$slideshowNavigation = tempSlideshowNavigation;
@@ -75,41 +72,41 @@
};
</script>
<Modal size="small" title={$t('slideshow_settings')} onClose={() => onClose()}>
<ModalBody>
<div class="flex flex-col gap-4 text-primary">
<SettingDropdown
title={$t('direction')}
options={Object.values(navigationOptions)}
selectedOption={navigationOptions[tempSlideshowNavigation]}
onToggle={(option) => {
tempSlideshowNavigation = handleToggle(option, navigationOptions) || tempSlideshowNavigation;
}}
/>
<SettingDropdown
title={$t('look')}
options={Object.values(lookOptions)}
selectedOption={lookOptions[tempSlideshowLook]}
onToggle={(option) => {
tempSlideshowLook = handleToggle(option, lookOptions) || tempSlideshowLook;
}}
/>
<SettingSwitch title={$t('autoplay_slideshow')} bind:checked={tempSlideshowAutoplay} />
<SettingSwitch title={$t('show_progress_bar')} bind:checked={tempShowProgressBar} />
<SettingSwitch title={$t('show_slideshow_transition')} bind:checked={tempSlideshowTransition} />
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('duration')}
description={$t('admin.slideshow_duration_description')}
min={1}
bind:value={tempSlideshowDelay}
/>
</div>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button color="secondary" shape="round" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button fullWidth color="primary" shape="round" onclick={applyChanges}>{$t('confirm')}</Button>
</HStack>
</ModalFooter>
</Modal>
<FormModal size="small" title={$t('slideshow_settings')} {onClose} {onSubmit}>
<div class="flex flex-col gap-4">
<SettingDropdown
title={$t('direction')}
options={Object.values(navigationOptions)}
selectedOption={navigationOptions[tempSlideshowNavigation]}
onToggle={(option) => {
tempSlideshowNavigation = handleToggle(option, navigationOptions) || tempSlideshowNavigation;
}}
/>
<SettingDropdown
title={$t('look')}
options={Object.values(lookOptions)}
selectedOption={lookOptions[tempSlideshowLook]}
onToggle={(option) => {
tempSlideshowLook = handleToggle(option, lookOptions) || tempSlideshowLook;
}}
/>
<Field label={$t('autoplay_slideshow')}>
<Switch bind:checked={tempSlideshowAutoplay} />
</Field>
<Field label={$t('show_progress_bar')}>
<Switch bind:checked={tempShowProgressBar} />
</Field>
<Field label={$t('show_slideshow_transition')}>
<Switch bind:checked={tempSlideshowTransition} />
</Field>
<Field label={$t('duration')}>
<NumberInput min={1} bind:value={tempSlideshowDelay} />
<HelperText>{$t('admin.slideshow_duration_description')}</HelperText>
</Field>
</div>
</FormModal>

View File

@@ -1,4 +0,0 @@
export enum OnboardingRole {
SERVER = 'server',
USER = 'user',
}

View File

@@ -1,22 +0,0 @@
export enum UploadState {
PENDING,
STARTED,
DONE,
ERROR,
DUPLICATED,
}
export type UploadAsset = {
id: string;
file: File;
assetId?: string;
isTrashed?: boolean;
albumId?: string;
progress?: number;
state?: UploadState;
startDate?: number;
eta?: number;
speed?: number;
error?: unknown;
message?: string;
};

View File

@@ -1,10 +1,41 @@
import { goto } from '$app/navigation';
import ToastAction from '$lib/components/ToastAction.svelte';
import { AppRoute } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { downloadArchive } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import { deleteAlbum, type AlbumResponseDto } from '@immich/sdk';
import { deleteAlbum, updateAlbumInfo, type AlbumResponseDto, type UpdateAlbumDto } from '@immich/sdk';
import { modalManager, toastManager } from '@immich/ui';
export const handleUpdateAlbum = async ({ id }: { id: string }, dto: UpdateAlbumDto) => {
const $t = await getFormatter();
try {
const response = await updateAlbumInfo({ id, updateAlbumDto: dto });
eventManager.emit('AlbumUpdate', response);
toastManager.custom({
component: ToastAction,
props: {
color: 'primary',
title: $t('success'),
description: $t('album_info_updated'),
button: {
text: $t('view_album'),
color: 'primary',
onClick() {
return goto(`${AppRoute.ALBUMS}/${id}`);
},
},
},
});
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_update_album_info'));
}
};
export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { prompt?: boolean; notify?: boolean }) => {
const $t = await getFormatter();
const { prompt = true, notify = true } = options ?? {};

View File

@@ -0,0 +1,110 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import ApiKeyCreateModal from '$lib/modals/ApiKeyCreateModal.svelte';
import ApiKeySecretModal from '$lib/modals/ApiKeySecretModal.svelte';
import ApiKeyUpdateModal from '$lib/modals/ApiKeyUpdateModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import {
createApiKey,
deleteApiKey,
updateApiKey,
type ApiKeyCreateDto,
type ApiKeyResponseDto,
type ApiKeyUpdateDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
export const getApiKeysActions = ($t: MessageFormatter) => {
const Create: ActionItem = {
title: $t('new_api_key'),
icon: mdiPlus,
onAction: () => modalManager.show(ApiKeyCreateModal, {}),
};
return { Create };
};
export const getApiKeyActions = ($t: MessageFormatter, apiKey: ApiKeyResponseDto) => {
const Update: ActionItem = {
title: $t('edit_key'),
icon: mdiPencilOutline,
onAction: () => modalManager.show(ApiKeyUpdateModal, { apiKey }),
};
const Delete: ActionItem = {
title: $t('delete_key'),
icon: mdiTrashCanOutline,
onAction: () => handleDeleteApiKey(apiKey),
};
return { Update, Delete };
};
export const handleCreateApiKey = async (dto: ApiKeyCreateDto) => {
const $t = await getFormatter();
try {
if (!dto.name) {
toastManager.warning($t('api_key_empty'));
return;
}
if (dto.permissions.length === 0) {
toastManager.warning($t('permission_empty'));
return;
}
const { apiKey, secret } = await createApiKey({ apiKeyCreateDto: dto });
eventManager.emit('ApiKeyCreate', apiKey);
// no nested modal
void modalManager.show(ApiKeySecretModal, { secret });
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_create_api_key'));
}
};
export const handleUpdateApiKey = async (apiKey: { id: string }, dto: ApiKeyUpdateDto) => {
const $t = await getFormatter();
if (!dto.name) {
toastManager.warning($t('api_key_empty'));
return;
}
if (dto.permissions && dto.permissions.length === 0) {
toastManager.warning($t('permission_empty'));
return;
}
try {
const response = await updateApiKey({ id: apiKey.id, apiKeyUpdateDto: dto });
eventManager.emit('ApiKeyUpdate', response);
toastManager.success($t('saved_api_key'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_save_api_key'));
}
};
export const handleDeleteApiKey = async (apiKey: ApiKeyResponseDto) => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({ prompt: $t('delete_api_key_prompt') });
if (!confirmed) {
return;
}
try {
await deleteApiKey({ id: apiKey.id });
eventManager.emit('ApiKeyDelete', apiKey);
toastManager.success($t('removed_api_key', { values: { name: apiKey.name } }));
} catch (error) {
handleError(error, $t('errors.unable_to_remove_api_key'));
}
};

View File

@@ -1,6 +1,23 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { user as authUser } from '$lib/stores/user.store';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { copyAsset, deleteAssets } from '@immich/sdk';
import { AssetVisibility, copyAsset, deleteAssets, type AssetResponseDto } from '@immich/sdk';
import { modalManager, type ActionItem } from '@immich/ui';
import { mdiShareVariantOutline } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store';
export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) => {
const Share: ActionItem = {
title: $t('share'),
icon: mdiShareVariantOutline,
$if: () => !!(get(authUser) && !asset.isTrashed && asset.visibility !== AssetVisibility.Locked),
onAction: () => modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] }),
};
return { Share };
};
export const handleReplaceAsset = async (oldAssetId: string) => {
const [newAssetId] = await openFileUploadDialog({ multiple: false });

View File

@@ -1,12 +1,10 @@
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import LibraryCreateModal from '$lib/modals/LibraryCreateModal.svelte';
import LibraryExclusionPatternAddModal from '$lib/modals/LibraryExclusionPatternAddModal.svelte';
import LibraryExclusionPatternEditModal from '$lib/modals/LibraryExclusionPatternEditModal.svelte';
import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte';
import LibraryFolderEditModal from '$lib/modals/LibraryFolderEditModal.svelte';
import LibraryRenameModal from '$lib/modals/LibraryRenameModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import {
@@ -19,6 +17,7 @@ import {
updateLibrary,
type CreateLibraryDto,
type LibraryResponseDto,
type UpdateLibraryDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js';
@@ -38,7 +37,7 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
title: $t('create_library'),
type: $t('command'),
icon: mdiPlusBoxOutline,
onAction: () => handleShowLibraryCreateModal(),
onAction: () => goto(AppRoute.ADMIN_LIBRARIES_NEW),
shortcuts: { shift: true, key: 'n' },
};
@@ -46,11 +45,11 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
};
export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponseDto) => {
const Rename: ActionItem = {
const Edit: ActionItem = {
icon: mdiPencilOutline,
type: $t('command'),
title: $t('rename'),
onAction: () => modalManager.show(LibraryRenameModal, { library }),
title: $t('edit'),
onAction: () => goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}/edit`),
shortcuts: { key: 'r' },
};
@@ -85,7 +84,7 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
shortcuts: { shift: true, key: 'r' },
};
return { Rename, Delete, AddFolder, AddExclusionPattern, Scan };
return { Edit, Delete, AddFolder, AddExclusionPattern, Scan };
};
export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryResponseDto, folder: string) => {
@@ -150,7 +149,7 @@ const handleScanLibrary = async (library: LibraryResponseDto) => {
};
export const handleViewLibrary = async (library: LibraryResponseDto) => {
await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`);
await goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}`);
};
export const handleCreateLibrary = async (dto: CreateLibraryDto) => {
@@ -160,33 +159,24 @@ export const handleCreateLibrary = async (dto: CreateLibraryDto) => {
const library = await createLibrary({ createLibraryDto: dto });
eventManager.emit('LibraryCreate', library);
toastManager.success($t('admin.library_created', { values: { library: library.name } }));
return true;
return library;
} catch (error) {
handleError(error, $t('errors.unable_to_create_library'));
return false;
}
};
export const handleRenameLibrary = async (library: { id: string }, name?: string) => {
export const handleUpdateLibrary = async (library: LibraryResponseDto, dto: UpdateLibraryDto) => {
const $t = await getFormatter();
if (!name) {
return false;
}
try {
const updatedLibrary = await updateLibrary({
id: library.id,
updateLibraryDto: { name },
});
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: dto });
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
}
return true;
};
const handleDeleteLibrary = async (library: LibraryResponseDto) => {
@@ -241,14 +231,14 @@ export const handleAddLibraryFolder = async (library: LibraryResponseDto, folder
return true;
};
export const handleEditLibraryFolder = async (library: LibraryResponseDto, oldFolder: string, newFolder: string) => {
export const handleEditLibraryFolder = async (library: LibraryResponseDto, oldValue: string, newValue: string) => {
const $t = await getFormatter();
if (oldFolder === newFolder) {
if (oldValue === newValue) {
return true;
}
const importPaths = library.importPaths.map((path) => (path === oldFolder ? newFolder : path));
const importPaths = library.importPaths.map((path) => (path === oldValue ? newValue : path));
try {
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: { importPaths } });
@@ -309,20 +299,14 @@ export const handleAddLibraryExclusionPattern = async (library: LibraryResponseD
return true;
};
export const handleEditExclusionPattern = async (
library: LibraryResponseDto,
oldExclusionPattern: string,
newExclusionPattern: string,
) => {
export const handleEditExclusionPattern = async (library: LibraryResponseDto, oldValue: string, newValue: string) => {
const $t = await getFormatter();
if (oldExclusionPattern === newExclusionPattern) {
if (oldValue === newValue) {
return true;
}
const exclusionPatterns = library.exclusionPatterns.map((pattern) =>
pattern === oldExclusionPattern ? newExclusionPattern : pattern,
);
const exclusionPatterns = library.exclusionPatterns.map((pattern) => (pattern === oldValue ? newValue : pattern));
try {
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: { exclusionPatterns } });
@@ -357,7 +341,3 @@ const handleDeleteExclusionPattern = async (library: LibraryResponseDto, exclusi
handleError(error, $t('errors.unable_to_update_library'));
}
};
export const handleShowLibraryCreateModal = async () => {
await modalManager.show(LibraryCreateModal, {});
};

View File

@@ -9,6 +9,7 @@ import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import {
createSharedLink,
getSharedLinkById,
removeSharedLink,
removeSharedLinkAssets,
updateSharedLink,
@@ -24,7 +25,7 @@ export const getSharedLinkActions = ($t: MessageFormatter, sharedLink: SharedLin
const Edit: ActionItem = {
title: $t('edit_link'),
icon: mdiPencilOutline,
onAction: () => goto(`${AppRoute.SHARED_LINKS}/${sharedLink.id}`),
onAction: () => goto(`${AppRoute.SHARED_LINKS}/${sharedLink.id}/edit`),
};
const Delete: ActionItem = {
@@ -58,7 +59,11 @@ export const handleCreateSharedLink = async (dto: SharedLinkCreateDto) => {
const $t = await getFormatter();
try {
const sharedLink = await createSharedLink({ sharedLinkCreateDto: dto });
let sharedLink = await createSharedLink({ sharedLinkCreateDto: dto });
if (dto.albumId) {
// fetch album details, for event
sharedLink = await getSharedLinkById({ id: sharedLink.id });
}
eventManager.emit('SharedLinkCreate', sharedLink);

View File

@@ -1,10 +1,9 @@
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import PasswordResetSuccessModal from '$lib/modals/PasswordResetSuccessModal.svelte';
import UserCreateModal from '$lib/modals/UserCreateModal.svelte';
import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte';
import UserEditModal from '$lib/modals/UserEditModal.svelte';
import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte';
import { user as authUser } from '$lib/stores/user.store';
import type { HeaderButtonActionItem } from '$lib/types';
@@ -39,7 +38,7 @@ export const getUserAdminsActions = ($t: MessageFormatter) => {
title: $t('create_user'),
type: $t('command'),
icon: mdiPlusBoxOutline,
onAction: () => modalManager.show(UserCreateModal, {}),
onAction: () => goto(AppRoute.ADMIN_USERS_NEW),
shortcuts: { shift: true, key: 'n' },
};
@@ -50,7 +49,7 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
const Update: ActionItem = {
icon: mdiPencilOutline,
title: $t('edit'),
onAction: () => modalManager.show(UserEditModal, { user }),
onAction: () => goto(`${AppRoute.ADMIN_USERS}/${user.id}/edit`),
};
const Delete: ActionItem = {
@@ -103,7 +102,7 @@ export const handleCreateUserAdmin = async (dto: UserAdminCreateDto) => {
const response = await createUserAdmin({ userAdminCreateDto: dto });
eventManager.emit('UserAdminCreate', response);
toastManager.success();
return true;
return response;
} catch (error) {
handleError(error, $t('errors.unable_to_create_user'));
}

View File

@@ -1,5 +1,5 @@
import { UploadState, type UploadAsset } from '$lib/types';
import { derived, writable } from 'svelte/store';
import { UploadState, type UploadAsset } from '../models/upload-asset';
function createUploadStore() {
const uploadAssets = writable<Array<UploadAsset>>([]);

View File

@@ -12,3 +12,31 @@ export interface ReleaseEvent {
export type QueueSnapshot = { timestamp: number; snapshot?: QueueResponseDto[] };
export type HeaderButtonActionItem = ActionItem & { data?: { title?: string } };
export enum UploadState {
PENDING,
STARTED,
DONE,
ERROR,
DUPLICATED,
}
export type UploadAsset = {
id: string;
file: File;
assetId?: string;
isTrashed?: boolean;
albumId?: string;
progress?: number;
state?: UploadState;
startDate?: number;
eta?: number;
speed?: number;
error?: unknown;
message?: string;
};
export enum OnboardingRole {
SERVER = 'server',
USER = 'user',
}

View File

@@ -353,7 +353,7 @@ const supportedImageMimeTypes = new Set([
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755
if (isSafari) {
supportedImageMimeTypes.add('image/heic').add('image/heif');
supportedImageMimeTypes.add('image/heic').add('image/heif').add('image/jxl');
}
/**

View File

@@ -0,0 +1,540 @@
import { CancellableTask } from '$lib/utils/cancellable-task';
describe('CancellableTask', () => {
describe('execute', () => {
it('should execute task successfully and return LOADED', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async (_: AbortSignal) => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
const result = await task.execute(taskFn, true);
expect(result).toBe('LOADED');
expect(task.executed).toBe(true);
expect(task.loading).toBe(false);
expect(taskFn).toHaveBeenCalledTimes(1);
});
it('should call loadedCallback when task completes successfully', async () => {
const loadedCallback = vi.fn();
const task = new CancellableTask(loadedCallback);
const taskFn = vi.fn(async () => {});
await task.execute(taskFn, true);
expect(loadedCallback).toHaveBeenCalledTimes(1);
});
it('should return DONE if task is already executed', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {});
await task.execute(taskFn, true);
const result = await task.execute(taskFn, true);
expect(result).toBe('DONE');
expect(taskFn).toHaveBeenCalledTimes(1);
});
it('should wait if task is already running', async () => {
const task = new CancellableTask();
let resolveTask: () => void;
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFn = vi.fn(async () => {
await taskPromise;
});
const promise1 = task.execute(taskFn, true);
const promise2 = task.execute(taskFn, true);
expect(task.loading).toBe(true);
resolveTask!();
const [result1, result2] = await Promise.all([promise1, promise2]);
expect(result1).toBe('LOADED');
expect(result2).toBe('WAITED');
expect(taskFn).toHaveBeenCalledTimes(1);
});
it('should pass AbortSignal to task function', async () => {
const task = new CancellableTask();
let capturedSignal: AbortSignal | null = null;
const taskFn = async (signal: AbortSignal) => {
await Promise.resolve();
capturedSignal = signal;
};
await task.execute(taskFn, true);
expect(capturedSignal).toBeInstanceOf(AbortSignal);
});
it('should set cancellable flag correctly', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {});
expect(task.cancellable).toBe(true);
const promise = task.execute(taskFn, false);
expect(task.cancellable).toBe(false);
await promise;
});
it('should not allow transition from prevent cancel to allow cancel when task is running', async () => {
const task = new CancellableTask();
let resolveTask: () => void;
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFn = vi.fn(async () => {
await taskPromise;
});
const promise1 = task.execute(taskFn, false);
expect(task.cancellable).toBe(false);
const promise2 = task.execute(taskFn, true);
expect(task.cancellable).toBe(false);
resolveTask!();
await Promise.all([promise1, promise2]);
});
});
describe('cancel', () => {
it('should cancel a running task', async () => {
const task = new CancellableTask();
let taskStarted = false;
const taskFn = async (signal: AbortSignal) => {
taskStarted = true;
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const promise = task.execute(taskFn, true);
// Wait a bit to ensure task has started
await new Promise((resolve) => setTimeout(resolve, 10));
expect(taskStarted).toBe(true);
task.cancel();
const result = await promise;
expect(result).toBe('CANCELED');
expect(task.executed).toBe(false);
});
it('should call canceledCallback when task is canceled', async () => {
const canceledCallback = vi.fn();
const task = new CancellableTask(undefined, canceledCallback);
const taskFn = async (signal: AbortSignal) => {
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const promise = task.execute(taskFn, true);
await new Promise((resolve) => setTimeout(resolve, 10));
task.cancel();
await promise;
expect(canceledCallback).toHaveBeenCalledTimes(1);
});
it('should not cancel if task is not cancellable', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
const promise = task.execute(taskFn, false);
task.cancel();
const result = await promise;
expect(result).toBe('LOADED');
expect(task.executed).toBe(true);
});
it('should not cancel if task is already executed', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {});
await task.execute(taskFn, true);
expect(task.executed).toBe(true);
task.cancel();
expect(task.executed).toBe(true);
});
});
describe('reset', () => {
it('should reset task to initial state', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {});
await task.execute(taskFn, true);
expect(task.executed).toBe(true);
await task.reset();
expect(task.executed).toBe(false);
expect(task.cancelToken).toBe(null);
expect(task.loading).toBe(false);
});
it('should cancel running task before resetting', async () => {
const task = new CancellableTask();
const taskFn = async (signal: AbortSignal) => {
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const promise = task.execute(taskFn, true);
await new Promise((resolve) => setTimeout(resolve, 10));
const resetPromise = task.reset();
await promise;
await resetPromise;
expect(task.executed).toBe(false);
expect(task.loading).toBe(false);
});
it('should allow re-execution after reset', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {});
await task.execute(taskFn, true);
await task.reset();
const result = await task.execute(taskFn, true);
expect(result).toBe('LOADED');
expect(task.executed).toBe(true);
expect(taskFn).toHaveBeenCalledTimes(2);
});
});
describe('waitUntilCompletion', () => {
it('should return DONE if task is already executed', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {});
await task.execute(taskFn, true);
const result = await task.waitUntilCompletion();
expect(result).toBe('DONE');
});
it('should return WAITED if task completes while waiting', async () => {
const task = new CancellableTask();
let resolveTask: () => void;
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFn = async () => {
await taskPromise;
};
const executePromise = task.execute(taskFn, true);
const waitPromise = task.waitUntilCompletion();
resolveTask!();
const [, waitResult] = await Promise.all([executePromise, waitPromise]);
expect(waitResult).toBe('WAITED');
});
it('should return CANCELED if task is canceled', async () => {
const task = new CancellableTask();
const taskFn = async (signal: AbortSignal) => {
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const executePromise = task.execute(taskFn, true);
const waitPromise = task.waitUntilCompletion();
await new Promise((resolve) => setTimeout(resolve, 10));
task.cancel();
const [, waitResult] = await Promise.all([executePromise, waitPromise]);
expect(waitResult).toBe('CANCELED');
});
});
describe('waitUntilExecution', () => {
it('should return DONE if task is already executed', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {});
await task.execute(taskFn, true);
const result = await task.waitUntilExecution();
expect(result).toBe('DONE');
});
it('should return WAITED if task completes successfully', async () => {
const task = new CancellableTask();
let resolveTask: () => void;
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFn = async () => {
await taskPromise;
};
const executePromise = task.execute(taskFn, true);
const waitPromise = task.waitUntilExecution();
resolveTask!();
const [, waitResult] = await Promise.all([executePromise, waitPromise]);
expect(waitResult).toBe('WAITED');
});
it('should retry if task is canceled and wait for next execution', async () => {
vi.useFakeTimers();
const task = new CancellableTask();
let attempt = 0;
const taskFn = async (signal: AbortSignal) => {
attempt++;
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted && attempt === 1) {
throw new DOMException('Aborted', 'AbortError');
}
};
// Start first execution
const executePromise1 = task.execute(taskFn, true);
const waitPromise = task.waitUntilExecution();
// Cancel the first execution
vi.advanceTimersByTime(10);
task.cancel();
vi.advanceTimersByTime(100);
await executePromise1;
// Start second execution
const executePromise2 = task.execute(taskFn, true);
vi.advanceTimersByTime(100);
const [executeResult, waitResult] = await Promise.all([executePromise2, waitPromise]);
expect(executeResult).toBe('LOADED');
expect(waitResult).toBe('WAITED');
expect(attempt).toBe(2);
vi.useRealTimers();
});
});
describe('error handling', () => {
it('should return ERRORED when task throws non-abort error', async () => {
const task = new CancellableTask();
const error = new Error('Task failed');
const taskFn = async () => {
await Promise.resolve();
throw error;
};
const result = await task.execute(taskFn, true);
expect(result).toBe('ERRORED');
expect(task.executed).toBe(false);
});
it('should call errorCallback when task throws non-abort error', async () => {
const errorCallback = vi.fn();
const task = new CancellableTask(undefined, undefined, errorCallback);
const error = new Error('Task failed');
const taskFn = async () => {
await Promise.resolve();
throw error;
};
await task.execute(taskFn, true);
expect(errorCallback).toHaveBeenCalledTimes(1);
expect(errorCallback).toHaveBeenCalledWith(error);
});
it('should return CANCELED when task throws AbortError', async () => {
const task = new CancellableTask();
const taskFn = async () => {
await Promise.resolve();
throw new DOMException('Aborted', 'AbortError');
};
const result = await task.execute(taskFn, true);
expect(result).toBe('CANCELED');
expect(task.executed).toBe(false);
});
it('should allow re-execution after error', async () => {
const task = new CancellableTask();
const taskFn1 = async () => {
await Promise.resolve();
throw new Error('Failed');
};
const taskFn2 = vi.fn(async () => {});
const result1 = await task.execute(taskFn1, true);
expect(result1).toBe('ERRORED');
const result2 = await task.execute(taskFn2, true);
expect(result2).toBe('LOADED');
expect(task.executed).toBe(true);
});
});
describe('loading property', () => {
it('should return true when task is running', async () => {
const task = new CancellableTask();
let resolveTask: () => void;
const taskPromise = new Promise<void>((resolve) => {
resolveTask = resolve;
});
const taskFn = async () => {
await taskPromise;
};
expect(task.loading).toBe(false);
const promise = task.execute(taskFn, true);
expect(task.loading).toBe(true);
resolveTask!();
await promise;
expect(task.loading).toBe(false);
});
});
describe('complete promise', () => {
it('should resolve when task completes successfully', async () => {
const task = new CancellableTask();
const taskFn = vi.fn(async () => {});
const completePromise = task.complete;
await task.execute(taskFn, true);
await expect(completePromise).resolves.toBeUndefined();
});
it('should reject when task is canceled', async () => {
const task = new CancellableTask();
const taskFn = async (signal: AbortSignal) => {
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const completePromise = task.complete;
const promise = task.execute(taskFn, true);
await new Promise((resolve) => setTimeout(resolve, 10));
task.cancel();
await promise;
await expect(completePromise).rejects.toBeUndefined();
});
it('should reject when task errors', async () => {
const task = new CancellableTask();
const taskFn = async () => {
await Promise.resolve();
throw new Error('Failed');
};
const completePromise = task.complete;
await task.execute(taskFn, true);
await expect(completePromise).rejects.toBeUndefined();
});
});
describe('abort signal handling', () => {
it('should automatically call abort() on signal when task is canceled', async () => {
const task = new CancellableTask();
let capturedSignal: AbortSignal | null = null;
const taskFn = async (signal: AbortSignal) => {
capturedSignal = signal;
// Simulate a long-running task
await new Promise((resolve) => setTimeout(resolve, 100));
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
};
const promise = task.execute(taskFn, true);
// Wait a bit to ensure task has started
await new Promise((resolve) => setTimeout(resolve, 10));
expect(capturedSignal).not.toBeNull();
expect(capturedSignal!.aborted).toBe(false);
// Cancel the task
task.cancel();
// Verify the signal was aborted
expect(capturedSignal!.aborted).toBe(true);
const result = await promise;
expect(result).toBe('CANCELED');
});
it('should detect if signal was aborted after task completes', async () => {
const task = new CancellableTask();
let controller: AbortController | null = null;
const taskFn = async (_: AbortSignal) => {
// Capture the controller to abort it externally
controller = task.cancelToken;
// Simulate some work
await new Promise((resolve) => setTimeout(resolve, 10));
// Now abort before the function returns
controller?.abort();
};
const result = await task.execute(taskFn, true);
expect(result).toBe('CANCELED');
expect(task.executed).toBe(false);
});
it('should handle abort signal in async operations', async () => {
const task = new CancellableTask();
const taskFn = async (signal: AbortSignal) => {
// Simulate listening to abort signal during async operation
return new Promise<void>((resolve, reject) => {
signal.addEventListener('abort', () => {
reject(new DOMException('Aborted', 'AbortError'));
});
setTimeout(() => resolve(), 100);
});
};
const promise = task.execute(taskFn, true);
await new Promise((resolve) => setTimeout(resolve, 10));
task.cancel();
const result = await promise;
expect(result).toBe('CANCELED');
});
});
});

View File

@@ -15,15 +15,7 @@ export class CancellableTask {
private canceledCallback?: () => void,
private errorCallback?: (error: unknown) => void,
) {
this.complete = new Promise<void>((resolve, reject) => {
this.loadedSignal = resolve;
this.canceledSignal = reject;
}).catch(
() =>
// if no-one waits on complete its rejected a uncaught rejection message is logged.
// prevent this message with an empty reject handler, since waiting on a bucket is optional.
void 0,
);
this.init();
}
get loading() {
@@ -34,11 +26,30 @@ export class CancellableTask {
if (this.executed) {
return 'DONE';
}
// if there is a cancel token, task is currently executing, so wait on the promise. If it
// isn't, then the task is in new state, it hasn't been loaded, nor has it been executed.
// in either case, we wait on the promise.
await this.complete;
return 'WAITED';
// The `complete` promise resolves when executed, rejects when canceled/errored.
try {
const complete = this.complete;
await complete;
return 'WAITED';
} catch {
// ignore
}
return 'CANCELED';
}
async waitUntilExecution() {
// Keep retrying until the task completes successfully (not canceled)
for (;;) {
try {
if (this.executed) {
return 'DONE';
}
await this.complete;
return 'WAITED';
} catch {
continue;
}
}
}
async execute<F extends (abortSignal: AbortSignal) => Promise<void>>(f: F, cancellable: boolean) {
@@ -80,21 +91,14 @@ export class CancellableTask {
}
private init() {
this.cancelToken = null;
this.executed = false;
// create a promise, and store its resolve/reject callbacks. The loadedSignal callback
// will be incoked when a bucket is loaded, fulfilling the promise. The canceledSignal
// callback will be called if the bucket is canceled before it was loaded, rejecting the
// promise.
this.complete = new Promise<void>((resolve, reject) => {
this.cancelToken = null;
this.executed = false;
this.loadedSignal = resolve;
this.canceledSignal = reject;
}).catch(
() =>
// if no-one waits on complete its rejected a uncaught rejection message is logged.
// prevent this message with an empty reject handler, since waiting on a bucket is optional.
void 0,
);
});
// Suppress unhandled rejection warning
this.complete.catch(() => {});
}
// will reset this job back to the initial state (isLoaded=false, no errors, etc)

View File

@@ -1,8 +1,8 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { uploadManager } from '$lib/managers/upload-manager.svelte';
import { UploadState } from '$lib/models/upload-asset';
import { uploadAssetsStore } from '$lib/stores/upload';
import { user } from '$lib/stores/user.store';
import { UploadState } from '$lib/types';
import { uploadRequest } from '$lib/utils';
import { addAssetsToAlbum } from '$lib/utils/asset-utils';
import { ExecutorQueue } from '$lib/utils/executor-queue';

View File

@@ -1,6 +1,7 @@
import { UUID_REGEX } from '$lib/constants';
import type { ParamMatcher } from '@sveltejs/kit';
/* Returns true if the given param matches UUID format */
export const match: ParamMatcher = (param: string) => {
return /^[\dA-Fa-f]{8}(?:\b-[\dA-Fa-f]{4}){3}\b-[\dA-Fa-f]{12}$/.test(param);
return UUID_REGEX.test(param);
};

View File

@@ -6,20 +6,20 @@
import SharedLinkCard from '$lib/components/sharedlinks-page/SharedLinkCard.svelte';
import { AppRoute } from '$lib/constants';
import GroupTab from '$lib/elements/GroupTab.svelte';
import SharedLinkUpdateModal from '$lib/modals/SharedLinkUpdateModal.svelte';
import { getAllSharedLinks, SharedLinkType, type SharedLinkResponseDto } from '@immich/sdk';
import { onMount } from 'svelte';
import { Container } from '@immich/ui';
import { onMount, type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import type { LayoutData } from './$types';
type Props = {
data: PageData;
children?: Snippet;
data: LayoutData;
};
const { data }: Props = $props();
const { children, data }: Props = $props();
let sharedLinks: SharedLinkResponseDto[] = $state([]);
let sharedLink = $derived(sharedLinks.find(({ id }) => id === page.params.id));
const refresh = async () => {
sharedLinks = await getAllSharedLinks({});
@@ -80,7 +80,7 @@
</div>
{/snippet}
<div class="w-full max-w-3xl m-auto">
<Container center size="medium">
{#if sharedLinks.length === 0}
<div
class="flex place-content-center place-items-center rounded-lg bg-gray-100 dark:bg-immich-dark-gray dark:text-immich-gray p-12"
@@ -95,8 +95,6 @@
</div>
{/if}
{#if sharedLink}
<SharedLinkUpdateModal {sharedLink} onClose={() => goto(AppRoute.SHARED_LINKS)} />
{/if}
</div>
{@render children?.()}
</Container>
</UserPageLayout>

View File

@@ -0,0 +1,14 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { LayoutLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url);
const $t = await getFormatter();
return {
meta: {
title: $t('shared_links'),
},
};
}) satisfies LayoutLoad;

View File

@@ -0,0 +1,28 @@
import { AppRoute, UUID_REGEX } from '$lib/constants';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAllSharedLinks } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import type { LayoutLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url);
if (!UUID_REGEX.test(params.id)) {
redirect(302, AppRoute.SHARED_LINKS);
}
const [sharedLink] = await getAllSharedLinks({ id: params.id });
if (!sharedLink) {
redirect(302, AppRoute.SHARED_LINKS);
}
const $t = await getFormatter();
return {
sharedLink,
meta: {
title: $t('shared_links'),
},
};
}) satisfies LayoutLoad;

View File

@@ -0,0 +1,96 @@
<script lang="ts">
import { goto } from '$app/navigation';
import SharedLinkExpiration from '$lib/components/SharedLinkExpiration.svelte';
import { AppRoute } from '$lib/constants';
import { handleUpdateSharedLink } from '$lib/services/shared-link.service';
import { SharedLinkType } from '@immich/sdk';
import { Field, FormModal, Input, PasswordInput, Switch, Text } from '@immich/ui';
import { mdiLink } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
type Props = {
data: PageData;
};
let { data }: Props = $props();
const sharedLink = $state(data.sharedLink);
let description = $state(sharedLink.description ?? '');
let allowDownload = $state(sharedLink.allowDownload);
let allowUpload = $state(sharedLink.allowUpload);
let showMetadata = $state(sharedLink.showMetadata);
let password = $state(sharedLink.password ?? '');
let slug = $state(sharedLink.slug ?? '');
let shareType = sharedLink.album ? SharedLinkType.Album : SharedLinkType.Individual;
let expiresAt = $state(sharedLink.expiresAt);
const onClose = async () => {
await goto(`${AppRoute.SHARED_LINKS}`);
};
const onSubmit = async () => {
const success = await handleUpdateSharedLink(sharedLink, {
description,
password: password ?? null,
expiresAt,
allowUpload,
allowDownload,
showMetadata,
slug: slug.trim() ?? null,
});
if (success) {
await onClose();
}
};
</script>
<FormModal title={$t('edit_link')} icon={mdiLink} {onClose} {onSubmit} submitText={$t('confirm')} size="small">
{#if shareType === SharedLinkType.Album}
<div class="text-sm">
{$t('public_album')} |
<span class="text-primary">{sharedLink.album?.albumName}</span>
</div>
{/if}
{#if shareType === SharedLinkType.Individual}
<div class="text-sm">
{$t('individual_share')} |
<span class="text-primary">{sharedLink.description || ''}</span>
</div>
{/if}
<div class="flex flex-col gap-4 mt-4">
<div>
<Field label={$t('custom_url')} description={$t('shared_link_custom_url_description')}>
<Input bind:value={slug} autocomplete="off" />
</Field>
{#if slug}
<Text size="tiny" color="muted" class="pt-2">/s/{encodeURIComponent(slug)}</Text>
{/if}
</div>
<Field label={$t('password')} description={$t('shared_link_password_description')}>
<PasswordInput bind:value={password} autocomplete="new-password" />
</Field>
<Field label={$t('description')}>
<Input bind:value={description} autocomplete="off" />
</Field>
<SharedLinkExpiration createdAt={sharedLink.createdAt} bind:expiresAt />
<Field label={$t('show_metadata')}>
<Switch bind:checked={showMetadata} />
</Field>
<Field label={$t('allow_public_user_to_download')} disabled={!showMetadata}>
<Switch bind:checked={allowDownload} />
</Field>
<Field label={$t('allow_public_user_to_upload')}>
<Switch bind:checked={allowUpload} />
</Field>
</div>
</FormModal>

View File

@@ -0,0 +1,15 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url);
const $t = await getFormatter();
return {
meta: {
title: $t('shared_links'),
},
};
}) satisfies PageLoad;

View File

@@ -166,7 +166,7 @@
title: $t('external_libraries'),
description: $t('admin.external_libraries_page_description'),
icon: mdiBookshelf,
onAction: () => goto(AppRoute.ADMIN_LIBRARY_MANAGEMENT),
onAction: () => goto(AppRoute.ADMIN_LIBRARIES),
},
{
title: $t('server_stats'),

View File

@@ -3,5 +3,5 @@ import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load = (() => {
redirect(302, AppRoute.ADMIN_USERS);
redirect(302, AppRoute.ADMIN_SETTINGS);
}) satisfies PageLoad;

View File

@@ -4,27 +4,29 @@
import OnEvents from '$lib/components/OnEvents.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AppRoute } from '$lib/constants';
import { getLibrariesActions, handleShowLibraryCreateModal, handleViewLibrary } from '$lib/services/library.service';
import { getLibrariesActions, handleViewLibrary } from '$lib/services/library.service';
import { locale } from '$lib/stores/preferences.store';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
import { Button, CommandPaletteContext } from '@immich/ui';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { PageData } from './$types';
import type { LayoutData } from './$types';
type Props = {
data: PageData;
children?: Snippet;
data: LayoutData;
};
let { data }: Props = $props();
let { children, data }: Props = $props();
let libraries = $state(data.libraries);
let statistics = $state(data.statistics);
let owners = $state(data.owners);
const onLibraryCreate = async (library: LibraryResponseDto) => {
await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`);
await goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}`);
};
const onLibraryUpdate = async (library: LibraryResponseDto) => {
@@ -100,10 +102,12 @@
{:else}
<EmptyPlaceholder
text={$t('no_libraries_message')}
onClick={handleShowLibraryCreateModal}
onClick={() => goto(AppRoute.ADMIN_LIBRARIES_NEW)}
class="mt-10 mx-auto"
/>
{/if}
{@render children?.()}
</div>
</section>
</AdminPageLayout>

View File

@@ -1,7 +1,7 @@
import { authenticate, requestServerInfo } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAllLibraries, getLibraryStatistics, getUserAdmin, searchUsersAdmin } from '@immich/sdk';
import type { PageLoad } from './$types';
import type { LayoutLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
@@ -26,4 +26,4 @@ export const load = (async ({ url }) => {
title: $t('external_libraries'),
},
};
}) satisfies PageLoad;
}) satisfies LayoutLoad;

View File

@@ -1,5 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { AppRoute } from '$lib/constants';
import { handleCreateLibrary } from '$lib/services/library.service';
import { user } from '$lib/stores/user.store';
import { searchUsersAdmin } from '@immich/sdk';
@@ -8,12 +10,6 @@
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
type Props = {
onClose: () => void;
};
let { onClose }: Props = $props();
let ownerId: string = $state($user.id);
let userOptions: { value: string; text: string }[] = $state([]);
@@ -23,10 +19,14 @@
userOptions = users.map((user) => ({ value: user.id, text: user.name }));
});
const onClose = async () => {
await goto(AppRoute.ADMIN_LIBRARIES);
};
const onSubmit = async () => {
const success = await handleCreateLibrary({ ownerId });
if (success) {
onClose();
const library = await handleCreateLibrary({ ownerId });
if (library) {
await goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}`, { replaceState: true });
}
};
</script>

View File

@@ -0,0 +1,14 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
const $t = await getFormatter();
return {
meta: {
title: $t('external_libraries'),
},
};
}) satisfies PageLoad;

View File

@@ -0,0 +1,118 @@
<script lang="ts">
import { goto } from '$app/navigation';
import emptyFoldersUrl from '$lib/assets/empty-folders.svg';
import AdminCard from '$lib/components/AdminCard.svelte';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import TableButton from '$lib/components/TableButton.svelte';
import { AppRoute } from '$lib/constants';
import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte';
import {
getLibraryActions,
getLibraryExclusionPatternActions,
getLibraryFolderActions,
} from '$lib/services/library.service';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import type { LibraryResponseDto } from '@immich/sdk';
import { Code, CommandPaletteContext, Container, Heading, modalManager } from '@immich/ui';
import { mdiCameraIris, mdiChartPie, mdiFilterMinusOutline, mdiFolderOutline, mdiPlayCircle } from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import type { LayoutData } from './$types';
type Props = {
children?: Snippet;
data: LayoutData;
};
const { children, data }: Props = $props();
const statistics = data.statistics;
const [storageUsage, unit] = getBytesWithUnit(statistics.usage);
let library = $state(data.library);
const onLibraryUpdate = (newLibrary: LibraryResponseDto) => {
if (newLibrary.id === library.id) {
library = newLibrary;
}
};
const onLibraryDelete = async ({ id }: { id: string }) => {
if (id === library.id) {
await goto(AppRoute.ADMIN_LIBRARIES);
}
};
const { Edit, Delete, AddFolder, AddExclusionPattern, Scan } = $derived(getLibraryActions($t, library));
</script>
<OnEvents {onLibraryUpdate} {onLibraryDelete} />
<CommandPaletteContext commands={[Edit, Delete, AddFolder, AddExclusionPattern, Scan]} />
<AdminPageLayout
breadcrumbs={[{ title: $t('external_libraries'), href: AppRoute.ADMIN_LIBRARIES }, { title: library.name }]}
actions={[Scan, Edit, Delete]}
>
<Container size="large" center>
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 w-full">
<Heading tag="h1" size="large" class="col-span-full my-4">{library.name}</Heading>
<div class="flex flex-col lg:flex-row gap-4 col-span-full">
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} value={statistics.photos} />
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={statistics.videos} />
<ServerStatisticsCard icon={mdiChartPie} title={$t('usage')} value={storageUsage} {unit} />
</div>
<AdminCard icon={mdiFolderOutline} title={$t('folders')} headerAction={AddFolder}>
{#if library.importPaths.length === 0}
<EmptyPlaceholder
src={emptyFoldersUrl}
text={$t('admin.library_folder_description')}
fullWidth
onClick={() => modalManager.show(LibraryFolderAddModal, { library })}
/>
{:else}
<table class="w-full">
<tbody>
{#each library.importPaths as folder (folder)}
{@const { Edit, Delete } = getLibraryFolderActions($t, library, folder)}
<tr class="h-12">
<td>
<Code>{folder}</Code>
</td>
<td class="flex gap-2 justify-end">
<TableButton action={Edit} />
<TableButton action={Delete} />
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</AdminCard>
<AdminCard icon={mdiFilterMinusOutline} title={$t('exclusion_pattern')} headerAction={AddExclusionPattern}>
<table class="w-full">
<tbody>
{#each library.exclusionPatterns as exclusionPattern (exclusionPattern)}
{@const { Edit, Delete } = getLibraryExclusionPatternActions($t, library, exclusionPattern)}
<tr class="h-12">
<td>
<Code>{exclusionPattern}</Code>
</td>
<td class="flex gap-2 justify-end">
<TableButton action={Edit} />
<TableButton action={Delete} />
</td>
</tr>
{/each}
</tbody>
</table>
</AdminCard>
</div>
{@render children?.()}
</Container>
</AdminPageLayout>

View File

@@ -0,0 +1,29 @@
import { AppRoute } from '$lib/constants';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import type { LayoutLoad } from './$types';
export const load = (async ({ params: { id }, url }) => {
await authenticate(url, { admin: true });
let library: LibraryResponseDto;
try {
library = await getLibrary({ id });
} catch {
redirect(302, AppRoute.ADMIN_LIBRARIES);
}
const statistics = await getLibraryStatistics({ id });
const $t = await getFormatter();
return {
library,
statistics,
meta: {
title: $t('admin.library_details'),
},
};
}) satisfies LayoutLoad;

View File

@@ -1,105 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import emptyFoldersUrl from '$lib/assets/empty-folders.svg';
import AdminCard from '$lib/components/AdminCard.svelte';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import TableButton from '$lib/components/TableButton.svelte';
import { AppRoute } from '$lib/constants';
import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte';
import {
getLibraryActions,
getLibraryExclusionPatternActions,
getLibraryFolderActions,
} from '$lib/services/library.service';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { Code, CommandPaletteContext, Container, Heading, modalManager } from '@immich/ui';
import { mdiCameraIris, mdiChartPie, mdiFilterMinusOutline, mdiFolderOutline, mdiPlayCircle } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
type Props = {
data: PageData;
};
const { data }: Props = $props();
const statistics = data.statistics;
const [storageUsage, unit] = getBytesWithUnit(statistics.usage);
let library = $derived(data.library);
const { Rename, Delete, AddFolder, AddExclusionPattern, Scan } = $derived(getLibraryActions($t, library));
</script>
<OnEvents
onLibraryUpdate={(newLibrary) => (library = newLibrary)}
onLibraryDelete={({ id }) => id === library.id && goto(AppRoute.ADMIN_LIBRARY_MANAGEMENT)}
/>
<CommandPaletteContext commands={[Rename, Delete, AddFolder, AddExclusionPattern, Scan]} />
<AdminPageLayout
breadcrumbs={[{ title: $t('external_libraries'), href: AppRoute.ADMIN_LIBRARY_MANAGEMENT }, { title: library.name }]}
actions={[Scan, Rename, Delete]}
>
<Container size="large" center>
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 w-full">
<Heading tag="h1" size="large" class="col-span-full my-4">{library.name}</Heading>
<div class="flex flex-col lg:flex-row gap-4 col-span-full">
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} value={statistics.photos} />
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={statistics.videos} />
<ServerStatisticsCard icon={mdiChartPie} title={$t('usage')} value={storageUsage} {unit} />
</div>
<AdminCard icon={mdiFolderOutline} title={$t('folders')} headerAction={AddFolder}>
{#if library.importPaths.length === 0}
<EmptyPlaceholder
src={emptyFoldersUrl}
text={$t('admin.library_folder_description')}
fullWidth
onClick={() => modalManager.show(LibraryFolderAddModal, { library })}
/>
{:else}
<table class="w-full">
<tbody>
{#each library.importPaths as folder (folder)}
{@const { Edit, Delete } = getLibraryFolderActions($t, library, folder)}
<tr class="h-12">
<td>
<Code>{folder}</Code>
</td>
<td class="flex gap-2 justify-end">
<TableButton action={Edit} />
<TableButton action={Delete} />
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</AdminCard>
<AdminCard icon={mdiFilterMinusOutline} title={$t('exclusion_pattern')} headerAction={AddExclusionPattern}>
<table class="w-full">
<tbody>
{#each library.exclusionPatterns as exclusionPattern (exclusionPattern)}
{@const { Edit, Delete } = getLibraryExclusionPatternActions($t, library, exclusionPattern)}
<tr class="h-12">
<td>
<Code>{exclusionPattern}</Code>
</td>
<td class="flex gap-2 justify-end">
<TableButton action={Edit} />
<TableButton action={Delete} />
</td>
</tr>
{/each}
</tbody>
</table>
</AdminCard>
</div>
</Container>
</AdminPageLayout>

View File

@@ -1,26 +1,13 @@
import { AppRoute } from '$lib/constants';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load = (async ({ params: { id }, url }) => {
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
let library: LibraryResponseDto;
try {
library = await getLibrary({ id });
} catch {
redirect(302, AppRoute.ADMIN_LIBRARY_MANAGEMENT);
}
const statistics = await getLibraryStatistics({ id });
const $t = await getFormatter();
return {
library,
statistics,
meta: {
title: $t('admin.library_details'),
},

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
import { handleUpdateLibrary } from '$lib/services/library.service';
import { Field, FormModal, Input } from '@immich/ui';
import { mdiRenameOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from '../$types';
type Props = {
data: PageData;
};
let { data }: Props = $props();
const library = $state(data.library);
let name = $state(library.name);
const onClose = async () => {
await goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}`);
};
const onSubmit = async () => {
const success = await handleUpdateLibrary(library, { name });
if (success) {
await onClose();
}
};
</script>
<FormModal icon={mdiRenameOutline} title={$t('edit')} {onSubmit} {onClose} size="small">
<Field label={$t('name')}>
<Input bind:value={name} />
</Field>
</FormModal>

View File

@@ -0,0 +1,15 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
const $t = await getFormatter();
return {
meta: {
title: $t('admin.library_details'),
},
};
}) satisfies PageLoad;

View File

@@ -0,0 +1,93 @@
<script lang="ts">
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { getUserAdminsActions, handleNavigateUserAdmin } from '$lib/services/user-admin.service';
import { locale } from '$lib/stores/preferences.store';
import { getByteUnitString } from '$lib/utils/byte-units';
import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
import { Button, CommandPaletteContext, Container, Icon } from '@immich/ui';
import { mdiInfinity } from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import type { LayoutData } from './$types';
type Props = {
children?: Snippet;
data: LayoutData;
};
let { children, data }: Props = $props();
let users: UserAdminResponseDto[] = $state(data.users);
const onUpdate = async (user: UserAdminResponseDto) => {
const index = users.findIndex(({ id }) => id === user.id);
if (index === -1) {
users = await searchUsersAdmin({ withDeleted: true });
} else {
users[index] = user;
}
};
const onUserAdminDeleted = ({ id: userId }: { id: string }) => {
users = users.filter(({ id }) => id !== userId);
};
const { Create } = $derived(getUserAdminsActions($t));
</script>
<OnEvents
onUserAdminCreate={onUpdate}
onUserAdminUpdate={onUpdate}
onUserAdminDelete={onUpdate}
onUserAdminRestore={onUpdate}
{onUserAdminDeleted}
/>
<CommandPaletteContext commands={[Create]} />
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[Create]}>
<Container center size="large">
<table class="my-5 w-full text-start">
<thead
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
>
<tr class="flex w-full place-items-center">
<th class="w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12 text-center text-sm font-medium">{$t('email')}</th>
<th class="hidden sm:block w-3/12 text-center text-sm font-medium">{$t('name')}</th>
<th class="hidden xl:block w-3/12 2xl:w-2/12 text-center text-sm font-medium">{$t('has_quota')}</th>
</tr>
</thead>
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each users as user (user.id)}
<tr
class="flex h-20 overflow-hidden w-full place-items-center text-center dark:text-immich-dark-fg {user.deletedAt
? 'bg-red-300 dark:bg-red-900'
: 'even:bg-subtle/20 odd:bg-subtle/80'}"
>
<td class="w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12 text-ellipsis break-all px-2 text-sm">
{user.email}
</td>
<td class="hidden sm:block w-3/12 text-ellipsis break-all px-2 text-sm">{user.name}</td>
<td class="hidden xl:block w-3/12 2xl:w-2/12 text-ellipsis break-all px-2 text-sm">
<div class="container mx-auto flex flex-wrap justify-center">
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
{getByteUnitString(user.quotaSizeInBytes, $locale)}
{:else}
<Icon icon={mdiInfinity} size="16" />
{/if}
</div>
</td>
<td
class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-4/12 lg:w-3/12 xl:w-2/12 text-ellipsis break-all text-sm"
>
<Button onclick={() => handleNavigateUserAdmin(user)}>{$t('view')}</Button>
</td>
</tr>
{/each}
</tbody>
</table>
{@render children?.()}
</Container>
</AdminPageLayout>

View File

@@ -1,18 +1,18 @@
import { authenticate, requestServerInfo } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { searchUsersAdmin } from '@immich/sdk';
import type { PageLoad } from './$types';
import type { LayoutLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
await requestServerInfo();
const allUsers = await searchUsersAdmin({ withDeleted: true });
const users = await searchUsersAdmin({ withDeleted: true });
const $t = await getFormatter();
return {
allUsers,
users,
meta: {
title: $t('admin.user_management'),
},
};
}) satisfies PageLoad;
}) satisfies LayoutLoad;

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { handleCreateUserAdmin } from '$lib/services/user-admin.service';
import { userInteraction } from '$lib/stores/user.svelte';
@@ -18,12 +20,6 @@
} from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
onClose: () => void;
};
let { onClose }: Props = $props();
let success = $state(false);
let email = $state('');
@@ -46,6 +42,10 @@
const passwordMismatchMessage = $derived(passwordMismatch ? $t('password_does_not_match') : '');
const valid = $derived(!passwordMismatch && !isCreatingUser);
const onClose = async () => {
await goto(AppRoute.ADMIN_USERS);
};
const onSubmit = async (event: Event) => {
event.preventDefault();
@@ -55,7 +55,7 @@
isCreatingUser = true;
const success = await handleCreateUserAdmin({
const user = await handleCreateUserAdmin({
email,
password,
shouldChangePassword,
@@ -65,8 +65,8 @@
isAdmin,
});
if (success) {
onClose();
if (user) {
await goto(`${AppRoute.ADMIN_USERS}/${user.id}`, { replaceState: true });
}
isCreatingUser = false;

View File

@@ -0,0 +1,14 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
const $t = await getFormatter();
return {
meta: {
title: $t('admin.user_management'),
},
};
}) satisfies PageLoad;

View File

@@ -1,93 +0,0 @@
<script lang="ts">
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { getUserAdminsActions, handleNavigateUserAdmin } from '$lib/services/user-admin.service';
import { locale } from '$lib/stores/preferences.store';
import { getByteUnitString } from '$lib/utils/byte-units';
import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
import { Button, CommandPaletteContext, Icon } from '@immich/ui';
import { mdiInfinity } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
type Props = {
data: PageData;
};
let { data }: Props = $props();
let allUsers: UserAdminResponseDto[] = $state(data.allUsers);
const onUpdate = async (user: UserAdminResponseDto) => {
const index = allUsers.findIndex(({ id }) => id === user.id);
if (index === -1) {
allUsers = await searchUsersAdmin({ withDeleted: true });
} else {
allUsers[index] = user;
}
};
const onUserAdminDeleted = ({ id: userId }: { id: string }) => {
allUsers = allUsers.filter(({ id }) => id !== userId);
};
const { Create } = $derived(getUserAdminsActions($t));
</script>
<OnEvents
onUserAdminCreate={onUpdate}
onUserAdminUpdate={onUpdate}
onUserAdminDelete={onUpdate}
onUserAdminRestore={onUpdate}
{onUserAdminDeleted}
/>
<CommandPaletteContext commands={[Create]} />
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[Create]}>
<section id="setting-content" class="flex place-content-center sm:mx-4">
<section class="w-full pb-28 lg:w-212.5">
<table class="my-5 w-full text-start">
<thead
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
>
<tr class="flex w-full place-items-center">
<th class="w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12 text-center text-sm font-medium"
>{$t('email')}</th
>
<th class="hidden sm:block w-3/12 text-center text-sm font-medium">{$t('name')}</th>
<th class="hidden xl:block w-3/12 2xl:w-2/12 text-center text-sm font-medium">{$t('has_quota')}</th>
</tr>
</thead>
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each allUsers as user (user.id)}
<tr
class="flex h-20 overflow-hidden w-full place-items-center text-center dark:text-immich-dark-fg {user.deletedAt
? 'bg-red-300 dark:bg-red-900'
: 'even:bg-subtle/20 odd:bg-subtle/80'}"
>
<td class="w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12 text-ellipsis break-all px-2 text-sm">
{user.email}
</td>
<td class="hidden sm:block w-3/12 text-ellipsis break-all px-2 text-sm">{user.name}</td>
<td class="hidden xl:block w-3/12 2xl:w-2/12 text-ellipsis break-all px-2 text-sm">
<div class="container mx-auto flex flex-wrap justify-center">
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
{getByteUnitString(user.quotaSizeInBytes, $locale)}
{:else}
<Icon icon={mdiInfinity} size="16" />
{/if}
</div>
</td>
<td
class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-4/12 lg:w-3/12 xl:w-2/12 text-ellipsis break-all text-sm"
>
<Button onclick={() => handleNavigateUserAdmin(user)}>{$t('view')}</Button>
</td>
</tr>
{/each}
</tbody>
</table>
</section>
</section>
</AdminPageLayout>

View File

@@ -0,0 +1,222 @@
<script lang="ts">
import { goto } from '$app/navigation';
import AdminCard from '$lib/components/AdminCard.svelte';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import DeviceCard from '$lib/components/user-settings-page/device-card.svelte';
import FeatureSetting from '$lib/components/users/FeatureSetting.svelte';
import { AppRoute } from '$lib/constants';
import { getUserAdminActions } from '$lib/services/user-admin.service';
import { locale } from '$lib/stores/preferences.store';
import { createDateFormatter, findLocale } from '$lib/utils';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { type UserAdminResponseDto } from '@immich/sdk';
import {
Alert,
Badge,
Code,
CommandPaletteContext,
Container,
getByteUnitString,
Heading,
Icon,
MenuItemType,
Stack,
Text,
} from '@immich/ui';
import {
mdiAccountOutline,
mdiCameraIris,
mdiChartPie,
mdiChartPieOutline,
mdiCheckCircle,
mdiDevices,
mdiFeatureSearchOutline,
mdiPlayCircle,
mdiTrashCanOutline,
} from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import type { LayoutData } from './$types';
type Props = {
children?: Snippet;
data: LayoutData;
};
const { children, data }: Props = $props();
let user = $state(data.user);
const userPreferences = $state(data.userPreferences);
const userStatistics = $state(data.userStatistics);
const userSessions = $state(data.userSessions);
const TiB = 1024 ** 4;
const usage = $derived(user.quotaUsageInBytes ?? 0);
let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(usage, usage > TiB ? 2 : 0));
const usedBytes = $derived(user.quotaUsageInBytes ?? 0);
const availableBytes = $derived(user.quotaSizeInBytes ?? 1);
let usedPercentage = $derived(Math.min(Math.round((usedBytes / availableBytes) * 100), 100));
let editedLocale = $derived(findLocale($locale).code);
let createAtDate: Date = $derived(new Date(user.createdAt));
let updatedAtDate: Date = $derived(new Date(user.updatedAt));
let userCreatedAtDateAndTime: string = $derived(createDateFormatter(editedLocale).formatDateTime(createAtDate));
let userUpdatedAtDateAndTime: string = $derived(createDateFormatter(editedLocale).formatDateTime(updatedAtDate));
const getUsageClass = () => {
if (usedPercentage >= 95) {
return 'bg-red-500';
}
if (usedPercentage > 80) {
return 'bg-yellow-500';
}
return 'bg-primary';
};
const { ResetPassword, ResetPinCode, Update, Delete, Restore } = $derived(getUserAdminActions($t, user));
const onUpdate = (update: UserAdminResponseDto) => {
if (update.id === user.id) {
user = update;
}
};
const onUserAdminDeleted = async ({ id }: { id: string }) => {
if (id === user.id) {
await goto(AppRoute.ADMIN_USERS);
}
};
</script>
<OnEvents
onUserAdminUpdate={onUpdate}
onUserAdminDelete={onUpdate}
onUserAdminRestore={onUpdate}
{onUserAdminDeleted}
/>
<CommandPaletteContext commands={[ResetPassword, ResetPinCode, Update, Delete, Restore]} />
<AdminPageLayout
breadcrumbs={[{ title: $t('admin.user_management'), href: AppRoute.ADMIN_USERS }, { title: user.name }]}
actions={[ResetPassword, ResetPinCode, Update, Restore, MenuItemType.Divider, Delete]}
>
<div>
<Container size="large" center>
{#if user.deletedAt}
<Alert color="danger" class="my-4" title={$t('user_has_been_deleted')} icon={mdiTrashCanOutline} />
{/if}
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 w-full">
<div class="col-span-full flex flex-col gap-4 my-4">
<div class="flex items-center gap-4">
<UserAvatar {user} size="md" />
<Heading tag="h1" size="large">{user.name}</Heading>
</div>
{#if user.isAdmin}
<div>
<Badge color="primary" size="small">{$t('admin.admin_user')}</Badge>
</div>
{/if}
</div>
<div class="col-span-full">
<div class="flex flex-col lg:flex-row gap-4 w-full">
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} value={userStatistics.images} />
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={userStatistics.videos} />
<ServerStatisticsCard icon={mdiChartPie} title={$t('storage')} value={statsUsage} unit={statsUsageUnit} />
</div>
</div>
<AdminCard icon={mdiAccountOutline} title={$t('profile')}>
<Stack gap={2}>
<div>
<Heading tag="h3" size="tiny">{$t('name')}</Heading>
<Text>{user.name}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('email')}</Heading>
<Text>{user.email}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('created_at')}</Heading>
<Text>{userCreatedAtDateAndTime}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('updated_at')}</Heading>
<Text>{userUpdatedAtDateAndTime}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('id')}</Heading>
<Code>{user.id}</Code>
</div>
</Stack>
</AdminCard>
<AdminCard icon={mdiFeatureSearchOutline} title={$t('features')}>
<Stack gap={3}>
<FeatureSetting title={$t('email_notifications')} state={userPreferences.emailNotifications.enabled} />
<FeatureSetting title={$t('folders')} state={userPreferences.folders.enabled} />
<FeatureSetting title={$t('memories')} state={userPreferences.memories.enabled} />
<FeatureSetting title={$t('people')} state={userPreferences.people.enabled} />
<FeatureSetting title={$t('rating')} state={userPreferences.ratings.enabled} />
<FeatureSetting title={$t('shared_links')} state={userPreferences.sharedLinks.enabled} />
<FeatureSetting title={$t('show_supporter_badge')} state={userPreferences.purchase.showSupportBadge} />
<FeatureSetting title={$t('tags')} state={userPreferences.tags.enabled} />
<FeatureSetting title={$t('gcast_enabled')} state={userPreferences.cast.gCastEnabled} />
</Stack>
</AdminCard>
<AdminCard icon={mdiChartPieOutline} title={$t('storage_quota')}>
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
<Text>
{$t('storage_usage', {
values: {
used: getByteUnitString(usedBytes, $locale, 3),
available: getByteUnitString(availableBytes, $locale, 3),
},
})}
</Text>
{:else}
<Text class="flex items-center gap-1">
<Icon icon={mdiCheckCircle} size="1.25rem" class="text-success" />
{$t('unlimited')}
</Text>
{/if}
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
<div
class="storage-status p-4 mt-4 bg-gray-100 dark:bg-immich-dark-primary/10 rounded-lg text-sm w-full"
title={$t('storage_usage', {
values: {
used: getByteUnitString(usedBytes, $locale, 3),
available: getByteUnitString(availableBytes, $locale, 3),
},
})}
>
<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="h-[7px] rounded-full {getUsageClass()}" style="width: {usedPercentage}%"></div>
</div>
</div>
{/if}
</AdminCard>
<AdminCard icon={mdiDevices} title={$t('authorized_devices')}>
<Stack gap={3}>
{#each userSessions as session (session.id)}
<DeviceCard {session} />
{:else}
<span class="text-dark">{$t('no_devices')}</span>
{/each}
</Stack>
</AdminCard>
</div>
{@render children?.()}
</Container>
</div>
</AdminPageLayout>

View File

@@ -0,0 +1,38 @@
import { AppRoute, UUID_REGEX } from '$lib/constants';
import { authenticate, requestServerInfo } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getUserPreferencesAdmin, getUserSessionsAdmin, getUserStatisticsAdmin, searchUsersAdmin } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import type { LayoutLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url, { admin: true });
await requestServerInfo();
if (!UUID_REGEX.test(params.id)) {
redirect(302, AppRoute.ADMIN_USERS);
}
const [user] = await searchUsersAdmin({ id: params.id, withDeleted: true }).catch(() => []);
if (!user) {
redirect(302, AppRoute.ADMIN_USERS);
}
const [userPreferences, userStatistics, userSessions] = await Promise.all([
getUserPreferencesAdmin({ id: user.id }),
getUserStatisticsAdmin({ id: user.id }),
getUserSessionsAdmin({ id: user.id }),
]);
const $t = await getFormatter();
return {
user,
userPreferences,
userStatistics,
userSessions,
meta: {
title: $t('admin.user_details'),
},
};
}) satisfies LayoutLoad;

View File

@@ -1,218 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import AdminCard from '$lib/components/AdminCard.svelte';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import DeviceCard from '$lib/components/user-settings-page/device-card.svelte';
import FeatureSetting from '$lib/components/users/FeatureSetting.svelte';
import { AppRoute } from '$lib/constants';
import { getUserAdminActions } from '$lib/services/user-admin.service';
import { locale } from '$lib/stores/preferences.store';
import { createDateFormatter, findLocale } from '$lib/utils';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { type UserAdminResponseDto } from '@immich/sdk';
import {
Alert,
Badge,
Code,
CommandPaletteContext,
Container,
getByteUnitString,
Heading,
Icon,
MenuItemType,
Stack,
Text,
} from '@immich/ui';
import {
mdiAccountOutline,
mdiCameraIris,
mdiChartPie,
mdiChartPieOutline,
mdiCheckCircle,
mdiDevices,
mdiFeatureSearchOutline,
mdiPlayCircle,
mdiTrashCanOutline,
} from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
type Props = {
data: PageData;
};
const { data }: Props = $props();
let user = $derived(data.user);
const userPreferences = $derived(data.userPreferences);
const userStatistics = $derived(data.userStatistics);
const userSessions = $derived(data.userSessions);
const TiB = 1024 ** 4;
const usage = $derived(user.quotaUsageInBytes ?? 0);
let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(usage, usage > TiB ? 2 : 0));
const usedBytes = $derived(user.quotaUsageInBytes ?? 0);
const availableBytes = $derived(user.quotaSizeInBytes ?? 1);
let usedPercentage = $derived(Math.min(Math.round((usedBytes / availableBytes) * 100), 100));
let editedLocale = $derived(findLocale($locale).code);
let createAtDate: Date = $derived(new Date(user.createdAt));
let updatedAtDate: Date = $derived(new Date(user.updatedAt));
let userCreatedAtDateAndTime: string = $derived(createDateFormatter(editedLocale).formatDateTime(createAtDate));
let userUpdatedAtDateAndTime: string = $derived(createDateFormatter(editedLocale).formatDateTime(updatedAtDate));
const getUsageClass = () => {
if (usedPercentage >= 95) {
return 'bg-red-500';
}
if (usedPercentage > 80) {
return 'bg-yellow-500';
}
return 'bg-primary';
};
const { ResetPassword, ResetPinCode, Update, Delete, Restore } = $derived(getUserAdminActions($t, user));
const onUpdate = (update: UserAdminResponseDto) => {
if (update.id === user.id) {
user = update;
}
};
const onUserAdminDeleted = async ({ id }: { id: string }) => {
if (id === user.id) {
await goto(AppRoute.ADMIN_USERS);
}
};
</script>
<OnEvents
onUserAdminUpdate={onUpdate}
onUserAdminDelete={onUpdate}
onUserAdminRestore={onUpdate}
{onUserAdminDeleted}
/>
<CommandPaletteContext commands={[ResetPassword, ResetPinCode, Update, Delete, Restore]} />
<AdminPageLayout
breadcrumbs={[{ title: $t('admin.user_management'), href: AppRoute.ADMIN_USERS }, { title: user.name }]}
actions={[ResetPassword, ResetPinCode, Update, Restore, MenuItemType.Divider, Delete]}
>
<div>
<Container size="large" center>
{#if user.deletedAt}
<Alert color="danger" class="my-4" title={$t('user_has_been_deleted')} icon={mdiTrashCanOutline} />
{/if}
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 w-full">
<div class="col-span-full flex flex-col gap-4 my-4">
<div class="flex items-center gap-4">
<UserAvatar {user} size="md" />
<Heading tag="h1" size="large">{user.name}</Heading>
</div>
{#if user.isAdmin}
<div>
<Badge color="primary" size="small">{$t('admin.admin_user')}</Badge>
</div>
{/if}
</div>
<div class="col-span-full">
<div class="flex flex-col lg:flex-row gap-4 w-full">
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} value={userStatistics.images} />
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={userStatistics.videos} />
<ServerStatisticsCard icon={mdiChartPie} title={$t('storage')} value={statsUsage} unit={statsUsageUnit} />
</div>
</div>
<AdminCard icon={mdiAccountOutline} title={$t('profile')}>
<Stack gap={2}>
<div>
<Heading tag="h3" size="tiny">{$t('name')}</Heading>
<Text>{user.name}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('email')}</Heading>
<Text>{user.email}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('created_at')}</Heading>
<Text>{userCreatedAtDateAndTime}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('updated_at')}</Heading>
<Text>{userUpdatedAtDateAndTime}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('id')}</Heading>
<Code>{user.id}</Code>
</div>
</Stack>
</AdminCard>
<AdminCard icon={mdiFeatureSearchOutline} title={$t('features')}>
<Stack gap={3}>
<FeatureSetting title={$t('email_notifications')} state={userPreferences.emailNotifications.enabled} />
<FeatureSetting title={$t('folders')} state={userPreferences.folders.enabled} />
<FeatureSetting title={$t('memories')} state={userPreferences.memories.enabled} />
<FeatureSetting title={$t('people')} state={userPreferences.people.enabled} />
<FeatureSetting title={$t('rating')} state={userPreferences.ratings.enabled} />
<FeatureSetting title={$t('shared_links')} state={userPreferences.sharedLinks.enabled} />
<FeatureSetting title={$t('show_supporter_badge')} state={userPreferences.purchase.showSupportBadge} />
<FeatureSetting title={$t('tags')} state={userPreferences.tags.enabled} />
<FeatureSetting title={$t('gcast_enabled')} state={userPreferences.cast.gCastEnabled} />
</Stack>
</AdminCard>
<AdminCard icon={mdiChartPieOutline} title={$t('storage_quota')}>
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
<Text>
{$t('storage_usage', {
values: {
used: getByteUnitString(usedBytes, $locale, 3),
available: getByteUnitString(availableBytes, $locale, 3),
},
})}
</Text>
{:else}
<Text class="flex items-center gap-1">
<Icon icon={mdiCheckCircle} size="1.25rem" class="text-success" />
{$t('unlimited')}
</Text>
{/if}
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
<div
class="storage-status p-4 mt-4 bg-gray-100 dark:bg-immich-dark-primary/10 rounded-lg text-sm w-full"
title={$t('storage_usage', {
values: {
used: getByteUnitString(usedBytes, $locale, 3),
available: getByteUnitString(availableBytes, $locale, 3),
},
})}
>
<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="h-[7px] rounded-full {getUsageClass()}" style="width: {usedPercentage}%"></div>
</div>
</div>
{/if}
</AdminCard>
<AdminCard icon={mdiDevices} title={$t('authorized_devices')}>
<Stack gap={3}>
{#each userSessions as session (session.id)}
<DeviceCard {session} />
{:else}
<span class="text-dark">{$t('no_devices')}</span>
{/each}
</Stack>
</AdminCard>
</div>
</Container>
</div>
</AdminPageLayout>

View File

@@ -1,31 +1,12 @@
import { AppRoute } from '$lib/constants';
import { authenticate, requestServerInfo } from '$lib/utils/auth';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getUserPreferencesAdmin, getUserSessionsAdmin, getUserStatisticsAdmin, searchUsersAdmin } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
await requestServerInfo();
const [user] = await searchUsersAdmin({ id: params.id, withDeleted: true }).catch(() => []);
if (!user) {
redirect(302, AppRoute.ADMIN_USERS);
}
const [userPreferences, userStatistics, userSessions] = await Promise.all([
getUserPreferencesAdmin({ id: user.id }),
getUserStatisticsAdmin({ id: user.id }),
getUserSessionsAdmin({ id: user.id }),
]);
const $t = await getFormatter();
return {
user,
userPreferences,
userStatistics,
userSessions,
meta: {
title: $t('admin.user_details'),
},

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
import { handleUpdateUserAdmin } from '$lib/services/user-admin.service';
import { user as authUser } from '$lib/stores/user.store';
import { userInteraction } from '$lib/stores/user.svelte';
import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units';
import { type UserAdminResponseDto } from '@immich/sdk';
import {
Button,
Field,
@@ -20,22 +20,22 @@
} from '@immich/ui';
import { mdiAccountEditOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props {
user: UserAdminResponseDto;
onClose: () => void;
}
type Props = {
data: PageData;
};
let { user, onClose }: Props = $props();
let { data }: Props = $props();
let isAdmin = $derived(user.isAdmin);
let name = $derived(user.name);
let email = $derived(user.email);
let storageLabel = $derived(user.storageLabel || '');
const user = $state(data.user);
let isAdmin = $state(user.isAdmin);
let name = $state(user.name);
let email = $state(user.email);
let storageLabel = $state(user.storageLabel || '');
const previousQuota = $state(user.quotaSizeInBytes);
const previousQuota = user.quotaSizeInBytes;
let quotaSize = $state(
let quotaSize = $derived(
typeof user.quotaSizeInBytes === 'number' ? convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB) : undefined,
);
@@ -48,6 +48,10 @@
quotaSizeBytes > userInteraction.serverInfo.diskSizeRaw,
);
const onClose = async () => {
await goto(`${AppRoute.ADMIN_USERS}/${user.id}`);
};
const onSubmit = async (event: Event) => {
event.preventDefault();
@@ -60,7 +64,7 @@
});
if (success) {
onClose();
await onClose();
}
};
</script>

View File

@@ -0,0 +1,15 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
const $t = await getFormatter();
return {
meta: {
title: $t('admin.user_details'),
},
};
}) satisfies PageLoad;

View File

@@ -13,8 +13,8 @@
import { AppRoute, QueryParameter } from '$lib/constants';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { OnboardingRole } from '$lib/models/onboarding-role';
import { user } from '$lib/stores/user.store';
import { OnboardingRole } from '$lib/types';
import { setUserOnboarding, updateAdminOnboarding } from '@immich/sdk';
import {
mdiCellphoneArrowDownVariant,