1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-28 06:31:58 +00:00

Add ablum feature to web (#352)

* Added album page

* Refactor sidebar

* Added album assets count info

* Added album viewer page

* Refactor album sorting

* Fixed incorrectly showing selected asset in album selection

* Improve fetching speed with prefetch

* Refactor to use ImmichThubmnail component for all

* Update to the latest version of Svelte

* Implement fixed app bar in album viewer

* Added shared user avatar

* Correctly get all owned albums, including shared
This commit is contained in:
Alex 2022-07-15 23:18:17 -05:00 committed by GitHub
parent 1887b5a860
commit 7134f93eb8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
62 changed files with 2430 additions and 986 deletions

View file

@ -17,4 +17,7 @@ prod:
docker-compose -f ./docker/docker-compose.yml up --build -V --remove-orphans docker-compose -f ./docker/docker-compose.yml up --build -V --remove-orphans
prod-scale: prod-scale:
docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
api:
cd ./server && npm run api:generate

View file

@ -96,13 +96,16 @@ class MonthGroupTitle extends HookConsumerWidget {
color: Colors.grey, color: Colors.grey,
), ),
), ),
Padding( GestureDetector(
padding: const EdgeInsets.only(left: 8.0), onTap: _handleTitleIconClick,
child: Text( child: Padding(
_getSimplifiedMonth(), padding: const EdgeInsets.only(left: 8.0),
style: TextStyle( child: Text(
fontSize: 24, _getSimplifiedMonth(),
color: Theme.of(context).primaryColor, style: TextStyle(
fontSize: 24,
color: Theme.of(context).primaryColor,
),
), ),
), ),
), ),

View file

