This commit is contained in:
j3d1 2026-07-23 04:41:04 +02:00
parent 7c91661be2
commit 796fef81be
37 changed files with 3404 additions and 108 deletions

View file

@ -23,13 +23,18 @@
</div>
<div class="col-md-4">
<div class="text-center">
<img alt="Charles Hall"
src="/assets/img/avatars/avatar.png"
class="rounded-circle img-responsive mt-2" width="128"
height="128"/>
<authenticated-image :src="profilePictureSrc" :owner="user"
:refresh-key="profilePictureRefreshKey"
:alt="user" :title="user"
img-class="rounded-circle img-responsive mt-2"
:width="128" :height="128" fit="cover"/>
<div class="mt-2">
<span class="btn btn-primary"><i
class="fas fa-upload"></i> Upload</span>
<fs-file-source @input="uploadPicture">
<span class="btn btn-primary"><i class="fas fa-upload"></i> Upload</span>
</fs-file-source>
<button class="btn btn-outline-secondary ml-2" type="button" @click="removePicture">
Remove
</button>
</div>
<small>For best results, use an image at least 128px by
128px in .jpg format</small>
@ -99,10 +104,38 @@
</template>
<script>
import {mapActions, mapState} from "vuex";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import FsFileSource from "@/components/inputs/FsFileSource.vue";
export default {
name: 'Account',
components: {}
components: {AuthenticatedImage, FsFileSource},
computed: {
...mapState(['user', 'user_profile']),
profilePictureSrc() {
return this.user_profile?.profile_picture?.name || null;
},
profilePictureRefreshKey() {
return this.user_profile?.profile_picture?.id || 'none';
}
},
methods: {
...mapActions(['fetchUserProfile', 'updateUserProfilePicture']),
async uploadPicture(files) {
const image = files.find(file => file.mime_type?.startsWith('image/'))
if (!image) {
return;
}
await this.updateUserProfilePicture({file: image});
},
async removePicture() {
await this.updateUserProfilePicture({file: null});
}
},
mounted() {
this.fetchUserProfile();
}
}
</script>

View file

