Merge branch 'locationspage' into master

This commit is contained in:
Donkie
2024-11-24 15:05:01 +01:00
22 changed files with 2423 additions and 875 deletions

2205
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,49 +7,53 @@
"private": true,
"type": "module",
"dependencies": {
"@ant-design/icons": "^5.4.0",
"@ant-design/icons": "^5.5.1",
"@loadable/component": "^5.16.4",
"@refinedev/antd": "^5.42.0",
"@refinedev/core": "^4.53.0",
"@refinedev/antd": "^5.44.0",
"@refinedev/core": "^4.56.0",
"@refinedev/kbar": "^1.3.12",
"@refinedev/react-router-v6": "^4.5.11",
"@refinedev/react-router-v6": "^4.6.0",
"@refinedev/simple-rest": "^5.0.8",
"@tanstack/react-query": "^4.36.1",
"@tanstack/react-query-devtools": "^4.36.1",
"@types/loadable__component": "^5.13.9",
"@types/lodash": "^4.17.7",
"@types/lodash": "^4.17.13",
"@types/uuid": "^10.0.0",
"@yudiel/react-qr-scanner": "^1.2.10",
"antd": "^5.20.0",
"antd": "^5.22.1",
"axios": "^1.7.7",
"flag-icons": "^7.2.3",
"html-to-image": "^1.11.11",
"i18next": "^23.12.2",
"i18next": "^23.16.6",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-http-backend": "^2.5.2",
"i18next-http-backend": "^2.6.2",
"lodash": "^4.17.21",
"react": "^18.3.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.0.0",
"react-router-dom": "^6.26.0",
"react-i18next": "^15.1.1",
"react-router-dom": "^6.28.0",
"react-to-print": "^2.15.1",
"uuid": "^10.0.0",
"vite-plugin-svgr": "^4.2.0"
"uuid": "^11.0.3",
"vite-plugin-svgr": "^4.3.0",
"zustand": "^5.0.1"
},
"devDependencies": {
"@refinedev/cli": "^2.16.36",
"@types/node": "^20.14.6",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitejs/plugin-react": "^4.3.1",
"@refinedev/cli": "^2.16.39",
"@simbathesailor/use-what-changed": "^2.0.0",
"@types/node": "^22.9.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0",
"@vitejs/plugin-react": "^4.3.3",
"eslint": "^9.8.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.9",
"typescript": "^5.5.4",
"vite": "^5.4.6",
"vite-plugin-mkcert": "^1.17.5"
"typescript": "^5.6.3",
"vite": "^5.4.11",
"vite-plugin-mkcert": "^1.17.6"
},
"scripts": {
"dev": "refine dev",

View File

@@ -208,6 +208,9 @@
},
"messages": {
"archive": "Are you sure you want to archive this spool?"
},
"formats": {
"last_used": "Last used {{date}}"
}
},
"filament": {
@@ -356,6 +359,9 @@
"help": {
"list": "Help | Spoolman"
},
"locations": {
"list": "Locations | Spoolman"
},
"filament": {
"list": "Filaments | Spoolman",
"show": "#{{id}} Show Filament | Spoolman",
@@ -377,5 +383,11 @@
"create": "Create Manufacturer | Spoolman",
"clone": "#{{id}} Clone Manufacturer | Spoolman"
}
},
"locations": {
"locations": "Locations",
"new_location": "New Location",
"no_location": "No Location",
"no_locations_help": "This page lets you organize your spools in locations, add some spools to get started!"
}
}

View File

@@ -210,6 +210,9 @@
"measurement_type_label": "Mättyp",
"adjust_filament_value": "Mängd att konsumera",
"adjust_filament_help": "Här kan du direkt lägga till eller subtrahera filament från spolen. Ett positivt värde kommer att konsumera filament, ett negativt värde kommer att lägga till det."
},
"formats": {
"last_used": "Senast använd {{date}}"
}
},
"filament": {
@@ -376,5 +379,9 @@
"description": "<title>Hjälp</title><p>Här är några tips för att komma igång.</p><p>Spoolman innehåller 3 olika typer av data:</p><itemsHelp/><p>För att lägga till en ny spole i databasen måste du först skapa ett <filamentCreateLink>Filament</filamentCreateLink>-objekt för den. När det är klart kan du sedan skapa ett <spoolCreateLink>Spol</spoolCreateLink>-objekt för den individuella spolen. Om du sedan köper ytterligare spolar av samma filament kan du bara skapa ytterligare Spol-objekt och återanvända samma Filament-objekt.</p><p>Du kan valfritt också skapa ett <vendorCreateLink>Tillverkar</vendorCreateLink>-objekt för företaget som tillverkar filamentet om du vill spåra den informationen.</p><p>Du kan koppla andra 3D-skrivartjänster till Spoolman, som Moonraker, som sedan automatiskt kan spåra filamentanvändning och uppdatera Spol-objekten åt dig. Se <readmeLink>Spoolman README</readmeLink> för hur du gör det.</p>",
"help": "Hjälp"
},
"kofi": "Dricksa mig på Ko-fi"
"kofi": "Dricksa mig på Ko-fi",
"locations": {
"new_location": "Ny plats",
"no_location": "Ingen plats"
}
}

