0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-03-25 02:41:37 -05: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 2572 additions and 991 deletions

View file

@ -18,3 +18,6 @@ prod:
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

View file

@ -136,6 +136,7 @@ Class | Method | HTTP request | Description
- [ServerVersionReponseDto](doc//ServerVersionReponseDto.md) - [ServerVersionReponseDto](doc//ServerVersionReponseDto.md)
- [SignUpDto](doc//SignUpDto.md) - [SignUpDto](doc//SignUpDto.md)
- [SmartInfoResponseDto](doc//SmartInfoResponseDto.md) - [SmartInfoResponseDto](doc//SmartInfoResponseDto.md)
- [ThumbnailFormat](doc//ThumbnailFormat.md)
- [UpdateAlbumDto](doc//UpdateAlbumDto.md) - [UpdateAlbumDto](doc//UpdateAlbumDto.md)
- [UpdateDeviceInfoDto](doc//UpdateDeviceInfoDto.md) - [UpdateDeviceInfoDto](doc//UpdateDeviceInfoDto.md)
- [UpdateUserDto](doc//UpdateUserDto.md) - [UpdateUserDto](doc//UpdateUserDto.md)

View file

@ -311,7 +311,7 @@ This endpoint does not need any parameter.
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getAssetThumbnail** # **getAssetThumbnail**
> Object getAssetThumbnail(assetId) > Object getAssetThumbnail(assetId, format)
@ -327,9 +327,10 @@ import 'package:openapi/api.dart';
final api_instance = AssetApi(); final api_instance = AssetApi();
final assetId = assetId_example; // String | final assetId = assetId_example; // String |
final format = ; // ThumbnailFormat |
try { try {
final result = api_instance.getAssetThumbnail(assetId); final result = api_instance.getAssetThumbnail(assetId, format);
print(result); print(result);
} catch (e) { } catch (e) {
print('Exception when calling AssetApi->getAssetThumbnail: $e\n'); print('Exception when calling AssetApi->getAssetThumbnail: $e\n');
@ -341,6 +342,7 @@ try {
Name | Type | Description | Notes Name | Type | Description | Notes
------------- | ------------- | ------------- | ------------- ------------- | ------------- | ------------- | -------------
**assetId** | **String**| | **assetId** | **String**| |
**format** | [**ThumbnailFormat**](.md)| | [optional]
### Return type ### Return type

View file

@ -0,0 +1,14 @@
# openapi.model.ThumbnailFormat
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View file

@ -64,6 +64,7 @@ part 'model/server_ping_response.dart';
part 'model/server_version_reponse_dto.dart'; part 'model/server_version_reponse_dto.dart';
part 'model/sign_up_dto.dart'; part 'model/sign_up_dto.dart';
part 'model/smart_info_response_dto.dart'; part 'model/smart_info_response_dto.dart';
part 'model/thumbnail_format.dart';
part 'model/update_album_dto.dart'; part 'model/update_album_dto.dart';
part 'model/update_device_info_dto.dart'; part 'model/update_device_info_dto.dart';
part 'model/update_user_dto.dart'; part 'model/update_user_dto.dart';

View file

@ -346,7 +346,9 @@ class AssetApi {
/// Parameters: /// Parameters:
/// ///
/// * [String] assetId (required): /// * [String] assetId (required):
Future<Response> getAssetThumbnailWithHttpInfo(String assetId,) async { ///
/// * [ThumbnailFormat] format:
Future<Response> getAssetThumbnailWithHttpInfo(String assetId, { ThumbnailFormat? format, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final path = r'/asset/thumbnail/{assetId}' final path = r'/asset/thumbnail/{assetId}'
.replaceAll('{assetId}', assetId); .replaceAll('{assetId}', assetId);
@ -358,6 +360,10 @@ class AssetApi {
final headerParams = <String, String>{}; final headerParams = <String, String>{};
final formParams = <String, String>{}; final formParams = <String, String>{};
if (format != null) {
queryParams.addAll(_queryParams('', 'format', format));
}
const contentTypes = <String>[]; const contentTypes = <String>[];
@ -375,8 +381,10 @@ class AssetApi {
/// Parameters: /// Parameters:
/// ///
/// * [String] assetId (required): /// * [String] assetId (required):
Future<Object?> getAssetThumbnail(String assetId,) async { ///
final response = await getAssetThumbnailWithHttpInfo(assetId,); /// * [ThumbnailFormat] format:
Future<Object?> getAssetThumbnail(String assetId, { ThumbnailFormat? format, }) async {
final response = await getAssetThumbnailWithHttpInfo(assetId, format: format, );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }

View file

@ -252,6 +252,8 @@ class ApiClient {
return SignUpDto.fromJson(value); return SignUpDto.fromJson(value);
case 'SmartInfoResponseDto': case 'SmartInfoResponseDto':
return SmartInfoResponseDto.fromJson(value); return SmartInfoResponseDto.fromJson(value);
case 'ThumbnailFormat':
return ThumbnailFormatTypeTransformer().decode(value);
case 'UpdateAlbumDto': case 'UpdateAlbumDto':
return UpdateAlbumDto.fromJson(value); return UpdateAlbumDto.fromJson(value);
case 'UpdateDeviceInfoDto': case 'UpdateDeviceInfoDto':

View file

@ -64,6 +64,9 @@ String parameterToString(dynamic value) {
if (value is DeviceTypeEnum) { if (value is DeviceTypeEnum) {
return DeviceTypeEnumTypeTransformer().encode(value).toString(); return DeviceTypeEnumTypeTransformer().encode(value).toString();
} }
if (value is ThumbnailFormat) {
return ThumbnailFormatTypeTransformer().encode(value).toString();
}
return value.toString(); return value.toString();
} }

View file

@ -0,0 +1,85 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class ThumbnailFormat {
/// Instantiate a new enum with the provided [value].
const ThumbnailFormat._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const JPEG = ThumbnailFormat._(r'JPEG');
static const WEBP = ThumbnailFormat._(r'WEBP');
/// List of all possible values in this [enum][ThumbnailFormat].
static const values = <ThumbnailFormat>[
JPEG,
WEBP,
];
static ThumbnailFormat? fromJson(dynamic value) => ThumbnailFormatTypeTransformer().decode(value);
static List<ThumbnailFormat>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <ThumbnailFormat>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ThumbnailFormat.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [ThumbnailFormat] to String,
/// and [decode] dynamic data back to [ThumbnailFormat].
class ThumbnailFormatTypeTransformer {
factory ThumbnailFormatTypeTransformer() => _instance ??= const ThumbnailFormatTypeTransformer._();
const ThumbnailFormatTypeTransformer._();
String encode(ThumbnailFormat data) => data.value;
/// Decodes a [dynamic value][data] to a ThumbnailFormat.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
ThumbnailFormat? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data.toString()) {
case r'JPEG': return ThumbnailFormat.JPEG;
case r'WEBP': return ThumbnailFormat.WEBP;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [ThumbnailFormatTypeTransformer] instance.
static ThumbnailFormatTypeTransformer? _instance;
}

View file

@ -0,0 +1,21 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for ThumbnailFormat
void main() {
group('test ThumbnailFormat', () {
});
}

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>
<div>%sveltekit.body%</div>
</body>
</html> </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;