Compare commits
33 Commits
mk/db-sett
...
tonya/upgr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b239567c81 | ||
|
|
642b7e8801 | ||
|
|
ae234993e5 | ||
|
|
a4fc3f03f9 | ||
|
|
715e6da380 | ||
|
|
f20204127b | ||
|
|
790352da34 | ||
|
|
d345dc6b71 | ||
|
|
b5834818c9 | ||
|
|
235929de77 | ||
|
|
5553ad6d55 | ||
|
|
8d84f06ed1 | ||
|
|
4e0dd04b42 | ||
|
|
4ee569dc71 | ||
|
|
9fc8cf4e8f | ||
|
|
e2ca22e0e2 | ||
|
|
73179ea8f5 | ||
|
|
52a6a31098 | ||
|
|
1d02285b0d | ||
|
|
19563d8b38 | ||
|
|
282977e82c | ||
|
|
769d5c5b95 | ||
|
|
b8f7ce7eb2 | ||
|
|
62ed3fabc2 | ||
|
|
304fc7f11f | ||
|
|
1b7a7a1999 | ||
|
|
a63f08ad87 | ||
|
|
9cb1a3f83c | ||
|
|
f86d38412b | ||
|
|
cbbe056d01 | ||
|
|
5f6b1a0805 | ||
|
|
27e9eb2277 | ||
|
|
6fcd10d796 |
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"
|
||||
3
.vscode/settings.json
vendored
@@ -4,7 +4,7 @@
|
||||
},
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.patterns": {
|
||||
"package.json": "package-lock.json, yarn.lock, .eslintrc.js, tsconfig.json, .prettierrc, .editorconfig, pnpm-lock.yaml, postcss.config.js, tailwind.config.js",
|
||||
"package.json": "package-lock.json, yarn.lock, eslint.config.mjs, tsconfig.json, .prettierrc, .editorconfig, pnpm-lock.yaml, postcss.config.js, tailwind.config.js",
|
||||
"docker-compose.yml": "Dockerfile, .dockerignore, docker-compose.dev.yml, docker-compose.yml",
|
||||
"README.md": "LICENSE, SECURITY.md"
|
||||
},
|
||||
@@ -22,6 +22,7 @@
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||
},
|
||||
"eslint.format.enable": true,
|
||||
"eslint.useFlatConfig": true,
|
||||
"css.validate": false,
|
||||
"tailwindCSS.includeLanguages": {
|
||||
"vue": "html",
|
||||
|
||||
@@ -20,7 +20,7 @@ HomeBox is the inventory and organization system built for the Home User! With a
|
||||
- _Portable_ - Homebox is designed to be portable and run on anywhere. We use SQLite and an embedded Web UI to make it easy to deploy, use, and backup.
|
||||
|
||||
# Screenshots
|
||||
Check out screenshots of the project [here](https://imgur.com/a/5gLWt2j).
|
||||
Check out screenshots of the project [here](https://github.com/sysadminsmedia/homebox/tree/main/screenshots).
|
||||
You can also try the demo instances of Homebox:
|
||||
- [Demo](https://demo.homebox.software)
|
||||
- [Nightly](https://nightly.homebox.software)
|
||||
|
||||
BIN
Screenshots/1.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
Screenshots/10.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
Screenshots/2.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
Screenshots/3.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
Screenshots/4.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
Screenshots/5.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
Screenshots/6.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
Screenshots/7.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
Screenshots/8.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
Screenshots/9.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
8
Screenshots/readme.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Screenshots
|
||||
|
||||
These screenshots are taken from our public [Demo](https://demo.homebox.software) instance.
|
||||
Note that whilst we will make every effort to ensure that these are maintained and updated, they may be outdated or missing functionality and we would always advise reviewing our demo instances:
|
||||
|
||||
- [Demo](https://demo.homebox.software)
|
||||
- [Nightly](https://nightly.homebox.software)
|
||||
- [VNext](https://vnext.homebox.software/)
|
||||
15
Taskfile.yml
@@ -93,6 +93,16 @@ tasks:
|
||||
- go run ./app/api/ {{ .CLI_ARGS }} &
|
||||
silent: true
|
||||
|
||||
go:ci:with-frontend:
|
||||
desc: Run backend with frontend in CI mode
|
||||
dir: frontend
|
||||
cmds:
|
||||
- pnpm install
|
||||
- pnpm run build
|
||||
- cp -r ./.output/public ../backend/app/api/static/
|
||||
- task: go:ci
|
||||
silent: true
|
||||
|
||||
go:test:
|
||||
desc: Runs all go tests using gotestsum - supports passing gotestsum args
|
||||
dir: backend
|
||||
@@ -201,12 +211,11 @@ tasks:
|
||||
desc: Runs end-to-end test on a live server
|
||||
dir: frontend
|
||||
cmds:
|
||||
- task: go:ci
|
||||
- task: ui:ci
|
||||
- task: go:ci:with-frontend
|
||||
- pnpm exec playwright install-deps
|
||||
- pnpm exec playwright install
|
||||
- sleep 30
|
||||
- TEST_SHUTDOWN_API_SERVER=true pnpm exec playwright test -c ./test/playwright.config.ts {{ .CLI_ARGS }}
|
||||
- TEST_SHUTDOWN_API_SERVER=true E2E_BASE_URL=http://localhost:7745 pnpm exec playwright test -c ./test/playwright.config.ts {{ .CLI_ARGS }}
|
||||
|
||||
pr:
|
||||
desc: Runs all tasks required for a PR
|
||||
|
||||
@@ -16,11 +16,11 @@ aside: false
|
||||
| HBOX_OPTIONS_AUTO_INCREMENT_ASSET_ID | true | auto-increments the asset_id field for new items |
|
||||
| HBOX_OPTIONS_CURRENCY_CONFIG | | json configuration file containing additional currencie |
|
||||
| HBOX_OPTIONS_ALLOW_ANALYTICS | false | Allows the homebox team to view extremely basic information about the system that your running on. This helps make decisions regarding builds and other general decisions. |
|
||||
| HBOX_WEB_MAX_UPLOAD | 10 | maximum file upload size supported in MB |
|
||||
| HBOX_WEB_MAX_UPLOAD_SIZE | 10 | maximum file upload size supported in MB |
|
||||
| HBOX_WEB_READ_TIMEOUT | 10s | Read timeout of HTTP sever |
|
||||
| HBOX_WEB_WRITE_TIMEOUT | 10s | Write timeout of HTTP server |
|
||||
| HBOX_WEB_IDLE_TIMEOUT | 30s | Idle timeout of HTTP server |
|
||||
| HBOX_STORAGE_CONN_STRING | file://./ | path to the data directory, do not change this if you're using docker |
|
||||
| HBOX_STORAGE_CONN_STRING | file:///./ | path to the data directory, do not change this if you're using docker |
|
||||
| HBOX_STORAGE_PREFIX_PATH | .data | prefix path for the storage, if not set the storage will be used as is |
|
||||
| HBOX_LOG_LEVEL | `info` | log level to use, can be one of `trace`, `debug`, `info`, `warn`, `error`, `critical` |
|
||||
| HBOX_LOG_FORMAT | `text` | log format to use, can be one of: `text`, `json` |
|
||||
@@ -29,20 +29,18 @@ aside: false
|
||||
| HBOX_MAILER_USERNAME | | email user to use |
|
||||
| HBOX_MAILER_PASSWORD | | email password to use |
|
||||
| HBOX_MAILER_FROM | | email from address to use |
|
||||
| HBOX_SWAGGER_HOST | 7745 | swagger host to use, if not set swagger will be disabled |
|
||||
| HBOX_SWAGGER_SCHEMA | `http` | swagger schema to use, can be one of: `http`, `https` |
|
||||
| HBOX_DATABASE_DRIVER | sqlite3 | sets the correct database type (`sqlite3` or `postgres`) |
|
||||
| HBOX_DATABASE_SQLITE_PATH | ./.data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1 | sets the directory path for Sqlite |
|
||||
| HBOX_DATABASE_SQLITE_PATH | ./.data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1&_time_format=sqlite | sets the directory path for Sqlite |
|
||||
| HBOX_DATABASE_HOST | | sets the hostname for a postgres database |
|
||||
| HBOX_DATABASE_PORT | | sets the port for a postgres database |
|
||||
| HBOX_DATABASE_USERNAME | | sets the username for a postgres connection (optional if using cert auth) |
|
||||
| HBOX_DATABASE_PASSWORD | | sets the password for a postgres connection (optional if using cert auth) |
|
||||
| HBOX_DATABASE_DATABASE | | sets the database for a postgres connection |
|
||||
| HBOX_DATABASE_SSL_MODE | | sets the sslmode for a postgres connection |
|
||||
| HBOX_DATABASE_SSL_MODE | prefer | sets the sslmode for a postgres connection |
|
||||
| HBOX_DATABASE_SSL_CERT | | sets the sslcert for a postgres connection (should be a path) |
|
||||
| HBOX_DATABASE_SSL_KEY | | sets the sslkey for a postgres connection (should be a path) |
|
||||
| HBOX_DATABASE_SSL_ROOTCERT | | sets the sslrootcert for a postgres connection (should be a path) |
|
||||
| HBOX_OPTIONS_CHECK_GITHUB_RELEASE | true | check for new github releases |
|
||||
| HBOX_DATABASE_SSL_ROOT_CERT | | sets the sslrootcert for a postgres connection (should be a path) |
|
||||
| HBOX_OPTIONS_GITHUB_RELEASE_CHECK | true | check for new github releases |
|
||||
| HBOX_LABEL_MAKER_WIDTH | 526 | width for generated labels in pixels |
|
||||
| HBOX_LABEL_MAKER_HEIGHT | 200 | height for generated labels in pixels |
|
||||
| HBOX_LABEL_MAKER_PADDING | 32 | space between elements on label |
|
||||
@@ -160,8 +158,8 @@ OPTIONS
|
||||
--mode/$HBOX_MODE <string> (default: development)
|
||||
--web-port/$HBOX_WEB_PORT <string> (default: 7745)
|
||||
--web-host/$HBOX_WEB_HOST <string>
|
||||
--web-max-file-upload/$HBOX_WEB_MAX_FILE_UPLOAD <int> (default: 10)
|
||||
--storage-conn-string/$HBOX_STORAGE_CONN_STRING <string> (default: file://./)
|
||||
--web-max-upload-size/$HBOX_WEB_MAX_UPLOAD_SIZE <int> (default: 10)
|
||||
--storage-conn-string/$HBOX_STORAGE_CONN_STRING <string> (default: file:///./)
|
||||
--storage-prefix-path/$HBOX_STORAGE_PREFIX_PATH <string> (default: .data)
|
||||
--log-level/$HBOX_LOG_LEVEL <string> (default: info)
|
||||
--log-format/$HBOX_LOG_FORMAT <string> (default: text)
|
||||
@@ -170,31 +168,29 @@ OPTIONS
|
||||
--mailer-username/$HBOX_MAILER_USERNAME <string>
|
||||
--mailer-password/$HBOX_MAILER_PASSWORD <string>
|
||||
--mailer-from/$HBOX_MAILER_FROM <string>
|
||||
--swagger-host/$HBOX_SWAGGER_HOST <string> (default: localhost:7745)
|
||||
--swagger-scheme/$HBOX_SWAGGER_SCHEME <string> (default: http)
|
||||
--demo/$HBOX_DEMO <bool>
|
||||
--debug-enabled/$HBOX_DEBUG_ENABLED <bool> (default: false)
|
||||
--debug-port/$HBOX_DEBUG_PORT <string> (default: 4000)
|
||||
--database-driver/$HBOX_DATABASE_DRIVER <string> (default: sqlite3)
|
||||
--database-sqlite-path/$HBOX_DATABASE_SQLITE_PATH <string> (default: ./.data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1)
|
||||
--database-sqlite-path/$HBOX_DATABASE_SQLITE_PATH <string> (default: ./.data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1&_time_format=sqlite)
|
||||
--database-host/$HBOX_DATABASE_HOST <string>
|
||||
--database-port/$HBOX_DATABASE_PORT <string>
|
||||
--database-username/$HBOX_DATABASE_USERNAME <string>
|
||||
--database-password/$HBOX_DATABASE_PASSWORD <string>
|
||||
--database-database/$HBOX_DATABASE_DATABASE <string>
|
||||
--database-ssl-mode/$HBOX_DATABASE_SSL_MODE <string>
|
||||
--database-ssl-mode/$HBOX_DATABASE_SSL_MODE <string> (default: prefer)
|
||||
--options-allow-registration/$HBOX_OPTIONS_ALLOW_REGISTRATION <bool> (default: true)
|
||||
--options-auto-increment-asset-id/$HBOX_OPTIONS_AUTO_INCREMENT_ASSET_ID <bool> (default: true)
|
||||
--options-currency-config/$HBOX_OPTIONS_CURRENCY_CONFIG <string>
|
||||
--options-check-github-release/$HBOX_OPTIONS_CHECK_GITHUB_RELEASE <bool> (default: true)
|
||||
--options-github-release-check/$HBOX_OPTIONS_GITHUB_RELEASE_CHECK <bool> (default: true)
|
||||
--options-allow-analytics/$HBOX_OPTIONS_ALLOW_ANALYTICS <bool> (default: false)
|
||||
--label-maker-width/$HBOX_LABEL_MAKER_WIDTH <int> (default: 526)
|
||||
--label-maker-height/$HBOX_LABEL_MAKER_HEIGHT <int> (default: 200)
|
||||
--label-maker-padding/$HBOX_LABEL_MAKER_PADDING <int> (default: 32)
|
||||
--label-maker-margin/$HBOX_LABEL_MAKER_MARGIN <int> (default: 32)
|
||||
--label-maker-margin/$HBOX_LABEL_MAKER_MARGIN <int> (default: 32)
|
||||
--label-maker-font-size/$HBOX_LABEL_MAKER_FONT_SIZE <float> (default: 32.0)
|
||||
--label-maker-print-command/$HBOX_LABEL_MAKER_PRINT_COMMAND <string>
|
||||
--label-maker-additional-information/$HBOX_LABEL_MAKER_DYNAMIC_LENGTH <string> (default: true)
|
||||
--label-maker-dynamic-length/$HBOX_LABEL_MAKER_DYNAMIC_LENGTH <bool> (default: true)
|
||||
--label-maker-additional-information/$HBOX_LABEL_MAKER_ADDITIONAL_INFORMATION <string>
|
||||
--thumbnail-enabled/$HBOX_THUMBNAIL_ENABLED <bool> (default: true)
|
||||
--thumbnail-width/$HBOX_THUMBNAIL_WIDTH <int> (default: 500)
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:vue/essential",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"@nuxtjs/eslint-config-typescript",
|
||||
"plugin:vue/vue3-recommended",
|
||||
"plugin:prettier/recommended",
|
||||
"plugin:tailwindcss/recommended",
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
parser: "@typescript-eslint/parser",
|
||||
sourceType: "module",
|
||||
},
|
||||
plugins: ["vue", "@typescript-eslint"],
|
||||
rules: {
|
||||
"no-console": 0,
|
||||
"no-unused-vars": "off",
|
||||
"vue/multi-word-component-names": "off",
|
||||
"vue/no-setup-props-destructure": 0,
|
||||
"vue/no-multiple-template-root": 0,
|
||||
"vue/no-v-model-argument": 0,
|
||||
"vue/no-v-html": 0,
|
||||
"@typescript-eslint/consistent-type-imports": "error",
|
||||
"@typescript-eslint/ban-ts-comment": 0,
|
||||
"tailwindcss/no-custom-classname": "warn",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
ignoreRestSiblings: true,
|
||||
destructuredArrayIgnorePattern: "_",
|
||||
caughtErrors: "none",
|
||||
},
|
||||
],
|
||||
"prettier/prettier": [
|
||||
"warn",
|
||||
{
|
||||
arrowParens: "avoid",
|
||||
semi: true,
|
||||
tabWidth: 2,
|
||||
useTabs: false,
|
||||
vueIndentScriptAndStyle: true,
|
||||
singleQuote: false,
|
||||
trailingComma: "es5",
|
||||
printWidth: 120,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<NuxtLayout>
|
||||
<Html :lang="locale" :data-theme="theme || 'homebox'" />
|
||||
<Link rel="icon" type="image/svg" href="/favicon.svg"></Link>
|
||||
<Link rel="icon" type="image/svg" href="/favicon.svg" />
|
||||
<Link rel="apple-touch-icon" href="/apple-touch-icon.png" size="180x180" />
|
||||
<Link rel="mask-icon" href="/mask-icon.svg" color="#5b7f67" />
|
||||
<Meta name="theme-color" content="#5b7f67" />
|
||||
|
||||
@@ -1046,4 +1046,32 @@
|
||||
:root {
|
||||
--header-height: 4rem;
|
||||
--header-height-mobile: 7rem;
|
||||
}
|
||||
|
||||
/* Non-scoped styles for regular text */
|
||||
.break-all {
|
||||
word-break: break-all;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Handle very long words */
|
||||
pre,
|
||||
code,
|
||||
a,
|
||||
p,
|
||||
span,
|
||||
div,
|
||||
td,
|
||||
th,
|
||||
li,
|
||||
blockquote,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: normal;
|
||||
hyphens: auto;
|
||||
}
|
||||
@@ -47,7 +47,8 @@
|
||||
import { useMediaQuery } from "@vueuse/core";
|
||||
import type { DialogID } from "@/components/ui/dialog-provider/utils";
|
||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
|
||||
import { Dialog, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogFooter, DialogHeader, DialogScrollContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
type Props = {
|
||||
modelValue: boolean;
|
||||
modelValue?: boolean;
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -66,13 +66,13 @@
|
||||
|
||||
const api = useUserApi();
|
||||
|
||||
const importCsv = ref<File | null>(null);
|
||||
const importCsv = ref<File | undefined>(undefined);
|
||||
const importLoading = ref(false);
|
||||
const importRef = ref<HTMLInputElement>();
|
||||
whenever(
|
||||
() => !dialog.value,
|
||||
() => {
|
||||
importCsv.value = null;
|
||||
importCsv.value = undefined;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
// Reset
|
||||
dialog.value = false;
|
||||
importLoading.value = false;
|
||||
importCsv.value = null;
|
||||
importCsv.value = undefined;
|
||||
|
||||
if (importRef.value) {
|
||||
importRef.value.value = "";
|
||||
|
||||
@@ -29,12 +29,12 @@
|
||||
import { lt } from "semver";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogAction,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<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,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "~/components/ui/command";
|
||||
import { Shortcut } from "~/components/ui/shortcut";
|
||||
@@ -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);
|
||||
}
|
||||
"
|
||||
>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
<!-- eslint-disable-next-line tailwindcss/no-custom-classname -->
|
||||
<video ref="video" class="aspect-video w-full rounded-lg bg-muted shadow" poster="data:image/gif,AAAA"></video>
|
||||
<video ref="video" class="aspect-video w-full rounded-lg bg-muted shadow" poster="data:image/gif,AAAA" />
|
||||
<div class="mt-4">
|
||||
<Select v-model="selectedSource">
|
||||
<SelectTrigger class="w-full">
|
||||
@@ -52,13 +52,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from "vue";
|
||||
import { BrowserMultiFormatReader, NotFoundException, BarcodeFormat } from "@zxing/library";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { BarcodeFormat, BrowserMultiFormatReader, NotFoundException } from "@zxing/library";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { DialogID } from "@/components/ui/dialog-provider/utils";
|
||||
import { Dialog, DialogHeader, DialogTitle, DialogScrollContent } from "@/components/ui/dialog";
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogHeader, DialogScrollContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button, ButtonGroup } from "@/components/ui/button";
|
||||
import MdiBarcode from "~icons/mdi/barcode";
|
||||
import MdiAlertCircleOutline from "~icons/mdi/alert-circle-outline";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
@@ -93,7 +93,7 @@
|
||||
};
|
||||
|
||||
const handleButtonClick = () => {
|
||||
openDialog(DialogID.ProductImport, { barcode: detectedBarcode.value });
|
||||
openDialog(DialogID.ProductImport, { params: { barcode: detectedBarcode.value } });
|
||||
};
|
||||
|
||||
const startScanner = async () => {
|
||||
@@ -113,12 +113,12 @@
|
||||
|
||||
if (devices.length > 0) {
|
||||
for (let i = 0; i < devices.length; i++) {
|
||||
if (devices[i].label.toLowerCase().includes("back")) {
|
||||
selectedSource.value = devices[i].deviceId;
|
||||
if (devices[i]!.label.toLowerCase().includes("back")) {
|
||||
selectedSource.value = devices[i]!.deviceId;
|
||||
}
|
||||
}
|
||||
if (!selectedSource.value) {
|
||||
selectedSource.value = devices[0].deviceId;
|
||||
selectedSource.value = devices[0]!.deviceId;
|
||||
}
|
||||
} else {
|
||||
errorMessage.value = t("scanner.no_sources");
|
||||
|
||||
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 class="col-start-1 row-start-2 bg-sidebar" />
|
||||
<div class="col-start-1 row-start-3 bg-background-accent" />
|
||||
<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>
|
||||
@@ -3,7 +3,7 @@
|
||||
<CardHeader v-if="$slots.title" class="px-4 py-5 sm:px-6">
|
||||
<component :is="collapsable ? 'button' : 'div'" v-on="collapsable ? { click: toggle } : {}">
|
||||
<h3 class="flex items-center text-lg font-medium leading-6">
|
||||
<slot name="title"></slot>
|
||||
<slot name="title" />
|
||||
<template v-if="collapsable">
|
||||
<span class="ml-2 transition-transform" :class="{ 'rotate-180': collapsed }">
|
||||
<MdiChevronDown class="size-6" />
|
||||
@@ -13,10 +13,10 @@
|
||||
</component>
|
||||
<div>
|
||||
<p v-if="$slots.subtitle" class="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
<slot name="subtitle"></slot>
|
||||
<slot name="subtitle" />
|
||||
</p>
|
||||
<template v-if="$slots['title-actions']">
|
||||
<slot name="title-actions"></slot>
|
||||
<slot name="title-actions" />
|
||||
</template>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
@@ -2,24 +2,24 @@
|
||||
<div class="flex flex-col gap-10 py-6 md:flex-row">
|
||||
<div class="flex-1">
|
||||
<h4 class="mb-1 text-lg font-semibold">
|
||||
<slot name="title"></slot>
|
||||
<slot name="title" />
|
||||
</h4>
|
||||
<p class="text-sm">
|
||||
<slot></slot>
|
||||
<slot />
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<template v-if="to">
|
||||
<NuxtLink :to="to" :class="buttonVariants({ size: 'lg' })" class="min-w-52 grow">
|
||||
<slot name="button">
|
||||
<slot name="title"></slot>
|
||||
<slot name="title" />
|
||||
</slot>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button class="min-w-52 grow" size="lg" @click="$emit('action')">
|
||||
<slot name="button">
|
||||
<slot name="title"></slot>
|
||||
<slot name="title" />
|
||||
</slot>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: true,
|
||||
required: false,
|
||||
default: "",
|
||||
},
|
||||
label: {
|
||||
@@ -81,7 +81,7 @@
|
||||
:aria-label="`${t('components.color_selector.color')}: ${modelValue || t('components.color_selector.no_color_selected')}`"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="$refs.colorInput.click()"
|
||||
@click="($refs.colorInput as HTMLInputElement).click()"
|
||||
/>
|
||||
<span v-if="showHex" class="font-mono text-xs text-muted-foreground">{{
|
||||
modelValue || t("components.color_selector.no_color")
|
||||
@@ -122,7 +122,7 @@
|
||||
:aria-label="`${t('components.color_selector.color')}: ${modelValue || t('components.color_selector.no_color_selected')}`"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="$refs.colorInput.click()"
|
||||
@click="($refs.colorInput as HTMLInputElement).click()"
|
||||
/>
|
||||
<span v-if="showHex" class="font-mono text-xs text-muted-foreground">{{
|
||||
modelValue || t("components.color_selector.no_color")
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// @ts-ignore
|
||||
import VueDatePicker from "@vuepic/vue-datepicker";
|
||||
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 +34,7 @@
|
||||
},
|
||||
});
|
||||
|
||||
const isDark = useIsDark();
|
||||
const isDark = useIsThemeInList(darkThemes);
|
||||
|
||||
const formatDate = (date: Date | string | number) => fmtDate(date, "human", "date");
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<FormTextField v-model="value" :placeholder="localizedPlaceholder" :label="localizedLabel" :type="inputType">
|
||||
</FormTextField>
|
||||
<FormTextField v-model="value" :placeholder="localizedPlaceholder" :label="localizedLabel" :type="inputType" />
|
||||
<TooltipProvider :delay-duration="0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
@@ -22,7 +21,8 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import MdiEye from "~icons/mdi/eye";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import FormTextField from "@/components/Form/TextField.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
type Props = {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div v-if="!inline" class="flex w-full flex-col gap-1.5">
|
||||
<Label :for="id" class="flex w-full px-1">
|
||||
<span>{{ label }}</span>
|
||||
<span class="grow"></span>
|
||||
<span class="grow" />
|
||||
<span :class="{ 'text-destructive': isLengthInvalid }">
|
||||
{{ lengthIndicator }}
|
||||
</span>
|
||||
@@ -18,7 +18,7 @@
|
||||
<div v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
|
||||
<Label :for="id" class="flex w-full px-1 py-2">
|
||||
<span>{{ label }}</span>
|
||||
<span class="grow"></span>
|
||||
<span class="grow" />
|
||||
<span :class="{ 'text-destructive': isLengthInvalid }">
|
||||
{{ lengthIndicator }}
|
||||
</span>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div v-if="!inline" class="flex w-full flex-col gap-1.5">
|
||||
<Label :for="id" class="flex w-full px-1">
|
||||
<span> {{ label }} </span>
|
||||
<span class="grow"></span>
|
||||
<span class="grow" />
|
||||
<span
|
||||
:class="{
|
||||
'text-destructive':
|
||||
@@ -26,7 +26,7 @@
|
||||
<div v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
|
||||
<Label class="flex w-full px-1 py-2" :for="id">
|
||||
<span> {{ label }} </span>
|
||||
<span class="grow"></span>
|
||||
<span class="grow" />
|
||||
<span
|
||||
:class="{
|
||||
'text-destructive':
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
import MdiDownload from "~icons/mdi/download";
|
||||
import MdiOpenInNew from "~icons/mdi/open-in-new";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
const props = defineProps({
|
||||
attachments: {
|
||||
|
||||
@@ -116,6 +116,11 @@
|
||||
import MdiBarcode from "~icons/mdi/barcode";
|
||||
import MdiLoading from "~icons/mdi/loading";
|
||||
import type { TableData } from "~/components/Item/View/Table.types";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import BaseCard from "@/components/Base/Card.vue";
|
||||
import FormTextField from "@/components/Form/TextField.vue";
|
||||
|
||||
const { openDialog, registerOpenDialogCallback } = useDialog();
|
||||
const { t } = useI18n();
|
||||
@@ -149,7 +154,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 +173,8 @@
|
||||
products.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(cleanup);
|
||||
});
|
||||
|
||||
const api = useUserApi();
|
||||
@@ -180,7 +187,9 @@
|
||||
selectedRow.value < products.value.length
|
||||
) {
|
||||
const p = products.value![selectedRow.value];
|
||||
openDialog(DialogID.CreateItem, { product: p });
|
||||
openDialog(DialogID.CreateItem, {
|
||||
params: { product: p },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
{{ $t("global.archived") }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div class="grow"></div>
|
||||
<div class="grow" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Badge>
|
||||
@@ -62,6 +62,8 @@
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import Markdown from "@/components/global/Markdown.vue";
|
||||
import LabelChip from "@/components/Label/Chip.vue";
|
||||
|
||||
const api = useUserApi();
|
||||
|
||||
|
||||
@@ -190,6 +190,10 @@
|
||||
import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider";
|
||||
import LabelSelector from "~/components/Label/Selector.vue";
|
||||
import ItemSelector from "~/components/Item/Selector.vue";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/components/ui/tooltip";
|
||||
import LocationSelector from "~/components/Location/Selector.vue";
|
||||
import FormTextField from "~/components/Form/TextField.vue";
|
||||
import FormTextArea from "~/components/Form/TextArea.vue";
|
||||
|
||||
interface PhotoPreview {
|
||||
photoName: string;
|
||||
@@ -276,8 +280,8 @@
|
||||
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;
|
||||
if (primary !== -1) form.photos[primary]!.primary = false;
|
||||
if (primary !== index) form.photos[index]!.primary = true;
|
||||
}
|
||||
|
||||
function previewImage(event: Event) {
|
||||
@@ -300,13 +304,13 @@
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (subItemCreate.value && itemId.value) {
|
||||
const itemIdRead = typeof itemId.value === "string" ? (itemId.value as string) : itemId.value[0];
|
||||
const itemIdRead = typeof itemId.value === "string" ? (itemId.value as string) : itemId.value[0]!;
|
||||
const { data, error } = await api.items.get(itemIdRead);
|
||||
if (error || !data) {
|
||||
toast.error(t("components.item.create_modal.toast.failed_load_parent"));
|
||||
@@ -359,6 +363,8 @@
|
||||
form.labels = labels.value.filter(l => l.id === labelId.value).map(l => l.id);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(cleanup);
|
||||
});
|
||||
|
||||
async function create(close = true) {
|
||||
@@ -374,7 +380,7 @@
|
||||
|
||||
loading.value = true;
|
||||
|
||||
if (shift.value) close = false;
|
||||
if (shift?.value) close = false;
|
||||
|
||||
const out: ItemCreate = {
|
||||
parentId: form.parentId,
|
||||
@@ -438,7 +444,7 @@
|
||||
function dataURLtoFile(dataURL: string, fileName: string) {
|
||||
try {
|
||||
const arr = dataURL.split(",");
|
||||
const mimeMatch = arr[0].match(/:(.*?);/);
|
||||
const mimeMatch = arr[0]!.match(/:(.*?);/);
|
||||
if (!mimeMatch || !mimeMatch[1]) {
|
||||
throw new Error("Invalid data URL format");
|
||||
}
|
||||
@@ -449,7 +455,7 @@
|
||||
throw new Error("Invalid mime type, expected image");
|
||||
}
|
||||
|
||||
const bstr = atob(arr[arr.length - 1]);
|
||||
const bstr = atob(arr[arr.length - 1]!);
|
||||
let n = bstr.length;
|
||||
const u8arr = new Uint8Array(n);
|
||||
while (n--) {
|
||||
@@ -498,8 +504,8 @@
|
||||
|
||||
// Encode image to data-uri with base64
|
||||
try {
|
||||
form.photos[index].fileBase64 = offScreenCanvas.toDataURL(imageType, 100);
|
||||
form.photos[index].file = dataURLtoFile(form.photos[index].fileBase64, form.photos[index].photoName);
|
||||
form.photos[index]!.fileBase64 = offScreenCanvas.toDataURL(imageType, 100);
|
||||
form.photos[index]!.file = dataURLtoFile(form.photos[index]!.fileBase64, form.photos[index]!.photoName);
|
||||
} catch (error) {
|
||||
toast.error(t("components.item.create_modal.toast.rotate_process_failed"));
|
||||
console.error(error);
|
||||
|
||||
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 { Button, buttonVariants } 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>
|
||||
@@ -37,7 +37,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { Check, ChevronsUpDown } from "lucide-vue-next";
|
||||
import fuzzysort from "fuzzysort";
|
||||
import { useVModel } from "@vueuse/core";
|
||||
@@ -47,7 +47,6 @@
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { useId } from "#imports";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -56,9 +55,9 @@
|
||||
};
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
modelValue: string | ItemsObject | null | undefined;
|
||||
items: ItemsObject[] | string[];
|
||||
label?: string;
|
||||
modelValue?: string | ItemsObject | null | undefined;
|
||||
items?: ItemsObject[] | string[];
|
||||
itemText?: string;
|
||||
itemValue?: string;
|
||||
search?: string;
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
import MdiTable from "~icons/mdi/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button, ButtonGroup } from "@/components/ui/button";
|
||||
import BaseSectionHeader from "@/components/Base/SectionHeader.vue";
|
||||
import ItemCard from "@/components/Item/Card.vue";
|
||||
import ItemViewTable from "@/components/Item/View/Table.vue";
|
||||
|
||||
type Props = {
|
||||
view?: ViewType;
|
||||
@@ -30,7 +33,7 @@
|
||||
<template>
|
||||
<section>
|
||||
<BaseSectionHeader class="mb-2 mt-4 flex items-center justify-between">
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 text-nowrap">
|
||||
{{ $t("components.item.view.selectable.items") }}
|
||||
<Badge>
|
||||
{{ items.length }}
|
||||
|
||||
@@ -9,4 +9,5 @@ export type TableHeaderType = {
|
||||
type?: "price" | "boolean" | "name" | "location" | "date";
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type TableData = Record<string, any>;
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
:sibling-count="2"
|
||||
@update:page="pagination.page = $event"
|
||||
>
|
||||
<PaginationList v-slot="{ pageItems }" class="flex items-center gap-1">
|
||||
<PaginationList v-slot="{ items: pageItems }" class="flex items-center gap-1">
|
||||
<PaginationFirst />
|
||||
<template v-for="(item, index) in pageItems">
|
||||
<PaginationListItem v-if="item.type === 'page'" :key="index" :value="item.value" as-child>
|
||||
@@ -162,7 +162,7 @@
|
||||
import MdiClose from "~icons/mdi/close";
|
||||
import MdiTableCog from "~icons/mdi/table-cog";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Table, TableBody, TableHeader, TableCell, TableHead, TableRow } from "@/components/ui/table";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationEllipsis,
|
||||
@@ -175,6 +175,11 @@
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
import { DialogID } from "~/components/ui/dialog-provider/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import BaseCard from "@/components/Base/Card.vue";
|
||||
import Currency from "~/components/global/Currency.vue";
|
||||
import DateTime from "~/components/global/DateTime.vue";
|
||||
|
||||
const { openDialog, closeDialog } = useDialog();
|
||||
|
||||
@@ -225,6 +230,9 @@
|
||||
};
|
||||
const moveHeader = (from: number, to: number) => {
|
||||
const header = headers.value[from];
|
||||
if (!header) {
|
||||
return;
|
||||
}
|
||||
headers.value.splice(from, 1);
|
||||
headers.value.splice(to, 0, header);
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
import BaseModal from "@/components/App/CreateModal.vue";
|
||||
import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider";
|
||||
import ColorSelector from "@/components/Form/ColorSelector.vue";
|
||||
import FormTextField from "~/components/Form/TextField.vue";
|
||||
import FormTextArea from "~/components/Form/TextArea.vue";
|
||||
import { Button, ButtonGroup } from "~/components/ui/button";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -72,7 +75,7 @@
|
||||
|
||||
loading.value = true;
|
||||
|
||||
if (shift.value) close = false;
|
||||
if (shift?.value) close = false;
|
||||
|
||||
const { error, data } = await api.labels.create(form);
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
TagsInputItemText,
|
||||
} from "@/components/ui/tags-input";
|
||||
import type { LabelOut } from "~/lib/api/types/data-contracts";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
|
||||
@@ -51,8 +51,6 @@
|
||||
});
|
||||
|
||||
const count = computed(() => {
|
||||
if (hasCount.value) {
|
||||
return (props.location as LocationOutCount).itemCount;
|
||||
}
|
||||
return hasCount.value ? (props.location as LocationOutCount).itemCount : undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
import BaseModal from "@/components/App/CreateModal.vue";
|
||||
import type { LocationSummary } from "~~/lib/api/types/data-contracts";
|
||||
import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider";
|
||||
import LocationSelector from "~/components/Location/Selector.vue";
|
||||
import FormTextField from "~/components/Form/TextField.vue";
|
||||
import FormTextArea from "~/components/Form/TextArea.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -94,7 +97,7 @@
|
||||
}
|
||||
loading.value = true;
|
||||
|
||||
if (shift.value) close = false;
|
||||
if (shift?.value) close = false;
|
||||
|
||||
const { data, error } = await api.locations.create({
|
||||
name: form.name,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import MdiChevronRight from "~icons/mdi/chevron-right";
|
||||
import MdiMapMarker from "~icons/mdi/map-marker";
|
||||
import MdiPackageVariant from "~icons/mdi/package-variant";
|
||||
import LocationTreeNode from "./Node.vue";
|
||||
|
||||
type Props = {
|
||||
treeId: string;
|
||||
@@ -51,7 +52,7 @@
|
||||
'hover:bg-accent hover:text-accent-foreground': hasChildren,
|
||||
}"
|
||||
>
|
||||
<div v-if="!hasChildren" class="size-6"></div>
|
||||
<div v-if="!hasChildren" class="size-6" />
|
||||
<div v-else class="group/node relative size-6" :data-swap="openRef">
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center transition-transform duration-300 group-data-[swap=true]/node:rotate-90"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { TreeItem } from "~~/lib/api/types/data-contracts";
|
||||
import LocationTreeNode from "./Node.vue";
|
||||
|
||||
type Props = {
|
||||
locs: TreeItem[];
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
import DatePicker from "~~/components/Form/DatePicker.vue";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
import FormTextField from "~/components/Form/TextField.vue";
|
||||
import FormTextArea from "~/components/Form/TextArea.vue";
|
||||
import Button from "@/components/ui/button/Button.vue";
|
||||
|
||||
const { openDialog, closeDialog } = useDialog();
|
||||
|
||||
|
||||
@@ -11,9 +11,15 @@
|
||||
import MdiWrenchClock from "~icons/mdi/wrench-clock";
|
||||
import MdiContentDuplicate from "~icons/mdi/content-duplicate";
|
||||
import MaintenanceEditModal from "~~/components/Maintenance/EditModal.vue";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ButtonGroup, Button } from "@/components/ui/button";
|
||||
import { Button, ButtonGroup } from "@/components/ui/button";
|
||||
import StatCard from "~/components/global/StatCard/StatCard.vue";
|
||||
import BaseCard from "@/components/Base/Card.vue";
|
||||
import BaseSectionHeader from "@/components/Base/SectionHeader.vue";
|
||||
import DateTime from "~/components/global/DateTime.vue";
|
||||
import Currency from "~/components/global/Currency.vue";
|
||||
import Markdown from "~/components/global/Markdown.vue";
|
||||
|
||||
const maintenanceFilterStatus = ref(MaintenanceFilterStatus.MaintenanceFilterStatusScheduled);
|
||||
const maintenanceEditModal = ref<InstanceType<typeof MaintenanceEditModal>>();
|
||||
@@ -125,7 +131,7 @@
|
||||
</section>
|
||||
<section>
|
||||
<!-- begin -->
|
||||
<MaintenanceEditModal ref="maintenanceEditModal" @changed="refreshList"></MaintenanceEditModal>
|
||||
<MaintenanceEditModal ref="maintenanceEditModal" @changed="refreshList" />
|
||||
<div class="container space-y-6">
|
||||
<BaseCard v-for="e in maintenanceDataList" :key="e.id">
|
||||
<BaseSectionHeader class="border-b p-6">
|
||||
|
||||
@@ -19,6 +19,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDialog } from "./ui/dialog-provider";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
const { text, isRevealed, confirm, cancel } = useConfirm();
|
||||
const { addAlert, removeAlert } = useDialog();
|
||||
|
||||
@@ -50,13 +50,13 @@
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
label?: string;
|
||||
options: {
|
||||
name: string;
|
||||
id: string;
|
||||
treeString?: string;
|
||||
}[];
|
||||
modelValue: {
|
||||
modelValue?: {
|
||||
name: string;
|
||||
id: string;
|
||||
treeString?: string;
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const props = defineProps({
|
||||
text: {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -19,9 +19,7 @@
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
type AsyncReturnType<T extends (...args: any) => Promise<any>> = T extends (...args: any) => Promise<infer R>
|
||||
? R
|
||||
: any;
|
||||
type AsyncReturnType<T extends (...args: unknown[]) => unknown> = Awaited<ReturnType<T>>;
|
||||
|
||||
const fmt = ref<AsyncReturnType<typeof useFormatCurrency> | null>(null);
|
||||
|
||||
|
||||
@@ -73,7 +73,11 @@
|
||||
import type { AnyDetail, Detail } from "./types";
|
||||
import MdiOpenInNew from "~icons/mdi/open-in-new";
|
||||
import { badgeVariants } from "~/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import DateTime from "@/components/global/DateTime.vue";
|
||||
import Currency from "@/components/global/Currency.vue";
|
||||
import Markdown from "@/components/global/Markdown.vue";
|
||||
import CopyText from "@/components/global/CopyText.vue";
|
||||
|
||||
defineProps({
|
||||
details: {
|
||||
@@ -144,37 +148,5 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Non-scoped styles for regular text */
|
||||
.break-all {
|
||||
word-break: break-all;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Handle very long words */
|
||||
pre,
|
||||
code,
|
||||
a,
|
||||
p,
|
||||
span,
|
||||
div,
|
||||
td,
|
||||
th,
|
||||
li,
|
||||
blockquote,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
-ms-word-break: break-all;
|
||||
word-break: break-all;
|
||||
word-break: break-word;
|
||||
-ms-hyphens: auto;
|
||||
-moz-hyphens: auto;
|
||||
-webkit-hyphens: auto;
|
||||
hyphens: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,14 +11,14 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
import { Button, ButtonGroup } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { openDialog, closeDialog } = useDialog();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
type Props = {
|
||||
source: string | null | undefined;
|
||||
source?: string | null;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
<template>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="markdown text-wrap break-words" v-html="raw"></div>
|
||||
<div class="markdown text-wrap break-words" v-html="raw" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<template>
|
||||
<div class="max-w-full"></div>
|
||||
<div class="max-w-full" />
|
||||
</template>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Currency from "../Currency.vue";
|
||||
import type { StatsFormat } from "./types";
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
|
||||
@@ -5,4 +5,5 @@ export type TableHeader = {
|
||||
align?: "left" | "center" | "right";
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type TableData = Record<string, any>;
|
||||
|
||||
@@ -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,63 +1,143 @@
|
||||
import type { ComputedRef } from "vue";
|
||||
/* eslint-disable @typescript-eslint/unified-signatures */
|
||||
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',
|
||||
ChangePassword = 'changePassword',
|
||||
CreateItem = 'create-item',
|
||||
CreateLocation = 'create-location',
|
||||
CreateLabel = 'create-label',
|
||||
CreateNotifier = 'create-notifier',
|
||||
DuplicateSettings = 'duplicate-settings',
|
||||
DuplicateTemporarySettings = 'duplicate-temporary-settings',
|
||||
EditMaintenance = 'edit-maintenance',
|
||||
Import = 'import',
|
||||
ItemImage = 'item-image',
|
||||
ItemTableSettings = 'item-table-settings',
|
||||
PrintLabel = 'print-label',
|
||||
ProductImport = 'product-import',
|
||||
QuickMenu = 'quick-menu',
|
||||
Scanner = 'scanner',
|
||||
PageQRCode = 'page-qr-code',
|
||||
UpdateLabel = 'update-label',
|
||||
UpdateLocation = 'update-location',
|
||||
AttachmentEdit = "attachment-edit",
|
||||
ChangePassword = "changePassword",
|
||||
CreateItem = "create-item",
|
||||
CreateLocation = "create-location",
|
||||
CreateLabel = "create-label",
|
||||
CreateNotifier = "create-notifier",
|
||||
DuplicateSettings = "duplicate-settings",
|
||||
DuplicateTemporarySettings = "duplicate-temporary-settings",
|
||||
EditMaintenance = "edit-maintenance",
|
||||
Import = "import",
|
||||
ItemImage = "item-image",
|
||||
ItemTableSettings = "item-table-settings",
|
||||
PrintLabel = "print-label",
|
||||
ProductImport = "product-import",
|
||||
QuickMenu = "quick-menu",
|
||||
Scanner = "scanner",
|
||||
PageQRCode = "page-qr-code",
|
||||
UpdateLabel = "update-label",
|
||||
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> = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
[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;
|
||||
<T extends DialogID>(dialogId: T): 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");
|
||||
|
||||
export const useDialogHotkey = (
|
||||
dialogId: DialogID,
|
||||
key: {
|
||||
shift?: boolean;
|
||||
ctrl?: boolean;
|
||||
code: string;
|
||||
}
|
||||
) => {
|
||||
/**
|
||||
* 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: HotkeyKey, getParams?: () => unknown) {
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
const activeElement = useActiveElement();
|
||||
@@ -69,17 +149,6 @@ export const useDialogHotkey = (
|
||||
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,
|
||||
// });
|
||||
if (
|
||||
notUsingInput.value &&
|
||||
event.type === "keydown" &&
|
||||
@@ -87,9 +156,15 @@ export const useDialogHotkey = (
|
||||
(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();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
const { closeDialog, activeDialog } = useDialog();
|
||||
|
||||
const isOpen = computed(() => (activeDialog.value && activeDialog.value === props.dialogId));
|
||||
const isOpen = computed(() => (activeDialog.value && activeDialog.value === props.dialogId) ?? false);
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (!open) closeDialog(props.dialogId);
|
||||
};
|
||||
|
||||
@@ -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,7 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import { Toaster as Sonner, type ToasterProps } from 'vue-sonner'
|
||||
import { Toaster as Sonner, type ToasterProps } from "vue-sonner";
|
||||
import "vue-sonner/style.css";
|
||||
|
||||
const props = defineProps<ToasterProps>()
|
||||
const props = defineProps<ToasterProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -9,15 +10,14 @@ const props = defineProps<ToasterProps>()
|
||||
class="toaster group"
|
||||
v-bind="props"
|
||||
rich-colors
|
||||
visible-toasts="10"
|
||||
:visible-toasts="10"
|
||||
:toast-options="{
|
||||
classes: {
|
||||
toast: 'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
|
||||
toast:
|
||||
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
|
||||
description: 'group-[.toast]:text-muted-foreground',
|
||||
actionButton:
|
||||
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
||||
cancelButton:
|
||||
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
|
||||
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
||||
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
|
||||
},
|
||||
}"
|
||||
/>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default as Toaster } from './Sonner.vue'
|
||||
export { toast } from './toast'
|
||||
export { default as Toaster } from "./Sonner.vue";
|
||||
export { toast } from "./toast";
|
||||
|
||||
@@ -14,6 +14,7 @@ export function defineObserver(key: string, observer: Observer): RemoveObserver
|
||||
observers[key] = observer;
|
||||
|
||||
return () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete observers[key];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ export interface IAuthContext {
|
||||
}
|
||||
|
||||
class AuthContext implements IAuthContext {
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
private static _instance?: AuthContext;
|
||||
|
||||
private static readonly cookieTokenKey = "hb.auth.session";
|
||||
@@ -45,7 +44,7 @@ class AuthContext implements IAuthContext {
|
||||
private _attachmentToken: CookieRef<string | null>;
|
||||
|
||||
get token() {
|
||||
// @ts-ignore sometimes it's a boolean I guess?
|
||||
// @ts-expect-error sometimes it's a boolean I guess?
|
||||
return this._token.value === "true" || this._token.value === true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { UseConfirmDialogRevealResult, UseConfirmDialogReturn } from "@vueuse/core";
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { UseConfirmDialogReturn, UseConfirmDialogRevealResult } from "@vueuse/core";
|
||||
import type { Ref } from "vue";
|
||||
|
||||
type Store = UseConfirmDialogReturn<any, boolean, boolean> & {
|
||||
|
||||
@@ -44,7 +44,6 @@ export function useBreakpoints(): Breakpoints {
|
||||
}
|
||||
|
||||
class ThemeObserver {
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
private static instance?: ThemeObserver;
|
||||
private readonly observer: MutationObserver;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
type DeferFunction<TArgs extends any[], TReturn> = (...args: TArgs) => TReturn;
|
||||
|
||||
// useDefer is a function that takes a function and returns a function that
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Ref } from "vue";
|
||||
import type { TreeItem, LocationSummary } from "~~/lib/api/types/data-contracts";
|
||||
import type { LocationSummary, TreeItem } from "~~/lib/api/types/data-contracts";
|
||||
|
||||
export interface FlatTreeItem {
|
||||
id: string;
|
||||
|
||||
@@ -8,6 +8,7 @@ export function useRouteQuery(q: string, def: string): WritableComputedRef<strin
|
||||
export function useRouteQuery(q: string, def: boolean): WritableComputedRef<boolean>;
|
||||
export function useRouteQuery(q: string, def: number): WritableComputedRef<number>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function useRouteQuery(q: string, def: any): WritableComputedRef<any> {
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -39,7 +39,7 @@ function connect(onmessage: (m: EventMessage) => void) {
|
||||
console.error("websocket error", err);
|
||||
};
|
||||
|
||||
const thorttled = new Map<ServerEvent, any>();
|
||||
const thorttled = new Map<ServerEvent, (m: EventMessage) => void>();
|
||||
|
||||
thorttled.set(ServerEvent.LocationMutation, useThrottleFn(onmessage, 1000));
|
||||
thorttled.set(ServerEvent.ItemMutation, useThrottleFn(onmessage, 1000));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -59,8 +59,8 @@ export function maybeUrl(str: string): MaybeUrlResult {
|
||||
const match = str.match(/\[(.*)\]\((.*)\)/);
|
||||
if (match && match.length === 3) {
|
||||
result.isUrl = true;
|
||||
result.text = match[1];
|
||||
result.url = match[2];
|
||||
result.text = match[1]!;
|
||||
result.url = match[2]!;
|
||||
}
|
||||
} else {
|
||||
result.url = str;
|
||||
|
||||
72
frontend/eslint.config.mjs
Normal file
@@ -0,0 +1,72 @@
|
||||
import withNuxt from "./.nuxt/eslint.config.mjs";
|
||||
|
||||
import tailwind from "eslint-plugin-tailwindcss";
|
||||
import prettier from "eslint-plugin-prettier";
|
||||
|
||||
import { includeIgnoreFile } from "@eslint/compat";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const gitignorePath = fileURLToPath(new URL("../.gitignore", import.meta.url));
|
||||
|
||||
export default withNuxt([
|
||||
includeIgnoreFile(gitignorePath, "Imported ../.gitignore patterns"),
|
||||
...tailwind.configs["flat/recommended"],
|
||||
{
|
||||
plugins: {
|
||||
prettier,
|
||||
},
|
||||
rules: {
|
||||
"vue/no-undef-components": [
|
||||
"error",
|
||||
{
|
||||
// ignore anything that start with a lowercase letter or #composables
|
||||
ignorePatterns: [
|
||||
"^i18n",
|
||||
"ClientOnly",
|
||||
"Html",
|
||||
"Link",
|
||||
"Meta",
|
||||
"NuxtLayout",
|
||||
"NuxtPage",
|
||||
"NuxtLink",
|
||||
"Title",
|
||||
],
|
||||
},
|
||||
],
|
||||
"no-console": 0,
|
||||
"no-unused-vars": "off",
|
||||
"vue/multi-word-component-names": "off",
|
||||
"vue/no-setup-props-destructure": 0,
|
||||
"vue/no-multiple-template-root": 0,
|
||||
"vue/no-v-model-argument": 0,
|
||||
"vue/no-v-html": 0,
|
||||
"vue/html-self-closing": 0,
|
||||
|
||||
"tailwindcss/no-custom-classname": "warn",
|
||||
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
ignoreRestSiblings: true,
|
||||
destructuredArrayIgnorePattern: "_",
|
||||
caughtErrors: "none",
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/no-invalid-void-type": "off",
|
||||
|
||||
"prettier/prettier": [
|
||||
"warn",
|
||||
{
|
||||
arrowParens: "avoid",
|
||||
semi: true,
|
||||
tabWidth: 2,
|
||||
useTabs: false,
|
||||
vueIndentScriptAndStyle: true,
|
||||
singleQuote: false,
|
||||
trailingComma: "es5",
|
||||
printWidth: 120,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
2
frontend/global.d.ts
vendored
@@ -1 +1 @@
|
||||
/// <reference types="unplugin-icons/types/vue" />
|
||||
/// <reference types="unplugin-icons/types/vue" />
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
up the tree
|
||||
-->
|
||||
<ModalConfirm />
|
||||
<AppOutdatedModal v-if="status" :status="status" />
|
||||
<OutdatedModal v-if="status" :status="status" />
|
||||
<ItemCreateModal />
|
||||
<LabelCreateModal />
|
||||
<LocationCreateModal />
|
||||
@@ -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
|
||||
@@ -124,7 +124,7 @@
|
||||
<AppHeaderText class="h-6" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="sm:grow"></div>
|
||||
<div class="sm:grow" />
|
||||
<div class="flex h-1/2 grow items-center justify-end gap-2 sm:h-auto">
|
||||
<Input
|
||||
v-model:model-value="search"
|
||||
@@ -146,8 +146,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot></slot>
|
||||
<div class="grow"></div>
|
||||
<slot />
|
||||
<div class="grow" />
|
||||
|
||||
<footer v-if="status" class="bottom-0 w-full pb-4 text-center">
|
||||
<p class="text-center text-sm">
|
||||
@@ -157,9 +157,9 @@
|
||||
$t('global.footer.version_link', { version: status.build.version, build: status.build.commit })
|
||||
)
|
||||
"
|
||||
></span>
|
||||
/>
|
||||
~
|
||||
<span v-html="DOMPurify.sanitize($t('global.footer.api_link'))"></span>
|
||||
<span v-html="DOMPurify.sanitize($t('global.footer.api_link'))" />
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -188,16 +188,17 @@
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarRail,
|
||||
SidebarTrigger,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuLink,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -210,7 +211,19 @@
|
||||
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";
|
||||
import ModalConfirm from "~/components/ModalConfirm.vue";
|
||||
import OutdatedModal from "~/components/App/OutdatedModal.vue";
|
||||
import ItemCreateModal from "~/components/Item/CreateModal.vue";
|
||||
|
||||
import LabelCreateModal from "~/components/Label/CreateModal.vue";
|
||||
import LocationCreateModal from "~/components/Location/CreateModal.vue";
|
||||
import ItemBarcodeModal from "~/components/Item/BarcodeModal.vue";
|
||||
import AppQuickMenuModal from "~/components/App/QuickMenuModal.vue";
|
||||
import AppScannerModal from "~/components/App/ScannerModal.vue";
|
||||
import AppLogo from "~/components/App/Logo.vue";
|
||||
import AppHeaderDecor from "~/components/App/HeaderDecor.vue";
|
||||
import AppHeaderText from "~/components/App/HeaderText.vue";
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const username = computed(() => authCtx.user?.name || "User");
|
||||
@@ -269,7 +282,7 @@
|
||||
id: number;
|
||||
name: ComputedRef<string>;
|
||||
shortcut: string;
|
||||
dialogId: DialogID;
|
||||
dialogId: NoParamDialogIDs | OptionalDialogIDs;
|
||||
};
|
||||
|
||||
const dropdown: DropdownItem[] = [
|
||||
@@ -343,8 +356,8 @@
|
||||
const quickMenuActions = reactive([
|
||||
...dropdown.map(v => ({
|
||||
text: computed(() => v.name.value),
|
||||
dialogId: v.dialogId,
|
||||
shortcut: v.shortcut.split("+")[1],
|
||||
dialogId: v.dialogId as NoParamDialogIDs,
|
||||
shortcut: v.shortcut.split("+")[1] as string,
|
||||
type: "create" as const,
|
||||
})),
|
||||
...nav.map(v => ({
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import { Toaster } from "~/components/ui/sonner";
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<Toaster />
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Requests } from "../../../requests";
|
||||
|
||||
function itemField(id = null): ItemField {
|
||||
return {
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error - not actually an issue
|
||||
id,
|
||||
name: faker.lorem.word(),
|
||||
type: "text",
|
||||
@@ -45,7 +45,7 @@ function label(): LabelCreate {
|
||||
return {
|
||||
name: faker.lorem.word(),
|
||||
description: faker.lorem.sentence(),
|
||||
color: faker.internet.color(),
|
||||
color: faker.color.rgb(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { factories } from "./factories";
|
||||
|
||||
describe("[GET] /api/v1/status", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { factories } from "../factories";
|
||||
import { sharedUserClient } from "../test-utils";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { ItemField, ItemUpdate, LocationOut } from "../../types/data-contracts";
|
||||
import { AttachmentTypes } from "../../types/non-generated";
|
||||
import type { UserClient } from "../../user";
|
||||
@@ -55,9 +55,9 @@ describe("user should be able to create an item and add an attachment", () => {
|
||||
expect(itmResp.status).toBe(200);
|
||||
|
||||
expect(data.attachments).toHaveLength(1);
|
||||
expect(data.attachments[0].title).toBe("test.txt");
|
||||
expect(data.attachments[0]?.title).toBe("test.txt");
|
||||
|
||||
const resp = await api.items.attachments.delete(data.id, data.attachments[0].id);
|
||||
const resp = await api.items.attachments.delete(data.id, data.attachments[0]!.id);
|
||||
expect(resp.response.status).toBe(204);
|
||||
|
||||
api.items.delete(item.id);
|
||||
@@ -100,21 +100,21 @@ describe("user should be able to create an item and add an attachment", () => {
|
||||
expect(item2.fields).toHaveLength(fields.length);
|
||||
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
expect(item2.fields[i].name).toBe(fields[i].name);
|
||||
expect(item2.fields[i].textValue).toBe(fields[i].textValue);
|
||||
expect(item2.fields[i].numberValue).toBe(fields[i].numberValue);
|
||||
expect(item2.fields[i]?.name).toBe(fields[i]!.name);
|
||||
expect(item2.fields[i]?.textValue).toBe(fields[i]!.textValue);
|
||||
expect(item2.fields[i]?.numberValue).toBe(fields[i]!.numberValue);
|
||||
}
|
||||
|
||||
itemUpdate.fields = [fields[0], fields[1]];
|
||||
itemUpdate.fields = [fields[0]!, fields[1]!];
|
||||
|
||||
const { response: updateResponse2, data: item3 } = await api.items.update(item.id, itemUpdate as ItemUpdate);
|
||||
expect(updateResponse2.status).toBe(200);
|
||||
|
||||
expect(item3.fields).toHaveLength(2);
|
||||
for (let i = 0; i < item3.fields.length; i++) {
|
||||
expect(item3.fields[i].name).toBe(itemUpdate.fields[i].name);
|
||||
expect(item3.fields[i].textValue).toBe(itemUpdate.fields[i].textValue);
|
||||
expect(item3.fields[i].numberValue).toBe(itemUpdate.fields[i].numberValue);
|
||||
expect(item3.fields[i]?.name).toBe(itemUpdate.fields[i]!.name);
|
||||
expect(item3.fields[i]?.textValue).toBe(itemUpdate.fields[i]!.textValue);
|
||||
expect(item3.fields[i]?.numberValue).toBe(itemUpdate.fields[i]!.numberValue);
|
||||
}
|
||||
|
||||
cleanup();
|
||||
@@ -168,7 +168,7 @@ describe("user should be able to create an item and add an attachment", () => {
|
||||
// Skip first one
|
||||
const { response, data: loc } = await api.locations.create({
|
||||
parentId: lastLocationId,
|
||||
name: locations[i],
|
||||
name: locations[i]!,
|
||||
description: "",
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
@@ -28,7 +28,7 @@ type ImportObj = {
|
||||
};
|
||||
|
||||
function toCsv(data: ImportObj[]): string {
|
||||
const headers = Object.keys(data[0]).join("\t");
|
||||
const headers = Object.keys(data[0]!).join("\t");
|
||||
const rows = data.map(row => {
|
||||
return Object.values(row).join("\t");
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { Requests } from "../../requests";
|
||||
import { route } from ".";
|
||||
|
||||
@@ -47,7 +48,7 @@ export function parseDate<T>(obj: T, keys: Array<keyof T> = []): T {
|
||||
throw new Error(`Invalid date format: ${value}`);
|
||||
}
|
||||
|
||||
const [year, month, day] = split;
|
||||
const [year, month, day] = split as [string, string, string];
|
||||
|
||||
const dt = new Date();
|
||||
|
||||
@@ -90,9 +91,9 @@ export class BaseAPI {
|
||||
protected dropFields<T>(obj: T, keys: Array<keyof T> = []): T {
|
||||
const result = { ...obj };
|
||||
[...keys, "createdAt", "updatedAt"].forEach(key => {
|
||||
// @ts-ignore - we are checking for the key above
|
||||
// @ts-expect-error - TS doesn't know that we're checking for the key above
|
||||
if (hasKey(result, key)) {
|
||||
// @ts-ignore - we are guarding against this above
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete result[key];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BaseAPI, route } from "../base";
|
||||
import type { LocationOutCount, LocationCreate, LocationOut, LocationUpdate, TreeItem } from "../types/data-contracts";
|
||||
import type { LocationCreate, LocationOut, LocationOutCount, LocationUpdate, TreeItem } from "../types/data-contracts";
|
||||
|
||||
export type LocationsQuery = {
|
||||
filterChildren: boolean;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { BaseAPI, route } from "../base";
|
||||
import type {
|
||||
MaintenanceEntry,
|
||||
MaintenanceEntryWithDetails,
|
||||
MaintenanceEntryUpdate,
|
||||
MaintenanceEntryWithDetails,
|
||||
MaintenanceFilterStatus,
|
||||
} from "../types/data-contracts";
|
||||
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { format, zeroTime, factorRange, parse } from "./datelib";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { factorRange, format, parse, zeroTime } from "./datelib";
|
||||
|
||||
describe("format", () => {
|
||||
test("should format a date as a string", () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ export function format(date: Date | string): string {
|
||||
if (typeof date === "string") {
|
||||
return date;
|
||||
}
|
||||
return date.toISOString().split("T")[0];
|
||||
return date.toISOString().split("T")[0]!;
|
||||
}
|
||||
|
||||
export function zeroTime(date: Date): Date {
|
||||
@@ -31,6 +31,6 @@ export function factory(offset = 0): Date {
|
||||
}
|
||||
|
||||
export function parse(yyyyMMdd: string): Date {
|
||||
const parts = yyyyMMdd.split("-");
|
||||
const parts = yyyyMMdd.split("-") as [string, string, string];
|
||||
return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { scorePassword } from ".";
|
||||
|
||||
describe("scorePassword tests", () => {
|
||||
|
||||
@@ -23,8 +23,8 @@ export function scorePassword(pass: string): number {
|
||||
const letters: { [key: string]: number } = {};
|
||||
|
||||
for (let i = 0; i < pass.length; i++) {
|
||||
letters[pass[i]] = (letters[pass[i]] || 0) + 1;
|
||||
score += 5.0 / letters[pass[i]];
|
||||
letters[pass[i]!] = (letters[pass[i]!] || 0) + 1;
|
||||
score += 5.0 / letters[pass[i]!]!;
|
||||
}
|
||||
|
||||
// bonus points for mixing it up
|
||||
|
||||
@@ -82,7 +82,7 @@ export class Requests {
|
||||
const token = this.token();
|
||||
if (token !== "" && payload.headers !== undefined) {
|
||||
// @ts-expect-error - we know that the header is there
|
||||
payload.headers["Authorization"] = token; // eslint-disable-line dot-notation
|
||||
payload.headers["Authorization"] = token;
|
||||
}
|
||||
|
||||
if (this.methodSupportsBody(method)) {
|
||||
|
||||
@@ -25,7 +25,7 @@ export function getContrastTextColor(bgColor: string): string {
|
||||
g = parseInt(hex.slice(2, 4), 16);
|
||||
b = parseInt(hex.slice(4, 6), 16);
|
||||
} else if (bgColor.startsWith("rgb")) {
|
||||
const match = bgColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
||||
const match = bgColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/) as [string, string, string, string];
|
||||
if (match) {
|
||||
r = parseInt(match[1]);
|
||||
g = parseInt(match[2]);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4,6 +4,10 @@ import { defineNuxtConfig } from "nuxt/config";
|
||||
export default defineNuxtConfig({
|
||||
ssr: false,
|
||||
|
||||
components: {
|
||||
dirs: [],
|
||||
},
|
||||
|
||||
build: {
|
||||
transpile: ["vue-i18n"],
|
||||
},
|
||||
@@ -15,8 +19,13 @@ export default defineNuxtConfig({
|
||||
"@vite-pwa/nuxt",
|
||||
"unplugin-icons/nuxt",
|
||||
"shadcn-nuxt",
|
||||
"@nuxt/eslint",
|
||||
],
|
||||
|
||||
eslint: {
|
||||
config: {},
|
||||
},
|
||||
|
||||
nitro: {
|
||||
devProxy: {
|
||||
"/api": {
|
||||
|
||||
@@ -5,78 +5,77 @@
|
||||
"dev": "nuxt dev",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
"lint": "eslint --ext \".ts,.js,.vue\" --ignore-path ../.gitignore --ignore-pattern \"components/ui\" .",
|
||||
"lint:fix": "eslint --ext \".ts,.js,.vue\" --ignore-path ../.gitignore --ignore-pattern \"components/ui\" . --fix",
|
||||
"lint:ci": "eslint --ext \".ts,.js,.vue\" --ignore-path ../.gitignore --ignore-pattern \"components/ui\" . --max-warnings 1",
|
||||
"typecheck": "pnpm vue-tsc --noEmit",
|
||||
"lint": "eslint --ext \".ts,.js,.vue\" --ignore-pattern \"components/ui\" .",
|
||||
"lint:fix": "eslint --ext \".ts,.js,.vue\" --ignore-pattern \"components/ui\" . --fix",
|
||||
"lint:ci": "eslint --ext \".ts,.js,.vue\" --ignore-pattern \"components/ui\" . --max-warnings 1",
|
||||
"typecheck": "pnpm nuxi typecheck --noEmit",
|
||||
"test:ci": "TEST_SHUTDOWN_API_SERVER=true vitest --run --config ./test/vitest.config.ts",
|
||||
"test:local": "TEST_SHUTDOWN_API_SERVER=false && vitest --run --config ./test/vitest.config.ts",
|
||||
"test:watch": " TEST_SHUTDOWN_API_SERVER=false vitest --config ./test/vitest.config.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@eslint/compat": "^1.3.2",
|
||||
"@faker-js/faker": "^10.0.0",
|
||||
"@iconify-json/mdi": "^1.2.3",
|
||||
"@intlify/unplugin-vue-i18n": "^4.0.0",
|
||||
"@nuxtjs/eslint-config-typescript": "^12.1.0",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@types/markdown-it": "^14.1.0",
|
||||
"@intlify/unplugin-vue-i18n": "^6.0.8",
|
||||
"@nuxt/eslint": "^1.9.0",
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vite-pwa/nuxt": "^0.5.0",
|
||||
"@vue/runtime-core": "^3.5.13",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.6",
|
||||
"eslint-plugin-tailwindcss": "^3.18.0",
|
||||
"eslint-plugin-vue": "^9.33.0",
|
||||
"@vite-pwa/nuxt": "^1.0.4",
|
||||
"@vue/runtime-core": "^3.5.20",
|
||||
"eslint": "^9.34.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-tailwindcss": "^3.18.2",
|
||||
"globals": "^16.3.0",
|
||||
"h3": "^1.7.1",
|
||||
"intl-messageformat": "^10.7.16",
|
||||
"isomorphic-fetch": "^3.0.0",
|
||||
"nuxt": "3.12.4",
|
||||
"prettier": "^3.5.3",
|
||||
"shadcn-nuxt": "0.11.3",
|
||||
"typescript": "5.6.2",
|
||||
"unplugin-icons": "^0.18.5",
|
||||
"nuxt": "4.0.3",
|
||||
"prettier": "^3.6.2",
|
||||
"shadcn-nuxt": "2.2.0",
|
||||
"typescript": "5.9.2",
|
||||
"unplugin-icons": "^22.2.0",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vitest": "^1.6.1",
|
||||
"vue-i18n": "^9.14.4",
|
||||
"vue-tsc": "2.1.6"
|
||||
"vitest": "^3.2.4",
|
||||
"vue-i18n": "^11.1.11",
|
||||
"vue-tsc": "3.0.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdit/plugin-img-size": "^0.22.2",
|
||||
"@nuxtjs/color-mode": "^3.5.2",
|
||||
"@nuxtjs/tailwindcss": "^6.13.2",
|
||||
"@pinia/nuxt": "^0.5.5",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"@pinia/nuxt": "^0.11.2",
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@vuepic/vue-datepicker": "^8.8.1",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"@vueuse/nuxt": "^10.11.1",
|
||||
"@vueuse/router": "^10.11.1",
|
||||
"@vuepic/vue-datepicker": "^11.0.2",
|
||||
"@vueuse/core": "^13.8.0",
|
||||
"@vueuse/nuxt": "^13.8.0",
|
||||
"@vueuse/router": "^13.8.0",
|
||||
"@zxing/library": "^0.21.3",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.2.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.2.6",
|
||||
"fuzzysort": "^3.1.0",
|
||||
"h3": "^1.15.1",
|
||||
"h3": "^1.15.4",
|
||||
"http-proxy": "^1.18.1",
|
||||
"lucide-vue-next": "^0.474.0",
|
||||
"lucide-vue-next": "^0.542.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"pinia": "^2.3.1",
|
||||
"postcss": "^8.5.3",
|
||||
"reka-ui": "^2.2.0",
|
||||
"semver": "^7.7.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"pinia": "^3.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
"reka-ui": "^2.5.0",
|
||||
"semver": "^7.7.2",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul-vue": "^0.4.1",
|
||||
"vite": "^6.3.5",
|
||||
"vue": "3.4.8",
|
||||
"vue-router": "^4.5.0",
|
||||
"vue-sonner": "^1.3.2"
|
||||
"vite": "^7.1.3",
|
||||
"vue": "3.5.20",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue-sonner": "^2.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { toast } from "@/components/ui/sonner";
|
||||
import BaseContainer from "@/components/Base/Container.vue";
|
||||
import BaseSectionHeader from "@/components/Base/SectionHeader.vue";
|
||||
import ItemCard from "~/components/Item/Card.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -26,7 +29,7 @@
|
||||
navigateTo("/home");
|
||||
break;
|
||||
case 1:
|
||||
navigateTo(`/item/${data.items[0].id}`, { replace: true, redirectCode: 302 });
|
||||
navigateTo(`/item/${data.items[0]!.id}`, { replace: true, redirectCode: 302 });
|
||||
break;
|
||||
default:
|
||||
return data.items;
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
import { itemsTable } from "./table";
|
||||
import { useLabelStore } from "~~/stores/labels";
|
||||
import { useLocationStore } from "~~/stores/locations";
|
||||
import BaseContainer from "@/components/Base/Container.vue";
|
||||
import BaseCard from "@/components/Base/Card.vue";
|
||||
import Subtitle from "~/components/global/Subtitle.vue";
|
||||
import StatCard from "~/components/global/StatCard/StatCard.vue";
|
||||
import ItemViewTable from "~/components/Item/View/Table.vue";
|
||||
import ItemCard from "~/components/Item/Card.vue";
|
||||
import LocationCard from "~/components/Location/Card.vue";
|
||||
import LabelChip from "~/components/Label/Chip.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
|
||||