diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index e6896f0f..956e9913 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -356,6 +356,7 @@ "boolean_true": "Yes", "boolean_false": "No", "choices_missing_error": "You cannot remove choices. The following choices are missing: {{choices}}", + "choices_reorder_hint": "Drag, or use the arrow keys, to reorder this choice.", "non_unique_key_error": "The key must be unique.", "key_not_changed": "Please change the key to something else.", "delete_confirm": "Delete field {{name}}?", diff --git a/client/src/pages/settings/extraFieldsSettings.tsx b/client/src/pages/settings/extraFieldsSettings.tsx index 76599ea7..61a15b4f 100644 --- a/client/src/pages/settings/extraFieldsSettings.tsx +++ b/client/src/pages/settings/extraFieldsSettings.tsx @@ -1,4 +1,4 @@ -import { PlusOutlined } from "@ant-design/icons"; +import { HolderOutlined, PlusOutlined } from "@ant-design/icons"; import { useTranslate } from "@refinedev/core"; import { Button, @@ -12,6 +12,7 @@ import { Select, Space, Table, + Tag, message, } from "antd"; import { FormItemProps, Rule } from "antd/es/form"; @@ -20,7 +21,9 @@ import dayjs from "dayjs"; import advancedFormat from "dayjs/plugin/advancedFormat"; import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; -import { useState } from "react"; +import { useRef, useState } from "react"; +import { DndProvider, useDrag, useDrop } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; import { Trans } from "react-i18next"; import { useParams } from "react-router"; import { DateTimePicker } from "../../components/dateTimePicker"; @@ -55,6 +58,92 @@ const canEditField = (dataIndex: string, isNew: boolean) => { return dataIndex !== "key" && dataIndex !== "field_type" && dataIndex !== "multi_choice"; }; +const CHOICE_DRAG_TYPE = "extraFieldChoice"; + +interface ChoiceDragItem { + choice: string; +} + +interface DraggableChoiceTagProps { + label: React.ReactNode; + value: string; + closable: boolean; + onClose: (e?: React.MouseEvent) => void; + form: FormInstance; +} + +/** + * A tag in the choices editor that can be dragged, or arrow-keyed, to a new position. + * The order of the choices is the order they're presented in wherever the field is used, + * so it's the choices form value itself that gets reordered. + */ +const DraggableChoiceTag = ({ label, value, closable, onClose, form }: DraggableChoiceTagProps) => { + const t = useTranslate(); + const ref = useRef(null); + + // Choices are unique, so a choice's own value identifies its position. + const moveChoice = (choice: string, before: string) => { + const choices: string[] = [...(form.getFieldValue("choices") || [])]; + const from = choices.indexOf(choice); + const to = choices.indexOf(before); + if (from === -1 || to === -1 || from === to) { + return; + } + choices.splice(from, 1); + choices.splice(to, 0, choice); + form.setFieldValue("choices", choices); + }; + + const moveChoiceBy = (offset: number) => { + const choices: string[] = form.getFieldValue("choices") || []; + const to = choices.indexOf(value) + offset; + if (to < 0 || to >= choices.length) { + return; + } + moveChoice(value, choices[to]); + }; + + const [{ isDragging }, drag] = useDrag({ + type: CHOICE_DRAG_TYPE, + item: { choice: value }, + collect: (monitor) => ({ isDragging: monitor.isDragging() }), + }); + + const [, drop] = useDrop({ + accept: CHOICE_DRAG_TYPE, + hover: (item) => moveChoice(item.choice, value), + }); + + drag(drop(ref)); + + return ( + e.stopPropagation()} + style={{ display: "inline-flex", opacity: isDragging ? 0.4 : 1 }} + > + { + if (e.key === "ArrowLeft" || e.key === "ArrowRight") { + e.preventDefault(); + moveChoiceBy(e.key === "ArrowLeft" ? -1 : 1); + } + }} + style={{ marginInlineEnd: 4, cursor: "grab", userSelect: "none" }} + > + + {label} + + + ); +}; + const EditableCell = ({ record, editing, dataIndex, children, form, ...restProps }: EditableCellProps) => { const t = useTranslate(); @@ -221,7 +310,22 @@ const EditableCell = ({ record, editing, dataIndex, children, form, ...restProps } } else if (dataIndex === "choices") { if (fieldType === FieldType.choice) { - inputNode = ( + + )} + /> + ); rules.push({ required: true, min: 1, @@ -608,18 +712,20 @@ export function ExtraFieldsSettings() { }} />
- + +
+ {newField == null && ( diff --git a/client_v2/locales/en/common.json b/client_v2/locales/en/common.json index 5cbe8dcd..82237d3f 100644 --- a/client_v2/locales/en/common.json +++ b/client_v2/locales/en/common.json @@ -364,6 +364,7 @@ "scanner.starting": "Starting camera...", "scanner.title": "QR Code Scanner", "search.colorHint": "Color names must be entered in English.", + "search.moreSpools": "+{{count}} more", "search.noResults": "No matches found", "search.searching": "Searching...", "search.section.filaments": "Filaments", @@ -417,6 +418,7 @@ "settings.extraFields.params.name": "Name", "settings.extraFields.params.order": "Order", "settings.extraFields.params.unit": "Unit", + "settings.extraFields.reorderChoice": "Drag, or use the arrow keys, to reorder this choice.", "settings.extraFields.saveField": "Save field", "settings.extraFields.tab": "Extra Fields", "settings.general.baseUrl.label": "Base URL", diff --git a/client_v2/locales/ru/common.json b/client_v2/locales/ru/common.json index 3d59de27..13538b63 100644 --- a/client_v2/locales/ru/common.json +++ b/client_v2/locales/ru/common.json @@ -1,17 +1,99 @@ { + "add.addAndNew": "Добавить и новое", + "add.addN_few": "Добавить {{count}} катушки", + "add.addN_many": "Добавить {{count}} катушек", + "add.addN_one": "Добавить {{count}} катушку", + "add.adding": "Добавление...", + "add.advanced": "Расширенные характеристики", + "add.advancedNote": "плотность, диаметр, температуры (заполняются автоматически из материала)", + "add.change": "изменить", + "add.count": "Количество", + "add.createNew": "Создать новый филамент", + "add.createNewSub": "нет в вашем каталоге или {{name}}", + "add.dbUnavailable": "{{name}} недоступен", + "add.duplicate": "клонировать", + "add.duplicateNote": "Характеристики, производитель и цена были скопированы. Задайте ему новое название и цвет.", + "add.duplicateRename": "Название все еще как у оригинала — переименуйте его, чтобы различать их.", + "add.duplicateTitle": "Новый филамент · копия «{{name}}", + "add.filamentNamePlaceholder": "например, PolyTerra Matte Sage", + "add.fill.full": "Полная", + "add.fillLevel": "Уровень заполнения", + "add.importNote": "Этот филамент будет добавлен в ваш каталог при добавлении катушек.", + "add.inCatalog": "в каталоге", + "add.locationHint": "Где физически хранится катушка — полка, коробка или принтер. Оставьте пустым, если они хранятся в одном месте.", + "add.locationPlaceholder": "например: Полка А, Сухой бокс 2, Принтер 1", + "add.manufacturerPlaceholder": "например, Polymaker", + "add.nameHint": "Укажите цвет — именно по нему можно различать филаменты.", + "add.newFilament": "новый филамент", + "add.newFilamentTitle": "Новый филамент", + "add.noCatalog": "В каталоге нет филаментов", + "add.noManufacturer": "Нет производителя", + "add.pickColor": "Выбрать цвет", + "add.searchPlaceholder": "Поиск по каталогу и {{name}}...", + "add.searching": "Поиск...", + "add.spoolWeightPreset.cardboard": "Картонная {{weight}} г", + "add.spoolWeightPreset.plastic": "Пластиковая {{weight}} г", + "add.spoolWeightPresetsLead": "Начните с типичной катушки:", + "add.step2": "Шаг 2 · детали катушки", + "add.summary_few": "{{count}} катушки", + "add.summary_many": "{{count}} катушек", + "add.summary_one": "{{count}} катушка", + "add.typeToSearchAll": "Введите для поиска по тысячам филаментов", + "add.typeToSearchMatches": "Введите для поиска — нет совпадений", + "add.useExisting": "использовать существующий", + "add.vendorHint.existing": "Используется существующий производитель «{{name}}", + "add.vendorHint.new": "Будет создан новый производитель «{{name}}", + "add.vendorHint.optional": "Необязательно — оставьте пустым, если производитель не нужен", + "add.yourCatalog": "Ваш каталог", "buttons.archive": "В архив", "buttons.cancel": "Отмена", "buttons.clear": "Очистить", + "buttons.close": "Закрыть", "buttons.delete": "Удалить", "buttons.edit": "Редактировать", "buttons.filter": "Фильтр", "buttons.save": "Сохранить", "buttons.showArchived": "Показать архивные", "buttons.unArchive": "Из архива", + "common.decrement": "Уменьшить", + "common.increment": "Увеличить", + "common.remove": "Удалить", + "common.search": "Поиск", + "dashboard.addFieldHint": "Добавьте текстовое поле или поле выбора для катушек в Настройках, чтобы группировать и по нему", + "dashboard.dashboard": "Панель", + "dashboard.deleteGroup": "Удалить группу", + "dashboard.dragHint": "Перетащите катушку на другую карточку, чтобы изменить её {{field}}, или внутри карточки для изменения порядка", + "dashboard.dropHere": "перетащите катушки сюда", + "dashboard.emptyHelp": "На этой странице катушки группируются по выбранному вами полю. Добавьте катушки, чтобы начать!", + "dashboard.errorEmptyName": "Имя не может быть пустым", + "dashboard.errorExists": "Группа с таким именем уже существует", + "dashboard.errorRename": "Не удалось переименовать группу", + "dashboard.lastUsed": "Последнее использование {{time}} назад", + "dashboard.newGroup": "Новая группа", + "dashboard.newGroupName": "Новая группа {{n}}", + "dashboard.reorderGroup": "Перетащите для изменения порядка группы", + "dashboard.spoolCount_few": "\"{{count}} катушки", + "dashboard.spoolCount_many": "\"{{count}} катушек", + "dashboard.spoolCount_one": "\"{{count}} катушка", + "dashboard.unassigned": "Не назначено", + "datetime.done": "Готово", + "datetime.hour": "Час", + "datetime.minute": "Минута", + "datetime.nextMonth": "Следующий месяц", + "datetime.now": "Сейчас", + "datetime.pick": "Выберите дату и время", + "datetime.prevMonth": "Предыдущий месяц", + "datetime.time": "Время", + "documentTitle.dashboard.list": "Панель | Spoolman", + "documentTitle.labels.list": "Этикетки | Spoolman", + "documentTitle.library.list": "Библиотека | Spoolman", "documentTitle.locations.list": "Местоположения | Spoolman", + "documentTitle.settings.list": "Настройки | Spoolman", + "filament.fields.addColor": "Добавить цвет", "filament.fields.articleNumber": "Артикул", "filament.fields.coaxial": "Коэкструдированный", "filament.fields.colorHex": "Цвет", + "filament.fields.colorType": "Тип цвета", "filament.fields.comment": "Комментарий", "filament.fields.density": "Плотность", "filament.fields.diameter": "Диаметр", @@ -23,6 +105,7 @@ "filament.fields.name": "Имя", "filament.fields.price": "Цена", "filament.fields.registered": "Добавлен", + "filament.fields.removeColor": "Удалить цвет", "filament.fields.settingsBedTemp": "Температура стола", "filament.fields.settingsExtruderTemp": "Температура экструдера", "filament.fields.singleColor": "Одноцветный", @@ -34,8 +117,157 @@ "filament.fieldsHelp.spoolWeight": "Вес пустой катушки. Используется для определения измеренного веса катушки.", "filament.fieldsHelp.weight": "Вес филамента на полной катушке (вес нетто). Сюда не следует включать вес самой катушки, а только филамента. Это то, что обычно пишут на упаковке.", "filament.filament": "Филаменты", + "footer.documentation": "Документация", + "footer.reportIssue": "Сообщить о проблеме", "help.help": "Помощь", + "inspector.addSpoolsOfThis": "Добавить катушки этого филамента", + "inspector.adjustFailed": "Не удалось изменить вес.", + "inspector.adjustHelp": "Положительное значение расходует филамент, отрицательное — добавляет.", + "inspector.adjustWeight": "Изменить вес", + "inspector.apply": "Применить", + "inspector.applying": "Применение...", + "inspector.colorHex": "Шестнадцатеричный код цвета", + "inspector.consumeAmount": "Количество для списания:", + "inspector.delete.deleting": "Удаление...", + "inspector.delete.errorGone": "Он уже был удален.", + "inspector.delete.errorSpoolsExist": "Невозможно удалить этот филамент — его используют катушки.", + "inspector.delete.errorUnknown": "Не удалось удалить. Подробности см. в логах сервера.", + "inspector.delete.filament": "Удалить филамент", + "inspector.delete.filamentBlockedHint": "Сначала удалите эти катушки. Архивирования недостаточно.", + "inspector.delete.filamentBlockedTitle": "Этот филамент не может быть удален", + "inspector.delete.filamentBlocked_few": "{{count}} катушки все еще использует «{{name}}», включая архивные.", + "inspector.delete.filamentBlocked_many": "{{count}} катушек все еще использует «{{name}}», включая архивные.", + "inspector.delete.filamentBlocked_one": "{{count}} катушка все еще использует «{{name}}», включая архивные.", + "inspector.delete.filamentBody": "«{{name}}» будет удален из вашего каталога. Это действие нельзя отменить.", + "inspector.delete.filamentDone": "Филамент удален.", + "inspector.delete.filamentTitle": "Удалить этот филамент?", + "inspector.delete.spool": "Удалить катушку", + "inspector.delete.spoolBody": "Катушка #{{id}} «{{name}}» будет удалена вместе с историей использования. Это действие нельзя отменить.", + "inspector.delete.spoolDone": "Катушка удалена.", + "inspector.delete.spoolRemaining": "На ней еще осталось {{weight}} филамента. Архивируйте её, если вы просто хотите убрать её с глаз.", + "inspector.delete.spoolTitle": "Удалить эту катушку?", + "inspector.delete.vendor": "Удалить производителя", + "inspector.delete.vendorBody": "«{{name}}» будет удален. Это действие нельзя отменить.", + "inspector.delete.vendorDone": "Производитель удален.", + "inspector.delete.vendorOrphans_few": "Его {{count}} филамента сохранено, но остался без производителя.", + "inspector.delete.vendorOrphans_many": "Его {{count}} филаментов сохранено, но остался без производителя.", + "inspector.delete.vendorOrphans_one": "Его {{count}} филамент сохранен, но остался без производителя.", + "inspector.delete.vendorTitle": "Удалить этого производителя?", + "inspector.duplicateFilament": "Клонировать", + "inspector.empty": "Выберите катушку, филамент или производителя из списка, чтобы просмотреть подробную информацию здесь.", + "inspector.enterValidLength": "Введите корректную длину.", + "inspector.enterValidWeight": "Введите корректный вес.", + "inspector.filamentDefault": "{{price}} · по умолчанию для филамента", + "inspector.loading": "Загрузка...", + "inspector.manageExtraFields": "Управление полями", + "inspector.measuredHelp": "Общий вес катушки + оставшийся филамент по показаниям весов.", + "inspector.multi": "мульти ×{{count}}", + "inspector.newGross": "Новый брутто-вес:", + "inspector.noExtraFields": "Пока нет — добавьте собственные поля для отслеживания того, чего нет в Spoolman.", + "inspector.notFound": "Элемент не найден. Возможно, он был удален.", + "inspector.ofRemaining": "осталось {{weight}} · {{length}} м", + "inspector.openFilament": "Открыть филамент", + "inspector.openLink": "Открыть {{url}} в новой вкладке", + "inspector.openManufacturer": "Открыть производителя", + "inspector.specs": "Характеристики — редактирование на месте", + "inspector.spoolsCount": "Катушки · {{count}}", + "inspector.spoolsTotalRemaining": "всего {{weight}}", + "inspector.used": "использовано", + "inspector.vendorSub_few": "{{count}} филамента", + "inspector.vendorSub_many": "{{count}} филаментов", + "inspector.vendorSub_one": "{{count}} филамент", "kofi": "Поддержите меня на Ko-fi", + "labels.addHead": "Добавить", + "labels.addQr": "QR-код", + "labels.addRect": "Прямоугольник", + "labels.addSwatch": "Образец цвета", + "labels.addText": "Текст / поле", + "labels.align": "Выравнивание", + "labels.alignCenter": "По центру", + "labels.alignLeft": "По левому краю", + "labels.alignRight": "По правому краю", + "labels.blank": "У вас пока нет дизайнов этикеток.", + "labels.bold": "Жирный", + "labels.canvasHint": "Перетащите для перемещения · нажмите для выбора · маркеры изменения размера.", + "labels.centerLogo": "Центрировать логотип Spoolman", + "labels.chooseField": "Выберите поле...", + "labels.clearFill": "Без заливки (только контур)", + "labels.copies": "Копии", + "labels.cornerRadiusMm": "Радиус скругления", + "labels.createFirst": "Создайте свою первую этикетку", + "labels.cutGuides": "Линии реза", + "labels.deleteConfirm": "Удалить этот дизайн этикетки?", + "labels.deleteElement": "Удалить элемент", + "labels.designName": "Название дизайна", + "labels.designerDesc": "\"Создавайте QR-этикетки с любыми полями катушки, филамента или производителя, а затем печатайте их на листах или термопринтере. Дизайны сохраняются на сервере.", + "labels.designerTitle": "Дизайнер этикеток", + "labels.dpi": "Разрешение", + "labels.dpiHint": "Соответствуйте параметрам принтера, чтобы мелкие этикетки были четкими: 203 dpi для большинства термопринтеров, 300 или более для лазерных и струйных.", + "labels.duplicate": "Дублировать", + "labels.fields.bedTemp": "Температура стола", + "labels.fields.diameterMm": "Диаметр (мм)", + "labels.fields.groupVendor": "Производитель", + "labels.fields.initialG": "Начальный (г)", + "labels.fields.netWeightG": "Вес нетто (г)", + "labels.fields.remainingG": "Остаток (г)", + "labels.filamentsSelected_few": "{{count}} филамента выбрано", + "labels.filamentsSelected_many": "{{count}} филаментов выбрано", + "labels.filamentsSelected_one": "{{count}} филамент выбран", + "labels.fill": "Заливка", + "labels.fitWidth": "Подгонять ширину под столбцы", + "labels.fitWidthHint": "Установите ширину этикетки так, чтобы выбранное количество столбцов заполняло страницу.", + "labels.fontMm": "Шрифт", + "labels.gridInfo": "{{cols}} × {{rows}} = {{perPage}} этикеток / страница", + "labels.hMm": "В", + "labels.heightMm": "Высота", + "labels.hintNowrap": "Текст остается на одной строке; всё, что выходит за пределы ширины, обрежется.", + "labels.hintSwatch": "Заполняется цветом(-ами) филамента катушки во время печати.", + "labels.hintWrap": "Оберните поле, чтобы скрыть его, если оно пустое:", + "labels.imageDesc": "Сохраняет по одному PNG на выбранную катушку вместо печати, размер каждого {{w}}×{{h}} мм.", + "labels.imageZipNote": "Более {{threshold}} этикеток доставляются в виде одного ZIP-архива.", + "labels.insertField": "Вставить поле", + "labels.kind.qr": "QR-код", + "labels.kind.rect": "Прямоугольник", + "labels.kind.swatch": "Образец цвета", + "labels.kind.text": "Текст", + "labels.labelSize": "Размер этикетки", + "labels.labelType": "Тип этикетки", + "labels.layout": "Макет", + "labels.modeImage": "Файлы изображений", + "labels.modeLabel": "Принтер этикеток", + "labels.modeSheet": "Лист (плитка)", + "labels.new": "Новый", + "labels.newText": "Новый текст", + "labels.noDesignsOption": "Пока нет дизайнов", + "labels.noFilaments": "Нет филаментов.", + "labels.noSpools": "Нет катушек.", + "labels.onePerLabel": "Одна страница {{w}}×{{h}} мм на этикетку.", + "labels.preparing": "Подготовка...", + "labels.preview": "Предпросмотр", + "labels.previewWith": "Предпросмотр с", + "labels.printN_few": "Печать {{count}} этикетки", + "labels.printN_many": "Печать {{count}} этикеток", + "labels.printN_one": "Печать {{count}} этикетки", + "labels.radiusMm": "Радиус", + "labels.sampleData": "Пример данных", + "labels.saved": "Сохранено", + "labels.saving": "Сохранение...", + "labels.searchFilaments": "Поиск филаментов...", + "labels.searchSpools": "Поиск катушек...", + "labels.selectAllShort": "Все", + "labels.selectAtLeastOne": "Выберите хотя бы одну катушку.", + "labels.selectAtLeastOneFilament": "Выберите хотя бы один филамент.", + "labels.selectDesign": "Выберите дизайн этикетки", + "labels.selectElement": "Выберите элемент для редактирования или добавьте его из палитры.", + "labels.selectNoneShort": "Ничего", + "labels.selectRecentHint": "Выбрать катушки, добавленные за последние 24 часа", + "labels.selectRecentShort": "Недавние ({{count}})", + "labels.showingFilament": "Отображение филамента #{{id}}", + "labels.showingReal": "Отображение реальных данных катушки.", + "labels.showingSpool": "Отображение катушки #{{id}}", + "labels.sortHint": "Сортировать катушки", + "labels.sortId": "#", + "labels.sortNewest": "Самые новые", "loading": "Загрузка", "locations.locations": "Местоположения", "locations.newLocation": "Новое местоположение", @@ -83,8 +315,8 @@ "settings.extraFields.booleanFalse": "Нет", "settings.extraFields.booleanTrue": "Да", "settings.extraFields.deleteConfirm": "Удалить поле {{name}}?", - "settings.extraFields.description.intro": "Здесь вы можете добавлять дополнительные пользовательские поля к своим сущностям.", "settings.extraFields.description.constraints": "Как только поле добавлено, вы не можете изменить его ключ или тип, а для полей типа выбора вы не можете удалить варианты или изменить состояние множественного выбора. Если вы удалите поле, связанные данные для всех сущностей будут удалены.", + "settings.extraFields.description.intro": "Здесь вы можете добавлять дополнительные пользовательские поля к своим сущностям.", "settings.extraFields.description.keyUsage": "Ключевым моментом является то, как другие программы считывают/записывают данные, поэтому, если предполагается, что ваше пользовательское поле интегрируется со сторонней программой, убедитесь, что оно настроено правильно. Значение по умолчанию применяется только к новым элементам.", "settings.extraFields.description.tableViews": "Дополнительные поля нельзя отсортировать или отфильтровать в табличных представлениях.", "settings.extraFields.fieldType.boolean": "Логический", diff --git a/client_v2/src/lib/api/search.ts b/client_v2/src/lib/api/search.ts index 1f0dc165..c0fca0ea 100644 --- a/client_v2/src/lib/api/search.ts +++ b/client_v2/src/lib/api/search.ts @@ -1,5 +1,5 @@ -import type { SearchMatch, SearchResults } from './types'; -import type { Filament, Spool, Vendor } from '$lib/types'; +import type { FilamentSearchMatch, FilamentSpoolHit, SearchMatch, SearchResults } from './types'; +import type { Spool, Vendor } from '$lib/types'; import { getJson } from './http'; import { mapFilament, mapSpool, mapVendor } from './map'; import { inventory } from '$lib/stores/inventory.svelte'; @@ -8,6 +8,11 @@ import { inventory } from '$lib/stores/inventory.svelte'; // entity it fetches is upserted into the reactive cache so that clicking a result // (which opens its inspector) has the data ready immediately. +// Spools to request per matching filament, shown as shortcut pills under the +// filament (issue #993: searching a filament name should lead to a spool). Kept +// small: the panel only has room for a few, and every keystroke re-queries. +const SPOOLS_PER_FILAMENT = 5; + /* eslint-disable @typescript-eslint/no-explicit-any */ type Json = Record; @@ -24,12 +29,29 @@ function mapSpoolHit(m: Json): SearchMatch { return { entity: spool, matchField: m.match_field }; } -function mapFilamentHit(m: Json): SearchMatch { +function mapFilamentSpool(s: Json): FilamentSpoolHit { + return { + id: s.id, + remainingWeight: s.remaining_weight ?? undefined, + location: s.location ?? undefined, + archived: !!s.archived + }; +} + +function mapFilamentHit(m: Json): FilamentSearchMatch { const raw: Json = m.filament ?? {}; if (raw.vendor) inventory.upsertVendor(mapVendor(raw.vendor)); const filament = mapFilament(raw); inventory.upsertFilament(filament); - return { entity: filament, matchField: m.match_field }; + // These are too partial to upsert into the inventory cache; opening one fetches + // the full spool (see Inspector.svelte), which is what a plain deep link does too. + const spools = (m.spools ?? []).map(mapFilamentSpool); + return { + entity: filament, + matchField: m.match_field, + spools, + spoolCount: m.spool_count ?? spools.length + }; } function mapVendorHit(m: Json): SearchMatch { @@ -50,7 +72,15 @@ export async function searchAll( const q = query.trim(); if (!q) return EMPTY; - const data = await getJson('/search', { q, color_similarity_threshold: threshold }, signal); + const data = await getJson( + '/search', + { + q, + color_similarity_threshold: threshold, + spools_per_filament: SPOOLS_PER_FILAMENT + }, + signal + ); return { spools: (data.spools ?? []).map(mapSpoolHit), diff --git a/client_v2/src/lib/api/types.ts b/client_v2/src/lib/api/types.ts index 390ba7fb..42b7a8e4 100644 --- a/client_v2/src/lib/api/types.ts +++ b/client_v2/src/lib/api/types.ts @@ -100,10 +100,25 @@ export interface SearchMatch { matchField: string; } +/** One of a matched filament's spools, in the fields a shortcut pill shows. */ +export interface FilamentSpoolHit { + id: number; + remainingWeight?: number; + location?: string; + archived: boolean; +} + +/** A filament search hit, plus the spools offered as shortcuts beneath it. */ +export interface FilamentSearchMatch extends SearchMatch { + spools: FilamentSpoolHit[]; + /** Total spools of this filament, of which `spools` holds at most the first few. */ + spoolCount: number; +} + /** Categorized results of a cross-entity search. */ export interface SearchResults { spools: SearchMatch[]; - filaments: SearchMatch[]; + filaments: FilamentSearchMatch[]; vendors: SearchMatch[]; /** True when the query was recognized as a color (a threshold slider is relevant). */ isColorQuery: boolean; diff --git a/client_v2/src/lib/components/MaterialBadge.svelte b/client_v2/src/lib/components/MaterialBadge.svelte index 156e0f3a..c6894943 100644 --- a/client_v2/src/lib/components/MaterialBadge.svelte +++ b/client_v2/src/lib/components/MaterialBadge.svelte @@ -1,8 +1,16 @@ -{label} +{label} diff --git a/client_v2/src/lib/components/library/SearchBox.svelte b/client_v2/src/lib/components/library/SearchBox.svelte index ea416125..537dc38a 100644 --- a/client_v2/src/lib/components/library/SearchBox.svelte +++ b/client_v2/src/lib/components/library/SearchBox.svelte @@ -12,6 +12,7 @@ import { page } from '$app/state'; import { inventory } from '$lib/stores/inventory.svelte'; import { fields } from '$lib/stores/fields.svelte'; + import { weightAuto } from '$lib/utils/format'; import type { EntityKind } from '$lib/types'; import * as m from '$lib/paraglide/messages'; @@ -100,14 +101,34 @@ interface FlatItem { kind: EntityKind; id: string; + /** Unique per row. A spool can be both its own result and a pill under a + * filament, so kind+id alone would highlight two rows at once. */ + key: string; } let flat = $derived( results ? [ - ...results.spools.map((m) => ({ kind: 'spool' as const, id: String(m.entity.id) })), - ...results.filaments.map((m) => ({ kind: 'filament' as const, id: m.entity.id })), - ...results.vendors.map((m) => ({ kind: 'vendor' as const, id: m.entity.id })) + ...results.spools.map((m) => ({ + kind: 'spool' as const, + id: String(m.entity.id), + key: `spool:${m.entity.id}` + })), + // Each filament is followed by its spool pills, so arrowing down walks + // from the filament straight into its spools. + ...results.filaments.flatMap((m) => [ + { kind: 'filament' as const, id: m.entity.id, key: `filament:${m.entity.id}` }, + ...m.spools.map((s) => ({ + kind: 'spool' as const, + id: String(s.id), + key: `filament:${m.entity.id}/spool:${s.id}` + })) + ]), + ...results.vendors.map((m) => ({ + kind: 'vendor' as const, + id: m.entity.id, + key: `vendor:${m.entity.id}` + })) ] : [] ); @@ -194,9 +215,9 @@ return matchField in MATCH_LABEL[kind] ? MATCH_LABEL[kind][matchField]() : matchField; } - // The flat index of an item, so hover/selection highlight lines up with keyboard nav. - function indexOf(kind: EntityKind, id: string): number { - return flat.findIndex((f) => f.kind === kind && f.id === id); + // The flat index of a row, so hover/selection highlight lines up with keyboard nav. + function indexOf(key: string): number { + return flat.findIndex((f) => f.key === key); } @@ -239,7 +260,7 @@ {@const filament = inventory.filamentById(spool.entity.filamentId)} - {filament.entity.name} + + + {filament.entity.name} + {#if filament.entity.material} + + {/if} + {#if vendor?.name}{vendor.name}{/if} - {#if filament.entity.material}{/if} {matchLabel('filament', filament.matchField)} + + {#if filament.spools.length} + + {/if} {/each} {/if} @@ -296,7 +362,7 @@ {#each results.vendors as vendor (vendor.entity.id)} diff --git a/client_v2/src/lib/components/settings/ExtraFieldsManager.svelte b/client_v2/src/lib/components/settings/ExtraFieldsManager.svelte index a45ec4b8..c746a7eb 100644 --- a/client_v2/src/lib/components/settings/ExtraFieldsManager.svelte +++ b/client_v2/src/lib/components/settings/ExtraFieldsManager.svelte @@ -12,6 +12,7 @@ import * as m from '$lib/paraglide/messages'; import Plus from '@lucide/svelte/icons/plus'; import X from '@lucide/svelte/icons/x'; + import GripVertical from '@lucide/svelte/icons/grip-vertical'; const ENTITIES: { key: EntityType; label: () => string }[] = [ { key: 'spool', label: m['library.section.spool'] }, @@ -144,6 +145,38 @@ choices = choices.filter((x) => x !== c); } + // Choices may not be removed once saved, but their order is free to change — + // it is the order they are offered in wherever the field is used. + let draggedChoice = $state(null); + + function moveChoice(choice: string, before: string) { + const next = [...choices]; + const from = next.indexOf(choice); + const to = next.indexOf(before); + if (from === -1 || to === -1 || from === to) return; + next.splice(from, 1); + next.splice(to, 0, choice); + choices = next; + } + + function moveChoiceBy(choice: string, offset: number) { + const to = choices.indexOf(choice) + offset; + if (to < 0 || to >= choices.length) return; + moveChoice(choice, choices[to]); + } + + function onChoiceDragOver(e: DragEvent, target: string) { + if (draggedChoice === null) return; + e.preventDefault(); + moveChoice(draggedChoice, target); + } + + function onGripKeydown(e: KeyboardEvent, choice: string) { + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; + e.preventDefault(); + moveChoiceBy(choice, e.key === 'ArrowLeft' ? -1 : 1); + } + async function save() { error = ''; if (!/^[a-z0-9_]+$/.test(key)) { @@ -303,7 +336,24 @@ {m['settings.extraFields.params.choices']()}
{#each choices as c (c)} - + onChoiceDragOver(e, c)} + > + {c} {#if isNew || !originalChoices.includes(c)}