@ -0,0 +1,234 @@
<template>
<div class="card">
<div class="card-body">
<h5 class="card-title">Preferences</h5>
<div v-if="!preferencesLoaded" class="text-center py-4 text-muted">
Loading preferences...
</div>
<div v-else>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th style="width: 26%">Preference</th>
<th style="width: 17%">Device</th>
<th style="width: 17%">Account</th>
<th style="width: 17%">Default</th>
<th style="width: 23%">Effective</th>
</tr>
</thead>
<tbody>
<tr v-for="schema in schemas" :key="schema.key">
<td>
<div class="font-weight-bold">{{ schema.label }}</div>
<small v-if="schema.description" class="text-muted">{{ schema.description }}</small>
</td>
<td>
<PreferenceInput
:id="`device-${schema.key}`"
:schema="schema"
:value="effectiveDeviceValues[schema.key]"
@input="updateDevicePreference(schema.key, $event)"
@clear="clearDevicePreference(schema.key)"
/>
</td>
<td>
<PreferenceInput
:id="`account-${schema.key}`"
:schema="schema"
:value="effectiveAccountValues[schema.key]"
@input="updateAccountPreference(schema.key, $event)"
@clear="clearAccountPreference(schema.key)"
/>
</td>
<td>
<PreferenceInput
:id="`default-${schema.key}`"
:schema="schema"
:value="schema.default"
:disabled="true"
/>
</td>
<td>
<div class="font-weight-bold">
{{ formatEffectiveValue(schema, getPreference(schema.key)) }}
<span
v-if="hasChanges && formatEffectiveValue(schema, getEffectiveValueWithPendingChanges(schema.key)) !== formatEffectiveValue(schema, getPreference(schema.key))"
class="text-warning ml-1"
>
-> {{ formatEffectiveValue(schema, getEffectiveValueWithPendingChanges(schema.key)) }}
</span>
</div>
<small class="text-muted">Source: {{ getPreferenceSource(schema.key) }}</small>
</td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex pt-3 border-top mt-3">
<button @click="savePreferences" :disabled="!hasChanges || saving" class="btn btn-primary mr-2">
{{ saving ? 'Saving...' : 'Save changes' }}
</button>
<button @click="reloadPreferences" :disabled="!hasChanges || saving" class="btn btn-secondary">
Discard changes
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import {mapActions, mapGetters, mapState} from 'vuex'
import PreferenceInput from '@/components/inputs/PreferenceInput.vue'
export default {
name: 'Preferences',
components: {PreferenceInput},
data() {
return {
saving: false,
accountChanges: {},
deviceChanges: {},
}
},
computed: {
...mapState(['preferencesLoaded', 'preferenceDefinitions', 'devicePreferences', 'accountPreferences']),
...mapGetters(['getPreference']),
schemas() {
return this.preferenceDefinitions.map((pref) => ({
...pref,
label: pref.label || this.formatLabel(pref.key),
description: pref.description || '',
}))
},
hasChanges() {
return Object.keys(this.accountChanges).length > 0 || Object.keys(this.deviceChanges).length > 0
},
effectiveDeviceValues() {
return {...this.devicePreferences, ...this.deviceChanges}
},
effectiveAccountValues() {
return {...this.accountPreferences, ...this.accountChanges}
},
},
methods: {
...mapActions([
'loadUserPreferences',
'setAccountPreference',
'resetAccountPreference',
'setDevicePreference',
'resetDevicePreference',
]),
formatLabel(key) {
const parts = key.split('.')
const name = parts[parts.length - 1] || ''
return name
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
},
getEffectiveValueWithPendingChanges(key) {
if (Object.prototype.hasOwnProperty.call(this.deviceChanges, key)) {
if (this.deviceChanges[key] !== null) {
return this.deviceChanges[key]
}
if (Object.prototype.hasOwnProperty.call(this.accountChanges, key) && this.accountChanges[key] !== null) {
return this.accountChanges[key]
}
if (Object.prototype.hasOwnProperty.call(this.accountPreferences, key) && this.accountPreferences[key] !== null) {
return this.accountPreferences[key]
}
} else if (Object.prototype.hasOwnProperty.call(this.accountChanges, key)) {
if (this.accountChanges[key] !== null) {
return this.accountChanges[key]
}
} else {
return this.getPreference(key)
}
const definition = this.preferenceDefinitions.find((p) => p.key === key)
return definition ? definition.default : null
},
getPreferenceSource(key) {
if (Object.prototype.hasOwnProperty.call(this.deviceChanges, key)) {
return this.deviceChanges[key] !== null
? 'Device'
: Object.prototype.hasOwnProperty.call(this.accountPreferences, key) && this.accountPreferences[key] !== null
? 'Account'
: 'Default'
}
if (Object.prototype.hasOwnProperty.call(this.accountChanges, key)) {
return this.accountChanges[key] !== null ? 'Account' : 'Default'
}
if (Object.prototype.hasOwnProperty.call(this.devicePreferences, key) && this.devicePreferences[key] !== null) {
return 'Device'
}
if (Object.prototype.hasOwnProperty.call(this.accountPreferences, key) && this.accountPreferences[key] !== null) {
return 'Account'
}
return 'Default'
},
updateDevicePreference(key, value) {
this.deviceChanges = {...this.deviceChanges, [key]: value}
},
updateAccountPreference(key, value) {
this.accountChanges = {...this.accountChanges, [key]: value}
},
clearDevicePreference(key) {
this.deviceChanges = {...this.deviceChanges, [key]: null}
},
clearAccountPreference(key) {
this.accountChanges = {...this.accountChanges, [key]: null}
},
reloadPreferences() {
this.accountChanges = {}
this.deviceChanges = {}
},
formatEffectiveValue(schema, value) {
if (value === null || value === undefined) {
return '(not set)'
}
if (schema.type === 'boolean') {
return value ? 'Yes' : 'No'
}
if (schema.type === 'json') {
return typeof value === 'object' ? JSON.stringify(value) : String(value)
}
return String(value)
},
async savePreferences() {
this.saving = true
try {
for (const [key, value] of Object.entries(this.accountChanges)) {
if (value === null) {
await this.resetAccountPreference(key)
} else {
await this.setAccountPreference({key, value})
}
}
for (const [key, value] of Object.entries(this.deviceChanges)) {
if (value === null) {
await this.resetDevicePreference(key)
} else {
await this.setDevicePreference({key, value})
}
}
this.accountChanges = {}
this.deviceChanges = {}
} catch (error) {
console.error('Error saving preferences:', error)
} finally {
this.saving = false
}
},
},
mounted() {
this.loadUserPreferences()
},
}
</script>