004f794c75
Add tojson_attr Jinja2 filter that escapes double quotes to " for safe embedding in HTML attributes. The browser decodes entities before Alpine.js evaluates, so JSON parses correctly. Replaces |tojson with |tojson_attr in x-data attributes (select_recipe, recipe_list, base flash messages). Script tag usages are unaffected. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
386 lines
16 KiB
HTML
386 lines
16 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}{{ _('Gestione Ricette') }} — TieMeasureFlow{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-6xl"
|
|
x-data="{
|
|
recipes: {{ recipes|tojson_attr }},
|
|
search: '{{ search or '' }}',
|
|
filter: 'all',
|
|
deleteModal: false,
|
|
deleteTarget: null,
|
|
|
|
get filteredRecipes() {
|
|
let filtered = this.recipes;
|
|
|
|
// Apply search filter
|
|
if (this.search.trim()) {
|
|
const term = this.search.toLowerCase().trim();
|
|
filtered = filtered.filter(r =>
|
|
r.name.toLowerCase().includes(term) ||
|
|
r.code.toLowerCase().includes(term) ||
|
|
(r.description && r.description.toLowerCase().includes(term))
|
|
);
|
|
}
|
|
|
|
// Apply status filter
|
|
if (this.filter === 'active') {
|
|
filtered = filtered.filter(r => r.active);
|
|
} else if (this.filter === 'inactive') {
|
|
filtered = filtered.filter(r => !r.active);
|
|
}
|
|
|
|
return filtered;
|
|
},
|
|
|
|
async deleteRecipe(id) {
|
|
const csrfToken = document.querySelector('meta[name=\"csrf-token\"]')?.content;
|
|
try {
|
|
const resp = await fetch(`/maker/api/recipes/${id}`, {
|
|
method: 'DELETE',
|
|
headers: { 'X-CSRFToken': csrfToken || '' }
|
|
});
|
|
|
|
if (resp.ok) {
|
|
this.recipes = this.recipes.filter(r => r.id !== id);
|
|
this.deleteModal = false;
|
|
this.deleteTarget = null;
|
|
} else {
|
|
const data = await resp.json();
|
|
alert(data.error || '{{ _('Errore durante eliminazione') }}');
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
alert('{{ _('Errore di connessione') }}');
|
|
}
|
|
},
|
|
|
|
openDeleteModal(recipe) {
|
|
this.deleteTarget = recipe;
|
|
this.deleteModal = true;
|
|
}
|
|
}">
|
|
|
|
<!-- 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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
|
|
</svg>
|
|
{{ _('Dashboard') }}
|
|
</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)]">
|
|
{{ _('Ricette') }}
|
|
</li>
|
|
</ol>
|
|
</nav>
|
|
|
|
<!-- Header Section -->
|
|
<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">
|
|
{{ _('Gestione Ricette') }}
|
|
</h1>
|
|
<p class="text-sm text-[var(--text-secondary)]">
|
|
{{ _('Crea e gestisci le ricette di misura') }}
|
|
</p>
|
|
</div>
|
|
<a href="{{ url_for('maker.recipe_new') }}"
|
|
class="btn btn-primary gap-2 shrink-0 w-full sm:w-auto justify-center">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
|
|
</svg>
|
|
{{ _('Nuova Ricetta') }}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="tmf-card mb-6">
|
|
<div class="p-4">
|
|
<div class="flex flex-col sm:flex-row gap-3">
|
|
<!-- Search Input -->
|
|
<div class="flex-1">
|
|
<div class="relative">
|
|
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
|
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
|
</svg>
|
|
</div>
|
|
<input type="text"
|
|
x-model="search"
|
|
class="tmf-input pl-10"
|
|
:placeholder="'{{ _('Cerca per nome, codice o descrizione...') }}'">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Status Filter -->
|
|
<div class="sm:w-48">
|
|
<select x-model="filter" class="tmf-input">
|
|
<option value="all">{{ _('Tutti') }}</option>
|
|
<option value="active">{{ _('Attive') }}</option>
|
|
<option value="inactive">{{ _('Disattivate') }}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Results Count -->
|
|
<div class="mb-5">
|
|
<p class="text-sm text-[var(--text-secondary)]">
|
|
<span x-text="filteredRecipes.length"></span>
|
|
<span x-text="filteredRecipes.length === 1 ? '{{ _('ricetta trovata') }}' : '{{ _('ricette trovate') }}'"></span>
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Recipe Cards -->
|
|
<div class="space-y-4" x-show="filteredRecipes.length > 0">
|
|
<template x-for="recipe in filteredRecipes" :key="recipe.id">
|
|
<div class="tmf-card hover:border-primary/30 transition-all duration-200 group">
|
|
<div class="p-5 sm:p-6">
|
|
<!-- Card Header -->
|
|
<div class="flex flex-col sm:flex-row sm:items-start gap-4 mb-4">
|
|
<!-- Left: Recipe Info -->
|
|
<div class="flex-1 min-w-0">
|
|
<!-- Badges Row -->
|
|
<div class="flex items-center gap-2 mb-3 flex-wrap">
|
|
<!-- Code Badge -->
|
|
<span class="inline-flex items-center px-3 py-1 rounded-md text-sm font-semibold
|
|
bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300
|
|
border border-primary-200 dark:border-primary-800 font-mono tracking-wide"
|
|
x-text="recipe.code">
|
|
</span>
|
|
|
|
<!-- Version Badge -->
|
|
<template x-if="recipe.current_version">
|
|
<span class="badge badge-neutral font-mono"
|
|
x-text="'v' + recipe.current_version.version_number">
|
|
</span>
|
|
</template>
|
|
|
|
<!-- Status Badge -->
|
|
<span class="badge"
|
|
:class="recipe.active ? 'badge-pass' : 'badge-fail'"
|
|
x-text="recipe.active ? '{{ _('Attiva') }}' : '{{ _('Disattivata') }}'">
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Recipe Name -->
|
|
<h3 class="text-xl font-bold text-[var(--text-primary)] mb-2"
|
|
x-text="recipe.name">
|
|
</h3>
|
|
|
|
<!-- Description -->
|
|
<template x-if="recipe.description">
|
|
<p class="text-sm text-[var(--text-secondary)] leading-relaxed line-clamp-2 mb-3"
|
|
x-text="recipe.description">
|
|
</p>
|
|
</template>
|
|
|
|
<!-- Meta Information -->
|
|
<div class="flex items-center gap-4 flex-wrap text-xs text-[var(--text-secondary)]">
|
|
<!-- Task Count -->
|
|
<template x-if="recipe.current_version && recipe.current_version.task_count">
|
|
<span class="inline-flex items-center gap-1.5">
|
|
<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="M4 6h16M4 10h16M4 14h16M4 18h16"/>
|
|
</svg>
|
|
<span x-text="recipe.current_version.task_count"></span>
|
|
<span>{{ _('task') }}</span>
|
|
</span>
|
|
</template>
|
|
|
|
<!-- Updated Date -->
|
|
<template x-if="recipe.current_version && recipe.current_version.created_at">
|
|
<span class="inline-flex items-center gap-1.5">
|
|
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
<span>{{ _('Aggiornata') }}</span>
|
|
<span class="font-medium" x-text="new Date(recipe.current_version.created_at).toLocaleDateString('{{ current_language or 'it' }}')"></span>
|
|
</span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Action Buttons -->
|
|
<div class="flex items-center gap-2 flex-wrap pt-4 border-t border-[var(--border-color)]">
|
|
<!-- Modifica -->
|
|
<a :href="'/maker/recipes/' + recipe.id + '/edit'"
|
|
class="btn btn-secondary gap-1.5 text-xs">
|
|
<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="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') }}
|
|
</a>
|
|
|
|
<!-- Task -->
|
|
<a :href="'/maker/recipes/' + recipe.id + '/tasks'"
|
|
class="btn btn-secondary gap-1.5 text-xs">
|
|
<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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
|
</svg>
|
|
{{ _('Task') }}
|
|
</a>
|
|
|
|
<!-- Anteprima -->
|
|
<a :href="'/maker/recipes/' + recipe.id + '/preview'"
|
|
class="btn btn-secondary gap-1.5 text-xs">
|
|
<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>
|
|
|
|
<!-- Versioni -->
|
|
<a :href="'/maker/recipes/' + recipe.id + '/versions'"
|
|
class="btn btn-secondary gap-1.5 text-xs">
|
|
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
{{ _('Versioni') }}
|
|
</a>
|
|
|
|
<!-- Spacer -->
|
|
<div class="flex-1"></div>
|
|
|
|
<!-- Elimina -->
|
|
<button @click="openDeleteModal(recipe)"
|
|
class="btn btn-danger gap-1.5 text-xs">
|
|
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
|
</svg>
|
|
{{ _('Elimina') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div x-show="filteredRecipes.length === 0"
|
|
x-cloak
|
|
class="text-center py-16">
|
|
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full
|
|
bg-[var(--bg-secondary)] mb-5">
|
|
<svg class="w-10 h-10 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="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>
|
|
</div>
|
|
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
|
<span x-show="search.trim() || filter !== 'all'">
|
|
{{ _('Nessuna ricetta trovata') }}
|
|
</span>
|
|
<span x-show="!search.trim() && filter === 'all'">
|
|
{{ _('Nessuna ricetta disponibile') }}
|
|
</span>
|
|
</h3>
|
|
<p class="text-sm text-[var(--text-secondary)] max-w-md mx-auto mb-6">
|
|
<span x-show="search.trim() || filter !== 'all'">
|
|
{{ _('Prova a modificare i filtri di ricerca') }}
|
|
</span>
|
|
<span x-show="!search.trim() && filter === 'all'">
|
|
{{ _('Inizia creando la tua prima ricetta di misura') }}
|
|
</span>
|
|
</p>
|
|
<a href="{{ url_for('maker.recipe_new') }}"
|
|
x-show="!search.trim() && filter === 'all'"
|
|
class="btn btn-primary gap-2 inline-flex">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
|
|
</svg>
|
|
{{ _('Crea Prima Ricetta') }}
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Delete Confirmation Modal -->
|
|
<div x-show="deleteModal"
|
|
x-cloak
|
|
@keydown.escape.window="deleteModal = false"
|
|
class="fixed inset-0 z-50 overflow-y-auto"
|
|
aria-labelledby="modal-title"
|
|
role="dialog"
|
|
aria-modal="true">
|
|
|
|
<!-- Backdrop -->
|
|
<div x-show="deleteModal"
|
|
x-transition:enter="transition ease-out duration-300"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="transition ease-in duration-200"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
@click="deleteModal = false"
|
|
class="fixed inset-0 bg-black/50 backdrop-blur-sm transition-opacity">
|
|
</div>
|
|
|
|
<!-- Modal Content -->
|
|
<div class="flex min-h-full items-center justify-center p-4">
|
|
<div x-show="deleteModal"
|
|
x-transition:enter="transition ease-out duration-300"
|
|
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
|
x-transition:leave="transition ease-in duration-200"
|
|
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
|
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
class="tmf-card max-w-lg w-full relative">
|
|
|
|
<div class="p-6">
|
|
<!-- Icon -->
|
|
<div class="mx-auto flex items-center justify-center w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 mb-4">
|
|
<svg class="w-6 h-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
|
</svg>
|
|
</div>
|
|
|
|
<!-- Title -->
|
|
<h3 class="text-lg font-semibold text-[var(--text-primary)] text-center mb-2">
|
|
{{ _('Conferma Eliminazione') }}
|
|
</h3>
|
|
|
|
<!-- Message -->
|
|
<template x-if="deleteTarget">
|
|
<div>
|
|
<p class="text-sm text-[var(--text-secondary)] text-center mb-4">
|
|
{{ _('Sei sicuro di voler disattivare la ricetta') }}
|
|
<span class="font-semibold text-[var(--text-primary)]" x-text="deleteTarget.name"></span>?
|
|
</p>
|
|
<p class="text-xs text-[var(--text-muted)] text-center mb-6">
|
|
{{ _('Le misure esistenti non verranno eliminate') }}
|
|
</p>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Actions -->
|
|
<div class="flex flex-col sm:flex-row gap-3">
|
|
<button @click="deleteModal = false"
|
|
class="btn btn-secondary flex-1 justify-center">
|
|
{{ _('Annulla') }}
|
|
</button>
|
|
<button @click="deleteRecipe(deleteTarget.id)"
|
|
class="btn btn-danger flex-1 justify-center">
|
|
{{ _('Elimina') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
{% endblock %}
|