| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242 |
- <template>
- <q-card-section class="avatar-step no-padding">
- <div class="column items-center q-gutter-y-xl q-pt-md">
- <div class="column items-center q-gutter-y-sm">
- <div
- class="photo-circle"
- :class="form.avatar ? 'photo-circle--done' : 'photo-circle--pending'"
- @click="openPicker"
- >
- <img
- v-if="avatarPreview"
- :src="avatarPreview"
- class="avatar-preview__image"
- />
- <q-icon v-else color="white" name="photo_camera" size="52px" />
- </div>
- <input
- ref="uploadInputRef"
- accept="image/jpeg,image/png,image/webp"
- class="hidden"
- type="file"
- @change="onFileSelected"
- />
- <q-btn
- v-if="!form.avatar"
- color="primary-button"
- no-caps
- padding="10px 28px"
- rounded
- :label="$t('auth.avatar_step_button')"
- @click="openPicker"
- />
- <q-btn
- v-if="form.avatar"
- color="grey-6"
- flat
- no-caps
- padding="10px 16px"
- rounded
- :label="$t('auth.avatar_step_remove')"
- @click="removeAvatar"
- />
- <div class="text-text text-center font12">
- {{ $t("auth.avatar_step_title") }}
- </div>
- <div class="avatar-step__hint text-center text-grey-7 font11">
- {{ $t("auth.avatar_step_hint") }}
- </div>
- </div>
- </div>
- </q-card-section>
- </template>
- <script setup>
- import { Camera, CameraResultType, CameraSource } from "@capacitor/camera";
- import { computed, onBeforeUnmount, ref, watch } from "vue";
- import { useQuasar } from "quasar";
- import { useI18n } from "vue-i18n";
- const $q = useQuasar();
- const { t } = useI18n();
- const form = defineModel({ type: Object, required: true });
- const uploadInputRef = ref(null);
- const avatarPreview = ref(null);
- const isNativeApp = computed(() => {
- return $q.platform.is.capacitor || $q.platform.is.cordova;
- });
- const maxAvatarSize = 5 * 1024 * 1024;
- const acceptedAvatarTypes = ["image/jpeg", "image/png", "image/webp"];
- const base64ToFile = (base64, filename, mimeType = "image/jpeg") => {
- const byteString = atob(base64);
- const ab = new ArrayBuffer(byteString.length);
- const ia = new Uint8Array(ab);
- for (let i = 0; i < byteString.length; i++) {
- ia[i] = byteString.charCodeAt(i);
- }
- return new File([ab], filename, { type: mimeType });
- };
- //
- const openNativePicker = async () => {
- try {
- const photo = await Camera.getPhoto({
- resultType: CameraResultType.Base64,
- source: CameraSource.Prompt,
- quality: 85,
- correctOrientation: true,
- promptLabelHeader: t("auth.avatar_step_prompt_header"),
- promptLabelPhoto: t("auth.avatar_step_prompt_gallery"),
- promptLabelPicture: t("auth.avatar_step_prompt_camera"),
- promptLabelCancel: t("auth.avatar_step_prompt_cancel"),
- });
- const base64 = photo.base64String.replace(/\s/g, "");
- const mime = `image/${photo.format}`;
- const file = base64ToFile(base64, `avatar.${photo.format}`, mime);
- validateAndSetFile(file);
- } catch (err) {
- const msg = (err?.message || "").toLowerCase();
- if (
- !msg.includes("cancel") &&
- !msg.includes("dismiss") &&
- !msg.includes("no image")
- ) {
- $q.notify({
- type: "negative",
- message: t("auth.avatar_step_invalid"),
- });
- }
- }
- };
- const openPicker = () => {
- if (isNativeApp.value) {
- openNativePicker();
- return;
- }
- uploadInputRef.value?.click();
- };
- const revokePreview = () => {
- if (!avatarPreview.value) return;
- URL.revokeObjectURL(avatarPreview.value);
- avatarPreview.value = null;
- };
- //
- const onFileSelected = (event) => {
- const file = event.target.files?.[0] ?? null;
- validateAndSetFile(file);
- event.target.value = "";
- };
- const removeAvatar = () => {
- revokePreview();
- form.value.avatar = null;
- if (uploadInputRef.value) {
- uploadInputRef.value.value = "";
- }
- };
- const validateAndSetFile = (file) => {
- if (!file) return;
- if (!acceptedAvatarTypes.includes(file.type) || file.size > maxAvatarSize) {
- $q.notify({
- type: "negative",
- message: t("auth.avatar_step_invalid"),
- });
- return;
- }
- revokePreview();
- form.value.avatar = file;
- avatarPreview.value = URL.createObjectURL(file);
- };
- watch(
- () => form.value.avatar,
- (avatar) => {
- if (!avatar) {
- revokePreview();
- }
- },
- );
- onBeforeUnmount(() => {
- revokePreview();
- });
- </script>
- <style lang="scss" scoped>
- .avatar-step {
- min-height: 100%;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .photo-circle {
- width: 130px;
- height: 130px;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- cursor: pointer;
- overflow: hidden;
- transition: transform 0.15s ease;
- &:active {
- transform: scale(0.95);
- }
- &--pending {
- background-color: rgba(139, 92, 246, 0.18);
- }
- &--done {
- background: linear-gradient(-90deg, #ec48d1 5%, #6b11cb 65%, #2574fc 100%);
- }
- }
- .avatar-preview__image {
- width: 100%;
- height: 100%;
- object-fit: cover;
- }
- .avatar-step__hint {
- max-width: 220px;
- line-height: 1.3;
- }
- </style>
|