Compare commits

...

10 Commits

Author SHA1 Message Date
tonyaellie
a1947dd09e feat: autosave after image upload 2025-12-22 23:46:29 +00:00
tonyaellie
018f1f5977 fix: use logical sorting for locations 2025-12-22 23:34:29 +00:00
tonyaellie
9a9e3d462e feat: add a clear button for selectors and stop create modal overflow 2025-12-22 23:24:01 +00:00
tonyaellie
37890c2a22 docs: update OIDC configuration details 2025-12-22 11:19:49 +00:00
Tonya
096b682f0a Improve oidc docs and fix attachment issue (#1153)
* fix: sort auth issues for oidc

* feat: improve oidc docs
2025-12-21 22:11:38 +00:00
Tonya
e4d8bb2ada chore: use example.com for example
better safe than sorry
2025-12-20 21:50:44 +00:00
Katos
3becf046e6 Merge pull request #1147 from sysadminsmedia/katos/docs-variable
Update max file upload environment variable
2025-12-20 16:01:04 +00:00
Katos
a21b3257d4 Update max file upload environment variable 2025-12-20 15:57:14 +00:00
Robert Eggl
5f9ab577bb fix: request camera permission in ScannerModal (#1113)
* feat: request camera permission in ScannerModal

* chore: simplify source code
2025-12-19 21:47:37 +00:00
Robert Eggl
0a969bb64d fix(sidebar): prevent dropdown menu layout shift on hover (#1116) 2025-12-19 21:38:06 +00:00
18 changed files with 281 additions and 157 deletions

View File

@@ -124,7 +124,7 @@ func (ctrl *V1Controller) HandleAuthLogin(ps ...AuthProvider) errchain.HandlerFu
return validate.NewUnauthorizedError() return validate.NewUnauthorizedError()
} }
ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, true) ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, true, newToken.AttachmentToken)
return server.JSON(w, http.StatusOK, TokenResponse{ return server.JSON(w, http.StatusOK, TokenResponse{
Token: "Bearer " + newToken.Raw, Token: "Bearer " + newToken.Raw,
ExpiresAt: newToken.ExpiresAt, ExpiresAt: newToken.ExpiresAt,
@@ -178,7 +178,7 @@ func (ctrl *V1Controller) HandleAuthRefresh() errchain.HandlerFunc {
return validate.NewUnauthorizedError() return validate.NewUnauthorizedError()
} }
ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, false) ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, false, newToken.AttachmentToken)
return server.JSON(w, http.StatusOK, newToken) return server.JSON(w, http.StatusOK, newToken)
} }
} }
@@ -187,7 +187,7 @@ func noPort(host string) string {
return strings.Split(host, ":")[0] return strings.Split(host, ":")[0]
} }
func (ctrl *V1Controller) setCookies(w http.ResponseWriter, domain, token string, expires time.Time, remember bool) { func (ctrl *V1Controller) setCookies(w http.ResponseWriter, domain, token string, expires time.Time, remember bool, attachmentToken string) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: cookieNameRemember, Name: cookieNameRemember,
Value: strconv.FormatBool(remember), Value: strconv.FormatBool(remember),
@@ -219,6 +219,19 @@ func (ctrl *V1Controller) setCookies(w http.ResponseWriter, domain, token string
HttpOnly: false, HttpOnly: false,
Path: "/", Path: "/",
}) })
// Set attachment token cookie (accessible to frontend, not HttpOnly)
if attachmentToken != "" {
http.SetCookie(w, &http.Cookie{
Name: "hb.auth.attachment_token",
Value: attachmentToken,
Expires: expires,
Domain: domain,
Secure: ctrl.cookieSecure,
HttpOnly: false,
Path: "/",
})
}
} }
func (ctrl *V1Controller) unsetCookies(w http.ResponseWriter, domain string) { func (ctrl *V1Controller) unsetCookies(w http.ResponseWriter, domain string) {
@@ -252,6 +265,17 @@ func (ctrl *V1Controller) unsetCookies(w http.ResponseWriter, domain string) {
HttpOnly: false, HttpOnly: false,
Path: "/", Path: "/",
}) })
// Unset attachment token cookie
http.SetCookie(w, &http.Cookie{
Name: "hb.auth.attachment_token",
Value: "",
Expires: time.Unix(0, 0),
Domain: domain,
Secure: ctrl.cookieSecure,
HttpOnly: false,
Path: "/",
})
} }
// HandleOIDCLogin godoc // HandleOIDCLogin godoc
@@ -310,7 +334,7 @@ func (ctrl *V1Controller) HandleOIDCCallback() errchain.HandlerFunc {
} }
// Set cookies and redirect to home // Set cookies and redirect to home
ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, true) ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, true, newToken.AttachmentToken)
http.Redirect(w, r, "/home", http.StatusFound) http.Redirect(w, r, "/home", http.StatusFound)
return nil return nil
} }