View File

@@ -10,6 +10,7 @@ import {
HighlightOutlined,
HomeOutlined,
QuestionOutlined,
TableOutlined,
ToolOutlined,
UserOutlined,
} from "@ant-design/icons";
@@ -148,6 +149,14 @@ function App() {
icon: <UserOutlined />,
},
},
{
name: "locations",
list: "/locations",
meta: {
canDelete: false,
icon: <TableOutlined />,
},
},
{
name: "settings",
list: "/settings",
@@ -222,6 +231,7 @@ function App() {
</Route>
<Route path="/settings/*" element={<LoadablePage name="settings" />} />
<Route path="/help" element={<LoadablePage name="help" />} />
<Route path="/locations" element={<LoadablePage name="locations" />} />
<Route path="*" element={<ErrorComponent />} />
</Route>
</Routes>

View File

@@ -61,8 +61,6 @@ const dataProvider = (
params: queryParams,
});
// console.log(url, requestMethod, queryParams, data, headers)
return {
data,
total: parseInt(headers["x-total-count"]) ?? 100,

View File

@@ -1,3 +1,4 @@
import dayjs from "dayjs";
import i18n from "i18next";
import detector from "i18next-browser-languagedetector";
import Backend from "i18next-http-backend";
@@ -8,6 +9,7 @@ interface Language {
name: string;
countryCode: string;
fullCode: string;
djs: () => Promise<ILocale>;
}
/**
@@ -16,107 +18,128 @@ interface Language {
* name: Name of the language in the list
* countryCode: Country code of the country's flag to display for this language
* fullCode: Full language code, used for Ant Design's locale
* djs: Function to load the dayjs locale, see https://github.com/iamkun/dayjs/tree/dev/src/locale for list of locales
*/
export const languages: { [key: string]: Language } = {
["en"]: {
name: "English",
countryCode: "gb",
fullCode: "en-GB",
djs: () => import("dayjs/locale/en"),
},
["sv"]: {
name: "Svenska",
countryCode: "se",
fullCode: "sv-SE",
djs: () => import("dayjs/locale/sv"),
},
["de"]: {
name: "Deutsch",
countryCode: "de",
fullCode: "de-DE",
djs: () => import("dayjs/locale/de"),
},
["es"]: {
name: "Español",
countryCode: "es",
fullCode: "es-ES",
djs: () => import("dayjs/locale/es"),
},
["zh"]: {
name: "简体中文",
countryCode: "cn",
fullCode: "zh-CN",
djs: () => import("dayjs/locale/zh-cn"),
},
["zh-Hant"]: {
name: "繁體中文",
countryCode: "cn",
fullCode: "zh-TW",
djs: () => import("dayjs/locale/zh-hk"),
},
["pl"]: {
name: "Polski",
countryCode: "pl",
fullCode: "pl-PL",
djs: () => import("dayjs/locale/pl"),
},
["ru"]: {
name: "Русский",
countryCode: "ru",
fullCode: "ru-RU",
djs: () => import("dayjs/locale/ru"),
},
["cs"]: {
name: "Česky",
countryCode: "cz",
fullCode: "cs-CZ",
djs: () => import("dayjs/locale/cs"),
},
["nb-NO"]: {
name: "Norsk bokmål",
countryCode: "no",
fullCode: "nb-NO",
djs: () => import("dayjs/locale/nb"),
},
["nl"]: {
name: "Nederlands",
countryCode: "nl",
fullCode: "nl-NL",
djs: () => import("dayjs/locale/nl"),
},
["fr"]: {
name: "Français",
countryCode: "fr",
fullCode: "fr-FR",
djs: () => import("dayjs/locale/fr"),
},
["hu"]: {
name: "Magyar",
countryCode: "hu",
fullCode: "hu-HU",
djs: () => import("dayjs/locale/hu"),
},
["it"]: {
name: "Italiano",
countryCode: "it",
fullCode: "it-IT",
djs: () => import("dayjs/locale/it"),
},
["uk"]: {
name: "Українська",
countryCode: "ua",
fullCode: "uk-UA",
djs: () => import("dayjs/locale/uk"),
},
["el"]: {
name: "Ελληνικά",
countryCode: "gr",
fullCode: "el-GR",
djs: () => import("dayjs/locale/el"),
},
["da"]: {
name: "Dansk",
countryCode: "dk",
fullCode: "da-DK",
djs: () => import("dayjs/locale/da"),
},
["pt"]: {
name: "Português",
countryCode: "pt",
fullCode: "pt-PT",
djs: () => import("dayjs/locale/pt"),
},
["fa"]: {
name: "فارسی",
countryCode: "ir",
fullCode: "fa-IR",
djs: () => import("dayjs/locale/fa"),
},
["ro"]: {
name: "Român",
countryCode: "ro",
fullCode: "ro-RO",
djs: () => import("dayjs/locale/ro"),
},
};
@@ -134,4 +157,8 @@ i18n
fallbackLng: "en",
});
i18n.on("languageChanged", function (lng) {
languages[lng].djs().then((djs) => dayjs.locale(djs.name));
});
export default i18n;

