mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-06 14:11:35 +02:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '目標板數',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user