mirror of
https://github.com/leporello-js/leporello-js
synced 2026-01-13 13:04:30 -08:00
implement code snippet sharing
This commit is contained in:
48
index.html
48
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;
|
||||
|
||||
@@ -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]}
|
||||
}
|
||||
|
||||
|
||||
92
src/editor/share_dialog.js
Normal file
92
src/editor/share_dialog.js
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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
28
src/effects.js
vendored
@@ -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)
|
||||
|
||||
39
src/index.js
39
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)
|
||||
|
||||
74
src/share.js
Normal file
74
src/share.js
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user