diff options
Diffstat (limited to 'desktop/modules/user/UserPage.qml')
| -rw-r--r-- | desktop/modules/user/UserPage.qml | 256 |
1 files changed, 256 insertions, 0 deletions
diff --git a/desktop/modules/user/UserPage.qml b/desktop/modules/user/UserPage.qml new file mode 100644 index 0000000..024a63b --- /dev/null +++ b/desktop/modules/user/UserPage.qml @@ -0,0 +1,256 @@ +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Quickshell +import Quickshell.Io +import Quickshell.Widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Dialogs +import "../.." +import "User.js" as U + +// The form. `saved` is what userctl read, `draft` the edits; Save sends each +// changed key to `userctl set` in turn. A failed key keeps its edit and shows +// the error under the field, so the next Save retries it. +Column { + id: form + + spacing: 14 + + // A new detail is one entry here and one case in userctl. Avatar is not + // listed: it is the picture at the top, not a row. + readonly property var fields: [ + { key: "name", label: "Name", kind: "text" }, + { key: "email", label: "Email", kind: "text" }, + { key: "location", label: "Location", kind: "text" }, + { key: "language", label: "Language", kind: "locale" }, + { key: "signingkey", label: "Signing key", kind: "key" }, + ] + + readonly property string ctl: Qt.resolvedUrl("userctl").toString().replace(/^file:\/\//, "") + property var saved: ({}) + property var draft: ({}) + property var errors: ({}) + property var keys: [] + property var locales: [] + property bool busy: false + property string loadError: "" + readonly property var changed: Object.keys(draft).filter(k => draft[k] !== saved[k]) + + function set(key, value) { + const d = Object.assign({}, form.draft); + d[key] = value; + form.draft = d; + } + + // ComboBox entries per kind. The current language is kept even if + // locale -a no longer lists it, so the combo never shows blank. + function options(kind) { + if (kind === "locale") { + const l = form.locales.slice(); + if (form.saved.language && !l.includes(form.saved.language)) l.unshift(form.saved.language); + return l.map(v => ({ text: v, value: v })); + } + return [{ text: "None", value: "" }] + .concat(form.keys.map(k => ({ text: k.uid + " · " + k.fpr.slice(-16), value: k.fpr }))); + } + + // One Process per call, so reads and writes never share one (a running + // Process ignores a second start, AGENTS.md). cb gets stdout. + function run(cmd, cb) { + op.createObject(form, { command: cmd, cb: cb }).running = true; + } + Component { + id: op + Process { + id: proc + property var cb: null + stdout: StdioCollector { + onStreamFinished: { + try { proc.cb(text); } + finally { proc.destroy(); } + } + } + } + } + + // keep: draft values to carry over the reload (the keys that failed). + function load(keep) { + form.run([form.ctl, "get"], out => { + const s = U.parse(out); + if (s.error) { + form.loadError = s.error; + return; + } + form.loadError = ""; + form.saved = ({}); + form.saved = s; + form.draft = Object.assign({}, s, keep); + }); + } + + function save() { + if (form.busy || form.changed.length === 0) return; + form.busy = true; + form.errors = ({}); + form.step(form.changed.slice(), {}); + } + function step(queue, failed) { + if (queue.length === 0) { + form.busy = false; + form.load(failed); + return; + } + const k = queue.shift(); + form.run([form.ctl, "set", k, form.draft[k]], out => { + if (out.trim() !== "") { + const e = Object.assign({}, form.errors); + e[k] = out.trim(); + form.errors = e; + failed[k] = form.draft[k]; + } + form.step(queue, failed); + }); + } + + Component.onCompleted: { + form.load({}); + form.run([form.ctl, "keys"], out => form.keys = U.keys(out)); + form.run(["locale", "-a"], out => form.locales = U.locales(out)); + } + + // A FileDialog hands back a file:// URL; AccountsService wants a path. + FileDialog { + id: picker + title: "Choose a picture" + fileMode: FileDialog.OpenFile + nameFilters: ["Images (*.png *.jpg *.jpeg)"] + onAccepted: form.set("avatar", decodeURIComponent(selectedFile.toString().replace(/^file:\/\//, ""))) + } + + ClippingRectangle { + anchors.horizontalCenter: parent.horizontalCenter + width: 96 + height: 96 + radius: 48 + color: Theme.surfaceAlt + + Image { + id: pic + anchors.fill: parent + source: form.draft.avatar ? "file://" + form.draft.avatar : "" + fillMode: Image.PreserveAspectCrop + sourceSize: Qt.size(192, 192) + cache: false + } + Text { + anchors.centerIn: parent + visible: pic.status !== Image.Ready + text: "\uf007" + font { family: Theme.iconFamily; pixelSize: 40 } + color: Theme.subtext + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: picker.open() + } + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + visible: !!form.errors.avatar + text: form.errors.avatar ?? "" + color: Theme.red + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + + Text { + width: parent.width + visible: form.loadError !== "" + text: form.loadError + wrapMode: Text.Wrap + color: Theme.red + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + + Repeater { + model: form.fields + + delegate: Column { + id: field + required property var modelData + width: form.width + spacing: 4 + + Text { + text: field.modelData.label + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.subtext + } + + Rectangle { + visible: field.modelData.kind === "text" + width: parent.width + height: 34 + radius: 8 + color: Theme.surface + TextInput { + anchors { fill: parent; leftMargin: 10; rightMargin: 10 } + verticalAlignment: TextInput.AlignVCenter + clip: true + selectByMouse: true + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + text: form.draft[field.modelData.key] ?? "" + onTextEdited: form.set(field.modelData.key, text) + } + } + + ComboBox { + visible: field.modelData.kind !== "text" + width: parent.width + textRole: "text" + valueRole: "value" + model: field.modelData.kind === "text" ? [] : form.options(field.modelData.kind) + // count is read so the index is recomputed once the list + // arrives, which is after the draft. + currentIndex: count >= 0 ? indexOfValue(form.draft[field.modelData.key] ?? "") : -1 + onActivated: form.set(field.modelData.key, currentValue) + } + + Text { + width: parent.width + visible: !!form.errors[field.modelData.key] + text: form.errors[field.modelData.key] ?? "" + wrapMode: Text.Wrap + color: Theme.red + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + } + } + + Text { + width: parent.width + text: "git: " + U.gitName(form.draft.name ?? "") + " <" + (form.draft.email ?? "") + ">" + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.overlay + } + + Button { + anchors.right: parent.right + text: form.busy ? "Saving…" : "Save" + enabled: !form.busy && form.loadError === "" && form.changed.length > 0 + onClicked: form.save() + } +} |
