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",