Parcourir la source

Merge 975040afe968eb8d7e00ebfecf2eb7eb31a5720b into e1739ac4fc7301ee3c5f6d9dc8d10e4e828e03af

faupau il y a 1 an
Parent
commit
4d7422b595

BIN
docker/library/photos/library/admin/2023/2023-07-16/test(3).png


BIN
docker/library/photos/thumbs/23f2cd16-56af-4d27-95c9-9094f3d4c26a/b1/b8/b1b82ac7-e1c8-4261-8bcf-b188888c4a12.jpeg


BIN
docker/library/photos/thumbs/23f2cd16-56af-4d27-95c9-9094f3d4c26a/b1/b8/b1b82ac7-e1c8-4261-8bcf-b188888c4a12.webp


+ 6 - 0
web/package-lock.json

@@ -13,6 +13,7 @@
         "@zoom-image/svelte": "^0.2.0",
         "axios": "^0.27.2",
         "buffer": "^6.0.3",
+        "context-filter-polyfill": "^0.3.6",
         "copy-image-clipboard": "^2.1.2",
         "dom-to-image": "^2.6.0",
         "handlebars": "^4.7.7",
@@ -4813,6 +4814,11 @@
       "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
       "dev": true
     },
+    "node_modules/context-filter-polyfill": {
+      "version": "0.3.6",
+      "resolved": "https://registry.npmjs.org/context-filter-polyfill/-/context-filter-polyfill-0.3.6.tgz",
+      "integrity": "sha512-NV2/op48+p+v4eueErpUszzrURW0QPvvvUQqvuA8V20e69cCP1m8R4N43iECASbWvbim+EFwruX6bPil6s070g=="
+    },
     "node_modules/convert-source-map": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",

+ 1 - 0
web/package.json

@@ -62,6 +62,7 @@
     "@zoom-image/svelte": "^0.2.0",
     "axios": "^0.27.2",
     "buffer": "^6.0.3",
+    "context-filter-polyfill": "^0.3.6",
     "copy-image-clipboard": "^2.1.2",
     "dom-to-image": "^2.6.0",
     "handlebars": "^4.7.7",

+ 6 - 0
web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte

@@ -19,6 +19,7 @@
     mdiMagnifyPlusOutline,
     mdiMotionPauseOutline,
     mdiPlaySpeed,
+    mdiTune,
   } from '@mdi/js';
   import { createEventDispatcher } from 'svelte';
   import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
@@ -30,6 +31,7 @@
   export let showMotionPlayButton: boolean;
   export let isMotionPhotoPlaying = false;
   export let showDownloadButton: boolean;
+  export let showEditButton = true;
   export let showDetailButton: boolean;
   export let showSlideshow = false;
   export let hasStackChildren = false;
@@ -53,6 +55,7 @@
     runJob: AssetJobName;
     playSlideShow: void;
     unstack: void;
+    edit: void;
   }>();
 
   let contextMenuPosition = { x: 0, y: 0 };
@@ -81,6 +84,9 @@
     <CircleIconButton isOpacity={true} icon={mdiArrowLeft} on:click={() => dispatch('goBack')} />
   </div>
   <div class="flex w-[calc(100%-3rem)] justify-end gap-2 overflow-hidden text-white">
+    {#if showEditButton}
+      <CircleIconButton isOpacity={true} icon={mdiTune} title="Edit" on:click={() => dispatch('edit')} />
+    {/if}
     {#if asset.isOffline}
       <CircleIconButton
         isOpacity={true}

+ 9 - 1
web/src/lib/components/asset-viewer/asset-viewer.svelte

@@ -26,6 +26,7 @@
   import { addAssetsToAlbum, downloadFile, getAssetType } from '$lib/utils/asset-utils';
   import NavigationArea from './navigation-area.svelte';
   import { browser } from '$app/environment';
+  import PhotoEditor from './photo-editor/photo-editor.svelte';
   import { handleError } from '$lib/utils/handle-error';
   import type { AssetStore } from '$lib/stores/assets.store';
   import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
@@ -174,6 +175,8 @@
       getNumberOfComments();
     }
   }
+  let shouldShowPhotoEditor = false;
+
   const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo);
 
   onMount(async () => {
@@ -475,6 +478,7 @@
     }
   };
 
+  $: console.log(shouldShowPhotoEditor);
   const handleRunJob = async (name: AssetJobName) => {
     try {
       await api.assetApi.runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } });
@@ -587,8 +591,9 @@
         on:toggleArchive={toggleArchive}
         on:asProfileImage={() => (isShowProfileImageCrop = true)}
         on:runJob={({ detail: job }) => handleRunJob(job)}
-        on:playSlideShow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
+        on:playSlideShow={handlePlaySlideshow}
         on:unstack={handleUnstack}
+        on:edit={() => (shouldShowPhotoEditor = true)}
       />
     </div>
   {/if}
@@ -783,6 +788,9 @@
     </ConfirmDialogue>
   {/if}
 
