You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

589 lines
18 KiB

  1. <template>
  2. <div class="file-uploader"
  3. @dragover.prevent="dragover"
  4. @dragleave.prevent="dragleave"
  5. @drop.prevent="dropfiles"
  6. >
  7. <div
  8. class="file-upload-area"
  9. v-show="files.length === 0 && !show_file_browser && !show_web_link"
  10. >
  11. <div v-if="!is_dragging">
  12. <div class="text-center">
  13. {{ __('Drag and drop files here or upload from') }}
  14. </div>
  15. <div class="mt-2 text-center">
  16. <button class="btn btn-file-upload" @click="browse_files">
  17. <svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
  18. <circle cx="15" cy="15" r="15" fill="url(#paint0_linear)"/>
  19. <path d="M13.5 22V19" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
  20. <path d="M16.5 22V19" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
  21. <path d="M10.5 22H19.5" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
  22. <path d="M7.5 16H22.5" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
  23. <path d="M21 8H9C8.17157 8 7.5 8.67157 7.5 9.5V17.5C7.5 18.3284 8.17157 19 9 19H21C21.8284 19 22.5 18.3284 22.5 17.5V9.5C22.5 8.67157 21.8284 8 21 8Z" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
  24. <defs>
  25. <linearGradient id="paint0_linear" x1="0" y1="0" x2="0" y2="30" gradientUnits="userSpaceOnUse">
  26. <stop stop-color="#2C9AF1"/>
  27. <stop offset="1" stop-color="#2490EF"/>
  28. </linearGradient>
  29. </defs>
  30. </svg>
  31. <div class="mt-1">{{ __('My Device') }}</div>
  32. </button>
  33. <input
  34. type="file"
  35. class="hidden"
  36. ref="file_input"
  37. @change="on_file_input"
  38. :multiple="allow_multiple"
  39. :accept="(restrictions.allowed_file_types || []).join(', ')"
  40. >
  41. <button class="btn btn-file-upload" v-if="!disable_file_browser" @click="show_file_browser = true">
  42. <svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
  43. <circle cx="15" cy="15" r="15" fill="#48BB74"/>
  44. <path d="M13.0245 11.5H8C7.72386 11.5 7.5 11.7239 7.5 12V20C7.5 21.1046 8.39543 22 9.5 22H20.5C21.6046 22 22.5 21.1046 22.5 20V14.5C22.5 14.2239 22.2761 14 22 14H15.2169C15.0492 14 14.8926 13.9159 14.8 13.776L13.4414 11.724C13.3488 11.5841 13.1922 11.5 13.0245 11.5Z" stroke="white" stroke-miterlimit="10" stroke-linecap="square"/>
  45. <path d="M8.87939 9.5V8.5C8.87939 8.22386 9.10325 8 9.37939 8H20.6208C20.8969 8 21.1208 8.22386 21.1208 8.5V12" stroke="white" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
  46. </svg>
  47. <div class="mt-1">{{ __('Library') }}</div>
  48. </button>
  49. <button class="btn btn-file-upload" v-if="allow_web_link" @click="show_web_link = true">
  50. <svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
  51. <circle cx="15" cy="15" r="15" fill="#ECAC4B"/>
  52. <path d="M12.0469 17.9543L17.9558 12.0454" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
  53. <path d="M13.8184 11.4547L15.7943 9.47873C16.4212 8.85205 17.2714 8.5 18.1578 8.5C19.0443 8.5 19.8945 8.85205 20.5214 9.47873V9.47873C21.1481 10.1057 21.5001 10.9558 21.5001 11.8423C21.5001 12.7287 21.1481 13.5789 20.5214 14.2058L18.5455 16.1818" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
  54. <path d="M11.4547 13.8184L9.47873 15.7943C8.85205 16.4212 8.5 17.2714 8.5 18.1578C8.5 19.0443 8.85205 19.8945 9.47873 20.5214V20.5214C10.1057 21.1481 10.9558 21.5001 11.8423 21.5001C12.7287 21.5001 13.5789 21.1481 14.2058 20.5214L16.1818 18.5455" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
  55. </svg>
  56. <div class="mt-1">{{ __('Link') }}</div>
  57. </button>
  58. <button v-if="allow_take_photo" class="btn btn-file-upload" @click="capture_image">
  59. <svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
  60. <circle cx="15" cy="15" r="15" fill="#CE315B"/>
  61. <path d="M11.5 10.5H9.5C8.67157 10.5 8 11.1716 8 12V20C8 20.8284 8.67157 21.5 9.5 21.5H20.5C21.3284 21.5 22 20.8284 22 20V12C22 11.1716 21.3284 10.5 20.5 10.5H18.5L17.3 8.9C17.1111 8.64819 16.8148 8.5 16.5 8.5H13.5C13.1852 8.5 12.8889 8.64819 12.7 8.9L11.5 10.5Z" stroke="white" stroke-linejoin="round"/>
  62. <circle cx="15" cy="16" r="2.5" stroke="white"/>
  63. </svg>
  64. <div class="mt-1">{{ __('Camera') }}</div>
  65. </button>
  66. <button v-if="google_drive_settings.enabled" class="btn btn-file-upload" @click="show_google_drive_picker">
  67. <svg width="30" height="30">
  68. <image xlink:href="/assets/frappe/icons/social/google_drive.svg" width="30" height="30"/>
  69. </svg>
  70. <div class="mt-1">{{ __('Google Drive') }}</div>
  71. </button>
  72. </div>
  73. <div class="text-muted text-medium">
  74. {{ upload_notes }}
  75. </div>
  76. </div>
  77. <div v-else>
  78. {{ __('Drop files here') }}
  79. </div>
  80. </div>
  81. <div class="file-preview-area" v-show="files.length && !show_file_browser && !show_web_link">
  82. <div class="file-preview-container" v-if="!show_image_cropper">
  83. <FilePreview
  84. v-for="(file, i) in files"
  85. :key="file.name"
  86. :file="file"
  87. @remove="remove_file(file)"
  88. @toggle_private="file.private = !file.private"
  89. @toggle_optimize="file.optimize = !file.optimize"
  90. @toggle_image_cropper="toggle_image_cropper(i)"
  91. />
  92. </div>
  93. <div class="flex align-center" v-if="show_upload_button && currently_uploading === -1">
  94. <button
  95. class="btn btn-primary btn-sm margin-right"
  96. @click="upload_files"
  97. >
  98. <span v-if="files.length === 1">
  99. {{ __('Upload file') }}
  100. </span>
  101. <span v-else>
  102. {{ __('Upload {0} files', [files.length]) }}
  103. </span>
  104. </button>
  105. <div class="text-muted text-medium">
  106. {{ __('Click on the lock icon to toggle public/private') }}
  107. </div>
  108. </div>
  109. </div>
  110. <ImageCropper
  111. v-if="show_image_cropper && wrapper_ready"
  112. :file="files[crop_image_with_index]"
  113. :fixed_aspect_ratio="restrictions.crop_image_aspect_ratio"
  114. @toggle_image_cropper="toggle_image_cropper(-1)"
  115. @upload_after_crop="trigger_upload=true"
  116. />
  117. <FileBrowser
  118. ref="file_browser"
  119. v-if="show_file_browser && !disable_file_browser"
  120. @hide-browser="show_file_browser = false"
  121. />
  122. <WebLink
  123. ref="web_link"
  124. v-if="show_web_link"
  125. @hide-web-link="show_web_link = false"
  126. />
  127. </div>
  128. </template>
  129. <script>
  130. import FilePreview from './FilePreview.vue';
  131. import FileBrowser from './FileBrowser.vue';
  132. import WebLink from './WebLink.vue';
  133. import GoogleDrivePicker from '../../integrations/google_drive_picker';
  134. import ImageCropper from './ImageCropper.vue';
  135. export default {
  136. name: 'FileUploader',
  137. props: {
  138. show_upload_button: {
  139. default: true
  140. },
  141. disable_file_browser: {
  142. default: false
  143. },
  144. allow_multiple: {
  145. default: true
  146. },
  147. as_dataurl: {
  148. default: false
  149. },
  150. doctype: {
  151. default: null
  152. },
  153. docname: {
  154. default: null
  155. },
  156. fieldname: {
  157. default: null
  158. },
  159. folder: {
  160. default: 'Home'
  161. },
  162. method: {
  163. default: null
  164. },
  165. on_success: {
  166. default: null
  167. },
  168. restrictions: {
  169. default: () => ({
  170. max_file_size: null, // 2048 -> 2KB
  171. max_number_of_files: null,
  172. allowed_file_types: [], // ['image/*', 'video/*', '.jpg', '.gif', '.pdf'],
  173. crop_image_aspect_ratio: null // 1, 16 / 9, 4 / 3, NaN (free)
  174. })
  175. },
  176. attach_doc_image: {
  177. default: false
  178. },
  179. upload_notes: {
  180. default: null // "Images or video, upto 2MB"
  181. }
  182. },
  183. components: {
  184. FilePreview,
  185. FileBrowser,
  186. WebLink,
  187. ImageCropper
  188. },
  189. data() {
  190. return {
  191. files: [],
  192. is_dragging: false,
  193. currently_uploading: -1,
  194. show_file_browser: false,
  195. show_web_link: false,
  196. show_image_cropper: false,
  197. crop_image_with_index: -1,
  198. trigger_upload: false,
  199. close_dialog: false,
  200. hide_dialog_footer: false,
  201. allow_take_photo: false,
  202. allow_web_link: true,
  203. google_drive_settings: {
  204. enabled: false
  205. },
  206. wrapper_ready: false
  207. }
  208. },
  209. created() {
  210. this.allow_take_photo = window.navigator.mediaDevices;
  211. if (frappe.user_id !== "Guest") {
  212. frappe.call({
  213. // method only available after login
  214. method: "frappe.integrations.doctype.google_settings.google_settings.get_file_picker_settings",
  215. callback: (resp) => {
  216. if (!resp.exc) {
  217. this.google_drive_settings = resp.message;
  218. }
  219. }
  220. });
  221. }
  222. if (this.restrictions.max_file_size == null) {
  223. frappe.call('frappe.core.doctype.file.file.get_max_file_size')
  224. .then(res => {
  225. this.restrictions.max_file_size = Number(res.message);
  226. });
  227. }
  228. },
  229. watch: {
  230. files(newvalue, oldvalue) {
  231. if (!this.allow_multiple && newvalue.length > 1) {
  232. this.files = [newvalue[newvalue.length - 1]];
  233. }
  234. }
  235. },
  236. computed: {
  237. upload_complete() {
  238. return this.files.length > 0
  239. && this.files.every(
  240. file => file.total !== 0 && file.progress === file.total);
  241. }
  242. },
  243. methods: {
  244. dragover() {
  245. this.is_dragging = true;
  246. },
  247. dragleave() {
  248. this.is_dragging = false;
  249. },
  250. dropfiles(e) {
  251. this.is_dragging = false;
  252. this.add_files(e.dataTransfer.files);
  253. },
  254. browse_files() {
  255. this.$refs.file_input.click();
  256. },
  257. on_file_input(e) {
  258. this.add_files(this.$refs.file_input.files);
  259. },
  260. remove_file(file) {
  261. this.files = this.files.filter(f => f !== file);
  262. },
  263. toggle_image_cropper(index) {
  264. this.crop_image_with_index = this.show_image_cropper ? -1 : index;
  265. this.hide_dialog_footer = !this.show_image_cropper;
  266. this.show_image_cropper = !this.show_image_cropper;
  267. },
  268. toggle_all_private() {
  269. let flag;
  270. let private_values = this.files.filter(file => file.private);
  271. if (private_values.length < this.files.length) {
  272. // there are some private and some public
  273. // set all to private
  274. flag = true;
  275. } else {
  276. // all are private, set all to public
  277. flag = false;
  278. }
  279. this.files = this.files.map(file => {
  280. file.private = flag;
  281. return file;
  282. });
  283. },
  284. add_files(file_array) {
  285. let files = Array.from(file_array)
  286. .filter(this.check_restrictions)
  287. .map(file => {
  288. let is_image = file.type.startsWith('image');
  289. let size_kb = file.size / 1024;
  290. return {
  291. file_obj: file,
  292. cropper_file: file,
  293. crop_box_data: null,
  294. optimize: size_kb > 200 && is_image && !file.type.includes('svg'),
  295. name: file.name,
  296. doc: null,
  297. progress: 0,
  298. total: 0,
  299. failed: false,
  300. request_succeeded: false,
  301. error_message: null,
  302. uploading: false,
  303. private: !is_image
  304. }
  305. });
  306. this.files = this.files.concat(files);
  307. // if only one file is allowed and crop_image_aspect_ratio is set, open cropper immediately
  308. if (this.files.length === 1 && !this.allow_multiple && this.restrictions.crop_image_aspect_ratio != null) {
  309. if (!this.files[0].file_obj.type.includes('svg')) {
  310. this.toggle_image_cropper(0);
  311. }
  312. }
  313. },
  314. check_restrictions(file) {
  315. let { max_file_size, allowed_file_types = [] } = this.restrictions;
  316. let mime_type = file.type;
  317. let extension = '.' + file.name.split('.').pop();
  318. let is_correct_type = true;
  319. let valid_file_size = true;
  320. if (allowed_file_types.length) {
  321. is_correct_type = allowed_file_types.some((type) => {
  322. // is this is a mime-type
  323. if (type.includes('/')) {
  324. if (!file.type) return false;
  325. return file.type.match(type);
  326. }
  327. // otherwise this is likely an extension
  328. if (type[0] === '.') {
  329. return file.name.endsWith(type);
  330. }
  331. return false;
  332. });
  333. }
  334. if (max_file_size && file.size != null) {
  335. valid_file_size = file.size < max_file_size;
  336. }
  337. if (!is_correct_type) {
  338. console.warn('File skipped because of invalid file type', file);
  339. frappe.show_alert({
  340. message: __('File "{0}" was skipped because of invalid file type', [file.name]),
  341. indicator: 'orange'
  342. });
  343. }
  344. if (!valid_file_size) {
  345. console.warn('File skipped because of invalid file size', file.size, file);
  346. frappe.show_alert({
  347. message: __('File "{0}" was skipped because size exceeds {1} MB', [file.name, max_file_size / (1024 * 1024)]),
  348. indicator: 'orange'
  349. });
  350. }
  351. return is_correct_type && valid_file_size;
  352. },
  353. upload_files() {
  354. if (this.show_file_browser) {
  355. return this.upload_via_file_browser();
  356. }
  357. if (this.show_web_link) {
  358. return this.upload_via_web_link();
  359. }
  360. if (this.as_dataurl) {
  361. return this.return_as_dataurl();
  362. }
  363. return frappe.run_serially(
  364. this.files.map(
  365. (file, i) =>
  366. () => this.upload_file(file, i)
  367. )
  368. );
  369. },
  370. upload_via_file_browser() {
  371. let selected_file = this.$refs.file_browser.selected_node;
  372. if (!selected_file.value) {
  373. frappe.msgprint(__('Click on a file to select it.'));
  374. this.close_dialog = true;
  375. return Promise.reject();
  376. }
  377. this.close_dialog = true;
  378. return this.upload_file({
  379. file_url: selected_file.file_url
  380. });
  381. },
  382. upload_via_web_link() {
  383. let file_url = this.$refs.web_link.url;
  384. if (!file_url) {
  385. frappe.msgprint(__('Invalid URL'));
  386. this.close_dialog = true;
  387. return Promise.reject();
  388. }
  389. file_url = decodeURI(file_url)
  390. this.close_dialog = true;
  391. return this.upload_file({
  392. file_url
  393. });
  394. },
  395. return_as_dataurl() {
  396. let promises = this.files.map(file =>
  397. frappe.dom.file_to_base64(file.file_obj)
  398. .then(dataurl => {
  399. file.dataurl = dataurl;
  400. this.on_success && this.on_success(file);
  401. })
  402. );
  403. this.close_dialog = true;
  404. return Promise.all(promises);
  405. },
  406. upload_file(file, i) {
  407. this.currently_uploading = i;
  408. return new Promise((resolve, reject) => {
  409. let xhr = new XMLHttpRequest();
  410. xhr.upload.addEventListener('loadstart', (e) => {
  411. file.uploading = true;
  412. })
  413. xhr.upload.addEventListener('progress', (e) => {
  414. if (e.lengthComputable) {
  415. file.progress = e.loaded;
  416. file.total = e.total;
  417. }
  418. })
  419. xhr.upload.addEventListener('load', (e) => {
  420. file.uploading = false;
  421. resolve();
  422. })
  423. xhr.addEventListener('error', (e) => {
  424. file.failed = true;
  425. reject();
  426. })
  427. xhr.onreadystatechange = () => {
  428. if (xhr.readyState == XMLHttpRequest.DONE) {
  429. if (xhr.status === 200) {
  430. file.request_succeeded = true;
  431. let r = null;
  432. let file_doc = null;
  433. try {
  434. r = JSON.parse(xhr.responseText);
  435. if (r.message.doctype === 'File') {
  436. file_doc = r.message;
  437. }
  438. } catch(e) {
  439. r = xhr.responseText;
  440. }
  441. file.doc = file_doc;
  442. if (this.on_success) {
  443. this.on_success(file_doc, r);
  444. }
  445. if (i == this.files.length - 1 && this.files.every(file => file.request_succeeded)) {
  446. this.close_dialog = true;
  447. }
  448. } else if (xhr.status === 403) {
  449. file.failed = true;
  450. let response = JSON.parse(xhr.responseText);
  451. file.error_message = `Not permitted. ${response._error_message || ''}`;
  452. } else if (xhr.status === 413) {
  453. file.failed = true;
  454. file.error_message = 'Size exceeds the maximum allowed file size.';
  455. } else {
  456. file.failed = true;
  457. file.error_message = xhr.status === 0 ? 'XMLHttpRequest Error' : `${xhr.status} : ${xhr.statusText}`;
  458. let error = null;
  459. try {
  460. error = JSON.parse(xhr.responseText);
  461. } catch(e) {
  462. // pass
  463. }
  464. frappe.request.cleanup({}, error);
  465. }
  466. }
  467. }
  468. xhr.open('POST', '/api/method/upload_file', true);
  469. xhr.setRequestHeader('Accept', 'application/json');
  470. xhr.setRequestHeader('X-Frappe-CSRF-Token', frappe.csrf_token);
  471. let form_data = new FormData();
  472. if (file.file_obj) {
  473. form_data.append('file', file.file_obj, file.name);
  474. }
  475. form_data.append('is_private', +file.private);
  476. form_data.append('folder', this.folder);
  477. if (file.file_url) {
  478. form_data.append('file_url', file.file_url);
  479. }
  480. if (file.file_name) {
  481. form_data.append('file_name', file.file_name);
  482. }
  483. if (this.doctype && this.docname) {
  484. form_data.append('doctype', this.doctype);
  485. form_data.append('docname', this.docname);
  486. }
  487. if (this.fieldname) {
  488. form_data.append('fieldname', this.fieldname);
  489. }
  490. if (this.method) {
  491. form_data.append('method', this.method);
  492. }
  493. if (file.optimize) {
  494. form_data.append('optimize', true);
  495. }
  496. if (this.attach_doc_image) {
  497. form_data.append('max_width', 200);
  498. form_data.append('max_height', 200);
  499. }
  500. xhr.send(form_data);
  501. });
  502. },
  503. capture_image() {
  504. const capture = new frappe.ui.Capture({
  505. animate: false,
  506. error: true
  507. });
  508. capture.show();
  509. capture.submit(data_urls => {
  510. data_urls.forEach(data_url => {
  511. let filename = `capture_${frappe.datetime.now_datetime().replaceAll(/[: -]/g, '_')}.png`;
  512. this.url_to_file(data_url, filename, 'image/png').then((file) =>
  513. this.add_files([file])
  514. );
  515. });
  516. });
  517. },
  518. show_google_drive_picker() {
  519. this.close_dialog = true;
  520. let google_drive = new GoogleDrivePicker({
  521. pickerCallback: data => this.google_drive_callback(data),
  522. ...this.google_drive_settings
  523. });
  524. google_drive.loadPicker();
  525. },
  526. google_drive_callback(data) {
  527. if (data.action == google.picker.Action.PICKED) {
  528. this.upload_file({
  529. file_url: data.docs[0].url,
  530. file_name: data.docs[0].name
  531. });
  532. } else if (data.action == google.picker.Action.CANCEL) {
  533. cur_frm.attachments.new_attachment()
  534. }
  535. },
  536. url_to_file(url, filename, mime_type) {
  537. return fetch(url)
  538. .then(res => res.arrayBuffer())
  539. .then(buffer => new File([buffer], filename, { type: mime_type }));
  540. },
  541. }
  542. }
  543. </script>
  544. <style>
  545. .file-upload-area {
  546. min-height: 16rem;
  547. display: flex;
  548. align-items: center;
  549. justify-content: center;
  550. border: 1px dashed var(--dark-border-color);
  551. border-radius: var(--border-radius);
  552. cursor: pointer;
  553. background-color: var(--bg-color);
  554. }
  555. .btn-file-upload {
  556. background-color: transparent;
  557. border: none;
  558. box-shadow: none;
  559. font-size: var(--text-xs);
  560. }
  561. </style>