fix: file display, persistence, PDF support and save error handling
- Add file proxy route in maker blueprint (X-API-Key auth for browser requests) - Persist file_path/annotations_json to DB via RecipeCreate/RecipeUpdate schemas - Fix canvas sizing using grandparent container instead of Fabric.js wrapper div - Defer canvas init with requestAnimationFrame for x-show timing - Add PDF.js support in annotation-editor and annotation-viewer - Fix annotations_json double-serialization (parse string to object before send) - Handle FastAPI 422 validation error arrays in api_client and JS error display - Update template URLs to use /maker/api/files/ proxy path Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
"""Maker blueprint - recipe creation and editing."""
|
||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
|
||||
import requests as http_requests
|
||||
|
||||
from flask import Blueprint, Response, flash, jsonify, redirect, render_template, request, session, url_for
|
||||
from flask_babel import gettext as _
|
||||
|
||||
from blueprints.auth import login_required, role_required
|
||||
from config import Config
|
||||
from services.api_client import api_client
|
||||
|
||||
maker_bp = Blueprint("maker", __name__, url_prefix="/maker")
|
||||
@@ -349,6 +352,25 @@ def api_upload_file():
|
||||
return jsonify(resp), 201
|
||||
|
||||
|
||||
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
|
||||
@login_required
|
||||
def api_get_file(file_path: str):
|
||||
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
|
||||
api_key = session.get("api_key", "")
|
||||
base_url = Config.API_SERVER_URL.rstrip("/")
|
||||
resp = http_requests.get(
|
||||
f"{base_url}/api/files/{file_path}",
|
||||
headers={"X-API-Key": api_key},
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return Response(resp.text, status=resp.status_code)
|
||||
return Response(
|
||||
resp.content,
|
||||
content_type=resp.headers.get("content-type", "application/octet-stream"),
|
||||
)
|
||||
|
||||
|
||||
@maker_bp.route("/api/files/<path:file_path>", methods=["DELETE"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
|
||||
@@ -40,6 +40,11 @@ class APIClient:
|
||||
try:
|
||||
error_body = response.json()
|
||||
detail = error_body.get("detail", str(error_body))
|
||||
# FastAPI 422 returns detail as a list of validation errors
|
||||
if isinstance(detail, list):
|
||||
detail = "; ".join(
|
||||
e.get("msg", str(e)) for e in detail if isinstance(e, dict)
|
||||
) or str(detail)
|
||||
except Exception:
|
||||
detail = response.text or f"HTTP {response.status_code}"
|
||||
|
||||
|
||||
@@ -90,7 +90,6 @@ function annotationEditor() {
|
||||
|
||||
this.setupEvents();
|
||||
this.setupKeyboard();
|
||||
this.resizeCanvas();
|
||||
|
||||
this._debouncedResize = debounce(this.resizeCanvas.bind(this), 200);
|
||||
window.addEventListener('resize', this._debouncedResize);
|
||||
@@ -126,26 +125,63 @@ function annotationEditor() {
|
||||
|
||||
this._onImageLoaded = function (e) {
|
||||
if (e.detail && e.detail.path) {
|
||||
// Wait for x-show transition to reveal the canvas
|
||||
setTimeout(function () {
|
||||
self.resizeCanvas();
|
||||
self.loadBackgroundImage('/api/files/' + e.detail.path);
|
||||
if (e.detail.annotations) {
|
||||
self.loadAnnotationsJson(e.detail.annotations);
|
||||
}
|
||||
}, 150);
|
||||
// Wait for x-show transition to reveal the canvas and DOM layout
|
||||
self._deferredLoad(
|
||||
'/maker/api/files/' + e.detail.path,
|
||||
e.detail.annotations || null
|
||||
);
|
||||
}
|
||||
};
|
||||
window.addEventListener('image-loaded', this._onImageLoaded);
|
||||
|
||||
// ---- Initial load: check parent scope for existing file ----
|
||||
// Deferred to requestAnimationFrame so the browser has laid out the
|
||||
// container (x-cloak removed, x-show applied) before we read dimensions.
|
||||
var filePath = this.currentFilePath; // from parent recipeEditor scope
|
||||
if (filePath) {
|
||||
var annoJson = this.annotationsJson; // from parent recipeEditor scope
|
||||
this.loadBackgroundImage('/api/files/' + filePath);
|
||||
if (annoJson) {
|
||||
this.loadAnnotationsJson(annoJson);
|
||||
this._deferredLoad(
|
||||
'/maker/api/files/' + filePath,
|
||||
this.annotationsJson || null
|
||||
);
|
||||
} else {
|
||||
// No file yet — still resize so canvas is ready when image is uploaded
|
||||
requestAnimationFrame(function () {
|
||||
self.resizeCanvas();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Deferred load: waits for the DOM to be fully laid out (via
|
||||
* requestAnimationFrame) then resizes the canvas and loads the image.
|
||||
* Falls back to a second attempt after 300ms if the container still
|
||||
* has zero width (e.g. Alpine x-show transition not yet complete).
|
||||
*/
|
||||
_deferredLoad(imageUrl, annotationsJson) {
|
||||
var self = this;
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
self.resizeCanvas();
|
||||
|
||||
// If container is still hidden / zero-width, retry after transition
|
||||
if (!self.canvas.width) {
|
||||
setTimeout(function () {
|
||||
self.resizeCanvas();
|
||||
self._loadImageAndAnnotations(imageUrl, annotationsJson);
|
||||
}, 350);
|
||||
} else {
|
||||
self._loadImageAndAnnotations(imageUrl, annotationsJson);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Internal: load background image and optionally annotations.
|
||||
*/
|
||||
_loadImageAndAnnotations(imageUrl, annotationsJson) {
|
||||
this.loadBackgroundImage(imageUrl);
|
||||
if (annotationsJson) {
|
||||
this.loadAnnotationsJson(annotationsJson);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -154,32 +190,93 @@ function annotationEditor() {
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Load an image as the canvas background.
|
||||
* Scales the image to fit within the canvas while maintaining aspect ratio
|
||||
* and never exceeding the original size (scale <= 1).
|
||||
* Load an image or PDF as the canvas background.
|
||||
* For PDFs, renders the first page via PDF.js then uses it as background.
|
||||
* Scales to fit within the canvas while maintaining aspect ratio.
|
||||
*
|
||||
* @param {string} imageUrl - URL of the image to load
|
||||
* @param {string} imageUrl - URL of the image or PDF to load
|
||||
*/
|
||||
loadBackgroundImage(imageUrl) {
|
||||
if (!this.canvas) return;
|
||||
|
||||
if (!this.canvas.width) {
|
||||
this.resizeCanvas();
|
||||
}
|
||||
|
||||
var isPdf = imageUrl.toLowerCase().replace(/\?.*$/, '').endsWith('.pdf');
|
||||
|
||||
if (isPdf && typeof pdfjsLib !== 'undefined') {
|
||||
this._loadPdfBackground(imageUrl);
|
||||
} else {
|
||||
this._loadImageBackground(imageUrl);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Render first page of a PDF via PDF.js and set as canvas background.
|
||||
*/
|
||||
_loadPdfBackground(pdfUrl) {
|
||||
var self = this;
|
||||
var loadingTask = pdfjsLib.getDocument(pdfUrl);
|
||||
|
||||
loadingTask.promise.then(function (pdf) {
|
||||
return pdf.getPage(1);
|
||||
}).then(function (page) {
|
||||
// Render at 2x scale for crisp display
|
||||
var scale = 2;
|
||||
var viewport = page.getViewport({ scale: scale });
|
||||
|
||||
// Off-screen canvas for rendering
|
||||
var offCanvas = document.createElement('canvas');
|
||||
offCanvas.width = viewport.width;
|
||||
offCanvas.height = viewport.height;
|
||||
var ctx = offCanvas.getContext('2d');
|
||||
|
||||
var renderContext = {
|
||||
canvasContext: ctx,
|
||||
viewport: viewport,
|
||||
};
|
||||
|
||||
page.render(renderContext).promise.then(function () {
|
||||
// Convert rendered PDF page to data URL, then load as Fabric image
|
||||
var dataUrl = offCanvas.toDataURL('image/png');
|
||||
self._loadImageBackground(dataUrl);
|
||||
});
|
||||
}).catch(function (err) {
|
||||
console.error('AnnotationEditor: failed to load PDF:', err);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Load a raster image URL as the canvas background.
|
||||
*/
|
||||
_loadImageBackground(imageUrl) {
|
||||
var self = this;
|
||||
|
||||
fabric.Image.fromURL(
|
||||
imageUrl,
|
||||
function (img) {
|
||||
if (!img || !img.width || !img.height) {
|
||||
console.error('AnnotationEditor: failed to load background image');
|
||||
console.error('AnnotationEditor: failed to load background image from', imageUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self.canvas.width) {
|
||||
self.resizeCanvas();
|
||||
}
|
||||
|
||||
var cw = self.canvas.width || 800;
|
||||
var ch = self.canvas.height || 480;
|
||||
|
||||
var scale = Math.min(
|
||||
this.canvas.width / img.width,
|
||||
this.canvas.height / img.height,
|
||||
cw / img.width,
|
||||
ch / img.height,
|
||||
1 // never upscale
|
||||
);
|
||||
|
||||
this.canvas.setBackgroundImage(
|
||||
self.canvas.setBackgroundImage(
|
||||
img,
|
||||
this.canvas.renderAll.bind(this.canvas),
|
||||
self.canvas.renderAll.bind(self.canvas),
|
||||
{
|
||||
scaleX: scale,
|
||||
scaleY: scale,
|
||||
@@ -188,9 +285,8 @@ function annotationEditor() {
|
||||
}
|
||||
);
|
||||
|
||||
this.imageLoaded = true;
|
||||
}.bind(this),
|
||||
{ crossOrigin: 'anonymous' }
|
||||
self.imageLoaded = true;
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
@@ -670,16 +766,25 @@ function annotationEditor() {
|
||||
/**
|
||||
* Resize the Fabric.js canvas to fill its parent container.
|
||||
* Maintains a minimum 5:3 aspect ratio (height = width * 0.6).
|
||||
*
|
||||
* Note: Fabric.js wraps the <canvas> in its own div, so parentElement
|
||||
* is the Fabric wrapper (which has a fixed pixel width from creation).
|
||||
* We need the .canvas-container grandparent for the real layout width.
|
||||
*/
|
||||
resizeCanvas() {
|
||||
var container = this.canvasEl ? this.canvasEl.parentElement : null;
|
||||
if (!container || !this.canvas) return;
|
||||
if (!this.canvasEl || !this.canvas) return;
|
||||
|
||||
// Fabric wrapper → .canvas-container (or whatever holds the canvas)
|
||||
var fabricWrapper = this.canvasEl.parentElement;
|
||||
var container = fabricWrapper ? fabricWrapper.parentElement : null;
|
||||
if (!container) return;
|
||||
|
||||
var width = container.clientWidth;
|
||||
var height = Math.max(400, Math.round(width * 0.6));
|
||||
|
||||
this.canvas.setWidth(width);
|
||||
this.canvas.setHeight(height);
|
||||
this.canvas.calcOffset();
|
||||
this.canvas.renderAll();
|
||||
},
|
||||
|
||||
|
||||
@@ -80,47 +80,90 @@ function annotationViewer() {
|
||||
},
|
||||
|
||||
/**
|
||||
* Carica e renderizza l'immagine con scaling
|
||||
* Carica e renderizza l'immagine (o la prima pagina PDF) con scaling
|
||||
*/
|
||||
loadImage() {
|
||||
var isPdf = this.imageUrl.toLowerCase().replace(/\?.*$/, '').endsWith('.pdf');
|
||||
|
||||
if (isPdf && typeof pdfjsLib !== 'undefined') {
|
||||
this._loadPdf();
|
||||
} else {
|
||||
this._loadRasterImage();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Render first page of a PDF via PDF.js
|
||||
*/
|
||||
_loadPdf() {
|
||||
const self = this;
|
||||
pdfjsLib.getDocument(this.imageUrl).promise.then(function (pdf) {
|
||||
return pdf.getPage(1);
|
||||
}).then(function (page) {
|
||||
const pdfScale = 2; // render at 2x for clarity
|
||||
const viewport = page.getViewport({ scale: pdfScale });
|
||||
|
||||
const offCanvas = document.createElement('canvas');
|
||||
offCanvas.width = viewport.width;
|
||||
offCanvas.height = viewport.height;
|
||||
const offCtx = offCanvas.getContext('2d');
|
||||
|
||||
page.render({ canvasContext: offCtx, viewport: viewport }).promise.then(function () {
|
||||
// Use rendered PDF page as if it were an image
|
||||
self._drawImageOnCanvas(offCanvas, viewport.width, viewport.height);
|
||||
});
|
||||
}).catch(function (err) {
|
||||
console.error('AnnotationViewer: failed to load PDF:', err);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Load a raster image (PNG, JPG, etc.)
|
||||
*/
|
||||
_loadRasterImage() {
|
||||
const self = this;
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
// Calculate scale to fit container
|
||||
const container = this.canvas.parentElement;
|
||||
if (!container) {
|
||||
console.error('AnnotationViewer: parent container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const containerWidth = container.clientWidth;
|
||||
const containerHeight = container.clientHeight || 500; // fallback height
|
||||
|
||||
this.scale = Math.min(
|
||||
containerWidth / img.width,
|
||||
containerHeight / img.height,
|
||||
1 // non ingrandire oltre dimensione originale
|
||||
);
|
||||
|
||||
this.canvas.width = img.width * this.scale;
|
||||
this.canvas.height = img.height * this.scale;
|
||||
|
||||
// Draw image
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.ctx.drawImage(img, 0, 0, this.canvas.width, this.canvas.height);
|
||||
this.imageLoaded = true;
|
||||
|
||||
// Draw annotations on top
|
||||
this.drawAnnotations();
|
||||
img.onload = function () {
|
||||
self._drawImageOnCanvas(img, img.width, img.height);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
console.error('AnnotationViewer: failed to load image:', this.imageUrl);
|
||||
img.onerror = function () {
|
||||
console.error('AnnotationViewer: failed to load image:', self.imageUrl);
|
||||
};
|
||||
|
||||
img.src = this.imageUrl;
|
||||
},
|
||||
|
||||
/**
|
||||
* Draw an image source (Image or Canvas) scaled to fit the container
|
||||
*/
|
||||
_drawImageOnCanvas(source, srcWidth, srcHeight) {
|
||||
const container = this.canvas.parentElement;
|
||||
if (!container) {
|
||||
console.error('AnnotationViewer: parent container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const containerWidth = container.clientWidth;
|
||||
const containerHeight = container.clientHeight || 500;
|
||||
|
||||
this.scale = Math.min(
|
||||
containerWidth / srcWidth,
|
||||
containerHeight / srcHeight,
|
||||
1
|
||||
);
|
||||
|
||||
this.canvas.width = srcWidth * this.scale;
|
||||
this.canvas.height = srcHeight * this.scale;
|
||||
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.ctx.drawImage(source, 0, 0, this.canvas.width, this.canvas.height);
|
||||
this.imageLoaded = true;
|
||||
|
||||
this.drawAnnotations();
|
||||
},
|
||||
|
||||
/**
|
||||
* Disegna tutti i markers sulle annotazioni
|
||||
*/
|
||||
|
||||
@@ -580,7 +580,11 @@
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.1/fabric.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/annotation-editor.js') }}"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>
|
||||
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-editor.js') }}?v=5"></script>
|
||||
<script>
|
||||
function recipeEditor() {
|
||||
return {
|
||||
@@ -649,9 +653,11 @@ function recipeEditor() {
|
||||
payload.change_notes = this.changeNotes.trim();
|
||||
}
|
||||
|
||||
// Include annotations if available
|
||||
// Include annotations if available (may be a JSON string or an object)
|
||||
if (this.annotationsJson) {
|
||||
payload.annotations_json = this.annotationsJson;
|
||||
payload.annotations_json = (typeof this.annotationsJson === 'string')
|
||||
? JSON.parse(this.annotationsJson)
|
||||
: this.annotationsJson;
|
||||
}
|
||||
|
||||
// Include file path if uploaded
|
||||
@@ -677,7 +683,8 @@ function recipeEditor() {
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.error) {
|
||||
this.errorMessage = data.detail || data.error || '{{ _("Errore nel salvataggio") }}';
|
||||
var detail = data.detail || data.error || '{{ _("Errore nel salvataggio") }}';
|
||||
this.errorMessage = (typeof detail === 'string') ? detail : JSON.stringify(detail);
|
||||
this.saving = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@
|
||||
<div class="annotation-container"
|
||||
x-data="annotationViewer()"
|
||||
x-init="
|
||||
imageUrl = '/api/files/{{ task.file_path }}';
|
||||
imageUrl = '/maker/api/files/{{ task.file_path }}';
|
||||
annotations = {{ task.annotations_json|default('null')|tojson }};
|
||||
$nextTick(() => init());
|
||||
">
|
||||
@@ -225,7 +225,7 @@
|
||||
<p class="text-sm font-medium text-[var(--text-primary)]">{{ _('Documento PDF allegato') }}</p>
|
||||
<p class="text-xs text-[var(--text-muted)]">{{ task.file_path }}</p>
|
||||
</div>
|
||||
<a href="/api/files/{{ task.file_path }}"
|
||||
<a href="/maker/api/files/{{ task.file_path }}"
|
||||
target="_blank"
|
||||
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">
|
||||
@@ -424,7 +424,11 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>
|
||||
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=2"></script>
|
||||
<script>
|
||||
/**
|
||||
* Recipe Preview - Minimal Alpine.js component
|
||||
|
||||
Reference in New Issue
Block a user