View File

@@ -6,6 +6,7 @@ export default [
{text: 'Installation', link: '/en/installation'}, {text: 'Installation', link: '/en/installation'},
{text: 'Configure', link: '/en/configure'}, {text: 'Configure', link: '/en/configure'},
{text: 'Storage', link: '/en/configure/storage'}, {text: 'Storage', link: '/en/configure/storage'},
{text: 'OIDC', link: '/en/configure/oidc'},
{text: 'Upgrade Guide', link: '/en/upgrade'}, {text: 'Upgrade Guide', link: '/en/upgrade'},
{text: 'Migration Guide', link: '/en/migration'}, {text: 'Migration Guide', link: '/en/migration'},
] ]

View File

@@ -73,6 +73,83 @@ aside: false
| HBOX_THUMBNAIL_HEIGHT | 500 | height for generated thumbnails in pixels | | HBOX_THUMBNAIL_HEIGHT | 500 | height for generated thumbnails in pixels |
| HBOX_BARCODE_TOKEN_BARCODESPIDER | | API token for BarcodeSpider.com service used for barcode product lookups. If not set, barcode product lookups will not be performed. | | HBOX_BARCODE_TOKEN_BARCODESPIDER | | API token for BarcodeSpider.com service used for barcode product lookups. If not set, barcode product lookups will not be performed. |
```sh
Options:
--barcode-token-barcodespider <string>
--database-database <string>
--database-driver <string> (default: sqlite3)
--database-host <string>
--database-password <string>
--database-port <string>
--database-pub-sub-conn-string <string> (default: mem://{{ .Topic }})
--database-sqlite-path <string> (default: ./.data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1&_time_format=sqlite)
--database-ssl-cert <string>
--database-ssl-key <string>
--database-ssl-mode <string> (default: require)
--database-ssl-root-cert <string>
--database-username <string>
--debug-enabled <bool> (default: false)
--debug-port <string> (default: 4000)
--demo <bool>
-h, --help display this help message
--label-maker-additional-information <string>
--label-maker-bold-font-path <string>
--label-maker-dynamic-length <bool> (default: true)
--label-maker-font-size <float> (default: 32.0)
--label-maker-height <int> (default: 200)
--label-maker-label-service-timeout <int>
--label-maker-label-service-url <string>
--label-maker-margin <int> (default: 32)
--label-maker-padding <int> (default: 32)
--label-maker-print-command <string>
--label-maker-regular-font-path <string>
--label-maker-width <int> (default: 526)
--log-format <string> (default: text)
--log-level <string> (default: info)
--mailer-from <string>
--mailer-host <string>
--mailer-password <string>
--mailer-port <int>
--mailer-username <string>
--mode <string> (default: development)
--oidc-allowed-groups <string>
--oidc-auto-redirect <bool> (default: false)
--oidc-button-text <string> (default: Sign in with OIDC)
--oidc-client-id <string>
--oidc-client-secret <string>
--oidc-email-claim <string> (default: email)
--oidc-email-verified-claim <string> (default: email_verified)
--oidc-enabled <bool> (default: false)
--oidc-group-claim <string> (default: groups)
--oidc-issuer-url <string>
--oidc-name-claim <string> (default: name)
--oidc-request-timeout <duration> (default: 30s)
--oidc-scope <string> (default: openid profile email)
--oidc-state-expiry <duration> (default: 10m)
--oidc-verify-email <bool> (default: false)
--options-allow-analytics <bool> (default: false)
--options-allow-local-login <bool> (default: true)
--options-allow-registration <bool> (default: true)
--options-auto-increment-asset-id <bool> (default: true)
--options-currency-config <string>
--options-github-release-check <bool> (default: true)
--options-hostname <string>
--options-trust-proxy <bool> (default: false)
--storage-conn-string <string> (default: file:///./)
--storage-prefix-path <string> (default: .data)
--thumbnail-enabled <bool> (default: true)
--thumbnail-height <int> (default: 500)
--thumbnail-width <int> (default: 500)
-v, --version display version
--web-host <string>
--web-idle-timeout <duration> (default: 30s)
--web-max-upload-size <int> (default: 10)
--web-port <string> (default: 7745)
--web-read-timeout <duration> (default: 10s)
--web-write-timeout <duration> (default: 10s)
```
:::
### HBOX_WEB_HOST examples ### HBOX_WEB_HOST examples
| Value | Notes | | Value | Notes |
@@ -170,114 +247,4 @@ For SQLite in production:
## OIDC Configuration ## OIDC Configuration
HomeBox supports OpenID Connect (OIDC) authentication, allowing users to login using external identity providers like Keycloak, Authentik, Google, Microsoft, etc. For configuring OpenID Connect (OIDC) authentication, refer to the [OIDC Configuration Guide](/en/configure/oidc).
### Basic OIDC Setup
1. **Enable OIDC**: Set `HBOX_OIDC_ENABLED=true`
2. **Provider Configuration**: Set the required provider details:
- `HBOX_OIDC_ISSUER_URL`: Your OIDC provider's issuer URL
- `HBOX_OIDC_CLIENT_ID`: Client ID from your OIDC provider
- `HBOX_OIDC_CLIENT_SECRET`: Client secret from your OIDC provider
3. **Configure Redirect URI**: In your OIDC provider, set the redirect URI to:
`https://your-homebox-domain.com/api/v1/users/login/oidc/callback`
### Advanced OIDC Configuration
- **Group Authorization**: Use `HBOX_OIDC_ALLOWED_GROUPS` to restrict access to specific groups
- **Custom Claims**: Configure `HBOX_OIDC_GROUP_CLAIM`, `HBOX_OIDC_EMAIL_CLAIM`, and `HBOX_OIDC_NAME_CLAIM` if your provider uses different claim names
- **Auto Redirect to OIDC**: Set `HBOX_OIDC_AUTO_REDIRECT=true` to automatically redirect users directly to OIDC
- **Local Login**: Set `HBOX_OPTIONS_ALLOW_LOCAL_LOGIN=false` to completely disable username/password login
- **Email Verification**: Set `HBOX_OIDC_VERIFY_EMAIL=true` to require email verification from the OIDC provider
### Security Considerations
::: warning OIDC Security
- Store `HBOX_OIDC_CLIENT_SECRET` securely (use environment variables, not config files)
- Use HTTPS for production deployments
- Configure proper redirect URIs in your OIDC provider
- Consider setting `HBOX_OIDC_ALLOWED_GROUPS` for group-based access control
:::
::: tip CLI Arguments
If you're deploying without docker you can use command line arguments to configure the application. Run `homebox --help`
for more information.
```sh
Options:
--barcode-token-barcodespider <string>
--database-database <string>
--database-driver <string> (default: sqlite3)
--database-host <string>
--database-password <string>
--database-port <string>
--database-pub-sub-conn-string <string> (default: mem://{{ .Topic }})
--database-sqlite-path <string> (default: ./.data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1&_time_format=sqlite)
--database-ssl-cert <string>
--database-ssl-key <string>
--database-ssl-mode <string> (default: require)
--database-ssl-root-cert <string>
--database-username <string>
--debug-enabled <bool> (default: false)
--debug-port <string> (default: 4000)
--demo <bool>
-h, --help display this help message
--label-maker-additional-information <string>
--label-maker-bold-font-path <string>
--label-maker-dynamic-length <bool> (default: true)
--label-maker-font-size <float> (default: 32.0)
--label-maker-height <int> (default: 200)
--label-maker-label-service-timeout <int>
--label-maker-label-service-url <string>
--label-maker-margin <int> (default: 32)
--label-maker-padding <int> (default: 32)
--label-maker-print-command <string>
--label-maker-regular-font-path <string>
--label-maker-width <int> (default: 526)
--log-format <string> (default: text)
--log-level <string> (default: info)
--mailer-from <string>
--mailer-host <string>
--mailer-password <string>
--mailer-port <int>
--mailer-username <string>
--mode <string> (default: development)
--oidc-allowed-groups <string>
--oidc-auto-redirect <bool> (default: false)
--oidc-button-text <string> (default: Sign in with OIDC)
--oidc-client-id <string>
--oidc-client-secret <string>
--oidc-email-claim <string> (default: email)
--oidc-email-verified-claim <string> (default: email_verified)
--oidc-enabled <bool> (default: false)
--oidc-group-claim <string> (default: groups)
--oidc-issuer-url <string>
--oidc-name-claim <string> (default: name)
--oidc-request-timeout <duration> (default: 30s)
--oidc-scope <string> (default: openid profile email)
--oidc-state-expiry <duration> (default: 10m)
--oidc-verify-email <bool> (default: false)
--options-allow-analytics <bool> (default: false)
--options-allow-local-login <bool> (default: true)
--options-allow-registration <bool> (default: true)
--options-auto-increment-asset-id <bool> (default: true)
--options-currency-config <string>
--options-github-release-check <bool> (default: true)
--options-hostname <string>
--options-trust-proxy <bool> (default: false)
--storage-conn-string <string> (default: file:///./)
--storage-prefix-path <string> (default: .data)
--thumbnail-enabled <bool> (default: true)
--thumbnail-height <int> (default: 500)
--thumbnail-width <int> (default: 500)
-v, --version display version
--web-host <string>
--web-idle-timeout <duration> (default: 30s)
--web-max-upload-size <int> (default: 10)
--web-port <string> (default: 7745)
--web-read-timeout <duration> (default: 10s)
--web-write-timeout <duration> (default: 10s)
```
:::

