mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-21 21:33:02 +01:00
Compare commits
12 Commits
mk/db-sett
...
mk/keyless
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5131630640 | ||
|
|
b7369b00ee | ||
|
|
62ed3fabc2 | ||
|
|
304fc7f11f | ||
|
|
1b7a7a1999 | ||
|
|
a63f08ad87 | ||
|
|
9cb1a3f83c | ||
|
|
f86d38412b | ||
|
|
cbbe056d01 | ||
|
|
5f6b1a0805 | ||
|
|
27e9eb2277 | ||
|
|
6fcd10d796 |
21
.github/workflows/binaries-publish.yaml
vendored
21
.github/workflows/binaries-publish.yaml
vendored
@@ -1,6 +1,7 @@
|
||||
name: Publish Release Binaries
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags: [ 'v*.*.*' ]
|
||||
|
||||
@@ -8,6 +9,10 @@ jobs:
|
||||
goreleaser:
|
||||
name: goreleaser
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -37,6 +42,7 @@ jobs:
|
||||
go install github.com/sigstore/cosign/cmd/cosign@latest
|
||||
|
||||
- name: Run GoReleaser
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: goreleaser/goreleaser-action@v5
|
||||
with:
|
||||
workdir: "backend"
|
||||
@@ -45,3 +51,18 @@ jobs:
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
COSIGN_PWD: ${{ secrets.COSIGN_PWD }}
|
||||
COSIGN_YES: "true"
|
||||
|
||||
- name: Run GoReleaser No Release
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
uses: goreleaser/goreleaser-action@v5
|
||||
with:
|
||||
workdir: "backend"
|
||||
distribution: goreleaser
|
||||
version: "~> v2"
|
||||
args: release --clean --snapshot --skip=publish
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
COSIGN_PWD: ${{ secrets.COSIGN_PWD }}
|
||||
COSIGN_YES: "true"
|
||||
@@ -43,7 +43,7 @@ signs:
|
||||
stdin: "{{ .Env.COSIGN_PWD }}"
|
||||
args:
|
||||
- "sign-blob"
|
||||
- "--key=cosign.key"
|
||||
- "--output-certificate=${certificate}"
|
||||
- "--output-signature=${signature}"
|
||||
- "${artifact}"
|
||||
- "--yes" # needed on cosign 2.0.0+
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
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 {
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
export type QuickMenuAction =
|
||||
| { 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({
|
||||
actions: {
|
||||
@@ -40,7 +40,7 @@
|
||||
const item = props.actions.filter(item => 'shortcut' in item).find(item => item.shortcut === e.key);
|
||||
if (item) {
|
||||
e.preventDefault();
|
||||
openDialog(item.dialogId);
|
||||
openDialog(item.dialogId as NoParamDialogIDs);
|
||||
}
|
||||
// if esc is pressed, close the dialog
|
||||
if (e.key === 'Escape') {
|
||||
@@ -61,7 +61,7 @@
|
||||
@select="
|
||||
e => {
|
||||
e.preventDefault();
|
||||
openDialog(create.dialogId);
|
||||
openDialog(create.dialogId as NoParamDialogIDs);
|
||||
}
|
||||
"
|
||||
>
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
};
|
||||
|
||||
const handleButtonClick = () => {
|
||||
openDialog(DialogID.ProductImport, { barcode: detectedBarcode.value });
|
||||
openDialog(DialogID.ProductImport, { params: { barcode: detectedBarcode.value } });
|
||||
};
|
||||
|
||||
const startScanner = async () => {
|
||||
|
||||
45
frontend/components/App/ThemePicker.vue
Normal file
45
frontend/components/App/ThemePicker.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { themes } from "~~/lib/data/themes";
|
||||
import { useTheme } from "~/composables/use-theme";
|
||||
|
||||
const { setTheme } = useTheme();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="homebox grid grid-cols-1 gap-4 font-sans sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
<div
|
||||
v-for="theme in themes"
|
||||
:key="theme.value"
|
||||
:class="'theme-' + theme.value"
|
||||
class="overflow-hidden rounded-lg border outline-2 outline-offset-2"
|
||||
:data-theme="theme.value"
|
||||
:data-set-theme="theme.value"
|
||||
data-act-class="outline"
|
||||
@click="setTheme(theme.value)"
|
||||
>
|
||||
<div :data-theme="theme.value" class="w-full cursor-pointer bg-background-accent text-foreground">
|
||||
<div class="grid grid-cols-5 grid-rows-3">
|
||||
<div class="col-start-1 row-start-1 bg-background"></div>
|
||||
<div class="col-start-1 row-start-2 bg-sidebar"></div>
|
||||
<div class="col-start-1 row-start-3 bg-background-accent"></div>
|
||||
<div class="col-span-4 col-start-2 row-span-3 row-start-1 flex flex-col gap-1 bg-background p-2">
|
||||
<div class="font-bold">{{ theme.label }}</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<div class="flex size-5 items-center justify-center rounded bg-primary lg:size-6">
|
||||
<div class="text-sm font-bold text-primary-foreground">A</div>
|
||||
</div>
|
||||
<div class="flex size-5 items-center justify-center rounded bg-secondary lg:size-6">
|
||||
<div class="text-sm font-bold text-secondary-foreground">A</div>
|
||||
</div>
|
||||
<div class="flex size-5 items-center justify-center rounded bg-accent lg:size-6">
|
||||
<div class="text-sm font-bold text-accent-foreground">A</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -15,6 +15,7 @@
|
||||
import "@vuepic/vue-datepicker/dist/main.css";
|
||||
import * as datelib from "~/lib/datelib/datelib";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { darkThemes } from "~/lib/data/themes";
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "update:text"]);
|
||||
|
||||
@@ -34,7 +35,7 @@
|
||||
},
|
||||
});
|
||||
|
||||
const isDark = useIsDark();
|
||||
const isDark = useIsThemeInList(darkThemes);
|
||||
|
||||
const formatDate = (date: Date | string | number) => fmtDate(date, "human", "date");
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
const headers = defaultHeaders;
|
||||
|
||||
onMounted(() => {
|
||||
registerOpenDialogCallback(DialogID.ProductImport, params => {
|
||||
const cleanup = registerOpenDialogCallback(DialogID.ProductImport, params => {
|
||||
selectedRow.value = -1;
|
||||
searching.value = false;
|
||||
errorMessage.value = null;
|
||||
@@ -168,6 +168,8 @@
|
||||
products.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(cleanup);
|
||||
});
|
||||
|
||||
const api = useUserApi();
|
||||
@@ -180,7 +182,9 @@
|
||||
selectedRow.value < products.value.length
|
||||
) {
|
||||
const p = products.value![selectedRow.value];
|
||||
openDialog(DialogID.CreateItem, { product: p });
|
||||
openDialog(DialogID.CreateItem, {
|
||||
params: { product: p },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -300,7 +300,7 @@
|
||||
}
|
||||
|
||||
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
|
||||
subItemCreate.value = subItemCreateParam.value === "y";
|
||||
let parentItemLocationId = null;
|
||||
@@ -359,6 +359,8 @@
|
||||
form.labels = labels.value.filter(l => l.id === labelId.value).map(l => l.id);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(cleanup);
|
||||
});
|
||||
|
||||
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>
|
||||
<AlertDialogPortal>
|
||||
<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
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
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,
|
||||
)
|
||||
"
|
||||
|
||||
@@ -1,40 +1,59 @@
|
||||
<!-- DialogProvider.vue -->
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from "vue";
|
||||
import { provideDialogContext, type DialogID, type DialogParamsMap } from "./utils";
|
||||
import { ref, reactive, computed } from 'vue';
|
||||
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 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>(
|
||||
dialogId: T,
|
||||
callback: (params?: T extends keyof DialogParamsMap ? DialogParamsMap[T] : undefined) => 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;
|
||||
|
||||
activeDialog.value = dialogId;
|
||||
activeOnCloseCallback = options?.onClose;
|
||||
|
||||
const openCallback = openDialogCallbacks.get(dialogId);
|
||||
if (openCallback) {
|
||||
openCallback(params);
|
||||
openCallback(options?.params);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDialog = (dialogId?: DialogID) => {
|
||||
if (dialogId) {
|
||||
if (activeDialog.value && activeDialog.value === dialogId) {
|
||||
activeDialog.value = null;
|
||||
function closeDialog(dialogId?: DialogID, result?: any) {
|
||||
// No dialogId passed -> close current active dialog without result
|
||||
if (!dialogId) {
|
||||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const addAlert = (alertId: string) => {
|
||||
activeAlerts.push(alertId);
|
||||
@@ -42,9 +61,7 @@
|
||||
|
||||
const removeAlert = (alertId: string) => {
|
||||
const index = activeAlerts.indexOf(alertId);
|
||||
if (index !== -1) {
|
||||
activeAlerts.splice(index, 1);
|
||||
}
|
||||
if (index !== -1) activeAlerts.splice(index, 1);
|
||||
};
|
||||
|
||||
// Provide context to child components
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ComputedRef } from "vue";
|
||||
import { createContext } from "reka-ui";
|
||||
import { useMagicKeys, useActiveElement } from "@vueuse/core";
|
||||
import type { BarcodeProduct } from "~~/lib/api/types/data-contracts";
|
||||
import { computed, type ComputedRef } from 'vue';
|
||||
import { createContext } from 'reka-ui';
|
||||
import { useMagicKeys, useActiveElement } from '@vueuse/core';
|
||||
import type { BarcodeProduct } from '~~/lib/api/types/data-contracts';
|
||||
|
||||
export enum DialogID {
|
||||
AttachmentEdit = 'attachment-edit',
|
||||
@@ -25,71 +25,164 @@ export enum DialogID {
|
||||
UpdateLocation = 'update-location',
|
||||
}
|
||||
|
||||
/**
|
||||
* - Keys present without ? => params required
|
||||
* - Keys present with ? => params optional
|
||||
* - Keys not present => no params allowed
|
||||
*/
|
||||
export type DialogParamsMap = {
|
||||
[DialogID.CreateItem]: { product?: BarcodeProduct };
|
||||
[DialogID.ProductImport]: { barcode?: string };
|
||||
[DialogID.ItemImage]:
|
||||
| ({
|
||||
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 = {
|
||||
<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 = {
|
||||
<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<{
|
||||
activeDialog: ComputedRef<DialogID | null>;
|
||||
activeAlerts: ComputedRef<string[]>;
|
||||
registerOpenDialogCallback: OpenCallback;
|
||||
openDialog: OpenDialog;
|
||||
closeDialog: (dialogId?: DialogID) => void;
|
||||
closeDialog: CloseDialog;
|
||||
addAlert: (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,
|
||||
key: {
|
||||
shift?: boolean;
|
||||
ctrl?: boolean;
|
||||
code: string;
|
||||
}
|
||||
) => {
|
||||
key: HotkeyKey,
|
||||
getParams?: () => unknown
|
||||
) {
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
const activeElement = useActiveElement();
|
||||
|
||||
const notUsingInput = computed(
|
||||
() => activeElement.value?.tagName !== "INPUT" && activeElement.value?.tagName !== "TEXTAREA"
|
||||
() =>
|
||||
activeElement.value?.tagName !== 'INPUT' &&
|
||||
activeElement.value?.tagName !== 'TEXTAREA'
|
||||
);
|
||||
|
||||
useMagicKeys({
|
||||
passive: false,
|
||||
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,
|
||||
// });
|
||||
onEventFired: (event) => {
|
||||
if (
|
||||
notUsingInput.value &&
|
||||
event.type === "keydown" &&
|
||||
event.type === 'keydown' &&
|
||||
event.code === key.code &&
|
||||
(key.shift === undefined || event.shiftKey === key.shift) &&
|
||||
(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();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
const isOpen = computed(() => (activeDialog.value && activeDialog.value === props.dialogId));
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (!open) closeDialog(props.dialogId);
|
||||
if (!open) closeDialog(props.dialogId as any);
|
||||
};
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
const isOpen = computed(() => activeDialog.value !== null && activeDialog.value === props.dialogId);
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (!open) closeDialog(props.dialogId);
|
||||
if (!open) closeDialog(props.dialogId as any);
|
||||
};
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComputedRef } from "vue";
|
||||
import type { DaisyTheme } from "~~/lib/data/themes";
|
||||
import { type DaisyTheme } from "~~/lib/data/themes";
|
||||
|
||||
export interface UseTheme {
|
||||
theme: ComputedRef<DaisyTheme>;
|
||||
@@ -42,27 +42,11 @@ export function useTheme(): UseTheme {
|
||||
return { theme, setTheme };
|
||||
}
|
||||
|
||||
export function useIsDark() {
|
||||
export function useIsThemeInList(list: DaisyTheme[]) {
|
||||
const theme = useTheme();
|
||||
|
||||
const darkthemes = [
|
||||
"synthwave",
|
||||
"retro",
|
||||
"cyberpunk",
|
||||
"valentine",
|
||||
"halloween",
|
||||
"forest",
|
||||
"aqua",
|
||||
"black",
|
||||
"luxury",
|
||||
"dracula",
|
||||
"business",
|
||||
"night",
|
||||
"coffee",
|
||||
];
|
||||
|
||||
return computed(() => {
|
||||
return darkthemes.includes(theme.theme.value);
|
||||
return list.includes(theme.theme.value);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
v-for="btn in dropdown"
|
||||
:key="btn.id"
|
||||
class="group cursor-pointer text-lg"
|
||||
@click="openDialog(btn.dialogId)"
|
||||
@click="openDialog(btn.dialogId as NoParamDialogIDs)"
|
||||
>
|
||||
{{ btn.name.value }}
|
||||
<Shortcut
|
||||
@@ -210,7 +210,7 @@
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Button } from "~/components/ui/button";
|
||||
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 username = computed(() => authCtx.user?.name || "User");
|
||||
@@ -269,7 +269,7 @@
|
||||
id: number;
|
||||
name: ComputedRef<string>;
|
||||
shortcut: string;
|
||||
dialogId: DialogID;
|
||||
dialogId: NoParamDialogIDs | OptionalDialogIDs;
|
||||
};
|
||||
|
||||
const dropdown: DropdownItem[] = [
|
||||
@@ -343,7 +343,7 @@
|
||||
const quickMenuActions = reactive([
|
||||
...dropdown.map(v => ({
|
||||
text: computed(() => v.name.value),
|
||||
dialogId: v.dialogId,
|
||||
dialogId: v.dialogId as NoParamDialogIDs,
|
||||
shortcut: v.shortcut.split("+")[1],
|
||||
type: "create" as const,
|
||||
})),
|
||||
|
||||
@@ -153,3 +153,19 @@ export const themes: ThemeOption[] = [
|
||||
value: "winter",
|
||||
},
|
||||
];
|
||||
|
||||
export const darkThemes: DaisyTheme[] = [
|
||||
"synthwave",
|
||||
"retro",
|
||||
"cyberpunk",
|
||||
"valentine",
|
||||
"halloween",
|
||||
"forest",
|
||||
"aqua",
|
||||
"black",
|
||||
"luxury",
|
||||
"dracula",
|
||||
"business",
|
||||
"night",
|
||||
"coffee",
|
||||
];
|
||||
|
||||
@@ -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.",
|
||||
"select_type": "Select a type",
|
||||
"title": "Attachment Edit"
|
||||
}
|
||||
},
|
||||
"view_image": "View Image"
|
||||
},
|
||||
"edit_details": "Edit Details",
|
||||
"field_selector": "Field Selector",
|
||||
|
||||
@@ -54,6 +54,20 @@
|
||||
}
|
||||
});
|
||||
|
||||
const isEvilAccentTheme = useIsThemeInList([
|
||||
"bumblebee",
|
||||
"corporate",
|
||||
"forest",
|
||||
"pastel",
|
||||
"wireframe",
|
||||
"black",
|
||||
"dracula",
|
||||
"autumn",
|
||||
"acid",
|
||||
]);
|
||||
const isEvilForegroundTheme = useIsThemeInList(["light", "aqua", "fantasy", "autumn", "night"]);
|
||||
const isLofiTheme = useIsThemeInList(["lofi"]);
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -166,14 +180,19 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<header class="mx-auto p-4 text-accent sm:flex sm:items-end sm:p-6 lg:p-14">
|
||||
<header
|
||||
class="mx-auto p-4 sm:flex sm:items-end sm:p-6 lg:p-14"
|
||||
:class="{ 'text-accent': !isEvilAccentTheme, 'text-white': isLofiTheme }"
|
||||
>
|
||||
<div class="z-10">
|
||||
<h2 class="mt-1 flex text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl">
|
||||
HomeB
|
||||
<AppLogo class="-mb-4 w-12" />
|
||||
x
|
||||
</h2>
|
||||
<p class="ml-1 text-lg text-foreground">{{ $t("index.tagline") }}</p>
|
||||
<p class="ml-1 text-lg" :class="{ 'text-foreground': !isEvilForegroundTheme, 'text-white': isLofiTheme }">
|
||||
{{ $t("index.tagline") }}
|
||||
</p>
|
||||
</div>
|
||||
<TooltipProvider :delay-duration="0">
|
||||
<div class="z-10 ml-auto mt-6 flex items-center gap-4 sm:mt-0">
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
import type { AnyDetail, Detail, Details } from "~~/components/global/DetailsSection/types";
|
||||
import { filterZeroValues } from "~~/components/global/DetailsSection/types";
|
||||
import type { ItemAttachment } from "~~/lib/api/types/data-contracts";
|
||||
import MdiClose from "~icons/mdi/close";
|
||||
import MdiPackageVariant from "~icons/mdi/package-variant";
|
||||
import MdiPlus from "~icons/mdi/plus";
|
||||
import MdiMinus from "~icons/mdi/minus";
|
||||
import MdiDownload from "~icons/mdi/download";
|
||||
import MdiContentCopy from "~icons/mdi/content-copy";
|
||||
import MdiDelete from "~icons/mdi/delete";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -19,8 +17,7 @@
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { Button, ButtonGroup, buttonVariants } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Button, ButtonGroup } from "@/components/ui/button";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -29,7 +26,7 @@
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { openDialog, closeDialog } = useDialog();
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
definePageMeta({
|
||||
middleware: ["auth"],
|
||||
@@ -109,6 +106,7 @@
|
||||
type Photo = {
|
||||
thumbnailSrc?: string;
|
||||
originalSrc: string;
|
||||
attachmentId: string;
|
||||
originalType?: string;
|
||||
};
|
||||
|
||||
@@ -122,6 +120,7 @@
|
||||
const photo: Photo = {
|
||||
originalSrc: api.authURL(`/items/${item.value!.id}/attachments/${cur.id}`),
|
||||
originalType: cur.mimeType,
|
||||
attachmentId: cur.id,
|
||||
};
|
||||
if (cur.thumbnail) {
|
||||
photo.thumbnailSrc = api.authURL(`/items/${item.value!.id}/attachments/${cur.thumbnail.id}`);
|
||||
@@ -406,19 +405,22 @@
|
||||
return v;
|
||||
});
|
||||
|
||||
const dialoged = reactive<Photo>({
|
||||
originalSrc: "",
|
||||
});
|
||||
|
||||
function openImageDialog(img: Photo) {
|
||||
dialoged.originalSrc = img.originalSrc;
|
||||
dialoged.originalType = img.originalType;
|
||||
dialoged.thumbnailSrc = img.thumbnailSrc;
|
||||
openDialog(DialogID.ItemImage);
|
||||
}
|
||||
|
||||
function closeImageDialog() {
|
||||
closeDialog(DialogID.ItemImage);
|
||||
function openImageDialog(img: Photo, itemId: string) {
|
||||
openDialog(DialogID.ItemImage, {
|
||||
params: {
|
||||
type: "preloaded",
|
||||
originalSrc: img.originalSrc,
|
||||
originalType: img.originalType,
|
||||
thumbnailSrc: img.thumbnailSrc,
|
||||
attachmentId: img.attachmentId,
|
||||
itemId,
|
||||
},
|
||||
onClose: result => {
|
||||
if (result?.action === "delete") {
|
||||
item.value!.attachments = item.value!.attachments.filter(a => a.id !== result.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const currentUrl = computed(() => {
|
||||
@@ -552,6 +554,7 @@
|
||||
<!-- set page title -->
|
||||
<Title>{{ item.name }}</Title>
|
||||
|
||||
<ItemImageDialog />
|
||||
<Dialog :dialog-id="DialogID.DuplicateTemporarySettings">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
@@ -571,26 +574,6 @@
|
||||
</DialogContent>
|
||||
</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>
|
||||
<Card class="p-3">
|
||||
<header :class="{ 'mb-2': item.description }">
|
||||
@@ -726,7 +709,7 @@
|
||||
<BaseCard v-if="photos && photos.length > 0">
|
||||
<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">
|
||||
<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>
|
||||
<source :srcset="img.originalSrc" :type="img.originalType" />
|
||||
<img class="max-h-[200px] rounded" :src="img.thumbnailSrc" alt="attachment image" />
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import MdiDelete from "~icons/mdi/delete";
|
||||
import MdiPencil from "~icons/mdi/pencil";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
@@ -697,6 +698,33 @@
|
||||
{{ $t(`items.${attachment.type}`) }}
|
||||
</p>
|
||||
<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>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="destructive" size="icon" @click="deleteAttachment(attachment.id)">
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { toast } from "@/components/ui/sonner";
|
||||
import type { Detail } from "~~/components/global/DetailsSection/types";
|
||||
import { themes } from "~~/lib/data/themes";
|
||||
import type { CurrenciesCurrency, NotifierCreate, NotifierOut } from "~~/lib/api/types/data-contracts";
|
||||
import MdiLoading from "~icons/mdi/loading";
|
||||
import MdiAccount from "~icons/mdi/account";
|
||||
@@ -21,6 +20,7 @@
|
||||
import LanguageSelector from "~/components/App/LanguageSelector.vue";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { DialogID } from "~/components/ui/dialog-provider/utils";
|
||||
import ThemePicker from "~/components/App/ThemePicker.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -105,8 +105,6 @@
|
||||
toast.success(t("profile.toast.group_updated"));
|
||||
}
|
||||
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
const auth = useAuthContext();
|
||||
|
||||
const details = computed(() => {
|
||||
@@ -516,40 +514,7 @@
|
||||
{{ $t("profile.display_legacy_header", { currentValue: preferences.displayLegacyHeader }) }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="homebox grid grid-cols-1 gap-4 font-sans sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
<div
|
||||
v-for="theme in themes"
|
||||
:key="theme.value"
|
||||
:class="'theme-' + theme.value"
|
||||
class="overflow-hidden rounded-lg border outline-2 outline-offset-2"
|
||||
:data-theme="theme.value"
|
||||
:data-set-theme="theme.value"
|
||||
data-act-class="outline"
|
||||
@click="setTheme(theme.value)"
|
||||
>
|
||||
<div :data-theme="theme.value" class="w-full cursor-pointer bg-background-accent text-foreground">
|
||||
<div class="grid grid-cols-5 grid-rows-3">
|
||||
<div class="col-start-1 row-start-1 bg-background"></div>
|
||||
<div class="col-start-1 row-start-2 bg-sidebar"></div>
|
||||
<div class="col-start-1 row-start-3 bg-background-accent"></div>
|
||||
<div class="col-span-4 col-start-2 row-span-3 row-start-1 flex flex-col gap-1 bg-background p-2">
|
||||
<div class="font-bold">{{ theme.label }}</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<div class="flex size-5 items-center justify-center rounded bg-primary lg:size-6">
|
||||
<div class="text-sm font-bold text-primary-foreground">A</div>
|
||||
</div>
|
||||
<div class="flex size-5 items-center justify-center rounded bg-secondary lg:size-6">
|
||||
<div class="text-sm font-bold text-secondary-foreground">A</div>
|
||||
</div>
|
||||
<div class="flex size-5 items-center justify-center rounded bg-accent lg:size-6">
|
||||
<div class="text-sm font-bold text-accent-foreground">A</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ThemePicker />
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user