feat(phase-5): Complete dashboard UI templates
- Add posts.html: Post management with filtering by status/platform/type, stats display, pagination, edit modal, and actions (approve, reject, publish now, schedule, edit, delete) - Add calendar.html: Visual calendar with month/week views, drag-and-drop rescheduling, platform filtering with color-coded status - Add interactions.html: Interactions management with filtering, detail panel for responding, AI response suggestions, lead marking - Add settings.html: API connection status, DeepSeek config, Telegram notifications setup, system info, and quick actions - Update dashboard.py with settings route Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -167,3 +167,16 @@ async def dashboard_services(request: Request, db: Session = Depends(get_db)):
|
|||||||
"request": request,
|
"request": request,
|
||||||
"user": user.to_dict()
|
"user": user.to_dict()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings", response_class=HTMLResponse)
|
||||||
|
async def dashboard_settings(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Página de configuración."""
|
||||||
|
user = require_auth(request, db)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login", status_code=302)
|
||||||
|
|
||||||
|
return templates.TemplateResponse("settings.html", {
|
||||||
|
"request": request,
|
||||||
|
"user": user.to_dict()
|
||||||
|
})
|
||||||
|
|||||||
436
dashboard/templates/calendar.html
Normal file
436
dashboard/templates/calendar.html
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Calendario - Social Media Automation</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { background-color: #1a1a2e; color: #eee; }
|
||||||
|
.card { background-color: #16213e; border-radius: 12px; }
|
||||||
|
.accent { color: #d4a574; }
|
||||||
|
.btn-primary { background-color: #d4a574; color: #1a1a2e; }
|
||||||
|
.btn-primary:hover { background-color: #c49564; }
|
||||||
|
.btn-secondary { background-color: #374151; }
|
||||||
|
.btn-secondary:hover { background-color: #4b5563; }
|
||||||
|
.calendar-day { min-height: 120px; }
|
||||||
|
.calendar-day:hover { background-color: #1e3a5f; }
|
||||||
|
.calendar-day.today { border: 2px solid #d4a574; }
|
||||||
|
.calendar-day.other-month { opacity: 0.4; }
|
||||||
|
.post-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||||
|
.post-item { font-size: 11px; padding: 2px 4px; border-radius: 4px; margin-bottom: 2px; cursor: pointer; }
|
||||||
|
.post-item:hover { opacity: 0.8; }
|
||||||
|
.status-published { background-color: #065f46; }
|
||||||
|
.status-scheduled { background-color: #1e40af; }
|
||||||
|
.status-pending { background-color: #92400e; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="bg-gray-900 border-b border-gray-800 px-6 py-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h1 class="text-2xl font-bold">
|
||||||
|
<span class="accent">Consultoría AS</span> - Social Media
|
||||||
|
</h1>
|
||||||
|
<nav class="flex gap-4">
|
||||||
|
<a href="/dashboard" class="px-4 py-2 rounded hover:bg-gray-800">Home</a>
|
||||||
|
<a href="/dashboard/compose" class="px-4 py-2 rounded hover:bg-gray-800">+ Crear</a>
|
||||||
|
<a href="/dashboard/posts" class="px-4 py-2 rounded hover:bg-gray-800">Posts</a>
|
||||||
|
<a href="/dashboard/calendar" class="px-4 py-2 rounded bg-gray-800 accent">Calendario</a>
|
||||||
|
<a href="/dashboard/interactions" class="px-4 py-2 rounded hover:bg-gray-800">Interacciones</a>
|
||||||
|
<a href="/dashboard/settings" class="px-4 py-2 rounded hover:bg-gray-800">Config</a>
|
||||||
|
<a href="/logout" class="px-4 py-2 rounded hover:bg-gray-800 text-red-400">Salir</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-6 py-8">
|
||||||
|
<!-- Calendar Controls -->
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<button onclick="prevMonth()" class="btn-secondary px-3 py-2 rounded">
|
||||||
|
← Anterior
|
||||||
|
</button>
|
||||||
|
<h2 class="text-2xl font-bold" id="current-month">Enero 2025</h2>
|
||||||
|
<button onclick="nextMonth()" class="btn-secondary px-3 py-2 rounded">
|
||||||
|
Siguiente →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="setView('month')" id="btn-month" class="btn-primary px-4 py-2 rounded">
|
||||||
|
Mes
|
||||||
|
</button>
|
||||||
|
<button onclick="setView('week')" id="btn-week" class="btn-secondary px-4 py-2 rounded">
|
||||||
|
Semana
|
||||||
|
</button>
|
||||||
|
<button onclick="goToToday()" class="btn-secondary px-4 py-2 rounded">
|
||||||
|
Hoy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Platform Filters -->
|
||||||
|
<div class="card p-4 mb-6">
|
||||||
|
<div class="flex gap-4 items-center">
|
||||||
|
<span class="text-gray-400">Filtrar:</span>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked class="platform-filter" value="x" onchange="filterCalendar()">
|
||||||
|
<span class="bg-gray-800 px-2 py-1 rounded text-sm">X</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked class="platform-filter" value="threads" onchange="filterCalendar()">
|
||||||
|
<span class="bg-gray-800 px-2 py-1 rounded text-sm">Threads</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked class="platform-filter" value="instagram" onchange="filterCalendar()">
|
||||||
|
<span class="bg-pink-900 px-2 py-1 rounded text-sm">Instagram</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked class="platform-filter" value="facebook" onchange="filterCalendar()">
|
||||||
|
<span class="bg-blue-900 px-2 py-1 rounded text-sm">Facebook</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<div class="flex gap-2 text-xs">
|
||||||
|
<span class="flex items-center gap-1"><span class="post-dot status-scheduled"></span> Programado</span>
|
||||||
|
<span class="flex items-center gap-1"><span class="post-dot status-pending"></span> Pendiente</span>
|
||||||
|
<span class="flex items-center gap-1"><span class="post-dot status-published"></span> Publicado</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Calendar Grid -->
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<!-- Day Headers -->
|
||||||
|
<div class="grid grid-cols-7 bg-gray-800">
|
||||||
|
<div class="p-3 text-center font-semibold border-r border-gray-700">Lun</div>
|
||||||
|
<div class="p-3 text-center font-semibold border-r border-gray-700">Mar</div>
|
||||||
|
<div class="p-3 text-center font-semibold border-r border-gray-700">Mié</div>
|
||||||
|
<div class="p-3 text-center font-semibold border-r border-gray-700">Jue</div>
|
||||||
|
<div class="p-3 text-center font-semibold border-r border-gray-700">Vie</div>
|
||||||
|
<div class="p-3 text-center font-semibold border-r border-gray-700 text-blue-400">Sáb</div>
|
||||||
|
<div class="p-3 text-center font-semibold text-blue-400">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 hidden mt-6">
|
||||||
|
<div id="week-grid" class="divide-y divide-gray-700">
|
||||||
|
<!-- Week days filled dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Post Detail Modal -->
|
||||||
|
<div id="post-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
|
<div class="card 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">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="post-modal-content">
|
||||||
|
<!-- Content loaded dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentDate = new Date();
|
||||||
|
let currentView = 'month';
|
||||||
|
let calendarData = {};
|
||||||
|
let allPosts = [];
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
loadCalendarData();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadCalendarData() {
|
||||||
|
const year = currentDate.getFullYear();
|
||||||
|
const month = currentDate.getMonth();
|
||||||
|
|
||||||
|
// Get first and last day of visible calendar (including overflow days)
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
const lastDay = new Date(year, month + 1, 0);
|
||||||
|
|
||||||
|
// Adjust for Monday start
|
||||||
|
const startDate = new Date(firstDay);
|
||||||
|
startDate.setDate(startDate.getDate() - (startDate.getDay() || 7) + 1);
|
||||||
|
|
||||||
|
const endDate = new Date(lastDay);
|
||||||
|
endDate.setDate(endDate.getDate() + (7 - endDate.getDay()) % 7);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update month title
|
||||||
|
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 lastDay = new Date(year, month + 1, 0);
|
||||||
|
|
||||||
|
// Start from Monday
|
||||||
|
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-gray-700 ${isToday ? 'today' : ''} ${isOtherMonth ? 'other-month' : ''}"
|
||||||
|
ondrop="dropPost(event, '${dateKey}')" ondragover="allowDrop(event)">
|
||||||
|
<div class="font-semibold text-sm mb-1 ${isToday ? 'accent' : ''}">${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); // Monday
|
||||||
|
|
||||||
|
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-gray-800' : ''}" ondrop="dropPost(event, '${dateKey}')" ondragover="allowDrop(event)">
|
||||||
|
<div class="flex items-center gap-4 mb-3">
|
||||||
|
<div class="font-semibold ${isToday ? 'accent' : ''}">${days[i]}</div>
|
||||||
|
<div class="text-gray-400">${date.getDate()}/${date.getMonth() + 1}</div>
|
||||||
|
${isToday ? '<span class="bg-amber-600 text-xs px-2 py-1 rounded">Hoy</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-4 gap-2">
|
||||||
|
${dayPosts.map(post => `
|
||||||
|
<div class="post-item status-${post.status} p-2"
|
||||||
|
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' : 'btn-secondary px-4 py-2 rounded';
|
||||||
|
document.getElementById('btn-week').className = view === 'week' ? 'btn-primary px-4 py-2 rounded' : 'btn-secondary px-4 py-2 rounded';
|
||||||
|
|
||||||
|
document.getElementById('calendar-grid').parentElement.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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag and drop
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post detail modal
|
||||||
|
function showPostDetail(postId) {
|
||||||
|
// Find post in calendar data
|
||||||
|
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 text-xs">${post.status}</span>
|
||||||
|
${post.platforms ? post.platforms.map(p => `<span class="bg-gray-700 px-2 py-1 rounded 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-gray-700">
|
||||||
|
<a href="/dashboard/posts" class="btn-secondary px-4 py-2 rounded text-sm">Ver en Posts</a>
|
||||||
|
${post.status === 'scheduled' ? `
|
||||||
|
<button onclick="publishNowFromModal(${post.id})" class="btn-primary px-4 py-2 rounded 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();
|
||||||
|
alert('Post enviado a publicación');
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
function formatDateISO(date) {
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
424
dashboard/templates/interactions.html
Normal file
424
dashboard/templates/interactions.html
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Interacciones - Social Media Automation</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { background-color: #1a1a2e; color: #eee; }
|
||||||
|
.card { background-color: #16213e; border-radius: 12px; }
|
||||||
|
.accent { color: #d4a574; }
|
||||||
|
.btn-primary { background-color: #d4a574; color: #1a1a2e; }
|
||||||
|
.btn-primary:hover { background-color: #c49564; }
|
||||||
|
.btn-secondary { background-color: #374151; }
|
||||||
|
.btn-secondary:hover { background-color: #4b5563; }
|
||||||
|
.interaction-item { transition: all 0.2s; }
|
||||||
|
.interaction-item:hover { background-color: #1e3a5f; }
|
||||||
|
.interaction-item.selected { border-left: 3px solid #d4a574; }
|
||||||
|
.platform-x { color: #1d9bf0; }
|
||||||
|
.platform-threads { color: #fff; }
|
||||||
|
.platform-instagram { color: #e1306c; }
|
||||||
|
.platform-facebook { color: #1877f2; }
|
||||||
|
.type-comment { background-color: #1e40af; }
|
||||||
|
.type-mention { background-color: #7c3aed; }
|
||||||
|
.type-dm { background-color: #059669; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="bg-gray-900 border-b border-gray-800 px-6 py-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h1 class="text-2xl font-bold">
|
||||||
|
<span class="accent">Consultoría AS</span> - Social Media
|
||||||
|
</h1>
|
||||||
|
<nav class="flex gap-4">
|
||||||
|
<a href="/dashboard" class="px-4 py-2 rounded hover:bg-gray-800">Home</a>
|
||||||
|
<a href="/dashboard/compose" class="px-4 py-2 rounded hover:bg-gray-800">+ Crear</a>
|
||||||
|
<a href="/dashboard/posts" class="px-4 py-2 rounded hover:bg-gray-800">Posts</a>
|
||||||
|
<a href="/dashboard/calendar" class="px-4 py-2 rounded hover:bg-gray-800">Calendario</a>
|
||||||
|
<a href="/dashboard/interactions" class="px-4 py-2 rounded bg-gray-800 accent">Interacciones</a>
|
||||||
|
<a href="/dashboard/settings" class="px-4 py-2 rounded hover:bg-gray-800">Config</a>
|
||||||
|
<a href="/logout" class="px-4 py-2 rounded hover:bg-gray-800 text-red-400">Salir</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-6 py-8">
|
||||||
|
<div class="grid grid-cols-3 gap-6">
|
||||||
|
<!-- Interactions List -->
|
||||||
|
<div class="col-span-2">
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="card p-4 mb-4">
|
||||||
|
<div class="flex gap-4 items-center">
|
||||||
|
<select id="filter-platform" onchange="filterInteractions()" class="bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
||||||
|
<option value="">Todas las plataformas</option>
|
||||||
|
<option value="x">X (Twitter)</option>
|
||||||
|
<option value="threads">Threads</option>
|
||||||
|
<option value="instagram">Instagram</option>
|
||||||
|
<option value="facebook">Facebook</option>
|
||||||
|
</select>
|
||||||
|
<select id="filter-type" onchange="filterInteractions()" class="bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
||||||
|
<option value="">Todos los tipos</option>
|
||||||
|
<option value="comment">Comentarios</option>
|
||||||
|
<option value="mention">Menciones</option>
|
||||||
|
<option value="dm">Mensajes</option>
|
||||||
|
</select>
|
||||||
|
<select id="filter-status" onchange="filterInteractions()" class="bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
||||||
|
<option value="pending">Sin responder</option>
|
||||||
|
<option value="responded">Respondidos</option>
|
||||||
|
<option value="all">Todos</option>
|
||||||
|
</select>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button onclick="syncInteractions()" class="btn-secondary px-4 py-2 rounded flex items-center gap-2">
|
||||||
|
<span id="sync-icon">🔄</span> Sincronizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="grid grid-cols-4 gap-4 mb-4">
|
||||||
|
<div class="card p-3 text-center">
|
||||||
|
<div class="text-xl font-bold text-red-400" id="stat-pending">0</div>
|
||||||
|
<div class="text-xs text-gray-400">Sin responder</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-3 text-center">
|
||||||
|
<div class="text-xl font-bold text-green-400" id="stat-responded">0</div>
|
||||||
|
<div class="text-xs text-gray-400">Respondidos</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-3 text-center">
|
||||||
|
<div class="text-xl font-bold text-purple-400" id="stat-leads">0</div>
|
||||||
|
<div class="text-xs text-gray-400">Leads</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-3 text-center">
|
||||||
|
<div class="text-xl font-bold text-gray-400" id="stat-total">0</div>
|
||||||
|
<div class="text-xs text-gray-400">Total</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="p-4 border-b border-gray-700">
|
||||||
|
<h2 class="font-semibold">Interacciones</h2>
|
||||||
|
</div>
|
||||||
|
<div id="interactions-list" class="divide-y divide-gray-700 max-h-screen overflow-y-auto">
|
||||||
|
<div class="p-8 text-center text-gray-500">Cargando...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detail Panel -->
|
||||||
|
<div class="col-span-1">
|
||||||
|
<div class="card p-6 sticky top-6" id="detail-panel">
|
||||||
|
<div class="text-center text-gray-500 py-8">
|
||||||
|
<p>Selecciona una interacción</p>
|
||||||
|
<p class="text-sm mt-2">para ver detalles y responder</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let interactions = [];
|
||||||
|
let selectedId = null;
|
||||||
|
|
||||||
|
window.addEventListener('load', loadInteractions);
|
||||||
|
|
||||||
|
async function loadInteractions() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/interactions/');
|
||||||
|
interactions = await response.json();
|
||||||
|
updateStats();
|
||||||
|
filterInteractions();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
document.getElementById('interactions-list').innerHTML =
|
||||||
|
'<div class="p-8 text-center text-red-400">Error cargando interacciones</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats() {
|
||||||
|
const pending = interactions.filter(i => !i.responded && !i.is_archived).length;
|
||||||
|
const responded = interactions.filter(i => i.responded).length;
|
||||||
|
const leads = interactions.filter(i => i.is_potential_lead).length;
|
||||||
|
|
||||||
|
document.getElementById('stat-pending').textContent = pending;
|
||||||
|
document.getElementById('stat-responded').textContent = responded;
|
||||||
|
document.getElementById('stat-leads').textContent = leads;
|
||||||
|
document.getElementById('stat-total').textContent = interactions.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterInteractions() {
|
||||||
|
const platform = document.getElementById('filter-platform').value;
|
||||||
|
const type = document.getElementById('filter-type').value;
|
||||||
|
const status = document.getElementById('filter-status').value;
|
||||||
|
|
||||||
|
let filtered = interactions;
|
||||||
|
|
||||||
|
if (platform) {
|
||||||
|
filtered = filtered.filter(i => i.platform === platform);
|
||||||
|
}
|
||||||
|
if (type) {
|
||||||
|
filtered = filtered.filter(i => i.interaction_type === type);
|
||||||
|
}
|
||||||
|
if (status === 'pending') {
|
||||||
|
filtered = filtered.filter(i => !i.responded && !i.is_archived);
|
||||||
|
} else if (status === 'responded') {
|
||||||
|
filtered = filtered.filter(i => i.responded);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderInteractions(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInteractions(items) {
|
||||||
|
const container = document.getElementById('interactions-list');
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
container.innerHTML = '<div class="p-8 text-center text-gray-500">No hay interacciones</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = items.map(item => `
|
||||||
|
<div class="interaction-item p-4 cursor-pointer ${selectedId === item.id ? 'selected' : ''}"
|
||||||
|
onclick="selectInteraction(${item.id})">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="platform-${item.platform} font-medium">@${item.author_username || 'usuario'}</span>
|
||||||
|
<span class="type-${item.interaction_type} text-xs px-2 py-0.5 rounded">${item.interaction_type}</span>
|
||||||
|
<span class="text-gray-500 text-xs">${item.platform}</span>
|
||||||
|
${item.is_potential_lead ? '<span class="bg-purple-600 text-xs px-2 py-0.5 rounded">Lead</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-300 text-sm line-clamp-2">${escapeHtml(item.content || '')}</p>
|
||||||
|
<div class="text-gray-500 text-xs mt-1">
|
||||||
|
${formatDate(item.interaction_at)}
|
||||||
|
${item.responded ? '<span class="text-green-400 ml-2">✓ Respondido</span>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${!item.responded ? '<span class="w-2 h-2 bg-red-500 rounded-full"></span>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectInteraction(id) {
|
||||||
|
selectedId = id;
|
||||||
|
const item = interactions.find(i => i.id === id);
|
||||||
|
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
// Update selection visual
|
||||||
|
document.querySelectorAll('.interaction-item').forEach(el => el.classList.remove('selected'));
|
||||||
|
event.currentTarget?.classList.add('selected');
|
||||||
|
|
||||||
|
// Render detail panel
|
||||||
|
const panel = document.getElementById('detail-panel');
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="platform-${item.platform} font-semibold text-lg">@${item.author_username || 'usuario'}</span>
|
||||||
|
${item.is_potential_lead ? '<span class="bg-purple-600 text-xs px-2 py-1 rounded">Lead</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<span class="type-${item.interaction_type} text-xs px-2 py-1 rounded">${item.interaction_type}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gray-800 rounded-lg p-4">
|
||||||
|
<p class="text-gray-200">${escapeHtml(item.content || '')}</p>
|
||||||
|
<div class="text-gray-500 text-xs mt-2">
|
||||||
|
${formatDate(item.interaction_at)} • ${item.platform}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${item.responded && item.response_content ? `
|
||||||
|
<div class="bg-green-900 bg-opacity-30 rounded-lg p-4">
|
||||||
|
<div class="text-xs text-green-400 mb-1">Tu respuesta:</div>
|
||||||
|
<p class="text-gray-200">${escapeHtml(item.response_content)}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${!item.responded ? `
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-2">Responder</label>
|
||||||
|
<textarea id="response-text" rows="3"
|
||||||
|
class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm"
|
||||||
|
placeholder="Escribe tu respuesta..."></textarea>
|
||||||
|
|
||||||
|
<div class="flex gap-2 mt-2">
|
||||||
|
<button onclick="generateSuggestions(${item.id})" class="btn-secondary px-3 py-1 rounded text-sm flex-1">
|
||||||
|
🤖 Sugerir IA
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="suggestions-${item.id}" class="hidden mt-3 space-y-2">
|
||||||
|
<!-- AI suggestions -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="sendResponse(${item.id})" class="btn-primary flex-1 py-2 rounded">
|
||||||
|
Responder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="flex gap-2 pt-4 border-t border-gray-700">
|
||||||
|
${!item.is_potential_lead ? `
|
||||||
|
<button onclick="markAsLead(${item.id})" class="btn-secondary px-3 py-2 rounded text-sm flex-1">
|
||||||
|
⭐ Marcar Lead
|
||||||
|
</button>
|
||||||
|
` : `
|
||||||
|
<button onclick="unmarkLead(${item.id})" class="btn-secondary px-3 py-2 rounded text-sm flex-1">
|
||||||
|
Quitar Lead
|
||||||
|
</button>
|
||||||
|
`}
|
||||||
|
<button onclick="archiveInteraction(${item.id})" class="text-gray-400 hover:text-red-400 px-3 py-2 text-sm">
|
||||||
|
Archivar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateSuggestions(id) {
|
||||||
|
const item = interactions.find(i => i.id === id);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
const suggestionsDiv = document.getElementById(`suggestions-${id}`);
|
||||||
|
suggestionsDiv.classList.remove('hidden');
|
||||||
|
suggestionsDiv.innerHTML = '<div class="text-gray-400 text-sm">Generando sugerencias...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/generate/response', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
interaction_content: item.content,
|
||||||
|
interaction_type: item.interaction_type
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.contents) {
|
||||||
|
suggestionsDiv.innerHTML = data.contents.map((suggestion, i) => `
|
||||||
|
<div class="bg-gray-800 rounded p-2 text-sm cursor-pointer hover:bg-gray-700"
|
||||||
|
onclick="useSuggestion('${escapeHtml(suggestion).replace(/'/g, "\\'")}')">
|
||||||
|
${i + 1}. ${escapeHtml(suggestion)}
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} else {
|
||||||
|
suggestionsDiv.innerHTML = '<div class="text-red-400 text-sm">Error: ' + (data.error || 'No se pudo generar') + '</div>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
suggestionsDiv.innerHTML = '<div class="text-red-400 text-sm">Error de conexión</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSuggestion(text) {
|
||||||
|
document.getElementById('response-text').value = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendResponse(id) {
|
||||||
|
const responseText = document.getElementById('response-text').value.trim();
|
||||||
|
if (!responseText) {
|
||||||
|
alert('Escribe una respuesta');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/interactions/${id}/respond`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ response: responseText })
|
||||||
|
});
|
||||||
|
|
||||||
|
loadInteractions();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error al enviar: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAsLead(id) {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/interactions/${id}/mark-lead`, { method: 'POST' });
|
||||||
|
loadInteractions();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unmarkLead(id) {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/interactions/${id}/unmark-lead`, { method: 'POST' });
|
||||||
|
loadInteractions();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveInteraction(id) {
|
||||||
|
if (!confirm('¿Archivar esta interacción?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/interactions/${id}/archive`, { method: 'POST' });
|
||||||
|
loadInteractions();
|
||||||
|
document.getElementById('detail-panel').innerHTML =
|
||||||
|
'<div class="text-center text-gray-500 py-8"><p>Interacción archivada</p></div>';
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncInteractions() {
|
||||||
|
const icon = document.getElementById('sync-icon');
|
||||||
|
icon.style.animation = 'spin 1s linear infinite';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch('/api/interactions/sync', { method: 'POST' });
|
||||||
|
await loadInteractions();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error al sincronizar: ' + error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
icon.style.animation = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now - date;
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 60) return `hace ${diffMins}m`;
|
||||||
|
if (diffHours < 24) return `hace ${diffHours}h`;
|
||||||
|
if (diffDays < 7) return `hace ${diffDays}d`;
|
||||||
|
|
||||||
|
return date.toLocaleDateString('es-MX', { day: '2-digit', month: 'short' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text || '';
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
449
dashboard/templates/posts.html
Normal file
449
dashboard/templates/posts.html
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Posts - Social Media Automation</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { background-color: #1a1a2e; color: #eee; }
|
||||||
|
.card { background-color: #16213e; border-radius: 12px; }
|
||||||
|
.accent { color: #d4a574; }
|
||||||
|
.btn-primary { background-color: #d4a574; color: #1a1a2e; }
|
||||||
|
.btn-primary:hover { background-color: #c49564; }
|
||||||
|
.btn-secondary { background-color: #374151; }
|
||||||
|
.btn-secondary:hover { background-color: #4b5563; }
|
||||||
|
.btn-danger { background-color: #dc2626; }
|
||||||
|
.btn-danger:hover { background-color: #b91c1c; }
|
||||||
|
.status-published { background-color: #065f46; color: #6ee7b7; }
|
||||||
|
.status-scheduled { background-color: #1e40af; color: #93c5fd; }
|
||||||
|
.status-pending { background-color: #92400e; color: #fcd34d; }
|
||||||
|
.status-draft { background-color: #374151; color: #9ca3af; }
|
||||||
|
.status-failed { background-color: #991b1b; color: #fca5a5; }
|
||||||
|
.platform-x { background-color: #1d1d1d; }
|
||||||
|
.platform-threads { background-color: #1a1a1a; }
|
||||||
|
.platform-instagram { background: linear-gradient(45deg, #f09433, #e6683c, #dc2743, #cc2366, #bc1888); }
|
||||||
|
.platform-facebook { background-color: #1877f2; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="bg-gray-900 border-b border-gray-800 px-6 py-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h1 class="text-2xl font-bold">
|
||||||
|
<span class="accent">Consultoría AS</span> - Social Media
|
||||||
|
</h1>
|
||||||
|
<nav class="flex gap-4">
|
||||||
|
<a href="/dashboard" class="px-4 py-2 rounded hover:bg-gray-800">Home</a>
|
||||||
|
<a href="/dashboard/compose" class="px-4 py-2 rounded hover:bg-gray-800">+ Crear</a>
|
||||||
|
<a href="/dashboard/posts" class="px-4 py-2 rounded bg-gray-800 accent">Posts</a>
|
||||||
|
<a href="/dashboard/calendar" class="px-4 py-2 rounded hover:bg-gray-800">Calendario</a>
|
||||||
|
<a href="/dashboard/interactions" class="px-4 py-2 rounded hover:bg-gray-800">Interacciones</a>
|
||||||
|
<a href="/dashboard/settings" class="px-4 py-2 rounded hover:bg-gray-800">Config</a>
|
||||||
|
<a href="/logout" class="px-4 py-2 rounded hover:bg-gray-800 text-red-400">Salir</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-6 py-8">
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="card p-4 mb-6">
|
||||||
|
<div class="flex flex-wrap gap-4 items-center">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-gray-400 block mb-1">Estado</label>
|
||||||
|
<select id="filter-status" onchange="filterPosts()" class="bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
||||||
|
<option value="">Todos</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>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-gray-400 block mb-1">Plataforma</label>
|
||||||
|
<select id="filter-platform" onchange="filterPosts()" class="bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
||||||
|
<option value="">Todas</option>
|
||||||
|
<option value="x">X (Twitter)</option>
|
||||||
|
<option value="threads">Threads</option>
|
||||||
|
<option value="instagram">Instagram</option>
|
||||||
|
<option value="facebook">Facebook</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm text-gray-400 block mb-1">Tipo</label>
|
||||||
|
<select id="filter-type" onchange="filterPosts()" class="bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
||||||
|
<option value="">Todos</option>
|
||||||
|
<option value="tip">Tips</option>
|
||||||
|
<option value="product">Productos</option>
|
||||||
|
<option value="service">Servicios</option>
|
||||||
|
<option value="engagement">Engagement</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<div>
|
||||||
|
<a href="/dashboard/compose" class="btn-primary px-4 py-2 rounded inline-block">
|
||||||
|
+ Nuevo Post
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="grid grid-cols-5 gap-4 mb-6">
|
||||||
|
<div class="card p-4 text-center">
|
||||||
|
<div class="text-2xl font-bold text-green-400" id="stat-published">0</div>
|
||||||
|
<div class="text-gray-400 text-sm">Publicados</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4 text-center">
|
||||||
|
<div class="text-2xl font-bold text-blue-400" id="stat-scheduled">0</div>
|
||||||
|
<div class="text-gray-400 text-sm">Programados</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4 text-center">
|
||||||
|
<div class="text-2xl font-bold text-yellow-400" id="stat-pending">0</div>
|
||||||
|
<div class="text-gray-400 text-sm">Pendientes</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4 text-center">
|
||||||
|
<div class="text-2xl font-bold text-gray-400" id="stat-draft">0</div>
|
||||||
|
<div class="text-gray-400 text-sm">Borradores</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4 text-center">
|
||||||
|
<div class="text-2xl font-bold text-red-400" id="stat-failed">0</div>
|
||||||
|
<div class="text-gray-400 text-sm">Fallidos</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Posts List -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="p-4 border-b border-gray-700 flex justify-between items-center">
|
||||||
|
<h2 class="text-xl font-semibold">Posts</h2>
|
||||||
|
<span class="text-gray-400 text-sm" id="posts-count">0 posts</span>
|
||||||
|
</div>
|
||||||
|
<div id="posts-list" class="divide-y divide-gray-700">
|
||||||
|
<!-- Posts loaded dynamically -->
|
||||||
|
<div class="p-8 text-center text-gray-500">
|
||||||
|
Cargando posts...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="flex justify-center gap-2 mt-6" id="pagination">
|
||||||
|
<!-- Pagination buttons -->
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Edit Modal -->
|
||||||
|
<div id="edit-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
|
<div class="card p-6 max-w-2xl w-full mx-4">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="font-semibold text-lg">Editar Post</h3>
|
||||||
|
<button onclick="closeEditModal()" class="text-gray-400 hover:text-white">✕</button>
|
||||||
|
</div>
|
||||||
|
<form id="edit-form">
|
||||||
|
<input type="hidden" id="edit-post-id">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm text-gray-400 mb-1">Contenido</label>
|
||||||
|
<textarea id="edit-content" rows="4" class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2"></textarea>
|
||||||
|
<div class="text-right text-sm text-gray-500 mt-1">
|
||||||
|
<span id="edit-char-count">0</span> caracteres
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm text-gray-400 mb-1">Programar para</label>
|
||||||
|
<input type="datetime-local" id="edit-scheduled" class="bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button type="button" onclick="closeEditModal()" class="btn-secondary px-4 py-2 rounded">Cancelar</button>
|
||||||
|
<button type="submit" class="btn-primary px-4 py-2 rounded">Guardar</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let allPosts = [];
|
||||||
|
let currentPage = 1;
|
||||||
|
const postsPerPage = 20;
|
||||||
|
|
||||||
|
// Load posts on page load
|
||||||
|
window.addEventListener('load', loadPosts);
|
||||||
|
|
||||||
|
async function loadPosts() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/posts/');
|
||||||
|
allPosts = await response.json();
|
||||||
|
updateStats();
|
||||||
|
filterPosts();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading posts:', error);
|
||||||
|
document.getElementById('posts-list').innerHTML =
|
||||||
|
'<div class="p-8 text-center text-red-400">Error cargando posts</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats() {
|
||||||
|
const stats = {
|
||||||
|
published: 0,
|
||||||
|
scheduled: 0,
|
||||||
|
pending_approval: 0,
|
||||||
|
draft: 0,
|
||||||
|
failed: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
allPosts.forEach(post => {
|
||||||
|
if (stats[post.status] !== undefined) {
|
||||||
|
stats[post.status]++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('stat-published').textContent = stats.published;
|
||||||
|
document.getElementById('stat-scheduled').textContent = stats.scheduled;
|
||||||
|
document.getElementById('stat-pending').textContent = stats.pending_approval;
|
||||||
|
document.getElementById('stat-draft').textContent = stats.draft;
|
||||||
|
document.getElementById('stat-failed').textContent = stats.failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterPosts() {
|
||||||
|
const status = document.getElementById('filter-status').value;
|
||||||
|
const platform = document.getElementById('filter-platform').value;
|
||||||
|
const type = document.getElementById('filter-type').value;
|
||||||
|
|
||||||
|
let filtered = allPosts;
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
filtered = filtered.filter(p => p.status === status);
|
||||||
|
}
|
||||||
|
if (platform) {
|
||||||
|
filtered = filtered.filter(p => p.platforms && p.platforms.includes(platform));
|
||||||
|
}
|
||||||
|
if (type) {
|
||||||
|
filtered = filtered.filter(p => p.content_type === type);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPosts(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPosts(posts) {
|
||||||
|
const container = document.getElementById('posts-list');
|
||||||
|
document.getElementById('posts-count').textContent = `${posts.length} posts`;
|
||||||
|
|
||||||
|
if (posts.length === 0) {
|
||||||
|
container.innerHTML = '<div class="p-8 text-center text-gray-500">No hay posts</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const start = (currentPage - 1) * postsPerPage;
|
||||||
|
const pagePosts = posts.slice(start, start + postsPerPage);
|
||||||
|
|
||||||
|
container.innerHTML = pagePosts.map(post => `
|
||||||
|
<div class="p-4 hover:bg-gray-800 transition-colors" data-post-id="${post.id}">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="status-${post.status} px-2 py-1 rounded text-xs font-medium">
|
||||||
|
${getStatusLabel(post.status)}
|
||||||
|
</span>
|
||||||
|
${post.platforms ? post.platforms.map(p => `
|
||||||
|
<span class="platform-${p} px-2 py-1 rounded text-xs">${p}</span>
|
||||||
|
`).join('') : ''}
|
||||||
|
<span class="text-gray-500 text-xs">${post.content_type || 'general'}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-300 mb-2">${escapeHtml(post.content || '').substring(0, 200)}${post.content && post.content.length > 200 ? '...' : ''}</p>
|
||||||
|
<div class="text-gray-500 text-xs">
|
||||||
|
${post.scheduled_at ? `Programado: ${formatDate(post.scheduled_at)}` : ''}
|
||||||
|
${post.published_at ? `Publicado: ${formatDate(post.published_at)}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
${post.status === 'pending_approval' ? `
|
||||||
|
<button onclick="approvePost(${post.id})" class="btn-primary px-3 py-1 rounded text-sm">Aprobar</button>
|
||||||
|
<button onclick="rejectPost(${post.id})" class="btn-danger px-3 py-1 rounded text-sm">Rechazar</button>
|
||||||
|
` : ''}
|
||||||
|
${post.status === 'scheduled' ? `
|
||||||
|
<button onclick="publishNow(${post.id})" class="btn-primary px-3 py-1 rounded text-sm">Publicar Ya</button>
|
||||||
|
<button onclick="cancelPost(${post.id})" class="btn-secondary px-3 py-1 rounded text-sm">Cancelar</button>
|
||||||
|
` : ''}
|
||||||
|
${post.status === 'draft' ? `
|
||||||
|
<button onclick="schedulePost(${post.id})" class="btn-primary px-3 py-1 rounded text-sm">Programar</button>
|
||||||
|
` : ''}
|
||||||
|
<button onclick="editPost(${post.id})" class="btn-secondary px-3 py-1 rounded text-sm">Editar</button>
|
||||||
|
<button onclick="deletePost(${post.id})" class="text-red-400 hover:text-red-300 text-sm">Eliminar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Render pagination
|
||||||
|
renderPagination(posts.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPagination(totalPosts) {
|
||||||
|
const totalPages = Math.ceil(totalPosts / postsPerPage);
|
||||||
|
const container = document.getElementById('pagination');
|
||||||
|
|
||||||
|
if (totalPages <= 1) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
|
html += `<button onclick="goToPage(${i})" class="${i === currentPage ? 'btn-primary' : 'btn-secondary'} px-3 py-1 rounded">${i}</button>`;
|
||||||
|
}
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPage(page) {
|
||||||
|
currentPage = page;
|
||||||
|
filterPosts();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(status) {
|
||||||
|
const labels = {
|
||||||
|
'published': 'Publicado',
|
||||||
|
'scheduled': 'Programado',
|
||||||
|
'pending_approval': 'Pendiente',
|
||||||
|
'draft': 'Borrador',
|
||||||
|
'failed': 'Fallido'
|
||||||
|
};
|
||||||
|
return labels[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleString('es-MX', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
async function approvePost(id) {
|
||||||
|
if (!confirm('¿Aprobar este post para publicación?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/posts/${id}/approve`, { method: 'POST' });
|
||||||
|
loadPosts();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error al aprobar: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejectPost(id) {
|
||||||
|
if (!confirm('¿Rechazar este post?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/posts/${id}/reject`, { method: 'POST' });
|
||||||
|
loadPosts();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error al rechazar: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishNow(id) {
|
||||||
|
if (!confirm('¿Publicar inmediatamente?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/calendar/posts/${id}/publish-now`, { method: 'POST' });
|
||||||
|
alert('Post enviado a publicación');
|
||||||
|
loadPosts();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelPost(id) {
|
||||||
|
if (!confirm('¿Cancelar programación?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/calendar/posts/${id}/cancel`, { method: 'POST' });
|
||||||
|
loadPosts();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function schedulePost(id) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/calendar/posts/${id}/schedule?auto=true`, { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
alert(`Programado para: ${formatDate(data.scheduled_at)}`);
|
||||||
|
loadPosts();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editPost(id) {
|
||||||
|
const post = allPosts.find(p => p.id === id);
|
||||||
|
if (!post) return;
|
||||||
|
|
||||||
|
document.getElementById('edit-post-id').value = id;
|
||||||
|
document.getElementById('edit-content').value = post.content || '';
|
||||||
|
document.getElementById('edit-char-count').textContent = (post.content || '').length;
|
||||||
|
|
||||||
|
if (post.scheduled_at) {
|
||||||
|
const date = new Date(post.scheduled_at);
|
||||||
|
document.getElementById('edit-scheduled').value = date.toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('edit-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('edit-modal').classList.add('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditModal() {
|
||||||
|
document.getElementById('edit-modal').classList.add('hidden');
|
||||||
|
document.getElementById('edit-modal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('edit-content').addEventListener('input', function() {
|
||||||
|
document.getElementById('edit-char-count').textContent = this.value.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('edit-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const id = document.getElementById('edit-post-id').value;
|
||||||
|
const content = document.getElementById('edit-content').value;
|
||||||
|
const scheduled = document.getElementById('edit-scheduled').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/posts/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
content,
|
||||||
|
scheduled_at: scheduled || null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
closeEditModal();
|
||||||
|
loadPosts();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error al guardar: ' + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function deletePost(id) {
|
||||||
|
if (!confirm('¿Eliminar este post permanentemente?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/posts/${id}`, { method: 'DELETE' });
|
||||||
|
loadPosts();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error al eliminar: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
422
dashboard/templates/settings.html
Normal file
422
dashboard/templates/settings.html
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Configuración - Social Media Automation</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { background-color: #1a1a2e; color: #eee; }
|
||||||
|
.card { background-color: #16213e; border-radius: 12px; }
|
||||||
|
.accent { color: #d4a574; }
|
||||||
|
.btn-primary { background-color: #d4a574; color: #1a1a2e; }
|
||||||
|
.btn-primary:hover { background-color: #c49564; }
|
||||||
|
.btn-secondary { background-color: #374151; }
|
||||||
|
.btn-secondary:hover { background-color: #4b5563; }
|
||||||
|
.status-ok { color: #10b981; }
|
||||||
|
.status-error { color: #ef4444; }
|
||||||
|
.status-warning { color: #f59e0b; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="bg-gray-900 border-b border-gray-800 px-6 py-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h1 class="text-2xl font-bold">
|
||||||
|
<span class="accent">Consultoría AS</span> - Social Media
|
||||||
|
</h1>
|
||||||
|
<nav class="flex gap-4">
|
||||||
|
<a href="/dashboard" class="px-4 py-2 rounded hover:bg-gray-800">Home</a>
|
||||||
|
<a href="/dashboard/compose" class="px-4 py-2 rounded hover:bg-gray-800">+ Crear</a>
|
||||||
|
<a href="/dashboard/posts" class="px-4 py-2 rounded hover:bg-gray-800">Posts</a>
|
||||||
|
<a href="/dashboard/calendar" class="px-4 py-2 rounded hover:bg-gray-800">Calendario</a>
|
||||||
|
<a href="/dashboard/interactions" class="px-4 py-2 rounded hover:bg-gray-800">Interacciones</a>
|
||||||
|
<a href="/dashboard/settings" class="px-4 py-2 rounded bg-gray-800 accent">Config</a>
|
||||||
|
<a href="/logout" class="px-4 py-2 rounded hover:bg-gray-800 text-red-400">Salir</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-6 py-8 max-w-4xl">
|
||||||
|
<h2 class="text-2xl font-bold mb-6">Configuración del Sistema</h2>
|
||||||
|
|
||||||
|
<!-- API Connections -->
|
||||||
|
<div class="card p-6 mb-6">
|
||||||
|
<h3 class="font-semibold text-lg mb-4">Conexiones de APIs</h3>
|
||||||
|
<div id="api-status" class="space-y-4">
|
||||||
|
<div class="text-gray-400">Verificando conexiones...</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="testAllConnections()" class="btn-secondary px-4 py-2 rounded mt-4">
|
||||||
|
🔄 Verificar Todas
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AI Configuration -->
|
||||||
|
<div class="card p-6 mb-6">
|
||||||
|
<h3 class="font-semibold text-lg mb-4">Configuración de IA (DeepSeek)</h3>
|
||||||
|
<div id="ai-status" class="mb-4">
|
||||||
|
<div class="text-gray-400">Verificando...</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-800 rounded-lg p-4 text-sm">
|
||||||
|
<p class="text-gray-400 mb-2">Para configurar DeepSeek API:</p>
|
||||||
|
<ol class="list-decimal list-inside space-y-1 text-gray-300">
|
||||||
|
<li>Visita <a href="https://platform.deepseek.com/" target="_blank" class="text-blue-400 hover:underline">platform.deepseek.com</a></li>
|
||||||
|
<li>Crea una cuenta y genera una API Key</li>
|
||||||
|
<li>Agrega al archivo <code class="bg-gray-700 px-1 rounded">.env</code>: <code class="bg-gray-700 px-1 rounded">DEEPSEEK_API_KEY=tu_key</code></li>
|
||||||
|
<li>Reinicia la aplicación</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Telegram Notifications -->
|
||||||
|
<div class="card p-6 mb-6">
|
||||||
|
<h3 class="font-semibold text-lg mb-4">Notificaciones Telegram</h3>
|
||||||
|
<div id="telegram-status" class="mb-4">
|
||||||
|
<div class="text-gray-400">Verificando...</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="testTelegram()" class="btn-secondary px-4 py-2 rounded">
|
||||||
|
📱 Enviar Prueba
|
||||||
|
</button>
|
||||||
|
<button onclick="showTelegramGuide()" class="btn-secondary px-4 py-2 rounded">
|
||||||
|
📖 Ver Guía
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="telegram-guide" class="hidden mt-4 bg-gray-800 rounded-lg p-4 text-sm">
|
||||||
|
<!-- Guide loaded dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System Info -->
|
||||||
|
<div class="card p-6 mb-6">
|
||||||
|
<h3 class="font-semibold text-lg mb-4">Información del Sistema</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-400">Versión:</span>
|
||||||
|
<span class="ml-2">1.0.0</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-400">Entorno:</span>
|
||||||
|
<span class="ml-2" id="app-env">-</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-400">Base de datos:</span>
|
||||||
|
<span class="ml-2" id="db-status">-</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-400">Redis:</span>
|
||||||
|
<span class="ml-2" id="redis-status">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="card p-6 mb-6">
|
||||||
|
<h3 class="font-semibold text-lg mb-4">Acciones Rápidas</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<button onclick="generateWeekContent()" class="btn-secondary p-4 rounded text-left">
|
||||||
|
<div class="font-semibold">📅 Generar Contenido Semanal</div>
|
||||||
|
<div class="text-sm text-gray-400">Crear posts para los próximos 7 días</div>
|
||||||
|
</button>
|
||||||
|
<button onclick="syncAllInteractions()" class="btn-secondary p-4 rounded text-left">
|
||||||
|
<div class="font-semibold">🔄 Sincronizar Interacciones</div>
|
||||||
|
<div class="text-sm text-gray-400">Obtener comentarios y menciones</div>
|
||||||
|
</button>
|
||||||
|
<button onclick="viewApiDocs()" class="btn-secondary p-4 rounded text-left">
|
||||||
|
<div class="font-semibold">📚 Documentación API</div>
|
||||||
|
<div class="text-sm text-gray-400">Ver endpoints disponibles</div>
|
||||||
|
</button>
|
||||||
|
<button onclick="downloadLogs()" class="btn-secondary p-4 rounded text-left">
|
||||||
|
<div class="font-semibold">📋 Ver Logs</div>
|
||||||
|
<div class="text-sm text-gray-400">Registro de actividad</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Usage Stats -->
|
||||||
|
<div class="card p-6">
|
||||||
|
<h3 class="font-semibold text-lg mb-4">Estadísticas de Uso</h3>
|
||||||
|
<div id="usage-stats" class="grid grid-cols-4 gap-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold accent" id="stat-posts-total">-</div>
|
||||||
|
<div class="text-sm text-gray-400">Posts Totales</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-green-400" id="stat-posts-month">-</div>
|
||||||
|
<div class="text-sm text-gray-400">Este Mes</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-blue-400" id="stat-interactions">-</div>
|
||||||
|
<div class="text-sm text-gray-400">Interacciones</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-purple-400" id="stat-leads">-</div>
|
||||||
|
<div class="text-sm text-gray-400">Leads</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Result Modal -->
|
||||||
|
<div id="result-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
|
<div class="card p-6 max-w-md w-full mx-4">
|
||||||
|
<div id="result-content"></div>
|
||||||
|
<button onclick="closeModal()" class="btn-primary w-full mt-4 py-2 rounded">Cerrar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
loadApiStatus();
|
||||||
|
loadAiStatus();
|
||||||
|
loadTelegramStatus();
|
||||||
|
loadSystemInfo();
|
||||||
|
loadUsageStats();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadApiStatus() {
|
||||||
|
const container = document.getElementById('api-status');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/publish/test');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const platforms = ['x', 'threads', 'facebook', 'instagram'];
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
for (const platform of platforms) {
|
||||||
|
const status = data.platforms[platform];
|
||||||
|
const icon = status.connected ? '✅' : (status.configured ? '⚠️' : '❌');
|
||||||
|
const statusClass = status.connected ? 'status-ok' : (status.configured ? 'status-warning' : 'status-error');
|
||||||
|
const statusText = status.connected ? 'Conectado' : (status.configured ? 'Configurado (error de conexión)' : 'No configurado');
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-xl">${icon}</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">${platform.charAt(0).toUpperCase() + platform.slice(1)}</div>
|
||||||
|
<div class="text-sm ${statusClass}">${statusText}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${status.details ? `
|
||||||
|
<div class="text-sm text-gray-400">
|
||||||
|
${status.details.username ? '@' + status.details.username : ''}
|
||||||
|
${status.details.name ? status.details.name : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
container.innerHTML = '<div class="text-red-400">Error al verificar APIs</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAiStatus() {
|
||||||
|
const container = document.getElementById('ai-status');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/generate/status');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const icon = data.status === 'connected' ? '✅' : (data.configured ? '⚠️' : '❌');
|
||||||
|
const statusClass = data.status === 'connected' ? 'status-ok' : (data.configured ? 'status-warning' : 'status-error');
|
||||||
|
const statusText = data.status === 'connected' ? 'Conectado y funcionando' : (data.configured ? 'Error: ' + (data.error || 'desconocido') : 'No configurado');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="flex items-center gap-3 p-3 bg-gray-800 rounded-lg">
|
||||||
|
<span class="text-xl">${icon}</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">DeepSeek API</div>
|
||||||
|
<div class="text-sm ${statusClass}">${statusText}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
container.innerHTML = '<div class="text-red-400">Error al verificar IA</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTelegramStatus() {
|
||||||
|
const container = document.getElementById('telegram-status');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/notifications/status');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const icon = data.telegram_configured ? '✅' : '❌';
|
||||||
|
const statusClass = data.telegram_configured ? 'status-ok' : 'status-error';
|
||||||
|
const statusText = data.telegram_configured ? 'Configurado' : 'No configurado';
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="flex items-center gap-3 p-3 bg-gray-800 rounded-lg">
|
||||||
|
<span class="text-xl">${icon}</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">Telegram Bot</div>
|
||||||
|
<div class="text-sm ${statusClass}">${statusText}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
container.innerHTML = '<div class="text-red-400">Error al verificar Telegram</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSystemInfo() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/health');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
document.getElementById('app-env').textContent = 'Desarrollo';
|
||||||
|
document.getElementById('db-status').innerHTML = '<span class="status-ok">Conectada</span>';
|
||||||
|
document.getElementById('redis-status').innerHTML = '<span class="status-warning">No verificado</span>';
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
document.getElementById('db-status').innerHTML = '<span class="status-error">Error</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsageStats() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/stats');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
document.getElementById('stat-posts-total').textContent = data.posts_month || 0;
|
||||||
|
document.getElementById('stat-posts-month').textContent = data.posts_week || 0;
|
||||||
|
document.getElementById('stat-interactions').textContent = data.interactions_pending || 0;
|
||||||
|
document.getElementById('stat-leads').textContent = 0;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading stats:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testAllConnections() {
|
||||||
|
showModal('Verificando conexiones...', true);
|
||||||
|
await loadApiStatus();
|
||||||
|
await loadAiStatus();
|
||||||
|
await loadTelegramStatus();
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testTelegram() {
|
||||||
|
showModal('Enviando mensaje de prueba...', true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/notifications/test', { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showModal('✅ Mensaje enviado. Revisa tu Telegram.');
|
||||||
|
} else {
|
||||||
|
showModal('❌ Error: ' + (data.detail || 'No se pudo enviar'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showModal('❌ Error de conexión');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showTelegramGuide() {
|
||||||
|
const container = document.getElementById('telegram-guide');
|
||||||
|
|
||||||
|
if (!container.classList.contains('hidden')) {
|
||||||
|
container.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/notifications/setup-guide');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
let html = `<h4 class="font-semibold mb-3">${data.title}</h4>`;
|
||||||
|
|
||||||
|
data.steps.forEach(step => {
|
||||||
|
html += `
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="font-medium text-amber-400">Paso ${step.step}: ${step.title}</div>
|
||||||
|
<ul class="list-disc list-inside text-gray-300 ml-2">
|
||||||
|
${step.instructions.map(i => `<li>${i}</li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
container.classList.remove('hidden');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
container.innerHTML = '<div class="text-red-400">Error al cargar guía</div>';
|
||||||
|
container.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateWeekContent() {
|
||||||
|
showModal('Generando contenido para la semana...', true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/generate/batch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
platforms: ['x', 'threads'],
|
||||||
|
days: 7
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showModal(`✅ Generados ${data.total_generated} posts para la semana`);
|
||||||
|
} else {
|
||||||
|
showModal('❌ Error: ' + (data.error || 'No se pudo generar'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showModal('❌ Error: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncAllInteractions() {
|
||||||
|
showModal('Sincronizando interacciones...', true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/interactions/sync', { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
showModal(`✅ Sincronización completada`);
|
||||||
|
} catch (error) {
|
||||||
|
showModal('❌ Error: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewApiDocs() {
|
||||||
|
window.open('/api/docs', '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadLogs() {
|
||||||
|
showModal('Función de logs próximamente disponible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showModal(content, loading = false) {
|
||||||
|
const modal = document.getElementById('result-modal');
|
||||||
|
const contentDiv = document.getElementById('result-content');
|
||||||
|
|
||||||
|
contentDiv.innerHTML = loading
|
||||||
|
? `<div class="text-center py-4"><div class="text-2xl mb-2">⏳</div>${content}</div>`
|
||||||
|
: `<div class="text-center py-4">${content}</div>`;
|
||||||
|
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.classList.add('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('result-modal').classList.add('hidden');
|
||||||
|
document.getElementById('result-modal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user