1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-29 15:11:58 +00:00

feat(web): onboarding (#6066)

* feat(web): onboarding

* feat: openapi

* feat: modulization

* feat: page advancing

* Animation

* Add storage templaete settings

* sql

* more style

* Theme

* information and styling

* hide/show table

* Styling

* Update user property

* fix test

* fix test:

* fix e2e

* test

* Update web/src/lib/components/onboarding-page/onboarding-hello.svelte

Co-authored-by: bo0tzz <git@bo0tzz.me>

* naming

* use System Metadata

* better return type

* onboarding using server metadata

* revert previous changes in user entity

* sql

* test web

* fix test server

* server/web test

* more test

* consolidate color theme change logic

* consolidate save button to storage template

* merge main

* fix web

---------

Co-authored-by: bo0tzz <git@bo0tzz.me>
This commit is contained in:
Alex 2024-01-03 23:28:32 -06:00 committed by GitHub
parent f8d64be13c
commit 18f59f78e3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 593 additions and 110 deletions

View file

@ -3019,6 +3019,12 @@ export interface ServerConfigDto {
* @memberof ServerConfigDto
*/
'isInitialized': boolean;
/**
*
* @type {boolean}
* @memberof ServerConfigDto
*/
'isOnboarded': boolean;
/**
*
* @type {string}
@ -15142,6 +15148,44 @@ export const ServerInfoApiAxiosParamCreator = function (configuration?: Configur
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
setAdminOnboarding: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/server-info/admin-onboarding`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@ -15233,6 +15277,15 @@ export const ServerInfoApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.pingServer(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async setAdminOnboarding(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.setAdminOnboarding(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
};
@ -15307,6 +15360,14 @@ export const ServerInfoApiFactory = function (configuration?: Configuration, bas
pingServer(options?: AxiosRequestConfig): AxiosPromise<ServerPingResponse> {
return localVarFp.pingServer(options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
setAdminOnboarding(options?: AxiosRequestConfig): AxiosPromise<void> {
return localVarFp.setAdminOnboarding(options).then((request) => request(axios, basePath));
},
};
};
@ -15396,6 +15457,16 @@ export class ServerInfoApi extends BaseAPI {
public pingServer(options?: AxiosRequestConfig) {
return ServerInfoApiFp(this.configuration).pingServer(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ServerInfoApi
*/
public setAdminOnboarding(options?: AxiosRequestConfig) {
return ServerInfoApiFp(this.configuration).setAdminOnboarding(options).then((request) => request(this.axios, this.basePath));
}
}

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -4725,6 +4725,31 @@
]
}
},
"/server-info/admin-onboarding": {
"post": {
"operationId": "setAdminOnboarding",
"parameters": [],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Server Info"
]
}
},
"/server-info/config": {
"get": {
"operationId": "getServerConfig",
@ -8599,6 +8624,9 @@
"isInitialized": {
"type": "boolean"
},
"isOnboarded": {
"type": "boolean"
},
"loginPageMessage": {
"type": "string"
},
@ -8614,6 +8642,7 @@
"oauthButtonText",
"loginPageMessage",
"isInitialized",
"isOnboarded",
"externalDomain"
],
"type": "object"

View file

@ -86,6 +86,7 @@ export class ServerConfigDto {
@ApiProperty({ type: 'integer' })
trashDays!: number;
isInitialized!: boolean;
isOnboarded!: boolean;
externalDomain!: string;
}

View file

@ -1,8 +1,10 @@
import { SystemMetadataKey } from '@app/infra/entities';
import {
newCommunicationRepositoryMock,
newServerInfoRepositoryMock,
newStorageRepositoryMock,
newSystemConfigRepositoryMock,
newSystemMetadataRepositoryMock,
newUserRepositoryMock,
} from '@test';
import { serverVersion } from '../domain.constant';
@ -11,6 +13,7 @@ import {
IServerInfoRepository,
IStorageRepository,
ISystemConfigRepository,
ISystemMetadataRepository,
IUserRepository,
} from '../repositories';
import { ServerInfoService } from './server-info.service';
@ -22,6 +25,7 @@ describe(ServerInfoService.name, () => {
let serverInfoMock: jest.Mocked<IServerInfoRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
let userMock: jest.Mocked<IUserRepository>;
let systemMetadataMock: jest.Mocked<ISystemMetadataRepository>;
beforeEach(() => {
configMock = newSystemConfigRepositoryMock();
@ -29,8 +33,16 @@ describe(ServerInfoService.name, () => {
serverInfoMock = newServerInfoRepositoryMock();
storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock();
systemMetadataMock = newSystemMetadataRepositoryMock();
sut = new ServerInfoService(communicationMock, configMock, userMock, serverInfoMock, storageMock);
sut = new ServerInfoService(
communicationMock,
configMock,
userMock,
serverInfoMock,
storageMock,
systemMetadataMock,
);
});
it('should work', () => {
@ -184,12 +196,21 @@ describe(ServerInfoService.name, () => {
loginPageMessage: '',
oauthButtonText: 'Login with OAuth',
trashDays: 30,
isInitialized: undefined,
isOnboarded: false,
externalDomain: '',
});
expect(configMock.load).toHaveBeenCalled();
});
});
describe('setAdminOnboarding', () => {
it('should set admin onboarding to true', async () => {
await sut.setAdminOnboarding();
expect(systemMetadataMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true });
});
});
describe('getStats', () => {
it('should total up usage by user', async () => {
userMock.getUserStats.mockResolvedValue([

View file

@ -1,3 +1,4 @@
import { SystemMetadataKey } from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger';
import { Inject, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
@ -9,6 +10,7 @@ import {
IServerInfoRepository,
IStorageRepository,
ISystemConfigRepository,
ISystemMetadataRepository,
IUserRepository,
UserStatsQueryResponse,
} from '../repositories';
@ -37,6 +39,7 @@ export class ServerInfoService {
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IServerInfoRepository) private repository: IServerInfoRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemMetadataRepository) private readonly systemMetadataRepository: ISystemMetadataRepository,
) {
this.configCore = SystemConfigCore.create(configRepository);
this.communicationRepository.on('connect', (userId) => this.handleConnect(userId));
@ -79,16 +82,22 @@ export class ServerInfoService {
async getConfig(): Promise<ServerConfigDto> {
const config = await this.configCore.getConfig();
const isInitialized = await this.userRepository.hasAdmin();
const onboarding = await this.systemMetadataRepository.get(SystemMetadataKey.ADMIN_ONBOARDING);
return {
loginPageMessage: config.server.loginPageMessage,
trashDays: config.trash.days,
oauthButtonText: config.oauth.buttonText,
isInitialized,
isOnboarded: onboarding?.isOnboarded || false,
externalDomain: config.server.externalDomain,
};
}
setAdminOnboarding(): Promise<void> {
return this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true });
}
async getStatistics(): Promise<ServerStatsResponseDto> {
const userStats: UserStatsQueryResponse[] = await this.userRepository.getUserStats();
const serverStats = new ServerStatsResponseDto();

View file

@ -9,7 +9,7 @@ import {
ServerThemeDto,
ServerVersionResponseDto,
} from '@app/domain';
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AdminRoute, Authenticated, PublicRoute } from '../app.guard';
import { UseValidation } from '../app.utils';
@ -67,4 +67,11 @@ export class ServerInfoController {
getSupportedMediaTypes(): ServerMediaTypesResponseDto {
return this.service.getSupportedMediaTypes();
}
@AdminRoute()
@Post('admin-onboarding')
@HttpCode(HttpStatus.NO_CONTENT)
setAdminOnboarding(): Promise<void> {
return this.service.setAdminOnboarding();
}
}

View file

@ -11,8 +11,10 @@ export class SystemMetadataEntity {
export enum SystemMetadataKey {
REVERSE_GEOCODING_STATE = 'reverse-geocoding-state',
ADMIN_ONBOARDING = 'admin-onboarding',
}
export interface SystemMetadata extends Record<SystemMetadataKey, { [key: string]: unknown }> {
[SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string };
[SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean };
}

View file

@ -5,6 +5,7 @@ import { assetApi } from './asset-api';
import { authApi } from './auth-api';
import { libraryApi } from './library-api';
import { partnerApi } from './partner-api';
import { serverInfoApi } from './server-info-api';
import { sharedLinkApi } from './shared-link-api';
import { userApi } from './user-api';
@ -14,6 +15,7 @@ export const api = {
apiKeyApi,
assetApi,
libraryApi,
serverInfoApi,
sharedLinkApi,
albumApi,
userApi,

View file

@ -0,0 +1,10 @@
import { ServerConfigDto } from '@app/domain';
import request from 'supertest';
export const serverInfoApi = {
getConfig: async (server: any) => {
const res = await request(server).get('/server-info/config');
expect(res.status).toBe(200);
return res.body as ServerConfigDto;
},
};

View file

@ -98,6 +98,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
trashDays: 30,
isInitialized: true,
externalDomain: '',
isOnboarded: false,
});
});
});
@ -167,4 +168,19 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
});
});
});
describe('POST /server-info/admin-onboarding', () => {
it('should set admin onboarding', async () => {
const config = await api.serverInfoApi.getConfig(server);
expect(config.isOnboarded).toBe(false);
const { status } = await request(server)
.post('/server-info/admin-onboarding')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
const newConfig = await api.serverInfoApi.getConfig(server);
expect(newConfig.isOnboarded).toBe(true);
});
});
});

