From cc18abda184f09c9980e0877b75fd0f3c8018743 Mon Sep 17 00:00:00 2001 From: Harsh Mahajan <127186841+HarshMN2345@users.noreply.github.com> Date: Thu, 1 Jan 2026 15:00:50 +0530 Subject: [PATCH 1/5] show badge --- src/lib/helpers/files.ts | 26 +++++++ .../storage/grid.svelte | 69 +++++++++++++++++-- .../storage/table.svelte | 63 ++++++++++++++++- 3 files changed, 150 insertions(+), 8 deletions(-) diff --git a/src/lib/helpers/files.ts b/src/lib/helpers/files.ts index b72e289f66..79a71152a3 100644 --- a/src/lib/helpers/files.ts +++ b/src/lib/helpers/files.ts @@ -97,6 +97,32 @@ export enum InvalidFileType { EXTENSION = 'invalid_extension' } +/** + * Check if a file is an image based on its MIME type + */ +export function isImageFile(mimeType: string | null | undefined): boolean { + if (!mimeType) return false; + return mimeType.startsWith('image/'); +} + +/** + * Check if a file is larger than the specified size threshold (in bytes) + */ +export function isLargeFile(fileSize: number, thresholdBytes: number = 1024 * 1024): boolean { + return fileSize > thresholdBytes; +} + +/** + * Check if a file is a large image + */ +export function isLargeImage( + mimeType: string | null | undefined, + fileSize: number, + thresholdBytes: number = 1024 * 1024 +): boolean { + return isImageFile(mimeType) && isLargeFile(fileSize, thresholdBytes); +} + export const defaultIgnore = ` ### Node ### # Logs diff --git a/src/routes/(console)/project-[region]-[project]/storage/grid.svelte b/src/routes/(console)/project-[region]-[project]/storage/grid.svelte index dbdc549a3f..5b20e9721d 100644 --- a/src/routes/(console)/project-[region]-[project]/storage/grid.svelte +++ b/src/routes/(console)/project-[region]-[project]/storage/grid.svelte @@ -3,14 +3,30 @@ import { page } from '$app/state'; import { CardContainer, GridItem1, Id } from '$lib/components'; import { canWriteBuckets } from '$lib/stores/roles'; - import { Badge, Tooltip } from '@appwrite.io/pink-svelte'; + import { Badge, Tooltip, Layout, Popover, Typography } from '@appwrite.io/pink-svelte'; import type { PageData } from './$types'; + import { goto } from '$app/navigation'; + import Link from '$lib/elements/link.svelte'; export let data: PageData; export let showCreate = false; const region = page.params.region; const project = page.params.project; + + let isMouseOverTooltip = false; + function hidePopover(hideTooltip: () => void, timeout = true) { + if (!timeout) { + isMouseOverTooltip = false; + return hideTooltip(); + } + + setTimeout(() => { + if (!isMouseOverTooltip) { + hideTooltip(); + } + }, 150); + } (showCreate = true)}> {#each data.buckets.buckets as bucket} - - {bucket.name} + {@const showOptimizable = bucket.transformations} + {@const bucketId = bucket.$id} + + + + {bucket.name} + {#if showOptimizable} + + { + setTimeout(show, 150); + }} + on:mouseleave={() => hidePopover(hide)}> + + +
(isMouseOverTooltip = true)} + on:mouseleave={() => hidePopover(hide, false)}> + {#if showing} + + This bucket contains large images. Use{' '} + { + e.preventDefault(); + hide(); + goto( + `${base}/project-${region}-${project}/storage/bucket-${bucketId}/settings#transformations` + ); + }}>image transformations{' '}to serve optimized versions in your app. + + {/if} +
+
+ {/if} +
+
{#if !bucket.enabled} -
- -
+ {/if}
diff --git a/src/routes/(console)/project-[region]-[project]/storage/table.svelte b/src/routes/(console)/project-[region]-[project]/storage/table.svelte index fd7ef03960..cfc573811f 100644 --- a/src/routes/(console)/project-[region]-[project]/storage/table.svelte +++ b/src/routes/(console)/project-[region]-[project]/storage/table.svelte @@ -5,9 +5,28 @@ import DualTimeView from '$lib/components/dualTimeView.svelte'; import type { PageData } from './$types'; import { columns } from './store'; - import { Table } from '@appwrite.io/pink-svelte'; + import { Table, Badge, Layout, Popover, Typography } from '@appwrite.io/pink-svelte'; + import { goto } from '$app/navigation'; + import Link from '$lib/elements/link.svelte'; export let data: PageData; + + const region = page.params.region; + const project = page.params.project; + + let isMouseOverTooltip = false; + function hidePopover(hideTooltip: () => void, timeout = true) { + if (!timeout) { + isMouseOverTooltip = false; + return hideTooltip(); + } + + setTimeout(() => { + if (!isMouseOverTooltip) { + hideTooltip(); + } + }, 150); + } @@ -29,7 +48,47 @@ {/key} {:else if column.id === 'name'} - {bucket.name} + + {bucket.name} + {#if bucket.transformations} + + { + setTimeout(show, 150); + }} + on:mouseleave={() => hidePopover(hide)} + on:click|stopPropagation> + + +
(isMouseOverTooltip = true)} + on:mouseleave={() => hidePopover(hide, false)}> + {#if showing} + + This bucket contains large images. Use{' '} + { + e.preventDefault(); + hide(); + goto( + `${base}/project-${region}-${project}/storage/bucket-${bucket.$id}/settings#transformations` + ); + }}>image transformations{' '}to serve optimized versions in your app. + + {/if} +
+
+ {/if} +
{:else if column.type === 'datetime'} {:else} From d249fc83bf23fc85501f7419de245534aaf3d72e Mon Sep 17 00:00:00 2001 From: Harsh Mahajan <127186841+HarshMN2345@users.noreply.github.com> Date: Thu, 1 Jan 2026 15:50:46 +0530 Subject: [PATCH 2/5] add new storage usage column --- .../(console)/project-[region]-[project]/storage/store.ts | 3 ++- .../project-[region]-[project]/storage/table.svelte | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/routes/(console)/project-[region]-[project]/storage/store.ts b/src/routes/(console)/project-[region]-[project]/storage/store.ts index 92de92584f..a5a0a35120 100644 --- a/src/routes/(console)/project-[region]-[project]/storage/store.ts +++ b/src/routes/(console)/project-[region]-[project]/storage/store.ts @@ -3,7 +3,8 @@ import { writable } from 'svelte/store'; export const columns = writable([ { id: '$id', title: 'Bucket ID', type: 'string', width: 200 }, - { id: 'name', title: 'Name', type: 'string', width: { min: 120 } }, + { id: 'name', title: 'Name', type: 'string', width: { min: 200 } }, + { id: 'storageUsage', title: 'Storage usage', type: 'integer', width: 220 }, { id: '$createdAt', title: 'Created', type: 'datetime', width: { min: 120 } }, { id: '$updatedAt', title: 'Updated', type: 'datetime', width: { min: 120 } } ]); diff --git a/src/routes/(console)/project-[region]-[project]/storage/table.svelte b/src/routes/(console)/project-[region]-[project]/storage/table.svelte index cfc573811f..a9595331cb 100644 --- a/src/routes/(console)/project-[region]-[project]/storage/table.svelte +++ b/src/routes/(console)/project-[region]-[project]/storage/table.svelte @@ -8,6 +8,7 @@ import { Table, Badge, Layout, Popover, Typography } from '@appwrite.io/pink-svelte'; import { goto } from '$app/navigation'; import Link from '$lib/elements/link.svelte'; + import { calculateSize } from '$lib/helpers/sizeConvertion'; export let data: PageData; @@ -48,8 +49,10 @@ {/key} {:else if column.id === 'name'} + {bucket.name} + {:else if column.id === 'storageUsage'} - {bucket.name} + {calculateSize(0)} {#if bucket.transformations} Date: Mon, 5 Jan 2026 22:10:28 +0530 Subject: [PATCH 3/5] just a structure --- .../bucket-[bucket]/editor/+page.svelte | 856 +++++++++++++++++ .../storage/bucket-[bucket]/editor/+page.ts | 3 + .../bucket-[bucket]/file-[file]/+page.svelte | 6 + .../file-[file]/editor/+page.svelte | 887 ++++++++++++++++++ .../file-[file]/editor/+page.ts | 9 + .../storage/bucket-[bucket]/header.svelte | 6 + 6 files changed, 1767 insertions(+) create mode 100644 src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/+page.svelte create mode 100644 src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/+page.ts create mode 100644 src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/file-[file]/editor/+page.svelte create mode 100644 src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/file-[file]/editor/+page.ts diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/+page.svelte b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/+page.svelte new file mode 100644 index 0000000000..a11bb8d228 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/+page.svelte @@ -0,0 +1,856 @@ + + + + {#if loading} + + Loading editor... + + {:else if !selectedFile} + +
+ Image Editor + No files available to edit. +
+
+ {:else} + + +
+ +
+ + +
+ + (activeTab = 'design')}> + Design + + (activeTab = 'code')}> + Code + + +
+ +
+
+ + +
+ +
+ +
+ + Focal point: {focalPointOptions.find((opt) => opt.value === focalPoint) + ?.label || 'Bottom-Left'} + +
+ +
+
+ + +
+
+
+
+
+
+
+ + {width} × {height} + + + +
+ + +
+
+
+ + +
+ +
+
+ +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + + +
+ +
+
+ Dimensions +
+ +
+ W + + handleWidthChange( + parseInt(e.currentTarget.value) || 0 + )} /> +
+ + +
+
+ +
+ H + + handleHeightChange( + parseInt(e.currentTarget.value) || 0 + )} /> +
+ + +
+
+ + +
+
+ +
+ Crop + +
+
+
+
+ + +
+ +
+
+ Width + +
+ {#if borderWidth > 0} +
+ Color + +
+ {/if} +
+
+
+ + +
+ +
+
+ Background Color + +
+
+
+
+ + +
+ +
+
+ Format + +
+
+
+
+
+
+
+ {/if} +
+ + diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/+page.ts b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/+page.ts new file mode 100644 index 0000000000..7f9a7283d5 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/+page.ts @@ -0,0 +1,3 @@ +export const load = async () => { + return {}; +}; diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/file-[file]/+page.svelte b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/file-[file]/+page.svelte index a940dc8f6f..fa28b68bd6 100644 --- a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/file-[file]/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/file-[file]/+page.svelte @@ -1,4 +1,5 @@ + + + + +
+ + {previewUrl.split('://')[0]}:// + +
+ + +
+ + (activeTab = 'design')}> + Design + + (activeTab = 'code')}> + Code + + +
+ +
+
+ + +
+ +
+ {#if $file} + +
+ + Focal point: {focalPointOptions.find((opt) => opt.value === focalPoint) + ?.label || 'Bottom-Left'} + +
+ +
+
+ + +
+
+
+
+
+
+
+ + {width} × {height} + + + +
+ + +
+
+ {/if} +
+ + +
+ +
+
+ +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+ +
+
+ Dimensions +
+
+ W + + handleWidthChange(Number(e.currentTarget.value))} /> +
+ + +
+
+
+ H + + handleHeightChange( + Number(e.currentTarget.value) + )} /> +
+ + +
+
+ +
+
+ +
+ Crop + +
+
+
+
+ + +
+ +
+
+
+ + {#if borderWidth > 0} + + {/if} +
+
+
+
+
+ + +
+ +
+
+ +
+
+
+
+ + +
+ +
+
+ +
+
+
+
+
+
+
+
+ + diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/file-[file]/editor/+page.ts b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/file-[file]/editor/+page.ts new file mode 100644 index 0000000000..375d099ab8 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/file-[file]/editor/+page.ts @@ -0,0 +1,9 @@ +import { Dependencies } from '$lib/constants'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ depends }) => { + depends(Dependencies.FILE); + depends(Dependencies.BUCKET); + + return {}; +}; diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/header.svelte b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/header.svelte index 5bdf521833..23c46a9914 100644 --- a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/header.svelte +++ b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/header.svelte @@ -17,6 +17,12 @@ event: 'files', hasChildren: true }, + { + href: `${path}/editor`, + title: 'Editor', + event: 'editor', + hasChildren: true + }, { href: `${path}/usage`, title: 'Usage', From 0e0c62c47040711f2adcf6182e9ad63b1a32b685 Mon Sep 17 00:00:00 2001 From: Harsh Mahajan <127186841+HarshMN2345@users.noreply.github.com> Date: Mon, 12 Jan 2026 19:19:41 +0530 Subject: [PATCH 4/5] added grid --- package.json | 1 + pnpm-lock.yaml | 12 + src/lib/helpers/imageTransformations.ts | 128 ++ .../bucket-[bucket]/editor/+page.svelte | 1128 +++++++---------- .../editor/components/codePanel.svelte | 80 ++ .../editor/components/gridOverlay.svelte | 92 ++ .../editor/components/imageGrid.svelte | 138 ++ .../editor/components/presetManager.ts | 60 + .../components/transformationPanel.svelte | 579 +++++++++ 9 files changed, 1538 insertions(+), 680 deletions(-) create mode 100644 src/lib/helpers/imageTransformations.ts create mode 100644 src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/codePanel.svelte create mode 100644 src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/gridOverlay.svelte create mode 100644 src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/imageGrid.svelte create mode 100644 src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/presetManager.ts create mode 100644 src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/transformationPanel.svelte diff --git a/package.json b/package.json index a2e1778fe3..83feebd364 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@appwrite.io/pink-legacy": "^1.0.3", "@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@865e2fc", "@faker-js/faker": "^9.9.0", + "@neodrag/svelte": "^2.3.3", "@popperjs/core": "^2.11.8", "@sentry/sveltekit": "^8.38.0", "@stripe/stripe-js": "^3.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a968c01e9..f7a9af9aeb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@faker-js/faker': specifier: ^9.9.0 version: 9.9.0 + '@neodrag/svelte': + specifier: ^2.3.3 + version: 2.3.3(svelte@5.25.3) '@popperjs/core': specifier: ^2.11.8 version: 2.11.8 @@ -690,6 +693,11 @@ packages: peerDependencies: svelte: ^3.0.0 || ^4.0.0 || ^5.0.0-next.118 + '@neodrag/svelte@2.3.3': + resolution: {integrity: sha512-avXzhrilsBsnMFljhVAQ7h+6hbSIrvRCJ61GCiGbGISkC1QOhjDCNvPZo2+7KVwiYrnUBx4NRH0kTIqrcxv9Lg==} + peerDependencies: + svelte: ^3.0.0 || ^4.0.0 || ^5.0.0 + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -4209,6 +4217,10 @@ snapshots: nanoid: 5.1.5 svelte: 5.25.3 + '@neodrag/svelte@2.3.3(svelte@5.25.3)': + dependencies: + svelte: 5.25.3 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 diff --git a/src/lib/helpers/imageTransformations.ts b/src/lib/helpers/imageTransformations.ts new file mode 100644 index 0000000000..525eddd2aa --- /dev/null +++ b/src/lib/helpers/imageTransformations.ts @@ -0,0 +1,128 @@ +import { ImageFormat, type Models } from '@appwrite.io/console'; + +export type TransformationState = { + width?: number; + height?: number; + gravity?: string; // focal point: 'top-left', 'top', 'top-right', 'left', 'center', 'right', 'bottom-left', 'bottom', 'bottom-right' + borderWidth?: number; + borderColor?: string; // hex without # + borderStyle?: string; // 'solid', 'dashed', 'dotted' + borderOpacity?: number; // 0-100 + borderRadius?: number; + background?: string; // hex without # + quality?: number; // 1-100 + output?: ImageFormat; + rotation?: number; // 0-360 +}; + +export function generateTransformationParams( + state: TransformationState +): Record { + const params: Record = {}; + + if (state.width) params.w = state.width; + if (state.height) params.h = state.height; + if (state.gravity && state.gravity !== 'center') { + params.gravity = state.gravity; + } + if (state.borderWidth && state.borderWidth > 0) { + params.border = state.borderWidth; + if (state.borderColor) { + params['border-color'] = state.borderColor.replace('#', ''); + } + if (state.borderStyle) { + params['border-style'] = state.borderStyle; + } + } + if (state.borderRadius && state.borderRadius > 0) { + params['border-radius'] = state.borderRadius; + } + if (state.background) { + params.background = state.background.replace('#', ''); + } + if (state.quality && state.quality < 100) { + params.quality = state.quality; + } + if (state.output) { + params.output = state.output; + } + if (state.rotation && state.rotation !== 0) { + params.rotation = state.rotation; + } + + return params; +} + +export function generateSDKCode( + state: TransformationState, + bucketId: string, + fileId: string, + sdk: 'js' | 'python' | 'flutter' | 'swift' | 'kotlin' +): string { + const params = generateTransformationParams(state); + const paramStrings: string[] = []; + + // Build parameter object/string + Object.entries(params).forEach(([key, value]) => { + if (sdk === 'js' || sdk === 'python') { + paramStrings.push(` ${key}: ${typeof value === 'string' ? `'${value}'` : value}`); + } else if (sdk === 'flutter' || sdk === 'swift' || sdk === 'kotlin') { + paramStrings.push(` ${key}: ${typeof value === 'string' ? `"${value}"` : value}`); + } + }); + + const paramsBlock = paramStrings.length > 0 ? `,\n${paramStrings.join(',\n')}` : ''; + + switch (sdk) { + case 'js': + return `storage.getFilePreview({ + bucketId: '${bucketId}', + fileId: '${fileId}'${paramsBlock} +});`; + + case 'python': + return `storage.get_file_preview( + bucket_id='${bucketId}', + file_id='${fileId}'${paramsBlock.replace(/(\w+):/g, '$1=')} +);`; + + case 'flutter': + return `Storage.getFilePreview( + bucketId: '${bucketId}', + fileId: '${fileId}'${paramsBlock} +);`; + + case 'swift': + return `storage.getFilePreview( + bucketId: "${bucketId}", + fileId: "${fileId}"${paramsBlock} +)`; + + case 'kotlin': + return `storage.getFilePreview( + bucketId = "${bucketId}", + fileId = "${fileId}"${paramsBlock.replace(/(\w+):/g, '$1 =')} +)`; + + default: + return ''; + } +} + +export function getFormatLabel(format: ImageFormat): string { + switch (format) { + case ImageFormat.Jpg: + return 'JPG'; + case ImageFormat.Png: + return 'PNG'; + case ImageFormat.Gif: + return 'GIF'; + case ImageFormat.Webp: + return 'WEBP'; + case ImageFormat.Avif: + return 'AVIF'; + default: + return 'JPG'; + } +} + diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/+page.svelte b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/+page.svelte index a11bb8d228..38c9b11fc6 100644 --- a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/+page.svelte @@ -3,61 +3,81 @@ import { Container } from '$lib/layout'; import { sdk } from '$lib/stores/sdk'; import { ImageFormat, Query, type Models } from '@appwrite.io/console'; - import { Layout, Typography, Accordion } from '@appwrite.io/pink-svelte'; - import { onMount} from 'svelte'; - import { CopyInput, Tab, Tabs } from '$lib/components'; +import { Layout, Typography, Input } from '@appwrite.io/pink-svelte'; +import { onMount } from 'svelte'; + import { Copy } from '$lib/components'; + import { IconDuplicate } from '@appwrite.io/pink-icons-svelte'; + import { InputSelect } from '$lib/elements/forms'; + import { draggable } from '@neodrag/svelte'; + import type { DragEventData } from '@neodrag/svelte'; +import ImageGrid from './components/imageGrid.svelte'; +import TransformationPanel from './components/transformationPanel.svelte'; +import CodePanel from './components/codePanel.svelte'; +import GridOverlay from './components/gridOverlay.svelte'; +import { getPresets, type Preset } from './components/presetManager'; +import type { TransformationState } from '$lib/helpers/imageTransformations'; // UI State let activeTab = $state<'design' | 'code'>('design'); let bucketFiles = $state([]); let selectedFile = $state(null); let loading = $state(true); + let zoom = $state(100); // Transformation state - let width = $state(584); - let height = $state(438); - let aspectRatioLocked = $state(true); - let originalAspectRatio = $state(584 / 438); - - // Focal point - let focalPoint = $state('bottom-left'); - const focalPointOptions = [ - { label: 'Top-Left', value: 'top-left' }, - { label: 'Top', value: 'top' }, - { label: 'Top-Right', value: 'top-right' }, - { label: 'Left', value: 'left' }, - { label: 'Center', value: 'center' }, - { label: 'Right', value: 'right' }, - { label: 'Bottom-Left', value: 'bottom-left' }, - { label: 'Bottom', value: 'bottom' }, - { label: 'Bottom-Right', value: 'bottom-right' } - ]; - - // Crop/Gravity options - let gravity = $state('4:3'); - const gravityOptions = [ - { label: '4:3', value: '4:3' }, - { label: '16:9', value: '16:9' }, - { label: '1:1', value: '1:1' }, - { label: 'Custom', value: 'custom' } - ]; - - // Border - let borderWidth = $state(0); - let borderColor = $state('#000000'); - - // Background fill - let backgroundColor = $state(''); - - // Export settings - let outputFormat = $state(ImageFormat.Jpg); - let quality = $state(100); - - // Canvas handling - let canvasEl = $state(); - let isDragging = false; - let startX = 0; - let startY = 0; + let transformationState = $state({ + width: 700, + height: 438, + aspectRatioLocked: true, + originalAspectRatio: 700 / 438, + gravity: 'center', + borderWidth: 0, + borderColor: '000000', + borderStyle: 'solid', + borderOpacity: 100, + borderRadius: 0, + background: '', + quality: 100, + output: ImageFormat.Jpg, + rotation: 0, + crop: 'none' + }); + + // Presets + let presets = $state([]); + let selectedPresetId = $state(null); + let appliedPresets = $state>({}); // fileId -> presetId + + // Canvas state + let canvasContainer = $state(); + let resizeStartDimensions = $state<{ width: number; height: number } | null>(null); + + // Derived values for selectors + const fileOptions = $derived(bucketFiles.map(f => ({ + value: f.$id, + label: f.name.length > 15 ? f.name.substring(0, 15) + '...' : f.name + }))); + + const presetOptions = $derived([ + { value: 'none', label: 'None' }, + ...presets.map(p => ({ value: p.id, label: p.name })) + ]); + + let selectedFileId = $derived(selectedFile?.$id || ''); + + function handleFileChange(event: CustomEvent) { + const fileId = event.detail; + const file = bucketFiles.find((f) => f.$id === fileId); + if (file) { + selectedFile = file; + } + } + + function handlePresetChangeTop(event: CustomEvent) { + const value = event.detail; + selectedPresetId = value === 'none' ? null : value; + handlePresetSelected(selectedPresetId); + } onMount(async () => { try { @@ -67,10 +87,13 @@ bucketId: page.params.bucket, queries: [Query.limit(100), Query.orderDesc('$createdAt')] }); - bucketFiles = response.files; - if (bucketFiles.length > 0) { + bucketFiles = response.files.filter((f) => f.mimeType?.startsWith('image/')); + if (bucketFiles.length > 0 && !selectedFile) { selectedFile = bucketFiles[0]; + // Load original image dimensions + loadImageDimensions(); } + presets = getPresets(page.params.bucket); } catch (error) { console.error('Failed to load bucket files:', error); } finally { @@ -78,126 +101,233 @@ } }); - const drawCanvas = () => { - if (!canvasEl || !selectedFile) return; - const ctx = canvasEl.getContext('2d'); - if (!ctx) return; - + function loadImageDimensions() { + if (!selectedFile) return; const img = new Image(); - img.crossOrigin = 'anonymous'; - img.src = previewUrl; - img.onload = () => { - canvasEl.width = width; - canvasEl.height = height; - ctx.clearRect(0, 0, width, height); - ctx.drawImage(img, 0, 0, width, height); + transformationState.width = img.width; + transformationState.height = img.height; + transformationState.originalAspectRatio = img.width / img.height; }; - }; - - $effect(() => { - if (previewUrl && selectedFile) { - drawCanvas(); - } - }); - - function onMouseDown(event: MouseEvent) { - isDragging = true; - startX = event.clientX; - startY = event.clientY; - } - function onMouseMove(event: MouseEvent) { - if (!isDragging) return; - const dx = event.clientX - startX; - const dy = event.clientY - startY; - width = Math.max(1, width + dx); - height = Math.max(1, height + dy); - startX = event.clientX; - startY = event.clientY; - } - function onMouseUp() { - isDragging = false; + img.src = getPreviewUrl(); } - // Generate preview URL with transformations - const getTransformedPreview = () => { + function getPreviewUrl(): string { if (!selectedFile) return ''; - - const params: any = { + + // Build params for SDK (camelCase) + const sdkParams: any = { bucketId: selectedFile.bucketId, - fileId: selectedFile.$id, - width, - height, - output: outputFormat + fileId: selectedFile.$id }; - - if (focalPoint !== 'center') { - params.gravity = focalPoint; + + if (transformationState.width) sdkParams.width = transformationState.width; + if (transformationState.height) sdkParams.height = transformationState.height; + if (transformationState.gravity && transformationState.gravity !== 'center') { + sdkParams.gravity = transformationState.gravity; } - - if (borderWidth > 0) { - params.borderWidth = borderWidth; - params.borderColor = borderColor.replace('#', ''); + if (transformationState.borderWidth && transformationState.borderWidth > 0) { + sdkParams.borderWidth = transformationState.borderWidth; + if (transformationState.borderColor) { + sdkParams.borderColor = transformationState.borderColor.replace('#', ''); + } + if (transformationState.borderStyle) { + sdkParams.borderStyle = transformationState.borderStyle; + } } - - if (backgroundColor) { - params.background = backgroundColor.replace('#', ''); + if (transformationState.borderRadius && transformationState.borderRadius > 0) { + sdkParams.borderRadius = transformationState.borderRadius; } - - if (quality < 100) { - params.quality = quality; + if (transformationState.background) { + sdkParams.background = transformationState.background.replace('#', ''); } - - return ( - sdk - .forProject(page.params.region, page.params.project) - .storage.getFilePreview(params) - .toString() + '&mode=admin' - ); - }; - - let previewUrl = $derived(getTransformedPreview()); - - function handleFileSwitch(event: Event) { - const select = event.target as HTMLSelectElement; - const newFileId = select.value; - const found = bucketFiles.find((f) => f.$id === newFileId); - if (found) { - selectedFile = found; - // Reset dimensions or keep? Let's keep for now or maybe reset ratio if needed. + if (transformationState.quality && transformationState.quality < 100) { + sdkParams.quality = transformationState.quality; } - } - - // Handle dimension changes with aspect ratio lock - function handleWidthChange(newWidth: number) { - width = newWidth; - if (aspectRatioLocked && originalAspectRatio) { - height = Math.round(newWidth / originalAspectRatio); + if (transformationState.output) { + sdkParams.output = transformationState.output; } + if (transformationState.rotation && transformationState.rotation !== 0) { + sdkParams.rotation = transformationState.rotation; + } + + const baseUrl = sdk + .forProject(page.params.region, page.params.project) + .storage.getFilePreview(sdkParams) + .toString(); + + // Format URL parameters to match the desired format (kebab-case) + const url = new URL(baseUrl); + const params = new URLSearchParams(); + + // Convert camelCase to kebab-case for display + url.searchParams.forEach((value, key) => { + if (key === 'width') { + params.set('w', value); + } else if (key === 'height') { + params.set('h', value); + } else if (key === 'borderWidth') { + params.set('border', value); + } else if (key === 'borderColor') { + params.set('border-color', value); + } else if (key === 'borderStyle') { + params.set('border-style', value); + } else if (key === 'borderRadius') { + params.set('border-radius', value); + } else { + params.set(key, value); + } + }); + + return `${url.origin}${url.pathname}?${params.toString()}&mode=admin`; } - function handleHeightChange(newHeight: number) { - height = newHeight; - if (aspectRatioLocked && originalAspectRatio) { - width = Math.round(newHeight * originalAspectRatio); + let previewUrl = $derived(getPreviewUrl()); + + // Watch for file selection changes and apply presets + $effect(() => { + if (selectedFile) { + // Reset transformations or apply preset if one is selected for this file + if (appliedPresets[selectedFile.$id]) { + const preset = presets.find((p) => p.id === appliedPresets[selectedFile.$id]); + if (preset) { + transformationState = { ...preset.transformations }; + } + } else { + loadImageDimensions(); + } } - } + }); - function changeWidth(delta: number) { - handleWidthChange(width + delta); + function handleFocalPointClick(event: MouseEvent) { + if (!canvasContainer || !selectedFile || resizeStartDimensions) return; + const rect = canvasContainer.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + const width = rect.width; + const height = rect.height; + + // Determine focal point based on position + const thirdX = width / 3; + const thirdY = height / 3; + + let point = 'center'; + if (x < thirdX && y < thirdY) point = 'top-left'; + else if (x < thirdX && y > thirdY * 2) point = 'bottom-left'; + else if (x > thirdX * 2 && y < thirdY) point = 'top-right'; + else if (x > thirdX * 2 && y > thirdY * 2) point = 'bottom-right'; + else if (x < thirdX) point = 'left'; + else if (x > thirdX * 2) point = 'right'; + else if (y < thirdY) point = 'top'; + else if (y > thirdY * 2) point = 'bottom'; + + transformationState.gravity = point; + } + + // Store handle positions to keep them at corners + let handlePosNw = $state({ x: 0, y: 0 }); + let handlePosNe = $state({ x: 0, y: 0 }); + let handlePosSw = $state({ x: 0, y: 0 }); + let handlePosSe = $state({ x: 0, y: 0 }); + + function createResizeHandler(handle: string) { + // Determine cursor and axis based on handle position + const isDiagonal = handle.length === 2; + const axis = isDiagonal ? 'both' : (handle.includes('e') || handle.includes('w') ? 'x' : 'y'); + + // Get the appropriate handle position based on handle name + let handlePos: { x: number; y: number }; + if (handle === 'nw') handlePos = handlePosNw; + else if (handle === 'ne') handlePos = handlePosNe; + else if (handle === 'sw') handlePos = handlePosSw; + else handlePos = handlePosSe; + + return { + onDragStart: () => { + resizeStartDimensions = { + width: transformationState.width || 0, + height: transformationState.height || 0 + }; + handlePos.x = 0; + handlePos.y = 0; + }, + onDrag: ({ offsetX, offsetY }: DragEventData) => { + if (!resizeStartDimensions) return; + + const scale = zoom / 100; + // Calculate dimension changes based on drag offset + const dx = offsetX / scale; + const dy = offsetY / scale; + + let newWidth = resizeStartDimensions.width; + let newHeight = resizeStartDimensions.height; + + // Calculate new dimensions based on handle position + // East (right) handle: increase width + if (handle.includes('e')) { + newWidth = Math.max(1, resizeStartDimensions.width + dx); + } + // West (left) handle: decrease width + if (handle.includes('w')) { + newWidth = Math.max(1, resizeStartDimensions.width - dx); + } + // South (bottom) handle: increase height + if (handle.includes('s')) { + newHeight = Math.max(1, resizeStartDimensions.height + dy); + } + // North (top) handle: decrease height + if (handle.includes('n')) { + newHeight = Math.max(1, resizeStartDimensions.height - dy); + } + + // Apply aspect ratio lock with smooth calculation + if (transformationState.aspectRatioLocked && transformationState.originalAspectRatio) { + // For diagonal handles, prefer width-based calculation + if (handle.includes('e') || handle.includes('w')) { + newHeight = Math.round(newWidth / transformationState.originalAspectRatio); + } else { + newWidth = Math.round(newHeight * transformationState.originalAspectRatio); + } + } + + // Apply with smooth transition (physics-like) + transformationState.width = Math.round(newWidth); + transformationState.height = Math.round(newHeight); + + // Reset handle position to keep it at the corner + handlePos.x = 0; + handlePos.y = 0; + }, + onDragEnd: () => { + resizeStartDimensions = null; + handlePos.x = 0; + handlePos.y = 0; + }, + // Physics-like behavior: grid snapping and smooth movement + grid: [5, 5], // Snap to 5px grid for smoother feel + threshold: { distance: 2 }, // Small threshold to prevent accidental drags + gpuAcceleration: true, // Smooth hardware-accelerated movement + axis: axis as 'both' | 'x' | 'y', // Constrain movement based on handle + // Keep handle at corner by resetting position reactively + position: handlePos, + // Add smooth easing for physics-like feel + defaultClassDragging: 'resizing' + }; } - function changeHeight(delta: number) { - handleHeightChange(height + delta); + function handlePresetSelected(presetId: string | null) { + selectedPresetId = presetId; + if (presetId && selectedFile) { + const preset = presets.find((p) => p.id === presetId); + if (preset) { + transformationState = { ...preset.transformations }; + appliedPresets[selectedFile.$id] = presetId; + } + } else if (selectedFile) { + delete appliedPresets[selectedFile.$id]; + } } - const formatOptions = [ - { label: 'Original', value: null }, // Handle original format if needed, simplistic maps for now - { label: 'JPG', value: ImageFormat.Jpg }, - { label: 'PNG', value: ImageFormat.Png }, - { label: 'GIF', value: ImageFormat.Gif }, - { label: 'WEBP', value: ImageFormat.Webp } - ]; @@ -205,266 +335,117 @@ Loading editor... - {:else if !selectedFile} + {:else if bucketFiles.length === 0}
Image Editor - No files available to edit. + No images found in this bucket.
- {:else} + {:else if !selectedFile} + - -
- -
- - -
- - (activeTab = 'design')}> - Design - - (activeTab = 'code')}> - Code - - -
- + Select an image to edit + + + {:else} + +
+ +
+
+ + https:// + + + + +
+
+ +
- +
- -
- - Focal point: {focalPointOptions.find((opt) => opt.value === focalPoint) - ?.label || 'Bottom-Left'} - -
- -
-
- - -
-
-
-
-
+ +
- +
- -
-
- -
-
- -
-
- - -
-
- - + + + {#if activeTab === 'code' && selectedFile} +
+
-
- -
-
- - - -
- -
-
- Dimensions -
- -
- W - - handleWidthChange( - parseInt(e.currentTarget.value) || 0 - )} /> -
- - -
-
- -
- H - - handleHeightChange( - parseInt(e.currentTarget.value) || 0 - )} /> -
- - -
-
- - -
-
- -
- Crop - -
-
-
-
- - -
- -
-
- Width - -
- {#if borderWidth > 0} -
- Color - -
- {/if} -
-
-
- - -
- -
-
- Background Color - -
-
-
-
- - -
- -
-
- Format - -
-
-
-
+ {/if}
- +
{/if} @@ -478,24 +459,38 @@ text-align: center; } - .url-section { - width: 100%; + .editor-wrapper { + display: flex; + flex-direction: column; + gap: var(--space-m); } - .tabs-section { + .editor-header { display: flex; - justify-content: space-between; align-items: center; - border-bottom: 1px solid var(--color-border); + gap: var(--space-m); + } + + .url-input-wrapper { + flex: 1; + } + + .header-selectors { + display: flex; + gap: var(--space-s); + } + + :global(.header-selectors > *) { + min-width: 120px; } .editor-layout { display: grid; - grid-template-columns: 1fr 320px; - gap: 1.5rem; - height: 600px; + grid-template-columns: 1fr 280px; + gap: 0; + min-height: 550px; border: 1px solid var(--color-border); - border-radius: var(--border-radius-medium); + border-radius: var(--border-radius-small); overflow: hidden; } @@ -507,342 +502,114 @@ overflow: hidden; } - .focal-point-section { - position: absolute; - top: 1rem; - left: 1rem; - z-index: 10; - background: rgba(255, 255, 255, 0.8); - padding: 0.25rem 0.5rem; - border-radius: var(--border-radius-small); - pointer-events: none; - } - .preview-container { flex: 1; display: flex; - flex-direction: column; align-items: center; justify-content: center; position: relative; - padding: 2rem; + padding: var(--space-xl); + overflow: auto; } .preview-wrapper { position: relative; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + transform-origin: center; } - .preview-canvas { + .preview-image { display: block; max-width: 100%; - max-height: 480px; - background: #fff; + max-height: 70vh; + border-radius: var(--border-radius-small); + user-select: none; } - .grid-overlay { + + .resize-handles { position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none; - border: 1px solid var(--color-primary-100); } - .grid-line { + .handle { position: absolute; - background: rgba(253, 54, 110, 0.3); /* Brand color light */ - } - - .grid-line-v { - top: 0; - bottom: 0; - width: 1px; - } - .grid-line-v:nth-child(1) { - left: 33.33%; - } - .grid-line-v:nth-child(2) { - left: 66.66%; - } - - .grid-line-h { - left: 0; - right: 0; - height: 1px; - } - .grid-line-h:nth-child(3) { - top: 33.33%; - } - .grid-line-h:nth-child(4) { - top: 66.66%; - } - - .dimensions-text { - margin-top: 1rem; - background: var(--color-neutral-0); - padding: 0.25rem 0.5rem; - border-radius: var(--border-radius-small); - border: 1px solid var(--color-border); - } - - .rotation-slider { - position: absolute; - bottom: 1rem; - display: flex; - align-items: center; - gap: 0.5rem; - background: var(--color-neutral-0); - padding: 0.5rem; - border-radius: var(--border-radius-medium); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); - } - - .slider { - width: 100px; - accent-color: var(--color-primary-100); - } - - .controls-section { - display: flex; - flex-direction: column; - background: var(--color-neutral-0); - border: 1px solid var(--color-border); - border-right: none; - height: 100%; - } - - .panel-header { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 0.5rem; - padding: 0.75rem 1rem; - border-bottom: 1px solid var(--color-border); - } - - .view-toggle-section { - padding: 0.75rem 1rem; - display: flex; - justify-content: space-between; - align-items: center; - border-bottom: 1px solid var(--color-border); + width: 12px; + height: 12px; + background: var(--color-primary-100); + border: 2px solid white; + border-radius: 50%; + pointer-events: all; + z-index: 10; + cursor: nwse-resize; + transition: transform 0.1s ease-out, background-color 0.2s; + will-change: transform; } - /* Segmented Control */ - .segmented-control { - display: inline-flex; - background: var(--color-neutral-10); - padding: 2px; - border-radius: var(--border-radius-small); + .handle:hover { + transform: scale(1.2); + background: var(--color-primary-110); } - .segment-btn { - padding: 0.25rem 0.75rem; - font-size: var(--font-size-0); - font-weight: 500; - color: var(--color-neutral-60); - background: transparent; - border: none; - border-radius: calc(var(--border-radius-small) - 2px); - cursor: pointer; - transition: all 0.2s; + .handle-nw { + top: -6px; + left: -6px; + cursor: nwse-resize; } - .segment-btn:hover { - color: var(--color-neutral-100); + .handle-ne { + top: -6px; + right: -6px; + cursor: nesw-resize; } - .segment-btn.is-active { - background: var(--color-neutral-0); - color: var(--color-neutral-100); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + .handle-sw { + bottom: -6px; + left: -6px; + cursor: nesw-resize; } - .quality-selector { - min-width: 70px; + .handle-se { + bottom: -6px; + right: -6px; + cursor: nwse-resize; } - /* Accordions */ - .accordion-group { - border-bottom: 1px solid var(--color-border); - background: var(--color-neutral-0); + /* Smooth transitions for image dimensions with physics-like easing */ + .preview-image { + transition: width 0.15s cubic-bezier(0.4, 0, 0.2, 1), height 0.15s cubic-bezier(0.4, 0, 0.2, 1); } - :global(.accordion-group .accordion-trigger) { - padding: 1rem !important; - font-weight: 500; - color: var(--color-neutral-100); + /* Resizing state for handles (applied by neodrag) */ + :global(.resizing) { + opacity: 0.9; } - .control-content { - padding: 0 1rem 1rem 1rem; - display: flex; - flex-direction: column; - gap: 1rem; + :global(.resizing .handle) { + transform: scale(1.3); + background: var(--color-primary-110); } - .control-row { + .controls-section { display: flex; flex-direction: column; - gap: 0.5rem; - } - - /* Inputs */ - .panel-select, - .panel-select-small, - .panel-input { - width: 100%; - border: 1px solid var(--color-border); - border-radius: var(--border-radius-small); background: var(--color-neutral-0); - color: var(--color-neutral-100); - font-size: var(--font-size-0); - transition: border-color 0.2s; - } - - .panel-select { - padding: 0.5rem; - } - - .panel-select-small { - padding: 0.25rem 0.5rem; - } - - .panel-input { - padding: 0.5rem; - } - - .panel-select:hover, - .panel-input:hover { - border-color: var(--color-neutral-50); - } - - .panel-select:focus, - .panel-input:focus { - outline: none; - border-color: var(--color-primary-100); - box-shadow: 0 0 0 3px rgba(253, 54, 110, 0.1); - } - - /* Dimensions Grid */ - .dimensions-grid { - display: grid; - grid-template-columns: 1fr 1fr auto; - gap: 0.5rem; - align-items: center; - } - - .input-group { - position: relative; - display: flex; - align-items: center; - } - - .input-prefix { - position: absolute; - left: 0.75rem; - color: var(--color-neutral-50); - font-size: var(--font-size-0); - font-weight: 500; - pointer-events: none; - } - - .dimensions-grid .panel-input { - padding-left: 2rem; /* space for prefix */ - padding-right: 20px; /* space for spinner */ - } - - /* Spinner Controls */ - .spinner-controls { - position: absolute; - right: 2px; - top: 2px; - bottom: 2px; - display: flex; - flex-direction: column; - width: 16px; border-left: 1px solid var(--color-border); - background: var(--color-neutral-5); - border-radius: 0 var(--border-radius-small) var(--border-radius-small) 0; + height: 100%; + overflow-y: auto; } - .spinner-btn { + .code-panel-wrapper { + padding: 1rem; + border-top: 1px solid var(--color-border); flex: 1; - display: flex; - align-items: center; - justify-content: center; - border: none; - background: transparent; - font-size: 8px; - color: var(--color-neutral-50); - cursor: pointer; - padding: 0; - } - - .spinner-btn:hover { - background: var(--color-neutral-10); - color: var(--color-neutral-100); - } - - .spinner-btn:first-child { - border-bottom: 1px solid var(--color-border); - } - - /* Lock Button */ - .lock-btn { - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - border: none; - background: transparent; - color: var(--color-neutral-50); - border-radius: var(--border-radius-small); - cursor: pointer; - transition: all 0.2s; - } - - .lock-btn:hover { - background: var(--color-neutral-10); - color: var(--color-neutral-80); - } - - .lock-btn.is-locked { - color: var(--color-neutral-100); - } - - /* Helpers */ - .full-width { - width: 100%; - } - - .label-muted { - color: var(--color-neutral-70); - } - - .color-input-small, - .color-input-full { - padding: 2px; - border: 1px solid var(--color-border); - border-radius: var(--border-radius-small); - cursor: pointer; - background: var(--color-neutral-0); - } - - .color-input-small { - width: 38px; - height: 38px; - flex-shrink: 0; - } - - .color-input-full { - width: 100%; - height: 38px; + overflow-y: auto; } - /* Layout Media Query */ @media (max-width: 1024px) { .editor-layout { grid-template-columns: 1fr; @@ -850,7 +617,8 @@ .controls-section { order: -1; - border-right: 1px solid var(--color-border); + border-left: none; + border-bottom: 1px solid var(--color-border); } } diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/codePanel.svelte b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/codePanel.svelte new file mode 100644 index 0000000000..1e0827919e --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/codePanel.svelte @@ -0,0 +1,80 @@ + + + + + Code + + + +
+ +
+
+ + + diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/gridOverlay.svelte b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/gridOverlay.svelte new file mode 100644 index 0000000000..d841abebdf --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/gridOverlay.svelte @@ -0,0 +1,92 @@ + + +
+ {#if type === 'rule-of-thirds'} + + + + + + + + + + {:else if type === 'dots'} + + + + + + + + + + {/if} +
+ + + diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/imageGrid.svelte b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/imageGrid.svelte new file mode 100644 index 0000000000..32b508858d --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/imageGrid.svelte @@ -0,0 +1,138 @@ + + +{#if imageFiles.length === 0} +
+

No images found in this bucket

+
+{:else} + + {#each imageFiles as file (file.$id)} + {@const previewUrl = getPreview(file.$id, appliedPresets[file.$id] ? transformationState : undefined)} + {@const isSelected = selectedFile?.$id === file.$id} +
selectFile(file)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + selectFile(file); + } + }}> +
+ + {#if appliedPresets[file.$id]} + + {/if} +
+
{file.name}
+
+ {/each} +
+{/if} + + + diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/presetManager.ts b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/presetManager.ts new file mode 100644 index 0000000000..a54e3e612f --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/presetManager.ts @@ -0,0 +1,60 @@ +import type { TransformationState } from '$lib/helpers/imageTransformations'; + +export type Preset = { + id: string; + name: string; + transformations: TransformationState; + createdAt: number; +}; + +const STORAGE_PREFIX = 'image-presets-'; + +export function getPresets(bucketId: string): Preset[] { + if (typeof window === 'undefined') return []; + try { + const stored = localStorage.getItem(`${STORAGE_PREFIX}${bucketId}`); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } +} + +export function savePreset(bucketId: string, preset: Preset): void { + if (typeof window === 'undefined') return; + try { + const presets = getPresets(bucketId); + const existingIndex = presets.findIndex((p) => p.id === preset.id); + if (existingIndex >= 0) { + presets[existingIndex] = preset; + } else { + presets.push(preset); + } + localStorage.setItem(`${STORAGE_PREFIX}${bucketId}`, JSON.stringify(presets)); + } catch (error) { + console.error('Failed to save preset:', error); + } +} + +export function deletePreset(bucketId: string, presetId: string): void { + if (typeof window === 'undefined') return; + try { + const presets = getPresets(bucketId); + const filtered = presets.filter((p) => p.id !== presetId); + localStorage.setItem(`${STORAGE_PREFIX}${bucketId}`, JSON.stringify(filtered)); + } catch (error) { + console.error('Failed to delete preset:', error); + } +} + +export function createPreset( + name: string, + transformations: TransformationState +): Preset { + return { + id: `preset-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + name, + transformations: { ...transformations }, + createdAt: Date.now() + }; +} + diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/transformationPanel.svelte b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/transformationPanel.svelte new file mode 100644 index 0000000000..6d011f76bc --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/transformationPanel.svelte @@ -0,0 +1,579 @@ + + +
+ +
+ + (activeTab = 'design')}> + Design + + (activeTab = 'code')}> + Code + + + ({ value: z, label: `${z}%` }))} + bind:value={zoom} /> +
+ + {#if activeTab === 'design'} + +
+ +
+
+ Dimensions +
+ +
+ W + + handleWidthChange(parseInt(e.currentTarget.value) || 0)} /> +
+ + +
+
+ +
+ H + + handleHeightChange(parseInt(e.currentTarget.value) || 0)} /> +
+ + +
+
+ + +
+
+ +
+ Crop + +
+
+
+
+ + +
+ +
+
+ Color +
+ { + transformationState.borderColor = (e.target as HTMLInputElement).value.replace('#', ''); + }} /> + { + const value = (e.target as HTMLInputElement).value.replace('#', ''); + if (/^[0-9A-Fa-f]{0,6}$/.test(value)) { + transformationState.borderColor = value; + } + }} /> +
+ + +
+
+
+
+ Width +
+ { + transformationState.borderWidth = parseInt(e.currentTarget.value) || 0; + }} /> +
+ + +
+
+
+
+ Border radius +
+ + { + transformationState.borderRadius = parseInt(e.currentTarget.value) || 0; + }} /> + +
+
+
+
+
+ + +
+ +
+
+ Background Color + { + transformationState.background = (e.target as HTMLInputElement).value.replace('#', ''); + }} /> +
+
+
+
+ + +
+ +
+ +
+
+
+ {/if} +
+ + + From ebe3b13e60a162ee2e3dd95e6b349cbee3339e81 Mon Sep 17 00:00:00 2001 From: Harsh Mahajan <127186841+HarshMN2345@users.noreply.github.com> Date: Mon, 12 Jan 2026 19:34:17 +0530 Subject: [PATCH 5/5] removed some code --- src/lib/helpers/imageTransformations.ts | 15 +- .../bucket-[bucket]/editor/+page.svelte | 627 ++++++++++-------- .../editor/components/codePanel.svelte | 12 +- .../editor/components/gridOverlay.svelte | 92 --- .../editor/components/imageGrid.svelte | 28 +- .../editor/components/presetManager.ts | 6 +- .../components/transformationPanel.svelte | 253 +++++-- 7 files changed, 575 insertions(+), 458 deletions(-) delete mode 100644 src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/gridOverlay.svelte diff --git a/src/lib/helpers/imageTransformations.ts b/src/lib/helpers/imageTransformations.ts index 525eddd2aa..b42d969b8c 100644 --- a/src/lib/helpers/imageTransformations.ts +++ b/src/lib/helpers/imageTransformations.ts @@ -6,7 +6,6 @@ export type TransformationState = { gravity?: string; // focal point: 'top-left', 'top', 'top-right', 'left', 'center', 'right', 'bottom-left', 'bottom', 'bottom-right' borderWidth?: number; borderColor?: string; // hex without # - borderStyle?: string; // 'solid', 'dashed', 'dotted' borderOpacity?: number; // 0-100 borderRadius?: number; background?: string; // hex without # @@ -20,22 +19,19 @@ export function generateTransformationParams( ): Record { const params: Record = {}; - if (state.width) params.w = state.width; - if (state.height) params.h = state.height; + if (state.width) params.width = state.width; + if (state.height) params.height = state.height; if (state.gravity && state.gravity !== 'center') { params.gravity = state.gravity; } if (state.borderWidth && state.borderWidth > 0) { - params.border = state.borderWidth; + params.borderWidth = state.borderWidth; if (state.borderColor) { - params['border-color'] = state.borderColor.replace('#', ''); - } - if (state.borderStyle) { - params['border-style'] = state.borderStyle; + params.borderColor = state.borderColor.replace('#', ''); } } if (state.borderRadius && state.borderRadius > 0) { - params['border-radius'] = state.borderRadius; + params.borderRadius = state.borderRadius; } if (state.background) { params.background = state.background.replace('#', ''); @@ -125,4 +121,3 @@ export function getFormatLabel(format: ImageFormat): string { return 'JPG'; } } - diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/+page.svelte b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/+page.svelte index 38c9b11fc6..56216a755d 100644 --- a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/+page.svelte @@ -3,19 +3,17 @@ import { Container } from '$lib/layout'; import { sdk } from '$lib/stores/sdk'; import { ImageFormat, Query, type Models } from '@appwrite.io/console'; -import { Layout, Typography, Input } from '@appwrite.io/pink-svelte'; -import { onMount } from 'svelte'; - import { Copy } from '$lib/components'; - import { IconDuplicate } from '@appwrite.io/pink-icons-svelte'; - import { InputSelect } from '$lib/elements/forms'; - import { draggable } from '@neodrag/svelte'; - import type { DragEventData } from '@neodrag/svelte'; -import ImageGrid from './components/imageGrid.svelte'; -import TransformationPanel from './components/transformationPanel.svelte'; -import CodePanel from './components/codePanel.svelte'; -import GridOverlay from './components/gridOverlay.svelte'; -import { getPresets, type Preset } from './components/presetManager'; -import type { TransformationState } from '$lib/helpers/imageTransformations'; + import { Layout, Typography } from '@appwrite.io/pink-svelte'; + import { onMount } from 'svelte'; + import { CopyInput } from '$lib/components'; + import ImageGrid from './components/imageGrid.svelte'; + import TransformationPanel from './components/transformationPanel.svelte'; + import CodePanel from './components/codePanel.svelte'; + import { getPresets, savePreset, createPreset, type Preset } from './components/presetManager'; + import { + generateTransformationParams, + type TransformationState + } from '$lib/helpers/imageTransformations'; // UI State let activeTab = $state<'design' | 'code'>('design'); @@ -25,7 +23,13 @@ import type { TransformationState } from '$lib/helpers/imageTransformations'; let zoom = $state(100); // Transformation state - let transformationState = $state({ + let transformationState = $state< + TransformationState & { + aspectRatioLocked?: boolean; + originalAspectRatio?: number; + crop?: string; + } + >({ width: 700, height: 438, aspectRatioLocked: true, @@ -33,7 +37,6 @@ import type { TransformationState } from '$lib/helpers/imageTransformations'; gravity: 'center', borderWidth: 0, borderColor: '000000', - borderStyle: 'solid', borderOpacity: 100, borderRadius: 0, background: '', @@ -50,34 +53,14 @@ import type { TransformationState } from '$lib/helpers/imageTransformations'; // Canvas state let canvasContainer = $state(); - let resizeStartDimensions = $state<{ width: number; height: number } | null>(null); - - // Derived values for selectors - const fileOptions = $derived(bucketFiles.map(f => ({ - value: f.$id, - label: f.name.length > 15 ? f.name.substring(0, 15) + '...' : f.name - }))); - - const presetOptions = $derived([ - { value: 'none', label: 'None' }, - ...presets.map(p => ({ value: p.id, label: p.name })) - ]); - - let selectedFileId = $derived(selectedFile?.$id || ''); - - function handleFileChange(event: CustomEvent) { - const fileId = event.detail; - const file = bucketFiles.find((f) => f.$id === fileId); - if (file) { - selectedFile = file; - } - } - - function handlePresetChangeTop(event: CustomEvent) { - const value = event.detail; - selectedPresetId = value === 'none' ? null : value; - handlePresetSelected(selectedPresetId); - } + let imageElement = $state(); + let isResizing = $state(false); + let resizeHandle = $state(null); + let startX = $state(0); + let startY = $state(0); + let startWidth = $state(0); + let startHeight = $state(0); + let focalPointOverlay = $state(null); onMount(async () => { try { @@ -108,85 +91,42 @@ import type { TransformationState } from '$lib/helpers/imageTransformations'; transformationState.width = img.width; transformationState.height = img.height; transformationState.originalAspectRatio = img.width / img.height; + transformationState.aspectRatioLocked = true; }; - img.src = getPreviewUrl(); + // Load original image without transformations to get original dimensions + img.src = + sdk + .forProject(page.params.region, page.params.project) + .storage.getFilePreview({ + bucketId: selectedFile.bucketId, + fileId: selectedFile.$id + }) + .toString() + '&mode=admin'; } function getPreviewUrl(): string { if (!selectedFile) return ''; - - // Build params for SDK (camelCase) - const sdkParams: any = { + const params = generateTransformationParams(transformationState); + const previewParams: any = { bucketId: selectedFile.bucketId, - fileId: selectedFile.$id + fileId: selectedFile.$id, + ...params }; - - if (transformationState.width) sdkParams.width = transformationState.width; - if (transformationState.height) sdkParams.height = transformationState.height; - if (transformationState.gravity && transformationState.gravity !== 'center') { - sdkParams.gravity = transformationState.gravity; - } - if (transformationState.borderWidth && transformationState.borderWidth > 0) { - sdkParams.borderWidth = transformationState.borderWidth; - if (transformationState.borderColor) { - sdkParams.borderColor = transformationState.borderColor.replace('#', ''); - } - if (transformationState.borderStyle) { - sdkParams.borderStyle = transformationState.borderStyle; - } - } - if (transformationState.borderRadius && transformationState.borderRadius > 0) { - sdkParams.borderRadius = transformationState.borderRadius; - } - if (transformationState.background) { - sdkParams.background = transformationState.background.replace('#', ''); - } - if (transformationState.quality && transformationState.quality < 100) { - sdkParams.quality = transformationState.quality; - } - if (transformationState.output) { - sdkParams.output = transformationState.output; - } - if (transformationState.rotation && transformationState.rotation !== 0) { - sdkParams.rotation = transformationState.rotation; - } - - const baseUrl = sdk - .forProject(page.params.region, page.params.project) - .storage.getFilePreview(sdkParams) - .toString(); - - // Format URL parameters to match the desired format (kebab-case) - const url = new URL(baseUrl); - const params = new URLSearchParams(); - - // Convert camelCase to kebab-case for display - url.searchParams.forEach((value, key) => { - if (key === 'width') { - params.set('w', value); - } else if (key === 'height') { - params.set('h', value); - } else if (key === 'borderWidth') { - params.set('border', value); - } else if (key === 'borderColor') { - params.set('border-color', value); - } else if (key === 'borderStyle') { - params.set('border-style', value); - } else if (key === 'borderRadius') { - params.set('border-radius', value); - } else { - params.set(key, value); - } - }); - - return `${url.origin}${url.pathname}?${params.toString()}&mode=admin`; + return ( + sdk + .forProject(page.params.region, page.params.project) + .storage.getFilePreview(previewParams) + .toString() + '&mode=admin' + ); } let previewUrl = $derived(getPreviewUrl()); // Watch for file selection changes and apply presets + let lastSelectedFileId = $state(null); $effect(() => { - if (selectedFile) { + if (selectedFile && selectedFile.$id !== lastSelectedFileId) { + lastSelectedFileId = selectedFile.$id; // Reset transformations or apply preset if one is selected for this file if (appliedPresets[selectedFile.$id]) { const preset = presets.find((p) => p.id === appliedPresets[selectedFile.$id]); @@ -194,13 +134,14 @@ import type { TransformationState } from '$lib/helpers/imageTransformations'; transformationState = { ...preset.transformations }; } } else { + // Reset to original dimensions loadImageDimensions(); } } }); function handleFocalPointClick(event: MouseEvent) { - if (!canvasContainer || !selectedFile || resizeStartDimensions) return; + if (!canvasContainer || !selectedFile || isResizing) return; const rect = canvasContainer.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; @@ -222,97 +163,57 @@ import type { TransformationState } from '$lib/helpers/imageTransformations'; else if (y > thirdY * 2) point = 'bottom'; transformationState.gravity = point; + focalPointOverlay = point; + setTimeout(() => (focalPointOverlay = null), 1000); } - // Store handle positions to keep them at corners - let handlePosNw = $state({ x: 0, y: 0 }); - let handlePosNe = $state({ x: 0, y: 0 }); - let handlePosSw = $state({ x: 0, y: 0 }); - let handlePosSe = $state({ x: 0, y: 0 }); - - function createResizeHandler(handle: string) { - // Determine cursor and axis based on handle position - const isDiagonal = handle.length === 2; - const axis = isDiagonal ? 'both' : (handle.includes('e') || handle.includes('w') ? 'x' : 'y'); - - // Get the appropriate handle position based on handle name - let handlePos: { x: number; y: number }; - if (handle === 'nw') handlePos = handlePosNw; - else if (handle === 'ne') handlePos = handlePosNe; - else if (handle === 'sw') handlePos = handlePosSw; - else handlePos = handlePosSe; - - return { - onDragStart: () => { - resizeStartDimensions = { - width: transformationState.width || 0, - height: transformationState.height || 0 - }; - handlePos.x = 0; - handlePos.y = 0; - }, - onDrag: ({ offsetX, offsetY }: DragEventData) => { - if (!resizeStartDimensions) return; - - const scale = zoom / 100; - // Calculate dimension changes based on drag offset - const dx = offsetX / scale; - const dy = offsetY / scale; - - let newWidth = resizeStartDimensions.width; - let newHeight = resizeStartDimensions.height; - - // Calculate new dimensions based on handle position - // East (right) handle: increase width - if (handle.includes('e')) { - newWidth = Math.max(1, resizeStartDimensions.width + dx); - } - // West (left) handle: decrease width - if (handle.includes('w')) { - newWidth = Math.max(1, resizeStartDimensions.width - dx); - } - // South (bottom) handle: increase height - if (handle.includes('s')) { - newHeight = Math.max(1, resizeStartDimensions.height + dy); - } - // North (top) handle: decrease height - if (handle.includes('n')) { - newHeight = Math.max(1, resizeStartDimensions.height - dy); - } + function handleResizeStart(event: MouseEvent, handle: string) { + event.stopPropagation(); + isResizing = true; + resizeHandle = handle; + startX = event.clientX; + startY = event.clientY; + startWidth = transformationState.width || 0; + startHeight = transformationState.height || 0; + } - // Apply aspect ratio lock with smooth calculation - if (transformationState.aspectRatioLocked && transformationState.originalAspectRatio) { - // For diagonal handles, prefer width-based calculation - if (handle.includes('e') || handle.includes('w')) { - newHeight = Math.round(newWidth / transformationState.originalAspectRatio); - } else { - newWidth = Math.round(newHeight * transformationState.originalAspectRatio); - } - } + function handleMouseMove(event: MouseEvent) { + if (!isResizing || !resizeHandle) return; + const dx = event.clientX - startX; + const dy = event.clientY - startY; + const scale = zoom / 100; - // Apply with smooth transition (physics-like) - transformationState.width = Math.round(newWidth); - transformationState.height = Math.round(newHeight); - - // Reset handle position to keep it at the corner - handlePos.x = 0; - handlePos.y = 0; - }, - onDragEnd: () => { - resizeStartDimensions = null; - handlePos.x = 0; - handlePos.y = 0; - }, - // Physics-like behavior: grid snapping and smooth movement - grid: [5, 5], // Snap to 5px grid for smoother feel - threshold: { distance: 2 }, // Small threshold to prevent accidental drags - gpuAcceleration: true, // Smooth hardware-accelerated movement - axis: axis as 'both' | 'x' | 'y', // Constrain movement based on handle - // Keep handle at corner by resetting position reactively - position: handlePos, - // Add smooth easing for physics-like feel - defaultClassDragging: 'resizing' - }; + let newWidth = startWidth; + let newHeight = startHeight; + + if (resizeHandle.includes('e')) { + newWidth = Math.max(1, startWidth + dx / scale); + } + if (resizeHandle.includes('w')) { + newWidth = Math.max(1, startWidth - dx / scale); + } + if (resizeHandle.includes('s')) { + newHeight = Math.max(1, startHeight + dy / scale); + } + if (resizeHandle.includes('n')) { + newHeight = Math.max(1, startHeight - dy / scale); + } + + if (transformationState.aspectRatioLocked && transformationState.originalAspectRatio) { + if (resizeHandle.includes('e') || resizeHandle.includes('w')) { + newHeight = Math.round(newWidth / transformationState.originalAspectRatio); + } else { + newWidth = Math.round(newHeight * transformationState.originalAspectRatio); + } + } + + transformationState.width = newWidth; + transformationState.height = newHeight; + } + + function handleMouseUp() { + isResizing = false; + resizeHandle = null; } function handlePresetSelected(presetId: string | null) { @@ -328,6 +229,50 @@ import type { TransformationState } from '$lib/helpers/imageTransformations'; } } + function saveCurrentAsPreset() { + const name = prompt('Enter preset name:'); + if (!name || !selectedFile) return; + const preset = createPreset(name, transformationState); + savePreset(page.params.bucket, preset); + presets = getPresets(page.params.bucket); + selectedPresetId = preset.id; + appliedPresets[selectedFile.$id] = preset.id; + } + + function getFocalPointLabel(): string { + const point = transformationState.gravity || 'center'; + const labels: Record = { + 'top-left': 'Top-Left', + top: 'Top', + 'top-right': 'Top-Right', + left: 'Left', + center: 'Center', + right: 'Right', + 'bottom-left': 'Bottom-Left', + bottom: 'Bottom', + 'bottom-right': 'Bottom-Right' + }; + return labels[point] || 'Center'; + } + + function getFocalPointPosition(): { top: string; left: string; width: string; height: string } { + const point = transformationState.gravity || 'center'; + const positions: Record< + string, + { top: string; left: string; width: string; height: string } + > = { + 'top-left': { top: '0%', left: '0%', width: '33.33%', height: '33.33%' }, + top: { top: '0%', left: '33.33%', width: '33.33%', height: '33.33%' }, + 'top-right': { top: '0%', left: '66.66%', width: '33.33%', height: '33.33%' }, + left: { top: '33.33%', left: '0%', width: '33.33%', height: '33.33%' }, + center: { top: '33.33%', left: '33.33%', width: '33.33%', height: '33.33%' }, + right: { top: '33.33%', left: '66.66%', width: '33.33%', height: '33.33%' }, + 'bottom-left': { top: '66.66%', left: '0%', width: '33.33%', height: '33.33%' }, + bottom: { top: '66.66%', left: '33.33%', width: '33.33%', height: '33.33%' }, + 'bottom-right': { top: '66.66%', left: '66.66%', width: '33.33%', height: '33.33%' } + }; + return positions[point] || positions.center; + } @@ -349,91 +294,124 @@ import type { TransformationState } from '$lib/helpers/imageTransformations'; + {transformationState} + {appliedPresets} /> {:else} -
- -
-
- - https:// - - - - -
-
- - -
+ + +
+
+ +
+ + Focal point: {getFocalPointLabel()} + +
+
+ onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleFocalPointClick(e as any); + } + }} + onmousemove={handleMouseMove} + onmouseup={handleMouseUp} + onmouseleave={handleMouseUp} + style="cursor: crosshair;">
{selectedFile.name} - - +
+
+
+
+
+
+ + {#if focalPointOverlay || transformationState.gravity} + {@const pos = getFocalPointPosition()} +
+
+ {/if} +
+ onmousedown={(e) => handleResizeStart(e, 'nw')} + aria-label="Resize from top-left" + style="cursor: nwse-resize;"> + onmousedown={(e) => handleResizeStart(e, 'ne')} + aria-label="Resize from top-right" + style="cursor: nesw-resize;"> + onmousedown={(e) => handleResizeStart(e, 'sw')} + aria-label="Resize from bottom-left" + style="cursor: nesw-resize;"> + onmousedown={(e) => handleResizeStart(e, 'se')} + aria-label="Resize from bottom-right" + style="cursor: nwse-resize;">
+ + {transformationState.width || 0} × {transformationState.height || 0} + + + +
+ + {transformationState.rotation || 0}° +
- +
+ {presets} + bind:selectedPresetId + bind:zoom + on:presetSelected={(e) => handlePresetSelected(e.detail)} /> {#if activeTab === 'code' && selectedFile}
@@ -443,9 +421,16 @@ import type { TransformationState } from '$lib/helpers/imageTransformations'; fileId={selectedFile.$id} />
{/if} + + +
+ +
-
+ {/if} @@ -459,56 +444,48 @@ import type { TransformationState } from '$lib/helpers/imageTransformations'; text-align: center; } - .editor-wrapper { - display: flex; - flex-direction: column; - gap: var(--space-m); - } - - .editor-header { - display: flex; - align-items: center; - gap: var(--space-m); - } - - .url-input-wrapper { - flex: 1; - } - - .header-selectors { - display: flex; - gap: var(--space-s); - } - - :global(.header-selectors > *) { - min-width: 120px; + .url-section { + width: 100%; } .editor-layout { display: grid; - grid-template-columns: 1fr 280px; - gap: 0; - min-height: 550px; + grid-template-columns: 1fr 320px; + gap: 1.5rem; + min-height: 600px; border: 1px solid var(--color-border); - border-radius: var(--border-radius-small); + border-radius: var(--border-radius-medium); overflow: hidden; } .preview-section { - background: var(--color-neutral-10); + background: var(--color-neutral-5); position: relative; display: flex; flex-direction: column; overflow: hidden; } + .focal-point-section { + position: absolute; + top: 1rem; + left: 1rem; + z-index: 10; + background: rgba(255, 255, 255, 0.9); + padding: 0.5rem 0.75rem; + border-radius: var(--border-radius-small); + pointer-events: none; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + .preview-container { flex: 1; display: flex; + flex-direction: column; align-items: center; justify-content: center; position: relative; - padding: var(--space-xl); + padding: 2rem; overflow: auto; } @@ -526,6 +503,39 @@ import type { TransformationState } from '$lib/helpers/imageTransformations'; user-select: none; } + .grid-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + } + + .grid-line { + position: absolute; + background: rgba(255, 255, 255, 0.3); + } + + .grid-line-v { + top: 0; + bottom: 0; + width: 1px; + } + + .grid-line-h { + left: 0; + right: 0; + height: 1px; + } + + .focal-overlay { + position: absolute; + background: rgba(59, 130, 246, 0.3); + border: 2px solid rgba(59, 130, 246, 0.6); + pointer-events: none; + transition: all 0.2s; + } .resize-handles { position: absolute; @@ -545,53 +555,65 @@ import type { TransformationState } from '$lib/helpers/imageTransformations'; border-radius: 50%; pointer-events: all; z-index: 10; - cursor: nwse-resize; - transition: transform 0.1s ease-out, background-color 0.2s; - will-change: transform; - } - - .handle:hover { - transform: scale(1.2); - background: var(--color-primary-110); } .handle-nw { top: -6px; left: -6px; - cursor: nwse-resize; } .handle-ne { top: -6px; right: -6px; - cursor: nesw-resize; } .handle-sw { bottom: -6px; left: -6px; - cursor: nesw-resize; } .handle-se { bottom: -6px; right: -6px; - cursor: nwse-resize; } - /* Smooth transitions for image dimensions with physics-like easing */ - .preview-image { - transition: width 0.15s cubic-bezier(0.4, 0, 0.2, 1), height 0.15s cubic-bezier(0.4, 0, 0.2, 1); + /* svelte-ignore css_unused_selector */ + .dimensions-text { + margin-top: 1rem; + background: var(--color-neutral-0); + padding: 0.5rem 0.75rem; + border-radius: var(--border-radius-small); + border: 1px solid var(--color-border); + } + + .rotation-slider { + margin-top: 1rem; + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + max-width: 300px; } - /* Resizing state for handles (applied by neodrag) */ - :global(.resizing) { - opacity: 0.9; + .slider { + flex: 1; + height: 4px; + border-radius: 2px; + background: var(--color-neutral-20); + outline: none; + accent-color: var(--color-primary-100); } - :global(.resizing .handle) { - transform: scale(1.3); - background: var(--color-primary-110); + /* svelte-ignore css_unused_selector */ + .rotation-text { + min-width: 40px; + text-align: right; + color: var(--color-neutral-70); + } + + .handle { + border: none; + padding: 0; } .controls-section { @@ -606,10 +628,33 @@ import type { TransformationState } from '$lib/helpers/imageTransformations'; .code-panel-wrapper { padding: 1rem; border-top: 1px solid var(--color-border); - flex: 1; + max-height: 400px; overflow-y: auto; } + .preset-actions { + padding: 1rem; + border-top: 1px solid var(--color-border); + margin-top: auto; + } + + .save-preset-btn { + width: 100%; + padding: 0.75rem; + background: var(--color-primary-100); + color: white; + border: none; + border-radius: var(--border-radius-small); + font-size: var(--font-size-0); + font-weight: 500; + cursor: pointer; + transition: background 0.2s; + } + + .save-preset-btn:hover { + background: var(--color-primary-110); + } + @media (max-width: 1024px) { .editor-layout { grid-template-columns: 1fr; diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/codePanel.svelte b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/codePanel.svelte index 1e0827919e..f47e08ac36 100644 --- a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/codePanel.svelte +++ b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/codePanel.svelte @@ -16,9 +16,7 @@ let selectedSDK = $state<'js' | 'python' | 'flutter' | 'swift' | 'kotlin'>('js'); - const code = $derived( - generateSDKCode(transformationState, bucketId, fileId, selectedSDK) - ); + const code = $derived(generateSDKCode(transformationState, bucketId, fileId, selectedSDK)); const sdkOptions = [ { label: 'JavaScript', value: 'js' as const }, @@ -53,12 +51,7 @@
- +
@@ -77,4 +70,3 @@ overflow-y: auto; } - diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/gridOverlay.svelte b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/gridOverlay.svelte deleted file mode 100644 index d841abebdf..0000000000 --- a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/gridOverlay.svelte +++ /dev/null @@ -1,92 +0,0 @@ - - -
- {#if type === 'rule-of-thirds'} - - - - - - - - - - {:else if type === 'dots'} - - - - - - - - - - {/if} -
- - - diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/imageGrid.svelte b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/imageGrid.svelte index 32b508858d..a88f1f47b6 100644 --- a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/imageGrid.svelte +++ b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/imageGrid.svelte @@ -29,15 +29,19 @@ if (transformations?.gravity) params.gravity = transformations.gravity; if (transformations?.borderWidth) params.borderWidth = transformations.borderWidth; - if (transformations?.borderColor) params.borderColor = transformations.borderColor.replace('#', ''); + if (transformations?.borderColor) + params.borderColor = transformations.borderColor.replace('#', ''); if (transformations?.borderRadius) params.borderRadius = transformations.borderRadius; - if (transformations?.background) params.background = transformations.background.replace('#', ''); + if (transformations?.background) + params.background = transformations.background.replace('#', ''); if (transformations?.quality) params.quality = transformations.quality; - return sdk - .forProject(page.params.region, page.params.project) - .storage.getFilePreview(params) - .toString() + '&mode=admin'; + return ( + sdk + .forProject(page.params.region, page.params.project) + .storage.getFilePreview(params) + .toString() + '&mode=admin' + ); } function selectFile(file: Models.File) { @@ -58,7 +62,10 @@ {:else} {#each imageFiles as file (file.$id)} - {@const previewUrl = getPreview(file.$id, appliedPresets[file.$id] ? transformationState : undefined)} + {@const previewUrl = getPreview( + file.$id, + appliedPresets[file.$id] ? transformationState : undefined + )} {@const isSelected = selectedFile?.$id === file.$id}
{#if appliedPresets[file.$id]} - + {/if}
{file.name}
@@ -135,4 +146,3 @@ white-space: nowrap; } - diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/presetManager.ts b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/presetManager.ts index a54e3e612f..516f738bb0 100644 --- a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/presetManager.ts +++ b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/presetManager.ts @@ -46,10 +46,7 @@ export function deletePreset(bucketId: string, presetId: string): void { } } -export function createPreset( - name: string, - transformations: TransformationState -): Preset { +export function createPreset(name: string, transformations: TransformationState): Preset { return { id: `preset-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, name, @@ -57,4 +54,3 @@ export function createPreset( createdAt: Date.now() }; } - diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/transformationPanel.svelte b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/transformationPanel.svelte index 6d011f76bc..c09c88649e 100644 --- a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/transformationPanel.svelte +++ b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/transformationPanel.svelte @@ -1,19 +1,34 @@
+ +
+
+ +
+
+ +
+
+
- - (activeTab = 'design')}> +
+ + +
+
+ +
{#if activeTab === 'design'} @@ -78,7 +151,8 @@
- Dimensions + Dimensions
@@ -92,8 +166,10 @@ oninput={(e) => handleWidthChange(parseInt(e.currentTarget.value) || 0)} />
- - + +
@@ -108,23 +184,35 @@ oninput={(e) => handleHeightChange(parseInt(e.currentTarget.value) || 0)} />
- - + +
@@ -224,7 +329,8 @@
- Border radius + Border radius
@@ -319,6 +445,14 @@ height: 100%; } + .panel-header { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--color-border); + } + .view-toggle-section { padding: 0.75rem 1rem; display: flex; @@ -327,6 +461,39 @@ border-bottom: 1px solid var(--color-border); } + .segmented-control { + display: inline-flex; + background: var(--color-neutral-10); + padding: 2px; + border-radius: var(--border-radius-small); + } + + .segment-btn { + padding: 0.25rem 0.75rem; + font-size: var(--font-size-0); + font-weight: 500; + color: var(--color-neutral-60); + background: transparent; + border: none; + border-radius: calc(var(--border-radius-small) - 2px); + cursor: pointer; + transition: all 0.2s; + } + + .segment-btn:hover { + color: var(--color-neutral-100); + } + + .segment-btn.is-active { + background: var(--color-neutral-0); + color: var(--color-neutral-100); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + } + + .zoom-selector { + min-width: 70px; + } + .accordion-group { border-bottom: 1px solid var(--color-border); background: var(--color-neutral-0); @@ -352,6 +519,7 @@ } .panel-select, + .panel-select-small, .panel-input { width: 100%; border: 1px solid var(--color-border); @@ -366,6 +534,10 @@ padding: 0.5rem; } + .panel-select-small { + padding: 0.25rem 0.5rem; + } + .panel-input { padding: 0.5rem; } @@ -471,6 +643,7 @@ width: 100%; } + /* svelte-ignore css_unused_selector */ .label-muted { color: var(--color-neutral-70); } @@ -574,6 +747,4 @@ .export-plus-icon { margin-left: auto; } - -