Compare commits

...

3 Commits

Author SHA1 Message Date
267fadfc54 feat(color): add hex code display and click-to-copy functionality
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
Enhances SpoolIcon component with optional showHex prop:
- Displays hex color code next to swatch when enabled
- Click to copy hex code to clipboard
- Shows tooltip with copy hint
- Supports both single and multi-color filaments

Enabled on detail pages:
- Filament show page
- Spool show page

Added translation keys for copy feedback messages.

Closes #33

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:33:21 -06:00
48bd516c0f feat(filament): add spool count and total remaining weight columns
Adds computed spool statistics to filament list:
- spool_count: Number of non-archived spools of this filament
- total_remaining_weight: Sum of remaining weight across all spools

Backend changes:
- Modified database/filament.py find() to compute stats via subquery
- Added fields to Filament pydantic model in api/v1/models.py
- Updated filament API endpoint to include stats in response

Frontend changes:
- Added fields to IFilament interface
- Added columns to filament list table
- Added translation keys

Closes #15

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:31:47 -06:00
0a741c9712 feat(home): redesign dashboard with spool list and low stock alerts
Replaces stat card layout with functional dashboard:
- Prominent spool count with "Add Spool" button
- Paginated list of all spools sorted by last used
- Low stock alert badge (< 100g threshold)
- Color swatch, name, material, and remaining weight columns
- Clickable rows navigate to spool detail
- Secondary stats for filaments and vendors

Closes #12

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:27:14 -06:00
10 changed files with 339 additions and 107 deletions

View File

