- Fix YAML files with unquoted strings containing quotes - Export singleton instances in ai/__init__.py - Fix validator scoring prompt to use replace() instead of format() to avoid conflicts with JSON curly braces Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
396 lines
17 KiB
HTML
396 lines
17 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Calendario{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="animate-fade-in">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between mb-8">
|
|
<div>
|
|
<h1 class="text-3xl font-bold">Calendario</h1>
|
|
<p class="text-gray-400 mt-1">Visualiza y programa tus publicaciones</p>
|
|
</div>
|
|
<div class="flex gap-3">
|
|
<button onclick="prevMonth()" class="btn-secondary px-4 py-2 rounded-xl flex items-center gap-2">
|
|
<span>←</span> Anterior
|
|
</button>
|
|
<h2 class="text-xl font-bold px-4 py-2" id="current-month">Enero 2025</h2>
|
|
<button onclick="nextMonth()" class="btn-secondary px-4 py-2 rounded-xl flex items-center gap-2">
|
|
Siguiente <span>→</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- View Controls -->
|
|
<div class="card rounded-2xl p-4 mb-6">
|
|
<div class="flex flex-wrap gap-4 items-center justify-between">
|
|
<div class="flex gap-2">
|
|
<button onclick="setView('month')" id="btn-month" class="btn-primary px-4 py-2 rounded-xl">
|
|
Mes
|
|
</button>
|
|
<button onclick="setView('week')" id="btn-week" class="btn-secondary px-4 py-2 rounded-xl">
|
|
Semana
|
|
</button>
|
|
<button onclick="goToToday()" class="btn-secondary px-4 py-2 rounded-xl">
|
|
Hoy
|
|
</button>
|
|
</div>
|
|
<div class="flex gap-4 items-center">
|
|
<span class="text-gray-400 text-sm">Filtrar:</span>
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" checked class="platform-filter w-4 h-4 rounded" value="x" onchange="filterCalendar()">
|
|
<span class="text-sm bg-dark-700 px-2 py-1 rounded-lg">X</span>
|
|
</label>
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" checked class="platform-filter w-4 h-4 rounded" value="facebook" onchange="filterCalendar()">
|
|
<span class="text-sm bg-blue-600/30 text-blue-400 px-2 py-1 rounded-lg">Facebook</span>
|
|
</label>
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" checked class="platform-filter w-4 h-4 rounded" value="instagram" onchange="filterCalendar()">
|
|
<span class="text-sm bg-pink-600/30 text-pink-400 px-2 py-1 rounded-lg">Instagram</span>
|
|
</label>
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" checked class="platform-filter w-4 h-4 rounded" value="threads" onchange="filterCalendar()">
|
|
<span class="text-sm bg-dark-700 px-2 py-1 rounded-lg">Threads</span>
|
|
</label>
|
|
</div>
|
|
<div class="flex gap-3 text-xs">
|
|
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-blue-500"></span> Programado</span>
|
|
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-yellow-500"></span> Pendiente</span>
|
|
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-green-500"></span> Publicado</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Calendar Grid -->
|
|
<div id="month-view" class="card rounded-2xl overflow-hidden">
|
|
<!-- Day Headers -->
|
|
<div class="grid grid-cols-7 bg-dark-800">
|
|
<div class="p-3 text-center font-semibold border-r border-dark-600">Lun</div>
|
|
<div class="p-3 text-center font-semibold border-r border-dark-600">Mar</div>
|
|
<div class="p-3 text-center font-semibold border-r border-dark-600">Mié</div>
|
|
<div class="p-3 text-center font-semibold border-r border-dark-600">Jue</div>
|
|
<div class="p-3 text-center font-semibold border-r border-dark-600">Vie</div>
|
|
<div class="p-3 text-center font-semibold border-r border-dark-600 text-primary">Sáb</div>
|
|
<div class="p-3 text-center font-semibold text-primary">Dom</div>
|
|
</div>
|
|
|
|
<!-- Calendar Days -->
|
|
<div id="calendar-grid" class="grid grid-cols-7">
|
|
<!-- Days filled dynamically -->
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Week View (hidden by default) -->
|
|
<div id="week-view" class="card rounded-2xl hidden">
|
|
<div id="week-grid" class="divide-y divide-dark-600">
|
|
<!-- Week days filled dynamically -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Post Detail Modal -->
|
|
<div id="post-modal" class="fixed inset-0 bg-black/70 backdrop-blur-sm hidden items-center justify-center z-50">
|
|
<div class="card rounded-2xl p-6 max-w-lg w-full mx-4">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<h3 class="font-semibold text-lg">Detalle del Post</h3>
|
|
<button onclick="closePostModal()" class="text-gray-400 hover:text-white text-xl">✕</button>
|
|
</div>
|
|
<div id="post-modal-content">
|
|
<!-- Content loaded dynamically -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_scripts %}
|
|
<style>
|
|
.calendar-day { min-height: 120px; }
|
|
.calendar-day:hover { background-color: rgba(99, 102, 241, 0.1); }
|
|
.calendar-day.today { border: 2px solid #6366f1; }
|
|
.calendar-day.other-month { opacity: 0.4; }
|
|
.post-item { font-size: 11px; padding: 2px 6px; border-radius: 6px; margin-bottom: 2px; cursor: pointer; }
|
|
.post-item:hover { opacity: 0.8; }
|
|
.status-published { background-color: rgba(16, 185, 129, 0.3); color: #34d399; }
|
|
.status-scheduled { background-color: rgba(59, 130, 246, 0.3); color: #60a5fa; }
|
|
.status-pending_approval { background-color: rgba(245, 158, 11, 0.3); color: #fbbf24; }
|
|
</style>
|
|
<script>
|
|
let currentDate = new Date();
|
|
let currentView = 'month';
|
|
let calendarData = {};
|
|
|
|
window.addEventListener('load', () => {
|
|
loadCalendarData();
|
|
});
|
|
|
|
async function loadCalendarData() {
|
|
const year = currentDate.getFullYear();
|
|
const month = currentDate.getMonth();
|
|
|
|
const firstDay = new Date(year, month, 1);
|
|
const startDate = new Date(firstDay);
|
|
startDate.setDate(startDate.getDate() - (startDate.getDay() || 7) + 1);
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`/api/calendar/posts/view?start_date=${formatDateISO(startDate)}&days=42`
|
|
);
|
|
const data = await response.json();
|
|
calendarData = data.calendar || {};
|
|
renderCalendar();
|
|
} catch (error) {
|
|
console.error('Error loading calendar:', error);
|
|
}
|
|
}
|
|
|
|
function renderCalendar() {
|
|
if (currentView === 'month') {
|
|
renderMonthView();
|
|
} else {
|
|
renderWeekView();
|
|
}
|
|
|
|
const months = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
|
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
|
|
document.getElementById('current-month').textContent =
|
|
`${months[currentDate.getMonth()]} ${currentDate.getFullYear()}`;
|
|
}
|
|
|
|
function renderMonthView() {
|
|
const grid = document.getElementById('calendar-grid');
|
|
const year = currentDate.getFullYear();
|
|
const month = currentDate.getMonth();
|
|
|
|
const firstDay = new Date(year, month, 1);
|
|
const startDate = new Date(firstDay);
|
|
startDate.setDate(startDate.getDate() - (startDate.getDay() || 7) + 1);
|
|
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
const platforms = getSelectedPlatforms();
|
|
let html = '';
|
|
|
|
for (let i = 0; i < 42; i++) {
|
|
const date = new Date(startDate);
|
|
date.setDate(date.getDate() + i);
|
|
|
|
const dateKey = formatDateISO(date);
|
|
const isToday = date.getTime() === today.getTime();
|
|
const isOtherMonth = date.getMonth() !== month;
|
|
|
|
const dayPosts = (calendarData[dateKey] || []).filter(post =>
|
|
post.platforms && post.platforms.some(p => platforms.includes(p))
|
|
);
|
|
|
|
html += `
|
|
<div class="calendar-day p-2 border-r border-b border-dark-600 ${isToday ? 'today' : ''} ${isOtherMonth ? 'other-month' : ''}"
|
|
ondrop="dropPost(event, '${dateKey}')" ondragover="allowDrop(event)">
|
|
<div class="font-semibold text-sm mb-1 ${isToday ? 'text-primary' : ''}">${date.getDate()}</div>
|
|
<div class="space-y-1">
|
|
${dayPosts.slice(0, 4).map(post => `
|
|
<div class="post-item status-${post.status} truncate"
|
|
draggable="true"
|
|
ondragstart="dragPost(event, ${post.id})"
|
|
onclick="showPostDetail(${post.id})">
|
|
${post.platforms ? post.platforms[0].charAt(0).toUpperCase() : '?'}:
|
|
${escapeHtml(post.content || '').substring(0, 20)}
|
|
</div>
|
|
`).join('')}
|
|
${dayPosts.length > 4 ? `<div class="text-xs text-gray-400">+${dayPosts.length - 4} más</div>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
grid.innerHTML = html;
|
|
}
|
|
|
|
function renderWeekView() {
|
|
const container = document.getElementById('week-grid');
|
|
const weekStart = new Date(currentDate);
|
|
weekStart.setDate(weekStart.getDate() - weekStart.getDay() + 1);
|
|
|
|
const platforms = getSelectedPlatforms();
|
|
const days = ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'];
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
let html = '';
|
|
|
|
for (let i = 0; i < 7; i++) {
|
|
const date = new Date(weekStart);
|
|
date.setDate(date.getDate() + i);
|
|
const dateKey = formatDateISO(date);
|
|
const isToday = date.getTime() === today.getTime();
|
|
|
|
const dayPosts = (calendarData[dateKey] || []).filter(post =>
|
|
post.platforms && post.platforms.some(p => platforms.includes(p))
|
|
);
|
|
|
|
html += `
|
|
<div class="p-4 ${isToday ? 'bg-primary/10' : ''}" ondrop="dropPost(event, '${dateKey}')" ondragover="allowDrop(event)">
|
|
<div class="flex items-center gap-4 mb-3">
|
|
<div class="font-semibold ${isToday ? 'text-primary' : ''}">${days[i]}</div>
|
|
<div class="text-gray-400">${date.getDate()}/${date.getMonth() + 1}</div>
|
|
${isToday ? '<span class="bg-primary text-white text-xs px-2 py-1 rounded-full">Hoy</span>' : ''}
|
|
</div>
|
|
<div class="grid grid-cols-4 gap-2">
|
|
${dayPosts.map(post => `
|
|
<div class="post-item status-${post.status} p-2 rounded-lg"
|
|
draggable="true"
|
|
ondragstart="dragPost(event, ${post.id})"
|
|
onclick="showPostDetail(${post.id})">
|
|
<div class="flex gap-1 mb-1">
|
|
${post.platforms ? post.platforms.map(p => `
|
|
<span class="text-xs opacity-75">${p}</span>
|
|
`).join('') : ''}
|
|
</div>
|
|
<div class="text-xs truncate">${escapeHtml(post.content || '').substring(0, 50)}</div>
|
|
<div class="text-xs text-gray-400 mt-1">
|
|
${post.scheduled_at ? new Date(post.scheduled_at).toLocaleTimeString('es-MX', {hour: '2-digit', minute: '2-digit'}) : ''}
|
|
</div>
|
|
</div>
|
|
`).join('') || '<div class="text-gray-500 text-sm">Sin posts</div>'}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function setView(view) {
|
|
currentView = view;
|
|
document.getElementById('btn-month').className = view === 'month' ? 'btn-primary px-4 py-2 rounded-xl' : 'btn-secondary px-4 py-2 rounded-xl';
|
|
document.getElementById('btn-week').className = view === 'week' ? 'btn-primary px-4 py-2 rounded-xl' : 'btn-secondary px-4 py-2 rounded-xl';
|
|
|
|
document.getElementById('month-view').classList.toggle('hidden', view !== 'month');
|
|
document.getElementById('week-view').classList.toggle('hidden', view !== 'week');
|
|
|
|
renderCalendar();
|
|
}
|
|
|
|
function prevMonth() {
|
|
if (currentView === 'month') {
|
|
currentDate.setMonth(currentDate.getMonth() - 1);
|
|
} else {
|
|
currentDate.setDate(currentDate.getDate() - 7);
|
|
}
|
|
loadCalendarData();
|
|
}
|
|
|
|
function nextMonth() {
|
|
if (currentView === 'month') {
|
|
currentDate.setMonth(currentDate.getMonth() + 1);
|
|
} else {
|
|
currentDate.setDate(currentDate.getDate() + 7);
|
|
}
|
|
loadCalendarData();
|
|
}
|
|
|
|
function goToToday() {
|
|
currentDate = new Date();
|
|
loadCalendarData();
|
|
}
|
|
|
|
function getSelectedPlatforms() {
|
|
return Array.from(document.querySelectorAll('.platform-filter:checked')).map(el => el.value);
|
|
}
|
|
|
|
function filterCalendar() {
|
|
renderCalendar();
|
|
}
|
|
|
|
let draggedPostId = null;
|
|
|
|
function dragPost(event, postId) {
|
|
draggedPostId = postId;
|
|
event.dataTransfer.setData('text/plain', postId);
|
|
}
|
|
|
|
function allowDrop(event) {
|
|
event.preventDefault();
|
|
}
|
|
|
|
async function dropPost(event, dateKey) {
|
|
event.preventDefault();
|
|
|
|
if (!draggedPostId) return;
|
|
|
|
const newDate = new Date(dateKey + 'T12:00:00');
|
|
|
|
try {
|
|
await fetch(`/api/calendar/posts/${draggedPostId}/reschedule?scheduled_at=${newDate.toISOString()}`, {
|
|
method: 'POST'
|
|
});
|
|
loadCalendarData();
|
|
} catch (error) {
|
|
alert('Error al reprogramar: ' + error.message);
|
|
}
|
|
|
|
draggedPostId = null;
|
|
}
|
|
|
|
function showPostDetail(postId) {
|
|
let post = null;
|
|
for (const dayPosts of Object.values(calendarData)) {
|
|
post = dayPosts.find(p => p.id === postId);
|
|
if (post) break;
|
|
}
|
|
|
|
if (!post) return;
|
|
|
|
const content = `
|
|
<div class="space-y-4">
|
|
<div>
|
|
<span class="status-${post.status} px-2 py-1 rounded-lg text-xs">${post.status}</span>
|
|
${post.platforms ? post.platforms.map(p => `<span class="bg-dark-700 px-2 py-1 rounded-lg text-xs ml-1">${p}</span>`).join('') : ''}
|
|
</div>
|
|
<p class="text-gray-300">${escapeHtml(post.content || '')}</p>
|
|
<div class="text-gray-400 text-sm">
|
|
${post.scheduled_at ? `Programado: ${new Date(post.scheduled_at).toLocaleString('es-MX')}` : ''}
|
|
</div>
|
|
<div class="flex gap-2 pt-4 border-t border-dark-600">
|
|
<a href="/posts" class="btn-secondary px-4 py-2 rounded-xl text-sm">Ver en Posts</a>
|
|
${post.status === 'scheduled' ? `
|
|
<button onclick="publishNowFromModal(${post.id})" class="btn-primary px-4 py-2 rounded-xl text-sm">Publicar Ahora</button>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('post-modal-content').innerHTML = content;
|
|
document.getElementById('post-modal').classList.remove('hidden');
|
|
document.getElementById('post-modal').classList.add('flex');
|
|
}
|
|
|
|
function closePostModal() {
|
|
document.getElementById('post-modal').classList.add('hidden');
|
|
document.getElementById('post-modal').classList.remove('flex');
|
|
}
|
|
|
|
async function publishNowFromModal(postId) {
|
|
try {
|
|
await fetch(`/api/calendar/posts/${postId}/publish-now`, { method: 'POST' });
|
|
closePostModal();
|
|
loadCalendarData();
|
|
showModal('<div class="text-center"><span class="text-4xl mb-4 block">🎉</span><p>Post enviado a publicación</p></div>');
|
|
} catch (error) {
|
|
showModal('<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error: ' + error.message + '</p></div>');
|
|
}
|
|
}
|
|
|
|
function formatDateISO(date) {
|
|
return date.toISOString().split('T')[0];
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
</script>
|
|
{% endblock %}
|