44
docs/en/configure/oidc.md Normal file
View File

@@ -0,0 +1,44 @@
# Configure OIDC
HomeBox supports OpenID Connect (OIDC) authentication, allowing users to login using external identity providers like Keycloak, Authentik, Authelia, Google, Microsoft, etc.
::: tip OIDC Provider Documentation
When configuring OIDC, always refer to the documentation provided by your identity provider for specific details and requirements.
:::
## Basic OIDC Setup
1. **Enable OIDC**: Set `HBOX_OIDC_ENABLED=true`.
2. **Provider Configuration**: Set the required provider details:
- `HBOX_OIDC_ISSUER_URL`: Your OIDC provider's issuer URL.
- Generally this URL should not have a trailing slash, though it may be required for some providers.
- `HBOX_OIDC_CLIENT_ID`: Client ID from your OIDC provider.
- `HBOX_OIDC_CLIENT_SECRET`: Client secret from your OIDC provider.
- If you are using a reverse proxy, it may be necessary to set `HBOX_OPTIONS_TRUST_PROXY=true` to ensure `https` is correctly detected.
- If you have set `HBOX_OPTIONS_HOSTNAME` make sure it is just the hostname and does not include `https://` or `http://`.
3. **Configure Redirect URI**: In your OIDC provider, set the redirect URI to:
`https://your-homebox-domain.example.com/api/v1/users/login/oidc/callback`.
## Advanced OIDC Configuration
- **Group Authorization**: Use `HBOX_OIDC_ALLOWED_GROUPS` to restrict access to specific groups, e.g. `HBOX_OIDC_ALLOWED_GROUPS=admin,homebox`.
- Some providers require the `groups` scope to return group claims, include it in `HBOX_OIDC_SCOPE` (e.g. `openid profile email groups`) or configure the provider to release the claim.
- **Custom Claims**: Configure `HBOX_OIDC_GROUP_CLAIM`, `HBOX_OIDC_EMAIL_CLAIM`, and `HBOX_OIDC_NAME_CLAIM` if your provider uses different claim names.
- These default to `HBOX_OIDC_GROUP_CLAIM=groups`, `HBOX_OIDC_EMAIL_CLAIM=email` and `HBOX_OIDC_NAME_CLAIM=name`.
- **Auto Redirect to OIDC**: Set `HBOX_OIDC_AUTO_REDIRECT=true` to automatically redirect users directly to OIDC.
- **Local Login**: Set `HBOX_OPTIONS_ALLOW_LOCAL_LOGIN=false` to completely disable username/password login.
- **Email Verification**: Set `HBOX_OIDC_VERIFY_EMAIL=true` to require email verification from the OIDC provider.
## Security Considerations
::: warning OIDC Security
- Store `HBOX_OIDC_CLIENT_SECRET` securely (use environment variables, not config files).
- Use HTTPS for production deployments.
- Configure proper redirect URIs in your OIDC provider.
- Consider setting `HBOX_OIDC_ALLOWED_GROUPS` for group-based access control.
:::
::: tip CLI Arguments
If you're deploying without docker you can use command line arguments to configure the application. Run `homebox --help` for more information.
:::

