Compare commits

...

14 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
88 changed files with 2176 additions and 1459 deletions

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

@@ -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,