Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 31 additions & 0 deletions .gitea/workflows/build.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Build and Push Docker Image

on:
push:
branches:
- hannu

jobs:
build:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v3

- name: Log in to Container Registry
run: |
REGISTRY="${GITHUB_SERVER_URL#https://}"
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY" -u "${{ github.repository_owner }}" --password-stdin

- name: Build and push Docker image
run: |
REGISTRY="${GITHUB_SERVER_URL#https://}"
IMAGE="$REGISTRY/${GITHUB_REPOSITORY,,}"
docker buildx build --push \
-t "$IMAGE:${{ github.sha }}" \
-t "$IMAGE:latest" \
.

- name: Log out from Registry
run: docker logout "${GITHUB_SERVER_URL#https://}"
10 changes: 9 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
FROM node:20-slim AS node-builder

WORKDIR /build
COPY client/package.json client/package-lock.json client/.npmrc ./
RUN npm ci
COPY client/ ./
RUN echo "VITE_APIURL=/api/v1" > .env.production && npm run build

FROM python:3.14-slim-bookworm AS python-builder

ENV UV_COMPILE_BYTECODE=1
Expand Down Expand Up @@ -51,7 +59,7 @@ RUN groupmod -g 1000 users \
&& chown -R app:app /home/app/.local/share/spoolman

# Copy built client
COPY --chown=app:app ./client/dist /home/app/spoolman/client/dist
COPY --from=node-builder --chown=app:app /build/dist /home/app/spoolman/client/dist

# Copy built app
COPY --chown=app:app --from=python-builder /home/app/spoolman /home/app/spoolman
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
> **This is a personal fork of [Donkie/Spoolman](https://github.com/Donkie/Spoolman)** by [Hannu Teulahti](https://github.com/hannteu).
>
> **Why this fork?** I wanted human-readable color names on filament spools — being able to say "Neon Green" instead of remembering `#39FF14`. The feature covers the full stack: a nearest-neighbor color lookup table, API input/output, filtering, sorting, and a color autocomplete with live swatches in the UI.
>
> I also changed the Docker build to compile the frontend from source instead of copying it from the upstream image, which makes it possible to ship frontend changes in a fork without depending on the upstream release cycle.
>
> **Note on AI assistance:** All ideas and requirements are my own. Implementation was done with extensive assistance from [Claude Code](https://claude.ai/code) (Anthropic's AI coding assistant). The architecture decisions, feature design, and direction were driven by me — Claude handled a large part of the code generation, iteration, and debugging.
>
> The commits on the `feature/color-name` branch are cleaned-up, logically grouped versions of the development history, intended to make it easy for the upstream author to review and cherry-pick anything useful.
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/Donkie/Spoolman/assets/2332094/4e6e80ac-c7be-4ad2-9a33-dedc1b5ba30e">
<source media="(prefers-color-scheme: light)" srcset="https://github.com/Donkie/Spoolman/assets/2332094/3c120b3a-1422-42f6-a16b-8d5a07c33000">
Expand Down
5 changes: 4 additions & 1 deletion client/public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@
"spool_weight": "Empty Weight",
"location": "Location",
"lot_nr": "Lot Nr",
"filament_color_name": "Color",
"first_used": "First Used",
"last_used": "Last Used",
"registered": "Registered",
Expand Down Expand Up @@ -232,6 +233,7 @@
"settings_extruder_temp": "Extruder Temp",
"settings_bed_temp": "Bed Temp",
"color_hex": "Color",
"color_name": "Color",
"single_color": "Single",
"multi_color": "Multi",
"coaxial": "Coextruded",
Expand All @@ -246,7 +248,8 @@
"weight": "The filament weight of a full spool (net weight). This should not include the weight of the spool itself, only the filament. It is what is usually written on the packaging.",
"spool_weight": "The weight of an empty spool. Used to determine measured weight of a spool.",
"article_number": "E.g. EAN, UPC, etc.",
"multi_color_direction": "Filaments can have multiple colors in two ways: either through coextrusion, like dual-color filaments with consistent multi-colors, or through longitudinal color changes, like gradient filaments that shift colors along the spool."
"multi_color_direction": "Filaments can have multiple colors in two ways: either through coextrusion, like dual-color filaments with consistent multi-colors, or through longitudinal color changes, like gradient filaments that shift colors along the spool.",
"color_name": "Type a color name (e.g. \"Neon Green\") to set the color by name instead of the color picker."
},
"titles": {
"create": "Create Filament",
Expand Down
8 changes: 7 additions & 1 deletion client/src/components/column.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,13 @@ export function SortedColumn<Obj extends Entity>(props: BaseColumnProps<Obj>) {
});
}

