mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-24 06:28:34 +01:00
improve dialogs, option to open image dialog in edit then delete (#951)
* fix: change Content-Disposition to inline for proper document display in attachments * feat: overhaul how dialog system works, add delete to image dialog and add button to open image dialog on edit page * chore: remove unneeded console log * fix: ensure cleanup of dialog callbacks on unmount in BarcodeModal, CreateModal, and ImageDialog components
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { DialogID } from "@/components/ui/dialog-provider/utils";
|
import { DialogID, type NoParamDialogIDs, type OptionalDialogIDs } from "@/components/ui/dialog-provider/utils";
|
||||||
import {
|
import {
|
||||||
CommandDialog,
|
CommandDialog,
|
||||||
CommandInput,
|
CommandInput,
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
export type QuickMenuAction =
|
export type QuickMenuAction =
|
||||||
| { text: string; href: string; type: "navigate" }
|
| { text: string; href: string; type: "navigate" }
|
||||||
| { text: string; dialogId: DialogID; shortcut: string; type: "create" };
|
| { text: string; dialogId: NoParamDialogIDs | OptionalDialogIDs; shortcut: string; type: "create" };
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
actions: {
|
actions: {
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
const item = props.actions.filter(item => 'shortcut' in item).find(item => item.shortcut === e.key);
|
const item = props.actions.filter(item => 'shortcut' in item).find(item => item.shortcut === e.key);
|
||||||
if (item) {
|
if (item) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openDialog(item.dialogId);
|
openDialog(item.dialogId as NoParamDialogIDs);
|
||||||
}
|
}
|
||||||
// if esc is pressed, close the dialog
|
// if esc is pressed, close the dialog
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
@select="
|
@select="
|
||||||
e => {
|
e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openDialog(create.dialogId);
|
openDialog(create.dialogId as NoParamDialogIDs);
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleButtonClick = () => {
|
const handleButtonClick = () => {
|
||||||
openDialog(DialogID.ProductImport, { barcode: detectedBarcode.value });
|
openDialog(DialogID.ProductImport, { params: { barcode: detectedBarcode.value } });
|
||||||
};
|
};
|
||||||
|
|
||||||
const startScanner = async () => {
|
const startScanner = async () => {
|
||||||
|
|||||||
@@ -149,7 +149,7 @@
|
|||||||
const headers = defaultHeaders;
|
const headers = defaultHeaders;
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
registerOpenDialogCallback(DialogID.ProductImport, params => {
|
const cleanup = registerOpenDialogCallback(DialogID.ProductImport, params => {
|
||||||
selectedRow.value = -1;
|
selectedRow.value = -1;
|
||||||
searching.value = false;
|
searching.value = false;
|
||||||
errorMessage.value = null;
|
errorMessage.value = null;
|
||||||
@@ -168,6 +168,8 @@
|
|||||||
products.value = null;
|
products.value = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onUnmounted(cleanup);
|
||||||
});
|
});
|
||||||
|
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
@@ -180,7 +182,9 @@
|
|||||||
selectedRow.value < products.value.length
|
selectedRow.value < products.value.length
|
||||||
) {
|
) {
|
||||||
const p = products.value![selectedRow.value];
|
const p = products.value![selectedRow.value];
|
||||||
openDialog(DialogID.CreateItem, { product: p });
|
openDialog(DialogID.CreateItem, {
|
||||||
|
params: { product: p },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -300,7 +300,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
registerOpenDialogCallback(DialogID.CreateItem, async params => {
|
const cleanup = registerOpenDialogCallback(DialogID.CreateItem, async params => {
|
||||||
// needed since URL will be cleared in the next step => ParentId Selection should stay though
|
// needed since URL will be cleared in the next step => ParentId Selection should stay though
|
||||||
subItemCreate.value = subItemCreateParam.value === "y";
|
subItemCreate.value = subItemCreateParam.value === "y";
|
||||||
let parentItemLocationId = null;
|
let parentItemLocationId = null;
|
||||||
@@ -359,6 +359,8 @@
|
|||||||
form.labels = labels.value.filter(l => l.id === labelId.value).map(l => l.id);
|
form.labels = labels.value.filter(l => l.id === labelId.value).map(l => l.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onUnmounted(cleanup);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function create(close = true) {
|
async function create(close = true) {
|
||||||
|
|||||||
99
frontend/components/Item/ImageDialog.vue
Normal file
99
frontend/components/Item/ImageDialog.vue
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
|
import { buttonVariants, Button } from "@/components/ui/button";
|
||||||
|
import { useDialog } from "@/components/ui/dialog-provider";
|
||||||
|
import { DialogID } from "~/components/ui/dialog-provider/utils";
|
||||||
|
import { useConfirm } from "@/composables/use-confirm";
|
||||||
|
import { toast } from "@/components/ui/sonner";
|
||||||
|
import MdiClose from "~icons/mdi/close";
|
||||||
|
import MdiDownload from "~icons/mdi/download";
|
||||||
|
import MdiDelete from "~icons/mdi/delete";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
|
||||||
|
const { closeDialog, registerOpenDialogCallback } = useDialog();
|
||||||
|
|
||||||
|
const api = useUserApi();
|
||||||
|
|
||||||
|
const image = reactive<{
|
||||||
|
attachmentId: string;
|
||||||
|
itemId: string;
|
||||||
|
originalSrc: string;
|
||||||
|
originalType?: string;
|
||||||
|
thumbnailSrc?: string;
|
||||||
|
}>({
|
||||||
|
attachmentId: "",
|
||||||
|
itemId: "",
|
||||||
|
originalSrc: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const cleanup = registerOpenDialogCallback(DialogID.ItemImage, params => {
|
||||||
|
image.attachmentId = params.attachmentId;
|
||||||
|
image.itemId = params.itemId;
|
||||||
|
if (params.type === "preloaded") {
|
||||||
|
image.originalSrc = params.originalSrc;
|
||||||
|
image.originalType = params.originalType;
|
||||||
|
image.thumbnailSrc = params.thumbnailSrc;
|
||||||
|
} else if (params.type === "attachment") {
|
||||||
|
image.originalSrc = api.authURL(`/items/${params.itemId}/attachments/${params.attachmentId}`);
|
||||||
|
image.originalType = params.mimeType;
|
||||||
|
image.thumbnailSrc = params.thumbnailId
|
||||||
|
? api.authURL(`/items/${params.itemId}/attachments/${params.thumbnailId}`)
|
||||||
|
: image.originalSrc;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(cleanup);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function deleteAttachment() {
|
||||||
|
const confirmed = await confirm.open(t("items.delete_attachment_confirm"));
|
||||||
|
|
||||||
|
if (confirmed.isCanceled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await api.items.attachments.delete(image.itemId, image.attachmentId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error(t("items.toast.failed_delete_attachment"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDialog(DialogID.ItemImage, {
|
||||||
|
action: "delete",
|
||||||
|
id: image.attachmentId,
|
||||||
|
});
|
||||||
|
toast.success(t("items.toast.attachment_deleted"));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :dialog-id="DialogID.ItemImage">
|
||||||
|
<DialogContent class="w-auto border-transparent bg-transparent p-0" disable-close>
|
||||||
|
<picture>
|
||||||
|
<source :srcset="image.originalSrc" :type="image.originalType" />
|
||||||
|
<img :src="image.thumbnailSrc" alt="attachment image" />
|
||||||
|
</picture>
|
||||||
|
<Button variant="destructive" size="icon" class="absolute right-[84px] top-1" @click="deleteAttachment">
|
||||||
|
<MdiDelete />
|
||||||
|
</Button>
|
||||||
|
<a :class="buttonVariants({ size: 'icon' })" :href="image.originalSrc" download class="absolute right-11 top-1">
|
||||||
|
<MdiDownload />
|
||||||
|
</a>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
class="absolute right-1 top-1"
|
||||||
|
@click="
|
||||||
|
closeDialog(DialogID.ItemImage);
|
||||||
|
image.originalSrc = '';
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<MdiClose />
|
||||||
|
</Button>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -25,13 +25,13 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|||||||
<template>
|
<template>
|
||||||
<AlertDialogPortal>
|
<AlertDialogPortal>
|
||||||
<AlertDialogOverlay
|
<AlertDialogOverlay
|
||||||
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
class="fixed inset-0 z-[60] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||||
/>
|
/>
|
||||||
<AlertDialogContent
|
<AlertDialogContent
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
'fixed left-1/2 top-1/2 z-[60] grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||||
props.class,
|
props.class,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
|||||||
@@ -1,40 +1,59 @@
|
|||||||
<!-- DialogProvider.vue -->
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed } from "vue";
|
import { ref, reactive, computed } from 'vue';
|
||||||
import { provideDialogContext, type DialogID, type DialogParamsMap } from "./utils";
|
import {
|
||||||
|
provideDialogContext,
|
||||||
|
type DialogID,
|
||||||
|
type DialogParamsMap,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
const activeDialog = ref<DialogID | null>(null);
|
const activeDialog = ref<DialogID | null>(null);
|
||||||
const activeAlerts = reactive<string[]>([]);
|
const activeAlerts = reactive<string[]>([]);
|
||||||
const openDialogCallbacks = new Map<DialogID, (params: any) => void>();
|
const openDialogCallbacks = new Map<DialogID, (params: any) => void>();
|
||||||
|
|
||||||
|
// onClose for the currently-open dialog (only one dialog can be active)
|
||||||
|
let activeOnCloseCallback: ((result?: any) => void) | undefined;
|
||||||
|
|
||||||
const registerOpenDialogCallback = <T extends DialogID>(
|
const registerOpenDialogCallback = <T extends DialogID>(
|
||||||
dialogId: T,
|
dialogId: T,
|
||||||
callback: (params?: T extends keyof DialogParamsMap ? DialogParamsMap[T] : undefined) => void
|
callback: (params?: T extends keyof DialogParamsMap ? DialogParamsMap[T] : undefined) => void
|
||||||
) =>
|
) => {
|
||||||
{
|
|
||||||
openDialogCallbacks.set(dialogId, callback as (params: any) => void);
|
openDialogCallbacks.set(dialogId, callback as (params: any) => void);
|
||||||
}
|
return () => {
|
||||||
|
openDialogCallbacks.delete(dialogId);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const openDialog = (dialogId: DialogID, params?: any) => {
|
const openDialog = <T extends DialogID>(dialogId: T, options?: any) => {
|
||||||
if (activeAlerts.length > 0) return;
|
if (activeAlerts.length > 0) return;
|
||||||
|
|
||||||
activeDialog.value = dialogId;
|
activeDialog.value = dialogId;
|
||||||
|
activeOnCloseCallback = options?.onClose;
|
||||||
|
|
||||||
const openCallback = openDialogCallbacks.get(dialogId);
|
const openCallback = openDialogCallbacks.get(dialogId);
|
||||||
if (openCallback) {
|
if (openCallback) {
|
||||||
openCallback(params);
|
openCallback(options?.params);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeDialog = (dialogId?: DialogID) => {
|
function closeDialog(dialogId?: DialogID, result?: any) {
|
||||||
if (dialogId) {
|
// No dialogId passed -> close current active dialog without result
|
||||||
if (activeDialog.value && activeDialog.value === dialogId) {
|
if (!dialogId) {
|
||||||
activeDialog.value = null;
|
if (activeDialog.value) {
|
||||||
|
// call onClose (if any) with no result
|
||||||
|
activeOnCloseCallback?.(undefined);
|
||||||
|
activeOnCloseCallback = undefined;
|
||||||
}
|
}
|
||||||
} else {
|
activeDialog.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialogId passed -> if it's the active dialog, call onClose with result
|
||||||
|
if (activeDialog.value && activeDialog.value === dialogId) {
|
||||||
|
activeOnCloseCallback?.(result);
|
||||||
|
activeOnCloseCallback = undefined;
|
||||||
activeDialog.value = null;
|
activeDialog.value = null;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const addAlert = (alertId: string) => {
|
const addAlert = (alertId: string) => {
|
||||||
activeAlerts.push(alertId);
|
activeAlerts.push(alertId);
|
||||||
@@ -42,9 +61,7 @@
|
|||||||
|
|
||||||
const removeAlert = (alertId: string) => {
|
const removeAlert = (alertId: string) => {
|
||||||
const index = activeAlerts.indexOf(alertId);
|
const index = activeAlerts.indexOf(alertId);
|
||||||
if (index !== -1) {
|
if (index !== -1) activeAlerts.splice(index, 1);
|
||||||
activeAlerts.splice(index, 1);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Provide context to child components
|
// Provide context to child components
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComputedRef } from "vue";
|
import { computed, type ComputedRef } from 'vue';
|
||||||
import { createContext } from "reka-ui";
|
import { createContext } from 'reka-ui';
|
||||||
import { useMagicKeys, useActiveElement } from "@vueuse/core";
|
import { useMagicKeys, useActiveElement } from '@vueuse/core';
|
||||||
import type { BarcodeProduct } from "~~/lib/api/types/data-contracts";
|
import type { BarcodeProduct } from '~~/lib/api/types/data-contracts';
|
||||||
|
|
||||||
export enum DialogID {
|
export enum DialogID {
|
||||||
AttachmentEdit = 'attachment-edit',
|
AttachmentEdit = 'attachment-edit',
|
||||||
@@ -25,71 +25,164 @@ export enum DialogID {
|
|||||||
UpdateLocation = 'update-location',
|
UpdateLocation = 'update-location',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - Keys present without ? => params required
|
||||||
|
* - Keys present with ? => params optional
|
||||||
|
* - Keys not present => no params allowed
|
||||||
|
*/
|
||||||
export type DialogParamsMap = {
|
export type DialogParamsMap = {
|
||||||
[DialogID.CreateItem]: { product?: BarcodeProduct };
|
[DialogID.ItemImage]:
|
||||||
[DialogID.ProductImport]: { barcode?: string };
|
| ({
|
||||||
|
type: 'preloaded';
|
||||||
|
originalSrc: string;
|
||||||
|
originalType?: string;
|
||||||
|
thumbnailSrc?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'attachment';
|
||||||
|
mimeType: string;
|
||||||
|
thumbnailId?: string;
|
||||||
|
}) & {
|
||||||
|
itemId: string;
|
||||||
|
attachmentId: string;
|
||||||
|
};
|
||||||
|
[DialogID.CreateItem]?: { product?: BarcodeProduct };
|
||||||
|
[DialogID.ProductImport]?: { barcode?: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
type DialogsWithParams = keyof DialogParamsMap;
|
/**
|
||||||
|
* Defines the payload type for a dialog's onClose callback.
|
||||||
|
*/
|
||||||
|
export type DialogResultMap = {
|
||||||
|
[DialogID.ItemImage]?: { action: 'delete', id: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Helpers to split IDs by requirement */
|
||||||
|
type OptionalKeys<T> = {
|
||||||
|
[K in keyof T]-?: {} extends Pick<T, K> ? K : never;
|
||||||
|
}[keyof T];
|
||||||
|
|
||||||
|
type RequiredKeys<T> = Exclude<keyof T, OptionalKeys<T>>;
|
||||||
|
|
||||||
|
type SpecifiedDialogIDs = keyof DialogParamsMap;
|
||||||
|
export type NoParamDialogIDs = Exclude<DialogID, SpecifiedDialogIDs>;
|
||||||
|
export type RequiredDialogIDs = RequiredKeys<DialogParamsMap>;
|
||||||
|
export type OptionalDialogIDs = OptionalKeys<DialogParamsMap>;
|
||||||
|
|
||||||
|
type ParamsOf<T extends DialogID> = T extends SpecifiedDialogIDs
|
||||||
|
? DialogParamsMap[T]
|
||||||
|
: never;
|
||||||
|
|
||||||
|
type ResultOf<T extends DialogID> = T extends keyof DialogResultMap
|
||||||
|
? DialogResultMap[T]
|
||||||
|
: void;
|
||||||
|
|
||||||
type OpenDialog = {
|
type OpenDialog = {
|
||||||
<T extends DialogID>(dialogId: T, params?: T extends DialogsWithParams ? DialogParamsMap[T] : undefined): void;
|
// Dialogs with no parameters
|
||||||
|
<T extends NoParamDialogIDs>(
|
||||||
|
dialogId: T,
|
||||||
|
options?: { onClose?: (result?: ResultOf<T>) => void; params?: never }
|
||||||
|
): void;
|
||||||
|
// Dialogs with required parameters
|
||||||
|
<T extends RequiredDialogIDs>(
|
||||||
|
dialogId: T,
|
||||||
|
options: { params: ParamsOf<T>; onClose?: (result?: ResultOf<T>) => void }
|
||||||
|
): void;
|
||||||
|
// Dialogs with optional parameters
|
||||||
|
<T extends OptionalDialogIDs>(
|
||||||
|
dialogId: T,
|
||||||
|
options?: { params?: ParamsOf<T>; onClose?: (result?: ResultOf<T>) => void }
|
||||||
|
): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CloseDialog = {
|
||||||
|
// Close the currently active dialog, no ID specified. No result payload.
|
||||||
|
(): void;
|
||||||
|
// Close a specific dialog that has a defined result type.
|
||||||
|
<T extends keyof DialogResultMap>(dialogId: T, result?: ResultOf<T>): void;
|
||||||
|
// Close a specific dialog that has NO defined result type.
|
||||||
|
<T extends Exclude<DialogID, keyof DialogResultMap>>(
|
||||||
|
dialogId: T,
|
||||||
|
result?: never
|
||||||
|
): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type OpenCallback = {
|
type OpenCallback = {
|
||||||
<T extends DialogID>(dialogId: T, callback: (params?: T extends keyof DialogParamsMap ? DialogParamsMap[T] : undefined) => void): void;
|
<T extends NoParamDialogIDs>(dialogId: T, cb: () => void): () => void;
|
||||||
}
|
<T extends RequiredDialogIDs>(
|
||||||
|
dialogId: T,
|
||||||
|
cb: (params: ParamsOf<T>) => void
|
||||||
|
): () => void;
|
||||||
|
<T extends OptionalDialogIDs>(
|
||||||
|
dialogId: T,
|
||||||
|
cb: (params?: ParamsOf<T>) => void
|
||||||
|
): () => void;
|
||||||
|
};
|
||||||
|
|
||||||
export const [useDialog, provideDialogContext] = createContext<{
|
export const [useDialog, provideDialogContext] = createContext<{
|
||||||
activeDialog: ComputedRef<DialogID | null>;
|
activeDialog: ComputedRef<DialogID | null>;
|
||||||
activeAlerts: ComputedRef<string[]>;
|
activeAlerts: ComputedRef<string[]>;
|
||||||
registerOpenDialogCallback: OpenCallback;
|
registerOpenDialogCallback: OpenCallback;
|
||||||
openDialog: OpenDialog;
|
openDialog: OpenDialog;
|
||||||
closeDialog: (dialogId?: DialogID) => void;
|
closeDialog: CloseDialog;
|
||||||
addAlert: (alertId: string) => void;
|
addAlert: (alertId: string) => void;
|
||||||
removeAlert: (alertId: string) => void;
|
removeAlert: (alertId: string) => void;
|
||||||
}>("DialogProvider");
|
}>('DialogProvider');
|
||||||
|
|
||||||
export const useDialogHotkey = (
|
/**
|
||||||
|
* Hotkey helper:
|
||||||
|
* - No/optional params: pass dialogId + key
|
||||||
|
* - Required params: pass dialogId + key + getParams()
|
||||||
|
*/
|
||||||
|
type HotkeyKey = {
|
||||||
|
shift?: boolean;
|
||||||
|
ctrl?: boolean;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useDialogHotkey<T extends NoParamDialogIDs | OptionalDialogIDs>(
|
||||||
|
dialogId: T,
|
||||||
|
key: HotkeyKey
|
||||||
|
): void;
|
||||||
|
export function useDialogHotkey<T extends RequiredDialogIDs>(
|
||||||
|
dialogId: T,
|
||||||
|
key: HotkeyKey,
|
||||||
|
getParams: () => ParamsOf<T>
|
||||||
|
): void;
|
||||||
|
export function useDialogHotkey(
|
||||||
dialogId: DialogID,
|
dialogId: DialogID,
|
||||||
key: {
|
key: HotkeyKey,
|
||||||
shift?: boolean;
|
getParams?: () => unknown
|
||||||
ctrl?: boolean;
|
) {
|
||||||
code: string;
|
|
||||||
}
|
|
||||||
) => {
|
|
||||||
const { openDialog } = useDialog();
|
const { openDialog } = useDialog();
|
||||||
|
|
||||||
const activeElement = useActiveElement();
|
const activeElement = useActiveElement();
|
||||||
|
|
||||||
const notUsingInput = computed(
|
const notUsingInput = computed(
|
||||||
() => activeElement.value?.tagName !== "INPUT" && activeElement.value?.tagName !== "TEXTAREA"
|
() =>
|
||||||
|
activeElement.value?.tagName !== 'INPUT' &&
|
||||||
|
activeElement.value?.tagName !== 'TEXTAREA'
|
||||||
);
|
);
|
||||||
|
|
||||||
useMagicKeys({
|
useMagicKeys({
|
||||||
passive: false,
|
passive: false,
|
||||||
onEventFired: event => {
|
onEventFired: (event) => {
|
||||||
// console.log({
|
|
||||||
// event,
|
|
||||||
// notUsingInput: notUsingInput.value,
|
|
||||||
// eventType: event.type,
|
|
||||||
// keyCode: event.code,
|
|
||||||
// matchingKeyCode: key.code === event.code,
|
|
||||||
// shift: event.shiftKey,
|
|
||||||
// matchingShift: key.shift === undefined || event.shiftKey === key.shift,
|
|
||||||
// ctrl: event.ctrlKey,
|
|
||||||
// matchingCtrl: key.ctrl === undefined || event.ctrlKey === key.ctrl,
|
|
||||||
// });
|
|
||||||
if (
|
if (
|
||||||
notUsingInput.value &&
|
notUsingInput.value &&
|
||||||
event.type === "keydown" &&
|
event.type === 'keydown' &&
|
||||||
event.code === key.code &&
|
event.code === key.code &&
|
||||||
(key.shift === undefined || event.shiftKey === key.shift) &&
|
(key.shift === undefined || event.shiftKey === key.shift) &&
|
||||||
(key.ctrl === undefined || event.ctrlKey === key.ctrl)
|
(key.ctrl === undefined || event.ctrlKey === key.ctrl)
|
||||||
) {
|
) {
|
||||||
openDialog(dialogId);
|
if (getParams) {
|
||||||
|
openDialog(dialogId as RequiredDialogIDs, {
|
||||||
|
params: getParams() as never,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
openDialog(dialogId as NoParamDialogIDs);
|
||||||
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
const isOpen = computed(() => (activeDialog.value && activeDialog.value === props.dialogId));
|
const isOpen = computed(() => (activeDialog.value && activeDialog.value === props.dialogId));
|
||||||
const onOpenChange = (open: boolean) => {
|
const onOpenChange = (open: boolean) => {
|
||||||
if (!open) closeDialog(props.dialogId);
|
if (!open) closeDialog(props.dialogId as any);
|
||||||
};
|
};
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(props, emits);
|
const forwarded = useForwardPropsEmits(props, emits);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
const isOpen = computed(() => activeDialog.value !== null && activeDialog.value === props.dialogId);
|
const isOpen = computed(() => activeDialog.value !== null && activeDialog.value === props.dialogId);
|
||||||
const onOpenChange = (open: boolean) => {
|
const onOpenChange = (open: boolean) => {
|
||||||
if (!open) closeDialog(props.dialogId);
|
if (!open) closeDialog(props.dialogId as any);
|
||||||
};
|
};
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(props, emits);
|
const forwarded = useForwardPropsEmits(props, emits);
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
v-for="btn in dropdown"
|
v-for="btn in dropdown"
|
||||||
:key="btn.id"
|
:key="btn.id"
|
||||||
class="group cursor-pointer text-lg"
|
class="group cursor-pointer text-lg"
|
||||||
@click="openDialog(btn.dialogId)"
|
@click="openDialog(btn.dialogId as NoParamDialogIDs)"
|
||||||
>
|
>
|
||||||
{{ btn.name.value }}
|
{{ btn.name.value }}
|
||||||
<Shortcut
|
<Shortcut
|
||||||
@@ -210,7 +210,7 @@
|
|||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { toast } from "@/components/ui/sonner";
|
import { toast } from "@/components/ui/sonner";
|
||||||
import { DialogID } from "~/components/ui/dialog-provider/utils";
|
import { DialogID, type NoParamDialogIDs, type OptionalDialogIDs } from "~/components/ui/dialog-provider/utils";
|
||||||
|
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const username = computed(() => authCtx.user?.name || "User");
|
const username = computed(() => authCtx.user?.name || "User");
|
||||||
@@ -269,7 +269,7 @@
|
|||||||
id: number;
|
id: number;
|
||||||
name: ComputedRef<string>;
|
name: ComputedRef<string>;
|
||||||
shortcut: string;
|
shortcut: string;
|
||||||
dialogId: DialogID;
|
dialogId: NoParamDialogIDs | OptionalDialogIDs;
|
||||||
};
|
};
|
||||||
|
|
||||||
const dropdown: DropdownItem[] = [
|
const dropdown: DropdownItem[] = [
|
||||||
@@ -343,7 +343,7 @@
|
|||||||
const quickMenuActions = reactive([
|
const quickMenuActions = reactive([
|
||||||
...dropdown.map(v => ({
|
...dropdown.map(v => ({
|
||||||
text: computed(() => v.name.value),
|
text: computed(() => v.name.value),
|
||||||
dialogId: v.dialogId,
|
dialogId: v.dialogId as NoParamDialogIDs,
|
||||||
shortcut: v.shortcut.split("+")[1],
|
shortcut: v.shortcut.split("+")[1],
|
||||||
type: "create" as const,
|
type: "create" as const,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -311,7 +311,8 @@
|
|||||||
"primary_photo_sub": "This option is only available for photos. Only one photo can be primary. If you select this option, the current primary photo, if any will be unselected.",
|
"primary_photo_sub": "This option is only available for photos. Only one photo can be primary. If you select this option, the current primary photo, if any will be unselected.",
|
||||||
"select_type": "Select a type",
|
"select_type": "Select a type",
|
||||||
"title": "Attachment Edit"
|
"title": "Attachment Edit"
|
||||||
}
|
},
|
||||||
|
"view_image": "View Image"
|
||||||
},
|
},
|
||||||
"edit_details": "Edit Details",
|
"edit_details": "Edit Details",
|
||||||
"field_selector": "Field Selector",
|
"field_selector": "Field Selector",
|
||||||
|
|||||||
@@ -4,11 +4,9 @@
|
|||||||
import type { AnyDetail, Detail, Details } from "~~/components/global/DetailsSection/types";
|
import type { AnyDetail, Detail, Details } from "~~/components/global/DetailsSection/types";
|
||||||
import { filterZeroValues } from "~~/components/global/DetailsSection/types";
|
import { filterZeroValues } from "~~/components/global/DetailsSection/types";
|
||||||
import type { ItemAttachment } from "~~/lib/api/types/data-contracts";
|
import type { ItemAttachment } from "~~/lib/api/types/data-contracts";
|
||||||
import MdiClose from "~icons/mdi/close";
|
|
||||||
import MdiPackageVariant from "~icons/mdi/package-variant";
|
import MdiPackageVariant from "~icons/mdi/package-variant";
|
||||||
import MdiPlus from "~icons/mdi/plus";
|
import MdiPlus from "~icons/mdi/plus";
|
||||||
import MdiMinus from "~icons/mdi/minus";
|
import MdiMinus from "~icons/mdi/minus";
|
||||||
import MdiDownload from "~icons/mdi/download";
|
|
||||||
import MdiContentCopy from "~icons/mdi/content-copy";
|
import MdiContentCopy from "~icons/mdi/content-copy";
|
||||||
import MdiDelete from "~icons/mdi/delete";
|
import MdiDelete from "~icons/mdi/delete";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@@ -19,8 +17,7 @@
|
|||||||
BreadcrumbList,
|
BreadcrumbList,
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from "@/components/ui/breadcrumb";
|
} from "@/components/ui/breadcrumb";
|
||||||
import { Button, ButtonGroup, buttonVariants } from "@/components/ui/button";
|
import { Button, ButtonGroup } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
|
||||||
import { useDialog } from "@/components/ui/dialog-provider";
|
import { useDialog } from "@/components/ui/dialog-provider";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
@@ -29,7 +26,7 @@
|
|||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const { openDialog, closeDialog } = useDialog();
|
const { openDialog } = useDialog();
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ["auth"],
|
middleware: ["auth"],
|
||||||
@@ -109,6 +106,7 @@
|
|||||||
type Photo = {
|
type Photo = {
|
||||||
thumbnailSrc?: string;
|
thumbnailSrc?: string;
|
||||||
originalSrc: string;
|
originalSrc: string;
|
||||||
|
attachmentId: string;
|
||||||
originalType?: string;
|
originalType?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -122,6 +120,7 @@
|
|||||||
const photo: Photo = {
|
const photo: Photo = {
|
||||||
originalSrc: api.authURL(`/items/${item.value!.id}/attachments/${cur.id}`),
|
originalSrc: api.authURL(`/items/${item.value!.id}/attachments/${cur.id}`),
|
||||||
originalType: cur.mimeType,
|
originalType: cur.mimeType,
|
||||||
|
attachmentId: cur.id,
|
||||||
};
|
};
|
||||||
if (cur.thumbnail) {
|
if (cur.thumbnail) {
|
||||||
photo.thumbnailSrc = api.authURL(`/items/${item.value!.id}/attachments/${cur.thumbnail.id}`);
|
photo.thumbnailSrc = api.authURL(`/items/${item.value!.id}/attachments/${cur.thumbnail.id}`);
|
||||||
@@ -406,19 +405,22 @@
|
|||||||
return v;
|
return v;
|
||||||
});
|
});
|
||||||
|
|
||||||
const dialoged = reactive<Photo>({
|
function openImageDialog(img: Photo, itemId: string) {
|
||||||
originalSrc: "",
|
openDialog(DialogID.ItemImage, {
|
||||||
});
|
params: {
|
||||||
|
type: "preloaded",
|
||||||
function openImageDialog(img: Photo) {
|
originalSrc: img.originalSrc,
|
||||||
dialoged.originalSrc = img.originalSrc;
|
originalType: img.originalType,
|
||||||
dialoged.originalType = img.originalType;
|
thumbnailSrc: img.thumbnailSrc,
|
||||||
dialoged.thumbnailSrc = img.thumbnailSrc;
|
attachmentId: img.attachmentId,
|
||||||
openDialog(DialogID.ItemImage);
|
itemId,
|
||||||
}
|
},
|
||||||
|
onClose: result => {
|
||||||
function closeImageDialog() {
|
if (result?.action === "delete") {
|
||||||
closeDialog(DialogID.ItemImage);
|
item.value!.attachments = item.value!.attachments.filter(a => a.id !== result.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentUrl = computed(() => {
|
const currentUrl = computed(() => {
|
||||||
@@ -552,6 +554,7 @@
|
|||||||
<!-- set page title -->
|
<!-- set page title -->
|
||||||
<Title>{{ item.name }}</Title>
|
<Title>{{ item.name }}</Title>
|
||||||
|
|
||||||
|
<ItemImageDialog />
|
||||||
<Dialog :dialog-id="DialogID.DuplicateTemporarySettings">
|
<Dialog :dialog-id="DialogID.DuplicateTemporarySettings">
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -571,26 +574,6 @@
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog :dialog-id="DialogID.ItemImage">
|
|
||||||
<DialogContent class="w-auto border-transparent bg-transparent p-0" disable-close>
|
|
||||||
<picture>
|
|
||||||
<source :srcset="dialoged.originalSrc" :type="dialoged.originalType" />
|
|
||||||
<img :src="dialoged.thumbnailSrc" alt="attachement image" />
|
|
||||||
</picture>
|
|
||||||
<a
|
|
||||||
:class="buttonVariants({ size: 'icon' })"
|
|
||||||
:href="dialoged.originalSrc"
|
|
||||||
download
|
|
||||||
class="absolute right-11 top-1"
|
|
||||||
>
|
|
||||||
<MdiDownload />
|
|
||||||
</a>
|
|
||||||
<Button size="icon" class="absolute right-1 top-1" @click="closeImageDialog">
|
|
||||||
<MdiClose />
|
|
||||||
</Button>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<Card class="p-3">
|
<Card class="p-3">
|
||||||
<header :class="{ 'mb-2': item.description }">
|
<header :class="{ 'mb-2': item.description }">
|
||||||
@@ -726,7 +709,7 @@
|
|||||||
<BaseCard v-if="photos && photos.length > 0">
|
<BaseCard v-if="photos && photos.length > 0">
|
||||||
<template #title> {{ $t("items.photos") }} </template>
|
<template #title> {{ $t("items.photos") }} </template>
|
||||||
<div class="scroll-bg container mx-auto flex max-h-[500px] flex-wrap gap-2 overflow-y-scroll border-t p-4">
|
<div class="scroll-bg container mx-auto flex max-h-[500px] flex-wrap gap-2 overflow-y-scroll border-t p-4">
|
||||||
<button v-for="(img, i) in photos" :key="i" @click="openImageDialog(img)">
|
<button v-for="(img, i) in photos" :key="i" @click="openImageDialog(img, item.id)">
|
||||||
<picture>
|
<picture>
|
||||||
<source :srcset="img.originalSrc" :type="img.originalType" />
|
<source :srcset="img.originalSrc" :type="img.originalType" />
|
||||||
<img class="max-h-[200px] rounded" :src="img.thumbnailSrc" alt="attachment image" />
|
<img class="max-h-[200px] rounded" :src="img.thumbnailSrc" alt="attachment image" />
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import MdiDelete from "~icons/mdi/delete";
|
import MdiDelete from "~icons/mdi/delete";
|
||||||
import MdiPencil from "~icons/mdi/pencil";
|
import MdiPencil from "~icons/mdi/pencil";
|
||||||
import MdiContentSaveOutline from "~icons/mdi/content-save-outline";
|
import MdiContentSaveOutline from "~icons/mdi/content-save-outline";
|
||||||
|
import MdiImageOutline from "~icons/mdi/image-outline";
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useDialog } from "@/components/ui/dialog-provider";
|
import { useDialog } from "@/components/ui/dialog-provider";
|
||||||
@@ -697,6 +698,33 @@
|
|||||||
{{ $t(`items.${attachment.type}`) }}
|
{{ $t(`items.${attachment.type}`) }}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
|
<Tooltip v-if="attachment.type === 'photo'">
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
@click="
|
||||||
|
openDialog(DialogID.ItemImage, {
|
||||||
|
params: {
|
||||||
|
type: 'attachment',
|
||||||
|
itemId: item.id,
|
||||||
|
attachmentId: attachment.id,
|
||||||
|
thumbnailId: attachment.thumbnail?.id,
|
||||||
|
mimeType: attachment.mimeType,
|
||||||
|
},
|
||||||
|
onClose: result => {
|
||||||
|
if (result?.action === 'delete') {
|
||||||
|
item.attachments = item.attachments.filter(a => a.id !== result.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<MdiImageOutline />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{{ $t("items.edit.view_image") }}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<Button variant="destructive" size="icon" @click="deleteAttachment(attachment.id)">
|
<Button variant="destructive" size="icon" @click="deleteAttachment(attachment.id)">
|
||||||
|
|||||||
Reference in New Issue
Block a user