From 74851b9c4d31d124a7d23be11c4b479d3df9ac1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Sun, 21 Jun 2026 10:59:38 +0200 Subject: [PATCH] Prefs managing mostly done --- main.go | 48 ++++- static/css/notes2.css | 2 +- static/js/app.mjs | 14 ++ static/js/lib/custom_html_element.mjs | 56 +++--- static/js/page_preferences.mjs | 269 +++++++++++++++++++++++++- user/pkg.go | 6 + 6 files changed, 344 insertions(+), 51 deletions(-) diff --git a/main.go b/main.go index 86e52e9..6e3cf94 100644 --- a/main.go +++ b/main.go @@ -135,7 +135,8 @@ func main() { // {{{ http.HandleFunc("/offline", pageOffline) http.HandleFunc("/user/authenticate", AuthManager.AuthenticationHandler) - http.HandleFunc("/user/preferences", authenticated(actionUserPreferences)) + 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)) @@ -268,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")) @@ -291,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) @@ -311,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") @@ -327,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") @@ -350,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") @@ -367,7 +368,7 @@ func actionNodeHistoryCount(w http.ResponseWriter, r *http.Request) { // {{{ }) } // }}} func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{ - user := getUser(r) + user := getUserSession(r) body, _ := io.ReadAll(r.Body) var request struct { @@ -391,8 +392,8 @@ func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{ }) } // }}} -func actionUserPreferences(w http.ResponseWriter, r *http.Request) { // {{{ - user := getUser(r) +func actionUserGetPreferences(w http.ResponseWriter, r *http.Request) { // {{{ + user := getUserSession(r) prefs, err := user.Preferences() if err != nil { httpError(w, err) @@ -404,6 +405,33 @@ func actionUserPreferences(w http.ResponseWriter, r *http.Request) { // {{{ "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) @@ -447,7 +475,7 @@ func changePassword(username string) { // {{{ fmt.Printf("\nPassword changed\n") } // }}} -func getUser(r *http.Request) appUser.UserSession { // {{{ +func getUserSession(r *http.Request) appUser.UserSession { // {{{ user, _ := r.Context().Value(CONTEXT_USER).(appUser.UserSession) user.Db = db return user diff --git a/static/css/notes2.css b/static/css/notes2.css index f8d46eb..7fdea0b 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -274,7 +274,7 @@ button { &.page-preferences { #page-preferences { - display: grid !important; + display: block; grid-area: n2-page; } } 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/page_preferences.mjs b/static/js/page_preferences.mjs index 273523c..9655278 100644 --- a/static/js/page_preferences.mjs +++ b/static/js/page_preferences.mjs @@ -5,28 +5,279 @@ 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() - window._mbus.subscribe('SHOW_PAGE', event => { - if (event.detail.data?.page == 'preferences') + 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) + } + }// }}} - getPreferences() { - API.query('GET', '/user/preferences') - } } customElements.define('n2-pagepreferences', N2PagePreferences) // Preferences is a set of preferences, of which there can be many named. -class Preferences { - constructor(name, data) { +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/user/pkg.go b/user/pkg.go index 4810df0..bcdfac8 100644 --- a/user/pkg.go +++ b/user/pkg.go @@ -55,3 +55,9 @@ func (u UserSession) Preferences() (prefs map[string]UserPreferences, err error) 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 +}