export function TextColumn<Obj extends Entity>(props: BaseColumnProps<Obj>) {
return Column({
...props,
sorter: false,
});
}

export function RichColumn<Obj extends Entity>(
props: Omit<BaseColumnProps<Obj>, "transform"> & { transform?: (value: unknown) => string },
) {
Expand Down Expand Up @@ -340,7 +347,6 @@ export function SpoolIconColumn<Obj extends Entity>(props: SpoolIconColumnProps<
onCell: () => {
return {
style: {
paddingLeft: 0,
paddingTop: 0,
paddingBottom: 0,
},
Expand Down
52 changes: 52 additions & 0 deletions client/src/components/otherModels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,55 @@ export function useSpoolmanLocations(enabled: boolean = false) {
},
});
}

export function useSpoolmanColorNames(enabled: boolean = false) {
return useQuery<string[]>({
enabled: enabled,
queryKey: ["colorNames"],
queryFn: async () => {
const response = await fetch(getAPIURL() + "/filament/color-names");
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.json();
},
select: (data) => {
return data.sort();
},
});
}

export function useSpoolmanUsedColorNames(enabled: boolean = false) {
return useQuery<{ name: string; hex: string }[], unknown, ColumnFilterItem[]>({
enabled: enabled,
queryKey: ["usedColorNames"],
queryFn: async () => {
const response = await fetch(getAPIURL() + "/filament/used-colors");
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.json();
},
select: (data) => {
return data.map(({ name, hex }) => ({
text: (
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
<span
style={{
width: 16,
height: 16,
flexShrink: 0,
display: "inline-block",
backgroundColor: `#${hex}`,
border: "1px solid rgba(0,0,0,0.15)",
borderRadius: 2,
}}
/>
{name}
</span>
),
value: '"' + name + '"',
}));
},
});
}
58 changes: 56 additions & 2 deletions client/src/pages/filaments/create.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Create, useForm, useSelect } from "@refinedev/antd";
import { HttpError, IResourceComponentsProps, useInvalidate, useTranslate } from "@refinedev/core";
import { Button, ColorPicker, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
import { AutoComplete, Button, ColorPicker, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
import TextArea from "antd/es/input/TextArea";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
Expand All @@ -12,6 +12,7 @@ import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from ".
import { ExternalFilament } from "../../utils/queryExternalDB";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
import { getAPIURL } from "../../utils/url";
import { getOrCreateVendorFromExternal } from "../vendors/functions";
import { IVendor } from "../vendors/model";
import { IFilament, IFilamentParsedExtras } from "./model";
Expand All @@ -33,6 +34,14 @@ export const FilamentCreate = (props: IResourceComponentsProps & CreateOrClonePr
const [isImportExtOpen, setIsImportExtOpen] = useState(false);
const invalidate = useInvalidate();
const [colorType, setColorType] = useState<"single" | "multi">("single");
const [colorMap, setColorMap] = useState<Record<string, string>>({});

useEffect(() => {
fetch(getAPIURL() + "/filament/color-map")
.then((r) => r.json())
.then(setColorMap)
.catch(() => undefined);
}, []);

const { form, formProps, formLoading, onFinish, redirect } = useForm<
IFilament,
Expand Down Expand Up @@ -192,7 +201,52 @@ export const FilamentCreate = (props: IResourceComponentsProps & CreateOrClonePr
return e?.toHex();
}}
>
<ColorPicker format="hex" />
<ColorPicker
format="hex"
onChange={(color) => {
const hex = color.toHex();
if (!hex || Object.keys(colorMap).length === 0) return;
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
let best = "";
let bestDist = Infinity;
for (const [name, h] of Object.entries(colorMap)) {
const dr = r - parseInt(h.slice(0, 2), 16);
const dg = g - parseInt(h.slice(2, 4), 16);
const db = b - parseInt(h.slice(4, 6), 16);
const dist = dr * dr + dg * dg + db * db;
if (dist < bestDist) { bestDist = dist; best = name; }
}
form.setFieldValue("color_name", best);
}}
/>
</Form.Item>
)}
{colorType == "single" && (
<Form.Item
label={t("filament.fields.color_name")}
help={t("filament.fields_help.color_name")}
name={"color_name"}
rules={[{ required: false }]}
>
<AutoComplete
options={Object.entries(colorMap).map(([name, hex]) => ({
value: name,
label: (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ width: 16, height: 16, flexShrink: 0, backgroundColor: `#${hex}`, border: "1px solid rgba(0,0,0,0.15)", borderRadius: 2 }} />
{name}
</div>
),
}))}
filterOption={(input, option) => (option?.value ?? "").toLowerCase().includes(input.toLowerCase())}
onSelect={(name: string) => {
const hex = colorMap[name];
if (hex) form.setFieldValue("color_hex", hex);
}}
allowClear
/>
</Form.Item>
)}
{colorType == "multi" && (
Expand Down
59 changes: 56 additions & 3 deletions client/src/pages/filaments/edit.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Edit, useForm, useSelect } from "@refinedev/antd";
import { HttpError, useTranslate } from "@refinedev/core";
import { Alert, ColorPicker, DatePicker, Form, Input, InputNumber, message, Radio, Select, Typography } from "antd";
import { Alert, AutoComplete, ColorPicker, DatePicker, Form, Input, InputNumber, message, Radio, Select, Typography } from "antd";
import TextArea from "antd/es/input/TextArea";
import dayjs from "dayjs";
import { useEffect, useState } from "react";
Expand All @@ -9,6 +9,7 @@ import { MultiColorPicker } from "../../components/multiColorPicker";
import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from "../../utils/parsing";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
import { getAPIURL } from "../../utils/url";
import { IVendor } from "../vendors/model";
import { IFilament, IFilamentParsedExtras } from "./model";

Expand All @@ -26,8 +27,16 @@ export const FilamentEdit = () => {
const extraFields = useGetFields(EntityType.filament);
const currency = useCurrency();
const [colorType, setColorType] = useState<"single" | "multi">("single");
const [colorMap, setColorMap] = useState<Record<string, string>>({});

const { formProps, saveButtonProps } = useForm<IFilament, HttpError, IFilament, IFilament>({
useEffect(() => {
fetch(getAPIURL() + "/filament/color-map")
.then((r) => r.json())
.then(setColorMap)
.catch(() => undefined);
}, []);

const { form, formProps, saveButtonProps } = useForm<IFilament, HttpError, IFilament, IFilament>({
liveMode: "manual",
onLiveEvent() {
// Warn the user if the filament has been updated since the form was opened
Expand Down Expand Up @@ -170,7 +179,51 @@ export const FilamentEdit = () => {
return e?.toHex();
}}
>
<ColorPicker />
<ColorPicker
onChange={(color) => {
const hex = color.toHex();
if (!hex || Object.keys(colorMap).length === 0) return;
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
let best = "";
let bestDist = Infinity;
for (const [name, h] of Object.entries(colorMap)) {
const dr = r - parseInt(h.slice(0, 2), 16);
const dg = g - parseInt(h.slice(2, 4), 16);
const db = b - parseInt(h.slice(4, 6), 16);
const dist = dr * dr + dg * dg + db * db;
if (dist < bestDist) { bestDist = dist; best = name; }
}
form.setFieldValue("color_name", best);
}}
/>
</Form.Item>
)}
{colorType == "single" && (
<Form.Item
label={t("filament.fields.color_name")}
help={t("filament.fields_help.color_name")}
name={"color_name"}
rules={[{ required: false }]}
>
<AutoComplete
options={Object.entries(colorMap).map(([name, hex]) => ({
value: name,
label: (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ width: 16, height: 16, flexShrink: 0, backgroundColor: `#${hex}`, border: "1px solid rgba(0,0,0,0.15)", borderRadius: 2 }} />
{name}
</div>
),
}))}
filterOption={(input, option) => (option?.value ?? "").toLowerCase().includes(input.toLowerCase())}
onSelect={(name: string) => {
const hex = colorMap[name];
if (hex) form.setFieldValue("color_hex", hex);
}}
allowClear
/>
</Form.Item>
)}
{colorType == "multi" && (
Expand Down
Loading