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/8] 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/8] 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/8] 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/8] 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/8] 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; } - - From 0420810f72e960b5f2d8fc4806bf2142dffb147e Mon Sep 17 00:00:00 2001 From: Harsh Mahajan <127186841+HarshMN2345@users.noreply.github.com> Date: Thu, 15 Jan 2026 12:53:36 +0530 Subject: [PATCH 6/8] minimum editor --- package.json | 4 +- pnpm-lock.yaml | 20 +- .../bucket-[bucket]/editor/+page.svelte | 387 +++++++++--------- 3 files changed, 200 insertions(+), 211 deletions(-) diff --git a/package.json b/package.json index 83feebd364..cde91d1c53 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,9 @@ "@ai-sdk/svelte": "^1.1.24", "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@f21fc7f", "@appwrite.io/pink-icons": "0.25.0", - "@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@865e2fc", + "@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@957a779", "@appwrite.io/pink-legacy": "^1.0.3", - "@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@865e2fc", + "@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@957a779", "@faker-js/faker": "^9.9.0", "@neodrag/svelte": "^2.3.3", "@popperjs/core": "^2.11.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7a9af9aeb..2d3be5ae05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,14 +18,14 @@ importers: specifier: 0.25.0 version: 0.25.0 '@appwrite.io/pink-icons-svelte': - specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@865e2fc - version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@865e2fc(svelte@5.25.3) + specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@957a779 + version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@957a779(svelte@5.25.3) '@appwrite.io/pink-legacy': specifier: ^1.0.3 version: 1.0.3 '@appwrite.io/pink-svelte': - specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@865e2fc - version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@865e2fc(svelte@5.25.3) + specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@957a779 + version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@957a779(svelte@5.25.3) '@faker-js/faker': specifier: ^9.9.0 version: 9.9.0 @@ -284,8 +284,8 @@ packages: peerDependencies: svelte: ^4.0.0 - '@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@865e2fc': - resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@865e2fc} + '@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@957a779': + resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@957a779} version: 2.0.0-RC.1 peerDependencies: svelte: ^4.0.0 @@ -299,8 +299,8 @@ packages: '@appwrite.io/pink-legacy@1.0.3': resolution: {integrity: sha512-GGde5fmPhs+s6/3aFeMPc/kKADG/gTFkYQSy6oBN8pK0y0XNCLrZZgBv+EBbdhwdtqVEWXa0X85Mv9w7jcIlwQ==} - '@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@865e2fc': - resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@865e2fc} + '@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@957a779': + resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@957a779} version: 2.0.0-RC.2 peerDependencies: svelte: ^4.0.0 @@ -3837,7 +3837,7 @@ snapshots: dependencies: svelte: 5.25.3 - '@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@865e2fc(svelte@5.25.3)': + '@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@957a779(svelte@5.25.3)': dependencies: svelte: 5.25.3 @@ -3850,7 +3850,7 @@ snapshots: '@appwrite.io/pink-icons': 1.0.0 the-new-css-reset: 1.11.3 - '@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@865e2fc(svelte@5.25.3)': + '@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@957a779(svelte@5.25.3)': dependencies: '@appwrite.io/pink-icons-svelte': 2.0.0-RC.1(svelte@5.25.3) '@floating-ui/dom': 1.6.13 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 56216a755d..3117365566 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,7 +3,7 @@ import { Container } from '$lib/layout'; import { sdk } from '$lib/stores/sdk'; import { ImageFormat, Query, type Models } from '@appwrite.io/console'; - import { Layout, Typography } from '@appwrite.io/pink-svelte'; + import { Layout, Typography, Canvas } from '@appwrite.io/pink-svelte'; import { onMount } from 'svelte'; import { CopyInput } from '$lib/components'; import ImageGrid from './components/imageGrid.svelte'; @@ -14,6 +14,7 @@ generateTransformationParams, type TransformationState } from '$lib/helpers/imageTransformations'; + import type { CanvasObjectUnion } from '@appwrite.io/pink-svelte/dist/canvas/index.js'; // Import type explicitly if needed or infer // UI State let activeTab = $state<'design' | 'code'>('design'); @@ -52,15 +53,95 @@ let appliedPresets = $state>({}); // fileId -> presetId // Canvas state - let canvasContainer = $state(); - 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); + let canvasCore = $state(null); + let currentZoom = $state(1); + + let canvasObjects = $state([]); + + // Force selection of the main image and ensure handles are visible + $effect(() => { + if (canvasCore && selectedFile && canvasObjects.length > 0) { + // Use a small timeout to ensure the canvas has initialized properly + setTimeout(() => { + canvasCore.selectObject('main-image'); + }, 0); + } + }); + + $effect(() => { + if (!canvasCore) return; + const unsubscribe = canvasCore.zoom.subscribe((z: number) => { + currentZoom = z; + zoom = Math.round(z * 100); + }); + return unsubscribe; + }); + // Sync init + $effect(() => { + if (selectedFile) { + const existing = canvasObjects.find((o) => o.id === 'main-image'); + if (!existing) { + // Initialize + canvasObjects = [ + { + id: 'main-image', + type: 'image', + x: 0, + y: 0, + width: transformationState.width || 700, + height: transformationState.height || 438, + rotation: transformationState.rotation || 0, + src: previewUrl, // Use current preview URL + alt: selectedFile.name, + maintainAspectRatio: transformationState.aspectRatioLocked, + selected: true, + // Ensure optional props are set + cropX: 0, + cropY: 0 + } + ]; + // Center the view on init + setTimeout(() => { + canvasCore?.panTo(0, 0); + canvasCore?.selectObject('main-image'); + }, 100); + } else { + // Only update if dimensions drastically mismatch initial load (prevent loops) + // or if rotation changes from external source (like input) + // But wait, if we bind, we should let Canvas update objects. + // We should only push to objects if the user manually types in the inputs. + // The issue is distinguishing input change vs drag change. + // We can check document.activeElement? + } + } + }); + + // Update transformationState when canvasObjects change (drag/resize) + $effect(() => { + const obj = canvasObjects.find((o) => o.id === 'main-image'); + if (obj) { + // Only update if not currently editing inputs? + // Actually, the binding should handle it if we are consistent. + transformationState.width = Math.round(obj.width); + transformationState.height = Math.round(obj.height); + if (obj.rotation !== undefined) transformationState.rotation = obj.rotation; + } + }); + + // When transformationState changes (e.g. user input), update canvas object + // We need to avoid loops. + $effect(() => { + const obj = canvasObjects.find((o) => o.id === 'main-image'); + if (obj) { + if (obj.width !== transformationState.width) obj.width = transformationState.width; + if (obj.height !== transformationState.height) obj.height = transformationState.height; + if (obj.rotation !== transformationState.rotation) + obj.rotation = transformationState.rotation; + if (obj.maintainAspectRatio !== transformationState.aspectRatioLocked) + obj.maintainAspectRatio = transformationState.aspectRatioLocked; + } + }); onMount(async () => { try { @@ -104,9 +185,17 @@ .toString() + '&mode=admin'; } - function getPreviewUrl(): string { + function getPreviewUrl(forCanvas = false): string { if (!selectedFile) return ''; const params = generateTransformationParams(transformationState); + + // For canvas preview, we want to omit width/height so the browser handles scaling + // This prevents "zooming"/flashing artifacts during resize drag + if (forCanvas) { + delete params.width; + delete params.height; + } + const previewParams: any = { bucketId: selectedFile.bucketId, fileId: selectedFile.$id, @@ -120,7 +209,8 @@ ); } - let previewUrl = $derived(getPreviewUrl()); + let previewUrl = $derived(getPreviewUrl(true)); // For canvas + let downloadUrl = $derived(getPreviewUrl(false)); // For final output/code // Watch for file selection changes and apply presets let lastSelectedFileId = $state(null); @@ -141,8 +231,15 @@ }); function handleFocalPointClick(event: MouseEvent) { - if (!canvasContainer || !selectedFile || isResizing) return; - const rect = canvasContainer.getBoundingClientRect(); + if (!selectedFile) return; + + const target = event.target as HTMLElement; + if (target.closest('.resize-handle')) return; + + const objectEl = target.closest('.canvas-object'); + if (!objectEl) return; + + const rect = objectEl.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; const width = rect.width; @@ -167,55 +264,6 @@ setTimeout(() => (focalPointOverlay = null), 1000); } - 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; - } - - function handleMouseMove(event: MouseEvent) { - if (!isResizing || !resizeHandle) return; - const dx = event.clientX - startX; - const dy = event.clientY - startY; - const scale = zoom / 100; - - 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) { selectedPresetId = presetId; if (presetId && selectedFile) { @@ -302,13 +350,13 @@
- +
-
+
@@ -317,72 +365,61 @@
-
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleFocalPointClick(e as any); + + + -
- {selectedFile.name} - -
-
-
-
-
-
- - {#if focalPointOverlay || transformationState.gravity} - {@const pos = getFocalPointPosition()} -
-
- {/if} - -
- - - - -
+ showGrid={true} + width="100%" + height="100%" + bind:objects={canvasObjects}> + +
+ {#each canvasObjects as obj (obj.id)} + {#if obj.type === 'image'} + + {:else if obj.type === 'shape'} + + {/if} + {/each}
+ + + {#if focalPointOverlay || transformationState.gravity} + {#each canvasObjects as obj (obj.id)} + {#if obj.type === 'image' && obj.id === 'main-image'} + {@const pos = getFocalPointPosition()} +
+
+
+
+ {/if} + {/each} + {/if} + + +
{transformationState.width || 0} × {transformationState.height || 0} @@ -478,17 +515,6 @@ 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: 2rem; - overflow: auto; - } - .preview-wrapper { position: relative; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); @@ -498,37 +524,10 @@ .preview-image { display: block; max-width: 100%; - max-height: 70vh; border-radius: var(--border-radius-small); 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); @@ -537,44 +536,39 @@ transition: all 0.2s; } - .resize-handles { + .overlay-controls { position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; + bottom: 1rem; + left: 1rem; + right: 1rem; pointer-events: none; - } - - .handle { - position: absolute; - width: 12px; - height: 12px; - background: var(--color-primary-100); - border: 2px solid white; - border-radius: 50%; - pointer-events: all; + display: flex; + justify-content: space-between; + align-items: flex-end; z-index: 10; } - .handle-nw { - top: -6px; - left: -6px; + /* Fallback styles for resize handles to ensure visibility */ + /* Fallback styles for resize handles to ensure visibility */ + :global(.resize-handle) { + width: 10px !important; + height: 10px !important; + background-color: var(--color-primary-100, #00d9ff) !important; + border: 1px solid blue !important; + z-index: 20 !important; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2) !important; + /* Inverse scale to keep constant size */ + transform: scale(calc(1 / var(--zoom, 1))) !important; + transform-origin: center !important; } - .handle-ne { - top: -6px; - right: -6px; + :global(.resize-handle:hover) { + /* Scale up slightly from the base inverse scale */ + transform: scale(calc(1.2 / var(--zoom, 1))) !important; } - .handle-sw { - bottom: -6px; - left: -6px; - } - - .handle-se { - bottom: -6px; - right: -6px; + .overlay-controls > * { + pointer-events: auto; } /* svelte-ignore css_unused_selector */ @@ -611,11 +605,6 @@ color: var(--color-neutral-70); } - .handle { - border: none; - padding: 0; - } - .controls-section { display: flex; flex-direction: column; From c41c73d551f2b3684e4cfeafd060eb25f676d962 Mon Sep 17 00:00:00 2001 From: Harsh Mahajan <127186841+HarshMN2345@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:11:05 +0530 Subject: [PATCH 7/8] slider --- .../bucket-[bucket]/editor/+page.svelte | 221 +++++++++++++++--- 1 file changed, 190 insertions(+), 31 deletions(-) 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 3117365566..9e8e041081 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 @@ -101,10 +101,87 @@ cropY: 0 } ]; - // Center the view on init + // Initialize + canvasObjects = [ + { + id: 'main-image', + type: 'image', + x: 0, + y: 0, + width: transformationState.width || 700, + height: transformationState.height || 438, + rotation: transformationState.rotation || 0, + src: previewUrl, // Use current preview URL + alt: selectedFile.name, + maintainAspectRatio: transformationState.aspectRatioLocked, + selected: true, + // Ensure optional props are set + cropX: 0, + cropY: 0 + } + ]; + // Center and zoom to fit view on init setTimeout(() => { - canvasCore?.panTo(0, 0); - canvasCore?.selectObject('main-image'); + if (canvasCore) { + // Get container dimensions (assuming it fills parent or use window fallback) + // The canvasCore component binds to canvasEl, but we don't have direct access to it easily + // unless we use document or assume a fixed size. + // But wait, the preview section has a known size or fills the area. + const container = document.querySelector('.preview-section'); + if (container) { + const { width: containerWidth, height: containerHeight } = + container.getBoundingClientRect(); + const imgWidth = transformationState.width || 700; + const imgHeight = transformationState.height || 438; + + const padding = 40; + const availableWidth = containerWidth - padding * 2; + const availableHeight = containerHeight - padding * 2; + + const scaleX = availableWidth / imgWidth; + const scaleY = availableHeight / imgHeight; + const scale = Math.min(scaleX, scaleY, 1); // Don't zoom in more than 100% initially + + // Center the image (0,0 is center in Pink?) No, usually 0,0 is top-left or based on pan. + // Pink canvas usually has (0,0) as origin. Pan offset translates the view. + + // To center: + // The image is at 0,0 (its top-left). + // We need to move the view so the image center aligns with container center. + + const imageCenterX = imgWidth / 2; + const imageCenterY = imgHeight / 2; + + // Not 100% sure on Pink's coordinate system details without deeper check, + // but generally centering involves panning. + // However, let's just use the calculated zoom for now. + + canvasCore.zoomTo(scale, { duration: 0 }); + + // Calculate pan to center + const panX = (containerWidth - imgWidth * scale) / 2 / scale; + const panY = (containerHeight - imgHeight * scale) / 2 / scale; + // Wait, pink-svelte pan logic: newOffset = current + delta / zoom. + // Let's just try centering on the image center. + // Actually, let's look at panTo: viewOffset.set(newOffset). + // transform = translate(offset.x * zoom, offset.y * zoom) + + // We want final transform to center the image. + // Image is at 0,0 to w,h. + // We want image midpoint (w/2, h/2) to comprise the view center. + // View center is (containerW/2, containerH/2) in screen coords. + + // ScreenX = (ObjX + OffsetX) * Zoom + // containerW/2 = (w/2 + OffsetX) * Zoom + // OffsetX = (containerW/2) / Zoom - w/2 + + const offsetX = containerWidth / 2 / scale - imgWidth / 2; + const offsetY = containerHeight / 2 / scale - imgHeight / 2; + + canvasCore.panTo(offsetX, offsetY, { hard: true }); + } + canvasCore.selectObject('main-image'); + } }, 100); } else { // Only update if dimensions drastically mismatch initial load (prevent loops) @@ -209,7 +286,11 @@ ); } - let previewUrl = $derived(getPreviewUrl(true)); // For canvas + let previewUrl = $derived.by(() => { + const url = getPreviewUrl(true); + console.log('Preview URL update:', url); + return url; + }); // For canvas let downloadUrl = $derived(getPreviewUrl(false)); // For final output/code // Watch for file selection changes and apply presets @@ -425,15 +506,23 @@ +
- - {transformationState.rotation || 0}° +
+ +
+ +
+ + +
+ {transformationState.rotation || 0}°
@@ -508,11 +597,16 @@ 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); + background: var(--color-neutral-0); + padding: 0.5rem 1rem; + border-radius: 999px; /* Pill shape */ pointer-events: none; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); + border: 1px solid var(--color-border); + font-size: var(--font-size-0); + font-weight: 500; } .preview-wrapper { @@ -533,7 +627,7 @@ background: rgba(59, 130, 246, 0.3); border: 2px solid rgba(59, 130, 246, 0.6); pointer-events: none; - transition: all 0.2s; + /* No transition to ensure instant sync with resize */ } .overlay-controls { @@ -574,34 +668,99 @@ /* 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); + background: var(--color-neutral-10); + color: var(--color-neutral-100); + padding: 0.25rem 0.5rem; + border-radius: 999px; /* Pill shape */ + border: none; + font-size: var(--font-size-0); + font-family: var(--font-family-mono); + pointer-events: auto; /* Allow selecting text if needed */ } .rotation-slider { margin-top: 1rem; display: flex; + flex-direction: column; align-items: center; - gap: 0.75rem; + gap: 0.25rem; width: 100%; max-width: 300px; + position: relative; + } + + .slider-track { + position: relative; + width: 100%; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 999px; + overflow: hidden; + /* Figma gradient background for rotation bar */ + background-image: linear-gradient( + 90deg, + rgba(25, 25, 28, 0) 0%, + rgba(25, 25, 28, 0.8) 19.663%, + rgba(25, 25, 28, 1) 50.076%, + rgba(25, 25, 28, 0.8) 80.401%, + rgba(25, 25, 28, 0) 100% + ); + } + + /* Ticks generated via background gradient for performance */ + .ticks { + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 8px; /* Slightly taller */ + transform: translateY(-50%); + /* Lighter ticks on top of dark gradient */ + background-image: linear-gradient(to right, rgba(255, 255, 255, 0.7) 1px, transparent 1px); + background-size: 10px 100%; /* Spacing between ticks */ + background-position: center; + -webkit-mask-image: linear-gradient( + to right, + transparent, + black 10%, + black 90%, + transparent + ); + mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent); + opacity: 0.9; + } + + .center-tick { + position: absolute; + left: 50%; + top: 0; + bottom: 0; + width: 2px; + background-color: var(--color-neutral-100, #333); /* Fallback */ + transform: translateX(-50%); + height: 14px; /* Taller center tick */ + top: 3px; + z-index: 5; } .slider { - flex: 1; - height: 4px; - border-radius: 2px; - background: var(--color-neutral-20); - outline: none; - accent-color: var(--color-primary-100); + position: absolute; + width: 100%; + height: 100%; + opacity: 0; /* Invisible input on top */ + cursor: grab; + z-index: 10; + margin: 0; + } + + .slider:active { + cursor: grabbing; } - /* svelte-ignore css_unused_selector */ .rotation-text { - min-width: 40px; - text-align: right; + font-size: var(--font-size-0); color: var(--color-neutral-70); } From efb46ef453be0b723b7da3a9f2609ddc36337abb Mon Sep 17 00:00:00 2001 From: Harsh Mahajan <127186841+HarshMN2345@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:57:54 +0530 Subject: [PATCH 8/8] used components in design panel --- .../bucket-[bucket]/editor/+page.svelte | 94 ++++------ .../components/transformationPanel.svelte | 175 +++++++----------- 2 files changed, 102 insertions(+), 167 deletions(-) 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 9e8e041081..a983c4a78b 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 @@ -268,9 +268,11 @@ // For canvas preview, we want to omit width/height so the browser handles scaling // This prevents "zooming"/flashing artifacts during resize drag + // We also omit rotation so we can handle it instantly via CSS/transform if (forCanvas) { delete params.width; delete params.height; + delete params.rotation; } const previewParams: any = { @@ -498,13 +500,30 @@ {/if} {/each} {/if} + + + {#each canvasObjects as obj (obj.id)} + {#if obj.type === 'image' && obj.id === 'main-image'} +
+ + {Math.round(obj.width)} × {Math.round(obj.height)} + +
+ {/if} + {/each}
- - {transformationState.width || 0} × {transformationState.height || 0} - -
@@ -518,11 +537,12 @@ type="range" min="-180" max="180" - step="1" + step="0.1" bind:value={transformationState.rotation} class="slider" />
- {transformationState.rotation || 0}° + {(transformationState.rotation || 0).toFixed(1)}°
@@ -547,13 +567,6 @@ fileId={selectedFile.$id} />
{/if} - - -
- -
@@ -609,19 +622,6 @@ font-weight: 500; } - .preview-wrapper { - position: relative; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - transform-origin: center; - } - - .preview-image { - display: block; - max-width: 100%; - border-radius: var(--border-radius-small); - user-select: none; - } - .focal-overlay { position: absolute; background: rgba(59, 130, 246, 0.3); @@ -633,13 +633,14 @@ .overlay-controls { position: absolute; bottom: 1rem; - left: 1rem; - right: 1rem; + left: 0; + right: 0; pointer-events: none; display: flex; - justify-content: space-between; + justify-content: center; /* Center the rotation slider */ align-items: flex-end; z-index: 10; + padding-bottom: 1rem; } /* Fallback styles for resize handles to ensure visibility */ @@ -667,7 +668,6 @@ /* svelte-ignore css_unused_selector */ .dimensions-text { - margin-top: 1rem; background: var(--color-neutral-10); color: var(--color-neutral-100); padding: 0.25rem 0.5rem; @@ -675,7 +675,8 @@ border: none; font-size: var(--font-size-0); font-family: var(--font-family-mono); - pointer-events: auto; /* Allow selecting text if needed */ + white-space: nowrap; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .rotation-slider { @@ -699,12 +700,12 @@ border-radius: 999px; overflow: hidden; /* Figma gradient background for rotation bar */ - background-image: linear-gradient( + background: linear-gradient( 90deg, rgba(25, 25, 28, 0) 0%, - rgba(25, 25, 28, 0.8) 19.663%, - rgba(25, 25, 28, 1) 50.076%, - rgba(25, 25, 28, 0.8) 80.401%, + rgba(25, 25, 28, 0.8) 19.66%, + #19191c 50.08%, + rgba(25, 25, 28, 0.8) 80.4%, rgba(25, 25, 28, 0) 100% ); } @@ -780,29 +781,6 @@ 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/transformationPanel.svelte b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/editor/components/transformationPanel.svelte index c09c88649e..e4f5b52df2 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 @@ -4,6 +4,7 @@ import type { TransformationState } from '$lib/helpers/imageTransformations'; import type { Preset } from './presetManager'; import { createEventDispatcher } from 'svelte'; + import { InputNumber, InputSelect } from '$lib/elements/forms'; let { activeTab = $bindable('design'), @@ -40,6 +41,11 @@ const zoomOptions = [50, 75, 100, 125, 150, 200]; + // Local values for dimension inputs so we can bind to InputNumber and + // still reuse the existing aspect-ratio logic. + let widthValue = $state(0); + let heightValue = $state(0); + function handleFileSwitch(event: Event) { const select = event.target as HTMLSelectElement; const fileId = select.value; @@ -72,13 +78,21 @@ } } - function changeWidth(delta: number) { - handleWidthChange((transformationState.width || 0) + delta); - } + // Keep local dimension values in sync with transformation state + $effect(() => { + widthValue = transformationState.width || 0; + heightValue = transformationState.height || 0; + }); + + // Apply aspect-ratio aware updates whenever the local values change + $effect(() => { + handleWidthChange(widthValue); + }); + + $effect(() => { + handleHeightChange(heightValue); + }); - function changeHeight(delta: number) { - handleHeightChange((transformationState.height || 0) + delta); - } function toggleAspectRatioLock() { transformationState.aspectRatioLocked = !transformationState.aspectRatioLocked; @@ -97,28 +111,29 @@
- + options={files.map((file) => ({ + value: file.$id, + label: file.name + }))} + on:change={handleFileSwitch} />
- + options={[ + { value: 'none', label: 'None' }, + ...presets.map((preset) => ({ + value: preset.id, + label: preset.name + })) + ]} + on:change={handlePresetSwitch} />
@@ -156,39 +171,23 @@
- W - - handleWidthChange(parseInt(e.currentTarget.value) || 0)} /> -
- - -
+ W +
- H - - handleHeightChange(parseInt(e.currentTarget.value) || 0)} /> -
- - -
+ H +
@@ -441,7 +439,8 @@ display: flex; flex-direction: column; background: var(--color-neutral-0); - border-left: 1px solid var(--color-border); + /* Let the outer editor layout own the border so the panel visually + attaches to the right edge like the databases spreadsheet layout. */ height: 100%; } @@ -567,53 +566,11 @@ align-items: center; } - .input-prefix { - position: absolute; - left: 0.75rem; - color: var(--color-neutral-50); + .dim-label { + color: var(--color-neutral-70); font-size: var(--font-size-0); font-weight: 500; - pointer-events: none; - } - - .dimensions-grid .panel-input { - padding-left: 2rem; - padding-right: 20px; - } - - .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; - } - - .spinner-btn { - 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); + margin-right: 0.5rem; } .lock-btn {