+  {#if shouldShowPhotoEditor}
+    <PhotoEditor {asset} on:close={() => (shouldShowPhotoEditor = false)} />
+  {/if}
   {#if isShowProfileImageCrop}
     <ProfileImageCropper
       {asset}

+ 239 - 0
web/src/lib/components/asset-viewer/photo-editor/adjust-element.svelte

@@ -0,0 +1,239 @@
+<script lang="ts">
+  import { onMount, createEventDispatcher } from 'svelte';
+
+  export let title = 'Free';
+
+  export let type = false; // false = range from 0 to 100, true = range from 0 to 200 with default value of 100
+
+  export let value = 0;
+
+  let dispatch = createEventDispatcher();
+  let progressBar: HTMLDivElement;
+
+  let rangeValue = 0;
+
+  $: if (type) {
+    rangeValue = (value / 2) * 100;
+  } else {
+    rangeValue = value * 100;
+  }
+
+  onMount(() => {
+    renderProgress();
+  });
+
+  const renderProgress = () => {
+    const progress = rangeValue;
+    if (type) {
+      value = (progress * 2) / 100;
+    } else {
+      value = Number(progress) / 100;
+    }
+
+    const progressPercent = (progress / 100) * 100;
+    let progressColor;
+
+    if (type) {
+      if (progress <= 50) {
+        progressColor = `linear-gradient(90deg, #373737 ${progressPercent}%, #adcbfa ${progressPercent}%, #adcbfa 50%,#373737 50%)`;
+      } else {
+        progressColor = `linear-gradient(90deg, #373737 50%, #adcbfa 50%, #adcbfa ${progressPercent}%, #373737 ${progressPercent}%)`;
+      }
+    } else {
+      progressColor = `linear-gradient(90deg, #adcbfa ${progressPercent}%,#373737 ${progressPercent}%)`;
+    }
+    progressBar.style.background = '#373737';
+    progressBar.style.background = progressColor;
+    //console.log(progressColor);
+    dispatch('applyFilter');
+  };
+</script>
+
+<div class="flex w-full text-white">
+  <button
+    class="{(type && value != 1) || (!type && value != 0)
+      ? 'active-edit'
+      : ''} bg-immich-gray/10 hover:bg-immich-gray/20 mr-3 rounded-full p-4 text-2xl"
+    on:click={() => {
+      if (type) {
+        value = 1;
+        rangeValue = 50;
+      } else {
+        value = 0;
+        rangeValue = 0;
+      }
+      renderProgress();
+    }}
+  >
+    <slot />
+  </button>
+  <div class="relative grid w-full">
+    <span>{title}</span>
+    <input bind:value={rangeValue} type="range" name="" id="" on:input={renderProgress} />
+    <div
+      bind:this={progressBar}
+      class="bg-immich-gray/10 progress-bar pointer-events-none absolute bottom-[22px] h-[3px] w-full rounded-full"
+    />
+  </div>
+</div>
+
+<style>
+  .active-edit {
+    background-color: #adcbfa;
+    color: rgb(33, 33, 33);
+  }
+  .active-edit:hover {
+    background-color: #adcbfa;
+  }
+
+  input[type='range'] {
+    font-size: 1.5rem;
+  }
+
+  input[type='range'] {
+    color: #adcbfa;
+    --thumb-height: 12px;
+    --track-height: -1px;
+    --track-color: rgba(246, 246, 244, 0);
+    --brightness-hover: 100%;
+    --brightness-down: 100%;
+    --clip-edges: 0.125em;
+  }
+  /* === range commons === */
+  input[type='range'] {
+    position: relative;
+    background: #fff0;
+    overflow: hidden;
+  }
+
+  input[type='range']:active {
+    cursor: grabbing;
+  }
+
+  input[type='range']:disabled {
+    filter: grayscale(1);
+    opacity: 0.3;
+    cursor: not-allowed;
+  }
+
+  /* === WebKit specific styles === */
+  input[type='range'],
+  input[type='range']::-webkit-slider-runnable-track,
+  input[type='range']::-webkit-slider-thumb {
+    -webkit-appearance: none;
+    transition: all ease 100ms;
+    height: var(--thumb-height);
+  }
+
+  input[type='range']::-webkit-slider-runnable-track,
+  input[type='range']::-webkit-slider-thumb {
+    position: relative;
+  }
+
+  input[type='range']::-webkit-slider-thumb {
+    --thumb-radius: calc((var(--thumb-height) * 0.5) - 1px);
+    --clip-top: calc((var(--thumb-height) - var(--track-height)) * 0.5 - 0.5px);
+    --clip-bottom: calc(var(--thumb-height) - var(--clip-top));
+    --clip-further: calc(100% + 1px);
+    --box-fill: calc(-100vmax - var(--thumb-width, var(--thumb-height))) 0 0 100vmax currentColor;
+
+    width: var(--thumb-width, var(--thumb-height));
+    background: linear-gradient(currentColor 0 0) scroll no-repeat left center / 50% calc(var(--track-height) + 1px);
+    background-color: currentColor;
+    box-shadow: var(--box-fill);
+    border-radius: var(--thumb-width, var(--thumb-height));
+
+    filter: brightness(100%);
+    clip-path: polygon(
+      100% -1px,
+      var(--clip-edges) -1px,
+      0 var(--clip-top),
+      -100vmax var(--clip-top),
+      -100vmax var(--clip-bottom),
+      0 var(--clip-bottom),
+      var(--clip-edges) 100%,
+      var(--clip-further) var(--clip-further)
+    );
+  }
+
+  input[type='range']:hover::-webkit-slider-thumb {
+    filter: brightness(var(--brightness-hover));
+    cursor: grab;
+  }
+
+  input[type='range']:active::-webkit-slider-thumb {
+    filter: brightness(var(--brightness-down));
+    cursor: grabbing;
+  }
+
+  input[type='range']::-webkit-slider-runnable-track {
+    background: linear-gradient(var(--track-color) 0 0) scroll no-repeat center / 100% calc(var(--track-height) + 1px);
+  }
+
+  input[type='range']:disabled::-webkit-slider-thumb {
+    cursor: not-allowed;
+  }
+
+  /* === Firefox specific styles === */
+  input[type='range'],
+  input[type='range']::-moz-range-track,
+  input[type='range']::-moz-range-thumb {
+    appearance: none;
+    transition: all ease 100ms;
+    height: var(--thumb-height);
+  }
+
+  input[type='range']::-moz-range-track,
+  input[type='range']::-moz-range-thumb,
+  input[type='range']::-moz-range-progress {
+    background: #fff0;
+  }
+
+  input[type='range']::-moz-range-thumb {
+    background: currentColor;
+    border: 0;
+    width: var(--thumb-width, var(--thumb-height));
+    border-radius: var(--thumb-width, var(--thumb-height));
+    cursor: grab;
+  }
+
+  input[type='range']:active::-moz-range-thumb {
+    cursor: grabbing;
+  }
+
+  input[type='range']::-moz-range-track {
+    width: 100%;
+    background: var(--track-color);
+  }
+
+  input[type='range']::-moz-range-progress {
+    appearance: none;
+    background: currentColor;
+    transition-delay: 30ms;
+  }
+
+  input[type='range']::-moz-range-track,
+  input[type='range']::-moz-range-progress {
+    height: calc(var(--track-height) + 1px);
+    border-radius: var(--track-height);
+  }
+
+  input[type='range']::-moz-range-thumb,
+  input[type='range']::-moz-range-progress {
+    filter: brightness(100%);
+  }
+
+  input[type='range']:hover::-moz-range-thumb,
+  input[type='range']:hover::-moz-range-progress {
+    filter: brightness(var(--brightness-hover));
+  }
+
+  input[type='range']:active::-moz-range-thumb,
+  input[type='range']:active::-moz-range-progress {
+    filter: brightness(var(--brightness-down));
+  }
+
+  input[type='range']:disabled::-moz-range-thumb {
+    cursor: not-allowed;
+  }
+</style>

+ 25 - 0
web/src/lib/components/asset-viewer/photo-editor/aspect-ratio-button.svelte

@@ -0,0 +1,25 @@
+<script lang="ts">
+  export let title: string = 'Free';
+  export let isActive: boolean = false;
+</script>
+
+<div class="flex items-center text-white">
+  <button
+    on:click
+    class:active-edit={isActive}
+    class="rounded-full p-3 mr-3 text-3xl bg-immich-gray/10 hover:bg-immich-gray/20"
+  >
+    <slot />
+  </button>
+  <span>{title}</span>
+</div>
+
+<style>
+  .active-edit {
+    background-color: #adcbfa;
+    color: rgb(33, 33, 33);
+  }
+  .active-edit:hover {
+    background-color: #adcbfa;
+  }
+</style>

+ 80 - 0
web/src/lib/components/asset-viewer/photo-editor/filter-card.svelte

@@ -0,0 +1,80 @@
+<script lang="ts">
+  import { onMount, createEventDispatcher } from 'svelte';
+
+  export let title = 'Without';
+
+  export let currentFilter: string;
+  export let thumbData: string;
+
+  let dispatch = createEventDispatcher();
+
+  let imgElement: HTMLImageElement;
+
+  import Icon from '$lib/components/elements/icon.svelte';
+  import { mdiCheck } from '@mdi/js';
+  import { presets as presetsObject } from './filter';
+
+  type Preset = {
+    blur: number;
+    brightness: number;
+    contrast: number;
+    grayscale: number;
+    hueRotate: number;
+    invert: number;
+    opacity: number;
+    saturation: number;
+    sepia: number;
+  };
+
+  const presets = presetsObject as { [key: string]: Preset };
+
+  onMount(() => {
+    if (title === 'Custom') {
+      return;
+    }
+    const img = new Image();
+    img.src = thumbData;
+    img.onload = () => {
+      imgElement.src = img.src;
+    };
+    const filter = presets[title.toLowerCase()];
+
+    imgElement.style.filter = `blur(${filter.blur * 10}px) brightness(${filter.brightness}) contrast(${
+      filter.contrast
+    }) grayscale(${filter.grayscale}) hue-rotate(${(filter.hueRotate - 1) * 180}deg) invert(${filter.invert}) opacity(${
+      filter.opacity
+    }) saturate(${filter.saturation}) sepia(${filter.sepia})`;
+  });
+</script>
+
+<button
+  class=" text-immich-gray/70 w-fit text-center text-sm {title.toLowerCase() === currentFilter ? 'isActive' : ''}"
+  on:click={() => {
+    if (title === 'Custom') {
+      return;
+    }
+    dispatch('setPreset', title.toLowerCase());
+  }}
+>
+  <div
+    class="bg-immich-primary flex h-[92px] w-[92px] items-center justify-center text-3xl {title.toLowerCase() ===
+    currentFilter
+      ? ''
+      : 'hidden'}"
+  >
+    <Icon path={mdiCheck} />
+  </div>
+  <img
+    bind:this={imgElement}
+    class="bg-immich-gray/10 h-[92px] w-[92px] {title.toLowerCase() === currentFilter ? 'hidden' : ''}"
+    src=""
+    alt="asset preview"
+  />
+  <div class="my-[4px]">{title}</div>
+</button>
+
+<style>
+  .isActive {
+    color: white;
+  }
+</style>

+ 222 - 0
web/src/lib/components/asset-viewer/photo-editor/filter.js

