Added project page viewer and editor

This commit is contained in:
Martin Ziegler
2025-11-28 12:41:28 +01:00
parent 77605a4fe7
commit 53c94deade
21 changed files with 10499 additions and 8459 deletions
+39
View File
@@ -286,6 +286,45 @@ export const api = {
has_gcode: boolean;
build_volume: { x: number; y: number; z: number };
}>(`/archives/${id}/capabilities`),
// Project Page
getArchiveProjectPage: (id: number) =>
request<{
title: string | null;
description: string | null;
designer: string | null;
designer_user_id: string | null;
license: string | null;
copyright: string | null;
creation_date: string | null;
modification_date: string | null;
origin: string | null;
profile_title: string | null;
profile_description: string | null;
profile_cover: string | null;
profile_user_id: string | null;
profile_user_name: string | null;
design_model_id: string | null;
design_profile_id: string | null;
design_region: string | null;
model_pictures: Array<{ name: string; path: string; url: string }>;
profile_pictures: Array<{ name: string; path: string; url: string }>;
thumbnails: Array<{ name: string; path: string; url: string }>;
}>(`/archives/${id}/project-page`),
updateArchiveProjectPage: (id: number, data: {
title?: string;
description?: string;
designer?: string;
license?: string;
copyright?: string;
profile_title?: string;
profile_description?: string;
}) =>
request(`/archives/${id}/project-page`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
getArchiveProjectImageUrl: (archiveId: number, imagePath: string) =>
`${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')}`,
reprintArchive: (archiveId: number, printerId: number) =>
@@ -0,0 +1,475 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
X,
User,
Calendar,
FileText,
Image,
Edit3,
Save,
ExternalLink,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { api } from '../api/client';
import { Button } from './Button';
import { RichTextEditor } from './RichTextEditor';
interface ProjectPageModalProps {
archiveId: number;
archiveName?: string;
onClose: () => void;
}
export function ProjectPageModal({ archiveId, archiveName, onClose }: ProjectPageModalProps) {
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(false);
const [selectedImageIndex, setSelectedImageIndex] = useState<number | null>(null);
const [editData, setEditData] = useState<{
title?: string;
description?: string;
designer?: string;
license?: string;
profile_title?: string;
profile_description?: string;
}>({});
const { data: projectPage, isLoading, error } = useQuery({
queryKey: ['archive-project-page', archiveId],
queryFn: () => api.getArchiveProjectPage(archiveId),
});
const updateMutation = useMutation({
mutationFn: (data: typeof editData) => api.updateArchiveProjectPage(archiveId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['archive-project-page', archiveId] });
setIsEditing(false);
setEditData({});
},
});
// Handle escape key to close modal
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (selectedImageIndex !== null) {
setSelectedImageIndex(null);
} else if (isEditing) {
handleCancelEdit();
} else {
onClose();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [selectedImageIndex, isEditing, onClose]);
// Combine all images for gallery
const allImages = [
...(projectPage?.model_pictures || []),
...(projectPage?.profile_pictures || []),
];
const handleStartEdit = () => {
setEditData({
title: projectPage?.title || '',
description: projectPage?.description || '',
designer: projectPage?.designer || '',
license: projectPage?.license || '',
profile_title: projectPage?.profile_title || '',
profile_description: projectPage?.profile_description || '',
});
setIsEditing(true);
};
const handleSave = () => {
updateMutation.mutate(editData);
};
const handleCancelEdit = () => {
setIsEditing(false);
setEditData({});
};
// Sanitize HTML content (basic XSS prevention)
const sanitizeHtml = (html: string) => {
// Allow basic formatting tags only
const allowed = ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'ul', 'ol', 'li', 'figure', 'img'];
const doc = new DOMParser().parseFromString(html, 'text/html');
const clean = (node: Node): string => {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent || '';
}
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as Element;
const tag = el.tagName.toLowerCase();
if (!allowed.includes(tag)) {
// Return children content without the tag
return Array.from(el.childNodes).map(clean).join('');
}
// Build allowed attributes
let attrs = '';
if (tag === 'a' && el.getAttribute('href')) {
const href = el.getAttribute('href');
if (href?.toLowerCase().startsWith('http')) {
attrs = ` href="${href}" target="_blank" rel="noopener noreferrer"`;
}
}
if (tag === 'img') {
const src = el.getAttribute('src');
// Only render img if it has a valid http(s) URL, otherwise skip entirely
if (!src?.toLowerCase().startsWith('http')) {
return ''; // Skip images without valid URLs
}
attrs = ` src="${src}" style="max-width: 100%; height: auto;"`;
}
const children = Array.from(el.childNodes).map(clean).join('');
if (['br', 'img'].includes(tag)) {
return `<${tag}${attrs} />`;
}
return `<${tag}${attrs}>${children}</${tag}>`;
}
return '';
};
return Array.from(doc.body.childNodes).map(clean).join('');
};
const hasContent = projectPage && (
projectPage.title ||
projectPage.description ||
projectPage.designer ||
projectPage.profile_title ||
allImages.length > 0
);
// Handle backdrop click to close modal
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
return (
<div
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
onClick={handleBackdropClick}
>
<div className="bg-bambu-dark-secondary rounded-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-3">
<FileText className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">
Project Page
{archiveName && <span className="text-bambu-gray ml-2">- {archiveName}</span>}
</h2>
</div>
<div className="flex items-center gap-2">
{!isEditing && hasContent && (
<Button variant="ghost" size="sm" onClick={handleStartEdit}>
<Edit3 className="w-4 h-4 mr-1" />
Edit
</Button>
)}
{isEditing && (
<>
<Button variant="ghost" size="sm" onClick={handleCancelEdit}>
Cancel
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSave}
disabled={updateMutation.isPending}
>
<Save className="w-4 h-4 mr-1" />
Save
</Button>
</>
)}
<button
onClick={onClose}
className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
>
<X className="w-5 h-5 text-bambu-gray" />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{isLoading && (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-bambu-green border-t-transparent" />
</div>
)}
{error && (
<div className="text-red-400 text-center py-12">
Failed to load project page data
</div>
)}
{projectPage && !hasContent && (
<div className="text-bambu-gray text-center py-12">
<FileText className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No project page data found in this 3MF file.</p>
<p className="text-sm mt-2">
Project pages are typically included in files downloaded from MakerWorld.
</p>
</div>
)}
{projectPage && hasContent && (
<div className="space-y-6">
{/* Title & Designer */}
<div className="space-y-4">
{isEditing ? (
<input
type="text"
value={editData.title || ''}
onChange={(e) => setEditData({ ...editData, title: e.target.value })}
placeholder="Title"
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded-lg px-4 py-2 text-white text-xl font-semibold"
/>
) : (
projectPage.title && (
<h3 className="text-xl font-semibold text-white">{projectPage.title}</h3>
)
)}
<div className="flex flex-wrap gap-4 text-sm">
{isEditing ? (
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-bambu-gray" />
<input
type="text"
value={editData.designer || ''}
onChange={(e) => setEditData({ ...editData, designer: e.target.value })}
placeholder="Designer"
className="bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1 text-white"
/>
</div>
) : (
projectPage.designer && (
<div className="flex items-center gap-2 text-bambu-gray">
<User className="w-4 h-4" />
<span>{projectPage.designer}</span>
{projectPage.designer_user_id && (
<a
href={`https://makerworld.com/en/@${projectPage.designer_user_id}`}
target="_blank"
rel="noopener noreferrer"
className="text-bambu-green hover:underline"
>
<ExternalLink className="w-3 h-3" />
</a>
)}
</div>
)
)}
{projectPage.creation_date && (
<div className="flex items-center gap-2 text-bambu-gray">
<Calendar className="w-4 h-4" />
<span>{projectPage.creation_date}</span>
</div>
)}
{isEditing ? (
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-bambu-gray" />
<input
type="text"
value={editData.license || ''}
onChange={(e) => setEditData({ ...editData, license: e.target.value })}
placeholder="License"
className="bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1 text-white"
/>
</div>
) : (
projectPage.license && (
<div className="flex items-center gap-2 text-bambu-gray">
<FileText className="w-4 h-4" />
<span>{projectPage.license}</span>
</div>
)
)}
{projectPage.origin && (
<span className="px-2 py-0.5 bg-bambu-dark rounded text-bambu-gray">
{projectPage.origin}
</span>
)}
</div>
</div>
{/* Description */}
{(projectPage.description || isEditing) && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-bambu-gray uppercase tracking-wide">
Description
</h4>
{isEditing ? (
<RichTextEditor
content={editData.description || ''}
onChange={(html) => setEditData({ ...editData, description: html })}
placeholder="Enter description..."
/>
) : (
<div
className="prose prose-invert prose-sm max-w-none text-bambu-gray-light"
dangerouslySetInnerHTML={{
__html: sanitizeHtml(projectPage.description || ''),
}}
/>
)}
</div>
)}
{/* Profile Info */}
{(projectPage.profile_title || projectPage.profile_description || isEditing) && (
<div className="space-y-2 p-4 bg-bambu-dark rounded-lg">
<h4 className="text-sm font-medium text-bambu-gray uppercase tracking-wide">
Print Profile
</h4>
{isEditing ? (
<div className="space-y-2">
<input
type="text"
value={editData.profile_title || ''}
onChange={(e) => setEditData({ ...editData, profile_title: e.target.value })}
placeholder="Profile Title"
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-white"
/>
<RichTextEditor
content={editData.profile_description || ''}
onChange={(html) => setEditData({ ...editData, profile_description: html })}
placeholder="Profile description..."
/>
</div>
) : (
<>
{projectPage.profile_title && (
<p className="text-white font-medium">{projectPage.profile_title}</p>
)}
{projectPage.profile_description && (
<div
className="prose prose-invert prose-sm max-w-none text-bambu-gray-light"
dangerouslySetInnerHTML={{
__html: sanitizeHtml(projectPage.profile_description),
}}
/>
)}
{projectPage.profile_user_name && (
<p className="text-sm text-bambu-gray">
by {projectPage.profile_user_name}
</p>
)}
</>
)}
</div>
)}
{/* Image Gallery */}
{allImages.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-bambu-gray uppercase tracking-wide flex items-center gap-2">
<Image className="w-4 h-4" />
Images ({allImages.length})
</h4>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-2">
{allImages.map((img, index) => (
<button
key={img.path}
onClick={() => setSelectedImageIndex(index)}
className="aspect-square rounded-lg overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
>
<img
src={img.url}
alt={img.name}
className="w-full h-full object-cover"
/>
</button>
))}
</div>
</div>
)}
{/* MakerWorld Link */}
{projectPage.design_model_id && (
<div className="pt-4 border-t border-bambu-dark-tertiary">
<a
href={`https://makerworld.com/en/models/${projectPage.design_model_id}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-bambu-green hover:underline"
>
<ExternalLink className="w-4 h-4" />
View on MakerWorld
</a>
</div>
)}
</div>
)}
</div>
</div>
{/* Image Lightbox */}
{selectedImageIndex !== null && allImages[selectedImageIndex] && (
<div
className="fixed inset-0 bg-black/90 flex items-center justify-center z-60"
onClick={() => setSelectedImageIndex(null)}
>
<button
onClick={(e) => {
e.stopPropagation();
setSelectedImageIndex(Math.max(0, selectedImageIndex - 1));
}}
disabled={selectedImageIndex === 0}
className="absolute left-4 p-2 bg-bambu-dark-secondary rounded-full hover:bg-bambu-dark-tertiary disabled:opacity-30"
>
<ChevronLeft className="w-6 h-6 text-white" />
</button>
<img
src={allImages[selectedImageIndex].url}
alt={allImages[selectedImageIndex].name}
className="max-w-[90vw] max-h-[90vh] object-contain"
onClick={(e) => e.stopPropagation()}
/>
<button
onClick={(e) => {
e.stopPropagation();
setSelectedImageIndex(Math.min(allImages.length - 1, selectedImageIndex + 1));
}}
disabled={selectedImageIndex === allImages.length - 1}
className="absolute right-4 p-2 bg-bambu-dark-secondary rounded-full hover:bg-bambu-dark-tertiary disabled:opacity-30"
>
<ChevronRight className="w-6 h-6 text-white" />
</button>
<button
onClick={() => setSelectedImageIndex(null)}
className="absolute top-4 right-4 p-2 bg-bambu-dark-secondary rounded-full hover:bg-bambu-dark-tertiary"
>
<X className="w-6 h-6 text-white" />
</button>
<div className="absolute bottom-4 text-white text-sm">
{selectedImageIndex + 1} / {allImages.length}
</div>
</div>
)}
</div>
);
}
+191
View File
@@ -0,0 +1,191 @@
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Underline from '@tiptap/extension-underline';
import TextAlign from '@tiptap/extension-text-align';
import { TextStyle } from '@tiptap/extension-text-style';
import Color from '@tiptap/extension-color';
import Image from '@tiptap/extension-image';
import {
Bold,
Italic,
Underline as UnderlineIcon,
List,
ListOrdered,
AlignLeft,
AlignCenter,
AlignRight,
Link as LinkIcon,
Unlink,
} from 'lucide-react';
interface RichTextEditorProps {
content: string;
onChange: (html: string) => void;
placeholder?: string;
}
export function RichTextEditor({ content, onChange, placeholder }: RichTextEditorProps) {
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: false,
codeBlock: false,
code: false,
}),
Underline,
Link.configure({
openOnClick: false,
HTMLAttributes: {
target: '_blank',
rel: 'noopener noreferrer',
},
}),
TextAlign.configure({
types: ['paragraph'],
}),
TextStyle,
Color,
Image.configure({
HTMLAttributes: {
style: 'max-width: 100%; height: auto;',
},
}),
],
content,
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},
editorProps: {
attributes: {
class: 'prose prose-invert prose-sm max-w-none focus:outline-none min-h-[120px] px-3 py-2',
placeholder: placeholder || '',
},
},
});
if (!editor) {
return null;
}
const ToolbarButton = ({
onClick,
isActive = false,
children,
title,
}: {
onClick: () => void;
isActive?: boolean;
children: React.ReactNode;
title: string;
}) => (
<button
type="button"
onClick={onClick}
title={title}
className={`p-1.5 rounded hover:bg-bambu-dark-tertiary transition-colors ${
isActive ? 'bg-bambu-dark-tertiary text-bambu-green' : 'text-bambu-gray'
}`}
>
{children}
</button>
);
const setLink = () => {
const url = window.prompt('Enter URL:');
if (url) {
editor.chain().focus().setLink({ href: url }).run();
}
};
return (
<div className="border border-bambu-dark-tertiary rounded-lg overflow-hidden bg-bambu-dark">
{/* Toolbar */}
<div className="flex items-center gap-0.5 p-1.5 border-b border-bambu-dark-tertiary bg-bambu-dark-secondary">
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
title="Bold"
>
<Bold className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
title="Italic"
>
<Italic className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleUnderline().run()}
isActive={editor.isActive('underline')}
title="Underline"
>
<UnderlineIcon className="w-4 h-4" />
</ToolbarButton>
<div className="w-px h-5 bg-bambu-dark-tertiary mx-1" />
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive('bulletList')}
title="Bullet List"
>
<List className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive('orderedList')}
title="Numbered List"
>
<ListOrdered className="w-4 h-4" />
</ToolbarButton>
<div className="w-px h-5 bg-bambu-dark-tertiary mx-1" />
<ToolbarButton
onClick={() => editor.chain().focus().setTextAlign('left').run()}
isActive={editor.isActive({ textAlign: 'left' })}
title="Align Left"
>
<AlignLeft className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setTextAlign('center').run()}
isActive={editor.isActive({ textAlign: 'center' })}
title="Align Center"
>
<AlignCenter className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setTextAlign('right').run()}
isActive={editor.isActive({ textAlign: 'right' })}
title="Align Right"
>
<AlignRight className="w-4 h-4" />
</ToolbarButton>
<div className="w-px h-5 bg-bambu-dark-tertiary mx-1" />
<ToolbarButton
onClick={setLink}
isActive={editor.isActive('link')}
title="Add Link"
>
<LinkIcon className="w-4 h-4" />
</ToolbarButton>
{editor.isActive('link') && (
<ToolbarButton
onClick={() => editor.chain().focus().unsetLink().run()}
title="Remove Link"
>
<Unlink className="w-4 h-4" />
</ToolbarButton>
)}
</div>
{/* Editor */}
<EditorContent editor={editor} />
</div>
);
}
+17
View File
@@ -33,6 +33,7 @@ import {
ScanSearch,
QrCode,
Camera,
FileText,
} from 'lucide-react';
import { api } from '../api/client';
import type { Archive } from '../api/client';
@@ -48,6 +49,7 @@ import { BatchTagModal } from '../components/BatchTagModal';
import { CalendarView } from '../components/CalendarView';
import { QRCodeModal } from '../components/QRCodeModal';
import { PhotoGalleryModal } from '../components/PhotoGalleryModal';
import { ProjectPageModal } from '../components/ProjectPageModal';
import { useToast } from '../contexts/ToastContext';
function formatFileSize(bytes: number): string {
@@ -95,6 +97,7 @@ function ArchiveCard({
const [showTimelapse, setShowTimelapse] = useState(false);
const [showQRCode, setShowQRCode] = useState(false);
const [showPhotos, setShowPhotos] = useState(false);
const [showProjectPage, setShowProjectPage] = useState(false);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const timelapseScanMutation = useMutation({
@@ -210,6 +213,11 @@ function ArchiveCard({
onClick: () => setShowPhotos(true),
disabled: !archive.photos?.length,
},
{
label: 'Project Page',
icon: <FileText className="w-4 h-4" />,
onClick: () => setShowProjectPage(true),
},
{ label: '', divider: true, onClick: () => {} },
{
label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites',
@@ -598,6 +606,15 @@ function ArchiveCard({
}}
/>
)}
{/* Project Page Modal */}
{showProjectPage && (
<ProjectPageModal
archiveId={archive.id}
archiveName={archive.print_name || archive.filename}
onClose={() => setShowProjectPage(false)}
/>
)}
</Card>
);
}