Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
dbe98f5
fix(filaments): fix spool_count sort/filter 500 and optimize list query
akira69 Feb 20, 2026
fdcf534
fix(vendors): harden logo sync base-path handling and spool-count query
akira69 Feb 21, 2026
9c5693d
fix(ui): align coextruded color preview swatches
akira69 Feb 21, 2026
9e7af19
fix(migrations): avoid duplicate spool.filament_id index creation
akira69 Feb 21, 2026
7e3c5fb
style(ui): use orange app-link styling on show pages
akira69 Feb 21, 2026
4d3170e
perf(filaments): optimize list filtering and name lookups
akira69 Feb 21, 2026
ab9f128
style(ui): enforce app-link color overrides in typography contexts
akira69 Feb 21, 2026
10d183c
feat(labels): persist column visibility in label pickers
akira69 Feb 21, 2026
d076029
feat(vendors): add web-to-print logo converter
akira69 Feb 21, 2026
6d9b561
feat(ui): add resizable columns and consistent floating edit actions
akira69 Feb 21, 2026
53d8254
fix(ui): align floating form actions with camera button
akira69 Feb 21, 2026
56a7fd6
fix(ui): align floating action button bottoms with camera button
akira69 Feb 21, 2026
07e71af
fix(ui): lift floating actions and lock camera anchor offsets
akira69 Feb 21, 2026
8d802f7
Make spool and filament edit Save button stateful
akira69 Feb 21, 2026
f4b27c1
feat: expand list column filtering across spool/filament/vendor
akira69 Feb 22, 2026
a9c651b
fix: show selectable values for date/weight filters
akira69 Feb 22, 2026
8208660
fix: enforce min-width table scrolling with resizable columns
akira69 Feb 22, 2026
99ff9c2
feat(tables): pin Actions column and add double-click auto-fit for re…
akira69 Feb 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions client/public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"hideArchived": "Hide Archived",
"showArchived": "Show Archived",
"notAccessTitle": "You don't have permission to access",
"hideColumns": "Hide Columns",
"hideColumns": "Columns",
"clearFilters": "Clear Filters",
"selectAll": "Select All",
"selectNone": "Select None"
Expand Down Expand Up @@ -362,7 +362,9 @@
"fields_help": {
"empty_spool_weight": "The weight of an empty spool from this manufacturer.",
"logo_url": "Optional custom logo used in the UI. Supports absolute URLs or local paths like /vendor-logos/web/bambu-lab-web.png.",
"print_logo_url": "Optional custom logo used for label rendering. Supports absolute URLs or local paths like /vendor-logos/print/bambu-lab.png."
"print_logo_url": "Optional custom logo used for label rendering. Supports absolute URLs or local paths like /vendor-logos/print/bambu-lab.png.",
"logo_suggestions": "Checks the logo database for files with names similar to this manufacturer.",
"print_logo_suggestions": "Checks the print-logo database for files with names similar to this manufacturer."
},
"titles": {
"create": "Create Manufacturer",
Expand All @@ -374,14 +376,19 @@
},
"buttons": {
"sync_logos": "Sync Logos",
"clear_logo_url": "Clear URL"
"clear_logo_url": "Clear URL",
"convert_logo_to_print": "Convert Logo to Print",
"convert_logo_to_print_help": "Creates a black-and-white print logo from the current Logo URL and stores it as a separate local print logo file."
},
"form": {
"vendor_updated": "This manufacturer has been updated by someone/something else since you opened this page. Saving will overwrite those changes!",
"logo_sync_no_match": "No matching logos found for this manufacturer name.",
"logo_sync_applied": "Suggested logo paths applied.",
"logo_preview_auto_notice": "using auto-matched logo from bundled logo pack",
"logo_preview_default_notice": "no logo defined, using default generated text logo"
"logo_preview_default_notice": "no logo defined, using default generated text logo",
"logo_convert_requires_web_logo": "Set a Logo URL first.",
"logo_convert_success": "Generated print logo from web logo.",
"logo_convert_error": "Could not generate a print logo from this Logo URL."
}
},
"home": {
Expand Down
61 changes: 17 additions & 44 deletions client/src/components/colorHexPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ export default function ColorHexPreview({ colorHex, multiColorHexes, multiColorD
if (isLongitudinal) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{colors.map((hex) => (
<div key={hex} style={{ display: "flex", alignItems: "center", gap: 8 }}>
{colors.map((hex, index) => (
<div key={`${hex}-${index}`} style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div
style={{
width: 56,
Expand All @@ -56,50 +56,23 @@ export default function ColorHexPreview({ colorHex, multiColorHexes, multiColorD
);
}

const swatchWidth = Math.max(64, colors.length * 22);
const strip = (
<div
style={{
display: "grid",
gridTemplateColumns: `repeat(${colors.length}, minmax(0, 1fr))`,
width: swatchWidth,
height: 26,
borderRadius: 6,
overflow: "hidden",
border: "1px solid rgba(255,255,255,0.22)",
}}
>
return (
<div style={{ display: "flex", alignItems: "flex-start", gap: 0 }}>
{colors.map((hex, index) => (
<div key={`${hex}-${index}`} style={{ background: hex }} />
<div
key={`${hex}-${index}`}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
width: 64,
gap: 4,
}}
>
<SpoolIcon color={hex} size="large" no_margin />
<Typography.Text style={{ ...SMALL_TEXT_STYLE, textAlign: "center" }}>{hex}</Typography.Text>
</div>
))}
</div>
);

if (colors.length === 2) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Typography.Text style={SMALL_TEXT_STYLE}>{colors[0]}</Typography.Text>
{strip}
<Typography.Text style={SMALL_TEXT_STYLE}>{colors[1]}</Typography.Text>
</div>
);
}

const middle = colors.slice(1, -1);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Typography.Text style={SMALL_TEXT_STYLE}>{colors[0]}</Typography.Text>
{strip}
<Typography.Text style={SMALL_TEXT_STYLE}>{colors[colors.length - 1]}</Typography.Text>
</div>
<div style={{ marginLeft: 8 + 54, display: "grid", gridTemplateColumns: `repeat(${middle.length}, minmax(0, 1fr))`, width: swatchWidth - 44 }}>
{middle.map((hex, index) => (
<Typography.Text key={`${hex}-${index}`} style={{ ...SMALL_TEXT_STYLE, textAlign: "center" }}>
{hex}
</Typography.Text>
))}
</div>
</div>
);
}
169 changes: 168 additions & 1 deletion client/src/components/column.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,41 @@ function valueKey(value: Key): string {
return String(value);
}

function normalizeSearchableValue(value: unknown): string {
if (value === null || value === undefined) {
return "";
}
if (Array.isArray(value)) {
return value.map((entry) => String(entry)).join(", ");
}
return String(value);
}

function getRecordValue(record: unknown, dataIndex: string | string[]): unknown {
if (Array.isArray(dataIndex)) {
return dataIndex.reduce<unknown>((current, part) => {
if (current === null || current === undefined || typeof current !== "object") {
return undefined;
}
return (current as Record<string, unknown>)[part];
}, record);
}

if (record !== null && record !== undefined && typeof record === "object") {
const recordObject = record as Record<string, unknown>;
if (Object.prototype.hasOwnProperty.call(recordObject, dataIndex)) {
return recordObject[dataIndex];
}
}

return dataIndex.split(".").reduce<unknown>((current, part) => {
if (current === null || current === undefined || typeof current !== "object") {
return undefined;
}
return (current as Record<string, unknown>)[part];
}, record);
}

function FilterDropdownContent(props: {
items: ColumnFilterItem[];
selectedKeys: Key[];
Expand Down Expand Up @@ -207,6 +242,56 @@ function FilterDropdownContent(props: {
);
}


function SearchFilterDropdownContent(props: {
selectedKeys: Key[];
setSelectedKeys: (keys: Key[]) => void;
confirm: () => void;
clearFilters?: () => void;
t: (key: string) => string;
placeholder: string;
}) {
const { selectedKeys, setSelectedKeys, confirm, clearFilters, t, placeholder } = props;
const currentValue = selectedKeys.length > 0 ? String(selectedKeys[0]) : "";

return (
<div style={{ padding: 8, width: 240 }}>
<Input
allowClear
size="small"
value={currentValue}
placeholder={placeholder}
onChange={(event) => {
const value = event.target.value;
setSelectedKeys(value ? [value] : []);
}}
onPressEnter={() => confirm()}
/>
<Space style={{ marginTop: 8 }}>
<Button
size="small"
type="primary"
onClick={() => {
confirm();
}}
>
{t("buttons.filter")}
</Button>
<Button
size="small"
onClick={() => {
setSelectedKeys([]);
clearFilters?.();
confirm();
}}
>
{t("buttons.clear")}
</Button>
</Space>
</div>
);
}

interface Entity {
id: number;
}
Expand All @@ -226,6 +311,9 @@ interface BaseColumnProps<Obj extends Entity> {
title?: string;
align?: AlignType;
sorter?: boolean;
searchable?: boolean;
searchPlaceholder?: string;
searchValueFormatter?: (rawValue: unknown, record: Obj) => string;
t: (key: string) => string;
navigate: (link: string) => void;
dataSource: Obj[];
Expand Down Expand Up @@ -314,6 +402,69 @@ function Column<Obj extends Entity>(
if (props.dataId) {
columnProps.key = props.dataId;
}
} else if (props.searchable) {
const filterField = props.dataId ?? (Array.isArray(props.id) ? undefined : (props.id as keyof Obj));
if (filterField) {
const typedFilters = typeFilters<Obj>(props.tableState.filters);
const filteredValue = getFiltersForField(typedFilters, filterField);
const searchableValues = new Map<string, string>();
const searchValueDataIndex = props.dataId ?? props.id;

props.dataSource.forEach((record) => {
const rawValue = getRecordValue(record, searchValueDataIndex);
const displayValue = props.searchValueFormatter
? props.searchValueFormatter(rawValue, record)
: normalizeSearchableValue(rawValue);
const normalizedDisplayValue = displayValue ?? "";
const filterValue = normalizedDisplayValue === "" ? "<empty>" : normalizedDisplayValue;
if (!searchableValues.has(filterValue)) {
searchableValues.set(filterValue, normalizedDisplayValue);
}
});

const searchableFilters: ColumnFilterItem[] = Array.from(searchableValues.entries())
.map(([value, label]) => ({ value, text: label }))
.sort((left, right) =>
filterSearchTerm(left).localeCompare(filterSearchTerm(right), undefined, {
numeric: true,
sensitivity: "base",
}),
);

columnProps.filteredValue = filteredValue;

if (searchableFilters.length > 0) {
columnProps.filters = searchableFilters;
columnProps.filterMultiple = true;
columnProps.filterDropdown = ({ selectedKeys, setSelectedKeys, confirm, clearFilters }) => (
<FilterDropdownContent
items={searchableFilters}
selectedKeys={selectedKeys}
setSelectedKeys={setSelectedKeys}
confirm={confirm}
clearFilters={clearFilters}
allowMultipleFilters={true}
t={t}
/>
);
} else {
columnProps.filterMultiple = false;
columnProps.filterDropdown = ({ selectedKeys, setSelectedKeys, confirm, clearFilters }) => (
<SearchFilterDropdownContent
selectedKeys={selectedKeys}
setSelectedKeys={setSelectedKeys}
confirm={confirm}
clearFilters={clearFilters}
t={t}
placeholder={props.searchPlaceholder ?? t("buttons.filter")}
/>
);
}

if (props.dataId) {
columnProps.key = props.dataId;
}
}
}

// Render
Expand Down Expand Up @@ -363,6 +514,7 @@ export function SortedColumn<Obj extends Entity>(props: BaseColumnProps<Obj>) {
return Column({
...props,
sorter: true,
searchable: props.searchable ?? true,
});
}

Expand All @@ -371,6 +523,7 @@ export function RichColumn<Obj extends Entity>(
) {
return Column({
...props,
searchable: props.searchable ?? true,
render: (rawValue: string | undefined) => {
const value = props.transform ? props.transform(rawValue) : rawValue;
return enrichText(value);
Expand All @@ -382,6 +535,7 @@ interface FilteredQueryColumnProps<Obj extends Entity> extends BaseColumnProps<O
filterValueQuery: UseQueryResult<string[] | ColumnFilterItem[], unknown>;
allowMultipleFilters?: boolean;
includeEmptyFilter?: boolean;
emptyFilterLabel?: string;
}

export function FilteredQueryColumn<Obj extends Entity>(props: FilteredQueryColumnProps<Obj>) {
Expand All @@ -401,7 +555,7 @@ export function FilteredQueryColumn<Obj extends Entity>(props: FilteredQueryColu
}
if (props.includeEmptyFilter !== false) {
filters.push({
text: "<empty>",
text: props.emptyFilterLabel ?? "<empty>",
value: "<empty>",
});
}
Expand Down Expand Up @@ -435,6 +589,7 @@ export function NumberColumn<Obj extends Entity>(props: NumberColumnProps<Obj>)
return Column({
...props,
align: "right",
searchable: props.searchable ?? true,
render: (rawValue) => {
const value = props.transform ? props.transform(rawValue) : rawValue;
if (value === null || value === undefined) {
Expand All @@ -457,6 +612,14 @@ export function NumberColumn<Obj extends Entity>(props: NumberColumnProps<Obj>)
export function DateColumn<Obj extends Entity>(props: BaseColumnProps<Obj>) {
return Column({
...props,
searchable: props.searchable ?? true,
searchValueFormatter: (rawValue) => {
const value = props.transform ? props.transform(rawValue) : rawValue;
if (!value) {
return "";
}
return dayjs.utc(value as string).local().format("YYYY-MM-DD HH:mm");
},
render: (rawValue) => {
const value = props.transform ? props.transform(rawValue) : rawValue;
return (
Expand All @@ -477,7 +640,10 @@ export function ActionsColumn<Obj extends Entity>(
): ColumnType<Obj> | undefined {
return {
title,
key: "actions",
responsive: ["lg"],
fixed: "right",
width: 190,
render: (_, record) => {
const buttons = actionsFn(record).map((action) => {
if (action.link) {
Expand Down Expand Up @@ -570,6 +736,7 @@ export function SpoolIconColumn<Obj extends Entity>(props: SpoolIconColumnProps<
export function NumberRangeColumn<Obj extends Entity>(props: NumberColumnProps<Obj>) {
return Column({
...props,
searchable: props.searchable ?? true,
render: (rawValue) => {
const value = props.transform ? props.transform(rawValue) : rawValue;
if (value === null || value === undefined) {
Expand Down
Loading