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
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:
@@ -362,6 +362,14 @@
|
|||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"add_spool": "Add Spool"
|
"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": {
|
"vendor": {
|
||||||
|
|||||||
166
client/src/components/filamentColorLookup.tsx
Normal file
166
client/src/components/filamentColorLookup.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../compo
|
|||||||
import { FilamentImportModal } from "../../components/filamentImportModal";
|
import { FilamentImportModal } from "../../components/filamentImportModal";
|
||||||
import { VendorCreateModal } from "../../components/vendorCreateModal";
|
import { VendorCreateModal } from "../../components/vendorCreateModal";
|
||||||
import { MultiColorPicker } from "../../components/multiColorPicker";
|
import { MultiColorPicker } from "../../components/multiColorPicker";
|
||||||
|
import { FilamentColorLookup } from "../../components/filamentColorLookup";
|
||||||
import {
|
import {
|
||||||
DEFAULT_DENSITY,
|
DEFAULT_DENSITY,
|
||||||
DEFAULT_DIAMETER,
|
DEFAULT_DIAMETER,
|
||||||
@@ -248,19 +249,22 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
|
|||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{colorType == "single" && (
|
{colorType == "single" && (
|
||||||
<Form.Item
|
<div style={{ display: "flex", gap: 8, alignItems: "start" }}>
|
||||||
name={"color_hex"}
|
<Form.Item
|
||||||
rules={[
|
name={"color_hex"}
|
||||||
{
|
rules={[
|
||||||
required: false,
|
{
|
||||||
},
|
required: false,
|
||||||
]}
|
},
|
||||||
getValueFromEvent={(e) => {
|
]}
|
||||||
return e?.toHex();
|
getValueFromEvent={(e) => {
|
||||||
}}
|
return e?.toHex();
|
||||||
>
|
}}
|
||||||
<ColorPicker format="hex" />
|
>
|
||||||
</Form.Item>
|
<ColorPicker format="hex" />
|
||||||
|
</Form.Item>
|
||||||
|
<FilamentColorLookup onSelect={(hex) => form.setFieldValue("color_hex", hex)} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{colorType == "multi" && (
|
{colorType == "multi" && (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import React, { useEffect, useMemo, useState } from "react";
|
|||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
|
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
|
||||||
import { MultiColorPicker } from "../../components/multiColorPicker";
|
import { MultiColorPicker } from "../../components/multiColorPicker";
|
||||||
|
import { FilamentColorLookup } from "../../components/filamentColorLookup";
|
||||||
import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from "../../utils/parsing";
|
import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from "../../utils/parsing";
|
||||||
import { EntityType, useGetFields } from "../../utils/queryFields";
|
import { EntityType, useGetFields } from "../../utils/queryFields";
|
||||||
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
|
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
|
||||||
@@ -248,19 +249,22 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
|
|||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{colorType == "single" && (
|
{colorType == "single" && (
|
||||||
<Form.Item
|
<div style={{ display: "flex", gap: 8, alignItems: "start" }}>
|
||||||
name={"color_hex"}
|
<Form.Item
|
||||||
rules={[
|
name={"color_hex"}
|
||||||
{
|
rules={[
|
||||||
required: false,
|
{
|
||||||
},
|
required: false,
|
||||||
]}
|
},
|
||||||
getValueFromEvent={(e) => {
|
]}
|
||||||
return e?.toHex();
|
getValueFromEvent={(e) => {
|
||||||
}}
|
return e?.toHex();
|
||||||
>
|
}}
|
||||||
<ColorPicker />
|
>
|
||||||
</Form.Item>
|
<ColorPicker />
|
||||||
|
</Form.Item>
|
||||||
|
<FilamentColorLookup onSelect={(hex) => form.setFieldValue("color_hex", hex)} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{colorType == "multi" && (
|
{colorType == "multi" && (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import logging
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Annotated, Optional
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from pydantic import BaseModel, Field, RootModel
|
from pydantic import BaseModel, Field, RootModel
|
||||||
@@ -371,3 +372,104 @@ async def get_cost_stats(
|
|||||||
avg_cost_per_kg=avg_cost_per_kg,
|
avg_cost_per_kg=avg_cost_per_kg,
|
||||||
cost_by_material=cost_by_material,
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user