View File

@@ -52,7 +52,6 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
// Update colorType state
useEffect(() => {
console.log(formProps.initialValues?.multi_color_hexes);
if (formProps.initialValues?.multi_color_hexes) {
setColorType("multi");
} else {
@@ -325,13 +324,13 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
<Input maxLength={64} />
</Form.Item>
<Form.Item
label={t("filament.fields.external_id")}
name={["external_id"]}
rules={[
{
required: false,
},
]}
label={t("filament.fields.external_id")}
name={["external_id"]}
rules={[
{
required: false,
},
]}
>
<Input maxLength={64} />
</Form.Item>

View File

@@ -0,0 +1,181 @@
import { Button, Input, theme } from "antd";
import type { Identifier, XYCoord } from "dnd-core";
import { useRef, useState } from "react";
import { useDrag, useDrop } from "react-dnd";
import { DeleteOutlined } from "@ant-design/icons";
import { useTranslate, useUpdate } from "@refinedev/core";
import { ISpool } from "../../spools/model";
import { DragItem, ItemTypes, SpoolDragItem } from "../dnd";
import { EMPTYLOC } from "../functions";
import { SpoolList } from "./spoolList";
const { useToken } = theme;
export function Location({
index,
title,
spools,
showDelete,
onDelete,
moveLocation,
onEditTitle,
locationSpoolOrder,
setLocationSpoolOrder,
}: {
index: number;
title: string;
spools: ISpool[];
showDelete?: boolean;
onDelete?: () => void;
moveLocation: (dragIndex: number, hoverIndex: number) => void;
onEditTitle: (newTitle: string) => void;
locationSpoolOrder: number[];
setLocationSpoolOrder: (spoolOrder: number[]) => void;
}) {
const { token } = useToken();
const t = useTranslate();
const [editTitle, setEditTitle] = useState(false);
const [newTitle, setNewTitle] = useState(title);
const { mutate: updateSpool } = useUpdate({
resource: "spool",
mutationMode: "optimistic",
successNotification: false,
});
const moveSpoolLocation = (spool_id: number, location: string) => {
updateSpool({
id: spool_id,
values: {
location: location,
},
});
};
const dropTypes = title == EMPTYLOC ? [ItemTypes.SPOOL] : [ItemTypes.CONTAINER, ItemTypes.SPOOL];
const ref = useRef<HTMLDivElement>(null);
const [{ handlerId }, drop] = useDrop<DragItem, void, { handlerId: Identifier | null }>({
accept: dropTypes,
collect(monitor) {
return {
handlerId: monitor.getHandlerId(),
};
},
hover(item, monitor) {
if (!ref.current) {
return null;
}
if ("spool" in item) {
// Only allow dropping spools on the container if it's empty.
if (spools.length > 0) {
return null;
}
const spoolitem = item as SpoolDragItem;
if (spoolitem.spool.location !== title) {
moveSpoolLocation(spoolitem.spool.id, title);
spoolitem.spool.location = title;
}
return null;
}
const dragIndex = item.index;
const hoverIndex = index;
// Don't replace items with themselves
if (dragIndex === hoverIndex) {
return;
}
// Determine rectangle on screen
const hoverBoundingRect = ref.current?.getBoundingClientRect();
// Get horizontal middle
const hoverMiddleX = (hoverBoundingRect.right - hoverBoundingRect.left) / 2;
// Determine mouse position
const clientOffset = monitor.getClientOffset();
// Get pixels to the left
const hoverClientX = (clientOffset as XYCoord).x - hoverBoundingRect.left;
// Dragging downwards
if (dragIndex < hoverIndex && hoverClientX < hoverMiddleX) {
return;
}
// Dragging upwards
if (dragIndex > hoverIndex && hoverClientX > hoverMiddleX) {
return;
}
// Time to actually perform the action
moveLocation(dragIndex, hoverIndex);
item.index = hoverIndex;
},
});
const [{ isDragging }, drag] = useDrag({
type: ItemTypes.CONTAINER,
canDrag: !editTitle && title != EMPTYLOC,
item: () => {
return { title, index };
},
collect: (monitor: any) => ({
isDragging: monitor.isDragging(),
}),
});
const displayTitle = title == EMPTYLOC ? t("locations.no_location") : title;
const opacity = isDragging ? 0 : 1;
drag(drop(ref));
const canEditTitle = title != EMPTYLOC;
const titleStyle = {
color: canEditTitle ? undefined : token.colorTextTertiary,
};
return (
<div
className={"loc-container " + (title != EMPTYLOC ? "grabable" : "")}
ref={ref}
style={{ opacity }}
data-handler-id={handlerId}
>
<h3>
{editTitle ? (
<Input
autoFocus
variant="borderless"
value={newTitle}
onBlur={() => setEditTitle(false)}
onChange={(e) => setNewTitle(e.target.value)}
onPressEnter={() => {
setEditTitle(false);
return onEditTitle(newTitle);
}}
/>
) : (
<span
className={canEditTitle ? "editable" : ""}
onClick={() => {
if (!canEditTitle) return;
setNewTitle(title);
setEditTitle(true);
}}
style={titleStyle}
>
{displayTitle}
</span>
)}
{showDelete && <Button icon={<DeleteOutlined />} size="small" type="text" onClick={onDelete} />}
</h3>
<SpoolList spools={spools} spoolOrder={locationSpoolOrder} setSpoolOrder={setLocationSpoolOrder} />
</div>
);
}

View File

@@ -0,0 +1,200 @@
import { PlusOutlined } from "@ant-design/icons";
import { useList, useTranslate } from "@refinedev/core";
import { Button } from "antd";
import { useEffect, useMemo } from "react";
import { useSetSetting } from "../../../utils/querySettings";
import { ISpool } from "../../spools/model";
import { EMPTYLOC, useLocations, useLocationsSpoolOrders, useRenameSpoolLocation } from "../functions";
import { Location } from "./location";
export function LocationContainer() {
const t = useTranslate();
const renameSpoolLocation = useRenameSpoolLocation();
const settingsLocations = useLocations();
const setLocationsSetting = useSetSetting<string[]>("locations");
const locationsSpoolOrders = useLocationsSpoolOrders();
const setLocationsSpoolOrders = useSetSetting<Record<string, number[]>>("locations_spoolorders");
const {
data: spoolData,
isLoading,
isError,
} = useList<ISpool>({
resource: "spool",
meta: {
queryParams: {
["allow_archived"]: false,
},
},
pagination: {
mode: "off",
},
});
// Group spools by location
const spoolLocations = (() => {
const spools = spoolData?.data ?? [];
spools.sort((a, b) => a.id - b.id);
const grouped: Record<string, ISpool[]> = {};
spools.forEach((spool) => {
const loc = spool.location ?? EMPTYLOC;
if (!grouped[loc]) {
grouped[loc] = [];
}
grouped[loc].push(spool);
});
// Sort spools in the locations by the spool order
for (const loc of Object.keys(grouped)) {
if (!locationsSpoolOrders[loc]) {
continue;
}
grouped[loc].sort((a, b) => {
let aidx = locationsSpoolOrders[loc].indexOf(a.id);
if (aidx === -1) {
aidx = 999999;
}
let bidx = locationsSpoolOrders[loc].indexOf(b.id);
if (bidx === -1) {
bidx = 999999;
}
return aidx - bidx;
});
}
return grouped;
})();
// Create list of locations that's sorted
const locationsList = useMemo(() => {
// Start with the default loc
let allLocs = [];
if (EMPTYLOC in spoolLocations) {
allLocs.push(EMPTYLOC);
}
// Add from the locations setting
if (settingsLocations) allLocs.push(...settingsLocations);
// Add any missing locations from the spools
for (const loc of Object.keys(spoolLocations)) {
if (loc != EMPTYLOC && !allLocs.includes(loc)) {
allLocs.push(loc);
}
}
return allLocs;
}, [spoolLocations, settingsLocations]);
const moveLocation = (dragIndex: number, hoverIndex: number) => {
const newLocs = [...locationsList];
newLocs.splice(dragIndex, 1);
newLocs.splice(hoverIndex, 0, locationsList[dragIndex]);
setLocationsSetting.mutate(newLocs.filter((loc) => loc != EMPTYLOC));
};
const onEditTitle = async (location: string, newTitle: string) => {
if (location == "") return; // Can't edit the default location
if (newTitle == location) return; // No change
if (newTitle == "") return; // Can't have an empty location
if (locationsList.includes(newTitle)) return; // Location already exists
// Update all spool locations in the database
if (spoolLocations[location] && spoolLocations[location].length > 0) {
renameSpoolLocation.mutate({ old: location, new: newTitle });
}
// Update the value in the settings
const newLocs = [...locationsList].filter((loc) => loc != EMPTYLOC);
newLocs[locationsList.indexOf(location)] = newTitle;
setLocationsSetting.mutate(newLocs);
};
const setLocationSpoolOrder = (location: string, spoolOrder: number[]) => {
setLocationsSpoolOrders.mutate({
...locationsSpoolOrders,
[location]: spoolOrder,
});
};
// Create containers
const containers = locationsList.map((loc, idx) => {
const spools = spoolLocations[loc] ?? [];
const spoolOrder = locationsSpoolOrders[loc] ?? [];
return (
<Location
key={loc}
index={idx}
title={loc}
spools={spools}
showDelete={spools.length == 0}
onDelete={() => {
setLocationsSetting.mutate(locationsList.filter((l) => l !== loc));
}}
moveLocation={moveLocation}
onEditTitle={(newTitle: string) => onEditTitle(loc, newTitle)}
locationSpoolOrder={spoolOrder}
setLocationSpoolOrder={(spoolOrder: number[]) => setLocationSpoolOrder(loc, spoolOrder)}
/>
);
});
// Update locations settings so it always includes all spool locations
useEffect(() => {
// Check if they're not the same
const curLocList = locationsList.filter((l) => l != EMPTYLOC);
if (settingsLocations != null && JSON.stringify(curLocList) !== JSON.stringify(settingsLocations)) {
setLocationsSetting.mutate(curLocList);
}
}, [locationsList, settingsLocations, setLocationsSetting]);
if (isLoading) {
return <div>Loading...</div>;
}
if (isError) {
return <div>Failed to load spools</div>;
}
const addNewLocation = () => {
const baseLocationName = t("locations.new_location");
let newLocationName = baseLocationName;
const newLocs = [...locationsList];
let i = 1;
while (newLocs.includes(newLocationName)) {
newLocationName = baseLocationName + " " + i;
i++;
}
newLocs.push(newLocationName);
setLocationsSetting.mutate(newLocs);
};
return (
<div>
{!isLoading && spoolData.data.length == 0 && (
<div className="no-locations">{t("locations.no_locations_help")}</div>
)}
<div className="loc-metacontainer">
{containers}
<div className="newLocContainer">
<Button
type="dashed"
shape="circle"
icon={<PlusOutlined />}
size="large"
style={{
margin: "1em",
}}
onClick={addNewLocation}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,186 @@
import { useNavigation, useTranslate, useUpdate } from "@refinedev/core";
import { Button, theme } from "antd";
import type { Identifier, XYCoord } from "dnd-core";
import { useDrag, useDrop } from "react-dnd";
import { EditOutlined, EyeOutlined } from "@ant-design/icons";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import utc from "dayjs/plugin/utc";
import { useEffect, useRef } from "react";
import { Link } from "react-router-dom";
import SpoolIcon from "../../../components/spoolIcon";
import { ISpool } from "../../spools/model";
import { ItemTypes, SpoolDragItem, useCurrentDraggedSpool } from "../dnd";
dayjs.extend(utc);
dayjs.extend(relativeTime);
const { useToken } = theme;
export function SpoolCard({
index,
spool,
moveSpoolOrder,
}: {
index: number;
spool: ISpool;
moveSpoolOrder: (dragIndex: number, hoverIndex: number) => void;
}) {
const { token } = useToken();
const t = useTranslate();
const { showUrl } = useNavigation();
// Using a global state for this, because the drag handlers are reset when the spool changes location
const { draggedSpoolId, setDraggedSpoolId } = useCurrentDraggedSpool();
const { mutate: updateSpool } = useUpdate({
resource: "spool",
mutationMode: "optimistic",
successNotification: false,
});
const moveSpoolLocation = (spool_id: number, location: string) => {
updateSpool({
id: spool_id,
values: {
location: location,
},
});
};
const ref = useRef<HTMLDivElement>(null);
const [{ handlerId }, drop] = useDrop<SpoolDragItem, void, { handlerId: Identifier | null }>({
accept: ItemTypes.SPOOL,
collect(monitor) {
return {
handlerId: monitor.getHandlerId(),
};
},
hover(item, monitor) {
if (!ref.current || item.spool.id === spool.id) {
return null;
}
if (item.spool.location !== spool.location && spool.location) {
moveSpoolLocation(item.spool.id, spool.location);
item.spool.location = spool.location;
return;
}
const dragIndex = item.index;
const hoverIndex = index;
// Determine rectangle on screen
const hoverBoundingRect = ref.current?.getBoundingClientRect();
// Get horizontal middle
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
// Determine mouse position
const clientOffset = monitor.getClientOffset();
// Get pixels to the top
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
// Dragging downwards
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
return;
}
// Dragging upwards
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
return;
}
// Time to actually perform the action
moveSpoolOrder(item.spool.id, hoverIndex);
item.index = hoverIndex;
},
});
const [{ isDragging }, drag] = useDrag({
type: ItemTypes.SPOOL,
item: () => {
return { spool, index };
},
collect: (monitor: any) => ({
isDragging: monitor.isDragging(),
}),
end() {
setDraggedSpoolId(-1);
},
});
useEffect(() => {
if (isDragging) {
setDraggedSpoolId(spool.id);
}
}, [isDragging]);
const colorObj = spool.filament.multi_color_hexes
? {
colors: spool.filament.multi_color_hexes.split(","),
vertical: spool.filament.multi_color_direction === "longitudinal",
}
: spool.filament.color_hex || "#000000";
let filament_name: string;
if (spool.filament.vendor && "name" in spool.filament.vendor) {
filament_name = `${spool.filament.vendor.name} - ${spool.filament.name}`;
} else {
filament_name = spool.filament.name ?? spool.filament.id.toString();
}
const opacity = draggedSpoolId === spool.id ? 0 : 1;
const style = {
opacity,
backgroundColor: token.colorBgContainerDisabled,
};
drag(drop(ref));
function formatSubtitle(spool: ISpool) {
let str = "";
if (spool.filament.material) str += spool.filament.material + " - ";
if (spool.filament.weight) {
const remaining_weight = spool.remaining_weight ?? spool.filament.weight;
str += `${remaining_weight} / ${spool.filament.weight} g`;
}
if (spool.last_used) {
// Format like "last used X time ago"
const dt = dayjs(spool.last_used);
str += ` - ${t("spool.formats.last_used", { date: dt.fromNow() })}`;
}
return str;
}
return (
<div className="spool" ref={ref} style={style} data-handler-id={handlerId}>
<SpoolIcon color={colorObj} />
<div className="info">
<div className="title">
<span>
#{spool.id} {filament_name}
</span>
<div>
<Link to={`/spool/edit/${spool.id}?return=` + encodeURIComponent(window.location.pathname)}>
<Button icon={<EditOutlined />} title={t("buttons.edit")} size="small" type="text" />
</Link>
<Link to={showUrl("spool", spool.id)}>
<Button icon={<EyeOutlined />} title={t("buttons.show")} size="small" type="text" />
</Link>
</div>
</div>
<div
className="subtitle"
style={{
color: token.colorTextSecondary,
}}
>
{formatSubtitle(spool)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { theme } from "antd";
import { ISpool } from "../../spools/model";
import { SpoolCard } from "./spoolCard";
const { useToken } = theme;
export function SpoolList({
spools,
spoolOrder,
setSpoolOrder,
}: {
spools: ISpool[];
spoolOrder: number[];
setSpoolOrder: (spoolOrder: number[]) => void;
}) {
const { token } = useToken();
// Make sure all spools are in the spoolOrders array
const finalSpoolOrder = [...spoolOrder].filter((id) => spools.find((spool) => spool.id === id)); // Remove any spools that are not in the spools array
spools.forEach((spool) => {
if (!finalSpoolOrder.includes(spool.id)) finalSpoolOrder.push(spool.id);
});
const moveSpoolOrder = (spool_id: number, hoverIndex: number) => {
// Move spool spool_id to position hoverIndex
let curIdx = finalSpoolOrder.indexOf(spool_id);
if (curIdx === -1) {
// Spool is missing from spool order array, add it to the end of the array
finalSpoolOrder.push(spool_id);
curIdx = finalSpoolOrder.length - 1;
} else if (curIdx === hoverIndex) {
// Spool is already in the right position
return;
}
const newSpoolOrder = [...finalSpoolOrder];
newSpoolOrder.splice(curIdx, 1);
newSpoolOrder.splice(hoverIndex, 0, finalSpoolOrder[curIdx]);
setSpoolOrder(newSpoolOrder);
};
const style = {
backgroundColor: token.colorBgContainer,
borderRadius: token.borderRadiusLG,
};
return (
<div className="loc-spools" style={style}>
{spools.map((spool, idx) => (
<SpoolCard key={spool.id} index={idx} spool={spool} moveSpoolOrder={moveSpoolOrder} />
))}
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { create } from "zustand";
import { ISpool } from "../spools/model";
export const ItemTypes = {
SPOOL: "spool",
CONTAINER: "spool-container",
};
export interface DragItem {
index: number;
}
export interface SpoolDragItem extends DragItem {
spool: ISpool;
}
export interface ContainerDragItem extends DragItem {
title: string;
}
interface CurrentDraggedSpool {
draggedSpoolId: number;
setDraggedSpoolId: (spoolid: number) => void;
}
export const useCurrentDraggedSpool = create<CurrentDraggedSpool>((set) => ({
draggedSpoolId: -1,
setDraggedSpoolId: (spoolid: number) => set({ draggedSpoolId: spoolid }),
}));

View File

@@ -0,0 +1,109 @@
import { GetListResponse } from "@refinedev/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react";
import { useGetSetting } from "../../utils/querySettings";
import { getAPIURL } from "../../utils/url";
import { ISpool } from "../spools/model";
export const EMPTYLOC = "";
interface LocationRename {
old: string;
new: string;
}
export function useRenameSpoolLocation() {
const queryClient = useQueryClient();
const queryKey = ["default", "spool"];
const queryKeyList = ["default", "spool", "list"];
return useMutation<string, unknown, LocationRename, undefined>({
mutationFn: async (value) => {
const response = await fetch(getAPIURL() + "/location/" + value.old, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: value.new,
}),
});
if (!response.ok) {
throw new Error("Network response was not ok");
}
return await response.text();
},
onMutate: async (value) => {
await queryClient.cancelQueries(queryKeyList);
// Optimistically update all spools with matching location to the new one
queryClient.setQueriesData<GetListResponse<ISpool>>({ queryKey: queryKeyList }, (old) => {
if (old) {
return {
data: old.data.map((spool) => {
if (spool.location === value.old) {
return { ...spool, location: value.new };
}
return spool;
}),
total: old.total,
};
}
return old;
});
},
onError: (_error, value, _context) => {
// Mutation failed, reset spools with matching location to the old one
queryClient.setQueriesData<GetListResponse<ISpool>>({ queryKey: queryKeyList }, (old) => {
if (old) {
return {
data: old.data.map((spool) => {
if (spool.location === value.new) {
return { ...spool, location: value.old };
}
return spool;
}),
total: old.total,
};
}
return old;
});
},
onSuccess: (_data, _value) => {
// Mutation succeeded, refetch
queryClient.invalidateQueries(queryKey);
},
});
}
export function useLocations(): string[] | null {
const query = useGetSetting("locations");
return useMemo(() => {
if (!query.data) return null;
try {
let data = (JSON.parse(query.data.value) ?? []) as string[];
data = data.filter((location) => location != null && location.length > 0);
return data;
} catch {
console.warn("Failed to parse locations", query.data.value);
return null;
}
}, [query.data]);
}
export function useLocationsSpoolOrders(): Record<string, number[]> {
const query = useGetSetting("locations_spoolorders");
return useMemo(() => {
if (!query.data) return {};
try {
return (JSON.parse(query.data.value) ?? {}) as Record<string, number[]>;
} catch {
console.warn("Failed to parse locations spool orders", query.data.value);
return {};
}
}, [query.data]);
}

View File

@@ -0,0 +1,25 @@
import { IResourceComponentsProps, useTranslate } from "@refinedev/core";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import React from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { LocationContainer } from "./components/locationContainer";
import "./locations.css";
dayjs.extend(utc);
export const Locations: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate();
return (
<div>
<h1>{t("locations.locations")}</h1>
<DndProvider backend={HTML5Backend}>
<LocationContainer />
</DndProvider>
</div>
);
};
export default Locations;

View File

@@ -0,0 +1,75 @@
.loc-metacontainer {
display: flex;
flex-wrap: wrap;
}
.loc-container {
padding: 1em;
width: 24em;
}
.loc-container.grabable,
.loc-container .spool {
cursor: move;
cursor: grab;
cursor: -moz-grab;
cursor: -webkit-grab;
}
.loc-container h3 {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 21px;
width: 100%;
}
.loc-container h3 span {
cursor: default;
}
.loc-container h3 span.editable {
cursor: text;
}
.loc-container h3 input {
font-size: 21px;
margin: 0;
padding: 0;
border: 0;
}
.loc-container .loc-spools {
padding: 0.2em;
display: flex;
flex-direction: column;
gap: 0.5em;
min-height: 3em;
max-height: 50em;
overflow-y: scroll;
}
.loc-container .spool {
padding: 0.5em 0.5em 0.5em 0;
display: flex;
align-items: center;
border-radius: 0.5em;
}
.loc-container .spool .info {
display: flex;
flex-direction: column;
width: 100%;
}
.loc-container .spool .info .title {
font-size: 1em;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.loc-container .spool .info .subtitle {
font-size: 0.8em;
}

View File

@@ -100,17 +100,13 @@ export function renderLabelContents(template: string, spool: ISpool): JSX.Elemen
// Find all {tags} in the template string and loop over them
// let matches = [...template.matchAll(/(?:{(.*?))?{(.*?)}(.*?)(?:}(.*?))?/gs)];
let matches = [...template.matchAll(/{(?:[^}{]|{[^}{]*})*}/gs)];
// console.log(matches){(?:[^}{]|{[^}{]*})*}
let label_text = template;
matches.forEach((match) => {
// console.log(match)
if ((match[0].match(/{/g)||[]).length == 1) {
if ((match[0].match(/{/g) || []).length == 1) {
let tag = match[0].replace(/[{}]/g, "");
// console.log(tag)
let tagValue = getTagValue(tag, spool)
let tagValue = getTagValue(tag, spool);
label_text = label_text.replace(match[0], tagValue);
}
else if ((match[0].match(/{/g)||[]).length == 2) {
} else if ((match[0].match(/{/g) || []).length == 2) {
let structure = match[0].match(/{(.*?){(.*?)}(.*?)}/);
if (structure != null) {
const tag = structure[2];

View File

@@ -5,6 +5,7 @@ import TextArea from "antd/es/input/TextArea";
import { message } from "antd/lib";
import dayjs from "dayjs";
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
import { useSpoolmanLocations } from "../../components/otherModels";
import { searchMatches } from "../../utils/filtering";
@@ -12,6 +13,7 @@ import { numberFormatter, numberParser } from "../../utils/parsing";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
import { createFilamentFromExternal } from "../filaments/functions";
import { useLocations } from "../locations/functions";
import { useGetFilamentSelectOptions } from "./functions";
import { ISpool, ISpoolParsedExtras, WeightToEnter } from "./model";
@@ -32,6 +34,8 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
const [hasChanged, setHasChanged] = useState(false);
const extraFields = useGetFields(EntityType.spool);
const currency = useCurrency();
const [searchParams, _] = useSearchParams();
const navigate = useNavigate();
const { form, formProps, saveButtonProps } = useForm<ISpool, HttpError, ISpoolRequest, ISpool>({
liveMode: "manual",
@@ -40,6 +44,17 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
messageApi.warning(t("spool.form.spool_updated"));
setHasChanged(true);
},
// Custom redirect logic
redirect: false,
onMutationSuccess: () => {
const returnUrl = searchParams.get("return");
if (returnUrl) {
navigate(returnUrl, { relative: "path" });
} else {
navigate("/spool");
}
},
});
const initialWeightValue = Form.useWatch("initial_weight", form);
@@ -118,7 +133,6 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
useEffect(() => {
const newFilamentWeight = selectedFilament?.weight || 0;
const newSpoolWeight = selectedFilament?.spool_weight || 0;
console.log("selectedFilament", selectedFilament, newFilamentWeight, newSpoolWeight);
if (newFilamentWeight > 0) {
form.setFieldValue("initial_weight", newFilamentWeight);
}
@@ -135,9 +149,15 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
};
const locations = useSpoolmanLocations(true);
const settingsLocation = useLocations();
const [newLocation, setNewLocation] = useState("");
const allLocations = [...(locations.data || [])];
const allLocations = [...(settingsLocation || [])];
locations?.data?.forEach((loc) => {
if (!allLocations.includes(loc)) {
allLocations.push(loc);
}
});
if (newLocation.trim() && !allLocations.includes(newLocation)) {
allLocations.push(newLocation.trim());
}

View File

@@ -1,7 +1,7 @@
import {InboxOutlined, PrinterOutlined, ToTopOutlined} from "@ant-design/icons";
import { InboxOutlined, PrinterOutlined, ToTopOutlined } from "@ant-design/icons";
import { DateField, NumberField, Show, TextField } from "@refinedev/antd";
import {IResourceComponentsProps, useInvalidate, useShow, useTranslate} from "@refinedev/core";
import {Button, Modal, Typography} from "antd";
import { IResourceComponentsProps, useInvalidate, useShow, useTranslate } from "@refinedev/core";
import { Button, Modal, Typography } from "antd";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import React from "react";
@@ -12,8 +12,8 @@ import { enrichText } from "../../utils/parsing";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { useCurrency } from "../../utils/settings";
import { IFilament } from "../filaments/model";
import { setSpoolArchived } from "./functions";
import { ISpool } from "./model";
import {setSpoolArchived} from "./functions";
dayjs.extend(utc);
@@ -52,9 +52,9 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
});
};
const archiveSpoolPopup = async (spool: ISpool|undefined) => {
const archiveSpoolPopup = async (spool: ISpool | undefined) => {
if (spool === undefined) {
return
return;
}
// If the spool has no remaining weight, archive it immediately since it's likely not a mistake
if (spool.remaining_weight != undefined && spool.remaining_weight <= 0) {
@@ -116,27 +116,20 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
headerButtons={({ defaultButtons }) => (
<>
<Button
type="primary"
icon={<PrinterOutlined />}
href={"/spool/print?spools=" + record?.id + "&return=" + encodeURIComponent(window.location.pathname)}
type="primary"
icon={<PrinterOutlined />}
href={"/spool/print?spools=" + record?.id + "&return=" + encodeURIComponent(window.location.pathname)}
>
{t("printing.qrcode.button")}
</Button>
{record?.archived ? (
<Button
icon={<ToTopOutlined />}
onClick={() => archiveSpool(record, false)}
>
{t("buttons.unArchive")}
</Button>
<Button icon={<ToTopOutlined />} onClick={() => archiveSpool(record, false)}>
{t("buttons.unArchive")}
</Button>
) : (
<Button
danger
icon={<InboxOutlined />}
onClick={() => archiveSpoolPopup(record)}
>
{t("buttons.archive")}
</Button>
<Button danger icon={<InboxOutlined />} onClick={() => archiveSpoolPopup(record)}>
{t("buttons.archive")}
</Button>
)}
{defaultButtons}

View File

@@ -4,6 +4,7 @@ import logging
from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field, RootModel
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.database import filament, spool
@@ -123,3 +124,25 @@ async def find_locations(
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> list[str]:
return await spool.find_locations(db=db)
class RenameLocationBody(BaseModel):
name: str = Field(description="The new name of the location.", min_length=1)
@router.patch(
"/location/{location}",
name="Rename location",
description="Rename a spool location. All spools in this location will be moved to the new location.",
response_model_exclude_none=True,
response_model=RootModel[str],
)
async def rename_location(
location: str,
*,
db: Annotated[AsyncSession, Depends(get_db_session)],
body: RenameLocationBody,
) -> str:
logger.info("Renaming location %s to %s", location, body.name)
await spool.rename_location(db=db, current_name=location, new_name=body.name)
return body.name

View File

@@ -462,3 +462,15 @@ async def reset_initial_weight(db: AsyncSession, spool_id: int, weight: float) -
await db.commit()
await spool_changed(spool, EventType.UPDATED)
return spool
async def rename_location(
*,
db: AsyncSession,
current_name: str,
new_name: str,
) -> None:
"""Rename all spools with the current location name to the new name."""
await db.execute(
sqlalchemy.update(models.Spool).where(models.Spool.location == current_name).values(location=new_name),
)

View File

@@ -68,3 +68,6 @@ register_setting("extra_fields_vendor", SettingType.ARRAY, json.dumps([]))
register_setting("extra_fields_filament", SettingType.ARRAY, json.dumps([]))
register_setting("extra_fields_spool", SettingType.ARRAY, json.dumps([]))
register_setting("base_url", SettingType.STRING, json.dumps(""))
register_setting("locations", SettingType.ARRAY, json.dumps([]))
register_setting("locations_spoolorders", SettingType.OBJECT, json.dumps({}))