Compare commits

...

2 Commits

Author SHA1 Message Date
tonyaellie
53a5ed1f77 feat: proof of concept 2025-04-13 16:43:16 +00:00
tonyaellie
5d5ee8f555 feat: move image upload to its own component 2025-04-13 15:33:23 +00:00
4 changed files with 251 additions and 112 deletions

View File

@@ -17,25 +17,7 @@
:max-length="1000" :max-length="1000"
/> />
<LabelSelector v-model="form.labels" :labels="labels ?? []" /> <LabelSelector v-model="form.labels" :labels="labels ?? []" />
<div class="flex w-full flex-col gap-1.5"> <PhotoUploader :initial-photos="form.photos" @update:photos="photos => (form.photos = photos)" />
<Label for="image-create-photo" class="flex w-full px-1">
{{ $t("components.item.create_modal.item_photo") }}
</Label>
<div class="relative inline-block">
<Button type="button" variant="outline" class="w-full" aria-hidden="true" @click.prevent="">
{{ $t("components.item.create_modal.upload_photos") }}
</Button>
<Input
id="image-create-photo"
ref="fileInput"
class="absolute left-0 top-0 size-full cursor-pointer opacity-0"
type="file"
accept="image/png,image/jpeg,image/gif,image/avif,image/webp;capture=camera"
multiple
@change="previewImage"
/>
</div>
</div>
<div class="mt-4 flex flex-row-reverse"> <div class="mt-4 flex flex-row-reverse">
<ButtonGroup> <ButtonGroup>
<Button :disabled="loading" type="submit" class="group"> <Button :disabled="loading" type="submit" class="group">
@@ -54,53 +36,6 @@
</Button> </Button>
</ButtonGroup> </ButtonGroup>
</div> </div>
<!-- photo preview area is AFTER the create button, to avoid pushing the button below the screen on small displays -->
<div v-if="form.photos.length > 0" class="mt-4 border-t border-gray-300 px-4 pb-4">
<div v-for="(photo, index) in form.photos" :key="index">
<div class="mt-8 w-full">
<img
:src="photo.fileBase64"
class="w-full rounded border-gray-300 object-fill shadow-sm"
alt="Uploaded Photo"
/>
</div>
<div class="mt-2 flex items-center gap-2">
<TooltipProvider class="flex gap-2" :delay-duration="0">
<Tooltip>
<TooltipTrigger>
<Button size="icon" type="button" variant="destructive" @click.prevent="deleteImage(index)">
<MdiDelete />
<div class="sr-only">Delete photo</div>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete photo</p>
</TooltipContent>
</Tooltip>
<!-- TODO: re-enable when we have a way to set primary photos -->
<!-- <Tooltip>
<TooltipTrigger>
<Button
size="icon"
type="button"
:variant="photo.primary ? 'default' : 'outline'"
@click.prevent="setPrimary(index)"
>
<MdiStar v-if="photo.primary" />
<MdiStarOutline v-else />
<div class="sr-only">Set as {{ photo.primary ? "non" : "" }} primary photo</div>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Set as {{ photo.primary ? "non" : "" }} primary photo</p>
</TooltipContent>
</Tooltip> -->
</TooltipProvider>
<p class="mt-1 text-sm" style="overflow-wrap: anywhere">{{ photo.photoName }}</p>
</div>
</div>
</div>
</form> </form>
</BaseModal> </BaseModal>
</template> </template>
@@ -109,26 +44,16 @@
import { toast } from "@/components/ui/sonner"; import { toast } from "@/components/ui/sonner";
import { Button, ButtonGroup } from "~/components/ui/button"; import { Button, ButtonGroup } from "~/components/ui/button";
import BaseModal from "@/components/App/CreateModal.vue"; import BaseModal from "@/components/App/CreateModal.vue";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import type { ItemCreate, LocationOut } from "~~/lib/api/types/data-contracts"; import type { ItemCreate, LocationOut } from "~~/lib/api/types/data-contracts";
import { useLabelStore } from "~~/stores/labels"; import { useLabelStore } from "~~/stores/labels";
import { useLocationStore } from "~~/stores/locations"; import { useLocationStore } from "~~/stores/locations";
import MdiPackageVariant from "~icons/mdi/package-variant"; import MdiPackageVariant from "~icons/mdi/package-variant";
import MdiPackageVariantClosed from "~icons/mdi/package-variant-closed"; import MdiPackageVariantClosed from "~icons/mdi/package-variant-closed";
import MdiDelete from "~icons/mdi/delete";
// import MdiStarOutline from "~icons/mdi/star-outline";
// import MdiStar from "~icons/mdi/star";
import { AttachmentTypes } from "~~/lib/api/types/non-generated"; import { AttachmentTypes } from "~~/lib/api/types/non-generated";
import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider"; import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider";
import LabelSelector from "~/components/Label/Selector.vue"; import LabelSelector from "~/components/Label/Selector.vue";
import type { PhotoPreview } from "~/components/Item/ImageUpload.vue";
interface PhotoPreview { import PhotoUploader from "~/components/Item/ImageUpload.vue";
photoName: string;
file: File;
fileBase64: string;
primary: boolean;
}
const { activeDialog, closeDialog } = useDialog(); const { activeDialog, closeDialog } = useDialog();
@@ -173,40 +98,6 @@
const { shift } = useMagicKeys(); const { shift } = useMagicKeys();
function deleteImage(index: number) {
form.photos.splice(index, 1);
}
// TODO: actually set the primary when adding item
// function setPrimary(index: number) {
// const primary = form.photos.findIndex(p => p.primary);
// if (primary !== -1) form.photos[primary].primary = false;
// if (primary !== index) form.photos[index].primary = true;
// toast.error("Currently this does not do anything, the first photo will always be primary");
// }
function previewImage(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
for (const file of input.files) {
const reader = new FileReader();
reader.onload = e => {
form.photos.push({
photoName: file.name,
fileBase64: e.target?.result as string,
file,
primary: form.photos.length === 0,
});
};
reader.readAsDataURL(file);
}
input.value = "";
}
}
watch( watch(
() => activeDialog.value, () => activeDialog.value,
active => { active => {

View File

@@ -0,0 +1,216 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { Cropper } from "vue-advanced-cropper";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "~/components/ui/button";
import MdiDelete from "~icons/mdi/delete";
import MdiRotateLeft from "~icons/mdi/rotate-left";
import MdiRotateRight from "~icons/mdi/rotate-right";
import MdiFlipHorizontal from "~icons/mdi/flip-horizontal";
import MdiFlipVertical from "~icons/mdi/flip-vertical";
// import MdiStarOutline from "~icons/mdi/star-outline";
// import MdiStar from "~icons/mdi/star";
import "vue-advanced-cropper/dist/style.css";
export type PhotoPreview = {
photoName: string;
file: File;
fileBase64: string;
primary: boolean;
};
const props = defineProps<{ initialPhotos: PhotoPreview[] }>();
const emits = defineEmits<{
(e: "update:photos", photos: PhotoPreview[]): void;
}>();
const photos = ref<PhotoPreview[]>(props.initialPhotos);
const croppers = ref<(InstanceType<typeof Cropper> | null)[]>([]);
onMounted(() => {
croppers.value = Array(photos.value.length).fill(null);
});
function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
for (const file of input.files) {
const reader = new FileReader();
reader.onload = e => {
const photo = {
photoName: file.name,
fileBase64: e.target?.result as string,
file,
primary: photos.value.length === 0,
};
photos.value.push(photo);
emits("update:photos", photos.value);
};
reader.readAsDataURL(file);
}
input.value = "";
}
}
function deleteImage(index: number) {
photos.value.splice(index, 1);
croppers.value.splice(index, 1);
emits("update:photos", photos.value);
}
// function setPrimary(index: number) {
// const primary = photos.value.findIndex(p => p.primary);
// if (primary !== -1) photos.value[primary].primary = false;
// if (primary !== index) photos.value[index].primary = true;
// toast.error("Currently this does not do anything, the first photo will always be primary");
// }
const setSize = (index: number) => {
const cropper = croppers.value[index];
const img = new Image();
img.src = photos.value[index].fileBase64;
img.onload = () => {
// get the image size
cropper?.setCoordinates({
width: img.naturalWidth,
height: img.naturalHeight,
left: 0,
top: 0,
});
};
};
</script>
<template>
<div>
<div class="flex w-full flex-col gap-1.5">
<Label for="image-create-photo" class="flex w-full px-1">
{{ $t("components.item.create_modal.item_photo") }}
</Label>
<div class="relative inline-block">
<Button type="button" variant="outline" class="w-full" aria-hidden="true">
{{ $t("components.item.create_modal.upload_photos") }}
</Button>
<Input
id="image-create-photo"
class="absolute left-0 top-0 size-full cursor-pointer opacity-0"
type="file"
accept="image/png,image/jpeg,image/gif,image/avif,image/webp;capture=camera"
multiple
@change="handleFileChange"
/>
</div>
</div>
<div v-if="photos.length > 0" class="mt-4 border-t border-gray-300">
<div v-for="(photo, index) in photos" :key="index">
<div class="mt-8 w-full">
<cropper
ref="croppers"
:src="photo.fileBase64"
alt="Uploaded Photo"
background-class="image-cropper-bg"
class="image-cropper"
@ready="
() => {
setSize(index);
}
"
/>
<!-- class="w-full rounded border-gray-300 object-fill shadow-sm" -->
</div>
<div class="mt-2 flex justify-center gap-2">
<TooltipProvider class="flex gap-2">
<Tooltip>
<TooltipTrigger>
<Button size="icon" type="button" variant="outline" @click.prevent="croppers[index]?.rotate(-90)">
<MdiRotateLeft />
<div class="sr-only">Rotate left</div>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Rotate left</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<Button size="icon" type="button" variant="outline" @click.prevent="croppers[index]?.flip(true, false)">
<MdiFlipHorizontal />
<div class="sr-only">Flip horizontal</div>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Flip horizontal</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<Button size="icon" type="button" variant="destructive" @click.prevent="deleteImage(index)">
<MdiDelete />
<div class="sr-only">Delete photo</div>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete photo</p>
</TooltipContent>
</Tooltip>
<!-- TODO: re-enable when we have a way to set primary photos -->
<!-- <Tooltip>
<TooltipTrigger>
<Button
size="icon"
type="button"
:variant="photo.primary ? 'default' : 'outline'"
@click.prevent="setPrimary(index)"
>
<MdiStar v-if="photo.primary" />
<MdiStarOutline v-else />
<div class="sr-only">Set as {{ photo.primary ? "non" : "" }} primary photo</div>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Set as {{ photo.primary ? "non" : "" }} primary photo</p>
</TooltipContent>
</Tooltip> -->
<Tooltip>
<TooltipTrigger>
<Button size="icon" type="button" variant="outline" @click.prevent="croppers[index]?.flip(false, true)">
<MdiFlipVertical />
<div class="sr-only">Flip vertical</div>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Flip vertical</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<Button size="icon" type="button" variant="outline" @click.prevent="croppers[index]?.rotate(90)">
<MdiRotateRight />
<div class="sr-only">Rotate right</div>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Rotate right</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<p class="mt-1 text-center text-sm" style="overflow-wrap: anywhere">{{ photo.photoName }}</p>
</div>
</div>
</div>
</template>
<style>
.image-cropper {
width: 462px;
}
.image-cropper-bg {
background-color: white;
}
</style>

View File

@@ -76,6 +76,7 @@
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vaul-vue": "^0.4.1", "vaul-vue": "^0.4.1",
"vue": "3.4.8", "vue": "3.4.8",
"vue-advanced-cropper": "^2.8.9",
"vue-router": "^4.5.0", "vue-router": "^4.5.0",
"vue-sonner": "^1.3.0" "vue-sonner": "^1.3.0"
} }

