diff --git a/README.md b/README.md index 0fd1beb..88fa536 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,11 @@ You can simply paste images you have copied in your clipboard, and then arrange * Canvas zoom and translate * Image paste from clipboard and translate * Name and rename boards +* Crop images + the ability to re-crop the original image at any stage ![](./images/screenshot.png) ![](./images/screenshot2.png) +![](./images/screenshot3.png) ## Requirements diff --git a/images/screenshot.png b/images/screenshot.png index b1b400d..f782637 100644 Binary files a/images/screenshot.png and b/images/screenshot.png differ diff --git a/images/screenshot2.png b/images/screenshot2.png index 85fc8c4..3b5120d 100644 Binary files a/images/screenshot2.png and b/images/screenshot2.png differ diff --git a/images/screenshot3.png b/images/screenshot3.png new file mode 100644 index 0000000..f3ef550 Binary files /dev/null and b/images/screenshot3.png differ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b204a24..b1fd060 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: '@projectstorm/tornado-common': specifier: workspace:* version: link:../tornado-common + cropperjs: + specifier: ^1.5.13 + version: 1.5.13 formik: specifier: ^2.4.1 version: 2.4.1(react@18.2.0) @@ -83,6 +86,9 @@ importers: react-avatar: specifier: ^5.0.3 version: 5.0.3(@babel/runtime@7.22.5)(core-js-pure@3.31.0)(prop-types@15.8.1)(react@18.2.0) + react-cropper: + specifier: ^2.3.3 + version: 2.3.3(react@18.2.0) react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) @@ -1311,6 +1317,10 @@ packages: yaml: 1.10.2 dev: false + /cropperjs@1.5.13: + resolution: {integrity: sha512-by7jKAo73y5/Do0K6sxdTKHgndY0NMjG2bEdgeJxycbcmHuCiMXqw8sxy5C5Y5WTOTcDGmbT7Sr5CgKOXR06OA==} + dev: false + /cross-spawn@7.0.3: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} @@ -2606,6 +2616,15 @@ packages: react: 18.2.0 dev: false + /react-cropper@2.3.3(react@18.2.0): + resolution: {integrity: sha512-zghiEYkUb41kqtu+2jpX2Ntigf+Jj1dF9ew4lAobPzI2adaPE31z0p+5TcWngK6TvmWQUwK3lj4G+NDh1PDQ1w==} + peerDependencies: + react: '>=17.0.2' + dependencies: + cropperjs: 1.5.13 + react: 18.2.0 + dev: false + /react-dom@18.2.0(react@18.2.0): resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} peerDependencies: diff --git a/tornado-common/src/api/media.ts b/tornado-common/src/api/media.ts index f042f3b..b98fa8c 100644 --- a/tornado-common/src/api/media.ts +++ b/tornado-common/src/api/media.ts @@ -2,10 +2,19 @@ export enum MediaSize { SMALL = 0, MEDIUM = 1, LARGE = 2, - ORIGINAL = 3 + X_LARGE = 3, + ORIGINAL = 4 } export interface GetMediaRequest { image: number; size: MediaSize; } + +export interface MediaCropRequest { + image: number; + top: number; + left: number; + width: number; + height: number; +} diff --git a/tornado-common/src/routes.ts b/tornado-common/src/routes.ts index ee7d82a..1d57309 100644 --- a/tornado-common/src/routes.ts +++ b/tornado-common/src/routes.ts @@ -6,6 +6,7 @@ export enum Routes { CONCEPT_DELETE = '/concept/delete', CONCEPT_UPDATE = '/concept/update', UPLOAD_MEDIA = '/media/upload', - GET_MEDIA = '/media/get', + MEDIA_GET = '/media/get', + MEDIA_CROP = '/media/crop', CONCEPT = '/concept' } diff --git a/tornado-frontend/package.json b/tornado-frontend/package.json index b966894..31c26b7 100644 --- a/tornado-frontend/package.json +++ b/tornado-frontend/package.json @@ -25,8 +25,10 @@ "react-avatar": "^5.0.3", "react-dom": "^18.2.0", "react-switch": "^7.0.0", + "react-cropper": "^2.3.3", "react-router-dom": "^6.12.1", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "cropperjs": "^1.5.13" }, "devDependencies": { "@types/lodash": "^4.14.195", diff --git a/tornado-frontend/src/client/MediaClient.ts b/tornado-frontend/src/client/MediaClient.ts index fd415c3..7cfcdda 100644 --- a/tornado-frontend/src/client/MediaClient.ts +++ b/tornado-frontend/src/client/MediaClient.ts @@ -1,4 +1,5 @@ import { BaseObserver, FileData, GetMediaRequest, MediaSize, Routes } from '@projectstorm/tornado-common'; +import * as _ from 'lodash'; export interface MediaUploadListener { progressChanged: (percent: number) => any; @@ -45,6 +46,14 @@ export class MediaObject { this.cache = new Map(); } + clearCachesSizes() { + _.forEach(MediaSize, (m) => { + if (m !== MediaSize.ORIGINAL) { + this.cache.delete(m); + } + }); + } + async getURL(size: MediaSize) { if (!this.cache.has(size)) { this.cache.set( @@ -81,7 +90,7 @@ export class MediaClient { } async getMedia(image: number, size: MediaSize) { - const res = await fetch(`${this.options.baseURL}${Routes.GET_MEDIA}`, { + const res = await fetch(`${this.options.baseURL}${Routes.MEDIA_GET}`, { method: 'POST', headers: { 'Content-Type': 'application/json' diff --git a/tornado-frontend/src/client/TornadoClient.ts b/tornado-frontend/src/client/TornadoClient.ts index af0ed1b..4ff5e8c 100644 --- a/tornado-frontend/src/client/TornadoClient.ts +++ b/tornado-frontend/src/client/TornadoClient.ts @@ -7,6 +7,7 @@ import { DeleteConceptsResponse, LoginRequest, LoginResponse, + MediaCropRequest, Routes, UpdateConceptRequest } from '@projectstorm/tornado-common'; @@ -74,4 +75,6 @@ export class TornadoClient { deleteConcept = this.createRoute(Routes.CONCEPT_DELETE); updateConcept = this.createRoute(Routes.CONCEPT_UPDATE); + + mediaCrop = this.createRoute(Routes.MEDIA_CROP); } diff --git a/tornado-frontend/src/fonts.ts b/tornado-frontend/src/fonts.ts index 15fac6d..a803755 100644 --- a/tornado-frontend/src/fonts.ts +++ b/tornado-frontend/src/fonts.ts @@ -1,9 +1,9 @@ import '@fontsource/open-sans'; import { library } from '@fortawesome/fontawesome-svg-core'; -import { faMoon, faPlus, faSignOut, faSpinner, faSun } from '@fortawesome/free-solid-svg-icons'; +import { faCrop, faMoon, faPlus, faSignOut, faSpinner, faSun, faTrash } from '@fortawesome/free-solid-svg-icons'; import { css } from '@emotion/react'; -library.add(faPlus, faSpinner, faSignOut, faSun, faMoon); +library.add(faPlus, faSpinner, faSignOut, faSun, faMoon, faCrop, faTrash); export const FONT = css` font-family: 'Open sans', sans-serif; diff --git a/tornado-frontend/src/hooks/useButton.tsx b/tornado-frontend/src/hooks/useButton.tsx index acc187d..d5a599d 100644 --- a/tornado-frontend/src/hooks/useButton.tsx +++ b/tornado-frontend/src/hooks/useButton.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; export interface UseButtonOptions { action: (event: MouseEvent) => Promise; disabled?: boolean; + instant?: boolean; } export const useButton = (props: UseButtonOptions, ref: React.RefObject) => { @@ -26,9 +27,9 @@ export const useButton = (props: UseButtonOptions, ref: React.RefObject { - ref.current?.removeEventListener('click', l); + ref.current?.removeEventListener(props.instant ? 'mousedown' : 'click', l, { capture: true }); }; }, [props.action, props.disabled]); return { loading }; diff --git a/tornado-frontend/src/routes/content/ConceptBoardPage.tsx b/tornado-frontend/src/routes/content/ConceptBoardPage.tsx index 6f731ac..a0b6566 100644 --- a/tornado-frontend/src/routes/content/ConceptBoardPage.tsx +++ b/tornado-frontend/src/routes/content/ConceptBoardPage.tsx @@ -7,25 +7,18 @@ import { useSystem } from '../../hooks/useSystem'; import { ConceptCanvasWidget } from './widgets/react-canvas/ConceptCanvasWidget'; import { useParams } from 'react-router-dom'; import { observer } from 'mobx-react'; -import { autorun } from 'mobx'; -export interface ConceptBoardPageProps {} - -export const ConceptBoardPage: React.FC = observer((props) => { +export const ConceptBoardPage: React.FC = observer((props) => { const system = useSystem(); - const { id } = useParams<{ id: string }>(); + const { board } = useParams<{ board: string }>(); useAuthenticated(); useEffect(() => { - system.conceptStore.loadConcepts(); - return autorun(() => { - const concept = system.conceptStore.getConcept(parseInt(id)); - if (concept) { - system.updateTitle(`Concept ${concept.board.name}`); - } + system.conceptStore.loadConcept(parseInt(board)).then((concept) => { + system.updateTitle(`Concept ${concept.board.name}`); }); - }, [id]); + }, [board]); - const concept = system.conceptStore.getConcept(parseInt(id)); + const concept = system.conceptStore.getConcept(parseInt(board)); if (!concept) { return null; } diff --git a/tornado-frontend/src/routes/content/ImageCropPage.tsx b/tornado-frontend/src/routes/content/ImageCropPage.tsx new file mode 100644 index 0000000..86b2730 --- /dev/null +++ b/tornado-frontend/src/routes/content/ImageCropPage.tsx @@ -0,0 +1,128 @@ +import * as React from 'react'; +import { createRef, useEffect, useState } from 'react'; +import styled from '@emotion/styled'; +import { useAuthenticated } from '../../hooks/useAuthenticated'; +import { useSystem } from '../../hooks/useSystem'; +import { generatePath, useNavigate, useParams } from 'react-router-dom'; +import { observer } from 'mobx-react'; +import Cropper, { ReactCropperElement } from 'react-cropper'; +import { MediaSize } from '@projectstorm/tornado-common'; +import 'cropperjs/dist/cropper.css'; +import { ButtonType, ButtonWidget } from '../../widgets/forms/ButtonWidget'; +import { Routing } from '../routes'; +import * as _ from 'lodash'; + +export const ImageCropPage: React.FC = observer((props) => { + const system = useSystem(); + useAuthenticated(); + const navigate = useNavigate(); + const { board, image } = useParams<{ board: string; image: string }>(); + + const [mediaUrl, setMedia] = useState(null); + useEffect(() => { + const media = system.clientMedia.getMediaObject(parseInt(image)); + media.getURL(MediaSize.ORIGINAL).then((url) => { + setMedia(url); + }); + }, [board, image]); + + const cropperRef = createRef(); + + if (!mediaUrl) { + return null; + } + + return ( + + + { + navigate(generatePath(Routing.CONCEPTS_BOARD, { board: board })); + }} + /> + { + const data = cropperRef.current.cropper.getImageData(); + cropperRef.current.cropper.setData({ + x: 0, + y: 0, + width: data.naturalWidth, + height: data.naturalHeight + }); + }} + /> + { + const data = cropperRef.current.cropper.getData(); + await system.client.mediaCrop({ + image: parseInt(image), + top: data.y, + left: data.x, + width: data.width, + height: data.height + }); + + // clear the URL caches + system.clientMedia.getMediaObject(parseInt(image)).clearCachesSizes(); + + // update the image sizes on the board + const concept = await system.conceptStore.loadConcept(parseInt(board)); + _.forEach(concept.board.data.layers[0].models, (m) => { + if (m.image_id === parseInt(image)) { + m.height = (m.width / data.width) * data.height; + } + }); + await concept.setCanvasData(concept.board.data); + navigate(generatePath(Routing.CONCEPTS_BOARD, { board: board })); + }} + /> + + + + ); +}); +namespace S { + export const Container = styled.div` + box-sizing: border-box; + padding: 10px; + height: 100%; + display: flex; + flex-direction: column; + `; + + export const Buttons = styled.div` + padding-top: 10px; + padding-bottom: 10px; + justify-content: flex-end; + display: flex; + `; + + export const Button = styled(ButtonWidget)` + margin-left: 5px; + `; + + export const Crop = styled(Cropper)` + height: 100%; + width: 100%; + flex-grow: 1; + `; +} diff --git a/tornado-frontend/src/routes/content/widgets/react-canvas/ConceptCanvasModel.ts b/tornado-frontend/src/routes/content/widgets/react-canvas/ConceptCanvasModel.ts index af9510c..c726100 100644 --- a/tornado-frontend/src/routes/content/widgets/react-canvas/ConceptCanvasModel.ts +++ b/tornado-frontend/src/routes/content/widgets/react-canvas/ConceptCanvasModel.ts @@ -19,6 +19,7 @@ export class ConceptCanvasModel extends CanvasModel { if (!this.model.board.data) { return; } + // @ts-ignore this.deserializeModel(this.model.board.data, engine); } diff --git a/tornado-frontend/src/routes/content/widgets/react-canvas/controls-layer/ControlsElementWidget.tsx b/tornado-frontend/src/routes/content/widgets/react-canvas/controls-layer/ControlsElementWidget.tsx index eec4ebf..0a97790 100644 --- a/tornado-frontend/src/routes/content/widgets/react-canvas/controls-layer/ControlsElementWidget.tsx +++ b/tornado-frontend/src/routes/content/widgets/react-canvas/controls-layer/ControlsElementWidget.tsx @@ -1,31 +1,26 @@ import * as React from 'react'; import { ImageElement } from '../image-element/ImageElementFactory'; import { styled } from '../../../../../theme/theme'; -import { useForceUpdate } from '../../../../../hooks/useForceUpdate'; -import { useEffect } from 'react'; import { ImageLayerModel } from '../image-layer/ImageLayerFactory'; +import { ButtonType, ButtonWidget } from '../../../../../widgets/forms/ButtonWidget'; +import { CanvasEngine } from '@projectstorm/react-canvas-core'; +import { generatePath, useNavigate } from 'react-router-dom'; +import { Routing } from '../../../../routes'; export interface ControlsElementWidgetProps { model: ImageElement; + engine: CanvasEngine; } export const ControlsElementWidget: React.FC = (props) => { - const forceUpdate = useForceUpdate(); - useEffect(() => { - const l = props.model.registerListener({ - selectionChanged: () => { - forceUpdate(); - } - }); - return l.deregister; - }, []); + const canvas = (props.model.getParent() as ImageLayerModel).getParent(); + const zoom = canvas.getZoomLevel() / 100; + const navigate = useNavigate(); + if (!props.model.isSelected()) { return null; } - const canvas = (props.model.getParent() as ImageLayerModel).getParent(); - const zoom = canvas.getZoomLevel() / 100; - return ( = (prop width: props.model.width * zoom, height: props.model.height * zoom }} - /> + > + + { + props.model.remove(); + props.engine.repaintCanvas(); + }} + /> + { + navigate( + generatePath(Routing.CONCEPTS_BOARD_IMAGE_CROP, { + board: `${props.model.getCanvasModel().model.id}`, + image: `${props.model.imageID}` + }) + ); + }} + /> + + ); }; namespace S { @@ -45,4 +65,17 @@ namespace S { box-sizing: border-box; pointer-events: none; `; + + export const Controls = styled.div` + position: absolute; + top: -40px; + right: 0; + display: flex; + `; + + export const Button = styled(ButtonWidget)` + pointer-events: all; + margin-left: 5px; + overflow: visible; + `; } diff --git a/tornado-frontend/src/routes/content/widgets/react-canvas/controls-layer/ControlsLayerFactory.tsx b/tornado-frontend/src/routes/content/widgets/react-canvas/controls-layer/ControlsLayerFactory.tsx index dcc4e20..516340d 100644 --- a/tornado-frontend/src/routes/content/widgets/react-canvas/controls-layer/ControlsLayerFactory.tsx +++ b/tornado-frontend/src/routes/content/widgets/react-canvas/controls-layer/ControlsLayerFactory.tsx @@ -28,7 +28,7 @@ export class ControlsLayerFactory extends AbstractReactFactory {_.map(event.model.getParent().getLayers()[0].getModels(), (m: ImageElement) => { - return ; + return ; })} ); diff --git a/tornado-frontend/src/routes/content/widgets/react-canvas/image-element/ResponseImageWidget.tsx b/tornado-frontend/src/routes/content/widgets/react-canvas/image-element/ResponseImageWidget.tsx index 9abed8c..335874a 100644 --- a/tornado-frontend/src/routes/content/widgets/react-canvas/image-element/ResponseImageWidget.tsx +++ b/tornado-frontend/src/routes/content/widgets/react-canvas/image-element/ResponseImageWidget.tsx @@ -28,7 +28,7 @@ export const ResponseImageWidget: React.FC = (props) = } else if (zoom >= 50 && zoom < 120) { setSize(MediaSize.LARGE); } else { - setSize(MediaSize.ORIGINAL); + setSize(MediaSize.X_LARGE); } }; diff --git a/tornado-frontend/src/routes/manage/ConceptBoardsPage.tsx b/tornado-frontend/src/routes/manage/ConceptBoardsPage.tsx index d86cbc2..c671095 100644 --- a/tornado-frontend/src/routes/manage/ConceptBoardsPage.tsx +++ b/tornado-frontend/src/routes/manage/ConceptBoardsPage.tsx @@ -32,7 +32,7 @@ export const ConceptBoardsPage: React.FC = observer((props) => { icon="plus" action={async () => { const board = await system.conceptStore.createConcept('Unknown'); - navigate(generatePath(Routing.CONCEPTS_BOARD, { id: `${board.id}` })); + navigate(generatePath(Routing.CONCEPTS_BOARD, { board: `${board.id}` })); }} /> @@ -90,7 +90,7 @@ export const ConceptBoardsPage: React.FC = observer((props) => { key: `${c.id}`, board: c, action: () => { - navigate(generatePath(Routing.CONCEPTS_BOARD, { id: `${c.id}` })); + navigate(generatePath(Routing.CONCEPTS_BOARD, { board: `${c.id}` })); }, cells: { id: `${c.id}`, diff --git a/tornado-frontend/src/routes/routes.ts b/tornado-frontend/src/routes/routes.ts index ec9df38..90ded23 100644 --- a/tornado-frontend/src/routes/routes.ts +++ b/tornado-frontend/src/routes/routes.ts @@ -1,5 +1,6 @@ export enum Routing { SIGN_IN = '/sign-in', CONCEPTS_BOARDS = '/concepts', - CONCEPTS_BOARD = '/concepts/:id' + CONCEPTS_BOARD = '/concepts/:board', + CONCEPTS_BOARD_IMAGE_CROP = '/concepts/:board/image/:image' } diff --git a/tornado-frontend/src/stores/ConceptsStore.ts b/tornado-frontend/src/stores/ConceptsStore.ts index 11c6eda..9f14f9c 100644 --- a/tornado-frontend/src/stores/ConceptsStore.ts +++ b/tornado-frontend/src/stores/ConceptsStore.ts @@ -19,7 +19,19 @@ export interface ConceptBoardModelOptions { export class ConceptBoardModel extends BaseObserver { @observable - board: ConceptBoard; + board: ConceptBoard & { + data: { + layers: { + models: { + [id: string]: { + width: number; + height: number; + image_id: number; + }; + }; + }[]; + }; + }; constructor(protected options: ConceptBoardModelOptions) { super(); @@ -74,8 +86,10 @@ export class ConceptsStore { }); } - async loadConcept(id: string) { + async loadConcept(id: number) { + // FIXME be more specific await this.loadConcepts(); + return this._concepts.getValue(id); } async createConcept(name: string) { diff --git a/tornado-frontend/src/theme/theme-dark.ts b/tornado-frontend/src/theme/theme-dark.ts index f1d98fb..75fa11e 100644 --- a/tornado-frontend/src/theme/theme-dark.ts +++ b/tornado-frontend/src/theme/theme-dark.ts @@ -17,7 +17,7 @@ export const ThemeDark: TornadoTheme = { }, text: { heading: '#fff', - description: '#5f5f60' + description: '#757575' }, table: { row: { @@ -40,7 +40,7 @@ export const ThemeDark: TornadoTheme = { }, button: { [ButtonType.NORMAL]: { - background: '#212123', + background: '#343436', backgroundHover: '#38383c', color: '#ffffff', colorHover: '#ffffff' diff --git a/tornado-frontend/src/widgets/layout/BodyWidget.tsx b/tornado-frontend/src/widgets/layout/BodyWidget.tsx index 82da993..e640c3f 100644 --- a/tornado-frontend/src/widgets/layout/BodyWidget.tsx +++ b/tornado-frontend/src/widgets/layout/BodyWidget.tsx @@ -8,6 +8,7 @@ import { Routing } from '../../routes/routes'; import { ConceptBoardPage } from '../../routes/content/ConceptBoardPage'; import { useSystem } from '../../hooks/useSystem'; import { motion } from 'framer-motion'; +import { ImageCropPage } from '../../routes/content/ImageCropPage'; export interface BodyWidgetProps { className?: any; @@ -36,6 +37,7 @@ export const BodyWidget: React.FC = observer((props) => { } /> } /> } /> + } /> } /> diff --git a/tornado-server/src/api/MediaApi.ts b/tornado-server/src/api/MediaApi.ts index 4731807..32939fb 100644 --- a/tornado-server/src/api/MediaApi.ts +++ b/tornado-server/src/api/MediaApi.ts @@ -5,14 +5,15 @@ import { ENV } from '../Env'; import * as path from 'path'; import { System } from '../System'; import { User } from '@prisma/client'; -import { MediaSize } from '@projectstorm/tornado-common'; +import { MediaCropRequest, MediaSize } from '@projectstorm/tornado-common'; import * as crypto from 'crypto'; export class MediaApi extends AbstractApi { static SIZES = { [MediaSize.SMALL]: 200, [MediaSize.MEDIUM]: 500, - [MediaSize.LARGE]: 1000 + [MediaSize.LARGE]: 1000, + [MediaSize.X_LARGE]: 2000 }; constructor(system: System) { @@ -26,7 +27,50 @@ export class MediaApi extends AbstractApi { return crypto.createHash('md5').update(str).digest('hex'); } - async getMediaPath(image: number, size: MediaSize) { + async cropMedia(req: MediaCropRequest) { + this.logger.info(`cropping image ${req.image}`); + const file = await fs.promises.readFile(this.getMediaPath(req.image, MediaSize.ORIGINAL)); + await this.resizeMedia(file, req.image, req); + } + + async resizeMedia(file: Buffer, id: number, crop?: { left: number; top: number; width: number; height: number }) { + for (let k in MediaApi.SIZES) { + const size = MediaApi.SIZES[k]; + try { + this.logger.info(`resizing to ${size}`); + + let s = sharp(file); + if (crop) { + s = s.extract({ + top: Math.round(crop.top), + left: Math.round(crop.left), + width: Math.round(crop.width), + height: Math.round(crop.height) + }); + } + await s + .resize({ + fit: 'inside', + background: { + r: 0, + g: 0, + b: 0, + alpha: 0 + }, + width: size, + height: size + }) + .jpeg({ + quality: 100 + }) + .toFile(path.join(this.resizeDir, `${id}.${size}.jpg`)); + } catch (ex) { + this.logger.error(`failed to resize original to ${size}`, ex); + } + } + } + + getMediaPath(image: number, size: MediaSize) { if (size === MediaSize.ORIGINAL) { return path.join(this.originalDir, `${image}`); } @@ -56,31 +100,8 @@ export class MediaApi extends AbstractApi { }); } - // resize - for (let k in MediaApi.SIZES) { - const size = MediaApi.SIZES[k]; - try { - this.logger.info(`resizing to ${size}`); - await sharp(file) - .resize({ - fit: 'inside', - background: { - r: 0, - g: 0, - b: 0, - alpha: 0 - }, - width: size, - height: size - }) - .jpeg({ - quality: 100 - }) - .toFile(path.join(this.resizeDir, `${media.id}.${size}.jpg`)); - } catch (ex) { - this.logger.error(`failed to resize original to ${size}`, ex); - } - } + // resize media but dont crop + await this.resizeMedia(file, media.id); return media; } diff --git a/tornado-server/src/routes/media.ts b/tornado-server/src/routes/media.ts index 36528bd..2442301 100644 --- a/tornado-server/src/routes/media.ts +++ b/tornado-server/src/routes/media.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import { System } from '../System'; -import { GetMediaRequest, Routes } from '@projectstorm/tornado-common'; +import { GetMediaRequest, MediaCropRequest, Routes } from '@projectstorm/tornado-common'; import { createApiRoute } from '../routeUtils'; export const setupMediaRoutes = (router: Router, system: System) => { @@ -16,10 +16,20 @@ export const setupMediaRoutes = (router: Router, system: System) => { createApiRoute({ router, system, - route: Routes.GET_MEDIA, + route: Routes.MEDIA_GET, cb: async ({ data, response }) => { const path = await system.media.getMediaPath(data.image, data.size); response.sendFile(path); } }); + + createApiRoute({ + router, + system, + route: Routes.MEDIA_CROP, + cb: async ({ data }) => { + await system.media.cropMedia(data); + return {}; + } + }); };