diff --git a/public/backgrounds/desert.webp b/public/backgrounds/desert.webp new file mode 100644 index 0000000..5c5410e Binary files /dev/null and b/public/backgrounds/desert.webp differ diff --git a/public/backgrounds/forest.webp b/public/backgrounds/forest.webp new file mode 100644 index 0000000..ecd758f Binary files /dev/null and b/public/backgrounds/forest.webp differ diff --git a/public/backgrounds/underwater.webp b/public/backgrounds/underwater.webp new file mode 100644 index 0000000..ad736b1 Binary files /dev/null and b/public/backgrounds/underwater.webp differ diff --git a/public/backgrounds/winter.webp b/public/backgrounds/winter.webp new file mode 100644 index 0000000..cae8d8b Binary files /dev/null and b/public/backgrounds/winter.webp differ diff --git a/public/characters/bird.png b/public/characters/bird.png new file mode 100644 index 0000000..b6bf8e9 Binary files /dev/null and b/public/characters/bird.png differ diff --git a/public/characters/cat.webp b/public/characters/cat.webp new file mode 100644 index 0000000..8f8c101 Binary files /dev/null and b/public/characters/cat.webp differ diff --git a/public/characters/dog.png b/public/characters/dog.png new file mode 100644 index 0000000..fcfc7c4 Binary files /dev/null and b/public/characters/dog.png differ diff --git a/public/characters/fish.png b/public/characters/fish.png new file mode 100644 index 0000000..5a92d9d Binary files /dev/null and b/public/characters/fish.png differ diff --git a/public/characters/turtle.png b/public/characters/turtle.png new file mode 100644 index 0000000..99f7b3b Binary files /dev/null and b/public/characters/turtle.png differ diff --git a/src/app/components/activity-list/activity-list.component.html b/src/app/components/activity-list/activity-list.component.html index 7409b67..0c0d9c0 100644 --- a/src/app/components/activity-list/activity-list.component.html +++ b/src/app/components/activity-list/activity-list.component.html @@ -7,7 +7,7 @@ studentStyle="{{style}} text-student border-student" teacherStyle="{{style}} text-teacher border-teacher" /> -
+
@for (activity of activityList(); track activity.id) { -
+ @if (activity.thumbnail) { + + } @else { +
+ } -
diff --git a/src/app/components/activity/activity.component.spec.ts b/src/app/components/activity/activity.component.spec.ts index 676e10c..24a849f 100644 --- a/src/app/components/activity/activity.component.spec.ts +++ b/src/app/components/activity/activity.component.spec.ts @@ -23,7 +23,9 @@ describe('ActivityComponent', () => { toolboxInfo: { toolboxDefinition: '', BLOCK_LIMITS: {} - } + }, + sceneObjects: [], + thumbnail: '' }; it('should toggle the menu when clicking the button', async () => { diff --git a/src/app/components/blocks-modal/blocks-modal.component.ts b/src/app/components/blocks-modal/blocks-modal.component.ts index 7f9e3a0..fb02954 100644 --- a/src/app/components/blocks-modal/blocks-modal.component.ts +++ b/src/app/components/blocks-modal/blocks-modal.component.ts @@ -1,5 +1,5 @@ import {AfterViewInit, Component, EventEmitter, Input, Output, signal} from '@angular/core'; -import {ButtonComponent} from '../button/button.component'; +import {ButtonComponent} from '../../layout/button/button.component'; import * as Blockly from 'blockly'; @Component({ diff --git a/src/app/components/description-modal/description-modal.component.ts b/src/app/components/description-modal/description-modal.component.ts index f122031..573f3cb 100644 --- a/src/app/components/description-modal/description-modal.component.ts +++ b/src/app/components/description-modal/description-modal.component.ts @@ -1,5 +1,5 @@ import {Component, EventEmitter, inject, Input, Output, signal} from '@angular/core'; -import {ButtonComponent} from '../button/button.component'; +import {ButtonComponent} from '../../layout/button/button.component'; import {Activity} from '../../models/activity'; import {FormsModule} from '@angular/forms'; import {ModeService} from '../../services/mode.service'; diff --git a/src/app/components/images-modal/images-modal.component.html b/src/app/components/images-modal/images-modal.component.html new file mode 100644 index 0000000..5c482f8 --- /dev/null +++ b/src/app/components/images-modal/images-modal.component.html @@ -0,0 +1,20 @@ +
+
+
+

{{ backgrounds ? 'Select the new background' : 'Select the new character' }}

+ +
+ +
+ @let paths = backgrounds ? backgroundPaths : charactersPaths; + @for (path of paths; track path) { + + } +
+
+
diff --git a/src/app/components/images-modal/images-modal.component.ts b/src/app/components/images-modal/images-modal.component.ts new file mode 100644 index 0000000..d6f3474 --- /dev/null +++ b/src/app/components/images-modal/images-modal.component.ts @@ -0,0 +1,32 @@ +import {Component, EventEmitter, Input, Output} from '@angular/core'; +import {ButtonComponent} from "../../layout/button/button.component"; +import {NgClass} from '@angular/common'; + +@Component({ + selector: 'blearn-images-modal', + imports: [ + ButtonComponent, + NgClass + ], + templateUrl: './images-modal.component.html', +}) +export class ImagesModalComponent { + @Input() backgrounds = false; + @Output() imageSelected = new EventEmitter(); + @Output() close = new EventEmitter(); + + protected backgroundPaths = [ + '/backgrounds/desert.webp', + '/backgrounds/forest.webp', + '/backgrounds/winter.webp', + '/backgrounds/underwater.webp', + ]; + + protected charactersPaths = [ + '/characters/cat.webp', + '/characters/dog.png', + '/characters/bird.png', + '/characters/turtle.png', + '/characters/fish.png', + ]; +} diff --git a/src/app/components/scene-input/scene-input.component.html b/src/app/components/scene-input/scene-input.component.html new file mode 100644 index 0000000..ea5ce5d --- /dev/null +++ b/src/app/components/scene-input/scene-input.component.html @@ -0,0 +1,11 @@ + diff --git a/src/app/components/scene-input/scene-input.component.ts b/src/app/components/scene-input/scene-input.component.ts new file mode 100644 index 0000000..4c52e85 --- /dev/null +++ b/src/app/components/scene-input/scene-input.component.ts @@ -0,0 +1,26 @@ +import {Component, EventEmitter, inject, Input, Output} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {ModeService} from '../../services/mode.service'; + +@Component({ + selector: 'blearn-scene-input', + imports: [ + FormsModule + ], + templateUrl: './scene-input.component.html', +}) +export class SceneInputComponent { + protected modeService = inject(ModeService); + + @Input() label!: string; + @Input() value!: number; + + @Output() valueChange = new EventEmitter(); + + onChange(event: Event) { + const inputElement = event.target as HTMLInputElement; + + const parsed = parseFloat(inputElement.value); + if (!isNaN(parsed)) this.valueChange.emit(parsed); + } +} diff --git a/src/app/components/scene/scene.component.html b/src/app/components/scene/scene.component.html index d90dd92..e86aeaf 100644 --- a/src/app/components/scene/scene.component.html +++ b/src/app/components/scene/scene.component.html @@ -1,11 +1,11 @@ -
-
+
+
@@ -20,22 +20,81 @@ /> @if (modeService.getMode() === 'teacher') { }
-
- @for (obj of sceneObjects; track obj.id) { - + + +
- -
+
+

Scene Objects

+ +
+ @for (obj of sceneObjects; track obj.id) { + + } + + @if (modeService.getMode() === 'teacher') { +
+ +
+ } +
+
+ + @if (contextMenuVisible && modeService.getMode() === 'teacher') { +
+ + +
+ } + + diff --git a/src/app/components/scene/scene.component.ts b/src/app/components/scene/scene.component.ts index f1f340b..de31fa8 100644 --- a/src/app/components/scene/scene.component.ts +++ b/src/app/components/scene/scene.component.ts @@ -1,8 +1,9 @@ import { AfterViewInit, Component, + computed, ElementRef, - EventEmitter, + EventEmitter, HostListener, inject, Input, Output, @@ -10,15 +11,20 @@ import { ViewChild } from '@angular/core'; import {SceneObject} from '../../models/scene-object'; -import {ButtonComponent} from '../button/button.component'; +import {ButtonComponent} from '../../layout/button/button.component'; import {ModeService} from '../../services/mode.service'; import {NgClass} from '@angular/common'; +import {FormsModule} from '@angular/forms'; +import {SceneInputComponent} from '../scene-input/scene-input.component'; +import loadImage from '../../utils/loadImage'; @Component({ selector: 'blearn-scene', imports: [ ButtonComponent, - NgClass + NgClass, + FormsModule, + SceneInputComponent ], templateUrl: './scene.component.html', }) @@ -30,46 +36,55 @@ export class SceneComponent implements AfterViewInit { @Input() isRunning = signal(false); @Input() sceneObjects: SceneObject[] = []; - @Input() selectedObject = signal(undefined); + @Input() selectedObjectId = signal(undefined); + @Input() bgSrc: string | undefined = undefined; @Output() runCode = new EventEmitter(); @Output() sceneObjectsChange = new EventEmitter(); @Output() objectAdded = new EventEmitter(); @Output() objectSelected = new EventEmitter(); + @Output() objectDeleted = new EventEmitter(); + @Output() objectDuplicated = new EventEmitter(); + @Output() backgroundChange = new EventEmitter(); private ctx: CanvasRenderingContext2D | null = null; private draggingObject: SceneObject | null = null; private offsetX: number = 0; private offsetY: number = 0; + public bgImage: HTMLImageElement | null = null; + + protected contextMenuVisible = false; + protected contextMenuX = 0; + protected contextMenuY = 0; + protected contextMenuObject: SceneObject | null = null; + + protected selectedObject= computed(() => { + if (!this.selectedObjectId()) return undefined; + + return this.sceneObjects.find(obj => obj.id === this.selectedObjectId()); + }); ngAfterViewInit(): void { - this.initCanvas(); + this.initCanvas().then(() => { + this.setupMouseEvents(); + this.drawImages(); + }); } - private initCanvas() { - this.ctx = this.canvas.nativeElement.getContext('2d'); + private async initCanvas() { + const canvasEl = this.canvas.nativeElement; + const style = getComputedStyle(canvasEl); - // Initialize the canvas size based on the scene - this.canvas.nativeElement.width = this.scene.nativeElement.offsetWidth; - this.canvas.nativeElement.height = this.scene.nativeElement.offsetHeight; + const width = parseFloat(style.width); + const height = parseFloat(style.height); - const imageLoadPromises = this.sceneObjects.map(obj => { - return new Promise((resolve) => { - const img = new Image(); - img.src = obj.imgSrc; - img.onload = () => { - obj.img = img; - resolve(); - } - }); - }); + canvasEl.width = width; + canvasEl.height = height; - Promise.all(imageLoadPromises).then(() => this.drawImages()); - this.setupMouseEvents(); - } + this.ctx = canvasEl.getContext('2d'); - protected addObject() { - this.objectAdded.emit(); + this.sceneObjects.map(async obj => obj.img = await loadImage(obj.imgSrc)); + if (this.bgSrc) this.bgImage = await loadImage(this.bgSrc); } private setupMouseEvents() { @@ -80,10 +95,11 @@ export class SceneComponent implements AfterViewInit { const mouseY = e.offsetY; for (let sceneObj of this.sceneObjects) { - if (mouseX >= sceneObj.x && mouseX <= sceneObj.x + sceneObj.width && mouseY >= sceneObj.y && mouseY <= sceneObj.y + sceneObj.height) { + if (mouseX >= sceneObj.x && mouseX <= sceneObj.x + sceneObj.size && mouseY >= sceneObj.y && mouseY <= sceneObj.y + sceneObj.size) { this.draggingObject = sceneObj; this.offsetX = mouseX - sceneObj.x; this.offsetY = mouseY - sceneObj.y; + this.objectSelected.emit(sceneObj.id); } } }); @@ -117,30 +133,48 @@ export class SceneComponent implements AfterViewInit { }); } + protected onRightClick(event: MouseEvent, obj: SceneObject) { + event.preventDefault(); + this.contextMenuVisible = true; + this.contextMenuX = event.clientX; + this.contextMenuY = event.clientY; + this.contextMenuObject = obj; + } + + @HostListener('document:click') + hideContextMenu() { + this.contextMenuVisible = false; + } + public drawImages() { if (!this.ctx) return; - this.ctx.clearRect(0, 0, this.canvas.nativeElement.width, this.canvas.nativeElement.height); + if (this.bgImage) { + this.ctx.drawImage(this.bgImage!, 0, 0, this.canvas.nativeElement.width, this.canvas.nativeElement.height); + } else { + this.ctx.fillStyle = '#d1d5db'; + this.ctx.fillRect(0, 0, this.canvas.nativeElement.width, this.canvas.nativeElement.height); + } for (let obj of this.sceneObjects) { this.ctx.save(); - if (obj.id === this.selectedObject()) { + const angleInRadians = obj.rotation * Math.PI / 180; + + const centerX = obj.x + obj.size / 2; + const centerY = obj.y + obj.size / 2; + + this.ctx.translate(centerX, centerY); + this.ctx.rotate(angleInRadians); + + if (obj.id === this.selectedObjectId()) { this.ctx.shadowColor = 'red'; this.ctx.shadowBlur = 20; this.ctx.shadowOffsetX = 0; this.ctx.shadowOffsetY = 0; } - if (!obj.img) { - const img = new Image(); - img.src = obj.imgSrc; - img.onload = () => { - obj.img = img; - this.ctx?.drawImage(obj.img!, obj.x, obj.y, obj.width, obj.height); - } - } else - this.ctx.drawImage(obj.img!, obj.x, obj.y, obj.width, obj.height); + if (obj.img) this.ctx.drawImage(obj.img!, -obj.size / 2, -obj.size / 2, obj.size, obj.size); this.ctx.restore(); } diff --git a/src/app/components/button/button.component.html b/src/app/layout/button/button.component.html similarity index 81% rename from src/app/components/button/button.component.html rename to src/app/layout/button/button.component.html index 80fca80..e72a319 100644 --- a/src/app/components/button/button.component.html +++ b/src/app/layout/button/button.component.html @@ -1,6 +1,6 @@
diff --git a/src/app/pages/home/home.component.spec.ts b/src/app/pages/home/home.component.spec.ts index 7895dce..41e5d2b 100644 --- a/src/app/pages/home/home.component.spec.ts +++ b/src/app/pages/home/home.component.spec.ts @@ -2,7 +2,7 @@ import {ActivityService} from '../../services/activity.service'; import {ModeService} from '../../services/mode.service'; import {fireEvent, render, screen, waitFor} from '@testing-library/angular'; import {HomeComponent} from './home.component'; -import {ButtonComponent} from '../../components/button/button.component'; +import {ButtonComponent} from '../../layout/button/button.component'; import {TitleComponent} from '../../components/title/title.component'; import {ActivityListComponent} from '../../components/activity-list/activity-list.component'; import {userEvent} from '@testing-library/user-event'; @@ -37,33 +37,12 @@ describe('HomeComponent', () => { ], }); - // then expect(screen.getByTestId('home-component')).toBeInTheDocument(); }); - it('should not render evaluate button when in student mode', async () => { - // given - modeServiceMock.getMode.mockReturnValue('student'); - await render(HomeComponent, { - providers: [ - {provide: ActivityService, useValue: activityServiceMock}, - {provide: ModeService, useValue: modeServiceMock}, - ], - imports: [ - ButtonComponent, - TitleComponent, - ActivityListComponent - ], - }); - - // then - const evaluateButton = screen.queryByText('Evaluate task'); - expect(evaluateButton).not.toBeInTheDocument(); - }); - - it('should render evaluate button when in teacher mode', async () => { - // given + it('should render create button when in teacher mode', async () => { modeServiceMock.getMode.mockReturnValue('teacher'); + await render(HomeComponent, { providers: [ {provide: ActivityService, useValue: activityServiceMock}, @@ -76,13 +55,13 @@ describe('HomeComponent', () => { ], }); - // then - expect(screen.getByText('Evaluate task')).toBeInTheDocument(); + // AquĆ­ nos aseguramos de buscar por el texto visible + expect(screen.getByText('Create activity')).toBeInTheDocument(); }); - it('should call addActivity when "Create task" button is clicked', async () => { - // given + it('should call addActivity when "Create activity" button is clicked', async () => { modeServiceMock.getMode.mockReturnValue('teacher'); + await render(HomeComponent, { providers: [ {provide: ActivityService, useValue: activityServiceMock}, @@ -95,16 +74,14 @@ describe('HomeComponent', () => { ], }); - // when - await userEvent.click(screen.getByText('Create task')); + await userEvent.click(screen.getByText('Create activity')); - // then expect(activityServiceMock.addActivity).toHaveBeenCalled(); }); - it('should open file input when "Import task" button is clicked', async () => { - // given + it('should open file input when "Import activity" button is clicked', async () => { modeServiceMock.getMode.mockReturnValue('student'); + const {fixture} = await render(HomeComponent, { providers: [ {provide: ActivityService, useValue: activityServiceMock}, @@ -116,18 +93,16 @@ describe('HomeComponent', () => { ActivityListComponent ], }); + const fileInput = fixture.componentInstance.fileInput; jest.spyOn(fileInput.nativeElement, 'click'); - // when - await userEvent.click(screen.getByText('Import task')); + await userEvent.click(screen.getByText('Import activity')); - // then expect(fileInput.nativeElement.click).toHaveBeenCalled(); }); it('should handle receiving deleteActivityEmitter from ActivityList', async () => { - // given const {fixture} = await render(HomeComponent, { providers: [ {provide: ActivityService, useValue: activityServiceMock}, @@ -139,26 +114,28 @@ describe('HomeComponent', () => { ActivityListComponent ], }); + const activityListDebugElement = fixture.debugElement.query(By.directive(ActivityListComponent)); const activityListComponent = activityListDebugElement.componentInstance as ActivityListComponent; - // when activityListComponent.deleteActivityEmitter.emit('1'); fixture.detectChanges(); - // then expect(activityServiceMock.deleteActivity).toHaveBeenCalledWith('1'); }); it('should handle file selection and parsing', async () => { - // given modeServiceMock.getMode.mockReturnValue('student'); + const file = new File([JSON.stringify({ id: '1', title: 'Activity 1', dueDate: '03/03', - workspace: '{}' + workspace: '{}', + toolboxDefinition: '{}', + })], 'test.blearn', {type: 'application/json'}); + await render(HomeComponent, { providers: [ {provide: ActivityService, useValue: activityServiceMock}, @@ -171,11 +148,9 @@ describe('HomeComponent', () => { ], }); - // when const input = screen.getByTestId('file-input'); fireEvent.change(input, { target: { files: [file] } }); - // then await waitFor(() => { expect(activityServiceMock.addActivity).toHaveBeenCalled(); }); diff --git a/src/app/pages/home/home.component.ts b/src/app/pages/home/home.component.ts index 70692d2..aab1bc1 100644 --- a/src/app/pages/home/home.component.ts +++ b/src/app/pages/home/home.component.ts @@ -1,6 +1,6 @@ import {Component, effect, ElementRef, inject, OnInit, signal, ViewChild} from '@angular/core'; import {ModeService} from "../../services/mode.service"; -import {ButtonComponent} from '../../components/button/button.component'; +import {ButtonComponent} from '../../layout/button/button.component'; import {TitleComponent} from '../../components/title/title.component'; import {ActivityService} from '../../services/activity.service'; import genUniqueId from '../../utils/genUniqueId'; @@ -95,6 +95,8 @@ export class HomeComponent { workspace: jsonData.workspace, toolboxInfo: jsonData.toolboxInfo, sceneObjects: jsonData.sceneObjects, + thumbnail: jsonData.thumbnail, + background: jsonData.background } } } diff --git a/src/app/services/activity.service.spec.ts b/src/app/services/activity.service.spec.ts index 4cc9845..16091f5 100644 --- a/src/app/services/activity.service.spec.ts +++ b/src/app/services/activity.service.spec.ts @@ -43,7 +43,9 @@ describe('ActivityService', () => { toolboxInfo: { toolboxDefinition: '', BLOCK_LIMITS: {} - } + }, + sceneObjects: [], + thumbnail: '' }; modeService.setMode('student'); @@ -65,7 +67,9 @@ describe('ActivityService', () => { toolboxInfo: { toolboxDefinition: '', BLOCK_LIMITS: {} - } + }, + sceneObjects: [], + thumbnail: '' }; modeService.setMode('teacher'); @@ -87,7 +91,9 @@ describe('ActivityService', () => { toolboxInfo: { toolboxDefinition: '', BLOCK_LIMITS: {} - } + }, + sceneObjects: [], + thumbnail: '' }]; browserStorageService.loadData.mockReturnValue(mockActivities); @@ -120,7 +126,9 @@ describe('ActivityService', () => { toolboxInfo: { toolboxDefinition: '', BLOCK_LIMITS: {} - } + }, + sceneObjects: [], + thumbnail: '' }; const existingActivities: Activity[] = [{ id: '1', @@ -131,7 +139,9 @@ describe('ActivityService', () => { toolboxInfo: { toolboxDefinition: '', BLOCK_LIMITS: {} - } + }, + sceneObjects: [], + thumbnail: '' }]; browserStorageService.loadData.mockReturnValue(existingActivities); @@ -150,7 +160,9 @@ describe('ActivityService', () => { toolboxInfo: { toolboxDefinition: '', BLOCK_LIMITS: {} - } + }, + sceneObjects: [], + thumbnail: '' }, { id: '2', @@ -161,7 +173,9 @@ describe('ActivityService', () => { toolboxInfo: { toolboxDefinition: '', BLOCK_LIMITS: {} - } + }, + sceneObjects: [], + thumbnail: '' } ] ); @@ -179,7 +193,9 @@ describe('ActivityService', () => { toolboxInfo: { toolboxDefinition: '', BLOCK_LIMITS: {} - } + }, + sceneObjects: [], + thumbnail: '' }, { id: '2', @@ -190,7 +206,9 @@ describe('ActivityService', () => { toolboxInfo: { toolboxDefinition: '', BLOCK_LIMITS: {} - } + }, + sceneObjects: [], + thumbnail: '' } ]; browserStorageService.loadData.mockReturnValue(existingActivities); @@ -209,7 +227,9 @@ describe('ActivityService', () => { toolboxInfo: { toolboxDefinition: '', BLOCK_LIMITS: {} - } + }, + sceneObjects: [], + thumbnail: '' } ); }); @@ -224,8 +244,10 @@ describe('ActivityService', () => { workspace: '{}', toolboxInfo: { toolboxDefinition: '', - BLOCK_LIMITS: {} - } + BLOCK_LIMITS: {}, + }, + sceneObjects: [], + thumbnail: '' }; browserStorageService.loadData.mockReturnValue([mockActivity]); @@ -249,7 +271,9 @@ describe('ActivityService', () => { toolboxInfo: { toolboxDefinition: '', BLOCK_LIMITS: {} - } + }, + sceneObjects: [], + thumbnail: '' } ]; browserStorageService.loadData.mockImplementation(() => mockActivity); diff --git a/src/app/services/mode.service.spec.ts b/src/app/services/mode.service.spec.ts index fb4d595..7b6c9a2 100644 --- a/src/app/services/mode.service.spec.ts +++ b/src/app/services/mode.service.spec.ts @@ -1,11 +1,16 @@ import {beforeEach, describe, expect, it} from '@jest/globals'; import {ModeService} from './mode.service'; +import {BrowserStorageService} from './browser-storage.service'; +import {TestBed} from '@angular/core/testing'; describe('ModeService', () => { let service: ModeService; + let browserStorageMock: jest.Mocked; beforeEach(() => { - service = new ModeService(); + TestBed.configureTestingModule({providers: [BrowserStorageService]}) + browserStorageMock = TestBed.inject(BrowserStorageService) as jest.Mocked + service = new ModeService(browserStorageMock); }); it('should be created', () => { diff --git a/src/app/services/mode.service.ts b/src/app/services/mode.service.ts index a572334..62a8114 100644 --- a/src/app/services/mode.service.ts +++ b/src/app/services/mode.service.ts @@ -1,4 +1,5 @@ import {Injectable, signal} from '@angular/core'; +import {BrowserStorageService} from './browser-storage.service'; @Injectable({ providedIn: 'root' @@ -6,9 +7,16 @@ import {Injectable, signal} from '@angular/core'; export class ModeService { private mode = signal<'student' | 'teacher'>('student'); + constructor(public browserStorage: BrowserStorageService) { + const localMode = browserStorage.loadData('mode'); + if (localMode) this.setMode(localMode); + } + + // Method to set the mode setMode(mode: 'student' | 'teacher') { this.mode.set(mode); + this.browserStorage.saveData('mode', mode); } // Method to get the current mode diff --git a/src/app/utils/loadImage.ts b/src/app/utils/loadImage.ts new file mode 100644 index 0000000..bc2c7c2 --- /dev/null +++ b/src/app/utils/loadImage.ts @@ -0,0 +1,11 @@ +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.src = src; + img.crossOrigin = 'anonymous'; + img.onload = () => resolve(img); + img.onerror = reject; + }); +} + +export default loadImage; diff --git a/src/styles.css b/src/styles.css index b5c61c9..e06369b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,3 +1,7 @@ @tailwind base; @tailwind components; @tailwind utilities; + +.clickable { + @apply transition-transform hover:scale-105 shadow-md active:scale-95 duration-150; +}