1
0
forked from Cutlery/immich

Compare commits

..

27 Commits

Author SHA1 Message Date
martabal 8fd54e3994 Merge branch 'main' into fix/edit-faces-notification 2024-04-08 19:58:32 +02:00
bo0tzz db45ec7434 feat(gh-templates): Require non-duplicate confirmation on FR (#8618) 2024-04-08 13:21:35 -04:00
Daniel Dietzler 4f4bceec94 feat(web): add search bar shortcuts (#8630)
add search bar shortcuts
2024-04-08 13:20:24 -04:00
Kevin Huang 7a16233584 fix(server): delete thumbnail for readonly asset (#8593)
* delete thumbnail and other generated files even for readonly asset

* updated test

* don't delete sidecar file for readonly file

* fixed test

* improved external detection
2024-04-08 12:54:10 -04:00
renovate[bot] fff12e3d78 chore(deps): update dependency eslint-plugin-unicorn to v52 (#8629)
* chore(deps): update dependency eslint-plugin-unicorn to v52

* chore: linting

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2024-04-08 12:45:46 -04:00
dependabot[bot] da750ed838 chore(deps): bump docker/setup-buildx-action from 3.2.0 to 3.3.0 (#8621)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.2.0 to 3.3.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3.2.0...v3.3.0)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-08 12:21:49 -04:00
TomixUG e49512896f feat(web): paste photo from clipboard (#8475)
* feat(web): paste photo from clipboard

* listen on svelte:window instead of a div

* refactor: move logic to drag overlay

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2024-04-08 12:19:58 -04:00
pedrxd 0075243ed5 feat(cli): Implement logic for --skip-hash (#8561)
* feat(cli): Implement logic for --skip-hash

* feat: better output for duplicates

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2024-04-08 11:40:32 -04:00
Jelle Dekker 29e47dd7c1 fix: npm i on Windows … (#8619) 2024-04-08 10:53:27 -04:00
Mert 105a74caca feat(server,web): configure image format (#8581) 2024-04-07 12:44:34 -04:00
Mert 55b9acca78 fix(server): hevc tag being set when copying a non-hevc stream (#8582) 2024-04-07 12:44:09 -04:00
Mert 0d130b8957 fix(server): x264/x265 params not being set correctly (#8587) 2024-04-07 12:43:50 -04:00
Mert 0aa5d3daeb fix(web): reset to default button always being shown (#8577) 2024-04-07 12:43:40 -04:00
martabal ad9aafd484 Merge branch 'main' into fix/edit-faces-notification 2024-04-06 18:52:53 +02:00
martabal 8eca25007e Merge branch 'main' into fix/edit-faces-notification 2024-04-02 19:30:27 +02:00
martabal 86b200e870 Merge branch 'main' into fix/edit-faces-notification 2024-03-19 18:37:43 +01:00
martabal 5dcc4ddfc6 Merge branch 'main' into fix/edit-faces-notification 2024-03-18 20:35:59 +01:00
martabal 6b10994bd2 Merge branch 'main' into fix/edit-faces-notification 2024-03-13 23:32:40 +01:00
martabal 12ede06411 Merge branch 'main' into fix/edit-faces-notification 2024-03-11 23:02:02 +01:00
martabal 121e9f1f1c merge main 2024-03-08 22:50:34 +01:00
martabal c263607515 Merge branch 'main' into fix/edit-faces-notification 2024-03-03 15:04:50 +01:00
martabal 4cb2e16549 merge main 2024-02-27 21:03:12 +01:00
martabal 0f8fb8d38b Merge branch 'main' into fix/edit-faces-notification 2024-02-27 08:02:51 +01:00
martabal 107157b856 rename 2024-02-27 08:02:47 +01:00
martabal 87cbcc02c3 fix: use id instead of index 2024-02-23 00:46:41 +01:00
martabal 62531a75eb fix: lint 2024-02-22 18:48:51 +01:00
martabal 29c7663caa fix: notification number of people when editing faces 2024-02-22 18:41:17 +01:00
51 changed files with 451 additions and 444 deletions
@@ -6,6 +6,13 @@ body:
attributes:
value: |
Please use this form to request new feature for Immich
- type: checkboxes
attributes:
options:
- label: I have searched the existing feature requests to make sure this is not a duplicate request.
required: true
- type: textarea
id: feature
attributes:
+1 -1
View File
@@ -58,7 +58,7 @@ jobs:
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.2.0
uses: docker/setup-buildx-action@v3.3.0
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
+1 -1
View File
@@ -66,7 +66,7 @@ jobs:
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.2.0
uses: docker/setup-buildx-action@v3.3.0
- name: Login to Docker Hub
# Only push to Docker Hub when making a release
+1
View File
@@ -21,6 +21,7 @@ module.exports = {
'unicorn/prefer-module': 'off',
'unicorn/prevent-abbreviations': 'off',
'unicorn/no-process-exit': 'off',
'unicorn/import-style': 'off',
curly: 2,
'prettier/prettier': 0,
},
+4 -4
View File
@@ -30,7 +30,7 @@
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^51.0.0",
"eslint-plugin-unicorn": "^52.0.0",
"glob": "^10.3.1",
"mock-fs": "^5.2.0",
"prettier": "^3.2.5",
@@ -2194,9 +2194,9 @@
}
},
"node_modules/eslint-plugin-unicorn": {
"version": "51.0.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-51.0.1.tgz",
"integrity": "sha512-MuR/+9VuB0fydoI0nIn2RDA5WISRn4AsJyNSaNKLVwie9/ONvQhxOBbkfSICBPnzKrB77Fh6CZZXjgTt/4Latw==",
"version": "52.0.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-52.0.0.tgz",
"integrity": "sha512-1Yzm7/m+0R4djH0tjDjfVei/ju2w3AzUGjG6q8JnuNIL5xIwsflyCooW5sfBvQp2pMYQFSWWCFONsjCax1EHng==",
"dev": true,
"dependencies": {
"@babel/helper-validator-identifier": "^7.22.20",
+1 -1
View File
@@ -28,7 +28,7 @@
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^51.0.0",
"eslint-plugin-unicorn": "^52.0.0",
"glob": "^10.3.1",
"mock-fs": "^5.2.0",
"prettier": "^3.2.5",
+31 -9
View File
@@ -56,14 +56,13 @@ class UploadFile extends File {
export const upload = async (paths: string[], baseOptions: BaseOptions, options: UploadOptionsDto) => {
await authenticate(baseOptions);
const files = await scan(paths, options);
if (files.length === 0) {
const scanFiles = await scan(paths, options);
if (scanFiles.length === 0) {
console.log('No files found, exiting');
return;
}
const { newFiles, duplicates } = await checkForDuplicates(files, options);
const { newFiles, duplicates } = await checkForDuplicates(scanFiles, options);
const newAssets = await uploadFiles(newFiles, options);
await updateAlbums([...newAssets, ...duplicates], options);
await deleteFiles(newFiles, options);
@@ -84,7 +83,12 @@ const scan = async (pathsToCrawl: string[], options: UploadOptionsDto) => {
return files;
};
const checkForDuplicates = async (files: string[], { concurrency }: UploadOptionsDto) => {
const checkForDuplicates = async (files: string[], { concurrency, skipHash }: UploadOptionsDto) => {
if (skipHash) {
console.log('Skipping hash check, assuming all files are new');
return { newFiles: files, duplicates: [] };
}
const progressBar = new SingleBar(
{ format: 'Checking files | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
Presets.shades_classic,
@@ -147,17 +151,32 @@ const uploadFiles = async (files: string[], { dryRun, concurrency }: UploadOptio
uploadProgress.start(totalSize, 0);
uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) });
let totalSizeUploaded = 0;
let duplicateCount = 0;
let duplicateSize = 0;
let successCount = 0;
let successSize = 0;
const newAssets: Asset[] = [];
try {
for (const items of chunk(files, concurrency)) {
await Promise.all(
items.map(async (filepath) => {
const stats = statsMap.get(filepath) as Stats;
const response = await uploadFile(filepath, stats);
totalSizeUploaded += stats.size ?? 0;
uploadProgress.update(totalSizeUploaded, { value_formatted: byteSize(totalSizeUploaded) });
newAssets.push({ id: response.id, filepath });
if (response.duplicate) {
duplicateCount++;
duplicateSize += stats.size ?? 0;
} else {
successCount++;
successSize += stats.size ?? 0;
}
uploadProgress.update(successSize, { value_formatted: byteSize(successSize + duplicateSize) });
return response;
}),
);
@@ -166,7 +185,10 @@ const uploadFiles = async (files: string[], { dryRun, concurrency }: UploadOptio
uploadProgress.stop();
}
console.log(`Successfully uploaded ${newAssets.length} asset${s(newAssets.length)} (${byteSize(totalSizeUploaded)})`);
console.log(`Successfully uploaded ${successCount} new asset${s(successCount)} (${byteSize(successSize)})`);
if (duplicateCount > 0) {
console.log(`Skipped ${duplicateCount} duplicate asset${s(duplicateCount)} (${byteSize(duplicateSize)})`);
}
return newAssets;
};
+1
View File
@@ -19,6 +19,7 @@ module.exports = {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'error',
'unicorn/prefer-module': 'off',
'unicorn/import-style': 'off',
curly: 2,
'prettier/prettier': 0,
'unicorn/prevent-abbreviations': 'off',
+5 -5
View File
@@ -23,7 +23,7 @@
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^51.0.1",
"eslint-plugin-unicorn": "^52.0.0",
"exiftool-vendored": "^24.5.0",
"luxon": "^3.4.4",
"pg": "^8.11.3",
@@ -63,7 +63,7 @@
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^51.0.0",
"eslint-plugin-unicorn": "^52.0.0",
"glob": "^10.3.1",
"mock-fs": "^5.2.0",
"prettier": "^3.2.5",
@@ -2343,9 +2343,9 @@
}
},
"node_modules/eslint-plugin-unicorn": {
"version": "51.0.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-51.0.1.tgz",
"integrity": "sha512-MuR/+9VuB0fydoI0nIn2RDA5WISRn4AsJyNSaNKLVwie9/ONvQhxOBbkfSICBPnzKrB77Fh6CZZXjgTt/4Latw==",
"version": "52.0.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-52.0.0.tgz",
"integrity": "sha512-1Yzm7/m+0R4djH0tjDjfVei/ju2w3AzUGjG6q8JnuNIL5xIwsflyCooW5sfBvQp2pMYQFSWWCFONsjCax1EHng==",
"dev": true,
"dependencies": {
"@babel/helper-validator-identifier": "^7.22.20",
+1 -1
View File
@@ -33,7 +33,7 @@
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^51.0.1",
"eslint-plugin-unicorn": "^52.0.0",
"exiftool-vendored": "^24.5.0",
"luxon": "^3.4.4",
"pg": "^8.11.3",
+12 -17
View File
@@ -148,7 +148,7 @@ describe('/activity', () => {
});
it('should filter by userId', async () => {
const [reaction] = await Promise.all([createActivity({ albumId: album.id, type: ReactionType.Like })]);
const reaction = await createActivity({ albumId: album.id, type: ReactionType.Like });
const response1 = await request(app)
.get('/activity')
@@ -250,8 +250,7 @@ describe('/activity', () => {
});
it('should return a 200 for a duplicate like on the album', async () => {
const [reaction] = await Promise.all([createActivity({ albumId: album.id, type: ReactionType.Like })]);
const reaction = await createActivity({ albumId: album.id, type: ReactionType.Like });
const { status, body } = await request(app)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
@@ -261,13 +260,11 @@ describe('/activity', () => {
});
it('should not confuse an album like with an asset like', async () => {
const [reaction] = await Promise.all([
createActivity({
albumId: album.id,
assetId: asset.id,
type: ReactionType.Like,
}),
]);
const reaction = await createActivity({
albumId: album.id,
assetId: asset.id,
type: ReactionType.Like,
});
const { status, body } = await request(app)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
@@ -314,13 +311,11 @@ describe('/activity', () => {
});
it('should return a 200 for a duplicate like on an asset', async () => {
const [reaction] = await Promise.all([
createActivity({
albumId: album.id,
assetId: asset.id,
type: ReactionType.Like,
}),
]);
const reaction = await createActivity({
albumId: album.id,
assetId: asset.id,
type: ReactionType.Like,
});
const { status, body } = await request(app)
.post('/activity')
+1 -1
View File
@@ -111,7 +111,7 @@ describe('/asset', () => {
utils.createAsset(user1.accessToken),
]);
user2Assets = await Promise.all([utils.createAsset(user2.accessToken)]);
user2Assets = [await utils.createAsset(user2.accessToken)];
await Promise.all([
utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-01-01').toISOString() }),
+38 -8
View File
@@ -1,4 +1,5 @@
import { LoginResponseDto, getAllAlbums, getAllAssets } from '@immich/sdk';
import { readFileSync } from 'node:fs';
import { mkdir, readdir, rm, symlink } from 'node:fs/promises';
import { asKeyAuth, immichCli, testAssetDir, utils } from 'src/utils';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
@@ -23,7 +24,7 @@ describe(`immich upload`, () => {
const { stderr, stdout, exitCode } = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
expect(stderr).toBe('');
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 asset')]),
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
);
expect(exitCode).toBe(0);
@@ -35,7 +36,7 @@ describe(`immich upload`, () => {
const first = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
expect(first.stderr).toBe('');
expect(first.stdout.split('\n')).toEqual(
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 asset')]),
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 new asset')]),
);
expect(first.exitCode).toBe(0);
@@ -69,7 +70,7 @@ describe(`immich upload`, () => {
const { stderr, stdout, exitCode } = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--recursive']);
expect(stderr).toBe('');
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 assets')]),
expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 new assets')]),
);
expect(exitCode).toBe(0);
@@ -88,7 +89,7 @@ describe(`immich upload`, () => {
]);
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([
expect.stringContaining('Successfully uploaded 9 assets'),
expect.stringContaining('Successfully uploaded 9 new assets'),
expect.stringContaining('Successfully created 1 new album'),
expect.stringContaining('Successfully updated 9 assets'),
]),
@@ -107,7 +108,7 @@ describe(`immich upload`, () => {
it('should add existing assets to albums', async () => {
const response1 = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--recursive']);
expect(response1.stdout.split('\n')).toEqual(
expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 assets')]),
expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 new assets')]),
);
expect(response1.stderr).toBe('');
expect(response1.exitCode).toBe(0);
@@ -147,7 +148,7 @@ describe(`immich upload`, () => {
]);
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([
expect.stringContaining('Successfully uploaded 9 assets'),
expect.stringContaining('Successfully uploaded 9 new assets'),
expect.stringContaining('Successfully created 1 new album'),
expect.stringContaining('Successfully updated 9 assets'),
]),
@@ -180,7 +181,7 @@ describe(`immich upload`, () => {
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([
expect.stringContaining('Successfully uploaded 9 assets'),
expect.stringContaining('Successfully uploaded 9 new assets'),
expect.stringContaining('Deleting assets that have been uploaded'),
]),
);
@@ -192,6 +193,32 @@ describe(`immich upload`, () => {
});
});
describe('immich upload --skip-hash', () => {
it('should skip hashing', async () => {
const filename = `albums/nature/silver_fir.jpg`;
await utils.createAsset(admin.accessToken, {
assetData: {
bytes: readFileSync(`${testAssetDir}/${filename}`),
filename: 'silver_fit.jpg',
},
});
const { stderr, stdout, exitCode } = await immichCli(['upload', `${testAssetDir}/${filename}`, '--skip-hash']);
expect(stderr).toBe('');
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([
'Skipping hash check, assuming all files are new',
expect.stringContaining('Successfully uploaded 0 new assets'),
expect.stringContaining('Skipped 1 duplicate asset'),
]),
);
expect(exitCode).toBe(0);
const assets = await getAllAssets({}, { headers: asKeyAuth(key) });
expect(assets.length).toBe(1);
});
});
describe('immich upload --concurrency <number>', () => {
it('should work', async () => {
const { stderr, stdout, exitCode } = await immichCli([
@@ -203,7 +230,10 @@ describe(`immich upload`, () => {
expect(stderr).toBe('');
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([expect.stringContaining('Successfully uploaded 9 assets')]),
expect.arrayContaining([
'Found 9 new files and 0 duplicates',
expect.stringContaining('Successfully uploaded 9 new assets'),
]),
);
expect(exitCode).toBe(0);
+3 -1
View File
@@ -1,7 +1,7 @@
import { exec, spawn } from 'node:child_process';
import { setTimeout } from 'node:timers';
export default async () => {
const setup = async () => {
let _resolve: () => unknown;
let _reject: (error: Error) => unknown;
@@ -31,3 +31,5 @@ export default async () => {
await new Promise<void>((resolve) => exec('docker compose down', () => resolve()));
};
};
export default setup;
+1
View File
@@ -25,6 +25,7 @@ module.exports = {
'unicorn/prefer-top-level-await': 'off',
'unicorn/prefer-event-target': 'off',
'unicorn/no-thenable': 'off',
'unicorn/import-style': 'off',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': 'error',
+2 -1
View File
@@ -5,7 +5,7 @@ RUN apt-get install --no-install-recommends -yqq tini
WORKDIR /usr/src/app
COPY server/package.json server/package-lock.json ./
RUN npm ci && \
# sharp-linux-x64 and sharp-linux-arm64 are the only ones we need
# exiftool-vendored.pl, sharp-linux-x64 and sharp-linux-arm64 are the only ones we need
# they're marked as optional dependencies, so we need to copy them manually after pruning
rm -rf node_modules/@img/sharp-libvips* && \
rm -rf node_modules/@img/sharp-linuxmusl-x64
@@ -22,6 +22,7 @@ FROM dev AS prod
RUN npm run build
RUN npm prune --omit=dev --omit=optional
COPY --from=dev /usr/src/app/node_modules/@img ./node_modules/@img
COPY --from=dev /usr/src/app/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl
# web build
FROM node:iron-alpine3.18@sha256:fa5d3cf51725bd42d32e67917623038539dbe720dab082f590785c001eb4dfef as web
+10 -9
View File
@@ -35,7 +35,6 @@
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.6",
"exiftool-vendored": "~24.6.0",
"exiftool-vendored.pl": "12.78",
"fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2",
"geo-tz": "^8.0.0",
@@ -89,7 +88,7 @@
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^51.0.0",
"eslint-plugin-unicorn": "^52.0.0",
"jest": "^29.6.4",
"jest-when": "^3.6.0",
"mock-fs": "^5.2.0",
@@ -7494,9 +7493,9 @@
}
},
"node_modules/eslint-plugin-unicorn": {
"version": "51.0.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-51.0.1.tgz",
"integrity": "sha512-MuR/+9VuB0fydoI0nIn2RDA5WISRn4AsJyNSaNKLVwie9/ONvQhxOBbkfSICBPnzKrB77Fh6CZZXjgTt/4Latw==",
"version": "52.0.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-52.0.0.tgz",
"integrity": "sha512-1Yzm7/m+0R4djH0tjDjfVei/ju2w3AzUGjG6q8JnuNIL5xIwsflyCooW5sfBvQp2pMYQFSWWCFONsjCax1EHng==",
"dev": true,
"dependencies": {
"@babel/helper-validator-identifier": "^7.22.20",
@@ -7774,6 +7773,7 @@
"version": "12.78.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.78.0.tgz",
"integrity": "sha512-K8j9NgxRpTFskFuXEl0AGsc692yYyThe4i3SXgx7xc0fu/vwD2c7tRGljkEtvaweYnMmfrF4DhCpuTu0aux6sg==",
"optional": true,
"os": [
"!win32"
]
@@ -19876,9 +19876,9 @@
}
},
"eslint-plugin-unicorn": {
"version": "51.0.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-51.0.1.tgz",
"integrity": "sha512-MuR/+9VuB0fydoI0nIn2RDA5WISRn4AsJyNSaNKLVwie9/ONvQhxOBbkfSICBPnzKrB77Fh6CZZXjgTt/4Latw==",
"version": "52.0.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-52.0.0.tgz",
"integrity": "sha512-1Yzm7/m+0R4djH0tjDjfVei/ju2w3AzUGjG6q8JnuNIL5xIwsflyCooW5sfBvQp2pMYQFSWWCFONsjCax1EHng==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.22.20",
@@ -20051,7 +20051,8 @@
"exiftool-vendored.pl": {
"version": "12.78.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.78.0.tgz",
"integrity": "sha512-K8j9NgxRpTFskFuXEl0AGsc692yYyThe4i3SXgx7xc0fu/vwD2c7tRGljkEtvaweYnMmfrF4DhCpuTu0aux6sg=="
"integrity": "sha512-K8j9NgxRpTFskFuXEl0AGsc692yYyThe4i3SXgx7xc0fu/vwD2c7tRGljkEtvaweYnMmfrF4DhCpuTu0aux6sg==",
"optional": true
},
"exit": {
"version": "0.1.2",
+1 -2
View File
@@ -59,7 +59,6 @@
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.6",
"exiftool-vendored": "~24.6.0",
"exiftool-vendored.pl": "12.78",
"fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2",
"geo-tz": "^8.0.0",
@@ -113,7 +112,7 @@
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^51.0.0",
"eslint-plugin-unicorn": "^52.0.0",
"jest": "^29.6.4",
"jest-when": "^3.6.0",
"mock-fs": "^5.2.0",
+15 -3
View File
@@ -695,7 +695,7 @@ describe(AssetService.name, () => {
});
});
it('should not schedule delete-files job for readonly assets', async () => {
it('should only delete generated files for readonly assets', async () => {
when(assetMock.getById)
.calledWith(assetStub.readOnly.id, {
faces: {
@@ -709,7 +709,20 @@ describe(AssetService.name, () => {
await sut.handleAssetDeletion({ id: assetStub.readOnly.id });
expect(jobMock.queue.mock.calls).toEqual([]);
expect(jobMock.queue.mock.calls).toEqual([
[
{
name: JobName.DELETE_FILES,
data: {
files: [
assetStub.readOnly.thumbnailPath,
assetStub.readOnly.previewPath,
assetStub.readOnly.encodedVideoPath,
],
},
},
],
]);
expect(assetMock.remove).toHaveBeenCalledWith(assetStub.readOnly);
});
@@ -748,7 +761,6 @@ describe(AssetService.name, () => {
assetStub.external.thumbnailPath,
assetStub.external.previewPath,
assetStub.external.encodedVideoPath,
assetStub.external.sidecarPath,
],
},
},
+4 -6
View File
@@ -399,14 +399,12 @@ export class AssetService {
await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.livePhotoVideoId } });
}
const files = [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath, asset.sidecarPath];
if (!fromExternal) {
files.push(asset.originalPath);
const files = [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath];
if (!(asset.isExternal || asset.isReadOnly)) {
files.push(asset.sidecarPath, asset.originalPath);
}
if (!asset.isReadOnly) {
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files } });
}
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files } });
return JobStatus.SUCCESS;
}
+94 -39
View File
@@ -210,25 +210,21 @@ describe(MediaService.name, () => {
expect(assetMock.update).not.toHaveBeenCalledWith();
});
it('should generate a thumbnail for an image', async () => {
it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => {
configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_PREVIEW_FORMAT, value: format }]);
assetMock.getByIds.mockResolvedValue([assetStub.image]);
const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`;
await sut.handleGeneratePreview({ id: assetStub.image.id });
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
expect(mediaMock.resize).toHaveBeenCalledWith(
'/original/path.jpg',
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
{
size: 1440,
format: ImageFormat.JPEG,
quality: 80,
colorspace: Colorspace.SRGB,
},
);
expect(assetMock.update).toHaveBeenCalledWith({
id: 'asset-id',
previewPath: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', previewPath, {
size: 1440,
format,
quality: 80,
colorspace: Colorspace.SRGB,
});
expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', previewPath });
});
it('should generate a P3 thumbnail for a wide gamut image', async () => {
@@ -342,25 +338,25 @@ describe(MediaService.name, () => {
expect(assetMock.update).not.toHaveBeenCalledWith();
});
it('should generate a thumbnail', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
await sut.handleGenerateThumbnail({ id: assetStub.image.id });
it.each(Object.values(ImageFormat))(
'should generate a %s thumbnail for an image when specified',
async (format) => {
configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_THUMBNAIL_FORMAT, value: format }]);
assetMock.getByIds.mockResolvedValue([assetStub.image]);
const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`;
expect(mediaMock.resize).toHaveBeenCalledWith(
'/original/path.jpg',
'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
{
format: ImageFormat.WEBP,
await sut.handleGenerateThumbnail({ id: assetStub.image.id });
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', thumbnailPath, {
size: 250,
format,
quality: 80,
colorspace: Colorspace.SRGB,
},
);
expect(assetMock.update).toHaveBeenCalledWith({
id: 'asset-id',
thumbnailPath: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
});
});
});
expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbnailPath });
},
);
});
it('should generate a P3 thumbnail for a wide gamut image', async () => {
@@ -747,6 +743,67 @@ describe(MediaService.name, () => {
);
});
it('should not include hevc tag when target is hevc and video stream is copied from a different codec', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamH264);
configMock.load.mockResolvedValue([
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
{ key: SystemConfigKey.FFMPEG_ACCEPTED_VIDEO_CODECS, value: [VideoCodec.H264, VideoCodec.HEVC] },
{ key: SystemConfigKey.FFMPEG_ACCEPTED_AUDIO_CODECS, value: [AudioCodec.AAC] },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
inputOptions: [],
outputOptions: [
'-c:v copy',
'-c:a aac',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
'-map 0:1',
'-v verbose',
'-preset ultrafast',
'-crf 23',
],
twoPass: false,
},
);
});
it('should include hevc tag when target is hevc and copying hevc video stream', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
{ key: SystemConfigKey.FFMPEG_ACCEPTED_VIDEO_CODECS, value: [VideoCodec.H264, VideoCodec.HEVC] },
{ key: SystemConfigKey.FFMPEG_ACCEPTED_AUDIO_CODECS, value: [AudioCodec.AAC] },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
inputOptions: [],
outputOptions: [
'-c:v copy',
'-c:a aac',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
'-map 0:1',
'-tag:v hvc1',
'-v verbose',
'-preset ultrafast',
'-crf 23',
],
twoPass: false,
},
);
});
it('should copy audio stream when audio matches target', async () => {
mediaMock.probe.mockResolvedValue(probeStub.audioStreamAac);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]);
@@ -1091,9 +1148,9 @@ describe(MediaService.name, () => {
);
});
it('should disable thread pooling for h264 if thread limit is above 0', async () => {
it('should disable thread pooling for h264 if thread limit is 1', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 }]);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 1 }]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
@@ -1111,9 +1168,8 @@ describe(MediaService.name, () => {
'-v verbose',
'-vf scale=-2:720,format=yuv420p',
'-preset ultrafast',
'-threads 2',
'-x264-params "pools=none"',
'-x264-params "frame-threads=2"',
'-threads 1',
'-x264-params frame-threads=1:pools=none',
'-crf 23',
],
twoPass: false,
@@ -1148,10 +1204,10 @@ describe(MediaService.name, () => {
);
});
it('should disable thread pooling for hevc if thread limit is above 0', async () => {
it('should disable thread pooling for hevc if thread limit is 1', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
configMock.load.mockResolvedValue([
{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 },
{ key: SystemConfigKey.FFMPEG_THREADS, value: 1 },
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
@@ -1172,9 +1228,8 @@ describe(MediaService.name, () => {
'-v verbose',
'-vf scale=-2:720,format=yuv420p',
'-preset ultrafast',
'-threads 2',
'-x265-params "pools=none"',
'-x265-params "frame-threads=2"',
'-threads 1',
'-x265-params frame-threads=1:pools=none',
'-crf 23',
],
twoPass: false,
+11 -5
View File
@@ -167,12 +167,15 @@ export class MediaService {
}
async handleGeneratePreview({ id }: IEntityJob): Promise<JobStatus> {
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
const [{ image }, [asset]] = await Promise.all([
this.configCore.getConfig(),
this.assetRepository.getByIds([id], { exifInfo: true }),
]);
if (!asset) {
return JobStatus.FAILED;
}
const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, ImageFormat.JPEG);
const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, image.previewFormat);
await this.assetRepository.update({ id: asset.id, previewPath });
return JobStatus.SUCCESS;
}
@@ -210,18 +213,21 @@ export class MediaService {
}
}
this.logger.log(
`Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} thumbnail for asset ${asset.id}`,
`Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} ${type} for asset ${asset.id}`,
);
return path;
}
async handleGenerateThumbnail({ id }: IEntityJob): Promise<JobStatus> {
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
const [{ image }, [asset]] = await Promise.all([
this.configCore.getConfig(),
this.assetRepository.getByIds([id], { exifInfo: true }),
]);
if (!asset) {
return JobStatus.FAILED;
}
const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, ImageFormat.WEBP);
const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat);
await this.assetRepository.update({ id: asset.id, thumbnailPath });
return JobStatus.SUCCESS;
}
+19 -17
View File
@@ -37,9 +37,12 @@ class BaseConfig implements VideoCodecSWConfig {
}
getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
const videoCodec = [TranscodeTarget.ALL, TranscodeTarget.VIDEO].includes(target) ? this.getVideoCodec() : 'copy';
const audioCodec = [TranscodeTarget.ALL, TranscodeTarget.AUDIO].includes(target) ? this.getAudioCodec() : 'copy';
const options = [
`-c:v ${[TranscodeTarget.ALL, TranscodeTarget.VIDEO].includes(target) ? this.getVideoCodec() : 'copy'}`,
`-c:a ${[TranscodeTarget.ALL, TranscodeTarget.AUDIO].includes(target) ? this.getAudioCodec() : 'copy'}`,
`-c:v ${videoCodec}`,
`-c:a ${audioCodec}`,
// Makes a second pass moving the moov atom to the
// beginning of the file for improved playback speed.
'-movflags faststart',
@@ -61,7 +64,10 @@ class BaseConfig implements VideoCodecSWConfig {
options.push(`-g ${this.getGopSize()}`);
}
if (this.config.targetVideoCodec === VideoCodec.HEVC) {
if (
this.config.targetVideoCodec === VideoCodec.HEVC &&
(videoCodec !== 'copy' || videoStream.codecName === 'hevc')
) {
options.push('-tag:v hvc1');
}
@@ -343,27 +349,23 @@ export class ThumbnailConfig extends BaseConfig {
export class H264Config extends BaseConfig {
getThreadOptions() {
if (this.config.threads <= 0) {
return [];
const options = super.getThreadOptions();
if (this.config.threads === 1) {
options.push('-x264-params frame-threads=1:pools=none');
}
return [
...super.getThreadOptions(),
'-x264-params "pools=none"',
`-x264-params "frame-threads=${this.config.threads}"`,
];
return options;
}
}
export class HEVCConfig extends BaseConfig {
getThreadOptions() {
if (this.config.threads <= 0) {
return [];
const options = super.getThreadOptions();
if (this.config.threads === 1) {
options.push('-x265-params frame-threads=1:pools=none');
}
return [
...super.getThreadOptions(),
'-x265-params "pools=none"',
`-x265-params "frame-threads=${this.config.threads}"`,
];
return options;
}
}
+4
View File
@@ -173,4 +173,8 @@ export const probeStub = {
bitrate: 0,
},
}),
videoStreamH264: Object.freeze<VideoInfo>({
...probeStubDefault,
videoStreams: [{ ...probeStubDefaultVideoStream[0], codecName: 'h264' }],
}),
};
+1
View File
@@ -50,6 +50,7 @@ module.exports = {
'unicorn/no-nested-ternary': 'off',
'unicorn/consistent-function-scoping': 'off',
'unicorn/prefer-top-level-await': 'off',
'unicorn/import-style': 'off',
// TODO: set recommended-type-checked and remove these rules
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-floating-promises': 'error',
+4 -4
View File
@@ -45,7 +45,7 @@
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.35.1",
"eslint-plugin-unicorn": "^51.0.1",
"eslint-plugin-unicorn": "^52.0.0",
"factory.ts": "^1.4.1",
"postcss": "^8.4.35",
"prettier": "^3.2.5",
@@ -4167,9 +4167,9 @@
"dev": true
},
"node_modules/eslint-plugin-unicorn": {
"version": "51.0.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-51.0.1.tgz",
"integrity": "sha512-MuR/+9VuB0fydoI0nIn2RDA5WISRn4AsJyNSaNKLVwie9/ONvQhxOBbkfSICBPnzKrB77Fh6CZZXjgTt/4Latw==",
"version": "52.0.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-52.0.0.tgz",
"integrity": "sha512-1Yzm7/m+0R4djH0tjDjfVei/ju2w3AzUGjG6q8JnuNIL5xIwsflyCooW5sfBvQp2pMYQFSWWCFONsjCax1EHng==",
"dev": true,
"dependencies": {
"@babel/helper-validator-identifier": "^7.22.20",
+1 -1
View File
@@ -41,7 +41,7 @@
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.35.1",
"eslint-plugin-unicorn": "^51.0.1",
"eslint-plugin-unicorn": "^52.0.0",
"factory.ts": "^1.4.1",
"postcss": "^8.4.35",
"prettier": "^3.2.5",
@@ -355,7 +355,7 @@
<SettingButtonsRow
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['ffmpeg'] })}
on:save={() => dispatch('save', { ffmpeg: config.ffmpeg })}
showResetToDefault={!isEqual(savedConfig.ffmpeg, defaultConfig)}
showResetToDefault={!isEqual(savedConfig.ffmpeg, defaultConfig.ffmpeg)}
{disabled}
/>
</div>
@@ -1,5 +1,5 @@
<script lang="ts">
import { Colorspace, type SystemConfigDto } from '@immich/sdk';
import { Colorspace, ImageFormat, type SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
@@ -24,6 +24,19 @@
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSelect
label="THUMBNAIL FORMAT"
desc="WebP produces smaller files than JPEG, but is slower to encode."
bind:value={config.image.thumbnailFormat}
options={[
{ value: ImageFormat.Jpeg, text: 'JPEG' },
{ value: ImageFormat.Webp, text: 'WebP' },
]}
name="format"
isEdited={config.image.thumbnailFormat !== savedConfig.image.thumbnailFormat}
{disabled}
/>
<SettingSelect
label="THUMBNAIL RESOLUTION"
desc="Used when viewing groups of photos (main timeline, album view, etc.). Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
@@ -41,6 +54,19 @@
{disabled}
/>
<SettingSelect
label="PREVIEW FORMAT"
desc="WebP produces smaller files than JPEG, but is slower to encode."
bind:value={config.image.previewFormat}
options={[
{ value: ImageFormat.Jpeg, text: 'JPEG' },
{ value: ImageFormat.Webp, text: 'WebP' },
]}
name="format"
isEdited={config.image.previewFormat !== savedConfig.image.previewFormat}
{disabled}
/>
<SettingSelect
label="PREVIEW RESOLUTION"
desc="Used when viewing a single photo and for machine learning. Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
@@ -81,7 +107,7 @@
<SettingButtonsRow
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['image'] })}
on:save={() => dispatch('save', { image: config.image })}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
showResetToDefault={!isEqual(savedConfig.image, defaultConfig.image)}
{disabled}
/>
</div>
@@ -29,7 +29,7 @@
<SettingButtonsRow
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['newVersionCheck'] })}
on:save={() => dispatch('save', { newVersionCheck: config.newVersionCheck })}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
showResetToDefault={!isEqual(savedConfig.newVersionCheck, defaultConfig.newVersionCheck)}
{disabled}
/>
</div>
@@ -41,7 +41,7 @@
<SettingButtonsRow
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['server'] })}
on:save={() => dispatch('save', { server: config.server })}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
showResetToDefault={!isEqual(savedConfig.server, defaultConfig.server)}
{disabled}
/>
</div>
@@ -236,7 +236,7 @@
<SettingButtonsRow
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['storageTemplate'] })}
on:save={() => dispatch('save', { storageTemplate: config.storageTemplate })}
showResetToDefault={!isEqual(savedConfig, defaultConfig) && !minified}
showResetToDefault={!isEqual(savedConfig.storageTemplate, defaultConfig.storageTemplate) && !minified}
{disabled}
/>
{/if}
@@ -45,7 +45,7 @@
<SettingButtonsRow
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['trash'] })}
on:save={() => dispatch('save', { trash: config.trash })}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
showResetToDefault={!isEqual(savedConfig.trash, defaultConfig.trash)}
{disabled}
/>
</div>
@@ -36,7 +36,7 @@
<SettingButtonsRow
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['user'] })}
on:save={() => dispatch('save', { user: config.user })}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
showResetToDefault={!isEqual(savedConfig.user, defaultConfig.user)}
{disabled}
/>
</div>
@@ -42,7 +42,7 @@
'text-immich-primary dark:text-immich-dark-primary enabled:dark:hover:bg-immich-dark-primary/10 enabled:hover:bg-immich-primary/10',
'light-red': 'bg-[#F9DEDC] text-[#410E0B] enabled:hover:bg-red-50',
red: 'bg-red-500 text-white enabled:hover:bg-red-400',
green: 'bg-green-600 text-white enabled:hover:bg-green-400/90',
green: 'bg-green-500 text-gray-800 enabled:hover:bg-green-400/90',
gray: 'bg-gray-500 dark:bg-gray-200 enabled:hover:bg-gray-500/75 enabled:dark:hover:bg-gray-200/80 text-white dark:text-immich-dark-gray',
'transparent-gray':
'dark:text-immich-dark-fg enabled:hover:bg-immich-primary/5 enabled:hover:text-gray-700 enabled:hover:dark:text-immich-dark-fg enabled:dark:hover:bg-immich-dark-primary/25',
@@ -21,7 +21,7 @@
export let peopleWithFaces: AssetFaceResponseDto[];
export let allPeople: PersonResponseDto[];
export let editedPersonIndex: number;
export let editedPerson: PersonResponseDto;
export let assetType: AssetTypeEnum;
export let assetId: string;
@@ -106,7 +106,7 @@
const handleCreatePerson = async () => {
const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner);
const personToUpdate = peopleWithFaces.find((person) => person.id === peopleWithFaces[editedPersonIndex].id);
const personToUpdate = peopleWithFaces.find((face) => face.person?.id === editedPerson.id);
const newFeaturePhoto = personToUpdate ? await zoomImageToBase64(personToUpdate) : null;
@@ -229,7 +229,7 @@
<div class="immich-scrollbar mt-4 flex flex-wrap gap-2 overflow-y-auto">
{#if searchName == ''}
{#each allPeople as person (person.id)}
{#if person.id !== peopleWithFaces[editedPersonIndex].person?.id}
{#if person.id !== editedPerson.id}
<div class="w-fit">
<button class="w-[90px]" on:click={() => dispatch('reassign', person)}>
<div class="relative">
@@ -255,7 +255,7 @@
{/each}
{:else}
{#each searchedPeople as person (person.id)}
{#if person.id !== peopleWithFaces[editedPersonIndex].person?.id}
{#if person.id !== editedPerson.id}
<div class="w-fit">
<button class="w-[90px]" on:click={() => dispatch('reassign', person)}>
<div class="relative">
@@ -28,14 +28,14 @@
export let assetType: AssetTypeEnum;
// keep track of the changes
let numberOfPersonToCreate: string[] = [];
let numberOfAssetFaceGenerated: string[] = [];
let peopleToCreate: string[] = [];
let assetFaceGenerated: string[] = [];
// faces
let peopleWithFaces: AssetFaceResponseDto[] = [];
let selectedPersonToReassign: (PersonResponseDto | null)[];
let selectedPersonToCreate: (string | null)[];
let editedPersonIndex: number;
let selectedPersonToReassign: Record<string, PersonResponseDto> = {};
let selectedPersonToCreate: Record<string, string> = {};
let editedPerson: PersonResponseDto;
// loading spinners
let isShowLoadingDone = false;
@@ -49,6 +49,8 @@
let loaderLoadingDoneTimeout: ReturnType<typeof setTimeout>;
let automaticRefreshTimeout: ReturnType<typeof setTimeout>;
const thumbnailWidth = '90px';
const dispatch = createEventDispatcher<{
close: void;
refresh: void;
@@ -60,8 +62,6 @@
const { people } = await getAllPeople({ withHidden: true });
allPeople = people;
peopleWithFaces = await getFaces({ id: assetId });
selectedPersonToCreate = Array.from({ length: peopleWithFaces.length });
selectedPersonToReassign = Array.from({ length: peopleWithFaces.length });
} catch (error) {
handleError(error, "Can't get faces");
} finally {
@@ -71,12 +71,12 @@
}
const onPersonThumbnail = (personId: string) => {
numberOfAssetFaceGenerated.push(personId);
assetFaceGenerated.push(personId);
if (
isEqual(numberOfAssetFaceGenerated, numberOfPersonToCreate) &&
isEqual(assetFaceGenerated, peopleToCreate) &&
loaderLoadingDoneTimeout &&
automaticRefreshTimeout &&
selectedPersonToCreate.filter((person) => person !== null).length === numberOfPersonToCreate.length
Object.keys(selectedPersonToCreate).length === peopleToCreate.length
) {
clearTimeout(loaderLoadingDoneTimeout);
clearTimeout(automaticRefreshTimeout);
@@ -97,36 +97,41 @@
dispatch('close');
};
const handleReset = (index: number) => {
if (selectedPersonToReassign[index]) {
selectedPersonToReassign[index] = null;
const handleReset = (id: string) => {
if (selectedPersonToReassign[id]) {
delete selectedPersonToReassign[id];
// trigger reactivity
selectedPersonToReassign = selectedPersonToReassign;
}
if (selectedPersonToCreate[index]) {
selectedPersonToCreate[index] = null;
if (selectedPersonToCreate[id]) {
delete selectedPersonToCreate[id];
// trigger reactivity
selectedPersonToCreate = selectedPersonToCreate;
}
};
const handleEditFaces = async () => {
loaderLoadingDoneTimeout = setTimeout(() => (isShowLoadingDone = true), timeBeforeShowLoadingSpinner);
const numberOfChanges =
selectedPersonToCreate.filter((person) => person !== null).length +
selectedPersonToReassign.filter((person) => person !== null).length;
const numberOfChanges = Object.keys(selectedPersonToCreate).length + Object.keys(selectedPersonToReassign).length;
if (numberOfChanges > 0) {
try {
for (const [index, peopleWithFace] of peopleWithFaces.entries()) {
const personId = selectedPersonToReassign[index]?.id;
for (const personWithFace of peopleWithFaces) {
const personId = selectedPersonToReassign[personWithFace.id]?.id;
if (personId) {
await reassignFacesById({
id: personId,
faceDto: { id: peopleWithFace.id },
faceDto: { id: personWithFace.id },
});
} else if (selectedPersonToCreate[index]) {
} else if (selectedPersonToCreate[personWithFace.id]) {
const data = await createPerson({ personCreateDto: {} });
numberOfPersonToCreate.push(data.id);
peopleToCreate.push(data.id);
await reassignFacesById({
id: data.id,
faceDto: { id: peopleWithFace.id },
faceDto: { id: personWithFace.id },
});
}
}
@@ -141,7 +146,7 @@
}
isShowLoadingDone = false;
if (numberOfPersonToCreate.length === 0) {
if (peopleToCreate.length === 0) {
clearTimeout(loaderLoadingDoneTimeout);
dispatch('refresh');
} else {
@@ -150,23 +155,26 @@
};
const handleCreatePerson = (newFeaturePhoto: string | null) => {
const personToUpdate = peopleWithFaces.find((person) => person.id === peopleWithFaces[editedPersonIndex].id);
const personToUpdate = peopleWithFaces.find((face) => face.person?.id === editedPerson.id);
if (newFeaturePhoto && personToUpdate) {
selectedPersonToCreate[peopleWithFaces.indexOf(personToUpdate)] = newFeaturePhoto;
selectedPersonToCreate[personToUpdate.id] = newFeaturePhoto;
}
showSeletecFaces = false;
};
const handleReassignFace = (person: PersonResponseDto | null) => {
if (person) {
selectedPersonToReassign[editedPersonIndex] = person;
const personToUpdate = peopleWithFaces.find((face) => face.person?.id === editedPerson.id);
if (person && personToUpdate) {
selectedPersonToReassign[personToUpdate.id] = person;
showSeletecFaces = false;
}
};
const handlePersonPicker = (index: number) => {
editedPersonIndex = index;
showSeletecFaces = true;
const handlePersonPicker = (person: PersonResponseDto | null) => {
if (person) {
editedPerson = person;
showSeletecFaces = true;
}
};
</script>
@@ -217,35 +225,48 @@
on:mouseleave={() => ($boundingBoxesArray = [])}
>
<div class="relative">
<ImageThumbnail
curve
shadow
url={selectedPersonToCreate[index] ||
getPeopleThumbnailUrl(selectedPersonToReassign[index]?.id || face.person.id)}
altText={selectedPersonToReassign[index]
? selectedPersonToReassign[index]?.name
: selectedPersonToCreate[index]
? 'New person'
: getPersonNameWithHiddenValue(face.person?.name, face.person?.isHidden)}
title={selectedPersonToReassign[index]
? selectedPersonToReassign[index]?.name
: selectedPersonToCreate[index]
? 'New person'
: getPersonNameWithHiddenValue(face.person?.name, face.person?.isHidden)}
widthStyle="90px"
heightStyle="90px"
thumbhash={null}
hidden={selectedPersonToReassign[index]
? selectedPersonToReassign[index]?.isHidden
: selectedPersonToCreate[index]
? false
: face.person?.isHidden}
/>
{#if selectedPersonToCreate[face.id]}
<ImageThumbnail
curve
shadow
url={selectedPersonToCreate[face.id]}
altText={selectedPersonToCreate[face.id]}
title={'New person'}
widthStyle={thumbnailWidth}
heightStyle={thumbnailWidth}
/>
{:else if selectedPersonToReassign[face.id]}
<ImageThumbnail
curve
shadow
url={getPeopleThumbnailUrl(selectedPersonToReassign[face.id].id)}
altText={selectedPersonToReassign[face.id]?.name || selectedPersonToReassign[face.id].id}
title={getPersonNameWithHiddenValue(
selectedPersonToReassign[face.id].name,
face.person?.isHidden,
)}
widthStyle={thumbnailWidth}
heightStyle={thumbnailWidth}
hidden={selectedPersonToReassign[face.id].isHidden}
/>
{:else}
<ImageThumbnail
curve
shadow
url={getPeopleThumbnailUrl(face.person.id)}
altText={face.person.name || face.person.id}
title={getPersonNameWithHiddenValue(face.person.name, face.person.isHidden)}
widthStyle={thumbnailWidth}
heightStyle={thumbnailWidth}
hidden={face.person.isHidden}
/>
{/if}
</div>
{#if !selectedPersonToCreate[index]}
{#if !selectedPersonToCreate[face.id]}
<p class="relative mt-1 truncate font-medium" title={face.person?.name}>
{#if selectedPersonToReassign[index]?.id}
{selectedPersonToReassign[index]?.name}
{#if selectedPersonToReassign[face.id]?.id}
{selectedPersonToReassign[face.id]?.name}
{:else}
{face.person?.name}
{/if}
@@ -253,8 +274,8 @@
{/if}
<div class="absolute -right-[5px] -top-[5px] h-[20px] w-[20px] rounded-full bg-blue-700">
{#if selectedPersonToCreate[index] || selectedPersonToReassign[index]}
<button on:click={() => handleReset(index)} class="flex h-full w-full">
{#if selectedPersonToCreate[face.id] || selectedPersonToReassign[face.id]}
<button on:click={() => handleReset(face.id)} class="flex h-full w-full">
<div class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
<div>
<Icon path={mdiRestart} size={18} />
@@ -262,7 +283,7 @@
</div>
</button>
{:else}
<button on:click={() => handlePersonPicker(index)} class="flex h-full w-full">
<button on:click={() => handlePersonPicker(face.person)} class="flex h-full w-full">
<div
class="absolute left-1/2 top-1/2 h-[2px] w-[14px] translate-x-[-50%] translate-y-[-50%] transform bg-white"
/>
@@ -282,7 +303,7 @@
<AssignFaceSidePanel
{peopleWithFaces}
{allPeople}
{editedPersonIndex}
{editedPerson}
{assetType}
{assetId}
on:close={() => (showSeletecFaces = false)}
@@ -1,28 +1,54 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import ImmichLogo from './immich-logo.svelte';
export let dropHandler: (event: DragEvent) => void;
import { page } from '$app/stores';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { fileUploadHandler } from '$lib/utils/file-uploader';
$: albumId = ($page.route?.id === '/(user)/albums/[albumId]' || undefined) && $page.params.albumId;
$: isShare = $page.route?.id === '/(user)/share/[key]' || undefined;
let dragStartTarget: EventTarget | null = null;
const handleDragEnter = (e: DragEvent) => {
const onDragEnter = (e: DragEvent) => {
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
dragStartTarget = e.target;
}
};
</script>
<svelte:body
on:dragenter|stopPropagation|preventDefault={handleDragEnter}
on:dragleave|stopPropagation|preventDefault={(e) => {
const onDragLeave = (e: DragEvent) => {
if (dragStartTarget === e.target) {
dragStartTarget = null;
}
}}
on:drop|stopPropagation|preventDefault={(e) => {
};
const onDrop = async (e: DragEvent) => {
dragStartTarget = null;
dropHandler(e);
}}
await handleFiles(e.dataTransfer?.files);
};
const onPaste = ({ clipboardData }: ClipboardEvent) => handleFiles(clipboardData?.files);
const handleFiles = async (files?: FileList) => {
if (!files) {
return;
}
const filesArray: File[] = Array.from<File>(files);
if (isShare) {
dragAndDropFilesStore.set({ isDragging: true, files: filesArray });
} else {
await fileUploadHandler(filesArray, albumId);
}
};
</script>
<svelte:window on:paste={onPaste} />
<svelte:body
on:dragenter|stopPropagation|preventDefault={onDragEnter}
on:dragleave|stopPropagation|preventDefault={onDragLeave}
on:drop|stopPropagation|preventDefault={onDrop}
/>
{#if dragStartTarget}
@@ -11,7 +11,7 @@
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
import { handlePromiseError } from '$lib/utils';
import { shortcut } from '$lib/utils/shortcut';
import { shortcuts } from '$lib/utils/shortcut';
import { focusOutside } from '$lib/utils/focus-outside';
export let value = '';
@@ -87,12 +87,11 @@
</script>
<svelte:window
use:shortcut={{
shortcut: { key: 'Escape' },
onShortcut: () => {
onFocusOut();
},
}}
use:shortcuts={[
{ shortcut: { key: 'Escape' }, onShortcut: onFocusOut },
{ shortcut: { ctrl: true, key: 'k' }, onShortcut: () => input.focus() },
{ shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick },
]}
/>
<div class="w-full relative" use:clickOutside={{ onOutclick: onFocusOut }} use:focusOutside={{ onFocusOut }}>
@@ -130,12 +129,10 @@
on:click={onFocusIn}
on:focus={onFocusIn}
disabled={showFilter}
use:shortcut={{
shortcut: { key: 'Escape' },
onShortcut: () => {
onFocusOut();
},
}}
use:shortcuts={[
{ shortcut: { key: 'Escape' }, onShortcut: onFocusOut },
{ shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick },
]}
/>
<div class="absolute inset-y-0 {showClearIcon ? 'right-14' : 'right-5'} flex items-center pl-6 transition-all">
@@ -20,7 +20,8 @@
general: [
{ key: ['←', '→'], action: 'Previous or next photo' },
{ key: ['Esc'], action: 'Back, close, or deselect' },
{ key: ['/'], action: 'Search your photos' },
{ key: ['Ctrl', 'k'], action: 'Search your photos' },
{ key: ['Ctrl', '⇧', 'k'], action: 'Open the search filters' },
],
actions: [
{ key: ['f'], action: 'Favorite or unfavorite photo' },
@@ -40,7 +41,7 @@
<FullScreenModal onClose={() => dispatch('close')}>
<div class="flex h-full w-full place-content-center place-items-center overflow-hidden">
<div
class="w-[400px] max-w-[125vw] rounded-3xl border bg-immich-bg shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg md:w-[650px]"
class="rounded-3xl border bg-immich-bg shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>
<div class="relative px-4 pt-4">
<h1 class="px-4 py-4 font-medium text-immich-primary dark:text-immich-dark-primary">Keyboard Shortcuts</h1>
@@ -54,7 +55,7 @@
<h2>General</h2>
<div class="text-sm">
{#each shortcuts.general as shortcut}
<div class="grid grid-cols-[20%_80%] items-center gap-4 pt-4 text-sm">
<div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
<div class="flex justify-self-end">
{#each shortcut.key as key}
<p
@@ -74,7 +75,7 @@
<h2>Actions</h2>
<div class="text-sm">
{#each shortcuts.actions as shortcut}
<div class="grid grid-cols-[20%_80%] items-center gap-4 pt-4 text-sm">
<div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
<div class="flex justify-self-end">
{#each shortcut.key as key}
<p
@@ -15,7 +15,6 @@
mdiMagnify,
mdiMap,
mdiTrashCanOutline,
mdiTuneVariant,
} from '@mdi/js';
import LoadingSpinner from '../loading-spinner.svelte';
import StatusBox from '../status-box.svelte';
@@ -146,12 +145,6 @@
</svelte:fragment>
</SideBarLink>
{/if}
<div class="text-xs transition-all duration-200 dark:text-immich-dark-fg">
<p class="hidden p-6 group-hover:sm:block md:block">AUTOMATION</p>
<hr class="mx-4 mb-[31px] mt-8 block group-hover:sm:hidden md:hidden" />
</div>
<SideBarLink title="Workflows" routeId="/(user)/workflows" icon={mdiTuneVariant} />
</nav>
<!-- Status Box -->
@@ -1,15 +0,0 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { mdiPencil, mdiTrashCan } from '@mdi/js';
</script>
<div
class="bg-zinc-300 dark:bg-zinc-800 text-black dark:text-white min-h-[60px] grid grid-cols-[15%_70%_15%] place-items-center place-content-center rounded-2xl mx-4 mt-2 p-2"
>
<p class="col-start-2 col-span-1">Add to album "RANDOM"</p>
<div class="col-start-3 col-span-1 flex gap-2 justify-self-end">
<CircleIconButton size="20" padding="2" title="Remove rule" icon={mdiTrashCan} />
<CircleIconButton size="20" padding="2" title="Edit rule" icon={mdiPencil} />
</div>
</div>
@@ -1,15 +0,0 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { mdiPencil, mdiTrashCan } from '@mdi/js';
</script>
<div
class="bg-zinc-200 text-black dark:bg-zinc-700 dark:text-white min-h-[60px] grid grid-cols-[15%_70%_15%] place-items-center place-content-center rounded-2xl mx-4 mt-2 p-2"
>
<p class="col-start-2 col-span-1">And has Alex and Henry and Nate</p>
<div class="col-start-3 col-span-1 flex gap-2 justify-self-end">
<CircleIconButton size="20" padding="2" title="Remove rule" icon={mdiTrashCan} />
<CircleIconButton size="20" padding="2" title="Edit rule" icon={mdiPencil} />
</div>
</div>
@@ -1,5 +0,0 @@
<div
class="bg-immich-primary text-white dark:bg-immich-dark-primary dark:text-black min-h-[60px] flex place-items-center place-content-center mx-4 mt-2 rounded-2xl"
>
When an asset is uploaded
</div>
@@ -1,10 +0,0 @@
<script lang="ts">
// export let onSelect: () => void;
</script>
<div
class="rounded-3xl mr-2 my-4 min-h-[60px] grid grid-cols-[32px_1fr] gap-2 place-items-center p-4 place-content-center bg-gray-100 hover:bg-gray-200 dark:bg-gray-900 hover:dark:bg-gray-800"
>
<div class="w-4 h-4 rounded-full bg-green-400"></div>
<p class="dark:text-gray-300">Add this photo to every albums and notify everybody about this glorious asset</p>
</div>
@@ -1,82 +0,0 @@
<script lang="ts">
import Button from '$lib/components/elements/buttons/button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import WorkflowActionCard from '$lib/components/workflow-page/editor/workflow-action-card.svelte';
import WorkflowRuleCard from '$lib/components/workflow-page/editor/workflow-rule-card.svelte';
import WorkflowTriggerCard from '$lib/components/workflow-page/editor/workflow-trigger-card.svelte';
import { mdiPlus } from '@mdi/js';
</script>
<section class="h-full overflow-scroll">
<div
id="workflow-control-bar"
class="sticky top-0 flex justify-between place-items-center border-b border-gray-200 dark:border-gray-800 p-4 bg-zinc-50 dark:bg-zinc-900 z-20"
>
<p class="uppercase text-lg dark:text-white font-medium">
Add this photo to every albums and notify everybody about this glorious asset
</p>
<div class="flex gap-2">
<Button size="sm" color="red">Discard</Button>
<Button size="sm">Disable</Button>
<Button size="sm" color="green">Save</Button>
</div>
</div>
<div id="workflows-selection">
<!-- TRIGGER BLOCK -->
<div class="translate-y-3">
<p class="pl-4 text-xs dark:text-gray-300">TRIGGER</p>
<WorkflowTriggerCard />
</div>
<!-- VISUAL CONNECTOR -->
<div class="relative w-full grid grid-cols-3 place-items-center place-content-center z-10">
<p class="col-start-1 col-span-1 justify-self-start self-end pl-4 pb-6 text-xs dark:text-gray-300">RULES</p>
<div class="col-start-2 col-span-1 flex flex-col place-items-center">
<div
class="rounded-full border-[6px] border-immich-primary dark:border-immich-dark-primary h-[20px] w-[20px] bg-white translate-y-1"
></div>
<div
class="h-[60px] w-[5px] bg-white bg-gradient-to-b from-immich-primary dark:from-immich-dark-primary via-zinc-300 dark:via-gray-600 dark:to-zinc-700"
></div>
<div
class="rounded-full border-[6px] border-zinc-200 dark:border-gray-700 h-[20px] w-[20px] bg-white -translate-y-1"
></div>
</div>
</div>
<!-- RULES BLOCK -->
<div id="rule-block" class="-translate-y-6">
<WorkflowRuleCard />
<WorkflowRuleCard />
<div
class="border-2 dark:border-gray-700 dark:text-white min-h-[60px] flex place-items-center place-content-center rounded-2xl mx-4 mt-2"
>
<span><Icon path={mdiPlus} /></span> ADD RULE
</div>
</div>
<!-- VISUAL CONNECTOR -->
<div class="relative w-full grid grid-cols-3 place-items-center place-content-center -translate-y-9 z-10">
<p class="col-start-1 col-span-1 justify-self-start self-end pl-4 pb-6 text-xs dark:text-gray-300">ACTIONS</p>
<div class="col-start-2 col-span-1 flex flex-col place-items-center">
<div class="rounded-full border-[6px] border-gray-900 h-[20px] w-[20px] bg-white translate-y-1"></div>
<div class="h-[60px] w-[5px] bg-white bg-gradient-to-b from-gray-900 via-indigo-800 to-gray-700"></div>
<div class="rounded-full border-[6px] border-gray-700 h-[20px] w-[20px] bg-white -translate-y-1"></div>
</div>
</div>
<!-- ACTION BLOCK -->
<div id="action-block" class="-translate-y-14">
<WorkflowActionCard />
<WorkflowActionCard />
<div
class="border-2 dark:border-gray-500 dark:text-white min-h-[60px] flex place-items-center place-content-center rounded-2xl mx-4 mt-2"
>
<span><Icon path={mdiPlus} /></span> ADD ACTION
</div>
</div>
</div>
</section>
@@ -1,17 +0,0 @@
<script lang="ts">
import Button from '$lib/components/elements/buttons/button.svelte';
import WorkflowCard from '$lib/components/workflow-page/workflow-card.svelte';
</script>
<section id="workflow-list" class="border-r border-gray-200 dark:border-gray-800 h-full relative overflow-scroll pr-2">
<div class="sticky top-0 dark:bg-immich-dark-bg flex justify-between place-items-center pr-2 py-4 bg-immich-bg">
<p class="text-xs dark:text-white">CURRENT WORKFLOWS</p>
<Button size="sm">New Workflow</Button>
</div>
<div>
{#each Array.from({ length: 50 }) as _}
<WorkflowCard />
{/each}
</div>
</section>
+2 -25
View File
@@ -1,29 +1,6 @@
<script lang="ts">
import { page } from '$app/stores';
import UploadCover from '$lib/components/shared-components/drag-and-drop-upload-overlay.svelte';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { fileUploadHandler } from '$lib/utils/file-uploader';
let albumId: string | undefined;
const dropHandler = async ({ dataTransfer }: DragEvent) => {
const files = dataTransfer?.files;
if (!files) {
return;
}
const filesArray: File[] = Array.from<File>(files);
albumId = ($page.route.id === '/(user)/albums/[albumId]' || undefined) && $page.params.albumId;
const isShare = $page.route.id === '/(user)/share/[key]' || undefined;
if (isShare) {
dragAndDropFilesStore.set({ isDragging: true, files: filesArray });
} else {
await fileUploadHandler(filesArray, albumId);
}
};
</script>
<slot {albumId} />
<UploadCover {dropHandler} />
<slot />
<UploadCover />
@@ -1,15 +0,0 @@
<script lang="ts">
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import WorkflowEditor from '$lib/components/workflow-page/workflow-editor.svelte';
import WorkflowList from '$lib/components/workflow-page/workflow-list.svelte';
import type { PageData } from './$types';
export let data: PageData;
</script>
<UserPageLayout title={data.meta.title} scrollbar={false}>
<section class="grid grid-cols-[25%_1fr] h-full">
<WorkflowList />
<WorkflowEditor />
</section>
</UserPageLayout>
-12
View File
@@ -1,12 +0,0 @@
import { authenticate } from '$lib/utils/auth';
import type { PageLoad } from './$types';
export const load = (async () => {
await authenticate();
return {
meta: {
title: 'Workflows',
},
};
}) satisfies PageLoad;
+1 -2
View File
@@ -19,7 +19,6 @@
import '../app.css';
let showNavigationLoadingBar = false;
let albumId: string | undefined;
const isSharedLinkRoute = (route: string | null) => route?.startsWith('/(user)/share/[key]');
@@ -111,7 +110,7 @@
</FullscreenContainer>
</noscript>
<slot {albumId} />
<slot />
{#if showNavigationLoadingBar}
<NavigationLoadingBar />