diff --git a/index.html b/index.html index 749b4c0..bb774e2 100644 --- a/index.html +++ b/index.html @@ -56,7 +56,7 @@ .editor_container:focus-within, .bottom:focus-within, .files_container:focus-within, - .help_dialog { + dialog { outline: none; box-shadow: 1px 1px 6px 3px var(--shadow_color); } @@ -314,13 +314,8 @@ /* status */ - /* - .request_fullscreen { - margin-left: auto; - } - */ - .statusbar { + margin-bottom: 0px; grid-area: statusbar; display: flex; flex-direction: row; @@ -384,8 +379,41 @@ margin: 0em 0.5em; } - .help_dialog[open] { + .share_button, .upload_button { border: none; + color: white; + background: rgb(23 166 236); + } + + .share_button { + font-size: 1.2em; + margin-left: 1em; + margin-right: 0.5em; + } + + .share_button[disabled] { + background: grey; + } + + + .share_dialog input, .share_dialog button { + font-size: 1.2em; + } + + .share_dialog button { + padding: 5px; + height: 2em; + } + + dialog { + border: none; + } + + dialog::backdrop { + background-color: rgb(225 244 253 / 80%); + } + + .help_dialog[open] { display: flex; flex-direction: column; justify-content: center; @@ -396,10 +424,6 @@ background-color: white; } - .help_dialog::backdrop { - background-color: rgb(225 244 253 / 80%); - } - .help { padding: 2em; border-spacing: 5px; diff --git a/src/cmd.js b/src/cmd.js index 0eb304a..e7ade81 100644 --- a/src/cmd.js +++ b/src/cmd.js @@ -248,12 +248,13 @@ const input = (state, code, index) => { set_cursor_position({...state, files}, index), [state.current_module] ) - const effect_save = next.current_module == '' - ? {type: 'save_to_localstorage', args: ['code', code]} - : {type: 'write', args: [ + const effect_save = { + type: 'write', + args: [ next.current_module, next.files[next.current_module], - ]} + ] + } return {state: next, effects: [effect_save]} } diff --git a/src/editor/share_dialog.js b/src/editor/share_dialog.js new file mode 100644 index 0000000..12cc6b4 --- /dev/null +++ b/src/editor/share_dialog.js @@ -0,0 +1,92 @@ +import {el} from './domutils.js' +import {save_share} from '../share.js' +import {get_state} from '../index.js' + +export class ShareDialog { + constructor() { + this.el = el('dialog', 'share_dialog', + this.upload_begin = el('p', '', + el('p', '', + 'This button will upload your scratch file to the cloud for sharing with others.'), + el('ul', '', + el('li', '', + 'Please ensure that no personal data or confidential information is included.' + ), + el('li', '', + 'Avoid including copyrighted materials.' + ), + ), + el('span', {style: 'color: red'}, + 'Caution: Once shared, files cannot be deleted.' + ), + this.upload_buttons = el('p', {style: 'text-align: center'}, + el('button', { + 'class': 'upload_button', + click: () => this.upload() + }, + "Upload" + ), + this.cancel_button = el('button', { + style: 'margin-left: 1em', + click: () => this.cancel() + }, + "Cancel" + ) + ), + ), + this.uploading = el('span', {style: 'display: none'}, + "Uploading..." + ), + this.upload_finish = el('p', {style: 'display: none'}, + el('p', '', + el('p', {style: ` + text-align: center; + margin-bottom: 1em; + font-size: 1.2em + `}, 'Upload successful'), + this.url_share = el('input', { + type: 'text', + readonly: true, + style: 'min-width: 30em', + }), + this.copy_button = el('button', { + click: () => this.copy(), + style: 'margin-left: 1em', + }, 'Copy URL') + ), + this.close_button = el('button', { + style: 'display: block; margin: auto', + click: () => this.cancel(), + }, 'Close'), + ) + ) + } + + async upload() { + this.uploading.style.display = '' + this.upload_begin.style.display = 'none' + try { + const id = await save_share(get_state().files['']) + this.url = new URL(window.location) + this.url.searchParams.append('share_id', id) + this.url_share.value = this.url + this.upload_finish.style.display = '' + } catch(e) { + alert(e.message) + this.upload_begin.style.display = '' + } finally { + this.uploading.style.display = 'none' + } + } + + copy() { + this.url_share.select() + document.execCommand('copy') + } + + cancel() { + this.upload_finish.style.display = 'none' + this.upload_begin.style.display = '' + this.el.close() + } +} diff --git a/src/editor/ui.js b/src/editor/ui.js index 692b071..ec2687a 100644 --- a/src/editor/ui.js +++ b/src/editor/ui.js @@ -4,6 +4,7 @@ import {Files} from './files.js' import {CallTree} from './calltree.js' import {Logs} from './logs.js' import {IO_Trace} from './io_trace.js' +import {ShareDialog} from './share_dialog.js' import {el} from './domutils.js' export class UI { @@ -129,7 +130,12 @@ export class UI { href: 'https://github.com/leporello-js/leporello-js', target: '__blank', }, 'Github'), + el('button', { + 'class': 'share_button', + 'click': () => this.share_dialog.showModal(), + }, 'Share'), this.help_dialog = this.render_help(), + this.share_dialog = new ShareDialog().el, ) )) ) diff --git a/src/effects.js b/src/effects.js index 48eb077..623d3c0 100644 --- a/src/effects.js +++ b/src/effects.js @@ -299,8 +299,32 @@ export const EFFECTS = { localStorage[key] = value }, - write: (state, [name, contents], ui) => { - if(state.has_file_system_access) { + write: (state, [name, contents], ui, prev_state) => { + if(name == '') { + const share_id = new URL(window.location).searchParams.get('share_id') + if(share_id == null) { + localStorage['code'] = contents + } else { + const key = 'share_' + share_id + if(localStorage['code'] == prev_state.files['']) { + /* + If scratch code is the same with share code, then update both + + Imagine the following scenario: + + - User shares code. URL is replaced with ?share_id=XXX + - He keeps working on code + - He closes browser tab and on the next day he opens app.leporello.tech + - His work is lost (actually, he can still access it with + ?share_id=XXX, but that not obvious + + To prevent that, we keep updating scratch code after sharing + */ + localStorage['code'] = contents + } + localStorage[key] = contents + } + } else if(state.has_file_system_access) { write_file(name, contents) } else { write_example(name, contents) diff --git a/src/index.js b/src/index.js index a227209..39fcec2 100644 --- a/src/index.js +++ b/src/index.js @@ -6,6 +6,7 @@ import { init_window_service_worker } from './filesystem.js' import {examples, examples_dir_promise} from './examples.js' +import {get_share} from './share.js' const EXAMPLE = `function fib(n) { if(n == 0 || n == 1) { @@ -178,41 +179,51 @@ export const init = async (container, _COMMANDS) => { set_error_handler(window) - const default_module = {'': localStorage.code || EXAMPLE} + let files = {'': localStorage.code || EXAMPLE} let initial_state, entrypoint_settings const project_dir = await open_dir(false) let example if(project_dir == null) { /* - extract example from URL params and delete it + extract example_id from URL params and delete it (because we dont want to + persist in on refresh) */ const params = new URLSearchParams(window.location.search) const example_path = params.get('example') - params.delete('example') - globalThis.history.replaceState( - null, - null, - '/' + params.toString() + window.location.hash - ) + const nextURL = new URL(window.location) + nextURL.searchParams.delete('example') + history.replaceState(null, null, nextURL.href) example = examples.find(e => e.path == example_path) - entrypoint_settings = example == null - ? get_entrypoint_settings() - : { + + if(example == null) { + const shared_code = await get_share() + if(shared_code == null) { + entrypoint_settings = get_entrypoint_settings() + } else { + files = {'': shared_code} + entrypoint_settings = { + current_module: '', + entrypoint: '', + } + } + } else { + entrypoint_settings = { current_module: example.entrypoint, entrypoint: example.entrypoint, } + } initial_state = { project_dir: await examples_dir_promise, - files: default_module, + files, has_file_system_access: false, } } else { entrypoint_settings = get_entrypoint_settings() initial_state = { project_dir, - files: default_module, + files, has_file_system_access: true, } } @@ -306,7 +317,7 @@ export const exec = (cmd, ...args) => { } else { console.log('apply effect', e.type, ...(e.args ?? [])) } - EFFECTS[e.type](nextstate, e.args, ui) + EFFECTS[e.type](nextstate, e.args, ui, state) }) } }, nextstate) diff --git a/src/share.js b/src/share.js new file mode 100644 index 0000000..4078d65 --- /dev/null +++ b/src/share.js @@ -0,0 +1,74 @@ +const PROJECT_ID = 'leporello-js' +const URL_BASE = `https://firebasestorage.googleapis.com/v0/b/${PROJECT_ID}.appspot.com/o/` + +// see https://stackoverflow.com/a/48161723/795038 +async function sha256(message) { + // encode as UTF-8 + const msgBuffer = new TextEncoder().encode(message); + + // hash the message + const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer); + + // convert ArrayBuffer to Array + const hashArray = Array.from(new Uint8Array(hashBuffer)); + + // convert bytes to hex string + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + return hashHex; +} + +async function upload_share(text) { + const id = (await sha256(text)) + // Truncate to 20 bytes, like in git + .slice(0, 40) + const blob = new Blob([text], { type: 'text/plain' }) + const formData = new FormData() + formData.append('file', blob) + const response = await fetch(URL_BASE + id, { + method: 'POST', + body: formData + }) + if(!response.ok) { + const json = await response.json() + const message = json?.error?.message + throw new Error('Failed to upload: ' + message) + } + return id +} + +async function download_share(id) { + const response = await fetch(URL_BASE + id + '?alt=media') + if(!response.ok) { + const json = await response.json() + const message = json?.error?.message + throw new Error('Failed to fetch: ' + message) + } + return response.text() +} + +export async function get_share() { + const params = new URLSearchParams(window.location.search) + const share_id = params.get('share_id') + if(share_id == null) { + return null + } + + const shared_code = localStorage['share_' + share_id] + if(shared_code != null) { + return shared_code + } + + try { + return await download_share(share_id) + } catch(e) { + alert(e.message) + return null + } +} + +export async function save_share(text) { + const share_id = await upload_share(text) + const nextURL = new URL(window.location) + nextURL.searchParams.set('share_id', share_id) + history.replaceState(null, null, nextURL.href) +}