From 15dad8e3be7eacd938c60ee2d66d961f4cc425b9 Mon Sep 17 00:00:00 2001 From: jedi Date: Mon, 31 Aug 2026 16:05:05 +0200 Subject: [PATCH] stash --- backend/authentication/api.py | 50 ------------- frontend/src/label.js | 10 +-- frontend/src/preferences.js | 89 +++++++++++++++++++++++ frontend/src/store.js | 98 ++++---------------------- frontend/src/views/Inventory.vue | 18 +++-- frontend/src/views/StorageLocation.vue | 18 +++-- 6 files changed, 130 insertions(+), 153 deletions(-) create mode 100644 frontend/src/preferences.js diff --git a/backend/authentication/api.py b/backend/authentication/api.py index 599fea5..f59eb7c 100644 --- a/backend/authentication/api.py +++ b/backend/authentication/api.py @@ -18,47 +18,6 @@ from hostadmin.models import Domain router = routers.SimpleRouter() -# Schema for account-level preferences a client may store server-side (see AccountPreference); -# device-level preferences stay in the browser's local storage instead. -PREFERENCE_DEFINITIONS = [ - { - 'key': 'ui.compact_mode', - 'type': 'boolean', - 'default': False, - 'label': 'Compact mode', - 'description': 'Show denser item rows and reduce spacing in lists.', - }, - { - 'key': 'ui.default_search_scope', - 'type': 'enum', - 'options': ['inventory', 'friends', 'all'], - 'default': 'inventory', - 'label': 'Default search scope', - 'description': 'Choose where the global search starts.', - }, - { - 'key': 'notifications.desktop_enabled', - 'type': 'boolean', - 'default': True, - 'label': 'Desktop notifications', - 'description': 'Enable in-browser notifications for important updates.', - }, - { - 'key': 'files.max_upload_mb', - 'type': 'integer', - 'default': 25, - 'label': 'Default upload size limit (MB)', - 'description': 'Used as a prefill hint in upload dialogs.', - }, - { - 'key': 'ui.experimental_flags', - 'type': 'json', - 'default': {}, - 'label': 'Experimental flags', - 'description': 'Optional JSON toggles for feature previews.', - }, -] - class UserAuthToken(ObtainAuthToken): @@ -199,14 +158,6 @@ def registerUser(request): return Response({'errors': {'domain': 'Domain does not exist or is not open for registration'}}, status=400) -@api_view(['GET']) -@permission_classes([]) -@authentication_classes([]) -def preference_definitions(request): - """Return the schema (types, defaults, labels) for the account preferences clients may set.""" - return Response(PREFERENCE_DEFINITIONS) - - @api_view(['GET', 'PUT']) @permission_classes([IsAuthenticated]) @authentication_classes([SignatureAuthenticationLocal]) @@ -241,7 +192,6 @@ urlpatterns = [ path('user//', getUserProfile), path('register/', registerUser), path('token/', UserAuthToken.as_view()), - path('preferences/', preference_definitions), path('self/preferences/', account_preferences), path('self/preferences//', account_preference_detail), ] diff --git a/frontend/src/label.js b/frontend/src/label.js index 5111bce..ae8e848 100644 --- a/frontend/src/label.js +++ b/frontend/src/label.js @@ -249,8 +249,8 @@ function positionTree(node, ownAxis, x, y) { // MIN_RECOMMENDED_QR_PX_PER_MODULE/MIN_RECOMMENDED_QR_MODULE_MM the leaf falls short of (still // printable, just flagged as a scan-reliability risk), and unconditionally appends its raw // {scale, moduleMm} to `qrInfo` - callers needing to show those numbers regardless of whether -// they tripped a threshold (see LabelLayoutPreview.vue's debug line) shouldn't have to re-derive -// them. See docs/implementation.md#crisp-qr-sizing. +// they tripped a threshold shouldn't have to re-derive them. See +// docs/implementation.md#crisp-qr-sizing. function snapQrToCrispSize(node, pxPerMm, warnings, qrInfo) { if (isSplit(node)) { node.forEach(child => snapQrToCrispSize(child, pxPerMm, warnings, qrInfo)); @@ -349,7 +349,7 @@ function measureTextBlock(ctx, lines, referencePx) { return {width, height}; } -// Converts a resolved content tree (see templateContent) into one ready for layout. See +// Converts a resolved content tree (see label-layouts.js's resolveContent) into one ready for layout. See // docs/implementation.md#render-tree-construction. function buildRenderTree(ctx, node, referencePx) { if (isSplit(node)) { @@ -483,7 +483,7 @@ function layoutContent(ctx, content, fixedSize, maxLength, referencePx, pxPerMm, return {tree, length, warnings, qrInfo, textInfo}; } -// The tape-fed layout: draws a fully resolved content tree (see templateContent) at the tape's +// The tape-fed layout: draws a fully resolved content tree (see label-layouts.js's resolveContent) at the tape's // real pixel dimensions. See docs/implementation.md#tape-fed-label-drawing. Returns // {textSizesPx}: each "text" leaf's effective font size, in the tree's own left-to-right, // top-to-bottom order; {warnings}: {short, message} scan-reliability entries from @@ -602,7 +602,7 @@ function splitUserHandle(userHandle) { // Seeds label-layouts.js's BASE_VARS, keyed by `kind`; derived vars (userHandle, itemUrl, …) are // computed live elsewhere (see DERIVED_VARS, Print.vue's `shortUrl`), and omitting a field -// (rather than leaving it present-but-empty) signals "not available" to templateIsAvailable. +// (rather than leaving it present-but-empty) signals "not available" to contentIsAvailable. const LABEL_FIELD_BUILDERS = { "item": ({userHandle, item}) => { const split = splitUserHandle(userHandle); diff --git a/frontend/src/preferences.js b/frontend/src/preferences.js new file mode 100644 index 0000000..6005851 --- /dev/null +++ b/frontend/src/preferences.js @@ -0,0 +1,89 @@ +// Preference schema catalog, device/account resolution, and device-local (localStorage) +// persistence. See docs/implementation.md#preferences for account vs device precedence. + +export const DEVICE_STORAGE_KEY = 'toolshed.preferences.device' + +export const defaultPreferenceDefinitions = [ + { + key: 'ui.compact_mode', + type: 'boolean', + default: false, + label: 'Compact mode', + description: 'Show denser item rows and reduce spacing in lists.' + }, + { + key: 'ui.default_search_scope', + type: 'enum', + options: ['inventory', 'friends', 'all'], + default: 'inventory', + label: 'Default search scope', + description: 'Choose where the global search starts.' + }, + { + key: 'notifications.desktop_enabled', + type: 'boolean', + default: true, + label: 'Desktop notifications', + description: 'Enable in-browser notifications for important updates.' + }, + { + key: 'files.max_upload_mb', + type: 'integer', + default: 25, + label: 'Default upload size limit (MB)', + description: 'Used as a prefill hint in upload dialogs.' + }, + { + key: 'ui.experimental_flags', + type: 'json', + default: {}, + label: 'Experimental flags', + description: 'Optional JSON toggles for feature previews.' + }, +] + +export function loadDevicePreferences(storageKey = DEVICE_STORAGE_KEY) { + try { + const raw = localStorage.getItem(storageKey) + if (!raw) { + return {} + } + const parsed = JSON.parse(raw) + return parsed && typeof parsed === 'object' ? parsed : {} + } catch (_error) { + return {} + } +} + +// Best-effort - a disabled/full localStorage shouldn't break whatever triggered the save (see +// e.g. Print.vue's rememberLayout). +export function saveDevicePreferences(preferences, storageKey = DEVICE_STORAGE_KEY) { + try { + localStorage.setItem(storageKey, JSON.stringify(preferences)) + } catch (error) { + console.error('Failed to save device preferences:', error) + } +} + +// Resolution order: device -> account -> schema default -> fallbackDefault. +export function resolvePreference(key, {devicePreferences = {}, accountPreferences = {}, definitions = []} = {}, fallbackDefault = null) { + if (Object.prototype.hasOwnProperty.call(devicePreferences, key) && devicePreferences[key] !== null) { + return devicePreferences[key] + } + if (Object.prototype.hasOwnProperty.call(accountPreferences, key) && accountPreferences[key] !== null) { + return accountPreferences[key] + } + const definition = definitions.find((pref) => pref.key === key) + if (definition) { + return definition.default + } + return fallbackDefault +} + +export default { + DEVICE_STORAGE_KEY, + defaultPreferenceDefinitions, + loadDevicePreferences, + saveDevicePreferences, + resolvePreference, +}; diff --git a/frontend/src/store.js b/frontend/src/store.js index 1c0bde8..69f6381 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -5,6 +5,7 @@ import NeighborsCache from "@/neigbors"; import {createNullAuth, createSignAuth, createTokenAuth, ServerSet, ServerSetUnion} from "@/federation"; import {parseIdentityRecord, serializeIdentityRecord} from "@/identity"; import {serializeWorkflowPayload, deserializeWorkflowPayload} from "@/workflows.js"; +import {defaultPreferenceDefinitions, loadDevicePreferences, saveDevicePreferences, resolvePreference} from "@/preferences.js"; //import sharedStatePlugin from "@/../extras/shared-state-plugin"; //import persistentStatePlugin from "@/../extras/persistent-state-plugin"; @@ -13,59 +14,6 @@ function splitGroupHandle(handle) { return {name, domain} } -const defaultPreferenceDefinitions = [ - { - key: 'ui.compact_mode', - type: 'boolean', - default: false, - label: 'Compact mode', - description: 'Show denser item rows and reduce spacing in lists.' - }, - { - key: 'ui.default_search_scope', - type: 'enum', - options: ['inventory', 'friends', 'all'], - default: 'inventory', - label: 'Default search scope', - description: 'Choose where the global search starts.' - }, - { - key: 'notifications.desktop_enabled', - type: 'boolean', - default: true, - label: 'Desktop notifications', - description: 'Enable in-browser notifications for important updates.' - }, - { - key: 'files.max_upload_mb', - type: 'integer', - default: 25, - label: 'Default upload size limit (MB)', - description: 'Used as a prefill hint in upload dialogs.' - }, - { - key: 'ui.experimental_flags', - type: 'json', - default: {}, - label: 'Experimental flags', - description: 'Optional JSON toggles for feature previews.' - }, -] - -const parseStoredPreferences = (storageKey) => { - try { - const raw = localStorage.getItem(storageKey) - if (!raw) { - return {} - } - const parsed = JSON.parse(raw) - return parsed && typeof parsed === 'object' ? parsed : {} - } catch (_error) { - return {} - } -} - - export default createStore({ state: { local_loaded: false, @@ -97,9 +45,12 @@ export default createStore({ domains: [], storage_locations: [], active_workflows: [], - preferenceDefinitions: [], + // Static schema and device-local values are available synchronously - see + // docs/implementation.md#preferences - unlike accountPreferences, which needs a network + // round trip and is gated by preferencesLoaded. + preferenceDefinitions: defaultPreferenceDefinitions, accountPreferences: {}, - devicePreferences: {}, + devicePreferences: loadDevicePreferences(), preferencesLoaded: false, }, mutations: { @@ -161,15 +112,9 @@ export default createStore({ setActiveWorkflows(state, workflows) { state.active_workflows = workflows; }, - setPreferenceDefinitions(state, definitions) { - state.preferenceDefinitions = definitions; - }, setAccountPreferences(state, preferences) { state.accountPreferences = preferences; }, - setDevicePreferences(state, preferences) { - state.devicePreferences = preferences; - }, setAccountPreference(state, {key, value}) { state.accountPreferences = {...state.accountPreferences, [key]: value}; }, @@ -237,9 +182,8 @@ export default createStore({ state.token = null; state.keypair = null; state.accountPreferences = {}; - state.preferenceDefinitions = []; state.preferencesLoaded = false; - // Note: devicePreferences are NOT cleared on logout (device-specific) + // Note: devicePreferences/preferenceDefinitions are NOT cleared on logout (device-specific / static) localStorage.removeItem('user'); localStorage.removeItem('token'); localStorage.removeItem('keypair'); @@ -266,19 +210,13 @@ export default createStore({ actions: { async loadUserPreferences({state, commit, dispatch, getters}) { if (state.preferencesLoaded) return - const deviceKey = 'toolshed.preferences.device' - commit('setDevicePreferences', parseStoredPreferences(deviceKey)) - - let definitions = defaultPreferenceDefinitions let accountPreferences = {} try { const servers = await dispatch('getHomeServers') - definitions = await servers.get(getters.nullAuth, '/auth/preferences/') || defaultPreferenceDefinitions accountPreferences = await servers.get(getters.signAuth, '/auth/self/preferences/') || {} } catch (error) { console.error('Failed to load user preferences:', error) } - commit('setPreferenceDefinitions', definitions) commit('setAccountPreferences', accountPreferences) commit('setPreferencesLoaded', true) }, @@ -294,11 +232,11 @@ export default createStore({ }, async setDevicePreference({state, commit}, {key, value}) { commit('setDevicePreference', {key, value}) - localStorage.setItem('toolshed.preferences.device', JSON.stringify(state.devicePreferences)) + saveDevicePreferences(state.devicePreferences) }, async resetDevicePreference({state, commit}, key) { commit('deleteDevicePreference', key) - localStorage.setItem('toolshed.preferences.device', JSON.stringify(state.devicePreferences)) + saveDevicePreferences(state.devicePreferences) }, async login({commit, dispatch, state, getters}, {username, password, remember}) { commit('setRemember', remember); @@ -949,19 +887,11 @@ export default createStore({ time: Date.now() - 1000 * 60 * 60 * 24 }] }, - getPreference: (state) => (key, fallbackDefault = null) => { - if (Object.prototype.hasOwnProperty.call(state.devicePreferences, key) && state.devicePreferences[key] !== null) { - return state.devicePreferences[key] - } - if (Object.prototype.hasOwnProperty.call(state.accountPreferences, key) && state.accountPreferences[key] !== null) { - return state.accountPreferences[key] - } - const definition = state.preferenceDefinitions.find((pref) => pref.key === key) - if (definition) { - return definition.default - } - return fallbackDefault - }, + getPreference: (state) => (key, fallbackDefault = null) => resolvePreference(key, { + devicePreferences: state.devicePreferences, + accountPreferences: state.accountPreferences, + definitions: state.preferenceDefinitions, + }, fallbackDefault), /** Extracts the name from a handle like "git:tools#tag:drill"; returns non-handles unchanged. */ getNameFromHandle: () => (handle) => { if (typeof handle !== 'string') { diff --git a/frontend/src/views/Inventory.vue b/frontend/src/views/Inventory.vue index 102f39a..0e0a2ea 100644 --- a/frontend/src/views/Inventory.vue +++ b/frontend/src/views/Inventory.vue @@ -132,19 +132,23 @@ export default { default: null } }, - data() { - return { - layout: "grid", - } - }, components: { AuthenticatedImage, BaseLayout, ...BIcons }, computed: { - ...mapGetters(["inventory_items", "groupInventoryItems", "loaded_items", "identityIdByHandle", "groupIdByHandle"]), + ...mapGetters(["inventory_items", "groupInventoryItems", "loaded_items", "identityIdByHandle", "groupIdByHandle", "getPreference"]), ...mapState(["user", "storage_locations", "groups", "groupMemberships", "friends"]), + // Implicit preference: remembers the last-used view without a settings-page entry. See docs/implementation.md#preferences. + layout: { + get() { + return this.getPreference('ui.remembered.inventory_view_mode', 'grid') + }, + set(value) { + this.setDevicePreference({key: 'ui.remembered.inventory_view_mode', value}) + } + }, // Groups hosted here plus GroupMembership-only ones - see Groups.vue's allGroups for the same merge/dedupe. ownerGroups() { const hostedHandles = new Set(this.groups.map(group => group.handle)) @@ -181,7 +185,7 @@ export default { methods: { ...mapActions(["fetchInventoryItems", "fetchGroupInventoryItems", "fetchFriendInventoryItems", "deleteInventoryItem", "fetchStorageLocations", "fetchIdMap", "fetchGroups", - "fetchGroupMemberships", "fetchFriends"]), + "fetchGroupMemberships", "fetchFriends", "setDevicePreference"]), fetchItemsForOwner() { if (this.selectedOwner === this.user) return this.fetchInventoryItems() if (this.isGroupSelected) return this.fetchGroupInventoryItems({groupHandle: this.selectedOwner}) diff --git a/frontend/src/views/StorageLocation.vue b/frontend/src/views/StorageLocation.vue index 269aff3..ec7d9dc 100644 --- a/frontend/src/views/StorageLocation.vue +++ b/frontend/src/views/StorageLocation.vue @@ -130,18 +130,22 @@ export default { default: null } }, - data() { - return { - layout: "grid", - } - }, components: { BaseLayout, ...BIcons }, computed: { - ...mapGetters(["identityIdByHandle", "groupIdByHandle", "groupStorageLocations"]), + ...mapGetters(["identityIdByHandle", "groupIdByHandle", "groupStorageLocations", "getPreference"]), ...mapState(["user", "storage_locations", "groups", "groupMemberships"]), + // Implicit preference: remembers the last-used view without a settings-page entry. See docs/implementation.md#preferences. + layout: { + get() { + return this.getPreference('ui.remembered.storage_location_view_mode', 'grid') + }, + set(value) { + this.setDevicePreference({key: 'ui.remembered.storage_location_view_mode', value}) + } + }, // Groups hosted here plus GroupMembership-only ones - see Groups.vue's allGroups for the same merge/dedupe. ownerGroups() { const hostedHandles = new Set(this.groups.map(group => group.handle)) @@ -169,7 +173,7 @@ export default { }, methods: { ...mapActions(["fetchStorageLocations", "fetchGroupStorageLocations", "deleteStorageLocation", - "fetchIdMap", "fetchGroups", "fetchGroupMemberships"]), + "fetchIdMap", "fetchGroups", "fetchGroupMemberships", "setDevicePreference"]), fetchLocationsForOwner() { return this.selectedOwner === this.user ? this.fetchStorageLocations()