diff --git a/authentication/pkg.go b/authentication/pkg.go index 9eb6245..c0b9a2e 100644 --- a/authentication/pkg.go +++ b/authentication/pkg.go @@ -8,6 +8,9 @@ import ( "github.com/jmoiron/sqlx" "github.com/lib/pq" + // Internal + appUser "notes2/user" + // Standard "database/sql" "encoding/hex" @@ -27,12 +30,6 @@ type Manager struct { ExpireDays int } -type User struct { - ID int - Username string - Name string -} - func httpError(w http.ResponseWriter, err error) { // {{{ j, _ := json.Marshal(struct { OK bool @@ -165,16 +162,16 @@ func (mngr *Manager) AuthenticationHandler(w http.ResponseWriter, r *http.Reques mngr.log.Info("authentication", "username", request.Username, "status", "accepted") j, _ := json.Marshal(struct { OK bool - User User + User appUser.User Token string }{true, user, token}) w.Write(j) } // }}} -func (mngr *Manager) Authenticate(username, password string) (authenticated bool, user User, err error) { // {{{ +func (mngr *Manager) Authenticate(username, password string) (authenticated bool, user appUser.User, err error) { // {{{ var row *sql.Row row = mngr.db.QueryRow(` - SELECT id, username, name + SELECT id, username, name, preferences FROM public.user WHERE LOWER(username) = LOWER($1) AND @@ -183,13 +180,21 @@ func (mngr *Manager) Authenticate(username, password string) (authenticated bool username, password, ) - err = row.Scan(&user.ID, &user.Username, &user.Name) + var data []byte + err = row.Scan(&user.ID, &user.Username, &user.Name, &data) if err != nil && err.Error() == "sql: no rows in result set" { err = nil authenticated = false return } if err != nil { + authenticated = false + return + } + + err = json.Unmarshal(data, &user.Preferences) + if err != nil { + authenticated = false return } @@ -278,7 +283,7 @@ func (mngr *Manager) ChangePassword(username, currentPassword, newPassword strin changed = (rowsAffected == 1) return } // }}} -func (mngr *Manager) NewClientUUID(user User) (clientUUID string, err error) { // {{{ +func (mngr *Manager) NewClientUUID(user appUser.User) (clientUUID string, err error) { // {{{ // Each client session has its own UUID. // Loop through until a unique one is established. var proposedClientUUID string diff --git a/main.go b/main.go index d7b71af..6e3cf94 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( // Internal "notes2/authentication" "notes2/html_template" + appUser "notes2/user" "os" // Standard @@ -23,7 +24,7 @@ import ( "text/template" ) -const VERSION = "v26" +const VERSION = "v29" const CONTEXT_USER = 1 const SYNC_PAGINATION = 200 @@ -134,6 +135,8 @@ func main() { // {{{ http.HandleFunc("/offline", pageOffline) http.HandleFunc("/user/authenticate", AuthManager.AuthenticationHandler) + http.HandleFunc("GET /user/preferences", authenticated(actionUserGetPreferences)) + http.HandleFunc("POST /user/preferences", authenticated(actionUserSetPreferences)) http.HandleFunc("/sync/from_server/count/{sequence}", authenticated(actionSyncFromServerCount)) http.HandleFunc("/sync/from_server/{sequence}/{offset}", authenticated(actionSyncFromServer)) @@ -178,7 +181,7 @@ func authenticated(fn func(http.ResponseWriter, *http.Request)) func(http.Respon } // User object is added to the context for the next handler. - user := NewUser(claims) + user := appUser.NewUser(claims) r = r.WithContext(context.WithValue(r.Context(), CONTEXT_USER, user)) Log.Debug("webserver", "op", "request", "method", r.Method, "url", r.URL.String(), "username", user.Username, "client", user.ClientUUID) @@ -266,7 +269,7 @@ func actionSyncFromServer(w http.ResponseWriter, r *http.Request) { // {{{ // The purpose of the Client UUID is to avoid // sending nodes back once again to a client that // just created or modified it. - user := getUser(r) + user := getUserSession(r) changedFrom, _ := strconv.Atoi(r.PathValue("sequence")) offset, _ := strconv.Atoi(r.PathValue("offset")) @@ -289,7 +292,7 @@ func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{ // The purpose of the Client UUID is to avoid // sending nodes back once again to a client that // just created or modified it. - user := getUser(r) + user := getUserSession(r) changedFrom, _ := strconv.Atoi(r.PathValue("sequence")) count, err := NodesCount(user.UserID, uint64(changedFrom), user.ClientUUID) @@ -309,7 +312,7 @@ func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{ w.Write(j) } // }}} func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{ - user := getUser(r) + user := getUserSession(r) var err error uuid := r.PathValue("uuid") @@ -325,7 +328,7 @@ func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{ }) } // }}} func actionNodeHistoryRetrieve(w http.ResponseWriter, r *http.Request) { // {{{ - user := getUser(r) + user := getUserSession(r) var err error uuid := r.PathValue("uuid") @@ -348,7 +351,7 @@ func actionNodeHistoryRetrieve(w http.ResponseWriter, r *http.Request) { // {{{ }) } // }}} func actionNodeHistoryCount(w http.ResponseWriter, r *http.Request) { // {{{ - user := getUser(r) + user := getUserSession(r) var err error uuid := r.PathValue("uuid") @@ -360,12 +363,12 @@ func actionNodeHistoryCount(w http.ResponseWriter, r *http.Request) { // {{{ } responseData(w, map[string]any{ - "OK": true, - "Count": count, + "OK": true, + "Count": count, }) } // }}} func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{ - user := getUser(r) + user := getUserSession(r) body, _ := io.ReadAll(r.Body) var request struct { @@ -389,6 +392,47 @@ func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{ }) } // }}} +func actionUserGetPreferences(w http.ResponseWriter, r *http.Request) { // {{{ + user := getUserSession(r) + prefs, err := user.Preferences() + if err != nil { + httpError(w, err) + return + } + + responseData(w, map[string]any{ + "OK": true, + "Preferences": prefs, + }) +} // }}} +func actionUserSetPreferences(w http.ResponseWriter, r *http.Request) { // {{{ + session := getUserSession(r) + + // Verify the "default" profile is still there. + var newPrefs map[string]appUser.UserPreferences + body, _ := io.ReadAll(r.Body) + err := json.Unmarshal(body, &newPrefs) + if err != nil { + httpError(w, err) + return + } + + if _, found := newPrefs["default"]; !found { + httpError(w, fmt.Errorf("'default' profile missing.")) + return + } + + err = session.SetPreferences(newPrefs) + if err != nil { + httpError(w, err) + return + } + + responseData(w, map[string]any{ + "OK": true, + }) +} // }}} + func createNewUser(username string) { // {{{ reader := bufio.NewReader(os.Stdin) @@ -431,7 +475,8 @@ func changePassword(username string) { // {{{ fmt.Printf("\nPassword changed\n") } // }}} -func getUser(r *http.Request) UserSession { // {{{ - user, _ := r.Context().Value(CONTEXT_USER).(UserSession) +func getUserSession(r *http.Request) appUser.UserSession { // {{{ + user, _ := r.Context().Value(CONTEXT_USER).(appUser.UserSession) + user.Db = db return user } // }}} diff --git a/sql/00010.sql b/sql/00010.sql new file mode 100644 index 0000000..ecd8ab4 --- /dev/null +++ b/sql/00010.sql @@ -0,0 +1 @@ +ALTER TABLE public."user" ADD preferences jsonb DEFAULT '{}' NOT NULL; diff --git a/static/css/markdown.css b/static/css/markdown.css index 1ecbc94..832d4a2 100644 --- a/static/css/markdown.css +++ b/static/css/markdown.css @@ -102,6 +102,11 @@ border: 1px solid #ccc; padding: 2px 4px; border-radius: 4px; + + &.copy { + border: var(--markdown-copy-border); + background-color: var(--markdown-copy-background); + } } pre { @@ -111,6 +116,14 @@ border-radius: 4px; white-space: pre-wrap; + &.copy { + border: var(--markdown-copy-border); + background-color: var(--markdown-copy-background); + code { + background-color: inherit !important; + } + } + code { border: unset; padding: unset; diff --git a/static/css/notes2.css b/static/css/notes2.css index 2d9f074..7fdea0b 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -15,6 +15,9 @@ --menu-item-hover-color: #f4f4f4; --font-monospace: "Liberation Mono", monospace; + + --markdown-copy-border: 1px solid #0a0; + --markdown-copy-background: #e3f4d7; } html { @@ -70,9 +73,10 @@ button { 1fr; } - &.page-history { + /* The other pages just gets the whole page without dividing it up. */ + &:not(.page-node) { grid-template-areas: - "tree-expander tree pad1 n2-pagehistory pad2" + "tree-expander tree pad1 n2-page pad2" ; grid-template-columns: @@ -242,7 +246,6 @@ button { #notes2 { &.page-node { - #page-root { display: none; } @@ -257,7 +260,7 @@ button { display: contents; n2-pagestorage { - grid-area: content; + grid-area: n2-page; } } } @@ -265,9 +268,14 @@ button { &.page-history { #page-history { display: grid; - grid-area: n2-pagehistory; + grid-area: n2-page; + } + } - n2-pagehistory {} + &.page-preferences { + #page-preferences { + display: block; + grid-area: n2-page; } } @@ -279,7 +287,6 @@ button { #page-root { display: contents !important; } - } } diff --git a/static/images/icon_menu.svg b/static/images/icon_menu.svg index e60bdee..cfdd1e8 100644 --- a/static/images/icon_menu.svg +++ b/static/images/icon_menu.svg @@ -2,18 +2,18 @@ - - - - menu - hamburger - - - + transform="translate(-147.15925,-92.339586)">menuhamburger diff --git a/static/js/api.mjs b/static/js/api.mjs index 3fff10a..26a19de 100644 --- a/static/js/api.mjs +++ b/static/js/api.mjs @@ -1,7 +1,7 @@ export class API { // query resolves into the JSON data produced by the application, or an exception with 'type' and 'error' properties. static async query(method, path, request) { - return new Promise((resolve, reject) => { + try { const body = JSON.stringify(request) const headers = {} @@ -12,33 +12,22 @@ export class API { headers.Authorization = `Bearer ${token}` } - fetch(path, { method, headers, body }) - .then(response => { - // An HTTP communication level error occured. - if (!response.ok || response.status != 200) - return reject({ - type: 'http', - error: response, - }) - return response.json() - }) - .then(json => { - // Application level response are handled here. - if (!json.OK) - return reject({ - type: 'application', - error: json.Error, - application: json, - }) - resolve(json) - }) - .catch(err => - // Catch any other errors from fetch. - reject({ - type: 'http', - error: err, - })) - }) + const res = await fetch(path, { method, headers, body }) + // An HTTP communication level error occured. + if (!res.ok || res.status != 200) + throw new Error('HTTP error', { cause: { type: 'http', error: res, }}) + + // Application level response are handled here. + const json = await res.json() + if (!json.OK) + throw new Error(json.Error, { cause: { type: 'application', application: json, }}) + + return json + + } catch (err) { + // Catch any other errors from fetch. + throw new Error(err.message, { cause: { type: 'http', error: err, }}) + } } static hasAuthenticationToken() {//{{{ diff --git a/static/js/app.mjs b/static/js/app.mjs index c556341..90bad39 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -2,6 +2,7 @@ import { ROOT_NODE } from 'node_store' import { CustomHTMLElement } from './lib/custom_html_element.mjs' import { N2Sidebar } from 'sidebar' import { Node } from 'node' +import { N2PreferenceSet } from './page_preferences.mjs' export class App { static PAGES = ['node', 'history', 'storage'] @@ -14,6 +15,8 @@ export class App { this.nodeUI = document.getElementById('note') this.dragIcon = new N2DragIcon() + this.preferences = this.getPreferences() + this.sidebar.render().then(sidebar => { document.getElementById('tree').append(sidebar) document.getElementById('tree-nodes')?.focus() @@ -61,6 +64,11 @@ export class App { classList.add('page-' + page) }) + _mbus.subscribe('DEVICE_PREFERENCE_SET_UPDATED', ()=>{ + this.preferences = this.getPreferences() + console.log(this.preferences.data) + }) + window.addEventListener('keydown', event => this.keyHandler(event)) window.addEventListener('popstate', event => this.popState(event)) document.getElementById('notes2').addEventListener('click', event => { @@ -211,6 +219,12 @@ export class App { let classList = document.querySelector('#main-page').classList return classList.contains(page) }// }}} + getPreferences() {// {{{ + const devPrefSet = localStorage.getItem('device_preference_set') || 'default' + const userData = localStorage.getItem('user') || '{"default": {}}' + const user = JSON.parse(userData) + return new N2PreferenceSet(devPrefSet, user.Preferences[devPrefSet]) + }// }}} } class N2Crumbs extends CustomHTMLElement { diff --git a/static/js/lib/custom_html_element.mjs b/static/js/lib/custom_html_element.mjs index 2cec808..d1fb7ae 100644 --- a/static/js/lib/custom_html_element.mjs +++ b/static/js/lib/custom_html_element.mjs @@ -1,7 +1,17 @@ +/* Use data-el or data-field attribute. + * Element with data-el="hum-ding" is accessible as this.elHumDing and fields with + * data-field="long-dong" as this.fieldLongDong. + * + * All field values can be retrieved with fieldValues() and uses the data-field attribute + * as LongDong as key. + */ + export class CustomHTMLElement extends HTMLElement { constructor(useShadow) {// {{{ super() + this._fields = new Map() + const workOn = useShadow ? this.attachShadow({ mode: 'open' }) : this workOn.appendChild(this.constructor.tmpl.content.cloneNode(true)) workOn.querySelectorAll('*').forEach(el => { @@ -9,6 +19,7 @@ export class CustomHTMLElement extends HTMLElement { if (field !== undefined) { const fieldName = this.toElementName('field', field) this[fieldName] = el + this._fields.set(this.toElementName('', field), el) } const name = el.dataset.el @@ -19,39 +30,22 @@ export class CustomHTMLElement extends HTMLElement { } }) }// }}} + allFields() {// {{{ + return this._fields + }// }}} + fieldValues() {// {{{ + const state = {} + for (const [name, field] of this._fields) { + if (field.tagName.toLowerCase() == 'input' && field.getAttribute('type').toLowerCase() == 'checkbox') + state[name] = field.checked + else + state[name] = field.value + + } + return state + }// }}} toElementName(prefix, str) {// {{{ str = prefix + '-' + str return str.replace(/-(id|[a-z])/g, match => match.toUpperCase().replace('-', '')) }// }}} } - -export class StupidPreactCustomHTMLElement extends HTMLElement { - constructor() {// {{{ - super() - - // Stupid stuff because of Preact. - this.clonedNodes = this.constructor.tmpl.content.cloneNode(true) - this.clonedNodes.querySelectorAll('*').forEach(el => { - const field = el.dataset.field - if (field !== undefined) { - const fieldName = this.toElementName('field', field) - this[fieldName] = el - } - - const name = el.dataset.el - if (name !== undefined) { - const elName = this.toElementName('el', name) - this[elName] = el - el.classList.add('el-' + name) - } - }) - }// }}} - toElementName(prefix, str) {// {{{ - str = prefix + '-' + str - return str.replace(/-(id|[a-z])/g, match => match.toUpperCase().replace('-', '')) - }// }}} - connectedCallback() {// {{{ - // Stupid stuff because of Preact. - this.appendChild(this.clonedNodes) - }// }}} -} diff --git a/static/js/marked_position.mjs b/static/js/marked_position.mjs index 5f77251..8c81eb4 100644 --- a/static/js/marked_position.mjs +++ b/static/js/marked_position.mjs @@ -94,6 +94,7 @@ export class MarkedPosition { constructor() {// {{{ window.marked_setpos = (event) => this.setpos(event) window.marked_changecheckbox = (event) => this.changecheckbox(event) + window.marked_copy_to_clipboard = (event, tagname) => this.copy_to_clipboard(event, tagname) this.render() }// }}} setpos(event) {// {{{ @@ -119,8 +120,32 @@ export class MarkedPosition { } }) }// }}} + async copy_to_clipboard(event, tagname) {// {{{ + if (!event.shiftKey) + return + + try { + // Stop text selections on the page to the mouse pointer. + // Old selections are remove as well to give a cleaner view + // of the copied text/highlighting. + event.preventDefault() + event.stopPropagation() + window.getSelection().removeAllRanges() + + const text = event.target.innerText + await navigator.clipboard.writeText(text) + + const tagClasslist = event.target.closest(tagname).classList + tagClasslist.add('copy') + setTimeout(()=>tagClasslist.remove('copy'), 250) + + } catch (err) { + console.error('Failed to copy: ', err) + alert('Failed to copy: ', err) + } + }// }}} + render() {// {{{ - const markedObject = this this.marked = new Marked() this.marked.use(markedTokenPosition()) this.marked.use({ @@ -165,12 +190,12 @@ export class MarkedPosition { const code = token.text.replace(other.endingNewline, '') + '\n' if (!langString) { - return `
`
+						return `
`
 							+ (token.escaped ? code : escapeHtmlEntities(code, true))
 							+ '
\n' } - return `
'
 						+ (token.escaped ? code : escapeHtmlEntities(code, true))
@@ -260,7 +285,7 @@ export class MarkedPosition {
 				},
 
 				codespan(token) {
-					return `${escapeHtmlEntities(token.text, true)}`
+					return `${escapeHtmlEntities(token.text, true)}`
 				},
 
 				br(token) {
diff --git a/static/js/page_preferences.mjs b/static/js/page_preferences.mjs
new file mode 100644
index 0000000..9655278
--- /dev/null
+++ b/static/js/page_preferences.mjs
@@ -0,0 +1,283 @@
+import { CustomHTMLElement } from "./lib/custom_html_element.mjs"
+import { API } from './api.mjs'
+
+export class N2PagePreferences extends CustomHTMLElement {
+	static {// {{{
+		this.tmpl = document.createElement('template')
+		this.tmpl.innerHTML = `
+			
+			

Preferences

+ +
Changes preferences to not download images or files on the device doesn't remove the already downloaded data.
+ +
+
Device preference set
+ +
+ +
+ + + + ` + }// }}} + constructor() {// {{{ + super(true) + this.sets = [] + + this.elNewSet.addEventListener('click', () => this.newSet()) + this.elSave.addEventListener('click', () => this.save()) + this.elDevPreferenceSet.addEventListener('change', event=>this.changePreferenceSet(event)) + + window._mbus.subscribe('SHOW_PAGE', async event => { + if (event.detail.data?.page == 'preferences') { + this.sets = await this.getPreferenceSets() + this.render() + } + }) + + window._mbus.subscribe('PREFERENCE_SET_MODIFIED', () => this.preferencesModified()) + window._mbus.subscribe('PREFERENCE_SET_DELETE', event => this.preferencesDelete(event.detail.data.set)) + }// }}} + sortSets(a, b) {// {{{ + if (a.name == 'default') return -1 + if (b.name == 'default') return 1 + + if (a.name.toLowerCase() < b.name.toLowerCase()) return -1 + if (a.name.toLowerCase() > b.name.toLowerCase()) return 1 + + return 0 + }// }}} + async render() {// {{{ + try { + this.sets.sort(this.sortSets) + this.elSets.replaceChildren(...this.sets) + + const setNames = this.sets.entries().map(([i, set]) => { + const optn = document.createElement('option') + optn.innerText = set.name + return optn + }) + this.elDevPreferenceSet.replaceChildren(...setNames) + } catch (e) { + console.error(e) + alert(e.message) + } + }// }}} + async getPreferenceSets() {// {{{ + const userData = localStorage.getItem('user') + if (userData === null) + throw new Error('Could not find user in localStorage') + + const user = JSON.parse(userData) + const prefsData = user.Preferences + + if (prefsData === undefined) + throw new Error('User object is missing preferences') + + if (!prefsData.hasOwnProperty('default')) + throw new Error('The "default" preferences set is missing') + + return Object.keys(prefsData).map(name => new N2PreferenceSet(name, prefsData[name])) + }// }}} + async retrieveServerPreferences() {// {{{ + try { + API.query('GET', '/user/preferences') + } catch (e) { + console.error(e) + alert(`Error retrieving preferences: ${e.message}`) + } + }// }}} + changePreferenceSet(event) {// {{{ + this.preferencesModified() + }// }}} + newSet() {// {{{ + let name = prompt("Name for new preference set") + if (!name) + return + + name = name.trim() + if (name === '') + return + + if (name == 'default') { + alert(`Name can't be "default".`) + return + } + + const exists = this.sets.some(s => s.name.toLowerCase() == name.toLowerCase()) + if (exists) { + alert(`Set with name "${name}" already exist.`) + return + } + + this.sets.push(new N2PreferenceSet(name, {})) + this.preferencesModified() + this.render() + }// }}} + preferencesModified() {// {{{ + this.elSave.removeAttribute('disabled') + }// }}} + preferencesDelete(deleteSet) {// {{{ + if (deleteSet.name == 'default') { + alert("Can't delete the default set.") + return + } + + if (!confirm(`Confirm deleting "${deleteSet.name}"`)) + return + + this.sets = this.sets.filter(set => { + return !(set.name === deleteSet.name) + }) + + this.preferencesModified() + this.render() + }// }}} + async save() {// {{{ + try { + let newPrefs = {} + this.sets.forEach(s => { + const setState = s.getState() + newPrefs[setState.name] = setState.state + }) + + // Throws exception on both HTTP and application errors. + await API.query('POST', '/user/preferences', newPrefs) + + const userData = localStorage.getItem('user') + const user = JSON.parse(userData) + user.Preferences = newPrefs + localStorage.setItem('user', JSON.stringify(user)) + localStorage.setItem('device_preference_set', this.elDevPreferenceSet.value) + _mbus.dispatch('DEVICE_PREFERENCE_SET_UPDATED') + } catch (e) { + console.error(e) + alert(e.message) + } finally { + this.elSave.setAttribute('disabled', true) + } + + }// }}} +} +customElements.define('n2-pagepreferences', N2PagePreferences) + +// Preferences is a set of preferences, of which there can be many named. +export class N2PreferenceSet extends CustomHTMLElement { + static {// {{{ + this.tmpl = document.createElement('template') + this.tmpl.innerHTML = ` + + +
+
+
+
+ +
+ + +
+ + ` + }// }}} + constructor(name, data) {// {{{ + super(true) + this.name = name + this.data = data + this.render() + + // Enable the save button when settings are modified. + this.allFields().forEach(f => + f.addEventListener('input', () => _mbus.dispatch('PREFERENCE_SET_MODIFIED')) + ) + + this.elName.addEventListener('click', () => this.updateName()) + this.elDelete.addEventListener('click', () => this.deleteSet()) + }// }}} + updateName() {// {{{ + if (this.name == 'default') { + alert('Can not change name of the default profile.') + return + } + + const name = prompt("Change name", this.name) + if (!name) + return + + this.name = name + this.render() + _mbus.dispatch('PREFERENCE_SET_MODIFIED') + }// }}} + deleteSet() {// {{{ + _mbus.dispatch('PREFERENCE_SET_DELETE', { set: this }) + }// }}} + render() {// {{{ + this.elName.innerText = this.name + + this.fieldDownloadImages.checked = this.data.DownloadImages + this.fieldDownloadFiles.checked = this.data.DownloadFiles + }// }}} + getState() {// {{{ + const name = this.name.trim() + if (name === '') + throw new Error('Name can not be empty.') + + return { + name: this.name.trim(), + state: this.fieldValues(), + } + }// }}} +} +customElements.define('n2-preferenceset', N2PreferenceSet) diff --git a/static/js/page_storage.mjs b/static/js/page_storage.mjs index 931a718..a007130 100644 --- a/static/js/page_storage.mjs +++ b/static/js/page_storage.mjs @@ -13,7 +13,10 @@ export class N2PageStorage extends CustomHTMLElement { constructor() { super() - window._mbus.subscribe('SHOW_PAGE', () => this.render()) + window._mbus.subscribe('SHOW_PAGE', event => { + if (event.detail.data?.page == 'storage') + this.render() + }) } async render() { const countNodes = await globalThis.nodeStore.nodeCount() diff --git a/static/js/sidebar.mjs b/static/js/sidebar.mjs index 129b9d1..6cd5814 100644 --- a/static/js/sidebar.mjs +++ b/static/js/sidebar.mjs @@ -128,6 +128,7 @@ export class N2Sidebar extends CustomHTMLElement { this.elSearch.addEventListener('click', () => _mbus.dispatch('op-search')) this.elSync.addEventListener('click', () => _sync.run()) this.elLogo.addEventListener('click', () => _app.goToNode(ROOT_NODE, false, false)) + this.elSettings.addEventListener('click', ()=> _mbus.dispatch('SHOW_PAGE', { page: 'preferences' })) this.elHideTree.addEventListener('click', event => { event.stopPropagation() _mbus.dispatch('TREE_EXPANSION', { expand: false }) @@ -197,9 +198,8 @@ export class N2Sidebar extends CustomHTMLElement { this.expandedNodes[UUID] = false return this.expandedNodes[UUID] }//}}} - setNodeExpanded(node, value) {//{{{ + async setNodeExpanded(node, value) {//{{{ let expanded = this.expandedNodes[node.UUID] - if (expanded === undefined) { this.expandedNodes[node.UUID] = false expanded = false @@ -249,8 +249,6 @@ export class N2Sidebar extends CustomHTMLElement { // Holding shift down does it recursively. case Space: case 'Enter': - if (n.UUID === ROOT_NODE) - return const expanded = this.getNodeExpanded(n.UUID) if (event.shiftKey) { this.recursiveExpand(n, !expanded) @@ -259,38 +257,31 @@ export class N2Sidebar extends CustomHTMLElement { } break - case 'g': case 'Home': this.navigateTop() break - case 'G': case 'End': this.navigateBottom() break - case 'j': case 'ArrowDown': await this.navigateDown(this.selectedNode) break - case 'k': case 'ArrowUp': await this.navigateUp(this.selectedNode) break - case 'h': case 'ArrowLeft': await this.navigateLeft(this.selectedNode) break - case 'l': case 'ArrowRight': await this.navigateRight(this.selectedNode) break default: - // nonsole.log(event.key) handled = false } @@ -412,23 +403,26 @@ export class N2Sidebar extends CustomHTMLElement { }//}}} async navigateTop() {//{{{ const root = await nodeStore.get(ROOT_NODE) - if (root.Children.length === 0) - return - _mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[0]?.UUID, dontPush: false, dontExpand: true }) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: root.UUID, dontPush: false, dontExpand: true }) }//}}} async navigateBottom() {//{{{ - const root = await nodeStore.get(ROOT_NODE) - if (root.Children.length === 0) - return + const orphaned = await nodeStore.get(ORPHANED_NODE) - const toplevel = root.Children[root.Children.length - 1] + if (!orphaned.hasChildren() || this.getNodeExpanded(orphaned.UUID)) { + _mbus.dispatch("GO_TO_NODE", { nodeUUID: orphaned.UUID, dontPush: false, dontExpand: true }) + return + } + + /* TODO - fix this when orphaned nodes are implemented. + const toplevel = orphaned.Children[orphaned.Children.length - 1] const toplevelExpanded = this.getNodeExpanded(toplevel?.UUID) if (toplevelExpanded) { const lastnode = this.getLastExpandedNode(toplevel) _mbus.dispatch("GO_TO_NODE", { nodeUUID: lastnode?.UUID, dontPush: false, dontExpand: true }) } else - _mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[root.Children.length - 1]?.UUID, dontPush: false, dontExpand: true }) + _mbus.dispatch("GO_TO_NODE", { nodeUUID: orphaned.Children[orphaned.Children.length - 1]?.UUID, dontPush: false, dontExpand: true }) + */ }//}}} getParentWithNextSibling(node) {//{{{ @@ -449,6 +443,10 @@ export class N2Sidebar extends CustomHTMLElement { if (state) await this.setNodeExpanded(node, true) + // An expanded node needs to have its children fetched. + if (!node.hasFetchedChildren()) + await node.fetchChildren() + for (const child of node.Children) await this.recursiveExpand(child, state) @@ -577,7 +575,12 @@ export class N2TreeNode extends CustomHTMLElement { this.rendered = false this.dragNode = null - this.elExpandToggle.addEventListener('click', () => this.sidebar.setNodeExpanded(this.node, !this.sidebar.getNodeExpanded(this.node.UUID))) + this.elExpandToggle.addEventListener('click', event => { + if (this.node.hasChildren()) + this.expandNode(event) + else + _mbus.dispatch('TREE_NODE_SELECTED', this.node) + }) this.elName.addEventListener('click', () => _mbus.dispatch('TREE_NODE_SELECTED', this.node)) _mbus.subscribe(`NODE_EXPAND_${node.UUID}`, _state => { @@ -592,6 +595,7 @@ export class N2TreeNode extends CustomHTMLElement { this.elName.addEventListener('dragenter', event => this.dragEnter(event)) this.elName.addEventListener('dragleave', event => this.dragLeave(event)) }// }}} + dragStart(e) {// {{{ if (this.node.isModified()) { alert('Save note before moving it.') @@ -654,6 +658,16 @@ export class N2TreeNode extends CustomHTMLElement { _app.dragIcon.icon('') this.classList.remove('drag-target') }// }}} + + async expandNode(event) {// {{{ + const expanded = _app.sidebar.getNodeExpanded(this.node.UUID) + + if (event.shiftKey) { + _app.sidebar.recursiveExpand(this.node, !expanded) + } else { + _app.sidebar.setNodeExpanded(this.node, !expanded) + } + }// }}} async fetchChildren(force_fetch) {//{{{ if (this.children_populated && !force_fetch) return diff --git a/static/js/sync.mjs b/static/js/sync.mjs index fe72c3f..daa603f 100644 --- a/static/js/sync.mjs +++ b/static/js/sync.mjs @@ -90,6 +90,7 @@ export class Sync { nodeStore.setAppState('latest_sync_node', currMax) } catch (e) { console.error('sync node tree', e) + alert(e.message) } finally { syncEnd = Date.now() const duration = (syncEnd - syncStart) / 1000 @@ -157,8 +158,8 @@ export class Sync { _mbus.dispatch('SYNC_UPLOADED', { count: nodesToSend.length }) } catch (e) { - console.trace(e) - alert(e.error) + console.error(e) + alert(e.message) return } } diff --git a/user.go b/user.go deleted file mode 100644 index b1c2abf..0000000 --- a/user.go +++ /dev/null @@ -1,27 +0,0 @@ -package main - -import ( - // External - "github.com/golang-jwt/jwt/v5" -) - -type UserSession struct { - UserID int - Username string - Password string - Name string - ClientUUID string -} - -func NewUser(claims jwt.MapClaims) (u UserSession) { - uid, _ := claims["uid"].(float64) - name, _ := claims["name"].(string) - username, _ := claims["login"].(string) - clientUUID, _ := claims["cid"].(string) - - u.UserID = int(uid) - u.Username = username - u.Name = name - u.ClientUUID = clientUUID - return -} diff --git a/user/pkg.go b/user/pkg.go new file mode 100644 index 0000000..bcdfac8 --- /dev/null +++ b/user/pkg.go @@ -0,0 +1,63 @@ +package user + +import ( + // External + "github.com/golang-jwt/jwt/v5" + "github.com/jmoiron/sqlx" + + // Standard + "encoding/json" +) + +type User struct { + ID int + Username string + Name string + Preferences map[string]UserPreferences +} + +type UserSession struct { + UserID int + Username string + Password string + Name string + ClientUUID string + Db *sqlx.DB +} + +type UserPreferences struct { + DownloadImages bool + DownloadFiles bool +} + +func NewUser(claims jwt.MapClaims) (u UserSession) { + uid, _ := claims["uid"].(float64) + name, _ := claims["name"].(string) + username, _ := claims["login"].(string) + clientUUID, _ := claims["cid"].(string) + + u.UserID = int(uid) + u.Username = username + u.Name = name + u.ClientUUID = clientUUID + return +} + +func (u UserSession) Preferences() (prefs map[string]UserPreferences, err error) { + row := u.Db.QueryRow(`SELECT preferences FROM public.user WHERE id=$1`, u.UserID) + + var data []byte + err = row.Scan(&data) + if err != nil { + return + } + + err = json.Unmarshal(data, &prefs) + return +} + +func (u UserSession) SetPreferences(prefs map[string]UserPreferences) (err error) { + j, _ := json.Marshal(prefs) + _, err = u.Db.Exec(`UPDATE public.user SET preferences=$2 WHERE id=$1`, u.UserID, j) + return +} diff --git a/views/pages/notes2.gotmpl b/views/pages/notes2.gotmpl index d54c71d..2755aea 100644 --- a/views/pages/notes2.gotmpl +++ b/views/pages/notes2.gotmpl @@ -32,9 +32,10 @@ -
- -
+ + + + @@ -46,6 +47,7 @@ import {App} from "/js/{{ .VERSION }}/app.mjs" import {API} from 'api' import {Sync} from 'sync' + import { } from '/js/{{ .VERSION }}/page_preferences.mjs' import { } from '/js/{{ .VERSION }}/page_storage.mjs' import { } from '/js/{{ .VERSION }}/page_history.mjs' import { } from '/js/{{ .VERSION }}/file.mjs'