Added full i8n localization support. Available languages are English and German

This commit is contained in:
maziggy
2026-02-02 13:49:35 +01:00
parent 475773c64d
commit 71def0a9dc
38 changed files with 15033 additions and 10643 deletions
@@ -131,7 +131,7 @@ describe('ConfirmModal', () => {
it('shows default loading text when loadingText not provided', () => {
render(<ConfirmModal {...defaultProps} isLoading={true} />);
expect(screen.getByText('Processing...')).toBeInTheDocument();
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('disables buttons when loading', () => {
@@ -78,7 +78,8 @@ describe('LinkSpoolModal', () => {
render(<LinkSpoolModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('Link to Spoolman')).toBeInTheDocument();
// Look for the title in h2 element
expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument();
});
});
@@ -127,13 +128,13 @@ describe('LinkSpoolModal', () => {
render(<LinkSpoolModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('No unlinked spools found in Spoolman.')).toBeInTheDocument();
expect(screen.getByText('No unlinked spools available')).toBeInTheDocument();
});
});
it('does not render when isOpen is false', () => {
render(<LinkSpoolModal {...defaultProps} isOpen={false} />);
expect(screen.queryByText('Link to Spoolman')).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: /link to spoolman/i })).not.toBeInTheDocument();
});
});
@@ -160,7 +161,7 @@ describe('LinkSpoolModal', () => {
expect(screen.getByText('PLA Red')).toBeInTheDocument();
});
const linkButton = screen.getByRole('button', { name: /link spool/i });
const linkButton = screen.getByRole('button', { name: /link to spoolman/i });
expect(linkButton).toBeDisabled();
// Select a spool
@@ -182,7 +183,7 @@ describe('LinkSpoolModal', () => {
fireEvent.click(screen.getByText('PLA Red'));
// Click link button
fireEvent.click(screen.getByRole('button', { name: /link spool/i }));
fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
await waitFor(() => {
expect(api.linkSpool).toHaveBeenCalledWith(1, defaultProps.trayUuid);
@@ -197,7 +198,7 @@ describe('LinkSpoolModal', () => {
});
fireEvent.click(screen.getByText('PLA Red'));
fireEvent.click(screen.getByRole('button', { name: /link spool/i }));
fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith(
@@ -215,7 +216,7 @@ describe('LinkSpoolModal', () => {
});
fireEvent.click(screen.getByText('PLA Red'));
fireEvent.click(screen.getByRole('button', { name: /link spool/i }));
fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
await waitFor(() => {
expect(defaultProps.onClose).toHaveBeenCalled();
@@ -233,7 +234,7 @@ describe('LinkSpoolModal', () => {
});
fireEvent.click(screen.getByText('PLA Red'));
fireEvent.click(screen.getByRole('button', { name: /link spool/i }));
fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith(
@@ -260,7 +261,7 @@ describe('LinkSpoolModal', () => {
render(<LinkSpoolModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('Link to Spoolman')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument();
});
// Click the backdrop (the element with bg-black/60)
@@ -275,7 +276,7 @@ describe('LinkSpoolModal', () => {
render(<LinkSpoolModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('Link to Spoolman')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument();
});
// Find and click the X button in the header
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import en from '../../i18n/locales/en';
import de from '../../i18n/locales/de';
/**
* Recursively extracts all keys from a nested object as dot-notation paths.
* Example: { foo: { bar: 'baz' } } => ['foo.bar']
*/
const getKeys = (obj: object, prefix = ''): string[] => {
return Object.entries(obj).flatMap(([key, value]) => {
const path = prefix ? `${prefix}.${key}` : key;
return typeof value === 'object' && value !== null
? getKeys(value, path)
: [path];
});
};
describe('i18n locale parity', () => {
const enKeys = new Set(getKeys(en));
const deKeys = new Set(getKeys(de));
it('German locale has all English keys', () => {
const missingInGerman = [...enKeys].filter((k) => !deKeys.has(k)).sort();
expect(missingInGerman, `Missing ${missingInGerman.length} key(s) in German locale`).toEqual([]);
});
it('English locale has all German keys', () => {
const missingInEnglish = [...deKeys].filter((k) => !enKeys.has(k)).sort();
expect(missingInEnglish, `Missing ${missingInEnglish.length} key(s) in English locale`).toEqual([]);
});
it('both locales have the same number of keys', () => {
expect(enKeys.size).toBe(deKeys.size);
});
});
@@ -11,6 +11,8 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '../../contexts/ThemeContext';
import { ToastProvider } from '../../contexts/ToastContext';
import { I18nextProvider } from 'react-i18next';
import i18n from '../../i18n';
// Mock navigator.sendBeacon which isn't available in jsdom
vi.stubGlobal('navigator', {
@@ -39,15 +41,17 @@ function renderCameraPage(printerId: number) {
return rtlRender(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[`/cameras/${printerId}`]}>
<ThemeProvider>
<ToastProvider>
<Routes>
<Route path="/cameras/:printerId" element={<CameraPage />} />
</Routes>
</ToastProvider>
</ThemeProvider>
</MemoryRouter>
<I18nextProvider i18n={i18n}>
<MemoryRouter initialEntries={[`/cameras/${printerId}`]}>
<ThemeProvider>
<ToastProvider>
<Routes>
<Route path="/cameras/:printerId" element={<CameraPage />} />
</Routes>
</ToastProvider>
</ThemeProvider>
</MemoryRouter>
</I18nextProvider>
</QueryClientProvider>
);
}
@@ -94,8 +98,9 @@ describe('CameraPage', () => {
renderCameraPage(1);
await waitFor(() => {
expect(screen.getByText('Live')).toBeInTheDocument();
expect(screen.getByText('Snapshot')).toBeInTheDocument();
// Check for translation key or translated text
expect(screen.getByText(/Live|camera\.live/)).toBeInTheDocument();
expect(screen.getByText(/Snapshot|camera\.snapshot/)).toBeInTheDocument();
});
});
@@ -124,7 +129,8 @@ describe('CameraPage', () => {
renderCameraPage(0);
await waitFor(() => {
expect(screen.getByText('Invalid printer ID')).toBeInTheDocument();
// Check for translation key or translated text
expect(screen.getByText(/Invalid printer ID|camera\.invalidPrinterId/)).toBeInTheDocument();
});
});
});
+10 -5
View File
@@ -1,4 +1,5 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { AlertTriangle, Loader2 } from 'lucide-react';
import { Card, CardContent } from './Card';
import { Button } from './Button';
@@ -18,14 +19,18 @@ interface ConfirmModalProps {
export function ConfirmModal({
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
confirmText,
cancelText,
variant = 'default',
isLoading = false,
loadingText,
onConfirm,
onCancel,
}: ConfirmModalProps) {
const { t } = useTranslation();
const resolvedConfirmText = confirmText ?? t('common.confirm');
const resolvedCancelText = cancelText ?? t('common.cancel');
const resolvedLoadingText = loadingText ?? t('common.loading');
// Close on Escape key (but not while loading)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -70,7 +75,7 @@ export function ConfirmModal({
</div>
<div className="flex gap-3 mt-6">
<Button variant="secondary" onClick={onCancel} className="flex-1" disabled={isLoading}>
{cancelText}
{resolvedCancelText}
</Button>
<Button
onClick={onConfirm}
@@ -80,10 +85,10 @@ export function ConfirmModal({
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{loadingText || 'Processing...'}
{resolvedLoadingText}
</>
) : (
confirmText
resolvedConfirmText
)}
</Button>
</div>
+49 -49
View File
@@ -1,30 +1,28 @@
import { useState, useEffect, useRef } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash, Link } from 'lucide-react';
import { api } from '../api/client';
import type { Archive } from '../api/client';
import { Button } from './Button';
const FAILURE_REASONS = [
'Adhesion failure',
'Spaghetti / Detached',
'Layer shift',
'Clogged nozzle',
'Filament runout',
'Warping',
'Stringing',
'Under-extrusion',
'Power failure',
'User cancelled',
'Other',
];
// Keys for failure reasons - translated at render time
const FAILURE_REASON_KEYS = [
'adhesionFailure',
'spaghettiDetached',
'layerShift',
'cloggedNozzle',
'filamentRunout',
'warping',
'stringing',
'underExtrusion',
'powerFailure',
'userCancelled',
'other',
] as const;
const ARCHIVE_STATUSES = [
{ value: 'completed', label: 'Completed' },
{ value: 'failed', label: 'Failed' },
{ value: 'aborted', label: 'Cancelled' },
{ value: 'printing', label: 'Printing' },
];
// Keys for archive statuses - translated at render time
const ARCHIVE_STATUS_KEYS = ['completed', 'failed', 'aborted', 'printing'] as const;
interface EditArchiveModalProps {
archive: Archive;
@@ -33,6 +31,8 @@ interface EditArchiveModalProps {
}
export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditArchiveModalProps) {
const { t } = useTranslation();
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -205,7 +205,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
<h2 className="text-lg font-semibold text-white">Edit Archive</h2>
<h2 className="text-lg font-semibold text-white">{t('editArchive.title')}</h2>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
@@ -218,25 +218,25 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
<form onSubmit={handleSubmit} className="p-6 space-y-4 overflow-y-auto flex-1">
{/* Print Name */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Name</label>
<label className="block text-sm text-bambu-gray mb-1">{t('editArchive.name')}</label>
<input
type="text"
value={printName}
onChange={(e) => setPrintName(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
placeholder="Print name"
placeholder={t('editArchive.namePlaceholder')}
/>
</div>
{/* Printer */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Printer</label>
<label className="block text-sm text-bambu-gray mb-1">{t('editArchive.printer')}</label>
<select
value={printerId ?? ''}
onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="">No printer</option>
<option value="">{t('editArchive.noPrinter')}</option>
{printers?.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
@@ -249,14 +249,14 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
<div>
<label className="block text-sm text-bambu-gray mb-1">
<FolderKanban className="w-4 h-4 inline mr-1" />
Project
{t('editArchive.project')}
</label>
<select
value={projectId ?? ''}
onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="">No project</option>
<option value="">{t('editArchive.noProject')}</option>
{projects?.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
@@ -269,7 +269,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
<div>
<label className="block text-sm text-bambu-gray mb-1">
<Hash className="w-4 h-4 inline mr-1" />
Items Printed
{t('editArchive.itemsPrinted')}
</label>
<input
type="number"
@@ -280,19 +280,19 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
placeholder="1"
/>
<p className="text-xs text-bambu-gray mt-1">
Number of items produced in this print job
{t('editArchive.itemsPrintedHelp')}
</p>
</div>
{/* Notes */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Notes</label>
<label className="block text-sm text-bambu-gray mb-1">{t('editArchive.notes')}</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none resize-none"
placeholder="Add notes about this print..."
placeholder={t('editArchive.notesPlaceholder')}
/>
</div>
@@ -300,7 +300,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
<div>
<label className="block text-sm text-bambu-gray mb-1">
<Link className="w-4 h-4 inline mr-1" />
External Link
{t('editArchive.externalLink')}
</label>
<input
type="url"
@@ -310,13 +310,13 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
placeholder="https://printables.com/model/..."
/>
<p className="text-xs text-bambu-gray mt-1">
Link to Printables, Thingiverse, or other source
{t('editArchive.externalLinkHelp')}
</p>
</div>
{/* Tags */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Tags</label>
<label className="block text-sm text-bambu-gray mb-1">{t('editArchive.tags')}</label>
{/* Current tags as chips */}
{currentTags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
@@ -355,13 +355,13 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
blurTimeoutRef.current = window.setTimeout(() => setShowTagSuggestions(false), 200);
}}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
placeholder={currentTags.length > 0 ? "Add more tags..." : "Add tags..."}
placeholder={currentTags.length > 0 ? t('editArchive.addMoreTags') : t('editArchive.tagsPlaceholder')}
/>
{/* Suggestions dropdown */}
{showTagSuggestions && tagSuggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10 max-h-40 overflow-y-auto">
<div className="p-2 text-xs text-bambu-gray border-b border-bambu-dark-tertiary">
{currentInput ? `Matching "${currentInput}"` : 'Existing tags'} (click to add)
{currentInput ? t('editArchive.matchingTags', { query: currentInput }) : t('editArchive.existingTags')} {t('editArchive.clickToAdd')}
</div>
<div className="p-2 flex flex-wrap gap-1.5">
{tagSuggestions.map((tag) => (
@@ -382,7 +382,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
{/* Status */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Status</label>
<label className="block text-sm text-bambu-gray mb-1">{t('editArchive.status')}</label>
<select
value={status}
onChange={(e) => {
@@ -394,9 +394,9 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
}}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
{ARCHIVE_STATUSES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
{ARCHIVE_STATUS_KEYS.map((statusKey) => (
<option key={statusKey} value={statusKey}>
{t(`editArchive.statuses.${statusKey}`)}
</option>
))}
</select>
@@ -405,16 +405,16 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
{/* Failure Reason - only show for failed/aborted prints */}
{(status === 'failed' || status === 'aborted') && (
<div>
<label className="block text-sm text-bambu-gray mb-1">Failure Reason</label>
<label className="block text-sm text-bambu-gray mb-1">{t('editArchive.failureReason')}</label>
<select
value={failureReason}
onChange={(e) => setFailureReason(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="">Select reason...</option>
{FAILURE_REASONS.map((reason) => (
<option key={reason} value={reason}>
{reason}
<option value="">{t('editArchive.selectReason')}</option>
{FAILURE_REASON_KEYS.map((reasonKey) => (
<option key={reasonKey} value={t(`editArchive.failureReasons.${reasonKey}`)}>
{t(`editArchive.failureReasons.${reasonKey}`)}
</option>
))}
</select>
@@ -425,7 +425,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
<div>
<label className="block text-sm text-bambu-gray mb-1">
<Camera className="w-4 h-4 inline mr-1" />
Photos of Printed Result
{t('editArchive.photos')}
</label>
{/* Photo grid */}
<div className="flex flex-wrap gap-2 mb-2">
@@ -433,7 +433,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
<div key={filename} className="relative group">
<img
src={api.getArchivePhotoUrl(archive.id, filename)}
alt="Print result"
alt={t('editArchive.printResult')}
className="w-20 h-20 object-cover rounded-lg border border-bambu-dark-tertiary"
/>
<button
@@ -462,7 +462,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
)}
</label>
</div>
<p className="text-xs text-bambu-gray">Click + to add photos of your printed result</p>
<p className="text-xs text-bambu-gray">{t('editArchive.photosHelp')}</p>
</div>
{/* Actions */}
@@ -473,7 +473,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
onClick={onClose}
className="flex-1"
>
Cancel
{t('common.cancel')}
</Button>
<Button
type="submit"
@@ -481,7 +481,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
className="flex-1"
>
<Save className="w-4 h-4" />
{updateMutation.isPending ? 'Saving...' : 'Save'}
{updateMutation.isPending ? t('common.saving') : t('common.save')}
</Button>
</div>
</form>
+16 -13
View File
@@ -1,4 +1,5 @@
import { useState, useRef, useEffect, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Droplets, Link2, Copy, Check, Settings2, ExternalLink } from 'lucide-react';
interface FilamentData {
@@ -38,6 +39,7 @@ interface FilamentHoverCardProps {
* Replaces the basic browser tooltip with a styled popover.
*/
export function FilamentHoverCard({ data, children, disabled, className = '', spoolman, configureSlot }: FilamentHoverCardProps) {
const { t } = useTranslation();
const [isVisible, setIsVisible] = useState(false);
const [position, setPosition] = useState<'top' | 'bottom'>('top');
const [copied, setCopied] = useState(false);
@@ -201,7 +203,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
{/* Profile name */}
<div className="flex items-center justify-between">
<span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
Profile
{t('ams.profile')}
</span>
<span className="text-xs text-white font-semibold truncate max-w-[120px]">
{data.profile}
@@ -211,7 +213,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
{/* K Factor */}
<div className="flex items-center justify-between">
<span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
K Factor
{t('ams.kFactor')}
</span>
<span className="text-xs text-bambu-green font-mono font-bold">
{data.kFactor}
@@ -223,7 +225,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
<div className="flex items-center justify-between">
<span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium flex items-center gap-1">
<Droplets className="w-3 h-3" />
Fill
{t('ams.fill')}
</span>
<span className="text-xs text-white font-semibold">
{data.fillLevel !== null ? `${data.fillLevel}%` : '—'}
@@ -251,7 +253,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
{/* Tray UUID with copy button */}
<div className="flex items-center justify-between">
<span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
Spool ID
{t('spoolman.spoolId')}
</span>
<button
onClick={(e) => {
@@ -280,10 +282,10 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green"
title="View this spool in Spoolman"
title={t('spoolman.openInSpoolman')}
>
<ExternalLink className="w-3.5 h-3.5" />
Open in Spoolman
{t('spoolman.openInSpoolman')}
</a>
)}
@@ -302,10 +304,10 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
? 'bg-bambu-gray/10 text-bambu-gray cursor-not-allowed'
: 'bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green'
}`}
title={spoolman.hasUnlinkedSpools === false ? 'No unlinked spools available' : 'Link this spool to a Spoolman spool'}
title={spoolman.hasUnlinkedSpools === false ? t('spoolman.noUnlinkedSpools') : t('spoolman.linkToSpoolman')}
>
<Link2 className="w-3.5 h-3.5" />
Link to Spoolman
{t('spoolman.linkToSpoolman')}
</button>
)}
</div>
@@ -320,10 +322,10 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
configureSlot.onConfigure?.();
}}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
title="Configure slot with filament profile and K value"
title={t('ams.configureSlot')}
>
<Settings2 className="w-3.5 h-3.5" />
Configure
{t('ams.configure')}
</button>
</div>
)}
@@ -357,6 +359,7 @@ interface EmptySlotHoverCardProps {
* Wrapper for empty slots - shows "Empty" on hover with optional configure button
*/
export function EmptySlotHoverCard({ children, className = '', configureSlot }: EmptySlotHoverCardProps) {
const { t } = useTranslation();
const [isVisible, setIsVisible] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -394,7 +397,7 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot }:
rounded-md shadow-lg overflow-hidden
">
<div className="px-3 py-1.5 text-xs text-bambu-gray whitespace-nowrap">
Empty slot
{t('ams.emptySlot')}
</div>
{/* Configure slot button */}
{configureSlot?.enabled && (
@@ -405,10 +408,10 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot }:
configureSlot.onConfigure?.();
}}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
title="Configure slot with filament profile and K value"
title={t('ams.configureSlot')}
>
<Settings2 className="w-3.5 h-3.5" />
Configure
{t('ams.configure')}
</button>
</div>
)}
+13 -11
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
X,
Folder,
@@ -82,6 +83,7 @@ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
];
export function FileManagerModal({ printerId, printerName, onClose }: FileManagerModalProps) {
const { t } = useTranslation();
const { showToast } = useToast();
const queryClient = useQueryClient();
const [currentPath, setCurrentPath] = useState('/');
@@ -119,13 +121,13 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
}
},
onSuccess: () => {
showToast(`Deleted ${filesToDelete.length} file${filesToDelete.length > 1 ? 's' : ''}`);
showToast(t('printerFiles.toast.filesDeleted', { count: filesToDelete.length }));
queryClient.invalidateQueries({ queryKey: ['printerFiles', printerId] });
setSelectedFiles(new Set());
setFilesToDelete([]);
},
onError: (error: Error) => {
showToast(`Delete failed: ${error.message}`, 'error');
showToast(t('printerFiles.toast.deleteFailed', { error: error.message }), 'error');
},
});
@@ -227,7 +229,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
<div className="flex items-center gap-3">
<HardDrive className="w-5 h-5 text-bambu-green" />
<div>
<h2 className="text-lg font-semibold text-white">File Manager</h2>
<h2 className="text-lg font-semibold text-white">{t('printerFiles.title')}</h2>
<p className="text-sm text-bambu-gray">{printerName}</p>
</div>
</div>
@@ -236,13 +238,13 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
{storageData && (storageData.used_bytes != null || storageData.free_bytes != null) && (
<div className="text-sm text-bambu-gray flex items-center gap-2">
{storageData.used_bytes != null && (
<span>Used: {formatStorageSize(storageData.used_bytes)}</span>
<span>{t('printerFiles.storageUsed')} {formatStorageSize(storageData.used_bytes)}</span>
)}
{storageData.used_bytes != null && storageData.free_bytes != null && (
<span className="text-bambu-dark-tertiary">|</span>
)}
{storageData.free_bytes != null && (
<span>Free: {formatStorageSize(storageData.free_bytes)}</span>
<span>{t('printerFiles.storageFree')} {formatStorageSize(storageData.free_bytes)}</span>
)}
</div>
)}
@@ -278,7 +280,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
type="text"
placeholder="Filter files..."
placeholder={t('printerFiles.filterPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-40 pl-8 pr-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
@@ -481,7 +483,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
) : (
<Trash2 className="w-4 h-4" />
)}
Delete{selectedFiles.size > 1 ? ` (${selectedFiles.size})` : ''}
{t('printerFiles.deleteButton')}{selectedFiles.size > 1 ? ` (${selectedFiles.size})` : ''}
</Button>
</div>
</div>
@@ -490,13 +492,13 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
{/* Delete Confirmation Modal */}
{filesToDelete.length > 0 && (
<ConfirmModal
title={filesToDelete.length > 1 ? `Delete ${filesToDelete.length} Files` : 'Delete File'}
title={filesToDelete.length > 1 ? t('printerFiles.deleteFiles', { count: filesToDelete.length }) : t('fileManager.deleteFile')}
message={
filesToDelete.length > 1
? `Delete ${filesToDelete.length} selected files? This cannot be undone.`
: `Delete "${filesToDelete[0].split('/').pop()}"? This cannot be undone.`
? t('printerFiles.deleteFilesConfirm', { count: filesToDelete.length })
: t('printerFiles.deleteFileConfirm', { name: filesToDelete[0].split('/').pop() })
}
confirmText="Delete"
confirmText={t('common.delete')}
variant="danger"
onConfirm={() => {
deleteMutation.mutate(filesToDelete);
+7 -4
View File
@@ -1,6 +1,7 @@
// HMS Error Modal - Comprehensive error code database
// Source: https://github.com/greghesp/ha-bambulab
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { X, AlertTriangle, AlertCircle, Info, ExternalLink } from 'lucide-react';
import type { HMSError } from '../api/client';
@@ -904,6 +905,8 @@ function getHMSHomeUrl(): string {
}
export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalProps) {
const { t } = useTranslation();
// Debug: log errors to see what data we're receiving
console.log('HMSErrorModal errors:', JSON.stringify(errors, null, 2));
@@ -930,7 +933,7 @@ export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalPro
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-400" />
<h2 className="text-lg font-semibold text-white">Errors - {printerName}</h2>
<h2 className="text-lg font-semibold text-white">{t('hmsErrors.title', { name: printerName })}</h2>
</div>
<button
onClick={onClose}
@@ -945,7 +948,7 @@ export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalPro
{knownErrors.length === 0 ? (
<div className="text-center py-8 text-bambu-gray">
<AlertCircle className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p>No errors</p>
<p>{t('hmsErrors.noErrors')}</p>
</div>
) : (
<div className="space-y-3">
@@ -979,7 +982,7 @@ export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalPro
className="inline-flex items-center gap-1 text-xs text-bambu-green hover:underline"
>
<ExternalLink className="w-3 h-3" />
View on Bambu Lab Wiki
{t('hmsErrors.viewOnWiki')}
</a>
</div>
</div>
@@ -993,7 +996,7 @@ export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalPro
{/* Footer */}
<div className="p-4 border-t border-bambu-dark-tertiary">
<p className="text-xs text-bambu-gray">
Clear errors on the printer to dismiss them here.
{t('hmsErrors.clearInstructions')}
</p>
</div>
</div>
+97 -95
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
Gauge,
Loader2,
@@ -171,6 +172,7 @@ function KProfileModal({
onSaveNote,
hasPermission,
}: KProfileModalProps) {
const { t } = useTranslation();
const { showToast } = useToast();
const [name, setName] = useState(profile?.name || '');
@@ -219,7 +221,7 @@ function KProfileModal({
},
onSuccess: (result) => {
console.log('[KProfile] Save success:', result);
showToast('K-profile saved');
showToast(t('kProfiles.toast.profileSaved'));
// Save note if it changed (including clearing it)
if (onSaveNote && note !== initialNote) {
let profileKey: string;
@@ -258,7 +260,7 @@ function KProfileModal({
},
onSuccess: (result) => {
console.log('[KProfile] Delete success:', result);
showToast('K-profile deleted');
showToast(t('kProfiles.toast.profileDeleted'));
// Show syncing indicator while printer processes the command
setIsSyncing(true);
// Add longer delay for delete - printer needs more time to process
@@ -294,7 +296,7 @@ function KProfileModal({
// Validate at least one extruder is selected for dual-nozzle
if (isDualNozzle && !profile && selectedExtruders.length === 0) {
showToast('Please select at least one extruder', 'error');
showToast(t('kProfiles.toast.selectAtLeastOneExtruder'), 'error');
return;
}
@@ -340,7 +342,7 @@ function KProfileModal({
try {
await api.setKProfilesBatch(printerId, batchPayload);
showToast(`K-profile saved to ${selectedExtruders.length} extruders`);
showToast(t('kProfiles.toast.profilesSaved', { count: selectedExtruders.length }));
// Save note for new batch profiles
if (onSaveNote && note) {
const profileKey = `name_${name}_${filamentId}`;
@@ -348,7 +350,7 @@ function KProfileModal({
}
} catch (error) {
console.error('[KProfile] Failed to save batch:', error);
showToast('Failed to save K-profiles', 'error');
showToast(t('kProfiles.toast.failedToSaveBatch'), 'error');
setIsSyncing(false);
setSavingProgress({ current: 0, total: 0 });
return;
@@ -373,16 +375,16 @@ function KProfileModal({
<Loader2 className="w-8 h-8 text-bambu-green animate-spin mb-3" />
<p className="text-white font-medium">
{savingProgress.total > 1
? `Saving to extruder ${savingProgress.current}/${savingProgress.total}...`
: 'Syncing with printer...'}
? t('kProfiles.modal.savingExtruder', { current: savingProgress.current, total: savingProgress.total })
: t('kProfiles.modal.syncing')}
</p>
<p className="text-bambu-gray text-sm mt-1">Please wait</p>
<p className="text-bambu-gray text-sm mt-1">{t('kProfiles.modal.pleaseWait')}</p>
</div>
)}
<CardContent className="p-0">
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<h2 className="text-xl font-semibold text-white">
{profile ? 'Edit K-Profile' : 'Add K-Profile'}
{profile ? t('kProfiles.modal.editTitle') : t('kProfiles.modal.addTitle')}
</h2>
<button
onClick={onClose}
@@ -396,21 +398,21 @@ function KProfileModal({
<form onSubmit={handleSubmit} className="p-4 space-y-4">
{/* Profile Name - read-only when editing */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Profile Name</label>
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.profileName')}</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={!!profile}
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
placeholder="My PLA Profile"
placeholder={t('kProfiles.modal.profileNamePlaceholder')}
required={!profile}
/>
</div>
{/* K-Value - always editable */}
<div>
<label className="block text-sm text-bambu-gray mb-1">K-Value</label>
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.kValue')}</label>
<input
type="text"
inputMode="decimal"
@@ -430,17 +432,17 @@ function KProfileModal({
}
}}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none font-mono"
placeholder="0.020"
placeholder={t('kProfiles.modal.kValuePlaceholder')}
required
/>
<p className="text-xs text-bambu-gray mt-1">
Typical range: 0.01 - 0.06 for PLA, 0.02 - 0.10 for PETG
{t('kProfiles.modal.kValueHelp')}
</p>
</div>
{/* Filament - read-only when editing */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Filament</label>
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.filament')}</label>
<select
value={filamentId}
onChange={(e) => {
@@ -460,7 +462,7 @@ function KProfileModal({
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
required={!profile}
>
<option value="">Select filament...</option>
<option value="">{t('kProfiles.modal.selectFilament')}</option>
{/* Show current filament when editing - look up from knownFilaments */}
{profile?.filament_id && (
<option key={profile.filament_id} value={profile.filament_id}>
@@ -476,7 +478,7 @@ function KProfileModal({
</select>
{!profile && knownFilaments.length === 0 && (
<p className="text-xs text-bambu-gray mt-1">
No filaments found. Create a K-profile in Bambu Studio first.
{t('kProfiles.modal.noFilamentsHelp')}
</p>
)}
</div>
@@ -484,7 +486,7 @@ function KProfileModal({
{/* Flow Type and Nozzle Size - read-only when editing */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-bambu-gray mb-1">Flow Type</label>
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.flowType')}</label>
<select
value={nozzleType}
onChange={(e) => {
@@ -503,12 +505,12 @@ function KProfileModal({
disabled={!!profile}
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
>
<option value="HH00">High Flow</option>
<option value="HS00">Standard</option>
<option value="HH00">{t('kProfiles.modal.highFlow')}</option>
<option value="HS00">{t('kProfiles.modal.standard')}</option>
</select>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Nozzle Size</label>
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.nozzleSize')}</label>
<select
value={modalDiameter}
onChange={(e) => setModalDiameter(e.target.value)}
@@ -527,12 +529,12 @@ function KProfileModal({
{isDualNozzle && (
<div>
<label className="block text-sm text-bambu-gray mb-1">
{profile ? 'Extruder' : 'Extruders'}
{profile ? t('kProfiles.modal.extruder') : t('kProfiles.modal.extruders')}
</label>
{profile ? (
// Read-only display for editing
<div className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white opacity-60">
{profile.extruder_id === 1 ? 'Left' : 'Right'}
{profile.extruder_id === 1 ? t('kProfiles.modal.left') : t('kProfiles.modal.right')}
</div>
) : (
// Checkboxes for new profile - can select both
@@ -550,7 +552,7 @@ function KProfileModal({
}}
className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0 accent-bambu-green"
/>
<span className="text-white">Left</span>
<span className="text-white">{t('kProfiles.modal.left')}</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
@@ -565,7 +567,7 @@ function KProfileModal({
}}
className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0 accent-bambu-green"
/>
<span className="text-white">Right</span>
<span className="text-white">{t('kProfiles.modal.right')}</span>
</label>
</div>
)}
@@ -574,16 +576,16 @@ function KProfileModal({
{/* Notes */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Notes (stored locally)</label>
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.notes')}</label>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Add notes about this profile..."
placeholder={t('kProfiles.modal.notesPlaceholder')}
rows={2}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none resize-none"
/>
<p className="text-xs text-bambu-gray mt-1">
Notes are saved in Bambuddy, not on the printer
{t('kProfiles.modal.notesHelp')}
</p>
</div>
@@ -594,7 +596,7 @@ function KProfileModal({
variant="secondary"
onClick={() => setShowDeleteConfirm(true)}
disabled={deleteMutation.isPending || isSyncing || !hasPermission('kprofiles:delete')}
title={!hasPermission('kprofiles:delete') ? 'You do not have permission to delete K-profiles' : undefined}
title={!hasPermission('kprofiles:delete') ? t('kProfiles.permission.noDelete') : undefined}
className="text-red-500 hover:bg-red-500/10"
>
{deleteMutation.isPending ? (
@@ -611,12 +613,12 @@ function KProfileModal({
disabled={isSyncing}
className="flex-1"
>
Cancel
{t('common.cancel')}
</Button>
<Button
type="submit"
disabled={saveMutation.isPending || isSyncing || !hasPermission(profile ? 'kprofiles:update' : 'kprofiles:create')}
title={!hasPermission(profile ? 'kprofiles:update' : 'kprofiles:create') ? `You do not have permission to ${profile ? 'update' : 'create'} K-profiles` : undefined}
title={!hasPermission(profile ? 'kprofiles:update' : 'kprofiles:create') ? t(profile ? 'kProfiles.permission.noUpdate' : 'kProfiles.permission.noCreate') : undefined}
className="flex-1"
>
{saveMutation.isPending ? (
@@ -624,7 +626,7 @@ function KProfileModal({
) : (
<Gauge className="w-4 h-4" />
)}
Save
{t('common.save')}
</Button>
</div>
</form>
@@ -641,12 +643,12 @@ function KProfileModal({
<Trash2 className="w-5 h-5 text-red-500" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Delete Profile</h3>
<p className="text-sm text-bambu-gray">This cannot be undone</p>
<h3 className="text-lg font-semibold text-white">{t('kProfiles.deleteConfirm.title')}</h3>
<p className="text-sm text-bambu-gray">{t('kProfiles.deleteConfirm.cannotUndo')}</p>
</div>
</div>
<p className="text-bambu-gray mb-6">
Are you sure you want to delete <span className="text-white font-medium">"{profile?.name}"</span> from the printer?
{t('kProfiles.deleteConfirm.message', { name: profile?.name })}
</p>
<div className="flex gap-3">
<Button
@@ -654,7 +656,7 @@ function KProfileModal({
onClick={() => setShowDeleteConfirm(false)}
className="flex-1"
>
Cancel
{t('common.cancel')}
</Button>
<Button
onClick={() => {
@@ -669,7 +671,7 @@ function KProfileModal({
) : (
<Trash2 className="w-4 h-4" />
)}
Delete
{t('common.delete')}
</Button>
</div>
</CardContent>
@@ -691,6 +693,7 @@ const STORAGE_KEYS = {
};
export function KProfilesView() {
const { t } = useTranslation();
const { showToast } = useToast();
const { hasPermission } = useAuth();
const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
@@ -882,7 +885,7 @@ export function KProfilesView() {
// Export profiles to JSON file
const handleExport = useCallback(() => {
if (!kprofiles?.profiles || kprofiles.profiles.length === 0) {
showToast('No profiles to export', 'error');
showToast(t('kProfiles.toast.noProfilesToExport'), 'error');
return;
}
@@ -910,8 +913,8 @@ export function KProfilesView() {
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast(`Exported ${kprofiles.profiles.length} profiles`);
}, [kprofiles?.profiles, selectedPrinterData, nozzleDiameter, showToast]);
showToast(t('kProfiles.toast.exportedProfiles', { count: kprofiles.profiles.length }));
}, [kprofiles?.profiles, selectedPrinterData, nozzleDiameter, showToast, t]);
// Import profiles from JSON file
const handleImport = useCallback(() => {
@@ -927,7 +930,7 @@ export function KProfilesView() {
const data = JSON.parse(text);
if (!data.profiles || !Array.isArray(data.profiles)) {
showToast('Invalid file format', 'error');
showToast(t('kProfiles.toast.invalidFileFormat'), 'error');
return;
}
@@ -954,15 +957,15 @@ export function KProfilesView() {
}
}
showToast(`Imported ${imported} of ${data.profiles.length} profiles`);
showToast(t('kProfiles.toast.importedProfiles', { count: imported, total: data.profiles.length }));
refetchProfiles();
} catch (err) {
console.error('Import error:', err);
showToast('Failed to parse import file', 'error');
showToast(t('kProfiles.toast.failedToParseImport'), 'error');
}
};
input.click();
}, [selectedPrinter, nozzleDiameter, showToast, refetchProfiles]);
}, [selectedPrinter, nozzleDiameter, showToast, refetchProfiles, t]);
// Toggle profile selection using composite key
const toggleProfileSelection = useCallback((profileKey: string) => {
@@ -1012,13 +1015,13 @@ export function KProfilesView() {
}
}
showToast(`Deleted ${deleted} profiles`);
showToast(t('kProfiles.toast.profilesDeleted', { count: deleted }));
setBulkDeleteInProgress(false);
setShowBulkDeleteConfirm(false);
setSelectionMode(false);
setSelectedProfiles(new Set());
refetchProfiles();
}, [selectedPrinter, selectedProfiles, filteredProfiles, showToast, refetchProfiles, getProfileKey]);
}, [selectedPrinter, selectedProfiles, filteredProfiles, showToast, refetchProfiles, getProfileKey, t]);
// Generate possible keys for a profile (for notes lookup)
// Returns array of keys to check: setting_id, slot-based, name-based
@@ -1042,9 +1045,9 @@ export function KProfilesView() {
refetchNotes();
} catch (err) {
console.error('Failed to save note:', err);
showToast('Failed to save note', 'error');
showToast(t('kProfiles.toast.failedToSaveNote'), 'error');
}
}, [selectedPrinter, refetchNotes, showToast]);
}, [selectedPrinter, refetchNotes, showToast, t]);
// Get note for a profile (checks all possible keys)
// Returns { note, key } so we know which key the note was stored under
@@ -1077,9 +1080,9 @@ export function KProfilesView() {
<Card>
<CardContent className="py-12 text-center">
<AlertCircle className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">No Printers Configured</h3>
<h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.noPrintersConfigured')}</h3>
<p className="text-bambu-gray">
Add a printer in Settings to manage K-profiles
{t('kProfiles.addPrinterInSettings')}
</p>
</CardContent>
</Card>
@@ -1091,9 +1094,9 @@ export function KProfilesView() {
<Card>
<CardContent className="py-12 text-center">
<Printer className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">No Active Printers</h3>
<h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.noActivePrinters')}</h3>
<p className="text-bambu-gray">
Enable a printer connection to view its K-profiles
{t('kProfiles.enablePrinterConnection')}
</p>
</CardContent>
</Card>
@@ -1106,14 +1109,14 @@ export function KProfilesView() {
{isFetching && !kprofilesLoading && (
<div className="fixed inset-0 bg-black/50 flex flex-col items-center justify-center z-40">
<Loader2 className="w-10 h-10 text-bambu-green animate-spin mb-3" />
<p className="text-white font-medium">Loading K-Profiles...</p>
<p className="text-white font-medium">{t('kProfiles.loadingProfiles')}</p>
</div>
)}
{/* Printer & Nozzle Selector */}
<div className="flex flex-wrap gap-4 mb-6">
<div className="flex-1 min-w-48">
<label className="block text-sm text-bambu-gray mb-1">Printer</label>
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.printer')}</label>
<select
value={selectedPrinter || ''}
onChange={(e) => setSelectedPrinter(parseInt(e.target.value))}
@@ -1128,7 +1131,7 @@ export function KProfilesView() {
</div>
<div className="w-32">
<label className="block text-sm text-bambu-gray mb-1">Nozzle</label>
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.nozzle')}</label>
<select
value={nozzleDiameter}
onChange={(e) => setNozzleDiameter(e.target.value)}
@@ -1146,18 +1149,18 @@ export function KProfilesView() {
variant="secondary"
onClick={() => refetchProfiles()}
disabled={isFetching || !hasPermission('kprofiles:read')}
title={!hasPermission('kprofiles:read') ? 'You do not have permission to refresh profiles' : undefined}
title={!hasPermission('kprofiles:read') ? t('kProfiles.permission.noRead') : undefined}
>
<RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin' : ''}`} />
Refresh
{t('kProfiles.refresh')}
</Button>
<Button
onClick={() => setShowAddModal(true)}
disabled={!hasPermission('kprofiles:create')}
title={!hasPermission('kprofiles:create') ? 'You do not have permission to add profiles' : undefined}
title={!hasPermission('kprofiles:create') ? t('kProfiles.permission.noCreate') : undefined}
>
<Plus className="w-4 h-4" />
Add Profile
{t('kProfiles.addProfile')}
</Button>
</div>
</div>
@@ -1170,7 +1173,7 @@ export function KProfilesView() {
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by name or filament..."
placeholder={t('kProfiles.searchPlaceholder')}
className="w-full pl-10 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
@@ -1181,9 +1184,9 @@ export function KProfilesView() {
onChange={(e) => setExtruderFilter(e.target.value as ExtruderFilter)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="all">All Extruders</option>
<option value="left">Left Only</option>
<option value="right">Right Only</option>
<option value="all">{t('kProfiles.allExtruders')}</option>
<option value="left">{t('kProfiles.leftOnly')}</option>
<option value="right">{t('kProfiles.rightOnly')}</option>
</select>
</div>
)}
@@ -1193,9 +1196,9 @@ export function KProfilesView() {
onChange={(e) => setFlowTypeFilter(e.target.value as FlowTypeFilter)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="all">All Flow</option>
<option value="hf">HF Only</option>
<option value="s">S Only</option>
<option value="all">{t('kProfiles.allFlow')}</option>
<option value="hf">{t('kProfiles.hfOnly')}</option>
<option value="s">{t('kProfiles.sOnly')}</option>
</select>
</div>
<div className="w-32">
@@ -1204,9 +1207,9 @@ export function KProfilesView() {
onChange={(e) => setSortOption(e.target.value as SortOption)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="name">Sort: Name</option>
<option value="k_value">Sort: K-Value</option>
<option value="filament">Sort: Filament</option>
<option value="name">{t('kProfiles.sortName')}</option>
<option value="k_value">{t('kProfiles.sortKValue')}</option>
<option value="filament">{t('kProfiles.sortFilament')}</option>
</select>
</div>
</div>
@@ -1217,19 +1220,19 @@ export function KProfilesView() {
variant="secondary"
onClick={handleExport}
disabled={!kprofiles?.profiles?.length || !hasPermission('kprofiles:read')}
title={!hasPermission('kprofiles:read') ? 'You do not have permission to export profiles' : 'Export profiles to JSON'}
title={!hasPermission('kprofiles:read') ? t('kProfiles.permission.noExport') : undefined}
>
<Download className="w-4 h-4" />
Export
{t('kProfiles.export')}
</Button>
<Button
variant="secondary"
onClick={handleImport}
disabled={!hasPermission('kprofiles:create')}
title={!hasPermission('kprofiles:create') ? 'You do not have permission to import profiles' : 'Import profiles from JSON'}
title={!hasPermission('kprofiles:create') ? t('kProfiles.permission.noImport') : undefined}
>
<Upload className="w-4 h-4" />
Import
{t('kProfiles.import')}
</Button>
<div className="flex-1" />
{selectionMode ? (
@@ -1237,20 +1240,19 @@ export function KProfilesView() {
<Button
variant="secondary"
onClick={selectAllProfiles}
title="Select all visible profiles"
>
<CheckSquare className="w-4 h-4" />
Select All
{t('kProfiles.selectAll')}
</Button>
<Button
variant="secondary"
onClick={handleBulkDelete}
disabled={selectedProfiles.size === 0 || !hasPermission('kprofiles:delete')}
className="text-red-500 hover:bg-red-500/10"
title={!hasPermission('kprofiles:delete') ? 'You do not have permission to delete profiles' : `Delete ${selectedProfiles.size} selected profiles`}
title={!hasPermission('kprofiles:delete') ? t('kProfiles.permission.noDelete') : undefined}
>
<Trash2 className="w-4 h-4" />
Delete ({selectedProfiles.size})
{t('kProfiles.delete')} ({selectedProfiles.size})
</Button>
<Button
variant="secondary"
@@ -1260,7 +1262,7 @@ export function KProfilesView() {
}}
>
<X className="w-4 h-4" />
Cancel
{t('common.cancel')}
</Button>
</>
) : (
@@ -1268,10 +1270,10 @@ export function KProfilesView() {
variant="secondary"
onClick={() => setSelectionMode(true)}
disabled={!filteredProfiles.length || !hasPermission('kprofiles:delete')}
title={!hasPermission('kprofiles:delete') ? 'You do not have permission to delete profiles' : 'Enter selection mode for bulk delete'}
title={!hasPermission('kprofiles:delete') ? t('kProfiles.permission.noDelete') : undefined}
>
<CheckSquare className="w-4 h-4" />
Select
{t('kProfiles.select')}
</Button>
)}
</div>
@@ -1285,13 +1287,13 @@ export function KProfilesView() {
<Card>
<CardContent className="py-12 text-center">
<WifiOff className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">Printer Offline</h3>
<h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.printerOffline')}</h3>
<p className="text-bambu-gray mb-4">
The selected printer is not connected. Power it on to view K-profiles.
{t('kProfiles.printerOfflineDesc')}
</p>
<Button variant="secondary" onClick={() => refetchProfiles()}>
<RefreshCw className="w-4 h-4" />
Retry
{t('common.refresh')}
</Button>
</CardContent>
</Card>
@@ -1301,7 +1303,7 @@ export function KProfilesView() {
<div className="grid grid-cols-2 gap-4">
{/* Left Extruder (extruder_id 1 on Bambu) */}
<div>
<h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">Left Extruder</h3>
<h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">{t('kProfiles.leftExtruder')}</h3>
<div className="space-y-1">
{filteredProfiles
.filter((p) => p.extruder_id === 1)
@@ -1321,7 +1323,7 @@ export function KProfilesView() {
</div>
{/* Right Extruder (extruder_id 0 on Bambu) */}
<div>
<h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">Right Extruder</h3>
<h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">{t('kProfiles.rightExtruder')}</h3>
<div className="space-y-1">
{filteredProfiles
.filter((p) => p.extruder_id === 0)
@@ -1361,9 +1363,9 @@ export function KProfilesView() {
<Card>
<CardContent className="py-12 text-center">
<Search className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">No Matching Profiles</h3>
<h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.noMatchingProfiles')}</h3>
<p className="text-bambu-gray">
No profiles match your search criteria
{t('kProfiles.noMatchingProfilesDesc')}
</p>
</CardContent>
</Card>
@@ -1371,13 +1373,13 @@ export function KProfilesView() {
<Card>
<CardContent className="py-12 text-center">
<Gauge className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">No K-Profiles</h3>
<h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.noKProfiles')}</h3>
<p className="text-bambu-gray mb-4">
No pressure advance profiles found for {nozzleDiameter}mm nozzle
{t('kProfiles.noKProfilesDesc', { diameter: nozzleDiameter })}
</p>
<Button onClick={() => setShowAddModal(true)}>
<Plus className="w-4 h-4" />
Create First Profile
{t('kProfiles.createFirstProfile')}
</Button>
</CardContent>
</Card>
@@ -1466,12 +1468,12 @@ export function KProfilesView() {
<Trash2 className="w-5 h-5 text-red-500" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Delete Profiles</h3>
<p className="text-sm text-bambu-gray">This cannot be undone</p>
<h3 className="text-lg font-semibold text-white">{t('kProfiles.bulkDelete.title')}</h3>
<p className="text-sm text-bambu-gray">{t('kProfiles.bulkDelete.cannotUndo')}</p>
</div>
</div>
<p className="text-bambu-gray mb-6">
Are you sure you want to delete <span className="text-white font-medium">{selectedProfiles.size}</span> selected profiles from the printer?
{t('kProfiles.bulkDelete.message', { count: selectedProfiles.size })}
</p>
<div className="flex gap-3">
<Button
@@ -1480,7 +1482,7 @@ export function KProfilesView() {
disabled={bulkDeleteInProgress}
className="flex-1"
>
Cancel
{t('common.cancel')}
</Button>
<Button
onClick={executeBulkDelete}
@@ -1492,7 +1494,7 @@ export function KProfilesView() {
) : (
<Trash2 className="w-4 h-4" />
)}
Delete
{t('common.delete')}
</Button>
</div>
</CardContent>
+20 -21
View File
@@ -619,7 +619,7 @@ export function Layout() {
<button
onClick={() => setShowChangePasswordModal(true)}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title="Change Password"
title={t('changePassword.title')}
>
<Key className="w-5 h-5" />
</button>
@@ -723,7 +723,7 @@ export function Layout() {
<button
onClick={() => setShowChangePasswordModal(true)}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title="Change Password"
title={t('changePassword.title')}
>
<Key className="w-5 h-5" />
</button>
@@ -824,20 +824,19 @@ export function Layout() {
</svg>
</div>
<h2 className="text-xl font-bold text-yellow-400 mb-2">
Print Paused!
{t('plateAlert.title')}
</h2>
<p className="text-lg text-white mb-2">
{plateDetectionAlert.printer_name}
</p>
<p className="text-bambu-gray mb-6">
Objects detected on build plate. The print has been automatically paused.
Please clear the plate and resume the print.
{t('plateAlert.message')}
</p>
<button
onClick={() => setPlateDetectionAlert(null)}
className="w-full py-3 px-6 bg-yellow-500 hover:bg-yellow-600 text-black font-semibold rounded-lg transition-colors"
>
I Understand
{t('plateAlert.understand')}
</button>
</div>
</div>
@@ -861,7 +860,7 @@ export function Layout() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Key className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">Change Password</h2>
<h2 className="text-lg font-semibold text-white">{t('changePassword.title')}</h2>
</div>
<Button
variant="ghost"
@@ -879,34 +878,34 @@ export function Layout() {
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-white mb-2">
Current Password
{t('changePassword.currentPassword')}
</label>
<input
type="password"
value={changePasswordData.currentPassword}
onChange={(e) => setChangePasswordData({ ...changePasswordData, currentPassword: e.target.value })}
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder="Enter current password"
placeholder={t('changePassword.currentPasswordPlaceholder')}
autoComplete="current-password"
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
New Password
{t('changePassword.newPassword')}
</label>
<input
type="password"
value={changePasswordData.newPassword}
onChange={(e) => setChangePasswordData({ ...changePasswordData, newPassword: e.target.value })}
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder="Enter new password (min 6 characters)"
placeholder={t('changePassword.newPasswordPlaceholder')}
autoComplete="new-password"
minLength={6}
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
Confirm New Password
{t('changePassword.confirmPassword')}
</label>
<input
type="password"
@@ -917,12 +916,12 @@ export function Layout() {
? 'border-red-500'
: 'border-bambu-dark-tertiary'
}`}
placeholder="Confirm new password"
placeholder={t('changePassword.confirmPasswordPlaceholder')}
autoComplete="new-password"
minLength={6}
/>
{changePasswordData.confirmPassword && changePasswordData.newPassword !== changePasswordData.confirmPassword && (
<p className="text-red-400 text-xs mt-1">Passwords do not match</p>
<p className="text-red-400 text-xs mt-1">{t('changePassword.passwordsDoNotMatch')}</p>
)}
</div>
</div>
@@ -934,26 +933,26 @@ export function Layout() {
setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
}}
>
Cancel
{t('common.cancel')}
</Button>
<Button
onClick={async () => {
if (changePasswordData.newPassword !== changePasswordData.confirmPassword) {
showToast('Passwords do not match', 'error');
showToast(t('changePassword.passwordsDoNotMatch'), 'error');
return;
}
if (changePasswordData.newPassword.length < 6) {
showToast('Password must be at least 6 characters', 'error');
showToast(t('changePassword.passwordTooShort'), 'error');
return;
}
setChangePasswordLoading(true);
try {
await api.changePassword(changePasswordData.currentPassword, changePasswordData.newPassword);
showToast('Password changed successfully', 'success');
showToast(t('changePassword.success'), 'success');
setShowChangePasswordModal(false);
setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to change password';
const message = error instanceof Error ? error.message : t('changePassword.failed');
showToast(message, 'error');
} finally {
setChangePasswordLoading(false);
@@ -964,12 +963,12 @@ export function Layout() {
{changePasswordLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Changing...
{t('changePassword.changing')}
</>
) : (
<>
<Key className="w-4 h-4" />
Change Password
{t('changePassword.title')}
</>
)}
</Button>
+11 -10
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Loader2, Link2, Check } from 'lucide-react';
import { api } from '../api/client';
import { Button } from './Button';
@@ -17,6 +18,7 @@ interface LinkSpoolModalProps {
}
export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoolModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
@@ -35,11 +37,11 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });
queryClient.invalidateQueries({ queryKey: ['linked-spools'] });
queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });
showToast('Spool linked to Spoolman successfully', 'success');
showToast(t('spoolman.linkSuccess'), 'success');
onClose();
},
onError: (error: Error) => {
showToast(`Failed to link spool: ${error.message}`, 'error');
showToast(`${t('spoolman.linkFailed')}: ${error.message}`, 'error');
},
});
@@ -65,7 +67,7 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2">
<Link2 className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">Link to Spoolman</h2>
<h2 className="text-lg font-semibold text-white">{t('spoolman.linkToSpoolman')}</h2>
</div>
<button
onClick={onClose}
@@ -96,14 +98,14 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
{/* Spool UUID */}
<div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-xs text-bambu-gray mb-1">Spool UUID:</p>
<p className="text-xs text-bambu-gray mb-1">{t('spoolman.spoolId')}:</p>
<code className="text-xs text-bambu-green font-mono break-all">{trayUuid}</code>
</div>
{/* Spool list */}
<div>
<p className="text-sm text-bambu-gray mb-2">
Select a Spoolman spool to link:
{t('spoolman.selectSpool')}:
</p>
{isLoading ? (
@@ -148,8 +150,7 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
</div>
) : (
<div className="text-center py-8 text-bambu-gray">
<p>No unlinked spools found in Spoolman.</p>
<p className="text-xs mt-1">All spools are already linked to AMS trays.</p>
<p>{t('spoolman.noUnlinkedSpools')}</p>
</div>
)}
</div>
@@ -158,7 +159,7 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
{/* Footer */}
<div className="flex justify-end gap-2 p-4 border-t border-bambu-dark-tertiary">
<Button variant="secondary" onClick={onClose}>
Cancel
{t('common.cancel')}
</Button>
<Button
onClick={handleLink}
@@ -167,12 +168,12 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
{linkMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Linking...
{t('spoolman.syncing')}
</>
) : (
<>
<Link2 className="w-4 h-4" />
Link Spool
{t('spoolman.linkToSpoolman')}
</>
)}
</Button>
+18 -16
View File
@@ -1,4 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Play, Square, Trash2, RefreshCw, ArrowDown, ArrowUp, Search } from 'lucide-react';
import { api, type MQTTLogEntry } from '../api/client';
import { Button } from './Button';
@@ -11,6 +12,7 @@ interface MQTTDebugModalProps {
}
export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [autoScroll, setAutoScroll] = useState(true);
const [expandedLogs, setExpandedLogs] = useState<Set<number>>(new Set());
@@ -119,7 +121,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div>
<h2 className="text-lg font-semibold text-white">MQTT Debug Log</h2>
<h2 className="text-lg font-semibold text-white">{t('mqttDebug.title')}</h2>
<p className="text-sm text-bambu-gray">{printerName}</p>
</div>
<button
@@ -141,7 +143,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
disabled={disableMutation.isPending}
>
<Square className="w-4 h-4" />
Stop
{t('mqttDebug.stopLogging')}
</Button>
) : (
<Button
@@ -150,7 +152,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
disabled={enableMutation.isPending}
>
<Play className="w-4 h-4" />
Start Logging
{t('mqttDebug.startLogging')}
</Button>
)}
<Button
@@ -160,7 +162,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
disabled={clearMutation.isPending || logs.length === 0}
>
<Trash2 className="w-4 h-4" />
Clear
{t('mqttDebug.clearLog')}
</Button>
<Button
size="sm"
@@ -191,7 +193,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
type="text"
placeholder="Search topic or payload..."
placeholder={t('mqttDebug.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
@@ -214,7 +216,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
: 'text-bambu-gray hover:text-white'
}`}
>
All
{t('mqttDebug.all')}
</button>
<button
onClick={() => setDirectionFilter('in')}
@@ -225,7 +227,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
}`}
>
<ArrowDown className="w-3 h-3" />
In
{t('mqttDebug.incoming')}
</button>
<button
onClick={() => setDirectionFilter('out')}
@@ -236,7 +238,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
}`}
>
<ArrowUp className="w-3 h-3" />
Out
{t('mqttDebug.outgoing')}
</button>
</div>
</div>
@@ -249,15 +251,15 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
>
{logs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-bambu-gray">
<p className="mb-2">No messages logged yet</p>
<p className="mb-2">{t('mqttDebug.noMessages')}</p>
{!loggingEnabled && (
<p className="text-sm">Click "Start Logging" to begin capturing MQTT messages</p>
<p className="text-sm">{t('mqttDebug.startLoggingHint')}</p>
)}
</div>
) : filteredLogs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-bambu-gray">
<p className="mb-2">No messages match your filter</p>
<p className="text-sm">Try adjusting your search or filter criteria</p>
<p className="mb-2">{t('mqttDebug.noMessagesMatch')}</p>
<p className="text-sm">{t('mqttDebug.adjustFilterHint')}</p>
</div>
) : (
<div className="space-y-1">
@@ -281,7 +283,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
className={`shrink-0 ${
isIncoming ? 'text-blue-400' : 'text-green-400'
}`}
title={isIncoming ? 'Incoming' : 'Outgoing'}
title={isIncoming ? t('mqttDebug.incoming') : t('mqttDebug.outgoing')}
>
{isIncoming ? (
<ArrowDown className="w-3 h-3" />
@@ -313,14 +315,14 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
{loggingEnabled ? (
<span className="flex items-center gap-2">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Logging active - messages will auto-refresh
{t('mqttDebug.loggingActive')}
</span>
) : (
<span>Logging stopped</span>
<span>{t('mqttDebug.loggingStopped')}</span>
)}
</div>
<Button variant="secondary" onClick={onClose}>
Close
{t('common.close')}
</Button>
</div>
</div>
+14 -12
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Printer, Loader2, Calendar, Pencil, AlertCircle, AlertTriangle } from 'lucide-react';
import { api } from '../../api/client';
import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client';
@@ -41,6 +42,7 @@ export function PrintModal({
onClose,
onSuccess,
}: PrintModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
@@ -529,35 +531,35 @@ export function PrintModal({
if (mode === 'reprint') {
return {
title: isLibraryFile ? 'Print' : 'Re-print',
title: isLibraryFile ? t('queue.print') : t('queue.reprint'),
icon: Printer,
submitText: printerCount > 1 ? `Print to ${printerCount} Printers` : 'Print',
submitText: printerCount > 1 ? t('queue.printToPrinters', { count: printerCount }) : t('queue.print'),
submitIcon: Printer,
loadingText: submitProgress.total > 1
? `Sending ${submitProgress.current}/${submitProgress.total}...`
: 'Sending...',
? t('queue.sendingProgress', { current: submitProgress.current, total: submitProgress.total })
: t('queue.sending'),
};
}
if (mode === 'add-to-queue') {
return {
title: 'Schedule Print',
title: t('queue.schedulePrint'),
icon: Calendar,
submitText: printerCount > 1 ? `Queue to ${printerCount} Printers` : 'Add to Queue',
submitText: printerCount > 1 ? t('queue.queueToPrinters', { count: printerCount }) : t('queue.addToQueue'),
submitIcon: Calendar,
loadingText: submitProgress.total > 1
? `Adding ${submitProgress.current}/${submitProgress.total}...`
: 'Adding...',
? t('queue.addingProgress', { current: submitProgress.current, total: submitProgress.total })
: t('queue.adding'),
};
}
// edit-queue-item mode
return {
title: 'Edit Queue Item',
title: t('queue.editQueueItem'),
icon: Pencil,
submitText: 'Save',
submitText: t('common.save'),
submitIcon: Pencil,
loadingText: submitProgress.total > 1
? `Saving ${submitProgress.current}/${submitProgress.total}...`
: 'Saving...',
? t('queue.savingProgress', { current: submitProgress.current, total: submitProgress.total })
: t('common.saving'),
};
};
+12 -10
View File
@@ -1,5 +1,6 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Upload, X, File, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
import { api } from '../api/client';
import type { BulkUploadResult } from '../api/client';
@@ -20,6 +21,7 @@ interface UploadModalProps {
}
export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -150,7 +152,7 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
<CardContent className="p-0 flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<h2 className="text-xl font-semibold text-white">Upload 3MF Files</h2>
<h2 className="text-xl font-semibold text-white">{t('uploadModal.title')}</h2>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
@@ -173,15 +175,15 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
>
<Upload className="w-12 h-12 mx-auto mb-4 text-bambu-gray" />
<p className="text-white mb-2">
Drag & drop .3mf files here
{t('uploadModal.dragDrop')}
</p>
<p className="text-bambu-gray text-sm mb-4">or</p>
<p className="text-bambu-gray text-sm mb-4">{t('uploadModal.or')}</p>
<Button
variant="secondary"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
Browse Files
{t('uploadModal.browseFiles')}
</Button>
<input
ref={fileInputRef}
@@ -197,7 +199,7 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
{/* Info about printer model extraction */}
<div className="px-4 pb-4">
<p className="text-xs text-bambu-gray">
The printer model will be automatically extracted from the 3MF file metadata.
{t('uploadModal.extractionInfo')}
</p>
</div>
@@ -249,9 +251,9 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
<div className="px-4 pb-4">
<div className="p-3 bg-bambu-dark rounded-lg">
<p className="text-sm text-white">
<span className="text-bambu-green">{uploadResult.uploaded}</span> uploaded
<span className="text-bambu-green">{uploadResult.uploaded}</span> {t('uploadModal.uploaded')}
{uploadResult.failed > 0 && (
<>, <span className="text-red-400">{uploadResult.failed}</span> failed</>
<>, <span className="text-red-400">{uploadResult.failed}</span> {t('uploadModal.failed')}</>
)}
</p>
</div>
@@ -261,7 +263,7 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
{/* Footer */}
<div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary">
<Button variant="secondary" onClick={onClose} className="flex-1">
{uploadResult ? 'Close' : 'Cancel'}
{uploadResult ? t('common.close') : t('common.cancel')}
</Button>
{!uploadResult && (
<Button
@@ -272,12 +274,12 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
{isUploading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Uploading...
{t('uploadModal.uploading')}
</>
) : (
<>
<Upload className="w-4 h-4" />
Upload {pendingCount > 0 && `(${pendingCount})`}
{t('uploadModal.upload')} {pendingCount > 0 && `(${pendingCount})`}
</>
)}
</Button>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+18 -16
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff, ZoomIn, ZoomOut } from 'lucide-react';
import { api } from '../api/client';
@@ -10,6 +11,7 @@ const MAX_RECONNECT_DELAY = 30000; // 30 seconds
const STALL_CHECK_INTERVAL = 5000; // Check every 5 seconds
export function CameraPage() {
const { t } = useTranslation();
const { printerId } = useParams<{ printerId: string }>();
const id = parseInt(printerId || '0', 10);
@@ -539,7 +541,7 @@ export function CameraPage() {
if (!id) {
return (
<div className="min-h-screen bg-black flex items-center justify-center">
<p className="text-white">Invalid printer ID</p>
<p className="text-white">{t('camera.invalidPrinterId')}</p>
</div>
);
}
@@ -564,7 +566,7 @@ export function CameraPage() {
: 'text-bambu-gray hover:text-white disabled:opacity-50'
}`}
>
Live
{t('camera.live')}
</button>
<button
onClick={() => switchToMode('snapshot')}
@@ -575,21 +577,21 @@ export function CameraPage() {
: 'text-bambu-gray hover:text-white disabled:opacity-50'
}`}
>
Snapshot
{t('camera.snapshot')}
</button>
</div>
<button
onClick={refresh}
disabled={isDisabled}
className="p-1.5 hover:bg-bambu-dark-tertiary rounded disabled:opacity-50"
title={streamMode === 'stream' ? 'Restart stream' : 'Refresh snapshot'}
title={streamMode === 'stream' ? t('camera.restartStream') : t('camera.refreshSnapshot')}
>
<RefreshCw className={`w-4 h-4 text-bambu-gray ${isDisabled ? 'animate-spin' : ''}`} />
</button>
<button
onClick={toggleFullscreen}
className="p-1.5 hover:bg-bambu-dark-tertiary rounded"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
title={isFullscreen ? t('camera.exitFullscreen') : t('camera.fullscreen')}
>
{isFullscreen ? (
<Minimize className="w-4 h-4 text-bambu-gray" />
@@ -618,7 +620,7 @@ export function CameraPage() {
<div className="text-center">
<RefreshCw className="w-8 h-8 text-bambu-gray animate-spin mx-auto mb-2" />
<p className="text-sm text-bambu-gray">
{streamMode === 'stream' ? 'Connecting to camera...' : 'Capturing snapshot...'}
{streamMode === 'stream' ? t('camera.connectingToCamera') : t('camera.capturingSnapshot')}
</p>
</div>
</div>
@@ -627,15 +629,15 @@ export function CameraPage() {
<div className="absolute inset-0 flex items-center justify-center bg-black/80 z-10">
<div className="text-center p-4">
<WifiOff className="w-10 h-10 text-orange-400 mx-auto mb-3" />
<p className="text-white mb-2">Connection lost</p>
<p className="text-white mb-2">{t('camera.connectionLost')}</p>
<p className="text-sm text-bambu-gray mb-3">
Reconnecting in {reconnectCountdown}s... (attempt {reconnectAttempts + 1}/{MAX_RECONNECT_ATTEMPTS})
{t('camera.reconnecting', { countdown: reconnectCountdown, attempt: reconnectAttempts + 1, max: MAX_RECONNECT_ATTEMPTS })}
</p>
<button
onClick={refresh}
className="px-4 py-2 bg-bambu-green text-white text-sm rounded hover:bg-bambu-green/80 transition-colors"
>
Reconnect now
{t('camera.reconnectNow')}
</button>
</div>
</div>
@@ -644,15 +646,15 @@ export function CameraPage() {
<div className="absolute inset-0 flex items-center justify-center bg-black z-10">
<div className="text-center p-4">
<AlertTriangle className="w-12 h-12 text-orange-400 mx-auto mb-3" />
<p className="text-white mb-2">Camera unavailable</p>
<p className="text-white mb-2">{t('camera.cameraUnavailable')}</p>
<p className="text-xs text-bambu-gray mb-4 max-w-md">
Make sure the printer is powered on and connected.
{t('camera.cameraUnavailableDesc')}
</p>
<button
onClick={refresh}
className="px-4 py-2 bg-bambu-green text-white rounded hover:bg-bambu-green/80 transition-colors"
>
Retry
{t('camera.retry')}
</button>
</div>
</div>
@@ -661,7 +663,7 @@ export function CameraPage() {
ref={imgRef}
key={imageKey}
src={currentUrl}
alt="Camera stream"
alt={t('camera.cameraStream')}
className="max-w-full max-h-full object-contain select-none"
style={{
transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px)`,
@@ -679,14 +681,14 @@ export function CameraPage() {
onClick={handleZoomOut}
disabled={zoomLevel <= 1}
className="p-1.5 hover:bg-white/10 rounded disabled:opacity-30"
title="Zoom out"
title={t('camera.zoomOut')}
>
<ZoomOut className="w-4 h-4 text-white" />
</button>
<button
onClick={resetZoom}
className="px-2 py-1 text-sm text-white hover:bg-white/10 rounded min-w-[48px]"
title="Reset zoom"
title={t('camera.resetZoom')}
>
{Math.round(zoomLevel * 100)}%
</button>
@@ -694,7 +696,7 @@ export function CameraPage() {
onClick={handleZoomIn}
disabled={zoomLevel >= 4}
className="p-1.5 hover:bg-white/10 rounded disabled:opacity-30"
title="Zoom in"
title={t('camera.zoomIn')}
>
<ZoomIn className="w-4 h-4 text-white" />
</button>
+3 -1
View File
@@ -1,10 +1,12 @@
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Loader2, AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { api } from '../api/client';
import { useTheme } from '../contexts/ThemeContext';
export function ExternalLinkPage() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const { mode } = useTheme();
@@ -26,7 +28,7 @@ export function ExternalLinkPage() {
return (
<div className="flex flex-col items-center justify-center h-full gap-4 text-bambu-gray">
<AlertTriangle className="w-12 h-12" />
<p>Link not found</p>
<p>{t('common.linkNotFound')}</p>
</div>
);
}
File diff suppressed because it is too large Load Diff
+31 -29
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
X,
Plus,
@@ -25,6 +26,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
export function GroupsPage() {
const navigate = useNavigate();
const { t } = useTranslation();
const { hasPermission } = useAuth();
const { showToast } = useToast();
const queryClient = useQueryClient();
@@ -74,7 +76,7 @@ export function GroupsPage() {
queryClient.invalidateQueries({ queryKey: ['groups'] });
setShowCreateModal(false);
resetForm();
showToast('Group created successfully');
showToast(t('groups.toast.created'));
},
onError: (error: Error) => {
showToast(error.message, 'error');
@@ -87,7 +89,7 @@ export function GroupsPage() {
queryClient.invalidateQueries({ queryKey: ['groups'] });
setEditingGroup(null);
resetForm();
showToast('Group updated successfully');
showToast(t('groups.toast.updated'));
},
onError: (error: Error) => {
showToast(error.message, 'error');
@@ -98,7 +100,7 @@ export function GroupsPage() {
mutationFn: (id: number) => api.deleteGroup(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['groups'] });
showToast('Group deleted successfully');
showToast(t('groups.toast.deleted'));
},
onError: (error: Error) => {
showToast(error.message, 'error');
@@ -112,7 +114,7 @@ export function GroupsPage() {
const handleCreate = () => {
if (!formData.name.trim()) {
showToast('Please enter a group name', 'error');
showToast(t('groups.toast.enterGroupName'), 'error');
return;
}
createMutation.mutate({
@@ -125,7 +127,7 @@ export function GroupsPage() {
const handleUpdate = () => {
if (!editingGroup) return;
if (!formData.name.trim()) {
showToast('Please enter a group name', 'error');
showToast(t('groups.toast.enterGroupName'), 'error');
return;
}
updateMutation.mutate({
@@ -206,7 +208,7 @@ export function GroupsPage() {
<CardContent className="py-6">
<div className="flex items-center gap-3 text-red-400">
<Shield className="w-5 h-5" />
<p className="text-white">You do not have permission to access this page.</p>
<p className="text-white">{t('groups.noPermission')}</p>
</div>
</CardContent>
</Card>
@@ -283,17 +285,17 @@ export function GroupsPage() {
<button
onClick={() => navigate('/settings?tab=users')}
className="p-2 rounded-lg bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
title="Back to Settings"
title={t('groups.backToSettings')}
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<Shield className="w-6 h-6 text-bambu-green" />
Group Management
{t('groups.title')}
</h1>
<p className="text-sm text-bambu-gray mt-1">
Manage permission groups for access control
{t('groups.subtitle')}
</p>
</div>
</div>
@@ -305,7 +307,7 @@ export function GroupsPage() {
}}
>
<Plus className="w-4 h-4" />
Create Group
{t('groups.createGroup')}
</Button>
)}
</div>
@@ -335,34 +337,34 @@ export function GroupsPage() {
<h3 className="text-lg font-semibold text-white">{group.name}</h3>
{group.is_system && (
<span className="px-2 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400">
System
{t('groups.system')}
</span>
)}
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-bambu-gray mb-4">{group.description || 'No description'}</p>
<p className="text-sm text-bambu-gray mb-4">{group.description || t('groups.noDescription')}</p>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-bambu-gray">
<Users className="w-4 h-4" />
<span>{group.user_count} users</span>
<span>{t('groups.usersCount', { count: group.user_count })}</span>
</div>
<div className="text-xs text-bambu-gray">
{group.permissions.length} permissions
{t('groups.permissionsCount', { count: group.permissions.length })}
</div>
</div>
<div className="flex gap-2 mt-4 pt-4 border-t border-bambu-dark-tertiary">
{hasPermission('groups:update') && (
<Button size="sm" variant="ghost" onClick={() => startEdit(group)}>
<Edit2 className="w-4 h-4" />
Edit
{t('groups.edit')}
</Button>
)}
{hasPermission('groups:delete') && !group.is_system && (
<Button size="sm" variant="ghost" onClick={() => handleDelete(group.id)}>
<Trash2 className="w-4 h-4" />
Delete
{t('groups.delete')}
</Button>
)}
</div>
@@ -391,7 +393,7 @@ export function GroupsPage() {
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">
{editingGroup ? 'Edit Group' : 'Create Group'}
{editingGroup ? t('groups.modal.editGroup') : t('groups.modal.createGroup')}
</h2>
</div>
<Button
@@ -411,7 +413,7 @@ export function GroupsPage() {
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-white mb-2">
Group Name
{t('groups.form.groupName')}
</label>
<input
type="text"
@@ -419,27 +421,27 @@ export function GroupsPage() {
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
disabled={editingGroup?.is_system}
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors disabled:opacity-50"
placeholder="Enter group name"
placeholder={t('groups.form.groupNamePlaceholder')}
/>
{editingGroup?.is_system && (
<p className="text-xs text-yellow-400 mt-1">System group names cannot be changed</p>
<p className="text-xs text-yellow-400 mt-1">{t('groups.form.systemGroupWarning')}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
Description
{t('groups.form.description')}
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors resize-none"
placeholder="Enter description (optional)"
placeholder={t('groups.form.descriptionPlaceholder')}
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
Permissions ({formData.permissions.length} selected)
{t('groups.form.permissions', { count: formData.permissions.length })}
</label>
{renderPermissionEditor()}
</div>
@@ -453,7 +455,7 @@ export function GroupsPage() {
resetForm();
}}
>
Cancel
{t('groups.modal.cancel')}
</Button>
<Button
onClick={editingGroup ? handleUpdate : handleCreate}
@@ -462,12 +464,12 @@ export function GroupsPage() {
{(createMutation.isPending || updateMutation.isPending) ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{editingGroup ? 'Saving...' : 'Creating...'}
{editingGroup ? t('groups.modal.saving') : t('groups.modal.creating')}
</>
) : (
<>
<Save className="w-4 h-4" />
{editingGroup ? 'Save Changes' : 'Create Group'}
{editingGroup ? t('groups.modal.saveChanges') : t('groups.modal.createGroup')}
</>
)}
</Button>
@@ -480,9 +482,9 @@ export function GroupsPage() {
{/* Delete Confirmation Modal */}
{deleteGroupId !== null && (
<ConfirmModal
title="Delete Group"
message="Are you sure you want to delete this group? Users in this group will lose these permissions."
confirmText="Delete Group"
title={t('groups.deleteModal.title')}
message={t('groups.deleteModal.message')}
confirmText={t('groups.deleteModal.confirm')}
variant="danger"
onConfirm={() => {
deleteMutation.mutate(deleteGroupId);
+21 -19
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../contexts/AuthContext';
import { useToast } from '../contexts/ToastContext';
import { useTheme } from '../contexts/ThemeContext';
@@ -8,6 +9,7 @@ import { HelpCircle, X } from 'lucide-react';
export function LoginPage() {
const navigate = useNavigate();
const { t } = useTranslation();
const { login } = useAuth();
const { showToast } = useToast();
const { mode } = useTheme();
@@ -18,18 +20,18 @@ export function LoginPage() {
const loginMutation = useMutation({
mutationFn: () => login(username, password),
onSuccess: () => {
showToast('Logged in successfully');
showToast(t('login.loginSuccess'));
navigate('/');
},
onError: (error: Error) => {
showToast(error.message || 'Login failed', 'error');
showToast(error.message || t('login.loginFailed'), 'error');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!username || !password) {
showToast('Please enter username and password', 'error');
showToast(t('login.enterCredentials'), 'error');
return;
}
loginMutation.mutate();
@@ -47,10 +49,10 @@ export function LoginPage() {
/>
</div>
<h2 className="text-3xl font-bold text-white">
Bambuddy Login
{t('login.title')}
</h2>
<p className="mt-2 text-sm text-bambu-gray">
Sign in to your account
{t('login.subtitle')}
</p>
</div>
@@ -58,7 +60,7 @@ export function LoginPage() {
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-white mb-2">
Username
{t('login.username')}
</label>
<input
id="username"
@@ -67,14 +69,14 @@ export function LoginPage() {
value={username}
onChange={(e) => setUsername(e.target.value)}
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder="Enter your username"
placeholder={t('login.usernamePlaceholder')}
autoComplete="username"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-white mb-2">
Password
{t('login.password')}
</label>
<input
id="password"
@@ -83,7 +85,7 @@ export function LoginPage() {
value={password}
onChange={(e) => setPassword(e.target.value)}
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder="Enter your password"
placeholder={t('login.passwordPlaceholder')}
autoComplete="current-password"
/>
</div>
@@ -95,7 +97,7 @@ export function LoginPage() {
disabled={loginMutation.isPending}
className="w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-bambu-green"
>
{loginMutation.isPending ? 'Logging in...' : 'Sign in'}
{loginMutation.isPending ? t('login.signingIn') : t('login.signIn')}
</button>
</div>
@@ -105,7 +107,7 @@ export function LoginPage() {
onClick={() => setShowForgotPassword(true)}
className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
>
Forgot your password?
{t('login.forgotPassword')}
</button>
</div>
</form>
@@ -124,7 +126,7 @@ export function LoginPage() {
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<HelpCircle className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">Forgot Password</h2>
<h2 className="text-lg font-semibold text-white">{t('login.forgotPasswordTitle')}</h2>
</div>
<button
onClick={() => setShowForgotPassword(false)}
@@ -136,16 +138,16 @@ export function LoginPage() {
<div className="space-y-4">
<p className="text-bambu-gray">
If you've forgotten your password, please contact your system administrator to reset it.
{t('login.forgotPasswordMessage')}
</p>
<div className="bg-bambu-dark rounded-lg p-4 space-y-2">
<p className="text-sm text-white font-medium">How to reset your password:</p>
<p className="text-sm text-white font-medium">{t('login.howToReset')}</p>
<ol className="text-sm text-bambu-gray space-y-1 list-decimal list-inside">
<li>Contact your Bambuddy administrator</li>
<li>Ask them to reset your password in User Management</li>
<li>They can set a new temporary password for you</li>
<li>Log in with the new password and change it in Settings</li>
<li>{t('login.resetStep1')}</li>
<li>{t('login.resetStep2')}</li>
<li>{t('login.resetStep3')}</li>
<li>{t('login.resetStep4')}</li>
</ol>
</div>
@@ -153,7 +155,7 @@ export function LoginPage() {
onClick={() => setShowForgotPassword(false)}
className="w-full py-2 px-4 bg-bambu-dark-tertiary hover:bg-bambu-dark text-white rounded-lg transition-colors"
>
Got it
{t('login.gotIt')}
</button>
</div>
</div>
+119 -97
View File
@@ -1,4 +1,5 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Wrench,
@@ -73,15 +74,26 @@ function getIcon(iconName: string | null) {
return iconMap[iconName] || Wrench;
}
function formatDuration(value: number, type: 'hours' | 'days'): string {
type TFunction = (key: string, options?: Record<string, unknown>) => string;
function formatDuration(value: number, type: 'hours' | 'days', t?: TFunction): string {
if (type === 'days') {
if (value < 1) return 'Today';
if (value === 1) return '1 day';
if (value < 7) return `${Math.round(value)} days`;
if (value < 1) return t ? t('common.today') : 'Today';
if (value === 1) return t ? t('maintenance.day') : '1 day';
if (value < 7) {
const days = Math.round(value);
return t ? t('maintenance.days', { count: days }) : `${days} days`;
}
// Show weeks for anything under 6 months for better precision
if (value < 180) return `${Math.round(value / 7)} weeks`;
if (value < 180) {
const weeks = Math.round(value / 7);
if (weeks === 1) return t ? t('maintenance.week') : '1 week';
return t ? t('maintenance.weeks', { count: weeks }) : `${weeks} weeks`;
}
// 6+ months show as months
return `${Math.round(value / 30)} months`;
const months = Math.round(value / 30);
if (months === 1) return t ? t('maintenance.month') : '1 month';
return t ? t('maintenance.months', { count: months }) : `${months} months`;
} else {
// Print hours - convert to readable units
if (value < 1) return `${Math.round(value * 60)}m`;
@@ -97,17 +109,17 @@ function formatDuration(value: number, type: 'hours' | 'days'): string {
}
}
function formatIntervalLabel(value: number, type: 'hours' | 'days'): string {
function formatIntervalLabel(value: number, type: 'hours' | 'days', t?: TFunction): string {
if (type === 'days') {
if (value === 1) return '1 day';
if (value === 7) return '1 week';
if (value === 14) return '2 weeks';
if (value === 30) return '1 month';
if (value === 60) return '2 months';
if (value === 90) return '3 months';
if (value === 180) return '6 months';
if (value === 365) return '1 year';
return `${value} days`;
if (value === 1) return t ? t('maintenance.day') : '1 day';
if (value === 7) return t ? t('maintenance.week') : '1 week';
if (value === 14) return t ? t('maintenance.weeks', { count: 2 }) : '2 weeks';
if (value === 30) return t ? t('maintenance.month') : '1 month';
if (value === 60) return t ? t('maintenance.months', { count: 2 }) : '2 months';
if (value === 90) return t ? t('maintenance.months', { count: 3 }) : '3 months';
if (value === 180) return t ? t('maintenance.months', { count: 6 }) : '6 months';
if (value === 365) return t ? t('maintenance.year') : '1 year';
return t ? t('maintenance.days', { count: value }) : `${value} days`;
}
return `${value}h`;
}
@@ -201,11 +213,13 @@ function MaintenanceCard({
onPerform,
onToggle,
hasPermission,
t,
}: {
item: MaintenanceStatus;
onPerform: (id: number) => void;
onToggle: (id: number, enabled: boolean) => void;
hasPermission: (permission: Permission) => boolean;
t: TFunction;
}) {
const Icon = getIcon(item.maintenance_type_icon);
const intervalType = item.interval_type || 'hours';
@@ -245,17 +259,17 @@ function MaintenanceCard({
};
const getStatusText = () => {
if (!item.enabled) return 'Disabled';
if (!item.enabled) return t('common.disabled');
if (intervalType === 'days') {
const daysUntil = item.days_until_due ?? 0;
if (item.is_due) return `Overdue by ${formatDuration(Math.abs(daysUntil), 'days')}`;
if (item.is_warning) return `Due in ${formatDuration(daysUntil, 'days')}`;
return `${formatDuration(daysUntil, 'days')} left`;
if (item.is_due) return t('maintenance.overdueBy', { duration: formatDuration(Math.abs(daysUntil), 'days', t) });
if (item.is_warning) return t('maintenance.dueIn', { duration: formatDuration(daysUntil, 'days', t) });
return t('maintenance.timeLeft', { duration: formatDuration(daysUntil, 'days', t) });
} else {
if (item.is_due) return `Overdue by ${formatDuration(Math.abs(item.hours_until_due), 'hours')}`;
if (item.is_warning) return `Due in ${formatDuration(item.hours_until_due, 'hours')}`;
return `${formatDuration(item.hours_until_due, 'hours')} left`;
if (item.is_due) return t('maintenance.overdueBy', { duration: formatDuration(Math.abs(item.hours_until_due), 'hours', t) });
if (item.is_warning) return t('maintenance.dueIn', { duration: formatDuration(item.hours_until_due, 'hours', t) });
return t('maintenance.timeLeft', { duration: formatDuration(item.hours_until_due, 'hours', t) });
}
};
@@ -283,7 +297,7 @@ function MaintenanceCard({
{item.maintenance_type_name}
</h3>
{intervalType === 'days' && (
<span title="Time-based interval">
<span title={t('maintenance.timeBasedInterval')}>
<Calendar className="w-3.5 h-3.5 text-bambu-gray shrink-0" />
</span>
)}
@@ -297,7 +311,7 @@ function MaintenanceCard({
target="_blank"
rel="noopener noreferrer"
className="text-bambu-gray hover:text-bambu-green transition-colors shrink-0"
title="View documentation"
title={t('maintenance.viewDocumentation')}
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="w-3.5 h-3.5" />
@@ -327,7 +341,7 @@ function MaintenanceCard({
{/* Actions */}
<div className="flex items-center gap-2 shrink-0">
<span title={!hasPermission('maintenance:update') ? 'You do not have permission to update maintenance items' : undefined}>
<span title={!hasPermission('maintenance:update') ? t('maintenance.noPermissionUpdate') : undefined}>
<Toggle
checked={item.enabled}
onChange={(checked) => onToggle(item.id, checked)}
@@ -339,11 +353,11 @@ function MaintenanceCard({
variant={item.is_due ? 'primary' : 'secondary'}
onClick={() => onPerform(item.id)}
disabled={!item.enabled || !hasPermission('maintenance:update')}
title={!hasPermission('maintenance:update') ? 'You do not have permission to perform maintenance' : undefined}
title={!hasPermission('maintenance:update') ? t('maintenance.noPermissionPerform') : undefined}
className="!px-3"
>
<RotateCcw className="w-3.5 h-3.5" />
Reset
{t('common.reset')}
</Button>
</div>
</div>
@@ -358,12 +372,14 @@ function PrinterSection({
onToggle,
onSetHours,
hasPermission,
t,
}: {
overview: PrinterMaintenanceOverview;
onPerform: (id: number) => void;
onToggle: (id: number, enabled: boolean) => void;
onSetHours: (printerId: number, hours: number) => void;
hasPermission: (permission: Permission) => boolean;
t: TFunction;
}) {
const [expanded, setExpanded] = useState(true);
const [editingHours, setEditingHours] = useState(false);
@@ -399,19 +415,19 @@ function PrinterSection({
{overview.due_count > 0 && (
<span className="px-2.5 py-1 bg-red-500/20 text-red-400 text-xs font-medium rounded-full flex items-center gap-1.5">
<AlertTriangle className="w-3 h-3" />
{overview.due_count} overdue
{t('maintenance.overdueCount', { count: overview.due_count })}
</span>
)}
{overview.warning_count > 0 && (
<span className="px-2.5 py-1 bg-amber-500/20 text-amber-400 text-xs font-medium rounded-full flex items-center gap-1.5">
<Clock className="w-3 h-3" />
{overview.warning_count} due soon
{t('maintenance.dueSoonCount', { count: overview.warning_count })}
</span>
)}
{overview.due_count === 0 && overview.warning_count === 0 && (
<span className="px-2.5 py-1 bg-bambu-green/20 text-bambu-green text-xs font-medium rounded-full flex items-center gap-1.5">
<Check className="w-3 h-3" />
All good
{t('maintenance.allGood')}
</span>
)}
</div>
@@ -421,7 +437,7 @@ function PrinterSection({
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-bambu-gray hover:text-white hover:bg-bambu-dark rounded-lg transition-colors"
>
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
{expanded ? 'Collapse' : 'Expand'}
{expanded ? t('common.collapse') : t('common.expand')}
</button>
</div>
@@ -447,9 +463,9 @@ function PrinterSection({
step="1"
autoFocus
/>
<span className="text-xs text-bambu-gray">hours</span>
<Button size="sm" onClick={handleSaveHours}>Save</Button>
<Button size="sm" variant="secondary" onClick={() => setEditingHours(false)}>Cancel</Button>
<span className="text-xs text-bambu-gray">{t('common.hours')}</span>
<Button size="sm" onClick={handleSaveHours}>{t('common.save')}</Button>
<Button size="sm" variant="secondary" onClick={() => setEditingHours(false)}>{t('common.cancel')}</Button>
</div>
) : (
<button
@@ -459,13 +475,13 @@ function PrinterSection({
setEditingHours(true);
}}
className={`group ${!hasPermission('maintenance:update') ? 'cursor-not-allowed opacity-60' : ''}`}
title={!hasPermission('maintenance:update') ? 'You do not have permission to edit print hours' : undefined}
title={!hasPermission('maintenance:update') ? t('maintenance.noPermissionEditHours') : undefined}
>
<div className={`text-sm font-medium text-white ${hasPermission('maintenance:update') ? 'group-hover:text-bambu-green' : ''} transition-colors flex items-center gap-1`}>
{Math.round(overview.total_print_hours)} hours
{Math.round(overview.total_print_hours)} {t('common.hours')}
<Edit3 className={`w-3 h-3 text-bambu-gray ${hasPermission('maintenance:update') ? 'group-hover:text-bambu-green' : ''}`} />
</div>
<div className="text-xs text-bambu-gray">Total print time</div>
<div className="text-xs text-bambu-gray">{t('maintenance.totalPrintTime')}</div>
</button>
)}
</div>
@@ -489,7 +505,7 @@ function PrinterSection({
{nextTask.maintenance_type_name}
</div>
<div className={`text-xs ${nextTask.is_due ? 'text-red-400/70' : 'text-amber-400/70'}`}>
{nextTask.is_due ? 'Overdue' : 'Due soon'}
{nextTask.is_due ? t('common.overdue') : t('maintenance.dueSoon')}
</div>
</div>
</div>
@@ -508,6 +524,7 @@ function PrinterSection({
onPerform={onPerform}
onToggle={onToggle}
hasPermission={hasPermission}
t={t}
/>
))}
</div>
@@ -528,6 +545,7 @@ function SettingsSection({
onAssignType,
onRemoveItem,
hasPermission,
t,
}: {
overview: PrinterMaintenanceOverview[] | undefined;
types: MaintenanceType[];
@@ -538,6 +556,7 @@ function SettingsSection({
onAssignType: (printerId: number, typeId: number) => void;
onRemoveItem: (itemId: number) => void;
hasPermission: (permission: Permission) => boolean;
t: TFunction;
}) {
const [editingInterval, setEditingInterval] = useState<number | null>(null);
const [intervalInput, setIntervalInput] = useState('');
@@ -665,16 +684,16 @@ function SettingsSection({
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-white">Maintenance Types</h2>
<p className="text-sm text-bambu-gray mt-1">System types and your custom maintenance tasks</p>
<h2 className="text-lg font-semibold text-white">{t('maintenance.maintenanceTypes')}</h2>
<p className="text-sm text-bambu-gray mt-1">{t('maintenance.maintenanceTypesDescription')}</p>
</div>
<Button
onClick={() => setShowAddType(!showAddType)}
disabled={!hasPermission('maintenance:create')}
title={!hasPermission('maintenance:create') ? 'You do not have permission to create maintenance types' : undefined}
title={!hasPermission('maintenance:create') ? t('maintenance.noPermissionEditTypes') : undefined}
>
<Plus className="w-4 h-4" />
Add Custom Type
{t('maintenance.addCustomType')}
</Button>
</div>
@@ -685,18 +704,18 @@ function SettingsSection({
<form onSubmit={handleAddType}>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="lg:col-span-2">
<label className="block text-xs text-bambu-gray mb-1.5">Name</label>
<label className="block text-xs text-bambu-gray mb-1.5">{t('common.name')}</label>
<input
type="text"
value={newTypeName}
onChange={(e) => setNewTypeName(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
placeholder="e.g., Replace HEPA Filter"
placeholder={t('maintenance.exampleName')}
autoFocus
/>
</div>
<div>
<label className="block text-xs text-bambu-gray mb-1.5">Interval Type</label>
<label className="block text-xs text-bambu-gray mb-1.5">{t('maintenance.intervalType')}</label>
<select
value={newTypeIntervalType}
onChange={(e) => {
@@ -710,13 +729,13 @@ function SettingsSection({
}}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
>
<option value="hours">Print Hours</option>
<option value="days">Calendar Days</option>
<option value="hours">{t('maintenance.printHours')}</option>
<option value="days">{t('maintenance.calendarDays')}</option>
</select>
</div>
<div>
<label className="block text-xs text-bambu-gray mb-1.5">
Interval ({newTypeIntervalType === 'days' ? 'days' : 'hours'})
{t('maintenance.intervalValue', { type: newTypeIntervalType === 'days' ? t('maintenance.calendarDays').toLowerCase() : t('common.hours') })}
</label>
<input
type="number"
@@ -729,7 +748,7 @@ function SettingsSection({
</div>
<div className="mt-4 flex items-end justify-between">
<div>
<label className="block text-xs text-bambu-gray mb-1.5">Icon</label>
<label className="block text-xs text-bambu-gray mb-1.5">{t('maintenance.icon')}</label>
<div className="flex gap-1">
{Object.keys(iconMap).map((iconName) => {
const IconComp = iconMap[iconName];
@@ -753,7 +772,7 @@ function SettingsSection({
</div>
{/* Wiki URL */}
<div className="mt-4">
<label className="block text-xs text-bambu-gray mb-1.5">Documentation Link (optional)</label>
<label className="block text-xs text-bambu-gray mb-1.5">{t('maintenance.documentationLink')}</label>
<input
type="url"
value={newTypeWikiUrl}
@@ -764,7 +783,7 @@ function SettingsSection({
</div>
{/* Printer selection */}
<div className="mt-4">
<label className="block text-xs text-bambu-gray mb-1.5">Assign to Printers</label>
<label className="block text-xs text-bambu-gray mb-1.5">{t('maintenance.assignToPrinters')}</label>
<div className="flex flex-wrap gap-2">
{printers.map(p => (
<button
@@ -782,15 +801,15 @@ function SettingsSection({
))}
</div>
{selectedPrinters.size === 0 && (
<p className="text-xs text-orange-400 mt-1">Select at least one printer</p>
<p className="text-xs text-orange-400 mt-1">{t('maintenance.selectAtLeastOnePrinter')}</p>
)}
</div>
<div className="mt-4 flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => { setShowAddType(false); setSelectedPrinters(new Set()); }}>
Cancel
{t('common.cancel')}
</Button>
<Button type="submit" disabled={!newTypeName.trim() || selectedPrinters.size === 0}>
Add Type
{t('maintenance.addType')}
</Button>
</div>
</form>
@@ -814,7 +833,7 @@ function SettingsSection({
<div className="text-sm font-medium text-white truncate">{type.name}</div>
<div className="text-xs text-bambu-gray mt-0.5 flex items-center gap-1">
{intervalType === 'days' ? <Calendar className="w-3 h-3" /> : <Timer className="w-3 h-3" />}
{formatIntervalLabel(type.default_interval_hours, intervalType)}
{formatIntervalLabel(type.default_interval_hours, intervalType, t)}
</div>
</div>
</div>
@@ -836,7 +855,7 @@ function SettingsSection({
value={editTypeName}
onChange={(e) => setEditTypeName(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
placeholder="Name"
placeholder={t('common.name')}
autoFocus
/>
<div className="flex gap-2">
@@ -845,8 +864,8 @@ function SettingsSection({
onChange={(e) => setEditTypeIntervalType(e.target.value as 'hours' | 'days')}
className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
>
<option value="hours">Print Hours</option>
<option value="days">Calendar Days</option>
<option value="hours">{t('maintenance.printHours')}</option>
<option value="days">{t('maintenance.calendarDays')}</option>
</select>
<input
type="number"
@@ -880,14 +899,14 @@ function SettingsSection({
value={editTypeWikiUrl}
onChange={(e) => setEditTypeWikiUrl(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
placeholder="Documentation link (optional)"
placeholder={t('maintenance.documentationLink')}
/>
<div className="flex gap-2">
<Button size="sm" onClick={handleSaveEditType} disabled={!editTypeName.trim()}>
Save
{t('common.save')}
</Button>
<Button size="sm" variant="secondary" onClick={() => setEditingType(null)}>
Cancel
{t('common.cancel')}
</Button>
</div>
</div>
@@ -909,12 +928,12 @@ function SettingsSection({
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-white truncate">{type.name}</span>
<span className="px-1.5 py-0.5 bg-bambu-green/20 text-bambu-green text-[10px] font-medium rounded">
Custom
{t('maintenance.custom')}
</span>
</div>
<div className="text-xs text-bambu-gray mt-0.5 flex items-center gap-1">
{intervalType === 'days' ? <Calendar className="w-3 h-3" /> : <Timer className="w-3 h-3" />}
{formatIntervalLabel(type.default_interval_hours, intervalType)}
{formatIntervalLabel(type.default_interval_hours, intervalType, t)}
</div>
</div>
<button
@@ -924,7 +943,7 @@ function SettingsSection({
? 'border-bambu-green/50 bg-bambu-green/10 text-bambu-green hover:bg-bambu-green/20'
: 'border-orange-400/50 bg-orange-400/10 text-orange-400 hover:bg-orange-400/20'
}`}
title={`${assignedPrinters.length} printer(s) assigned - click to manage`}
title={t('maintenance.printersAssignedClick', { count: assignedPrinters.length })}
>
<Printer className="w-3 h-3" />
<span className="text-xs font-medium">{assignedPrinters.length}</span>
@@ -933,19 +952,19 @@ function SettingsSection({
<button
onClick={() => startEditType(type)}
disabled={!hasPermission('maintenance:update')}
title={!hasPermission('maintenance:update') ? 'You do not have permission to edit maintenance types' : undefined}
title={!hasPermission('maintenance:update') ? t('maintenance.noPermissionEditTypes') : undefined}
className={`p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors ${!hasPermission('maintenance:update') ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<Edit3 className="w-4 h-4" />
</button>
<button
onClick={() => {
if (confirm(`Delete "${type.name}"?`)) {
if (confirm(t('maintenance.deleteTypeConfirm', { name: type.name }))) {
onDeleteType(type.id);
}
}}
disabled={!hasPermission('maintenance:delete')}
title={!hasPermission('maintenance:delete') ? 'You do not have permission to delete maintenance types' : undefined}
title={!hasPermission('maintenance:delete') ? t('maintenance.noPermissionDeleteTypes') : undefined}
className={`p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors ${!hasPermission('maintenance:delete') ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<Trash2 className="w-4 h-4" />
@@ -955,9 +974,9 @@ function SettingsSection({
{/* Printer assignment management */}
{isExpanded && (
<div className="mt-3 pt-3 border-t border-bambu-dark-tertiary">
<p className="text-xs text-bambu-gray mb-2">Assigned to printers:</p>
<p className="text-xs text-bambu-gray mb-2">{t('maintenance.assignedToPrinters')}</p>
{assignedPrinters.length === 0 ? (
<p className="text-xs text-orange-400">No printers assigned</p>
<p className="text-xs text-orange-400">{t('maintenance.noPrintersAssigned')}</p>
) : (
<div className="flex flex-wrap gap-1 mb-2">
{assignedPrinters.map(p => (
@@ -969,7 +988,7 @@ function SettingsSection({
<button
onClick={() => p.itemId && onRemoveItem(p.itemId)}
disabled={!hasPermission('maintenance:delete')}
title={!hasPermission('maintenance:delete') ? 'You do not have permission to remove printer assignments' : 'Remove from this printer'}
title={!hasPermission('maintenance:delete') ? t('maintenance.noPermissionRemovePrinter') : t('maintenance.removeFromPrinter')}
className={`ml-1 ${hasPermission('maintenance:delete') ? 'hover:text-red-400' : 'opacity-50 cursor-not-allowed'}`}
>
×
@@ -980,13 +999,13 @@ function SettingsSection({
)}
{unassignedPrinters.length > 0 && (
<div className="flex flex-wrap gap-1">
<span className="text-xs text-bambu-gray mr-1">Add:</span>
<span className="text-xs text-bambu-gray mr-1">{t('maintenance.addPrinterShort')}</span>
{unassignedPrinters.map(p => (
<button
key={p.id}
onClick={() => onAssignType(p.id, type.id)}
disabled={!hasPermission('maintenance:create')}
title={!hasPermission('maintenance:create') ? 'You do not have permission to assign printers' : undefined}
title={!hasPermission('maintenance:create') ? t('maintenance.noPermissionAssignPrinter') : undefined}
className={`px-2 py-1 bg-bambu-dark rounded text-xs transition-colors ${hasPermission('maintenance:create') ? 'hover:bg-bambu-green/20 text-bambu-gray hover:text-bambu-green' : 'opacity-50 cursor-not-allowed text-bambu-gray'}`}
>
+ {p.name}
@@ -1006,8 +1025,8 @@ function SettingsSection({
{printerItems.length > 0 && (
<div>
<div className="mb-4">
<h2 className="text-lg font-semibold text-white">Interval Overrides</h2>
<p className="text-sm text-bambu-gray mt-1">Customize intervals for specific printers</p>
<h2 className="text-lg font-semibold text-white">{t('maintenance.intervalOverrides')}</h2>
<p className="text-sm text-bambu-gray mt-1">{t('maintenance.intervalOverridesDescription')}</p>
</div>
<div className="space-y-4">
{printerItems.map((printer) => (
@@ -1040,8 +1059,8 @@ function SettingsSection({
onChange={(e) => setIntervalTypeInput(e.target.value as 'hours' | 'days')}
className="px-1.5 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs"
>
<option value="hours">Print Hours</option>
<option value="days">Calendar Days</option>
<option value="hours">{t('maintenance.printHours')}</option>
<option value="days">{t('maintenance.calendarDays')}</option>
</select>
<input
type="number"
@@ -1065,11 +1084,11 @@ function SettingsSection({
setIntervalTypeInput(intervalType);
}}
disabled={!hasPermission('maintenance:update')}
title={!hasPermission('maintenance:update') ? 'You do not have permission to edit intervals' : undefined}
title={!hasPermission('maintenance:update') ? t('maintenance.noPermissionEditIntervals') : undefined}
className={`px-2 py-1 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-xs font-medium text-white transition-colors flex items-center gap-1 ${hasPermission('maintenance:update') ? 'hover:bg-bambu-dark-secondary hover:border-bambu-green' : 'opacity-50 cursor-not-allowed'}`}
>
{intervalType === 'days' ? <Calendar className="w-3 h-3" /> : <Timer className="w-3 h-3" />}
{formatIntervalLabel(item.interval_hours, intervalType)}
{formatIntervalLabel(item.interval_hours, intervalType, t)}
<Edit3 className="w-3 h-3 text-bambu-gray" />
</button>
)}
@@ -1088,9 +1107,9 @@ function SettingsSection({
<Card>
<CardContent className="text-center py-12">
<Clock className="w-12 h-12 mx-auto mb-4 text-bambu-gray/30" />
<p className="text-bambu-gray">No printers configured</p>
<p className="text-bambu-gray">{t('common.noPrinters')}</p>
<p className="text-sm text-bambu-gray/70 mt-1">
Add printers to configure maintenance intervals
{t('maintenance.intervalOverridesDescription')}
</p>
</CardContent>
</Card>
@@ -1102,6 +1121,7 @@ function SettingsSection({
type TabType = 'status' | 'settings';
export function MaintenancePage() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const { hasPermission } = useAuth();
@@ -1123,7 +1143,7 @@ export function MaintenancePage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
queryClient.invalidateQueries({ queryKey: ['maintenanceSummary'] });
showToast('Maintenance marked as complete');
showToast(t('maintenance.maintenanceComplete'));
},
onError: (error: Error) => {
showToast(error.message, 'error');
@@ -1150,7 +1170,7 @@ export function MaintenancePage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
showToast('Maintenance type updated');
showToast(t('maintenance.typeUpdated'));
},
onError: (error: Error) => {
showToast(error.message, 'error');
@@ -1162,7 +1182,7 @@ export function MaintenancePage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
showToast('Maintenance type deleted');
showToast(t('maintenance.typeDeleted'));
},
onError: (error: Error) => {
showToast(error.message, 'error');
@@ -1175,7 +1195,7 @@ export function MaintenancePage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
queryClient.invalidateQueries({ queryKey: ['maintenanceSummary'] });
showToast('Print hours updated');
showToast(t('maintenance.printHoursUpdated'));
},
onError: (error: Error) => {
showToast(error.message, 'error');
@@ -1187,7 +1207,7 @@ export function MaintenancePage() {
api.assignMaintenanceType(printerId, typeId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
showToast('Printer assigned');
showToast(t('maintenance.printerAssigned'));
},
onError: (error: Error) => {
showToast(error.message, 'error');
@@ -1198,7 +1218,7 @@ export function MaintenancePage() {
mutationFn: api.removeMaintenanceItem,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
showToast('Printer removed');
showToast(t('maintenance.printerRemoved'));
},
onError: (error: Error) => {
showToast(error.message, 'error');
@@ -1232,17 +1252,17 @@ export function MaintenancePage() {
<div className="p-4 md:p-8">
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">Maintenance</h1>
<h1 className="text-2xl font-bold text-white">{t('maintenance.title')}</h1>
<p className="text-bambu-gray text-sm mt-1">
{activeTab === 'status' ? (
<>
{totalDue > 0 && <span className="text-red-400">{totalDue} task{totalDue !== 1 ? 's' : ''} overdue</span>}
{totalDue > 0 && <span className="text-red-400">{t('maintenance.dueCount', { count: totalDue })}</span>}
{totalDue > 0 && totalWarning > 0 && ' · '}
{totalWarning > 0 && <span className="text-amber-400">{totalWarning} due soon</span>}
{totalDue === 0 && totalWarning === 0 && <span className="text-bambu-green">All maintenance up to date</span>}
{totalWarning > 0 && <span className="text-amber-400">{t('maintenance.warningCount', { count: totalWarning })}</span>}
{totalDue === 0 && totalWarning === 0 && <span className="text-bambu-green">{t('maintenance.allOk')}</span>}
</>
) : (
'Configure maintenance types and intervals'
t('maintenance.configureSettings')
)}
</p>
</div>
@@ -1257,7 +1277,7 @@ export function MaintenancePage() {
: 'text-bambu-gray border-transparent hover:text-white'
}`}
>
Status
{t('maintenance.statusTab')}
</button>
<button
onClick={() => setActiveTab('settings')}
@@ -1267,7 +1287,7 @@ export function MaintenancePage() {
: 'text-bambu-gray border-transparent hover:text-white'
}`}
>
Settings
{t('maintenance.settingsTab')}
</button>
</div>
@@ -1289,14 +1309,15 @@ export function MaintenancePage() {
onToggle={handleToggle}
onSetHours={handleSetHours}
hasPermission={hasPermission}
t={t}
/>
))
) : (
<Card>
<CardContent className="text-center py-16">
<Wrench className="w-16 h-16 mx-auto mb-4 text-bambu-gray/30" />
<p className="text-lg font-medium text-white mb-2">No printers configured</p>
<p className="text-bambu-gray">Add printers to start tracking maintenance</p>
<p className="text-lg font-medium text-white mb-2">{t('common.noPrinters')}</p>
<p className="text-bambu-gray">{t('maintenance.configureSettings')}</p>
</CardContent>
</Card>
)}
@@ -1317,13 +1338,14 @@ export function MaintenancePage() {
}
queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
showToast('Maintenance type added');
showToast(t('maintenance.typeUpdated'));
}}
onUpdateType={(id, data) => updateTypeMutation.mutate({ id, data })}
onDeleteType={(id) => deleteTypeMutation.mutate(id)}
onAssignType={(printerId, typeId) => assignTypeMutation.mutate({ printerId, typeId })}
onRemoveItem={(itemId) => removeItemMutation.mutate(itemId)}
hasPermission={hasPermission}
t={t}
/>
)}
</div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+114 -103
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
ArrowLeft,
Edit3,
@@ -59,7 +60,9 @@ function formatFilament(grams: number): string {
return `${Math.round(grams)}g`;
}
function StatusBadge({ status }: { status: string }) {
type TFunction = (key: string, options?: Record<string, unknown>) => string;
function StatusBadge({ status, t }: { status: string; t: TFunction }) {
const colors = {
active: 'bg-bambu-green/20 text-bambu-green',
completed: 'bg-blue-500/20 text-blue-400',
@@ -67,9 +70,15 @@ function StatusBadge({ status }: { status: string }) {
};
const color = colors[status as keyof typeof colors] || colors.active;
const labels: Record<string, string> = {
active: t('projectDetail.status.active'),
completed: t('projectDetail.status.completed'),
archived: t('projectDetail.status.archived'),
};
return (
<span className={`px-2 py-1 rounded text-sm font-medium ${color}`}>
{status.charAt(0).toUpperCase() + status.slice(1)}
{labels[status] || status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
}
@@ -107,12 +116,12 @@ function StatCard({
);
}
function ArchiveGrid({ archives }: { archives: Archive[] }) {
function ArchiveGrid({ archives, t }: { archives: Archive[]; t: TFunction }) {
if (archives.length === 0) {
return (
<div className="text-center py-8 text-bambu-gray">
<Package className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>No prints in this project yet</p>
<p>{t('projectDetail.noPrints')}</p>
</div>
);
}
@@ -159,12 +168,12 @@ function ArchiveGrid({ archives }: { archives: Archive[] }) {
);
}
function PriorityBadge({ priority }: { priority: string }) {
function PriorityBadge({ priority, t }: { priority: string; t: TFunction }) {
const config = {
low: { color: 'bg-gray-500/20 text-gray-400', label: 'Low' },
normal: { color: 'bg-blue-500/20 text-blue-400', label: 'Normal' },
high: { color: 'bg-orange-500/20 text-orange-400', label: 'High' },
urgent: { color: 'bg-red-500/20 text-red-400', label: 'Urgent' },
low: { color: 'bg-gray-500/20 text-gray-400', label: t('projectDetail.priority.low') },
normal: { color: 'bg-blue-500/20 text-blue-400', label: t('projectDetail.priority.normal') },
high: { color: 'bg-orange-500/20 text-orange-400', label: t('projectDetail.priority.high') },
urgent: { color: 'bg-red-500/20 text-red-400', label: t('projectDetail.priority.urgent') },
};
const { color, label } = config[priority as keyof typeof config] || config.normal;
@@ -181,22 +190,23 @@ function formatDate(dateString: string | null): string {
return formatDateOnly(dateString, { year: 'numeric', month: 'short', day: 'numeric' });
}
function getDueDateStatus(dateString: string | null): { color: string; label: string } | null {
function getDueDateStatus(dateString: string | null, t: TFunction): { color: string; label: string } | null {
if (!dateString) return null;
const dueDate = parseUTCDate(dateString);
if (!dueDate) return null;
const now = new Date();
const diffDays = Math.ceil((dueDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays < 0) return { color: 'text-red-400', label: 'Overdue' };
if (diffDays === 0) return { color: 'text-orange-400', label: 'Due today' };
if (diffDays <= 3) return { color: 'text-yellow-400', label: `${diffDays} days left` };
return { color: 'text-bambu-gray', label: `${diffDays} days left` };
if (diffDays < 0) return { color: 'text-red-400', label: t('projectDetail.dueDate.overdue') };
if (diffDays === 0) return { color: 'text-orange-400', label: t('projectDetail.dueDate.today') };
if (diffDays <= 3) return { color: 'text-yellow-400', label: t('projectDetail.dueDate.daysLeft', { count: diffDays }) };
return { color: 'text-bambu-gray', label: t('projectDetail.dueDate.daysLeft', { count: diffDays }) };
}
export function ProjectDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const { hasPermission } = useAuth();
@@ -251,7 +261,7 @@ export function ProjectDetailPage() {
queryClient.invalidateQueries({ queryKey: ['projects'] });
setShowEditModal(false);
setEditingNotes(false);
showToast('Project updated', 'success');
showToast(t('projectDetail.toast.projectUpdated'), 'success');
},
onError: (error: Error) => {
showToast(error.message, 'error');
@@ -306,7 +316,7 @@ export function ProjectDetailPage() {
setNewBomUrl('');
setNewBomRemarks('');
setShowBomForm(false);
showToast('Part added', 'success');
showToast(t('projectDetail.toast.partAdded'), 'success');
},
onError: (error: Error) => showToast(error.message, 'error'),
});
@@ -327,7 +337,7 @@ export function ProjectDetailPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });
queryClient.invalidateQueries({ queryKey: ['project', projectId] });
showToast('Part removed', 'success');
showToast(t('projectDetail.toast.partRemoved'), 'success');
},
onError: (error: Error) => showToast(error.message, 'error'),
});
@@ -355,8 +365,8 @@ export function ProjectDetailPage() {
const handleDeleteBomItem = (itemId: number, itemName: string) => {
setConfirmModal({
isOpen: true,
title: 'Delete Part',
message: `Are you sure you want to delete "${itemName}"?`,
title: t('projectDetail.bom.deletePart'),
message: t('projectDetail.bom.deleteConfirm', { name: itemName }),
onConfirm: () => {
setConfirmModal(prev => ({ ...prev, isOpen: false }));
deleteBomMutation.mutate(itemId);
@@ -397,7 +407,7 @@ export function ProjectDetailPage() {
// Fetch ZIP file directly
const response = await fetch(`/api/v1/projects/${projectId}/export`);
if (!response.ok) {
throw new Error('Export failed');
throw new Error(t('projectDetail.toast.exportFailed'));
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
@@ -409,7 +419,7 @@ export function ProjectDetailPage() {
a.download = filenameMatch?.[1] || `${project?.name || 'project'}_${new Date().toISOString().split('T')[0]}.zip`;
a.click();
URL.revokeObjectURL(url);
showToast('Project exported', 'success');
showToast(t('projectDetail.toast.projectExported'), 'success');
} catch (error) {
showToast((error as Error).message, 'error');
}
@@ -420,7 +430,7 @@ export function ProjectDetailPage() {
mutationFn: () => api.createTemplateFromProject(projectId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
showToast('Template created', 'success');
showToast(t('projectDetail.toast.templateCreated'), 'success');
},
onError: (error: Error) => showToast(error.message, 'error'),
});
@@ -446,10 +456,10 @@ export function ProjectDetailPage() {
return (
<div className="text-center py-24">
<p className="text-bambu-gray">
{projectError ? `Error: ${(projectError as Error).message}` : 'Project not found'}
{projectError ? `${t('common.error')}: ${(projectError as Error).message}` : t('projectDetail.notFound')}
</p>
<Button variant="secondary" className="mt-4" onClick={() => navigate('/projects')}>
Back to Projects
{t('projectDetail.backToProjects')}
</Button>
</div>
);
@@ -466,7 +476,7 @@ export function ProjectDetailPage() {
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-bambu-gray">
<Link to="/projects" className="hover:text-white transition-colors">
Projects
{t('navigation.projects')}
</Link>
<ChevronRight className="w-4 h-4" />
<span className="text-white">{project.name}</span>
@@ -493,25 +503,25 @@ export function ProjectDetailPage() {
)}
</div>
</div>
<StatusBadge status={project.status} />
<StatusBadge status={project.status} t={t} />
</div>
<div className="flex gap-2">
<Button
variant="secondary"
onClick={handleExportProject}
disabled={!hasPermission('projects:read')}
title={!hasPermission('projects:read') ? 'You do not have permission to export projects' : 'Export project'}
title={!hasPermission('projects:read') ? t('projectDetail.noExportPermission') : t('projectDetail.exportProject')}
>
<Download className="w-4 h-4 mr-2" />
Export
{t('projectDetail.export')}
</Button>
<Button
onClick={() => setShowEditModal(true)}
disabled={!hasPermission('projects:update')}
title={!hasPermission('projects:update') ? 'You do not have permission to edit projects' : undefined}
title={!hasPermission('projects:update') ? t('projectDetail.noEditPermission') : undefined}
>
<Edit3 className="w-4 h-4 mr-2" />
Edit
{t('common.edit')}
</Button>
</div>
</div>
@@ -524,9 +534,9 @@ export function ProjectDetailPage() {
{project.target_count && (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-bambu-gray">Plates Progress</span>
<span className="text-sm text-bambu-gray">{t('projectDetail.progress.platesProgress')}</span>
<span className="text-sm font-medium text-white">
{stats?.total_archives || 0} / {project.target_count} print jobs
{stats?.total_archives || 0} / {project.target_count} {t('projectDetail.progress.printJobs')}
</span>
</div>
<div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
@@ -540,11 +550,11 @@ export function ProjectDetailPage() {
</div>
<div className="flex justify-between mt-1">
<span className="text-xs text-bambu-gray/70">
{platesProgressPercent.toFixed(0)}% complete
{t('projectDetail.progress.percentComplete', { percent: platesProgressPercent.toFixed(0) })}
</span>
{stats?.remaining_prints != null && stats.remaining_prints > 0 && (
<span className="text-xs text-bambu-gray/70">
{stats.remaining_prints} remaining
{t('projectDetail.progress.remaining', { count: stats.remaining_prints })}
</span>
)}
</div>
@@ -554,9 +564,9 @@ export function ProjectDetailPage() {
{project.target_parts_count && (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-bambu-gray">Parts Progress</span>
<span className="text-sm text-bambu-gray">{t('projectDetail.progress.partsProgress')}</span>
<span className="text-sm font-medium text-white">
{stats?.completed_prints || 0} / {project.target_parts_count} parts
{stats?.completed_prints || 0} / {project.target_parts_count} {t('projectDetail.progress.parts')}
</span>
</div>
<div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
@@ -570,11 +580,11 @@ export function ProjectDetailPage() {
</div>
<div className="flex justify-between mt-1">
<span className="text-xs text-bambu-gray/70">
{partsProgressPercent.toFixed(0)}% complete
{t('projectDetail.progress.percentComplete', { percent: partsProgressPercent.toFixed(0) })}
</span>
{stats?.remaining_parts != null && stats.remaining_parts > 0 && (
<span className="text-xs text-bambu-gray/70">
{stats.remaining_parts} remaining
{t('projectDetail.progress.remaining', { count: stats.remaining_parts })}
</span>
)}
</div>
@@ -594,25 +604,25 @@ export function ProjectDetailPage() {
<Package className="w-5 h-5" />
</div>
<div>
<p className="text-sm text-bambu-gray">Print Jobs</p>
<p className="text-xl font-semibold text-white">{stats.total_archives} <span className="text-sm font-normal text-bambu-gray">total</span></p>
<p className="text-sm text-bambu-gray">{t('projectDetail.stats.printJobs')}</p>
<p className="text-xl font-semibold text-white">{stats.total_archives} <span className="text-sm font-normal text-bambu-gray">{t('projectDetail.stats.total')}</span></p>
{stats.failed_prints > 0 && (
<p className="text-sm text-status-error">{stats.failed_prints} failed</p>
<p className="text-sm text-status-error">{t('projectDetail.stats.failed', { count: stats.failed_prints })}</p>
)}
<p className="text-sm text-bambu-gray">{stats.completed_prints} parts printed</p>
<p className="text-sm text-bambu-gray">{t('projectDetail.stats.partsPrinted', { count: stats.completed_prints })}</p>
</div>
</div>
</CardContent>
</Card>
<StatCard
icon={Clock}
label="Print Time"
label={t('projectDetail.stats.printTime')}
value={formatDuration(stats.total_print_time_hours)}
color="text-yellow-400"
/>
<StatCard
icon={Printer}
label="Filament Used"
label={t('projectDetail.stats.filamentUsed')}
value={formatFilament(stats.total_filament_grams)}
color="text-purple-400"
/>
@@ -624,18 +634,18 @@ export function ProjectDetailPage() {
<Card>
<CardContent className="p-4">
<h2 className="text-lg font-semibold text-white mb-3">
Cost Tracking
{t('projectDetail.cost.title')}
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-xs text-bambu-gray uppercase">Filament Cost</p>
<p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.filamentCost')}</p>
<p className="text-lg font-semibold text-white">
{currency}{stats.estimated_cost.toFixed(2)}
</p>
</div>
{stats.total_energy_kwh > 0 && (
<div>
<p className="text-xs text-bambu-gray uppercase">Energy</p>
<p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.energy')}</p>
<p className="text-lg font-semibold text-white">
{stats.total_energy_kwh.toFixed(2)} kWh
{stats.total_energy_cost > 0 && (
@@ -649,11 +659,11 @@ export function ProjectDetailPage() {
{project.budget && (
<>
<div>
<p className="text-xs text-bambu-gray uppercase">Budget</p>
<p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.budget')}</p>
<p className="text-lg font-semibold text-white">{currency}{project.budget.toFixed(2)}</p>
</div>
<div>
<p className="text-xs text-bambu-gray uppercase">Remaining</p>
<p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.remaining')}</p>
<p className={`text-lg font-semibold ${project.budget - stats.estimated_cost >= 0 ? 'text-bambu-green' : 'text-red-400'}`}>
{currency}{(project.budget - stats.estimated_cost).toFixed(2)}
</p>
@@ -671,7 +681,7 @@ export function ProjectDetailPage() {
<CardContent className="p-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-3">
<FolderTree className="w-5 h-5" />
Sub-projects ({project.children.length})
{t('projectDetail.subProjects.title', { count: project.children.length })}
</h2>
<div className="space-y-2">
{project.children.map((child) => (
@@ -710,7 +720,7 @@ export function ProjectDetailPage() {
{project.parent_id && project.parent_name && (
<div className="flex items-center gap-2 text-sm">
<Layers className="w-4 h-4 text-bambu-gray" />
<span className="text-bambu-gray">Part of:</span>
<span className="text-bambu-gray">{t('projectDetail.partOf')}</span>
<Link
to={`/projects/${project.parent_id}`}
className="text-bambu-green hover:underline"
@@ -726,8 +736,8 @@ export function ProjectDetailPage() {
{/* Priority */}
{project.priority && project.priority !== 'normal' && (
<div className="flex items-center gap-2">
<span className="text-xs text-bambu-gray uppercase">Priority:</span>
<PriorityBadge priority={project.priority} />
<span className="text-xs text-bambu-gray uppercase">{t('projectDetail.priorityLabel')}</span>
<PriorityBadge priority={project.priority} t={t} />
</div>
)}
@@ -736,9 +746,9 @@ export function ProjectDetailPage() {
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-bambu-gray" />
<span className="text-sm text-white">{formatDate(project.due_date)}</span>
{getDueDateStatus(project.due_date) && (
<span className={`text-xs ${getDueDateStatus(project.due_date)!.color}`}>
({getDueDateStatus(project.due_date)!.label})
{getDueDateStatus(project.due_date, t) && (
<span className={`text-xs ${getDueDateStatus(project.due_date, t)!.color}`}>
({getDueDateStatus(project.due_date, t)!.label})
</span>
)}
</div>
@@ -769,7 +779,7 @@ export function ProjectDetailPage() {
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<FileText className="w-5 h-5" />
Notes
{t('projectDetail.notes.title')}
</h2>
{!editingNotes ? (
<Button
@@ -777,10 +787,10 @@ export function ProjectDetailPage() {
size="sm"
onClick={handleStartEditNotes}
disabled={!hasPermission('projects:update')}
title={!hasPermission('projects:update') ? 'You do not have permission to edit notes' : undefined}
title={!hasPermission('projects:update') ? t('projectDetail.notes.noEditPermission') : undefined}
>
<Edit3 className="w-4 h-4 mr-1" />
Edit
{t('common.edit')}
</Button>
) : (
<div className="flex gap-2">
@@ -791,7 +801,7 @@ export function ProjectDetailPage() {
disabled={updateMutation.isPending}
>
<X className="w-4 h-4 mr-1" />
Cancel
{t('common.cancel')}
</Button>
<Button
size="sm"
@@ -803,7 +813,7 @@ export function ProjectDetailPage() {
) : (
<Save className="w-4 h-4 mr-1" />
)}
Save
{t('common.save')}
</Button>
</div>
)}
@@ -813,7 +823,7 @@ export function ProjectDetailPage() {
<RichTextEditor
content={notesContent}
onChange={setNotesContent}
placeholder="Add notes about this project..."
placeholder={t('projectDetail.notes.placeholder')}
/>
) : project.notes ? (
<div
@@ -822,7 +832,7 @@ export function ProjectDetailPage() {
/>
) : (
<p className="text-bambu-gray/70 text-sm italic">
No notes yet. Click Edit to add notes.
{t('projectDetail.notes.empty')}
</p>
)}
</CardContent>
@@ -834,15 +844,15 @@ export function ProjectDetailPage() {
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<FolderOpen className="w-5 h-5" />
Files
{t('projectDetail.files.title')}
</h2>
</div>
<p className="text-xs text-bambu-gray mb-3">
<Link to="/files" className="text-bambu-green hover:underline">
Link folders from the File Manager
{t('projectDetail.files.linkFolders')}
</Link>
{' '}to this project for quick access.
{' '}{t('projectDetail.files.forQuickAccess')}
</p>
{linkedFolders && linkedFolders.length > 0 ? (
@@ -860,7 +870,7 @@ export function ProjectDetailPage() {
{folder.name}
</p>
<p className="text-xs text-bambu-gray">
{folder.file_count} file{folder.file_count !== 1 ? 's' : ''}
{t('projectDetail.files.fileCount', { count: folder.file_count })}
</p>
</div>
</div>
@@ -870,7 +880,7 @@ export function ProjectDetailPage() {
</div>
) : (
<p className="text-bambu-gray/70 text-sm italic">
No folders linked. Go to File Manager and link a folder to this project.
{t('projectDetail.files.empty')}
</p>
)}
</CardContent>
@@ -882,10 +892,10 @@ export function ProjectDetailPage() {
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<ShoppingCart className="w-5 h-5" />
Bill of Materials
{t('projectDetail.bom.title')}
{stats && stats.bom_total_items > 0 && (
<span className="text-sm font-normal text-bambu-gray">
({stats.bom_completed_items}/{stats.bom_total_items} acquired)
({t('projectDetail.bom.acquired', { completed: stats.bom_completed_items, total: stats.bom_total_items })})
</span>
)}
</h2>
@@ -899,7 +909,7 @@ export function ProjectDetailPage() {
: 'bg-bambu-dark text-bambu-gray hover:text-white'
}`}
>
{hideBomCompleted ? 'Show all' : 'Hide done'}
{hideBomCompleted ? t('projectDetail.bom.showAll') : t('projectDetail.bom.hideDone')}
</button>
)}
{!showBomForm && (
@@ -908,10 +918,10 @@ export function ProjectDetailPage() {
size="sm"
onClick={() => setShowBomForm(true)}
disabled={!hasPermission('projects:update')}
title={!hasPermission('projects:update') ? 'You do not have permission to add parts' : undefined}
title={!hasPermission('projects:update') ? t('projectDetail.bom.noAddPermission') : undefined}
>
<Plus className="w-4 h-4 mr-1" />
Add Part
{t('projectDetail.bom.addPart')}
</Button>
)}
</div>
@@ -926,7 +936,7 @@ export function ProjectDetailPage() {
value={newBomName}
onChange={(e) => setNewBomName(e.target.value)}
className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
placeholder="Part name (e.g., M3x8 screws)"
placeholder={t('projectDetail.bom.partNamePlaceholder')}
autoFocus
/>
<div className="flex gap-2">
@@ -936,7 +946,7 @@ export function ProjectDetailPage() {
onChange={(e) => setNewBomQty(parseInt(e.target.value) || 1)}
className="w-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-bambu-green"
min="1"
placeholder="Qty"
placeholder={t('projectDetail.bom.qty')}
/>
<input
type="number"
@@ -944,7 +954,7 @@ export function ProjectDetailPage() {
value={newBomPrice}
onChange={(e) => setNewBomPrice(e.target.value)}
className="flex-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
placeholder={`Price (${currency})`}
placeholder={t('projectDetail.bom.price', { currency })}
/>
</div>
</div>
@@ -953,24 +963,24 @@ export function ProjectDetailPage() {
value={newBomUrl}
onChange={(e) => setNewBomUrl(e.target.value)}
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
placeholder="Sourcing URL (optional)"
placeholder={t('projectDetail.bom.sourcingUrlPlaceholder')}
/>
<input
type="text"
value={newBomRemarks}
onChange={(e) => setNewBomRemarks(e.target.value)}
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
placeholder="Remarks (optional)"
placeholder={t('projectDetail.bom.remarksPlaceholder')}
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" size="sm" onClick={() => setShowBomForm(false)}>
Cancel
{t('common.cancel')}
</Button>
<Button type="submit" size="sm" disabled={!newBomName.trim() || createBomMutation.isPending}>
{createBomMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Add Part'
t('projectDetail.bom.addPart')
)}
</Button>
</div>
@@ -1001,7 +1011,7 @@ export function ProjectDetailPage() {
value={editBomName}
onChange={(e) => setEditBomName(e.target.value)}
className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
placeholder="Part name"
placeholder={t('projectDetail.bom.partName')}
autoFocus
/>
<div className="flex gap-2">
@@ -1011,7 +1021,7 @@ export function ProjectDetailPage() {
onChange={(e) => setEditBomQty(parseInt(e.target.value) || 1)}
className="w-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-bambu-green"
min="1"
placeholder="Qty"
placeholder={t('projectDetail.bom.qty')}
/>
<input
type="number"
@@ -1019,7 +1029,7 @@ export function ProjectDetailPage() {
value={editBomPrice}
onChange={(e) => setEditBomPrice(e.target.value)}
className="flex-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
placeholder={`Price (${currency})`}
placeholder={t('projectDetail.bom.price', { currency })}
/>
</div>
</div>
@@ -1028,24 +1038,24 @@ export function ProjectDetailPage() {
value={editBomUrl}
onChange={(e) => setEditBomUrl(e.target.value)}
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
placeholder="Sourcing URL (optional)"
placeholder={t('projectDetail.bom.sourcingUrlPlaceholder')}
/>
<input
type="text"
value={editBomRemarks}
onChange={(e) => setEditBomRemarks(e.target.value)}
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
placeholder="Remarks (optional)"
placeholder={t('projectDetail.bom.remarksPlaceholder')}
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" size="sm" onClick={handleCancelBomEdit}>
Cancel
{t('common.cancel')}
</Button>
<Button type="submit" size="sm" disabled={!editBomName.trim() || updateBomMutation.isPending}>
{updateBomMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Save'
t('common.save')
)}
</Button>
</div>
@@ -1056,7 +1066,7 @@ export function ProjectDetailPage() {
<button
onClick={() => hasPermission('projects:update') && handleToggleAcquired(item)}
disabled={updateBomMutation.isPending || !hasPermission('projects:update')}
title={!hasPermission('projects:update') ? 'You do not have permission to update parts' : undefined}
title={!hasPermission('projects:update') ? t('projectDetail.bom.noUpdatePermission') : undefined}
className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors flex-shrink-0 ${
item.is_complete
? 'bg-status-ok border-status-ok text-white'
@@ -1091,7 +1101,7 @@ export function ProjectDetailPage() {
? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
title={!hasPermission('projects:update') ? 'You do not have permission to edit parts' : 'Edit'}
title={!hasPermission('projects:update') ? t('projectDetail.bom.noEditPermission') : t('common.edit')}
>
<Pencil className="w-4 h-4" />
</button>
@@ -1103,7 +1113,7 @@ export function ProjectDetailPage() {
? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
title={!hasPermission('projects:update') ? 'You do not have permission to delete parts' : 'Delete'}
title={!hasPermission('projects:update') ? t('projectDetail.bom.noDeletePermission') : t('common.delete')}
>
<Trash2 className="w-4 h-4" />
</button>
@@ -1144,7 +1154,7 @@ export function ProjectDetailPage() {
{/* BOM Total */}
{bomItems.some(item => item.unit_price !== null) && (
<div className="pt-2 mt-2 border-t border-bambu-dark-tertiary flex justify-between text-sm">
<span className="text-bambu-gray">Total cost:</span>
<span className="text-bambu-gray">{t('projectDetail.bom.totalCost')}</span>
<span className="text-white font-medium">
{currency}{bomItems.reduce((sum, item) => sum + (item.unit_price || 0) * item.quantity_needed, 0).toFixed(2)}
</span>
@@ -1153,7 +1163,7 @@ export function ProjectDetailPage() {
</div>
) : (
<p className="text-bambu-gray/70 text-sm italic">
No parts in the bill of materials. Add hardware, electronics, or other components to track what needs to be sourced.
{t('projectDetail.bom.empty')}
</p>
)}
</CardContent>
@@ -1165,7 +1175,7 @@ export function ProjectDetailPage() {
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<History className="w-5 h-5" />
Activity Timeline
{t('projectDetail.timeline.title')}
</h2>
</div>
@@ -1201,7 +1211,7 @@ export function ProjectDetailPage() {
</div>
) : (
<p className="text-bambu-gray/70 text-sm italic">
No activity yet.
{t('projectDetail.timeline.empty')}
</p>
)}
</CardContent>
@@ -1215,14 +1225,14 @@ export function ProjectDetailPage() {
size="sm"
onClick={() => createTemplateMutation.mutate()}
disabled={createTemplateMutation.isPending || !hasPermission('projects:create')}
title={!hasPermission('projects:create') ? 'You do not have permission to create templates' : undefined}
title={!hasPermission('projects:create') ? t('projectDetail.template.noCreatePermission') : undefined}
>
{createTemplateMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Copy className="w-4 h-4 mr-2" />
)}
Save as Template
{t('projectDetail.template.saveAsTemplate')}
</Button>
</div>
)}
@@ -1234,24 +1244,24 @@ export function ProjectDetailPage() {
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<ListTodo className="w-5 h-5" />
Queue
{t('projectDetail.queue.title')}
</h2>
<Link
to={`/queue?project=${projectId}`}
className="text-sm text-bambu-green hover:underline"
>
View all
{t('projectDetail.queue.viewAll')}
</Link>
</div>
<div className="flex items-center gap-4 text-sm">
{stats.in_progress_prints > 0 && (
<span className="text-yellow-400">
{stats.in_progress_prints} printing
{t('projectDetail.queue.printing', { count: stats.in_progress_prints })}
</span>
)}
{stats.queued_prints > 0 && (
<span className="text-bambu-gray">
{stats.queued_prints} queued
{t('projectDetail.queue.queued', { count: stats.queued_prints })}
</span>
)}
</div>
@@ -1264,7 +1274,7 @@ export function ProjectDetailPage() {
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Package className="w-5 h-5" />
Prints ({archives?.length || 0})
{t('projectDetail.prints.title', { count: archives?.length || 0 })}
</h2>
</div>
{archivesLoading ? (
@@ -1272,13 +1282,14 @@ export function ProjectDetailPage() {
<Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
</div>
) : (
<ArchiveGrid archives={archives || []} />
<ArchiveGrid archives={archives || []} t={t} />
)}
</div>
{/* Edit Modal */}
{showEditModal && (
<ProjectModal
t={t}
project={{
...project,
archive_count: stats?.total_archives || 0,
@@ -1300,7 +1311,7 @@ export function ProjectDetailPage() {
<ConfirmModal
title={confirmModal.title}
message={confirmModal.message}
confirmText="Delete"
confirmText={t('common.delete')}
variant="danger"
onConfirm={confirmModal.onConfirm}
onCancel={() => setConfirmModal(prev => ({ ...prev, isOpen: false }))}
+89 -81
View File
@@ -1,4 +1,5 @@
import { useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
@@ -38,14 +39,17 @@ const PROJECT_COLORS = [
'#6b7280', // gray
];
type TFunction = (key: string, options?: Record<string, unknown>) => string;
interface ProjectModalProps {
project?: ProjectListItem;
onClose: () => void;
onSave: (data: ProjectCreate | ProjectUpdate) => void;
isLoading: boolean;
t: TFunction;
}
export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectModalProps) {
export function ProjectModal({ project, onClose, onSave, isLoading, t }: ProjectModalProps) {
const [name, setName] = useState(project?.name || '');
const [description, setDescription] = useState(project?.description || '');
const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]);
@@ -76,41 +80,41 @@ export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectMod
<div className="bg-bambu-dark-secondary rounded-lg w-full max-w-md border border-bambu-dark-tertiary">
<div className="p-4 border-b border-bambu-dark-tertiary">
<h2 className="text-lg font-semibold text-white">
{project ? 'Edit Project' : 'New Project'}
{project ? t('projects.editProject') : t('projects.newProject')}
</h2>
</div>
<form onSubmit={handleSubmit} className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-white mb-1">
Name
{t('common.name')}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
placeholder="e.g., Voron 2.4 Build"
placeholder={t('projects.namePlaceholder')}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-1">
Description
{t('common.description')}
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green resize-none"
placeholder="Optional description..."
placeholder={t('projects.descriptionPlaceholder')}
rows={2}
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-1">
Color
{t('projects.color')}
</label>
<div className="flex gap-2 flex-wrap">
{PROJECT_COLORS.map((c) => (
@@ -131,45 +135,45 @@ export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectMod
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-white mb-1">
Target Plates
{t('projects.targetPlates')}
</label>
<input
type="number"
value={targetCount}
onChange={(e) => setTargetCount(e.target.value)}
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
placeholder="e.g., 25"
placeholder={t('projects.targetPlatesPlaceholder')}
min="1"
/>
<p className="text-xs text-bambu-gray mt-1">Number of print jobs</p>
<p className="text-xs text-bambu-gray mt-1">{t('projects.targetPlatesHelp')}</p>
</div>
<div>
<label className="block text-sm font-medium text-white mb-1">
Target Parts
{t('projects.targetParts')}
</label>
<input
type="number"
value={targetPartsCount}
onChange={(e) => setTargetPartsCount(e.target.value)}
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
placeholder="e.g., 150"
placeholder={t('projects.targetPartsPlaceholder')}
min="1"
/>
<p className="text-xs text-bambu-gray mt-1">Total objects needed</p>
<p className="text-xs text-bambu-gray mt-1">{t('projects.targetPartsHelp')}</p>
</div>
</div>
{/* Tags */}
<div>
<label className="block text-sm font-medium text-white mb-1">
Tags (comma-separated)
{t('projects.tagsLabel')}
</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
placeholder="e.g., voron, functional, gift"
placeholder={t('projects.tagsPlaceholder')}
/>
</div>
@@ -177,7 +181,7 @@ export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectMod
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-white mb-1">
Due Date
{t('projects.dueDate')}
</label>
<input
type="date"
@@ -188,17 +192,17 @@ export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectMod
</div>
<div>
<label className="block text-sm font-medium text-white mb-1">
Priority
{t('projects.priority')}
</label>
<select
value={priority}
onChange={(e) => setPriority(e.target.value)}
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
>
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
<option value="low">{t('projects.priorityLow')}</option>
<option value="normal">{t('projects.priorityNormal')}</option>
<option value="high">{t('projects.priorityHigh')}</option>
<option value="urgent">{t('projects.priorityUrgent')}</option>
</select>
</div>
</div>
@@ -206,31 +210,31 @@ export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectMod
{project && (
<div>
<label className="block text-sm font-medium text-white mb-1">
Status
{t('common.status')}
</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
>
<option value="active">Active</option>
<option value="completed">Completed</option>
<option value="archived">Archived</option>
<option value="active">{t('projects.statusActive')}</option>
<option value="completed">{t('projects.statusCompleted')}</option>
<option value="archived">{t('projects.statusArchived')}</option>
</select>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="secondary" onClick={onClose}>
Cancel
{t('common.cancel')}
</Button>
<Button type="submit" disabled={!name.trim() || isLoading}>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : project ? (
'Save'
t('common.save')
) : (
'Create'
t('projects.create')
)}
</Button>
</div>
@@ -246,9 +250,10 @@ interface ProjectCardProps {
onEdit: () => void;
onDelete: () => void;
hasPermission: (permission: Permission) => boolean;
t: TFunction;
}
function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: ProjectCardProps) {
function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission, t }: ProjectCardProps) {
// Plates progress: archive_count / target_count
const platesProgressPercent = project.target_count
? Math.round((project.archive_count / project.target_count) * 100)
@@ -300,7 +305,7 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
? 'bg-bambu-green/20 text-bambu-green'
: 'bg-bambu-dark text-bambu-gray'
}`}>
{project.completed_count}/{project.target_parts_count} parts
{project.completed_count}/{project.target_parts_count} {t('projects.parts')}
</span>
) : project.target_count ? (
<span className={`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${
@@ -308,21 +313,21 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
? 'bg-bambu-green/20 text-bambu-green'
: 'bg-bambu-dark text-bambu-gray'
}`}>
{project.archive_count}/{project.target_count} plates
{project.archive_count}/{project.target_count} {t('projects.plates')}
</span>
) : project.completed_count > 0 ? (
<span className="text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium bg-bambu-dark text-bambu-gray">
{project.completed_count} parts
{project.completed_count} {t('projects.parts')}
</span>
) : null}
{isCompleted && (
<span className="text-xs bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full whitespace-nowrap">
Done
{t('projects.done')}
</span>
)}
{isArchived && (
<span className="text-xs bg-bambu-gray/20 text-bambu-gray px-2 py-0.5 rounded-full whitespace-nowrap">
Archived
{t('projects.statusArchived')}
</span>
)}
</div>
@@ -396,10 +401,10 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
}`}
onClick={() => { if (hasPermission('projects:update')) { onEdit(); setShowActions(false); } }}
disabled={!hasPermission('projects:update')}
title={!hasPermission('projects:update') ? 'You do not have permission to edit projects' : undefined}
title={!hasPermission('projects:update') ? t('projects.noEditPermission') : undefined}
>
<Edit3 className="w-4 h-4" />
Edit
{t('common.edit')}
</button>
<button
className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 ${
@@ -407,10 +412,10 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
}`}
onClick={() => { if (hasPermission('projects:delete')) { onDelete(); setShowActions(false); } }}
disabled={!hasPermission('projects:delete')}
title={!hasPermission('projects:delete') ? 'You do not have permission to delete projects' : undefined}
title={!hasPermission('projects:delete') ? t('projects.noDeletePermission') : undefined}
>
<Trash2 className="w-4 h-4" />
Delete
{t('common.delete')}
</button>
</div>
</>
@@ -426,7 +431,7 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
{project.target_count && (
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-bambu-gray">Plates</span>
<span className="text-bambu-gray">{t('projects.plates')}</span>
<span className={platesProgressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
{project.archive_count} / {project.target_count}
</span>
@@ -449,7 +454,7 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
{project.target_parts_count && (
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-bambu-gray">Parts</span>
<span className="text-bambu-gray">{t('projects.parts')}</span>
<span className={partsProgressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
{project.completed_count} / {project.target_parts_count}
</span>
@@ -471,7 +476,7 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
{/* Failed count */}
{project.failed_count > 0 && (
<div className="text-xs text-red-400">
{project.failed_count} failed
{project.failed_count} {t('projects.failed')}
</div>
)}
</div>
@@ -480,25 +485,25 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
{project.completed_count > 0 && (
<div className="flex items-center gap-1.5 text-bambu-gray">
<Archive className="w-3.5 h-3.5" />
<span>{project.completed_count} completed</span>
<span>{project.completed_count} {t('projects.completed')}</span>
</div>
)}
{project.failed_count > 0 && (
<div className="flex items-center gap-1.5 text-red-400">
<AlertTriangle className="w-3.5 h-3.5" />
<span>{project.failed_count} failed</span>
<span>{project.failed_count} {t('projects.failed')}</span>
</div>
)}
{project.queue_count > 0 && (
<div className="flex items-center gap-1.5 text-blue-400">
<Clock className="w-3.5 h-3.5" />
<span>{project.queue_count} in queue</span>
<span>{project.queue_count} {t('projects.inQueue')}</span>
</div>
)}
</div>
) : (
<div className="text-xs text-bambu-gray/60 italic">
No prints yet
{t('projects.noPrintsYet')}
</div>
)}
</div>
@@ -534,7 +539,7 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
</div>
{project.archive_count > 4 && (
<p className="text-xs text-bambu-gray mt-1.5 text-center">
+{project.archive_count - 4} more
{t('common.more', { count: project.archive_count - 4 })}
</p>
)}
</div>
@@ -543,22 +548,22 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
{/* Stats footer */}
<div className="flex items-center justify-between pt-3 border-t border-bambu-dark-tertiary">
<div className="flex items-center gap-4 text-xs text-bambu-gray">
<div className="flex items-center gap-1.5" title="Print jobs (plates)">
<div className="flex items-center gap-1.5" title={t('projects.printJobs')}>
<Layers className="w-3.5 h-3.5 text-blue-400" />
<span>{project.archive_count} plates</span>
<span>{project.archive_count} {t('projects.plates')}</span>
</div>
<div className="flex items-center gap-1.5" title="Parts printed">
<div className="flex items-center gap-1.5" title={t('projects.partsPrinted')}>
<Package className="w-3.5 h-3.5 text-bambu-green" />
<span>{project.completed_count} parts</span>
<span>{project.completed_count} {t('projects.parts')}</span>
</div>
{project.failed_count > 0 && (
<div className="flex items-center gap-1.5 text-red-400" title="Failed parts">
<div className="flex items-center gap-1.5 text-red-400" title={t('projects.failedParts')}>
<AlertTriangle className="w-3.5 h-3.5" />
<span>{project.failed_count}</span>
</div>
)}
{project.queue_count > 0 && (
<div className="flex items-center gap-1.5 text-yellow-400" title="In queue">
<div className="flex items-center gap-1.5 text-yellow-400" title={t('projects.inQueue')}>
<ListTodo className="w-3.5 h-3.5" />
<span>{project.queue_count}</span>
</div>
@@ -572,6 +577,7 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
}
export function ProjectsPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showToast } = useToast();
@@ -591,7 +597,7 @@ export function ProjectsPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
setShowModal(false);
showToast('Project created', 'success');
showToast(t('projects.toast.created'), 'success');
},
onError: (error: Error) => {
showToast(error.message, 'error');
@@ -605,7 +611,7 @@ export function ProjectsPage() {
queryClient.invalidateQueries({ queryKey: ['projects'] });
setShowModal(false);
setEditingProject(undefined);
showToast('Project updated', 'success');
showToast(t('projects.toast.updated'), 'success');
},
onError: (error: Error) => {
showToast(error.message, 'error');
@@ -616,7 +622,7 @@ export function ProjectsPage() {
mutationFn: (id: number) => api.deleteProject(id),
onSuccess: () => {
setDeleteConfirm(null);
showToast('Project deleted', 'success');
showToast(t('projects.toast.deleted'), 'success');
// Reload to refresh the list (React Query cache invalidation not working reliably)
setTimeout(() => window.location.reload(), 100);
},
@@ -630,7 +636,7 @@ export function ProjectsPage() {
mutationFn: (data: ProjectImport) => api.importProject(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
showToast('Project imported', 'success');
showToast(t('projects.toast.imported'), 'success');
},
onError: (error: Error) => {
showToast(error.message, 'error');
@@ -656,7 +662,7 @@ export function ProjectsPage() {
a.download = `bambuddy_projects_${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
showToast('Projects exported (metadata only)', 'success');
showToast(t('projects.toast.exported'), 'success');
} catch (error) {
showToast((error as Error).message, 'error');
}
@@ -689,7 +695,7 @@ export function ProjectsPage() {
}
queryClient.invalidateQueries({ queryKey: ['projects'] });
showToast('Project imported', 'success');
showToast(t('projects.toast.imported'), 'success');
} else {
// JSON file: parse and handle bulk or single import
const text = await file.text();
@@ -703,11 +709,11 @@ export function ProjectsPage() {
}
if (projectsToImport.length > 1) {
showToast(`${projectsToImport.length} projects imported`, 'success');
showToast(t('projects.toast.multipleImported', { count: projectsToImport.length }), 'success');
}
}
} catch (error) {
showToast(`Import failed: ${(error as Error).message}`, 'error');
showToast(`${t('projects.toast.importFailed')}: ${(error as Error).message}`, 'error');
}
// Reset file input
@@ -767,10 +773,10 @@ export function ProjectsPage() {
<div className="p-2.5 bg-bambu-green/10 rounded-xl">
<FolderKanban className="w-6 h-6 text-bambu-green" />
</div>
Projects
{t('projects.title')}
</h1>
<p className="text-sm text-bambu-gray mt-2 ml-14">
Organize and track your 3D printing projects
{t('projects.subtitle')}
</p>
</div>
<div className="flex gap-2">
@@ -778,28 +784,28 @@ export function ProjectsPage() {
variant="secondary"
onClick={handleImportClick}
disabled={!hasPermission('projects:create')}
title={!hasPermission('projects:create') ? 'You do not have permission to import projects' : 'Import project'}
title={!hasPermission('projects:create') ? t('projects.noImportPermission') : t('projects.importProject')}
>
<Upload className="w-4 h-4 mr-2" />
Import
{t('projects.import')}
</Button>
<Button
variant="secondary"
onClick={handleExportAll}
disabled={!hasPermission('projects:read')}
title={!hasPermission('projects:read') ? 'You do not have permission to export projects' : 'Export all projects'}
title={!hasPermission('projects:read') ? t('projects.noExportPermission') : t('projects.exportAll')}
>
<Download className="w-4 h-4 mr-2" />
Export
{t('projects.export')}
</Button>
<Button
onClick={() => setShowModal(true)}
className="sm:w-auto w-full"
disabled={!hasPermission('projects:create')}
title={!hasPermission('projects:create') ? 'You do not have permission to create projects' : undefined}
title={!hasPermission('projects:create') ? t('projects.noCreatePermission') : undefined}
>
<Plus className="w-4 h-4 mr-2" />
New Project
{t('projects.newProject')}
</Button>
</div>
</div>
@@ -807,10 +813,10 @@ export function ProjectsPage() {
{/* Filter tabs */}
<div className="flex gap-1 p-1 bg-bambu-dark rounded-xl w-fit">
{[
{ key: 'active', label: 'Active', icon: Clock },
{ key: 'completed', label: 'Completed', icon: CheckCircle2 },
{ key: 'archived', label: 'Archived', icon: Archive },
{ key: 'all', label: 'All', icon: FolderKanban },
{ key: 'active', label: t('projects.statusActive'), icon: Clock },
{ key: 'completed', label: t('projects.statusCompleted'), icon: CheckCircle2 },
{ key: 'archived', label: t('projects.statusArchived'), icon: Archive },
{ key: 'all', label: t('common.all'), icon: FolderKanban },
].map(({ key, label, icon: Icon }) => (
<button
key={key}
@@ -839,7 +845,7 @@ export function ProjectsPage() {
<div className="flex items-center justify-center py-20">
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
<p className="text-sm text-bambu-gray">Loading projects...</p>
<p className="text-sm text-bambu-gray">{t('projects.loading')}</p>
</div>
</div>
) : projects?.length === 0 ? (
@@ -848,22 +854,22 @@ export function ProjectsPage() {
<FolderKanban className="w-12 h-12 text-bambu-gray/50" />
</div>
<h3 className="text-lg font-medium text-white mb-2">
{statusFilter === 'all' ? 'No projects yet' : `No ${statusFilter} projects`}
{statusFilter === 'all' ? t('projects.noProjects') : t('projects.noProjectsFiltered', { status: statusFilter })}
</h3>
<p className="text-bambu-gray text-center max-w-md mb-6">
{statusFilter === 'all'
? 'Create your first project to start organizing related prints, tracking progress, and managing your builds.'
: `You don't have any ${statusFilter} projects. Projects will appear here when their status changes.`
? t('projects.createFirst')
: t('projects.noProjectsFilteredHelp', { status: statusFilter })
}
</p>
{statusFilter === 'all' && (
<Button
onClick={() => setShowModal(true)}
disabled={!hasPermission('projects:create')}
title={!hasPermission('projects:create') ? 'You do not have permission to create projects' : undefined}
title={!hasPermission('projects:create') ? t('projects.noCreatePermission') : undefined}
>
<Plus className="w-4 h-4 mr-2" />
Create Your First Project
{t('projects.createFirstButton')}
</Button>
)}
</div>
@@ -877,6 +883,7 @@ export function ProjectsPage() {
onEdit={() => handleEdit(project)}
onDelete={() => handleDeleteClick(project.id)}
hasPermission={hasPermission}
t={t}
/>
))}
</div>
@@ -885,9 +892,9 @@ export function ProjectsPage() {
{/* Delete Confirmation Modal */}
{deleteConfirm !== null && (
<ConfirmModal
title="Delete Project"
message="Are you sure you want to delete this project? Archives and queue items will be unlinked but not deleted."
confirmText="Delete Project"
title={t('projects.deleteProject')}
message={t('projects.deleteConfirm')}
confirmText={t('projects.deleteProject')}
variant="danger"
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteConfirm(null)}
@@ -904,6 +911,7 @@ export function ProjectsPage() {
}}
onSave={handleSave}
isLoading={createMutation.isPending || updateMutation.isPending}
t={t}
/>
)}
</div>
+135 -124
View File
@@ -1,4 +1,5 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import {
@@ -64,39 +65,39 @@ function formatDuration(seconds: number | null | undefined): string {
return `${minutes}m`;
}
function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat = 'system'): string {
if (!dateString) return 'ASAP';
function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat = 'system', t?: (key: string, options?: Record<string, unknown>) => string): string {
if (!dateString) return t?.('queue.time.asap') ?? 'ASAP';
const date = parseUTCDate(dateString);
if (!date) return 'ASAP';
if (!date) return t?.('queue.time.asap') ?? 'ASAP';
const now = new Date();
const diff = date.getTime() - now.getTime();
if (diff < -60000) return 'Overdue';
if (diff < 0) return 'Now';
if (diff < 60000) return 'In less than a minute';
if (diff < 3600000) return `In ${Math.round(diff / 60000)} min`;
if (diff < 86400000) return `In ${Math.round(diff / 3600000)} hours`;
if (diff < -60000) return t?.('queue.time.overdue') ?? 'Overdue';
if (diff < 0) return t?.('queue.time.now') ?? 'Now';
if (diff < 60000) return t?.('queue.time.lessThanMinute') ?? 'In less than a minute';
if (diff < 3600000) return t?.('queue.time.inMinutes', { count: Math.round(diff / 60000) }) ?? `In ${Math.round(diff / 60000)} min`;
if (diff < 86400000) return t?.('queue.time.inHours', { count: Math.round(diff / 3600000) }) ?? `In ${Math.round(diff / 3600000)} hours`;
return formatDateTime(dateString, timeFormat);
}
function StatusBadge({ status, waitingReason }: { status: PrintQueueItem['status']; waitingReason?: string | null }) {
function StatusBadge({ status, waitingReason, t }: { status: PrintQueueItem['status']; waitingReason?: string | null; t: (key: string) => string }) {
// Special case: pending with waiting_reason shows as "Waiting"
if (status === 'pending' && waitingReason) {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border text-purple-400 bg-purple-400/10 border-purple-400/20">
<Clock className="w-3.5 h-3.5" />
Waiting
{t('queue.status.waiting')}
</span>
);
}
const config = {
pending: { icon: Clock, color: 'text-status-warning bg-status-warning/10 border-status-warning/20', label: 'Pending' },
printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20', label: 'Printing' },
completed: { icon: CheckCircle, color: 'text-status-ok bg-status-ok/10 border-status-ok/20', label: 'Completed' },
failed: { icon: XCircle, color: 'text-status-error bg-status-error/10 border-status-error/20', label: 'Failed' },
skipped: { icon: SkipForward, color: 'text-orange-400 bg-orange-400/10 border-orange-400/20', label: 'Skipped' },
cancelled: { icon: X, color: 'text-gray-400 bg-gray-400/10 border-gray-400/20', label: 'Cancelled' },
pending: { icon: Clock, color: 'text-status-warning bg-status-warning/10 border-status-warning/20', label: t('queue.status.pending') },
printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20', label: t('queue.status.printing') },
completed: { icon: CheckCircle, color: 'text-status-ok bg-status-ok/10 border-status-ok/20', label: t('queue.status.completed') },
failed: { icon: XCircle, color: 'text-status-error bg-status-error/10 border-status-error/20', label: t('queue.status.failed') },
skipped: { icon: SkipForward, color: 'text-orange-400 bg-orange-400/10 border-orange-400/20', label: t('queue.status.skipped') },
cancelled: { icon: X, color: 'text-gray-400 bg-gray-400/10 border-gray-400/20', label: t('queue.status.cancelled') },
};
const { icon: Icon, color, label } = config[status];
@@ -116,12 +117,14 @@ function BulkEditModal({
onSave,
onClose,
isSaving,
t,
}: {
selectedCount: number;
printers: { id: number; name: string }[];
onSave: (data: Partial<PrintQueueBulkUpdate>) => void;
onClose: () => void;
isSaving: boolean;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
const [printerId, setPrinterId] = useState<number | null | 'unchanged'>('unchanged');
const [manualStart, setManualStart] = useState<boolean | 'unchanged'>('unchanged');
@@ -158,7 +161,7 @@ function BulkEditModal({
<div className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<h2 className="text-lg font-semibold text-white">
Edit {selectedCount} Item{selectedCount !== 1 ? 's' : ''}
{t('queue.bulkEdit.title', { count: selectedCount })}
</h2>
<button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded">
<X className="w-5 h-5 text-bambu-gray" />
@@ -167,12 +170,12 @@ function BulkEditModal({
<div className="p-4 space-y-4">
<p className="text-sm text-bambu-gray">
Only changed settings will be applied to selected items.
{t('queue.bulkEdit.description')}
</p>
{/* Printer Assignment */}
<div>
<label className="block text-sm font-medium text-white mb-2">Printer</label>
<label className="block text-sm font-medium text-white mb-2">{t('queue.bulkEdit.printer')}</label>
<select
value={printerId === null ? 'null' : printerId === 'unchanged' ? 'unchanged' : String(printerId)}
onChange={(e) => {
@@ -183,8 +186,8 @@ function BulkEditModal({
}}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="unchanged">— No change —</option>
<option value="null">Unassigned</option>
<option value="unchanged">{t('queue.bulkEdit.noChange')}</option>
<option value="null">{t('queue.filter.unassigned')}</option>
{printers.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
@@ -193,35 +196,35 @@ function BulkEditModal({
{/* Queue Options */}
<div>
<label className="block text-sm font-medium text-white mb-2">Queue Options</label>
<label className="block text-sm font-medium text-white mb-2">{t('queue.bulkEdit.queueOptions')}</label>
<div className="space-y-2">
<TriStateToggle label="Staged (manual start)" value={manualStart} onChange={setManualStart} />
<TriStateToggle label="Auto power off after print" value={autoOffAfter} onChange={setAutoOffAfter} />
<TriStateToggle label="Require previous success" value={requirePreviousSuccess} onChange={setRequirePreviousSuccess} />
<TriStateToggle label={t('queue.bulkEdit.staged')} value={manualStart} onChange={setManualStart} t={t} />
<TriStateToggle label={t('queue.bulkEdit.autoPowerOff')} value={autoOffAfter} onChange={setAutoOffAfter} t={t} />
<TriStateToggle label={t('queue.bulkEdit.requirePrevious')} value={requirePreviousSuccess} onChange={setRequirePreviousSuccess} t={t} />
</div>
</div>
{/* Print Options */}
<div>
<label className="block text-sm font-medium text-white mb-2">Print Options</label>
<label className="block text-sm font-medium text-white mb-2">{t('queue.bulkEdit.printOptions')}</label>
<div className="space-y-2">
<TriStateToggle label="Bed levelling" value={bedLevelling} onChange={setBedLevelling} />
<TriStateToggle label="Flow calibration" value={flowCali} onChange={setFlowCali} />
<TriStateToggle label="Vibration calibration" value={vibrationCali} onChange={setVibrationCali} />
<TriStateToggle label="First layer inspection" value={layerInspect} onChange={setLayerInspect} />
<TriStateToggle label="Timelapse" value={timelapse} onChange={setTimelapse} />
<TriStateToggle label="Use AMS" value={useAms} onChange={setUseAms} />
<TriStateToggle label={t('queue.bulkEdit.bedLevelling')} value={bedLevelling} onChange={setBedLevelling} t={t} />
<TriStateToggle label={t('queue.bulkEdit.flowCalibration')} value={flowCali} onChange={setFlowCali} t={t} />
<TriStateToggle label={t('queue.bulkEdit.vibrationCalibration')} value={vibrationCali} onChange={setVibrationCali} t={t} />
<TriStateToggle label={t('queue.bulkEdit.layerInspection')} value={layerInspect} onChange={setLayerInspect} t={t} />
<TriStateToggle label={t('queue.bulkEdit.timelapse')} value={timelapse} onChange={setTimelapse} t={t} />
<TriStateToggle label={t('queue.bulkEdit.useAms')} value={useAms} onChange={setUseAms} t={t} />
</div>
</div>
</div>
<div className="flex justify-end gap-3 p-4 border-t border-bambu-dark-tertiary">
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
<Button
onClick={handleSave}
disabled={!hasChanges || isSaving}
>
{isSaving ? 'Saving...' : 'Apply Changes'}
{isSaving ? t('common.saving') : t('queue.bulkEdit.applyChanges')}
</Button>
</div>
</div>
@@ -234,10 +237,12 @@ function TriStateToggle({
label,
value,
onChange,
t,
}: {
label: string;
value: boolean | 'unchanged';
onChange: (val: boolean | 'unchanged') => void;
t: (key: string) => string;
}) {
return (
<div className="flex items-center justify-between py-1">
@@ -253,13 +258,13 @@ function TriStateToggle({
onClick={() => onChange(false)}
className={`px-2 py-1 text-xs rounded ${value === false ? 'bg-red-500/20 text-red-400' : 'text-bambu-gray hover:text-white'}`}
>
Off
{t('common.off')}
</button>
<button
onClick={() => onChange(true)}
className={`px-2 py-1 text-xs rounded ${value === true ? 'bg-bambu-green/20 text-bambu-green' : 'text-bambu-gray hover:text-white'}`}
>
On
{t('common.on')}
</button>
</div>
</div>
@@ -281,6 +286,7 @@ function SortableQueueItem({
onToggleSelect,
hasPermission,
canModify,
t,
}: {
item: PrintQueueItem;
position?: number;
@@ -295,6 +301,7 @@ function SortableQueueItem({
onToggleSelect?: () => void;
hasPermission: (permission: Permission) => boolean;
canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
const canReorder = hasPermission('queue:reorder');
const {
@@ -400,7 +407,7 @@ function SortableQueueItem({
<Link
to={`/archives?highlight=${item.archive_id}`}
className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
title="View archive"
title={t('queue.viewArchive')}
>
<ExternalLink className="w-3.5 h-3.5" />
</Link>
@@ -408,7 +415,7 @@ function SortableQueueItem({
<Link
to={`/library?highlight=${item.library_file_id}`}
className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
title="View in File Manager"
title={t('queue.viewInFileManager')}
>
<ExternalLink className="w-3.5 h-3.5" />
</Link>
@@ -419,10 +426,10 @@ function SortableQueueItem({
<span className={`flex items-center gap-1.5 ${item.printer_id === null && !item.target_model ? 'text-orange-400' : ''} ${item.target_model ? 'text-blue-400' : ''}`}>
<Printer className="w-3.5 h-3.5" />
{item.target_model
? `Any ${item.target_model}${item.target_location ? ` @ ${item.target_location}` : ''}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`
? `${t('queue.filter.any')} ${item.target_model}${item.target_location ? ` @ ${item.target_location}` : ''}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`
: item.printer_id === null
? 'Unassigned'
: (item.printer_name || `Printer #${item.printer_id}`)}
? t('queue.filter.unassigned')
: (item.printer_name || `${t('common.printer')} #${item.printer_id}`)}
</span>
{item.print_time_seconds && (
<span className="flex items-center gap-1.5">
@@ -431,7 +438,7 @@ function SortableQueueItem({
</span>
)}
{item.created_by_username && (
<span className="flex items-center gap-1.5" title={`Added by ${item.created_by_username}`}>
<span className="flex items-center gap-1.5" title={t('queue.addedBy', { name: item.created_by_username })}>
<User className="w-3.5 h-3.5" />
{item.created_by_username}
</span>
@@ -439,7 +446,7 @@ function SortableQueueItem({
{isPending && !item.manual_start && (
<span className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />
{formatRelativeTime(item.scheduled_time, timeFormat)}
{formatRelativeTime(item.scheduled_time, timeFormat, t)}
</span>
)}
</div>
@@ -449,18 +456,18 @@ function SortableQueueItem({
{item.manual_start && (
<span className="text-xs px-2 py-0.5 bg-purple-500/10 text-purple-400 rounded-full border border-purple-500/20 flex items-center gap-1">
<Hand className="w-3 h-3" />
Staged
{t('queue.badges.staged')}
</span>
)}
{item.require_previous_success && (
<span className="text-xs px-2 py-0.5 bg-orange-500/10 text-orange-400 rounded-full border border-orange-500/20">
Requires previous success
{t('queue.badges.requiresPrevious')}
</span>
)}
{item.auto_off_after && (
<span className="text-xs px-2 py-0.5 bg-blue-500/10 text-blue-400 rounded-full border border-blue-500/20 flex items-center gap-1">
<Power className="w-3 h-3" />
Auto power off
{t('queue.badges.autoPowerOff')}
</span>
)}
</div>
@@ -471,7 +478,7 @@ function SortableQueueItem({
<div className="h-2 bg-bambu-dark rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-blue-500 to-blue-400 animate-pulse w-full opacity-50" />
</div>
<p className="text-xs text-bambu-gray mt-1">Printing in progress...</p>
<p className="text-xs text-bambu-gray mt-1">{t('queue.printingInProgress')}</p>
</div>
)}
@@ -493,7 +500,7 @@ function SortableQueueItem({
</div>
{/* Status badge */}
<StatusBadge status={item.status} waitingReason={item.waiting_reason} />
<StatusBadge status={item.status} waitingReason={item.waiting_reason} t={t} />
{/* Actions */}
<div className="flex items-center gap-1">
@@ -503,7 +510,7 @@ function SortableQueueItem({
size="sm"
onClick={onStop}
disabled={!hasPermission('printers:control')}
title={!hasPermission('printers:control') ? 'You do not have permission to stop prints' : 'Stop Print'}
title={!hasPermission('printers:control') ? t('queue.permissions.noStopPrint') : t('queue.actions.stopPrint')}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<StopCircle className="w-4 h-4" />
@@ -517,7 +524,7 @@ function SortableQueueItem({
size="sm"
onClick={onStart}
disabled={!hasPermission('printers:control')}
title={!hasPermission('printers:control') ? 'You do not have permission to start prints' : 'Start Print'}
title={!hasPermission('printers:control') ? t('queue.permissions.noStartPrint') : t('queue.actions.startPrint')}
className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10"
>
<Play className="w-4 h-4" />
@@ -528,7 +535,7 @@ function SortableQueueItem({
size="sm"
onClick={onEdit}
disabled={!canModify('queue', 'update', item.created_by_id)}
title={!canModify('queue', 'update', item.created_by_id) ? 'You do not have permission to edit this queue item' : 'Edit'}
title={!canModify('queue', 'update', item.created_by_id) ? t('queue.permissions.noEdit') : t('common.edit')}
>
<Pencil className="w-4 h-4" />
</Button>
@@ -537,7 +544,7 @@ function SortableQueueItem({
size="sm"
onClick={onCancel}
disabled={!canModify('queue', 'delete', item.created_by_id)}
title={!canModify('queue', 'delete', item.created_by_id) ? 'You do not have permission to cancel this queue item' : 'Cancel'}
title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noCancel') : t('common.cancel')}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<X className="w-4 h-4" />
@@ -551,7 +558,7 @@ function SortableQueueItem({
size="sm"
onClick={onRequeue}
disabled={!hasPermission('queue:create')}
title={!hasPermission('queue:create') ? 'You do not have permission to re-queue items' : 'Re-queue'}
title={!hasPermission('queue:create') ? t('queue.permissions.noRequeue') : t('queue.actions.requeue')}
className="text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10"
>
<RefreshCw className="w-4 h-4" />
@@ -561,7 +568,7 @@ function SortableQueueItem({
size="sm"
onClick={onRemove}
disabled={!canModify('queue', 'delete', item.created_by_id)}
title={!canModify('queue', 'delete', item.created_by_id) ? 'You do not have permission to remove this queue item' : 'Remove'}
title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noRemove') : t('common.remove')}
>
<Trash2 className="w-4 h-4" />
</Button>
@@ -574,6 +581,7 @@ function SortableQueueItem({
}
export function QueuePage() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const { hasPermission, hasAnyPermission, canModify } = useAuth();
@@ -650,36 +658,36 @@ export function QueuePage() {
mutationFn: (id: number) => api.cancelQueueItem(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['queue'] });
showToast('Queue item cancelled');
showToast(t('queue.toast.cancelled'));
},
onError: () => showToast('Failed to cancel item', 'error'),
onError: () => showToast(t('queue.toast.cancelFailed'), 'error'),
});
const removeMutation = useMutation({
mutationFn: (id: number) => api.removeFromQueue(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['queue'] });
showToast('Queue item removed');
showToast(t('queue.toast.removed'));
},
onError: () => showToast('Failed to remove item', 'error'),
onError: () => showToast(t('queue.toast.removeFailed'), 'error'),
});
const stopMutation = useMutation({
mutationFn: (id: number) => api.stopQueueItem(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['queue'] });
showToast('Print stopped');
showToast(t('queue.toast.stopped'));
},
onError: () => showToast('Failed to stop print', 'error'),
onError: () => showToast(t('queue.toast.stopFailed'), 'error'),
});
const startMutation = useMutation({
mutationFn: (id: number) => api.startQueueItem(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['queue'] });
showToast('Print released to queue');
showToast(t('queue.toast.released'));
},
onError: () => showToast('Failed to start print', 'error'),
onError: () => showToast(t('queue.toast.startFailed'), 'error'),
});
const reorderMutation = useMutation({
@@ -687,7 +695,7 @@ export function QueuePage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['queue'] });
},
onError: () => showToast('Failed to reorder queue', 'error'),
onError: () => showToast(t('queue.toast.reorderFailed'), 'error'),
});
const clearHistoryMutation = useMutation({
@@ -702,9 +710,9 @@ export function QueuePage() {
},
onSuccess: (count) => {
queryClient.invalidateQueries({ queryKey: ['queue'] });
showToast(`Cleared ${count} history item${count !== 1 ? 's' : ''}`);
showToast(t('queue.toast.historyCleared', { count }));
},
onError: () => showToast('Failed to clear history', 'error'),
onError: () => showToast(t('queue.toast.clearHistoryFailed'), 'error'),
});
const bulkUpdateMutation = useMutation({
@@ -715,7 +723,7 @@ export function QueuePage() {
setShowBulkEditModal(false);
showToast(result.message);
},
onError: () => showToast('Failed to update items', 'error'),
onError: () => showToast(t('queue.toast.updateFailed'), 'error'),
});
const bulkCancelMutation = useMutation({
@@ -728,9 +736,9 @@ export function QueuePage() {
onSuccess: (count) => {
queryClient.invalidateQueries({ queryKey: ['queue'] });
setSelectedItems([]);
showToast(`Cancelled ${count} item${count !== 1 ? 's' : ''}`);
showToast(t('queue.toast.bulkCancelled', { count }));
},
onError: () => showToast('Failed to cancel items', 'error'),
onError: () => showToast(t('queue.toast.bulkCancelFailed'), 'error'),
});
const handleToggleSelect = (id: number) => {
@@ -867,9 +875,9 @@ export function QueuePage() {
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<ListOrdered className="w-7 h-7 text-bambu-green" />
Print Queue
{t('queue.title')}
</h1>
<p className="text-bambu-gray mt-1">Schedule and manage your print jobs</p>
<p className="text-bambu-gray mt-1">{t('queue.subtitle')}</p>
</div>
</div>
@@ -883,7 +891,7 @@ export function QueuePage() {
</div>
<div>
<p className="text-2xl font-bold text-white">{activeItems.length}</p>
<p className="text-sm text-bambu-gray">Printing</p>
<p className="text-sm text-bambu-gray">{t('queue.summary.printing')}</p>
</div>
</div>
</CardContent>
@@ -897,7 +905,7 @@ export function QueuePage() {
</div>
<div>
<p className="text-2xl font-bold text-white">{pendingItems.length}</p>
<p className="text-sm text-bambu-gray">Queued</p>
<p className="text-sm text-bambu-gray">{t('queue.summary.queued')}</p>
</div>
</div>
</CardContent>
@@ -911,7 +919,7 @@ export function QueuePage() {
</div>
<div>
<p className="text-2xl font-bold text-white">{formatDuration(totalQueueTime)}</p>
<p className="text-sm text-bambu-gray">Total Queue Time</p>
<p className="text-sm text-bambu-gray">{t('queue.summary.totalTime')}</p>
</div>
</div>
</CardContent>
@@ -925,7 +933,7 @@ export function QueuePage() {
</div>
<div>
<p className="text-2xl font-bold text-white">{historyItems.length}</p>
<p className="text-sm text-bambu-gray">History</p>
<p className="text-sm text-bambu-gray">{t('queue.summary.history')}</p>
</div>
</div>
</CardContent>
@@ -944,8 +952,8 @@ export function QueuePage() {
else setFilterPrinter(Number(val));
}}
>
<option value="">All Printers</option>
<option value="unassigned">Unassigned</option>
<option value="">{t('queue.filter.allPrinters')}</option>
<option value="unassigned">{t('queue.filter.unassigned')}</option>
{printers?.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
@@ -956,13 +964,13 @@ export function QueuePage() {
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
>
<option value="">All Status</option>
<option value="pending">Pending</option>
<option value="printing">Printing</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="skipped">Skipped</option>
<option value="cancelled">Cancelled</option>
<option value="">{t('queue.filter.allStatus')}</option>
<option value="pending">{t('queue.status.pending')}</option>
<option value="printing">{t('queue.status.printing')}</option>
<option value="completed">{t('queue.status.completed')}</option>
<option value="failed">{t('queue.status.failed')}</option>
<option value="skipped">{t('queue.status.skipped')}</option>
<option value="cancelled">{t('queue.status.cancelled')}</option>
</select>
{uniqueLocations.length > 0 && (
@@ -971,7 +979,7 @@ export function QueuePage() {
value={filterLocation}
onChange={(e) => setFilterLocation(e.target.value)}
>
<option value="">All Locations</option>
<option value="">{t('queue.filter.allLocations')}</option>
{uniqueLocations.map((loc) => (
<option key={loc} value={loc}>{loc}</option>
))}
@@ -986,23 +994,22 @@ export function QueuePage() {
size="sm"
onClick={() => setShowClearHistoryConfirm(true)}
disabled={!hasPermission('queue:delete_all')}
title={!hasPermission('queue:delete_all') ? 'You do not have permission to clear all history' : undefined}
title={!hasPermission('queue:delete_all') ? t('queue.permissions.noClearHistory') : undefined}
>
<Trash2 className="w-4 h-4" />
Clear History
{t('queue.clearHistory')}
</Button>
)}
</div>
{isLoading ? (
<div className="text-center py-12 text-bambu-gray">Loading...</div>
<div className="text-center py-12 text-bambu-gray">{t('common.loading')}</div>
) : queue?.length === 0 ? (
<Card className="p-12 text-center border-dashed">
<Calendar className="w-16 h-16 text-bambu-gray mx-auto mb-4 opacity-50" />
<h3 className="text-xl font-medium text-white mb-2">No prints scheduled</h3>
<h3 className="text-xl font-medium text-white mb-2">{t('queue.empty.title')}</h3>
<p className="text-bambu-gray max-w-md mx-auto">
Schedule a print from the Archives page using the "Schedule" option in the context menu,
or drag and drop files to get started.
{t('queue.empty.description')}
</p>
</Card>
) : (
@@ -1012,7 +1019,7 @@ export function QueuePage() {
<div>
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
Currently Printing
{t('queue.sections.currentlyPrinting')}
</h2>
<div className="space-y-3">
{activeItems.map((item) => (
@@ -1028,6 +1035,7 @@ export function QueuePage() {
timeFormat={timeFormat}
hasPermission={hasPermission}
canModify={canModify}
t={t}
/>
))}
</div>
@@ -1040,12 +1048,12 @@ export function QueuePage() {
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Clock className="w-5 h-5 text-yellow-400" />
Queued
{t('queue.sections.queued')}
<span className="text-sm font-normal text-bambu-gray">
({pendingItems.length} item{pendingItems.length !== 1 ? 's' : ''})
({t('queue.itemCount', { count: pendingItems.length })})
</span>
<span className="text-xs text-bambu-gray ml-2" title="Position only affects ASAP items. Scheduled items run at their set time.">
Drag to reorder (ASAP only)
<span className="text-xs text-bambu-gray ml-2" title={t('queue.reorderHint')}>
{t('queue.dragToReorder')}
</span>
</h2>
<div className="flex items-center gap-2">
@@ -1054,16 +1062,16 @@ export function QueuePage() {
value={pendingSortBy}
onChange={(e) => setPendingSortBy(e.target.value as 'position' | 'name' | 'printer' | 'time')}
>
<option value="position">Sort by Position</option>
<option value="name">Sort by Name</option>
<option value="printer">Sort by Printer</option>
<option value="time">Sort by Schedule</option>
<option value="position">{t('queue.sort.byPosition')}</option>
<option value="name">{t('queue.sort.byName')}</option>
<option value="printer">{t('queue.sort.byPrinter')}</option>
<option value="time">{t('queue.sort.bySchedule')}</option>
</select>
<Button
variant="ghost"
size="sm"
onClick={() => setPendingSortAsc(!pendingSortAsc)}
title={pendingSortAsc ? 'Ascending' : 'Descending'}
title={pendingSortAsc ? t('common.ascending') : t('common.descending')}
className="px-2"
>
{pendingSortAsc ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
@@ -1084,12 +1092,12 @@ export function QueuePage() {
) : (
<Square className="w-4 h-4" />
)}
{selectedItems.length === pendingItems.length && pendingItems.length > 0 ? 'Deselect All' : 'Select All'}
{selectedItems.length === pendingItems.length && pendingItems.length > 0 ? t('queue.bulkEdit.deselectAll') : t('queue.bulkEdit.selectAll')}
</Button>
{selectedItems.length > 0 && (
<>
<span className="text-sm text-bambu-gray">
{selectedItems.length} selected
{t('queue.bulkEdit.selected', { count: selectedItems.length })}
</span>
<div className="h-4 w-px bg-bambu-dark-tertiary" />
<Button
@@ -1098,10 +1106,10 @@ export function QueuePage() {
onClick={() => setShowBulkEditModal(true)}
className="flex items-center gap-2 text-bambu-green hover:text-bambu-green-light"
disabled={!hasAnyPermission('queue:update_own', 'queue:update_all')}
title={!hasAnyPermission('queue:update_own', 'queue:update_all') ? 'You do not have permission to edit queue items' : undefined}
title={!hasAnyPermission('queue:update_own', 'queue:update_all') ? t('queue.permissions.noEditItems') : undefined}
>
<Pencil className="w-4 h-4" />
Edit Selected
{t('queue.bulkEdit.editSelected')}
</Button>
<Button
variant="ghost"
@@ -1109,10 +1117,10 @@ export function QueuePage() {
onClick={() => bulkCancelMutation.mutate(selectedItems)}
className="flex items-center gap-2 text-red-400 hover:text-red-300"
disabled={bulkCancelMutation.isPending || !hasAnyPermission('queue:delete_own', 'queue:delete_all')}
title={!hasAnyPermission('queue:delete_own', 'queue:delete_all') ? 'You do not have permission to cancel queue items' : undefined}
title={!hasAnyPermission('queue:delete_own', 'queue:delete_all') ? t('queue.permissions.noCancelItems') : undefined}
>
<X className="w-4 h-4" />
Cancel Selected
{t('queue.bulkEdit.cancelSelected')}
</Button>
</>
)}
@@ -1144,6 +1152,7 @@ export function QueuePage() {
onToggleSelect={() => handleToggleSelect(item.id)}
hasPermission={hasPermission}
canModify={canModify}
t={t}
/>
))}
</div>
@@ -1158,9 +1167,9 @@ export function QueuePage() {
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-bambu-gray" />
History
{t('queue.sections.history')}
<span className="text-sm font-normal text-bambu-gray">
({historyItems.length} item{historyItems.length !== 1 ? 's' : ''})
({t('queue.itemCount', { count: historyItems.length })})
</span>
</h2>
<div className="flex items-center gap-2">
@@ -1169,15 +1178,15 @@ export function QueuePage() {
value={historySortBy}
onChange={(e) => setHistorySortBy(e.target.value as 'date' | 'name' | 'printer')}
>
<option value="date">Sort by Date</option>
<option value="name">Sort by Name</option>
<option value="printer">Sort by Printer</option>
<option value="date">{t('queue.sort.byDate')}</option>
<option value="name">{t('queue.sort.byName')}</option>
<option value="printer">{t('queue.sort.byPrinter')}</option>
</select>
<Button
variant="ghost"
size="sm"
onClick={() => setHistorySortAsc(!historySortAsc)}
title={historySortAsc ? 'Ascending (oldest first)' : 'Descending (newest first)'}
title={historySortAsc ? t('queue.sort.ascendingOldest') : t('queue.sort.descendingNewest')}
className="px-2"
>
{historySortAsc ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
@@ -1199,6 +1208,7 @@ export function QueuePage() {
timeFormat={timeFormat}
hasPermission={hasPermission}
canModify={canModify}
t={t}
/>
))}
</div>
@@ -1234,21 +1244,21 @@ export function QueuePage() {
{confirmAction && (
<ConfirmModal
title={
confirmAction.type === 'cancel' ? 'Cancel Scheduled Print' :
confirmAction.type === 'stop' ? 'Stop Print' :
'Remove from History'
confirmAction.type === 'cancel' ? t('queue.confirm.cancelTitle') :
confirmAction.type === 'stop' ? t('queue.confirm.stopTitle') :
t('queue.confirm.removeTitle')
}
message={
confirmAction.type === 'cancel'
? `Are you sure you want to cancel "${confirmAction.item.archive_name || confirmAction.item.library_file_name || 'this print'}"?`
? t('queue.confirm.cancelMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisPrint') })
: confirmAction.type === 'stop'
? `Are you sure you want to stop the current print "${confirmAction.item.archive_name || confirmAction.item.library_file_name || 'this print'}"? This will cancel the print job on the printer.`
: `Are you sure you want to remove "${confirmAction.item.archive_name || confirmAction.item.library_file_name || 'this item'}" from the queue history?`
? t('queue.confirm.stopMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisPrint') })
: t('queue.confirm.removeMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisItem') })
}
confirmText={
confirmAction.type === 'cancel' ? 'Cancel Print' :
confirmAction.type === 'stop' ? 'Stop Print' :
'Remove'
confirmAction.type === 'cancel' ? t('queue.confirm.cancelButton') :
confirmAction.type === 'stop' ? t('queue.confirm.stopButton') :
t('common.remove')
}
variant="danger"
onConfirm={() => {
@@ -1268,9 +1278,9 @@ export function QueuePage() {
{/* Clear History Confirm Modal */}
{showClearHistoryConfirm && (
<ConfirmModal
title="Clear History"
message={`Are you sure you want to remove all ${historyItems.length} item${historyItems.length !== 1 ? 's' : ''} from the history?`}
confirmText="Clear History"
title={t('queue.confirm.clearHistoryTitle')}
message={t('queue.confirm.clearHistoryMessage', { count: historyItems.length })}
confirmText={t('queue.clearHistory')}
variant="danger"
onConfirm={() => {
clearHistoryMutation.mutate();
@@ -1292,6 +1302,7 @@ export function QueuePage() {
}}
onClose={() => setShowBulkEditModal(false)}
isSaving={bulkUpdateMutation.isPending}
t={t}
/>
)}
</div>
File diff suppressed because it is too large Load Diff
+20 -19
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { api } from '../api/client';
import { useToast } from '../contexts/ToastContext';
import { useTheme } from '../contexts/ThemeContext';
@@ -9,6 +10,7 @@ import { Info } from 'lucide-react';
export function SetupPage() {
const navigate = useNavigate();
const { t } = useTranslation();
const { showToast } = useToast();
const { mode } = useTheme();
const { refreshAuth } = useAuth();
@@ -30,14 +32,14 @@ export function SetupPage() {
if (data.auth_enabled) {
if (data.admin_created) {
showToast('Authentication enabled and admin user created');
showToast(t('setup.toast.authEnabledAdminCreated'));
navigate('/login');
} else {
showToast('Authentication enabled using existing admin users');
showToast(t('setup.toast.authEnabledExistingAdmins'));
navigate('/login');
}
} else {
showToast('Setup completed');
showToast(t('setup.toast.setupCompleted'));
navigate('/');
}
},
@@ -54,15 +56,15 @@ export function SetupPage() {
// If no credentials provided, backend will use existing admin users if they exist
if (adminUsername || adminPassword) {
if (!adminUsername || !adminPassword) {
showToast('Please enter both admin username and password, or leave both empty to use existing admin users', 'error');
showToast(t('setup.toast.enterBothCredentials'), 'error');
return;
}
if (adminPassword !== confirmPassword) {
showToast('Passwords do not match', 'error');
showToast(t('setup.toast.passwordsDoNotMatch'), 'error');
return;
}
if (adminPassword.length < 6) {
showToast('Password must be at least 6 characters', 'error');
showToast(t('setup.toast.passwordTooShort'), 'error');
return;
}
}
@@ -83,10 +85,10 @@ export function SetupPage() {
/>
</div>
<h2 className="text-3xl font-bold text-white">
Bambuddy Setup
{t('setup.title')}
</h2>
<p className="mt-2 text-sm text-bambu-gray">
Configure authentication for your Bambuddy instance
{t('setup.subtitle')}
</p>
</div>
@@ -101,7 +103,7 @@ export function SetupPage() {
className="h-4 w-4 text-bambu-green focus:ring-bambu-green border-bambu-dark-tertiary rounded bg-bambu-dark-secondary"
/>
<label htmlFor="auth-enabled" className="ml-3 block text-sm font-medium text-white">
Enable Authentication
{t('setup.enableAuth')}
</label>
</div>
@@ -111,10 +113,9 @@ export function SetupPage() {
<div className="flex items-start gap-2">
<Info className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
<div className="text-sm text-bambu-gray">
<p className="text-white font-medium mb-1">Admin Account</p>
<p className="text-white font-medium mb-1">{t('setup.adminAccount')}</p>
<p>
If admin users already exist, authentication will be enabled using the existing admin accounts.
Leave the fields below empty to use existing admins, or enter new credentials to create a new admin user.
{t('setup.adminAccountDesc')}
</p>
</div>
</div>
@@ -122,7 +123,7 @@ export function SetupPage() {
<div>
<label htmlFor="admin-username" className="block text-sm font-medium text-white mb-2">
Admin Username <span className="text-bambu-gray text-xs">(optional if admin users exist)</span>
{t('setup.adminUsername')} <span className="text-bambu-gray text-xs">{t('setup.optionalIfAdminExists')}</span>
</label>
<input
id="admin-username"
@@ -130,14 +131,14 @@ export function SetupPage() {
value={adminUsername}
onChange={(e) => setAdminUsername(e.target.value)}
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder="Enter admin username (optional)"
placeholder={t('setup.adminUsernamePlaceholder')}
autoComplete="username"
/>
</div>
<div>
<label htmlFor="admin-password" className="block text-sm font-medium text-white mb-2">
Admin Password <span className="text-bambu-gray text-xs">(optional if admin users exist)</span>
{t('setup.adminPassword')} <span className="text-bambu-gray text-xs">{t('setup.optionalIfAdminExists')}</span>
</label>
<input
id="admin-password"
@@ -145,7 +146,7 @@ export function SetupPage() {
value={adminPassword}
onChange={(e) => setAdminPassword(e.target.value)}
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder="Enter admin password (optional)"
placeholder={t('setup.adminPasswordPlaceholder')}
minLength={6}
autoComplete="new-password"
/>
@@ -154,7 +155,7 @@ export function SetupPage() {
{adminPassword && (
<div>
<label htmlFor="confirm-password" className="block text-sm font-medium text-white mb-2">
Confirm Password
{t('setup.confirmPassword')}
</label>
<input
id="confirm-password"
@@ -162,7 +163,7 @@ export function SetupPage() {
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder="Confirm admin password"
placeholder={t('setup.confirmPasswordPlaceholder')}
minLength={6}
autoComplete="new-password"
/>
@@ -178,7 +179,7 @@ export function SetupPage() {
disabled={setupMutation.isPending}
className="w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-bambu-green"
>
{setupMutation.isPending ? 'Setting up...' : 'Complete Setup'}
{setupMutation.isPending ? t('setup.settingUp') : t('setup.completeSetup')}
</button>
</div>
</form>
+72 -58
View File
@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Package,
Clock,
@@ -30,6 +31,7 @@ import { Dashboard, type DashboardWidget } from '../components/Dashboard';
function QuickStatsWidget({
stats,
currency,
t,
}: {
stats: {
total_prints: number;
@@ -42,6 +44,7 @@ function QuickStatsWidget({
total_energy_cost: number;
} | undefined;
currency: string;
t: (key: string) => string;
}) {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
@@ -50,7 +53,7 @@ function QuickStatsWidget({
<Package className="w-5 h-5" />
</div>
<div>
<p className="text-xs text-bambu-gray">Total Prints</p>
<p className="text-xs text-bambu-gray">{t('stats.totalPrints')}</p>
<p className="text-xl font-bold text-white">{stats?.total_prints || 0}</p>
</div>
</div>
@@ -59,7 +62,7 @@ function QuickStatsWidget({
<Clock className="w-5 h-5" />
</div>
<div>
<p className="text-xs text-bambu-gray">Print Time</p>
<p className="text-xs text-bambu-gray">{t('stats.printTime')}</p>
<p className="text-xl font-bold text-white">{stats?.total_print_time_hours.toFixed(1) || 0}h</p>
</div>
</div>
@@ -68,7 +71,7 @@ function QuickStatsWidget({
<Package className="w-5 h-5" />
</div>
<div>
<p className="text-xs text-bambu-gray">Filament Used</p>
<p className="text-xs text-bambu-gray">{t('stats.filamentUsed')}</p>
<p className="text-xl font-bold text-white">{((stats?.total_filament_grams || 0) / 1000).toFixed(2)}kg</p>
</div>
</div>
@@ -77,7 +80,7 @@ function QuickStatsWidget({
<DollarSign className="w-5 h-5" />
</div>
<div>
<p className="text-xs text-bambu-gray">Filament Cost</p>
<p className="text-xs text-bambu-gray">{t('stats.filamentCost')}</p>
<p className="text-xl font-bold text-white">{currency} {stats?.total_cost.toFixed(2) || '0.00'}</p>
</div>
</div>
@@ -86,7 +89,7 @@ function QuickStatsWidget({
<Zap className="w-5 h-5" />
</div>
<div>
<p className="text-xs text-bambu-gray">Energy Used</p>
<p className="text-xs text-bambu-gray">{t('stats.energyUsed')}</p>
<p className="text-xl font-bold text-white">{stats?.total_energy_kwh.toFixed(2) || '0.00'} kWh</p>
</div>
</div>
@@ -95,7 +98,7 @@ function QuickStatsWidget({
<DollarSign className="w-5 h-5" />
</div>
<div>
<p className="text-xs text-bambu-gray">Energy Cost</p>
<p className="text-xs text-bambu-gray">{t('stats.energyCost')}</p>
<p className="text-xl font-bold text-white">{currency} {stats?.total_energy_cost.toFixed(2) || '0.00'}</p>
</div>
</div>
@@ -107,6 +110,7 @@ function SuccessRateWidget({
stats,
printerMap,
size = 1,
t,
}: {
stats: {
total_prints: number;
@@ -116,6 +120,7 @@ function SuccessRateWidget({
} | undefined;
printerMap: Map<string, string>;
size?: 1 | 2 | 4;
t: (key: string) => string;
}) {
const successRate = stats?.total_prints
? Math.round((stats.successful_prints / stats.total_prints) * 100)
@@ -157,24 +162,24 @@ function SuccessRateWidget({
<div className="space-y-2">
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-status-ok flex-shrink-0" />
<span className="text-sm text-bambu-gray">Successful:</span>
<span className="text-sm text-bambu-gray">{t('stats.successful')}</span>
<span className="text-sm text-white font-medium">{stats?.successful_prints || 0}</span>
</div>
<div className="flex items-center gap-2">
<XCircle className="w-4 h-4 text-status-error flex-shrink-0" />
<span className="text-sm text-bambu-gray">Failed:</span>
<span className="text-sm text-bambu-gray">{t('stats.failed')}</span>
<span className="text-sm text-white font-medium">{stats?.failed_prints || 0}</span>
</div>
</div>
{/* Show per-printer breakdown when expanded */}
{size >= 2 && stats?.prints_by_printer && Object.keys(stats.prints_by_printer).length > 0 && (
<div className="mt-4 pt-4 border-t border-bambu-dark-tertiary">
<p className="text-xs text-bambu-gray font-medium mb-2">Prints by Printer</p>
<p className="text-xs text-bambu-gray font-medium mb-2">{t('stats.printsByPrinter')}</p>
<div className={`grid gap-x-6 gap-y-1 ${size === 4 ? 'grid-cols-3' : 'grid-cols-2'}`} style={{ width: 'fit-content' }}>
{Object.entries(stats.prints_by_printer).map(([printerId, count]) => (
<div key={printerId} className="flex items-center gap-3 text-sm">
<span className="text-bambu-gray truncate max-w-[120px]">
{printerMap.get(printerId) || `Printer ${printerId}`}
{printerMap.get(printerId) || `${t('common.printer')} ${printerId}`}
</span>
<span className="text-white font-medium">{count}</span>
</div>
@@ -191,6 +196,7 @@ function TimeAccuracyWidget({
stats,
printerMap,
size = 1,
t,
}: {
stats: {
average_time_accuracy: number | null;
@@ -198,13 +204,14 @@ function TimeAccuracyWidget({
} | undefined;
printerMap: Map<string, string>;
size?: 1 | 2 | 4;
t: (key: string) => string;
}) {
const accuracy = stats?.average_time_accuracy;
if (accuracy === null || accuracy === undefined) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-bambu-gray text-center py-4">No time accuracy data yet</p>
<p className="text-bambu-gray text-center py-4">{t('stats.noTimeAccuracyData')}</p>
</div>
);
}
@@ -267,14 +274,14 @@ function TimeAccuracyWidget({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-xs text-bambu-gray">
<Target className="w-3 h-3 flex-shrink-0" />
<span>100% = perfect estimate</span>
<span>{t('stats.perfectEstimate')}</span>
</div>
{printerEntries.length > 0 && (
<div className={`mt-2 ${size === 4 ? 'grid grid-cols-3 gap-x-6 gap-y-1' : size === 2 ? 'grid grid-cols-2 gap-x-6 gap-y-1' : 'space-y-1'}`} style={{ width: 'fit-content' }}>
{printerEntries.map(([printerId, acc]) => (
<div key={printerId} className="flex items-center gap-2 text-xs">
<span className="text-bambu-gray truncate max-w-[100px]">
{printerMap.get(printerId) || `Printer ${printerId}`}
{printerMap.get(printerId) || `${t('common.printer')} ${printerId}`}
</span>
<span className={`font-medium ${
acc >= 95 && acc <= 105 ? 'text-status-ok' :
@@ -294,15 +301,17 @@ function TimeAccuracyWidget({
function FilamentTypesWidget({
stats,
size = 1,
t,
}: {
stats: {
total_prints: number;
prints_by_filament_type: Record<string, number>;
} | undefined;
size?: 1 | 2 | 4;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
if (!stats?.prints_by_filament_type || Object.keys(stats.prints_by_filament_type).length === 0) {
return <p className="text-bambu-gray text-center py-4">No filament data available</p>;
return <p className="text-bambu-gray text-center py-4">{t('stats.noFilamentData')}</p>;
}
// Sort by print count descending
@@ -348,7 +357,7 @@ function FilamentTypesWidget({
<div key={type}>
<div className="flex justify-between text-sm mb-1">
<span className="text-white">{type}</span>
<span className="text-bambu-gray">{count} prints</span>
<span className="text-bambu-gray">{count} {t('common.prints')}</span>
</div>
<div className="h-2 bg-bambu-dark rounded-full">
<div
@@ -361,7 +370,7 @@ function FilamentTypesWidget({
})}
{hasMore && (
<p className="text-xs text-bambu-gray text-center pt-1">
+{sortedEntries.length - maxEntries} more types
{t('common.more', { count: sortedEntries.length - maxEntries })}
</p>
)}
</div>
@@ -383,12 +392,14 @@ function PrintActivityWidget({
function PrintsByPrinterWidget({
stats,
printerMap,
t,
}: {
stats: { prints_by_printer: Record<string, number> } | undefined;
printerMap: Map<string, string>;
t: (key: string) => string;
}) {
if (!stats?.prints_by_printer || Object.keys(stats.prints_by_printer).length === 0) {
return <p className="text-bambu-gray text-center py-4">No printer data available</p>;
return <p className="text-bambu-gray text-center py-4">{t('stats.noPrinterData')}</p>;
}
return (
@@ -400,9 +411,9 @@ function PrintsByPrinterWidget({
</div>
<div>
<p className="text-white font-medium text-sm">
{printerMap.get(printerId) || `Printer ${printerId}`}
{printerMap.get(printerId) || `${t('common.printer')} ${printerId}`}
</p>
<p className="text-xs text-bambu-gray">{count} prints</p>
<p className="text-xs text-bambu-gray">{count} {t('common.prints')}</p>
</div>
</div>
))}
@@ -413,17 +424,19 @@ function PrintsByPrinterWidget({
function FilamentTrendsWidget({
archives,
currency,
t,
}: {
archives: Parameters<typeof FilamentTrends>[0]['archives'];
currency: string;
t: (key: string) => string;
}) {
if (!archives || archives.length === 0) {
return <p className="text-bambu-gray text-center py-4">No print data available</p>;
return <p className="text-bambu-gray text-center py-4">{t('stats.noPrintData')}</p>;
}
return <FilamentTrends archives={archives} currency={currency} />;
}
function FailureAnalysisWidget({ size = 1 }: { size?: 1 | 2 | 4 }) {
function FailureAnalysisWidget({ size = 1, t }: { size?: 1 | 2 | 4; t: (key: string, options?: Record<string, unknown>) => string }) {
const { data: analysis, isLoading } = useQuery({
queryKey: ['failureAnalysis'],
queryFn: () => api.getFailureAnalysis({ days: 30 }),
@@ -438,7 +451,7 @@ function FailureAnalysisWidget({ size = 1 }: { size?: 1 | 2 | 4 }) {
}
if (!analysis || analysis.total_prints === 0) {
return <p className="text-bambu-gray text-center py-4">No print data in the last 30 days</p>;
return <p className="text-bambu-gray text-center py-4">{t('stats.noPrintDataLast30Days')}</p>;
}
// Show more reasons when expanded
@@ -458,7 +471,7 @@ function FailureAnalysisWidget({ size = 1 }: { size?: 1 | 2 | 4 }) {
</div>
</div>
<div className="text-sm text-bambu-gray mt-1">
{analysis.failed_prints} / {analysis.total_prints} prints failed
{t('stats.failedPrintsCount', { failed: analysis.failed_prints, total: analysis.total_prints })}
</div>
{/* Trend indicator */}
{analysis.trend && analysis.trend.length >= 2 && (
@@ -470,7 +483,7 @@ function FailureAnalysisWidget({ size = 1 }: { size?: 1 | 2 | 4 }) {
: 'text-status-error'
}`} />
<span className="text-bambu-gray">
Last week: {analysis.trend[analysis.trend.length - 1].failure_rate.toFixed(1)}%
{t('stats.lastWeekRate', { rate: analysis.trend[analysis.trend.length - 1].failure_rate.toFixed(1) })}
</span>
</div>
</div>
@@ -481,13 +494,13 @@ function FailureAnalysisWidget({ size = 1 }: { size?: 1 | 2 | 4 }) {
{topReasons.length > 0 && (
<div className={`flex-1 ${size >= 2 ? 'border-l border-bambu-dark-tertiary pl-8' : 'pt-2'}`}>
<p className="text-xs text-bambu-gray font-medium mb-2">
{size >= 2 ? 'Failure Reasons' : 'Top Failure Reasons'}
{size >= 2 ? t('stats.failureReasons') : t('stats.topFailureReasons')}
</p>
<div className={`${size === 4 ? 'grid grid-cols-2 gap-x-6 gap-y-1' : 'space-y-1'}`}>
{topReasons.map(([reason, count]) => (
<div key={reason} className="flex items-center justify-between text-sm">
<span className={`text-white truncate ${size === 4 ? 'max-w-[200px]' : 'max-w-[160px]'}`}>
{reason || 'Unknown'}
{reason || t('common.unknown')}
</span>
<span className="text-bambu-gray ml-2">{count}</span>
</div>
@@ -495,7 +508,7 @@ function FailureAnalysisWidget({ size = 1 }: { size?: 1 | 2 | 4 }) {
</div>
{hasMore && (
<p className="text-xs text-bambu-gray mt-2">
+{allReasons.length - maxReasons} more reasons
{t('common.more', { count: allReasons.length - maxReasons })}
</p>
)}
</div>
@@ -505,6 +518,7 @@ function FailureAnalysisWidget({ size = 1 }: { size?: 1 | 2 | 4 }) {
}
export function StatsPage() {
const { t } = useTranslation();
const { showToast } = useToast();
const { hasPermission } = useAuth();
const [isExporting, setIsExporting] = useState(false);
@@ -568,9 +582,9 @@ export function StatsPage() {
a.download = filename;
a.click();
URL.revokeObjectURL(url);
showToast('Export downloaded');
showToast(t('stats.exportDownloaded'));
} catch {
showToast('Export failed', 'error');
showToast(t('stats.exportFailed'), 'error');
} finally {
setIsExporting(false);
}
@@ -581,9 +595,9 @@ export function StatsPage() {
try {
const result = await api.recalculateCosts();
await refetchStats();
showToast(`Recalculated costs for ${result.updated} archives`);
showToast(t('stats.recalculatedCosts', { count: result.updated }));
} catch {
showToast('Failed to recalculate costs', 'error');
showToast(t('stats.recalculateFailed'), 'error');
} finally {
setIsRecalculating(false);
}
@@ -596,7 +610,7 @@ export function StatsPage() {
if (isLoading) {
return (
<div className="p-4 md:p-8">
<div className="text-center py-12 text-bambu-gray">Loading statistics...</div>
<div className="text-center py-12 text-bambu-gray">{t('stats.loadingStats')}</div>
</div>
);
}
@@ -607,50 +621,50 @@ export function StatsPage() {
const widgets: DashboardWidget[] = [
{
id: 'quick-stats',
title: 'Quick Stats',
component: <QuickStatsWidget stats={stats} currency={currency} />,
title: t('stats.quickStats'),
component: <QuickStatsWidget stats={stats} currency={currency} t={t} />,
defaultSize: 2,
},
{
id: 'success-rate',
title: 'Success Rate',
component: (size) => <SuccessRateWidget stats={stats} printerMap={printerMap} size={size} />,
title: t('stats.successRate'),
component: (size) => <SuccessRateWidget stats={stats} printerMap={printerMap} size={size} t={t} />,
defaultSize: 1,
},
{
id: 'time-accuracy',
title: 'Time Accuracy',
component: (size) => <TimeAccuracyWidget stats={stats} printerMap={printerMap} size={size} />,
title: t('stats.timeAccuracy'),
component: (size) => <TimeAccuracyWidget stats={stats} printerMap={printerMap} size={size} t={t} />,
defaultSize: 1,
},
{
id: 'filament-types',
title: 'Filament Types',
component: (size) => <FilamentTypesWidget stats={stats} size={size} />,
title: t('stats.filamentTypes'),
component: (size) => <FilamentTypesWidget stats={stats} size={size} t={t} />,
defaultSize: 1,
},
{
id: 'failure-analysis',
title: 'Failure Analysis (30 days)',
component: (size) => <FailureAnalysisWidget size={size} />,
title: t('stats.failureAnalysis'),
component: (size) => <FailureAnalysisWidget size={size} t={t} />,
defaultSize: 1,
},
{
id: 'print-activity',
title: 'Print Activity',
title: t('stats.printActivity'),
component: (size) => <PrintActivityWidget printDates={printDates} size={size} />,
defaultSize: 2,
},
{
id: 'prints-by-printer',
title: 'Prints by Printer',
component: <PrintsByPrinterWidget stats={stats} printerMap={printerMap} />,
title: t('stats.printsByPrinter'),
component: <PrintsByPrinterWidget stats={stats} printerMap={printerMap} t={t} />,
defaultSize: 2,
},
{
id: 'filament-trends',
title: 'Filament Usage Trends',
component: <FilamentTrendsWidget archives={archives || []} currency={currency} />,
title: t('stats.filamentTrends'),
component: <FilamentTrendsWidget archives={archives || []} currency={currency} t={t} />,
defaultSize: 4,
},
];
@@ -660,8 +674,8 @@ export function StatsPage() {
<div className="p-4 md:p-8">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
<p className="text-bambu-gray">Drag widgets to rearrange. Click the eye icon to hide.</p>
<h1 className="text-2xl font-bold text-white">{t('stats.title')}</h1>
<p className="text-bambu-gray">{t('stats.subtitle')}</p>
</div>
<div className="flex items-center gap-2">
{/* Hidden widgets button - toggles panel in Dashboard */}
@@ -674,7 +688,7 @@ export function StatsPage() {
}}
>
<Eye className="w-4 h-4" />
{hiddenCount} Hidden
{t('stats.hiddenCount', { count: hiddenCount })}
</Button>
)}
{/* Reset Layout */}
@@ -683,27 +697,27 @@ export function StatsPage() {
onClick={() => {
localStorage.removeItem('bambusy-dashboard-layout');
setDashboardKey(prev => prev + 1);
showToast('Layout reset');
showToast(t('stats.layoutReset'));
}}
disabled={!hasPermission('settings:update')}
title={!hasPermission('settings:update') ? 'You do not have permission to reset layout' : undefined}
title={!hasPermission('settings:update') ? t('stats.noPermissionResetLayout') : undefined}
>
<RotateCcw className="w-4 h-4" />
Reset Layout
{t('stats.resetLayout')}
</Button>
{/* Recalculate Costs */}
<Button
variant="secondary"
onClick={handleRecalculateCosts}
disabled={isRecalculating || !hasPermission('archives:update_all')}
title={!hasPermission('archives:update_all') ? 'You do not have permission to recalculate costs' : 'Recalculate all archive costs using current filament prices'}
title={!hasPermission('archives:update_all') ? t('stats.noPermissionRecalculate') : t('stats.recalculateCostsHint')}
>
{isRecalculating ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Calculator className="w-4 h-4" />
)}
Recalculate Costs
{t('stats.recalculateCosts')}
</Button>
{/* Export dropdown */}
<div className="relative">
@@ -717,7 +731,7 @@ export function StatsPage() {
) : (
<FileSpreadsheet className="w-4 h-4" />
)}
Export Stats
{t('stats.exportStats')}
</Button>
{showExportMenu && (
<div className="absolute right-0 top-full mt-1 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-20">
@@ -726,14 +740,14 @@ export function StatsPage() {
onClick={() => handleExport('csv')}
>
<FileText className="w-4 h-4" />
Export as CSV
{t('stats.exportAsCsv')}
</button>
<button
className="w-full px-4 py-2 text-left text-white hover:bg-bambu-dark-tertiary transition-colors flex items-center gap-2 rounded-b-lg"
onClick={() => handleExport('xlsx')}
>
<FileSpreadsheet className="w-4 h-4" />
Export as Excel
{t('stats.exportAsExcel')}
</button>
</div>
)}
+22 -18
View File
@@ -1,10 +1,13 @@
import { useEffect, useMemo, useState } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Layers, Clock, Timer, Printer } from 'lucide-react';
import { api } from '../api/client';
import type { PrinterStatus } from '../api/client';
type TFunction = (key: string, options?: Record<string, unknown>) => string;
type OverlaySize = 'small' | 'medium' | 'large';
interface OverlayConfig {
@@ -49,7 +52,7 @@ function formatTime(seconds: number): string {
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
function formatETA(remainingMinutes: number): string {
function formatETA(remainingMinutes: number, t: TFunction): string {
const now = new Date();
const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
const today = new Date();
@@ -62,22 +65,22 @@ function formatETA(remainingMinutes: number): string {
if (etaDay.getTime() === today.getTime()) {
return timeStr;
} else if (etaDay.getTime() === today.getTime() + 86400000) {
return `Tomorrow ${timeStr}`;
return `${t('streamOverlay.tomorrow')} ${timeStr}`;
} else {
return eta.toLocaleDateString([], { weekday: 'short' }) + ' ' + timeStr;
}
}
function getStatusText(status: PrinterStatus): string {
function getStatusText(status: PrinterStatus, t: TFunction): string {
if (status.stg_cur_name) return status.stg_cur_name;
switch (status.state) {
case 'RUNNING': return 'Printing';
case 'PAUSE': return 'Paused';
case 'FINISH': return 'Finished';
case 'FAILED': return 'Failed';
case 'IDLE': return 'Idle';
default: return status.state || 'Unknown';
case 'RUNNING': return t('streamOverlay.status.printing');
case 'PAUSE': return t('streamOverlay.status.paused');
case 'FINISH': return t('streamOverlay.status.finished');
case 'FAILED': return t('streamOverlay.status.failed');
case 'IDLE': return t('streamOverlay.status.idle');
default: return status.state || t('streamOverlay.status.unknown');
}
}
@@ -120,6 +123,7 @@ function getSizeClasses(size: OverlaySize) {
export function StreamOverlayPage() {
const { printerId } = useParams<{ printerId: string }>();
const [searchParams] = useSearchParams();
const { t } = useTranslation();
const queryClient = useQueryClient();
const id = parseInt(printerId || '0', 10);
const [imageKey, setImageKey] = useState(Date.now());
@@ -172,11 +176,11 @@ export function StreamOverlayPage() {
// Update document title
useEffect(() => {
document.title = printer ? `${printer.name} - Stream Overlay` : 'Stream Overlay';
document.title = printer ? `${printer.name} - ${t('streamOverlay.title')}` : t('streamOverlay.title');
return () => {
document.title = 'Bambuddy';
};
}, [printer]);
}, [printer, t]);
// Refresh stream on error
const handleStreamError = () => {
@@ -188,7 +192,7 @@ export function StreamOverlayPage() {
if (!id) {
return (
<div className="min-h-screen bg-black flex items-center justify-center">
<p className="text-white">Invalid printer ID</p>
<p className="text-white">{t('streamOverlay.invalidPrinterId')}</p>
</div>
);
}
@@ -196,7 +200,7 @@ export function StreamOverlayPage() {
if (!status) {
return (
<div className="min-h-screen bg-black flex items-center justify-center">
<p className="text-gray-400">Loading...</p>
<p className="text-gray-400">{t('common.loading')}</p>
</div>
);
}
@@ -212,7 +216,7 @@ export function StreamOverlayPage() {
<img
key={imageKey}
src={streamUrl}
alt="Camera stream"
alt={t('streamOverlay.cameraStream')}
className="absolute inset-0 w-full h-full object-contain"
onError={handleStreamError}
/>
@@ -253,7 +257,7 @@ export function StreamOverlayPage() {
{/* Status text */}
{config.showStatus && (
<div className={`${sizes.text} text-white/70 mb-2`}>
{getStatusText(status)}
{getStatusText(status, t)}
</div>
)}
@@ -261,7 +265,7 @@ export function StreamOverlayPage() {
{config.showProgress && isPrinting && (
<div className="mb-3">
<div className={`flex items-center justify-between mb-1 ${sizes.text}`}>
<span className="text-white/70">Progress</span>
<span className="text-white/70">{t('streamOverlay.progress')}</span>
<span className="text-white font-bold">{Math.round(progress)}%</span>
</div>
<div className={`w-full bg-white/20 rounded-full ${sizes.progressHeight}`}>
@@ -301,7 +305,7 @@ export function StreamOverlayPage() {
<div className={`flex items-center ${sizes.gap} text-white/70`}>
<Clock className={sizes.icon} />
<span className={`${sizes.text} text-white`}>
ETA {formatETA(status.remaining_time)}
{t('streamOverlay.eta')} {formatETA(status.remaining_time, t)}
</span>
</div>
</>
@@ -312,7 +316,7 @@ export function StreamOverlayPage() {
{/* Idle state */}
{!isPrinting && (
<div className={`${sizes.text} text-white/70 py-2`}>
{status.connected ? 'Printer is idle' : 'Printer offline'}
{status.connected ? t('streamOverlay.printerIdle') : t('streamOverlay.printerOffline')}
</div>
)}
</div>
+55 -53
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Plus, Edit2, Trash2, Save, Loader2, Users as UsersIcon, Shield, ArrowLeft } from 'lucide-react';
import { api } from '../api/client';
import type { UserCreate, UserUpdate, UserResponse } from '../api/client';
@@ -17,6 +18,7 @@ interface FormData extends UserCreate {
export function UsersPage() {
const navigate = useNavigate();
const { t } = useTranslation();
const { user: currentUser, hasPermission } = useAuth();
const { showToast } = useToast();
const queryClient = useQueryClient();
@@ -70,7 +72,7 @@ export function UsersPage() {
queryClient.invalidateQueries({ queryKey: ['groups'] });
setShowCreateModal(false);
setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
showToast('User created successfully');
showToast(t('users.toast.created'));
},
onError: (error: Error) => {
showToast(error.message, 'error');
@@ -85,7 +87,7 @@ export function UsersPage() {
setShowEditModal(false);
setEditingUserId(null);
setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
showToast('User updated successfully');
showToast(t('users.toast.updated'));
},
onError: (error: Error) => {
showToast(error.message, 'error');
@@ -96,7 +98,7 @@ export function UsersPage() {
mutationFn: (id: number) => api.deleteUser(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
showToast('User deleted successfully');
showToast(t('users.toast.deleted'));
},
onError: (error: Error) => {
showToast(error.message, 'error');
@@ -105,15 +107,15 @@ export function UsersPage() {
const handleCreate = () => {
if (!formData.username || !formData.password) {
showToast('Please fill in all required fields', 'error');
showToast(t('users.toast.fillRequired'), 'error');
return;
}
if (formData.password !== formData.confirmPassword) {
showToast('Passwords do not match', 'error');
showToast(t('users.toast.passwordsDoNotMatch'), 'error');
return;
}
if (formData.password.length < 6) {
showToast('Password must be at least 6 characters', 'error');
showToast(t('users.toast.passwordTooShort'), 'error');
return;
}
createMutation.mutate({
@@ -128,11 +130,11 @@ export function UsersPage() {
// Validate password confirmation if a new password is being set
if (formData.password) {
if (formData.password !== formData.confirmPassword) {
showToast('Passwords do not match', 'error');
showToast(t('users.toast.passwordsDoNotMatch'), 'error');
return;
}
if (formData.password.length < 6) {
showToast('Password must be at least 6 characters', 'error');
showToast(t('users.toast.passwordTooShort'), 'error');
return;
}
}
@@ -187,7 +189,7 @@ export function UsersPage() {
<CardContent className="py-6">
<div className="flex items-center gap-3 text-red-400">
<Shield className="w-5 h-5" />
<p className="text-white">You do not have permission to access this page.</p>
<p className="text-white">{t('users.noPermission')}</p>
</div>
</CardContent>
</Card>
@@ -202,17 +204,17 @@ export function UsersPage() {
<button
onClick={() => navigate('/settings?tab=users')}
className="p-2 rounded-lg bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
title="Back to Settings"
title={t('users.backToSettings')}
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<UsersIcon className="w-6 h-6 text-bambu-green" />
User Management
{t('users.title')}
</h1>
<p className="text-sm text-bambu-gray mt-1">
Manage users and their access to your Bambuddy instance
{t('users.subtitle')}
</p>
</div>
</div>
@@ -223,7 +225,7 @@ export function UsersPage() {
}}
>
<Plus className="w-4 h-4" />
Create User
{t('users.createUser')}
</Button>
</div>
@@ -238,16 +240,16 @@ export function UsersPage() {
<thead>
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
Username
{t('users.table.username')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
Groups
{t('users.table.groups')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
Status
{t('users.table.status')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
Actions
{t('users.table.actions')}
</th>
</tr>
</thead>
@@ -261,7 +263,7 @@ export function UsersPage() {
<div className="flex flex-wrap gap-1">
{user.is_admin && (
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-500/20 text-purple-300">
Admin
{t('users.admin')}
</span>
)}
{user.groups?.map(group => (
@@ -281,7 +283,7 @@ export function UsersPage() {
</span>
))}
{(!user.groups || user.groups.length === 0) && !user.is_admin && (
<span className="text-bambu-gray">No groups</span>
<span className="text-bambu-gray">{t('users.noGroups')}</span>
)}
</div>
</td>
@@ -291,7 +293,7 @@ export function UsersPage() {
? 'bg-bambu-green/20 text-bambu-green'
: 'bg-red-500/20 text-red-400'
}`}>
{user.is_active ? 'Active' : 'Inactive'}
{user.is_active ? t('users.active') : t('users.inactive')}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
@@ -302,7 +304,7 @@ export function UsersPage() {
onClick={() => startEdit(user)}
>
<Edit2 className="w-4 h-4" />
Edit
{t('users.edit')}
</Button>
{user.id !== currentUser?.id && (
<Button
@@ -311,7 +313,7 @@ export function UsersPage() {
onClick={() => handleDelete(user.id)}
>
<Trash2 className="w-4 h-4" />
Delete
{t('users.delete')}
</Button>
)}
</div>
@@ -341,7 +343,7 @@ export function UsersPage() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<UsersIcon className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">Create User</h2>
<h2 className="text-lg font-semibold text-white">{t('users.modal.createUser')}</h2>
</div>
<Button
variant="ghost"
@@ -359,34 +361,34 @@ export function UsersPage() {
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-white mb-2">
Username
{t('users.form.username')}
</label>
<input
type="text"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder="Enter username"
placeholder={t('users.form.usernamePlaceholder')}
autoComplete="username"
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
Password
{t('users.form.password')}
</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder="Enter password"
placeholder={t('users.form.passwordPlaceholder')}
autoComplete="new-password"
minLength={6}
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
Confirm Password
{t('users.form.confirmPassword')}
</label>
<input
type="password"
@@ -397,17 +399,17 @@ export function UsersPage() {
? 'border-red-500'
: 'border-bambu-dark-tertiary'
}`}
placeholder="Confirm password"
placeholder={t('users.form.confirmPasswordPlaceholder')}
autoComplete="new-password"
minLength={6}
/>
{formData.confirmPassword && formData.password !== formData.confirmPassword && (
<p className="text-red-400 text-xs mt-1">Passwords do not match</p>
<p className="text-red-400 text-xs mt-1">{t('users.toast.passwordsDoNotMatch')}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
Groups
{t('users.form.groups')}
</label>
<div className="space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
{groups.map(group => (
@@ -423,12 +425,12 @@ export function UsersPage() {
/>
<span className="text-sm text-white">{group.name}</span>
{group.is_system && (
<span className="text-xs text-yellow-400">(System)</span>
<span className="text-xs text-yellow-400">({t('users.system')})</span>
)}
</label>
))}
{groups.length === 0 && (
<p className="text-sm text-bambu-gray">No groups available</p>
<p className="text-sm text-bambu-gray">{t('users.noGroupsAvailable')}</p>
)}
</div>
</div>
@@ -441,7 +443,7 @@ export function UsersPage() {
setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
}}
>
Cancel
{t('users.modal.cancel')}
</Button>
<Button
onClick={handleCreate}
@@ -450,12 +452,12 @@ export function UsersPage() {
{createMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Creating...
{t('users.modal.creating')}
</>
) : (
<>
<Plus className="w-4 h-4" />
Create User
{t('users.modal.createUser')}
</>
)}
</Button>
@@ -479,7 +481,7 @@ export function UsersPage() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Edit2 className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">Edit User</h2>
<h2 className="text-lg font-semibold text-white">{t('users.modal.editUser')}</h2>
</div>
<Button
variant="ghost"
@@ -494,27 +496,27 @@ export function UsersPage() {
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-white mb-2">
Username
{t('users.form.username')}
</label>
<input
type="text"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder="Enter username"
placeholder={t('users.form.usernamePlaceholder')}
autoComplete="username"
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
Password <span className="text-bambu-gray font-normal">(leave blank to keep current)</span>
{t('users.form.password')} <span className="text-bambu-gray font-normal">({t('users.form.leaveBlankToKeep')})</span>
</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value, confirmPassword: '' })}
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder="Enter new password"
placeholder={t('users.form.newPasswordPlaceholder')}
autoComplete="new-password"
minLength={6}
/>
@@ -522,7 +524,7 @@ export function UsersPage() {
{formData.password && (
<div>
<label className="block text-sm font-medium text-white mb-2">
Confirm Password
{t('users.form.confirmPassword')}
</label>
<input
type="password"
@@ -533,18 +535,18 @@ export function UsersPage() {
? 'border-red-500'
: 'border-bambu-dark-tertiary'
}`}
placeholder="Confirm new password"
placeholder={t('users.form.confirmNewPasswordPlaceholder')}
autoComplete="new-password"
minLength={6}
/>
{formData.confirmPassword && formData.password !== formData.confirmPassword && (
<p className="text-red-400 text-xs mt-1">Passwords do not match</p>
<p className="text-red-400 text-xs mt-1">{t('users.toast.passwordsDoNotMatch')}</p>
)}
</div>
)}
<div>
<label className="block text-sm font-medium text-white mb-2">
Groups
{t('users.form.groups')}
</label>
<div className="space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
{groups.map(group => (
@@ -560,12 +562,12 @@ export function UsersPage() {
/>
<span className="text-sm text-white">{group.name}</span>
{group.is_system && (
<span className="text-xs text-yellow-400">(System)</span>
<span className="text-xs text-yellow-400">({t('users.system')})</span>
)}
</label>
))}
{groups.length === 0 && (
<p className="text-sm text-bambu-gray">No groups available</p>
<p className="text-sm text-bambu-gray">{t('users.noGroupsAvailable')}</p>
)}
</div>
</div>
@@ -575,7 +577,7 @@ export function UsersPage() {
variant="secondary"
onClick={closeEditModal}
>
Cancel
{t('users.modal.cancel')}
</Button>
<Button
onClick={() => handleUpdate(editingUserId)}
@@ -584,12 +586,12 @@ export function UsersPage() {
{updateMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Saving...
{t('users.modal.saving')}
</>
) : (
<>
<Save className="w-4 h-4" />
Save Changes
{t('users.modal.saveChanges')}
</>
)}
</Button>
@@ -602,9 +604,9 @@ export function UsersPage() {
{/* Delete Confirmation Modal */}
{deleteUserId !== null && (
<ConfirmModal
title="Delete User"
message={`Are you sure you want to delete this user? This action cannot be undone.`}
confirmText="Delete User"
title={t('users.deleteModal.title')}
message={t('users.deleteModal.message')}
confirmText={t('users.deleteModal.confirm')}
variant="danger"
onConfirm={() => {
deleteMutation.mutate(deleteUserId);