feat: Integrate official drive_provider, update user profile features & UI improvements
All checks were successful
CI / container-job (push) Successful in 41s

- adonisrc.ts: Load official drive_provider and unload custom driver_provider.
- packages.json: Add @headlessui/vue dependency for tab components.
- AvatarController.ts: Rewrite avatar generation logic to always return the same avatar per user.
- auth/UserController.ts: Add profile and profileUpdate methods to support user profile editing.
- Submitter/datasetController.ts & app/models/file.ts: Adapt code to use the official drive_provider.
- app/models/user.ts: Introduce “isAdmin” getter.
- config/drive.ts: Create new configuration for the official drive_provider.
- providers/vinejs_provider.ts: Adapt allowedExtensions control to use provided options or database enabled extensions.
- resource/js/app.ts: Load default Head and Link components.
- resources/js/menu.ts: Add settings-profile.edit menu point.
- resources/js/Components/action-message.vue: Add new component for improved user feedback after form submissions.
- New avatar-input.vue component: Enable profile picture selection.
- Components/CardBox.vue: Alter layout to optionally show HeaderIcon in title bar.
- FormControl.vue: Define a readonly prop for textareas.
- Improve overall UI with updates to NavBar.vue, UserAvatar.vue, UserAvatarCurrentUser.vue, and add v-model support to password-meter.vue.
- Remove profile editing logic from AccountInfo.vue and introduce new profile components (show.vue, update-password-form.vue, update-profile-information.vue).
- app.edge: Modify page (add @inertiaHead tag) for better meta management.
- routes.ts: Add new routes for editing user profiles.
- General npm updates.
This commit is contained in:
Kaimbacher 2025-02-27 16:24:25 +01:00
parent a41b091214
commit 36cd7a757b
34 changed files with 1396 additions and 407 deletions

Binary file not shown.

View file

