Fix untranslated strings (#756)

* add missing translations and translate page titles

* fix: actually use the declared localized variables

* lint and prettier fixes

* add missing translations for toasts and confirms

* use components for shift/enter keys, add pluralization for photos, and fix primary photo conditional

* remove prop defaults since we're computing these anyways
This commit is contained in:
Nikolai Oakfield
2025-05-29 08:56:30 -04:00
committed by GitHub
parent e6f7397b30
commit 3a4fae5eb8
27 changed files with 478 additions and 233 deletions

View File

@@ -8,10 +8,18 @@
<slot />
<DialogFooter>
<span class="flex items-center gap-1 text-sm">
Use <Shortcut size="sm" :keys="['Shift']" /> + <Shortcut size="sm" :keys="['Enter']" /> to create and add
another.
</span>
<i18n-t
keypath="components.app.create_modal.createAndAddAnother"
tag="span"
class="flex items-center gap-1 text-sm"
>
<template #shiftKey>
<Shortcut size="sm" :keys="[$t('components.app.create_modal.shift')]" />
</template>
<template #enterKey>
<Shortcut size="sm" :keys="[$t('components.app.create_modal.enter')]" />
</template>
</i18n-t>
</DialogFooter>
</DialogScrollContent>
</Dialog>

View File

@@ -37,6 +37,7 @@
</template>
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { toast } from "@/components/ui/sonner";
import {
Dialog,
@@ -52,6 +53,8 @@
modelValue: boolean;
};
const { t } = useI18n();
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
});
@@ -83,7 +86,7 @@
async function submitCsvFile() {
if (!importCsv.value) {
toast.error("Please select a file to import.");
toast.error(t("components.app.import_dialog.toast.please_select_file"));
return;
}
@@ -92,7 +95,7 @@
const { error } = await api.items.import(importCsv.value);
if (error) {
toast.error("Import failed. Please try again later.");
toast.error(t("components.app.import_dialog.toast.import_failed"));
}
// Reset
@@ -104,6 +107,6 @@
importRef.value.value = "";
}
toast.success("Import successful!");
toast.success(t("components.app.import_dialog.toast.import_success"));
}
</script>

View File

@@ -1,6 +1,7 @@
<template>
<div class="relative">
<FormTextField v-model="value" placeholder="Password" :label="label" :type="inputType"> </FormTextField>
<FormTextField v-model="value" :placeholder="localizedPlaceholder" :label="localizedLabel" :type="inputType">
</FormTextField>
<TooltipProvider :delay-duration="0">
<Tooltip>
<TooltipTrigger as-child>
@@ -12,29 +13,31 @@
<MdiEye name="mdi-eye" class="size-5" />
</button>
</TooltipTrigger>
<TooltipContent>Toggle Password Show</TooltipContent>
<TooltipContent>{{ $t("components.form.password.toggle_show") }}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</template>
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import MdiEye from "~icons/mdi/eye";
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
const { t } = useI18n();
type Props = {
modelValue: string;
placeholder?: string;
label: string;
};
const props = withDefaults(defineProps<Props>(), {
placeholder: "Password",
label: "Password",
});
const props = defineProps<Props>();
const [hide, toggle] = useToggle(true);
const localizedPlaceholder = computed(() => props.placeholder ?? t("global.password"));
const localizedLabel = computed(() => props.label ?? t("global.password"));
const inputType = computed(() => {
return hide.value ? "password" : "text";
});

View File

@@ -21,7 +21,7 @@
<MdiDownload />
</a>
</TooltipTrigger>
<TooltipContent> Download </TooltipContent>
<TooltipContent> {{ $t("components.item.attachments_list.download") }} </TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
@@ -29,7 +29,7 @@
<MdiOpenInNew />
</a>
</TooltipTrigger>
<TooltipContent> Open in new tab </TooltipContent>
<TooltipContent> {{ $t("components.item.attachments_list.open_new_tab") }} </TooltipContent>
</Tooltip>
</TooltipProvider>
</div>

View File

