Files
ppeasy_vg12/resources/js/components/users/AvatarCard.vue
T
2026-10-09 08:21:06 +02:00

192 lines
5.8 KiB
Vue

<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { ref, useTemplateRef } from 'vue';
import { Cropper } from 'vue-advanced-cropper';
import 'vue-advanced-cropper/dist/style.css';
import Button from '@/components/ui/Button.vue';
import Dialog from '@/components/ui/Dialog.vue';
import { useTranslations } from '@/composables/useVanguard';
const props = defineProps<{
avatar: string;
nameOrEmail: string;
/** Multipart endpoint for a cropped upload. */
uploadUrl: string;
/** Endpoint that takes a `url` for Gravatar, socials and the placeholder. */
externalUrl: string;
sources: Array<{ label: string; url: string; external: boolean }>;
}>();
const { __ } = useTranslations();
const picking = ref(false);
const processing = ref(false);
const fileInput = useTemplateRef<HTMLInputElement>('fileInput');
const cropper = useTemplateRef<InstanceType<typeof Cropper>>('cropper');
/** Object URL of the file being cropped; null when not cropping. */
const editing = ref<string | null>(null);
let pendingFile: File | null = null;
function chooseExternal(url: string): void {
picking.value = false;
processing.value = true;
router.post(
props.externalUrl,
{ url },
{ preserveScroll: true, onFinish: () => (processing.value = false) },
);
}
function onFileSelected(event: Event): void {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) {
return;
}
pendingFile = file;
editing.value = URL.createObjectURL(file);
picking.value = false;
}
function cancelCrop(): void {
if (editing.value) {
URL.revokeObjectURL(editing.value);
}
editing.value = null;
pendingFile = null;
if (fileInput.value) {
fileInput.value.value = '';
}
}
/**
* The server crops a square of side `x2 - x1` starting at (x1, y1), which is
* why the stencil is locked to 1:1.
*/
function saveCrop(): void {
const coordinates = cropper.value?.getResult()?.coordinates;
if (!pendingFile || !coordinates) {
return;
}
processing.value = true;
router.post(
props.uploadUrl,
{
avatar: pendingFile,
points: {
x1: Math.round(coordinates.left),
y1: Math.round(coordinates.top),
x2: Math.round(coordinates.left + coordinates.width),
y2: Math.round(coordinates.top + coordinates.height),
},
},
{
forceFormData: true,
preserveScroll: true,
onSuccess: () => cancelCrop(),
onFinish: () => (processing.value = false),
},
);
}
</script>
<template>
<div class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<div v-if="editing" class="space-y-4">
<Cropper
ref="cropper"
:src="editing"
class="max-h-80 rounded"
:stencil-props="{ aspectRatio: 1 }"
/>
<div class="grid grid-cols-2 gap-2">
<Button
:label="__('Cancel')"
icon="fa fa-times"
severity="secondary"
outlined
@click="cancelCrop"
/>
<Button
:label="__('Save')"
icon="fa fa-check"
severity="success"
:loading="processing"
@click="saveCrop"
/>
</div>
</div>
<div v-else class="text-center">
<img
:src="avatar"
width="150"
height="150"
:alt="nameOrEmail"
class="mx-auto mb-4 size-[150px] rounded-full border border-slate-200 object-cover"
/>
<h5 class="text-slate-500">{{ nameOrEmail }}</h5>
<Button
:label="__('Change Photo')"
icon="fa fa-camera"
severity="secondary"
outlined
class="mt-6 w-full"
:loading="processing"
@click="picking = true"
/>
</div>
<input
ref="fileInput"
type="file"
accept="image/*"
class="hidden"
@change="onFileSelected"
/>
<Dialog v-model:open="picking" :header="__('Change Photo')" width="34rem">
<div class="grid grid-cols-3 gap-4">
<button
type="button"
class="hover:border-primary-600 flex cursor-pointer flex-col items-center gap-2 rounded-lg border border-transparent p-3 hover:bg-slate-50"
@click="fileInput?.click()"
>
<span
class="flex size-[100px] items-center justify-center rounded-full border border-slate-200 bg-slate-50 text-slate-400"
>
<i class="fa fa-upload text-2xl" aria-hidden="true" />
</span>
<span>{{ __('Upload Photo') }}</span>
</button>
<button
v-for="source in sources"
:key="source.label"
type="button"
class="hover:border-primary-600 flex cursor-pointer flex-col items-center gap-2 rounded-lg border border-transparent p-3 hover:bg-slate-50"
@click="chooseExternal(source.url)"
>
<img
:src="source.url"
:alt="source.label"
class="size-[100px] rounded-full border border-slate-200 object-cover"
/>
<span>{{ source.label }}</span>
</button>
</div>
</Dialog>
</div>
</template>