View File

@@ -52,7 +52,7 @@ services:
environment: environment:
- HBOX_LOG_LEVEL=info - HBOX_LOG_LEVEL=info
- HBOX_LOG_FORMAT=text - HBOX_LOG_FORMAT=text
- HBOX_WEB_MAX_FILE_UPLOAD=10 - HBOX_WEB_MAX_UPLOAD_SIZE=10
# Please consider allowing analytics to help us improve Homebox (basic computer information, no personal data) # Please consider allowing analytics to help us improve Homebox (basic computer information, no personal data)
- HBOX_OPTIONS_ALLOW_ANALYTICS=false - HBOX_OPTIONS_ALLOW_ANALYTICS=false
volumes: volumes:

View File

@@ -81,17 +81,6 @@
errorMessage.value = t("scanner.error"); errorMessage.value = t("scanner.error");
}; };
const checkPermissionsError = async () => {
if (navigator.permissions) {
const permissionStatus = await navigator.permissions.query({ name: "camera" as PermissionName });
if (permissionStatus.state === "denied") {
errorMessage.value = t("scanner.permission_denied");
console.error("Camera permission denied");
return true;
}
}
};
const handleButtonClick = () => { const handleButtonClick = () => {
openDialog(DialogID.ProductImport, { params: { barcode: detectedBarcode.value } }); openDialog(DialogID.ProductImport, { params: { barcode: detectedBarcode.value } });
}; };
@@ -103,11 +92,19 @@
return; return;
} }
if (await checkPermissionsError()) {
return;
}
try { try {
// Request camera permission first
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
stream.getTracks().forEach(track => track.stop());
} catch (err: unknown) {
if (err instanceof Error && err.name === "NotAllowedError") {
errorMessage.value = t("scanner.permission_denied");
return;
}
throw err;
}
const devices = await codeReader.listVideoInputDevices(); const devices = await codeReader.listVideoInputDevices();
sources.value = devices; sources.value = devices;

View File

@@ -39,7 +39,7 @@
</div> </div>
</template> </template>
<form class="flex flex-col gap-2" @submit.prevent="create()"> <form class="flex min-w-0 flex-col gap-2" @submit.prevent="create()">
<LocationSelector v-model="form.location" /> <LocationSelector v-model="form.location" />
<!-- Template Info Display - Collapsible banner with distinct styling --> <!-- Template Info Display - Collapsible banner with distinct styling -->

View File

@@ -6,12 +6,25 @@
<Popover v-model:open="open"> <Popover v-model:open="open">
<PopoverTrigger as-child> <PopoverTrigger as-child>
<Button :id="id" variant="outline" role="combobox" :aria-expanded="open" class="w-full justify-between"> <Button :id="id" variant="outline" role="combobox" :aria-expanded="open" class="w-full justify-between">
<span> <span class="truncate text-left">
<slot name="display" v-bind="{ item: value }"> <slot name="display" v-bind="{ item: value }">
{{ displayValue(value) || localizedPlaceholder }} {{ displayValue(value) || localizedPlaceholder }}
</slot> </slot>
</span> </span>
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
<span class="ml-2 flex items-center">
<button
v-if="value"
type="button"
class="shrink-0 rounded p-1 hover:bg-primary/20"
:aria-label="t('components.item.selector.clear')"
@click.stop.prevent="clearSelection"
>
<X class="size-4" />
</button>
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</span>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent class="w-[--reka-popper-anchor-width] p-0"> <PopoverContent class="w-[--reka-popper-anchor-width] p-0">
@@ -44,7 +57,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import { Check, ChevronsUpDown } from "lucide-vue-next"; import { Check, ChevronsUpDown, X } from "lucide-vue-next";
import fuzzysort from "fuzzysort"; import fuzzysort from "fuzzysort";
import { useVModel } from "@vueuse/core"; import { useVModel } from "@vueuse/core";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
@@ -174,6 +187,12 @@
open.value = false; open.value = false;
} }
function clearSelection() {
value.value = null;
search.value = "";
open.value = false;
}
const filtered = computed(() => { const filtered = computed(() => {
let baseItems = props.items; let baseItems = props.items;

View File

@@ -1,6 +1,6 @@
<template> <template>
<BaseModal :dialog-id="DialogID.CreateLabel" :title="$t('components.label.create_modal.title')"> <BaseModal :dialog-id="DialogID.CreateLabel" :title="$t('components.label.create_modal.title')">
<form class="flex flex-col gap-2" @submit.prevent="create()"> <form class="flex min-w-0 flex-col gap-2" @submit.prevent="create()">
<FormTextField <FormTextField
v-model="form.name" v-model="form.name"
:trigger-focus="focused" :trigger-focus="focused"

View File

@@ -1,6 +1,6 @@
<template> <template>
<BaseModal :dialog-id="DialogID.CreateLocation" :title="$t('components.location.create_modal.title')"> <BaseModal :dialog-id="DialogID.CreateLocation" :title="$t('components.location.create_modal.title')">
<form class="flex flex-col gap-2" @submit.prevent="create()"> <form class="flex min-w-0 flex-col gap-2" @submit.prevent="create()">
<LocationSelector v-model="form.parent" /> <LocationSelector v-model="form.parent" />
<FormTextField <FormTextField
ref="locationNameRef" ref="locationNameRef"

View File

@@ -7,8 +7,23 @@
<Popover v-model:open="open"> <Popover v-model:open="open">
<PopoverTrigger as-child> <PopoverTrigger as-child>
<Button :id="id" variant="outline" role="combobox" :aria-expanded="open" class="w-full justify-between"> <Button :id="id" variant="outline" role="combobox" :aria-expanded="open" class="w-full justify-between">
{{ value && value.name ? value.name : $t("components.location.selector.select_location") }} <span class="min-w-0 flex-auto truncate text-left">
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" /> {{ value && value.name ? value.name : $t("components.location.selector.select_location") }}
</span>
<span class="ml-2 flex items-center">
<button
v-if="value"
type="button"
class="shrink-0 rounded p-1 hover:bg-primary/20"
:aria-label="$t('components.location.selector.clear')"
@click.stop.prevent="clearSelection"
>
<X class="size-4" />
</button>
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</span>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent class="w-[--reka-popper-anchor-width] p-0"> <PopoverContent class="w-[--reka-popper-anchor-width] p-0">
@@ -46,7 +61,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Check, ChevronsUpDown } from "lucide-vue-next"; import { Check, ChevronsUpDown, X } from "lucide-vue-next";
import fuzzysort from "fuzzysort"; import fuzzysort from "fuzzysort";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command";
@@ -79,6 +94,12 @@
open.value = false; open.value = false;
} }
function clearSelection() {
value.value = null;
search.value = "";
open.value = false;
}
const filteredLocations = computed(() => { const filteredLocations = computed(() => {
const filtered = fuzzysort.go(search.value, locations.value, { key: "name", all: true }).map(i => i.obj); const filtered = fuzzysort.go(search.value, locations.value, { key: "name", all: true }).map(i => i.obj);

View File

@@ -22,6 +22,13 @@
const state = useTreeState(props.treeId); const state = useTreeState(props.treeId);
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
const sortedChildren = computed(() => {
const children = props.item.children ?? [];
return [...children].sort((a, b) => collator.compare(a.name, b.name));
});
const openRef = computed({ const openRef = computed({
get() { get() {
return state.value[nodeHash.value] ?? false; return state.value[nodeHash.value] ?? false;
@@ -66,7 +73,7 @@
<NuxtLink class="text-lg hover:underline" :to="link" @click.stop>{{ item.name }} </NuxtLink> <NuxtLink class="text-lg hover:underline" :to="link" @click.stop>{{ item.name }} </NuxtLink>
</div> </div>
<div v-if="openRef" class="ml-4"> <div v-if="openRef" class="ml-4">
<LocationTreeNode v-for="child in item.children" :key="child.id" :item="child" :tree-id="treeId" /> <LocationTreeNode v-for="child in sortedChildren" :key="child.id" :item="child" :tree-id="treeId" />
</div> </div>
</div> </div>
</template> </template>

View File

@@ -7,14 +7,21 @@
treeId: string; treeId: string;
}; };
defineProps<Props>(); const props = defineProps<Props>();
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
const sortedLocs = computed(() => {
const list = props.locs ?? [];
return [...list].sort((a, b) => collator.compare(a.name, b.name));
});
</script> </script>
<template> <template>
<div> <div>
<p v-if="locs.length === 0" class="text-center text-sm"> <p v-if="sortedLocs.length === 0" class="text-center text-sm">
{{ $t("location.tree.no_locations") }} {{ $t("location.tree.no_locations") }}
</p> </p>
<LocationTreeNode v-for="item in locs" :key="item.id" :item="item" :tree-id="treeId" /> <LocationTreeNode v-for="item in sortedLocs" :key="item.id" :item="item" :tree-id="treeId" />
</div> </div>
</template> </template>

View File

@@ -1,6 +1,6 @@
<template> <template>
<BaseModal :dialog-id="DialogID.CreateTemplate" :title="$t('components.template.create_modal.title')"> <BaseModal :dialog-id="DialogID.CreateTemplate" :title="$t('components.template.create_modal.title')">
<form class="flex flex-col gap-2" @submit.prevent="create()"> <form class="flex min-w-0 flex-col gap-2" @submit.prevent="create()">
<FormTextField <FormTextField
v-model="form.name" v-model="form.name"
:autofocus="true" :autofocus="true"
@@ -16,7 +16,7 @@
<Separator class="my-2" /> <Separator class="my-2" />
<h3 class="text-sm font-medium">{{ $t("components.template.form.default_item_values") }}</h3> <h3 class="text-sm font-medium">{{ $t("components.template.form.default_item_values") }}</h3>
<div class="grid gap-2"> <div class="flex min-w-0 flex-col gap-2">
<FormTextField v-model="form.defaultName" :label="$t('components.template.form.item_name')" :max-length="255" /> <FormTextField v-model="form.defaultName" :label="$t('components.template.form.item_name')" :max-length="255" />
<FormTextArea <FormTextArea
v-model="form.defaultDescription" v-model="form.defaultDescription"

View File

@@ -38,6 +38,14 @@
</div> </div>
</CommandItem> </CommandItem>
</CommandGroup> </CommandGroup>
<CommandSeparator v-if="value" />
<CommandGroup v-if="value">
<CommandItem v-if="value" value="clear-selection" @select="clearSelection">
<div class="flex w-full">
{{ $t("components.template.selector.clear") }}
</div>
</CommandItem>
</CommandGroup>
</CommandList> </CommandList>
</Command> </Command>
</PopoverContent> </PopoverContent>
@@ -79,6 +87,13 @@
</div> </div>
</CommandItem> </CommandItem>
</CommandGroup> </CommandGroup>
<CommandSeparator />
<CommandItem v-if="value" value="clear-selection" @select="clearSelection">
<X :class="cn('mr-2 h-4 w-4')" />
<div class="flex w-full">
<span class="text-destructive">{{ $t("components.template.selector.clear") }}</span>
</div>
</CommandItem>
</CommandList> </CommandList>
</Command> </Command>
</PopoverContent> </PopoverContent>
@@ -87,10 +102,18 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Check, ChevronsUpDown } from "lucide-vue-next"; import { Check, ChevronsUpDown, X } from "lucide-vue-next";
import fuzzysort from "fuzzysort"; import fuzzysort from "fuzzysort";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command"; import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "~/components/ui/command";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
@@ -132,6 +155,13 @@
open.value = false; open.value = false;
} }
function clearSelection() {
value.value = null;
emit("template-selected", null);
search.value = "";
open.value = false;
}
const filteredTemplates = computed(() => { const filteredTemplates = computed(() => {
if (!templates.value) return []; if (!templates.value) return [];
const filtered = fuzzysort.go(search.value, templates.value, { key: "name", all: true }).map(i => i.obj); const filtered = fuzzysort.go(search.value, templates.value, { key: "name", all: true }).map(i => i.obj);

View File

@@ -47,7 +47,7 @@
{{ btn.name.value }} {{ btn.name.value }}
<Shortcut <Shortcut
v-if="btn.shortcut" v-if="btn.shortcut"
class="ml-auto hidden group-hover:inline" class="invisible ml-auto group-hover:visible"
:keys="btn.shortcut.replace('Shift', '').split('+')" :keys="btn.shortcut.replace('Shift', '').split('+')"
/> />
</DropdownMenuItem> </DropdownMenuItem>

View File

@@ -136,7 +136,8 @@
"no_results": "No Results Found", "no_results": "No Results Found",
"placeholder": "Select…", "placeholder": "Select…",
"search_placeholder": "Type to search…", "search_placeholder": "Type to search…",
"searching": "Searching…" "searching": "Searching…",
"clear": "Clear Item Selection"
}, },
"view": { "view": {
"change_details": { "change_details": {
@@ -221,7 +222,8 @@
"no_location_found": "No location found", "no_location_found": "No location found",
"parent_location": "Parent Location", "parent_location": "Parent Location",
"search_location": "Search Locations", "search_location": "Search Locations",
"select_location": "Select a Location" "select_location": "Select a Location",
"clear": "Clear Location Selection"
}, },
"tree": { "tree": {
"no_locations": "No locations available. Add new locations through the\n '<span class=\"link-primary\">'Create'</span>' button on the navigation bar." "no_locations": "No locations available. Add new locations through the\n '<span class=\"link-primary\">'Create'</span>' button on the navigation bar."
@@ -268,7 +270,8 @@
"label": "Template (Optional)", "label": "Template (Optional)",
"not_found": "No template found", "not_found": "No template found",
"search": "Search templates...", "search": "Search templates...",
"select": "Select template..." "select": "Select template...",
"clear": "Clear Template Selection"
}, },
"toast": { "toast": {
"applied": "Template \"{name}\" applied", "applied": "Template \"{name}\" applied",

View File

@@ -98,7 +98,7 @@
const saving = ref(false); const saving = ref(false);
async function saveItem() { async function saveItem(redirect: boolean) {
if (!item.value.location?.id) { if (!item.value.location?.id) {
toast.error(t("items.toast.failed_save_no_location")); toast.error(t("items.toast.failed_save_no_location"));
return; return;
@@ -139,7 +139,9 @@
} }
toast.success(t("items.toast.item_saved")); toast.success(t("items.toast.item_saved"));
navigateTo("/item/" + itemId.value); if (redirect) {
navigateTo("/item/" + itemId.value);
}
} }
type NonNullableStringKeys<T> = Extract<keyof T, keyof { [K in keyof T as T[K] extends string ? K : never]: any }>; type NonNullableStringKeys<T> = Extract<keyof T, keyof { [K in keyof T as T[K] extends string ? K : never]: any }>;
@@ -339,6 +341,8 @@
toast.success(t("items.toast.attachment_uploaded")); toast.success(t("items.toast.attachment_uploaded"));
await saveItem(false);
item.value.attachments = data.attachments; item.value.attachments = data.attachments;
} }
@@ -432,13 +436,13 @@
// Cmd + S // Cmd + S
if (e.metaKey && e.key === "s") { if (e.metaKey && e.key === "s") {
e.preventDefault(); e.preventDefault();
await saveItem(); await saveItem(false);
} }
// Ctrl + S // Ctrl + S
if (e.ctrlKey && e.key === "s") { if (e.ctrlKey && e.key === "s") {
e.preventDefault(); e.preventDefault();
await saveItem(); await saveItem(false);
} }
} }
@@ -573,7 +577,7 @@
<TooltipContent>{{ $t("items.show_advanced_view_options") }}</TooltipContent> <TooltipContent>{{ $t("items.show_advanced_view_options") }}</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
<Button size="sm" :disabled="saving" @click="saveItem"> <Button size="sm" :disabled="saving" @click="saveItem(true)">
<MdiLoading v-if="saving" class="animate-spin" /> <MdiLoading v-if="saving" class="animate-spin" />
<MdiContentSaveOutline v-else /> <MdiContentSaveOutline v-else />
{{ $t("global.save") }} {{ $t("global.save") }}