mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
Merge branch '0.2.1b' into add-H2D-Pro-png
This commit is contained in:
@@ -28,7 +28,7 @@ const mockFiles = [
|
||||
{
|
||||
name: 'benchy.3mf',
|
||||
path: '/benchy.3mf',
|
||||
size: 1024000,
|
||||
size: 1048575,
|
||||
is_directory: false,
|
||||
mtime: '2024-01-15T10:00:00Z',
|
||||
},
|
||||
@@ -135,8 +135,8 @@ describe('FileManagerModal', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// 1024000 bytes = 1000 KB = ~1.0 MB
|
||||
expect(screen.getByText('1000 KB')).toBeInTheDocument();
|
||||
// 1024000 bytes = 1024.0 KB
|
||||
expect(screen.getByText('1024.0 KB')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,11 +77,11 @@ describe('PrinterQueueWidget', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows ASAP for unscheduled items', async () => {
|
||||
it('shows Waiting for unscheduled items', async () => {
|
||||
render(<PrinterQueueWidget printerId={1} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('ASAP')).toBeInTheDocument();
|
||||
expect(screen.getByText('Waiting')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
getDatePlaceholder,
|
||||
getTimePlaceholder,
|
||||
formatDateInput,
|
||||
formatTimeInput,
|
||||
parseDateInput,
|
||||
parseTimeInput,
|
||||
toDateTimeLocalValue,
|
||||
applyTimeFormat,
|
||||
parseUTCDate,
|
||||
formatDate,
|
||||
formatDateOnly,
|
||||
formatDateTime,
|
||||
formatTimeOnly,
|
||||
formatETA,
|
||||
formatDuration,
|
||||
formatRelativeTime,
|
||||
} from '../../utils/date';
|
||||
|
||||
describe('getDatePlaceholder', () => {
|
||||
it('returns MM/DD/YYYY for us format', () => {
|
||||
expect(getDatePlaceholder('us')).toBe('MM/DD/YYYY');
|
||||
});
|
||||
|
||||
it('returns DD/MM/YYYY for eu format', () => {
|
||||
expect(getDatePlaceholder('eu')).toBe('DD/MM/YYYY');
|
||||
});
|
||||
|
||||
it('returns YYYY-MM-DD for iso format', () => {
|
||||
expect(getDatePlaceholder('iso')).toBe('YYYY-MM-DD');
|
||||
});
|
||||
|
||||
it('returns a placeholder for system format', () => {
|
||||
const result = getDatePlaceholder('system');
|
||||
expect(['MM/DD/YYYY', 'DD/MM/YYYY', 'YYYY-MM-DD']).toContain(result);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimePlaceholder', () => {
|
||||
it('returns HH:MM AM/PM for 12h format', () => {
|
||||
expect(getTimePlaceholder('12h')).toBe('HH:MM AM/PM');
|
||||
});
|
||||
|
||||
it('returns HH:MM for 24h format', () => {
|
||||
expect(getTimePlaceholder('24h')).toBe('HH:MM');
|
||||
});
|
||||
|
||||
it('returns a placeholder for system format', () => {
|
||||
const result = getTimePlaceholder('system');
|
||||
expect(['HH:MM AM/PM', 'HH:MM']).toContain(result);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDateInput', () => {
|
||||
const date = new Date(2025, 5, 15); // June 15, 2025
|
||||
|
||||
it('formats as MM/DD/YYYY for us format', () => {
|
||||
expect(formatDateInput(date, 'us')).toBe('06/15/2025');
|
||||
});
|
||||
|
||||
it('formats as DD/MM/YYYY for eu format', () => {
|
||||
expect(formatDateInput(date, 'eu')).toBe('15/06/2025');
|
||||
});
|
||||
|
||||
it('formats as YYYY-MM-DD for iso format', () => {
|
||||
expect(formatDateInput(date, 'iso')).toBe('2025-06-15');
|
||||
});
|
||||
|
||||
it('uses toLocaleDateString for system format', () => {
|
||||
const result = formatDateInput(date, 'system');
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTimeInput', () => {
|
||||
it('formats as 12h with AM', () => {
|
||||
const date = new Date(2025, 0, 1, 9, 30);
|
||||
expect(formatTimeInput(date, '12h')).toBe('9:30 AM');
|
||||
});
|
||||
|
||||
it('formats as 12h with PM', () => {
|
||||
const date = new Date(2025, 0, 1, 14, 45);
|
||||
expect(formatTimeInput(date, '12h')).toBe('2:45 PM');
|
||||
});
|
||||
|
||||
it('formats 12:00 as 12:00 PM', () => {
|
||||
const date = new Date(2025, 0, 1, 12, 0);
|
||||
expect(formatTimeInput(date, '12h')).toBe('12:00 PM');
|
||||
});
|
||||
|
||||
it('formats 00:00 as 12:00 AM', () => {
|
||||
const date = new Date(2025, 0, 1, 0, 0);
|
||||
expect(formatTimeInput(date, '12h')).toBe('12:00 AM');
|
||||
});
|
||||
|
||||
it('formats as 24h', () => {
|
||||
const date = new Date(2025, 0, 1, 14, 30);
|
||||
expect(formatTimeInput(date, '24h')).toBe('14:30');
|
||||
});
|
||||
|
||||
it('pads hours in 24h format', () => {
|
||||
const date = new Date(2025, 0, 1, 9, 5);
|
||||
expect(formatTimeInput(date, '24h')).toBe('09:05');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDateInput', () => {
|
||||
it('parses us format MM/DD/YYYY', () => {
|
||||
const result = parseDateInput('06/15/2025', 'us');
|
||||
expect(result?.getFullYear()).toBe(2025);
|
||||
expect(result?.getMonth()).toBe(5); // June
|
||||
expect(result?.getDate()).toBe(15);
|
||||
});
|
||||
|
||||
it('parses eu format DD/MM/YYYY', () => {
|
||||
const result = parseDateInput('15/06/2025', 'eu');
|
||||
expect(result?.getFullYear()).toBe(2025);
|
||||
expect(result?.getMonth()).toBe(5);
|
||||
expect(result?.getDate()).toBe(15);
|
||||
});
|
||||
|
||||
it('parses iso format YYYY-MM-DD', () => {
|
||||
const result = parseDateInput('2025-06-15', 'iso');
|
||||
expect(result?.getFullYear()).toBe(2025);
|
||||
expect(result?.getMonth()).toBe(5);
|
||||
expect(result?.getDate()).toBe(15);
|
||||
});
|
||||
|
||||
it('accepts different separators', () => {
|
||||
expect(parseDateInput('06-15-2025', 'us')?.getDate()).toBe(15);
|
||||
expect(parseDateInput('15.06.2025', 'eu')?.getDate()).toBe(15);
|
||||
expect(parseDateInput('2025/06/15', 'iso')?.getDate()).toBe(15);
|
||||
});
|
||||
|
||||
it('returns null for invalid input', () => {
|
||||
expect(parseDateInput('', 'us')).toBeNull();
|
||||
expect(parseDateInput('invalid', 'us')).toBeNull();
|
||||
expect(parseDateInput('13/32/2025', 'us')).toBeNull(); // invalid month
|
||||
expect(parseDateInput('01/01/1800', 'us')).toBeNull(); // year out of range
|
||||
});
|
||||
|
||||
it('returns null for invalid month', () => {
|
||||
expect(parseDateInput('13/01/2025', 'us')).toBeNull();
|
||||
expect(parseDateInput('00/01/2025', 'us')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for invalid day', () => {
|
||||
expect(parseDateInput('01/32/2025', 'us')).toBeNull();
|
||||
expect(parseDateInput('01/00/2025', 'us')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTimeInput', () => {
|
||||
it('parses 24h format', () => {
|
||||
expect(parseTimeInput('14:30')).toEqual({ hours: 14, minutes: 30 });
|
||||
expect(parseTimeInput('09:05')).toEqual({ hours: 9, minutes: 5 });
|
||||
expect(parseTimeInput('0:00')).toEqual({ hours: 0, minutes: 0 });
|
||||
});
|
||||
|
||||
it('parses 12h format with AM', () => {
|
||||
expect(parseTimeInput('9:30 AM')).toEqual({ hours: 9, minutes: 30 });
|
||||
expect(parseTimeInput('12:00 AM')).toEqual({ hours: 0, minutes: 0 });
|
||||
});
|
||||
|
||||
it('parses 12h format with PM', () => {
|
||||
expect(parseTimeInput('2:45 PM')).toEqual({ hours: 14, minutes: 45 });
|
||||
expect(parseTimeInput('12:00 PM')).toEqual({ hours: 12, minutes: 0 });
|
||||
});
|
||||
|
||||
it('is case insensitive for AM/PM', () => {
|
||||
expect(parseTimeInput('9:30 am')).toEqual({ hours: 9, minutes: 30 });
|
||||
expect(parseTimeInput('2:45 pm')).toEqual({ hours: 14, minutes: 45 });
|
||||
});
|
||||
|
||||
it('returns null for invalid input', () => {
|
||||
expect(parseTimeInput('')).toBeNull();
|
||||
expect(parseTimeInput('invalid')).toBeNull();
|
||||
expect(parseTimeInput('25:00')).toBeNull();
|
||||
expect(parseTimeInput('12:60')).toBeNull();
|
||||
expect(parseTimeInput('-1:00')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toDateTimeLocalValue', () => {
|
||||
it('formats date to datetime-local value', () => {
|
||||
const date = new Date(2025, 5, 15, 14, 30);
|
||||
expect(toDateTimeLocalValue(date)).toBe('2025-06-15T14:30');
|
||||
});
|
||||
|
||||
it('pads single digit values', () => {
|
||||
const date = new Date(2025, 0, 5, 9, 5);
|
||||
expect(toDateTimeLocalValue(date)).toBe('2025-01-05T09:05');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTimeFormat', () => {
|
||||
it('sets hour12 true for 12h format', () => {
|
||||
const options: Intl.DateTimeFormatOptions = {};
|
||||
applyTimeFormat(options, '12h');
|
||||
expect(options.hour12).toBe(true);
|
||||
});
|
||||
|
||||
it('sets hour12 false for 24h format', () => {
|
||||
const options: Intl.DateTimeFormatOptions = {};
|
||||
applyTimeFormat(options, '24h');
|
||||
expect(options.hour12).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves hour12 undefined for system format', () => {
|
||||
const options: Intl.DateTimeFormatOptions = {};
|
||||
applyTimeFormat(options, 'system');
|
||||
expect(options.hour12).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the modified options object', () => {
|
||||
const options: Intl.DateTimeFormatOptions = { hour: '2-digit' };
|
||||
const result = applyTimeFormat(options, '12h');
|
||||
expect(result).toBe(options);
|
||||
expect(result.hour).toBe('2-digit');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseUTCDate', () => {
|
||||
it('returns null for null/undefined input', () => {
|
||||
expect(parseUTCDate(null)).toBeNull();
|
||||
expect(parseUTCDate(undefined)).toBeNull();
|
||||
expect(parseUTCDate('')).toBeNull();
|
||||
});
|
||||
|
||||
it('parses ISO string with Z suffix as-is', () => {
|
||||
const result = parseUTCDate('2025-06-15T12:00:00Z');
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
expect(result?.getUTCHours()).toBe(12);
|
||||
});
|
||||
|
||||
it('parses ISO string with timezone offset as-is', () => {
|
||||
const result = parseUTCDate('2025-06-15T12:00:00+05:00');
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('appends Z to strings without timezone indicator', () => {
|
||||
const result = parseUTCDate('2025-06-15T12:00:00');
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
expect(result?.getUTCHours()).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('returns empty string for null input', () => {
|
||||
expect(formatDate(null)).toBe('');
|
||||
expect(formatDate(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('formats a valid date string', () => {
|
||||
const result = formatDate('2025-06-15T12:00:00Z');
|
||||
expect(result).toBeTruthy();
|
||||
expect(result).toContain('2025');
|
||||
});
|
||||
|
||||
it('accepts custom options', () => {
|
||||
const result = formatDate('2025-06-15T12:00:00Z', { year: 'numeric' });
|
||||
expect(result).toContain('2025');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDateOnly', () => {
|
||||
it('returns empty string for null input', () => {
|
||||
expect(formatDateOnly(null)).toBe('');
|
||||
});
|
||||
|
||||
it('formats date without time', () => {
|
||||
const result = formatDateOnly('2025-06-15T12:00:00Z');
|
||||
expect(result).toBeTruthy();
|
||||
expect(result).toContain('2025');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDateTime', () => {
|
||||
it('returns empty string for null input', () => {
|
||||
expect(formatDateTime(null)).toBe('');
|
||||
});
|
||||
|
||||
it('formats with 12h time format', () => {
|
||||
const result = formatDateTime('2025-06-15T14:00:00Z', '12h');
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('formats with 24h time format', () => {
|
||||
const result = formatDateTime('2025-06-15T14:00:00Z', '24h');
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTimeOnly', () => {
|
||||
it('formats time with 12h format', () => {
|
||||
const date = new Date(2025, 5, 15, 14, 30);
|
||||
const result = formatTimeOnly(date, '12h');
|
||||
expect(result).toMatch(/2:30|02:30/);
|
||||
expect(result.toUpperCase()).toContain('PM');
|
||||
});
|
||||
|
||||
it('formats time with 24h format', () => {
|
||||
const date = new Date(2025, 5, 15, 14, 30);
|
||||
const result = formatTimeOnly(date, '24h');
|
||||
expect(result).toContain('14:30');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatETA', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-06-15T12:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns time only for same day', () => {
|
||||
const result = formatETA(60); // 1 hour from now
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('includes "Tomorrow" for next day', () => {
|
||||
const result = formatETA(60 * 24); // 24 hours from now
|
||||
expect(result).toContain('Tomorrow');
|
||||
});
|
||||
|
||||
it('uses translation function for tomorrow', () => {
|
||||
const t = vi.fn((key: string) => (key === 'common.tomorrow' ? 'Demain' : key));
|
||||
const result = formatETA(60 * 24, 'system', t);
|
||||
expect(result).toContain('Demain');
|
||||
});
|
||||
|
||||
it('shows weekday for dates beyond tomorrow', () => {
|
||||
const result = formatETA(60 * 48); // 48 hours from now
|
||||
expect(result).not.toContain('Tomorrow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDuration', () => {
|
||||
it('returns "--" for null/undefined', () => {
|
||||
expect(formatDuration(null)).toBe('--');
|
||||
expect(formatDuration(undefined)).toBe('--');
|
||||
});
|
||||
|
||||
it('returns "--" for negative values', () => {
|
||||
expect(formatDuration(-1)).toBe('--');
|
||||
});
|
||||
|
||||
it('formats minutes only when under 1 hour', () => {
|
||||
expect(formatDuration(0)).toBe('0m');
|
||||
expect(formatDuration(60)).toBe('1m');
|
||||
expect(formatDuration(2700)).toBe('45m');
|
||||
});
|
||||
|
||||
it('formats hours and minutes', () => {
|
||||
expect(formatDuration(3600)).toBe('1h 0m');
|
||||
expect(formatDuration(5400)).toBe('1h 30m');
|
||||
expect(formatDuration(9000)).toBe('2h 30m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRelativeTime', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-06-15T12:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns "-" for null input', () => {
|
||||
expect(formatRelativeTime(null)).toBe('-');
|
||||
});
|
||||
|
||||
it('returns translated unknown for null with translation', () => {
|
||||
const t = vi.fn((key: string) => (key === 'time.unknown' ? 'Unknown' : key));
|
||||
expect(formatRelativeTime(null, 'system', t)).toBe('Unknown');
|
||||
});
|
||||
|
||||
it('returns "Just now" for times less than 1 minute ago', () => {
|
||||
expect(formatRelativeTime('2025-06-15T11:59:30Z')).toBe('Just now');
|
||||
});
|
||||
|
||||
it('returns "Now" for times less than 1 minute in future', () => {
|
||||
expect(formatRelativeTime('2025-06-15T12:00:30Z')).toBe('Now');
|
||||
});
|
||||
|
||||
it('returns minutes ago for times under 1 hour ago', () => {
|
||||
expect(formatRelativeTime('2025-06-15T11:55:00Z')).toBe('5m ago');
|
||||
expect(formatRelativeTime('2025-06-15T11:30:00Z')).toBe('30m ago');
|
||||
});
|
||||
|
||||
it('returns "in Xm" for times under 1 hour in future', () => {
|
||||
expect(formatRelativeTime('2025-06-15T12:05:00Z')).toBe('in 5m');
|
||||
expect(formatRelativeTime('2025-06-15T12:30:00Z')).toBe('in 30m');
|
||||
});
|
||||
|
||||
it('returns hours ago for times under 1 day ago', () => {
|
||||
expect(formatRelativeTime('2025-06-15T10:00:00Z')).toBe('2h ago');
|
||||
expect(formatRelativeTime('2025-06-15T06:00:00Z')).toBe('6h ago');
|
||||
});
|
||||
|
||||
it('returns "in Xh" for times under 1 day in future', () => {
|
||||
expect(formatRelativeTime('2025-06-15T14:00:00Z')).toBe('in 2h');
|
||||
expect(formatRelativeTime('2025-06-15T18:00:00Z')).toBe('in 6h');
|
||||
});
|
||||
|
||||
it('returns days ago for times under 7 days ago', () => {
|
||||
expect(formatRelativeTime('2025-06-14T12:00:00Z')).toBe('1d ago');
|
||||
expect(formatRelativeTime('2025-06-10T12:00:00Z')).toBe('5d ago');
|
||||
});
|
||||
|
||||
it('returns "in Xd" for times under 7 days in future', () => {
|
||||
expect(formatRelativeTime('2025-06-16T12:00:00Z')).toBe('in 1d');
|
||||
expect(formatRelativeTime('2025-06-20T12:00:00Z')).toBe('in 5d');
|
||||
});
|
||||
|
||||
it('returns formatted date for times older than 7 days', () => {
|
||||
const result = formatRelativeTime('2025-06-01T12:00:00Z');
|
||||
expect(result).toContain('2025');
|
||||
});
|
||||
|
||||
it('uses translation function when provided', () => {
|
||||
const t = vi.fn((key: string, options?: Record<string, unknown>) => {
|
||||
if (key === 'time.minsAgo') return `${options?.count} minutes ago`;
|
||||
if (key === 'time.inMins') return `in ${options?.count} minutes`;
|
||||
return key;
|
||||
});
|
||||
|
||||
expect(formatRelativeTime('2025-06-15T11:55:00Z', 'system', t)).toBe('5 minutes ago');
|
||||
expect(formatRelativeTime('2025-06-15T12:05:00Z', 'system', t)).toBe('in 5 minutes');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatFileSize } from '../../utils/file';
|
||||
|
||||
describe('formatFileSize', () => {
|
||||
it('returns "0 B" for 0 bytes', () => {
|
||||
expect(formatFileSize(0)).toBe('0 B');
|
||||
});
|
||||
|
||||
it('returns bytes without decimals for values under 1 KB', () => {
|
||||
expect(formatFileSize(1)).toBe('1 B');
|
||||
expect(formatFileSize(500)).toBe('500 B');
|
||||
expect(formatFileSize(1023)).toBe('1023 B');
|
||||
});
|
||||
|
||||
it('returns KB with 1 decimal for values under 1 MB', () => {
|
||||
expect(formatFileSize(1024)).toBe('1.0 KB');
|
||||
expect(formatFileSize(1536)).toBe('1.5 KB');
|
||||
expect(formatFileSize(10240)).toBe('10.0 KB');
|
||||
});
|
||||
|
||||
it('returns MB with 1 decimal for values under 1 GB', () => {
|
||||
expect(formatFileSize(1048576)).toBe('1.0 MB');
|
||||
expect(formatFileSize(1572864)).toBe('1.5 MB');
|
||||
expect(formatFileSize(10485760)).toBe('10.0 MB');
|
||||
});
|
||||
|
||||
it('returns GB with 1 decimal for values under 1 TB', () => {
|
||||
expect(formatFileSize(1073741824)).toBe('1.0 GB');
|
||||
expect(formatFileSize(1610612736)).toBe('1.5 GB');
|
||||
});
|
||||
|
||||
it('returns TB with 1 decimal for very large values', () => {
|
||||
expect(formatFileSize(1099511627776)).toBe('1.0 TB');
|
||||
expect(formatFileSize(1649267441664)).toBe('1.5 TB');
|
||||
});
|
||||
|
||||
it('handles edge cases at unit boundaries', () => {
|
||||
expect(formatFileSize(1023)).toBe('1023 B');
|
||||
expect(formatFileSize(1024)).toBe('1.0 KB');
|
||||
expect(formatFileSize(1048575)).toBe('1024.0 KB');
|
||||
expect(formatFileSize(1048576)).toBe('1.0 MB');
|
||||
});
|
||||
});
|
||||
@@ -2786,6 +2786,10 @@ export const api = {
|
||||
},
|
||||
getSource3mfForSlicer: (archiveId: number, filename: string) =>
|
||||
`${API_BASE}/archives/${archiveId}/source/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
|
||||
createSourceSlicerToken: (archiveId: number) =>
|
||||
request<{ token: string }>(`/archives/${archiveId}/source-slicer-token`, { method: 'POST' }),
|
||||
getSourceSlicerDownloadUrl: (archiveId: number, token: string, filename: string) =>
|
||||
`${API_BASE}/archives/${archiveId}/source-dl/${token}/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
|
||||
uploadSource3mf: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
@@ -2908,6 +2912,10 @@ export const api = {
|
||||
`${API_BASE}/archives/${archiveId}/project-image/${encodeURIComponent(imagePath)}`,
|
||||
getArchiveForSlicer: (id: number, filename: string) =>
|
||||
`${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
|
||||
createArchiveSlicerToken: (archiveId: number) =>
|
||||
request<{ token: string }>(`/archives/${archiveId}/slicer-token`, { method: 'POST' }),
|
||||
getArchiveSlicerDownloadUrl: (archiveId: number, token: string, filename: string) =>
|
||||
`${API_BASE}/archives/${archiveId}/dl/${token}/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
|
||||
getArchivePlates: (archiveId: number) =>
|
||||
request<ArchivePlatesResponse>(`/archives/${archiveId}/plates`),
|
||||
getArchiveFilamentRequirements: (archiveId: number, plateId?: number) =>
|
||||
@@ -3938,6 +3946,10 @@ export const api = {
|
||||
deleteLibraryFile: (id: number) =>
|
||||
request<{ status: string; message: string }>(`/library/files/${id}`, { method: 'DELETE' }),
|
||||
getLibraryFileDownloadUrl: (id: number) => `${API_BASE}/library/files/${id}/download`,
|
||||
createLibrarySlicerToken: (fileId: number) =>
|
||||
request<{ token: string }>(`/library/files/${fileId}/slicer-token`, { method: 'POST' }),
|
||||
getLibrarySlicerDownloadUrl: (fileId: number, token: string, filename: string) =>
|
||||
`${API_BASE}/library/files/${fileId}/dl/${token}/${encodeURIComponent(filename)}`,
|
||||
downloadLibraryFile: async (id: number, filename?: string): Promise<void> => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (authToken) {
|
||||
|
||||
@@ -177,7 +177,7 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
|
||||
<p className="text-xs text-bambu-gray">
|
||||
{spool.color_name || ''}
|
||||
{spool.label_weight ? ` - ${spool.label_weight}g` : ''}
|
||||
{spool.weight_used > 0 ? ` (${Math.round(spool.weight_used)}g used)` : ''}
|
||||
{spool.label_weight ? ` (${Math.max(0, Math.round(spool.label_weight - spool.weight_used))}g ${t('ams.remainingUnit')})` : ''}
|
||||
</p>
|
||||
</div>
|
||||
{selectedSpoolId === spool.id && (
|
||||
|
||||
@@ -24,7 +24,7 @@ interface SpoolmanConfig {
|
||||
interface InventoryConfig {
|
||||
onAssignSpool?: () => void;
|
||||
onUnassignSpool?: () => void;
|
||||
assignedSpool?: { id: number; material: string; brand: string | null; color_name: string | null } | null;
|
||||
assignedSpool?: { id: number; material: string; brand: string | null; color_name: string | null; remainingWeightGrams?: number | null } | null;
|
||||
}
|
||||
|
||||
interface ConfigureSlotConfig {
|
||||
@@ -148,6 +148,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
|
||||
};
|
||||
|
||||
const colorHex = data.colorHex ? `#${data.colorHex.replace('#', '')}` : null;
|
||||
const assignedRemainingWeight = inventory?.assignedSpool?.remainingWeightGrams ?? null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -238,7 +239,10 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
|
||||
{t('ams.fill')}
|
||||
</span>
|
||||
<span className="text-xs text-white font-semibold flex items-center gap-1">
|
||||
{data.fillLevel !== null ? `${data.fillLevel}%` : '—'}
|
||||
<span>{data.fillLevel !== null ? `${data.fillLevel}%` : '—'}</span>
|
||||
{assignedRemainingWeight !== null && data.fillLevel !== null && (
|
||||
<span className="text-[9px] text-bambu-gray font-normal">• {assignedRemainingWeight}g</span>
|
||||
)}
|
||||
{data.fillSource === 'spoolman' && data.fillLevel !== null && (
|
||||
<span className="text-[9px] text-bambu-gray font-normal">{t('spoolman.fillSourceLabel')}</span>
|
||||
)}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { ModelViewer } from './ModelViewer';
|
||||
import { GcodeViewer } from './GcodeViewer';
|
||||
import type { PlateMetadata } from '../types/plates';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { formatFileSize } from '../utils/file';
|
||||
|
||||
interface FileManagerModalProps {
|
||||
printerId: number;
|
||||
@@ -235,14 +236,6 @@ function PrinterFileViewerModal({ printerId, filePath, filename, onClose }: Prin
|
||||
);
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function formatStorageSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 GB';
|
||||
const gb = bytes / (1024 * 1024 * 1024);
|
||||
@@ -253,6 +246,7 @@ function formatStorageSize(bytes: number): string {
|
||||
return `${mb.toFixed(0)} MB`;
|
||||
}
|
||||
|
||||
|
||||
function getFileIcon(filename: string, isDirectory: boolean) {
|
||||
if (isDirectory) return Folder;
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import { Button } from './Button';
|
||||
import { Toggle } from './Toggle';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { formatRelativeTime } from '../utils/date';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: string | null;
|
||||
@@ -71,29 +72,6 @@ function formatDateTime(dateStr: string | null): string {
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string | null): string {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = date.getTime() - now.getTime();
|
||||
const diffMins = Math.round(diffMs / 60000);
|
||||
|
||||
if (diffMins < 0) {
|
||||
const absMins = Math.abs(diffMins);
|
||||
if (absMins < 60) return `${absMins}m ago`;
|
||||
const hours = Math.floor(absMins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
} else {
|
||||
if (diffMins < 60) return `in ${diffMins}m`;
|
||||
const hours = Math.floor(diffMins / 60);
|
||||
if (hours < 24) return `in ${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `in ${days}d`;
|
||||
}
|
||||
}
|
||||
|
||||
export function GitHubBackupSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
@@ -563,7 +541,7 @@ export function GitHubBackupSettings() {
|
||||
<div className="flex items-center gap-2 text-bambu-gray">
|
||||
{status.last_backup_at ? (
|
||||
<>
|
||||
<span>Last backup: {formatRelativeTime(status.last_backup_at)}</span>
|
||||
<span>Last backup: {formatRelativeTime(status.last_backup_at, 'system', t)}</span>
|
||||
<StatusBadge status={status.last_backup_status} />
|
||||
</>
|
||||
) : (
|
||||
@@ -573,7 +551,7 @@ export function GitHubBackupSettings() {
|
||||
{status.next_scheduled_run && (
|
||||
<span className="text-bambu-gray">
|
||||
<Clock className="w-3 h-3 inline mr-1" />
|
||||
Next: {formatRelativeTime(status.next_scheduled_run)}
|
||||
Next: {formatRelativeTime(status.next_scheduled_run, 'system', t)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -263,17 +263,29 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
|
||||
|
||||
const canOpenInSlicer = isLibrary ? (fileType || '').toLowerCase() === '3mf' : true;
|
||||
|
||||
const handleOpenInSlicer = () => {
|
||||
const handleOpenInSlicer = async () => {
|
||||
if (!canOpenInSlicer) return;
|
||||
// URL must include .3mf filename for Bambu Studio to recognize the format
|
||||
const filename = title || 'model';
|
||||
if (isLibrary) {
|
||||
const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
|
||||
openInSlicer(downloadUrl, preferredSlicer);
|
||||
return;
|
||||
try {
|
||||
if (isLibrary) {
|
||||
const { token } = await api.createLibrarySlicerToken(libraryFileId!);
|
||||
const path = api.getLibrarySlicerDownloadUrl(libraryFileId!, token, filename);
|
||||
openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
|
||||
} else {
|
||||
const { token } = await api.createArchiveSlicerToken(archiveId!);
|
||||
const path = api.getArchiveSlicerDownloadUrl(archiveId!, token, filename);
|
||||
openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
|
||||
}
|
||||
} catch {
|
||||
// Fallback to direct URL (works when auth is disabled)
|
||||
if (isLibrary) {
|
||||
const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
|
||||
openInSlicer(downloadUrl, preferredSlicer);
|
||||
} else {
|
||||
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
|
||||
openInSlicer(downloadUrl, preferredSlicer);
|
||||
}
|
||||
}
|
||||
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
|
||||
openInSlicer(downloadUrl, preferredSlicer);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,12 +8,7 @@ import { Card, CardContent, CardHeader } from './Card';
|
||||
import { Button } from './Button';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
import { formatFileSize } from '../utils/file';
|
||||
|
||||
function formatTimeAgo(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { api } from '../api/client';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { parseUTCDate } from '../utils/date';
|
||||
import { formatRelativeTime } from '../utils/date';
|
||||
|
||||
interface PrinterQueueWidgetProps {
|
||||
printerId: number;
|
||||
@@ -13,20 +13,6 @@ interface PrinterQueueWidgetProps {
|
||||
plateCleared?: boolean;
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateString: string | null): string {
|
||||
if (!dateString) return 'ASAP';
|
||||
const date = parseUTCDate(dateString);
|
||||
if (!date) return 'ASAP';
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
|
||||
if (diff < 0) return 'Now';
|
||||
if (diff < 60000) return 'In <1 min';
|
||||
if (diff < 3600000) return `In ${Math.round(diff / 60000)} min`;
|
||||
if (diff < 86400000) return `In ${Math.round(diff / 3600000)}h`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
export function PrinterQueueWidget({ printerId, printerState, plateCleared }: PrinterQueueWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -117,7 +103,7 @@ export function PrinterQueueWidget({ printerId, printerState, plateCleared }: Pr
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className="text-xs text-bambu-gray flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatRelativeTime(nextItem?.scheduled_time || null)}
|
||||
{nextItem?.scheduled_time ? formatRelativeTime(nextItem.scheduled_time, 'system', t) : t('time.waiting')}
|
||||
</span>
|
||||
{totalPending > 1 && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-yellow-400/20 text-yellow-400 rounded">
|
||||
|
||||
@@ -320,7 +320,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
if (!validation.isValid) {
|
||||
setErrors(validation.errors);
|
||||
// Switch to filament tab if there are errors there
|
||||
if (validation.errors.slicer_filament || validation.errors.material) {
|
||||
if (validation.errors.slicer_filament || validation.errors.material || validation.errors.brand || validation.errors.subtype) {
|
||||
setActiveTab('filament');
|
||||
}
|
||||
return;
|
||||
@@ -438,6 +438,12 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
{errors.material && (
|
||||
<p className="mt-1 text-xs text-red-400">{errors.material}</p>
|
||||
)}
|
||||
{errors.brand && (
|
||||
<p className="mt-1 text-xs text-red-400">{errors.brand}</p>
|
||||
)}
|
||||
{errors.subtype && (
|
||||
<p className="mt-1 text-xs text-red-400">{errors.subtype}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Color Section */}
|
||||
|
||||
@@ -161,7 +161,7 @@ export function FilamentSection({
|
||||
|
||||
{/* Brand (dropdown with search) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.brand')}</label>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.brand')} *</label>
|
||||
<div className="relative" ref={brandRef}>
|
||||
<input
|
||||
type="text"
|
||||
@@ -221,7 +221,7 @@ export function FilamentSection({
|
||||
|
||||
{/* Variant / Subtype */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.subtype')}</label>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.subtype')} *</label>
|
||||
<div className="relative" ref={subtypeRef}>
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -117,6 +117,14 @@ export function validateForm(formData: SpoolFormData): ValidationResult {
|
||||
errors.material = 'Material is required';
|
||||
}
|
||||
|
||||
if (!formData.brand) {
|
||||
errors.brand = 'Brand is required';
|
||||
}
|
||||
|
||||
if (!formData.subtype) {
|
||||
errors.subtype = 'Subtype is required';
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: Object.keys(errors).length === 0,
|
||||
errors,
|
||||
|
||||
@@ -8,6 +8,7 @@ import de from './locales/de';
|
||||
import fr from './locales/fr';
|
||||
import ja from './locales/ja';
|
||||
import it from './locales/it';
|
||||
import ptBR from './locales/pt-BR';
|
||||
|
||||
const resources = {
|
||||
en: { translation: en },
|
||||
@@ -15,6 +16,7 @@ const resources = {
|
||||
fr: { translation: fr },
|
||||
ja: { translation: ja },
|
||||
it: { translation: it },
|
||||
'pt-BR': { translation: ptBR },
|
||||
};
|
||||
|
||||
i18n
|
||||
@@ -23,7 +25,7 @@ i18n
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: ['en', 'de', 'fr', 'ja', 'it'],
|
||||
supportedLngs: ['en', 'de', 'fr', 'ja', 'it', 'pt-BR'],
|
||||
|
||||
detection: {
|
||||
// Order of detection methods
|
||||
@@ -52,4 +54,5 @@ export const availableLanguages = [
|
||||
{ code: 'fr', name: 'French', nativeName: 'Français' },
|
||||
{ code: 'ja', name: 'Japanese', nativeName: '日本語' },
|
||||
{ code: 'it', name: 'Italian', nativeName: 'Italiano' },
|
||||
{ code: 'pt-BR', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)' },
|
||||
];
|
||||
|
||||
@@ -2682,6 +2682,8 @@ export default {
|
||||
kFactor: 'K-Faktor',
|
||||
fill: 'Füllstand',
|
||||
configure: 'Konfigurieren',
|
||||
used: 'verwendet',
|
||||
remainingUnit: 'verbleibend',
|
||||
},
|
||||
|
||||
// Print modal
|
||||
@@ -3433,4 +3435,18 @@ export default {
|
||||
|
||||
// Spoolman Settings
|
||||
spoolmanSettings: {},
|
||||
|
||||
// Time
|
||||
time: {
|
||||
unknown: '-',
|
||||
waiting: 'Wartend',
|
||||
justNow: 'Gerade eben',
|
||||
now: 'Jetzt',
|
||||
minsAgo: 'vor {{count}}m',
|
||||
inMins: 'in {{count}}m',
|
||||
hoursAgo: 'vor {{count}}h',
|
||||
inHours: 'in {{count}}h',
|
||||
daysAgo: 'vor {{count}}d',
|
||||
inDays: 'in {{count}}d',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2686,6 +2686,8 @@ export default {
|
||||
kFactor: 'K Factor',
|
||||
fill: 'Fill',
|
||||
configure: 'Configure',
|
||||
used: 'used',
|
||||
remainingUnit: 'remaining',
|
||||
},
|
||||
|
||||
// Print modal
|
||||
@@ -3438,4 +3440,18 @@ export default {
|
||||
|
||||
// Spoolman Settings
|
||||
spoolmanSettings: {},
|
||||
|
||||
// Time
|
||||
time: {
|
||||
unknown: '-',
|
||||
waiting: 'Waiting',
|
||||
justNow: 'Just now',
|
||||
now: 'Now',
|
||||
minsAgo: '{{count}}m ago',
|
||||
inMins: 'in {{count}}m',
|
||||
hoursAgo: '{{count}}h ago',
|
||||
inHours: 'in {{count}}h',
|
||||
daysAgo: '{{count}}d ago',
|
||||
inDays: 'in {{count}}d',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2674,6 +2674,8 @@ export default {
|
||||
kFactor: 'Facteur K',
|
||||
fill: 'Remplir',
|
||||
configure: 'Configurer',
|
||||
used: 'utilisé',
|
||||
remainingUnit: 'restant',
|
||||
},
|
||||
|
||||
// Print modal
|
||||
@@ -3426,4 +3428,18 @@ export default {
|
||||
|
||||
// Spoolman Settings
|
||||
spoolmanSettings: {},
|
||||
|
||||
// Time
|
||||
time: {
|
||||
unknown: '-',
|
||||
waiting: 'En attente',
|
||||
justNow: 'À l\'instant',
|
||||
now: 'Maintenant',
|
||||
minsAgo: 'il y a {{count}}m',
|
||||
inMins: 'dans {{count}}m',
|
||||
hoursAgo: 'il y a {{count}}h',
|
||||
inHours: 'dans {{count}}h',
|
||||
daysAgo: 'il y a {{count}}j',
|
||||
inDays: 'dans {{count}}j',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2347,7 +2347,7 @@ export default {
|
||||
kProfiles: 'K-Profiles',
|
||||
addKProfile: 'Aggiungi K-Profile',
|
||||
assignSpool: 'Assegna Bobina',
|
||||
unassignSpool: 'Deassegna',
|
||||
unassignSpool: 'Scollega',
|
||||
assignSuccess: 'Bobina assegnata e slot AMS configurato',
|
||||
assignFailed: 'Assegnazione bobina fallita',
|
||||
selectSpool: 'Seleziona una bobina da assegnare a questo slot',
|
||||
@@ -2397,8 +2397,10 @@ export default {
|
||||
externalSpool: 'Bobina esterna',
|
||||
profile: 'Profilo',
|
||||
kFactor: 'K Factor',
|
||||
fill: 'Riempi',
|
||||
fill: 'Livello',
|
||||
configure: 'Configura',
|
||||
used: 'utilizzato',
|
||||
remainingUnit: 'rimanente',
|
||||
},
|
||||
|
||||
// Print modal
|
||||
@@ -2814,4 +2816,18 @@ export default {
|
||||
configuring: 'Configurazione...',
|
||||
configureSlot: 'Configura slot',
|
||||
},
|
||||
|
||||
// Time
|
||||
time: {
|
||||
unknown: '-',
|
||||
waiting: 'In attesa',
|
||||
justNow: 'Proprio ora',
|
||||
now: 'Ora',
|
||||
minsAgo: '{{count}}m fa',
|
||||
inMins: 'tra {{count}}m',
|
||||
hoursAgo: '{{count}}h fa',
|
||||
inHours: 'tra {{count}}h',
|
||||
daysAgo: '{{count}}g fa',
|
||||
inDays: 'tra {{count}}g',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2604,6 +2604,8 @@ export default {
|
||||
kFactor: 'K値',
|
||||
fill: '充填率',
|
||||
configure: '設定',
|
||||
used: '使用済み',
|
||||
remainingUnit: '残り',
|
||||
},
|
||||
printModal: {
|
||||
flowCalibration: 'フローキャリブレーション',
|
||||
@@ -3269,4 +3271,18 @@ export default {
|
||||
|
||||
// Spoolman Settings
|
||||
spoolmanSettings: {},
|
||||
|
||||
// Time
|
||||
time: {
|
||||
unknown: '-',
|
||||
waiting: '待機中',
|
||||
justNow: 'たった今',
|
||||
now: '今すぐ',
|
||||
minsAgo: '{{count}}分前',
|
||||
inMins: 'あと{{count}}分',
|
||||
hoursAgo: '{{count}}時間前',
|
||||
inHours: 'あと{{count}}時間',
|
||||
daysAgo: '{{count}}日前',
|
||||
inDays: 'あと{{count}}日',
|
||||
},
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -74,15 +74,10 @@ import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
|
||||
import { TagManagementModal } from '../components/TagManagementModal';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { formatFileSize } from '../utils/file';
|
||||
|
||||
type TFunction = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an archive filename represents a sliced/printable file.
|
||||
* Matches: .gcode, .gcode.3mf, .gcode.anything
|
||||
@@ -105,6 +100,36 @@ function getArchiveFileType(filename: string | null | undefined): string | undef
|
||||
|
||||
// formatDate imported from '../utils/date' - handles UTC conversion
|
||||
|
||||
/**
|
||||
* Open an archive file in the slicer.
|
||||
* Fetches a short-lived download token, then builds a token-authenticated URL
|
||||
* that bypasses auth middleware (slicer protocol handlers can't send auth headers).
|
||||
*/
|
||||
async function openInSlicerWithToken(
|
||||
archiveId: number,
|
||||
filename: string,
|
||||
resourceType: 'file' | 'source',
|
||||
slicer: SlicerType,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (resourceType === 'source') {
|
||||
const { token } = await api.createSourceSlicerToken(archiveId);
|
||||
const path = api.getSourceSlicerDownloadUrl(archiveId, token, filename);
|
||||
openInSlicer(`${window.location.origin}${path}`, slicer);
|
||||
} else {
|
||||
const { token } = await api.createArchiveSlicerToken(archiveId);
|
||||
const path = api.getArchiveSlicerDownloadUrl(archiveId, token, filename);
|
||||
openInSlicer(`${window.location.origin}${path}`, slicer);
|
||||
}
|
||||
} catch {
|
||||
// Fallback to direct URL (works when auth is disabled)
|
||||
const path = resourceType === 'source'
|
||||
? api.getSource3mfForSlicer(archiveId, filename)
|
||||
: api.getArchiveForSlicer(archiveId, filename);
|
||||
openInSlicer(`${window.location.origin}${path}`, slicer);
|
||||
}
|
||||
}
|
||||
|
||||
function ArchiveCard({
|
||||
archive,
|
||||
printerName,
|
||||
@@ -337,8 +362,7 @@ function ArchiveCard({
|
||||
icon: <ExternalLink className="w-4 h-4" />,
|
||||
onClick: () => {
|
||||
const filename = archive.print_name || archive.filename || 'model';
|
||||
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
|
||||
openInSlicer(downloadUrl, preferredSlicer);
|
||||
openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
|
||||
},
|
||||
disabled: !archive.file_path,
|
||||
title: !archive.file_path ? t('archives.card.noFileForReprint') : undefined,
|
||||
@@ -349,8 +373,7 @@ function ArchiveCard({
|
||||
icon: <ExternalLink className="w-4 h-4" />,
|
||||
onClick: () => {
|
||||
const filename = archive.print_name || archive.filename || 'model';
|
||||
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
|
||||
openInSlicer(downloadUrl, preferredSlicer);
|
||||
openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
|
||||
},
|
||||
},
|
||||
]),
|
||||
@@ -738,8 +761,7 @@ function ArchiveCard({
|
||||
e.stopPropagation();
|
||||
// Open source 3MF in Bambu Studio - use filename in URL for slicer compatibility
|
||||
const sourceName = (archive.print_name || archive.filename || 'source').replace(/\.gcode\.3mf$/i, '') + '_source';
|
||||
const downloadUrl = `${window.location.origin}${api.getSource3mfForSlicer(archive.id, sourceName)}`;
|
||||
openInSlicer(downloadUrl, preferredSlicer);
|
||||
openInSlicerWithToken(archive.id, sourceName, 'source', preferredSlicer);
|
||||
}}
|
||||
title={t('archives.card.openSource3mf')}
|
||||
>
|
||||
@@ -1008,8 +1030,7 @@ function ArchiveCard({
|
||||
className="min-w-0 p-1 sm:p-1.5"
|
||||
onClick={() => {
|
||||
const filename = archive.print_name || archive.filename || 'model';
|
||||
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
|
||||
openInSlicer(downloadUrl, preferredSlicer);
|
||||
openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
|
||||
}}
|
||||
title={t('archives.card.openInBambuStudio')}
|
||||
>
|
||||
@@ -1024,8 +1045,7 @@ function ArchiveCard({
|
||||
className="flex-1 min-w-0"
|
||||
onClick={() => {
|
||||
const filename = archive.print_name || archive.filename || 'model';
|
||||
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
|
||||
openInSlicer(downloadUrl, preferredSlicer);
|
||||
openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
|
||||
}}
|
||||
title={t('archives.card.openInBambuStudioToSlice')}
|
||||
>
|
||||
@@ -1546,8 +1566,7 @@ function ArchiveListRow({
|
||||
icon: <ExternalLink className="w-4 h-4" />,
|
||||
onClick: () => {
|
||||
const filename = archive.print_name || archive.filename || 'model';
|
||||
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
|
||||
openInSlicer(downloadUrl, preferredSlicer);
|
||||
openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
|
||||
},
|
||||
disabled: !archive.file_path,
|
||||
title: !archive.file_path ? t('archives.card.noFileForReprint') : undefined,
|
||||
@@ -1558,8 +1577,7 @@ function ArchiveListRow({
|
||||
icon: <ExternalLink className="w-4 h-4" />,
|
||||
onClick: () => {
|
||||
const filename = archive.print_name || archive.filename || 'model';
|
||||
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
|
||||
openInSlicer(downloadUrl, preferredSlicer);
|
||||
openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
|
||||
},
|
||||
},
|
||||
]),
|
||||
@@ -1900,8 +1918,7 @@ function ArchiveListRow({
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const filename = archive.print_name || archive.filename || 'model';
|
||||
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
|
||||
openInSlicer(downloadUrl, preferredSlicer);
|
||||
openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
|
||||
}}
|
||||
title={t('archives.card.openInBambuStudio')}
|
||||
>
|
||||
|
||||
@@ -58,19 +58,12 @@ import { useToast } from '../contexts/ToastContext';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { formatDuration } from '../utils/date';
|
||||
import { formatFileSize } from '../utils/file';
|
||||
|
||||
type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
type TFunction = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
// Utility to format file size
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
// New Folder Modal
|
||||
interface NewFolderModalProps {
|
||||
parentId: number | null;
|
||||
|
||||
@@ -2908,6 +2908,7 @@ function PrinterCard({
|
||||
material: assignment.spool.material,
|
||||
brand: assignment.spool.brand,
|
||||
color_name: assignment.spool.color_name,
|
||||
remainingWeightGrams: Math.max(0, Math.round(assignment.spool.label_weight - assignment.spool.weight_used)),
|
||||
} : null,
|
||||
onAssignSpool: filamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({
|
||||
printerId: printer.id,
|
||||
@@ -3145,6 +3146,7 @@ function PrinterCard({
|
||||
material: assignment.spool.material,
|
||||
brand: assignment.spool.brand,
|
||||
color_name: assignment.spool.color_name,
|
||||
remainingWeightGrams: Math.max(0, Math.round(assignment.spool.label_weight - assignment.spool.weight_used)),
|
||||
} : null,
|
||||
onAssignSpool: filamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({
|
||||
printerId: printer.id,
|
||||
@@ -3331,6 +3333,7 @@ function PrinterCard({
|
||||
material: assignment.spool.material,
|
||||
brand: assignment.spool.brand,
|
||||
color_name: assignment.spool.color_name,
|
||||
remainingWeightGrams: Math.max(0, Math.round(assignment.spool.label_weight - assignment.spool.weight_used)),
|
||||
} : null,
|
||||
onAssignSpool: () => setAssignSpoolModal({
|
||||
printerId: printer.id,
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
HardDrive,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { parseUTCDate } from '../utils/date';
|
||||
import { formatRelativeTime } from '../utils/date';
|
||||
import type { SlicerSetting, SlicerSettingsResponse, SlicerSettingDetail, SlicerSettingCreate, Printer, FieldDefinition, Permission } from '../api/client';
|
||||
import { Card, CardContent } from '../components/Card';
|
||||
import { Button } from '../components/Button';
|
||||
@@ -90,23 +90,6 @@ function isUserPreset(settingId: string): boolean {
|
||||
return /^(P[FPM]US|PF\d|PP\d)/.test(settingId);
|
||||
}
|
||||
|
||||
// Format relative time
|
||||
function formatRelativeTime(dateStr: string, t: TFunction): string {
|
||||
const date = parseUTCDate(dateStr);
|
||||
if (!date) return '';
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return t('profiles.time.justNow');
|
||||
if (diffMins < 60) return t('profiles.time.minsAgo', { count: diffMins });
|
||||
if (diffHours < 24) return t('profiles.time.hoursAgo', { count: diffHours });
|
||||
if (diffDays < 7) return t('profiles.time.daysAgo', { count: diffDays });
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LOGIN FORM
|
||||
// ============================================================================
|
||||
@@ -2646,7 +2629,7 @@ function CloudProfilesView({
|
||||
{lastSyncTime && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{t('profiles.cloudView.lastSynced')} {formatRelativeTime(lastSyncTime.toISOString(), t)}
|
||||
{t('profiles.cloudView.lastSynced')} {formatRelativeTime(lastSyncTime.toISOString(), 'system', t)}
|
||||
</div>
|
||||
)}
|
||||
<span>{t('profiles.cloudView.showingCount', { showing: filteredPresets.length, total: totalCount })}</span>
|
||||
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
Weight,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { parseUTCDate, formatDateTime, type TimeFormat, formatETA, formatDuration } from '../utils/date';
|
||||
import { type TimeFormat, formatETA, formatDuration, formatRelativeTime } from '../utils/date';
|
||||
import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client';
|
||||
import { Card, CardContent } from '../components/Card';
|
||||
import { Button } from '../components/Button';
|
||||
@@ -64,21 +64,6 @@ function formatWeight(g: number, useKg = false): string {
|
||||
return `${Math.round(g)}g`;
|
||||
}
|
||||
|
||||
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 t?.('queue.time.asap') ?? 'ASAP';
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
|
||||
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, printerState, t }: { status: PrintQueueItem['status']; waitingReason?: string | null; printerState?: string | null; t: (key: string) => string }) {
|
||||
// Special case: pending with waiting_reason shows as "Waiting"
|
||||
if (status === 'pending' && waitingReason) {
|
||||
@@ -506,9 +491,13 @@ function SortableQueueItem({
|
||||
</span>
|
||||
)}
|
||||
{isPending && !item.manual_start && (
|
||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
||||
<Clock className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
|
||||
{formatRelativeTime(item.scheduled_time, timeFormat, t)}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{item.scheduled_time
|
||||
? (new Date(item.scheduled_time).getTime() - Date.now() < -60000
|
||||
? t?.('queue.time.overdue') ?? 'Overdue'
|
||||
: formatRelativeTime(item.scheduled_time, timeFormat, t))
|
||||
: t?.('queue.time.asap') ?? 'ASAP'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -586,7 +575,6 @@ function SortableQueueItem({
|
||||
</div>
|
||||
|
||||
{/* Status badge + Actions */}
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div className="flex flex-col sm:flex-row items-end sm:items-center gap-2 sm:gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<StatusBadge status={item.status} waitingReason={item.waiting_reason} printerState={printerState} t={t} />
|
||||
|
||||
|
||||
@@ -392,7 +392,7 @@ export function formatETA(
|
||||
): string {
|
||||
const now = new Date();
|
||||
const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
|
||||
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const etaDay = new Date(eta);
|
||||
@@ -418,9 +418,70 @@ export function formatETA(
|
||||
*/
|
||||
export function formatDuration(seconds: number | null | undefined): string {
|
||||
if (seconds == null || seconds < 0) return '--';
|
||||
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
|
||||
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
||||
}
|
||||
|
||||
type TranslateFunction = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
/**
|
||||
* Format a date string as a human-readable relative time expression.
|
||||
*
|
||||
* @param dateStr - UTC date string, or null
|
||||
* @param timeFormat - Time format preference ('12h', '24h', or 'system')
|
||||
* @param t - Optional translation function for i18n support
|
||||
* @returns Relative string (e.g., "5m ago", "in 2h", "3d ago") or formatted date if older than 7 days
|
||||
*/
|
||||
export function formatRelativeTime(
|
||||
dateStr: string | null,
|
||||
timeFormat: TimeFormat = 'system',
|
||||
t?: TranslateFunction
|
||||
): string {
|
||||
if (!dateStr) return t?.('time.unknown') ?? '-';
|
||||
|
||||
const date = parseUTCDate(dateStr);
|
||||
if (!date) return t?.('time.unknown') ?? '-';
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = date.getTime() - now.getTime();
|
||||
const isPast = diffMs < 0;
|
||||
const absDiffMs = Math.abs(diffMs);
|
||||
|
||||
const minutes = Math.floor(absDiffMs / 60000);
|
||||
const hours = Math.floor(absDiffMs / 3600000);
|
||||
const days = Math.floor(absDiffMs / 86400000);
|
||||
|
||||
// Less than 1 minute
|
||||
if (minutes < 1) {
|
||||
return isPast
|
||||
? t?.('time.justNow') ?? 'Just now'
|
||||
: t?.('time.now') ?? 'Now';
|
||||
}
|
||||
|
||||
// Less than 1 hour
|
||||
if (hours < 1) {
|
||||
return isPast
|
||||
? t?.('time.minsAgo', { count: minutes }) ?? `${minutes}m ago`
|
||||
: t?.('time.inMins', { count: minutes }) ?? `in ${minutes}m`;
|
||||
}
|
||||
|
||||
// Less than 1 day
|
||||
if (days < 1) {
|
||||
return isPast
|
||||
? t?.('time.hoursAgo', { count: hours }) ?? `${hours}h ago`
|
||||
: t?.('time.inHours', { count: hours }) ?? `in ${hours}h`;
|
||||
}
|
||||
|
||||
// Less than 7 days
|
||||
if (days < 7) {
|
||||
return isPast
|
||||
? t?.('time.daysAgo', { count: days }) ?? `${days}d ago`
|
||||
: t?.('time.inDays', { count: days }) ?? `in ${days}d`;
|
||||
}
|
||||
|
||||
// Older than 7 days
|
||||
return formatDateTime(dateStr, timeFormat);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Formats a byte count into a human-readable string (e.g. `1.5 MB`).
|
||||
*
|
||||
* @param bytes - The number of bytes to format.
|
||||
* @returns A formatted string with the appropriate unit (B, KB, MB, GB, or TB).
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const k = 1024;
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
const size = bytes / Math.pow(k, i);
|
||||
|
||||
// No decimals for bytes, 1 decimal for larger units
|
||||
return i === 0
|
||||
? `${size} ${units[i]}`
|
||||
: `${size.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
/**
|
||||
* Utility for opening files in slicer applications
|
||||
*
|
||||
* Bambu Studio URL protocol is OS-specific:
|
||||
* - Windows: bambustudio://<encoded-URL>
|
||||
* - macOS/Linux: bambustudioopen://<encoded-URL>
|
||||
* Protocol handler URL formats (from BambuStudio/OrcaSlicer source code):
|
||||
*
|
||||
* OrcaSlicer uses the same protocol on all platforms:
|
||||
* - orcaslicer://open?file=<URL>
|
||||
* Bambu Studio has TWO separate URL handlers:
|
||||
* 1. post_init() [Windows/Linux CLI args]: bambustudio://open?file=<URL>
|
||||
* - Checks: starts_with("bambustudio://open")
|
||||
* - Calls url_decode(), then split_str(url, "file=")
|
||||
* 2. MacOpenURL() [macOS Apple Events]: bambustudioopen://<encoded-URL>
|
||||
* - Checks: starts_with("bambustudioopen://")
|
||||
* - Strips prefix, then url_decode()
|
||||
*
|
||||
* OrcaSlicer Downloader accepts both formats via regex:
|
||||
* - (orcaslicer|bambustudio|...)://open?file=<URL>
|
||||
* - bambustudioopen://<URL>
|
||||
*
|
||||
* Key insight: Using ?file= query format, the browser's URL parser preserves
|
||||
* http:// in the query string without any encoding. Only the macOS-specific
|
||||
* bambustudioopen:// format needs encodeURIComponent (BS calls url_decode).
|
||||
*/
|
||||
|
||||
export type SlicerType = 'bambu_studio' | 'orcaslicer';
|
||||
@@ -34,8 +45,6 @@ export function detectPlatform(): Platform {
|
||||
|
||||
/**
|
||||
* Open a URL in the specified slicer application.
|
||||
* Uses a temporary link element to trigger the protocol handler,
|
||||
* which avoids browser "unknown protocol" blocks on window.location.href.
|
||||
* @param downloadUrl - The URL to the file to open
|
||||
* @param slicer - Which slicer to use (defaults to bambu_studio)
|
||||
*/
|
||||
@@ -43,15 +52,26 @@ export function openInSlicer(downloadUrl: string, slicer: SlicerType = 'bambu_st
|
||||
let url: string;
|
||||
|
||||
if (slicer === 'orcaslicer') {
|
||||
// OrcaSlicer: ?file= query format — http:// preserved in query string
|
||||
url = `orcaslicer://open?file=${downloadUrl}`;
|
||||
} else {
|
||||
const platform = detectPlatform();
|
||||
const protocol = platform === 'windows' ? 'bambustudio' : 'bambustudioopen';
|
||||
url = `${protocol}://${encodeURIComponent(downloadUrl)}`;
|
||||
if (platform === 'macos') {
|
||||
// macOS only: bambustudioopen scheme via MacOpenURL() callback.
|
||||
// Must encode because bare http:// in authority gets mangled by browser.
|
||||
// BS calls url_decode() after stripping "bambustudioopen://" prefix.
|
||||
url = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;
|
||||
} else {
|
||||
// Windows/Linux: bambustudio://open?file= via post_init() CLI args.
|
||||
// The ?file= query format preserves http:// without encoding.
|
||||
// IMPORTANT: On Linux, BS only handles "bambustudio://open" prefix —
|
||||
// it does NOT process "bambustudioopen://" (that's macOS-only).
|
||||
url = `bambustudio://open?file=${downloadUrl}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Use a temporary <a> element to trigger the protocol handler.
|
||||
// This works more reliably than window.location.href for custom protocols.
|
||||
// This avoids navigating away from the page (unlike window.location.href).
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.style.display = 'none';
|
||||
|
||||
Reference in New Issue
Block a user