Compare commits

...

4 Commits

Author SHA1 Message Date
Daniel Dietzler 6bc445a03b chore: move workflows components to different folder 2026-05-28 12:23:27 +02:00
Min Idzelis 1bd367bd51 refactor(web): replace per-asset viewport proximity with day-tier active indices (#28597) 2026-05-28 11:44:18 +02:00
Daniel Dietzler 725f266b81 chore: migrate more make targets to mise (#28651) 2026-05-28 11:31:02 +02:00
Daniel Dietzler d08e3de207 fix: e2e linting (#28659) 2026-05-28 11:12:26 +02:00
15 changed files with 138 additions and 72 deletions
+11 -11
View File
@@ -1,39 +1,39 @@
dev:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
@printf "This command has been removed. Please use:\n\n mise dev # or mise //:dev from another directory\n\n"\n\n >&2 && exit 1
dev-down:
docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans
@printf "This command has been removed. Please use:\n\n mise dev-down # or mise //:dev-down from another directory\n\n"\n\n >&2 && exit 1
dev-update:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
@printf "This command has been removed. Please use:\n\n mise dev-update # or mise //:dev-update from another directory\n\n"\n\n >&2 && exit 1
dev-scale:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
@printf "This command has been removed. Please use:\n\n mise dev-scale # or mise //:dev-scale from another directory\n\n"\n\n >&2 && exit 1
dev-docs:
npm --prefix docs run start
.PHONY: e2e
e2e:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans
@printf "This command has been removed. Please use:\n\n mise e2e # or mise //:e2e from another directory\n\n"\n\n >&2 && exit 1
e2e-dev:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.dev.yml up --remove-orphans
@printf "This command has been removed. Please use:\n\n mise e2e-dev # or mise //:e2e-dev from another directory\n\n"\n\n >&2 && exit 1
e2e-update:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
@printf "This command has been removed. Please use:\n\n mise e2e-update # or mise //:e2e-update from another directory\n\n"\n\n >&2 && exit 1
e2e-down:
docker compose -f ./e2e/docker-compose.yml down --remove-orphans
@printf "This command has been removed. Please use:\n\n mise e2e-down # or mise //:e2e-down from another directory\n\n"\n\n >&2 && exit 1
prod:
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
@printf "This command has been removed. Please use:\n\n mise prod # or mise //:prod from another directory\n\n"\n\n >&2 && exit 1
prod-down:
docker compose -f ./docker/docker-compose.prod.yml down --remove-orphans
@printf "This command has been removed. Please use:\n\n mise prod-down # or mise //:prod-down from another directory\n\n"\n\n >&2 && exit 1
prod-scale:
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
@printf "This command has been removed. Please use:\n\n mise prod-scale # or mise //:prod-scale from another directory\n\n"\n\n >&2 && exit 1
.PHONY: open-api
open-api:
@@ -55,8 +55,8 @@ export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetRe
result.duration.push(asset.duration);
result.projectionType.push(asset.projectionType);
result.livePhotoVideoId.push(asset.livePhotoVideoId);
result.city.push(asset.city);
result.country.push(asset.country);
result.city?.push(asset.city);
result.country?.push(asset.country);
result.visibility.push(asset.visibility);
}
+66
View File
@@ -84,6 +84,72 @@ run = [
dir = "server"
run = "node ./dist/bin/sync-sql.js"
# TODO dev, prod, and e2e should be de-duplicated by using env but for some reason I ran into issues
[tasks.dev]
depends = "//:plugins"
dir = "docker"
interactive = true
env = { COMPOSE_BAKE = true }
run = "docker compose -f ./docker-compose.dev.yml up --remove-orphans"
depends_post = "//:dev-down"
[tasks.dev-update]
run = { task = "//:dev", args = ["--build", "-V"] }
[tasks.dev-scale]
run = { task = "//:dev", args = ["--build", "-V", "--scale immich-server=3"] }
[tasks.dev-down]
dir = "docker"
run = "docker compose -f ./docker-compose.dev.yml down --remove-orphans"
[tasks.prod]
depends = "//:plugins"
dir = "docker"
interactive = true
env = { COMPOSE_BAKE = true }
run = "docker compose -f ./docker-compose.prod.yml up --remove-orphans"
depends_post = "//:prod-down"
[tasks.prod-scale]
run = { task = "//:prod", args = [
"--build",
"-V",
"--scale immich-server=3",
"--scale immich-microservices",
] }
[tasks.prod-down]
dir = "docker"
run = "docker compose -f ./docker-compose.prod.yml down --remove-orphans"
[tasks.e2e]
depends = "//:plugins"
dir = "e2e"
interactive = true
env = { COMPOSE_BAKE = true }
run = "docker compose -f ./docker-compose.yml up --remove-orphans"
depends_post = "//:e2e-down"
[tasks.e2e-dev]
depends = "//:plugins"
dir = "e2e"
interactive = true
env = { COMPOSE_BAKE = true }
run = "docker compose -f ./docker-compose.dev.yml up --remove-orphans"
depends_post = "//:e2e-dev-down"
[tasks.e2e-update]
run = { task = "//:e2e", args = ["--build", '-V'] }
[tasks.e2e-down]
dir = "e2e"
run = "docker compose -f ./docker-compose.yml down --remove-orphans"
[tasks.e2e-dev-down]
dir = "e2e"
run = "docker compose -f ./docker-compose.dev.yml down --remove-orphans"
# SDK tasks
[tasks."sdk:install"]
dir = "packages/sdk"
@@ -30,14 +30,11 @@
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const firstInOrNearViewport = $derived(viewerAssets.findIndex((a) => a.isInOrNearViewport));
const lastInOrNearViewport = $derived(viewerAssets.findLastIndex((a) => a.isInOrNearViewport));
</script>
<!-- Image grid -->
<div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}>
{#each viewerAssets.slice(firstInOrNearViewport, lastInOrNearViewport + 1) as viewerAsset (viewerAsset.id)}
{#each viewerAssets as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}
+1 -1
View File
@@ -100,7 +100,7 @@
<AssetLayout
{manager}
viewerAssets={timelineDay.viewerAssets}
viewerAssets={timelineDay.activeViewerAssets}
height={timelineDay.height}
width={timelineDay.width}
{customThumbnailLayout}
@@ -53,17 +53,3 @@ export function updateTimelineMonthViewportProximity(timelineManager: TimelineMa
timelineManager.clearDeferredLayout(month);
}
}
export function calculateViewerAssetViewportProximity(
timelineManager: TimelineManager,
positionTop: number,
positionHeight: number,
) {
const headerHeight = timelineManager.headerHeight;
return calculateViewportProximity(
positionTop,
positionTop + positionHeight,
timelineManager.visibleWindow.top - headerHeight,
timelineManager.visibleWindow.bottom + headerHeight,
);
}
@@ -1,12 +1,31 @@
import { AssetOrder, AssetOrderBy } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity';
import type { CommonLayoutOptions } from '$lib/utils/layout-utils';
import type { CommonLayoutOptions, CommonPosition } from '$lib/utils/layout-utils';
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
import { getOrderingDate, plainDateTimeCompare } from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables';
import type { TimelineMonth } from './timeline-month.svelte';
import type { Direction, MoveAsset, TimelineAsset } from './types';
import { ViewerAsset } from './viewer-asset.svelte';
const {
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
} = TUNABLES;
function lowerBound(assets: ViewerAsset[], target: number, key: (pos: CommonPosition) => number): number {
let lo = 0;
let hi = assets.length;
while (lo < hi) {
const mid = Math.floor((lo + hi) / 2);
if (key(assets[mid].position!) < target) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo;
}
export class TimelineDay {
readonly timelineMonth: TimelineMonth;
readonly index: number;
@@ -18,12 +37,15 @@ export class TimelineDay {
height = $state(0);
width = $state(0);
// Assets in or near the viewport; active assets should be added to the DOM.
activeViewerAssets: ViewerAsset[] = $state([]);
isInOrNearViewport = $state(false);
#top: number = $state(0);
#start: number = $state(0);
#row = $state(0);
#col = $state(0);
#deferredLayout = false;
#lastInOrNearViewport = -1;
constructor(timelineMonth: TimelineMonth, index: number, day: number, groupTitle: string, orderBy: AssetOrderBy) {
this.index = index;
@@ -149,18 +171,32 @@ export class TimelineDay {
for (let i = 0; i < this.viewerAssets.length; i++) {
this.viewerAssets[i].position = geometry.getPosition(i);
}
this.updateAssetBoundaries();
}
updateAssetBoundaries() {
const manager = this.timelineMonth.timelineManager;
const visibleWindow = manager.visibleWindow;
if (this.viewerAssets.length === 0 || !this.viewerAssets[0].position) {
this.activeViewerAssets = [];
this.isInOrNearViewport = false;
return;
}
const dayOffset = this.absoluteTimelineDayTop;
const headerHeight = manager.headerHeight;
const expandedTop = visibleWindow.top - headerHeight - INTERSECTION_EXPAND_TOP - dayOffset;
const expandedBottom = visibleWindow.bottom + headerHeight + INTERSECTION_EXPAND_BOTTOM - dayOffset;
const first = lowerBound(this.viewerAssets, expandedTop, (p) => p.top + p.height);
const last = lowerBound(this.viewerAssets, expandedBottom, (p) => p.top) - 1;
const hasActive = last >= first && first < this.viewerAssets.length;
this.activeViewerAssets = hasActive ? this.viewerAssets.slice(first, last + 1) : [];
this.isInOrNearViewport = hasActive;
}
get absoluteTimelineDayTop() {
return this.timelineMonth.top + this.#top;
}
get isInOrNearViewport() {
if (this.#lastInOrNearViewport !== -1 && this.viewerAssets[this.#lastInOrNearViewport].isInOrNearViewport) {
return true;
}
this.#lastInOrNearViewport = this.viewerAssets.findIndex((viewAsset) => viewAsset.isInOrNearViewport);
return this.#lastInOrNearViewport !== -1;
}
}
@@ -214,6 +214,11 @@ export class TimelineManager extends VirtualScrollManager {
for (const month of this.months) {
updateTimelineMonthViewportProximity(this, month);
if (month.isInOrNearViewport && month.isLoaded) {
for (const day of month.timelineDays) {
day.updateAssetBoundaries();
}
}
}
const month = this.months.find((month) => month.isInViewport);
@@ -254,7 +254,7 @@ export class TimelineMonth {
addContext.newTimelineDays.add(timelineDay);
}
const viewerAsset = new ViewerAsset(timelineDay, timelineAsset);
const viewerAsset = new ViewerAsset(timelineAsset);
timelineDay.viewerAssets.push(viewerAsset);
addContext.changedTimelineDays.add(timelineDay);
}
@@ -1,36 +1,12 @@
import type { CommonPosition } from '$lib/utils/layout-utils';
import {
ViewportProximity,
calculateViewerAssetViewportProximity,
isInOrNearViewport,
} from './internal/intersection-support.svelte';
import type { TimelineDay } from './timeline-day.svelte';
import type { TimelineAsset } from './types';
export class ViewerAsset {
readonly #group: TimelineDay;
#viewportProximity = $derived.by(() => {
if (!this.position) {
return ViewportProximity.FarFromViewport;
}
const store = this.#group.timelineMonth.timelineManager;
const positionTop = this.#group.absoluteTimelineDayTop + this.position.top;
return calculateViewerAssetViewportProximity(store, positionTop, this.position.height);
});
get isInOrNearViewport() {
return isInOrNearViewport(this.#viewportProximity);
}
position: CommonPosition | undefined = $state.raw();
asset: TimelineAsset = $state() as TimelineAsset;
id: string = $derived(this.asset.id);
constructor(group: TimelineDay, asset: TimelineAsset) {
this.#group = group;
constructor(asset: TimelineAsset) {
this.asset = asset;
}
}
@@ -45,9 +45,9 @@
import { cloneDeep, isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import WorkflowJsonEditor from './WorkflowJsonEditor.svelte';
import WorkflowStepCard from './WorkflowStepCard.svelte';
import WorkflowSummary from './WorkflowSummary.svelte';
import WorkflowStepCard from '$lib/components/workflows/WorkflowStepCard.svelte';
import WorkflowJsonEditor from '$lib/components/workflows/WorkflowJsonEditor.svelte';
import WorkflowSummary from '$lib/components/workflows/WorkflowSummary.svelte';
type WorkflowJsonContent = Required<
Pick<WorkflowUpdateDto, 'description' | 'enabled' | 'name' | 'steps' | 'trigger'>