@@ -0,0 +1,222 @@
+export const presets = {
+  without: {
+    brightness: 1,
+    contrast: 1,
+    saturation: 1,
+    hueRotate: 1,
+    sepia: 0,
+    blur: 0, // Default blur value
+    grayscale: 0, // Default grayscale value
+    invert: 0, // Default invert value
+    opacity: 1, // Default opacity value
+  },
+  vivid: {
+    brightness: 1.1,
+    contrast: 1.1,
+    saturation: 1.1,
+    hueRotate: 1.0333,
+    sepia: 0.05,
+    blur: 0, // Default blur value
+    grayscale: 0, // Default grayscale value
+    invert: 0,
+    opacity: 1, // Default opacity value
+  },
+  playa: {
+    brightness: 1.1,
+    contrast: 1.2,
+    saturation: 1.5,
+    hueRotate: 1.3333,
+    sepia: 0.05,
+    blur: 0, // Default blur value
+    grayscale: 0, // Default grayscale value
+    invert: 0, // Default invert value
+    opacity: 1, // Default opacity value
+  },
+  honey: {
+    brightness: 1.2,
+    contrast: 1.1,
+    saturation: 1.2,
+    hueRotate: 1.5,
+    blur: 0, // Default blur value
+    grayscale: 0, // Default grayscale value
+    invert: 0, // Default invert value
+    opacity: 1, // Default opacity value
+    sepia: 0,
+  },
+  isla: {
+    brightness: 0.9,
+    contrast: 1.3,
+    saturation: 1.2,
+    hueRotate: 1,
+    blur: 0, // Default blur value
+    grayscale: 0, // Default grayscale value
+    invert: 0, // Default invert value
+    opacity: 1, // Default opacity value
+    sepia: 0,
+  },
+  desert: {
+    brightness: 1.2,
+    contrast: 0.9,
+    saturation: 1.4,
+    hueRotate: 0.6667,
+    blur: 0, // Default blur value
+    grayscale: 0, // Default grayscale value
+    invert: 0, // Default invert value
+    opacity: 1, // Default opacity value
+    sepia: 0,
+  },
+  clay: {
+    brightness: 0.8,
+    contrast: 1.4,
+    saturation: 0.9,
+    hueRotate: 0.1667,
+    blur: 0, // Default blur value
+    grayscale: 0, // Default grayscale value
+    invert: 0, // Default invert value
+    opacity: 1, // Default opacity value
+    sepia: 0,
+  },
+  palma: {
+    brightness: 1.1,
+    contrast: 1.2,
+    saturation: 1.2,
+    hueRotate: 0.5,
+    blur: 0, // Default blur value
+    grayscale: 0, // Default grayscale value
+    invert: 0, // Default invert value
+    opacity: 1, // Default opacity value
+    sepia: 0,
+  },
+  blush: {
+    brightness: 1.1,
+    contrast: 1.1,
+    saturation: 1.1,
+    hueRotate: 1.8333,
+    blur: 0, // Default blur value
+    grayscale: 0, // Default grayscale value
+    invert: 0, // Default invert value
+    opacity: 1, // Default opacity value
+    sepia: 0,
+  },
+  alpaca: {
+    brightness: 1.2,
+    contrast: 0.8,
+    saturation: 1.3,
+    hueRotate: 1.25,
+    blur: 0, // Default blur value
+    grayscale: 0, // Default grayscale value
+    invert: 0, // Default invert value
+    opacity: 1, // Default opacity value
+    sepia: 0,
+  },
+  modena: {
+    brightness: 0.9,
+    contrast: 1.2,
+    saturation: 1.1,
+    hueRotate: 0.1111,
+    blur: 0, // Default blur value
+    grayscale: 0, // Default grayscale value
+    invert: 0, // Default invert value
+    opacity: 1, // Default opacity value
+    sepia: 0,
+  },
+  west: {
+    brightness: 1.1,
+    contrast: 1.2,
+    saturation: 0.8,
+    hueRotate: 1.25,
+    blur: 0, // Default blur value
+    grayscale: 0, // Default grayscale value
+    invert: 0, // Default invert value
+    opacity: 1, // Default opacity value
+    sepia: 0,
+  },
+  metro: {
+    brightness: 1.1,
+    contrast: 1.4,
+    saturation: 0.9,
+    hueRotate: 1.0833,
+    blur: 0, // Default blur value
+    grayscale: 0, // Default grayscale value
+    invert: 0, // Default invert value
+    opacity: 1, // Default opacity value
+    sepia: 0,
+  },
+  reel: {
+    brightness: 0.9,
+    contrast: 1.2,
+    saturation: 1.4,
+    hueRotate: 1.1667,
+    blur: 0, // Default blur value
+    grayscale: 0, // Default grayscale value
+    invert: 0, // Default invert value
+    opacity: 1, // Default opacity value
+    sepia: 0,
+  },
+  bazaar: {
+    brightness: 1.2,
+    contrast: 1.1,
+    saturation: 1.1,
+    hueRotate: 1.0833,
+    blur: 0, // Default blur value
+    grayscale: 0, // Default grayscale value
+    invert: 0, // Default invert value
+    opacity: 1,
+    sepia: 0, // Default sepia value
+  },
+  ollie: {
+    brightness: 0.9,
+    contrast: 1.3,
+    saturation: 1.2,
+    hueRotate: 0.75,
+    blur: 0, // Default blur value
+    grayscale: 0, // Default grayscale value
+    invert: 0, // Default invert value
+    opacity: 1, // Default opacity value#
+    sepia: 0,
+  },
+  onyx: {
+    brightness: 1.2,
+    contrast: 0.9,
+    saturation: 1.3,
+    hueRotate: 1.3333,
+    blur: 0, // Default blur value
+    grayscale: 0, // Default grayscale value
+    invert: 0, // Default invert value
+    opacity: 1, // Default opacity value
+    sepia: 0, // Default sepia value
+  },
+  eiffel: {
+    brightness: 1.1,
+    contrast: 1.1,
+    saturation: 0.8,
+    hueRotate: 1.6667,
+    blur: 0, // Default blur value
+    grayscale: 0, // Default grayscale value
+    invert: 0, // Default invert value
+    opacity: 1, // Default opacity value
+    sepia: 1,
+  },
+  vogue: {
+    brightness: 1.1,
+    contrast: 1.2,
+    saturation: 1.1,
+    hueRotate: 1.1667,
+    blur: 0, // Default blur value
+    grayscale: 0, // Default grayscale value
+    invert: 0, // Default invert value
+    opacity: 1, // Default opacity value
+    sepia: 0,
+  },
+  vista: {
+    brightness: 1.2,
+    contrast: 1.4,
+    saturation: 1.2,
+    hueRotate: 0.5,
+    blur: 0, // Default blur value
+    grayscale: 0, // Default grayscale value
+    invert: 0, // Default invert value
+    opacity: 1, // Default opacity value
+    sepia: 0,
+  },
+};

+ 1252 - 0
web/src/lib/components/asset-viewer/photo-editor/photo-editor.svelte

