kopia lustrzana https://github.com/elk-zone/elk
				
				
				
			feat: added a profile settings and settings nav (#432)
							rodzic
							
								
									c8a7e6e7e7
								
							
						
					
					
						commit
						613c5315b3
					
				|  | @ -62,6 +62,8 @@ watchEffect(() => { | |||
|   namedFields.value = named | ||||
|   iconFields.value = icons | ||||
| }) | ||||
| 
 | ||||
| const isSelf = $computed(() => currentUser.value?.account.id === account.id) | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|  | @ -91,6 +93,15 @@ watchEffect(() => { | |||
|         <div absolute top-18 right-0 flex gap-2 items-center> | ||||
|           <AccountMoreButton :account="account" :command="command" /> | ||||
|           <AccountFollowButton :account="account" :command="command" /> | ||||
|           <!-- Edit profile --> | ||||
|           <NuxtLink | ||||
|             v-if="isSelf" | ||||
|             to="/settings/profile/appearance" | ||||
|             gap-1 items-center border="1" rounded-full flex="~ gap2 center" font-500 w-30 h-fit py1 | ||||
|             hover="border-primary text-primary bg-active" | ||||
|           > | ||||
|             {{ $t('settings.profile.appearance.title') }} | ||||
|           </NuxtLink> | ||||
|           <!-- <button flex gap-1 items-center w-full rounded op75 hover="op100 text-purple" group> | ||||
|             <div rounded p2 group-hover="bg-rose/10"> | ||||
|               <div i-ri:bell-line /> | ||||
|  |  | |||
|  | @ -0,0 +1,109 @@ | |||
| <script lang="ts" setup> | ||||
| import type { Boundaries } from 'vue-advanced-cropper' | ||||
| import { Cropper } from 'vue-advanced-cropper' | ||||
| import 'vue-advanced-cropper/dist/style.css' | ||||
| 
 | ||||
| export interface Props { | ||||
|   /** Images to be cropped */ | ||||
|   modelValue?: File | ||||
|   /** Crop frame aspect ratio (width/height), default 1/1 */ | ||||
|   stencilAspectRatio?: number | ||||
|   /** The ratio of the longest edge of the cut box to the length of the cut screen, default 0.9, not more than 1 */ | ||||
|   stencilSizePercentage?: number | ||||
| } | ||||
| const props = withDefaults(defineProps<Props>(), { | ||||
|   stencilAspectRatio: 1 / 1, | ||||
|   stencilSizePercentage: 0.9, | ||||
| }) | ||||
| 
 | ||||
| const emits = defineEmits<{ | ||||
|   (event: 'update:modelValue', value: File): void | ||||
| }>() | ||||
| 
 | ||||
| const vmFile = useVModel(props, 'modelValue', emits, { passive: true }) | ||||
| 
 | ||||
| const cropperDialog = ref(false) | ||||
| 
 | ||||
| const cropper = ref<InstanceType<typeof Cropper>>() | ||||
| 
 | ||||
| const cropperFlag = ref(false) | ||||
| 
 | ||||
| const cropperImage = reactive({ | ||||
|   src: '', | ||||
|   type: 'image/jpg', | ||||
| }) | ||||
| 
 | ||||
| const stencilSize = ({ boundaries }: { boundaries: Boundaries }) => { | ||||
|   return { | ||||
|     width: boundaries.width * props.stencilSizePercentage, | ||||
|     height: boundaries.height * props.stencilSizePercentage, | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| watch(vmFile, (file, _, onCleanup) => { | ||||
|   let expired = false | ||||
|   onCleanup(() => expired = true) | ||||
| 
 | ||||
|   if (file && !cropperFlag.value) { | ||||
|     cropperDialog.value = true | ||||
|     const reader = new FileReader() | ||||
|     reader.readAsDataURL(file) | ||||
|     reader.onload = (e) => { | ||||
|       if (expired) | ||||
|         return | ||||
|       cropperImage.src = e.target?.result as string | ||||
|       cropperImage.type = file.type | ||||
|     } | ||||
|   } | ||||
|   cropperFlag.value = false | ||||
| }) | ||||
| 
 | ||||
| const cropImage = () => { | ||||
|   if (cropper.value && vmFile.value) { | ||||
|     cropperFlag.value = true | ||||
|     cropperDialog.value = false | ||||
|     const { canvas } = cropper.value.getResult() | ||||
|     canvas?.toBlob((blob) => { | ||||
|       vmFile.value = new File([blob as any], `cropped${vmFile.value?.name}` as string, { type: blob?.type }) | ||||
|     }, cropperImage.type) | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <ModalDialog v-model="cropperDialog" :use-v-if="false" max-w-500px flex> | ||||
|     <div flex-1 w-0> | ||||
|       <div text-lg text-center my2 px3> | ||||
|         <h1> | ||||
|           {{ $t('action.edit') }} | ||||
|         </h1> | ||||
|       </div> | ||||
|       <div aspect-ratio-1> | ||||
|         <Cropper | ||||
|           ref="cropper" | ||||
|           class="overflow-hidden w-full h-full" | ||||
|           :src="cropperImage.src" | ||||
|           :resize-image="{ | ||||
|             adjustStencil: false, | ||||
|           }" | ||||
|           :stencil-size="stencilSize" | ||||
|           :stencil-props="{ | ||||
|             aspectRatio: props.stencilAspectRatio, | ||||
|             movable: false, | ||||
|             resizable: false, | ||||
|             handlers: {}, | ||||
|           }" | ||||
|           image-restriction="stencil" | ||||
|         /> | ||||
|       </div> | ||||
|       <div m-4> | ||||
|         <button | ||||
|           btn-solid w-full rounded text-sm | ||||
|           @click="cropImage()" | ||||
|         > | ||||
|           {{ $t('action.confirm') }} | ||||
|         </button> | ||||
|       </div> | ||||
|     </div> | ||||
|   </ModalDialog> | ||||
| </template> | ||||
|  | @ -0,0 +1,121 @@ | |||
| <script lang="ts" setup> | ||||
| const props = withDefaults(defineProps<{ | ||||
|   modelValue?: File | ||||
|   /** The image src before change */ | ||||
|   original?: string | ||||
|   /** Allowed file types */ | ||||
|   allowedFileTypes?: string[] | ||||
|   /** Allowed file size */ | ||||
|   allowedFileSize?: number | ||||
| 
 | ||||
|   imgClass?: string | ||||
| 
 | ||||
|   loading?: boolean | ||||
| }>(), { | ||||
|   allowedFileTypes: () => ['image/jpeg', 'image/png'], | ||||
|   allowedFileSize: 1024 * 1024 * 5, // 5 MB | ||||
| }) | ||||
| const emits = defineEmits<{ | ||||
|   (event: 'update:modelValue', value: File): void | ||||
|   (event: 'error', code: number, message: string): void | ||||
| }>() | ||||
| 
 | ||||
| const vmFile = useVModel(props, 'modelValue', emits, { passive: true }) | ||||
| 
 | ||||
| const { t } = useI18n() | ||||
| 
 | ||||
| const elInput = ref<HTMLInputElement>() | ||||
| 
 | ||||
| function clearInput() { | ||||
|   if (elInput.value) | ||||
|     elInput.value.value = '' | ||||
| } | ||||
| 
 | ||||
| function selectImage(e: Event) { | ||||
|   const target = e.target as HTMLInputElement | ||||
|   const image = target.files?.[0] | ||||
|   if (!image) { | ||||
|     vmFile.value = image | ||||
|   } | ||||
|   else if (!props.allowedFileTypes.includes(image.type)) { | ||||
|     emits('error', 1, t('error.unsupported_file_format')) | ||||
|     clearInput() | ||||
|   } | ||||
|   else if (image.size > props.allowedFileSize) { | ||||
|     emits('error', 2, t('error.file_size_cannot_exceed_n_mb', [5])) | ||||
|     clearInput() | ||||
|   } | ||||
|   else { | ||||
|     vmFile.value = image | ||||
|     clearInput() | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| const defaultImage = computed(() => props.original || '') | ||||
| /** Preview of selected images */ | ||||
| const previewImage = ref('') | ||||
| /** The current images on display */ | ||||
| const imageSrc = computed<string>(() => previewImage.value || defaultImage.value) | ||||
| 
 | ||||
| // Update the preview image when the input file change | ||||
| watch(vmFile, (image, _, onCleanup) => { | ||||
|   let expired = false | ||||
|   onCleanup(() => expired = true) | ||||
| 
 | ||||
|   if (image) { | ||||
|     const reader = new FileReader() | ||||
|     reader.readAsDataURL(image) | ||||
|     reader.onload = (e) => { | ||||
|       if (expired) | ||||
|         return | ||||
|       previewImage.value = e.target?.result as string | ||||
|     } | ||||
|   } | ||||
|   else { | ||||
|     previewImage.value = '' | ||||
|     clearInput() | ||||
|   } | ||||
| }) | ||||
| 
 | ||||
| defineExpose({ | ||||
|   clearInput, | ||||
| }) | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <label | ||||
|     class="bg-slate-500/10 focus-within:(outline outline-primary)" | ||||
|     relative | ||||
|     flex justify-center items-center | ||||
|     cursor-pointer | ||||
|     of-hidden | ||||
|   > | ||||
|     <img | ||||
|       v-if="imageSrc" | ||||
|       :src="imageSrc" | ||||
|       :class="imgClass || ''" | ||||
|       object-cover | ||||
|       w-full | ||||
|       h-full | ||||
|     > | ||||
|     <div absolute bg="black/50" text-white rounded-full text-xl w12 h12 flex justify-center items-center hover="bg-black/40 text-primary"> | ||||
|       <div i-ri:upload-line /> | ||||
|     </div> | ||||
| 
 | ||||
|     <div | ||||
|       v-if="loading" | ||||
|       absolute inset-0 | ||||
|       bg="black/30" text-white | ||||
|       flex justify-center items-center | ||||
|     > | ||||
|       <div class="i-ri:loader-4-line animate-spin animate-duration-[2.5s]" text-4xl /> | ||||
|     </div> | ||||
|     <input | ||||
|       ref="elInput" | ||||
|       type="file" | ||||
|       absolute opacity-0 inset-0 z--1 | ||||
|       :accept="allowedFileTypes.join(',')" | ||||
|       @change="selectImage" | ||||
|     > | ||||
|   </label> | ||||
| </template> | ||||
|  | @ -21,5 +21,6 @@ const { notifications } = useNotifications() | |||
|     <NavSideItem :text="$t('nav_side.conversations')" to="/conversations" icon="i-ri:at-line" user-only /> | ||||
|     <NavSideItem :text="$t('nav_side.favourites')" to="/favourites" icon="i-ri:heart-3-line" user-only /> | ||||
|     <NavSideItem :text="$t('nav_side.bookmarks')" to="/bookmarks" icon="i-ri:bookmark-line " user-only /> | ||||
|     <NavSideItem :text="$t('nav_side.settings')" to="/settings" icon="i-ri:settings-4-line " user-only /> | ||||
|   </nav> | ||||
| </template> | ||||
|  |  | |||
|  | @ -0,0 +1,60 @@ | |||
| <script lang="ts" setup> | ||||
| const props = defineProps<{ | ||||
|   text?: string | ||||
|   icon?: string | ||||
|   to: string | Record<string, string> | ||||
|   command?: boolean | ||||
| }>() | ||||
| 
 | ||||
| const router = useRouter() | ||||
| 
 | ||||
| if (props.command) { | ||||
|   useCommand({ | ||||
|     scope: 'Settings', | ||||
| 
 | ||||
|     name: () => props.text ?? (typeof props.to === 'string' ? props.to as string : props.to.name), | ||||
|     icon: () => props.icon || '', | ||||
| 
 | ||||
|     onActivate() { | ||||
|       router.push(props.to) | ||||
|     }, | ||||
|   }) | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <NuxtLink | ||||
|     :to="to" | ||||
|     exact-active-class="text-primary" | ||||
|     block w-full group focus:outline-none | ||||
|     @click="$scrollToTop" | ||||
|   > | ||||
|     <div | ||||
|       w-full flex w-fit px5 py3 md:gap2 gap4 items-center | ||||
|       transition-250 group-hover:bg-active | ||||
|       group-focus-visible:ring="2 current" | ||||
|     > | ||||
|       <div flex-1 flex items-center md:gap2 gap4> | ||||
|         <div | ||||
|           flex items-center justify-center | ||||
|           :class="$slots.description ? 'w-12 h-12' : ''" | ||||
|         > | ||||
|           <slot name="icon"> | ||||
|             <div v-if="icon" :class="icon" md:text-size-inherit text-xl /> | ||||
|           </slot> | ||||
|         </div> | ||||
|         <div space-y-1> | ||||
|           <p> | ||||
|             <slot> | ||||
|               <span>{{ text }}</span> | ||||
|             </slot> | ||||
|           </p> | ||||
|           <p v-if="$slots.description" text-sm text-secondary> | ||||
|             <slot name="description" /> | ||||
|           </p> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div i-ri:arrow-right-s-line text-xl text-secondary-light /> | ||||
|     </div> | ||||
|   </NuxtLink> | ||||
| </template> | ||||
|  | @ -44,6 +44,13 @@ const switchUser = (user: UserLogin) => { | |||
|         icon="i-ri:user-add-line" | ||||
|         @click="openSigninDialog" | ||||
|       /> | ||||
| 
 | ||||
|       <NuxtLink to="/settings"> | ||||
|         <CommonDropdownItem | ||||
|           :text="$t('nav_side.settings')" | ||||
|           icon="i-ri:settings-4-line" | ||||
|         /> | ||||
|       </NuxtLink> | ||||
|       <CommonDropdownItem | ||||
|         v-if="isMastoInitialised && currentUser" | ||||
|         :text="$t('user.sign_out_account', [getFullHandle(currentUser.account)])" | ||||
|  |  | |||
|  | @ -14,6 +14,7 @@ const scopes = [ | |||
|   'Account', | ||||
|   'Languages', | ||||
|   'Switch account', | ||||
|   'Settings', | ||||
| ] as const | ||||
| 
 | ||||
| export type CommandScopeNames = typeof scopes[number] | ||||
|  |  | |||
|  | @ -103,6 +103,24 @@ async function loginTo(user?: Omit<UserLogin, 'account'> & { account?: AccountCr | |||
|   return masto | ||||
| } | ||||
| 
 | ||||
| export function setAccountInfo(userId: string, account: AccountCredentials) { | ||||
|   const index = getUsersIndexByUserId(userId) | ||||
|   if (index === -1) | ||||
|     return false | ||||
| 
 | ||||
|   users.value[index].account = account | ||||
|   return true | ||||
| } | ||||
| 
 | ||||
| export async function pullMyAccountInfo() { | ||||
|   const me = await useMasto().accounts.verifyCredentials() | ||||
|   setAccountInfo(currentUserId.value, me) | ||||
| } | ||||
| 
 | ||||
| export function getUsersIndexByUserId(userId: string) { | ||||
|   return users.value.findIndex(u => u.account?.id === userId) | ||||
| } | ||||
| 
 | ||||
| export async function removePushNotificationData(user: UserLogin, fromSWPushManager = true) { | ||||
|   // clear push subscription
 | ||||
|   user.pushSubscription = undefined | ||||
|  |  | |||
|  | @ -31,6 +31,8 @@ export const useIsMac = () => computed(() => | |||
|   useRequestHeaders(['user-agent'])['user-agent']?.includes('Macintosh') | ||||
|     ?? navigator?.platform?.includes('Mac') ?? false) | ||||
| 
 | ||||
| export const isEmptyObject = (object: Object) => Object.keys(object).length === 0 | ||||
| 
 | ||||
| export function removeHTMLTags(str: string) { | ||||
|   return str.replaceAll(HTMLTagRE, '') | ||||
| } | ||||
|  |  | |||
|  | @ -9,4 +9,3 @@ async function run() { | |||
| } | ||||
| 
 | ||||
| run() | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,3 +1,9 @@ | |||
| <script lang="ts" setup> | ||||
| const route = useRoute() | ||||
| 
 | ||||
| const wideLayout = computed(() => route.meta.wideLayout ?? false) | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <div h-full :class="{ zen: isZenMode }"> | ||||
|     <div v-if="isHydrated.value && showUserSwitcherSidebar" fixed h-full hidden lg:block bg-code border-r-1 border-base> | ||||
|  | @ -39,7 +45,7 @@ | |||
|           </slot> | ||||
|         </div> | ||||
|       </aside> | ||||
|       <div class="w-full sm:w-600px min-h-screen sm:border-l sm:border-r border-base"> | ||||
|       <div class="w-full min-h-screen" :class="wideLayout ? 'lg:w-full sm:w-600px' : 'sm:w-600px'" sm:border-l sm:border-r border-base> | ||||
|         <div min-h="[calc(100vh-3.5rem)]" sm:min-h-screen> | ||||
|           <slot /> | ||||
|         </div> | ||||
|  | @ -48,7 +54,7 @@ | |||
|           <NavBottom v-if="isHydrated.value" /> | ||||
|         </div> | ||||
|       </div> | ||||
|       <aside class="hidden sm:none lg:block w-1/4 zen-hide"> | ||||
|       <aside v-if="!wideLayout" class="hidden sm:none lg:block w-1/4 zen-hide"> | ||||
|         <div sticky top-0 h-screen flex="~ col" py3> | ||||
|           <slot name="right"> | ||||
|             <SearchWidget /> | ||||
|  |  | |||
|  | @ -46,6 +46,7 @@ | |||
|     "clear_upload_failed": "Clear file upload errors", | ||||
|     "close": "Close", | ||||
|     "compose": "Compose", | ||||
|     "confirm": "Confirm", | ||||
|     "edit": "Edit", | ||||
|     "enter_app": "Enter App", | ||||
|     "favourite": "Favourite", | ||||
|  | @ -55,6 +56,7 @@ | |||
|     "prev": "Prev", | ||||
|     "publish": "Publish!", | ||||
|     "reply": "Reply", | ||||
|     "save": "Save", | ||||
|     "save_changes": "Save changes", | ||||
|     "sign_in": "Sign in", | ||||
|     "switch_account": "Switch account", | ||||
|  | @ -95,8 +97,10 @@ | |||
|   "error": { | ||||
|     "account_not_found": "Account {0} not found", | ||||
|     "explore-list-empty": "Nothing is trending right now. Check back later!", | ||||
|     "file_size_cannot_exceed_n_mb": "File size cannot exceed {0}MB", | ||||
|     "sign_in_error": "Cannot connect to the server.", | ||||
|     "status_not_found": "Post not found" | ||||
|     "status_not_found": "Post not found", | ||||
|     "unsupported_file_format": "Unsupported file format" | ||||
|   }, | ||||
|   "feature_flag": { | ||||
|     "avatar_on_avatar": "Avatar on Avatar", | ||||
|  | @ -153,7 +157,8 @@ | |||
|     "local": "Local", | ||||
|     "notifications": "Notifications", | ||||
|     "profile": "Profile", | ||||
|     "search": "Search {0}" | ||||
|     "search": "Search {0}", | ||||
|     "settings": "Settings" | ||||
|   }, | ||||
|   "nav_user": { | ||||
|     "sign_in_desc": "Sign in to follow profiles or hashtags, favourite, share and reply to posts, or interact from your account on a different server." | ||||
|  | @ -219,6 +224,26 @@ | |||
|   "search": { | ||||
|     "search_desc": "Search for people & hashtags" | ||||
|   }, | ||||
|   "settings": { | ||||
|     "preferences": { | ||||
|       "label": "Preferences" | ||||
|     }, | ||||
|     "profile": { | ||||
|       "appearance": { | ||||
|         "bio": "Bio", | ||||
|         "description": "Edit avatar, username, profile, etc.", | ||||
|         "display_name": "Display name", | ||||
|         "label": "Appearance", | ||||
|         "title": "Edit profile" | ||||
|       }, | ||||
|       "featured_tags": { | ||||
|         "description": "People can browse your public posts under these hashtags.", | ||||
|         "label": "Featured hashtags" | ||||
|       }, | ||||
|       "label": "Profile" | ||||
|     }, | ||||
|     "select_a_settings": "Select a settings" | ||||
|   }, | ||||
|   "state": { | ||||
|     "edited": "(Edited)", | ||||
|     "editing": "Editing", | ||||
|  |  | |||
|  | @ -37,6 +37,8 @@ | |||
|     "boosted": "已转发", | ||||
|     "close": "关闭", | ||||
|     "compose": "撰写", | ||||
|     "confirm": "确认", | ||||
|     "edit": "编辑", | ||||
|     "enter_app": "进入应用", | ||||
|     "favourite": "喜欢", | ||||
|     "favourited": "已喜欢", | ||||
|  | @ -45,6 +47,7 @@ | |||
|     "prev": "上一个", | ||||
|     "publish": "发布!", | ||||
|     "reply": "回复", | ||||
|     "save": "保存", | ||||
|     "save_changes": "保存更改", | ||||
|     "sign_in": "登鹿", | ||||
|     "switch_account": "切换帐号", | ||||
|  | @ -81,8 +84,10 @@ | |||
|   "error": { | ||||
|     "account_not_found": "未找到用户 {0}", | ||||
|     "explore-list-empty": "目前没有热门话题,稍后再来看看吧!", | ||||
|     "file_size_cannot_exceed_n_mb": "文件大小不能超过 {0}MB", | ||||
|     "sign_in_error": "无法连接服务器", | ||||
|     "status_not_found": "未找到帖文" | ||||
|     "status_not_found": "未找到帖文", | ||||
|     "unsupported_file_format": "不支持的文件格式" | ||||
|   }, | ||||
|   "feature_flag": { | ||||
|     "avatar_on_avatar": "头像堆叠", | ||||
|  | @ -139,7 +144,8 @@ | |||
|     "local": "本地", | ||||
|     "notifications": "通知", | ||||
|     "profile": "个人资料", | ||||
|     "search": "搜索 {0}" | ||||
|     "search": "搜索 {0}", | ||||
|     "settings": "设置" | ||||
|   }, | ||||
|   "nav_user": { | ||||
|     "sign_in_desc": "登录后可关注其他人或标签、点赞、分享和回复帖文,或与不同服务器上的账号交互。" | ||||
|  | @ -201,6 +207,26 @@ | |||
|   "search": { | ||||
|     "search_desc": "搜索用户或话题标签" | ||||
|   }, | ||||
|   "settings": { | ||||
|     "preferences": { | ||||
|       "label": "首选项" | ||||
|     }, | ||||
|     "profile": { | ||||
|       "appearance": { | ||||
|         "bio": "简介", | ||||
|         "description": "编辑个人资料,例如头像、用户名、个人简介等。", | ||||
|         "display_name": "昵称", | ||||
|         "label": "外观", | ||||
|         "title": "编辑个人资料" | ||||
|       }, | ||||
|       "featured_tags": { | ||||
|         "description": "人们可以在这些标签下浏览你的公共嘟文。", | ||||
|         "label": "精选的话题标签" | ||||
|       }, | ||||
|       "label": "个人资料" | ||||
|     }, | ||||
|     "select_a_settings": "在左侧选择一个设置" | ||||
|   }, | ||||
|   "state": { | ||||
|     "edited": "(已编辑)", | ||||
|     "editing": "编辑中", | ||||
|  |  | |||
|  | @ -47,11 +47,13 @@ | |||
|     "pinia": "^2.0.28", | ||||
|     "shiki": "^0.12.1", | ||||
|     "shiki-es": "^0.1.2", | ||||
|     "slimeform": "^0.8.0", | ||||
|     "tauri-plugin-log-api": "github:tauri-apps/tauri-plugin-log", | ||||
|     "tauri-plugin-store-api": "github:tauri-apps/tauri-plugin-store", | ||||
|     "tippy.js": "^6.3.7", | ||||
|     "ufo": "^1.0.1", | ||||
|     "ultrahtml": "^1.2.0", | ||||
|     "vue-advanced-cropper": "^2.8.6", | ||||
|     "vue-virtual-scroller": "2.0.0-beta.7" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|  |  | |||
|  | @ -0,0 +1,41 @@ | |||
| <script lang="ts" setup> | ||||
| definePageMeta({ | ||||
|   wideLayout: true, | ||||
| }) | ||||
| 
 | ||||
| const route = useRoute() | ||||
| 
 | ||||
| const isRootPath = computedEager(() => route.name === 'settings') | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <div min-h-screen flex> | ||||
|     <div border="x base" :class="isRootPath ? 'block lg:flex-none flex-1' : 'hidden lg:block'"> | ||||
|       <MainContent> | ||||
|         <template #title> | ||||
|           <div text-lg font-bold flex items-center gap-2 @click="$scrollToTop"> | ||||
|             <div i-ri:settings-4-line /> | ||||
|             <span>{{ $t('nav_side.settings') }}</span> | ||||
|           </div> | ||||
|         </template> | ||||
|         <div xl:w-97 lg:w-78 w-full> | ||||
|           <SettingsNavItem | ||||
|             command | ||||
|             icon="i-ri:user-line" | ||||
|             :text="$t('settings.profile.label')" | ||||
|             to="/settings/profile" | ||||
|           /> | ||||
|           <SettingsNavItem | ||||
|             command | ||||
|             icon="i-ri:settings-2-line" | ||||
|             :text="$t('settings.preferences.label')" | ||||
|             to="/settings/preferences" | ||||
|           /> | ||||
|         </div> | ||||
|       </MainContent> | ||||
|     </div> | ||||
|     <div flex-1 :class="isRootPath ? 'hidden lg:block' : 'block'"> | ||||
|       <NuxtPage /> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | @ -0,0 +1,8 @@ | |||
| <template> | ||||
|   <div min-h-screen flex justify-center items-center> | ||||
|     <div text-center flex="~ col gap-2" items-center> | ||||
|       <div i-ri:settings-4-line text-5xl /> | ||||
|       <span text-xl>{{ $t('settings.select_a_settings') }}</span> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | @ -0,0 +1,21 @@ | |||
| <script lang="ts" setup> | ||||
| const { lg } = breakpoints | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <MainContent :back="!lg"> | ||||
|     <template #title> | ||||
|       <div text-lg font-bold flex items-center gap-2 @click="$scrollToTop"> | ||||
|         <span>{{ $t('settings.preferences.label') }}</span> | ||||
|       </div> | ||||
|     </template> | ||||
|     <div text-center mt-10> | ||||
|       <h1 text-4xl> | ||||
|         🚧 | ||||
|       </h1> | ||||
|       <h3 text-xl> | ||||
|         {{ $t('settings.preferences.label') }} | ||||
|       </h3> | ||||
|     </div> | ||||
|   </MainContent> | ||||
| </template> | ||||
|  | @ -0,0 +1,135 @@ | |||
| <script lang="ts" setup> | ||||
| import { invoke } from '@vueuse/shared' | ||||
| import { useForm } from 'slimeform' | ||||
| 
 | ||||
| definePageMeta({ | ||||
|   // Keep alive the form page will reduce raw data timeliness and its status timeliness | ||||
|   keepalive: false, | ||||
| }) | ||||
| 
 | ||||
| const router = useRouter() | ||||
| 
 | ||||
| const my = $computed(() => currentUser.value?.account) | ||||
| 
 | ||||
| watch($$(my), (value) => { | ||||
|   if (!value) | ||||
|     router.push('/') | ||||
| }) | ||||
| 
 | ||||
| const onlineSrc = $computed(() => ({ | ||||
|   avatar: my?.avatar || '', | ||||
|   header: my?.header || '', | ||||
| })) | ||||
| 
 | ||||
| const { form, reset, submitter, dirtyFields, isError } = useForm({ | ||||
|   form: () => ({ | ||||
|     displayName: my?.displayName ?? '', | ||||
|     note: my?.source.note.replaceAll('\r', '') ?? '', | ||||
| 
 | ||||
|     avatar: null as null | File, | ||||
|     header: null as null | File, | ||||
| 
 | ||||
|     // These look more like account and privacy settings than appearance settings | ||||
|     // discoverable: false, | ||||
|     // bot: false, | ||||
|     // locked: false, | ||||
|   }), | ||||
| }) | ||||
| 
 | ||||
| // Keep the information to be edited up to date | ||||
| invoke(async () => { | ||||
|   await pullMyAccountInfo() | ||||
|   reset() | ||||
| }) | ||||
| 
 | ||||
| const isCanSubmit = computed(() => !isError.value && !isEmptyObject(dirtyFields.value)) | ||||
| 
 | ||||
| const { submit, submitting } = submitter(async ({ dirtyFields }) => { | ||||
|   const res = await useMasto().accounts.updateCredentials(dirtyFields.value) | ||||
|     .then(account => ({ account })) | ||||
|     .catch((error: Error) => ({ error })) | ||||
| 
 | ||||
|   if ('error' in res) { | ||||
|     // TODO: Show error message | ||||
|     console.error('Error(updateCredentials):', res.error) | ||||
|     return | ||||
|   } | ||||
| 
 | ||||
|   setAccountInfo(my!.id, res.account) | ||||
|   reset() | ||||
| }) | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <MainContent back> | ||||
|     <template #title> | ||||
|       <div text-lg font-bold flex items-center gap-2 @click="$scrollToTop"> | ||||
|         <span>{{ $t('settings.profile.appearance.title') }}</span> | ||||
|       </div> | ||||
|     </template> | ||||
| 
 | ||||
|     <form space-y-5 @submit.prevent="submit"> | ||||
|       <div> | ||||
|         <!-- banner --> | ||||
|         <div of-hidden bg="gray-500/20" aspect="3"> | ||||
|           <CommonInputImage | ||||
|             ref="elInputImage" | ||||
|             v-model="form.header" | ||||
|             :original="onlineSrc.header" | ||||
|             w-full h-full | ||||
|           /> | ||||
|         </div> | ||||
|         <CommonCropImage v-model="form.header" :stencil-aspect-ratio="3 / 1" /> | ||||
| 
 | ||||
|         <!-- avatar --> | ||||
|         <div px-4> | ||||
|           <CommonInputImage | ||||
|             v-model="form.avatar" | ||||
|             :original="onlineSrc.avatar" | ||||
|             mt--10 | ||||
|             rounded-full border="bg-base 4" | ||||
|             w="sm:30 24" min-w="sm:30 24" h="sm:30 24" | ||||
|           /> | ||||
|         </div> | ||||
|         <CommonCropImage v-model="form.avatar" /> | ||||
|       </div> | ||||
| 
 | ||||
|       <div px4 py3 space-y-5> | ||||
|         <!-- display name --> | ||||
|         <label space-y-2 block> | ||||
|           <p font-medium> | ||||
|             {{ $t('settings.profile.appearance.display_name') }} | ||||
|           </p> | ||||
|           <input v-model="form.displayName" type="text" input-base> | ||||
|         </label> | ||||
| 
 | ||||
|         <!-- note --> | ||||
|         <label space-y-2 block> | ||||
|           <p font-medium> | ||||
|             {{ $t('settings.profile.appearance.bio') }} | ||||
|           </p> | ||||
|           <textarea v-model="form.note" maxlength="500" min-h-10ex input-base /> | ||||
|         </label> | ||||
| 
 | ||||
|         <!-- submit --> | ||||
|         <div text-right> | ||||
|           <button | ||||
|             type="submit" | ||||
|             btn-solid rounded-full text-sm | ||||
|             flex-inline gap-x-2 items-center | ||||
|             :disabled="submitting || !isCanSubmit" | ||||
|           > | ||||
|             <span | ||||
|               aria-hidden="true" | ||||
|               inline-block | ||||
|               :class="submitting ? 'i-ri:loader-2-fill animate animate-spin' : 'i-ri:save-line'" | ||||
|             /> | ||||
|             <span> | ||||
|               {{ $t('action.save') }} | ||||
|             </span> | ||||
|           </button> | ||||
|         </div> | ||||
|       </div> | ||||
|     </form> | ||||
|   </MainContent> | ||||
| </template> | ||||
|  | @ -0,0 +1,18 @@ | |||
| <template> | ||||
|   <MainContent back> | ||||
|     <template #title> | ||||
|       <div text-lg font-bold flex items-center gap-2 @click="$scrollToTop"> | ||||
|         <div i-ri:test-tube-line /> | ||||
|         <span>{{ $t('settings.profile.featured_tags.label') }}</span> | ||||
|       </div> | ||||
|     </template> | ||||
|     <div text-center mt-10> | ||||
|       <h1 text-4xl> | ||||
|         🚧 | ||||
|       </h1> | ||||
|       <h3 text-xl> | ||||
|         {{ $t('settings.profile.featured_tags.label') }} | ||||
|       </h3> | ||||
|     </div> | ||||
|   </MainContent> | ||||
| </template> | ||||
|  | @ -0,0 +1,34 @@ | |||
| <script lang="ts" setup> | ||||
| const { lg } = breakpoints | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <MainContent :back="!lg"> | ||||
|     <template #title> | ||||
|       <div text-lg font-bold flex items-center gap-2 @click="$scrollToTop"> | ||||
|         <span>{{ $t('settings.profile.label') }}</span> | ||||
|       </div> | ||||
|     </template> | ||||
| 
 | ||||
|     <SettingsNavItem | ||||
|       command | ||||
|       icon="i-ri:user-settings-line" | ||||
|       :text="$t('settings.profile.appearance.label')" | ||||
|       to="/settings/profile/appearance" | ||||
|     > | ||||
|       <template #description> | ||||
|         {{ $t('settings.profile.appearance.description') }} | ||||
|       </template> | ||||
|     </SettingsNavItem> | ||||
|     <SettingsNavItem | ||||
|       command | ||||
|       icon="i-ri:hashtag" | ||||
|       :text="$t('settings.profile.featured_tags.label')" | ||||
|       to="/settings/profile/featured-tags" | ||||
|     > | ||||
|       <template #description> | ||||
|         {{ $t('settings.profile.featured_tags.description') }} | ||||
|       </template> | ||||
|     </SettingsNavItem> | ||||
|   </MainContent> | ||||
| </template> | ||||
|  | @ -56,6 +56,7 @@ specifiers: | |||
|   shiki: ^0.12.1 | ||||
|   shiki-es: ^0.1.2 | ||||
|   simple-git-hooks: ^2.8.1 | ||||
|   slimeform: ^0.8.0 | ||||
|   std-env: ^3.3.1 | ||||
|   tauri-plugin-log-api: github:tauri-apps/tauri-plugin-log | ||||
|   tauri-plugin-store-api: github:tauri-apps/tauri-plugin-store | ||||
|  | @ -68,6 +69,7 @@ specifiers: | |||
|   vite-plugin-inspect: ^0.7.11 | ||||
|   vite-plugin-pwa: ^0.13.3 | ||||
|   vitest: ^0.26.2 | ||||
|   vue-advanced-cropper: ^2.8.6 | ||||
|   vue-tsc: ^1.0.16 | ||||
|   vue-virtual-scroller: 2.0.0-beta.7 | ||||
|   workbox-window: ^6.5.4 | ||||
|  | @ -97,11 +99,13 @@ dependencies: | |||
|   pinia: 2.0.28_typescript@4.9.4 | ||||
|   shiki: 0.12.1 | ||||
|   shiki-es: 0.1.2 | ||||
|   slimeform: 0.8.0 | ||||
|   tauri-plugin-log-api: github.com/tauri-apps/tauri-plugin-log/b58475bbc410fa78eb69276c62d0b64c91c07914 | ||||
|   tauri-plugin-store-api: github.com/tauri-apps/tauri-plugin-store/9bd993aa67766596638bbfd91e79a1bf8f632014 | ||||
|   tippy.js: 6.3.7 | ||||
|   ufo: 1.0.1 | ||||
|   ultrahtml: 1.2.0 | ||||
|   vue-advanced-cropper: 2.8.6 | ||||
|   vue-virtual-scroller: 2.0.0-beta.7 | ||||
| 
 | ||||
| devDependencies: | ||||
|  | @ -4163,6 +4167,10 @@ packages: | |||
|     engines: {node: '>=8'} | ||||
|     dev: true | ||||
| 
 | ||||
|   /classnames/2.3.2: | ||||
|     resolution: {integrity: sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==} | ||||
|     dev: false | ||||
| 
 | ||||
|   /clean-regexp/1.0.0: | ||||
|     resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} | ||||
|     engines: {node: '>=4'} | ||||
|  | @ -4544,6 +4552,10 @@ packages: | |||
|     resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} | ||||
|     dev: true | ||||
| 
 | ||||
|   /debounce/1.2.1: | ||||
|     resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} | ||||
|     dev: false | ||||
| 
 | ||||
|   /debug/2.6.9: | ||||
|     resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} | ||||
|     peerDependencies: | ||||
|  | @ -4758,6 +4770,10 @@ packages: | |||
|     resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} | ||||
|     dev: true | ||||
| 
 | ||||
|   /easy-bem/1.1.1: | ||||
|     resolution: {integrity: sha512-GJRqdiy2h+EXy6a8E6R+ubmqUM08BK0FWNq41k24fup6045biQ8NXxoXimiwegMQvFFV3t1emADdGNL1TlS61A==} | ||||
|     dev: false | ||||
| 
 | ||||
|   /ee-first/1.1.1: | ||||
|     resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} | ||||
|     dev: true | ||||
|  | @ -8807,6 +8823,12 @@ packages: | |||
|       is-fullwidth-code-point: 4.0.0 | ||||
|     dev: true | ||||
| 
 | ||||
|   /slimeform/0.8.0: | ||||
|     resolution: {integrity: sha512-oh0GY3qPkN1ouH3TQex/+SbVsgGmJhZvgz8NqfECuMuSy7k0NOQNUudH/bebcAY7fIk5nVunMS2GPfo4UWwmDw==} | ||||
|     peerDependencies: | ||||
|       vue: '>=3' | ||||
|     dev: false | ||||
| 
 | ||||
|   /snake-case/3.0.4: | ||||
|     resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} | ||||
|     dependencies: | ||||
|  | @ -10013,6 +10035,17 @@ packages: | |||
|     resolution: {integrity: sha512-fmL7V1eiDBFRRnu+gfRWTzyPpNIHJTc4mWnFkwBUmO9U3KPgJAmTx7oxi2bl/Rh6HLdU7+4C9wlj0k2E4AdKFQ==} | ||||
|     dev: true | ||||
| 
 | ||||
|   /vue-advanced-cropper/2.8.6: | ||||
|     resolution: {integrity: sha512-R1vkXG/Vam3OEd3vMJsVSJkXUc9ejM9l/NzPcPvkyzKGHwF69c2v1lh2Kqj2A5MCqrTmk76bmzmWFuYj+AcwmA==} | ||||
|     engines: {node: '>=8', npm: '>=5'} | ||||
|     peerDependencies: | ||||
|       vue: ^3.0.0 | ||||
|     dependencies: | ||||
|       classnames: 2.3.2 | ||||
|       debounce: 1.2.1 | ||||
|       easy-bem: 1.1.1 | ||||
|     dev: false | ||||
| 
 | ||||
|   /vue-bundle-renderer/1.0.0: | ||||
|     resolution: {integrity: sha512-43vCqTgaMXfHhtR8/VcxxWD1DgtzyvNc4wNyG5NKCIH19O1z5G9ZCRXTGEA2wifVec5PU82CkRLD2sTK9NkTdA==} | ||||
|     dependencies: | ||||
|  |  | |||
|  | @ -3,6 +3,7 @@ | |||
|   --c-primary-active: #C16929; | ||||
|   --c-primary-light: #EA9E441A; | ||||
|   --c-border: #eee; | ||||
|   --c-border-dark: #dccfcf; | ||||
| 
 | ||||
|   --c-bg-base: #fff; | ||||
|   --c-bg-active: #f6f6f6; | ||||
|  | @ -18,10 +19,15 @@ | |||
|   --c-bg-btn-disabled: #a1a1a1; | ||||
|   --c-text-btn-disabled: #fff; | ||||
|   --c-text-btn: #232323; | ||||
| 
 | ||||
|   --c-success: #67C23A; | ||||
|   --c-warning: #E6A23C; | ||||
|   --c-error: #F56C6C; | ||||
| } | ||||
| 
 | ||||
| .dark { | ||||
|   --c-border: #222; | ||||
|   --c-border-dark: #545251; | ||||
| 
 | ||||
|   --c-bg-base: #111; | ||||
|   --c-bg-active: #191919; | ||||
|  |  | |||
|  | @ -13,6 +13,7 @@ export default defineConfig({ | |||
|   shortcuts: [ | ||||
|     { | ||||
|       'border-base': 'border-$c-border', | ||||
|       'border-dark': 'border-$c-border-dark', | ||||
|       'border-strong': 'border-$c-text-base', | ||||
|       'border-bg-base': 'border-$c-bg-base', | ||||
|       'border-primary-light': 'border-$c-primary-light', | ||||
|  | @ -38,6 +39,12 @@ export default defineConfig({ | |||
|       'btn-text': 'btn-base px-4 py-2 text-$c-primary hover:text-$c-primary-active', | ||||
|       'btn-action-icon': 'btn-base hover:bg-active rounded-full h9 w9 flex items-center justify-center', | ||||
| 
 | ||||
|       // input
 | ||||
|       'input-base-focus': 'focus:outline-none focus:border-$c-primary', | ||||
|       'input-base-disabled': 'disabled:pointer-events-none disabled:bg-gray-500/5 disabled:text-gray-500/50', | ||||
|       'input-base': 'p2 rounded w-full bg-transparent border border-dark input-base-focus input-base-disabled', | ||||
|       'input-error': 'border-$c-error focus:(outline-offset-0 outline-$c-error outline-1px)', | ||||
| 
 | ||||
|       // link
 | ||||
|       'text-link-rounded': 'focus:outline-none focus:ring-(2 primary inset) hover:bg-active rounded md:rounded-full px2 mx--2', | ||||
| 
 | ||||
|  |  | |||
		Ładowanie…
	
		Reference in New Issue
	
	 Ayaka Rizumu
						Ayaka Rizumu