diff --git a/client/constants.js b/client/constants.js index 57c85f4e85..328868f07f 100644 --- a/client/constants.js +++ b/client/constants.js @@ -140,3 +140,8 @@ export const SET_COOKIE_CONSENT = 'SET_COOKIE_CONSENT'; export const CONSOLE_EVENT = 'CONSOLE_EVENT'; export const CLEAR_CONSOLE = 'CLEAR_CONSOLE'; + +export const OPEN_UPLOAD_IMAGE_BY_URL_MODAL = 'OPEN_UPLOAD_IMAGE_BY_URL_MODAL'; +export const CLOSE_UPLOAD_IMAGE_BY_URL_MODAL = + 'CLOSE_UPLOAD_IMAGE_BY_URL_MODAL'; +export const ADD_SKETCH_FILE = 'ADD_SKETCH_FILE'; diff --git a/client/modules/IDE/actions/ide.js b/client/modules/IDE/actions/ide.js index 80a43443bc..5b823db57d 100644 --- a/client/modules/IDE/actions/ide.js +++ b/client/modules/IDE/actions/ide.js @@ -91,6 +91,19 @@ export function closeUploadFileModal() { }; } +export function openUploadImageByUrlModal(parentId) { + return { + type: ActionTypes.OPEN_UPLOAD_IMAGE_BY_URL_MODAL, + parentId + }; +} + +export function closeUploadImageByUrlModal() { + return { + type: ActionTypes.CLOSE_UPLOAD_IMAGE_BY_URL_MODAL + }; +} + export function expandSidebar() { return { type: ActionTypes.EXPAND_SIDEBAR diff --git a/client/modules/IDE/components/Sidebar.jsx b/client/modules/IDE/components/Sidebar.jsx index 24fd487c9a..8b2fd2a735 100644 --- a/client/modules/IDE/components/Sidebar.jsx +++ b/client/modules/IDE/components/Sidebar.jsx @@ -8,7 +8,8 @@ import { newFile, newFolder, openProjectOptions, - openUploadFileModal + openUploadFileModal, + openUploadImageByUrlModal } from '../actions/ide'; import { selectRootFile } from '../selectors/files'; import { getAuthenticated, selectCanEditSketch } from '../selectors/users'; @@ -16,7 +17,7 @@ import { getAuthenticated, selectCanEditSketch } from '../selectors/users'; import ConnectedFileNode from './FileNode'; import { PlusIcon } from '../../../common/icons'; import { FileDrawer } from './Editor/MobileEditor'; - +import UploadMediaModal from './UploadMediaModal'; // TODO: use a generic Dropdown UI component export default function SideBar() { @@ -31,6 +32,9 @@ export default function SideBar() { const isExpanded = useSelector((state) => state.ide.sidebarIsExpanded); const canEditProject = useSelector(selectCanEditSketch); const isAuthenticated = useSelector(getAuthenticated); + const isUploadImageByUrlModalOpen = useSelector( + (state) => state.ide.uploadImageByUrlModalVisible + ); const sidebarOptionsRef = useRef(null); @@ -137,11 +141,42 @@ export default function SideBar() { )} + {isAuthenticated && canEditProject && ( +
  • + +
  • + )} )} + {isUploadImageByUrlModalOpen && ( + { + dispatch({ + type: 'ADD_SKETCH_FILE', + payload: { + name: `image-${Date.now()}.jpg`, + url: s3Url, + parentId: rootFile.id + } + }); + dispatch({ type: 'CLOSE_UPLOAD_IMAGE_BY_URL_MODAL' }); + }} + onClose={() => + dispatch({ type: 'CLOSE_UPLOAD_IMAGE_BY_URL_MODAL' }) + } + /> + )} ); diff --git a/client/modules/IDE/components/UploadMediaModal.jsx b/client/modules/IDE/components/UploadMediaModal.jsx new file mode 100644 index 0000000000..d7d8a8f676 --- /dev/null +++ b/client/modules/IDE/components/UploadMediaModal.jsx @@ -0,0 +1,96 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { useTranslation } from 'react-i18next'; +import api from '../../../utils/api'; +import Button from '../../../common/Button'; +import Modal from './Modal'; + +const UploadMediaModal = ({ onUploadSuccess, onClose }) => { + const { t } = useTranslation(); + const [imageUrl, setImageUrl] = useState(''); + const [isUploading, setIsUploading] = useState(false); + const [error, setError] = useState(null); + + const handleInputChange = (e) => { + setImageUrl(e.target.value); + if (error) setError(null); + }; + + const handleSubmit = async () => { + if (!imageUrl.trim()) { + setError(t('UploadMediaModal.EmptyUrlError', 'Image URL is required')); + return; + } + + setIsUploading(true); + setError(null); + + try { + console.log('Uploading image URL:', imageUrl); + const { s3Url } = await api.uploadImageByUrl(imageUrl); + console.log('Upload success, s3Url:', s3Url); + onUploadSuccess(s3Url); + } catch (err) { + console.error('Upload failed:', err.message, err.stack); + setError(t('UploadMediaModal.UploadError', 'Failed to upload image')); + setIsUploading(false); + } + }; + + return ( + +
    + + +
    + + {error &&

    {error}

    } + +
    + +
    + + +
    + + ); +}; + +UploadMediaModal.propTypes = { + onUploadSuccess: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired +}; + +export default UploadMediaModal; diff --git a/client/modules/IDE/reducers/files.js b/client/modules/IDE/reducers/files.js index de1b9b1aa7..732d820c9c 100644 --- a/client/modules/IDE/reducers/files.js +++ b/client/modules/IDE/reducers/files.js @@ -214,6 +214,38 @@ const files = (state, action) => { return file; }); } + case ActionTypes.ADD_SKETCH_FILE: { + const parentFile = state.find( + (file) => file.id === action.payload.parentId + ); + const filePath = + parentFile.name === 'root' + ? '' + : `${parentFile.filePath}/${parentFile.name}`; + const newId = objectID().toHexString(); + const newState = [ + ...updateParent(state, { + parentId: action.payload.parentId, + id: newId + }), + { + name: action.payload.name, + id: newId, + _id: newId, + url: action.payload.url, + content: '', + children: [], + fileType: 'file', + filePath + } + ]; + return newState.map((file) => { + if (file.id === action.payload.parentId) { + file.children = sortedChildrenId(newState, file.children); + } + return file; + }); + } case ActionTypes.UPDATE_FILE_NAME: { const newState = renameFile(state, action); const updatedFile = newState.find((file) => file.id === action.id); diff --git a/client/modules/IDE/reducers/ide.js b/client/modules/IDE/reducers/ide.js index 8d45b8b50a..2cb396956d 100644 --- a/client/modules/IDE/reducers/ide.js +++ b/client/modules/IDE/reducers/ide.js @@ -10,6 +10,7 @@ const initialState = { projectOptionsVisible: false, newFolderModalVisible: false, uploadFileModalVisible: false, + uploadImageByUrlModalVisible: false, shareModalVisible: false, shareModalProjectId: 'abcd', shareModalProjectName: 'My Cute Sketch', @@ -122,6 +123,13 @@ const ide = (state = initialState, action) => { }); case ActionTypes.CLOSE_UPLOAD_FILE_MODAL: return Object.assign({}, state, { uploadFileModalVisible: false }); + case ActionTypes.OPEN_UPLOAD_IMAGE_BY_URL_MODAL: + return Object.assign({}, state, { + uploadImageByUrlModalVisible: true, + parentId: action.parentId + }); + case ActionTypes.CLOSE_UPLOAD_IMAGE_BY_URL_MODAL: + return Object.assign({}, state, { uploadImageByUrlModalVisible: false }); default: return state; } diff --git a/client/styles/components/_modal.scss b/client/styles/components/_modal.scss index 9942b9887e..68b7f0e511 100644 --- a/client/styles/components/_modal.scss +++ b/client/styles/components/_modal.scss @@ -1,10 +1,18 @@ @use "sass:math"; +$base-font-size: 16; + .modal { - position: absolute; - top: #{math.div(60, $base-font-size)}rem; - right: 50%; - transform: translate(50%, 0); + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: flex-start; + padding-top: #{math.div(60, $base-font-size)}rem; z-index: 100; outline: none; } @@ -18,9 +26,9 @@ width: #{math.div(500, $base-font-size)}rem; } - .modal--reduced & { - //min-height: #{150 / $base-font-size}rem; - } + // .modal--reduced & { + // //min-height: #{150 / $base-font-size}rem; + // } } .modal-content-folder { @@ -28,6 +36,10 @@ height: #{math.div(150, $base-font-size)}rem; } +.upload-media-modal__content { + text-align: center; +} + .modal__exit-button { @include icon(); } @@ -38,20 +50,63 @@ margin-bottom: #{math.div(20, $base-font-size)}rem; } -.new-folder-form__input-wrapper, .new-file-form__input-wrapper { +.modal__title { + font-size: #{math.div(20, $base-font-size)}rem; +} + +.new-folder-form__input-wrapper, +.new-file-form__input-wrapper, +.upload-media-modal__input-wrapper { display: flex; + margin-bottom: #{math.div(20, $base-font-size)}rem; } -.new-file-form__name-label, .new-folder-form__name-label { +.new-file-form__name-label, +.new-folder-form__name-label, +.upload-media-modal__name-label { @extend %hidden-element; } -.new-file-form__name-input, .new-folder-form__name-input { +.new-file-form__name-input, +.new-folder-form__name-input, +.upload-media-modal__input { margin-right: #{math.div(10, $base-font-size)}rem; flex: 1; + width: 100%; + padding: #{math.div(8, $base-font-size)}rem; + border: #{math.div(1, $base-font-size)}rem solid #ccc; + box-sizing: border-box; +} + +.upload-media-modal__error { + margin: #{math.div(10, $base-font-size)}rem 0; + font-size: #{math.div(14, $base-font-size)}rem; } .modal__divider { text-align: center; margin: #{math.div(20, $base-font-size)}rem 0; + border-top: #{math.div(1, $base-font-size)}rem solid #ccc; } + +.upload-media-modal__footer { + display: flex; + justify-content: center; +} + +.upload-media-modal__button { + padding: #{math.div(8, $base-font-size)}rem #{math.div(16, $base-font-size)}rem; + margin: #{math.div(5, $base-font-size)}rem; + border: none; + cursor: pointer; + font-size: #{math.div(14, $base-font-size)}rem; + + &--cancel { + margin-left: #{math.div(10, $base-font-size)}rem; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } +} \ No newline at end of file diff --git a/client/utils/api.js b/client/utils/api.js new file mode 100644 index 0000000000..dedbd77dd5 --- /dev/null +++ b/client/utils/api.js @@ -0,0 +1,14 @@ +import axios from 'axios'; + +const api = { + uploadImageByUrl: async (imageUrl) => { + const response = await axios.post('/api/media/upload-by-url', { imageUrl }); + return response.data; + }, + updateSketchFiles: async ({ sketchId, files }) => { + const response = await axios.put(`/api/sketches/${sketchId}`, { files }); + return response.data; + } +}; + +export default api; diff --git a/package.json b/package.json index 98256b4c4c..02d9f1a34f 100644 --- a/package.json +++ b/package.json @@ -169,7 +169,8 @@ "@redux-devtools/log-monitor": "^4.0.2", "@reduxjs/toolkit": "^1.9.3", "async": "^3.2.3", - "axios": "^1.8.2", + "aws-sdk": "^2.1692.0", + "axios": "^1.9.0", "babel-plugin-styled-components": "^1.13.2", "bcryptjs": "^2.4.3", "blob-util": "^1.2.1", diff --git a/server/controllers/media.controller.js b/server/controllers/media.controller.js new file mode 100644 index 0000000000..ec5f13e749 --- /dev/null +++ b/server/controllers/media.controller.js @@ -0,0 +1,46 @@ +const AWS = require('aws-sdk'); +const axios = require('axios'); +const { v4: uuidv4 } = require('uuid'); + +const s3 = new AWS.S3({ + accessKeyId: process.env.AWS_ACCESS_KEY, + secretAccessKey: process.env.AWS_SECRET_KEY, + region: process.env.AWS_REGION +}); + +exports.uploadImageByUrl = async (req, res) => { + const { imageUrl } = req.body; + if (!imageUrl) { + return res.status(400).json({ error: 'Image URL is required' }); + } + + try { + console.log('Fetching image from:', imageUrl); + const response = await axios.get(imageUrl, { + responseType: 'arraybuffer' + }); + + const fileName = `image-${uuidv4()}.jpg`; + const bucket = process.env.S3_BUCKET; + const key = `images/${fileName}`; + + const params = { + Bucket: bucket, + Key: key, + Body: response.data, + ContentType: response.headers['content-type'], + ACL: 'public-read' + }; + + console.log('Uploading to S3:', { bucket, key }); + const uploadResult = await s3.upload(params).promise(); + + const s3Url = uploadResult.Location; + console.log('S3 upload success:', s3Url); + + return res.json({ s3Url }); + } catch (error) { + console.error('Error uploading image:', error.message, error.stack); + return res.status(500).json({ error: 'Failed to upload image' }); + } +}; diff --git a/server/routes/media.routes.js b/server/routes/media.routes.js new file mode 100644 index 0000000000..9930028bd1 --- /dev/null +++ b/server/routes/media.routes.js @@ -0,0 +1,8 @@ +const express = require('express'); + +const router = express.Router(); +const mediaController = require('../controllers/media.controller'); + +router.post('/upload-by-url', mediaController.uploadImageByUrl); + +module.exports = router; diff --git a/server/server.js b/server/server.js index 1e9aa6ed6a..c31c2306f4 100644 --- a/server/server.js +++ b/server/server.js @@ -76,6 +76,10 @@ app.use(cookieParser()); mongoose.set('strictQuery', true); +const mediaRoutes = require('./routes/media.routes'); + +app.use('/api/media', mediaRoutes); + const clientPromise = mongoose .connect(mongoConnectionString, { useNewUrlParser: true, diff --git a/server/utils/s3.js b/server/utils/s3.js new file mode 100644 index 0000000000..5ae0a1b744 --- /dev/null +++ b/server/utils/s3.js @@ -0,0 +1,25 @@ +const AWS = require('aws-sdk'); + +const s3 = new AWS.S3({ + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + region: process.env.AWS_REGION +}); + +exports.uploadToS3 = async (buffer, fileName, contentType) => { + const params = { + Bucket: process.env.S3_BUCKET_NAME, + Key: fileName, + Body: buffer, + ContentType: contentType, + ACL: 'public-read' + }; + + try { + const { Location } = await s3.upload(params).promise(); + return Location; + } catch (error) { + console.error('Error uploading to S3:', error); + throw new Error('Failed to upload to S3'); + } +}; diff --git a/translations/locales/en-US/translations.json b/translations/locales/en-US/translations.json index 85359d670c..de0b0f8011 100644 --- a/translations/locales/en-US/translations.json +++ b/translations/locales/en-US/translations.json @@ -266,7 +266,9 @@ "AddFile": "Create file", "AddFileARIA": "add file", "UploadFile": "Upload file", - "UploadFileARIA": "upload file" + "UploadFileARIA": "upload file", + "UploadImageByUrl":"Upload Image by URL", + "UploadImageByUrlARIA":"Upload Image by URL" }, "FileNode": { "OpenFolderARIA": "Open folder contents",