@@ -33,7 +33,8 @@
"no": "No",
"notAccessTitle": "You don't have permission to access",
"hideColumns": "Hide Columns",
"clearFilters": "Clear Filters"
"clearFilters": "Clear Filters",
"viewAll": "View All"
},
"warnWhenUnsavedChanges": "Are you sure you want to leave? You have unsaved changes.",
"notifications": {
@@ -51,6 +52,11 @@
"validationError": "Validation error: {{error}}"
},
"kofi": "Tip me on Ko-fi",
"color": {
"click_to_copy": "Click to copy",
"copied": "Copied {{color}} to clipboard",
"copy_failed": "Failed to copy to clipboard"
},
"loading": "Loading",
"version": "Version",
"unknown": "Unknown",
@@ -259,7 +265,9 @@
"longitudinal": "Longitudinal",
"external_id": "External ID",
"spools": "Show Spools",
"not_set": "Not Set"
"not_set": "Not Set",
"spool_count": "Spools",
"total_remaining_weight": "Total Remaining"
},
"fields_help": {
"name": "Filament name, to distinguish this filament type among others from the same manufacturer. Should contain the color for example.",
@@ -324,7 +332,10 @@
"home": {
"home": "Home",
"welcome": "Welcome to your Spoolman instance!",
"description": "It looks like you haven't added any spools yet. See the <helpPageLink>Help page</helpPageLink> for help getting started."
"description": "It looks like you haven't added any spools yet. See the <helpPageLink>Help page</helpPageLink> for help getting started.",
"all_spools": "All Spools",
"low_stock_alert": "{{count}} spools below {{threshold}}g",
"low_stock_warning": "Low stock"
},
"help": {
"help": "Help",

View File

@@ -1,16 +1,21 @@
import { CopyOutlined } from "@ant-design/icons";
import { message, Tooltip } from "antd";
import { useTranslate } from "@refinedev/core";
import "./spoolIcon.css";
interface Props {
color: string | { colors: string[]; vertical: boolean };
size?: "small" | "large";
no_margin? : boolean
no_margin?: boolean;
showHex?: boolean;
}
export default function SpoolIcon(props: Readonly<Props>) {
const t = useTranslate();
let dirClass = "vertical";
let cols = [];
let size = props.size ? props.size : "small";
let no_margin = props.no_margin ? "no-margin" : "";
let cols: string[] = [];
const size = props.size ? props.size : "small";
const no_margin = props.no_margin ? "no-margin" : "";
if (typeof props.color === "string") {
cols = [props.color];
@@ -19,16 +24,49 @@ export default function SpoolIcon(props: Readonly<Props>) {
cols = props.color.colors;
}
return (
// Normalize hex codes (remove # if present, uppercase)
const normalizedCols = cols.map((col) => col.replace("#", "").toUpperCase());
const hexDisplay = normalizedCols.length === 1 ? `#${normalizedCols[0]}` : normalizedCols.map((c) => `#${c}`).join(", ");
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(hexDisplay);
message.success(t("color.copied", { color: hexDisplay }));
} catch {
message.error(t("color.copy_failed"));
}
};
const swatchContent = (
<div className={"spool-icon " + dirClass + " " + size + " " + no_margin}>
{cols.map((col) => (
{normalizedCols.map((col) => (
<div
key={col}
style={{
backgroundColor: "#" + col.replace("#", ""),
backgroundColor: "#" + col,
}}
></div>
))}
</div>
);
if (props.showHex) {
return (
<Tooltip title={`${t("color.click_to_copy")}: ${hexDisplay}`}>
<div
className="spool-icon-with-hex"
onClick={copyToClipboard}
style={{ cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 8 }}
>
{swatchContent}
<span className="hex-code" style={{ fontFamily: "monospace", fontSize: "0.85em", opacity: 0.8 }}>
{hexDisplay}
<CopyOutlined style={{ marginLeft: 4, fontSize: "0.9em" }} />
</span>
</div>
</Tooltip>
);
}
return swatchContent;
}

View File

@@ -65,6 +65,8 @@ const allColumns: (keyof IFilamentCollapsed & string)[] = [
"article_number",
"settings_extruder_temp",
"settings_bed_temp",
"spool_count",
"total_remaining_weight",
"registered",
"comment",
];
@@ -324,6 +326,22 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
maxDecimals: 0,
width: 100,
}),
NumberColumn({
...commonProps,
id: "spool_count",
i18ncat: "filament",
unit: "",
maxDecimals: 0,
width: 80,
}),
NumberColumn({
...commonProps,
id: "total_remaining_weight",
i18ncat: "filament",
unit: "g",
maxDecimals: 0,
width: 120,
}),
DateColumn({
...commonProps,
id: "registered",

View File

@@ -24,6 +24,8 @@ export interface IFilament {
multi_color_direction?: string;
external_id?: string;
extra: { [key: string]: string };
spool_count?: number;
total_remaining_weight?: number;
}
// IFilamentParsedExtras is the same as IFilament, but with the extra field parsed into its real types

View File

@@ -88,8 +88,7 @@ export const FilamentShow: React.FC<IResourceComponentsProps> = () => {
<Title level={5}>{t("filament.fields.name")}</Title>
<TextField value={record?.name} />
<Title level={5}>{t("filament.fields.color_hex")}</Title>
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin />}
{record?.color_hex && <TextField value={`#${record?.color_hex}`} />}
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin showHex />}
<Title level={5}>{t("filament.fields.material")}</Title>
<TextField value={record?.material} />
<Title level={5}>{t("filament.fields.price")}</Title>

View File

@@ -1,129 +1,247 @@
import { FileOutlined, HighlightOutlined, PlusOutlined, UnorderedListOutlined, UserOutlined } from "@ant-design/icons";
import {
ExclamationCircleOutlined,
HighlightOutlined,
PlusOutlined,
UserOutlined,
} from "@ant-design/icons";
import { IResourceComponentsProps, useList, useTranslate } from "@refinedev/core";
import { Card, Col, Row, Statistic, theme } from "antd";
import { Badge, Button, Card, Col, Pagination, Row, Statistic, Table, theme, Tooltip } from "antd";
import { Content } from "antd/es/layout/layout";
import Title from "antd/es/typography/Title";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import React, { ReactNode } from "react";
import { Trans } from "react-i18next";
import { Link } from "react-router";
import Logo from "../../icon.svg?react";
import React, { useMemo, useState } from "react";
import { Link, useNavigate } from "react-router";
import SpoolIcon from "../../components/spoolIcon";
import { IFilament } from "../filaments/model";
import { ISpool } from "../spools/model";
import { IVendor } from "../vendors/model";
dayjs.extend(utc);
const { useToken } = theme;
const LOW_STOCK_THRESHOLD = 100; // grams
export const Home: React.FC<IResourceComponentsProps> = () => {
const { token } = useToken();
const t = useTranslate();
const navigate = useNavigate();
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 15;
// Fetch all spools (not archived) for the main list
const spools = useList<ISpool>({
resource: "spool",
pagination: { pageSize: 1 },
pagination: { current: currentPage, pageSize },
sorters: [{ field: "last_used", order: "desc" }],
meta: {
queryParams: {
allow_archived: false,
},
},
});
const filaments = useList<ISpool>({
// Fetch counts for stats
const filaments = useList<IFilament>({
resource: "filament",
pagination: { pageSize: 1 },
});
const vendors = useList<ISpool>({
const vendors = useList<IVendor>({
resource: "vendor",
pagination: { pageSize: 1 },
});
const hasSpools = !spools.result || spools.result.data.length > 0;
// Calculate low stock spools
const lowStockCount = useMemo(() => {
if (!spools.data?.data) return 0;
return spools.data.data.filter(
(spool) => spool.remaining_weight !== undefined && spool.remaining_weight < LOW_STOCK_THRESHOLD
).length;
}, [spools.data?.data]);
const ResourceStatsCard = (props: { loading: boolean; value: number; resource: string; icon: ReactNode }) => (
<Col xs={12} md={6}>
<Card
loading={props.loading}
hoverable
style={{ cursor: "pointer" }}
styles={{ body: { padding: 0 } }}
actions={[
<Link to={`/${props.resource}`} onClick={(e) => e.stopPropagation()}>
<UnorderedListOutlined />
</Link>,
<Link to={`/${props.resource}/create`} onClick={(e) => e.stopPropagation()}>
<PlusOutlined />
</Link>,
]}
>
<Link to={`/${props.resource}`} style={{ display: "block", padding: "24px", color: "inherit" }}>
<Statistic title={t(`${props.resource}.${props.resource}`)} value={props.value} prefix={props.icon} />
</Link>
</Card>
</Col>
// For accurate low stock count, we need all spools
const allSpoolsForLowStock = useList<ISpool>({
resource: "spool",
pagination: { pageSize: 1000 },
filters: [
{
field: "remaining_weight",
operator: "lt",
value: LOW_STOCK_THRESHOLD,
},
],
meta: {
queryParams: {
allow_archived: false,
},
},
});
const totalLowStock = allSpoolsForLowStock.data?.total ?? lowStockCount;
const spoolColumns = [
{
title: "",
dataIndex: "color",
key: "color",
width: 50,
render: (_: unknown, record: ISpool) => {
const colorObj = record.filament.multi_color_hexes
? {
colors: record.filament.multi_color_hexes.split(","),
vertical: record.filament.multi_color_direction === "longitudinal",
}
: record.filament.color_hex;
return colorObj ? <SpoolIcon color={colorObj} /> : null;
},
},
{
title: t("spool.fields.filament_name"),
dataIndex: "filament",
key: "filament",
render: (_: unknown, record: ISpool) => {
const vendorName = record.filament.vendor?.name;
const filamentName = record.filament.name ?? `Filament #${record.filament.id}`;
return vendorName ? `${vendorName} - ${filamentName}` : filamentName;
},
},
{
title: t("spool.fields.material"),
dataIndex: ["filament", "material"],
key: "material",
width: 100,
},
{
title: t("spool.fields.remaining_weight"),
dataIndex: "remaining_weight",
key: "remaining_weight",
width: 120,
align: "right" as const,
render: (weight: number | undefined, record: ISpool) => {
if (weight === undefined) return t("unknown");
const isLowStock = weight < LOW_STOCK_THRESHOLD;
return (
<span style={{ color: isLowStock ? token.colorError : undefined, fontWeight: isLowStock ? 600 : undefined }}>
{Math.round(weight)} g
{isLowStock && (
<Tooltip title={t("home.low_stock_warning")}>
<ExclamationCircleOutlined style={{ marginLeft: 6 }} />
</Tooltip>
)}
</span>
);
},
},
];
return (
<Content
style={{
padding: "2em 20px",
padding: "1.5em 20px",
minHeight: 280,
maxWidth: 800,
maxWidth: 900,
margin: "0 auto",
backgroundColor: token.colorBgContainer,
borderRadius: token.borderRadiusLG,
color: token.colorText,
fontFamily: token.fontFamily,
fontSize: token.fontSizeLG,
lineHeight: 1.5,
}}
>
<Title
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: token.fontSizeHeading1,
}}
>
<div
style={{
display: "inline-block",
height: "1.5em",
marginRight: "0.5em",
}}
>
<Logo />
</div>
Spoolman
</Title>
<Row justify="center" gutter={[16, 16]} style={{ marginTop: "3em" }}>
<ResourceStatsCard
resource="spool"
value={spools.result?.total || 0}
loading={spools.query.isLoading}
icon={<FileOutlined />}
/>
<ResourceStatsCard
resource="filament"
value={filaments.result?.total || 0}
loading={filaments.query.isLoading}
icon={<HighlightOutlined />}
/>
<ResourceStatsCard
resource="vendor"
value={vendors.result?.total || 0}
loading={vendors.query.isLoading}
icon={<UserOutlined />}
{/* Header with spool count and add button */}
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
<Col>
<Statistic
title={t("spool.spool")}
value={spools.data?.total ?? 0}
loading={spools.isLoading}
valueStyle={{ fontSize: 36, fontWeight: 600 }}
/>
</Col>
<Col>
<Button type="primary" size="large" icon={<PlusOutlined />} onClick={() => navigate("/spool/create")}>
{t("spool.titles.create")}
</Button>
</Col>
</Row>
{/* Alert badges */}
{totalLowStock > 0 && (
<Row style={{ marginBottom: 16 }}>
<Col>
<Link to="/spool?remaining_weight_lt=100">
<Badge
count={totalLowStock}
style={{ backgroundColor: token.colorError }}
overflowCount={99}
>
<Card size="small" hoverable style={{ cursor: "pointer" }}>
<ExclamationCircleOutlined style={{ color: token.colorError, marginRight: 8 }} />
{t("home.low_stock_alert", { count: totalLowStock, threshold: LOW_STOCK_THRESHOLD })}
</Card>
</Badge>
</Link>
</Col>
</Row>
{!hasSpools && (
<>
<p style={{ marginTop: 32 }}>{t("home.welcome")}</p>
<p>
<Trans
i18nKey="home.description"
components={{
helpPageLink: <Link to="/help" />,
}}
/>
</p>
</>
)}
{/* Spool list */}
<Card
title={t("home.all_spools")}
extra={<Link to="/spool">{t("buttons.viewAll")}</Link>}
styles={{ body: { padding: 0 } }}
>
<Table
dataSource={spools.data?.data}
columns={spoolColumns}
rowKey="id"
loading={spools.isLoading}
pagination={false}
size="small"
onRow={(record) => ({
onClick: () => navigate(`/spool/show/${record.id}`),
style: { cursor: "pointer" },
})}
/>
<div style={{ padding: "12px 16px", borderTop: `1px solid ${token.colorBorderSecondary}` }}>
<Pagination
current={currentPage}
pageSize={pageSize}
total={spools.data?.total ?? 0}
onChange={setCurrentPage}
showSizeChanger={false}
size="small"
/>
</div>
</Card>
{/* Secondary stats */}
<Row gutter={16} style={{ marginTop: 16 }}>
<Col xs={12}>
<Link to="/filament">
<Card hoverable size="small">
<Statistic
title={t("filament.filament")}
value={filaments.data?.total ?? 0}
loading={filaments.isLoading}
prefix={<HighlightOutlined />}
/>
</Card>
</Link>
</Col>
<Col xs={12}>
<Link to="/vendor">
<Card hoverable size="small">
<Statistic
title={t("vendor.vendor")}
value={vendors.data?.total ?? 0}
loading={vendors.isLoading}
prefix={<UserOutlined />}
/>
</Card>
</Link>
</Col>
</Row>
</Content>
);
};

View File

@@ -163,7 +163,7 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
<Title level={5}>{t("spool.fields.id")}</Title>
<NumberField value={record?.id ?? ""} />
<Title level={5}>{t("spool.fields.filament")}</Title>
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin />}
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin showHex />}
<TextField value={record ? filamentURL(record?.filament) : ""} />
<Title level={5}>{t("spool.fields.price")}</Title>
<TextField value={spoolPrice(record)} />

View File

@@ -366,7 +366,7 @@ async def find(
else:
filter_by_ids = None
db_items, total_count = await filament.find(
db_items, total_count, spool_stats = await filament.find(
db=db,
ids=filter_by_ids,
vendor_name=vendor_name if vendor_name is not None else vendor_name_old,
@@ -383,7 +383,14 @@ async def find(
# Set x-total-count header for pagination
return JSONResponse(
content=jsonable_encoder(
(Filament.from_db(db_item) for db_item in db_items),
(
Filament.from_db(
db_item,
spool_count=spool_stats.get(db_item.id, (0, 0))[0],
total_remaining_weight=spool_stats.get(db_item.id, (0, 0))[1],
)
for db_item in db_items
),
exclude_none=True,
),
headers={"x-total-count": str(total_count)},

View File

@@ -221,9 +221,25 @@ class Filament(BaseModel):
"Query the /fields endpoint for more details about the fields."
),
)
spool_count: Optional[int] = Field(
None,
ge=0,
description="Number of spools of this filament (excluding archived). Computed field.",
examples=[5],
)
total_remaining_weight: Optional[float] = Field(
None,
ge=0,
description="Total remaining weight across all spools of this filament (excluding archived), in grams. Computed field.",
examples=[2500.0],
)
@staticmethod
def from_db(item: models.Filament) -> "Filament":
def from_db(
item: models.Filament,
spool_count: Optional[int] = None,
total_remaining_weight: Optional[float] = None,
) -> "Filament":
"""Create a new Pydantic filament object from a database filament object."""
return Filament(
id=item.id,
@@ -251,6 +267,8 @@ class Filament(BaseModel):
),
external_id=item.external_id,
extra={field.key: field.value for field in item.extra},
spool_count=spool_count,
total_remaining_weight=total_remaining_weight,
)

View File

@@ -114,13 +114,16 @@ async def find(
sort_by: Optional[dict[str, SortOrder]] = None,
limit: Optional[int] = None,
offset: int = 0,
) -> tuple[list[models.Filament], int]:
) -> tuple[list[models.Filament], int, dict[int, tuple[int, float]]]:
"""Find a list of filament objects by search criteria.
Sort by a field by passing a dict with the field name as key and the sort order as value.
The field name can contain nested fields, e.g. vendor.name.
Returns a tuple containing the list of items and the total count of matching items.
Returns a tuple containing:
- list of filaments
- total count of matching items
- dict mapping filament_id to (spool_count, total_remaining_weight)
"""
stmt = (
select(models.Filament)
@@ -160,7 +163,25 @@ async def find(
if total_count is None:
total_count = len(result)
return result, total_count
# Fetch spool statistics for all filaments in the result
spool_stats: dict[int, tuple[int, float]] = {}
if result:
filament_ids = [f.id for f in result]
stats_stmt = (
select(
models.Spool.filament_id,
func.count(models.Spool.id).label("spool_count"),
func.coalesce(func.sum(models.Spool.initial_weight - models.Spool.used_weight), 0).label("total_remaining"),
)
.where(models.Spool.filament_id.in_(filament_ids))
.where((models.Spool.archived == False) | (models.Spool.archived == None)) # noqa: E712, E711
.group_by(models.Spool.filament_id)
)
stats_rows = await db.execute(stats_stmt)
for row in stats_rows.all():
spool_stats[row.filament_id] = (row.spool_count, float(row.total_remaining or 0))
return result, total_count, spool_stats
async def update(