diff --git a/client/public/locales/cs/common.json b/client/public/locales/cs/common.json index cc4313b5d..919de26ba 100644 --- a/client/public/locales/cs/common.json +++ b/client/public/locales/cs/common.json @@ -352,7 +352,16 @@ "home": { "home": "Domů", "description": "Vypadá to, že jste ještě nepřidali žádné cívky. Úvodní nápovědu najdete na stránce Help page .", - "welcome": "Vítejte ve vaší instanci Spoolman!" + "welcome": "Vítejte ve vaší instanci Spoolman!", + "total_weight": "Celkový stav", + "total_value": "Hodnota", + "low_stock": "Nízký stav", + "all_stocked": "Všechny cívky jsou dobře zásobeny", + "recently_used": "Naposledy použité", + "no_recent": "Žádné nedávno použité cívky", + "by_material": "Podle materiálu", + "by_location": "Podle umístění", + "telemetry_subtitle": "Stav vašeho inventáře filamentů v reálném čase." }, "settings": { "extra_fields": { @@ -407,6 +416,8 @@ "locations": "Umístění", "no_locations_help": "Na této stránce můžete uspořádat své cívky podle umístění, přidejte několik cívek a začněte!", "new_location": "Nové umístění", - "no_location": "Žádné umístění" + "no_location": "Žádné umístění", + "error_empty": "Název nesmí být prázdný", + "error_exists": "Umístění již existuje" } } diff --git a/client/public/locales/da/common.json b/client/public/locales/da/common.json index 42919410e..53513528f 100644 --- a/client/public/locales/da/common.json +++ b/client/public/locales/da/common.json @@ -316,7 +316,18 @@ }, "kofi": "Donér på Ko-fi", "home": { - "home": "Hjem" + "home": "Hjem", + "welcome": "Velkommen til din Spoolman-instans!", + "description": "Det ser ud til, at du endnu ikke har tilføjet nogen spoler. Se hjælpesiden for at komme i gang.", + "total_weight": "Samlet beholdning", + "total_value": "Værdi", + "low_stock": "Lav beholdning", + "all_stocked": "Alle spoler er godt fyldt op", + "recently_used": "Senest brugt", + "no_recent": "Ingen nyligt brugte spoler", + "by_material": "Efter materiale", + "by_location": "Efter placering", + "telemetry_subtitle": "Realtidsstatus for dit filamentlager." }, "settings": { "header": "Indstillinger", @@ -358,5 +369,9 @@ "delete_confirm_description": "Dette vil slette feltet samt alle associerede data for alle poster." }, "settings": "Indstillinger" + }, + "locations": { + "error_empty": "Navn må ikke være tomt", + "error_exists": "Placering findes allerede" } } diff --git a/client/public/locales/de/common.json b/client/public/locales/de/common.json index b39d18fa7..5013c9fac 100644 --- a/client/public/locales/de/common.json +++ b/client/public/locales/de/common.json @@ -351,7 +351,16 @@ "home": { "home": "Home", "welcome": "Willkommen auf deiner Spoolman Instanz!", - "description": "Es sieht so aus, als hättest du noch keine Spulen hinzugefügt. Schau auf unsere Hilfeseite, falls du Hilfe beim Start benötigst." + "description": "Es sieht so aus, als hättest du noch keine Spulen hinzugefügt. Schau auf unsere Hilfeseite, falls du Hilfe beim Start benötigst.", + "total_weight": "Gesamtbestand", + "total_value": "Wert", + "low_stock": "Niedriger Bestand", + "all_stocked": "Alle Spulen sind gut bevorratet", + "recently_used": "Zuletzt verwendet", + "no_recent": "Keine kürzlich verwendeten Spulen", + "by_material": "Nach Material", + "by_location": "Nach Standort", + "telemetry_subtitle": "Echtzeitstatus deines Filament-Inventars." }, "settings": { "header": "Einstellungen", @@ -406,6 +415,8 @@ "no_location": "Kein Ort", "no_locations_help": "Diese Seite lässt Sie Ihre Spulen zu Orten zuweisen, fügen Sie ein paar Spulen hinzu um loszulegen!", "locations": "Orte", - "new_location": "Neuer Ort" + "new_location": "Neuer Ort", + "error_empty": "Name darf nicht leer sein", + "error_exists": "Ort existiert bereits" } } diff --git a/client/public/locales/el/common.json b/client/public/locales/el/common.json index 490b9a33b..5084be6e7 100644 --- a/client/public/locales/el/common.json +++ b/client/public/locales/el/common.json @@ -288,7 +288,18 @@ }, "kofi": "Φιλοδώρημα στο Ko-fi", "home": { - "home": "Αρχική" + "home": "Αρχική", + "welcome": "Καλώς ήρθατε στην εγκατάσταση Spoolman σας!", + "description": "Φαίνεται ότι δεν έχετε προσθέσει ακόμα κουβαρίστρες. Δείτε τη σελίδα βοήθειας για να ξεκινήσετε.", + "total_weight": "Συνολικό απόθεμα", + "total_value": "Αξία", + "low_stock": "Χαμηλό απόθεμα", + "all_stocked": "Όλες οι κουβαρίστρες είναι καλά εφοδιασμένες", + "recently_used": "Πρόσφατα χρησιμοποιημένα", + "no_recent": "Δεν υπάρχουν πρόσφατα χρησιμοποιημένες κουβαρίστρες", + "by_material": "Ανά υλικό", + "by_location": "Ανά τοποθεσία", + "telemetry_subtitle": "Κατάσταση σε πραγματικό χρόνο του αποθέματος νημάτων σας." }, "settings": { "extra_fields": { @@ -330,5 +341,9 @@ }, "header": "Ρυθμίσεις", "settings": "Ρυθμίσεις" + }, + "locations": { + "error_empty": "Το όνομα δεν μπορεί να είναι κενό", + "error_exists": "Η τοποθεσία υπάρχει ήδη" } } diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 88ff2ae85..c89742f7c 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -293,7 +293,17 @@ "home": { "home": "Home", "welcome": "Welcome to your Spoolman instance!", - "description": "It looks like you haven't added any spools yet. See the Help page for help getting started." + "description": "It looks like you haven't added any spools yet. See the Help page for help getting started.", + "total_weight": "Total Stock", + "total_value": "Value", + "low_stock": "Low Stock", + "all_stocked": "All spools are well stocked", + "recently_used": "Recently Used", + "no_recent": "No recently used spools", + "by_material": "By Material", + "by_location": "By Location", + "by_vendor": "By Manufacturer", + "telemetry_subtitle": "Real-time status of your filament inventory." }, "help": { "help": "Help", @@ -394,6 +404,8 @@ "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!" + "no_locations_help": "This page lets you organize your spools in locations, add some spools to get started!", + "error_empty": "Name cannot be empty", + "error_exists": "Location already exists" } } diff --git a/client/public/locales/es/common.json b/client/public/locales/es/common.json index 18e22b98d..21b28ff1c 100644 --- a/client/public/locales/es/common.json +++ b/client/public/locales/es/common.json @@ -352,7 +352,16 @@ "home": { "home": "Inicio", "welcome": "¡Bienvenido a tu instancia de Spoolman!", - "description": "Parece que aún no has añadido ninguna bobina aún. Visita la página de ayuda para comenzar." + "description": "Parece que aún no has añadido ninguna bobina aún. Visita la página de ayuda para comenzar.", + "total_weight": "Stock total", + "total_value": "Valor", + "low_stock": "Stock bajo", + "all_stocked": "Todas las bobinas están bien abastecidas", + "recently_used": "Usadas recientemente", + "no_recent": "No hay bobinas usadas recientemente", + "by_material": "Por material", + "by_location": "Por ubicación", + "telemetry_subtitle": "Estado en tiempo real de tu inventario de filamento." }, "settings": { "header": "Ajustes", @@ -407,6 +416,8 @@ "locations": "Ubicaciones", "new_location": "Nueva ubicación", "no_location": "Sin ubicación", - "no_locations_help": "Esta página te permite organizar tus carretes en ubicaciones, ¡añade algunos carretes para empezar!" + "no_locations_help": "Esta página te permite organizar tus carretes en ubicaciones, ¡añade algunos carretes para empezar!", + "error_empty": "El nombre no puede estar vacío", + "error_exists": "La ubicación ya existe" } } diff --git a/client/public/locales/et/common.json b/client/public/locales/et/common.json index ececeb30c..414e4919f 100644 --- a/client/public/locales/et/common.json +++ b/client/public/locales/et/common.json @@ -62,5 +62,20 @@ "generic": { "title": "Trükkib" } + }, + "home": { + "total_weight": "Koguvaru", + "total_value": "Väärtus", + "low_stock": "Madal varu", + "all_stocked": "Kõik poolid on hästi varustatud", + "recently_used": "Hiljuti kasutatud", + "no_recent": "Hiljuti kasutatud poole pole", + "by_material": "Materjali järgi", + "by_location": "Asukoha järgi", + "telemetry_subtitle": "Teie filamendi varude reaalajas olek." + }, + "locations": { + "error_empty": "Nimi ei tohi olla tühi", + "error_exists": "Asukoht on juba olemas" } } diff --git a/client/public/locales/fa/common.json b/client/public/locales/fa/common.json index e695407f1..97ef8d98c 100644 --- a/client/public/locales/fa/common.json +++ b/client/public/locales/fa/common.json @@ -288,7 +288,18 @@ } }, "home": { - "home": "صفحه اصلی" + "home": "صفحه اصلی", + "welcome": "به نمونه Spoolman خود خوش آمدید!", + "description": "به نظر می‌رسد هنوز هیچ قرقره‌ای اضافه نکرده‌اید. برای شروع به صفحه راهنما مراجعه کنید.", + "total_weight": "موجودی کل", + "total_value": "ارزش", + "low_stock": "موجودی کم", + "all_stocked": "همه قرقره‌ها به خوبی ذخیره شده‌اند", + "recently_used": "اخیراً استفاده شده", + "no_recent": "قرقره‌ای اخیراً استفاده نشده", + "by_material": "بر اساس مواد", + "by_location": "بر اساس مکان", + "telemetry_subtitle": "وضعیت بلادرنگ موجودی فیلامنت شما." }, "help": { "help": "راهنما", @@ -377,5 +388,9 @@ "create": "تعریف کردن تولید کننده فیلامنت جدید | اسپول من", "clone": "تکثیر تولید کننده فیلامنت شماره {{id}} | اسپول من" } + }, + "locations": { + "error_empty": "نام نمی‌تواند خالی باشد", + "error_exists": "مکان از قبل وجود دارد" } } diff --git a/client/public/locales/fr/common.json b/client/public/locales/fr/common.json index becc4697f..636e7d8cb 100644 --- a/client/public/locales/fr/common.json +++ b/client/public/locales/fr/common.json @@ -351,7 +351,17 @@ "kofi": "Me donner un pourboire sur Ko-fi", "home": { "home": "Accueil", - "welcome": "Bienvenue sur votre instance Spoolman !" + "welcome": "Bienvenue sur votre instance Spoolman !", + "description": "Il semble que vous n'ayez pas encore ajouté de bobines. Consultez la page d'aide pour commencer.", + "total_weight": "Stock total", + "total_value": "Valeur", + "low_stock": "Stock faible", + "all_stocked": "Toutes les bobines sont bien approvisionnées", + "recently_used": "Utilisées récemment", + "no_recent": "Aucune bobine utilisée récemment", + "by_material": "Par matériau", + "by_location": "Par emplacement", + "telemetry_subtitle": "État en temps réel de votre inventaire de filament." }, "settings": { "settings": "Paramètres", @@ -402,6 +412,8 @@ "locations": "Emplacements", "new_location": "Créer un emplacement", "no_location": "Pas de localisation", - "no_locations_help": "Cette page vous permet d'organiser vos bobines par lieu, ajoutez une bobine pour commencer !" + "no_locations_help": "Cette page vous permet d'organiser vos bobines par lieu, ajoutez une bobine pour commencer !", + "error_empty": "Le nom ne peut pas être vide", + "error_exists": "L'emplacement existe déjà" } } diff --git a/client/public/locales/hi-Latn/common.json b/client/public/locales/hi-Latn/common.json index 88011dd4a..e9312fef0 100644 --- a/client/public/locales/hi-Latn/common.json +++ b/client/public/locales/hi-Latn/common.json @@ -13,5 +13,20 @@ "confirm": "Kya aapko pakka hai?", "continue": "Aage badhen", "show": "Dikhayen" + }, + "home": { + "total_weight": "Kul stock", + "total_value": "Mulya", + "low_stock": "Kam stock", + "all_stocked": "Sabhi spool achhe se stock hain", + "recently_used": "Haal hi mein istemal ki gayi", + "no_recent": "Koi haal hi mein istemal ki gayi spool nahi", + "by_material": "Material ke anusaar", + "by_location": "Jagah ke anusaar", + "telemetry_subtitle": "Aapke filament inventory ki real-time sthiti." + }, + "locations": { + "error_empty": "Naam khaali nahi ho sakta", + "error_exists": "Jagah pehle se maujood hai" } } diff --git a/client/public/locales/hu/common.json b/client/public/locales/hu/common.json index 7e0e7e275..127e74410 100644 --- a/client/public/locales/hu/common.json +++ b/client/public/locales/hu/common.json @@ -277,7 +277,18 @@ }, "kofi": "Tipp a Ko-fi-ra", "home": { - "home": "Kezdő lap" + "home": "Kezdő lap", + "welcome": "Üdvözöljük a Spoolman példányában!", + "description": "Úgy tűnik, még nem adott hozzá orsókat. Nézze meg a Súgó oldalt az induláshoz.", + "total_weight": "Teljes készlet", + "total_value": "Érték", + "low_stock": "Alacsony készlet", + "all_stocked": "Minden orsó jól feltöltve", + "recently_used": "Nemrég használt", + "no_recent": "Nincsenek nemrég használt orsók", + "by_material": "Anyag szerint", + "by_location": "Hely szerint", + "telemetry_subtitle": "A filament készleted valós idejű állapota." }, "help": { "resources": { @@ -328,5 +339,9 @@ }, "tab": "Általános" } + }, + "locations": { + "error_empty": "A név nem lehet üres", + "error_exists": "A hely már létezik" } } diff --git a/client/public/locales/it/common.json b/client/public/locales/it/common.json index e45aec76a..0505e9348 100644 --- a/client/public/locales/it/common.json +++ b/client/public/locales/it/common.json @@ -352,7 +352,16 @@ "home": { "home": "Home", "welcome": "Benvenuto nella tua istanza Spoolman!", - "description": "Sembra che tu non abbia ancora aggiunto alcuna bobina. Vai su Help page per ricevere assistenza su come iniziare." + "description": "Sembra che tu non abbia ancora aggiunto alcuna bobina. Vai su Help page per ricevere assistenza su come iniziare.", + "total_weight": "Stock totale", + "total_value": "Valore", + "low_stock": "Scorte basse", + "all_stocked": "Tutte le bobine sono ben rifornite", + "recently_used": "Usate di recente", + "no_recent": "Nessuna bobina usata di recente", + "by_material": "Per materiale", + "by_location": "Per posizione", + "telemetry_subtitle": "Stato in tempo reale del tuo inventario di filamento." }, "settings": { "settings": "Configurazione", @@ -407,6 +416,8 @@ "locations": "Posizioni", "new_location": "Nuova Posizione", "no_location": "Nessuna Posizione", - "no_locations_help": "Questa pagina ti consente di organizzare le bobine in base alla posizione. Aggiungi alcune bobine per iniziare!" + "no_locations_help": "Questa pagina ti consente di organizzare le bobine in base alla posizione. Aggiungi alcune bobine per iniziare!", + "error_empty": "Il nome non può essere vuoto", + "error_exists": "La posizione esiste già" } } diff --git a/client/public/locales/ja/common.json b/client/public/locales/ja/common.json index 951fc1b11..5114ef88c 100644 --- a/client/public/locales/ja/common.json +++ b/client/public/locales/ja/common.json @@ -388,9 +388,22 @@ "locations": "場所", "new_location": "新しい場所", "no_location": "場所なし", - "no_locations_help": "このページでは、スプールを場所ごとに整理することができます!" + "no_locations_help": "このページでは、スプールを場所ごとに整理することができます!", + "error_empty": "名前を入力してください", + "error_exists": "この場所は既に存在します" }, "home": { - "home": "ホーム" + "home": "ホーム", + "welcome": "Spoolmanへようこそ!", + "description": "まだスプールが追加されていないようです。始め方についてはヘルプページをご覧ください。", + "total_weight": "総在庫", + "total_value": "総額", + "low_stock": "在庫不足", + "all_stocked": "すべてのスプールは十分な在庫があります", + "recently_used": "最近使用", + "no_recent": "最近使用したスプールはありません", + "by_material": "素材別", + "by_location": "場所別", + "telemetry_subtitle": "フィラメント在庫のリアルタイムステータス。" } } diff --git a/client/public/locales/lt/common.json b/client/public/locales/lt/common.json index 2dbd9ca79..f8c068b3a 100644 --- a/client/public/locales/lt/common.json +++ b/client/public/locales/lt/common.json @@ -294,7 +294,16 @@ "home": { "home": "Pradžia", "welcome": "Sveiki atvykę į savo Spoolman!", - "description": "Panašu, kad dar nepridėjote jokių ričių. Norėdami gauti pagalbos, kaip pradėti, žr. pagalbos puslapį." + "description": "Panašu, kad dar nepridėjote jokių ričių. Norėdami gauti pagalbos, kaip pradėti, žr. pagalbos puslapį.", + "total_weight": "Bendras kiekis", + "total_value": "Vertė", + "low_stock": "Mažas kiekis", + "all_stocked": "Visos ritės gerai aprūpintos", + "recently_used": "Neseniai naudotos", + "no_recent": "Nėra neseniai naudotų ričių", + "by_material": "Pagal medžiagą", + "by_location": "Pagal vietą", + "telemetry_subtitle": "Jūsų filamento atsargų būsena realiu laiku." }, "help": { "help": "Pagalba", @@ -395,6 +404,8 @@ "locations": "Vietos", "new_location": "Nauja vieta", "no_location": "Be vietos", - "no_locations_help": "Šiame puslapyje galite tvarkyti rites pagal vietas, pridėkite keletą ričių, kad pradėtumėte!" + "no_locations_help": "Šiame puslapyje galite tvarkyti rites pagal vietas, pridėkite keletą ričių, kad pradėtumėte!", + "error_empty": "Pavadinimas negali būti tuščias", + "error_exists": "Vieta jau egzistuoja" } } diff --git a/client/public/locales/nb-NO/common.json b/client/public/locales/nb-NO/common.json index f98639776..e3830d824 100644 --- a/client/public/locales/nb-NO/common.json +++ b/client/public/locales/nb-NO/common.json @@ -342,7 +342,16 @@ "home": { "home": "Hjem", "welcome": "Velkommen til Spoolman!", - "description": "Det ser ut som du ikke har lagt til noen spoler ennå. Se hjelpesiden for hjelp til å komme i gang." + "description": "Det ser ut som du ikke har lagt til noen spoler ennå. Se hjelpesiden for hjelp til å komme i gang.", + "total_weight": "Totalt lager", + "total_value": "Verdi", + "low_stock": "Lavt lager", + "all_stocked": "Alle spoler er godt fylt opp", + "recently_used": "Nylig brukt", + "no_recent": "Ingen nylig brukte spoler", + "by_material": "Etter materiale", + "by_location": "Etter plassering", + "telemetry_subtitle": "Sanntidsstatus for filamentlageret ditt." }, "help": { "help": "Hjelp", @@ -357,7 +366,9 @@ "locations": "Lokasjoner", "new_location": "Ny lokasjon", "no_location": "Ingen lokasjon", - "no_locations_help": "Denne siden mar deg organisere rullene med filament i lokasjoner. Legg til noen ruller for å starte!" + "no_locations_help": "Denne siden mar deg organisere rullene med filament i lokasjoner. Legg til noen ruller for å starte!", + "error_empty": "Navn kan ikke være tomt", + "error_exists": "Plasseringen finnes allerede" }, "settings": { "settings": "Innstillinger", diff --git a/client/public/locales/nl/common.json b/client/public/locales/nl/common.json index caac3566c..8f09ad596 100644 --- a/client/public/locales/nl/common.json +++ b/client/public/locales/nl/common.json @@ -349,7 +349,18 @@ }, "kofi": "Tip mij op Ko-fi", "home": { - "home": "Thuis" + "home": "Thuis", + "welcome": "Welkom bij je Spoolman-installatie!", + "description": "Het lijkt erop dat je nog geen spoelen hebt toegevoegd. Bekijk de helppagina om aan de slag te gaan.", + "total_weight": "Totale voorraad", + "total_value": "Waarde", + "low_stock": "Lage voorraad", + "all_stocked": "Alle spoelen zijn goed bevoorraad", + "recently_used": "Recent gebruikt", + "no_recent": "Geen recent gebruikte spoelen", + "by_material": "Per materiaal", + "by_location": "Per locatie", + "telemetry_subtitle": "Realtime status van je filamentvoorraad." }, "settings": { "header": "Instellingen", @@ -404,6 +415,8 @@ "no_location": "Geen locatie", "no_locations_help": "Op deze pagina kunt u uw spoelen op verschillende locaties ordenen. Voeg spoelen toe om aan de slag te gaan!", "locations": "Locaties", - "new_location": "Nieuwe locatie" + "new_location": "Nieuwe locatie", + "error_empty": "Naam mag niet leeg zijn", + "error_exists": "Locatie bestaat al" } } diff --git a/client/public/locales/pl/common.json b/client/public/locales/pl/common.json index 2bb533c0d..c5c124755 100644 --- a/client/public/locales/pl/common.json +++ b/client/public/locales/pl/common.json @@ -352,7 +352,16 @@ "home": { "home": "Strona główna", "welcome": "Witaj w instancji Spoolman!", - "description": "Wygląda na to, że nie dodano jeszcze żadnych szpul. Zobacz stronę pomocy, aby dowiedzieć się jak rozpocząć." + "description": "Wygląda na to, że nie dodano jeszcze żadnych szpul. Zobacz stronę pomocy, aby dowiedzieć się jak rozpocząć.", + "total_weight": "Łączny zapas", + "total_value": "Wartość", + "low_stock": "Niski zapas", + "all_stocked": "Wszystkie szpule są dobrze zaopatrzone", + "recently_used": "Ostatnio używane", + "no_recent": "Brak ostatnio używanych szpul", + "by_material": "Wg materiału", + "by_location": "Wg lokalizacji", + "telemetry_subtitle": "Stan zapasów filamentu w czasie rzeczywistym." }, "settings": { "settings": "Ustawienia", @@ -407,6 +416,8 @@ "locations": "Lokalizacje", "new_location": "Nowa lokalizacja", "no_location": "Brak lokalizacji", - "no_locations_help": "Ta strona pozwala uporządkować szpule w lokalizacjach, dodaj kilka szpul aby rozpocząć!" + "no_locations_help": "Ta strona pozwala uporządkować szpule w lokalizacjach, dodaj kilka szpul aby rozpocząć!", + "error_empty": "Nazwa nie może być pusta", + "error_exists": "Lokalizacja już istnieje" } } diff --git a/client/public/locales/pt-BR/common.json b/client/public/locales/pt-BR/common.json index 349c6fe37..817206fa1 100644 --- a/client/public/locales/pt-BR/common.json +++ b/client/public/locales/pt-BR/common.json @@ -294,7 +294,16 @@ "home": { "home": "Início", "welcome": "Bem-vindo à sua instância do Spoolman!", - "description": "Parece que você ainda não adicionou nenhum carretel. Veja a Página de Ajuda para obter ajuda sobre como começar." + "description": "Parece que você ainda não adicionou nenhum carretel. Veja a Página de Ajuda para obter ajuda sobre como começar.", + "total_weight": "Estoque total", + "total_value": "Valor", + "low_stock": "Estoque baixo", + "all_stocked": "Todas as bobinas estão bem abastecidas", + "recently_used": "Usadas recentemente", + "no_recent": "Nenhuma bobina usada recentemente", + "by_material": "Por material", + "by_location": "Por localização", + "telemetry_subtitle": "Status em tempo real do seu inventário de filamento." }, "help": { "help": "Ajuda", @@ -395,6 +404,8 @@ "locations": "Locais", "new_location": "Novo Local", "no_location": "Sem Local", - "no_locations_help": "Esta página permite que você organize seus carretéis em locais, adicione alguns carretéis para começar!" + "no_locations_help": "Esta página permite que você organize seus carretéis em locais, adicione alguns carretéis para começar!", + "error_empty": "O nome não pode estar vazio", + "error_exists": "A localização já existe" } } diff --git a/client/public/locales/pt/common.json b/client/public/locales/pt/common.json index 9eb6fada7..d46ba08e5 100644 --- a/client/public/locales/pt/common.json +++ b/client/public/locales/pt/common.json @@ -293,7 +293,16 @@ "home": { "home": "Início", "welcome": "Bem vindo à sua instância do Spoolman!", - "description": "Parece que ainda não adicionou nenhuma bobine. Veja a Página de Ajuda para obter ajuda a começar." + "description": "Parece que ainda não adicionou nenhuma bobine. Veja a Página de Ajuda para obter ajuda a começar.", + "total_weight": "Stock total", + "total_value": "Valor", + "low_stock": "Stock baixo", + "all_stocked": "Todas as bobinas estão bem abastecidas", + "recently_used": "Usadas recentemente", + "no_recent": "Sem bobinas usadas recentemente", + "by_material": "Por material", + "by_location": "Por localização", + "telemetry_subtitle": "Estado em tempo real do seu inventário de filamento." }, "help": { "help": "Ajuda", @@ -407,6 +416,8 @@ "locations": "Locais", "new_location": "Novo Local", "no_location": "Sem Local", - "no_locations_help": "Esta página permite organizar as suas bobines em locais, adicione algumas bobines para começar!" + "no_locations_help": "Esta página permite organizar as suas bobines em locais, adicione algumas bobines para começar!", + "error_empty": "O nome não pode estar vazio", + "error_exists": "A localização já existe" } } diff --git a/client/public/locales/ro/common.json b/client/public/locales/ro/common.json index a87852591..ceeae187e 100644 --- a/client/public/locales/ro/common.json +++ b/client/public/locales/ro/common.json @@ -337,7 +337,16 @@ "home": { "home": "Acasă", "welcome": "Bine ai venit în instanța ta Spoolman!", - "description": "Se pare că nu ați adăugat încă nici o rolă. Consultați pagina Ajutor pentru a începe." + "description": "Se pare că nu ați adăugat încă nici o rolă. Consultați pagina Ajutor pentru a începe.", + "total_weight": "Stoc total", + "total_value": "Valoare", + "low_stock": "Stoc scăzut", + "all_stocked": "Toate bobinele sunt bine aprovizionate", + "recently_used": "Utilizate recent", + "no_recent": "Nicio bobină utilizată recent", + "by_material": "După material", + "by_location": "După locație", + "telemetry_subtitle": "Starea în timp real a inventarului de filament." }, "scanner": { "error": { @@ -407,6 +416,8 @@ "locations": "Zone", "new_location": "Zonă nouă", "no_location": "Fără zonă", - "no_locations_help": "Această pagină îți permite să îți organizezi rolele în zone, adaugă câteva role pentru a începe!" + "no_locations_help": "Această pagină îți permite să îți organizezi rolele în zone, adaugă câteva role pentru a începe!", + "error_empty": "Numele nu poate fi gol", + "error_exists": "Locația există deja" } } diff --git a/client/public/locales/ru/common.json b/client/public/locales/ru/common.json index b73ee07fe..1d728ee04 100644 --- a/client/public/locales/ru/common.json +++ b/client/public/locales/ru/common.json @@ -352,7 +352,16 @@ "home": { "home": "Главная", "welcome": "Добро пожаловать в ваш личный Spoolman!", - "description": "Похоже, что вы ещё не добавили ни одной катушки. Посетите Страницу помощи, для комфортного начала." + "description": "Похоже, что вы ещё не добавили ни одной катушки. Посетите Страницу помощи, для комфортного начала.", + "total_weight": "Общий запас", + "total_value": "Стоимость", + "low_stock": "Мало на складе", + "all_stocked": "Все катушки хорошо укомплектованы", + "recently_used": "Недавно использованные", + "no_recent": "Нет недавно использованных катушек", + "by_material": "По материалу", + "by_location": "По расположению", + "telemetry_subtitle": "Состояние запасов филамента в реальном времени." }, "settings": { "extra_fields": { @@ -407,6 +416,8 @@ "no_locations_help": "Эта страница позволяет организовать катушки по местоположениям. Добавьте несколько катушек, чтобы начать!", "locations": "Местоположения", "new_location": "Новое местоположение", - "no_location": "Нет местоположения" + "no_location": "Нет местоположения", + "error_empty": "Название не может быть пустым", + "error_exists": "Расположение уже существует" } } diff --git a/client/public/locales/sv/common.json b/client/public/locales/sv/common.json index 39977fc0e..a5cfe154b 100644 --- a/client/public/locales/sv/common.json +++ b/client/public/locales/sv/common.json @@ -293,7 +293,18 @@ } }, "home": { - "home": "Hem" + "home": "Hem", + "welcome": "Välkommen till din Spoolman-instans!", + "description": "Det verkar som att du inte har lagt till några spolar ännu. Se hjälpsidan för att komma igång.", + "total_weight": "Totalt lager", + "total_value": "Värde", + "low_stock": "Lågt lager", + "all_stocked": "Alla spolar är välfyllda", + "recently_used": "Nyligen använda", + "no_recent": "Inga nyligen använda spolar", + "by_material": "Per material", + "by_location": "Per plats", + "telemetry_subtitle": "Realtidsstatus för ditt filamentlager." }, "table": { "actions": "Åtgärder" @@ -382,6 +393,8 @@ "kofi": "Dricksa mig på Ko-fi", "locations": { "new_location": "Ny plats", - "no_location": "Ingen plats" + "no_location": "Ingen plats", + "error_empty": "Namnet får inte vara tomt", + "error_exists": "Platsen finns redan" } } diff --git a/client/public/locales/ta/common.json b/client/public/locales/ta/common.json index 4e30a0ebc..76a3bdafb 100644 --- a/client/public/locales/ta/common.json +++ b/client/public/locales/ta/common.json @@ -291,7 +291,9 @@ "locations": "இருப்பிடங்கள்", "new_location": "புதிய இடம்", "no_location": "இடம் இல்லை", - "no_locations_help": "இந்த பக்கம் உங்கள் ச்பூல்களை இருப்பிடங்களில் ஒழுங்கமைக்க அனுமதிக்கிறது, தொடங்குவதற்கு சில ச்பூல்களைச் சேர்க்கவும்!" + "no_locations_help": "இந்த பக்கம் உங்கள் ச்பூல்களை இருப்பிடங்களில் ஒழுங்கமைக்க அனுமதிக்கிறது, தொடங்குவதற்கு சில ச்பூல்களைச் சேர்க்கவும்!", + "error_empty": "பெயர் காலியாக இருக்க முடியாது", + "error_exists": "இடம் ஏற்கனவே உள்ளது" }, "actions": { "list": "பட்டியல்", @@ -342,7 +344,16 @@ "home": { "home": "வீடு", "welcome": "உங்கள் ச்பூல்மேன் உதாரணத்திற்கு வருக!", - "description": "நீங்கள் இதுவரை எந்த ச்பூல்களையும் சேர்க்கவில்லை என்று தெரிகிறது. தொடங்குவதற்கு உதவி பக்கம் ஐப் பார்க்கவும்." + "description": "நீங்கள் இதுவரை எந்த ச்பூல்களையும் சேர்க்கவில்லை என்று தெரிகிறது. தொடங்குவதற்கு உதவி பக்கம் ஐப் பார்க்கவும்.", + "total_weight": "மொத்த இருப்பு", + "total_value": "மதிப்பு", + "low_stock": "குறைந்த இருப்பு", + "all_stocked": "அனைத்து ஸ்பூல்களும் நன்கு நிரப்பப்பட்டுள்ளன", + "recently_used": "சமீபத்தில் பயன்படுத்தியவை", + "no_recent": "சமீபத்தில் பயன்படுத்திய ஸ்பூல்கள் இல்லை", + "by_material": "பொருள் வாரியாக", + "by_location": "இடம் வாரியாக", + "telemetry_subtitle": "உங்கள் பிலமென்ட் சரக்குகளின் நிகழ்நேர நிலை." }, "table": { "actions": "செயல்கள்" diff --git a/client/public/locales/th/common.json b/client/public/locales/th/common.json index 92e08eff8..8f8337f37 100644 --- a/client/public/locales/th/common.json +++ b/client/public/locales/th/common.json @@ -380,10 +380,23 @@ "new_location": "สถานที่เก็บใหม่", "no_location": "ไม่มีสถานที่เก็บ", "locations": "สถานที่เก็บ", - "no_locations_help": "หน้านี้ให้คุณจัดการม้วนพลาสติกตามสถานที่เก็บ เพิ่มม้วนพลาสติกเพื่อเริ่มต้น!" + "no_locations_help": "หน้านี้ให้คุณจัดการม้วนพลาสติกตามสถานที่เก็บ เพิ่มม้วนพลาสติกเพื่อเริ่มต้น!", + "error_empty": "ชื่อต้องไม่ว่างเปล่า", + "error_exists": "ตำแหน่งนี้มีอยู่แล้ว" }, "home": { - "home": "หน้าแรก" + "home": "หน้าแรก", + "welcome": "ยินดีต้อนรับสู่ Spoolman ของคุณ!", + "description": "ดูเหมือนว่าคุณยังไม่ได้เพิ่มม้วนใดๆ ดูหน้าช่วยเหลือเพื่อเริ่มต้นใช้งาน", + "total_weight": "สต็อกทั้งหมด", + "total_value": "มูลค่า", + "low_stock": "สต็อกต่ำ", + "all_stocked": "ม้วนทั้งหมดมีสต็อกเพียงพอ", + "recently_used": "ใช้ล่าสุด", + "no_recent": "ไม่มีม้วนที่ใช้ล่าสุด", + "by_material": "ตามวัสดุ", + "by_location": "ตามตำแหน่ง", + "telemetry_subtitle": "สถานะแบบเรียลไทม์ของคลังเส้นพลาสติกของคุณ" }, "warnWhenUnsavedChanges": "คุณแน่ใจหรือไม่ว่าต้องการออก? คุณมีการเปลี่ยนแปลงที่ยังไม่ได้บันทึก", "table": { diff --git a/client/public/locales/tr/common.json b/client/public/locales/tr/common.json index 8724a9645..d94068509 100644 --- a/client/public/locales/tr/common.json +++ b/client/public/locales/tr/common.json @@ -293,7 +293,16 @@ "home": { "home": "Ana Sayfa", "welcome": "Spoolman sunucunuza hoşgeldiniz!", - "description": "Görünüşe göre henüz hiç makara eklememişsiniz. Başlamak için yardım almak isterseniz Yardım sayfasına göz atabilirsiniz." + "description": "Görünüşe göre henüz hiç makara eklememişsiniz. Başlamak için yardım almak isterseniz Yardım sayfasına göz atabilirsiniz.", + "total_weight": "Toplam stok", + "total_value": "Değer", + "low_stock": "Düşük stok", + "all_stocked": "Tüm makaralar iyi stoklanmış", + "recently_used": "Son kullanılan", + "no_recent": "Son kullanılan makara yok", + "by_material": "Malzemeye göre", + "by_location": "Konuma göre", + "telemetry_subtitle": "Filament envanterinizin gerçek zamanlı durumu." }, "help": { "help": "Yardım", @@ -394,6 +403,8 @@ "locations": "Konumlar", "new_location": "Yeni Konum", "no_location": "Konum Yok", - "no_locations_help": "Bu sayfa, makaralarınızı konumlara göre düzenlemenizi sağlar. Başlamak için birkaç makara ekleyin!" + "no_locations_help": "Bu sayfa, makaralarınızı konumlara göre düzenlemenizi sağlar. Başlamak için birkaç makara ekleyin!", + "error_empty": "Ad boş olamaz", + "error_exists": "Konum zaten mevcut" } } diff --git a/client/public/locales/uk/common.json b/client/public/locales/uk/common.json index 66eff3cbe..c00e4d834 100644 --- a/client/public/locales/uk/common.json +++ b/client/public/locales/uk/common.json @@ -333,7 +333,18 @@ }, "kofi": "Задонатити на Ko-fi", "home": { - "home": "Домашня" + "home": "Домашня", + "welcome": "Ласкаво просимо до вашого екземпляра Spoolman!", + "description": "Схоже, що ви ще не додали жодної котушки. Перегляньте сторінку довідки, щоб дізнатися, як почати.", + "total_weight": "Загальний запас", + "total_value": "Вартість", + "low_stock": "Низький запас", + "all_stocked": "Всі котушки добре укомплектовані", + "recently_used": "Нещодавно використані", + "no_recent": "Немає нещодавно використаних котушок", + "by_material": "За матеріалом", + "by_location": "За розташуванням", + "telemetry_subtitle": "Стан запасів філаменту в реальному часі." }, "settings": { "header": "Налаштування", @@ -378,5 +389,9 @@ "description": "

Тут ви можете додати додаткові користувацькі поля до своїх об’єктів.

Після додавання поля ви не можете змінити його ключ або тип, а для полів типу вибору ви не можете видалити варіанти або змінити стан кількох варіантів. Якщо ви вилучите поле, пов’язані дані для всіх об’єктів буде видалено.

Ключ – це те, як інші програми читають/записують дані, тож якщо ваше спеціальне поле має інтегруватися зі сторонніми програми, переконайтеся, що ви правильно її налаштували. Значення за замовчуванням застосовується лише до нових елементів.

Додаткові поля не можна сортувати чи фільтрувати в режимі перегляду таблиці.

" }, "settings": "Налаштування" + }, + "locations": { + "error_empty": "Назва не може бути порожньою", + "error_exists": "Розташування вже існує" } } diff --git a/client/public/locales/zh-Hant/common.json b/client/public/locales/zh-Hant/common.json index 58572be9f..3368f4332 100644 --- a/client/public/locales/zh-Hant/common.json +++ b/client/public/locales/zh-Hant/common.json @@ -324,7 +324,18 @@ } }, "home": { - "home": "主頁" + "home": "主頁", + "welcome": "歡迎使用您的 Spoolman!", + "description": "看起來您還沒有添加任何線軸。請查看說明頁面以開始使用。", + "total_weight": "總庫存", + "total_value": "總價值", + "low_stock": "庫存不足", + "all_stocked": "所有線軸庫存充足", + "recently_used": "最近使用", + "no_recent": "沒有最近使用的線軸", + "by_material": "按材料", + "by_location": "按位置", + "telemetry_subtitle": "耗材庫存即時狀態。" }, "help": { "help": "說明", @@ -387,6 +398,8 @@ "locations": "位置", "no_location": "未設定位置", "no_locations_help": "這個頁面讓您將料盤按位置進行整理,請從增加一些線盤來開始!", - "new_location": "新增位置" + "new_location": "新增位置", + "error_empty": "名稱不能為空", + "error_exists": "位置已存在" } } diff --git a/client/public/locales/zh/common.json b/client/public/locales/zh/common.json index d08b0aac3..a712281e0 100644 --- a/client/public/locales/zh/common.json +++ b/client/public/locales/zh/common.json @@ -339,7 +339,18 @@ }, "kofi": "在Ko-fi上给我打赏一点小费", "home": { - "home": "主页" + "home": "主页", + "welcome": "欢迎使用您的 Spoolman!", + "description": "看起来您还没有添加任何线轴。请查看帮助页面以开始使用。", + "total_weight": "总库存", + "total_value": "总价值", + "low_stock": "库存不足", + "all_stocked": "所有线轴库存充足", + "recently_used": "最近使用", + "no_recent": "没有最近使用的线轴", + "by_material": "按材料", + "by_location": "按位置", + "telemetry_subtitle": "耗材库存实时状态。" }, "help": { "help": "帮助", @@ -403,6 +414,8 @@ "no_locations_help": "此页面允许您在不同位置管理料盘,添加一些料盘即可开始!", "locations": "位置", "new_location": "新位置", - "no_location": "没有位置" + "no_location": "没有位置", + "error_empty": "名称不能为空", + "error_exists": "位置已存在" } } diff --git a/client/src/App.tsx b/client/src/App.tsx index d907b8ee1..7c4f20008 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -222,7 +222,7 @@ function App() { } /> } /> - } /> + } /> } /> } /> } /> diff --git a/client/src/components/header/index.tsx b/client/src/components/header/index.tsx index 98bd8020b..686f48e38 100644 --- a/client/src/components/header/index.tsx +++ b/client/src/components/header/index.tsx @@ -1,9 +1,11 @@ import { DownOutlined } from "@ant-design/icons"; import type { RefineThemedLayoutHeaderProps } from "@refinedev/antd"; -import { useGetLocale, useSetLocale } from "@refinedev/core"; -import { Layout as AntdLayout, Button, Dropdown, MenuProps, Space, Switch, theme } from "antd"; +import { useGetLocale, useSetLocale, useTranslate } from "@refinedev/core"; +import { Grid, Layout as AntdLayout, Button, Dropdown, MenuProps, Space, Switch, theme } from "antd"; import React, { useContext } from "react"; import { ColorModeContext } from "../../contexts/color-mode"; +import { getBasePath } from "../../utils/url"; +import { Version } from "../version"; import { languages } from "../../i18n"; import QRCodeScannerModal from "../qrCodeScanner"; @@ -15,6 +17,9 @@ export const Header = ({ sticky }: RefineThemedLayoutHeaderProps) => { const locale = useGetLocale(); const changeLanguage = useSetLocale(); const { mode, setMode } = useContext(ColorModeContext); + const t = useTranslate(); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; const currentLocale = locale(); @@ -41,6 +46,19 @@ export const Header = ({ sticky }: RefineThemedLayoutHeaderProps) => { return ( + + {isMobile ? : {t("version")} } + + .ant-layout { + height: 100vh; + overflow: hidden; +} + +.spoolman-root > .ant-layout > .ant-layout { + height: 100vh; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.spoolman-root .ant-layout-content { + flex: 1; + min-height: 0; + overflow: auto; + display: flex; + flex-direction: column; +} + +/* Override refine's wrapper div (inline: minHeight:360, padding) */ +.spoolman-root .ant-layout-content > div { + flex: 1 !important; + min-height: 0 !important; + display: flex !important; + flex-direction: column !important; +} + +@media (max-width: 1024px) { + .spoolman-root .ant-layout-content > div { + flex: none !important; + height: auto !important; + overflow: visible !important; + } +} + +/* Mobile: move sider hamburger into header row */ +@media (max-width: 992px) { + .spoolman-root .ant-btn-lg[style*="position: fixed"][style*="top: 64"] { + top: 12px !important; + } +} + +/* Mobile: show only icons in list header buttons */ +@media (max-width: 768px) { + [class*="page-header-heading-extra"] .ant-btn > .ant-btn-icon + span { + display: none !important; + } + + [class*="page-header-heading-extra"] .ant-space { + gap: 4px !important; + } + + [class*="page-header-heading-extra"] .ant-btn { + padding-inline: 8px !important; + } +} diff --git a/client/src/components/layout.tsx b/client/src/components/layout.tsx index 1921819b0..0d68a51dd 100644 --- a/client/src/components/layout.tsx +++ b/client/src/components/layout.tsx @@ -1,62 +1,48 @@ import { ThemedLayout, ThemedSider, ThemedTitle } from "@refinedev/antd"; -import { useTranslate } from "@refinedev/core"; -import { Button } from "antd"; -import { Footer } from "antd/es/layout/layout"; +import { Menu } from "antd"; +import React from "react"; import Logo from "../icon.svg?react"; -import { getBasePath } from "../utils/url"; import { Header } from "./header"; -import { Version } from "./version"; -const SpoolmanFooter = () => { - const t = useTranslate(); - - return ( -
-
-
- {t("version")} -
-
- -
-
-
- ); -}; +import "./layout.css"; export const SpoolmanLayout = ({ children }: { children: React.ReactNode }) => ( +
} Sider={() => ( } />} + render={({ items, logout, collapsed }) => { + const bottomKeys = ["/settings", "/help"]; + const mainItems: React.ReactNode[] = []; + const bottomItems: React.ReactNode[] = []; + + React.Children.forEach(items as React.ReactNode, (child) => { + if (!React.isValidElement(child)) return; + const key = String(child.key ?? ""); + if (bottomKeys.some((k) => key.includes(k))) { + bottomItems.push(child); + } else { + mainItems.push(child); + } + }); + + return ( + <> + {mainItems} +
  • + + {bottomItems} + {logout} + + ); + }} /> )} - Footer={() => } > {children} +
  • ); diff --git a/client/src/contexts/color-mode/index.tsx b/client/src/contexts/color-mode/index.tsx index ee606ce92..8b5db7f26 100644 --- a/client/src/contexts/color-mode/index.tsx +++ b/client/src/contexts/color-mode/index.tsx @@ -42,6 +42,14 @@ export const ColorModeContextProvider = ({ children }: PropsWithChildren) => { algorithm: mode === "light" ? defaultAlgorithm : darkAlgorithm, token: { colorPrimary: "#dc7734", + ...(mode === "dark" + ? { + colorBgBase: "#181818", + colorBgContainer: "#1f1f1f", + colorBgElevated: "#252525", + colorBgLayout: "#141414", + } + : {}), }, }} > diff --git a/client/src/pages/help/index.tsx b/client/src/pages/help/index.tsx index bf493e6fd..b4f9c1e89 100644 --- a/client/src/pages/help/index.tsx +++ b/client/src/pages/help/index.tsx @@ -1,8 +1,7 @@ import { FileOutlined, HighlightOutlined, UserOutlined } from "@ant-design/icons"; +import { List as RefineList } from "@refinedev/antd"; import { useTranslate } from "@refinedev/core"; -import { List, theme } from "antd"; -import { Content } from "antd/es/layout/layout"; -import Title from "antd/es/typography/Title"; +import { Card, List, theme } from "antd"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import { Trans } from "react-i18next"; @@ -16,61 +15,60 @@ export const Help = () => { const { token } = useToken(); const t = useTranslate(); + const resources = [ + { + title: t("filament.filament"), + description: t("help.resources.filament"), + icon: , + }, + { + title: t("spool.spool"), + description: t("help.resources.spool"), + icon: , + }, + { + title: t("vendor.vendor"), + description: t("help.resources.vendor"), + icon: , + }, + ]; + return ( - - , - title: , - filamentCreateLink: <Link to="/filament/create" />, - spoolCreateLink: <Link to="/spool/create" />, - vendorCreateLink: <Link to="/vendor/create" />, - readmeLink: <Link to="https://github.com/Donkie/Spoolman#integration-status" target="_blank" />, - itemsHelp: ( - <List - itemLayout="horizontal" - size="large" - dataSource={[ - { - title: t("filament.filament"), - description: t("help.resources.filament"), - icon: <HighlightOutlined />, - }, - { - title: t("spool.spool"), - description: t("help.resources.spool"), - icon: <FileOutlined />, - }, - { - title: t("vendor.vendor"), - description: t("help.resources.vendor"), - icon: <UserOutlined />, - }, - ]} - renderItem={(item) => ( - <List.Item> - <List.Item.Meta avatar={item.icon} title={item.title} description={item.description} /> - </List.Item> - )} - /> - ), - }} - /> - </Content> + <RefineList headerButtons={() => null} title={t("help.help")}> + <div style={{ maxWidth: 900, margin: "0 auto" }}> + <Card + style={{ + marginBottom: 16, + background: token.colorBgContainer, + }} + > + <Trans + i18nKey={"help.description"} + components={{ + p: <p style={{ marginBottom: 12, lineHeight: 1.7 }} />, + title: <span style={{ display: "none" }} />, + filamentCreateLink: <Link to="/filament/create" />, + spoolCreateLink: <Link to="/spool/create" />, + vendorCreateLink: <Link to="/vendor/create" />, + readmeLink: <Link to="https://github.com/Donkie/Spoolman#integration-status" target="_blank" />, + itemsHelp: ( + <List + itemLayout="horizontal" + size="large" + dataSource={resources} + style={{ marginTop: 16, marginBottom: 16 }} + renderItem={(item) => ( + <List.Item> + <List.Item.Meta avatar={item.icon} title={item.title} description={item.description} /> + </List.Item> + )} + /> + ), + }} + /> + </Card> + </div> + </RefineList> ); }; diff --git a/client/src/pages/home/home.css b/client/src/pages/home/home.css new file mode 100644 index 000000000..c331c8280 --- /dev/null +++ b/client/src/pages/home/home.css @@ -0,0 +1,595 @@ +.dashboard { + max-width: 1400px; + width: 100%; + margin: 0 auto; + padding: 0 4px; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} + +/* Header */ +.dashboard-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-bottom: 16px; + flex-shrink: 0; +} + +.dashboard-header h2 { + font-size: 26px; + font-weight: 800; + letter-spacing: -0.03em; + margin: 0; +} + +.dashboard-header .dash-subtitle { + font-size: 13px; + opacity: 0.4; + margin: 4px 0 0 0; +} + +.dashboard-header .dash-new-btn { + border: none; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + padding: 10px 24px; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + transition: opacity 0.2s, transform 0.1s; +} + +.dashboard-header .dash-new-btn:hover { + opacity: 0.9; +} + +.dashboard-header .dash-new-btn:active { + transform: scale(0.97); +} + +/* KPI Grid */ +.kpi-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + margin-bottom: 16px; + flex-shrink: 0; +} + +@media (max-width: 1100px) { + .kpi-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 600px) { + .kpi-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +/* Mobile: compact KPI cards */ +@media (max-width: 768px) { + .kpi-grid { + gap: 8px; + } + + .kpi-card { + padding: 12px 14px; + } + + .kpi-card .kpi-bg-icon { + display: none; + } + + .kpi-card .kpi-value { + font-size: 22px; + } + + .kpi-card .kpi-footer { + margin-top: 8px; + } +} + +.kpi-card { + padding: 20px 24px; + border-radius: 8px; + position: relative; + overflow: hidden; + border-top: 1px solid rgba(255, 255, 255, 0.03); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.kpi-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +.kpi-card .kpi-bg-icon { + position: absolute; + right: -4px; + bottom: -10px; + font-size: 80px !important; + opacity: 0.035; + pointer-events: none; + transition: opacity 0.3s ease; +} + +.kpi-card:hover .kpi-bg-icon { + opacity: 0.07; +} + +.kpi-card .kpi-label { + font-size: 10px; + text-transform: uppercase; + font-weight: 700; + letter-spacing: 0.12em; + opacity: 0.45; + margin-bottom: 6px; +} + +.kpi-card .kpi-value { + font-size: 32px; + font-weight: 800; + letter-spacing: -0.03em; + line-height: 1.1; +} + +.kpi-card .kpi-value .kpi-unit { + font-size: 14px; + font-weight: 400; + opacity: 0.45; +} + +.kpi-card .kpi-footer { + margin-top: 16px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + display: flex; + align-items: center; + gap: 4px; +} + +/* Main content area — fills remaining viewport */ +.dashboard-main { + flex: 1 1 0; + min-height: 0; + display: grid; + grid-template-columns: 2fr 1fr; + gap: 16px; + overflow: hidden; +} + +/* Left column: Tabs */ +.dashboard-main .ant-tabs { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.dashboard-main .ant-tabs-content-holder { + flex: 1; + min-height: 0; + overflow: hidden; +} + +.dashboard-main .ant-tabs-content, +.dashboard-main .ant-tabs-tabpane-active { + height: 100%; +} + +.dashboard-main .ant-tabs-tabpane-active { + overflow-y: auto; +} + +.dashboard-main .ant-tabs-tabpane-active > .dash-section { + min-height: 100%; +} + +/* Right column */ +.dash-right-col { + display: flex; + flex-direction: column; + gap: 16px; + min-height: 0; + padding-top: 46px; +} + +.dash-right-section { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 24px; + border-radius: 8px; +} + +/* Section Cards */ +.dash-section { + padding: 24px; + border-radius: 8px; +} + +.dash-section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; +} + +.dash-section-title { + font-size: 17px; + font-weight: 700; + margin: 0; + display: flex; + align-items: center; + gap: 8px; +} + +/* Low Stock */ +.low-stock-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.low-stock-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + border-radius: 6px; + cursor: pointer; + transition: background 0.15s ease; +} + +.low-stock-item:hover { + filter: brightness(1.15); +} + +.low-stock-left { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; + flex: 1; +} + +.low-stock-color-dot { + width: 42px; + height: 42px; + border-radius: 6px; + flex-shrink: 0; +} + +.low-stock-info { + min-width: 0; +} + +.low-stock-info h4 { + font-size: 13px; + font-weight: 600; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.low-stock-info p { + font-size: 11px; + opacity: 0.4; + margin: 2px 0 0 0; +} + +.low-stock-right { + text-align: right; + flex-shrink: 0; + margin-left: 16px; +} + +.low-stock-weight { + font-size: 13px; + font-weight: 700; +} + +.low-stock-weight .total { + font-weight: 400; + opacity: 0.4; +} + +.low-stock-bar { + width: 128px; + height: 5px; + border-radius: 3px; + overflow: hidden; + margin-top: 8px; + margin-left: auto; +} + +.low-stock-bar-fill { + height: 100%; + border-radius: 3px; + transition: width 0.3s ease; +} + +/* Material Bars */ +.material-list { + display: flex; + flex-direction: column; + gap: 20px; +} + +.material-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.material-name { + font-size: 13px; + font-weight: 600; +} + +.material-weight { + font-size: 13px; + font-weight: 700; +} + +.material-bar { + height: 7px; + border-radius: 4px; + overflow: hidden; +} + +.material-bar-fill { + height: 100%; + border-radius: 4px; + transition: width 0.3s ease; +} + +/* Timeline */ +.timeline-list { + display: flex; + flex-direction: column; +} + +.timeline-item { + position: relative; + padding-bottom: 24px; + padding-left: 28px; + margin-left: 6px; + border-left: 1px solid rgba(255, 255, 255, 0.06); + cursor: pointer; +} + +.timeline-item:last-child { + border-left-color: transparent; + padding-bottom: 0; +} + +.timeline-item:hover .timeline-name { + opacity: 0.7; +} + +.timeline-dot { + position: absolute; + left: -5px; + top: 0; + width: 9px; + height: 9px; + border-radius: 50%; +} + +.timeline-dot.active { + box-shadow: 0 0 10px currentColor; +} + +.timeline-time { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: -0.01em; + opacity: 0.35; +} + +.timeline-name { + font-size: 13px; + font-weight: 600; + margin-top: 3px; + transition: opacity 0.15s; +} + +.timeline-detail { + font-size: 11px; + opacity: 0.4; + margin-top: 2px; +} + +/* Location List */ +.location-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.location-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-radius: 6px; + cursor: pointer; + transition: background 0.15s ease; +} + +.location-item:hover { + filter: brightness(1.15); +} + +.location-name { + font-size: 13px; + font-weight: 500; +} + +.location-badge { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + padding: 3px 10px; + border-radius: 4px; + letter-spacing: 0.02em; +} + +/* Empty state */ +.dash-empty { + text-align: center; + padding: 32px; + opacity: 0.3; + font-size: 13px; +} + +/* Empty Hero */ +.empty-hero { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + text-align: center; + gap: 0; +} + +.empty-hero-icon { + width: 88px; + height: 88px; + border-radius: 20px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 28px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); +} + +.empty-hero-title { + font-size: 26px; + font-weight: 800; + letter-spacing: -0.03em; + margin: 0 0 8px 0; +} + +.empty-hero-desc { + font-size: 14px; + opacity: 0.5; + margin: 0 0 32px 0; + max-width: 400px; + line-height: 1.6; +} + +.empty-hero-desc a { + opacity: 1; + text-decoration: underline; +} + +.empty-hero-btn { + height: 48px !important; + padding: 0 32px !important; + font-size: 15px !important; + font-weight: 700 !important; + border-radius: 10px !important; + letter-spacing: 0.01em; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.empty-hero-btn:hover { + transform: translateY(-2px); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +.empty-hero-btn:active { + transform: scale(0.97); +} + +/* ============================================ + MOBILE OVERRIDES — must be LAST to win cascade + ============================================ */ +@media (max-width: 1024px) { + .dashboard { + overflow: auto; + flex: none; + height: auto; + } + + .dashboard-main { + display: flex; + flex-direction: column; + overflow: visible; + flex: none; + height: auto; + gap: 16px; + } + + .dashboard-main .ant-tabs { + height: auto; + } + + .dashboard-main .ant-tabs-content-holder { + flex: none; + overflow: visible; + } + + .dashboard-main .ant-tabs-content, + .dashboard-main .ant-tabs-tabpane-active { + height: auto; + } + + .dashboard-main .ant-tabs-tabpane-active { + overflow-y: visible; + } + + .dashboard-main .ant-tabs-tabpane-active > .dash-section { + min-height: 0; + } + + .dash-right-col { + padding-top: 0; + height: auto; + } + + .dash-right-section { + flex: none; + height: auto; + overflow-y: visible; + } + + /* Low stock items: compact on mobile */ + .low-stock-item { + flex-wrap: wrap; + gap: 8px; + padding: 10px 12px; + } + + .low-stock-color-dot { + width: 32px; + height: 32px; + } + + .low-stock-right { + margin-left: auto; + } + + .low-stock-bar { + width: 100px; + } +} diff --git a/client/src/pages/home/index.tsx b/client/src/pages/home/index.tsx index ec0ab95bb..e060a2477 100644 --- a/client/src/pages/home/index.tsx +++ b/client/src/pages/home/index.tsx @@ -1,29 +1,50 @@ -import { FileOutlined, HighlightOutlined, PlusOutlined, UnorderedListOutlined, UserOutlined } from "@ant-design/icons"; -import { useList, useTranslate } from "@refinedev/core"; -import { Card, Col, Row, Statistic, theme } from "antd"; -import { Content } from "antd/es/layout/layout"; -import Title from "antd/es/typography/Title"; +import { + DatabaseOutlined, + EnvironmentOutlined, + ExperimentOutlined, + HighlightOutlined, + PlusOutlined, + ShopOutlined, + ShoppingOutlined, + WarningOutlined, +} from "@ant-design/icons"; +import { useList, useNavigation, useTranslate } from "@refinedev/core"; +import { Button, Tabs, theme, Tooltip } from "antd"; import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; import utc from "dayjs/plugin/utc"; -import { ReactNode } from "react"; import { Trans } from "react-i18next"; -import { Link } from "react-router"; -import Logo from "../../icon.svg?react"; +import { Link, useNavigate } from "react-router"; +import { formatWeight } from "../../utils/parsing"; +import { useCurrencyFormatter } from "../../utils/settings"; +import { IFilament } from "../filaments/model"; import { ISpool } from "../spools/model"; +import "./home.css"; dayjs.extend(utc); +dayjs.extend(relativeTime); const { useToken } = theme; +// Dark surface palette — works on top of the app's existing dark background export const Home = () => { const { token } = useToken(); + const isDark = token.colorBgBase !== "#fff" && token.colorBgBase !== "#ffffff"; + + const S = isDark + ? { lowest: "#1a1a1a", low: "#1f1f1f", base: "#252525", high: "#2a2a2a", highest: "#313131" } + : { lowest: "#f5f5f5", low: "#ffffff", base: "#fafafa", high: "#f0f0f0", highest: "#d9d9d9" }; const t = useTranslate(); + const navigate = useNavigate(); + const { showUrl } = useNavigation(); + const currencyFormatter = useCurrencyFormatter(); - const spools = useList<ISpool>({ + const spoolsAll = useList<ISpool>({ resource: "spool", - pagination: { pageSize: 1 }, + pagination: { mode: "off" }, + meta: { queryParams: { allow_archived: false } }, }); - const filaments = useList<ISpool>({ + const filaments = useList<IFilament>({ resource: "filament", pagination: { pageSize: 1 }, }); @@ -32,94 +53,422 @@ export const Home = () => { pagination: { pageSize: 1 }, }); - const hasSpools = !spools.result || spools.result.data.length > 0; - - const ResourceStatsCard = (props: { loading: boolean; value: number; resource: string; icon: ReactNode }) => ( - <Col xs={12} md={6}> - <Card - loading={props.loading} - actions={[ - <Link to={`/${props.resource}`} key="resource"> - <UnorderedListOutlined /> - </Link>, - <Link to={`/${props.resource}/create`} key="create"> - <PlusOutlined /> - </Link>, - ]} - > - <Statistic title={t(`${props.resource}.${props.resource}`)} value={props.value} prefix={props.icon} /> - </Card> - </Col> - ); + const allSpools = spoolsAll.result?.data ?? []; + const hasSpools = allSpools.length > 0; + const isLoading = spoolsAll.query.isLoading; - return ( - <Content - style={{ - padding: "2em 20px", - minHeight: 280, - maxWidth: 800, - 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", - }} + // --- Calculations --- + const totalRemainingWeight = allSpools.reduce((sum, s) => sum + (s.remaining_weight ?? 0), 0); + const totalValue = allSpools.reduce((sum, s) => sum + (s.price ?? 0), 0); + + const lowStockSpools = allSpools + .filter((s) => { + const total = s.initial_weight ?? s.filament.weight ?? 1000; + const remaining = s.remaining_weight ?? total; + return remaining / total < 0.15; + }) + .sort((a, b) => { + const pctA = (a.remaining_weight ?? 0) / (a.initial_weight ?? a.filament.weight ?? 1000); + const pctB = (b.remaining_weight ?? 0) / (b.initial_weight ?? b.filament.weight ?? 1000); + return pctA - pctB; + }); + + const recentSpools = [...allSpools] + .filter((s) => s.last_used) + .sort((a, b) => dayjs(b.last_used).valueOf() - dayjs(a.last_used).valueOf()) + .slice(0, 5); + + const materialMap: Record<string, { count: number; weight: number }> = {}; + allSpools.forEach((s) => { + const mat = s.filament.material ?? "Unknown"; + if (!materialMap[mat]) materialMap[mat] = { count: 0, weight: 0 }; + materialMap[mat].count++; + materialMap[mat].weight += s.remaining_weight ?? 0; + }); + const materialBreakdown = Object.entries(materialMap).sort((a, b) => b[1].weight - a[1].weight); + + const locationMap: Record<string, number> = {}; + allSpools.forEach((s) => { + const loc = s.location || t("locations.no_location"); + locationMap[loc] = (locationMap[loc] ?? 0) + 1; + }); + const locationBreakdown = Object.entries(locationMap).sort((a, b) => b[1] - a[1]); + + const vendorCount: Record<string, number> = {}; + allSpools.forEach((s) => { + const name = s.filament.vendor && "name" in s.filament.vendor ? s.filament.vendor.name : "?"; + vendorCount[name] = (vendorCount[name] ?? 0) + 1; + }); + const vendorBreakdown = Object.entries(vendorCount).sort((a, b) => b[1] - a[1]); + const topVendor = vendorBreakdown[0]?.[0] ?? "-"; + + // --- Helpers --- + function getColorHex(spool: ISpool): string { + return "#" + (spool.filament.color_hex ?? "555555").replace("#", ""); + } + + function getSpoolName(spool: ISpool): string { + if (spool.filament.vendor && "name" in spool.filament.vendor) { + return `${spool.filament.vendor.name} - ${spool.filament.name}`; + } + return spool.filament.name ?? spool.filament.id.toString(); + } + + function getWeightPct(spool: ISpool): number { + const total = spool.initial_weight ?? spool.filament.weight ?? 1000; + const remaining = spool.remaining_weight ?? total; + return Math.max(0, Math.min(100, (remaining / total) * 100)); + } + + const matColors: Record<string, string> = isDark + ? { + PLA: "#81ecff", + "PLA+": "#00e3fd", + PETG: "#6ded00", + ABS: "#ff7350", + "ABS+": "#ff9070", + ASA: "#eb2f96", + TPU: "#b388ff", + "TPU 95A": "#b388ff", + "PETG-CF": "#00bcd4", + nGen: "#ff5252", + } + : { + PLA: "#0891b2", + "PLA+": "#0e7490", + PETG: "#16a34a", + ABS: "#ea580c", + "ABS+": "#f97316", + ASA: "#c026d3", + TPU: "#7c3aed", + "TPU 95A": "#7c3aed", + "PETG-CF": "#0d9488", + nGen: "#dc2626", + }; + + if (isLoading) { + return <div className="dashboard" style={{ paddingTop: 64, textAlign: "center", opacity: 0.3 }}>Loading...</div>; + } + + if (!hasSpools) { + return ( + <div className="dashboard empty-hero"> + <div className="empty-hero-icon" style={{ background: token.colorPrimary }}> + <DatabaseOutlined style={{ fontSize: 40, color: "#fff" }} /> + </div> + <h2 className="empty-hero-title">{t("home.welcome")}</h2> + <p className="empty-hero-desc"> + <Trans i18nKey="home.description" components={{ helpPageLink: <Link to="/help" /> }} /> + </p> + <Button + type="primary" + size="large" + icon={<PlusOutlined />} + onClick={() => navigate("/spool/create")} + className="empty-hero-btn" > - <Logo /> + {t("spool.titles.create")} + </Button> + </div> + ); + } + + return ( + <div className="dashboard"> + {/* Header */} + <div className="dashboard-header"> + <div> + <h2>{t("home.home")}</h2> + <p className="dash-subtitle">{t("home.telemetry_subtitle") || "Real-time status of your filament inventory."}</p> </div> - Spoolman - - - } - /> - } - /> - } +
    + +
    + + + {/* KPI Cards */} +
    +
    + +
    {t("spool.spool")}
    +
    {spoolsAll.result?.total ?? 0}
    +
    + +{allSpools.filter((s) => dayjs(s.registered).isAfter(dayjs().subtract(30, "day"))).length} THIS MONTH +
    +
    + +
    + +
    {t("filament.filament")}
    +
    {filaments.result?.total ?? 0}
    +
    + ALL SYNCED +
    +
    + +
    + +
    {t("vendor.vendor")}
    +
    {vendors.result?.total ?? 0}
    +
    + TOP: {topVendor.toUpperCase()} +
    +
    + +
    + +
    {t("home.total_weight")}
    +
    + {formatWeight(totalRemainingWeight, 1).split(" ")[0]}{" "} + {formatWeight(totalRemainingWeight, 1).split(" ")[1]} +
    +
    0 ? "#ff716c" : undefined, opacity: lowStockSpools.length > 0 ? 1 : 0.4 }}> + {lowStockSpools.length > 0 ? ( + <> {lowStockSpools.length} {t("home.low_stock").toUpperCase()} + ) : ( + {t("home.total_value")}: {currencyFormatter.format(totalValue)} + )} +
    +
    +
    + + {/* Main content area */} +
    + {/* Left Column — Tabs */} + + {t("home.low_stock")} + + ), + children: ( +
    + {lowStockSpools.length === 0 ? ( +
    {t("home.all_stocked")}
    + ) : ( +
    + {lowStockSpools.map((spool) => { + const pct = getWeightPct(spool); + const remaining = spool.remaining_weight ?? 0; + const total = spool.initial_weight ?? spool.filament.weight ?? 1000; + const barColor = pct <= 5 ? "#ff716c" : "#d7383b"; + const hex = getColorHex(spool); + + return ( +
    navigate(showUrl("spool", spool.id))} + > +
    +
    +
    +

    {getSpoolName(spool)}

    +

    Material: {spool.filament.material ?? "?"}

    +
    +
    +
    +
    + {formatWeight(remaining, 0)} / {formatWeight(total, 0)} +
    +
    +
    +
    +
    +
    + ); + })} +
    + )} +
    + ), + }, + { + key: "materials", + label: ( + + {t("home.by_material")} + + ), + children: ( +
    +
    + {materialBreakdown.map(([material, data]) => { + const maxWeight = materialBreakdown[0]?.[1].weight || 1; + const pct = (data.weight / maxWeight) * 100; + const color = matColors[material] ?? "#81ecff"; + return ( +
    +
    + {material} + {formatWeight(data.weight, 0)} +
    +
    +
    +
    +
    + ); + })} +
    +
    + ), + }, + { + key: "vendors", + label: ( + + {t("home.by_vendor")} + + ), + children: ( +
    +
    + {vendorBreakdown.map(([vendor, count], idx) => { + const maxCount = vendorBreakdown[0]?.[1] || 1; + const pct = (count / maxCount) * 100; + let barColor: string; + if (idx === 0) { + barColor = isDark ? "#81ecff" : "#0891b2"; + } else if (idx < 3) { + barColor = isDark ? "#6ded00" : "#16a34a"; + } else { + barColor = isDark ? "rgba(255,255,255,0.25)" : "rgba(0,0,0,0.2)"; + } + return ( +
    +
    + {vendor} + {count} {t("spool.spool")} +
    +
    +
    +
    +
    + ); + })} +
    +
    + ), + }, + ]} /> - - {!hasSpools && ( - <> -

    {t("home.welcome")}

    -

    - , - }} - /> -

    - - )} - + + {/* Right Column — Recently Used + Locations */} +
    +
    +
    +

    {t("home.recently_used")}

    +
    + {recentSpools.length === 0 ? ( +
    {t("home.no_recent")}
    + ) : ( +
    + {recentSpools.map((spool, idx) => { + const isFirst = idx === 0; + return ( +
    navigate(showUrl("spool", spool.id))} + > +
    +
    +
    {dayjs(spool.last_used).fromNow()}
    +
    {getSpoolName(spool)}
    +
    + {spool.filament.material ?? ""} · {formatWeight(spool.remaining_weight ?? 0, 0)} · {spool.location || t("locations.no_location")} +
    +
    +
    + ); + })} +
    + )} +
    + +
    +
    +

    + + {t("home.by_location")} +

    +
    +
    + {locationBreakdown.map(([location, count], idx) => { + let badgeBg: string; + let badgeColor: string; + if (idx === 0) { + badgeBg = isDark ? "rgba(129, 236, 255, 0.1)" : "rgba(8, 145, 178, 0.1)"; + badgeColor = isDark ? "#00e3fd" : "#0891b2"; + } else if (idx < 3) { + badgeBg = isDark ? "rgba(109, 237, 0, 0.08)" : "rgba(22, 163, 74, 0.1)"; + badgeColor = isDark ? "#6ded00" : "#16a34a"; + } else { + badgeBg = isDark ? "rgba(255, 255, 255, 0.04)" : "rgba(0, 0, 0, 0.04)"; + badgeColor = isDark ? "rgba(255, 255, 255, 0.35)" : "rgba(0, 0, 0, 0.4)"; + } + return ( +
    navigate("/locations")} + > + {location} + + {count} {t("spool.spool")} + +
    + ); + })} +
    +
    +
    +
    +
    ); }; diff --git a/client/src/pages/locations/components/location.tsx b/client/src/pages/locations/components/location.tsx index 0a90b44b4..64a855af4 100644 --- a/client/src/pages/locations/components/location.tsx +++ b/client/src/pages/locations/components/location.tsx @@ -3,7 +3,7 @@ import type { Identifier, XYCoord } from "dnd-core"; import { useRef, useState } from "react"; import { DragSourceMonitor, useDrag, useDrop } from "react-dnd"; -import { DeleteOutlined } from "@ant-design/icons"; +import { DeleteOutlined, InboxOutlined } from "@ant-design/icons"; import { useTranslate, useUpdate } from "@refinedev/core"; import { ISpool } from "../../spools/model"; import { DragItem, ItemTypes, SpoolDragItem } from "../dnd"; @@ -136,18 +136,15 @@ export function Location({ const canEditTitle = title != EMPTYLOC; - const titleStyle = { - color: canEditTitle ? undefined : token.colorTextTertiary, - }; - const spoolCountStyle = { - color: token.colorTextQuaternary, - }; - return (

    @@ -162,6 +159,7 @@ export function Location({ setEditTitle(false); return onEditTitle(newTitle); }} + style={{ fontWeight: 600 }} /> ) : ( {displayTitle} - { ({spools.length})} + 0 ? token.colorPrimaryBg : token.colorBgContainerDisabled, + color: spools.length > 0 ? token.colorPrimaryText : token.colorTextQuaternary, + }} + > + {spools.length} + )} - {showDelete &&

    - + {spools.length === 0 ? ( +
    + + {t("locations.no_locations_help") || "Drop spools here"} +
    + ) : ( + + )}
    ); } diff --git a/client/src/pages/locations/components/locationContainer.tsx b/client/src/pages/locations/components/locationContainer.tsx index 263bc75a2..26ce98f98 100644 --- a/client/src/pages/locations/components/locationContainer.tsx +++ b/client/src/pages/locations/components/locationContainer.tsx @@ -1,13 +1,17 @@ -import { PlusOutlined } from "@ant-design/icons"; import { useList, useTranslate } from "@refinedev/core"; -import { Button } from "antd"; -import { useEffect, useMemo } from "react"; +import { Input, Modal, Spin } from "antd"; +import { useEffect, useMemo, useState } 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() { +interface LocationContainerProps { + modalOpen: boolean; + setModalOpen: (open: boolean) => void; +} + +export function LocationContainer({ modalOpen, setModalOpen }: LocationContainerProps) { const t = useTranslate(); const renameSpoolLocation = useRenameSpoolLocation(); @@ -150,8 +154,15 @@ export function LocationContainer() { } }, [locationsList, settingsLocations, setLocationsSetting]); + const [newLocationName, setNewLocationName] = useState(""); + const [modalError, setModalError] = useState(""); + if (isLoading) { - return
    Loading...
    ; + return ( +
    + +
    + ); } if (isError) { @@ -159,40 +170,58 @@ export function LocationContainer() { } 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++; + const name = newLocationName.trim(); + if (!name) { + setModalError(t("locations.error_empty") || "Name cannot be empty"); + return; + } + if (locationsList.includes(name)) { + setModalError(t("locations.error_exists") || "Location already exists"); + return; } - newLocs.push(newLocationName); + const newLocs = [...locationsList]; + newLocs.push(name); setLocationsSetting.mutate(newLocs); + setModalOpen(false); + setNewLocationName(""); + setModalError(""); }; return (
    - {!isLoading && spoolData.data.length == 0 && ( -
    {t("locations.no_locations_help")}
    - )} -
    - {containers} -
    -
    + )} +
    {containers}
    ); } diff --git a/client/src/pages/locations/components/spoolCard.tsx b/client/src/pages/locations/components/spoolCard.tsx index b935636cd..4cbbe2461 100644 --- a/client/src/pages/locations/components/spoolCard.tsx +++ b/client/src/pages/locations/components/spoolCard.tsx @@ -19,6 +19,18 @@ dayjs.extend(relativeTime); const { useToken } = theme; +function getWeightPercentage(spool: ISpool): number { + const total = spool.initial_weight ?? spool.filament.weight ?? 1000; + const remaining = spool.remaining_weight ?? total; + return Math.max(0, Math.min(100, (remaining / total) * 100)); +} + +function getWeightColor(percentage: number): string { + if (percentage <= 10) return "#ff4d4f"; + if (percentage <= 25) return "#faad14"; + return "#52c41a"; +} + export function SpoolCard({ index, spool, @@ -134,6 +146,9 @@ export function SpoolCard({ filament_name = spool.filament.name ?? spool.filament.id.toString(); } + const weightPct = getWeightPercentage(spool); + const weightColor = getWeightColor(weightPct); + const opacity = draggedSpoolId === spool.id ? 0 : 1; const style = { opacity, @@ -143,15 +158,14 @@ export function SpoolCard({ function formatSubtitle(spool: ISpool) { let str = ""; - if (spool.filament.material) str += spool.filament.material + " - "; + if (spool.filament.material) str += spool.filament.material; if (spool.filament.weight) { const remaining_weight = spool.remaining_weight ?? spool.filament.weight; - str += `${formatWeight(remaining_weight, 0)} / ${formatWeight(spool.filament.weight, 0)}`; + str += ` \u00B7 ${formatWeight(remaining_weight, 0)} / ${formatWeight(spool.filament.weight, 0)}`; } 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() })}`; + str += ` \u00B7 ${dt.fromNow()}`; } return str; } @@ -164,7 +178,7 @@ export function SpoolCard({ #{spool.id} {filament_name} -
    +
    +
    +
    +
    ); diff --git a/client/src/pages/locations/components/spoolList.tsx b/client/src/pages/locations/components/spoolList.tsx index 46293c407..61deb3944 100644 --- a/client/src/pages/locations/components/spoolList.tsx +++ b/client/src/pages/locations/components/spoolList.tsx @@ -1,9 +1,6 @@ -import { theme } from "antd"; import { ISpool } from "../../spools/model"; import { SpoolCard } from "./spoolCard"; -const { useToken } = theme; - export function SpoolList({ spools, spoolOrder, @@ -13,8 +10,6 @@ export function SpoolList({ 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) => { @@ -39,13 +34,8 @@ export function SpoolList({ setSpoolOrder(newSpoolOrder); }; - const style = { - backgroundColor: token.colorBgContainer, - borderRadius: token.borderRadiusLG, - }; - return ( -
    +
    {spools.map((spool, idx) => ( ))} diff --git a/client/src/pages/locations/index.tsx b/client/src/pages/locations/index.tsx index 3b38e0b20..27dfa2f31 100644 --- a/client/src/pages/locations/index.tsx +++ b/client/src/pages/locations/index.tsx @@ -1,4 +1,8 @@ +import { PlusOutlined } from "@ant-design/icons"; +import { List } from "@refinedev/antd"; import { useTranslate } from "@refinedev/core"; +import { Button } from "antd"; +import { useState } from "react"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import { DndProvider } from "react-dnd"; @@ -11,13 +15,20 @@ dayjs.extend(utc); export const Locations = () => { const t = useTranslate(); + const [modalOpen, setModalOpen] = useState(false); + return ( -
    -

    {t("locations.locations")}

    + ( + + )} + > - + -
    + ); }; diff --git a/client/src/pages/locations/locations.css b/client/src/pages/locations/locations.css index 357d502cf..e5ecc82cc 100644 --- a/client/src/pages/locations/locations.css +++ b/client/src/pages/locations/locations.css @@ -1,11 +1,20 @@ .loc-metacontainer { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 16px; + padding: 0; } .loc-container { - padding: 1em; - width: 24em; + border-radius: 12px; + overflow: hidden; + display: flex; + flex-direction: column; + transition: box-shadow 0.2s ease; +} + +.loc-container:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); } .loc-container.grabable, @@ -16,59 +25,156 @@ cursor: -webkit-grab; } +.loc-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + .loc-container h3 { display: flex; align-items: center; justify-content: space-between; - font-size: 21px; + font-size: 16px; + font-weight: 600; width: 100%; + margin: 0; + padding: 12px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); } .loc-container h3 span { cursor: default; + display: flex; + align-items: center; + gap: 8px; } .loc-container h3 span.editable { cursor: text; } +.loc-container h3 span.editable:hover { + opacity: 0.8; +} + .loc-container h3 input { - font-size: 21px; + font-size: 16px; + font-weight: 600; margin: 0; padding: 0; border: 0; + background: transparent; +} + +.loc-spool-count { + font-size: 12px; + font-weight: 500; + padding: 2px 8px; + border-radius: 10px; + white-space: nowrap; } .loc-container .loc-spools { - padding: 0.2em; + padding: 8px; display: flex; flex-direction: column; - gap: 0.5em; - min-height: 3em; - overflow-y: scroll; + gap: 6px; + min-height: 60px; + max-height: 500px; + overflow-y: auto; + flex: 1; +} + +.loc-container .loc-spools::-webkit-scrollbar { + width: 4px; +} + +.loc-container .loc-spools::-webkit-scrollbar-thumb { + border-radius: 4px; + background: rgba(255, 255, 255, 0.1); } .loc-container .spool { - padding: 0.5em 0.5em 0.5em 0; + padding: 10px 12px; display: flex; align-items: center; - border-radius: 0.5em; + border-radius: 8px; + gap: 10px; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.loc-container .spool:hover { + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } .loc-container .spool .info { display: flex; flex-direction: column; width: 100%; + min-width: 0; } .loc-container .spool .info .title { - font-size: 1em; + font-size: 13px; + font-weight: 500; display: flex; align-items: center; justify-content: space-between; width: 100%; + gap: 8px; +} + +.loc-container .spool .info .title > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .loc-container .spool .info .subtitle { - font-size: 0.8em; + font-size: 12px; + margin-top: 2px; + opacity: 0.7; +} + +.loc-container .spool .spool-weight-bar { + width: 100%; + height: 3px; + border-radius: 2px; + margin-top: 6px; + overflow: hidden; +} + +.loc-container .spool .spool-weight-bar .spool-weight-bar-fill { + height: 100%; + border-radius: 2px; + transition: width 0.3s ease; +} + +.newLocContainer { + display: flex; + align-items: center; + justify-content: center; + min-height: 120px; + border-radius: 12px; + border: 2px dashed rgba(255, 255, 255, 0.1); + transition: border-color 0.2s ease, background 0.2s ease; +} + +.newLocContainer:hover { + border-color: rgba(255, 255, 255, 0.25); + background: rgba(255, 255, 255, 0.02); +} + +.loc-empty-state { + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + opacity: 0.4; + font-size: 13px; + font-style: italic; } diff --git a/client/src/pages/printing/spoolSelectModal.tsx b/client/src/pages/printing/spoolSelectModal.tsx index 91d8fdb36..32a960ca6 100644 --- a/client/src/pages/printing/spoolSelectModal.tsx +++ b/client/src/pages/printing/spoolSelectModal.tsx @@ -126,7 +126,7 @@ const SpoolSelectModal = ({ description, onContinue }: Props) => { tableLayout="auto" dataSource={dataSource} pagination={false} - scroll={{ y: 200 }} + scroll={{ x: "max-content" }} columns={removeUndefined([ { width: 50, diff --git a/client/src/pages/settings/extraFieldsSettings.tsx b/client/src/pages/settings/extraFieldsSettings.tsx index 76599ea76..7ba076d1e 100644 --- a/client/src/pages/settings/extraFieldsSettings.tsx +++ b/client/src/pages/settings/extraFieldsSettings.tsx @@ -22,7 +22,6 @@ import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; import { useState } from "react"; import { Trans } from "react-i18next"; -import { useParams } from "react-router"; import { DateTimePicker } from "../../components/dateTimePicker"; import { InputNumberRange } from "../../components/inputNumberRange"; import { EntityType, Field, FieldType, useDeleteField, useGetFields, useSetField } from "../../utils/queryFields"; @@ -283,13 +282,16 @@ const EditableCell = ({ record, editing, dataIndex, children, form, ...restProps return {formItem}; }; -export function ExtraFieldsSettings() { - const { entityType } = useParams<{ entityType: EntityType }>(); +interface ExtraFieldsSettingsProps { + entityType: EntityType; +} + +export function ExtraFieldsSettings({ entityType }: ExtraFieldsSettingsProps) { const t = useTranslate(); const [form] = Form.useForm(); - const fields = useGetFields(entityType as EntityType); - const setField = useSetField(entityType as EntityType); - const deleteField = useDeleteField(entityType as EntityType); + const fields = useGetFields(entityType); + const setField = useSetField(entityType); + const deleteField = useDeleteField(entityType); const [isSubmitting, setIsSubmitting] = useState(false); const [newField, setNewField] = useState(null); @@ -338,7 +340,7 @@ export function ExtraFieldsSettings() { const newFieldData: Field = { key: "new_field", name: "", - entity_type: entityType as EntityType, + entity_type: entityType, field_type: FieldType.text, unit: "", order: newOrder, @@ -442,8 +444,6 @@ export function ExtraFieldsSettings() { setIsSubmitting(false); }; - const niceName = t(`${entityType}.${entityType}`); - const columns: ColumnType[] = [ { title: t("settings.extra_fields.params.key"), @@ -598,9 +598,6 @@ export function ExtraFieldsSettings() { return ( <> -

    - {t("settings.extra_fields.tab")} - {niceName} -

    { const { token } = useToken(); const t = useTranslate(); - const navigate = useNavigate(); + const [activeKey, setActiveKey] = useState("general"); - const getCurrentKey = () => { - const path = window.location.pathname.replace("/settings", ""); - // Remove starting slash and ending slash if exists and return - return path.replace(/^\/|\/$/g, ""); + const panels: Record = { + general: , + "extra-spool": , + "extra-filament": , + "extra-vendor": , }; return ( - <> -

    null}> +
    - {t("settings.header")} -

    - - { - if (e.key === "") { - return navigate("/settings"); - } else { - return navigate(`/settings/${e.key}`); - } - }} - items={[ - { key: "", label: t("settings.general.tab"), icon: }, - { - key: "extra", - label: t("settings.extra_fields.tab"), - icon: , - children: [ - { - label: t("spool.spool"), - key: "extra/spool", - icon: , - }, - { - label: t("filament.filament"), - key: "extra/filament", - icon: , - }, - { - label: t("vendor.vendor"), - key: "extra/vendor", - icon: , - }, - ], - }, - ]} - style={{ - marginBottom: "1em", - }} - /> -
    - - } /> - } /> - -
    - - +
    + setActiveKey(e.key)} + items={[ + { + key: "general", + icon: , + label: t("settings.general.tab"), + }, + { type: "divider" }, + { + key: "extra-spool", + icon: , + label: `${t("settings.extra_fields.tab")} - ${t("spool.spool")}`, + }, + { + key: "extra-filament", + icon: , + label: `${t("settings.extra_fields.tab")} - ${t("filament.filament")}`, + }, + { + key: "extra-vendor", + icon: , + label: `${t("settings.extra_fields.tab")} - ${t("vendor.vendor")}`, + }, + ]} + /> +
    +
    + {panels[activeKey]} +
    +
    + ); }; diff --git a/client/src/pages/settings/settings.css b/client/src/pages/settings/settings.css new file mode 100644 index 000000000..dc0437ac2 --- /dev/null +++ b/client/src/pages/settings/settings.css @@ -0,0 +1,63 @@ +.settings-layout { + display: flex; + min-height: 500px; +} + +/* Left navigation panel */ +.settings-nav { + flex-shrink: 0; + width: 230px; + padding: 8px 0; + border-right: 1px solid rgba(255, 255, 255, 0.08); +} + +.settings-nav .ant-menu { + background: transparent !important; + border-inline-end: none !important; +} + +.settings-nav .ant-menu-item { + border-radius: 6px !important; + margin: 2px 8px !important; + height: 42px !important; + line-height: 42px !important; +} + +/* Right content panel */ +.settings-content { + flex: 1; + min-width: 0; + padding: 16px 32px; + overflow-x: auto; +} + +/* Mobile: stack vertically */ +@media (max-width: 768px) { + .settings-layout { + flex-direction: column; + min-height: auto; + } + + .settings-nav { + width: 100%; + border-right: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + padding: 4px 0; + } + + .settings-nav .ant-menu { + display: flex; + overflow-x: auto; + } + + .settings-nav .ant-menu-item { + margin: 4px !important; + height: 36px !important; + line-height: 36px !important; + white-space: nowrap; + } + + .settings-content { + padding: 16px 8px; + } +}