- 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>
354 lines
17 KiB
HTML
354 lines
17 KiB
HTML
{% 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 %}
|