192 lines
5.8 KiB
Vue
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>
|