feat: FASE 4 - Editor Maker (Fabric.js) con annotazioni, task editor, preview e storico versioni
- recipe_list.html: lista ricette con filtri, paginazione, cards Alpine.js - recipe_editor.html: form metadati, upload drag-and-drop, canvas Fabric.js per annotazioni - annotation-editor.js: editor annotazioni Fabric.js (marker, frecce, rettangoli, zoom, pan) - task_editor.html: editor task/subtask inline con drag-and-drop reorder e tolleranze - recipe_preview.html: anteprima ricetta come MeasurementTec - version_history.html: timeline versioni con conteggio misurazioni AJAX - maker.py: 6 route pagina + 13 proxy AJAX, gestione sicura risposte lista API - i18n: 170+ stringhe tradotte IT/EN per tutti i template Maker Architect review: 3 CRITICO + 5 MEDIO + 3 NEW risolti, 2 BASSO differiti Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ recipe.code }} — {{ _('Storico Versioni') }} — TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
/* Timeline vertical connector */
|
||||
.timeline-connector {
|
||||
position: absolute;
|
||||
left: 1.25rem;
|
||||
top: 2.5rem;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--border-color);
|
||||
}
|
||||
.timeline-connector::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: -3px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
/* Timeline dot */
|
||||
.timeline-dot {
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
top: 0.875rem;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
z-index: 1;
|
||||
border: 2px solid var(--bg-primary);
|
||||
box-shadow: 0 0 0 2px var(--border-color);
|
||||
}
|
||||
.timeline-dot.current {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 2px var(--color-primary), 0 0 8px rgba(37, 99, 235, 0.3);
|
||||
}
|
||||
.timeline-dot.past {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-4xl"
|
||||
x-data="versionHistory()"
|
||||
x-init="loadMeasurementCounts()"
|
||||
x-cloak>
|
||||
|
||||
{# ================================================================
|
||||
BREADCRUMB
|
||||
================================================================ #}
|
||||
<nav class="mb-6" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||
<li>
|
||||
<a href="{{ url_for('maker.recipe_list') }}"
|
||||
class="hover:text-primary transition-colors inline-flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
{{ _('Ricette') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('maker.recipe_edit', recipe_id=recipe.id) }}"
|
||||
class="hover:text-primary transition-colors">
|
||||
{{ recipe.code }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</li>
|
||||
<li class="font-medium text-[var(--text-primary)]">
|
||||
{{ _('Storico Versioni') }}
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{# ================================================================
|
||||
HEADER
|
||||
================================================================ #}
|
||||
<div class="mb-8">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-3">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-[var(--text-primary)] mb-1">
|
||||
<span class="font-mono text-primary">{{ recipe.code }}</span>
|
||||
<span class="text-[var(--text-muted)] text-lg font-normal ml-2">
|
||||
— {{ recipe.name }}
|
||||
</span>
|
||||
</h1>
|
||||
<div class="flex items-center gap-3 mt-1.5">
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
{{ _('Storico Versioni') }}
|
||||
</p>
|
||||
{% if recipe.current_version %}
|
||||
<span class="badge badge-pass font-mono text-xs">
|
||||
{{ _('Corrente:') }} v{{ recipe.current_version.version_number }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('maker.recipe_edit', recipe_id=recipe.id) }}"
|
||||
class="btn btn-secondary gap-1.5 shrink-0">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
{{ _('Torna a Ricetta') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
VERSION TIMELINE
|
||||
================================================================ #}
|
||||
<template x-if="versions.length > 0">
|
||||
<div class="relative pl-12">
|
||||
|
||||
{# Vertical connector line #}
|
||||
<div class="absolute left-5 top-6 bottom-6 w-0.5 bg-[var(--border-color)]"></div>
|
||||
|
||||
<template x-for="(version, idx) in sortedVersions" :key="version.id">
|
||||
<div class="relative mb-6 last:mb-0">
|
||||
|
||||
{# Timeline dot #}
|
||||
<div class="absolute -left-7 top-4 w-6 h-6 rounded-full flex items-center justify-center
|
||||
text-[10px] font-bold z-10 border-2"
|
||||
:class="version.version_number === currentVersionNumber
|
||||
? 'bg-primary text-white border-primary shadow-md shadow-primary/30'
|
||||
: 'bg-[var(--bg-card)] text-[var(--text-muted)] border-[var(--border-color)]'"
|
||||
x-text="'v' + version.version_number">
|
||||
</div>
|
||||
|
||||
{# Version card #}
|
||||
<div class="tmf-card transition-all duration-200"
|
||||
:class="version.version_number === currentVersionNumber ? 'border-primary/40 shadow-md' : ''">
|
||||
<div class="p-5">
|
||||
|
||||
{# Card header row #}
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-3">
|
||||
<div class="flex items-center gap-2.5 flex-wrap">
|
||||
{# Version badge #}
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-md text-sm font-bold font-mono"
|
||||
:class="version.version_number === currentVersionNumber
|
||||
? 'bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 border border-primary-200 dark:border-primary-800'
|
||||
: 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-color)]'"
|
||||
x-text="'v' + version.version_number">
|
||||
</span>
|
||||
|
||||
{# Current badge #}
|
||||
<template x-if="version.version_number === currentVersionNumber">
|
||||
<span class="badge badge-pass text-xs">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
{{ _('Corrente') }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
{# Measurement count badge #}
|
||||
<template x-if="measurementCounts[version.id] !== undefined">
|
||||
<span class="badge text-xs"
|
||||
:class="measurementCounts[version.id] > 0 ? 'badge-pass' : 'badge-neutral'">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<span x-text="measurementCounts[version.id]"></span>
|
||||
{{ _('misurazioni') }}
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="measurementCounts[version.id] === undefined && loadingCounts">
|
||||
<span class="badge badge-neutral text-xs">
|
||||
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
|
||||
</svg>
|
||||
{{ _('Caricamento...') }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# View button #}
|
||||
<a :href="'/maker/recipes/' + recipeId + '/edit?version=' + version.id"
|
||||
class="btn btn-secondary text-xs gap-1.5 shrink-0">
|
||||
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
{{ _('Visualizza') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Version details #}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||
|
||||
{# Created at #}
|
||||
<div class="flex items-center gap-2 text-[var(--text-secondary)]">
|
||||
<svg class="w-4 h-4 text-[var(--text-muted)] shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<span x-text="formatDate(version.created_at)"></span>
|
||||
</div>
|
||||
|
||||
{# Created by #}
|
||||
<template x-if="version.created_by_user">
|
||||
<div class="flex items-center gap-2 text-[var(--text-secondary)]">
|
||||
<svg class="w-4 h-4 text-[var(--text-muted)] shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||
</svg>
|
||||
<span x-text="version.created_by_user.display_name || version.created_by_user.username"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{# Task count (if available) #}
|
||||
<template x-if="version.tasks && version.tasks.length > 0">
|
||||
<div class="flex items-center gap-2 text-[var(--text-secondary)]">
|
||||
<svg class="w-4 h-4 text-[var(--text-muted)] shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
|
||||
</svg>
|
||||
<span x-text="version.tasks.length + ' task'"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# Change notes #}
|
||||
<template x-if="version.change_notes">
|
||||
<div class="mt-3 p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
|
||||
<div class="flex items-start gap-2">
|
||||
<svg class="w-4 h-4 text-[var(--text-muted)] shrink-0 mt-0.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/>
|
||||
</svg>
|
||||
<p class="text-sm text-[var(--text-secondary)] leading-relaxed" x-text="version.change_notes"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{# ================================================================
|
||||
EMPTY STATE
|
||||
================================================================ #}
|
||||
<template x-if="versions.length === 0">
|
||||
<div class="tmf-card">
|
||||
<div class="p-12 text-center">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full
|
||||
bg-[var(--bg-secondary)] mb-4">
|
||||
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
{{ _('Nessuna versione trovata') }}
|
||||
</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)] max-w-md mx-auto">
|
||||
{{ _('Questa ricetta non ha ancora versioni registrate. La prima versione verra creata automaticamente al salvataggio.') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{# ================================================================
|
||||
FOOTER
|
||||
================================================================ #}
|
||||
<div class="flex items-center justify-between gap-3 pt-6 pb-8">
|
||||
<a href="{{ url_for('maker.recipe_edit', recipe_id=recipe.id) }}"
|
||||
class="btn btn-secondary gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
{{ _('Torna a Ricetta') }}
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('maker.recipe_preview', recipe_id=recipe.id) }}"
|
||||
class="btn btn-secondary gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
{{ _('Anteprima') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
/**
|
||||
* Version History - Alpine.js component
|
||||
* Loads and displays recipe version timeline with AJAX measurement counts.
|
||||
*/
|
||||
function versionHistory() {
|
||||
return {
|
||||
recipeId: {{ recipe.id|tojson }},
|
||||
currentVersionNumber: {{ (recipe.current_version.version_number if recipe.current_version else 0)|tojson }},
|
||||
versions: {{ versions|tojson }},
|
||||
measurementCounts: {},
|
||||
loadingCounts: true,
|
||||
|
||||
/**
|
||||
* Sorted versions: most recent first.
|
||||
*/
|
||||
get sortedVersions() {
|
||||
return [...this.versions].sort((a, b) => b.version_number - a.version_number);
|
||||
},
|
||||
|
||||
/**
|
||||
* Load measurement counts for all versions via AJAX.
|
||||
* Calls the proxy API endpoint for each version.
|
||||
*/
|
||||
async loadMeasurementCounts() {
|
||||
this.loadingCounts = true;
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
|
||||
const promises = this.versions.map(async (version) => {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
'/maker/api/recipes/' + this.recipeId + '/versions/' + version.version_number + '/measurement-count',
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
this.measurementCounts[version.id] = data.measurement_count || 0;
|
||||
} else {
|
||||
this.measurementCounts[version.id] = 0;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading measurement count for version', version.version_number, err);
|
||||
this.measurementCounts[version.id] = 0;
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
this.loadingCounts = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Format ISO date string to a localized display format.
|
||||
* Example output: "15 Gen 2026, 14:30"
|
||||
* @param {string} isoString - ISO 8601 date string
|
||||
* @returns {string} Formatted date
|
||||
*/
|
||||
formatDate(isoString) {
|
||||
if (!isoString) return '—';
|
||||
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
if (isNaN(date.getTime())) return isoString;
|
||||
|
||||
const months = [
|
||||
'Gen', 'Feb', 'Mar', 'Apr', 'Mag', 'Giu',
|
||||
'Lug', 'Ago', 'Set', 'Ott', 'Nov', 'Dic'
|
||||
];
|
||||
|
||||
const day = date.getDate();
|
||||
const month = months[date.getMonth()];
|
||||
const year = date.getFullYear();
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
|
||||
return day + ' ' + month + ' ' + year + ', ' + hours + ':' + minutes;
|
||||
} catch (e) {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user