feat(projects): URL field + cover photo on project cards (#1155)

Two new project fields: a free-text URL rendered as a one-click
  external-link button beside the project name on every card (opens in a
  new tab, click is e.stopPropagation()-guarded so it doesn't enter the
  project), and a cover photo that replaces the status-icon box with a
  square thumbnail.

  URL is plumbed through ProjectCreate/Update/Response/ListResponse,
  including from-template + create-template flows so it inherits between
  a project and its template. Cover photo is not inherited because the
  file would be shared on disk between source and copy.

  Schema validator rejects anything other than http:// or https://
  prefixes -- <a href> rendering would otherwise execute javascript:
  / data: / file: URLs even with React's default escaping. PATCH uses
  model_fields_set for the URL field so users can clear it by sending
  {"url": null}.

  Cover image storage: Project.cover_image_filename references a file
  Cover image storage: Project.cover_image_filename references a file
  inside the existing archives/projects/{id}/attachments/ dir, but it's
  tracked separately from the attachments JSON list so swap/delete on
  the cover doesn't perturb the user's other attachments. Three routes
  (POST/GET/DELETE /projects/{id}/cover-image) accept only .jpg/.jpeg/
  .png/.gif/.webp (no SVG -- SVG can carry script payloads), replace in
  place (prior file deleted before the new one lands so repeat uploads
  can't accumulate orphans), and self-heal when a DB reference points at
  a vanished disk file by clearing the column and 404'ing.

  GET cover-image is gated by RequireCameraStreamTokenIfAuthEnabled
  (accepts ?token=... query string) -- not the bearer-token gate -- so
  <img src> requests work in both auth-on and auth-off configurations.
  The frontend wraps getProjectCoverImageUrl with withStreamToken(),
  matching the existing pattern from getArchiveThumbnail.

  Permissions: PROJECTS_UPDATE for upload/delete/PATCH, PROJECTS_READ
  gate is implicit via the stream-token credential. Migration: 2
  idempotent ALTER TABLE projects ADD COLUMN. Localised across all 8
  UI languages.
This commit is contained in:
maziggy
2026-04-29 07:42:09 +02:00
parent b6a9d56651
commit 57af8a1c19
22 changed files with 876 additions and 127 deletions
+33 -1
View File
@@ -5,7 +5,7 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { setAuthToken, getAuthToken, api } from '../../api/client';
import { setAuthToken, getAuthToken, api, setStreamToken } from '../../api/client';
// Mock sessionStorage (H-5: tokens are stored in sessionStorage, not localStorage)
const sessionStorageMock = {
@@ -297,3 +297,35 @@ describe('Printer control endpoints', () => {
expect(capturedUrl).toContain('mode=heating');
});
});
// #1155 — `<img src>` can't carry an `Authorization: Bearer …` header, so the
// project cover-image URL must use the same stream-token pattern as
// /archives/{id}/thumbnail. A regression where `withStreamToken` is removed
// would break the modal preview AND the card thumbnail when auth is enabled.
describe('Project cover image URL (#1155)', () => {
afterEach(() => {
setStreamToken(null);
});
it('appends the stream token query string when one is set', () => {
setStreamToken('abc123');
const url = api.getProjectCoverImageUrl(42);
expect(url).toContain('/projects/42/cover-image');
expect(url).toContain('token=abc123');
});
it('returns the bare URL when no stream token is set', () => {
setStreamToken(null);
const url = api.getProjectCoverImageUrl(42);
expect(url).toContain('/projects/42/cover-image');
expect(url).not.toContain('token=');
});
it('URL-encodes a token containing query-string-unsafe characters', () => {
setStreamToken('a&b=c');
const url = api.getProjectCoverImageUrl(7);
// Decoded back, the token must round-trip exactly.
const params = new URL(url, 'http://x').searchParams;
expect(params.get('token')).toBe('a&b=c');
});
});
@@ -148,4 +148,87 @@ describe('ProjectsPage', () => {
});
});
});
// #1155 — URL link icon + cover image thumbnail on project cards.
describe('URL link and cover image (#1155)', () => {
it('renders an external-link icon next to the project name when URL is set', async () => {
server.use(
http.get('/api/v1/projects/', () =>
HttpResponse.json([
{
...mockProjects[0],
url: 'https://makerworld.com/models/12345',
cover_image_filename: null,
},
])
)
);
render(<ProjectsPage />);
const link = await screen.findByLabelText(/Open project URL/i);
expect(link).toBeInTheDocument();
expect(link.getAttribute('href')).toBe('https://makerworld.com/models/12345');
expect(link.getAttribute('target')).toBe('_blank');
expect(link.getAttribute('rel')).toContain('noopener');
});
it('does not render the link icon when URL is not set', async () => {
// Default fixture has no `url` field — verify the icon is absent.
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText('Functional Parts')).toBeInTheDocument();
});
expect(screen.queryByLabelText(/Open project URL/i)).not.toBeInTheDocument();
});
it('clicking the URL link does not bubble to the card onClick', async () => {
server.use(
http.get('/api/v1/projects/', () =>
HttpResponse.json([
{
...mockProjects[0],
url: 'https://example.com',
cover_image_filename: null,
},
])
)
);
const user = userEvent.setup();
render(<ProjectsPage />);
const link = await screen.findByLabelText(/Open project URL/i);
// Prevent the underlying anchor from triggering jsdom navigation noise
// — we only need the propagation guard verified.
link.addEventListener('click', (e) => e.preventDefault(), { once: true });
await user.click(link);
// No navigate / detail-page transition should have happened. Card root
// is still rendered.
expect(screen.getByText('Functional Parts')).toBeInTheDocument();
});
it('renders a cover image thumbnail when cover_image_filename is set', async () => {
server.use(
http.get('/api/v1/projects/', () =>
HttpResponse.json([
{
...mockProjects[0],
url: null,
cover_image_filename: 'cover_abc.png',
},
])
)
);
render(<ProjectsPage />);
const img = await screen.findByAltText(/Project cover photo/i);
expect(img).toBeInTheDocument();
// Card thumbnail uses the GET endpoint URL, project.id is 1.
expect(img.getAttribute('src')).toContain('/projects/1/cover-image');
});
});
});
+35
View File
@@ -647,6 +647,8 @@ export interface Project {
created_at: string;
updated_at: string;
stats?: ProjectStats;
url: string | null; // External link rendered next to project name on the card (#1155)
cover_image_filename: string | null; // Filename within project attachments dir (#1155)
}
export interface ProjectAttachment {
@@ -682,6 +684,8 @@ export interface ProjectListItem {
queue_count: number;
progress_percent: number | null; // Plates progress
archives: ArchivePreview[];
url: string | null; // #1155
cover_image_filename: string | null; // #1155
}
export interface ProjectCreate {
@@ -696,6 +700,7 @@ export interface ProjectCreate {
priority?: string;
budget?: number | null;
parent_id?: number;
url?: string | null; // #1155
}
export interface ProjectUpdate {
@@ -711,6 +716,7 @@ export interface ProjectUpdate {
priority?: string;
budget?: number | null;
parent_id?: number;
url?: string | null; // #1155 — explicit null clears the URL
}
// BOM Types - Tracks sourced/purchased parts (hardware, electronics, etc.)
@@ -4615,6 +4621,35 @@ export const api = {
{ method: 'DELETE' }
),
// #1155: Cover image
// Browsers can't attach `Authorization: Bearer ...` to `<img src>`, so we
// append the stream-token query string the same way archive thumbnails do.
getProjectCoverImageUrl: (projectId: number) =>
withStreamToken(`${API_BASE}/projects/${projectId}/cover-image`),
uploadProjectCoverImage: async (
projectId: number,
file: File
): Promise<{ status: string; filename: string; size: number }> => {
const formData = new FormData();
formData.append('file', file);
const headers: Record<string, string> = {};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${API_BASE}/projects/${projectId}/cover-image`, {
method: 'POST',
headers,
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
},
deleteProjectCoverImage: (projectId: number) =>
request<{ status: string }>(`/projects/${projectId}/cover-image`, { method: 'DELETE' }),
// BOM (Bill of Materials)
getProjectBOM: (projectId: number) =>
request<BOMItem[]>(`/projects/${projectId}/bom`),
+9
View File
@@ -3034,6 +3034,15 @@ export default {
// Modal fields
namePlaceholder: 'z.B. Voron 2.4 Build',
descriptionPlaceholder: 'Optionale Beschreibung...',
urlLabel: 'URL',
urlPlaceholder: 'https://makerworld.com/...',
urlInvalid: 'URL muss mit http:// oder https:// beginnen',
openExternalUrl: 'Projekt-URL öffnen',
coverImageLabel: 'Titelbild',
coverImageAlt: 'Projekt-Titelbild',
coverImageUpload: 'Hochladen',
coverImageReplace: 'Ersetzen',
coverImageRemove: 'Entfernen',
color: 'Farbe',
targetPlates: 'Ziel-Platten',
targetPlatesPlaceholder: 'z.B. 25',
+9
View File
@@ -3037,6 +3037,15 @@ export default {
// Modal fields
namePlaceholder: 'e.g., Voron 2.4 Build',
descriptionPlaceholder: 'Optional description...',
urlLabel: 'URL',
urlPlaceholder: 'https://makerworld.com/...',
urlInvalid: 'URL must start with http:// or https://',
openExternalUrl: 'Open project URL',
coverImageLabel: 'Cover photo',
coverImageAlt: 'Project cover photo',
coverImageUpload: 'Upload',
coverImageReplace: 'Replace',
coverImageRemove: 'Remove',
color: 'Color',
targetPlates: 'Target Plates',
targetPlatesPlaceholder: 'e.g., 25',
+9
View File
@@ -2956,6 +2956,15 @@ export default {
// Modal fields
namePlaceholder: 'ex: Build Voron 2.4',
descriptionPlaceholder: 'Description optionnelle...',
urlLabel: 'URL',
urlPlaceholder: 'https://makerworld.com/...',
urlInvalid: "L'URL doit commencer par http:// ou https://",
openExternalUrl: 'Ouvrir l’URL du projet',
coverImageLabel: 'Photo de couverture',
coverImageAlt: 'Photo de couverture du projet',
coverImageUpload: 'Téléverser',
coverImageReplace: 'Remplacer',
coverImageRemove: 'Supprimer',
color: 'Couleur',
targetPlates: 'Plateaux cibles',
targetPlatesPlaceholder: 'ex: 25',
+9
View File
@@ -2955,6 +2955,15 @@ export default {
// Modal fields
namePlaceholder: 'es., Build Voron 2.4',
descriptionPlaceholder: 'Descrizione opzionale...',
urlLabel: 'URL',
urlPlaceholder: 'https://makerworld.com/...',
urlInvalid: "L'URL deve iniziare con http:// o https://",
openExternalUrl: 'Apri URL del progetto',
coverImageLabel: 'Immagine di copertina',
coverImageAlt: 'Immagine di copertina del progetto',
coverImageUpload: 'Carica',
coverImageReplace: 'Sostituisci',
coverImageRemove: 'Rimuovi',
color: 'Colore',
targetPlates: 'Piatti target',
targetPlatesPlaceholder: 'es., 25',
+9
View File
@@ -2994,6 +2994,15 @@ export default {
// Modal fields
namePlaceholder: 'プロジェクト名',
descriptionPlaceholder: 'プロジェクトの説明(任意)',
urlLabel: 'URL',
urlPlaceholder: 'https://makerworld.com/...',
urlInvalid: 'URLはhttp://またはhttps://で始まる必要があります',
openExternalUrl: 'プロジェクトのURLを開く',
coverImageLabel: 'カバー画像',
coverImageAlt: 'プロジェクトのカバー画像',
coverImageUpload: 'アップロード',
coverImageReplace: '置き換える',
coverImageRemove: '削除',
color: '色',
targetPlates: '目標プレート数',
targetPlatesPlaceholder: '例: 10',
+9
View File
@@ -2969,6 +2969,15 @@ export default {
// Modal fields
namePlaceholder: 'ex., Voron 2.4 Build',
descriptionPlaceholder: 'Descrição opcional...',
urlLabel: 'URL',
urlPlaceholder: 'https://makerworld.com/...',
urlInvalid: 'A URL deve começar com http:// ou https://',
openExternalUrl: 'Abrir URL do projeto',
coverImageLabel: 'Imagem de capa',
coverImageAlt: 'Imagem de capa do projeto',
coverImageUpload: 'Enviar',
coverImageReplace: 'Substituir',
coverImageRemove: 'Remover',
color: 'Cor',
targetPlates: 'Placas Alvo',
targetPlatesPlaceholder: 'ex., 25',
+9
View File
@@ -3021,6 +3021,15 @@ export default {
// Modal fields
namePlaceholder: '例如:Voron 2.4 构建',
descriptionPlaceholder: '可选描述...',
urlLabel: '网址',
urlPlaceholder: 'https://makerworld.com/...',
urlInvalid: '网址必须以 http:// 或 https:// 开头',
openExternalUrl: '打开项目网址',
coverImageLabel: '封面图片',
coverImageAlt: '项目封面图片',
coverImageUpload: '上传',
coverImageReplace: '替换',
coverImageRemove: '移除',
color: '颜色',
targetPlates: '目标板数',
targetPlatesPlaceholder: '例如:25',
+9
View File
@@ -3020,6 +3020,15 @@ export default {
viewDetails: '檢視詳情',
// Modal fields
namePlaceholder: '例如:Voron 2.4 構建',
urlLabel: '網址',
urlPlaceholder: 'https://makerworld.com/...',
urlInvalid: '網址必須以 http:// 或 https:// 開頭',
openExternalUrl: '開啟專案網址',
coverImageLabel: '封面圖片',
coverImageAlt: '專案封面圖片',
coverImageUpload: '上傳',
coverImageReplace: '替換',
coverImageRemove: '移除',
descriptionPlaceholder: '可選描述...',
color: '顏色',
targetPlates: '目標板數',
+154 -3
View File
@@ -19,6 +19,9 @@ import {
MoreVertical,
Download,
Upload,
ExternalLink,
Image as ImageIcon,
X,
} from 'lucide-react';
import { api } from '../api/client';
import type { ProjectListItem, ProjectCreate, ProjectUpdate, ProjectImport, Permission } from '../api/client';
@@ -62,9 +65,54 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
const [dueDate, setDueDate] = useState((project as ProjectListItem & { due_date?: string })?.due_date?.split('T')[0] || '');
const [priority, setPriority] = useState((project as ProjectListItem & { priority?: string })?.priority || 'normal');
const [budget, setBudget] = useState(project?.budget?.toString() || '');
const [url, setUrl] = useState(project?.url || '');
const [urlError, setUrlError] = useState<string | null>(null);
const queryClient = useQueryClient();
const [coverImageFilename, setCoverImageFilename] = useState(project?.cover_image_filename || null);
const coverFileInputRef = useRef<HTMLInputElement>(null);
const [coverUploading, setCoverUploading] = useState(false);
// Cache-bust the cover image URL when it changes mid-edit so the preview
// refreshes after upload/remove.
const [coverCacheKey, setCoverCacheKey] = useState(0);
const handleCoverFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !project) return;
setCoverUploading(true);
try {
const result = await api.uploadProjectCoverImage(project.id, file);
setCoverImageFilename(result.filename);
setCoverCacheKey((k) => k + 1);
queryClient.invalidateQueries({ queryKey: ['projects'] });
} catch {
// Upload failed — leave existing cover image in place.
} finally {
setCoverUploading(false);
if (coverFileInputRef.current) coverFileInputRef.current.value = '';
}
};
const handleRemoveCover = async () => {
if (!project) return;
setCoverUploading(true);
try {
await api.deleteProjectCoverImage(project.id);
setCoverImageFilename(null);
setCoverCacheKey((k) => k + 1);
queryClient.invalidateQueries({ queryKey: ['projects'] });
} finally {
setCoverUploading(false);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmedUrl = url.trim();
if (trimmedUrl && !/^https?:\/\//i.test(trimmedUrl)) {
setUrlError(t('projects.urlInvalid'));
return;
}
setUrlError(null);
onSave({
name: name.trim(),
description: description.trim() || undefined,
@@ -75,6 +123,10 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
due_date: dueDate || undefined,
priority,
budget: budget.trim() ? parseFloat(budget) : null,
// Pydantic accepts null to clear the URL; an empty string would fail the
// http(s) prefix validator. Use undefined for create (omit) and null for
// edit-with-cleared-value.
url: project ? (trimmedUrl || null) : (trimmedUrl || undefined),
...(project && { status }),
});
};
@@ -116,6 +168,80 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
/>
</div>
{/* #1155: External URL */}
<div>
<label className="block text-sm font-medium text-white mb-1">
{t('projects.urlLabel')}
</label>
<input
type="url"
value={url}
onChange={(e) => { setUrl(e.target.value); if (urlError) setUrlError(null); }}
className={`w-full bg-bambu-dark border rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none ${
urlError ? 'border-red-500 focus:border-red-500' : 'border-bambu-dark-tertiary focus:border-bambu-green'
}`}
placeholder={t('projects.urlPlaceholder')}
maxLength={2048}
/>
{urlError && <p className="text-xs text-red-400 mt-1">{urlError}</p>}
</div>
{/* #1155: Cover image — only available when editing an existing project,
since uploading needs a project_id. New projects can add it after save. */}
{project && (
<div>
<label className="block text-sm font-medium text-white mb-1">
{t('projects.coverImageLabel')}
</label>
<div className="flex items-center gap-3">
<div className="w-20 h-20 rounded bg-bambu-dark border border-bambu-dark-tertiary overflow-hidden flex items-center justify-center flex-shrink-0">
{coverImageFilename ? (
<img
src={`${api.getProjectCoverImageUrl(project.id)}?v=${coverCacheKey}`}
alt={t('projects.coverImageAlt')}
className="w-full h-full object-cover"
/>
) : (
<ImageIcon className="w-6 h-6 text-bambu-gray" />
)}
</div>
<div className="flex flex-col gap-2">
<input
ref={coverFileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={handleCoverFileChange}
className="hidden"
/>
<Button
type="button"
variant="secondary"
onClick={() => coverFileInputRef.current?.click()}
disabled={coverUploading}
>
{coverUploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Upload className="w-4 h-4 mr-1" />
)}
{coverImageFilename ? t('projects.coverImageReplace') : t('projects.coverImageUpload')}
</Button>
{coverImageFilename && (
<Button
type="button"
variant="secondary"
onClick={handleRemoveCover}
disabled={coverUploading}
>
<X className="w-4 h-4 mr-1" />
{t('projects.coverImageRemove')}
</Button>
)}
</div>
</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-white mb-1">
{t('projects.color')}
@@ -317,12 +443,37 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission, t }: P
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className={`p-2 rounded-lg ${statusConfig.bg} flex-shrink-0`}>
<statusConfig.icon className={`w-5 h-5 ${statusConfig.color}`} />
</div>
{project.cover_image_filename ? (
// #1155: cover photo replaces the status-icon box
<div className="w-10 h-10 rounded-lg overflow-hidden flex-shrink-0 bg-bambu-dark border border-bambu-dark-tertiary">
<img
src={api.getProjectCoverImageUrl(project.id)}
alt={t('projects.coverImageAlt')}
className="w-full h-full object-cover"
loading="lazy"
/>
</div>
) : (
<div className={`p-2 rounded-lg ${statusConfig.bg} flex-shrink-0`}>
<statusConfig.icon className={`w-5 h-5 ${statusConfig.color}`} />
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-white truncate">{project.name}</h3>
{project.url && (
<a
href={project.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
title={project.url}
aria-label={t('projects.openExternalUrl')}
className="inline-flex items-center justify-center w-6 h-6 rounded bg-bambu-dark border border-bambu-dark-tertiary text-bambu-green hover:bg-bambu-green/10 hover:border-bambu-green transition-colors flex-shrink-0"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
{project.target_parts_count ? (
<span className={`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${
partsProgressPercent >= 100