@@ -0,0 +1,1252 @@
+<script lang="ts">
+  import { mdiContrastCircle } from '@mdi/js'; // Contrast
+  import { mdiBrightness6 } from '@mdi/js'; // Brightness
+  import { mdiInvertColors } from '@mdi/js'; // Invert
+  import { mdiBlur } from '@mdi/js'; // Blur
+  import { mdiCircleHalfFull } from '@mdi/js'; // Vignette
+  import { mdiDotsCircle } from '@mdi/js'; // Tilt-shift
+  import { mdiSelectInverse } from '@mdi/js'; // Selective
+  import { mdiPillar } from '@mdi/js'; // Pillarbox
+
+  import { onMount, SvelteComponent } from 'svelte';
+  import { api, AssetResponseDto } from '@api';
+  import { handleError } from '$lib/utils/handle-error';
+
+  import Icon from '$lib/components/elements/icon.svelte';
+
+  import { mdiAutoFix } from '@mdi/js'; // Auto
+  import { mdiImageFilterHdr } from '@mdi/js'; // HDR
+  import { mdiWeatherSunny } from '@mdi/js'; // Exposure
+  import { mdiWeatherCloudy } from '@mdi/js'; // Contrast
+  import { mdiCropRotate } from '@mdi/js'; // Rotate
+  import { mdiTune } from '@mdi/js'; // Adjust
+  import { mdiImageAutoAdjust } from '@mdi/js'; // Auto Adjust
+  import { mdiFullscreen } from '@mdi/js'; // Fullscreen
+  import { mdiRelativeScale } from '@mdi/js'; // Ratio
+  import { mdiRectangleOutline } from '@mdi/js'; // Rectangle
+  import { mdiSquareOutline } from '@mdi/js'; // Square
+
+  import { mdiClose } from '@mdi/js'; // Close
+  import { mdiDotsVertical } from '@mdi/js'; // More
+  import { mdiFlipHorizontal } from '@mdi/js'; // Flip horizontal
+  import { mdiFlipVertical } from '@mdi/js'; // Flip vertical
+  import { mdiFormatRotate90 } from '@mdi/js'; // Rotate
+  import { mdiTriangleSmallUp } from '@mdi/js'; // Triangle
+
+  import SuggestionsButton from './suggestions-button.svelte';
+  import AspectRatioButton from './aspect-ratio-button.svelte';
+  import AdjustElement from './adjust-element.svelte';
+  import FilterCard from './filter-card.svelte';
+
+  import Render from './render.svelte';
+  import { presets as presetsObject } from './filter.js';
+
+  const presets = presetsObject as { [key: string]: Preset };
+
+  type Preset = {
+    blur: number;
+    brightness: number;
+    contrast: number;
+    grayscale: number;
+    hueRotate: number;
+    invert: number;
+    opacity: number;
+    saturation: number;
+    sepia: number;
+  };
+
+  import { createEventDispatcher } from 'svelte';
+  import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
+
+  const dispatch = createEventDispatcher();
+
+  // Image adjustment
+  let filter: Preset = {
+    blur: 0,
+    brightness: 1,
+    contrast: 1,
+    grayscale: 0,
+    hueRotate: 1,
+    invert: 0,
+    opacity: 1,
+    saturation: 1,
+    sepia: 0,
+  };
+
+  // Render
+  let renderedImage: string;
+
+  let renderElement: SvelteComponent;
+  let editorElement: HTMLDivElement;
+  let imageElement: HTMLImageElement;
+
+  let originalImage: HTMLImageElement;
+  let isLoaded = false;
+
+  let imageWrapper: HTMLDivElement;
+  let cropElement: HTMLDivElement;
+  let cropElementWrapper: HTMLDivElement;
+  let assetDragHandle: HTMLDivElement;
+
+  let currentAngle = 0;
+  let currentAngleOffset = 0;
+  let currentAspectRatio: string | aspectRatio = 'original';
+  let currentCrop = { width: 0, height: 0 };
+  let currentFlipY = false;
+  let currentFlipX = false;
+
+  let currentFilter = 'without';
+
+  let currentRatio = 0;
+
+  let currentTranslateDirection: 'x' | 'y' | '' = '';
+  let currentTranslate = { x: 0, y: 0 };
+  let currentZoom = 1;
+
+  const zoomSpeed = 0.1;
+
+  let angleSlider: HTMLElement;
+  let angleSliderHandle: HTMLElement;
+
+  let activeButton: 'autofix' | 'crop' | 'adjust' | 'filter' = 'adjust';
+  type activeEdit = 'optimized' | 'dynamic' | 'warm' | 'cold' | '';
+  let activeEdit: activeEdit;
+
+  let aspectRatioNum = 9 / 16;
+
+  let isRendering = false;
+
+  type aspectRatio =
+    | 'free'
+    | 'square'
+    | 'original'
+    | '16_9'
+    | '9_16'
+    | '5_4'
+    | '4_5'
+    | '4_3'
+    | '3_4'
+    | '3_2'
+    | '2_3'
+    | 'square';
+
+  export let asset: AssetResponseDto;
+  let assetData: string;
+  let thumbData: string;
+  let publicSharedKey = '';
+
+  $: currentCrop = cropElement
+    ? {
+        width: cropElement.offsetWidth,
+        height: cropElement.offsetHeight,
+      }
+    : { width: 0, height: 0 };
+
+  $: currentRatio = originalImage ? originalImage.naturalWidth / imageWrapper.offsetWidth : 0;
+
+  const isFilter = (f: Preset) => {
+    return (
+      f &&
+      f.blur === filter.blur &&
+      f.brightness === filter.brightness &&
+      f.contrast === filter.contrast &&
+      f.grayscale === filter.grayscale &&
+      f.hueRotate === filter.hueRotate &&
+      f.invert === filter.invert &&
+      f.opacity === filter.opacity &&
+      f.saturation === filter.saturation &&
+      f.sepia === filter.sepia
+    );
+  };
+
+  const applyFilter = () => {
+    if (!isFilter(presets[currentFilter] as Preset)) {
+      // console.log(currentFilter);
+      // console.log(presets[currentFilter]);
+      // console.log(filter);
+      currentFilter = 'custom';
+    }
+
+    // console.log('apply filter');
+    // console.log(filter);
+    imageElement.style.filter = `blur(${filter.blur * 10}px) brightness(${filter.brightness}) contrast(${
+      filter.contrast
+    }) grayscale(${filter.grayscale}) hue-rotate(${(filter.hueRotate - 1) * 180}deg) invert(${filter.invert}) opacity(${
+      filter.opacity
+    }) saturate(${filter.saturation}) sepia(${filter.sepia})`;
+    //console.log('applied filter');
+  };
+
+  onMount(async () => {
+    isLoaded = false;
+    try {
+      await loadThumbData();
+      await loadAssetData();
+    } catch (error) {
+      // Throw error
+      handleError(error, 'Failed to load asset data');
+    }
+    imageElement.src = thumbData || assetData;
+    imageElement.onload = () => {
+      isLoaded = true;
+      setAspectRatio('original');
+    };
+  });
+
+  const initZoom = () => {
+    document.onwheel = (e: WheelEvent) => {
+      if (e.deltaY > 0) {
+        if (currentZoom <= 5) {
+          currentZoom += zoomSpeed;
+        }
+      } else {
+        if (currentZoom > 1) {
+          currentZoom -= zoomSpeed;
+        }
+      }
+      setImageWrapperTransform();
+    };
+  };
+
+  // Sets the filter to the preset passed in by the event
+  const setPreset = (event: CustomEvent<string>) => {
+    const preset = event.detail;
+    filter.blur = presets[preset].blur;
+    filter.brightness = presets[preset].brightness;
+    filter.contrast = presets[preset].contrast;
+    filter.grayscale = presets[preset].grayscale;
+    filter.hueRotate = presets[preset].hueRotate;
+    filter.invert = presets[preset].invert;
+    filter.opacity = presets[preset].opacity;
+    filter.saturation = presets[preset].saturation;
+    filter.sepia = presets[preset].sepia;
+    applyFilter();
+    currentFilter = preset;
+  };
+
+  const loadAssetData = async () => {
+    // Load original image
+    try {
+      const { data } = await api.assetApi.serveFile(
+        { id: asset.id, isThumb: false, isWeb: false, key: publicSharedKey },
+        {
+          responseType: 'blob',
+        },
+      );
+
+      if (!(data instanceof Blob)) {
+        return;
+      }
+
+      assetData = URL.createObjectURL(data);
+      originalImage = document.createElement('img');
+      originalImage.src = assetData;
+      //return assetData;
+    } catch {
+      // Do nothing
+      console.log('Failed to load asset data');
+    }
+  };
+
+  const loadThumbData = async () => {
+    // Load thumbnail
+    try {
+      const { data } = await api.assetApi.serveFile(
+        { id: asset.id, isThumb: true, isWeb: true, key: publicSharedKey },
+        {
+          responseType: 'blob',
+        },
+      );
+
+      if (!(data instanceof Blob)) {
+        return;
+      }
+
+      thumbData = URL.createObjectURL(data);
+      //return thumbData;
+    } catch {
+      // Do nothing
+      console.log('Failed to load thumb data');
+    }
+  };
+
+  const navigateEditTab = async (button: 'autofix' | 'crop' | 'adjust' | 'filter') => {
+    activeButton = button;
+    const cropWrapperParent = cropElementWrapper.parentElement;
+
+    removeAngleSlider();
+    removeAssetDrag();
+    removeZoom();
+
+    //TODO: better solution
+    if (!cropWrapperParent) {
+      return;
+    }
+    switch (activeButton) {
+      case 'autofix':
+        cropWrapperParent.classList.remove('p-24');
+        cropWrapperParent.classList.remove('pb-52');
+        setAspectRatio(currentAspectRatio);
+        break;
+      case 'crop':
+        initAngleSlider();
+        initAssetDrag();
+        initZoom();
+        if (!cropWrapperParent.classList.contains('p-24')) {
+          cropWrapperParent.classList.add('p-24');
+          cropWrapperParent.classList.add('pb-52');
+        }
+        setAspectRatio(currentAspectRatio);
+        break;
+      case 'adjust':
+        cropWrapperParent.classList.remove('p-24');
+        cropWrapperParent.classList.remove('pb-52');
+        setAspectRatio(currentAspectRatio);
+        break;
+      case 'filter':
+        cropWrapperParent.classList.remove('p-24');
+        cropWrapperParent.classList.remove('pb-52');
+        setAspectRatio(currentAspectRatio);
+        break;
+      default:
+        break;
+    }
+  };
+
+  const setAspectRatio = async (aspectRatio: string | aspectRatio, isRotate?: boolean) => {
+    const originalAspect = imageElement.naturalWidth / imageElement.naturalHeight;
+
+    if (isRotate) {
+      if (!['free', 'square', 'original'].includes(aspectRatio)) {
+        const strings = aspectRatio.split('_');
+        aspectRatio = strings[1] + '_' + strings[0];
+      }
+      if (currentAngleOffset % 180 == 0) {
+        //console.log('currentAngleOffset', currentAngleOffset);
+        //console.log('isRotate', isRotate);
+        isRotate = false;
+      }
+    }
+
+    //console.log('isRotate', isRotate);
+
+    currentAspectRatio = aspectRatio;
+
+    switch (aspectRatio) {
+      case 'free':
+        // free ratio selection
+        aspectRatioNum = 0;
+        break;
+      case 'square':
+        aspectRatioNum = 1;
+        break;
+      case 'original':
+        aspectRatioNum = originalAspect;
+        if (currentAngleOffset % 180 !== 0) {
+          aspectRatioNum = 1 / originalAspect;
+        }
+        break;
+      case '16_9':
+        aspectRatioNum = 16 / 9;
+        break;
+      case '9_16':
+        aspectRatioNum = 9 / 16;
+        break;
+      case '5_4':
+        aspectRatioNum = 5 / 4;
+        break;
+      case '4_5':
+        aspectRatioNum = 4 / 5;
+        break;
+      case '4_3':
+        aspectRatioNum = 4 / 3;
+        break;
+      case '3_4':
+        aspectRatioNum = 3 / 4;
+        break;
+      case '3_2':
+        aspectRatioNum = 3 / 2;
+        break;
+      case '2_3':
+        aspectRatioNum = 2 / 3;
+        break;
+      default:
+        aspectRatioNum = originalAspect;
+        break;
+    }
+
+    cropElement.style.aspectRatio = '' + aspectRatioNum;
+    const cropElementWrapperAspectRatio = cropElementWrapper.offsetWidth / cropElementWrapper.offsetHeight;
+
+    //console.log('aspectRatioNum', aspectRatioNum);
+    //console.log('cropElementWrapperAspectRatio', cropElementWrapperAspectRatio);
+
+    if (aspectRatioNum >= 1 && cropElementWrapperAspectRatio >= 1 && aspectRatioNum < cropElementWrapperAspectRatio) {
+      cropElement.style.width = 'auto';
+      cropElement.style.height = '100%';
+      //cropElement.style.maxHeight = '100%';
+    } else if (
+      aspectRatioNum >= 1 &&
+      cropElementWrapperAspectRatio >= 1 &&
+      aspectRatioNum > cropElementWrapperAspectRatio
+    ) {
+      cropElement.style.width = 'auto';
+      cropElement.style.height = '100%';
+    } else if (
+      aspectRatioNum < 1 &&
+      cropElementWrapperAspectRatio < 1 &&
+      aspectRatioNum < cropElementWrapperAspectRatio
+    ) {
+      cropElement.style.width = 'auto';
+      cropElement.style.height = '100%';
+    } else if (aspectRatioNum < 1 && cropElementWrapperAspectRatio >= 1) {
+      cropElement.style.width = 'auto';
+      cropElement.style.height = '100%';
+      //cropElement.style.maxHeight = '100%';
+    } else if (aspectRatioNum >= 1 && cropElementWrapperAspectRatio < 1) {
+      cropElement.style.width = '100%';
+      cropElement.style.height = 'auto';
+    } else {
+      cropElement.style.width = '100%';
+      cropElement.style.height = 'auto';
+      //cropElement.style.maxWidth = '100%';
+    }
+
+    //console.log('cropElement.offsetWidth', cropElement.offsetWidth);
+    //console.log('cropElement.offsetHeight', cropElement.offsetHeight);
+
+    currentTranslate = { x: 0, y: 0 };
+    calcImageElement(currentAngle);
+    setImageWrapperTransform();
+  };
+
+  //function for dragging the angle selection slider
+  const initAngleSlider = async () => {
+    console.log('initAngleSlider');
+    let pos1 = 0,
+      pos2 = 0;
+
+    const closeDragElement = () => {
+      // stop moving when mouse button is released:
+      document.onmouseup = null;
+      document.onmousemove = null;
+      document.ontouchend = null;
+      document.ontouchmove = null;
+    };
+
+    const elementDrag = async (e: MouseEvent) => {
+      e.preventDefault();
+      // calculate the new cursor position:
+      pos1 = pos2 - e.clientX;
+      pos2 = e.clientX;
+      // set the element's new position:
+      let a = angleSlider.offsetLeft - pos1;
+      if (a < 0) {
+        a = Math.max(a, (-125 / 49) * 45);
+      } else {
+        a = Math.min(a, (125 / 49) * 45);
+      }
+
+      //console.log('a', a);
+
+      let angle = Math.round((a / 125) * 49);
+      angle = angle * -1;
+
+      rotate(angle, currentAngleOffset);
+    };
+
+    const elementDragTouch = async (e: TouchEvent) => {
+      e.preventDefault();
+      // calculate the new cursor position:
+      pos1 = pos2 - e.touches[0].clientX;
+      pos2 = e.touches[0].clientX;
+      // set the element's new position:
+      let a = angleSlider.offsetLeft - pos1;
+
+      //console.log('a', a);
+      if (a < 0) {
+        a = Math.max(a, (-125 / 49) * 45);
+      } else {
+        a = Math.min(a, (125 / 49) * 45);
+      }
+
+      console.log('a', a);
+
+      let angle = Math.round((a / 125) * 49);
+      angle = angle * -1;
+
+      rotate(angle, currentAngleOffset);
+    };
+
+    const dragMouseDown = (e: MouseEvent) => {
+      e.preventDefault();
+      // get the mouse cursor position at startup:
+      pos2 = e.clientX;
+      document.onmouseup = closeDragElement;
+      // call a function whenever the cursor moves:
+      document.onmousemove = elementDrag;
+    };
+
+    const dragTouchStart = (e: TouchEvent) => {
+      e.preventDefault();
+      //console.log('dragTouchStart');
+      // get the mouse cursor position at startup:
+      pos2 = e.touches[0].clientX;
+      document.ontouchend = closeDragElement;
+      // call a function whenever the cursor moves:
+      document.ontouchmove = elementDragTouch;
+    };
+    angleSliderHandle.onmousedown = dragMouseDown;
+    angleSliderHandle.ontouchstart = dragTouchStart;
+  };
+
+  const initAssetDrag = async () => {
+    console.log('initAssetDrag');
+    let pos1 = 0,
+      pos2 = 0,
+      pos3 = 0,
+      pos4 = 0;
+    const closeDragElement = () => {
+      // stop moving when mouse button is released:
+      document.onmouseup = null;
+      document.onmousemove = null;
+    };
+
+    const elementDrag = async (e: MouseEvent) => {
+      if (activeButton !== 'crop') {
+        return;
+      }
+      e.preventDefault();
+      // calculate the new cursor position:
+      pos1 = pos3 - e.clientX;
+      pos2 = pos4 - e.clientY;
+      pos3 = e.clientX;
+      pos4 = e.clientY;
+
+      if (currentAngleOffset === 90) {
+        const temp = pos1;
+        pos1 = -pos2;
+        pos2 = temp;
+      } else if (currentAngleOffset === 180) {
+        pos1 = -pos1;
+        pos2 = -pos2;
+      } else if (currentAngleOffset === 270) {
+        const temp = pos1;
+        pos1 = pos2;
+        pos2 = -temp;
+      }
+
+      let x = 0;
+      let y = 0;
+
+      //Calc max y translation
+      let h1 = cropElement.offsetHeight;
+      let w1 = cropElement.offsetWidth;
+
+      if (currentAngleOffset === 90 || currentAngleOffset === 270) {
+        const temp = h1;
+        h1 = w1;
+        w1 = temp;
+      }
+
+      // const h2 = w1 * Math.tan((Math.abs(currentAngle) * Math.PI) / 180);
+      // const d = Math.cos((Math.abs(currentAngle) * Math.PI) / 180) * (h1 + h2);
+
+      const a = Math.sin((Math.abs(currentAngle) * Math.PI) / 180) * w1;
+      const b = Math.cos((Math.abs(currentAngle) * Math.PI) / 180) * h1;
+      const d = a + b;
+
+      let maxY = (imageWrapper.offsetHeight * currentZoom - d) / 2;
+
+      maxY = maxY / currentZoom;
+
+      // console.log('currentZoom', currentZoom);
+      // console.log('offsetHeight', imageWrapper.offsetHeight);
+      // console.log('realHight', imageWrapper.offsetHeight * currentZoom);
+      // console.log('maxY', maxY);
+
+      // Calc max x translation
+      const h3 = Math.sin((Math.abs(currentAngle) * Math.PI) / 180) * h1;
+      const h4 = Math.cos((Math.abs(currentAngle) * Math.PI) / 180) * w1;
+      let maxX = (imageWrapper.offsetWidth * currentZoom - h3 - h4) / 2;
+      maxX = maxX / currentZoom;
+      //console.log('maxX', maxX);
+
+      if (currentTranslate.x - pos1 > maxX) {
+        x = maxX;
+      } else if (currentTranslate.x - pos1 < -maxX) {
+        x = -maxX;
+      } else {
+        x = currentTranslate.x - pos1;
+      }
+
+      if (currentTranslate.y - pos2 > maxY) {
+        y = maxY;
+      } else if (currentTranslate.y - pos2 < -maxY) {
+        y = -maxY;
+      } else {
+        y = currentTranslate.y - pos2;
+      }
+
+      // console.log('y:', Math.round(y));
+      // console.log('x:', Math.round(x));
+
+      // Decide which direction to translate
+      // if (currentTranslateDirection === 'y') {
+      //   currentTranslate = {
+      //     x: 0,
+      //     y: y,
+      //   };
+      // } else if (currentTranslateDirection === 'x') {
+      //   currentTranslate = {
+      //     x: x,
+      //     y: 0,
+      //   };
+      // } else {
+      //   currentTranslate = {
+      //     x: 0,
+      //     y: 0,
+      //   };
+      // }
+      currentTranslate = {
+        x: x,
+        y: y,
+      };
+      // console.log('currentTranslateBefore', currentTranslate);
+
+      // console.log('currentTranslate', currentTranslate);
+      setImageWrapperTransform();
+    };
+
+    const dragMouseDown = (e: MouseEvent) => {
+      //console.log('dragMouseDown');
+
+      e.preventDefault();
+      // get the mouse cursor position at startup:
+      pos3 = e.clientX;
+      pos4 = e.clientY;
+      document.onmouseup = closeDragElement;
+      // call a function whenever the cursor moves:
+      document.onmousemove = elementDrag;
+    };
+    assetDragHandle.onmousedown = dragMouseDown;
+  };
+
+  const removeAssetDrag = () => {
+    assetDragHandle.onmousedown = null;
+    document.onmouseup = null;
+    document.onmousemove = null;
+  };
+
+  const removeAngleSlider = () => {
+    angleSliderHandle.onmousedown = null;
+    document.onmouseup = null;
+    document.onmousemove = null;
+    document.ontouchmove = null;
+    document.ontouchend = null;
+    angleSliderHandle.ontouchstart = null;
+  };
+
+  const removeZoom = () => {
+    document.onwheel = null;
+  };
+
+  const calcImageElement = (angle: number) => {
+    // Get image wrapper width and height
+
+    let newHeight;
+    let newWidth;
+
+    let originalAspect = imageElement.naturalWidth / imageElement.naturalHeight;
+
+    if (currentAngleOffset === 90 || currentAngleOffset === 270) {
+      originalAspect = 1 / originalAspect;
+    }
+
+    // Get crop element width and height
+    const cropElementWidth = cropElement.offsetWidth;
+    const cropElementHeight = cropElement.offsetHeight;
+
+    // console.log('cropElementWidth', cropElementWidth);
+    // console.log('cropElementHeight', cropElementHeight);
+
+    const x1 = Math.cos((Math.abs(angle) * Math.PI) / 180) * cropElementWidth;
+    const x2 = Math.cos(((90 - Math.abs(angle)) * Math.PI) / 180) * cropElementHeight;
+
+    const y1 = Math.cos((Math.abs(angle) * Math.PI) / 180) * cropElementHeight;
+    const y2 = Math.cos(((90 - Math.abs(angle)) * Math.PI) / 180) * cropElementWidth;
+
+    if ((x1 + x2) / (y1 + y2) > originalAspect) {
+      newWidth = `${x1 + x2}px`;
+      newHeight = `${(x1 + x2) / originalAspect}px`;
+      // console.log('Translation in Y possible');
+      // console.log('case4');
+      currentTranslateDirection = 'y';
+    } else if ((x1 + x2) / (y1 + y2) < originalAspect) {
+      newHeight = `${y1 + y2}px`;
+      newWidth = `${(y1 + y2) / (1 / originalAspect)}px`;
+      // console.log('Translation in X possible');
+      currentTranslateDirection = 'x';
+      // console.log('case5');
+    } else {
+      newHeight = `${y1 + y2}px`;
+      newWidth = `${(y1 + y2) / (1 / originalAspect)}px`;
+      currentTranslateDirection = '';
+      // console.log('case6');
+    }
+
+    // Set image element width and height
+    // console.log('newWidth', newWidth);
+    // console.log('newHeight', newHeight);
+    // console.log('currentAngleOffset', currentAngleOffset);
+    // console.log('currentTranslateDirection', currentTranslateDirection);
+    // console.log('currentTranslate', currentTranslate);
+    // console.log('currentAngle', currentAngle);
+
+    if (currentAngleOffset === 90 || currentAngleOffset === 270) {
+      imageWrapper.style.height = newWidth;
+      imageWrapper.style.width = newHeight;
+      if (currentTranslateDirection === 'x') {
+        currentTranslateDirection = 'y';
+      } else if (currentTranslateDirection === 'y') {
+        currentTranslateDirection = 'x';
+      }
+    } else {
+      imageWrapper.style.height = newHeight;
+      imageWrapper.style.width = newWidth;
+    }
+  };
+
+  const navigateEdit = (edit: activeEdit) => {
+    // console.log('navigateEdit');
+    let revert = false;
+    if (activeEdit === edit) {
+      revert = true;
+    }
+    activeEdit = edit;
+    if (revert) {
+      activeEdit = '';
+    }
+    switch (activeEdit) {
+      case 'optimized':
+        break;
+      case 'dynamic':
+        break;
+      case 'warm':
+        break;
+      case 'cold':
+        break;
+      default:
+        break;
+    }
+  };
+
+  const resetCropAndRotate = async () => {
+    // Reset the image orientation.
+    currentFlipX = false;
+    currentFlipY = false;
+    currentZoom = 1;
+    rotate(0, 0);
+
+    // Reset the aspect ratio.
+    await setAspectRatio('original');
+  };
+
+  const flipVertical = async () => {
+    currentFlipY = !currentFlipY;
+    rotate(currentAngle, currentAngleOffset);
+  };
+  const flipHorizontal = async () => {
+    currentFlipX = !currentFlipX;
+    rotate(currentAngle, currentAngleOffset);
+  };
+
+  const rotate = async (angle: number, angleOffset: number, isRotate?: boolean) => {
+    // If the angle offset is greater than 360 degrees, reset it back to 0
+    if (angleOffset > 360) {
+      angleOffset = angleOffset - 360;
+    }
+
+    // Set current angle and angle offset
+    currentAngle = angle;
+    currentAngleOffset = angleOffset;
+
+    // Set aspect ratio
+    setAspectRatio(currentAspectRatio, isRotate ? true : false);
+
+    // Set slider handle position
+    let a = -1 * angle * (125 / 49);
+    angleSliderHandle.style.left = a + 'px';
+    angleSlider.style.left = a + 'px';
+  };
+
+  const save = async () => {
+    // Save element
+    if (isRendering) {
+      return;
+    }
+    await renderElement.start();
+  };
+
+  // Set the transform of the image wrapper, which includes the rotation, translation, and scale.
+  const setImageWrapperTransform = () => {
+    let transformString = '';
+
+    // Add rotation to the transform string.
+    transformString += `rotate(${currentAngle - currentAngleOffset}deg)`;
+
+    // If translation is non-zero, add it to the transform string.
+    if (currentTranslate.x || currentTranslate.y) {
+      transformString += ` translate(${currentTranslate.x * currentZoom}px, ${currentTranslate.y * currentZoom}px)`;
+    }
+
+    // Add scale to the transform string.
+    transformString += ` scaleX(${(currentFlipX ? -1 : 1) * currentZoom}) scaleY(${
+      (currentFlipY ? -1 : 1) * currentZoom
+    })`;
+    // Set the transform of the image wrapper.
+
+    if (imageWrapper && imageWrapper.style) {
+      imageWrapper.style.transform = transformString;
+    }
+  };
+</script>
+
+<div
+  class="fixed left-0 top-0 z-[1003] grid h-screen w-screen grid-cols-[1fr_1fr_1fr_360px] grid-rows-[64px_1fr] overflow-hidden bg-black"
+>
+  <div class="z-[1000] col-span-3 col-start-1 row-span-1 row-start-1 flex items-center transition-transform">
+    <button
+      on:click={() => dispatch('close')}
+      class="hover:bg-immich-gray/10 ml-4 rounded-full p-3 text-2xl text-white"
+    >
+      <Icon path={mdiClose} />
+    </button>
+    <button
+      on:click={() => save()}
+      disabled={isRendering}
+      class=" {isRendering
+        ? 'bg-immich-dark-primary/50 hover:cursor-wait'
+        : 'bg-immich-dark-primary hover:bg-immich-dark-primary/80 '}  ml-auto mr-5 inline-flex items-center rounded-md p-[6px] px-4 text-black"
+    >
+      <svg
+        class="-ml-1 mr-3 h-5 w-5 animate-spin text-black {isRendering ? '' : 'hidden'}"
+        xmlns="http://www.w3.org/2000/svg"
+        fill="none"
+        viewBox="0 0 24 24"
+      >
+        <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
+        <path
+          class="opacity-75"
+          fill="currentColor"
+          d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+        />
+      </svg>
+      Save
+    </button>
+    <button class="hover:bg-immich-gray/10 mr-4 rounded-full p-3 text-2xl text-white">
+      <Icon path={mdiDotsVertical} />
+    </button>
+  </div>
+  <div class="relative col-span-3 col-start-1 row-span-full row-start-1">
+    <!-- TODO: fix only allow drag from crop Element or imageWrapper -->
+    <div class="flex h-full w-full justify-center" bind:this={assetDragHandle}>
+      <div class="flex hidden h-full w-full items-center justify-center" bind:this={editorElement} />
+      <div class="-z-10 flex h-full w-full items-center justify-center">
+        <div bind:this={cropElementWrapper} class="relative flex h-full w-full items-center justify-center">
+          <div>
+            <div bind:this={imageWrapper}>
+              <div
+                class="{isLoaded
+                  ? 'hidden'
+                  : 'flex'} absolute z-[1001] left-0 top-0 w-full h-full bg-black justify-center items-center gap-1"
+              >
+                <span>Loading</span>
+                <LoadingSpinner />
+              </div>
+              <img class="h-full w-full {isLoaded ? '' : 'hidden'}" bind:this={imageElement} src="" alt="" />
+            </div>
+            <div
+              bind:this={cropElement}
+              id="cropElement"
+              class="absolute left-1/2 top-1/2 z-[1000] mx-auto -translate-x-1/2 -translate-y-1/2 bg-transparent {activeButton ===
+              'crop'
+                ? 'shadow-[0_0_5000px_5000px_rgba(0,0,0,0.8)]'
+                : 'shadow-[0_0_5000px_5000px_#000000]'}"
+            >
+              {#if activeButton === 'crop'}
+                <div class="absolute -left-1 -top-1 h-2 w-2 rounded-full bg-white" />
+                <div class="absolute -bottom-1 -left-1 h-2 w-2 rounded-full bg-white" />
+                <div class="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-white" />
+                <div class="absolute -bottom-1 -right-1 h-2 w-2 rounded-full bg-white" />
+              {/if}
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div
+      class="{activeButton === 'crop'
+        ? ''
+        : 'hidden'} h-26 bg-immich-dark-bg absolute bottom-0 flex w-full justify-center gap-0 px-4 py-2"
+    >
+      <div class="grid h-full w-full max-w-[750px] grid-cols-[1fr,1fr,500px,2fr] grid-rows-1 items-center">
+        <!-- Crop Options -->
+        <div class="z-10 flex h-full w-full flex-col justify-center bg-black">
+          <button
+            on:click={() => flipHorizontal()}
+            class="hover:bg-immich-gray/10 rounded-full p-3 text-2xl text-white"
+          >
+            <Icon path={mdiFlipHorizontal} />
+          </button>
+
+          <button on:click={() => flipVertical()} class="hover:bg-immich-gray/10 rounded-full p-3 text-2xl text-white">
+            <Icon path={mdiFlipVertical} />
+          </button>
+        </div>
+        <div class="z-10 flex h-full w-full items-center justify-center bg-black">
+          <button
+            on:click={() => rotate(currentAngle, currentAngleOffset + 90, true)}
+            class="hover:bg-immich-gray/10 rounded-full p-3 text-2xl text-white"
+          >
+            <Icon path={mdiFormatRotate90} />
+          </button>
+        </div>
+
+        <div class="relative z-0 h-[50px] w-[500px]">
+          <!-- Angle selector slider -->
+          <div
+            bind:this={angleSlider}
+            class="angle-slider absolute grid h-full w-full select-none grid-cols-[repeat(13,1fr)] grid-rows-2 justify-around text-center text-xs"
+          >
+            <div>-90°</div>
+            <div>-75°</div>
+            <div>-60°</div>
+            <div>-45°</div>
+            <div>-30°</div>
+            <div>-15°</div>
+            <div>0°</div>
+            <div>15°</div>
+            <div>30°</div>
+            <div>45°</div>
+            <div>60°</div>
+            <div>75°</div>
+            <div>90°</div>
+            <div class="col-start-1 col-end-[14] row-start-2 grid grid-cols-[repeat(39,1fr)]">
+              <div />
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div>.</div>
+              <div />
+            </div>
+          </div>
+          <div class="angle-slider-shadow absolute h-full w-full" />
+          <div bind:this={angleSliderHandle} class="angle-slider absolute z-20 h-full w-full cursor-pointer" />
+          <div class="angle-slider-selection absolute left-[calc(50%-56px)] w-28 text-lg text-white">
+            <div class="-mt-1.5 flex justify-center">
+              {currentAngle}°
+            </div>
+            <div class="mt-1.5 flex justify-center">
+              <Icon path={mdiTriangleSmallUp} size="1.5em" />
+            </div>
+          </div>
+        </div>
+
+        <div class="z-10 flex h-full w-full items-center justify-center bg-black">
+          <button
+            class=" text-md text-immich-dark-primary hover:bg-immich-dark-primary/10 focus:bg-immich-dark-primary/20 rounded border border-white px-3 py-1.5 focus:outline-none"
+            on:click={() => resetCropAndRotate()}
+          >
+            Reset
+          </button>
+        </div>
+      </div>
+    </div>
+  </div>
+  <!-- <div class="absolute h-full w-full" /> -->
+  <div
+    class="bg-immich-dark-gray z-[1000] col-span-1 col-start-4 row-span-1 row-start-1 flex justify-evenly pb-[16px] transition-transform"
+  >
+    <button
+      title="Suggestions"
+      on:click={() => navigateEditTab('autofix')}
+      class="text-immich-gray/70 hover:text-immich-gray hover:bg-immich-gray/5 active:text-immich-dark-primary relative flex w-1/4 items-center justify-center"
+      class:active={activeButton === 'autofix'}
+    >
+      <Icon path={mdiAutoFix} size="1.5em" />
+      <div
+        class="bg-immich-dark-primary absolute bottom-0 hidden h-[3px] w-6 rounded-t-full"
+        class:active={activeButton == 'autofix'}
+      />
+    </button>
+    <button
+      title="Crop & Rotate"
+      on:click={() => navigateEditTab('crop')}
+      class="text-immich-gray/70 hover:text-immich-gray hover:bg-immich-gray/5 active:text-immich-dark-primary relative flex w-1/4 items-center justify-center"
+      class:active={activeButton === 'crop'}
+    >
+      <Icon path={mdiCropRotate} size="1.5em" />
+      <div
+        class="bg-immich-dark-primary absolute bottom-0 hidden h-[3px] w-6 rounded-t-full"
+        class:active={activeButton == 'crop'}
+      />
+    </button><button
+      title="Adjust"
+      on:click={() => navigateEditTab('adjust')}
+      class="text-immich-gray/70 hover:text-immich-gray hover:bg-immich-gray/5 active:text-immich-dark-primary relative flex w-1/4 items-center justify-center"
+      class:active={activeButton === 'adjust'}
+    >
+      <Icon path={mdiTune} size="1.5em" />
+      <div
+        class="bg-immich-dark-primary absolute bottom-0 hidden h-[3px] w-6 rounded-t-full"
+        class:active={activeButton == 'adjust'}
+      />
+    </button><button
+      title="Filter"
+      on:click={() => navigateEditTab('filter')}
+      class="text-immich-gray/70 hover:text-immich-gray hover:bg-immich-gray/5 active:text-immich-dark-primary relative flex w-1/4 items-center justify-center"
+      class:active={activeButton === 'filter'}
+    >
+      <Icon path={mdiImageAutoAdjust} size="1.5em" />
+      <div
+        class="bg-immich-dark-primary absolute bottom-0 hidden h-[3px] w-6 rounded-t-full"
+        class:active={activeButton == 'filter'}
+      />
+    </button>
+  </div>
+  <div class="bg-immich-dark-gray col-span-1 col-start-4 row-span-full row-start-2 overflow-auto">
+    {#if activeButton === 'autofix'}
+      <div class="grid gap-y-2 px-6 pt-2">
+        <!-- Suggestions -->
+        <div class="text-immich-gray/60 mb-4">Suggestions</div>
+        <SuggestionsButton
+          buttonName="Enhanced"
+          isActive={activeEdit === 'optimized'}
+          on:click={() => navigateEdit('optimized')}
+        >
+          <Icon path={mdiAutoFix} size="1.5em" />
+        </SuggestionsButton>
+        <SuggestionsButton
+          buttonName="Dynamic"
+          isActive={activeEdit === 'dynamic'}
+          on:click={() => navigateEdit('dynamic')}
+        >
+          <Icon path={mdiImageFilterHdr} size="1.5em" />
+        </SuggestionsButton>
+        <SuggestionsButton buttonName="Warm" isActive={activeEdit === 'warm'} on:click={() => navigateEdit('warm')}>
+          <Icon path={mdiWeatherSunny} size="1.5em" />
+        </SuggestionsButton>
+        <SuggestionsButton buttonName="Cold" isActive={activeEdit === 'cold'} on:click={() => navigateEdit('cold')}>
+          <Icon path={mdiWeatherCloudy} size="1.5em" />
+        </SuggestionsButton>
+      </div>
+    {:else if activeButton === 'crop'}
+      <div class="grid gap-y-2 px-6 pt-2">
+        <!-- Crop & Rotate -->
+        <div class="text-immich-gray/60 mb-4">Aspect Ratio</div>
+        <div class="grid grid-cols-2 gap-y-4">
+          <AspectRatioButton
+            on:click={() => setAspectRatio('free')}
+            isActive={currentAspectRatio === 'free'}
+            title="Free"
+          >
+            <Icon path={mdiFullscreen} size="1.5em" />
+          </AspectRatioButton>
+          <AspectRatioButton
+            on:click={() => setAspectRatio('original')}
+            isActive={currentAspectRatio === 'original'}
+            title="Original"
+          >
+            <Icon path={mdiRelativeScale} size="1.5em" />
+          </AspectRatioButton>
+          <AspectRatioButton
+            on:click={() => setAspectRatio('16_9')}
+            isActive={currentAspectRatio === '16_9'}
+            title="16:9"
+          >
+            <Icon path={mdiRectangleOutline} size="1.5em" />
+          </AspectRatioButton>
+          <AspectRatioButton
+            on:click={() => setAspectRatio('9_16')}
+            isActive={currentAspectRatio === '9_16'}
+            title="9:16"
+          >
+            <Icon path={mdiRectangleOutline} size="1.5em" />
+          </AspectRatioButton>
+          <AspectRatioButton on:click={() => setAspectRatio('5_4')} isActive={currentAspectRatio === '5_4'} title="5:4">
+            <Icon path={mdiRectangleOutline} size="1.5em" />
+          </AspectRatioButton>
+          <AspectRatioButton on:click={() => setAspectRatio('4_5')} isActive={currentAspectRatio === '4_5'} title="4:5">
+            <Icon path={mdiRectangleOutline} size="1.5em" />
+          </AspectRatioButton>
+          <AspectRatioButton on:click={() => setAspectRatio('4_3')} isActive={currentAspectRatio === '4_3'} title="4:3">
+            <Icon path={mdiRectangleOutline} size="1.5em" />
+          </AspectRatioButton>
+          <AspectRatioButton on:click={() => setAspectRatio('3_4')} isActive={currentAspectRatio === '3_4'} title="3:4">
+            <Icon path={mdiRectangleOutline} size="1.5em" />
+          </AspectRatioButton>
+          <AspectRatioButton on:click={() => setAspectRatio('3_2')} isActive={currentAspectRatio === '3_2'} title="3:2">
+            <Icon path={mdiRectangleOutline} size="1.5em" />
+          </AspectRatioButton>
+          <AspectRatioButton on:click={() => setAspectRatio('2_3')} isActive={currentAspectRatio === '2_3'} title="2:3">
+            <Icon path={mdiRectangleOutline} size="1.5em" />
+          </AspectRatioButton>
+          <AspectRatioButton
+            on:click={() => setAspectRatio('square')}
+            isActive={currentAspectRatio === 'square'}
+            title="Square"
+          >
+            <Icon path={mdiSquareOutline} size="1.5em" />
+          </AspectRatioButton>
+        </div>
+      </div>
+    {:else if activeButton === 'adjust'}
+      <div class="grid gap-y-2 px-6 pt-2">
+        <!-- Adjust -->
+        <div class="grid gap-y-8">
+          <AdjustElement title="Brightness" type={true} bind:value={filter.brightness} on:applyFilter={applyFilter}>
+            <Icon path={mdiBrightness6} size="1.5em" />
+          </AdjustElement>
+          <AdjustElement title="Contrast" type={true} bind:value={filter.contrast} on:applyFilter={applyFilter}>
+            <Icon path={mdiContrastCircle} size="1.5em" />
+          </AdjustElement>
+          <AdjustElement title="Saturation" type={true} bind:value={filter.saturation} on:applyFilter={applyFilter}>
+            <Icon path={mdiInvertColors} size="1.5em" />
+          </AdjustElement>
+          <AdjustElement title="Blur" type={false} bind:value={filter.blur} on:applyFilter={applyFilter}>
+            <Icon path={mdiBlur} size="1.5em" />
+          </AdjustElement>
+          <AdjustElement title="Grayscale" type={false} bind:value={filter.grayscale} on:applyFilter={applyFilter}>
+            <Icon path={mdiCircleHalfFull} size="1.5em" />
+          </AdjustElement>
+          <AdjustElement title="Hue Rotate" type={true} bind:value={filter.hueRotate} on:applyFilter={applyFilter}>
+            <Icon path={mdiDotsCircle} size="1.5em" />
+          </AdjustElement>
+          <AdjustElement title="Invert" type={false} bind:value={filter.invert} on:applyFilter={applyFilter}>
+            <Icon path={mdiSelectInverse} size="1.5em" />
+          </AdjustElement>
+          <AdjustElement title="Sepia" type={false} bind:value={filter.sepia} on:applyFilter={applyFilter}>
+            <Icon path={mdiPillar} size="1.5em" />
+          </AdjustElement>
+        </div>
+      </div>
+    {:else if activeButton === 'filter'}
+      <div class="grid justify-center px-6 pt-2">
+        <!-- Filter -->
+        <div class="grid grid-cols-3 gap-x-3">
+          <FilterCard title="Custom" {currentFilter} on:setPreset={setPreset} {thumbData} />
+          <FilterCard title="Without" {currentFilter} on:setPreset={setPreset} {thumbData} />
+          <FilterCard title="Vivid" {currentFilter} on:setPreset={setPreset} {thumbData} />
+        </div>
+        <hr class="border-1 border-immich-gray/10 mx-4 my-7" />
+        <div class="grid grid-cols-3 gap-x-3">
+          <FilterCard title="Playa" {currentFilter} on:setPreset={setPreset} {thumbData} />
+          <FilterCard title="Honey" {currentFilter} on:setPreset={setPreset} {thumbData} />
+          <FilterCard title="Isla" {currentFilter} on:setPreset={setPreset} {thumbData} />
+          <FilterCard title="Desert" {currentFilter} on:setPreset={setPreset} {thumbData} />
+          <FilterCard title="Clay" {currentFilter} on:setPreset={setPreset} {thumbData} />
+          <FilterCard title="Palma" {currentFilter} on:setPreset={setPreset} {thumbData} />
+          <FilterCard title="Blush" {currentFilter} on:setPreset={setPreset} {thumbData} />
+          <FilterCard title="Alpaca" {currentFilter} on:setPreset={setPreset} {thumbData} />
+          <FilterCard title="Modena" {currentFilter} on:setPreset={setPreset} {thumbData} />
+        </div>
+        <hr class="border-1 border-immich-gray/10 mx-4 my-7" />
+        <div class="grid grid-cols-3 gap-x-3">
+          <FilterCard title="West" {currentFilter} on:setPreset={setPreset} {thumbData} />
+          <FilterCard title="Metro" {currentFilter} on:setPreset={setPreset} {thumbData} />
+          <FilterCard title="Reel" {currentFilter} on:setPreset={setPreset} {thumbData} />
+          <FilterCard title="Bazaar" {currentFilter} on:setPreset={setPreset} {thumbData} />
+          <FilterCard title="Ollie" {currentFilter} on:setPreset={setPreset} {thumbData} />
+        </div>
+        <hr class="border-1 border-immich-gray/10 mx-4 my-7" />
+        <div class="grid grid-cols-3 gap-x-3">
+          <FilterCard title="Onyx" {currentFilter} on:setPreset={setPreset} {thumbData} />
+          <FilterCard title="Eiffel" {currentFilter} on:setPreset={setPreset} {thumbData} />
+          <FilterCard title="Vogue" {currentFilter} on:setPreset={setPreset} {thumbData} />
+          <FilterCard title="Vista" {currentFilter} on:setPreset={setPreset} {thumbData} />
+        </div>
+      </div>
+    {/if}
+  </div>
+  <Render
+    bind:this={renderElement}
+    {assetData}
+    editedImage={renderedImage}
+    angle={currentAngle - currentAngleOffset}
+    scale={currentZoom}
+    translate={currentTranslate}
+    aspectRatio={aspectRatioNum}
+    crop={currentCrop}
+    ratio={currentRatio}
+    {filter}
+    bind:isRendering
+  />
+</div>
+
+<style>
+  .active {
+    color: #adcbfa;
+    display: flex;
+  }
+  .active:focus {
+    background-color: rgba(173, 203, 250, 0.15);
+  }
+
+  .angle-slider-shadow {
+    background: rgb(0, 0, 0);
+    background: linear-gradient(
+      90deg,
+      rgba(0, 0, 0, 1) 0%,
+      rgba(9, 9, 121, 0) 30%,
+      rgba(5, 108, 186, 0) 70%,
+      rgba(0, 0, 0, 1) 100%
+    );
+  }
+  .angle-slider {
+    left: 0px;
+  }
+  .angle-slider-selection {
+    background: rgb(0, 0, 0);
+    background: linear-gradient(
+      90deg,
+      rgba(0, 0, 0, 0) 0%,
+      rgba(0, 0, 0, 1) 33%,
+      rgba(0, 0, 0, 1) 66%,
+      rgba(0, 0, 0, 0) 100%
+    );
+  }
+</style>

+ 128 - 0
web/src/lib/components/asset-viewer/photo-editor/render.svelte

@@ -0,0 +1,128 @@
+<script lang="ts">
+  import 'context-filter-polyfill'; // polyfill for canvas filters
+
+  export let editedImage: string;
+  export let isRendering = false;
+  export let assetData: string;
+  export let angle = 0;
+  export let crop = { width: 0, height: 0 };
+  export let scale = 1;
+  export let translate = { x: 0, y: 0 };
+  export let aspectRatio = 0;
+  export let ratio = 1; // ratio of the original image to the displayed image
+  export let filter = {
+    blur: 0,
+    brightness: 1,
+    contrast: 1,
+    grayscale: 0,
+    hueRotate: 0,
+    invert: 0,
+    opacity: 1,
+    saturation: 1,
+    sepia: 0,
+  };
+
+  export const start = async () => {
+    console.log('scale', scale);
+    console.log('aspectRatio', aspectRatio);
+
+    isRendering = true;
+
+    const img = new Image();
+    img.src = assetData;
+
+    const imgWidth = img.width;
+    const imgHeight = img.height;
+
+    const d = Math.sqrt(imgWidth * imgWidth + imgHeight * imgHeight);
+    const dx = -imgWidth / 2;
+    const dy = -imgHeight / 2;
+
+    const translateX = translate.x * ratio;
+    const translateY = translate.y * ratio;
+    const canvas = createCanvas(d, d);
+    const ctx = getCanvasContext(canvas);
+    if (!ctx) {
+      return;
+    }
+
+    drawImageOnCanvas(ctx, img, (dx + translateX) * scale, (dy + translateY) * scale, imgWidth, imgHeight);
+
+    const canvas2 = createCanvas(crop.width * ratio, crop.height * ratio);
+    const cropCtx = getCanvasContext(canvas2);
+    if (!cropCtx) {
+      return;
+    }
+
+    cropCtx.drawImage(
+      canvas,
+      (d - canvas2.width) / 2,
+      (d - canvas2.height) / 2,
+      canvas2.width,
+      canvas2.height,
+      0,
+      0,
+      canvas2.width,
+      canvas2.height,
+    );
+
+    downloadImage(canvas2);
+  };
+
+  const createCanvas = (width: number, height: number): HTMLCanvasElement => {
+    const canvas = document.createElement('canvas');
+    canvas.width = width;
+    canvas.height = height;
+    return canvas;
+  };
+
+  const getCanvasContext = (canvas: HTMLCanvasElement | null): CanvasRenderingContext2D | null => {
+    if (!canvas) {
+      return null;
+    }
+    const ctx = canvas.getContext('2d');
+    return ctx;
+  };
+
+  const drawImageOnCanvas = (
+    ctx: CanvasRenderingContext2D,
+    img: HTMLImageElement,
+    x: number,
+    y: number,
+    originalWidth: number,
+    originalHeight: number,
+  ) => {
+    ctx.save();
+    ctx.translate(ctx.canvas.width / 2, ctx.canvas.height / 2);
+    ctx.rotate((angle * Math.PI) / 180);
+    ctx.filter = `blur(${filter.blur * 10}px) brightness(${filter.brightness}) contrast(${filter.contrast}) grayscale(${
+      filter.grayscale
+    }) hue-rotate(${(filter.hueRotate - 1) * 180}deg) invert(${filter.invert}) opacity(${filter.opacity}) saturate(${
+      filter.saturation
+    }) sepia(${filter.sepia})`;
+
+    const { scaledWidth, scaledHeight } = scaleImage(originalWidth, originalHeight);
+    ctx.drawImage(img, x, y, scaledWidth, scaledHeight);
+    ctx.restore();
+  };
+
+  const scaleImage = (width: number, height: number) => {
+    const scaledWidth = width * scale;
+    const scaledHeight = height * scale;
+    return { scaledWidth, scaledHeight };
+  };
+
+  const downloadImage = (canvas: HTMLCanvasElement) => {
+    window.setTimeout(() => {
+      const dataURL = canvas.toDataURL('image/png');
+      editedImage = dataURL;
+
+      const link = document.createElement('a');
+      link.href = dataURL;
+      link.download = 'test.png';
+      link.click();
+
+      isRendering = false;
+    }, 0);
+  };
+</script>

+ 22 - 0
web/src/lib/components/asset-viewer/photo-editor/suggestions-button.svelte

@@ -0,0 +1,22 @@
+<script lang="ts">
+  export let isActive: boolean = false;
+  export let buttonName = 'Optimize';
+</script>
+
+<button
+  class:active-edit={isActive}
+  on:click
+  class="rounded-lg bg-immich-gray/10 hover:bg-immich-gray/20 text-white w-full flex items-center h-16"
+>
+  <div class="px-8 text-2xl">
+    <slot />
+  </div>
+  {buttonName}
+</button>
+
+<style>
+  .active-edit {
+    background-color: #adcbfa;
+    color: rgb(33, 33, 33);
+  }
+</style>