Feature: print files directly from project view (closes #930) (#932)

* feat: print files directly from project view (closes #930)

Show printable files from linked library folders directly in the project
detail page, with Print Now and Add to Queue buttons per file. Removes
the detour through the File Manager for common reprint workflows.
This commit is contained in:
lietschaend
2026-04-10 12:54:27 +02:00
committed by GitHub
parent b453385d22
commit f95b2acd7d
21 changed files with 566 additions and 31 deletions
+14 -1
View File
@@ -1044,14 +1044,16 @@ async def scan_external_folder(
async def list_files(
response: Response,
folder_id: int | None = None,
project_id: int | None = None,
include_root: bool = True,
db: AsyncSession = Depends(get_db),
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
):
"""List files, optionally filtered by folder.
"""List files, optionally filtered by folder or project.
Args:
folder_id: Filter by folder ID. If None and include_root=True, returns root files.
project_id: Return all files across folders linked to this project (bulk fetch, avoids N+1).
include_root: If True and folder_id is None, returns files at root level.
If False and folder_id is None, returns all files.
"""
@@ -1059,6 +1061,10 @@ async def list_files(
if folder_id is not None:
query = query.where(LibraryFile.folder_id == folder_id)
elif project_id is not None:
# Single join instead of one query per folder (avoids N+1 pattern)
query = query.join(LibraryFolder, LibraryFile.folder_id == LibraryFolder.id)
query = query.where(LibraryFolder.project_id == project_id)
elif include_root:
query = query.where(LibraryFile.folder_id.is_(None))
@@ -2210,6 +2216,12 @@ async def print_library_file(
if not printer_manager.is_connected(printer_id):
raise HTTPException(status_code=400, detail="Printer is not connected")
# Validate project exists before dispatching so a bogus ID yields 404, not a FK-constraint 500
if body.project_id is not None:
project_result = await db.execute(select(Project).where(Project.id == body.project_id))
if not project_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
plate_name = body.plate_name
if not plate_name and body.plate_id is not None:
plate_name = f"Plate {body.plate_id}"
@@ -2225,6 +2237,7 @@ async def print_library_file(
printer_id=printer_id,
printer_name=printer.name,
options=body.model_dump(exclude_none=True),
project_id=body.project_id,
requested_by_user_id=None,
requested_by_username=None,
)
+8
View File
@@ -21,6 +21,7 @@ from backend.app.models.library import LibraryFile
from backend.app.models.print_batch import PrintBatch
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.printer import Printer
from backend.app.models.project import Project
from backend.app.models.user import User
from backend.app.schemas.print_queue import (
PrintBatchResponse,
@@ -484,6 +485,12 @@ async def add_to_queue(
if plate_time is not None:
cached_print_time = plate_time
# Validate project exists before insert so a bogus ID yields 404, not an FK-constraint 500
if data.project_id is not None:
project_result = await db.execute(select(Project).where(Project.id == data.project_id))
if not project_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
ams_mapping_json = json.dumps(data.ams_mapping) if data.ams_mapping else None
items = []
for i in range(quantity):
@@ -508,6 +515,7 @@ async def add_to_queue(
timelapse=data.timelapse,
use_ams=data.use_ams,
gcode_injection=data.gcode_injection,
project_id=data.project_id,
position=max_pos + 1 + i,
status="pending",
created_by_id=current_user.id if current_user else None,
+2
View File
@@ -208,6 +208,8 @@ class FilePrintRequest(BaseModel):
layer_inspect: bool = False
timelapse: bool = False
use_ams: bool = True
# Project to associate the resulting archive with
project_id: int | None = None
class FileUploadResponse(BaseModel):
+2
View File
@@ -44,6 +44,8 @@ class PrintQueueItemCreate(BaseModel):
gcode_injection: bool = False
# Batch: create multiple copies (creates a batch if > 1)
quantity: int = 1
# Project to associate the resulting archive with
project_id: int | None = None
class PrintQueueItemUpdate(BaseModel):
+4
View File
@@ -854,6 +854,7 @@ class ArchiveService:
print_data: dict | None = None,
created_by_id: int | None = None,
original_filename: str | None = None,
project_id: int | None = None,
) -> PrintArchive | None:
"""Archive a 3MF file with metadata.
@@ -864,6 +865,8 @@ class ArchiveService:
created_by_id: User ID who created this archive (optional, for user tracking)
original_filename: Original human-readable filename (optional, for library files
stored with UUID names)
project_id: Project to associate this archive with (optional, set when triggered
from the project view)
"""
# Verify printer exists if specified
if printer_id is not None:
@@ -974,6 +977,7 @@ class ArchiveService:
quantity=quantity,
extra_data=metadata,
created_by_id=created_by_id,
project_id=project_id,
)
self.db.add(archive)
@@ -54,6 +54,7 @@ class PrintDispatchJob:
options: dict[str, Any] = field(default_factory=dict)
requested_by_user_id: int | None = None
requested_by_username: str | None = None
project_id: int | None = None
@dataclass(slots=True)
@@ -160,6 +161,7 @@ class BackgroundDispatchService:
options: dict[str, Any],
requested_by_user_id: int | None,
requested_by_username: str | None,
project_id: int | None = None,
) -> dict[str, Any]:
return await self._dispatch(
kind="print_library_file",
@@ -170,6 +172,7 @@ class BackgroundDispatchService:
options=options,
requested_by_user_id=requested_by_user_id,
requested_by_username=requested_by_username,
project_id=project_id,
)
async def cancel_job(self, job_id: int) -> dict[str, Any]:
@@ -257,6 +260,7 @@ class BackgroundDispatchService:
options: dict[str, Any],
requested_by_user_id: int | None,
requested_by_username: str | None,
project_id: int | None = None,
) -> dict[str, Any]:
async with self._lock:
has_pending_for_printer = any(job.printer_id == printer_id for job in self._queued_jobs)
@@ -279,6 +283,7 @@ class BackgroundDispatchService:
options=options,
requested_by_user_id=requested_by_user_id,
requested_by_username=requested_by_username,
project_id=project_id,
)
self._next_job_id += 1
self._batch_total += 1
@@ -722,6 +727,7 @@ class BackgroundDispatchService:
printer_id=job.printer_id,
source_file=file_path,
original_filename=lib_file.filename,
project_id=job.project_id,
)
if not archive:
raise RuntimeError("Failed to create archive")
+1
View File
@@ -1641,6 +1641,7 @@ class PrintScheduler:
source_file=file_path,
original_filename=filename,
created_by_id=item.created_by_id,
project_id=item.project_id,
)
if archive:
item.archive_id = archive.id
@@ -1141,4 +1141,107 @@ describe('PrintModal', () => {
expect(input.value).toBe('5');
});
});
describe('project_id forwarding', () => {
beforeEach(() => {
// Additional handlers needed for library file mode
server.use(
http.get('/api/v1/library/files/:id', () => {
return HttpResponse.json({
id: 5,
filename: 'benchy.gcode.3mf',
print_name: null,
file_type: '3mf',
folder_id: null,
project_id: null,
file_hash: null,
file_size_bytes: 1024,
thumbnail_path: null,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
});
}),
http.get('/api/v1/library/files/:id/plates', () => {
return HttpResponse.json({ is_multi_plate: false, plates: [] });
}),
http.get('/api/v1/library/files/:id/filament-requirements', () => {
return HttpResponse.json({ file_id: 5, filename: 'benchy.gcode.3mf', filaments: [] });
}),
http.get('/api/v1/printers/:id/status', () => {
return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] });
}),
);
});
it('includes project_id in printLibraryFile call when projectId prop is set', async () => {
let capturedBody: Record<string, unknown> | null = null;
server.use(
http.post('/api/v1/library/files/:id/print', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ status: 'dispatched', dispatch_job_id: 'abc', dispatch_position: 0 });
})
);
const user = userEvent.setup();
render(
<PrintModal
mode="reprint"
libraryFileId={5}
archiveName="Benchy"
projectId={42}
initialSelectedPrinterIds={[1]}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
// Wait for the modal to load printer and file data
await waitFor(() => {
expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: /^print$/i }));
await waitFor(() => {
expect(capturedBody).not.toBeNull();
expect(capturedBody?.project_id).toBe(42);
});
});
it('does NOT include project_id in reprintArchive call (archives carry their own project association)', async () => {
// The reprintArchive branch omits project_id by design — archives already carry
// their project association from the original print. This test guards that intent.
let capturedBody: Record<string, unknown> | null = null;
server.use(
http.post('/api/v1/archives/:id/reprint', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ status: 'dispatched' });
})
);
const user = userEvent.setup();
render(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
projectId={42}
initialSelectedPrinterIds={[1]}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
await waitFor(() => {
expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: /^print$/i }));
await waitFor(() => {
expect(capturedBody).not.toBeNull();
expect(capturedBody).not.toHaveProperty('project_id');
});
});
});
});
@@ -0,0 +1,225 @@
/**
* Tests for the ProjectDetailPage component.
* Covers: isSlicedFilename conditional print-button logic, linked folder file rendering,
* and the PrintModal open trigger with projectId.
*/
/// <reference types="@testing-library/jest-dom" />
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { ProjectDetailPage } from '../../pages/ProjectDetailPage';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
// Mock useParams so the component receives a fixed project id without a nested Router
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return {
...actual,
useParams: () => ({ id: '1' }),
useNavigate: () => vi.fn(),
};
});
const mockProject = {
id: 1,
name: 'Test Project',
description: 'A test project',
color: '#00ae42',
status: 'active',
priority: 'normal',
due_date: null,
notes: null,
parent_id: null,
archive_count: 0,
total_print_time_seconds: 0,
total_filament_grams: 0,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
const mockFolder = {
id: 10,
name: 'Sliced Files',
project_id: 1,
archive_id: null,
parent_id: null,
file_count: 3,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
function makeFile(overrides: { id: number; filename: string; file_type?: string }) {
return {
id: overrides.id,
filename: overrides.filename,
print_name: null,
file_type: overrides.file_type ?? '3mf',
folder_id: 10,
project_id: 1,
file_hash: null,
file_size_bytes: 1024,
thumbnail_path: null,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
duplicate_count: 0,
};
}
describe('ProjectDetailPage', () => {
beforeEach(() => {
server.use(
http.get('/api/v1/projects/:id', () => {
return HttpResponse.json(mockProject);
}),
http.get('/api/v1/projects/:id/archives', () => {
return HttpResponse.json([]);
}),
http.get('/api/v1/projects/:id/bom', () => {
return HttpResponse.json([]);
}),
http.get('/api/v1/projects/:id/timeline', () => {
return HttpResponse.json([]);
}),
http.get('/api/v1/library/folders/by-project/:id', () => {
return HttpResponse.json([mockFolder]);
}),
);
});
describe('isSlicedFilename — conditional print button', () => {
it('shows print button for .gcode files', async () => {
server.use(
http.get('/api/v1/library/files', () => {
return HttpResponse.json([makeFile({ id: 1, filename: 'benchy.gcode', file_type: 'gcode' })]);
})
);
render(<ProjectDetailPage />);
await waitFor(() => {
expect(screen.getByTitle('Print Now')).toBeInTheDocument();
});
});
it('shows print button for .gcode.3mf files', async () => {
server.use(
http.get('/api/v1/library/files', () => {
return HttpResponse.json([makeFile({ id: 2, filename: 'benchy.gcode.3mf', file_type: '3mf' })]);
})
);
render(<ProjectDetailPage />);
await waitFor(() => {
expect(screen.getByTitle('Print Now')).toBeInTheDocument();
});
});
it('does NOT show print button for .gcode.bak files (regression for includes bug)', async () => {
server.use(
http.get('/api/v1/library/files', () => {
return HttpResponse.json([makeFile({ id: 3, filename: 'benchy.gcode.bak', file_type: '3mf' })]);
})
);
render(<ProjectDetailPage />);
await waitFor(() => {
expect(screen.getByText('benchy.gcode.bak')).toBeInTheDocument();
});
expect(screen.queryByTitle('Print Now')).not.toBeInTheDocument();
});
it('does NOT show print button for .stl files', async () => {
server.use(
http.get('/api/v1/library/files', () => {
return HttpResponse.json([makeFile({ id: 4, filename: 'model.stl', file_type: 'stl' })]);
})
);
render(<ProjectDetailPage />);
await waitFor(() => {
expect(screen.getByText('model.stl')).toBeInTheDocument();
});
expect(screen.queryByTitle('Print Now')).not.toBeInTheDocument();
});
});
describe('linked folder file rendering', () => {
it('renders filenames from linked folder', async () => {
server.use(
http.get('/api/v1/library/files', () => {
return HttpResponse.json([
makeFile({ id: 5, filename: 'part_a.gcode.3mf', file_type: '3mf' }),
makeFile({ id: 6, filename: 'design.stl', file_type: 'stl' }),
]);
})
);
render(<ProjectDetailPage />);
await waitFor(() => {
expect(screen.getByText('part_a.gcode.3mf')).toBeInTheDocument();
expect(screen.getByText('design.stl')).toBeInTheDocument();
});
});
it('renders the linked folder name', async () => {
server.use(
http.get('/api/v1/library/files', () => {
return HttpResponse.json([]);
})
);
render(<ProjectDetailPage />);
await waitFor(() => {
expect(screen.getByText('Sliced Files')).toBeInTheDocument();
});
});
});
describe('print modal trigger', () => {
it('opens PrintModal when print button is clicked on a sliced file', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/v1/library/files', () => {
return HttpResponse.json([makeFile({ id: 7, filename: 'cube.gcode.3mf', file_type: '3mf' })]);
}),
http.get('/api/v1/printers/', () => {
return HttpResponse.json([]);
}),
http.get('/api/v1/library/files/:id', () => {
return HttpResponse.json(makeFile({ id: 7, filename: 'cube.gcode.3mf', file_type: '3mf' }));
}),
http.get('/api/v1/library/files/:id/plates', () => {
return HttpResponse.json({ is_multi_plate: false, plates: [] });
}),
http.get('/api/v1/library/files/:id/filament-requirements', () => {
return HttpResponse.json({ file_id: 7, filename: 'cube.gcode.3mf', filaments: [] });
}),
);
render(<ProjectDetailPage />);
await waitFor(() => {
expect(screen.getByTitle('Print Now')).toBeInTheDocument();
});
await user.click(screen.getByTitle('Print Now'));
// PrintModal should open — look for the modal heading "Print"
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Print' })).toBeInTheDocument();
});
});
});
});
+7 -1
View File
@@ -1441,6 +1441,8 @@ export interface PrintQueueItemCreate {
gcode_injection?: boolean;
// Batch: create multiple copies (creates a batch if > 1)
quantity?: number;
// Project to associate the resulting archive with
project_id?: number;
}
export interface PrintQueueItemUpdate {
@@ -4240,11 +4242,14 @@ export const api = {
getLibraryFoldersByArchive: (archiveId: number) =>
request<LibraryFolder[]>(`/library/folders/by-archive/${archiveId}`),
getLibraryFiles: (folderId?: number | null, includeRoot = true) => {
getLibraryFiles: (folderId?: number | null, includeRoot = true, projectId?: number) => {
const params = new URLSearchParams();
if (folderId !== undefined && folderId !== null) {
params.set('folder_id', String(folderId));
}
if (projectId !== undefined) {
params.set('project_id', String(projectId));
}
params.set('include_root', String(includeRoot));
return request<LibraryFileListItem[]>(`/library/files?${params}`);
},
@@ -4379,6 +4384,7 @@ export const api = {
layer_inspect?: boolean;
timelapse?: boolean;
use_ams?: boolean;
project_id?: number;
}
) =>
request<BackgroundDispatchResponse>(
@@ -48,6 +48,7 @@ export function PrintModal({
initialSelectedPrinterIds,
onClose,
onSuccess,
projectId,
}: PrintModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
@@ -654,6 +655,7 @@ export function PrintModal({
? new Date(scheduleOptions.scheduledTime).toISOString()
: undefined,
...printOptions,
project_id: projectId ?? undefined,
});
// Model-based assignment
@@ -734,8 +736,11 @@ export function PrintModal({
plate_name: selectedPlateName,
ams_mapping: printerMapping,
...printOptions,
project_id: projectId,
});
} else {
// project_id is intentionally omitted here: reprintArchive targets an existing
// archive that already carries its own project association from the original print.
await api.reprintArchive(archiveId!, printerId, {
plate_id: selectedPlate ?? undefined,
plate_name: selectedPlateName,
@@ -32,6 +32,8 @@ export interface PrintModalProps {
onClose: () => void;
/** Handler for successful operation */
onSuccess?: () => void;
/** Project ID to associate the resulting archive with (only when triggered from project view) */
projectId?: number;
}
/**
+3
View File
@@ -2902,6 +2902,9 @@ export default {
forQuickAccess: 'für schnellen Zugriff auf dieses Projekt.',
fileCount: '{{count}} Datei(en)',
empty: 'Keine Ordner verknüpft. Gehen Sie zum Dateimanager und verknüpfen Sie einen Ordner mit diesem Projekt.',
noFiles: 'Keine Dateien in diesem Ordner.',
print: 'Jetzt drucken',
addToQueue: 'Zur Warteschlange',
},
bom: {
title: 'Stückliste',
+3
View File
@@ -2904,6 +2904,9 @@ export default {
forQuickAccess: 'to this project for quick access.',
fileCount: '{{count}} file(s)',
empty: 'No folders linked. Go to File Manager and link a folder to this project.',
noFiles: 'No files in this folder.',
print: 'Print Now',
addToQueue: 'Add to Queue',
},
bom: {
title: 'Bill of Materials',
+3
View File
@@ -2889,6 +2889,9 @@ export default {
forQuickAccess: 'pour un accès rapide.',
fileCount: '{{count}} fichier(s)',
empty: 'Aucun dossier lié.',
noFiles: 'Aucun fichier dans ce dossier.',
print: 'Imprimer maintenant',
addToQueue: 'Ajouter à la file',
},
bom: {
title: 'BOM (Liste matériel)',
+3
View File
@@ -2888,6 +2888,9 @@ export default {
forQuickAccess: 'a questo progetto per accesso rapido.',
fileCount: '{{count}} file',
empty: 'Nessuna cartella collegata. Vai a Gestore file e collega una cartella a questo progetto.',
noFiles: 'Nessun file in questa cartella.',
print: 'Stampa ora',
addToQueue: 'Aggiungi alla coda',
},
bom: {
title: 'Distinta materiali',
+3
View File
@@ -2901,6 +2901,9 @@ export default {
forQuickAccess: 'してクイックアクセスできるようにします。',
fileCount: '{{count}}ファイル',
empty: '<空>',
noFiles: 'このフォルダにファイルはありません。',
print: '今すぐ印刷',
addToQueue: 'キューに追加',
},
bom: {
title: '部品表',
+3
View File
@@ -2888,6 +2888,9 @@ export default {
forQuickAccess: 'a este projeto para acesso rápido.',
fileCount: '{{count}} arquivo(s)',
empty: 'Nenhuma pasta vinculada. Vá para o Gerenciador de Arquivos e vincule uma pasta a este projeto.',
noFiles: 'Nenhum arquivo nesta pasta.',
print: 'Imprimir agora',
addToQueue: 'Adicionar à fila',
},
bom: {
title: 'Lista de Materiais',
+3
View File
@@ -2888,6 +2888,9 @@ export default {
forQuickAccess: '到此项目以便快速访问。',
fileCount: '{{count}} 个文件',
empty: '未链接文件夹。前往文件管理器将文件夹链接到此项目。',
noFiles: '此文件夹中没有文件。',
print: '立即打印',
addToQueue: '加入队列',
},
bom: {
title: '材料清单',
+1 -1
View File
@@ -677,7 +677,7 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
// Helper to check if a file is sliced (printable)
function isSlicedFilename(filename: string): boolean {
const lower = filename.toLowerCase();
return lower.endsWith('.gcode') || lower.includes('.gcode.');
return lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf');
}
// File Card
+165 -28
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import DOMPurify from 'dompurify';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -31,21 +31,31 @@ import {
FolderOpen,
Download,
Pencil,
Play,
CalendarPlus,
FileBox,
} from 'lucide-react';
import { api } from '../api/client';
import { parseUTCDate, formatDateOnly, formatDateTime, formatDurationFromHours, type TimeFormat } from '../utils/date';
import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate, BOMItemUpdate } from '../api/client';
import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate, BOMItemUpdate, LibraryFileListItem } from '../api/client';
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
import { RichTextEditor } from '../components/RichTextEditor';
import { ConfirmModal } from '../components/ConfirmModal';
import { PrintModal } from '../components/PrintModal';
// Project edit modal (reused from ProjectsPage)
import { ProjectModal } from './ProjectsPage';
import { getCurrencySymbol } from '../utils/currency';
// Returns true for sliced (printable) files: .gcode and .gcode.3mf
function isSlicedFilename(filename: string): boolean {
const lower = filename.toLowerCase();
return lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf');
}
function formatFilament(grams: number): string {
if (grams >= 1000) {
return `${(grams / 1000).toFixed(2)}kg`;
@@ -201,6 +211,8 @@ export function ProjectDetailPage() {
const [showEditModal, setShowEditModal] = useState(false);
const [editingNotes, setEditingNotes] = useState(false);
const [notesContent, setNotesContent] = useState('');
const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
const [scheduleFile, setScheduleFile] = useState<LibraryFileListItem | null>(null);
const projectId = parseInt(id || '0', 10);
@@ -239,6 +251,26 @@ export function ProjectDetailPage() {
enabled: projectId > 0,
});
// Single bulk query — replaces the previous N+1 useQueries pattern
const { data: allProjectFiles, isLoading: projectFilesLoading } = useQuery({
queryKey: ['project-files', projectId],
queryFn: () => api.getLibraryFiles(null, false, projectId),
enabled: projectId > 0,
});
// Group files by folder_id for the section-based render
const filesByFolder = useMemo(() => {
const map = new Map<number, LibraryFileListItem[]>();
for (const file of allProjectFiles ?? []) {
if (file.folder_id != null) {
const arr = map.get(file.folder_id) ?? [];
arr.push(file);
map.set(file.folder_id, arr);
}
}
return map;
}, [allProjectFiles]);
const currency = getCurrencySymbol(settings?.currency || 'USD');
const timeFormat: TimeFormat = settings?.time_format || 'system';
@@ -473,7 +505,7 @@ export function ProjectDetailPage() {
</button>
<div className="flex items-center gap-3">
<div
className="w-4 h-4 rounded-full flex-shrink-0"
className="w-4 h-4 rounded-full shrink-0"
style={{ backgroundColor: project.color || '#6b7280' }}
/>
<div>
@@ -837,7 +869,7 @@ export function ProjectDetailPage() {
</CardContent>
</Card>
{/* Files section - linked folders from File Manager */}
{/* Files section - linked folders from File Manager with printable files */}
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
@@ -855,27 +887,102 @@ export function ProjectDetailPage() {
</p>
{linkedFolders && linkedFolders.length > 0 ? (
<div className="space-y-2">
{linkedFolders.map((folder) => (
<Link
key={folder.id}
to={`/files?folder=${folder.id}`}
className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
<FolderOpen className="w-5 h-5 text-bambu-green flex-shrink-0" />
<div className="min-w-0">
<p className="text-sm text-white truncate">
{folder.name}
<div className="space-y-4">
{linkedFolders.map((folder) => {
const files = filesByFolder.get(folder.id) ?? [];
const isLoading = projectFilesLoading;
return (
<div key={folder.id}>
{/* Folder header — links to File Manager */}
<Link
to={`/files?folder=${folder.id}`}
className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors mb-2"
>
<div className="flex items-center gap-3 min-w-0">
<FolderOpen className="w-5 h-5 text-bambu-green shrink-0" />
<div className="min-w-0">
<p className="text-sm text-white truncate">{folder.name}</p>
<p className="text-xs text-bambu-gray">
{t('projectDetail.files.fileCount', { count: folder.file_count })}
</p>
</div>
</div>
<ChevronRight className="w-4 h-4 text-bambu-gray shrink-0" />
</Link>
{/* File list within the folder */}
{isLoading ? (
<div className="flex items-center gap-2 px-3 py-2 text-bambu-gray text-sm">
<Loader2 className="w-4 h-4 animate-spin" />
</div>
) : files.length === 0 ? (
<p className="text-bambu-gray/60 text-xs italic px-3">
{t('projectDetail.files.noFiles')}
</p>
<p className="text-xs text-bambu-gray">
{t('projectDetail.files.fileCount', { count: folder.file_count })}
</p>
</div>
) : (
<div className="space-y-1 pl-3">
{files.map((file) => {
const printable = isSlicedFilename(file.filename);
return (
<div
key={file.id}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
>
{/* Thumbnail */}
<div className="w-10 h-10 shrink-0 rounded bg-bambu-dark overflow-hidden flex items-center justify-center">
{file.thumbnail_path ? (
<img
src={api.getLibraryFileThumbnailUrl(file.id)}
alt={file.print_name || file.filename}
className="w-full h-full object-cover"
/>
) : (
<FileBox className="w-5 h-5 text-bambu-gray/40" />
)}
</div>
{/* Name + type badge */}
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate" title={file.print_name || file.filename}>
{file.print_name || file.filename}
</p>
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
file.file_type === '3mf' ? 'bg-bambu-green/20 text-bambu-green'
: file.file_type === 'gcode' ? 'bg-blue-500/20 text-blue-400'
: 'bg-bambu-gray/20 text-bambu-gray'
}`}>
{file.file_type.toUpperCase()}
</span>
</div>
{/* Print actions for sliced files */}
{printable && (
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => setPrintFile(file)}
title={t('projectDetail.files.print')}
className="p-1.5 rounded hover:bg-bambu-green/20 text-bambu-green transition-colors"
>
<Play className="w-4 h-4" />
</button>
<button
onClick={() => setScheduleFile(file)}
title={t('projectDetail.files.addToQueue')}
className="p-1.5 rounded hover:bg-blue-500/20 text-blue-400 transition-colors"
>
<CalendarPlus className="w-4 h-4" />
</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
<ChevronRight className="w-4 h-4 text-bambu-gray flex-shrink-0" />
</Link>
))}
);
})}
</div>
) : (
<p className="text-bambu-gray/70 text-sm italic">
@@ -1066,7 +1173,7 @@ export function ProjectDetailPage() {
onClick={() => hasPermission('projects:update') && handleToggleAcquired(item)}
disabled={updateBomMutation.isPending || !hasPermission('projects:update')}
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 ${
className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors shrink-0 ${
item.is_complete
? 'bg-status-ok border-status-ok text-white'
: hasPermission('projects:update')
@@ -1095,7 +1202,7 @@ export function ProjectDetailPage() {
<button
onClick={() => hasPermission('projects:update') && handleEditBomItem(item)}
disabled={!hasPermission('projects:update')}
className={`p-1 rounded transition-colors flex-shrink-0 ${
className={`p-1 rounded transition-colors shrink-0 ${
hasPermission('projects:update')
? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
: 'text-bambu-gray/50 cursor-not-allowed'
@@ -1107,7 +1214,7 @@ export function ProjectDetailPage() {
<button
onClick={() => hasPermission('projects:update') && handleDeleteBomItem(item.id, item.name)}
disabled={!hasPermission('projects:update')}
className={`p-1 rounded transition-colors flex-shrink-0 ${
className={`p-1 rounded transition-colors shrink-0 ${
hasPermission('projects:update')
? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400'
: 'text-bambu-gray/50 cursor-not-allowed'
@@ -1127,7 +1234,7 @@ export function ProjectDetailPage() {
className="flex items-center gap-1 mt-1 text-xs text-blue-400 hover:text-blue-300 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="w-3 h-3 flex-shrink-0" />
<ExternalLink className="w-3 h-3 shrink-0" />
<span className="truncate">
{(() => {
try {
@@ -1186,7 +1293,7 @@ export function ProjectDetailPage() {
<div className="space-y-3">
{timeline.slice(0, 10).map((event, index) => (
<div key={index} className="flex gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${
event.event_type === 'print_completed' ? 'bg-status-ok/20 text-status-ok' :
event.event_type === 'print_failed' ? 'bg-status-error/20 text-status-error' :
event.event_type === 'print_started' ? 'bg-yellow-500/20 text-yellow-400' :
@@ -1317,6 +1424,36 @@ export function ProjectDetailPage() {
onCancel={() => setConfirmModal(prev => ({ ...prev, isOpen: false }))}
/>
)}
{/* Print directly from project — reprint mode */}
{printFile && (
<PrintModal
mode="reprint"
libraryFileId={printFile.id}
archiveName={printFile.print_name || printFile.filename}
projectId={projectId}
onClose={() => setPrintFile(null)}
onSuccess={() => {
setPrintFile(null);
queryClient.invalidateQueries({ queryKey: ['archives'] });
}}
/>
)}
{/* Add to queue from project */}
{scheduleFile && (
<PrintModal
mode="add-to-queue"
libraryFileId={scheduleFile.id}
archiveName={scheduleFile.print_name || scheduleFile.filename}
projectId={projectId}
onClose={() => setScheduleFile(null)}
onSuccess={() => {
setScheduleFile(null);
queryClient.invalidateQueries({ queryKey: ['queue'] });
}}
/>
)}
</div>
);
}