1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-24 08:52:28 +02:00

Add web interface with admin functionality (#167)

This commit is contained in:
Alex 2022-05-21 02:23:55 -05:00 committed by GitHub
parent 79dea504b0
commit a779c3803c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
76 changed files with 8252 additions and 87 deletions

View File

@ -8,7 +8,6 @@ on:
branches: [main]
jobs:
build_and_push_server_latest:
runs-on: ubuntu-latest
steps:
@ -62,3 +61,31 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: |
altran1502/immich-microservices:latest
build_and_push_web_latest:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
ref: "main" # branch
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.0.0
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Web
uses: docker/build-push-action@v3.0.0
with:
context: ./web
file: ./web/Dockerfile
platforms: linux/arm/v7,linux/amd64,linux/arm64
target: prod
push: ${{ github.event_name != 'pull_request' }}
tags: |
altran1502/immich-web:latest

View File

@ -15,7 +15,7 @@ jobs:
ref: "main"
fetch-depth: 0
- name: 'Get Previous tag'
- name: "Get Previous tag"
id: previoustag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
@ -53,7 +53,7 @@ jobs:
ref: "main"
fetch-depth: 0
- name: 'Get Previous tag'
- name: "Get Previous tag"
id: previoustag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
@ -81,3 +81,42 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: |
altran1502/immich-microservices:${{ steps.previoustag.outputs.tag }}
build_and_push_web_release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
ref: "main"
fetch-depth: 0
- name: "Get Previous tag"
id: previoustag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
fallback: latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2.0.0
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push immich-web release
uses: docker/build-push-action@v3.0.0
with:
context: ./web
file: ./web/Dockerfile
platforms: linux/arm/v7,linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
target: prod
tags: |
altran1502/immich-web:${{ steps.previoustag.outputs.tag }}

9
NOTES.md Normal file
View File

@ -0,0 +1,9 @@
# TODO
Server scenario with web
[ ] 1 user exist without admin right -> make admin on first check
[ ] 2 users exist without admin right -> ask user to choose which account will be the admin
[ X ] No users exist -> prompt signup form for Admin

View File

@ -86,10 +86,11 @@ I haven't tested with `Docker for Windows` as well as `WSL` on Windows
You can use docker compose for development and testing out the application, there are several services that compose Immich:
1. **NestJs** - Backend of the application
2. **PostgreSQL** - Main database of the application
3. **Redis** - For sharing websocket instance between docker instances and background tasks message queue.
4. **Nginx** - Load balancing and optimized file uploading.
5. **TensorFlow** - Object Detection and Image Classification.
2. **SvelteKit** - Web frontend of the application
3. **PostgreSQL** - Main database of the application
4. **Redis** - For sharing websocket instance between docker instances and background tasks message queue.
5. **Nginx** - Load balancing and optimized file uploading.
6. **TensorFlow** - Object Detection and Image Classification.
## Step 1: Populate .env file
@ -133,7 +134,7 @@ To start, run
docker-compose -f ./docker/docker-compose.yml up
```
If you have a few thousand photos/videos, I suggest running docker-compose with scaling option for the `immich_server` container to handle high I/O load when using fast scrolling.
If you have a few thousand photos/videos, I suggest running docker-compose with scaling option for the `immich_-erver` container to handle high I/O load when using fast scrolling.
```bash
docker-compose -f ./docker/docker-compose.yml up --scale immich-server=5
@ -144,17 +145,17 @@ The server will be running at `http://your-ip:2283` through `Nginx`
## Step 3: Register User
Use the command below on your terminal to create user as we don't have user interface for this function yet.
Access the web interface at `http://your-ip:2285` to register an admin account.
```bash
curl --location --request POST 'http://your-server-ip:2283/auth/signUp' \
--header 'Content-Type: application/json' \
--data-raw '{
"email": "testuser@email.com",
"password": "password"
}'
```
<p align="left">
<img src="design/admin-registration-form.png" width="300" title="Admin Registration">
<p/>
Additional accounts on the server can be created by the admin account.
<p align="left">
<img src="design/admin-interface.png" width="500" title="Admin User Management">
<p/>
## Step 4: Run mobile app
The app is distributed on several platforms below.

BIN
design/admin-interface.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

View File

@ -1,15 +1,51 @@
###################################################################################
# Database
###################################################################################
DB_USERNAME=postgres
DB_PASSWORD=postgres
DB_DATABASE_NAME=immich
###################################################################################
# Upload File Config
###################################################################################
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
###################################################################################
# JWT SECRET
###################################################################################
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
###################################################################################
# MAPBOX
## ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
####################################################################################
# ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
ENABLE_MAPBOX=false
MAPBOX_KEY=
###################################################################################
# WEB
###################################################################################
# This is the URL of your vm/server where you host Immich, so that the web frontend
# know where can it make the request to.
# For example: If your server IP address is 10.1.11.50, the environment variable will
# be VITE_SERVER_ENDPOINT=http://10.1.11.50:2283
VITE_SERVER_ENDPOINT=

View File

@ -44,6 +44,24 @@ services:
networks:
- immich-network
immich-web:
image: immich-web-dev:1.9.0
build:
context: ../web
dockerfile: Dockerfile
target: dev
command: npm run dev --host
env_file:
- .env
ports:
- 3002:3002
- 24678:24678
volumes:
- ../web:/usr/src/app
- /usr/src/app/node_modules
networks:
- immich-network
restart: always
redis:
container_name: immich_redis

View File

@ -17,7 +17,7 @@ services:
- database
networks:
- immich-network
restart: unless-stopped
restart: always
immich-microservices:
image: altran1502/immich-microservices:latest
@ -34,13 +34,27 @@ services:
- database
networks:
- immich-network
restart: unless-stopped
restart: always
immich-web:
image: altran1502/immich-web:latest
entrypoint: ["/bin/sh", "./entrypoint.sh"]
env_file:
- .env
ports:
- 2285:3000
networks:
- immich-network
restart: always
redis:
container_name: immich_redis
image: redis:6.2
networks:
- immich-network
restart: always
database:
container_name: immich_postgres
@ -73,6 +87,7 @@ services:
- immich-network
depends_on:
- immich-server
restart: always
networks:
immich-network:

View File

@ -1,5 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 120
"printWidth": 120,
"semi": true
}

View File

@ -7,16 +7,16 @@ import { SignUpDto } from './dto/sign-up.dto';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
constructor(private readonly authService: AuthService) { }
@Post('/login')
async login(@Body(ValidationPipe) loginCredential: LoginCredentialDto) {
return await this.authService.login(loginCredential);
}
@Post('/signUp')
async signUp(@Body(ValidationPipe) signUpCrendential: SignUpDto) {
return await this.authService.signUp(signUpCrendential);
@Post('/admin-sign-up')
async adminSignUp(@Body(ValidationPipe) signUpCrendential: SignUpDto) {
return await this.authService.adminSignUp(signUpCrendential);
}
@UseGuards(JwtAuthGuard)

View File

@ -14,12 +14,12 @@ export class AuthService {
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
private immichJwtService: ImmichJwtService,
) {}
) { }
private async validateUser(loginCredential: LoginCredentialDto): Promise<UserEntity> {
const user = await this.userRepository.findOne(
{ email: loginCredential.email },
{ select: ['id', 'email', 'password', 'salt'] },
{ select: ['id', 'email', 'password', 'salt', 'firstName', 'lastName', 'isAdmin'] },
);
const isAuthenticated = await this.validatePassword(user.password, loginCredential.password, user.salt);
@ -44,32 +44,45 @@ export class AuthService {
accessToken: await this.immichJwtService.generateToken(payload),
userId: validatedUser.id,
userEmail: validatedUser.email,
firstName: validatedUser.firstName,
lastName: validatedUser.lastName,
isAdmin: validatedUser.isAdmin,
profileImagePath: validatedUser.profileImagePath,
isFirstLogin: validatedUser.isFirstLoggedIn
};
}
public async signUp(signUpCrendential: SignUpDto) {
const registerUser = await this.userRepository.findOne({ email: signUpCrendential.email });
if (registerUser) {
throw new BadRequestException('User exist');
public async adminSignUp(signUpCrendential: SignUpDto) {
const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } });
if (adminUser) {
throw new BadRequestException('The server already has an admin')
}
const newUser = new UserEntity();
newUser.email = signUpCrendential.email;
newUser.salt = await bcrypt.genSalt();
newUser.password = await this.hashPassword(signUpCrendential.password, newUser.salt);
const newAdminUser = new UserEntity();
newAdminUser.email = signUpCrendential.email;
newAdminUser.salt = await bcrypt.genSalt();
newAdminUser.password = await this.hashPassword(signUpCrendential.password, newAdminUser.salt);
newAdminUser.firstName = signUpCrendential.firstName;
newAdminUser.lastName = signUpCrendential.lastName;
newAdminUser.isAdmin = true;
try {
const savedUser = await this.userRepository.save(newUser);
const savedNewAdminUserUser = await this.userRepository.save(newAdminUser);
return {
id: savedUser.id,
email: savedUser.email,
createdAt: savedUser.createdAt,
id: savedNewAdminUserUser.id,
email: savedNewAdminUserUser.email,
firstName: savedNewAdminUserUser.firstName,
lastName: savedNewAdminUserUser.lastName,
createdAt: savedNewAdminUserUser.createdAt,
};
} catch (e) {
Logger.error('e', 'signUp');
throw new InternalServerErrorException('Failed to register new user');
throw new InternalServerErrorException('Failed to register new admin user');
}
}

View File

@ -6,4 +6,10 @@ export class SignUpDto {
@IsNotEmpty()
password: string;
@IsNotEmpty()
firstName: string;
@IsNotEmpty()
lastName: string;
}

View File

@ -1 +1,27 @@
export class CreateUserDto {}
import { IsNotEmpty, IsOptional } from 'class-validator';
export class CreateUserDto {
@IsNotEmpty()
email: string;
@IsNotEmpty()
password: string;
@IsNotEmpty()
firstName: string;
@IsNotEmpty()
lastName: string;
@IsOptional()
profileImagePath: string;
@IsOptional()
isAdmin: boolean;
@IsOptional()
isFirstLoggedIn: boolean;
@IsOptional()
id: string;
}

