diff --git a/package.json b/package.json index 512e2fb13..17f6fdfff 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "name": "Total JS (gzip)", "path": "dist/assets/*.js", "gzip": true, - "limit": "1644 kB" + "limit": "1652 kB" } ], "dependencies": { diff --git a/scripts/design-system-allowlist.txt b/scripts/design-system-allowlist.txt index 9276db2cc..f105df12f 100644 --- a/scripts/design-system-allowlist.txt +++ b/scripts/design-system-allowlist.txt @@ -165,3 +165,6 @@ src/shared/components/Toast/Toast.tsx src/shared/components/ToolSwitcher/ToolSwitcher.tsx src/shared/components/TwoClickDeleteButton/TwoClickDeleteButton.tsx src/shared/components/ViewModeToggle/ViewModeToggle.tsx +src/features/bin-designer/components/DesignListDialog/TagInput/TagInput.tsx +src/features/bin-designer/components/DesignListDialog/TagFilterBar/TagFilterBar.tsx +src/features/bin-designer/components/DesignListDialog/BulkActionBar/BulkActionBar.tsx diff --git a/src/features/bin-designer/components/DesignActions/DesignActions.test.tsx b/src/features/bin-designer/components/DesignActions/DesignActions.test.tsx index f108b34ce..4f224e466 100644 --- a/src/features/bin-designer/components/DesignActions/DesignActions.test.tsx +++ b/src/features/bin-designer/components/DesignActions/DesignActions.test.tsx @@ -20,6 +20,7 @@ describe('DesignActions', () => { onLoad: vi.fn(), onDownloadJSON: vi.fn(), onRename: vi.fn(), + onEditTags: vi.fn(), onDuplicate: vi.fn(), onDelete: vi.fn(), }; @@ -92,6 +93,20 @@ describe('DesignActions', () => { expect(onRename).toHaveBeenCalled(); }); + it('calls onEditTags when Edit tags is clicked', async () => { + const onEditTags = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /more actions/i })); + + await waitFor(() => { + expect(screen.getByRole('menuitem', { name: /edit tags/i })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('menuitem', { name: /edit tags/i })); + expect(onEditTags).toHaveBeenCalled(); + }); + it('calls onDuplicate when Duplicate is clicked', async () => { const onDuplicate = vi.fn(); render(); diff --git a/src/features/bin-designer/components/DesignActions/DesignActions.tsx b/src/features/bin-designer/components/DesignActions/DesignActions.tsx index c3b161989..b1c687818 100644 --- a/src/features/bin-designer/components/DesignActions/DesignActions.tsx +++ b/src/features/bin-designer/components/DesignActions/DesignActions.tsx @@ -10,6 +10,7 @@ interface DesignActionsProps { onLoad: () => void; onDownloadJSON: () => void; onRename: () => void; + onEditTags: () => void; onDuplicate: () => void; onDelete: () => void; } @@ -24,6 +25,7 @@ export function DesignActions({ onLoad, onDownloadJSON, onRename, + onEditTags, onDuplicate, onDelete, }: DesignActionsProps) { @@ -199,6 +201,29 @@ export function DesignActions({ {t('common.rename')} + {/* Edit tags */} + + {/* Duplicate */} +
+ + + + +
+ + ); +} diff --git a/src/features/bin-designer/components/DesignListDialog/BulkActionBar/index.ts b/src/features/bin-designer/components/DesignListDialog/BulkActionBar/index.ts new file mode 100644 index 000000000..d177e1ff9 --- /dev/null +++ b/src/features/bin-designer/components/DesignListDialog/BulkActionBar/index.ts @@ -0,0 +1 @@ +export { BulkActionBar } from './BulkActionBar'; diff --git a/src/features/bin-designer/components/DesignListDialog/DesignListDialog.tsx b/src/features/bin-designer/components/DesignListDialog/DesignListDialog.tsx index 54eaaa2a8..a1dda1492 100644 --- a/src/features/bin-designer/components/DesignListDialog/DesignListDialog.tsx +++ b/src/features/bin-designer/components/DesignListDialog/DesignListDialog.tsx @@ -13,7 +13,14 @@ import { deleteDesign, duplicateDesign, saveDesign, + updateDesignTags, } from '@/features/bin-designer/storage/DesignerStorage'; +import { collectTags, filterByTags, toggleTag } from '@/features/bin-designer/utils/tagFilter'; +import { normalizeTags, tagsEqual } from '@/features/bin-designer/utils/tags'; +import { TagFilterBar } from './TagFilterBar'; +import { BulkActionBar } from './BulkActionBar'; +import { TagEditDialog } from './TagEditDialog'; +import { useDesignSelection } from './useDesignSelection'; import { removeRegistryEntry } from '../../store/customBinRegistry'; import { useDesignerStore } from '../../store'; import { useDesignerRouting } from '@/shared/hooks/useDesignerRouting'; @@ -61,6 +68,12 @@ export function DesignListDialog({ open, onClose }: DesignListDialogProps) { const [sortBy, setSortBy] = useState('recent'); const [focusedIndex, setFocusedIndex] = useState(0); const [showImport, setShowImport] = useState(false); + const [activeTags, setActiveTags] = useState([]); + const [tagEdit, setTagEdit] = useState<{ mode: 'single' | 'bulk'; design?: SavedDesign } | null>( + null + ); + const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false); + const selection = useDesignSelection(); const itemRefs = useRef>(new Map()); const gridRef = useRef(null); @@ -102,6 +115,10 @@ export function DesignListDialog({ open, onClose }: DesignListDialogProps) { setFocusedIndex(0); setLoading(true); setShowImport(false); + setActiveTags([]); + setTagEdit(null); + setShowBulkDeleteConfirm(false); + selection.exit(); } else if (!open && prevOpen) { setPrevOpen(false); } @@ -128,12 +145,34 @@ export function DesignListDialog({ open, onClose }: DesignListDialogProps) { }, []); useThumbnailRegeneration(designs, handleThumbnailUpdated); + const allTags = useMemo(() => collectTags(designs), [designs]); + + // Drop active filters whose tag no longer exists (e.g. after deleting the last + // design carrying it). Otherwise the list can get stuck showing nothing while + // the filter bar — which hides when no tags exist — offers no way to clear it. + const prunedActiveTags = activeTags.filter((tag) => + allTags.some((t) => t.toLowerCase() === tag.toLowerCase()) + ); + if (prunedActiveTags.length !== activeTags.length) { + setActiveTags(prunedActiveTags); + } + + // Drop selected IDs for designs that no longer exist (e.g. a single delete via + // the row menu while in selection mode) so the count and bulk actions stay honest. + if (selection.active && selection.count > 0) { + const presentIds = designs.map((d) => d.id); + const present = new Set(presentIds); + if ([...selection.selectedIds].some((id) => !present.has(id))) { + selection.prune(presentIds); + } + } + // Filter and sort designs const sortedDesigns = useMemo(() => { - let filtered = designs; + let filtered = filterByTags(designs, activeTags); if (searchQuery.trim()) { const query = searchQuery.toLowerCase(); - filtered = designs.filter((d) => d.name.toLowerCase().includes(query)); + filtered = filtered.filter((d) => d.name.toLowerCase().includes(query)); } return [...filtered].sort((a, b) => { @@ -154,7 +193,7 @@ export function DesignListDialog({ open, onClose }: DesignListDialogProps) { return b.updatedAt.localeCompare(a.updatedAt); } }); - }, [designs, searchQuery, sortBy, currentDesignId]); + }, [designs, activeTags, searchQuery, sortBy, currentDesignId]); // Get localized sort options const localizedSortOptions = useMemo( @@ -256,6 +295,97 @@ export function DesignListDialog({ open, onClose }: DesignListDialogProps) { [addToast, t] ); + const handleSaveTags = useCallback( + async (rawTags: string[]) => { + const edit = tagEdit; + if (!edit) return; + + if (edit.mode === 'single' && edit.design) { + const next = normalizeTags(rawTags); + // Skip the write (and its updatedAt bump + sync) when nothing changed. + if (tagsEqual(next, edit.design.tags ?? [])) { + setTagEdit(null); + return; + } + const result = await updateDesignTags(edit.design.id, next); + if (isOk(result)) { + const saved = result.value; + setDesigns((prev) => prev.map((d) => (d.id === saved.id ? saved : d))); + if (saved.id === currentDesignId) { + useDesignerStore.getState().setDesignName(saved.name); + } + } + } else if (edit.mode === 'bulk') { + const ids = selection.selectedIds; + const targets = designs.filter((d) => ids.has(d.id)); + const updated = new Map(); + for (const d of targets) { + // Bulk = add the entered tags to each design's existing set (union). + const nextTags = normalizeTags([...(d.tags ?? []), ...rawTags]); + // Only write designs whose tag set actually changes — a no-op bulk + // tag shouldn't bump updatedAt or trigger a sync for every design. + if (tagsEqual(nextTags, d.tags ?? [])) continue; + const result = await updateDesignTags(d.id, nextTags); + if (isOk(result)) updated.set(result.value.id, result.value); + } + if (updated.size > 0) { + setDesigns((prev) => prev.map((d) => updated.get(d.id) ?? d)); + addToast({ + message: t('binDesigner.bulk.toastTagged', { count: updated.size }), + type: 'success', + duration: 2000, + }); + } + selection.exit(); + } + setTagEdit(null); + }, + [tagEdit, selection, designs, currentDesignId, addToast, t] + ); + + const handleBulkDelete = useCallback(async () => { + const ids = [...selection.selectedIds]; + // Track only the IDs that actually deleted, so a partial storage failure + // doesn't drop a still-present design from the UI. + const deletedIds: string[] = []; + for (const id of ids) { + const design = designs.find((d) => d.id === id); + if (!design) continue; + const result = await deleteDesign(design.id); + if (isOk(result)) { + removeRegistryEntry(design.id); + deletedIds.push(id); + } + } + if (deletedIds.length > 0) { + const removed = new Set(deletedIds); + setDesigns((prev) => prev.filter((d) => !removed.has(d.id))); + addToast({ + message: t('binDesigner.bulk.toastDeleted', { count: deletedIds.length }), + type: 'success', + duration: 2000, + }); + } + setShowBulkDeleteConfirm(false); + selection.exit(); + }, [selection, designs, addToast, t]); + + const handleBulkExport = useCallback(() => { + const ids = selection.selectedIds; + const targets = designs.filter((d) => ids.has(d.id)); + for (const d of targets) { + downloadDesignAsFile(d.name, d.params); + } + if (targets.length > 0) { + addToast({ + message: t('binDesigner.bulk.toastExported', { count: targets.length }), + type: 'success', + duration: 2000, + }); + } + selection.exit(); + }, [selection, designs, addToast, t]); + const getGridColumns = useCallback(() => { if (effectiveViewMode === 'list' || !gridRef.current) return 1; const style = window.getComputedStyle(gridRef.current); @@ -368,7 +498,7 @@ export function DesignListDialog({ open, onClose }: DesignListDialogProps) { }} >

{t('binDesigner.savedDesigns')}

+ {!showImport && designs.length > 0 && !selection.active && ( + + )} + + + + ); +} diff --git a/src/features/bin-designer/components/DesignListDialog/TagEditDialog/index.ts b/src/features/bin-designer/components/DesignListDialog/TagEditDialog/index.ts new file mode 100644 index 000000000..b963c7519 --- /dev/null +++ b/src/features/bin-designer/components/DesignListDialog/TagEditDialog/index.ts @@ -0,0 +1 @@ +export { TagEditDialog } from './TagEditDialog'; diff --git a/src/features/bin-designer/components/DesignListDialog/TagFilterBar/TagFilterBar.test.tsx b/src/features/bin-designer/components/DesignListDialog/TagFilterBar/TagFilterBar.test.tsx new file mode 100644 index 000000000..cf7305c40 --- /dev/null +++ b/src/features/bin-designer/components/DesignListDialog/TagFilterBar/TagFilterBar.test.tsx @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { TagFilterBar } from './TagFilterBar'; + +describe('TagFilterBar', () => { + it('renders nothing when there are no tags', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it('marks active chips with aria-pressed and toggles on click', () => { + const onToggle = vi.fn(); + render( + + ); + expect(screen.getByRole('button', { name: 'kitchen' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: 'screws' })).toHaveAttribute('aria-pressed', 'false'); + fireEvent.click(screen.getByRole('button', { name: 'screws' })); + expect(onToggle).toHaveBeenCalledWith('screws'); + }); + + it('shows clear only when a filter is active', () => { + const onClear = vi.fn(); + const { rerender } = render( + + ); + expect(screen.queryByText(/clear/i)).not.toBeInTheDocument(); + rerender( + + ); + fireEvent.click(screen.getByText(/clear/i)); + expect(onClear).toHaveBeenCalled(); + }); +}); diff --git a/src/features/bin-designer/components/DesignListDialog/TagFilterBar/TagFilterBar.tsx b/src/features/bin-designer/components/DesignListDialog/TagFilterBar/TagFilterBar.tsx new file mode 100644 index 000000000..b77367b72 --- /dev/null +++ b/src/features/bin-designer/components/DesignListDialog/TagFilterBar/TagFilterBar.tsx @@ -0,0 +1,53 @@ +import { useTranslation } from '@/i18n'; + +interface TagFilterBarProps { + allTags: readonly string[]; + activeTags: readonly string[]; + onToggle: (tag: string) => void; + onClear: () => void; +} + +/** Toggle-chip row for filtering the design list by tag. Hidden when no tags exist. */ +export function TagFilterBar({ allTags, activeTags, onToggle, onClear }: TagFilterBarProps) { + const t = useTranslation(); + if (allTags.length === 0) return null; + + const activeSet = new Set(activeTags.map((x) => x.toLowerCase())); + + return ( +
+ {allTags.map((tag) => { + const isActive = activeSet.has(tag.toLowerCase()); + return ( + + ); + })} + {activeTags.length > 0 && ( + + )} +
+ ); +} diff --git a/src/features/bin-designer/components/DesignListDialog/TagFilterBar/index.ts b/src/features/bin-designer/components/DesignListDialog/TagFilterBar/index.ts new file mode 100644 index 000000000..cbbfb7dc7 --- /dev/null +++ b/src/features/bin-designer/components/DesignListDialog/TagFilterBar/index.ts @@ -0,0 +1 @@ +export { TagFilterBar } from './TagFilterBar'; diff --git a/src/features/bin-designer/components/DesignListDialog/TagInput/TagInput.test.tsx b/src/features/bin-designer/components/DesignListDialog/TagInput/TagInput.test.tsx new file mode 100644 index 000000000..8dddfdc82 --- /dev/null +++ b/src/features/bin-designer/components/DesignListDialog/TagInput/TagInput.test.tsx @@ -0,0 +1,39 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { TagInput } from './TagInput'; + +describe('TagInput', () => { + it('commits a tag on Enter and clears the draft', () => { + const onChange = vi.fn(); + render(); + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: 'kitchen' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(onChange).toHaveBeenCalledWith(['kitchen']); + }); + + it('does not add a duplicate (case-insensitive)', () => { + const onChange = vi.fn(); + render(); + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: 'kitchen' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('removes a tag via its remove button', () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByLabelText(/kitchen/i)); + expect(onChange).toHaveBeenCalledWith(['screws']); + }); + + it('Backspace on an empty draft drops the last tag', () => { + const onChange = vi.fn(); + render(); + const input = screen.getByRole('textbox'); + fireEvent.keyDown(input, { key: 'Backspace' }); + expect(onChange).toHaveBeenCalledWith(['kitchen']); + }); +}); diff --git a/src/features/bin-designer/components/DesignListDialog/TagInput/TagInput.tsx b/src/features/bin-designer/components/DesignListDialog/TagInput/TagInput.tsx new file mode 100644 index 000000000..c55ab62a4 --- /dev/null +++ b/src/features/bin-designer/components/DesignListDialog/TagInput/TagInput.tsx @@ -0,0 +1,96 @@ +import { useState, useCallback } from 'react'; +import type { KeyboardEvent } from 'react'; +import { useTranslation } from '@/i18n'; +import { Input } from '@/design-system'; +import { MAX_TAGS, normalizeTags } from '@/features/bin-designer/utils/tags'; + +interface TagInputProps { + value: readonly string[]; + onChange: (tags: string[]) => void; +} + +/** + * Controlled tag editor: current tags as removable chips plus an input that + * commits on Enter or comma. Normalization (trim/dedupe/cap) is shared with the + * storage layer via `normalizeTags`, so the editor can't produce a tag the + * store would reject. + */ +export function TagInput({ value, onChange }: TagInputProps) { + const t = useTranslation(); + const [draft, setDraft] = useState(''); + const atMax = value.length >= MAX_TAGS; + + const commit = useCallback( + (raw: string) => { + const trimmed = raw.trim(); + if (trimmed === '') return; + const next = normalizeTags([...value, trimmed]); + if (next.length !== value.length) onChange(next); + setDraft(''); + }, + [value, onChange] + ); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + commit(draft); + } else if (e.key === 'Backspace' && draft === '' && value.length > 0) { + onChange(value.slice(0, -1)); + } + }; + + const removeTag = (tag: string) => onChange(value.filter((x) => x !== tag)); + + return ( +
+ {value.length > 0 && ( +
    + {value.map((tag) => ( +
  • + + + {tag} + + + +
  • + ))} +
+ )} + setDraft(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => commit(draft)} + disabled={atMax} + placeholder={ + atMax + ? t('binDesigner.tags.max', { count: MAX_TAGS }) + : t('binDesigner.tags.addPlaceholder') + } + aria-label={t('binDesigner.tags.addPlaceholder')} + /> +
+ ); +} diff --git a/src/features/bin-designer/components/DesignListDialog/TagInput/index.ts b/src/features/bin-designer/components/DesignListDialog/TagInput/index.ts new file mode 100644 index 000000000..2321a0913 --- /dev/null +++ b/src/features/bin-designer/components/DesignListDialog/TagInput/index.ts @@ -0,0 +1 @@ +export { TagInput } from './TagInput'; diff --git a/src/features/bin-designer/components/DesignListDialog/useDesignSelection.test.ts b/src/features/bin-designer/components/DesignListDialog/useDesignSelection.test.ts new file mode 100644 index 000000000..919a458f3 --- /dev/null +++ b/src/features/bin-designer/components/DesignListDialog/useDesignSelection.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { selectionReducer, initialSelectionState } from './useDesignSelection'; + +describe('selectionReducer', () => { + it('starts inactive with an empty selection', () => { + expect(initialSelectionState.active).toBe(false); + expect(initialSelectionState.selected.size).toBe(0); + }); + + it('ENTER activates selection mode with an empty selection', () => { + const entered = selectionReducer(initialSelectionState, { type: 'ENTER' }); + expect(entered.active).toBe(true); + expect(entered.selected.size).toBe(0); + }); + + it('TOGGLE adds then removes an id', () => { + const s1 = selectionReducer({ active: true, selected: new Set() }, { type: 'TOGGLE', id: 'a' }); + expect([...s1.selected]).toEqual(['a']); + const s2 = selectionReducer(s1, { type: 'TOGGLE', id: 'a' }); + expect(s2.selected.size).toBe(0); + }); + + it('SELECT_ALL replaces the selection with the given ids', () => { + const s = selectionReducer( + { active: true, selected: new Set(['x']) }, + { type: 'SELECT_ALL', ids: ['a', 'b', 'c'] } + ); + expect([...s.selected].sort()).toEqual(['a', 'b', 'c']); + }); + + it('EXIT clears the selection and leaves selection mode', () => { + const s = selectionReducer({ active: true, selected: new Set(['a', 'b']) }, { type: 'EXIT' }); + expect(s.active).toBe(false); + expect(s.selected.size).toBe(0); + }); + + it('PRUNE drops ids no longer present (e.g. after deletes)', () => { + const s = selectionReducer( + { active: true, selected: new Set(['a', 'b', 'c']) }, + { type: 'PRUNE', ids: ['a', 'c'] } + ); + expect([...s.selected].sort()).toEqual(['a', 'c']); + }); +}); diff --git a/src/features/bin-designer/components/DesignListDialog/useDesignSelection.ts b/src/features/bin-designer/components/DesignListDialog/useDesignSelection.ts new file mode 100644 index 000000000..dc62c30a3 --- /dev/null +++ b/src/features/bin-designer/components/DesignListDialog/useDesignSelection.ts @@ -0,0 +1,72 @@ +import { useReducer, useMemo } from 'react'; + +export interface SelectionState { + /** Whether bulk-selection mode is active. */ + active: boolean; + selected: ReadonlySet; +} + +export type SelectionAction = + | { type: 'ENTER' } + | { type: 'EXIT' } + | { type: 'TOGGLE'; id: string } + | { type: 'SELECT_ALL'; ids: readonly string[] } + | { type: 'PRUNE'; ids: readonly string[] }; + +export const initialSelectionState: SelectionState = { + active: false, + selected: new Set(), +}; + +export function selectionReducer(state: SelectionState, action: SelectionAction): SelectionState { + switch (action.type) { + case 'ENTER': + return { active: true, selected: new Set() }; + case 'EXIT': + return initialSelectionState; + case 'TOGGLE': { + const next = new Set(state.selected); + if (next.has(action.id)) next.delete(action.id); + else next.add(action.id); + return { ...state, selected: next }; + } + case 'SELECT_ALL': + return { ...state, selected: new Set(action.ids) }; + case 'PRUNE': { + const keep = new Set(action.ids); + return { ...state, selected: new Set([...state.selected].filter((id) => keep.has(id))) }; + } + } +} + +export interface DesignSelection { + active: boolean; + selectedIds: ReadonlySet; + count: number; + isSelected: (id: string) => boolean; + enter: () => void; + exit: () => void; + toggle: (id: string) => void; + selectAll: (ids: readonly string[]) => void; + prune: (ids: readonly string[]) => void; +} + +/** Bulk-selection state for the designs manager. */ +export function useDesignSelection(): DesignSelection { + const [state, dispatch] = useReducer(selectionReducer, initialSelectionState); + + return useMemo( + () => ({ + active: state.active, + selectedIds: state.selected, + count: state.selected.size, + isSelected: (id: string) => state.selected.has(id), + enter: () => dispatch({ type: 'ENTER' }), + exit: () => dispatch({ type: 'EXIT' }), + toggle: (id: string) => dispatch({ type: 'TOGGLE', id }), + selectAll: (ids: readonly string[]) => dispatch({ type: 'SELECT_ALL', ids }), + prune: (ids: readonly string[]) => dispatch({ type: 'PRUNE', ids }), + }), + [state] + ); +} diff --git a/src/features/bin-designer/components/DesignListItem/DesignListItem.test.tsx b/src/features/bin-designer/components/DesignListItem/DesignListItem.test.tsx index 13b164c46..8f7744b3a 100644 --- a/src/features/bin-designer/components/DesignListItem/DesignListItem.test.tsx +++ b/src/features/bin-designer/components/DesignListItem/DesignListItem.test.tsx @@ -32,6 +32,7 @@ describe('DesignListItem', () => { onSelect: vi.fn(), onDownloadJSON: vi.fn(), onRename: vi.fn(), + onEditTags: vi.fn(), onDuplicate: vi.fn(), onDelete: vi.fn(), onFocus: vi.fn(), diff --git a/src/features/bin-designer/components/DesignListItem/DesignListItem.tsx b/src/features/bin-designer/components/DesignListItem/DesignListItem.tsx index c391d8fec..31e990a54 100644 --- a/src/features/bin-designer/components/DesignListItem/DesignListItem.tsx +++ b/src/features/bin-designer/components/DesignListItem/DesignListItem.tsx @@ -1,7 +1,9 @@ import { useTranslation, useFormatting } from '@/i18n'; import { useInlineEdit } from '@/shared/hooks'; +import { Checkbox } from '@/design-system'; import { BinDesignThumbnail } from '../BinDesignThumbnail'; import { DesignActions } from '../DesignActions'; +import { DesignTagChips } from '../DesignTagChips'; import type { SavedDesign } from '../../types'; interface DesignListItemProps { @@ -11,10 +13,15 @@ interface DesignListItemProps { onSelect: () => void; onDownloadJSON: () => void; onRename: (newName: string) => void; + onEditTags: () => void; onDuplicate: () => void; onDelete: () => void; onFocus: () => void; itemRef: (el: HTMLLIElement | null) => void; + /** Bulk-selection mode: clicking toggles selection instead of loading. */ + selectionActive?: boolean; + isSelected?: boolean; + onToggleSelect?: () => void; } /** @@ -28,10 +35,14 @@ export function DesignListItem({ onSelect, onDownloadJSON, onRename, + onEditTags, onDuplicate, onDelete, onFocus, itemRef, + selectionActive = false, + isSelected = false, + onToggleSelect, }: DesignListItemProps) { const t = useTranslation(); const { formatRelativeDate } = useFormatting(); @@ -52,9 +63,14 @@ export function DesignListItem({ const { width, depth, height, compartments } = design.params; const numCompartments = new Set(compartments.cells).size; + const activate = () => { + if (selectionActive) onToggleSelect?.(); + else onSelect(); + }; + const handleClick = () => { if (!isEditing) { - onSelect(); + activate(); } }; @@ -65,7 +81,7 @@ export function DesignListItem({ } if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - onSelect(); + activate(); } }; @@ -83,9 +99,11 @@ export function DesignListItem({ cursor-pointer transition-colors outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-secondary ${ - isActive - ? 'border-accent bg-accent/10 ring-1 ring-accent/30' - : 'border-stroke-subtle hover:bg-surface-hover' + isSelected + ? 'border-accent bg-accent/10 ring-1 ring-accent/40' + : isActive + ? 'border-accent bg-accent/10 ring-1 ring-accent/30' + : 'border-stroke-subtle hover:bg-surface-hover' } `} > @@ -96,6 +114,22 @@ export function DesignListItem({ )} + {/* Selection checkbox (bulk mode) */} + {selectionActive && ( +
{ + e.stopPropagation(); + onToggleSelect?.(); + }} + > + +
+ )} + {/* Thumbnail */}
{design.thumbnail ? ( @@ -137,6 +171,11 @@ export function DesignListItem({

{formatRelativeDate(design.updatedAt, { includeTime: true })}

+ {design.tags && design.tags.length > 0 && ( +
+ +
+ )}
{/* Actions - always visible on touch, hover/focus on desktop */} @@ -147,6 +186,7 @@ export function DesignListItem({ onLoad={onSelect} onDownloadJSON={onDownloadJSON} onRename={startEditing} + onEditTags={onEditTags} onDuplicate={onDuplicate} onDelete={onDelete} /> diff --git a/src/features/bin-designer/components/DesignTagChips/DesignTagChips.test.tsx b/src/features/bin-designer/components/DesignTagChips/DesignTagChips.test.tsx new file mode 100644 index 000000000..833af8c3c --- /dev/null +++ b/src/features/bin-designer/components/DesignTagChips/DesignTagChips.test.tsx @@ -0,0 +1,25 @@ +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { DesignTagChips } from './DesignTagChips'; + +describe('DesignTagChips', () => { + it('renders nothing when there are no tags', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders each tag as a chip', () => { + render(); + expect(screen.getByText('kitchen')).toBeInTheDocument(); + expect(screen.getByText('screws')).toBeInTheDocument(); + }); + + it('caps visible chips and shows a +N overflow', () => { + render(); + expect(screen.getByText('a')).toBeInTheDocument(); + expect(screen.getByText('c')).toBeInTheDocument(); + expect(screen.queryByText('d')).not.toBeInTheDocument(); + expect(screen.getByText('+2')).toBeInTheDocument(); + }); +}); diff --git a/src/features/bin-designer/components/DesignTagChips/DesignTagChips.tsx b/src/features/bin-designer/components/DesignTagChips/DesignTagChips.tsx new file mode 100644 index 000000000..ac3f8d9d7 --- /dev/null +++ b/src/features/bin-designer/components/DesignTagChips/DesignTagChips.tsx @@ -0,0 +1,34 @@ +interface DesignTagChipsProps { + tags: readonly string[]; + /** Cap how many chips render before a "+N" overflow chip. */ + max?: number; +} + +/** Read-only row of tag chips shown on a design card/row. Renders nothing when untagged. */ +export function DesignTagChips({ tags, max = 3 }: DesignTagChipsProps) { + if (tags.length === 0) return null; + const shown = tags.slice(0, max); + const overflow = tags.length - shown.length; + // As a plain string (not a JSX-inline literal) so i18next/no-literal-string + // doesn't flag this non-linguistic count indicator. + const overflowLabel = `+${overflow}`; + + return ( +
+ {shown.map((tag) => ( + + {tag} + + ))} + {overflow > 0 && ( + + {overflowLabel} + + )} +
+ ); +} diff --git a/src/features/bin-designer/components/DesignTagChips/index.ts b/src/features/bin-designer/components/DesignTagChips/index.ts new file mode 100644 index 000000000..b4de39dae --- /dev/null +++ b/src/features/bin-designer/components/DesignTagChips/index.ts @@ -0,0 +1 @@ +export { DesignTagChips } from './DesignTagChips'; diff --git a/src/features/bin-designer/utils/tagFilter.test.ts b/src/features/bin-designer/utils/tagFilter.test.ts new file mode 100644 index 000000000..36f34876e --- /dev/null +++ b/src/features/bin-designer/utils/tagFilter.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { collectTags, filterByTags, toggleTag } from './tagFilter'; +import type { SavedDesign } from '../types'; + +function design(id: string, tags?: string[]): SavedDesign { + return { + id: id as SavedDesign['id'], + name: id, + params: {} as SavedDesign['params'], + thumbnail: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + exportFileNameConfig: null, + ...(tags ? { tags } : {}), + }; +} + +describe('collectTags', () => { + it('returns the sorted unique union of all design tags', () => { + const designs = [design('a', ['kitchen', 'screws']), design('b', ['kitchen']), design('c')]; + expect(collectTags(designs)).toEqual(['kitchen', 'screws']); + }); + + it('dedupes case-insensitively, keeping first-seen casing', () => { + const designs = [design('a', ['Kitchen']), design('b', ['kitchen', 'Garage'])]; + expect(collectTags(designs)).toEqual(['Garage', 'Kitchen']); + }); + + it('returns [] when no design has tags', () => { + expect(collectTags([design('a'), design('b')])).toEqual([]); + }); +}); + +describe('filterByTags', () => { + const designs = [ + design('a', ['kitchen', 'screws']), + design('b', ['kitchen']), + design('c', ['garage']), + design('d'), + ]; + + it('returns all designs when no tags are active', () => { + expect(filterByTags(designs, []).map((d) => d.id)).toEqual(['a', 'b', 'c', 'd']); + }); + + it('AND-matches: a design must carry every active tag', () => { + expect(filterByTags(designs, ['kitchen']).map((d) => d.id)).toEqual(['a', 'b']); + expect(filterByTags(designs, ['kitchen', 'screws']).map((d) => d.id)).toEqual(['a']); + }); + + it('matches tags case-insensitively', () => { + expect(filterByTags(designs, ['KITCHEN']).map((d) => d.id)).toEqual(['a', 'b']); + }); + + it('excludes untagged designs when any tag is active', () => { + expect(filterByTags(designs, ['garage']).map((d) => d.id)).toEqual(['c']); + }); +}); + +describe('toggleTag', () => { + it('adds a tag that is absent', () => { + expect(toggleTag(['a'], 'b')).toEqual(['a', 'b']); + }); + + it('removes a tag that is present, case-insensitively', () => { + expect(toggleTag(['Kitchen', 'screws'], 'kitchen')).toEqual(['screws']); + }); + + it('does not duplicate-add when a case variant is already present', () => { + expect(toggleTag(['Kitchen'], 'kitchen')).toEqual([]); + }); +}); diff --git a/src/features/bin-designer/utils/tagFilter.ts b/src/features/bin-designer/utils/tagFilter.ts new file mode 100644 index 000000000..59cc8fac7 --- /dev/null +++ b/src/features/bin-designer/utils/tagFilter.ts @@ -0,0 +1,37 @@ +import type { SavedDesign } from '../types'; + +/** Sorted, case-insensitively-deduped union of every tag across the designs. */ +export function collectTags(designs: readonly SavedDesign[]): string[] { + const seen = new Map(); // lowercased key -> first-seen casing + for (const d of designs) { + for (const tag of d.tags ?? []) { + const key = tag.toLowerCase(); + if (!seen.has(key)) seen.set(key, tag); + } + } + return [...seen.values()].sort((a, b) => a.localeCompare(b)); +} + +/** + * Filter designs to those carrying every active tag (AND). With no active + * tags, returns the input unchanged. Matching is case-insensitive. + */ +export function filterByTags( + designs: readonly SavedDesign[], + activeTags: readonly string[] +): SavedDesign[] { + if (activeTags.length === 0) return [...designs]; + const wanted = activeTags.map((t) => t.toLowerCase()); + return designs.filter((d) => { + const has = new Set((d.tags ?? []).map((t) => t.toLowerCase())); + return wanted.every((w) => has.has(w)); + }); +} + +/** Add or remove a tag from a list, comparing case-insensitively. */ +export function toggleTag(tags: readonly string[], tag: string): string[] { + const key = tag.toLowerCase(); + return tags.some((t) => t.toLowerCase() === key) + ? tags.filter((t) => t.toLowerCase() !== key) + : [...tags, tag]; +} diff --git a/src/features/bin-designer/utils/tags.test.ts b/src/features/bin-designer/utils/tags.test.ts index fa22b0fea..77489bf21 100644 --- a/src/features/bin-designer/utils/tags.test.ts +++ b/src/features/bin-designer/utils/tags.test.ts @@ -1,5 +1,19 @@ import { describe, it, expect } from 'vitest'; -import { normalizeTags, MAX_TAGS, MAX_TAG_LENGTH } from './tags'; +import { normalizeTags, tagsEqual, MAX_TAGS, MAX_TAG_LENGTH } from './tags'; + +describe('tagsEqual', () => { + it('is true for identical lists', () => { + expect(tagsEqual(['a', 'b'], ['a', 'b'])).toBe(true); + }); + it('is false for different length, values, or order', () => { + expect(tagsEqual(['a'], ['a', 'b'])).toBe(false); + expect(tagsEqual(['a', 'b'], ['a', 'c'])).toBe(false); + expect(tagsEqual(['a', 'b'], ['b', 'a'])).toBe(false); + }); + it('treats two empty lists as equal', () => { + expect(tagsEqual([], [])).toBe(true); + }); +}); describe('normalizeTags', () => { it('trims, drops empties, and dedupes case-insensitively keeping first casing', () => { diff --git a/src/features/bin-designer/utils/tags.ts b/src/features/bin-designer/utils/tags.ts index e4d6f5e26..ad6e3bd1f 100644 --- a/src/features/bin-designer/utils/tags.ts +++ b/src/features/bin-designer/utils/tags.ts @@ -37,3 +37,8 @@ export function normalizeTags(input: unknown): string[] { } return out; } + +/** True when two already-normalized tag lists are identical (same order, same values). */ +export function tagsEqual(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((tag, i) => tag === b[i]); +} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 474c06b95..5edbe83f4 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2115,5 +2115,28 @@ "binDesigner.angledDividers.shiftLabel": "Verschiebung", "binDesigner.angledDividers.fineTune": "Feinabstimmung", "binDesigner.angledDividers.badgeAngle": "{angle}°", - "binDesigner.angledDividers.conflictNotice": "Mulden, Beschriftungslaschen und Einsätze werden entlang geneigter Trennwände entfernt." + "binDesigner.angledDividers.conflictNotice": "Mulden, Beschriftungslaschen und Einsätze werden entlang geneigter Trennwände entfernt.", + "binDesigner.tags.addPlaceholder": "Tag hinzufügen…", + "binDesigner.tags.max": "Bis zu {count} Tags", + "binDesigner.tags.remove": "Tag {tag} entfernen", + "binDesigner.tags.editAction": "Tags bearbeiten", + "binDesigner.tags.editForDesign": "Tags für {name} bearbeiten", + "binDesigner.tags.save": "Speichern", + "binDesigner.tags.filterLabel": "Nach Tag filtern", + "binDesigner.tags.clearFilter": "Filter zurücksetzen", + "binDesigner.select": "Auswählen", + "binDesigner.selectDesign": "{name} auswählen", + "binDesigner.bulk.selected": "{count} ausgewählt", + "binDesigner.bulk.selectAll": "Alle auswählen", + "binDesigner.bulk.cancelSelection": "Abbrechen", + "binDesigner.bulk.delete": "Löschen", + "binDesigner.bulk.export": "Exportieren", + "binDesigner.bulk.tag": "Taggen", + "binDesigner.bulk.deleteTitle": "Designs löschen", + "binDesigner.bulk.deleteConfirm": "{count} ausgewählte Design(s) löschen? Dies kann nicht rückgängig gemacht werden.", + "binDesigner.bulk.tagTitle": "Tags zu {count} Design(s) hinzufügen", + "binDesigner.bulk.tagApply": "Tags hinzufügen", + "binDesigner.bulk.toastDeleted": "{count} Design(s) gelöscht", + "binDesigner.bulk.toastTagged": "{count} Design(s) getaggt", + "binDesigner.bulk.toastExported": "{count} Design(s) exportiert" } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index adeb0ca62..173629ba2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1647,6 +1647,29 @@ "binDesigner.confirmDelete": "Click again to delete", "binDesigner.moreActionsForDesign": "More actions for {name}", "binDesigner.compartmentsShort": "{count} comp.", + "binDesigner.tags.addPlaceholder": "Add a tag…", + "binDesigner.tags.max": "Up to {count} tags", + "binDesigner.tags.remove": "Remove tag {tag}", + "binDesigner.tags.editAction": "Edit tags", + "binDesigner.tags.editForDesign": "Edit tags for {name}", + "binDesigner.tags.save": "Save", + "binDesigner.tags.filterLabel": "Filter by tag", + "binDesigner.tags.clearFilter": "Clear filters", + "binDesigner.select": "Select", + "binDesigner.selectDesign": "Select {name}", + "binDesigner.bulk.selected": "{count} selected", + "binDesigner.bulk.selectAll": "Select all", + "binDesigner.bulk.cancelSelection": "Cancel", + "binDesigner.bulk.delete": "Delete", + "binDesigner.bulk.export": "Export", + "binDesigner.bulk.tag": "Tag", + "binDesigner.bulk.deleteTitle": "Delete designs", + "binDesigner.bulk.deleteConfirm": "Delete {count} selected design(s)? This can’t be undone.", + "binDesigner.bulk.tagTitle": "Add tags to {count} design(s)", + "binDesigner.bulk.tagApply": "Add tags", + "binDesigner.bulk.toastDeleted": "Deleted {count} design(s)", + "binDesigner.bulk.toastTagged": "Tagged {count} design(s)", + "binDesigner.bulk.toastExported": "Exported {count} design(s)", "baseplate.title": "Baseplate", "baseplate.magnetHoles": "Magnet holes", "baseplate.magnetDiameter": "Magnet diameter", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 318b7ec6e..c471a3f1c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1902,6 +1902,33 @@ const en: Record = { 'binDesigner.moreActionsForDesign': 'More actions for {name}', 'binDesigner.compartmentsShort': '{count} comp.', + // Design tags + 'binDesigner.tags.addPlaceholder': 'Add a tag…', + 'binDesigner.tags.max': 'Up to {count} tags', + 'binDesigner.tags.remove': 'Remove tag {tag}', + 'binDesigner.tags.editAction': 'Edit tags', + 'binDesigner.tags.editForDesign': 'Edit tags for {name}', + 'binDesigner.tags.save': 'Save', + 'binDesigner.tags.filterLabel': 'Filter by tag', + 'binDesigner.tags.clearFilter': 'Clear filters', + + // Bulk selection / actions + 'binDesigner.select': 'Select', + 'binDesigner.selectDesign': 'Select {name}', + 'binDesigner.bulk.selected': '{count} selected', + 'binDesigner.bulk.selectAll': 'Select all', + 'binDesigner.bulk.cancelSelection': 'Cancel', + 'binDesigner.bulk.delete': 'Delete', + 'binDesigner.bulk.export': 'Export', + 'binDesigner.bulk.tag': 'Tag', + 'binDesigner.bulk.deleteTitle': 'Delete designs', + 'binDesigner.bulk.deleteConfirm': 'Delete {count} selected design(s)? This can’t be undone.', + 'binDesigner.bulk.tagTitle': 'Add tags to {count} design(s)', + 'binDesigner.bulk.tagApply': 'Add tags', + 'binDesigner.bulk.toastDeleted': 'Deleted {count} design(s)', + 'binDesigner.bulk.toastTagged': 'Tagged {count} design(s)', + 'binDesigner.bulk.toastExported': 'Exported {count} design(s)', + // Baseplate Generator (standalone feature) 'baseplate.title': 'Baseplate', 'baseplate.magnetHoles': 'Magnet holes', diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index c36a67265..e98f015fa 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2115,5 +2115,28 @@ "binDesigner.angledDividers.shiftLabel": "Desplazamiento", "binDesigner.angledDividers.fineTune": "Ajuste fino", "binDesigner.angledDividers.badgeAngle": "{angle}°", - "binDesigner.angledDividers.conflictNotice": "Las rampas, las pestañas de etiqueta y los insertos se eliminan a lo largo de los divisores inclinados." + "binDesigner.angledDividers.conflictNotice": "Las rampas, las pestañas de etiqueta y los insertos se eliminan a lo largo de los divisores inclinados.", + "binDesigner.tags.addPlaceholder": "Añadir etiqueta…", + "binDesigner.tags.max": "Hasta {count} etiquetas", + "binDesigner.tags.remove": "Quitar etiqueta {tag}", + "binDesigner.tags.editAction": "Editar etiquetas", + "binDesigner.tags.editForDesign": "Editar etiquetas de {name}", + "binDesigner.tags.save": "Guardar", + "binDesigner.tags.filterLabel": "Filtrar por etiqueta", + "binDesigner.tags.clearFilter": "Borrar filtros", + "binDesigner.select": "Seleccionar", + "binDesigner.selectDesign": "Seleccionar {name}", + "binDesigner.bulk.selected": "{count} seleccionados", + "binDesigner.bulk.selectAll": "Seleccionar todo", + "binDesigner.bulk.cancelSelection": "Cancelar", + "binDesigner.bulk.delete": "Eliminar", + "binDesigner.bulk.export": "Exportar", + "binDesigner.bulk.tag": "Etiquetar", + "binDesigner.bulk.deleteTitle": "Eliminar diseños", + "binDesigner.bulk.deleteConfirm": "¿Eliminar {count} diseño(s) seleccionado(s)? Esta acción no se puede deshacer.", + "binDesigner.bulk.tagTitle": "Añadir etiquetas a {count} diseño(s)", + "binDesigner.bulk.tagApply": "Añadir etiquetas", + "binDesigner.bulk.toastDeleted": "Se eliminaron {count} diseño(s)", + "binDesigner.bulk.toastTagged": "Se etiquetaron {count} diseño(s)", + "binDesigner.bulk.toastExported": "Se exportaron {count} diseño(s)" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 5ec5c1fb1..6beac61a9 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -2115,5 +2115,28 @@ "binDesigner.angledDividers.shiftLabel": "Décalage", "binDesigner.angledDividers.fineTune": "Réglage fin", "binDesigner.angledDividers.badgeAngle": "{angle}°", - "binDesigner.angledDividers.conflictNotice": "Les rampes, les languettes d'étiquette et les inserts sont supprimés le long des séparateurs inclinés." + "binDesigner.angledDividers.conflictNotice": "Les rampes, les languettes d'étiquette et les inserts sont supprimés le long des séparateurs inclinés.", + "binDesigner.tags.addPlaceholder": "Ajouter une étiquette…", + "binDesigner.tags.max": "Jusqu’à {count} étiquettes", + "binDesigner.tags.remove": "Supprimer l’étiquette {tag}", + "binDesigner.tags.editAction": "Modifier les étiquettes", + "binDesigner.tags.editForDesign": "Modifier les étiquettes de {name}", + "binDesigner.tags.save": "Enregistrer", + "binDesigner.tags.filterLabel": "Filtrer par étiquette", + "binDesigner.tags.clearFilter": "Effacer les filtres", + "binDesigner.select": "Sélectionner", + "binDesigner.selectDesign": "Sélectionner {name}", + "binDesigner.bulk.selected": "{count} sélectionné(s)", + "binDesigner.bulk.selectAll": "Tout sélectionner", + "binDesigner.bulk.cancelSelection": "Annuler", + "binDesigner.bulk.delete": "Supprimer", + "binDesigner.bulk.export": "Exporter", + "binDesigner.bulk.tag": "Étiqueter", + "binDesigner.bulk.deleteTitle": "Supprimer les modèles", + "binDesigner.bulk.deleteConfirm": "Supprimer {count} modèle(s) sélectionné(s) ? Cette action est irréversible.", + "binDesigner.bulk.tagTitle": "Ajouter des étiquettes à {count} modèle(s)", + "binDesigner.bulk.tagApply": "Ajouter des étiquettes", + "binDesigner.bulk.toastDeleted": "{count} modèle(s) supprimé(s)", + "binDesigner.bulk.toastTagged": "{count} modèle(s) étiqueté(s)", + "binDesigner.bulk.toastExported": "{count} modèle(s) exporté(s)" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 4849ee6cc..1b26f9a3c 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2115,5 +2115,28 @@ "binDesigner.angledDividers.shiftLabel": "シフト", "binDesigner.angledDividers.fineTune": "微調整", "binDesigner.angledDividers.badgeAngle": "{angle}°", - "binDesigner.angledDividers.conflictNotice": "傾斜した仕切りに沿って、スクープ、ラベルタブ、インサートは削除されます。" + "binDesigner.angledDividers.conflictNotice": "傾斜した仕切りに沿って、スクープ、ラベルタブ、インサートは削除されます。", + "binDesigner.tags.addPlaceholder": "タグを追加…", + "binDesigner.tags.max": "最大{count}個のタグ", + "binDesigner.tags.remove": "タグ{tag}を削除", + "binDesigner.tags.editAction": "タグを編集", + "binDesigner.tags.editForDesign": "{name}のタグを編集", + "binDesigner.tags.save": "保存", + "binDesigner.tags.filterLabel": "タグで絞り込む", + "binDesigner.tags.clearFilter": "フィルターをクリア", + "binDesigner.select": "選択", + "binDesigner.selectDesign": "{name}を選択", + "binDesigner.bulk.selected": "{count}件選択中", + "binDesigner.bulk.selectAll": "すべて選択", + "binDesigner.bulk.cancelSelection": "キャンセル", + "binDesigner.bulk.delete": "削除", + "binDesigner.bulk.export": "エクスポート", + "binDesigner.bulk.tag": "タグ付け", + "binDesigner.bulk.deleteTitle": "デザインを削除", + "binDesigner.bulk.deleteConfirm": "選択した{count}件のデザインを削除しますか?この操作は元に戻せません。", + "binDesigner.bulk.tagTitle": "{count}件のデザインにタグを追加", + "binDesigner.bulk.tagApply": "タグを追加", + "binDesigner.bulk.toastDeleted": "{count}件のデザインを削除しました", + "binDesigner.bulk.toastTagged": "{count}件のデザインにタグを付けました", + "binDesigner.bulk.toastExported": "{count}件のデザインをエクスポートしました" } diff --git a/src/i18n/locales/nb.json b/src/i18n/locales/nb.json index 8a5ab1679..4dfeb83fa 100644 --- a/src/i18n/locales/nb.json +++ b/src/i18n/locales/nb.json @@ -2115,5 +2115,28 @@ "binDesigner.angledDividers.shiftLabel": "Forskyvning", "binDesigner.angledDividers.fineTune": "Finjustering", "binDesigner.angledDividers.badgeAngle": "{angle}°", - "binDesigner.angledDividers.conflictNotice": "Skåler, etikettflik og innlegg fjernes langs skråstilte skillevegger." + "binDesigner.angledDividers.conflictNotice": "Skåler, etikettflik og innlegg fjernes langs skråstilte skillevegger.", + "binDesigner.tags.addPlaceholder": "Legg til etikett…", + "binDesigner.tags.max": "Opptil {count} etiketter", + "binDesigner.tags.remove": "Fjern etikett {tag}", + "binDesigner.tags.editAction": "Rediger etiketter", + "binDesigner.tags.editForDesign": "Rediger etiketter for {name}", + "binDesigner.tags.save": "Lagre", + "binDesigner.tags.filterLabel": "Filtrer etter etikett", + "binDesigner.tags.clearFilter": "Tøm filtre", + "binDesigner.select": "Velg", + "binDesigner.selectDesign": "Velg {name}", + "binDesigner.bulk.selected": "{count} valgt", + "binDesigner.bulk.selectAll": "Velg alle", + "binDesigner.bulk.cancelSelection": "Avbryt", + "binDesigner.bulk.delete": "Slett", + "binDesigner.bulk.export": "Eksporter", + "binDesigner.bulk.tag": "Merk", + "binDesigner.bulk.deleteTitle": "Slett design", + "binDesigner.bulk.deleteConfirm": "Slette {count} valgte design? Dette kan ikke angres.", + "binDesigner.bulk.tagTitle": "Legg til etiketter på {count} design", + "binDesigner.bulk.tagApply": "Legg til etiketter", + "binDesigner.bulk.toastDeleted": "Slettet {count} design", + "binDesigner.bulk.toastTagged": "Merket {count} design", + "binDesigner.bulk.toastExported": "Eksporterte {count} design" } diff --git a/src/i18n/locales/nl.json b/src/i18n/locales/nl.json index 7f02a33e9..64bab9197 100644 --- a/src/i18n/locales/nl.json +++ b/src/i18n/locales/nl.json @@ -2115,5 +2115,28 @@ "binDesigner.angledDividers.shiftLabel": "Verschuiving", "binDesigner.angledDividers.fineTune": "Fijnafstelling", "binDesigner.angledDividers.badgeAngle": "{angle}°", - "binDesigner.angledDividers.conflictNotice": "Scheppen, labeltabs en inzetstukken worden langs schuine scheidingswanden verwijderd." + "binDesigner.angledDividers.conflictNotice": "Scheppen, labeltabs en inzetstukken worden langs schuine scheidingswanden verwijderd.", + "binDesigner.tags.addPlaceholder": "Label toevoegen…", + "binDesigner.tags.max": "Maximaal {count} labels", + "binDesigner.tags.remove": "Label {tag} verwijderen", + "binDesigner.tags.editAction": "Labels bewerken", + "binDesigner.tags.editForDesign": "Labels bewerken voor {name}", + "binDesigner.tags.save": "Opslaan", + "binDesigner.tags.filterLabel": "Filteren op label", + "binDesigner.tags.clearFilter": "Filters wissen", + "binDesigner.select": "Selecteren", + "binDesigner.selectDesign": "{name} selecteren", + "binDesigner.bulk.selected": "{count} geselecteerd", + "binDesigner.bulk.selectAll": "Alles selecteren", + "binDesigner.bulk.cancelSelection": "Annuleren", + "binDesigner.bulk.delete": "Verwijderen", + "binDesigner.bulk.export": "Exporteren", + "binDesigner.bulk.tag": "Labelen", + "binDesigner.bulk.deleteTitle": "Ontwerpen verwijderen", + "binDesigner.bulk.deleteConfirm": "{count} geselecteerde ontwerp(en) verwijderen? Dit kan niet ongedaan worden gemaakt.", + "binDesigner.bulk.tagTitle": "Labels toevoegen aan {count} ontwerp(en)", + "binDesigner.bulk.tagApply": "Labels toevoegen", + "binDesigner.bulk.toastDeleted": "{count} ontwerp(en) verwijderd", + "binDesigner.bulk.toastTagged": "{count} ontwerp(en) gelabeld", + "binDesigner.bulk.toastExported": "{count} ontwerp(en) geëxporteerd" } diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index 4c1aaa4f1..bea7bb85a 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -2115,5 +2115,28 @@ "binDesigner.angledDividers.shiftLabel": "Deslocamento", "binDesigner.angledDividers.fineTune": "Ajuste fino", "binDesigner.angledDividers.badgeAngle": "{angle}°", - "binDesigner.angledDividers.conflictNotice": "Rampas, abas de etiqueta e encaixes são removidos ao longo de divisórias inclinadas." + "binDesigner.angledDividers.conflictNotice": "Rampas, abas de etiqueta e encaixes são removidos ao longo de divisórias inclinadas.", + "binDesigner.tags.addPlaceholder": "Adicionar etiqueta…", + "binDesigner.tags.max": "Até {count} etiquetas", + "binDesigner.tags.remove": "Remover etiqueta {tag}", + "binDesigner.tags.editAction": "Editar etiquetas", + "binDesigner.tags.editForDesign": "Editar etiquetas de {name}", + "binDesigner.tags.save": "Salvar", + "binDesigner.tags.filterLabel": "Filtrar por etiqueta", + "binDesigner.tags.clearFilter": "Limpar filtros", + "binDesigner.select": "Selecionar", + "binDesigner.selectDesign": "Selecionar {name}", + "binDesigner.bulk.selected": "{count} selecionado(s)", + "binDesigner.bulk.selectAll": "Selecionar tudo", + "binDesigner.bulk.cancelSelection": "Cancelar", + "binDesigner.bulk.delete": "Excluir", + "binDesigner.bulk.export": "Exportar", + "binDesigner.bulk.tag": "Etiquetar", + "binDesigner.bulk.deleteTitle": "Excluir designs", + "binDesigner.bulk.deleteConfirm": "Excluir {count} design(s) selecionado(s)? Isso não pode ser desfeito.", + "binDesigner.bulk.tagTitle": "Adicionar etiquetas a {count} design(s)", + "binDesigner.bulk.tagApply": "Adicionar etiquetas", + "binDesigner.bulk.toastDeleted": "{count} design(s) excluído(s)", + "binDesigner.bulk.toastTagged": "{count} design(s) etiquetado(s)", + "binDesigner.bulk.toastExported": "{count} design(s) exportado(s)" } diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index a9fcf71ce..6fb6f9c2b 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -2115,5 +2115,28 @@ "binDesigner.angledDividers.shiftLabel": "Förskjutning", "binDesigner.angledDividers.fineTune": "Finjustering", "binDesigner.angledDividers.badgeAngle": "{angle}°", - "binDesigner.angledDividers.conflictNotice": "Skopor, etikettflikar och insatser tas bort längs lutande mellanväggar." + "binDesigner.angledDividers.conflictNotice": "Skopor, etikettflikar och insatser tas bort längs lutande mellanväggar.", + "binDesigner.tags.addPlaceholder": "Lägg till tagg…", + "binDesigner.tags.max": "Upp till {count} taggar", + "binDesigner.tags.remove": "Ta bort tagg {tag}", + "binDesigner.tags.editAction": "Redigera taggar", + "binDesigner.tags.editForDesign": "Redigera taggar för {name}", + "binDesigner.tags.save": "Spara", + "binDesigner.tags.filterLabel": "Filtrera efter tagg", + "binDesigner.tags.clearFilter": "Rensa filter", + "binDesigner.select": "Välj", + "binDesigner.selectDesign": "Välj {name}", + "binDesigner.bulk.selected": "{count} valda", + "binDesigner.bulk.selectAll": "Välj alla", + "binDesigner.bulk.cancelSelection": "Avbryt", + "binDesigner.bulk.delete": "Ta bort", + "binDesigner.bulk.export": "Exportera", + "binDesigner.bulk.tag": "Tagga", + "binDesigner.bulk.deleteTitle": "Ta bort design", + "binDesigner.bulk.deleteConfirm": "Ta bort {count} valda design? Detta kan inte ångras.", + "binDesigner.bulk.tagTitle": "Lägg till taggar på {count} design", + "binDesigner.bulk.tagApply": "Lägg till taggar", + "binDesigner.bulk.toastDeleted": "Tog bort {count} design", + "binDesigner.bulk.toastTagged": "Taggade {count} design", + "binDesigner.bulk.toastExported": "Exporterade {count} design" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index d9f241e7b..f947bca47 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -2115,5 +2115,28 @@ "binDesigner.angledDividers.shiftLabel": "Зсув", "binDesigner.angledDividers.fineTune": "Точне налаштування", "binDesigner.angledDividers.badgeAngle": "{angle}°", - "binDesigner.angledDividers.conflictNotice": "Совки, етикеткові язички та вставки видаляються вздовж нахилених роздільників." + "binDesigner.angledDividers.conflictNotice": "Совки, етикеткові язички та вставки видаляються вздовж нахилених роздільників.", + "binDesigner.tags.addPlaceholder": "Додати тег…", + "binDesigner.tags.max": "До {count} тегів", + "binDesigner.tags.remove": "Видалити тег {tag}", + "binDesigner.tags.editAction": "Редагувати теги", + "binDesigner.tags.editForDesign": "Редагувати теги для {name}", + "binDesigner.tags.save": "Зберегти", + "binDesigner.tags.filterLabel": "Фільтрувати за тегом", + "binDesigner.tags.clearFilter": "Очистити фільтри", + "binDesigner.select": "Вибрати", + "binDesigner.selectDesign": "Вибрати {name}", + "binDesigner.bulk.selected": "Вибрано {count}", + "binDesigner.bulk.selectAll": "Вибрати все", + "binDesigner.bulk.cancelSelection": "Скасувати", + "binDesigner.bulk.delete": "Видалити", + "binDesigner.bulk.export": "Експортувати", + "binDesigner.bulk.tag": "Тегувати", + "binDesigner.bulk.deleteTitle": "Видалити дизайни", + "binDesigner.bulk.deleteConfirm": "Видалити {count} вибраних дизайнів? Цю дію не можна скасувати.", + "binDesigner.bulk.tagTitle": "Додати теги до {count} дизайнів", + "binDesigner.bulk.tagApply": "Додати теги", + "binDesigner.bulk.toastDeleted": "Видалено {count} дизайнів", + "binDesigner.bulk.toastTagged": "Позначено {count} дизайнів", + "binDesigner.bulk.toastExported": "Експортовано {count} дизайнів" }