feat: per-task image/annotations, annotation editor toolbar, Tailwind compiled
- Add per-task file upload with image preview in task editor - Add dedicated annotation editor page (task_drawing.html) with Fabric.js - Add color picker, stroke width, and line dash controls to annotation toolbar - Apply property changes to selected objects in real-time - Disable style controls until a drawing tool or object is selected - Remove zoom/pan from annotation toolbar (simplified UX) - Auto-switch to select mode after placing annotation elements - Show annotation overlay on task image previews (read-only canvas) - Add file proxy route in measure blueprint for task file access - Add file_path/file_type fields to TaskCreate/TaskUpdate Pydantic schemas - Replace Tailwind CDN with compiled CSS (tailwind.config.js with full shades) - Fix Alpine.js x-init crash: extract annotations JSON to <script> tags (recipe_preview.html, task_execute.html) to avoid HTML attribute breakage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -432,6 +432,59 @@
|
||||
|
||||
<div class="tmf-card-body space-y-4">
|
||||
|
||||
<!-- ========== Task File Upload / Preview ========== -->
|
||||
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
|
||||
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
|
||||
{{ _('Disegno Tecnico') }}
|
||||
</p>
|
||||
|
||||
<!-- Preview if file exists -->
|
||||
<template x-if="task.file_path">
|
||||
<div class="space-y-2">
|
||||
<div class="relative rounded-lg overflow-hidden bg-[var(--bg-secondary)] border border-[var(--border-color)]">
|
||||
<canvas :id="'preview-' + task.id"
|
||||
class="block mx-auto"
|
||||
style="max-height: 192px;">
|
||||
</canvas>
|
||||
</div>
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<a :href="'/maker/recipes/' + recipeId + '/tasks/' + task.id + '/drawing'"
|
||||
class="btn btn-secondary text-xs gap-1.5">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
{{ _('Modifica Disegno') }}
|
||||
</a>
|
||||
<label class="btn btn-secondary text-xs gap-1.5 cursor-pointer">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
|
||||
</svg>
|
||||
{{ _('Sostituisci') }}
|
||||
<input type="file"
|
||||
accept="image/*,application/pdf"
|
||||
class="hidden"
|
||||
@change="uploadTaskFile(task.id, $event.target.files[0], task); $event.target.value = ''">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Upload zone if no file -->
|
||||
<template x-if="!task.file_path">
|
||||
<label class="flex flex-col items-center gap-2 p-4 rounded-lg border-2 border-dashed border-[var(--border-color)] hover:border-primary/50 cursor-pointer transition-colors">
|
||||
<svg class="w-8 h-8 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/>
|
||||
</svg>
|
||||
<span class="text-xs text-[var(--text-secondary)]">{{ _('Carica immagine o PDF') }}</span>
|
||||
<input type="file"
|
||||
accept="image/*,application/pdf"
|
||||
class="hidden"
|
||||
@change="uploadTaskFile(task.id, $event.target.files[0], task); $event.target.value = ''">
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Task Directive / Description (read-only display) -->
|
||||
<template x-if="task.directive || task.description">
|
||||
<div class="flex flex-col gap-2 p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
|
||||
@@ -1020,6 +1073,9 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';</script>
|
||||
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=3"></script>
|
||||
<script>
|
||||
function taskEditor() {
|
||||
return {
|
||||
@@ -1073,6 +1129,7 @@ function taskEditor() {
|
||||
// Auto-expand the first task if there is only one
|
||||
if (this.tasks.length === 1) {
|
||||
this.expandedTask = this.tasks[0].id;
|
||||
this.renderTaskPreview(this.tasks[0]);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1083,11 +1140,45 @@ function taskEditor() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
// Render Task Preview (deferred)
|
||||
// ============================================================
|
||||
renderTaskPreview(task) {
|
||||
if (!task || !task.file_path) return;
|
||||
if (typeof renderAnnotationPreview !== 'function') {
|
||||
// Fallback: show image via <img> tag if viewer script not loaded
|
||||
this.$nextTick(() => {
|
||||
const canvas = document.getElementById('preview-' + task.id);
|
||||
if (canvas && canvas.parentElement) {
|
||||
const img = document.createElement('img');
|
||||
img.src = '/maker/api/files/' + task.file_path;
|
||||
img.className = 'block mx-auto max-h-48 object-contain';
|
||||
img.loading = 'lazy';
|
||||
canvas.parentElement.replaceChild(img, canvas);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
setTimeout(() => {
|
||||
const canvas = document.getElementById('preview-' + task.id);
|
||||
if (canvas) {
|
||||
renderAnnotationPreview(canvas, '/maker/api/files/' + task.file_path, task.annotations_json);
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
// Toggle Expand
|
||||
// ============================================================
|
||||
toggleExpand(taskId) {
|
||||
this.expandedTask = this.expandedTask === taskId ? null : taskId;
|
||||
// Render preview when expanding
|
||||
if (this.expandedTask === taskId) {
|
||||
const task = this.tasks.find(t => t.id === taskId);
|
||||
if (task) this.renderTaskPreview(task);
|
||||
}
|
||||
// Reset inline editors when collapsing
|
||||
if (this.expandedTask !== taskId) {
|
||||
this.editingSubtask = null;
|
||||
@@ -1125,7 +1216,7 @@ function taskEditor() {
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.error) {
|
||||
this.errorMessage = data.detail || '{{ _("Errore nella creazione del task") }}';
|
||||
this.errorMessage = data.detail || {{ _("Errore nella creazione del task")|tojson }};
|
||||
} else {
|
||||
// Ensure subtasks array exists
|
||||
if (!data.subtasks) data.subtasks = [];
|
||||
@@ -1133,11 +1224,11 @@ function taskEditor() {
|
||||
this.resetNewTask();
|
||||
this.showAddTask = false;
|
||||
this.expandedTask = data.id;
|
||||
this.successMessage = '{{ _("Task creato con successo") }}';
|
||||
this.successMessage = {{ _("Task creato con successo")|tojson }};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('addTask error:', err);
|
||||
this.errorMessage = '{{ _("Errore di connessione al server") }}';
|
||||
this.errorMessage = {{ _("Errore di connessione al server")|tojson }};
|
||||
}
|
||||
|
||||
this.saving = false;
|
||||
@@ -1181,7 +1272,7 @@ function taskEditor() {
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.error) {
|
||||
this.errorMessage = data.detail || '{{ _("Errore nel salvataggio del task") }}';
|
||||
this.errorMessage = data.detail || {{ _("Errore nel salvataggio del task")|tojson }};
|
||||
} else {
|
||||
// Update local state
|
||||
const idx = this.tasks.findIndex(t => t.id === taskId);
|
||||
@@ -1194,11 +1285,11 @@ function taskEditor() {
|
||||
}
|
||||
}
|
||||
this.cancelEditTask();
|
||||
this.successMessage = '{{ _("Task aggiornato") }}';
|
||||
this.successMessage = {{ _("Task aggiornato")|tojson }};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('updateTask error:', err);
|
||||
this.errorMessage = '{{ _("Errore di connessione al server") }}';
|
||||
this.errorMessage = {{ _("Errore di connessione al server")|tojson }};
|
||||
}
|
||||
|
||||
this.saving = false;
|
||||
@@ -1224,14 +1315,14 @@ function taskEditor() {
|
||||
this.deleteTaskModal = false;
|
||||
this.deleteTargetTask = null;
|
||||
if (this.expandedTask === taskId) this.expandedTask = null;
|
||||
this.successMessage = '{{ _("Task eliminato") }}';
|
||||
this.successMessage = {{ _("Task eliminato")|tojson }};
|
||||
} else {
|
||||
const data = await resp.json();
|
||||
this.errorMessage = data.detail || {{ _("Errore nell'eliminazione del task")|tojson }};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('deleteTask error:', err);
|
||||
this.errorMessage = '{{ _("Errore di connessione al server") }}';
|
||||
this.errorMessage = {{ _("Errore di connessione al server")|tojson }};
|
||||
}
|
||||
|
||||
this.saving = false;
|
||||
@@ -1322,11 +1413,11 @@ function taskEditor() {
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.error) {
|
||||
this.errorMessage = data.detail || '{{ _("Errore nel riordinamento") }}';
|
||||
this.errorMessage = data.detail || {{ _("Errore nel riordinamento")|tojson }};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('reorderTasks error:', err);
|
||||
this.errorMessage = '{{ _("Errore di connessione al server") }}';
|
||||
this.errorMessage = {{ _("Errore di connessione al server")|tojson }};
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1390,7 +1481,7 @@ function taskEditor() {
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.error) {
|
||||
this.errorMessage = data.detail || '{{ _("Errore nella creazione della misurazione") }}';
|
||||
this.errorMessage = data.detail || {{ _("Errore nella creazione della misurazione")|tojson }};
|
||||
} else {
|
||||
const task = this.tasks.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
@@ -1398,11 +1489,11 @@ function taskEditor() {
|
||||
task.subtasks.push(data);
|
||||
}
|
||||
this.resetNewSubtask(taskId);
|
||||
this.successMessage = '{{ _("Misurazione aggiunta") }}';
|
||||
this.successMessage = {{ _("Misurazione aggiunta")|tojson }};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('addSubtask error:', err);
|
||||
this.errorMessage = '{{ _("Errore di connessione al server") }}';
|
||||
this.errorMessage = {{ _("Errore di connessione al server")|tojson }};
|
||||
}
|
||||
|
||||
this.saving = false;
|
||||
@@ -1462,7 +1553,7 @@ function taskEditor() {
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.error) {
|
||||
this.errorMessage = data.detail || '{{ _("Errore nel salvataggio della misurazione") }}';
|
||||
this.errorMessage = data.detail || {{ _("Errore nel salvataggio della misurazione")|tojson }};
|
||||
} else {
|
||||
// Update local state
|
||||
const task = this.tasks.find(t => t.id === taskId);
|
||||
@@ -1473,11 +1564,11 @@ function taskEditor() {
|
||||
}
|
||||
}
|
||||
this.cancelEditSubtask();
|
||||
this.successMessage = '{{ _("Misurazione aggiornata") }}';
|
||||
this.successMessage = {{ _("Misurazione aggiornata")|tojson }};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('updateSubtask error:', err);
|
||||
this.errorMessage = '{{ _("Errore di connessione al server") }}';
|
||||
this.errorMessage = {{ _("Errore di connessione al server")|tojson }};
|
||||
}
|
||||
|
||||
this.saving = false;
|
||||
@@ -1507,14 +1598,93 @@ function taskEditor() {
|
||||
this.deleteSubtaskModal = false;
|
||||
this.deleteTargetSubtask = null;
|
||||
this.deleteSubtaskParentId = null;
|
||||
this.successMessage = '{{ _("Misurazione eliminata") }}';
|
||||
this.successMessage = {{ _("Misurazione eliminata")|tojson }};
|
||||
} else {
|
||||
const data = await resp.json();
|
||||
this.errorMessage = data.detail || {{ _("Errore nell'eliminazione della misurazione")|tojson }};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('deleteSubtask error:', err);
|
||||
this.errorMessage = '{{ _("Errore di connessione al server") }}';
|
||||
this.errorMessage = {{ _("Errore di connessione al server")|tojson }};
|
||||
}
|
||||
|
||||
this.saving = false;
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
// TASK FILE UPLOAD
|
||||
// ============================================================
|
||||
|
||||
async uploadTaskFile(taskId, file, task) {
|
||||
if (!file) return;
|
||||
|
||||
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'application/pdf'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
this.errorMessage = {{ _("Formato file non supportato. Usa PNG, JPG o PDF.")|tojson }};
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
this.errorMessage = {{ _("File troppo grande. Dimensione massima: 20MB.")|tojson }};
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
try {
|
||||
// 1. Upload file
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('recipe_id', this.recipeId);
|
||||
|
||||
const uploadResp = await fetch('/maker/api/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': this.csrfToken() },
|
||||
body: formData
|
||||
});
|
||||
|
||||
const uploadData = await uploadResp.json();
|
||||
if (uploadData.error) {
|
||||
this.errorMessage = uploadData.detail || {{ _("Errore durante il caricamento del file")|tojson }};
|
||||
this.saving = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine file type
|
||||
const fileType = file.type === 'application/pdf' ? 'pdf' : 'image';
|
||||
|
||||
// 2. Update task with file_path
|
||||
const updateResp = await fetch(`/maker/api/tasks/${taskId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': this.csrfToken()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: uploadData.file_path,
|
||||
file_type: fileType
|
||||
})
|
||||
});
|
||||
|
||||
const updateData = await updateResp.json();
|
||||
if (updateData.error) {
|
||||
this.errorMessage = updateData.detail || {{ _("Errore nel salvataggio del task")|tojson }};
|
||||
this.saving = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Update local state
|
||||
task.file_path = uploadData.file_path;
|
||||
task.file_type = fileType;
|
||||
this.successMessage = {{ _("File caricato con successo")|tojson }};
|
||||
|
||||
// Re-render preview canvas with new image
|
||||
this.renderTaskPreview(task);
|
||||
|
||||
} catch (err) {
|
||||
console.error('uploadTaskFile error:', err);
|
||||
this.errorMessage = {{ _("Errore di connessione al server")|tojson }};
|
||||
}
|
||||
|
||||
this.saving = false;
|
||||
|
||||
Reference in New Issue
Block a user