View File

@ -5,6 +5,15 @@ export class UserEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
firstName: string;
@Column()
lastName: string;
@Column()
isAdmin: boolean;
@Column()
email: string;
@ -14,6 +23,12 @@ export class UserEntity {
@Column({ select: false })
salt: string;
@Column()
profileImagePath: string;
@Column()
isFirstLoggedIn: boolean;
@CreateDateColumn()
createdAt: string;
}

View File

@ -1,15 +1,38 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common';
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, ValidationPipe, Put, Query } from '@nestjs/common';
import { UserService } from './user.service';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { CreateUserDto } from './dto/create-user.dto';
import { AdminRolesGuard } from '../../middlewares/admin-role-guard.middleware';
import { UpdateUserDto } from './dto/update-user.dto';
import { boolean } from 'joi';
@UseGuards(JwtAuthGuard)
@Controller('user')
export class UserController {
constructor(private readonly userService: UserService) {}
constructor(private readonly userService: UserService) { }
@UseGuards(JwtAuthGuard)
@Get()
async getAllUsers(@GetAuthUser() authUser: AuthUserDto) {
return await this.userService.getAllUsers(authUser);
async getAllUsers(@GetAuthUser() authUser: AuthUserDto, @Query('isAll') isAll: boolean) {
return await this.userService.getAllUsers(authUser, isAll);
}
@UseGuards(JwtAuthGuard)
@UseGuards(AdminRolesGuard)
@Post()
async createNewUser(@Body(ValidationPipe) createUserDto: CreateUserDto) {
return await this.userService.createUser(createUserDto);
}
@Get('/count')
async getUserCount(@Query('isAdmin') isAdmin: boolean) {
return await this.userService.getUserCount(isAdmin);
}
@UseGuards(JwtAuthGuard)
@Put()
async updateUser(@Body(ValidationPipe) updateUserDto: UpdateUserDto) {
return await this.userService.updateUser(updateUserDto)
}
}

View File

@ -3,10 +3,14 @@ import { UserService } from './user.service';
import { UserController } from './user.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from './entities/user.entity';
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { JwtModule } from '@nestjs/jwt';
import { jwtConfig } from '../../config/jwt.config';
@Module({
imports: [TypeOrmModule.forFeature([UserEntity])],
imports: [TypeOrmModule.forFeature([UserEntity]), ImmichJwtModule, JwtModule.register(jwtConfig)],
controllers: [UserController],
providers: [UserService],
providers: [UserService, ImmichJwtService],
})
export class UserModule {}
export class UserModule { }

View File

@ -1,21 +1,127 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserEntity } from './entities/user.entity';
import * as bcrypt from 'bcrypt';
@Injectable()
export class UserService {
constructor(
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
) {}
) { }
async getAllUsers(authUser: AuthUserDto, isAll: boolean) {
if (isAll) {
return await this.userRepository.find();
}
async getAllUsers(authUser: AuthUserDto) {
return await this.userRepository.find({
where: { id: Not(authUser.id) },
order: {
createdAt: 'DESC'
}
});
}
async getUserCount(isAdmin: boolean) {
let users;
if (isAdmin) {
users = await this.userRepository.find({ where: { isAdmin: true } });
} else {
users = await this.userRepository.find();
}
return {
userCount: users.length
}
}
async createUser(createUserDto: CreateUserDto) {
const user = await this.userRepository.findOne({ where: { email: createUserDto.email } });
if (user) {
throw new BadRequestException('User exists');
}
const newUser = new UserEntity();
newUser.email = createUserDto.email;
newUser.salt = await bcrypt.genSalt();
newUser.password = await this.hashPassword(createUserDto.password, newUser.salt);
newUser.firstName = createUserDto.firstName;
newUser.lastName = createUserDto.lastName;
newUser.isAdmin = false;
try {
const savedUser = await this.userRepository.save(newUser);
return {
id: savedUser.id,
email: savedUser.email,
firstName: savedUser.firstName,
lastName: savedUser.lastName,
createdAt: savedUser.createdAt,
};
} catch (e) {
Logger.error(e, 'Create new user');
throw new InternalServerErrorException('Failed to register new user');
}
}
private async hashPassword(password: string, salt: string): Promise<string> {
return bcrypt.hash(password, salt);
}
async updateUser(updateUserDto: UpdateUserDto) {
const user = await this.userRepository.findOne(updateUserDto.id);
user.lastName = updateUserDto.lastName || user.lastName;
user.firstName = updateUserDto.firstName || user.firstName;
user.profileImagePath = updateUserDto.profileImagePath || user.profileImagePath;
user.isFirstLoggedIn = updateUserDto.isFirstLoggedIn || user.isFirstLoggedIn;
// If payload includes password - Create new password for user
if (updateUserDto.password) {
user.salt = await bcrypt.genSalt();
user.password = await this.hashPassword(updateUserDto.password, user.salt);
}
if (updateUserDto.isAdmin) {
const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } })
if (adminUser) {
throw new BadRequestException("Admin user exists")
}
user.isAdmin = true;
}
try {
const updatedUser = await this.userRepository.save(user);
return {
id: updatedUser.id,
email: updatedUser.email,
firstName: updatedUser.firstName,
lastName: updatedUser.lastName,
isAdmin: updatedUser.isAdmin,
profileImagePath: updatedUser.profileImagePath,
};
} catch (e) {
Logger.error(e, 'Create new user');
throw new InternalServerErrorException('Failed to register new user');
}
}
}

View File

@ -0,0 +1,15 @@
import { Controller, Get, Res, Headers } from '@nestjs/common';
import { Response } from 'express';
@Controller()
export class AppController {
constructor() { }
@Get()
async redirectToWebpage(@Res({ passthrough: true }) res: Response, @Headers() headers) {
const host = headers.host;
return res.redirect(`http://${host}:2285`)
}
}

View File

@ -15,6 +15,7 @@ import { ServerInfoModule } from './api-v1/server-info/server-info.module';
import { BackgroundTaskModule } from './modules/background-task/background-task.module';
import { CommunicationModule } from './api-v1/communication/communication.module';
import { SharingModule } from './api-v1/sharing/sharing.module';
import { AppController } from './app.controller';
@Module({
imports: [
@ -44,7 +45,7 @@ import { SharingModule } from './api-v1/sharing/sharing.module';
SharingModule,
],
controllers: [],
controllers: [AppController],
providers: [],
})
export class AppModule implements NestModule {

View File

@ -1,4 +1,4 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { createParamDecorator, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { UserEntity } from '../api-v1/user/entities/user.entity';
// import { AuthUserDto } from './dto/auth-user.dto';

View File

@ -7,6 +7,8 @@ import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.enableCors();
app.set('trust proxy');
app.useWebSocketAdapter(new RedisIoAdapter(app));

View File

@ -0,0 +1,30 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserEntity } from '../api-v1/user/entities/user.entity';
import { ImmichJwtService } from '../modules/immich-jwt/immich-jwt.service';
@Injectable()
export class AdminRolesGuard implements CanActivate {
constructor(private reflector: Reflector, private jwtService: ImmichJwtService,
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
) { }
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
if (request.headers['authorization']) {
const bearerToken = request.headers['authorization'].split(" ")[1]
const { userId } = await this.jwtService.validateToken(bearerToken);
const user = await this.userRepository.findOne(userId);
return user.isAdmin;
}
return false;
}
}

View File

@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class UpdateUserTableWithAdminAndName1652633525943 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
alter table users
add column if not exists "firstName" varchar default '';
alter table users
add column if not exists "lastName" varchar default '';
alter table users
add column if not exists "profileImagePath" varchar default '';
alter table users
add column if not exists "isAdmin" bool default false;
alter table users
add column if not exists "isFirstLoggedIn" bool default true;
`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
alter table users
drop column "firstName";
alter table users
drop column "lastName";
alter table users
drop column "isAdmin";
`);
}
}

4
web/.dockerignore Normal file
View File

@ -0,0 +1,4 @@
node_modules/
upload/
dist/

20
web/.eslintrc.cjs Normal file
View File

@ -0,0 +1,20 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
plugins: ['svelte3', '@typescript-eslint'],
ignorePatterns: ['*.cjs'],
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
settings: {
'svelte3/typescript': () => require('typescript')
},
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020
},
env: {
browser: true,
es2017: true,
node: true
}
};

10
web/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
.vercel
.output

1
web/.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

7
web/.prettierrc Normal file
View File

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

8
web/CHANGELOG.md Normal file
View File

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

35
web/Dockerfile Normal file
View File

@ -0,0 +1,35 @@
# Our Node base image
FROM node:16-alpine3.14 as base
WORKDIR /usr/src/app
RUN chown node:node /usr/src/app
COPY --chown=node:node package*.json ./
RUN npm install
COPY --chown=node:node . .
EXPOSE 3000
EXPOSE 24678
FROM base AS dev
ENV CHOKIDAR_USEPOLLING=true
CMD ["npm", "run", "dev"]
FROM node:16-alpine3.14 as prod
WORKDIR /usr/src/app
RUN chown node:node /usr/src/app
COPY --chown=node:node package*.json ./
COPY --chown=node:node . .
RUN npm install
EXPOSE 3000
# Issue build command in entrypoint.sh to capture user .env file instead of the builder .env file.

38
web/README.md Normal file
View File

@ -0,0 +1,38 @@
# create-svelte
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm init svelte
# create a new project in my-app
npm init svelte my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.

1
web/entrypoint.sh Normal file
View File

@ -0,0 +1 @@
npm run build && node /usr/src/app/build/index.js

5783
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
web/package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "web",
"version": "0.0.1",
"scripts": {
"dev": "svelte-kit dev --host 0.0.0.0 --port 3002",
"build": "svelte-kit build",
"package": "svelte-kit package",
"preview": "svelte-kit preview",
"prepare": "svelte-kit sync",
"check": "svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .",
"format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "next",
"@sveltejs/adapter-node": "^1.0.0-next.73",
"@sveltejs/kit": "next",
"@types/bcrypt": "^5.0.0",
"@types/cookie": "^0.4.1",
"@typescript-eslint/eslint-plugin": "^5.10.1",
"@typescript-eslint/parser": "^5.10.1",
"autoprefixer": "^10.4.7",
"eslint": "^8.12.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-svelte3": "^4.0.0",
"postcss": "^8.4.13",
"prettier": "^2.5.1",
"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",
"dependencies": {
"@fontsource/fira-mono": "^4.5.0",
"@lukeed/uuid": "^2.0.0",
"bcrypt": "^5.0.1",
"cookie": "^0.4.2",
"svelte-material-icons": "^2.0.2"
}
}