View file

@ -19,6 +19,7 @@ export * from './smart-info.repository.mock';
export * from './storage.repository.mock';
export * from './system-config.repository.mock';
export * from './system-info.repository.mock';
export * from './system-metadata.repository.mock';
export * from './tag.repository.mock';
export * from './user-token.repository.mock';
export * from './user.repository.mock';

View file

@ -0,0 +1,8 @@
import { ISystemMetadataRepository } from '@app/domain';
export const newSystemMetadataRepositoryMock = (): jest.Mocked<ISystemMetadataRepository> => {
return {
get: jest.fn(),
set: jest.fn(),
};
};

View file

@ -3019,6 +3019,12 @@ export interface ServerConfigDto {
* @memberof ServerConfigDto
*/
'isInitialized': boolean;
/**
*
* @type {boolean}
* @memberof ServerConfigDto
*/
'isOnboarded': boolean;
/**
*
* @type {string}
@ -15142,6 +15148,44 @@ export const ServerInfoApiAxiosParamCreator = function (configuration?: Configur
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
setAdminOnboarding: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/server-info/admin-onboarding`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@ -15233,6 +15277,15 @@ export const ServerInfoApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.pingServer(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async setAdminOnboarding(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.setAdminOnboarding(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
};
@ -15307,6 +15360,14 @@ export const ServerInfoApiFactory = function (configuration?: Configuration, bas
pingServer(options?: AxiosRequestConfig): AxiosPromise<ServerPingResponse> {
return localVarFp.pingServer(options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
setAdminOnboarding(options?: AxiosRequestConfig): AxiosPromise<void> {
return localVarFp.setAdminOnboarding(options).then((request) => request(axios, basePath));
},
};
};
@ -15396,6 +15457,16 @@ export class ServerInfoApi extends BaseAPI {
public pingServer(options?: AxiosRequestConfig) {
return ServerInfoApiFp(this.configuration).pingServer(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ServerInfoApi
*/
public setAdminOnboarding(options?: AxiosRequestConfig) {
return ServerInfoApiFp(this.configuration).setAdminOnboarding(options).then((request) => request(this.axios, this.basePath));
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.7 KiB

View file

@ -16,7 +16,7 @@
<div class="flex place-items-center justify-between">
<div>
<div class="flex h-[26px] place-items-center gap-1">
<label class="immich-form-label text-sm" for={title}>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for={title}>
{title}
</label>
{#if isEdited}

View file

@ -16,15 +16,22 @@
import { user } from '$lib/stores/user.store';
import type { ResetOptions } from '$lib/utils/dipatch';
import SettingSwitch from '$lib/components/admin-page/settings/setting-switch.svelte';
import Button from '$lib/components/elements/buttons/button.svelte';
import { createEventDispatcher } from 'svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { mdiCheck } from '@mdi/js';
export let storageConfig: SystemConfigStorageTemplateDto;
export let disabled = false;
export let minified = false;
let savedConfig: SystemConfigStorageTemplateDto;
let defaultConfig: SystemConfigStorageTemplateDto;
let templateOptions: SystemConfigTemplateStorageOptionDto;
let selectedPreset = '';
const dispatch = createEventDispatcher<{ save: void }>();
const handleReset = (detail: ResetOptions) => {
if (detail.default) {
resetToDefault();
@ -115,10 +122,18 @@
storageConfig.template = result.data.storageTemplate.template;
savedConfig.template = result.data.storageTemplate.template;
storageConfig.enabled = result.data.storageTemplate.enabled;
savedConfig.enabled = result.data.storageTemplate.enabled;
storageConfig.hashVerificationEnabled = result.data.storageTemplate.hashVerificationEnabled;
savedConfig.hashVerificationEnabled = result.data.storageTemplate.hashVerificationEnabled;
notificationController.show({
message: 'Storage template saved',
type: NotificationType.Info,
});
dispatch('save');
} catch (e) {
console.error('Error [storage-template-settings] [saveSetting]', e);
notificationController.show({
@ -152,118 +167,138 @@
{disabled}
subtitle="Enable storage template engine"
bind:checked={storageConfig.enabled}
isEdited={!(storageConfig.enabled === savedConfig.enabled)}
/>
<SettingSwitch
title="HASH VERIFICATION ENABLED"
{disabled}
subtitle="Enables hash verification, don't disable this unless you're certain of the implications"
bind:checked={storageConfig.hashVerificationEnabled}
/>
{#if !minified}
<SettingSwitch
title="HASH VERIFICATION ENABLED"
{disabled}
subtitle="Enables hash verification, don't disable this unless you're certain of the implications"
bind:checked={storageConfig.hashVerificationEnabled}
isEdited={!(storageConfig.hashVerificationEnabled === savedConfig.hashVerificationEnabled)}
/>
{/if}
<hr />
{#if storageConfig.enabled}
<hr />
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">Variables</h3>
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">Variables</h3>
<section class="support-date">
{#await getSupportDateTimeFormat()}
<LoadingSpinner />
{:then options}
<div transition:fade={{ duration: 200 }}>
<SupportedDatetimePanel {options} />
</div>
{/await}
</section>
<section class="support-date">
<SupportedVariablesPanel />
</section>
<div class="flex flex-col mt-4">
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">Template</h3>
<div class="my-2 text-sm">
<h4>PREVIEW</h4>
</div>
<p class="text-sm">
Approximately path length limit : <span
class="font-semibold text-immich-primary dark:text-immich-dark-primary"
>{parsedTemplate().length + $user.id.length + 'UPLOAD_LOCATION'.length}</span
>/260
</p>
<p class="text-sm">
<code class="text-immich-primary dark:text-immich-dark-primary">{$user.storageLabel || $user.id}</code> is the
user's Storage Label
</p>
<p class="p-4 py-2 mt-2 text-xs bg-gray-200 rounded-lg dark:bg-gray-700 dark:text-immich-dark-fg">
<span class="text-immich-fg/25 dark:text-immich-dark-fg/50"
>UPLOAD_LOCATION/{$user.storageLabel || $user.id}</span
>/{parsedTemplate()}.jpg
</p>
<form autocomplete="off" class="flex flex-col" on:submit|preventDefault>
<div class="flex flex-col my-2">
<label class="text-sm" for="preset-select">PRESET</label>
<select
class="immich-form-input p-2 mt-2 text-sm rounded-lg bg-slate-200 hover:cursor-pointer dark:bg-gray-600"
disabled={disabled || !storageConfig.enabled}
name="presets"
id="preset-select"
bind:value={selectedPreset}
on:change={handlePresetSelection}
>
{#each templateOptions.presetOptions as preset}
<option value={preset}>{renderTemplate(preset)}</option>
{/each}
</select>
</div>
<div class="flex gap-2 align-bottom">
<SettingInputField
label="TEMPLATE"
disabled={disabled || !storageConfig.enabled}
required
inputType={SettingInputFieldType.TEXT}
bind:value={storageConfig.template}
isEdited={!(storageConfig.template === savedConfig.template)}
/>
<div class="flex-0">
<SettingInputField label="EXTENSION" inputType={SettingInputFieldType.TEXT} value={'.jpg'} disabled />
<section class="support-date">
{#await getSupportDateTimeFormat()}
<LoadingSpinner />
{:then options}
<div transition:fade={{ duration: 200 }}>
<SupportedDatetimePanel {options} />
</div>
{/await}
</section>
<section class="support-date">
<SupportedVariablesPanel />
</section>
<div class="flex flex-col mt-4">
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">Template</h3>
<div class="my-2 text-sm">
<h4>PREVIEW</h4>
</div>
<div id="migration-info" class="mt-2 text-sm">
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">Notes</h3>
<section class="flex flex-col gap-2">
<p>
Template changes will only apply to new assets. To retroactively apply the template to previously
uploaded assets, run the
<a href="/admin/jobs-status" class="text-immich-primary dark:text-immich-dark-primary"
>Storage Migration Job</a
>.
</p>
<p>
The template variable <span class="font-mono">{`{{album}}`}</span> will always be empty for new assets,
so manually running the
<p class="text-sm">
Approximately path length limit : <span
class="font-semibold text-immich-primary dark:text-immich-dark-primary"
>{parsedTemplate().length + $user.id.length + 'UPLOAD_LOCATION'.length}</span
>/260
</p>
<a href="/admin/jobs-status" class="text-immich-primary dark:text-immich-dark-primary"
>Storage Migration Job</a
>
is required in order to successfully use the variable.
</p>
</section>
</div>
<SettingButtonsRow
on:reset={({ detail }) => handleReset(detail)}
on:save={saveSetting}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
{disabled}
/>
</form>
</div>
<p class="text-sm">
<code class="text-immich-primary dark:text-immich-dark-primary">{$user.storageLabel || $user.id}</code> is the
user's Storage Label
</p>
<p class="p-4 py-2 mt-2 text-xs bg-gray-200 rounded-lg dark:bg-gray-700 dark:text-immich-dark-fg">
<span class="text-immich-fg/25 dark:text-immich-dark-fg/50"
>UPLOAD_LOCATION/{$user.storageLabel || $user.id}</span
>/{parsedTemplate()}.jpg
</p>
<form autocomplete="off" class="flex flex-col" on:submit|preventDefault>
<div class="flex flex-col my-2">
<label class="text-sm" for="preset-select">PRESET</label>
<select
class="immich-form-input p-2 mt-2 text-sm rounded-lg bg-slate-200 hover:cursor-pointer dark:bg-gray-600"
disabled={disabled || !storageConfig.enabled}
name="presets"
id="preset-select"
bind:value={selectedPreset}
on:change={handlePresetSelection}
>
{#each templateOptions.presetOptions as preset}
<option value={preset}>{renderTemplate(preset)}</option>
{/each}
</select>
</div>
<div class="flex gap-2 align-bottom">
<SettingInputField
label="TEMPLATE"
disabled={disabled || !storageConfig.enabled}
required
inputType={SettingInputFieldType.TEXT}
bind:value={storageConfig.template}
isEdited={!(storageConfig.template === savedConfig.template)}
/>
<div class="flex-0">
<SettingInputField label="EXTENSION" inputType={SettingInputFieldType.TEXT} value={'.jpg'} disabled />
</div>
</div>
{#if !minified}
<div id="migration-info" class="mt-2 text-sm">
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">Notes</h3>
<section class="flex flex-col gap-2">
<p>
Template changes will only apply to new assets. To retroactively apply the template to previously
uploaded assets, run the
<a href="/admin/jobs-status" class="text-immich-primary dark:text-immich-dark-primary"
>Storage Migration Job</a
>.
</p>
<p>
The template variable <span class="font-mono">{`{{album}}`}</span> will always be empty for new
assets, so manually running the
<a href="/admin/jobs-status" class="text-immich-primary dark:text-immich-dark-primary"
>Storage Migration Job</a
>
is required in order to successfully use the variable.
</p>
</section>
</div>
{/if}
</form>
</div>
{/if}
{#if minified}
<div class="flex w-full place-content-end">
<Button on:click={saveSetting}>
<span class="flex place-content-center place-items-center gap-2">
Done
<Icon path={mdiCheck} size="18" />
</span>
</Button>
</div>
{:else}
<SettingButtonsRow
on:reset={({ detail }) => handleReset(detail)}
on:save={saveSetting}
showResetToDefault={!isEqual(savedConfig, defaultConfig) && !minified}
{disabled}
/>
{/if}
</div>
{/await}
</section>

View file

@ -26,6 +26,8 @@
export let fullwidth = false;
export let border = false;
export let title: string | undefined = '';
let className = '';
export { className as class };
const colorClasses: Record<Color, string> = {
primary:
@ -36,7 +38,7 @@
'text-gray-500 dark:text-immich-dark-primary enabled:hover:bg-gray-100 enabled:dark:hover:bg-gray-700',
'light-red': 'bg-[#F9DEDC] text-[#410E0B] enabled:hover:bg-red-50',
red: 'bg-red-500 text-white enabled:hover:bg-red-400',
green: 'bg-lime-600 text-white enabled:hover:bg-lime-500',
green: 'bg-green-500 text-gray-800 enabled:hover:bg-green-400/90',
gray: 'bg-gray-500 dark:bg-gray-200 enabled:hover:bg-gray-500/75 enabled:dark:hover:bg-gray-200/80 text-white dark:text-immich-dark-gray',
'transparent-gray':
'dark:text-immich-dark-fg enabled:hover:bg-immich-primary/5 enabled:hover:text-gray-700 enabled:hover:dark:text-immich-dark-fg enabled:dark:hover:bg-immich-dark-primary/25',
@ -60,7 +62,7 @@
{disabled}
{title}
on:click
class="inline-flex items-center justify-center transition-colors disabled:cursor-not-allowed disabled:opacity-60 {colorClasses[
class="{className} inline-flex items-center justify-center transition-colors disabled:cursor-not-allowed disabled:opacity-60 {colorClasses[
color
]} {sizeClasses[size]}"
class:rounded-lg={rounded === 'lg'}

View file

@ -19,6 +19,7 @@
const dispatch = createEventDispatcher<{
success: void;
firstLogin: void;
onboarding: void;
}>();
onMount(async () => {
@ -57,14 +58,21 @@
errorMessage = '';
loading = true;
const { data } = await api.authenticationApi.login({
const { data: user } = await api.authenticationApi.login({
loginCredentialDto: {
email,
password,
},
});
if (!data.isAdmin && data.shouldChangePassword) {
const { data: serverConfig } = await api.serverInfoApi.getServerConfig();
if (user.isAdmin && !serverConfig.isOnboarded) {
dispatch('onboarding');
return;
}
if (!user.isAdmin && user.shouldChangePassword) {
dispatch('firstLogin');
return;
}

View file

@ -0,0 +1,11 @@
<script lang="ts">
import { fade } from 'svelte/transition';
</script>
<div
id="onboarding-card"
class="flex w-full max-w-4xl flex-col gap-4 rounded-3xl border-2 border-gray-500 px-8 py-14 dark:border-immich-dark-gray dark:bg-immich-dark-gray text-black dark:text-immich-dark-fg bg-gray-50"
in:fade={{ duration: 250 }}
>
<slot />
</div>

View file

@ -0,0 +1,28 @@
<script lang="ts">
import OnboardingCard from './onboarding-card.svelte';
import Button from '$lib/components/elements/buttons/button.svelte';
import { mdiArrowRight } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
import { createEventDispatcher } from 'svelte';
import ImmichLogo from '../shared-components/immich-logo.svelte';
import { user } from '$lib/stores/user.store';
const dispatch = createEventDispatcher<{
done: void;
}>();
</script>
<OnboardingCard>
<ImmichLogo class="w-12 h-12" />
<p class="font-medium text-6xl my-6 text-immich-primary dark:text-immich-dark-primary">
Welcome, {$user.name}
</p>
<p class="text-3xl pb-6 font-light">Let's get your instance set up with some common settings.</p>
<div class="w-full flex place-content-end">
<Button class="flex gap-2 place-content-center" on:click={() => dispatch('done')}>
<p>Theme</p>
<Icon path={mdiArrowRight} size="18" />
</Button>
</div>
</OnboardingCard>

View file

@ -0,0 +1,37 @@
<script lang="ts">
import OnboardingCard from './onboarding-card.svelte';
import { createEventDispatcher, onMount } from 'svelte';
import { featureFlags } from '$lib/stores/server-config.store';
import StorageTemplateSettings from '../admin-page/settings/storage-template/storage-template-settings.svelte';
import { SystemConfigDto, api } from '@api';
import { user } from '$lib/stores/user.store';
const dispatch = createEventDispatcher<{
done: void;
}>();
let configs: SystemConfigDto | null = null;
onMount(async () => {
const { data } = await api.systemConfigApi.getConfig();
configs = data;
});
</script>
<OnboardingCard>
<p class="text-xl text-immich-primary dark:text-immich-dark-primary">STORAGE TEMPLATE</p>
<p>
The storage template is used to determine the folder structure and file name of your media files. You can use
variables to customize the template to your liking.
</p>
{#if configs && $user}
<StorageTemplateSettings
minified
disabled={$featureFlags.configFile}
storageConfig={configs.storageTemplate}
on:save={() => dispatch('done')}
/>
{/if}
</OnboardingCard>

View file

@ -0,0 +1,60 @@
<script lang="ts">
import { mdiArrowRight, mdiWhiteBalanceSunny, mdiMoonWaningCrescent } from '@mdi/js';
import Button from '../elements/buttons/button.svelte';
import Icon from '../elements/icon.svelte';
import OnboardingCard from './onboarding-card.svelte';
import { createEventDispatcher } from 'svelte';
import { colorTheme } from '$lib/stores/preferences.store';
const dispatch = createEventDispatcher<{
done: void;
}>();
const toggleLightTheme = () => {
$colorTheme = 'light';
};
const toggleDarkTheme = () => {
$colorTheme = 'dark';
};
</script>
<OnboardingCard>
<p class="text-xl text-immich-primary dark:text-immich-dark-primary">COLOR THEME</p>
<div>
<p class="pb-6 font-light">Choose a color theme for your instance. You can change this later in your settings.</p>
</div>
<div class="flex gap-4 mb-6">
<button
class="w-1/2 aspect-square bg-immich-bg rounded-3xl transition-all shadow-sm hover:shadow-xl"
on:click={toggleLightTheme}
>
<div
class="flex flex-col place-items-center place-content-center justify-around h-full w-full text-immich-primary"
>
<Icon path={mdiWhiteBalanceSunny} size="96" />
<p class="font-semibold text-4xl">LIGHT</p>
</div>
</button>
<button
class="w-1/2 aspect-square bg-immich-dark-bg rounded-3xl border border-transparent"
on:click={toggleDarkTheme}
>
<div
class="flex flex-col place-items-center place-content-center justify-around h-full w-full text-immich-dark-primary"
>
<Icon path={mdiMoonWaningCrescent} size="96" />
<p class="font-semibold text-4xl">DARK</p>
</div>
</button>
</div>
<div class="w-full flex place-content-end">
<Button class="flex gap-2 place-content-center" on:click={() => dispatch('done')}>
<p>Storage Template</p>
<Icon path={mdiArrowRight} size="18" />
</Button>
</div>
</OnboardingCard>

View file

@ -36,6 +36,7 @@ export enum AppRoute {
AUTH_LOGOUT = '/auth/logout',
AUTH_REGISTER = '/auth/register',
AUTH_CHANGE_PASSWORD = '/auth/change-password',
AUTH_ONBOARDING = '/auth/onboarding',
}
export enum ProjectionType {

View file

@ -26,6 +26,7 @@ export const serverConfig = writable<ServerConfig>({
loginPageMessage: '',
trashDays: 30,
isInitialized: false,
isOnboarded: false,
externalDomain: '',
});

View file

@ -18,6 +18,8 @@
import { api } from '@api';
import { closeWebsocketConnection, openWebsocketConnection } from '$lib/stores/websocket';
import { user } from '$lib/stores/user.store';
import { browser } from '$app/environment';
import { colorTheme } from '$lib/stores/preferences.store';
let showNavigationLoadingBar = false;
let albumId: string | undefined;
@ -25,6 +27,16 @@
const isSharedLinkRoute = (route: string | null) => route?.startsWith('/(user)/share/[key]');
const isAuthRoute = (route?: string) => route?.startsWith('/auth');
$: {
if (browser) {
if ($colorTheme === 'light') {
document.documentElement.classList.remove('dark');
} else {
document.documentElement.classList.add('dark');
}
}
}
if (isSharedLinkRoute($page.route?.id)) {
api.setKey($page.params.key);
}

View file

@ -19,6 +19,7 @@
<LoginForm
on:success={() => goto(AppRoute.PHOTOS, { invalidateAll: true })}
on:firstLogin={() => goto(AppRoute.AUTH_CHANGE_PASSWORD)}
on:onboarding={() => goto(AppRoute.AUTH_ONBOARDING)}
/>
</FullscreenContainer>
{/if}

View file

@ -0,0 +1,25 @@
<script lang="ts">
import OnboardingHello from '$lib/components/onboarding-page/onboarding-hello.svelte';
import OnboardingTheme from '$lib/components/onboarding-page/onboarding-theme.svelte';
import OnboadingStorageTemplate from '$lib/components/onboarding-page/onboarding-storage-template.svelte';
import { api } from '@api';
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
let index = 0;
let onboardingSteps = [OnboardingHello, OnboardingTheme, OnboadingStorageTemplate];
const handleDoneClicked = async () => {
index++;
if (index >= onboardingSteps.length) {
await api.serverInfoApi.setAdminOnboarding();
goto(AppRoute.PHOTOS);
}
};
</script>
<section id="onboarding-page" class="min-w-screen flex min-h-screen place-content-center place-items-center p-4">
<svelte:component this={onboardingSteps[index]} on:done={handleDoneClicked} />
</section>

View file

@ -0,0 +1,13 @@
import type { PageLoad } from './$types';
import { authenticate } from '$lib/utils/auth';
import { loadConfig } from '$lib/stores/server-config.store';
export const load = (async () => {
await authenticate({ admin: true });
await loadConfig();
return {
meta: {
title: 'Onboarding',
},
};
}) satisfies PageLoad;