Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions lang/en/common.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
'uploading' => 'Uploading...',
'remove' => 'Remove photo',
'hint' => 'Recommended: square image, max 2 MB.',
'crop_title' => 'Crop image',
'crop_description' => 'Drag and zoom to frame it. The area inside the circle will be used.',
'crop_hint' => 'Drag to position',
'crop_save' => 'Save',
'crop_cancel' => 'Cancel',
],

'timezone' => [
Expand Down
5 changes: 5 additions & 0 deletions lang/es/common.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
'uploading' => 'Subiendo...',
'remove' => 'Eliminar foto',
'hint' => 'Recomendado: imagen cuadrada, máximo 2 MB.',
'crop_title' => 'Recortar imagen',
'crop_description' => 'Arrastra y usa el zoom para encuadrar. Se usará el área dentro del círculo.',
'crop_hint' => 'Arrastra para posicionar',
'crop_save' => 'Guardar',
'crop_cancel' => 'Cancelar',
],

'timezone' => [
Expand Down
5 changes: 5 additions & 0 deletions lang/pt-BR/common.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
'uploading' => 'Enviando...',
'remove' => 'Remover foto',
'hint' => 'Recomendado: imagem quadrada, máximo 2 MB.',
'crop_title' => 'Recortar imagem',
'crop_description' => 'Arraste e use o zoom para enquadrar. A área dentro do círculo será usada.',
'crop_hint' => 'Arraste para posicionar',
'crop_save' => 'Salvar',
'crop_cancel' => 'Cancelar',
],

'timezone' => [
Expand Down
37 changes: 37 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"tw-animate-css": "^1.2.5",
"vaul-vue": "^0.4.1",
"vue": "^3.5.13",
"vue-advanced-cropper": "^2.8.9",
"vue-input-otp": "^0.3.2",
"vue-sonner": "^2.0.9"
},
Expand Down
142 changes: 142 additions & 0 deletions resources/js/components/ImageCropperDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { IconZoomIn, IconZoomOut } from '@tabler/icons-vue';
import { computed, markRaw, ref, watch } from 'vue';
import { CircleStencil, Cropper, RectangleStencil } from 'vue-advanced-cropper';
import 'vue-advanced-cropper/dist/style.css';

import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';

type Props = {
open: boolean;
/** Data URL of the selected image. */
src: string | null;
fileName?: string;
mimeType?: string;
/** Mask shape — round by default. */
shape?: 'circle' | 'square';
};

const props = withDefaults(defineProps<Props>(), {
fileName: 'image.png',
mimeType: 'image/png',
shape: 'circle',
});

const emit = defineEmits<{
(e: 'update:open', value: boolean): void;
(e: 'cropped', file: File): void;
}>();

type CropperInstance = {
getResult: () => { canvas?: HTMLCanvasElement | null };
zoom: (factor: number) => void;
};

const cropperRef = ref<CropperInstance | null>(null);
const processing = ref(false);
// Cropper inside a modal: only mount it after the dialog has its final layout,
// otherwise it measures 0x0 and the circular stencil degrades into a square.
const cropperReady = ref(false);

// markRaw: components shouldn't become reactive when passed as a prop.
const stencilComponent = computed(() => markRaw(props.shape === 'square' ? RectangleStencil : CircleStencil));

const close = () => {
emit('update:open', false);
};

const zoom = (factor: number) => {
cropperRef.value?.zoom(factor);
};

const save = () => {
const result = cropperRef.value?.getResult();
const canvas = result?.canvas;
if (!canvas) {
return;
}

processing.value = true;
canvas.toBlob(
(blob) => {
processing.value = false;
if (!blob) {
return;
}
const file = new File([blob], props.fileName, { type: props.mimeType });
emit('cropped', file);
close();
},
props.mimeType,
0.92,
);
};

watch(
() => props.open,
(isOpen) => {
if (isOpen) {
// Wait two frames to make sure the DialogContent already has a size.
cropperReady.value = false;
requestAnimationFrame(() => requestAnimationFrame(() => {
cropperReady.value = true;
}));
} else {
cropperReady.value = false;
processing.value = false;
}
},
);
</script>

<template>
<Dialog :open="open" @update:open="emit('update:open', $event)">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{{ $t('common.photo_upload.crop_title') }}</DialogTitle>
<DialogDescription>{{ $t('common.photo_upload.crop_description') }}</DialogDescription>
</DialogHeader>

<div class="overflow-hidden rounded-xl border-2 border-foreground bg-muted">
<Cropper
v-if="src && cropperReady"
ref="cropperRef"
:src="src"
:stencil-component="stencilComponent"
:stencil-props="{ aspectRatio: 1 }"
:canvas="{ maxWidth: 512, maxHeight: 512 }"
image-restriction="stencil"
class="h-[340px]"
/>
<div v-else class="h-[340px]" />
</div>

<div class="flex items-center justify-center gap-3">
<Button type="button" variant="outline" size="icon" class="size-9" @click="zoom(0.9)">
<IconZoomOut class="size-4" />
</Button>
<span class="text-xs text-muted-foreground">{{ $t('common.photo_upload.crop_hint') }}</span>
<Button type="button" variant="outline" size="icon" class="size-9" @click="zoom(1.1)">
<IconZoomIn class="size-4" />
</Button>
</div>

<DialogFooter>
<Button type="button" :disabled="processing || !src" @click="save">
{{ $t('common.photo_upload.crop_save') }}
</Button>
<Button type="button" variant="outline" @click="close">
{{ $t('common.photo_upload.crop_cancel') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
36 changes: 33 additions & 3 deletions resources/js/components/PhotoUpload.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { ref } from 'vue';

import ImageCropperDialog from '@/components/ImageCropperDialog.vue';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
Expand Down Expand Up @@ -34,6 +35,12 @@ const props = withDefaults(defineProps<Props>(), {
const fileInput = ref<HTMLInputElement | null>(null);
const uploading = ref(false);

// Crop: selecting a file opens the crop (round mask) before uploading.
const cropOpen = ref(false);
const cropSrc = ref<string | null>(null);
const cropFileName = ref('image.png');
const cropMime = ref('image/png');

const sizeClasses = {
sm: 'size-16',
md: 'size-20',
Expand Down Expand Up @@ -63,6 +70,23 @@ const handleFileChange = (event: Event) => {
return;
}

// Open the cropper with the selected image; the actual upload happens on crop.
cropFileName.value = file.name || 'image.png';
cropMime.value = file.type || 'image/png';
const reader = new FileReader();
reader.onload = () => {
cropSrc.value = reader.result as string;
cropOpen.value = true;
};
reader.readAsDataURL(file);

// Reset the input so the same file can be selected again later.
if (fileInput.value) {
fileInput.value.value = '';
}
};

const uploadCropped = (file: File) => {
uploading.value = true;

router.post(
Expand All @@ -72,9 +96,7 @@ const handleFileChange = (event: Event) => {
forceFormData: true,
onFinish: () => {
uploading.value = false;
if (fileInput.value) {
fileInput.value.value = '';
}
cropSrc.value = null;
},
},
);
Expand Down Expand Up @@ -140,5 +162,13 @@ const handleDelete = () => {
{{ $t('common.photo_upload.hint') }}
</p>
</div>

<ImageCropperDialog
v-model:open="cropOpen"
:src="cropSrc"
:file-name="cropFileName"
:mime-type="cropMime"
@cropped="uploadCropped"
/>
</div>
</template>