[Frontend] AI Image Editing Interface for Property Gallery
Feature Overview
Add AI-powered image editing capabilities to the property gallery with a dedicated editing interface. This feature enables landlords to enhance property images using AI tools like room clearing, wall painting, and staging style selection.
Note: This issue covers frontend only. Backend API integration will be handled separately.
---
User Story
As a landlord, I want to edit my property images using AI tools so that I can improve the visual appeal of my listings without hiring a photographer or designer.
---
Design Specifications
Page Layout
The AI Image Editing page should use a split-panel layout:
- Left Panel (3/5 width): Large preview of the currently selected image
- Right Panel (2/5 width): Sticky action panel with editing tools
Action Panel (Right Side)
Four primary action buttons displayed vertically:
- Clear Room - Remove furniture and objects from the image
- Paint Walls White - Change wall color to white
- Choose Staging Style - Opens lightbox to select staging style
- Manual Edit - Open manual editing tools (future enhancement)
Each button should:
- Display an icon + label
- Show loading state when processing
- Be disabled when no image is selected
- Trigger frontend-only logic (backend call placeholder)
Version History (Below Main Panel)
Display a horizontal scrollable timeline of image versions:
- First thumbnail: Original uploaded image (labeled "Original")
- Subsequent thumbnails: Added after each edit action (labeled with edit type: "Cleared", "White Walls", "Staging: Modern", etc.)
- Clicking a thumbnail: Sets it as the main preview
- Hover state: Reveals a checkbox for comparison selection
- Max 2 selections: Enable slider comparison when 2 images are checked
Slider Comparison
When exactly 2 images are selected from version history:
- Replace main preview with a slider comparison view
- Left side: First selected image
- Right side: Second selected image
- Draggable slider in the middle to reveal more/less of each image
- Close comparison button to return to single image view
Staging Style Lightbox
When "Choose Staging Style" is clicked:
- Open a lightbox/dialog overlay
- Display a grid of staging style cards (2-3 columns responsive)
- Each card shows:
- Preview thumbnail of the staging style
- Style name (e.g., "Modern", "Scandinavian", "Industrial", etc.)
- Clicking a card:
- Closes the lightbox
- Triggers frontend logic to apply staging (backend placeholder)
- Adds new version to history
---
Technical Implementation
1. Create New Components
Component: PropertyImageEditorComponent
Location: src/app/account/components/properties/own/gallery/image-editor/
Files to create:
property-image-editor.component.tsproperty-image-editor.component.htmlproperty-image-editor.component.scss
Responsibilities:
- Main container for the editing interface
- Manage selected image state
- Manage version history array
- Handle comparison mode toggle
Key Properties:
export interface ImageVersion {
id: string;
file_url: string;
label: string;
created_at: Date;
is_original: boolean;
}
@Component({
selector: 'app-property-image-editor',
templateUrl: './property-image-editor.component.html',
styleUrls: ['./property-image-editor.component.scss']
})
export class PropertyImageEditorComponent implements OnInit {
@Input() propertyId!: number;
@Input() imageId!: number;
currentImage: ImageVersion | null = null;
versionHistory: ImageVersion[] = [];
selectedForComparison: ImageVersion[] = [];
comparisonMode = false;
isProcessing = false;
constructor(
private dialog: MatDialog,
private snackbar: SnackBarService,
// Backend service to be added later
) {}
clearRoom(): void {
// TODO: Call backend API
// For now, simulate processing
this.isProcessing = true;
setTimeout(() => {
this.addVersionToHistory('Cleared Room', this.currentImage!.file_url);
this.isProcessing = false;
}, 2000);
}
paintWallsWhite(): void {
// TODO: Call backend API
this.isProcessing = true;
setTimeout(() => {
this.addVersionToHistory('White Walls', this.currentImage!.file_url);
this.isProcessing = false;
}, 2000);
}
openStagingStyleSelector(): void {
const dialogRef = this.dialog.open(StagingStyleSelectorDialog, {
width: '800px',
data: { imageId: this.currentImage?.id }
});
dialogRef.afterClosed().subscribe((selectedStyle: string | null) => {
if (selectedStyle) {
this.applyStagingStyle(selectedStyle);
}
});
}
applyStagingStyle(style: string): void {
// TODO: Call backend API
this.isProcessing = true;
setTimeout(() => {
this.addVersionToHistory(`Staging: ${style}`, this.currentImage!.file_url);
this.isProcessing = false;
}, 2000);
}
selectVersion(version: ImageVersion): void {
if (this.comparisonMode) {
// In comparison mode, clicking exits comparison
this.exitComparisonMode();
} else {
this.currentImage = version;
}
}
toggleComparisonSelection(version: ImageVersion, event: Event): void {
event.stopPropagation();
const index = this.selectedForComparison.findIndex(v => v.id === version.id);
if (index > -1) {
this.selectedForComparison.splice(index, 1);
} else if (this.selectedForComparison.length < 2) {
this.selectedForComparison.push(version);
}
if (this.selectedForComparison.length === 2) {
this.enterComparisonMode();
}
}
enterComparisonMode(): void {
this.comparisonMode = true;
}
exitComparisonMode(): void {
this.comparisonMode = false;
this.selectedForComparison = [];
}
private addVersionToHistory(label: string, fileUrl: string): void {
const newVersion: ImageVersion = {
id: `version-${Date.now()}`,
file_url: fileUrl,
label: label,
created_at: new Date(),
is_original: false
};
this.versionHistory.push(newVersion);
this.currentImage = newVersion;
}
}
HTML Structure:
<div class="image-editor-container">
<!-- Main editing area -->
<div class="editor-main">
<!-- Left panel: Preview -->
<div class="preview-panel">
<div *ngIf="!comparisonMode" class="single-image-preview">
<img [src]="currentImage?.file_url" [alt]="currentImage?.label" />
<div *ngIf="isProcessing" class="processing-overlay">
<mat-progress-spinner color="accent" mode="indeterminate"></mat-progress-spinner>
<p i18n>Processing...</p>
</div>
</div>
<div *ngIf="comparisonMode" class="comparison-view">
<!-- Image comparison slider component -->
<app-image-comparison-slider
[leftImage]="selectedForComparison[0]"
[rightImage]="selectedForComparison[1]"
(close)="exitComparisonMode()">
</app-image-comparison-slider>
</div>
</div>
<!-- Right panel: Actions -->
<div class="action-panel">
<h3 i18n>AI Editing Tools</h3>
<button mat-flat-button color="accent"
(click)="clearRoom()"
[disabled]="!currentImage || isProcessing">
<mat-icon>clear_all</mat-icon>
<span i18n>Clear Room</span>
</button>
<button mat-flat-button color="accent"
(click)="paintWallsWhite()"
[disabled]="!currentImage || isProcessing">
<mat-icon>format_paint</mat-icon>
<span i18n>Paint Walls White</span>
</button>
<button mat-flat-button color="accent"
(click)="openStagingStyleSelector()"
[disabled]="!currentImage || isProcessing">
<mat-icon>style</mat-icon>
<span i18n>Choose Staging Style</span>
</button>
<button mat-flat-button color="accent"
[disabled]="true">
<mat-icon>edit</mat-icon>
<span i18n>Manual Edit</span>
<small i18n>(Coming soon)</small>
</button>
</div>
</div>
<!-- Version history -->
<div class="version-history">
<h4 i18n>Version History</h4>
<div class="history-timeline">
<div *ngFor="let version of versionHistory"
class="history-item"
[class.selected]="currentImage?.id === version.id"
[class.comparison-selected]="isSelectedForComparison(version)"
(click)="selectVersion(version)">
<div class="thumbnail" [style.background-image]="'url(' + version.file_url + ')'">
<div class="checkbox-overlay" (click)="toggleComparisonSelection(version, $event)">
<mat-checkbox
[checked]="isSelectedForComparison(version)"
[disabled]="!isSelectedForComparison(version) && selectedForComparison.length >= 2">
</mat-checkbox>
</div>
</div>
<span class="label">{{ version.label }}</span>
</div>
</div>
</div>
</div>
SCSS Styling:
.image-editor-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 24px;
.editor-main {
display: flex;
gap: 24px;
flex: 1;
.preview-panel {
flex: 3;
position: relative;
background: #f5f5f5;
border-radius: 8px;
overflow: hidden;
.single-image-preview {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.processing-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
}
}
}
.action-panel {
flex: 2;
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
position: sticky;
top: 24px;
align-self: flex-start;
button {
width: 100%;
height: 56px;
font-size: 16px;
mat-icon {
margin-right: 8px;
}
}
}
}
.version-history {
margin-top: 32px;
.history-timeline {
display: flex;
gap: 16px;
overflow-x: auto;
padding: 16px 0;
.history-item {
flex-shrink: 0;
cursor: pointer;
text-align: center;
&.selected .thumbnail {
border: 3px solid #00bcd4;
}
&.comparison-selected .thumbnail {
border: 3px solid #ff9800;
}
.thumbnail {
width: 120px;
height: 80px;
background-size: cover;
background-position: center;
border-radius: 4px;
border: 2px solid transparent;
position: relative;
&:hover .checkbox-overlay {
opacity: 1;
}
.checkbox-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
}
.label {
display: block;
margin-top: 8px;
font-size: 12px;
color: #666;
}
}
}
}
}
Component: ImageComparisonSliderComponent
Location: src/app/common/components/image-comparison-slider/
Files to create:
image-comparison-slider.component.tsimage-comparison-slider.component.htmlimage-comparison-slider.component.scss
Responsibilities:
- Display two images side-by-side with draggable slider
- Handle slider dragging interaction
- Emit close event
Implementation:
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { ImageVersion } from '../property-image-editor/property-image-editor.component';
@Component({
selector: 'app-image-comparison-slider',
templateUrl: './image-comparison-slider.component.html',
styleUrls: ['./image-comparison-slider.component.scss']
})
export class ImageComparisonSliderComponent {
@Input() leftImage!: ImageVersion;
@Input() rightImage!: ImageVersion;
@Output() close = new EventEmitter<void>();
sliderPosition = 50; // percentage
isDragging = false;
onMouseDown(): void {
this.isDragging = true;
}
onMouseUp(): void {
this.isDragging = false;
}
onMouseMove(event: MouseEvent, container: HTMLElement): void {
if (!this.isDragging) return;
const rect = container.getBoundingClientRect();
const x = event.clientX - rect.left;
this.sliderPosition = (x / rect.width) * 100;
this.sliderPosition = Math.max(0, Math.min(100, this.sliderPosition));
}
closeComparison(): void {
this.close.emit();
}
}
HTML:
<div class="comparison-container"
#container
(mousemove)="onMouseMove($event, container)"
(mouseup)="onMouseUp()"
(mouseleave)="onMouseUp()">
<button mat-icon-button class="close-button" (click)="closeComparison()">
<mat-icon>close</mat-icon>
</button>
<div class="image-wrapper">
<img [src]="rightImage.file_url" alt="Right image" class="full-image" />
<div class="left-image-clip" [style.width.%]="sliderPosition">
<img [src]="leftImage.file_url" alt="Left image" />
</div>
<div class="slider-handle"
[style.left.%]="sliderPosition"
(mousedown)="onMouseDown()">
<div class="handle-line"></div>
<div class="handle-circle">
<mat-icon>drag_handle</mat-icon>
</div>
</div>
</div>
<div class="image-labels">
<span class="left-label">{{ leftImage.label }}</span>
<span class="right-label">{{ rightImage.label }}</span>
</div>
</div>
SCSS:
.comparison-container {
position: relative;
width: 100%;
height: 100%;
user-select: none;
.close-button {
position: absolute;
top: 16px;
right: 16px;
z-index: 10;
background: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.image-wrapper {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
.full-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.left-image-clip {
position: absolute;
top: 0;
left: 0;
height: 100%;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.slider-handle {
position: absolute;
top: 0;
bottom: 0;
width: 4px;
cursor: ew-resize;
transform: translateX(-50%);
.handle-line {
width: 4px;
height: 100%;
background: white;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.3);
}
.handle-circle {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 48px;
height: 48px;
border-radius: 50%;
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
mat-icon {
color: #00bcd4;
}
}
}
}
.image-labels {
position: absolute;
bottom: 16px;
left: 16px;
right: 16px;
display: flex;
justify-content: space-between;
span {
padding: 8px 16px;
background: rgba(0, 0, 0, 0.7);
color: white;
border-radius: 4px;
font-size: 14px;
}
}
}
Dialog: StagingStyleSelectorDialog
Location: src/app/account/components/properties/own/gallery/staging-style-selector/
Files to create:
staging-style-selector.dialog.tsstaging-style-selector.dialog.htmlstaging-style-selector.dialog.scss
Responsibilities:
- Display grid of available staging styles
- Handle style selection
- Return selected style to parent component
Implementation:
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
export interface StagingStyle {
id: string;
name: string;
thumbnail: string;
description: string;
}
@Component({
selector: 'app-staging-style-selector-dialog',
templateUrl: './staging-style-selector.dialog.html',
styleUrls: ['./staging-style-selector.dialog.scss']
})
export class StagingStyleSelectorDialog {
// TODO: Fetch from backend in future
stagingStyles: StagingStyle[] = [
{
id: 'modern',
name: 'Modern',
thumbnail: '/assets/staging/modern.jpg',
description: 'Clean lines and minimalist furniture'
},
{
id: 'scandinavian',
name: 'Scandinavian',
thumbnail: '/assets/staging/scandinavian.jpg',
description: 'Light woods and cozy textiles'
},
{
id: 'industrial',
name: 'Industrial',
thumbnail: '/assets/staging/industrial.jpg',
description: 'Exposed brick and metal accents'
},
{
id: 'bohemian',
name: 'Bohemian',
thumbnail: '/assets/staging/bohemian.jpg',
description: 'Eclectic patterns and natural materials'
},
{
id: 'traditional',
name: 'Traditional',
thumbnail: '/assets/staging/traditional.jpg',
description: 'Classic furniture and warm colors'
},
{
id: 'contemporary',
name: 'Contemporary',
thumbnail: '/assets/staging/contemporary.jpg',
description: 'Current trends with bold statements'
}
];
constructor(
public dialogRef: MatDialogRef<StagingStyleSelectorDialog>,
@Inject(MAT_DIALOG_DATA) public data: { imageId: string }
) {}
selectStyle(style: StagingStyle): void {
this.dialogRef.close(style.name);
}
cancel(): void {
this.dialogRef.close(null);
}
}
HTML:
<h2 mat-dialog-title i18n>Choose Staging Style</h2>
<mat-dialog-content>
<div class="staging-styles-grid">
<div *ngFor="let style of stagingStyles"
class="style-card"
(click)="selectStyle(style)">
<div class="thumbnail" [style.background-image]="'url(' + style.thumbnail + ')'"></div>
<h3>{{ style.name }}</h3>
<p>{{ style.description }}</p>
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button (click)="cancel()" i18n>Cancel</button>
</mat-dialog-actions>
SCSS:
mat-dialog-content {
padding: 24px;
.staging-styles-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
.style-card {
cursor: pointer;
border: 2px solid transparent;
border-radius: 8px;
padding: 12px;
transition: all 0.2s;
&:hover {
border-color: #00bcd4;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.thumbnail {
width: 100%;
height: 150px;
background-size: cover;
background-position: center;
border-radius: 4px;
margin-bottom: 8px;
}
h3 {
margin: 8px 0 4px;
font-size: 16px;
font-weight: 600;
}
p {
margin: 0;
font-size: 12px;
color: #666;
}
}
}
}
2. Update Module Declarations
File: src/app/account/components/properties/own/own-property.module.ts
Add the new components to declarations:
import { PropertyImageEditorComponent } from './gallery/image-editor/property-image-editor.component';
import { StagingStyleSelectorDialog } from './gallery/staging-style-selector/staging-style-selector.dialog';
@NgModule({
declarations: [
// ... existing declarations
PropertyImageEditorComponent,
StagingStyleSelectorDialog,
],
// ...
})
File: src/app/common/common.module.ts
Add the comparison slider component:
import { ImageComparisonSliderComponent } from './components/image-comparison-slider/image-comparison-slider.component';
@NgModule({
declarations: [
// ... existing declarations
ImageComparisonSliderComponent,
],
exports: [
// ... existing exports
ImageComparisonSliderComponent,
],
// ...
})
3. Add Routing
File: src/app/account/components/properties/own/own-property-routing.module.ts
Add a route for the image editor:
{
path: ':id/edit/gallery/image/:imageId',
component: PropertyImageEditorComponent,
data: { title: $localize`Edit Image` }
}
4. Link from Gallery Page
File: src/app/account/components/properties/own/gallery/own-property-gallery.component.html
Update the gallery grid to add an "Edit" button:
<mat-menu #menu="matMenu">
<button mat-menu-item (click)="setCover(image.id)">
<mat-icon>collections</mat-icon>
<span i18n>Make cover</span>
</button>
<a mat-menu-item [routerLink]="['/properties', property.id, 'edit', 'gallery', 'image', image.id]">
<mat-icon>auto_fix_high</mat-icon>
<span i18n>AI Edit</span>
</a>
<button mat-menu-item (click)="deleteItem(image.id)">
<mat-icon>delete</mat-icon>
<span i18n>Delete</span>
</button>
</mat-menu>
---
Integration Notes (Backend Placeholder)
The following methods need backend API integration in the future:
- Load image versions:
GET /web-app/properties/own/{propertyId}/gallery/{imageId}/versions/ - Clear room:
POST /web-app/properties/own/{propertyId}/gallery/{imageId}/clear-room/ - Paint walls white:
POST /web-app/properties/own/{propertyId}/gallery/{imageId}/paint-walls/ - Apply staging style:
POST /web-app/properties/own/{propertyId}/gallery/{imageId}/apply-staging/ - Get staging styles:
GET /web-app/staging-styles/
For now, these should use placeholder/mock implementations that:
- Show a loading spinner for 2 seconds
- Add a mock version to the history
- Display success/error snackbar messages
---
Responsive Design
- Desktop (≥1200px): Full 3/5 + 2/5 layout
- Tablet (768px - 1199px): Stack panels vertically, action panel full width
- Mobile (<768px): Stack panels, reduce button sizes, horizontal scroll for version history
---
Testing Checklist
Manual Testing
- [ ] Image editor page loads with correct image
- [ ] Action buttons are visible and styled correctly
- [ ] "Clear Room" button shows loading state and adds version to history
- [ ] "Paint Walls White" button shows loading state and adds version to history
- [ ] "Choose Staging Style" opens lightbox with staging style cards
- [ ] Selecting a staging style closes lightbox and adds version to history
- [ ] Version history displays all versions horizontally
- [ ] Clicking a version thumbnail switches the main preview
- [ ] Hovering over a thumbnail shows checkbox overlay
- [ ] Selecting 2 thumbnails activates comparison mode
- [ ] Comparison slider can be dragged left/right
- [ ] Close button exits comparison mode
- [ ] Action buttons are disabled when processing
- [ ] Responsive design works on mobile/tablet/desktop
- [ ] Navigation back to gallery works correctly
- [ ] "AI Edit" menu item appears on gallery page
Browser Testing
- [ ] Chrome (latest)
- [ ] Firefox (latest)
- [ ] Safari (latest)
- [ ] Edge (latest)
---
Acceptance Criteria
- ✅ AI Image Editor page accessible from property gallery via "AI Edit" menu item
- ✅ Split-panel layout (3/5 preview + 2/5 actions) renders correctly
- ✅ Four action buttons displayed with icons and labels
- ✅ Action buttons show loading state during processing
- ✅ Version history displays horizontally below main panel
- ✅ Clicking version thumbnail changes main preview
- ✅ Checkbox overlay appears on thumbnail hover
- ✅ Selecting exactly 2 thumbnails enables comparison mode
- ✅ Comparison slider can be dragged to reveal left/right images
- ✅ "Choose Staging Style" opens lightbox with grid of style cards
- ✅ Selecting a style closes lightbox and triggers processing
- ✅ All interactions work without backend (mock/placeholder logic)
- ✅ Responsive design adapts to different screen sizes
- ✅ Navigation and routing work correctly
---
Future Enhancements (Out of Scope)
- Backend API integration for actual AI processing
- "Manual Edit" functionality (crop, rotate, adjust brightness, etc.)
- Download edited images
- Share edited images
- Batch editing multiple images
- Undo/redo functionality
- Save custom staging styles
---
Related Issues
- Backend API issue (to be created separately)
---
Files to Create/Modify
New Files:
src/app/account/components/properties/own/gallery/image-editor/property-image-editor.component.tssrc/app/account/components/properties/own/gallery/image-editor/property-image-editor.component.htmlsrc/app/account/components/properties/own/gallery/image-editor/property-image-editor.component.scsssrc/app/common/components/image-comparison-slider/image-comparison-slider.component.tssrc/app/common/components/image-comparison-slider/image-comparison-slider.component.htmlsrc/app/common/components/image-comparison-slider/image-comparison-slider.component.scsssrc/app/account/components/properties/own/gallery/staging-style-selector/staging-style-selector.dialog.tssrc/app/account/components/properties/own/gallery/staging-style-selector/staging-style-selector.dialog.htmlsrc/app/account/components/properties/own/gallery/staging-style-selector/staging-style-selector.dialog.scss
Modified Files:
src/app/account/components/properties/own/own-property.module.ts(add declarations)src/app/common/common.module.ts(add comparison slider)src/app/account/components/properties/own/own-property-routing.module.ts(add route)src/app/account/components/properties/own/gallery/own-property-gallery.component.html(add AI Edit menu item)
---
Estimated Effort
8-12 hours for a developer familiar with Angular and Angular Material.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