LoginStepAvatarPanel.vue 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. <template>
  2. <q-card-section class="avatar-step no-padding">
  3. <div class="column items-center q-gutter-y-xl q-pt-md">
  4. <div class="column items-center q-gutter-y-sm">
  5. <div
  6. class="photo-circle"
  7. :class="form.avatar ? 'photo-circle--done' : 'photo-circle--pending'"
  8. @click="openPicker"
  9. >
  10. <img
  11. v-if="avatarPreview"
  12. :src="avatarPreview"
  13. class="avatar-preview__image"
  14. />
  15. <q-icon v-else color="white" name="photo_camera" size="52px" />
  16. </div>
  17. <input
  18. ref="uploadInputRef"
  19. accept="image/jpeg,image/png,image/webp"
  20. class="hidden"
  21. type="file"
  22. @change="onFileSelected"
  23. />
  24. <q-btn
  25. v-if="!form.avatar"
  26. color="primary-button"
  27. no-caps
  28. padding="10px 28px"
  29. rounded
  30. :label="$t('auth.avatar_step_button')"
  31. @click="openPicker"
  32. />
  33. <q-btn
  34. v-if="form.avatar"
  35. color="grey-6"
  36. flat
  37. no-caps
  38. padding="10px 16px"
  39. rounded
  40. :label="$t('auth.avatar_step_remove')"
  41. @click="removeAvatar"
  42. />
  43. <div class="text-text text-center font12">
  44. {{ $t("auth.avatar_step_title") }}
  45. </div>
  46. <div class="avatar-step__hint text-center text-grey-7 font11">
  47. {{ $t("auth.avatar_step_hint") }}
  48. </div>
  49. </div>
  50. </div>
  51. </q-card-section>
  52. </template>
  53. <script setup>
  54. import { Camera, CameraResultType, CameraSource } from "@capacitor/camera";
  55. import { computed, onBeforeUnmount, ref, watch } from "vue";
  56. import { useQuasar } from "quasar";
  57. import { useI18n } from "vue-i18n";
  58. const $q = useQuasar();
  59. const { t } = useI18n();
  60. const form = defineModel({ type: Object, required: true });
  61. const uploadInputRef = ref(null);
  62. const avatarPreview = ref(null);
  63. const isNativeApp = computed(() => {
  64. return $q.platform.is.capacitor || $q.platform.is.cordova;
  65. });
  66. const maxAvatarSize = 5 * 1024 * 1024;
  67. const acceptedAvatarTypes = ["image/jpeg", "image/png", "image/webp"];
  68. const base64ToFile = (base64, filename, mimeType = "image/jpeg") => {
  69. const byteString = atob(base64);
  70. const ab = new ArrayBuffer(byteString.length);
  71. const ia = new Uint8Array(ab);
  72. for (let i = 0; i < byteString.length; i++) {
  73. ia[i] = byteString.charCodeAt(i);
  74. }
  75. return new File([ab], filename, { type: mimeType });
  76. };
  77. //
  78. const openNativePicker = async () => {
  79. try {
  80. const photo = await Camera.getPhoto({
  81. resultType: CameraResultType.Base64,
  82. source: CameraSource.Prompt,
  83. quality: 85,
  84. correctOrientation: true,
  85. promptLabelHeader: t("auth.avatar_step_prompt_header"),
  86. promptLabelPhoto: t("auth.avatar_step_prompt_gallery"),
  87. promptLabelPicture: t("auth.avatar_step_prompt_camera"),
  88. promptLabelCancel: t("auth.avatar_step_prompt_cancel"),
  89. });
  90. const base64 = photo.base64String.replace(/\s/g, "");
  91. const mime = `image/${photo.format}`;
  92. const file = base64ToFile(base64, `avatar.${photo.format}`, mime);
  93. validateAndSetFile(file);
  94. } catch (err) {
  95. const msg = (err?.message || "").toLowerCase();
  96. if (
  97. !msg.includes("cancel") &&
  98. !msg.includes("dismiss") &&
  99. !msg.includes("no image")
  100. ) {
  101. $q.notify({
  102. type: "negative",
  103. message: t("auth.avatar_step_invalid"),
  104. });
  105. }
  106. }
  107. };
  108. const openPicker = () => {
  109. if (isNativeApp.value) {
  110. openNativePicker();
  111. return;
  112. }
  113. uploadInputRef.value?.click();
  114. };
  115. const revokePreview = () => {
  116. if (!avatarPreview.value) return;
  117. URL.revokeObjectURL(avatarPreview.value);
  118. avatarPreview.value = null;
  119. };
  120. //
  121. const onFileSelected = (event) => {
  122. const file = event.target.files?.[0] ?? null;
  123. validateAndSetFile(file);
  124. event.target.value = "";
  125. };
  126. const removeAvatar = () => {
  127. revokePreview();
  128. form.value.avatar = null;
  129. if (uploadInputRef.value) {
  130. uploadInputRef.value.value = "";
  131. }
  132. };
  133. const validateAndSetFile = (file) => {
  134. if (!file) return;
  135. if (!acceptedAvatarTypes.includes(file.type) || file.size > maxAvatarSize) {
  136. $q.notify({
  137. type: "negative",
  138. message: t("auth.avatar_step_invalid"),
  139. });
  140. return;
  141. }
  142. revokePreview();
  143. form.value.avatar = file;
  144. avatarPreview.value = URL.createObjectURL(file);
  145. };
  146. watch(
  147. () => form.value.avatar,
  148. (avatar) => {
  149. if (!avatar) {
  150. revokePreview();
  151. }
  152. },
  153. );
  154. onBeforeUnmount(() => {
  155. revokePreview();
  156. });
  157. </script>
  158. <style lang="scss" scoped>
  159. .avatar-step {
  160. min-height: 100%;
  161. display: flex;
  162. align-items: center;
  163. justify-content: center;
  164. }
  165. .photo-circle {
  166. width: 130px;
  167. height: 130px;
  168. border-radius: 50%;
  169. display: flex;
  170. align-items: center;
  171. justify-content: center;
  172. cursor: pointer;
  173. overflow: hidden;
  174. transition: transform 0.15s ease;
  175. &:active {
  176. transform: scale(0.95);
  177. }
  178. &--pending {
  179. background-color: rgba(139, 92, 246, 0.18);
  180. }
  181. &--done {
  182. background: linear-gradient(-90deg, #ec48d1 5%, #6b11cb 65%, #2574fc 100%);
  183. }
  184. }
  185. .avatar-preview__image {
  186. width: 100%;
  187. height: 100%;
  188. object-fit: cover;
  189. }
  190. .avatar-step__hint {
  191. max-width: 220px;
  192. line-height: 1.3;
  193. }
  194. </style>