mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-21 13:23:14 +01:00
feat: move image upload to its own component
This commit is contained in:
@@ -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 => {
|
||||||
|
|||||||
126
frontend/components/Item/ImageUpload.vue
Normal file
126
frontend/components/Item/ImageUpload.vue
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<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 px-4 pb-4">
|
||||||
|
<div v-for="(photo, index) in 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">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
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 MdiStarOutline from "~icons/mdi/star-outline";
|
||||||
|
// import MdiStar from "~icons/mdi/star";
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
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");
|
||||||
|
// }
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user