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:
Adriano
2026-02-08 01:20:34 +01:00
parent b075115cef
commit 6ea94cca47
14 changed files with 1290 additions and 555 deletions
+188 -18
View File
@@ -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;