@@ -69,7 +69,11 @@
<div v-if="form.photos.length > 0" class="mt-4 border-t px-4 pb-4">
<div v-for="(photo, index) in form.photos" :key="index">
<div class="mt-8 w-full">
<img :src="photo.fileBase64" class="w-full rounded object-fill shadow-sm" alt="Uploaded Photo" />
<img
:src="photo.fileBase64"
class="w-full rounded object-fill shadow-sm"
:alt="$t('components.item.create_modal.uploaded')"
/>
</div>
<div class="mt-2 flex items-center gap-2">
<TooltipProvider class="flex gap-2" :delay-duration="0">
@@ -77,11 +81,11 @@
<TooltipTrigger>
<Button size="icon" type="button" variant="destructive" @click.prevent="deleteImage(index)">
<MdiDelete />
<div class="sr-only">Delete photo</div>
<div class="sr-only">{{ $t("components.item.create_modal.delete_photo") }}</div>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete photo</p>
<p>{{ $t("components.item.create_modal.delete_photo") }}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
@@ -97,11 +101,11 @@
"
>
<MdiRotateClockwise />
<div class="sr-only">Rotate photo</div>
<div class="sr-only">{{ $t("components.item.create_modal.rotate_photo") }}</div>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Rotate photo</p>
<p>{{ $t("components.item.create_modal.rotate_photo") }}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
@@ -114,11 +118,15 @@
>
<MdiStar v-if="photo.primary" />
<MdiStarOutline v-else />
<div class="sr-only">Set as {{ photo.primary ? "non" : "" }} primary photo</div>
<div class="sr-only">
{{ $t("components.item.create_modal.set_as_primary_photo", { isPrimary: photo.primary }) }}
</div>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Set as {{ photo.primary ? "non" : "" }} primary photo</p>
<p>
{{ $t("components.item.create_modal.set_as_primary_photo", { isPrimary: photo.primary }) }}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -131,6 +139,7 @@
</template>
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { toast } from "@/components/ui/sonner";
import { Button, ButtonGroup } from "~/components/ui/button";
import BaseModal from "@/components/App/CreateModal.vue";
@@ -157,6 +166,7 @@
primary: boolean;
}
const { t } = useI18n();
const { activeDialog, closeDialog } = useDialog();
useDialogHotkey("create-item", { code: "Digit1", shift: true });
@@ -269,7 +279,7 @@
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("Failed to load parent item - please select manually");
toast.error(t("components.item.create_modal.toast.failed_load_parent"));
console.error("Parent item fetch error:", error);
}
@@ -309,12 +319,12 @@
async function create(close = true) {
if (!form.location?.id) {
toast.error("Please select a location.");
toast.error(t("components.item.create_modal.toast.please_select_location"));
return;
}
if (loading.value) {
toast.error("Already creating an item");
toast.error(t("components.item.create_modal.toast.already_creating"));
return;
}
@@ -335,14 +345,14 @@
if (error) {
loading.value = false;
toast.error("Couldn't create item");
toast.error(t("components.item.create_modal.toast.create_failed"));
return;
}
toast.success("Item created");
toast.success(t("components.item.create_modal.toast.create_success"));
if (form.photos.length > 0) {
toast.info(`Uploading ${form.photos.length} photo(s)...`);
toast.info(t("components.item.create_modal.toast.uploading_photos", { count: form.photos.length }));
let uploadError = false;
for (const photo of form.photos) {
const { error: attachError } = await api.items.attachments.add(
@@ -355,14 +365,14 @@
if (attachError) {
uploadError = true;
toast.error(`Failed to upload Photo: ${photo.photoName}`);
toast.error(t("components.item.create_modal.toast.upload_failed", { photoName: photo.photoName }));
console.error(attachError);
}
}
if (uploadError) {
toast.warning("Some photos failed to upload.");
toast.warning(t("components.item.create_modal.toast.some_photos_failed", { count: form.photos.length }));
} else {
toast.success("All photos uploaded successfully.");
toast.success(t("components.item.create_modal.toast.upload_success", { count: form.photos.length }));
}
}
@@ -415,7 +425,7 @@
const offScreenCanvasCtx = offScreenCanvas.getContext("2d");
if (!offScreenCanvasCtx) {
toast.error("Your browser doesn't support canvas operations");
toast.error(t("components.item.create_modal.toast.no_canvas_support"));
return;
}
@@ -428,7 +438,7 @@
img.onerror = () => reject(new Error("Failed to load image"));
img.src = base64Image;
}).catch(error => {
toast.error("Failed to rotate image: " + error.message);
toast.error(t("components.item.create_modal.toast.rotate_failed", { error: error.message }));
});
// Set its dimensions to rotated size
@@ -447,7 +457,7 @@
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("Failed to process rotated image");
toast.error(t("components.item.create_modal.toast.rotate_process_failed"));
console.error(error);
} finally {
// Clean up resources

View File

@@ -8,7 +8,7 @@
<Button :id="id" variant="outline" role="combobox" :aria-expanded="open" class="w-full justify-between">
<span>
<slot name="display" v-bind="{ item: value }">
{{ displayValue(value) || placeholder }}
{{ displayValue(value) || localizedPlaceholder }}
</slot>
</span>
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
@@ -16,9 +16,9 @@
</PopoverTrigger>
<PopoverContent class="w-[--reka-popper-anchor-width] p-0">
<Command :ignore-filter="true">
<CommandInput v-model="search" :placeholder="searchPlaceholder" :display-value="_ => ''" />
<CommandInput v-model="search" :placeholder="localizedSearchPlaceholder" :display-value="_ => ''" />
<CommandEmpty>
{{ noResultsText }}
{{ localizedNoResultsText }}
</CommandEmpty>
<CommandList>
<CommandGroup>
@@ -41,6 +41,7 @@
import { Check, ChevronsUpDown } from "lucide-vue-next";
import fuzzysort from "fuzzysort";
import { useVModel } from "@vueuse/core";
import { useI18n } from "vue-i18n";
import { Button } from "~/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command";
import { Label } from "~/components/ui/label";
@@ -48,6 +49,8 @@
import { cn } from "~/lib/utils";
import { useId } from "#imports";
const { t } = useI18n();
type ItemsObject = {
[key: string]: unknown;
};
@@ -72,9 +75,9 @@
itemText: "text",
itemValue: "value",
search: "",
searchPlaceholder: "Type to search...",
noResultsText: "No Results Found",
placeholder: "Select...",
searchPlaceholder: undefined,
noResultsText: undefined,
placeholder: undefined,
});
const id = useId();
@@ -82,6 +85,12 @@
const search = ref(props.search);
const value = useVModel(props, "modelValue", emit);
const localizedSearchPlaceholder = computed(
() => props.searchPlaceholder ?? t("components.item.selector.search_placeholder")
);
const localizedNoResultsText = computed(() => props.noResultsText ?? t("components.item.selector.no_results"));
const localizedPlaceholder = computed(() => props.placeholder ?? t("components.item.selector.placeholder"));
watch(
() => props.search,
val => {

View File

@@ -27,10 +27,13 @@
</template>
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { toast } from "@/components/ui/sonner";
import BaseModal from "@/components/App/CreateModal.vue";
import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider";
const { t } = useI18n();
const { closeDialog } = useDialog();
useDialogHotkey("create-label", { code: "Digit2", shift: true });
@@ -56,11 +59,11 @@
async function create(close = true) {
if (loading.value) {
toast.error("Already creating a label");
toast.error(t("components.label.create_modal.toast.already_creating"));
return;
}
if (form.name.length > 50) {
toast.error("Label name must not be longer than 50 characters");
toast.error(t("components.label.create_modal.toast.label_name_too_long"));
return;
}
@@ -71,12 +74,12 @@
const { error, data } = await api.labels.create(form);
if (error) {
toast.error("Couldn't create label");
toast.error(t("components.label.create_modal.toast.create_failed"));
loading.value = false;
return;
}
toast.success("Label created");
toast.success(t("components.label.create_modal.toast.create_success"));
reset();
if (close) {

View File

@@ -66,6 +66,7 @@
</div>
</template>
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { ComboboxAnchor, ComboboxContent, ComboboxInput, ComboboxPortal, ComboboxRoot } from "reka-ui";
import { computed, ref } from "vue";
import fuzzysort from "fuzzysort";
@@ -80,6 +81,8 @@
} from "@/components/ui/tags-input";
import type { LabelOut } from "~/lib/api/types/data-contracts";
const { t } = useI18n();
const id = useId();
const api = useUserApi();
@@ -118,7 +121,7 @@
.filter(i => !modelValue.value.includes(i.value));
if (searchTerm.value.trim() !== "") {
filtered.push({ value: "create-item", label: `Create ${searchTerm.value}` });
filtered.push({ value: "create-item", label: `${t("global.create")} ${searchTerm.value}` });
}
return filtered;
@@ -126,7 +129,7 @@
const createAndAdd = async (name: string) => {
if (name.length > 50) {
toast.error("Label name must not be longer than 50 characters");
toast.error(t("components.label.create_modal.toast.label_name_too_long"));
return;
}
const { error, data } = await api.labels.create({
@@ -136,11 +139,11 @@
});
if (error) {
toast.error("Couldn't create label");
toast.error(t("components.label.create_modal.toast.create_failed"));
return;
}
toast.success("Label created");
toast.success(t("components.label.create_modal.toast.create_success"));
modelValue.value = [...modelValue.value, data.id];
};

View File

@@ -30,12 +30,15 @@
</template>
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { toast } from "@/components/ui/sonner";
import { Button, ButtonGroup } from "~/components/ui/button";
import BaseModal from "@/components/App/CreateModal.vue";
import type { LocationSummary } from "~~/lib/api/types/data-contracts";
import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider";
const { t } = useI18n();
const { activeDialog, closeDialog } = useDialog();
useDialogHotkey("create-location", { code: "Digit3", shift: true });
@@ -94,7 +97,7 @@
async function create(close = true) {
if (loading.value) {
toast.error("Already creating a location");
toast.error(t("components.location.create_modal.toast.already_creating"));
return;
}
loading.value = true;
@@ -109,11 +112,11 @@
if (error) {
loading.value = false;
toast.error("Couldn't create location");
toast.error(t("components.location.create_modal.toast.create_failed"));
}
if (data) {
toast.success("Location created");
toast.success(t("components.location.create_modal.toast.create_success"));
}
reset();

View File

@@ -3,7 +3,7 @@
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ $t("global.confirm") }}</AlertDialogTitle>
<AlertDialogDescription> {{ text }} </AlertDialogDescription>
<AlertDialogDescription> {{ text || $t("global.delete_confirm") }} </AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel @click="cancel(false)">

View File

@@ -3,7 +3,7 @@
<template #default>
{{ formattedValue }}
</template>
<template #fallback> Loading... </template>
<template #fallback> {{ $t("global.loading") }} </template>
</Suspense>
</template>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { route } from "../../lib/api/base";
import PageQRCode from "./PageQRCode.vue";
import { toast } from "@/components/ui/sonner";
@@ -17,6 +18,7 @@
import { Button, ButtonGroup } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
const { t } = useI18n();
const { openDialog, closeDialog } = useDialog();
const props = defineProps<{
@@ -29,7 +31,7 @@
const { data: status } = useAsyncData(async () => {
const { data, error } = await pubApi.status();
if (error) {
toast.error("Failed to load status");
toast.error(t("components.global.label_maker.toast.load_status_failed"));
return;
}
@@ -55,11 +57,11 @@
} catch (err) {
console.error("Failed to print labels:", err);
serverPrinting.value = false;
toast.error("Failed to print label");
toast.error(t("components.global.label_maker.toast.print_failed"));
return;
}
toast.success("Label printed");
toast.success(t("components.global.label_maker.toast.print_success"));
closeDialog("print-label");
serverPrinting.value = false;
}

View File

@@ -17,7 +17,7 @@
<h1 class="flex flex-col gap-5 text-center font-extrabold">
<span class="text-7xl">{{ error.statusCode }}.</span>
<span class="text-5xl"> {{ error.message }} </span>
<NuxtLink to="/" :class="buttonVariants({ size: 'lg' })"> Return Home </NuxtLink>
<NuxtLink to="/" :class="buttonVariants({ size: 'lg' })"> {{ $t("global.return_home") }} </NuxtLink>
</h1>
</main>
</template>

View File

@@ -4,7 +4,12 @@
"import_dialog": {
"change_warning": "Behavior for imports with existing import_refs has changed. If an import_ref is present in the CSV file, the \nitem will be updated with the values in the CSV file.",
"description": "Import a CSV file containing your items, labels, and locations. See documentation for more information on the \nrequired format.",
"title": "Import CSV File"
"title": "Import CSV File",
"toast": {
"import_failed": "Import failed. Please try again later.",
"import_success": "Import successful!",
"please_select_file": "Please select a file to import."
}
},
"outdated": {
"current_version": "Current Version",
@@ -12,6 +17,16 @@
"latest_version": "Latest Version",
"new_version_available": "New Version Available",
"new_version_available_link": "Click here to view the release notes"
},
"create_modal": {
"createAndAddAnother": "Use {shiftKey} + {enterKey} to create and add another.",
"enter": "Enter",
"shift": "Shift"
}
},
"form": {
"password": {
"toggle_show": "Toggle Password Show"
}
},
"global": {
@@ -51,7 +66,12 @@
"download": "Download Label",
"print": "Print label",
"server_print": "Print on Server",
"titles": "Labels"
"titles": "Labels",
"toast": {
"load_status_failed": "Failed to load status",
"print_failed": "Failed to print label",
"print_success": "Label printed"
}
},
"page_qr_code": {
"page_url": "Page URL",
@@ -62,14 +82,41 @@
}
},
"item": {
"attachments_list": {
"download": "Download",
"open_new_tab": "Open in new tab"
},
"create_modal": {
"delete_photo": "Delete photo",
"item_description": "Item Description",
"item_name": "Item Name",
"item_photo": "Item Photo 📷",
"item_quantity": "Item Quantity",
"parent_item" :"Parent Item",
"rotate_photo": "Rotate photo",
"set_as_primary_photo": "Set as { isPrimary, select, true {non-} false {} other {}}primary photo",
"title": "Create Item",
"upload_photos": "Upload Photos"
"toast": {
"already_creating": "Already creating an item",
"create_failed": "Couldn't create item",
"create_success": "Item created",
"failed_load_parent": "Failed to load parent item - please select manually",
"no_canvas_support": "Your browser doesn't support canvas operations",
"please_select_location": "Please select a location.",
"rotate_failed": "Failed to rotate image: { error }",
"rotate_process_failed": "Failed to process rotated image",
"some_photos_failed": "{count, plural, =0 {No photos to upload.} =1 {1 photo failed to upload.} other {Some photos failed to upload.}}",
"upload_failed": "Failed to upload photo: { photoName }",
"upload_success": "{count, plural, =0 {No photos uploaded.} =1 {Photo uploaded successfully.} other {All photos uploaded successfully.}}",
"uploading_photos": "{count, plural, =0 {No photos to upload} =1 {Uploading 1 photo...} other {Uploading {count} photos...}}"
},
"upload_photos": "Upload Photos",
"uploaded": "Uploaded Photo"
},
"selector": {
"no_results": "No Results Found",
"placeholder": "Select...",
"search_placeholder": "Type to search..."
},
"view": {
"selectable": {
@@ -90,7 +137,13 @@
"create_modal": {
"label_description": "Label Description",
"label_name": "Label Name",
"title": "Create Label"
"title": "Create Label",
"toast": {
"already_creating": "Already creating a label",
"create_failed": "Couldn't create label",
"create_success": "Label created",
"label_name_too_long": "Label name must not be longer than 50 characters"
}
},
"selector": {
"select_labels": "Select Labels"
@@ -100,7 +153,12 @@
"create_modal": {
"location_description": "Location Description",
"location_name": "Location Name",
"title": "Create Location"
"title": "Create Location",
"toast": {
"already_creating": "Already creating a location",
"create_failed": "Couldn't create location",
"create_success": "Location created"
}
},
"selector": {
"no_location_found": "No location found",
@@ -128,6 +186,8 @@
"created": "Created",
"create_subitem": "Create Subitem",
"delete": "Delete",
"delete_confirm": "Are you sure you want to delete this item? ",
"demo_instance": "This is a demo instance",
"details": "Details",
"duplicate": "Duplicate",
"edit": "Edit",
@@ -149,11 +209,14 @@
"password": "Password",
"quantity": "Quantity",
"read_docs": "Read the Docs",
"return_home": "Return Home",
"save": "Save",
"search": "Search",
"sign_out": "Sign Out",
"submit": "Submit",
"unknown": "Unknown",
"update": "Update",
"updating": "Updating",
"value": "Value",
"version": "Version: { version }",
"welcome": "Welcome, { username }",
@@ -179,13 +242,24 @@
"set_email": "What's your email?",
"set_name": "What's your name?",
"set_password": "Set your password",
"tagline": "Track, Organize, and Manage your Things."
"tagline": "Track, Organize, and Manage your Things.",
"title": "Organize and Tag Your Stuff",
"toast": {
"invalid_email": "Invalid email address",
"invalid_email_password": "Invalid email or password",
"login_success": "Logged in successfully",
"problem_registering": "Problem registering user",
"user_registered": "User registered"
}
},
"items": {
"add": "Add",
"advanced": "Advanced",
"archived": "Archived",
"delete_attachment_confirm": "Are you sure you want to delete this attachment?",
"delete_item_confirm": "Are you sure you want to delete this item?",
"asset_id": "Asset ID",
"associated_with_multiple": "This Asset Id is associated with multiple items",
"attachment": "Attachment",
"attachments": "Attachments",
"changes_persisted_immediately": "Changes to attachments will be saved immediately",
@@ -194,11 +268,22 @@
"description": "Description",
"details": "Details",
"drag_and_drop": "Drag and drop files here or click to select files",
"edit": {
"edit_attachment_dialog": {
"title": "Attachment Edit",
"attachment_title": "Attachment Title",
"attachment_type": "Attachment Type",
"select_type": "Select a type",
"primary_photo": "Primary Photo",
"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."
}
},
"edit_details": "Edit Details",
"field_selector": "Field Selector",
"field_value": "Field Value",
"first": "First",
"include_archive": "Include Archived Items",
"invalid_asset_id": "Invalid Asset ID",
"insured": "Insured",
"last": "Last",
"lifetime_warranty": "Lifetime Warranty",
@@ -210,6 +295,7 @@
"name": "Name",
"negate_labels": "Negate Selected Labels",
"next_page": "Next Page",
"no_attachments": "No attachments found",
"no_results": "No Items Found",
"notes": "Notes",
"only_with_photo": "Only items with photo",
@@ -231,24 +317,60 @@
"receipts": "Receipts",
"reset_search": "Reset Search",
"results": "{ total } Results",
"select_field": "Select a field",
"serial_number": "Serial Number",
"show_advanced_view_options": "Show Advanced View Options",
"sold_at": "Sold At",
"sold_details": "Sold Details",
"sold_price": "Sold Price",
"sold_to": "Sold To",
"sync_child_locations": "Sync child items' locations",
"tip_1": "Location and label filters use the 'OR' operation. If more than one is selected only one will be\n required for a match.",
"tip_2": "Searches prefixed with '#'' will query for a asset ID (example '#000-001')",
"tip_3": "Field filters use the 'OR' operation. If more than one is selected only one will be required for a\n match.",
"tips": "Tips",
"tips_sub": "Search Tips",
"toast": {
"asset_not_found": "Asset not found",
"attachment_deleted": "Attachment deleted",
"attachment_updated": "Attachment updated",
"attachment_uploaded": "Attachment uploaded",
"child_location_desync": "Changing location will de-sync it from the parent's location",
"child_items_location_no_longer_synced": "Child items' locations will no longer be synced with this item.",
"child_items_location_synced": "Child items' locations have been synced with this item",
"error_loading_parent_data": "Something went wrong trying to load parent data",
"failed_adjust_quantity": "Failed to adjust quantity",
"failed_delete_attachment": "Failed to delete attachment",
"failed_delete_item": "Failed to delete item",
"failed_duplicate_item": "Failed to duplicate item",
"failed_load_asset": "Failed to load asset",
"failed_load_item": "Failed to load item",
"failed_load_items": "Failed to load items",
"failed_save": "Failed to save item",
"failed_save_no_location": "Failed to save item: no location selected",
"failed_search_items": "Failed to search items",
"failed_update_attachment": "Failed to update attachment",
"failed_upload_attachment": "Failed to upload attachment",
"item_saved": "Item saved",
"item_deleted": "Item deleted",
"quantity_cannot_negative": "Quantity cannot be negative",
"sync_child_location": "Selected parent syncs its children's locations to its own. The location has been updated."
},
"updated_at": "Updated At",
"warranty": "Warranty",
"warranty_details": "Warranty Details",
"warranty_expires": "Warranty Expires"
},
"labels": {
"label_delete_confirm": "Are you sure you want to delete this label? This action cannot be undone.",
"no_results": "No Labels Found",
"toast": {
"failed_delete_label": "Failed to delete label",
"failed_load_label": "Failed to load label",
"failed_update_label": "Failed to update label",
"label_deleted": "Label deleted",
"label_updated": "Label updated"
},
"update_label": "Update Label"
},
"languages": {
@@ -288,10 +410,18 @@
"zh-TW": "Chinese (Traditional)"
},
"locations": {
"location_items_delete_confirm": "Are you sure you want to delete this location and all of its items? This action cannot be undone.",
"child_locations": "Child Locations",
"collapse_tree": "Collapse Tree",
"expand_tree": "Expand Tree",
"no_results": "No Locations Found",
"toast": {
"failed_delete_location": "Failed to delete location",
"failed_load_location": "Failed to load location",
"failed_update_location": "Failed to update location",
"location_deleted": "Location deleted",
"location_updated": "Location updated"
},
"update_location": "Update Location"
},
"maintenance": {
@@ -347,7 +477,9 @@
"currency_format": "Currency Format",
"current_password": "Current Password",
"delete_account": "Delete Account",
"delete_account_confirm": "Are you sure you want to delete your account? If you are the last member in your group all your data will be deleted. This action cannot be undone.",
"delete_account_sub": "Delete your account and all its associated data. This can not be undone.",
"delete_notifier_confirm": "Are you sure you want to delete this notifier?",
"display_legacy_header": "{ currentValue, select, true {Disable Legacy Header} false {Enable Legacy Header} other {Not Hit}}",
"enabled": "Enabled",
"example": "Example",
@@ -366,35 +498,83 @@
"test": "Test",
"theme_settings": "Theme Settings",
"theme_settings_sub": "Theme settings are stored in your browser's local storage. You can change the theme at any time. If you're\n having trouble setting your theme try refreshing your browser.",
"toast": {
"account_deleted": "Your account has been deleted.",
"failed_change_password": "Failed to change password.",
"failed_create_notifier": "Failed to create notifier.",
"failed_delete_account": "Failed to delete your account.",
"failed_delete_notifier": "Failed to delete notifier.",
"failed_get_currencies": "Failed to get currencies",
"failed_test_notifier": "Failed to test notifier.",
"failed_update_group": "Failed to update group",
"failed_update_notifier": "Failed to update notifier.",
"group_updated": "Group updated",
"notifier_test_success": "Notifier test successful.",
"password_changed": "Password changed successfully."
},
"update_group": "Update Group",
"update_language": "Update Language",
"url": "URL",
"user_profile": "User Profile",
"user_profile_sub": "Invite users, and manage your account."
},
"reports": {
"label_generator": {
"asset_end": "Asset End",
"asset_start": "Asset Start",
"base_url": "Base URL",
"bordered_labels": "Bordered Labels",
"generate_page": "Generate Page",
"input_placeholder": "Type here",
"instruction_1": "The Homebox Label Generator is a tool to help you print labels for your Homebox inventory. These are intended to\n be print-ahead labels so you can print many labels and have them ready to apply",
"instruction_2": "As such, these labels work by printing a URL QR Code and AssetID information on a label. If you've disabled\n AssetID's in your Homebox settings, you can still use this tool, but the AssetID's won't reference any item",
"instruction_3": "This feature is in early development stages and may change in future releases, if you have feedback please\n provide it in the '<a href=\"https://github.com/sysadminsmedia/homebox/discussions/53\">'GitHub Discussion'</a>'",
"label_height": "Label Height",
"label_width": "Label Width",
"measure_type": "Measure Type",
"page_bottom_padding": "Page Bottom Padding",
"page_height": "Page Height",
"page_left_padding": "Page Left Padding",
"page_right_padding": "Page Right Padding",
"page_top_padding": "Page Top Padding",
"page_width": "Page Width",
"qr_code_example": "QR Code Example",
"title": "Label Generator",
"tip_1": "The defaults here are setup for the\n '<a href=\"https://www.avery.com/templates/5260\">'Avery 5260 label sheets'</a>'. If you're using a different sheet,\n you'll need to adjust the settings to match your sheet.",
"tip_2": "If you're customizing your sheet the dimensions are in inches. When building the 5260 sheet, I found that the\n dimensions used in their template, did not match what was needed to print within the boxes.\n '<b>'Be prepared for some trial and error.'</b>'",
"tip_3": "When printing be sure to:\n '<ol><li>'Set the margins to 0 or None'</li><li>'Set the scaling to 100%'</li><li>'Disable double-sided printing'</li><li>'Print a test page before printing multiple pages'</li></ol>'",
"tips": "Tips",
"toast": {
"page_too_small_card": "Page size is too small for the card size"
}
}
},
"scanner": {
"error": "An error occurred while scanning",
"invalid_url": "Invalid barcode URL",
"no_sources": "No video sources available",
"permission_denied": "Camera permission denied, please allow access to the camera in your browser settings",
"select_video_source": "Pick a video source",
"title": "Scanner",
"unsupported": "Media Stream API is not supported without HTTPS",
"permission_denied": "Camera permission denied, please allow access to the camera in your browser settings"
"unsupported": "Media Stream API is not supported without HTTPS"
},
"tools": {
"actions": "Inventory Actions",
"actions_set": {
"ensure_ids": "Ensure Asset IDs",
"ensure_ids_button": "Ensure Asset IDs",
"ensure_ids_confirm": "Are you sure you want to ensure all assets have an ID? This can take a while and cannot be undone.",
"ensure_ids_sub": "Ensures that all items in your inventory have a valid asset_id field. This is done by finding the highest current asset_id field in the database and applying the next value to each item that has an unset asset_id field. This is done in order of the created_at field.",
"ensure_import_refs": "Ensure Import Refs",
"ensure_import_refs_button": "Ensure Import Refs",
"ensure_import_refs_sub": "Ensures that all items in your inventory have a valid import_ref field. This is done by randomly generating a 8 character string for each item that has an unset import_ref field.",
"set_primary_photo": "Set Primary Photo",
"set_primary_photo_button": "Set Primary Photo",
"set_primary_photo_confirm": "Are you sure you want to set primary photos? This can take a while and cannot be undone.",
"set_primary_photo_sub": "In version v0.10.0 of Homebox, the primary image field was added to attachments of type photo. This action will set the primary image field to the first image in the attachments array in the database, if it is not already set. '<a class=\"link\" href=\"https://github.com/hay-kot/homebox/pull/576\">'See GitHub PR #576'</a>'",
"zero_datetimes": "Zero Item Date Times",
"zero_datetimes_button": "Zero Item Date Times",
"zero_datetimes_confirm": "Are you sure you want to reset all date and time values? This can take a while and cannot be undone.",
"zero_datetimes_sub": "Resets the time value for all date time fields in your inventory to the beginning of the date. This is to fix a bug that was introduced early on in the development of the site that caused the time value to be stored with the time which caused issues with date fields displaying accurate values. '<a class=\"link\" href=\"https://github.com/hay-kot/homebox/issues/236\" target=\"_blank\">'See Github Issue #236 for more details.'</a>'"
},
"actions_sub": "Apply Actions to your inventory in bulk. These are irreversible actions. '<b>'Be careful.'</b>'",
@@ -405,6 +585,7 @@
"export_sub": "Exports the standard CSV format for Homebox. This will export all items in your inventory.",
"import": "Import Inventory",
"import_button": "Import Inventory",
"import_ref_confirm": "Are you sure you want to ensure all assets have an import_ref? This can take a while and cannot be undone.",
"import_sub": "Imports the standard CSV format for Homebox. Without an '<code>'HB.import_ref'</code>' column, this will '<b>'not'</b>' overwrite any existing items in your inventory, only add new items. Rows with an '<code>'HB.import_ref'</code>' column are merged into existing items with the same import_ref, if one exists."
},
"import_export_sub": "Import and export your inventory to and from a CSV file. This is useful for migrating your inventory to a new instance of Homebox.",
@@ -417,6 +598,13 @@
"bill_of_materials_button": "Generate BOM",
"bill_of_materials_sub": "Generates a CSV (Comma Separated Values) file that can be imported into a spreadsheet program. This is a summary of your inventory with basic item and pricing information."
},
"reports_sub": "Generate different reports for your inventory."
"reports_sub": "Generate different reports for your inventory.",
"toast": {
"asset_success": "{ results } assets have been updated.",
"failed_ensure_ids": "Failed to ensure asset IDs.",
"failed_ensure_import_refs": "Failed to ensure import refs.",
"failed_set_primary_photos": "Failed to set primary photos.",
"failed_zero_datetimes": "Failed to reset date and time values."
}
}
}

View File

@@ -1,6 +1,9 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { toast } from "@/components/ui/sonner";
const { t } = useI18n();
definePageMeta({
middleware: ["auth"],
});
@@ -13,13 +16,13 @@
const { pending, data: items } = useLazyAsyncData(`asset/${assetId.value}`, async () => {
const { data, error } = await api.assets.get(assetId.value);
if (error) {
toast.error("Failed to load asset");
toast.error(t("items.toast.failed_to_load_asset"));
navigateTo("/home");
return;
}
switch (data.total) {
case 0:
toast.error("Asset not found");
toast.error(t("items.toast.asset_not_found"));
navigateTo("/home");
break;
case 1:
@@ -34,7 +37,7 @@
<template>
<BaseContainer>
<section v-if="!pending">
<BaseSectionHeader class="mb-5"> This Asset Id is associated with multiple items</BaseSectionHeader>
<BaseSectionHeader class="mb-5"> {{ $t("items.associated_with_multiple") }} </BaseSectionHeader>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<ItemCard v-for="item in items" :key="item.id" :item="item" />
</div>

View File

@@ -1,14 +1,17 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { statCardData } from "./statistics";
import { itemsTable } from "./table";
import { useLabelStore } from "~~/stores/labels";
import { useLocationStore } from "~~/stores/locations";
const { t } = useI18n();
definePageMeta({
middleware: ["auth"],
});
useHead({
title: "Homebox | Home",
title: "HomeBox | " + t("menu.home"),
});
const api = useUserApi();

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { toast } from "@/components/ui/sonner";
import MdiGithub from "~icons/mdi/github";
import MdiDiscord from "~icons/mdi/discord";
@@ -14,8 +15,10 @@
import LanguageSelector from "~/components/App/LanguageSelector.vue";
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
const { t } = useI18n();
useHead({
title: "Homebox | Organize and Tag Your Stuff",
title: "HomeBox | " + t("index.title"),
});
definePageMeta({
@@ -85,7 +88,7 @@
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email.value)) {
toast.error("Invalid email address");
toast.error(t("index.toast.invalid_email"));
loading.value = false;
return;
}
@@ -98,7 +101,7 @@
});
if (error) {
toast.error("Problem registering user", {
toast.error(t("index.toast.problem_registering"), {
classes: {
title: "login-error",
},
@@ -106,7 +109,7 @@
return;
}
toast.success("User registered");
toast.success(t("index.toast.user_registered"));
loading.value = false;
registerForm.value = false;
@@ -127,7 +130,7 @@
const { error } = await ctx.login(api, email.value, loginPassword.value, remember.value);
if (error) {
toast.error("Invalid email or password", {
toast.error(t("index.toast.invalid_email_password"), {
classes: {
title: "login-error",
},
@@ -136,7 +139,7 @@
return;
}
toast.success("Logged in successfully");
toast.success(t("index.toast.login_success"));
navigateTo(redirectTo.value || "/home");
redirectTo.value = null;
@@ -260,7 +263,7 @@
</CardHeader>
<CardContent class="flex flex-col gap-2">
<template v-if="status && status.demo">
<p class="text-center text-xs italic">This is a demo instance</p>
<p class="text-center text-xs italic">{{ $t("global.demo_instance") }}</p>
<p class="text-center text-xs">
<b>{{ $t("global.email") }}</b> demo@example.com
</p>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { toast } from "@/components/ui/sonner";
import type { AnyDetail, Detail, Details } from "~~/components/global/DetailsSection/types";
import { filterZeroValues } from "~~/components/global/DetailsSection/types";
@@ -25,6 +26,8 @@
import { Switch } from "@/components/ui/switch";
import { Card } from "@/components/ui/card";
const { t } = useI18n();
const { openDialog, closeDialog } = useDialog();
definePageMeta({
@@ -45,7 +48,7 @@
const { data: item, refresh } = useAsyncData(itemId.value, async () => {
const { data, error } = await api.items.get(itemId.value);
if (error) {
toast.error("Failed to load item");
toast.error(t("items.toast.failed_load_item"));
navigateTo("/home");
return;
}
@@ -71,7 +74,7 @@
const newQuantity = item.value.quantity + amount;
if (newQuantity < 0) {
toast.error("Quantity cannot be negative");
toast.error(t("items.toast.quantity_cannot_negative"));
return;
}
@@ -81,7 +84,7 @@
});
if (resp.error) {
toast.error("Failed to adjust quantity");
toast.error(t("items.toast.failed_adjust_quantity"));
return;
}
@@ -432,7 +435,7 @@
const resp = await api.items.fullpath(item.value.id);
if (resp.error) {
toast.error("Failed to load item");
toast.error(t("items.toast.failed_load_item"));
return [];
}
@@ -449,7 +452,7 @@
});
if (resp.error) {
toast.error("Failed to load items");
toast.error(t("items.toast.failed_load_items"));
return [];
}
@@ -471,7 +474,7 @@
});
if (error) {
toast.error("Failed to duplicate item");
toast.error(t("items.toast.failed_duplicate_item"));
return;
}
@@ -486,7 +489,7 @@
});
if (updateError) {
toast.error("Failed to duplicate item");
toast.error(t("items.toast.failed_duplicate_item"));
return;
}
@@ -496,7 +499,7 @@
const confirm = useConfirm();
async function deleteItem() {
const confirmed = await confirm.open("Are you sure you want to delete this item?");
const confirmed = await confirm.open(t("items.delete_item_confirm"));
if (!confirmed.data) {
return;
@@ -504,10 +507,10 @@
const { error } = await api.items.delete(itemId.value);
if (error) {
toast.error("Failed to delete item");
toast.error(t("items.toast.failed_delete_item"));
return;
}
toast.success("Item deleted");
toast.success(t("items.toast.item_deleted"));
navigateTo("/home");
}
@@ -714,7 +717,7 @@
</template>
</DetailsSection>
<div v-else>
<p class="px-6 pb-4 text-foreground/70">No attachments found</p>
<p class="px-6 pb-4 text-foreground/70">{{ $t("items.no_attachments") }}</p>
</div>
</BaseCard>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { toast } from "@/components/ui/sonner";
import type { ItemAttachment, ItemField, ItemOut, ItemUpdate } from "~~/lib/api/types/data-contracts";
import { AttachmentTypes } from "~~/lib/api/types/non-generated";
@@ -16,6 +17,8 @@
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
const { t } = useI18n();
const { openDialog, closeDialog } = useDialog();
definePageMeta({
@@ -41,7 +44,7 @@
} = useAsyncData(async () => {
const { data, error } = await api.items.get(itemId.value);
if (error) {
toast.error("Failed to load item");
toast.error(t("items.toast.failed_load_item"));
navigateTo("/home");
return;
}
@@ -80,7 +83,7 @@
async function saveItem() {
if (!item.value.location?.id) {
toast.error("Failed to save item: no location selected");
toast.error(t("items.toast.failed_save_no_location"));
return;
}
@@ -110,11 +113,11 @@
const { error } = await api.items.update(itemId.value, payload);
if (error) {
toast.error("Failed to save item");
toast.error(t("items.toast.failed_save"));
return;
}
toast.success("Item saved");
toast.success(t("items.toast.item_saved"));
navigateTo("/item/" + itemId.value);
}
type NoUndefinedField<T> = { [P in keyof T]-?: NoUndefinedField<NonNullable<T[P]>> };
@@ -323,11 +326,11 @@
const { data, error } = await api.items.attachments.add(itemId.value, files[0], files[0].name, type);
if (error) {
toast.error("Failed to upload attachment");
toast.error(t("items.toast.failed_upload_attachment"));
return;
}
toast.success("Attachment uploaded");
toast.success(t("items.toast.attachment_uploaded"));
item.value.attachments = data.attachments;
}
@@ -335,7 +338,7 @@
const confirm = useConfirm();
async function deleteAttachment(attachmentId: string) {
const confirmed = await confirm.open("Are you sure you want to delete this attachment?");
const confirmed = await confirm.open(t("items.delete_attachment_confirm"));
if (confirmed.isCanceled) {
return;
@@ -344,11 +347,11 @@
const { error } = await api.items.attachments.delete(itemId.value, attachmentId);
if (error) {
toast.error("Failed to delete attachment");
toast.error(t("items.toast.failed_delete_attachment"));
return;
}
toast.success("Attachment deleted");
toast.success(t("items.toast.attachment_deleted"));
item.value.attachments = item.value.attachments.filter(a => a.id !== attachmentId);
}
@@ -387,7 +390,7 @@
});
if (error) {
toast.error("Failed to update attachment");
toast.error(t("items.toast.failed_delete_attachment"));
return;
}
@@ -400,7 +403,7 @@
editState.title = "";
editState.type = "";
toast.success("Attachment updated");
toast.success(t("items.toast.attachment_updated"));
}
function addField() {
@@ -437,12 +440,12 @@
const { data, error } = await api.items.get(parent.value.id);
if (error) {
toast.error("Something went wrong trying to load parent data");
toast.error(t("items.toast.error_loading_parent_data"));
return;
}
if (data.syncChildItemsLocations) {
toast.info("Selected parent syncs its children's locations to its own. The location has been updated.");
toast.info(t("items.toast.sync_child_location"));
item.value.location = data.location;
}
}
@@ -453,19 +456,19 @@
const { data, error } = await api.items.get(parent.value.id);
if (error) {
toast.error("Something went wrong trying to load parent data");
toast.error(t("items.toast.error_loading_parent_data"));
return;
}
if (data.syncChildItemsLocations) {
toast.info("Changing location will de-sync it from the parent's location");
toast.info(t("items.toast.child_location_desync"));
}
}
}
async function syncChildItemsLocations() {
if (!item.value.location?.id) {
toast.error("Failed to save item: no location selected");
toast.error(t("items.toast.failed_save_no_location"));
return;
}
@@ -485,9 +488,9 @@
}
if (!item.value.syncChildItemsLocations) {
toast.success("Child items' locations will no longer be synced with this item.");
toast.success(t("items.toast.child_items_location_no_longer_synced"));
} else {
toast.success("Child items' locations have been synced with this item");
toast.success(t("items.toast.child_items_location_synced"));
}
}
@@ -505,15 +508,15 @@
<Dialog dialog-id="attachment-edit">
<DialogContent>
<DialogHeader>
<DialogTitle>Attachment Edit</DialogTitle>
<DialogTitle>{{ $t("items.edit.edit_attachment_dialog.title") }}</DialogTitle>
</DialogHeader>
<FormTextField v-model="editState.title" label="Attachment Title" />
<FormTextField v-model="editState.title" :label="$t('items.edit.edit_attachment_dialog.attachment_title')" />
<div>
<Label for="attachment-type"> Attachment Type </Label>
<Label for="attachment-type"> {{ $t("items.edit.edit_attachment_dialog.attachment_type") }} </Label>
<Select id="attachment-type" v-model:model-value="editState.type">
<SelectTrigger>
<SelectValue placeholder="Select a type" />
<SelectValue :placeholder="$t('items.edit.edit_attachment_dialog.select_type')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="opt in attachmentOpts" :key="opt.value" :value="opt.value">
@@ -523,16 +526,19 @@
</Select>
</div>
<div v-if="editState.type == 'photo'" class="mt-3 flex items-center gap-2">
<Checkbox id="primary" v-model="editState.primary" label="Primary Photo" />
<Checkbox
id="primary"
v-model="editState.primary"
:label="$t('items.edit.edit_attachment_dialog.primary_photo')"
/>
<label class="cursor-pointer text-sm" for="primary">
<span class="font-semibold">Primary Photo</span>
This options 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.
<span class="font-semibold">{{ $t("items.edit.edit_attachment_dialog.primary_photo") }}</span>
{{ $t("items.edit.edit_attachment_dialog.primary_photo_sub") }}
</label>
</div>
<DialogFooter>
<Button :loading="editState.loading" @click="updateAttachment"> Update </Button>
<Button :loading="editState.loading" @click="updateAttachment"> {{ $t("global.update") }} </Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -570,7 +576,7 @@
@update:model-value="maybeSyncWithParentLocation()"
/>
<div class="flex flex-col gap-2">
<Label class="px-1">Sync child items' locations</Label>
<Label class="px-1">{{ $t("items.sync_child_locations") }}</Label>
<Switch v-model="item.syncChildItemsLocations" @update:model-value="syncChildItemsLocations()" />
</div>
<LabelSelector v-model="item.labelIds" :labels="labels" />
@@ -803,7 +809,7 @@
<Card v-if="preferences.editorAdvancedView" class="overflow-visible shadow-xl">
<div class="px-4 py-5 sm:px-6">
<h3 class="text-lg font-medium leading-6">Sold Details</h3>
<h3 class="text-lg font-medium leading-6">{{ $t("items.sold_details") }}</h3>
</div>
<div class="border-t sm:p-0">
<div v-for="field in soldFields" :key="field.ref" class="grid grid-cols-1 sm:divide-y">

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { toast } from "@/components/ui/sonner";
import { Input } from "~/components/ui/input";
import type { ItemSummary, LabelSummary, LocationOutCount } from "~~/lib/api/types/data-contracts";
@@ -23,12 +24,14 @@
PaginationListItem,
} from "@/components/ui/pagination";
const { t } = useI18n();
definePageMeta({
middleware: ["auth"],
});
useHead({
title: "Homebox | Items",
title: "HomeBox | " + t("global.items"),
});
const searchLocked = ref(false);
@@ -145,7 +148,7 @@
} else {
const [aid, valid] = parseAssetIDString(query.value.replace("#", ""));
if (!valid) {
return "Invalid Asset ID";
return t("items.invalid_asset_id");
} else {
return aid;
}
@@ -285,7 +288,7 @@
if (error) {
resetItems();
toast.error("Failed to search items");
toast.error(t("items.toast.failed_search_items"));
return;
}
@@ -465,7 +468,7 @@
<Label> Field </Label>
<Select v-model="fieldTuples[idx][0]" @update:model-value="fetchValues(f[0])">
<SelectTrigger>
<SelectValue placeholder="Select a field" />
<SelectValue :placeholder="$t('items.select_field')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="field in allFields" :key="field" :value="field"> {{ field }} </SelectItem>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { toast } from "@/components/ui/sonner";
import MdiPackageVariant from "~icons/mdi/package-variant";
import MdiPencil from "~icons/mdi/pencil";
@@ -14,6 +15,8 @@
middleware: ["auth"],
});
const { t } = useI18n();
const { openDialog, closeDialog } = useDialog();
const route = useRoute();
@@ -24,7 +27,7 @@
const { data: label } = useAsyncData(labelId.value, async () => {
const { data, error } = await api.labels.get(labelId.value);
if (error) {
toast.error("Failed to load label");
toast.error(t("labels.toast.failed_load_label"));
navigateTo("/home");
return;
}
@@ -34,9 +37,7 @@
const confirm = useConfirm();
async function confirmDelete() {
const { isCanceled } = await confirm.open(
"Are you sure you want to delete this label? This action cannot be undone."
);
const { isCanceled } = await confirm.open(t("labels.label_delete_confirm"));
if (isCanceled) {
return;
@@ -45,10 +46,10 @@
const { error } = await api.labels.delete(labelId.value);
if (error) {
toast.error("Failed to delete label");
toast.error(t("labels.toast.failed_delete_label"));
return;
}
toast.success("Label deleted");
toast.success(t("labels.toast.label_deleted"));
navigateTo("/home");
}
@@ -72,11 +73,11 @@
if (error) {
updating.value = false;
toast.error("Failed to update label");
toast.error(t("labels.toast.failed_update_label"));
return;
}
toast.success("Label updated");
toast.success(t("labels.toast.label_updated"));
label.value = data;
closeDialog("update-label");
updating.value = false;
@@ -95,7 +96,7 @@
});
if (resp.error) {
toast.error("Failed to load items");
toast.error(t("items.toast.failed_load_items"));
return {
items: [],
totalPrice: null,

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { toast } from "@/components/ui/sonner";
import type { LocationSummary, LocationUpdate } from "~~/lib/api/types/data-contracts";
import { useLocationStore } from "~~/stores/locations";
@@ -23,6 +24,8 @@
middleware: ["auth"],
});
const { t } = useI18n();
const { openDialog, closeDialog } = useDialog();
const route = useRoute();
@@ -33,7 +36,7 @@
const { data: location } = useAsyncData(locationId.value, async () => {
const { data, error } = await api.locations.get(locationId.value);
if (error) {
toast.error("Failed to load location");
toast.error(t("locations.toast.failed_load_location"));
navigateTo("/home");
return;
}
@@ -52,20 +55,18 @@
const confirm = useConfirm();
async function confirmDelete() {
const { isCanceled } = await confirm.open(
"Are you sure you want to delete this location and all of its items? This action cannot be undone."
);
const { isCanceled } = await confirm.open(t("locations.location_items_delete_confirm"));
if (isCanceled) {
return;
}
const { error } = await api.locations.delete(locationId.value);
if (error) {
toast.error("Failed to delete location");
toast.error(t("locations.toast.failed_delete_location"));
return;
}
toast.success("Location deleted");
toast.success(t("locations.toast.location_deleted"));
navigateTo("/locations");
}
@@ -90,11 +91,11 @@
if (error) {
updating.value = false;
toast.error("Failed to update location");
toast.error(t("locations.toast.failed_update_location"));
return;
}
toast.success("Location updated");
toast.success(t("locations.toast.location_updated"));
location.value = data;
closeDialog("update-location");
updating.value = false;
@@ -115,7 +116,7 @@
});
if (resp.error) {
toast.error("Failed to load items");
toast.error(t("items.toast.failed_load_items"));
return [];
}

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { useTreeState } from "~~/components/Location/Tree/tree-state";
import MdiCollapseAllOutline from "~icons/mdi/collapse-all-outline";
import MdiExpandAllOutline from "~icons/mdi/expand-all-outline";
@@ -7,6 +8,8 @@
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import type { TreeItem } from "~/lib/api/types/data-contracts";
const { t } = useI18n();
// TODO: eventually move to https://reka-ui.com/docs/components/tree#draggable-sortable-tree
definePageMeta({
@@ -14,7 +17,7 @@
});
useHead({
title: "Homebox | Items",
title: "HomeBox | " + t("menu.locations"),
});
const api = useUserApi();

View File

@@ -1,9 +1,13 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
const { t } = useI18n();
definePageMeta({
middleware: ["auth"],
});
useHead({
title: "Homebox | Maintenance",
title: "HomeBox | " + t("menu.maintenance"),
});
</script>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { toast } from "@/components/ui/sonner";
import type { Detail } from "~~/components/global/DetailsSection/types";
import { themes } from "~~/lib/data/themes";
@@ -19,11 +20,13 @@
import LanguageSelector from "~/components/App/LanguageSelector.vue";
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
const { t } = useI18n();
definePageMeta({
middleware: ["auth"],
});
useHead({
title: "Homebox | Profile",
title: "HomeBox | " + t("menu.profile"),
});
const api = useUserApi();
@@ -34,7 +37,7 @@
const currencies = computedAsync(async () => {
const resp = await api.group.currencies();
if (resp.error) {
toast.error("Failed to get currencies");
toast.error(t("profile.toast.failed_get_currencies"));
return [];
}
@@ -92,12 +95,12 @@
});
if (error) {
toast.error("Failed to update group");
toast.error(t("profile.toast.failed_update_group"));
return;
}
group.value = data;
toast.success("Group updated");
toast.success(t("profile.toast.group_updated"));
}
const { setTheme } = useTheme();
@@ -109,19 +112,17 @@
return [
{
name: "global.name",
text: auth.user?.name || "Unknown",
text: auth.user?.name || t("global.unknown"),
},
{
name: "global.email",
text: auth.user?.email || "Unknown",
text: auth.user?.email || t("global.unknown"),
},
] as Detail[];
});
async function deleteProfile() {
const result = await confirm.open(
"Are you sure you want to delete your account? If you are the last member in your group all your data will be deleted. This action cannot be undone."
);
const result = await confirm.open(t("profile.delete_account_confirm"));
if (result.isCanceled) {
return;
@@ -130,12 +131,12 @@
const { response } = await api.user.delete();
if (response?.status === 204) {
toast.success("Your account has been deleted.");
toast.success(t("profile.toast.account_deleted"));
auth.logout(api);
navigateTo("/");
}
toast.error("Failed to delete your account.");
toast.error(t("profile.toast.failed_delete_account"));
}
const token = ref("");
@@ -176,12 +177,12 @@
const { error } = await api.user.changePassword(passwordChange.current, passwordChange.new);
if (error) {
toast.error("Failed to change password.");
toast.error(t("profile.toast.failed_change_password"));
passwordChange.loading = false;
return;
}
toast.success("Password changed successfully.");
toast.success(t("profile.toast.password_changed"));
closeDialog("change-password");
passwordChange.new = "";
passwordChange.current = "";
@@ -236,7 +237,7 @@
});
if (result.error) {
toast.error("Failed to create notifier.");
toast.error(t("profile.toast.failed_create_notifier"));
}
notifier.value = null;
@@ -257,7 +258,7 @@
});
if (result.error) {
toast.error("Failed to update notifier.");
toast.error(t("profile.toast.failed_update_notifier"));
}
notifier.value = null;
@@ -268,7 +269,7 @@
}
async function deleteNotifier(id: string) {
const result = await confirm.open("Are you sure you want to delete this notifier?");
const result = await confirm.open(t("profile.delete_notifier_confirm"));
if (result.isCanceled) {
return;
@@ -277,7 +278,7 @@
const { error } = await api.notifiers.delete(id);
if (error) {
toast.error("Failed to delete notifier.");
toast.error(t("profile.toast.failed_delete_notifier"));
return;
}
@@ -292,11 +293,11 @@
const { error } = await api.notifiers.test(notifier.value.url);
if (error) {
toast.error("Failed to test notifier.");
toast.error(t("profile.toast.failed_test_notifier"));
return;
}
toast.success("Notifier test successful.");
toast.success(t("profile.toast.notifier_test_success"));
}
</script>
@@ -408,7 +409,7 @@
<MdiDelete />
</Button>
</TooltipTrigger>
<TooltipContent> Delete </TooltipContent>
<TooltipContent> {{ $t("global.delete") }} </TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
@@ -416,7 +417,7 @@
<MdiPencil />
</Button>
</TooltipTrigger>
<TooltipContent> Edit </TooltipContent>
<TooltipContent> {{ $t("global.edit") }} </TooltipContent>
</Tooltip>
</TooltipProvider>
</div>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import DOMPurify from "dompurify";
import { route } from "../../lib/api/base";
import { toast, Toaster } from "@/components/ui/sonner";
import { Separator } from "@/components/ui/separator";
@@ -7,12 +9,14 @@
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
const { t } = useI18n();
definePageMeta({
middleware: ["auth"],
layout: false,
});
useHead({
title: "Homebox | Printer",
title: "HomeBox | " + t("reports.label_generator.title"),
});
const api = useUserApi();
@@ -80,7 +84,7 @@
const availablePageHeight = page.height - page.pageTopPadding - page.pageBottomPadding;
if (availablePageWidth < cardWidth || availablePageHeight < cardHeight) {
toast.error("Page size is too small for the card size");
toast.error(t("reports.label_generator.toast.page_too_small_card"));
return out.value;
}
@@ -119,52 +123,52 @@
const propertyInputs = computed<InputDef[]>(() => {
return [
{
label: "Asset Start",
label: t("reports.label_generator.asset_start"),
ref: "assetRange",
},
{
label: "Asset End",
label: t("reports.label_generator.asset_end"),
ref: "assetRangeMax",
},
{
label: "Measure Type",
label: t("reports.label_generator.measure_type"),
ref: "measure",
type: "text",
},
{
label: "Label Height",
label: t("reports.label_generator.label_height"),
ref: "cardHeight",
},
{
label: "Label Width",
label: t("reports.label_generator.label_width"),
ref: "cardWidth",
},
{
label: "Page Width",
label: t("reports.label_generator.page_width"),
ref: "pageWidth",
},
{
label: "Page Height",
label: t("reports.label_generator.page_height"),
ref: "pageHeight",
},
{
label: "Page Top Padding",
label: t("reports.label_generator.page_top_padding"),
ref: "pageTopPadding",
},
{
label: "Page Bottom Padding",
label: t("reports.label_generator.page_bottom_padding"),
ref: "pageBottomPadding",
},
{
label: "Page Left Padding",
label: t("reports.label_generator.page_left_padding"),
ref: "pageLeftPadding",
},
{
label: "Page Right Padding",
label: t("reports.label_generator.page_right_padding"),
ref: "pageRightPadding",
},
{
label: "Base URL",
label: t("reports.label_generator.base_url"),
ref: "baseURL",
type: "text",
},
@@ -333,44 +337,23 @@
<div class="print:hidden">
<Toaster />
<div class="container prose mx-auto max-w-4xl p-4 pt-6">
<h1>Homebox Label Generator</h1>
<h1>HomeBox {{ $t("reports.label_generator.title") }}</h1>
<p>
The Homebox Label Generator is a tool to help you print labels for your Homebox inventory. These are intended to
be print-ahead labels so you can print many labels and have them ready to apply
{{ $t("reports.label_generator.instruction_1") }}
</p>
<p>
As such, these labels work by printing a URL QR Code and AssetID information on a label. If you've disabled
AssetID's in your Homebox settings, you can still use this tool, but the AssetID's won't reference any item
{{ $t("reports.label_generator.instruction_2") }}
</p>
<p>
This feature is in early development stages and may change in future releases, if you have feedback please
provide it in the <a href="https://github.com/sysadminsmedia/homebox/discussions/53">GitHub Discussion</a>
</p>
<h2>Tips</h2>
<p v-html="DOMPurify.sanitize($t('reports.label_generator.instruction_3'))"></p>
<h2>{{ $t("reports.label_generator.tips") }}</h2>
<ul>
<li>
The defaults here are setup for the
<a href="https://www.avery.com/templates/5260">Avery 5260 label sheets</a>. If you're using a different sheet,
you'll need to adjust the settings to match your sheet.
</li>
<li>
If you're customizing your sheet the dimensions are in inches. When building the 5260 sheet, I found that the
dimensions used in their template, did not match what was needed to print within the boxes.
<b>Be prepared for some trial and error</b>
</li>
<li>
When printing be sure to:
<ol>
<li>Set the margins to 0 or None</li>
<li>Set the scaling to 100%</li>
<li>Disable double-sided printing</li>
<li>Print a test page before printing multiple pages</li>
</ol>
</li>
<li v-html="DOMPurify.sanitize($t('reports.label_generator.tip_1'))"></li>
<li v-html="DOMPurify.sanitize($t('reports.label_generator.tip_2'))"></li>
<li v-html="DOMPurify.sanitize($t('reports.label_generator.tip_3'))"></li>
</ul>
<div class="flex flex-wrap gap-2">
<NuxtLink href="/tools">Tools</NuxtLink>
<NuxtLink href="/home">Home</NuxtLink>
<NuxtLink href="/tools">{{ $t("menu.tools") }}</NuxtLink>
<NuxtLink href="/home">{{ $t("menu.home") }}</NuxtLink>
</div>
</div>
<Separator class="mx-auto max-w-4xl" />
@@ -385,7 +368,7 @@
v-model="displayProperties[prop.ref]"
:type="prop.type ? prop.type : 'number'"
step="0.01"
placeholder="Type here"
:placeholder="$t('reports.label_generator.input_placeholder')"
class="w-full max-w-xs"
/>
</div>
@@ -393,13 +376,17 @@
<div class="max-w-xs">
<div class="flex items-center gap-2 py-4">
<Checkbox id="borderedLabels" v-model="bordered" />
<Label class="cursor-pointer" for="borderedLabels"> Bordered Labels </Label>
<Label class="cursor-pointer" for="borderedLabels">
{{ $t("reports.label_generator.bordered_labels") }}
</Label>
</div>
</div>
<div>
<p>QR Code Example {{ displayProperties.baseURL }}/a/{asset_id}</p>
<Button size="lg" class="my-4 w-full" @click="calcPages"> Generate Page </Button>
<p>{{ $t("reports.label_generator.qr_code_example") }} {{ displayProperties.baseURL }}/a/{asset_id}</p>
<Button size="lg" class="my-4 w-full" @click="calcPages">
{{ $t("reports.label_generator.generate_page") }}
</Button>
</div>
</div>
</div>
@@ -452,7 +439,7 @@
</div>
<div class="ml-2 flex flex-col justify-center">
<div class="font-bold">{{ item.assetID }}</div>
<div class="text-xs font-light italic">Homebox</div>
<div class="text-xs font-light italic">HomeBox</div>
<div class="overflow-hidden text-wrap text-xs">{{ item.name }}</div>
<div class="text-xs">{{ item.location }}</div>
</div>

View File

@@ -92,6 +92,7 @@
<script setup lang="ts">
import DOMPurify from "dompurify";
import { useI18n } from "vue-i18n";
import { toast } from "@/components/ui/sonner";
import MdiFileChart from "~icons/mdi/file-chart";
import MdiArrowRight from "~icons/mdi/arrow-right";
@@ -99,11 +100,13 @@
import MdiAlert from "~icons/mdi/alert";
import { useDialog } from "~/components/ui/dialog-provider";
const { t } = useI18n();
definePageMeta({
middleware: ["auth"],
});
useHead({
title: "Homebox | Tools",
title: "HomeBox | " + t("menu.tools"),
});
const { openDialog } = useDialog();
@@ -122,9 +125,7 @@
}
async function ensureAssetIDs() {
const { isCanceled } = await confirm.open(
"Are you sure you want to ensure all assets have an ID? This can take a while and cannot be undone."
);
const { isCanceled } = await confirm.open(t("tools.actions_set.ensure_ids_confirm"));
if (isCanceled) {
return;
@@ -133,17 +134,15 @@
const result = await api.actions.ensureAssetIDs();
if (result.error) {
toast.error("Failed to ensure asset IDs.");
toast.error(t("tools.toast.failed_ensure_ids"));
return;
}
toast.success(`${result.data.completed} assets have been updated.`);
toast.success(t("tools.toast.asset_success", { results: result.data.completed }));
}
async function ensureImportRefs() {
const { isCanceled } = await confirm.open(
"Are you sure you want to ensure all assets have an import_ref? This can take a while and cannot be undone."
);
const { isCanceled } = await confirm.open(t("tools.import_export_set.import_ref_confirm"));
if (isCanceled) {
return;
@@ -152,17 +151,15 @@
const result = await api.actions.ensureImportRefs();
if (result.error) {
toast.error("Failed to ensure import refs.");
toast.error(t("tools.toast.failed_ensure_import_refs"));
return;
}
toast.success(`${result.data.completed} assets have been updated.`);
toast.success(t("tools.toast.asset_success", { results: result.data.completed }));
}
async function resetItemDateTimes() {
const { isCanceled } = await confirm.open(
"Are you sure you want to reset all date and time values? This can take a while and cannot be undone."
);
const { isCanceled } = await confirm.open(t("tools.actions_set.zero_datetimes_confirm"));
if (isCanceled) {
return;
@@ -171,17 +168,15 @@
const result = await api.actions.resetItemDateTimes();
if (result.error) {
toast.error("Failed to reset date and time values.");
toast.error(t("tools.toast.failed_zero_datetimes"));
return;
}
toast.success(`${result.data.completed} assets have been updated.`);
toast.success(t("tools.toast.asset_success", { results: result.data.completed }));
}
async function setPrimaryPhotos() {
const { isCanceled } = await confirm.open(
"Are you sure you want to set primary photos? This can take a while and cannot be undone."
);
const { isCanceled } = await confirm.open(t("tools.actions_set.set_primary_photo_confirm"));
if (isCanceled) {
return;
@@ -190,11 +185,11 @@
const result = await api.actions.setPrimaryPhotos();
if (result.error) {
toast.error("Failed to set primary photos.");
toast.error(t("tools.toast.failed_set_primary_photos"));
return;
}
toast.success(`${result.data.completed} assets have been updated.`);
toast.success(t("tools.toast.asset_success", { results: result.data.completed }));
}
</script>