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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,93 +3,93 @@
@tailwind utilities; @tailwind utilities;
@font-face { @font-face {
font-family: 'Work Sans'; font-family: 'Work Sans';
src: url('$lib/assets/fonts/WorkSans-VariableFont_wght.ttf') format('truetype-variations'); src: url('$lib/assets/fonts/WorkSans-VariableFont_wght.ttf') format('truetype-variations');
font-weight: 1 999; font-weight: 1 999;
} }
@font-face { @font-face {
font-family: 'Snowburst One'; font-family: 'Snowburst One';
src: url('$lib/assets/fonts/SnowburstOne-Regular.ttf') format('truetype'); src: url('$lib/assets/fonts/SnowburstOne-Regular.ttf') format('truetype');
} }
:root { :root {
font-family: 'Work Sans', sans-serif; font-family: 'Work Sans', sans-serif;
} }
html { html {
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
html::-webkit-scrollbar { html::-webkit-scrollbar {
width: 8px; width: 8px;
} }
/* Track */ /* Track */
html::-webkit-scrollbar-track { html::-webkit-scrollbar-track {
background: #f1f1f1; background: #f1f1f1;
border-radius: 16px; border-radius: 16px;
} }
/* Handle */ /* Handle */
html::-webkit-scrollbar-thumb { html::-webkit-scrollbar-thumb {
background: rgba(85, 86, 87, 0.408); background: rgba(85, 86, 87, 0.408);
border-radius: 16px; border-radius: 16px;
} }
/* Handle on hover */ /* Handle on hover */
html::-webkit-scrollbar-thumb:hover { html::-webkit-scrollbar-thumb:hover {
background: #4250afad; background: #4250afad;
border-radius: 16px; border-radius: 16px;
} }
body { body {
margin: 0; margin: 0;
color: #5f6368; color: #5f6368;
} }
input:focus-visible { input:focus-visible {
outline-offset: 0px !important; outline-offset: 0px !important;
outline: none !important; outline: none !important;
} }
@layer utilities { @layer utilities {
.immich-form-input { .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; @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 { .immich-form-label {
@apply font-medium text-gray-500 dark:text-gray-300; @apply font-medium text-gray-500 dark:text-gray-300;
} }
/* width */ /* width */
.immich-scrollbar::-webkit-scrollbar { .immich-scrollbar::-webkit-scrollbar {
width: 8px; width: 8px;
} }
/* Track */ /* Track */
.immich-scrollbar::-webkit-scrollbar-track { .immich-scrollbar::-webkit-scrollbar-track {
background: #f1f1f1; background: #f1f1f1;
border-radius: 16px; border-radius: 16px;
} }
/* Handle */ /* Handle */
.immich-scrollbar::-webkit-scrollbar-thumb { .immich-scrollbar::-webkit-scrollbar-thumb {
background: rgba(85, 86, 87, 0.408); background: rgba(85, 86, 87, 0.408);
border-radius: 16px; border-radius: 16px;
} }
/* Handle on hover */ /* Handle on hover */
.immich-scrollbar::-webkit-scrollbar-thumb:hover { .immich-scrollbar::-webkit-scrollbar-thumb:hover {
background: #4250afad; background: #4250afad;
border-radius: 16px; border-radius: 16px;
} }
/* Hidden scrollbar */ /* Hidden scrollbar */
/* width */ /* width */
.scrollbar-hidden::-webkit-scrollbar { .scrollbar-hidden::-webkit-scrollbar {
display: none; display: none;
scrollbar-width: 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 // See https://kit.svelte.dev/docs/types#app
// for information about these interfaces // for information about these interfaces
declare namespace App { declare namespace App {
interface Locals { interface Locals {
user?: import('@api').UserResponseDto; user?: import('@api').UserResponseDto;
api: import('@api').ImmichApi; api: import('@api').ImmichApi;
} }
interface PageData { interface PageData {
meta: { meta: {
title: string; title: string;
description?: string; description?: string;
imageUrl?: string; imageUrl?: string;
}; };
} }
interface Error { interface Error {
message: string; message: string;
stack?: string; stack?: string;
code?: string | number; code?: string | number;
} }
} }
// Source: https://stackoverflow.com/questions/63814432/typescript-typing-of-non-standard-window-event-in-svelte // 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 // To fix the <svelte:window... in components/asset-viewer/photo-viewer.svelte
declare namespace svelteHTML { declare namespace svelteHTML {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
interface HTMLAttributes<T> { interface HTMLAttributes<T> {
'on:copyImage'?: () => void; 'on:copyImage'?: () => void;
'on:zoomImage'?: () => void; 'on:zoomImage'?: () => void;
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -1,21 +1,21 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
export type Colors = 'light-gray' | 'gray'; export type Colors = 'light-gray' | 'gray';
</script> </script>
<script lang="ts"> <script lang="ts">
export let color: Colors; export let color: Colors;
const colorClasses: Record<Colors, string> = { const colorClasses: Record<Colors, string> = {
'light-gray': 'bg-gray-300/90 dark:bg-gray-600/90', 'light-gray': 'bg-gray-300/90 dark:bg-gray-600/90',
gray: 'bg-gray-300 dark:bg-gray-600' gray: 'bg-gray-300 dark:bg-gray-600',
}; };
</script> </script>
<button <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[ 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 color
]}" ]}"
on:click on:click
> >
<slot /> <slot />
</button> </button>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,211 +1,211 @@
<script lang="ts"> <script lang="ts">
import { import {
notificationController, notificationController,
NotificationType NotificationType,
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import { api, SystemConfigFFmpegDto, SystemConfigFFmpegDtoTranscodeEnum } from '@api'; import { api, SystemConfigFFmpegDto, SystemConfigFFmpegDtoTranscodeEnum } from '@api';
import SettingButtonsRow from '../setting-buttons-row.svelte'; import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte'; import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
import SettingSelect from '../setting-select.svelte'; import SettingSelect from '../setting-select.svelte';
import SettingSwitch from '../setting-switch.svelte'; import SettingSwitch from '../setting-switch.svelte';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition'; 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 savedConfig: SystemConfigFFmpegDto;
let defaultConfig: SystemConfigFFmpegDto; let defaultConfig: SystemConfigFFmpegDto;
async function getConfigs() { async function getConfigs() {
[savedConfig, defaultConfig] = await Promise.all([ [savedConfig, defaultConfig] = await Promise.all([
api.systemConfigApi.getConfig().then((res) => res.data.ffmpeg), api.systemConfigApi.getConfig().then((res) => res.data.ffmpeg),
api.systemConfigApi.getDefaults().then((res) => res.data.ffmpeg) api.systemConfigApi.getDefaults().then((res) => res.data.ffmpeg),
]); ]);
} }
async function saveSetting() { async function saveSetting() {
try { try {
const { data: configs } = await api.systemConfigApi.getConfig(); const { data: configs } = await api.systemConfigApi.getConfig();
const result = await api.systemConfigApi.updateConfig({ const result = await api.systemConfigApi.updateConfig({
systemConfigDto: { systemConfigDto: {
...configs, ...configs,
ffmpeg: ffmpegConfig ffmpeg: ffmpegConfig,
} },
}); });
ffmpegConfig = { ...result.data.ffmpeg }; ffmpegConfig = { ...result.data.ffmpeg };
savedConfig = { ...result.data.ffmpeg }; savedConfig = { ...result.data.ffmpeg };
notificationController.show({ notificationController.show({
message: 'FFmpeg settings saved', message: 'FFmpeg settings saved',
type: NotificationType.Info type: NotificationType.Info,
}); });
} catch (e) { } catch (e) {
console.error('Error [ffmpeg-settings] [saveSetting]', e); console.error('Error [ffmpeg-settings] [saveSetting]', e);
notificationController.show({ notificationController.show({
message: 'Unable to save settings', message: 'Unable to save settings',
type: NotificationType.Error type: NotificationType.Error,
}); });
} }
} }
async function reset() { async function reset() {
const { data: resetConfig } = await api.systemConfigApi.getConfig(); const { data: resetConfig } = await api.systemConfigApi.getConfig();
ffmpegConfig = { ...resetConfig.ffmpeg }; ffmpegConfig = { ...resetConfig.ffmpeg };
savedConfig = { ...resetConfig.ffmpeg }; savedConfig = { ...resetConfig.ffmpeg };
notificationController.show({ notificationController.show({
message: 'Reset FFmpeg settings to the recent saved settings', message: 'Reset FFmpeg settings to the recent saved settings',
type: NotificationType.Info type: NotificationType.Info,
}); });
} }
async function resetToDefault() { async function resetToDefault() {
const { data: configs } = await api.systemConfigApi.getDefaults(); const { data: configs } = await api.systemConfigApi.getDefaults();
ffmpegConfig = { ...configs.ffmpeg }; ffmpegConfig = { ...configs.ffmpeg };
defaultConfig = { ...configs.ffmpeg }; defaultConfig = { ...configs.ffmpeg };
notificationController.show({ notificationController.show({
message: 'Reset FFmpeg settings to default', message: 'Reset FFmpeg settings to default',
type: NotificationType.Info type: NotificationType.Info,
}); });
} }
</script> </script>
<div> <div>
{#await getConfigs() then} {#await getConfigs() then}
<div in:fade={{ duration: 500 }}> <div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault> <form autocomplete="off" on:submit|preventDefault>
<div class="flex flex-col gap-4 ml-4 mt-4"> <div class="flex flex-col gap-4 ml-4 mt-4">
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.NUMBER} inputType={SettingInputFieldType.NUMBER}
label="CONSTANT RATE FACTOR (-crf)" 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." 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} bind:value={ffmpegConfig.crf}
required={true} required={true}
isEdited={!(ffmpegConfig.crf == savedConfig.crf)} isEdited={!(ffmpegConfig.crf == savedConfig.crf)}
/> />
<SettingSelect <SettingSelect
label="PRESET (-preset)" label="PRESET (-preset)"
desc="Compression speed. Slower presets produce smaller files, and increase quality when targeting a certain bitrate. VP9 ignores speeds above `faster`." 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} bind:value={ffmpegConfig.preset}
name="preset" name="preset"
options={[ options={[
{ value: 'ultrafast', text: 'ultrafast' }, { value: 'ultrafast', text: 'ultrafast' },
{ value: 'superfast', text: 'superfast' }, { value: 'superfast', text: 'superfast' },
{ value: 'veryfast', text: 'veryfast' }, { value: 'veryfast', text: 'veryfast' },
{ value: 'faster', text: 'faster' }, { value: 'faster', text: 'faster' },
{ value: 'fast', text: 'fast' }, { value: 'fast', text: 'fast' },
{ value: 'medium', text: 'medium' }, { value: 'medium', text: 'medium' },
{ value: 'slow', text: 'slow' }, { value: 'slow', text: 'slow' },
{ value: 'slower', text: 'slower' }, { value: 'slower', text: 'slower' },
{ value: 'veryslow', text: 'veryslow' } { value: 'veryslow', text: 'veryslow' },
]} ]}
isEdited={!(ffmpegConfig.preset == savedConfig.preset)} isEdited={!(ffmpegConfig.preset == savedConfig.preset)}
/> />
<SettingSelect <SettingSelect
label="AUDIO CODEC" label="AUDIO CODEC"
desc="Opus is the highest quality option, but has lower compatibility with old devices or software." desc="Opus is the highest quality option, but has lower compatibility with old devices or software."
bind:value={ffmpegConfig.targetAudioCodec} bind:value={ffmpegConfig.targetAudioCodec}
options={[ options={[
{ value: 'aac', text: 'aac' }, { value: 'aac', text: 'aac' },
{ value: 'mp3', text: 'mp3' }, { value: 'mp3', text: 'mp3' },
{ value: 'opus', text: 'opus' } { value: 'opus', text: 'opus' },
]} ]}
name="acodec" name="acodec"
isEdited={!(ffmpegConfig.targetAudioCodec == savedConfig.targetAudioCodec)} isEdited={!(ffmpegConfig.targetAudioCodec == savedConfig.targetAudioCodec)}
/> />
<SettingSelect <SettingSelect
label="VIDEO CODEC" 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." 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} bind:value={ffmpegConfig.targetVideoCodec}
options={[ options={[
{ value: 'h264', text: 'h264' }, { value: 'h264', text: 'h264' },
{ value: 'hevc', text: 'hevc' }, { value: 'hevc', text: 'hevc' },
{ value: 'vp9', text: 'vp9' } { value: 'vp9', text: 'vp9' },
]} ]}
name="vcodec" name="vcodec"
isEdited={!(ffmpegConfig.targetVideoCodec == savedConfig.targetVideoCodec)} isEdited={!(ffmpegConfig.targetVideoCodec == savedConfig.targetVideoCodec)}
/> />
<SettingSelect <SettingSelect
label="TARGET RESOLUTION" label="TARGET RESOLUTION"
desc="Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness." 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} bind:value={ffmpegConfig.targetResolution}
options={[ options={[
{ value: '2160', text: '4k' }, { value: '2160', text: '4k' },
{ value: '1440', text: '1440p' }, { value: '1440', text: '1440p' },
{ value: '1080', text: '1080p' }, { value: '1080', text: '1080p' },
{ value: '720', text: '720p' }, { value: '720', text: '720p' },
{ value: '480', text: '480p' }, { value: '480', text: '480p' },
{ value: 'original', text: 'original' } { value: 'original', text: 'original' },
]} ]}
name="resolution" name="resolution"
isEdited={!(ffmpegConfig.targetResolution == savedConfig.targetResolution)} isEdited={!(ffmpegConfig.targetResolution == savedConfig.targetResolution)}
/> />
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
label="MAX BITRATE" 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." 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} bind:value={ffmpegConfig.maxBitrate}
isEdited={!(ffmpegConfig.maxBitrate == savedConfig.maxBitrate)} isEdited={!(ffmpegConfig.maxBitrate == savedConfig.maxBitrate)}
/> />
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.NUMBER} inputType={SettingInputFieldType.NUMBER}
label="THREADS" 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." 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} bind:value={ffmpegConfig.threads}
isEdited={!(ffmpegConfig.threads == savedConfig.threads)} isEdited={!(ffmpegConfig.threads == savedConfig.threads)}
/> />
<SettingSelect <SettingSelect
label="TRANSCODE" label="TRANSCODE"
desc="Policy for when a video should be transcoded." desc="Policy for when a video should be transcoded."
bind:value={ffmpegConfig.transcode} bind:value={ffmpegConfig.transcode}
name="transcode" name="transcode"
options={[ options={[
{ value: SystemConfigFFmpegDtoTranscodeEnum.All, text: 'All videos' }, { value: SystemConfigFFmpegDtoTranscodeEnum.All, text: 'All videos' },
{ {
value: SystemConfigFFmpegDtoTranscodeEnum.Optimal, value: SystemConfigFFmpegDtoTranscodeEnum.Optimal,
text: 'Videos higher than target resolution or not in the desired format' text: 'Videos higher than target resolution or not in the desired format',
}, },
{ {
value: SystemConfigFFmpegDtoTranscodeEnum.Required, value: SystemConfigFFmpegDtoTranscodeEnum.Required,
text: 'Only videos not in the desired format' text: 'Only videos not in the desired format',
}, },
{ {
value: SystemConfigFFmpegDtoTranscodeEnum.Disabled, value: SystemConfigFFmpegDtoTranscodeEnum.Disabled,
text: "Don't transcode any videos, may break playback on some clients" text: "Don't transcode any videos, may break playback on some clients",
} },
]} ]}
isEdited={!(ffmpegConfig.transcode == savedConfig.transcode)} isEdited={!(ffmpegConfig.transcode == savedConfig.transcode)}
/> />
<SettingSwitch <SettingSwitch
title="TWO-PASS ENCODING" 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." 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} bind:checked={ffmpegConfig.twoPass}
isEdited={!(ffmpegConfig.twoPass === savedConfig.twoPass)} isEdited={!(ffmpegConfig.twoPass === savedConfig.twoPass)}
/> />
</div> </div>
<div class="ml-4"> <div class="ml-4">
<SettingButtonsRow <SettingButtonsRow
on:reset={reset} on:reset={reset}
on:save={saveSetting} on:save={saveSetting}
on:reset-to-default={resetToDefault} on:reset-to-default={resetToDefault}
showResetToDefault={!isEqual(savedConfig, defaultConfig)} showResetToDefault={!isEqual(savedConfig, defaultConfig)}
/> />
</div> </div>
</form> </form>
</div> </div>
{/await} {/await}
</div> </div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,29 +1,29 @@
<div class="text-xs mt-4"> <div class="text-xs mt-4">
<h4>OTHER VARIABLES</h4> <h4>OTHER VARIABLES</h4>
</div> </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="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 class="flex gap-[50px]">
<div> <div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">FILE NAME</p> <p class="text-immich-primary font-medium dark:text-immich-dark-primary">FILE NAME</p>
<ul> <ul>
<li>{`{{filename}}`}</li> <li>{`{{filename}}`}</li>
</ul> </ul>
</div> </div>
<div> <div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">FILE EXTENSION</p> <p class="text-immich-primary font-medium dark:text-immich-dark-primary">FILE EXTENSION</p>
<ul> <ul>
<li>{`{{ext}}`}</li> <li>{`{{ext}}`}</li>
</ul> </ul>
</div> </div>
<div> <div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">FILE TYPE</p> <p class="text-immich-primary font-medium dark:text-immich-dark-primary">FILE TYPE</p>
<ul> <ul>
<li>{`{{filetype}}`} - VID or IMG</li> <li>{`{{filetype}}`} - VID or IMG</li>
<li>{`{{filetypefull}}`} - VIDEO or IMAGE</li> <li>{`{{filetypefull}}`} - VIDEO or IMAGE</li>
</ul> </ul>
</div> </div>
</div> </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 { createObjectURLMock } from '$lib/__mocks__/jsdom-url.mock';
import { api, ThumbnailFormat } from '@api'; import { api, ThumbnailFormat } from '@api';
import { describe, it, jest } from '@jest/globals';
import { albumFactory } from '@test-data'; import { albumFactory } from '@test-data';
import AlbumCard from '../album-card.svelte';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { fireEvent, render, RenderResult, waitFor } from '@testing-library/svelte';
import AlbumCard from '../album-card.svelte';
jest.mock('@api'); jest.mock('@api');
const apiMock: jest.MockedObject<typeof api> = api as jest.MockedObject<typeof api>; const apiMock: jest.MockedObject<typeof api> = api as jest.MockedObject<typeof api>;
describe('AlbumCard component', () => { describe('AlbumCard component', () => {
let sut: RenderResult<AlbumCard>; let sut: RenderResult<AlbumCard>;
it.each([ it.each([
{ {
album: albumFactory.build({ albumThumbnailAssetId: null, shared: false, assetCount: 0 }), album: albumFactory.build({ albumThumbnailAssetId: null, shared: false, assetCount: 0 }),
count: 0, count: 0,
shared: false shared: false,
}, },
{ {
album: albumFactory.build({ albumThumbnailAssetId: null, shared: true, assetCount: 0 }), album: albumFactory.build({ albumThumbnailAssetId: null, shared: true, assetCount: 0 }),
count: 0, count: 0,
shared: true shared: true,
}, },
{ {
album: albumFactory.build({ albumThumbnailAssetId: null, shared: false, assetCount: 5 }), album: albumFactory.build({ albumThumbnailAssetId: null, shared: false, assetCount: 5 }),
count: 5, count: 5,
shared: false shared: false,
}, },
{ {
album: albumFactory.build({ albumThumbnailAssetId: null, shared: true, assetCount: 2 }), album: albumFactory.build({ albumThumbnailAssetId: null, shared: true, assetCount: 2 }),
count: 2, count: 2,
shared: true shared: true,
} },
])( ])('shows album data without thumbnail with count $count - shared: $shared', async ({ album, count, shared }) => {
'shows album data without thumbnail with count $count - shared: $shared', sut = render(AlbumCard, { album, user: album.owner });
async ({ album, count, shared }) => {
sut = render(AlbumCard, { album, user: album.owner });
const albumImgElement = sut.getByTestId('album-image'); const albumImgElement = sut.getByTestId('album-image');
const albumNameElement = sut.getByTestId('album-name'); const albumNameElement = sut.getByTestId('album-name');
const albumDetailsElement = sut.getByTestId('album-details'); const albumDetailsElement = sut.getByTestId('album-details');
const detailsText = `${count} items` + (shared ? ' . Shared' : ''); const detailsText = `${count} items` + (shared ? ' . Shared' : '');
expect(albumImgElement).toHaveAttribute('src'); expect(albumImgElement).toHaveAttribute('src');
expect(albumImgElement).toHaveAttribute('alt', album.id); 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(albumImgElement).toHaveAttribute('alt', album.id);
expect(apiMock.assetApi.getAssetThumbnail).not.toHaveBeenCalled(); expect(apiMock.assetApi.getAssetThumbnail).not.toHaveBeenCalled();
expect(albumNameElement).toHaveTextContent(album.albumName); expect(albumNameElement).toHaveTextContent(album.albumName);
expect(albumDetailsElement).toHaveTextContent(new RegExp(detailsText)); expect(albumDetailsElement).toHaveTextContent(new RegExp(detailsText));
} });
);
it('shows album data and and loads the thumbnail image when available', async () => { it('shows album data and and loads the thumbnail image when available', async () => {
const thumbnailFile = new File([new Blob()], 'fileThumbnail'); const thumbnailFile = new File([new Blob()], 'fileThumbnail');
const thumbnailUrl = 'blob:thumbnailUrlOne'; const thumbnailUrl = 'blob:thumbnailUrlOne';
apiMock.assetApi.getAssetThumbnail.mockResolvedValue({ apiMock.assetApi.getAssetThumbnail.mockResolvedValue({
data: thumbnailFile, data: thumbnailFile,
config: {}, config: {},
headers: {}, headers: {},
status: 200, status: 200,
statusText: '' statusText: '',
}); });
createObjectURLMock.mockReturnValueOnce(thumbnailUrl); createObjectURLMock.mockReturnValueOnce(thumbnailUrl);
const album = albumFactory.build({ const album = albumFactory.build({
albumThumbnailAssetId: 'thumbnailIdOne', albumThumbnailAssetId: 'thumbnailIdOne',
shared: false, shared: false,
albumName: 'some album name' albumName: 'some album name',
}); });
sut = render(AlbumCard, { album, user: album.owner }); sut = render(AlbumCard, { album, user: album.owner });
const albumImgElement = sut.getByTestId('album-image'); const albumImgElement = sut.getByTestId('album-image');
const albumNameElement = sut.getByTestId('album-name'); const albumNameElement = sut.getByTestId('album-name');
const albumDetailsElement = sut.getByTestId('album-details'); const albumDetailsElement = sut.getByTestId('album-details');
expect(albumImgElement).toHaveAttribute('alt', album.id); 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(albumImgElement).toHaveAttribute('alt', album.id);
expect(apiMock.assetApi.getAssetThumbnail).toHaveBeenCalledTimes(1); expect(apiMock.assetApi.getAssetThumbnail).toHaveBeenCalledTimes(1);
expect(apiMock.assetApi.getAssetThumbnail).toHaveBeenCalledWith( expect(apiMock.assetApi.getAssetThumbnail).toHaveBeenCalledWith(
{ {
id: 'thumbnailIdOne', id: 'thumbnailIdOne',
format: ThumbnailFormat.Jpeg format: ThumbnailFormat.Jpeg,
}, },
{ responseType: 'blob' } { responseType: 'blob' },
); );
expect(createObjectURLMock).toHaveBeenCalledWith(thumbnailFile); expect(createObjectURLMock).toHaveBeenCalledWith(thumbnailFile);
expect(albumNameElement).toHaveTextContent('some album name'); expect(albumNameElement).toHaveTextContent('some album name');
expect(albumDetailsElement).toHaveTextContent('0 items'); expect(albumDetailsElement).toHaveTextContent('0 items');
}); });
describe('with rendered component - no thumbnail', () => { describe('with rendered component - no thumbnail', () => {
const album = Object.freeze(albumFactory.build({ albumThumbnailAssetId: null })); const album = Object.freeze(albumFactory.build({ albumThumbnailAssetId: null }));
beforeEach(async () => { beforeEach(async () => {
sut = render(AlbumCard, { album, user: album.owner }); sut = render(AlbumCard, { album, user: album.owner });
const albumImgElement = sut.getByTestId('album-image'); const albumImgElement = sut.getByTestId('album-image');
await waitFor(() => expect(albumImgElement).toHaveAttribute('src')); await waitFor(() => expect(albumImgElement).toHaveAttribute('src'));
}); });
it('dispatches custom "click" event with the album in context', async () => { it('dispatches custom "click" event with the album in context', async () => {
const onClickHandler = jest.fn(); const onClickHandler = jest.fn();
sut.component.$on('click', onClickHandler); sut.component.$on('click', onClickHandler);
const albumCardElement = sut.getByTestId('album-card'); const albumCardElement = sut.getByTestId('album-card');
await fireEvent.click(albumCardElement); await fireEvent.click(albumCardElement);
expect(onClickHandler).toHaveBeenCalledTimes(1); expect(onClickHandler).toHaveBeenCalledTimes(1);
expect(onClickHandler).toHaveBeenCalledWith(expect.objectContaining({ detail: album })); expect(onClickHandler).toHaveBeenCalledWith(expect.objectContaining({ detail: album }));
}); });
it('dispatches custom "click" event on context menu click with mouse coordinates', async () => { it('dispatches custom "click" event on context menu click with mouse coordinates', async () => {
const onClickHandler = jest.fn(); const onClickHandler = jest.fn();
sut.component.$on('showalbumcontextmenu', onClickHandler); sut.component.$on('showalbumcontextmenu', onClickHandler);
const contextMenuBtnParent = sut.getByTestId('context-button-parent'); const contextMenuBtnParent = sut.getByTestId('context-button-parent');
await fireEvent( await fireEvent(
contextMenuBtnParent, contextMenuBtnParent,
new MouseEvent('click', { new MouseEvent('click', {
clientX: 123, clientX: 123,
clientY: 456 clientY: 456,
}) }),
); );
expect(onClickHandler).toHaveBeenCalledTimes(1); expect(onClickHandler).toHaveBeenCalledTimes(1);
expect(onClickHandler).toHaveBeenCalledWith( expect(onClickHandler).toHaveBeenCalledWith(expect.objectContaining({ detail: { x: 123, y: 456 } }));
expect.objectContaining({ detail: { x: 123, y: 456 } }) });
); });
});
});
}); });

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,26 +1,24 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
export type Color = 'primary' | 'secondary'; export type Color = 'primary' | 'secondary';
export type Rounded = false | true | 'full'; export type Rounded = false | true | 'full';
</script> </script>
<script lang="ts"> <script lang="ts">
export let color: Color = 'primary'; export let color: Color = 'primary';
export let rounded: Rounded = true; export let rounded: Rounded = true;
const colorClasses: Record<Color, string> = { const colorClasses: Record<Color, string> = {
primary: primary: 'text-gray-100 dark:text-immich-dark-gray bg-immich-primary dark:bg-immich-dark-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',
secondary: };
'text-immich-dark-bg dark:text-immich-gray dark:bg-gray-600 bg-gray-300 dark:text-immich-gray'
};
</script> </script>
<span <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[ 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 color
]}" ]}"
class:rounded-md={rounded === true} class:rounded-md={rounded === true}
class:rounded-full={rounded === 'full'} class:rounded-full={rounded === 'full'}
> >
<slot /> <slot />
</span> </span>

View file

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

View file

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

View file

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

View file

@ -1,13 +1,13 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
export type Color = 'transparent-primary' | 'transparent-gray'; export type Color = 'transparent-primary' | 'transparent-gray';
</script> </script>
<script lang="ts"> <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> </script>
<Button size="link" {color} shadow={false} rounded="lg" on:click> <Button size="link" {color} shadow={false} rounded="lg" on:click>
<slot /> <slot />
</Button> </Button>

View file

@ -1,62 +1,60 @@
<script lang="ts"> <script lang="ts">
import SwapVertical from 'svelte-material-icons/SwapVertical.svelte'; import SwapVertical from 'svelte-material-icons/SwapVertical.svelte';
import Check from 'svelte-material-icons/Check.svelte'; import Check from 'svelte-material-icons/Check.svelte';
import LinkButton from './buttons/link-button.svelte'; import LinkButton from './buttons/link-button.svelte';
import { clickOutside } from '$lib/utils/click-outside'; import { clickOutside } from '$lib/utils/click-outside';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
export let options: string[] = []; export let options: string[] = [];
export let value = options[0]; export let value = options[0];
let showMenu = false; let showMenu = false;
const handleClickOutside = () => { const handleClickOutside = () => {
showMenu = false; showMenu = false;
}; };
const handleSelectOption = (index: number) => { const handleSelectOption = (index: number) => {
value = options[index]; value = options[index];
showMenu = false; showMenu = false;
}; };
</script> </script>
<div id="dropdown-button" use:clickOutside on:outclick={handleClickOutside}> <div id="dropdown-button" use:clickOutside on:outclick={handleClickOutside}>
<!-- BUTTON TITLE --> <!-- BUTTON TITLE -->
<LinkButton on:click={() => (showMenu = true)}> <LinkButton on:click={() => (showMenu = true)}>
<div class="flex place-items-center gap-2 text-sm"> <div class="flex place-items-center gap-2 text-sm">
<SwapVertical size="18" /> <SwapVertical size="18" />
{value} {value}
</div> </div>
</LinkButton> </LinkButton>
<!-- DROP DOWN MENU --> <!-- DROP DOWN MENU -->
{#if showMenu} {#if showMenu}
<div <div
transition:fly={{ y: -30, x: 30, duration: 200 }} 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" 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)} {#each options as option, index (option)}
<button <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" 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)} on:click={() => handleSelectOption(index)}
> >
{#if value == option} {#if value == option}
<div class="text-immich-primary dark:text-immich-dark-primary font-medium"> <div class="text-immich-primary dark:text-immich-dark-primary font-medium">
<Check size="18" /> <Check size="18" />
</div> </div>
<p <p class="justify-self-start text-immich-primary dark:text-immich-dark-primary font-medium">
class="justify-self-start text-immich-primary dark:text-immich-dark-primary font-medium" {option}
> </p>
{option} {:else}
</p> <div />
{:else} <p class="justify-self-start">
<div /> {option}
<p class="justify-self-start"> </p>
{option} {/if}
</p> </button>
{/if} {/each}
</button> </div>
{/each} {/if}
</div>
{/if}
</div> </div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,44 +1,42 @@
<script lang="ts"> <script lang="ts">
import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { openFileUploadDialog } from '$lib/utils/file-uploader';
import type { UserResponseDto } from '@api'; import type { UserResponseDto } from '@api';
import NavigationBar from '../shared-components/navigation-bar/navigation-bar.svelte'; import NavigationBar from '../shared-components/navigation-bar/navigation-bar.svelte';
import SideBar from '../shared-components/side-bar/side-bar.svelte'; import SideBar from '../shared-components/side-bar/side-bar.svelte';
export let user: UserResponseDto; export let user: UserResponseDto;
export let hideNavbar = false; export let hideNavbar = false;
export let showUploadButton = false; export let showUploadButton = false;
export let title: string | undefined = undefined; export let title: string | undefined = undefined;
</script> </script>
<header> <header>
{#if !hideNavbar} {#if !hideNavbar}
<NavigationBar {user} {showUploadButton} on:uploadClicked={() => openFileUploadDialog()} /> <NavigationBar {user} {showUploadButton} on:uploadClicked={() => openFileUploadDialog()} />
{/if} {/if}
<slot name="header" /> <slot name="header" />
</header> </header>
<main <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"> <slot name="sidebar">
<SideBar /> <SideBar />
</slot> </slot>
<slot name="content"> <slot name="content">
{#if title} {#if title}
<section class="relative"> <section class="relative">
<div <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" 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> <p class="font-medium">{title}</p>
<slot name="buttons" /> <slot name="buttons" />
</div> </div>
<div <div class="absolute overflow-y-auto top-16 h-[calc(100%-theme(spacing.16))] w-full immich-scrollbar p-4 pb-8">
class="absolute overflow-y-auto top-16 h-[calc(100%-theme(spacing.16))] w-full immich-scrollbar p-4 pb-8" <slot />
> </div>
<slot /> </section>
</div> {/if}
</section> </slot>
{/if}
</slot>
</main> </main>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,184 +1,184 @@
<script lang="ts"> <script lang="ts">
import appleSplash20482732 from '$lib/assets/apple/apple-splash-2048-2732.png'; import appleSplash20482732 from '$lib/assets/apple/apple-splash-2048-2732.png';
import appleSplash27322048 from '$lib/assets/apple/apple-splash-2732-2048.png'; import appleSplash27322048 from '$lib/assets/apple/apple-splash-2732-2048.png';
import appleSplash16682388 from '$lib/assets/apple/apple-splash-1668-2388.png'; import appleSplash16682388 from '$lib/assets/apple/apple-splash-1668-2388.png';
import appleSplash23881668 from '$lib/assets/apple/apple-splash-2388-1668.png'; import appleSplash23881668 from '$lib/assets/apple/apple-splash-2388-1668.png';
import appleSplash15362048 from '$lib/assets/apple/apple-splash-1536-2048.png'; import appleSplash15362048 from '$lib/assets/apple/apple-splash-1536-2048.png';
import appleSplash20481536 from '$lib/assets/apple/apple-splash-2048-1536.png'; import appleSplash20481536 from '$lib/assets/apple/apple-splash-2048-1536.png';
import appleSplash16682224 from '$lib/assets/apple/apple-splash-1668-2224.png'; import appleSplash16682224 from '$lib/assets/apple/apple-splash-1668-2224.png';
import appleSplash22241668 from '$lib/assets/apple/apple-splash-2224-1668.png'; import appleSplash22241668 from '$lib/assets/apple/apple-splash-2224-1668.png';
import appleSplash16202160 from '$lib/assets/apple/apple-splash-1620-2160.png'; import appleSplash16202160 from '$lib/assets/apple/apple-splash-1620-2160.png';
import appleSplash21601620 from '$lib/assets/apple/apple-splash-2160-1620.png'; import appleSplash21601620 from '$lib/assets/apple/apple-splash-2160-1620.png';
import appleSplash12902796 from '$lib/assets/apple/apple-splash-1290-2796.png'; import appleSplash12902796 from '$lib/assets/apple/apple-splash-1290-2796.png';
import appleSplash27961290 from '$lib/assets/apple/apple-splash-2796-1290.png'; import appleSplash27961290 from '$lib/assets/apple/apple-splash-2796-1290.png';
import appleSplash11792556 from '$lib/assets/apple/apple-splash-1179-2556.png'; import appleSplash11792556 from '$lib/assets/apple/apple-splash-1179-2556.png';
import appleSplash25561179 from '$lib/assets/apple/apple-splash-2556-1179.png'; import appleSplash25561179 from '$lib/assets/apple/apple-splash-2556-1179.png';
import appleSplash12842778 from '$lib/assets/apple/apple-splash-1284-2778.png'; import appleSplash12842778 from '$lib/assets/apple/apple-splash-1284-2778.png';
import appleSplash27781284 from '$lib/assets/apple/apple-splash-2778-1284.png'; import appleSplash27781284 from '$lib/assets/apple/apple-splash-2778-1284.png';
import appleSplash11702532 from '$lib/assets/apple/apple-splash-1170-2532.png'; import appleSplash11702532 from '$lib/assets/apple/apple-splash-1170-2532.png';
import appleSplash25321170 from '$lib/assets/apple/apple-splash-2532-1170.png'; import appleSplash25321170 from '$lib/assets/apple/apple-splash-2532-1170.png';
import appleSplash11252436 from '$lib/assets/apple/apple-splash-1125-2436.png'; import appleSplash11252436 from '$lib/assets/apple/apple-splash-1125-2436.png';
import appleSplash24361125 from '$lib/assets/apple/apple-splash-2436-1125.png'; import appleSplash24361125 from '$lib/assets/apple/apple-splash-2436-1125.png';
import appleSplash12422688 from '$lib/assets/apple/apple-splash-1242-2688.png'; import appleSplash12422688 from '$lib/assets/apple/apple-splash-1242-2688.png';
import appleSplash26881242 from '$lib/assets/apple/apple-splash-2688-1242.png'; import appleSplash26881242 from '$lib/assets/apple/apple-splash-2688-1242.png';
import appleSplash8281792 from '$lib/assets/apple/apple-splash-828-1792.png'; import appleSplash8281792 from '$lib/assets/apple/apple-splash-828-1792.png';
import appleSplash1792828 from '$lib/assets/apple/apple-splash-1792-828.png'; import appleSplash1792828 from '$lib/assets/apple/apple-splash-1792-828.png';
import appleSplash12422208 from '$lib/assets/apple/apple-splash-1242-2208.png'; import appleSplash12422208 from '$lib/assets/apple/apple-splash-1242-2208.png';
import appleSplash22081242 from '$lib/assets/apple/apple-splash-2208-1242.png'; import appleSplash22081242 from '$lib/assets/apple/apple-splash-2208-1242.png';
import appleSplash7501334 from '$lib/assets/apple/apple-splash-750-1334.png'; import appleSplash7501334 from '$lib/assets/apple/apple-splash-750-1334.png';
import appleSplash1334750 from '$lib/assets/apple/apple-splash-1334-750.png'; import appleSplash1334750 from '$lib/assets/apple/apple-splash-1334-750.png';
import appleSplash6401136 from '$lib/assets/apple/apple-splash-640-1136.png'; import appleSplash6401136 from '$lib/assets/apple/apple-splash-640-1136.png';
import appleSplash1136640 from '$lib/assets/apple/apple-splash-1136-640.png'; import appleSplash1136640 from '$lib/assets/apple/apple-splash-1136-640.png';
</script> </script>
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash20482732} href={appleSplash20482732}
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash27322048} href={appleSplash27322048}
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash16682388} href={appleSplash16682388}
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash23881668} href={appleSplash23881668}
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash15362048} href={appleSplash15362048}
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash20481536} href={appleSplash20481536}
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash16682224} href={appleSplash16682224}
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash22241668} href={appleSplash22241668}
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash16202160} href={appleSplash16202160}
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash21601620} href={appleSplash21601620}
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash12902796} href={appleSplash12902796}
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash27961290} href={appleSplash27961290}
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash11792556} href={appleSplash11792556}
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash25561179} href={appleSplash25561179}
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash12842778} href={appleSplash12842778}
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash27781284} href={appleSplash27781284}
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash11702532} href={appleSplash11702532}
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash25321170} href={appleSplash25321170}
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash11252436} href={appleSplash11252436}
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash24361125} href={appleSplash24361125}
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash12422688} href={appleSplash12422688}
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash26881242} href={appleSplash26881242}
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash8281792} href={appleSplash8281792}
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash1792828} href={appleSplash1792828}
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash12422208} href={appleSplash12422208}
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash22081242} href={appleSplash22081242}
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash7501334} href={appleSplash7501334}
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash1334750} href={appleSplash1334750}
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash6401136} href={appleSplash6401136}
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/> />
<link <link
rel="apple-touch-startup-image" rel="apple-touch-startup-image"
href={appleSplash1136640} href={appleSplash1136640}
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" 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"> <script lang="ts">
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { quintOut } from 'svelte/easing'; import { quintOut } from 'svelte/easing';
import Close from 'svelte-material-icons/Close.svelte'; import Close from 'svelte-material-icons/Close.svelte';
import { createEventDispatcher, onMount, onDestroy } from 'svelte'; import { createEventDispatcher, onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import { clickOutside } from '$lib/utils/click-outside'; import { clickOutside } from '$lib/utils/click-outside';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
export let zIndex = 9999; export let zIndex = 9999;
onMount(() => { onMount(() => {
if (browser) { if (browser) {
const scrollTop = document.documentElement.scrollTop; const scrollTop = document.documentElement.scrollTop;
const scrollLeft = document.documentElement.scrollLeft; const scrollLeft = document.documentElement.scrollLeft;
window.onscroll = function () { window.onscroll = function () {
window.scrollTo(scrollLeft, scrollTop); window.scrollTo(scrollLeft, scrollTop);
}; };
} }
}); });
onDestroy(() => { onDestroy(() => {
if (browser) { if (browser) {
window.onscroll = null; window.onscroll = null;
} }
}); });
</script> </script>
<div <div
id="immich-modal" id="immich-modal"
style:z-index={zIndex} style:z-index={zIndex}
transition:fade={{ duration: 100, easing: quintOut }} 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" class="fixed top-0 left-0 w-full h-full bg-black/50 flex place-items-center place-content-center overflow-hidden"
> >
<div <div
use:clickOutside use:clickOutside
on:outclick={() => dispatch('close')} 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" 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 class="flex justify-between place-items-center px-5 py-3">
<div> <div>
<slot name="title"> <slot name="title">
<p>Modal Title</p> <p>Modal Title</p>
</slot> </slot>
</div> </div>
<CircleIconButton on:click={() => dispatch('close')} logo={Close} size={'20'} /> <CircleIconButton on:click={() => dispatch('close')} logo={Close} size={'20'} />
</div> </div>
<div class=""> <div class="">
<slot /> <slot />
</div> </div>
</div> </div>
</div> </div>

View file

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

View file

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

View file

@ -1,15 +1,15 @@
<script> <script>
export let text = ''; export let text = '';
</script> </script>
<button <button
on:click 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" 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" role="menuitem"
> >
{#if text} {#if text}
{text} {text}
{:else} {:else}
<slot /> <slot />
{/if} {/if}
</button> </button>

View file

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

View file

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

View file

@ -1,39 +1,39 @@
<script lang="ts"> <script lang="ts">
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import ImmichLogo from './immich-logo.svelte'; import ImmichLogo from './immich-logo.svelte';
export let dropHandler: (event: DragEvent) => void; export let dropHandler: (event: DragEvent) => void;
let dragStartTarget: EventTarget | null = null; let dragStartTarget: EventTarget | null = null;
const handleDragEnter = (e: DragEvent) => { const handleDragEnter = (e: DragEvent) => {
dragStartTarget = e.target; dragStartTarget = e.target;
}; };
</script> </script>
<svelte:body <svelte:body
on:dragenter|stopPropagation|preventDefault={handleDragEnter} on:dragenter|stopPropagation|preventDefault={handleDragEnter}
on:dragleave|stopPropagation|preventDefault={(e) => { on:dragleave|stopPropagation|preventDefault={(e) => {
if (dragStartTarget === e.target) { if (dragStartTarget === e.target) {
dragStartTarget = null; dragStartTarget = null;
} }
}} }}
on:drop|stopPropagation|preventDefault={(e) => { on:drop|stopPropagation|preventDefault={(e) => {
dragStartTarget = null; dragStartTarget = null;
dropHandler(e); dropHandler(e);
}} }}
/> />
{#if dragStartTarget} {#if dragStartTarget}
<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" 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 }} transition:fade={{ duration: 250 }}
on:dragover={(e) => { on:dragover={(e) => {
// Prevent browser from opening the dropped file. // Prevent browser from opening the dropped file.
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
}} }}
> >
<ImmichLogo class="animate-bounce w-48 m-16" /> <ImmichLogo class="animate-bounce w-48 m-16" />
<div class="text-2xl">Drop files anywhere to upload</div> <div class="text-2xl">Drop files anywhere to upload</div>
</div> </div>
{/if} {/if}

View file

@ -1,76 +1,76 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
export type ImmichDropDownOption = { export type ImmichDropDownOption = {
default: string; default: string;
options: string[]; options: string[];
}; };
</script> </script>
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
export let options: ImmichDropDownOption; export let options: ImmichDropDownOption;
export let selected: string; export let selected: string;
export let disabled = false; export let disabled = false;
onMount(() => { onMount(() => {
selected = options.default; selected = options.default;
}); });
export let isOpen = false; export let isOpen = false;
const toggle = () => (isOpen = !isOpen); const toggle = () => (isOpen = !isOpen);
</script> </script>
<div id="immich-dropdown" class="relative"> <div id="immich-dropdown" class="relative">
<button <button
{disabled} {disabled}
on:click={toggle} on:click={toggle}
aria-expanded={isOpen} 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" 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> <div>
{selected} {selected}
</div> </div>
<div> <div>
<svg <svg
style="tran" style="tran"
width="20" width="20"
height="20" height="20"
fill="none" fill="none"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
> >
<path d="M19 9l-7 7-7-7" /> <path d="M19 9l-7 7-7-7" />
</svg> </svg>
</div> </div>
</button> </button>
{#if isOpen} {#if isOpen}
<div class="flex flex-col mt-2 absolute w-full"> <div class="flex flex-col mt-2 absolute w-full">
{#each options.options as option} {#each options.options as option}
<button <button
on:click={() => { on:click={() => {
selected = option; selected = option;
isOpen = false; 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" 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} {option}
</button> </button>
{/each} {/each}
</div> </div>
{/if} {/if}
</div> </div>
<style> <style>
svg { svg {
transition: transform 0.2s ease-in; transition: transform 0.2s ease-in;
} }
[aria-expanded='true'] svg { [aria-expanded='true'] svg {
transform: rotate(0.5turn); transform: rotate(0.5turn);
} }
</style> </style>

View file

@ -1,28 +1,27 @@
<script lang="ts"> <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 actionHandler: undefined | (() => Promise<void>) = undefined;
export let text = ''; export let text = '';
export let alt = ''; export let alt = '';
let hoverClasses = let hoverClasses = 'hover:bg-immich-primary/5 dark:hover:bg-immich-dark-primary/25 hover:cursor-pointer';
'hover:bg-immich-primary/5 dark:hover:bg-immich-dark-primary/25 hover:cursor-pointer';
</script> </script>
{#if actionHandler} {#if actionHandler}
<div <div
on:click={actionHandler} on:click={actionHandler}
on:keydown={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" 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" /> <img src={empty1Url} {alt} width="500" draggable="false" />
<p class="text-center text-immich-text-gray-500 dark:text-immich-dark-fg">{text}</p> <p class="text-center text-immich-text-gray-500 dark:text-immich-dark-fg">{text}</p>
</div> </div>
{:else} {:else}
<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" 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" /> <img src={empty1Url} {alt} width="500" draggable="false" />
<p class="text-center text-immich-text-gray-500 dark:text-immich-dark-fg">{text}</p> <p class="text-center text-immich-text-gray-500 dark:text-immich-dark-fg">{text}</p>
</div> </div>
{/if} {/if}

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