implement code snippet sharing

This commit is contained in:
Dmitry Vasilev
2023-10-02 03:27:32 +03:00
parent a255ba6067
commit 7606419bb9
7 changed files with 264 additions and 32 deletions

View File

@@ -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;

View File

@@ -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]}
}

View File

@@ -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()
}
}

View File

@@ -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,
)
))
)

28
src/effects.js vendored
View File

@@ -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)

View File

@@ -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)

74
src/share.js Normal file
View File

@@ -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)
}