feat(#19): Add filamentcolors.xyz color lookup integration
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled

- Backend proxy endpoints: GET /filamentcolors/manufacturers, GET /filamentcolors/search
- Frontend: FilamentColorLookup modal with manufacturer filter + color search
- Color grid shows swatches with hex codes, click to apply
- Added to both filament create and edit forms next to ColorPicker

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-23 00:21:13 -06:00
parent c321d916b5
commit 23ef41270c
5 changed files with 310 additions and 26 deletions

View File

@@ -362,6 +362,14 @@
},
"buttons": {
"add_spool": "Add Spool"
},
"color_lookup": {
"button": "Find color from filamentcolors.xyz",
"title": "Find Color - filamentcolors.xyz",
"manufacturer": "Manufacturer",
"color_search": "Search color name...",
"results": "results",
"showing_page": "page {{page}}"
}
},
"vendor": {

View File

@@ -0,0 +1,166 @@
import { SearchOutlined } from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
import { useQuery } from "@tanstack/react-query";
import { Button, Input, Modal, Select, Space, Spin, Tooltip } from "antd";
import { useState } from "react";
import { getAPIURL } from "../utils/url";
interface Swatch {
id: number;
color_name: string;
hex_color: string | null;
manufacturer_name: string;
filament_type: string;
image_front: string | null;
}
interface Manufacturer {
id: number;
name: string;
}
interface FilamentColorLookupProps {
onSelect: (hexColor: string) => void;
}
export function FilamentColorLookup({ onSelect }: FilamentColorLookupProps) {
const t = useTranslate();
const [open, setOpen] = useState(false);
const [manufacturer, setManufacturer] = useState<string | undefined>();
const [colorSearch, setColorSearch] = useState("");
const [page, setPage] = useState(1);
const { data: manufacturers, isLoading: mfrsLoading } = useQuery<Manufacturer[]>({
queryKey: ["filamentcolors", "manufacturers"],
queryFn: async () => {
const res = await fetch(`${getAPIURL()}/filamentcolors/manufacturers`);
return res.json();
},
enabled: open,
});
const { data: searchResult, isLoading: searchLoading } = useQuery<{ count: number; swatches: Swatch[] }>({
queryKey: ["filamentcolors", "search", manufacturer, colorSearch, page],
queryFn: async () => {
const params = new URLSearchParams();
if (manufacturer) params.set("manufacturer", manufacturer);
if (colorSearch) params.set("color", colorSearch);
params.set("limit", "50");
params.set("page", String(page));
const res = await fetch(`${getAPIURL()}/filamentcolors/search?${params}`);
return res.json();
},
enabled: open,
});
const handleSelect = (hex: string) => {
onSelect(hex);
setOpen(false);
};
return (
<>
<Tooltip title={t("filament.color_lookup.button")}>
<Button icon={<SearchOutlined />} onClick={() => setOpen(true)} size="small" />
</Tooltip>
<Modal
title={t("filament.color_lookup.title")}
open={open}
onCancel={() => setOpen(false)}
footer={null}
width={700}
>
<Space.Compact style={{ width: "100%", marginBottom: 12 }}>
<Select
showSearch
allowClear
placeholder={t("filament.color_lookup.manufacturer")}
style={{ width: "50%" }}
loading={mfrsLoading}
value={manufacturer}
onChange={(v) => { setManufacturer(v); setPage(1); }}
options={manufacturers?.map((m) => ({ label: m.name, value: m.name }))}
filterOption={(input, option) =>
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
}
/>
<Input
placeholder={t("filament.color_lookup.color_search")}
value={colorSearch}
onChange={(e) => { setColorSearch(e.target.value); setPage(1); }}
allowClear
style={{ width: "50%" }}
/>
</Space.Compact>
{searchLoading ? (
<div style={{ textAlign: "center", padding: 40 }}><Spin /></div>
) : (
<>
<div style={{ marginBottom: 8, fontSize: 12, color: "#888" }}>
{searchResult?.count ?? 0} {t("filament.color_lookup.results")}
{searchResult && searchResult.count > 50 && ` (${t("filament.color_lookup.showing_page", { page })})`}
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(100px, 1fr))",
gap: 8,
maxHeight: 400,
overflowY: "auto",
}}
>
{searchResult?.swatches.map((s) => (
<Tooltip
key={s.id}
title={`${s.manufacturer_name} - ${s.color_name} (${s.filament_type}) #${s.hex_color ?? "?"}`}
>
<div
onClick={() => s.hex_color && handleSelect(s.hex_color)}
style={{
cursor: s.hex_color ? "pointer" : "default",
border: "1px solid #d9d9d9",
borderRadius: 6,
padding: 6,
textAlign: "center",
opacity: s.hex_color ? 1 : 0.4,
}}
>
<div
style={{
width: "100%",
aspectRatio: "1",
borderRadius: 4,
backgroundColor: s.hex_color ? `#${s.hex_color}` : "#ccc",
marginBottom: 4,
}}
/>
<div style={{ fontSize: 10, lineHeight: 1.2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{s.color_name}
</div>
<div style={{ fontSize: 9, color: "#888", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{s.filament_type}
</div>
</div>
</Tooltip>
))}
</div>
{searchResult && searchResult.count > 50 && (
<div style={{ marginTop: 12, textAlign: "center" }}>
<Button size="small" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t("buttons.prev")}
</Button>
<span style={{ margin: "0 12px", fontSize: 12 }}>
{page} / {Math.ceil(searchResult.count / 50)}
</span>
<Button size="small" disabled={page * 50 >= searchResult.count} onClick={() => setPage((p) => p + 1)}>
{t("buttons.next")}
</Button>
</div>
)}
</>
)}
</Modal>
</>
);
}

View File

@@ -10,6 +10,7 @@ import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../compo
import { FilamentImportModal } from "../../components/filamentImportModal";
import { VendorCreateModal } from "../../components/vendorCreateModal";
import { MultiColorPicker } from "../../components/multiColorPicker";
import { FilamentColorLookup } from "../../components/filamentColorLookup";
import {
DEFAULT_DENSITY,
DEFAULT_DIAMETER,
@@ -248,6 +249,7 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
</Radio.Group>
</Form.Item>
{colorType == "single" && (
<div style={{ display: "flex", gap: 8, alignItems: "start" }}>
<Form.Item
name={"color_hex"}
rules={[
@@ -261,6 +263,8 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
>
<ColorPicker format="hex" />
</Form.Item>
<FilamentColorLookup onSelect={(hex) => form.setFieldValue("color_hex", hex)} />
</div>
)}
{colorType == "multi" && (
<Form.Item

View File

@@ -8,6 +8,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
import { MultiColorPicker } from "../../components/multiColorPicker";
import { FilamentColorLookup } from "../../components/filamentColorLookup";
import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from "../../utils/parsing";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
@@ -248,6 +249,7 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
</Radio.Group>
</Form.Item>
{colorType == "single" && (
<div style={{ display: "flex", gap: 8, alignItems: "start" }}>
<Form.Item
name={"color_hex"}
rules={[
@@ -261,6 +263,8 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
>
<ColorPicker />
</Form.Item>
<FilamentColorLookup onSelect={(hex) => form.setFieldValue("color_hex", hex)} />
</div>
)}
{colorType == "multi" && (
<Form.Item

View File

@@ -4,6 +4,7 @@ import logging
from datetime import datetime, timedelta
from typing import Annotated, Optional
import httpx
import sqlalchemy
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field, RootModel
@@ -371,3 +372,104 @@ async def get_cost_stats(
avg_cost_per_kg=avg_cost_per_kg,
cost_by_material=cost_by_material,
)
# --- filamentcolors.xyz integration ---
FILAMENTCOLORS_API = "https://filamentcolors.xyz/api"
class FilamentColorSwatch(BaseModel):
"""A color swatch from filamentcolors.xyz."""
id: int
color_name: str
hex_color: Optional[str] = None
manufacturer_name: str
filament_type: str
image_front: Optional[str] = None
class FilamentColorManufacturer(BaseModel):
"""A manufacturer from filamentcolors.xyz."""
id: int
name: str
class FilamentColorSearchResult(BaseModel):
"""Search results from filamentcolors.xyz."""
count: int
swatches: list[FilamentColorSwatch]
@router.get(
"/filamentcolors/manufacturers",
name="Get filamentcolors.xyz manufacturers",
description="List all manufacturers from filamentcolors.xyz.",
response_model=list[FilamentColorManufacturer],
)
async def get_filamentcolors_manufacturers() -> list[FilamentColorManufacturer]:
"""Proxy manufacturer list from filamentcolors.xyz."""
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"{FILAMENTCOLORS_API}/manufacturer/", params={"format": "json", "limit": 500})
resp.raise_for_status()
data = resp.json()
return [
FilamentColorManufacturer(id=m["id"], name=m["name"])
for m in data.get("results", [])
]
@router.get(
"/filamentcolors/search",
name="Search filamentcolors.xyz swatches",
description="Search color swatches from filamentcolors.xyz by manufacturer and/or color name.",
response_model=FilamentColorSearchResult,
)
async def search_filamentcolors(
*,
manufacturer: Annotated[
Optional[str],
Query(description="Manufacturer name to filter by."),
] = None,
color: Annotated[
Optional[str],
Query(description="Color name to search for."),
] = None,
filament_type: Annotated[
Optional[str],
Query(description="Filament type to filter by (e.g., PLA, PETG)."),
] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 50,
page: Annotated[int, Query(ge=1)] = 1,
) -> FilamentColorSearchResult:
"""Proxy swatch search from filamentcolors.xyz."""
params: dict = {"format": "json", "limit": limit, "page": page}
if manufacturer:
params["manufacturer__name"] = manufacturer
if color:
params["color_name__icontains"] = color
if filament_type:
params["filament_type__name__icontains"] = filament_type
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"{FILAMENTCOLORS_API}/swatch/", params=params)
resp.raise_for_status()
data = resp.json()
swatches = [
FilamentColorSwatch(
id=s["id"],
color_name=s.get("color_name", ""),
hex_color=s.get("hex_color"),
manufacturer_name=s.get("manufacturer", {}).get("name", ""),
filament_type=s.get("filament_type", {}).get("name", ""),
image_front=s.get("image_front"),
)
for s in data.get("results", [])
]
return FilamentColorSearchResult(count=data.get("count", 0), swatches=swatches)