6
web/postcss.config.cjs Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

31
web/src/app.css Normal file
View File

@ -0,0 +1,31 @@
@import url('https://fonts.googleapis.com/css2?family=Work+Sans:wght@300;400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Snowburst+One&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: 'Work Sans', sans-serif;
}
body {
min-height: 100vh;
margin: 0;
background-color: #f6f8fe;
color: #5f6368;
}
@layer utilities {
.immich-form-input {
@apply bg-slate-100 p-2 rounded-md focus:border-immich-primary text-sm
}
.immich-form-label {
@apply font-medium text-sm text-gray-500
}
.immich-btn-primary {
@apply bg-immich-primary text-gray-100 border rounded-xl py-2 px-4 transition-all duration-150 hover:bg-immich-primary hover:shadow-lg text-sm font-medium
}
}

32
web/src/app.d.ts vendored Normal file
View File

@ -0,0 +1,32 @@
/// <reference types="@sveltejs/kit" />
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare namespace App {
interface Locals {
user?: {
id: string,
email: string,
accessToken: string,
firstName: string,
lastName: string,
isAdmin: boolean,
}
}
// interface Platform {}
interface Session {
user?: {
id: string,
email: string,
accessToken: string,
firstName: string,
lastName: string
isAdmin: boolean,
}
}
// interface Stuff {}
}

12
web/src/app.html Normal file
View File

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

54
web/src/hooks.ts Normal file
View File