@ -26,17 +26,21 @@ class SelectionThumbnailImage extends HookConsumerWidget {
var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist; var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
Widget _buildSelectionIcon(AssetResponseDto asset) { Widget _buildSelectionIcon(AssetResponseDto asset) {
if (selectedAsset.contains(asset) && !isAlbumExist) { var isSelected = selectedAsset.map((item) => item.id).contains(asset.id);
var isNewlySelected =
newAssetsForAlbum.map((item) => item.id).contains(asset.id);
if (isSelected && !isAlbumExist) {
return Icon( return Icon(
Icons.check_circle, Icons.check_circle,
color: Theme.of(context).primaryColor, color: Theme.of(context).primaryColor,
); );
} else if (selectedAsset.contains(asset) && isAlbumExist) { } else if (isSelected && isAlbumExist) {
return const Icon( return const Icon(
Icons.check_circle, Icons.check_circle,
color: Color.fromARGB(255, 233, 233, 233), color: Color.fromARGB(255, 233, 233, 233),
); );
} else if (newAssetsForAlbum.contains(asset) && isAlbumExist) { } else if (isNewlySelected && isAlbumExist) {
return Icon( return Icon(
Icons.check_circle, Icons.check_circle,
color: Theme.of(context).primaryColor, color: Theme.of(context).primaryColor,
@ -50,17 +54,21 @@ class SelectionThumbnailImage extends HookConsumerWidget {
} }
BoxBorder drawBorderColor() { BoxBorder drawBorderColor() {
if (selectedAsset.contains(asset) && !isAlbumExist) { var isSelected = selectedAsset.map((item) => item.id).contains(asset.id);
var isNewlySelected =
newAssetsForAlbum.map((item) => item.id).contains(asset.id);
if (isSelected && !isAlbumExist) {
return Border.all( return Border.all(
color: Theme.of(context).primaryColorLight, color: Theme.of(context).primaryColorLight,
width: 10, width: 10,
); );
} else if (selectedAsset.contains(asset) && isAlbumExist) { } else if (isSelected && isAlbumExist) {
return Border.all( return Border.all(
color: const Color.fromARGB(255, 190, 190, 190), color: const Color.fromARGB(255, 190, 190, 190),
width: 10, width: 10,
); );
} else if (newAssetsForAlbum.contains(asset) && isAlbumExist) { } else if (isNewlySelected && isAlbumExist) {
return Border.all( return Border.all(
color: Theme.of(context).primaryColorLight, color: Theme.of(context).primaryColorLight,
width: 10, width: 10,
@ -71,10 +79,15 @@ class SelectionThumbnailImage extends HookConsumerWidget {
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
var isSelected =
selectedAsset.map((item) => item.id).contains(asset.id);
var isNewlySelected =
newAssetsForAlbum.map((item) => item.id).contains(asset.id);
if (isAlbumExist) { if (isAlbumExist) {
// Operation for existing album // Operation for existing album
if (!selectedAsset.contains(asset)) { if (!isSelected) {
if (newAssetsForAlbum.contains(asset)) { if (isNewlySelected) {
ref ref
.watch(assetSelectionProvider.notifier) .watch(assetSelectionProvider.notifier)
.removeSelectedAdditionalAssets([asset]); .removeSelectedAdditionalAssets([asset]);
@ -86,7 +99,7 @@ class SelectionThumbnailImage extends HookConsumerWidget {
} }
} else { } else {
// Operation for new album // Operation for new album
if (selectedAsset.contains(asset)) { if (isSelected) {
ref ref
.watch(assetSelectionProvider.notifier) .watch(assetSelectionProvider.notifier)
.removeSelectedNewAssets([asset]); .removeSelectedNewAssets([asset]);

View file

@ -37,6 +37,7 @@ doc/ServerPingResponse.md
doc/ServerVersionReponseDto.md doc/ServerVersionReponseDto.md
doc/SignUpDto.md doc/SignUpDto.md
doc/SmartInfoResponseDto.md doc/SmartInfoResponseDto.md
doc/ThumbnailFormat.md
doc/UpdateAlbumDto.md doc/UpdateAlbumDto.md
doc/UpdateDeviceInfoDto.md doc/UpdateDeviceInfoDto.md
doc/UpdateUserDto.md doc/UpdateUserDto.md
@ -90,6 +91,7 @@ lib/model/server_ping_response.dart
lib/model/server_version_reponse_dto.dart lib/model/server_version_reponse_dto.dart
lib/model/sign_up_dto.dart lib/model/sign_up_dto.dart
lib/model/smart_info_response_dto.dart lib/model/smart_info_response_dto.dart
lib/model/thumbnail_format.dart
lib/model/update_album_dto.dart lib/model/update_album_dto.dart
lib/model/update_device_info_dto.dart lib/model/update_device_info_dto.dart
lib/model/update_user_dto.dart lib/model/update_user_dto.dart
@ -97,4 +99,4 @@ lib/model/user_count_response_dto.dart
lib/model/user_response_dto.dart lib/model/user_response_dto.dart
lib/model/validate_access_token_response_dto.dart lib/model/validate_access_token_response_dto.dart
pubspec.yaml pubspec.yaml
test/validate_access_token_response_dto_test.dart test/thumbnail_format_test.dart

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -84,7 +84,7 @@ export class AlbumRepository implements IAlbumRepository {
}); });
} }
getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise<AlbumEntity[]> { async getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise<AlbumEntity[]> {
const filteringByShared = typeof getAlbumsDto.shared == 'boolean'; const filteringByShared = typeof getAlbumsDto.shared == 'boolean';
const userId = ownerId; const userId = ownerId;
let query = this.albumRepository.createQueryBuilder('album'); let query = this.albumRepository.createQueryBuilder('album');
@ -132,35 +132,44 @@ export class AlbumRepository implements IAlbumRepository {
query = query query = query
.leftJoinAndSelect('album.sharedUsers', 'sharedUser') .leftJoinAndSelect('album.sharedUsers', 'sharedUser')
.leftJoinAndSelect('sharedUser.userInfo', 'userInfo') .leftJoinAndSelect('sharedUser.userInfo', 'userInfo')
.where('album.ownerId = :ownerId', { ownerId: userId }) .where('album.ownerId = :ownerId', { ownerId: userId });
.orWhere((qb) => { // .orWhere((qb) => {
const subQuery = qb // const subQuery = qb
.subQuery() // .subQuery()
.select('userAlbum.albumId') // .select('userAlbum.albumId')
.from(UserAlbumEntity, 'userAlbum') // .from(UserAlbumEntity, 'userAlbum')
.where('userAlbum.sharedUserId = :sharedUserId', { sharedUserId: userId }) // .where('userAlbum.sharedUserId = :sharedUserId', { sharedUserId: userId })
.getQuery(); // .getQuery();
return `album.id IN ${subQuery}`; // return `album.id IN ${subQuery}`;
}); // });
} }
return query.orderBy('album.createdAt', 'DESC').getMany(); // Get information of assets in albums
query = query
.leftJoinAndSelect('album.assets', 'assets')
.leftJoinAndSelect('assets.assetInfo', 'assetInfo')
.orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC');
const albums = await query.getMany();
albums.sort((a, b) => new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf());
return albums;
} }
async get(albumId: string): Promise<AlbumEntity | undefined> { async get(albumId: string): Promise<AlbumEntity | undefined> {
const album = await this.albumRepository.findOne({ let query = this.albumRepository.createQueryBuilder('album');
where: { id: albumId },
relations: ['sharedUsers', 'sharedUsers.userInfo', 'assets', 'assets.assetInfo'], const album = await query
}); .where('album.id = :albumId', { albumId })
.leftJoinAndSelect('album.sharedUsers', 'sharedUser')
.leftJoinAndSelect('sharedUser.userInfo', 'userInfo')
.leftJoinAndSelect('album.assets', 'assets')
.leftJoinAndSelect('assets.assetInfo', 'assetInfo')
.orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC')
.getOne();
if (!album) { if (!album) {
return; return;
} }
// TODO: sort in query
const sortedSharedAsset = album.assets?.sort(
(a, b) => new Date(a.assetInfo.createdAt).valueOf() - new Date(b.assetInfo.createdAt).valueOf(),
);
album.assets = sortedSharedAsset;
return album; return album;
} }

View file

@ -43,6 +43,7 @@ import { AssetFileUploadDto } from './dto/asset-file-upload.dto';
import { CreateAssetDto } from './dto/create-asset.dto'; import { CreateAssetDto } from './dto/create-asset.dto';
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto'; import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto'; import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
import { GetAssetThumbnailDto } from './dto/get-asset-thumbnail.dto';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@ -109,8 +110,11 @@ export class AssetController {
} }
@Get('/thumbnail/:assetId') @Get('/thumbnail/:assetId')
async getAssetThumbnail(@Param('assetId') assetId: string): Promise<any> { async getAssetThumbnail(
return this.assetService.getAssetThumbnail(assetId); @Param('assetId') assetId: string,
@Query(new ValidationPipe({ transform: true })) query: GetAssetThumbnailDto,
): Promise<any> {
return this.assetService.getAssetThumbnail(assetId, query);
} }
@Get('/allObjects') @Get('/allObjects')

View file

@ -23,6 +23,7 @@ import { AssetResponseDto, mapAsset } from './response-dto/asset-response.dto';
import { AssetFileUploadDto } from './dto/asset-file-upload.dto'; import { AssetFileUploadDto } from './dto/asset-file-upload.dto';
import { CreateAssetDto } from './dto/create-asset.dto'; import { CreateAssetDto } from './dto/create-asset.dto';
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto'; import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto';
const fileInfo = promisify(stat); const fileInfo = promisify(stat);
@ -187,7 +188,7 @@ export class AssetService {
} }
} }
public async getAssetThumbnail(assetId: string) { public async getAssetThumbnail(assetId: string, query: GetAssetThumbnailDto) {
let fileReadStream: ReadStream; let fileReadStream: ReadStream;
const asset = await this.assetRepository.findOne({ where: { id: assetId } }); const asset = await this.assetRepository.findOne({ where: { id: assetId } });
@ -197,16 +198,25 @@ export class AssetService {
} }
try { try {
if (asset.webpPath && asset.webpPath.length > 0) { if (query.format == GetAssetThumbnailFormatEnum.JPEG) {
await fs.access(asset.webpPath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.webpPath);
} else {
if (!asset.resizePath) { if (!asset.resizePath) {
throw new NotFoundException('resizePath not set'); throw new NotFoundException('resizePath not set');
} }
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK); await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.resizePath); fileReadStream = createReadStream(asset.resizePath);
} else {
if (asset.webpPath && asset.webpPath.length > 0) {
await fs.access(asset.webpPath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.webpPath);
} else {
if (!asset.resizePath) {
throw new NotFoundException('resizePath not set');
}
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.resizePath);
}
} }
return new StreamableFile(fileReadStream); return new StreamableFile(fileReadStream);

View file

@ -0,0 +1,19 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
export enum GetAssetThumbnailFormatEnum {
JPEG = 'JPEG',
WEBP = 'WEBP',
}
export class GetAssetThumbnailDto {
@IsOptional()
@ApiProperty({
enum: GetAssetThumbnailFormatEnum,
default: GetAssetThumbnailFormatEnum.WEBP,
required: false,
enumName: 'ThumbnailFormat',
})
format = GetAssetThumbnailFormatEnum.WEBP;
}

File diff suppressed because one or more lines are too long

13
web/.eslintignore Normal file
View file

@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

View file

@ -6,15 +6,15 @@ module.exports = {
ignorePatterns: ['*.cjs'], ignorePatterns: ['*.cjs'],
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }], overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
settings: { settings: {
'svelte3/typescript': () => require('typescript'), 'svelte3/typescript': () => require('typescript')
}, },
parserOptions: { parserOptions: {
sourceType: 'module', sourceType: 'module',
ecmaVersion: 2020, ecmaVersion: 2020
}, },
env: { env: {
browser: true, browser: true,
es2017: true, es2017: true,
node: true, node: true
}, }
}; };

2
web/.gitignore vendored
View file

@ -6,5 +6,3 @@ node_modules
.env .env
.env.* .env.*
!.env.example !.env.example
.vercel
.output

13
web/.prettierignore Normal file
View file

@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

View file

@ -1,7 +1,6 @@
{ {
"useTabs": true, "useTabs": true,
"singleQuote": true, "singleQuote": true,
"trailingComma": "all", "trailingComma": "none",
"printWidth": 120, "printWidth": 100
"semi": true
} }

View file

@ -1,8 +0,0 @@
# default-template
## 0.0.2-next.0
### Patch Changes
- [chore] upgrade cookie library ([#4592](https://github.com/sveltejs/kit/pull/4592))

2334
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,22 +1,34 @@
{ {
"name": "web", "name": "immich-web",
"version": "0.0.1", "version": "1.0.0",
"scripts": { "scripts": {
"dev": "svelte-kit dev --host 0.0.0.0", "dev": "vite dev --host 0.0.0.0 --port 3000",
"build": "svelte-kit build", "build": "vite build",
"package": "svelte-kit package", "package": "svelte-kit package",
"preview": "svelte-kit preview", "preview": "vite preview",
"prepare": "svelte-kit sync", "prepare": "svelte-kit sync",
"check": "svelte-check --tsconfig ./tsconfig.json", "check": "svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .", "lint": "prettier --check --plugin-search-dir=. . && eslint .",
"format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. ." "format": "prettier --write --plugin-search-dir=. ."
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "next", "@sveltejs/adapter-auto": "next",
"@sveltejs/adapter-node": "^1.0.0-next.73",
"@sveltejs/kit": "next", "@sveltejs/kit": "next",
"@types/axios": "^0.14.0", "@typescript-eslint/eslint-plugin": "^5.27.0",
"@typescript-eslint/parser": "^5.27.0",
"eslint": "^8.16.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-svelte3": "^4.0.0",
"prettier": "^2.6.2",
"prettier-plugin-svelte": "^2.7.0",
"svelte": "^3.44.0",
"svelte-check": "^2.7.1",
"svelte-preprocess": "^4.10.6",
"tslib": "^2.3.1",
"typescript": "^4.7.4",
"vite": "^3.0.0",
"@sveltejs/adapter-node": "next",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/cookie": "^0.4.1", "@types/cookie": "^0.4.1",
"@types/fluent-ffmpeg": "^2.1.20", "@types/fluent-ffmpeg": "^2.1.20",
@ -24,21 +36,9 @@
"@types/lodash": "^4.14.182", "@types/lodash": "^4.14.182",
"@types/lodash-es": "^4.17.6", "@types/lodash-es": "^4.17.6",
"@types/socket.io-client": "^3.0.0", "@types/socket.io-client": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^5.10.1",
"@typescript-eslint/parser": "^5.10.1",
"autoprefixer": "^10.4.7", "autoprefixer": "^10.4.7",
"eslint": "^8.12.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-svelte3": "^4.0.0",
"postcss": "^8.4.13", "postcss": "^8.4.13",
"prettier": "^2.5.1", "tailwindcss": "^3.0.24"
"prettier-plugin-svelte": "^2.5.0",
"svelte": "^3.46.0",
"svelte-check": "^2.2.6",
"svelte-preprocess": "^4.10.1",
"tailwindcss": "^3.0.24",
"tslib": "^2.3.1",
"typescript": "~4.6.2"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {

View file

@ -957,6 +957,20 @@ export interface SmartInfoResponseDto {
*/ */
'objects'?: Array<string> | null; 'objects'?: Array<string> | null;
} }
/**
*
* @export
* @enum {string}
*/
export const ThumbnailFormat = {
Jpeg: 'JPEG',
Webp: 'WEBP'
} as const;
export type ThumbnailFormat = typeof ThumbnailFormat[keyof typeof ThumbnailFormat];
/** /**
* *
* @export * @export
@ -2069,10 +2083,11 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
/** /**
* *
* @param {string} assetId * @param {string} assetId
* @param {ThumbnailFormat} [format]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
getAssetThumbnail: async (assetId: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { getAssetThumbnail: async (assetId: string, format?: ThumbnailFormat, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'assetId' is not null or undefined // verify required parameter 'assetId' is not null or undefined
assertParamExists('getAssetThumbnail', 'assetId', assetId) assertParamExists('getAssetThumbnail', 'assetId', assetId)
const localVarPath = `/asset/thumbnail/{assetId}` const localVarPath = `/asset/thumbnail/{assetId}`
@ -2092,6 +2107,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
// http bearer authentication required // http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration) await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (format !== undefined) {
localVarQueryParameter['format'] = format;
}
setSearchParams(localVarUrlObj, localVarQueryParameter); setSearchParams(localVarUrlObj, localVarQueryParameter);
@ -2424,11 +2443,12 @@ export const AssetApiFp = function(configuration?: Configuration) {
/** /**
* *
* @param {string} assetId * @param {string} assetId
* @param {ThumbnailFormat} [format]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
async getAssetThumbnail(assetId: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<object>> { async getAssetThumbnail(assetId: string, format?: ThumbnailFormat, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<object>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetThumbnail(assetId, options); const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetThumbnail(assetId, format, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/** /**
@ -2564,11 +2584,12 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
/** /**
* *
* @param {string} assetId * @param {string} assetId
* @param {ThumbnailFormat} [format]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
getAssetThumbnail(assetId: string, options?: any): AxiosPromise<object> { getAssetThumbnail(assetId: string, format?: ThumbnailFormat, options?: any): AxiosPromise<object> {
return localVarFp.getAssetThumbnail(assetId, options).then((request) => request(axios, basePath)); return localVarFp.getAssetThumbnail(assetId, format, options).then((request) => request(axios, basePath));
}, },
/** /**
* *
@ -2709,12 +2730,13 @@ export class AssetApi extends BaseAPI {
/** /**
* *
* @param {string} assetId * @param {string} assetId
* @param {ThumbnailFormat} [format]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
* @memberof AssetApi * @memberof AssetApi
*/ */
public getAssetThumbnail(assetId: string, options?: AxiosRequestConfig) { public getAssetThumbnail(assetId: string, format?: ThumbnailFormat, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getAssetThumbnail(assetId, options).then((request) => request(this.axios, this.basePath)); return AssetApiFp(this.configuration).getAssetThumbnail(assetId, format, options).then((request) => request(this.axios, this.basePath));
} }
/** /**

View file

@ -1,12 +1,15 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head>
<meta charset="utf-8" /> <head>
<link rel="icon" href="%svelte.assets%/favicon.png" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <link rel="icon" href="%sveltekit.assets%/favicon.png" />
%svelte.head% <meta name="viewport" content="width=device-width, initial-scale=1" />
</head> %sveltekit.head%
<body> </head>
<div>%svelte.body%</div>
</body> <body>
</html> <div>%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,57 @@
<script lang="ts">
import { AlbumResponseDto, api, ThumbnailFormat } from '@api';
import { createEventDispatcher, onMount } from 'svelte';
import { fade } from 'svelte/transition';
export let album: AlbumResponseDto;
let imageData: string = '/no-thumbnail.png';
const dispatch = createEventDispatcher();
const loadImageData = async (thubmnailId: string | null) => {
if (thubmnailId == null) {
return '/no-thumbnail.png';
}
const { data } = await api.assetApi.getAssetThumbnail(thubmnailId!, ThumbnailFormat.Jpeg, { responseType: 'blob' });
if (data instanceof Blob) {
imageData = URL.createObjectURL(data);
return imageData;
}
};
</script>
<div class="h-[339px] w-[275px] hover:cursor-pointer mt-4" on:click={() => dispatch('click', album)}>
<div class={`h-[275px] w-[275px]`}>
{#await loadImageData(album.albumThumbnailAssetId)}
<div class={`bg-immich-primary/10 w-full h-full flex place-items-center place-content-center rounded-xl`}>
...
</div>
{:then imageData}
<img
in:fade={{ duration: 250 }}
src={imageData}
alt={album.id}
class={`object-cover w-full h-full transition-all z-0 rounded-xl duration-300 hover:translate-x-2 hover:-translate-y-2 hover:shadow-[-8px_8px_0px_0_#FFB800]`}
/>
{/await}
</div>
<div class="mt-4">
<p class="text-sm font-medium text-gray-800">
{album.albumName}
</p>
<span class="text-xs flex gap-2">
<p>{album.assets.length} items</p>
{#if album.shared}
<p>·</p>
<p>Shared</p>
{/if}
</span>
</div>
</div>
<style>
</style>

View file

@ -0,0 +1,101 @@
<script lang="ts">
import { AlbumResponseDto, ThumbnailFormat } from '@api';
import { createEventDispatcher, onMount } from 'svelte';
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
import FileImagePlusOutline from 'svelte-material-icons/FileImagePlusOutline.svelte';
import CircleAvatar from '../shared/circle-avatar.svelte';
import ImmichThumbnail from '../shared/immich-thumbnail.svelte';
const dispatch = createEventDispatcher();
export let album: AlbumResponseDto;
let viewWidth: number;
let thumbnailSize: number = 300;
let border = '';
$: {
if (album.assets.length < 6) {
thumbnailSize = Math.floor(viewWidth / album.assets.length - album.assets.length);
} else {
thumbnailSize = Math.floor(viewWidth / 6 - 6);
}
}
const getDateRange = () => {
const startDate = new Date(album.assets[0].createdAt);
const endDate = new Date(album.assets[album.assets.length - 1].createdAt);
const startDateString = startDate.toLocaleDateString('us-EN', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
const endDateString = endDate.toLocaleDateString('us-EN', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
return `${startDateString} - ${endDateString}`;
};
onMount(() => {
window.onscroll = (event: Event) => {
if (window.pageYOffset > 80) {
border = 'border border-gray-200 bg-gray-50';
} else {
border = '';
}
};
});
</script>
<section class="w-screen h-screen bg-immich-bg">
<div class="fixed top-0 w-full bg-immich-bg z-[100]">
<div class={`flex justify-between rounded-lg ${border} p-2 mx-2 mt-2 transition-all`}>
<a sveltekit:prefetch href="/albums" title="Go Back">
<button
id="immich-circle-icon-button"
class={`rounded-full p-3 flex place-items-center place-content-center text-gray-600 transition-all hover:bg-gray-200`}
>
<ArrowLeft size="24" />
</button>
</a>
<div class="right-button-group" title="Add Photos">
<button
id="immich-circle-icon-button"
class={`rounded-full p-3 flex place-items-center place-content-center text-gray-600 transition-all hover:bg-gray-200`}
on:click={() => dispatch('click')}
>
<FileImagePlusOutline size="24" />
</button>
</div>
</div>
</div>
<section class="m-6 py-[72px] px-[160px]">
<p class="text-6xl text-immich-primary">
{album.albumName}
</p>
<p class="my-4 text-sm text-gray-500">{getDateRange()}</p>
{#if album.sharedUsers.length > 0}
<div class="mb-4">
{#each album.sharedUsers as user}
<span class="mr-1">
<CircleAvatar {user} />
</span>
{/each}
</div>
{/if}
<div class="flex flex-wrap gap-1 w-full" bind:clientWidth={viewWidth}>
{#each album.assets as asset}
{#if album.assets.length < 7}
<ImmichThumbnail {asset} {thumbnailSize} format={ThumbnailFormat.Jpeg} />
{:else}
<ImmichThumbnail {asset} {thumbnailSize} />
{/if}
{/each}
</div>
</section>
</section>

View file

@ -6,7 +6,7 @@
import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte'; import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
import TrashCanOutline from 'svelte-material-icons/TrashCanOutline.svelte'; import TrashCanOutline from 'svelte-material-icons/TrashCanOutline.svelte';
import InformationOutline from 'svelte-material-icons/InformationOutline.svelte'; import InformationOutline from 'svelte-material-icons/InformationOutline.svelte';
import CircleIconButton from '../shared/circle_icon_button.svelte'; import CircleIconButton from '../shared/circle-icon-button.svelte';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
</script> </script>

View file

@ -5,13 +5,12 @@
import { flattenAssetGroupByDate } from '$lib/stores/assets'; import { flattenAssetGroupByDate } from '$lib/stores/assets';
import ChevronRight from 'svelte-material-icons/ChevronRight.svelte'; import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte'; import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
import { AssetType } from '../../models/immich-asset';
import PhotoViewer from './photo-viewer.svelte'; import PhotoViewer from './photo-viewer.svelte';
import DetailPanel from './detail-panel.svelte'; import DetailPanel from './detail-panel.svelte';
import { session } from '$app/stores'; import { session } from '$app/stores';
import { downloadAssets } from '$lib/stores/download'; import { downloadAssets } from '$lib/stores/download';
import VideoViewer from './video-viewer.svelte'; import VideoViewer from './video-viewer.svelte';
import { api, AssetResponseDto } from '@api'; import { api, AssetResponseDto, AssetTypeEnum } from '@api';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -191,7 +190,7 @@
<div class="row-start-1 row-span-full col-start-1 col-span-4"> <div class="row-start-1 row-span-full col-start-1 col-span-4">
{#key selectedIndex} {#key selectedIndex}
{#if viewAssetId && viewDeviceId} {#if viewAssetId && viewDeviceId}
{#if selectedAsset.type == AssetType.IMAGE} {#if selectedAsset.type == AssetTypeEnum.Image}
<PhotoViewer assetId={viewAssetId} deviceId={viewDeviceId} on:close={closeViewer} /> <PhotoViewer assetId={viewAssetId} deviceId={viewDeviceId} on:close={closeViewer} />
{:else} {:else}
<VideoViewer assetId={viewAssetId} on:close={closeViewer} /> <VideoViewer assetId={viewAssetId} on:close={closeViewer} />

View file

@ -0,0 +1,35 @@
<script lang="ts">
import { api, UserResponseDto } from '@api';
import { onMount } from 'svelte';
export let user: UserResponseDto;
onMount(() => {
console.log(user);
});
const getUserAvatar = async () => {
try {
const { data } = await api.userApi.getProfileImage(user.id, {
responseType: 'blob'
});
if (data instanceof Blob) {
return URL.createObjectURL(data);
}
} catch (e) {
return '/favicon.png';
}
};
</script>
{#await getUserAvatar()}
<div class="w-12 h-12 rounded-full bg-immich-primary/25" />
{:then data}
<img
src={data}
alt="profile-img"
class="inline rounded-full w-12 h-12 object-cover border shadow-md"
title={user.email}
/>
{/await}

View file

@ -1,15 +0,0 @@
export function clickOutside(node: Node) {
const handleClick = (event: any) => {
if (!node.contains(event.target)) {
node.dispatchEvent(new CustomEvent("outclick"));
}
};
document.addEventListener("click", handleClick, true);
return {
destroy() {
document.removeEventListener("click", handleClick, true);
}
};
}

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { clickOutside } from './click-outside'; import { clickOutside } from '../../utils/click-outside';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
@ -11,7 +11,7 @@
out:fade={{ duration: 100 }} out:fade={{ duration: 100 }}
class="absolute w-full h-full bg-black/40 z-[100] flex place-items-center place-content-center " class="absolute w-full h-full bg-black/40 z-[100] flex place-items-center place-content-center "
> >
<div class="z-[9999]" use:clickOutside on:outclick={() => dispatch('clickOutside')}> <div class="z-[9999]" use:clickOutside on:out-click={() => dispatch('clickOutside')}>
<slot /> <slot />
</div> </div>
</section> </section>

View file

@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { AssetType } from '../../models/immich-asset';
import { session } from '$app/stores'; import { session } from '$app/stores';
import { createEventDispatcher, onDestroy } from 'svelte'; import { createEventDispatcher, onDestroy } from 'svelte';
import { fade, fly } from 'svelte/transition'; import { fade, fly } from 'svelte/transition';
@ -7,13 +6,15 @@
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte'; import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte'; import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
import PauseCircleOutline from 'svelte-material-icons/PauseCircleOutline.svelte'; import PauseCircleOutline from 'svelte-material-icons/PauseCircleOutline.svelte';
import LoadingSpinner from '../shared/loading-spinner.svelte'; import LoadingSpinner from './loading-spinner.svelte';
import { api, AssetResponseDto } from '@api'; import { api, AssetResponseDto, AssetTypeEnum, ThumbnailFormat } from '@api';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
export let asset: AssetResponseDto; export let asset: AssetResponseDto;
export let groupIndex: number; export let groupIndex = 0;
export let thumbnailSize: number | undefined = undefined;
export let format: ThumbnailFormat = ThumbnailFormat.Webp;
let imageData: string; let imageData: string;
let videoData: string; let videoData: string;
@ -29,7 +30,9 @@
const loadImageData = async () => { const loadImageData = async () => {
if ($session.user) { if ($session.user) {
const { data } = await api.assetApi.getAssetThumbnail(asset.id, { responseType: 'blob' }); const { data } = await api.assetApi.getAssetThumbnail(asset.id, format, {
responseType: 'blob'
});
if (data instanceof Blob) { if (data instanceof Blob) {
imageData = URL.createObjectURL(data); imageData = URL.createObjectURL(data);
return imageData; return imageData;
@ -42,9 +45,15 @@
if ($session.user) { if ($session.user) {
try { try {
const { data } = await api.assetApi.serveFile(asset.deviceAssetId, asset.deviceId, false, true, { const { data } = await api.assetApi.serveFile(
responseType: 'blob', asset.deviceAssetId,
}); asset.deviceId,
false,
true,
{
responseType: 'blob'
}
);
if (!(data instanceof Blob)) { if (!(data instanceof Blob)) {
return; return;
@ -109,6 +118,10 @@
}); });
const getSize = () => { const getSize = () => {
if (thumbnailSize) {
return `w-[${thumbnailSize}px] h-[${thumbnailSize}px]`;
}
if (asset.exifInfo?.orientation === 'Rotate 90 CW') { if (asset.exifInfo?.orientation === 'Rotate 90 CW') {
return 'w-[176px] h-[235px]'; return 'w-[176px] h-[235px]';
} else if (asset.exifInfo?.orientation === 'Horizontal (normal)') { } else if (asset.exifInfo?.orientation === 'Horizontal (normal)') {
@ -135,6 +148,8 @@
<IntersectionObserver once={true} let:intersecting> <IntersectionObserver once={true} let:intersecting>
<div <div
style:width={`${thumbnailSize}px`}
style:height={`${thumbnailSize}px`}
class={`bg-gray-100 relative hover:cursor-pointer ${getSize()}`} class={`bg-gray-100 relative hover:cursor-pointer ${getSize()}`}
on:mouseenter={handleMouseOverThumbnail} on:mouseenter={handleMouseOverThumbnail}
on:mouseleave={handleMouseLeaveThumbnail} on:mouseleave={handleMouseLeaveThumbnail}
@ -156,8 +171,10 @@
{/if} {/if}
<!-- Playback and info --> <!-- Playback and info -->
{#if asset.type === AssetType.VIDEO} {#if asset.type === AssetTypeEnum.Video}
<div class="absolute right-2 top-2 text-white text-xs font-medium flex gap-1 place-items-center z-10"> <div
class="absolute right-2 top-2 text-white text-xs font-medium flex gap-1 place-items-center z-10"
>
{#if isThumbnailVideoPlaying} {#if isThumbnailVideoPlaying}
<span in:fly={{ x: -25, duration: 500 }}> <span in:fly={{ x: -25, duration: 500 }}>
{videoProgress} {videoProgress}
@ -189,9 +206,17 @@
<!-- Thumbnail --> <!-- Thumbnail -->
{#if intersecting} {#if intersecting}
{#await loadImageData()} {#await loadImageData()}
<div class={`bg-immich-primary/10 ${getSize()} flex place-items-center place-content-center`}>...</div> <div
style:width={`${thumbnailSize}px`}
style:height={`${thumbnailSize}px`}
class={`bg-immich-primary/10 ${getSize()} flex place-items-center place-content-center`}
>
...
</div>
{:then imageData} {:then imageData}
<img <img
style:width={`${thumbnailSize}px`}
style:height={`${thumbnailSize}px`}
in:fade={{ duration: 250 }} in:fade={{ duration: 250 }}
src={imageData} src={imageData}
alt={asset.id} alt={asset.id}
@ -201,9 +226,17 @@
{/await} {/await}
{/if} {/if}
{#if mouseOver && asset.type === AssetType.VIDEO} {#if mouseOver && asset.type === AssetTypeEnum.Video}
<div class="absolute w-full h-full top-0" on:mouseenter={loadVideoData}> <div class="absolute w-full h-full top-0" on:mouseenter={loadVideoData}>
<video muted autoplay preload="none" class="h-full object-cover" width="250px" bind:this={videoPlayerNode}> <video
muted
autoplay
preload="none"
class="h-full object-cover"
width="250px"
style:width={`${thumbnailSize}px`}
bind:this={videoPlayerNode}
>
<track kind="captions" /> <track kind="captions" />
</video> </video>
</div> </div>

View file

@ -7,7 +7,7 @@
import { fade, fly, slide } from 'svelte/transition'; import { fade, fly, slide } from 'svelte/transition';
import { serverEndpoint } from '../../constants'; import { serverEndpoint } from '../../constants';
import TrayArrowUp from 'svelte-material-icons/TrayArrowUp.svelte'; import TrayArrowUp from 'svelte-material-icons/TrayArrowUp.svelte';
import { clickOutside } from './click-outside'; import { clickOutside } from '../../utils/click-outside';
import { api } from '@api'; import { api } from '@api';
export let user: ImmichUser; export let user: ImmichUser;
@ -56,7 +56,7 @@
<section id="dashboard-navbar" class="fixed w-screen z-[100] bg-immich-bg text-sm"> <section id="dashboard-navbar" class="fixed w-screen z-[100] bg-immich-bg text-sm">
<div class="flex border-b place-items-center px-6 py-2 "> <div class="flex border-b place-items-center px-6 py-2 ">
<a class="flex gap-2 place-items-center hover:cursor-pointer" href="/photos"> <a sveltekit:prefetch class="flex gap-2 place-items-center hover:cursor-pointer" href="/photos">
<img src="/immich-logo.svg" alt="immich logo" height="35" width="35" /> <img src="/immich-logo.svg" alt="immich logo" height="35" width="35" />
<h1 class="font-immich-title text-2xl text-immich-primary">IMMICH</h1> <h1 class="font-immich-title text-2xl text-immich-primary">IMMICH</h1>
</a> </a>
@ -76,12 +76,13 @@
{/if} {/if}
{#if user.isAdmin} {#if user.isAdmin}
<button <a sveltekit:prefetch href={`admin`}>
class={`flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium ${ <button
$page.url.pathname == '/admin' && 'text-immich-primary underline' class={`flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium ${
}`} $page.url.pathname == '/admin' && 'text-immich-primary underline'
on:click={navigateToAdmin}>Administration</button }`}>Administration</button
> >
</a>
{/if} {/if}
<div <div
@ -125,7 +126,7 @@
id="account-info-panel" id="account-info-panel"
class="absolute right-[25px] top-[75px] bg-white shadow-lg rounded-2xl w-[360px] text-center" class="absolute right-[25px] top-[75px] bg-white shadow-lg rounded-2xl w-[360px] text-center"
use:clickOutside use:clickOutside
on:outclick={() => (shouldShowAccountInfoPanel = false)} on:out-click={() => (shouldShowAccountInfoPanel = false)}
> >
<div class="flex place-items-center place-content-center mt-6"> <div class="flex place-items-center place-content-center mt-6">
<button <button

View file

@ -5,13 +5,16 @@
export let isSelected: boolean; export let isSelected: boolean;
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import type { AdminSideBarSelection, AppSideBarSelection } from '../../models/admin-sidebar-selection'; import type {
AdminSideBarSelection,
AppSideBarSelection
} from '../../../models/admin-sidebar-selection';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
const onButtonClicked = () => { const onButtonClicked = () => {
dispatch('selected', { dispatch('selected', {
actionType, actionType
}); });
}; };
</script> </script>

View file

@ -0,0 +1,65 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { AppSideBarSelection } from '$lib/models/admin-sidebar-selection';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import ImageAlbum from 'svelte-material-icons/ImageAlbum.svelte';
import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
import SideBarButton from './side-bar-button.svelte';
import StatusBox from '../status-box.svelte';
let selectedAction: AppSideBarSelection;
const onSidebarButtonClicked = (buttonType: CustomEvent) => {
selectedAction = buttonType.detail['actionType'] as AppSideBarSelection;
if (selectedAction == AppSideBarSelection.PHOTOS) {
if ($page.routeId != 'photos') {
goto('/photos');
}
}
if (selectedAction == AppSideBarSelection.ALBUMS) {
if ($page.routeId != 'albums') {
goto('/albums');
}
}
};
onMount(async () => {
if ($page.routeId == 'albums') {
selectedAction = AppSideBarSelection.ALBUMS;
} else if ($page.routeId == 'photos') {
selectedAction = AppSideBarSelection.PHOTOS;
}
});
</script>
<section id="sidebar" class="flex flex-col gap-4 pt-8 pr-6">
<a sveltekit:prefetch href={$page.routeId != 'photos' ? `/photos` : null}>
<SideBarButton
title="Photos"
logo={ImageOutline}
actionType={AppSideBarSelection.PHOTOS}
isSelected={selectedAction === AppSideBarSelection.PHOTOS}
/></a
>
<div class="text-xs ml-5">
<p>LIBRARY</p>
</div>
<a sveltekit:prefetch href={$page.routeId != 'albums' ? `/albums` : null}>
<SideBarButton
title="Albums"
logo={ImageAlbum}
actionType={AppSideBarSelection.ALBUMS}
isSelected={selectedAction === AppSideBarSelection.ALBUMS}
/>
</a>
<!-- Status Box -->
<div class="mb-6 mt-auto">
<StatusBox />
</div>
</section>

View file

@ -1,9 +1,9 @@
export enum AdminSideBarSelection { export enum AdminSideBarSelection {
USER_MANAGEMENT = "User management", USER_MANAGEMENT = 'User management',
} }
export enum AppSideBarSelection { export enum AppSideBarSelection {
PHOTOS = "Photos", PHOTOS = 'Photos',
EXPLORE = "Explore", EXPLORE = 'Explore',
} ALBUMS = 'Albums',
}

View file

@ -1,54 +0,0 @@
export enum AssetType {
IMAGE = 'IMAGE',
VIDEO = 'VIDEO',
AUDIO = 'AUDIO',
OTHER = 'OTHER',
}
export type ImmichExif = {
id: string;
assetId: string;
make: string;
model: string;
imageName: string;
exifImageWidth: number;
exifImageHeight: number;
fileSizeInByte: number;
orientation: string;
dateTimeOriginal: Date;
modifyDate: Date;
lensModel: string;
fNumber: number;
focalLength: number;
iso: number;
exposureTime: number;
latitude: number;
longitude: number;
city: string;
state: string;
country: string;
}
export type ImmichAssetSmartInfo = {
id: string;
assetId: string;
tags: string[];
objects: string[];
}
export type ImmichAsset = {
id: string;
deviceAssetId: string;
userId: string;
deviceId: string;
type: AssetType;
originalPath: string;
resizePath: string;
createdAt: string;
modifiedAt: string;
isFavorite: boolean;
mimeType: string;
duration: string;
exifInfo?: ImmichExif;
smartInfo?: ImmichAssetSmartInfo;
}

View file

@ -1,8 +1,6 @@
import { Socket, io } from 'socket.io-client'; import { Socket, io } from 'socket.io-client';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import { serverEndpoint } from '../constants'; import { serverEndpoint } from '../constants';
import type { ImmichAsset } from '../models/immich-asset';
import { assets } from './assets';
let websocket: Socket; let websocket: Socket;
@ -28,10 +26,7 @@ export const openWebsocketConnection = (accessToken: string) => {
}; };
const listenToEvent = (socket: Socket) => { const listenToEvent = (socket: Socket) => {
socket.on('on_upload_success', (data) => { socket.on('on_upload_success', (data) => {});
const newUploadedAsset: ImmichAsset = JSON.parse(data);
// assets.update((assets) => [...assets, newUploadedAsset]);
});
socket.on('error', (e) => { socket.on('error', (e) => {
console.log('Websocket Error', e); console.log('Websocket Error', e);

View file

@ -0,0 +1,15 @@
export function clickOutside(node: Node) {
const handleClick = (event: any) => {
if (!node.contains(event.target)) {
node.dispatchEvent(new CustomEvent('out-click'));
}
};
document.addEventListener('click', handleClick, true);
return {
destroy() {
document.removeEventListener('click', handleClick, true);
},
};
}

View file

@ -16,7 +16,7 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import { blur } from 'svelte/transition'; import { blur, fade, slide } from 'svelte/transition';
import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte'; import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
import AnnouncementBox from '$lib/components/shared/announcement-box.svelte'; import AnnouncementBox from '$lib/components/shared/announcement-box.svelte';
@ -40,7 +40,7 @@
<main> <main>
{#key url} {#key url}
<div transition:blur={{ duration: 250 }}> <div in:fade={{ duration: 100 }}>
<slot /> <slot />
<DownloadPanel /> <DownloadPanel />
<UploadPanel /> <UploadPanel />

View file

@ -1,7 +1,7 @@
import type { RequestHandler } from '@sveltejs/kit'; import type { RequestHandler } from '@sveltejs/kit';
import { api } from '@api'; import { api } from '@api';
export const post: RequestHandler = async ({ request }) => { export const POST: RequestHandler = async ({ request }) => {
const form = await request.formData(); const form = await request.formData();
const email = form.get('email'); const email = form.get('email');
@ -13,22 +13,22 @@ export const post: RequestHandler = async ({ request }) => {
email: String(email), email: String(email),
password: String(password), password: String(password),
firstName: String(firstName), firstName: String(firstName),
lastName: String(lastName), lastName: String(lastName)
}); });
if (status === 201) { if (status === 201) {
return { return {
status: 201, status: 201,
body: { body: {
success: 'Succesfully create user account', success: 'Succesfully create user account'
}, }
}; };
} else { } else {
return { return {
status: 400, status: 400,
body: { body: {
error: 'Error create user account', error: 'Error create user account'
}, }
}; };
} }
}; };

View file

@ -27,7 +27,7 @@
import type { ImmichUser } from '$lib/models/immich-user'; import type { ImmichUser } from '$lib/models/immich-user';
import { AdminSideBarSelection } from '$lib/models/admin-sidebar-selection'; import { AdminSideBarSelection } from '$lib/models/admin-sidebar-selection';
import SideBarButton from '$lib/components/shared/side-bar-button.svelte'; import SideBarButton from '$lib/components/shared/side-bar/side-bar-button.svelte';
import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte'; import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
import NavigationBar from '$lib/components/shared/navigation-bar.svelte'; import NavigationBar from '$lib/components/shared/navigation-bar.svelte';
import UserManagement from '$lib/components/admin/user-management.svelte'; import UserManagement from '$lib/components/admin/user-management.svelte';
@ -59,7 +59,7 @@
</script> </script>
<svelte:head> <svelte:head>
<title>Immich - Administration</title> <title>Administration - Immich</title>
</svelte:head> </svelte:head>
<NavigationBar {user} /> <NavigationBar {user} />

View file

@ -0,0 +1,49 @@
<script context="module" lang="ts">
export const prerender = false;
import type { Load } from '@sveltejs/kit';
import { AlbumResponseDto, api } from '@api';
export const load: Load = async ({ session, params }) => {
if (!session.user) {
return {
status: 302,
redirect: '/auth/login'
};
}
const albumId = params['albumId'];
let album: AlbumResponseDto;
try {
const { data } = await api.albumApi.getAlbumInfo(albumId);
album = data;
} catch (e) {
return {
status: 302,
redirect: '/albums'
};
}
return {
status: 200,
props: {
album: album
}
};
};
</script>
<script lang="ts">
import { goto } from '$app/navigation';
import AlbumViewer from '$lib/components/album/album-viewer.svelte';
export let album: AlbumResponseDto;
</script>
<svelte:head>
<title>{album.albumName} - Immich</title>
</svelte:head>
<AlbumViewer {album} />

View file

@ -0,0 +1,94 @@
<script context="module" lang="ts">
export const prerender = false;
import PlusBoxOutline from 'svelte-material-icons/PlusBoxOutline.svelte';
import NavigationBar from '$lib/components/shared/navigation-bar.svelte';
import { ImmichUser } from '$lib/models/immich-user';
import type { Load } from '@sveltejs/kit';
import SideBar from '$lib/components/shared/side-bar/side-bar.svelte';
import { AlbumResponseDto, api } from '@api';
export const load: Load = async ({ session }) => {
if (!session.user) {
return {
status: 302,
redirect: '/auth/login'
};
}
let allAlbums: AlbumResponseDto[] = [];
try {
const { data } = await api.albumApi.getAllAlbums();
allAlbums = data;
} catch (e) {
console.log('Error [getAllAlbums] ', e);
}
return {
status: 200,
props: {
user: session.user,
allAlbums: allAlbums
}
};
};
</script>
<script lang="ts">
import AlbumCard from '$lib/components/album/album-card.svelte';
import { goto } from '$app/navigation';
export let user: ImmichUser;
export let allAlbums: AlbumResponseDto[];
const showAlbum = (event: CustomEvent) => {
goto('/albums/' + event.detail.id);
};
</script>
<svelte:head>
<title>Albums - Immich</title>
</svelte:head>
<section>
<NavigationBar {user} on:uploadClicked={() => {}} />
</section>
<section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen bg-immich-bg">
<SideBar />
<!-- Main Section -->
<section class="overflow-y-auto relative">
<section id="album-content" class="relative pt-8 pl-4 mb-12 bg-immich-bg">
<div class="px-4 flex justify-between place-items-center">
<div>
<p>Albums</p>
</div>
<div>
<button
class="flex place-items-center gap-1 text-sm hover:bg-immich-primary/5 p-2 rounded-lg font-medium hover:text-gray-700"
>
<span>
<PlusBoxOutline size="18" />
</span>
<p>Create album</p>
</button>
</div>
</div>
<div class="my-4">
<hr />
</div>
<!-- Album Card -->
<div class="flex flex-wrap gap-8">
{#each allAlbums as album}
<a sveltekit:prefetch href={`albums/${album.id}`}> <AlbumCard {album} /></a>
{/each}
</div>
</section>
</section>
</section>

View file

@ -57,7 +57,7 @@
</script> </script>
<svelte:head> <svelte:head>
<title>Immich - Change Password</title> <title>Change Password - Immich</title>
</svelte:head> </svelte:head>
<section class="h-screen w-screen flex place-items-center place-content-center"> <section class="h-screen w-screen flex place-items-center place-content-center">

View file

@ -1,13 +1,13 @@
import type { RequestHandler } from '@sveltejs/kit'; import type { RequestHandler } from '@sveltejs/kit';
import { api } from '@api'; import { api } from '@api';
export const post: RequestHandler = async ({ request, locals }) => { export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) { if (!locals.user) {
return { return {
status: 401, status: 401,
body: { body: {
error: 'Unauthorized', error: 'Unauthorized'
}, }
}; };
} }
@ -17,22 +17,22 @@ export const post: RequestHandler = async ({ request, locals }) => {
const { status } = await api.userApi.updateUser({ const { status } = await api.userApi.updateUser({
id: locals.user.id, id: locals.user.id,
password: String(password), password: String(password),
shouldChangePassword: false, shouldChangePassword: false
}); });
if (status === 200) { if (status === 200) {
return { return {
status: 200, status: 200,
body: { body: {
success: 'Succesfully change password', success: 'Succesfully change password'
}, }
}; };
} else { } else {
return { return {
status: 400, status: 400,
body: { body: {
error: 'Error change password', error: 'Error change password'
}, }
}; };
} }
}; };

View file

@ -10,7 +10,7 @@
</script> </script>
<svelte:head> <svelte:head>
<title>Immich - Login</title> <title>Login - Immich</title>
</svelte:head> </svelte:head>
<section class="h-screen w-screen flex place-items-center place-content-center"> <section class="h-screen w-screen flex place-items-center place-content-center">

View file

@ -2,7 +2,7 @@ import type { RequestHandler } from '@sveltejs/kit';
import * as cookie from 'cookie'; import * as cookie from 'cookie';
import { api } from '@api'; import { api } from '@api';
export const post: RequestHandler = async ({ request }) => { export const POST: RequestHandler = async ({ request }) => {
const form = await request.formData(); const form = await request.formData();
const email = form.get('email'); const email = form.get('email');
@ -11,7 +11,7 @@ export const post: RequestHandler = async ({ request }) => {
try { try {
const { data: authUser } = await api.authenticationApi.login({ const { data: authUser } = await api.authenticationApi.login({
email: String(email), email: String(email),
password: String(password), password: String(password)
}); });
return { return {
@ -24,9 +24,9 @@ export const post: RequestHandler = async ({ request }) => {
lastName: authUser.lastName, lastName: authUser.lastName,
isAdmin: authUser.isAdmin, isAdmin: authUser.isAdmin,
email: authUser.userEmail, email: authUser.userEmail,
shouldChangePassword: authUser.shouldChangePassword, shouldChangePassword: authUser.shouldChangePassword
}, },
success: 'success', success: 'success'
}, },
headers: { headers: {
'Set-Cookie': cookie.serialize( 'Set-Cookie': cookie.serialize(
@ -37,23 +37,23 @@ export const post: RequestHandler = async ({ request }) => {
firstName: authUser.firstName, firstName: authUser.firstName,
lastName: authUser.lastName, lastName: authUser.lastName,
isAdmin: authUser.isAdmin, isAdmin: authUser.isAdmin,
email: authUser.userEmail, email: authUser.userEmail
}), }),
{ {
path: '/', path: '/',
httpOnly: true, httpOnly: true,
sameSite: 'strict', sameSite: 'strict',
maxAge: 60 * 60 * 24 * 30, maxAge: 60 * 60 * 24 * 30
}, }
), )
}, }
}; };
} catch (error) { } catch (error) {
return { return {
status: 400, status: 400,
body: { body: {
error: 'Incorrect email or password', error: 'Incorrect email or password'
}, }
}; };
} }
}; };

View file

@ -1,12 +1,12 @@
import type { RequestHandler } from '@sveltejs/kit'; import type { RequestHandler } from '@sveltejs/kit';
export const post: RequestHandler = async () => { export const POST: RequestHandler = async () => {
return { return {
headers: { headers: {
'Set-Cookie': 'session=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT', 'Set-Cookie': 'session=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'
}, },
body: { body: {
ok: true, ok: true
}, }
}; };
}; };

View file

@ -29,7 +29,7 @@
</script> </script>
<svelte:head> <svelte:head>
<title>Immich - Admin Registration</title> <title>Admin Registration - Immich</title>
</svelte:head> </svelte:head>
<section class="h-screen w-screen flex place-items-center place-content-center"> <section class="h-screen w-screen flex place-items-center place-content-center">

View file

@ -1,7 +1,7 @@
import type { RequestHandler } from '@sveltejs/kit'; import type { RequestHandler } from '@sveltejs/kit';
import { api } from '@api'; import { api } from '@api';
export const post: RequestHandler = async ({ request }) => { export const POST: RequestHandler = async ({ request }) => {
const form = await request.formData(); const form = await request.formData();
const email = form.get('email'); const email = form.get('email');
@ -13,22 +13,22 @@ export const post: RequestHandler = async ({ request }) => {
email: String(email), email: String(email),
password: String(password), password: String(password),
firstName: String(firstName), firstName: String(firstName),
lastName: String(lastName), lastName: String(lastName)
}); });
if (status === 201) { if (status === 201) {
return { return {
status: 201, status: 201,
body: { body: {
success: 'Succesfully create admin account', success: 'Succesfully create admin account'
}, }
}; };
} else { } else {
return { return {
status: 400, status: 400,
body: { body: {
error: 'Error create admin account', error: 'Error create admin account'
}, }
}; };
} }
}; };

View file

@ -33,7 +33,7 @@
</script> </script>
<svelte:head> <svelte:head>
<title>Immich - Welcome 🎉</title> <title>Welcome 🎉 - Immich</title>
<meta name="description" content="Immich Web Interface" /> <meta name="description" content="Immich Web Interface" />
</svelte:head> </svelte:head>

View file

@ -8,7 +8,7 @@
if (!session.user) { if (!session.user) {
return { return {
status: 302, status: 302,
redirect: '/auth/login', redirect: '/auth/login'
}; };
} }
@ -17,8 +17,8 @@
return { return {
status: 200, status: 200,
props: { props: {
user: session.user, user: session.user
}, }
}; };
}; };
</script> </script>
@ -27,26 +27,19 @@
import type { ImmichUser } from '$lib/models/immich-user'; import type { ImmichUser } from '$lib/models/immich-user';
import NavigationBar from '$lib/components/shared/navigation-bar.svelte'; import NavigationBar from '$lib/components/shared/navigation-bar.svelte';
import SideBarButton from '$lib/components/shared/side-bar-button.svelte';
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte'; import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
import { AppSideBarSelection } from '$lib/models/admin-sidebar-selection';
import { onMount } from 'svelte';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { session } from '$app/stores'; import { session } from '$app/stores';
import { assetsGroupByDate, flattenAssetGroupByDate } from '$lib/stores/assets'; import { assetsGroupByDate, flattenAssetGroupByDate } from '$lib/stores/assets';
import ImmichThumbnail from '$lib/components/asset-viewer/immich-thumbnail.svelte'; import ImmichThumbnail from '$lib/components/shared/immich-thumbnail.svelte';
import moment from 'moment'; import moment from 'moment';
import AssetViewer from '$lib/components/asset-viewer/asset-viewer.svelte'; import AssetViewer from '$lib/components/asset-viewer/asset-viewer.svelte';
import StatusBox from '$lib/components/shared/status-box.svelte';
import { fileUploader } from '$lib/utils/file-uploader'; import { fileUploader } from '$lib/utils/file-uploader';
import { AssetResponseDto } from '@api'; import { AssetResponseDto } from '@api';
import SideBar from '$lib/components/shared/side-bar/side-bar.svelte';
export let user: ImmichUser; export let user: ImmichUser;
let selectedAction: AppSideBarSelection;
let selectedGroupThumbnail: number | null; let selectedGroupThumbnail: number | null;
let isMouseOverGroup: boolean; let isMouseOverGroup: boolean;
$: if (isMouseOverGroup == false) { $: if (isMouseOverGroup == false) {
@ -57,14 +50,6 @@
let currentViewAssetIndex = 0; let currentViewAssetIndex = 0;
let currentSelectedAsset: AssetResponseDto; let currentSelectedAsset: AssetResponseDto;
const onButtonClicked = (buttonType: CustomEvent) => {
selectedAction = buttonType.detail['actionType'] as AppSideBarSelection;
};
onMount(async () => {
selectedAction = AppSideBarSelection.PHOTOS;
});
const thumbnailMouseEventHandler = (event: CustomEvent) => { const thumbnailMouseEventHandler = (event: CustomEvent) => {
const { selectedGroupIndex }: { selectedGroupIndex: number } = event.detail; const { selectedGroupIndex }: { selectedGroupIndex: number } = event.detail;
@ -92,7 +77,7 @@
const files = Array.from<File>(e.target.files); const files = Array.from<File>(e.target.files);
const acceptedFile = files.filter( const acceptedFile = files.filter(
(e) => e.type.split('/')[0] === 'video' || e.type.split('/')[0] === 'image', (e) => e.type.split('/')[0] === 'video' || e.type.split('/')[0] === 'image'
); );
for (const asset of acceptedFile) { for (const asset of acceptedFile) {
@ -109,7 +94,7 @@
</script> </script>
<svelte:head> <svelte:head>
<title>Immich - Photos</title> <title>Photos - Immich</title>
</svelte:head> </svelte:head>
<section> <section>
@ -117,22 +102,7 @@
</section> </section>
<section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen bg-immich-bg"> <section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen bg-immich-bg">
<!-- Sidebar --> <SideBar />
<section id="sidebar" class="flex flex-col gap-4 pt-8 pr-6">
<SideBarButton
title="Photos"
logo={ImageOutline}
actionType={AppSideBarSelection.PHOTOS}
isSelected={selectedAction === AppSideBarSelection.PHOTOS}
on:selected={onButtonClicked}
/>
<!-- Status Box -->
<div class="mb-6 mt-auto">
<StatusBox />
</div>
</section>
<!-- Main Section --> <!-- Main Section -->
<section class="overflow-y-auto relative"> <section class="overflow-y-auto relative">

BIN
web/static/no-thumbnail.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 B

View file

@ -8,17 +8,9 @@ const config = {
kit: { kit: {
adapter: adapter({ out: 'build' }), adapter: adapter({ out: 'build' }),
methodOverride: { methodOverride: {
allowed: ['PATCH', 'DELETE'], allowed: ['PATCH', 'DELETE']
}, }
vite: { }
resolve: {
alias: {
'xmlhttprequest-ssl': './node_modules/engine.io-client/lib/xmlhttprequest.js',
'@api': path.resolve('./src/api'),
},
},
},
},
}; };
export default config; export default config;

View file

@ -19,9 +19,15 @@
"importsNotUsedAsValues": "preserve", "importsNotUsedAsValues": "preserve",
"preserveValueImports": false, "preserveValueImports": false,
"paths": { "paths": {
"$lib": ["src/lib"], "$lib": [
"$lib/*": ["src/lib/*"], "src/lib"
"@api": ["src/api"] ],
"$lib/*": [
"src/lib/*"
],
"@api": [
"src/api"
]
} }
}, },
} }

15
web/vite.config.js Normal file
View file

@ -0,0 +1,15 @@
import { sveltekit } from '@sveltejs/kit/vite';
import path from 'path';
/** @type {import('vite').UserConfig} */
const config = {
resolve: {
alias: {
'xmlhttprequest-ssl': './node_modules/engine.io-client/lib/xmlhttprequest.js',
'@api': path.resolve('./src/api')
}
},
plugins: [sveltekit()]
};
export default config;