Files
social-media-automation/dashboard/templates/posts.html
Consultoría AS 29520a00f6 feat: Add publish and mark-published endpoints with validation
- Add /api/posts/{id}/publish endpoint for API-based publishing
- Add /api/posts/{id}/mark-published endpoint for manual workflow
- Add content length validation before publishing
- Update modal with "Ya lo publiqué" and "Publicar (API)" buttons
- Fix retry_count handling for None values

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 23:01:28 +00:00

354 lines
17 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Posts{% 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">Posts</h1>
<p class="text-gray-400 mt-1">Gestiona todas tus publicaciones</p>
</div>
<a href="/compose" class="btn-primary px-6 py-3 rounded-xl font-medium flex items-center gap-2 transition-all">
<span>✍️</span>
<span>Crear Post</span>
</a>
</div>
<!-- Filters -->
<div class="card rounded-2xl p-4 mb-6">
<div class="flex flex-wrap gap-4 items-center">
<select id="filter-status" onchange="filterPosts()" class="bg-dark-800 border border-dark-600 rounded-xl px-4 py-2 text-white focus:border-primary focus:outline-none">
<option value="">Todos los estados</option>
<option value="published">Publicados</option>
<option value="scheduled">Programados</option>
<option value="pending_approval">Pendientes</option>
<option value="draft">Borradores</option>
<option value="failed">Fallidos</option>
</select>
<select id="filter-platform" onchange="filterPosts()" class="bg-dark-800 border border-dark-600 rounded-xl px-4 py-2 text-white focus:border-primary focus:outline-none">
<option value="">Todas las plataformas</option>
<option value="x">X (Twitter)</option>
<option value="facebook">Facebook</option>
<option value="instagram">Instagram</option>
<option value="threads">Threads</option>
</select>
<input type="text" id="filter-search" placeholder="Buscar..." onkeyup="filterPosts()" class="bg-dark-800 border border-dark-600 rounded-xl px-4 py-2 text-white placeholder-gray-500 focus:border-primary focus:outline-none flex-1 min-w-48">
</div>
</div>
<!-- Posts List -->
<div id="posts-container" class="space-y-4">
{% for post in posts %}
<div class="card rounded-2xl p-6 hover:border-primary/30 transition-all post-item cursor-pointer"
data-status="{{ post.status }}"
data-platforms="{{ post.platforms|join(',') if post.platforms else '' }}"
data-content="{{ post.content|lower }}"
onclick="viewPost({{ post.id }})">
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<p class="text-white mb-3">{{ post.content[:200] }}{% if post.content|length > 200 %}...{% endif %}</p>
<div class="flex flex-wrap items-center gap-2">
{% for platform in post.platforms %}
<span class="text-xs px-2 py-1 rounded-full
{% if platform == 'x' %}bg-gray-700{% endif %}
{% if platform == 'facebook' %}bg-blue-600/30 text-blue-400{% endif %}
{% if platform == 'instagram' %}bg-pink-600/30 text-pink-400{% endif %}
{% if platform == 'threads' %}bg-purple-600/30 text-purple-400{% endif %}
">{{ platform }}</span>
{% endfor %}
<span class="text-xs px-2 py-1 rounded-full
{% if post.status == 'published' %}bg-green-500/20 text-green-400{% endif %}
{% if post.status == 'scheduled' %}bg-blue-500/20 text-blue-400{% endif %}
{% if post.status == 'pending_approval' %}bg-yellow-500/20 text-yellow-400{% endif %}
{% if post.status == 'draft' %}bg-gray-500/20 text-gray-400{% endif %}
{% if post.status == 'failed' %}bg-red-500/20 text-red-400{% endif %}
">{{ post.status }}</span>
{% if post.quality_score %}
<span class="text-xs px-2 py-1 rounded-full bg-primary/20 text-primary">Score: {{ post.quality_score }}</span>
{% endif %}
</div>
</div>
<div class="flex flex-col items-end gap-2" onclick="event.stopPropagation()">
<span class="text-xs text-gray-500">{{ post.created_at[:16] if post.created_at else '-' }}</span>
<div class="flex gap-2">
<button onclick="viewPost({{ post.id }})" class="text-xs bg-primary/20 text-primary px-3 py-1 rounded-lg hover:bg-primary/30 transition-colors">
Ver
</button>
{% if post.status == 'draft' or post.status == 'pending_approval' %}
<button onclick="publishPost({{ post.id }})" class="text-xs bg-green-500/20 text-green-400 px-3 py-1 rounded-lg hover:bg-green-500/30 transition-colors">
Publicar
</button>
{% endif %}
<button onclick="deletePost({{ post.id }})" class="text-xs bg-red-500/20 text-red-400 px-3 py-1 rounded-lg hover:bg-red-500/30 transition-colors">
Eliminar
</button>
</div>
</div>
</div>
</div>
{% else %}
<div class="text-center py-16 text-gray-500">
<span class="text-6xl mb-4 block">📭</span>
<p class="text-xl">No hay posts</p>
<p class="mt-2">Crea tu primera publicación</p>
<a href="/compose" class="btn-primary px-6 py-3 rounded-xl font-medium inline-flex items-center gap-2 mt-4">
<span>✍️</span>
<span>Crear Post</span>
</a>
</div>
{% endfor %}
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<!-- Post Detail Modal (larger) -->
<div id="post-modal" class="fixed inset-0 bg-black/50 backdrop-blur-sm hidden items-center justify-center z-50">
<div class="card rounded-2xl p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto animate-fade-in">
<div id="post-modal-content"></div>
</div>
</div>
<script>
// View post details
async function viewPost(id) {
const modal = document.getElementById('post-modal');
const content = document.getElementById('post-modal-content');
content.innerHTML = `<div class="flex items-center gap-3"><div class="animate-spin w-5 h-5 border-2 border-primary border-t-transparent rounded-full"></div><span>Cargando...</span></div>`;
modal.classList.remove('hidden');
modal.classList.add('flex');
try {
const response = await fetch(`/api/posts/${id}`);
const post = await response.json();
// Build platform tabs
let platformTabs = '';
let platformContents = '';
const platforms = post.platforms || [];
platforms.forEach((platform, index) => {
const isActive = index === 0;
const platformContent = getPlatformContent(post, platform);
platformTabs += `
<button onclick="switchTab('${platform}')"
id="tab-${platform}"
class="tab-btn px-4 py-2 rounded-lg text-sm transition-colors ${isActive ? 'bg-primary text-white' : 'bg-dark-700 text-gray-400 hover:text-white'}">
${getPlatformIcon(platform)} ${platform}
</button>`;
platformContents += `
<div id="content-${platform}" class="tab-content ${isActive ? '' : 'hidden'}">
<div class="bg-dark-800 rounded-xl p-4 mb-4">
<pre class="whitespace-pre-wrap text-gray-200 font-sans text-sm leading-relaxed">${escapeHtml(platformContent)}</pre>
</div>
<button onclick="copyContent('${platform}')" class="btn-secondary px-4 py-2 rounded-lg text-sm flex items-center gap-2">
<span>📋</span> Copiar contenido
</button>
<input type="hidden" id="copy-${platform}" value="${escapeHtml(platformContent)}">
</div>`;
});
// Status badge color
const statusColors = {
'published': 'bg-green-500/20 text-green-400',
'scheduled': 'bg-blue-500/20 text-blue-400',
'pending_approval': 'bg-yellow-500/20 text-yellow-400',
'draft': 'bg-gray-500/20 text-gray-400',
'failed': 'bg-red-500/20 text-red-400'
};
content.innerHTML = `
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold">Post #${post.id}</h2>
<button onclick="closePostModal()" class="text-gray-400 hover:text-white">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="flex flex-wrap gap-2 mb-4">
<span class="text-xs px-2 py-1 rounded-full ${statusColors[post.status] || 'bg-gray-500/20'}">${post.status}</span>
<span class="text-xs px-2 py-1 rounded-full bg-dark-700">${post.content_type}</span>
${post.quality_score ? `<span class="text-xs px-2 py-1 rounded-full bg-primary/20 text-primary">Score: ${post.quality_score}</span>` : ''}
</div>
<div class="flex gap-2 mb-4 flex-wrap">
${platformTabs}
</div>
${platformContents}
<div class="flex flex-wrap gap-2 mt-6 pt-4 border-t border-dark-700">
${post.status === 'draft' || post.status === 'pending_approval' || post.status === 'failed' ?
`<button onclick="markAsPublished(${post.id})" class="flex-1 bg-green-500/20 text-green-400 px-4 py-2 rounded-lg hover:bg-green-500/30 transition-colors">
✓ Ya lo publiqué
</button>
<button onclick="closePostModal(); publishPost(${post.id})" class="flex-1 bg-blue-500/20 text-blue-400 px-4 py-2 rounded-lg hover:bg-blue-500/30 transition-colors">
🚀 Publicar (API)
</button>` : ''}
<button onclick="closePostModal(); deletePost(${post.id})" class="flex-1 bg-red-500/20 text-red-400 px-4 py-2 rounded-lg hover:bg-red-500/30 transition-colors">
Eliminar
</button>
<button onclick="closePostModal()" class="flex-1 btn-secondary px-4 py-2 rounded-lg">
Cerrar
</button>
</div>
`;
} catch (error) {
content.innerHTML = `
<div class="text-center">
<span class="text-4xl mb-4 block">❌</span>
<p>Error cargando el post</p>
<button onclick="closePostModal()" class="mt-4 btn-secondary px-4 py-2 rounded-lg">Cerrar</button>
</div>`;
}
}
function getPlatformContent(post, platform) {
const contentMap = {
'x': post.content_x,
'threads': post.content_threads,
'instagram': post.content_instagram,
'facebook': post.content_facebook
};
return contentMap[platform] || post.content || '';
}
function getPlatformIcon(platform) {
const icons = { 'x': '𝕏', 'threads': '🧵', 'instagram': '📷', 'facebook': '📘' };
return icons[platform] || '📱';
}
function switchTab(platform) {
// Hide all contents and deactivate tabs
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
document.querySelectorAll('.tab-btn').forEach(el => {
el.classList.remove('bg-primary', 'text-white');
el.classList.add('bg-dark-700', 'text-gray-400');
});
// Show selected content and activate tab
document.getElementById(`content-${platform}`).classList.remove('hidden');
const tab = document.getElementById(`tab-${platform}`);
tab.classList.remove('bg-dark-700', 'text-gray-400');
tab.classList.add('bg-primary', 'text-white');
}
async function copyContent(platform) {
const content = document.getElementById(`copy-${platform}`).value;
try {
await navigator.clipboard.writeText(content);
showModal('<div class="text-center"><span class="text-4xl mb-4 block">✅</span><p>¡Copiado!</p></div>');
setTimeout(closeModal, 1000);
} catch (err) {
// Fallback for older browsers
const textarea = document.createElement('textarea');
textarea.value = content;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
showModal('<div class="text-center"><span class="text-4xl mb-4 block">✅</span><p>¡Copiado!</p></div>');
setTimeout(closeModal, 1000);
}
}
function closePostModal() {
const modal = document.getElementById('post-modal');
modal.classList.add('hidden');
modal.classList.remove('flex');
}
async function markAsPublished(id) {
try {
const response = await fetch(`/api/posts/${id}/mark-published`, { method: 'POST' });
const data = await response.json();
if (data.success) {
closePostModal();
showModal('<div class="text-center"><span class="text-4xl mb-4 block">✅</span><p>Marcado como publicado</p></div>');
setTimeout(() => { closeModal(); location.reload(); }, 1500);
} else {
showModal(`<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>${data.detail || 'Error'}</p></div>`);
}
} catch (error) {
showModal('<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error de conexión</p></div>');
}
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Close modal on outside click
document.getElementById('post-modal').addEventListener('click', function(e) {
if (e.target === this) closePostModal();
});
function filterPosts() {
const status = document.getElementById('filter-status').value;
const platform = document.getElementById('filter-platform').value;
const search = document.getElementById('filter-search').value.toLowerCase();
document.querySelectorAll('.post-item').forEach(post => {
const postStatus = post.dataset.status;
const postPlatforms = post.dataset.platforms;
const postContent = post.dataset.content;
const matchesStatus = !status || postStatus === status;
const matchesPlatform = !platform || postPlatforms.includes(platform);
const matchesSearch = !search || postContent.includes(search);
post.style.display = matchesStatus && matchesPlatform && matchesSearch ? 'block' : 'none';
});
}
async function publishPost(id) {
if (!confirm('¿Publicar este post ahora?')) return;
showModal('Publicando...', true);
try {
const response = await fetch(`/api/posts/${id}/publish`, { method: 'POST' });
const data = await response.json();
if (data.success) {
showModal('<div class="text-center"><span class="text-4xl mb-4 block">🎉</span><p>¡Publicado!</p></div>');
setTimeout(() => location.reload(), 1500);
} else {
showModal(`<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>${data.error || 'Error'}</p></div>`);
}
} catch (error) {
showModal('<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error de conexión</p></div>');
}
}
async function deletePost(id) {
if (!confirm('¿Eliminar este post?')) return;
showModal('Eliminando...', true);
try {
const response = await fetch(`/api/posts/${id}`, { method: 'DELETE' });
if (response.ok) {
showModal('<div class="text-center"><span class="text-4xl mb-4 block">🗑️</span><p>Eliminado</p></div>');
setTimeout(() => location.reload(), 1000);
} else {
showModal('<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error eliminando</p></div>');
}
} catch (error) {
showModal('<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error de conexión</p></div>');
}
}
</script>
{% endblock %}