@ -0,0 +1,54 @@
import type { ExternalFetch, GetSession, Handle } from '@sveltejs/kit';
import * as cookie from 'cookie';
import { serverEndpoint } from '$lib/constants';
import { session } from '$app/stores';
export const handle: Handle = async ({ event, resolve, }) => {
const cookies = cookie.parse(event.request.headers.get('cookie') || '');
if (!cookies.session) {
return await resolve(event)
}
const { email, isAdmin, firstName, lastName, id, accessToken } = JSON.parse(cookies.session);
const res = await fetch(`${serverEndpoint}/auth/validateToken`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`
}
})
if (res.status === 201) {
event.locals.user = {
id,
accessToken,
firstName,
lastName,
isAdmin,
email
};
}
const response = await resolve(event);
return response;
};
export const getSession: GetSession = async ({ locals }) => {
if (!locals.user) return {}
return {
user: {
id: locals.user.id,
accessToken: locals.user.accessToken,
firstName: locals.user.firstName,
lastName: locals.user.lastName,
isAdmin: locals.user.isAdmin,
email: locals.user.email
}
}
}

53
web/src/lib/api.ts Normal file
View File

@ -0,0 +1,53 @@
import { serverEndpoint } from './constants';
type ISend = {
method: string,
path: string,
data?: any,
token: string
}
type IOption = {
method: string,
headers: Record<string, string>,
body: any
}
async function send({ method, path, data, token }: ISend) {
const opts: IOption = { method, headers: {} } as IOption;
if (data) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(data);
}
if (token) {
opts.headers['Authorization'] = `Bearer ${token}`;
}
return fetch(`${serverEndpoint}/${path}`, opts)
.then((r) => r.text())
.then((json) => {
try {
return JSON.parse(json);
} catch (err) {
return json;
}
});
}
export function getRequest(path: string, token: string) {
return send({ method: 'GET', path, token });
}
export function delRequest(path: string, token: string) {
return send({ method: 'DELETE', path, token });
}
export function postRequest(path: string, data: any, token: string) {
return send({ method: 'POST', path, data, token });
}
export function putRequest(path: string, data: any, token: string) {
return send({ method: 'PUT', path, data, token });
}

74
web/src/lib/auth-api.ts Normal file
View File

@ -0,0 +1,74 @@
type AdminRegistrationResult = Promise<{
error?: string
success?: string
user?: {
email: string
}
}>
type LoginResult = Promise<{
error?: string
success?: string
needUpdate?: boolean
needSelectAdmin?: boolean
user?: {
accessToken: string
firstName: string
lastName: string
isAdmin: boolean
id: string
email: string
}
}>
type UpdateResult = Promise<{
error?: string
success?: string,
user?: {
accessToken: string
firstName: string
lastName: string
isAdmin: boolean
id: string
email: string
}
}>
export async function sendRegistrationForm(form: HTMLFormElement): AdminRegistrationResult {
const response = await fetch(form.action, {
method: form.method,
body: new FormData(form),
headers: { accept: 'application/json' },
})
return await response.json()
}
export async function sendLoginForm(form: HTMLFormElement): LoginResult {
const response = await fetch(form.action, {
method: form.method,
body: new FormData(form),
headers: { accept: 'application/json' },
})
return await response.json()
}
export async function sendUpdateForm(form: HTMLFormElement): UpdateResult {
const response = await fetch(form.action, {
method: form.method,
body: new FormData(form),
headers: { accept: 'application/json' },
})
return await response.json()
}

View File

@ -0,0 +1,41 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import PencilOutline from 'svelte-material-icons/PencilOutline.svelte';
export let usersOnServer: Array<any>;
const dispatch = createEventDispatcher();
</script>
<p class="text-sm">USER LIST</p>
<table class="text-left w-full my-4">
<thead class="border rounded-md mb-2 bg-gray-50 flex text-immich-primary w-full h-12 ">
<tr class="flex w-full place-items-center">
<th class="text-center w-1/4 font-medium text-sm">Email</th>
<th class="text-center w-1/4 font-medium text-sm">First name</th>
<th class="text-center w-1/4 font-medium text-sm">Last name</th>
<th class="text-center w-1/4 font-medium text-sm">Edit</th>
</tr>
</thead>
<tbody class="overflow-y-auto rounded-md w-full max-h-[320px] block border">
{#each usersOnServer as user, i}
<tr
class={`text-center flex place-items-center w-full border-b h-[80px] ${
i % 2 == 0 ? 'bg-gray-100' : 'bg-immich-bg'
}`}
>
<td class="text-sm px-4 w-1/4 text-ellipsis">{user.email}</td>
<td class="text-sm px-4 w-1/4 text-ellipsis">{user.firstName}</td>
<td class="text-sm px-4 w-1/4 text-ellipsis">{user.lastName}</td>
<td class="text-sm px-4 w-1/4 text-ellipsis"
><button
class="bg-immich-primary text-gray-100 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
><PencilOutline size="20" /></button
></td
>
</tr>
{/each}
</tbody>
</table>
<button on:click={() => dispatch('createUser')} class="immich-btn-primary">Create user</button>

View File

@ -0,0 +1,78 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { sendRegistrationForm } from '$lib/auth-api';
let error: string;
let success: string;
async function registerAdmin(event: SubmitEvent) {
error = '';
const formElement = event.target as HTMLFormElement;
const response = await sendRegistrationForm(formElement);
if (response.error) {
error = JSON.stringify(response.error);
}
if (response.success) {
success = response.success;
goto('/auth/login');
}
}
</script>
<div class="border bg-white p-4 shadow-sm w-[500px] rounded-md py-8">
<div class="flex flex-col place-items-center place-content-center gap-4 px-4">
<img class="text-center" src="/immich-logo.svg" height="100" width="100" alt="immich-logo" />
<h1 class="text-2xl text-immich-primary font-medium">Admin Registration</h1>
<p class="text-sm border rounded-md p-4 font-mono text-gray-600">
Since you are the first user on the system, you will be assigned as the Admin and are responsible for
administrative tasks, and additional users will be created by you.
</p>
</div>
<form on:submit|preventDefault={registerAdmin} method="post" action="" autocomplete="off">
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="email">Admin Email</label>
<input class="immich-form-input" id="email" name="email" type="email" required />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="password">Admin Password</label>
<input class="immich-form-input" id="password" name="password" type="password" required />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="password">First Name</label>
<input class="immich-form-input" id="firstName" name="firstName" type="text" required />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="password">Last Name</label>
<input class="immich-form-input" id="lastName" name="lastName" type="text" required />
</div>
{#if error}
<p class="text-red-400">{error}</p>
{/if}
{#if success}
<div>
<p>Admin account has been registered</p>
<p>
<a href="/auth/login">Login</a>
</p>
</div>
{/if}
<div class="flex w-full">
<button
type="submit"
class="m-4 p-2 bg-immich-primary hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full"
>Sign Up</button
>
</div>
</form>
</div>

View File

@ -0,0 +1,74 @@
<script lang="ts">
import { sendRegistrationForm } from '$lib/auth-api';
import { createEventDispatcher } from 'svelte';
let error: string;
let success: string;
const dispatch = createEventDispatcher();
async function registerUser(event: SubmitEvent) {
error = '';
const formElement = event.target as HTMLFormElement;
const response = await sendRegistrationForm(formElement);
if (response.error) {
error = JSON.stringify(response.error);
}
if (response.success) {
success = 'New user created';
dispatch('user-created');
}
}
</script>
<div class="border bg-white p-4 shadow-sm w-[500px] rounded-md py-8">
<div class="flex flex-col place-items-center place-content-center gap-4 px-4">
<img class="text-center" src="/immich-logo.svg" height="100" width="100" alt="immich-logo" />
<h1 class="text-2xl text-immich-primary font-medium">Create new user</h1>
<p class="text-sm border rounded-md p-4 font-mono text-gray-600">
Please provide your user with the password, they will have to change it on their first sign in.
</p>
</div>
<form on:submit|preventDefault={registerUser} method="post" action="/admin/api/create-user" autocomplete="off">
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="email">Email</label>
<input class="immich-form-input" id="email" name="email" type="email" required />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="password">Password</label>
<input class="immich-form-input" id="password" name="password" type="password" required />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="password">First Name</label>
<input class="immich-form-input" id="firstName" name="firstName" type="text" required />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="password">Last Name</label>
<input class="immich-form-input" id="lastName" name="lastName" type="text" required />
</div>
{#if error}
<p class="text-red-400">{error}</p>
{/if}
{#if success}
<p class="text-immich-primary">{success}</p>
{/if}
<div class="flex w-full">
<button
type="submit"
class="m-4 p-2 bg-immich-primary hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full"
>Create</button
>
</div>
</form>
</div>

View File

@ -0,0 +1,73 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { session } from '$app/stores';
import { sendLoginForm } from '$lib/auth-api';
import { createEventDispatcher } from 'svelte';
let error: string;
const dispatch = createEventDispatcher();
async function login(event: SubmitEvent) {
error = '';
const formElement = event.target as HTMLFormElement;
const response = await sendLoginForm(formElement);
if (response.error) {
error = response.error;
}
if (response.needUpdate) {
return dispatch('need-update');
}
if (response.needSelectAdmin) {
return dispatch('need-select-admin');
}
if (response.success) {
$session.user = {
accessToken: response.user!.accessToken,
firstName: response.user!.firstName,
lastName: response.user!.lastName,
isAdmin: response.user!.isAdmin,
id: response.user!.id,
email: response.user!.email,
};
return dispatch('success');
}
}
</script>
<div class="border bg-white p-4 shadow-sm w-[500px] rounded-md py-8">
<div class="flex flex-col place-items-center place-content-center gap-4 px-4">
<img class="text-center" src="/immich-logo.svg" height="100" width="100" alt="immich-logo" />
<h1 class="text-2xl text-immich-primary font-medium">Login</h1>
</div>
<form on:submit|preventDefault={login} method="post" action="" autocomplete="off">
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="email">Email</label>
<input class="immich-form-input" id="email" name="email" type="email" required />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="password">Password</label>
<input class="immich-form-input" id="password" name="password" type="password" required />
</div>
{#if error}
<p class="text-red-400 pl-4">{error}</p>
{/if}
<div class="flex w-full">
<button
type="submit"
class="m-4 p-2 bg-immich-primary hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full font-semibold"
>Login</button
>
</div>
</form>
</div>

View File

@ -0,0 +1,93 @@
<script lang="ts">
import { session } from '$app/stores';
import { createEventDispatcher, onMount } from 'svelte';
import { fade } from 'svelte/transition';
import type { ImmichUser } from '../../models/immich-user';
import Check from 'svelte-material-icons/Check.svelte';
let error: string = '';
let allUsers: Array<ImmichUser> = [];
let selectedUserId: string;
const dispatch = createEventDispatcher();
onMount(async () => {
const res = await fetch('/auth/login/api/get-users', { method: 'GET' });
const data = await res.json();
allUsers = data.allUsers;
});
const assignAdmin = async () => {
const res = await fetch('/auth/login/api/select-admin', {
method: 'POST',
body: JSON.stringify({
id: selectedUserId,
isAdmin: true,
}),
});
if (res.status === 200) {
const data = await res.json();
$session.user = {
accessToken: '',
firstName: data.userInfo.firstName,
lastName: data.userInfo.lastName,
isAdmin: data.userInfo.isAdmin,
id: data.userInfo.id,
email: data.userInfo.email,
};
dispatch('success');
} else {
error = JSON.stringify(await res.json());
}
};
</script>
<div class="border bg-white p-4 shadow-sm w-[500px] rounded-md py-8">
<div class="flex flex-col place-items-center place-content-center gap-4 px-4">
<img class="text-center" src="/immich-logo.svg" height="100" width="100" alt="immich-logo" />
<h1 class="text-2xl text-immich-primary font-medium">Select Admin</h1>
<p class="text-sm border rounded-md p-4 font-mono text-gray-600">
There are multiple users on the server, and none have been selected to be the admin. Please assign one as the
admin, who will be responsible for administrative tasks
</p>
</div>
<div class="text-xs m-4">USERS ON SERVER, CLICK TO SELECT ONE</div>
<div class="overflow-y-auto rounded-md max-h-[300px] block border mx-4 px-4 py-2">
{#each allUsers as user, i}
<div
class="p-4 flex justify-between place-items-center my-4 rounded-md hover:cursor-pointer shadow-sm bg-gray-50 hover:bg-gray-100"
on:click={() => (selectedUserId = user.id)}
>
<p class="test-sm text-slate-600">{i + 1} | {user.email}</p>
<!-- Icon -->
{#if selectedUserId == user.id}
<div
in:fade={{ duration: 100 }}
class="border rounded-full border-gray-300 bg-immich-primary w-8 h-8 flex place-items-center place-content-center"
>
<Check color="white" size="24" />
</div>
{:else}
<div in:fade={{ duration: 100 }} class="border rounded-full border-gray-300 w-8 h-8" />
{/if}
</div>
{/each}
</div>
{#if error}
<div class="text-xs m-4 text-red-400">Error: {error}</div>
{/if}
<div class="flex w-full">
<button
type="submit"
class="m-4 p-2 bg-immich-primary hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full font-semibold"
on:click={assignAdmin}>Assign as Admin</button
>
</div>
</div>

View File

@ -0,0 +1,68 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { session } from '$app/stores';
import { sendUpdateForm } from '$lib/auth-api';
import { createEventDispatcher } from 'svelte';
let error: string;
const dispatch = createEventDispatcher();
async function updateInfo(event: SubmitEvent) {
error = '';
const formElement = event.target as HTMLFormElement;
const response = await sendUpdateForm(formElement);
if (response.error) {
error = response.error;
}
if (response.success) {
$session.user = {
accessToken: response.user!.accessToken,
firstName: response.user!.firstName,
lastName: response.user!.lastName,
isAdmin: response.user!.isAdmin,
id: response.user!.id,
email: response.user!.email,
};
dispatch('success');
}
}
</script>
<div class="border bg-white p-4 shadow-sm w-[500px] rounded-md py-8">
<div class="flex flex-col place-items-center place-content-center gap-4 px-4">
<img class="text-center" src="/immich-logo.svg" height="100" width="100" alt="immich-logo" />
<h1 class="text-2xl text-immich-primary font-medium">Update User Info</h1>
<p class="text-sm border rounded-md p-4 font-mono text-gray-600">
Your account doesn't have information about your name, please update to continue the login process.
</p>
</div>
<form on:submit|preventDefault={updateInfo} method="post" action="/auth/login/update" autocomplete="off">
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="firstName">First name</label>
<input class="immich-form-input" id="firstName" name="firstName" type="text" required />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="lastName">Last name</label>
<input class="immich-form-input" id="lastName" name="lastName" type="text" required />
</div>
{#if error}
<p class="text-red-400 pl-4">{error}</p>
{/if}
<div class="flex w-full">
<button
type="submit"
class="m-4 p-2 bg-immich-primary hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full font-semibold"
>Update</button
>
</div>
</form>
</div>

View File

@ -0,0 +1,15 @@
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

@ -0,0 +1,17 @@
<script lang="ts">
import { clickOutside } from './click-outside';
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
const dispatch = createEventDispatcher();
</script>
<section
in: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 "
>
<div class="bg-immich-bg z-[9999] rounded-md" use:clickOutside on:outclick={() => dispatch('clickOutside')}>
<slot />
</div>
</section>

View File

@ -0,0 +1,60 @@
<script lang="ts">
import { page } from '$app/stores';
import type { ImmichUser } from '$lib/models/immich-user';
import { fade } from 'svelte/transition';
export let user: ImmichUser;
let shouldShowAccountInfo = false;
const getFirstLetter = (text?: string) => {
return text?.charAt(0).toUpperCase();
};
</script>
<section id="dashboard-navbar" class="fixed w-screen z-[100] bg-immich-bg text-sm">
<div class="flex border place-items-center px-6 py-2 ">
<a class="flex gap-2 place-items-center hover:cursor-pointer" href="/photos">
<img src="/immich-logo.svg" alt="immich logo" height="35" width="35" />
<h1 class="font-immich-title text-2xl text-immich-primary">Immich</h1>
</a>
<div class="flex-1 ml-24">
<div class="w-[50%] border rounded-md bg-gray-200 px-8 py-4">Search</div>
</div>
<section class="flex gap-6 place-items-center">
<!-- <div>Upload</div> -->
{#if user.isAdmin}
<a
class={`hover:text-immich-primary font-medium ${
$page.url.pathname == '/admin' && 'text-immich-primary underline'
}`}
href="/admin">Administration</a
>
{/if}
<div
on:mouseover={() => (shouldShowAccountInfo = true)}
on:focus={() => (shouldShowAccountInfo = true)}
on:mouseleave={() => (shouldShowAccountInfo = false)}
>
<button
class="flex place-items-center place-content-center rounded-full bg-immich-primary/80 h-10 w-10 text-gray-100 hover:bg-immich-primary"
>
{getFirstLetter(user.firstName)}{getFirstLetter(user.lastName)}
</button>
{#if shouldShowAccountInfo}
<div
in:fade={{ delay: 500, duration: 150 }}
out:fade={{ delay: 200, duration: 150 }}
class="absolute -bottom-12 right-5 border bg-gray-500 text-[12px] text-gray-100 p-2 rounded-md shadow-md"
>
<p>{user.firstName} {user.lastName}</p>
<p>{user.email}</p>
</div>
{/if}
</div>
</section>
</div>
</section>

View File

@ -0,0 +1,27 @@
<script lang="ts">
export let title: string;
export let logo: any;
export let actionType: AdminSideBarSelection | AppSideBarSelection;
export let isSelected: boolean;
import { createEventDispatcher } from 'svelte';
import type { AdminSideBarSelection, AppSideBarSelection } from '../../models/admin-sidebar-selection';
const dispatch = createEventDispatcher();
const onButtonClicked = () => {
dispatch('selected', {
actionType,
});
};
</script>
<div
on:click={onButtonClicked}
class={`flex gap-4 place-items-center pl-5 py-3 rounded-tr-xl rounded-br-xl hover:bg-gray-200 hover:text-immich-primary hover:cursor-pointer
${isSelected && 'bg-immich-primary/10 text-immich-primary hover:bg-immich-primary/50'}
`}
>
<svelte:component this={logo} size="24" />
<p class="font-medium text-sm">{title}</p>
</div>

1
web/src/lib/constants.ts Normal file
View File

@ -0,0 +1 @@
export const serverEndpoint = import.meta.env.VITE_SERVER_ENDPOINT

View File

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

View File

@ -0,0 +1,7 @@
export type ImmichUser = {
id: string,
email: string,
firstName: string,
lastName: string,
isAdmin: boolean,
}

View File

@ -0,0 +1,39 @@
<script lang="ts">
import { getRequest } from '$lib/api';
import { onDestroy } from 'svelte';
import { fade } from 'svelte/transition';
import '../app.css';
import { serverEndpoint } from '../lib/constants';
let endpoint = serverEndpoint;
let isServerOk = true;
const pingServerInterval = setInterval(async () => {
const response = await getRequest('server-info/ping', '');
if (response.res === 'pong') isServerOk = true;
if (response.statusCode === 404) isServerOk = false;
}, 10000);
onDestroy(() => clearInterval(pingServerInterval));
</script>
<main>
<slot />
</main>
<footer
class="text-sm fixed bottom-0 h-8 flex place-items-center place-content-center bg-immich-primary/10 w-screen font-mono gap-8 px-4 font-medium"
>
<p class="">
Server URL <span class="text-immich-primary font-bold">{endpoint}</span>
</p>
<p class="">
Server Status
{#if isServerOk}
<span class="text-immich-primary font-bold">OK</span>
{:else}
<span class="text-red-500 font-bold">OFFLINE</span>
{/if}
</p>
</footer>

View File

@ -0,0 +1,44 @@
import type { RequestHandler } from '@sveltejs/kit';
import { serverEndpoint } from '$lib/constants';
export const post: RequestHandler = async ({ request, locals }) => {
const form = await request.formData();
const email = form.get('email')
const password = form.get('password')
const firstName = form.get('firstName')
const lastName = form.get('lastName')
const payload = {
email,
password,
firstName,
lastName,
}
const res = await fetch(`${serverEndpoint}/user`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${locals.user?.accessToken}`
},
body: JSON.stringify(payload),
})
if (res.status === 201) {
return {
status: 201,
body: {
success: 'Succesfully create user account'
}
}
} else {
return {
status: 400,
body: {
error: await res.json()
}
}
}
}

