import { h, Component, createRef } from 'preact' import htm from 'htm' import { signal } from 'preact/signals' const html = htm.bind(h) export class NodeUI extends Component { constructor() {//{{{ super() this.menu = signal(false) this.node = signal(null) this.nodeContent = createRef() this.upload = signal(false) window.addEventListener('popstate', evt=>{ if(evt.state && evt.state.hasOwnProperty('nodeID')) this.goToNode(evt.state.nodeID, true) else this.goToNode(0, true) }) window.addEventListener('keydown', evt=>this.keyHandler(evt)) }//}}} render() {//{{{ console.log('render', 'nodeUI') if(this.node.value === null) return let node = this.node.value let crumbs = [ html`
this.goToNode(0)}>Start
` ] crumbs = crumbs.concat(node.Crumbs.slice(0).map(node=> html`
this.goToNode(node.ID)}>${node.Name}
` ).reverse()) let children = node.Children.sort((a,b)=>{ if(a.Name.toLowerCase() > b.Name.toLowerCase()) return 1; if(a.Name.toLowerCase() < b.Name.toLowerCase()) return -1; return 0 }).map(child=>html`
this.goToNode(child.ID)}>${child.Name}
`) let modified = '' if(this.props.app.nodeModified.value) modified = 'modified'; let upload = ''; if(this.upload.value) upload = html`<${UploadUI} nodeui=${this} />` let menu = ''; if(this.menu.value) upload = html`<${Menu} nodeui=${this} />` return html` ${menu} ${upload}
this.saveNode()}>
Notes
this.createNode(evt)}>+
${crumbs}
${children.length > 0 ? html`
${children}
` : html``} ${node.ID > 0 ? html`
${node.Name}
<${NodeContent} key=${node.ID} content=${node.Content} ref=${this.nodeContent} /> ` : html``} <${NodeFiles} node=${this.node.value} /> ` }//}}} componentDidMount() {//{{{ this.props.app.startNode.retrieve(node=>{ this.node.value = node // The tree isn't guaranteed to have loaded yet. This is also run from // the tree code, in case the node hasn't loaded. this.props.app.tree.crumbsUpdateNodes(node) }) }//}}} keyHandler(evt) {//{{{ let handled = true switch(evt.key.toUpperCase()) { case 'S': if(evt.ctrlKey || (evt.shiftKey && evt.altKey)) this.saveNode() else handled = false break case 'N': if((evt.ctrlKey && evt.AltKey) || (evt.shiftKey && evt.altKey)) this.createNode() else handled = false break case 'U': if((evt.ctrlKey && evt.altKey) || (evt.shiftKey && evt.altKey)) this.upload.value = true else handled = false default: handled = false } if(handled) { evt.preventDefault() evt.stopPropagation() } }//}}} showMenu(evt) {//{{{ evt.stopPropagation() this.menu.value = true }//}}} logout() {//{{{ window.localStorage.removeItem('session.UUID') location.href = '/' }//}}} goToNode(nodeID, dontPush) {//{{{ if(this.props.app.nodeModified.value) { if(!confirm("Changes not saved. Do you want to discard changes?")) return } if(!dontPush) history.pushState({ nodeID }, '', `/?node=${nodeID}`) // New node is fetched in order to retrieve content and files. // Such data is unnecessary to transfer for tree/navigational purposes. let node = new Node(this.props.app, nodeID) node.retrieve(node=>{ this.props.app.nodeModified.value = false this.node.value = node // Tree needs to know another node is selected, in order to render any // previously selected node not selected. this.props.app.tree.setSelected(node) }) }//}}} createNode(evt) {//{{{ if(evt) evt.stopPropagation() let name = prompt("Name") if(!name) return this.node.value.create(name, nodeID=>this.goToNode(nodeID)) }//}}} saveNode() {//{{{ let content = this.nodeContent.current.contentDiv.current.value this.node.value.save(content, ()=>this.props.app.nodeModified.value = false) }//}}} renameNode() {//{{{ let name = prompt("New name") if(!name) return this.node.value.rename(name, ()=>{ this.goToNode(this.node.value.ID) this.menu.value = false }) }//}}} deleteNode() {//{{{ if(!confirm("Do you want to delete this note and all sub-notes?")) return this.node.value.delete(()=>{ this.goToNode(this.node.value.ParentID) this.menu.value = false }) }//}}} } class NodeContent extends Component { constructor(props) {//{{{ super(props) this.contentDiv = createRef() this.state = { modified: false, //content: props.content, } }//}}} render({ content }) {//{{{ return html`
` }//}}} componentDidMount() {//{{{ this.resize() window.addEventListener('resize', ()=>this.resize()) }//}}} componentDidUpdate() {//{{{ this.resize() }//}}} contentChanged() {//{{{ window._app.current.nodeModified.value = true this.resize() }//}}} resize() {//{{{ let textarea = document.getElementById('node-content') if(textarea) textarea.parentNode.dataset.replicatedValue = textarea.value }//}}} } class NodeFiles extends Component { render({ node }) {//{{{ if(node.Files === null || node.Files.length == 0) return let files = node.Files .sort((a, b)=>{ if(a.Filename.toUpperCase() < b.Filename.toUpperCase()) return -1 if(a.Filename.toUpperCase() > b.Filename.toUpperCase()) return 1 return 0 }) .map(file=> html`
node.download(file.ID)}>${file.Filename}
${this.formatSize(file.Size)}
` ) return html`
Files
${files}
` }//}}} formatSize(size) {//{{{ if(size < 1048576) { return `${Math.round(size / 1024)} KiB` } else { return `${Math.round(size / 1048576)} MiB` } }//}}} } export class Node { constructor(app, nodeID) {//{{{ this.app = app this.ID = nodeID this.ParentID = 0 this.UserID = 0 this.Name = '' this.Content = '' this.Children = [] this.Crumbs = [] this.Files = [] this._expanded = false // start value for the TreeNode component, // it doesn't control it afterwards. // Used to expand the crumbs upon site loading. }//}}} retrieve(callback) {//{{{ this.app.request('/node/retrieve', { ID: this.ID }) .then(res=>{ this.ParentID = res.Node.ParentID this.UserID = res.Node.UserID this.Name = res.Node.Name this.Content = res.Node.Content this.Children = res.Node.Children this.Crumbs = res.Node.Crumbs this.Files = res.Node.Files callback(this) }) .catch(this.app.responseError) }//}}} delete(callback) {//{{{ this.app.request('/node/delete', { NodeID: this.ID, }) .then(callback) .catch(this.app.responseError) }//}}} create(name, callback) {//{{{ this.app.request('/node/create', { Name: name.trim(), ParentID: this.ID, }) .then(res=>{ callback(res.Node.ID) }) .catch(this.app.responseError) }//}}} save(content, callback) {//{{{ this.app.request('/node/update', { NodeID: this.ID, Content: content, }) .then(callback) .catch(this.app.responseError) }//}}} rename(name, callback) {//{{{ this.app.request('/node/rename', { Name: name.trim(), NodeID: this.ID, }) .then(callback) .catch(this.app.responseError) }//}}} download(fileID) {//{{{ let headers = { 'Content-Type': 'application/json', } if(this.app.session.UUID !== '') headers['X-Session-Id'] = this.app.session.UUID let fname = "" fetch("/node/download", { method: 'POST', headers, body: JSON.stringify({ NodeID: this.ID, FileID: fileID, }), }) .then(response=>{ let match = response.headers.get('content-disposition').match(/filename="([^"]*)"/) fname = match[1] return response.blob() }) .then(blob=>{ let url = window.URL.createObjectURL(blob) let a = document.createElement('a'); a.href = url; a.download = fname; document.body.appendChild(a); // we need to append the element to the dom -> otherwise it will not work in firefox a.click(); a.remove(); //afterwards we remove the element again }) }//}}} } class Menu extends Component { render({ nodeui }) {//{{{ return html`
nodeui.menu.value = false}>
` }//}}} } class UploadUI extends Component { constructor(props) {//{{{ super(props) this.file = createRef() this.filelist = signal([]) this.fileRefs = [] this.progressRefs = [] }//}}} render({ nodeui }) {//{{{ let filelist = this.filelist.value let files = [] for(let i = 0; i < filelist.length; i++) { files.push(html`
${filelist.item(i).name}
`) } return html`
nodeui.upload.value = false}>
this.upload()} multiple />
${files}
` }//}}} componentDidMount() {//{{{ this.file.current.focus() }//}}} upload() {//{{{ let nodeID = this.props.nodeui.node.value.ID this.fileRefs = [] this.progressRefs = [] let input = this.file.current this.filelist.value = input.files for(let i = 0; i < input.files.length; i++) { this.fileRefs.push(createRef()) this.progressRefs.push(createRef()) this.postFile( input.files[i], nodeID, progress=>{ this.progressRefs[i].current.innerHTML = `${progress}%` }, res=>{ this.props.nodeui.node.value.Files.push(res.File) this.props.nodeui.forceUpdate() this.fileRefs[i].current.classList.add("done") this.progressRefs[i].current.classList.add("done") this.props.nodeui.upload.value = false }) } }//}}} postFile(file, nodeID, progressCallback, doneCallback) {//{{{ var formdata = new FormData() formdata.append('file', file) formdata.append('NodeID', nodeID) var request = new XMLHttpRequest() request.addEventListener("error", ()=>{ window._app.current.responseError({ upload: "An unknown error occured" }) }) request.addEventListener("loadend", ()=>{ if(request.status != 200) { window._app.current.responseError({ upload: request.statusText }) return } let response = JSON.parse(request.response) if(!response.OK) { window._app.current.responseError({ upload: response.Error }) return } doneCallback(response) }) request.upload.addEventListener('progress', evt=>{ var fileSize = file.size if(evt.loaded <= fileSize) progressCallback(Math.round(evt.loaded / fileSize * 100)) if(evt.loaded == evt.total) progressCallback(100) }) request.open('post', '/node/upload') request.setRequestHeader("X-Session-Id", window._app.current.session.UUID) //request.timeout = 45000 request.send(formdata) }//}}} } // vim: foldmethod=marker