archiveTile.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
  1. /* global Android */
  2. const html = require('choo/html');
  3. const raw = require('choo/html/raw');
  4. const assets = require('../../common/assets');
  5. const {
  6. bytes,
  7. copyToClipboard,
  8. list,
  9. percent,
  10. platform,
  11. timeLeft
  12. } = require('../utils');
  13. const expiryOptions = require('./expiryOptions');
  14. function expiryInfo(translate, archive) {
  15. const l10n = timeLeft(archive.expiresAt - Date.now());
  16. return raw(
  17. translate('archiveExpiryInfo', {
  18. downloadCount: translate('downloadCount', {
  19. num: archive.dlimit - archive.dtotal
  20. }),
  21. timespan: translate(l10n.id, l10n)
  22. })
  23. );
  24. }
  25. function password(state) {
  26. const MAX_LENGTH = 32;
  27. return html`
  28. <div class="mb-2 px-1">
  29. <div class="checkbox inline-block mr-3">
  30. <input
  31. id="add-password"
  32. type="checkbox"
  33. ${state.archive.password ? 'checked' : ''}
  34. autocomplete="off"
  35. onchange="${togglePasswordInput}"
  36. />
  37. <label for="add-password">
  38. ${state.translate('addPassword')}
  39. </label>
  40. </div>
  41. <input
  42. id="password-input"
  43. class="${state.archive.password
  44. ? ''
  45. : 'invisible'} border rounded focus:border-blue-60 leading-normal my-1 py-1 px-2 h-8 dark:bg-grey-80"
  46. autocomplete="off"
  47. maxlength="${MAX_LENGTH}"
  48. type="password"
  49. oninput="${inputChanged}"
  50. onfocus="${focused}"
  51. placeholder="${state.translate('unlockInputPlaceholder')}"
  52. value="${state.archive.password || ''}"
  53. />
  54. <label
  55. id="password-msg"
  56. for="password-input"
  57. class="block text-xs text-grey-70"
  58. ></label>
  59. </div>
  60. `;
  61. function togglePasswordInput(event) {
  62. event.stopPropagation();
  63. const checked = event.target.checked;
  64. const input = document.getElementById('password-input');
  65. if (checked) {
  66. input.classList.remove('invisible');
  67. input.focus();
  68. } else {
  69. input.classList.add('invisible');
  70. input.value = '';
  71. document.getElementById('password-msg').textContent = '';
  72. state.archive.password = null;
  73. }
  74. }
  75. function inputChanged() {
  76. const passwordInput = document.getElementById('password-input');
  77. const pwdmsg = document.getElementById('password-msg');
  78. const password = passwordInput.value;
  79. const length = password.length;
  80. if (length === MAX_LENGTH) {
  81. pwdmsg.textContent = state.translate('maxPasswordLength', {
  82. length: MAX_LENGTH
  83. });
  84. } else {
  85. pwdmsg.textContent = '';
  86. }
  87. state.archive.password = password;
  88. }
  89. function focused(event) {
  90. event.preventDefault();
  91. const el = document.getElementById('password-input');
  92. if (el.placeholder !== state.translate('unlockInputPlaceholder')) {
  93. el.placeholder = '';
  94. }
  95. }
  96. }
  97. function fileInfo(file, action) {
  98. return html`
  99. <send-file class="flex flex-row items-center p-3 w-full">
  100. <svg class="h-8 w-8 text-white dark:text-grey-90">
  101. <use xlink:href="${assets.get('blue_file.svg')}#icon"/>
  102. </svg>
  103. <p class="ml-4 w-full">
  104. <h1 class="text-base font-medium word-break-all">${file.name}</h1>
  105. <div class="text-sm font-normal opacity-75 pt-1">${bytes(
  106. file.size
  107. )}</div>
  108. </p>
  109. ${action}
  110. </send-file>`;
  111. }
  112. function archiveInfo(archive, action) {
  113. return html`
  114. <p class="w-full flex items-center">
  115. <svg class="h-8 w-6 mr-3 flex-shrink-0 text-white dark:text-grey-90">
  116. <use xlink:href="${assets.get('blue_file.svg')}#icon"/>
  117. </svg>
  118. <p class="flex-grow">
  119. <h1 class="text-base font-medium word-break-all">${archive.name}</h1>
  120. <div class="text-sm font-normal opacity-75 pt-1">${bytes(
  121. archive.size
  122. )}</div>
  123. </p>
  124. ${action}
  125. </p>`;
  126. }
  127. function archiveDetails(translate, archive) {
  128. if (archive.manifest.files.length > 1) {
  129. return html`
  130. <details
  131. class="w-full pb-1"
  132. ${archive.open ? 'open' : ''}
  133. ontoggle="${toggled}"
  134. >
  135. <summary
  136. class="flex items-center link-blue text-sm cursor-pointer outline-none"
  137. >
  138. <svg
  139. class="fill-current w-4 h-4 mr-1"
  140. xmlns="http://www.w3.org/2000/svg"
  141. viewBox="0 0 20 20"
  142. >
  143. <path
  144. d="M12.95 10.707l.707-.707L8 4.343 6.586 5.757 10.828 10l-4.242 4.243L8 15.657l4.95-4.95z"
  145. />
  146. </svg>
  147. ${translate('fileCount', {
  148. num: archive.manifest.files.length
  149. })}
  150. </summary>
  151. ${list(archive.manifest.files.map(f => fileInfo(f)))}
  152. </details>
  153. `;
  154. }
  155. function toggled(event) {
  156. event.stopPropagation();
  157. archive.open = event.target.open;
  158. }
  159. }
  160. module.exports = function(state, emit, archive) {
  161. const copyOrShare =
  162. state.capabilities.share || platform() === 'android'
  163. ? html`
  164. <button
  165. class="link-blue self-end flex items-start"
  166. onclick=${share}
  167. title="Share link"
  168. >
  169. <svg class="h-4 w-4 mr-2">
  170. <use xlink:href="${assets.get('share-24.svg')}#icon" />
  171. </svg>
  172. Share link
  173. </button>
  174. `
  175. : html`
  176. <button
  177. class="link-blue focus:outline self-end flex items-center"
  178. onclick=${copy}
  179. title="${state.translate('copyLinkButton')}"
  180. >
  181. <svg class="h-4 w-4 mr-2">
  182. <use xlink:href="${assets.get('copy-16.svg')}#icon" />
  183. </svg>
  184. ${state.translate('copyLinkButton')}
  185. </button>
  186. `;
  187. const dl =
  188. platform() === 'web'
  189. ? html`
  190. <a
  191. class="flex items-baseline link-blue"
  192. href="${archive.url}"
  193. title="${state.translate('downloadButtonLabel')}"
  194. tabindex="0"
  195. >
  196. <svg class="h-4 w-3 mr-2">
  197. <use xlink:href="${assets.get('dl.svg')}#icon" />
  198. </svg>
  199. ${state.translate('downloadButtonLabel')}
  200. </a>
  201. `
  202. : html`
  203. <div></div>
  204. `;
  205. return html`
  206. <send-archive
  207. id="archive-${archive.id}"
  208. class="flex flex-col items-start rounded shadow-light bg-white p-4 w-full dark:bg-grey-90 dark:border dark:border-grey-70"
  209. >
  210. ${archiveInfo(
  211. archive,
  212. html`
  213. <input
  214. type="image"
  215. class="self-start flex-shrink-0 text-white hover:opacity-75 focus:outline"
  216. alt="${state.translate('deleteButtonHover')}"
  217. title="${state.translate('deleteButtonHover')}"
  218. src="${assets.get('close-16.svg')}"
  219. onclick=${del}
  220. />
  221. `
  222. )}
  223. <div class="text-sm opacity-75 w-full mt-2 mb-2">
  224. ${expiryInfo(state.translate, archive)}
  225. </div>
  226. ${archiveDetails(state.translate, archive)}
  227. <hr class="w-full border-t my-4 dark:border-grey-70" />
  228. <div class="flex justify-between w-full">
  229. ${dl} ${copyOrShare}
  230. </div>
  231. </send-archive>
  232. `;
  233. function copy(event) {
  234. event.stopPropagation();
  235. copyToClipboard(archive.url);
  236. const text = event.target.lastChild;
  237. text.textContent = state.translate('copiedUrl');
  238. setTimeout(
  239. () => (text.textContent = state.translate('copyLinkButton')),
  240. 1000
  241. );
  242. }
  243. function del(event) {
  244. event.stopPropagation();
  245. emit('delete', archive);
  246. }
  247. async function share(event) {
  248. event.stopPropagation();
  249. if (platform() === 'android') {
  250. Android.shareUrl(archive.url);
  251. } else {
  252. try {
  253. await navigator.share({
  254. title: state.translate('-send-brand'),
  255. text: `Download "${archive.name}" with Firefox Send: simple, safe file sharing`,
  256. //state.translate('shareMessage', { name }),
  257. url: archive.url
  258. });
  259. } catch (e) {
  260. // ignore
  261. }
  262. }
  263. }
  264. };
  265. module.exports.wip = function(state, emit) {
  266. return html`
  267. <send-upload-area
  268. class="flex flex-col bg-white h-full w-full dark:bg-grey-90"
  269. id="wip"
  270. >
  271. ${list(
  272. Array.from(state.archive.files)
  273. .reverse()
  274. .map(f =>
  275. fileInfo(f, remove(f, state.translate('deleteButtonHover')))
  276. ),
  277. 'flex-shrink bg-grey-10 rounded-t overflow-y-auto px-6 py-4 md:h-full md:max-h-half-screen dark:bg-black',
  278. 'bg-white px-2 my-2 shadow-light rounded dark:bg-grey-90 dark:border dark:border-grey-80'
  279. )}
  280. <div
  281. class="flex-shrink-0 flex-grow flex items-end p-4 bg-grey-10 rounded-b mb-1 font-medium dark:bg-grey-90"
  282. >
  283. <input
  284. id="file-upload"
  285. class="opacity-0 w-0 h-0 appearance-none absolute overflow-hidden"
  286. type="file"
  287. multiple
  288. onfocus="${focus}"
  289. onblur="${blur}"
  290. onchange="${add}"
  291. />
  292. <div
  293. for="file-upload"
  294. class="flex flex-row items-center justify-between w-full p-2"
  295. >
  296. <label
  297. for="file-upload"
  298. class="flex items-center cursor-pointer"
  299. title="${state.translate('addFilesButton')}"
  300. >
  301. <svg class="w-6 h-6 mr-2 link-blue">
  302. <use xlink:href="${assets.get('addfiles.svg')}#plus" />
  303. </svg>
  304. ${state.translate('addFilesButton')}
  305. </label>
  306. <div class="font-normal text-sm text-grey-70 dark:text-grey-40">
  307. ${state.translate('totalSize', {
  308. size: bytes(state.archive.size)
  309. })}
  310. </div>
  311. </div>
  312. </div>
  313. ${expiryOptions(state, emit)} ${password(state, emit)}
  314. <button
  315. id="upload-btn"
  316. class="btn rounded-lg flex-shrink-0 focus:outline"
  317. title="${state.translate('uploadButton')}"
  318. onclick="${upload}"
  319. >
  320. ${state.translate('uploadButton')}
  321. </button>
  322. </send-upload-area>
  323. `;
  324. function focus(event) {
  325. event.target.nextElementSibling.firstElementChild.classList.add('outline');
  326. }
  327. function blur(event) {
  328. event.target.nextElementSibling.firstElementChild.classList.remove(
  329. 'outline'
  330. );
  331. }
  332. function upload(event) {
  333. window.scrollTo(0, 0);
  334. event.preventDefault();
  335. event.target.disabled = true;
  336. if (!state.uploading) {
  337. emit('upload');
  338. }
  339. }
  340. function add(event) {
  341. event.preventDefault();
  342. const newFiles = Array.from(event.target.files);
  343. emit('addFiles', { files: newFiles });
  344. setTimeout(() => {
  345. document
  346. .querySelector('#wip > ul > li:first-child')
  347. .scrollIntoView({ block: 'center' });
  348. });
  349. }
  350. function remove(file, desc) {
  351. return html`
  352. <input
  353. type="image"
  354. class="self-center text-white ml-4 h-4 hover:opacity-75 focus:outline"
  355. alt="${desc}"
  356. title="${desc}"
  357. src="${assets.get('close-16.svg')}"
  358. onclick="${del}"
  359. />
  360. `;
  361. function del(event) {
  362. event.stopPropagation();
  363. emit('removeUpload', file);
  364. }
  365. }
  366. };
  367. module.exports.uploading = function(state, emit) {
  368. const progress = state.transfer.progressRatio;
  369. const progressPercent = percent(progress);
  370. const archive = state.archive;
  371. return html`
  372. <send-upload-area
  373. id="${archive.id}"
  374. class="flex flex-col items-start rounded shadow-light bg-white p-4 w-full dark:bg-grey-90"
  375. >
  376. ${archiveInfo(archive)}
  377. <div class="text-xs opacity-75 w-full mt-2 mb-2">
  378. ${expiryInfo(state.translate, {
  379. dlimit: state.archive.dlimit,
  380. dtotal: 0,
  381. expiresAt: Date.now() + 500 + state.archive.timeLimit * 1000
  382. })}
  383. </div>
  384. <div class="link-blue text-sm font-medium mt-2">
  385. ${progressPercent}
  386. </div>
  387. <progress class="my-3" value="${progress}">${progressPercent}</progress>
  388. <button
  389. class="link-blue self-end font-medium"
  390. onclick=${cancel}
  391. title="${state.translate('deletePopupCancel')}"
  392. >
  393. ${state.translate('deletePopupCancel')}
  394. </button>
  395. </send-upload-area>
  396. `;
  397. function cancel(event) {
  398. event.stopPropagation();
  399. event.target.disabled = true;
  400. emit('cancel');
  401. }
  402. };
  403. module.exports.empty = function(state, emit) {
  404. const upsell =
  405. state.user.loggedIn || !state.capabilities.account
  406. ? ''
  407. : html`
  408. <button
  409. class="center font-medium text-sm link-blue mt-4 mb-2"
  410. onclick="${event => {
  411. event.stopPropagation();
  412. emit('signup-cta', 'drop');
  413. }}"
  414. >
  415. ${state.translate('signInSizeBump', {
  416. size: bytes(state.LIMITS.MAX_FILE_SIZE)
  417. })}
  418. </button>
  419. `;
  420. return html`
  421. <send-upload-area
  422. class="flex flex-col items-center justify-center border-2 border-dashed border-grey-transparent rounded px-6 py-16 h-full w-full dark:border-grey-60"
  423. onclick="${e => {
  424. if (e.target.tagName !== 'LABEL') {
  425. document.getElementById('file-upload').click();
  426. }
  427. }}"
  428. >
  429. <svg class="w-10 h-10 link-blue">
  430. <use xlink:href="/${assets.get('addfiles.svg')}#plus" />
  431. </svg>
  432. <div class="pt-6 pb-2 text-center text-lg font-bold tracking-wide">
  433. ${state.translate('dragAndDropFiles')}
  434. </div>
  435. <div class="pb-6 text-center text-base">
  436. ${state.translate('orClickWithSize', {
  437. size: bytes(state.user.maxSize)
  438. })}
  439. </div>
  440. <input
  441. id="file-upload"
  442. class="opacity-0 w-0 h-0 appearance-none absolute overflow-hidden"
  443. type="file"
  444. multiple
  445. onfocus="${focus}"
  446. onblur="${blur}"
  447. onchange="${add}"
  448. onclick="${e => e.stopPropagation()}"
  449. />
  450. <label
  451. for="file-upload"
  452. role="button"
  453. class="btn rounded-lg flex items-center mt-4"
  454. title="${state.translate('addFilesButton', {
  455. size: bytes(state.user.maxSize)
  456. })}"
  457. >
  458. ${state.translate('addFilesButton')}
  459. </label>
  460. ${upsell}
  461. </send-upload-area>
  462. `;
  463. function focus(event) {
  464. event.target.nextElementSibling.classList.add('bg-blue-70', 'outline');
  465. }
  466. function blur(event) {
  467. event.target.nextElementSibling.classList.remove('bg-blue-70', 'outline');
  468. }
  469. function add(event) {
  470. event.preventDefault();
  471. const newFiles = Array.from(event.target.files);
  472. emit('addFiles', { files: newFiles });
  473. }
  474. };
  475. module.exports.preview = function(state, emit) {
  476. const archive = state.fileInfo;
  477. if (archive.open === undefined) {
  478. archive.open = true;
  479. }
  480. const single = archive.manifest.files.length === 1;
  481. const details = single
  482. ? ''
  483. : html`
  484. <div class="mt-4 h-full md:h-48 overflow-y-auto">
  485. ${archiveDetails(state.translate, archive)}
  486. </div>
  487. `;
  488. return html`
  489. <send-archive
  490. class="flex flex-col max-h-full bg-white p-4 w-full md:w-128 dark:bg-grey-90"
  491. >
  492. <div class="border rounded py-3 px-6 dark:border-grey-70">
  493. ${archiveInfo(archive)} ${details}
  494. </div>
  495. <button
  496. id="download-btn"
  497. class="btn rounded-lg mt-4 w-full flex-shrink-0 focus:outline"
  498. title="${state.translate('downloadButtonLabel')}"
  499. onclick=${download}
  500. >
  501. ${state.translate('downloadButtonLabel')}
  502. </button>
  503. </send-archive>
  504. `;
  505. function download(event) {
  506. event.preventDefault();
  507. event.target.disabled = true;
  508. emit('download', archive);
  509. }
  510. };
  511. module.exports.downloading = function(state) {
  512. const archive = state.fileInfo;
  513. const progress = state.transfer.progressRatio;
  514. const progressPercent = percent(progress);
  515. return html`
  516. <send-archive
  517. class="flex flex-col bg-white rounded shadow-light p-4 w-full max-w-sm md:w-128 dark:bg-grey-90"
  518. >
  519. ${archiveInfo(archive)}
  520. <div class="link-blue text-sm font-medium mt-2">
  521. ${progressPercent}
  522. </div>
  523. <progress class="my-3" value="${progress}">${progressPercent}</progress>
  524. </send-archive>
  525. `;
  526. };