View File

@ -0,0 +1,100 @@
<script context="module" lang="ts">
import type { Load } from '@sveltejs/kit';
import { getRequest } from '$lib/api';
export const load: Load = async ({ session, fetch }) => {
if (!session.user) {
return {
status: 302,
redirect: '/auth/login',
};
}
const usersOnServer = await getRequest('user', session.user.accessToken);
return {
status: 200,
props: {
user: session.user,
usersOnServer,
},
};
};
</script>
<script lang="ts">
import { onMount } from 'svelte';
import { session } from '$app/stores';
import type { ImmichUser } from '$lib/models/immich-user';
import { AdminSideBarSelection } from '$lib/models/admin-sidebar-selection';
import SideBarButton from '$lib/components/shared/side-bar-button.svelte';
import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
import NavigationBar from '$lib/components/shared/navigation-bar.svelte';
import UserManagement from '$lib/components/admin/user-management.svelte';
import FullScreenModal from '$lib/components/shared/full-screen-modal.svelte';
import CreateUserForm from '$lib/components/forms/create-user-form.svelte';
let selectedAction: AdminSideBarSelection;
export let user: ImmichUser;
export let usersOnServer: Array<ImmichUser>;
let shouldShowCreateUserForm: boolean;
const onButtonClicked = (buttonType: CustomEvent) => {
selectedAction = buttonType.detail['actionType'] as AdminSideBarSelection;
};
onMount(() => {
selectedAction = AdminSideBarSelection.USER_MANAGEMENT;
});
const onUserCreated = async () => {
if ($session.user) {
usersOnServer = await getRequest('user', $session.user.accessToken);
}
shouldShowCreateUserForm = false;
};
</script>
<svelte:head>
<title>Immich - Administration</title>
</svelte:head>
<NavigationBar {user} />
{#if shouldShowCreateUserForm}
<FullScreenModal on:clickOutside={() => (shouldShowCreateUserForm = false)}>
<div>
<CreateUserForm on:user-created={onUserCreated} />
</div>
</FullScreenModal>
{/if}
<section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen">
<section id="admin-sidebar" class="pt-8 pr-6">
<SideBarButton
title="User"
logo={AccountMultipleOutline}
actionType={AdminSideBarSelection.USER_MANAGEMENT}
isSelected={selectedAction === AdminSideBarSelection.USER_MANAGEMENT}
on:selected={onButtonClicked}
/>
</section>
<section class="overflow-y-auto relative">
<div id="setting-title" class="pt-10 fixed w-full z-50 bg-immich-bg">
<h1 class="text-lg ml-8 mb-4 text-immich-primary font-medium">{selectedAction}</h1>
<hr />
</div>
<section id="setting-content" class="relative pt-[85px] flex place-content-center">
<section class="w-[800px] pt-4">
{#if selectedAction === AdminSideBarSelection.USER_MANAGEMENT}
<UserManagement {usersOnServer} on:createUser={() => (shouldShowCreateUserForm = true)} />
{/if}
</section>
</section>
</section>
</section>

View File

@ -0,0 +1,12 @@
import type { RequestHandler } from '@sveltejs/kit';
import { getRequest } from '../../../../lib/api';
export const get: RequestHandler = async ({ request, locals }) => {
const allUsers = await getRequest('user?isAll=true', locals.user!.accessToken)
return {
status: 200,
body: { allUsers }
};
}

View File

@ -0,0 +1,52 @@
import type { RequestHandler } from '@sveltejs/kit';
import { putRequest } from '$lib/api';
import * as cookie from 'cookie';
export const post: RequestHandler = async ({ request, locals }) => {
const { id, isAdmin } = await request.json()
const res = await putRequest('user', {
id,
isAdmin,
}, locals.user!.accessToken);
if (res.statusCode) {
return {
status: res.statusCode,
body: JSON.stringify(res)
}
}
if (res.id == locals.user!.id) {
return {
status: 200,
body: { userInfo: res },
headers: {
'Set-Cookie': cookie.serialize('session', JSON.stringify(
{
id: res.id,
accessToken: locals.user!.accessToken,
firstName: res.firstName,
lastName: res.lastName,
isAdmin: res.isAdmin,
email: res.email,
}), {
path: '/',
httpOnly: true,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 30,
})
}
}
} else {
return {
status: 200,
body: { userInfo: { ...locals.user! } },
}
}
}

View File

@ -0,0 +1,49 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { fade } from 'svelte/transition';
import LoginForm from '$lib/components/forms/login-form.svelte';
import UpdateForm from '../../../lib/components/forms/update-form.svelte';
import SelectAdminForm from '../../../lib/components/forms/select-admin-form.svelte';
let shouldShowUpdateForm = false;
let shouldShowSelectAdminForm = false;
const onLoginSuccess = async () => {
goto('/photos');
};
const onNeedUpdate = () => {
shouldShowUpdateForm = true;
shouldShowSelectAdminForm = false;
};
const onNeedSelectAdmin = () => {
shouldShowUpdateForm = false;
shouldShowSelectAdminForm = true;
};
</script>
<svelte:head>
<title>Immich - Login</title>
</svelte:head>
<section class="h-screen w-screen flex place-items-center place-content-center">
{#if !shouldShowUpdateForm && !shouldShowSelectAdminForm}
<div in:fade={{ duration: 100 }} out:fade={{ duration: 100 }}>
<LoginForm on:success={onLoginSuccess} on:need-update={onNeedUpdate} on:need-select-admin={onNeedSelectAdmin} />
</div>
{/if}
{#if shouldShowUpdateForm}
<div in:fade={{ duration: 100 }} out:fade={{ duration: 100 }}>
<UpdateForm on:success={onLoginSuccess} />
</div>
{/if}
{#if shouldShowSelectAdminForm}
<div in:fade={{ duration: 100 }} out:fade={{ duration: 100 }}>
<SelectAdminForm on:success={onLoginSuccess} />
</div>
{/if}
</section>

View File

@ -0,0 +1,229 @@
import type { RequestHandler } from '@sveltejs/kit';
import { serverEndpoint } from '$lib/constants';
import * as cookie from 'cookie'
import { getRequest, putRequest } from '$lib/api';
type LoggedInUser = {
accessToken: string;
userId: string;
userEmail: string;
firstName: string;
lastName: string;
isAdmin: boolean;
}
export const post: RequestHandler = async ({ request }) => {
const form = await request.formData();
const email = form.get('email')
const password = form.get('password')
const payload = {
email,
password,
}
const res = await fetch(`${serverEndpoint}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload),
})
if (res.status === 201) {
// Login success
const loggedInUser = await res.json() as LoggedInUser;
/**
* Support legacy users with two scenario
*
* Scenario 1 - If one user exists on the server - make the user admin and ask for name.
* Scenario 2 - After assigned as admin, scenario 1 user not complete update form with names
* Scenario 3 - If two users exists on the server and no admin - ask to choose which one will be made admin
*/
// check how many user on the server
const { userCount } = await getRequest('user/count', '');
const { userCount: adminUserCount } = await getRequest('user/count?isAdmin=true', '')
/**
* Scenario 1 handler
*/
if (userCount == 1 && !loggedInUser.isAdmin) {
const updatedUser = await putRequest('user', {
id: loggedInUser.userId,
isAdmin: true
}, loggedInUser.accessToken)
/**
* Scenario 2 handler for current admin user
*/
let bodyResponse = { success: true, needUpdate: false }
if (loggedInUser.firstName == "" || loggedInUser.lastName == "") {
bodyResponse = { success: false, needUpdate: true }
}
return {
status: 200,
body: {
...bodyResponse,
user: {
id: updatedUser.userId,
accessToken: loggedInUser.accessToken,
firstName: updatedUser.firstName,
lastName: updatedUser.lastName,
isAdmin: updatedUser.isAdmin,
email: updatedUser.email,
},
},
headers: {
'Set-Cookie': cookie.serialize('session', JSON.stringify(
{
id: updatedUser.userId,
accessToken: loggedInUser.accessToken,
firstName: updatedUser.firstName,
lastName: updatedUser.lastName,
isAdmin: updatedUser.isAdmin,
email: updatedUser.email,
}), {
path: '/',
httpOnly: true,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 30,
})
}
}
}
/**
* Scenario 3 handler
*/
if (userCount >= 2 && adminUserCount == 0) {
return {
status: 200,
body: {
needSelectAdmin: true,
user: {
id: loggedInUser.userId,
accessToken: loggedInUser.accessToken,
firstName: loggedInUser.firstName,
lastName: loggedInUser.lastName,
isAdmin: loggedInUser.isAdmin,
email: loggedInUser.userEmail
},
success: 'success'
},
headers: {
'Set-Cookie': cookie.serialize('session', JSON.stringify(
{
id: loggedInUser.userId,
accessToken: loggedInUser.accessToken,
firstName: loggedInUser.firstName,
lastName: loggedInUser.lastName,
isAdmin: loggedInUser.isAdmin,
email: loggedInUser.userEmail
}), {
path: '/',
httpOnly: true,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 30,
})
}
}
}
/**
* Scenario 2 handler
*/
if (loggedInUser.firstName == "" || loggedInUser.lastName == "") {
return {
status: 200,
body: {
needUpdate: true,
user: {
id: loggedInUser.userId,
accessToken: loggedInUser.accessToken,
firstName: loggedInUser.firstName,
lastName: loggedInUser.lastName,
isAdmin: loggedInUser.isAdmin,
email: loggedInUser.userEmail
},
},
headers: {
'Set-Cookie': cookie.serialize('session', JSON.stringify(
{
id: loggedInUser.userId,
accessToken: loggedInUser.accessToken,
firstName: loggedInUser.firstName,
lastName: loggedInUser.lastName,
isAdmin: loggedInUser.isAdmin,
email: loggedInUser.userEmail
}), {
path: '/',
httpOnly: true,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 30,
})
}
}
}
return {
status: 200,
body: {
user: {
id: loggedInUser.userId,
accessToken: loggedInUser.accessToken,
firstName: loggedInUser.firstName,
lastName: loggedInUser.lastName,
isAdmin: loggedInUser.isAdmin,
email: loggedInUser.userEmail
},
success: 'success'
},
headers: {
'Set-Cookie': cookie.serialize('session', JSON.stringify(
{
id: loggedInUser.userId,
accessToken: loggedInUser.accessToken,
firstName: loggedInUser.firstName,
lastName: loggedInUser.lastName,
isAdmin: loggedInUser.isAdmin,
email: loggedInUser.userEmail,
}), {
// send cookie for every page
path: '/',
// server side only cookie so you can't use `document.cookie`
httpOnly: true,
// only requests from same site can send cookies
// and serves to protect from CSRF
// https://developer.mozilla.org/en-US/docs/Glossary/CSRF
sameSite: 'strict',
// set cookie to expire after a month
maxAge: 60 * 60 * 24 * 30,
})
}
}
} else {
return {
status: 400,
body: {
error: 'Incorrect email or password'
}
}
}
}

View File

@ -0,0 +1,63 @@
import type { RequestHandler } from '@sveltejs/kit';
import { putRequest } from '../../../lib/api';
import * as cookie from 'cookie'
export const post: RequestHandler = async ({ request, locals }) => {
const form = await request.formData();
const firstName = form.get('firstName')
const lastName = form.get('lastName')
if (locals.user) {
const updatedUser = await putRequest('user', {
id: locals.user.id,
firstName,
lastName
}, locals.user.accessToken)
return {
status: 200,
body: {
user: {
id: updatedUser.id,
accessToken: locals.user.accessToken,
firstName: updatedUser.firstName,
lastName: updatedUser.lastName,
isAdmin: updatedUser.isAdmin,
email: updatedUser.email,
},
success: 'Update user success'
},
headers: {
'Set-Cookie': cookie.serialize('session', JSON.stringify(
{
id: updatedUser.id,
accessToken: locals.user.accessToken,
firstName: updatedUser.firstName,
lastName: updatedUser.lastName,
isAdmin: updatedUser.isAdmin,
email: updatedUser.email,
}), {
path: '/',
httpOnly: true,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 30,
})
}
}
}
return {
status: 400,
body: {
error: 'Cannot get access token from cookies'
}
}
}

View File

@ -0,0 +1,39 @@
<script context="module" lang="ts">
import type { Load } from '@sveltejs/kit';
import { serverEndpoint } from '$lib/constants';
export const load: Load = async ({ session, fetch }) => {
const res = await fetch(`${serverEndpoint}/user/count`);
const { userCount } = await res.json();
if (userCount != 0) {
// Admin has been registered, redirect to login
if (!session.user) {
return {
status: 302,
redirect: '/auth/login',
};
} else {
return {
status: 302,
redirect: '/dashboard',
};
}
}
return {};
};
</script>
<script lang="ts">
import AdminRegistrationForm from '$lib/components/forms/admin-registration-form.svelte';
</script>
<svelte:head>
<title>Immich - Admin Registration</title>
</svelte:head>
<section class="h-screen w-screen flex place-items-center place-content-center">
<AdminRegistrationForm />
</section>

View File

@ -0,0 +1,43 @@
import type { RequestHandler } from '@sveltejs/kit';
import { serverEndpoint } from '$lib/constants';
export const post: RequestHandler = async ({ request }) => {
const form = await request.formData();
const email = form.get('email')
const password = form.get('password')
const firstName = form.get('firstName')
const lastName = form.get('lastName')
const payload = {
email,
password,
firstName,
lastName,
}
const res = await fetch(`${serverEndpoint}/auth/admin-sign-up`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload),
})
if (res.status === 201) {
return {
status: 201,
body: {
success: 'Succesfully create admin account'
}
}
} else {
return {
status: 400,
body: {
error: await res.json()
}
}
}
}

View File

@ -0,0 +1,62 @@
<script context="module" lang="ts">
export const prerender = false;
import type { Load } from '@sveltejs/kit';
export const load: Load = async ({ session, fetch }) => {
const res = await fetch(`${serverEndpoint}/user/count`);
const { userCount } = await res.json();
if (!session.user) {
// Check if admin exist to wherether navigating to login or registration
if (userCount != 0) {
return {
status: 200,
props: {
isAdminUserExist: true,
},
};
} else {
return {
status: 200,
props: {
isAdminUserExist: false,
},
};
}
} else {
return {
status: 302,
redirect: '/photos',
};
}
};
</script>
<script lang="ts">
import { serverEndpoint } from '$lib/constants';
import { goto } from '$app/navigation';
export let isAdminUserExist: boolean;
async function onGettingStartedClicked() {
isAdminUserExist ? goto('/auth/login') : goto('/auth/register');
}
</script>
<svelte:head>
<title>Immich - Welcome 🎉</title>
<meta name="description" content="Immich Web Interface" />
</svelte:head>
<section class="h-screen w-screen flex place-items-center place-content-center">
<div class="flex flex-col place-items-center gap-8 text-center max-w-[350px]">
<div class="flex place-items-center place-content-center ">
<img class="text-center" src="immich-logo.svg" height="200" width="200" alt="immich-logo" />
</div>
<h1 class="text-4xl text-immich-primary font-bold font-immich-title">Welcome to Immich Web</h1>
<button
class="border px-4 py-2 rounded-md bg-immich-primary hover:bg-immich-primary/75 text-white font-bold w-[200px]"
on:click={onGettingStartedClicked}>Getting Started</button
>
</div>
</section>

View File

@ -0,0 +1,75 @@
<script context="module" lang="ts">
import type { Load } from '@sveltejs/kit';
export const load: Load = ({ session }) => {
if (!session.user) {
return {
status: 302,
redirect: '/auth/login',
};
}
return {
status: 200,
props: {
user: session.user,
},
};
};
</script>
<script lang="ts">
import type { ImmichUser } from '$lib/models/immich-user';
import NavigationBar from '../../lib/components/shared/navigation-bar.svelte';
import SideBarButton from '$lib/components/shared/side-bar-button.svelte';
import Magnify from 'svelte-material-icons/Magnify.svelte';
import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
import { AppSideBarSelection } from '$lib/models/admin-sidebar-selection';
import { onMount } from 'svelte';
export let user: ImmichUser;
let selectedAction: AppSideBarSelection;
const onButtonClicked = (buttonType: CustomEvent) => {
selectedAction = buttonType.detail['actionType'] as AppSideBarSelection;
};
onMount(() => {
selectedAction = AppSideBarSelection.PHOTOS;
});
</script>
<svelte:head>
<title>Immich - Photos</title>
</svelte:head>
<section>
<NavigationBar {user} />
</section>
<section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen">
<section id="admin-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}
/>
<SideBarButton
title="Explore"
logo={Magnify}
actionType={AppSideBarSelection.EXPLORE}
isSelected={selectedAction === AppSideBarSelection.EXPLORE}
on:selected={onButtonClicked}
/>
</section>
<section class="overflow-y-auto relative">
<section id="setting-content" class="relative pt-[85px]">
<section class="pt-4">Coming soon</section>
</section>
</section>
</section>

BIN
web/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

View File

@ -0,0 +1,98 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="svg2781" xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 564.2 553.5"
style="enable-background:new 0 0 564.2 553.5;" xml:space="preserve">
<style type="text/css">
.st0{fill:#4081EF;stroke:#512D8C;stroke-miterlimit:10;}
.st1{fill:#31A452;stroke:#512D8C;stroke-miterlimit:10;}
.st2{fill:#DE7FB3;stroke:#512D8C;stroke-miterlimit:10;}
.st3{fill:#FFB800;stroke:#512D8C;stroke-miterlimit:10;}
.st4{fill:#E64132;stroke:#512D8C;stroke-miterlimit:10;}
.st5{fill:#F2F5FB;stroke:#512D8C;stroke-miterlimit:10;}
</style>
<path class="st0" d="M210.5,549.6c-2.2-0.2-5.5-1-9.7-2.2c-52.4-15.7-99-46.5-133.8-88.5c-8.8-10.7-17.2-22.4-19.4-27.5
c-8.1-18.1-6.3-38.7,4.8-55.4c5-7.5,13.2-15,20.5-18.7c1.2-0.6,54.1-20,55.8-20.4c0.5-0.1,0.5,0.2-0.3,2.1c-0.7,1.7-1,3.1-1.1,5.5
l-0.1,3.2l2.8,5.8c8.7,17.9,19.2,32.7,33.2,46.4c6.3,6.2,7.8,7.6,13.8,12.3c22.7,18.1,52,30.7,79.9,34.3c2.5,0.3,5,0.8,5.7,1
c2.8,0.9,7.7-0.8,11-3.7l1.8-1.6l-0.2,4.8c-0.1,2.7-0.6,15.4-1,28.3c-0.6,20.3-0.8,24-1.5,27.5c-3.9,20.7-18.6,37.5-38.4,44.1
c-4.6,1.5-8,2.2-13.1,2.7C216.6,550.1,215.3,550,210.5,549.6z"/>
<path class="st1" d="M339.8,549.4c-4-0.4-9.4-1.6-13.2-2.9c-3.4-1.2-10-4.4-12.5-6.1c-10.9-7.4-19-17.9-23.1-30
c-2.2-6.7-2.3-7.5-3.3-36.9c-0.5-14.9-0.9-27.9-0.9-28.9l0-1.9l2.3,1.8c2.6,2,6.6,3.4,8.5,3.1c0.6-0.1,3-0.5,5.3-0.8
c37.7-5.3,71.2-22.2,97.4-49.1c12.2-12.5,21.4-25.5,29.9-42.4l3.5-7l0-3.6c0-3.1-0.1-3.8-1-5.7c-0.5-1.2-0.9-2.1-0.9-2.2
c0.2-0.2,55.3,20.1,56.9,20.9c2.6,1.3,6.6,4.1,9.9,7c9.2,7.7,16.1,19.4,18.8,31.8c0.7,3.1,0.8,4.8,0.8,11.3c0,8.6-0.5,11.7-2.9,18.7
c-1.7,5-2.9,7.2-7.1,13.1c-7.6,11-15.3,20.5-25.2,31.2c-32.8,35.4-76.5,62.5-123.4,76.3C351.6,549.6,347.2,550.1,339.8,549.4z"/>
<path class="st2" d="M255.6,438c-25.9-4.2-50.7-14.9-71.7-31c-5.2-4-8.7-7.1-14.1-12.4c-12.7-12.5-21.9-24.9-30.5-41.4
c-2.3-4.4-2.4-4.7-2.4-7.1c0-8.8,8.5-15.2,16.9-12.7c5.6,1.7,9.6,6.8,9.7,12.2c0,2.6-0.8,4.6-2.6,6.2c-1.2,1.1-3.2,1.9-4.6,1.9
c-1.2,0-3.3-0.8-4.3-1.6c-2.1-1.8-2-1,0.4,3.2c19.3,33.8,52.3,59.1,90,69.1c5.7,1.5,11.5,2.7,11.8,2.4c0.1-0.1-0.4-0.8-1.3-1.6
c-5.1-4.5-2.3-11.7,5-12.8c5.4-0.8,11.4,2.7,13.9,8c0.8,1.7,1,2.5,1,5.3s-0.1,3.5-1,5.3c-2,4.3-6.8,7.9-10.3,7.8
C260.6,438.7,257.9,438.3,255.6,438z"/>
<path class="st0" d="M297.6,438.2c-3.4-1.3-6.4-4.3-7.8-8.1c-1.1-2.9-0.9-7.3,0.5-10.2c2.6-5.3,8.7-8.5,14.4-7.5
c2.9,0.5,4.7,1.9,6,4.3c0.8,1.6,1,2.2,0.8,3.6c-0.3,2.2-0.9,3.3-2.7,4.8c-0.8,0.7-1.4,1.4-1.3,1.5c0.5,0.5,13.4-2.7,21.3-5.4
c33.6-11.3,62.5-35.1,80.4-66.1c2.5-4.4,2.6-5,0.5-3.2c-2.8,2.4-7,1.9-9.6-1c-4-4.6-0.7-13.8,6.1-16.9c2-0.9,2.7-1,5.5-1
c2.9,0,3.5,0.1,5.6,1.1c4.4,2.1,7.4,6.4,7.8,11c0.2,2.2,0.1,2.3-2.2,6.9c-23,45.9-67,78.1-117.2,85.9
C300.2,438.8,299.4,438.9,297.6,438.2z"/>
<path class="st1" d="M211.1,398.5c-4.7-0.9-8.7-2.7-12.9-5.9c-10.8-8.1-13.5-22.3-6.6-33.7c0.7-1.2,1.1-2.2,1-2.4
c-0.2-0.2-1.2-0.6-2.3-1.1c-7.6-3-13-10.6-13.5-19.1c-0.5-7.4,3.1-15,9-19.4c1-0.7,2.2-1.5,2.6-1.8c0.8-0.4,68.9-22.7,69.4-22.7
c0.2,0,0.7,0.7,1.2,1.5c0.5,0.8,1.6,2.3,2.4,3.3c1.2,1.4,1.5,1.9,1.2,2.3c-0.2,0.3-6.9,9.5-14.8,20.5
c-15.9,21.9-15.5,21.3-13.4,23.4c1.3,1.3,2.9,1.4,4.4,0.3c0.6-0.4,7.5-9.7,15.5-20.7c11.2-15.4,14.6-19.9,15-19.7
c0.9,0.4,5.5,1.9,6.6,2.1l1,0.2l0,35.3c0,39.7,0,38.8-2.5,44c-2.6,5.3-7.2,9.3-12.7,11.2c-3.7,1.3-6.8,1.6-10.2,1
c-5.5-0.9-9.8-3.2-13.7-7.4l-2.2-2.4l-0.6,0.9c-3,4.3-8.6,8.1-14,9.5C218.2,398.6,213.2,398.9,211.1,398.5z"/>
<path class="st3" d="M342.9,398.5c-5.5-0.9-9.9-3.2-14.3-7.6l-3.2-3.2l-0.7,1c-2.3,3.3-6.8,6.5-11.1,7.9c-3.7,1.2-9.2,1.4-12.6,0.3
c-7.1-2.1-12.7-7.4-15.2-14.3l-0.9-2.6v-37.1v-37.1l1.8-0.4c1-0.2,2.7-0.8,3.9-1.2c1.1-0.5,2.1-0.8,2.2-0.7c0.1,0.1,6.5,9,14.4,19.9
c7.8,10.9,14.7,20.1,15.2,20.5c2.2,1.9,5.4,0.4,5.4-2.6c0-1.4-1-2.9-13.8-20.5c-7.6-10.5-14.2-19.6-14.7-20.4l-0.9-1.3l1.4-1.7
c0.8-0.9,1.9-2.5,2.5-3.4l1-1.6l34.4,11.2c18.9,6.2,35.1,11.6,35.9,12.1c6.8,4,11.1,11.3,11.1,19.1c0,4.1-0.5,6.4-2.4,10.2
c-2,4.1-5.5,7.6-9.6,9.7c-1.6,0.8-3.2,1.5-3.4,1.5c-1,0-0.9,0.7,0.3,2.6c2.8,4.3,4,8.5,3.9,13.7c0,8.1-3.7,15.2-10.6,20.3
C356.4,397.6,349.5,399.5,342.9,398.5z"/>
<path class="st2" d="M53.9,341.9c-0.5-0.1-2.3-0.4-3.9-0.7c-15.6-2.6-30.4-12.6-38.8-26.2c-3.5-5.7-6.4-13.2-7.8-19.9
c-1.2-6.1-0.8-28.1,0.8-43.1c4.5-43,19-84.3,42.2-120.7c6.5-10.2,14.9-21.5,18.2-24.6c17.8-16.6,43.1-20.5,64.8-10
c4.3,2.1,8.8,5.1,12.7,8.6c2.8,2.4,5.8,6.1,20.9,25.5c9.7,12.5,17.8,22.8,17.9,23c0.2,0.2-0.9,0.4-3.2,0.4c-2.5,0-4.1,0.2-5.7,0.7
c-2.1,0.7-2.6,1.1-7.9,6.3c-8.2,8.1-14.4,15.3-20.3,23.9c-15.5,22.2-25.4,47.7-28.8,74.8c-2.2,16.9-1.6,37.5,1.6,52.3
c0.3,1.4,0.5,2.8,0.4,3c-0.1,0.2,0.2,1.3,0.8,2.4c1.1,2.4,4.3,5.7,6.5,6.8l1.5,0.8l-1.2,0.4c-0.7,0.2-13.1,3.8-27.6,8
c-16.4,4.7-27.7,7.8-29.8,8.1C64.1,342.1,56.1,342.3,53.9,341.9z"/>
<path class="st3" d="M494.7,341.7c-2.1-0.3-33.8-9.1-56.5-15.8l-2.5-0.7l1.6-0.8c3.4-1.7,7.2-6.6,7.3-9.6c0-0.7,0.4-3.3,0.8-5.8
c3.9-22.7,3.1-46.1-2.5-68.4c-6.4-25.5-18.6-49.2-35.8-69.1c-4.6-5.3-14.8-15.4-16.4-16.1c-2.4-1.1-5.1-1.6-8-1.4l-2.7,0.2l1.2-1.5
c0.7-0.8,8.5-10.8,17.5-22.3c8.9-11.5,17.2-21.8,18.5-23.1c2.6-2.7,7-6.2,10.3-8.2c19.3-11.6,43-11.1,61.6,1.2
c5.4,3.6,8.2,6.2,12.3,11.7c26.4,34.5,44,73.7,52.3,116.2c3.4,17.6,4.9,33.3,5,52.4c0,13-0.2,14.8-2.5,21.8
C547.8,328.6,521.7,345.2,494.7,341.7z"/>
<path class="st4" d="M133.9,318.5c-2-0.5-4.6-1.9-6-3.3c-2.5-2.4-3.1-3.5-3.7-7.3c-4.4-27.3-2.2-54,6.7-79.3
c5.3-15.1,13.5-30.5,23-43.1c5.8-7.8,16.6-19.5,19-20.7c4.7-2.4,11.3-1.2,15.2,2.7c5.4,5.4,5.2,13.9-0.3,19.1
c-4.3,4-9.4,4.4-12.6,0.9c-1.7-1.9-2.2-3.9-1.7-6.4c0.2-1.1,0.3-2,0.2-2.2c-0.3-0.3-3.6,3.3-8.3,9.1c-17.6,21.8-28.5,48-31.9,76.5
c-1.1,9.3-1,26.4,0.1,34.6c0.3,1.8,0.8,1.9,1.4,0.1c0.9-2.6,4-4.7,6.8-4.7c3,0,5.9,2.2,7.5,5.7c0.6,1.3,0.8,2.3,0.8,5.2
c0,3.3-0.1,3.8-1.1,5.7c-1.4,2.7-4.6,5.7-7.1,6.6C139.4,318.6,135.8,318.9,133.9,318.5z"/>
<path class="st1" d="M422.6,318.5c-3.7-0.6-7.7-3.6-9.4-7.1c-3.8-7.5,0.1-16.9,6.9-16.9c3.1,0,5.8,2,6.9,5.2
c0.4,1.2,0.5,1.3,0.7,0.7c1.3-3.7,1.7-26.4,0.6-35.7c-3.6-29.6-14.5-55.3-33-77.9c-5.5-6.7-8.4-9.4-7.1-6.6c0.7,1.4,0.5,4.3-0.3,5.9
c-0.9,1.7-3.2,3.5-5,3.8c-3.2,0.6-7.9-1.6-10.2-4.8c-6.5-8.8-0.5-21.2,10.4-21.4c4.6-0.1,5.2,0.3,11.2,6.4
c12.1,12.3,21.1,24.9,28.8,40.3c13.2,26.3,18.6,54.9,16.1,84.5c-0.5,5.6-2,15.7-2.6,17.1c-1.3,2.8-4.8,5.5-8.4,6.5
C425.9,318.9,425.1,318.9,422.6,318.5z"/>
<path class="st0" d="M178.2,307.2c-6-1.3-12.2-6.2-14.9-11.7c-3.4-7-3.1-15.1,0.9-21.6c0.7-1.2,1.2-2.3,1.1-2.4
c-0.1-0.1-1.1-0.6-2.1-1c-3.9-1.5-8.1-4.8-10.7-8.3c-4.6-6.2-6.1-14.6-3.9-22.1c2.9-10.3,9.4-16.8,19.1-19.3c2.8-0.7,9-0.8,11.7,0
c1.1,0.3,2.2,0.5,2.4,0.5c0.2,0,0.3-0.7,0.3-1.5c0-2.9,0.8-5.8,2.4-9.2c5.2-10.8,18.1-15.5,29-10.5c2.7,1.2,6.2,3.8,7.8,5.8
c0.7,0.8,10.3,14,21.5,29.4l20.3,27.9l-1.5,1.8c-0.8,1-1.9,2.6-2.5,3.5c-0.6,1-1.2,1.7-1.5,1.6c-4.5-1.7-46.7-15-47.7-15
c-1.9,0-3.1,1.3-3.1,3.2c0,1,0.2,1.7,0.8,2.3c0.6,0.6,7.8,3.1,24.5,8.5l23.7,7.7l-0.1,4.3l-0.1,4.3L223,295.9
c-18,5.9-33.9,10.9-35.2,11.2C184.7,307.8,181.2,307.8,178.2,307.2z"/>
<path class="st4" d="M372.5,306.8c-1.8-0.5-17.5-5.6-35-11.3l-31.8-10.4l1-4.3v-4.3l22.6-7.7c15-4.9,24-8,24.6-8.5
c0.7-0.6,0.9-1.1,0.9-2.2c0-2-1.2-3.3-3.1-3.3c-0.9,0-10.5,2.9-24.7,7.5c-12.8,4.1-23.4,7.5-23.6,7.5c-0.1,0-0.7-0.8-1.3-1.9
c-0.6-1-1.6-2.5-2.2-3.2c-0.7-0.7-1.2-1.5-1.2-1.6c0-0.2,9.6-13.5,21.4-29.6c18.9-26,21.6-29.6,23.6-31.1c5.7-4.4,13.1-5.8,19.7-3.9
c9,2.7,16.1,11.6,16.1,20.3c0,2.3-0.1,2.3,3.1,1.5c4.7-1.1,11.5-0.5,16,1.5c4.6,2,9,6,11.5,10.2c2.1,3.6,3.9,9.4,4.2,13.2
c0.3,5.2-1.1,10.7-4,15.3c-2.6,4.1-7.8,8.3-12.1,9.8c-0.9,0.3-1.7,0.8-1.7,1c0,0.2,0.4,1,0.9,1.7c2.4,3.6,3.6,7.7,3.5,12.7
c0,5.8-2.1,10.7-6.4,15.1c-4,4.1-8.9,6.3-14.9,6.5C376.3,307.7,375.3,307.6,372.5,306.8z"/>
<path class="st5" d="M276.2,298.9c-6.1-1.6-11.4-6.8-13.2-12.9c-0.7-2.4-0.7-7.5,0-9.9c1.7-5.8,6.6-10.8,12.3-12.5
c2.7-0.8,7.2-0.9,10-0.2c6.2,1.6,11.6,7.1,13.2,13.3c1.6,6-0.3,12.6-5,17.3C288.9,298.6,282.2,300.5,276.2,298.9z"/>
<path class="st2" d="M248.3,229.8c-13.3-18.3-21.2-29.6-22-31.1c-1.4-3-1.9-5.5-1.9-9.4c0-14.1,13.1-24.4,27.1-21.4
c1.4,0.3,2.6,0.5,2.7,0.5s0.3-1.3,0.4-2.8c0.8-10.7,8.4-19.6,18.9-22.4c3.9-1,10.6-1,14.5,0c8.9,2.3,15.9,9.3,18.2,18.2
c0.4,1.5,0.7,3.7,0.7,4.9c0,1.2,0.1,2.1,0.3,2.1s1.5-0.3,3-0.6c7.4-1.6,15.2,0.7,20.5,6c4.3,4.3,6.6,9.6,6.6,15.6
c0,4-0.6,6.5-2.4,10c-0.6,1.2-10.4,15-21.7,30.7c-17.8,24.5-20.8,28.5-21.4,28.3c-0.4-0.1-1.9-0.6-3.4-1.1c-1.5-0.5-2.9-0.9-3.3-0.9
c-0.7,0-0.7-0.8-0.3-25.5v-25.5l-1.4-0.9c-1-1.1-2.5-1.5-3.8-0.9c-2,0.8-2-0.5-1.8,27.2v25.8h-1.2c-0.5-0.2-2.4,0.3-4,0.9
s-3.1,1.1-3.2,1.1C269.2,258.5,259.8,245.6,248.3,229.8z"/>
<path class="st3" d="M210.9,164.8c-4.1-0.9-7.7-3.6-9.6-7.4c-1.4-2.8-1.7-7.3-0.5-10.3c1.7-4.5,3.9-6.1,15.6-11.2
c15.8-7,31.4-11.1,49.2-12.9c7.3-0.8,23.2-0.8,30.6,0c17.4,1.8,33.3,6,49.1,13c7.3,3.2,12.5,6.1,13.6,7.5c4.3,5.6,3.8,12.7-1.1,17.6
c-5.1,5.1-12.9,5.4-18.1,0.7c-2-1.8-3-3.5-3.4-5.6c-0.7-4,2.9-8.1,7.3-8.2c1.4,0,1.5-0.1,1.1-0.5c-0.3-0.3-2.2-1.2-4.3-2.1
c-33.2-14.5-70.5-16.4-105-5.4c-7.5,2.4-19,7.2-18.6,7.7c0.1,0.2,0.8,0.3,1.6,0.3c5.6,0,9.1,6.2,6.1,10.8
C221.6,163.3,215.9,165.9,210.9,164.8z"/>
<path class="st4" d="M174.7,123.4c-8.9-13.1-16.8-25.1-17.5-26.6c-1.6-3.3-3.6-9.2-4.4-13c-2.6-12.5-0.9-25.8,5-37.5
c4.2-8.3,11.2-16.3,18.6-21.3c5-3.4,6.1-3.9,12.8-6.3c23.1-8.2,47.2-13.1,73.4-15c7.5-0.6,28.5-0.6,36.3,0
c25.5,1.8,50.6,6.9,73,14.8c6.4,2.2,8.2,3.1,13.1,6.5c9.8,6.6,18.1,17.5,22,29.2c2.2,6.5,2.7,10,2.7,17.9c0,7.9-0.5,11.3-2.7,17.9
c-2.3,6.8-3.7,9.1-20.3,33.6l-16.1,23.8l-0.4-2.2c-0.2-1.2-0.9-3-1.4-4c-1-1.8-4.4-5.6-4.7-5.2c-0.1,0.1-1.2-0.4-2.4-1.1
c-9.1-5.2-21.9-10.5-33.2-13.9c-37-11-77.2-8.8-113,6.1c-4.9,2.1-17.7,8.4-19.2,9.5c-2.2,1.6-5.1,6.8-5.1,9c0,0.4-0.1,1-0.3,1.2
C191,147,184.7,138,174.7,123.4z"/>
</svg>

After

Width:  |  Height:  |  Size: 9.7 KiB

3
web/static/robots.txt Normal file
View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

18
web/svelte.config.js Normal file
View File

@ -0,0 +1,18 @@
import preprocess from 'svelte-preprocess';
import adapter from '@sveltejs/adapter-node';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://github.com/sveltejs/svelte-preprocess
// for more information about preprocessors
preprocess: preprocess(),
kit: {
adapter: adapter({ out: 'build' }),
methodOverride: {
allowed: ['PATCH', 'DELETE'],
},
},
};
export default config;

15
web/tailwind.config.cjs Normal file
View File

@ -0,0 +1,15 @@
module.exports = {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {
colors: {
'immich-primary': '#4250af',
'immich-bg': '#f6f8fe',
},
fontFamily: {
'immich-title': ['Snowburst One', 'cursive'],
},
},
},
plugins: [],
};

20
web/tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"lib": [
"es2020",
"DOM"
],
"moduleResolution": "node",
"module": "es2020",
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"target": "es2020",
}
}