chore(web): prettier (#2821)

Co-authored-by: Thomas Way <thomas@6f.io>
This commit is contained in:
Jason Rasmussen 2023-07-01 00:50:47 -04:00 committed by GitHub
parent 7c2f7d6c51
commit f55b3add80
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
242 changed files with 12794 additions and 13426 deletions

View file

@ -1,43 +1,39 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended'
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2017: true,
node: true
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
}
],
globals: {
NodeJS: true
},
rules: {
'@typescript-eslint/no-unused-vars': [
'warn',
{
// Allow underscore (_) variables
argsIgnorePattern: '^_$',
varsIgnorePattern: '^_$'
}
]
}
root: true,
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:svelte/recommended'],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte'],
},
env: {
browser: true,
es2017: true,
node: true,
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
},
},
],
globals: {
NodeJS: true,
},
rules: {
'@typescript-eslint/no-unused-vars': [
'warn',
{
// Allow underscore (_) variables
argsIgnorePattern: '^_$',
varsIgnorePattern: '^_$',
},
],
},
};

View file

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

View file

@ -1,3 +1,3 @@
module.exports = {
browser: false
browser: false,
};

View file

@ -1,3 +1,3 @@
module.exports = {
env: {}
env: {},
};

View file

@ -1,3 +1,3 @@
module.exports = {
presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript']
presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'],
};

View file

@ -4,200 +4,199 @@
*/
export default {
// All imported modules in your tests should be mocked automatically
// automock: false,
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// Stop running tests after `n` failures
// bail: 0,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/private/var/folders/6n/31wm28711gzbt3gzsxhzxx500000gn/T/jest_dx",
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/private/var/folders/6n/31wm28711gzbt3gzsxhzxx500000gn/T/jest_dx",
// Automatically clear mock calls, instances, contexts and results before every test
clearMocks: true,
// Automatically clear mock calls, instances, contexts and results before every test
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
// collectCoverage: false,
// Indicates whether the coverage information should be collected while executing the test
// collectCoverage: false,
// An array of glob patterns indicating a set of files for which coverage information should be collected
collectCoverageFrom: ['src/**/*.*', '!src/api/open-api/**'],
// An array of glob patterns indicating a set of files for which coverage information should be collected
collectCoverageFrom: ['src/**/*.*', '!src/api/open-api/**'],
// The directory where Jest should output its coverage files
// coverageDirectory: undefined,
coverageThreshold: {
global: {
lines: 4,
statements: 4
}
},
// The directory where Jest should output its coverage files
// coverageDirectory: undefined,
coverageThreshold: {
global: {
lines: 4,
statements: 4,
},
},
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// "/node_modules/"
// ],
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// "/node_modules/"
// ],
// Indicates which provider should be used to instrument code for coverage
coverageProvider: 'v8',
// Indicates which provider should be used to instrument code for coverage
coverageProvider: 'v8',
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,
// A path to a custom dependency extractor
// dependencyExtractor: undefined,
// A path to a custom dependency extractor
// dependencyExtractor: undefined,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// The default configuration for fake timers
// fakeTimers: {
// "enableGlobally": false
// },
// The default configuration for fake timers
// fakeTimers: {
// "enableGlobally": false
// },
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: undefined,
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: undefined,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: undefined,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: undefined,
// A set of global variables that need to be available in all test environments
// globals: {},
// A set of global variables that need to be available in all test environments
// globals: {},
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
// maxWorkers: "50%",
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
// maxWorkers: "50%",
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
moduleFileExtensions: ['svelte', 'js', 'ts'],
// An array of file extensions your modules use
moduleFileExtensions: ['svelte', 'js', 'ts'],
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
moduleNameMapper: {
'\\.(jpg|ico|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
'identity-obj-proxy',
'^\\$lib(.*)$': '<rootDir>/src/lib$1',
'^\\@api(.*)$': '<rootDir>/src/api$1',
'^\\@test-data(.*)$': '<rootDir>/src/test-data$1'
},
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
moduleNameMapper: {
'\\.(jpg|ico|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 'identity-obj-proxy',
'^\\$lib(.*)$': '<rootDir>/src/lib$1',
'^\\@api(.*)$': '<rootDir>/src/api$1',
'^\\@test-data(.*)$': '<rootDir>/src/test-data$1',
},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// notify: false,
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
// preset: undefined,
// A preset that is used as a base for Jest's configuration
// preset: undefined,
// Run tests from one or more projects
// projects: undefined,
// Run tests from one or more projects
// projects: undefined,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state before every test
// resetMocks: false,
// Automatically reset mock state before every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: undefined,
// A path to a custom resolver
// resolver: undefined,
// Automatically restore mock state and implementation before every test
// restoreMocks: false,
// Automatically restore mock state and implementation before every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
// rootDir: undefined,
// The root directory that Jest should scan for tests and modules within
// rootDir: undefined,
// A list of paths to directories that Jest should use to search for files in
// roots: [
// "<rootDir>"
// ],
// A list of paths to directories that Jest should use to search for files in
// roots: [
// "<rootDir>"
// ],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
// The number of seconds after which a test is considered as slow and reported as such in the results.
// slowTestThreshold: 5,
// The number of seconds after which a test is considered as slow and reported as such in the results.
// slowTestThreshold: 5,
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
testEnvironment: 'jsdom',
// The test environment that will be used for testing
testEnvironment: 'jsdom',
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
// testMatch: [
// "**/__tests__/**/*.[jt]s?(x)",
// "**/?(*.)+(spec|test).[tj]s?(x)"
// ],
// The glob patterns Jest uses to detect test files
// testMatch: [
// "**/__tests__/**/*.[jt]s?(x)",
// "**/?(*.)+(spec|test).[tj]s?(x)"
// ],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// "/node_modules/"
// ],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// "/node_modules/"
// ],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,
// This option allows use of a custom test runner
// testRunner: "jest-circus/runner",
// This option allows use of a custom test runner
// testRunner: "jest-circus/runner",
// A map from regular expressions to paths to transformers
transform: {
'\\.[jt]sx?$': 'babel-jest',
'^.+\\.svelte$': [
'svelte-jester',
{
preprocess: true
}
]
},
// A map from regular expressions to paths to transformers
transform: {
'\\.[jt]sx?$': 'babel-jest',
'^.+\\.svelte$': [
'svelte-jester',
{
preprocess: true,
},
],
},
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
transformIgnorePatterns: ['/node_modules/(?!svelte-material-icons).*/', '\\.pnp\\.[^\\/]+$']
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
transformIgnorePatterns: ['/node_modules/(?!svelte-material-icons).*/', '\\.pnp\\.[^\\/]+$'],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: undefined,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
// Whether to use watchman for file crawling
// watchman: true,
};

View file

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

View file

@ -1,129 +1,125 @@
import {
AlbumApi,
APIKeyApi,
AssetApi,
AssetApiFp,
AuthenticationApi,
Configuration,
ConfigurationParameters,
JobApi,
JobName,
OAuthApi,
PartnerApi,
PersonApi,
SearchApi,
ServerInfoApi,
SharedLinkApi,
SystemConfigApi,
UserApi,
UserApiFp
AlbumApi,
APIKeyApi,
AssetApi,
AssetApiFp,
AuthenticationApi,
Configuration,
ConfigurationParameters,
JobApi,
JobName,
OAuthApi,
PartnerApi,
PersonApi,
SearchApi,
ServerInfoApi,
SharedLinkApi,
SystemConfigApi,
UserApi,
UserApiFp,
} from './open-api';
import { BASE_PATH } from './open-api/base';
import { DUMMY_BASE_URL, toPathString } from './open-api/common';
import type { ApiParams } from './types';
export class ImmichApi {
public albumApi: AlbumApi;
public assetApi: AssetApi;
public authenticationApi: AuthenticationApi;
public jobApi: JobApi;
public keyApi: APIKeyApi;
public oauthApi: OAuthApi;
public partnerApi: PartnerApi;
public searchApi: SearchApi;
public serverInfoApi: ServerInfoApi;
public sharedLinkApi: SharedLinkApi;
public personApi: PersonApi;
public systemConfigApi: SystemConfigApi;
public userApi: UserApi;
public albumApi: AlbumApi;
public assetApi: AssetApi;
public authenticationApi: AuthenticationApi;
public jobApi: JobApi;
public keyApi: APIKeyApi;
public oauthApi: OAuthApi;
public partnerApi: PartnerApi;
public searchApi: SearchApi;
public serverInfoApi: ServerInfoApi;
public sharedLinkApi: SharedLinkApi;
public personApi: PersonApi;
public systemConfigApi: SystemConfigApi;
public userApi: UserApi;
private config: Configuration;
private config: Configuration;
constructor(params: ConfigurationParameters) {
this.config = new Configuration(params);
constructor(params: ConfigurationParameters) {
this.config = new Configuration(params);
this.albumApi = new AlbumApi(this.config);
this.assetApi = new AssetApi(this.config);
this.authenticationApi = new AuthenticationApi(this.config);
this.jobApi = new JobApi(this.config);
this.keyApi = new APIKeyApi(this.config);
this.oauthApi = new OAuthApi(this.config);
this.partnerApi = new PartnerApi(this.config);
this.searchApi = new SearchApi(this.config);
this.serverInfoApi = new ServerInfoApi(this.config);
this.sharedLinkApi = new SharedLinkApi(this.config);
this.personApi = new PersonApi(this.config);
this.systemConfigApi = new SystemConfigApi(this.config);
this.userApi = new UserApi(this.config);
}
this.albumApi = new AlbumApi(this.config);
this.assetApi = new AssetApi(this.config);
this.authenticationApi = new AuthenticationApi(this.config);
this.jobApi = new JobApi(this.config);
this.keyApi = new APIKeyApi(this.config);
this.oauthApi = new OAuthApi(this.config);
this.partnerApi = new PartnerApi(this.config);
this.searchApi = new SearchApi(this.config);
this.serverInfoApi = new ServerInfoApi(this.config);
this.sharedLinkApi = new SharedLinkApi(this.config);
this.personApi = new PersonApi(this.config);
this.systemConfigApi = new SystemConfigApi(this.config);
this.userApi = new UserApi(this.config);
}
private createUrl(path: string, params?: Record<string, unknown>) {
const searchParams = new URLSearchParams();
for (const key in params) {
const value = params[key];
if (value !== undefined && value !== null) {
searchParams.set(key, value.toString());
}
}
private createUrl(path: string, params?: Record<string, unknown>) {
const searchParams = new URLSearchParams();
for (const key in params) {
const value = params[key];
if (value !== undefined && value !== null) {
searchParams.set(key, value.toString());
}
}
const url = new URL(path, DUMMY_BASE_URL);
url.search = searchParams.toString();
const url = new URL(path, DUMMY_BASE_URL);
url.search = searchParams.toString();
return (this.config.basePath || BASE_PATH) + toPathString(url);
}
return (this.config.basePath || BASE_PATH) + toPathString(url);
}
public setAccessToken(accessToken: string) {
this.config.accessToken = accessToken;
}
public setAccessToken(accessToken: string) {
this.config.accessToken = accessToken;
}
public removeAccessToken() {
this.config.accessToken = undefined;
}
public removeAccessToken() {
this.config.accessToken = undefined;
}
public setBaseUrl(baseUrl: string) {
this.config.basePath = baseUrl;
}
public setBaseUrl(baseUrl: string) {
this.config.basePath = baseUrl;
}
public getAssetFileUrl(
...[assetId, isThumb, isWeb, key]: ApiParams<typeof AssetApiFp, 'serveFile'>
) {
const path = `/asset/file/${assetId}`;
return this.createUrl(path, { isThumb, isWeb, key });
}
public getAssetFileUrl(...[assetId, isThumb, isWeb, key]: ApiParams<typeof AssetApiFp, 'serveFile'>) {
const path = `/asset/file/${assetId}`;
return this.createUrl(path, { isThumb, isWeb, key });
}
public getAssetThumbnailUrl(
...[assetId, format, key]: ApiParams<typeof AssetApiFp, 'getAssetThumbnail'>
) {
const path = `/asset/thumbnail/${assetId}`;
return this.createUrl(path, { format, key });
}
public getAssetThumbnailUrl(...[assetId, format, key]: ApiParams<typeof AssetApiFp, 'getAssetThumbnail'>) {
const path = `/asset/thumbnail/${assetId}`;
return this.createUrl(path, { format, key });
}
public getProfileImageUrl(...[userId]: ApiParams<typeof UserApiFp, 'getProfileImage'>) {
const path = `/user/profile-image/${userId}`;
return this.createUrl(path);
}
public getProfileImageUrl(...[userId]: ApiParams<typeof UserApiFp, 'getProfileImage'>) {
const path = `/user/profile-image/${userId}`;
return this.createUrl(path);
}
public getPeopleThumbnailUrl(personId: string) {
const path = `/person/${personId}/thumbnail`;
return this.createUrl(path);
}
public getPeopleThumbnailUrl(personId: string) {
const path = `/person/${personId}/thumbnail`;
return this.createUrl(path);
}
public getJobName(jobName: JobName) {
const names: Record<JobName, string> = {
[JobName.ThumbnailGeneration]: 'Generate Thumbnails',
[JobName.MetadataExtraction]: 'Extract Metadata',
[JobName.Sidecar]: 'Sidecar Metadata',
[JobName.ObjectTagging]: 'Tag Objects',
[JobName.ClipEncoding]: 'Encode Clip',
[JobName.RecognizeFaces]: 'Recognize Faces',
[JobName.VideoConversion]: 'Transcode Videos',
[JobName.StorageTemplateMigration]: 'Storage Template Migration',
[JobName.BackgroundTask]: 'Background Tasks',
[JobName.Search]: 'Search'
};
public getJobName(jobName: JobName) {
const names: Record<JobName, string> = {
[JobName.ThumbnailGeneration]: 'Generate Thumbnails',
[JobName.MetadataExtraction]: 'Extract Metadata',
[JobName.Sidecar]: 'Sidecar Metadata',
[JobName.ObjectTagging]: 'Tag Objects',
[JobName.ClipEncoding]: 'Encode Clip',
[JobName.RecognizeFaces]: 'Recognize Faces',
[JobName.VideoConversion]: 'Transcode Videos',
[JobName.StorageTemplateMigration]: 'Storage Template Migration',
[JobName.BackgroundTask]: 'Background Tasks',
[JobName.Search]: 'Search',
};
return names[jobName];
}
return names[jobName];
}
}
export const api = new ImmichApi({ basePath: '/api' });

View file

@ -1,3 +1,3 @@
export * from './open-api';
export * from './api';
export * from './open-api';
export * from './utils';

View file

@ -3,10 +3,6 @@ import type { Configuration } from './open-api';
/* eslint-disable @typescript-eslint/no-explicit-any */
export type ApiFp = (configuration: Configuration) => Record<any, (...args: any) => any>;
export type OmitLast<T extends readonly unknown[]> = T extends readonly [...infer U, any?]
? U
: [...T];
export type OmitLast<T extends readonly unknown[]> = T extends readonly [...infer U, any?] ? U : [...T];
export type ApiParams<F extends ApiFp, K extends keyof ReturnType<F>> = OmitLast<
Parameters<ReturnType<F>[K]>
>;
export type ApiParams<F extends ApiFp, K extends keyof ReturnType<F>> = OmitLast<Parameters<ReturnType<F>[K]>>;

View file

@ -5,31 +5,31 @@ import type { UserResponseDto } from './open-api';
export type ApiError = AxiosError<{ message: string }>;
export const oauth = {
isCallback: (location: Location) => {
const search = location.search;
return search.includes('code=') || search.includes('error=');
},
isAutoLaunchDisabled: (location: Location) => {
const values = ['autoLaunch=0', 'password=1', 'password=true'];
for (const value of values) {
if (location.search.includes(value)) {
return true;
}
}
return false;
},
getConfig: (location: Location) => {
const redirectUri = location.href.split('?')[0];
console.log(`OAuth Redirect URI: ${redirectUri}`);
return api.oauthApi.generateConfig({ oAuthConfigDto: { redirectUri } });
},
login: (location: Location) => {
return api.oauthApi.callback({ oAuthCallbackDto: { url: location.href } });
},
link: (location: Location): AxiosPromise<UserResponseDto> => {
return api.oauthApi.link({ oAuthCallbackDto: { url: location.href } });
},
unlink: () => {
return api.oauthApi.unlink();
}
isCallback: (location: Location) => {
const search = location.search;
return search.includes('code=') || search.includes('error=');
},
isAutoLaunchDisabled: (location: Location) => {
const values = ['autoLaunch=0', 'password=1', 'password=true'];
for (const value of values) {
if (location.search.includes(value)) {
return true;
}
}
return false;
},
getConfig: (location: Location) => {
const redirectUri = location.href.split('?')[0];
console.log(`OAuth Redirect URI: ${redirectUri}`);
return api.oauthApi.generateConfig({ oAuthConfigDto: { redirectUri } });
},
login: (location: Location) => {
return api.oauthApi.callback({ oAuthCallbackDto: { url: location.href } });
},
link: (location: Location): AxiosPromise<UserResponseDto> => {
return api.oauthApi.link({ oAuthCallbackDto: { url: location.href } });
},
unlink: () => {
return api.oauthApi.unlink();
},
};

View file

@ -3,93 +3,93 @@
@tailwind utilities;
@font-face {
font-family: 'Work Sans';
src: url('$lib/assets/fonts/WorkSans-VariableFont_wght.ttf') format('truetype-variations');
font-weight: 1 999;
font-family: 'Work Sans';
src: url('$lib/assets/fonts/WorkSans-VariableFont_wght.ttf') format('truetype-variations');
font-weight: 1 999;
}
@font-face {
font-family: 'Snowburst One';
src: url('$lib/assets/fonts/SnowburstOne-Regular.ttf') format('truetype');
font-family: 'Snowburst One';
src: url('$lib/assets/fonts/SnowburstOne-Regular.ttf') format('truetype');
}
:root {
font-family: 'Work Sans', sans-serif;
font-family: 'Work Sans', sans-serif;
}
html {
height: 100%;
width: 100%;
height: 100%;
width: 100%;
}
html::-webkit-scrollbar {
width: 8px;
width: 8px;
}
/* Track */
html::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 16px;
background: #f1f1f1;
border-radius: 16px;
}
/* Handle */
html::-webkit-scrollbar-thumb {
background: rgba(85, 86, 87, 0.408);
border-radius: 16px;
background: rgba(85, 86, 87, 0.408);
border-radius: 16px;
}
/* Handle on hover */
html::-webkit-scrollbar-thumb:hover {
background: #4250afad;
border-radius: 16px;
background: #4250afad;
border-radius: 16px;
}
body {
margin: 0;
color: #5f6368;
margin: 0;
color: #5f6368;
}
input:focus-visible {
outline-offset: 0px !important;
outline: none !important;
outline-offset: 0px !important;
outline: none !important;
}
@layer utilities {
.immich-form-input {
@apply bg-slate-200 p-4 rounded-xl focus:border-immich-primary text-sm dark:bg-gray-600 dark:text-immich-dark-fg disabled:bg-gray-400 dark:disabled:bg-gray-800 disabled:cursor-not-allowed disabled:text-gray-200;
}
.immich-form-input {
@apply bg-slate-200 p-4 rounded-xl focus:border-immich-primary text-sm dark:bg-gray-600 dark:text-immich-dark-fg disabled:bg-gray-400 dark:disabled:bg-gray-800 disabled:cursor-not-allowed disabled:text-gray-200;
}
.immich-form-label {
@apply font-medium text-gray-500 dark:text-gray-300;
}
.immich-form-label {
@apply font-medium text-gray-500 dark:text-gray-300;
}
/* width */
.immich-scrollbar::-webkit-scrollbar {
width: 8px;
}
/* width */
.immich-scrollbar::-webkit-scrollbar {
width: 8px;
}
/* Track */
.immich-scrollbar::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 16px;
}
/* Track */
.immich-scrollbar::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 16px;
}
/* Handle */
.immich-scrollbar::-webkit-scrollbar-thumb {
background: rgba(85, 86, 87, 0.408);
border-radius: 16px;
}
/* Handle */
.immich-scrollbar::-webkit-scrollbar-thumb {
background: rgba(85, 86, 87, 0.408);
border-radius: 16px;
}
/* Handle on hover */
.immich-scrollbar::-webkit-scrollbar-thumb:hover {
background: #4250afad;
border-radius: 16px;
}
/* Handle on hover */
.immich-scrollbar::-webkit-scrollbar-thumb:hover {
background: #4250afad;
border-radius: 16px;
}
/* Hidden scrollbar */
/* width */
.scrollbar-hidden::-webkit-scrollbar {
display: none;
scrollbar-width: none;
}
/* Hidden scrollbar */
/* width */
.scrollbar-hidden::-webkit-scrollbar {
display: none;
scrollbar-width: none;
}
}

42
web/src/app.d.ts vendored
View file

@ -3,32 +3,32 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare namespace App {
interface Locals {
user?: import('@api').UserResponseDto;
api: import('@api').ImmichApi;
}
interface Locals {
user?: import('@api').UserResponseDto;
api: import('@api').ImmichApi;
}
interface PageData {
meta: {
title: string;
description?: string;
imageUrl?: string;
};
}
interface PageData {
meta: {
title: string;
description?: string;
imageUrl?: string;
};
}
interface Error {
message: string;
stack?: string;
code?: string | number;
}
interface Error {
message: string;
stack?: string;
code?: string | number;
}
}
// Source: https://stackoverflow.com/questions/63814432/typescript-typing-of-non-standard-window-event-in-svelte
// To fix the <svelte:window... in components/asset-viewer/photo-viewer.svelte
declare namespace svelteHTML {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface HTMLAttributes<T> {
'on:copyImage'?: () => void;
'on:zoomImage'?: () => void;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface HTMLAttributes<T> {
'on:copyImage'?: () => void;
'on:zoomImage'?: () => void;
}
}

View file

@ -1,21 +1,21 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
<script>
/**
* Prevent FOUC on page load.
*/
const theme = localStorage.getItem('color-theme') || 'dark';
if (theme === 'light') {
document.documentElement.classList.remove('dark');
}
</script>
</head>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
<script>
/**
* Prevent FOUC on page load.
*/
const theme = localStorage.getItem('color-theme') || 'dark';
if (theme === 'light') {
document.documentElement.classList.remove('dark');
}
</script>
</head>
<body class="bg-immich-bg dark:bg-immich-dark-bg">
<div>%sveltekit.body%</div>
</body>
<body class="bg-immich-bg dark:bg-immich-dark-bg">
<div>%sveltekit.body%</div>
</body>
</html>

View file

@ -4,54 +4,54 @@ import type { AxiosError, AxiosResponse } from 'axios';
import { ImmichApi } from './api/api';
export const handle = (async ({ event, resolve }) => {
const basePath = env.PUBLIC_IMMICH_SERVER_URL || 'http://immich-server:3001';
const accessToken = event.cookies.get('immich_access_token');
const api = new ImmichApi({ basePath, accessToken });
const basePath = env.PUBLIC_IMMICH_SERVER_URL || 'http://immich-server:3001';
const accessToken = event.cookies.get('immich_access_token');
const api = new ImmichApi({ basePath, accessToken });
// API instance that should be used for all server-side requests.
event.locals.api = api;
// API instance that should be used for all server-side requests.
event.locals.api = api;
if (accessToken) {
try {
const { data: user } = await api.userApi.getMyUserInfo();
event.locals.user = user;
} catch (err) {
const apiError = err as AxiosError;
if (accessToken) {
try {
const { data: user } = await api.userApi.getMyUserInfo();
event.locals.user = user;
} catch (err) {
const apiError = err as AxiosError;
// Ignore 401 unauthorized errors and log all others.
if (apiError.response?.status !== 401) {
console.error('[ERROR] hooks.server.ts [handle]:', err);
}
}
}
// Ignore 401 unauthorized errors and log all others.
if (apiError.response?.status !== 401) {
console.error('[ERROR] hooks.server.ts [handle]:', err);
}
}
}
const res = await resolve(event);
const res = await resolve(event);
// The link header can grow quite big and has caused issues with our nginx
// proxy returning a 502 Bad Gateway error. Therefore the header gets deleted.
res.headers.delete('Link');
// The link header can grow quite big and has caused issues with our nginx
// proxy returning a 502 Bad Gateway error. Therefore the header gets deleted.
res.headers.delete('Link');
return res;
return res;
}) satisfies Handle;
const DEFAULT_MESSAGE = 'Hmm, not sure about that. Check the logs or open a ticket?';
export const handleError: HandleServerError = async ({ error }) => {
const httpError = error as AxiosError;
const response = httpError?.response as AxiosResponse<{
message: string;
statusCode: number;
error: string;
}>;
const httpError = error as AxiosError;
const response = httpError?.response as AxiosResponse<{
message: string;
statusCode: number;
error: string;
}>;
let code = response?.data?.statusCode || response?.status || httpError.code || '500';
if (response) {
code += ` - ${response.data?.error || response.statusText}`;
}
let code = response?.data?.statusCode || response?.status || httpError.code || '500';
if (response) {
code += ` - ${response.data?.error || response.statusText}`;
}
return {
message: response?.data?.message || httpError?.message || DEFAULT_MESSAGE,
code,
stack: httpError?.stack
};
return {
message: response?.data?.message || httpError?.message || DEFAULT_MESSAGE,
code,
stack: httpError?.stack,
};
};

View file

@ -1,8 +1,8 @@
const createObjectURLMock = jest.fn();
Object.defineProperty(URL, 'createObjectURL', {
writable: true,
value: createObjectURLMock
writable: true,
value: createObjectURLMock,
});
export { createObjectURLMock };

View file

@ -1,36 +1,35 @@
<script lang="ts">
import { api, UserResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import { handleError } from '../../utils/handle-error';
import { api, UserResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import { handleError } from '../../utils/handle-error';
export let user: UserResponseDto;
export let user: UserResponseDto;
const dispatch = createEventDispatcher();
const dispatch = createEventDispatcher();
const deleteUser = async () => {
try {
const deletedUser = await api.userApi.deleteUser({ userId: user.id });
if (deletedUser.data.deletedAt != null) {
dispatch('user-delete-success');
} else {
dispatch('user-delete-fail');
}
} catch (error) {
handleError(error, 'Unable to delete user');
dispatch('user-delete-fail');
}
};
const deleteUser = async () => {
try {
const deletedUser = await api.userApi.deleteUser({ userId: user.id });
if (deletedUser.data.deletedAt != null) {
dispatch('user-delete-success');
} else {
dispatch('user-delete-fail');
}
} catch (error) {
handleError(error, 'Unable to delete user');
dispatch('user-delete-fail');
}
};
</script>
<ConfirmDialogue title="Delete User" confirmText="Delete" on:confirm={deleteUser} on:cancel>
<svelte:fragment slot="prompt">
<div class="flex flex-col gap-4">
<p>
<b>{user.firstName} {user.lastName}</b>'s account and assets will be permanently deleted
after 7 days.
</p>
<p>Are you sure you want to continue?</p>
</div>
</svelte:fragment>
<svelte:fragment slot="prompt">
<div class="flex flex-col gap-4">
<p>
<b>{user.firstName} {user.lastName}</b>'s account and assets will be permanently deleted after 7 days.
</p>
<p>Are you sure you want to continue?</p>
</div>
</svelte:fragment>
</ConfirmDialogue>

View file

@ -1,21 +1,21 @@
<script lang="ts" context="module">
export type Colors = 'light-gray' | 'gray';
export type Colors = 'light-gray' | 'gray';
</script>
<script lang="ts">
export let color: Colors;
export let color: Colors;
const colorClasses: Record<Colors, string> = {
'light-gray': 'bg-gray-300/90 dark:bg-gray-600/90',
gray: 'bg-gray-300 dark:bg-gray-600'
};
const colorClasses: Record<Colors, string> = {
'light-gray': 'bg-gray-300/90 dark:bg-gray-600/90',
gray: 'bg-gray-300 dark:bg-gray-600',
};
</script>
<button
class="h-full w-full py-2 flex gap-2 flex-col place-items-center place-content-center px-8 text-gray-600 transition-colors hover:bg-immich-primary hover:text-white dark:text-gray-200 dark:hover:bg-immich-dark-primary text-xs dark:hover:text-black {colorClasses[
color
]}"
on:click
class="h-full w-full py-2 flex gap-2 flex-col place-items-center place-content-center px-8 text-gray-600 transition-colors hover:bg-immich-primary hover:text-white dark:text-gray-200 dark:hover:bg-immich-dark-primary text-xs dark:hover:text-black {colorClasses[
color
]}"
on:click
>
<slot />
<slot />
</button>

View file

@ -1,16 +1,16 @@
<script lang="ts" context="module">
export type Color = 'success' | 'warning';
export type Color = 'success' | 'warning';
</script>
<script lang="ts">
export let color: Color;
export let color: Color;
const colorClasses: Record<Color, string> = {
success: 'bg-green-500/70 text-gray-900 dark:bg-green-700/90 dark:text-gray-100',
warning: 'bg-orange-400/70 text-gray-900 dark:bg-orange-900 dark:text-gray-100'
};
const colorClasses: Record<Color, string> = {
success: 'bg-green-500/70 text-gray-900 dark:bg-green-700/90 dark:text-gray-100',
warning: 'bg-orange-400/70 text-gray-900 dark:bg-orange-900 dark:text-gray-100',
};
</script>
<div class="w-full text-center text-sm p-2 {colorClasses[color]}">
<slot />
<slot />
</div>

View file

@ -1,149 +1,141 @@
<script lang="ts">
import type Icon from 'svelte-material-icons/AbTesting.svelte';
import SelectionSearch from 'svelte-material-icons/SelectionSearch.svelte';
import Play from 'svelte-material-icons/Play.svelte';
import Pause from 'svelte-material-icons/Pause.svelte';
import FastForward from 'svelte-material-icons/FastForward.svelte';
import AllInclusive from 'svelte-material-icons/AllInclusive.svelte';
import Close from 'svelte-material-icons/Close.svelte';
import { locale } from '$lib/stores/preferences.store';
import { createEventDispatcher } from 'svelte';
import { JobCommand, JobCommandDto, JobCountsDto, QueueStatusDto } from '@api';
import Badge from '$lib/components/elements/badge.svelte';
import JobTileButton from './job-tile-button.svelte';
import JobTileStatus from './job-tile-status.svelte';
import type Icon from 'svelte-material-icons/AbTesting.svelte';
import SelectionSearch from 'svelte-material-icons/SelectionSearch.svelte';
import Play from 'svelte-material-icons/Play.svelte';
import Pause from 'svelte-material-icons/Pause.svelte';
import FastForward from 'svelte-material-icons/FastForward.svelte';
import AllInclusive from 'svelte-material-icons/AllInclusive.svelte';
import Close from 'svelte-material-icons/Close.svelte';
import { locale } from '$lib/stores/preferences.store';
import { createEventDispatcher } from 'svelte';
import { JobCommand, JobCommandDto, JobCountsDto, QueueStatusDto } from '@api';
import Badge from '$lib/components/elements/badge.svelte';
import JobTileButton from './job-tile-button.svelte';
import JobTileStatus from './job-tile-status.svelte';
export let title: string;
export let subtitle: string | undefined = undefined;
export let jobCounts: JobCountsDto;
export let queueStatus: QueueStatusDto;
export let allowForceCommand = true;
export let icon: typeof Icon;
export let title: string;
export let subtitle: string | undefined = undefined;
export let jobCounts: JobCountsDto;
export let queueStatus: QueueStatusDto;
export let allowForceCommand = true;
export let icon: typeof Icon;
export let allText: string;
export let missingText: string;
export let allText: string;
export let missingText: string;
const slots = $$props.$$slots;
const slots = $$props.$$slots;
$: waitingCount = jobCounts.waiting + jobCounts.paused + jobCounts.delayed;
$: isIdle = !queueStatus.isActive && !queueStatus.isPaused;
$: waitingCount = jobCounts.waiting + jobCounts.paused + jobCounts.delayed;
$: isIdle = !queueStatus.isActive && !queueStatus.isPaused;
const commonClasses = 'flex place-items-center justify-between w-full py-2 sm:py-4 pr-4 pl-6';
const commonClasses = 'flex place-items-center justify-between w-full py-2 sm:py-4 pr-4 pl-6';
const dispatch = createEventDispatcher<{ command: JobCommandDto }>();
const dispatch = createEventDispatcher<{ command: JobCommandDto }>();
</script>
<div
class="flex sm:flex-row flex-col bg-gray-100 dark:bg-immich-dark-gray rounded-2xl sm:rounded-[35px] overflow-hidden"
class="flex sm:flex-row flex-col bg-gray-100 dark:bg-immich-dark-gray rounded-2xl sm:rounded-[35px] overflow-hidden"
>
<div class="flex flex-col w-full">
{#if queueStatus.isPaused}
<JobTileStatus color="warning">Paused</JobTileStatus>
{:else if queueStatus.isActive}
<JobTileStatus color="success">Active</JobTileStatus>
{/if}
<div class="flex flex-col gap-2 p-5 sm:p-7 md:p-9">
<div
class="flex items-center gap-4 text-xl font-semibold text-immich-primary dark:text-immich-dark-primary"
>
<span class="flex gap-2 items-center">
<svelte:component this={icon} size="1.25em" class="shrink-0 hidden sm:block" />
{title.toUpperCase()}
</span>
<div class="flex gap-2">
{#if jobCounts.failed > 0}
<Badge color="primary">
{jobCounts.failed.toLocaleString($locale)} failed
</Badge>
{/if}
{#if jobCounts.delayed > 0}
<Badge color="secondary">
{jobCounts.delayed.toLocaleString($locale)} delayed
</Badge>
{/if}
</div>
</div>
<div class="flex flex-col w-full">
{#if queueStatus.isPaused}
<JobTileStatus color="warning">Paused</JobTileStatus>
{:else if queueStatus.isActive}
<JobTileStatus color="success">Active</JobTileStatus>
{/if}
<div class="flex flex-col gap-2 p-5 sm:p-7 md:p-9">
<div class="flex items-center gap-4 text-xl font-semibold text-immich-primary dark:text-immich-dark-primary">
<span class="flex gap-2 items-center">
<svelte:component this={icon} size="1.25em" class="shrink-0 hidden sm:block" />
{title.toUpperCase()}
</span>
<div class="flex gap-2">
{#if jobCounts.failed > 0}
<Badge color="primary">
{jobCounts.failed.toLocaleString($locale)} failed
</Badge>
{/if}
{#if jobCounts.delayed > 0}
<Badge color="secondary">
{jobCounts.delayed.toLocaleString($locale)} delayed
</Badge>
{/if}
</div>
</div>
{#if subtitle}
<div class="text-sm dark:text-white whitespace-pre-line">{subtitle}</div>
{/if}
{#if subtitle}
<div class="text-sm dark:text-white whitespace-pre-line">{subtitle}</div>
{/if}
{#if slots?.description}
<div class="text-sm dark:text-white">
<slot name="description" />
</div>
{/if}
{#if slots?.description}
<div class="text-sm dark:text-white">
<slot name="description" />
</div>
{/if}
<div class="flex w-full max-w-md mt-2 flex-col sm:flex-row">
<div
class="{commonClasses} bg-immich-primary dark:bg-immich-dark-primary text-white dark:text-immich-dark-gray rounded-t-lg sm:rounded-l-lg sm:rounded-r-none"
>
<p>Active</p>
<p class="text-2xl">
{jobCounts.active.toLocaleString($locale)}
</p>
</div>
<div class="flex w-full max-w-md mt-2 flex-col sm:flex-row">
<div
class="{commonClasses} bg-immich-primary dark:bg-immich-dark-primary text-white dark:text-immich-dark-gray rounded-t-lg sm:rounded-l-lg sm:rounded-r-none"
>
<p>Active</p>
<p class="text-2xl">
{jobCounts.active.toLocaleString($locale)}
</p>
</div>
<div
class="{commonClasses} bg-gray-200 text-immich-dark-bg dark:bg-gray-700 dark:text-immich-gray rounded-b-lg sm:rounded-r-lg sm:rounded-l-none flex-row-reverse"
>
<p class="text-2xl">
{waitingCount.toLocaleString($locale)}
</p>
<p>Waiting</p>
</div>
</div>
</div>
</div>
<div class="flex sm:flex-col flex-row sm:w-32 w-full overflow-hidden">
{#if !isIdle}
{#if waitingCount > 0}
<JobTileButton
color="gray"
on:click={() => dispatch('command', { command: JobCommand.Empty, force: false })}
>
<Close size="24" /> CLEAR
</JobTileButton>
{/if}
{#if queueStatus.isPaused}
{@const size = waitingCount > 0 ? '24' : '48'}
<JobTileButton
color="light-gray"
on:click={() => dispatch('command', { command: JobCommand.Resume, force: false })}
>
<!-- size property is not reactive, so have to use width and height -->
<FastForward width={size} height={size} /> RESUME
</JobTileButton>
{:else}
<JobTileButton
color="light-gray"
on:click={() => dispatch('command', { command: JobCommand.Pause, force: false })}
>
<Pause size="24" /> PAUSE
</JobTileButton>
{/if}
{:else if allowForceCommand}
<JobTileButton
color="gray"
on:click={() => dispatch('command', { command: JobCommand.Start, force: true })}
>
<AllInclusive size="24" />
{allText}
</JobTileButton>
<JobTileButton
color="light-gray"
on:click={() => dispatch('command', { command: JobCommand.Start, force: false })}
>
<SelectionSearch size="24" />
{missingText}
</JobTileButton>
{:else}
<JobTileButton
color="light-gray"
on:click={() => dispatch('command', { command: JobCommand.Start, force: false })}
>
<Play size="48" /> START
</JobTileButton>
{/if}
</div>
<div
class="{commonClasses} bg-gray-200 text-immich-dark-bg dark:bg-gray-700 dark:text-immich-gray rounded-b-lg sm:rounded-r-lg sm:rounded-l-none flex-row-reverse"
>
<p class="text-2xl">
{waitingCount.toLocaleString($locale)}
</p>
<p>Waiting</p>
</div>
</div>
</div>
</div>
<div class="flex sm:flex-col flex-row sm:w-32 w-full overflow-hidden">
{#if !isIdle}
{#if waitingCount > 0}
<JobTileButton color="gray" on:click={() => dispatch('command', { command: JobCommand.Empty, force: false })}>
<Close size="24" /> CLEAR
</JobTileButton>
{/if}
{#if queueStatus.isPaused}
{@const size = waitingCount > 0 ? '24' : '48'}
<JobTileButton
color="light-gray"
on:click={() => dispatch('command', { command: JobCommand.Resume, force: false })}
>
<!-- size property is not reactive, so have to use width and height -->
<FastForward width={size} height={size} /> RESUME
</JobTileButton>
{:else}
<JobTileButton
color="light-gray"
on:click={() => dispatch('command', { command: JobCommand.Pause, force: false })}
>
<Pause size="24" /> PAUSE
</JobTileButton>
{/if}
{:else if allowForceCommand}
<JobTileButton color="gray" on:click={() => dispatch('command', { command: JobCommand.Start, force: true })}>
<AllInclusive size="24" />
{allText}
</JobTileButton>
<JobTileButton
color="light-gray"
on:click={() => dispatch('command', { command: JobCommand.Start, force: false })}
>
<SelectionSearch size="24" />
{missingText}
</JobTileButton>
{:else}
<JobTileButton
color="light-gray"
on:click={() => dispatch('command', { command: JobCommand.Start, force: false })}
>
<Play size="48" /> START
</JobTileButton>
{/if}
</div>
</div>

View file

@ -1,160 +1,159 @@
<script lang="ts">
import {
notificationController,
NotificationType
} from '$lib/components/shared-components/notification/notification';
import { AppRoute } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { AllJobStatusResponseDto, api, JobCommand, JobCommandDto, JobName } from '@api';
import type { ComponentType } from 'svelte';
import type Icon from 'svelte-material-icons/DotsVertical.svelte';
import FaceRecognition from 'svelte-material-icons/FaceRecognition.svelte';
import FileJpgBox from 'svelte-material-icons/FileJpgBox.svelte';
import FileXmlBox from 'svelte-material-icons/FileXmlBox.svelte';
import FolderMove from 'svelte-material-icons/FolderMove.svelte';
import CogIcon from 'svelte-material-icons/Cog.svelte';
import Table from 'svelte-material-icons/Table.svelte';
import TagMultiple from 'svelte-material-icons/TagMultiple.svelte';
import VectorCircle from 'svelte-material-icons/VectorCircle.svelte';
import Video from 'svelte-material-icons/Video.svelte';
import ConfirmDialogue from '../../shared-components/confirm-dialogue.svelte';
import JobTile from './job-tile.svelte';
import StorageMigrationDescription from './storage-migration-description.svelte';
import Button from '../../elements/buttons/button.svelte';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { AppRoute } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { AllJobStatusResponseDto, api, JobCommand, JobCommandDto, JobName } from '@api';
import type { ComponentType } from 'svelte';
import type Icon from 'svelte-material-icons/DotsVertical.svelte';
import FaceRecognition from 'svelte-material-icons/FaceRecognition.svelte';
import FileJpgBox from 'svelte-material-icons/FileJpgBox.svelte';
import FileXmlBox from 'svelte-material-icons/FileXmlBox.svelte';
import FolderMove from 'svelte-material-icons/FolderMove.svelte';
import CogIcon from 'svelte-material-icons/Cog.svelte';
import Table from 'svelte-material-icons/Table.svelte';
import TagMultiple from 'svelte-material-icons/TagMultiple.svelte';
import VectorCircle from 'svelte-material-icons/VectorCircle.svelte';
import Video from 'svelte-material-icons/Video.svelte';
import ConfirmDialogue from '../../shared-components/confirm-dialogue.svelte';
import JobTile from './job-tile.svelte';
import StorageMigrationDescription from './storage-migration-description.svelte';
import Button from '../../elements/buttons/button.svelte';
export let jobs: AllJobStatusResponseDto;
export let jobs: AllJobStatusResponseDto;
interface JobDetails {
title: string;
subtitle?: string;
allText?: string;
missingText?: string;
icon: typeof Icon;
allowForceCommand?: boolean;
component?: ComponentType;
handleCommand?: (jobId: JobName, jobCommand: JobCommandDto) => Promise<void>;
}
interface JobDetails {
title: string;
subtitle?: string;
allText?: string;
missingText?: string;
icon: typeof Icon;
allowForceCommand?: boolean;
component?: ComponentType;
handleCommand?: (jobId: JobName, jobCommand: JobCommandDto) => Promise<void>;
}
let faceConfirm = false;
let faceConfirm = false;
const handleFaceCommand = async (jobId: JobName, dto: JobCommandDto) => {
if (dto.force) {
faceConfirm = true;
return;
}
const handleFaceCommand = async (jobId: JobName, dto: JobCommandDto) => {
if (dto.force) {
faceConfirm = true;
return;
}
await handleCommand(jobId, dto);
};
await handleCommand(jobId, dto);
};
const onFaceConfirm = () => {
faceConfirm = false;
handleCommand(JobName.RecognizeFaces, { command: JobCommand.Start, force: true });
};
const onFaceConfirm = () => {
faceConfirm = false;
handleCommand(JobName.RecognizeFaces, { command: JobCommand.Start, force: true });
};
const jobDetails: Partial<Record<JobName, JobDetails>> = {
[JobName.ThumbnailGeneration]: {
icon: FileJpgBox,
title: api.getJobName(JobName.ThumbnailGeneration),
subtitle: 'Regenerate JPEG and WebP thumbnails'
},
[JobName.MetadataExtraction]: {
icon: Table,
title: api.getJobName(JobName.MetadataExtraction),
subtitle: 'Extract metadata information i.e. GPS, resolution...etc'
},
[JobName.Sidecar]: {
title: api.getJobName(JobName.Sidecar),
icon: FileXmlBox,
subtitle: 'Discover or synchronize sidecar metadata from the filesystem',
allText: 'SYNC',
missingText: 'DISCOVER'
},
[JobName.ObjectTagging]: {
icon: TagMultiple,
title: api.getJobName(JobName.ObjectTagging),
subtitle:
'Run machine learning to tag objects\nNote that some assets may not have any objects detected'
},
[JobName.ClipEncoding]: {
icon: VectorCircle,
title: api.getJobName(JobName.ClipEncoding),
subtitle: 'Run machine learning to generate clip embeddings'
},
[JobName.RecognizeFaces]: {
icon: FaceRecognition,
title: api.getJobName(JobName.RecognizeFaces),
subtitle: 'Run machine learning to recognize faces',
handleCommand: handleFaceCommand
},
[JobName.VideoConversion]: {
icon: Video,
title: api.getJobName(JobName.VideoConversion),
subtitle: 'Transcode videos not in the desired format'
},
[JobName.StorageTemplateMigration]: {
icon: FolderMove,
title: api.getJobName(JobName.StorageTemplateMigration),
allowForceCommand: false,
component: StorageMigrationDescription
}
};
const jobDetails: Partial<Record<JobName, JobDetails>> = {
[JobName.ThumbnailGeneration]: {
icon: FileJpgBox,
title: api.getJobName(JobName.ThumbnailGeneration),
subtitle: 'Regenerate JPEG and WebP thumbnails',
},
[JobName.MetadataExtraction]: {
icon: Table,
title: api.getJobName(JobName.MetadataExtraction),
subtitle: 'Extract metadata information i.e. GPS, resolution...etc',
},
[JobName.Sidecar]: {
title: api.getJobName(JobName.Sidecar),
icon: FileXmlBox,
subtitle: 'Discover or synchronize sidecar metadata from the filesystem',
allText: 'SYNC',
missingText: 'DISCOVER',
},
[JobName.ObjectTagging]: {
icon: TagMultiple,
title: api.getJobName(JobName.ObjectTagging),
subtitle: 'Run machine learning to tag objects\nNote that some assets may not have any objects detected',
},
[JobName.ClipEncoding]: {
icon: VectorCircle,
title: api.getJobName(JobName.ClipEncoding),
subtitle: 'Run machine learning to generate clip embeddings',
},
[JobName.RecognizeFaces]: {
icon: FaceRecognition,
title: api.getJobName(JobName.RecognizeFaces),
subtitle: 'Run machine learning to recognize faces',
handleCommand: handleFaceCommand,
},
[JobName.VideoConversion]: {
icon: Video,
title: api.getJobName(JobName.VideoConversion),
subtitle: 'Transcode videos not in the desired format',
},
[JobName.StorageTemplateMigration]: {
icon: FolderMove,
title: api.getJobName(JobName.StorageTemplateMigration),
allowForceCommand: false,
component: StorageMigrationDescription,
},
};
const jobDetailsArray = Object.entries(jobDetails) as [JobName, JobDetails][];
const jobDetailsArray = Object.entries(jobDetails) as [JobName, JobDetails][];
async function handleCommand(jobId: JobName, jobCommand: JobCommandDto) {
const title = jobDetails[jobId]?.title;
async function handleCommand(jobId: JobName, jobCommand: JobCommandDto) {
const title = jobDetails[jobId]?.title;
try {
const { data } = await api.jobApi.sendJobCommand({ id: jobId, jobCommandDto: jobCommand });
jobs[jobId] = data;
try {
const { data } = await api.jobApi.sendJobCommand({ id: jobId, jobCommandDto: jobCommand });
jobs[jobId] = data;
switch (jobCommand.command) {
case JobCommand.Empty:
notificationController.show({
message: `Cleared jobs for: ${title}`,
type: NotificationType.Info
});
break;
}
} catch (error) {
handleError(error, `Command '${jobCommand.command}' failed for job: ${title}`);
}
}
switch (jobCommand.command) {
case JobCommand.Empty:
notificationController.show({
message: `Cleared jobs for: ${title}`,
type: NotificationType.Info,
});
break;
}
} catch (error) {
handleError(error, `Command '${jobCommand.command}' failed for job: ${title}`);
}
}
</script>
{#if faceConfirm}
<ConfirmDialogue
prompt="Are you sure you want to reprocess all faces? This will also clear named people."
on:confirm={onFaceConfirm}
on:cancel={() => (faceConfirm = false)}
/>
<ConfirmDialogue
prompt="Are you sure you want to reprocess all faces? This will also clear named people."
on:confirm={onFaceConfirm}
on:cancel={() => (faceConfirm = false)}
/>
{/if}
<div class="flex flex-col gap-7">
<div class="flex justify-end">
<a href="{AppRoute.ADMIN_SETTINGS}?open=job-settings">
<Button size="sm">
<CogIcon size="18" />
<span class="pl-2">Manage Concurrency</span>
</Button>
</a>
</div>
{#each jobDetailsArray as [jobName, { title, subtitle, allText, missingText, allowForceCommand, icon, component, handleCommand: handleCommandOverride }]}
{@const { jobCounts, queueStatus } = jobs[jobName]}
<JobTile
{icon}
{title}
{subtitle}
allText={allText || 'ALL'}
missingText={missingText || 'MISSING'}
{allowForceCommand}
{jobCounts}
{queueStatus}
on:command={({ detail }) => (handleCommandOverride || handleCommand)(jobName, detail)}
>
{#if component}
<svelte:component this={component} slot="description" />
{/if}
</JobTile>
{/each}
<div class="flex justify-end">
<a href="{AppRoute.ADMIN_SETTINGS}?open=job-settings">
<Button size="sm">
<CogIcon size="18" />
<span class="pl-2">Manage Concurrency</span>
</Button>
</a>
</div>
{#each jobDetailsArray as [jobName, { title, subtitle, allText, missingText, allowForceCommand, icon, component, handleCommand: handleCommandOverride }]}
{@const { jobCounts, queueStatus } = jobs[jobName]}
<JobTile
{icon}
{title}
{subtitle}
allText={allText || 'ALL'}
missingText={missingText || 'MISSING'}
{allowForceCommand}
{jobCounts}
{queueStatus}
on:command={({ detail }) => (handleCommandOverride || handleCommand)(jobName, detail)}
>
{#if component}
<svelte:component this={component} slot="description" />
{/if}
</JobTile>
{/each}
</div>

View file

@ -1,10 +1,9 @@
<script lang="ts">
import { AppRoute } from '$lib/constants';
import { AppRoute } from '$lib/constants';
</script>
Apply the current
<a
href={`${AppRoute.ADMIN_SETTINGS}?open=storage-template`}
class="text-immich-primary dark:text-immich-dark-primary">Storage template</a
<a href={`${AppRoute.ADMIN_SETTINGS}?open=storage-template`} class="text-immich-primary dark:text-immich-dark-primary"
>Storage template</a
>
to previously uploaded assets

View file

@ -1,27 +1,21 @@
<script lang="ts">
import { api, UserResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import { api, UserResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
export let user: UserResponseDto;
export let user: UserResponseDto;
const dispatch = createEventDispatcher();
const dispatch = createEventDispatcher();
const restoreUser = async () => {
const restoredUser = await api.userApi.restoreUser({ userId: user.id });
if (restoredUser.data.deletedAt == null) dispatch('user-restore-success');
else dispatch('user-restore-fail');
};
const restoreUser = async () => {
const restoredUser = await api.userApi.restoreUser({ userId: user.id });
if (restoredUser.data.deletedAt == null) dispatch('user-restore-success');
else dispatch('user-restore-fail');
};
</script>
<ConfirmDialogue
title="Restore User"
confirmText="Continue"
confirmColor="green"
on:confirm={restoreUser}
on:cancel
>
<svelte:fragment slot="prompt">
<p><b>{user.firstName} {user.lastName}</b>'s account will be restored.</p>
</svelte:fragment>
<ConfirmDialogue title="Restore User" confirmText="Continue" confirmColor="green" on:confirm={restoreUser} on:cancel>
<svelte:fragment slot="prompt">
<p><b>{user.firstName} {user.lastName}</b>'s account will be restored.</p>
</svelte:fragment>
</ConfirmDialogue>

View file

@ -1,122 +1,109 @@
<script lang="ts">
import { locale } from '$lib/stores/preferences.store';
import type { ServerStatsResponseDto } from '@api';
import CameraIris from 'svelte-material-icons/CameraIris.svelte';
import Memory from 'svelte-material-icons/Memory.svelte';
import PlayCircle from 'svelte-material-icons/PlayCircle.svelte';
import { asByteUnitString, getBytesWithUnit } from '../../../utils/byte-units';
import StatsCard from './stats-card.svelte';
import { locale } from '$lib/stores/preferences.store';
import type { ServerStatsResponseDto } from '@api';
import CameraIris from 'svelte-material-icons/CameraIris.svelte';
import Memory from 'svelte-material-icons/Memory.svelte';
import PlayCircle from 'svelte-material-icons/PlayCircle.svelte';
import { asByteUnitString, getBytesWithUnit } from '../../../utils/byte-units';
import StatsCard from './stats-card.svelte';
export let stats: ServerStatsResponseDto = {
photos: 0,
videos: 0,
usage: 0,
usageByUser: []
};
export let stats: ServerStatsResponseDto = {
photos: 0,
videos: 0,
usage: 0,
usageByUser: [],
};
$: zeros = (value: number) => {
const maxLength = 13;
const valueLength = value.toString().length;
const zeroLength = maxLength - valueLength;
$: zeros = (value: number) => {
const maxLength = 13;
const valueLength = value.toString().length;
const zeroLength = maxLength - valueLength;
return '0'.repeat(zeroLength);
};
return '0'.repeat(zeroLength);
};
$: [statsUsage, statsUsageUnit] = getBytesWithUnit(stats.usage, 0);
$: [statsUsage, statsUsageUnit] = getBytesWithUnit(stats.usage, 0);
</script>
<div class="flex flex-col gap-5">
<div>
<p class="text-sm dark:text-immich-dark-fg">TOTAL USAGE</p>
<div>
<p class="text-sm dark:text-immich-dark-fg">TOTAL USAGE</p>
<div class="mt-5 justify-between lg:flex hidden">
<StatsCard logo={CameraIris} title="PHOTOS" value={stats.photos} />
<StatsCard logo={PlayCircle} title="VIDEOS" value={stats.videos} />
<StatsCard logo={Memory} title="STORAGE" value={statsUsage} unit={statsUsageUnit} />
</div>
<div class="mt-5 lg:hidden flex">
<div
class="bg-immich-gray dark:bg-immich-dark-gray rounded-3xl p-5 flex flex-col justify-between"
>
<div class="flex flex-wrap gap-x-12">
<div
class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary"
>
<CameraIris size="25" />
<p>PHOTOS</p>
</div>
<div class="mt-5 justify-between lg:flex hidden">
<StatsCard logo={CameraIris} title="PHOTOS" value={stats.photos} />
<StatsCard logo={PlayCircle} title="VIDEOS" value={stats.videos} />
<StatsCard logo={Memory} title="STORAGE" value={statsUsage} unit={statsUsageUnit} />
</div>
<div class="mt-5 lg:hidden flex">
<div class="bg-immich-gray dark:bg-immich-dark-gray rounded-3xl p-5 flex flex-col justify-between">
<div class="flex flex-wrap gap-x-12">
<div class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary">
<CameraIris size="25" />
<p>PHOTOS</p>
</div>
<div class="relative text-center font-mono font-semibold text-2xl">
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros(stats.photos)}</span><span
class="text-immich-primary dark:text-immich-dark-primary">{stats.photos}</span
>
</div>
</div>
<div class="flex flex-wrap gap-x-12">
<div
class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary"
>
<PlayCircle size="25" />
<p>VIDEOS</p>
</div>
<div class="relative text-center font-mono font-semibold text-2xl">
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros(stats.photos)}</span><span
class="text-immich-primary dark:text-immich-dark-primary">{stats.photos}</span
>
</div>
</div>
<div class="flex flex-wrap gap-x-12">
<div class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary">
<PlayCircle size="25" />
<p>VIDEOS</p>
</div>
<div class="relative text-center font-mono font-semibold text-2xl">
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros(stats.videos)}</span><span
class="text-immich-primary dark:text-immich-dark-primary">{stats.videos}</span
>
</div>
</div>
<div class="flex flex-wrap gap-x-7">
<div
class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary"
>
<Memory size="25" />
<p>STORAGE</p>
</div>
<div class="relative text-center font-mono font-semibold text-2xl">
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros(stats.videos)}</span><span
class="text-immich-primary dark:text-immich-dark-primary">{stats.videos}</span
>
</div>
</div>
<div class="flex flex-wrap gap-x-7">
<div class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary">
<Memory size="25" />
<p>STORAGE</p>
</div>
<div class="relative text-center font-mono font-semibold text-2xl flex">
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros(statsUsage)}</span><span
class="text-immich-primary dark:text-immich-dark-primary">{statsUsage}</span
>
<span class="text-center my-auto ml-2 text-base font-light text-gray-400"
>{statsUsageUnit}</span
>
</div>
</div>
</div>
</div>
</div>
<div class="relative text-center font-mono font-semibold text-2xl flex">
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros(statsUsage)}</span><span
class="text-immich-primary dark:text-immich-dark-primary">{statsUsage}</span
>
<span class="text-center my-auto ml-2 text-base font-light text-gray-400">{statsUsageUnit}</span>
</div>
</div>
</div>
</div>
</div>
<div>
<p class="text-sm dark:text-immich-dark-fg">USER USAGE DETAIL</p>
<table class="text-left w-full mt-5">
<thead
class="border rounded-md mb-4 bg-gray-50 dark:bg-immich-dark-gray dark:border-immich-dark-gray flex text-immich-primary dark:text-immich-dark-primary w-full h-12"
>
<tr class="flex w-full place-items-center">
<th class="text-center w-1/4 font-medium text-sm">User</th>
<th class="text-center w-1/4 font-medium text-sm">Photos</th>
<th class="text-center w-1/4 font-medium text-sm">Videos</th>
<th class="text-center w-1/4 font-medium text-sm">Size</th>
</tr>
</thead>
<tbody
class="overflow-y-auto rounded-md w-full max-h-[320px] block border dark:border-immich-dark-gray dark:text-immich-dark-fg"
>
{#each stats.usageByUser as user (user.userId)}
<tr
class="text-center flex place-items-center w-full h-[50px] even:bg-immich-bg even:dark:bg-immich-dark-gray/50 odd:bg-immich-gray odd:dark:bg-immich-dark-gray/75"
>
<td class="text-sm px-2 w-1/4 text-ellipsis"
>{user.userFirstName} {user.userLastName}</td
>
<td class="text-sm px-2 w-1/4 text-ellipsis">{user.photos.toLocaleString($locale)}</td>
<td class="text-sm px-2 w-1/4 text-ellipsis">{user.videos.toLocaleString($locale)}</td>
<td class="text-sm px-2 w-1/4 text-ellipsis">{asByteUnitString(user.usage, $locale)}</td
>
</tr>
{/each}
</tbody>
</table>
</div>
<div>
<p class="text-sm dark:text-immich-dark-fg">USER USAGE DETAIL</p>
<table class="text-left w-full mt-5">
<thead
class="border rounded-md mb-4 bg-gray-50 dark:bg-immich-dark-gray dark:border-immich-dark-gray flex text-immich-primary dark:text-immich-dark-primary w-full h-12"
>
<tr class="flex w-full place-items-center">
<th class="text-center w-1/4 font-medium text-sm">User</th>
<th class="text-center w-1/4 font-medium text-sm">Photos</th>
<th class="text-center w-1/4 font-medium text-sm">Videos</th>
<th class="text-center w-1/4 font-medium text-sm">Size</th>
</tr>
</thead>
<tbody
class="overflow-y-auto rounded-md w-full max-h-[320px] block border dark:border-immich-dark-gray dark:text-immich-dark-fg"
>
{#each stats.usageByUser as user (user.userId)}
<tr
class="text-center flex place-items-center w-full h-[50px] even:bg-immich-bg even:dark:bg-immich-dark-gray/50 odd:bg-immich-gray odd:dark:bg-immich-dark-gray/75"
>
<td class="text-sm px-2 w-1/4 text-ellipsis">{user.userFirstName} {user.userLastName}</td>
<td class="text-sm px-2 w-1/4 text-ellipsis">{user.photos.toLocaleString($locale)}</td>
<td class="text-sm px-2 w-1/4 text-ellipsis">{user.videos.toLocaleString($locale)}</td>
<td class="text-sm px-2 w-1/4 text-ellipsis">{asByteUnitString(user.usage, $locale)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>

View file

@ -1,34 +1,32 @@
<script lang="ts">
import type Icon from 'svelte-material-icons/AbTesting.svelte';
import type Icon from 'svelte-material-icons/AbTesting.svelte';
export let logo: typeof Icon;
export let title: string;
export let value: number;
export let unit: string | undefined = undefined;
export let logo: typeof Icon;
export let title: string;
export let value: number;
export let unit: string | undefined = undefined;
$: zeros = () => {
const maxLength = 13;
const valueLength = value.toString().length;
const zeroLength = maxLength - valueLength;
$: zeros = () => {
const maxLength = 13;
const valueLength = value.toString().length;
const zeroLength = maxLength - valueLength;
return '0'.repeat(zeroLength);
};
return '0'.repeat(zeroLength);
};
</script>
<div
class="w-[250px] h-[140px] bg-immich-gray dark:bg-immich-dark-gray rounded-3xl p-5 flex flex-col justify-between"
>
<div class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary">
<svelte:component this={logo} size="40" />
<p>{title}</p>
</div>
<div class="w-[250px] h-[140px] bg-immich-gray dark:bg-immich-dark-gray rounded-3xl p-5 flex flex-col justify-between">
<div class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary">
<svelte:component this={logo} size="40" />
<p>{title}</p>
</div>
<div class="relative text-center font-mono font-semibold text-2xl">
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros()}</span><span
class="text-immich-primary dark:text-immich-dark-primary">{value}</span
>
{#if unit}
<span class="absolute -top-5 right-2 text-base font-light text-gray-400">{unit}</span>
{/if}
</div>
<div class="relative text-center font-mono font-semibold text-2xl">
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros()}</span><span
class="text-immich-primary dark:text-immich-dark-primary">{value}</span
>
{#if unit}
<span class="absolute -top-5 right-2 text-base font-light text-gray-400">{unit}</span>
{/if}
</div>
</div>

View file

@ -1,22 +1,22 @@
<script lang="ts">
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
</script>
<ConfirmDialogue title="Disable Login" on:cancel on:confirm>
<svelte:fragment slot="prompt">
<div class="flex flex-col gap-4">
<p>Are you sure you want to disable all login methods? Login will be completely disabled.</p>
<p>
To re-enable, use a
<a
href="https://immich.app/docs/administration/server-commands"
rel="noreferrer"
target="_blank"
class="underline"
>
Server Command</a
>.
</p>
</div>
</svelte:fragment>
<svelte:fragment slot="prompt">
<div class="flex flex-col gap-4">
<p>Are you sure you want to disable all login methods? Login will be completely disabled.</p>
<p>
To re-enable, use a
<a
href="https://immich.app/docs/administration/server-commands"
rel="noreferrer"
target="_blank"
class="underline"
>
Server Command</a
>.
</p>
</div>
</svelte:fragment>
</ConfirmDialogue>

View file

@ -1,211 +1,211 @@
<script lang="ts">
import {
notificationController,
NotificationType
} from '$lib/components/shared-components/notification/notification';
import { api, SystemConfigFFmpegDto, SystemConfigFFmpegDtoTranscodeEnum } from '@api';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
import SettingSelect from '../setting-select.svelte';
import SettingSwitch from '../setting-switch.svelte';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { api, SystemConfigFFmpegDto, SystemConfigFFmpegDtoTranscodeEnum } from '@api';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
import SettingSelect from '../setting-select.svelte';
import SettingSwitch from '../setting-switch.svelte';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
export let ffmpegConfig: SystemConfigFFmpegDto; // this is the config that is being edited
export let ffmpegConfig: SystemConfigFFmpegDto; // this is the config that is being edited
let savedConfig: SystemConfigFFmpegDto;
let defaultConfig: SystemConfigFFmpegDto;
let savedConfig: SystemConfigFFmpegDto;
let defaultConfig: SystemConfigFFmpegDto;
async function getConfigs() {
[savedConfig, defaultConfig] = await Promise.all([
api.systemConfigApi.getConfig().then((res) => res.data.ffmpeg),
api.systemConfigApi.getDefaults().then((res) => res.data.ffmpeg)
]);
}
async function getConfigs() {
[savedConfig, defaultConfig] = await Promise.all([
api.systemConfigApi.getConfig().then((res) => res.data.ffmpeg),
api.systemConfigApi.getDefaults().then((res) => res.data.ffmpeg),
]);
}
async function saveSetting() {
try {
const { data: configs } = await api.systemConfigApi.getConfig();
async function saveSetting() {
try {
const { data: configs } = await api.systemConfigApi.getConfig();
const result = await api.systemConfigApi.updateConfig({
systemConfigDto: {
...configs,
ffmpeg: ffmpegConfig
}
});
const result = await api.systemConfigApi.updateConfig({
systemConfigDto: {
...configs,
ffmpeg: ffmpegConfig,
},
});
ffmpegConfig = { ...result.data.ffmpeg };
savedConfig = { ...result.data.ffmpeg };
ffmpegConfig = { ...result.data.ffmpeg };
savedConfig = { ...result.data.ffmpeg };
notificationController.show({
message: 'FFmpeg settings saved',
type: NotificationType.Info
});
} catch (e) {
console.error('Error [ffmpeg-settings] [saveSetting]', e);
notificationController.show({
message: 'Unable to save settings',
type: NotificationType.Error
});
}
}
notificationController.show({
message: 'FFmpeg settings saved',
type: NotificationType.Info,
});
} catch (e) {
console.error('Error [ffmpeg-settings] [saveSetting]', e);
notificationController.show({
message: 'Unable to save settings',
type: NotificationType.Error,
});
}
}
async function reset() {
const { data: resetConfig } = await api.systemConfigApi.getConfig();
async function reset() {
const { data: resetConfig } = await api.systemConfigApi.getConfig();
ffmpegConfig = { ...resetConfig.ffmpeg };
savedConfig = { ...resetConfig.ffmpeg };
ffmpegConfig = { ...resetConfig.ffmpeg };
savedConfig = { ...resetConfig.ffmpeg };
notificationController.show({
message: 'Reset FFmpeg settings to the recent saved settings',
type: NotificationType.Info
});
}
notificationController.show({
message: 'Reset FFmpeg settings to the recent saved settings',
type: NotificationType.Info,
});
}
async function resetToDefault() {
const { data: configs } = await api.systemConfigApi.getDefaults();
async function resetToDefault() {
const { data: configs } = await api.systemConfigApi.getDefaults();
ffmpegConfig = { ...configs.ffmpeg };
defaultConfig = { ...configs.ffmpeg };
ffmpegConfig = { ...configs.ffmpeg };
defaultConfig = { ...configs.ffmpeg };
notificationController.show({
message: 'Reset FFmpeg settings to default',
type: NotificationType.Info
});
}
notificationController.show({
message: 'Reset FFmpeg settings to default',
type: NotificationType.Info,
});
}
</script>
<div>
{#await getConfigs() then}
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="flex flex-col gap-4 ml-4 mt-4">
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="CONSTANT RATE FACTOR (-crf)"
desc="Video quality level. Typical values are 23 for H.264, 28 for HEVC, and 31 for VP9. Lower is better, but takes longer to encode and produces larger files."
bind:value={ffmpegConfig.crf}
required={true}
isEdited={!(ffmpegConfig.crf == savedConfig.crf)}
/>
{#await getConfigs() then}
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="flex flex-col gap-4 ml-4 mt-4">
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="CONSTANT RATE FACTOR (-crf)"
desc="Video quality level. Typical values are 23 for H.264, 28 for HEVC, and 31 for VP9. Lower is better, but takes longer to encode and produces larger files."
bind:value={ffmpegConfig.crf}
required={true}
isEdited={!(ffmpegConfig.crf == savedConfig.crf)}
/>
<SettingSelect
label="PRESET (-preset)"
desc="Compression speed. Slower presets produce smaller files, and increase quality when targeting a certain bitrate. VP9 ignores speeds above `faster`."
bind:value={ffmpegConfig.preset}
name="preset"
options={[
{ value: 'ultrafast', text: 'ultrafast' },
{ value: 'superfast', text: 'superfast' },
{ value: 'veryfast', text: 'veryfast' },
{ value: 'faster', text: 'faster' },
{ value: 'fast', text: 'fast' },
{ value: 'medium', text: 'medium' },
{ value: 'slow', text: 'slow' },
{ value: 'slower', text: 'slower' },
{ value: 'veryslow', text: 'veryslow' }
]}
isEdited={!(ffmpegConfig.preset == savedConfig.preset)}
/>
<SettingSelect
label="PRESET (-preset)"
desc="Compression speed. Slower presets produce smaller files, and increase quality when targeting a certain bitrate. VP9 ignores speeds above `faster`."
bind:value={ffmpegConfig.preset}
name="preset"
options={[
{ value: 'ultrafast', text: 'ultrafast' },
{ value: 'superfast', text: 'superfast' },
{ value: 'veryfast', text: 'veryfast' },
{ value: 'faster', text: 'faster' },
{ value: 'fast', text: 'fast' },
{ value: 'medium', text: 'medium' },
{ value: 'slow', text: 'slow' },
{ value: 'slower', text: 'slower' },
{ value: 'veryslow', text: 'veryslow' },
]}
isEdited={!(ffmpegConfig.preset == savedConfig.preset)}
/>
<SettingSelect
label="AUDIO CODEC"
desc="Opus is the highest quality option, but has lower compatibility with old devices or software."
bind:value={ffmpegConfig.targetAudioCodec}
options={[
{ value: 'aac', text: 'aac' },
{ value: 'mp3', text: 'mp3' },
{ value: 'opus', text: 'opus' }
]}
name="acodec"
isEdited={!(ffmpegConfig.targetAudioCodec == savedConfig.targetAudioCodec)}
/>
<SettingSelect
label="AUDIO CODEC"
desc="Opus is the highest quality option, but has lower compatibility with old devices or software."
bind:value={ffmpegConfig.targetAudioCodec}
options={[
{ value: 'aac', text: 'aac' },
{ value: 'mp3', text: 'mp3' },
{ value: 'opus', text: 'opus' },
]}
name="acodec"
isEdited={!(ffmpegConfig.targetAudioCodec == savedConfig.targetAudioCodec)}
/>
<SettingSelect
label="VIDEO CODEC"
desc="VP9 has high efficiency and web compatibility, but takes longer to transcode. HEVC performs similarly, but has lower web compatibility. H.264 is widely compatible and quick to transcode, but produces much larger files."
bind:value={ffmpegConfig.targetVideoCodec}
options={[
{ value: 'h264', text: 'h264' },
{ value: 'hevc', text: 'hevc' },
{ value: 'vp9', text: 'vp9' }
]}
name="vcodec"
isEdited={!(ffmpegConfig.targetVideoCodec == savedConfig.targetVideoCodec)}
/>
<SettingSelect
label="VIDEO CODEC"
desc="VP9 has high efficiency and web compatibility, but takes longer to transcode. HEVC performs similarly, but has lower web compatibility. H.264 is widely compatible and quick to transcode, but produces much larger files."
bind:value={ffmpegConfig.targetVideoCodec}
options={[
{ value: 'h264', text: 'h264' },
{ value: 'hevc', text: 'hevc' },
{ value: 'vp9', text: 'vp9' },
]}
name="vcodec"
isEdited={!(ffmpegConfig.targetVideoCodec == savedConfig.targetVideoCodec)}
/>
<SettingSelect
label="TARGET RESOLUTION"
desc="Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
bind:value={ffmpegConfig.targetResolution}
options={[
{ value: '2160', text: '4k' },
{ value: '1440', text: '1440p' },
{ value: '1080', text: '1080p' },
{ value: '720', text: '720p' },
{ value: '480', text: '480p' },
{ value: 'original', text: 'original' }
]}
name="resolution"
isEdited={!(ffmpegConfig.targetResolution == savedConfig.targetResolution)}
/>
<SettingSelect
label="TARGET RESOLUTION"
desc="Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
bind:value={ffmpegConfig.targetResolution}
options={[
{ value: '2160', text: '4k' },
{ value: '1440', text: '1440p' },
{ value: '1080', text: '1080p' },
{ value: '720', text: '720p' },
{ value: '480', text: '480p' },
{ value: 'original', text: 'original' },
]}
name="resolution"
isEdited={!(ffmpegConfig.targetResolution == savedConfig.targetResolution)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="MAX BITRATE"
desc="Setting a max bitrate can make file sizes more predictable at a minor cost to quality. At 720p, typical values are 2600k for VP9 or HEVC, or 4500k for H.264. Disabled if set to 0."
bind:value={ffmpegConfig.maxBitrate}
isEdited={!(ffmpegConfig.maxBitrate == savedConfig.maxBitrate)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="MAX BITRATE"
desc="Setting a max bitrate can make file sizes more predictable at a minor cost to quality. At 720p, typical values are 2600k for VP9 or HEVC, or 4500k for H.264. Disabled if set to 0."
bind:value={ffmpegConfig.maxBitrate}
isEdited={!(ffmpegConfig.maxBitrate == savedConfig.maxBitrate)}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="THREADS"
desc="Higher values lead to faster encoding, but leave less room for the server to process other tasks while active. This value should not be more than the number of CPU cores. Maximizes utilization if set to 0."
bind:value={ffmpegConfig.threads}
isEdited={!(ffmpegConfig.threads == savedConfig.threads)}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="THREADS"
desc="Higher values lead to faster encoding, but leave less room for the server to process other tasks while active. This value should not be more than the number of CPU cores. Maximizes utilization if set to 0."
bind:value={ffmpegConfig.threads}
isEdited={!(ffmpegConfig.threads == savedConfig.threads)}
/>
<SettingSelect
label="TRANSCODE"
desc="Policy for when a video should be transcoded."
bind:value={ffmpegConfig.transcode}
name="transcode"
options={[
{ value: SystemConfigFFmpegDtoTranscodeEnum.All, text: 'All videos' },
{
value: SystemConfigFFmpegDtoTranscodeEnum.Optimal,
text: 'Videos higher than target resolution or not in the desired format'
},
{
value: SystemConfigFFmpegDtoTranscodeEnum.Required,
text: 'Only videos not in the desired format'
},
{
value: SystemConfigFFmpegDtoTranscodeEnum.Disabled,
text: "Don't transcode any videos, may break playback on some clients"
}
]}
isEdited={!(ffmpegConfig.transcode == savedConfig.transcode)}
/>
<SettingSelect
label="TRANSCODE"
desc="Policy for when a video should be transcoded."
bind:value={ffmpegConfig.transcode}
name="transcode"
options={[
{ value: SystemConfigFFmpegDtoTranscodeEnum.All, text: 'All videos' },
{
value: SystemConfigFFmpegDtoTranscodeEnum.Optimal,
text: 'Videos higher than target resolution or not in the desired format',
},
{
value: SystemConfigFFmpegDtoTranscodeEnum.Required,
text: 'Only videos not in the desired format',
},
{
value: SystemConfigFFmpegDtoTranscodeEnum.Disabled,
text: "Don't transcode any videos, may break playback on some clients",
},
]}
isEdited={!(ffmpegConfig.transcode == savedConfig.transcode)}
/>
<SettingSwitch
title="TWO-PASS ENCODING"
subtitle="Transcode in two passes to produce better encoded videos. When max bitrate is enabled (required for it to work with H.264 and HEVC), this mode uses a bitrate range based on the max bitrate and ignores CRF. For VP9, CRF can be used if max bitrate is disabled."
bind:checked={ffmpegConfig.twoPass}
isEdited={!(ffmpegConfig.twoPass === savedConfig.twoPass)}
/>
</div>
<SettingSwitch
title="TWO-PASS ENCODING"
subtitle="Transcode in two passes to produce better encoded videos. When max bitrate is enabled (required for it to work with H.264 and HEVC), this mode uses a bitrate range based on the max bitrate and ignores CRF. For VP9, CRF can be used if max bitrate is disabled."
bind:checked={ffmpegConfig.twoPass}
isEdited={!(ffmpegConfig.twoPass === savedConfig.twoPass)}
/>
</div>
<div class="ml-4">
<SettingButtonsRow
on:reset={reset}
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
/>
</div>
</form>
</div>
{/await}
<div class="ml-4">
<SettingButtonsRow
on:reset={reset}
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
/>
</div>
</form>
</div>
{/await}
</div>

View file

@ -1,103 +1,101 @@
<script lang="ts">
import {
notificationController,
NotificationType
} from '$lib/components/shared-components/notification/notification';
import { api, JobName, SystemConfigJobDto } from '@api';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import { handleError } from '../../../../utils/handle-error';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { api, JobName, SystemConfigJobDto } from '@api';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import { handleError } from '../../../../utils/handle-error';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
export let jobConfig: SystemConfigJobDto; // this is the config that is being edited
export let jobConfig: SystemConfigJobDto; // this is the config that is being edited
let savedConfig: SystemConfigJobDto;
let defaultConfig: SystemConfigJobDto;
let savedConfig: SystemConfigJobDto;
let defaultConfig: SystemConfigJobDto;
const ignoredJobs = [JobName.BackgroundTask, JobName.Search] as JobName[];
const jobNames = Object.values(JobName).filter(
(jobName) => !ignoredJobs.includes(jobName as JobName)
);
const ignoredJobs = [JobName.BackgroundTask, JobName.Search] as JobName[];
const jobNames = Object.values(JobName).filter((jobName) => !ignoredJobs.includes(jobName as JobName));
async function getConfigs() {
[savedConfig, defaultConfig] = await Promise.all([
api.systemConfigApi.getConfig().then((res) => res.data.job),
api.systemConfigApi.getDefaults().then((res) => res.data.job)
]);
}
async function getConfigs() {
[savedConfig, defaultConfig] = await Promise.all([
api.systemConfigApi.getConfig().then((res) => res.data.job),
api.systemConfigApi.getDefaults().then((res) => res.data.job),
]);
}
async function saveSetting() {
try {
const { data: configs } = await api.systemConfigApi.getConfig();
async function saveSetting() {
try {
const { data: configs } = await api.systemConfigApi.getConfig();
const result = await api.systemConfigApi.updateConfig({
systemConfigDto: {
...configs,
job: jobConfig
}
});
const result = await api.systemConfigApi.updateConfig({
systemConfigDto: {
...configs,
job: jobConfig,
},
});
jobConfig = { ...result.data.job };
savedConfig = { ...result.data.job };
jobConfig = { ...result.data.job };
savedConfig = { ...result.data.job };
notificationController.show({ message: 'Job settings saved', type: NotificationType.Info });
} catch (error) {
handleError(error, 'Unable to save settings');
}
}
notificationController.show({ message: 'Job settings saved', type: NotificationType.Info });
} catch (error) {
handleError(error, 'Unable to save settings');
}
}
async function reset() {
const { data: resetConfig } = await api.systemConfigApi.getConfig();
async function reset() {
const { data: resetConfig } = await api.systemConfigApi.getConfig();
jobConfig = { ...resetConfig.job };
savedConfig = { ...resetConfig.job };
jobConfig = { ...resetConfig.job };
savedConfig = { ...resetConfig.job };
notificationController.show({
message: 'Reset Job settings to the recent saved settings',
type: NotificationType.Info
});
}
notificationController.show({
message: 'Reset Job settings to the recent saved settings',
type: NotificationType.Info,
});
}
async function resetToDefault() {
const { data: configs } = await api.systemConfigApi.getDefaults();
async function resetToDefault() {
const { data: configs } = await api.systemConfigApi.getDefaults();
jobConfig = { ...configs.job };
defaultConfig = { ...configs.job };
jobConfig = { ...configs.job };
defaultConfig = { ...configs.job };
notificationController.show({
message: 'Reset Job settings to default',
type: NotificationType.Info
});
}
notificationController.show({
message: 'Reset Job settings to default',
type: NotificationType.Info,
});
}
</script>
<div>
{#await getConfigs() then}
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
{#each jobNames as jobName}
<div class="flex flex-col gap-4 ml-4 mt-4">
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="{api.getJobName(jobName)} Concurrency"
desc=""
bind:value={jobConfig[jobName].concurrency}
required={true}
isEdited={!(jobConfig[jobName].concurrency == savedConfig[jobName].concurrency)}
/>
</div>
{/each}
{#await getConfigs() then}
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
{#each jobNames as jobName}
<div class="flex flex-col gap-4 ml-4 mt-4">
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="{api.getJobName(jobName)} Concurrency"
desc=""
bind:value={jobConfig[jobName].concurrency}
required={true}
isEdited={!(jobConfig[jobName].concurrency == savedConfig[jobName].concurrency)}
/>
</div>
{/each}
<div class="ml-4">
<SettingButtonsRow
on:reset={reset}
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
/>
</div>
</form>
</div>
{/await}
<div class="ml-4">
<SettingButtonsRow
on:reset={reset}
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
/>
</div>
</form>
</div>
{/await}
</div>

View file

@ -1,212 +1,209 @@
<script lang="ts">
import {
notificationController,
NotificationType
} from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { api, SystemConfigOAuthDto } from '@api';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import ConfirmDisableLogin from '../confirm-disable-login.svelte';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
import SettingSwitch from '../setting-switch.svelte';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { api, SystemConfigOAuthDto } from '@api';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import ConfirmDisableLogin from '../confirm-disable-login.svelte';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
import SettingSwitch from '../setting-switch.svelte';
export let oauthConfig: SystemConfigOAuthDto;
export let oauthConfig: SystemConfigOAuthDto;
let savedConfig: SystemConfigOAuthDto;
let defaultConfig: SystemConfigOAuthDto;
let savedConfig: SystemConfigOAuthDto;
let defaultConfig: SystemConfigOAuthDto;
const handleToggleOverride = () => {
// click runs before bind
const previouslyEnabled = oauthConfig.mobileOverrideEnabled;
if (!previouslyEnabled && !oauthConfig.mobileRedirectUri) {
oauthConfig.mobileRedirectUri = window.location.origin + '/api/oauth/mobile-redirect';
}
};
const handleToggleOverride = () => {
// click runs before bind
const previouslyEnabled = oauthConfig.mobileOverrideEnabled;
if (!previouslyEnabled && !oauthConfig.mobileRedirectUri) {
oauthConfig.mobileRedirectUri = window.location.origin + '/api/oauth/mobile-redirect';
}
};
async function getConfigs() {
[savedConfig, defaultConfig] = await Promise.all([
api.systemConfigApi.getConfig().then((res) => res.data.oauth),
api.systemConfigApi.getDefaults().then((res) => res.data.oauth)
]);
}
async function getConfigs() {
[savedConfig, defaultConfig] = await Promise.all([
api.systemConfigApi.getConfig().then((res) => res.data.oauth),
api.systemConfigApi.getDefaults().then((res) => res.data.oauth),
]);
}
async function reset() {
const { data: resetConfig } = await api.systemConfigApi.getConfig();
async function reset() {
const { data: resetConfig } = await api.systemConfigApi.getConfig();
oauthConfig = { ...resetConfig.oauth };
savedConfig = { ...resetConfig.oauth };
oauthConfig = { ...resetConfig.oauth };
savedConfig = { ...resetConfig.oauth };
notificationController.show({
message: 'Reset OAuth settings to the last saved settings',
type: NotificationType.Info
});
}
notificationController.show({
message: 'Reset OAuth settings to the last saved settings',
type: NotificationType.Info,
});
}
let isConfirmOpen = false;
let handleConfirm: (value: boolean) => void;
let isConfirmOpen = false;
let handleConfirm: (value: boolean) => void;
const openConfirmModal = () => {
return new Promise((resolve) => {
handleConfirm = (value: boolean) => {
isConfirmOpen = false;
resolve(value);
};
isConfirmOpen = true;
});
};
const openConfirmModal = () => {
return new Promise((resolve) => {
handleConfirm = (value: boolean) => {
isConfirmOpen = false;
resolve(value);
};
isConfirmOpen = true;
});
};
async function saveSetting() {
try {
const { data: current } = await api.systemConfigApi.getConfig();
async function saveSetting() {
try {
const { data: current } = await api.systemConfigApi.getConfig();
if (!current.passwordLogin.enabled && current.oauth.enabled && !oauthConfig.enabled) {
const confirmed = await openConfirmModal();
if (!confirmed) {
return;
}
}
if (!current.passwordLogin.enabled && current.oauth.enabled && !oauthConfig.enabled) {
const confirmed = await openConfirmModal();
if (!confirmed) {
return;
}
}
if (!oauthConfig.mobileOverrideEnabled) {
oauthConfig.mobileRedirectUri = '';
}
if (!oauthConfig.mobileOverrideEnabled) {
oauthConfig.mobileRedirectUri = '';
}
const { data: updated } = await api.systemConfigApi.updateConfig({
systemConfigDto: {
...current,
oauth: oauthConfig
}
});
const { data: updated } = await api.systemConfigApi.updateConfig({
systemConfigDto: {
...current,
oauth: oauthConfig,
},
});
oauthConfig = { ...updated.oauth };
savedConfig = { ...updated.oauth };
oauthConfig = { ...updated.oauth };
savedConfig = { ...updated.oauth };
notificationController.show({ message: 'OAuth settings saved', type: NotificationType.Info });
} catch (error) {
handleError(error, 'Unable to save OAuth settings');
}
}
notificationController.show({ message: 'OAuth settings saved', type: NotificationType.Info });
} catch (error) {
handleError(error, 'Unable to save OAuth settings');
}
}
async function resetToDefault() {
const { data: defaultConfig } = await api.systemConfigApi.getDefaults();
async function resetToDefault() {
const { data: defaultConfig } = await api.systemConfigApi.getDefaults();
oauthConfig = { ...defaultConfig.oauth };
oauthConfig = { ...defaultConfig.oauth };
notificationController.show({
message: 'Reset OAuth settings to default',
type: NotificationType.Info
});
}
notificationController.show({
message: 'Reset OAuth settings to default',
type: NotificationType.Info,
});
}
</script>
{#if isConfirmOpen}
<ConfirmDisableLogin
on:cancel={() => handleConfirm(false)}
on:confirm={() => handleConfirm(true)}
/>
<ConfirmDisableLogin on:cancel={() => handleConfirm(false)} on:confirm={() => handleConfirm(true)} />
{/if}
<div class="mt-2">
{#await getConfigs() then}
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault class="flex flex-col mx-4 gap-4 py-4">
<p class="text-sm dark:text-immich-dark-fg">
For more details about this feature, refer to the <a
href="http://immich.app/docs/administration/oauth#mobile-redirect-uri"
class="underline"
target="_blank"
rel="noreferrer">docs</a
>.
</p>
{#await getConfigs() then}
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault class="flex flex-col mx-4 gap-4 py-4">
<p class="text-sm dark:text-immich-dark-fg">
For more details about this feature, refer to the <a
href="http://immich.app/docs/administration/oauth#mobile-redirect-uri"
class="underline"
target="_blank"
rel="noreferrer">docs</a
>.
</p>
<SettingSwitch title="ENABLE" bind:checked={oauthConfig.enabled} />
<hr />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="ISSUER URL"
bind:value={oauthConfig.issuerUrl}
required={true}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.issuerUrl == savedConfig.issuerUrl)}
/>
<SettingSwitch title="ENABLE" bind:checked={oauthConfig.enabled} />
<hr />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="ISSUER URL"
bind:value={oauthConfig.issuerUrl}
required={true}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.issuerUrl == savedConfig.issuerUrl)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT ID"
bind:value={oauthConfig.clientId}
required={true}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.clientId == savedConfig.clientId)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT ID"
bind:value={oauthConfig.clientId}
required={true}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.clientId == savedConfig.clientId)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT SECRET"
bind:value={oauthConfig.clientSecret}
required={true}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.clientSecret == savedConfig.clientSecret)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT SECRET"
bind:value={oauthConfig.clientSecret}
required={true}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.clientSecret == savedConfig.clientSecret)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="SCOPE"
bind:value={oauthConfig.scope}
required={true}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.scope == savedConfig.scope)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="SCOPE"
bind:value={oauthConfig.scope}
required={true}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.scope == savedConfig.scope)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="BUTTON TEXT"
bind:value={oauthConfig.buttonText}
required={false}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.buttonText == savedConfig.buttonText)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="BUTTON TEXT"
bind:value={oauthConfig.buttonText}
required={false}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.buttonText == savedConfig.buttonText)}
/>
<SettingSwitch
title="AUTO REGISTER"
subtitle="Automatically register new users after signing in with OAuth"
bind:checked={oauthConfig.autoRegister}
disabled={!oauthConfig.enabled}
/>
<SettingSwitch
title="AUTO REGISTER"
subtitle="Automatically register new users after signing in with OAuth"
bind:checked={oauthConfig.autoRegister}
disabled={!oauthConfig.enabled}
/>
<SettingSwitch
title="AUTO LAUNCH"
subtitle="Start the OAuth login flow automatically upon navigating to the login page"
disabled={!oauthConfig.enabled}
bind:checked={oauthConfig.autoLaunch}
/>
<SettingSwitch
title="AUTO LAUNCH"
subtitle="Start the OAuth login flow automatically upon navigating to the login page"
disabled={!oauthConfig.enabled}
bind:checked={oauthConfig.autoLaunch}
/>
<SettingSwitch
title="MOBILE REDIRECT URI OVERRIDE"
subtitle="Enable when `app.immich:/` is an invalid redirect URI."
disabled={!oauthConfig.enabled}
on:click={() => handleToggleOverride()}
bind:checked={oauthConfig.mobileOverrideEnabled}
/>
<SettingSwitch
title="MOBILE REDIRECT URI OVERRIDE"
subtitle="Enable when `app.immich:/` is an invalid redirect URI."
disabled={!oauthConfig.enabled}
on:click={() => handleToggleOverride()}
bind:checked={oauthConfig.mobileOverrideEnabled}
/>
{#if oauthConfig.mobileOverrideEnabled}
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="MOBILE REDIRECT URI"
bind:value={oauthConfig.mobileRedirectUri}
required={true}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.mobileRedirectUri == savedConfig.mobileRedirectUri)}
/>
{/if}
{#if oauthConfig.mobileOverrideEnabled}
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="MOBILE REDIRECT URI"
bind:value={oauthConfig.mobileRedirectUri}
required={true}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.mobileRedirectUri == savedConfig.mobileRedirectUri)}
/>
{/if}
<SettingButtonsRow
on:reset={reset}
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
/>
</form>
</div>
{/await}
<SettingButtonsRow
on:reset={reset}
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
/>
</form>
</div>
{/await}
</div>

View file

@ -1,121 +1,118 @@
<script lang="ts">
import {
notificationController,
NotificationType
} from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { api, SystemConfigPasswordLoginDto } from '@api';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import ConfirmDisableLogin from '../confirm-disable-login.svelte';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingSwitch from '../setting-switch.svelte';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { api, SystemConfigPasswordLoginDto } from '@api';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import ConfirmDisableLogin from '../confirm-disable-login.svelte';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingSwitch from '../setting-switch.svelte';
export let passwordLoginConfig: SystemConfigPasswordLoginDto; // this is the config that is being edited
export let passwordLoginConfig: SystemConfigPasswordLoginDto; // this is the config that is being edited
let savedConfig: SystemConfigPasswordLoginDto;
let defaultConfig: SystemConfigPasswordLoginDto;
let savedConfig: SystemConfigPasswordLoginDto;
let defaultConfig: SystemConfigPasswordLoginDto;
async function getConfigs() {
[savedConfig, defaultConfig] = await Promise.all([
api.systemConfigApi.getConfig().then((res) => res.data.passwordLogin),
api.systemConfigApi.getDefaults().then((res) => res.data.passwordLogin)
]);
}
async function getConfigs() {
[savedConfig, defaultConfig] = await Promise.all([
api.systemConfigApi.getConfig().then((res) => res.data.passwordLogin),
api.systemConfigApi.getDefaults().then((res) => res.data.passwordLogin),
]);
}
let isConfirmOpen = false;
let handleConfirm: (value: boolean) => void;
let isConfirmOpen = false;
let handleConfirm: (value: boolean) => void;
const openConfirmModal = () => {
return new Promise((resolve) => {
handleConfirm = (value: boolean) => {
isConfirmOpen = false;
resolve(value);
};
isConfirmOpen = true;
});
};
const openConfirmModal = () => {
return new Promise((resolve) => {
handleConfirm = (value: boolean) => {
isConfirmOpen = false;
resolve(value);
};
isConfirmOpen = true;
});
};
async function saveSetting() {
try {
const { data: current } = await api.systemConfigApi.getConfig();
async function saveSetting() {
try {
const { data: current } = await api.systemConfigApi.getConfig();
if (!current.oauth.enabled && current.passwordLogin.enabled && !passwordLoginConfig.enabled) {
const confirmed = await openConfirmModal();
if (!confirmed) {
return;
}
}
if (!current.oauth.enabled && current.passwordLogin.enabled && !passwordLoginConfig.enabled) {
const confirmed = await openConfirmModal();
if (!confirmed) {
return;
}
}
const { data: updated } = await api.systemConfigApi.updateConfig({
systemConfigDto: {
...current,
passwordLogin: passwordLoginConfig
}
});
const { data: updated } = await api.systemConfigApi.updateConfig({
systemConfigDto: {
...current,
passwordLogin: passwordLoginConfig,
},
});
passwordLoginConfig = { ...updated.passwordLogin };
savedConfig = { ...updated.passwordLogin };
passwordLoginConfig = { ...updated.passwordLogin };
savedConfig = { ...updated.passwordLogin };
notificationController.show({ message: 'Settings saved', type: NotificationType.Info });
} catch (error) {
handleError(error, 'Unable to save settings');
}
}
notificationController.show({ message: 'Settings saved', type: NotificationType.Info });
} catch (error) {
handleError(error, 'Unable to save settings');
}
}
async function reset() {
const { data: resetConfig } = await api.systemConfigApi.getConfig();
async function reset() {
const { data: resetConfig } = await api.systemConfigApi.getConfig();
passwordLoginConfig = { ...resetConfig.passwordLogin };
savedConfig = { ...resetConfig.passwordLogin };
passwordLoginConfig = { ...resetConfig.passwordLogin };
savedConfig = { ...resetConfig.passwordLogin };
notificationController.show({
message: 'Reset settings to the recent saved settings',
type: NotificationType.Info
});
}
notificationController.show({
message: 'Reset settings to the recent saved settings',
type: NotificationType.Info,
});
}
async function resetToDefault() {
const { data: configs } = await api.systemConfigApi.getDefaults();
async function resetToDefault() {
const { data: configs } = await api.systemConfigApi.getDefaults();
passwordLoginConfig = { ...configs.passwordLogin };
defaultConfig = { ...configs.passwordLogin };
passwordLoginConfig = { ...configs.passwordLogin };
defaultConfig = { ...configs.passwordLogin };
notificationController.show({
message: 'Reset password settings to default',
type: NotificationType.Info
});
}
notificationController.show({
message: 'Reset password settings to default',
type: NotificationType.Info,
});
}
</script>
{#if isConfirmOpen}
<ConfirmDisableLogin
on:cancel={() => handleConfirm(false)}
on:confirm={() => handleConfirm(true)}
/>
<ConfirmDisableLogin on:cancel={() => handleConfirm(false)} on:confirm={() => handleConfirm(true)} />
{/if}
<div>
{#await getConfigs() then}
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="flex flex-col gap-4 ml-4 mt-4">
<div class="ml-4">
<SettingSwitch
title="ENABLED"
subtitle="Login with email and password"
bind:checked={passwordLoginConfig.enabled}
/>
{#await getConfigs() then}
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="flex flex-col gap-4 ml-4 mt-4">
<div class="ml-4">
<SettingSwitch
title="ENABLED"
subtitle="Login with email and password"
bind:checked={passwordLoginConfig.enabled}
/>
<SettingButtonsRow
on:reset={reset}
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
/>
</div>
</div>
</form>
</div>
{/await}
<SettingButtonsRow
on:reset={reset}
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
/>
</div>
</div>
</form>
</div>
{/await}
</div>

View file

@ -1,56 +1,56 @@
<script lang="ts">
import { slide } from 'svelte/transition';
export let title: string;
export let subtitle = '';
import { slide } from 'svelte/transition';
export let title: string;
export let subtitle = '';
export let isOpen = false;
const toggle = () => (isOpen = !isOpen);
export let isOpen = false;
const toggle = () => (isOpen = !isOpen);
</script>
<div class="border-b-[1px] border-gray-200 dark:border-gray-700 py-4">
<div class="flex justify-between place-items-center">
<div>
<h2 class="font-medium text-immich-primary dark:text-immich-dark-primary">
{title}
</h2>
<div class="flex justify-between place-items-center">
<div>
<h2 class="font-medium text-immich-primary dark:text-immich-dark-primary">
{title}
</h2>
<p class="text-sm dark:text-immich-dark-fg">{subtitle}</p>
</div>
<p class="text-sm dark:text-immich-dark-fg">{subtitle}</p>
</div>
<button
on:click={toggle}
aria-expanded={isOpen}
class="immich-circle-icon-button hover:bg-immich-primary/10 dark:text-immich-dark-fg hover:dark:bg-immich-dark-primary/20 rounded-full p-3 flex place-items-center place-content-center transition-all"
>
<svg
style="tran"
width="20"
height="20"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
<button
on:click={toggle}
aria-expanded={isOpen}
class="immich-circle-icon-button hover:bg-immich-primary/10 dark:text-immich-dark-fg hover:dark:bg-immich-dark-primary/20 rounded-full p-3 flex place-items-center place-content-center transition-all"
>
<svg
style="tran"
width="20"
height="20"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
{#if isOpen}
<ul transition:slide={{ duration: 250 }} class="mb-2 ml-4">
<slot />
</ul>
{/if}
{#if isOpen}
<ul transition:slide={{ duration: 250 }} class="mb-2 ml-4">
<slot />
</ul>
{/if}
</div>
<style>
svg {
transition: transform 0.2s ease-in;
}
svg {
transition: transform 0.2s ease-in;
}
[aria-expanded='true'] svg {
transform: rotate(0.5turn);
}
[aria-expanded='true'] svg {
transform: rotate(0.5turn);
}
</style>

View file

@ -1,26 +1,26 @@
<script lang="ts">
import Button from '$lib/components/elements/buttons/button.svelte';
import { createEventDispatcher } from 'svelte';
import Button from '$lib/components/elements/buttons/button.svelte';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
const dispatch = createEventDispatcher();
export let showResetToDefault = true;
export let showResetToDefault = true;
</script>
<div class="flex justify-between gap-2 mt-8">
<div class="left">
{#if showResetToDefault}
<button
on:click={() => dispatch('reset-to-default')}
class="text-sm dark:text-immich-dark-primary hover:dark:text-immich-dark-primary/75 text-immich-primary hover:text-immich-primary/75 font-medium bg-none"
>
Reset to default
</button>
{/if}
</div>
<div class="left">
{#if showResetToDefault}
<button
on:click={() => dispatch('reset-to-default')}
class="text-sm dark:text-immich-dark-primary hover:dark:text-immich-dark-primary/75 text-immich-primary hover:text-immich-primary/75 font-medium bg-none"
>
Reset to default
</button>
{/if}
</div>
<div class="right">
<Button size="sm" color="gray" on:click={() => dispatch('reset')}>Reset</Button>
<Button size="sm" on:click={() => dispatch('save')}>Save</Button>
</div>
<div class="right">
<Button size="sm" color="gray" on:click={() => dispatch('reset')}>Reset</Button>
<Button size="sm" on:click={() => dispatch('save')}>Save</Button>
</div>
</div>

View file

@ -1,65 +1,65 @@
<script lang="ts" context="module">
export enum SettingInputFieldType {
EMAIL = 'email',
TEXT = 'text',
NUMBER = 'number',
PASSWORD = 'password'
}
export enum SettingInputFieldType {
EMAIL = 'email',
TEXT = 'text',
NUMBER = 'number',
PASSWORD = 'password',
}
</script>
<script lang="ts">
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
export let inputType: SettingInputFieldType;
export let value: string | number;
export let label = '';
export let desc = '';
export let required = false;
export let disabled = false;
export let isEdited = false;
export let inputType: SettingInputFieldType;
export let value: string | number;
export let label = '';
export let desc = '';
export let required = false;
export let disabled = false;
export let isEdited = false;
const handleInput = (e: Event) => {
value = (e.target as HTMLInputElement).value;
if (inputType === SettingInputFieldType.NUMBER) {
value = Number(value) || 0;
}
};
const handleInput = (e: Event) => {
value = (e.target as HTMLInputElement).value;
if (inputType === SettingInputFieldType.NUMBER) {
value = Number(value) || 0;
}
};
</script>
<div class="w-full">
<div class={`flex place-items-center gap-1 h-[26px]`}>
<label class={`immich-form-label text-sm`} for={label}>{label}</label>
{#if required}
<div class="text-red-400">*</div>
{/if}
<div class={`flex place-items-center gap-1 h-[26px]`}>
<label class={`immich-form-label text-sm`} for={label}>{label}</label>
{#if required}
<div class="text-red-400">*</div>
{/if}
{#if isEdited}
<div
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
class="bg-orange-100 px-2 rounded-full text-orange-900 text-[10px]"
>
Unsaved change
</div>
{/if}
</div>
{#if isEdited}
<div
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
class="bg-orange-100 px-2 rounded-full text-orange-900 text-[10px]"
>
Unsaved change
</div>
{/if}
</div>
{#if desc}
<p class="immich-form-label text-xs pb-2" id="{label}-desc">
{desc}
</p>
{/if}
{#if desc}
<p class="immich-form-label text-xs pb-2" id="{label}-desc">
{desc}
</p>
{/if}
<input
class="immich-form-input pb-2 w-full"
aria-describedby={desc ? `${label}-desc` : undefined}
aria-labelledby="{label}-label"
id={label}
name={label}
type={inputType}
{required}
{value}
on:input={handleInput}
{disabled}
/>
<input
class="immich-form-input pb-2 w-full"
aria-describedby={desc ? `${label}-desc` : undefined}
aria-labelledby="{label}-label"
id={label}
name={label}
type={inputType}
{required}
{value}
on:input={handleInput}
{disabled}
/>
</div>

View file

@ -1,49 +1,49 @@
<script lang="ts">
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
export let value: string;
export let options: { value: string; text: string }[];
export let label = '';
export let desc = '';
export let name = '';
export let isEdited = false;
export let value: string;
export let options: { value: string; text: string }[];
export let label = '';
export let desc = '';
export let name = '';
export let isEdited = false;
const handleChange = (e: Event) => {
value = (e.target as HTMLInputElement).value;
};
const handleChange = (e: Event) => {
value = (e.target as HTMLInputElement).value;
};
</script>
<div class="w-full">
<div class={`flex place-items-center gap-1 h-[26px]`}>
<label class={`immich-form-label text-sm`} for="{name}-select">{label}</label>
<div class={`flex place-items-center gap-1 h-[26px]`}>
<label class={`immich-form-label text-sm`} for="{name}-select">{label}</label>
{#if isEdited}
<div
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
class="bg-orange-100 px-2 rounded-full text-orange-900 text-[10px]"
>
Unsaved change
</div>
{/if}
</div>
{#if isEdited}
<div
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
class="bg-orange-100 px-2 rounded-full text-orange-900 text-[10px]"
>
Unsaved change
</div>
{/if}
</div>
{#if desc}
<p class="immich-form-label text-xs pb-2" id="{name}-desc">
{desc}
</p>
{/if}
{#if desc}
<p class="immich-form-label text-xs pb-2" id="{name}-desc">
{desc}
</p>
{/if}
<select
class="immich-form-input pb-2 w-full"
aria-describedby={desc ? `${name}-desc` : undefined}
{name}
id="{name}-select"
bind:value
on:change={handleChange}
>
{#each options as option}
<option value={option.value}>{option.text}</option>
{/each}
</select>
<select
class="immich-form-input pb-2 w-full"
aria-describedby={desc ? `${name}-desc` : undefined}
{name}
id="{name}-select"
bind:value
on:change={handleChange}
>
{#each options as option}
<option value={option.value}>{option.text}</option>
{/each}
</select>
</div>

View file

@ -1,96 +1,90 @@
<script lang="ts">
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
export let title: string;
export let subtitle = '';
export let checked = false;
export let disabled = false;
export let isEdited = false;
export let title: string;
export let subtitle = '';
export let checked = false;
export let disabled = false;
export let isEdited = false;
</script>
<div class="flex justify-between place-items-center">
<div>
<div class="flex place-items-center gap-1 h-[26px]">
<label class="immich-form-label text-sm" for={title}>
{title}
</label>
{#if isEdited}
<div
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
class="bg-orange-100 px-2 rounded-full text-orange-900 text-[10px]"
>
Unsaved change
</div>
{/if}
</div>
<div>
<div class="flex place-items-center gap-1 h-[26px]">
<label class="immich-form-label text-sm" for={title}>
{title}
</label>
{#if isEdited}
<div
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
class="bg-orange-100 px-2 rounded-full text-orange-900 text-[10px]"
>
Unsaved change
</div>
{/if}
</div>
<p class="text-sm dark:text-immich-dark-fg">{subtitle}</p>
</div>
<p class="text-sm dark:text-immich-dark-fg">{subtitle}</p>
</div>
<label class="relative inline-block flex-none w-[36px] h-[10px]">
<input
class="opacity-0 w-0 h-0 disabled::cursor-not-allowed"
type="checkbox"
bind:checked
on:click
{disabled}
/>
<label class="relative inline-block flex-none w-[36px] h-[10px]">
<input class="opacity-0 w-0 h-0 disabled::cursor-not-allowed" type="checkbox" bind:checked on:click {disabled} />
{#if disabled}
<span class="slider-disable" />
{:else}
<span class="slider" />
{/if}
</label>
{#if disabled}
<span class="slider-disable" />
{:else}
<span class="slider" />
{/if}
</label>
</div>
<style>
.slider,
.slider-disable {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
-webkit-transition: 0.4s;
transition: 0.4s;
border-radius: 34px;
}
.slider,
.slider-disable {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
-webkit-transition: 0.4s;
transition: 0.4s;
border-radius: 34px;
}
input:disabled {
cursor: not-allowed;
}
input:disabled {
cursor: not-allowed;
}
.slider:before,
.slider-disable:before {
position: absolute;
content: '';
height: 20px;
width: 20px;
left: 0px;
right: 0px;
bottom: -4px;
background-color: gray;
-webkit-transition: 0.4s;
transition: 0.4s;
border-radius: 50%;
}
.slider:before,
.slider-disable:before {
position: absolute;
content: '';
height: 20px;
width: 20px;
left: 0px;
right: 0px;
bottom: -4px;
background-color: gray;
-webkit-transition: 0.4s;
transition: 0.4s;
border-radius: 50%;
}
input:checked + .slider-disable {
background-color: gray;
}
input:checked + .slider-disable {
background-color: gray;
}
input:checked + .slider {
background-color: #adcbfa;
}
input:checked + .slider {
background-color: #adcbfa;
}
input:checked + .slider:before {
-webkit-transform: translateX(18px);
-ms-transform: translateX(18px);
transform: translateX(18px);
background-color: #4250af;
}
input:checked + .slider:before {
-webkit-transform: translateX(18px);
-ms-transform: translateX(18px);
transform: translateX(18px);
background-color: #4250af;
}
</style>

View file

@ -1,241 +1,224 @@
<script lang="ts">
import {
api,
SystemConfigStorageTemplateDto,
SystemConfigTemplateStorageOptionDto,
UserResponseDto
} from '@api';
import * as luxon from 'luxon';
import handlebar from 'handlebars';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { fade } from 'svelte/transition';
import SupportedDatetimePanel from './supported-datetime-panel.svelte';
import SupportedVariablesPanel from './supported-variables-panel.svelte';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import { isEqual } from 'lodash-es';
import {
notificationController,
NotificationType
} from '$lib/components/shared-components/notification/notification';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
import { api, SystemConfigStorageTemplateDto, SystemConfigTemplateStorageOptionDto, UserResponseDto } from '@api';
import * as luxon from 'luxon';
import handlebar from 'handlebars';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { fade } from 'svelte/transition';
import SupportedDatetimePanel from './supported-datetime-panel.svelte';
import SupportedVariablesPanel from './supported-variables-panel.svelte';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import { isEqual } from 'lodash-es';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
export let storageConfig: SystemConfigStorageTemplateDto;
export let user: UserResponseDto;
export let storageConfig: SystemConfigStorageTemplateDto;
export let user: UserResponseDto;
let savedConfig: SystemConfigStorageTemplateDto;
let defaultConfig: SystemConfigStorageTemplateDto;
let templateOptions: SystemConfigTemplateStorageOptionDto;
let selectedPreset = '';
let savedConfig: SystemConfigStorageTemplateDto;
let defaultConfig: SystemConfigStorageTemplateDto;
let templateOptions: SystemConfigTemplateStorageOptionDto;
let selectedPreset = '';
async function getConfigs() {
[savedConfig, defaultConfig, templateOptions] = await Promise.all([
api.systemConfigApi.getConfig().then((res) => res.data.storageTemplate),
api.systemConfigApi.getDefaults().then((res) => res.data.storageTemplate),
api.systemConfigApi.getStorageTemplateOptions().then((res) => res.data)
]);
async function getConfigs() {
[savedConfig, defaultConfig, templateOptions] = await Promise.all([
api.systemConfigApi.getConfig().then((res) => res.data.storageTemplate),
api.systemConfigApi.getDefaults().then((res) => res.data.storageTemplate),
api.systemConfigApi.getStorageTemplateOptions().then((res) => res.data),
]);
selectedPreset = savedConfig.template;
}
selectedPreset = savedConfig.template;
}
const getSupportDateTimeFormat = async () => {
const { data } = await api.systemConfigApi.getStorageTemplateOptions();
return data;
};
const getSupportDateTimeFormat = async () => {
const { data } = await api.systemConfigApi.getStorageTemplateOptions();
return data;
};
$: parsedTemplate = () => {
try {
return renderTemplate(storageConfig.template);
} catch (error) {
return 'error';
}
};
$: parsedTemplate = () => {
try {
return renderTemplate(storageConfig.template);
} catch (error) {
return 'error';
}
};
const renderTemplate = (templateString: string) => {
const template = handlebar.compile(templateString, {
knownHelpers: undefined
});
const renderTemplate = (templateString: string) => {
const template = handlebar.compile(templateString, {
knownHelpers: undefined,
});
const substitutions: Record<string, string> = {
filename: 'IMAGE_56437',
ext: 'jpg',
filetype: 'IMG',
filetypefull: 'IMAGE'
};
const substitutions: Record<string, string> = {
filename: 'IMAGE_56437',
ext: 'jpg',
filetype: 'IMG',
filetypefull: 'IMAGE',
};
const dt = luxon.DateTime.fromISO(new Date('2022-09-04T20:03:05.250').toISOString());
const dt = luxon.DateTime.fromISO(new Date('2022-09-04T20:03:05.250').toISOString());
const dateTokens = [
...templateOptions.yearOptions,
...templateOptions.monthOptions,
...templateOptions.dayOptions,
...templateOptions.hourOptions,
...templateOptions.minuteOptions,
...templateOptions.secondOptions
];
const dateTokens = [
...templateOptions.yearOptions,
...templateOptions.monthOptions,
...templateOptions.dayOptions,
...templateOptions.hourOptions,
...templateOptions.minuteOptions,
...templateOptions.secondOptions,
];
for (const token of dateTokens) {
substitutions[token] = dt.toFormat(token);
}
for (const token of dateTokens) {
substitutions[token] = dt.toFormat(token);
}
return template(substitutions);
};
return template(substitutions);
};
async function reset() {
const { data: resetConfig } = await api.systemConfigApi.getConfig();
async function reset() {
const { data: resetConfig } = await api.systemConfigApi.getConfig();
storageConfig.template = resetConfig.storageTemplate.template;
savedConfig.template = resetConfig.storageTemplate.template;
storageConfig.template = resetConfig.storageTemplate.template;
savedConfig.template = resetConfig.storageTemplate.template;
notificationController.show({
message: 'Reset storage template settings to the recent saved settings',
type: NotificationType.Info
});
}
notificationController.show({
message: 'Reset storage template settings to the recent saved settings',
type: NotificationType.Info,
});
}
async function saveSetting() {
try {
const { data: currentConfig } = await api.systemConfigApi.getConfig();
async function saveSetting() {
try {
const { data: currentConfig } = await api.systemConfigApi.getConfig();
const result = await api.systemConfigApi.updateConfig({
systemConfigDto: {
...currentConfig,
storageTemplate: storageConfig
}
});
const result = await api.systemConfigApi.updateConfig({
systemConfigDto: {
...currentConfig,
storageTemplate: storageConfig,
},
});
storageConfig.template = result.data.storageTemplate.template;
savedConfig.template = result.data.storageTemplate.template;
storageConfig.template = result.data.storageTemplate.template;
savedConfig.template = result.data.storageTemplate.template;
notificationController.show({
message: 'Storage template saved',
type: NotificationType.Info
});
} catch (e) {
console.error('Error [storage-template-settings] [saveSetting]', e);
notificationController.show({
message: 'Unable to save settings',
type: NotificationType.Error
});
}
}
notificationController.show({
message: 'Storage template saved',
type: NotificationType.Info,
});
} catch (e) {
console.error('Error [storage-template-settings] [saveSetting]', e);
notificationController.show({
message: 'Unable to save settings',
type: NotificationType.Error,
});
}
}
async function resetToDefault() {
const { data: defaultConfig } = await api.systemConfigApi.getDefaults();
async function resetToDefault() {
const { data: defaultConfig } = await api.systemConfigApi.getDefaults();
storageConfig.template = defaultConfig.storageTemplate.template;
storageConfig.template = defaultConfig.storageTemplate.template;
notificationController.show({
message: 'Reset storage template to default',
type: NotificationType.Info
});
}
notificationController.show({
message: 'Reset storage template to default',
type: NotificationType.Info,
});
}
const handlePresetSelection = () => {
storageConfig.template = selectedPreset;
};
const handlePresetSelection = () => {
storageConfig.template = selectedPreset;
};
</script>
<section class="dark:text-immich-dark-fg">
{#await getConfigs() then}
<div id="directory-path-builder" class="m-4">
<h3 class="font-medium text-immich-primary dark:text-immich-dark-primary text-base">
Variables
</h3>
{#await getConfigs() then}
<div id="directory-path-builder" class="m-4">
<h3 class="font-medium text-immich-primary dark:text-immich-dark-primary text-base">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">
{#await getSupportDateTimeFormat()}
<LoadingSpinner />
{:then options}
<div transition:fade={{ duration: 200 }}>
<SupportedDatetimePanel {options} />
</div>
{/await}
</section>
<section class="support-date">
<SupportedVariablesPanel />
</section>
<section class="support-date">
<SupportedVariablesPanel />
</section>
<div class="mt-4 flex flex-col">
<h3 class="font-medium text-immich-primary dark:text-immich-dark-primary text-base">
Template
</h3>
<div class="mt-4 flex flex-col">
<h3 class="font-medium text-immich-primary dark:text-immich-dark-primary text-base">Template</h3>
<div class="text-xs my-2">
<h4>PREVIEW</h4>
</div>
<div class="text-xs my-2">
<h4>PREVIEW</h4>
</div>
<p class="text-xs">
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-xs">
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-xs">
<code>{user.storageLabel || user.id}</code> is the user's Storage Label
</p>
<p class="text-xs">
<code>{user.storageLabel || user.id}</code> is the user's Storage Label
</p>
<p
class="text-xs p-4 bg-gray-200 dark:bg-gray-700 dark:text-immich-dark-fg py-2 rounded-lg mt-2"
>
<span class="text-immich-fg/25 dark:text-immich-dark-fg/50"
>UPLOAD_LOCATION/{user.storageLabel || user.id}</span
>/{parsedTemplate()}.jpg
</p>
<p class="text-xs p-4 bg-gray-200 dark:bg-gray-700 dark:text-immich-dark-fg py-2 rounded-lg mt-2">
<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-xs" for="presets">PRESET</label>
<select
class="text-sm bg-slate-200 p-2 rounded-lg mt-2 dark:bg-gray-600 hover:cursor-pointer"
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"
required
inputType={SettingInputFieldType.TEXT}
bind:value={storageConfig.template}
isEdited={!(storageConfig.template === savedConfig.template)}
/>
<form autocomplete="off" class="flex flex-col" on:submit|preventDefault>
<div class="flex flex-col my-2">
<label class="text-xs" for="presets">PRESET</label>
<select
class="text-sm bg-slate-200 p-2 rounded-lg mt-2 dark:bg-gray-600 hover:cursor-pointer"
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"
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>
<div class="flex-0">
<SettingInputField label="EXTENSION" inputType={SettingInputFieldType.TEXT} value={'.jpg'} disabled />
</div>
</div>
<div id="migration-info" class="text-sm mt-4">
<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>
</div>
<div id="migration-info" class="text-sm mt-4">
<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>
</div>
<SettingButtonsRow
on:reset={reset}
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
/>
</form>
</div>
</div>
{/await}
<SettingButtonsRow
on:reset={reset}
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
/>
</form>
</div>
</div>
{/await}
</section>

View file

@ -1,78 +1,76 @@
<script lang="ts">
import type { SystemConfigTemplateStorageOptionDto } from '@api';
import * as luxon from 'luxon';
import type { SystemConfigTemplateStorageOptionDto } from '@api';
import * as luxon from 'luxon';
export let options: SystemConfigTemplateStorageOptionDto;
export let options: SystemConfigTemplateStorageOptionDto;
const getLuxonExample = (format: string) => {
return luxon.DateTime.fromISO(new Date('2022-09-04T20:03:05.250').toISOString()).toFormat(
format
);
};
const getLuxonExample = (format: string) => {
return luxon.DateTime.fromISO(new Date('2022-09-04T20:03:05.250').toISOString()).toFormat(format);
};
</script>
<div class="text-xs mt-2">
<h4>DATE & TIME</h4>
<h4>DATE & TIME</h4>
</div>
<div class="text-xs bg-gray-200 dark:bg-gray-700 dark:text-immich-dark-fg p-4 mt-2 rounded-lg">
<div class="mb-2 text-gray-600 dark:text-immich-dark-fg">
<p>Asset's creation timestamp is used for the datetime information</p>
<p>Sample time 2022-09-04T20:03:05.250</p>
</div>
<div class="flex gap-[50px]">
<div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">YEAR</p>
<ul>
{#each options.yearOptions as yearFormat}
<li>{'{{'}{yearFormat}{'}}'} - {getLuxonExample(yearFormat)}</li>
{/each}
</ul>
</div>
<div class="mb-2 text-gray-600 dark:text-immich-dark-fg">
<p>Asset's creation timestamp is used for the datetime information</p>
<p>Sample time 2022-09-04T20:03:05.250</p>
</div>
<div class="flex gap-[50px]">
<div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">YEAR</p>
<ul>
{#each options.yearOptions as yearFormat}
<li>{'{{'}{yearFormat}{'}}'} - {getLuxonExample(yearFormat)}</li>
{/each}
</ul>
</div>
<div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">MONTH</p>
<ul>
{#each options.monthOptions as monthFormat}
<li>{'{{'}{monthFormat}{'}}'} - {getLuxonExample(monthFormat)}</li>
{/each}
</ul>
</div>
<div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">MONTH</p>
<ul>
{#each options.monthOptions as monthFormat}
<li>{'{{'}{monthFormat}{'}}'} - {getLuxonExample(monthFormat)}</li>
{/each}
</ul>
</div>
<div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">DAY</p>
<ul>
{#each options.dayOptions as dayFormat}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each}
</ul>
</div>
<div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">DAY</p>
<ul>
{#each options.dayOptions as dayFormat}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each}
</ul>
</div>
<div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">HOUR</p>
<ul>
{#each options.hourOptions as dayFormat}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each}
</ul>
</div>
<div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">HOUR</p>
<ul>
{#each options.hourOptions as dayFormat}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each}
</ul>
</div>
<div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">MINUTE</p>
<ul>
{#each options.minuteOptions as dayFormat}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each}
</ul>
</div>
<div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">MINUTE</p>
<ul>
{#each options.minuteOptions as dayFormat}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each}
</ul>
</div>
<div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">SECOND</p>
<ul>
{#each options.secondOptions as dayFormat}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each}
</ul>
</div>
</div>
<div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">SECOND</p>
<ul>
{#each options.secondOptions as dayFormat}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each}
</ul>
</div>
</div>
</div>

View file

@ -1,29 +1,29 @@
<div class="text-xs mt-4">
<h4>OTHER VARIABLES</h4>
<h4>OTHER VARIABLES</h4>
</div>
<div class="text-xs bg-gray-200 dark:bg-gray-700 dark:text-immich-dark-fg p-4 mt-2 rounded-lg">
<div class="flex gap-[50px]">
<div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">FILE NAME</p>
<ul>
<li>{`{{filename}}`}</li>
</ul>
</div>
<div class="flex gap-[50px]">
<div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">FILE NAME</p>
<ul>
<li>{`{{filename}}`}</li>
</ul>
</div>
<div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">FILE EXTENSION</p>
<ul>
<li>{`{{ext}}`}</li>
</ul>
</div>
<div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">FILE EXTENSION</p>
<ul>
<li>{`{{ext}}`}</li>
</ul>
</div>
<div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">FILE TYPE</p>
<ul>
<li>{`{{filetype}}`} - VID or IMG</li>
<li>{`{{filetypefull}}`} - VIDEO or IMAGE</li>
</ul>
</div>
</div>
<div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">FILE TYPE</p>
<ul>
<li>{`{{filetype}}`} - VID or IMG</li>
<li>{`{{filetypefull}}`} - VIDEO or IMAGE</li>
</ul>
</div>
</div>
</div>

View file

@ -1,141 +1,136 @@
import { jest, describe, it } from '@jest/globals';
import { render, RenderResult, waitFor, fireEvent } from '@testing-library/svelte';
import { createObjectURLMock } from '$lib/__mocks__/jsdom-url.mock';
import { api, ThumbnailFormat } from '@api';
import { describe, it, jest } from '@jest/globals';
import { albumFactory } from '@test-data';
import AlbumCard from '../album-card.svelte';
import '@testing-library/jest-dom';
import { fireEvent, render, RenderResult, waitFor } from '@testing-library/svelte';
import AlbumCard from '../album-card.svelte';
jest.mock('@api');
const apiMock: jest.MockedObject<typeof api> = api as jest.MockedObject<typeof api>;
describe('AlbumCard component', () => {
let sut: RenderResult<AlbumCard>;
let sut: RenderResult<AlbumCard>;
it.each([
{
album: albumFactory.build({ albumThumbnailAssetId: null, shared: false, assetCount: 0 }),
count: 0,
shared: false
},
{
album: albumFactory.build({ albumThumbnailAssetId: null, shared: true, assetCount: 0 }),
count: 0,
shared: true
},
{
album: albumFactory.build({ albumThumbnailAssetId: null, shared: false, assetCount: 5 }),
count: 5,
shared: false
},
{
album: albumFactory.build({ albumThumbnailAssetId: null, shared: true, assetCount: 2 }),
count: 2,
shared: true
}
])(
'shows album data without thumbnail with count $count - shared: $shared',
async ({ album, count, shared }) => {
sut = render(AlbumCard, { album, user: album.owner });
it.each([
{
album: albumFactory.build({ albumThumbnailAssetId: null, shared: false, assetCount: 0 }),
count: 0,
shared: false,
},
{
album: albumFactory.build({ albumThumbnailAssetId: null, shared: true, assetCount: 0 }),
count: 0,
shared: true,
},
{
album: albumFactory.build({ albumThumbnailAssetId: null, shared: false, assetCount: 5 }),
count: 5,
shared: false,
},
{
album: albumFactory.build({ albumThumbnailAssetId: null, shared: true, assetCount: 2 }),
count: 2,
shared: true,
},
])('shows album data without thumbnail with count $count - shared: $shared', async ({ album, count, shared }) => {
sut = render(AlbumCard, { album, user: album.owner });
const albumImgElement = sut.getByTestId('album-image');
const albumNameElement = sut.getByTestId('album-name');
const albumDetailsElement = sut.getByTestId('album-details');
const detailsText = `${count} items` + (shared ? ' . Shared' : '');
const albumImgElement = sut.getByTestId('album-image');
const albumNameElement = sut.getByTestId('album-name');
const albumDetailsElement = sut.getByTestId('album-details');
const detailsText = `${count} items` + (shared ? ' . Shared' : '');
expect(albumImgElement).toHaveAttribute('src');
expect(albumImgElement).toHaveAttribute('alt', album.id);
expect(albumImgElement).toHaveAttribute('src');
expect(albumImgElement).toHaveAttribute('alt', album.id);
await waitFor(() => expect(albumImgElement).toHaveAttribute('src'));
await waitFor(() => expect(albumImgElement).toHaveAttribute('src'));
expect(albumImgElement).toHaveAttribute('alt', album.id);
expect(apiMock.assetApi.getAssetThumbnail).not.toHaveBeenCalled();
expect(albumImgElement).toHaveAttribute('alt', album.id);
expect(apiMock.assetApi.getAssetThumbnail).not.toHaveBeenCalled();
expect(albumNameElement).toHaveTextContent(album.albumName);
expect(albumDetailsElement).toHaveTextContent(new RegExp(detailsText));
}
);
expect(albumNameElement).toHaveTextContent(album.albumName);
expect(albumDetailsElement).toHaveTextContent(new RegExp(detailsText));
});
it('shows album data and and loads the thumbnail image when available', async () => {
const thumbnailFile = new File([new Blob()], 'fileThumbnail');
const thumbnailUrl = 'blob:thumbnailUrlOne';
apiMock.assetApi.getAssetThumbnail.mockResolvedValue({
data: thumbnailFile,
config: {},
headers: {},
status: 200,
statusText: ''
});
createObjectURLMock.mockReturnValueOnce(thumbnailUrl);
it('shows album data and and loads the thumbnail image when available', async () => {
const thumbnailFile = new File([new Blob()], 'fileThumbnail');
const thumbnailUrl = 'blob:thumbnailUrlOne';
apiMock.assetApi.getAssetThumbnail.mockResolvedValue({
data: thumbnailFile,
config: {},
headers: {},
status: 200,
statusText: '',
});
createObjectURLMock.mockReturnValueOnce(thumbnailUrl);
const album = albumFactory.build({
albumThumbnailAssetId: 'thumbnailIdOne',
shared: false,
albumName: 'some album name'
});
sut = render(AlbumCard, { album, user: album.owner });
const album = albumFactory.build({
albumThumbnailAssetId: 'thumbnailIdOne',
shared: false,
albumName: 'some album name',
});
sut = render(AlbumCard, { album, user: album.owner });
const albumImgElement = sut.getByTestId('album-image');
const albumNameElement = sut.getByTestId('album-name');
const albumDetailsElement = sut.getByTestId('album-details');
expect(albumImgElement).toHaveAttribute('alt', album.id);
const albumImgElement = sut.getByTestId('album-image');
const albumNameElement = sut.getByTestId('album-name');
const albumDetailsElement = sut.getByTestId('album-details');
expect(albumImgElement).toHaveAttribute('alt', album.id);
await waitFor(() => expect(albumImgElement).toHaveAttribute('src', thumbnailUrl));
await waitFor(() => expect(albumImgElement).toHaveAttribute('src', thumbnailUrl));
expect(albumImgElement).toHaveAttribute('alt', album.id);
expect(apiMock.assetApi.getAssetThumbnail).toHaveBeenCalledTimes(1);
expect(apiMock.assetApi.getAssetThumbnail).toHaveBeenCalledWith(
{
id: 'thumbnailIdOne',
format: ThumbnailFormat.Jpeg
},
{ responseType: 'blob' }
);
expect(createObjectURLMock).toHaveBeenCalledWith(thumbnailFile);
expect(albumImgElement).toHaveAttribute('alt', album.id);
expect(apiMock.assetApi.getAssetThumbnail).toHaveBeenCalledTimes(1);
expect(apiMock.assetApi.getAssetThumbnail).toHaveBeenCalledWith(
{
id: 'thumbnailIdOne',
format: ThumbnailFormat.Jpeg,
},
{ responseType: 'blob' },
);
expect(createObjectURLMock).toHaveBeenCalledWith(thumbnailFile);
expect(albumNameElement).toHaveTextContent('some album name');
expect(albumDetailsElement).toHaveTextContent('0 items');
});
expect(albumNameElement).toHaveTextContent('some album name');
expect(albumDetailsElement).toHaveTextContent('0 items');
});
describe('with rendered component - no thumbnail', () => {
const album = Object.freeze(albumFactory.build({ albumThumbnailAssetId: null }));
describe('with rendered component - no thumbnail', () => {
const album = Object.freeze(albumFactory.build({ albumThumbnailAssetId: null }));
beforeEach(async () => {
sut = render(AlbumCard, { album, user: album.owner });
beforeEach(async () => {
sut = render(AlbumCard, { album, user: album.owner });
const albumImgElement = sut.getByTestId('album-image');
await waitFor(() => expect(albumImgElement).toHaveAttribute('src'));
});
const albumImgElement = sut.getByTestId('album-image');
await waitFor(() => expect(albumImgElement).toHaveAttribute('src'));
});
it('dispatches custom "click" event with the album in context', async () => {
const onClickHandler = jest.fn();
sut.component.$on('click', onClickHandler);
const albumCardElement = sut.getByTestId('album-card');
it('dispatches custom "click" event with the album in context', async () => {
const onClickHandler = jest.fn();
sut.component.$on('click', onClickHandler);
const albumCardElement = sut.getByTestId('album-card');
await fireEvent.click(albumCardElement);
expect(onClickHandler).toHaveBeenCalledTimes(1);
expect(onClickHandler).toHaveBeenCalledWith(expect.objectContaining({ detail: album }));
});
await fireEvent.click(albumCardElement);
expect(onClickHandler).toHaveBeenCalledTimes(1);
expect(onClickHandler).toHaveBeenCalledWith(expect.objectContaining({ detail: album }));
});
it('dispatches custom "click" event on context menu click with mouse coordinates', async () => {
const onClickHandler = jest.fn();
sut.component.$on('showalbumcontextmenu', onClickHandler);
it('dispatches custom "click" event on context menu click with mouse coordinates', async () => {
const onClickHandler = jest.fn();
sut.component.$on('showalbumcontextmenu', onClickHandler);
const contextMenuBtnParent = sut.getByTestId('context-button-parent');
const contextMenuBtnParent = sut.getByTestId('context-button-parent');
await fireEvent(
contextMenuBtnParent,
new MouseEvent('click', {
clientX: 123,
clientY: 456
})
);
await fireEvent(
contextMenuBtnParent,
new MouseEvent('click', {
clientX: 123,
clientY: 456,
}),
);
expect(onClickHandler).toHaveBeenCalledTimes(1);
expect(onClickHandler).toHaveBeenCalledWith(
expect.objectContaining({ detail: { x: 123, y: 456 } })
);
});
});
expect(onClickHandler).toHaveBeenCalledTimes(1);
expect(onClickHandler).toHaveBeenCalledWith(expect.objectContaining({ detail: { x: 123, y: 456 } }));
});
});
});

View file

@ -1,133 +1,133 @@
<script lang="ts">
import noThumbnailUrl from '$lib/assets/no-thumbnail.png';
import { locale } from '$lib/stores/preferences.store';
import { AlbumResponseDto, api, ThumbnailFormat, UserResponseDto } from '@api';
import { createEventDispatcher, onMount } from 'svelte';
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
import IconButton from '../elements/buttons/icon-button.svelte';
import type { OnClick, OnShowContextMenu } from './album-card';
import noThumbnailUrl from '$lib/assets/no-thumbnail.png';
import { locale } from '$lib/stores/preferences.store';
import { AlbumResponseDto, api, ThumbnailFormat, UserResponseDto } from '@api';
import { createEventDispatcher, onMount } from 'svelte';
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
import IconButton from '../elements/buttons/icon-button.svelte';
import type { OnClick, OnShowContextMenu } from './album-card';
export let album: AlbumResponseDto;
export let isSharingView = false;
export let user: UserResponseDto;
export let showItemCount = true;
export let showContextMenu = true;
export let album: AlbumResponseDto;
export let isSharingView = false;
export let user: UserResponseDto;
export let showItemCount = true;
export let showContextMenu = true;
$: imageData = album.albumThumbnailAssetId
? api.getAssetThumbnailUrl(album.albumThumbnailAssetId, ThumbnailFormat.Webp)
: noThumbnailUrl;
$: imageData = album.albumThumbnailAssetId
? api.getAssetThumbnailUrl(album.albumThumbnailAssetId, ThumbnailFormat.Webp)
: noThumbnailUrl;
const dispatchClick = createEventDispatcher<OnClick>();
const dispatchShowContextMenu = createEventDispatcher<OnShowContextMenu>();
const dispatchClick = createEventDispatcher<OnClick>();
const dispatchShowContextMenu = createEventDispatcher<OnShowContextMenu>();
const loadHighQualityThumbnail = async (thubmnailId: string | null) => {
if (thubmnailId == null) {
return;
}
const loadHighQualityThumbnail = async (thubmnailId: string | null) => {
if (thubmnailId == null) {
return;
}
const { data } = await api.assetApi.getAssetThumbnail(
{
id: thubmnailId,
format: ThumbnailFormat.Jpeg
},
{
responseType: 'blob'
}
);
const { data } = await api.assetApi.getAssetThumbnail(
{
id: thubmnailId,
format: ThumbnailFormat.Jpeg,
},
{
responseType: 'blob',
},
);
if (data instanceof Blob) {
return URL.createObjectURL(data);
}
};
if (data instanceof Blob) {
return URL.createObjectURL(data);
}
};
const showAlbumContextMenu = (e: MouseEvent) => {
dispatchShowContextMenu('showalbumcontextmenu', {
x: e.clientX,
y: e.clientY
});
};
const showAlbumContextMenu = (e: MouseEvent) => {
dispatchShowContextMenu('showalbumcontextmenu', {
x: e.clientX,
y: e.clientY,
});
};
onMount(async () => {
imageData = (await loadHighQualityThumbnail(album.albumThumbnailAssetId)) || noThumbnailUrl;
});
onMount(async () => {
imageData = (await loadHighQualityThumbnail(album.albumThumbnailAssetId)) || noThumbnailUrl;
});
const getAlbumOwnerInfo = async (): Promise<UserResponseDto> => {
const { data } = await api.userApi.getUserById({ userId: album.ownerId });
const getAlbumOwnerInfo = async (): Promise<UserResponseDto> => {
const { data } = await api.userApi.getUserById({ userId: album.ownerId });
return data;
};
return data;
};
</script>
<div
class="group hover:cursor-pointer mt-4 border-[3px] border-transparent dark:hover:border-immich-dark-primary/75 hover:border-immich-primary/75 rounded-3xl p-5 relative"
on:click={() => dispatchClick('click', album)}
on:keydown={() => dispatchClick('click', album)}
data-testid="album-card"
class="group hover:cursor-pointer mt-4 border-[3px] border-transparent dark:hover:border-immich-dark-primary/75 hover:border-immich-primary/75 rounded-3xl p-5 relative"
on:click={() => dispatchClick('click', album)}
on:keydown={() => dispatchClick('click', album)}
data-testid="album-card"
>
<!-- svelte-ignore a11y-click-events-have-key-events -->
{#if showContextMenu}
<div
id={`icon-${album.id}`}
class="absolute top-6 right-6 z-10"
on:click|stopPropagation|preventDefault={showAlbumContextMenu}
data-testid="context-button-parent"
>
<IconButton color="overlay-primary">
<DotsVertical size="20" />
</IconButton>
</div>
{/if}
<!-- svelte-ignore a11y-click-events-have-key-events -->
{#if showContextMenu}
<div
id={`icon-${album.id}`}
class="absolute top-6 right-6 z-10"
on:click|stopPropagation|preventDefault={showAlbumContextMenu}
data-testid="context-button-parent"
>
<IconButton color="overlay-primary">
<DotsVertical size="20" />
</IconButton>
</div>
{/if}
<div class={`aspect-square relative`}>
<img
src={imageData}
alt={album.id}
class={`object-cover h-full w-full transition-all z-0 rounded-3xl duration-300 hover:shadow-lg`}
data-testid="album-image"
draggable="false"
/>
<div
class="w-full h-full absolute top-0 rounded-3xl {isSharingView
? 'group-hover:bg-yellow-800/25'
: 'group-hover:bg-indigo-800/25'} "
/>
</div>
<div class={`aspect-square relative`}>
<img
src={imageData}
alt={album.id}
class={`object-cover h-full w-full transition-all z-0 rounded-3xl duration-300 hover:shadow-lg`}
data-testid="album-image"
draggable="false"
/>
<div
class="w-full h-full absolute top-0 rounded-3xl {isSharingView
? 'group-hover:bg-yellow-800/25'
: 'group-hover:bg-indigo-800/25'} "
/>
</div>
<div class="mt-4">
<p
class="text-xl font-semibold dark:text-immich-dark-primary text-immich-primary w-full truncate"
data-testid="album-name"
title={album.albumName}
>
{album.albumName}
</p>
<div class="mt-4">
<p
class="text-xl font-semibold dark:text-immich-dark-primary text-immich-primary w-full truncate"
data-testid="album-name"
title={album.albumName}
>
{album.albumName}
</p>
<span class="text-sm flex gap-2 dark:text-immich-dark-fg" data-testid="album-details">
{#if showItemCount}
<p>
{album.assetCount.toLocaleString($locale)}
{album.assetCount == 1 ? `item` : `items`}
</p>
{/if}
<span class="text-sm flex gap-2 dark:text-immich-dark-fg" data-testid="album-details">
{#if showItemCount}
<p>
{album.assetCount.toLocaleString($locale)}
{album.assetCount == 1 ? `item` : `items`}
</p>
{/if}
{#if isSharingView || album.shared}
<p>·</p>
{/if}
{#if isSharingView || album.shared}
<p>·</p>
{/if}
{#if isSharingView}
{#await getAlbumOwnerInfo() then albumOwner}
{#if user.email == albumOwner.email}
<p>Owned</p>
{:else}
<p>
Shared by {albumOwner.firstName}
{albumOwner.lastName}
</p>
{/if}
{/await}
{:else if album.shared}
<p>Shared</p>
{/if}
</span>
</div>
{#if isSharingView}
{#await getAlbumOwnerInfo() then albumOwner}
{#if user.email == albumOwner.email}
<p>Owned</p>
{:else}
<p>
Shared by {albumOwner.firstName}
{albumOwner.lastName}
</p>
{/if}
{/await}
{:else if album.shared}
<p>Shared</p>
{/if}
</span>
</div>
</div>

View file

@ -1,11 +1,11 @@
import type { AlbumResponseDto } from '@api';
export type OnShowContextMenu = {
showalbumcontextmenu: OnShowContextMenuDetail;
showalbumcontextmenu: OnShowContextMenuDetail;
};
export type OnClick = {
click: OnClickDetail;
click: OnClickDetail;
};
export type OnShowContextMenuDetail = { x: number; y: number };

View file

@ -1,533 +1,490 @@
<script lang="ts">
import { browser } from '$app/environment';
import { afterNavigate, goto } from '$app/navigation';
import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { locale } from '$lib/stores/preferences.store';
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import {
AlbumResponseDto,
AssetResponseDto,
SharedLinkResponseDto,
SharedLinkType,
UserResponseDto,
api
} from '@api';
import { onMount } from 'svelte';
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
import FileImagePlusOutline from 'svelte-material-icons/FileImagePlusOutline.svelte';
import FolderDownloadOutline from 'svelte-material-icons/FolderDownloadOutline.svelte';
import Plus from 'svelte-material-icons/Plus.svelte';
import ShareVariantOutline from 'svelte-material-icons/ShareVariantOutline.svelte';
import Button from '../elements/buttons/button.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import DownloadAction from '../photos-page/actions/download-action.svelte';
import RemoveFromAlbum from '../photos-page/actions/remove-from-album.svelte';
import AssetSelectControlBar from '../photos-page/asset-select-control-bar.svelte';
import UserAvatar from '../shared-components/user-avatar.svelte';
import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import CreateSharedLinkModal from '../shared-components/create-share-link-modal/create-shared-link-modal.svelte';
import GalleryViewer from '../shared-components/gallery-viewer/gallery-viewer.svelte';
import ImmichLogo from '../shared-components/immich-logo.svelte';
import SelectAll from 'svelte-material-icons/SelectAll.svelte';
import {
NotificationType,
notificationController
} from '../shared-components/notification/notification';
import ThemeButton from '../shared-components/theme-button.svelte';
import AssetSelection from './asset-selection.svelte';
import ShareInfoModal from './share-info-modal.svelte';
import ThumbnailSelection from './thumbnail-selection.svelte';
import UserSelectionModal from './user-selection-modal.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import { handleError } from '../../utils/handle-error';
import { downloadArchive } from '../../utils/asset-utils';
import { browser } from '$app/environment';
import { afterNavigate, goto } from '$app/navigation';
import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { locale } from '$lib/stores/preferences.store';
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import {
AlbumResponseDto,
AssetResponseDto,
SharedLinkResponseDto,
SharedLinkType,
UserResponseDto,
api,
} from '@api';
import { onMount } from 'svelte';
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
import FileImagePlusOutline from 'svelte-material-icons/FileImagePlusOutline.svelte';
import FolderDownloadOutline from 'svelte-material-icons/FolderDownloadOutline.svelte';
import Plus from 'svelte-material-icons/Plus.svelte';
import ShareVariantOutline from 'svelte-material-icons/ShareVariantOutline.svelte';
import Button from '../elements/buttons/button.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import DownloadAction from '../photos-page/actions/download-action.svelte';
import RemoveFromAlbum from '../photos-page/actions/remove-from-album.svelte';
import AssetSelectControlBar from '../photos-page/asset-select-control-bar.svelte';
import UserAvatar from '../shared-components/user-avatar.svelte';
import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import CreateSharedLinkModal from '../shared-components/create-share-link-modal/create-shared-link-modal.svelte';
import GalleryViewer from '../shared-components/gallery-viewer/gallery-viewer.svelte';
import ImmichLogo from '../shared-components/immich-logo.svelte';
import SelectAll from 'svelte-material-icons/SelectAll.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import ThemeButton from '../shared-components/theme-button.svelte';
import AssetSelection from './asset-selection.svelte';
import ShareInfoModal from './share-info-modal.svelte';
import ThumbnailSelection from './thumbnail-selection.svelte';
import UserSelectionModal from './user-selection-modal.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import { handleError } from '../../utils/handle-error';
import { downloadArchive } from '../../utils/asset-utils';
export let album: AlbumResponseDto;
export let sharedLink: SharedLinkResponseDto | undefined = undefined;
export let album: AlbumResponseDto;
export let sharedLink: SharedLinkResponseDto | undefined = undefined;
const { isAlbumAssetSelectionOpen } = albumAssetSelectionStore;
const { isAlbumAssetSelectionOpen } = albumAssetSelectionStore;
let isShowAssetSelection = false;
let isShowAssetSelection = false;
let isShowShareLinkModal = false;
let isShowShareLinkModal = false;
$: $isAlbumAssetSelectionOpen = isShowAssetSelection;
$: {
if (browser) {
if (isShowAssetSelection) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'auto';
}
}
}
let isShowShareUserSelection = false;
let isEditingTitle = false;
let isCreatingSharedAlbum = false;
let isShowShareInfoModal = false;
let isShowAlbumOptions = false;
let isShowThumbnailSelection = false;
let isShowDeleteConfirmation = false;
$: $isAlbumAssetSelectionOpen = isShowAssetSelection;
$: {
if (browser) {
if (isShowAssetSelection) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'auto';
}
}
}
let isShowShareUserSelection = false;
let isEditingTitle = false;
let isCreatingSharedAlbum = false;
let isShowShareInfoModal = false;
let isShowAlbumOptions = false;
let isShowThumbnailSelection = false;
let isShowDeleteConfirmation = false;
let backUrl = '/albums';
let currentAlbumName = '';
let currentUser: UserResponseDto;
let titleInput: HTMLInputElement;
let contextMenuPosition = { x: 0, y: 0 };
let backUrl = '/albums';
let currentAlbumName = '';
let currentUser: UserResponseDto;
let titleInput: HTMLInputElement;
let contextMenuPosition = { x: 0, y: 0 };
$: isPublicShared = sharedLink;
$: isOwned = currentUser?.id == album.ownerId;
$: isPublicShared = sharedLink;
$: isOwned = currentUser?.id == album.ownerId;
dragAndDropFilesStore.subscribe((value) => {
if (value.isDragging && value.files.length > 0) {
fileUploadHandler(value.files, album.id, sharedLink?.key);
dragAndDropFilesStore.set({ isDragging: false, files: [] });
}
});
dragAndDropFilesStore.subscribe((value) => {
if (value.isDragging && value.files.length > 0) {
fileUploadHandler(value.files, album.id, sharedLink?.key);
dragAndDropFilesStore.set({ isDragging: false, files: [] });
}
});
let multiSelectAsset: Set<AssetResponseDto> = new Set();
$: isMultiSelectionMode = multiSelectAsset.size > 0;
let multiSelectAsset: Set<AssetResponseDto> = new Set();
$: isMultiSelectionMode = multiSelectAsset.size > 0;
afterNavigate(({ from }) => {
backUrl = from?.url.pathname ?? '/albums';
afterNavigate(({ from }) => {
backUrl = from?.url.pathname ?? '/albums';
if (from?.url.pathname === '/sharing' && album.sharedUsers.length === 0) {
isCreatingSharedAlbum = true;
}
if (from?.url.pathname === '/sharing' && album.sharedUsers.length === 0) {
isCreatingSharedAlbum = true;
}
if (from?.route.id === '/(user)/search') {
backUrl = from.url.href;
}
});
if (from?.route.id === '/(user)/search') {
backUrl = from.url.href;
}
});
const albumDateFormat: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric'
};
const albumDateFormat: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
};
const getDateRange = () => {
const startDate = new Date(album.assets[0].fileCreatedAt);
const endDate = new Date(album.assets[album.assetCount - 1].fileCreatedAt);
const getDateRange = () => {
const startDate = new Date(album.assets[0].fileCreatedAt);
const endDate = new Date(album.assets[album.assetCount - 1].fileCreatedAt);
const startDateString = startDate.toLocaleDateString($locale, albumDateFormat);
const endDateString = endDate.toLocaleDateString($locale, albumDateFormat);
const startDateString = startDate.toLocaleDateString($locale, albumDateFormat);
const endDateString = endDate.toLocaleDateString($locale, albumDateFormat);
// If the start and end date are the same, only show one date
return startDateString === endDateString
? startDateString
: `${startDateString} - ${endDateString}`;
};
// If the start and end date are the same, only show one date
return startDateString === endDateString ? startDateString : `${startDateString} - ${endDateString}`;
};
onMount(async () => {
currentAlbumName = album.albumName;
onMount(async () => {
currentAlbumName = album.albumName;
try {
const { data } = await api.userApi.getMyUserInfo();
currentUser = data;
} catch (e) {
console.log('Error [getMyUserInfo - album-viewer] ', e);
}
});
try {
const { data } = await api.userApi.getMyUserInfo();
currentUser = data;
} catch (e) {
console.log('Error [getMyUserInfo - album-viewer] ', e);
}
});
// Update Album Name
$: {
if (!isEditingTitle && currentAlbumName != album.albumName && isOwned) {
api.albumApi
.updateAlbumInfo({
id: album.id,
updateAlbumDto: {
albumName: album.albumName
}
})
.then(() => {
currentAlbumName = album.albumName;
})
.catch((e) => {
console.error('Error [updateAlbumInfo] ', e);
notificationController.show({
type: NotificationType.Error,
message: "Error updating album's name, check console for more details"
});
});
}
}
// Update Album Name
$: {
if (!isEditingTitle && currentAlbumName != album.albumName && isOwned) {
api.albumApi
.updateAlbumInfo({
id: album.id,
updateAlbumDto: {
albumName: album.albumName,
},
})
.then(() => {
currentAlbumName = album.albumName;
})
.catch((e) => {
console.error('Error [updateAlbumInfo] ', e);
notificationController.show({
type: NotificationType.Error,
message: "Error updating album's name, check console for more details",
});
});
}
}
const createAlbumHandler = async (event: CustomEvent) => {
const { assets }: { assets: AssetResponseDto[] } = event.detail;
try {
const { data } = await api.albumApi.addAssetsToAlbum({
id: album.id,
addAssetsDto: {
assetIds: assets.map((a) => a.id)
},
key: sharedLink?.key
});
const createAlbumHandler = async (event: CustomEvent) => {
const { assets }: { assets: AssetResponseDto[] } = event.detail;
try {
const { data } = await api.albumApi.addAssetsToAlbum({
id: album.id,
addAssetsDto: {
assetIds: assets.map((a) => a.id),
},
key: sharedLink?.key,
});
if (data.album) {
album = data.album;
}
isShowAssetSelection = false;
} catch (e) {
console.error('Error [createAlbumHandler] ', e);
notificationController.show({
type: NotificationType.Error,
message: 'Error creating album, check console for more details'
});
}
};
if (data.album) {
album = data.album;
}
isShowAssetSelection = false;
} catch (e) {
console.error('Error [createAlbumHandler] ', e);
notificationController.show({
type: NotificationType.Error,
message: 'Error creating album, check console for more details',
});
}
};
const addUserHandler = async (event: CustomEvent) => {
const { selectedUsers }: { selectedUsers: UserResponseDto[] } = event.detail;
const addUserHandler = async (event: CustomEvent) => {
const { selectedUsers }: { selectedUsers: UserResponseDto[] } = event.detail;
try {
const { data } = await api.albumApi.addUsersToAlbum({
id: album.id,
addUsersDto: {
sharedUserIds: Array.from(selectedUsers).map((u) => u.id)
}
});
try {
const { data } = await api.albumApi.addUsersToAlbum({
id: album.id,
addUsersDto: {
sharedUserIds: Array.from(selectedUsers).map((u) => u.id),
},
});
album = data;
album = data;
isShowShareUserSelection = false;
} catch (e) {
console.error('Error [addUserHandler] ', e);
notificationController.show({
type: NotificationType.Error,
message: 'Error adding users to album, check console for more details'
});
}
};
isShowShareUserSelection = false;
} catch (e) {
console.error('Error [addUserHandler] ', e);
notificationController.show({
type: NotificationType.Error,
message: 'Error adding users to album, check console for more details',
});
}
};
const sharedUserDeletedHandler = async (event: CustomEvent) => {
const { userId }: { userId: string } = event.detail;
const sharedUserDeletedHandler = async (event: CustomEvent) => {
const { userId }: { userId: string } = event.detail;
if (userId == 'me') {
isShowShareInfoModal = false;
goto(backUrl);
return;
}
if (userId == 'me') {
isShowShareInfoModal = false;
goto(backUrl);
return;
}
try {
const { data } = await api.albumApi.getAlbumInfo({ id: album.id });
try {
const { data } = await api.albumApi.getAlbumInfo({ id: album.id });
album = data;
isShowShareInfoModal = data.sharedUsers.length >= 1;
} catch (e) {
handleError(e, 'Error deleting share users');
}
};
album = data;
isShowShareInfoModal = data.sharedUsers.length >= 1;
} catch (e) {
handleError(e, 'Error deleting share users');
}
};
const removeAlbum = async () => {
try {
await api.albumApi.deleteAlbum({ id: album.id });
goto(backUrl);
} catch (e) {
console.error('Error [userDeleteMenu] ', e);
notificationController.show({
type: NotificationType.Error,
message: 'Error deleting album, check console for more details'
});
} finally {
isShowDeleteConfirmation = false;
}
};
const removeAlbum = async () => {
try {
await api.albumApi.deleteAlbum({ id: album.id });
goto(backUrl);
} catch (e) {
console.error('Error [userDeleteMenu] ', e);
notificationController.show({
type: NotificationType.Error,
message: 'Error deleting album, check console for more details',
});
} finally {
isShowDeleteConfirmation = false;
}
};
const downloadAlbum = async () => {
await downloadArchive(
`${album.albumName}.zip`,
{ albumId: album.id },
undefined,
sharedLink?.key
);
};
const downloadAlbum = async () => {
await downloadArchive(`${album.albumName}.zip`, { albumId: album.id }, undefined, sharedLink?.key);
};
const showAlbumOptionsMenu = ({ x, y }: MouseEvent) => {
contextMenuPosition = { x, y };
isShowAlbumOptions = !isShowAlbumOptions;
};
const showAlbumOptionsMenu = ({ x, y }: MouseEvent) => {
contextMenuPosition = { x, y };
isShowAlbumOptions = !isShowAlbumOptions;
};
const setAlbumThumbnailHandler = (event: CustomEvent) => {
const { asset }: { asset: AssetResponseDto } = event.detail;
try {
api.albumApi.updateAlbumInfo({
id: album.id,
updateAlbumDto: {
albumThumbnailAssetId: asset.id
}
});
} catch (e) {
console.error('Error [setAlbumThumbnailHandler] ', e);
notificationController.show({
type: NotificationType.Error,
message: 'Error setting album thumbnail, check console for more details'
});
}
const setAlbumThumbnailHandler = (event: CustomEvent) => {
const { asset }: { asset: AssetResponseDto } = event.detail;
try {
api.albumApi.updateAlbumInfo({
id: album.id,
updateAlbumDto: {
albumThumbnailAssetId: asset.id,
},
});
} catch (e) {
console.error('Error [setAlbumThumbnailHandler] ', e);
notificationController.show({
type: NotificationType.Error,
message: 'Error setting album thumbnail, check console for more details',
});
}
isShowThumbnailSelection = false;
};
isShowThumbnailSelection = false;
};
const onSharedLinkClickHandler = () => {
isShowShareUserSelection = false;
isShowShareLinkModal = true;
};
const onSharedLinkClickHandler = () => {
isShowShareUserSelection = false;
isShowShareLinkModal = true;
};
const handleSelectAll = () => {
multiSelectAsset = new Set(album.assets);
};
const handleSelectAll = () => {
multiSelectAsset = new Set(album.assets);
};
</script>
<section class="bg-immich-bg dark:bg-immich-dark-bg" class:hidden={isShowThumbnailSelection}>
<!-- Multiselection mode app bar -->
{#if isMultiSelectionMode}
<AssetSelectControlBar
assets={multiSelectAsset}
clearSelect={() => (multiSelectAsset = new Set())}
>
<CircleIconButton title="Select all" logo={SelectAll} on:click={handleSelectAll} />
{#if sharedLink?.allowDownload || !isPublicShared}
<DownloadAction filename="{album.albumName}.zip" sharedLinkKey={sharedLink?.key} />
{/if}
{#if isOwned}
<RemoveFromAlbum bind:album />
{/if}
</AssetSelectControlBar>
{/if}
<!-- Multiselection mode app bar -->
{#if isMultiSelectionMode}
<AssetSelectControlBar assets={multiSelectAsset} clearSelect={() => (multiSelectAsset = new Set())}>
<CircleIconButton title="Select all" logo={SelectAll} on:click={handleSelectAll} />
{#if sharedLink?.allowDownload || !isPublicShared}
<DownloadAction filename="{album.albumName}.zip" sharedLinkKey={sharedLink?.key} />
{/if}
{#if isOwned}
<RemoveFromAlbum bind:album />
{/if}
</AssetSelectControlBar>
{/if}
<!-- Default app bar -->
{#if !isMultiSelectionMode}
<ControlAppBar
on:close-button-click={() => goto(backUrl)}
backIcon={ArrowLeft}
showBackButton={(!isPublicShared && isOwned) ||
(!isPublicShared && !isOwned) ||
(isPublicShared && isOwned)}
>
<svelte:fragment slot="leading">
{#if isPublicShared && !isOwned}
<a
data-sveltekit-preload-data="hover"
class="flex gap-2 place-items-center hover:cursor-pointer ml-6"
href="https://immich.app"
>
<ImmichLogo height={30} width={30} />
<h1 class="font-immich-title text-lg text-immich-primary dark:text-immich-dark-primary">
IMMICH
</h1>
</a>
{/if}
</svelte:fragment>
<!-- Default app bar -->
{#if !isMultiSelectionMode}
<ControlAppBar
on:close-button-click={() => goto(backUrl)}
backIcon={ArrowLeft}
showBackButton={(!isPublicShared && isOwned) || (!isPublicShared && !isOwned) || (isPublicShared && isOwned)}
>
<svelte:fragment slot="leading">
{#if isPublicShared && !isOwned}
<a
data-sveltekit-preload-data="hover"
class="flex gap-2 place-items-center hover:cursor-pointer ml-6"
href="https://immich.app"
>
<ImmichLogo height={30} width={30} />
<h1 class="font-immich-title text-lg text-immich-primary dark:text-immich-dark-primary">IMMICH</h1>
</a>
{/if}
</svelte:fragment>
<svelte:fragment slot="trailing">
{#if !isCreatingSharedAlbum}
{#if !sharedLink}
<CircleIconButton
title="Add Photos"
on:click={() => (isShowAssetSelection = true)}
logo={FileImagePlusOutline}
/>
{:else if sharedLink?.allowUpload}
<CircleIconButton
title="Add Photos"
on:click={() => openFileUploadDialog(album.id, sharedLink?.key)}
logo={FileImagePlusOutline}
/>
{/if}
<svelte:fragment slot="trailing">
{#if !isCreatingSharedAlbum}
{#if !sharedLink}
<CircleIconButton
title="Add Photos"
on:click={() => (isShowAssetSelection = true)}
logo={FileImagePlusOutline}
/>
{:else if sharedLink?.allowUpload}
<CircleIconButton
title="Add Photos"
on:click={() => openFileUploadDialog(album.id, sharedLink?.key)}
logo={FileImagePlusOutline}
/>
{/if}
{#if isOwned}
<CircleIconButton
title="Share"
on:click={() => (isShowShareUserSelection = true)}
logo={ShareVariantOutline}
/>
<CircleIconButton
title="Remove album"
on:click={() => (isShowDeleteConfirmation = true)}
logo={DeleteOutline}
/>
{/if}
{/if}
{#if isOwned}
<CircleIconButton
title="Share"
on:click={() => (isShowShareUserSelection = true)}
logo={ShareVariantOutline}
/>
<CircleIconButton
title="Remove album"
on:click={() => (isShowDeleteConfirmation = true)}
logo={DeleteOutline}
/>
{/if}
{/if}
{#if album.assetCount > 0 && !isCreatingSharedAlbum}
{#if !isPublicShared || (isPublicShared && sharedLink?.allowDownload)}
<CircleIconButton
title="Download"
on:click={() => downloadAlbum()}
logo={FolderDownloadOutline}
/>
{/if}
{#if album.assetCount > 0 && !isCreatingSharedAlbum}
{#if !isPublicShared || (isPublicShared && sharedLink?.allowDownload)}
<CircleIconButton title="Download" on:click={() => downloadAlbum()} logo={FolderDownloadOutline} />
{/if}
{#if !isPublicShared && isOwned}
<CircleIconButton
title="Album options"
on:click={showAlbumOptionsMenu}
logo={DotsVertical}
>
{#if isShowAlbumOptions}
<ContextMenu
{...contextMenuPosition}
on:outclick={() => (isShowAlbumOptions = false)}
>
<MenuOption
on:click={() => {
isShowThumbnailSelection = true;
isShowAlbumOptions = false;
}}
text="Set album cover"
/>
</ContextMenu>
{/if}
</CircleIconButton>
{/if}
{/if}
{#if !isPublicShared && isOwned}
<CircleIconButton title="Album options" on:click={showAlbumOptionsMenu} logo={DotsVertical}>
{#if isShowAlbumOptions}
<ContextMenu {...contextMenuPosition} on:outclick={() => (isShowAlbumOptions = false)}>
<MenuOption
on:click={() => {
isShowThumbnailSelection = true;
isShowAlbumOptions = false;
}}
text="Set album cover"
/>
</ContextMenu>
{/if}
</CircleIconButton>
{/if}
{/if}
{#if isPublicShared}
<ThemeButton />
{/if}
{#if isPublicShared}
<ThemeButton />
{/if}
{#if isCreatingSharedAlbum && album.sharedUsers.length == 0}
<Button
size="sm"
rounded="lg"
disabled={album.assetCount == 0}
on:click={() => (isShowShareUserSelection = true)}
>
Share
</Button>
{/if}
</svelte:fragment>
</ControlAppBar>
{/if}
{#if isCreatingSharedAlbum && album.sharedUsers.length == 0}
<Button
size="sm"
rounded="lg"
disabled={album.assetCount == 0}
on:click={() => (isShowShareUserSelection = true)}
>
Share
</Button>
{/if}
</svelte:fragment>
</ControlAppBar>
{/if}
<section class="flex flex-col my-[160px] px-6 sm:px-12 md:px-24 lg:px-40">
<input
on:keydown={(e) => {
if (e.key == 'Enter') {
isEditingTitle = false;
titleInput.blur();
}
}}
on:focus={() => (isEditingTitle = true)}
on:blur={() => (isEditingTitle = false)}
class={`transition-all text-6xl text-immich-primary dark:text-immich-dark-primary w-[99%] border-b-2 border-transparent outline-none ${
isOwned ? 'hover:border-gray-400' : 'hover:border-transparent'
} focus:outline-none focus:border-b-2 focus:border-immich-primary dark:focus:border-immich-dark-primary bg-immich-bg dark:bg-immich-dark-bg dark:focus:bg-immich-dark-gray`}
type="text"
bind:value={album.albumName}
disabled={!isOwned}
bind:this={titleInput}
/>
<section class="flex flex-col my-[160px] px-6 sm:px-12 md:px-24 lg:px-40">
<input
on:keydown={(e) => {
if (e.key == 'Enter') {
isEditingTitle = false;
titleInput.blur();
}
}}
on:focus={() => (isEditingTitle = true)}
on:blur={() => (isEditingTitle = false)}
class={`transition-all text-6xl text-immich-primary dark:text-immich-dark-primary w-[99%] border-b-2 border-transparent outline-none ${
isOwned ? 'hover:border-gray-400' : 'hover:border-transparent'
} focus:outline-none focus:border-b-2 focus:border-immich-primary dark:focus:border-immich-dark-primary bg-immich-bg dark:bg-immich-dark-bg dark:focus:bg-immich-dark-gray`}
type="text"
bind:value={album.albumName}
disabled={!isOwned}
bind:this={titleInput}
/>
{#if album.assetCount > 0}
<span class="flex gap-2 my-4 text-sm text-gray-500 font-medium" data-testid="album-details">
<p class="">{getDateRange()}</p>
<p>·</p>
<p>{album.assetCount} items</p>
</span>
{/if}
{#if album.shared}
<div class="flex my-6 gap-x-1">
{#each album.sharedUsers as user (user.id)}
<button on:click={() => (isShowShareInfoModal = true)}>
<UserAvatar {user} size="md" autoColor />
</button>
{/each}
{#if album.assetCount > 0}
<span class="flex gap-2 my-4 text-sm text-gray-500 font-medium" data-testid="album-details">
<p class="">{getDateRange()}</p>
<p>·</p>
<p>{album.assetCount} items</p>
</span>
{/if}
{#if album.shared}
<div class="flex my-6 gap-x-1">
{#each album.sharedUsers as user (user.id)}
<button on:click={() => (isShowShareInfoModal = true)}>
<UserAvatar {user} size="md" autoColor />
</button>
{/each}
<button
style:display={isOwned ? 'block' : 'none'}
on:click={() => (isShowShareUserSelection = true)}
title="Add more users"
class="h-12 w-12 border bg-white transition-colors hover:bg-gray-300 text-3xl flex place-items-center place-content-center rounded-full"
>+</button
>
</div>
{/if}
<button
style:display={isOwned ? 'block' : 'none'}
on:click={() => (isShowShareUserSelection = true)}
title="Add more users"
class="h-12 w-12 border bg-white transition-colors hover:bg-gray-300 text-3xl flex place-items-center place-content-center rounded-full"
>+</button
>
</div>
{/if}
{#if album.assetCount > 0}
<GalleryViewer
assets={album.assets}
{sharedLink}
bind:selectedAssets={multiSelectAsset}
viewFrom="album-page"
/>
{:else}
<!-- Album is empty - Show asset selectection buttons -->
<section id="empty-album" class=" mt-[200px] flex place-content-center place-items-center">
<div class="w-[300px]">
<p class="text-xs dark:text-immich-dark-fg">ADD PHOTOS</p>
<button
on:click={() => (isShowAssetSelection = true)}
class="w-full py-8 border bg-immich-bg dark:bg-immich-dark-gray text-immich-fg dark:text-immich-dark-fg dark:hover:text-immich-dark-primary rounded-md mt-5 flex place-items-center gap-6 px-8 transition-all hover:bg-gray-100 hover:text-immich-primary dark:border-none"
>
<span class="text-text-immich-primary dark:text-immich-dark-primary"
><Plus size="24" />
</span>
<span class="text-lg">Select photos</span>
</button>
</div>
</section>
{/if}
</section>
{#if album.assetCount > 0}
<GalleryViewer assets={album.assets} {sharedLink} bind:selectedAssets={multiSelectAsset} viewFrom="album-page" />
{:else}
<!-- Album is empty - Show asset selectection buttons -->
<section id="empty-album" class=" mt-[200px] flex place-content-center place-items-center">
<div class="w-[300px]">
<p class="text-xs dark:text-immich-dark-fg">ADD PHOTOS</p>
<button
on:click={() => (isShowAssetSelection = true)}
class="w-full py-8 border bg-immich-bg dark:bg-immich-dark-gray text-immich-fg dark:text-immich-dark-fg dark:hover:text-immich-dark-primary rounded-md mt-5 flex place-items-center gap-6 px-8 transition-all hover:bg-gray-100 hover:text-immich-primary dark:border-none"
>
<span class="text-text-immich-primary dark:text-immich-dark-primary"><Plus size="24" /> </span>
<span class="text-lg">Select photos</span>
</button>
</div>
</section>
{/if}
</section>
</section>
{#if isShowAssetSelection}
<AssetSelection
albumId={album.id}
assetsInAlbum={album.assets}
on:go-back={() => (isShowAssetSelection = false)}
on:create-album={createAlbumHandler}
/>
<AssetSelection
albumId={album.id}
assetsInAlbum={album.assets}
on:go-back={() => (isShowAssetSelection = false)}
on:create-album={createAlbumHandler}
/>
{/if}
{#if isShowShareUserSelection}
<UserSelectionModal
{album}
on:close={() => (isShowShareUserSelection = false)}
on:add-user={addUserHandler}
on:sharedlinkclick={onSharedLinkClickHandler}
sharedUsersInAlbum={new Set(album.sharedUsers)}
/>
<UserSelectionModal
{album}
on:close={() => (isShowShareUserSelection = false)}
on:add-user={addUserHandler}
on:sharedlinkclick={onSharedLinkClickHandler}
sharedUsersInAlbum={new Set(album.sharedUsers)}
/>
{/if}
{#if isShowShareLinkModal}
<CreateSharedLinkModal
on:close={() => (isShowShareLinkModal = false)}
shareType={SharedLinkType.Album}
{album}
/>
<CreateSharedLinkModal on:close={() => (isShowShareLinkModal = false)} shareType={SharedLinkType.Album} {album} />
{/if}
{#if isShowShareInfoModal}
<ShareInfoModal
on:close={() => (isShowShareInfoModal = false)}
{album}
on:user-deleted={sharedUserDeletedHandler}
/>
<ShareInfoModal on:close={() => (isShowShareInfoModal = false)} {album} on:user-deleted={sharedUserDeletedHandler} />
{/if}
{#if isShowThumbnailSelection}
<ThumbnailSelection
{album}
on:close={() => (isShowThumbnailSelection = false)}
on:thumbnail-selected={setAlbumThumbnailHandler}
/>
<ThumbnailSelection
{album}
on:close={() => (isShowThumbnailSelection = false)}
on:thumbnail-selected={setAlbumThumbnailHandler}
/>
{/if}
{#if isShowDeleteConfirmation}
<ConfirmDialogue
title="Delete Album"
confirmText="Delete"
on:confirm={removeAlbum}
on:cancel={() => (isShowDeleteConfirmation = false)}
>
<svelte:fragment slot="prompt">
<p>Are you sure you want to delete the album <b>{album.albumName}</b>?</p>
<p>If this album is shared, other users will not be able to access it anymore.</p>
</svelte:fragment>
</ConfirmDialogue>
<ConfirmDialogue
title="Delete Album"
confirmText="Delete"
on:confirm={removeAlbum}
on:cancel={() => (isShowDeleteConfirmation = false)}
>
<svelte:fragment slot="prompt">
<p>Are you sure you want to delete the album <b>{album.albumName}</b>?</p>
<p>If this album is shared, other users will not be able to access it anymore.</p>
</svelte:fragment>
</ConfirmDialogue>
{/if}

View file

@ -1,80 +1,69 @@
<script lang="ts">
import {
assetInteractionStore,
assetsInAlbumStoreState,
selectedAssets
} from '$lib/stores/asset-interaction.store';
import { locale } from '$lib/stores/preferences.store';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import type { AssetResponseDto } from '@api';
import { createEventDispatcher, onMount } from 'svelte';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import Button from '../elements/buttons/button.svelte';
import AssetGrid from '../photos-page/asset-grid.svelte';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import { assetInteractionStore, assetsInAlbumStoreState, selectedAssets } from '$lib/stores/asset-interaction.store';
import { locale } from '$lib/stores/preferences.store';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import type { AssetResponseDto } from '@api';
import { createEventDispatcher, onMount } from 'svelte';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import Button from '../elements/buttons/button.svelte';
import AssetGrid from '../photos-page/asset-grid.svelte';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
const dispatch = createEventDispatcher();
const dispatch = createEventDispatcher();
export let albumId: string;
export let assetsInAlbum: AssetResponseDto[];
export let albumId: string;
export let assetsInAlbum: AssetResponseDto[];
onMount(() => {
$assetsInAlbumStoreState = assetsInAlbum;
});
onMount(() => {
$assetsInAlbumStoreState = assetsInAlbum;
});
const addSelectedAssets = async () => {
dispatch('create-album', {
assets: Array.from($selectedAssets)
});
const addSelectedAssets = async () => {
dispatch('create-album', {
assets: Array.from($selectedAssets),
});
assetInteractionStore.clearMultiselect();
};
const handleSelectFromComputerClicked = async () => {
await openFileUploadDialog(albumId, '');
assetInteractionStore.clearMultiselect();
dispatch('go-back');
};
assetInteractionStore.clearMultiselect();
};
const handleSelectFromComputerClicked = async () => {
await openFileUploadDialog(albumId, '');
assetInteractionStore.clearMultiselect();
dispatch('go-back');
};
</script>
<section
transition:fly={{ y: 500, duration: 100, easing: quintOut }}
class="absolute top-0 left-0 w-full h-full bg-immich-bg dark:bg-immich-dark-bg z-[9999]"
transition:fly={{ y: 500, duration: 100, easing: quintOut }}
class="absolute top-0 left-0 w-full h-full bg-immich-bg dark:bg-immich-dark-bg z-[9999]"
>
<ControlAppBar
on:close-button-click={() => {
assetInteractionStore.clearMultiselect();
dispatch('go-back');
}}
>
<svelte:fragment slot="leading">
{#if $selectedAssets.size == 0}
<p class="text-lg dark:text-immich-dark-fg">Add to album</p>
{:else}
<p class="text-lg dark:text-immich-dark-fg">
{$selectedAssets.size.toLocaleString($locale)} selected
</p>
{/if}
</svelte:fragment>
<ControlAppBar
on:close-button-click={() => {
assetInteractionStore.clearMultiselect();
dispatch('go-back');
}}
>
<svelte:fragment slot="leading">
{#if $selectedAssets.size == 0}
<p class="text-lg dark:text-immich-dark-fg">Add to album</p>
{:else}
<p class="text-lg dark:text-immich-dark-fg">
{$selectedAssets.size.toLocaleString($locale)} selected
</p>
{/if}
</svelte:fragment>
<svelte:fragment slot="trailing">
<button
on:click={handleSelectFromComputerClicked}
class="text-immich-primary dark:text-immich-dark-primary text-sm hover:bg-immich-primary/10 dark:hover:bg-immich-dark-primary/25 transition-all px-6 py-2 rounded-lg font-medium"
>
Select from computer
</button>
<Button
size="sm"
rounded="lg"
disabled={$selectedAssets.size === 0}
on:click={addSelectedAssets}
>
Done
</Button>
</svelte:fragment>
</ControlAppBar>
<section class="pt-[100px] pl-[70px] grid h-screen bg-immich-bg dark:bg-immich-dark-bg">
<AssetGrid isAlbumSelectionMode={true} />
</section>
<svelte:fragment slot="trailing">
<button
on:click={handleSelectFromComputerClicked}
class="text-immich-primary dark:text-immich-dark-primary text-sm hover:bg-immich-primary/10 dark:hover:bg-immich-dark-primary/25 transition-all px-6 py-2 rounded-lg font-medium"
>
Select from computer
</button>
<Button size="sm" rounded="lg" disabled={$selectedAssets.size === 0} on:click={addSelectedAssets}>Done</Button>
</svelte:fragment>
</ControlAppBar>
<section class="pt-[100px] pl-[70px] grid h-screen bg-immich-bg dark:bg-immich-dark-bg">
<AssetGrid isAlbumSelectionMode={true} />
</section>
</section>

View file

@ -1,144 +1,140 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { AlbumResponseDto, api, UserResponseDto } from '@api';
import BaseModal from '../shared-components/base-modal.svelte';
import UserAvatar from '../shared-components/user-avatar.svelte';
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
import {
notificationController,
NotificationType
} from '../shared-components/notification/notification';
import { handleError } from '../../utils/handle-error';
import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte';
import { createEventDispatcher, onMount } from 'svelte';
import { AlbumResponseDto, api, UserResponseDto } from '@api';
import BaseModal from '../shared-components/base-modal.svelte';
import UserAvatar from '../shared-components/user-avatar.svelte';
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
import { notificationController, NotificationType } from '../shared-components/notification/notification';
import { handleError } from '../../utils/handle-error';
import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte';
export let album: AlbumResponseDto;
export let album: AlbumResponseDto;
const dispatch = createEventDispatcher();
const dispatch = createEventDispatcher();
let currentUser: UserResponseDto;
let position = { x: 0, y: 0 };
let selectedMenuUser: UserResponseDto | null = null;
let selectedRemoveUser: UserResponseDto | null = null;
let currentUser: UserResponseDto;
let position = { x: 0, y: 0 };
let selectedMenuUser: UserResponseDto | null = null;
let selectedRemoveUser: UserResponseDto | null = null;
$: isOwned = currentUser?.id == album.ownerId;
$: isOwned = currentUser?.id == album.ownerId;
onMount(async () => {
try {
const { data } = await api.userApi.getMyUserInfo();
currentUser = data;
} catch (e) {
handleError(e, 'Unable to refresh user');
}
});
onMount(async () => {
try {
const { data } = await api.userApi.getMyUserInfo();
currentUser = data;
} catch (e) {
handleError(e, 'Unable to refresh user');
}
});
const showContextMenu = (user: UserResponseDto) => {
const iconButton = document.getElementById('icon-' + user.id);
const showContextMenu = (user: UserResponseDto) => {
const iconButton = document.getElementById('icon-' + user.id);
if (iconButton) {
position = {
x: iconButton.getBoundingClientRect().left,
y: iconButton.getBoundingClientRect().bottom
};
}
if (iconButton) {
position = {
x: iconButton.getBoundingClientRect().left,
y: iconButton.getBoundingClientRect().bottom,
};
}
selectedMenuUser = user;
selectedRemoveUser = null;
};
selectedMenuUser = user;
selectedRemoveUser = null;
};
const handleMenuRemove = () => {
selectedRemoveUser = selectedMenuUser;
selectedMenuUser = null;
};
const handleMenuRemove = () => {
selectedRemoveUser = selectedMenuUser;
selectedMenuUser = null;
};
const handleRemoveUser = async () => {
if (!selectedRemoveUser) {
return;
}
const handleRemoveUser = async () => {
if (!selectedRemoveUser) {
return;
}
const userId = selectedRemoveUser.id === currentUser?.id ? 'me' : selectedRemoveUser.id;
const userId = selectedRemoveUser.id === currentUser?.id ? 'me' : selectedRemoveUser.id;
try {
await api.albumApi.removeUserFromAlbum({ id: album.id, userId });
dispatch('user-deleted', { userId });
const message =
userId === 'me' ? `Left ${album.albumName}` : `Removed ${selectedRemoveUser.firstName}`;
notificationController.show({ type: NotificationType.Info, message });
} catch (e) {
handleError(e, 'Unable to remove user');
} finally {
selectedRemoveUser = null;
}
};
try {
await api.albumApi.removeUserFromAlbum({ id: album.id, userId });
dispatch('user-deleted', { userId });
const message = userId === 'me' ? `Left ${album.albumName}` : `Removed ${selectedRemoveUser.firstName}`;
notificationController.show({ type: NotificationType.Info, message });
} catch (e) {
handleError(e, 'Unable to remove user');
} finally {
selectedRemoveUser = null;
}
};
</script>
{#if !selectedRemoveUser}
<BaseModal on:close={() => dispatch('close')}>
<svelte:fragment slot="title">
<span class="flex gap-2 place-items-center">
<p class="font-medium text-immich-fg dark:text-immich-dark-fg">Options</p>
</span>
</svelte:fragment>
<BaseModal on:close={() => dispatch('close')}>
<svelte:fragment slot="title">
<span class="flex gap-2 place-items-center">
<p class="font-medium text-immich-fg dark:text-immich-dark-fg">Options</p>
</span>
</svelte:fragment>
<section class="max-h-[400px] overflow-y-auto immich-scrollbar pb-4">
{#each album.sharedUsers as user}
<div
class="flex gap-4 p-5 place-items-center justify-between w-full transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
>
<div class="flex gap-4 place-items-center">
<UserAvatar {user} size="md" autoColor />
<p class="font-medium text-sm">{user.firstName} {user.lastName}</p>
</div>
<section class="max-h-[400px] overflow-y-auto immich-scrollbar pb-4">
{#each album.sharedUsers as user}
<div
class="flex gap-4 p-5 place-items-center justify-between w-full transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
>
<div class="flex gap-4 place-items-center">
<UserAvatar {user} size="md" autoColor />
<p class="font-medium text-sm">{user.firstName} {user.lastName}</p>
</div>
<div id={`icon-${user.id}`} class="flex place-items-center">
{#if isOwned}
<div>
<CircleIconButton
on:click={() => showContextMenu(user)}
logo={DotsVertical}
backgroundColor="transparent"
hoverColor="#e2e7e9"
size="20"
/>
<div id={`icon-${user.id}`} class="flex place-items-center">
{#if isOwned}
<div>
<CircleIconButton
on:click={() => showContextMenu(user)}
logo={DotsVertical}
backgroundColor="transparent"
hoverColor="#e2e7e9"
size="20"
/>
{#if selectedMenuUser === user}
<ContextMenu {...position} on:outclick={() => (selectedMenuUser = null)}>
<MenuOption on:click={handleMenuRemove} text="Remove" />
</ContextMenu>
{/if}
</div>
{:else if user.id == currentUser?.id}
<button
on:click={() => (selectedRemoveUser = user)}
class="text-sm text-immich-primary dark:text-immich-dark-primary font-medium transition-colors hover:text-immich-primary/75"
>Leave</button
>
{/if}
</div>
</div>
{/each}
</section>
</BaseModal>
{#if selectedMenuUser === user}
<ContextMenu {...position} on:outclick={() => (selectedMenuUser = null)}>
<MenuOption on:click={handleMenuRemove} text="Remove" />
</ContextMenu>
{/if}
</div>
{:else if user.id == currentUser?.id}
<button
on:click={() => (selectedRemoveUser = user)}
class="text-sm text-immich-primary dark:text-immich-dark-primary font-medium transition-colors hover:text-immich-primary/75"
>Leave</button
>
{/if}
</div>
</div>
{/each}
</section>
</BaseModal>
{/if}
{#if selectedRemoveUser && selectedRemoveUser?.id === currentUser?.id}
<ConfirmDialogue
title="Leave Album?"
prompt="Are you sure you want to leave {album.albumName}?"
confirmText="Leave"
on:confirm={handleRemoveUser}
on:cancel={() => (selectedRemoveUser = null)}
/>
<ConfirmDialogue
title="Leave Album?"
prompt="Are you sure you want to leave {album.albumName}?"
confirmText="Leave"
on:confirm={handleRemoveUser}
on:cancel={() => (selectedRemoveUser = null)}
/>
{/if}
{#if selectedRemoveUser && selectedRemoveUser?.id !== currentUser?.id}
<ConfirmDialogue
title="Remove User?"
prompt="Are you sure you want to remove {selectedRemoveUser.firstName} {selectedRemoveUser.lastName}"
confirmText="Remove"
on:confirm={handleRemoveUser}
on:cancel={() => (selectedRemoveUser = null)}
/>
<ConfirmDialogue
title="Remove User?"
prompt="Are you sure you want to remove {selectedRemoveUser.firstName} {selectedRemoveUser.lastName}"
confirmText="Remove"
on:confirm={handleRemoveUser}
on:cancel={() => (selectedRemoveUser = null)}
/>
{/if}

View file

@ -1,57 +1,53 @@
<script lang="ts">
import type { AlbumResponseDto, AssetResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import Button from '../elements/buttons/button.svelte';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import type { AlbumResponseDto, AssetResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import Button from '../elements/buttons/button.svelte';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
export let album: AlbumResponseDto;
export let album: AlbumResponseDto;
let selectedThumbnail: AssetResponseDto | undefined;
const dispatch = createEventDispatcher();
let selectedThumbnail: AssetResponseDto | undefined;
const dispatch = createEventDispatcher();
$: isSelected = (id: string): boolean | undefined => {
if (!selectedThumbnail && album.albumThumbnailAssetId == id) {
return true;
} else {
return selectedThumbnail?.id == id;
}
};
$: isSelected = (id: string): boolean | undefined => {
if (!selectedThumbnail && album.albumThumbnailAssetId == id) {
return true;
} else {
return selectedThumbnail?.id == id;
}
};
</script>
<section
transition:fly={{ y: 500, duration: 100, easing: quintOut }}
class="absolute top-0 left-0 w-full h-full py-[160px] bg-immich-bg dark:bg-immich-dark-bg z-[9999]"
transition:fly={{ y: 500, duration: 100, easing: quintOut }}
class="absolute top-0 left-0 w-full h-full py-[160px] bg-immich-bg dark:bg-immich-dark-bg z-[9999]"
>
<ControlAppBar on:close-button-click={() => dispatch('close')}>
<svelte:fragment slot="leading">
<p class="text-lg">Select album cover</p>
</svelte:fragment>
<ControlAppBar on:close-button-click={() => dispatch('close')}>
<svelte:fragment slot="leading">
<p class="text-lg">Select album cover</p>
</svelte:fragment>
<svelte:fragment slot="trailing">
<Button
size="sm"
rounded="lg"
disabled={selectedThumbnail == undefined}
on:click={() => dispatch('thumbnail-selected', { asset: selectedThumbnail })}
>
Done
</Button>
</svelte:fragment>
</ControlAppBar>
<svelte:fragment slot="trailing">
<Button
size="sm"
rounded="lg"
disabled={selectedThumbnail == undefined}
on:click={() => dispatch('thumbnail-selected', { asset: selectedThumbnail })}
>
Done
</Button>
</svelte:fragment>
</ControlAppBar>
<section class="flex flex-wrap gap-14 px-20 overflow-y-auto">
<!-- Image grid -->
<div class="flex flex-wrap gap-[2px]">
{#each album.assets as asset}
<Thumbnail
{asset}
on:click={() => (selectedThumbnail = asset)}
selected={isSelected(asset.id)}
/>
{/each}
</div>
</section>
<section class="flex flex-wrap gap-14 px-20 overflow-y-auto">
<!-- Image grid -->
<div class="flex flex-wrap gap-[2px]">
{#each album.assets as asset}
<Thumbnail {asset} on:click={() => (selectedThumbnail = asset)} selected={isSelected(asset.id)} />
{/each}
</div>
</section>
</section>

View file

@ -1,149 +1,146 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { AlbumResponseDto, api, SharedLinkResponseDto, UserResponseDto } from '@api';
import BaseModal from '../shared-components/base-modal.svelte';
import UserAvatar from '../shared-components/user-avatar.svelte';
import Link from 'svelte-material-icons/Link.svelte';
import ShareCircle from 'svelte-material-icons/ShareCircle.svelte';
import { goto } from '$app/navigation';
import ImmichLogo from '../shared-components/immich-logo.svelte';
import Button from '../elements/buttons/button.svelte';
import { AppRoute } from '$lib/constants';
import { createEventDispatcher, onMount } from 'svelte';
import { AlbumResponseDto, api, SharedLinkResponseDto, UserResponseDto } from '@api';
import BaseModal from '../shared-components/base-modal.svelte';
import UserAvatar from '../shared-components/user-avatar.svelte';
import Link from 'svelte-material-icons/Link.svelte';
import ShareCircle from 'svelte-material-icons/ShareCircle.svelte';
import { goto } from '$app/navigation';
import ImmichLogo from '../shared-components/immich-logo.svelte';
import Button from '../elements/buttons/button.svelte';
import { AppRoute } from '$lib/constants';
export let album: AlbumResponseDto;
export let sharedUsersInAlbum: Set<UserResponseDto>;
let users: UserResponseDto[] = [];
let selectedUsers: UserResponseDto[] = [];
export let album: AlbumResponseDto;
export let sharedUsersInAlbum: Set<UserResponseDto>;
let users: UserResponseDto[] = [];
let selectedUsers: UserResponseDto[] = [];
const dispatch = createEventDispatcher();
let sharedLinks: SharedLinkResponseDto[] = [];
onMount(async () => {
await getSharedLinks();
const { data } = await api.userApi.getAllUsers({ isAll: false });
const dispatch = createEventDispatcher();
let sharedLinks: SharedLinkResponseDto[] = [];
onMount(async () => {
await getSharedLinks();
const { data } = await api.userApi.getAllUsers({ isAll: false });
// remove invalid users
users = data.filter((user) => !(user.deletedAt || user.id === album.ownerId));
// remove invalid users
users = data.filter((user) => !(user.deletedAt || user.id === album.ownerId));
// Remove the existed shared users from the album
sharedUsersInAlbum.forEach((sharedUser) => {
users = users.filter((user) => user.id !== sharedUser.id);
});
});
// Remove the existed shared users from the album
sharedUsersInAlbum.forEach((sharedUser) => {
users = users.filter((user) => user.id !== sharedUser.id);
});
});
const getSharedLinks = async () => {
const { data } = await api.sharedLinkApi.getAllSharedLinks();
const getSharedLinks = async () => {
const { data } = await api.sharedLinkApi.getAllSharedLinks();
sharedLinks = data.filter((link) => link.album?.id === album.id);
};
sharedLinks = data.filter((link) => link.album?.id === album.id);
};
const selectUser = (user: UserResponseDto) => {
if (selectedUsers.includes(user)) {
selectedUsers = selectedUsers.filter((selectedUser) => selectedUser.id !== user.id);
} else {
selectedUsers = [...selectedUsers, user];
}
};
const selectUser = (user: UserResponseDto) => {
if (selectedUsers.includes(user)) {
selectedUsers = selectedUsers.filter((selectedUser) => selectedUser.id !== user.id);
} else {
selectedUsers = [...selectedUsers, user];
}
};
const deselectUser = (user: UserResponseDto) => {
selectedUsers = selectedUsers.filter((selectedUser) => selectedUser.id !== user.id);
};
const deselectUser = (user: UserResponseDto) => {
selectedUsers = selectedUsers.filter((selectedUser) => selectedUser.id !== user.id);
};
const onSharedLinkClick = () => {
dispatch('sharedlinkclick');
};
const onSharedLinkClick = () => {
dispatch('sharedlinkclick');
};
</script>
<BaseModal on:close={() => dispatch('close')}>
<svelte:fragment slot="title">
<span class="flex gap-2 place-items-center">
<ImmichLogo width={24} />
<p class="font-medium">Invite to album</p>
</span>
</svelte:fragment>
<svelte:fragment slot="title">
<span class="flex gap-2 place-items-center">
<ImmichLogo width={24} />
<p class="font-medium">Invite to album</p>
</span>
</svelte:fragment>
<div class="max-h-[300px] overflow-y-auto immich-scrollbar">
{#if selectedUsers.length > 0}
<div class="flex gap-4 py-2 px-5 overflow-x-auto place-items-center mb-2">
<p class="font-medium">To</p>
<div class="max-h-[300px] overflow-y-auto immich-scrollbar">
{#if selectedUsers.length > 0}
<div class="flex gap-4 py-2 px-5 overflow-x-auto place-items-center mb-2">
<p class="font-medium">To</p>
{#each selectedUsers as user}
{#key user.id}
<button
on:click={() => deselectUser(user)}
class="flex gap-1 place-items-center border border-gray-400 rounded-full p-1 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
<UserAvatar {user} size="sm" autoColor />
<p class="text-xs font-medium">{user.firstName} {user.lastName}</p>
</button>
{/key}
{/each}
</div>
{/if}
{#each selectedUsers as user}
{#key user.id}
<button
on:click={() => deselectUser(user)}
class="flex gap-1 place-items-center border border-gray-400 rounded-full p-1 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
<UserAvatar {user} size="sm" autoColor />
<p class="text-xs font-medium">{user.firstName} {user.lastName}</p>
</button>
{/key}
{/each}
</div>
{/if}
{#if users.length > 0}
<p class="text-xs font-medium px-5">SUGGESTIONS</p>
{#if users.length > 0}
<p class="text-xs font-medium px-5">SUGGESTIONS</p>
<div class="my-4">
{#each users as user}
<button
on:click={() => selectUser(user)}
class="w-full flex place-items-center gap-4 py-4 px-5 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all"
>
{#if selectedUsers.includes(user)}
<span
class="bg-immich-primary dark:bg-immich-dark-primary text-white dark:text-immich-dark-bg rounded-full w-12 h-12 border flex place-items-center place-content-center text-3xl dark:border-immich-dark-gray"
>✓</span
>
{:else}
<UserAvatar {user} size="md" autoColor />
{/if}
<div class="my-4">
{#each users as user}
<button
on:click={() => selectUser(user)}
class="w-full flex place-items-center gap-4 py-4 px-5 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all"
>
{#if selectedUsers.includes(user)}
<span
class="bg-immich-primary dark:bg-immich-dark-primary text-white dark:text-immich-dark-bg rounded-full w-12 h-12 border flex place-items-center place-content-center text-3xl dark:border-immich-dark-gray"
>✓</span
>
{:else}
<UserAvatar {user} size="md" autoColor />
{/if}
<div class="text-left">
<p class="text-immich-fg dark:text-immich-dark-fg">
{user.firstName}
{user.lastName}
</p>
<p class="text-xs">
{user.email}
</p>
</div>
</button>
{/each}
</div>
{:else}
<p class="text-sm p-5">
Looks like you have shared this album with all users or you don't have any user to share
with.
</p>
{/if}
<div class="text-left">
<p class="text-immich-fg dark:text-immich-dark-fg">
{user.firstName}
{user.lastName}
</p>
<p class="text-xs">
{user.email}
</p>
</div>
</button>
{/each}
</div>
{:else}
<p class="text-sm p-5">
Looks like you have shared this album with all users or you don't have any user to share with.
</p>
{/if}
{#if selectedUsers.length > 0}
<div class="flex place-content-end p-5">
<Button size="sm" rounded="lg" on:click={() => dispatch('add-user', { selectedUsers })}>
Add
</Button>
</div>
{/if}
</div>
{#if selectedUsers.length > 0}
<div class="flex place-content-end p-5">
<Button size="sm" rounded="lg" on:click={() => dispatch('add-user', { selectedUsers })}>Add</Button>
</div>
{/if}
</div>
<hr />
<div id="shared-buttons" class="flex my-4 justify-around place-items-center place-content-center">
<button
class="flex flex-col gap-2 place-items-center place-content-center hover:cursor-pointer"
on:click={onSharedLinkClick}
>
<Link size={24} />
<p class="text-sm">Create link</p>
</button>
<hr />
<div id="shared-buttons" class="flex my-4 justify-around place-items-center place-content-center">
<button
class="flex flex-col gap-2 place-items-center place-content-center hover:cursor-pointer"
on:click={onSharedLinkClick}
>
<Link size={24} />
<p class="text-sm">Create link</p>
</button>
{#if sharedLinks.length}
<button
class="flex flex-col gap-2 place-items-center place-content-center hover:cursor-pointer"
on:click={() => goto(AppRoute.SHARED_LINKS)}
>
<ShareCircle size={24} />
<p class="text-sm">View links</p>
</button>
{/if}
</div>
{#if sharedLinks.length}
<button
class="flex flex-col gap-2 place-items-center place-content-center hover:cursor-pointer"
on:click={() => goto(AppRoute.SHARED_LINKS)}
>
<ShareCircle size={24} />
<p class="text-sm">View links</p>
</button>
{/if}
</div>
</BaseModal>

View file

@ -1,56 +1,56 @@
<script lang="ts">
import { AlbumResponseDto, ThumbnailFormat, api } from '@api';
import { createEventDispatcher } from 'svelte';
import { AlbumResponseDto, ThumbnailFormat, api } from '@api';
import { createEventDispatcher } from 'svelte';
const dispatcher = createEventDispatcher();
const dispatcher = createEventDispatcher();
export let album: AlbumResponseDto;
export let variant: 'simple' | 'full' = 'full';
export let searchQuery = '';
let albumNameArray: string[] = ['', '', ''];
export let album: AlbumResponseDto;
export let variant: 'simple' | 'full' = 'full';
export let searchQuery = '';
let albumNameArray: string[] = ['', '', ''];
// This part of the code is responsible for splitting album name into 3 parts where part 2 is the search query
// It is used to highlight the search query in the album name
$: {
let { albumName } = album;
let findIndex = albumName.toLowerCase().indexOf(searchQuery.toLowerCase());
let findLength = searchQuery.length;
albumNameArray = [
albumName.slice(0, findIndex),
albumName.slice(findIndex, findIndex + findLength),
albumName.slice(findIndex + findLength)
];
}
// This part of the code is responsible for splitting album name into 3 parts where part 2 is the search query
// It is used to highlight the search query in the album name
$: {
let { albumName } = album;
let findIndex = albumName.toLowerCase().indexOf(searchQuery.toLowerCase());
let findLength = searchQuery.length;
albumNameArray = [
albumName.slice(0, findIndex),
albumName.slice(findIndex, findIndex + findLength),
albumName.slice(findIndex + findLength),
];
}
</script>
<button
on:click={() => dispatcher('album')}
class="w-full flex gap-4 px-6 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
on:click={() => dispatcher('album')}
class="w-full flex gap-4 px-6 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
<div class="h-12 w-12 rounded-xl bg-slate-300">
{#if album.albumThumbnailAssetId}
<img
src={api.getAssetThumbnailUrl(album.albumThumbnailAssetId, ThumbnailFormat.Webp)}
alt={album.albumName}
class={`object-cover h-full w-full transition-all z-0 rounded-xl duration-300 hover:shadow-lg`}
data-testid="album-image"
draggable="false"
/>
{/if}
</div>
<div class="h-12 flex flex-col items-start justify-center">
<span>{albumNameArray[0]}<b>{albumNameArray[1]}</b>{albumNameArray[2]}</span>
<span class="flex gap-1 text-sm">
{#if variant === 'simple'}
<span
>{#if album.shared}Shared{/if}
</span>
{:else}
<span>{album.assetCount} items</span>
<span
>{#if album.shared} · Shared{/if}
</span>
{/if}
</span>
</div>
<div class="h-12 w-12 rounded-xl bg-slate-300">
{#if album.albumThumbnailAssetId}
<img
src={api.getAssetThumbnailUrl(album.albumThumbnailAssetId, ThumbnailFormat.Webp)}
alt={album.albumName}
class={`object-cover h-full w-full transition-all z-0 rounded-xl duration-300 hover:shadow-lg`}
data-testid="album-image"
draggable="false"
/>
{/if}
</div>
<div class="h-12 flex flex-col items-start justify-center">
<span>{albumNameArray[0]}<b>{albumNameArray[1]}</b>{albumNameArray[2]}</span>
<span class="flex gap-1 text-sm">
{#if variant === 'simple'}
<span
>{#if album.shared}Shared{/if}
</span>
{:else}
<span>{album.assetCount} items</span>
<span
>{#if album.shared} · Shared{/if}
</span>
{/if}
</span>
</div>
</button>

View file

@ -1,155 +1,135 @@
<script lang="ts">
import { page } from '$app/stores';
import { clickOutside } from '$lib/utils/click-outside';
import type { AssetResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
import ContentCopy from 'svelte-material-icons/ContentCopy.svelte';
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
import Heart from 'svelte-material-icons/Heart.svelte';
import HeartOutline from 'svelte-material-icons/HeartOutline.svelte';
import InformationOutline from 'svelte-material-icons/InformationOutline.svelte';
import MagnifyPlusOutline from 'svelte-material-icons/MagnifyPlusOutline.svelte';
import MagnifyMinusOutline from 'svelte-material-icons/MagnifyMinusOutline.svelte';
import MotionPauseOutline from 'svelte-material-icons/MotionPauseOutline.svelte';
import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { page } from '$app/stores';
import { clickOutside } from '$lib/utils/click-outside';
import type { AssetResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
import ContentCopy from 'svelte-material-icons/ContentCopy.svelte';
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
import Heart from 'svelte-material-icons/Heart.svelte';
import HeartOutline from 'svelte-material-icons/HeartOutline.svelte';
import InformationOutline from 'svelte-material-icons/InformationOutline.svelte';
import MagnifyPlusOutline from 'svelte-material-icons/MagnifyPlusOutline.svelte';
import MagnifyMinusOutline from 'svelte-material-icons/MagnifyMinusOutline.svelte';
import MotionPauseOutline from 'svelte-material-icons/MotionPauseOutline.svelte';
import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
import { photoZoomState } from '$lib/stores/zoom-image.store';
export let asset: AssetResponseDto;
export let showCopyButton: boolean;
export let showZoomButton: boolean;
export let showMotionPlayButton: boolean;
export let isMotionPhotoPlaying = false;
export let showDownloadButton: boolean;
export let asset: AssetResponseDto;
export let showCopyButton: boolean;
export let showZoomButton: boolean;
export let showMotionPlayButton: boolean;
export let isMotionPhotoPlaying = false;
export let showDownloadButton: boolean;
const isOwner = asset.ownerId === $page.data.user?.id;
const isOwner = asset.ownerId === $page.data.user?.id;
const dispatch = createEventDispatcher();
const dispatch = createEventDispatcher();
let contextMenuPosition = { x: 0, y: 0 };
let isShowAssetOptions = false;
let contextMenuPosition = { x: 0, y: 0 };
let isShowAssetOptions = false;
const showOptionsMenu = ({ x, y }: MouseEvent) => {
contextMenuPosition = { x, y };
isShowAssetOptions = !isShowAssetOptions;
};
const showOptionsMenu = ({ x, y }: MouseEvent) => {
contextMenuPosition = { x, y };
isShowAssetOptions = !isShowAssetOptions;
};
const onMenuClick = (eventName: string) => {
isShowAssetOptions = false;
dispatch(eventName);
};
const onMenuClick = (eventName: string) => {
isShowAssetOptions = false;
dispatch(eventName);
};
</script>
<div
class="h-16 flex justify-between place-items-center px-3 transition-transform duration-200 z-[1001] bg-gradient-to-b from-black/40"
class="h-16 flex justify-between place-items-center px-3 transition-transform duration-200 z-[1001] bg-gradient-to-b from-black/40"
>
<div class="text-white">
<CircleIconButton isOpacity={true} logo={ArrowLeft} on:click={() => dispatch('goBack')} />
</div>
<div class="text-white flex gap-2 justify-end w-[calc(100%-3rem)] overflow-hidden">
{#if showMotionPlayButton}
{#if isMotionPhotoPlaying}
<CircleIconButton
isOpacity={true}
logo={MotionPauseOutline}
title="Stop Motion Photo"
on:click={() => dispatch('stopMotionPhoto')}
/>
{:else}
<CircleIconButton
isOpacity={true}
logo={MotionPlayOutline}
title="Play Motion Photo"
on:click={() => dispatch('playMotionPhoto')}
/>
{/if}
{/if}
{#if showZoomButton}
<CircleIconButton
isOpacity={true}
hideMobile={true}
logo={$photoZoomState && $photoZoomState.currentZoom > 1
? MagnifyMinusOutline
: MagnifyPlusOutline}
title="Zoom Image"
on:click={() => {
const zoomImage = new CustomEvent('zoomImage');
window.dispatchEvent(zoomImage);
}}
/>
{/if}
{#if showCopyButton}
<CircleIconButton
isOpacity={true}
logo={ContentCopy}
title="Copy Image"
on:click={() => {
const copyEvent = new CustomEvent('copyImage');
window.dispatchEvent(copyEvent);
}}
/>
{/if}
<div class="text-white">
<CircleIconButton isOpacity={true} logo={ArrowLeft} on:click={() => dispatch('goBack')} />
</div>
<div class="text-white flex gap-2 justify-end w-[calc(100%-3rem)] overflow-hidden">
{#if showMotionPlayButton}
{#if isMotionPhotoPlaying}
<CircleIconButton
isOpacity={true}
logo={MotionPauseOutline}
title="Stop Motion Photo"
on:click={() => dispatch('stopMotionPhoto')}
/>
{:else}
<CircleIconButton
isOpacity={true}
logo={MotionPlayOutline}
title="Play Motion Photo"
on:click={() => dispatch('playMotionPhoto')}
/>
{/if}
{/if}
{#if showZoomButton}
<CircleIconButton
isOpacity={true}
hideMobile={true}
logo={$photoZoomState && $photoZoomState.currentZoom > 1 ? MagnifyMinusOutline : MagnifyPlusOutline}
title="Zoom Image"
on:click={() => {
const zoomImage = new CustomEvent('zoomImage');
window.dispatchEvent(zoomImage);
}}
/>
{/if}
{#if showCopyButton}
<CircleIconButton
isOpacity={true}
logo={ContentCopy}
title="Copy Image"
on:click={() => {
const copyEvent = new CustomEvent('copyImage');
window.dispatchEvent(copyEvent);
}}
/>
{/if}
{#if showDownloadButton}
<CircleIconButton
isOpacity={true}
logo={CloudDownloadOutline}
on:click={() => dispatch('download')}
title="Download"
/>
{/if}
<CircleIconButton
isOpacity={true}
logo={InformationOutline}
on:click={() => dispatch('showDetail')}
title="Info"
/>
{#if isOwner}
<CircleIconButton
isOpacity={true}
logo={asset.isFavorite ? Heart : HeartOutline}
on:click={() => dispatch('favorite')}
title="Favorite"
/>
{/if}
{#if showDownloadButton}
<CircleIconButton
isOpacity={true}
logo={CloudDownloadOutline}
on:click={() => dispatch('download')}
title="Download"
/>
{/if}
<CircleIconButton isOpacity={true} logo={InformationOutline} on:click={() => dispatch('showDetail')} title="Info" />
{#if isOwner}
<CircleIconButton
isOpacity={true}
logo={asset.isFavorite ? Heart : HeartOutline}
on:click={() => dispatch('favorite')}
title="Favorite"
/>
{/if}
{#if isOwner}
<CircleIconButton
isOpacity={true}
logo={DeleteOutline}
on:click={() => dispatch('delete')}
title="Delete"
/>
<div use:clickOutside on:outclick={() => (isShowAssetOptions = false)}>
<CircleIconButton
isOpacity={true}
logo={DotsVertical}
on:click={showOptionsMenu}
title="More"
>
{#if isShowAssetOptions}
<ContextMenu {...contextMenuPosition} direction="left">
<MenuOption on:click={() => onMenuClick('addToAlbum')} text="Add to Album" />
<MenuOption
on:click={() => onMenuClick('addToSharedAlbum')}
text="Add to Shared Album"
/>
{#if isOwner}
<CircleIconButton isOpacity={true} logo={DeleteOutline} on:click={() => dispatch('delete')} title="Delete" />
<div use:clickOutside on:outclick={() => (isShowAssetOptions = false)}>
<CircleIconButton isOpacity={true} logo={DotsVertical} on:click={showOptionsMenu} title="More">
{#if isShowAssetOptions}
<ContextMenu {...contextMenuPosition} direction="left">
<MenuOption on:click={() => onMenuClick('addToAlbum')} text="Add to Album" />
<MenuOption on:click={() => onMenuClick('addToSharedAlbum')} text="Add to Shared Album" />
{#if isOwner}
<MenuOption
on:click={() => dispatch('toggleArchive')}
text={asset.isArchived ? 'Unarchive' : 'Archive'}
/>
{/if}
</ContextMenu>
{/if}
</CircleIconButton>
</div>
{/if}
</div>
{#if isOwner}
<MenuOption
on:click={() => dispatch('toggleArchive')}
text={asset.isArchived ? 'Unarchive' : 'Archive'}
/>
{/if}
</ContextMenu>
{/if}
</CircleIconButton>
</div>
{/if}
</div>
</div>

View file

@ -1,399 +1,386 @@
<script lang="ts">
import { goto } from '$app/navigation';
import {
AlbumResponseDto,
api,
AssetResponseDto,
AssetTypeEnum,
SharedLinkResponseDto
} from '@api';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
import ImageBrokenVariant from 'svelte-material-icons/ImageBrokenVariant.svelte';
import { fly } from 'svelte/transition';
import AlbumSelectionModal from '../shared-components/album-selection-modal.svelte';
import {
notificationController,
NotificationType
} from '../shared-components/notification/notification';
import AssetViewerNavBar from './asset-viewer-nav-bar.svelte';
import DetailPanel from './detail-panel.svelte';
import PhotoViewer from './photo-viewer.svelte';
import VideoViewer from './video-viewer.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import { goto } from '$app/navigation';
import { AlbumResponseDto, api, AssetResponseDto, AssetTypeEnum, SharedLinkResponseDto } from '@api';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
import ImageBrokenVariant from 'svelte-material-icons/ImageBrokenVariant.svelte';
import { fly } from 'svelte/transition';
import AlbumSelectionModal from '../shared-components/album-selection-modal.svelte';
import { notificationController, NotificationType } from '../shared-components/notification/notification';
import AssetViewerNavBar from './asset-viewer-nav-bar.svelte';
import DetailPanel from './detail-panel.svelte';
import PhotoViewer from './photo-viewer.svelte';
import VideoViewer from './video-viewer.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import { assetStore } from '$lib/stores/assets.store';
import { isShowDetail } from '$lib/stores/preferences.store';
import { addAssetsToAlbum, downloadFile } from '$lib/utils/asset-utils';
import { browser } from '$app/environment';
import { assetStore } from '$lib/stores/assets.store';
import { isShowDetail } from '$lib/stores/preferences.store';
import { addAssetsToAlbum, downloadFile } from '$lib/utils/asset-utils';
import { browser } from '$app/environment';
export let asset: AssetResponseDto;
export let publicSharedKey = '';
export let showNavigation = true;
export let sharedLink: SharedLinkResponseDto | undefined = undefined;
export let asset: AssetResponseDto;
export let publicSharedKey = '';
export let showNavigation = true;
export let sharedLink: SharedLinkResponseDto | undefined = undefined;
const dispatch = createEventDispatcher();
let halfLeftHover = false;
let halfRightHover = false;
let appearsInAlbums: AlbumResponseDto[] = [];
let isShowAlbumPicker = false;
let isShowDeleteConfirmation = false;
let addToSharedAlbum = true;
let shouldPlayMotionPhoto = false;
let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : true;
let canCopyImagesToClipboard: boolean;
const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo.key);
const dispatch = createEventDispatcher();
let halfLeftHover = false;
let halfRightHover = false;
let appearsInAlbums: AlbumResponseDto[] = [];
let isShowAlbumPicker = false;
let isShowDeleteConfirmation = false;
let addToSharedAlbum = true;
let shouldPlayMotionPhoto = false;
let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : true;
let canCopyImagesToClipboard: boolean;
const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo.key);
onMount(async () => {
document.addEventListener('keydown', onKeyboardPress);
onMount(async () => {
document.addEventListener('keydown', onKeyboardPress);
getAllAlbums();
getAllAlbums();
// Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295
// TODO: Move to regular import once the package correctly supports ESM.
const module = await import('copy-image-clipboard');
canCopyImagesToClipboard = module.canCopyImagesToClipboard();
});
// Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295
// TODO: Move to regular import once the package correctly supports ESM.
const module = await import('copy-image-clipboard');
canCopyImagesToClipboard = module.canCopyImagesToClipboard();
});
onDestroy(() => {
if (browser) {
document.removeEventListener('keydown', onKeyboardPress);
}
});
onDestroy(() => {
if (browser) {
document.removeEventListener('keydown', onKeyboardPress);
}
});
$: asset.id && getAllAlbums(); // Update the album information when the asset ID changes
$: asset.id && getAllAlbums(); // Update the album information when the asset ID changes
const getAllAlbums = async () => {
try {
const { data } = await api.albumApi.getAllAlbums({ assetId: asset.id });
appearsInAlbums = data;
} catch (e) {
console.error('Error getting album that asset belong to', e);
}
};
const getAllAlbums = async () => {
try {
const { data } = await api.albumApi.getAllAlbums({ assetId: asset.id });
appearsInAlbums = data;
} catch (e) {
console.error('Error getting album that asset belong to', e);
}
};
const handleKeyboardPress = (key: string) => {
switch (key) {
case 'Escape':
closeViewer();
return;
case 'Delete':
isShowDeleteConfirmation = true;
return;
case 'i':
$isShowDetail = !$isShowDetail;
return;
case 'ArrowLeft':
navigateAssetBackward();
return;
case 'ArrowRight':
navigateAssetForward();
return;
}
};
const handleKeyboardPress = (key: string) => {
switch (key) {
case 'Escape':
closeViewer();
return;
case 'Delete':
isShowDeleteConfirmation = true;
return;
case 'i':
$isShowDetail = !$isShowDetail;
return;
case 'ArrowLeft':
navigateAssetBackward();
return;
case 'ArrowRight':
navigateAssetForward();
return;
}
};
const handleCloseViewer = () => {
$isShowDetail = false;
closeViewer();
};
const handleCloseViewer = () => {
$isShowDetail = false;
closeViewer();
};
const closeViewer = () => {
dispatch('close');
};
const closeViewer = () => {
dispatch('close');
};
const navigateAssetForward = (e?: Event) => {
e?.stopPropagation();
dispatch('navigate-next');
};
const navigateAssetForward = (e?: Event) => {
e?.stopPropagation();
dispatch('navigate-next');
};
const navigateAssetBackward = (e?: Event) => {
e?.stopPropagation();
dispatch('navigate-previous');
};
const navigateAssetBackward = (e?: Event) => {
e?.stopPropagation();
dispatch('navigate-previous');
};
const showDetailInfoHandler = () => {
$isShowDetail = !$isShowDetail;
};
const showDetailInfoHandler = () => {
$isShowDetail = !$isShowDetail;
};
const deleteAsset = async () => {
try {
const { data: deletedAssets } = await api.assetApi.deleteAsset({
deleteAssetDto: {
ids: [asset.id]
}
});
const deleteAsset = async () => {
try {
const { data: deletedAssets } = await api.assetApi.deleteAsset({
deleteAssetDto: {
ids: [asset.id],
},
});
navigateAssetForward();
navigateAssetForward();
for (const asset of deletedAssets) {
if (asset.status == 'SUCCESS') {
assetStore.removeAsset(asset.id);
}
}
} catch (e) {
notificationController.show({
type: NotificationType.Error,
message: 'Error deleting this asset, check console for more details'
});
console.error('Error deleteAsset', e);
} finally {
isShowDeleteConfirmation = false;
}
};
for (const asset of deletedAssets) {
if (asset.status == 'SUCCESS') {
assetStore.removeAsset(asset.id);
}
}
} catch (e) {
notificationController.show({
type: NotificationType.Error,
message: 'Error deleting this asset, check console for more details',
});
console.error('Error deleteAsset', e);
} finally {
isShowDeleteConfirmation = false;
}
};
const toggleFavorite = async () => {
const { data } = await api.assetApi.updateAsset({
id: asset.id,
updateAssetDto: {
isFavorite: !asset.isFavorite
}
});
const toggleFavorite = async () => {
const { data } = await api.assetApi.updateAsset({
id: asset.id,
updateAssetDto: {
isFavorite: !asset.isFavorite,
},
});
asset.isFavorite = data.isFavorite;
assetStore.updateAsset(asset.id, data.isFavorite);
};
asset.isFavorite = data.isFavorite;
assetStore.updateAsset(asset.id, data.isFavorite);
};
const openAlbumPicker = (shared: boolean) => {
isShowAlbumPicker = true;
addToSharedAlbum = shared;
};
const openAlbumPicker = (shared: boolean) => {
isShowAlbumPicker = true;
addToSharedAlbum = shared;
};
const handleAddToNewAlbum = (event: CustomEvent) => {
isShowAlbumPicker = false;
const handleAddToNewAlbum = (event: CustomEvent) => {
isShowAlbumPicker = false;
const { albumName }: { albumName: string } = event.detail;
api.albumApi
.createAlbum({ createAlbumDto: { albumName, assetIds: [asset.id] } })
.then((response) => {
const album = response.data;
goto('/albums/' + album.id);
});
};
const { albumName }: { albumName: string } = event.detail;
api.albumApi.createAlbum({ createAlbumDto: { albumName, assetIds: [asset.id] } }).then((response) => {
const album = response.data;
goto('/albums/' + album.id);
});
};
const handleAddToAlbum = async (event: CustomEvent<{ album: AlbumResponseDto }>) => {
isShowAlbumPicker = false;
const album = event.detail.album;
const handleAddToAlbum = async (event: CustomEvent<{ album: AlbumResponseDto }>) => {
isShowAlbumPicker = false;
const album = event.detail.album;
addAssetsToAlbum(album.id, [asset.id]).then((dto) => {
if (dto.successfullyAdded === 1 && dto.album) {
appearsInAlbums = [...appearsInAlbums, dto.album];
}
});
};
addAssetsToAlbum(album.id, [asset.id]).then((dto) => {
if (dto.successfullyAdded === 1 && dto.album) {
appearsInAlbums = [...appearsInAlbums, dto.album];
}
});
};
const disableKeyDownEvent = () => {
if (browser) {
document.removeEventListener('keydown', onKeyboardPress);
}
};
const disableKeyDownEvent = () => {
if (browser) {
document.removeEventListener('keydown', onKeyboardPress);
}
};
const enableKeyDownEvent = () => {
if (browser) {
document.addEventListener('keydown', onKeyboardPress);
}
};
const enableKeyDownEvent = () => {
if (browser) {
document.addEventListener('keydown', onKeyboardPress);
}
};
const toggleArchive = async () => {
try {
const { data } = await api.assetApi.updateAsset({
id: asset.id,
updateAssetDto: {
isArchived: !asset.isArchived
}
});
const toggleArchive = async () => {
try {
const { data } = await api.assetApi.updateAsset({
id: asset.id,
updateAssetDto: {
isArchived: !asset.isArchived,
},
});
asset.isArchived = data.isArchived;
asset.isArchived = data.isArchived;
if (data.isArchived) {
dispatch('archived', data);
} else {
dispatch('unarchived', data);
}
if (data.isArchived) {
dispatch('archived', data);
} else {
dispatch('unarchived', data);
}
notificationController.show({
type: NotificationType.Info,
message: asset.isArchived ? `Added to archive` : `Removed from archive`
});
} catch (error) {
console.error(error);
notificationController.show({
type: NotificationType.Error,
message: `Error ${
asset.isArchived ? 'archiving' : 'unarchiving'
} asset, check console for more details`
});
}
};
notificationController.show({
type: NotificationType.Info,
message: asset.isArchived ? `Added to archive` : `Removed from archive`,
});
} catch (error) {
console.error(error);
notificationController.show({
type: NotificationType.Error,
message: `Error ${asset.isArchived ? 'archiving' : 'unarchiving'} asset, check console for more details`,
});
}
};
const getAssetType = () => {
switch (asset.type) {
case 'IMAGE':
return 'Photo';
case 'VIDEO':
return 'Video';
default:
return 'Asset';
}
};
const getAssetType = () => {
switch (asset.type) {
case 'IMAGE':
return 'Photo';
case 'VIDEO':
return 'Video';
default:
return 'Asset';
}
};
</script>
<section
id="immich-asset-viewer"
class="fixed h-screen w-screen left-0 top-0 overflow-y-hidden bg-black z-[1001] grid grid-rows-[64px_1fr] grid-cols-4"
id="immich-asset-viewer"
class="fixed h-screen w-screen left-0 top-0 overflow-y-hidden bg-black z-[1001] grid grid-rows-[64px_1fr] grid-cols-4"
>
<div class="col-start-1 col-span-4 row-start-1 row-span-1 z-[1000] transition-transform">
<AssetViewerNavBar
{asset}
isMotionPhotoPlaying={shouldPlayMotionPhoto}
showCopyButton={canCopyImagesToClipboard && asset.type === AssetTypeEnum.Image}
showZoomButton={asset.type === AssetTypeEnum.Image}
showMotionPlayButton={!!asset.livePhotoVideoId}
showDownloadButton={shouldShowDownloadButton}
on:goBack={closeViewer}
on:showDetail={showDetailInfoHandler}
on:download={() => downloadFile(asset, publicSharedKey)}
on:delete={() => (isShowDeleteConfirmation = true)}
on:favorite={toggleFavorite}
on:addToAlbum={() => openAlbumPicker(false)}
on:addToSharedAlbum={() => openAlbumPicker(true)}
on:playMotionPhoto={() => (shouldPlayMotionPhoto = true)}
on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)}
on:toggleArchive={toggleArchive}
/>
</div>
<div class="col-start-1 col-span-4 row-start-1 row-span-1 z-[1000] transition-transform">
<AssetViewerNavBar
{asset}
isMotionPhotoPlaying={shouldPlayMotionPhoto}
showCopyButton={canCopyImagesToClipboard && asset.type === AssetTypeEnum.Image}
showZoomButton={asset.type === AssetTypeEnum.Image}
showMotionPlayButton={!!asset.livePhotoVideoId}
showDownloadButton={shouldShowDownloadButton}
on:goBack={closeViewer}
on:showDetail={showDetailInfoHandler}
on:download={() => downloadFile(asset, publicSharedKey)}
on:delete={() => (isShowDeleteConfirmation = true)}
on:favorite={toggleFavorite}
on:addToAlbum={() => openAlbumPicker(false)}
on:addToSharedAlbum={() => openAlbumPicker(true)}
on:playMotionPhoto={() => (shouldPlayMotionPhoto = true)}
on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)}
on:toggleArchive={toggleArchive}
/>
</div>
{#if showNavigation}
<div
class={`row-start-2 row-span-end col-start-1 flex place-items-center hover:cursor-pointer w-1/4 mb-[60px] ${
asset.type === AssetTypeEnum.Video ? '' : 'z-[999]'
}`}
on:mouseenter={() => {
halfLeftHover = true;
halfRightHover = false;
}}
on:mouseleave={() => {
halfLeftHover = false;
}}
on:click={navigateAssetBackward}
on:keydown={navigateAssetBackward}
>
<button
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 z-[1000] text-gray-500 mx-4"
class:navigation-button-hover={halfLeftHover}
on:click={navigateAssetBackward}
>
<ChevronLeft size="36" />
</button>
</div>
{/if}
{#if showNavigation}
<div
class={`row-start-2 row-span-end col-start-1 flex place-items-center hover:cursor-pointer w-1/4 mb-[60px] ${
asset.type === AssetTypeEnum.Video ? '' : 'z-[999]'
}`}
on:mouseenter={() => {
halfLeftHover = true;
halfRightHover = false;
}}
on:mouseleave={() => {
halfLeftHover = false;
}}
on:click={navigateAssetBackward}
on:keydown={navigateAssetBackward}
>
<button
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 z-[1000] text-gray-500 mx-4"
class:navigation-button-hover={halfLeftHover}
on:click={navigateAssetBackward}
>
<ChevronLeft size="36" />
</button>
</div>
{/if}
<div class="row-start-1 row-span-full col-start-1 col-span-4">
{#key asset.id}
{#if !asset.resized}
<div class="h-full w-full flex justify-center">
<div
class="h-full bg-gray-100 dark:bg-immich-dark-gray flex items-center justify-center aspect-square px-auto"
>
<ImageBrokenVariant size="25%" />
</div>
</div>
{:else if asset.type === AssetTypeEnum.Image}
{#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
<VideoViewer
{publicSharedKey}
assetId={asset.livePhotoVideoId}
on:close={closeViewer}
on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
/>
{:else}
<PhotoViewer {publicSharedKey} {asset} on:close={closeViewer} />
{/if}
{:else}
<VideoViewer {publicSharedKey} assetId={asset.id} on:close={closeViewer} />
{/if}
{/key}
</div>
<div class="row-start-1 row-span-full col-start-1 col-span-4">
{#key asset.id}
{#if !asset.resized}
<div class="h-full w-full flex justify-center">
<div
class="h-full bg-gray-100 dark:bg-immich-dark-gray flex items-center justify-center aspect-square px-auto"
>
<ImageBrokenVariant size="25%" />
</div>
</div>
{:else if asset.type === AssetTypeEnum.Image}
{#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
<VideoViewer
{publicSharedKey}
assetId={asset.livePhotoVideoId}
on:close={closeViewer}
on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
/>
{:else}
<PhotoViewer {publicSharedKey} {asset} on:close={closeViewer} />
{/if}
{:else}
<VideoViewer {publicSharedKey} assetId={asset.id} on:close={closeViewer} />
{/if}
{/key}
</div>
{#if showNavigation}
<div
class={`row-start-2 row-span-full col-start-4 flex justify-end place-items-center hover:cursor-pointer w-1/4 justify-self-end mb-[60px] ${
asset.type === AssetTypeEnum.Video ? '' : 'z-[500]'
}`}
on:click={navigateAssetForward}
on:keydown={navigateAssetForward}
on:mouseenter={() => {
halfLeftHover = false;
halfRightHover = true;
}}
on:mouseleave={() => {
halfRightHover = false;
}}
>
<button
class="rounded-full p-3 hover:bg-gray-500 hover:text-white text-gray-500 mx-4"
class:navigation-button-hover={halfRightHover}
on:click={navigateAssetForward}
>
<ChevronRight size="36" />
</button>
</div>
{/if}
{#if showNavigation}
<div
class={`row-start-2 row-span-full col-start-4 flex justify-end place-items-center hover:cursor-pointer w-1/4 justify-self-end mb-[60px] ${
asset.type === AssetTypeEnum.Video ? '' : 'z-[500]'
}`}
on:click={navigateAssetForward}
on:keydown={navigateAssetForward}
on:mouseenter={() => {
halfLeftHover = false;
halfRightHover = true;
}}
on:mouseleave={() => {
halfRightHover = false;
}}
>
<button
class="rounded-full p-3 hover:bg-gray-500 hover:text-white text-gray-500 mx-4"
class:navigation-button-hover={halfRightHover}
on:click={navigateAssetForward}
>
<ChevronRight size="36" />
</button>
</div>
{/if}
{#if $isShowDetail}
<div
transition:fly={{ duration: 150 }}
id="detail-panel"
class="bg-immich-bg w-[360px] z-[1002] row-span-full transition-all overflow-y-auto dark:bg-immich-dark-bg dark:border-l dark:border-l-immich-dark-gray"
translate="yes"
>
<DetailPanel
{asset}
albums={appearsInAlbums}
on:close={() => ($isShowDetail = false)}
on:close-viewer={handleCloseViewer}
on:description-focus-in={disableKeyDownEvent}
on:description-focus-out={enableKeyDownEvent}
/>
</div>
{/if}
{#if $isShowDetail}
<div
transition:fly={{ duration: 150 }}
id="detail-panel"
class="bg-immich-bg w-[360px] z-[1002] row-span-full transition-all overflow-y-auto dark:bg-immich-dark-bg dark:border-l dark:border-l-immich-dark-gray"
translate="yes"
>
<DetailPanel
{asset}
albums={appearsInAlbums}
on:close={() => ($isShowDetail = false)}
on:close-viewer={handleCloseViewer}
on:description-focus-in={disableKeyDownEvent}
on:description-focus-out={enableKeyDownEvent}
/>
</div>
{/if}
{#if isShowAlbumPicker}
<AlbumSelectionModal
shared={addToSharedAlbum}
on:newAlbum={handleAddToNewAlbum}
on:newSharedAlbum={handleAddToNewAlbum}
on:album={handleAddToAlbum}
on:close={() => (isShowAlbumPicker = false)}
/>
{/if}
{#if isShowAlbumPicker}
<AlbumSelectionModal
shared={addToSharedAlbum}
on:newAlbum={handleAddToNewAlbum}
on:newSharedAlbum={handleAddToNewAlbum}
on:album={handleAddToAlbum}
on:close={() => (isShowAlbumPicker = false)}
/>
{/if}
{#if isShowDeleteConfirmation}
<ConfirmDialogue
title="Delete {getAssetType()}"
confirmText="Delete"
on:confirm={deleteAsset}
on:cancel={() => (isShowDeleteConfirmation = false)}
>
<svelte:fragment slot="prompt">
<p>
Are you sure you want to delete this {getAssetType().toLowerCase()}? This will also remove
it from its album(s).
</p>
<p><b>You cannot undo this action!</b></p>
</svelte:fragment>
</ConfirmDialogue>
{/if}
{#if isShowDeleteConfirmation}
<ConfirmDialogue
title="Delete {getAssetType()}"
confirmText="Delete"
on:confirm={deleteAsset}
on:cancel={() => (isShowDeleteConfirmation = false)}
>
<svelte:fragment slot="prompt">
<p>
Are you sure you want to delete this {getAssetType().toLowerCase()}? This will also remove it from its
album(s).
</p>
<p><b>You cannot undo this action!</b></p>
</svelte:fragment>
</ConfirmDialogue>
{/if}
</section>
<style>
#immich-asset-viewer {
contain: layout;
}
#immich-asset-viewer {
contain: layout;
}
.navigation-button-hover {
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
color: rgb(255 255 255 / var(--tw-text-opacity));
transition: all 150ms;
}
.navigation-button-hover {
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
color: rgb(255 255 255 / var(--tw-text-opacity));
transition: all 150ms;
}
</style>

View file

@ -1,296 +1,293 @@
<script lang="ts">
import { page } from '$app/stores';
import { locale } from '$lib/stores/preferences.store';
import type { LatLngTuple } from 'leaflet';
import { DateTime } from 'luxon';
import Calendar from 'svelte-material-icons/Calendar.svelte';
import CameraIris from 'svelte-material-icons/CameraIris.svelte';
import Close from 'svelte-material-icons/Close.svelte';
import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
import MapMarkerOutline from 'svelte-material-icons/MapMarkerOutline.svelte';
import { createEventDispatcher } from 'svelte';
import { AssetResponseDto, AlbumResponseDto, api, ThumbnailFormat } from '@api';
import { asByteUnitString } from '../../utils/byte-units';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import { getAssetFilename } from '$lib/utils/asset-utils';
import { page } from '$app/stores';
import { locale } from '$lib/stores/preferences.store';
import type { LatLngTuple } from 'leaflet';
import { DateTime } from 'luxon';
import Calendar from 'svelte-material-icons/Calendar.svelte';
import CameraIris from 'svelte-material-icons/CameraIris.svelte';
import Close from 'svelte-material-icons/Close.svelte';
import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
import MapMarkerOutline from 'svelte-material-icons/MapMarkerOutline.svelte';
import { createEventDispatcher } from 'svelte';
import { AssetResponseDto, AlbumResponseDto, api, ThumbnailFormat } from '@api';
import { asByteUnitString } from '../../utils/byte-units';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import { getAssetFilename } from '$lib/utils/asset-utils';
export let asset: AssetResponseDto;
export let albums: AlbumResponseDto[] = [];
let textarea: HTMLTextAreaElement;
let description: string;
export let asset: AssetResponseDto;
export let albums: AlbumResponseDto[] = [];
let textarea: HTMLTextAreaElement;
let description: string;
$: {
// Get latest description from server
if (asset.id) {
api.assetApi.getAssetById({ id: asset.id }).then((res) => {
people = res.data?.people || [];
textarea.value = res.data?.exifInfo?.description || '';
});
}
}
$: {
// Get latest description from server
if (asset.id) {
api.assetApi.getAssetById({ id: asset.id }).then((res) => {
people = res.data?.people || [];
textarea.value = res.data?.exifInfo?.description || '';
});
}
}
$: latlng = (() => {
const lat = asset.exifInfo?.latitude;
const lng = asset.exifInfo?.longitude;
$: latlng = (() => {
const lat = asset.exifInfo?.latitude;
const lng = asset.exifInfo?.longitude;
if (lat && lng) {
return [lat, lng] as LatLngTuple;
}
})();
if (lat && lng) {
return [lat, lng] as LatLngTuple;
}
})();
$: people = asset.people || [];
$: people = asset.people || [];
const dispatch = createEventDispatcher();
const dispatch = createEventDispatcher();
const getMegapixel = (width: number, height: number): number | undefined => {
const megapixel = Math.round((height * width) / 1_000_000);
const getMegapixel = (width: number, height: number): number | undefined => {
const megapixel = Math.round((height * width) / 1_000_000);
if (megapixel) {
return megapixel;
}
if (megapixel) {
return megapixel;
}
return undefined;
};
return undefined;
};
const autoGrowHeight = (e: Event) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = 'auto';
target.style.height = `${target.scrollHeight}px`;
};
const autoGrowHeight = (e: Event) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = 'auto';
target.style.height = `${target.scrollHeight}px`;
};
const handleFocusIn = () => {
dispatch('description-focus-in');
};
const handleFocusIn = () => {
dispatch('description-focus-in');
};
const handleFocusOut = async () => {
dispatch('description-focus-out');
try {
await api.assetApi.updateAsset({
id: asset.id,
updateAssetDto: {
description: description
}
});
} catch (error) {
console.error(error);
}
};
const handleFocusOut = async () => {
dispatch('description-focus-out');
try {
await api.assetApi.updateAsset({
id: asset.id,
updateAssetDto: {
description: description,
},
});
} catch (error) {
console.error(error);
}
};
</script>
<section class="p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
<div class="flex place-items-center gap-2">
<button
class="rounded-full p-3 flex place-items-center place-content-center hover:bg-gray-200 transition-colors dark:text-immich-dark-fg dark:hover:bg-gray-900"
on:click={() => dispatch('close')}
>
<Close size="24" />
</button>
<div class="flex place-items-center gap-2">
<button
class="rounded-full p-3 flex place-items-center place-content-center hover:bg-gray-200 transition-colors dark:text-immich-dark-fg dark:hover:bg-gray-900"
on:click={() => dispatch('close')}
>
<Close size="24" />
</button>
<p class="text-immich-fg dark:text-immich-dark-fg text-lg">Info</p>
</div>
<p class="text-immich-fg dark:text-immich-dark-fg text-lg">Info</p>
</div>
<section class="mx-4 mt-10">
<textarea
bind:this={textarea}
class="max-h-[500px]
<section class="mx-4 mt-10">
<textarea
bind:this={textarea}
class="max-h-[500px]
text-base text-black bg-transparent dark:text-white border-b focus:border-b-2 border-gray-500 w-full focus:border-immich-primary dark:focus:border-immich-dark-primary transition-all resize-none overflow-hidden outline-none disabled:border-none"
placeholder={$page?.data?.user?.id !== asset.ownerId ? '' : 'Add a description'}
style:display={$page?.data?.user?.id !== asset.ownerId && textarea?.value == ''
? 'none'
: 'block'}
on:focusin={handleFocusIn}
on:focusout={handleFocusOut}
on:input={autoGrowHeight}
bind:value={description}
disabled={$page?.data?.user?.id !== asset.ownerId}
/>
</section>
placeholder={$page?.data?.user?.id !== asset.ownerId ? '' : 'Add a description'}
style:display={$page?.data?.user?.id !== asset.ownerId && textarea?.value == '' ? 'none' : 'block'}
on:focusin={handleFocusIn}
on:focusout={handleFocusOut}
on:input={autoGrowHeight}
bind:value={description}
disabled={$page?.data?.user?.id !== asset.ownerId}
/>
</section>
{#if people.length > 0}
<section class="px-4 py-4 text-sm">
<h2>PEOPLE</h2>
{#if people.length > 0}
<section class="px-4 py-4 text-sm">
<h2>PEOPLE</h2>
<div class="flex flex-wrap gap-2 mt-4">
{#each people as person (person.id)}
<a href="/people/{person.id}" class="w-[90px]" on:click={() => dispatch('close-viewer')}>
<ImageThumbnail
curve
shadow
url={api.getPeopleThumbnailUrl(person.id)}
altText={person.name}
widthStyle="90px"
heightStyle="90px"
thumbhash={null}
/>
<p class="font-medium mt-1 truncate">{person.name}</p>
</a>
{/each}
</div>
</section>
{/if}
<div class="flex flex-wrap gap-2 mt-4">
{#each people as person (person.id)}
<a href="/people/{person.id}" class="w-[90px]" on:click={() => dispatch('close-viewer')}>
<ImageThumbnail
curve
shadow
url={api.getPeopleThumbnailUrl(person.id)}
altText={person.name}
widthStyle="90px"
heightStyle="90px"
thumbhash={null}
/>
<p class="font-medium mt-1 truncate">{person.name}</p>
</a>
{/each}
</div>
</section>
{/if}
<div class="px-4 py-4">
{#if !asset.exifInfo}
<p class="text-sm">NO EXIF INFO AVAILABLE</p>
{:else}
<p class="text-sm">DETAILS</p>
{/if}
<div class="px-4 py-4">
{#if !asset.exifInfo}
<p class="text-sm">NO EXIF INFO AVAILABLE</p>
{:else}
<p class="text-sm">DETAILS</p>
{/if}
{#if asset.exifInfo?.dateTimeOriginal}
{@const assetDateTimeOriginal = DateTime.fromISO(asset.exifInfo.dateTimeOriginal, {
zone: asset.exifInfo.timeZone ?? undefined
})}
<div class="flex gap-4 py-4">
<div>
<Calendar size="24" />
</div>
{#if asset.exifInfo?.dateTimeOriginal}
{@const assetDateTimeOriginal = DateTime.fromISO(asset.exifInfo.dateTimeOriginal, {
zone: asset.exifInfo.timeZone ?? undefined,
})}
<div class="flex gap-4 py-4">
<div>
<Calendar size="24" />
</div>
<div>
<p>
{assetDateTimeOriginal.toLocaleString(
{
month: 'short',
day: 'numeric',
year: 'numeric'
},
{ locale: $locale }
)}
</p>
<div class="flex gap-2 text-sm">
<p>
{assetDateTimeOriginal.toLocaleString(
{
weekday: 'short',
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'longOffset'
},
{ locale: $locale }
)}
</p>
</div>
</div>
</div>{/if}
<div>
<p>
{assetDateTimeOriginal.toLocaleString(
{
month: 'short',
day: 'numeric',
year: 'numeric',
},
{ locale: $locale },
)}
</p>
<div class="flex gap-2 text-sm">
<p>
{assetDateTimeOriginal.toLocaleString(
{
weekday: 'short',
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'longOffset',
},
{ locale: $locale },
)}
</p>
</div>
</div>
</div>{/if}
{#if asset.exifInfo?.fileSizeInByte}
<div class="flex gap-4 py-4">
<div><ImageOutline size="24" /></div>
{#if asset.exifInfo?.fileSizeInByte}
<div class="flex gap-4 py-4">
<div><ImageOutline size="24" /></div>
<div>
<p class="break-all">
{getAssetFilename(asset)}
</p>
<div class="flex text-sm gap-2">
{#if asset.exifInfo.exifImageHeight && asset.exifInfo.exifImageWidth}
{#if getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)}
<p>
{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)} MP
</p>
{/if}
<div>
<p class="break-all">
{getAssetFilename(asset)}
</p>
<div class="flex text-sm gap-2">
{#if asset.exifInfo.exifImageHeight && asset.exifInfo.exifImageWidth}
{#if getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)}
<p>
{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)} MP
</p>
{/if}
<p>{asset.exifInfo.exifImageHeight} x {asset.exifInfo.exifImageWidth}</p>
{/if}
<p>{asByteUnitString(asset.exifInfo.fileSizeInByte, $locale)}</p>
</div>
</div>
</div>
{/if}
<p>{asset.exifInfo.exifImageHeight} x {asset.exifInfo.exifImageWidth}</p>
{/if}
<p>{asByteUnitString(asset.exifInfo.fileSizeInByte, $locale)}</p>
</div>
</div>
</div>
{/if}
{#if asset.exifInfo?.fNumber}
<div class="flex gap-4 py-4">
<div><CameraIris size="24" /></div>
{#if asset.exifInfo?.fNumber}
<div class="flex gap-4 py-4">
<div><CameraIris size="24" /></div>
<div>
<p>{asset.exifInfo.make || ''} {asset.exifInfo.model || ''}</p>
<div class="flex text-sm gap-2">
<p>{`ƒ/${asset.exifInfo.fNumber.toLocaleString($locale)}` || ''}</p>
<div>
<p>{asset.exifInfo.make || ''} {asset.exifInfo.model || ''}</p>
<div class="flex text-sm gap-2">
<p>{`ƒ/${asset.exifInfo.fNumber.toLocaleString($locale)}` || ''}</p>
{#if asset.exifInfo.exposureTime}
<p>{`${asset.exifInfo.exposureTime}`}</p>
{/if}
{#if asset.exifInfo.exposureTime}
<p>{`${asset.exifInfo.exposureTime}`}</p>
{/if}
{#if asset.exifInfo.focalLength}
<p>{`${asset.exifInfo.focalLength.toLocaleString($locale)} mm`}</p>
{/if}
{#if asset.exifInfo.focalLength}
<p>{`${asset.exifInfo.focalLength.toLocaleString($locale)} mm`}</p>
{/if}
{#if asset.exifInfo.iso}
<p>
{`ISO${asset.exifInfo.iso}`}
</p>
{/if}
</div>
</div>
</div>
{/if}
{#if asset.exifInfo.iso}
<p>
{`ISO${asset.exifInfo.iso}`}
</p>
{/if}
</div>
</div>
</div>
{/if}
{#if asset.exifInfo?.city}
<div class="flex gap-4 py-4">
<div><MapMarkerOutline size="24" /></div>
{#if asset.exifInfo?.city}
<div class="flex gap-4 py-4">
<div><MapMarkerOutline size="24" /></div>
<div>
<p>{asset.exifInfo.city}</p>
<div class="flex text-sm gap-2">
<p>{asset.exifInfo.state}</p>
</div>
<div class="flex text-sm gap-2">
<p>{asset.exifInfo.country}</p>
</div>
</div>
</div>
{/if}
</div>
<div>
<p>{asset.exifInfo.city}</p>
<div class="flex text-sm gap-2">
<p>{asset.exifInfo.state}</p>
</div>
<div class="flex text-sm gap-2">
<p>{asset.exifInfo.country}</p>
</div>
</div>
</div>
{/if}
</div>
</section>
{#if latlng}
<div class="h-[360px]">
{#await import('../shared-components/leaflet') then { Map, TileLayer, Marker }}
<Map center={latlng} zoom={14}>
<TileLayer
urlTemplate={'https://tile.openstreetmap.org/{z}/{x}/{y}.png'}
options={{
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}}
/>
<Marker {latlng} popupContent="{latlng[0].toFixed(7)},{latlng[1].toFixed(7)}" />
</Map>
{/await}
</div>
<div class="h-[360px]">
{#await import('../shared-components/leaflet') then { Map, TileLayer, Marker }}
<Map center={latlng} zoom={14}>
<TileLayer
urlTemplate={'https://tile.openstreetmap.org/{z}/{x}/{y}.png'}
options={{
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}}
/>
<Marker {latlng} popupContent="{latlng[0].toFixed(7)},{latlng[1].toFixed(7)}" />
</Map>
{/await}
</div>
{/if}
<section class="p-2 dark:text-immich-dark-fg">
<div class="px-4 py-4">
{#if albums.length > 0}
<p class="text-sm pb-4">APPEARS IN</p>
{/if}
{#each albums as album}
<a data-sveltekit-preload-data="hover" href={`/albums/${album.id}`}>
<div
class="flex gap-4 py-2 hover:cursor-pointer"
on:click={() => dispatch('click', album)}
on:keydown={() => dispatch('click', album)}
>
<div>
<img
alt={album.albumName}
class="w-[50px] h-[50px] object-cover rounded"
src={album.albumThumbnailAssetId &&
api.getAssetThumbnailUrl(album.albumThumbnailAssetId, ThumbnailFormat.Jpeg)}
draggable="false"
/>
</div>
<div class="px-4 py-4">
{#if albums.length > 0}
<p class="text-sm pb-4">APPEARS IN</p>
{/if}
{#each albums as album}
<a data-sveltekit-preload-data="hover" href={`/albums/${album.id}`}>
<div
class="flex gap-4 py-2 hover:cursor-pointer"
on:click={() => dispatch('click', album)}
on:keydown={() => dispatch('click', album)}
>
<div>
<img
alt={album.albumName}
class="w-[50px] h-[50px] object-cover rounded"
src={album.albumThumbnailAssetId &&
api.getAssetThumbnailUrl(album.albumThumbnailAssetId, ThumbnailFormat.Jpeg)}
draggable="false"
/>
</div>
<div class="mt-auto mb-auto">
<p class="dark:text-immich-dark-primary">{album.albumName}</p>
<div class="flex gap-2 text-sm">
<p>{album.assetCount} items</p>
{#if album.shared}
<p>· Shared</p>
{/if}
</div>
</div>
</div>
</a>
{/each}
</div>
<div class="mt-auto mb-auto">
<p class="dark:text-immich-dark-primary">{album.albumName}</p>
<div class="flex gap-2 text-sm">
<p>{album.assetCount} items</p>
{#if album.shared}
<p>· Shared</p>
{/if}
</div>
</div>
</div>
</a>
{/each}
</div>
</section>

View file

@ -1,31 +1,28 @@
<script lang="ts">
import { downloadAssets, isDownloading } from '$lib/stores/download';
import { fly, slide } from 'svelte/transition';
import { downloadAssets, isDownloading } from '$lib/stores/download';
import { fly, slide } from 'svelte/transition';
</script>
{#if $isDownloading}
<div
transition:fly={{ x: -100, duration: 350 }}
class="w-[315px] max-h-[270px] bg-immich-bg border rounded-2xl shadow-sm absolute bottom-10 left-2 p-4 z-[10000] text-sm"
>
<p class="text-gray-500 text-xs mb-2">DOWNLOADING</p>
<div class="max-h-[200px] my-2 overflow-y-auto mb-2 flex flex-col text-sm">
{#each Object.keys($downloadAssets) as fileName}
<div class="mb-2" transition:slide>
<p class="font-medium text-xs truncate">{fileName}</p>
<div class="flex flex-row-reverse place-items-center gap-5">
<p>
<span class="text-immich-primary font-medium">{$downloadAssets[fileName]}</span>/100
</p>
<div class="w-full bg-gray-200 rounded-full h-[7px] dark:bg-gray-700">
<div
class="bg-immich-primary h-[7px] rounded-full"
style={`width: ${$downloadAssets[fileName]}%`}
/>
</div>
</div>
</div>
{/each}
</div>
</div>
<div
transition:fly={{ x: -100, duration: 350 }}
class="w-[315px] max-h-[270px] bg-immich-bg border rounded-2xl shadow-sm absolute bottom-10 left-2 p-4 z-[10000] text-sm"
>
<p class="text-gray-500 text-xs mb-2">DOWNLOADING</p>
<div class="max-h-[200px] my-2 overflow-y-auto mb-2 flex flex-col text-sm">
{#each Object.keys($downloadAssets) as fileName}
<div class="mb-2" transition:slide>
<p class="font-medium text-xs truncate">{fileName}</p>
<div class="flex flex-row-reverse place-items-center gap-5">
<p>
<span class="text-immich-primary font-medium">{$downloadAssets[fileName]}</span>/100
</p>
<div class="w-full bg-gray-200 rounded-full h-[7px] dark:bg-gray-700">
<div class="bg-immich-primary h-[7px] rounded-full" style={`width: ${$downloadAssets[fileName]}%`} />
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}

View file

@ -1,77 +1,77 @@
<script lang="ts">
import { BucketPosition } from '$lib/models/asset-grid-state';
import { onMount } from 'svelte';
import { createEventDispatcher } from 'svelte';
import { BucketPosition } from '$lib/models/asset-grid-state';
import { onMount } from 'svelte';
import { createEventDispatcher } from 'svelte';
export let once = false;
export let top = 0;
export let bottom = 0;
export let left = 0;
export let right = 0;
export let root: HTMLElement | null = null;
export let once = false;
export let top = 0;
export let bottom = 0;
export let left = 0;
export let right = 0;
export let root: HTMLElement | null = null;
let intersecting = false;
let container: HTMLDivElement;
const dispatch = createEventDispatcher();
let intersecting = false;
let container: HTMLDivElement;
const dispatch = createEventDispatcher();
onMount(() => {
if (typeof IntersectionObserver !== 'undefined') {
const rootMargin = `${top}px ${right}px ${bottom}px ${left}px`;
const observer = new IntersectionObserver(
(entries) => {
intersecting = entries[0].isIntersecting;
if (!intersecting) {
dispatch('hidden', container);
}
onMount(() => {
if (typeof IntersectionObserver !== 'undefined') {
const rootMargin = `${top}px ${right}px ${bottom}px ${left}px`;
const observer = new IntersectionObserver(
(entries) => {
intersecting = entries[0].isIntersecting;
if (!intersecting) {
dispatch('hidden', container);
}
if (intersecting && once) {
observer.unobserve(container);
}
if (intersecting && once) {
observer.unobserve(container);
}
if (intersecting) {
let position: BucketPosition = BucketPosition.Visible;
if (entries[0].boundingClientRect.top + 50 > entries[0].intersectionRect.bottom) {
position = BucketPosition.Below;
} else if (entries[0].boundingClientRect.bottom < 0) {
position = BucketPosition.Above;
}
if (intersecting) {
let position: BucketPosition = BucketPosition.Visible;
if (entries[0].boundingClientRect.top + 50 > entries[0].intersectionRect.bottom) {
position = BucketPosition.Below;
} else if (entries[0].boundingClientRect.bottom < 0) {
position = BucketPosition.Above;
}
dispatch('intersected', {
container,
position
});
}
},
{
rootMargin,
root
}
);
dispatch('intersected', {
container,
position,
});
}
},
{
rootMargin,
root,
},
);
observer.observe(container);
return () => observer.unobserve(container);
}
observer.observe(container);
return () => observer.unobserve(container);
}
// The following is a fallback for older browsers
function handler() {
const bcr = container.getBoundingClientRect();
// The following is a fallback for older browsers
function handler() {
const bcr = container.getBoundingClientRect();
intersecting =
bcr.bottom + bottom > 0 &&
bcr.right + right > 0 &&
bcr.top - top < window.innerHeight &&
bcr.left - left < window.innerWidth;
intersecting =
bcr.bottom + bottom > 0 &&
bcr.right + right > 0 &&
bcr.top - top < window.innerHeight &&
bcr.left - left < window.innerWidth;
if (intersecting && once) {
window.removeEventListener('scroll', handler);
}
}
if (intersecting && once) {
window.removeEventListener('scroll', handler);
}
}
window.addEventListener('scroll', handler);
return () => window.removeEventListener('scroll', handler);
});
window.addEventListener('scroll', handler);
return () => window.removeEventListener('scroll', handler);
});
</script>
<div bind:this={container}>
<slot {intersecting} />
<slot {intersecting} />
</div>

View file

@ -1,119 +1,113 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { onMount } from 'svelte';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { api, AssetResponseDto } from '@api';
import {
notificationController,
NotificationType
} from '../shared-components/notification/notification';
import { useZoomImageWheel } from '@zoom-image/svelte';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { fade } from 'svelte/transition';
import { onMount } from 'svelte';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { api, AssetResponseDto } from '@api';
import { notificationController, NotificationType } from '../shared-components/notification/notification';
import { useZoomImageWheel } from '@zoom-image/svelte';
import { photoZoomState } from '$lib/stores/zoom-image.store';
export let asset: AssetResponseDto;
export let publicSharedKey = '';
let imgElement: HTMLDivElement;
export let asset: AssetResponseDto;
export let publicSharedKey = '';
let imgElement: HTMLDivElement;
let assetData: string;
let assetData: string;
let copyImageToClipboard: (src: string) => Promise<Blob>;
let canCopyImagesToClipboard: () => boolean;
let copyImageToClipboard: (src: string) => Promise<Blob>;
let canCopyImagesToClipboard: () => boolean;
onMount(async () => {
// Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295
// TODO: Move to regular import once the package correctly supports ESM.
const module = await import('copy-image-clipboard');
copyImageToClipboard = module.copyImageToClipboard;
canCopyImagesToClipboard = module.canCopyImagesToClipboard;
});
onMount(async () => {
// Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295
// TODO: Move to regular import once the package correctly supports ESM.
const module = await import('copy-image-clipboard');
copyImageToClipboard = module.copyImageToClipboard;
canCopyImagesToClipboard = module.canCopyImagesToClipboard;
});
const loadAssetData = async () => {
try {
const { data } = await api.assetApi.serveFile(
{ id: asset.id, isThumb: false, isWeb: true, key: publicSharedKey },
{
responseType: 'blob'
}
);
const loadAssetData = async () => {
try {
const { data } = await api.assetApi.serveFile(
{ id: asset.id, isThumb: false, isWeb: true, key: publicSharedKey },
{
responseType: 'blob',
},
);
if (!(data instanceof Blob)) {
return;
}
if (!(data instanceof Blob)) {
return;
}
assetData = URL.createObjectURL(data);
return assetData;
} catch {
// Do nothing
}
};
assetData = URL.createObjectURL(data);
return assetData;
} catch {
// Do nothing
}
};
const handleKeypress = async ({ metaKey, ctrlKey, key }: KeyboardEvent) => {
if ((metaKey || ctrlKey) && key === 'c') {
await doCopy();
}
};
const handleKeypress = async ({ metaKey, ctrlKey, key }: KeyboardEvent) => {
if ((metaKey || ctrlKey) && key === 'c') {
await doCopy();
}
};
const doCopy = async () => {
if (!canCopyImagesToClipboard()) {
return;
}
const doCopy = async () => {
if (!canCopyImagesToClipboard()) {
return;
}
try {
await copyImageToClipboard(assetData);
notificationController.show({
type: NotificationType.Info,
message: 'Copied image to clipboard.',
timeout: 3000
});
} catch (err) {
console.error('Error [photo-viewer]:', err);
notificationController.show({
type: NotificationType.Error,
message: 'Copying image to clipboard failed.'
});
}
};
try {
await copyImageToClipboard(assetData);
notificationController.show({
type: NotificationType.Info,
message: 'Copied image to clipboard.',
timeout: 3000,
});
} catch (err) {
console.error('Error [photo-viewer]:', err);
notificationController.show({
type: NotificationType.Error,
message: 'Copying image to clipboard failed.',
});
}
};
const doZoomImage = async () => {
setZoomImageWheelState({
currentZoom: $zoomImageWheelState.currentZoom === 1 ? 2 : 1
});
};
const doZoomImage = async () => {
setZoomImageWheelState({
currentZoom: $zoomImageWheelState.currentZoom === 1 ? 2 : 1,
});
};
const {
createZoomImage: createZoomImageWheel,
zoomImageState: zoomImageWheelState,
setZoomImageState: setZoomImageWheelState
} = useZoomImageWheel();
const {
createZoomImage: createZoomImageWheel,
zoomImageState: zoomImageWheelState,
setZoomImageState: setZoomImageWheelState,
} = useZoomImageWheel();
zoomImageWheelState.subscribe((state) => {
photoZoomState.set(state);
});
zoomImageWheelState.subscribe((state) => {
photoZoomState.set(state);
});
$: if (imgElement) {
createZoomImageWheel(imgElement, {
wheelZoomRatio: 0.06
});
}
$: if (imgElement) {
createZoomImageWheel(imgElement, {
wheelZoomRatio: 0.06,
});
}
</script>
<svelte:window on:keydown={handleKeypress} on:copyImage={doCopy} on:zoomImage={doZoomImage} />
<div
transition:fade={{ duration: 150 }}
class="flex place-items-center place-content-center h-full select-none"
>
{#await loadAssetData()}
<LoadingSpinner />
{:then assetData}
<div bind:this={imgElement} class="h-full w-full">
<img
transition:fade={{ duration: 150 }}
src={assetData}
alt={asset.id}
class="object-contain h-full w-full"
draggable="false"
/>
</div>
{/await}
<div transition:fade={{ duration: 150 }} class="flex place-items-center place-content-center h-full select-none">
{#await loadAssetData()}
<LoadingSpinner />
{:then assetData}
<div bind:this={imgElement} class="h-full w-full">
<img
transition:fade={{ duration: 150 }}
src={assetData}
alt={asset.id}
class="object-contain h-full w-full"
draggable="false"
/>
</div>
{/await}
</div>

View file

@ -1,45 +1,42 @@
<script lang="ts">
import { api } from '@api';
import { fade } from 'svelte/transition';
import { createEventDispatcher } from 'svelte';
import { videoViewerVolume } from '$lib/stores/preferences.store';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { api } from '@api';
import { fade } from 'svelte/transition';
import { createEventDispatcher } from 'svelte';
import { videoViewerVolume } from '$lib/stores/preferences.store';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
export let assetId: string;
export let publicSharedKey: string | undefined = undefined;
export let assetId: string;
export let publicSharedKey: string | undefined = undefined;
let isVideoLoading = true;
const dispatch = createEventDispatcher();
let isVideoLoading = true;
const dispatch = createEventDispatcher();
const handleCanPlay = (ev: Event & { currentTarget: HTMLVideoElement }) => {
const playerNode = ev.currentTarget;
const handleCanPlay = (ev: Event & { currentTarget: HTMLVideoElement }) => {
const playerNode = ev.currentTarget;
playerNode.muted = true;
playerNode.play();
playerNode.muted = false;
playerNode.muted = true;
playerNode.play();
playerNode.muted = false;
isVideoLoading = false;
};
isVideoLoading = false;
};
</script>
<div
transition:fade={{ duration: 150 }}
class="flex place-items-center place-content-center h-full select-none"
>
<video
controls
class="h-full object-contain"
on:canplay={handleCanPlay}
on:ended={() => dispatch('onVideoEnded')}
bind:volume={$videoViewerVolume}
>
<source src={api.getAssetFileUrl(assetId, false, true, publicSharedKey)} type="video/mp4" />
<track kind="captions" />
</video>
<div transition:fade={{ duration: 150 }} class="flex place-items-center place-content-center h-full select-none">
<video
controls
class="h-full object-contain"
on:canplay={handleCanPlay}
on:ended={() => dispatch('onVideoEnded')}
bind:volume={$videoViewerVolume}
>
<source src={api.getAssetFileUrl(assetId, false, true, publicSharedKey)} type="video/mp4" />
<track kind="captions" />
</video>
{#if isVideoLoading}
<div class="absolute flex place-items-center place-content-center">
<LoadingSpinner />
</div>
{/if}
{#if isVideoLoading}
<div class="absolute flex place-items-center place-content-center">
<LoadingSpinner />
</div>
{/if}
</div>

View file

@ -1,47 +1,47 @@
<script lang="ts">
import { imageLoad } from '$lib/utils/image-load';
import { fade } from 'svelte/transition';
import { thumbHashToDataURL } from 'thumbhash';
import { Buffer } from 'buffer';
import { imageLoad } from '$lib/utils/image-load';
import { fade } from 'svelte/transition';
import { thumbHashToDataURL } from 'thumbhash';
import { Buffer } from 'buffer';
export let url: string;
export let altText: string;
export let heightStyle: string | undefined = undefined;
export let widthStyle: string;
export let thumbhash: string | null = null;
export let curve = false;
export let shadow = false;
export let circle = false;
export let url: string;
export let altText: string;
export let heightStyle: string | undefined = undefined;
export let widthStyle: string;
export let thumbhash: string | null = null;
export let curve = false;
export let shadow = false;
export let circle = false;
let complete = false;
let complete = false;
</script>
<img
style:width={widthStyle}
style:height={heightStyle}
src={url}
alt={altText}
class="object-cover transition-opacity duration-300"
class:rounded-lg={curve}
class:shadow-lg={shadow}
class:rounded-full={circle}
class:opacity-0={!thumbhash && !complete}
draggable="false"
use:imageLoad
on:image-load|once={() => (complete = true)}
style:width={widthStyle}
style:height={heightStyle}
src={url}
alt={altText}
class="object-cover transition-opacity duration-300"
class:rounded-lg={curve}
class:shadow-lg={shadow}
class:rounded-full={circle}
class:opacity-0={!thumbhash && !complete}
draggable="false"
use:imageLoad
on:image-load|once={() => (complete = true)}
/>
{#if thumbhash && !complete}
<img
style:width={widthStyle}
style:height={heightStyle}
src={thumbHashToDataURL(Buffer.from(thumbhash, 'base64'))}
alt={altText}
class="absolute object-cover top-0"
class:rounded-lg={curve}
class:shadow-lg={shadow}
class:rounded-full={circle}
draggable="false"
out:fade={{ duration: 300 }}
/>
<img
style:width={widthStyle}
style:height={heightStyle}
src={thumbHashToDataURL(Buffer.from(thumbhash, 'base64'))}
alt={altText}
class="absolute object-cover top-0"
class:rounded-lg={curve}
class:shadow-lg={shadow}
class:rounded-full={circle}
draggable="false"
out:fade={{ duration: 300 }}
/>
{/if}

View file

@ -1,158 +1,158 @@
<script lang="ts">
import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
import { timeToSeconds } from '$lib/utils/time-to-seconds';
import { api, AssetResponseDto, AssetTypeEnum, ThumbnailFormat } from '@api';
import { createEventDispatcher } from 'svelte';
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
import MotionPauseOutline from 'svelte-material-icons/MotionPauseOutline.svelte';
import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
import Heart from 'svelte-material-icons/Heart.svelte';
import ArchiveArrowDownOutline from 'svelte-material-icons/ArchiveArrowDownOutline.svelte';
import ImageThumbnail from './image-thumbnail.svelte';
import VideoThumbnail from './video-thumbnail.svelte';
import ImageBrokenVariant from 'svelte-material-icons/ImageBrokenVariant.svelte';
import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
import { timeToSeconds } from '$lib/utils/time-to-seconds';
import { api, AssetResponseDto, AssetTypeEnum, ThumbnailFormat } from '@api';
import { createEventDispatcher } from 'svelte';
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
import MotionPauseOutline from 'svelte-material-icons/MotionPauseOutline.svelte';
import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
import Heart from 'svelte-material-icons/Heart.svelte';
import ArchiveArrowDownOutline from 'svelte-material-icons/ArchiveArrowDownOutline.svelte';
import ImageThumbnail from './image-thumbnail.svelte';
import VideoThumbnail from './video-thumbnail.svelte';
import ImageBrokenVariant from 'svelte-material-icons/ImageBrokenVariant.svelte';
const dispatch = createEventDispatcher();
const dispatch = createEventDispatcher();
export let asset: AssetResponseDto;
export let groupIndex = 0;
export let thumbnailSize: number | undefined = undefined;
export let thumbnailWidth: number | undefined = undefined;
export let thumbnailHeight: number | undefined = undefined;
export let format: ThumbnailFormat = ThumbnailFormat.Webp;
export let selected = false;
export let disabled = false;
export let readonly = false;
export let publicSharedKey: string | undefined = undefined;
export let showArchiveIcon = false;
export let asset: AssetResponseDto;
export let groupIndex = 0;
export let thumbnailSize: number | undefined = undefined;
export let thumbnailWidth: number | undefined = undefined;
export let thumbnailHeight: number | undefined = undefined;
export let format: ThumbnailFormat = ThumbnailFormat.Webp;
export let selected = false;
export let disabled = false;
export let readonly = false;
export let publicSharedKey: string | undefined = undefined;
export let showArchiveIcon = false;
let mouseOver = false;
let mouseOver = false;
$: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
$: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
$: [width, height] = (() => {
if (thumbnailSize) {
return [thumbnailSize, thumbnailSize];
}
$: [width, height] = (() => {
if (thumbnailSize) {
return [thumbnailSize, thumbnailSize];
}
if (thumbnailWidth && thumbnailHeight) {
return [thumbnailWidth, thumbnailHeight];
}
if (thumbnailWidth && thumbnailHeight) {
return [thumbnailWidth, thumbnailHeight];
}
return [235, 235];
})();
return [235, 235];
})();
const thumbnailClickedHandler = () => {
if (!disabled) {
dispatch('click', { asset });
}
};
const thumbnailClickedHandler = () => {
if (!disabled) {
dispatch('click', { asset });
}
};
const onIconClickedHandler = (e: MouseEvent) => {
e.stopPropagation();
if (!disabled) {
dispatch('select', { asset });
}
};
const onIconClickedHandler = (e: MouseEvent) => {
e.stopPropagation();
if (!disabled) {
dispatch('select', { asset });
}
};
</script>
<IntersectionObserver once={false} let:intersecting>
<div
style:width="{width}px"
style:height="{height}px"
class="relative group overflow-hidden {disabled
? 'bg-gray-300'
: 'bg-immich-primary/20 dark:bg-immich-dark-primary/20'}"
class:cursor-not-allowed={disabled}
class:hover:cursor-pointer={!disabled}
on:mouseenter={() => (mouseOver = true)}
on:mouseleave={() => (mouseOver = false)}
on:click={thumbnailClickedHandler}
on:keydown={thumbnailClickedHandler}
>
{#if intersecting}
<div class="absolute w-full h-full z-20">
<!-- Select asset button -->
{#if !readonly}
<button
on:click={onIconClickedHandler}
class="absolute p-2 group-hover:block"
class:group-hover:block={!disabled}
class:hidden={!selected}
class:cursor-not-allowed={disabled}
role="checkbox"
aria-checked={selected}
{disabled}
>
{#if disabled}
<CheckCircle size="24" class="text-zinc-800" />
{:else if selected}
<CheckCircle size="24" class="text-immich-primary" />
{:else}
<CheckCircle size="24" class="text-white/80 hover:text-white" />
{/if}
</button>
{/if}
</div>
<div
style:width="{width}px"
style:height="{height}px"
class="relative group overflow-hidden {disabled
? 'bg-gray-300'
: 'bg-immich-primary/20 dark:bg-immich-dark-primary/20'}"
class:cursor-not-allowed={disabled}
class:hover:cursor-pointer={!disabled}
on:mouseenter={() => (mouseOver = true)}
on:mouseleave={() => (mouseOver = false)}
on:click={thumbnailClickedHandler}
on:keydown={thumbnailClickedHandler}
>
{#if intersecting}
<div class="absolute w-full h-full z-20">
<!-- Select asset button -->
{#if !readonly}
<button
on:click={onIconClickedHandler}
class="absolute p-2 group-hover:block"
class:group-hover:block={!disabled}
class:hidden={!selected}
class:cursor-not-allowed={disabled}
role="checkbox"
aria-checked={selected}
{disabled}
>
{#if disabled}
<CheckCircle size="24" class="text-zinc-800" />
{:else if selected}
<CheckCircle size="24" class="text-immich-primary" />
{:else}
<CheckCircle size="24" class="text-white/80 hover:text-white" />
{/if}
</button>
{/if}
</div>
<div
class="h-full w-full bg-gray-100 dark:bg-immich-dark-gray absolute select-none transition-transform"
class:scale-[0.85]={selected}
>
<!-- Gradient overlay on hover -->
<div
class="absolute w-full h-full bg-gradient-to-b from-black/25 via-[transparent_25%] opacity-0 group-hover:opacity-100 transition-opacity z-10"
/>
<div
class="h-full w-full bg-gray-100 dark:bg-immich-dark-gray absolute select-none transition-transform"
class:scale-[0.85]={selected}
>
<!-- Gradient overlay on hover -->
<div
class="absolute w-full h-full bg-gradient-to-b from-black/25 via-[transparent_25%] opacity-0 group-hover:opacity-100 transition-opacity z-10"
/>
<!-- Favorite asset star -->
{#if asset.isFavorite && !publicSharedKey}
<div class="absolute bottom-2 left-2 z-10">
<Heart size="24" class="text-white" />
</div>
{/if}
<!-- Favorite asset star -->
{#if asset.isFavorite && !publicSharedKey}
<div class="absolute bottom-2 left-2 z-10">
<Heart size="24" class="text-white" />
</div>
{/if}
{#if showArchiveIcon && asset.isArchived}
<div class="absolute {asset.isFavorite ? 'bottom-10' : 'bottom-2'} left-2 z-10">
<ArchiveArrowDownOutline size="24" class="text-white" />
</div>
{/if}
{#if showArchiveIcon && asset.isArchived}
<div class="absolute {asset.isFavorite ? 'bottom-10' : 'bottom-2'} left-2 z-10">
<ArchiveArrowDownOutline size="24" class="text-white" />
</div>
{/if}
{#if asset.resized}
<ImageThumbnail
url={api.getAssetThumbnailUrl(asset.id, format, publicSharedKey)}
altText={asset.originalFileName}
widthStyle="{width}px"
heightStyle="{height}px"
thumbhash={asset.thumbhash}
/>
{:else}
<div class="w-full h-full p-4 flex items-center justify-center">
<ImageBrokenVariant size="48" />
</div>
{/if}
{#if asset.resized}
<ImageThumbnail
url={api.getAssetThumbnailUrl(asset.id, format, publicSharedKey)}
altText={asset.originalFileName}
widthStyle="{width}px"
heightStyle="{height}px"
thumbhash={asset.thumbhash}
/>
{:else}
<div class="w-full h-full p-4 flex items-center justify-center">
<ImageBrokenVariant size="48" />
</div>
{/if}
{#if asset.type === AssetTypeEnum.Video}
<div class="absolute w-full h-full top-0">
<VideoThumbnail
url={api.getAssetFileUrl(asset.id, false, true, publicSharedKey)}
enablePlayback={mouseOver}
durationInSeconds={timeToSeconds(asset.duration)}
/>
</div>
{/if}
{#if asset.type === AssetTypeEnum.Video}
<div class="absolute w-full h-full top-0">
<VideoThumbnail
url={api.getAssetFileUrl(asset.id, false, true, publicSharedKey)}
enablePlayback={mouseOver}
durationInSeconds={timeToSeconds(asset.duration)}
/>
</div>
{/if}
{#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
<div class="absolute w-full h-full top-0">
<VideoThumbnail
url={api.getAssetFileUrl(asset.livePhotoVideoId, false, true, publicSharedKey)}
pauseIcon={MotionPauseOutline}
playIcon={MotionPlayOutline}
showTime={false}
playbackOnIconHover
/>
</div>
{/if}
</div>
{/if}
</div>
{#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
<div class="absolute w-full h-full top-0">
<VideoThumbnail
url={api.getAssetFileUrl(asset.livePhotoVideoId, false, true, publicSharedKey)}
pauseIcon={MotionPauseOutline}
playIcon={MotionPlayOutline}
showTime={false}
playbackOnIconHover
/>
</div>
{/if}
</div>
{/if}
</div>
</IntersectionObserver>

View file

@ -1,88 +1,86 @@
<script lang="ts">
import { Duration } from 'luxon';
import PauseCircleOutline from 'svelte-material-icons/PauseCircleOutline.svelte';
import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
import AlertCircleOutline from 'svelte-material-icons/AlertCircleOutline.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { Duration } from 'luxon';
import PauseCircleOutline from 'svelte-material-icons/PauseCircleOutline.svelte';
import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
import AlertCircleOutline from 'svelte-material-icons/AlertCircleOutline.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
export let url: string;
export let durationInSeconds = 0;
export let enablePlayback = false;
export let playbackOnIconHover = false;
export let showTime = true;
export let playIcon = PlayCircleOutline;
export let pauseIcon = PauseCircleOutline;
export let url: string;
export let durationInSeconds = 0;
export let enablePlayback = false;
export let playbackOnIconHover = false;
export let showTime = true;
export let playIcon = PlayCircleOutline;
export let pauseIcon = PauseCircleOutline;
let remainingSeconds = durationInSeconds;
let loading = true;
let error = false;
let player: HTMLVideoElement;
let remainingSeconds = durationInSeconds;
let loading = true;
let error = false;
let player: HTMLVideoElement;
$: if (!enablePlayback) {
// Reset remaining time when playback is disabled.
remainingSeconds = durationInSeconds;
$: if (!enablePlayback) {
// Reset remaining time when playback is disabled.
remainingSeconds = durationInSeconds;
if (player) {
// Cancel video buffering.
player.src = '';
}
}
if (player) {
// Cancel video buffering.
player.src = '';
}
}
</script>
<div
class="absolute right-0 top-0 text-white text-xs font-medium flex gap-1 place-items-center z-20"
>
{#if showTime}
<span class="pt-2">
{Duration.fromObject({ seconds: remainingSeconds }).toFormat('m:ss')}
</span>
{/if}
<div class="absolute right-0 top-0 text-white text-xs font-medium flex gap-1 place-items-center z-20">
{#if showTime}
<span class="pt-2">
{Duration.fromObject({ seconds: remainingSeconds }).toFormat('m:ss')}
</span>
{/if}
<span
class="pt-2 pr-2"
on:mouseenter={() => {
if (playbackOnIconHover) {
enablePlayback = true;
}
}}
on:mouseleave={() => {
if (playbackOnIconHover) {
enablePlayback = false;
}
}}
>
{#if enablePlayback}
{#if loading}
<LoadingSpinner />
{:else if error}
<AlertCircleOutline size="24" class="text-red-600" />
{:else}
<svelte:component this={pauseIcon} size="24" />
{/if}
{:else}
<svelte:component this={playIcon} size="24" />
{/if}
</span>
<span
class="pt-2 pr-2"
on:mouseenter={() => {
if (playbackOnIconHover) {
enablePlayback = true;
}
}}
on:mouseleave={() => {
if (playbackOnIconHover) {
enablePlayback = false;
}
}}
>
{#if enablePlayback}
{#if loading}
<LoadingSpinner />
{:else if error}
<AlertCircleOutline size="24" class="text-red-600" />
{:else}
<svelte:component this={pauseIcon} size="24" />
{/if}
{:else}
<svelte:component this={playIcon} size="24" />
{/if}
</span>
</div>
{#if enablePlayback}
<video
bind:this={player}
class="w-full h-full object-cover"
muted
autoplay
src={url}
on:play={() => {
loading = false;
error = false;
}}
on:error={() => {
error = true;
loading = false;
}}
on:timeupdate={({ currentTarget }) => {
const remaining = currentTarget.duration - currentTarget.currentTime;
remainingSeconds = Math.min(Math.ceil(remaining), durationInSeconds);
}}
/>
<video
bind:this={player}
class="w-full h-full object-cover"
muted
autoplay
src={url}
on:play={() => {
loading = false;
error = false;
}}
on:error={() => {
error = true;
loading = false;
}}
on:timeupdate={({ currentTarget }) => {
const remaining = currentTarget.duration - currentTarget.currentTime;
remainingSeconds = Math.min(Math.ceil(remaining), durationInSeconds);
}}
/>
{/if}

View file

@ -1,26 +1,24 @@
<script lang="ts" context="module">
export type Color = 'primary' | 'secondary';
export type Rounded = false | true | 'full';
export type Color = 'primary' | 'secondary';
export type Rounded = false | true | 'full';
</script>
<script lang="ts">
export let color: Color = 'primary';
export let rounded: Rounded = true;
export let color: Color = 'primary';
export let rounded: Rounded = true;
const colorClasses: Record<Color, string> = {
primary:
'text-gray-100 dark:text-immich-dark-gray bg-immich-primary dark:bg-immich-dark-primary',
secondary:
'text-immich-dark-bg dark:text-immich-gray dark:bg-gray-600 bg-gray-300 dark:text-immich-gray'
};
const colorClasses: Record<Color, string> = {
primary: 'text-gray-100 dark:text-immich-dark-gray bg-immich-primary dark:bg-immich-dark-primary',
secondary: 'text-immich-dark-bg dark:text-immich-gray dark:bg-gray-600 bg-gray-300 dark:text-immich-gray',
};
</script>
<span
class="inline-block h-min whitespace-nowrap px-4 pt-[0.55em] pb-[0.55em] text-center align-baseline text-xs leading-none {colorClasses[
color
]}"
class:rounded-md={rounded === true}
class:rounded-full={rounded === 'full'}
class="inline-block h-min whitespace-nowrap px-4 pt-[0.55em] pb-[0.55em] text-center align-baseline text-xs leading-none {colorClasses[
color
]}"
class:rounded-md={rounded === true}
class:rounded-full={rounded === 'full'}
>
<slot />
<slot />
</span>

View file

@ -1,73 +1,73 @@
<script lang="ts" context="module">
export type Type = 'button' | 'submit' | 'reset';
export type Color =
| 'primary'
| 'secondary'
| 'transparent-primary'
| 'light-red'
| 'red'
| 'green'
| 'gray'
| 'transparent-gray'
| 'dark-gray'
| 'overlay-primary';
export type Size = 'icon' | 'link' | 'sm' | 'base' | 'lg';
export type Rounded = 'lg' | '3xl' | 'full' | false;
export type Shadow = 'md' | false;
export type Type = 'button' | 'submit' | 'reset';
export type Color =
| 'primary'
| 'secondary'
| 'transparent-primary'
| 'light-red'
| 'red'
| 'green'
| 'gray'
| 'transparent-gray'
| 'dark-gray'
| 'overlay-primary';
export type Size = 'icon' | 'link' | 'sm' | 'base' | 'lg';
export type Rounded = 'lg' | '3xl' | 'full' | false;
export type Shadow = 'md' | false;
</script>
<script lang="ts">
export let type: Type = 'button';
export let color: Color = 'primary';
export let size: Size = 'base';
export let rounded: Rounded = '3xl';
export let shadow: Shadow = 'md';
export let disabled = false;
export let fullwidth = false;
export let border = false;
export let title: string | undefined = '';
export let type: Type = 'button';
export let color: Color = 'primary';
export let size: Size = 'base';
export let rounded: Rounded = '3xl';
export let shadow: Shadow = 'md';
export let disabled = false;
export let fullwidth = false;
export let border = false;
export let title: string | undefined = '';
const colorClasses: Record<Color, string> = {
primary:
'bg-immich-primary dark:bg-immich-dark-primary text-white dark:text-immich-dark-gray enabled:dark:hover:bg-immich-dark-primary/80 enabled:hover:bg-immich-primary/90',
secondary:
'bg-gray-500 dark:bg-gray-200 text-white dark:text-immich-dark-gray enabled:hover:bg-gray-500/90 enabled:dark:hover:bg-gray-200/90',
'transparent-primary':
'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',
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',
'dark-gray':
'dark:border-immich-dark-gray dark:bg-gray-500 enabled:dark:hover:bg-immich-dark-primary/50 enabled:hover:bg-immich-primary/10 dark:text-white',
'overlay-primary': 'text-gray-500 enabled:hover:bg-gray-100'
};
const colorClasses: Record<Color, string> = {
primary:
'bg-immich-primary dark:bg-immich-dark-primary text-white dark:text-immich-dark-gray enabled:dark:hover:bg-immich-dark-primary/80 enabled:hover:bg-immich-primary/90',
secondary:
'bg-gray-500 dark:bg-gray-200 text-white dark:text-immich-dark-gray enabled:hover:bg-gray-500/90 enabled:dark:hover:bg-gray-200/90',
'transparent-primary':
'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',
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',
'dark-gray':
'dark:border-immich-dark-gray dark:bg-gray-500 enabled:dark:hover:bg-immich-dark-primary/50 enabled:hover:bg-immich-primary/10 dark:text-white',
'overlay-primary': 'text-gray-500 enabled:hover:bg-gray-100',
};
const sizeClasses: Record<Size, string> = {
icon: 'p-2.5',
link: 'p-2 font-medium',
sm: 'px-4 py-2 text-sm font-medium',
base: 'px-6 py-3 font-medium',
lg: 'px-6 py-4 font-semibold'
};
const sizeClasses: Record<Size, string> = {
icon: 'p-2.5',
link: 'p-2 font-medium',
sm: 'px-4 py-2 text-sm font-medium',
base: 'px-6 py-3 font-medium',
lg: 'px-6 py-4 font-semibold',
};
</script>
<button
{type}
{disabled}
{title}
on:click
class="inline-flex justify-center items-center transition-colors disabled:cursor-not-allowed disabled:opacity-60 {colorClasses[
color
]} {sizeClasses[size]}"
class:rounded-lg={rounded === 'lg'}
class:rounded-3xl={rounded === '3xl'}
class:rounded-full={rounded === 'full'}
class:shadow-md={shadow === 'md'}
class:w-full={fullwidth}
class:border
{type}
{disabled}
{title}
on:click
class="inline-flex justify-center items-center transition-colors disabled:cursor-not-allowed disabled:opacity-60 {colorClasses[
color
]} {sizeClasses[size]}"
class:rounded-lg={rounded === 'lg'}
class:rounded-3xl={rounded === '3xl'}
class:rounded-full={rounded === 'full'}
class:shadow-md={shadow === 'md'}
class:w-full={fullwidth}
class:border
>
<slot />
<slot />
</button>

View file

@ -1,37 +1,37 @@
<script lang="ts">
import type Icon from 'svelte-material-icons/AbTesting.svelte';
import type Icon from 'svelte-material-icons/AbTesting.svelte';
export let logo: typeof Icon;
export let backgroundColor = 'transparent';
export let hoverColor = '#e2e7e9';
export let size = '24';
export let title = '';
export let isOpacity = false;
export let forceDark = false;
export let hideMobile = false;
export let logo: typeof Icon;
export let backgroundColor = 'transparent';
export let hoverColor = '#e2e7e9';
export let size = '24';
export let title = '';
export let isOpacity = false;
export let forceDark = false;
export let hideMobile = false;
</script>
<button
{title}
style:background-color={backgroundColor}
style:--immich-icon-button-hover-color={hoverColor}
class:dark:text-immich-dark-fg={!forceDark}
class="rounded-full p-3 flex place-items-center place-content-center transition-all
{title}
style:background-color={backgroundColor}
style:--immich-icon-button-hover-color={hoverColor}
class:dark:text-immich-dark-fg={!forceDark}
class="rounded-full p-3 flex place-items-center place-content-center transition-all
{isOpacity ? 'hover:bg-immich-bg/30' : 'immich-circle-icon-button hover:dark:text-immich-dark-gray'}
{forceDark && 'hover:text-black'}
{hideMobile && 'hidden sm:flex'}"
on:click
on:click
>
<svelte:component this={logo} {size} />
<slot />
<svelte:component this={logo} {size} />
<slot />
</button>
<style>
:root {
--immich-icon-button-hover-color: #d3d3d3;
}
:root {
--immich-icon-button-hover-color: #d3d3d3;
}
.immich-circle-icon-button:hover {
background-color: var(--immich-icon-button-hover-color) !important;
}
.immich-circle-icon-button:hover {
background-color: var(--immich-icon-button-hover-color) !important;
}
</style>

View file

@ -1,14 +1,14 @@
<script lang="ts" context="module">
export type Color = 'transparent-primary' | 'transparent-gray' | 'overlay-primary';
export type Color = 'transparent-primary' | 'transparent-gray' | 'overlay-primary';
</script>
<script lang="ts">
import Button from './button.svelte';
import Button from './button.svelte';
export let color: Color = 'transparent-primary';
export let title: string | undefined = undefined;
export let color: Color = 'transparent-primary';
export let title: string | undefined = undefined;
</script>
<Button size="icon" {color} {title} shadow={false} rounded="full" on:click>
<slot />
<slot />
</Button>

View file

@ -1,13 +1,13 @@
<script lang="ts" context="module">
export type Color = 'transparent-primary' | 'transparent-gray';
export type Color = 'transparent-primary' | 'transparent-gray';
</script>
<script lang="ts">
import Button from './button.svelte';
import Button from './button.svelte';
export let color: Color = 'transparent-gray';
export let color: Color = 'transparent-gray';
</script>
<Button size="link" {color} shadow={false} rounded="lg" on:click>
<slot />
<slot />
</Button>

View file

@ -1,62 +1,60 @@
<script lang="ts">
import SwapVertical from 'svelte-material-icons/SwapVertical.svelte';
import Check from 'svelte-material-icons/Check.svelte';
import LinkButton from './buttons/link-button.svelte';
import { clickOutside } from '$lib/utils/click-outside';
import { fly } from 'svelte/transition';
import SwapVertical from 'svelte-material-icons/SwapVertical.svelte';
import Check from 'svelte-material-icons/Check.svelte';
import LinkButton from './buttons/link-button.svelte';
import { clickOutside } from '$lib/utils/click-outside';
import { fly } from 'svelte/transition';
export let options: string[] = [];
export let value = options[0];
export let options: string[] = [];
export let value = options[0];
let showMenu = false;
let showMenu = false;
const handleClickOutside = () => {
showMenu = false;
};
const handleClickOutside = () => {
showMenu = false;
};
const handleSelectOption = (index: number) => {
value = options[index];
showMenu = false;
};
const handleSelectOption = (index: number) => {
value = options[index];
showMenu = false;
};
</script>
<div id="dropdown-button" use:clickOutside on:outclick={handleClickOutside}>
<!-- BUTTON TITLE -->
<LinkButton on:click={() => (showMenu = true)}>
<div class="flex place-items-center gap-2 text-sm">
<SwapVertical size="18" />
{value}
</div>
</LinkButton>
<!-- BUTTON TITLE -->
<LinkButton on:click={() => (showMenu = true)}>
<div class="flex place-items-center gap-2 text-sm">
<SwapVertical size="18" />
{value}
</div>
</LinkButton>
<!-- DROP DOWN MENU -->
{#if showMenu}
<div
transition:fly={{ y: -30, x: 30, duration: 200 }}
class="absolute top-5 right-0 min-w-[250px] bg-gray-100 dark:bg-gray-700 rounded-2xl py-4 shadow-lg dark:text-white text-black z-50 text-md flex flex-col"
>
{#each options as option, index (option)}
<button
class="hover:bg-gray-300 dark:hover:bg-gray-800 p-4 transition-all grid grid-cols-[20px,1fr] place-items-center gap-2"
on:click={() => handleSelectOption(index)}
>
{#if value == option}
<div class="text-immich-primary dark:text-immich-dark-primary font-medium">
<Check size="18" />
</div>
<p
class="justify-self-start text-immich-primary dark:text-immich-dark-primary font-medium"
>
{option}
</p>
{:else}
<div />
<p class="justify-self-start">
{option}
</p>
{/if}
</button>
{/each}
</div>
{/if}
<!-- DROP DOWN MENU -->
{#if showMenu}
<div
transition:fly={{ y: -30, x: 30, duration: 200 }}
class="absolute top-5 right-0 min-w-[250px] bg-gray-100 dark:bg-gray-700 rounded-2xl py-4 shadow-lg dark:text-white text-black z-50 text-md flex flex-col"
>
{#each options as option, index (option)}
<button
class="hover:bg-gray-300 dark:hover:bg-gray-800 p-4 transition-all grid grid-cols-[20px,1fr] place-items-center gap-2"
on:click={() => handleSelectOption(index)}
>
{#if value == option}
<div class="text-immich-primary dark:text-immich-dark-primary font-medium">
<Check size="18" />
</div>
<p class="justify-self-start text-immich-primary dark:text-immich-dark-primary font-medium">
{option}
</p>
{:else}
<div />
<p class="justify-self-start">
{option}
</p>
{/if}
</button>
{/each}
</div>
{/if}
</div>

View file

@ -1,46 +1,46 @@
<script lang="ts">
import { PersonResponseDto, api } from '@api';
import { createEventDispatcher } from 'svelte';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import Button from '../elements/buttons/button.svelte';
import { clickOutside } from '$lib/utils/click-outside';
import { PersonResponseDto, api } from '@api';
import { createEventDispatcher } from 'svelte';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import Button from '../elements/buttons/button.svelte';
import { clickOutside } from '$lib/utils/click-outside';
export let person: PersonResponseDto;
let name = person.name;
export let person: PersonResponseDto;
let name = person.name;
const dispatch = createEventDispatcher<{
change: string;
cancel: void;
}>();
const dispatch = createEventDispatcher<{
change: string;
cancel: void;
}>();
</script>
<div
class="flex place-items-center max-w-lg rounded-lg border dark:border-transparent p-2 bg-gray-100 dark:bg-gray-700"
use:clickOutside
on:outclick={() => dispatch('cancel')}
class="flex place-items-center max-w-lg rounded-lg border dark:border-transparent p-2 bg-gray-100 dark:bg-gray-700"
use:clickOutside
on:outclick={() => dispatch('cancel')}
>
<ImageThumbnail
circle
shadow
url={api.getPeopleThumbnailUrl(person.id)}
altText={person.name}
widthStyle="2rem"
heightStyle="2rem"
/>
<form
class="ml-4 flex justify-between w-full gap-16"
autocomplete="off"
on:submit|preventDefault={() => dispatch('change', name)}
>
<!-- svelte-ignore a11y-autofocus -->
<input
autofocus
class="gap-2 w-full bg-gray-100 dark:bg-gray-700 dark:text-white"
type="text"
placeholder="New name or nickname"
required
bind:value={name}
/>
<Button size="sm" type="submit">Done</Button>
</form>
<ImageThumbnail
circle
shadow
url={api.getPeopleThumbnailUrl(person.id)}
altText={person.name}
widthStyle="2rem"
heightStyle="2rem"
/>
<form
class="ml-4 flex justify-between w-full gap-16"
autocomplete="off"
on:submit|preventDefault={() => dispatch('change', name)}
>
<!-- svelte-ignore a11y-autofocus -->
<input
autofocus
class="gap-2 w-full bg-gray-100 dark:bg-gray-700 dark:text-white"
type="text"
placeholder="New name or nickname"
required
bind:value={name}
/>
<Button size="sm" type="submit">Done</Button>
</form>
</div>

View file

@ -1,123 +1,102 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
import { api } from '@api';
import Button from '../elements/buttons/button.svelte';
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
import { api } from '@api';
import Button from '../elements/buttons/button.svelte';
let error: string;
let password = '';
let confirmPassowrd = '';
let canRegister = false;
let error: string;
let password = '';
let confirmPassowrd = '';
let canRegister = false;
$: {
if (password !== confirmPassowrd && confirmPassowrd.length > 0) {
error = 'Password does not match';
canRegister = false;
} else {
error = '';
canRegister = true;
}
}
$: {
if (password !== confirmPassowrd && confirmPassowrd.length > 0) {
error = 'Password does not match';
canRegister = false;
} else {
error = '';
canRegister = true;
}
}
async function registerAdmin(event: SubmitEvent & { currentTarget: HTMLFormElement }) {
if (canRegister) {
error = '';
async function registerAdmin(event: SubmitEvent & { currentTarget: HTMLFormElement }) {
if (canRegister) {
error = '';
const form = new FormData(event.currentTarget);
const form = new FormData(event.currentTarget);
const email = form.get('email');
const password = form.get('password');
const firstName = form.get('firstName');
const lastName = form.get('lastName');
const email = form.get('email');
const password = form.get('password');
const firstName = form.get('firstName');
const lastName = form.get('lastName');
const { status } = await api.authenticationApi.adminSignUp({
signUpDto: {
email: String(email),
password: String(password),
firstName: String(firstName),
lastName: String(lastName)
}
});
const { status } = await api.authenticationApi.adminSignUp({
signUpDto: {
email: String(email),
password: String(password),
firstName: String(firstName),
lastName: String(lastName),
},
});
if (status === 201) {
goto(AppRoute.AUTH_LOGIN);
return;
} else {
error = 'Error create admin account';
return;
}
}
}
if (status === 201) {
goto(AppRoute.AUTH_LOGIN);
return;
} else {
error = 'Error create admin account';
return;
}
}
}
</script>
<form on:submit|preventDefault={registerAdmin} method="post" class="flex flex-col gap-5 mt-5">
<div class="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"
autocomplete="email"
required
/>
</div>
<div class="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" autocomplete="email" required />
</div>
<div class="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"
autocomplete="new-password"
required
bind:value={password}
/>
</div>
<div class="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"
autocomplete="new-password"
required
bind:value={password}
/>
</div>
<div class="flex flex-col gap-2">
<label class="immich-form-label" for="confirmPassword">Confirm Admin Password</label>
<input
class="immich-form-input"
id="confirmPassword"
name="password"
type="password"
autocomplete="new-password"
required
bind:value={confirmPassowrd}
/>
</div>
<div class="flex flex-col gap-2">
<label class="immich-form-label" for="confirmPassword">Confirm Admin Password</label>
<input
class="immich-form-input"
id="confirmPassword"
name="password"
type="password"
autocomplete="new-password"
required
bind:value={confirmPassowrd}
/>
</div>
<div class="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"
autocomplete="given-name"
required
/>
</div>
<div class="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" autocomplete="given-name" required />
</div>
<div class="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"
autocomplete="family-name"
required
/>
</div>
<div class="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" autocomplete="family-name" required />
</div>
{#if error}
<p class="text-red-400">{error}</p>
{/if}
{#if error}
<p class="text-red-400">{error}</p>
{/if}
<div class="my-5 flex w-full">
<Button type="submit" size="lg" fullwidth>Sign up</Button>
</div>
<div class="my-5 flex w-full">
<Button type="submit" size="lg" fullwidth>Sign up</Button>
</div>
</form>

View file

@ -1,49 +1,43 @@
<script lang="ts">
import type { APIKeyResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
import KeyVariant from 'svelte-material-icons/KeyVariant.svelte';
import Button from '../elements/buttons/button.svelte';
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
import type { APIKeyResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
import KeyVariant from 'svelte-material-icons/KeyVariant.svelte';
import Button from '../elements/buttons/button.svelte';
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
export let apiKey: Partial<APIKeyResponseDto>;
export let title = 'API Key';
export let cancelText = 'Cancel';
export let submitText = 'Save';
export let apiKey: Partial<APIKeyResponseDto>;
export let title = 'API Key';
export let cancelText = 'Cancel';
export let submitText = 'Save';
const dispatch = createEventDispatcher();
const handleCancel = () => dispatch('cancel');
const handleSubmit = () => dispatch('submit', { ...apiKey, name: apiKey.name });
const dispatch = createEventDispatcher();
const handleCancel = () => dispatch('cancel');
const handleSubmit = () => dispatch('submit', { ...apiKey, name: apiKey.name });
</script>
<FullScreenModal on:clickOutside={() => handleCancel()}>
<div
class="border bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray p-4 shadow-sm w-[500px] max-w-[95vw] rounded-3xl py-8 dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-items-center place-content-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<KeyVariant size="4em" />
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">
{title}
</h1>
</div>
<div
class="border bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray p-4 shadow-sm w-[500px] max-w-[95vw] rounded-3xl py-8 dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-items-center place-content-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<KeyVariant size="4em" />
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">
{title}
</h1>
</div>
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="email">Name</label>
<input
class="immich-form-input"
id="name"
name="name"
type="text"
bind:value={apiKey.name}
/>
</div>
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="email">Name</label>
<input class="immich-form-input" id="name" name="name" type="text" bind:value={apiKey.name} />
</div>
<div class="flex w-full px-4 gap-4 mt-8">
<Button color="gray" fullwidth on:click={() => handleCancel()}>{cancelText}</Button>
<Button type="submit" fullwidth>{submitText}</Button>
</div>
</form>
</div>
<div class="flex w-full px-4 gap-4 mt-8">
<Button color="gray" fullwidth on:click={() => handleCancel()}>{cancelText}</Button>
<Button type="submit" fullwidth>{submitText}</Button>
</div>
</form>
</div>
</FullScreenModal>

View file

@ -1,70 +1,59 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import KeyVariant from 'svelte-material-icons/KeyVariant.svelte';
import { handleError } from '../../utils/handle-error';
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
import {
notificationController,
NotificationType
} from '../shared-components/notification/notification';
import Button from '../elements/buttons/button.svelte';
import { createEventDispatcher, onMount } from 'svelte';
import KeyVariant from 'svelte-material-icons/KeyVariant.svelte';
import { handleError } from '../../utils/handle-error';
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
import { notificationController, NotificationType } from '../shared-components/notification/notification';
import Button from '../elements/buttons/button.svelte';
export let secret = '';
export let secret = '';
const dispatch = createEventDispatcher();
const handleDone = () => dispatch('done');
let canCopyImagesToClipboard = true;
const dispatch = createEventDispatcher();
const handleDone = () => dispatch('done');
let canCopyImagesToClipboard = true;
onMount(async () => {
const module = await import('copy-image-clipboard');
canCopyImagesToClipboard = module.canCopyImagesToClipboard();
});
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(secret);
notificationController.show({
message: 'Copied to clipboard!',
type: NotificationType.Info
});
} catch (error) {
handleError(error, 'Unable to copy to clipboard');
}
};
onMount(async () => {
const module = await import('copy-image-clipboard');
canCopyImagesToClipboard = module.canCopyImagesToClipboard();
});
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(secret);
notificationController.show({
message: 'Copied to clipboard!',
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to copy to clipboard');
}
};
</script>
<FullScreenModal>
<div
class="border bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray p-4 shadow-sm w-[500px] max-w-[95vw] rounded-3xl py-8 dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-items-center place-content-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<KeyVariant size="4em" />
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">
API Key
</h1>
<div
class="border bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray p-4 shadow-sm w-[500px] max-w-[95vw] rounded-3xl py-8 dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-items-center place-content-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<KeyVariant size="4em" />
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">API Key</h1>
<p class="text-sm dark:text-immich-dark-fg">
This value will only be shown once. Please be sure to copy it before closing the window.
</p>
</div>
<p class="text-sm dark:text-immich-dark-fg">
This value will only be shown once. Please be sure to copy it before closing the window.
</p>
</div>
<div class="m-4 flex flex-col gap-2">
<!-- <label class="immich-form-label" for="email">API Key</label> -->
<textarea
class="immich-form-input"
id="secret"
name="secret"
readonly={true}
value={secret}
/>
</div>
<div class="m-4 flex flex-col gap-2">
<!-- <label class="immich-form-label" for="email">API Key</label> -->
<textarea class="immich-form-input" id="secret" name="secret" readonly={true} value={secret} />
</div>
<div class="flex w-full px-4 gap-4 mt-8">
{#if canCopyImagesToClipboard}
<Button on:click={() => handleCopy()} fullwidth>Copy to Clipboard</Button>
{/if}
<Button on:click={() => handleDone()} fullwidth>Done</Button>
</div>
</div>
<div class="flex w-full px-4 gap-4 mt-8">
{#if canCopyImagesToClipboard}
<Button on:click={() => handleCopy()} fullwidth>Copy to Clipboard</Button>
{/if}
<Button on:click={() => handleDone()} fullwidth>Done</Button>
</div>
</div>
</FullScreenModal>

View file

@ -1,86 +1,86 @@
<script lang="ts">
import { api, UserResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
import Button from '../elements/buttons/button.svelte';
import { api, UserResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
import Button from '../elements/buttons/button.svelte';
export let user: UserResponseDto;
let error: string;
let success: string;
export let user: UserResponseDto;
let error: string;
let success: string;
let password = '';
let confirmPassowrd = '';
let password = '';
let confirmPassowrd = '';
let changeChagePassword = false;
let changeChagePassword = false;
$: {
if (password !== confirmPassowrd && confirmPassowrd.length > 0) {
error = 'Password does not match';
changeChagePassword = false;
} else {
error = '';
changeChagePassword = true;
}
}
$: {
if (password !== confirmPassowrd && confirmPassowrd.length > 0) {
error = 'Password does not match';
changeChagePassword = false;
} else {
error = '';
changeChagePassword = true;
}
}
const dispatch = createEventDispatcher();
const dispatch = createEventDispatcher();
async function changePassword() {
if (changeChagePassword) {
error = '';
async function changePassword() {
if (changeChagePassword) {
error = '';
const { status } = await api.userApi.updateUser({
updateUserDto: {
id: user.id,
password: String(password),
shouldChangePassword: false
}
});
const { status } = await api.userApi.updateUser({
updateUserDto: {
id: user.id,
password: String(password),
shouldChangePassword: false,
},
});
if (status === 200) {
dispatch('success');
return;
} else {
console.error('Error changing password');
}
}
}
if (status === 200) {
dispatch('success');
return;
} else {
console.error('Error changing password');
}
}
}
</script>
<form on:submit|preventDefault={changePassword} method="post" class="flex flex-col gap-5 mt-5">
<div class="flex flex-col gap-2">
<label class="immich-form-label" for="password">New Password</label>
<input
class="immich-form-input"
id="password"
name="password"
type="password"
autocomplete="new-password"
required
bind:value={password}
/>
</div>
<div class="flex flex-col gap-2">
<label class="immich-form-label" for="password">New Password</label>
<input
class="immich-form-input"
id="password"
name="password"
type="password"
autocomplete="new-password"
required
bind:value={password}
/>
</div>
<div class="flex flex-col gap-2">
<label class="immich-form-label" for="confirmPassword">Confirm Password</label>
<input
class="immich-form-input"
id="confirmPassword"
name="password"
type="password"
autocomplete="current-password"
required
bind:value={confirmPassowrd}
/>
</div>
<div class="flex flex-col gap-2">
<label class="immich-form-label" for="confirmPassword">Confirm Password</label>
<input
class="immich-form-input"
id="confirmPassword"
name="password"
type="password"
autocomplete="current-password"
required
bind:value={confirmPassowrd}
/>
</div>
{#if error}
<p class="text-red-400 text-sm">{error}</p>
{/if}
{#if error}
<p class="text-red-400 text-sm">{error}</p>
{/if}
{#if success}
<p class="text-immich-primary text-sm">{success}</p>
{/if}
<div class="my-5 flex w-full">
<Button type="submit" size="lg" fullwidth>Change password</Button>
</div>
{#if success}
<p class="text-immich-primary text-sm">{success}</p>
{/if}
<div class="my-5 flex w-full">
<Button type="submit" size="lg" fullwidth>Change password</Button>
</div>
</form>

View file

@ -1,150 +1,135 @@
<script lang="ts">
import { api } from '@api';
import { createEventDispatcher } from 'svelte';
import ImmichLogo from '../shared-components/immich-logo.svelte';
import {
notificationController,
NotificationType
} from '../shared-components/notification/notification';
import Button from '../elements/buttons/button.svelte';
import { api } from '@api';
import { createEventDispatcher } from 'svelte';
import ImmichLogo from '../shared-components/immich-logo.svelte';
import { notificationController, NotificationType } from '../shared-components/notification/notification';
import Button from '../elements/buttons/button.svelte';
let error: string;
let success: string;
let error: string;
let success: string;
let password = '';
let confirmPassowrd = '';
let password = '';
let confirmPassowrd = '';
let canCreateUser = false;
let canCreateUser = false;
let isCreatingUser = false;
let isCreatingUser = false;
$: {
if (password !== confirmPassowrd && confirmPassowrd.length > 0) {
error = 'Password does not match';
canCreateUser = false;
} else {
error = '';
canCreateUser = true;
}
}
const dispatch = createEventDispatcher();
$: {
if (password !== confirmPassowrd && confirmPassowrd.length > 0) {
error = 'Password does not match';
canCreateUser = false;
} else {
error = '';
canCreateUser = true;
}
}
const dispatch = createEventDispatcher();
async function registerUser(event: SubmitEvent) {
if (canCreateUser && !isCreatingUser) {
isCreatingUser = true;
async function registerUser(event: SubmitEvent) {
if (canCreateUser && !isCreatingUser) {
isCreatingUser = true;
error = '';
error = '';
const formElement = event.target as HTMLFormElement;
const formElement = event.target as HTMLFormElement;
const form = new FormData(formElement);
const form = new FormData(formElement);
const email = form.get('email');
const password = form.get('password');
const firstName = form.get('firstName');
const lastName = form.get('lastName');
const email = form.get('email');
const password = form.get('password');
const firstName = form.get('firstName');
const lastName = form.get('lastName');
try {
const { status } = await api.userApi.createUser({
createUserDto: {
email: String(email),
password: String(password),
firstName: String(firstName),
lastName: String(lastName)
}
});
try {
const { status } = await api.userApi.createUser({
createUserDto: {
email: String(email),
password: String(password),
firstName: String(firstName),
lastName: String(lastName),
},
});
if (status === 201) {
success = 'New user created';
if (status === 201) {
success = 'New user created';
dispatch('user-created');
dispatch('user-created');
isCreatingUser = false;
return;
} else {
error = 'Error create user account';
isCreatingUser = false;
}
} catch (e) {
error = 'Error create user account';
isCreatingUser = false;
isCreatingUser = false;
return;
} else {
error = 'Error create user account';
isCreatingUser = false;
}
} catch (e) {
error = 'Error create user account';
isCreatingUser = false;
console.log('[ERROR] registerUser', e);
console.log('[ERROR] registerUser', e);
notificationController.show({
message: `Error create new user, check console for more detail`,
type: NotificationType.Error
});
}
}
}
notificationController.show({
message: `Error create new user, check console for more detail`,
type: NotificationType.Error,
});
}
}
}
</script>
<div
class="border bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray p-4 shadow-sm w-[500px] max-w-[95vw] rounded-3xl py-8 dark:text-immich-dark-fg"
class="border bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray p-4 shadow-sm w-[500px] max-w-[95vw] rounded-3xl py-8 dark:text-immich-dark-fg"
>
<div class="flex flex-col place-items-center place-content-center gap-4 px-4">
<ImmichLogo class="text-center" height="100" width="100" />
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">
Create new user
</h1>
<p
class="text-sm border rounded-md p-4 font-mono text-gray-600 dark:border-immich-dark-bg dark:text-gray-300"
>
Please provide your user with the password, they will have to change it on their first sign
in.
</p>
</div>
<div class="flex flex-col place-items-center place-content-center gap-4 px-4">
<ImmichLogo class="text-center" height="100" width="100" />
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">Create new user</h1>
<p class="text-sm border rounded-md p-4 font-mono text-gray-600 dark:border-immich-dark-bg dark:text-gray-300">
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} 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>
<form on:submit|preventDefault={registerUser} 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
bind:value={password}
/>
</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 bind:value={password} />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="confirmPassword">Confirm Password</label>
<input
class="immich-form-input"
id="confirmPassword"
name="password"
type="password"
required
bind:value={confirmPassowrd}
/>
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="confirmPassword">Confirm Password</label>
<input
class="immich-form-input"
id="confirmPassword"
name="password"
type="password"
required
bind:value={confirmPassowrd}
/>
</div>
<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="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>
<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 ml-4 text-sm">{error}</p>
{/if}
{#if error}
<p class="text-red-400 ml-4 text-sm">{error}</p>
{/if}
{#if success}
<p class="text-immich-primary ml-4 text-sm">{success}</p>
{/if}
<div class="flex w-full p-4">
<Button type="submit" disabled={isCreatingUser} fullwidth>Create</Button>
</div>
</form>
{#if success}
<p class="text-immich-primary ml-4 text-sm">{success}</p>
{/if}
<div class="flex w-full p-4">
<Button type="submit" disabled={isCreatingUser} fullwidth>Create</Button>
</div>
</form>
</div>

View file

@ -1,187 +1,167 @@
<script lang="ts">
import { api, UserResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
import AccountEditOutline from 'svelte-material-icons/AccountEditOutline.svelte';
import {
notificationController,
NotificationType
} from '../shared-components/notification/notification';
import Button from '../elements/buttons/button.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import { handleError } from '../../utils/handle-error';
import { api, UserResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
import AccountEditOutline from 'svelte-material-icons/AccountEditOutline.svelte';
import { notificationController, NotificationType } from '../shared-components/notification/notification';
import Button from '../elements/buttons/button.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import { handleError } from '../../utils/handle-error';
export let user: UserResponseDto;
export let canResetPassword = true;
export let user: UserResponseDto;
export let canResetPassword = true;
let error: string;
let success: string;
let error: string;
let success: string;
let isShowResetPasswordConfirmation = false;
let isShowResetPasswordConfirmation = false;
const dispatch = createEventDispatcher();
const dispatch = createEventDispatcher();
const editUser = async () => {
try {
const { id, email, firstName, lastName, storageLabel, externalPath } = user;
const { status } = await api.userApi.updateUser({
updateUserDto: {
id,
email,
firstName,
lastName,
storageLabel: storageLabel || '',
externalPath: externalPath || ''
}
});
const editUser = async () => {
try {
const { id, email, firstName, lastName, storageLabel, externalPath } = user;
const { status } = await api.userApi.updateUser({
updateUserDto: {
id,
email,
firstName,
lastName,
storageLabel: storageLabel || '',
externalPath: externalPath || '',
},
});
if (status === 200) {
dispatch('edit-success');
}
} catch (error) {
handleError(error, 'Unable to update user');
}
};
if (status === 200) {
dispatch('edit-success');
}
} catch (error) {
handleError(error, 'Unable to update user');
}
};
const resetPassword = async () => {
try {
const defaultPassword = 'password';
const resetPassword = async () => {
try {
const defaultPassword = 'password';
const { status } = await api.userApi.updateUser({
updateUserDto: {
id: user.id,
password: defaultPassword,
shouldChangePassword: true
}
});
const { status } = await api.userApi.updateUser({
updateUserDto: {
id: user.id,
password: defaultPassword,
shouldChangePassword: true,
},
});
if (status == 200) {
dispatch('reset-password-success');
}
} catch (e) {
console.error('Error reseting user password', e);
notificationController.show({
message: 'Error reseting user password, check console for more details',
type: NotificationType.Error
});
} finally {
isShowResetPasswordConfirmation = false;
}
};
if (status == 200) {
dispatch('reset-password-success');
}
} catch (e) {
console.error('Error reseting user password', e);
notificationController.show({
message: 'Error reseting user password, check console for more details',
type: NotificationType.Error,
});
} finally {
isShowResetPasswordConfirmation = false;
}
};
</script>
<div
class="border bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray p-4 shadow-sm w-[500px] max-w-[95vw] rounded-3xl py-8 dark:text-immich-dark-fg"
class="border bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray p-4 shadow-sm w-[500px] max-w-[95vw] rounded-3xl py-8 dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-items-center place-content-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<AccountEditOutline size="4em" />
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">
Edit user
</h1>
</div>
<div
class="flex flex-col place-items-center place-content-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<AccountEditOutline size="4em" />
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">Edit user</h1>
</div>
<form on:submit|preventDefault={editUser} 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"
bind:value={user.email}
/>
</div>
<form on:submit|preventDefault={editUser} 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" bind:value={user.email} />
</div>
<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
bind:value={user.firstName}
/>
</div>
<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
bind:value={user.firstName}
/>
</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
bind:value={user.lastName}
/>
</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 bind:value={user.lastName} />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="storage-label">Storage Label</label>
<input
class="immich-form-input"
id="storage-label"
name="storage-label"
type="text"
bind:value={user.storageLabel}
/>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="storage-label">Storage Label</label>
<input
class="immich-form-input"
id="storage-label"
name="storage-label"
type="text"
bind:value={user.storageLabel}
/>
<p>
Note: To apply the Storage Label 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>
</div>
<p>
Note: To apply the Storage Label 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>
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="external-path">External Path</label>
<input
class="immich-form-input"
id="external-path"
name="external-path"
type="text"
bind:value={user.externalPath}
/>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="external-path">External Path</label>
<input
class="immich-form-input"
id="external-path"
name="external-path"
type="text"
bind:value={user.externalPath}
/>
<p>
Note: Absolute path of parent import directory. A user can only import files if they exist
at or under this path.
</p>
</div>
<p>
Note: Absolute path of parent import directory. A user can only import files if they exist at or under this
path.
</p>
</div>
{#if error}
<p class="text-red-400 ml-4 text-sm">{error}</p>
{/if}
{#if error}
<p class="text-red-400 ml-4 text-sm">{error}</p>
{/if}
{#if success}
<p class="text-immich-primary ml-4 text-sm">{success}</p>
{/if}
<div class="flex w-full px-4 gap-4 mt-8">
{#if canResetPassword}
<Button
color="light-red"
fullwidth
on:click={() => (isShowResetPasswordConfirmation = true)}>Reset password</Button
>
{/if}
<Button type="submit" fullwidth>Confirm</Button>
</div>
</form>
{#if success}
<p class="text-immich-primary ml-4 text-sm">{success}</p>
{/if}
<div class="flex w-full px-4 gap-4 mt-8">
{#if canResetPassword}
<Button color="light-red" fullwidth on:click={() => (isShowResetPasswordConfirmation = true)}
>Reset password</Button
>
{/if}
<Button type="submit" fullwidth>Confirm</Button>
</div>
</form>
</div>
{#if isShowResetPasswordConfirmation}
<ConfirmDialogue
title="Reset Password"
confirmText="Reset"
on:confirm={resetPassword}
on:cancel={() => (isShowResetPasswordConfirmation = false)}
>
<svelte:fragment slot="prompt">
<p>
Are you sure you want to reset <b>{user.firstName} {user.lastName}</b>'s password?
</p>
</svelte:fragment>
</ConfirmDialogue>
<ConfirmDialogue
title="Reset Password"
confirmText="Reset"
on:confirm={resetPassword}
on:cancel={() => (isShowResetPasswordConfirmation = false)}
>
<svelte:fragment slot="prompt">
<p>
Are you sure you want to reset <b>{user.firstName} {user.lastName}</b>'s password?
</p>
</svelte:fragment>
</ConfirmDialogue>
{/if}

View file

@ -1,166 +1,166 @@
<script lang="ts">
import { goto } from '$app/navigation';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { AppRoute } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { api, oauth, OAuthConfigResponseDto } from '@api';
import { createEventDispatcher, onMount } from 'svelte';
import { fade } from 'svelte/transition';
import Button from '../elements/buttons/button.svelte';
import { goto } from '$app/navigation';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { AppRoute } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { api, oauth, OAuthConfigResponseDto } from '@api';
import { createEventDispatcher, onMount } from 'svelte';
import { fade } from 'svelte/transition';
import Button from '../elements/buttons/button.svelte';
let error: string;
let email = '';
let password = '';
let oauthError: string;
export let authConfig: OAuthConfigResponseDto;
let loading = false;
let oauthLoading = true;
let error: string;
let email = '';
let password = '';
let oauthError: string;
export let authConfig: OAuthConfigResponseDto;
let loading = false;
let oauthLoading = true;
const dispatch = createEventDispatcher();
const dispatch = createEventDispatcher();
onMount(async () => {
if (oauth.isCallback(window.location)) {
try {
await oauth.login(window.location);
dispatch('success');
return;
} catch (e) {
console.error('Error [login-form] [oauth.callback]', e);
oauthError = 'Unable to complete OAuth login';
} finally {
oauthLoading = false;
}
}
onMount(async () => {
if (oauth.isCallback(window.location)) {
try {
await oauth.login(window.location);
dispatch('success');
return;
} catch (e) {
console.error('Error [login-form] [oauth.callback]', e);
oauthError = 'Unable to complete OAuth login';
} finally {
oauthLoading = false;
}
}
try {
const { data } = await oauth.getConfig(window.location);
authConfig = data;
try {
const { data } = await oauth.getConfig(window.location);
authConfig = data;
const { enabled, url, autoLaunch } = authConfig;
const { enabled, url, autoLaunch } = authConfig;
if (enabled && url && autoLaunch && !oauth.isAutoLaunchDisabled(window.location)) {
await goto(`${AppRoute.AUTH_LOGIN}?autoLaunch=0`, { replaceState: true });
await goto(url);
return;
}
} catch (error) {
authConfig.passwordLoginEnabled = true;
handleError(error, 'Unable to connect!');
}
if (enabled && url && autoLaunch && !oauth.isAutoLaunchDisabled(window.location)) {
await goto(`${AppRoute.AUTH_LOGIN}?autoLaunch=0`, { replaceState: true });
await goto(url);
return;
}
} catch (error) {
authConfig.passwordLoginEnabled = true;
handleError(error, 'Unable to connect!');
}
oauthLoading = false;
});
oauthLoading = false;
});
const login = async () => {
try {
error = '';
loading = true;
const login = async () => {
try {
error = '';
loading = true;
const { data } = await api.authenticationApi.login({
loginCredentialDto: {
email,
password
}
});
const { data } = await api.authenticationApi.login({
loginCredentialDto: {
email,
password,
},
});
if (!data.isAdmin && data.shouldChangePassword) {
dispatch('first-login');
return;
}
if (!data.isAdmin && data.shouldChangePassword) {
dispatch('first-login');
return;
}
dispatch('success');
return;
} catch (e) {
error = 'Incorrect email or password';
loading = false;
return;
}
};
dispatch('success');
return;
} catch (e) {
error = 'Incorrect email or password';
loading = false;
return;
}
};
</script>
{#if authConfig.passwordLoginEnabled}
<form on:submit|preventDefault={login} class="flex flex-col gap-5 mt-5">
{#if error}
<p class="text-red-400" transition:fade>
{error}
</p>
{/if}
<form on:submit|preventDefault={login} class="flex flex-col gap-5 mt-5">
{#if error}
<p class="text-red-400" transition:fade>
{error}
</p>
{/if}
<div class="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"
autocomplete="email"
bind:value={email}
required
/>
</div>
<div class="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"
autocomplete="email"
bind:value={email}
required
/>
</div>
<div class="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"
autocomplete="current-password"
bind:value={password}
required
/>
</div>
<div class="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"
autocomplete="current-password"
bind:value={password}
required
/>
</div>
<div class="my-5 flex w-full">
<Button type="submit" size="lg" fullwidth disabled={loading || oauthLoading}>
{#if loading}
<span class="h-6">
<LoadingSpinner />
</span>
{:else}
Login
{/if}
</Button>
</div>
</form>
<div class="my-5 flex w-full">
<Button type="submit" size="lg" fullwidth disabled={loading || oauthLoading}>
{#if loading}
<span class="h-6">
<LoadingSpinner />
</span>
{:else}
Login
{/if}
</Button>
</div>
</form>
{/if}
{#if authConfig.enabled}
{#if authConfig.passwordLoginEnabled}
<div class="inline-flex items-center justify-center w-full">
<hr class="w-3/4 h-px my-4 bg-gray-200 border-0 dark:bg-gray-600" />
<span
class="absolute px-3 font-medium text-gray-900 -translate-x-1/2 left-1/2 dark:text-white bg-white dark:bg-immich-dark-gray"
>
or
</span>
</div>
{/if}
<div class="my-5 flex flex-col gap-5">
{#if oauthError}
<p class="text-red-400" transition:fade>{oauthError}</p>
{/if}
<a href={authConfig.url} class="flex w-full">
<Button
type="button"
disabled={loading || oauthLoading}
size="lg"
fullwidth
color={authConfig.passwordLoginEnabled ? 'secondary' : 'primary'}
>
{#if oauthLoading}
<span class="h-6">
<LoadingSpinner />
</span>
{:else}
{authConfig.buttonText || 'Login with OAuth'}
{/if}
</Button>
</a>
</div>
{#if authConfig.passwordLoginEnabled}
<div class="inline-flex items-center justify-center w-full">
<hr class="w-3/4 h-px my-4 bg-gray-200 border-0 dark:bg-gray-600" />
<span
class="absolute px-3 font-medium text-gray-900 -translate-x-1/2 left-1/2 dark:text-white bg-white dark:bg-immich-dark-gray"
>
or
</span>
</div>
{/if}
<div class="my-5 flex flex-col gap-5">
{#if oauthError}
<p class="text-red-400" transition:fade>{oauthError}</p>
{/if}
<a href={authConfig.url} class="flex w-full">
<Button
type="button"
disabled={loading || oauthLoading}
size="lg"
fullwidth
color={authConfig.passwordLoginEnabled ? 'secondary' : 'primary'}
>
{#if oauthLoading}
<span class="h-6">
<LoadingSpinner />
</span>
{:else}
{authConfig.buttonText || 'Login with OAuth'}
{/if}
</Button>
</a>
</div>
{/if}
{#if !authConfig.enabled && !authConfig.passwordLoginEnabled}
<p class="text-center dark:text-immich-dark-fg p-4">Login has been disabled.</p>
<p class="text-center dark:text-immich-dark-fg p-4">Login has been disabled.</p>
{/if}

View file

@ -1,44 +1,42 @@
<script lang="ts">
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import type { UserResponseDto } from '@api';
import NavigationBar from '../shared-components/navigation-bar/navigation-bar.svelte';
import SideBar from '../shared-components/side-bar/side-bar.svelte';
export let user: UserResponseDto;
export let hideNavbar = false;
export let showUploadButton = false;
export let title: string | undefined = undefined;
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import type { UserResponseDto } from '@api';
import NavigationBar from '../shared-components/navigation-bar/navigation-bar.svelte';
import SideBar from '../shared-components/side-bar/side-bar.svelte';
export let user: UserResponseDto;
export let hideNavbar = false;
export let showUploadButton = false;
export let title: string | undefined = undefined;
</script>
<header>
{#if !hideNavbar}
<NavigationBar {user} {showUploadButton} on:uploadClicked={() => openFileUploadDialog()} />
{/if}
{#if !hideNavbar}
<NavigationBar {user} {showUploadButton} on:uploadClicked={() => openFileUploadDialog()} />
{/if}
<slot name="header" />
<slot name="header" />
</header>
<main
class="grid md:grid-cols-[theme(spacing.64)_auto] grid-cols-[theme(spacing.18)_auto] relative pt-[var(--navbar-height)] h-screen overflow-hidden bg-immich-bg dark:bg-immich-dark-bg"
class="grid md:grid-cols-[theme(spacing.64)_auto] grid-cols-[theme(spacing.18)_auto] relative pt-[var(--navbar-height)] h-screen overflow-hidden bg-immich-bg dark:bg-immich-dark-bg"
>
<slot name="sidebar">
<SideBar />
</slot>
<slot name="content">
{#if title}
<section class="relative">
<div
class="absolute border-b dark:border-immich-dark-gray flex justify-between place-items-center dark:text-immich-dark-fg w-full p-4 h-16"
>
<p class="font-medium">{title}</p>
<slot name="buttons" />
</div>
<slot name="sidebar">
<SideBar />
</slot>
<slot name="content">
{#if title}
<section class="relative">
<div
class="absolute border-b dark:border-immich-dark-gray flex justify-between place-items-center dark:text-immich-dark-fg w-full p-4 h-16"
>
<p class="font-medium">{title}</p>
<slot name="buttons" />
</div>
<div
class="absolute overflow-y-auto top-16 h-[calc(100%-theme(spacing.16))] w-full immich-scrollbar p-4 pb-8"
>
<slot />
</div>
</section>
{/if}
</slot>
<div class="absolute overflow-y-auto top-16 h-[calc(100%-theme(spacing.16))] w-full immich-scrollbar p-4 pb-8">
<slot />
</div>
</section>
{/if}
</slot>
</main>

View file

@ -1,120 +1,113 @@
<script lang="ts">
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import type { MapSettings } from '$lib/stores/preferences.store';
import { Duration } from 'luxon';
import { createEventDispatcher } from 'svelte';
import { fly } from 'svelte/transition';
import SettingSelect from '../admin-page/settings/setting-select.svelte';
import SettingSwitch from '../admin-page/settings/setting-switch.svelte';
import Button from '../elements/buttons/button.svelte';
import LinkButton from '../elements/buttons/link-button.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import type { MapSettings } from '$lib/stores/preferences.store';
import { Duration } from 'luxon';
import { createEventDispatcher } from 'svelte';
import { fly } from 'svelte/transition';
import SettingSelect from '../admin-page/settings/setting-select.svelte';
import SettingSwitch from '../admin-page/settings/setting-switch.svelte';
import Button from '../elements/buttons/button.svelte';
import LinkButton from '../elements/buttons/link-button.svelte';
export let settings: MapSettings;
let customDateRange = !!settings.dateAfter || !!settings.dateBefore;
export let settings: MapSettings;
let customDateRange = !!settings.dateAfter || !!settings.dateBefore;
const dispatch = createEventDispatcher<{
close: void;
save: MapSettings;
}>();
const dispatch = createEventDispatcher<{
close: void;
save: MapSettings;
}>();
</script>
<FullScreenModal on:clickOutside={() => dispatch('close')}>
<div
class="flex flex-col gap-8 border bg-white dark:bg-immich-dark-gray dark:border-immich-dark-gray p-8 shadow-sm w-96 max-w-lg rounded-3xl"
>
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium self-center">
Map Settings
</h1>
<div
class="flex flex-col gap-8 border bg-white dark:bg-immich-dark-gray dark:border-immich-dark-gray p-8 shadow-sm w-96 max-w-lg rounded-3xl"
>
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium self-center">Map Settings</h1>
<form
on:submit|preventDefault={() => dispatch('save', settings)}
class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary"
>
<SettingSwitch title="Allow dark mode" bind:checked={settings.allowDarkMode} />
<SettingSwitch title="Only favorites" bind:checked={settings.onlyFavorites} />
{#if customDateRange}
<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
<div class="flex justify-between items-center gap-8">
<label class="immich-form-label text-sm shrink-0" for="date-after">Date after</label>
<input
class="immich-form-input w-40"
type="date"
id="date-after"
max={settings.dateBefore}
bind:value={settings.dateAfter}
/>
</div>
<div class="flex justify-between items-center gap-8">
<label class="immich-form-label text-sm shrink-0" for="date-before">Date before</label>
<input
class="immich-form-input w-40"
type="date"
id="date-before"
bind:value={settings.dateBefore}
/>
</div>
<div class="flex justify-center text-xs">
<LinkButton
on:click={() => {
customDateRange = false;
settings.dateAfter = '';
settings.dateBefore = '';
}}
>
Remove custom date range
</LinkButton>
</div>
</div>
{:else}
<div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-1">
<SettingSelect
label="Date range"
name="date-range"
bind:value={settings.relativeDate}
options={[
{
value: '',
text: 'All'
},
{
value: Duration.fromObject({ hours: 24 }).toISO() || '',
text: 'Past 24 hours'
},
{
value: Duration.fromObject({ days: 7 }).toISO() || '',
text: 'Past 7 days'
},
{
value: Duration.fromObject({ days: 30 }).toISO() || '',
text: 'Past 30 days'
},
{
value: Duration.fromObject({ years: 1 }).toISO() || '',
text: 'Past year'
},
{
value: Duration.fromObject({ years: 3 }).toISO() || '',
text: 'Past 3 years'
}
]}
/>
<div class="text-xs">
<LinkButton
on:click={() => {
customDateRange = true;
settings.relativeDate = '';
}}
>
Use custom date range instead
</LinkButton>
</div>
</div>
{/if}
<form
on:submit|preventDefault={() => dispatch('save', settings)}
class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary"
>
<SettingSwitch title="Allow dark mode" bind:checked={settings.allowDarkMode} />
<SettingSwitch title="Only favorites" bind:checked={settings.onlyFavorites} />
{#if customDateRange}
<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
<div class="flex justify-between items-center gap-8">
<label class="immich-form-label text-sm shrink-0" for="date-after">Date after</label>
<input
class="immich-form-input w-40"
type="date"
id="date-after"
max={settings.dateBefore}
bind:value={settings.dateAfter}
/>
</div>
<div class="flex justify-between items-center gap-8">
<label class="immich-form-label text-sm shrink-0" for="date-before">Date before</label>
<input class="immich-form-input w-40" type="date" id="date-before" bind:value={settings.dateBefore} />
</div>
<div class="flex justify-center text-xs">
<LinkButton
on:click={() => {
customDateRange = false;
settings.dateAfter = '';
settings.dateBefore = '';
}}
>
Remove custom date range
</LinkButton>
</div>
</div>
{:else}
<div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-1">
<SettingSelect
label="Date range"
name="date-range"
bind:value={settings.relativeDate}
options={[
{
value: '',
text: 'All',
},
{
value: Duration.fromObject({ hours: 24 }).toISO() || '',
text: 'Past 24 hours',
},
{
value: Duration.fromObject({ days: 7 }).toISO() || '',
text: 'Past 7 days',
},
{
value: Duration.fromObject({ days: 30 }).toISO() || '',
text: 'Past 30 days',
},
{
value: Duration.fromObject({ years: 1 }).toISO() || '',
text: 'Past year',
},
{
value: Duration.fromObject({ years: 3 }).toISO() || '',
text: 'Past 3 years',
},
]}
/>
<div class="text-xs">
<LinkButton
on:click={() => {
customDateRange = true;
settings.relativeDate = '';
}}
>
Use custom date range instead
</LinkButton>
</div>
</div>
{/if}
<div class="flex w-full gap-4 mt-4">
<Button color="gray" size="sm" fullwidth on:click={() => dispatch('close')}>Cancel</Button>
<Button type="submit" size="sm" fullwidth>Save</Button>
</div>
</form>
</div>
<div class="flex w-full gap-4 mt-4">
<Button color="gray" size="sm" fullwidth on:click={() => dispatch('close')}>Cancel</Button>
<Button type="submit" size="sm" fullwidth>Save</Button>
</div>
</form>
</div>
</FullScreenModal>

View file

@ -1,318 +1,282 @@
<script lang="ts">
import { memoryStore } from '$lib/stores/memory.store';
import { DateTime } from 'luxon';
import { onMount } from 'svelte';
import { api } from '@api';
import { goto } from '$app/navigation';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import Play from 'svelte-material-icons/Play.svelte';
import Pause from 'svelte-material-icons/Pause.svelte';
import ChevronDown from 'svelte-material-icons/ChevronDown.svelte';
import ChevronUp from 'svelte-material-icons/ChevronUp.svelte';
import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
import { AppRoute } from '$lib/constants';
import { page } from '$app/stores';
import noThumbnailUrl from '$lib/assets/no-thumbnail.png';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
import { fade } from 'svelte/transition';
import { tweened } from 'svelte/motion';
import { memoryStore } from '$lib/stores/memory.store';
import { DateTime } from 'luxon';
import { onMount } from 'svelte';
import { api } from '@api';
import { goto } from '$app/navigation';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import Play from 'svelte-material-icons/Play.svelte';
import Pause from 'svelte-material-icons/Pause.svelte';
import ChevronDown from 'svelte-material-icons/ChevronDown.svelte';
import ChevronUp from 'svelte-material-icons/ChevronUp.svelte';
import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
import { AppRoute } from '$lib/constants';
import { page } from '$app/stores';
import noThumbnailUrl from '$lib/assets/no-thumbnail.png';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
import { fade } from 'svelte/transition';
import { tweened } from 'svelte/motion';
const parseIndex = (s: string | null, max: number | null) =>
Math.max(Math.min(parseInt(s ?? '') || 0, max ?? 0), 0);
const parseIndex = (s: string | null, max: number | null) => Math.max(Math.min(parseInt(s ?? '') || 0, max ?? 0), 0);
$: memoryIndex = parseIndex($page.url.searchParams.get('memory'), $memoryStore?.length - 1);
$: assetIndex = parseIndex($page.url.searchParams.get('asset'), currentMemory?.assets.length - 1);
$: memoryIndex = parseIndex($page.url.searchParams.get('memory'), $memoryStore?.length - 1);
$: assetIndex = parseIndex($page.url.searchParams.get('asset'), currentMemory?.assets.length - 1);
$: previousMemory = $memoryStore?.[memoryIndex - 1];
$: currentMemory = $memoryStore?.[memoryIndex];
$: nextMemory = $memoryStore?.[memoryIndex + 1];
$: previousMemory = $memoryStore?.[memoryIndex - 1];
$: currentMemory = $memoryStore?.[memoryIndex];
$: nextMemory = $memoryStore?.[memoryIndex + 1];
$: previousAsset = currentMemory?.assets[assetIndex - 1];
$: currentAsset = currentMemory?.assets[assetIndex];
$: nextAsset = currentMemory?.assets[assetIndex + 1];
$: previousAsset = currentMemory?.assets[assetIndex - 1];
$: currentAsset = currentMemory?.assets[assetIndex];
$: nextAsset = currentMemory?.assets[assetIndex + 1];
$: canGoForward = !!(nextMemory || nextAsset);
$: canGoBack = !!(previousMemory || previousAsset);
$: canGoForward = !!(nextMemory || nextAsset);
$: canGoBack = !!(previousMemory || previousAsset);
const toNextMemory = () => goto(`?memory=${memoryIndex + 1}`);
const toPreviousMemory = () => goto(`?memory=${memoryIndex - 1}`);
const toNextMemory = () => goto(`?memory=${memoryIndex + 1}`);
const toPreviousMemory = () => goto(`?memory=${memoryIndex - 1}`);
const toNextAsset = () => goto(`?memory=${memoryIndex}&asset=${assetIndex + 1}`);
const toPreviousAsset = () => goto(`?memory=${memoryIndex}&asset=${assetIndex - 1}`);
const toNextAsset = () => goto(`?memory=${memoryIndex}&asset=${assetIndex + 1}`);
const toPreviousAsset = () => goto(`?memory=${memoryIndex}&asset=${assetIndex - 1}`);
const toNext = () => (nextAsset ? toNextAsset() : toNextMemory());
const toPrevious = () => (previousAsset ? toPreviousAsset() : toPreviousMemory());
const toNext = () => (nextAsset ? toNextAsset() : toNextMemory());
const toPrevious = () => (previousAsset ? toPreviousAsset() : toPreviousMemory());
const progress = tweened<number>(0, {
duration: (from: number, to: number) => (to ? 5000 * (to - from) : 0)
});
const progress = tweened<number>(0, {
duration: (from: number, to: number) => (to ? 5000 * (to - from) : 0),
});
const play = () => progress.set(1);
const pause = () => progress.set($progress);
const play = () => progress.set(1);
const pause = () => progress.set($progress);
let resetPromise = Promise.resolve();
const reset = () => (resetPromise = progress.set(0));
let resetPromise = Promise.resolve();
const reset = () => (resetPromise = progress.set(0));
let paused = false;
let paused = false;
// Play or pause progress when the paused state changes.
$: paused ? pause() : play();
// Play or pause progress when the paused state changes.
$: paused ? pause() : play();
// Progress should be paused when it's no longer possible to advance.
$: paused ||= !canGoForward || galleryInView;
// Progress should be paused when it's no longer possible to advance.
$: paused ||= !canGoForward || galleryInView;
// Advance to the next asset or memory when progress is complete.
$: $progress === 1 && toNext();
// Advance to the next asset or memory when progress is complete.
$: $progress === 1 && toNext();
// Progress should be resumed when reset and not paused.
$: !$progress && !paused && play();
// Progress should be resumed when reset and not paused.
$: !$progress && !paused && play();
// Progress should be reset when the current memory or asset changes.
$: memoryIndex, assetIndex, reset();
// Progress should be reset when the current memory or asset changes.
$: memoryIndex, assetIndex, reset();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowRight' && canGoForward) {
e.preventDefault();
toNext();
} else if (e.key === 'ArrowLeft' && canGoBack) {
e.preventDefault();
toPrevious();
} else if (e.key === 'Escape') {
e.preventDefault();
goto(AppRoute.PHOTOS);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowRight' && canGoForward) {
e.preventDefault();
toNext();
} else if (e.key === 'ArrowLeft' && canGoBack) {
e.preventDefault();
toPrevious();
} else if (e.key === 'Escape') {
e.preventDefault();
goto(AppRoute.PHOTOS);
}
};
onMount(async () => {
if (!$memoryStore) {
const { data } = await api.assetApi.getMemoryLane({
timestamp: DateTime.local().startOf('day').toISO() || ''
});
$memoryStore = data;
}
});
onMount(async () => {
if (!$memoryStore) {
const { data } = await api.assetApi.getMemoryLane({
timestamp: DateTime.local().startOf('day').toISO() || '',
});
$memoryStore = data;
}
});
let memoryGallery: HTMLElement;
let memoryWrapper: HTMLElement;
let galleryInView = false;
let memoryGallery: HTMLElement;
let memoryWrapper: HTMLElement;
let galleryInView = false;
</script>
<svelte:window on:keydown={handleKeyDown} />
<section id="memory-viewer" class="w-full bg-immich-dark-gray" bind:this={memoryWrapper}>
{#if currentMemory}
<ControlAppBar on:close-button-click={() => goto(AppRoute.PHOTOS)} forceDark>
<svelte:fragment slot="leading">
<p class="text-lg">
{currentMemory.title}
</p>
</svelte:fragment>
{#if currentMemory}
<ControlAppBar on:close-button-click={() => goto(AppRoute.PHOTOS)} forceDark>
<svelte:fragment slot="leading">
<p class="text-lg">
{currentMemory.title}
</p>
</svelte:fragment>
{#if !galleryInView}
<div class="flex place-items-center place-content-center overflow-hidden gap-2">
<CircleIconButton
logo={paused ? Play : Pause}
forceDark
on:click={() => (paused = !paused)}
/>
{#if !galleryInView}
<div class="flex place-items-center place-content-center overflow-hidden gap-2">
<CircleIconButton logo={paused ? Play : Pause} forceDark on:click={() => (paused = !paused)} />
{#each currentMemory.assets as _, i}
<button
class="relative w-full py-2"
on:click={() => goto(`?memory=${memoryIndex}&asset=${i}`)}
>
<span class="absolute left-0 w-full h-[2px] bg-gray-500" />
{#await resetPromise}
<span
class="absolute left-0 h-[2px] bg-white"
style:width={`${i < assetIndex ? 100 : 0}%`}
/>
{:then}
<span
class="absolute left-0 h-[2px] bg-white"
style:width={`${i < assetIndex ? 100 : i > assetIndex ? 0 : $progress * 100}%`}
/>
{/await}
</button>
{/each}
{#each currentMemory.assets as _, i}
<button class="relative w-full py-2" on:click={() => goto(`?memory=${memoryIndex}&asset=${i}`)}>
<span class="absolute left-0 w-full h-[2px] bg-gray-500" />
{#await resetPromise}
<span class="absolute left-0 h-[2px] bg-white" style:width={`${i < assetIndex ? 100 : 0}%`} />
{:then}
<span
class="absolute left-0 h-[2px] bg-white"
style:width={`${i < assetIndex ? 100 : i > assetIndex ? 0 : $progress * 100}%`}
/>
{/await}
</button>
{/each}
<div>
<p class="text-small">
{assetIndex + 1}/{currentMemory.assets.length}
</p>
</div>
</div>
{/if}
</ControlAppBar>
<div>
<p class="text-small">
{assetIndex + 1}/{currentMemory.assets.length}
</p>
</div>
</div>
{/if}
</ControlAppBar>
{#if galleryInView}
<div
class="sticky top-20 flex place-content-center place-items-center z-30 transition-opacity"
class:opacity-0={!galleryInView}
class:opacity-100={galleryInView}
>
<button
on:click={() => memoryWrapper.scrollIntoView({ behavior: 'smooth' })}
disabled={!galleryInView}
>
<CircleIconButton logo={ChevronUp} backgroundColor="white" forceDark />
</button>
</div>
{/if}
<!-- Viewer -->
<section class="pt-20 overflow-hidden">
<div
class="flex w-[300%] h-[calc(100vh_-_180px)] items-center justify-center box-border ml-[-100%] gap-10 overflow-hidden"
>
<!-- PREVIOUS MEMORY -->
<div
class="rounded-2xl w-[20vw] h-1/2"
class:opacity-25={previousMemory}
class:opacity-0={!previousMemory}
class:hover:opacity-70={previousMemory}
>
<button
class="rounded-2xl h-full w-full relative"
disabled={!previousMemory}
on:click={toPreviousMemory}
>
<img
class="rounded-2xl h-full w-full object-cover"
src={previousMemory
? api.getAssetThumbnailUrl(previousMemory.assets[0].id, 'JPEG')
: noThumbnailUrl}
alt=""
draggable="false"
/>
{#if galleryInView}
<div
class="sticky top-20 flex place-content-center place-items-center z-30 transition-opacity"
class:opacity-0={!galleryInView}
class:opacity-100={galleryInView}
>
<button on:click={() => memoryWrapper.scrollIntoView({ behavior: 'smooth' })} disabled={!galleryInView}>
<CircleIconButton logo={ChevronUp} backgroundColor="white" forceDark />
</button>
</div>
{/if}
<!-- Viewer -->
<section class="pt-20 overflow-hidden">
<div
class="flex w-[300%] h-[calc(100vh_-_180px)] items-center justify-center box-border ml-[-100%] gap-10 overflow-hidden"
>
<!-- PREVIOUS MEMORY -->
<div
class="rounded-2xl w-[20vw] h-1/2"
class:opacity-25={previousMemory}
class:opacity-0={!previousMemory}
class:hover:opacity-70={previousMemory}
>
<button class="rounded-2xl h-full w-full relative" disabled={!previousMemory} on:click={toPreviousMemory}>
<img
class="rounded-2xl h-full w-full object-cover"
src={previousMemory ? api.getAssetThumbnailUrl(previousMemory.assets[0].id, 'JPEG') : noThumbnailUrl}
alt=""
draggable="false"
/>
{#if previousMemory}
<div class="absolute right-4 bottom-4 text-white text-left">
<p class="font-semibold text-xs text-gray-200">PREVIOUS</p>
<p class="text-xl">{previousMemory.title}</p>
</div>
{/if}
</button>
</div>
{#if previousMemory}
<div class="absolute right-4 bottom-4 text-white text-left">
<p class="font-semibold text-xs text-gray-200">PREVIOUS</p>
<p class="text-xl">{previousMemory.title}</p>
</div>
{/if}
</button>
</div>
<!-- CURRENT MEMORY -->
<div
class="main-view rounded-2xl h-full relative w-[70vw] bg-black flex place-items-center place-content-center"
>
<div class="bg-black w-full h-full rounded-2xl">
<!-- CONTROL BUTTONS -->
<div class="absolute h-full flex justify-between w-full">
<div class="flex h-full flex-col place-content-center place-items-center ml-4">
<div class="inline-block">
{#if canGoBack}
<CircleIconButton
logo={ChevronLeft}
backgroundColor="#202123"
on:click={toPrevious}
/>
{/if}
</div>
</div>
<div class="flex h-full flex-col place-content-center place-items-center mr-4">
<div class="inline-block">
{#if canGoForward}
<CircleIconButton
logo={ChevronRight}
backgroundColor="#202123"
on:click={toNext}
/>
{/if}
</div>
</div>
</div>
<!-- CURRENT MEMORY -->
<div
class="main-view rounded-2xl h-full relative w-[70vw] bg-black flex place-items-center place-content-center"
>
<div class="bg-black w-full h-full rounded-2xl">
<!-- CONTROL BUTTONS -->
<div class="absolute h-full flex justify-between w-full">
<div class="flex h-full flex-col place-content-center place-items-center ml-4">
<div class="inline-block">
{#if canGoBack}
<CircleIconButton logo={ChevronLeft} backgroundColor="#202123" on:click={toPrevious} />
{/if}
</div>
</div>
<div class="flex h-full flex-col place-content-center place-items-center mr-4">
<div class="inline-block">
{#if canGoForward}
<CircleIconButton logo={ChevronRight} backgroundColor="#202123" on:click={toNext} />
{/if}
</div>
</div>
</div>
{#key currentAsset.id}
<img
transition:fade|local
class="rounded-2xl w-full h-full object-contain transition-all"
src={api.getAssetThumbnailUrl(currentAsset.id, 'JPEG')}
alt=""
draggable="false"
/>
{/key}
{#key currentAsset.id}
<img
transition:fade|local
class="rounded-2xl w-full h-full object-contain transition-all"
src={api.getAssetThumbnailUrl(currentAsset.id, 'JPEG')}
alt=""
draggable="false"
/>
{/key}
<div class="absolute top-4 left-8 text-white text-sm font-medium">
<p>
{DateTime.fromISO(currentMemory.assets[0].fileCreatedAt).toLocaleString(
DateTime.DATE_FULL
)}
</p>
<p>
{currentAsset.exifInfo?.city || ''}
{currentAsset.exifInfo?.country || ''}
</p>
</div>
</div>
</div>
<div class="absolute top-4 left-8 text-white text-sm font-medium">
<p>
{DateTime.fromISO(currentMemory.assets[0].fileCreatedAt).toLocaleString(DateTime.DATE_FULL)}
</p>
<p>
{currentAsset.exifInfo?.city || ''}
{currentAsset.exifInfo?.country || ''}
</p>
</div>
</div>
</div>
<!-- NEXT MEMORY -->
<div
class="rounded-xl w-[20vw] h-1/2"
class:opacity-25={nextMemory}
class:opacity-0={!nextMemory}
class:hover:opacity-70={nextMemory}
>
<button
class="rounded-2xl h-full w-full relative"
on:click={toNextMemory}
disabled={!nextMemory}
>
<img
class="rounded-2xl h-full w-full object-cover"
src={nextMemory
? api.getAssetThumbnailUrl(nextMemory.assets[0].id, 'JPEG')
: noThumbnailUrl}
alt=""
draggable="false"
/>
<!-- NEXT MEMORY -->
<div
class="rounded-xl w-[20vw] h-1/2"
class:opacity-25={nextMemory}
class:opacity-0={!nextMemory}
class:hover:opacity-70={nextMemory}
>
<button class="rounded-2xl h-full w-full relative" on:click={toNextMemory} disabled={!nextMemory}>
<img
class="rounded-2xl h-full w-full object-cover"
src={nextMemory ? api.getAssetThumbnailUrl(nextMemory.assets[0].id, 'JPEG') : noThumbnailUrl}
alt=""
draggable="false"
/>
{#if nextMemory}
<div class="absolute left-4 bottom-4 text-white text-left">
<p class="font-semibold text-xs text-gray-200">UP NEXT</p>
<p class="text-xl">{nextMemory.title}</p>
</div>
{/if}
</button>
</div>
</div>
</section>
{#if nextMemory}
<div class="absolute left-4 bottom-4 text-white text-left">
<p class="font-semibold text-xs text-gray-200">UP NEXT</p>
<p class="text-xl">{nextMemory.title}</p>
</div>
{/if}
</button>
</div>
</div>
</section>
<!-- GALERY VIEWER -->
<!-- GALERY VIEWER -->
<section class="bg-immich-dark-gray pl-4">
<div
class="sticky flex place-content-center place-items-center mb-10 mt-4 transition-all"
class:opacity-0={galleryInView}
class:opacity-100={!galleryInView}
>
<button on:click={() => memoryGallery.scrollIntoView({ behavior: 'smooth' })}>
<CircleIconButton logo={ChevronDown} backgroundColor="white" forceDark />
</button>
</div>
<section class="bg-immich-dark-gray pl-4">
<div
class="sticky flex place-content-center place-items-center mb-10 mt-4 transition-all"
class:opacity-0={galleryInView}
class:opacity-100={!galleryInView}
>
<button on:click={() => memoryGallery.scrollIntoView({ behavior: 'smooth' })}>
<CircleIconButton logo={ChevronDown} backgroundColor="white" forceDark />
</button>
</div>
<IntersectionObserver
once={false}
on:intersected={() => (galleryInView = true)}
on:hidden={() => (galleryInView = false)}
bottom={-200}
>
<div id="gallery-memory" bind:this={memoryGallery}>
<GalleryViewer assets={currentMemory.assets} viewFrom="album-page" />
</div>
</IntersectionObserver>
</section>
{/if}
<IntersectionObserver
once={false}
on:intersected={() => (galleryInView = true)}
on:hidden={() => (galleryInView = false)}
bottom={-200}
>
<div id="gallery-memory" bind:this={memoryGallery}>
<GalleryViewer assets={currentMemory.assets} viewFrom="album-page" />
</div>
</IntersectionObserver>
</section>
{/if}
</section>
<style>
.main-view {
box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.3), 0 8px 12px 6px rgba(0, 0, 0, 0.15);
}
.main-view {
box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.3), 0 8px 12px 6px rgba(0, 0, 0, 0.15);
}
</style>

View file

@ -1,67 +1,64 @@
<script lang="ts">
import { goto } from '$app/navigation';
import AlbumSelectionModal from '$lib/components/shared-components/album-selection-modal.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import {
NotificationType,
notificationController
} from '$lib/components/shared-components/notification/notification';
import { addAssetsToAlbum } from '$lib/utils/asset-utils';
import { AlbumResponseDto, api } from '@api';
import { getMenuContext } from '../asset-select-context-menu.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import { goto } from '$app/navigation';
import AlbumSelectionModal from '$lib/components/shared-components/album-selection-modal.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { addAssetsToAlbum } from '$lib/utils/asset-utils';
import { AlbumResponseDto, api } from '@api';
import { getMenuContext } from '../asset-select-context-menu.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
export let shared = false;
let showAlbumPicker = false;
export let shared = false;
let showAlbumPicker = false;
const { getAssets, clearSelect } = getAssetControlContext();
const closeMenu = getMenuContext();
const { getAssets, clearSelect } = getAssetControlContext();
const closeMenu = getMenuContext();
const handleHideAlbumPicker = () => {
showAlbumPicker = false;
closeMenu();
};
const handleHideAlbumPicker = () => {
showAlbumPicker = false;
closeMenu();
};
const handleAddToNewAlbum = (event: CustomEvent) => {
showAlbumPicker = false;
const handleAddToNewAlbum = (event: CustomEvent) => {
showAlbumPicker = false;
const { albumName }: { albumName: string } = event.detail;
const assetIds = Array.from(getAssets()).map((asset) => asset.id);
api.albumApi.createAlbum({ createAlbumDto: { albumName, assetIds } }).then((response) => {
const { id, albumName } = response.data;
const { albumName }: { albumName: string } = event.detail;
const assetIds = Array.from(getAssets()).map((asset) => asset.id);
api.albumApi.createAlbum({ createAlbumDto: { albumName, assetIds } }).then((response) => {
const { id, albumName } = response.data;
notificationController.show({
message: `Added ${assetIds.length} to ${albumName}`,
type: NotificationType.Info
});
notificationController.show({
message: `Added ${assetIds.length} to ${albumName}`,
type: NotificationType.Info,
});
clearSelect();
clearSelect();
goto('/albums/' + id);
});
};
goto('/albums/' + id);
});
};
const handleAddToAlbum = async (event: CustomEvent<{ album: AlbumResponseDto }>) => {
showAlbumPicker = false;
const album = event.detail.album;
const handleAddToAlbum = async (event: CustomEvent<{ album: AlbumResponseDto }>) => {
showAlbumPicker = false;
const album = event.detail.album;
const assetIds = Array.from(getAssets()).map((asset) => asset.id);
const assetIds = Array.from(getAssets()).map((asset) => asset.id);
addAssetsToAlbum(album.id, assetIds).then(clearSelect);
};
addAssetsToAlbum(album.id, assetIds).then(clearSelect);
};
</script>
<MenuOption
on:click={() => (showAlbumPicker = true)}
text={shared ? 'Add to Shared Album' : 'Add to Album'}
/>
<MenuOption on:click={() => (showAlbumPicker = true)} text={shared ? 'Add to Shared Album' : 'Add to Album'} />
{#if showAlbumPicker}
<AlbumSelectionModal
{shared}
on:newAlbum={handleAddToNewAlbum}
on:newSharedAlbum={handleAddToNewAlbum}
on:album={handleAddToAlbum}
on:close={handleHideAlbumPicker}
/>
<AlbumSelectionModal
{shared}
on:newAlbum={handleAddToNewAlbum}
on:newSharedAlbum={handleAddToNewAlbum}
on:album={handleAddToAlbum}
on:close={handleHideAlbumPicker}
/>
{/if}

View file

@ -1,51 +1,51 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import {
NotificationType,
notificationController
} from '$lib/components/shared-components/notification/notification';
import { api } from '@api';
import ArchiveArrowDownOutline from 'svelte-material-icons/ArchiveArrowDownOutline.svelte';
import ArchiveArrowUpOutline from 'svelte-material-icons/ArchiveArrowUpOutline.svelte';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { OnAssetArchive, getAssetControlContext } from '../asset-select-control-bar.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { api } from '@api';
import ArchiveArrowDownOutline from 'svelte-material-icons/ArchiveArrowDownOutline.svelte';
import ArchiveArrowUpOutline from 'svelte-material-icons/ArchiveArrowUpOutline.svelte';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { OnAssetArchive, getAssetControlContext } from '../asset-select-control-bar.svelte';
export let onAssetArchive: OnAssetArchive = (asset, isArchived) => {
asset.isArchived = isArchived;
};
export let onAssetArchive: OnAssetArchive = (asset, isArchived) => {
asset.isArchived = isArchived;
};
export let menuItem = false;
export let unarchive = false;
export let menuItem = false;
export let unarchive = false;
$: text = unarchive ? 'Unarchive' : 'Archive';
$: logo = unarchive ? ArchiveArrowUpOutline : ArchiveArrowDownOutline;
$: text = unarchive ? 'Unarchive' : 'Archive';
$: logo = unarchive ? ArchiveArrowUpOutline : ArchiveArrowDownOutline;
const { getAssets, clearSelect } = getAssetControlContext();
const { getAssets, clearSelect } = getAssetControlContext();
const handleArchive = async () => {
const isArchived = !unarchive;
let cnt = 0;
const handleArchive = async () => {
const isArchived = !unarchive;
let cnt = 0;
for (const asset of getAssets()) {
if (asset.isArchived !== isArchived) {
api.assetApi.updateAsset({ id: asset.id, updateAssetDto: { isArchived } });
for (const asset of getAssets()) {
if (asset.isArchived !== isArchived) {
api.assetApi.updateAsset({ id: asset.id, updateAssetDto: { isArchived } });
onAssetArchive(asset, isArchived);
cnt = cnt + 1;
}
}
onAssetArchive(asset, isArchived);
cnt = cnt + 1;
}
}
notificationController.show({
message: `${isArchived ? 'Archived' : 'Unarchived'} ${cnt}`,
type: NotificationType.Info
});
notificationController.show({
message: `${isArchived ? 'Archived' : 'Unarchived'} ${cnt}`,
type: NotificationType.Info,
});
clearSelect();
};
clearSelect();
};
</script>
{#if menuItem}
<MenuOption {text} on:click={handleArchive} />
<MenuOption {text} on:click={handleArchive} />
{:else}
<CircleIconButton title={text} {logo} on:click={handleArchive} />
<CircleIconButton title={text} {logo} on:click={handleArchive} />
{/if}

View file

@ -1,23 +1,23 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
import { SharedLinkType } from '@api';
import ShareVariantOutline from 'svelte-material-icons/ShareVariantOutline.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
import { SharedLinkType } from '@api';
import ShareVariantOutline from 'svelte-material-icons/ShareVariantOutline.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
let showModal = false;
const { getAssets, clearSelect } = getAssetControlContext();
let showModal = false;
const { getAssets, clearSelect } = getAssetControlContext();
</script>
<CircleIconButton title="Share" logo={ShareVariantOutline} on:click={() => (showModal = true)} />
{#if showModal}
<CreateSharedLinkModal
sharedAssets={Array.from(getAssets())}
shareType={SharedLinkType.Individual}
on:close={() => {
showModal = false;
clearSelect();
}}
/>
<CreateSharedLinkModal
sharedAssets={Array.from(getAssets())}
shareType={SharedLinkType.Individual}
on:close={() => {
showModal = false;
clearSelect();
}}
/>
{/if}

View file

@ -1,74 +1,70 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import {
NotificationType,
notificationController
} from '$lib/components/shared-components/notification/notification';
import { api } from '@api';
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
import { OnAssetDelete, getAssetControlContext } from '../asset-select-control-bar.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import { handleError } from '../../../utils/handle-error';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { api } from '@api';
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
import { OnAssetDelete, getAssetControlContext } from '../asset-select-control-bar.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import { handleError } from '../../../utils/handle-error';
export let onAssetDelete: OnAssetDelete;
const { getAssets, clearSelect } = getAssetControlContext();
export let onAssetDelete: OnAssetDelete;
const { getAssets, clearSelect } = getAssetControlContext();
let isShowConfirmation = false;
let isShowConfirmation = false;
const handleDelete = async () => {
try {
let count = 0;
const handleDelete = async () => {
try {
let count = 0;
const { data: deletedAssets } = await api.assetApi.deleteAsset({
deleteAssetDto: {
ids: Array.from(getAssets()).map((a) => a.id)
}
});
const { data: deletedAssets } = await api.assetApi.deleteAsset({
deleteAssetDto: {
ids: Array.from(getAssets()).map((a) => a.id),
},
});
for (const asset of deletedAssets) {
if (asset.status === 'SUCCESS') {
onAssetDelete(asset.id);
count++;
}
}
for (const asset of deletedAssets) {
if (asset.status === 'SUCCESS') {
onAssetDelete(asset.id);
count++;
}
}
notificationController.show({
message: `Deleted ${count}`,
type: NotificationType.Info
});
notificationController.show({
message: `Deleted ${count}`,
type: NotificationType.Info,
});
clearSelect();
} catch (e) {
handleError(e, 'Error deleting assets');
} finally {
isShowConfirmation = false;
}
};
clearSelect();
} catch (e) {
handleError(e, 'Error deleting assets');
} finally {
isShowConfirmation = false;
}
};
</script>
<CircleIconButton
title="Delete"
logo={DeleteOutline}
on:click={() => (isShowConfirmation = true)}
/>
<CircleIconButton title="Delete" logo={DeleteOutline} on:click={() => (isShowConfirmation = true)} />
{#if isShowConfirmation}
<ConfirmDialogue
title="Delete Asset{getAssets().size > 1 ? 's' : ''}"
confirmText="Delete"
on:confirm={handleDelete}
on:cancel={() => (isShowConfirmation = false)}
>
<svelte:fragment slot="prompt">
<p>
Are you sure you want to delete
{#if getAssets().size > 1}
these <b>{getAssets().size}</b> assets? This will also remove them from their album(s).
{:else}
this asset? This will also remove it from its album(s).
{/if}
</p>
<p><b>You cannot undo this action!</b></p>
</svelte:fragment>
</ConfirmDialogue>
<ConfirmDialogue
title="Delete Asset{getAssets().size > 1 ? 's' : ''}"
confirmText="Delete"
on:confirm={handleDelete}
on:cancel={() => (isShowConfirmation = false)}
>
<svelte:fragment slot="prompt">
<p>
Are you sure you want to delete
{#if getAssets().size > 1}
these <b>{getAssets().size}</b> assets? This will also remove them from their album(s).
{:else}
this asset? This will also remove it from its album(s).
{/if}
</p>
<p><b>You cannot undo this action!</b></p>
</svelte:fragment>
</ConfirmDialogue>
{/if}

View file

@ -1,35 +1,30 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { downloadArchive, downloadFile } from '$lib/utils/asset-utils';
import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { downloadArchive, downloadFile } from '$lib/utils/asset-utils';
import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
export let filename = 'immich.zip';
export let sharedLinkKey: string | undefined = undefined;
export let menuItem = false;
export let filename = 'immich.zip';
export let sharedLinkKey: string | undefined = undefined;
export let menuItem = false;
const { getAssets, clearSelect } = getAssetControlContext();
const { getAssets, clearSelect } = getAssetControlContext();
const handleDownloadFiles = async () => {
const assets = Array.from(getAssets());
if (assets.length === 1) {
await downloadFile(assets[0], sharedLinkKey);
clearSelect();
return;
}
const handleDownloadFiles = async () => {
const assets = Array.from(getAssets());
if (assets.length === 1) {
await downloadFile(assets[0], sharedLinkKey);
clearSelect();
return;
}
await downloadArchive(
filename,
{ assetIds: assets.map((asset) => asset.id) },
clearSelect,
sharedLinkKey
);
};
await downloadArchive(filename, { assetIds: assets.map((asset) => asset.id) }, clearSelect, sharedLinkKey);
};
</script>
{#if menuItem}
<MenuOption text="Download" on:click={handleDownloadFiles} />
<MenuOption text="Download" on:click={handleDownloadFiles} />
{:else}
<CircleIconButton title="Download" logo={CloudDownloadOutline} on:click={handleDownloadFiles} />
<CircleIconButton title="Download" logo={CloudDownloadOutline} on:click={handleDownloadFiles} />
{/if}

View file

@ -1,50 +1,50 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import {
NotificationType,
notificationController
} from '$lib/components/shared-components/notification/notification';
import { api } from '@api';
import HeartMinusOutline from 'svelte-material-icons/HeartMinusOutline.svelte';
import HeartOutline from 'svelte-material-icons/HeartOutline.svelte';
import { OnAssetFavorite, getAssetControlContext } from '../asset-select-control-bar.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { api } from '@api';
import HeartMinusOutline from 'svelte-material-icons/HeartMinusOutline.svelte';
import HeartOutline from 'svelte-material-icons/HeartOutline.svelte';
import { OnAssetFavorite, getAssetControlContext } from '../asset-select-control-bar.svelte';
export let onAssetFavorite: OnAssetFavorite = (asset, isFavorite) => {
asset.isFavorite = isFavorite;
};
export let onAssetFavorite: OnAssetFavorite = (asset, isFavorite) => {
asset.isFavorite = isFavorite;
};
export let menuItem = false;
export let removeFavorite: boolean;
export let menuItem = false;
export let removeFavorite: boolean;
$: text = removeFavorite ? 'Remove from Favorites' : 'Favorite';
$: logo = removeFavorite ? HeartMinusOutline : HeartOutline;
$: text = removeFavorite ? 'Remove from Favorites' : 'Favorite';
$: logo = removeFavorite ? HeartMinusOutline : HeartOutline;
const { getAssets, clearSelect } = getAssetControlContext();
const { getAssets, clearSelect } = getAssetControlContext();
const handleFavorite = () => {
const isFavorite = !removeFavorite;
const handleFavorite = () => {
const isFavorite = !removeFavorite;
let cnt = 0;
for (const asset of getAssets()) {
if (asset.isFavorite !== isFavorite) {
api.assetApi.updateAsset({ id: asset.id, updateAssetDto: { isFavorite } });
onAssetFavorite(asset, isFavorite);
cnt = cnt + 1;
}
}
let cnt = 0;
for (const asset of getAssets()) {
if (asset.isFavorite !== isFavorite) {
api.assetApi.updateAsset({ id: asset.id, updateAssetDto: { isFavorite } });
onAssetFavorite(asset, isFavorite);
cnt = cnt + 1;
}
}
notificationController.show({
message: isFavorite ? `Added ${cnt} to favorites` : `Removed ${cnt} from favorites`,
type: NotificationType.Info
});
notificationController.show({
message: isFavorite ? `Added ${cnt} to favorites` : `Removed ${cnt} from favorites`,
type: NotificationType.Info,
});
clearSelect();
};
clearSelect();
};
</script>
{#if menuItem}
<MenuOption {text} on:click={handleFavorite} />
<MenuOption {text} on:click={handleFavorite} />
{:else}
<CircleIconButton title={text} {logo} on:click={handleFavorite} />
<CircleIconButton title={text} {logo} on:click={handleFavorite} />
{/if}

View file

@ -1,66 +1,62 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import {
NotificationType,
notificationController
} from '$lib/components/shared-components/notification/notification';
import { AlbumResponseDto, api } from '@api';
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { AlbumResponseDto, api } from '@api';
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
export let album: AlbumResponseDto;
export let album: AlbumResponseDto;
const { getAssets, clearSelect } = getAssetControlContext();
const { getAssets, clearSelect } = getAssetControlContext();
let isShowConfirmation = false;
let isShowConfirmation = false;
const removeFromAlbum = async () => {
try {
const { data } = await api.albumApi.removeAssetFromAlbum({
id: album.id,
removeAssetsDto: {
assetIds: Array.from(getAssets()).map((a) => a.id)
}
});
const removeFromAlbum = async () => {
try {
const { data } = await api.albumApi.removeAssetFromAlbum({
id: album.id,
removeAssetsDto: {
assetIds: Array.from(getAssets()).map((a) => a.id),
},
});
album = data;
clearSelect();
} catch (e) {
console.error('Error [album-viewer] [removeAssetFromAlbum]', e);
notificationController.show({
type: NotificationType.Error,
message: 'Error removing assets from album, check console for more details'
});
} finally {
isShowConfirmation = false;
}
};
album = data;
clearSelect();
} catch (e) {
console.error('Error [album-viewer] [removeAssetFromAlbum]', e);
notificationController.show({
type: NotificationType.Error,
message: 'Error removing assets from album, check console for more details',
});
} finally {
isShowConfirmation = false;
}
};
</script>
<CircleIconButton
title="Remove from album"
on:click={() => (isShowConfirmation = true)}
logo={DeleteOutline}
/>
<CircleIconButton title="Remove from album" on:click={() => (isShowConfirmation = true)} logo={DeleteOutline} />
{#if isShowConfirmation}
<ConfirmDialogue
title="Remove Asset{getAssets().size > 1 ? 's' : ''}"
confirmText="Remove"
on:confirm={removeFromAlbum}
on:cancel={() => (isShowConfirmation = false)}
>
<svelte:fragment slot="prompt">
<p>
Are you sure you want to remove
{#if getAssets().size > 1}
these <b>{getAssets().size}</b> assets
{:else}
this asset
{/if}
from the album?
</p>
</svelte:fragment>
</ConfirmDialogue>
<ConfirmDialogue
title="Remove Asset{getAssets().size > 1 ? 's' : ''}"
confirmText="Remove"
on:confirm={removeFromAlbum}
on:cancel={() => (isShowConfirmation = false)}
>
<svelte:fragment slot="prompt">
<p>
Are you sure you want to remove
{#if getAssets().size > 1}
these <b>{getAssets().size}</b> assets
{:else}
this asset
{/if}
from the album?
</p>
</svelte:fragment>
</ConfirmDialogue>
{/if}

View file

@ -1,65 +1,58 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { SharedLinkResponseDto, api } from '@api';
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
import ConfirmDialogue from '../../shared-components/confirm-dialogue.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import {
NotificationType,
notificationController
} from '../../shared-components/notification/notification';
import { handleError } from '../../../utils/handle-error';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { SharedLinkResponseDto, api } from '@api';
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
import ConfirmDialogue from '../../shared-components/confirm-dialogue.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import { NotificationType, notificationController } from '../../shared-components/notification/notification';
import { handleError } from '../../../utils/handle-error';
export let sharedLink: SharedLinkResponseDto;
export let sharedLink: SharedLinkResponseDto;
let removing = false;
let removing = false;
const { getAssets, clearSelect } = getAssetControlContext();
const { getAssets, clearSelect } = getAssetControlContext();
const handleRemove = async () => {
try {
const { data: results } = await api.sharedLinkApi.removeSharedLinkAssets({
id: sharedLink.id,
assetIdsDto: {
assetIds: Array.from(getAssets()).map((asset) => asset.id)
},
key: sharedLink.key
});
const handleRemove = async () => {
try {
const { data: results } = await api.sharedLinkApi.removeSharedLinkAssets({
id: sharedLink.id,
assetIdsDto: {
assetIds: Array.from(getAssets()).map((asset) => asset.id),
},
key: sharedLink.key,
});
for (const result of results) {
if (!result.success) {
continue;
}
for (const result of results) {
if (!result.success) {
continue;
}
sharedLink.assets = sharedLink.assets.filter((asset) => asset.id !== result.assetId);
}
sharedLink.assets = sharedLink.assets.filter((asset) => asset.id !== result.assetId);
}
const count = results.filter((item) => item.success).length;
const count = results.filter((item) => item.success).length;
notificationController.show({
type: NotificationType.Info,
message: `Removed ${count} assets`
});
notificationController.show({
type: NotificationType.Info,
message: `Removed ${count} assets`,
});
clearSelect();
} catch (error) {
handleError(error, 'Unable to remove assets from shared link');
}
};
clearSelect();
} catch (error) {
handleError(error, 'Unable to remove assets from shared link');
}
};
</script>
<CircleIconButton
title="Remove from shared link"
on:click={() => (removing = true)}
logo={DeleteOutline}
/>
<CircleIconButton title="Remove from shared link" on:click={() => (removing = true)} logo={DeleteOutline} />
{#if removing}
<ConfirmDialogue
title="Remove Assets?"
prompt="Are you sure you want to remove {getAssets().size} asset(s) from this shared link?"
confirmText="Remove"
on:confirm={() => handleRemove()}
on:cancel={() => (removing = false)}
/>
<ConfirmDialogue
title="Remove Assets?"
prompt="Are you sure you want to remove {getAssets().size} asset(s) from this shared link?"
confirmText="Remove"
on:confirm={() => handleRemove()}
on:cancel={() => (removing = false)}
/>
{/if}

View file

@ -1,41 +1,38 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import SelectAll from 'svelte-material-icons/SelectAll.svelte';
import TimerSand from 'svelte-material-icons/TimerSand.svelte';
import { assetInteractionStore } from '$lib/stores/asset-interaction.store';
import { assetGridState, assetStore } from '$lib/stores/assets.store';
import { handleError } from '../../../utils/handle-error';
import { AssetGridState, BucketPosition } from '$lib/models/asset-grid-state';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import SelectAll from 'svelte-material-icons/SelectAll.svelte';
import TimerSand from 'svelte-material-icons/TimerSand.svelte';
import { assetInteractionStore } from '$lib/stores/asset-interaction.store';
import { assetGridState, assetStore } from '$lib/stores/assets.store';
import { handleError } from '../../../utils/handle-error';
import { AssetGridState, BucketPosition } from '$lib/models/asset-grid-state';
let selecting = false;
let selecting = false;
const handleSelectAll = async () => {
try {
selecting = true;
let _assetGridState = new AssetGridState();
assetGridState.subscribe((state) => {
_assetGridState = state;
});
const handleSelectAll = async () => {
try {
selecting = true;
let _assetGridState = new AssetGridState();
assetGridState.subscribe((state) => {
_assetGridState = state;
});
for (let i = 0; i < _assetGridState.buckets.length; i++) {
await assetStore.getAssetsByBucket(
_assetGridState.buckets[i].bucketDate,
BucketPosition.Unknown
);
for (const asset of _assetGridState.buckets[i].assets) {
assetInteractionStore.addAssetToMultiselectGroup(asset);
}
}
selecting = false;
} catch (e) {
handleError(e, 'Error selecting all assets');
}
};
for (let i = 0; i < _assetGridState.buckets.length; i++) {
await assetStore.getAssetsByBucket(_assetGridState.buckets[i].bucketDate, BucketPosition.Unknown);
for (const asset of _assetGridState.buckets[i].assets) {
assetInteractionStore.addAssetToMultiselectGroup(asset);
}
}
selecting = false;
} catch (e) {
handleError(e, 'Error selecting all assets');
}
};
</script>
{#if selecting}
<CircleIconButton title="Delete" logo={TimerSand} />
<CircleIconButton title="Delete" logo={TimerSand} />
{/if}
{#if !selecting}
<CircleIconButton title="Select all" logo={SelectAll} on:click={handleSelectAll} />
<CircleIconButton title="Select all" logo={SelectAll} on:click={handleSelectAll} />
{/if}

View file

@ -1,244 +1,236 @@
<script lang="ts">
import {
assetInteractionStore,
assetsInAlbumStoreState,
isMultiSelectStoreState,
selectedAssets,
selectedGroup
} from '$lib/stores/asset-interaction.store';
import { assetStore } from '$lib/stores/assets.store';
import { locale } from '$lib/stores/preferences.store';
import type { AssetResponseDto } from '@api';
import justifiedLayout from 'justified-layout';
import lodash from 'lodash-es';
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
import CircleOutline from 'svelte-material-icons/CircleOutline.svelte';
import { fly } from 'svelte/transition';
import { getAssetRatio } from '$lib/utils/asset-utils';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import { createEventDispatcher } from 'svelte';
import {
assetInteractionStore,
assetsInAlbumStoreState,
isMultiSelectStoreState,
selectedAssets,
selectedGroup,
} from '$lib/stores/asset-interaction.store';
import { assetStore } from '$lib/stores/assets.store';
import { locale } from '$lib/stores/preferences.store';
import type { AssetResponseDto } from '@api';
import justifiedLayout from 'justified-layout';
import lodash from 'lodash-es';
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
import CircleOutline from 'svelte-material-icons/CircleOutline.svelte';
import { fly } from 'svelte/transition';
import { getAssetRatio } from '$lib/utils/asset-utils';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import { createEventDispatcher } from 'svelte';
export let assets: AssetResponseDto[];
export let bucketDate: string;
export let bucketHeight: number;
export let isAlbumSelectionMode = false;
export let viewportWidth: number;
export let assets: AssetResponseDto[];
export let bucketDate: string;
export let bucketHeight: number;
export let isAlbumSelectionMode = false;
export let viewportWidth: number;
const groupDateFormat: Intl.DateTimeFormatOptions = {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric'
};
const groupDateFormat: Intl.DateTimeFormatOptions = {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
};
const dispatch = createEventDispatcher();
const dispatch = createEventDispatcher();
let isMouseOverGroup = false;
let actualBucketHeight: number;
let hoveredDateGroup = '';
let isMouseOverGroup = false;
let actualBucketHeight: number;
let hoveredDateGroup = '';
interface LayoutBox {
top: number;
left: number;
width: number;
}
interface LayoutBox {
top: number;
left: number;
width: number;
}
$: assetsGroupByDate = lodash
.chain(assets)
.groupBy((a) => new Date(a.fileCreatedAt).toLocaleDateString($locale, groupDateFormat))
.sortBy((group) => assets.indexOf(group[0]))
.value();
$: assetsGroupByDate = lodash
.chain(assets)
.groupBy((a) => new Date(a.fileCreatedAt).toLocaleDateString($locale, groupDateFormat))
.sortBy((group) => assets.indexOf(group[0]))
.value();
$: geometry = (() => {
const geometry = [];
for (let group of assetsGroupByDate) {
const justifiedLayoutResult = justifiedLayout(group.map(getAssetRatio), {
boxSpacing: 2,
containerWidth: Math.floor(viewportWidth),
containerPadding: 0,
targetRowHeightTolerance: 0.15,
targetRowHeight: 235
});
geometry.push({
...justifiedLayoutResult,
containerWidth: calculateWidth(justifiedLayoutResult.boxes)
});
}
return geometry;
})();
$: geometry = (() => {
const geometry = [];
for (let group of assetsGroupByDate) {
const justifiedLayoutResult = justifiedLayout(group.map(getAssetRatio), {
boxSpacing: 2,
containerWidth: Math.floor(viewportWidth),
containerPadding: 0,
targetRowHeightTolerance: 0.15,
targetRowHeight: 235,
});
geometry.push({
...justifiedLayoutResult,
containerWidth: calculateWidth(justifiedLayoutResult.boxes),
});
}
return geometry;
})();
$: {
if (actualBucketHeight && actualBucketHeight != 0 && actualBucketHeight != bucketHeight) {
const heightDelta = assetStore.updateBucketHeight(bucketDate, actualBucketHeight);
if (heightDelta !== 0) {
scrollTimeline(heightDelta);
}
}
}
$: {
if (actualBucketHeight && actualBucketHeight != 0 && actualBucketHeight != bucketHeight) {
const heightDelta = assetStore.updateBucketHeight(bucketDate, actualBucketHeight);
if (heightDelta !== 0) {
scrollTimeline(heightDelta);
}
}
}
function scrollTimeline(heightDelta: number) {
dispatch('shift', {
heightDelta
});
}
function scrollTimeline(heightDelta: number) {
dispatch('shift', {
heightDelta,
});
}
const calculateWidth = (boxes: LayoutBox[]): number => {
let width = 0;
for (const box of boxes) {
if (box.top < 100) {
width = box.left + box.width;
}
}
const calculateWidth = (boxes: LayoutBox[]): number => {
let width = 0;
for (const box of boxes) {
if (box.top < 100) {
width = box.left + box.width;
}
}
return width;
};
return width;
};
const assetClickHandler = (
asset: AssetResponseDto,
assetsInDateGroup: AssetResponseDto[],
dateGroupTitle: string
) => {
if (isAlbumSelectionMode) {
assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle);
return;
}
const assetClickHandler = (
asset: AssetResponseDto,
assetsInDateGroup: AssetResponseDto[],
dateGroupTitle: string,
) => {
if (isAlbumSelectionMode) {
assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle);
return;
}
if ($isMultiSelectStoreState) {
assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle);
} else {
assetInteractionStore.setViewingAsset(asset);
}
};
if ($isMultiSelectStoreState) {
assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle);
} else {
assetInteractionStore.setViewingAsset(asset);
}
};
const selectAssetGroupHandler = (
selectAssetGroupHandler: AssetResponseDto[],
dateGroupTitle: string
) => {
if ($selectedGroup.has(dateGroupTitle)) {
assetInteractionStore.removeGroupFromMultiselectGroup(dateGroupTitle);
selectAssetGroupHandler.forEach((asset) => {
assetInteractionStore.removeAssetFromMultiselectGroup(asset);
});
} else {
assetInteractionStore.addGroupToMultiselectGroup(dateGroupTitle);
selectAssetGroupHandler.forEach((asset) => {
assetInteractionStore.addAssetToMultiselectGroup(asset);
});
}
};
const selectAssetGroupHandler = (selectAssetGroupHandler: AssetResponseDto[], dateGroupTitle: string) => {
if ($selectedGroup.has(dateGroupTitle)) {
assetInteractionStore.removeGroupFromMultiselectGroup(dateGroupTitle);
selectAssetGroupHandler.forEach((asset) => {
assetInteractionStore.removeAssetFromMultiselectGroup(asset);
});
} else {
assetInteractionStore.addGroupToMultiselectGroup(dateGroupTitle);
selectAssetGroupHandler.forEach((asset) => {
assetInteractionStore.addAssetToMultiselectGroup(asset);
});
}
};
const assetSelectHandler = (
asset: AssetResponseDto,
assetsInDateGroup: AssetResponseDto[],
dateGroupTitle: string
) => {
if ($selectedAssets.has(asset)) {
assetInteractionStore.removeAssetFromMultiselectGroup(asset);
} else {
assetInteractionStore.addAssetToMultiselectGroup(asset);
}
const assetSelectHandler = (
asset: AssetResponseDto,
assetsInDateGroup: AssetResponseDto[],
dateGroupTitle: string,
) => {
if ($selectedAssets.has(asset)) {
assetInteractionStore.removeAssetFromMultiselectGroup(asset);
} else {
assetInteractionStore.addAssetToMultiselectGroup(asset);
}
// Check if all assets are selected in a group to toggle the group selection's icon
let selectedAssetsInGroupCount = 0;
assetsInDateGroup.forEach((asset) => {
if ($selectedAssets.has(asset)) {
selectedAssetsInGroupCount++;
}
});
// Check if all assets are selected in a group to toggle the group selection's icon
let selectedAssetsInGroupCount = 0;
assetsInDateGroup.forEach((asset) => {
if ($selectedAssets.has(asset)) {
selectedAssetsInGroupCount++;
}
});
// if all assets are selected in a group, add the group to selected group
if (selectedAssetsInGroupCount == assetsInDateGroup.length) {
assetInteractionStore.addGroupToMultiselectGroup(dateGroupTitle);
} else {
assetInteractionStore.removeGroupFromMultiselectGroup(dateGroupTitle);
}
};
// if all assets are selected in a group, add the group to selected group
if (selectedAssetsInGroupCount == assetsInDateGroup.length) {
assetInteractionStore.addGroupToMultiselectGroup(dateGroupTitle);
} else {
assetInteractionStore.removeGroupFromMultiselectGroup(dateGroupTitle);
}
};
const assetMouseEventHandler = (dateGroupTitle: string) => {
// Show multi select icon on hover on date group
hoveredDateGroup = dateGroupTitle;
};
const assetMouseEventHandler = (dateGroupTitle: string) => {
// Show multi select icon on hover on date group
hoveredDateGroup = dateGroupTitle;
};
</script>
<section
id="asset-group-by-date"
class="flex flex-wrap gap-x-12"
bind:clientHeight={actualBucketHeight}
bind:clientWidth={viewportWidth}
id="asset-group-by-date"
class="flex flex-wrap gap-x-12"
bind:clientHeight={actualBucketHeight}
bind:clientWidth={viewportWidth}
>
{#each assetsGroupByDate as assetsInDateGroup, groupIndex (assetsInDateGroup[0].id)}
{@const dateGroupTitle = new Date(assetsInDateGroup[0].fileCreatedAt).toLocaleDateString(
$locale,
groupDateFormat
)}
<!-- Asset Group By Date -->
{#each assetsGroupByDate as assetsInDateGroup, groupIndex (assetsInDateGroup[0].id)}
{@const dateGroupTitle = new Date(assetsInDateGroup[0].fileCreatedAt).toLocaleDateString($locale, groupDateFormat)}
<!-- Asset Group By Date -->
<div
class="flex flex-col mt-5"
on:mouseenter={() => {
isMouseOverGroup = true;
assetMouseEventHandler(dateGroupTitle);
}}
on:mouseleave={() => (isMouseOverGroup = false)}
>
<!-- Date group title -->
<p
class="font-medium text-xs md:text-sm text-immich-fg dark:text-immich-dark-fg mb-2 flex place-items-center h-6"
style="width: {geometry[groupIndex].containerWidth}px"
>
{#if (hoveredDateGroup == dateGroupTitle && isMouseOverGroup) || $selectedGroup.has(dateGroupTitle)}
<div
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
class="inline-block px-2 hover:cursor-pointer"
on:click={() => selectAssetGroupHandler(assetsInDateGroup, dateGroupTitle)}
on:keydown={() => selectAssetGroupHandler(assetsInDateGroup, dateGroupTitle)}
>
{#if $selectedGroup.has(dateGroupTitle)}
<CheckCircle size="24" color="#4250af" />
{:else}
<CircleOutline size="24" color="#757575" />
{/if}
</div>
{/if}
<div
class="flex flex-col mt-5"
on:mouseenter={() => {
isMouseOverGroup = true;
assetMouseEventHandler(dateGroupTitle);
}}
on:mouseleave={() => (isMouseOverGroup = false)}
>
<!-- Date group title -->
<p
class="font-medium text-xs md:text-sm text-immich-fg dark:text-immich-dark-fg mb-2 flex place-items-center h-6"
style="width: {geometry[groupIndex].containerWidth}px"
>
{#if (hoveredDateGroup == dateGroupTitle && isMouseOverGroup) || $selectedGroup.has(dateGroupTitle)}
<div
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
class="inline-block px-2 hover:cursor-pointer"
on:click={() => selectAssetGroupHandler(assetsInDateGroup, dateGroupTitle)}
on:keydown={() => selectAssetGroupHandler(assetsInDateGroup, dateGroupTitle)}
>
{#if $selectedGroup.has(dateGroupTitle)}
<CheckCircle size="24" color="#4250af" />
{:else}
<CircleOutline size="24" color="#757575" />
{/if}
</div>
{/if}
<span class="truncate" title={dateGroupTitle}>
{dateGroupTitle}
</span>
</p>
<span class="truncate" title={dateGroupTitle}>
{dateGroupTitle}
</span>
</p>
<!-- Image grid -->
<div
class="relative"
style="height: {geometry[groupIndex].containerHeight}px;width: {geometry[groupIndex]
.containerWidth}px"
>
{#each assetsInDateGroup as asset, index (asset.id)}
{@const box = geometry[groupIndex].boxes[index]}
<div
class="absolute"
style="width: {box.width}px; height: {box.height}px; top: {box.top}px; left: {box.left}px"
>
<Thumbnail
{asset}
{groupIndex}
on:click={() => assetClickHandler(asset, assetsInDateGroup, dateGroupTitle)}
on:select={() => assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle)}
on:mouse-event={() => assetMouseEventHandler(dateGroupTitle)}
selected={$selectedAssets.has(asset) ||
$assetsInAlbumStoreState.findIndex((a) => a.id == asset.id) != -1}
disabled={$assetsInAlbumStoreState.findIndex((a) => a.id == asset.id) != -1}
thumbnailWidth={box.width}
thumbnailHeight={box.height}
/>
</div>
{/each}
</div>
</div>
{/each}
<!-- Image grid -->
<div
class="relative"
style="height: {geometry[groupIndex].containerHeight}px;width: {geometry[groupIndex].containerWidth}px"
>
{#each assetsInDateGroup as asset, index (asset.id)}
{@const box = geometry[groupIndex].boxes[index]}
<div
class="absolute"
style="width: {box.width}px; height: {box.height}px; top: {box.top}px; left: {box.left}px"
>
<Thumbnail
{asset}
{groupIndex}
on:click={() => assetClickHandler(asset, assetsInDateGroup, dateGroupTitle)}
on:select={() => assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle)}
on:mouse-event={() => assetMouseEventHandler(dateGroupTitle)}
selected={$selectedAssets.has(asset) || $assetsInAlbumStoreState.findIndex((a) => a.id == asset.id) != -1}
disabled={$assetsInAlbumStoreState.findIndex((a) => a.id == asset.id) != -1}
thumbnailWidth={box.width}
thumbnailHeight={box.height}
/>
</div>
{/each}
</div>
</div>
{/each}
</section>
<style>
#asset-group-by-date {
contain: layout;
}
#asset-group-by-date {
contain: layout;
}
</style>

View file

@ -1,190 +1,190 @@
<script lang="ts">
import {
assetInteractionStore,
isViewingAssetStoreState,
viewingAssetStoreState
} from '$lib/stores/asset-interaction.store';
import { assetGridState, assetStore, loadingBucketState } from '$lib/stores/assets.store';
import type { UserResponseDto } from '@api';
import { AssetCountByTimeBucketResponseDto, AssetResponseDto, TimeGroupEnum, api } from '@api';
import { onDestroy, onMount } from 'svelte';
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
import Portal from '../shared-components/portal/portal.svelte';
import Scrollbar, {
OnScrollbarClickDetail,
OnScrollbarDragDetail
} from '../shared-components/scrollbar/scrollbar.svelte';
import AssetDateGroup from './asset-date-group.svelte';
import { BucketPosition } from '$lib/models/asset-grid-state';
import MemoryLane from './memory-lane.svelte';
import {
assetInteractionStore,
isViewingAssetStoreState,
viewingAssetStoreState,
} from '$lib/stores/asset-interaction.store';
import { assetGridState, assetStore, loadingBucketState } from '$lib/stores/assets.store';
import type { UserResponseDto } from '@api';
import { AssetCountByTimeBucketResponseDto, AssetResponseDto, TimeGroupEnum, api } from '@api';
import { onDestroy, onMount } from 'svelte';
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
import Portal from '../shared-components/portal/portal.svelte';
import Scrollbar, {
OnScrollbarClickDetail,
OnScrollbarDragDetail,
} from '../shared-components/scrollbar/scrollbar.svelte';
import AssetDateGroup from './asset-date-group.svelte';
import { BucketPosition } from '$lib/models/asset-grid-state';
import MemoryLane from './memory-lane.svelte';
export let user: UserResponseDto | undefined = undefined;
export let isAlbumSelectionMode = false;
export let showMemoryLane = false;
export let user: UserResponseDto | undefined = undefined;
export let isAlbumSelectionMode = false;
export let showMemoryLane = false;
let viewportHeight = 0;
let viewportWidth = 0;
let assetGridElement: HTMLElement;
let bucketInfo: AssetCountByTimeBucketResponseDto;
let viewportHeight = 0;
let viewportWidth = 0;
let assetGridElement: HTMLElement;
let bucketInfo: AssetCountByTimeBucketResponseDto;
onMount(async () => {
const { data: assetCountByTimebucket } = await api.assetApi.getAssetCountByTimeBucket({
getAssetCountByTimeBucketDto: {
timeGroup: TimeGroupEnum.Month,
userId: user?.id,
withoutThumbs: true
}
});
onMount(async () => {
const { data: assetCountByTimebucket } = await api.assetApi.getAssetCountByTimeBucket({
getAssetCountByTimeBucketDto: {
timeGroup: TimeGroupEnum.Month,
userId: user?.id,
withoutThumbs: true,
},
});
bucketInfo = assetCountByTimebucket;
bucketInfo = assetCountByTimebucket;
assetStore.setInitialState(viewportHeight, viewportWidth, assetCountByTimebucket, user?.id);
assetStore.setInitialState(viewportHeight, viewportWidth, assetCountByTimebucket, user?.id);
// Get asset bucket if bucket height is smaller than viewport height
let bucketsToFetchInitially: string[] = [];
let initialBucketsHeight = 0;
$assetGridState.buckets.every((bucket) => {
if (initialBucketsHeight < viewportHeight) {
initialBucketsHeight += bucket.bucketHeight;
bucketsToFetchInitially.push(bucket.bucketDate);
return true;
} else {
return false;
}
});
// Get asset bucket if bucket height is smaller than viewport height
let bucketsToFetchInitially: string[] = [];
let initialBucketsHeight = 0;
$assetGridState.buckets.every((bucket) => {
if (initialBucketsHeight < viewportHeight) {
initialBucketsHeight += bucket.bucketHeight;
bucketsToFetchInitially.push(bucket.bucketDate);
return true;
} else {
return false;
}
});
bucketsToFetchInitially.forEach((bucketDate) => {
assetStore.getAssetsByBucket(bucketDate, BucketPosition.Visible);
});
});
bucketsToFetchInitially.forEach((bucketDate) => {
assetStore.getAssetsByBucket(bucketDate, BucketPosition.Visible);
});
});
onDestroy(() => {
assetStore.setInitialState(0, 0, { totalCount: 0, buckets: [] }, undefined);
});
onDestroy(() => {
assetStore.setInitialState(0, 0, { totalCount: 0, buckets: [] }, undefined);
});
function intersectedHandler(event: CustomEvent) {
const el = event.detail.container as HTMLElement;
const target = el.firstChild as HTMLElement;
if (target) {
const bucketDate = target.id.split('_')[1];
assetStore.getAssetsByBucket(bucketDate, event.detail.position);
}
}
function intersectedHandler(event: CustomEvent) {
const el = event.detail.container as HTMLElement;
const target = el.firstChild as HTMLElement;
if (target) {
const bucketDate = target.id.split('_')[1];
assetStore.getAssetsByBucket(bucketDate, event.detail.position);
}
}
function handleScrollTimeline(event: CustomEvent) {
assetGridElement.scrollBy(0, event.detail.heightDelta);
}
function handleScrollTimeline(event: CustomEvent) {
assetGridElement.scrollBy(0, event.detail.heightDelta);
}
const navigateToPreviousAsset = () => {
assetInteractionStore.navigateAsset('previous');
};
const navigateToPreviousAsset = () => {
assetInteractionStore.navigateAsset('previous');
};
const navigateToNextAsset = () => {
assetInteractionStore.navigateAsset('next');
};
const navigateToNextAsset = () => {
assetInteractionStore.navigateAsset('next');
};
let lastScrollPosition = 0;
let animationTick = false;
let lastScrollPosition = 0;
let animationTick = false;
const handleTimelineScroll = () => {
if (!animationTick) {
window.requestAnimationFrame(() => {
lastScrollPosition = assetGridElement?.scrollTop;
animationTick = false;
});
const handleTimelineScroll = () => {
if (!animationTick) {
window.requestAnimationFrame(() => {
lastScrollPosition = assetGridElement?.scrollTop;
animationTick = false;
});
animationTick = true;
}
};
animationTick = true;
}
};
const handleScrollbarClick = (e: OnScrollbarClickDetail) => {
assetGridElement.scrollTop = e.scrollTo;
};
const handleScrollbarClick = (e: OnScrollbarClickDetail) => {
assetGridElement.scrollTop = e.scrollTo;
};
const handleScrollbarDrag = (e: OnScrollbarDragDetail) => {
assetGridElement.scrollTop = e.scrollTo;
};
const handleScrollbarDrag = (e: OnScrollbarDragDetail) => {
assetGridElement.scrollTop = e.scrollTo;
};
const handleArchiveSuccess = (e: CustomEvent) => {
const asset = e.detail as AssetResponseDto;
navigateToNextAsset();
assetStore.removeAsset(asset.id);
};
const handleArchiveSuccess = (e: CustomEvent) => {
const asset = e.detail as AssetResponseDto;
navigateToNextAsset();
assetStore.removeAsset(asset.id);
};
</script>
{#if bucketInfo && viewportHeight && $assetGridState.timelineHeight > viewportHeight}
<Scrollbar
scrollbarHeight={viewportHeight}
scrollTop={lastScrollPosition}
on:onscrollbarclick={(e) => handleScrollbarClick(e.detail)}
on:onscrollbardrag={(e) => handleScrollbarDrag(e.detail)}
/>
<Scrollbar
scrollbarHeight={viewportHeight}
scrollTop={lastScrollPosition}
on:onscrollbarclick={(e) => handleScrollbarClick(e.detail)}
on:onscrollbardrag={(e) => handleScrollbarDrag(e.detail)}
/>
{/if}
<!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar -->
<section
id="asset-grid"
class="overflow-y-auto ml-4 mb-4 mr-[60px] scrollbar-hidden"
bind:clientHeight={viewportHeight}
bind:clientWidth={viewportWidth}
bind:this={assetGridElement}
on:scroll={handleTimelineScroll}
id="asset-grid"
class="overflow-y-auto ml-4 mb-4 mr-[60px] scrollbar-hidden"
bind:clientHeight={viewportHeight}
bind:clientWidth={viewportWidth}
bind:this={assetGridElement}
on:scroll={handleTimelineScroll}
>
{#if assetGridElement}
{#if showMemoryLane}
<MemoryLane />
{/if}
<section id="virtual-timeline" style:height={$assetGridState.timelineHeight + 'px'}>
{#each $assetGridState.buckets as bucket, bucketIndex (bucketIndex)}
<IntersectionObserver
on:intersected={intersectedHandler}
on:hidden={async () => {
// If bucket is hidden and in loading state, cancel the request
if ($loadingBucketState[bucket.bucketDate]) {
await assetStore.cancelBucketRequest(bucket.cancelToken, bucket.bucketDate);
}
}}
let:intersecting
top={750}
bottom={750}
root={assetGridElement}
>
<div id={'bucket_' + bucket.bucketDate} style:height={bucket.bucketHeight + 'px'}>
{#if intersecting}
<AssetDateGroup
{isAlbumSelectionMode}
on:shift={handleScrollTimeline}
assets={bucket.assets}
bucketDate={bucket.bucketDate}
bucketHeight={bucket.bucketHeight}
{viewportWidth}
/>
{/if}
</div>
</IntersectionObserver>
{/each}
</section>
{/if}
{#if assetGridElement}
{#if showMemoryLane}
<MemoryLane />
{/if}
<section id="virtual-timeline" style:height={$assetGridState.timelineHeight + 'px'}>
{#each $assetGridState.buckets as bucket, bucketIndex (bucketIndex)}
<IntersectionObserver
on:intersected={intersectedHandler}
on:hidden={async () => {
// If bucket is hidden and in loading state, cancel the request
if ($loadingBucketState[bucket.bucketDate]) {
await assetStore.cancelBucketRequest(bucket.cancelToken, bucket.bucketDate);
}
}}
let:intersecting
top={750}
bottom={750}
root={assetGridElement}
>
<div id={'bucket_' + bucket.bucketDate} style:height={bucket.bucketHeight + 'px'}>
{#if intersecting}
<AssetDateGroup
{isAlbumSelectionMode}
on:shift={handleScrollTimeline}
assets={bucket.assets}
bucketDate={bucket.bucketDate}
bucketHeight={bucket.bucketHeight}
{viewportWidth}
/>
{/if}
</div>
</IntersectionObserver>
{/each}
</section>
{/if}
</section>
<Portal target="body">
{#if $isViewingAssetStoreState}
<AssetViewer
asset={$viewingAssetStoreState}
on:navigate-previous={navigateToPreviousAsset}
on:navigate-next={navigateToNextAsset}
on:close={() => {
assetInteractionStore.setIsViewingAsset(false);
}}
on:archived={handleArchiveSuccess}
/>
{/if}
{#if $isViewingAssetStoreState}
<AssetViewer
asset={$viewingAssetStoreState}
on:navigate-previous={navigateToPreviousAsset}
on:navigate-next={navigateToNextAsset}
on:close={() => {
assetInteractionStore.setIsViewingAsset(false);
}}
on:archived={handleArchiveSuccess}
/>
{/if}
</Portal>
<style>
#asset-grid {
contain: layout;
scrollbar-width: none;
}
#asset-grid {
contain: layout;
scrollbar-width: none;
}
</style>

View file

@ -1,35 +1,35 @@
<script lang="ts" context="module">
import { createContext } from '$lib/utils/context';
import { createContext } from '$lib/utils/context';
const { get: getMenuContext, set: setContext } = createContext<() => void>();
export { getMenuContext };
const { get: getMenuContext, set: setContext } = createContext<() => void>();
export { getMenuContext };
</script>
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
import type Icon from 'svelte-material-icons/AbTesting.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
import type Icon from 'svelte-material-icons/AbTesting.svelte';
export let icon: typeof Icon;
export let title: string;
export let icon: typeof Icon;
export let title: string;
let showContextMenu = false;
let contextMenuPosition = { x: 0, y: 0 };
let showContextMenu = false;
let contextMenuPosition = { x: 0, y: 0 };
const handleShowMenu = ({ x, y }: MouseEvent) => {
contextMenuPosition = { x, y };
showContextMenu = !showContextMenu;
};
const handleShowMenu = ({ x, y }: MouseEvent) => {
contextMenuPosition = { x, y };
showContextMenu = !showContextMenu;
};
setContext(() => (showContextMenu = false));
setContext(() => (showContextMenu = false));
</script>
<CircleIconButton {title} logo={icon} on:click={handleShowMenu} />
{#if showContextMenu}
<ContextMenu {...contextMenuPosition} on:outclick={() => (showContextMenu = false)}>
<div class="flex flex-col rounded-lg">
<slot />
</div>
</ContextMenu>
<ContextMenu {...contextMenuPosition} on:outclick={() => (showContextMenu = false)}>
<div class="flex flex-col rounded-lg">
<slot />
</div>
</ContextMenu>
{/if}

View file

@ -1,39 +1,35 @@
<script lang="ts" context="module">
import { createContext } from '$lib/utils/context';
import { createContext } from '$lib/utils/context';
export type OnAssetDelete = (assetId: string) => void;
export type OnAssetArchive = (asset: AssetResponseDto, archived: boolean) => void;
export type OnAssetFavorite = (asset: AssetResponseDto, favorite: boolean) => void;
export type OnAssetDelete = (assetId: string) => void;
export type OnAssetArchive = (asset: AssetResponseDto, archived: boolean) => void;
export type OnAssetFavorite = (asset: AssetResponseDto, favorite: boolean) => void;
export interface AssetControlContext {
// Wrap assets in a function, because context isn't reactive.
getAssets: () => Set<AssetResponseDto>;
clearSelect: () => void;
}
export interface AssetControlContext {
// Wrap assets in a function, because context isn't reactive.
getAssets: () => Set<AssetResponseDto>;
clearSelect: () => void;
}
const { get: getAssetControlContext, set: setContext } = createContext<AssetControlContext>();
export { getAssetControlContext };
const { get: getAssetControlContext, set: setContext } = createContext<AssetControlContext>();
export { getAssetControlContext };
</script>
<script lang="ts">
import { locale } from '$lib/stores/preferences.store';
import type { AssetResponseDto } from '@api';
import Close from 'svelte-material-icons/Close.svelte';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import { locale } from '$lib/stores/preferences.store';
import type { AssetResponseDto } from '@api';
import Close from 'svelte-material-icons/Close.svelte';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
export let assets: Set<AssetResponseDto>;
export let clearSelect: () => void;
export let assets: Set<AssetResponseDto>;
export let clearSelect: () => void;
setContext({ getAssets: () => assets, clearSelect });
setContext({ getAssets: () => assets, clearSelect });
</script>
<ControlAppBar
on:close-button-click={clearSelect}
backIcon={Close}
tailwindClasses="bg-white shadow-md"
>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary" slot="leading">
Selected {assets.size.toLocaleString($locale)}
</p>
<slot slot="trailing" />
<ControlAppBar on:close-button-click={clearSelect} backIcon={Close} tailwindClasses="bg-white shadow-md">
<p class="font-medium text-immich-primary dark:text-immich-dark-primary" slot="leading">
Selected {assets.size.toLocaleString($locale)}
</p>
<slot slot="trailing" />
</ControlAppBar>

View file

@ -1,95 +1,95 @@
<script lang="ts">
import { onMount } from 'svelte';
import { DateTime } from 'luxon';
import { api } from '@api';
import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
import { memoryStore } from '$lib/stores/memory.store';
import { goto } from '$app/navigation';
import { fade } from 'svelte/transition';
import { onMount } from 'svelte';
import { DateTime } from 'luxon';
import { api } from '@api';
import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
import { memoryStore } from '$lib/stores/memory.store';
import { goto } from '$app/navigation';
import { fade } from 'svelte/transition';
$: shouldRender = $memoryStore?.length > 0;
$: shouldRender = $memoryStore?.length > 0;
onMount(async () => {
const { data } = await api.assetApi.getMemoryLane({
timestamp: DateTime.local().startOf('day').toISO() || ''
});
$memoryStore = data;
});
onMount(async () => {
const { data } = await api.assetApi.getMemoryLane({
timestamp: DateTime.local().startOf('day').toISO() || '',
});
$memoryStore = data;
});
let memoryLaneElement: HTMLElement;
let offsetWidth = 0;
let innerWidth = 0;
let memoryLaneElement: HTMLElement;
let offsetWidth = 0;
let innerWidth = 0;
let scrollLeftPosition = 0;
let scrollLeftPosition = 0;
const onScroll = () => (scrollLeftPosition = memoryLaneElement?.scrollLeft);
const onScroll = () => (scrollLeftPosition = memoryLaneElement?.scrollLeft);
$: canScrollLeft = scrollLeftPosition > 0;
$: canScrollRight = Math.ceil(scrollLeftPosition) < innerWidth - offsetWidth;
$: canScrollLeft = scrollLeftPosition > 0;
$: canScrollRight = Math.ceil(scrollLeftPosition) < innerWidth - offsetWidth;
const scrollBy = 400;
const scrollLeft = () => memoryLaneElement.scrollBy({ left: -scrollBy, behavior: 'smooth' });
const scrollRight = () => memoryLaneElement.scrollBy({ left: scrollBy, behavior: 'smooth' });
const scrollBy = 400;
const scrollLeft = () => memoryLaneElement.scrollBy({ left: -scrollBy, behavior: 'smooth' });
const scrollRight = () => memoryLaneElement.scrollBy({ left: scrollBy, behavior: 'smooth' });
</script>
{#if shouldRender}
<section
id="memory-lane"
bind:this={memoryLaneElement}
class="relative overflow-x-hidden whitespace-nowrap mt-5 transition-all"
bind:offsetWidth
on:scroll={onScroll}
>
{#if canScrollLeft || canScrollRight}
<div class="sticky left-0 z-20">
{#if canScrollLeft}
<div class="absolute left-4 top-[6rem] z-20" transition:fade={{ duration: 200 }}>
<button
class="rounded-full opacity-50 hover:opacity-100 p-2 border border-gray-500 bg-gray-100 text-gray-500"
on:click={scrollLeft}
>
<ChevronLeft size="36" /></button
>
</div>
{/if}
{#if canScrollRight}
<div class="absolute right-4 top-[6rem] z-20" transition:fade={{ duration: 200 }}>
<button
class="rounded-full opacity-50 hover:opacity-100 p-2 border border-gray-500 bg-gray-100 text-gray-500"
on:click={scrollRight}
>
<ChevronRight size="36" /></button
>
</div>
{/if}
</div>
{/if}
<section
id="memory-lane"
bind:this={memoryLaneElement}
class="relative overflow-x-hidden whitespace-nowrap mt-5 transition-all"
bind:offsetWidth
on:scroll={onScroll}
>
{#if canScrollLeft || canScrollRight}
<div class="sticky left-0 z-20">
{#if canScrollLeft}
<div class="absolute left-4 top-[6rem] z-20" transition:fade={{ duration: 200 }}>
<button
class="rounded-full opacity-50 hover:opacity-100 p-2 border border-gray-500 bg-gray-100 text-gray-500"
on:click={scrollLeft}
>
<ChevronLeft size="36" /></button
>
</div>
{/if}
{#if canScrollRight}
<div class="absolute right-4 top-[6rem] z-20" transition:fade={{ duration: 200 }}>
<button
class="rounded-full opacity-50 hover:opacity-100 p-2 border border-gray-500 bg-gray-100 text-gray-500"
on:click={scrollRight}
>
<ChevronRight size="36" /></button
>
</div>
{/if}
</div>
{/if}
<div class="inline-block" bind:offsetWidth={innerWidth}>
{#each $memoryStore as memory, i (memory.title)}
<button
class="memory-card relative inline-block mr-8 rounded-xl aspect-video h-[215px]"
on:click={() => goto(`/memory?memory=${i}`)}
>
<img
class="rounded-xl h-full w-full object-cover"
src={api.getAssetThumbnailUrl(memory.assets[0].id, 'JPEG')}
alt={memory.title}
draggable="false"
/>
<p class="absolute bottom-2 left-4 text-lg text-white z-10">{memory.title}</p>
<div
class="absolute top-0 left-0 w-full h-full rounded-xl bg-gradient-to-t from-black/40 via-transparent to-transparent z-0 hover:bg-black/20 transition-all"
/>
</button>
{/each}
</div>
</section>
<div class="inline-block" bind:offsetWidth={innerWidth}>
{#each $memoryStore as memory, i (memory.title)}
<button
class="memory-card relative inline-block mr-8 rounded-xl aspect-video h-[215px]"
on:click={() => goto(`/memory?memory=${i}`)}
>
<img
class="rounded-xl h-full w-full object-cover"
src={api.getAssetThumbnailUrl(memory.assets[0].id, 'JPEG')}
alt={memory.title}
draggable="false"
/>
<p class="absolute bottom-2 left-4 text-lg text-white z-10">{memory.title}</p>
<div
class="absolute top-0 left-0 w-full h-full rounded-xl bg-gradient-to-t from-black/40 via-transparent to-transparent z-0 hover:bg-black/20 transition-all"
/>
</button>
{/each}
</div>
</section>
{/if}
<style>
.memory-card {
box-shadow: rgba(60, 64, 67, 0.3) 0px 1px 2px 0px, rgba(60, 64, 67, 0.15) 0px 1px 3px 1px;
}
.memory-card {
box-shadow: rgba(60, 64, 67, 0.3) 0px 1px 2px 0px, rgba(60, 64, 67, 0.15) 0px 1px 3px 1px;
}
</style>

View file

@ -1,133 +1,116 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import { downloadArchive } from '$lib/utils/asset-utils';
import { api, AssetResponseDto, SharedLinkResponseDto } from '@api';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
import FileImagePlusOutline from 'svelte-material-icons/FileImagePlusOutline.svelte';
import FolderDownloadOutline from 'svelte-material-icons/FolderDownloadOutline.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import DownloadAction from '../photos-page/actions/download-action.svelte';
import RemoveFromSharedLink from '../photos-page/actions/remove-from-shared-link.svelte';
import AssetSelectControlBar from '../photos-page/asset-select-control-bar.svelte';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import GalleryViewer from '../shared-components/gallery-viewer/gallery-viewer.svelte';
import SelectAll from 'svelte-material-icons/SelectAll.svelte';
import ImmichLogo from '../shared-components/immich-logo.svelte';
import { goto } from '$app/navigation';
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import { downloadArchive } from '$lib/utils/asset-utils';
import { api, AssetResponseDto, SharedLinkResponseDto } from '@api';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
import FileImagePlusOutline from 'svelte-material-icons/FileImagePlusOutline.svelte';
import FolderDownloadOutline from 'svelte-material-icons/FolderDownloadOutline.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import DownloadAction from '../photos-page/actions/download-action.svelte';
import RemoveFromSharedLink from '../photos-page/actions/remove-from-shared-link.svelte';
import AssetSelectControlBar from '../photos-page/asset-select-control-bar.svelte';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import GalleryViewer from '../shared-components/gallery-viewer/gallery-viewer.svelte';
import SelectAll from 'svelte-material-icons/SelectAll.svelte';
import ImmichLogo from '../shared-components/immich-logo.svelte';
import {
notificationController,
NotificationType
} from '../shared-components/notification/notification';
import { handleError } from '../../utils/handle-error';
import { notificationController, NotificationType } from '../shared-components/notification/notification';
import { handleError } from '../../utils/handle-error';
export let sharedLink: SharedLinkResponseDto;
export let isOwned: boolean;
export let sharedLink: SharedLinkResponseDto;
export let isOwned: boolean;
let selectedAssets: Set<AssetResponseDto> = new Set();
let selectedAssets: Set<AssetResponseDto> = new Set();
$: assets = sharedLink.assets;
$: isMultiSelectionMode = selectedAssets.size > 0;
$: assets = sharedLink.assets;
$: isMultiSelectionMode = selectedAssets.size > 0;
dragAndDropFilesStore.subscribe((value) => {
if (value.isDragging && value.files.length > 0) {
handleUploadAssets(value.files);
dragAndDropFilesStore.set({ isDragging: false, files: [] });
}
});
dragAndDropFilesStore.subscribe((value) => {
if (value.isDragging && value.files.length > 0) {
handleUploadAssets(value.files);
dragAndDropFilesStore.set({ isDragging: false, files: [] });
}
});
const downloadAssets = async () => {
await downloadArchive(
`immich-shared.zip`,
{ assetIds: assets.map((asset) => asset.id) },
undefined,
sharedLink.key
);
};
const downloadAssets = async () => {
await downloadArchive(
`immich-shared.zip`,
{ assetIds: assets.map((asset) => asset.id) },
undefined,
sharedLink.key,
);
};
const handleUploadAssets = async (files: File[] = []) => {
try {
let results: (string | undefined)[] = [];
if (!files || files.length === 0 || !Array.isArray(files)) {
results = await openFileUploadDialog(undefined, sharedLink.key);
} else {
results = await fileUploadHandler(files, undefined, sharedLink.key);
}
const { data } = await api.sharedLinkApi.addSharedLinkAssets({
id: sharedLink.id,
assetIdsDto: {
assetIds: results.filter((id) => !!id) as string[]
},
key: sharedLink.key
});
const handleUploadAssets = async (files: File[] = []) => {
try {
let results: (string | undefined)[] = [];
if (!files || files.length === 0 || !Array.isArray(files)) {
results = await openFileUploadDialog(undefined, sharedLink.key);
} else {
results = await fileUploadHandler(files, undefined, sharedLink.key);
}
const { data } = await api.sharedLinkApi.addSharedLinkAssets({
id: sharedLink.id,
assetIdsDto: {
assetIds: results.filter((id) => !!id) as string[],
},
key: sharedLink.key,
});
const added = data.filter((item) => item.success).length;
const added = data.filter((item) => item.success).length;
notificationController.show({
message: `Added ${added} assets`,
type: NotificationType.Info
});
} catch (e) {
handleError(e, 'Unable to add assets to shared link');
}
};
notificationController.show({
message: `Added ${added} assets`,
type: NotificationType.Info,
});
} catch (e) {
handleError(e, 'Unable to add assets to shared link');
}
};
const handleSelectAll = () => {
selectedAssets = new Set(assets);
};
const handleSelectAll = () => {
selectedAssets = new Set(assets);
};
</script>
<section class="bg-immich-bg dark:bg-immich-dark-bg">
{#if isMultiSelectionMode}
<AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}>
<CircleIconButton title="Select all" logo={SelectAll} on:click={handleSelectAll} />
{#if sharedLink?.allowDownload}
<DownloadAction filename="immich-shared.zip" sharedLinkKey={sharedLink.key} />
{/if}
{#if isOwned}
<RemoveFromSharedLink bind:sharedLink />
{/if}
</AssetSelectControlBar>
{:else}
<ControlAppBar
on:close-button-click={() => goto('/photos')}
backIcon={ArrowLeft}
showBackButton={false}
>
<svelte:fragment slot="leading">
<a
data-sveltekit-preload-data="hover"
class="flex gap-2 place-items-center hover:cursor-pointer ml-6"
href="https://immich.app"
>
<ImmichLogo height="30" width="30" />
<h1 class="font-immich-title text-lg text-immich-primary dark:text-immich-dark-primary">
IMMICH
</h1>
</a>
</svelte:fragment>
{#if isMultiSelectionMode}
<AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}>
<CircleIconButton title="Select all" logo={SelectAll} on:click={handleSelectAll} />
{#if sharedLink?.allowDownload}
<DownloadAction filename="immich-shared.zip" sharedLinkKey={sharedLink.key} />
{/if}
{#if isOwned}
<RemoveFromSharedLink bind:sharedLink />
{/if}
</AssetSelectControlBar>
{:else}
<ControlAppBar on:close-button-click={() => goto('/photos')} backIcon={ArrowLeft} showBackButton={false}>
<svelte:fragment slot="leading">
<a
data-sveltekit-preload-data="hover"
class="flex gap-2 place-items-center hover:cursor-pointer ml-6"
href="https://immich.app"
>
<ImmichLogo height="30" width="30" />
<h1 class="font-immich-title text-lg text-immich-primary dark:text-immich-dark-primary">IMMICH</h1>
</a>
</svelte:fragment>
<svelte:fragment slot="trailing">
{#if sharedLink?.allowUpload}
<CircleIconButton
title="Add Photos"
on:click={() => handleUploadAssets()}
logo={FileImagePlusOutline}
/>
{/if}
<svelte:fragment slot="trailing">
{#if sharedLink?.allowUpload}
<CircleIconButton title="Add Photos" on:click={() => handleUploadAssets()} logo={FileImagePlusOutline} />
{/if}
{#if sharedLink?.allowDownload}
<CircleIconButton
title="Download"
on:click={downloadAssets}
logo={FolderDownloadOutline}
/>
{/if}
</svelte:fragment>
</ControlAppBar>
{/if}
<section class="flex flex-col my-[160px] px-6 sm:px-12 md:px-24 lg:px-40">
<GalleryViewer {assets} {sharedLink} bind:selectedAssets viewFrom="shared-link-page" />
</section>
{#if sharedLink?.allowDownload}
<CircleIconButton title="Download" on:click={downloadAssets} logo={FolderDownloadOutline} />
{/if}
</svelte:fragment>
</ControlAppBar>
{/if}
<section class="flex flex-col my-[160px] px-6 sm:px-12 md:px-24 lg:px-40">
<GalleryViewer {assets} {sharedLink} bind:selectedAssets viewFrom="shared-link-page" />
</section>
</section>

View file

@ -1,121 +1,117 @@
<script lang="ts">
import { AlbumResponseDto, api } from '@api';
import { createEventDispatcher, onMount } from 'svelte';
import Plus from 'svelte-material-icons/Plus.svelte';
import BaseModal from './base-modal.svelte';
import AlbumListItem from '../asset-viewer/album-list-item.svelte';
import { AlbumResponseDto, api } from '@api';
import { createEventDispatcher, onMount } from 'svelte';
import Plus from 'svelte-material-icons/Plus.svelte';
import BaseModal from './base-modal.svelte';
import AlbumListItem from '../asset-viewer/album-list-item.svelte';
let albums: AlbumResponseDto[] = [];
let recentAlbums: AlbumResponseDto[] = [];
let filteredAlbums: AlbumResponseDto[] = [];
let loading = true;
let search = '';
let albums: AlbumResponseDto[] = [];
let recentAlbums: AlbumResponseDto[] = [];
let filteredAlbums: AlbumResponseDto[] = [];
let loading = true;
let search = '';
const dispatch = createEventDispatcher();
const dispatch = createEventDispatcher();
export let shared: boolean;
export let shared: boolean;
onMount(async () => {
const { data } = await api.albumApi.getAllAlbums({ shared: shared || undefined });
albums = data;
onMount(async () => {
const { data } = await api.albumApi.getAllAlbums({ shared: shared || undefined });
albums = data;
recentAlbums = albums
.sort((a, b) => (new Date(a.createdAt) > new Date(b.createdAt) ? -1 : 1))
.slice(0, 3);
recentAlbums = albums.sort((a, b) => (new Date(a.createdAt) > new Date(b.createdAt) ? -1 : 1)).slice(0, 3);
loading = false;
});
loading = false;
});
$: {
if (search.length > 0 && albums.length > 0) {
filteredAlbums = albums.filter((album) => {
return album.albumName.toLowerCase().includes(search.toLowerCase());
});
} else {
filteredAlbums = albums;
}
}
$: {
if (search.length > 0 && albums.length > 0) {
filteredAlbums = albums.filter((album) => {
return album.albumName.toLowerCase().includes(search.toLowerCase());
});
} else {
filteredAlbums = albums;
}
}
const handleSelect = (album: AlbumResponseDto) => {
dispatch('album', { album });
};
const handleSelect = (album: AlbumResponseDto) => {
dispatch('album', { album });
};
const handleNew = () => {
if (shared) {
dispatch('newAlbum', { albumName: search.length > 0 ? search : 'Untitled' });
} else {
dispatch('newSharedAlbum', { albumName: search.length > 0 ? search : 'Untitled' });
}
};
const handleNew = () => {
if (shared) {
dispatch('newAlbum', { albumName: search.length > 0 ? search : 'Untitled' });
} else {
dispatch('newSharedAlbum', { albumName: search.length > 0 ? search : 'Untitled' });
}
};
</script>
<BaseModal on:close={() => dispatch('close')}>
<svelte:fragment slot="title">
<span class="flex gap-2 place-items-center">
<p class="font-medium">
Add to {#if shared}Shared {/if} Album
</p>
</span>
</svelte:fragment>
<svelte:fragment slot="title">
<span class="flex gap-2 place-items-center">
<p class="font-medium">
Add to {#if shared}Shared {/if} Album
</p>
</span>
</svelte:fragment>
<div class="max-h-[400px] flex flex-col mb-2">
{#if loading}
{#each { length: 3 } as _}
<div class="animate-pulse flex gap-4 px-6 py-2">
<div class="h-12 w-12 bg-slate-200 rounded-xl" />
<div class="flex flex-col items-start justify-center gap-2">
<span class="animate-pulse w-36 h-4 bg-slate-200" />
<div class="flex animate-pulse gap-1">
<span class="w-8 h-3 bg-slate-200" />
<span class="w-20 h-3 bg-slate-200" />
</div>
</div>
</div>
{/each}
{:else}
<!-- svelte-ignore a11y-autofocus -->
<input
class="px-6 py-2 text-2xl border-b-4 bg-immich-bg border-immich-bg focus:border-immich-primary dark:bg-immich-dark-gray dark:border-immich-dark-gray dark:focus:border-immich-dark-primary"
placeholder="Search"
autofocus
bind:value={search}
/>
<div class="overflow-y-auto immich-scrollbar">
<button
on:click={handleNew}
class="w-full flex gap-4 px-6 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors items-center"
>
<div class="h-12 w-12 flex justify-center items-center">
<Plus size="30" />
</div>
<p class="">
New {#if shared}Shared {/if}Album {#if search.length > 0}<b>{search}</b>{/if}
</p>
</button>
{#if filteredAlbums.length > 0}
{#if !shared && search.length === 0}
<p class="text-xs px-5 py-3">RECENT</p>
{#each recentAlbums as album (album.id)}
<AlbumListItem variant="simple" {album} on:album={() => handleSelect(album)} />
{/each}
{/if}
<div class="max-h-[400px] flex flex-col mb-2">
{#if loading}
{#each { length: 3 } as _}
<div class="animate-pulse flex gap-4 px-6 py-2">
<div class="h-12 w-12 bg-slate-200 rounded-xl" />
<div class="flex flex-col items-start justify-center gap-2">
<span class="animate-pulse w-36 h-4 bg-slate-200" />
<div class="flex animate-pulse gap-1">
<span class="w-8 h-3 bg-slate-200" />
<span class="w-20 h-3 bg-slate-200" />
</div>
</div>
</div>
{/each}
{:else}
<!-- svelte-ignore a11y-autofocus -->
<input
class="px-6 py-2 text-2xl border-b-4 bg-immich-bg border-immich-bg focus:border-immich-primary dark:bg-immich-dark-gray dark:border-immich-dark-gray dark:focus:border-immich-dark-primary"
placeholder="Search"
autofocus
bind:value={search}
/>
<div class="overflow-y-auto immich-scrollbar">
<button
on:click={handleNew}
class="w-full flex gap-4 px-6 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors items-center"
>
<div class="h-12 w-12 flex justify-center items-center">
<Plus size="30" />
</div>
<p class="">
New {#if shared}Shared {/if}Album {#if search.length > 0}<b>{search}</b>{/if}
</p>
</button>
{#if filteredAlbums.length > 0}
{#if !shared && search.length === 0}
<p class="text-xs px-5 py-3">RECENT</p>
{#each recentAlbums as album (album.id)}
<AlbumListItem variant="simple" {album} on:album={() => handleSelect(album)} />
{/each}
{/if}
{#if !shared}
<p class="text-xs px-5 py-3">
{#if search.length === 0}ALL {/if}ALBUMS
</p>
{/if}
{#each filteredAlbums as album (album.id)}
<AlbumListItem {album} searchQuery={search} on:album={() => handleSelect(album)} />
{/each}
{:else if albums.length > 0}
<p class="text-sm px-5 py-1">
It looks like you do not have any albums with this name yet.
</p>
{:else}
<p class="text-sm px-5 py-1">It looks like you do not have any albums yet.</p>
{/if}
</div>
{/if}
</div>
{#if !shared}
<p class="text-xs px-5 py-3">
{#if search.length === 0}ALL {/if}ALBUMS
</p>
{/if}
{#each filteredAlbums as album (album.id)}
<AlbumListItem {album} searchQuery={search} on:album={() => handleSelect(album)} />
{/each}
{:else if albums.length > 0}
<p class="text-sm px-5 py-1">It looks like you do not have any albums with this name yet.</p>
{:else}
<p class="text-sm px-5 py-1">It looks like you do not have any albums yet.</p>
{/if}
</div>
{/if}
</div>
</BaseModal>

View file

@ -1,184 +1,184 @@
<script lang="ts">
import appleSplash20482732 from '$lib/assets/apple/apple-splash-2048-2732.png';
import appleSplash27322048 from '$lib/assets/apple/apple-splash-2732-2048.png';
import appleSplash16682388 from '$lib/assets/apple/apple-splash-1668-2388.png';
import appleSplash23881668 from '$lib/assets/apple/apple-splash-2388-1668.png';
import appleSplash15362048 from '$lib/assets/apple/apple-splash-1536-2048.png';
import appleSplash20481536 from '$lib/assets/apple/apple-splash-2048-1536.png';
import appleSplash16682224 from '$lib/assets/apple/apple-splash-1668-2224.png';
import appleSplash22241668 from '$lib/assets/apple/apple-splash-2224-1668.png';
import appleSplash16202160 from '$lib/assets/apple/apple-splash-1620-2160.png';
import appleSplash21601620 from '$lib/assets/apple/apple-splash-2160-1620.png';
import appleSplash12902796 from '$lib/assets/apple/apple-splash-1290-2796.png';
import appleSplash27961290 from '$lib/assets/apple/apple-splash-2796-1290.png';
import appleSplash11792556 from '$lib/assets/apple/apple-splash-1179-2556.png';
import appleSplash25561179 from '$lib/assets/apple/apple-splash-2556-1179.png';
import appleSplash12842778 from '$lib/assets/apple/apple-splash-1284-2778.png';
import appleSplash27781284 from '$lib/assets/apple/apple-splash-2778-1284.png';
import appleSplash11702532 from '$lib/assets/apple/apple-splash-1170-2532.png';
import appleSplash25321170 from '$lib/assets/apple/apple-splash-2532-1170.png';
import appleSplash11252436 from '$lib/assets/apple/apple-splash-1125-2436.png';
import appleSplash24361125 from '$lib/assets/apple/apple-splash-2436-1125.png';
import appleSplash12422688 from '$lib/assets/apple/apple-splash-1242-2688.png';
import appleSplash26881242 from '$lib/assets/apple/apple-splash-2688-1242.png';
import appleSplash8281792 from '$lib/assets/apple/apple-splash-828-1792.png';
import appleSplash1792828 from '$lib/assets/apple/apple-splash-1792-828.png';
import appleSplash12422208 from '$lib/assets/apple/apple-splash-1242-2208.png';
import appleSplash22081242 from '$lib/assets/apple/apple-splash-2208-1242.png';
import appleSplash7501334 from '$lib/assets/apple/apple-splash-750-1334.png';
import appleSplash1334750 from '$lib/assets/apple/apple-splash-1334-750.png';
import appleSplash6401136 from '$lib/assets/apple/apple-splash-640-1136.png';
import appleSplash1136640 from '$lib/assets/apple/apple-splash-1136-640.png';
import appleSplash20482732 from '$lib/assets/apple/apple-splash-2048-2732.png';
import appleSplash27322048 from '$lib/assets/apple/apple-splash-2732-2048.png';
import appleSplash16682388 from '$lib/assets/apple/apple-splash-1668-2388.png';
import appleSplash23881668 from '$lib/assets/apple/apple-splash-2388-1668.png';
import appleSplash15362048 from '$lib/assets/apple/apple-splash-1536-2048.png';
import appleSplash20481536 from '$lib/assets/apple/apple-splash-2048-1536.png';
import appleSplash16682224 from '$lib/assets/apple/apple-splash-1668-2224.png';
import appleSplash22241668 from '$lib/assets/apple/apple-splash-2224-1668.png';
import appleSplash16202160 from '$lib/assets/apple/apple-splash-1620-2160.png';
import appleSplash21601620 from '$lib/assets/apple/apple-splash-2160-1620.png';
import appleSplash12902796 from '$lib/assets/apple/apple-splash-1290-2796.png';
import appleSplash27961290 from '$lib/assets/apple/apple-splash-2796-1290.png';
import appleSplash11792556 from '$lib/assets/apple/apple-splash-1179-2556.png';
import appleSplash25561179 from '$lib/assets/apple/apple-splash-2556-1179.png';
import appleSplash12842778 from '$lib/assets/apple/apple-splash-1284-2778.png';
import appleSplash27781284 from '$lib/assets/apple/apple-splash-2778-1284.png';
import appleSplash11702532 from '$lib/assets/apple/apple-splash-1170-2532.png';
import appleSplash25321170 from '$lib/assets/apple/apple-splash-2532-1170.png';
import appleSplash11252436 from '$lib/assets/apple/apple-splash-1125-2436.png';
import appleSplash24361125 from '$lib/assets/apple/apple-splash-2436-1125.png';
import appleSplash12422688 from '$lib/assets/apple/apple-splash-1242-2688.png';
import appleSplash26881242 from '$lib/assets/apple/apple-splash-2688-1242.png';
import appleSplash8281792 from '$lib/assets/apple/apple-splash-828-1792.png';
import appleSplash1792828 from '$lib/assets/apple/apple-splash-1792-828.png';
import appleSplash12422208 from '$lib/assets/apple/apple-splash-1242-2208.png';
import appleSplash22081242 from '$lib/assets/apple/apple-splash-2208-1242.png';
import appleSplash7501334 from '$lib/assets/apple/apple-splash-750-1334.png';
import appleSplash1334750 from '$lib/assets/apple/apple-splash-1334-750.png';
import appleSplash6401136 from '$lib/assets/apple/apple-splash-640-1136.png';
import appleSplash1136640 from '$lib/assets/apple/apple-splash-1136-640.png';
</script>
<link
rel="apple-touch-startup-image"
href={appleSplash20482732}
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
rel="apple-touch-startup-image"
href={appleSplash20482732}
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash27322048}
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
rel="apple-touch-startup-image"
href={appleSplash27322048}
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash16682388}
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
rel="apple-touch-startup-image"
href={appleSplash16682388}
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash23881668}
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
rel="apple-touch-startup-image"
href={appleSplash23881668}
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash15362048}
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
rel="apple-touch-startup-image"
href={appleSplash15362048}
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash20481536}
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
rel="apple-touch-startup-image"
href={appleSplash20481536}
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash16682224}
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
rel="apple-touch-startup-image"
href={appleSplash16682224}
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash22241668}
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
rel="apple-touch-startup-image"
href={appleSplash22241668}
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash16202160}
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
rel="apple-touch-startup-image"
href={appleSplash16202160}
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash21601620}
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
rel="apple-touch-startup-image"
href={appleSplash21601620}
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash12902796}
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
rel="apple-touch-startup-image"
href={appleSplash12902796}
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash27961290}
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
rel="apple-touch-startup-image"
href={appleSplash27961290}
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash11792556}
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
rel="apple-touch-startup-image"
href={appleSplash11792556}
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash25561179}
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
rel="apple-touch-startup-image"
href={appleSplash25561179}
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash12842778}
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
rel="apple-touch-startup-image"
href={appleSplash12842778}
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash27781284}
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
rel="apple-touch-startup-image"
href={appleSplash27781284}
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash11702532}
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
rel="apple-touch-startup-image"
href={appleSplash11702532}
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash25321170}
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
rel="apple-touch-startup-image"
href={appleSplash25321170}
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash11252436}
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
rel="apple-touch-startup-image"
href={appleSplash11252436}
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash24361125}
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
rel="apple-touch-startup-image"
href={appleSplash24361125}
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash12422688}
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
rel="apple-touch-startup-image"
href={appleSplash12422688}
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash26881242}
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
rel="apple-touch-startup-image"
href={appleSplash26881242}
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash8281792}
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
rel="apple-touch-startup-image"
href={appleSplash8281792}
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash1792828}
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
rel="apple-touch-startup-image"
href={appleSplash1792828}
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash12422208}
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
rel="apple-touch-startup-image"
href={appleSplash12422208}
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash22081242}
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
rel="apple-touch-startup-image"
href={appleSplash22081242}
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash7501334}
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
rel="apple-touch-startup-image"
href={appleSplash7501334}
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash1334750}
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
rel="apple-touch-startup-image"
href={appleSplash1334750}
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash6401136}
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
rel="apple-touch-startup-image"
href={appleSplash6401136}
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href={appleSplash1136640}
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
rel="apple-touch-startup-image"
href={appleSplash1136640}
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>

View file

@ -1,55 +1,55 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import Close from 'svelte-material-icons/Close.svelte';
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import { clickOutside } from '$lib/utils/click-outside';
import { fade } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import Close from 'svelte-material-icons/Close.svelte';
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import { clickOutside } from '$lib/utils/click-outside';
const dispatch = createEventDispatcher();
export let zIndex = 9999;
const dispatch = createEventDispatcher();
export let zIndex = 9999;
onMount(() => {
if (browser) {
const scrollTop = document.documentElement.scrollTop;
const scrollLeft = document.documentElement.scrollLeft;
window.onscroll = function () {
window.scrollTo(scrollLeft, scrollTop);
};
}
});
onMount(() => {
if (browser) {
const scrollTop = document.documentElement.scrollTop;
const scrollLeft = document.documentElement.scrollLeft;
window.onscroll = function () {
window.scrollTo(scrollLeft, scrollTop);
};
}
});
onDestroy(() => {
if (browser) {
window.onscroll = null;
}
});
onDestroy(() => {
if (browser) {
window.onscroll = null;
}
});
</script>
<div
id="immich-modal"
style:z-index={zIndex}
transition:fade={{ duration: 100, easing: quintOut }}
class="fixed top-0 left-0 w-full h-full bg-black/50 flex place-items-center place-content-center overflow-hidden"
id="immich-modal"
style:z-index={zIndex}
transition:fade={{ duration: 100, easing: quintOut }}
class="fixed top-0 left-0 w-full h-full bg-black/50 flex place-items-center place-content-center overflow-hidden"
>
<div
use:clickOutside
on:outclick={() => dispatch('close')}
class="bg-immich-bg dark:bg-immich-dark-gray dark:text-immich-dark-fg w-[450px] min-h-[200px] max-h-[600px] rounded-lg shadow-md"
>
<div class="flex justify-between place-items-center px-5 py-3">
<div>
<slot name="title">
<p>Modal Title</p>
</slot>
</div>
<div
use:clickOutside
on:outclick={() => dispatch('close')}
class="bg-immich-bg dark:bg-immich-dark-gray dark:text-immich-dark-fg w-[450px] min-h-[200px] max-h-[600px] rounded-lg shadow-md"
>
<div class="flex justify-between place-items-center px-5 py-3">
<div>
<slot name="title">
<p>Modal Title</p>
</slot>
</div>
<CircleIconButton on:click={() => dispatch('close')} logo={Close} size={'20'} />
</div>
<CircleIconButton on:click={() => dispatch('close')} logo={Close} size={'20'} />
</div>
<div class="">
<slot />
</div>
</div>
<div class="">
<slot />
</div>
</div>
</div>

View file

@ -1,62 +1,57 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import FullScreenModal from './full-screen-modal.svelte';
import Button from '../elements/buttons/button.svelte';
import type { Color } from '$lib/components/elements/buttons/button.svelte';
import { createEventDispatcher } from 'svelte';
import FullScreenModal from './full-screen-modal.svelte';
import Button from '../elements/buttons/button.svelte';
import type { Color } from '$lib/components/elements/buttons/button.svelte';
export let title = 'Confirm';
export let prompt = 'Are you sure you want to do this?';
export let confirmText = 'Confirm';
export let confirmColor: Color = 'red';
export let cancelText = 'Cancel';
export let cancelColor: Color = 'primary';
export let hideCancelButton = false;
export let title = 'Confirm';
export let prompt = 'Are you sure you want to do this?';
export let confirmText = 'Confirm';
export let confirmColor: Color = 'red';
export let cancelText = 'Cancel';
export let cancelColor: Color = 'primary';
export let hideCancelButton = false;
const dispatch = createEventDispatcher();
const dispatch = createEventDispatcher();
let isConfirmButtonDisabled = false;
let isConfirmButtonDisabled = false;
const handleCancel = () => dispatch('cancel');
const handleCancel = () => dispatch('cancel');
const handleConfirm = () => {
isConfirmButtonDisabled = true;
dispatch('confirm');
};
const handleConfirm = () => {
isConfirmButtonDisabled = true;
dispatch('confirm');
};
</script>
<FullScreenModal on:clickOutside={handleCancel}>
<div
class="border bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray p-4 shadow-sm w-[500px] max-w-[95vw] rounded-3xl py-8 dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-items-center place-content-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium pb-2">
{title}
</h1>
</div>
<div>
<div class="px-4 py-5 text-md text-center">
<slot name="prompt">
<p>{prompt}</p>
</slot>
</div>
<div
class="border bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray p-4 shadow-sm w-[500px] max-w-[95vw] rounded-3xl py-8 dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-items-center place-content-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium pb-2">
{title}
</h1>
</div>
<div>
<div class="px-4 py-5 text-md text-center">
<slot name="prompt">
<p>{prompt}</p>
</slot>
</div>
<div class="flex w-full px-4 gap-4 mt-4">
{#if !hideCancelButton}
<Button color={cancelColor} fullwidth on:click={handleCancel}>
{cancelText}
</Button>
{/if}
<Button
color={confirmColor}
fullwidth
on:click={handleConfirm}
disabled={isConfirmButtonDisabled}
>
{confirmText}
</Button>
</div>
</div>
</div>
<div class="flex w-full px-4 gap-4 mt-4">
{#if !hideCancelButton}
<Button color={cancelColor} fullwidth on:click={handleCancel}>
{cancelText}
</Button>
{/if}
<Button color={confirmColor} fullwidth on:click={handleConfirm} disabled={isConfirmButtonDisabled}>
{confirmText}
</Button>
</div>
</div>
</div>
</FullScreenModal>

View file

@ -1,33 +1,33 @@
<script lang="ts">
import { clickOutside } from '$lib/utils/click-outside';
import { quintOut } from 'svelte/easing';
import { slide } from 'svelte/transition';
import { clickOutside } from '$lib/utils/click-outside';
import { quintOut } from 'svelte/easing';
import { slide } from 'svelte/transition';
export let direction: 'left' | 'right' = 'right';
export let x = 0;
export let y = 0;
export let direction: 'left' | 'right' = 'right';
export let x = 0;
export let y = 0;
let menuElement: HTMLDivElement;
let left: number;
let top: number;
let menuElement: HTMLDivElement;
let left: number;
let top: number;
$: if (menuElement) {
const rect = menuElement.getBoundingClientRect();
const directionWidth = direction === 'left' ? rect.width : 0;
$: if (menuElement) {
const rect = menuElement.getBoundingClientRect();
const directionWidth = direction === 'left' ? rect.width : 0;
left = Math.min(window.innerWidth - rect.width, x - directionWidth);
top = Math.min(window.innerHeight - rect.height, y);
}
left = Math.min(window.innerWidth - rect.width, x - directionWidth);
top = Math.min(window.innerHeight - rect.height, y);
}
</script>
<div
transition:slide={{ duration: 200, easing: quintOut }}
bind:this={menuElement}
class="absolute w-[200px] z-[99999] rounded-lg overflow-hidden shadow-lg"
style="left: {left}px; top: {top}px;"
role="menu"
use:clickOutside
on:outclick
transition:slide={{ duration: 200, easing: quintOut }}
bind:this={menuElement}
class="absolute w-[200px] z-[99999] rounded-lg overflow-hidden shadow-lg"
style="left: {left}px; top: {top}px;"
role="menu"
use:clickOutside
on:outclick
>
<slot />
<slot />
</div>

View file

@ -1,15 +1,15 @@
<script>
export let text = '';
export let text = '';
</script>
<button
on:click
class="bg-slate-100 hover:bg-gray-200 text-immich-fg dark:text-immich-dark-bg p-4 w-full text-left text-sm font-medium focus:outline-none focus:ring-inset focus:ring-2"
role="menuitem"
on:click
class="bg-slate-100 hover:bg-gray-200 text-immich-fg dark:text-immich-dark-bg p-4 w-full text-left text-sm font-medium focus:outline-none focus:ring-inset focus:ring-2"
role="menuitem"
>
{#if text}
{text}
{:else}
<slot />
{/if}
{#if text}
{text}
{:else}
<slot />
{/if}
</button>

View file

@ -1,72 +1,72 @@
<script lang="ts">
import { browser } from '$app/environment';
import { browser } from '$app/environment';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import Close from 'svelte-material-icons/Close.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import { fly } from 'svelte/transition';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import Close from 'svelte-material-icons/Close.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import { fly } from 'svelte/transition';
export let showBackButton = true;
export let backIcon = Close;
export let tailwindClasses = '';
export let forceDark = false;
export let showBackButton = true;
export let backIcon = Close;
export let tailwindClasses = '';
export let forceDark = false;
let appBarBorder = 'bg-immich-bg border border-transparent';
let appBarBorder = 'bg-immich-bg border border-transparent';
const dispatch = createEventDispatcher();
const dispatch = createEventDispatcher();
const onScroll = () => {
if (window.pageYOffset > 80) {
appBarBorder = 'border border-gray-200 bg-gray-50 dark:border-gray-600';
const onScroll = () => {
if (window.pageYOffset > 80) {
appBarBorder = 'border border-gray-200 bg-gray-50 dark:border-gray-600';
if (forceDark) {
appBarBorder = 'border border-gray-600';
}
} else {
appBarBorder = 'bg-immich-bg border border-transparent';
}
};
if (forceDark) {
appBarBorder = 'border border-gray-600';
}
} else {
appBarBorder = 'bg-immich-bg border border-transparent';
}
};
onMount(() => {
if (browser) {
document.addEventListener('scroll', onScroll);
}
});
onMount(() => {
if (browser) {
document.addEventListener('scroll', onScroll);
}
});
onDestroy(() => {
if (browser) {
document.removeEventListener('scroll', onScroll);
}
});
onDestroy(() => {
if (browser) {
document.removeEventListener('scroll', onScroll);
}
});
</script>
<div in:fly={{ y: 10, duration: 200 }} class="fixed top-0 w-full bg-transparent z-[100]">
<div
id="asset-selection-app-bar"
class={`grid grid-cols-3 justify-between ${appBarBorder} rounded-lg p-2 mx-2 mt-2 transition-all place-items-center ${tailwindClasses} dark:bg-immich-dark-gray ${
forceDark && 'bg-immich-dark-gray text-white'
}`}
>
<div class="flex place-items-center gap-6 dark:text-immich-dark-fg justify-self-start">
{#if showBackButton}
<CircleIconButton
on:click={() => dispatch('close-button-click')}
logo={backIcon}
backgroundColor={'transparent'}
hoverColor={'#e2e7e9'}
size={'24'}
forceDark
/>
{/if}
<slot name="leading" />
</div>
<div
id="asset-selection-app-bar"
class={`grid grid-cols-3 justify-between ${appBarBorder} rounded-lg p-2 mx-2 mt-2 transition-all place-items-center ${tailwindClasses} dark:bg-immich-dark-gray ${
forceDark && 'bg-immich-dark-gray text-white'
}`}
>
<div class="flex place-items-center gap-6 dark:text-immich-dark-fg justify-self-start">
{#if showBackButton}
<CircleIconButton
on:click={() => dispatch('close-button-click')}
logo={backIcon}
backgroundColor={'transparent'}
hoverColor={'#e2e7e9'}
size={'24'}
forceDark
/>
{/if}
<slot name="leading" />
</div>
<div class="w-full">
<slot />
</div>
<div class="w-full">
<slot />
</div>
<div class="flex place-items-center gap-1 mr-4 justify-self-end">
<slot name="trailing" />
</div>
</div>
<div class="flex place-items-center gap-1 mr-4 justify-self-end">
<slot name="trailing" />
</div>
</div>
</div>

View file

@ -1,259 +1,241 @@
<script lang="ts">
import SettingInputField, {
SettingInputFieldType
} from '$lib/components/admin-page/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/admin-page/settings/setting-switch.svelte';
import Button from '$lib/components/elements/buttons/button.svelte';
import { handleError } from '$lib/utils/handle-error';
import {
AlbumResponseDto,
api,
AssetResponseDto,
SharedLinkResponseDto,
SharedLinkType
} from '@api';
import { createEventDispatcher, onMount } from 'svelte';
import Link from 'svelte-material-icons/Link.svelte';
import BaseModal from '../base-modal.svelte';
import type { ImmichDropDownOption } from '../dropdown-button.svelte';
import DropdownButton from '../dropdown-button.svelte';
import { notificationController, NotificationType } from '../notification/notification';
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/admin-page/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/admin-page/settings/setting-switch.svelte';
import Button from '$lib/components/elements/buttons/button.svelte';
import { handleError } from '$lib/utils/handle-error';
import { AlbumResponseDto, api, AssetResponseDto, SharedLinkResponseDto, SharedLinkType } from '@api';
import { createEventDispatcher, onMount } from 'svelte';
import Link from 'svelte-material-icons/Link.svelte';
import BaseModal from '../base-modal.svelte';
import type { ImmichDropDownOption } from '../dropdown-button.svelte';
import DropdownButton from '../dropdown-button.svelte';
import { notificationController, NotificationType } from '../notification/notification';
export let shareType: SharedLinkType;
export let sharedAssets: AssetResponseDto[] = [];
export let album: AlbumResponseDto | undefined = undefined;
export let editingLink: SharedLinkResponseDto | undefined = undefined;
export let shareType: SharedLinkType;
export let sharedAssets: AssetResponseDto[] = [];
export let album: AlbumResponseDto | undefined = undefined;
export let editingLink: SharedLinkResponseDto | undefined = undefined;
let sharedLink: string | null = null;
let description = '';
let allowDownload = true;
let allowUpload = false;
let showExif = true;
let expirationTime = '';
let shouldChangeExpirationTime = false;
let canCopyImagesToClipboard = true;
const dispatch = createEventDispatcher();
let sharedLink: string | null = null;
let description = '';
let allowDownload = true;
let allowUpload = false;
let showExif = true;
let expirationTime = '';
let shouldChangeExpirationTime = false;
let canCopyImagesToClipboard = true;
const dispatch = createEventDispatcher();
const expiredDateOption: ImmichDropDownOption = {
default: 'Never',
options: ['Never', '30 minutes', '1 hour', '6 hours', '1 day', '7 days', '30 days']
};
const expiredDateOption: ImmichDropDownOption = {
default: 'Never',
options: ['Never', '30 minutes', '1 hour', '6 hours', '1 day', '7 days', '30 days'],
};
onMount(async () => {
if (editingLink) {
if (editingLink.description) {
description = editingLink.description;
}
allowUpload = editingLink.allowUpload;
allowDownload = editingLink.allowDownload;
showExif = editingLink.showExif;
}
onMount(async () => {
if (editingLink) {
if (editingLink.description) {
description = editingLink.description;
}
allowUpload = editingLink.allowUpload;
allowDownload = editingLink.allowDownload;
showExif = editingLink.showExif;
}
const module = await import('copy-image-clipboard');
canCopyImagesToClipboard = module.canCopyImagesToClipboard();
});
const module = await import('copy-image-clipboard');
canCopyImagesToClipboard = module.canCopyImagesToClipboard();
});
const handleCreateSharedLink = async () => {
const expirationTime = getExpirationTimeInMillisecond();
const currentTime = new Date().getTime();
const expirationDate = expirationTime
? new Date(currentTime + expirationTime).toISOString()
: undefined;
const handleCreateSharedLink = async () => {
const expirationTime = getExpirationTimeInMillisecond();
const currentTime = new Date().getTime();
const expirationDate = expirationTime ? new Date(currentTime + expirationTime).toISOString() : undefined;
try {
const { data } = await api.sharedLinkApi.createSharedLink({
sharedLinkCreateDto: {
type: shareType,
albumId: album ? album.id : undefined,
assetIds: sharedAssets.map((a) => a.id),
expiresAt: expirationDate,
allowUpload,
description,
allowDownload,
showExif
}
});
sharedLink = `${window.location.origin}/share/${data.key}`;
} catch (e) {
handleError(e, 'Failed to create shared link');
}
};
try {
const { data } = await api.sharedLinkApi.createSharedLink({
sharedLinkCreateDto: {
type: shareType,
albumId: album ? album.id : undefined,
assetIds: sharedAssets.map((a) => a.id),
expiresAt: expirationDate,
allowUpload,
description,
allowDownload,
showExif,
},
});
sharedLink = `${window.location.origin}/share/${data.key}`;
} catch (e) {
handleError(e, 'Failed to create shared link');
}
};
const handleCopy = async () => {
if (!sharedLink) {
return;
}
const handleCopy = async () => {
if (!sharedLink) {
return;
}
try {
await navigator.clipboard.writeText(sharedLink);
notificationController.show({ message: 'Copied to clipboard!', type: NotificationType.Info });
} catch (e) {
handleError(
e,
'Cannot copy to clipboard, make sure you are accessing the page through https'
);
}
};
try {
await navigator.clipboard.writeText(sharedLink);
notificationController.show({ message: 'Copied to clipboard!', type: NotificationType.Info });
} catch (e) {
handleError(e, 'Cannot copy to clipboard, make sure you are accessing the page through https');
}
};
const getExpirationTimeInMillisecond = () => {
switch (expirationTime) {
case '30 minutes':
return 30 * 60 * 1000;
case '1 hour':
return 60 * 60 * 1000;
case '6 hours':
return 6 * 60 * 60 * 1000;
case '1 day':
return 24 * 60 * 60 * 1000;
case '7 days':
return 7 * 24 * 60 * 60 * 1000;
case '30 days':
return 30 * 24 * 60 * 60 * 1000;
default:
return 0;
}
};
const getExpirationTimeInMillisecond = () => {
switch (expirationTime) {
case '30 minutes':
return 30 * 60 * 1000;
case '1 hour':
return 60 * 60 * 1000;
case '6 hours':
return 6 * 60 * 60 * 1000;
case '1 day':
return 24 * 60 * 60 * 1000;
case '7 days':
return 7 * 24 * 60 * 60 * 1000;
case '30 days':
return 30 * 24 * 60 * 60 * 1000;
default:
return 0;
}
};
const handleEditLink = async () => {
if (!editingLink) {
return;
}
const handleEditLink = async () => {
if (!editingLink) {
return;
}
try {
const expirationTime = getExpirationTimeInMillisecond();
const currentTime = new Date().getTime();
const expirationDate: string | null = expirationTime
? new Date(currentTime + expirationTime).toISOString()
: null;
try {
const expirationTime = getExpirationTimeInMillisecond();
const currentTime = new Date().getTime();
const expirationDate: string | null = expirationTime
? new Date(currentTime + expirationTime).toISOString()
: null;
await api.sharedLinkApi.updateSharedLink({
id: editingLink.id,
sharedLinkEditDto: {
description,
expiresAt: shouldChangeExpirationTime ? expirationDate : undefined,
allowUpload: allowUpload,
allowDownload: allowDownload,
showExif: showExif
}
});
await api.sharedLinkApi.updateSharedLink({
id: editingLink.id,
sharedLinkEditDto: {
description,
expiresAt: shouldChangeExpirationTime ? expirationDate : undefined,
allowUpload: allowUpload,
allowDownload: allowDownload,
showExif: showExif,
},
});
notificationController.show({
type: NotificationType.Info,
message: 'Edited'
});
notificationController.show({
type: NotificationType.Info,
message: 'Edited',
});
dispatch('close');
} catch (e) {
handleError(e, 'Failed to edit shared link');
}
};
dispatch('close');
} catch (e) {
handleError(e, 'Failed to edit shared link');
}
};
</script>
<BaseModal on:close={() => dispatch('close')}>
<svelte:fragment slot="title">
<span class="flex gap-2 place-items-center">
<Link size={24} />
{#if editingLink}
<p class="font-medium text-immich-fg dark:text-immich-dark-fg">Edit link</p>
{:else}
<p class="font-medium text-immich-fg dark:text-immich-dark-fg">Create link to share</p>
{/if}
</span>
</svelte:fragment>
<svelte:fragment slot="title">
<span class="flex gap-2 place-items-center">
<Link size={24} />
{#if editingLink}
<p class="font-medium text-immich-fg dark:text-immich-dark-fg">Edit link</p>
{:else}
<p class="font-medium text-immich-fg dark:text-immich-dark-fg">Create link to share</p>
{/if}
</span>
</svelte:fragment>
<section class="mx-6 mb-6">
{#if shareType == SharedLinkType.Album}
{#if !editingLink}
<div>Let anyone with the link see photos and people in this album.</div>
{:else}
<div class="text-sm">
Public album | <span class="text-immich-primary dark:text-immich-dark-primary"
>{editingLink.album?.albumName}</span
>
</div>
{/if}
{/if}
<section class="mx-6 mb-6">
{#if shareType == SharedLinkType.Album}
{#if !editingLink}
<div>Let anyone with the link see photos and people in this album.</div>
{:else}
<div class="text-sm">
Public album | <span class="text-immich-primary dark:text-immich-dark-primary"
>{editingLink.album?.albumName}</span
>
</div>
{/if}
{/if}
{#if shareType == SharedLinkType.Individual}
{#if !editingLink}
<div>Let anyone with the link see the selected photo(s)</div>
{:else}
<div class="text-sm">
Individual shared | <span class="text-immich-primary dark:text-immich-dark-primary"
>{editingLink.description}</span
>
</div>
{/if}
{/if}
{#if shareType == SharedLinkType.Individual}
{#if !editingLink}
<div>Let anyone with the link see the selected photo(s)</div>
{:else}
<div class="text-sm">
Individual shared | <span class="text-immich-primary dark:text-immich-dark-primary"
>{editingLink.description}</span
>
</div>
{/if}
{/if}
<div class="mt-4 mb-2">
<p class="text-xs">LINK OPTIONS</p>
</div>
<div class="p-4 bg-gray-100 dark:bg-black/40 rounded-lg">
<div class="flex flex-col">
<div class="mb-2">
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="Description"
bind:value={description}
/>
</div>
<div class="mt-4 mb-2">
<p class="text-xs">LINK OPTIONS</p>
</div>
<div class="p-4 bg-gray-100 dark:bg-black/40 rounded-lg">
<div class="flex flex-col">
<div class="mb-2">
<SettingInputField inputType={SettingInputFieldType.TEXT} label="Description" bind:value={description} />
</div>
<div class="my-3">
<SettingSwitch bind:checked={showExif} title={'Show metadata'} />
</div>
<div class="my-3">
<SettingSwitch bind:checked={showExif} title={'Show metadata'} />
</div>
<div class="my-3">
<SettingSwitch bind:checked={allowDownload} title={'Allow public user to download'} />
</div>
<div class="my-3">
<SettingSwitch bind:checked={allowDownload} title={'Allow public user to download'} />
</div>
<div class="my-3">
<SettingSwitch bind:checked={allowUpload} title={'Allow public user to upload'} />
</div>
<div class="my-3">
<SettingSwitch bind:checked={allowUpload} title={'Allow public user to upload'} />
</div>
<div class="text-sm">
{#if editingLink}
<p class="my-2 immich-form-label">
<SettingSwitch
bind:checked={shouldChangeExpirationTime}
title={'Change expiration time'}
/>
</p>
{:else}
<p class="my-2 immich-form-label">Expire after</p>
{/if}
<div class="text-sm">
{#if editingLink}
<p class="my-2 immich-form-label">
<SettingSwitch bind:checked={shouldChangeExpirationTime} title={'Change expiration time'} />
</p>
{:else}
<p class="my-2 immich-form-label">Expire after</p>
{/if}
<DropdownButton
options={expiredDateOption}
bind:selected={expirationTime}
disabled={editingLink && !shouldChangeExpirationTime}
/>
</div>
</div>
</div>
</section>
<DropdownButton
options={expiredDateOption}
bind:selected={expirationTime}
disabled={editingLink && !shouldChangeExpirationTime}
/>
</div>
</div>
</div>
</section>
<hr />
<hr />
<section class="m-6">
{#if !sharedLink}
{#if editingLink}
<div class="flex justify-end">
<Button size="sm" rounded="lg" on:click={handleEditLink}>Confirm</Button>
</div>
{:else}
<div class="flex justify-end">
<Button size="sm" rounded="lg" on:click={handleCreateSharedLink}>Create link</Button>
</div>
{/if}
{:else}
<div class="flex w-full gap-4">
<input class="immich-form-input w-full" bind:value={sharedLink} disabled />
<section class="m-6">
{#if !sharedLink}
{#if editingLink}
<div class="flex justify-end">
<Button size="sm" rounded="lg" on:click={handleEditLink}>Confirm</Button>
</div>
{:else}
<div class="flex justify-end">
<Button size="sm" rounded="lg" on:click={handleCreateSharedLink}>Create link</Button>
</div>
{/if}
{:else}
<div class="flex w-full gap-4">
<input class="immich-form-input w-full" bind:value={sharedLink} disabled />
{#if canCopyImagesToClipboard}
<Button on:click={() => handleCopy()}>Copy</Button>
{/if}
</div>
{/if}
</section>
{#if canCopyImagesToClipboard}
<Button on:click={() => handleCopy()}>Copy</Button>
{/if}
</div>
{/if}
</section>
</BaseModal>

View file

@ -1,39 +1,39 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import ImmichLogo from './immich-logo.svelte';
export let dropHandler: (event: DragEvent) => void;
import { fade } from 'svelte/transition';
import ImmichLogo from './immich-logo.svelte';
export let dropHandler: (event: DragEvent) => void;
let dragStartTarget: EventTarget | null = null;
let dragStartTarget: EventTarget | null = null;
const handleDragEnter = (e: DragEvent) => {
dragStartTarget = e.target;
};
const handleDragEnter = (e: DragEvent) => {
dragStartTarget = e.target;
};
</script>
<svelte:body
on:dragenter|stopPropagation|preventDefault={handleDragEnter}
on:dragleave|stopPropagation|preventDefault={(e) => {
if (dragStartTarget === e.target) {
dragStartTarget = null;
}
}}
on:drop|stopPropagation|preventDefault={(e) => {
dragStartTarget = null;
dropHandler(e);
}}
on:dragenter|stopPropagation|preventDefault={handleDragEnter}
on:dragleave|stopPropagation|preventDefault={(e) => {
if (dragStartTarget === e.target) {
dragStartTarget = null;
}
}}
on:drop|stopPropagation|preventDefault={(e) => {
dragStartTarget = null;
dropHandler(e);
}}
/>
{#if dragStartTarget}
<div
class="fixed inset-0 w-full h-full z-[1000] flex flex-col items-center justify-center bg-gray-100/90 dark:bg-immich-dark-bg/90 text-immich-dark-gray dark:text-immich-gray"
transition:fade={{ duration: 250 }}
on:dragover={(e) => {
// Prevent browser from opening the dropped file.
e.stopPropagation();
e.preventDefault();
}}
>
<ImmichLogo class="animate-bounce w-48 m-16" />
<div class="text-2xl">Drop files anywhere to upload</div>
</div>
<div
class="fixed inset-0 w-full h-full z-[1000] flex flex-col items-center justify-center bg-gray-100/90 dark:bg-immich-dark-bg/90 text-immich-dark-gray dark:text-immich-gray"
transition:fade={{ duration: 250 }}
on:dragover={(e) => {
// Prevent browser from opening the dropped file.
e.stopPropagation();
e.preventDefault();
}}
>
<ImmichLogo class="animate-bounce w-48 m-16" />
<div class="text-2xl">Drop files anywhere to upload</div>
</div>
{/if}

View file

@ -1,76 +1,76 @@
<script lang="ts" context="module">
export type ImmichDropDownOption = {
default: string;
options: string[];
};
export type ImmichDropDownOption = {
default: string;
options: string[];
};
</script>
<script lang="ts">
import { onMount } from 'svelte';
import { onMount } from 'svelte';
export let options: ImmichDropDownOption;
export let selected: string;
export let disabled = false;
export let options: ImmichDropDownOption;
export let selected: string;
export let disabled = false;
onMount(() => {
selected = options.default;
});
onMount(() => {
selected = options.default;
});
export let isOpen = false;
const toggle = () => (isOpen = !isOpen);
export let isOpen = false;
const toggle = () => (isOpen = !isOpen);
</script>
<div id="immich-dropdown" class="relative">
<button
{disabled}
on:click={toggle}
aria-expanded={isOpen}
class="bg-gray-200 w-full flex p-2 rounded-lg dark:bg-gray-600 place-items-center justify-between disabled:cursor-not-allowed dark:disabled:bg-gray-300 disabled:bg-gray-600"
>
<div>
{selected}
</div>
<button
{disabled}
on:click={toggle}
aria-expanded={isOpen}
class="bg-gray-200 w-full flex p-2 rounded-lg dark:bg-gray-600 place-items-center justify-between disabled:cursor-not-allowed dark:disabled:bg-gray-300 disabled:bg-gray-600"
>
<div>
{selected}
</div>
<div>
<svg
style="tran"
width="20"
height="20"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
<div>
<svg
style="tran"
width="20"
height="20"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{#if isOpen}
<div class="flex flex-col mt-2 absolute w-full">
{#each options.options as option}
<button
on:click={() => {
selected = option;
isOpen = false;
}}
class="bg-gray-200 dark:bg-gray-500 dark:hover:bg-gray-700 w-full flex p-2 hover:bg-gray-300 transition-all"
>
{option}
</button>
{/each}
</div>
{/if}
{#if isOpen}
<div class="flex flex-col mt-2 absolute w-full">
{#each options.options as option}
<button
on:click={() => {
selected = option;
isOpen = false;
}}
class="bg-gray-200 dark:bg-gray-500 dark:hover:bg-gray-700 w-full flex p-2 hover:bg-gray-300 transition-all"
>
{option}
</button>
{/each}
</div>
{/if}
</div>
<style>
svg {
transition: transform 0.2s ease-in;
}
svg {
transition: transform 0.2s ease-in;
}
[aria-expanded='true'] svg {
transform: rotate(0.5turn);
}
[aria-expanded='true'] svg {
transform: rotate(0.5turn);
}
</style>

View file

@ -1,28 +1,27 @@
<script lang="ts">
import empty1Url from '$lib/assets/empty-1.svg';
import empty1Url from '$lib/assets/empty-1.svg';
export let actionHandler: undefined | (() => Promise<void>) = undefined;
export let text = '';
export let alt = '';
export let actionHandler: undefined | (() => Promise<void>) = undefined;
export let text = '';
export let alt = '';
let hoverClasses =
'hover:bg-immich-primary/5 dark:hover:bg-immich-dark-primary/25 hover:cursor-pointer';
let hoverClasses = 'hover:bg-immich-primary/5 dark:hover:bg-immich-dark-primary/25 hover:cursor-pointer';
</script>
{#if actionHandler}
<div
on:click={actionHandler}
on:keydown={actionHandler}
class="border dark:border-immich-dark-gray {hoverClasses} p-5 w-[50%] m-auto mt-10 bg-gray-50 dark:bg-immich-dark-gray rounded-3xl flex flex-col place-content-center place-items-center"
>
<img src={empty1Url} {alt} width="500" draggable="false" />
<p class="text-center text-immich-text-gray-500 dark:text-immich-dark-fg">{text}</p>
</div>
<div
on:click={actionHandler}
on:keydown={actionHandler}
class="border dark:border-immich-dark-gray {hoverClasses} p-5 w-[50%] m-auto mt-10 bg-gray-50 dark:bg-immich-dark-gray rounded-3xl flex flex-col place-content-center place-items-center"
>
<img src={empty1Url} {alt} width="500" draggable="false" />
<p class="text-center text-immich-text-gray-500 dark:text-immich-dark-fg">{text}</p>
</div>
{:else}
<div
class="border dark:border-immich-dark-gray p-5 w-[50%] m-auto mt-10 bg-gray-50 dark:bg-immich-dark-gray rounded-3xl flex flex-col place-content-center place-items-center"
>
<img src={empty1Url} {alt} width="500" draggable="false" />
<p class="text-center text-immich-text-gray-500 dark:text-immich-dark-fg">{text}</p>
</div>
<div
class="border dark:border-immich-dark-gray p-5 w-[50%] m-auto mt-10 bg-gray-50 dark:bg-immich-dark-gray rounded-3xl flex flex-col place-content-center place-items-center"
>
<img src={empty1Url} {alt} width="500" draggable="false" />
<p class="text-center text-immich-text-gray-500 dark:text-immich-dark-fg">{text}</p>
</div>
{/if}

Some files were not shown because too many files have changed in this diff Show more