@ -12,6 +12,10 @@ const props = defineProps({
type: String,
default: null,
},
showHeaderIcon: {
type: Boolean,
default: true,
},
headerIcon: {
type: String,
default: null,
@ -63,7 +67,7 @@ const submit = (e) => {
<BaseIcon v-if="icon" :path="icon" class="mr-3" />
{{ title }}
</div>
<button class="flex items-center py-3 px-4 justify-center ring-blue-700 focus:ring" @click="headerIconClick">
<button v-if="showHeaderIcon" class="flex items-center py-3 px-4 justify-center ring-blue-700 focus:ring" @click="headerIconClick">
<BaseIcon :path="computedHeaderIcon" />
</button>
</header>

View file

@ -118,6 +118,9 @@ if (props.ctrlKFocus) {
mainService.isFieldFocusRegistered = false;
});
}
const focus = () => {
inputEl?.value.focus();
};
</script>
<template>
@ -130,7 +133,7 @@ if (props.ctrlKFocus) {
</option>
</select>
<textarea v-else-if="computedType === 'textarea'" :id="id" v-model="computedValue" :class="inputElClass"
:name="name" :placeholder="placeholder" :required="required" />
:name="name" :placeholder="placeholder" :required="required" :readonly="isReadOnly"/>
<input v-else :id="id" ref="inputEl" v-model="computedValue" :name="name" :inputmode="inputmode"
:autocomplete="autocomplete" :required="required" :placeholder="placeholder" :type="computedType"
:class="inputElClass" :readonly="isReadOnly" />

View file

@ -2,7 +2,7 @@
import { computed, useSlots } from 'vue';
defineProps({
const props = defineProps({
label: {
type: String,
default: null,
@ -15,6 +15,10 @@ defineProps({
type: String,
default: null,
},
// class: {
// type: Object,
// default: {},
// },
});
const slots = useSlots();
@ -36,7 +40,7 @@ const wrapperClass = computed(() => {
</script>
<template>
<div class="mb-6 last:mb-0">
<div :class="['last:mb-0', 'mb-6']">
<!-- <label v-if="label" :for="labelFor" class="block font-bold mb-2">{{ label }}</label> -->
<label v-if="label" :for="labelFor" class="font-bold h-6 mt-3 text-xs leading-8 uppercase">{{ label }}</label>
<div v-bind:class="wrapperClass">

View file

@ -150,7 +150,7 @@ const showAbout = async () => {
<!-- personal menu -->
<NavBarMenu>
<NavBarItemLabel v-bind:label="`hello ${user.login}`">
<UserAvatarCurrentUser class="w-6 h-6 mr-3 inline-flex" />
<UserAvatarCurrentUser :user="user" class="w-6 h-6 mr-3 inline-flex" />
</NavBarItemLabel>
<template #dropdown>
<!-- <NavBarItem> -->

View file

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { computed } from 'vue';
import { checkStrength } from './logic/index';
import { mdiFormTextboxPassword } from '@mdi/js';
import FormField from '@/Components/FormField.vue';
@ -7,17 +7,25 @@ import FormControl from '@/Components/FormControl.vue';
// Define props
const props = defineProps<{
password: string;
modelValue: string;
errors: Partial<Record<"new_password" | "old_password" | "confirm_password", string>>;
}>();
const emit = defineEmits(['update:password', 'score']);
const emit = defineEmits(['update:modelValue', 'score']);
// A local reactive variable for password input
const localPassword = ref(props.password);
// Watch localPassword and emit changes back to the parent
watch(localPassword, (newValue) => {
emit('update:password', newValue);
// // A local reactive variable for password input
// const localPassword = ref(props.modelValue);
// // Watch localPassword and emit changes back to the parent
// watch(localPassword, (newValue) => {
// emit('update:modelValue', newValue);
// });
const localPassword = computed({
get: () => props.modelValue,
set: (value) => {
emit('update:modelValue', value);
// const { score } = checkStrength(localPassword.value);
// emit('score', score);
},
});
type PasswordMetrics = {
@ -53,7 +61,7 @@ const passwordMetrics = computed<PasswordMetrics>(() => {
<template>
<!-- Password input Form -->
<FormField label="New password" help="Required. New password" :class="{ 'text-red-400': errors.new_password }">
<FormField label="New password" help="Required. New password" :class="{'text-red-400': errors.new_password }">
<FormControl v-model="localPassword" :icon="mdiFormTextboxPassword" name="new_password" type="password" required
:error="errors.new_password">
<!-- Secure Icon -->
@ -72,6 +80,10 @@ const passwordMetrics = computed<PasswordMetrics>(() => {
{{ errors.new_password }}
</div>
</FormControl>
<!-- Score Display -->
<div class="text-gray-700 text-sm">
{{ passwordMetrics.score }} / 6 points max
</div>
</FormField>
<!-- Password Strength Bar -->
@ -93,9 +105,9 @@ const passwordMetrics = computed<PasswordMetrics>(() => {
</ul>
</div>
<!-- Score Display -->
<div class="text-gray-700 text-sm">
<!-- <div class="text-gray-700 text-sm">
{{ passwordMetrics.score }} / 6 points max
</div>
</div> -->
</template>
<style lang="css" scoped>

View file

@ -6,9 +6,9 @@ const props = defineProps({
type: String,
required: true,
},
avatar: {
defaultUrl: {
type: String,
default: null,
required: false
},
api: {
type: String,
@ -16,58 +16,37 @@ const props = defineProps({
},
});
const avatar = computed(
// () => props.avatar ?? `https://avatars.dicebear.com/api/${props.api}/${props.username?.replace(/[^a-z0-9]+/i, '-')}.svg`
// () => props.avatar ?? `https://avatars.dicebear.com/api/initials/${props.username}.svg`,
() => {
const initials = props.username
.split(' ')
.map((part) => part.charAt(0).toUpperCase())
.join('');
return props.avatar ?? generateAvatarUrl(props.username);
},
);
const avatar = computed(() => {
return props.defaultUrl ?? generateAvatarUrl(props.username);
});
const username = computed(() => props.username);
const darkenColor = (color) => {
// Convert hex to RGB
const r = parseInt(color.slice(0, 2), 16);
const g = parseInt(color.slice(2, 4), 16);
const b = parseInt(color.slice(4, 6), 16);
// Calculate darker color by reducing 20% of each RGB component
const darkerR = Math.round(r * 0.6);
const darkerG = Math.round(g * 0.6);
const darkerB = Math.round(b * 0.6);
// Convert back to hex
const darkerColor = ((darkerR << 16) + (darkerG << 8) + darkerB).toString(16);
return darkerColor.padStart(6, '0'); // Ensure it's 6 digits
return darkerColor.padStart(6, '0');
};
const getRandomColor = () => {
return Math.floor(Math.random() * 16777215).toString(16);
};
const adjustOpacity = (hexColor, opacity) => {
// Remove # if present
hexColor = hexColor.replace('#', '');
// Convert hex to RGB
// const r = parseInt(hexColor.slice(0, 2), 16);
// const g = parseInt(hexColor.slice(2, 4), 16);
// const b = parseInt(hexColor.slice(4, 6), 16);
// const r = parseInt(hexColor.slice(1, 3), 16);
// const g = parseInt(hexColor.slice(3, 5), 16);
// const b = parseInt(hexColor.slice(5, 7), 16);
const [r, g, b] = hexColor.match(/\w\w/g).map(x => parseInt(x, 16));
return `rgba(${r},${g},${b},${opacity})`;
const getColorFromName = (name) => {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
let color = '#';
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff;
color += ('00' + value.toString(16)).substr(-2);
}
return color.replace('#', '');
};
const lightenColor = (hexColor, percent) => {
@ -88,21 +67,12 @@ const lightenColor = (hexColor, percent) => {
return lighterHex.padStart(6, '0');
};
// backgroundColor = '7F9CF5',
const generateAvatarUrl = (name) => {
const initials = name
.split(' ')
.map((part) => part.charAt(0).toUpperCase())
.join('');
const originalColor = getRandomColor();
const backgroundColor = lightenColor(originalColor, 60); // Lighten by 20%
const originalColor = getColorFromName(name);
const backgroundColor = lightenColor(originalColor, 60);
const textColor = darkenColor(originalColor);
// const avatarUrl = `https://ui-avatars.com/api/?name=${initials}&size=50&background=${backgroundColor}&color=${textColor}`;
const avatarUrl = `/api/avatar?name=${name}&size=50&background=${backgroundColor}&textColor=${textColor}`;
const avatarUrl = `/api/avatar?name=${name}&size=50`;
return avatarUrl;
};
</script>

View file

@ -1,12 +1,29 @@
<script setup>
import { computed } from 'vue';
// import { usePage } from '@inertiajs/vue3'
import { usePage } from '@inertiajs/vue3';
// import { computed } from 'vue';
// import { usePage } from '@inertiajs/vue3';
import UserAvatar from '@/Components/UserAvatar.vue';
const userName = computed(() => usePage().props.auth?.user.name);
defineProps({
user: {
type: Object,
required: true,
},
});
</script>
<template>
<UserAvatar v-bind:username="'userName'" api="initials" />
<div v-if="user.avatar">
<UserAvatar :default-url="user.avatar ? '/public' + user.avatar : ''"
:username="user.first_name + ' ' + user.last_name" class="w-24 h-24 mx-auto lg:w-6 lg:h-6" />
</div>
<div v-else>
<UserAvatar :username="user.first_name + ' ' + user.last_name" class="w-24 h-24 mx-auto lg:w-6 lg:h-6" />
</div>
</template>
<!-- <template v-else>
<UserAvatar :username="user.first_name + ' ' + user.last_name" class="w-24 h-24 mx-auto lg:w-6 lg:h-6" />
</template> -->

View file

@ -0,0 +1,30 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { colorsBgLight, colorsOutline } from '@/colors';
const props = defineProps({
on: Boolean,
icon: {
type: String,
default: null,
},
outline: Boolean,
color: {
type: String,
required: false,
default: 'info',
},
});
const componentClass = computed(() => (props.outline ? colorsOutline[props.color] : colorsBgLight[props.color]));
</script>
<template>
<div >
<transition leave-active-class="transition ease-in duration-1000" leave-from-class="opacity-100"
leave-to-class="opacity-0" :class="componentClass" class="px-3 py-2 last:mb-0 border rounded transition-colors duration-150 text-sm text-gray-600">
<div v-show="on">
<slot />
</div>
</transition>
</div>
</template>

View file

@ -0,0 +1,79 @@
<template>
<div class="relative inline-block overflow-hidden rounded-full">
<input type="file" ref="avatarInput" @change="onChangeFile" class="hidden" accept="image/*">
<img :src="avatarUrl" alt="Avatar" class="h-full w-full object-cover">
<div class="absolute top-0 h-full w-full bg-black bg-opacity-25 flex items-center justify-center">
<button @click.prevent="browse"
class="rounded-full hover:bg-white hover:bg-opacity-25 p-2 focus:outline-none text-white transition-colors duration-300">
<IconRounded :icon="mdiCameraEnhanceOutline" class="bg-transparent h-6 w-6" />
</button>
<button v-if="file" @click.prevent="reset"
class="rounded-full hover:bg-white hover:bg-opacity-25 p-2 focus:outline-none text-white transition-colors duration-300">
<IconRounded :icon="mdiAlphaXCircleOutline " class="bg-transparent h-6 w-6" />
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, defineProps, defineEmits } from 'vue';
import { mdiCameraEnhanceOutline, mdiAlphaXCircleOutline } from '@mdi/js';
import IconRounded from './IconRounded.vue';
const props = defineProps({
modelValue: File,
defaultSrc: {
type: String,
required: true,
},
});
// vue data properties
const file = ref<File | null>(null);
const avatarUrl = ref<string>(props.defaultSrc);
const avatarInput = ref<HTMLInputElement | null>(null);
// const avatarUrl = computed({
// get: () => props.modelValue ? props.modelValue : props.defaultSrc,
// set: (value: string) => {
// emit('update:modelValue', value);
// },
// });
const emit = defineEmits<{ (e: 'update:modelValue', file: File | null): void; (e: 'input', file: File | null): void }>();
// const avatarInput = ref<HTMLInputElement | null>(null);
const browse = () => {
avatarInput.value?.click();
};
const reset = () => {
file.value = null;
avatarUrl.value = props.defaultSrc;
emit('input', file.value);
};
const onChangeFile = (e: Event) => {
// const target = (<HTMLInputElement>e.target)
const target = e.target as HTMLInputElement;
if (target.files && target.files[0]) {
file.value = target.files[0];
}
if (file.value) {
emit('input', file.value);
emit('update:modelValue', file.value);
let reader = new FileReader();
reader.readAsDataURL(file.value);
reader.onload = (e) => {
avatarUrl.value = e.target?.result as string;
};
}
};
</script>

View file

@ -1,43 +1,42 @@
<script setup>
import { computed, ref } from 'vue'
import { MainService } from '@/Stores/main'
import { mdiCheckDecagram } from '@mdi/js'
import BaseLevel from '@/Components/BaseLevel.vue'
import UserAvatarCurrentUser from '@/Components/UserAvatarCurrentUser.vue'
import CardBox from '@/Components/CardBox.vue'
import FormCheckRadioGroup from '@/Components/FormCheckRadioGroup.vue'
import PillTag from '@/Components/PillTag.vue'
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { MainService } from '@/Stores/main';
import { mdiCheckDecagram } from '@mdi/js';
import BaseLevel from '@/Components/BaseLevel.vue';
import UserAvatarCurrentUser from '@/Components/UserAvatarCurrentUser.vue';
import CardBox from '@/Components/CardBox.vue';
import FormCheckRadioGroup from '@/Components/FormCheckRadioGroup.vue';
import PillTag from '@/Components/PillTag.vue';
import { usePage } from '@inertiajs/vue3';
import type { User } from '@/Dataset';
import type { ComputedRef } from 'vue';
const mainService = MainService()
const mainService = MainService();
const userName = computed(() => mainService.userName)
const userName = computed(() => mainService.userName);
const userSwitchVal = ref([])
const user: ComputedRef<User> = computed(() => {
return usePage().props.authUser as User;
});
const userSwitchVal = ref([]);
</script>
<template>
<CardBox>
<BaseLevel type="justify-around lg:justify-center">
<UserAvatarCurrentUser class="lg:mx-12" />
<UserAvatarCurrentUser :user="user" class="lg:mx-12" />
<div class="space-y-3 text-center md:text-left lg:mx-12">
<div class="flex justify-center md:block">
<FormCheckRadioGroup
v-model="userSwitchVal"
name="sample-switch"
type="switch"
:options="{ one: 'Notifications' }"
/>
<FormCheckRadioGroup v-model="userSwitchVal" name="sample-switch" type="switch"
:options="{ one: 'Notifications' }" />
</div>
<h1 class="text-2xl">
Howdy, <b>{{ userName }}</b>!
</h1>
<p>Last login <b>12 mins ago</b> from <b>127.0.0.1</b></p>
<div class="flex justify-center md:block">
<PillTag
text="Verified"
type="info"
:icon="mdiCheckDecagram"
/>
<PillTag text="Verified" type="info" :icon="mdiCheckDecagram" />
</div>
</div>
</BaseLevel>