View File

@@ -110,6 +110,9 @@ importers:
vue: vue:
specifier: 3.4.8 specifier: 3.4.8
version: 3.4.8(typescript@5.6.2) version: 3.4.8(typescript@5.6.2)
vue-advanced-cropper:
specifier: ^2.8.9
version: 2.8.9(vue@3.4.8(typescript@5.6.2))
vue-router: vue-router:
specifier: ^4.5.0 specifier: ^4.5.0
version: 4.5.0(vue@3.4.8(typescript@5.6.2)) version: 4.5.0(vue@3.4.8(typescript@5.6.2))
@@ -2510,6 +2513,9 @@ packages:
class-variance-authority@0.7.1: class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
classnames@2.5.1:
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
clean-regexp@1.0.0: clean-regexp@1.0.0:
resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -2763,6 +2769,9 @@ packages:
de-indent@1.0.2: de-indent@1.0.2:
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
debounce@1.2.1:
resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==}
debug@2.6.9: debug@2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies: peerDependencies:
@@ -2926,6 +2935,9 @@ packages:
eastasianwidth@0.2.0: eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
easy-bem@1.1.1:
resolution: {integrity: sha512-GJRqdiy2h+EXy6a8E6R+ubmqUM08BK0FWNq41k24fup6045biQ8NXxoXimiwegMQvFFV3t1emADdGNL1TlS61A==}
ee-first@1.1.1: ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -5891,6 +5903,12 @@ packages:
vscode-uri@3.1.0: vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
vue-advanced-cropper@2.8.9:
resolution: {integrity: sha512-1jc5gO674kVGpJKekoaol6ZlwaF5VYDLSBwBOUpViW0IOrrRsyLw6XNszjEqgbavvqinlKNS6Kqlom3B5M72Tw==}
engines: {node: '>=8', npm: '>=5'}
peerDependencies:
vue: ^3.0.0
vue-bundle-renderer@2.1.1: vue-bundle-renderer@2.1.1:
resolution: {integrity: sha512-+qALLI5cQncuetYOXp4yScwYvqh8c6SMXee3B+M7oTZxOgtESP0l4j/fXdEJoZ+EdMxkGWIj+aSEyjXkOdmd7g==} resolution: {integrity: sha512-+qALLI5cQncuetYOXp4yScwYvqh8c6SMXee3B+M7oTZxOgtESP0l4j/fXdEJoZ+EdMxkGWIj+aSEyjXkOdmd7g==}
@@ -8915,6 +8933,8 @@ snapshots:
dependencies: dependencies:
clsx: 2.1.1 clsx: 2.1.1
classnames@2.5.1: {}
clean-regexp@1.0.0: clean-regexp@1.0.0:
dependencies: dependencies:
escape-string-regexp: 1.0.5 escape-string-regexp: 1.0.5
@@ -9157,6 +9177,8 @@ snapshots:
de-indent@1.0.2: {} de-indent@1.0.2: {}
debounce@1.2.1: {}
debug@2.6.9: debug@2.6.9:
dependencies: dependencies:
ms: 2.0.0 ms: 2.0.0
@@ -9284,6 +9306,8 @@ snapshots:
eastasianwidth@0.2.0: {} eastasianwidth@0.2.0: {}
easy-bem@1.1.1: {}
ee-first@1.1.1: {} ee-first@1.1.1: {}
ejs@3.1.10: ejs@3.1.10:
@@ -12728,6 +12752,13 @@ snapshots:
vscode-uri@3.1.0: {} vscode-uri@3.1.0: {}
vue-advanced-cropper@2.8.9(vue@3.4.8(typescript@5.6.2)):
dependencies:
classnames: 2.5.1
debounce: 1.2.1
easy-bem: 1.1.1
vue: 3.4.8(typescript@5.6.2)
vue-bundle-renderer@2.1.1: vue-bundle-renderer@2.1.1:
dependencies: dependencies:
ufo: 1.5.4 ufo: 1.5.4