feat: profile page
This commit is contained in:
2
frontend/src/components/Modals/EditCoverImage.vue
Normal file
2
frontend/src/components/Modals/EditCoverImage.vue
Normal file
@@ -0,0 +1,2 @@
|
||||
<template></template>
|
||||
<script setup></script>
|
||||
178
frontend/src/components/Modals/EditProfile.vue
Normal file
178
frontend/src/components/Modals/EditProfile.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:options="{
|
||||
title: 'Edit your profile',
|
||||
size: 'xl',
|
||||
actions: [
|
||||
{
|
||||
label: 'Save',
|
||||
variant: 'solid',
|
||||
onClick: (close) => saveProfile(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div>
|
||||
<FileUploader
|
||||
v-if="!profile.image"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||
<div class="mb-4">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{
|
||||
uploading
|
||||
? `Uploading ${progress}%`
|
||||
: 'Upload a profile image'
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="text-xs text-gray-600 mb-1">
|
||||
{{ __('Profile Image') }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
||||
</div>
|
||||
<div class="text-base flex flex-col">
|
||||
<span>
|
||||
{{ profile.image.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500 mt-1">
|
||||
{{ getFileSize(profile.image.file_size) }}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
@click="removeImage()"
|
||||
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="profile.first_name"
|
||||
:label="__('First Name')"
|
||||
class="mb-4"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="profile.last_name"
|
||||
:label="__('Last Name')"
|
||||
class="mb-4"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="profile.headline"
|
||||
:label="__('Headline')"
|
||||
class="mb-4"
|
||||
/>
|
||||
<FormControl type="textarea" v-model="profile.bio" :label="__('Bio')" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Dialog,
|
||||
FormControl,
|
||||
FileUploader,
|
||||
Button,
|
||||
createResource,
|
||||
} from 'frappe-ui'
|
||||
import { reactive, watch, defineModel } from 'vue'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
import { getFileSize, showToast } from '@/utils'
|
||||
|
||||
const reloadProfile = defineModel('reloadProfile')
|
||||
|
||||
const props = defineProps({
|
||||
profile: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const profile = reactive({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
headline: '',
|
||||
bio: '',
|
||||
image: '',
|
||||
})
|
||||
|
||||
const imageResource = createResource({
|
||||
url: 'lms.lms.api.get_file_info',
|
||||
makeParams(values) {
|
||||
return {
|
||||
file_url: values.image,
|
||||
}
|
||||
},
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
profile.image = data
|
||||
},
|
||||
})
|
||||
|
||||
const updateProfile = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'User',
|
||||
name: props.profile.data.name,
|
||||
fieldname: {
|
||||
user_image: profile.image.file_url,
|
||||
...profile,
|
||||
},
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
props.profile.data = data
|
||||
},
|
||||
})
|
||||
|
||||
const saveProfile = (close) => {
|
||||
updateProfile.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess() {
|
||||
close()
|
||||
reloadProfile.value.reload()
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const validateFile = (file) => {
|
||||
let extension = file.name.split('.').pop().toLowerCase()
|
||||
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
||||
return 'Only image file is allowed.'
|
||||
}
|
||||
}
|
||||
|
||||
const saveImage = (file) => {
|
||||
profile.image = file
|
||||
}
|
||||
|
||||
const removeImage = () => {
|
||||
profile.image = null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.profile.data,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
profile.first_name = newVal.first_name
|
||||
profile.last_name = newVal.last_name
|
||||
profile.headline = newVal.headline
|
||||
profile.bio = newVal.bio
|
||||
if (newVal.user_image) imageResource.submit({ image: newVal.user_image })
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -46,7 +46,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm italic text-red-600">
|
||||
<div
|
||||
v-else-if="evaluation.course && evaluation.date"
|
||||
class="text-sm italic text-red-600"
|
||||
>
|
||||
{{ __('No slots available for this date.') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
42
frontend/src/components/NoPermission.vue
Normal file
42
frontend/src/components/NoPermission.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="border rounded-md w-1/3 mx-auto my-32">
|
||||
<div class="border-b px-5 py-3 font-medium">
|
||||
<span
|
||||
class="inline-flex items-center before:bg-red-600 before:w-2 before:h-2 before:rounded-md before:mr-2"
|
||||
></span>
|
||||
{{ __('Not Permitted') }}
|
||||
</div>
|
||||
<div v-if="user.data" class="px-5 py-3">
|
||||
<div>
|
||||
{{ __('You do not have permission to access this page.') }}
|
||||
</div>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Courses',
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" class="mt-2">
|
||||
{{ __('Checkout Courses') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="px-5 py-3">
|
||||
<div>
|
||||
{{ __('Please login to access this page.') }}
|
||||
</div>
|
||||
<Button variant="solid" @click="redirectToLogin()" class="mt-2">
|
||||
{{ __('Login') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { inject } from 'vue'
|
||||
import { Button } from 'frappe-ui'
|
||||
|
||||
const user = inject('$user')
|
||||
|
||||
const redirectToLogin = () => {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
</script>
|
||||
90
frontend/src/components/UnsplashImageBrowser.vue
Normal file
90
frontend/src/components/UnsplashImageBrowser.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<Popover transition="default">
|
||||
<template #target="{ isOpen, togglePopover }" class="flex w-full">
|
||||
<slot v-bind="{ isOpen, togglePopover }"></slot>
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
class="absolute left-1/2 mt-3 max-w-sm -translate-x-1/2 transform rounded-lg bg-white px-4 sm:px-0 lg:max-w-3xl"
|
||||
>
|
||||
<div
|
||||
class="overflow-hidden rounded-lg p-3 shadow-2xl ring-1 ring-black ring-opacity-5"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex-1">
|
||||
<TextInput
|
||||
type="text"
|
||||
placeholder="search by keyword"
|
||||
v-model="search"
|
||||
:debounce="300"
|
||||
/>
|
||||
</div>
|
||||
<FileUploader @success="(file) => $emit('select', file.file_url)">
|
||||
<template
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
>
|
||||
<div class="w-full text-center">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{ uploading ? `Uploading ${progress}%` : 'Upload Image' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
</div>
|
||||
<div
|
||||
class="relative mt-2 grid w-[25.5rem] gap-2 bg-white lg:grid-cols-2"
|
||||
>
|
||||
<Button
|
||||
v-for="image in $resources.images.data"
|
||||
:key="image.id"
|
||||
class="h-[50px] w-[200px] overflow-hidden rounded hover:opacity-80"
|
||||
@click="$emit('select', image.urls.raw)"
|
||||
>
|
||||
<img
|
||||
:src="
|
||||
image.urls.raw +
|
||||
'&w=200&h=50&fit=crop&crop=entropy,faces,focalpoint'
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mt-2 text-center text-sm text-gray-500">
|
||||
{{ __('Image search powered by') }}
|
||||
<a class="underline" target="_blank" href="https://unsplash.com">
|
||||
{{ __('Unsplash') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
|
||||
import { Popover, FileUploader, Button } from 'frappe-ui'
|
||||
|
||||
export default {
|
||||
name: 'UnsplashImageBrowser',
|
||||
components: {
|
||||
Popover,
|
||||
FileUploader,
|
||||
},
|
||||
emits: ['select'],
|
||||
resources: {
|
||||
images() {
|
||||
return {
|
||||
url: 'gameplan.api.get_unsplash_photos',
|
||||
params: { keyword: this.search },
|
||||
auto: true,
|
||||
debounce: 500,
|
||||
}
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
search: '',
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -31,8 +31,11 @@
|
||||
</span>
|
||||
<span v-else> Learning </span>
|
||||
</div>
|
||||
<div v-if="user" class="mt-1 text-sm text-gray-700 leading-none">
|
||||
{{ convertToTitleCase(user.split('@')[0]) }}
|
||||
<div
|
||||
v-if="userResource"
|
||||
class="mt-1 text-sm text-gray-700 leading-none"
|
||||
>
|
||||
{{ convertToTitleCase(userResource.data?.email.split('@')[0]) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -57,7 +60,8 @@ import { Dropdown, createResource } from 'frappe-ui'
|
||||
import { ChevronDown, LogIn, LogOut, User } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { convertToTitleCase } from '../utils'
|
||||
import { onMounted } from 'vue'
|
||||
import { onMounted, inject } from 'vue'
|
||||
import { usersStore } from '@/stores/user'
|
||||
|
||||
const router = useRouter()
|
||||
const props = defineProps({
|
||||
@@ -76,19 +80,21 @@ const branding = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const { logout, user } = sessionStore()
|
||||
const { logout } = sessionStore()
|
||||
let { userResource } = usersStore()
|
||||
|
||||
let { isLoggedIn } = sessionStore()
|
||||
const userDropdownOptions = [
|
||||
/* {
|
||||
{
|
||||
icon: User,
|
||||
label: 'My Profile',
|
||||
onClick: () => {
|
||||
router.push(`/user/${user.data?.username}`)
|
||||
router.push(`/user/${userResource.data?.username}`)
|
||||
},
|
||||
condition: () => {
|
||||
return isLoggedIn
|
||||
},
|
||||
}, */
|
||||
},
|
||||
{
|
||||
icon: LogOut,
|
||||
label: 'Log out',
|
||||
|
||||
@@ -1 +1,171 @@
|
||||
<template></template>
|
||||
<template>
|
||||
<NoPermission v-if="!$user.data" />
|
||||
<div v-else-if="profile.data">
|
||||
<header
|
||||
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
</header>
|
||||
<div class="relative">
|
||||
<img
|
||||
v-if="profile.data.cover_image"
|
||||
:src="profile.data.cover_image"
|
||||
class="h-[130px] w-full"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
:class="{ 'bg-gray-50': !profile.data.cover_image }"
|
||||
class="h-[130px] w-full"
|
||||
></div>
|
||||
<div class="absolute top-0 right-0" v-if="isSessionUser()">
|
||||
<Button variant="outline">
|
||||
<template #icon>
|
||||
<Edit class="w-4 h-4 stroke-1.5 text-gray-700" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mx-auto -mt-4 max-w-4xl translate-x-0 sm:px-5">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<img
|
||||
v-if="profile.data.user_image"
|
||||
:src="profile.data.user_image"
|
||||
class="object-cover h-[100px] w-[100px] rounded-full border-4 border-white object-cover"
|
||||
/>
|
||||
<UserAvatar
|
||||
v-else
|
||||
:user="profile.data"
|
||||
class="object-cover h-[100px] w-[100px] rounded-full border-4 border-white object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-6">
|
||||
<h2 class="mt-2 text-3xl font-semibold text-gray-900">
|
||||
{{ profile.data.full_name }}
|
||||
</h2>
|
||||
<div class="mt-2 text-base text-gray-700">
|
||||
{{ profile.data.headline }}
|
||||
</div>
|
||||
</div>
|
||||
<Button v-if="isSessionUser()" class="ml-auto" @click="editProfile()">
|
||||
<template #prefix>
|
||||
<Edit class="w-4 h-4 stroke-1.5 text-gray-700" />
|
||||
</template>
|
||||
{{ __('Edit Profile') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 mt-6">
|
||||
<TabButtons
|
||||
class="inline-block"
|
||||
:buttons="getTabButtons()"
|
||||
v-model="activeTab"
|
||||
/>
|
||||
</div>
|
||||
<router-view :profile="profile" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EditProfile
|
||||
v-model="showProfileModal"
|
||||
v-model:reloadProfile="profile"
|
||||
:profile="profile"
|
||||
/>
|
||||
<EditCoverImage />
|
||||
</template>
|
||||
<script setup>
|
||||
import { Breadcrumbs, createResource, Button, TabButtons } from 'frappe-ui'
|
||||
import { computed, inject, reactive, ref, onMounted, watchEffect } from 'vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { Edit } from 'lucide-vue-next'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import NoPermission from '@/components/NoPermission.vue'
|
||||
import { convertToTitleCase } from '@/utils'
|
||||
import EditProfile from '@/components/Modals/EditProfile.vue'
|
||||
import EditCoverImage from '@/components/Modals/EditCoverImage.vue'
|
||||
|
||||
const { user } = sessionStore()
|
||||
const $user = inject('$user')
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const activeTab = ref('')
|
||||
const showProfileModal = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
username: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if ($user.data) profile.reload()
|
||||
|
||||
setActiveTab()
|
||||
})
|
||||
|
||||
const profile = createResource({
|
||||
url: 'frappe.client.get',
|
||||
params: {
|
||||
doctype: 'User',
|
||||
filters: {
|
||||
username: props.username,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const setActiveTab = () => {
|
||||
let fragments = route.path.split('/')
|
||||
let sections = ['certificates', 'settings']
|
||||
sections.forEach((section) => {
|
||||
if (fragments.includes(section)) {
|
||||
activeTab.value = convertToTitleCase(section)
|
||||
}
|
||||
})
|
||||
if (!activeTab.value) activeTab.value = 'About'
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (activeTab.value) {
|
||||
let route = {
|
||||
About: { name: 'ProfileAbout' },
|
||||
Certificates: { name: 'ProfileCertificates' },
|
||||
Settings: { name: 'ProfileSettings' },
|
||||
}[activeTab.value]
|
||||
router.push(route)
|
||||
}
|
||||
})
|
||||
|
||||
const editProfile = () => {
|
||||
showProfileModal.value = true
|
||||
}
|
||||
|
||||
const isSessionUser = () => {
|
||||
return $user.data?.email === profile.data?.email
|
||||
}
|
||||
|
||||
const getTabButtons = () => {
|
||||
let buttons = [{ label: 'About' }, { label: 'Certificates' }]
|
||||
if ($user.data?.is_moderator) buttons.push({ label: 'Settings' })
|
||||
|
||||
return buttons
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
label: 'People',
|
||||
},
|
||||
{
|
||||
label: profile.data?.full_name,
|
||||
route: {
|
||||
name: 'Profile',
|
||||
params: {
|
||||
username: user.doc?.username,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
return crumbs
|
||||
})
|
||||
</script>
|
||||
|
||||
23
frontend/src/pages/ProfileAbout.vue
Normal file
23
frontend/src/pages/ProfileAbout.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="mt-7">
|
||||
<h2 class="mb-3 text-lg font-semibold text-gray-900">
|
||||
{{ __('About') }}
|
||||
</h2>
|
||||
<div
|
||||
v-if="profile.data.bio"
|
||||
v-html="profile.data.bio"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal"
|
||||
></div>
|
||||
<div v-else class="text-gray-700 text-sm italic">
|
||||
{{ __('No introduction') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
profile: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
46
frontend/src/pages/ProfileCertificates.vue
Normal file
46
frontend/src/pages/ProfileCertificates.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="mt-7">
|
||||
<h2 class="mb-3 text-lg font-semibold text-gray-900">
|
||||
{{ __('Certificates') }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="certificate in certificates.data"
|
||||
:key="certificate.name"
|
||||
class="bg-white shadow rounded-lg p-3 cursor-pointer"
|
||||
>
|
||||
<div class="font-medium">
|
||||
{{ certificate.course_title }}
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<span class="text-xs text-gray-700"> {{ __('issued on') }}: </span>
|
||||
{{ dayjs(certificate.issue_date).format('DD MMM YYYY') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource } from 'frappe-ui'
|
||||
import { inject } from 'vue'
|
||||
|
||||
const dayjs = inject('$dayjs')
|
||||
const props = defineProps({
|
||||
profile: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const certificates = createResource({
|
||||
url: 'frappe.client.get_list',
|
||||
params: {
|
||||
doctype: 'LMS Certificate',
|
||||
fields: ['name', 'course', 'course_title', 'issue_date'],
|
||||
filters: {
|
||||
member: props.profile.data.name,
|
||||
},
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
</script>
|
||||
1
frontend/src/pages/ProfileSettings.vue
Normal file
1
frontend/src/pages/ProfileSettings.vue
Normal file
@@ -0,0 +1 @@
|
||||
<template></template>
|
||||
@@ -56,10 +56,28 @@ const routes = [
|
||||
component: () => import('@/pages/Statistics.vue'),
|
||||
},
|
||||
{
|
||||
path: '/user/:userName',
|
||||
path: '/user/:username',
|
||||
name: 'Profile',
|
||||
component: () => import('@/pages/Profile.vue'),
|
||||
props: true,
|
||||
redirect: { name: 'ProfileAbout' },
|
||||
children: [
|
||||
{
|
||||
name: 'ProfileAbout',
|
||||
path: '',
|
||||
component: () => import('@/pages/ProfileAbout.vue'),
|
||||
},
|
||||
{
|
||||
name: 'ProfileCertificates',
|
||||
path: 'certificates',
|
||||
component: () => import('@/pages/ProfileCertificates.vue'),
|
||||
},
|
||||
{
|
||||
name: 'ProfileSettings',
|
||||
path: 'settings',
|
||||
component: () => import('@/pages/ProfileSettings.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/job-openings',
|
||||
|
||||
@@ -85,6 +85,17 @@ export function showToast(title, text, icon) {
|
||||
})
|
||||
}
|
||||
|
||||
export function getImgDimensions(imgSrc) {
|
||||
return new Promise((resolve) => {
|
||||
let img = new Image()
|
||||
img.onload = function () {
|
||||
let { width, height } = img
|
||||
resolve({ width, height, ratio: width / height })
|
||||
}
|
||||
img.src = imgSrc
|
||||
})
|
||||
}
|
||||
|
||||
export function updateDocumentTitle(meta) {
|
||||
watch(
|
||||
() => meta,
|
||||
|
||||
2062
frontend/yarn.lock
2062
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user